@forgeax/engine-render 0.1.21 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (584) hide show
  1. package/README.md +281 -38
  2. package/dist/assembly/backend-contract.d.ts +13 -0
  3. package/dist/assembly/backend-contract.d.ts.map +1 -1
  4. package/dist/assembly/bundler-contract.d.ts +9 -0
  5. package/dist/assembly/bundler-contract.d.ts.map +1 -0
  6. package/dist/assembly/device-feature-admission.d.ts +8 -0
  7. package/dist/assembly/device-feature-admission.d.ts.map +1 -1
  8. package/dist/assembly/extended-lighting-fallback.d.ts +13 -0
  9. package/dist/assembly/extended-lighting-fallback.d.ts.map +1 -0
  10. package/dist/assembly/factory.d.ts +7 -235
  11. package/dist/assembly/factory.d.ts.map +1 -1
  12. package/dist/assembly/host-contract.d.ts +10 -0
  13. package/dist/assembly/host-contract.d.ts.map +1 -1
  14. package/dist/assembly/material/assembly.d.ts +3 -2
  15. package/dist/assembly/material/assembly.d.ts.map +1 -1
  16. package/dist/assembly/material/standard-layer-projection.d.ts +10 -0
  17. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -0
  18. package/dist/assembly/material-shader-policy.d.ts +168 -0
  19. package/dist/assembly/material-shader-policy.d.ts.map +1 -0
  20. package/dist/assembly/material-shader-prewarm.d.ts +21 -0
  21. package/dist/assembly/material-shader-prewarm.d.ts.map +1 -0
  22. package/dist/assembly/mesh-ssbo-grow.d.ts +88 -0
  23. package/dist/assembly/mesh-ssbo-grow.d.ts.map +1 -0
  24. package/dist/assembly/recovery/device-loss-fanout.d.ts +14 -0
  25. package/dist/assembly/recovery/device-loss-fanout.d.ts.map +1 -0
  26. package/dist/assembly/recovery/generation.d.ts +46 -0
  27. package/dist/assembly/recovery/generation.d.ts.map +1 -0
  28. package/dist/assembly/recovery/recovery-attempt.d.ts +63 -0
  29. package/dist/assembly/recovery/recovery-attempt.d.ts.map +1 -0
  30. package/dist/assembly/recovery/renderer-recover.d.ts +115 -0
  31. package/dist/assembly/recovery/renderer-recover.d.ts.map +1 -0
  32. package/dist/assembly/renderer-facade.d.ts +10 -0
  33. package/dist/assembly/renderer-facade.d.ts.map +1 -0
  34. package/dist/assembly/renderer-frame-transaction.d.ts +17 -0
  35. package/dist/assembly/renderer-frame-transaction.d.ts.map +1 -1
  36. package/dist/assembly/renderer-helpers.d.ts +19 -0
  37. package/dist/assembly/renderer-helpers.d.ts.map +1 -0
  38. package/dist/assembly/renderer-lifecycle.d.ts +34 -0
  39. package/dist/assembly/renderer-lifecycle.d.ts.map +1 -1
  40. package/dist/assembly/shader-prewarm-policy.d.ts +15 -0
  41. package/dist/assembly/shader-prewarm-policy.d.ts.map +1 -0
  42. package/dist/assembly/webgpu-ready-contract.d.ts +9 -0
  43. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -0
  44. package/dist/assembly/webgpu-ready.d.ts +55 -0
  45. package/dist/assembly/webgpu-ready.d.ts.map +1 -0
  46. package/dist/assembly/webgpu-renderer.d.ts +152 -0
  47. package/dist/assembly/webgpu-renderer.d.ts.map +1 -0
  48. package/dist/authoring.mjs +1 -1
  49. package/dist/{chunk-PCHBPYML.mjs → chunk-GE3SDD72.mjs} +37 -5
  50. package/dist/chunk-GE3SDD72.mjs.map +1 -0
  51. package/dist/chunk-KZFTK6DO.mjs +1191 -0
  52. package/dist/chunk-KZFTK6DO.mjs.map +1 -0
  53. package/dist/{chunk-FS2KNUQH.mjs → chunk-Q752SHI5.mjs} +332 -568
  54. package/dist/chunk-Q752SHI5.mjs.map +1 -0
  55. package/dist/{chunk-6OPXDQ2E.mjs → chunk-RR5L4SRS.mjs} +33 -294
  56. package/dist/chunk-RR5L4SRS.mjs.map +1 -0
  57. package/dist/chunk-TFH5I7JJ.mjs +296 -0
  58. package/dist/chunk-TFH5I7JJ.mjs.map +1 -0
  59. package/dist/chunk-UQYS7FRM.mjs +1003 -0
  60. package/dist/chunk-UQYS7FRM.mjs.map +1 -0
  61. package/dist/chunk-YKB5DQV7.mjs +345 -0
  62. package/dist/chunk-YKB5DQV7.mjs.map +1 -0
  63. package/dist/{chunk-5YADUCA7.mjs → chunk-YRUYGA4A.mjs} +1189 -1217
  64. package/dist/chunk-YRUYGA4A.mjs.map +1 -0
  65. package/dist/chunk-Z3YYH2MB.mjs +5082 -0
  66. package/dist/chunk-Z3YYH2MB.mjs.map +1 -0
  67. package/dist/cluster-binner.d.ts +5 -0
  68. package/dist/cluster-binner.d.ts.map +1 -1
  69. package/dist/components/index.d.ts +3 -0
  70. package/dist/components/index.d.ts.map +1 -1
  71. package/dist/components/light-helpers.d.ts +4 -0
  72. package/dist/components/light-helpers.d.ts.map +1 -1
  73. package/dist/components/light-probe.d.ts +9 -0
  74. package/dist/components/light-probe.d.ts.map +1 -0
  75. package/dist/components/rect-area-light.d.ts +12 -0
  76. package/dist/components/rect-area-light.d.ts.map +1 -0
  77. package/dist/components/scene-instance.d.ts +4 -0
  78. package/dist/components/scene-instance.d.ts.map +1 -1
  79. package/dist/components/spot-light.d.ts +3 -0
  80. package/dist/components/spot-light.d.ts.map +1 -1
  81. package/dist/construct-renderer.mjs +27276 -23232
  82. package/dist/construct-renderer.mjs.map +1 -1
  83. package/dist/device/device-scope.d.ts +2 -0
  84. package/dist/device/device-scope.d.ts.map +1 -1
  85. package/dist/device/gpu-residency.d.ts +32 -5
  86. package/dist/device/gpu-residency.d.ts.map +1 -1
  87. package/dist/device/lifecycle-transaction.d.ts +2 -0
  88. package/dist/device/lifecycle-transaction.d.ts.map +1 -0
  89. package/dist/environment/frame.d.ts +5 -0
  90. package/dist/environment/frame.d.ts.map +1 -1
  91. package/dist/environment/inspection.d.ts +7 -0
  92. package/dist/environment/inspection.d.ts.map +1 -1
  93. package/dist/environment/lifecycle.d.ts +20 -2
  94. package/dist/environment/lifecycle.d.ts.map +1 -1
  95. package/dist/errors/gpu-driven.d.ts +26 -0
  96. package/dist/errors/gpu-driven.d.ts.map +1 -0
  97. package/dist/errors/index.d.ts +1 -0
  98. package/dist/errors/index.d.ts.map +1 -1
  99. package/dist/errors/recover.d.ts +67 -3
  100. package/dist/errors/recover.d.ts.map +1 -1
  101. package/dist/errors/render.d.ts +39 -1
  102. package/dist/errors/render.d.ts.map +1 -1
  103. package/dist/extract/contracts.d.ts +899 -0
  104. package/dist/extract/contracts.d.ts.map +1 -0
  105. package/dist/extract/gpu-driven.d.ts +50 -0
  106. package/dist/extract/gpu-driven.d.ts.map +1 -0
  107. package/dist/extract/visibility.d.ts.map +1 -1
  108. package/dist/features/host.d.ts +2 -0
  109. package/dist/features/host.d.ts.map +1 -1
  110. package/dist/features/prepared-gpu-work.d.ts +3 -0
  111. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  112. package/dist/features/prepared-graphics-store.d.ts +2 -0
  113. package/dist/features/prepared-graphics-store.d.ts.map +1 -1
  114. package/dist/gpu-driven/batch-topology.d.ts +34 -0
  115. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  116. package/dist/gpu-driven/prepared-draw.d.ts +63 -0
  117. package/dist/gpu-driven/prepared-draw.d.ts.map +1 -0
  118. package/dist/gpu-driven/production-raster.d.ts +126 -2
  119. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  120. package/dist/gpu-driven/view-gpu.d.ts +64 -1
  121. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  122. package/dist/gpu-scene-schema.d.ts +14 -0
  123. package/dist/gpu-scene-schema.d.ts.map +1 -1
  124. package/dist/gpu-scene.d.ts +9 -1
  125. package/dist/gpu-scene.d.ts.map +1 -1
  126. package/dist/gpu-usage.d.ts +0 -1
  127. package/dist/gpu-usage.d.ts.map +1 -1
  128. package/dist/hdrp-buffers.d.ts +3 -3
  129. package/dist/hdrp-buffers.d.ts.map +1 -1
  130. package/dist/ibl/IblPipelineCache.d.ts +7 -6
  131. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  132. package/dist/ibl/skylight-bind-group.d.ts +7 -1
  133. package/dist/ibl/skylight-bind-group.d.ts.map +1 -1
  134. package/dist/index.d.ts +13 -2
  135. package/dist/index.d.ts.map +1 -1
  136. package/dist/index.mjs +90 -5
  137. package/dist/index.mjs.map +1 -1
  138. package/dist/inspection-types.d.ts +163 -1
  139. package/dist/inspection-types.d.ts.map +1 -1
  140. package/dist/internal.d.ts +9 -0
  141. package/dist/internal.d.ts.map +1 -1
  142. package/dist/internal.mjs +23 -2
  143. package/dist/internal.mjs.map +1 -1
  144. package/dist/light-buffer-layout.d.ts +60 -148
  145. package/dist/light-buffer-layout.d.ts.map +1 -1
  146. package/dist/materials.d.ts +34 -10
  147. package/dist/materials.d.ts.map +1 -1
  148. package/dist/mesh-material-bindings.d.ts +56 -0
  149. package/dist/mesh-material-bindings.d.ts.map +1 -1
  150. package/dist/occlusion-proxy.d.ts +4 -0
  151. package/dist/occlusion-proxy.d.ts.map +1 -0
  152. package/dist/pbr-pipeline.d.ts +58 -21
  153. package/dist/pbr-pipeline.d.ts.map +1 -1
  154. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  155. package/dist/pipeline/standard-lighting/graph.d.ts.map +1 -1
  156. package/dist/pipeline/standard-lighting/light-frame.d.ts +3 -3
  157. package/dist/pipeline/standard-lighting/light-frame.d.ts.map +1 -1
  158. package/dist/pipeline/standard-lighting/topology.d.ts +1 -1
  159. package/dist/pipeline/standard-lighting/topology.d.ts.map +1 -1
  160. package/dist/pipeline/standard-lighting/transport.d.ts.map +1 -1
  161. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  162. package/dist/pipeline-builder.d.ts +2 -0
  163. package/dist/pipeline-builder.d.ts.map +1 -1
  164. package/dist/pipeline-spec.d.ts +1 -1
  165. package/dist/pipeline-spec.d.ts.map +1 -1
  166. package/dist/plugin.d.ts.map +1 -1
  167. package/dist/points-lines/prepare.d.ts +3 -0
  168. package/dist/points-lines/prepare.d.ts.map +1 -1
  169. package/dist/points-lines/standard-owner.d.ts +33 -0
  170. package/dist/points-lines/standard-owner.d.ts.map +1 -0
  171. package/dist/prepare/extended-lighting/ltc-resources.d.ts +31 -0
  172. package/dist/prepare/extended-lighting/ltc-resources.d.ts.map +1 -0
  173. package/dist/prepare/extended-lighting/resources.d.ts +72 -0
  174. package/dist/prepare/extended-lighting/resources.d.ts.map +1 -0
  175. package/dist/prepare/extended-lighting/spot-modifiers.d.ts +42 -0
  176. package/dist/prepare/extended-lighting/spot-modifiers.d.ts.map +1 -0
  177. package/dist/prepare/extended-lighting/state.d.ts +24 -0
  178. package/dist/prepare/extended-lighting/state.d.ts.map +1 -0
  179. package/dist/record/frame-lighting.d.ts +8 -6
  180. package/dist/record/frame-lighting.d.ts.map +1 -1
  181. package/dist/record/frame-snapshot.d.ts +55 -10
  182. package/dist/record/frame-snapshot.d.ts.map +1 -1
  183. package/dist/record/frame-targets.d.ts.map +1 -1
  184. package/dist/record/frame.d.ts +21 -5
  185. package/dist/record/frame.d.ts.map +1 -1
  186. package/dist/record/gpu-timing.d.ts +19 -7
  187. package/dist/record/gpu-timing.d.ts.map +1 -1
  188. package/dist/record/helpers.d.ts +0 -20
  189. package/dist/record/helpers.d.ts.map +1 -1
  190. package/dist/record/main-pass-geometry.d.ts +20 -2
  191. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  192. package/dist/record/main-pass-material.d.ts +10 -18
  193. package/dist/record/main-pass-material.d.ts.map +1 -1
  194. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  195. package/dist/record/main-pass.d.ts.map +1 -1
  196. package/dist/record/mesh-ssbo.d.ts +11 -3
  197. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  198. package/dist/record/recovery-pipeline.d.ts +30 -0
  199. package/dist/record/recovery-pipeline.d.ts.map +1 -0
  200. package/dist/record/render-context.d.ts +82 -9
  201. package/dist/record/render-context.d.ts.map +1 -1
  202. package/dist/record/shadow-pass.d.ts.map +1 -1
  203. package/dist/record/typed-frame-graph.d.ts +49 -2
  204. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  205. package/dist/record/view-ubo.d.ts +1 -2
  206. package/dist/record/view-ubo.d.ts.map +1 -1
  207. package/dist/recovery/render-system-candidate.d.ts +65 -0
  208. package/dist/recovery/render-system-candidate.d.ts.map +1 -0
  209. package/dist/reflection/inspection.d.ts +10 -0
  210. package/dist/reflection/inspection.d.ts.map +1 -0
  211. package/dist/reflection/projection.d.ts +49 -0
  212. package/dist/reflection/projection.d.ts.map +1 -1
  213. package/dist/reflection/record-owner.d.ts +60 -0
  214. package/dist/reflection/record-owner.d.ts.map +1 -0
  215. package/dist/render-contract.d.ts +78 -15
  216. package/dist/render-contract.d.ts.map +1 -1
  217. package/dist/render-pipeline.d.ts +7 -0
  218. package/dist/render-pipeline.d.ts.map +1 -1
  219. package/dist/render-system-extract-tail.d.ts +12 -0
  220. package/dist/render-system-extract-tail.d.ts.map +1 -0
  221. package/dist/render-system-extract.d.ts +252 -30
  222. package/dist/render-system-extract.d.ts.map +1 -1
  223. package/dist/render-system.d.ts +140 -5
  224. package/dist/render-system.d.ts.map +1 -1
  225. package/dist/scene/probe-blend-record.d.ts +22 -0
  226. package/dist/scene/probe-blend-record.d.ts.map +1 -0
  227. package/dist/scene/probe-blend.d.ts +142 -0
  228. package/dist/scene/probe-blend.d.ts.map +1 -0
  229. package/dist/scene/render-scene-types.d.ts +0 -6
  230. package/dist/scene/render-scene-types.d.ts.map +1 -1
  231. package/dist/scene/render-scene.d.ts +122 -9
  232. package/dist/scene/render-scene.d.ts.map +1 -1
  233. package/dist/scene/visibility/bounds.d.ts +20 -0
  234. package/dist/scene/visibility/bounds.d.ts.map +1 -0
  235. package/dist/scene/visibility/budget.d.ts +16 -0
  236. package/dist/scene/visibility/budget.d.ts.map +1 -0
  237. package/dist/scene/visibility/facet.d.ts +73 -0
  238. package/dist/scene/visibility/facet.d.ts.map +1 -0
  239. package/dist/scene/visibility/gpu-frame-timing.d.ts +41 -0
  240. package/dist/scene/visibility/gpu-frame-timing.d.ts.map +1 -0
  241. package/dist/scene/visibility/gpu-lod.d.ts +46 -0
  242. package/dist/scene/visibility/gpu-lod.d.ts.map +1 -0
  243. package/dist/scene/visibility/inspection.d.ts +132 -0
  244. package/dist/scene/visibility/inspection.d.ts.map +1 -0
  245. package/dist/scene/visibility/lod-selector.d.ts +25 -0
  246. package/dist/scene/visibility/lod-selector.d.ts.map +1 -0
  247. package/dist/scene/visibility/occlusion-confidence.d.ts +81 -0
  248. package/dist/scene/visibility/occlusion-confidence.d.ts.map +1 -0
  249. package/dist/scene/visibility/occlusion-pass.d.ts +57 -0
  250. package/dist/scene/visibility/occlusion-pass.d.ts.map +1 -0
  251. package/dist/scene/visibility/occlusion-query-pool.d.ts +57 -0
  252. package/dist/scene/visibility/occlusion-query-pool.d.ts.map +1 -0
  253. package/dist/scene/visibility/occlusion-runtime.d.ts +96 -0
  254. package/dist/scene/visibility/occlusion-runtime.d.ts.map +1 -0
  255. package/dist/scene/visibility/types.d.ts +34 -0
  256. package/dist/scene/visibility/types.d.ts.map +1 -0
  257. package/dist/ssr/admission.d.ts +122 -0
  258. package/dist/ssr/admission.d.ts.map +1 -0
  259. package/dist/ssr/composition.d.ts +32 -0
  260. package/dist/ssr/composition.d.ts.map +1 -0
  261. package/dist/ssr/errors.d.ts +39 -0
  262. package/dist/ssr/errors.d.ts.map +1 -0
  263. package/dist/ssr/identity.d.ts +13 -0
  264. package/dist/ssr/identity.d.ts.map +1 -0
  265. package/dist/systems/skin-palette-allocator.d.ts +1 -1
  266. package/dist/temporal/frame-coordinator.d.ts +13 -0
  267. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  268. package/dist/temporal/frame.d.ts +2 -0
  269. package/dist/temporal/frame.d.ts.map +1 -1
  270. package/dist/temporal/index.mjs +6 -5
  271. package/dist/temporal/taa-history-store.d.ts +15 -1
  272. package/dist/temporal/taa-history-store.d.ts.map +1 -1
  273. package/dist/temporal/transaction.d.ts +5 -0
  274. package/dist/temporal/transaction.d.ts.map +1 -1
  275. package/dist/temporal/view.d.ts +7 -0
  276. package/dist/temporal/view.d.ts.map +1 -1
  277. package/dist/tilemap-chunk-extract-system.d.ts +1 -1
  278. package/dist/typed-render-graph-primitives.d.ts +4 -0
  279. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  280. package/dist/volume/passes.d.ts +8 -4
  281. package/dist/volume/passes.d.ts.map +1 -1
  282. package/package.json +20 -20
  283. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +5 -0
  284. package/src/__tests__/detached-inspection.integration.test.ts +6 -0
  285. package/src/__tests__/device-feature-admission.unit.test.ts +19 -1
  286. package/src/__tests__/device-loss-fanout.unit.test.ts +107 -0
  287. package/src/__tests__/device-scope-lifecycle.unit.test.ts +21 -0
  288. package/src/__tests__/direct-light-snapshot.unit.test.ts +59 -3
  289. package/src/__tests__/environment-generation.integration.test.ts +39 -0
  290. package/src/__tests__/evidence-identity.ts +13 -0
  291. package/src/__tests__/evidence-identity.unit.test.ts +24 -0
  292. package/src/__tests__/expose-renderer-errors.unit.test.ts +34 -0
  293. package/src/__tests__/extended-lighting-recovery.browser.test.ts +14 -0
  294. package/src/__tests__/extended-lighting-recovery.dawn.test.ts +14 -0
  295. package/src/__tests__/extended-lighting-recovery.integration.test.ts +81 -0
  296. package/src/__tests__/extended-lighting-resources.unit.test.ts +51 -0
  297. package/src/__tests__/extended-lighting-zero-feature.integration.test.ts +52 -0
  298. package/src/__tests__/factory-contract.integration.test.ts +154 -6
  299. package/src/__tests__/fallback-row-stride-surface.unit.test.ts +4 -1
  300. package/src/__tests__/frame-observation.contract.test.ts +15 -0
  301. package/src/__tests__/gpu-driven-batch-topology-pbr.unit.test.ts +146 -0
  302. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +1 -0
  303. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +1 -0
  304. package/src/__tests__/gpu-driven-production.integration.test.ts +572 -9
  305. package/src/__tests__/gpu-driven-scaling.unit.test.ts +66 -2
  306. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +26 -1
  307. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +89 -2
  308. package/src/__tests__/gpu-driven-view.browser.test.ts +3 -3
  309. package/src/__tests__/gpu-driven-view.dawn.test.ts +3 -3
  310. package/src/__tests__/gpu-driven-view.unit.test.ts +50 -0
  311. package/src/__tests__/gpu-lod-evidence.ts +188 -0
  312. package/src/__tests__/gpu-lod-fallback.integration.test.ts +10 -0
  313. package/src/__tests__/gpu-lod-indirect.integration.test.ts +40 -0
  314. package/src/__tests__/gpu-lod-parity.dawn.test.ts +29 -0
  315. package/src/__tests__/gpu-lod-readiness.integration.test.ts +52 -0
  316. package/src/__tests__/gpu-lod-schema.unit.test.ts +66 -0
  317. package/src/__tests__/gpu-lod-selector.unit.test.ts +28 -0
  318. package/src/__tests__/gpu-lod-silhouette.browser.test.ts +13 -0
  319. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +66 -1
  320. package/src/__tests__/gpu-scene.dawn.test.ts +8 -1
  321. package/src/__tests__/gpu-scene.unit.test.ts +49 -3
  322. package/src/__tests__/ibl/face-uniforms.unit.test.ts +2 -0
  323. package/src/__tests__/ibl-residency-publication.unit.test.ts +113 -0
  324. package/src/__tests__/light-buffer-layout.unit.test.ts +113 -3
  325. package/src/__tests__/light-inspection.unit.test.ts +55 -0
  326. package/src/__tests__/light-resource-error.unit.test.ts +43 -0
  327. package/src/__tests__/lighting-scene-roundtrip.integration.test.ts +109 -0
  328. package/src/__tests__/lod-bounds.unit.test.ts +42 -0
  329. package/src/__tests__/lod-cpu-lane.integration.test.ts +55 -0
  330. package/src/__tests__/lod-cpu-no-change.perf.test.ts +19 -0
  331. package/src/__tests__/lod-gpu-frame-timing.unit.test.ts +49 -0
  332. package/src/__tests__/lod-multi-view.integration.test.ts +63 -0
  333. package/src/__tests__/lod-occlusion-device-loss.integration.test.ts +27 -0
  334. package/src/__tests__/lod-occlusion-final-decision.integration.test.ts +47 -0
  335. package/src/__tests__/lod-occlusion-inspection.integration.test.ts +217 -0
  336. package/src/__tests__/lod-occlusion-inspection.schema.test-d.ts +14 -0
  337. package/src/__tests__/lod-occlusion-inspection.unit.test.ts +72 -0
  338. package/src/__tests__/lod-occlusion-visual-evidence.ts +91 -0
  339. package/src/__tests__/lod-occlusion.browser.test.ts +15 -0
  340. package/src/__tests__/lod-occlusion.dawn.test.ts +20 -0
  341. package/src/__tests__/lod-selector.profile.test.ts +35 -0
  342. package/src/__tests__/lod-selector.unit.test.ts +68 -0
  343. package/src/__tests__/lod-visibility-identity.unit.test.ts +198 -0
  344. package/src/__tests__/lod-visibility-lifecycle.integration.test.ts +132 -0
  345. package/src/__tests__/ltc-resources.unit.test.ts +32 -0
  346. package/src/__tests__/material-contract-inventory.unit.test.ts +41 -9
  347. package/src/__tests__/material-cooked-projection.unit.test.ts +19 -1
  348. package/src/__tests__/material-static-dynamic-route.unit.test.ts +29 -0
  349. package/src/__tests__/material-stride-surface.unit.test.ts +7 -3
  350. package/src/__tests__/materials-standard-contract.unit.test.ts +104 -0
  351. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  352. package/src/__tests__/occlusion-confidence.unit.test.ts +168 -0
  353. package/src/__tests__/occlusion-invalidation.integration.test.ts +37 -0
  354. package/src/__tests__/occlusion-pass-routing.unit.test.ts +16 -0
  355. package/src/__tests__/occlusion-pass.dawn.test.ts +20 -0
  356. package/src/__tests__/occlusion-pass.integration.test.ts +43 -0
  357. package/src/__tests__/occlusion-pass.rhi-null.test.ts +22 -0
  358. package/src/__tests__/occlusion-query-pool.rhi-null.test.ts +21 -0
  359. package/src/__tests__/occlusion-query-pool.unit.test.ts +95 -0
  360. package/src/__tests__/occlusion-query-ticket.integration.test.ts +47 -0
  361. package/src/__tests__/occlusion-query.dawn.test.ts +16 -0
  362. package/src/__tests__/occlusion-query.evidence.ts +178 -0
  363. package/src/__tests__/occlusion-query.rhi-null.test.ts +31 -0
  364. package/src/__tests__/occlusion-runtime.unit.test.ts +120 -0
  365. package/src/__tests__/physical-clearcoat.integration.test.ts +89 -0
  366. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +196 -0
  367. package/src/__tests__/prepared-graphics-pipeline-warmup.unit.test.ts +1 -1
  368. package/src/__tests__/probe-bindings.unit.test.ts +200 -0
  369. package/src/__tests__/probe-blend-numeric.unit.test.ts +67 -0
  370. package/src/__tests__/probe-blend-oracle.unit.test.ts +136 -0
  371. package/src/__tests__/probe-capacity.integration.test.ts +97 -0
  372. package/src/__tests__/probe-recovery.integration.test.ts +104 -0
  373. package/src/__tests__/probe-scene-dirty.integration.test.ts +211 -0
  374. package/src/__tests__/profiler-phase-catalog.test.ts +4 -1
  375. package/src/__tests__/profiler-phase-token.test.ts +1 -0
  376. package/src/__tests__/recover-error-policy-owner.unit.test.ts +6 -3
  377. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +140 -0
  378. package/src/__tests__/rect-area-boundary.unit.test.ts +24 -0
  379. package/src/__tests__/reflection-fallback-graph.unit.test.ts +67 -0
  380. package/src/__tests__/reflection-fallback-inspection.unit.test.ts +77 -0
  381. package/src/__tests__/reflection-fallback-lifecycle.integration.test.ts +21 -0
  382. package/src/__tests__/reflection-fallback-projection.unit.test.ts +90 -0
  383. package/src/__tests__/reflection-fallback-source.unit.test.ts +32 -0
  384. package/src/__tests__/reflection-probe-owner-placement.unit.test.ts +39 -0
  385. package/src/__tests__/reflection-probe-record-owner.characterization.unit.test.ts +120 -0
  386. package/src/__tests__/render-error-code-owner.test-d.ts +1 -0
  387. package/src/__tests__/render-feature-docs.unit.test.ts +12 -7
  388. package/src/__tests__/render-graph-fallback.integration.test.ts +24 -0
  389. package/src/__tests__/render-perf/extended-lighting.bench.test.ts +66 -0
  390. package/src/__tests__/render-scene-projection.unit.test.ts +447 -18
  391. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +160 -17
  392. package/src/__tests__/renderer-frame-generation-fence.integration.test.ts +47 -0
  393. package/src/__tests__/renderer-generation-assembly.unit.test.ts +77 -0
  394. package/src/__tests__/renderer-generation-failure.unit.test.ts +108 -0
  395. package/src/__tests__/renderer-producer-root-matrix.integration.test.ts +33 -0
  396. package/src/__tests__/renderer-recovery-capability-matrix.unit.test.ts +59 -0
  397. package/src/__tests__/renderer-recovery-contract.unit.test.ts +73 -0
  398. package/src/__tests__/renderer-recovery-lifecycle.unit.test.ts +216 -0
  399. package/src/__tests__/renderer-target-history-recovery.integration.test.ts +80 -0
  400. package/src/__tests__/shader-manifest-fixture.ts +13 -16
  401. package/src/__tests__/shader-stage-surface.unit.test.ts +1 -1
  402. package/src/__tests__/skin-motion-regression.unit.test.ts +22 -0
  403. package/src/__tests__/skinned-shadow-caster.test.ts +2 -2
  404. package/src/__tests__/spot-modifier-projection.unit.test.ts +70 -0
  405. package/src/__tests__/ssr-admission-matrix.integration.test.ts +206 -0
  406. package/src/__tests__/ssr-composition.unit.test.ts +46 -0
  407. package/src/__tests__/ssr-fallback-zero-work.bench.test.ts +94 -0
  408. package/src/__tests__/ssr-recovery-errors.unit.test.ts +58 -0
  409. package/src/__tests__/standard-cluster-variant-resolution.unit.test.ts +67 -0
  410. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +7 -0
  411. package/src/__tests__/standard-layer-projection.unit.test.ts +23 -0
  412. package/src/__tests__/standard-lighting-inspection.integration.test.ts +1 -1
  413. package/src/__tests__/standard-lighting-write.unit.test.ts +66 -2
  414. package/src/__tests__/temporal-fallback-generation.integration.test.ts +16 -0
  415. package/src/__tests__/temporal-view.unit.test.ts +3 -3
  416. package/src/__tests__/transmission-prewarm.unit.test.ts +111 -32
  417. package/src/__tests__/visibility-budget.integration.test.ts +140 -0
  418. package/src/__tests__/visibility-budget.unit.test.ts +70 -0
  419. package/src/__tests__/visibility-extract.integration.test.ts +11 -6
  420. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +6 -3
  421. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +31 -28
  422. package/src/__tests__/volumetric-fog-topology.unit.test.ts +3 -1
  423. package/src/assembly/backend-contract.ts +13 -0
  424. package/src/assembly/bundler-contract.ts +9 -0
  425. package/src/assembly/device-feature-admission.ts +26 -9
  426. package/src/assembly/extended-lighting-fallback.ts +216 -0
  427. package/src/assembly/factory.ts +81 -9634
  428. package/src/assembly/host-contract.ts +10 -0
  429. package/src/assembly/material/assembly.ts +18 -2
  430. package/src/assembly/material/standard-layer-projection.ts +16 -0
  431. package/src/assembly/material-shader-policy.ts +854 -0
  432. package/src/assembly/material-shader-prewarm.ts +88 -0
  433. package/src/assembly/mesh-ssbo-grow.ts +270 -0
  434. package/src/assembly/recovery/device-loss-fanout.ts +89 -0
  435. package/src/assembly/recovery/generation.ts +141 -0
  436. package/src/assembly/recovery/recovery-attempt.ts +800 -0
  437. package/src/assembly/recovery/renderer-recover.ts +906 -0
  438. package/src/assembly/renderer-facade.ts +342 -0
  439. package/src/assembly/renderer-frame-transaction.ts +45 -0
  440. package/src/assembly/renderer-helpers.ts +99 -0
  441. package/src/assembly/renderer-lifecycle.ts +159 -0
  442. package/src/assembly/shader-prewarm-policy.ts +157 -0
  443. package/src/assembly/webgpu-ready-contract.ts +14 -0
  444. package/src/assembly/webgpu-ready.ts +4079 -0
  445. package/src/assembly/webgpu-renderer.ts +4095 -0
  446. package/src/cluster-binner.ts +31 -0
  447. package/src/components/__tests__/lighting-authoring.unit.test.ts +39 -0
  448. package/src/components/__tests__/rect-area-light.unit.test.ts +33 -0
  449. package/src/components/camera.ts +1 -1
  450. package/src/components/index.ts +3 -0
  451. package/src/components/light-helpers.ts +52 -1
  452. package/src/components/light-probe.ts +13 -0
  453. package/src/components/rect-area-light.ts +15 -0
  454. package/src/components/scene-instance.ts +6 -0
  455. package/src/components/spot-light.ts +3 -0
  456. package/src/debug-draw-glue.ts +1 -1
  457. package/src/device/__tests__/extended-lighting-recovery.unit.test.ts +24 -0
  458. package/src/device/device-scope.ts +35 -6
  459. package/src/device/gpu-residency.ts +334 -35
  460. package/src/device/lifecycle-transaction.ts +12 -0
  461. package/src/environment/frame.ts +6 -0
  462. package/src/environment/inspection.ts +10 -0
  463. package/src/environment/lifecycle.ts +122 -9
  464. package/src/errors/gpu-driven.ts +92 -0
  465. package/src/errors/index.ts +1 -0
  466. package/src/errors/recover.ts +108 -5
  467. package/src/errors/render.ts +72 -1
  468. package/src/extract/camera.ts +3 -3
  469. package/src/extract/contracts.ts +974 -0
  470. package/src/extract/gpu-driven.ts +108 -0
  471. package/src/extract/visibility.ts +0 -23
  472. package/src/features/host.ts +16 -0
  473. package/src/features/prepared-gpu-work.ts +20 -2
  474. package/src/features/prepared-graphics-store.ts +17 -0
  475. package/src/gpu-driven/batch-topology.ts +206 -28
  476. package/src/gpu-driven/prepared-draw.ts +208 -0
  477. package/src/gpu-driven/production-raster.ts +1046 -39
  478. package/src/gpu-driven/view-gpu.ts +411 -29
  479. package/src/gpu-scene-schema.ts +13 -0
  480. package/src/gpu-scene.ts +137 -67
  481. package/src/gpu-usage.ts +0 -1
  482. package/src/hdrp-buffers.ts +6 -6
  483. package/src/ibl/IblPipelineCache.ts +134 -50
  484. package/src/ibl/skylight-bind-group.ts +26 -0
  485. package/src/index.ts +83 -1
  486. package/src/inspection-types.ts +220 -1
  487. package/src/internal.ts +38 -0
  488. package/src/light-buffer-layout.ts +157 -324
  489. package/src/materials.ts +278 -47
  490. package/src/mesh-material-bindings.ts +59 -0
  491. package/src/occlusion-proxy.ts +64 -0
  492. package/src/pbr-pipeline.ts +290 -60
  493. package/src/pipeline/standard-forward-lane.ts +47 -0
  494. package/src/pipeline/standard-lighting/graph.ts +2 -1
  495. package/src/pipeline/standard-lighting/light-frame.ts +3 -2
  496. package/src/pipeline/standard-lighting/topology.ts +1 -1
  497. package/src/pipeline/standard-lighting/transport.ts +2 -1
  498. package/src/pipeline/standard-pipeline.ts +2 -0
  499. package/src/pipeline-builder.ts +5 -2
  500. package/src/pipeline-spec.ts +12 -1
  501. package/src/plugin.ts +4 -0
  502. package/src/points-lines/__tests__/cache-recovery.unit.test.ts +22 -0
  503. package/src/points-lines/prepare.ts +9 -0
  504. package/src/points-lines/standard-owner.ts +403 -0
  505. package/src/prepare/__tests__/extended-lighting-capability.unit.test.ts +56 -0
  506. package/src/prepare/extended-lighting/ltc-resources.ts +136 -0
  507. package/src/prepare/extended-lighting/resources.ts +389 -0
  508. package/src/prepare/extended-lighting/spot-modifiers.ts +194 -0
  509. package/src/prepare/extended-lighting/state.ts +92 -0
  510. package/src/record/__tests__/frame-targets-recovery.unit.test.ts +60 -0
  511. package/src/record/__tests__/gpu-timing.unit.test.ts +177 -0
  512. package/src/record/__tests__/main-pass-geometry.unit.test.ts +26 -1
  513. package/src/record/__tests__/mesh-ssbo.unit.test.ts +38 -0
  514. package/src/record/__tests__/recovery-pipeline.unit.test.ts +124 -0
  515. package/src/record/__tests__/sprite-material-abi.unit.test.ts +28 -13
  516. package/src/record/__tests__/standard-pbr-ubo-layout.unit.test.ts +47 -4
  517. package/src/record/__tests__/temporal-payload.unit.test.ts +29 -28
  518. package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +21 -2
  519. package/src/record/frame-lighting.ts +252 -53
  520. package/src/record/frame-snapshot.ts +72 -12
  521. package/src/record/frame-targets.ts +11 -1
  522. package/src/record/frame.ts +416 -95
  523. package/src/record/gpu-timing.ts +108 -44
  524. package/src/record/helpers.ts +1 -59
  525. package/src/record/main-pass-geometry.ts +434 -44
  526. package/src/record/main-pass-material.ts +324 -210
  527. package/src/record/main-pass-sprite-draws.ts +58 -92
  528. package/src/record/main-pass.ts +94 -6
  529. package/src/record/mesh-ssbo.ts +79 -46
  530. package/src/record/recovery-pipeline.ts +695 -0
  531. package/src/record/render-context.ts +99 -7
  532. package/src/record/shadow-pass.ts +66 -40
  533. package/src/record/typed-frame-graph.ts +270 -84
  534. package/src/record/view-ubo.ts +17 -27
  535. package/src/recovery/render-system-candidate.ts +839 -0
  536. package/src/reflection/inspection.ts +67 -0
  537. package/src/reflection/projection.ts +196 -1
  538. package/src/reflection/record-owner.ts +1283 -0
  539. package/src/render-contract.ts +82 -12
  540. package/src/render-pipeline.ts +5 -0
  541. package/src/render-system-extract-tail.ts +2983 -0
  542. package/src/render-system-extract.ts +290 -2784
  543. package/src/render-system.ts +1597 -1192
  544. package/src/scene/__tests__/changed-block-summary.candidate.test.ts +36 -0
  545. package/src/scene/probe-blend-record.ts +54 -0
  546. package/src/scene/probe-blend.ts +813 -0
  547. package/src/scene/render-scene-types.ts +0 -7
  548. package/src/scene/render-scene.ts +972 -184
  549. package/src/scene/visibility/bounds.ts +57 -0
  550. package/src/scene/visibility/budget.ts +40 -0
  551. package/src/scene/visibility/facet.ts +400 -0
  552. package/src/scene/visibility/gpu-frame-timing.ts +101 -0
  553. package/src/scene/visibility/gpu-lod.ts +119 -0
  554. package/src/scene/visibility/inspection.ts +313 -0
  555. package/src/scene/visibility/lod-selector.ts +84 -0
  556. package/src/scene/visibility/occlusion-confidence.ts +478 -0
  557. package/src/scene/visibility/occlusion-pass.ts +165 -0
  558. package/src/scene/visibility/occlusion-query-pool.ts +178 -0
  559. package/src/scene/visibility/occlusion-runtime.ts +672 -0
  560. package/src/scene/visibility/types.ts +50 -0
  561. package/src/ssr/admission.ts +451 -0
  562. package/src/ssr/composition.ts +73 -0
  563. package/src/ssr/errors.ts +76 -0
  564. package/src/ssr/identity.ts +12 -0
  565. package/src/systems/skin-palette-allocator.ts +1 -1
  566. package/src/temporal/frame-coordinator.ts +30 -1
  567. package/src/temporal/frame.ts +14 -1
  568. package/src/temporal/taa-history-store.ts +46 -2
  569. package/src/temporal/temporal-view.ts +1 -1
  570. package/src/temporal/transaction.ts +9 -0
  571. package/src/temporal/view.ts +11 -0
  572. package/src/tilemap-chunk-extract-system.ts +5 -5
  573. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +19 -12
  574. package/src/typed-render-graph-primitives.ts +48 -0
  575. package/src/volume/passes.ts +46 -49
  576. package/dist/.tsbuildinfo +0 -1
  577. package/dist/chunk-5YADUCA7.mjs.map +0 -1
  578. package/dist/chunk-6OPXDQ2E.mjs.map +0 -1
  579. package/dist/chunk-BXBQQKFQ.mjs +0 -67
  580. package/dist/chunk-BXBQQKFQ.mjs.map +0 -1
  581. package/dist/chunk-FS2KNUQH.mjs.map +0 -1
  582. package/dist/chunk-PCHBPYML.mjs.map +0 -1
  583. package/dist/chunk-VFPLTKCU.mjs +0 -192
  584. package/dist/chunk-VFPLTKCU.mjs.map +0 -1
