@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
@@ -0,0 +1,2983 @@
1
+ import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
+ import {
3
+ resolveAssetHandle,
4
+ walkMaterialPassesOverSharedRefs,
5
+ } from '@forgeax/engine-assets-runtime';
6
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
7
+ import {
8
+ InstanceTransformsStrideMismatchError,
9
+ routeWorldError,
10
+ SpawnLightInvalidBoundsError,
11
+ SpriteInstancesCountMismatchError,
12
+ SpriteInstancesMutuallyExclusiveWithInstancesError,
13
+ SpriteInstancesRequiresSpriteShaderError,
14
+ } from '@forgeax/engine-ecs/projection';
15
+ import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
16
+ import { box3, frustum, type Mat4, mat4, type Vec3, vec3 } from '@forgeax/engine-math';
17
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
18
+ import { RhiError } from '@forgeax/engine-rhi';
19
+ import { GlobalTransform, MorphWeights, Transform } from '@forgeax/engine-scene';
20
+ import type { MaterialShaderArtifact } from '@forgeax/engine-shader';
21
+ import {
22
+ JointCountMismatchError,
23
+ JointEntityDanglingError,
24
+ SkeletonResolveFailedError,
25
+ Skin,
26
+ SkinInstancesCoexistForbiddenError,
27
+ } from '@forgeax/engine-skinning';
28
+ import type {
29
+ Asset,
30
+ Handle,
31
+ IesProfileAsset,
32
+ MeshAsset,
33
+ MeshLodLevel,
34
+ SkeletonAsset,
35
+ TextureAsset,
36
+ } from '@forgeax/engine-types';
37
+ import {
38
+ ASSET_ERROR_HINTS,
39
+ AssetError,
40
+ derive,
41
+ handleSlot,
42
+ materialValuesToLinearRuntime,
43
+ toShared,
44
+ } from '@forgeax/engine-types';
45
+ import {
46
+ Atmosphere,
47
+ DirectionalLight,
48
+ Instances,
49
+ Layer,
50
+ LightProbe,
51
+ Lines,
52
+ MeshFilter,
53
+ MeshRenderer,
54
+ PointLight,
55
+ PointLightShadow,
56
+ Points,
57
+ PostProcessParams,
58
+ pointShapeFromU32,
59
+ RectAreaLight,
60
+ SkyboxBackground,
61
+ Skylight,
62
+ SortKey,
63
+ SpotLight,
64
+ SpriteInstances,
65
+ SpriteRegionOverride,
66
+ } from './components';
67
+ import {
68
+ type DirectionalShadowQuality,
69
+ directionalShadowQualityFromF32,
70
+ } from './components/directional-shadow-filter';
71
+ import { GlyphText } from './components/glyph-text';
72
+ import {
73
+ computeInvRangeSquared,
74
+ degToCos,
75
+ type LightValidationError,
76
+ validateDirectionalLightData,
77
+ validateLightProbeData,
78
+ validateRectAreaLightData,
79
+ } from './components/light-helpers';
80
+ import { selectEnvironment } from './environment/frame';
81
+ import { MaterialSkinAttrMissingError, SkinMaterialMismatchError } from './errors/render';
82
+ import type { EnvironmentCandidate, EnvironmentFrame } from './extract/environment';
83
+ import { buildGpuDrivenDraws, resolvePreparedGpuDrivenDraw } from './extract/gpu-driven';
84
+ import type { VisibilitySnapshot } from './extract/visibility';
85
+ import type {
86
+ RenderFeatureHiddenEntityReport,
87
+ RenderFeatureWorldVisibilitySnapshot,
88
+ } from './features/types';
89
+ import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
90
+ import type { GpuDrivenGeometryReceipt } from './gpu-driven/prepared-draw';
91
+ import { deriveInstancesUnionBounds } from './instances-derived-bounds';
92
+ import {
93
+ type MeshMaterialBindingDiagnostic,
94
+ type MeshMaterialBindingSource,
95
+ resolveMeshMaterialBindings,
96
+ } from './mesh-material-bindings';
97
+ import { isStandardPbrSkinMaterialShader } from './pbr-pipeline';
98
+ import { expandPointsLinesBounds } from './points-lines/bounds';
99
+ import type { PointsLinesRetainedSnapshot, PointsLinesStyle } from './points-lines/snapshot';
100
+ import { COOKIE_SLICE_CAPACITY, IES_SLICE_CAPACITY } from './prepare/extended-lighting/resources';
101
+ import { prepareCookieProjection } from './prepare/extended-lighting/spot-modifiers';
102
+ import { STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from './render-contract';
103
+ import type {
104
+ CsmCameraData,
105
+ DirectionalCsmConfig,
106
+ DirectionalLightSnapshot,
107
+ DispatchEntry,
108
+ ExtractedFrame,
109
+ ExtractedLights,
110
+ ExtractFramesOwner,
111
+ ExtractPipelineSurface,
112
+ InstancesSnapshot,
113
+ LightProbeSnapshot,
114
+ MaterialSnapshot,
115
+ MaterialSnapshotCache,
116
+ MaterialSnapshotCachesByWorld,
117
+ MorphSnapshot,
118
+ PointLightSnapshot,
119
+ PointShadowSnapshot,
120
+ PreparedExtractContext,
121
+ RectAreaDirectLightSnapshot,
122
+ RenderableSnapshot,
123
+ SkyboxSnapshot,
124
+ SkylightSnapshot,
125
+ SpotLightSnapshot,
126
+ SpriteInstancesSnapshot,
127
+ TransformSnapshot,
128
+ } from './render-system-extract';
129
+ import {
130
+ appendMaterialDispatchEntries,
131
+ assetReferenceText,
132
+ BUILTIN_USER_REGION_TEXTURE_FIELDS,
133
+ buildPointShadowMatrices,
134
+ buildRectAreaWorldFrame,
135
+ collectCubeCameraSnapshots,
136
+ collectMaterialTextureCoordinates,
137
+ collectMaterialTextureSamplers,
138
+ collectMaterialTextureSources,
139
+ collectReflectionProbeFacts,
140
+ computeDirectionalCsm,
141
+ createWorldInternalView,
142
+ DEFAULT_FORWARD_PASS,
143
+ extractVolumeSnapshot,
144
+ fogState,
145
+ internSharedRefFromGuid,
146
+ isEngineInjectedTextureField,
147
+ materialColorParameterSchema,
148
+ materialNormalScale,
149
+ materialParametersToParamSchema,
150
+ materialTextureFields,
151
+ materialTextureRef,
152
+ pipelineRenderState,
153
+ prepareExtractContext,
154
+ pssmSplit,
155
+ readPersistentMaterialSnapshot,
156
+ readStablePersistentMaterialSnapshot,
157
+ resolveMaterialSnapshot,
158
+ resolveVideoFieldHandle,
159
+ runtimeMaterialShaderId,
160
+ Severity,
161
+ selectCameraRoles,
162
+ selectFogFrame,
163
+ sortDispatchByQueue,
164
+ storeMaterialSnapshot,
165
+ tonemapParams,
166
+ } from './render-system-extract';
167
+ import { SHADOW_ATLAS_DEFAULT_LAYERS } from './shadow-atlas';
168
+ import type { SkinPaletteSlice } from './systems/skin-palette-types';
169
+ export function extractFrames(
170
+ worlds: readonly World[],
171
+ owner: number | ExtractFramesOwner,
172
+ assets?: AssetRegistry | null,
173
+ pipelineState?: ExtractPipelineSurface | null,
174
+ materialSnapshotCachesByWorld?: MaterialSnapshotCachesByWorld,
175
+ options: {
176
+ readonly cull?: 'normal' | 'none';
177
+ readonly renderables?: 'full' | 'none';
178
+ readonly getMaterialShaderArtifact?: (
179
+ materialShaderId: string,
180
+ ) => MaterialShaderArtifact | undefined;
181
+ } = {},
182
+ ): ExtractedFrame {
183
+ // w4: normalize the owner argument. A bare number is the legacy single-owner
184
+ // form (cameraOwner === resourceOwner); an object carries the two split
185
+ // indices. When they coincide the code path is byte-identical to the pre-w4
186
+ // single-owner behaviour (w1 contract combination 2).
187
+ const cameraOwner = typeof owner === 'number' ? owner : owner.cameraOwner;
188
+ const resourceOwner = typeof owner === 'number' ? owner : owner.resourceOwner;
189
+
190
+ // ── D-2: frame-level side effects live here ────────────────────────────
191
+ //
192
+ // resetForFrame is called exactly once per frame, at the extractFrames
193
+ // entry. The skinPaletteAllocator cursor is reset before each per-world
194
+ // extract runs, so sequential per-world allocation yields non-overlapping
195
+ // palette slices (AC-08).
196
+ const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
197
+ if (skinPaletteAllocator !== null) {
198
+ skinPaletteAllocator.resetForFrame();
199
+ }
200
+
201
+ const failedWorlds = new Set<World>();
202
+ // Resource-owner selection is a frame-level concern. Select Fog once before
203
+ // per-world extraction so non-owner worlds do not scan their ECS tables for
204
+ // a value that the merge layer will discard.
205
+ const resourceOwnerWorld = worlds[resourceOwner];
206
+ const resourceOwnerFog =
207
+ resourceOwnerWorld === undefined ? null : (selectFogFrame(resourceOwnerWorld) ?? null);
208
+ // ── D-2: per-world extract with error isolation ────────────────────────
209
+ //
210
+ // Each world runs extractFrame over the final state published by
211
+ // world.update(). Failure in one world is caught, routed to that world's
212
+ // _routeError (systemName carries worldId for source identification),
213
+ // and the world's contribution is skipped (AC-09 graceful degradation).
214
+
215
+ // The camera-owner frame must be extracted first: non-owner worlds do not
216
+ // carry the surfaced camera, but their renderables still need to be tested
217
+ // against that camera's frustum. Frames are placed back into worlds[] order
218
+ // before merge so queue / directional-light / worldId semantics stay stable.
219
+ const succeededFrames: ExtractedFrame[] = [];
220
+ const succeededIndices: number[] = [];
221
+
222
+ const extractionOrder = Array.from({ length: worlds.length }, (_, wi) => wi).sort((a, b) => {
223
+ if (a === cameraOwner) return -1;
224
+ if (b === cameraOwner) return 1;
225
+ return a - b;
226
+ });
227
+ const framesByWorld = new Map<number, ExtractedFrame>();
228
+
229
+ for (const wi of extractionOrder) {
230
+ const world = worlds[wi];
231
+ if (world === undefined || failedWorlds.has(world)) continue;
232
+ try {
233
+ const isCameraOwner = wi === cameraOwner;
234
+ const cameraOwnerFrame = framesByWorld.get(cameraOwner);
235
+ const prepared = prepareExtractContext(world, {
236
+ ...(assets !== undefined ? { assets } : {}),
237
+ ...(pipelineState !== undefined ? { pipelineState } : {}),
238
+ resourceOwnerFog: wi === resourceOwner ? resourceOwnerFog : null,
239
+ ...(materialSnapshotCachesByWorld === undefined
240
+ ? {}
241
+ : {
242
+ materialSnapshotCache:
243
+ materialSnapshotCachesByWorld.get(world) ??
244
+ (() => {
245
+ const cache: MaterialSnapshotCache = new Map();
246
+ materialSnapshotCachesByWorld.set(world, cache);
247
+ return cache;
248
+ })(),
249
+ }),
250
+ cull: options.cull === 'none' ? 'none' : isCameraOwner ? 'self' : 'external',
251
+ renderables: options.renderables ?? 'full',
252
+ ...(options.getMaterialShaderArtifact === undefined
253
+ ? {}
254
+ : { getMaterialShaderArtifact: options.getMaterialShaderArtifact }),
255
+ ...(cameraOwnerFrame === undefined ? {} : { cullCameras: cameraOwnerFrame.cameras }),
256
+ });
257
+ const frame = extractFrame(world, prepared);
258
+
259
+ framesByWorld.set(wi, frame);
260
+ } catch (err) {
261
+ // Per-world failure: route to world's own error handler, skip contribution.
262
+ try {
263
+ createWorldInternalView(world)._routeError(err, {
264
+ severity: Severity.Error,
265
+ systemName: `RenderSystem.extractFrames(world[${wi}])`,
266
+ });
267
+ } catch {
268
+ // If _routeError itself throws, the world already failed — skip silently.
269
+ }
270
+ }
271
+ }
272
+
273
+ // Restore the caller's world order after the camera-owner-first extraction.
274
+ for (let wi = 0; wi < worlds.length; wi++) {
275
+ const frame = framesByWorld.get(wi);
276
+ if (frame === undefined) continue;
277
+ succeededFrames.push(frame);
278
+ succeededIndices.push(wi);
279
+ }
280
+
281
+ // ── D-3: merge semantics ───────────────────────────────────────────────
282
+
283
+ // AC-04: renderables — concat by worlds[] order, stamp worldId.
284
+ const renderables: RenderableSnapshot[] = [];
285
+ const dispatchEntries: DispatchEntry[] = [];
286
+ const visibilitySnapshots: VisibilitySnapshot[] = [];
287
+ const featureVisibilitySnapshots: RenderFeatureWorldVisibilitySnapshot[] = [];
288
+ const hiddenEntityReports: RenderFeatureHiddenEntityReport[] = [];
289
+
290
+ for (let fi = 0; fi < succeededFrames.length; fi++) {
291
+ const f = succeededFrames[fi];
292
+ const wId = succeededIndices[fi];
293
+ if (f === undefined || wId === undefined) continue;
294
+
295
+ const base = renderables.length;
296
+ const visibilitySnapshot = f.visibilitySnapshots[0];
297
+ const world = worlds[wId];
298
+ if (visibilitySnapshot !== undefined) {
299
+ visibilitySnapshots.push(visibilitySnapshot);
300
+ if (world !== undefined)
301
+ featureVisibilitySnapshots.push({ world, snapshot: visibilitySnapshot });
302
+ }
303
+ hiddenEntityReports.push(...f.hiddenEntityReports);
304
+ for (const r of f.renderables) {
305
+ renderables.push({
306
+ ...r,
307
+ worldId: wId,
308
+ ...(r.pointsLines === undefined ? {} : { pointsLines: { ...r.pointsLines, worldId: wId } }),
309
+ });
310
+ }
311
+
312
+ // D-3: dispatch — per-world renderableIndex rebased by base offset.
313
+ for (const d of f.dispatch) {
314
+ dispatchEntries.push({ ...d, renderableIndex: (d.renderableIndex ?? 0) + base });
315
+ }
316
+ }
317
+
318
+ // Stable sort dispatch by queue value.
319
+ dispatchEntries.sort((a, b) => (a.queue ?? 0) - (b.queue ?? 0));
320
+
321
+ // AC-04: lights — point[]/spot[] concat; directional first-hit in
322
+ // succeededFrames order (which preserves worlds[] order for successful
323
+ // frames); directionalCount sum.
324
+ const point: PointLightSnapshot[] = [];
325
+ const spot: SpotLightSnapshot[] = [];
326
+ const rect: RectAreaDirectLightSnapshot[] = [];
327
+ let directional: DirectionalLightSnapshot | undefined;
328
+ let directionalCount = 0;
329
+ let lightViewProj: readonly Float32Array[] | undefined;
330
+ let splitPlanes: Float32Array | undefined;
331
+ let cascadeCount: number | undefined;
332
+ let cascadeBlend: number | undefined;
333
+ let shadowMapSize: number | undefined;
334
+ let depthBias: number | undefined;
335
+ let normalBias: number | undefined;
336
+ let directionalShadowQuality: DirectionalShadowQuality | undefined;
337
+ let directionalShadowError: LightValidationError | undefined;
338
+ const pointShadow: PointShadowSnapshot[] = [];
339
+ // bug-20260710-editor-cross-world-shadow: carry the raw CSM config +
340
+ // direction of the first-hit directional so the merge layer can recompute
341
+ // matrices against the SURFACED (cameraOwner) camera — the light and the
342
+ // camera may live in different worlds (editor super-composite).
343
+ let directionalCsmConfig: DirectionalCsmConfig | undefined;
344
+ let directionalCsmDirection: Vec3 | undefined;
345
+ for (const f of succeededFrames) {
346
+ for (const p of f.lights.point) point.push(p);
347
+ for (const s of f.lights.spot) spot.push(s);
348
+ for (const r of f.lights.rect) rect.push(r);
349
+ for (const ps of f.lights.pointShadow) pointShadow.push(ps);
350
+ if (directional === undefined && f.lights.directional !== undefined) {
351
+ directional = f.lights.directional;
352
+ // Carry CSM shadow fields from the first-hit directional's world.
353
+ lightViewProj = f.lights.lightViewProj;
354
+ splitPlanes = f.lights.splitPlanes;
355
+ cascadeCount = f.lights.cascadeCount;
356
+ cascadeBlend = f.lights.cascadeBlend;
357
+ shadowMapSize = f.lights.shadowMapSize;
358
+ depthBias = f.lights.depthBias;
359
+ normalBias = f.lights.normalBias;
360
+ directionalShadowQuality = f.lights.directionalShadowQuality;
361
+ directionalShadowError = f.lights.directionalShadowError;
362
+ directionalCsmConfig = f.lights.directionalCsmConfig;
363
+ directionalCsmDirection = f.lights.directionalCsmDirection;
364
+ }
365
+ directionalCount += f.lights.directionalCount;
366
+ }
367
+
368
+ // AC-05/06 + w4 owner split (D-3 / R-6): cameras come from the cameraOwner
369
+ // world; skylight / skybox / postProcessParams come from the resourceOwner
370
+ // world (holistic snapshot selection). Scan succeededIndices once to locate
371
+ // each owner's surviving frame. When cameraOwner === resourceOwner both
372
+ // resolve to the same frame — byte-identical to the pre-w4 single-owner path.
373
+ let cameraOwnerFrame: ExtractedFrame | undefined;
374
+ let resourceOwnerFrame: ExtractedFrame | undefined;
375
+ for (let fi = 0; fi < succeededFrames.length; fi++) {
376
+ if (succeededIndices[fi] === cameraOwner) cameraOwnerFrame = succeededFrames[fi];
377
+ if (succeededIndices[fi] === resourceOwner) resourceOwnerFrame = succeededFrames[fi];
378
+ }
379
+ const cameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cameras] : [];
380
+ const auxiliaryCameras =
381
+ cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.auxiliaryCameras] : [];
382
+ const cubeCameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cubeCameras] : [];
383
+
384
+ // bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
385
+ // against the surfaced camera. In a single-world app the per-world extract
386
+ // already produced correct matrices (light+camera share the world), and this
387
+ // recompute reproduces them byte-identically (same config, same camera). In
388
+ // the editor super-composite the directional light's world has NO camera, so
389
+ // its per-world `lightViewProj` is undefined/degenerate; the surfaced camera
390
+ // lives in the cameraOwner world. Pairing them here is the ONLY place both
391
+ // are visible. Point/spot shadows are camera-independent (light-space only)
392
+ // and need no merge-layer fix-up.
393
+ const mergeCam = cameras[0];
394
+ if (directionalCsmConfig !== undefined && directionalCsmDirection !== undefined) {
395
+ const mergeCameraData: CsmCameraData | undefined =
396
+ mergeCam !== undefined
397
+ ? {
398
+ world: mergeCam.world,
399
+ fov: mergeCam.fov,
400
+ aspect: mergeCam.aspect,
401
+ near: mergeCam.near,
402
+ far: mergeCam.far,
403
+ projection: mergeCam.projection,
404
+ orthoLeft: mergeCam.orthoLeft,
405
+ orthoRight: mergeCam.orthoRight,
406
+ orthoBottom: mergeCam.orthoBottom,
407
+ orthoTop: mergeCam.orthoTop,
408
+ }
409
+ : undefined;
410
+ const csm = computeDirectionalCsm(
411
+ directionalCsmDirection,
412
+ directionalCsmConfig,
413
+ mergeCameraData,
414
+ );
415
+ if (csm !== null) {
416
+ lightViewProj = csm.lightViewProj;
417
+ splitPlanes = csm.splitPlanes;
418
+ cascadeCount = csm.cascadeCount;
419
+ cascadeBlend = csm.cascadeBlend;
420
+ shadowMapSize = csm.shadowMapSize;
421
+ directionalShadowQuality = csm.directionalShadowQuality;
422
+ }
423
+ // csm === null (no surfaced camera at all): keep the per-world carry —
424
+ // there is no better data, and a cameraless frame renders nothing anyway.
425
+ }
426
+
427
+ const lights: ExtractedLights = {
428
+ directional,
429
+ directionalCount,
430
+ point,
431
+ spot,
432
+ rect,
433
+ lightViewProj,
434
+ splitPlanes,
435
+ cascadeCount,
436
+ cascadeBlend,
437
+ shadowMapSize,
438
+ depthBias,
439
+ normalBias,
440
+ directionalShadowQuality,
441
+ directionalShadowError,
442
+ pointShadow,
443
+ directionalCsmConfig,
444
+ directionalCsmDirection,
445
+ };
446
+ const volumetricFog = resourceOwnerFrame?.volumetricFog;
447
+ const skylight = resourceOwnerFrame?.skylight;
448
+ const skylightCount = resourceOwnerFrame?.skylightCount ?? 0;
449
+ const skybox = resourceOwnerFrame?.skybox;
450
+ const skyboxCount = resourceOwnerFrame?.skyboxCount ?? 0;
451
+ const fog = resourceOwnerFrame?.fog;
452
+ const fogFailure = resourceOwnerFrame?.fogFailure;
453
+ const environment = resourceOwnerFrame?.environment;
454
+ const lightProbes = succeededFrames.flatMap((frame) => frame.lightProbes ?? []);
455
+ // User-authored PostProcessParams entities are singleton scene resources, so
456
+ // they come from the resourceOwner world (holistic snapshot selection).
457
+ const postProcessParams = new Map(resourceOwnerFrame?.postProcessParams);
458
+ // feat-20260709-editor-world-partition ENGINE-fix-round2 (defect 1): the
459
+ // engine built-in Standard tonemap param is NOT a scene resource — the
460
+ // per-world extractFrame bridges it from that world's own `cameras[0]`
461
+ // (Camera.exposure / whitePoint / tonemap is the SSOT). It therefore lives on
462
+ // the CAMERA-owner frame, not the resource-owner frame. In the split-owner
463
+ // editor topology the resourceOwner world has no Camera, so its frame carries
464
+ // no Standard tonemap entry; taking postProcessParams from resourceOwner
465
+ // alone drops it, the tonemap pass's params UBO stays zero-filled
466
+ // (exposure=0 => tonemapped output is uniformly black), and the whole frame
467
+ // reads black even though geometry drew into the HDR target. Overlay the
468
+ // camera-owner frame's tonemap param (its SSOT source) so the surfaced
469
+ // camera's exposure/whitePoint/mode reach the tonemap pass. When
470
+ // cameraOwner === resourceOwner this is a no-op (identical entry). The 'na'
471
+ // Standard tonemap identity mirrors the engine provider key set at the
472
+ // bottom of extractFrame (SSOT: same identity, same 16B layout).
473
+ const OUTPUT_TRANSFORM_PARAM_KEY = STANDARD_OUTPUT_TRANSFORM_FEATURE_ID;
474
+ const cameraTonemapParam = cameraOwnerFrame?.postProcessParams.get(OUTPUT_TRANSFORM_PARAM_KEY);
475
+ if (cameraTonemapParam !== undefined) {
476
+ postProcessParams.set(OUTPUT_TRANSFORM_PARAM_KEY, cameraTonemapParam);
477
+ } else {
478
+ // The camera-owner world produced no tonemap param (no Camera surfaced
479
+ // there this frame); do not leave a stale resource-owner entry that would
480
+ // apply a foreign camera's exposure. Removing it lets the tonemap pass fall
481
+ // back to its param-less zero path only when genuinely no camera exists.
482
+ postProcessParams.delete(OUTPUT_TRANSFORM_PARAM_KEY);
483
+ }
484
+
485
+ // D-3: frustumStats — culled/total summed across worlds.
486
+ const frustumStats = {
487
+ culled: succeededFrames.reduce((s, f) => s + f.frustumStats.culled, 0),
488
+ total: succeededFrames.reduce((s, f) => s + f.frustumStats.total, 0),
489
+ };
490
+ const visibilityStats = {
491
+ explicitlyHidden: succeededFrames.reduce((s, f) => s + f.visibilityStats.explicitlyHidden, 0),
492
+ };
493
+ const reflectionProbes = succeededFrames.flatMap((f, index) => {
494
+ const worldId = succeededIndices[index] ?? 0;
495
+ return (f.reflectionProbes ?? []).map((probe) => ({ ...probe, worldId }));
496
+ });
497
+
498
+ return {
499
+ cameras,
500
+ auxiliaryCameras,
501
+ cubeCameras,
502
+ reflectionProbes,
503
+ lights,
504
+ ...(volumetricFog === undefined
505
+ ? {}
506
+ : { volumetricFog: { ...volumetricFog, worldId: resourceOwner } }),
507
+ renderables,
508
+ dispatch: dispatchEntries,
509
+ skylight,
510
+ skylightCount,
511
+ lightProbes,
512
+ skybox,
513
+ skyboxCount,
514
+ fog,
515
+ ...(fogFailure === undefined ? {} : { fogFailure }),
516
+ environment,
517
+ environmentReady: resourceOwnerFrame !== undefined && environment !== undefined,
518
+ frustumStats,
519
+ visibilityStats,
520
+ postProcessParams,
521
+ visibilitySnapshots,
522
+ featureVisibilitySnapshots,
523
+ hiddenEntityReports,
524
+ };
525
+ }
526
+
527
+ /**
528
+ * A MeshAsset participates in frustum culling only when its producer supplied
529
+ * the complete finite local-space bounds promised by the asset contract.
530
+ * Missing, malformed, and inverted-infinity empty bounds remain conservative
531
+ * (always visible) so a bad asset cannot turn into a false-negative render;
532
+ * they are not cull candidates and therefore do not inflate frustumStats.
533
+ */
534
+ function hasFiniteOrderedLocalAabb(aabb: Float32Array | undefined): aabb is Float32Array {
535
+ if (aabb === undefined || aabb.length !== 6) return false;
536
+ const minX = aabb[0];
537
+ const minY = aabb[1];
538
+ const minZ = aabb[2];
539
+ const maxX = aabb[3];
540
+ const maxY = aabb[4];
541
+ const maxZ = aabb[5];
542
+ if (
543
+ minX === undefined ||
544
+ minY === undefined ||
545
+ minZ === undefined ||
546
+ maxX === undefined ||
547
+ maxY === undefined ||
548
+ maxZ === undefined
549
+ ) {
550
+ return false;
551
+ }
552
+ if (
553
+ !Number.isFinite(minX) ||
554
+ !Number.isFinite(minY) ||
555
+ !Number.isFinite(minZ) ||
556
+ !Number.isFinite(maxX) ||
557
+ !Number.isFinite(maxY) ||
558
+ !Number.isFinite(maxZ)
559
+ ) {
560
+ return false;
561
+ }
562
+ return minX <= maxX && minY <= maxY && minZ <= maxZ;
563
+ }
564
+
565
+ function morphSnapshotFor(
566
+ mesh: MeshAsset,
567
+ weights: ArrayLike<number> | undefined,
568
+ ): MorphSnapshot | undefined {
569
+ const targets = mesh.morphTargets;
570
+ if (targets === undefined || targets.length === 0 || weights === undefined) return undefined;
571
+ if (weights.length !== targets.length) return undefined;
572
+ const firstPositions = targets[0]?.position;
573
+ if (
574
+ firstPositions === undefined ||
575
+ firstPositions.length === 0 ||
576
+ firstPositions.length % 3 !== 0
577
+ ) {
578
+ return undefined;
579
+ }
580
+ const vertexCount = firstPositions.length / 3;
581
+ if (mesh.vertices.length % vertexCount !== 0) return undefined;
582
+ for (let targetIndex = 0; targetIndex < targets.length; targetIndex += 1) {
583
+ const target = targets[targetIndex];
584
+ if (target?.position?.length !== firstPositions.length) return undefined;
585
+ }
586
+ const copied = new Float32Array(weights.length);
587
+ for (let index = 0; index < weights.length; index += 1) {
588
+ const weight = weights[index] ?? Number.NaN;
589
+ if (!Number.isFinite(weight)) return undefined;
590
+ copied[index] = weight;
591
+ }
592
+ return { weights: copied, targetCount: targets.length };
593
+ }
594
+
595
+ export function extractFrame(world: World, context: PreparedExtractContext): ExtractedFrame {
596
+ // feat-20260708-composited-multi-world-rendering M2 / D-2: resetForFrame
597
+ // has been lifted to extractFrames (the frame-level entry point).
598
+ // extractFrame is now a pure world->snapshot function with no frame-level
599
+ // side effects. See plan-decisions PD2 for the reviewer ruling.
600
+ const {
601
+ assets,
602
+ pipelineState,
603
+ materialSnapshotCache: persistentMaterialSnapshotCache,
604
+ cull: cullMode,
605
+ resourceOwnerFog,
606
+ getMaterialShaderArtifact,
607
+ } = context;
608
+ // Environment reads are owned by the retained resource projection and are
609
+ // established at the frame boundary; record never revisits World state.
610
+ const fog =
611
+ resourceOwnerFog === undefined ? selectFogFrame(world) : (resourceOwnerFog ?? undefined);
612
+ const fogFailure = fogState(world).failure;
613
+ const visibility = context.visibility.hasAnyHiddenIntent ? context.visibility : undefined;
614
+ const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
615
+
616
+ const directionalLightQuery = world.query({ read: [DirectionalLight] }).unwrap();
617
+
618
+ // feat-20260601 D-3: camera / point / spot light world transforms are read
619
+ // through the single resolved `GlobalTransform.world` mat4 (written by
620
+ // propagateTransforms).
621
+ //
622
+ // Each segment routes through a World-owned Query with explicit read and
623
+ // optional roles. The packed entity handle for `readWorldMat4Copy` /
624
+ // `_getArrayView` reads comes from `row.entity` -- the
625
+ // archetype-graph back-door (`graph.archetypes` / `arch.components.some`) is gone.
626
+ // Plan-decisions K-2 sniffing scheme B (archetype-edge sniff once via
627
+ // `row.get(X) !== undefined`); K-3 invariant preserved (`_getArrayView`
628
+ // calls survive untouched, only the entity source changes).
629
+ const worldInternal = createWorldInternalView(world);
630
+ let volumetricFog = extractVolumeSnapshot(world, assets, worldInternal);
631
+
632
+ const cameraRoles = selectCameraRoles(world);
633
+ const cameras = [...cameraRoles.display];
634
+ const auxiliaryCameras = cameraRoles.auxiliary;
635
+ const cubeCameras = collectCubeCameraSnapshots(world);
636
+ const reflectionProbes = collectReflectionProbeFacts(world);
637
+
638
+ // Three-query union (M2 / w16 / AC-03): directional has no Transform
639
+ // dependency (sun-like infinite-source semantics); point + spot pull
640
+ // position from the companion Transform via the joined queries.
641
+ // Host-side pre-multiplication: color *= intensity (charter P4); cone
642
+ // deg -> cos (D-S2); range -> 1/range^2 (D-S5).
643
+ let directional: DirectionalLightSnapshot | undefined;
644
+ let directionalCount = 0;
645
+ // feat-20260621 M2: capture shadow fields from the first-hit DirectionalLight.
646
+ // castShadow defaults to true; the CSM path is gated on firstHitCastShadow !== false.
647
+ let firstHitCastShadow: boolean | undefined;
648
+ let firstHitShadowFields:
649
+ | {
650
+ cascadeCount: number;
651
+ splitLambda: number;
652
+ cascadeBlend: number;
653
+ mapSize: number;
654
+ depthBias: number;
655
+ normalBias: number;
656
+ shadowDistance: number;
657
+ shadowFilter: number;
658
+ shadowAngularRadius: number;
659
+ maxPenumbraTexels: number;
660
+ }
661
+ | undefined;
662
+ for (const row of directionalLightQuery) {
663
+ const l = row.get(DirectionalLight);
664
+ directionalCount += 1;
665
+ const intensity = l.intensity;
666
+ const snapshot: DirectionalLightSnapshot = {
667
+ kind: 'directional',
668
+ entity: row.entity,
669
+ direction: vec3.create(l.direction[0] ?? 0, l.direction[1] ?? -1, l.direction[2] ?? 0),
670
+ color: vec3.create(
671
+ (l.color[0] ?? 1) * intensity,
672
+ (l.color[1] ?? 1) * intensity,
673
+ (l.color[2] ?? 1) * intensity,
674
+ ),
675
+ intensity,
676
+ };
677
+ if (directional === undefined) {
678
+ // First hit wins; record-stage N>1 fail-fast (M3 / w19) flags duplicates.
679
+ directional = snapshot;
680
+ firstHitCastShadow = l.castShadow;
681
+ firstHitShadowFields = {
682
+ cascadeCount: l.cascadeCount,
683
+ splitLambda: l.splitLambda,
684
+ cascadeBlend: l.cascadeBlend,
685
+ mapSize: l.mapSize,
686
+ depthBias: l.depthBias,
687
+ normalBias: l.normalBias,
688
+ shadowDistance: l.shadowDistance,
689
+ shadowFilter: l.shadowFilter,
690
+ shadowAngularRadius: l.shadowAngularRadius,
691
+ maxPenumbraTexels: l.maxPenumbraTexels,
692
+ };
693
+ }
694
+ }
695
+
696
+ let directionalShadowError: LightValidationError | undefined;
697
+ if (directional !== undefined && firstHitShadowFields !== undefined) {
698
+ const validation = validateDirectionalLightData({
699
+ direction: directional.direction,
700
+ castShadow: firstHitCastShadow,
701
+ ...firstHitShadowFields,
702
+ });
703
+ if (!validation.ok) {
704
+ directionalShadowError = validation.error;
705
+ worldInternal._routeError(directionalShadowError, {
706
+ severity: Severity.Error,
707
+ systemName: 'RenderSystem.extract (directional-shadow)',
708
+ });
709
+ }
710
+ }
711
+
712
+ const pointSnapshots: PointLightSnapshot[] = [];
713
+ // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4: track entity per
714
+ // pointSnapshots index so the post-extract pointShadow join can stamp
715
+ // `shadowAtlasLayer + shadowNear + shadowFar` onto the matching PointLight.
716
+ const pointSnapshotEntities: number[] = [];
717
+ const pointLightQuery = world
718
+ .query({
719
+ read: [PointLight],
720
+ optional: [Transform, GlobalTransform],
721
+ })
722
+ .unwrap();
723
+ for (const row of pointLightQuery) {
724
+ const p = row.get(PointLight);
725
+ // K-2 scheme B: archetype-edge sniff -- `bundle.Transform` key is absent
726
+ // when the archetype does not carry the Transform column.
727
+ const hasTransform = row.get(GlobalTransform) !== undefined;
728
+ const intensity = p.intensity;
729
+ const range = p.range;
730
+ const entityId = row.entity;
731
+ // Position = world-space translation extracted from GlobalTransform.world.
732
+ // A point light archetype without a Transform column sits at the origin.
733
+ let worldMat: Float32Array | undefined;
734
+ if (hasTransform) {
735
+ const view = worldInternal._getArrayView(entityId, GlobalTransform, 'world');
736
+ if (view !== undefined) worldMat = new Float32Array(view);
737
+ }
738
+ const position =
739
+ worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
740
+ pointSnapshots.push({
741
+ kind: 'point',
742
+ entity: entityId,
743
+ position,
744
+ color: vec3.create(
745
+ (p.color[0] ?? 1) * intensity,
746
+ (p.color[1] ?? 1) * intensity,
747
+ (p.color[2] ?? 1) * intensity,
748
+ ),
749
+ intensity,
750
+ invRangeSquared: computeInvRangeSquared(range),
751
+ });
752
+ pointSnapshotEntities.push(entityId);
753
+ }
754
+
755
+ const spotSnapshots: SpotLightSnapshot[] = [];
756
+ const spotLightQuery = world
757
+ .query({
758
+ read: [SpotLight],
759
+ optional: [Transform, GlobalTransform],
760
+ })
761
+ .unwrap();
762
+ // feat-20260625-spot-light-shadow-mapping M1 w5: tile allocation for castShadow spots.
763
+ // Cap = 4 (OOS-5), sentinel -1 = unassigned (plan-strategy D-4).
764
+ // Direction degeneration (near-zero) also skips shadow (requirements $112).
765
+ let spotTileNext = 0;
766
+ const iesSlices = new Map<number, number>();
767
+ const cookieSlices = new Map<number, number>();
768
+ const projectorSlices = new Map<number, number>();
769
+ for (const row of spotLightQuery) {
770
+ const s = row.get(SpotLight);
771
+ const hasTransform = row.get(GlobalTransform) !== undefined;
772
+ const intensity = s.intensity;
773
+ const range = s.range;
774
+ const innerConeDeg = s.innerConeDeg;
775
+ const outerConeDeg = s.outerConeDeg;
776
+ let worldMat: Float32Array | undefined;
777
+ if (hasTransform) {
778
+ const entity = row.entity;
779
+ const view = worldInternal._getArrayView(entity, GlobalTransform, 'world');
780
+ if (view !== undefined) worldMat = new Float32Array(view);
781
+ }
782
+ const position =
783
+ worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
784
+ const dir = vec3.create(s.direction[0] ?? 0, s.direction[1] ?? -1, s.direction[2] ?? 0);
785
+
786
+ // Extract is the single direction-normalization owner for direct-light
787
+ // snapshots. URP and HDRP preserve this value downstream.
788
+ const dirLen = Math.sqrt(
789
+ (dir[0] ?? 0) * (dir[0] ?? 0) + (dir[1] ?? 0) * (dir[1] ?? 0) + (dir[2] ?? 0) * (dir[2] ?? 0),
790
+ );
791
+ const EPSILON = 1e-6;
792
+ const hasValidDirection = dirLen > EPSILON;
793
+ if (!hasValidDirection) {
794
+ worldInternal._routeError(
795
+ new SpawnLightInvalidBoundsError('SpotLight', 'direction', [
796
+ dir[0] ?? 0,
797
+ dir[1] ?? 0,
798
+ dir[2] ?? 0,
799
+ ]),
800
+ {
801
+ severity: Severity.Error,
802
+ systemName: 'RenderSystem.extract (spot-direction)',
803
+ },
804
+ );
805
+ }
806
+ const dirN = vec3.create(
807
+ hasValidDirection ? (dir[0] ?? 0) / dirLen : (dir[0] ?? 0),
808
+ hasValidDirection ? (dir[1] ?? 0) / dirLen : (dir[1] ?? 0),
809
+ hasValidDirection ? (dir[2] ?? 0) / dirLen : (dir[2] ?? 0),
810
+ );
811
+
812
+ // ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
813
+ const castShadow = s.castShadow;
814
+ const sMapSize = s.mapSize;
815
+ const sNearPlane = s.nearPlane;
816
+ const sFarPlane = s.farPlane;
817
+ const iesProfileAsset =
818
+ s.iesProfile === undefined
819
+ ? undefined
820
+ : resolveAssetHandle<IesProfileAsset>(world, s.iesProfile);
821
+ const cookieAsset =
822
+ s.cookie === undefined ? undefined : resolveAssetHandle<TextureAsset>(world, s.cookie);
823
+ // Resolve the legacy projector before assigning array slices so the
824
+ // extended-lighting path can reuse its texture transport without losing
825
+ // the old lightViewProj projection semantics.
826
+ const hasProjector = Number(s.projector) > 0;
827
+ let projectorHandle: Handle<'TextureAsset', 'shared'> | undefined;
828
+ let projectorAsset: TextureAsset | undefined;
829
+ let projectorGuid: string | undefined;
830
+ if (hasProjector) {
831
+ const candidate = toShared<'TextureAsset'>(Math.round(Number(s.projector)));
832
+ const resolvedProjector = resolveAssetHandle<TextureAsset>(world, candidate);
833
+ if (!resolvedProjector.ok || resolvedProjector.value.kind !== 'texture') {
834
+ worldInternal._routeError(
835
+ resolvedProjector.ok
836
+ ? new RhiError({
837
+ code: 'asset-not-registered',
838
+ expected: 'SpotLight.projector resolves to a TextureAsset',
839
+ hint: 'load the authored projector TextureAsset before assigning SpotLight.projector',
840
+ detail: { assetHandle: Number(candidate) },
841
+ })
842
+ : resolvedProjector.error,
843
+ { severity: Severity.Error, systemName: 'RenderSystem.extract (spot-projector)' },
844
+ );
845
+ } else {
846
+ projectorHandle = candidate;
847
+ projectorAsset = resolvedProjector.value;
848
+ projectorGuid = assets?.guidOf(projectorAsset) ?? `handle:${Number(candidate)}`;
849
+ }
850
+ }
851
+ const iesProfileData =
852
+ iesProfileAsset?.ok && iesProfileAsset.value.kind === 'ies-profile'
853
+ ? new Uint8Array(iesProfileAsset.value.data)
854
+ : undefined;
855
+ const cookieProjection =
856
+ cookieAsset?.ok && cookieAsset.value.kind === 'texture'
857
+ ? prepareCookieProjection(cookieAsset.value)
858
+ : undefined;
859
+ const cookieData = cookieProjection?.data;
860
+ const projectorProjection =
861
+ projectorAsset === undefined ? undefined : prepareCookieProjection(projectorAsset);
862
+ const projectorData = projectorProjection?.data;
863
+ const iesProfileHandle = s.iesProfile === undefined ? undefined : handleSlot(s.iesProfile);
864
+ const cookieHandle = s.cookie === undefined ? undefined : handleSlot(s.cookie);
865
+ let iesProfileSlice =
866
+ iesProfileHandle === undefined ? undefined : iesSlices.get(iesProfileHandle);
867
+ if (
868
+ iesProfileSlice === undefined &&
869
+ iesProfileHandle !== undefined &&
870
+ iesProfileData !== undefined &&
871
+ iesSlices.size < IES_SLICE_CAPACITY
872
+ ) {
873
+ iesProfileSlice = iesSlices.size;
874
+ iesSlices.set(iesProfileHandle, iesProfileSlice);
875
+ }
876
+ let cookieSlice = cookieHandle === undefined ? undefined : cookieSlices.get(cookieHandle);
877
+ if (
878
+ cookieSlice === undefined &&
879
+ cookieHandle !== undefined &&
880
+ cookieData !== undefined &&
881
+ cookieSlices.size < COOKIE_SLICE_CAPACITY
882
+ ) {
883
+ cookieSlice = cookieSlices.size;
884
+ cookieSlices.set(cookieHandle, cookieSlice);
885
+ }
886
+ let projectorSlice =
887
+ projectorHandle === undefined
888
+ ? undefined
889
+ : (projectorSlices.get(projectorHandle) ?? cookieSlices.get(projectorHandle));
890
+ if (
891
+ projectorSlice === undefined &&
892
+ projectorHandle !== undefined &&
893
+ projectorData !== undefined &&
894
+ cookieSlices.size + projectorSlices.size < COOKIE_SLICE_CAPACITY
895
+ ) {
896
+ projectorSlice = cookieSlices.size + projectorSlices.size;
897
+ projectorSlices.set(projectorHandle, projectorSlice);
898
+ }
899
+ const shadowIntensity = s.shadowIntensity;
900
+ // `shared<T>` columns are u32-backed and zero-filled when the optional
901
+ // field is absent. Treat only a positive handle as an authored projector;
902
+ // checking nullish values alone would turn the zero sentinel into a
903
+ // phantom projector matrix and an avoidable asset-not-found diagnostic.
904
+ let lightViewProj: Float32Array | undefined;
905
+ let shadowAtlasTile = -1;
906
+
907
+ // A projector needs the same perspective transform even when its SpotLight
908
+ // has no shadow atlas tile. The matrix remains a derived light fact; the
909
+ // shadow tile stays -1 so shadow visibility is still independently gated.
910
+ if ((castShadow || hasProjector) && hasValidDirection) {
911
+ const target = vec3.create(
912
+ (position[0] ?? 0) + (dirN[0] ?? 0),
913
+ (position[1] ?? 0) + (dirN[1] ?? 0),
914
+ (position[2] ?? 0) + (dirN[2] ?? 0),
915
+ );
916
+ // D-1: perspective(outerConeDeg*2, aspect=1, near, far) x lookAt(pos, pos+dir).
917
+ // FOV = outerConeDeg * 2 in degrees; mat4.perspective takes fov in radians.
918
+ const fov = outerConeDeg * 2 * (Math.PI / 180);
919
+ const proj = mat4.create();
920
+ mat4.perspective(proj, fov, 1, sNearPlane, sFarPlane);
921
+ const view = mat4.create();
922
+ mat4.lookAt(view, position, target, vec3.create(0, 1, 0));
923
+ lightViewProj = new Float32Array(16);
924
+ // Reinterpret the Float32Array surface field as a Mat4 out-param; a
925
+ // factory would force a needless alloc+copy. brand-cast-ok
926
+ mat4.multiply(lightViewProj as Mat4, proj, view);
927
+
928
+ // D-4: allocate tile 0..3; 5th+ = -1 sentinel.
929
+ if (castShadow && spotTileNext < 4) {
930
+ shadowAtlasTile = spotTileNext;
931
+ spotTileNext += 1;
932
+ }
933
+ }
934
+
935
+ spotSnapshots.push({
936
+ kind: 'spot',
937
+ entity: row.entity,
938
+ // D-6: position reflects world transform; direction stays sourced
939
+ // from SpotLight.direction (NOT rotated by the parent).
940
+ position,
941
+ direction: dirN,
942
+ color: vec3.create(
943
+ (s.color[0] ?? 1) * intensity,
944
+ (s.color[1] ?? 1) * intensity,
945
+ (s.color[2] ?? 1) * intensity,
946
+ ),
947
+ intensity,
948
+ invRangeSquared: computeInvRangeSquared(range),
949
+ cosInner: degToCos(innerConeDeg),
950
+ cosOuter: degToCos(outerConeDeg),
951
+ ...(s.iesProfile === undefined
952
+ ? {}
953
+ : {
954
+ iesProfileHandle: handleSlot(s.iesProfile),
955
+ ...(iesProfileSlice === undefined ? {} : { iesProfileSlice }),
956
+ }),
957
+ ...(s.cookie === undefined
958
+ ? {}
959
+ : {
960
+ cookieHandle: handleSlot(s.cookie),
961
+ ...(cookieSlice === undefined ? {} : { cookieSlice }),
962
+ }),
963
+ ...(iesProfileData === undefined ? {} : { iesProfileData }),
964
+ ...(cookieData === undefined ? {} : { cookieData }),
965
+ ...(cookieProjection === undefined ? {} : { cookieMatrix: cookieProjection.matrix }),
966
+ ...(projectorSlice === undefined ? {} : { projectorSlice }),
967
+ ...(projectorData === undefined ? {} : { projectorData }),
968
+ ...(projectorProjection === undefined ? {} : { projectorMatrix: projectorProjection.matrix }),
969
+ rollDeg: s.rollDeg,
970
+ // ── shadow fields ──
971
+ castShadow,
972
+ lightViewProj,
973
+ mapSize: sMapSize,
974
+ nearPlane: sNearPlane,
975
+ farPlane: sFarPlane,
976
+ shadowAtlasTile,
977
+ shadowIntensity,
978
+ depthBias: s.depthBias,
979
+ normalBias: s.normalBias,
980
+ pcfKernelSize: s.pcfKernelSize,
981
+ ...(projectorHandle === undefined ||
982
+ projectorAsset === undefined ||
983
+ projectorGuid === undefined
984
+ ? {}
985
+ : {
986
+ projectorHandle,
987
+ projectorAsset,
988
+ projectorGuid,
989
+ projectorGeneration: 1,
990
+ projectorRevision: 1,
991
+ }),
992
+ });
993
+ }
994
+
995
+ const rectSnapshots: RectAreaDirectLightSnapshot[] = [];
996
+ const rectLightQuery = world
997
+ .query({
998
+ read: [RectAreaLight],
999
+ optional: [Transform, GlobalTransform],
1000
+ })
1001
+ .unwrap();
1002
+ for (const row of rectLightQuery) {
1003
+ const light = row.get(RectAreaLight);
1004
+ const validation = validateRectAreaLightData({
1005
+ intensity: light.intensity,
1006
+ color: light.color,
1007
+ width: light.width,
1008
+ height: light.height,
1009
+ range: light.range,
1010
+ });
1011
+ if (!validation.ok) {
1012
+ worldInternal._routeError(validation.error, {
1013
+ severity: Severity.Error,
1014
+ systemName: 'RenderSystem.extract (rect-area-light)',
1015
+ });
1016
+ continue;
1017
+ }
1018
+ const worldView = row.get(GlobalTransform);
1019
+ const worldMat =
1020
+ worldView === undefined
1021
+ ? undefined
1022
+ : new Float32Array(worldInternal._getArrayView(row.entity, GlobalTransform, 'world') ?? []);
1023
+ const position =
1024
+ worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
1025
+ const frame = buildRectAreaWorldFrame({
1026
+ center: position,
1027
+ axisX: [worldMat?.[0] ?? 1, worldMat?.[1] ?? 0, worldMat?.[2] ?? 0],
1028
+ axisY: [worldMat?.[4] ?? 0, worldMat?.[5] ?? 1, worldMat?.[6] ?? 0],
1029
+ width: light.width,
1030
+ height: light.height,
1031
+ });
1032
+ rectSnapshots.push({
1033
+ kind: 'rect-area',
1034
+ position,
1035
+ color: vec3.create(
1036
+ (light.color[0] ?? 1) * light.intensity,
1037
+ (light.color[1] ?? 1) * light.intensity,
1038
+ (light.color[2] ?? 1) * light.intensity,
1039
+ ),
1040
+ intensity: light.intensity,
1041
+ invRangeSquared: computeInvRangeSquared(light.range),
1042
+ halfWidth: frame.halfWidth,
1043
+ halfHeight: frame.halfHeight,
1044
+ axisX: frame.axisX,
1045
+ axisY: frame.axisY,
1046
+ });
1047
+ }
1048
+ // The SpotLight is the sole projector owner. Once all light snapshots have
1049
+ // been extracted, publish its accepted TextureAsset tuple on the volume
1050
+ // snapshot so surface and volume consume the same identity and revision.
1051
+ let volumeSpot: SpotLightSnapshot | undefined;
1052
+ const extractedFog = volumetricFog;
1053
+ if (extractedFog !== undefined) {
1054
+ if (extractedFog.spotLightEntity !== undefined) {
1055
+ volumeSpot = spotSnapshots.find((light) => light.entity === extractedFog.spotLightEntity);
1056
+ } else if (extractedFog.lightKind === 'spot') {
1057
+ volumeSpot = spotSnapshots.find((light) => light.entity === extractedFog.lightEntity);
1058
+ }
1059
+ }
1060
+ if (
1061
+ volumetricFog !== undefined &&
1062
+ volumetricFog.status === 'available' &&
1063
+ volumeSpot?.projectorAsset !== undefined &&
1064
+ volumeSpot.projectorHandle !== undefined
1065
+ ) {
1066
+ const projectorHandle = volumeSpot.projectorHandle;
1067
+ const projectorAsset = volumeSpot.projectorAsset;
1068
+ volumetricFog = {
1069
+ ...volumetricFog,
1070
+ projector: {
1071
+ guid: volumeSpot.projectorGuid ?? `handle:${Number(volumeSpot.projectorHandle)}`,
1072
+ generation: volumeSpot.projectorGeneration ?? 1,
1073
+ view: '2d',
1074
+ sampler: 'linear-clamp-to-edge',
1075
+ projection: 'spot-projection',
1076
+ revision: volumeSpot.projectorRevision ?? 1,
1077
+ },
1078
+ projectorHandle,
1079
+ projectorAsset,
1080
+ };
1081
+ }
1082
+
1083
+ // bug-20260710-editor-cross-world-shadow: CSM matrices are computed by the
1084
+ // shared pure {@link computeDirectionalCsm}, called here per-world with THIS
1085
+ // world's own `cameras[0]`. In a single-world app the light and camera share
1086
+ // the world, so this per-world result is final (byte-identical to the prior
1087
+ // inline block). In the editor super-composite the light's world may have no
1088
+ // camera → this yields no matrices; {@link extractFrames} then RECOMPUTES at
1089
+ // the merge layer using the surfaced (cameraOwner) camera + the raw config
1090
+ // carried on ExtractedLights. The raw config + direction are surfaced
1091
+ // unconditionally so the merge layer can re-run the builder.
1092
+ let lightViewProj: Float32Array[] | undefined;
1093
+ let splitPlanes: Float32Array | undefined;
1094
+ let cascadeCount: number | undefined;
1095
+ let cascadeBlend: number | undefined;
1096
+ let shadowMapSize: number | undefined;
1097
+ let directionalShadowQuality: DirectionalShadowQuality | undefined;
1098
+ let directionalCsmConfig: DirectionalCsmConfig | undefined;
1099
+ let directionalCsmDirection: Vec3 | undefined;
1100
+
1101
+ // Camera data needed for frustum corner computation (first camera only;
1102
+ // multi-camera CSM is OOS-1). Undefined in a cameraless world.
1103
+ const cam0 = cameras[0];
1104
+ const cameraData: CsmCameraData | undefined =
1105
+ cam0 !== undefined
1106
+ ? {
1107
+ world: cam0.world,
1108
+ fov: cam0.fov,
1109
+ aspect: cam0.aspect,
1110
+ near: cam0.near,
1111
+ far: cam0.far,
1112
+ projection: cam0.projection,
1113
+ orthoLeft: cam0.orthoLeft,
1114
+ orthoRight: cam0.orthoRight,
1115
+ orthoBottom: cam0.orthoBottom,
1116
+ orthoTop: cam0.orthoTop,
1117
+ }
1118
+ : undefined;
1119
+
1120
+ // feat-20260621 M2: CSM computation gated on castShadow from the
1121
+ // merged DirectionalLight. castShadow defaults to true (first-hit-wins
1122
+ // semantics, D-6 no cardinality cap).
1123
+ if (
1124
+ directional !== undefined &&
1125
+ firstHitCastShadow !== false &&
1126
+ directionalShadowError === undefined
1127
+ ) {
1128
+ const dirSnapshot = directional;
1129
+ const sf = firstHitShadowFields;
1130
+ if (sf !== undefined) {
1131
+ directionalCsmConfig = {
1132
+ cascadeCount: sf.cascadeCount,
1133
+ splitLambda: sf.splitLambda,
1134
+ cascadeBlend: sf.cascadeBlend,
1135
+ mapSize: sf.mapSize,
1136
+ shadowDistance: sf.shadowDistance,
1137
+ shadowFilter: sf.shadowFilter,
1138
+ shadowAngularRadius: sf.shadowAngularRadius,
1139
+ maxPenumbraTexels: sf.maxPenumbraTexels,
1140
+ };
1141
+ directionalCsmDirection = dirSnapshot.direction;
1142
+ const csm = computeDirectionalCsm(dirSnapshot.direction, directionalCsmConfig, cameraData);
1143
+ // Cascade metadata (splitPlanes / count / blend / mapSize) is available
1144
+ // even without a camera (splitPlanes needs only near/far); the matrices
1145
+ // need the camera. When csm is null (no camera) leave lightViewProj
1146
+ // undefined — the merge layer recomputes. Still surface the split/count
1147
+ // metadata so a single-world path keeps its prior fields.
1148
+ cascadeCount = Math.round(sf.cascadeCount);
1149
+ cascadeBlend = sf.cascadeBlend;
1150
+ shadowMapSize = sf.mapSize;
1151
+ if (csm !== null) {
1152
+ lightViewProj = csm.lightViewProj;
1153
+ splitPlanes = csm.splitPlanes;
1154
+ directionalShadowQuality = csm.directionalShadowQuality;
1155
+ } else {
1156
+ const sNear = cameraData?.near ?? 0.1;
1157
+ const splits = pssmSplit(sNear, sf.shadowDistance, cascadeCount, sf.splitLambda);
1158
+ const padded = new Float32Array(16);
1159
+ for (let i = 0; i < splits.length; i++) padded[i * 4] = splits[i] ?? 0;
1160
+ splitPlanes = padded;
1161
+ }
1162
+ directionalShadowQuality = directionalShadowQualityFromF32(
1163
+ sf.shadowFilter,
1164
+ sf.shadowAngularRadius,
1165
+ sf.maxPenumbraTexels,
1166
+ );
1167
+ }
1168
+ }
1169
+
1170
+ // feat-20260613-csm M3 / w14 (plan-strategy §D-7): pad the up-to-4
1171
+ // splitPlanes into a fixed length-4 Float32Array (unused slots = 0) so
1172
+ // the View UBO tail keeps a stable layout regardless of the runtime
1173
+ // cascadeCount. Host-side correctness invariant: only the first
1174
+ // cascadeCount slots are ever read by the WGSL kernel.
1175
+ const paddedSplitPlanes = new Float32Array(16);
1176
+ if (splitPlanes !== undefined) {
1177
+ for (let i = 0; i < splitPlanes.length; i++) {
1178
+ paddedSplitPlanes[i] = splitPlanes[i] ?? 0;
1179
+ }
1180
+ }
1181
+
1182
+ // feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7 (plan-strategy §D-3,
1183
+ // requirements §5.3): query (PointLight + PointLightShadow + Transform)
1184
+ // archetype join. For each shadow caster, build 6 face VP matrices and pack
1185
+ // into Float32Array(96). Atlas layer is assigned in spawn order while the
1186
+ // renderer-owned atlas has capacity; the sentinel -1 is shader-side and
1187
+ // applies to non-admitted PointLightShadow requests as well.
1188
+ const pointShadowSnapshots: PointShadowSnapshot[] = [];
1189
+ {
1190
+ const pointShadowQuery = world
1191
+ .query({ read: [Transform, GlobalTransform, PointLightShadow], with: [PointLight] })
1192
+ .unwrap();
1193
+ for (const row of pointShadowQuery) {
1194
+ const ps = row.get(PointLightShadow);
1195
+ // Read world-space position from GlobalTransform.world (mat4 column-major;
1196
+ // translation lives at indices 12..14, mirroring CameraSnapshot.world
1197
+ // semantics in this file).
1198
+ // feat-20260614 M4 / w13: TypedArrayFor for `array<f32, 16>` now
1199
+ // resolves to a concrete `Float32Array` (was `never` pre-w11), which
1200
+ // surfaces the row-window slicing -- `t.world` is the stride-16 flat
1201
+ // column view; row i lives at `[i*16, (i+1)*16)`. The prior
1202
+ // `t.world?.[i]` form silently returned a single element under the
1203
+ // `never`-typed bundle path and `wRow[12]` widened to `undefined ?? 0`
1204
+ // so light positions clamped to the origin.
1205
+ const wRow = row.get(GlobalTransform).world;
1206
+ if (wRow === undefined) continue;
1207
+ const px = wRow[12] ?? 0;
1208
+ const py = wRow[13] ?? 0;
1209
+ const pz = wRow[14] ?? 0;
1210
+ const lightPos = vec3.create(px, py, pz);
1211
+ const mapSize = ps.mapSize;
1212
+ const nearPlane = ps.nearPlane;
1213
+ const farPlane = ps.farPlane;
1214
+ // The renderer, not ECS, owns the atlas budget. Preserve every request
1215
+ // for inspection, but emit the shader sentinel for entries that cannot
1216
+ // acquire a layer so record never calls faceView outside the atlas.
1217
+ const layer =
1218
+ pointShadowSnapshots.length < SHADOW_ATLAS_DEFAULT_LAYERS
1219
+ ? pointShadowSnapshots.length
1220
+ : -1;
1221
+
1222
+ const matrices = buildPointShadowMatrices(lightPos, nearPlane, farPlane);
1223
+ const packed = new Float32Array(96);
1224
+ for (let f = 0; f < 6; f++) {
1225
+ const m = matrices[f];
1226
+ if (m === undefined) continue;
1227
+ for (let k = 0; k < 16; k++) {
1228
+ packed[f * 16 + k] = m[k] ?? 0;
1229
+ }
1230
+ }
1231
+ pointShadowSnapshots.push({
1232
+ entity: row.entity,
1233
+ position: lightPos,
1234
+ mapSize,
1235
+ nearPlane,
1236
+ farPlane,
1237
+ shadowAtlasLayer: layer,
1238
+ shadowMatrices: packed,
1239
+ });
1240
+ }
1241
+ }
1242
+
1243
+ // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4 (plan-strategy §D-8):
1244
+ // join pointShadow snapshots into the matching PointLightSnapshot so the
1245
+ // record stage threads `shadowAtlasLayer + shadowNear + shadowFar` through
1246
+ // the unified direct-light metadata payload.
1247
+ // Mutates the freshly-built PointLightSnapshot in place; the snapshot is
1248
+ // not exposed elsewhere this frame yet (consumed only by lights.point[]).
1249
+ if (pointShadowSnapshots.length > 0) {
1250
+ const shadowByEntity = new Map<number, PointShadowSnapshot>();
1251
+ for (const ps of pointShadowSnapshots) shadowByEntity.set(ps.entity, ps);
1252
+ for (let i = 0; i < pointSnapshots.length; i++) {
1253
+ const entityId = pointSnapshotEntities[i] ?? 0;
1254
+ const ps = shadowByEntity.get(entityId);
1255
+ if (ps !== undefined) {
1256
+ pointSnapshots[i] = {
1257
+ ...(pointSnapshots[i] as PointLightSnapshot),
1258
+ shadowAtlasLayer: ps.shadowAtlasLayer,
1259
+ shadowNear: ps.nearPlane,
1260
+ shadowFar: ps.farPlane,
1261
+ };
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ const lights: ExtractedLights = {
1267
+ directional,
1268
+ directionalCount,
1269
+ point: pointSnapshots,
1270
+ spot: spotSnapshots,
1271
+ rect: rectSnapshots,
1272
+ lightViewProj,
1273
+ splitPlanes: splitPlanes !== undefined ? paddedSplitPlanes : undefined,
1274
+ cascadeCount,
1275
+ cascadeBlend,
1276
+ shadowMapSize,
1277
+ depthBias: firstHitCastShadow !== false ? firstHitShadowFields?.depthBias : undefined,
1278
+ normalBias: firstHitCastShadow !== false ? firstHitShadowFields?.normalBias : undefined,
1279
+ directionalShadowQuality,
1280
+ directionalShadowError,
1281
+ pointShadow: pointShadowSnapshots,
1282
+ // bug-20260710-editor-cross-world-shadow: raw CSM config + light direction
1283
+ // so the merge layer can recompute matrices against the surfaced camera.
1284
+ directionalCsmConfig,
1285
+ directionalCsmDirection,
1286
+ };
1287
+
1288
+ // feat-20260520-skylight-ibl-cubemap M4 / t26+t27: query Skylight entities.
1289
+ // First archetype hit wins (mirrors DirectionalLight pattern); multi-Skylight
1290
+ // warn in record stage (t27) uses skylightCount.
1291
+ const skylightQuery = world.query({ read: [Skylight] }).unwrap();
1292
+ let skylight: SkylightSnapshot | undefined;
1293
+ let skylightCount = 0;
1294
+ for (const row of skylightQuery) {
1295
+ const s = row.get(Skylight);
1296
+ // equirect is OPTIONAL: an omitted field zero-inits to handle 0, which
1297
+ // record treats as "no equirect" -> solid-color ambient via the white
1298
+ // fallback cube. A Skylight WITHOUT an equirect is still a valid snapshot
1299
+ // (the prior `equirectRaw !== undefined` gate dropped color-only
1300
+ // skylights, leaving the scene black -- the downstream gap #4).
1301
+ const equirectRaw = s.equirect;
1302
+ const intensity = s.intensity;
1303
+ const colorR = s.color[0] ?? 1.0;
1304
+ const colorG = s.color[1] ?? 1.0;
1305
+ const colorB = s.color[2] ?? 1.0;
1306
+ const rotation: [number, number, number, number] = [
1307
+ s.rotation[0] ?? 0,
1308
+ s.rotation[1] ?? 0,
1309
+ s.rotation[2] ?? 0,
1310
+ s.rotation[3] ?? 1,
1311
+ ];
1312
+ skylightCount += 1;
1313
+ if (skylight === undefined) {
1314
+ skylight = {
1315
+ equirectHandle: equirectRaw !== undefined ? Math.round(equirectRaw) : 0,
1316
+ color: [colorR, colorG, colorB],
1317
+ intensity,
1318
+ rotation,
1319
+ // w19: winning entity handle for the multi-Skylight once-warn (F-8).
1320
+ entityHandle: row.entity,
1321
+ };
1322
+ }
1323
+ }
1324
+
1325
+ const lightProbeQuery = world.query({ read: [LightProbe, GlobalTransform] }).unwrap();
1326
+ const lightProbes: LightProbeSnapshot[] = [];
1327
+ for (const row of lightProbeQuery) {
1328
+ const probe = row.get(LightProbe);
1329
+ const validation = validateLightProbeData({
1330
+ irradiance: probe.irradiance,
1331
+ radius: probe.radius,
1332
+ });
1333
+ if (!validation.ok) {
1334
+ worldInternal._routeError(validation.error, {
1335
+ severity: Severity.Error,
1336
+ systemName: 'RenderSystem.extract (light-probe)',
1337
+ });
1338
+ continue;
1339
+ }
1340
+ const transform = row.get(GlobalTransform);
1341
+ lightProbes.push({
1342
+ identity: String(row.entity),
1343
+ position: [transform.world[12] ?? 0, transform.world[13] ?? 0, transform.world[14] ?? 0],
1344
+ radius: probe.radius,
1345
+ irradiance: new Float32Array(probe.irradiance),
1346
+ admitted: true,
1347
+ });
1348
+ }
1349
+
1350
+ // feat-20260531-skybox-env-background M2 / w5: query SkyboxBackground entities.
1351
+ // First archetype hit wins (mirrors Skylight pattern); multi-entity
1352
+ // once-warn in record stage uses skyboxCount.
1353
+ const skyboxQuery = world.query({ read: [SkyboxBackground] }).unwrap();
1354
+ let skybox: SkyboxSnapshot | undefined;
1355
+ let skyboxCount = 0;
1356
+ for (const row of skyboxQuery) {
1357
+ const s = row.get(SkyboxBackground);
1358
+ const equirectRaw = s.equirect;
1359
+ const modeRaw = s.mode;
1360
+ const rotation: [number, number, number, number] = [
1361
+ s.rotation[0] ?? 0,
1362
+ s.rotation[1] ?? 0,
1363
+ s.rotation[2] ?? 0,
1364
+ s.rotation[3] ?? 1,
1365
+ ];
1366
+ skyboxCount += 1;
1367
+ if (skybox === undefined && equirectRaw !== undefined) {
1368
+ skybox = {
1369
+ equirectHandle: Math.round(equirectRaw),
1370
+ mode: modeRaw,
1371
+ rotation,
1372
+ // w19: winning entity handle for the multi-SkyboxBackground warn (F-8).
1373
+ entityHandle: row.entity,
1374
+ };
1375
+ }
1376
+ }
1377
+
1378
+ const environmentCandidates: EnvironmentCandidate[] = [];
1379
+ const imageEnvironment =
1380
+ skylight?.equirectHandle !== undefined && skylight.equirectHandle > 0
1381
+ ? skylight
1382
+ : skybox?.equirectHandle !== undefined && skybox.equirectHandle > 0
1383
+ ? skybox
1384
+ : undefined;
1385
+ if (imageEnvironment !== undefined) {
1386
+ environmentCandidates.push({
1387
+ kind: 'image',
1388
+ entityKey: imageEnvironment.entityHandle,
1389
+ sourceKey: `equirect:${imageEnvironment.equirectHandle}`,
1390
+ });
1391
+ }
1392
+ const atmosphereQuery = world.query({ read: [Atmosphere] }).unwrap();
1393
+ for (const row of atmosphereQuery) {
1394
+ const value = row.get(Atmosphere);
1395
+ const atmosphere = {
1396
+ turbidity: value.turbidity,
1397
+ rayleigh: value.rayleigh,
1398
+ mieCoefficient: value.mieCoefficient,
1399
+ mieDirectionalG: value.mieDirectionalG,
1400
+ sunAngularRadius: value.sunAngularRadius,
1401
+ } as const;
1402
+ environmentCandidates.push({
1403
+ kind: 'atmosphere',
1404
+ entityKey: row.entity,
1405
+ sourceKey: `atmosphere:${row.entity}`,
1406
+ atmosphere,
1407
+ });
1408
+ }
1409
+ const directionalDirection = directional?.direction;
1410
+ const directionalLength =
1411
+ directionalDirection === undefined
1412
+ ? 0
1413
+ : Math.hypot(
1414
+ directionalDirection[0] ?? 0,
1415
+ directionalDirection[1] ?? 0,
1416
+ directionalDirection[2] ?? 0,
1417
+ );
1418
+ const sun =
1419
+ directional === undefined || directionalLength <= 1e-6
1420
+ ? []
1421
+ : [
1422
+ {
1423
+ entityKey: 0,
1424
+ direction: [
1425
+ -(directionalDirection?.[0] ?? 0) / directionalLength,
1426
+ -(directionalDirection?.[1] ?? 0) / directionalLength,
1427
+ -(directionalDirection?.[2] ?? 0) / directionalLength,
1428
+ ] as [number, number, number],
1429
+ color: [
1430
+ directional.color[0] ?? 0,
1431
+ directional.color[1] ?? 0,
1432
+ directional.color[2] ?? 0,
1433
+ ] as [number, number, number],
1434
+ intensity: directional.intensity,
1435
+ },
1436
+ ];
1437
+ const environmentSelection = selectEnvironment({
1438
+ environments: environmentCandidates,
1439
+ fogs: fog === undefined ? [] : [fog],
1440
+ suns: sun,
1441
+ lane: 'direct',
1442
+ });
1443
+ if (!environmentSelection.ok) {
1444
+ routeWorldError(world, environmentSelection.error, {
1445
+ systemName: 'RenderSystem.extract (environment-selection)',
1446
+ });
1447
+ throw environmentSelection.error;
1448
+ }
1449
+ const environment: EnvironmentFrame = environmentSelection.value;
1450
+
1451
+ // feat-20260528-frustum-culling M3 / w10: precompute per-camera frustum
1452
+ // planes so entities can be tested against all cameras in the inner loop.
1453
+ // Cameras with degenerate projection parameters (e.g. zero fov, zero aspect)
1454
+ // are skipped — entities are always-visible for those. Frustum plane cache
1455
+ // stored as Float32Array[] parallel to the cameras[] array.
1456
+ //
1457
+ // feat-20260708-composited-multi-world-rendering M2 / D-4: a composite
1458
+ // non-owner world uses the camera-owner snapshots, not its own cameras.
1459
+ // This keeps culling correct when the surfaced camera and renderables live
1460
+ // in different worlds. The explicit 'none' mode remains the always-visible
1461
+ // escape hatch for callers that genuinely need it.
1462
+ const frustumPlanes: Float32Array[] = [];
1463
+ const cullingCameras = cullMode === 'external' ? (context.cullCameras ?? []) : cameras;
1464
+ if (cullMode !== 'none') {
1465
+ for (const cam of cullingCameras) {
1466
+ // feat-20260613 M6 / w20: orthographic cameras have fov=0 by design;
1467
+ // the previous degeneracy guard (`fov <= 0`) was rejecting valid ortho
1468
+ // cameras and returning the always-visible escape hatch. Only the
1469
+ // perspective path needs the fov check.
1470
+ if (cam.projection === 'perspective' && (cam.fov <= 0 || cam.aspect <= 0)) {
1471
+ frustumPlanes.push(new Float32Array(0)); // degenerate → always-visible
1472
+ continue;
1473
+ }
1474
+ if (cam.near >= cam.far) {
1475
+ frustumPlanes.push(new Float32Array(0));
1476
+ continue;
1477
+ }
1478
+ const proj = mat4.create();
1479
+ if (cam.projection === 'orthographic') {
1480
+ mat4.orthographic(
1481
+ proj,
1482
+ cam.orthoLeft,
1483
+ cam.orthoRight,
1484
+ cam.orthoTop,
1485
+ cam.orthoBottom,
1486
+ cam.near,
1487
+ cam.far,
1488
+ );
1489
+ } else {
1490
+ mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
1491
+ }
1492
+ // feat-20260601 D-3: view = invert(camera world mat4). The camera scale is
1493
+ // carried in the world basis columns; the cull frustum uses the same view
1494
+ // the record stage derives, so cull stays same-source with render (AC-05).
1495
+ const view = mat4.create();
1496
+ mat4.invert(view, cam.world);
1497
+ const vp = mat4.create();
1498
+ mat4.multiply(vp, proj, view);
1499
+ const f = frustum.create();
1500
+ frustum.fromViewProjection(f, vp);
1501
+ frustumPlanes.push(f);
1502
+ }
1503
+ }
1504
+
1505
+ const renderables: RenderableSnapshot[] = [];
1506
+ // feat-20260528-frustum-culling M3 / w11: frustum culling counters.
1507
+ let frustumCulled = 0;
1508
+ let frustumTotal = 0;
1509
+ const explicitlyHidden = new Set<EntityHandle>();
1510
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w22 (@new-surface): three-
1511
+ // bucket dispatch arrays. The legacy `materialDispatch` field stays as
1512
+ // a back-compat union (opaque + transparent + overlay back-compat
1513
+ // entries) so the pre-w25 RenderSystem.draw consumer loop keeps
1514
+ // working until M-3 / w25 lands the bucket-aware record. Plan-strategy
1515
+ // §6.1 (back-compat field stays until M-4 acceptance round green).
1516
+ // M3 / w26: single dispatch list replaces old three-bucket model
1517
+ // (plan-strategy D-3). Entries built per-entity per-pass inside the
1518
+ // archetype walk, then sorted by queue at the end.
1519
+ let dispatch: DispatchEntry[] = [];
1520
+ // Keep the derived material snapshot and its resolved passes local to this
1521
+ // extraction. Shared handles are immutable inputs for the frame, while
1522
+ // dispatch entries still need to be rebuilt for each entity.
1523
+ const materialSnapshotCache: MaterialSnapshotCache = new Map();
1524
+ // tweak-20260611 M1: MeshRenderer renderable archetype walk routes
1525
+ // through one World-owned Query. K-2 sniffing scheme B
1526
+ // (`row.get(X) !== undefined` edge sniff) replaces the prior
1527
+ // `arch.components.some` row-internal back-door. K-3 invariant: the
1528
+ // variable-length array reads (`MeshRenderer.materials`,
1529
+ // `Instances.transforms`) still flow through `_getArrayView` /
1530
+ // `world.get(e, Instances)` -- only the `entity` source switches to
1531
+ // `row.entity`.
1532
+ //
1533
+ // archVersion plumbing exception: the `RenderableSnapshot.instances
1534
+ // .archVersion` cache key is keyed off the live archetype's mutation
1535
+ // counter (record stage `instanceBuffers` cache invalidation). The
1536
+ // QueryRow does not surface this number, so a single archetype
1537
+ // graph access is retained for instance-bearing rows -- match by entity
1538
+ // packed handle. AC-01 grep ≤ current - 1 still holds: the stale
1539
+ // archetype-graph traversal commentary at the prior call site is gone.
1540
+ // feat-20260521-sprite-atlas-animation M3 / T-16: SpriteRegionOverride
1541
+ // column id for the sprite-bucket per-entity region override read
1542
+ void SpriteRegionOverride;
1543
+
1544
+ const meshRendererQuery =
1545
+ context.renderables === 'none'
1546
+ ? []
1547
+ : world
1548
+ .query({
1549
+ read: [MeshRenderer],
1550
+ optional: [
1551
+ Transform,
1552
+ GlobalTransform,
1553
+ MeshFilter,
1554
+ Instances,
1555
+ Skin,
1556
+ Layer,
1557
+ MorphWeights,
1558
+ SpriteRegionOverride,
1559
+ SpriteInstances,
1560
+ Points,
1561
+ Lines,
1562
+ SortKey,
1563
+ ],
1564
+ })
1565
+ .unwrap();
1566
+ // QueryRow deliberately exposes component data, not an archetype version.
1567
+ // The optimized ECS owner keeps table storage private, so instance-buffer
1568
+ // invalidation uses a content fingerprint of the managed arrays instead.
1569
+ const resolveArchVersion = (entity: EntityHandle): number => {
1570
+ const values = [
1571
+ worldInternal._getArrayView(entity, Instances, 'transforms'),
1572
+ worldInternal._getArrayView(entity, SpriteInstances, 'transforms'),
1573
+ worldInternal._getArrayView(entity, SpriteInstances, 'regions'),
1574
+ ];
1575
+ let hash = 2166136261;
1576
+ for (const value of values) {
1577
+ hash = Math.imul(hash ^ (value?.length ?? 0), 16777619) >>> 0;
1578
+ if (value === undefined) continue;
1579
+ for (let index = 0; index < value.length; index += 1) {
1580
+ hash = Math.imul(hash ^ Math.fround(value[index] ?? 0), 16777619) >>> 0;
1581
+ }
1582
+ }
1583
+ return hash;
1584
+ };
1585
+ // Pending entries are created with the current renderables.length, which is
1586
+ // exactly the slot this entity receives if it survives culling. No other
1587
+ // renderable can be pushed between staging and this entity's push, so publish
1588
+ // the original fresh entries instead of cloning every submesh descriptor.
1589
+ // A culled entity still discards its private pending array unchanged.
1590
+ const flushPendingDispatch = (pending: readonly DispatchEntry[]): void => {
1591
+ for (const entry of pending) dispatch.push(entry);
1592
+ };
1593
+ for (const row of meshRendererQuery) {
1594
+ // K-2 archetype-edge sniff (scheme B): a missing optional component
1595
+ // surfaces as an absent bundle key, not a row-internal optional chain.
1596
+ // Presence-only checks use QueryRow.has so large array-bearing components
1597
+ // are not materialised merely to answer a boolean question.
1598
+ const hasTransform = row.has(Transform);
1599
+ const meshFilter = row.get(MeshFilter);
1600
+ const hasInstances = row.has(Instances);
1601
+ const skin = row.get(Skin);
1602
+ const morphWeightsView = worldInternal._getArrayView(row.entity, MorphWeights, 'weights');
1603
+ const hasMeshFilter = meshFilter !== undefined;
1604
+ const hasSkin = skin !== undefined;
1605
+ // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10:
1606
+ // SpriteInstances optional component archetype-edge sniff. Three structured
1607
+ // EcsError codes fire at the row-loop entry (D-6 fail-fast at extract):
1608
+ // - 'sprite-instances-mutually-exclusive-with-instances'
1609
+ // (hasInstances && hasSpriteInstances) — Instances + SpriteInstances peers.
1610
+ // - 'sprite-instances-requires-sprite-shading-model'
1611
+ // (materialSnap.materialShaderId !== 'forgeax::sprite') — non-sprite material.
1612
+ // - 'sprite-instances-count-mismatch'
1613
+ // (transforms.length / 16 !== regions.length / 4) — stride pair desync.
1614
+ const hasSpriteInstances = row.has(SpriteInstances);
1615
+ const points = row.get(Points);
1616
+ const lines = row.get(Lines);
1617
+ const sortKey = row.get(SortKey)?.value;
1618
+ const pointsLinesComponent =
1619
+ points !== undefined ? 'Points' : lines !== undefined ? 'Lines' : undefined;
1620
+ const isRenderable = hasTransform && hasMeshFilter;
1621
+
1622
+ // The resolved world transform is read per-entity from the single
1623
+ // `GlobalTransform.world` mat4 (propagateTransforms output) inside the row
1624
+ // loop below. Normal structural paths materialize this carrier through
1625
+ // Transform's generic ECS requirement; keep the defensive row check because
1626
+ // an owner can explicitly remove a required component during recovery.
1627
+ const fAssetHandle = meshFilter?.assetHandle;
1628
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w22: Layer column read here;
1629
+ // value folded into each DispatchEntry so the render-system sort can use
1630
+ // it as the primary transparent-sort key without a second ECS round-trip.
1631
+ const fLayerValue = row.get(Layer)?.value;
1632
+ // feat-20260608-tilemap-object-layer-rendering M3 / m3-t5: tilemap-spawned
1633
+ // per-cell render entities (the ones `tilemap-chunk-extract-system`
1634
+ // pushes via `spawnDerivedRenderEntities`) reach this loop via the same
1635
+ // archetype edge that carries a sprite entity -- they all wear
1636
+ // `MeshFilter.assetHandle === HANDLE_QUAD` + a `forgeax::sprite`-shaded
1637
+ // material asset + the sprite-bucket `values.region` rectangle.
1638
+ // For the per-entity Y-sort path (requirements §AC-12 / §AC-13):
1639
+ //
1640
+ // sortKey = -(Transform.posY - effectivePivotY * |Transform.scaleY|)
1641
+ //
1642
+ // with `effectivePivotY = effectivePivotYForTilemapFlip(pivotY, pivotX,
1643
+ // flipV, flipDiagonal)` from `tilemap-chunk-extract-system` (the SAME
1644
+ // helper drives `spawnDerivedRenderEntities`, so the value the sort
1645
+ // uses matches the value baked into `Transform.posY` -- charter P4
1646
+ // single SSOT for the post-flip pivot). Sprite entities reuse the same
1647
+ // formula but skip the flip composition (their pivot stays raw); both
1648
+ // bucket types therefore feed one `transparentSortEntries` argsort
1649
+ // step + share the `argsortInPlace` radix LSD primitive (plan-strategy
1650
+ // §D-1 / §D-3). The detection lives on the material side -- detect a
1651
+ // tilemap-spawned entity by `MeshFilter.assetHandle === HANDLE_QUAD`
1652
+ // plus the `forgeax::sprite` shader id on `MeshRenderer.material`'s
1653
+ // first pass + non-empty `values.region`; no new public ECS
1654
+ // marker component lands (charter F1 minimum surface).
1655
+ //
1656
+ // Layer.value is now folded into each DispatchEntry.layer (fLayerValue
1657
+ // column, read once per archetype pass above). render-system.ts
1658
+ // `sortTransparentDispatch` applies (layer ASC, sortValue ASC) for all
1659
+ // transparent-sort modes (0/1/2) using posY/pivotY/sizeY from the
1660
+ // parallel renderables[] snapshot -- no second ECS round-trip needed.
1661
+ // feat-20260527-sprite-nineslice M4 / w17 (AC-14): SpriteRegionOverride
1662
+ // per-entity UV sub-rectangle. When the entity carries this component the
1663
+ // 4-float `[uMin, vMin, uW, vH]` override displaces the asset-side
1664
+ // `values.region` for this entity only — downstream 9-slice logic
1665
+ // measures slices against this effective region.zw, so a half-width sub-
1666
+ // sprite reduces the anchor budget to 0.5 rather than the asset's 1.0.
1667
+ //
1668
+ // SpriteRegionOverride.region is a fixed stride-4 value. Per-row reads
1669
+ // route through `_getArrayView` for the zero-copy row window
1670
+ // row-window slice (consistent with the variable-length array column
1671
+ // reads -- K-3 carve-out keeps `_getArrayView` as the row-accessor of
1672
+ // record for each non-scalar column).
1673
+ const hasSpriteRegionOverride = row.has(SpriteRegionOverride);
1674
+ // feat-20260523-skin-skeleton-animation M2 / T-21: Skin component
1675
+ // column views for coexistence check + joint despawn fail-fast.
1676
+ // `skeleton` holds the packed Handle<SkeletonAsset>; `joints` holds
1677
+ // the packed Entity u32 array (N x one u32 each).
1678
+ const skinSkeletonView = skin?.skeleton;
1679
+
1680
+ let archVersion = 0;
1681
+ if (hasInstances || hasSpriteInstances) {
1682
+ // All rows in this callback share one archetype. Only these two
1683
+ // instance-bearing paths consume the version in their cache key.
1684
+ archVersion = resolveArchVersion(row.entity);
1685
+ }
1686
+
1687
+ {
1688
+ // feat-20260608 M2 / w11: read materials array via _getArrayView
1689
+ const entity = row.entity;
1690
+ if (isRenderable && visibility?.effective(entity) === 'hidden') {
1691
+ explicitlyHidden.add(entity);
1692
+ continue;
1693
+ }
1694
+ const layerVal = fLayerValue ?? 0;
1695
+ // bug-20260709-builtin-quad-withoutaabb-disables-sprite-frustum-cu M2.5
1696
+ // (carries PR #598 feat-20260703 D-7): dispatch entries for this entity
1697
+ // are staged locally and flushed into the shared `dispatch[]` array
1698
+ // ONLY when the entity survives the frustum-cull `continue` below —
1699
+ // same cull-passed branch as the paired `renderables.push`. Prior to
1700
+ // this fix the three `dispatch.push` sites ran before the cull check,
1701
+ // so a culled entity left dangling entries whose `renderableIndex`
1702
+ // aliased the slot a LATER visible entity occupied — surfacing as the
1703
+ // pbr-mesh-array-bgl vs hdrp-unified-bgl-group2 BGL/PL mismatch on
1704
+ // the deferred-shading smoke (PR #598 CI). Pure ordering fix; cull
1705
+ // logic and MeshRenderer contract unchanged.
1706
+ const pendingDispatch: DispatchEntry[] = [];
1707
+ const materialsView = worldInternal._getArrayView(entity, MeshRenderer, 'materials') as
1708
+ | Uint32Array
1709
+ | undefined;
1710
+ const materialCount = materialsView?.length ?? 0;
1711
+ let materialHandles = Array.from(materialsView ?? []);
1712
+ let materialBindingSources: MeshMaterialBindingSource[] = materialHandles.map(
1713
+ () => 'renderer-override',
1714
+ );
1715
+ let materialBindingDiagnostics: MeshMaterialBindingDiagnostic[] = [];
1716
+ let gpuDrivenSubmeshes: readonly MeshAsset['submeshes'][number][] = [];
1717
+ let gpuDrivenIndexed = false;
1718
+ let gpuDrivenGeometry: GpuDrivenGeometryReceipt | undefined;
1719
+
1720
+ // Resolve instance overrides against mesh-owned slot defaults once.
1721
+ const fAssetHandleVal = fAssetHandle;
1722
+ if (
1723
+ fAssetHandleVal !== undefined &&
1724
+ fAssetHandleVal !== 0 &&
1725
+ assets !== undefined &&
1726
+ assets !== null
1727
+ ) {
1728
+ const meshHandle = toShared<'MeshAsset'>(fAssetHandleVal);
1729
+ const meshRes = resolveAssetHandle<Asset>(world, meshHandle);
1730
+ if (meshRes.ok && meshRes.value.kind === 'mesh') {
1731
+ const meshAsset = meshRes.value as MeshAsset;
1732
+ const guid = (meshRes.value as { guid?: string }).guid ?? '<no-guid>';
1733
+ gpuDrivenSubmeshes = meshAsset.submeshes;
1734
+ gpuDrivenIndexed = meshAsset.indices !== undefined;
1735
+ const layoutProjection = deriveVertexLayoutProjection(meshAsset.attributes);
1736
+ gpuDrivenGeometry = {
1737
+ identity: `${guid}:${layoutProjection.digest}`,
1738
+ vertexInputs: layoutProjection.attributes.map((attribute) => ({
1739
+ semantic: attribute.key,
1740
+ location: attribute.shaderLocation,
1741
+ format: attribute.format,
1742
+ })),
1743
+ topology: meshAsset.submeshes[0]?.topology ?? 'triangle-list',
1744
+ indexed: gpuDrivenIndexed,
1745
+ };
1746
+ // extractFrames is also a public read path and can run before the
1747
+ // Renderer.draw pre-render stage. GlyphText proves the derived mesh
1748
+ // owns exactly one Default slot; arbitrary meshes still fail closed
1749
+ // and never infer slot topology from their submeshes.
1750
+ if (!Array.isArray(meshAsset.materialSlots) && world.get(entity, GlyphText).ok) {
1751
+ ensureGlyphMeshMaterialSlots(world, meshHandle);
1752
+ }
1753
+ const resolvedBindings = resolveMeshMaterialBindings(meshAsset, materialsView ?? [], {
1754
+ isValidOverride(handle) {
1755
+ const resolved = resolveAssetHandle(world, toShared<'MaterialAsset'>(handle));
1756
+ return resolved.ok && resolved.value.kind === 'material';
1757
+ },
1758
+ resolveMeshDefault(defaultGuid) {
1759
+ const guidText = AssetGuid.format(defaultGuid);
1760
+ if (assets.lookup<Asset>(guidText)?.kind !== 'material') return undefined;
1761
+ return internSharedRefFromGuid(world, assets, guidText, 'MaterialAsset') as
1762
+ | number
1763
+ | undefined;
1764
+ },
1765
+ });
1766
+ if (!resolvedBindings.ok) {
1767
+ if (resolvedBindings.code === 'mesh-material-slots-missing') {
1768
+ worldInternal._routeError(
1769
+ new AssetError({
1770
+ code: 'load-failed',
1771
+ expected: 'every MeshAsset producer supplies materialSlots[]',
1772
+ hint: `fix the MeshAsset producer; renderer inheritance never guesses slot topology (mesh=${guid}, entity=${entity}, vertices=${meshAsset.vertices.length}, indices=${meshAsset.indices?.length ?? 0})`,
1773
+ detail: {
1774
+ referencedByGuid: guid,
1775
+ referencedByKind: 'mesh',
1776
+ subAssetGuid: '<material-slots-missing>',
1777
+ sourceField: { fieldName: 'materialSlots' },
1778
+ },
1779
+ }),
1780
+ {
1781
+ severity: Severity.Error,
1782
+ systemName: 'RenderSystem.extract (mesh-material-slots-missing)',
1783
+ },
1784
+ );
1785
+ continue;
1786
+ }
1787
+ const materialGuid = AssetGuid.format(resolvedBindings.defaultMaterial);
1788
+ worldInternal._routeError(
1789
+ new AssetError({
1790
+ code: 'load-failed',
1791
+ expected: `MeshAsset materialSlots[${resolvedBindings.slotIndex}] default ${materialGuid} is ready and a MaterialAsset`,
1792
+ hint: `loadByGuid(meshGuid) must recursively load the declared default material; mesh=${guid}, slot=${resolvedBindings.slotIndex}, material=${materialGuid}`,
1793
+ detail: {
1794
+ referencedByGuid: guid,
1795
+ referencedByKind: 'mesh',
1796
+ subAssetGuid: materialGuid,
1797
+ sourceField: {
1798
+ fieldName: 'materialSlots',
1799
+ arrayIndex: resolvedBindings.slotIndex,
1800
+ },
1801
+ },
1802
+ }),
1803
+ {
1804
+ severity: Severity.Error,
1805
+ systemName: 'RenderSystem.extract (mesh-default-not-ready)',
1806
+ },
1807
+ );
1808
+ continue;
1809
+ }
1810
+ materialHandles = resolvedBindings.bindings.map((binding) => binding.handle);
1811
+ materialBindingSources = resolvedBindings.bindings.map((binding) => binding.source);
1812
+ materialBindingDiagnostics = resolvedBindings.diagnostics.map((diagnostic) => ({
1813
+ ...diagnostic,
1814
+ detail:
1815
+ diagnostic.code === 'mesh-renderer-material-override-overflow'
1816
+ ? {
1817
+ expectedCount: meshAsset.materialSlots.length,
1818
+ actualCount: materialCount,
1819
+ meshAssetGuid: guid,
1820
+ }
1821
+ : {
1822
+ meshAssetGuid: guid,
1823
+ slotIndex: diagnostic.slotIndex,
1824
+ handle: diagnostic.handle ?? 0,
1825
+ },
1826
+ }));
1827
+ for (const diagnostic of resolvedBindings.diagnostics) {
1828
+ worldInternal._routeError(
1829
+ new AssetError({
1830
+ code: diagnostic.code,
1831
+ expected:
1832
+ diagnostic.code === 'mesh-renderer-material-override-overflow'
1833
+ ? `materials.length <= materialSlots.length (${meshAsset.materialSlots.length})`
1834
+ : `materials[${diagnostic.slotIndex}] resolves to a live MaterialAsset`,
1835
+ hint: ASSET_ERROR_HINTS[diagnostic.code],
1836
+ detail:
1837
+ diagnostic.code === 'mesh-renderer-material-override-overflow'
1838
+ ? {
1839
+ expectedCount: meshAsset.materialSlots.length,
1840
+ actualCount: materialCount,
1841
+ meshAssetGuid: guid,
1842
+ }
1843
+ : {
1844
+ meshAssetGuid: guid,
1845
+ slotIndex: diagnostic.slotIndex,
1846
+ handle: diagnostic.handle ?? 0,
1847
+ },
1848
+ }),
1849
+ {
1850
+ severity: Severity.Warning,
1851
+ systemName: `RenderSystem.extract (${diagnostic.code})`,
1852
+ },
1853
+ );
1854
+ }
1855
+ }
1856
+ }
1857
+
1858
+ if (materialHandles.length === 0) {
1859
+ materialHandles = [0];
1860
+ materialBindingSources = ['engine-default'];
1861
+ }
1862
+
1863
+ // Use the first material handle for the entity-level snapshot
1864
+ // (shading-model dispatch routing + multi-pass DispatchEntry. Per-
1865
+ // submesh materials[i>=1] are resolved by `resolveMaterialSnapshot`
1866
+ // below into the `materials[]` array, used by the record stage to
1867
+ // upload N material UBO slots and bind the i-th slot before the
1868
+ // i-th submesh draw.) -- feat-20260608 M5 amend / w11-a.
1869
+ const handleRaw = materialHandles[0] ?? 0;
1870
+
1871
+ const cachedMaterial =
1872
+ handleRaw !== 0 && !hasSpriteRegionOverride && !hasSkin
1873
+ ? materialSnapshotCache.get(handleRaw)
1874
+ : undefined;
1875
+
1876
+ let materialSnap: MaterialSnapshot;
1877
+
1878
+ if (cachedMaterial !== undefined) {
1879
+ materialSnap = cachedMaterial.snapshot;
1880
+ if (isRenderable) {
1881
+ appendMaterialDispatchEntries(
1882
+ pendingDispatch,
1883
+ cachedMaterial.passes,
1884
+ entity,
1885
+ handleRaw,
1886
+ renderables.length,
1887
+ layerVal,
1888
+ materialSnap.paramSnapshot,
1889
+ );
1890
+ }
1891
+ } else if (handleRaw === 0 || assets === undefined || assets === null) {
1892
+ // case B: missing-spec sentinel -> mid-grey defaultMaterialSnapshot.
1893
+ materialSnap = defaultMaterialSnapshot(handleRaw);
1894
+ } else {
1895
+ const tagged = toShared<'MaterialAsset'>(handleRaw);
1896
+ const stablePersistentCached =
1897
+ !hasSpriteRegionOverride && !hasSkin
1898
+ ? readStablePersistentMaterialSnapshot(
1899
+ persistentMaterialSnapshotCache,
1900
+ handleRaw,
1901
+ assets,
1902
+ )
1903
+ : undefined;
1904
+ if (stablePersistentCached !== undefined) {
1905
+ materialSnapshotCache.set(handleRaw, stablePersistentCached);
1906
+ materialSnap = stablePersistentCached.snapshot;
1907
+ if (isRenderable) {
1908
+ appendMaterialDispatchEntries(
1909
+ pendingDispatch,
1910
+ stablePersistentCached.passes,
1911
+ entity,
1912
+ handleRaw,
1913
+ renderables.length,
1914
+ layerVal,
1915
+ materialSnap.paramSnapshot,
1916
+ );
1917
+ }
1918
+ } else {
1919
+ const res = resolveAssetHandle(world, tagged);
1920
+ if (!res.ok) {
1921
+ if (isRenderable) {
1922
+ const rhiErr = new RhiError({
1923
+ code: 'asset-not-registered',
1924
+ expected: 'MeshRenderer.material in AssetRegistry',
1925
+ hint: 'catalog the material via assetRegistry.catalog(guid, asset) + world.allocSharedRef before spawn, or remove the material field to fall back to default',
1926
+ detail: { assetHandle: handleRaw },
1927
+ });
1928
+ worldInternal._routeError(rhiErr, {
1929
+ severity: Severity.Error,
1930
+ systemName: 'RenderSystem.extract (material asset-not-registered)',
1931
+ });
1932
+ }
1933
+ continue;
1934
+ }
1935
+ const asset = res.value;
1936
+ if (asset.kind !== 'material') {
1937
+ materialSnap = defaultMaterialSnapshot(handleRaw);
1938
+ } else {
1939
+ // feat-20260529 M3 / w11: material parent chain inheritance via
1940
+ // read-through _materialWalk accessor (plan-strategy D-6).
1941
+ // The old direct asset.passes / asset.values read never
1942
+ // walked the parent chain, causing broken-inheritance (root cause).
1943
+ const resolvedResult = walkMaterialPassesOverSharedRefs(world, tagged, assets);
1944
+ if (!resolvedResult.ok) {
1945
+ // AC-09 / S-7 / q8=A: passes-empty or cycle must fire structured
1946
+ // error through _routeError (same routing as asset-not-registered
1947
+ // branch above). Silent continue is forbidden because it produces
1948
+ // a black screen indistinguishable from a content bug.
1949
+ const err = resolvedResult.error;
1950
+ switch (err.code) {
1951
+ case 'material-parent-not-found':
1952
+ case 'material-no-effective-pass':
1953
+ case 'material-value-unknown':
1954
+ case 'material-value-type-mismatch':
1955
+ case 'material-contract-program-mismatch':
1956
+ worldInternal._routeError(err, {
1957
+ severity: Severity.Error,
1958
+ systemName: `RenderSystem.extract (${err.code})`,
1959
+ });
1960
+ break;
1961
+ case 'material-circular-inheritance':
1962
+ worldInternal._routeError(err, {
1963
+ severity: Severity.Error,
1964
+ systemName: 'RenderSystem.extract (material-circular-inheritance)',
1965
+ });
1966
+ break;
1967
+ default:
1968
+ // Exhaustive guard: unhandled error codes from _materialWalk
1969
+ // surface an internal assertion to avoid silent continuation.
1970
+ worldInternal._routeError(err, {
1971
+ severity: Severity.Error,
1972
+ systemName: `RenderSystem.extract (_materialWalk: ${err.code})`,
1973
+ });
1974
+ }
1975
+ continue;
1976
+ }
1977
+ const resolved = resolvedResult.value;
1978
+ const allPasses = resolved.passes;
1979
+ const firstPassShader =
1980
+ allPasses.length > 0
1981
+ ? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
1982
+ : undefined;
1983
+ const pv = materialValuesToLinearRuntime(
1984
+ resolved.values,
1985
+ materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assets),
1986
+ resolved.colorSpace,
1987
+ ) as Readonly<Record<string, unknown>>;
1988
+
1989
+ const baseColorPv = pv.baseColor as readonly number[] | undefined;
1990
+ const baseColor = vec3.create(
1991
+ baseColorPv?.[0] ?? 1,
1992
+ baseColorPv?.[1] ?? 1,
1993
+ baseColorPv?.[2] ?? 1,
1994
+ );
1995
+ const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
1996
+ const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
1997
+ const specularColorPv = pv.specularColor as readonly number[] | undefined;
1998
+ const normalScalePv = materialNormalScale(pv);
1999
+
2000
+ const paramSnap: Record<string, number | number[] | string> = {};
2001
+ for (const [k, v] of Object.entries(pv)) {
2002
+ if (typeof v === 'number') paramSnap[k] = v;
2003
+ else if (typeof v === 'string') paramSnap[k] = v;
2004
+ else if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
2005
+ paramSnap[k] = v as number[];
2006
+ }
2007
+ }
2008
+
2009
+ const materialParamSchema = materialParametersToParamSchema(
2010
+ resolved.parameters ?? [],
2011
+ firstPassShader,
2012
+ );
2013
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
2014
+ // bidirectional Skin <-> pbr-skin material fail-fast at extract.
2015
+ // Skin component without a forgeax::pbr-skin first-pass material
2016
+ // would draw with a non-skin shader against the 18-float vertex
2017
+ // buffer (joints/weights bytes interpreted as garbage). Conversely
2018
+ // a forgeax::pbr-skin material against a 12-float (unskinned) mesh
2019
+ // would have @location(4)/@location(5) read uninitialized memory.
2020
+ // Both cases route through `_routeError` + `continue` so a single
2021
+ // misconfigured entity does NOT abort the whole frame's draw list
2022
+ // (charter P3 explicit failure + plan-decisions D-5 over `return err`).
2023
+ {
2024
+ const hasSkinSkel =
2025
+ hasSkin &&
2026
+ skinSkeletonView !== undefined &&
2027
+ skinSkeletonView !== undefined &&
2028
+ skinSkeletonView !== 0;
2029
+ const isPbrSkinMaterial = isStandardPbrSkinMaterialShader(firstPassShader);
2030
+ if (hasSkinSkel && !isPbrSkinMaterial) {
2031
+ worldInternal._routeError(new SkinMaterialMismatchError(entity, firstPassShader), {
2032
+ severity: Severity.Error,
2033
+ systemName: 'RenderSystem.extract (skin-material-mismatch)',
2034
+ });
2035
+ continue;
2036
+ }
2037
+ if (isPbrSkinMaterial && fAssetHandleVal !== undefined && fAssetHandleVal !== 0) {
2038
+ const meshHandleForSkinCheck = toShared<'MeshAsset'>(fAssetHandleVal);
2039
+ const meshResForSkinCheck = resolveAssetHandle<MeshAsset>(
2040
+ world,
2041
+ meshHandleForSkinCheck,
2042
+ );
2043
+ if (meshResForSkinCheck.ok) {
2044
+ const meshAttrs = meshResForSkinCheck.value.attributes;
2045
+ const hasSkinIdx = meshAttrs.skinIndex !== undefined;
2046
+ const hasSkinWt = meshAttrs.skinWeight !== undefined;
2047
+ if (!hasSkinIdx || !hasSkinWt) {
2048
+ const missing: 'skinIndex' | 'skinWeight' | 'both' =
2049
+ !hasSkinIdx && !hasSkinWt ? 'both' : !hasSkinIdx ? 'skinIndex' : 'skinWeight';
2050
+ worldInternal._routeError(new MaterialSkinAttrMissingError(entity, missing), {
2051
+ severity: Severity.Error,
2052
+ systemName: 'RenderSystem.extract (material-skin-attr-missing)',
2053
+ });
2054
+ continue;
2055
+ }
2056
+ }
2057
+ }
2058
+ }
2059
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-3):
2060
+ // sprite materials now flow through the same generic paramSchema-
2061
+ // driven extract path PBR / unlit use. The narrow `forgeax::sprite`
2062
+ // exception block below covers exactly 2 plan-authorised cases:
2063
+ // 1. SpriteRegionOverride per-entity region displacement (Q4=a)
2064
+ // 2. flipX / flipY -> region fold (plan-strategy D-8)
2065
+ // No legacy values field-name shim; demos and SpriteParamValues
2066
+ // are UBO-aligned (no `texture` / `baseColor` / `pivot` / `slices`
2067
+ // / `sliceMode` keys reaching this code path). AGENTS.md §Change
2068
+ // stance: "no shim layer, no v1/v2 dual-path".
2069
+ //
2070
+ // feat-20260624 M1' / t6: `'forgeax::sprite-lit'` walks the same
2071
+ // sprite-family vertex path (VsOut byte-identical, paramSchema
2072
+ // mirror) so the SAME 2 folds apply — extending `isSprite` to
2073
+ // cover both shader ids keeps the narrowing-point count at 1
2074
+ // (plan-strategy §1.6 + D-1: mirror sprite, no new branch).
2075
+ const isSprite =
2076
+ firstPassShader === 'forgeax::sprite' || firstPassShader === 'forgeax::sprite-lit';
2077
+
2078
+ // feat-20260613-material-paramschema-driven-binding M4 / w23
2079
+ // (D-5 graceful): paramSchema-driven texture-field validation.
2080
+ // For each handle-shaped paramValue (typeof === 'number'),
2081
+ // verify it actually points at a registered texture asset
2082
+ // when the field is declared as a texture in the shader's
2083
+ // paramSchema; mis-typed handles (e.g. a scalar f32 stored as
2084
+ // int 0 the M4 / w22 graceful fallback resolved to a wrong
2085
+ // sub-asset) are dropped here so the record stage falls back
2086
+ // to MISSING_TEXTURE_HANDLE (default white) without raising.
2087
+ const validateTextureHandle = (
2088
+ fieldName: string,
2089
+ raw: unknown,
2090
+ ): Handle<'TextureAsset', 'shared'> | undefined => {
2091
+ // feat-20260614 M8 (D-19): a string value is an embedded texture
2092
+ // GUID; resolve it to a column handle via catalog + allocSharedRef
2093
+ // before validation. A number is an already-minted column handle.
2094
+ let handle: Handle<'TextureAsset', 'shared'>;
2095
+ const textureRef = materialTextureRef(raw);
2096
+ const textureGuid = assetReferenceText(textureRef);
2097
+ if (textureGuid !== undefined) {
2098
+ if (assets === null || assets === undefined) return undefined;
2099
+ // M4: intern so the GUID mints one stable handle per World
2100
+ // instead of a fresh slot every frame (GPU residency relies on
2101
+ // a stable handleSlot). onLastRelease -> gpuStore.evictTexture.
2102
+ const interned = internSharedRefFromGuid(
2103
+ world,
2104
+ assets,
2105
+ textureGuid,
2106
+ 'TextureAsset',
2107
+ );
2108
+ if (interned === undefined) return undefined;
2109
+ handle = interned;
2110
+ } else if (typeof textureRef === 'number') {
2111
+ handle = toShared<'TextureAsset'>(textureRef);
2112
+ } else {
2113
+ return undefined;
2114
+ }
2115
+ if (assets === null || assets === undefined) return handle;
2116
+ const declaredFields = materialTextureFields(
2117
+ firstPassShader,
2118
+ materialParamSchema.length > 0
2119
+ ? derive(materialParamSchema).textureFieldNames
2120
+ : firstPassShader !== undefined
2121
+ ? assets.materialShaderTextureFieldNames(firstPassShader)
2122
+ : undefined,
2123
+ );
2124
+ // Shader not registered (R-4 cross-worktree path) -> trust the
2125
+ // raw handle and let the record stage / GPU layer surface each
2126
+ // mismatch via MISSING_TEXTURE_HANDLE.
2127
+ if (declaredFields === undefined) return handle;
2128
+ // Field is not declared as a texture by the shader -> the
2129
+ // loader's "try every int" fallback misclassified a scalar;
2130
+ // drop the slot so the record stage uses the default white.
2131
+ if (
2132
+ !declaredFields.has(fieldName) &&
2133
+ !isEngineInjectedTextureField(firstPassShader, fieldName)
2134
+ ) {
2135
+ return undefined;
2136
+ }
2137
+ // Field declared as texture: verify the handle's asset kind.
2138
+ const assetRes = resolveAssetHandle(world, handle);
2139
+ if (!assetRes.ok) return undefined;
2140
+ const kind = (assetRes.value as { kind?: string }).kind;
2141
+ if (kind !== 'texture') return undefined;
2142
+ return handle;
2143
+ };
2144
+ // feat-20260614 M8 (D-19): resolve a sampler / texture paramValue
2145
+ // that may be an embedded GUID string (catalog + allocSharedRef) or
2146
+ // an already-minted column handle (number passthrough).
2147
+ const resolveParamHandle = <B extends string>(
2148
+ raw: unknown,
2149
+ brand: B,
2150
+ ): Handle<B, 'shared'> | undefined => {
2151
+ const value = materialTextureRef(raw);
2152
+ if (typeof value === 'number') return toShared<B>(value);
2153
+ const guid = assetReferenceText(value);
2154
+ if (guid !== undefined) {
2155
+ if (assets === null || assets === undefined) return undefined;
2156
+ // M4: intern the GUID -> column-handle resolution (one stable
2157
+ // handle per (world, guid, brand), reused across frames).
2158
+ return internSharedRefFromGuid(world, assets, guid, brand);
2159
+ }
2160
+ return undefined;
2161
+ };
2162
+ // feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): iterate the
2163
+ // shader's derive(paramSchema).textureFieldNames SSOT so the Nth
2164
+ // user-region texture (e.g. parallax heightTexture) is validated +
2165
+ // carried, replacing the hardcoded 3-field list. validateTextureHandle
2166
+ // already drops fields a shader doesn't declare as a texture.
2167
+ const userRegionFields =
2168
+ materialTextureFields(
2169
+ firstPassShader,
2170
+ materialParamSchema.length > 0
2171
+ ? derive(materialParamSchema).textureFieldNames
2172
+ : firstPassShader !== undefined && assets !== null && assets !== undefined
2173
+ ? assets.materialShaderTextureFieldNames(firstPassShader)
2174
+ : undefined,
2175
+ ) ?? BUILTIN_USER_REGION_TEXTURE_FIELDS;
2176
+ const textureHandles = new Map<string, Handle<'TextureAsset', 'shared'>>();
2177
+ const videoTextureFields = new Map<string, Handle<'VideoAsset', 'shared'>>();
2178
+ for (const field of userRegionFields) {
2179
+ // D-5: a video-kind paramValue routes to the transient path
2180
+ // (videoTextureFields), NOT validateTextureHandle (which drops
2181
+ // kind!=='texture', the R-7 silent-fail path). Static fields fall
2182
+ // through to validateTextureHandle unchanged.
2183
+ const videoHandle =
2184
+ assets !== null && assets !== undefined
2185
+ ? resolveVideoFieldHandle(pv[field], world, assets)
2186
+ : undefined;
2187
+ if (videoHandle !== undefined) {
2188
+ videoTextureFields.set(field, videoHandle);
2189
+ continue;
2190
+ }
2191
+ const handle = validateTextureHandle(field, pv[field]);
2192
+ if (handle !== undefined) textureHandles.set(field, handle);
2193
+ }
2194
+ const baseColorTextureHandle = textureHandles.get('baseColorTexture');
2195
+ const metallicRoughnessTextureHandle = textureHandles.get('metallicRoughnessTexture');
2196
+ const normalTextureHandle = textureHandles.get('normalTexture');
2197
+ const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
2198
+ resolveParamHandle(materialTextureRef(value), 'SamplerAsset'),
2199
+ );
2200
+ const emissiveTextureHandle = validateTextureHandle(
2201
+ 'emissiveTexture',
2202
+ pv.emissiveTexture,
2203
+ );
2204
+ const occlusionTextureHandle = validateTextureHandle(
2205
+ 'occlusionTexture',
2206
+ pv.occlusionTexture,
2207
+ );
2208
+ const textureCoordinates = collectMaterialTextureCoordinates(pv);
2209
+ const textureSources = collectMaterialTextureSources(pv, world);
2210
+ const emissivePv = pv.emissive as readonly number[] | undefined;
2211
+ // feat-20260625 M2 / w6: first-pass transparency flag folds into
2212
+ // MaterialSnapshot.transparent so the record stage can drive the
2213
+ // LDR split + premultiplied-alpha blend decision without
2214
+ // re-reading passes[]. feat-20260626-collapse M2: derive from
2215
+ // `passes[0].renderState.blend !== undefined` (blend presence is
2216
+ // the SSOT after MaterialPass.transparent was dropped).
2217
+ // Result is plain boolean (always defined here) — written as-is
2218
+ // into the snapshot (`boolean | undefined` field, see L759).
2219
+ const firstPassTransparent: boolean = allPasses[0]?.renderState?.blend !== undefined;
2220
+
2221
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-8):
2222
+ // narrow `forgeax::sprite` extract block --- folds the legacy user
2223
+ // values format (flipX / flipY / slices / sliceMode + free
2224
+ // region / pivot) into the UBO-aligned paramSnapshot vec4 fields
2225
+ // (region / pivotAndSize / slicesAndMode + colorTint). Also folds
2226
+ // per-entity SpriteRegionOverride (Q4=a). After this block the
2227
+ // generic else branch picks up the snapshot via the same writer
2228
+ // path PBR / unlit use; no more shadingModel='sprite' arm, no
2229
+ // spriteFields POD (AC-02 / AC-07: extract has exactly 2 hard
2230
+ // `forgeax::sprite` checks --- this fold + the slices mesh swap on
2231
+ // the record side).
2232
+ if (isSprite) {
2233
+ // SpriteRegionOverride: per-entity per-frame region displacement.
2234
+ let overrideRegion: readonly [number, number, number, number] | undefined;
2235
+ if (hasSpriteRegionOverride) {
2236
+ const overrideView = worldInternal._getArrayView(
2237
+ entity,
2238
+ SpriteRegionOverride,
2239
+ 'region',
2240
+ ) as Float32Array | undefined;
2241
+ if (overrideView !== undefined && overrideView.length >= 4) {
2242
+ overrideRegion = [
2243
+ overrideView[0] ?? 0,
2244
+ overrideView[1] ?? 0,
2245
+ overrideView[2] ?? 1,
2246
+ overrideView[3] ?? 1,
2247
+ ];
2248
+ }
2249
+ }
2250
+ // Region resolution priority: SpriteRegionOverride > paramSnapshot.
2251
+ // region (UBO-aligned user input) > [0,0,1,1] identity.
2252
+ const regionPv = paramSnap.region as readonly number[] | undefined;
2253
+ let regionX = overrideRegion?.[0] ?? regionPv?.[0] ?? 0;
2254
+ let regionY = overrideRegion?.[1] ?? regionPv?.[1] ?? 0;
2255
+ let regionZ = overrideRegion?.[2] ?? regionPv?.[2] ?? 1;
2256
+ let regionW = overrideRegion?.[3] ?? regionPv?.[3] ?? 1;
2257
+ // flipX / flipY fold into region (D-8): the shader does
2258
+ // `uv * region.zw + region.xy`, so flipping along U is a sign
2259
+ // negation of region.z plus an origin offset.
2260
+ const flipXPv = typeof pv.flipX === 'number' ? pv.flipX : 0;
2261
+ const flipYPv = typeof pv.flipY === 'number' ? pv.flipY : 0;
2262
+ if (flipXPv !== 0) {
2263
+ regionX += regionZ;
2264
+ regionZ = -regionZ;
2265
+ }
2266
+ if (flipYPv !== 0) {
2267
+ regionY += regionW;
2268
+ regionW = -regionW;
2269
+ }
2270
+ paramSnap.region = [regionX, regionY, regionZ, regionW];
2271
+ // Guard: slicesAndMode must be present and zero for non-9-slice
2272
+ // sprites so the record-stage UBO writer (applyParamSnapshotToUbo)
2273
+ // writes [0,0,0,0] at offset 48 instead of leaving the
2274
+ // buildPbrMaterialUboPayload PBR baseline (e.g. occlusionStrength=1
2275
+ // at that slot). A non-zero slicesAndMode trips `useSlices=true`
2276
+ // in sprite.wgsl, which degenerates HANDLE_QUAD geometry → invisible.
2277
+ if (!('slicesAndMode' in paramSnap)) {
2278
+ (paramSnap as Record<string, unknown>).slicesAndMode = [0, 0, 0, 0];
2279
+ }
2280
+ }
2281
+
2282
+ // Generic materialShaderId snapshot --- sprite included now flows
2283
+ // through this single branch (plan-strategy D-3 / AC-01 / AC-02 /
2284
+ // AC-07). The sprite block above only writes paramSnap.region (D-8
2285
+ // SpriteRegionOverride + flip fold); the rest of the UBO is filled
2286
+ // by the same paramSchema-driven path PBR / unlit use.
2287
+ materialSnap = {
2288
+ baseColor,
2289
+ metallic: metallicPv,
2290
+ roughness: roughnessPv,
2291
+ ...(specularColorPv !== undefined && {
2292
+ specularColor: [
2293
+ specularColorPv[0] ?? 1,
2294
+ specularColorPv[1] ?? 1,
2295
+ specularColorPv[2] ?? 1,
2296
+ ] as readonly [number, number, number],
2297
+ }),
2298
+ normalScale: normalScalePv,
2299
+ materialShaderId: firstPassShader,
2300
+ materialHandle: handleRaw,
2301
+ renderState: pipelineRenderState(allPasses[0]?.renderState),
2302
+ paramSnapshot: paramSnap,
2303
+ ...(materialParamSchema.length > 0 && { materialParamSchema }),
2304
+ ...(textureCoordinates.size > 0 && { textureCoordinates }),
2305
+ ...(samplerHandles.size > 0 && { samplerHandles }),
2306
+ ...(textureHandles.size > 0 && { textureHandles }),
2307
+ ...(textureSources.size > 0 && { textureSources }),
2308
+ ...(videoTextureFields.size > 0 && { videoTextureFields }),
2309
+ ...(baseColorTextureHandle !== undefined && {
2310
+ baseColorTexture: baseColorTextureHandle,
2311
+ }),
2312
+ ...(metallicRoughnessTextureHandle !== undefined && {
2313
+ metallicRoughnessTexture: metallicRoughnessTextureHandle,
2314
+ }),
2315
+ ...(normalTextureHandle !== undefined && { normalTexture: normalTextureHandle }),
2316
+ ...(emissivePv !== undefined && {
2317
+ emissive: [emissivePv[0] ?? 0, emissivePv[1] ?? 0, emissivePv[2] ?? 0] as readonly [
2318
+ number,
2319
+ number,
2320
+ number,
2321
+ ],
2322
+ }),
2323
+ ...(typeof pv.emissiveIntensity === 'number' && {
2324
+ emissiveIntensity: pv.emissiveIntensity,
2325
+ }),
2326
+ ...(emissiveTextureHandle !== undefined && {
2327
+ emissiveTexture: emissiveTextureHandle,
2328
+ }),
2329
+ ...(occlusionTextureHandle !== undefined && {
2330
+ occlusionTexture: occlusionTextureHandle,
2331
+ }),
2332
+ ...(typeof pv.occlusionStrength === 'number' && {
2333
+ occlusionStrength: pv.occlusionStrength,
2334
+ }),
2335
+ transparent: firstPassTransparent,
2336
+ };
2337
+
2338
+ if (!hasSpriteRegionOverride && !hasSkin) {
2339
+ const stored = storeMaterialSnapshot(
2340
+ materialSnapshotCache,
2341
+ handleRaw,
2342
+ materialSnap,
2343
+ allPasses,
2344
+ asset,
2345
+ assets,
2346
+ );
2347
+ if (stored.crossFrameSafe) persistentMaterialSnapshotCache?.set(handleRaw, stored);
2348
+ }
2349
+
2350
+ // Build dispatch entries from resolved passes.
2351
+ if (isRenderable) {
2352
+ appendMaterialDispatchEntries(
2353
+ pendingDispatch,
2354
+ allPasses,
2355
+ entity,
2356
+ handleRaw,
2357
+ renderables.length,
2358
+ layerVal,
2359
+ paramSnap,
2360
+ );
2361
+ }
2362
+ }
2363
+ }
2364
+ }
2365
+
2366
+ // feat-20260609 M2/M5 corrective fixup: default-material entities
2367
+ // (handleRaw===0 / case-B MeshRenderer{data:{}}) must produce
2368
+ // ShadowCaster dispatch entries so the shadow pass includes them.
2369
+ // The pre-existing logic only builds dispatch entries from
2370
+ // resolved material assets; defaultMaterialSnapshot() (mid-grey unlit)
2371
+ // left dispatch empty, causing shadow-m2/m3 test failures.
2372
+ // Requirements §10.5: shadow-casting is default behaviour, opt-out
2373
+ // via castShadow:false. The default material has no opt-out, so
2374
+ // it casts shadows.
2375
+ //
2376
+ // CHARTER NOTE (feat-20260609 T-005-a): the URP literals
2377
+ // `LightMode: 'ShadowCaster'` / `LightMode: 'Forward'` below are a
2378
+ // local URP-bridge — they mirror what `Materials.unlit({ castShadow:
2379
+ // true })` produces at the asset layer. The default-material
2380
+ // handle=0 path bypasses asset registration, so we synthesize the
2381
+ // same dispatch shape inline. Follow-up cleanup (F-1 from
2382
+ // implement-review R1): thread default materials through the
2383
+ // Materials factory so this block can call into the shared
2384
+ // passes[] producer.
2385
+ // tweak-20260701 M1: `materialSnap.shadingModel === 'unlit'` removed —
2386
+ // for handleRaw===0, defaultMaterialSnapshot() was always unlit
2387
+ // (the shadingModel check was a tautology); the isRenderable &&
2388
+ // handleRaw===0 guard alone preserves the exact same dispatch shape.
2389
+ if (isRenderable && handleRaw === 0) {
2390
+ const shadowCasterTags: Record<string, string> = { LightMode: 'ShadowCaster' };
2391
+ const nextRenderableIndex = renderables.length;
2392
+ // M2.5: stage into pendingDispatch; flushed at the renderable push
2393
+ // site below only when the entity survives cull.
2394
+ pendingDispatch.push({
2395
+ entityIndex: entity,
2396
+ materialHandle: 0,
2397
+ renderableIndex: nextRenderableIndex,
2398
+ passIndex: 0,
2399
+ queue: 2000,
2400
+ layer: layerVal,
2401
+ tags: shadowCasterTags,
2402
+ renderState: undefined,
2403
+ defines: undefined,
2404
+ vertexEntry: 'vs_main',
2405
+ fragmentEntry: undefined,
2406
+ materialShaderId: 'forgeax::default-shadow-caster',
2407
+ paramSnapshot: {},
2408
+ });
2409
+ // Also add a Forward pass entry so the entity renders in the
2410
+ // main scene pass (mirrors Materials.unlit default).
2411
+ appendMaterialDispatchEntries(
2412
+ pendingDispatch,
2413
+ [DEFAULT_FORWARD_PASS],
2414
+ entity,
2415
+ 0,
2416
+ nextRenderableIndex,
2417
+ layerVal,
2418
+ {},
2419
+ 1,
2420
+ );
2421
+ }
2422
+
2423
+ if (isRenderable) {
2424
+ // feat-20260612 M2 / m2-6: Skin + Instances coexistence + per-joint
2425
+ // dangling fail-fast + real palette slice allocation. Replaces the
2426
+ // T-21 placeholder ({0,0} discriminator-only sentinel) with full
2427
+ // resolve / validate / write chain (D-9 reset already fired at
2428
+ // extractFrame entry; per-entity allocate + writeJointPalette here).
2429
+ let skinSlice: SkinPaletteSlice | undefined;
2430
+ if (hasSkin) {
2431
+ const skeletonHandleRaw = skinSkeletonView;
2432
+ if (
2433
+ skeletonHandleRaw !== undefined &&
2434
+ skeletonHandleRaw !== 0 &&
2435
+ assets !== undefined &&
2436
+ assets !== null
2437
+ ) {
2438
+ // Skin + Instances coexistence is forbidden (D-10).
2439
+ if (hasInstances) {
2440
+ worldInternal._routeError(new SkinInstancesCoexistForbiddenError(entity), {
2441
+ severity: Severity.Error,
2442
+ systemName: 'RenderSystem.extract (skin-instances-coexist)',
2443
+ });
2444
+
2445
+ continue;
2446
+ }
2447
+ // (a) Resolve skeleton asset; on failure -> skeleton-resolve-failed.
2448
+ const skeletonHandle = toShared<'SkeletonAsset'>(skeletonHandleRaw);
2449
+ const skeletonRes = resolveAssetHandle<SkeletonAsset>(world, skeletonHandle);
2450
+ if (!skeletonRes.ok || skeletonRes.value.kind !== 'skeleton') {
2451
+ worldInternal._routeError(new SkeletonResolveFailedError(entity, skeletonHandleRaw), {
2452
+ severity: Severity.Error,
2453
+ systemName: 'RenderSystem.extract (skeleton-resolve-failed)',
2454
+ });
2455
+ continue;
2456
+ }
2457
+ const skeleton = skeletonRes.value;
2458
+ // (b) Reuse the Skin row already read for its skeleton handle;
2459
+ // the old path repeated a second whole-row world.get here.
2460
+ const skinJoints = skin?.joints;
2461
+ if (skinJoints === undefined) continue;
2462
+ const jointsLength = skinJoints.length;
2463
+ if (jointsLength !== skeleton.jointCount) {
2464
+ worldInternal._routeError(
2465
+ new JointCountMismatchError(entity, skeleton.jointCount, jointsLength),
2466
+ {
2467
+ severity: Severity.Error,
2468
+ systemName: 'RenderSystem.extract (joint-count-mismatch)',
2469
+ },
2470
+ );
2471
+ continue;
2472
+ }
2473
+ // (c) Resolve only each joint's GlobalTransform.world column. The
2474
+ // transient view is consumed before a structural mutation and
2475
+ // therefore preserves the same dangling-joint behavior without
2476
+ // constructing every other Transform field.
2477
+ // Build mat4 list eagerly so write happens once per entity (no
2478
+ // half-written slice on dangling).
2479
+ const jointWorlds = new Array<Mat4>(skeleton.jointCount);
2480
+ let jointDangling = -1;
2481
+ for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
2482
+ const jointEntityRaw = skinJoints[jIdx] ?? 0;
2483
+ const jointEntity = jointEntityRaw as EntityHandle;
2484
+ const jointWorld = worldInternal._getArrayView(jointEntity, GlobalTransform, 'world');
2485
+ if (jointWorld === undefined) {
2486
+ jointDangling = jIdx;
2487
+ break;
2488
+ }
2489
+ // The allocator consumes the canonical Mat4 brand. Copy the
2490
+ // column view into that owner-created value so the ECS storage
2491
+ // view never crosses the math brand boundary by assertion.
2492
+ const jointWorldMat = mat4.create();
2493
+ jointWorldMat.set(jointWorld);
2494
+ jointWorlds[jIdx] = jointWorldMat;
2495
+ }
2496
+ if (jointDangling >= 0) {
2497
+ worldInternal._routeError(new JointEntityDanglingError(entity, jointDangling), {
2498
+ severity: Severity.Error,
2499
+ systemName: 'RenderSystem.extract (joint-entity-dangling)',
2500
+ });
2501
+ continue;
2502
+ }
2503
+ // (d) Slice the IBM flat Float32Array into per-joint Float32Arrays.
2504
+ // skeleton.inverseBindMatrices length === jointCount * 16.
2505
+ const ibmFlat = skeleton.inverseBindMatrices;
2506
+ const ibms: Float32Array[] = new Array<Float32Array>(skeleton.jointCount);
2507
+ for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
2508
+ ibms[jIdx] = ibmFlat.subarray(jIdx * 16, jIdx * 16 + 16);
2509
+ }
2510
+ // (e) Allocate slice + write palette via the allocator (D-9 reset
2511
+ // already fired at extractFrame entry). When the pipelineState
2512
+ // surface is absent (test fixtures that pass undefined) the
2513
+ // hasSkin segment is skipped silently — bind-pose equivalent.
2514
+ if (skinPaletteAllocator !== null) {
2515
+ const slice = skinPaletteAllocator.allocateSlice(skeleton.jointCount);
2516
+ skinPaletteAllocator.writeJointPalette(slice, ibms, jointWorlds);
2517
+ skinSlice = {
2518
+ jointCount: slice.jointCount,
2519
+ byteOffset: slice.byteOffset,
2520
+ buffer: slice.buffer,
2521
+ };
2522
+ }
2523
+ }
2524
+ }
2525
+
2526
+ // A pbr-skin PSO requires the complete three-binding group(2) skin
2527
+ // projection. Do not publish a renderable or its staged dispatch when
2528
+ // palette extraction was unavailable; the record stage must never
2529
+ // reinterpret a skinned vertex stream through an ordinary mesh group.
2530
+ if (materialSnap.materialShaderId === 'forgeax::pbr-skin' && skinSlice === undefined) {
2531
+ pendingDispatch.length = 0;
2532
+ continue;
2533
+ }
2534
+
2535
+ // feat-20260601 D-3: read the resolved world mat4 (propagateTransforms
2536
+ // output) straight from the GlobalTransform.world column array view. The
2537
+ // record stage copies these 16 floats into the mesh SSBO with zero
2538
+ // per-snapshot `mat4.compose` (AC-07). A stale slot (generation gone)
2539
+ // skips the renderable, mirroring the Instances dangling-row sweep.
2540
+ // tweak-20260611 M1 / K-3: `_getArrayView` call survives untouched;
2541
+ // only the `entity` source switched to `bundle.Entity.self[i]`.
2542
+ const worldView = worldInternal._getArrayView(entity, GlobalTransform, 'world');
2543
+ if (worldView === undefined) continue;
2544
+ const worldMat = new Float32Array(worldView);
2545
+ const transformSnap: TransformSnapshot = { world: worldMat };
2546
+ // feat-20260608 M5 amend / w11-a: per-submesh `materials[]` array
2547
+ // aligned 1-1 with `MeshAsset.submeshes[]`. materials[0] === the
2548
+ // representative entity-level snapshot already built; materials[i>=1]
2549
+ // are resolved via `resolveMaterialSnapshot` (a non-sprite, single-
2550
+ // pass-equivalent resolver — sprite per-submesh is OOS-1). When the
2551
+ // entity has no materialsView (case-B sentinel) the array is a single
2552
+ // mid-grey default mirroring the legacy single-material path so the
2553
+ // record stage's per-submesh UBO upload loop trivially writes one
2554
+ // slot, no special branch.
2555
+ const materialsArr: MaterialSnapshot[] = [materialSnap];
2556
+ if (assets !== undefined && assets !== null) {
2557
+ for (let mi = 1; mi < materialHandles.length; mi++) {
2558
+ const subHandle = materialHandles[mi] ?? 0;
2559
+ const cachedSubmaterial = materialSnapshotCache.get(subHandle);
2560
+ materialsArr.push(
2561
+ cachedSubmaterial?.snapshot ??
2562
+ resolveMaterialSnapshot(
2563
+ subHandle,
2564
+ world,
2565
+ assets,
2566
+ materialSnapshotCache,
2567
+ persistentMaterialSnapshotCache,
2568
+ ),
2569
+ );
2570
+ }
2571
+ }
2572
+ if (isRenderable && assets !== undefined && assets !== null) {
2573
+ for (let mi = 1; mi < materialHandles.length; mi++) {
2574
+ const subHandle = materialHandles[mi] ?? 0;
2575
+ if (subHandle === handleRaw) continue;
2576
+ const subEntry =
2577
+ materialSnapshotCache.get(subHandle) ??
2578
+ readPersistentMaterialSnapshot(persistentMaterialSnapshotCache, subHandle, assets);
2579
+ appendMaterialDispatchEntries(
2580
+ pendingDispatch,
2581
+ subEntry?.passes ?? (subHandle === 0 ? [DEFAULT_FORWARD_PASS] : []),
2582
+ entity,
2583
+ subHandle,
2584
+ renderables.length,
2585
+ layerVal,
2586
+ materialsArr[mi]?.paramSnapshot,
2587
+ );
2588
+ }
2589
+ }
2590
+
2591
+ // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 /
2592
+ // w10: SpriteInstances validation + snapshot materialisation.
2593
+ // Three structured EcsError fires at this single point (plan-strategy
2594
+ // D-6 "fail-fast at the render domain entry, not at ECS spawn-time"):
2595
+ let spriteInstancesSnap: SpriteInstancesSnapshot | undefined;
2596
+ if (hasSpriteInstances) {
2597
+ // (1) mutually exclusive with Instances (peers — pick one).
2598
+ if (hasInstances) {
2599
+ worldInternal._routeError(
2600
+ new SpriteInstancesMutuallyExclusiveWithInstancesError(entity),
2601
+ {
2602
+ severity: Severity.Error,
2603
+ systemName: 'RenderSystem.extract (sprite-instances-mutually-exclusive)',
2604
+ },
2605
+ );
2606
+ continue;
2607
+ }
2608
+ // (2) requires sprite shader — the per-instance UV region is
2609
+ // consumed by the sprite vertex shader path only (plan-strategy D-4
2610
+ // axis on sprite.wgsl). Post-collapse (PR #520): sprite is no longer
2611
+ // a `shadingModel` enum member; identification is via the first-pass
2612
+ // `materialShaderId === 'forgeax::sprite'` (OOS-1 path retained).
2613
+ //
2614
+ // feat-20260624 M1' / t6: `'forgeax::sprite-lit'` also walks the same
2615
+ // per-instance UV region vertex path (VsOut byte-identical, paramSchema
2616
+ // mirror); accept either shader id.
2617
+ if (
2618
+ materialSnap.materialShaderId !== 'forgeax::sprite' &&
2619
+ materialSnap.materialShaderId !== 'forgeax::sprite-lit'
2620
+ ) {
2621
+ worldInternal._routeError(
2622
+ new SpriteInstancesRequiresSpriteShaderError(
2623
+ entity,
2624
+ materialSnap.materialShaderId ?? 'undefined',
2625
+ ),
2626
+ {
2627
+ severity: Severity.Error,
2628
+ systemName: 'RenderSystem.extract (sprite-instances-requires-sprite-shader)',
2629
+ },
2630
+ );
2631
+ continue;
2632
+ }
2633
+ // (3) count mismatch — transforms.length / 16 === regions.length / 4
2634
+ // (transforms.length=0 + regions.length=0 is the zero-instance lawful
2635
+ // boundary; both derivations are 0 and equality holds, so no fire).
2636
+ const transforms = worldInternal._getArrayView(entity, SpriteInstances, 'transforms');
2637
+ const regions = worldInternal._getArrayView(entity, SpriteInstances, 'regions');
2638
+ if (transforms !== undefined && regions !== undefined) {
2639
+ const transformsLength = transforms.length;
2640
+ const regionsLength = regions.length;
2641
+ // Stride sanity: transforms must be mod 16, regions must be mod 4.
2642
+ // A stride violation expresses as a count mismatch under the
2643
+ // canonical derivation transforms/16 vs regions/4 — fire the
2644
+ // count-mismatch code (the same code carries detail.expectedStride).
2645
+ const tCount = transformsLength / 16;
2646
+ const rCount = regionsLength / 4;
2647
+ if (transformsLength % 16 !== 0 || regionsLength % 4 !== 0 || tCount !== rCount) {
2648
+ worldInternal._routeError(
2649
+ new SpriteInstancesCountMismatchError(transformsLength, regionsLength),
2650
+ {
2651
+ severity: Severity.Error,
2652
+ systemName: 'RenderSystem.extract (sprite-instances-count-mismatch)',
2653
+ },
2654
+ );
2655
+ continue;
2656
+ }
2657
+ // Validation passes — build the snapshot. transforms.length === 0
2658
+ // is lawful (zero-instance) and produces instanceCount=0; the
2659
+ // record stage skips drawIndexed when instanceCount===0.
2660
+ const transformsCopy = new Float32Array(transforms);
2661
+ const regionsCopy = new Float32Array(regions);
2662
+ spriteInstancesSnap = {
2663
+ transforms: transformsCopy,
2664
+ regions: regionsCopy,
2665
+ instanceCount: tCount,
2666
+ cacheKey: entity,
2667
+ archVersion,
2668
+ };
2669
+ }
2670
+ }
2671
+
2672
+ let localAabb: Float32Array | undefined;
2673
+ let lods: readonly MeshLodLevel[] | undefined;
2674
+ let lodHysteresis: number | undefined;
2675
+ let morph: MorphSnapshot | undefined;
2676
+ const assetHandleRaw = Math.round(fAssetHandle ?? 0);
2677
+ if (assetHandleRaw !== 0) {
2678
+ const meshRes = resolveAssetHandle(world, toShared<'MeshAsset'>(assetHandleRaw));
2679
+ if (meshRes.ok && meshRes.value.kind === 'mesh') {
2680
+ const meshAsset = meshRes.value as MeshAsset;
2681
+ lods = meshAsset.lods;
2682
+ lodHysteresis = meshAsset.lodHysteresis;
2683
+ morph = morphSnapshotFor(meshAsset, morphWeightsView);
2684
+ const meshAabb = meshAsset.aabb;
2685
+ // Morph targets can expand the authored bounds. Keep the
2686
+ // specialized lane conservative until a target-aware bounds
2687
+ // projection is available; a false-positive draw is preferable
2688
+ // to culling a valid deformed vertex.
2689
+ if (morph === undefined && hasFiniteOrderedLocalAabb(meshAabb)) {
2690
+ localAabb = new Float32Array(meshAabb);
2691
+ }
2692
+ }
2693
+ }
2694
+
2695
+ const pointsLinesStyle: PointsLinesStyle | undefined =
2696
+ points !== undefined
2697
+ ? (() => {
2698
+ const shape = pointShapeFromU32(points.shape);
2699
+ return shape === undefined
2700
+ ? undefined
2701
+ : { kind: 'points' as const, sizePx: points.sizePx, shape };
2702
+ })()
2703
+ : lines === undefined
2704
+ ? undefined
2705
+ : { kind: 'lines' as const, widthPx: lines.widthPx };
2706
+ const cullingLocalAabb = expandPointsLinesBounds(localAabb ?? [], pointsLinesStyle);
2707
+ const pointsLines =
2708
+ pointsLinesComponent === undefined
2709
+ ? undefined
2710
+ : ({
2711
+ worldId: 0,
2712
+ entityKey: entity,
2713
+ component: pointsLinesComponent,
2714
+ meshHandle: assetHandleRaw,
2715
+ meshGeneration: assets?.catalogEpoch ?? 0,
2716
+ materialHandle: handleRaw,
2717
+ materialGeneration: assets?.catalogEpoch ?? 0,
2718
+ style: pointsLinesStyle,
2719
+ layer: layerVal,
2720
+ sortKey,
2721
+ visible: true,
2722
+ sourceBounds: cullingLocalAabb,
2723
+ viewport: { width: 0, height: 0, dpr: 1 },
2724
+ projection: identityProjection(),
2725
+ } satisfies PointsLinesRetainedSnapshot);
2726
+
2727
+ // Instances are snapshotted before the CPU visibility decision so the
2728
+ // culling owner can derive the union of every instance's transformed
2729
+ // mesh bounds. The same detached snapshot is then carried to record;
2730
+ // do not re-read the ECS array after culling (a structural write must
2731
+ // not make the cull and draw disagree within one frame).
2732
+ let instancesSnap: InstancesSnapshot | undefined;
2733
+ if (hasInstances) {
2734
+ const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
2735
+ if (transforms === undefined) {
2736
+ // A present component with an unavailable array is an empty
2737
+ // instance contribution, not an absent component. Preserve the
2738
+ // zero-draw contract so the record stage cannot synthesize one
2739
+ // identity instance from an incomplete row.
2740
+ instancesSnap = {
2741
+ transforms: new Float32Array(),
2742
+ instanceCount: 0,
2743
+ cacheKey: entity,
2744
+ archVersion,
2745
+ };
2746
+ } else {
2747
+ const actualLength = transforms.length;
2748
+ if (actualLength % 16 !== 0) {
2749
+ worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
2750
+ severity: Severity.Error,
2751
+ systemName: 'RenderSystem.extract (Instances stride)',
2752
+ });
2753
+ continue;
2754
+ }
2755
+ const snapshotCopy = new Float32Array(transforms);
2756
+ instancesSnap = {
2757
+ transforms: snapshotCopy,
2758
+ instanceCount: actualLength / 16,
2759
+ cacheKey: entity,
2760
+ archVersion,
2761
+ };
2762
+ }
2763
+ }
2764
+
2765
+ const baseRenderable: RenderableSnapshot = {
2766
+ assetHandle: Math.round(fAssetHandle ?? 0),
2767
+ transform: transformSnap,
2768
+ ...(lods === undefined ? {} : { lods }),
2769
+ ...(lodHysteresis === undefined ? {} : { lodHysteresis }),
2770
+ ...(localAabb !== undefined ? { localAabb: cullingLocalAabb } : {}),
2771
+ material: materialSnap,
2772
+ materials: materialsArr,
2773
+ materialBindingSources,
2774
+ materialBindingDiagnostics,
2775
+ worldId: 0,
2776
+ entityKey: entity,
2777
+ ...(skinSlice !== undefined ? { skin: skinSlice } : {}),
2778
+ ...(morph !== undefined ? { morph } : {}),
2779
+ ...(instancesSnap !== undefined ? { instances: instancesSnap } : {}),
2780
+ ...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),
2781
+ ...(pointsLines !== undefined ? { pointsLines } : {}),
2782
+ };
2783
+ const gpuDrivenDraws = buildGpuDrivenDraws({
2784
+ submeshes: gpuDrivenSubmeshes,
2785
+ indexed: gpuDrivenIndexed,
2786
+ materials: materialsArr,
2787
+ fallbackMaterial: materialSnap,
2788
+ ...(gpuDrivenGeometry === undefined || getMaterialShaderArtifact === undefined
2789
+ ? {}
2790
+ : {
2791
+ prepare(draw, drawMaterial) {
2792
+ const materialShaderId = drawMaterial.materialShaderId;
2793
+ if (materialShaderId === undefined) return undefined;
2794
+ const artifact = getMaterialShaderArtifact(materialShaderId);
2795
+ if (artifact?.receipt === undefined) return undefined;
2796
+ const geometry: GpuDrivenGeometryReceipt = {
2797
+ ...gpuDrivenGeometry,
2798
+ topology: draw.topology,
2799
+ indexed: draw.kind === 'indexed',
2800
+ };
2801
+ const result = resolvePreparedGpuDrivenDraw({
2802
+ snapshot: { ...baseRenderable, material: drawMaterial },
2803
+ artifact,
2804
+ geometry,
2805
+ generation: artifact.receipt.generation,
2806
+ draw,
2807
+ });
2808
+ return result.ok ? result.value : undefined;
2809
+ },
2810
+ }),
2811
+ });
2812
+ const renderable =
2813
+ morph === undefined && gpuDrivenDraws.length > 0
2814
+ ? { ...baseRenderable, gpuDrivenDraws }
2815
+ : baseRenderable;
2816
+
2817
+ // feat-20260528-frustum-culling M3 / w10: frustum culling check.
2818
+ // Skip the entity if a valid AABB exists AND ALL cameras' frusta
2819
+ // reject the world-space AABB. Missing or malformed AABBs are
2820
+ // conservative always-visible fallbacks; valid culling bounds are
2821
+ // producer-owned finite local-space AABBs. Culling is unconditional
2822
+ // engine behavior; there is no per-entity opt-out.
2823
+ if (localAabb !== undefined) {
2824
+ // An empty or malformed Instances payload has no meaningful bounds.
2825
+ // Keep it conservative (the draw path later emits zero instances)
2826
+ // instead of manufacturing the entity's identity bounds. For a
2827
+ // valid payload, derive one world-space union for CPU culling while
2828
+ // leaving the mesh-local AABB untouched for GPU per-instance cull.
2829
+ const derivedInstancesBounds =
2830
+ instancesSnap === undefined || instancesSnap.instanceCount === 0
2831
+ ? undefined
2832
+ : deriveInstancesUnionBounds({
2833
+ meshAabb: cullingLocalAabb,
2834
+ entityWorld: transformSnap.world,
2835
+ transforms: instancesSnap.transforms,
2836
+ });
2837
+ if (instancesSnap?.instanceCount === 0) {
2838
+ // No instance can contribute a visible bound. The retained
2839
+ // snapshot still records instanceCount=0 for the record stage.
2840
+ } else if (
2841
+ hasInstances &&
2842
+ (instancesSnap === undefined || derivedInstancesBounds === undefined)
2843
+ ) {
2844
+ // Missing/malformed derived facts are a conservative no-cull
2845
+ // result. The stride validator above already rejects malformed
2846
+ // lengths; this branch covers non-finite producer matrices/AABB.
2847
+ } else {
2848
+ // feat-20260601 D-3: cull AABB uses the resolved world mat4
2849
+ // directly (no compose) -- same source the record stage feeds
2850
+ // the mesh SSBO, so cull stays same-source with render (AC-05).
2851
+ const worldAabb = box3.create();
2852
+ if (derivedInstancesBounds !== undefined) {
2853
+ worldAabb[0] = derivedInstancesBounds[0] ?? 0;
2854
+ worldAabb[1] = derivedInstancesBounds[1] ?? 0;
2855
+ worldAabb[2] = derivedInstancesBounds[2] ?? 0;
2856
+ worldAabb[3] = derivedInstancesBounds[3] ?? 0;
2857
+ worldAabb[4] = derivedInstancesBounds[4] ?? 0;
2858
+ worldAabb[5] = derivedInstancesBounds[5] ?? 0;
2859
+ } else {
2860
+ box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
2861
+ }
2862
+
2863
+ // Test against all cameras. Entity is visible if one camera
2864
+ // frustum intersects the world-space AABB (or planes are empty
2865
+ // from degenerate projection).
2866
+ frustumTotal += 1;
2867
+ let visible = frustumPlanes.length === 0;
2868
+ for (let ci = 0; ci < frustumPlanes.length; ci++) {
2869
+ const planes = frustumPlanes[ci] as Float32Array;
2870
+ if (planes.length === 0) {
2871
+ visible = true;
2872
+ break;
2873
+ }
2874
+ if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
2875
+ visible = true;
2876
+ break;
2877
+ }
2878
+ }
2879
+ if (!visible) {
2880
+ frustumCulled += 1;
2881
+ continue;
2882
+ }
2883
+ }
2884
+ }
2885
+
2886
+ flushPendingDispatch(pendingDispatch);
2887
+ renderables.push(renderable);
2888
+ }
2889
+
2890
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w22 + w25: finalise the
2891
+ // pending TransparentEntry with the renderableIndex pointing at the
2892
+ // RenderableSnapshot we just pushed (when isRenderable === true).
2893
+ // The check below also covers a sprite entity that survives the
2894
+ // dangling-Instances branch (silent skip with `dispatchEntry !==
2895
+ // null` early-continue) — in that case renderableIndex is still set
2896
+ // to the just-pushed slot which is correct because materialDispatch
2897
+ // already captures the dispatch position.
2898
+ }
2899
+ }
2900
+
2901
+ // M3 / w26: sort dispatch entries by queue (ascending, stable sort)
2902
+ // per plan-strategy D-3.
2903
+ dispatch = sortDispatchByQueue(dispatch);
2904
+
2905
+ // D-1: collect PostProcessParams entities into Map<shaderId, Uint8Array>.
2906
+ // Last-one-wins when multiple entities bear the same shader id (mirrors
2907
+ // Camera.exposure -> CameraSnapshot pattern; extract stage only reads).
2908
+ const postProcessParams: Map<string, Uint8Array> = new Map();
2909
+ const postProcessParamsQuery = world.query({ with: [PostProcessParams] }).unwrap();
2910
+ for (const row of postProcessParamsQuery) {
2911
+ const entity = row.entity;
2912
+ const read = world.get(entity, PostProcessParams);
2913
+ if (!read.ok) continue;
2914
+ postProcessParams.set(read.value.shader, read.value.data);
2915
+ }
2916
+
2917
+ // feat-20260621 M-A3 / w13 (D-5): engine built-in tonemap data-driven
2918
+ // provider. The engine bridges the active camera's `Camera.exposure /
2919
+ // whitePoint / tonemap` onto the SAME unified params channel custom
2920
+ // post-processes use — `Camera.exposure` stays the AI-user-facing SSOT (D-5),
2921
+ // the engine itself acts as the provider for the Standard tonemap shader
2922
+ // id. The 16B layout keeps the prior recordTonemapPass packing
2923
+ // (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _,
2924
+ // ditherEnabled], with the mode u32 occupying the third 4-byte slot via
2925
+ // tonemapToU32 (SSOT in camera.ts). The final Output Transform makes a
2926
+ // per-pass copy and writes ditherEnabled at byte offset 12; the extracted
2927
+ // camera snapshot remains the SSOT. Run AFTER the user-entity collection above so the engine's
2928
+ // built-in provider is authoritative for its own reserved key (a user entity
2929
+ // can never shadow the Standard tonemap identity. The single active camera mirrors
2930
+ // recordFrame's `activeCameras[0]` selection.
2931
+ const tonemapCamera = cameras[0];
2932
+ if (tonemapCamera !== undefined) {
2933
+ postProcessParams.set(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, tonemapParams(tonemapCamera));
2934
+ }
2935
+
2936
+ return {
2937
+ cameras,
2938
+ auxiliaryCameras,
2939
+ cubeCameras,
2940
+ reflectionProbes,
2941
+ lights,
2942
+ environment,
2943
+ environmentReady: true,
2944
+ ...(volumetricFog === undefined ? {} : { volumetricFog }),
2945
+ renderables,
2946
+ dispatch,
2947
+ skylight,
2948
+ skylightCount,
2949
+ lightProbes,
2950
+ skybox,
2951
+ skyboxCount,
2952
+ fog,
2953
+ ...(fogFailure === undefined ? {} : { fogFailure }),
2954
+ frustumStats: { culled: frustumCulled, total: frustumTotal },
2955
+ visibilityStats: { explicitlyHidden: explicitlyHidden.size },
2956
+ postProcessParams,
2957
+ visibilitySnapshots: [context.visibility],
2958
+ featureVisibilitySnapshots: [{ world, snapshot: context.visibility }],
2959
+ hiddenEntityReports: [...explicitlyHidden].map((entity) => ({ world, entity })),
2960
+ };
2961
+ }
2962
+
2963
+ export function defaultMaterialSnapshot(materialHandle = 0): MaterialSnapshot {
2964
+ // Mid-grey unlit fallback (D-Q7 case B + extract-stage missing-spec),
2965
+ // matching the pre-w6 visual outcome where the record stage's force-cast
2966
+ // read of `firstMaterial.baseColorTexture` returned undefined and the
2967
+ // unlit fallback shader was selected.
2968
+ return {
2969
+ baseColor: vec3.create(0.5, 0.5, 0.5),
2970
+ metallic: 0,
2971
+ roughness: 1,
2972
+ materialHandle,
2973
+ };
2974
+ }
2975
+
2976
+ function identityProjection(): Float32Array {
2977
+ const projection = new Float32Array(16);
2978
+ projection[0] = 1;
2979
+ projection[5] = 1;
2980
+ projection[10] = 1;
2981
+ projection[15] = 1;
2982
+ return projection;
2983
+ }