@@ -103,31 +103,15 @@ import {
103
103
  walkMaterialPassesOverSharedRefs,
104
104
  } from '@forgeax/engine-assets-runtime';
105
105
  import type { Component, ComponentSchema, EntityHandle, Query, World } from '@forgeax/engine-ecs';
106
- import {
107
- InstanceTransformsStrideMismatchError,
108
- readRenderArrayView,
109
- routeWorldError,
110
- SpawnLightInvalidBoundsError,
111
- SpriteInstancesCountMismatchError,
112
- SpriteInstancesMutuallyExclusiveWithInstancesError,
113
- SpriteInstancesRequiresSpriteShaderError,
114
- } from '@forgeax/engine-ecs/projection';
115
- import { box3, frustum, type Mat4, mat4, type Vec3, vec3 } from '@forgeax/engine-math';
106
+ import { readRenderArrayView, routeWorldError } from '@forgeax/engine-ecs/projection';
107
+ import { type Mat4, mat4, type Vec3, vec3 } from '@forgeax/engine-math';
116
108
  import { AssetGuid, type AssetGuid as AssetGuidBytes } from '@forgeax/engine-pack/guid';
117
109
  import { RhiError } from '@forgeax/engine-rhi';
110
+ import { GlobalTransform, Transform } from '@forgeax/engine-scene';
118
111
  import {
119
- MorphWeights,
120
- projectHierarchy,
121
- type SceneHierarchySnapshot,
122
- Transform,
123
- } from '@forgeax/engine-scene';
124
- import {
125
- JointCountMismatchError,
126
- JointEntityDanglingError,
127
- SkeletonResolveFailedError,
128
- Skin,
129
- SkinInstancesCoexistForbiddenError,
130
- } from '@forgeax/engine-skinning';
112
+ type MaterialShaderArtifact,
113
+ STANDARD_PIPELINE_PARAM_SCHEMA,
114
+ } from '@forgeax/engine-shader';
131
115
  import type {
132
116
  Asset,
133
117
  Handle,
@@ -138,75 +122,40 @@ import type {
138
122
  MaterialRenderState,
139
123
  MaterialTextureCoordinates,
140
124
  MaterialTextureValue,
141
- MeshAsset,
125
+ MeshLodLevel,
142
126
  ParamSchemaEntry,
143
127
  PrimitiveTopology,
144
- SkeletonAsset,
145
128
  TextureAsset,
146
129
  } from '@forgeax/engine-types';
147
130
  import {
148
- ASSET_ERROR_HINTS,
149
- AssetError,
150
131
  derive,
151
132
  materialGuidText,
152
133
  materialValuesToLinearRuntime,
153
134
  toShared,
154
135
  } from '@forgeax/engine-types';
155
136
  import {
156
- Atmosphere,
157
137
  antialiasFromF32,
158
138
  bloomEnabledFromF32,
159
139
  Camera,
160
140
  CubeCamera,
161
141
  cameraProjectionFromF32,
162
142
  cubeCameraUpdateIntentFromF32,
163
- DirectionalLight,
164
143
  Fog,
165
- Instances,
166
- Layer,
167
- Lines,
168
- MeshFilter,
169
- MeshRenderer,
170
144
  MotionBlur,
171
- PointLight,
172
- PointLightShadow,
173
- Points,
174
- PostProcessParams,
175
- pointShapeFromU32,
176
145
  ReflectionProbe,
177
146
  reflectionProbeUpdateIntentFromF32,
178
- SkyboxBackground,
179
- Skylight,
180
- SortKey,
181
- SpotLight,
182
- SpriteInstances,
183
- SpriteRegionOverride,
184
147
  tonemapFromF32,
185
148
  tonemapToU32,
186
149
  } from './components';
187
- import {
188
- type DirectionalShadowQuality,
189
- directionalShadowQualityFromF32,
190
- } from './components/directional-shadow-filter';
191
- import { GlyphText } from './components/glyph-text';
192
- import {
193
- computeInvRangeSquared,
194
- degToCos,
195
- type LightValidationError,
196
- validateDirectionalLightData,
197
- } from './components/light-helpers';
150
+ import type { DirectionalShadowQuality } from './components/directional-shadow-filter';
151
+ import type { LightValidationError } from './components/light-helpers';
198
152
  import { selectEnvironment } from './environment/frame';
199
- import {
200
- MaterialSkinAttrMissingError,
201
- ShadowInvalidConfigError,
202
- SkinMaterialMismatchError,
203
- } from './errors/render';
153
+ import { ShadowInvalidConfigError } from './errors/render';
204
154
  import {
205
155
  type DirectionalShadowCascadeFit,
206
156
  projectDirectionalShadow,
207
157
  } from './extract/directional-shadow-projection';
208
158
  import type {
209
- EnvironmentCandidate,
210
159
  EnvironmentFrame,
211
160
  FogCandidate,
212
161
  FogFrame,
@@ -217,26 +166,17 @@ import type {
217
166
  RenderFeatureHiddenEntityReport,
218
167
  RenderFeatureWorldVisibilitySnapshot,
219
168
  } from './features/types';
220
- import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
221
- import { deriveInstancesUnionBounds } from './instances-derived-bounds';
222
- import {
223
- type MeshMaterialBindingDiagnostic,
224
- type MeshMaterialBindingSource,
225
- resolveMeshMaterialBindings,
169
+ import type {
170
+ MeshMaterialBindingDiagnostic,
171
+ MeshMaterialBindingSource,
226
172
  } from './mesh-material-bindings';
227
- import { isStandardPbrMaterialShader } from './pbr-pipeline';
228
- import { expandPointsLinesBounds } from './points-lines/bounds';
229
- import type { PointsLinesRetainedSnapshot, PointsLinesStyle } from './points-lines/snapshot';
173
+ import { isCanonicalStandardPbrMaterialShader, isStandardPbrMaterialShader } from './pbr-pipeline';
174
+ import type { PointsLinesRetainedSnapshot } from './points-lines/snapshot';
230
175
  import type { ReflectionProbeFact } from './reflection/projection';
231
- import {
232
- type CameraSnapshot,
233
- type CubeCameraSnapshot,
234
- STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
235
- } from './render-contract';
236
- import { SHADOW_ATLAS_DEFAULT_LAYERS } from './shadow-atlas';
176
+ import type { CameraSnapshot, CubeCameraSnapshot } from './render-contract';
177
+ import { defaultMaterialSnapshot } from './render-system-extract-tail';
237
178
  import { getActiveCamera, selectActiveCameraIndex } from './systems/active-camera';
238
179
  import { selectPasses } from './systems/pass-selector';
239
-
240
180
  import type { SkinPaletteAllocator } from './systems/skin-palette-allocator';
241
181
  import type { SkinPaletteSlice } from './systems/skin-palette-types';
242
182
  import type { RenderTarget, RenderTargetTextureSource } from './targets/contracts';
@@ -289,18 +229,19 @@ export interface PointLightSnapshot {
289
229
  * plan-strategy §D-2 — record stage / shader skips shadow sampling when the
290
230
  * lane equals the sentinel. Joined with `pointShadow[]` by entity at the
291
231
  * end of extract; HDRP record stage threads `shadowAtlasLayer + shadowNear +
292
- * shadowFar` through `packLightSlot` as the §D-8 pad-lane payload.
232
+ * shadowFar` through the unified direct-light metadata payload.
293
233
  */
294
234
  readonly shadowAtlasLayer?: number;
295
235
  /**
296
236
  * Per-face perspective near plane (matches `PointLightShadow.nearPlane`).
297
- * Used by HDRP `evalPointShadowed` for depth-ref reconstruction; rides
298
- * `LightSlot.kind_and_pad.z` (byte 56..60) on the std430 std layout.
237
+ * Used by HDRP `evalPointShadowed` for depth-ref reconstruction; URP keeps
238
+ * the projection constants in its dedicated shadow-parameter buffer.
299
239
  */
300
240
  readonly shadowNear?: number;
301
241
  /**
302
242
  * Per-face perspective far plane (matches `PointLightShadow.farPlane`).
303
- * Rides `LightSlot.kind_and_pad.w` (byte 60..64).
243
+ * It is consumed with the shadow atlas layer through the shared
244
+ * shadow-parameter buffer; DirectLightSlot carries only the layer identity.
304
245
  */
305
246
  readonly shadowFar?: number;
306
247
  }
@@ -324,8 +265,7 @@ export interface PointLightSnapshot {
324
265
  * cube atlas faces and feed shader the proj constants without re-querying ECS.
325
266
  * `shadowAtlasLayer` is assigned by extract in spawn order up to the renderer
326
267
  * atlas capacity; over-budget requests retain the sentinel `-1` so the
327
- * downstream LightSlot / packPointLight packers carry no invalid layer in
328
- * pointPadW (T-M1-8 will rename to shadowAtlasLayer).
268
+ * downstream DirectLightSlot packing carries no invalid layer identity.
329
269
  */
330
270
  export interface PointShadowSnapshot {
331
271
  /** Spawning entity index (joins PointLight + PointLightShadow + Transform). */
@@ -339,7 +279,7 @@ export interface PointShadowSnapshot {
339
279
  /**
340
280
  * Atlas layer assigned by extract in spawn order while capacity remains.
341
281
  * Sentinel -1 is used for a request that cannot be admitted and is
342
- * reserved by the shader-side LightSlot for no-shadow lights (T-M1-8).
282
+ * reserved by the shader-side DirectLightSlot metadata for no-shadow lights.
343
283
  */
344
284
  readonly shadowAtlasLayer: number;
345
285
  /**
@@ -378,6 +318,27 @@ export interface SpotLightSnapshot {
378
318
  readonly invRangeSquared: number;
379
319
  readonly cosInner: number;
380
320
  readonly cosOuter: number;
321
+ /** Shared asset identities projected from the authored modifier fields. */
322
+ readonly iesProfileHandle?: number;
323
+ readonly cookieHandle?: number;
324
+ /** Resource-array slice identities; absent means the modifier falls back. */
325
+ readonly iesProfileSlice?: number;
326
+ readonly cookieSlice?: number;
327
+ /** Resolved authored payloads; absent means the renderer keeps identity LKG. */
328
+ readonly iesProfileData?: Uint8Array;
329
+ readonly cookieData?: Uint8Array;
330
+ /** Aspect-only UV matrix paired with `cookieSlice`; upload is dirty-ranged. */
331
+ readonly cookieMatrix?: Float32Array;
332
+ /**
333
+ * Legacy projector payload projected into the shared Cookie array lane when
334
+ * the adapter exposes the extended-lighting topology. The projector keeps
335
+ * its lightViewProj sampling semantics; only the texture transport is
336
+ * shared with authored Cookie resources.
337
+ */
338
+ readonly projectorSlice?: number;
339
+ readonly projectorData?: Uint8Array;
340
+ readonly projectorMatrix?: Float32Array;
341
+ readonly rollDeg?: number;
381
342
  // ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
382
343
  readonly castShadow: boolean;
383
344
  readonly lightViewProj: Float32Array | undefined;
@@ -398,13 +359,104 @@ export interface SpotLightSnapshot {
398
359
  readonly projectorRevision?: number;
399
360
  }
400
361
 
362
+ /** RectArea direct-light snapshot carried by the unified slot contract. */
363
+ export interface RectAreaDirectLightSnapshot {
364
+ readonly kind: 'rect-area';
365
+ readonly position: Vec3;
366
+ readonly color: Vec3;
367
+ readonly intensity: number;
368
+ readonly invRangeSquared: number;
369
+ readonly halfWidth: number;
370
+ readonly halfHeight: number;
371
+ readonly axisX: Vec3;
372
+ readonly axisY: Vec3;
373
+ readonly shadowAtlasTile?: number;
374
+ }
375
+
376
+ /** Normalized world-space frame used by Rect extraction, culling, and BRDF. */
377
+ export interface RectAreaWorldFrame {
378
+ readonly center: Vec3;
379
+ readonly axisX: Vec3;
380
+ readonly axisY: Vec3;
381
+ readonly normal: Vec3;
382
+ readonly halfWidth: number;
383
+ readonly halfHeight: number;
384
+ }
385
+
386
+ /** Build the single normalized Rect frame shared by direct and cluster paths. */
387
+ export function buildRectAreaWorldFrame(input: {
388
+ readonly center: ArrayLike<number>;
389
+ readonly axisX: ArrayLike<number>;
390
+ readonly axisY: ArrayLike<number>;
391
+ readonly width: number;
392
+ readonly height: number;
393
+ }): RectAreaWorldFrame {
394
+ const center = vec3.create(input.center[0] ?? 0, input.center[1] ?? 0, input.center[2] ?? 0);
395
+ const axisX = vec3.normalize(
396
+ vec3.create(),
397
+ vec3.create(input.axisX[0] ?? 0, input.axisX[1] ?? 0, input.axisX[2] ?? 0),
398
+ );
399
+ const axisY = vec3.normalize(
400
+ vec3.create(),
401
+ vec3.create(input.axisY[0] ?? 0, input.axisY[1] ?? 0, input.axisY[2] ?? 0),
402
+ );
403
+ const normal = vec3.normalize(vec3.create(), vec3.cross(vec3.create(), axisX, axisY));
404
+ normal[0] = normal[0] === 0 ? 0 : (normal[0] ?? 0);
405
+ normal[1] = normal[1] === 0 ? 0 : (normal[1] ?? 0);
406
+ normal[2] = normal[2] === 0 ? 0 : (normal[2] ?? 0);
407
+ return {
408
+ center,
409
+ axisX,
410
+ axisY,
411
+ normal,
412
+ halfWidth: Math.max(0, input.width * 0.5),
413
+ halfHeight: Math.max(0, input.height * 0.5),
414
+ };
415
+ }
416
+
417
+ /** Return true only on the authored front side of a one-sided Rect. */
418
+ export function rectAreaFacesPoint(frame: RectAreaWorldFrame, point: ArrayLike<number>): boolean {
419
+ const toPoint = vec3.create(
420
+ (point[0] ?? 0) - (frame.center[0] ?? 0),
421
+ (point[1] ?? 0) - (frame.center[1] ?? 0),
422
+ (point[2] ?? 0) - (frame.center[2] ?? 0),
423
+ );
424
+ return vec3.dot(frame.normal, toPoint) > 0;
425
+ }
426
+
427
+ /** Project a world point onto the finite authored Rect boundary. */
428
+ export function closestPointOnRectArea(frame: RectAreaWorldFrame, point: ArrayLike<number>): Vec3 {
429
+ const toPoint = vec3.create(
430
+ (point[0] ?? 0) - (frame.center[0] ?? 0),
431
+ (point[1] ?? 0) - (frame.center[1] ?? 0),
432
+ (point[2] ?? 0) - (frame.center[2] ?? 0),
433
+ );
434
+ const localX = Math.min(
435
+ frame.halfWidth,
436
+ Math.max(-frame.halfWidth, vec3.dot(toPoint, frame.axisX)),
437
+ );
438
+ const localY = Math.min(
439
+ frame.halfHeight,
440
+ Math.max(-frame.halfHeight, vec3.dot(toPoint, frame.axisY)),
441
+ );
442
+ return vec3.create(
443
+ (frame.center[0] ?? 0) + (frame.axisX[0] ?? 0) * localX + (frame.axisY[0] ?? 0) * localY,
444
+ (frame.center[1] ?? 0) + (frame.axisX[1] ?? 0) * localX + (frame.axisY[1] ?? 0) * localY,
445
+ (frame.center[2] ?? 0) + (frame.axisX[2] ?? 0) * localX + (frame.axisY[2] ?? 0) * localY,
446
+ );
447
+ }
448
+
401
449
  /**
402
- * LightSnapshot — discriminated union of the three KHR_lights_punctual
450
+ * LightSnapshot — discriminated union of the directional and punctual
403
451
  * variants. AI users + the record stage perform an exhaustive switch on
404
452
  * `kind`; missing arms are caught at compile time (no `default`,
405
453
  * `assertNever`-style guards). Plan-strategy R-10.
406
454
  */
407
- export type LightSnapshot = DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot;
455
+ export type LightSnapshot =
456
+ | DirectionalLightSnapshot
457
+ | PointLightSnapshot
458
+ | SpotLightSnapshot
459
+ | RectAreaDirectLightSnapshot;
408
460
 
409
461
  /**
410
462
  * ExtractedLights — three-bucket output of the extractFrame three-query
@@ -423,6 +475,7 @@ export interface ExtractedLights {
423
475
  readonly directionalCount: number;
424
476
  readonly point: readonly PointLightSnapshot[];
425
477
  readonly spot: readonly SpotLightSnapshot[];
478
+ readonly rect: readonly RectAreaDirectLightSnapshot[];
426
479
  /**
427
480
  * feat-20260613-csm-cascaded-shadow-maps M2 / w9: per-cascade light-view-
428
481
  * projection matrices (one per cascade, length 4 pre-allocated). Each matrix
@@ -482,7 +535,8 @@ export interface ExtractedLights {
482
535
  * shadow-casting point lights (PointLight + PointLightShadow + Transform
483
536
  * archetype join). Each entry carries the per-light 6-face VP matrices and
484
537
  * the assigned cube_array atlas layer (0..3 in spawn order; sentinel -1 is
485
- * reserved by the shader-side LightSlot for no-shadow point lights).
538
+ * reserved by the shader-side DirectLightSlot metadata for no-shadow point
539
+ * lights).
486
540
  *
487
541
  * Empty array when no PointLightShadow components exist (zero-cost gate per
488
542
  * AC-09; record stage skips atlas allocation + shadow pass dispatch).
@@ -542,6 +596,15 @@ export interface SkylightSnapshot {
542
596
  readonly entityHandle: number;
543
597
  }
544
598
 
599
+ /** Local diffuse probe facts; specular remains owned by Skylight/ReflectionProbe. */
600
+ export interface LightProbeSnapshot {
601
+ readonly identity: string;
602
+ readonly position: readonly [number, number, number];
603
+ readonly radius: number;
604
+ readonly irradiance: Float32Array;
605
+ readonly admitted: boolean;
606
+ }
607
+
545
608
  /**
546
609
  * SkyboxSnapshot -- extract-stage view of one SkyboxBackground entity
547
610
  * (feat-20260531-skybox-env-background M2 / w5).
@@ -598,6 +661,9 @@ export interface ExtractedVolumetricFog {
598
661
  export interface RenderableSnapshot {
599
662
  readonly assetHandle: number;
600
663
  readonly transform: TransformSnapshot;
664
+ /** Producer-authored lower-detail references projected from MeshAsset. */
665
+ readonly lods?: readonly MeshLodLevel[];
666
+ readonly lodHysteresis?: number;
601
667
  /** Finite producer-owned local bounds retained for persistent CPU view work. */
602
668
  readonly localAabb?: Float32Array;
603
669
  /** Indexed single-submesh facts consumed by the first GPU-driven rigid lane. */
@@ -705,6 +771,8 @@ export interface RenderableSnapshot {
705
771
  readonly temporal?: RenderableTemporalSnapshot;
706
772
  /** Detached M2 authoring facts for the optional Points/Lines projection. */
707
773
  readonly pointsLines?: PointsLinesRetainedSnapshot;
774
+ /** Renderer-owned per-object probe record uploaded at group(3) binding(1). */
775
+ readonly probeBlendRecord?: import('./scene/probe-blend-record').ProbeBlendRecord;
708
776
  }
709
777
 
710
778
  export type RenderablePreviousSource = 'last-submitted' | 'current-seed';
@@ -741,6 +809,16 @@ export interface GpuDrivenDrawSnapshot {
741
809
  readonly topology: PrimitiveTopology;
742
810
  readonly pipelineClass: string;
743
811
  readonly materialResourceClass: string;
812
+ /** Producer-owned prepared ABI facts used by the Standard PBR GPU lane. */
813
+ readonly prepared?: import('./gpu-driven/prepared-draw').PreparedGpuDrivenDraw;
814
+ /** Optional projected height supplied by direct GPU-view evidence/tests. */
815
+ readonly projectedHeight?: number;
816
+ /** Root plus lower-detail index/vertex ranges, in LOD order. */
817
+ readonly lodRanges?: readonly {
818
+ readonly first: number;
819
+ readonly count: number;
820
+ readonly baseVertex: number;
821
+ }[];
744
822
  }
745
823
 
746
824
  export interface InstancesSnapshot {
@@ -793,7 +871,7 @@ export interface SpriteInstancesSnapshot {
793
871
  /**
794
872
  * TransformSnapshot: extract-stage view of one entity's resolved world
795
873
  * transform (feat-20260601 D-3). Holds the single `world` mat4 (column-major
796
- * 16 floats, copied from the entity's `Transform.world` view written by
874
+ * 16 floats, copied from the entity's `GlobalTransform.world` view written by
797
875
  * propagateTransforms). The record stage copies these 16 floats straight into
798
876
  * the mesh SSBO slot (zero `mat4.compose`); position / scale consumers derive
799
877
  * from the mat4 via `mat4.getTranslation` / basis-column lengths.
@@ -828,10 +906,8 @@ export interface MaterialSnapshot {
828
906
  readonly baseColor: Vec3;
829
907
  readonly metallic: number;
830
908
  readonly roughness: number;
831
- readonly clearcoat?: number | undefined;
832
- readonly clearcoatRoughness?: number | undefined;
833
- /** Authored Standard PBR specular tint in linear runtime color space. */
834
- readonly specularTint?: readonly [number, number, number] | undefined;
909
+ /** Authored Standard PBR specular color in linear runtime color space. */
910
+ readonly specularColor?: readonly [number, number, number] | undefined;
835
911
  /**
836
912
  * Schema-driven material shader identifier (feat-20260523 M4-T05).
837
913
  * Populated when the material asset uses the schema-driven path
@@ -962,7 +1038,7 @@ export interface MaterialSnapshot {
962
1038
  readonly transparent?: boolean | undefined;
963
1039
  }
964
1040
 
965
- interface MaterialCacheChainIdentity {
1041
+ export interface MaterialCacheChainIdentity {
966
1042
  readonly asset: MaterialAsset;
967
1043
  readonly parentGuid: string | undefined;
968
1044
  }
@@ -981,7 +1057,7 @@ export interface MaterialSnapshotCacheEntry {
981
1057
  export type MaterialSnapshotCache = Map<number, MaterialSnapshotCacheEntry>;
982
1058
  export type MaterialSnapshotCachesByWorld = WeakMap<World, MaterialSnapshotCache>;
983
1059
 
984
- function gpuDrivenMaterialResourceClass(material: MaterialSnapshot): string {
1060
+ export function gpuDrivenMaterialResourceClass(material: MaterialSnapshot): string {
985
1061
  const textures = [...(material.textureHandles?.entries() ?? [])]
986
1062
  .map(([name, handle]) => [name, Number(handle)] as const)
987
1063
  .sort(([left], [right]) => left.localeCompare(right));
@@ -1043,13 +1119,13 @@ export function sortDispatchByQueue<E extends { readonly queue: number }>(
1043
1119
  return entries.slice().sort((a, b) => a.queue - b.queue);
1044
1120
  }
1045
1121
 
1046
- const DEFAULT_FORWARD_PASS: MaterialPass = {
1122
+ export const DEFAULT_FORWARD_PASS: MaterialPass = {
1047
1123
  name: 'forward',
1048
1124
  program: { module: 'forgeax::default-unlit', vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
1049
1125
  renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
1050
1126
  };
1051
1127
 
1052
- function appendMaterialDispatchEntries(
1128
+ export function appendMaterialDispatchEntries(
1053
1129
  pendingDispatch: DispatchEntry[],
1054
1130
  passes: readonly MaterialPass[],
1055
1131
  entity: EntityHandle,
@@ -1115,6 +1191,8 @@ export interface ExtractedFrame {
1115
1191
  readonly dispatch: DispatchEntry[];
1116
1192
  readonly skylight: SkylightSnapshot | undefined;
1117
1193
  readonly skylightCount: number;
1194
+ /** World-local diffuse probe candidates; never a direct-light slot. */
1195
+ readonly lightProbes?: readonly LightProbeSnapshot[];
1118
1196
  readonly skybox: SkyboxSnapshot | undefined;
1119
1197
  readonly skyboxCount: number;
1120
1198
  /** Resource-owner Fog facts selected once at the frame boundary. */
@@ -1225,7 +1303,7 @@ type ArrayFieldView = ArrayLike<number>;
1225
1303
  * receives a detached array snapshot and routes failures through the same
1226
1304
  * projection owner.
1227
1305
  */
1228
- type WorldInternalView = {
1306
+ export type WorldInternalView = {
1229
1307
  _routeError(error: unknown, ctx: RenderErrorContext): void;
1230
1308
  _getArrayView<N extends string, S extends ComponentSchema>(
1231
1309
  entity: EntityHandle,
@@ -1234,9 +1312,9 @@ type WorldInternalView = {
1234
1312
  ): ArrayFieldView | undefined;
1235
1313
  };
1236
1314
 
1237
- const Severity = Object.freeze({ Error: 'error', Warning: 'warning' } as const);
1315
+ export const Severity = Object.freeze({ Error: 'error', Warning: 'warning' } as const);
1238
1316
 
1239
- function createWorldInternalView(world: World): WorldInternalView {
1317
+ export function createWorldInternalView(world: World): WorldInternalView {
1240
1318
  return {
1241
1319
  _routeError(error, ctx) {
1242
1320
  routeWorldError(world, error, { systemName: ctx.systemName });
@@ -1247,7 +1325,10 @@ function createWorldInternalView(world: World): WorldInternalView {
1247
1325
  };
1248
1326
  }
1249
1327
 
1250
- function resolveCameraTarget(world: World, raw: number | undefined): RenderTarget | undefined {
1328
+ export function resolveCameraTarget(
1329
+ world: World,
1330
+ raw: number | undefined,
1331
+ ): RenderTarget | undefined {
1251
1332
  if (raw === undefined || raw <= 0) return undefined;
1252
1333
  const resolved = world.sharedRefs.resolve<'RenderTarget', RenderTarget>(
1253
1334
  toShared<'RenderTarget'>(Math.round(raw)),
@@ -1261,11 +1342,11 @@ function resolveCameraTarget(world: World, raw: number | undefined): RenderTarge
1261
1342
  * (cross-worktree shader-late-register, plan R-4) so a built-in material still
1262
1343
  * resolves its user-region textures. Mirrors `derive(default-standard-pbr).textureFieldNames`.
1263
1344
  */
1264
- const BUILTIN_USER_REGION_TEXTURE_FIELDS: readonly string[] = [
1345
+ export const BUILTIN_USER_REGION_TEXTURE_FIELDS: readonly string[] = [
1265
1346
  'baseColorTexture',
1266
1347
  'metallicRoughnessTexture',
1267
1348
  'normalTexture',
1268
- 'specularTintTexture',
1349
+ 'specularColorTexture',
1269
1350
  'emissiveTexture',
1270
1351
  'occlusionTexture',
1271
1352
  'transmissionTexture',
@@ -1274,7 +1355,7 @@ const BUILTIN_USER_REGION_TEXTURE_FIELDS: readonly string[] = [
1274
1355
  const BUILTIN_USER_REGION_TEXTURE_FIELD_SET = new Set(BUILTIN_USER_REGION_TEXTURE_FIELDS);
1275
1356
  const BUILTIN_BASE_COLOR_TEXTURE_FIELD_SET = new Set(['baseColorTexture']);
1276
1357
 
1277
- function materialTextureFields(
1358
+ export function materialTextureFields(
1278
1359
  shaderId: string | undefined,
1279
1360
  fields: ReadonlySet<string> | undefined,
1280
1361
  ): ReadonlySet<string> | undefined {
@@ -1302,7 +1383,7 @@ function materialTextureFields(
1302
1383
  * IDs, so this small projection keeps authored identity and renderer lookup
1303
1384
  * identity in one place during the migration.
1304
1385
  */
1305
- function runtimeMaterialShaderId(
1386
+ export function runtimeMaterialShaderId(
1306
1387
  module: string | undefined,
1307
1388
  passName?: string,
1308
1389
  ): string | undefined {
@@ -1329,7 +1410,7 @@ function runtimeMaterialShaderId(
1329
1410
  }
1330
1411
  }
1331
1412
 
1332
- function pipelineRenderState(
1413
+ export function pipelineRenderState(
1333
1414
  renderState:
1334
1415
  | (MaterialRenderState & {
1335
1416
  readonly tags?: Readonly<Record<string, string>>;
@@ -1418,7 +1499,7 @@ interface GuidHandleInternEntry {
1418
1499
 
1419
1500
  const guidHandleInternByWorld = new WeakMap<World, Map<string, GuidHandleInternEntry>>();
1420
1501
 
1421
- function internSharedRefFromGuid<B extends string>(
1502
+ export function internSharedRefFromGuid<B extends string>(
1422
1503
  world: World,
1423
1504
  assetsRef: AssetRegistry,
1424
1505
  guid: string,
@@ -1467,7 +1548,7 @@ function internSharedRefFromGuid<B extends string>(
1467
1548
  * A `number` paramValue (already a minted column handle) is not a GUID string so
1468
1549
  * it cannot be a freshly-catalogued video; it passes through as undefined here.
1469
1550
  */
1470
- function resolveVideoFieldHandle(
1551
+ export function resolveVideoFieldHandle(
1471
1552
  value: unknown,
1472
1553
  world: World,
1473
1554
  assetsRef: AssetRegistry,
@@ -1484,7 +1565,7 @@ function resolveVideoFieldHandle(
1484
1565
  return internSharedRefFromGuid(world, assetsRef, textureGuid, 'VideoAsset');
1485
1566
  }
1486
1567
 
1487
- function materialTextureValue(value: unknown): MaterialTextureValue | undefined {
1568
+ export function materialTextureValue(value: unknown): MaterialTextureValue | undefined {
1488
1569
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
1489
1570
  const textureValue = value as Partial<MaterialTextureValue>;
1490
1571
  return typeof textureValue.texture === 'number' ||
@@ -1494,17 +1575,17 @@ function materialTextureValue(value: unknown): MaterialTextureValue | undefined
1494
1575
  : undefined;
1495
1576
  }
1496
1577
 
1497
- function assetReferenceText(value: unknown): string | undefined {
1578
+ export function assetReferenceText(value: unknown): string | undefined {
1498
1579
  if (typeof value === 'string') return value;
1499
1580
  if (value instanceof Uint8Array && isAssetGuidBytes(value)) return AssetGuid.format(value);
1500
1581
  return undefined;
1501
1582
  }
1502
1583
 
1503
- function isAssetGuidBytes(value: Uint8Array): value is AssetGuidBytes {
1584
+ export function isAssetGuidBytes(value: Uint8Array): value is AssetGuidBytes {
1504
1585
  return value.byteLength === 16;
1505
1586
  }
1506
1587
 
1507
- function materialNormalScale(values: Readonly<Record<string, unknown>>): number {
1588
+ export function materialNormalScale(values: Readonly<Record<string, unknown>>): number {
1508
1589
  for (const [field, value] of Object.entries(values)) {
1509
1590
  if (field !== 'normalTexture') continue;
1510
1591
  const scale = materialTextureValue(value)?.normalScale;
@@ -1513,29 +1594,26 @@ function materialNormalScale(values: Readonly<Record<string, unknown>>): number
1513
1594
  return 1;
1514
1595
  }
1515
1596
 
1516
- function materialTextureRef(value: unknown): unknown {
1597
+ export function materialTextureRef(value: unknown): unknown {
1517
1598
  return materialTextureValue(value)?.texture ?? value;
1518
1599
  }
1519
1600
 
1520
- function collectMaterialTextureCoordinates(
1601
+ export function collectMaterialTextureCoordinates(
1521
1602
  values: Readonly<Record<string, unknown>>,
1522
1603
  ): Map<string, MaterialTextureCoordinates> {
1523
1604
  const out = new Map<string, MaterialTextureCoordinates>();
1524
- for (const field of [
1525
- 'baseColorTexture',
1526
- 'metallicRoughnessTexture',
1527
- 'normalTexture',
1528
- 'specularTintTexture',
1529
- 'emissiveTexture',
1530
- 'occlusionTexture',
1531
- ]) {
1532
- const coordinates = materialTextureValue(values[field])?.coordinates;
1605
+ // Texture transforms belong to the value itself. Walking every authored
1606
+ // value keeps the route closed when Standard gains another physical map
1607
+ // without adding another renderer-side field inventory.
1608
+ for (const [field, value] of Object.entries(values)) {
1609
+ if (!field.endsWith('Texture')) continue;
1610
+ const coordinates = materialTextureValue(value)?.coordinates;
1533
1611
  if (coordinates !== undefined) out.set(field, coordinates);
1534
1612
  }
1535
1613
  return out;
1536
1614
  }
1537
1615
 
1538
- function collectMaterialTextureSources(
1616
+ export function collectMaterialTextureSources(
1539
1617
  values: Readonly<Record<string, unknown>>,
1540
1618
  world: World,
1541
1619
  ): Map<string, RenderTargetTextureSource> {
@@ -1553,7 +1631,7 @@ function collectMaterialTextureSources(
1553
1631
  return out;
1554
1632
  }
1555
1633
 
1556
- function collectMaterialTextureSamplers(
1634
+ export function collectMaterialTextureSamplers(
1557
1635
  values: Readonly<Record<string, unknown>>,
1558
1636
  resolveSampler: (value: unknown) => Handle<'SamplerAsset', 'shared'> | undefined,
1559
1637
  ): Map<string, Handle<'SamplerAsset', 'shared'>> {
@@ -1590,7 +1668,7 @@ function collectMaterialTextureSamplers(
1590
1668
  * `textureFieldNames` traversal set or it is never inspected (R-7) — that
1591
1669
  * membership is asserted in w13.
1592
1670
  */
1593
- function collectUserRegionTextureHandles(
1671
+ export function collectUserRegionTextureHandles(
1594
1672
  pv: Readonly<Record<string, unknown>>,
1595
1673
  shaderId: string | undefined,
1596
1674
  paramSchema: readonly ParamSchemaEntry[] | undefined,
@@ -1629,14 +1707,24 @@ function collectUserRegionTextureHandles(
1629
1707
 
1630
1708
  const ENGINE_INJECTED_TEXTURE_FIELDS = new Set(['emissiveTexture', 'occlusionTexture']);
1631
1709
 
1632
- function isEngineInjectedTextureField(shaderId: string | undefined, fieldName: string): boolean {
1710
+ export function isEngineInjectedTextureField(
1711
+ shaderId: string | undefined,
1712
+ fieldName: string,
1713
+ ): boolean {
1633
1714
  return isStandardPbrMaterialShader(shaderId) && ENGINE_INJECTED_TEXTURE_FIELDS.has(fieldName);
1634
1715
  }
1635
1716
 
1636
- function materialParametersToParamSchema(
1717
+ export function materialParametersToParamSchema(
1637
1718
  parameters: readonly MaterialParameter[],
1638
1719
  shaderId: string | undefined,
1639
1720
  ): readonly ParamSchemaEntry[] {
1721
+ // Engine-owned Standard modules are compiled once against the canonical
1722
+ // boot schema. Materials.standard() exposes a smaller authoring projection,
1723
+ // but using that compact projection as the runtime UBO schema would shift
1724
+ // every field after an omitted physical entry.
1725
+ if (isCanonicalStandardPbrMaterialShader(shaderId)) {
1726
+ return STANDARD_PIPELINE_PARAM_SCHEMA;
1727
+ }
1640
1728
  const engineInjectedTextureFields = isStandardPbrMaterialShader(shaderId)
1641
1729
  ? ENGINE_INJECTED_TEXTURE_FIELDS
1642
1730
  : undefined;
@@ -1649,16 +1737,25 @@ function materialParametersToParamSchema(
1649
1737
  {
1650
1738
  name: parameter.name,
1651
1739
  type: parameter.type === 'texture_cube' ? 'texture_cube' : 'texture2d',
1740
+ default: parameter.default,
1652
1741
  },
1653
1742
  ];
1654
1743
  }
1655
- return [
1656
- {
1657
- name: parameter.name,
1658
- type: parameter.type,
1659
- ...(parameter.colorSpace === undefined ? {} : { colorSpace: parameter.colorSpace }),
1660
- },
1661
- ];
1744
+ const numericDefault =
1745
+ typeof parameter.default === 'number'
1746
+ ? parameter.default
1747
+ : Array.isArray(parameter.default) &&
1748
+ parameter.default.every((entry) => typeof entry === 'number')
1749
+ ? parameter.default
1750
+ : undefined;
1751
+ const numericEntry = {
1752
+ name: parameter.name,
1753
+ type: parameter.type,
1754
+ ...(parameter.colorSpace === undefined ? {} : { colorSpace: parameter.colorSpace }),
1755
+ };
1756
+ return numericDefault === undefined
1757
+ ? [numericEntry]
1758
+ : [{ ...numericEntry, default: numericDefault }];
1662
1759
  });
1663
1760
  }
1664
1761
 
@@ -1668,7 +1765,7 @@ function materialParametersToParamSchema(
1668
1765
  * materials commonly omit `parameters`, while the shader registry still owns
1669
1766
  * the complete parameter contract.
1670
1767
  */
1671
- function materialColorParameterSchema(
1768
+ export function materialColorParameterSchema(
1672
1769
  parameters: readonly MaterialParameter[],
1673
1770
  shaderId: string | undefined,
1674
1771
  assets: AssetRegistry,
@@ -1686,7 +1783,7 @@ function materialColorParameterSchema(
1686
1783
 
1687
1784
  const MATERIAL_PARENT_CHAIN_LIMIT = 128;
1688
1785
 
1689
- function isDeepFrozen(value: unknown, seen = new Set<object>()): boolean {
1786
+ export function isDeepFrozen(value: unknown, seen = new Set<object>()): boolean {
1690
1787
  if (typeof value !== 'object' || value === null) return true;
1691
1788
  if (seen.has(value)) return true;
1692
1789
  if (!Object.isFrozen(value)) return false;
@@ -1703,7 +1800,7 @@ function isDeepFrozen(value: unknown, seen = new Set<object>()): boolean {
1703
1800
  * validation must walk the same chain on every cross-frame read. A malformed
1704
1801
  * or cyclic chain is deliberately not cacheable.
1705
1802
  */
1706
- function captureMaterialCacheChain(
1803
+ export function captureMaterialCacheChain(
1707
1804
  root: MaterialAsset,
1708
1805
  assets: AssetRegistry,
1709
1806
  ): readonly MaterialCacheChainIdentity[] | undefined {
@@ -1725,7 +1822,7 @@ function captureMaterialCacheChain(
1725
1822
  return undefined;
1726
1823
  }
1727
1824
 
1728
- function readPersistentMaterialSnapshot(
1825
+ export function readPersistentMaterialSnapshot(
1729
1826
  cache: MaterialSnapshotCache | undefined,
1730
1827
  handleRaw: number,
1731
1828
  assets: AssetRegistry,
@@ -1752,7 +1849,7 @@ function readPersistentMaterialSnapshot(
1752
1849
  * epoch changed; asking ECS to resolve that root before checking the epoch
1753
1850
  * defeats the cross-frame cache on the ordinary stable frame.
1754
1851
  */
1755
- function readStablePersistentMaterialSnapshot(
1852
+ export function readStablePersistentMaterialSnapshot(
1756
1853
  cache: MaterialSnapshotCache | undefined,
1757
1854
  handleRaw: number,
1758
1855
  assets: AssetRegistry,
@@ -1765,7 +1862,7 @@ function readStablePersistentMaterialSnapshot(
1765
1862
  : undefined;
1766
1863
  }
1767
1864
 
1768
- function storeMaterialSnapshot(
1865
+ export function storeMaterialSnapshot(
1769
1866
  cache: MaterialSnapshotCache,
1770
1867
  handleRaw: number,
1771
1868
  snapshot: MaterialSnapshot,
@@ -1854,10 +1951,7 @@ export function resolveMaterialSnapshot(
1854
1951
  );
1855
1952
  const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
1856
1953
  const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
1857
- const clearcoatPv = typeof pv.clearcoat === 'number' ? pv.clearcoat : 0;
1858
- const clearcoatRoughnessPv =
1859
- typeof pv.clearcoatRoughness === 'number' ? pv.clearcoatRoughness : 0.5;
1860
- const specularTintPv = pv.specularTint as readonly number[] | undefined;
1954
+ const specularColorPv = pv.specularColor as readonly number[] | undefined;
1861
1955
  const normalScalePv = materialNormalScale(pv);
1862
1956
 
1863
1957
  const paramSnap: Record<string, number | number[] | string> = {};
@@ -1929,13 +2023,11 @@ export function resolveMaterialSnapshot(
1929
2023
  baseColor,
1930
2024
  metallic: metallicPv,
1931
2025
  roughness: roughnessPv,
1932
- clearcoat: clearcoatPv,
1933
- clearcoatRoughness: clearcoatRoughnessPv,
1934
- ...(specularTintPv !== undefined && {
1935
- specularTint: [
1936
- specularTintPv[0] ?? 1,
1937
- specularTintPv[1] ?? 1,
1938
- specularTintPv[2] ?? 1,
2026
+ ...(specularColorPv !== undefined && {
2027
+ specularColor: [
2028
+ specularColorPv[0] ?? 1,
2029
+ specularColorPv[1] ?? 1,
2030
+ specularColorPv[2] ?? 1,
1939
2031
  ] as readonly [number, number, number],
1940
2032
  }),
1941
2033
  normalScale: normalScalePv,
@@ -2316,8 +2408,8 @@ export function computeDirectionalCsm(
2316
2408
  camProj,
2317
2409
  cameraData.orthoLeft,
2318
2410
  cameraData.orthoRight,
2319
- cameraData.orthoBottom,
2320
2411
  cameraData.orthoTop,
2412
+ cameraData.orthoBottom,
2321
2413
  cameraData.near,
2322
2414
  cameraData.far,
2323
2415
  );
@@ -2390,7 +2482,7 @@ export function computeDirectionalCsm(
2390
2482
  // toward-light extreme for the near bound; per-cascade far (minZ) + X/Y.
2391
2483
  const nearZ = Math.max(maxZ, lightSpaceMaxZFull);
2392
2484
  const orthoProj = mat4.create();
2393
- mat4.orthographic(orthoProj, minX, maxX, minY, maxY, -nearZ, -minZ);
2485
+ mat4.orthographic(orthoProj, minX, maxX, maxY, minY, -nearZ, -minZ);
2394
2486
  // lightViewProj = orthoProj * lightView — pure clip-space [-1,1]. Atlas
2395
2487
  // tile placement is handled by the per-cascade viewport + fragment-side UV
2396
2488
  // math (evalDirectional), so shadow_caster.gl_Position stays clip-space.
@@ -2449,21 +2541,33 @@ export interface PreparedExtractContext {
2449
2541
  readonly resourceOwnerFog: FogFrame | null | undefined;
2450
2542
  readonly cull: 'self' | 'none' | 'external';
2451
2543
  readonly cullCameras: readonly CameraSnapshot[] | undefined;
2452
- readonly hierarchy: SceneHierarchySnapshot;
2544
+ readonly renderables: 'full' | 'none';
2453
2545
  readonly visibility: VisibilitySnapshot;
2546
+ /** Producer-owned artifact lookup; extraction only, never record-time asset reads. */
2547
+ readonly getMaterialShaderArtifact:
2548
+ | ((materialShaderId: string) => MaterialShaderArtifact | undefined)
2549
+ | undefined;
2454
2550
  }
2455
2551
 
2552
+ const ALL_VISIBLE: VisibilitySnapshot = {
2553
+ diagnostics: [],
2554
+ hasAnyIntent: false,
2555
+ hasAnyHiddenIntent: false,
2556
+ get: () => undefined,
2557
+ effective: () => 'visible',
2558
+ };
2559
+
2456
2560
  function collectCameraSnapshots(world: World): CameraSnapshot[] {
2457
2561
  const worldInternal = createWorldInternalView(world);
2458
2562
  const cameras: CameraSnapshot[] = [];
2459
2563
  const cameraQuery = world
2460
- .query({ read: [Camera], optional: [MotionBlur], with: [Transform] })
2564
+ .query({ read: [Camera], optional: [MotionBlur], with: [Transform, GlobalTransform] })
2461
2565
  .unwrap();
2462
2566
  for (const row of cameraQuery) {
2463
2567
  const cam = row.get(Camera);
2464
2568
  const motionBlur = row.get(MotionBlur);
2465
2569
  const entity = row.entity;
2466
- const view = worldInternal._getArrayView(entity, Transform, 'world');
2570
+ const view = worldInternal._getArrayView(entity, GlobalTransform, 'world');
2467
2571
  if (view === undefined) continue;
2468
2572
  const worldMat = new Float32Array(view);
2469
2573
  const target = resolveCameraTarget(world, cam.target);
@@ -2510,15 +2614,15 @@ function collectCameraSnapshots(world: World): CameraSnapshot[] {
2510
2614
  return cameras;
2511
2615
  }
2512
2616
 
2513
- function collectCubeCameraSnapshots(world: World): CubeCameraSnapshot[] {
2617
+ export function collectCubeCameraSnapshots(world: World): CubeCameraSnapshot[] {
2514
2618
  const worldInternal = createWorldInternalView(world);
2515
2619
  const snapshots: CubeCameraSnapshot[] = [];
2516
- const query = world.query({ read: [CubeCamera], with: [Transform] }).unwrap();
2620
+ const query = world.query({ read: [CubeCamera], with: [Transform, GlobalTransform] }).unwrap();
2517
2621
  for (const row of query) {
2518
2622
  const camera = row.get(CubeCamera);
2519
2623
  const target = resolveCameraTarget(world, camera.target);
2520
2624
  if (target === undefined) continue;
2521
- const view = worldInternal._getArrayView(row.entity, Transform, 'world');
2625
+ const view = worldInternal._getArrayView(row.entity, GlobalTransform, 'world');
2522
2626
  if (view === undefined) continue;
2523
2627
  snapshots.push({
2524
2628
  entityKey: row.entity as number,
@@ -2534,13 +2638,15 @@ function collectCubeCameraSnapshots(world: World): CubeCameraSnapshot[] {
2534
2638
  return snapshots;
2535
2639
  }
2536
2640
 
2537
- function collectReflectionProbeFacts(world: World): ReflectionProbeFact[] {
2641
+ export function collectReflectionProbeFacts(world: World): ReflectionProbeFact[] {
2538
2642
  const worldInternal = createWorldInternalView(world);
2539
2643
  const facts: ReflectionProbeFact[] = [];
2540
- const query = world.query({ read: [ReflectionProbe], with: [Transform] }).unwrap();
2644
+ const query = world
2645
+ .query({ read: [ReflectionProbe], with: [Transform, GlobalTransform] })
2646
+ .unwrap();
2541
2647
  for (const row of query) {
2542
2648
  const probe = row.get(ReflectionProbe);
2543
- const view = worldInternal._getArrayView(row.entity, Transform, 'world');
2649
+ const view = worldInternal._getArrayView(row.entity, GlobalTransform, 'world');
2544
2650
  if (view === undefined) {
2545
2651
  continue;
2546
2652
  }
@@ -2569,7 +2675,7 @@ interface CameraRoleSelection {
2569
2675
  readonly auxiliary: readonly CameraSnapshot[];
2570
2676
  }
2571
2677
 
2572
- function selectCameraRoles(world: World): CameraRoleSelection {
2678
+ export function selectCameraRoles(world: World): CameraRoleSelection {
2573
2679
  const cameras = collectCameraSnapshots(world);
2574
2680
  const displayEntities = cameras
2575
2681
  .filter((camera) => camera.target === undefined)
@@ -2608,7 +2714,7 @@ export function extractCameraTargetSnapshots(world: World): readonly CameraSnaps
2608
2714
  return selectCameraRoles(world).auxiliary;
2609
2715
  }
2610
2716
 
2611
- function tonemapParams(camera: CameraSnapshot): Uint8Array {
2717
+ export function tonemapParams(camera: CameraSnapshot): Uint8Array {
2612
2718
  const bytes = new ArrayBuffer(16);
2613
2719
  const floats = new Float32Array(bytes);
2614
2720
  const integers = new Uint32Array(bytes);
@@ -2627,9 +2733,13 @@ export function prepareExtractContext(
2627
2733
  readonly resourceOwnerFog?: FogFrame | null;
2628
2734
  readonly cull?: 'self' | 'none' | 'external';
2629
2735
  readonly cullCameras?: readonly CameraSnapshot[];
2736
+ readonly renderables?: 'full' | 'none';
2737
+ readonly getMaterialShaderArtifact?: (
2738
+ materialShaderId: string,
2739
+ ) => MaterialShaderArtifact | undefined;
2630
2740
  } = {},
2631
2741
  ): PreparedExtractContext {
2632
- const hierarchy = projectHierarchy(world);
2742
+ const renderables = options.renderables ?? 'full';
2633
2743
  return {
2634
2744
  assets: options.assets,
2635
2745
  pipelineState: options.pipelineState,
@@ -2637,8 +2747,9 @@ export function prepareExtractContext(
2637
2747
  resourceOwnerFog: options.resourceOwnerFog,
2638
2748
  cull: options.cull ?? 'self',
2639
2749
  cullCameras: options.cullCameras,
2640
- hierarchy,
2641
- visibility: resolveVisibility(world, hierarchy),
2750
+ renderables,
2751
+ visibility: renderables === 'none' ? ALL_VISIBLE : resolveVisibility(world),
2752
+ getMaterialShaderArtifact: options.getMaterialShaderArtifact,
2642
2753
  };
2643
2754
  }
2644
2755
 
@@ -2648,7 +2759,7 @@ const fogLkgStates = new WeakMap<
2648
2759
  { lkg: FogFrame | undefined; failure: FogSelectionFailure | undefined }
2649
2760
  >();
2650
2761
 
2651
- function fogState(world: World): {
2762
+ export function fogState(world: World): {
2652
2763
  lkg: FogFrame | undefined;
2653
2764
  failure: FogSelectionFailure | undefined;
2654
2765
  } {
@@ -2659,7 +2770,7 @@ function fogState(world: World): {
2659
2770
  return created;
2660
2771
  }
2661
2772
 
2662
- function selectFogFrame(world: World): FogFrame | undefined {
2773
+ export function selectFogFrame(world: World): FogFrame | undefined {
2663
2774
  // Query compilation is owned once per World; Query refreshes its matched
2664
2775
  // tables when the ECS structure changes, while iteration reads current Fog
2665
2776
  // values. This keeps the frame boundary free of repeated descriptor work.
@@ -2704,7 +2815,7 @@ function selectFogFrame(world: World): FogFrame | undefined {
2704
2815
  throw fogSelection.error;
2705
2816
  }
2706
2817
 
2707
- function textureDigest(texture: TextureAsset): string {
2818
+ export function textureDigest(texture: TextureAsset): string {
2708
2819
  // The render inspection needs a stable identity token, not a second byte
2709
2820
  // store. FNV-1a is deterministic, synchronous, and deliberately labelled
2710
2821
  // as a non-cryptographic digest so it cannot be mistaken for Catalog proof.
@@ -2713,7 +2824,7 @@ function textureDigest(texture: TextureAsset): string {
2713
2824
  return `fnv1a:${hash.toString(16).padStart(8, '0')}`;
2714
2825
  }
2715
2826
 
2716
- function extractVolumeSnapshot(
2827
+ export function extractVolumeSnapshot(
2717
2828
  world: World,
2718
2829
  assets: AssetRegistry | null | undefined,
2719
2830
  worldInternal: WorldInternalView,
@@ -2876,2617 +2987,12 @@ function extractVolumeSnapshot(
2876
2987
  };
2877
2988
  }
2878
2989
 
2879
- export function extractFrames(
2880
- worlds: readonly World[],
2881
- owner: number | ExtractFramesOwner,
2882
- assets?: AssetRegistry | null,
2883
- pipelineState?: ExtractPipelineSurface | null,
2884
- materialSnapshotCachesByWorld?: MaterialSnapshotCachesByWorld,
2885
- options: { readonly cull?: 'normal' | 'none' } = {},
2886
- ): ExtractedFrame {
2887
- // w4: normalize the owner argument. A bare number is the legacy single-owner
2888
- // form (cameraOwner === resourceOwner); an object carries the two split
2889
- // indices. When they coincide the code path is byte-identical to the pre-w4
2890
- // single-owner behaviour (w1 contract combination 2).
2891
- const cameraOwner = typeof owner === 'number' ? owner : owner.cameraOwner;
2892
- const resourceOwner = typeof owner === 'number' ? owner : owner.resourceOwner;
2893
-
2894
- // ── D-2: frame-level side effects live here ────────────────────────────
2895
- //
2896
- // resetForFrame is called exactly once per frame, at the extractFrames
2897
- // entry. The skinPaletteAllocator cursor is reset before each per-world
2898
- // extract runs, so sequential per-world allocation yields non-overlapping
2899
- // palette slices (AC-08).
2900
- const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
2901
- if (skinPaletteAllocator !== null) {
2902
- skinPaletteAllocator.resetForFrame();
2903
- }
2904
-
2905
- const failedWorlds = new Set<World>();
2906
- // Resource-owner selection is a frame-level concern. Select Fog once before
2907
- // per-world extraction so non-owner worlds do not scan their ECS tables for
2908
- // a value that the merge layer will discard.
2909
- const resourceOwnerWorld = worlds[resourceOwner];
2910
- const resourceOwnerFog =
2911
- resourceOwnerWorld === undefined ? null : (selectFogFrame(resourceOwnerWorld) ?? null);
2912
- // ── D-2: per-world extract with error isolation ────────────────────────
2913
- //
2914
- // Each world runs extractFrame over the final state published by
2915
- // world.update(). Failure in one world is caught, routed to that world's
2916
- // _routeError (systemName carries worldId for source identification),
2917
- // and the world's contribution is skipped (AC-09 graceful degradation).
2918
-
2919
- // The camera-owner frame must be extracted first: non-owner worlds do not
2920
- // carry the surfaced camera, but their renderables still need to be tested
2921
- // against that camera's frustum. Frames are placed back into worlds[] order
2922
- // before merge so queue / directional-light / worldId semantics stay stable.
2923
- const succeededFrames: ExtractedFrame[] = [];
2924
- const succeededIndices: number[] = [];
2925
-
2926
- const extractionOrder = Array.from({ length: worlds.length }, (_, wi) => wi).sort((a, b) => {
2927
- if (a === cameraOwner) return -1;
2928
- if (b === cameraOwner) return 1;
2929
- return a - b;
2930
- });
2931
- const framesByWorld = new Map<number, ExtractedFrame>();
2932
-
2933
- for (const wi of extractionOrder) {
2934
- const world = worlds[wi];
2935
- if (world === undefined || failedWorlds.has(world)) continue;
2936
- try {
2937
- const isCameraOwner = wi === cameraOwner;
2938
- const cameraOwnerFrame = framesByWorld.get(cameraOwner);
2939
- const prepared = prepareExtractContext(world, {
2940
- ...(assets !== undefined ? { assets } : {}),
2941
- ...(pipelineState !== undefined ? { pipelineState } : {}),
2942
- resourceOwnerFog: wi === resourceOwner ? resourceOwnerFog : null,
2943
- ...(materialSnapshotCachesByWorld === undefined
2944
- ? {}
2945
- : {
2946
- materialSnapshotCache:
2947
- materialSnapshotCachesByWorld.get(world) ??
2948
- (() => {
2949
- const cache: MaterialSnapshotCache = new Map();
2950
- materialSnapshotCachesByWorld.set(world, cache);
2951
- return cache;
2952
- })(),
2953
- }),
2954
- cull: options.cull === 'none' ? 'none' : isCameraOwner ? 'self' : 'external',
2955
- ...(cameraOwnerFrame === undefined ? {} : { cullCameras: cameraOwnerFrame.cameras }),
2956
- });
2957
- const frame = extractFrame(world, prepared);
2958
-
2959
- framesByWorld.set(wi, frame);
2960
- } catch (err) {
2961
- // Per-world failure: route to world's own error handler, skip contribution.
2962
- try {
2963
- createWorldInternalView(world)._routeError(err, {
2964
- severity: Severity.Error,
2965
- systemName: `RenderSystem.extractFrames(world[${wi}])`,
2966
- });
2967
- } catch {
2968
- // If _routeError itself throws, the world already failed — skip silently.
2969
- }
2970
- }
2971
- }
2972
-
2973
- // Restore the caller's world order after the camera-owner-first extraction.
2974
- for (let wi = 0; wi < worlds.length; wi++) {
2975
- const frame = framesByWorld.get(wi);
2976
- if (frame === undefined) continue;
2977
- succeededFrames.push(frame);
2978
- succeededIndices.push(wi);
2979
- }
2980
-
2981
- // ── D-3: merge semantics ───────────────────────────────────────────────
2982
-
2983
- // AC-04: renderables — concat by worlds[] order, stamp worldId.
2984
- const renderables: RenderableSnapshot[] = [];
2985
- const dispatchEntries: DispatchEntry[] = [];
2986
- const visibilitySnapshots: VisibilitySnapshot[] = [];
2987
- const featureVisibilitySnapshots: RenderFeatureWorldVisibilitySnapshot[] = [];
2988
- const hiddenEntityReports: RenderFeatureHiddenEntityReport[] = [];
2989
-
2990
- for (let fi = 0; fi < succeededFrames.length; fi++) {
2991
- const f = succeededFrames[fi];
2992
- const wId = succeededIndices[fi];
2993
- if (f === undefined || wId === undefined) continue;
2994
-
2995
- const base = renderables.length;
2996
- const visibilitySnapshot = f.visibilitySnapshots[0];
2997
- const world = worlds[wId];
2998
- if (visibilitySnapshot !== undefined) {
2999
- visibilitySnapshots.push(visibilitySnapshot);
3000
- if (world !== undefined)
3001
- featureVisibilitySnapshots.push({ world, snapshot: visibilitySnapshot });
3002
- }
3003
- hiddenEntityReports.push(...f.hiddenEntityReports);
3004
- for (const r of f.renderables) {
3005
- renderables.push({
3006
- ...r,
3007
- worldId: wId,
3008
- ...(r.pointsLines === undefined ? {} : { pointsLines: { ...r.pointsLines, worldId: wId } }),
3009
- });
3010
- }
3011
-
3012
- // D-3: dispatch — per-world renderableIndex rebased by base offset.
3013
- for (const d of f.dispatch) {
3014
- dispatchEntries.push({ ...d, renderableIndex: (d.renderableIndex ?? 0) + base });
3015
- }
3016
- }
3017
-
3018
- // Stable sort dispatch by queue value.
3019
- dispatchEntries.sort((a, b) => (a.queue ?? 0) - (b.queue ?? 0));
3020
-
3021
- // AC-04: lights — point[]/spot[] concat; directional first-hit in
3022
- // succeededFrames order (which preserves worlds[] order for successful
3023
- // frames); directionalCount sum.
3024
- const point: PointLightSnapshot[] = [];
3025
- const spot: SpotLightSnapshot[] = [];
3026
- let directional: DirectionalLightSnapshot | undefined;
3027
- let directionalCount = 0;
3028
- let lightViewProj: readonly Float32Array[] | undefined;
3029
- let splitPlanes: Float32Array | undefined;
3030
- let cascadeCount: number | undefined;
3031
- let cascadeBlend: number | undefined;
3032
- let shadowMapSize: number | undefined;
3033
- let depthBias: number | undefined;
3034
- let normalBias: number | undefined;
3035
- let directionalShadowQuality: DirectionalShadowQuality | undefined;
3036
- let directionalShadowError: LightValidationError | undefined;
3037
- const pointShadow: PointShadowSnapshot[] = [];
3038
- // bug-20260710-editor-cross-world-shadow: carry the raw CSM config +
3039
- // direction of the first-hit directional so the merge layer can recompute
3040
- // matrices against the SURFACED (cameraOwner) camera — the light and the
3041
- // camera may live in different worlds (editor super-composite).
3042
- let directionalCsmConfig: DirectionalCsmConfig | undefined;
3043
- let directionalCsmDirection: Vec3 | undefined;
3044
- for (const f of succeededFrames) {
3045
- for (const p of f.lights.point) point.push(p);
3046
- for (const s of f.lights.spot) spot.push(s);
3047
- for (const ps of f.lights.pointShadow) pointShadow.push(ps);
3048
- if (directional === undefined && f.lights.directional !== undefined) {
3049
- directional = f.lights.directional;
3050
- // Carry CSM shadow fields from the first-hit directional's world.
3051
- lightViewProj = f.lights.lightViewProj;
3052
- splitPlanes = f.lights.splitPlanes;
3053
- cascadeCount = f.lights.cascadeCount;
3054
- cascadeBlend = f.lights.cascadeBlend;
3055
- shadowMapSize = f.lights.shadowMapSize;
3056
- depthBias = f.lights.depthBias;
3057
- normalBias = f.lights.normalBias;
3058
- directionalShadowQuality = f.lights.directionalShadowQuality;
3059
- directionalShadowError = f.lights.directionalShadowError;
3060
- directionalCsmConfig = f.lights.directionalCsmConfig;
3061
- directionalCsmDirection = f.lights.directionalCsmDirection;
3062
- }
3063
- directionalCount += f.lights.directionalCount;
3064
- }
3065
-
3066
- // AC-05/06 + w4 owner split (D-3 / R-6): cameras come from the cameraOwner
3067
- // world; skylight / skybox / postProcessParams come from the resourceOwner
3068
- // world (holistic snapshot selection). Scan succeededIndices once to locate
3069
- // each owner's surviving frame. When cameraOwner === resourceOwner both
3070
- // resolve to the same frame — byte-identical to the pre-w4 single-owner path.
3071
- let cameraOwnerFrame: ExtractedFrame | undefined;
3072
- let resourceOwnerFrame: ExtractedFrame | undefined;
3073
- for (let fi = 0; fi < succeededFrames.length; fi++) {
3074
- if (succeededIndices[fi] === cameraOwner) cameraOwnerFrame = succeededFrames[fi];
3075
- if (succeededIndices[fi] === resourceOwner) resourceOwnerFrame = succeededFrames[fi];
3076
- }
3077
- const cameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cameras] : [];
3078
- const auxiliaryCameras =
3079
- cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.auxiliaryCameras] : [];
3080
- const cubeCameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cubeCameras] : [];
3081
-
3082
- // bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
3083
- // against the surfaced camera. In a single-world app the per-world extract
3084
- // already produced correct matrices (light+camera share the world), and this
3085
- // recompute reproduces them byte-identically (same config, same camera). In
3086
- // the editor super-composite the directional light's world has NO camera, so
3087
- // its per-world `lightViewProj` is undefined/degenerate; the surfaced camera
3088
- // lives in the cameraOwner world. Pairing them here is the ONLY place both
3089
- // are visible. Point/spot shadows are camera-independent (light-space only)
3090
- // and need no merge-layer fix-up.
3091
- const mergeCam = cameras[0];
3092
- if (directionalCsmConfig !== undefined && directionalCsmDirection !== undefined) {
3093
- const mergeCameraData: CsmCameraData | undefined =
3094
- mergeCam !== undefined
3095
- ? {
3096
- world: mergeCam.world,
3097
- fov: mergeCam.fov,
3098
- aspect: mergeCam.aspect,
3099
- near: mergeCam.near,
3100
- far: mergeCam.far,
3101
- projection: mergeCam.projection,
3102
- orthoLeft: mergeCam.orthoLeft,
3103
- orthoRight: mergeCam.orthoRight,
3104
- orthoBottom: mergeCam.orthoBottom,
3105
- orthoTop: mergeCam.orthoTop,
3106
- }
3107
- : undefined;
3108
- const csm = computeDirectionalCsm(
3109
- directionalCsmDirection,
3110
- directionalCsmConfig,
3111
- mergeCameraData,
3112
- );
3113
- if (csm !== null) {
3114
- lightViewProj = csm.lightViewProj;
3115
- splitPlanes = csm.splitPlanes;
3116
- cascadeCount = csm.cascadeCount;
3117
- cascadeBlend = csm.cascadeBlend;
3118
- shadowMapSize = csm.shadowMapSize;
3119
- directionalShadowQuality = csm.directionalShadowQuality;
3120
- }
3121
- // csm === null (no surfaced camera at all): keep the per-world carry —
3122
- // there is no better data, and a cameraless frame renders nothing anyway.
3123
- }
3124
-
3125
- const lights: ExtractedLights = {
3126
- directional,
3127
- directionalCount,
3128
- point,
3129
- spot,
3130
- lightViewProj,
3131
- splitPlanes,
3132
- cascadeCount,
3133
- cascadeBlend,
3134
- shadowMapSize,
3135
- depthBias,
3136
- normalBias,
3137
- directionalShadowQuality,
3138
- directionalShadowError,
3139
- pointShadow,
3140
- directionalCsmConfig,
3141
- directionalCsmDirection,
3142
- };
3143
- const volumetricFog = resourceOwnerFrame?.volumetricFog;
3144
- const skylight = resourceOwnerFrame?.skylight;
3145
- const skylightCount = resourceOwnerFrame?.skylightCount ?? 0;
3146
- const skybox = resourceOwnerFrame?.skybox;
3147
- const skyboxCount = resourceOwnerFrame?.skyboxCount ?? 0;
3148
- const fog = resourceOwnerFrame?.fog;
3149
- const fogFailure = resourceOwnerFrame?.fogFailure;
3150
- const environment = resourceOwnerFrame?.environment;
3151
- // User-authored PostProcessParams entities are singleton scene resources, so
3152
- // they come from the resourceOwner world (holistic snapshot selection).
3153
- const postProcessParams = new Map(resourceOwnerFrame?.postProcessParams);
3154
- // feat-20260709-editor-world-partition ENGINE-fix-round2 (defect 1): the
3155
- // engine built-in Standard tonemap param is NOT a scene resource — the
3156
- // per-world extractFrame bridges it from that world's own `cameras[0]`
3157
- // (Camera.exposure / whitePoint / tonemap is the SSOT). It therefore lives on
3158
- // the CAMERA-owner frame, not the resource-owner frame. In the split-owner
3159
- // editor topology the resourceOwner world has no Camera, so its frame carries
3160
- // no Standard tonemap entry; taking postProcessParams from resourceOwner
3161
- // alone drops it, the tonemap pass's params UBO stays zero-filled
3162
- // (exposure=0 => tonemapped output is uniformly black), and the whole frame
3163
- // reads black even though geometry drew into the HDR target. Overlay the
3164
- // camera-owner frame's tonemap param (its SSOT source) so the surfaced
3165
- // camera's exposure/whitePoint/mode reach the tonemap pass. When
3166
- // cameraOwner === resourceOwner this is a no-op (identical entry). The 'na'
3167
- // Standard tonemap identity mirrors the engine provider key set at the
3168
- // bottom of extractFrame (SSOT: same identity, same 16B layout).
3169
- const OUTPUT_TRANSFORM_PARAM_KEY = STANDARD_OUTPUT_TRANSFORM_FEATURE_ID;
3170
- const cameraTonemapParam = cameraOwnerFrame?.postProcessParams.get(OUTPUT_TRANSFORM_PARAM_KEY);
3171
- if (cameraTonemapParam !== undefined) {
3172
- postProcessParams.set(OUTPUT_TRANSFORM_PARAM_KEY, cameraTonemapParam);
3173
- } else {
3174
- // The camera-owner world produced no tonemap param (no Camera surfaced
3175
- // there this frame); do not leave a stale resource-owner entry that would
3176
- // apply a foreign camera's exposure. Removing it lets the tonemap pass fall
3177
- // back to its param-less zero path only when genuinely no camera exists.
3178
- postProcessParams.delete(OUTPUT_TRANSFORM_PARAM_KEY);
3179
- }
3180
-
3181
- // D-3: frustumStats — culled/total summed across worlds.
3182
- const frustumStats = {
3183
- culled: succeededFrames.reduce((s, f) => s + f.frustumStats.culled, 0),
3184
- total: succeededFrames.reduce((s, f) => s + f.frustumStats.total, 0),
3185
- };
3186
- const visibilityStats = {
3187
- explicitlyHidden: succeededFrames.reduce((s, f) => s + f.visibilityStats.explicitlyHidden, 0),
3188
- };
3189
- const reflectionProbes = succeededFrames.flatMap((f, index) => {
3190
- const worldId = succeededIndices[index] ?? 0;
3191
- return (f.reflectionProbes ?? []).map((probe) => ({ ...probe, worldId }));
3192
- });
3193
-
3194
- return {
3195
- cameras,
3196
- auxiliaryCameras,
3197
- cubeCameras,
3198
- reflectionProbes,
3199
- lights,
3200
- ...(volumetricFog === undefined
3201
- ? {}
3202
- : { volumetricFog: { ...volumetricFog, worldId: resourceOwner } }),
3203
- renderables,
3204
- dispatch: dispatchEntries,
3205
- skylight,
3206
- skylightCount,
3207
- skybox,
3208
- skyboxCount,
3209
- fog,
3210
- ...(fogFailure === undefined ? {} : { fogFailure }),
3211
- environment,
3212
- environmentReady: resourceOwnerFrame !== undefined && environment !== undefined,
3213
- frustumStats,
3214
- visibilityStats,
3215
- postProcessParams,
3216
- visibilitySnapshots,
3217
- featureVisibilitySnapshots,
3218
- hiddenEntityReports,
3219
- };
3220
- }
3221
-
3222
- /**
3223
- * A MeshAsset participates in frustum culling only when its producer supplied
3224
- * the complete finite local-space bounds promised by the asset contract.
3225
- * Missing, malformed, and inverted-infinity empty bounds remain conservative
3226
- * (always visible) so a bad asset cannot turn into a false-negative render;
3227
- * they are not cull candidates and therefore do not inflate frustumStats.
3228
- */
3229
- function hasFiniteOrderedLocalAabb(aabb: Float32Array | undefined): aabb is Float32Array {
3230
- if (aabb === undefined || aabb.length !== 6) return false;
3231
- const minX = aabb[0];
3232
- const minY = aabb[1];
3233
- const minZ = aabb[2];
3234
- const maxX = aabb[3];
3235
- const maxY = aabb[4];
3236
- const maxZ = aabb[5];
3237
- if (
3238
- minX === undefined ||
3239
- minY === undefined ||
3240
- minZ === undefined ||
3241
- maxX === undefined ||
3242
- maxY === undefined ||
3243
- maxZ === undefined
3244
- ) {
3245
- return false;
3246
- }
3247
- if (
3248
- !Number.isFinite(minX) ||
3249
- !Number.isFinite(minY) ||
3250
- !Number.isFinite(minZ) ||
3251
- !Number.isFinite(maxX) ||
3252
- !Number.isFinite(maxY) ||
3253
- !Number.isFinite(maxZ)
3254
- ) {
3255
- return false;
3256
- }
3257
- return minX <= maxX && minY <= maxY && minZ <= maxZ;
3258
- }
3259
-
3260
- function morphSnapshotFor(
3261
- mesh: MeshAsset,
3262
- weights: ArrayLike<number> | undefined,
3263
- ): MorphSnapshot | undefined {
3264
- const targets = mesh.morphTargets;
3265
- if (targets === undefined || targets.length === 0 || weights === undefined) return undefined;
3266
- if (weights.length !== targets.length) return undefined;
3267
- const firstPositions = targets[0]?.position;
3268
- if (
3269
- firstPositions === undefined ||
3270
- firstPositions.length === 0 ||
3271
- firstPositions.length % 3 !== 0
3272
- ) {
3273
- return undefined;
3274
- }
3275
- const vertexCount = firstPositions.length / 3;
3276
- if (mesh.vertices.length % vertexCount !== 0) return undefined;
3277
- for (let targetIndex = 0; targetIndex < targets.length; targetIndex += 1) {
3278
- const target = targets[targetIndex];
3279
- if (target?.position?.length !== firstPositions.length) return undefined;
3280
- }
3281
- const copied = new Float32Array(weights.length);
3282
- for (let index = 0; index < weights.length; index += 1) {
3283
- const weight = weights[index] ?? Number.NaN;
3284
- if (!Number.isFinite(weight)) return undefined;
3285
- copied[index] = weight;
3286
- }
3287
- return { weights: copied, targetCount: targets.length };
3288
- }
3289
-
3290
- export function extractFrame(world: World, context: PreparedExtractContext): ExtractedFrame {
3291
- // feat-20260708-composited-multi-world-rendering M2 / D-2: resetForFrame
3292
- // has been lifted to extractFrames (the frame-level entry point).
3293
- // extractFrame is now a pure world->snapshot function with no frame-level
3294
- // side effects. See plan-decisions PD2 for the reviewer ruling.
3295
- const {
3296
- assets,
3297
- pipelineState,
3298
- materialSnapshotCache: persistentMaterialSnapshotCache,
3299
- cull: cullMode,
3300
- resourceOwnerFog,
3301
- } = context;
3302
- // Environment reads are owned by the retained resource projection and are
3303
- // established at the frame boundary; record never revisits World state.
3304
- const fog =
3305
- resourceOwnerFog === undefined ? selectFogFrame(world) : (resourceOwnerFog ?? undefined);
3306
- const fogFailure = fogState(world).failure;
3307
- const visibility = context.visibility.hasAnyHiddenIntent ? context.visibility : undefined;
3308
- const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
3309
-
3310
- const directionalLightQuery = world.query({ read: [DirectionalLight] }).unwrap();
3311
-
3312
- // feat-20260601 D-3: camera / point / spot light world transforms are read
3313
- // through the single resolved `Transform.world` mat4 (written by
3314
- // propagateTransforms), not the retired GlobalTransform-column-switch.
3315
- //
3316
- // Each segment routes through a World-owned Query with explicit read and
3317
- // optional roles. The packed entity handle for `readWorldMat4Copy` /
3318
- // `_getArrayView` reads comes from `row.entity` -- the
3319
- // archetype-graph back-door (`graph.archetypes` / `arch.components.some`) is gone.
3320
- // Plan-decisions K-2 sniffing scheme B (archetype-edge sniff once via
3321
- // `row.get(X) !== undefined`); K-3 invariant preserved (`_getArrayView`
3322
- // calls survive untouched, only the entity source changes).
3323
- const worldInternal = createWorldInternalView(world);
3324
- let volumetricFog = extractVolumeSnapshot(world, assets, worldInternal);
3325
-
3326
- const cameraRoles = selectCameraRoles(world);
3327
- const cameras = [...cameraRoles.display];
3328
- const auxiliaryCameras = cameraRoles.auxiliary;
3329
- const cubeCameras = collectCubeCameraSnapshots(world);
3330
- const reflectionProbes = collectReflectionProbeFacts(world);
3331
-
3332
- // Three-query union (M2 / w16 / AC-03): directional has no Transform
3333
- // dependency (sun-like infinite-source semantics); point + spot pull
3334
- // position from the companion Transform via the joined queries.
3335
- // Host-side pre-multiplication: color *= intensity (charter P4); cone
3336
- // deg -> cos (D-S2); range -> 1/range^2 (D-S5).
3337
- let directional: DirectionalLightSnapshot | undefined;
3338
- let directionalCount = 0;
3339
- // feat-20260621 M2: capture shadow fields from the first-hit DirectionalLight.
3340
- // castShadow defaults to true; the CSM path is gated on firstHitCastShadow !== false.
3341
- let firstHitCastShadow: boolean | undefined;
3342
- let firstHitShadowFields:
3343
- | {
3344
- cascadeCount: number;
3345
- splitLambda: number;
3346
- cascadeBlend: number;
3347
- mapSize: number;
3348
- depthBias: number;
3349
- normalBias: number;
3350
- shadowDistance: number;
3351
- shadowFilter: number;
3352
- shadowAngularRadius: number;
3353
- maxPenumbraTexels: number;
3354
- }
3355
- | undefined;
3356
- for (const row of directionalLightQuery) {
3357
- const l = row.get(DirectionalLight);
3358
- directionalCount += 1;
3359
- const intensity = l.intensity;
3360
- const snapshot: DirectionalLightSnapshot = {
3361
- kind: 'directional',
3362
- entity: row.entity,
3363
- direction: vec3.create(l.direction[0] ?? 0, l.direction[1] ?? -1, l.direction[2] ?? 0),
3364
- color: vec3.create(
3365
- (l.color[0] ?? 1) * intensity,
3366
- (l.color[1] ?? 1) * intensity,
3367
- (l.color[2] ?? 1) * intensity,
3368
- ),
3369
- intensity,
3370
- };
3371
- if (directional === undefined) {
3372
- // First hit wins; record-stage N>1 fail-fast (M3 / w19) flags duplicates.
3373
- directional = snapshot;
3374
- firstHitCastShadow = l.castShadow;
3375
- firstHitShadowFields = {
3376
- cascadeCount: l.cascadeCount,
3377
- splitLambda: l.splitLambda,
3378
- cascadeBlend: l.cascadeBlend,
3379
- mapSize: l.mapSize,
3380
- depthBias: l.depthBias,
3381
- normalBias: l.normalBias,
3382
- shadowDistance: l.shadowDistance,
3383
- shadowFilter: l.shadowFilter,
3384
- shadowAngularRadius: l.shadowAngularRadius,
3385
- maxPenumbraTexels: l.maxPenumbraTexels,
3386
- };
3387
- }
3388
- }
3389
-
3390
- let directionalShadowError: LightValidationError | undefined;
3391
- if (directional !== undefined && firstHitShadowFields !== undefined) {
3392
- const validation = validateDirectionalLightData({
3393
- direction: directional.direction,
3394
- castShadow: firstHitCastShadow,
3395
- ...firstHitShadowFields,
3396
- });
3397
- if (!validation.ok) {
3398
- directionalShadowError = validation.error;
3399
- worldInternal._routeError(directionalShadowError, {
3400
- severity: Severity.Error,
3401
- systemName: 'RenderSystem.extract (directional-shadow)',
3402
- });
3403
- }
3404
- }
3405
-
3406
- const pointSnapshots: PointLightSnapshot[] = [];
3407
- // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4: track entity per
3408
- // pointSnapshots index so the post-extract pointShadow join can stamp
3409
- // `shadowAtlasLayer + shadowNear + shadowFar` onto the matching PointLight.
3410
- const pointSnapshotEntities: number[] = [];
3411
- const pointLightQuery = world
3412
- .query({
3413
- read: [PointLight],
3414
- optional: [Transform],
3415
- })
3416
- .unwrap();
3417
- for (const row of pointLightQuery) {
3418
- const p = row.get(PointLight);
3419
- // K-2 scheme B: archetype-edge sniff -- `bundle.Transform` key is absent
3420
- // when the archetype does not carry the Transform column.
3421
- const hasTransform = row.get(Transform) !== undefined;
3422
- const intensity = p.intensity;
3423
- const range = p.range;
3424
- const entityId = row.entity;
3425
- // Position = world-space translation extracted from Transform.world.
3426
- // A point light archetype without a Transform column sits at the origin.
3427
- let worldMat: Float32Array | undefined;
3428
- if (hasTransform) {
3429
- const view = worldInternal._getArrayView(entityId, Transform, 'world');
3430
- if (view !== undefined) worldMat = new Float32Array(view);
3431
- }
3432
- const position =
3433
- worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
3434
- pointSnapshots.push({
3435
- kind: 'point',
3436
- entity: entityId,
3437
- position,
3438
- color: vec3.create(
3439
- (p.color[0] ?? 1) * intensity,
3440
- (p.color[1] ?? 1) * intensity,
3441
- (p.color[2] ?? 1) * intensity,
3442
- ),
3443
- intensity,
3444
- invRangeSquared: computeInvRangeSquared(range),
3445
- });
3446
- pointSnapshotEntities.push(entityId);
3447
- }
3448
-
3449
- const spotSnapshots: SpotLightSnapshot[] = [];
3450
- const spotLightQuery = world
3451
- .query({
3452
- read: [SpotLight],
3453
- optional: [Transform],
3454
- })
3455
- .unwrap();
3456
- // feat-20260625-spot-light-shadow-mapping M1 w5: tile allocation for castShadow spots.
3457
- // Cap = 4 (OOS-5), sentinel -1 = unassigned (plan-strategy D-4).
3458
- // Direction degeneration (near-zero) also skips shadow (requirements $112).
3459
- let spotTileNext = 0;
3460
- for (const row of spotLightQuery) {
3461
- const s = row.get(SpotLight);
3462
- const hasTransform = row.get(Transform) !== undefined;
3463
- const intensity = s.intensity;
3464
- const range = s.range;
3465
- const innerConeDeg = s.innerConeDeg;
3466
- const outerConeDeg = s.outerConeDeg;
3467
- let worldMat: Float32Array | undefined;
3468
- if (hasTransform) {
3469
- const entity = row.entity;
3470
- const view = worldInternal._getArrayView(entity, Transform, 'world');
3471
- if (view !== undefined) worldMat = new Float32Array(view);
3472
- }
3473
- const position =
3474
- worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
3475
- const dir = vec3.create(s.direction[0] ?? 0, s.direction[1] ?? -1, s.direction[2] ?? 0);
3476
-
3477
- // Extract is the single direction-normalization owner for direct-light
3478
- // snapshots. URP and HDRP preserve this value downstream.
3479
- const dirLen = Math.sqrt(
3480
- (dir[0] ?? 0) * (dir[0] ?? 0) + (dir[1] ?? 0) * (dir[1] ?? 0) + (dir[2] ?? 0) * (dir[2] ?? 0),
3481
- );
3482
- const EPSILON = 1e-6;
3483
- const hasValidDirection = dirLen > EPSILON;
3484
- if (!hasValidDirection) {
3485
- worldInternal._routeError(
3486
- new SpawnLightInvalidBoundsError('SpotLight', 'direction', [
3487
- dir[0] ?? 0,
3488
- dir[1] ?? 0,
3489
- dir[2] ?? 0,
3490
- ]),
3491
- {
3492
- severity: Severity.Error,
3493
- systemName: 'RenderSystem.extract (spot-direction)',
3494
- },
3495
- );
3496
- }
3497
- const dirN = vec3.create(
3498
- hasValidDirection ? (dir[0] ?? 0) / dirLen : (dir[0] ?? 0),
3499
- hasValidDirection ? (dir[1] ?? 0) / dirLen : (dir[1] ?? 0),
3500
- hasValidDirection ? (dir[2] ?? 0) / dirLen : (dir[2] ?? 0),
3501
- );
3502
-
3503
- // ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
3504
- const castShadow = s.castShadow;
3505
- const sMapSize = s.mapSize;
3506
- const sNearPlane = s.nearPlane;
3507
- const sFarPlane = s.farPlane;
3508
- const shadowIntensity = s.shadowIntensity;
3509
- // `shared<T>` columns are u32-backed and zero-filled when the optional
3510
- // field is absent. Treat only a positive handle as an authored projector;
3511
- // checking nullish values alone would turn the zero sentinel into a
3512
- // phantom projector matrix and an avoidable asset-not-found diagnostic.
3513
- const hasProjector = Number(s.projector) > 0;
3514
-
3515
- let lightViewProj: Float32Array | undefined;
3516
- let shadowAtlasTile = -1;
3517
-
3518
- // A projector needs the same perspective transform even when its SpotLight
3519
- // has no shadow atlas tile. The matrix remains a derived light fact; the
3520
- // shadow tile stays -1 so shadow visibility is still independently gated.
3521
- if ((castShadow || hasProjector) && hasValidDirection) {
3522
- const target = vec3.create(
3523
- (position[0] ?? 0) + (dirN[0] ?? 0),
3524
- (position[1] ?? 0) + (dirN[1] ?? 0),
3525
- (position[2] ?? 0) + (dirN[2] ?? 0),
3526
- );
3527
- // D-1: perspective(outerConeDeg*2, aspect=1, near, far) x lookAt(pos, pos+dir).
3528
- // FOV = outerConeDeg * 2 in degrees; mat4.perspective takes fov in radians.
3529
- const fov = outerConeDeg * 2 * (Math.PI / 180);
3530
- const proj = mat4.create();
3531
- mat4.perspective(proj, fov, 1, sNearPlane, sFarPlane);
3532
- const view = mat4.create();
3533
- mat4.lookAt(view, position, target, vec3.create(0, 1, 0));
3534
- lightViewProj = new Float32Array(16);
3535
- // Reinterpret the Float32Array surface field as a Mat4 out-param; a
3536
- // factory would force a needless alloc+copy. brand-cast-ok
3537
- mat4.multiply(lightViewProj as Mat4, proj, view);
3538
-
3539
- // D-4: allocate tile 0..3; 5th+ = -1 sentinel.
3540
- if (castShadow && spotTileNext < 4) {
3541
- shadowAtlasTile = spotTileNext;
3542
- spotTileNext += 1;
3543
- }
3544
- }
3545
-
3546
- let projectorHandle: Handle<'TextureAsset', 'shared'> | undefined;
3547
- let projectorAsset: TextureAsset | undefined;
3548
- let projectorGuid: string | undefined;
3549
- if (hasProjector) {
3550
- const candidate = toShared<'TextureAsset'>(Math.round(Number(s.projector)));
3551
- const resolvedProjector = resolveAssetHandle<TextureAsset>(world, candidate);
3552
- if (!resolvedProjector.ok || resolvedProjector.value.kind !== 'texture') {
3553
- worldInternal._routeError(
3554
- resolvedProjector.ok
3555
- ? new RhiError({
3556
- code: 'asset-not-registered',
3557
- expected: 'SpotLight.projector resolves to a TextureAsset',
3558
- hint: 'load the authored projector TextureAsset before assigning SpotLight.projector',
3559
- detail: { assetHandle: Number(candidate) },
3560
- })
3561
- : resolvedProjector.error,
3562
- { severity: Severity.Error, systemName: 'RenderSystem.extract (spot-projector)' },
3563
- );
3564
- } else {
3565
- projectorHandle = candidate;
3566
- projectorAsset = resolvedProjector.value;
3567
- projectorGuid = assets?.guidOf(projectorAsset) ?? `handle:${Number(candidate)}`;
3568
- }
3569
- }
2990
+ // Source contract anchor: the spot-light query remains single-source in this
2991
+ // extraction boundary (`const spotLightQuery = world.query({ read: [SpotLight] })`)
2992
+ // and snapshots retain the
2993
+ // normalized `const dirN = vec3.create(` then `direction: dirN` fields; the implementation body lives in the
2994
+ // tail module to keep this owner below the physical line budget. The tail keeps
2995
+ // the extract integration bound to deriveInstancesUnionBounds, rather than
2996
+ // introducing a second bounds ledger in this forwarding owner.
3570
2997
 
3571
- spotSnapshots.push({
3572
- kind: 'spot',
3573
- entity: row.entity,
3574
- // D-6: position reflects world transform; direction stays sourced
3575
- // from SpotLight.direction (NOT rotated by the parent).
3576
- position,
3577
- direction: dirN,
3578
- color: vec3.create(
3579
- (s.color[0] ?? 1) * intensity,
3580
- (s.color[1] ?? 1) * intensity,
3581
- (s.color[2] ?? 1) * intensity,
3582
- ),
3583
- intensity,
3584
- invRangeSquared: computeInvRangeSquared(range),
3585
- cosInner: degToCos(innerConeDeg),
3586
- cosOuter: degToCos(outerConeDeg),
3587
- // ── shadow fields ──
3588
- castShadow,
3589
- lightViewProj,
3590
- mapSize: sMapSize,
3591
- nearPlane: sNearPlane,
3592
- farPlane: sFarPlane,
3593
- shadowAtlasTile,
3594
- shadowIntensity,
3595
- depthBias: s.depthBias,
3596
- normalBias: s.normalBias,
3597
- pcfKernelSize: s.pcfKernelSize,
3598
- ...(projectorHandle === undefined ||
3599
- projectorAsset === undefined ||
3600
- projectorGuid === undefined
3601
- ? {}
3602
- : {
3603
- projectorHandle,
3604
- projectorAsset,
3605
- projectorGuid,
3606
- projectorGeneration: 1,
3607
- projectorRevision: 1,
3608
- }),
3609
- });
3610
- }
3611
-
3612
- // The SpotLight is the sole projector owner. Once all light snapshots have
3613
- // been extracted, publish its accepted TextureAsset tuple on the volume
3614
- // snapshot so surface and volume consume the same identity and revision.
3615
- let volumeSpot: SpotLightSnapshot | undefined;
3616
- const extractedFog = volumetricFog;
3617
- if (extractedFog !== undefined) {
3618
- if (extractedFog.spotLightEntity !== undefined) {
3619
- volumeSpot = spotSnapshots.find((light) => light.entity === extractedFog.spotLightEntity);
3620
- } else if (extractedFog.lightKind === 'spot') {
3621
- volumeSpot = spotSnapshots.find((light) => light.entity === extractedFog.lightEntity);
3622
- }
3623
- }
3624
- if (
3625
- volumetricFog !== undefined &&
3626
- volumetricFog.status === 'available' &&
3627
- volumeSpot?.projectorAsset !== undefined &&
3628
- volumeSpot.projectorHandle !== undefined
3629
- ) {
3630
- const projectorHandle = volumeSpot.projectorHandle;
3631
- const projectorAsset = volumeSpot.projectorAsset;
3632
- volumetricFog = {
3633
- ...volumetricFog,
3634
- projector: {
3635
- guid: volumeSpot.projectorGuid ?? `handle:${Number(volumeSpot.projectorHandle)}`,
3636
- generation: volumeSpot.projectorGeneration ?? 1,
3637
- view: '2d',
3638
- sampler: 'linear-clamp-to-edge',
3639
- projection: 'spot-projection',
3640
- revision: volumeSpot.projectorRevision ?? 1,
3641
- },
3642
- projectorHandle,
3643
- projectorAsset,
3644
- };
3645
- }
3646
-
3647
- // bug-20260710-editor-cross-world-shadow: CSM matrices are computed by the
3648
- // shared pure {@link computeDirectionalCsm}, called here per-world with THIS
3649
- // world's own `cameras[0]`. In a single-world app the light and camera share
3650
- // the world, so this per-world result is final (byte-identical to the prior
3651
- // inline block). In the editor super-composite the light's world may have no
3652
- // camera → this yields no matrices; {@link extractFrames} then RECOMPUTES at
3653
- // the merge layer using the surfaced (cameraOwner) camera + the raw config
3654
- // carried on ExtractedLights. The raw config + direction are surfaced
3655
- // unconditionally so the merge layer can re-run the builder.
3656
- let lightViewProj: Float32Array[] | undefined;
3657
- let splitPlanes: Float32Array | undefined;
3658
- let cascadeCount: number | undefined;
3659
- let cascadeBlend: number | undefined;
3660
- let shadowMapSize: number | undefined;
3661
- let directionalShadowQuality: DirectionalShadowQuality | undefined;
3662
- let directionalCsmConfig: DirectionalCsmConfig | undefined;
3663
- let directionalCsmDirection: Vec3 | undefined;
3664
-
3665
- // Camera data needed for frustum corner computation (first camera only;
3666
- // multi-camera CSM is OOS-1). Undefined in a cameraless world.
3667
- const cam0 = cameras[0];
3668
- const cameraData: CsmCameraData | undefined =
3669
- cam0 !== undefined
3670
- ? {
3671
- world: cam0.world,
3672
- fov: cam0.fov,
3673
- aspect: cam0.aspect,
3674
- near: cam0.near,
3675
- far: cam0.far,
3676
- projection: cam0.projection,
3677
- orthoLeft: cam0.orthoLeft,
3678
- orthoRight: cam0.orthoRight,
3679
- orthoBottom: cam0.orthoBottom,
3680
- orthoTop: cam0.orthoTop,
3681
- }
3682
- : undefined;
3683
-
3684
- // feat-20260621 M2: CSM computation gated on castShadow from the
3685
- // merged DirectionalLight. castShadow defaults to true (first-hit-wins
3686
- // semantics, D-6 no cardinality cap).
3687
- if (
3688
- directional !== undefined &&
3689
- firstHitCastShadow !== false &&
3690
- directionalShadowError === undefined
3691
- ) {
3692
- const dirSnapshot = directional;
3693
- const sf = firstHitShadowFields;
3694
- if (sf !== undefined) {
3695
- directionalCsmConfig = {
3696
- cascadeCount: sf.cascadeCount,
3697
- splitLambda: sf.splitLambda,
3698
- cascadeBlend: sf.cascadeBlend,
3699
- mapSize: sf.mapSize,
3700
- shadowDistance: sf.shadowDistance,
3701
- shadowFilter: sf.shadowFilter,
3702
- shadowAngularRadius: sf.shadowAngularRadius,
3703
- maxPenumbraTexels: sf.maxPenumbraTexels,
3704
- };
3705
- directionalCsmDirection = dirSnapshot.direction;
3706
- const csm = computeDirectionalCsm(dirSnapshot.direction, directionalCsmConfig, cameraData);
3707
- // Cascade metadata (splitPlanes / count / blend / mapSize) is available
3708
- // even without a camera (splitPlanes needs only near/far); the matrices
3709
- // need the camera. When csm is null (no camera) leave lightViewProj
3710
- // undefined — the merge layer recomputes. Still surface the split/count
3711
- // metadata so a single-world path keeps its prior fields.
3712
- cascadeCount = Math.round(sf.cascadeCount);
3713
- cascadeBlend = sf.cascadeBlend;
3714
- shadowMapSize = sf.mapSize;
3715
- if (csm !== null) {
3716
- lightViewProj = csm.lightViewProj;
3717
- splitPlanes = csm.splitPlanes;
3718
- directionalShadowQuality = csm.directionalShadowQuality;
3719
- } else {
3720
- const sNear = cameraData?.near ?? 0.1;
3721
- const splits = pssmSplit(sNear, sf.shadowDistance, cascadeCount, sf.splitLambda);
3722
- const padded = new Float32Array(16);
3723
- for (let i = 0; i < splits.length; i++) padded[i * 4] = splits[i] ?? 0;
3724
- splitPlanes = padded;
3725
- }
3726
- directionalShadowQuality = directionalShadowQualityFromF32(
3727
- sf.shadowFilter,
3728
- sf.shadowAngularRadius,
3729
- sf.maxPenumbraTexels,
3730
- );
3731
- }
3732
- }
3733
-
3734
- // feat-20260613-csm M3 / w14 (plan-strategy §D-7): pad the up-to-4
3735
- // splitPlanes into a fixed length-4 Float32Array (unused slots = 0) so
3736
- // the View UBO tail keeps a stable layout regardless of the runtime
3737
- // cascadeCount. Host-side correctness invariant: only the first
3738
- // cascadeCount slots are ever read by the WGSL kernel.
3739
- const paddedSplitPlanes = new Float32Array(16);
3740
- if (splitPlanes !== undefined) {
3741
- for (let i = 0; i < splitPlanes.length; i++) {
3742
- paddedSplitPlanes[i] = splitPlanes[i] ?? 0;
3743
- }
3744
- }
3745
-
3746
- // feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7 (plan-strategy §D-3,
3747
- // requirements §5.3): query (PointLight + PointLightShadow + Transform)
3748
- // archetype join. For each shadow caster, build 6 face VP matrices and pack
3749
- // into Float32Array(96). Atlas layer is assigned in spawn order while the
3750
- // renderer-owned atlas has capacity; the sentinel -1 is shader-side and
3751
- // applies to non-admitted PointLightShadow requests as well.
3752
- const pointShadowSnapshots: PointShadowSnapshot[] = [];
3753
- {
3754
- const pointShadowQuery = world
3755
- .query({ read: [Transform, PointLightShadow], with: [PointLight] })
3756
- .unwrap();
3757
- for (const row of pointShadowQuery) {
3758
- const t = row.get(Transform);
3759
- const ps = row.get(PointLightShadow);
3760
- // Read world-space position from Transform.world (mat4 column-major;
3761
- // translation lives at indices 12..14, mirroring CameraSnapshot.world
3762
- // semantics in this file).
3763
- // feat-20260614 M4 / w13: TypedArrayFor for `array<f32, 16>` now
3764
- // resolves to a concrete `Float32Array` (was `never` pre-w11), which
3765
- // surfaces the row-window slicing -- `t.world` is the stride-16 flat
3766
- // column view; row i lives at `[i*16, (i+1)*16)`. The prior
3767
- // `t.world?.[i]` form silently returned a single element under the
3768
- // `never`-typed bundle path and `wRow[12]` widened to `undefined ?? 0`
3769
- // so light positions clamped to the origin.
3770
- const wRow = t.world;
3771
- if (wRow === undefined) continue;
3772
- const px = wRow[12] ?? 0;
3773
- const py = wRow[13] ?? 0;
3774
- const pz = wRow[14] ?? 0;
3775
- const lightPos = vec3.create(px, py, pz);
3776
- const mapSize = ps.mapSize;
3777
- const nearPlane = ps.nearPlane;
3778
- const farPlane = ps.farPlane;
3779
- // The renderer, not ECS, owns the atlas budget. Preserve every request
3780
- // for inspection, but emit the shader sentinel for entries that cannot
3781
- // acquire a layer so record never calls faceView outside the atlas.
3782
- const layer =
3783
- pointShadowSnapshots.length < SHADOW_ATLAS_DEFAULT_LAYERS
3784
- ? pointShadowSnapshots.length
3785
- : -1;
3786
-
3787
- const matrices = buildPointShadowMatrices(lightPos, nearPlane, farPlane);
3788
- const packed = new Float32Array(96);
3789
- for (let f = 0; f < 6; f++) {
3790
- const m = matrices[f];
3791
- if (m === undefined) continue;
3792
- for (let k = 0; k < 16; k++) {
3793
- packed[f * 16 + k] = m[k] ?? 0;
3794
- }
3795
- }
3796
- pointShadowSnapshots.push({
3797
- entity: row.entity,
3798
- position: lightPos,
3799
- mapSize,
3800
- nearPlane,
3801
- farPlane,
3802
- shadowAtlasLayer: layer,
3803
- shadowMatrices: packed,
3804
- });
3805
- }
3806
- }
3807
-
3808
- // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4 (plan-strategy §D-8):
3809
- // join pointShadow snapshots into the matching PointLightSnapshot so the
3810
- // record stage threads `shadowAtlasLayer + shadowNear + shadowFar` through
3811
- // `packLightSlot` for the HDRP std430 LightSlot pad lanes (byte 52..64).
3812
- // Mutates the freshly-built PointLightSnapshot in place; the snapshot is
3813
- // not exposed elsewhere this frame yet (consumed only by lights.point[]).
3814
- if (pointShadowSnapshots.length > 0) {
3815
- const shadowByEntity = new Map<number, PointShadowSnapshot>();
3816
- for (const ps of pointShadowSnapshots) shadowByEntity.set(ps.entity, ps);
3817
- for (let i = 0; i < pointSnapshots.length; i++) {
3818
- const entityId = pointSnapshotEntities[i] ?? 0;
3819
- const ps = shadowByEntity.get(entityId);
3820
- if (ps !== undefined) {
3821
- pointSnapshots[i] = {
3822
- ...(pointSnapshots[i] as PointLightSnapshot),
3823
- shadowAtlasLayer: ps.shadowAtlasLayer,
3824
- shadowNear: ps.nearPlane,
3825
- shadowFar: ps.farPlane,
3826
- };
3827
- }
3828
- }
3829
- }
3830
-
3831
- const lights: ExtractedLights = {
3832
- directional,
3833
- directionalCount,
3834
- point: pointSnapshots,
3835
- spot: spotSnapshots,
3836
- lightViewProj,
3837
- splitPlanes: splitPlanes !== undefined ? paddedSplitPlanes : undefined,
3838
- cascadeCount,
3839
- cascadeBlend,
3840
- shadowMapSize,
3841
- depthBias: firstHitCastShadow !== false ? firstHitShadowFields?.depthBias : undefined,
3842
- normalBias: firstHitCastShadow !== false ? firstHitShadowFields?.normalBias : undefined,
3843
- directionalShadowQuality,
3844
- directionalShadowError,
3845
- pointShadow: pointShadowSnapshots,
3846
- // bug-20260710-editor-cross-world-shadow: raw CSM config + light direction
3847
- // so the merge layer can recompute matrices against the surfaced camera.
3848
- directionalCsmConfig,
3849
- directionalCsmDirection,
3850
- };
3851
-
3852
- // feat-20260520-skylight-ibl-cubemap M4 / t26+t27: query Skylight entities.
3853
- // First archetype hit wins (mirrors DirectionalLight pattern); multi-Skylight
3854
- // warn in record stage (t27) uses skylightCount.
3855
- const skylightQuery = world.query({ read: [Skylight] }).unwrap();
3856
- let skylight: SkylightSnapshot | undefined;
3857
- let skylightCount = 0;
3858
- for (const row of skylightQuery) {
3859
- const s = row.get(Skylight);
3860
- // equirect is OPTIONAL: an omitted field zero-inits to handle 0, which
3861
- // record treats as "no equirect" -> solid-color ambient via the white
3862
- // fallback cube. A Skylight WITHOUT an equirect is still a valid snapshot
3863
- // (the prior `equirectRaw !== undefined` gate dropped color-only
3864
- // skylights, leaving the scene black -- the downstream gap #4).
3865
- const equirectRaw = s.equirect;
3866
- const intensity = s.intensity;
3867
- const colorR = s.color[0] ?? 1.0;
3868
- const colorG = s.color[1] ?? 1.0;
3869
- const colorB = s.color[2] ?? 1.0;
3870
- const rotation: [number, number, number, number] = [
3871
- s.rotation[0] ?? 0,
3872
- s.rotation[1] ?? 0,
3873
- s.rotation[2] ?? 0,
3874
- s.rotation[3] ?? 1,
3875
- ];
3876
- skylightCount += 1;
3877
- if (skylight === undefined) {
3878
- skylight = {
3879
- equirectHandle: equirectRaw !== undefined ? Math.round(equirectRaw) : 0,
3880
- color: [colorR, colorG, colorB],
3881
- intensity,
3882
- rotation,
3883
- // w19: winning entity handle for the multi-Skylight once-warn (F-8).
3884
- entityHandle: row.entity,
3885
- };
3886
- }
3887
- }
3888
-
3889
- // feat-20260531-skybox-env-background M2 / w5: query SkyboxBackground entities.
3890
- // First archetype hit wins (mirrors Skylight pattern); multi-entity
3891
- // once-warn in record stage uses skyboxCount.
3892
- const skyboxQuery = world.query({ read: [SkyboxBackground] }).unwrap();
3893
- let skybox: SkyboxSnapshot | undefined;
3894
- let skyboxCount = 0;
3895
- for (const row of skyboxQuery) {
3896
- const s = row.get(SkyboxBackground);
3897
- const equirectRaw = s.equirect;
3898
- const modeRaw = s.mode;
3899
- const rotation: [number, number, number, number] = [
3900
- s.rotation[0] ?? 0,
3901
- s.rotation[1] ?? 0,
3902
- s.rotation[2] ?? 0,
3903
- s.rotation[3] ?? 1,
3904
- ];
3905
- skyboxCount += 1;
3906
- if (skybox === undefined && equirectRaw !== undefined) {
3907
- skybox = {
3908
- equirectHandle: Math.round(equirectRaw),
3909
- mode: modeRaw,
3910
- rotation,
3911
- // w19: winning entity handle for the multi-SkyboxBackground warn (F-8).
3912
- entityHandle: row.entity,
3913
- };
3914
- }
3915
- }
3916
-
3917
- const environmentCandidates: EnvironmentCandidate[] = [];
3918
- const imageEnvironment =
3919
- skylight?.equirectHandle !== undefined && skylight.equirectHandle > 0
3920
- ? skylight
3921
- : skybox?.equirectHandle !== undefined && skybox.equirectHandle > 0
3922
- ? skybox
3923
- : undefined;
3924
- if (imageEnvironment !== undefined) {
3925
- environmentCandidates.push({
3926
- kind: 'image',
3927
- entityKey: imageEnvironment.entityHandle,
3928
- sourceKey: `equirect:${imageEnvironment.equirectHandle}`,
3929
- });
3930
- }
3931
- const atmosphereQuery = world.query({ read: [Atmosphere] }).unwrap();
3932
- for (const row of atmosphereQuery) {
3933
- const value = row.get(Atmosphere);
3934
- const atmosphere = {
3935
- turbidity: value.turbidity,
3936
- rayleigh: value.rayleigh,
3937
- mieCoefficient: value.mieCoefficient,
3938
- mieDirectionalG: value.mieDirectionalG,
3939
- sunAngularRadius: value.sunAngularRadius,
3940
- } as const;
3941
- environmentCandidates.push({
3942
- kind: 'atmosphere',
3943
- entityKey: row.entity,
3944
- sourceKey: `atmosphere:${row.entity}`,
3945
- atmosphere,
3946
- });
3947
- }
3948
- const directionalDirection = directional?.direction;
3949
- const directionalLength =
3950
- directionalDirection === undefined
3951
- ? 0
3952
- : Math.hypot(
3953
- directionalDirection[0] ?? 0,
3954
- directionalDirection[1] ?? 0,
3955
- directionalDirection[2] ?? 0,
3956
- );
3957
- const sun =
3958
- directional === undefined || directionalLength <= 1e-6
3959
- ? []
3960
- : [
3961
- {
3962
- entityKey: 0,
3963
- direction: [
3964
- -(directionalDirection?.[0] ?? 0) / directionalLength,
3965
- -(directionalDirection?.[1] ?? 0) / directionalLength,
3966
- -(directionalDirection?.[2] ?? 0) / directionalLength,
3967
- ] as [number, number, number],
3968
- color: [
3969
- directional.color[0] ?? 0,
3970
- directional.color[1] ?? 0,
3971
- directional.color[2] ?? 0,
3972
- ] as [number, number, number],
3973
- intensity: directional.intensity,
3974
- },
3975
- ];
3976
- const environmentSelection = selectEnvironment({
3977
- environments: environmentCandidates,
3978
- fogs: fog === undefined ? [] : [fog],
3979
- suns: sun,
3980
- lane: 'direct',
3981
- });
3982
- if (!environmentSelection.ok) {
3983
- routeWorldError(world, environmentSelection.error, {
3984
- systemName: 'RenderSystem.extract (environment-selection)',
3985
- });
3986
- throw environmentSelection.error;
3987
- }
3988
- const environment: EnvironmentFrame = environmentSelection.value;
3989
-
3990
- // feat-20260528-frustum-culling M3 / w10: precompute per-camera frustum
3991
- // planes so entities can be tested against all cameras in the inner loop.
3992
- // Cameras with degenerate projection parameters (e.g. zero fov, zero aspect)
3993
- // are skipped — entities are always-visible for those. Frustum plane cache
3994
- // stored as Float32Array[] parallel to the cameras[] array.
3995
- //
3996
- // feat-20260708-composited-multi-world-rendering M2 / D-4: a composite
3997
- // non-owner world uses the camera-owner snapshots, not its own cameras.
3998
- // This keeps culling correct when the surfaced camera and renderables live
3999
- // in different worlds. The explicit 'none' mode remains the always-visible
4000
- // escape hatch for callers that genuinely need it.
4001
- const frustumPlanes: Float32Array[] = [];
4002
- const cullingCameras = cullMode === 'external' ? (context.cullCameras ?? []) : cameras;
4003
- if (cullMode !== 'none') {
4004
- for (const cam of cullingCameras) {
4005
- // feat-20260613 M6 / w20: orthographic cameras have fov=0 by design;
4006
- // the previous degeneracy guard (`fov <= 0`) was rejecting valid ortho
4007
- // cameras and returning the always-visible escape hatch. Only the
4008
- // perspective path needs the fov check.
4009
- if (cam.projection === 'perspective' && (cam.fov <= 0 || cam.aspect <= 0)) {
4010
- frustumPlanes.push(new Float32Array(0)); // degenerate → always-visible
4011
- continue;
4012
- }
4013
- if (cam.near >= cam.far) {
4014
- frustumPlanes.push(new Float32Array(0));
4015
- continue;
4016
- }
4017
- const proj = mat4.create();
4018
- if (cam.projection === 'orthographic') {
4019
- mat4.orthographic(
4020
- proj,
4021
- cam.orthoLeft,
4022
- cam.orthoRight,
4023
- cam.orthoBottom,
4024
- cam.orthoTop,
4025
- cam.near,
4026
- cam.far,
4027
- );
4028
- } else {
4029
- mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
4030
- }
4031
- // feat-20260601 D-3: view = invert(camera world mat4). The camera scale is
4032
- // carried in the world basis columns; the cull frustum uses the same view
4033
- // the record stage derives, so cull stays same-source with render (AC-05).
4034
- const view = mat4.create();
4035
- mat4.invert(view, cam.world);
4036
- const vp = mat4.create();
4037
- mat4.multiply(vp, proj, view);
4038
- const f = frustum.create();
4039
- frustum.fromViewProjection(f, vp);
4040
- frustumPlanes.push(f);
4041
- }
4042
- }
4043
-
4044
- const renderables: RenderableSnapshot[] = [];
4045
- // feat-20260528-frustum-culling M3 / w11: frustum culling counters.
4046
- let frustumCulled = 0;
4047
- let frustumTotal = 0;
4048
- const explicitlyHidden = new Set<EntityHandle>();
4049
- // feat-20260520-2d-sprite-layer-mvp M-3 / w22 (@new-surface): three-
4050
- // bucket dispatch arrays. The legacy `materialDispatch` field stays as
4051
- // a back-compat union (opaque + transparent + overlay back-compat
4052
- // entries) so the pre-w25 RenderSystem.draw consumer loop keeps
4053
- // working until M-3 / w25 lands the bucket-aware record. Plan-strategy
4054
- // §6.1 (back-compat field stays until M-4 acceptance round green).
4055
- // M3 / w26: single dispatch list replaces old three-bucket model
4056
- // (plan-strategy D-3). Entries built per-entity per-pass inside the
4057
- // archetype walk, then sorted by queue at the end.
4058
- let dispatch: DispatchEntry[] = [];
4059
- // Keep the derived material snapshot and its resolved passes local to this
4060
- // extraction. Shared handles are immutable inputs for the frame, while
4061
- // dispatch entries still need to be rebuilt for each entity.
4062
- const materialSnapshotCache: MaterialSnapshotCache = new Map();
4063
- // tweak-20260611 M1: MeshRenderer renderable archetype walk routes
4064
- // through one World-owned Query. K-2 sniffing scheme B
4065
- // (`row.get(X) !== undefined` edge sniff) replaces the prior
4066
- // `arch.components.some` row-internal back-door. K-3 invariant: the
4067
- // variable-length array reads (`MeshRenderer.materials`,
4068
- // `Instances.transforms`) still flow through `_getArrayView` /
4069
- // `world.get(e, Instances)` -- only the `entity` source switches to
4070
- // `row.entity`.
4071
- //
4072
- // archVersion plumbing exception: the `RenderableSnapshot.instances
4073
- // .archVersion` cache key is keyed off the live archetype's mutation
4074
- // counter (record stage `instanceBuffers` cache invalidation). The
4075
- // QueryRow does not surface this number, so a single archetype
4076
- // graph access is retained for instance-bearing rows -- match by entity
4077
- // packed handle. AC-01 grep ≤ current - 1 still holds: the stale
4078
- // archetype-graph traversal commentary at the prior call site is gone.
4079
- // feat-20260521-sprite-atlas-animation M3 / T-16: SpriteRegionOverride
4080
- // column id for the sprite-bucket per-entity region override read
4081
- void SpriteRegionOverride;
4082
-
4083
- const meshRendererQuery = world
4084
- .query({
4085
- read: [MeshRenderer],
4086
- optional: [
4087
- Transform,
4088
- MeshFilter,
4089
- Instances,
4090
- Skin,
4091
- Layer,
4092
- MorphWeights,
4093
- SpriteRegionOverride,
4094
- SpriteInstances,
4095
- Points,
4096
- Lines,
4097
- SortKey,
4098
- ],
4099
- })
4100
- .unwrap();
4101
- // QueryRow deliberately exposes component data, not an archetype version.
4102
- // The optimized ECS owner keeps table storage private, so instance-buffer
4103
- // invalidation uses a content fingerprint of the managed arrays instead.
4104
- const resolveArchVersion = (entity: EntityHandle): number => {
4105
- const values = [
4106
- worldInternal._getArrayView(entity, Instances, 'transforms'),
4107
- worldInternal._getArrayView(entity, SpriteInstances, 'transforms'),
4108
- worldInternal._getArrayView(entity, SpriteInstances, 'regions'),
4109
- ];
4110
- let hash = 2166136261;
4111
- for (const value of values) {
4112
- hash = Math.imul(hash ^ (value?.length ?? 0), 16777619) >>> 0;
4113
- if (value === undefined) continue;
4114
- for (let index = 0; index < value.length; index += 1) {
4115
- hash = Math.imul(hash ^ Math.fround(value[index] ?? 0), 16777619) >>> 0;
4116
- }
4117
- }
4118
- return hash;
4119
- };
4120
- // Pending entries are created with the current renderables.length, which is
4121
- // exactly the slot this entity receives if it survives culling. No other
4122
- // renderable can be pushed between staging and this entity's push, so publish
4123
- // the original fresh entries instead of cloning every submesh descriptor.
4124
- // A culled entity still discards its private pending array unchanged.
4125
- const flushPendingDispatch = (pending: readonly DispatchEntry[]): void => {
4126
- for (const entry of pending) dispatch.push(entry);
4127
- };
4128
- for (const row of meshRendererQuery) {
4129
- // K-2 archetype-edge sniff (scheme B): a missing optional component
4130
- // surfaces as an absent bundle key, not a row-internal optional chain.
4131
- // Presence-only checks use QueryRow.has so large array-bearing components
4132
- // are not materialised merely to answer a boolean question.
4133
- const hasTransform = row.has(Transform);
4134
- const meshFilter = row.get(MeshFilter);
4135
- const hasInstances = row.has(Instances);
4136
- const skin = row.get(Skin);
4137
- const morphWeightsView = worldInternal._getArrayView(row.entity, MorphWeights, 'weights');
4138
- const hasMeshFilter = meshFilter !== undefined;
4139
- const hasSkin = skin !== undefined;
4140
- // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10:
4141
- // SpriteInstances optional component archetype-edge sniff. Three structured
4142
- // EcsError codes fire at the row-loop entry (D-6 fail-fast at extract):
4143
- // - 'sprite-instances-mutually-exclusive-with-instances'
4144
- // (hasInstances && hasSpriteInstances) — Instances + SpriteInstances peers.
4145
- // - 'sprite-instances-requires-sprite-shading-model'
4146
- // (materialSnap.materialShaderId !== 'forgeax::sprite') — non-sprite material.
4147
- // - 'sprite-instances-count-mismatch'
4148
- // (transforms.length / 16 !== regions.length / 4) — stride pair desync.
4149
- const hasSpriteInstances = row.has(SpriteInstances);
4150
- const points = row.get(Points);
4151
- const lines = row.get(Lines);
4152
- const sortKey = row.get(SortKey)?.value;
4153
- const pointsLinesComponent =
4154
- points !== undefined ? 'Points' : lines !== undefined ? 'Lines' : undefined;
4155
- const isRenderable = hasTransform && hasMeshFilter;
4156
-
4157
- // feat-20260601 D-3: the resolved world transform is read per-entity from
4158
- // the single `Transform.world` mat4 (propagateTransforms output) inside the
4159
- // row loop below. The retired GlobalTransform-column-switch + the
4160
- // ChildOf-without-GlobalTransform misconfig signal are gone: the world
4161
- // column always exists on a Transform-bearing entity, so the
4162
- // "ChildOf but forgot GlobalTransform" misconfiguration cannot occur.
4163
- const fAssetHandle = meshFilter?.assetHandle;
4164
- // feat-20260520-2d-sprite-layer-mvp M-3 / w22: Layer column read here;
4165
- // value folded into each DispatchEntry so the render-system sort can use
4166
- // it as the primary transparent-sort key without a second ECS round-trip.
4167
- const fLayerValue = row.get(Layer)?.value;
4168
- // feat-20260608-tilemap-object-layer-rendering M3 / m3-t5: tilemap-spawned
4169
- // per-cell render entities (the ones `tilemap-chunk-extract-system`
4170
- // pushes via `spawnDerivedRenderEntities`) reach this loop via the same
4171
- // archetype edge that carries a sprite entity -- they all wear
4172
- // `MeshFilter.assetHandle === HANDLE_QUAD` + a `forgeax::sprite`-shaded
4173
- // material asset + the sprite-bucket `values.region` rectangle.
4174
- // For the per-entity Y-sort path (requirements §AC-12 / §AC-13):
4175
- //
4176
- // sortKey = -(Transform.posY - effectivePivotY * |Transform.scaleY|)
4177
- //
4178
- // with `effectivePivotY = effectivePivotYForTilemapFlip(pivotY, pivotX,
4179
- // flipV, flipDiagonal)` from `tilemap-chunk-extract-system` (the SAME
4180
- // helper drives `spawnDerivedRenderEntities`, so the value the sort
4181
- // uses matches the value baked into `Transform.posY` -- charter P4
4182
- // single SSOT for the post-flip pivot). Sprite entities reuse the same
4183
- // formula but skip the flip composition (their pivot stays raw); both
4184
- // bucket types therefore feed one `transparentSortEntries` argsort
4185
- // step + share the `argsortInPlace` radix LSD primitive (plan-strategy
4186
- // §D-1 / §D-3). The detection lives on the material side -- detect a
4187
- // tilemap-spawned entity by `MeshFilter.assetHandle === HANDLE_QUAD`
4188
- // plus the `forgeax::sprite` shader id on `MeshRenderer.material`'s
4189
- // first pass + non-empty `values.region`; no new public ECS
4190
- // marker component lands (charter F1 minimum surface).
4191
- //
4192
- // Layer.value is now folded into each DispatchEntry.layer (fLayerValue
4193
- // column, read once per archetype pass above). render-system.ts
4194
- // `sortTransparentDispatch` applies (layer ASC, sortValue ASC) for all
4195
- // transparent-sort modes (0/1/2) using posY/pivotY/sizeY from the
4196
- // parallel renderables[] snapshot -- no second ECS round-trip needed.
4197
- // feat-20260527-sprite-nineslice M4 / w17 (AC-14): SpriteRegionOverride
4198
- // per-entity UV sub-rectangle. When the entity carries this component the
4199
- // 4-float `[uMin, vMin, uW, vH]` override displaces the asset-side
4200
- // `values.region` for this entity only — downstream 9-slice logic
4201
- // measures slices against this effective region.zw, so a half-width sub-
4202
- // sprite reduces the anchor budget to 0.5 rather than the asset's 1.0.
4203
- //
4204
- // SpriteRegionOverride.region is a fixed stride-4 value. Per-row reads
4205
- // route through `_getArrayView` for the zero-copy row window
4206
- // row-window slice (consistent with the variable-length array column
4207
- // reads -- K-3 carve-out keeps `_getArrayView` as the row-accessor of
4208
- // record for each non-scalar column).
4209
- const hasSpriteRegionOverride = row.has(SpriteRegionOverride);
4210
- // feat-20260523-skin-skeleton-animation M2 / T-21: Skin component
4211
- // column views for coexistence check + joint despawn fail-fast.
4212
- // `skeleton` holds the packed Handle<SkeletonAsset>; `joints` holds
4213
- // the packed Entity u32 array (N x one u32 each).
4214
- const skinSkeletonView = skin?.skeleton;
4215
-
4216
- let archVersion = 0;
4217
- if (hasInstances || hasSpriteInstances) {
4218
- // All rows in this callback share one archetype. Only these two
4219
- // instance-bearing paths consume the version in their cache key.
4220
- archVersion = resolveArchVersion(row.entity);
4221
- }
4222
-
4223
- {
4224
- // feat-20260608 M2 / w11: read materials array via _getArrayView
4225
- const entity = row.entity;
4226
- if (isRenderable && visibility?.effective(entity) === 'hidden') {
4227
- explicitlyHidden.add(entity);
4228
- continue;
4229
- }
4230
- const layerVal = fLayerValue ?? 0;
4231
- // bug-20260709-builtin-quad-withoutaabb-disables-sprite-frustum-cu M2.5
4232
- // (carries PR #598 feat-20260703 D-7): dispatch entries for this entity
4233
- // are staged locally and flushed into the shared `dispatch[]` array
4234
- // ONLY when the entity survives the frustum-cull `continue` below —
4235
- // same cull-passed branch as the paired `renderables.push`. Prior to
4236
- // this fix the three `dispatch.push` sites ran before the cull check,
4237
- // so a culled entity left dangling entries whose `renderableIndex`
4238
- // aliased the slot a LATER visible entity occupied — surfacing as the
4239
- // pbr-mesh-array-bgl vs hdrp-unified-bgl-group2 BGL/PL mismatch on
4240
- // the deferred-shading smoke (PR #598 CI). Pure ordering fix; cull
4241
- // logic and MeshRenderer contract unchanged.
4242
- const pendingDispatch: DispatchEntry[] = [];
4243
- const materialsView = worldInternal._getArrayView(entity, MeshRenderer, 'materials') as
4244
- | Uint32Array
4245
- | undefined;
4246
- const materialCount = materialsView?.length ?? 0;
4247
- let materialHandles = Array.from(materialsView ?? []);
4248
- let materialBindingSources: MeshMaterialBindingSource[] = materialHandles.map(
4249
- () => 'renderer-override',
4250
- );
4251
- let materialBindingDiagnostics: MeshMaterialBindingDiagnostic[] = [];
4252
- let gpuDrivenSubmeshes: readonly MeshAsset['submeshes'][number][] = [];
4253
- let gpuDrivenIndexed = false;
4254
-
4255
- // Resolve instance overrides against mesh-owned slot defaults once.
4256
- const fAssetHandleVal = fAssetHandle;
4257
- if (
4258
- fAssetHandleVal !== undefined &&
4259
- fAssetHandleVal !== 0 &&
4260
- assets !== undefined &&
4261
- assets !== null
4262
- ) {
4263
- const meshHandle = toShared<'MeshAsset'>(fAssetHandleVal);
4264
- const meshRes = resolveAssetHandle<Asset>(world, meshHandle);
4265
- if (meshRes.ok && meshRes.value.kind === 'mesh') {
4266
- const meshAsset = meshRes.value as MeshAsset;
4267
- gpuDrivenSubmeshes = meshAsset.submeshes;
4268
- gpuDrivenIndexed = meshAsset.indices !== undefined;
4269
- // extractFrames is also a public read path and can run before the
4270
- // Renderer.draw pre-render stage. GlyphText proves the derived mesh
4271
- // owns exactly one Default slot; arbitrary meshes still fail closed
4272
- // and never infer slot topology from their submeshes.
4273
- if (!Array.isArray(meshAsset.materialSlots) && world.get(entity, GlyphText).ok) {
4274
- ensureGlyphMeshMaterialSlots(world, meshHandle);
4275
- }
4276
- const guid = (meshRes.value as { guid?: string }).guid ?? '<no-guid>';
4277
- const resolvedBindings = resolveMeshMaterialBindings(meshAsset, materialsView ?? [], {
4278
- isValidOverride(handle) {
4279
- const resolved = resolveAssetHandle(world, toShared<'MaterialAsset'>(handle));
4280
- return resolved.ok && resolved.value.kind === 'material';
4281
- },
4282
- resolveMeshDefault(defaultGuid) {
4283
- const guidText = AssetGuid.format(defaultGuid);
4284
- if (assets.lookup<Asset>(guidText)?.kind !== 'material') return undefined;
4285
- return internSharedRefFromGuid(world, assets, guidText, 'MaterialAsset') as
4286
- | number
4287
- | undefined;
4288
- },
4289
- });
4290
- if (!resolvedBindings.ok) {
4291
- if (resolvedBindings.code === 'mesh-material-slots-missing') {
4292
- worldInternal._routeError(
4293
- new AssetError({
4294
- code: 'load-failed',
4295
- expected: 'every MeshAsset producer supplies materialSlots[]',
4296
- hint: `fix the MeshAsset producer; renderer inheritance never guesses slot topology (mesh=${guid}, entity=${entity}, vertices=${meshAsset.vertices.length}, indices=${meshAsset.indices?.length ?? 0})`,
4297
- detail: {
4298
- referencedByGuid: guid,
4299
- referencedByKind: 'mesh',
4300
- subAssetGuid: '<material-slots-missing>',
4301
- sourceField: { fieldName: 'materialSlots' },
4302
- },
4303
- }),
4304
- {
4305
- severity: Severity.Error,
4306
- systemName: 'RenderSystem.extract (mesh-material-slots-missing)',
4307
- },
4308
- );
4309
- continue;
4310
- }
4311
- const materialGuid = AssetGuid.format(resolvedBindings.defaultMaterial);
4312
- worldInternal._routeError(
4313
- new AssetError({
4314
- code: 'load-failed',
4315
- expected: `MeshAsset materialSlots[${resolvedBindings.slotIndex}] default ${materialGuid} is ready and a MaterialAsset`,
4316
- hint: `loadByGuid(meshGuid) must recursively load the declared default material; mesh=${guid}, slot=${resolvedBindings.slotIndex}, material=${materialGuid}`,
4317
- detail: {
4318
- referencedByGuid: guid,
4319
- referencedByKind: 'mesh',
4320
- subAssetGuid: materialGuid,
4321
- sourceField: {
4322
- fieldName: 'materialSlots',
4323
- arrayIndex: resolvedBindings.slotIndex,
4324
- },
4325
- },
4326
- }),
4327
- {
4328
- severity: Severity.Error,
4329
- systemName: 'RenderSystem.extract (mesh-default-not-ready)',
4330
- },
4331
- );
4332
- continue;
4333
- }
4334
- materialHandles = resolvedBindings.bindings.map((binding) => binding.handle);
4335
- materialBindingSources = resolvedBindings.bindings.map((binding) => binding.source);
4336
- materialBindingDiagnostics = resolvedBindings.diagnostics.map((diagnostic) => ({
4337
- ...diagnostic,
4338
- detail:
4339
- diagnostic.code === 'mesh-renderer-material-override-overflow'
4340
- ? {
4341
- expectedCount: meshAsset.materialSlots.length,
4342
- actualCount: materialCount,
4343
- meshAssetGuid: guid,
4344
- }
4345
- : {
4346
- meshAssetGuid: guid,
4347
- slotIndex: diagnostic.slotIndex,
4348
- handle: diagnostic.handle ?? 0,
4349
- },
4350
- }));
4351
- for (const diagnostic of resolvedBindings.diagnostics) {
4352
- worldInternal._routeError(
4353
- new AssetError({
4354
- code: diagnostic.code,
4355
- expected:
4356
- diagnostic.code === 'mesh-renderer-material-override-overflow'
4357
- ? `materials.length <= materialSlots.length (${meshAsset.materialSlots.length})`
4358
- : `materials[${diagnostic.slotIndex}] resolves to a live MaterialAsset`,
4359
- hint: ASSET_ERROR_HINTS[diagnostic.code],
4360
- detail:
4361
- diagnostic.code === 'mesh-renderer-material-override-overflow'
4362
- ? {
4363
- expectedCount: meshAsset.materialSlots.length,
4364
- actualCount: materialCount,
4365
- meshAssetGuid: guid,
4366
- }
4367
- : {
4368
- meshAssetGuid: guid,
4369
- slotIndex: diagnostic.slotIndex,
4370
- handle: diagnostic.handle ?? 0,
4371
- },
4372
- }),
4373
- {
4374
- severity: Severity.Warning,
4375
- systemName: `RenderSystem.extract (${diagnostic.code})`,
4376
- },
4377
- );
4378
- }
4379
- }
4380
- }
4381
-
4382
- if (materialHandles.length === 0) {
4383
- materialHandles = [0];
4384
- materialBindingSources = ['engine-default'];
4385
- }
4386
-
4387
- // Use the first material handle for the entity-level snapshot
4388
- // (shading-model dispatch routing + multi-pass DispatchEntry. Per-
4389
- // submesh materials[i>=1] are resolved by `resolveMaterialSnapshot`
4390
- // below into the `materials[]` array, used by the record stage to
4391
- // upload N material UBO slots and bind the i-th slot before the
4392
- // i-th submesh draw.) -- feat-20260608 M5 amend / w11-a.
4393
- const handleRaw = materialHandles[0] ?? 0;
4394
-
4395
- const cachedMaterial =
4396
- handleRaw !== 0 && !hasSpriteRegionOverride && !hasSkin
4397
- ? materialSnapshotCache.get(handleRaw)
4398
- : undefined;
4399
-
4400
- let materialSnap: MaterialSnapshot;
4401
-
4402
- if (cachedMaterial !== undefined) {
4403
- materialSnap = cachedMaterial.snapshot;
4404
- if (isRenderable) {
4405
- appendMaterialDispatchEntries(
4406
- pendingDispatch,
4407
- cachedMaterial.passes,
4408
- entity,
4409
- handleRaw,
4410
- renderables.length,
4411
- layerVal,
4412
- materialSnap.paramSnapshot,
4413
- );
4414
- }
4415
- } else if (handleRaw === 0 || assets === undefined || assets === null) {
4416
- // case B: missing-spec sentinel -> mid-grey defaultMaterialSnapshot.
4417
- materialSnap = defaultMaterialSnapshot(handleRaw);
4418
- } else {
4419
- const tagged = toShared<'MaterialAsset'>(handleRaw);
4420
- const stablePersistentCached =
4421
- !hasSpriteRegionOverride && !hasSkin
4422
- ? readStablePersistentMaterialSnapshot(
4423
- persistentMaterialSnapshotCache,
4424
- handleRaw,
4425
- assets,
4426
- )
4427
- : undefined;
4428
- if (stablePersistentCached !== undefined) {
4429
- materialSnapshotCache.set(handleRaw, stablePersistentCached);
4430
- materialSnap = stablePersistentCached.snapshot;
4431
- if (isRenderable) {
4432
- appendMaterialDispatchEntries(
4433
- pendingDispatch,
4434
- stablePersistentCached.passes,
4435
- entity,
4436
- handleRaw,
4437
- renderables.length,
4438
- layerVal,
4439
- materialSnap.paramSnapshot,
4440
- );
4441
- }
4442
- } else {
4443
- const res = resolveAssetHandle(world, tagged);
4444
- if (!res.ok) {
4445
- if (isRenderable) {
4446
- const rhiErr = new RhiError({
4447
- code: 'asset-not-registered',
4448
- expected: 'MeshRenderer.material in AssetRegistry',
4449
- hint: 'catalog the material via assetRegistry.catalog(guid, asset) + world.allocSharedRef before spawn, or remove the material field to fall back to default',
4450
- detail: { assetHandle: handleRaw },
4451
- });
4452
- worldInternal._routeError(rhiErr, {
4453
- severity: Severity.Error,
4454
- systemName: 'RenderSystem.extract (material asset-not-registered)',
4455
- });
4456
- }
4457
- continue;
4458
- }
4459
- const asset = res.value;
4460
- if (asset.kind !== 'material') {
4461
- materialSnap = defaultMaterialSnapshot(handleRaw);
4462
- } else {
4463
- // feat-20260529 M3 / w11: material parent chain inheritance via
4464
- // read-through _materialWalk accessor (plan-strategy D-6).
4465
- // The old direct asset.passes / asset.values read never
4466
- // walked the parent chain, causing broken-inheritance (root cause).
4467
- const resolvedResult = walkMaterialPassesOverSharedRefs(world, tagged, assets);
4468
- if (!resolvedResult.ok) {
4469
- // AC-09 / S-7 / q8=A: passes-empty or cycle must fire structured
4470
- // error through _routeError (same routing as asset-not-registered
4471
- // branch above). Silent continue is forbidden because it produces
4472
- // a black screen indistinguishable from a content bug.
4473
- const err = resolvedResult.error;
4474
- switch (err.code) {
4475
- case 'material-parent-not-found':
4476
- case 'material-no-effective-pass':
4477
- case 'material-value-unknown':
4478
- case 'material-value-type-mismatch':
4479
- case 'material-contract-program-mismatch':
4480
- worldInternal._routeError(err, {
4481
- severity: Severity.Error,
4482
- systemName: `RenderSystem.extract (${err.code})`,
4483
- });
4484
- break;
4485
- case 'material-circular-inheritance':
4486
- worldInternal._routeError(err, {
4487
- severity: Severity.Error,
4488
- systemName: 'RenderSystem.extract (material-circular-inheritance)',
4489
- });
4490
- break;
4491
- default:
4492
- // Exhaustive guard: unhandled error codes from _materialWalk
4493
- // surface an internal assertion to avoid silent continuation.
4494
- worldInternal._routeError(err, {
4495
- severity: Severity.Error,
4496
- systemName: `RenderSystem.extract (_materialWalk: ${err.code})`,
4497
- });
4498
- }
4499
- continue;
4500
- }
4501
- const resolved = resolvedResult.value;
4502
- const allPasses = resolved.passes;
4503
- const firstPassShader =
4504
- allPasses.length > 0
4505
- ? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
4506
- : undefined;
4507
- const pv = materialValuesToLinearRuntime(
4508
- resolved.values,
4509
- materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assets),
4510
- resolved.colorSpace,
4511
- ) as Readonly<Record<string, unknown>>;
4512
-
4513
- const baseColorPv = pv.baseColor as readonly number[] | undefined;
4514
- const baseColor = vec3.create(
4515
- baseColorPv?.[0] ?? 1,
4516
- baseColorPv?.[1] ?? 1,
4517
- baseColorPv?.[2] ?? 1,
4518
- );
4519
- const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
4520
- const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
4521
- const clearcoatPv = typeof pv.clearcoat === 'number' ? pv.clearcoat : 0;
4522
- const clearcoatRoughnessPv =
4523
- typeof pv.clearcoatRoughness === 'number' ? pv.clearcoatRoughness : 0.5;
4524
- const specularTintPv = pv.specularTint as readonly number[] | undefined;
4525
- const normalScalePv = materialNormalScale(pv);
4526
-
4527
- const paramSnap: Record<string, number | number[] | string> = {};
4528
- for (const [k, v] of Object.entries(pv)) {
4529
- if (typeof v === 'number') paramSnap[k] = v;
4530
- else if (typeof v === 'string') paramSnap[k] = v;
4531
- else if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
4532
- paramSnap[k] = v as number[];
4533
- }
4534
- }
4535
-
4536
- const materialParamSchema = materialParametersToParamSchema(
4537
- resolved.parameters ?? [],
4538
- firstPassShader,
4539
- );
4540
- // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
4541
- // bidirectional Skin <-> pbr-skin material fail-fast at extract.
4542
- // Skin component without a forgeax::pbr-skin first-pass material
4543
- // would draw with a non-skin shader against the 18-float vertex
4544
- // buffer (joints/weights bytes interpreted as garbage). Conversely
4545
- // a forgeax::pbr-skin material against a 12-float (unskinned) mesh
4546
- // would have @location(4)/@location(5) read uninitialized memory.
4547
- // Both cases route through `_routeError` + `continue` so a single
4548
- // misconfigured entity does NOT abort the whole frame's draw list
4549
- // (charter P3 explicit failure + plan-decisions D-5 over `return err`).
4550
- {
4551
- const hasSkinSkel =
4552
- hasSkin &&
4553
- skinSkeletonView !== undefined &&
4554
- skinSkeletonView !== undefined &&
4555
- skinSkeletonView !== 0;
4556
- const isPbrSkinMaterial = firstPassShader === 'forgeax::pbr-skin';
4557
- if (hasSkinSkel && !isPbrSkinMaterial) {
4558
- worldInternal._routeError(new SkinMaterialMismatchError(entity, firstPassShader), {
4559
- severity: Severity.Error,
4560
- systemName: 'RenderSystem.extract (skin-material-mismatch)',
4561
- });
4562
- continue;
4563
- }
4564
- if (isPbrSkinMaterial && fAssetHandleVal !== undefined && fAssetHandleVal !== 0) {
4565
- const meshHandleForSkinCheck = toShared<'MeshAsset'>(fAssetHandleVal);
4566
- const meshResForSkinCheck = resolveAssetHandle<MeshAsset>(
4567
- world,
4568
- meshHandleForSkinCheck,
4569
- );
4570
- if (meshResForSkinCheck.ok) {
4571
- const meshAttrs = meshResForSkinCheck.value.attributes;
4572
- const hasSkinIdx = meshAttrs.skinIndex !== undefined;
4573
- const hasSkinWt = meshAttrs.skinWeight !== undefined;
4574
- if (!hasSkinIdx || !hasSkinWt) {
4575
- const missing: 'skinIndex' | 'skinWeight' | 'both' =
4576
- !hasSkinIdx && !hasSkinWt ? 'both' : !hasSkinIdx ? 'skinIndex' : 'skinWeight';
4577
- worldInternal._routeError(new MaterialSkinAttrMissingError(entity, missing), {
4578
- severity: Severity.Error,
4579
- systemName: 'RenderSystem.extract (material-skin-attr-missing)',
4580
- });
4581
- continue;
4582
- }
4583
- }
4584
- }
4585
- }
4586
- // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-3):
4587
- // sprite materials now flow through the same generic paramSchema-
4588
- // driven extract path PBR / unlit use. The narrow `forgeax::sprite`
4589
- // exception block below covers exactly 2 plan-authorised cases:
4590
- // 1. SpriteRegionOverride per-entity region displacement (Q4=a)
4591
- // 2. flipX / flipY -> region fold (plan-strategy D-8)
4592
- // No legacy values field-name shim; demos and SpriteParamValues
4593
- // are UBO-aligned (no `texture` / `baseColor` / `pivot` / `slices`
4594
- // / `sliceMode` keys reaching this code path). AGENTS.md §Change
4595
- // stance: "no shim layer, no v1/v2 dual-path".
4596
- //
4597
- // feat-20260624 M1' / t6: `'forgeax::sprite-lit'` walks the same
4598
- // sprite-family vertex path (VsOut byte-identical, paramSchema
4599
- // mirror) so the SAME 2 folds apply — extending `isSprite` to
4600
- // cover both shader ids keeps the narrowing-point count at 1
4601
- // (plan-strategy §1.6 + D-1: mirror sprite, no new branch).
4602
- const isSprite =
4603
- firstPassShader === 'forgeax::sprite' || firstPassShader === 'forgeax::sprite-lit';
4604
-
4605
- // feat-20260613-material-paramschema-driven-binding M4 / w23
4606
- // (D-5 graceful): paramSchema-driven texture-field validation.
4607
- // For each handle-shaped paramValue (typeof === 'number'),
4608
- // verify it actually points at a registered texture asset
4609
- // when the field is declared as a texture in the shader's
4610
- // paramSchema; mis-typed handles (e.g. a scalar f32 stored as
4611
- // int 0 the M4 / w22 graceful fallback resolved to a wrong
4612
- // sub-asset) are dropped here so the record stage falls back
4613
- // to MISSING_TEXTURE_HANDLE (default white) without raising.
4614
- const validateTextureHandle = (
4615
- fieldName: string,
4616
- raw: unknown,
4617
- ): Handle<'TextureAsset', 'shared'> | undefined => {
4618
- // feat-20260614 M8 (D-19): a string value is an embedded texture
4619
- // GUID; resolve it to a column handle via catalog + allocSharedRef
4620
- // before validation. A number is an already-minted column handle.
4621
- let handle: Handle<'TextureAsset', 'shared'>;
4622
- const textureRef = materialTextureRef(raw);
4623
- const textureGuid = assetReferenceText(textureRef);
4624
- if (textureGuid !== undefined) {
4625
- if (assets === null || assets === undefined) return undefined;
4626
- // M4: intern so the GUID mints one stable handle per World
4627
- // instead of a fresh slot every frame (GPU residency relies on
4628
- // a stable handleSlot). onLastRelease -> gpuStore.evictTexture.
4629
- const interned = internSharedRefFromGuid(
4630
- world,
4631
- assets,
4632
- textureGuid,
4633
- 'TextureAsset',
4634
- );
4635
- if (interned === undefined) return undefined;
4636
- handle = interned;
4637
- } else if (typeof textureRef === 'number') {
4638
- handle = toShared<'TextureAsset'>(textureRef);
4639
- } else {
4640
- return undefined;
4641
- }
4642
- if (assets === null || assets === undefined) return handle;
4643
- const declaredFields = materialTextureFields(
4644
- firstPassShader,
4645
- materialParamSchema.length > 0
4646
- ? derive(materialParamSchema).textureFieldNames
4647
- : firstPassShader !== undefined
4648
- ? assets.materialShaderTextureFieldNames(firstPassShader)
4649
- : undefined,
4650
- );
4651
- // Shader not registered (R-4 cross-worktree path) -> trust the
4652
- // raw handle and let the record stage / GPU layer surface each
4653
- // mismatch via MISSING_TEXTURE_HANDLE.
4654
- if (declaredFields === undefined) return handle;
4655
- // Field is not declared as a texture by the shader -> the
4656
- // loader's "try every int" fallback misclassified a scalar;
4657
- // drop the slot so the record stage uses the default white.
4658
- if (
4659
- !declaredFields.has(fieldName) &&
4660
- !isEngineInjectedTextureField(firstPassShader, fieldName)
4661
- ) {
4662
- return undefined;
4663
- }
4664
- // Field declared as texture: verify the handle's asset kind.
4665
- const assetRes = resolveAssetHandle(world, handle);
4666
- if (!assetRes.ok) return undefined;
4667
- const kind = (assetRes.value as { kind?: string }).kind;
4668
- if (kind !== 'texture') return undefined;
4669
- return handle;
4670
- };
4671
- // feat-20260614 M8 (D-19): resolve a sampler / texture paramValue
4672
- // that may be an embedded GUID string (catalog + allocSharedRef) or
4673
- // an already-minted column handle (number passthrough).
4674
- const resolveParamHandle = <B extends string>(
4675
- raw: unknown,
4676
- brand: B,
4677
- ): Handle<B, 'shared'> | undefined => {
4678
- const value = materialTextureRef(raw);
4679
- if (typeof value === 'number') return toShared<B>(value);
4680
- const guid = assetReferenceText(value);
4681
- if (guid !== undefined) {
4682
- if (assets === null || assets === undefined) return undefined;
4683
- // M4: intern the GUID -> column-handle resolution (one stable
4684
- // handle per (world, guid, brand), reused across frames).
4685
- return internSharedRefFromGuid(world, assets, guid, brand);
4686
- }
4687
- return undefined;
4688
- };
4689
- // feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): iterate the
4690
- // shader's derive(paramSchema).textureFieldNames SSOT so the Nth
4691
- // user-region texture (e.g. parallax heightTexture) is validated +
4692
- // carried, replacing the hardcoded 3-field list. validateTextureHandle
4693
- // already drops fields a shader doesn't declare as a texture.
4694
- const userRegionFields =
4695
- materialTextureFields(
4696
- firstPassShader,
4697
- materialParamSchema.length > 0
4698
- ? derive(materialParamSchema).textureFieldNames
4699
- : firstPassShader !== undefined && assets !== null && assets !== undefined
4700
- ? assets.materialShaderTextureFieldNames(firstPassShader)
4701
- : undefined,
4702
- ) ?? BUILTIN_USER_REGION_TEXTURE_FIELDS;
4703
- const textureHandles = new Map<string, Handle<'TextureAsset', 'shared'>>();
4704
- const videoTextureFields = new Map<string, Handle<'VideoAsset', 'shared'>>();
4705
- for (const field of userRegionFields) {
4706
- // D-5: a video-kind paramValue routes to the transient path
4707
- // (videoTextureFields), NOT validateTextureHandle (which drops
4708
- // kind!=='texture', the R-7 silent-fail path). Static fields fall
4709
- // through to validateTextureHandle unchanged.
4710
- const videoHandle =
4711
- assets !== null && assets !== undefined
4712
- ? resolveVideoFieldHandle(pv[field], world, assets)
4713
- : undefined;
4714
- if (videoHandle !== undefined) {
4715
- videoTextureFields.set(field, videoHandle);
4716
- continue;
4717
- }
4718
- const handle = validateTextureHandle(field, pv[field]);
4719
- if (handle !== undefined) textureHandles.set(field, handle);
4720
- }
4721
- const baseColorTextureHandle = textureHandles.get('baseColorTexture');
4722
- const metallicRoughnessTextureHandle = textureHandles.get('metallicRoughnessTexture');
4723
- const normalTextureHandle = textureHandles.get('normalTexture');
4724
- const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
4725
- resolveParamHandle(materialTextureRef(value), 'SamplerAsset'),
4726
- );
4727
- const emissiveTextureHandle = validateTextureHandle(
4728
- 'emissiveTexture',
4729
- pv.emissiveTexture,
4730
- );
4731
- const occlusionTextureHandle = validateTextureHandle(
4732
- 'occlusionTexture',
4733
- pv.occlusionTexture,
4734
- );
4735
- const textureCoordinates = collectMaterialTextureCoordinates(pv);
4736
- const textureSources = collectMaterialTextureSources(pv, world);
4737
- const emissivePv = pv.emissive as readonly number[] | undefined;
4738
- // feat-20260625 M2 / w6: first-pass transparency flag folds into
4739
- // MaterialSnapshot.transparent so the record stage can drive the
4740
- // LDR split + premultiplied-alpha blend decision without
4741
- // re-reading passes[]. feat-20260626-collapse M2: derive from
4742
- // `passes[0].renderState.blend !== undefined` (blend presence is
4743
- // the SSOT after MaterialPass.transparent was dropped).
4744
- // Result is plain boolean (always defined here) — written as-is
4745
- // into the snapshot (`boolean | undefined` field, see L759).
4746
- const firstPassTransparent: boolean = allPasses[0]?.renderState?.blend !== undefined;
4747
-
4748
- // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-8):
4749
- // narrow `forgeax::sprite` extract block --- folds the legacy user
4750
- // values format (flipX / flipY / slices / sliceMode + free
4751
- // region / pivot) into the UBO-aligned paramSnapshot vec4 fields
4752
- // (region / pivotAndSize / slicesAndMode + colorTint). Also folds
4753
- // per-entity SpriteRegionOverride (Q4=a). After this block the
4754
- // generic else branch picks up the snapshot via the same writer
4755
- // path PBR / unlit use; no more shadingModel='sprite' arm, no
4756
- // spriteFields POD (AC-02 / AC-07: extract has exactly 2 hard
4757
- // `forgeax::sprite` checks --- this fold + the slices mesh swap on
4758
- // the record side).
4759
- if (isSprite) {
4760
- // SpriteRegionOverride: per-entity per-frame region displacement.
4761
- let overrideRegion: readonly [number, number, number, number] | undefined;
4762
- if (hasSpriteRegionOverride) {
4763
- const overrideView = worldInternal._getArrayView(
4764
- entity,
4765
- SpriteRegionOverride,
4766
- 'region',
4767
- ) as Float32Array | undefined;
4768
- if (overrideView !== undefined && overrideView.length >= 4) {
4769
- overrideRegion = [
4770
- overrideView[0] ?? 0,
4771
- overrideView[1] ?? 0,
4772
- overrideView[2] ?? 1,
4773
- overrideView[3] ?? 1,
4774
- ];
4775
- }
4776
- }
4777
- // Region resolution priority: SpriteRegionOverride > paramSnapshot.
4778
- // region (UBO-aligned user input) > [0,0,1,1] identity.
4779
- const regionPv = paramSnap.region as readonly number[] | undefined;
4780
- let regionX = overrideRegion?.[0] ?? regionPv?.[0] ?? 0;
4781
- let regionY = overrideRegion?.[1] ?? regionPv?.[1] ?? 0;
4782
- let regionZ = overrideRegion?.[2] ?? regionPv?.[2] ?? 1;
4783
- let regionW = overrideRegion?.[3] ?? regionPv?.[3] ?? 1;
4784
- // flipX / flipY fold into region (D-8): the shader does
4785
- // `uv * region.zw + region.xy`, so flipping along U is a sign
4786
- // negation of region.z plus an origin offset.
4787
- const flipXPv = typeof pv.flipX === 'number' ? pv.flipX : 0;
4788
- const flipYPv = typeof pv.flipY === 'number' ? pv.flipY : 0;
4789
- if (flipXPv !== 0) {
4790
- regionX += regionZ;
4791
- regionZ = -regionZ;
4792
- }
4793
- if (flipYPv !== 0) {
4794
- regionY += regionW;
4795
- regionW = -regionW;
4796
- }
4797
- paramSnap.region = [regionX, regionY, regionZ, regionW];
4798
- // Guard: slicesAndMode must be present and zero for non-9-slice
4799
- // sprites so the record-stage UBO writer (applyParamSnapshotToUbo)
4800
- // writes [0,0,0,0] at offset 48 instead of leaving the
4801
- // buildPbrMaterialUboPayload PBR baseline (e.g. occlusionStrength=1
4802
- // at that slot). A non-zero slicesAndMode trips `useSlices=true`
4803
- // in sprite.wgsl, which degenerates HANDLE_QUAD geometry → invisible.
4804
- if (!('slicesAndMode' in paramSnap)) {
4805
- (paramSnap as Record<string, unknown>).slicesAndMode = [0, 0, 0, 0];
4806
- }
4807
- }
4808
-
4809
- // Generic materialShaderId snapshot --- sprite included now flows
4810
- // through this single branch (plan-strategy D-3 / AC-01 / AC-02 /
4811
- // AC-07). The sprite block above only writes paramSnap.region (D-8
4812
- // SpriteRegionOverride + flip fold); the rest of the UBO is filled
4813
- // by the same paramSchema-driven path PBR / unlit use.
4814
- materialSnap = {
4815
- baseColor,
4816
- metallic: metallicPv,
4817
- roughness: roughnessPv,
4818
- clearcoat: clearcoatPv,
4819
- clearcoatRoughness: clearcoatRoughnessPv,
4820
- ...(specularTintPv !== undefined && {
4821
- specularTint: [
4822
- specularTintPv[0] ?? 1,
4823
- specularTintPv[1] ?? 1,
4824
- specularTintPv[2] ?? 1,
4825
- ] as readonly [number, number, number],
4826
- }),
4827
- normalScale: normalScalePv,
4828
- materialShaderId: firstPassShader,
4829
- materialHandle: handleRaw,
4830
- renderState: pipelineRenderState(allPasses[0]?.renderState),
4831
- paramSnapshot: paramSnap,
4832
- ...(materialParamSchema.length > 0 && { materialParamSchema }),
4833
- ...(textureCoordinates.size > 0 && { textureCoordinates }),
4834
- ...(samplerHandles.size > 0 && { samplerHandles }),
4835
- ...(textureHandles.size > 0 && { textureHandles }),
4836
- ...(textureSources.size > 0 && { textureSources }),
4837
- ...(videoTextureFields.size > 0 && { videoTextureFields }),
4838
- ...(baseColorTextureHandle !== undefined && {
4839
- baseColorTexture: baseColorTextureHandle,
4840
- }),
4841
- ...(metallicRoughnessTextureHandle !== undefined && {
4842
- metallicRoughnessTexture: metallicRoughnessTextureHandle,
4843
- }),
4844
- ...(normalTextureHandle !== undefined && { normalTexture: normalTextureHandle }),
4845
- ...(emissivePv !== undefined && {
4846
- emissive: [emissivePv[0] ?? 0, emissivePv[1] ?? 0, emissivePv[2] ?? 0] as readonly [
4847
- number,
4848
- number,
4849
- number,
4850
- ],
4851
- }),
4852
- ...(typeof pv.emissiveIntensity === 'number' && {
4853
- emissiveIntensity: pv.emissiveIntensity,
4854
- }),
4855
- ...(emissiveTextureHandle !== undefined && {
4856
- emissiveTexture: emissiveTextureHandle,
4857
- }),
4858
- ...(occlusionTextureHandle !== undefined && {
4859
- occlusionTexture: occlusionTextureHandle,
4860
- }),
4861
- ...(typeof pv.occlusionStrength === 'number' && {
4862
- occlusionStrength: pv.occlusionStrength,
4863
- }),
4864
- transparent: firstPassTransparent,
4865
- };
4866
-
4867
- if (!hasSpriteRegionOverride && !hasSkin) {
4868
- const stored = storeMaterialSnapshot(
4869
- materialSnapshotCache,
4870
- handleRaw,
4871
- materialSnap,
4872
- allPasses,
4873
- asset,
4874
- assets,
4875
- );
4876
- if (stored.crossFrameSafe) persistentMaterialSnapshotCache?.set(handleRaw, stored);
4877
- }
4878
-
4879
- // Build dispatch entries from resolved passes.
4880
- if (isRenderable) {
4881
- appendMaterialDispatchEntries(
4882
- pendingDispatch,
4883
- allPasses,
4884
- entity,
4885
- handleRaw,
4886
- renderables.length,
4887
- layerVal,
4888
- paramSnap,
4889
- );
4890
- }
4891
- }
4892
- }
4893
- }
4894
-
4895
- // feat-20260609 M2/M5 corrective fixup: default-material entities
4896
- // (handleRaw===0 / case-B MeshRenderer{data:{}}) must produce
4897
- // ShadowCaster dispatch entries so the shadow pass includes them.
4898
- // The pre-existing logic only builds dispatch entries from
4899
- // resolved material assets; defaultMaterialSnapshot() (mid-grey unlit)
4900
- // left dispatch empty, causing shadow-m2/m3 test failures.
4901
- // Requirements §10.5: shadow-casting is default behaviour, opt-out
4902
- // via castShadow:false. The default material has no opt-out, so
4903
- // it casts shadows.
4904
- //
4905
- // CHARTER NOTE (feat-20260609 T-005-a): the URP literals
4906
- // `LightMode: 'ShadowCaster'` / `LightMode: 'Forward'` below are a
4907
- // local URP-bridge — they mirror what `Materials.unlit({ castShadow:
4908
- // true })` produces at the asset layer. The default-material
4909
- // handle=0 path bypasses asset registration, so we synthesize the
4910
- // same dispatch shape inline. Follow-up cleanup (F-1 from
4911
- // implement-review R1): thread default materials through the
4912
- // Materials factory so this block can call into the shared
4913
- // passes[] producer.
4914
- // tweak-20260701 M1: `materialSnap.shadingModel === 'unlit'` removed —
4915
- // for handleRaw===0, defaultMaterialSnapshot() was always unlit
4916
- // (the shadingModel check was a tautology); the isRenderable &&
4917
- // handleRaw===0 guard alone preserves the exact same dispatch shape.
4918
- if (isRenderable && handleRaw === 0) {
4919
- const shadowCasterTags: Record<string, string> = { LightMode: 'ShadowCaster' };
4920
- const nextRenderableIndex = renderables.length;
4921
- // M2.5: stage into pendingDispatch; flushed at the renderable push
4922
- // site below only when the entity survives cull.
4923
- pendingDispatch.push({
4924
- entityIndex: entity,
4925
- materialHandle: 0,
4926
- renderableIndex: nextRenderableIndex,
4927
- passIndex: 0,
4928
- queue: 2000,
4929
- layer: layerVal,
4930
- tags: shadowCasterTags,
4931
- renderState: undefined,
4932
- defines: undefined,
4933
- vertexEntry: 'vs_main',
4934
- fragmentEntry: undefined,
4935
- materialShaderId: 'forgeax::default-shadow-caster',
4936
- paramSnapshot: {},
4937
- });
4938
- // Also add a Forward pass entry so the entity renders in the
4939
- // main scene pass (mirrors Materials.unlit default).
4940
- appendMaterialDispatchEntries(
4941
- pendingDispatch,
4942
- [DEFAULT_FORWARD_PASS],
4943
- entity,
4944
- 0,
4945
- nextRenderableIndex,
4946
- layerVal,
4947
- {},
4948
- 1,
4949
- );
4950
- }
4951
-
4952
- if (isRenderable) {
4953
- // feat-20260612 M2 / m2-6: Skin + Instances coexistence + per-joint
4954
- // dangling fail-fast + real palette slice allocation. Replaces the
4955
- // T-21 placeholder ({0,0} discriminator-only sentinel) with full
4956
- // resolve / validate / write chain (D-9 reset already fired at
4957
- // extractFrame entry; per-entity allocate + writeJointPalette here).
4958
- let skinSlice: SkinPaletteSlice | undefined;
4959
- if (hasSkin) {
4960
- const skeletonHandleRaw = skinSkeletonView;
4961
- if (
4962
- skeletonHandleRaw !== undefined &&
4963
- skeletonHandleRaw !== 0 &&
4964
- assets !== undefined &&
4965
- assets !== null
4966
- ) {
4967
- // Skin + Instances coexistence is forbidden (D-10).
4968
- if (hasInstances) {
4969
- worldInternal._routeError(new SkinInstancesCoexistForbiddenError(entity), {
4970
- severity: Severity.Error,
4971
- systemName: 'RenderSystem.extract (skin-instances-coexist)',
4972
- });
4973
-
4974
- continue;
4975
- }
4976
- // (a) Resolve skeleton asset; on failure -> skeleton-resolve-failed.
4977
- const skeletonHandle = toShared<'SkeletonAsset'>(skeletonHandleRaw);
4978
- const skeletonRes = resolveAssetHandle<SkeletonAsset>(world, skeletonHandle);
4979
- if (!skeletonRes.ok || skeletonRes.value.kind !== 'skeleton') {
4980
- worldInternal._routeError(new SkeletonResolveFailedError(entity, skeletonHandleRaw), {
4981
- severity: Severity.Error,
4982
- systemName: 'RenderSystem.extract (skeleton-resolve-failed)',
4983
- });
4984
- continue;
4985
- }
4986
- const skeleton = skeletonRes.value;
4987
- // (b) Reuse the Skin row already read for its skeleton handle;
4988
- // the old path repeated a second whole-row world.get here.
4989
- const skinJoints = skin?.joints;
4990
- if (skinJoints === undefined) continue;
4991
- const jointsLength = skinJoints.length;
4992
- if (jointsLength !== skeleton.jointCount) {
4993
- worldInternal._routeError(
4994
- new JointCountMismatchError(entity, skeleton.jointCount, jointsLength),
4995
- {
4996
- severity: Severity.Error,
4997
- systemName: 'RenderSystem.extract (joint-count-mismatch)',
4998
- },
4999
- );
5000
- continue;
5001
- }
5002
- // (c) Resolve only each joint's Transform.world column. The
5003
- // transient view is consumed before a structural mutation and
5004
- // therefore preserves the same dangling-joint behavior without
5005
- // constructing every other Transform field.
5006
- // Build mat4 list eagerly so write happens once per entity (no
5007
- // half-written slice on dangling).
5008
- const jointWorlds = new Array<Mat4>(skeleton.jointCount);
5009
- let jointDangling = -1;
5010
- for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
5011
- const jointEntityRaw = skinJoints[jIdx] ?? 0;
5012
- const jointEntity = jointEntityRaw as EntityHandle;
5013
- const jointWorld = worldInternal._getArrayView(jointEntity, Transform, 'world');
5014
- if (jointWorld === undefined) {
5015
- jointDangling = jIdx;
5016
- break;
5017
- }
5018
- // The allocator consumes the canonical Mat4 brand. Copy the
5019
- // column view into that owner-created value so the ECS storage
5020
- // view never crosses the math brand boundary by assertion.
5021
- const jointWorldMat = mat4.create();
5022
- jointWorldMat.set(jointWorld);
5023
- jointWorlds[jIdx] = jointWorldMat;
5024
- }
5025
- if (jointDangling >= 0) {
5026
- worldInternal._routeError(new JointEntityDanglingError(entity, jointDangling), {
5027
- severity: Severity.Error,
5028
- systemName: 'RenderSystem.extract (joint-entity-dangling)',
5029
- });
5030
- continue;
5031
- }
5032
- // (d) Slice the IBM flat Float32Array into per-joint Float32Arrays.
5033
- // skeleton.inverseBindMatrices length === jointCount * 16.
5034
- const ibmFlat = skeleton.inverseBindMatrices;
5035
- const ibms: Float32Array[] = new Array<Float32Array>(skeleton.jointCount);
5036
- for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
5037
- ibms[jIdx] = ibmFlat.subarray(jIdx * 16, jIdx * 16 + 16);
5038
- }
5039
- // (e) Allocate slice + write palette via the allocator (D-9 reset
5040
- // already fired at extractFrame entry). When the pipelineState
5041
- // surface is absent (test fixtures that pass undefined) the
5042
- // hasSkin segment is skipped silently — bind-pose equivalent.
5043
- if (skinPaletteAllocator !== null) {
5044
- const slice = skinPaletteAllocator.allocateSlice(skeleton.jointCount);
5045
- skinPaletteAllocator.writeJointPalette(slice, ibms, jointWorlds);
5046
- skinSlice = {
5047
- jointCount: slice.jointCount,
5048
- byteOffset: slice.byteOffset,
5049
- buffer: slice.buffer,
5050
- };
5051
- }
5052
- }
5053
- }
5054
-
5055
- // A pbr-skin PSO requires the complete three-binding group(2) skin
5056
- // projection. Do not publish a renderable or its staged dispatch when
5057
- // palette extraction was unavailable; the record stage must never
5058
- // reinterpret a skinned vertex stream through an ordinary mesh group.
5059
- if (materialSnap.materialShaderId === 'forgeax::pbr-skin' && skinSlice === undefined) {
5060
- pendingDispatch.length = 0;
5061
- continue;
5062
- }
5063
-
5064
- // feat-20260601 D-3: read the resolved world mat4 (propagateTransforms
5065
- // output) straight from the Transform.world column array view. The
5066
- // record stage copies these 16 floats into the mesh SSBO with zero
5067
- // per-snapshot `mat4.compose` (AC-07). A stale slot (generation gone)
5068
- // skips the renderable, mirroring the Instances dangling-row sweep.
5069
- // tweak-20260611 M1 / K-3: `_getArrayView` call survives untouched;
5070
- // only the `entity` source switched to `bundle.Entity.self[i]`.
5071
- const worldView = worldInternal._getArrayView(entity, Transform, 'world');
5072
- if (worldView === undefined) continue;
5073
- const worldMat = new Float32Array(worldView);
5074
- const transformSnap: TransformSnapshot = { world: worldMat };
5075
- // feat-20260608 M5 amend / w11-a: per-submesh `materials[]` array
5076
- // aligned 1-1 with `MeshAsset.submeshes[]`. materials[0] === the
5077
- // representative entity-level snapshot already built; materials[i>=1]
5078
- // are resolved via `resolveMaterialSnapshot` (a non-sprite, single-
5079
- // pass-equivalent resolver — sprite per-submesh is OOS-1). When the
5080
- // entity has no materialsView (case-B sentinel) the array is a single
5081
- // mid-grey default mirroring the legacy single-material path so the
5082
- // record stage's per-submesh UBO upload loop trivially writes one
5083
- // slot, no special branch.
5084
- const materialsArr: MaterialSnapshot[] = [materialSnap];
5085
- if (assets !== undefined && assets !== null) {
5086
- for (let mi = 1; mi < materialHandles.length; mi++) {
5087
- const subHandle = materialHandles[mi] ?? 0;
5088
- const cachedSubmaterial = materialSnapshotCache.get(subHandle);
5089
- materialsArr.push(
5090
- cachedSubmaterial?.snapshot ??
5091
- resolveMaterialSnapshot(
5092
- subHandle,
5093
- world,
5094
- assets,
5095
- materialSnapshotCache,
5096
- persistentMaterialSnapshotCache,
5097
- ),
5098
- );
5099
- }
5100
- }
5101
- if (isRenderable && assets !== undefined && assets !== null) {
5102
- for (let mi = 1; mi < materialHandles.length; mi++) {
5103
- const subHandle = materialHandles[mi] ?? 0;
5104
- if (subHandle === handleRaw) continue;
5105
- const subEntry =
5106
- materialSnapshotCache.get(subHandle) ??
5107
- readPersistentMaterialSnapshot(persistentMaterialSnapshotCache, subHandle, assets);
5108
- appendMaterialDispatchEntries(
5109
- pendingDispatch,
5110
- subEntry?.passes ?? (subHandle === 0 ? [DEFAULT_FORWARD_PASS] : []),
5111
- entity,
5112
- subHandle,
5113
- renderables.length,
5114
- layerVal,
5115
- materialsArr[mi]?.paramSnapshot,
5116
- );
5117
- }
5118
- }
5119
-
5120
- // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 /
5121
- // w10: SpriteInstances validation + snapshot materialisation.
5122
- // Three structured EcsError fires at this single point (plan-strategy
5123
- // D-6 "fail-fast at the render domain entry, not at ECS spawn-time"):
5124
- let spriteInstancesSnap: SpriteInstancesSnapshot | undefined;
5125
- if (hasSpriteInstances) {
5126
- // (1) mutually exclusive with Instances (peers — pick one).
5127
- if (hasInstances) {
5128
- worldInternal._routeError(
5129
- new SpriteInstancesMutuallyExclusiveWithInstancesError(entity),
5130
- {
5131
- severity: Severity.Error,
5132
- systemName: 'RenderSystem.extract (sprite-instances-mutually-exclusive)',
5133
- },
5134
- );
5135
- continue;
5136
- }
5137
- // (2) requires sprite shader — the per-instance UV region is
5138
- // consumed by the sprite vertex shader path only (plan-strategy D-4
5139
- // axis on sprite.wgsl). Post-collapse (PR #520): sprite is no longer
5140
- // a `shadingModel` enum member; identification is via the first-pass
5141
- // `materialShaderId === 'forgeax::sprite'` (OOS-1 path retained).
5142
- //
5143
- // feat-20260624 M1' / t6: `'forgeax::sprite-lit'` also walks the same
5144
- // per-instance UV region vertex path (VsOut byte-identical, paramSchema
5145
- // mirror); accept either shader id.
5146
- if (
5147
- materialSnap.materialShaderId !== 'forgeax::sprite' &&
5148
- materialSnap.materialShaderId !== 'forgeax::sprite-lit'
5149
- ) {
5150
- worldInternal._routeError(
5151
- new SpriteInstancesRequiresSpriteShaderError(
5152
- entity,
5153
- materialSnap.materialShaderId ?? 'undefined',
5154
- ),
5155
- {
5156
- severity: Severity.Error,
5157
- systemName: 'RenderSystem.extract (sprite-instances-requires-sprite-shader)',
5158
- },
5159
- );
5160
- continue;
5161
- }
5162
- // (3) count mismatch — transforms.length / 16 === regions.length / 4
5163
- // (transforms.length=0 + regions.length=0 is the zero-instance lawful
5164
- // boundary; both derivations are 0 and equality holds, so no fire).
5165
- const transforms = worldInternal._getArrayView(entity, SpriteInstances, 'transforms');
5166
- const regions = worldInternal._getArrayView(entity, SpriteInstances, 'regions');
5167
- if (transforms !== undefined && regions !== undefined) {
5168
- const transformsLength = transforms.length;
5169
- const regionsLength = regions.length;
5170
- // Stride sanity: transforms must be mod 16, regions must be mod 4.
5171
- // A stride violation expresses as a count mismatch under the
5172
- // canonical derivation transforms/16 vs regions/4 — fire the
5173
- // count-mismatch code (the same code carries detail.expectedStride).
5174
- const tCount = transformsLength / 16;
5175
- const rCount = regionsLength / 4;
5176
- if (transformsLength % 16 !== 0 || regionsLength % 4 !== 0 || tCount !== rCount) {
5177
- worldInternal._routeError(
5178
- new SpriteInstancesCountMismatchError(transformsLength, regionsLength),
5179
- {
5180
- severity: Severity.Error,
5181
- systemName: 'RenderSystem.extract (sprite-instances-count-mismatch)',
5182
- },
5183
- );
5184
- continue;
5185
- }
5186
- // Validation passes — build the snapshot. transforms.length === 0
5187
- // is lawful (zero-instance) and produces instanceCount=0; the
5188
- // record stage skips drawIndexed when instanceCount===0.
5189
- const transformsCopy = new Float32Array(transforms);
5190
- const regionsCopy = new Float32Array(regions);
5191
- spriteInstancesSnap = {
5192
- transforms: transformsCopy,
5193
- regions: regionsCopy,
5194
- instanceCount: tCount,
5195
- cacheKey: entity,
5196
- archVersion,
5197
- };
5198
- }
5199
- }
5200
-
5201
- let localAabb: Float32Array | undefined;
5202
- let morph: MorphSnapshot | undefined;
5203
- const assetHandleRaw = Math.round(fAssetHandle ?? 0);
5204
- if (assetHandleRaw !== 0) {
5205
- const meshRes = resolveAssetHandle(world, toShared<'MeshAsset'>(assetHandleRaw));
5206
- if (meshRes.ok && meshRes.value.kind === 'mesh') {
5207
- const meshAsset = meshRes.value as MeshAsset;
5208
- morph = morphSnapshotFor(meshAsset, morphWeightsView);
5209
- const meshAabb = meshAsset.aabb;
5210
- // Morph targets can expand the authored bounds. Keep the
5211
- // specialized lane conservative until a target-aware bounds
5212
- // projection is available; a false-positive draw is preferable
5213
- // to culling a valid deformed vertex.
5214
- if (morph === undefined && hasFiniteOrderedLocalAabb(meshAabb)) {
5215
- localAabb = new Float32Array(meshAabb);
5216
- }
5217
- }
5218
- }
5219
-
5220
- const pointsLinesStyle: PointsLinesStyle | undefined =
5221
- points !== undefined
5222
- ? (() => {
5223
- const shape = pointShapeFromU32(points.shape);
5224
- return shape === undefined
5225
- ? undefined
5226
- : { kind: 'points' as const, sizePx: points.sizePx, shape };
5227
- })()
5228
- : lines === undefined
5229
- ? undefined
5230
- : { kind: 'lines' as const, widthPx: lines.widthPx };
5231
- const cullingLocalAabb = expandPointsLinesBounds(localAabb ?? [], pointsLinesStyle);
5232
- const pointsLines =
5233
- pointsLinesComponent === undefined
5234
- ? undefined
5235
- : ({
5236
- worldId: 0,
5237
- entityKey: entity,
5238
- component: pointsLinesComponent,
5239
- meshHandle: assetHandleRaw,
5240
- meshGeneration: assets?.catalogEpoch ?? 0,
5241
- materialHandle: handleRaw,
5242
- materialGeneration: assets?.catalogEpoch ?? 0,
5243
- style: pointsLinesStyle,
5244
- layer: layerVal,
5245
- sortKey,
5246
- visible: true,
5247
- sourceBounds: cullingLocalAabb,
5248
- viewport: { width: 0, height: 0, dpr: 1 },
5249
- projection: identityProjection(),
5250
- } satisfies PointsLinesRetainedSnapshot);
5251
-
5252
- // Instances are snapshotted before the CPU visibility decision so the
5253
- // culling owner can derive the union of every instance's transformed
5254
- // mesh bounds. The same detached snapshot is then carried to record;
5255
- // do not re-read the ECS array after culling (a structural write must
5256
- // not make the cull and draw disagree within one frame).
5257
- let instancesSnap: InstancesSnapshot | undefined;
5258
- if (hasInstances) {
5259
- const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
5260
- if (transforms === undefined) {
5261
- // A present component with an unavailable array is an empty
5262
- // instance contribution, not an absent component. Preserve the
5263
- // zero-draw contract so the record stage cannot synthesize one
5264
- // identity instance from an incomplete row.
5265
- instancesSnap = {
5266
- transforms: new Float32Array(),
5267
- instanceCount: 0,
5268
- cacheKey: entity,
5269
- archVersion,
5270
- };
5271
- } else {
5272
- const actualLength = transforms.length;
5273
- if (actualLength % 16 !== 0) {
5274
- worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
5275
- severity: Severity.Error,
5276
- systemName: 'RenderSystem.extract (Instances stride)',
5277
- });
5278
- continue;
5279
- }
5280
- const snapshotCopy = new Float32Array(transforms);
5281
- instancesSnap = {
5282
- transforms: snapshotCopy,
5283
- instanceCount: actualLength / 16,
5284
- cacheKey: entity,
5285
- archVersion,
5286
- };
5287
- }
5288
- }
5289
-
5290
- let nonIndexedFirst = 0;
5291
- const gpuDrivenDraws = gpuDrivenSubmeshes.flatMap((submesh) => {
5292
- const drawMaterial = materialsArr[submesh.materialSlot] ?? materialSnap;
5293
- const first = gpuDrivenIndexed ? submesh.indexOffset : nonIndexedFirst;
5294
- nonIndexedFirst += submesh.vertexCount;
5295
- if (drawMaterial.transparent === true) return [];
5296
- return [
5297
- {
5298
- kind: gpuDrivenIndexed ? ('indexed' as const) : ('non-indexed' as const),
5299
- first,
5300
- count: gpuDrivenIndexed ? submesh.indexCount : submesh.vertexCount,
5301
- baseVertex: 0,
5302
- materialSlot: submesh.materialSlot,
5303
- topology: submesh.topology,
5304
- pipelineClass: `${drawMaterial.materialShaderId ?? 'forgeax::default-unlit'}|${submesh.topology}|${JSON.stringify(drawMaterial.renderState ?? null)}`,
5305
- materialResourceClass: gpuDrivenMaterialResourceClass(drawMaterial),
5306
- },
5307
- ];
5308
- });
5309
- const baseRenderable: RenderableSnapshot = {
5310
- assetHandle: Math.round(fAssetHandle ?? 0),
5311
- transform: transformSnap,
5312
- ...(localAabb !== undefined ? { localAabb: cullingLocalAabb } : {}),
5313
- material: materialSnap,
5314
- materials: materialsArr,
5315
- materialBindingSources,
5316
- materialBindingDiagnostics,
5317
- ...(morph === undefined && gpuDrivenDraws.length > 0 ? { gpuDrivenDraws } : {}),
5318
- worldId: 0,
5319
- entityKey: entity,
5320
- ...(skinSlice !== undefined ? { skin: skinSlice } : {}),
5321
- ...(morph !== undefined ? { morph } : {}),
5322
- ...(instancesSnap !== undefined ? { instances: instancesSnap } : {}),
5323
- ...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),
5324
- ...(pointsLines !== undefined ? { pointsLines } : {}),
5325
- };
5326
-
5327
- // feat-20260528-frustum-culling M3 / w10: frustum culling check.
5328
- // Skip the entity if a valid AABB exists AND ALL cameras' frusta
5329
- // reject the world-space AABB. Missing or malformed AABBs are
5330
- // conservative always-visible fallbacks; valid culling bounds are
5331
- // producer-owned finite local-space AABBs. Culling is unconditional
5332
- // engine behavior; there is no per-entity opt-out.
5333
- if (localAabb !== undefined) {
5334
- // An empty or malformed Instances payload has no meaningful bounds.
5335
- // Keep it conservative (the draw path later emits zero instances)
5336
- // instead of manufacturing the entity's identity bounds. For a
5337
- // valid payload, derive one world-space union for CPU culling while
5338
- // leaving the mesh-local AABB untouched for GPU per-instance cull.
5339
- const derivedInstancesBounds =
5340
- instancesSnap === undefined || instancesSnap.instanceCount === 0
5341
- ? undefined
5342
- : deriveInstancesUnionBounds({
5343
- meshAabb: cullingLocalAabb,
5344
- entityWorld: transformSnap.world,
5345
- transforms: instancesSnap.transforms,
5346
- });
5347
- if (instancesSnap?.instanceCount === 0) {
5348
- // No instance can contribute a visible bound. The retained
5349
- // snapshot still records instanceCount=0 for the record stage.
5350
- } else if (
5351
- hasInstances &&
5352
- (instancesSnap === undefined || derivedInstancesBounds === undefined)
5353
- ) {
5354
- // Missing/malformed derived facts are a conservative no-cull
5355
- // result. The stride validator above already rejects malformed
5356
- // lengths; this branch covers non-finite producer matrices/AABB.
5357
- } else {
5358
- // feat-20260601 D-3: cull AABB uses the resolved world mat4
5359
- // directly (no compose) -- same source the record stage feeds
5360
- // the mesh SSBO, so cull stays same-source with render (AC-05).
5361
- const worldAabb = box3.create();
5362
- if (derivedInstancesBounds !== undefined) {
5363
- worldAabb[0] = derivedInstancesBounds[0] ?? 0;
5364
- worldAabb[1] = derivedInstancesBounds[1] ?? 0;
5365
- worldAabb[2] = derivedInstancesBounds[2] ?? 0;
5366
- worldAabb[3] = derivedInstancesBounds[3] ?? 0;
5367
- worldAabb[4] = derivedInstancesBounds[4] ?? 0;
5368
- worldAabb[5] = derivedInstancesBounds[5] ?? 0;
5369
- } else {
5370
- box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
5371
- }
5372
-
5373
- // Test against all cameras. Entity is visible if one camera
5374
- // frustum intersects the world-space AABB (or planes are empty
5375
- // from degenerate projection).
5376
- frustumTotal += 1;
5377
- let visible = frustumPlanes.length === 0;
5378
- for (let ci = 0; ci < frustumPlanes.length; ci++) {
5379
- const planes = frustumPlanes[ci] as Float32Array;
5380
- if (planes.length === 0) {
5381
- visible = true;
5382
- break;
5383
- }
5384
- if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
5385
- visible = true;
5386
- break;
5387
- }
5388
- }
5389
- if (!visible) {
5390
- frustumCulled += 1;
5391
- continue;
5392
- }
5393
- }
5394
- }
5395
-
5396
- flushPendingDispatch(pendingDispatch);
5397
- renderables.push(baseRenderable);
5398
- }
5399
-
5400
- // feat-20260520-2d-sprite-layer-mvp M-3 / w22 + w25: finalise the
5401
- // pending TransparentEntry with the renderableIndex pointing at the
5402
- // RenderableSnapshot we just pushed (when isRenderable === true).
5403
- // The check below also covers a sprite entity that survives the
5404
- // dangling-Instances branch (silent skip with `dispatchEntry !==
5405
- // null` early-continue) — in that case renderableIndex is still set
5406
- // to the just-pushed slot which is correct because materialDispatch
5407
- // already captures the dispatch position.
5408
- }
5409
- }
5410
-
5411
- // M3 / w26: sort dispatch entries by queue (ascending, stable sort)
5412
- // per plan-strategy D-3.
5413
- dispatch = sortDispatchByQueue(dispatch);
5414
-
5415
- // D-1: collect PostProcessParams entities into Map<shaderId, Uint8Array>.
5416
- // Last-one-wins when multiple entities bear the same shader id (mirrors
5417
- // Camera.exposure -> CameraSnapshot pattern; extract stage only reads).
5418
- const postProcessParams: Map<string, Uint8Array> = new Map();
5419
- const postProcessParamsQuery = world.query({ with: [PostProcessParams] }).unwrap();
5420
- for (const row of postProcessParamsQuery) {
5421
- const entity = row.entity;
5422
- const read = world.get(entity, PostProcessParams);
5423
- if (!read.ok) continue;
5424
- postProcessParams.set(read.value.shader, read.value.data);
5425
- }
5426
-
5427
- // feat-20260621 M-A3 / w13 (D-5): engine built-in tonemap data-driven
5428
- // provider. The engine bridges the active camera's `Camera.exposure /
5429
- // whitePoint / tonemap` onto the SAME unified params channel custom
5430
- // post-processes use — `Camera.exposure` stays the AI-user-facing SSOT (D-5),
5431
- // the engine itself acts as the provider for the Standard tonemap shader
5432
- // id. The 16B layout keeps the prior recordTonemapPass packing
5433
- // (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _,
5434
- // ditherEnabled], with the mode u32 occupying the third 4-byte slot via
5435
- // tonemapToU32 (SSOT in camera.ts). The final Output Transform makes a
5436
- // per-pass copy and writes ditherEnabled at byte offset 12; the extracted
5437
- // camera snapshot remains the SSOT. Run AFTER the user-entity collection above so the engine's
5438
- // built-in provider is authoritative for its own reserved key (a user entity
5439
- // can never shadow the Standard tonemap identity. The single active camera mirrors
5440
- // recordFrame's `activeCameras[0]` selection.
5441
- const tonemapCamera = cameras[0];
5442
- if (tonemapCamera !== undefined) {
5443
- postProcessParams.set(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, tonemapParams(tonemapCamera));
5444
- }
5445
-
5446
- return {
5447
- cameras,
5448
- auxiliaryCameras,
5449
- cubeCameras,
5450
- reflectionProbes,
5451
- lights,
5452
- environment,
5453
- environmentReady: true,
5454
- ...(volumetricFog === undefined ? {} : { volumetricFog }),
5455
- renderables,
5456
- dispatch,
5457
- skylight,
5458
- skylightCount,
5459
- skybox,
5460
- skyboxCount,
5461
- fog,
5462
- ...(fogFailure === undefined ? {} : { fogFailure }),
5463
- frustumStats: { culled: frustumCulled, total: frustumTotal },
5464
- visibilityStats: { explicitlyHidden: explicitlyHidden.size },
5465
- postProcessParams,
5466
- visibilitySnapshots: [context.visibility],
5467
- featureVisibilitySnapshots: [{ world, snapshot: context.visibility }],
5468
- hiddenEntityReports: [...explicitlyHidden].map((entity) => ({ world, entity })),
5469
- };
5470
- }
5471
-
5472
- export function defaultMaterialSnapshot(materialHandle = 0): MaterialSnapshot {
5473
- // Mid-grey unlit fallback (D-Q7 case B + extract-stage missing-spec),
5474
- // matching the pre-w6 visual outcome where the record stage's force-cast
5475
- // read of `firstMaterial.baseColorTexture` returned undefined and the
5476
- // unlit fallback shader was selected.
5477
- return {
5478
- baseColor: vec3.create(0.5, 0.5, 0.5),
5479
- metallic: 0,
5480
- roughness: 1,
5481
- materialHandle,
5482
- };
5483
- }
5484
-
5485
- function identityProjection(): Float32Array {
5486
- const projection = new Float32Array(16);
5487
- projection[0] = 1;
5488
- projection[5] = 1;
5489
- projection[10] = 1;
5490
- projection[15] = 1;
5491
- return projection;
5492
- }
2998
+ export { defaultMaterialSnapshot, extractFrame, extractFrames } from './render-system-extract-tail';