@forgeax/engine-render 0.0.0-dev.8d955ade1c79

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 (891) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +647 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/authoring-path.unit.test.d.ts +2 -0
  5. package/dist/__tests__/authoring-path.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/backend-selection-rhi-capture.test-d.d.ts +2 -0
  7. package/dist/__tests__/backend-selection-rhi-capture.test-d.d.ts.map +1 -0
  8. package/dist/__tests__/callback-inference.test-d.d.ts +2 -0
  9. package/dist/__tests__/callback-inference.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/cluster-binner.test.d.ts +2 -0
  11. package/dist/__tests__/cluster-binner.test.d.ts.map +1 -0
  12. package/dist/__tests__/cluster-grid-default-surface.unit.test.d.ts +2 -0
  13. package/dist/__tests__/cluster-grid-default-surface.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/construct-renderer-identity.unit.test.d.ts +2 -0
  15. package/dist/__tests__/construct-renderer-identity.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/device-scope-lifecycle.unit.test.d.ts +2 -0
  17. package/dist/__tests__/device-scope-lifecycle.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/device-scope-stale-matrix.unit.test.d.ts +2 -0
  19. package/dist/__tests__/device-scope-stale-matrix.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/direct-extract-consumer-contract.test-d.d.ts +2 -0
  21. package/dist/__tests__/direct-extract-consumer-contract.test-d.d.ts.map +1 -0
  22. package/dist/__tests__/direct-light-snapshot.unit.test.d.ts +2 -0
  23. package/dist/__tests__/direct-light-snapshot.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/factory-contract.integration.test.d.ts +2 -0
  25. package/dist/__tests__/factory-contract.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/fallback-row-stride-surface.unit.test.d.ts +2 -0
  27. package/dist/__tests__/fallback-row-stride-surface.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/frame-observation.contract.test.d.ts +2 -0
  29. package/dist/__tests__/frame-observation.contract.test.d.ts.map +1 -0
  30. package/dist/__tests__/fullscreen-feature-plan.unit.test.d.ts +2 -0
  31. package/dist/__tests__/fullscreen-feature-plan.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/glyph-text-layout-recovery.unit.test.d.ts +2 -0
  33. package/dist/__tests__/glyph-text-layout-recovery.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/gpu-driven-indirect-raster-evidence.d.ts +6 -0
  35. package/dist/__tests__/gpu-driven-indirect-raster-evidence.d.ts.map +1 -0
  36. package/dist/__tests__/gpu-driven-indirect-raster.browser.test.d.ts +2 -0
  37. package/dist/__tests__/gpu-driven-indirect-raster.browser.test.d.ts.map +1 -0
  38. package/dist/__tests__/gpu-driven-indirect-raster.dawn.test.d.ts +2 -0
  39. package/dist/__tests__/gpu-driven-indirect-raster.dawn.test.d.ts.map +1 -0
  40. package/dist/__tests__/gpu-driven-production.integration.test.d.ts +2 -0
  41. package/dist/__tests__/gpu-driven-production.integration.test.d.ts.map +1 -0
  42. package/dist/__tests__/gpu-driven-scaling.unit.test.d.ts +2 -0
  43. package/dist/__tests__/gpu-driven-scaling.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/gpu-driven-view-gpu-evidence.d.ts +18 -0
  45. package/dist/__tests__/gpu-driven-view-gpu-evidence.d.ts.map +1 -0
  46. package/dist/__tests__/gpu-driven-view-graph.integration.test.d.ts +2 -0
  47. package/dist/__tests__/gpu-driven-view-graph.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/gpu-driven-view-reference.d.ts +15 -0
  49. package/dist/__tests__/gpu-driven-view-reference.d.ts.map +1 -0
  50. package/dist/__tests__/gpu-driven-view.browser.test.d.ts +2 -0
  51. package/dist/__tests__/gpu-driven-view.browser.test.d.ts.map +1 -0
  52. package/dist/__tests__/gpu-driven-view.dawn.test.d.ts +2 -0
  53. package/dist/__tests__/gpu-driven-view.dawn.test.d.ts.map +1 -0
  54. package/dist/__tests__/gpu-driven-view.unit.test.d.ts +2 -0
  55. package/dist/__tests__/gpu-driven-view.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/gpu-resource-store-stride.unit.test.d.ts +2 -0
  57. package/dist/__tests__/gpu-resource-store-stride.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/gpu-scene-render-graph-gpu.d.ts +7 -0
  59. package/dist/__tests__/gpu-scene-render-graph-gpu.d.ts.map +1 -0
  60. package/dist/__tests__/gpu-scene-render-graph.browser.test.d.ts +2 -0
  61. package/dist/__tests__/gpu-scene-render-graph.browser.test.d.ts.map +1 -0
  62. package/dist/__tests__/gpu-scene-render-graph.dawn.test.d.ts +2 -0
  63. package/dist/__tests__/gpu-scene-render-graph.dawn.test.d.ts.map +1 -0
  64. package/dist/__tests__/gpu-scene-schema.unit.test.d.ts +2 -0
  65. package/dist/__tests__/gpu-scene-schema.unit.test.d.ts.map +1 -0
  66. package/dist/__tests__/gpu-scene.dawn.test.d.ts +2 -0
  67. package/dist/__tests__/gpu-scene.dawn.test.d.ts.map +1 -0
  68. package/dist/__tests__/gpu-scene.unit.test.d.ts +2 -0
  69. package/dist/__tests__/gpu-scene.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/ibl/face-uniforms.unit.test.d.ts +2 -0
  71. package/dist/__tests__/ibl/face-uniforms.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/infinite-grid-no-vertex-probe.unit.test.d.ts +2 -0
  73. package/dist/__tests__/infinite-grid-no-vertex-probe.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/injection-kind-owner.test-d.d.ts +2 -0
  75. package/dist/__tests__/injection-kind-owner.test-d.d.ts.map +1 -0
  76. package/dist/__tests__/ktx2-basis-gpu-consumer.dawn.test.d.ts +2 -0
  77. package/dist/__tests__/ktx2-basis-gpu-consumer.dawn.test.d.ts.map +1 -0
  78. package/dist/__tests__/light-buffer-layout.unit.test.d.ts +2 -0
  79. package/dist/__tests__/light-buffer-layout.unit.test.d.ts.map +1 -0
  80. package/dist/__tests__/material-contract-inventory.unit.test.d.ts +2 -0
  81. package/dist/__tests__/material-contract-inventory.unit.test.d.ts.map +1 -0
  82. package/dist/__tests__/material-cooked-projection.unit.test.d.ts +2 -0
  83. package/dist/__tests__/material-cooked-projection.unit.test.d.ts.map +1 -0
  84. package/dist/__tests__/material-snapshot-mutation.integration.test.d.ts +2 -0
  85. package/dist/__tests__/material-snapshot-mutation.integration.test.d.ts.map +1 -0
  86. package/dist/__tests__/material-static-dynamic-route.unit.test.d.ts +2 -0
  87. package/dist/__tests__/material-static-dynamic-route.unit.test.d.ts.map +1 -0
  88. package/dist/__tests__/material-stride-surface.unit.test.d.ts +2 -0
  89. package/dist/__tests__/material-stride-surface.unit.test.d.ts.map +1 -0
  90. package/dist/__tests__/mesh-buffer-usage-surface.unit.test.d.ts +2 -0
  91. package/dist/__tests__/mesh-buffer-usage-surface.unit.test.d.ts.map +1 -0
  92. package/dist/__tests__/mesh-material-bindings.unit.test.d.ts +2 -0
  93. package/dist/__tests__/mesh-material-bindings.unit.test.d.ts.map +1 -0
  94. package/dist/__tests__/mesh-stride-surface.unit.test.d.ts +2 -0
  95. package/dist/__tests__/mesh-stride-surface.unit.test.d.ts.map +1 -0
  96. package/dist/__tests__/morph-culling-reentry.dawn.test.d.ts +2 -0
  97. package/dist/__tests__/morph-culling-reentry.dawn.test.d.ts.map +1 -0
  98. package/dist/__tests__/morph-feature-gpu-work.integration.test.d.ts +2 -0
  99. package/dist/__tests__/morph-feature-gpu-work.integration.test.d.ts.map +1 -0
  100. package/dist/__tests__/morph-feature-plan.unit.test.d.ts +2 -0
  101. package/dist/__tests__/morph-feature-plan.unit.test.d.ts.map +1 -0
  102. package/dist/__tests__/morph-visual-evidence.integration.test.d.ts +2 -0
  103. package/dist/__tests__/morph-visual-evidence.integration.test.d.ts.map +1 -0
  104. package/dist/__tests__/nineslice-handle-surface.unit.test.d.ts +2 -0
  105. package/dist/__tests__/nineslice-handle-surface.unit.test.d.ts.map +1 -0
  106. package/dist/__tests__/post-process-error-policy-owner.unit.test.d.ts +2 -0
  107. package/dist/__tests__/post-process-error-policy-owner.unit.test.d.ts.map +1 -0
  108. package/dist/__tests__/prepared-buffer-kind-owner.test-d.d.ts +2 -0
  109. package/dist/__tests__/prepared-buffer-kind-owner.test-d.d.ts.map +1 -0
  110. package/dist/__tests__/prepared-graphics-capability.unit.test.d.ts +2 -0
  111. package/dist/__tests__/prepared-graphics-capability.unit.test.d.ts.map +1 -0
  112. package/dist/__tests__/prepared-graphics-generation.unit.test.d.ts +2 -0
  113. package/dist/__tests__/prepared-graphics-generation.unit.test.d.ts.map +1 -0
  114. package/dist/__tests__/prepared-graphics-lifecycle.unit.test.d.ts +2 -0
  115. package/dist/__tests__/prepared-graphics-lifecycle.unit.test.d.ts.map +1 -0
  116. package/dist/__tests__/prepared-graphics-pipeline-warmup.unit.test.d.ts +2 -0
  117. package/dist/__tests__/prepared-graphics-pipeline-warmup.unit.test.d.ts.map +1 -0
  118. package/dist/__tests__/prepared-graphics-resolver.unit.test.d.ts +2 -0
  119. package/dist/__tests__/prepared-graphics-resolver.unit.test.d.ts.map +1 -0
  120. package/dist/__tests__/prepared-graphics-snapshot.unit.test.d.ts +2 -0
  121. package/dist/__tests__/prepared-graphics-snapshot.unit.test.d.ts.map +1 -0
  122. package/dist/__tests__/prepared-graphics-store.unit.test.d.ts +2 -0
  123. package/dist/__tests__/prepared-graphics-store.unit.test.d.ts.map +1 -0
  124. package/dist/__tests__/prepared-graphics-validation.unit.test.d.ts +2 -0
  125. package/dist/__tests__/prepared-graphics-validation.unit.test.d.ts.map +1 -0
  126. package/dist/__tests__/profiler-default-off.test.d.ts +2 -0
  127. package/dist/__tests__/profiler-default-off.test.d.ts.map +1 -0
  128. package/dist/__tests__/profiler-phase-catalog.test.d.ts +2 -0
  129. package/dist/__tests__/profiler-phase-catalog.test.d.ts.map +1 -0
  130. package/dist/__tests__/profiler-phase-integration.test.d.ts +2 -0
  131. package/dist/__tests__/profiler-phase-integration.test.d.ts.map +1 -0
  132. package/dist/__tests__/profiler-phase-token.test.d.ts +2 -0
  133. package/dist/__tests__/profiler-phase-token.test.d.ts.map +1 -0
  134. package/dist/__tests__/public-surface.test-d.d.ts +2 -0
  135. package/dist/__tests__/public-surface.test-d.d.ts.map +1 -0
  136. package/dist/__tests__/recover-error-policy-owner.unit.test.d.ts +2 -0
  137. package/dist/__tests__/recover-error-policy-owner.unit.test.d.ts.map +1 -0
  138. package/dist/__tests__/render-error-code-owner.test-d.d.ts +2 -0
  139. package/dist/__tests__/render-error-code-owner.test-d.d.ts.map +1 -0
  140. package/dist/__tests__/render-error-detail-ssot.test-d.d.ts +2 -0
  141. package/dist/__tests__/render-error-detail-ssot.test-d.d.ts.map +1 -0
  142. package/dist/__tests__/render-error-exhaustive.test-d.d.ts +2 -0
  143. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -0
  144. package/dist/__tests__/render-feature-assembly.test-d.d.ts +2 -0
  145. package/dist/__tests__/render-feature-assembly.test-d.d.ts.map +1 -0
  146. package/dist/__tests__/render-feature-context.test-d.d.ts +2 -0
  147. package/dist/__tests__/render-feature-context.test-d.d.ts.map +1 -0
  148. package/dist/__tests__/render-feature-contract.test-d.d.ts +2 -0
  149. package/dist/__tests__/render-feature-contract.test-d.d.ts.map +1 -0
  150. package/dist/__tests__/render-feature-diagnostics.unit.test.d.ts +2 -0
  151. package/dist/__tests__/render-feature-diagnostics.unit.test.d.ts.map +1 -0
  152. package/dist/__tests__/render-feature-docs.unit.test.d.ts +2 -0
  153. package/dist/__tests__/render-feature-docs.unit.test.d.ts.map +1 -0
  154. package/dist/__tests__/render-feature-errors.unit.test.d.ts +2 -0
  155. package/dist/__tests__/render-feature-errors.unit.test.d.ts.map +1 -0
  156. package/dist/__tests__/render-feature-gpu-work.integration.test.d.ts +2 -0
  157. package/dist/__tests__/render-feature-gpu-work.integration.test.d.ts.map +1 -0
  158. package/dist/__tests__/render-feature-plan.test-d.d.ts +2 -0
  159. package/dist/__tests__/render-feature-plan.test-d.d.ts.map +1 -0
  160. package/dist/__tests__/render-feature-plan.unit.test.d.ts +2 -0
  161. package/dist/__tests__/render-feature-plan.unit.test.d.ts.map +1 -0
  162. package/dist/__tests__/render-feature-prepared-diagnostics.unit.test.d.ts +2 -0
  163. package/dist/__tests__/render-feature-prepared-diagnostics.unit.test.d.ts.map +1 -0
  164. package/dist/__tests__/render-feature-prepared-errors.test-d.d.ts +2 -0
  165. package/dist/__tests__/render-feature-prepared-errors.test-d.d.ts.map +1 -0
  166. package/dist/__tests__/render-feature-prepared-graphics-docs.unit.test.d.ts +2 -0
  167. package/dist/__tests__/render-feature-prepared-graphics-docs.unit.test.d.ts.map +1 -0
  168. package/dist/__tests__/render-feature-prepared-graphics-isolation.unit.test.d.ts +2 -0
  169. package/dist/__tests__/render-feature-prepared-graphics-isolation.unit.test.d.ts.map +1 -0
  170. package/dist/__tests__/render-feature-prepared-graphics-negative.test-d.d.ts +2 -0
  171. package/dist/__tests__/render-feature-prepared-graphics-negative.test-d.d.ts.map +1 -0
  172. package/dist/__tests__/render-feature-prepared-graphics.test-d.d.ts +2 -0
  173. package/dist/__tests__/render-feature-prepared-graphics.test-d.d.ts.map +1 -0
  174. package/dist/__tests__/render-feature-prepared-graphics.unit.test.d.ts +2 -0
  175. package/dist/__tests__/render-feature-prepared-graphics.unit.test.d.ts.map +1 -0
  176. package/dist/__tests__/render-feature-public-surface.test-d.d.ts +2 -0
  177. package/dist/__tests__/render-feature-public-surface.test-d.d.ts.map +1 -0
  178. package/dist/__tests__/render-feature-retry.unit.test.d.ts +2 -0
  179. package/dist/__tests__/render-feature-retry.unit.test.d.ts.map +1 -0
  180. package/dist/__tests__/render-feature-scheduling.unit.test.d.ts +2 -0
  181. package/dist/__tests__/render-feature-scheduling.unit.test.d.ts.map +1 -0
  182. package/dist/__tests__/render-feature-stage-failed-policy-owner.unit.test.d.ts +2 -0
  183. package/dist/__tests__/render-feature-stage-failed-policy-owner.unit.test.d.ts.map +1 -0
  184. package/dist/__tests__/render-feature-stage-fault-recovery.unit.test.d.ts +2 -0
  185. package/dist/__tests__/render-feature-stage-fault-recovery.unit.test.d.ts.map +1 -0
  186. package/dist/__tests__/render-feature-visibility-context.test-d.d.ts +2 -0
  187. package/dist/__tests__/render-feature-visibility-context.test-d.d.ts.map +1 -0
  188. package/dist/__tests__/render-feature-visibility-context.unit.test.d.ts +2 -0
  189. package/dist/__tests__/render-feature-visibility-context.unit.test.d.ts.map +1 -0
  190. package/dist/__tests__/render-feature-zero-work.unit.test.d.ts +2 -0
  191. package/dist/__tests__/render-feature-zero-work.unit.test.d.ts.map +1 -0
  192. package/dist/__tests__/render-graph-compute-raster-gpu.d.ts +7 -0
  193. package/dist/__tests__/render-graph-compute-raster-gpu.d.ts.map +1 -0
  194. package/dist/__tests__/render-graph-compute-raster.browser.test.d.ts +2 -0
  195. package/dist/__tests__/render-graph-compute-raster.browser.test.d.ts.map +1 -0
  196. package/dist/__tests__/render-graph-compute-raster.dawn.test.d.ts +2 -0
  197. package/dist/__tests__/render-graph-compute-raster.dawn.test.d.ts.map +1 -0
  198. package/dist/__tests__/render-graph-hzb-gpu.d.ts +7 -0
  199. package/dist/__tests__/render-graph-hzb-gpu.d.ts.map +1 -0
  200. package/dist/__tests__/render-graph-hzb.browser.test.d.ts +2 -0
  201. package/dist/__tests__/render-graph-hzb.browser.test.d.ts.map +1 -0
  202. package/dist/__tests__/render-graph-hzb.dawn.test.d.ts +2 -0
  203. package/dist/__tests__/render-graph-hzb.dawn.test.d.ts.map +1 -0
  204. package/dist/__tests__/render-graph-primitives.unit.test.d.ts +2 -0
  205. package/dist/__tests__/render-graph-primitives.unit.test.d.ts.map +1 -0
  206. package/dist/__tests__/render-graph-storage-indirect-gpu.d.ts +12 -0
  207. package/dist/__tests__/render-graph-storage-indirect-gpu.d.ts.map +1 -0
  208. package/dist/__tests__/render-graph-storage-indirect.browser.test.d.ts +2 -0
  209. package/dist/__tests__/render-graph-storage-indirect.browser.test.d.ts.map +1 -0
  210. package/dist/__tests__/render-graph-storage-indirect.dawn.test.d.ts +2 -0
  211. package/dist/__tests__/render-graph-storage-indirect.dawn.test.d.ts.map +1 -0
  212. package/dist/__tests__/render-scene-projection.unit.test.d.ts +2 -0
  213. package/dist/__tests__/render-scene-projection.unit.test.d.ts.map +1 -0
  214. package/dist/__tests__/renderer-factory-material-contract.unit.test.d.ts +2 -0
  215. package/dist/__tests__/renderer-factory-material-contract.unit.test.d.ts.map +1 -0
  216. package/dist/__tests__/renderer-lifecycle.integration.test.d.ts +2 -0
  217. package/dist/__tests__/renderer-lifecycle.integration.test.d.ts.map +1 -0
  218. package/dist/__tests__/rhi-backend-package-boundary.unit.test.d.ts +2 -0
  219. package/dist/__tests__/rhi-backend-package-boundary.unit.test.d.ts.map +1 -0
  220. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  221. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  222. package/dist/__tests__/shader-stage-surface.unit.test.d.ts +2 -0
  223. package/dist/__tests__/shader-stage-surface.unit.test.d.ts.map +1 -0
  224. package/dist/__tests__/ssao-config.unit.test.d.ts +2 -0
  225. package/dist/__tests__/ssao-config.unit.test.d.ts.map +1 -0
  226. package/dist/__tests__/ssao-kernel-surface.unit.test.d.ts +2 -0
  227. package/dist/__tests__/ssao-kernel-surface.unit.test.d.ts.map +1 -0
  228. package/dist/__tests__/standard-clustered-pipeline.unit.test.d.ts +2 -0
  229. package/dist/__tests__/standard-clustered-pipeline.unit.test.d.ts.map +1 -0
  230. package/dist/__tests__/standard-descriptors.test-d.d.ts +2 -0
  231. package/dist/__tests__/standard-descriptors.test-d.d.ts.map +1 -0
  232. package/dist/__tests__/standard-pipeline.dawn.test.d.ts +2 -0
  233. package/dist/__tests__/standard-pipeline.dawn.test.d.ts.map +1 -0
  234. package/dist/__tests__/standard-pipeline.integration.test.d.ts +2 -0
  235. package/dist/__tests__/standard-pipeline.integration.test.d.ts.map +1 -0
  236. package/dist/__tests__/standard-profile-oracle.unit.test.d.ts +2 -0
  237. package/dist/__tests__/standard-profile-oracle.unit.test.d.ts.map +1 -0
  238. package/dist/__tests__/surface-configuration.unit.test.d.ts +2 -0
  239. package/dist/__tests__/surface-configuration.unit.test.d.ts.map +1 -0
  240. package/dist/__tests__/texture-usage-surface.unit.test.d.ts +2 -0
  241. package/dist/__tests__/texture-usage-surface.unit.test.d.ts.map +1 -0
  242. package/dist/__tests__/typed-pipeline-topology.unit.test.d.ts +2 -0
  243. package/dist/__tests__/typed-pipeline-topology.unit.test.d.ts.map +1 -0
  244. package/dist/__tests__/urp-pipeline-targets.unit.test.d.ts +2 -0
  245. package/dist/__tests__/urp-pipeline-targets.unit.test.d.ts.map +1 -0
  246. package/dist/__tests__/urp-pipeline.unit.test.d.ts +2 -0
  247. package/dist/__tests__/urp-pipeline.unit.test.d.ts.map +1 -0
  248. package/dist/__tests__/vertex-color-pipeline.unit.test.d.ts +2 -0
  249. package/dist/__tests__/vertex-color-pipeline.unit.test.d.ts.map +1 -0
  250. package/dist/__tests__/vertex-layout-consumer.unit.test.d.ts +2 -0
  251. package/dist/__tests__/vertex-layout-consumer.unit.test.d.ts.map +1 -0
  252. package/dist/__tests__/visibility-diagnostics.integration.test.d.ts +2 -0
  253. package/dist/__tests__/visibility-diagnostics.integration.test.d.ts.map +1 -0
  254. package/dist/__tests__/visibility-extract.integration.test.d.ts +2 -0
  255. package/dist/__tests__/visibility-extract.integration.test.d.ts.map +1 -0
  256. package/dist/__tests__/visibility-extract.perf.test.d.ts +2 -0
  257. package/dist/__tests__/visibility-extract.perf.test.d.ts.map +1 -0
  258. package/dist/__tests__/visibility-feature-dedup.integration.test.d.ts +2 -0
  259. package/dist/__tests__/visibility-feature-dedup.integration.test.d.ts.map +1 -0
  260. package/dist/__tests__/visibility-hidden-report.unit.test.d.ts +2 -0
  261. package/dist/__tests__/visibility-hidden-report.unit.test.d.ts.map +1 -0
  262. package/dist/__tests__/visibility-instances.integration.test.d.ts +2 -0
  263. package/dist/__tests__/visibility-instances.integration.test.d.ts.map +1 -0
  264. package/dist/__tests__/visibility-multi-world.integration.test.d.ts +2 -0
  265. package/dist/__tests__/visibility-multi-world.integration.test.d.ts.map +1 -0
  266. package/dist/__tests__/visibility-producer-matrix.integration.test.d.ts +2 -0
  267. package/dist/__tests__/visibility-producer-matrix.integration.test.d.ts.map +1 -0
  268. package/dist/__tests__/visibility-producer-matrix.unit.test.d.ts +2 -0
  269. package/dist/__tests__/visibility-producer-matrix.unit.test.d.ts.map +1 -0
  270. package/dist/__tests__/visibility-public-surface.test-d.d.ts +2 -0
  271. package/dist/__tests__/visibility-public-surface.test-d.d.ts.map +1 -0
  272. package/dist/__tests__/visibility-resolver.unit.test.d.ts +2 -0
  273. package/dist/__tests__/visibility-resolver.unit.test.d.ts.map +1 -0
  274. package/dist/__tests__/visibility-resource-short-circuit.unit.test.d.ts +2 -0
  275. package/dist/__tests__/visibility-resource-short-circuit.unit.test.d.ts.map +1 -0
  276. package/dist/__tests__/visibility-snapshot-contract.unit.test.d.ts +2 -0
  277. package/dist/__tests__/visibility-snapshot-contract.unit.test.d.ts.map +1 -0
  278. package/dist/__tests__/vocabulary.unit.test.d.ts +2 -0
  279. package/dist/__tests__/vocabulary.unit.test.d.ts.map +1 -0
  280. package/dist/assembly/backend-contract.d.ts +37 -0
  281. package/dist/assembly/backend-contract.d.ts.map +1 -0
  282. package/dist/assembly/factory.d.ts +207 -0
  283. package/dist/assembly/factory.d.ts.map +1 -0
  284. package/dist/assembly/host-contract.d.ts +84 -0
  285. package/dist/assembly/host-contract.d.ts.map +1 -0
  286. package/dist/assembly/material/assembly.d.ts +21 -0
  287. package/dist/assembly/material/assembly.d.ts.map +1 -0
  288. package/dist/assembly/material/pipeline-projection.d.ts +30 -0
  289. package/dist/assembly/material/pipeline-projection.d.ts.map +1 -0
  290. package/dist/assets/asset-decoders.d.ts +5 -0
  291. package/dist/assets/asset-decoders.d.ts.map +1 -0
  292. package/dist/assets/render-asset-port.d.ts +17 -0
  293. package/dist/assets/render-asset-port.d.ts.map +1 -0
  294. package/dist/assets/resolve-render-asset.d.ts +6 -0
  295. package/dist/assets/resolve-render-asset.d.ts.map +1 -0
  296. package/dist/authoring.d.ts +12 -0
  297. package/dist/authoring.d.ts.map +1 -0
  298. package/dist/authoring.mjs +27 -0
  299. package/dist/authoring.mjs.map +1 -0
  300. package/dist/chunk-CQXZC5XI.mjs +182 -0
  301. package/dist/chunk-CQXZC5XI.mjs.map +1 -0
  302. package/dist/chunk-HCOGPHKS.mjs +93 -0
  303. package/dist/chunk-HCOGPHKS.mjs.map +1 -0
  304. package/dist/chunk-YEJIUW7D.mjs +1152 -0
  305. package/dist/chunk-YEJIUW7D.mjs.map +1 -0
  306. package/dist/cluster-binner.d.ts +152 -0
  307. package/dist/cluster-binner.d.ts.map +1 -0
  308. package/dist/components/__tests__/points-lines-admission.unit.test.d.ts +2 -0
  309. package/dist/components/__tests__/points-lines-admission.unit.test.d.ts.map +1 -0
  310. package/dist/components/__tests__/points-lines-schema.unit.test.d.ts +2 -0
  311. package/dist/components/__tests__/points-lines-schema.unit.test.d.ts.map +1 -0
  312. package/dist/components/__tests__/schema-contract.unit.test.d.ts +2 -0
  313. package/dist/components/__tests__/schema-contract.unit.test.d.ts.map +1 -0
  314. package/dist/components/__tests__/schema-parity.unit.test.d.ts +2 -0
  315. package/dist/components/__tests__/schema-parity.unit.test.d.ts.map +1 -0
  316. package/dist/components/__tests__/visibility-schema.unit.test.d.ts +2 -0
  317. package/dist/components/__tests__/visibility-schema.unit.test.d.ts.map +1 -0
  318. package/dist/components/camera.d.ts +311 -0
  319. package/dist/components/camera.d.ts.map +1 -0
  320. package/dist/components/directional-light.d.ts +76 -0
  321. package/dist/components/directional-light.d.ts.map +1 -0
  322. package/dist/components/glyph-text.d.ts +26 -0
  323. package/dist/components/glyph-text.d.ts.map +1 -0
  324. package/dist/components/index.d.ts +24 -0
  325. package/dist/components/index.d.ts.map +1 -0
  326. package/dist/components/instances.d.ts +41 -0
  327. package/dist/components/instances.d.ts.map +1 -0
  328. package/dist/components/layer.d.ts +34 -0
  329. package/dist/components/layer.d.ts.map +1 -0
  330. package/dist/components/light-helpers.d.ts +53 -0
  331. package/dist/components/light-helpers.d.ts.map +1 -0
  332. package/dist/components/lines.d.ts +5 -0
  333. package/dist/components/lines.d.ts.map +1 -0
  334. package/dist/components/mesh-filter.d.ts +24 -0
  335. package/dist/components/mesh-filter.d.ts.map +1 -0
  336. package/dist/components/mesh-renderer.d.ts +33 -0
  337. package/dist/components/mesh-renderer.d.ts.map +1 -0
  338. package/dist/components/point-light-shadow.d.ts +27 -0
  339. package/dist/components/point-light-shadow.d.ts.map +1 -0
  340. package/dist/components/point-light.d.ts +35 -0
  341. package/dist/components/point-light.d.ts.map +1 -0
  342. package/dist/components/points.d.ts +15 -0
  343. package/dist/components/points.d.ts.map +1 -0
  344. package/dist/components/post-process-params.d.ts +5 -0
  345. package/dist/components/post-process-params.d.ts.map +1 -0
  346. package/dist/components/scene-instance.d.ts +140 -0
  347. package/dist/components/scene-instance.d.ts.map +1 -0
  348. package/dist/components/skybox-background.d.ts +56 -0
  349. package/dist/components/skybox-background.d.ts.map +1 -0
  350. package/dist/components/skylight.d.ts +55 -0
  351. package/dist/components/skylight.d.ts.map +1 -0
  352. package/dist/components/sort-key.d.ts +44 -0
  353. package/dist/components/sort-key.d.ts.map +1 -0
  354. package/dist/components/spot-light.d.ts +81 -0
  355. package/dist/components/spot-light.d.ts.map +1 -0
  356. package/dist/components/sprite-animation.d.ts +74 -0
  357. package/dist/components/sprite-animation.d.ts.map +1 -0
  358. package/dist/components/sprite-instances.d.ts +51 -0
  359. package/dist/components/sprite-instances.d.ts.map +1 -0
  360. package/dist/components/sprite-playback-mode.d.ts +42 -0
  361. package/dist/components/sprite-playback-mode.d.ts.map +1 -0
  362. package/dist/components/sprite-region-override.d.ts +46 -0
  363. package/dist/components/sprite-region-override.d.ts.map +1 -0
  364. package/dist/components/tile-layer.d.ts +106 -0
  365. package/dist/components/tile-layer.d.ts.map +1 -0
  366. package/dist/components/tilemap.d.ts +25 -0
  367. package/dist/components/tilemap.d.ts.map +1 -0
  368. package/dist/components/visibility.d.ts +22 -0
  369. package/dist/components/visibility.d.ts.map +1 -0
  370. package/dist/construct-renderer.d.ts +19 -0
  371. package/dist/construct-renderer.d.ts.map +1 -0
  372. package/dist/construct-renderer.mjs +27462 -0
  373. package/dist/construct-renderer.mjs.map +1 -0
  374. package/dist/debug-draw-glue.d.ts +5 -0
  375. package/dist/debug-draw-glue.d.ts.map +1 -0
  376. package/dist/device/device-scope.d.ts +83 -0
  377. package/dist/device/device-scope.d.ts.map +1 -0
  378. package/dist/device/gpu-residency.d.ts +337 -0
  379. package/dist/device/gpu-residency.d.ts.map +1 -0
  380. package/dist/device/resource-types.d.ts +8 -0
  381. package/dist/device/resource-types.d.ts.map +1 -0
  382. package/dist/engine-metrics.d.ts +23 -0
  383. package/dist/engine-metrics.d.ts.map +1 -0
  384. package/dist/errors/ecs-validation.d.ts +3 -0
  385. package/dist/errors/ecs-validation.d.ts.map +1 -0
  386. package/dist/errors/index.d.ts +4 -0
  387. package/dist/errors/index.d.ts.map +1 -0
  388. package/dist/errors/recover.d.ts +105 -0
  389. package/dist/errors/recover.d.ts.map +1 -0
  390. package/dist/errors/render.d.ts +705 -0
  391. package/dist/errors/render.d.ts.map +1 -0
  392. package/dist/extract/visibility.d.ts +26 -0
  393. package/dist/extract/visibility.d.ts.map +1 -0
  394. package/dist/features/fullscreen.d.ts +19 -0
  395. package/dist/features/fullscreen.d.ts.map +1 -0
  396. package/dist/features/host.d.ts +106 -0
  397. package/dist/features/host.d.ts.map +1 -0
  398. package/dist/features/morph/morph-feature.d.ts +79 -0
  399. package/dist/features/morph/morph-feature.d.ts.map +1 -0
  400. package/dist/features/plan.d.ts +182 -0
  401. package/dist/features/plan.d.ts.map +1 -0
  402. package/dist/features/prepared-gpu-work.d.ts +117 -0
  403. package/dist/features/prepared-gpu-work.d.ts.map +1 -0
  404. package/dist/features/prepared-graphics-store.d.ts +66 -0
  405. package/dist/features/prepared-graphics-store.d.ts.map +1 -0
  406. package/dist/features/prepared-graphics.d.ts +215 -0
  407. package/dist/features/prepared-graphics.d.ts.map +1 -0
  408. package/dist/features/render-graph-compute.d.ts +13 -0
  409. package/dist/features/render-graph-compute.d.ts.map +1 -0
  410. package/dist/features/render-graph-contribution.d.ts +12 -0
  411. package/dist/features/render-graph-contribution.d.ts.map +1 -0
  412. package/dist/features/render-graph-raster.d.ts +38 -0
  413. package/dist/features/render-graph-raster.d.ts.map +1 -0
  414. package/dist/features/render-graph-resources.d.ts +11 -0
  415. package/dist/features/render-graph-resources.d.ts.map +1 -0
  416. package/dist/features/targets.d.ts +32 -0
  417. package/dist/features/targets.d.ts.map +1 -0
  418. package/dist/features/types.d.ts +68 -0
  419. package/dist/features/types.d.ts.map +1 -0
  420. package/dist/features/vocabulary.d.ts +14 -0
  421. package/dist/features/vocabulary.d.ts.map +1 -0
  422. package/dist/fullscreen-post-process-pass.d.ts +157 -0
  423. package/dist/fullscreen-post-process-pass.d.ts.map +1 -0
  424. package/dist/glyph-text-layout-system.d.ts +19 -0
  425. package/dist/glyph-text-layout-system.d.ts.map +1 -0
  426. package/dist/gpu-driven/batch-topology.d.ts +55 -0
  427. package/dist/gpu-driven/batch-topology.d.ts.map +1 -0
  428. package/dist/gpu-driven/production-raster.d.ts +51 -0
  429. package/dist/gpu-driven/production-raster.d.ts.map +1 -0
  430. package/dist/gpu-driven/view-gpu.d.ts +85 -0
  431. package/dist/gpu-driven/view-gpu.d.ts.map +1 -0
  432. package/dist/gpu-resource.d.ts +73 -0
  433. package/dist/gpu-resource.d.ts.map +1 -0
  434. package/dist/gpu-scene-schema.d.ts +78 -0
  435. package/dist/gpu-scene-schema.d.ts.map +1 -0
  436. package/dist/gpu-scene.d.ts +55 -0
  437. package/dist/gpu-scene.d.ts.map +1 -0
  438. package/dist/gpu-stage.d.ts +4 -0
  439. package/dist/gpu-stage.d.ts.map +1 -0
  440. package/dist/gpu-texture-usage.d.ts +6 -0
  441. package/dist/gpu-texture-usage.d.ts.map +1 -0
  442. package/dist/gpu-usage.d.ts +9 -0
  443. package/dist/gpu-usage.d.ts.map +1 -0
  444. package/dist/hdrp-buffers.d.ts +163 -0
  445. package/dist/hdrp-buffers.d.ts.map +1 -0
  446. package/dist/ibl/IblPipelineCache.d.ts +168 -0
  447. package/dist/ibl/IblPipelineCache.d.ts.map +1 -0
  448. package/dist/ibl/skylight-bind-group.d.ts +130 -0
  449. package/dist/ibl/skylight-bind-group.d.ts.map +1 -0
  450. package/dist/index.d.ts +31 -0
  451. package/dist/index.d.ts.map +1 -0
  452. package/dist/index.mjs +169 -0
  453. package/dist/index.mjs.map +1 -0
  454. package/dist/inspection-types.d.ts +72 -0
  455. package/dist/inspection-types.d.ts.map +1 -0
  456. package/dist/instance-buffer-cache.d.ts +56 -0
  457. package/dist/instance-buffer-cache.d.ts.map +1 -0
  458. package/dist/internal.d.ts +2 -0
  459. package/dist/internal.d.ts.map +1 -0
  460. package/dist/internal.mjs +3 -0
  461. package/dist/internal.mjs.map +1 -0
  462. package/dist/lifecycle.d.ts +62 -0
  463. package/dist/lifecycle.d.ts.map +1 -0
  464. package/dist/light-buffer-layout.d.ts +164 -0
  465. package/dist/light-buffer-layout.d.ts.map +1 -0
  466. package/dist/materials.d.ts +47 -0
  467. package/dist/materials.d.ts.map +1 -0
  468. package/dist/mesh-material-bindings.d.ts +43 -0
  469. package/dist/mesh-material-bindings.d.ts.map +1 -0
  470. package/dist/pbr-pipeline.d.ts +301 -0
  471. package/dist/pbr-pipeline.d.ts.map +1 -0
  472. package/dist/pipeline/standard-forward-lane.d.ts +5 -0
  473. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -0
  474. package/dist/pipeline/standard-pipeline.d.ts +31 -0
  475. package/dist/pipeline/standard-pipeline.d.ts.map +1 -0
  476. package/dist/pipeline/standard-profile.d.ts +40 -0
  477. package/dist/pipeline/standard-profile.d.ts.map +1 -0
  478. package/dist/pipeline-builder.d.ts +122 -0
  479. package/dist/pipeline-builder.d.ts.map +1 -0
  480. package/dist/pipeline-spec-types.d.ts +24 -0
  481. package/dist/pipeline-spec-types.d.ts.map +1 -0
  482. package/dist/pipeline-spec.d.ts +367 -0
  483. package/dist/pipeline-spec.d.ts.map +1 -0
  484. package/dist/plugin.d.ts +4 -0
  485. package/dist/plugin.d.ts.map +1 -0
  486. package/dist/points-lines/__tests__/bounds.unit.test.d.ts +2 -0
  487. package/dist/points-lines/__tests__/bounds.unit.test.d.ts.map +1 -0
  488. package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts +2 -0
  489. package/dist/points-lines/__tests__/cache-recovery.unit.test.d.ts.map +1 -0
  490. package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts +2 -0
  491. package/dist/points-lines/__tests__/inspection-lanes.integration.test.d.ts.map +1 -0
  492. package/dist/points-lines/__tests__/inspection.unit.test.d.ts +2 -0
  493. package/dist/points-lines/__tests__/inspection.unit.test.d.ts.map +1 -0
  494. package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts +2 -0
  495. package/dist/points-lines/__tests__/lane-structural.integration.test.d.ts.map +1 -0
  496. package/dist/points-lines/__tests__/points-lines.bench.d.ts +2 -0
  497. package/dist/points-lines/__tests__/points-lines.bench.d.ts.map +1 -0
  498. package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts +2 -0
  499. package/dist/points-lines/__tests__/raster-lane.integration.test.d.ts.map +1 -0
  500. package/dist/points-lines/__tests__/snapshot.unit.test.d.ts +2 -0
  501. package/dist/points-lines/__tests__/snapshot.unit.test.d.ts.map +1 -0
  502. package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts +2 -0
  503. package/dist/points-lines/__tests__/view-ubo.unit.test.d.ts.map +1 -0
  504. package/dist/points-lines/admission.d.ts +36 -0
  505. package/dist/points-lines/admission.d.ts.map +1 -0
  506. package/dist/points-lines/bounds.d.ts +8 -0
  507. package/dist/points-lines/bounds.d.ts.map +1 -0
  508. package/dist/points-lines/expansion-cache.d.ts +28 -0
  509. package/dist/points-lines/expansion-cache.d.ts.map +1 -0
  510. package/dist/points-lines/inspection.d.ts +53 -0
  511. package/dist/points-lines/inspection.d.ts.map +1 -0
  512. package/dist/points-lines/prepare.d.ts +67 -0
  513. package/dist/points-lines/prepare.d.ts.map +1 -0
  514. package/dist/points-lines/record.d.ts +60 -0
  515. package/dist/points-lines/record.d.ts.map +1 -0
  516. package/dist/points-lines/snapshot.d.ts +49 -0
  517. package/dist/points-lines/snapshot.d.ts.map +1 -0
  518. package/dist/post-process-errors.d.ts +182 -0
  519. package/dist/post-process-errors.d.ts.map +1 -0
  520. package/dist/prepare/prepared-graphics-resolver.d.ts +59 -0
  521. package/dist/prepare/prepared-graphics-resolver.d.ts.map +1 -0
  522. package/dist/record/__tests__/material-bg-cache.unit.test.d.ts +2 -0
  523. package/dist/record/__tests__/material-bg-cache.unit.test.d.ts.map +1 -0
  524. package/dist/record/__tests__/material-slot-plan.unit.test.d.ts +2 -0
  525. package/dist/record/__tests__/material-slot-plan.unit.test.d.ts.map +1 -0
  526. package/dist/record/__tests__/typed-frame-graph-plan.unit.test.d.ts +2 -0
  527. package/dist/record/__tests__/typed-frame-graph-plan.unit.test.d.ts.map +1 -0
  528. package/dist/record/__tests__/video-texture-view.unit.test.d.ts +2 -0
  529. package/dist/record/__tests__/video-texture-view.unit.test.d.ts.map +1 -0
  530. package/dist/record/frame-lighting.d.ts +121 -0
  531. package/dist/record/frame-lighting.d.ts.map +1 -0
  532. package/dist/record/frame-snapshot.d.ts +429 -0
  533. package/dist/record/frame-snapshot.d.ts.map +1 -0
  534. package/dist/record/frame-targets.d.ts +20 -0
  535. package/dist/record/frame-targets.d.ts.map +1 -0
  536. package/dist/record/frame.d.ts +71 -0
  537. package/dist/record/frame.d.ts.map +1 -0
  538. package/dist/record/helpers.d.ts +102 -0
  539. package/dist/record/helpers.d.ts.map +1 -0
  540. package/dist/record/main-pass-geometry.d.ts +28 -0
  541. package/dist/record/main-pass-geometry.d.ts.map +1 -0
  542. package/dist/record/main-pass-material.d.ts +204 -0
  543. package/dist/record/main-pass-material.d.ts.map +1 -0
  544. package/dist/record/main-pass-sprite-draws.d.ts +43 -0
  545. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -0
  546. package/dist/record/main-pass.d.ts +20 -0
  547. package/dist/record/main-pass.d.ts.map +1 -0
  548. package/dist/record/mesh-ssbo.d.ts +446 -0
  549. package/dist/record/mesh-ssbo.d.ts.map +1 -0
  550. package/dist/record/render-context.d.ts +252 -0
  551. package/dist/record/render-context.d.ts.map +1 -0
  552. package/dist/record/shadow-pass.d.ts +52 -0
  553. package/dist/record/shadow-pass.d.ts.map +1 -0
  554. package/dist/record/skybox-post-pass.d.ts +33 -0
  555. package/dist/record/skybox-post-pass.d.ts.map +1 -0
  556. package/dist/record/typed-frame-graph.d.ts +35 -0
  557. package/dist/record/typed-frame-graph.d.ts.map +1 -0
  558. package/dist/record/view-ubo.d.ts +45 -0
  559. package/dist/record/view-ubo.d.ts.map +1 -0
  560. package/dist/render-contract.d.ts +424 -0
  561. package/dist/render-contract.d.ts.map +1 -0
  562. package/dist/render-data.d.ts +143 -0
  563. package/dist/render-data.d.ts.map +1 -0
  564. package/dist/render-graph-primitives.d.ts +76 -0
  565. package/dist/render-graph-primitives.d.ts.map +1 -0
  566. package/dist/render-pipeline.d.ts +110 -0
  567. package/dist/render-pipeline.d.ts.map +1 -0
  568. package/dist/render-system-extract.d.ts +868 -0
  569. package/dist/render-system-extract.d.ts.map +1 -0
  570. package/dist/render-system.d.ts +125 -0
  571. package/dist/render-system.d.ts.map +1 -0
  572. package/dist/scene/render-scene-types.d.ts +61 -0
  573. package/dist/scene/render-scene-types.d.ts.map +1 -0
  574. package/dist/scene/render-scene.d.ts +112 -0
  575. package/dist/scene/render-scene.d.ts.map +1 -0
  576. package/dist/scene-instances/post-spawn-resolve-joints.d.ts +30 -0
  577. package/dist/scene-instances/post-spawn-resolve-joints.d.ts.map +1 -0
  578. package/dist/shadow-atlas.d.ts +117 -0
  579. package/dist/shadow-atlas.d.ts.map +1 -0
  580. package/dist/ssao-buffers.d.ts +50 -0
  581. package/dist/ssao-buffers.d.ts.map +1 -0
  582. package/dist/ssao-config.d.ts +28 -0
  583. package/dist/ssao-config.d.ts.map +1 -0
  584. package/dist/systems/active-camera.d.ts +53 -0
  585. package/dist/systems/active-camera.d.ts.map +1 -0
  586. package/dist/systems/pass-selector.d.ts +24 -0
  587. package/dist/systems/pass-selector.d.ts.map +1 -0
  588. package/dist/systems/skin-palette-allocator.d.ts +49 -0
  589. package/dist/systems/skin-palette-allocator.d.ts.map +1 -0
  590. package/dist/systems/skin-palette-types.d.ts +14 -0
  591. package/dist/systems/skin-palette-types.d.ts.map +1 -0
  592. package/dist/systems/transparent-sort-config.d.ts +112 -0
  593. package/dist/systems/transparent-sort-config.d.ts.map +1 -0
  594. package/dist/tilemap-chunk-extract-system.d.ts +143 -0
  595. package/dist/tilemap-chunk-extract-system.d.ts.map +1 -0
  596. package/dist/typed-render-graph-primitives.d.ts +57 -0
  597. package/dist/typed-render-graph-primitives.d.ts.map +1 -0
  598. package/dist/typed-shadow-passes.d.ts +11 -0
  599. package/dist/typed-shadow-passes.d.ts.map +1 -0
  600. package/package.json +94 -0
  601. package/src/__tests__/authoring-path.unit.test.ts +23 -0
  602. package/src/__tests__/backend-selection-rhi-capture.test-d.ts +21 -0
  603. package/src/__tests__/callback-inference.test-d.ts +8 -0
  604. package/src/__tests__/cluster-binner.test.ts +272 -0
  605. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +20 -0
  606. package/src/__tests__/construct-renderer-identity.unit.test.ts +23 -0
  607. package/src/__tests__/device-scope-lifecycle.unit.test.ts +151 -0
  608. package/src/__tests__/device-scope-stale-matrix.unit.test.ts +42 -0
  609. package/src/__tests__/direct-extract-consumer-contract.test-d.ts +23 -0
  610. package/src/__tests__/direct-light-snapshot.unit.test.ts +61 -0
  611. package/src/__tests__/factory-contract.integration.test.ts +280 -0
  612. package/src/__tests__/fallback-row-stride-surface.unit.test.ts +15 -0
  613. package/src/__tests__/frame-observation.contract.test.ts +112 -0
  614. package/src/__tests__/fullscreen-feature-plan.unit.test.ts +32 -0
  615. package/src/__tests__/glyph-text-layout-recovery.unit.test.ts +167 -0
  616. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +350 -0
  617. package/src/__tests__/gpu-driven-indirect-raster.browser.test.ts +12 -0
  618. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +16 -0
  619. package/src/__tests__/gpu-driven-production.integration.test.ts +436 -0
  620. package/src/__tests__/gpu-driven-scaling.unit.test.ts +87 -0
  621. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +271 -0
  622. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +290 -0
  623. package/src/__tests__/gpu-driven-view-reference.ts +111 -0
  624. package/src/__tests__/gpu-driven-view.browser.test.ts +30 -0
  625. package/src/__tests__/gpu-driven-view.dawn.test.ts +28 -0
  626. package/src/__tests__/gpu-driven-view.unit.test.ts +199 -0
  627. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +59 -0
  628. package/src/__tests__/gpu-scene-render-graph-gpu.ts +308 -0
  629. package/src/__tests__/gpu-scene-render-graph.browser.test.ts +19 -0
  630. package/src/__tests__/gpu-scene-render-graph.dawn.test.ts +19 -0
  631. package/src/__tests__/gpu-scene-schema.unit.test.ts +16 -0
  632. package/src/__tests__/gpu-scene.dawn.test.ts +205 -0
  633. package/src/__tests__/gpu-scene.unit.test.ts +147 -0
  634. package/src/__tests__/ibl/face-uniforms.unit.test.ts +46 -0
  635. package/src/__tests__/infinite-grid-no-vertex-probe.unit.test.ts +92 -0
  636. package/src/__tests__/injection-kind-owner.test-d.ts +14 -0
  637. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +728 -0
  638. package/src/__tests__/light-buffer-layout.unit.test.ts +40 -0
  639. package/src/__tests__/material-contract-inventory.unit.test.ts +24 -0
  640. package/src/__tests__/material-cooked-projection.unit.test.ts +150 -0
  641. package/src/__tests__/material-snapshot-mutation.integration.test.ts +53 -0
  642. package/src/__tests__/material-static-dynamic-route.unit.test.ts +94 -0
  643. package/src/__tests__/material-stride-surface.unit.test.ts +32 -0
  644. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +106 -0
  645. package/src/__tests__/mesh-material-bindings.unit.test.ts +89 -0
  646. package/src/__tests__/mesh-stride-surface.unit.test.ts +16 -0
  647. package/src/__tests__/morph-culling-reentry.dawn.test.ts +157 -0
  648. package/src/__tests__/morph-feature-gpu-work.integration.test.ts +80 -0
  649. package/src/__tests__/morph-feature-plan.unit.test.ts +53 -0
  650. package/src/__tests__/morph-visual-evidence.integration.test.ts +80 -0
  651. package/src/__tests__/nineslice-handle-surface.unit.test.ts +19 -0
  652. package/src/__tests__/post-process-error-policy-owner.unit.test.ts +201 -0
  653. package/src/__tests__/prepared-buffer-kind-owner.test-d.ts +29 -0
  654. package/src/__tests__/prepared-graphics-capability.unit.test.ts +65 -0
  655. package/src/__tests__/prepared-graphics-generation.unit.test.ts +161 -0
  656. package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +138 -0
  657. package/src/__tests__/prepared-graphics-pipeline-warmup.unit.test.ts +29 -0
  658. package/src/__tests__/prepared-graphics-resolver.unit.test.ts +285 -0
  659. package/src/__tests__/prepared-graphics-snapshot.unit.test.ts +105 -0
  660. package/src/__tests__/prepared-graphics-store.unit.test.ts +154 -0
  661. package/src/__tests__/prepared-graphics-validation.unit.test.ts +176 -0
  662. package/src/__tests__/profiler-default-off.test.ts +22 -0
  663. package/src/__tests__/profiler-phase-catalog.test.ts +66 -0
  664. package/src/__tests__/profiler-phase-integration.test.ts +62 -0
  665. package/src/__tests__/profiler-phase-token.test.ts +79 -0
  666. package/src/__tests__/public-surface.test-d.ts +94 -0
  667. package/src/__tests__/recover-error-policy-owner.unit.test.ts +107 -0
  668. package/src/__tests__/render-error-code-owner.test-d.ts +116 -0
  669. package/src/__tests__/render-error-detail-ssot.test-d.ts +139 -0
  670. package/src/__tests__/render-error-exhaustive.test-d.ts +110 -0
  671. package/src/__tests__/render-feature-assembly.test-d.ts +43 -0
  672. package/src/__tests__/render-feature-context.test-d.ts +57 -0
  673. package/src/__tests__/render-feature-contract.test-d.ts +49 -0
  674. package/src/__tests__/render-feature-diagnostics.unit.test.ts +149 -0
  675. package/src/__tests__/render-feature-docs.unit.test.ts +89 -0
  676. package/src/__tests__/render-feature-errors.unit.test.ts +47 -0
  677. package/src/__tests__/render-feature-gpu-work.integration.test.ts +316 -0
  678. package/src/__tests__/render-feature-plan.test-d.ts +161 -0
  679. package/src/__tests__/render-feature-plan.unit.test.ts +170 -0
  680. package/src/__tests__/render-feature-prepared-diagnostics.unit.test.ts +90 -0
  681. package/src/__tests__/render-feature-prepared-errors.test-d.ts +79 -0
  682. package/src/__tests__/render-feature-prepared-graphics-docs.unit.test.ts +80 -0
  683. package/src/__tests__/render-feature-prepared-graphics-isolation.unit.test.ts +72 -0
  684. package/src/__tests__/render-feature-prepared-graphics-negative.test-d.ts +36 -0
  685. package/src/__tests__/render-feature-prepared-graphics.test-d.ts +74 -0
  686. package/src/__tests__/render-feature-prepared-graphics.unit.test.ts +121 -0
  687. package/src/__tests__/render-feature-public-surface.test-d.ts +57 -0
  688. package/src/__tests__/render-feature-retry.unit.test.ts +87 -0
  689. package/src/__tests__/render-feature-scheduling.unit.test.ts +110 -0
  690. package/src/__tests__/render-feature-stage-failed-policy-owner.unit.test.ts +137 -0
  691. package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +146 -0
  692. package/src/__tests__/render-feature-visibility-context.test-d.ts +33 -0
  693. package/src/__tests__/render-feature-visibility-context.unit.test.ts +37 -0
  694. package/src/__tests__/render-feature-zero-work.unit.test.ts +76 -0
  695. package/src/__tests__/render-graph-compute-raster-gpu.ts +342 -0
  696. package/src/__tests__/render-graph-compute-raster.browser.test.ts +16 -0
  697. package/src/__tests__/render-graph-compute-raster.dawn.test.ts +22 -0
  698. package/src/__tests__/render-graph-hzb-gpu.ts +239 -0
  699. package/src/__tests__/render-graph-hzb.browser.test.ts +16 -0
  700. package/src/__tests__/render-graph-hzb.dawn.test.ts +23 -0
  701. package/src/__tests__/render-graph-primitives.unit.test.ts +22 -0
  702. package/src/__tests__/render-graph-storage-indirect-gpu.ts +162 -0
  703. package/src/__tests__/render-graph-storage-indirect.browser.test.ts +21 -0
  704. package/src/__tests__/render-graph-storage-indirect.dawn.test.ts +28 -0
  705. package/src/__tests__/render-scene-projection.unit.test.ts +168 -0
  706. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +121 -0
  707. package/src/__tests__/renderer-lifecycle.integration.test.ts +58 -0
  708. package/src/__tests__/rhi-backend-package-boundary.unit.test.ts +11 -0
  709. package/src/__tests__/scriptable-pack-consumer.test-d.ts +14 -0
  710. package/src/__tests__/shader-stage-surface.unit.test.ts +43 -0
  711. package/src/__tests__/ssao-config.unit.test.ts +36 -0
  712. package/src/__tests__/ssao-kernel-surface.unit.test.ts +14 -0
  713. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +28 -0
  714. package/src/__tests__/standard-descriptors.test-d.ts +22 -0
  715. package/src/__tests__/standard-pipeline.dawn.test.ts +61 -0
  716. package/src/__tests__/standard-pipeline.integration.test.ts +124 -0
  717. package/src/__tests__/standard-profile-oracle.unit.test.ts +62 -0
  718. package/src/__tests__/surface-configuration.unit.test.ts +91 -0
  719. package/src/__tests__/texture-usage-surface.unit.test.ts +58 -0
  720. package/src/__tests__/typed-pipeline-topology.unit.test.ts +223 -0
  721. package/src/__tests__/urp-pipeline-targets.unit.test.ts +42 -0
  722. package/src/__tests__/urp-pipeline.unit.test.ts +14 -0
  723. package/src/__tests__/vertex-color-pipeline.unit.test.ts +132 -0
  724. package/src/__tests__/vertex-layout-consumer.unit.test.ts +25 -0
  725. package/src/__tests__/visibility-diagnostics.integration.test.ts +62 -0
  726. package/src/__tests__/visibility-extract.integration.test.ts +98 -0
  727. package/src/__tests__/visibility-extract.perf.test.ts +130 -0
  728. package/src/__tests__/visibility-feature-dedup.integration.test.ts +55 -0
  729. package/src/__tests__/visibility-hidden-report.unit.test.ts +36 -0
  730. package/src/__tests__/visibility-instances.integration.test.ts +46 -0
  731. package/src/__tests__/visibility-multi-world.integration.test.ts +86 -0
  732. package/src/__tests__/visibility-producer-matrix.integration.test.ts +168 -0
  733. package/src/__tests__/visibility-producer-matrix.unit.test.ts +96 -0
  734. package/src/__tests__/visibility-public-surface.test-d.ts +27 -0
  735. package/src/__tests__/visibility-resolver.unit.test.ts +85 -0
  736. package/src/__tests__/visibility-resource-short-circuit.unit.test.ts +80 -0
  737. package/src/__tests__/visibility-snapshot-contract.unit.test.ts +60 -0
  738. package/src/__tests__/vocabulary.unit.test.ts +21 -0
  739. package/src/assembly/backend-contract.ts +57 -0
  740. package/src/assembly/factory.ts +8130 -0
  741. package/src/assembly/host-contract.ts +101 -0
  742. package/src/assembly/material/assembly.ts +48 -0
  743. package/src/assembly/material/pipeline-projection.ts +63 -0
  744. package/src/assets/asset-decoders.ts +158 -0
  745. package/src/assets/render-asset-port.ts +44 -0
  746. package/src/assets/resolve-render-asset.ts +13 -0
  747. package/src/authoring.ts +17 -0
  748. package/src/cluster-binner.ts +700 -0
  749. package/src/components/__tests__/points-lines-admission.unit.test.ts +219 -0
  750. package/src/components/__tests__/points-lines-schema.unit.test.ts +56 -0
  751. package/src/components/__tests__/schema-contract.unit.test.ts +10 -0
  752. package/src/components/__tests__/schema-parity.unit.test.ts +25 -0
  753. package/src/components/__tests__/visibility-schema.unit.test.ts +35 -0
  754. package/src/components/camera.ts +512 -0
  755. package/src/components/directional-light.ts +120 -0
  756. package/src/components/glyph-text.ts +64 -0
  757. package/src/components/index.ts +54 -0
  758. package/src/components/instances.ts +206 -0
  759. package/src/components/layer.ts +69 -0
  760. package/src/components/light-helpers.ts +188 -0
  761. package/src/components/lines.ts +18 -0
  762. package/src/components/mesh-filter.ts +60 -0
  763. package/src/components/mesh-renderer.ts +61 -0
  764. package/src/components/point-light-shadow.ts +43 -0
  765. package/src/components/point-light.ts +65 -0
  766. package/src/components/points.ts +40 -0
  767. package/src/components/post-process-params.ts +35 -0
  768. package/src/components/scene-instance.ts +193 -0
  769. package/src/components/skybox-background.ts +103 -0
  770. package/src/components/skylight.ts +97 -0
  771. package/src/components/sort-key.ts +75 -0
  772. package/src/components/spot-light.ts +124 -0
  773. package/src/components/sprite-animation.ts +168 -0
  774. package/src/components/sprite-instances.ts +179 -0
  775. package/src/components/sprite-playback-mode.ts +79 -0
  776. package/src/components/sprite-region-override.ts +105 -0
  777. package/src/components/tile-layer.ts +167 -0
  778. package/src/components/tilemap.ts +48 -0
  779. package/src/components/visibility.ts +53 -0
  780. package/src/construct-renderer.ts +67 -0
  781. package/src/debug-draw-glue.ts +48 -0
  782. package/src/device/device-scope.ts +306 -0
  783. package/src/device/gpu-residency.ts +1822 -0
  784. package/src/device/resource-types.ts +20 -0
  785. package/src/engine-metrics.ts +82 -0
  786. package/src/errors/ecs-validation.ts +16 -0
  787. package/src/errors/index.ts +3 -0
  788. package/src/errors/recover.ts +161 -0
  789. package/src/errors/render.ts +1284 -0
  790. package/src/extract/visibility.ts +120 -0
  791. package/src/features/fullscreen.ts +46 -0
  792. package/src/features/host.ts +1363 -0
  793. package/src/features/morph/morph-feature.ts +377 -0
  794. package/src/features/plan.ts +447 -0
  795. package/src/features/prepared-gpu-work.ts +959 -0
  796. package/src/features/prepared-graphics-store.ts +449 -0
  797. package/src/features/prepared-graphics.ts +420 -0
  798. package/src/features/render-graph-compute.ts +69 -0
  799. package/src/features/render-graph-contribution.ts +75 -0
  800. package/src/features/render-graph-raster.ts +332 -0
  801. package/src/features/render-graph-resources.ts +38 -0
  802. package/src/features/targets.ts +70 -0
  803. package/src/features/types.ts +103 -0
  804. package/src/features/vocabulary.ts +24 -0
  805. package/src/fullscreen-post-process-pass.ts +363 -0
  806. package/src/glyph-text-layout-system.ts +421 -0
  807. package/src/gpu-driven/batch-topology.ts +245 -0
  808. package/src/gpu-driven/production-raster.ts +695 -0
  809. package/src/gpu-driven/view-gpu.ts +703 -0
  810. package/src/gpu-resource.ts +159 -0
  811. package/src/gpu-scene-schema.ts +136 -0
  812. package/src/gpu-scene.ts +722 -0
  813. package/src/gpu-stage.ts +9 -0
  814. package/src/gpu-texture-usage.ts +12 -0
  815. package/src/gpu-usage.ts +14 -0
  816. package/src/hdrp-buffers.ts +480 -0
  817. package/src/ibl/IblPipelineCache.ts +1006 -0
  818. package/src/ibl/skylight-bind-group.ts +513 -0
  819. package/src/index.ts +129 -0
  820. package/src/inspection-types.ts +80 -0
  821. package/src/instance-buffer-cache.ts +95 -0
  822. package/src/internal.ts +9 -0
  823. package/src/lifecycle.ts +168 -0
  824. package/src/light-buffer-layout.ts +346 -0
  825. package/src/materials.ts +214 -0
  826. package/src/mesh-material-bindings.ts +103 -0
  827. package/src/pbr-pipeline.ts +1137 -0
  828. package/src/pipeline/standard-forward-lane.ts +229 -0
  829. package/src/pipeline/standard-pipeline.ts +555 -0
  830. package/src/pipeline/standard-profile.ts +65 -0
  831. package/src/pipeline-builder.ts +382 -0
  832. package/src/pipeline-spec-types.ts +28 -0
  833. package/src/pipeline-spec.ts +1231 -0
  834. package/src/plugin.ts +69 -0
  835. package/src/points-lines/__tests__/bounds.unit.test.ts +21 -0
  836. package/src/points-lines/__tests__/cache-recovery.unit.test.ts +157 -0
  837. package/src/points-lines/__tests__/inspection-lanes.integration.test.ts +146 -0
  838. package/src/points-lines/__tests__/inspection.unit.test.ts +173 -0
  839. package/src/points-lines/__tests__/lane-structural.integration.test.ts +122 -0
  840. package/src/points-lines/__tests__/points-lines.bench.ts +400 -0
  841. package/src/points-lines/__tests__/raster-lane.integration.test.ts +44 -0
  842. package/src/points-lines/__tests__/snapshot.unit.test.ts +101 -0
  843. package/src/points-lines/__tests__/view-ubo.unit.test.ts +73 -0
  844. package/src/points-lines/admission.ts +238 -0
  845. package/src/points-lines/bounds.ts +23 -0
  846. package/src/points-lines/expansion-cache.ts +215 -0
  847. package/src/points-lines/inspection.ts +97 -0
  848. package/src/points-lines/prepare.ts +177 -0
  849. package/src/points-lines/record.ts +125 -0
  850. package/src/points-lines/snapshot.ts +130 -0
  851. package/src/post-process-errors.ts +302 -0
  852. package/src/prepare/prepared-graphics-resolver.ts +456 -0
  853. package/src/record/__tests__/material-bg-cache.unit.test.ts +41 -0
  854. package/src/record/__tests__/material-slot-plan.unit.test.ts +44 -0
  855. package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +41 -0
  856. package/src/record/__tests__/video-texture-view.unit.test.ts +90 -0
  857. package/src/record/frame-lighting.ts +1074 -0
  858. package/src/record/frame-snapshot.ts +500 -0
  859. package/src/record/frame-targets.ts +137 -0
  860. package/src/record/frame.ts +1598 -0
  861. package/src/record/helpers.ts +284 -0
  862. package/src/record/main-pass-geometry.ts +1065 -0
  863. package/src/record/main-pass-material.ts +1043 -0
  864. package/src/record/main-pass-sprite-draws.ts +1137 -0
  865. package/src/record/main-pass.ts +610 -0
  866. package/src/record/mesh-ssbo.ts +885 -0
  867. package/src/record/render-context.ts +384 -0
  868. package/src/record/shadow-pass.ts +698 -0
  869. package/src/record/skybox-post-pass.ts +593 -0
  870. package/src/record/typed-frame-graph.ts +461 -0
  871. package/src/record/view-ubo.ts +222 -0
  872. package/src/render-contract.ts +582 -0
  873. package/src/render-data.ts +385 -0
  874. package/src/render-graph-primitives.ts +811 -0
  875. package/src/render-pipeline.ts +259 -0
  876. package/src/render-system-extract.ts +4585 -0
  877. package/src/render-system.ts +1933 -0
  878. package/src/scene/render-scene-types.ts +67 -0
  879. package/src/scene/render-scene.ts +1087 -0
  880. package/src/scene-instances/post-spawn-resolve-joints.ts +165 -0
  881. package/src/shadow-atlas.ts +244 -0
  882. package/src/ssao-buffers.ts +334 -0
  883. package/src/ssao-config.ts +61 -0
  884. package/src/systems/active-camera.ts +116 -0
  885. package/src/systems/pass-selector.ts +49 -0
  886. package/src/systems/skin-palette-allocator.ts +251 -0
  887. package/src/systems/skin-palette-types.ts +14 -0
  888. package/src/systems/transparent-sort-config.ts +233 -0
  889. package/src/tilemap-chunk-extract-system.ts +1290 -0
  890. package/src/typed-render-graph-primitives.ts +541 -0
  891. package/src/typed-shadow-passes.ts +165 -0
@@ -0,0 +1,4585 @@
1
+ // @forgeax/engine-runtime - RenderSystem Extract stage (D-S2 + plan-strategy R-15
2
+ // fallback split). Pure ECS query phase: walks Camera / DirectionalLight /
3
+ // the merged-MeshRenderer renderable archetype and produces SoA-free snapshot
4
+ // arrays consumed by the Record stage.
5
+ //
6
+ // Carve-out from render-system.ts (review round 1 finding #3 - 505 line cap
7
+ // fallback split into 3 files; main + extract + record). The public API remains
8
+ // limited to the renderer contract; this module is an owner-local
9
+ // implementation seam consumed through relative imports.
10
+ //
11
+ // w15 (feat-20260511-asset-system-v1 M5 / plan-strategy D-P4) +
12
+ // feat-20260513-component-naming-bevy-align M3 / D-2 (merged) +
13
+ // feat-20260517-merge-mesh-renderer-material-renderer M2 / w5 (rename) +
14
+ // M3 / w9 (single-query convergence; this commit):
15
+ // The previous 4-query alpha split (one full-archetype query gating on
16
+ // Transform + MeshFilter + MeshRenderer without Instances; one direct
17
+ // archetype-graph walk for the instanced variant; two fallback queries
18
+ // on Transform+MeshFilter without MeshRenderer / MeshFilter without
19
+ // Transform; one trailing dispatch query gating on MeshRenderer alone)
20
+ // collapses into ONE archetype-graph walk gated on `MeshRenderer`
21
+ // component presence (plan-strategy section 2.2 case A archetype-
22
+ // natural-absence + section 3.2 sequence diagram "with: [MeshRenderer]
23
+ // (sole)"). Inside the loop
24
+ // the four D-Q7 dispositions land:
25
+ // - case A (no MeshRenderer) : not in domain; archetype absent. No
26
+ // fire; no renderable; no dispatch.
27
+ // - case B (material === 0) : missing-spec sentinel; defaultMaterial
28
+ // Snapshot fills the slot. No fire.
29
+ // - case C (handle unresolved): assets.get(handle).err with the entity
30
+ // carrying the full T+MF+MR renderable
31
+ // archetype -> structured RhiError(
32
+ // `asset-not-registered`) routed through
33
+ // the World Layer-3 ErrorHandler + entity
34
+ // skipped from RenderableSnapshot[] +
35
+ // from MaterialDispatchSnapshot[]; with a
36
+ // non-renderable archetype (MeshRenderer
37
+ // only / lacking T or MF) the dangling
38
+ // handle stays a silent skip (charter F1
39
+ // surface minimization: AI users without
40
+ // render intent should not see render
41
+ // errors).
42
+ // - resolved : populated MaterialSnapshot (5 fields)
43
+ // + MaterialDispatchSnapshot dispatch
44
+ // entry (regardless of T+MF presence,
45
+ // preserving the dispatch-only counter
46
+ // semantics).
47
+ //
48
+ // D-Q7 three-tier subtable (SSOT mirrored in `packages/runtime/README.md`
49
+ // §ECS render bridge D-Q7 `MeshRenderer` three-tier subtable +
50
+ // `components/mesh-renderer.ts` head JSDoc; AGENTS.md §Component naming
51
+ // + §Breaking changes 2026-05-17 row references this surface):
52
+ // - case A archetype absent : silent skip; no onError; not in
53
+ // RenderableSnapshot[]; hint literal N/A
54
+ // (charter "ergonomic omission, not a
55
+ // misuse"; AC-09).
56
+ // - case B missing-spec : `material === undefined` ->
57
+ // `defaultMaterialSnapshot()` mid-grey
58
+ // unlit; no onError; entity present in
59
+ // RenderableSnapshot[] with default
60
+ // material; hint literal: `'pass
61
+ // undefined or omit field to request
62
+ // default material'` (AC-10).
63
+ // - case C dangling-ref : `assets.get(handle).err` (strict path,
64
+ // isRenderable === true) ->
65
+ // `RhiError({ code: 'asset-not-
66
+ // registered', detail: { assetHandle },
67
+ // hint: 'register material via
68
+ // assetRegistry.register(asset) before
69
+ // spawn, or remove the material field
70
+ // to fall back to default' })` routed
71
+ // through World Layer-3 ErrorHandler;
72
+ // entity skipped from
73
+ // RenderableSnapshot[] +
74
+ // MaterialDispatchSnapshot[]; mirrors
75
+ // `MeshFilter.assetHandle` dangling path
76
+ // (charter proposition 5; AC-11).
77
+ //
78
+ // feat-20260514-ecs-children-instances-managed-buffer-array M3 / w15:
79
+ // The legacy `Instances { buffer, count }` form (cross-coupled with the
80
+ // deleted `AssetRegistry.createInstancedBuffer` triplet + the retired
81
+ // `InstancedBufferAsset` POD) is gone. Per-entity instance transforms now
82
+ // live inside the ECS `Instances { transforms: 'array<f32>' }` column;
83
+ // extract materialises a `Float32Array` snapshot per Instances-bearing
84
+ // entity (via `world.get(e, Instances).transforms`) so the record stage
85
+ // can upload to a per-entity GPU storage buffer cached by entity packed
86
+ // u32.
87
+ //
88
+ // feat-20260515-buffer-array-vocab-collapse M3 / w15: the component-level
89
+ // `arrayStride` hook on `Instances.transforms` was retired (decision section
90
+ // 2.3 stride responsibility migration -- AI users gate at the set / push
91
+ // call sites + RenderSystem extract entry holds a defensive fail-fast).
92
+ // The extract entry checks `transforms.length % 16 === 0` directly after
93
+ // the `world.get(entity, Instances)` snapshot is taken; violations route
94
+ // `InstanceTransformsStrideMismatchError`
95
+ // (`code: 'instance-transforms-stride-mismatch'`,
96
+ // `detail: { actualLength, expectedStride: 16 }`) through the World
97
+ // Layer-3 ErrorHandler and the renderable is skipped. The record stage
98
+ // trusts the invariant and does not re-check.
99
+
100
+ import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
101
+ import {
102
+ resolveAssetHandle,
103
+ walkMaterialPassesOverSharedRefs,
104
+ } from '@forgeax/engine-assets-runtime';
105
+ import type { Component, ComponentSchema, EntityHandle, World } from '@forgeax/engine-ecs';
106
+ import {
107
+ InstanceTransformsStrideMismatchError,
108
+ readRenderArrayView,
109
+ routeWorldError,
110
+ SpawnLightInvalidBoundsError,
111
+ SpriteInstancesCountMismatchError,
112
+ SpriteInstancesMutuallyExclusiveWithInstancesError,
113
+ SpriteInstancesRequiresSpriteShaderError,
114
+ } from '@forgeax/engine-ecs/projection';
115
+ import { box3, frustum, type Mat4, mat4, type Vec3, vec3 } from '@forgeax/engine-math';
116
+ import { AssetGuid, type AssetGuid as AssetGuidBytes } from '@forgeax/engine-pack/guid';
117
+ import { RhiError } from '@forgeax/engine-rhi';
118
+ import {
119
+ MorphWeights,
120
+ projectHierarchy,
121
+ type SceneHierarchySnapshot,
122
+ Transform,
123
+ } from '@forgeax/engine-scene';
124
+ import {
125
+ JointCountMismatchError,
126
+ JointEntityDanglingError,
127
+ SkeletonResolveFailedError,
128
+ Skin,
129
+ SkinInstancesCoexistForbiddenError,
130
+ } from '@forgeax/engine-skinning';
131
+ import type {
132
+ Asset,
133
+ Handle,
134
+ MaterialAsset,
135
+ MaterialColorParameterSchema,
136
+ MaterialParameter,
137
+ MaterialPass,
138
+ MaterialRenderState,
139
+ MaterialTextureCoordinates,
140
+ MaterialTextureValue,
141
+ MeshAsset,
142
+ ParamSchemaEntry,
143
+ PrimitiveTopology,
144
+ SkeletonAsset,
145
+ } from '@forgeax/engine-types';
146
+ import {
147
+ ASSET_ERROR_HINTS,
148
+ AssetError,
149
+ derive,
150
+ materialGuidText,
151
+ materialValuesToLinearRuntime,
152
+ toShared,
153
+ } from '@forgeax/engine-types';
154
+ import {
155
+ antialiasFromF32,
156
+ bloomEnabledFromF32,
157
+ Camera,
158
+ cameraProjectionFromF32,
159
+ DirectionalLight,
160
+ Instances,
161
+ Layer,
162
+ Lines,
163
+ MeshFilter,
164
+ MeshRenderer,
165
+ PointLight,
166
+ PointLightShadow,
167
+ Points,
168
+ PostProcessParams,
169
+ pointShapeFromU32,
170
+ SkyboxBackground,
171
+ Skylight,
172
+ SortKey,
173
+ SpotLight,
174
+ SpriteInstances,
175
+ SpriteRegionOverride,
176
+ tonemapFromF32,
177
+ tonemapToU32,
178
+ } from './components';
179
+ import { GlyphText } from './components/glyph-text';
180
+ import { computeInvRangeSquared, degToCos } from './components/light-helpers';
181
+ import {
182
+ MaterialSkinAttrMissingError,
183
+ ShadowInvalidConfigError,
184
+ SkinMaterialMismatchError,
185
+ } from './errors/render';
186
+ import { resolveVisibility, type VisibilitySnapshot } from './extract/visibility';
187
+ import type {
188
+ RenderFeatureHiddenEntityReport,
189
+ RenderFeatureWorldVisibilitySnapshot,
190
+ } from './features/types';
191
+ import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
192
+ import {
193
+ type MeshMaterialBindingDiagnostic,
194
+ type MeshMaterialBindingSource,
195
+ resolveMeshMaterialBindings,
196
+ } from './mesh-material-bindings';
197
+ import { isStandardPbrMaterialShader } from './pbr-pipeline';
198
+ import { expandPointsLinesBounds } from './points-lines/bounds';
199
+ import type { PointsLinesRetainedSnapshot, PointsLinesStyle } from './points-lines/snapshot';
200
+ import { type CameraSnapshot, STANDARD_TONEMAP_FEATURE_ID } from './render-contract';
201
+ import { getActiveCamera, selectActiveCameraIndex } from './systems/active-camera';
202
+ import { selectPasses } from './systems/pass-selector';
203
+ import type { SkinPaletteAllocator } from './systems/skin-palette-allocator';
204
+ import type { SkinPaletteSlice } from './systems/skin-palette-types';
205
+
206
+ /**
207
+ * DirectionalLightSnapshot — sun-like infinite light variant of the
208
+ * `LightSnapshot` discriminated union (M2 / w16 / AC-03). Host pre-multiplies
209
+ * `color * intensity` so the shader sees the radiance term directly (charter
210
+ * P4 host-side parity); `direction` stays in raw outgoing-vector form so the
211
+ * shader can negate it once for BRDF (`let l = normalize(-light.direction)`).
212
+ *
213
+ * Plan-strategy D-S1 (3) (LightSnapshot to GPU buffer bucket 1:1 mapping).
214
+ */
215
+ export interface DirectionalLightSnapshot {
216
+ readonly kind: 'directional';
217
+ readonly direction: Vec3;
218
+ readonly color: Vec3;
219
+ readonly intensity: number;
220
+ }
221
+
222
+ /**
223
+ * PointLightSnapshot — omnidirectional point variant. `position` from the
224
+ * companion Transform; `invRangeSquared = 1 / range^2` host-folded via
225
+ * `computeInvRangeSquared` (range = +Infinity -> 0; range = 0 -> 1e8 NaN
226
+ * protection per D-S5).
227
+ */
228
+ export interface PointLightSnapshot {
229
+ readonly kind: 'point';
230
+ readonly position: Vec3;
231
+ readonly color: Vec3;
232
+ readonly intensity: number;
233
+ readonly invRangeSquared: number;
234
+ /**
235
+ * feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-8 + M4 / T-M4-4:
236
+ * cube_array atlas layer index for this light's shadow map (0..3 for shadow
237
+ * casters; sentinel `-1` for non-shadow lights). Default `-1` per
238
+ * plan-strategy §D-2 — record stage / shader skips shadow sampling when the
239
+ * lane equals the sentinel. Joined with `pointShadow[]` by entity at the
240
+ * end of extract; HDRP record stage threads `shadowAtlasLayer + shadowNear +
241
+ * shadowFar` through `packLightSlot` as the §D-8 pad-lane payload.
242
+ */
243
+ readonly shadowAtlasLayer?: number;
244
+ /**
245
+ * Per-face perspective near plane (matches `PointLightShadow.nearPlane`).
246
+ * Used by HDRP `evalPointShadowed` for depth-ref reconstruction; rides
247
+ * `LightSlot.kind_and_pad.z` (byte 56..60) on the std430 std layout.
248
+ */
249
+ readonly shadowNear?: number;
250
+ /**
251
+ * Per-face perspective far plane (matches `PointLightShadow.farPlane`).
252
+ * Rides `LightSlot.kind_and_pad.w` (byte 60..64).
253
+ */
254
+ readonly shadowFar?: number;
255
+ }
256
+
257
+ /**
258
+ * PointShadowSnapshot — extract-stage view of one shadow-casting point light
259
+ * (feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7).
260
+ *
261
+ * `entity` is the spawning entity (joins PointLight + PointLightShadow on the
262
+ * same archetype); `position` is the light world-space position resolved from
263
+ * the companion Transform (mirrors PointLightSnapshot.position).
264
+ * `shadowMatrices` is the 96-float packed mat4[6] table (column-major,
265
+ * 6 mat4 * 16 floats = 384 bytes) ready for UBO upload.
266
+ *
267
+ * Plan-strategy §D-3: the record stage allocates a dynamic-offset uniform
268
+ * buffer (384 B per-light, aligned to minUniformBufferOffsetAlignment, WebGPU
269
+ * min 256 -> 512 B stride). Extract owns the host-side `Float32Array(96)`;
270
+ * record owns the GPU `RhiBuffer` lifetime.
271
+ *
272
+ * `mapSize` / `nearPlane` / `farPlane` ride here so record stage can size the
273
+ * cube atlas faces and feed shader the proj constants without re-querying ECS.
274
+ * `shadowAtlasLayer` is assigned by extract in spawn order (0 .. cap-1) so the
275
+ * downstream LightSlot / packPointLight packers carry the layer index in
276
+ * pointPadW (T-M1-8 will rename to shadowAtlasLayer).
277
+ */
278
+ export interface PointShadowSnapshot {
279
+ /** Spawning entity index (joins PointLight + PointLightShadow + Transform). */
280
+ readonly entity: number;
281
+ /** World-space light position (sourced from companion Transform). */
282
+ readonly position: Vec3;
283
+ /** PointLightShadow.mapSize (per-face cube square dimension; 512 default). */
284
+ readonly mapSize: number;
285
+ readonly nearPlane: number;
286
+ readonly farPlane: number;
287
+ /**
288
+ * Atlas layer assigned by extract in spawn order (0..cap-1, where cap=4
289
+ * matches PointLightShadow cardinality). Sentinel -1 is reserved by the
290
+ * shader-side LightSlot for no-shadow lights (T-M1-8).
291
+ */
292
+ readonly shadowAtlasLayer: number;
293
+ /**
294
+ * 6 view-proj mat4 in face order [+X, -X, +Y, -Y, +Z, -Z] packed into one
295
+ * Float32Array(96). 16 floats per face = 64 B; 6 * 64 = 384 B raw. The
296
+ * record stage pads each slot to 512 B (256 alignment) when packing into
297
+ * the per-light dynamic-offset UBO (plan-strategy §D-3).
298
+ */
299
+ readonly shadowMatrices: Float32Array;
300
+ }
301
+
302
+ /**
303
+ * SpotLightSnapshot — cone-restricted variant. `position` from companion
304
+ * Transform; `direction` raw outgoing-vector; `cosInner` / `cosOuter` host
305
+ * pre-converted via `degToCos` so the shader sees only cosines (D-S2).
306
+ *
307
+ * feat-20260625-spot-light-shadow-mapping M1 w5: added shadow fields.
308
+ * castShadow (bool) gates shadow projection; lightViewProj is the perspective
309
+ * light-view-projection matrix computed in extract (undefined when castShadow
310
+ * is false, dir degenerates, or the light is clipped). shadowAtlasTile
311
+ * (i32 sentinel -1) is the allocated tile index 0..3 or -1 for unassigned
312
+ * (plan-strategy D-4). mapSize / nearPlane / farPlane are the source
313
+ * component shadow parameters carried through to the record stage.
314
+ */
315
+ export interface SpotLightSnapshot {
316
+ readonly kind: 'spot';
317
+ readonly position: Vec3;
318
+ readonly direction: Vec3;
319
+ readonly color: Vec3;
320
+ readonly intensity: number;
321
+ readonly invRangeSquared: number;
322
+ readonly cosInner: number;
323
+ readonly cosOuter: number;
324
+ // ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
325
+ readonly castShadow: boolean;
326
+ readonly lightViewProj: Float32Array | undefined;
327
+ readonly mapSize: number;
328
+ readonly nearPlane: number;
329
+ readonly farPlane: number;
330
+ readonly shadowAtlasTile: number;
331
+ }
332
+
333
+ /**
334
+ * LightSnapshot — discriminated union of the three KHR_lights_punctual
335
+ * variants. AI users + the record stage perform an exhaustive switch on
336
+ * `kind`; missing arms are caught at compile time (no `default`,
337
+ * `assertNever`-style guards). Plan-strategy R-10.
338
+ */
339
+ export type LightSnapshot = DirectionalLightSnapshot | PointLightSnapshot | SpotLightSnapshot;
340
+
341
+ /**
342
+ * ExtractedLights — three-bucket output of the extractFrame three-query
343
+ * union (M2 / w16 / AC-03). Plan-strategy section 3.1 EXT node:
344
+ * - `directional` — at most one (record-stage N>1 fail-fast lands in M3)
345
+ * - `point[]` — first-slice cap of 4 enforced at record stage (M3)
346
+ * - `spot[]` — first-slice cap of 4 enforced at record stage (M3)
347
+ * `directionalCount` exposes the raw count of DirectionalLight entities the
348
+ * extract observed (so the record stage can fire `render-system-multi-light`
349
+ * when N>1 without re-running the query). M3 / w19 will widen this with
350
+ * pointCount / spotCount fields once the matching record-time fail-fast
351
+ * lands; for M2 the field stays directional-only to keep AC-06 (a) intact.
352
+ */
353
+ export interface ExtractedLights {
354
+ readonly directional: DirectionalLightSnapshot | undefined;
355
+ readonly directionalCount: number;
356
+ readonly point: readonly PointLightSnapshot[];
357
+ readonly spot: readonly SpotLightSnapshot[];
358
+ /**
359
+ * feat-20260613-csm-cascaded-shadow-maps M2 / w9: per-cascade light-view-
360
+ * projection matrices (one per cascade, length 4 pre-allocated). Each matrix
361
+ * is a column-major 16-float mat4 with atlas tile UV inset baked in
362
+ * (plan-strategy D-3). cascadeCount < 4: unused slots are zero matrices.
363
+ * Undefined when castShadow=false or no directional light.
364
+ */
365
+ readonly lightViewProj: readonly Float32Array[] | undefined;
366
+ /**
367
+ * feat-20260613-csm-cascaded-shadow-maps M2 / w9: view-space z depths
368
+ * of the PSSM split planes (length 4, Float32Array). cascadeCount < 4:
369
+ * unused slots are 0.0f. Undefined when castShadow=false or no directional
370
+ * light.
371
+ */
372
+ readonly splitPlanes: Float32Array | undefined;
373
+ /**
374
+ * feat-20260613-csm-cascaded-shadow-maps M2 / w9: effective cascade count
375
+ * from the DirectionalLight component (1..4). Undefined when castShadow=false
376
+ * or no directional light.
377
+ */
378
+ readonly cascadeCount: number | undefined;
379
+ /**
380
+ * feat-20260613-csm-cascaded-shadow-maps M2 / w9: cascade blend width
381
+ * from the DirectionalLight component (0..0.5). Undefined when castShadow=false
382
+ * or no directional light.
383
+ */
384
+ readonly cascadeBlend: number | undefined;
385
+ /**
386
+ * feat-20260520-directional-light-shadow-mapping M1c / w8:
387
+ * shadowMapSize from DirectionalLight.mapSize. Drives shadow RT
388
+ * lazy-allocate (idempotency: same size -> no rebuild). Undefined when
389
+ * castShadow=false or no directional light.
390
+ */
391
+ readonly shadowMapSize: number | undefined;
392
+ /**
393
+ * feat-20260621-merge-directionallightshadow-into-directionallight M2:
394
+ * depthBias from the merged DirectionalLight (constant shadow-bias floor).
395
+ * Populated when castShadow=true on the first-hit directional light;
396
+ * undefined otherwise.
397
+ */
398
+ readonly depthBias: number | undefined;
399
+ /**
400
+ * feat-20260621-merge-directionallightshadow-into-directionallight M2:
401
+ * normalBias from the merged DirectionalLight (slope-based shadow-bias
402
+ * coefficient).
403
+ * Populated when castShadow=true on the first-hit directional light;
404
+ * undefined otherwise.
405
+ */
406
+ readonly normalBias: number | undefined;
407
+ /**
408
+ * feat-20260621-merge-directionallightshadow-into-directionallight M2:
409
+ * pcfKernelSize from the merged DirectionalLight (PCF kernel width, odd>=1).
410
+ * Populated when castShadow=true on the first-hit directional light;
411
+ * undefined otherwise.
412
+ */
413
+ readonly pcfKernelSize: number | undefined;
414
+ /**
415
+ * feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7:
416
+ * shadow-casting point lights (PointLight + PointLightShadow + Transform
417
+ * archetype join). Each entry carries the per-light 6-face VP matrices and
418
+ * the assigned cube_array atlas layer (0..3 in spawn order; sentinel -1 is
419
+ * reserved by the shader-side LightSlot for no-shadow point lights).
420
+ *
421
+ * Empty array when no PointLightShadow components exist (zero-cost gate per
422
+ * AC-09; record stage skips atlas allocation + shadow pass dispatch).
423
+ */
424
+ readonly pointShadow: readonly PointShadowSnapshot[];
425
+ /**
426
+ * bug-20260710-editor-cross-world-shadow: the RAW directional-shadow config
427
+ * (cascadeCount / splitLambda / cascadeBlend / mapSize / shadowDistance) of
428
+ * the first-hit castShadow DirectionalLight, carried unconditionally of
429
+ * whether this world had a camera. The {@link extractFrames} merge layer
430
+ * re-runs {@link computeDirectionalCsm} with this config + the surfaced
431
+ * camera so directional shadows work when the light and camera live in
432
+ * DIFFERENT worlds (editor editorWorld/sceneWorld super-composite). Undefined
433
+ * when castShadow=false or no directional light. The `lightViewProj` /
434
+ * `splitPlanes` fields above remain the *computed* per-world outputs (correct
435
+ * for single-world; recomputed at merge for cross-world).
436
+ */
437
+ readonly directionalCsmConfig: DirectionalCsmConfig | undefined;
438
+ /**
439
+ * bug-20260710-editor-cross-world-shadow: the light-direction of the
440
+ * first-hit castShadow DirectionalLight, carried so the merge layer can
441
+ * rebuild the light-view matrix. Redundant with `directional.direction` but
442
+ * kept explicit so the CSM recompute reads a single config bundle. Undefined
443
+ * when castShadow=false or no directional light.
444
+ */
445
+ readonly directionalCsmDirection: Vec3 | undefined;
446
+ }
447
+
448
+ /**
449
+ * SkylightSnapshot -- extract-stage view of one Skylight entity
450
+ * (feat-20260520-skylight-ibl-cubemap M4 / t26).
451
+ *
452
+ * `equirectHandle` carries the packed u32 handle for
453
+ * `Handle<EquirectAsset, 'shared'>`; the record stage drives the internal
454
+ * lazy cubemap projection from it and resolves the projected GPU cubemap via
455
+ * `GpuResidencyCache.getCubemapGpuView(...)` and related helpers
456
+ * (feat-20260630 M3 / w16-w18). The snapshot carries NO projection status
457
+ * field: the status truth lives in the store's CubemapGpuEntry (D-3 SSOT);
458
+ * record queries it once per frame.
459
+ *
460
+ * `intensity` defaults to 1.0 via the Skylight component token defaults
461
+ * (plan-strategy D-6: Skylight data flows through existing extract->record
462
+ * pipeline; no independent ECS system).
463
+ */
464
+ export interface SkylightSnapshot {
465
+ // 0 = no equirect supplied -> solid-color ambient via the white fallback
466
+ // cube (record falls to fallback resources when no IBL views are cached).
467
+ readonly equirectHandle: number;
468
+ readonly color: readonly [number, number, number];
469
+ readonly intensity: number;
470
+ /** Environment-space quaternion [x, y, z, w], identity by default. */
471
+ readonly rotation: readonly [number, number, number, number];
472
+ // feat-20260630 M3 / w19: the WINNING Skylight entity's packed handle (first
473
+ // archetype hit). Carried so the multi-Skylight once-warn can name which
474
+ // entity is used and which is ignored (F-8: warn carries conflicting entity
475
+ // info). 0 only if the bundle had no live entity (never happens for a hit).
476
+ readonly entityHandle: number;
477
+ }
478
+
479
+ /**
480
+ * SkyboxSnapshot -- extract-stage view of one SkyboxBackground entity
481
+ * (feat-20260531-skybox-env-background M2 / w5).
482
+ *
483
+ * `equirectHandle` carries the packed u32 handle for
484
+ * `Handle<EquirectAsset, 'shared'>`; the record stage resolves the projected
485
+ * GPU cubemap via `GpuResidencyCache.getCubemapGpuView(...)`.
486
+ *
487
+ * `mode` carries the raw `f32` column value (`SKYBOX_MODE_CUBEMAP = 0`).
488
+ * First hit wins per plan-strategy D-6; multi-entity once-warn in record stage.
489
+ */
490
+ export interface SkyboxSnapshot {
491
+ readonly equirectHandle: number;
492
+ readonly mode: number;
493
+ /** Environment-space quaternion [x, y, z, w], identity by default. */
494
+ readonly rotation: readonly [number, number, number, number];
495
+ // feat-20260630 M3 / w19: the WINNING SkyboxBackground entity's packed handle
496
+ // (first archetype hit), so the multi-SkyboxBackground once-warn can name the
497
+ // used entity (F-8 parity with the Skylight warn).
498
+ readonly entityHandle: number;
499
+ }
500
+
501
+ export interface RenderableSnapshot {
502
+ readonly assetHandle: number;
503
+ readonly transform: TransformSnapshot;
504
+ /** Finite producer-owned local bounds retained for persistent CPU view work. */
505
+ readonly localAabb?: Float32Array;
506
+ /** Indexed single-submesh facts consumed by the first GPU-driven rigid lane. */
507
+ readonly gpuDrivenDraws?: readonly GpuDrivenDrawSnapshot[];
508
+ /**
509
+ * feat-20260608 M5 amend / w11-a: the entity's representative (first)
510
+ * material snapshot, kept as a same-name shorthand for `materials[0]`.
511
+ * Per-entity dispatch (shading model routing, sprite-vs-mesh split, the
512
+ * D-1/D-3 pipeline-tag pick) is uniform across the entity's submeshes
513
+ * today, so the existing 20+ `entry.source.material.X` consumers in the
514
+ * record stage stay unchanged. Per-submesh material data (baseColor /
515
+ * UBO payload / paramSnapshot) lives in `materials[i]`.
516
+ */
517
+ readonly material: MaterialSnapshot;
518
+ /**
519
+ * feat-20260608 M5 amend / w11-a: per-submesh MaterialSnapshot[]
520
+ * positionally aligned with `MeshAsset.submeshes[]` (plan §3.2 sequence
521
+ * step 6 + AC-08). Length always equals the entity's submesh count once
522
+ * the extract stage's count-mismatch validator (M2 / w12) has filtered
523
+ * misaligned spawns. The record stage reads `materials[i]` to upload the
524
+ * i-th material UBO slot before drawing the i-th submesh.
525
+ *
526
+ * Backward-compat: `materials[0] === material` (the legacy mid-grey
527
+ * default-material case-B path also routes through `materials = [default]`,
528
+ * keeping the legacy single-mesh-no-material spawn shape working without
529
+ * a special branch in record).
530
+ */
531
+ readonly materials: readonly MaterialSnapshot[];
532
+ /** Per-slot provenance trace from the shared mesh-material resolver. */
533
+ readonly materialBindingSources: readonly MeshMaterialBindingSource[];
534
+ /** Active structured override diagnostics for this renderable. */
535
+ readonly materialBindingDiagnostics?: readonly MeshMaterialBindingDiagnostic[];
536
+ /**
537
+ * feat-20260708-composited-multi-world-rendering M1 / D-1: the worldId
538
+ * of the world this renderable was extracted from. Defaults to 0 in
539
+ * single-world path (extractFrame always assigns 0). The merge layer
540
+ * (extractFrames in M2) stamps the correct worldId per world before
541
+ * the record stage consumes it. Combined with `entityKey` via
542
+ * `worldEntityKey(worldId, entityKey)` to form per-entity cache keys.
543
+ *
544
+ * Never rewrite `entityKey` itself — consumers that need the real
545
+ * entity handle (video provider, etc.) read the bare `entityKey`.
546
+ */
547
+ readonly worldId: number;
548
+ /**
549
+ * feat-20260531-per-frame-bind-group-cache M1 / w3: packed Entity u32
550
+ * (encodeEntity(indexSlot, generation)) surface'd from the extract
551
+ * stage. Stable per-entity identity for the record stage cache keys
552
+ * (material / instances / per-frame clean-up) without re-querying the
553
+ * World (charter P5 Pipeline Isolation: record stage only consumes
554
+ * snapshot POD). Reuses the encodeEntity calculation already performed
555
+ * at :1293 for the Instances path, and now also computed for plain
556
+ * (non-Instances) renderables.
557
+ *
558
+ * Never rewrite this field — cache keys use `worldEntityKey(worldId, entityKey)`,
559
+ * not the bare entityKey alone (D-1).
560
+ */
561
+ readonly entityKey: number;
562
+ /**
563
+ * feat-20260514 M3 (w15): when the entity carries an `Instances` component
564
+ * the extract stage materialises a fresh `Float32Array` snapshot of the
565
+ * packed mat4 transforms (16 f32 per instance, stride 16) plus a stable
566
+ * `cacheKey` (the entity's packed u32) and the `archVersion` (used by the
567
+ * record stage to invalidate its per-entity GPU buffer cache when the
568
+ * archetype storage grew). Absent (`undefined`) means the record stage
569
+ * falls back to the shared 1-element identity-mat4 storage buffer +
570
+ * `drawIndexed(.., 1, ..)`.
571
+ */
572
+ readonly instances?: InstancesSnapshot;
573
+ /**
574
+ * feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10
575
+ * (plan-strategy D-1 + D-9): when the entity carries a `SpriteInstances`
576
+ * component the extract stage materialises a paired snapshot — packed mat4
577
+ * `transforms` (16 f32, stride 16) + per-instance UV `regions` (4 f32,
578
+ * stride 4) — plus the same cache fingerprint pair (`cacheKey` = entity
579
+ * packed u32, `archVersion` = archetype version stamp at snapshot time).
580
+ * The record stage interleaves the two arrays into an 80B-per-instance
581
+ * single GPU buffer routed through `@group(3) @binding(0)` (BGL unchanged,
582
+ * D-1). Absent (`undefined`) means the entity is not a `SpriteInstances`
583
+ * carrier; the record stage falls back to its existing sprite path
584
+ * (material UBO region + identity-instance buffer).
585
+ *
586
+ * Three structured `EcsError` codes fire at extract entry and skip the
587
+ * renderable on violation (charter P3 explicit failure):
588
+ * - `'sprite-instances-mutually-exclusive-with-instances'`
589
+ * - `'sprite-instances-requires-sprite-shading-model'`
590
+ * - `'sprite-instances-count-mismatch'`
591
+ */
592
+ readonly spriteInstances?: SpriteInstancesSnapshot;
593
+ /**
594
+ * feat-20260523-skin-skeleton-animation M2 / T-21: when the entity carries
595
+ * a `Skin` component the extract stage populates this field with the
596
+ * skin palette slice metadata. The record stage uses this to route the
597
+ * draw to `forgeax::pbr-skin` pipeline + set the palette dynamic offset.
598
+ * Absent (`undefined`) means the entity is not skinned.
599
+ */
600
+ readonly skin?: SkinPaletteSlice;
601
+ /**
602
+ * ECS-owned morph weights. Presence selects the Standard Pipeline CPU
603
+ * specialized deformation lane; the asset remains the source of target
604
+ * deltas and the record stage only receives this frozen POD snapshot.
605
+ */
606
+ readonly morph?: MorphSnapshot;
607
+ /** Detached M2 authoring facts for the optional Points/Lines projection. */
608
+ readonly pointsLines?: PointsLinesRetainedSnapshot;
609
+ }
610
+
611
+ export interface MorphSnapshot {
612
+ readonly weights: Float32Array;
613
+ readonly targetCount: number;
614
+ }
615
+
616
+ export interface GpuDrivenDrawSnapshot {
617
+ readonly kind: 'indexed' | 'non-indexed';
618
+ readonly first: number;
619
+ readonly count: number;
620
+ readonly baseVertex: number;
621
+ readonly materialSlot: number;
622
+ readonly topology: PrimitiveTopology;
623
+ readonly pipelineClass: string;
624
+ readonly materialResourceClass: string;
625
+ }
626
+
627
+ export interface InstancesSnapshot {
628
+ /** Packed column-major mat4 transforms (16 f32 per instance). */
629
+ readonly transforms: Float32Array;
630
+ /** Number of instances (transforms.length / 16). */
631
+ readonly instanceCount: number;
632
+ /** Stable per-entity GPU buffer cache key (the packed Entity u32). */
633
+ readonly cacheKey: number;
634
+ /**
635
+ * Archetype version stamp at snapshot time. The record stage compares this
636
+ * against its cached version per cacheKey; a bump means the underlying
637
+ * BufferPool slot may have grown / been reallocated, forcing a fresh
638
+ * `device.createBuffer + queue.writeBuffer` round.
639
+ */
640
+ readonly archVersion: number;
641
+ }
642
+
643
+ /**
644
+ * feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10:
645
+ * extract-stage view of a `SpriteInstances` carrier (2D peer of
646
+ * `InstancesSnapshot`).
647
+ *
648
+ * Field shapes mirror `InstancesSnapshot` so the record-stage cache protocol
649
+ * (`(cacheKey, archVersion, byteLength)` fingerprint triple) is reused
650
+ * verbatim. The byte length consumed at upload time is the sum
651
+ * `transforms.byteLength + regions.byteLength` (= 80*N for N instances,
652
+ * plan-strategy D-1 interleaved single-buffer); the record stage builds the
653
+ * 80B/instance interleaved buffer once per (entity, archVersion, byteLength)
654
+ * fingerprint change.
655
+ */
656
+ export interface SpriteInstancesSnapshot {
657
+ /** Packed column-major mat4 transforms (16 f32 per instance, stride 16). */
658
+ readonly transforms: Float32Array;
659
+ /** Per-instance UV vec4 regions (4 f32 per instance, stride 4). */
660
+ readonly regions: Float32Array;
661
+ /**
662
+ * Number of instances. Derived from `transforms.length / 16` (equivalently
663
+ * `regions.length / 4`); the extract-entry validator guarantees the two
664
+ * derivations agree, otherwise it fires
665
+ * `'sprite-instances-count-mismatch'` and skips the renderable.
666
+ */
667
+ readonly instanceCount: number;
668
+ /** Stable per-entity GPU buffer cache key (the packed Entity u32, D-9). */
669
+ readonly cacheKey: number;
670
+ /** Archetype version stamp at snapshot time (cache invalidation fingerprint). */
671
+ readonly archVersion: number;
672
+ }
673
+
674
+ /**
675
+ * TransformSnapshot: extract-stage view of one entity's resolved world
676
+ * transform (feat-20260601 D-3). Holds the single `world` mat4 (column-major
677
+ * 16 floats, copied from the entity's `Transform.world` view written by
678
+ * propagateTransforms). The record stage copies these 16 floats straight into
679
+ * the mesh SSBO slot (zero `mat4.compose`); position / scale consumers derive
680
+ * from the mat4 via `mat4.getTranslation` / basis-column lengths.
681
+ */
682
+ export interface TransformSnapshot {
683
+ readonly world: Float32Array;
684
+ }
685
+
686
+ /**
687
+ * MaterialSnapshot: extract-stage view of one entity's material asset (M2 / w6
688
+ * of feat-20260517-merge-mesh-renderer-material-renderer; plan-strategy section 2.3).
689
+ *
690
+ * Adds optional `baseColorTexture` + `sampler` slots (consumed by the record
691
+ * stage's textured-material code path; M3 / w10 dropped the cast-over-firstMaterial
692
+ * pattern in favour of direct snapshot field reads).
693
+ *
694
+ * feat-20260522-learn-render-3-1-sponza-model-loading-with-multi-l M4 extends
695
+ * the snapshot with `metallicRoughnessTexture` and `normalTexture`
696
+ * so the record stage can wire PBR texture bindings 4 and 6
697
+ * from real GPU views instead of placeholder 1x1 white / flat-normal views.
698
+ *
699
+ * Bounded scope: the snapshot tracks only what the record stage actually
700
+ * consumes today (charter proposition 5 consistent abstraction; YAGNI for
701
+ * future-proof field bloat). Future MaterialAsset extensions (emissive / etc)
702
+ * drive snapshot extensions when the record stage starts consuming them, not
703
+ * vice-versa.
704
+ *
705
+ * tweak-20260701 M1: `shadingModel` field removed — shader identity via
706
+ * {@link materialShaderId} is the single source of truth for material dispatch.
707
+ */
708
+ export interface MaterialSnapshot {
709
+ readonly baseColor: Vec3;
710
+ readonly metallic: number;
711
+ readonly roughness: number;
712
+ readonly clearcoat?: number | undefined;
713
+ readonly clearcoatRoughness?: number | undefined;
714
+ /** Authored Standard PBR specular tint in linear runtime color space. */
715
+ readonly specularTint?: readonly [number, number, number] | undefined;
716
+ /**
717
+ * Schema-driven material shader identifier (feat-20260523 M4-T05).
718
+ * Populated when the material asset uses the schema-driven path
719
+ * (payload.materialShader set). Undefined for unlit/sprite legacy
720
+ * materials and for case-B defaultMaterialSnapshot.
721
+ *
722
+ * The record stage uses this as the pipeline cache key first-level
723
+ * discriminator (M4-T06).
724
+ */
725
+ readonly materialShaderId?: string | undefined;
726
+ /**
727
+ * Stable source handle for the material asset. Record uses this only for
728
+ * same-frame assembly reuse; GPU bind-group validity still comes from the
729
+ * resource-identity cache.
730
+ */
731
+ readonly materialHandle?: number | undefined;
732
+ /**
733
+ * The selected forward-pass state for this exact material. A mesh can bind
734
+ * different materials to its submeshes, so pipeline selection must read the
735
+ * state from the submesh snapshot rather than the entity's first material.
736
+ */
737
+ readonly renderState?: MaterialRenderState | undefined;
738
+ /**
739
+ * Schema-driven parameter snapshot (feat-20260523 M4-T05).
740
+ * Populated alongside materialShaderId. Maps param name to its
741
+ * runtime-resolved value: number for scalar params, number[] for
742
+ * vec/color params, string (GUID) for texture2d/sampler params.
743
+ */
744
+ readonly paramSnapshot?: Readonly<Record<string, number | number[] | string>> | undefined;
745
+ /** Effective MaterialAsset parameter contract used to lay out this snapshot. */
746
+ readonly materialParamSchema?: readonly ParamSchemaEntry[] | undefined;
747
+ /** Authored per-slot UV set and KHR texture transform metadata. */
748
+ readonly textureCoordinates?: ReadonlyMap<string, MaterialTextureCoordinates> | undefined;
749
+ /**
750
+ * User-region texture handles keyed by paramSchema field name
751
+ * (feat-20260621-learn-render-5-5-parallax M2 / w7). The SSOT carrier for
752
+ * EVERY texture the shader's `derive(paramSchema).textureFieldNames`
753
+ * declares — `baseColorTexture` / `metallicRoughnessTexture` /
754
+ * `normalTexture` for built-in standard-PBR, plus each custom field such as
755
+ * `heightTexture` (LO 5.5 parallax). The record stage iterates this map to
756
+ * assemble the user-region bind group per the per-shader BGL (w8), so a 4th
757
+ * (or Nth) texture flows end-to-end without a hardcoded field list.
758
+ *
759
+ * Populated by iterating `textureFieldNames`; absent keys mean the
760
+ * paramValue was missing / mis-typed (record falls back to default white).
761
+ * `emissiveTexture` / `occlusionTexture` are NOT here — they live in the
762
+ * engine-injection lightmap region (their named fields below feed
763
+ * `appendInjection('lightmap')`, not the user-region).
764
+ */
765
+ readonly textureHandles?: ReadonlyMap<string, Handle<'TextureAsset', 'shared'>> | undefined;
766
+ /**
767
+ * User-region texture field names whose paramValue resolved to a VideoAsset
768
+ * (kind `'video'`) rather than a static TextureAsset
769
+ * (feat-20260623-world-space-video-asset M4 / w14, D-5). The video GUID
770
+ * occupies the same texture2d values slot a static texture would (P4:
771
+ * one binding shape), but extract routes it here instead of `textureHandles`
772
+ * so the record stage pulls the current-frame view from the transient
773
+ * DynamicTextureStore (D-3) instead of `GpuResidencyCache.ensureResident`
774
+ * (which has no `video` arm; AC-08). Each entry also carries the resolved
775
+ * clip handle so the record stage can key the per-frame upload.
776
+ *
777
+ * Producer/consumer split (charter P5 / AC-07 gate): extract owns the
778
+ * asset->snapshot translation; record consumes this POD field only — it never
779
+ * reaches back into the MaterialAsset to learn a field is video-sourced.
780
+ */
781
+ readonly videoTextureFields?: ReadonlyMap<string, Handle<'VideoAsset', 'shared'>> | undefined;
782
+ /** Authored sampler handles keyed by their matching texture parameter. */
783
+ readonly samplerHandles?: ReadonlyMap<string, Handle<'SamplerAsset', 'shared'>> | undefined;
784
+ readonly baseColorTexture?: Handle<'TextureAsset', 'shared'> | undefined;
785
+ /**
786
+ * PBR metallic-roughness texture handle (present for PBR/sprite/skin
787
+ * shaders). Undefined for the default-unlit shader
788
+ * (materialShaderId === 'forgeax::default-unlit'). The record stage reads
789
+ * this to write GPU view at material bind-group binding 4, falling
790
+ * back to a 1x1 white placeholder when undefined.
791
+ *
792
+ * feat-20260522-learn-render-3-1-sponza-model-loading-with-multi-l M4:
793
+ * field added so the record stage can wire real metallic-roughness
794
+ * textures from Sponza glTF materials (M3 writes the handle into
795
+ * SchemaDrivenMaterialAsset values; M4 extract carries it through to the snapshot).
796
+ */
797
+ readonly metallicRoughnessTexture?: Handle<'TextureAsset', 'shared'> | undefined;
798
+ /**
799
+ * PBR tangent-space normal texture handle (present for PBR/sprite/skin
800
+ * shaders). Undefined for the default-unlit shader
801
+ * (materialShaderId === 'forgeax::default-unlit'). The record stage reads
802
+ * this to write GPU view at material bind-group binding 6, falling
803
+ * back to a (0.5,0.5,1.0) flat-normal placeholder when undefined.
804
+ *
805
+ * feat-20260522-learn-render-3-1-sponza-model-loading-with-multi-l M4:
806
+ * field added symmetrically with metallicRoughnessTexture so the record
807
+ * stage can wire real normal textures from Sponza glTF materials.
808
+ */
809
+ readonly normalTexture?: Handle<'TextureAsset', 'shared'> | undefined;
810
+ /** Scalar applied to tangent-space normal XY after the normal texture sample. */
811
+ readonly normalScale?: number | undefined;
812
+ readonly emissive?: readonly [number, number, number] | undefined;
813
+ readonly emissiveIntensity?: number | undefined;
814
+ readonly emissiveTexture?: Handle<'TextureAsset', 'shared'> | undefined;
815
+ readonly occlusionTexture?: Handle<'TextureAsset', 'shared'> | undefined;
816
+ readonly occlusionStrength?: number | undefined;
817
+ /**
818
+ * Transparent composition flag derived from the first pass's
819
+ * `renderState.blend` presence on the underlying
820
+ * {@link MaterialPass} (feat-20260626-collapse M2: blend
821
+ * presence is the SSOT after `MaterialPass.transparent` was
822
+ * dropped in M1).
823
+ *
824
+ * The record stage reads this to drive both the LDR split-pass
825
+ * decision and the premultiplied-alpha blend resolution on the
826
+ * generic materialShaderId pipeline path — shader-agnostic, decoupled
827
+ * from the legacy `shadingModel` discriminant (feat-20260625 M2 D-3,
828
+ * finalised in w15; `shadingModel` field removed in tweak-20260701 M1).
829
+ *
830
+ * Extract derives this from the first pass's `renderState.blend !==
831
+ * undefined` (post-feat-20260626-collapse: blend presence is the
832
+ * single SSOT for "this material is transparent on the geometry
833
+ * pipeline cache key"). Multi-pass materials whose mix of opaque +
834
+ * transparent passes need finer routing should split into separate
835
+ * MaterialAsset entries (the normal forgeax pattern).
836
+ *
837
+ * Type is `boolean | undefined` (derived): `undefined` means "no
838
+ * passes / unknown"; consumers must read `=== true` / `!== true` to
839
+ * stay correct under both populated and absent cases.
840
+ */
841
+ readonly transparent?: boolean | undefined;
842
+ }
843
+
844
+ interface MaterialCacheChainIdentity {
845
+ readonly asset: MaterialAsset;
846
+ readonly parentGuid: string | undefined;
847
+ }
848
+
849
+ export interface MaterialSnapshotCacheEntry {
850
+ readonly snapshot: MaterialSnapshot;
851
+ readonly passes: readonly MaterialPass[];
852
+ /** Root/chain identities are present only on cross-frame-safe entries. */
853
+ readonly chain?: readonly MaterialCacheChainIdentity[];
854
+ /** AssetRegistry epoch at which the root/parent chain was last validated. */
855
+ readonly catalogEpoch?: number;
856
+ /** True only when every object reachable from the authored chain is frozen. */
857
+ readonly crossFrameSafe: boolean;
858
+ }
859
+
860
+ export type MaterialSnapshotCache = Map<number, MaterialSnapshotCacheEntry>;
861
+ export type MaterialSnapshotCachesByWorld = WeakMap<World, MaterialSnapshotCache>;
862
+
863
+ function gpuDrivenMaterialResourceClass(material: MaterialSnapshot): string {
864
+ const textures = [...(material.textureHandles?.entries() ?? [])]
865
+ .map(([name, handle]) => [name, Number(handle)] as const)
866
+ .sort(([left], [right]) => left.localeCompare(right));
867
+ const samplers = [...(material.samplerHandles?.entries() ?? [])]
868
+ .map(([name, handle]) => [name, Number(handle)] as const)
869
+ .sort(([left], [right]) => left.localeCompare(right));
870
+ return JSON.stringify({
871
+ textures,
872
+ samplers,
873
+ video: [...(material.videoTextureFields?.keys() ?? [])].sort(),
874
+ });
875
+ }
876
+
877
+ // === DispatchEntry — M3 / w26 single dispatch list (feat-20260526-material-asset-multipass-renderstate) ===
878
+ //
879
+ // Plan-strategy D-3: single dispatch list sorted by queue value,
880
+ // replacing the old three-bucket opaque/transparent/overlay dispatch.
881
+ // Each entry carries the per-pass render-state and entry-point
882
+ // data from the resolved MaterialPass so the record stage
883
+ // reads them without re-resolving the material.
884
+
885
+ export interface DispatchEntry {
886
+ readonly entityIndex: number;
887
+ readonly materialHandle: number;
888
+ readonly renderableIndex: number;
889
+ readonly passIndex: number;
890
+ readonly queue: number;
891
+ /**
892
+ * Signed i32 from the entity's {@link Layer} component value (default 0 for
893
+ * entities without a Layer). Primary sort key for the transparent-dispatch
894
+ * sort in {@link render-system.ts}: lower value = drawn first (behind).
895
+ */
896
+ readonly layer: number;
897
+ readonly tags: Record<string, string>;
898
+ readonly renderState: MaterialRenderState | undefined;
899
+ readonly defines: Record<string, string> | undefined;
900
+ readonly vertexEntry: string | undefined;
901
+ readonly fragmentEntry: string | undefined;
902
+ readonly materialShaderId: string | undefined;
903
+ readonly paramSnapshot: Readonly<Record<string, number | number[] | string>> | undefined;
904
+ /**
905
+ * Stencil reference value from {@link MaterialPass.stencilReference}
906
+ * (draw-call dynamic state). Folded during extract for per-draw consumption
907
+ * in the record stage. `undefined` when the pass does not set a reference
908
+ * value (record stage falls back to WebGPU default 0).
909
+ */
910
+ readonly stencilReference?: number;
911
+ }
912
+
913
+ /**
914
+ * Stable-sort dispatch entries by `queue` value ascending.
915
+ * Returns a new sorted array (does not mutate the input).
916
+ * Same-queue entries preserve insertion order (AC-11 stable-order guarantee).
917
+ */
918
+ export function sortDispatchByQueue<E extends { readonly queue: number }>(
919
+ entries: readonly E[],
920
+ ): E[] {
921
+ // Array.prototype.sort is stable per ES2019 spec (V8 7.0+ / Node 12+).
922
+ return entries.slice().sort((a, b) => a.queue - b.queue);
923
+ }
924
+
925
+ const DEFAULT_FORWARD_PASS: MaterialPass = {
926
+ name: 'forward',
927
+ program: { module: 'forgeax::default-unlit', vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
928
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
929
+ };
930
+
931
+ function appendMaterialDispatchEntries(
932
+ pendingDispatch: DispatchEntry[],
933
+ passes: readonly MaterialPass[],
934
+ entity: EntityHandle,
935
+ materialHandle: number,
936
+ renderableIndex: number,
937
+ layer: number,
938
+ paramSnapshot: Readonly<Record<string, number | number[] | string>> | undefined,
939
+ passIndexOffset = 0,
940
+ ): void {
941
+ const matchedPasses = selectPasses(passes, {});
942
+ for (let pIdx = 0; pIdx < matchedPasses.length; pIdx++) {
943
+ const pass = matchedPasses[pIdx];
944
+ if (pass === undefined) continue;
945
+ const passState = (pass.renderState ?? {}) as MaterialRenderState & {
946
+ readonly tags?: Record<string, string>;
947
+ readonly queue?: number;
948
+ readonly stencilReference?: number;
949
+ };
950
+ pendingDispatch.push({
951
+ entityIndex: entity,
952
+ materialHandle,
953
+ renderableIndex,
954
+ passIndex: passIndexOffset + pIdx,
955
+ queue: passState.queue ?? 2000,
956
+ layer,
957
+ tags: passState.tags ?? {},
958
+ renderState: pipelineRenderState(passState),
959
+ // Material module identity is already closed in the cooked program.
960
+ // It must never become a draw-time define map.
961
+ defines: undefined,
962
+ vertexEntry: pass.program.vertexEntry,
963
+ fragmentEntry: pass.program.fragmentEntry,
964
+ materialShaderId: runtimeMaterialShaderId(pass.program.module, pass.name),
965
+ paramSnapshot,
966
+ ...(passState.stencilReference !== undefined && {
967
+ stencilReference: passState.stencilReference,
968
+ }),
969
+ });
970
+ }
971
+ }
972
+
973
+ export interface ExtractedFrame {
974
+ readonly cameras: CameraSnapshot[];
975
+ readonly lights: ExtractedLights;
976
+ readonly renderables: RenderableSnapshot[];
977
+ /**
978
+ * Single dispatch list sorted by queue value (ascending, stable sort).
979
+ * Replaces the old opaqueDispatch / transparentDispatch / overlayDispatch
980
+ * three-bucket model per plan-strategy D-3.
981
+ */
982
+ readonly dispatch: DispatchEntry[];
983
+ readonly skylight: SkylightSnapshot | undefined;
984
+ readonly skylightCount: number;
985
+ readonly skybox: SkyboxSnapshot | undefined;
986
+ readonly skyboxCount: number;
987
+ /**
988
+ * feat-20260528-frustum-culling M3 / w11: frustum culling statistics
989
+ * collected during the extract phase. `total` is the count of entities
990
+ * that reached the culling decision point; `culled` is the count of
991
+ * those that were removed from renderables by frustum culling.
992
+ */
993
+ readonly frustumStats: { readonly culled: number; readonly total: number };
994
+ /** Candidate entities rejected by author visibility before resource parsing. */
995
+ readonly visibilityStats: { readonly explicitlyHidden: number };
996
+ /**
997
+ * Per-frame post-process params snapshot collected from PostProcessParams
998
+ * entities (D-1: data-driven params channel). Maps shader id to the
999
+ * raw data bytes (Uint8Array = FieldValueType<'buffer'>). Last-one-wins
1000
+ * when multiple entities bear the same shader id.
1001
+ * Empty map when no PostProcessParams entities exist.
1002
+ */
1003
+ readonly postProcessParams: ReadonlyMap<string, Uint8Array>;
1004
+ /** Visibility snapshots prepared for each contributing World. */
1005
+ readonly visibilitySnapshots: readonly VisibilitySnapshot[];
1006
+ /** World-labelled snapshots passed to generic RenderFeature extraction. */
1007
+ readonly featureVisibilitySnapshots: readonly RenderFeatureWorldVisibilitySnapshot[];
1008
+ /** Built-in hidden candidates, retained for host-side producer deduplication. */
1009
+ readonly hiddenEntityReports: readonly RenderFeatureHiddenEntityReport[];
1010
+ }
1011
+
1012
+ /**
1013
+ * Internal world surface used by extract for archetype-graph traversal and
1014
+ * error routing. The packed `Entity` handle for a row is read directly from
1015
+ * the essential id=0 `Entity` column (`arch.columns.get(Entity.id).get('self')`)
1016
+ * -- no generation lookup / encodeEntity rebuild (feat-20260602 M2).
1017
+ */
1018
+ type RenderErrorContext = {
1019
+ readonly systemName: string;
1020
+ /** Retained as diagnostic metadata for legacy render call sites. */
1021
+ readonly severity?: 'error' | 'warning';
1022
+ };
1023
+
1024
+ type ArrayFieldView = ArrayLike<number>;
1025
+
1026
+ /**
1027
+ * Render's old zero-copy ECS calls now use the explicit public projection
1028
+ * boundary. The optimized ECS owner keeps storage behind `World`; render
1029
+ * receives a detached array snapshot and routes failures through the same
1030
+ * projection owner.
1031
+ */
1032
+ type WorldInternalView = {
1033
+ _routeError(error: unknown, ctx: RenderErrorContext): void;
1034
+ _getArrayView<N extends string, S extends ComponentSchema>(
1035
+ entity: EntityHandle,
1036
+ component: Component<N, S>,
1037
+ fieldName: string,
1038
+ ): ArrayFieldView | undefined;
1039
+ };
1040
+
1041
+ const Severity = Object.freeze({ Error: 'error', Warning: 'warning' } as const);
1042
+
1043
+ function createWorldInternalView(world: World): WorldInternalView {
1044
+ return {
1045
+ _routeError(error, ctx) {
1046
+ routeWorldError(world, error, { systemName: ctx.systemName });
1047
+ },
1048
+ _getArrayView(entity, component, fieldName) {
1049
+ return readRenderArrayView(world, entity, component, fieldName) as ArrayFieldView | undefined;
1050
+ },
1051
+ };
1052
+ }
1053
+
1054
+ /**
1055
+ * The user-region texture fields the built-in standard-PBR material declares.
1056
+ * Used as the fallback texture-field set when the shader id is not registered
1057
+ * (cross-worktree shader-late-register, plan R-4) so a built-in material still
1058
+ * resolves its user-region textures. Mirrors `derive(default-standard-pbr).textureFieldNames`.
1059
+ */
1060
+ const BUILTIN_USER_REGION_TEXTURE_FIELDS: readonly string[] = [
1061
+ 'baseColorTexture',
1062
+ 'metallicRoughnessTexture',
1063
+ 'normalTexture',
1064
+ 'specularTintTexture',
1065
+ ];
1066
+ const BUILTIN_USER_REGION_TEXTURE_FIELD_SET = new Set(BUILTIN_USER_REGION_TEXTURE_FIELDS);
1067
+ const BUILTIN_BASE_COLOR_TEXTURE_FIELD_SET = new Set(['baseColorTexture']);
1068
+
1069
+ function materialTextureFields(
1070
+ shaderId: string | undefined,
1071
+ fields: ReadonlySet<string> | undefined,
1072
+ ): ReadonlySet<string> | undefined {
1073
+ if (fields !== undefined && fields.size > 0) return fields;
1074
+ if (
1075
+ shaderId === 'forgeax::default-standard-pbr' ||
1076
+ shaderId === 'forgeax::default-standard-pbr-skin' ||
1077
+ shaderId === 'forgeax::pbr-skin'
1078
+ ) {
1079
+ return BUILTIN_USER_REGION_TEXTURE_FIELD_SET;
1080
+ }
1081
+ if (
1082
+ shaderId === 'forgeax::default-unlit' ||
1083
+ shaderId === 'forgeax::sprite' ||
1084
+ shaderId === 'forgeax::sprite-lit'
1085
+ ) {
1086
+ return BUILTIN_BASE_COLOR_TEXTURE_FIELD_SET;
1087
+ }
1088
+ return fields;
1089
+ }
1090
+
1091
+ /**
1092
+ * The authored material surface uses the module IDs declared by built-in WGSL
1093
+ * sources. Runtime pipeline caches still use the existing engine registration
1094
+ * IDs, so this small projection keeps authored identity and renderer lookup
1095
+ * identity in one place during the migration.
1096
+ */
1097
+ function runtimeMaterialShaderId(
1098
+ module: string | undefined,
1099
+ passName?: string,
1100
+ ): string | undefined {
1101
+ if (
1102
+ passName === 'shadow-caster' &&
1103
+ (module === 'forgeax_material::standard' ||
1104
+ module === 'forgeax_material::unlit' ||
1105
+ module === 'forgeax::default-standard-pbr' ||
1106
+ module === 'forgeax::default-unlit')
1107
+ ) {
1108
+ return 'forgeax::default-shadow-caster';
1109
+ }
1110
+ switch (module) {
1111
+ case 'forgeax_material::standard':
1112
+ return 'forgeax::default-standard-pbr';
1113
+ case 'forgeax_material::unlit':
1114
+ return 'forgeax::default-unlit';
1115
+ case 'forgeax_material::sprite':
1116
+ return 'forgeax::sprite';
1117
+ case 'forgeax_material::sprite-lit':
1118
+ return 'forgeax::sprite-lit';
1119
+ default:
1120
+ return module;
1121
+ }
1122
+ }
1123
+
1124
+ function pipelineRenderState(
1125
+ renderState:
1126
+ | (MaterialRenderState & {
1127
+ readonly tags?: Readonly<Record<string, string>>;
1128
+ readonly queue?: number;
1129
+ readonly stencilReference?: number;
1130
+ })
1131
+ | undefined,
1132
+ ): MaterialRenderState | undefined {
1133
+ if (renderState === undefined) return undefined;
1134
+ const {
1135
+ cullMode,
1136
+ depthCompare,
1137
+ depthWriteEnabled,
1138
+ blend,
1139
+ alphaToCoverageEnabled,
1140
+ stencil,
1141
+ stencilReadMask,
1142
+ stencilWriteMask,
1143
+ frontFace,
1144
+ } = renderState;
1145
+ if (
1146
+ cullMode === undefined &&
1147
+ depthCompare === undefined &&
1148
+ depthWriteEnabled === undefined &&
1149
+ blend === undefined &&
1150
+ alphaToCoverageEnabled === undefined &&
1151
+ stencil === undefined &&
1152
+ stencilReadMask === undefined &&
1153
+ stencilWriteMask === undefined &&
1154
+ frontFace === undefined
1155
+ ) {
1156
+ return undefined;
1157
+ }
1158
+ return {
1159
+ ...(cullMode !== undefined && { cullMode }),
1160
+ ...(depthCompare !== undefined && { depthCompare }),
1161
+ ...(depthWriteEnabled !== undefined && { depthWriteEnabled }),
1162
+ ...(blend !== undefined && { blend }),
1163
+ ...(alphaToCoverageEnabled !== undefined && { alphaToCoverageEnabled }),
1164
+ ...(stencil !== undefined && { stencil }),
1165
+ ...(stencilReadMask !== undefined && { stencilReadMask }),
1166
+ ...(stencilWriteMask !== undefined && { stencilWriteMask }),
1167
+ ...(frontFace !== undefined && { frontFace }),
1168
+ };
1169
+ }
1170
+
1171
+ /**
1172
+ * tweak-20260627-model-loading-smoke-build-perf M4: per-World intern cache for
1173
+ * the loadByGuid texture/sampler resolution path. A MaterialAsset's
1174
+ * texture/sampler values remain embedded GUID strings (dash-form) after
1175
+ * loadByGuid; the extract stage re-resolves each GUID to a column handle every
1176
+ * frame. Before this cache each resolution called `world.allocSharedRef`, which
1177
+ * mints a NEW monotonically-increasing slot id per call. Because the GPU
1178
+ * residency cache (`GpuResidencyCache.textureGpuHandles`) is keyed on
1179
+ * `handleSlot(handle)`, a fresh slot every frame meant the residency check
1180
+ * ALWAYS missed -> all textures re-uploaded to the GPU every frame, old GPU
1181
+ * textures never freed (refcount never hits 0). Unbounded GPU memory +
1182
+ * unbounded per-frame upload cost (628ms -> 2764ms over 12 frames, SIGKILL).
1183
+ *
1184
+ * The fix interns the GUID-string -> column-handle resolution: each unique
1185
+ * `(guid, brand)` pair mints EXACTLY ONE stable shared handle per World, reused
1186
+ * across frames (architecture-principle §6 idempotency: same GUID resolved N
1187
+ * times yields the same handle). The handle is intentionally long-lived -- it
1188
+ * lives as long as the World references the material, which is exactly the
1189
+ * desired lifetime; the `onLastRelease` -> `gpuStore.evictTexture` wiring stays
1190
+ * coherent because the handle is no longer churned per frame.
1191
+ *
1192
+ * Invariant boundary (asset-registry.ts:1958-1962): the AssetRegistry is a
1193
+ * GUID -> payload catalogue with NO handle/World concept -- it cannot mint a
1194
+ * column handle. So this intern cache lives in the extract/render layer, keyed
1195
+ * per-World via a WeakMap (the World owns the SharedRefStore that mints slots).
1196
+ *
1197
+ * Inner key is `${lowercasedGuid}\u0000${brand}` -- a GUID catalogues to a
1198
+ * single asset kind in practice, but the brand keeps the key correct if the
1199
+ * same GUID is ever resolved under two brands.
1200
+ *
1201
+ * Cache hits are validated against the registry's current payload identity.
1202
+ * Re-cataloguing the same GUID therefore retires the cache's allocation grant
1203
+ * and mints a handle for the replacement payload. An invalidated GUID retires
1204
+ * the entry immediately instead of keeping a stale payload alive.
1205
+ */
1206
+ interface GuidHandleInternEntry {
1207
+ readonly handle: number;
1208
+ readonly payload: Asset;
1209
+ }
1210
+
1211
+ const guidHandleInternByWorld = new WeakMap<World, Map<string, GuidHandleInternEntry>>();
1212
+
1213
+ function internSharedRefFromGuid<B extends string>(
1214
+ world: World,
1215
+ assetsRef: AssetRegistry,
1216
+ guid: string,
1217
+ brand: B,
1218
+ ): Handle<B, 'shared'> | undefined {
1219
+ let perWorld = guidHandleInternByWorld.get(world);
1220
+ if (perWorld === undefined) {
1221
+ perWorld = new Map<string, GuidHandleInternEntry>();
1222
+ guidHandleInternByWorld.set(world, perWorld);
1223
+ }
1224
+ const key = `${guid.toLowerCase()}\u0000${brand}`;
1225
+ const cached = perWorld.get(key);
1226
+ const payload = assetsRef.lookup(guid);
1227
+ if (cached !== undefined && cached.payload === payload) {
1228
+ const cachedHandle = toShared<B>(cached.handle);
1229
+ if (world.sharedRefs.resolve(cachedHandle).ok) return cachedHandle;
1230
+ }
1231
+ if (cached !== undefined) {
1232
+ world.sharedRefs.release(toShared<B>(cached.handle));
1233
+ perWorld.delete(key);
1234
+ }
1235
+ if (payload === undefined) return undefined;
1236
+ // Mint exactly once per (world, guid, brand). ECS owns the allocation grant
1237
+ // and publishes release evidence through SharedRefStore; render does not
1238
+ // attach a second deleter lifecycle to the World.
1239
+ const handle = world.internSharedRef(brand, payload);
1240
+ perWorld.set(key, { handle, payload });
1241
+ return handle;
1242
+ }
1243
+
1244
+ /**
1245
+ * feat-20260623-world-space-video-asset M4 / w14 (D-5): if a user-region
1246
+ * texture field's paramValue is an embedded GUID string that catalogues to a
1247
+ * VideoAsset (`kind === 'video'`), mint a `VideoAsset`-branded column handle for
1248
+ * it and return that handle; otherwise return undefined (the field is a static
1249
+ * texture / sampler / scalar and flows through the normal TextureAsset path).
1250
+ *
1251
+ * The video GUID occupies the same texture2d values slot a static texture
1252
+ * would (P4: identical binding shape) — extract just routes it to a different
1253
+ * GPU lifecycle (the transient DynamicTextureStore, D-3) instead of the static
1254
+ * `ensureResident` cache, whose switch has no `video` arm (AC-08). Minting a
1255
+ * brand-`VideoAsset` handle keeps the snapshot self-describing: the record stage
1256
+ * sees a video handle and resolves the per-frame view without reaching back into
1257
+ * the asset (charter P5).
1258
+ *
1259
+ * A `number` paramValue (already a minted column handle) is not a GUID string so
1260
+ * it cannot be a freshly-catalogued video; it passes through as undefined here.
1261
+ */
1262
+ function resolveVideoFieldHandle(
1263
+ value: unknown,
1264
+ world: World,
1265
+ assetsRef: AssetRegistry,
1266
+ ): Handle<'VideoAsset', 'shared'> | undefined {
1267
+ const texture = materialTextureValue(value);
1268
+ const textureGuid = assetReferenceText(texture?.texture ?? value);
1269
+ if (textureGuid === undefined) return undefined;
1270
+ const payload = assetsRef.lookup(textureGuid);
1271
+ if (payload === undefined || payload.kind !== 'video') return undefined;
1272
+ // M4: intern so a video GUID mints one stable VideoAsset handle per World
1273
+ // instead of a fresh slot every frame. The transient per-frame view is
1274
+ // resolved downstream by this handle (DynamicTextureStore); minting the
1275
+ // handle once does not freeze the view (P5: handle != frame data).
1276
+ return internSharedRefFromGuid(world, assetsRef, textureGuid, 'VideoAsset');
1277
+ }
1278
+
1279
+ function materialTextureValue(value: unknown): MaterialTextureValue | undefined {
1280
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
1281
+ const textureValue = value as Partial<MaterialTextureValue>;
1282
+ return typeof textureValue.texture === 'number' ||
1283
+ typeof textureValue.texture === 'string' ||
1284
+ textureValue.texture instanceof Uint8Array
1285
+ ? (textureValue as MaterialTextureValue)
1286
+ : undefined;
1287
+ }
1288
+
1289
+ function assetReferenceText(value: unknown): string | undefined {
1290
+ if (typeof value === 'string') return value;
1291
+ if (value instanceof Uint8Array && isAssetGuidBytes(value)) return AssetGuid.format(value);
1292
+ return undefined;
1293
+ }
1294
+
1295
+ function isAssetGuidBytes(value: Uint8Array): value is AssetGuidBytes {
1296
+ return value.byteLength === 16;
1297
+ }
1298
+
1299
+ function materialNormalScale(values: Readonly<Record<string, unknown>>): number {
1300
+ for (const [field, value] of Object.entries(values)) {
1301
+ if (field !== 'normalTexture') continue;
1302
+ const scale = materialTextureValue(value)?.normalScale;
1303
+ return typeof scale === 'number' && Number.isFinite(scale) ? scale : 1;
1304
+ }
1305
+ return 1;
1306
+ }
1307
+
1308
+ function materialTextureRef(value: unknown): unknown {
1309
+ return materialTextureValue(value)?.texture ?? value;
1310
+ }
1311
+
1312
+ function collectMaterialTextureCoordinates(
1313
+ values: Readonly<Record<string, unknown>>,
1314
+ ): Map<string, MaterialTextureCoordinates> {
1315
+ const out = new Map<string, MaterialTextureCoordinates>();
1316
+ for (const field of [
1317
+ 'baseColorTexture',
1318
+ 'metallicRoughnessTexture',
1319
+ 'normalTexture',
1320
+ 'specularTintTexture',
1321
+ 'emissiveTexture',
1322
+ 'occlusionTexture',
1323
+ ]) {
1324
+ const coordinates = materialTextureValue(values[field])?.coordinates;
1325
+ if (coordinates !== undefined) out.set(field, coordinates);
1326
+ }
1327
+ return out;
1328
+ }
1329
+
1330
+ function collectMaterialTextureSamplers(
1331
+ values: Readonly<Record<string, unknown>>,
1332
+ resolveSampler: (value: unknown) => Handle<'SamplerAsset', 'shared'> | undefined,
1333
+ ): Map<string, Handle<'SamplerAsset', 'shared'>> {
1334
+ const out = new Map<string, Handle<'SamplerAsset', 'shared'>>();
1335
+ for (const [field, value] of Object.entries(values)) {
1336
+ const sampler = resolveSampler(materialTextureValue(value)?.sampler);
1337
+ if (sampler !== undefined) out.set(field, sampler);
1338
+ }
1339
+ const legacySampler = resolveSampler(values.sampler);
1340
+ if (legacySampler !== undefined && !out.has('baseColorTexture')) {
1341
+ out.set('baseColorTexture', legacySampler);
1342
+ }
1343
+ return out;
1344
+ }
1345
+
1346
+ /**
1347
+ * feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): collect the
1348
+ * user-region texture handles for a material by iterating the shader's
1349
+ * `derive(paramSchema).textureFieldNames` SSOT (via
1350
+ * `AssetRegistry.materialShaderTextureFieldNames`). Each declared texture
1351
+ * field whose paramValue resolves to a handle lands in the returned map keyed
1352
+ * by field name; this is the single path through which an arbitrary number of user-region
1353
+ * textures (4 standard or 5+ custom, e.g. parallax `heightTexture`) flow.
1354
+ *
1355
+ * When the shader is not registered the built-in 4-field set is used so
1356
+ * standard materials still resolve. `emissiveTexture` / `occlusionTexture`
1357
+ * are engine-injection (lightmap) textures and are NOT part of this set.
1358
+ *
1359
+ * feat-20260623-world-space-video-asset M4 / w14 (D-5): a field whose paramValue
1360
+ * catalogues to a VideoAsset is routed into `videoOut` (a VideoAsset-branded
1361
+ * handle) instead of the TextureAsset map, so the record stage pulls its view
1362
+ * from the transient DynamicTextureStore (D-3) rather than the static
1363
+ * ensureResident cache (AC-08). The video GUID still has to be in this
1364
+ * `textureFieldNames` traversal set or it is never inspected (R-7) — that
1365
+ * membership is asserted in w13.
1366
+ */
1367
+ function collectUserRegionTextureHandles(
1368
+ pv: Readonly<Record<string, unknown>>,
1369
+ shaderId: string | undefined,
1370
+ paramSchema: readonly ParamSchemaEntry[] | undefined,
1371
+ assetsRef: AssetRegistry,
1372
+ world: World,
1373
+ resolveTex: (
1374
+ value: unknown,
1375
+ brand: 'TextureAsset',
1376
+ ) => Handle<'TextureAsset', 'shared'> | undefined,
1377
+ videoOut: Map<string, Handle<'VideoAsset', 'shared'>>,
1378
+ ): Map<string, Handle<'TextureAsset', 'shared'>> {
1379
+ const fields =
1380
+ materialTextureFields(
1381
+ shaderId,
1382
+ paramSchema !== undefined
1383
+ ? derive(paramSchema).textureFieldNames
1384
+ : shaderId !== undefined
1385
+ ? assetsRef.materialShaderTextureFieldNames(shaderId)
1386
+ : undefined,
1387
+ ) ?? BUILTIN_USER_REGION_TEXTURE_FIELDS;
1388
+ const out = new Map<string, Handle<'TextureAsset', 'shared'>>();
1389
+ for (const field of fields) {
1390
+ // D-5: a video-kind paramValue is routed to the transient path (videoOut),
1391
+ // NOT minted as a TextureAsset (which would crash the record-stage
1392
+ // ensureResident, AC-08). A static field falls through to resolveTex.
1393
+ const videoHandle = resolveVideoFieldHandle(pv[field], world, assetsRef);
1394
+ if (videoHandle !== undefined) {
1395
+ videoOut.set(field, videoHandle);
1396
+ continue;
1397
+ }
1398
+ const handle = resolveTex(materialTextureRef(pv[field]), 'TextureAsset');
1399
+ if (handle !== undefined) out.set(field, handle);
1400
+ }
1401
+ return out;
1402
+ }
1403
+
1404
+ const ENGINE_INJECTED_TEXTURE_FIELDS = new Set(['emissiveTexture', 'occlusionTexture']);
1405
+
1406
+ function isEngineInjectedTextureField(shaderId: string | undefined, fieldName: string): boolean {
1407
+ return isStandardPbrMaterialShader(shaderId) && ENGINE_INJECTED_TEXTURE_FIELDS.has(fieldName);
1408
+ }
1409
+
1410
+ function materialParametersToParamSchema(
1411
+ parameters: readonly MaterialParameter[],
1412
+ shaderId: string | undefined,
1413
+ ): readonly ParamSchemaEntry[] {
1414
+ const engineInjectedTextureFields = isStandardPbrMaterialShader(shaderId)
1415
+ ? ENGINE_INJECTED_TEXTURE_FIELDS
1416
+ : undefined;
1417
+ return parameters.flatMap((parameter): ParamSchemaEntry[] => {
1418
+ // Boolean material values are runtime-only and have no UBO representation.
1419
+ if (parameter.type === 'bool') return [];
1420
+ if (parameter.type === 'texture') {
1421
+ if (engineInjectedTextureFields?.has(parameter.name) === true) return [];
1422
+ return [{ name: parameter.name, type: 'texture2d' }];
1423
+ }
1424
+ return [
1425
+ {
1426
+ name: parameter.name,
1427
+ type: parameter.type,
1428
+ ...(parameter.colorSpace === undefined ? {} : { colorSpace: parameter.colorSpace }),
1429
+ },
1430
+ ];
1431
+ });
1432
+ }
1433
+
1434
+ /**
1435
+ * Resolve color semantics from the material asset first and the shader schema
1436
+ * for fields the asset does not redeclare. Generated and historical pack
1437
+ * materials commonly omit `parameters`, while the shader registry still owns
1438
+ * the complete parameter contract.
1439
+ */
1440
+ function materialColorParameterSchema(
1441
+ parameters: readonly MaterialParameter[],
1442
+ shaderId: string | undefined,
1443
+ assets: AssetRegistry,
1444
+ ): readonly MaterialColorParameterSchema[] {
1445
+ if (shaderId === undefined) return parameters;
1446
+ const shader = assets.shaderRegistry.findMaterialArtifact(shaderId);
1447
+ if (!shader.ok) return parameters;
1448
+
1449
+ const byName = new Map<string, MaterialColorParameterSchema>(
1450
+ shader.value.paramSchema.map((parameter) => [parameter.name, parameter]),
1451
+ );
1452
+ for (const parameter of parameters) byName.set(parameter.name, parameter);
1453
+ return [...byName.values()];
1454
+ }
1455
+
1456
+ const MATERIAL_PARENT_CHAIN_LIMIT = 128;
1457
+
1458
+ function isDeepFrozen(value: unknown, seen = new Set<object>()): boolean {
1459
+ if (typeof value !== 'object' || value === null) return true;
1460
+ if (seen.has(value)) return true;
1461
+ if (!Object.isFrozen(value)) return false;
1462
+ seen.add(value);
1463
+ for (const child of Object.values(value)) {
1464
+ if (!isDeepFrozen(child, seen)) return false;
1465
+ }
1466
+ return true;
1467
+ }
1468
+
1469
+ /**
1470
+ * Capture the object identities that make a resolved material stable. The
1471
+ * renderer owns this cache, but the AssetRegistry owns parent GUID lookup, so
1472
+ * validation must walk the same chain on every cross-frame read. A malformed
1473
+ * or cyclic chain is deliberately not cacheable.
1474
+ */
1475
+ function captureMaterialCacheChain(
1476
+ root: MaterialAsset,
1477
+ assets: AssetRegistry,
1478
+ ): readonly MaterialCacheChainIdentity[] | undefined {
1479
+ const chain: MaterialCacheChainIdentity[] = [];
1480
+ const visitedParentGuids = new Set<string>();
1481
+ let current = root;
1482
+ for (let depth = 0; depth < MATERIAL_PARENT_CHAIN_LIMIT; depth += 1) {
1483
+ const parentRef = current.parent;
1484
+ const parentGuid = parentRef === undefined ? undefined : materialGuidText(parentRef);
1485
+ chain.push({ asset: current, parentGuid });
1486
+ if (parentGuid === undefined) return chain;
1487
+ if (parentRef === undefined) return undefined;
1488
+ if (visitedParentGuids.has(parentGuid)) return undefined;
1489
+ visitedParentGuids.add(parentGuid);
1490
+ const parent = assets.lookup<Asset>(parentRef);
1491
+ if (parent?.kind !== 'material') return undefined;
1492
+ current = parent;
1493
+ }
1494
+ return undefined;
1495
+ }
1496
+
1497
+ function readPersistentMaterialSnapshot(
1498
+ cache: MaterialSnapshotCache | undefined,
1499
+ handleRaw: number,
1500
+ assets: AssetRegistry,
1501
+ ): MaterialSnapshotCacheEntry | undefined {
1502
+ const entry = cache?.get(handleRaw);
1503
+ if (entry === undefined) return undefined;
1504
+ if (
1505
+ entry.crossFrameSafe &&
1506
+ entry.chain !== undefined &&
1507
+ entry.catalogEpoch === assets.catalogEpoch
1508
+ )
1509
+ return entry;
1510
+ // The snapshot contains resolved texture and sampler handles as well as the
1511
+ // material parent chain. Any registry mutation can replace one of those
1512
+ // referenced payloads, so an epoch mismatch is a hard miss even when the
1513
+ // material objects themselves are unchanged.
1514
+ cache?.delete(handleRaw);
1515
+ return undefined;
1516
+ }
1517
+
1518
+ /**
1519
+ * Fast path for a cache entry whose chain was already validated at the
1520
+ * current registry epoch. The full reader needs the root asset only when an
1521
+ * epoch changed; asking ECS to resolve that root before checking the epoch
1522
+ * defeats the cross-frame cache on the ordinary stable frame.
1523
+ */
1524
+ function readStablePersistentMaterialSnapshot(
1525
+ cache: MaterialSnapshotCache | undefined,
1526
+ handleRaw: number,
1527
+ assets: AssetRegistry,
1528
+ ): MaterialSnapshotCacheEntry | undefined {
1529
+ const entry = cache?.get(handleRaw);
1530
+ return entry?.crossFrameSafe === true &&
1531
+ entry.chain !== undefined &&
1532
+ entry.catalogEpoch === assets.catalogEpoch
1533
+ ? entry
1534
+ : undefined;
1535
+ }
1536
+
1537
+ function storeMaterialSnapshot(
1538
+ cache: MaterialSnapshotCache,
1539
+ handleRaw: number,
1540
+ snapshot: MaterialSnapshot,
1541
+ passes: readonly MaterialPass[],
1542
+ root: MaterialAsset,
1543
+ assets: AssetRegistry,
1544
+ ): MaterialSnapshotCacheEntry {
1545
+ const chain = captureMaterialCacheChain(root, assets);
1546
+ const entry: MaterialSnapshotCacheEntry = {
1547
+ snapshot,
1548
+ passes,
1549
+ crossFrameSafe: chain?.every(({ asset }) => isDeepFrozen(asset)) === true,
1550
+ ...(chain === undefined ? {} : { chain }),
1551
+ ...(chain === undefined ? {} : { catalogEpoch: assets.catalogEpoch }),
1552
+ };
1553
+ cache.set(handleRaw, entry);
1554
+ return entry;
1555
+ }
1556
+
1557
+ /**
1558
+ * feat-20260608 M5 amend / w11-a: resolve a single MaterialAsset handle into
1559
+ * a per-submesh MaterialSnapshot. Used by the extractFrame archetype loop to
1560
+ * build `RenderableSnapshot.materials[]` for indices >= 1 (the entity-level
1561
+ * snapshot at index 0 is built inline because it also drives multi-pass
1562
+ * DispatchEntry creation + sprite-region overrides; per-submesh secondary
1563
+ * materials are non-sprite + single-pass-equivalent for the record stage's
1564
+ * UBO upload, so this helper produces a plain MaterialSnapshot only).
1565
+ *
1566
+ * Returns `defaultMaterialSnapshot()` (mid-grey unlit) for handle=0 (case-B
1567
+ * sentinel, mirroring the inline path) and on each unresolved / non-material
1568
+ * asset (so a partially-mis-registered multi-material entity still renders
1569
+ * the resolvable submeshes; the count-mismatch validator already filtered
1570
+ * the count-disagreement case earlier).
1571
+ */
1572
+ export function resolveMaterialSnapshot(
1573
+ handleRaw: number,
1574
+ world: World,
1575
+ assetsRef: AssetRegistry,
1576
+ materialSnapshotCache?: MaterialSnapshotCache,
1577
+ persistentMaterialSnapshotCache?: MaterialSnapshotCache,
1578
+ ): MaterialSnapshot {
1579
+ if (handleRaw === 0) return defaultMaterialSnapshot(handleRaw);
1580
+ const cached = materialSnapshotCache?.get(handleRaw);
1581
+ if (cached !== undefined) return cached.snapshot;
1582
+ const stablePersistentCached = readStablePersistentMaterialSnapshot(
1583
+ persistentMaterialSnapshotCache,
1584
+ handleRaw,
1585
+ assetsRef,
1586
+ );
1587
+ if (stablePersistentCached !== undefined) {
1588
+ materialSnapshotCache?.set(handleRaw, stablePersistentCached);
1589
+ return stablePersistentCached.snapshot;
1590
+ }
1591
+ const tagged = toShared<'MaterialAsset'>(handleRaw);
1592
+ const res = resolveAssetHandle(world, tagged);
1593
+ if (!res.ok) return defaultMaterialSnapshot(handleRaw);
1594
+ const asset = res.value;
1595
+ if (asset.kind !== 'material') return defaultMaterialSnapshot(handleRaw);
1596
+ const persistentCached = readPersistentMaterialSnapshot(
1597
+ persistentMaterialSnapshotCache,
1598
+ handleRaw,
1599
+ assetsRef,
1600
+ );
1601
+ if (persistentCached !== undefined) {
1602
+ materialSnapshotCache?.set(handleRaw, persistentCached);
1603
+ return persistentCached.snapshot;
1604
+ }
1605
+ const resolvedResult = walkMaterialPassesOverSharedRefs(world, tagged, assetsRef);
1606
+ if (!resolvedResult.ok) return defaultMaterialSnapshot(handleRaw);
1607
+ const resolved = resolvedResult.value;
1608
+ const allPasses = resolved.passes;
1609
+ const firstPassShader =
1610
+ allPasses.length > 0
1611
+ ? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
1612
+ : undefined;
1613
+ const pv = materialValuesToLinearRuntime(
1614
+ resolved.values,
1615
+ materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assetsRef),
1616
+ resolved.colorSpace,
1617
+ ) as Readonly<Record<string, unknown>>;
1618
+ const baseColorPv = pv.baseColor as readonly number[] | undefined;
1619
+ const baseColor = vec3.create(
1620
+ baseColorPv?.[0] ?? 1,
1621
+ baseColorPv?.[1] ?? 1,
1622
+ baseColorPv?.[2] ?? 1,
1623
+ );
1624
+ const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
1625
+ const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
1626
+ const clearcoatPv = typeof pv.clearcoat === 'number' ? pv.clearcoat : 0;
1627
+ const clearcoatRoughnessPv =
1628
+ typeof pv.clearcoatRoughness === 'number' ? pv.clearcoatRoughness : 0.5;
1629
+ const specularTintPv = pv.specularTint as readonly number[] | undefined;
1630
+ const normalScalePv = materialNormalScale(pv);
1631
+
1632
+ const paramSnap: Record<string, number | number[] | string> = {};
1633
+ for (const [k, v] of Object.entries(pv)) {
1634
+ if (typeof v === 'number') paramSnap[k] = v;
1635
+ else if (typeof v === 'string') paramSnap[k] = v;
1636
+ else if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
1637
+ paramSnap[k] = v as number[];
1638
+ }
1639
+ }
1640
+ const materialParamSchema = materialParametersToParamSchema(
1641
+ resolved.parameters ?? [],
1642
+ firstPassShader,
1643
+ );
1644
+ // feat-20260614 M8 (D-19): texture / sampler values are embedded GUIDs
1645
+ // (dash-form strings) after loadByGuid. Resolve each to a user-tier column
1646
+ // handle by looking up the catalogued payload and minting via
1647
+ // world.allocSharedRef; a numeric value (already a column handle from a
1648
+ // directly-minted material) passes through unchanged.
1649
+ const resolveTexLike = <B extends string>(
1650
+ value: unknown,
1651
+ brand: B,
1652
+ ): Handle<B, 'shared'> | undefined => {
1653
+ if (typeof value === 'number') return toShared<B>(value);
1654
+ if (typeof value === 'string') {
1655
+ // M4: intern the GUID -> column-handle resolution so each unique
1656
+ // (world, guid, brand) mints exactly ONE stable handle reused across
1657
+ // frames (stops the per-frame slot churn that defeated the GPU
1658
+ // residency cache). feat-20260619 M2 / w8: TextureAsset brand wires
1659
+ // onLastRelease -> gpuStore.evictTexture; other brands (SamplerAsset)
1660
+ // bypass (releaseUnreferenced-fallback lifecycle).
1661
+ return internSharedRefFromGuid(world, assetsRef, value, brand);
1662
+ }
1663
+ return undefined;
1664
+ };
1665
+ // feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): iterate the
1666
+ // shader's derive(paramSchema).textureFieldNames SSOT instead of a hardcoded
1667
+ // user-region field list, so an Nth texture (e.g. parallax heightTexture)
1668
+ // resolves through the same path. emissive/occlusion are engine-injection
1669
+ // (lightmap) textures, NOT in textureFieldNames, so they keep named reads.
1670
+ const videoTextureFields = new Map<string, Handle<'VideoAsset', 'shared'>>();
1671
+ const textureHandles = collectUserRegionTextureHandles(
1672
+ pv,
1673
+ firstPassShader,
1674
+ materialParamSchema.length > 0 ? materialParamSchema : undefined,
1675
+ assetsRef,
1676
+ world,
1677
+ resolveTexLike,
1678
+ videoTextureFields,
1679
+ );
1680
+ const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
1681
+ resolveTexLike(materialTextureRef(value), 'SamplerAsset'),
1682
+ );
1683
+ const emissiveTextureHandle = resolveTexLike(
1684
+ materialTextureRef(pv.emissiveTexture),
1685
+ 'TextureAsset',
1686
+ );
1687
+ const occlusionTextureHandle = resolveTexLike(
1688
+ materialTextureRef(pv.occlusionTexture),
1689
+ 'TextureAsset',
1690
+ );
1691
+ const textureCoordinates = collectMaterialTextureCoordinates(pv);
1692
+ // Named user-region fields are derived from the map (sprite + legacy reads).
1693
+ const baseColorTextureHandle = textureHandles.get('baseColorTexture');
1694
+ const metallicRoughnessTextureHandle = textureHandles.get('metallicRoughnessTexture');
1695
+ const normalTextureHandle = textureHandles.get('normalTexture');
1696
+ const emissivePv = pv.emissive as readonly number[] | undefined;
1697
+ const snapshot: MaterialSnapshot = {
1698
+ baseColor,
1699
+ metallic: metallicPv,
1700
+ roughness: roughnessPv,
1701
+ clearcoat: clearcoatPv,
1702
+ clearcoatRoughness: clearcoatRoughnessPv,
1703
+ ...(specularTintPv !== undefined && {
1704
+ specularTint: [
1705
+ specularTintPv[0] ?? 1,
1706
+ specularTintPv[1] ?? 1,
1707
+ specularTintPv[2] ?? 1,
1708
+ ] as readonly [number, number, number],
1709
+ }),
1710
+ normalScale: normalScalePv,
1711
+ materialShaderId: firstPassShader,
1712
+ materialHandle: handleRaw,
1713
+ renderState: pipelineRenderState(allPasses[0]?.renderState),
1714
+ paramSnapshot: paramSnap,
1715
+ ...(materialParamSchema.length > 0 && { materialParamSchema }),
1716
+ ...(textureCoordinates.size > 0 && { textureCoordinates }),
1717
+ ...(textureHandles.size > 0 && { textureHandles }),
1718
+ ...(videoTextureFields.size > 0 && { videoTextureFields }),
1719
+ ...(samplerHandles.size > 0 && { samplerHandles }),
1720
+ ...(baseColorTextureHandle !== undefined && { baseColorTexture: baseColorTextureHandle }),
1721
+ ...(metallicRoughnessTextureHandle !== undefined && {
1722
+ metallicRoughnessTexture: metallicRoughnessTextureHandle,
1723
+ }),
1724
+ ...(normalTextureHandle !== undefined && { normalTexture: normalTextureHandle }),
1725
+ ...(emissivePv !== undefined && {
1726
+ emissive: [emissivePv[0] ?? 0, emissivePv[1] ?? 0, emissivePv[2] ?? 0] as readonly [
1727
+ number,
1728
+ number,
1729
+ number,
1730
+ ],
1731
+ }),
1732
+ ...(typeof pv.emissiveIntensity === 'number' && { emissiveIntensity: pv.emissiveIntensity }),
1733
+ ...(emissiveTextureHandle !== undefined && { emissiveTexture: emissiveTextureHandle }),
1734
+ ...(occlusionTextureHandle !== undefined && { occlusionTexture: occlusionTextureHandle }),
1735
+ ...(typeof pv.occlusionStrength === 'number' && { occlusionStrength: pv.occlusionStrength }),
1736
+ // feat-city-glb Bug 5 (per-submesh transparency): derive `transparent`
1737
+ // from the first pass's `renderState.blend` presence, identical to the
1738
+ // entity-level snapshot builder (extractFrame archetype loop). Without
1739
+ // this, per-submesh materials (materials[i>=1], e.g. a glTF BLEND decal
1740
+ // submesh on a multi-material mesh) never carry the transparent flag, so
1741
+ // the record stage's LDR split + blend routing treats them as opaque and
1742
+ // their alpha=0 texels composite as black.
1743
+ transparent: allPasses[0]?.renderState?.blend !== undefined,
1744
+ };
1745
+ const stored =
1746
+ materialSnapshotCache !== undefined
1747
+ ? storeMaterialSnapshot(
1748
+ materialSnapshotCache,
1749
+ handleRaw,
1750
+ snapshot,
1751
+ allPasses,
1752
+ asset,
1753
+ assetsRef,
1754
+ )
1755
+ : undefined;
1756
+ const cacheEntry =
1757
+ stored ??
1758
+ (persistentMaterialSnapshotCache !== undefined
1759
+ ? storeMaterialSnapshot(
1760
+ persistentMaterialSnapshotCache,
1761
+ handleRaw,
1762
+ snapshot,
1763
+ allPasses,
1764
+ asset,
1765
+ assetsRef,
1766
+ )
1767
+ : undefined);
1768
+ if (cacheEntry?.crossFrameSafe === true) {
1769
+ persistentMaterialSnapshotCache?.set(handleRaw, cacheEntry);
1770
+ }
1771
+ return snapshot;
1772
+ }
1773
+
1774
+ /**
1775
+ * feat-20260612-skin-palette-per-frame-upload M2 / m2-6: narrow per-frame
1776
+ * pipeline surface consumed by `extractFrame`. Today only `skinPaletteAllocator`
1777
+ * crosses the seam (the allocator owns the per-frame palette buffer + cursor;
1778
+ * extract calls `resetForFrame` at frame entry then `allocateSlice` +
1779
+ * `writeJointPalette` per skinned entity). Kept narrow on purpose — extract
1780
+ * does NOT see the rest of `PipelineState` to avoid a circular import via
1781
+ * `render-system.ts`.
1782
+ */
1783
+ export interface ExtractPipelineSurface {
1784
+ readonly skinPaletteAllocator: SkinPaletteAllocator | null;
1785
+ }
1786
+
1787
+ /**
1788
+ * PSSM (Parallel-Split Shadow Maps) split plane computation.
1789
+ *
1790
+ * Formula (plan-strategy D-8, research F1):
1791
+ * C_i = λ·n·(f/n)^(i/m) + (1-λ)·(n + i/m·(f-n))
1792
+ * where i = 1..m, n = near, f = far, m = cascadeCount
1793
+ *
1794
+ * The coverage range is [camera near, DirectionalLight.shadowDistance] — the
1795
+ * near end derives from the active camera, the far end is the shadowDistance
1796
+ * knob. Returns strictly monotonic view-space z depths (positive).
1797
+ *
1798
+ * When `far <= near + ε` (ε=1e-6), throws ShadowInvalidConfigError
1799
+ * (charter P3 explicit failure — research F2 guarantees formula stability
1800
+ * but degenerate configuration must be surfaced to the caller).
1801
+ *
1802
+ * @param nearPlane - near of the shadow casting range (from the camera near)
1803
+ * @param farPlane - far of the shadow casting range (DirectionalLight.shadowDistance)
1804
+ * @param cascadeCount - number of cascades (1..4)
1805
+ * @param splitLambda - PSSM split weight (0 = pure uniform, 1 = pure log)
1806
+ * @returns Array of m split depths (view-space z); length = cascadeCount,
1807
+ * guaranteed strictly monotonic, last element = far.
1808
+ *
1809
+ * @internal exported for w6/w7 testing only; production callers route through
1810
+ * extractFrame which validates cascadeCount/splitLambda via component schema.
1811
+ */
1812
+ /**
1813
+ * Compute the 8 world-space corner points of a camera frustum slice.
1814
+ *
1815
+ * Uses mat4.unproject to map NDC cube corners back to world space.
1816
+ * The slice is defined by view-space nearZ and farZ depths.
1817
+ *
1818
+ * For a WebGPU perspective matrix, ndcZ(viewZ) = camFar * (viewZ - camNear) /
1819
+ * (viewZ * (camFar - camNear)). We compute the ndcZ for each slice boundary
1820
+ * and unproject directly.
1821
+ *
1822
+ * @param vp - camera view-projection matrix (column-major 16 floats)
1823
+ * @param camNear - camera near plane (used to derive NDC mapping)
1824
+ * @param camFar - camera far plane (used to derive NDC mapping)
1825
+ * @param nearZ - near depth of the frustum slice (view-space, positive)
1826
+ * @param farZ - far depth of the frustum slice (view-space, positive)
1827
+ * @returns Array of 8 Vec3 world-space corner positions.
1828
+ */
1829
+ function computeFrustumCorners(
1830
+ vp: Mat4,
1831
+ camNear: number,
1832
+ camFar: number,
1833
+ nearZ: number,
1834
+ farZ: number,
1835
+ projection: 'perspective' | 'orthographic',
1836
+ ): Vec3[] {
1837
+ const invVP = mat4.create();
1838
+ mat4.invert(invVP, vp);
1839
+ const corners: Vec3[] = [];
1840
+
1841
+ // NDC z mapping is projection-dependent (WebGPU clip-space z in [0,1]):
1842
+ // perspective: ndc(z) = camFar * (z - camNear) / (z * (camFar - camNear))
1843
+ // orthographic: ndc(z) = (z - camNear) / (camFar - camNear)
1844
+ // feat-20260613-csm M6 / w22: orthographic cameras silently produced
1845
+ // garbage NDC z (perspective formula divides by viewZ, but ortho NDC is
1846
+ // linear), which mapped the cascade slab back to a degenerate world-space
1847
+ // corner set and the AABB-fit collapsed to near zero -- shadow_caster
1848
+ // wrote its triangles outside the [-1,1] clip volume so the depth
1849
+ // attachment stayed at clear=1.0 (root cause for shadow-m2 / shadow-m3 /
1850
+ // shadow-opt-out dawn red surfaced after w20's host-side ortho fix).
1851
+ const span = camFar - camNear;
1852
+ const ndcNear =
1853
+ projection === 'orthographic'
1854
+ ? (nearZ - camNear) / span
1855
+ : (camFar * (nearZ - camNear)) / (nearZ * span);
1856
+ const ndcFar =
1857
+ projection === 'orthographic'
1858
+ ? (farZ - camNear) / span
1859
+ : (camFar * (farZ - camNear)) / (farZ * span);
1860
+
1861
+ const signs = [-1, 1];
1862
+ for (const sx of signs) {
1863
+ for (const sy of signs) {
1864
+ corners.push(unprojectNDC(invVP, sx, sy, ndcNear));
1865
+ }
1866
+ }
1867
+ for (const sx of signs) {
1868
+ for (const sy of signs) {
1869
+ corners.push(unprojectNDC(invVP, sx, sy, ndcFar));
1870
+ }
1871
+ }
1872
+
1873
+ return corners;
1874
+ }
1875
+
1876
+ /**
1877
+ * Unproject a single NDC point to world space.
1878
+ */
1879
+ function unprojectNDC(invVP: Mat4, ndcX: number, ndcY: number, ndcZ: number): Vec3 {
1880
+ const ndc = vec3.create(ndcX, ndcY, ndcZ);
1881
+ const ws = vec3.create();
1882
+ mat4.unproject(ws, ndc, invVP);
1883
+ return ws;
1884
+ }
1885
+
1886
+ export function pssmSplit(
1887
+ nearPlane: number,
1888
+ farPlane: number,
1889
+ cascadeCount: number,
1890
+ splitLambda: number,
1891
+ ): Float32Array {
1892
+ const EPS = 1e-6;
1893
+ if (farPlane <= nearPlane + EPS) {
1894
+ throw new ShadowInvalidConfigError('shadowDistance', farPlane, nearPlane + EPS);
1895
+ }
1896
+
1897
+ const result = new Float32Array(cascadeCount);
1898
+ const m = cascadeCount;
1899
+ const n = nearPlane;
1900
+ const f = farPlane;
1901
+ const ratio = f / n;
1902
+
1903
+ for (let i = 1; i <= m; i++) {
1904
+ const t = i / m;
1905
+ const logPart = n * ratio ** t;
1906
+ const uniformPart = n + t * (f - n);
1907
+ result[i - 1] = splitLambda * logPart + (1 - splitLambda) * uniformPart;
1908
+ }
1909
+
1910
+ return result;
1911
+ }
1912
+
1913
+ // feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-5 (plan-strategy §D-3,
1914
+ // requirements §5.3): 6-face view-proj matrix table for one omnidirectional
1915
+ // shadow caster.
1916
+ //
1917
+ // Face order matches WebGPU cube layer convention (also LearnOpenGL 5.3.2):
1918
+ // 0 (+X) | look=(+1, 0, 0) | up=(0, -1, 0)
1919
+ // 1 (-X) | look=(-1, 0, 0) | up=(0, -1, 0)
1920
+ // 2 (+Y) | look=(0, +1, 0) | up=(0, 0, +1)
1921
+ // 3 (-Y) | look=(0, -1, 0) | up=(0, 0, -1)
1922
+ // 4 (+Z) | look=(0, 0, +1) | up=(0, -1, 0)
1923
+ // 5 (-Z) | look=(0, 0, -1) | up=(0, -1, 0)
1924
+ //
1925
+ // Projection: WebGPU [0, 1] NDC perspective (mat4.perspective short name).
1926
+ // fov=90deg, aspect=1, near/far from PointLightShadow component.
1927
+ const POINT_SHADOW_FACE_LOOK: readonly Vec3[] = [
1928
+ vec3.create(1, 0, 0),
1929
+ vec3.create(-1, 0, 0),
1930
+ vec3.create(0, 1, 0),
1931
+ vec3.create(0, -1, 0),
1932
+ vec3.create(0, 0, 1),
1933
+ vec3.create(0, 0, -1),
1934
+ ];
1935
+ const POINT_SHADOW_FACE_UP: readonly Vec3[] = [
1936
+ vec3.create(0, -1, 0),
1937
+ vec3.create(0, -1, 0),
1938
+ vec3.create(0, 0, 1),
1939
+ vec3.create(0, 0, -1),
1940
+ vec3.create(0, -1, 0),
1941
+ vec3.create(0, -1, 0),
1942
+ ];
1943
+
1944
+ /**
1945
+ * Build 6 face view-proj matrices for an omnidirectional shadow caster
1946
+ * (point-light cube map). Returns 6 mat4 (column-major 16-float each) in face
1947
+ * order [+X, -X, +Y, -Y, +Z, -Z] matching WebGPU cube layer convention.
1948
+ *
1949
+ * Each matrix is `proj * view` where:
1950
+ * - `view = lookAt(lightPos, lightPos + faceLook[i], faceUp[i])`
1951
+ * - `proj = perspective(PI/2, 1, near, far)` (WebGPU [0, 1] NDC short name)
1952
+ *
1953
+ * The returned `Mat4[]` length is always exactly 6. Caller can flatten into a
1954
+ * 96-float (6 mat4) Float32Array for UBO upload (T-M1-7).
1955
+ *
1956
+ * @param lightPos world-space position of the point light (from companion Transform)
1957
+ * @param near near plane distance (PointLightShadow.nearPlane)
1958
+ * @param far far plane distance (PointLightShadow.farPlane)
1959
+ */
1960
+ export function buildPointShadowMatrices(lightPos: Vec3, near: number, far: number): Mat4[] {
1961
+ const fovY = Math.PI / 2; // 90 deg
1962
+ const aspect = 1;
1963
+ const proj = mat4.create();
1964
+ mat4.perspective(proj, fovY, aspect, near, far);
1965
+
1966
+ const out: Mat4[] = [];
1967
+ for (let i = 0; i < 6; i++) {
1968
+ // biome-ignore lint/style/noNonNullAssertion: i in [0..6) and arrays are length-6 const
1969
+ const look = POINT_SHADOW_FACE_LOOK[i]!;
1970
+ // biome-ignore lint/style/noNonNullAssertion: i in [0..6) and arrays are length-6 const
1971
+ const up = POINT_SHADOW_FACE_UP[i]!;
1972
+ const target = vec3.create(
1973
+ (lightPos[0] ?? 0) + (look[0] ?? 0),
1974
+ (lightPos[1] ?? 0) + (look[1] ?? 0),
1975
+ (lightPos[2] ?? 0) + (look[2] ?? 0),
1976
+ );
1977
+ const view = mat4.create();
1978
+ mat4.lookAt(view, lightPos, target, up);
1979
+ const vp = mat4.create();
1980
+ mat4.multiply(vp, proj, view);
1981
+ out.push(vp);
1982
+ }
1983
+ return out;
1984
+ }
1985
+
1986
+ /**
1987
+ * bug-20260710-editor-cross-world-shadow: the camera-frustum subset of
1988
+ * {@link CameraSnapshot} that the directional-CSM matrix builder consumes.
1989
+ * Extracted so the builder can run both inside {@link extractFrame} (single
1990
+ * world) AND at the {@link extractFrames} merge layer, where the camera and the
1991
+ * directional light may originate in DIFFERENT worlds (the editor
1992
+ * editorWorld/sceneWorld super-composite). A cameraless world produces zero
1993
+ * lightViewProj matrices, so the merge layer MUST recompute using the surfaced
1994
+ * camera — see {@link computeDirectionalCsm}.
1995
+ */
1996
+ export interface CsmCameraData {
1997
+ readonly world: Float32Array;
1998
+ readonly fov: number;
1999
+ readonly aspect: number;
2000
+ readonly near: number;
2001
+ readonly far: number;
2002
+ readonly projection: 'perspective' | 'orthographic';
2003
+ readonly orthoLeft: number;
2004
+ readonly orthoRight: number;
2005
+ readonly orthoBottom: number;
2006
+ readonly orthoTop: number;
2007
+ }
2008
+
2009
+ /**
2010
+ * bug-20260710-editor-cross-world-shadow: the raw directional-shadow config a
2011
+ * DirectionalLight carries, threaded onto {@link ExtractedLights} so the merge
2012
+ * layer can recompute CSM matrices against the surfaced camera. Distinct from
2013
+ * the *computed* outputs (lightViewProj / splitPlanes) also on ExtractedLights.
2014
+ */
2015
+ export interface DirectionalCsmConfig {
2016
+ readonly cascadeCount: number;
2017
+ readonly splitLambda: number;
2018
+ readonly cascadeBlend: number;
2019
+ readonly mapSize: number;
2020
+ readonly shadowDistance: number;
2021
+ }
2022
+
2023
+ /**
2024
+ * bug-20260710-editor-cross-world-shadow: computed directional-CSM output —
2025
+ * the per-cascade light-view-projection matrices plus the derived cascade
2026
+ * metadata. Returned by {@link computeDirectionalCsm}; `undefined` when the
2027
+ * config or camera is missing (caller leaves the fields undefined).
2028
+ */
2029
+ export interface DirectionalCsmResult {
2030
+ readonly lightViewProj: Float32Array[];
2031
+ readonly splitPlanes: Float32Array;
2032
+ readonly cascadeCount: number;
2033
+ readonly cascadeBlend: number;
2034
+ readonly shadowMapSize: number;
2035
+ }
2036
+
2037
+ /**
2038
+ * bug-20260710-editor-cross-world-shadow: pure directional-CSM matrix builder,
2039
+ * extracted verbatim from the former inline block in {@link extractFrame} (the
2040
+ * PSSM split + per-cascade frustum-slice AABB fit + orthographic light
2041
+ * projection). It is now a free function so BOTH the per-world extract and the
2042
+ * cross-world merge layer can call it — the merge layer is the only place that
2043
+ * pairs the first-hit directional light of one world with the surfaced camera
2044
+ * of another (editor super-composite). Returns `null` when there is no camera
2045
+ * to fit against (a cameraless world's directional light yields no matrices —
2046
+ * the caller leaves lightViewProj undefined rather than emitting zero matrices
2047
+ * that the WGSL reader would sample as NaN → "fully lit").
2048
+ *
2049
+ * Byte-identical to the prior inline computation for the single-world case
2050
+ * (same PSSM lambda, same toward-light Z reach RC-2 fix, same clip-space
2051
+ * matrix / tile-placement-in-shader split).
2052
+ */
2053
+ export function computeDirectionalCsm(
2054
+ direction: Vec3,
2055
+ config: DirectionalCsmConfig,
2056
+ cameraData: CsmCameraData | undefined,
2057
+ ): DirectionalCsmResult | null {
2058
+ const cascadeCount = Math.round(config.cascadeCount);
2059
+ // Coverage range: near from the active camera near (no separate near knob);
2060
+ // far is the component's shadowDistance. Fallback near (0.1) only when no
2061
+ // camera exists — but matrices are gated on cameraData below anyway.
2062
+ const sNear = cameraData?.near ?? 0.1;
2063
+ const sFar = config.shadowDistance;
2064
+
2065
+ // PSSM split planes: [camera near, shadowDistance], not the camera far.
2066
+ const splits = pssmSplit(sNear, sFar, cascadeCount, config.splitLambda);
2067
+ const paddedSplits = new Float32Array(4);
2068
+ for (let i = 0; i < splits.length; i++) paddedSplits[i] = splits[i] ?? 0;
2069
+
2070
+ // Light view matrix: camera at origin, looking along the light direction.
2071
+ const lightDirN = vec3.normalize(vec3.create(), direction);
2072
+ const lightTarget = vec3.create(lightDirN[0] ?? 0, lightDirN[1] ?? 0, lightDirN[2] ?? 0);
2073
+ const lightView = mat4.create();
2074
+ mat4.lookAt(lightView, vec3.create(0, 0, 0), lightTarget, vec3.create(0, 1, 0));
2075
+
2076
+ // Camera view-projection for frustum-corner computation. Without a camera the
2077
+ // frustum cannot be fit — bail so the caller emits no matrices (undefined),
2078
+ // NOT zero matrices (which read as NaN in the shadow shader → no shadow).
2079
+ if (cameraData === undefined) return null;
2080
+ const camProj = mat4.create();
2081
+ if (cameraData.projection === 'orthographic') {
2082
+ mat4.orthographic(
2083
+ camProj,
2084
+ cameraData.orthoLeft,
2085
+ cameraData.orthoRight,
2086
+ cameraData.orthoBottom,
2087
+ cameraData.orthoTop,
2088
+ cameraData.near,
2089
+ cameraData.far,
2090
+ );
2091
+ } else {
2092
+ mat4.perspective(camProj, cameraData.fov, cameraData.aspect, cameraData.near, cameraData.far);
2093
+ }
2094
+ const camView = mat4.create();
2095
+ mat4.invert(camView, cameraData.world);
2096
+ const cameraVP = mat4.create();
2097
+ mat4.multiply(cameraVP, camProj, camView);
2098
+
2099
+ const resultLightViewProjs: Float32Array[] = [];
2100
+
2101
+ // bug-20260619 RC-2 (AC-05): toward-light Z reach. Extend the near (toward-
2102
+ // light) bound of EVERY cascade to the toward-light extreme of the WHOLE
2103
+ // shadow frustum (sNear..sFar) so casters between the light and a slice are
2104
+ // admitted; X/Y stays per-cascade tight. Larger light-space z == closer to
2105
+ // the light (lookAt forward = eye-target), so the full-frustum max-z is the
2106
+ // toward-light reach used as -maxZ (the ortho near plane) per cascade.
2107
+ let lightSpaceMaxZFull = -Infinity;
2108
+ const fullCorners = computeFrustumCorners(
2109
+ cameraVP,
2110
+ cameraData.near,
2111
+ cameraData.far,
2112
+ sNear,
2113
+ sFar,
2114
+ cameraData.projection,
2115
+ );
2116
+ for (const ws of fullCorners) {
2117
+ const ls = vec3.create();
2118
+ mat4.transformVec3(ls, lightView, ws);
2119
+ if ((ls[2] ?? 0) > lightSpaceMaxZFull) lightSpaceMaxZFull = ls[2] ?? 0;
2120
+ }
2121
+
2122
+ // Pre-allocate 4-cascade array; fill in the effective cascades.
2123
+ for (let cIdx = 0; cIdx < 4; cIdx++) {
2124
+ if (cIdx >= cascadeCount) {
2125
+ resultLightViewProjs.push(new Float32Array(16));
2126
+ continue;
2127
+ }
2128
+ const cascadeNear = cIdx === 0 ? sNear : (splits[cIdx - 1] ?? sFar);
2129
+ const cascadeFar = splits[cIdx] ?? sFar;
2130
+ const corners = computeFrustumCorners(
2131
+ cameraVP,
2132
+ cameraData.near,
2133
+ cameraData.far,
2134
+ cascadeNear,
2135
+ cascadeFar,
2136
+ cameraData.projection,
2137
+ );
2138
+ const lightMVP = mat4.clone(lightView);
2139
+ let minX = Infinity;
2140
+ let maxX = -Infinity;
2141
+ let minY = Infinity;
2142
+ let maxY = -Infinity;
2143
+ let minZ = Infinity;
2144
+ let maxZ = -Infinity;
2145
+ for (const ws of corners) {
2146
+ const ls = vec3.create();
2147
+ mat4.transformVec3(ls, lightMVP, ws);
2148
+ if ((ls[0] ?? 0) < minX) minX = ls[0] ?? 0;
2149
+ if ((ls[0] ?? 0) > maxX) maxX = ls[0] ?? 0;
2150
+ if ((ls[1] ?? 0) < minY) minY = ls[1] ?? 0;
2151
+ if ((ls[1] ?? 0) > maxY) maxY = ls[1] ?? 0;
2152
+ if ((ls[2] ?? 0) < minZ) minZ = ls[2] ?? 0;
2153
+ if ((ls[2] ?? 0) > maxZ) maxZ = ls[2] ?? 0;
2154
+ }
2155
+ // Orthographic projection from light-space AABB. RC-2: whole-frustum
2156
+ // toward-light extreme for the near bound; per-cascade far (minZ) + X/Y.
2157
+ const nearZ = Math.max(maxZ, lightSpaceMaxZFull);
2158
+ const orthoProj = mat4.create();
2159
+ mat4.orthographic(orthoProj, minX, maxX, minY, maxY, -nearZ, -minZ);
2160
+ // lightViewProj = orthoProj * lightView — pure clip-space [-1,1]. Atlas
2161
+ // tile placement is handled by the per-cascade viewport + fragment-side UV
2162
+ // math (evalDirectional), so shadow_caster.gl_Position stays clip-space.
2163
+ void cIdx;
2164
+ resultLightViewProjs.push(new Float32Array(mat4.multiply(mat4.create(), orthoProj, lightView)));
2165
+ }
2166
+
2167
+ return {
2168
+ lightViewProj: resultLightViewProjs,
2169
+ splitPlanes: paddedSplits,
2170
+ cascadeCount,
2171
+ cascadeBlend: config.cascadeBlend,
2172
+ shadowMapSize: config.mapSize,
2173
+ };
2174
+ }
2175
+
2176
+ /**
2177
+ * feat-20260709-editor-world-partition M1 / w4 (AC-08, plan-strategy §2 D-3):
2178
+ * the owner index that previously served BOTH cameras and singleton render
2179
+ * resources (skylight / skybox / postProcessParams) is split into two
2180
+ * independent indices. `cameraOwner` selects the world whose cameras are
2181
+ * surfaced; `resourceOwner` selects the world whose singleton resources are
2182
+ * surfaced. A single number is accepted as the backward-compatible legacy form
2183
+ * where `cameraOwner === resourceOwner === owner` (single-world callers +
2184
+ * frame-loop stay byte-identical; the hard `{ owner }` cutover lands in M2).
2185
+ */
2186
+ export interface ExtractFramesOwner {
2187
+ readonly cameraOwner: number;
2188
+ readonly resourceOwner: number;
2189
+ }
2190
+
2191
+ export interface PreparedExtractContext {
2192
+ readonly assets: AssetRegistry | null | undefined;
2193
+ readonly pipelineState: ExtractPipelineSurface | null | undefined;
2194
+ readonly materialSnapshotCache: MaterialSnapshotCache | undefined;
2195
+ readonly cull: 'self' | 'none' | 'external';
2196
+ readonly cullCameras: readonly CameraSnapshot[] | undefined;
2197
+ readonly hierarchy: SceneHierarchySnapshot;
2198
+ readonly visibility: VisibilitySnapshot;
2199
+ }
2200
+
2201
+ export function extractCameraSnapshots(world: World): CameraSnapshot[] {
2202
+ const worldInternal = createWorldInternalView(world);
2203
+ const cameras: CameraSnapshot[] = [];
2204
+ const cameraEntities: number[] = [];
2205
+ const cameraQuery = world.query({ read: [Camera], with: [Transform] }).unwrap();
2206
+ for (const row of cameraQuery) {
2207
+ const cam = row.get(Camera);
2208
+ const entity = row.entity;
2209
+ const view = worldInternal._getArrayView(entity, Transform, 'world');
2210
+ if (view === undefined) continue;
2211
+ const worldMat = new Float32Array(view);
2212
+ cameras.push({
2213
+ entityKey: entity as number,
2214
+ position: mat4.getTranslation(vec3.create(), worldMat),
2215
+ world: worldMat,
2216
+ fov: cam.fov,
2217
+ aspect: cam.aspect,
2218
+ near: cam.near,
2219
+ far: cam.far,
2220
+ projection: cameraProjectionFromF32(cam.projection),
2221
+ orthoLeft: cam.left,
2222
+ orthoRight: cam.right,
2223
+ orthoBottom: cam.bottom,
2224
+ orthoTop: cam.top,
2225
+ tonemap: tonemapFromF32(cam.tonemap),
2226
+ exposure: cam.exposure,
2227
+ whitePoint: cam.whitePoint,
2228
+ antialias: antialiasFromF32(cam.antialias),
2229
+ bloom: bloomEnabledFromF32(cam.bloom),
2230
+ bloomThreshold: cam.bloomThreshold,
2231
+ bloomIntensity: cam.bloomIntensity,
2232
+ bloomBlurRadius: cam.bloomBlurRadius,
2233
+ clearColor: [
2234
+ cam.clearColor[0] ?? 0,
2235
+ cam.clearColor[1] ?? 0,
2236
+ cam.clearColor[2] ?? 0,
2237
+ cam.clearColor[3] ?? 1,
2238
+ ],
2239
+ });
2240
+ cameraEntities.push(entity as number);
2241
+ }
2242
+ const activeCameraIndex = selectActiveCameraIndex(cameraEntities, getActiveCamera(world)?.entity);
2243
+ if (activeCameraIndex < 0) return cameras;
2244
+ const selected = cameras[activeCameraIndex];
2245
+ return selected === undefined ? cameras : [selected];
2246
+ }
2247
+
2248
+ function tonemapParams(camera: CameraSnapshot): Uint8Array {
2249
+ const bytes = new ArrayBuffer(16);
2250
+ const floats = new Float32Array(bytes);
2251
+ const integers = new Uint32Array(bytes);
2252
+ floats[0] = camera.exposure;
2253
+ floats[1] = camera.whitePoint;
2254
+ integers[2] = tonemapToU32(camera.tonemap);
2255
+ return new Uint8Array(bytes);
2256
+ }
2257
+
2258
+ export function prepareExtractContext(
2259
+ world: World,
2260
+ options: {
2261
+ readonly assets?: AssetRegistry | null;
2262
+ readonly pipelineState?: ExtractPipelineSurface | null;
2263
+ readonly materialSnapshotCache?: MaterialSnapshotCache;
2264
+ readonly cull?: 'self' | 'none' | 'external';
2265
+ readonly cullCameras?: readonly CameraSnapshot[];
2266
+ } = {},
2267
+ ): PreparedExtractContext {
2268
+ const hierarchy = projectHierarchy(world);
2269
+ return {
2270
+ assets: options.assets,
2271
+ pipelineState: options.pipelineState,
2272
+ materialSnapshotCache: options.materialSnapshotCache,
2273
+ cull: options.cull ?? 'self',
2274
+ cullCameras: options.cullCameras,
2275
+ hierarchy,
2276
+ visibility: resolveVisibility(world, hierarchy),
2277
+ };
2278
+ }
2279
+
2280
+ export function extractFrames(
2281
+ worlds: readonly World[],
2282
+ owner: number | ExtractFramesOwner,
2283
+ assets?: AssetRegistry | null,
2284
+ pipelineState?: ExtractPipelineSurface | null,
2285
+ materialSnapshotCachesByWorld?: MaterialSnapshotCachesByWorld,
2286
+ options: { readonly cull?: 'normal' | 'none' } = {},
2287
+ ): ExtractedFrame {
2288
+ // w4: normalize the owner argument. A bare number is the legacy single-owner
2289
+ // form (cameraOwner === resourceOwner); an object carries the two split
2290
+ // indices. When they coincide the code path is byte-identical to the pre-w4
2291
+ // single-owner behaviour (w1 contract combination 2).
2292
+ const cameraOwner = typeof owner === 'number' ? owner : owner.cameraOwner;
2293
+ const resourceOwner = typeof owner === 'number' ? owner : owner.resourceOwner;
2294
+
2295
+ // ── D-2: frame-level side effects live here ────────────────────────────
2296
+ //
2297
+ // resetForFrame is called exactly once per frame, at the extractFrames
2298
+ // entry. The skinPaletteAllocator cursor is reset before each per-world
2299
+ // extract runs, so sequential per-world allocation yields non-overlapping
2300
+ // palette slices (AC-08).
2301
+ const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
2302
+ if (skinPaletteAllocator !== null) {
2303
+ skinPaletteAllocator.resetForFrame();
2304
+ }
2305
+
2306
+ const failedWorlds = new Set<World>();
2307
+ // ── D-2: per-world extract with error isolation ────────────────────────
2308
+ //
2309
+ // Each world runs extractFrame over the final state published by
2310
+ // world.update(). Failure in one world is caught, routed to that world's
2311
+ // _routeError (systemName carries worldId for source identification),
2312
+ // and the world's contribution is skipped (AC-09 graceful degradation).
2313
+
2314
+ // The camera-owner frame must be extracted first: non-owner worlds do not
2315
+ // carry the surfaced camera, but their renderables still need to be tested
2316
+ // against that camera's frustum. Frames are placed back into worlds[] order
2317
+ // before merge so queue / directional-light / worldId semantics stay stable.
2318
+ const succeededFrames: ExtractedFrame[] = [];
2319
+ const succeededIndices: number[] = [];
2320
+
2321
+ const extractionOrder = Array.from({ length: worlds.length }, (_, wi) => wi).sort((a, b) => {
2322
+ if (a === cameraOwner) return -1;
2323
+ if (b === cameraOwner) return 1;
2324
+ return a - b;
2325
+ });
2326
+ const framesByWorld = new Map<number, ExtractedFrame>();
2327
+
2328
+ for (const wi of extractionOrder) {
2329
+ const world = worlds[wi];
2330
+ if (world === undefined || failedWorlds.has(world)) continue;
2331
+ try {
2332
+ const isCameraOwner = wi === cameraOwner;
2333
+ const cameraOwnerFrame = framesByWorld.get(cameraOwner);
2334
+ const prepared = prepareExtractContext(world, {
2335
+ ...(assets !== undefined ? { assets } : {}),
2336
+ ...(pipelineState !== undefined ? { pipelineState } : {}),
2337
+ ...(materialSnapshotCachesByWorld === undefined
2338
+ ? {}
2339
+ : {
2340
+ materialSnapshotCache:
2341
+ materialSnapshotCachesByWorld.get(world) ??
2342
+ (() => {
2343
+ const cache: MaterialSnapshotCache = new Map();
2344
+ materialSnapshotCachesByWorld.set(world, cache);
2345
+ return cache;
2346
+ })(),
2347
+ }),
2348
+ cull: options.cull === 'none' ? 'none' : isCameraOwner ? 'self' : 'external',
2349
+ ...(cameraOwnerFrame === undefined ? {} : { cullCameras: cameraOwnerFrame.cameras }),
2350
+ });
2351
+ const frame = extractFrame(world, prepared);
2352
+
2353
+ framesByWorld.set(wi, frame);
2354
+ } catch (err) {
2355
+ // Per-world failure: route to world's own error handler, skip contribution.
2356
+ try {
2357
+ createWorldInternalView(world)._routeError(err, {
2358
+ severity: Severity.Error,
2359
+ systemName: `RenderSystem.extractFrames(world[${wi}])`,
2360
+ });
2361
+ } catch {
2362
+ // If _routeError itself throws, the world already failed — skip silently.
2363
+ }
2364
+ }
2365
+ }
2366
+
2367
+ // Restore the caller's world order after the camera-owner-first extraction.
2368
+ for (let wi = 0; wi < worlds.length; wi++) {
2369
+ const frame = framesByWorld.get(wi);
2370
+ if (frame === undefined) continue;
2371
+ succeededFrames.push(frame);
2372
+ succeededIndices.push(wi);
2373
+ }
2374
+
2375
+ // ── D-3: merge semantics ───────────────────────────────────────────────
2376
+
2377
+ // AC-04: renderables — concat by worlds[] order, stamp worldId.
2378
+ const renderables: RenderableSnapshot[] = [];
2379
+ const dispatchEntries: DispatchEntry[] = [];
2380
+ const visibilitySnapshots: VisibilitySnapshot[] = [];
2381
+ const featureVisibilitySnapshots: RenderFeatureWorldVisibilitySnapshot[] = [];
2382
+ const hiddenEntityReports: RenderFeatureHiddenEntityReport[] = [];
2383
+
2384
+ for (let fi = 0; fi < succeededFrames.length; fi++) {
2385
+ const f = succeededFrames[fi];
2386
+ const wId = succeededIndices[fi];
2387
+ if (f === undefined || wId === undefined) continue;
2388
+
2389
+ const base = renderables.length;
2390
+ const visibilitySnapshot = f.visibilitySnapshots[0];
2391
+ const world = worlds[wId];
2392
+ if (visibilitySnapshot !== undefined) {
2393
+ visibilitySnapshots.push(visibilitySnapshot);
2394
+ if (world !== undefined)
2395
+ featureVisibilitySnapshots.push({ world, snapshot: visibilitySnapshot });
2396
+ }
2397
+ hiddenEntityReports.push(...f.hiddenEntityReports);
2398
+ for (const r of f.renderables) {
2399
+ renderables.push({
2400
+ ...r,
2401
+ worldId: wId,
2402
+ ...(r.pointsLines === undefined ? {} : { pointsLines: { ...r.pointsLines, worldId: wId } }),
2403
+ });
2404
+ }
2405
+
2406
+ // D-3: dispatch — per-world renderableIndex rebased by base offset.
2407
+ for (const d of f.dispatch) {
2408
+ dispatchEntries.push({ ...d, renderableIndex: (d.renderableIndex ?? 0) + base });
2409
+ }
2410
+ }
2411
+
2412
+ // Stable sort dispatch by queue value.
2413
+ dispatchEntries.sort((a, b) => (a.queue ?? 0) - (b.queue ?? 0));
2414
+
2415
+ // AC-04: lights — point[]/spot[] concat; directional first-hit in
2416
+ // succeededFrames order (which preserves worlds[] order for successful
2417
+ // frames); directionalCount sum.
2418
+ const point: PointLightSnapshot[] = [];
2419
+ const spot: SpotLightSnapshot[] = [];
2420
+ let directional: DirectionalLightSnapshot | undefined;
2421
+ let directionalCount = 0;
2422
+ let lightViewProj: readonly Float32Array[] | undefined;
2423
+ let splitPlanes: Float32Array | undefined;
2424
+ let cascadeCount: number | undefined;
2425
+ let cascadeBlend: number | undefined;
2426
+ let shadowMapSize: number | undefined;
2427
+ let depthBias: number | undefined;
2428
+ let normalBias: number | undefined;
2429
+ let pcfKernelSize: number | undefined;
2430
+ const pointShadow: PointShadowSnapshot[] = [];
2431
+ // bug-20260710-editor-cross-world-shadow: carry the raw CSM config +
2432
+ // direction of the first-hit directional so the merge layer can recompute
2433
+ // matrices against the SURFACED (cameraOwner) camera — the light and the
2434
+ // camera may live in different worlds (editor super-composite).
2435
+ let directionalCsmConfig: DirectionalCsmConfig | undefined;
2436
+ let directionalCsmDirection: Vec3 | undefined;
2437
+ for (const f of succeededFrames) {
2438
+ for (const p of f.lights.point) point.push(p);
2439
+ for (const s of f.lights.spot) spot.push(s);
2440
+ for (const ps of f.lights.pointShadow) pointShadow.push(ps);
2441
+ if (directional === undefined && f.lights.directional !== undefined) {
2442
+ directional = f.lights.directional;
2443
+ // Carry CSM shadow fields from the first-hit directional's world.
2444
+ lightViewProj = f.lights.lightViewProj;
2445
+ splitPlanes = f.lights.splitPlanes;
2446
+ cascadeCount = f.lights.cascadeCount;
2447
+ cascadeBlend = f.lights.cascadeBlend;
2448
+ shadowMapSize = f.lights.shadowMapSize;
2449
+ depthBias = f.lights.depthBias;
2450
+ normalBias = f.lights.normalBias;
2451
+ pcfKernelSize = f.lights.pcfKernelSize;
2452
+ directionalCsmConfig = f.lights.directionalCsmConfig;
2453
+ directionalCsmDirection = f.lights.directionalCsmDirection;
2454
+ }
2455
+ directionalCount += f.lights.directionalCount;
2456
+ }
2457
+
2458
+ // AC-05/06 + w4 owner split (D-3 / R-6): cameras come from the cameraOwner
2459
+ // world; skylight / skybox / postProcessParams come from the resourceOwner
2460
+ // world (holistic snapshot selection). Scan succeededIndices once to locate
2461
+ // each owner's surviving frame. When cameraOwner === resourceOwner both
2462
+ // resolve to the same frame — byte-identical to the pre-w4 single-owner path.
2463
+ let cameraOwnerFrame: ExtractedFrame | undefined;
2464
+ let resourceOwnerFrame: ExtractedFrame | undefined;
2465
+ for (let fi = 0; fi < succeededFrames.length; fi++) {
2466
+ if (succeededIndices[fi] === cameraOwner) cameraOwnerFrame = succeededFrames[fi];
2467
+ if (succeededIndices[fi] === resourceOwner) resourceOwnerFrame = succeededFrames[fi];
2468
+ }
2469
+ const cameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cameras] : [];
2470
+
2471
+ // bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
2472
+ // against the surfaced camera. In a single-world app the per-world extract
2473
+ // already produced correct matrices (light+camera share the world), and this
2474
+ // recompute reproduces them byte-identically (same config, same camera). In
2475
+ // the editor super-composite the directional light's world has NO camera, so
2476
+ // its per-world `lightViewProj` is undefined/degenerate; the surfaced camera
2477
+ // lives in the cameraOwner world. Pairing them here is the ONLY place both
2478
+ // are visible. Point/spot shadows are camera-independent (light-space only)
2479
+ // and need no merge-layer fix-up.
2480
+ const mergeCam = cameras[0];
2481
+ if (directionalCsmConfig !== undefined && directionalCsmDirection !== undefined) {
2482
+ const mergeCameraData: CsmCameraData | undefined =
2483
+ mergeCam !== undefined
2484
+ ? {
2485
+ world: mergeCam.world,
2486
+ fov: mergeCam.fov,
2487
+ aspect: mergeCam.aspect,
2488
+ near: mergeCam.near,
2489
+ far: mergeCam.far,
2490
+ projection: mergeCam.projection,
2491
+ orthoLeft: mergeCam.orthoLeft,
2492
+ orthoRight: mergeCam.orthoRight,
2493
+ orthoBottom: mergeCam.orthoBottom,
2494
+ orthoTop: mergeCam.orthoTop,
2495
+ }
2496
+ : undefined;
2497
+ const csm = computeDirectionalCsm(
2498
+ directionalCsmDirection,
2499
+ directionalCsmConfig,
2500
+ mergeCameraData,
2501
+ );
2502
+ if (csm !== null) {
2503
+ lightViewProj = csm.lightViewProj;
2504
+ splitPlanes = csm.splitPlanes;
2505
+ cascadeCount = csm.cascadeCount;
2506
+ cascadeBlend = csm.cascadeBlend;
2507
+ shadowMapSize = csm.shadowMapSize;
2508
+ }
2509
+ // csm === null (no surfaced camera at all): keep the per-world carry —
2510
+ // there is no better data, and a cameraless frame renders nothing anyway.
2511
+ }
2512
+
2513
+ const lights: ExtractedLights = {
2514
+ directional,
2515
+ directionalCount,
2516
+ point,
2517
+ spot,
2518
+ lightViewProj,
2519
+ splitPlanes,
2520
+ cascadeCount,
2521
+ cascadeBlend,
2522
+ shadowMapSize,
2523
+ depthBias,
2524
+ normalBias,
2525
+ pcfKernelSize,
2526
+ pointShadow,
2527
+ directionalCsmConfig,
2528
+ directionalCsmDirection,
2529
+ };
2530
+ const skylight = resourceOwnerFrame?.skylight;
2531
+ const skylightCount = resourceOwnerFrame?.skylightCount ?? 0;
2532
+ const skybox = resourceOwnerFrame?.skybox;
2533
+ const skyboxCount = resourceOwnerFrame?.skyboxCount ?? 0;
2534
+ // User-authored PostProcessParams entities are singleton scene resources, so
2535
+ // they come from the resourceOwner world (holistic snapshot selection).
2536
+ const postProcessParams = new Map(resourceOwnerFrame?.postProcessParams);
2537
+ // feat-20260709-editor-world-partition ENGINE-fix-round2 (defect 1): the
2538
+ // engine built-in Standard tonemap param is NOT a scene resource — the
2539
+ // per-world extractFrame bridges it from that world's own `cameras[0]`
2540
+ // (Camera.exposure / whitePoint / tonemap is the SSOT). It therefore lives on
2541
+ // the CAMERA-owner frame, not the resource-owner frame. In the split-owner
2542
+ // editor topology the resourceOwner world has no Camera, so its frame carries
2543
+ // no Standard tonemap entry; taking postProcessParams from resourceOwner
2544
+ // alone drops it, the tonemap pass's params UBO stays zero-filled
2545
+ // (exposure=0 => tonemapped output is uniformly black), and the whole frame
2546
+ // reads black even though geometry drew into the HDR target. Overlay the
2547
+ // camera-owner frame's tonemap param (its SSOT source) so the surfaced
2548
+ // camera's exposure/whitePoint/mode reach the tonemap pass. When
2549
+ // cameraOwner === resourceOwner this is a no-op (identical entry). The 'na'
2550
+ // Standard tonemap identity mirrors the engine provider key set at the
2551
+ // bottom of extractFrame (SSOT: same identity, same 16B layout).
2552
+ const TONEMAP_PARAM_KEY = STANDARD_TONEMAP_FEATURE_ID;
2553
+ const cameraTonemapParam = cameraOwnerFrame?.postProcessParams.get(TONEMAP_PARAM_KEY);
2554
+ if (cameraTonemapParam !== undefined) {
2555
+ postProcessParams.set(TONEMAP_PARAM_KEY, cameraTonemapParam);
2556
+ } else {
2557
+ // The camera-owner world produced no tonemap param (no Camera surfaced
2558
+ // there this frame); do not leave a stale resource-owner entry that would
2559
+ // apply a foreign camera's exposure. Removing it lets the tonemap pass fall
2560
+ // back to its param-less zero path only when genuinely no camera exists.
2561
+ postProcessParams.delete(TONEMAP_PARAM_KEY);
2562
+ }
2563
+
2564
+ // D-3: frustumStats — culled/total summed across worlds.
2565
+ const frustumStats = {
2566
+ culled: succeededFrames.reduce((s, f) => s + f.frustumStats.culled, 0),
2567
+ total: succeededFrames.reduce((s, f) => s + f.frustumStats.total, 0),
2568
+ };
2569
+ const visibilityStats = {
2570
+ explicitlyHidden: succeededFrames.reduce((s, f) => s + f.visibilityStats.explicitlyHidden, 0),
2571
+ };
2572
+
2573
+ return {
2574
+ cameras,
2575
+ lights,
2576
+ renderables,
2577
+ dispatch: dispatchEntries,
2578
+ skylight,
2579
+ skylightCount,
2580
+ skybox,
2581
+ skyboxCount,
2582
+ frustumStats,
2583
+ visibilityStats,
2584
+ postProcessParams,
2585
+ visibilitySnapshots,
2586
+ featureVisibilitySnapshots,
2587
+ hiddenEntityReports,
2588
+ };
2589
+ }
2590
+
2591
+ /**
2592
+ * A MeshAsset participates in frustum culling only when its producer supplied
2593
+ * the complete finite local-space bounds promised by the asset contract.
2594
+ * Missing, malformed, and inverted-infinity empty bounds remain conservative
2595
+ * (always visible) so a bad asset cannot turn into a false-negative render;
2596
+ * they are not cull candidates and therefore do not inflate frustumStats.
2597
+ */
2598
+ function hasFiniteOrderedLocalAabb(aabb: Float32Array | undefined): aabb is Float32Array {
2599
+ if (aabb === undefined || aabb.length !== 6) return false;
2600
+ const minX = aabb[0];
2601
+ const minY = aabb[1];
2602
+ const minZ = aabb[2];
2603
+ const maxX = aabb[3];
2604
+ const maxY = aabb[4];
2605
+ const maxZ = aabb[5];
2606
+ if (
2607
+ minX === undefined ||
2608
+ minY === undefined ||
2609
+ minZ === undefined ||
2610
+ maxX === undefined ||
2611
+ maxY === undefined ||
2612
+ maxZ === undefined
2613
+ ) {
2614
+ return false;
2615
+ }
2616
+ if (
2617
+ !Number.isFinite(minX) ||
2618
+ !Number.isFinite(minY) ||
2619
+ !Number.isFinite(minZ) ||
2620
+ !Number.isFinite(maxX) ||
2621
+ !Number.isFinite(maxY) ||
2622
+ !Number.isFinite(maxZ)
2623
+ ) {
2624
+ return false;
2625
+ }
2626
+ return minX <= maxX && minY <= maxY && minZ <= maxZ;
2627
+ }
2628
+
2629
+ function morphSnapshotFor(
2630
+ mesh: MeshAsset,
2631
+ weights: ArrayLike<number> | undefined,
2632
+ ): MorphSnapshot | undefined {
2633
+ const targets = mesh.morphTargets;
2634
+ if (targets === undefined || targets.length === 0 || weights === undefined) return undefined;
2635
+ if (weights.length !== targets.length) return undefined;
2636
+ const firstPositions = targets[0]?.position;
2637
+ if (
2638
+ firstPositions === undefined ||
2639
+ firstPositions.length === 0 ||
2640
+ firstPositions.length % 3 !== 0
2641
+ ) {
2642
+ return undefined;
2643
+ }
2644
+ const vertexCount = firstPositions.length / 3;
2645
+ if (mesh.vertices.length % vertexCount !== 0) return undefined;
2646
+ for (let targetIndex = 0; targetIndex < targets.length; targetIndex += 1) {
2647
+ const target = targets[targetIndex];
2648
+ if (target?.position?.length !== firstPositions.length) return undefined;
2649
+ }
2650
+ const copied = new Float32Array(weights.length);
2651
+ for (let index = 0; index < weights.length; index += 1) {
2652
+ const weight = weights[index] ?? Number.NaN;
2653
+ if (!Number.isFinite(weight)) return undefined;
2654
+ copied[index] = weight;
2655
+ }
2656
+ return { weights: copied, targetCount: targets.length };
2657
+ }
2658
+
2659
+ export function extractFrame(world: World, context: PreparedExtractContext): ExtractedFrame {
2660
+ // feat-20260708-composited-multi-world-rendering M2 / D-2: resetForFrame
2661
+ // has been lifted to extractFrames (the frame-level entry point).
2662
+ // extractFrame is now a pure world->snapshot function with no frame-level
2663
+ // side effects. See plan-decisions PD2 for the reviewer ruling.
2664
+ const {
2665
+ assets,
2666
+ pipelineState,
2667
+ materialSnapshotCache: persistentMaterialSnapshotCache,
2668
+ cull: cullMode,
2669
+ } = context;
2670
+ const visibility = context.visibility.hasAnyHiddenIntent ? context.visibility : undefined;
2671
+ const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
2672
+
2673
+ const directionalLightQuery = world.query({ read: [DirectionalLight] }).unwrap();
2674
+
2675
+ // feat-20260601 D-3: camera / point / spot light world transforms are read
2676
+ // through the single resolved `Transform.world` mat4 (written by
2677
+ // propagateTransforms), not the retired GlobalTransform-column-switch.
2678
+ //
2679
+ // Each segment routes through a World-owned Query with explicit read and
2680
+ // optional roles. The packed entity handle for `readWorldMat4Copy` /
2681
+ // `_getArrayView` reads comes from `row.entity` -- the
2682
+ // archetype-graph back-door (`graph.archetypes` / `arch.components.some`) is gone.
2683
+ // Plan-decisions K-2 sniffing scheme B (archetype-edge sniff once via
2684
+ // `row.get(X) !== undefined`); K-3 invariant preserved (`_getArrayView`
2685
+ // calls survive untouched, only the entity source changes).
2686
+ const worldInternal = createWorldInternalView(world);
2687
+
2688
+ const cameras = extractCameraSnapshots(world);
2689
+
2690
+ // Three-query union (M2 / w16 / AC-03): directional has no Transform
2691
+ // dependency (sun-like infinite-source semantics); point + spot pull
2692
+ // position from the companion Transform via the joined queries.
2693
+ // Host-side pre-multiplication: color *= intensity (charter P4); cone
2694
+ // deg -> cos (D-S2); range -> 1/range^2 (D-S5).
2695
+ let directional: DirectionalLightSnapshot | undefined;
2696
+ let directionalCount = 0;
2697
+ // feat-20260621 M2: capture shadow fields from the first-hit DirectionalLight.
2698
+ // castShadow defaults to true; the CSM path is gated on firstHitCastShadow !== false.
2699
+ let firstHitCastShadow: boolean | undefined;
2700
+ let firstHitShadowFields:
2701
+ | {
2702
+ cascadeCount: number;
2703
+ splitLambda: number;
2704
+ cascadeBlend: number;
2705
+ mapSize: number;
2706
+ depthBias: number;
2707
+ normalBias: number;
2708
+ shadowDistance: number;
2709
+ pcfKernelSize: number;
2710
+ }
2711
+ | undefined;
2712
+ for (const row of directionalLightQuery) {
2713
+ const l = row.get(DirectionalLight);
2714
+ directionalCount += 1;
2715
+ const intensity = l.intensity;
2716
+ const snapshot: DirectionalLightSnapshot = {
2717
+ kind: 'directional',
2718
+ direction: vec3.create(l.direction[0] ?? 0, l.direction[1] ?? -1, l.direction[2] ?? 0),
2719
+ color: vec3.create(
2720
+ (l.color[0] ?? 1) * intensity,
2721
+ (l.color[1] ?? 1) * intensity,
2722
+ (l.color[2] ?? 1) * intensity,
2723
+ ),
2724
+ intensity,
2725
+ };
2726
+ if (directional === undefined) {
2727
+ // First hit wins; record-stage N>1 fail-fast (M3 / w19) flags duplicates.
2728
+ directional = snapshot;
2729
+ firstHitCastShadow = l.castShadow;
2730
+ firstHitShadowFields = {
2731
+ cascadeCount: l.cascadeCount,
2732
+ splitLambda: l.splitLambda,
2733
+ cascadeBlend: l.cascadeBlend,
2734
+ mapSize: l.mapSize,
2735
+ depthBias: l.depthBias,
2736
+ normalBias: l.normalBias,
2737
+ shadowDistance: l.shadowDistance,
2738
+ pcfKernelSize: l.pcfKernelSize,
2739
+ };
2740
+ }
2741
+ }
2742
+
2743
+ const pointSnapshots: PointLightSnapshot[] = [];
2744
+ // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4: track entity per
2745
+ // pointSnapshots index so the post-extract pointShadow join can stamp
2746
+ // `shadowAtlasLayer + shadowNear + shadowFar` onto the matching PointLight.
2747
+ const pointSnapshotEntities: number[] = [];
2748
+ const pointLightQuery = world
2749
+ .query({
2750
+ read: [PointLight],
2751
+ optional: [Transform],
2752
+ })
2753
+ .unwrap();
2754
+ for (const row of pointLightQuery) {
2755
+ const p = row.get(PointLight);
2756
+ // K-2 scheme B: archetype-edge sniff -- `bundle.Transform` key is absent
2757
+ // when the archetype does not carry the Transform column.
2758
+ const hasTransform = row.get(Transform) !== undefined;
2759
+ const intensity = p.intensity;
2760
+ const range = p.range;
2761
+ const entityId = row.entity;
2762
+ // Position = world-space translation extracted from Transform.world.
2763
+ // A point light archetype without a Transform column sits at the origin.
2764
+ let worldMat: Float32Array | undefined;
2765
+ if (hasTransform) {
2766
+ const view = worldInternal._getArrayView(entityId, Transform, 'world');
2767
+ if (view !== undefined) worldMat = new Float32Array(view);
2768
+ }
2769
+ const position =
2770
+ worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
2771
+ pointSnapshots.push({
2772
+ kind: 'point',
2773
+ position,
2774
+ color: vec3.create(
2775
+ (p.color[0] ?? 1) * intensity,
2776
+ (p.color[1] ?? 1) * intensity,
2777
+ (p.color[2] ?? 1) * intensity,
2778
+ ),
2779
+ intensity,
2780
+ invRangeSquared: computeInvRangeSquared(range),
2781
+ });
2782
+ pointSnapshotEntities.push(entityId);
2783
+ }
2784
+
2785
+ const spotSnapshots: SpotLightSnapshot[] = [];
2786
+ const spotLightQuery = world
2787
+ .query({
2788
+ read: [SpotLight],
2789
+ optional: [Transform],
2790
+ })
2791
+ .unwrap();
2792
+ // feat-20260625-spot-light-shadow-mapping M1 w5: tile allocation for castShadow spots.
2793
+ // Cap = 4 (OOS-5), sentinel -1 = unassigned (plan-strategy D-4).
2794
+ // Direction degeneration (near-zero) also skips shadow (requirements $112).
2795
+ let spotTileNext = 0;
2796
+ for (const row of spotLightQuery) {
2797
+ const s = row.get(SpotLight);
2798
+ const hasTransform = row.get(Transform) !== undefined;
2799
+ const intensity = s.intensity;
2800
+ const range = s.range;
2801
+ const innerConeDeg = s.innerConeDeg;
2802
+ const outerConeDeg = s.outerConeDeg;
2803
+ let worldMat: Float32Array | undefined;
2804
+ if (hasTransform) {
2805
+ const entity = row.entity;
2806
+ const view = worldInternal._getArrayView(entity, Transform, 'world');
2807
+ if (view !== undefined) worldMat = new Float32Array(view);
2808
+ }
2809
+ const position =
2810
+ worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
2811
+ const dir = vec3.create(s.direction[0] ?? 0, s.direction[1] ?? -1, s.direction[2] ?? 0);
2812
+
2813
+ // Extract is the single direction-normalization owner for direct-light
2814
+ // snapshots. URP and HDRP preserve this value downstream.
2815
+ const dirLen = Math.sqrt(
2816
+ (dir[0] ?? 0) * (dir[0] ?? 0) + (dir[1] ?? 0) * (dir[1] ?? 0) + (dir[2] ?? 0) * (dir[2] ?? 0),
2817
+ );
2818
+ const EPSILON = 1e-6;
2819
+ const hasValidDirection = dirLen > EPSILON;
2820
+ if (!hasValidDirection) {
2821
+ worldInternal._routeError(
2822
+ new SpawnLightInvalidBoundsError('SpotLight', 'direction', [
2823
+ dir[0] ?? 0,
2824
+ dir[1] ?? 0,
2825
+ dir[2] ?? 0,
2826
+ ]),
2827
+ {
2828
+ severity: Severity.Error,
2829
+ systemName: 'RenderSystem.extract (spot-direction)',
2830
+ },
2831
+ );
2832
+ }
2833
+ const dirN = vec3.create(
2834
+ hasValidDirection ? (dir[0] ?? 0) / dirLen : (dir[0] ?? 0),
2835
+ hasValidDirection ? (dir[1] ?? 0) / dirLen : (dir[1] ?? 0),
2836
+ hasValidDirection ? (dir[2] ?? 0) / dirLen : (dir[2] ?? 0),
2837
+ );
2838
+
2839
+ // ── shadow fields (feat-20260625-spot-light-shadow-mapping M1) ──
2840
+ const castShadow = s.castShadow;
2841
+ const sMapSize = s.mapSize;
2842
+ const sNearPlane = s.nearPlane;
2843
+ const sFarPlane = s.farPlane;
2844
+
2845
+ let lightViewProj: Float32Array | undefined;
2846
+ let shadowAtlasTile = -1;
2847
+
2848
+ if (castShadow && hasValidDirection) {
2849
+ const target = vec3.create(
2850
+ (position[0] ?? 0) + (dirN[0] ?? 0),
2851
+ (position[1] ?? 0) + (dirN[1] ?? 0),
2852
+ (position[2] ?? 0) + (dirN[2] ?? 0),
2853
+ );
2854
+ // D-1: perspective(outerConeDeg*2, aspect=1, near, far) x lookAt(pos, pos+dir).
2855
+ // FOV = outerConeDeg * 2 in degrees; mat4.perspective takes fov in radians.
2856
+ const fov = outerConeDeg * 2 * (Math.PI / 180);
2857
+ const proj = mat4.create();
2858
+ mat4.perspective(proj, fov, 1, sNearPlane, sFarPlane);
2859
+ const view = mat4.create();
2860
+ mat4.lookAt(view, position, target, vec3.create(0, 1, 0));
2861
+ lightViewProj = new Float32Array(16);
2862
+ // Reinterpret the Float32Array surface field as a Mat4 out-param; a
2863
+ // factory would force a needless alloc+copy. brand-cast-ok
2864
+ mat4.multiply(lightViewProj as Mat4, proj, view);
2865
+
2866
+ // D-4: allocate tile 0..3; 5th+ = -1 sentinel.
2867
+ if (spotTileNext < 4) {
2868
+ shadowAtlasTile = spotTileNext;
2869
+ spotTileNext += 1;
2870
+ }
2871
+ }
2872
+
2873
+ spotSnapshots.push({
2874
+ kind: 'spot',
2875
+ // D-6: position reflects world transform; direction stays sourced
2876
+ // from SpotLight.direction (NOT rotated by the parent).
2877
+ position,
2878
+ direction: dirN,
2879
+ color: vec3.create(
2880
+ (s.color[0] ?? 1) * intensity,
2881
+ (s.color[1] ?? 1) * intensity,
2882
+ (s.color[2] ?? 1) * intensity,
2883
+ ),
2884
+ intensity,
2885
+ invRangeSquared: computeInvRangeSquared(range),
2886
+ cosInner: degToCos(innerConeDeg),
2887
+ cosOuter: degToCos(outerConeDeg),
2888
+ // ── shadow fields ──
2889
+ castShadow,
2890
+ lightViewProj,
2891
+ mapSize: sMapSize,
2892
+ nearPlane: sNearPlane,
2893
+ farPlane: sFarPlane,
2894
+ shadowAtlasTile,
2895
+ });
2896
+ }
2897
+
2898
+ // bug-20260710-editor-cross-world-shadow: CSM matrices are computed by the
2899
+ // shared pure {@link computeDirectionalCsm}, called here per-world with THIS
2900
+ // world's own `cameras[0]`. In a single-world app the light and camera share
2901
+ // the world, so this per-world result is final (byte-identical to the prior
2902
+ // inline block). In the editor super-composite the light's world may have no
2903
+ // camera → this yields no matrices; {@link extractFrames} then RECOMPUTES at
2904
+ // the merge layer using the surfaced (cameraOwner) camera + the raw config
2905
+ // carried on ExtractedLights. The raw config + direction are surfaced
2906
+ // unconditionally so the merge layer can re-run the builder.
2907
+ let lightViewProj: Float32Array[] | undefined;
2908
+ let splitPlanes: Float32Array | undefined;
2909
+ let cascadeCount: number | undefined;
2910
+ let cascadeBlend: number | undefined;
2911
+ let shadowMapSize: number | undefined;
2912
+ let directionalCsmConfig: DirectionalCsmConfig | undefined;
2913
+ let directionalCsmDirection: Vec3 | undefined;
2914
+
2915
+ // Camera data needed for frustum corner computation (first camera only;
2916
+ // multi-camera CSM is OOS-1). Undefined in a cameraless world.
2917
+ const cam0 = cameras[0];
2918
+ const cameraData: CsmCameraData | undefined =
2919
+ cam0 !== undefined
2920
+ ? {
2921
+ world: cam0.world,
2922
+ fov: cam0.fov,
2923
+ aspect: cam0.aspect,
2924
+ near: cam0.near,
2925
+ far: cam0.far,
2926
+ projection: cam0.projection,
2927
+ orthoLeft: cam0.orthoLeft,
2928
+ orthoRight: cam0.orthoRight,
2929
+ orthoBottom: cam0.orthoBottom,
2930
+ orthoTop: cam0.orthoTop,
2931
+ }
2932
+ : undefined;
2933
+
2934
+ // feat-20260621 M2: CSM computation gated on castShadow from the
2935
+ // merged DirectionalLight. castShadow defaults to true (first-hit-wins
2936
+ // semantics, D-6 no cardinality cap).
2937
+ if (directional !== undefined && firstHitCastShadow !== false) {
2938
+ const dirSnapshot = directional;
2939
+ const sf = firstHitShadowFields;
2940
+ if (sf !== undefined) {
2941
+ directionalCsmConfig = {
2942
+ cascadeCount: sf.cascadeCount,
2943
+ splitLambda: sf.splitLambda,
2944
+ cascadeBlend: sf.cascadeBlend,
2945
+ mapSize: sf.mapSize,
2946
+ shadowDistance: sf.shadowDistance,
2947
+ };
2948
+ directionalCsmDirection = dirSnapshot.direction;
2949
+ const csm = computeDirectionalCsm(dirSnapshot.direction, directionalCsmConfig, cameraData);
2950
+ // Cascade metadata (splitPlanes / count / blend / mapSize) is available
2951
+ // even without a camera (splitPlanes needs only near/far); the matrices
2952
+ // need the camera. When csm is null (no camera) leave lightViewProj
2953
+ // undefined — the merge layer recomputes. Still surface the split/count
2954
+ // metadata so a single-world path keeps its prior fields.
2955
+ cascadeCount = Math.round(sf.cascadeCount);
2956
+ cascadeBlend = sf.cascadeBlend;
2957
+ shadowMapSize = sf.mapSize;
2958
+ if (csm !== null) {
2959
+ lightViewProj = csm.lightViewProj;
2960
+ splitPlanes = csm.splitPlanes;
2961
+ } else {
2962
+ const sNear = cameraData?.near ?? 0.1;
2963
+ const splits = pssmSplit(sNear, sf.shadowDistance, cascadeCount, sf.splitLambda);
2964
+ const padded = new Float32Array(4);
2965
+ for (let i = 0; i < splits.length; i++) padded[i] = splits[i] ?? 0;
2966
+ splitPlanes = padded;
2967
+ }
2968
+ }
2969
+ }
2970
+
2971
+ // feat-20260613-csm M3 / w14 (plan-strategy §D-7): pad the up-to-4
2972
+ // splitPlanes into a fixed length-4 Float32Array (unused slots = 0) so
2973
+ // the View UBO tail keeps a stable layout regardless of the runtime
2974
+ // cascadeCount. Host-side correctness invariant: only the first
2975
+ // cascadeCount slots are ever read by the WGSL kernel.
2976
+ const paddedSplitPlanes = new Float32Array(4);
2977
+ if (splitPlanes !== undefined) {
2978
+ for (let i = 0; i < splitPlanes.length; i++) {
2979
+ paddedSplitPlanes[i] = splitPlanes[i] ?? 0;
2980
+ }
2981
+ }
2982
+
2983
+ // feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7 (plan-strategy §D-3,
2984
+ // requirements §5.3): query (PointLight + PointLightShadow + Transform)
2985
+ // archetype join. For each shadow caster, build 6 face VP matrices and pack
2986
+ // into Float32Array(96). Atlas layer assigned in spawn order (0..3); the
2987
+ // sentinel -1 is shader-side and applies to non-shadow PointLights only.
2988
+ // Cap of 4 is enforced by ECS cardinality on PointLightShadow.
2989
+ const pointShadowSnapshots: PointShadowSnapshot[] = [];
2990
+ {
2991
+ const pointShadowQuery = world
2992
+ .query({ read: [Transform, PointLightShadow], with: [PointLight] })
2993
+ .unwrap();
2994
+ for (const row of pointShadowQuery) {
2995
+ const t = row.get(Transform);
2996
+ const ps = row.get(PointLightShadow);
2997
+ // Read world-space position from Transform.world (mat4 column-major;
2998
+ // translation lives at indices 12..14, mirroring CameraSnapshot.world
2999
+ // semantics in this file).
3000
+ // feat-20260614 M4 / w13: TypedArrayFor for `array<f32, 16>` now
3001
+ // resolves to a concrete `Float32Array` (was `never` pre-w11), which
3002
+ // surfaces the row-window slicing -- `t.world` is the stride-16 flat
3003
+ // column view; row i lives at `[i*16, (i+1)*16)`. The prior
3004
+ // `t.world?.[i]` form silently returned a single element under the
3005
+ // `never`-typed bundle path and `wRow[12]` widened to `undefined ?? 0`
3006
+ // so light positions clamped to the origin.
3007
+ const wRow = t.world;
3008
+ if (wRow === undefined) continue;
3009
+ const px = wRow[12] ?? 0;
3010
+ const py = wRow[13] ?? 0;
3011
+ const pz = wRow[14] ?? 0;
3012
+ const lightPos = vec3.create(px, py, pz);
3013
+ const mapSize = ps.mapSize;
3014
+ const nearPlane = ps.nearPlane;
3015
+ const farPlane = ps.farPlane;
3016
+ const layer = pointShadowSnapshots.length; // 0, 1, 2, 3 in spawn order
3017
+
3018
+ const matrices = buildPointShadowMatrices(lightPos, nearPlane, farPlane);
3019
+ const packed = new Float32Array(96);
3020
+ for (let f = 0; f < 6; f++) {
3021
+ const m = matrices[f];
3022
+ if (m === undefined) continue;
3023
+ for (let k = 0; k < 16; k++) {
3024
+ packed[f * 16 + k] = m[k] ?? 0;
3025
+ }
3026
+ }
3027
+ pointShadowSnapshots.push({
3028
+ entity: row.entity,
3029
+ position: lightPos,
3030
+ mapSize,
3031
+ nearPlane,
3032
+ farPlane,
3033
+ shadowAtlasLayer: layer,
3034
+ shadowMatrices: packed,
3035
+ });
3036
+ }
3037
+ }
3038
+
3039
+ // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4 (plan-strategy §D-8):
3040
+ // join pointShadow snapshots into the matching PointLightSnapshot so the
3041
+ // record stage threads `shadowAtlasLayer + shadowNear + shadowFar` through
3042
+ // `packLightSlot` for the HDRP std430 LightSlot pad lanes (byte 52..64).
3043
+ // Mutates the freshly-built PointLightSnapshot in place; the snapshot is
3044
+ // not exposed elsewhere this frame yet (consumed only by lights.point[]).
3045
+ if (pointShadowSnapshots.length > 0) {
3046
+ const shadowByEntity = new Map<number, PointShadowSnapshot>();
3047
+ for (const ps of pointShadowSnapshots) shadowByEntity.set(ps.entity, ps);
3048
+ for (let i = 0; i < pointSnapshots.length; i++) {
3049
+ const entityId = pointSnapshotEntities[i] ?? 0;
3050
+ const ps = shadowByEntity.get(entityId);
3051
+ if (ps !== undefined) {
3052
+ pointSnapshots[i] = {
3053
+ ...(pointSnapshots[i] as PointLightSnapshot),
3054
+ shadowAtlasLayer: ps.shadowAtlasLayer,
3055
+ shadowNear: ps.nearPlane,
3056
+ shadowFar: ps.farPlane,
3057
+ };
3058
+ }
3059
+ }
3060
+ }
3061
+
3062
+ const lights: ExtractedLights = {
3063
+ directional,
3064
+ directionalCount,
3065
+ point: pointSnapshots,
3066
+ spot: spotSnapshots,
3067
+ lightViewProj,
3068
+ splitPlanes: splitPlanes !== undefined ? paddedSplitPlanes : undefined,
3069
+ cascadeCount,
3070
+ cascadeBlend,
3071
+ shadowMapSize,
3072
+ depthBias: firstHitCastShadow !== false ? firstHitShadowFields?.depthBias : undefined,
3073
+ normalBias: firstHitCastShadow !== false ? firstHitShadowFields?.normalBias : undefined,
3074
+ pcfKernelSize: firstHitCastShadow !== false ? firstHitShadowFields?.pcfKernelSize : undefined,
3075
+ pointShadow: pointShadowSnapshots,
3076
+ // bug-20260710-editor-cross-world-shadow: raw CSM config + light direction
3077
+ // so the merge layer can recompute matrices against the surfaced camera.
3078
+ directionalCsmConfig,
3079
+ directionalCsmDirection,
3080
+ };
3081
+
3082
+ // feat-20260520-skylight-ibl-cubemap M4 / t26+t27: query Skylight entities.
3083
+ // First archetype hit wins (mirrors DirectionalLight pattern); multi-Skylight
3084
+ // warn in record stage (t27) uses skylightCount.
3085
+ const skylightQuery = world.query({ read: [Skylight] }).unwrap();
3086
+ let skylight: SkylightSnapshot | undefined;
3087
+ let skylightCount = 0;
3088
+ for (const row of skylightQuery) {
3089
+ const s = row.get(Skylight);
3090
+ // equirect is OPTIONAL: an omitted field zero-inits to handle 0, which
3091
+ // record treats as "no equirect" -> solid-color ambient via the white
3092
+ // fallback cube. A Skylight WITHOUT an equirect is still a valid snapshot
3093
+ // (the prior `equirectRaw !== undefined` gate dropped color-only
3094
+ // skylights, leaving the scene black -- the downstream gap #4).
3095
+ const equirectRaw = s.equirect;
3096
+ const intensity = s.intensity;
3097
+ const colorR = s.color[0] ?? 1.0;
3098
+ const colorG = s.color[1] ?? 1.0;
3099
+ const colorB = s.color[2] ?? 1.0;
3100
+ const rotation: [number, number, number, number] = [
3101
+ s.rotation[0] ?? 0,
3102
+ s.rotation[1] ?? 0,
3103
+ s.rotation[2] ?? 0,
3104
+ s.rotation[3] ?? 1,
3105
+ ];
3106
+ skylightCount += 1;
3107
+ if (skylight === undefined) {
3108
+ skylight = {
3109
+ equirectHandle: equirectRaw !== undefined ? Math.round(equirectRaw) : 0,
3110
+ color: [colorR, colorG, colorB],
3111
+ intensity,
3112
+ rotation,
3113
+ // w19: winning entity handle for the multi-Skylight once-warn (F-8).
3114
+ entityHandle: row.entity,
3115
+ };
3116
+ }
3117
+ }
3118
+
3119
+ // feat-20260531-skybox-env-background M2 / w5: query SkyboxBackground entities.
3120
+ // First archetype hit wins (mirrors Skylight pattern); multi-entity
3121
+ // once-warn in record stage uses skyboxCount.
3122
+ const skyboxQuery = world.query({ read: [SkyboxBackground] }).unwrap();
3123
+ let skybox: SkyboxSnapshot | undefined;
3124
+ let skyboxCount = 0;
3125
+ for (const row of skyboxQuery) {
3126
+ const s = row.get(SkyboxBackground);
3127
+ const equirectRaw = s.equirect;
3128
+ const modeRaw = s.mode;
3129
+ const rotation: [number, number, number, number] = [
3130
+ s.rotation[0] ?? 0,
3131
+ s.rotation[1] ?? 0,
3132
+ s.rotation[2] ?? 0,
3133
+ s.rotation[3] ?? 1,
3134
+ ];
3135
+ skyboxCount += 1;
3136
+ if (skybox === undefined && equirectRaw !== undefined) {
3137
+ skybox = {
3138
+ equirectHandle: Math.round(equirectRaw),
3139
+ mode: modeRaw,
3140
+ rotation,
3141
+ // w19: winning entity handle for the multi-SkyboxBackground warn (F-8).
3142
+ entityHandle: row.entity,
3143
+ };
3144
+ }
3145
+ }
3146
+
3147
+ // feat-20260528-frustum-culling M3 / w10: precompute per-camera frustum
3148
+ // planes so entities can be tested against all cameras in the inner loop.
3149
+ // Cameras with degenerate projection parameters (e.g. zero fov, zero aspect)
3150
+ // are skipped — entities are always-visible for those. Frustum plane cache
3151
+ // stored as Float32Array[] parallel to the cameras[] array.
3152
+ //
3153
+ // feat-20260708-composited-multi-world-rendering M2 / D-4: a composite
3154
+ // non-owner world uses the camera-owner snapshots, not its own cameras.
3155
+ // This keeps culling correct when the surfaced camera and renderables live
3156
+ // in different worlds. The explicit 'none' mode remains the always-visible
3157
+ // escape hatch for callers that genuinely need it.
3158
+ const frustumPlanes: Float32Array[] = [];
3159
+ const cullingCameras = cullMode === 'external' ? (context.cullCameras ?? []) : cameras;
3160
+ if (cullMode !== 'none') {
3161
+ for (const cam of cullingCameras) {
3162
+ // feat-20260613 M6 / w20: orthographic cameras have fov=0 by design;
3163
+ // the previous degeneracy guard (`fov <= 0`) was rejecting valid ortho
3164
+ // cameras and returning the always-visible escape hatch. Only the
3165
+ // perspective path needs the fov check.
3166
+ if (cam.projection === 'perspective' && (cam.fov <= 0 || cam.aspect <= 0)) {
3167
+ frustumPlanes.push(new Float32Array(0)); // degenerate → always-visible
3168
+ continue;
3169
+ }
3170
+ if (cam.near >= cam.far) {
3171
+ frustumPlanes.push(new Float32Array(0));
3172
+ continue;
3173
+ }
3174
+ const proj = mat4.create();
3175
+ if (cam.projection === 'orthographic') {
3176
+ mat4.orthographic(
3177
+ proj,
3178
+ cam.orthoLeft,
3179
+ cam.orthoRight,
3180
+ cam.orthoBottom,
3181
+ cam.orthoTop,
3182
+ cam.near,
3183
+ cam.far,
3184
+ );
3185
+ } else {
3186
+ mat4.perspective(proj, cam.fov, cam.aspect, cam.near, cam.far);
3187
+ }
3188
+ // feat-20260601 D-3: view = invert(camera world mat4). The camera scale is
3189
+ // carried in the world basis columns; the cull frustum uses the same view
3190
+ // the record stage derives, so cull stays same-source with render (AC-05).
3191
+ const view = mat4.create();
3192
+ mat4.invert(view, cam.world);
3193
+ const vp = mat4.create();
3194
+ mat4.multiply(vp, proj, view);
3195
+ const f = frustum.create();
3196
+ frustum.fromViewProjection(f, vp);
3197
+ frustumPlanes.push(f);
3198
+ }
3199
+ }
3200
+
3201
+ const renderables: RenderableSnapshot[] = [];
3202
+ // feat-20260528-frustum-culling M3 / w11: frustum culling counters.
3203
+ let frustumCulled = 0;
3204
+ let frustumTotal = 0;
3205
+ const explicitlyHidden = new Set<EntityHandle>();
3206
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w22 (@new-surface): three-
3207
+ // bucket dispatch arrays. The legacy `materialDispatch` field stays as
3208
+ // a back-compat union (opaque + transparent + overlay back-compat
3209
+ // entries) so the pre-w25 RenderSystem.draw consumer loop keeps
3210
+ // working until M-3 / w25 lands the bucket-aware record. Plan-strategy
3211
+ // §6.1 (back-compat field stays until M-4 acceptance round green).
3212
+ // M3 / w26: single dispatch list replaces old three-bucket model
3213
+ // (plan-strategy D-3). Entries built per-entity per-pass inside the
3214
+ // archetype walk, then sorted by queue at the end.
3215
+ let dispatch: DispatchEntry[] = [];
3216
+ // Keep the derived material snapshot and its resolved passes local to this
3217
+ // extraction. Shared handles are immutable inputs for the frame, while
3218
+ // dispatch entries still need to be rebuilt for each entity.
3219
+ const materialSnapshotCache: MaterialSnapshotCache = new Map();
3220
+ // tweak-20260611 M1: MeshRenderer renderable archetype walk routes
3221
+ // through one World-owned Query. K-2 sniffing scheme B
3222
+ // (`row.get(X) !== undefined` edge sniff) replaces the prior
3223
+ // `arch.components.some` row-internal back-door. K-3 invariant: the
3224
+ // variable-length array reads (`MeshRenderer.materials`,
3225
+ // `Instances.transforms`) still flow through `_getArrayView` /
3226
+ // `world.get(e, Instances)` -- only the `entity` source switches to
3227
+ // `row.entity`.
3228
+ //
3229
+ // archVersion plumbing exception: the `RenderableSnapshot.instances
3230
+ // .archVersion` cache key is keyed off the live archetype's mutation
3231
+ // counter (record stage `instanceBuffers` cache invalidation). The
3232
+ // QueryRow does not surface this number, so a single archetype
3233
+ // graph access is retained for instance-bearing rows -- match by entity
3234
+ // packed handle. AC-01 grep ≤ current - 1 still holds: the stale
3235
+ // archetype-graph traversal commentary at the prior call site is gone.
3236
+ // feat-20260521-sprite-atlas-animation M3 / T-16: SpriteRegionOverride
3237
+ // column id for the sprite-bucket per-entity region override read
3238
+ void SpriteRegionOverride;
3239
+
3240
+ const meshRendererQuery = world
3241
+ .query({
3242
+ read: [MeshRenderer],
3243
+ optional: [
3244
+ Transform,
3245
+ MeshFilter,
3246
+ Instances,
3247
+ Skin,
3248
+ Layer,
3249
+ MorphWeights,
3250
+ SpriteRegionOverride,
3251
+ SpriteInstances,
3252
+ Points,
3253
+ Lines,
3254
+ SortKey,
3255
+ ],
3256
+ })
3257
+ .unwrap();
3258
+ // QueryRow deliberately exposes component data, not an archetype version.
3259
+ // The optimized ECS owner keeps table storage private, so instance-buffer
3260
+ // invalidation uses a content fingerprint of the managed arrays instead.
3261
+ const resolveArchVersion = (entity: EntityHandle): number => {
3262
+ const values = [
3263
+ worldInternal._getArrayView(entity, Instances, 'transforms'),
3264
+ worldInternal._getArrayView(entity, SpriteInstances, 'transforms'),
3265
+ worldInternal._getArrayView(entity, SpriteInstances, 'regions'),
3266
+ ];
3267
+ let hash = 2166136261;
3268
+ for (const value of values) {
3269
+ hash = Math.imul(hash ^ (value?.length ?? 0), 16777619) >>> 0;
3270
+ if (value === undefined) continue;
3271
+ for (let index = 0; index < value.length; index += 1) {
3272
+ hash = Math.imul(hash ^ Math.fround(value[index] ?? 0), 16777619) >>> 0;
3273
+ }
3274
+ }
3275
+ return hash;
3276
+ };
3277
+ // Pending entries are created with the current renderables.length, which is
3278
+ // exactly the slot this entity receives if it survives culling. No other
3279
+ // renderable can be pushed between staging and this entity's push, so publish
3280
+ // the original fresh entries instead of cloning every submesh descriptor.
3281
+ // A culled entity still discards its private pending array unchanged.
3282
+ const flushPendingDispatch = (pending: readonly DispatchEntry[]): void => {
3283
+ for (const entry of pending) dispatch.push(entry);
3284
+ };
3285
+ for (const row of meshRendererQuery) {
3286
+ // K-2 archetype-edge sniff (scheme B): a missing optional component
3287
+ // surfaces as an absent bundle key, not a row-internal optional chain.
3288
+ // Presence-only checks use QueryRow.has so large array-bearing components
3289
+ // are not materialised merely to answer a boolean question.
3290
+ const hasTransform = row.has(Transform);
3291
+ const meshFilter = row.get(MeshFilter);
3292
+ const hasInstances = row.has(Instances);
3293
+ const skin = row.get(Skin);
3294
+ const morphWeightsView = worldInternal._getArrayView(row.entity, MorphWeights, 'weights');
3295
+ const hasMeshFilter = meshFilter !== undefined;
3296
+ const hasSkin = skin !== undefined;
3297
+ // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 / w10:
3298
+ // SpriteInstances optional component archetype-edge sniff. Three structured
3299
+ // EcsError codes fire at the row-loop entry (D-6 fail-fast at extract):
3300
+ // - 'sprite-instances-mutually-exclusive-with-instances'
3301
+ // (hasInstances && hasSpriteInstances) — Instances + SpriteInstances peers.
3302
+ // - 'sprite-instances-requires-sprite-shading-model'
3303
+ // (materialSnap.materialShaderId !== 'forgeax::sprite') — non-sprite material.
3304
+ // - 'sprite-instances-count-mismatch'
3305
+ // (transforms.length / 16 !== regions.length / 4) — stride pair desync.
3306
+ const hasSpriteInstances = row.has(SpriteInstances);
3307
+ const points = row.get(Points);
3308
+ const lines = row.get(Lines);
3309
+ const sortKey = row.get(SortKey)?.value;
3310
+ const pointsLinesComponent =
3311
+ points !== undefined ? 'Points' : lines !== undefined ? 'Lines' : undefined;
3312
+ const isRenderable = hasTransform && hasMeshFilter;
3313
+
3314
+ // feat-20260601 D-3: the resolved world transform is read per-entity from
3315
+ // the single `Transform.world` mat4 (propagateTransforms output) inside the
3316
+ // row loop below. The retired GlobalTransform-column-switch + the
3317
+ // ChildOf-without-GlobalTransform misconfig signal are gone: the world
3318
+ // column always exists on a Transform-bearing entity, so the
3319
+ // "ChildOf but forgot GlobalTransform" misconfiguration cannot occur.
3320
+ const fAssetHandle = meshFilter?.assetHandle;
3321
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w22: Layer column read here;
3322
+ // value folded into each DispatchEntry so the render-system sort can use
3323
+ // it as the primary transparent-sort key without a second ECS round-trip.
3324
+ const fLayerValue = row.get(Layer)?.value;
3325
+ // feat-20260608-tilemap-object-layer-rendering M3 / m3-t5: tilemap-spawned
3326
+ // per-cell render entities (the ones `tilemap-chunk-extract-system`
3327
+ // pushes via `spawnDerivedRenderEntities`) reach this loop via the same
3328
+ // archetype edge that carries a sprite entity -- they all wear
3329
+ // `MeshFilter.assetHandle === HANDLE_QUAD` + a `forgeax::sprite`-shaded
3330
+ // material asset + the sprite-bucket `values.region` rectangle.
3331
+ // For the per-entity Y-sort path (requirements §AC-12 / §AC-13):
3332
+ //
3333
+ // sortKey = -(Transform.posY - effectivePivotY * |Transform.scaleY|)
3334
+ //
3335
+ // with `effectivePivotY = effectivePivotYForTilemapFlip(pivotY, pivotX,
3336
+ // flipV, flipDiagonal)` from `tilemap-chunk-extract-system` (the SAME
3337
+ // helper drives `spawnDerivedRenderEntities`, so the value the sort
3338
+ // uses matches the value baked into `Transform.posY` -- charter P4
3339
+ // single SSOT for the post-flip pivot). Sprite entities reuse the same
3340
+ // formula but skip the flip composition (their pivot stays raw); both
3341
+ // bucket types therefore feed one `transparentSortEntries` argsort
3342
+ // step + share the `argsortInPlace` radix LSD primitive (plan-strategy
3343
+ // §D-1 / §D-3). The detection lives on the material side -- detect a
3344
+ // tilemap-spawned entity by `MeshFilter.assetHandle === HANDLE_QUAD`
3345
+ // plus the `forgeax::sprite` shader id on `MeshRenderer.material`'s
3346
+ // first pass + non-empty `values.region`; no new public ECS
3347
+ // marker component lands (charter F1 minimum surface).
3348
+ //
3349
+ // Layer.value is now folded into each DispatchEntry.layer (fLayerValue
3350
+ // column, read once per archetype pass above). render-system.ts
3351
+ // `sortTransparentDispatch` applies (layer ASC, sortValue ASC) for all
3352
+ // transparent-sort modes (0/1/2) using posY/pivotY/sizeY from the
3353
+ // parallel renderables[] snapshot -- no second ECS round-trip needed.
3354
+ // feat-20260527-sprite-nineslice M4 / w17 (AC-14): SpriteRegionOverride
3355
+ // per-entity UV sub-rectangle. When the entity carries this component the
3356
+ // 4-float `[uMin, vMin, uW, vH]` override displaces the asset-side
3357
+ // `values.region` for this entity only — downstream 9-slice logic
3358
+ // measures slices against this effective region.zw, so a half-width sub-
3359
+ // sprite reduces the anchor budget to 0.5 rather than the asset's 1.0.
3360
+ //
3361
+ // SpriteRegionOverride.region is a fixed stride-4 value. Per-row reads
3362
+ // route through `_getArrayView` for the zero-copy row window
3363
+ // row-window slice (consistent with the variable-length array column
3364
+ // reads -- K-3 carve-out keeps `_getArrayView` as the row-accessor of
3365
+ // record for each non-scalar column).
3366
+ const hasSpriteRegionOverride = row.has(SpriteRegionOverride);
3367
+ // feat-20260523-skin-skeleton-animation M2 / T-21: Skin component
3368
+ // column views for coexistence check + joint despawn fail-fast.
3369
+ // `skeleton` holds the packed Handle<SkeletonAsset>; `joints` holds
3370
+ // the packed Entity u32 array (N x one u32 each).
3371
+ const skinSkeletonView = skin?.skeleton;
3372
+
3373
+ let archVersion = 0;
3374
+ if (hasInstances || hasSpriteInstances) {
3375
+ // All rows in this callback share one archetype. Only these two
3376
+ // instance-bearing paths consume the version in their cache key.
3377
+ archVersion = resolveArchVersion(row.entity);
3378
+ }
3379
+
3380
+ {
3381
+ // feat-20260608 M2 / w11: read materials array via _getArrayView
3382
+ const entity = row.entity;
3383
+ if (isRenderable && visibility?.effective(entity) === 'hidden') {
3384
+ explicitlyHidden.add(entity);
3385
+ continue;
3386
+ }
3387
+ const layerVal = fLayerValue ?? 0;
3388
+ // bug-20260709-builtin-quad-withoutaabb-disables-sprite-frustum-cu M2.5
3389
+ // (carries PR #598 feat-20260703 D-7): dispatch entries for this entity
3390
+ // are staged locally and flushed into the shared `dispatch[]` array
3391
+ // ONLY when the entity survives the frustum-cull `continue` below —
3392
+ // same cull-passed branch as the paired `renderables.push`. Prior to
3393
+ // this fix the three `dispatch.push` sites ran before the cull check,
3394
+ // so a culled entity left dangling entries whose `renderableIndex`
3395
+ // aliased the slot a LATER visible entity occupied — surfacing as the
3396
+ // pbr-mesh-array-bgl vs hdrp-unified-bgl-group2 BGL/PL mismatch on
3397
+ // the deferred-shading smoke (PR #598 CI). Pure ordering fix; cull
3398
+ // logic and MeshRenderer contract unchanged.
3399
+ const pendingDispatch: DispatchEntry[] = [];
3400
+ const materialsView = worldInternal._getArrayView(entity, MeshRenderer, 'materials') as
3401
+ | Uint32Array
3402
+ | undefined;
3403
+ const materialCount = materialsView?.length ?? 0;
3404
+ let materialHandles = Array.from(materialsView ?? []);
3405
+ let materialBindingSources: MeshMaterialBindingSource[] = materialHandles.map(
3406
+ () => 'renderer-override',
3407
+ );
3408
+ let materialBindingDiagnostics: MeshMaterialBindingDiagnostic[] = [];
3409
+ let gpuDrivenSubmeshes: readonly MeshAsset['submeshes'][number][] = [];
3410
+ let gpuDrivenIndexed = false;
3411
+
3412
+ // Resolve instance overrides against mesh-owned slot defaults once.
3413
+ const fAssetHandleVal = fAssetHandle;
3414
+ if (
3415
+ fAssetHandleVal !== undefined &&
3416
+ fAssetHandleVal !== 0 &&
3417
+ assets !== undefined &&
3418
+ assets !== null
3419
+ ) {
3420
+ const meshHandle = toShared<'MeshAsset'>(fAssetHandleVal);
3421
+ const meshRes = resolveAssetHandle<Asset>(world, meshHandle);
3422
+ if (meshRes.ok && meshRes.value.kind === 'mesh') {
3423
+ const meshAsset = meshRes.value as MeshAsset;
3424
+ gpuDrivenSubmeshes = meshAsset.submeshes;
3425
+ gpuDrivenIndexed = meshAsset.indices !== undefined;
3426
+ // extractFrames is also a public read path and can run before the
3427
+ // Renderer.draw pre-render stage. GlyphText proves the derived mesh
3428
+ // owns exactly one Default slot; arbitrary meshes still fail closed
3429
+ // and never infer slot topology from their submeshes.
3430
+ if (!Array.isArray(meshAsset.materialSlots) && world.get(entity, GlyphText).ok) {
3431
+ ensureGlyphMeshMaterialSlots(world, meshHandle);
3432
+ }
3433
+ const guid = (meshRes.value as { guid?: string }).guid ?? '<no-guid>';
3434
+ const resolvedBindings = resolveMeshMaterialBindings(meshAsset, materialsView ?? [], {
3435
+ isValidOverride(handle) {
3436
+ const resolved = resolveAssetHandle(world, toShared<'MaterialAsset'>(handle));
3437
+ return resolved.ok && resolved.value.kind === 'material';
3438
+ },
3439
+ resolveMeshDefault(defaultGuid) {
3440
+ const guidText = AssetGuid.format(defaultGuid);
3441
+ if (assets.lookup<Asset>(guidText)?.kind !== 'material') return undefined;
3442
+ return internSharedRefFromGuid(world, assets, guidText, 'MaterialAsset') as
3443
+ | number
3444
+ | undefined;
3445
+ },
3446
+ });
3447
+ if (!resolvedBindings.ok) {
3448
+ if (resolvedBindings.code === 'mesh-material-slots-missing') {
3449
+ worldInternal._routeError(
3450
+ new AssetError({
3451
+ code: 'load-failed',
3452
+ expected: 'every MeshAsset producer supplies materialSlots[]',
3453
+ hint: `fix the MeshAsset producer; renderer inheritance never guesses slot topology (mesh=${guid}, entity=${entity}, vertices=${meshAsset.vertices.length}, indices=${meshAsset.indices?.length ?? 0})`,
3454
+ detail: {
3455
+ referencedByGuid: guid,
3456
+ referencedByKind: 'mesh',
3457
+ subAssetGuid: '<material-slots-missing>',
3458
+ sourceField: { fieldName: 'materialSlots' },
3459
+ },
3460
+ }),
3461
+ {
3462
+ severity: Severity.Error,
3463
+ systemName: 'RenderSystem.extract (mesh-material-slots-missing)',
3464
+ },
3465
+ );
3466
+ continue;
3467
+ }
3468
+ const materialGuid = AssetGuid.format(resolvedBindings.defaultMaterial);
3469
+ worldInternal._routeError(
3470
+ new AssetError({
3471
+ code: 'load-failed',
3472
+ expected: `MeshAsset materialSlots[${resolvedBindings.slotIndex}] default ${materialGuid} is ready and a MaterialAsset`,
3473
+ hint: `loadByGuid(meshGuid) must recursively load the declared default material; mesh=${guid}, slot=${resolvedBindings.slotIndex}, material=${materialGuid}`,
3474
+ detail: {
3475
+ referencedByGuid: guid,
3476
+ referencedByKind: 'mesh',
3477
+ subAssetGuid: materialGuid,
3478
+ sourceField: {
3479
+ fieldName: 'materialSlots',
3480
+ arrayIndex: resolvedBindings.slotIndex,
3481
+ },
3482
+ },
3483
+ }),
3484
+ {
3485
+ severity: Severity.Error,
3486
+ systemName: 'RenderSystem.extract (mesh-default-not-ready)',
3487
+ },
3488
+ );
3489
+ continue;
3490
+ }
3491
+ materialHandles = resolvedBindings.bindings.map((binding) => binding.handle);
3492
+ materialBindingSources = resolvedBindings.bindings.map((binding) => binding.source);
3493
+ materialBindingDiagnostics = resolvedBindings.diagnostics.map((diagnostic) => ({
3494
+ ...diagnostic,
3495
+ detail:
3496
+ diagnostic.code === 'mesh-renderer-material-override-overflow'
3497
+ ? {
3498
+ expectedCount: meshAsset.materialSlots.length,
3499
+ actualCount: materialCount,
3500
+ meshAssetGuid: guid,
3501
+ }
3502
+ : {
3503
+ meshAssetGuid: guid,
3504
+ slotIndex: diagnostic.slotIndex,
3505
+ handle: diagnostic.handle ?? 0,
3506
+ },
3507
+ }));
3508
+ for (const diagnostic of resolvedBindings.diagnostics) {
3509
+ worldInternal._routeError(
3510
+ new AssetError({
3511
+ code: diagnostic.code,
3512
+ expected:
3513
+ diagnostic.code === 'mesh-renderer-material-override-overflow'
3514
+ ? `materials.length <= materialSlots.length (${meshAsset.materialSlots.length})`
3515
+ : `materials[${diagnostic.slotIndex}] resolves to a live MaterialAsset`,
3516
+ hint: ASSET_ERROR_HINTS[diagnostic.code],
3517
+ detail:
3518
+ diagnostic.code === 'mesh-renderer-material-override-overflow'
3519
+ ? {
3520
+ expectedCount: meshAsset.materialSlots.length,
3521
+ actualCount: materialCount,
3522
+ meshAssetGuid: guid,
3523
+ }
3524
+ : {
3525
+ meshAssetGuid: guid,
3526
+ slotIndex: diagnostic.slotIndex,
3527
+ handle: diagnostic.handle ?? 0,
3528
+ },
3529
+ }),
3530
+ {
3531
+ severity: Severity.Warning,
3532
+ systemName: `RenderSystem.extract (${diagnostic.code})`,
3533
+ },
3534
+ );
3535
+ }
3536
+ }
3537
+ }
3538
+
3539
+ if (materialHandles.length === 0) {
3540
+ materialHandles = [0];
3541
+ materialBindingSources = ['engine-default'];
3542
+ }
3543
+
3544
+ // Use the first material handle for the entity-level snapshot
3545
+ // (shading-model dispatch routing + multi-pass DispatchEntry. Per-
3546
+ // submesh materials[i>=1] are resolved by `resolveMaterialSnapshot`
3547
+ // below into the `materials[]` array, used by the record stage to
3548
+ // upload N material UBO slots and bind the i-th slot before the
3549
+ // i-th submesh draw.) -- feat-20260608 M5 amend / w11-a.
3550
+ const handleRaw = materialHandles[0] ?? 0;
3551
+
3552
+ const cachedMaterial =
3553
+ handleRaw !== 0 && !hasSpriteRegionOverride && !hasSkin
3554
+ ? materialSnapshotCache.get(handleRaw)
3555
+ : undefined;
3556
+
3557
+ let materialSnap: MaterialSnapshot;
3558
+
3559
+ if (cachedMaterial !== undefined) {
3560
+ materialSnap = cachedMaterial.snapshot;
3561
+ if (isRenderable) {
3562
+ appendMaterialDispatchEntries(
3563
+ pendingDispatch,
3564
+ cachedMaterial.passes,
3565
+ entity,
3566
+ handleRaw,
3567
+ renderables.length,
3568
+ layerVal,
3569
+ materialSnap.paramSnapshot,
3570
+ );
3571
+ }
3572
+ } else if (handleRaw === 0 || assets === undefined || assets === null) {
3573
+ // case B: missing-spec sentinel -> mid-grey defaultMaterialSnapshot.
3574
+ materialSnap = defaultMaterialSnapshot(handleRaw);
3575
+ } else {
3576
+ const tagged = toShared<'MaterialAsset'>(handleRaw);
3577
+ const stablePersistentCached =
3578
+ !hasSpriteRegionOverride && !hasSkin
3579
+ ? readStablePersistentMaterialSnapshot(
3580
+ persistentMaterialSnapshotCache,
3581
+ handleRaw,
3582
+ assets,
3583
+ )
3584
+ : undefined;
3585
+ if (stablePersistentCached !== undefined) {
3586
+ materialSnapshotCache.set(handleRaw, stablePersistentCached);
3587
+ materialSnap = stablePersistentCached.snapshot;
3588
+ if (isRenderable) {
3589
+ appendMaterialDispatchEntries(
3590
+ pendingDispatch,
3591
+ stablePersistentCached.passes,
3592
+ entity,
3593
+ handleRaw,
3594
+ renderables.length,
3595
+ layerVal,
3596
+ materialSnap.paramSnapshot,
3597
+ );
3598
+ }
3599
+ } else {
3600
+ const res = resolveAssetHandle(world, tagged);
3601
+ if (!res.ok) {
3602
+ if (isRenderable) {
3603
+ const rhiErr = new RhiError({
3604
+ code: 'asset-not-registered',
3605
+ expected: 'MeshRenderer.material in AssetRegistry',
3606
+ hint: 'catalog the material via assetRegistry.catalog(guid, asset) + world.allocSharedRef before spawn, or remove the material field to fall back to default',
3607
+ detail: { assetHandle: handleRaw },
3608
+ });
3609
+ worldInternal._routeError(rhiErr, {
3610
+ severity: Severity.Error,
3611
+ systemName: 'RenderSystem.extract (material asset-not-registered)',
3612
+ });
3613
+ }
3614
+ continue;
3615
+ }
3616
+ const asset = res.value;
3617
+ if (asset.kind !== 'material') {
3618
+ materialSnap = defaultMaterialSnapshot(handleRaw);
3619
+ } else {
3620
+ // feat-20260529 M3 / w11: material parent chain inheritance via
3621
+ // read-through _materialWalk accessor (plan-strategy D-6).
3622
+ // The old direct asset.passes / asset.values read never
3623
+ // walked the parent chain, causing broken-inheritance (root cause).
3624
+ const resolvedResult = walkMaterialPassesOverSharedRefs(world, tagged, assets);
3625
+ if (!resolvedResult.ok) {
3626
+ // AC-09 / S-7 / q8=A: passes-empty or cycle must fire structured
3627
+ // error through _routeError (same routing as asset-not-registered
3628
+ // branch above). Silent continue is forbidden because it produces
3629
+ // a black screen indistinguishable from a content bug.
3630
+ const err = resolvedResult.error;
3631
+ switch (err.code) {
3632
+ case 'material-parent-not-found':
3633
+ case 'material-no-effective-pass':
3634
+ case 'material-value-unknown':
3635
+ case 'material-value-type-mismatch':
3636
+ case 'material-contract-program-mismatch':
3637
+ worldInternal._routeError(err, {
3638
+ severity: Severity.Error,
3639
+ systemName: `RenderSystem.extract (${err.code})`,
3640
+ });
3641
+ break;
3642
+ case 'material-circular-inheritance':
3643
+ worldInternal._routeError(err, {
3644
+ severity: Severity.Error,
3645
+ systemName: 'RenderSystem.extract (material-circular-inheritance)',
3646
+ });
3647
+ break;
3648
+ default:
3649
+ // Exhaustive guard: unhandled error codes from _materialWalk
3650
+ // surface an internal assertion to avoid silent continuation.
3651
+ worldInternal._routeError(err, {
3652
+ severity: Severity.Error,
3653
+ systemName: `RenderSystem.extract (_materialWalk: ${err.code})`,
3654
+ });
3655
+ }
3656
+ continue;
3657
+ }
3658
+ const resolved = resolvedResult.value;
3659
+ const allPasses = resolved.passes;
3660
+ const firstPassShader =
3661
+ allPasses.length > 0
3662
+ ? runtimeMaterialShaderId(allPasses[0]?.program.module, allPasses[0]?.name)
3663
+ : undefined;
3664
+ const pv = materialValuesToLinearRuntime(
3665
+ resolved.values,
3666
+ materialColorParameterSchema(resolved.parameters ?? [], firstPassShader, assets),
3667
+ resolved.colorSpace,
3668
+ ) as Readonly<Record<string, unknown>>;
3669
+
3670
+ const baseColorPv = pv.baseColor as readonly number[] | undefined;
3671
+ const baseColor = vec3.create(
3672
+ baseColorPv?.[0] ?? 1,
3673
+ baseColorPv?.[1] ?? 1,
3674
+ baseColorPv?.[2] ?? 1,
3675
+ );
3676
+ const metallicPv = typeof pv.metallic === 'number' ? pv.metallic : 0;
3677
+ const roughnessPv = typeof pv.roughness === 'number' ? pv.roughness : 0.5;
3678
+ const clearcoatPv = typeof pv.clearcoat === 'number' ? pv.clearcoat : 0;
3679
+ const clearcoatRoughnessPv =
3680
+ typeof pv.clearcoatRoughness === 'number' ? pv.clearcoatRoughness : 0.5;
3681
+ const specularTintPv = pv.specularTint as readonly number[] | undefined;
3682
+ const normalScalePv = materialNormalScale(pv);
3683
+
3684
+ const paramSnap: Record<string, number | number[] | string> = {};
3685
+ for (const [k, v] of Object.entries(pv)) {
3686
+ if (typeof v === 'number') paramSnap[k] = v;
3687
+ else if (typeof v === 'string') paramSnap[k] = v;
3688
+ else if (Array.isArray(v) && v.every((x) => typeof x === 'number')) {
3689
+ paramSnap[k] = v as number[];
3690
+ }
3691
+ }
3692
+
3693
+ const materialParamSchema = materialParametersToParamSchema(
3694
+ resolved.parameters ?? [],
3695
+ firstPassShader,
3696
+ );
3697
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
3698
+ // bidirectional Skin <-> pbr-skin material fail-fast at extract.
3699
+ // Skin component without a forgeax::pbr-skin first-pass material
3700
+ // would draw with a non-skin shader against the 18-float vertex
3701
+ // buffer (joints/weights bytes interpreted as garbage). Conversely
3702
+ // a forgeax::pbr-skin material against a 12-float (unskinned) mesh
3703
+ // would have @location(4)/@location(5) read uninitialized memory.
3704
+ // Both cases route through `_routeError` + `continue` so a single
3705
+ // misconfigured entity does NOT abort the whole frame's draw list
3706
+ // (charter P3 explicit failure + plan-decisions D-5 over `return err`).
3707
+ {
3708
+ const hasSkinSkel =
3709
+ hasSkin &&
3710
+ skinSkeletonView !== undefined &&
3711
+ skinSkeletonView !== undefined &&
3712
+ skinSkeletonView !== 0;
3713
+ const isPbrSkinMaterial = firstPassShader === 'forgeax::pbr-skin';
3714
+ if (hasSkinSkel && !isPbrSkinMaterial) {
3715
+ worldInternal._routeError(new SkinMaterialMismatchError(entity, firstPassShader), {
3716
+ severity: Severity.Error,
3717
+ systemName: 'RenderSystem.extract (skin-material-mismatch)',
3718
+ });
3719
+ continue;
3720
+ }
3721
+ if (isPbrSkinMaterial && fAssetHandleVal !== undefined && fAssetHandleVal !== 0) {
3722
+ const meshHandleForSkinCheck = toShared<'MeshAsset'>(fAssetHandleVal);
3723
+ const meshResForSkinCheck = resolveAssetHandle<MeshAsset>(
3724
+ world,
3725
+ meshHandleForSkinCheck,
3726
+ );
3727
+ if (meshResForSkinCheck.ok) {
3728
+ const meshAttrs = meshResForSkinCheck.value.attributes;
3729
+ const hasSkinIdx = meshAttrs.skinIndex !== undefined;
3730
+ const hasSkinWt = meshAttrs.skinWeight !== undefined;
3731
+ if (!hasSkinIdx || !hasSkinWt) {
3732
+ const missing: 'skinIndex' | 'skinWeight' | 'both' =
3733
+ !hasSkinIdx && !hasSkinWt ? 'both' : !hasSkinIdx ? 'skinIndex' : 'skinWeight';
3734
+ worldInternal._routeError(new MaterialSkinAttrMissingError(entity, missing), {
3735
+ severity: Severity.Error,
3736
+ systemName: 'RenderSystem.extract (material-skin-attr-missing)',
3737
+ });
3738
+ continue;
3739
+ }
3740
+ }
3741
+ }
3742
+ }
3743
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-3):
3744
+ // sprite materials now flow through the same generic paramSchema-
3745
+ // driven extract path PBR / unlit use. The narrow `forgeax::sprite`
3746
+ // exception block below covers exactly 2 plan-authorised cases:
3747
+ // 1. SpriteRegionOverride per-entity region displacement (Q4=a)
3748
+ // 2. flipX / flipY -> region fold (plan-strategy D-8)
3749
+ // No legacy values field-name shim; demos and SpriteParamValues
3750
+ // are UBO-aligned (no `texture` / `baseColor` / `pivot` / `slices`
3751
+ // / `sliceMode` keys reaching this code path). AGENTS.md §Change
3752
+ // stance: "no shim layer, no v1/v2 dual-path".
3753
+ //
3754
+ // feat-20260624 M1' / t6: `'forgeax::sprite-lit'` walks the same
3755
+ // sprite-family vertex path (VsOut byte-identical, paramSchema
3756
+ // mirror) so the SAME 2 folds apply — extending `isSprite` to
3757
+ // cover both shader ids keeps the narrowing-point count at 1
3758
+ // (plan-strategy §1.6 + D-1: mirror sprite, no new branch).
3759
+ const isSprite =
3760
+ firstPassShader === 'forgeax::sprite' || firstPassShader === 'forgeax::sprite-lit';
3761
+
3762
+ // feat-20260613-material-paramschema-driven-binding M4 / w23
3763
+ // (D-5 graceful): paramSchema-driven texture-field validation.
3764
+ // For each handle-shaped paramValue (typeof === 'number'),
3765
+ // verify it actually points at a registered texture asset
3766
+ // when the field is declared as a texture in the shader's
3767
+ // paramSchema; mis-typed handles (e.g. a scalar f32 stored as
3768
+ // int 0 the M4 / w22 graceful fallback resolved to a wrong
3769
+ // sub-asset) are dropped here so the record stage falls back
3770
+ // to MISSING_TEXTURE_HANDLE (default white) without raising.
3771
+ const validateTextureHandle = (
3772
+ fieldName: string,
3773
+ raw: unknown,
3774
+ ): Handle<'TextureAsset', 'shared'> | undefined => {
3775
+ // feat-20260614 M8 (D-19): a string value is an embedded texture
3776
+ // GUID; resolve it to a column handle via catalog + allocSharedRef
3777
+ // before validation. A number is an already-minted column handle.
3778
+ let handle: Handle<'TextureAsset', 'shared'>;
3779
+ const textureRef = materialTextureRef(raw);
3780
+ const textureGuid = assetReferenceText(textureRef);
3781
+ if (textureGuid !== undefined) {
3782
+ if (assets === null || assets === undefined) return undefined;
3783
+ // M4: intern so the GUID mints one stable handle per World
3784
+ // instead of a fresh slot every frame (GPU residency relies on
3785
+ // a stable handleSlot). onLastRelease -> gpuStore.evictTexture.
3786
+ const interned = internSharedRefFromGuid(
3787
+ world,
3788
+ assets,
3789
+ textureGuid,
3790
+ 'TextureAsset',
3791
+ );
3792
+ if (interned === undefined) return undefined;
3793
+ handle = interned;
3794
+ } else if (typeof textureRef === 'number') {
3795
+ handle = toShared<'TextureAsset'>(textureRef);
3796
+ } else {
3797
+ return undefined;
3798
+ }
3799
+ if (assets === null || assets === undefined) return handle;
3800
+ const declaredFields = materialTextureFields(
3801
+ firstPassShader,
3802
+ materialParamSchema.length > 0
3803
+ ? derive(materialParamSchema).textureFieldNames
3804
+ : firstPassShader !== undefined
3805
+ ? assets.materialShaderTextureFieldNames(firstPassShader)
3806
+ : undefined,
3807
+ );
3808
+ // Shader not registered (R-4 cross-worktree path) -> trust the
3809
+ // raw handle and let the record stage / GPU layer surface each
3810
+ // mismatch via MISSING_TEXTURE_HANDLE.
3811
+ if (declaredFields === undefined) return handle;
3812
+ // Field is not declared as a texture by the shader -> the
3813
+ // loader's "try every int" fallback misclassified a scalar;
3814
+ // drop the slot so the record stage uses the default white.
3815
+ if (
3816
+ !declaredFields.has(fieldName) &&
3817
+ !isEngineInjectedTextureField(firstPassShader, fieldName)
3818
+ ) {
3819
+ return undefined;
3820
+ }
3821
+ // Field declared as texture: verify the handle's asset kind.
3822
+ const assetRes = resolveAssetHandle(world, handle);
3823
+ if (!assetRes.ok) return undefined;
3824
+ const kind = (assetRes.value as { kind?: string }).kind;
3825
+ if (kind !== 'texture') return undefined;
3826
+ return handle;
3827
+ };
3828
+ // feat-20260614 M8 (D-19): resolve a sampler / texture paramValue
3829
+ // that may be an embedded GUID string (catalog + allocSharedRef) or
3830
+ // an already-minted column handle (number passthrough).
3831
+ const resolveParamHandle = <B extends string>(
3832
+ raw: unknown,
3833
+ brand: B,
3834
+ ): Handle<B, 'shared'> | undefined => {
3835
+ const value = materialTextureRef(raw);
3836
+ if (typeof value === 'number') return toShared<B>(value);
3837
+ const guid = assetReferenceText(value);
3838
+ if (guid !== undefined) {
3839
+ if (assets === null || assets === undefined) return undefined;
3840
+ // M4: intern the GUID -> column-handle resolution (one stable
3841
+ // handle per (world, guid, brand), reused across frames).
3842
+ return internSharedRefFromGuid(world, assets, guid, brand);
3843
+ }
3844
+ return undefined;
3845
+ };
3846
+ // feat-20260621-learn-render-5-5-parallax M2 / w7 (D-3): iterate the
3847
+ // shader's derive(paramSchema).textureFieldNames SSOT so the Nth
3848
+ // user-region texture (e.g. parallax heightTexture) is validated +
3849
+ // carried, replacing the hardcoded 3-field list. validateTextureHandle
3850
+ // already drops fields a shader doesn't declare as a texture.
3851
+ const userRegionFields =
3852
+ materialTextureFields(
3853
+ firstPassShader,
3854
+ materialParamSchema.length > 0
3855
+ ? derive(materialParamSchema).textureFieldNames
3856
+ : firstPassShader !== undefined && assets !== null && assets !== undefined
3857
+ ? assets.materialShaderTextureFieldNames(firstPassShader)
3858
+ : undefined,
3859
+ ) ?? BUILTIN_USER_REGION_TEXTURE_FIELDS;
3860
+ const textureHandles = new Map<string, Handle<'TextureAsset', 'shared'>>();
3861
+ const videoTextureFields = new Map<string, Handle<'VideoAsset', 'shared'>>();
3862
+ for (const field of userRegionFields) {
3863
+ // D-5: a video-kind paramValue routes to the transient path
3864
+ // (videoTextureFields), NOT validateTextureHandle (which drops
3865
+ // kind!=='texture', the R-7 silent-fail path). Static fields fall
3866
+ // through to validateTextureHandle unchanged.
3867
+ const videoHandle =
3868
+ assets !== null && assets !== undefined
3869
+ ? resolveVideoFieldHandle(pv[field], world, assets)
3870
+ : undefined;
3871
+ if (videoHandle !== undefined) {
3872
+ videoTextureFields.set(field, videoHandle);
3873
+ continue;
3874
+ }
3875
+ const handle = validateTextureHandle(field, pv[field]);
3876
+ if (handle !== undefined) textureHandles.set(field, handle);
3877
+ }
3878
+ const baseColorTextureHandle = textureHandles.get('baseColorTexture');
3879
+ const metallicRoughnessTextureHandle = textureHandles.get('metallicRoughnessTexture');
3880
+ const normalTextureHandle = textureHandles.get('normalTexture');
3881
+ const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
3882
+ resolveParamHandle(materialTextureRef(value), 'SamplerAsset'),
3883
+ );
3884
+ const emissiveTextureHandle = validateTextureHandle(
3885
+ 'emissiveTexture',
3886
+ pv.emissiveTexture,
3887
+ );
3888
+ const occlusionTextureHandle = validateTextureHandle(
3889
+ 'occlusionTexture',
3890
+ pv.occlusionTexture,
3891
+ );
3892
+ const textureCoordinates = collectMaterialTextureCoordinates(pv);
3893
+ const emissivePv = pv.emissive as readonly number[] | undefined;
3894
+ // feat-20260625 M2 / w6: first-pass transparency flag folds into
3895
+ // MaterialSnapshot.transparent so the record stage can drive the
3896
+ // LDR split + premultiplied-alpha blend decision without
3897
+ // re-reading passes[]. feat-20260626-collapse M2: derive from
3898
+ // `passes[0].renderState.blend !== undefined` (blend presence is
3899
+ // the SSOT after MaterialPass.transparent was dropped).
3900
+ // Result is plain boolean (always defined here) — written as-is
3901
+ // into the snapshot (`boolean | undefined` field, see L759).
3902
+ const firstPassTransparent: boolean = allPasses[0]?.renderState?.blend !== undefined;
3903
+
3904
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w12 (D-8):
3905
+ // narrow `forgeax::sprite` extract block --- folds the legacy user
3906
+ // values format (flipX / flipY / slices / sliceMode + free
3907
+ // region / pivot) into the UBO-aligned paramSnapshot vec4 fields
3908
+ // (region / pivotAndSize / slicesAndMode + colorTint). Also folds
3909
+ // per-entity SpriteRegionOverride (Q4=a). After this block the
3910
+ // generic else branch picks up the snapshot via the same writer
3911
+ // path PBR / unlit use; no more shadingModel='sprite' arm, no
3912
+ // spriteFields POD (AC-02 / AC-07: extract has exactly 2 hard
3913
+ // `forgeax::sprite` checks --- this fold + the slices mesh swap on
3914
+ // the record side).
3915
+ if (isSprite) {
3916
+ // SpriteRegionOverride: per-entity per-frame region displacement.
3917
+ let overrideRegion: readonly [number, number, number, number] | undefined;
3918
+ if (hasSpriteRegionOverride) {
3919
+ const overrideView = worldInternal._getArrayView(
3920
+ entity,
3921
+ SpriteRegionOverride,
3922
+ 'region',
3923
+ ) as Float32Array | undefined;
3924
+ if (overrideView !== undefined && overrideView.length >= 4) {
3925
+ overrideRegion = [
3926
+ overrideView[0] ?? 0,
3927
+ overrideView[1] ?? 0,
3928
+ overrideView[2] ?? 1,
3929
+ overrideView[3] ?? 1,
3930
+ ];
3931
+ }
3932
+ }
3933
+ // Region resolution priority: SpriteRegionOverride > paramSnapshot.
3934
+ // region (UBO-aligned user input) > [0,0,1,1] identity.
3935
+ const regionPv = paramSnap.region as readonly number[] | undefined;
3936
+ let regionX = overrideRegion?.[0] ?? regionPv?.[0] ?? 0;
3937
+ let regionY = overrideRegion?.[1] ?? regionPv?.[1] ?? 0;
3938
+ let regionZ = overrideRegion?.[2] ?? regionPv?.[2] ?? 1;
3939
+ let regionW = overrideRegion?.[3] ?? regionPv?.[3] ?? 1;
3940
+ // flipX / flipY fold into region (D-8): the shader does
3941
+ // `uv * region.zw + region.xy`, so flipping along U is a sign
3942
+ // negation of region.z plus an origin offset.
3943
+ const flipXPv = typeof pv.flipX === 'number' ? pv.flipX : 0;
3944
+ const flipYPv = typeof pv.flipY === 'number' ? pv.flipY : 0;
3945
+ if (flipXPv !== 0) {
3946
+ regionX += regionZ;
3947
+ regionZ = -regionZ;
3948
+ }
3949
+ if (flipYPv !== 0) {
3950
+ regionY += regionW;
3951
+ regionW = -regionW;
3952
+ }
3953
+ paramSnap.region = [regionX, regionY, regionZ, regionW];
3954
+ // Guard: slicesAndMode must be present and zero for non-9-slice
3955
+ // sprites so the record-stage UBO writer (applyParamSnapshotToUbo)
3956
+ // writes [0,0,0,0] at offset 48 instead of leaving the
3957
+ // buildPbrMaterialUboPayload PBR baseline (e.g. occlusionStrength=1
3958
+ // at that slot). A non-zero slicesAndMode trips `useSlices=true`
3959
+ // in sprite.wgsl, which degenerates HANDLE_QUAD geometry → invisible.
3960
+ if (!('slicesAndMode' in paramSnap)) {
3961
+ (paramSnap as Record<string, unknown>).slicesAndMode = [0, 0, 0, 0];
3962
+ }
3963
+ }
3964
+
3965
+ // Generic materialShaderId snapshot --- sprite included now flows
3966
+ // through this single branch (plan-strategy D-3 / AC-01 / AC-02 /
3967
+ // AC-07). The sprite block above only writes paramSnap.region (D-8
3968
+ // SpriteRegionOverride + flip fold); the rest of the UBO is filled
3969
+ // by the same paramSchema-driven path PBR / unlit use.
3970
+ materialSnap = {
3971
+ baseColor,
3972
+ metallic: metallicPv,
3973
+ roughness: roughnessPv,
3974
+ clearcoat: clearcoatPv,
3975
+ clearcoatRoughness: clearcoatRoughnessPv,
3976
+ ...(specularTintPv !== undefined && {
3977
+ specularTint: [
3978
+ specularTintPv[0] ?? 1,
3979
+ specularTintPv[1] ?? 1,
3980
+ specularTintPv[2] ?? 1,
3981
+ ] as readonly [number, number, number],
3982
+ }),
3983
+ normalScale: normalScalePv,
3984
+ materialShaderId: firstPassShader,
3985
+ materialHandle: handleRaw,
3986
+ renderState: pipelineRenderState(allPasses[0]?.renderState),
3987
+ paramSnapshot: paramSnap,
3988
+ ...(materialParamSchema.length > 0 && { materialParamSchema }),
3989
+ ...(textureCoordinates.size > 0 && { textureCoordinates }),
3990
+ ...(samplerHandles.size > 0 && { samplerHandles }),
3991
+ ...(textureHandles.size > 0 && { textureHandles }),
3992
+ ...(videoTextureFields.size > 0 && { videoTextureFields }),
3993
+ ...(baseColorTextureHandle !== undefined && {
3994
+ baseColorTexture: baseColorTextureHandle,
3995
+ }),
3996
+ ...(metallicRoughnessTextureHandle !== undefined && {
3997
+ metallicRoughnessTexture: metallicRoughnessTextureHandle,
3998
+ }),
3999
+ ...(normalTextureHandle !== undefined && { normalTexture: normalTextureHandle }),
4000
+ ...(emissivePv !== undefined && {
4001
+ emissive: [emissivePv[0] ?? 0, emissivePv[1] ?? 0, emissivePv[2] ?? 0] as readonly [
4002
+ number,
4003
+ number,
4004
+ number,
4005
+ ],
4006
+ }),
4007
+ ...(typeof pv.emissiveIntensity === 'number' && {
4008
+ emissiveIntensity: pv.emissiveIntensity,
4009
+ }),
4010
+ ...(emissiveTextureHandle !== undefined && {
4011
+ emissiveTexture: emissiveTextureHandle,
4012
+ }),
4013
+ ...(occlusionTextureHandle !== undefined && {
4014
+ occlusionTexture: occlusionTextureHandle,
4015
+ }),
4016
+ ...(typeof pv.occlusionStrength === 'number' && {
4017
+ occlusionStrength: pv.occlusionStrength,
4018
+ }),
4019
+ transparent: firstPassTransparent,
4020
+ };
4021
+
4022
+ if (!hasSpriteRegionOverride && !hasSkin) {
4023
+ const stored = storeMaterialSnapshot(
4024
+ materialSnapshotCache,
4025
+ handleRaw,
4026
+ materialSnap,
4027
+ allPasses,
4028
+ asset,
4029
+ assets,
4030
+ );
4031
+ if (stored.crossFrameSafe) persistentMaterialSnapshotCache?.set(handleRaw, stored);
4032
+ }
4033
+
4034
+ // Build dispatch entries from resolved passes.
4035
+ if (isRenderable) {
4036
+ appendMaterialDispatchEntries(
4037
+ pendingDispatch,
4038
+ allPasses,
4039
+ entity,
4040
+ handleRaw,
4041
+ renderables.length,
4042
+ layerVal,
4043
+ paramSnap,
4044
+ );
4045
+ }
4046
+ }
4047
+ }
4048
+ }
4049
+
4050
+ // feat-20260609 M2/M5 corrective fixup: default-material entities
4051
+ // (handleRaw===0 / case-B MeshRenderer{data:{}}) must produce
4052
+ // ShadowCaster dispatch entries so the shadow pass includes them.
4053
+ // The pre-existing logic only builds dispatch entries from
4054
+ // resolved material assets; defaultMaterialSnapshot() (mid-grey unlit)
4055
+ // left dispatch empty, causing shadow-m2/m3 test failures.
4056
+ // Requirements §10.5: shadow-casting is default behaviour, opt-out
4057
+ // via castShadow:false. The default material has no opt-out, so
4058
+ // it casts shadows.
4059
+ //
4060
+ // CHARTER NOTE (feat-20260609 T-005-a): the URP literals
4061
+ // `LightMode: 'ShadowCaster'` / `LightMode: 'Forward'` below are a
4062
+ // local URP-bridge — they mirror what `Materials.unlit({ castShadow:
4063
+ // true })` produces at the asset layer. The default-material
4064
+ // handle=0 path bypasses asset registration, so we synthesize the
4065
+ // same dispatch shape inline. Follow-up cleanup (F-1 from
4066
+ // implement-review R1): thread default materials through the
4067
+ // Materials factory so this block can call into the shared
4068
+ // passes[] producer.
4069
+ // tweak-20260701 M1: `materialSnap.shadingModel === 'unlit'` removed —
4070
+ // for handleRaw===0, defaultMaterialSnapshot() was always unlit
4071
+ // (the shadingModel check was a tautology); the isRenderable &&
4072
+ // handleRaw===0 guard alone preserves the exact same dispatch shape.
4073
+ if (isRenderable && handleRaw === 0) {
4074
+ const shadowCasterTags: Record<string, string> = { LightMode: 'ShadowCaster' };
4075
+ const nextRenderableIndex = renderables.length;
4076
+ // M2.5: stage into pendingDispatch; flushed at the renderable push
4077
+ // site below only when the entity survives cull.
4078
+ pendingDispatch.push({
4079
+ entityIndex: entity,
4080
+ materialHandle: 0,
4081
+ renderableIndex: nextRenderableIndex,
4082
+ passIndex: 0,
4083
+ queue: 2000,
4084
+ layer: layerVal,
4085
+ tags: shadowCasterTags,
4086
+ renderState: undefined,
4087
+ defines: undefined,
4088
+ vertexEntry: 'vs_main',
4089
+ fragmentEntry: undefined,
4090
+ materialShaderId: 'forgeax::default-shadow-caster',
4091
+ paramSnapshot: {},
4092
+ });
4093
+ // Also add a Forward pass entry so the entity renders in the
4094
+ // main scene pass (mirrors Materials.unlit default).
4095
+ appendMaterialDispatchEntries(
4096
+ pendingDispatch,
4097
+ [DEFAULT_FORWARD_PASS],
4098
+ entity,
4099
+ 0,
4100
+ nextRenderableIndex,
4101
+ layerVal,
4102
+ {},
4103
+ 1,
4104
+ );
4105
+ }
4106
+
4107
+ if (isRenderable) {
4108
+ // feat-20260612 M2 / m2-6: Skin + Instances coexistence + per-joint
4109
+ // dangling fail-fast + real palette slice allocation. Replaces the
4110
+ // T-21 placeholder ({0,0} discriminator-only sentinel) with full
4111
+ // resolve / validate / write chain (D-9 reset already fired at
4112
+ // extractFrame entry; per-entity allocate + writeJointPalette here).
4113
+ let skinSlice: SkinPaletteSlice | undefined;
4114
+ if (hasSkin) {
4115
+ const skeletonHandleRaw = skinSkeletonView;
4116
+ if (
4117
+ skeletonHandleRaw !== undefined &&
4118
+ skeletonHandleRaw !== 0 &&
4119
+ assets !== undefined &&
4120
+ assets !== null
4121
+ ) {
4122
+ // Skin + Instances coexistence is forbidden (D-10).
4123
+ if (hasInstances) {
4124
+ worldInternal._routeError(new SkinInstancesCoexistForbiddenError(entity), {
4125
+ severity: Severity.Error,
4126
+ systemName: 'RenderSystem.extract (skin-instances-coexist)',
4127
+ });
4128
+
4129
+ continue;
4130
+ }
4131
+ // (a) Resolve skeleton asset; on failure -> skeleton-resolve-failed.
4132
+ const skeletonHandle = toShared<'SkeletonAsset'>(skeletonHandleRaw);
4133
+ const skeletonRes = resolveAssetHandle<SkeletonAsset>(world, skeletonHandle);
4134
+ if (!skeletonRes.ok || skeletonRes.value.kind !== 'skeleton') {
4135
+ worldInternal._routeError(new SkeletonResolveFailedError(entity, skeletonHandleRaw), {
4136
+ severity: Severity.Error,
4137
+ systemName: 'RenderSystem.extract (skeleton-resolve-failed)',
4138
+ });
4139
+ continue;
4140
+ }
4141
+ const skeleton = skeletonRes.value;
4142
+ // (b) Reuse the Skin row already read for its skeleton handle;
4143
+ // the old path repeated a second whole-row world.get here.
4144
+ const skinJoints = skin?.joints;
4145
+ if (skinJoints === undefined) continue;
4146
+ const jointsLength = skinJoints.length;
4147
+ if (jointsLength !== skeleton.jointCount) {
4148
+ worldInternal._routeError(
4149
+ new JointCountMismatchError(entity, skeleton.jointCount, jointsLength),
4150
+ {
4151
+ severity: Severity.Error,
4152
+ systemName: 'RenderSystem.extract (joint-count-mismatch)',
4153
+ },
4154
+ );
4155
+ continue;
4156
+ }
4157
+ // (c) Resolve only each joint's Transform.world column. The
4158
+ // transient view is consumed before a structural mutation and
4159
+ // therefore preserves the same dangling-joint behavior without
4160
+ // constructing every other Transform field.
4161
+ // Build mat4 list eagerly so write happens once per entity (no
4162
+ // half-written slice on dangling).
4163
+ const jointWorlds = new Array<Mat4>(skeleton.jointCount);
4164
+ let jointDangling = -1;
4165
+ for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
4166
+ const jointEntityRaw = skinJoints[jIdx] ?? 0;
4167
+ const jointEntity = jointEntityRaw as EntityHandle;
4168
+ const jointWorld = worldInternal._getArrayView(jointEntity, Transform, 'world');
4169
+ if (jointWorld === undefined) {
4170
+ jointDangling = jIdx;
4171
+ break;
4172
+ }
4173
+ // The allocator consumes the canonical Mat4 brand. Copy the
4174
+ // column view into that owner-created value so the ECS storage
4175
+ // view never crosses the math brand boundary by assertion.
4176
+ const jointWorldMat = mat4.create();
4177
+ jointWorldMat.set(jointWorld);
4178
+ jointWorlds[jIdx] = jointWorldMat;
4179
+ }
4180
+ if (jointDangling >= 0) {
4181
+ worldInternal._routeError(new JointEntityDanglingError(entity, jointDangling), {
4182
+ severity: Severity.Error,
4183
+ systemName: 'RenderSystem.extract (joint-entity-dangling)',
4184
+ });
4185
+ continue;
4186
+ }
4187
+ // (d) Slice the IBM flat Float32Array into per-joint Float32Arrays.
4188
+ // skeleton.inverseBindMatrices length === jointCount * 16.
4189
+ const ibmFlat = skeleton.inverseBindMatrices;
4190
+ const ibms: Float32Array[] = new Array<Float32Array>(skeleton.jointCount);
4191
+ for (let jIdx = 0; jIdx < skeleton.jointCount; jIdx++) {
4192
+ ibms[jIdx] = ibmFlat.subarray(jIdx * 16, jIdx * 16 + 16);
4193
+ }
4194
+ // (e) Allocate slice + write palette via the allocator (D-9 reset
4195
+ // already fired at extractFrame entry). When the pipelineState
4196
+ // surface is absent (test fixtures that pass undefined) the
4197
+ // hasSkin segment is skipped silently — bind-pose equivalent.
4198
+ if (skinPaletteAllocator !== null) {
4199
+ const slice = skinPaletteAllocator.allocateSlice(skeleton.jointCount);
4200
+ skinPaletteAllocator.writeJointPalette(slice, ibms, jointWorlds);
4201
+ skinSlice = {
4202
+ jointCount: slice.jointCount,
4203
+ byteOffset: slice.byteOffset,
4204
+ buffer: slice.buffer,
4205
+ };
4206
+ }
4207
+ }
4208
+ }
4209
+
4210
+ // feat-20260601 D-3: read the resolved world mat4 (propagateTransforms
4211
+ // output) straight from the Transform.world column array view. The
4212
+ // record stage copies these 16 floats into the mesh SSBO with zero
4213
+ // per-snapshot `mat4.compose` (AC-07). A stale slot (generation gone)
4214
+ // skips the renderable, mirroring the Instances dangling-row sweep.
4215
+ // tweak-20260611 M1 / K-3: `_getArrayView` call survives untouched;
4216
+ // only the `entity` source switched to `bundle.Entity.self[i]`.
4217
+ const worldView = worldInternal._getArrayView(entity, Transform, 'world');
4218
+ if (worldView === undefined) continue;
4219
+ const worldMat = new Float32Array(worldView);
4220
+ const transformSnap: TransformSnapshot = { world: worldMat };
4221
+ // feat-20260608 M5 amend / w11-a: per-submesh `materials[]` array
4222
+ // aligned 1-1 with `MeshAsset.submeshes[]`. materials[0] === the
4223
+ // representative entity-level snapshot already built; materials[i>=1]
4224
+ // are resolved via `resolveMaterialSnapshot` (a non-sprite, single-
4225
+ // pass-equivalent resolver — sprite per-submesh is OOS-1). When the
4226
+ // entity has no materialsView (case-B sentinel) the array is a single
4227
+ // mid-grey default mirroring the legacy single-material path so the
4228
+ // record stage's per-submesh UBO upload loop trivially writes one
4229
+ // slot, no special branch.
4230
+ const materialsArr: MaterialSnapshot[] = [materialSnap];
4231
+ if (assets !== undefined && assets !== null) {
4232
+ for (let mi = 1; mi < materialHandles.length; mi++) {
4233
+ const subHandle = materialHandles[mi] ?? 0;
4234
+ const cachedSubmaterial = materialSnapshotCache.get(subHandle);
4235
+ materialsArr.push(
4236
+ cachedSubmaterial?.snapshot ??
4237
+ resolveMaterialSnapshot(
4238
+ subHandle,
4239
+ world,
4240
+ assets,
4241
+ materialSnapshotCache,
4242
+ persistentMaterialSnapshotCache,
4243
+ ),
4244
+ );
4245
+ }
4246
+ }
4247
+ if (isRenderable && assets !== undefined && assets !== null) {
4248
+ for (let mi = 1; mi < materialHandles.length; mi++) {
4249
+ const subHandle = materialHandles[mi] ?? 0;
4250
+ if (subHandle === handleRaw) continue;
4251
+ const subEntry =
4252
+ materialSnapshotCache.get(subHandle) ??
4253
+ readPersistentMaterialSnapshot(persistentMaterialSnapshotCache, subHandle, assets);
4254
+ appendMaterialDispatchEntries(
4255
+ pendingDispatch,
4256
+ subEntry?.passes ?? (subHandle === 0 ? [DEFAULT_FORWARD_PASS] : []),
4257
+ entity,
4258
+ subHandle,
4259
+ renderables.length,
4260
+ layerVal,
4261
+ materialsArr[mi]?.paramSnapshot,
4262
+ );
4263
+ }
4264
+ }
4265
+
4266
+ // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M3 /
4267
+ // w10: SpriteInstances validation + snapshot materialisation.
4268
+ // Three structured EcsError fires at this single point (plan-strategy
4269
+ // D-6 "fail-fast at the render domain entry, not at ECS spawn-time"):
4270
+ let spriteInstancesSnap: SpriteInstancesSnapshot | undefined;
4271
+ if (hasSpriteInstances) {
4272
+ // (1) mutually exclusive with Instances (peers — pick one).
4273
+ if (hasInstances) {
4274
+ worldInternal._routeError(
4275
+ new SpriteInstancesMutuallyExclusiveWithInstancesError(entity),
4276
+ {
4277
+ severity: Severity.Error,
4278
+ systemName: 'RenderSystem.extract (sprite-instances-mutually-exclusive)',
4279
+ },
4280
+ );
4281
+ continue;
4282
+ }
4283
+ // (2) requires sprite shader — the per-instance UV region is
4284
+ // consumed by the sprite vertex shader path only (plan-strategy D-4
4285
+ // axis on sprite.wgsl). Post-collapse (PR #520): sprite is no longer
4286
+ // a `shadingModel` enum member; identification is via the first-pass
4287
+ // `materialShaderId === 'forgeax::sprite'` (OOS-1 path retained).
4288
+ //
4289
+ // feat-20260624 M1' / t6: `'forgeax::sprite-lit'` also walks the same
4290
+ // per-instance UV region vertex path (VsOut byte-identical, paramSchema
4291
+ // mirror); accept either shader id.
4292
+ if (
4293
+ materialSnap.materialShaderId !== 'forgeax::sprite' &&
4294
+ materialSnap.materialShaderId !== 'forgeax::sprite-lit'
4295
+ ) {
4296
+ worldInternal._routeError(
4297
+ new SpriteInstancesRequiresSpriteShaderError(
4298
+ entity,
4299
+ materialSnap.materialShaderId ?? 'undefined',
4300
+ ),
4301
+ {
4302
+ severity: Severity.Error,
4303
+ systemName: 'RenderSystem.extract (sprite-instances-requires-sprite-shader)',
4304
+ },
4305
+ );
4306
+ continue;
4307
+ }
4308
+ // (3) count mismatch — transforms.length / 16 === regions.length / 4
4309
+ // (transforms.length=0 + regions.length=0 is the zero-instance lawful
4310
+ // boundary; both derivations are 0 and equality holds, so no fire).
4311
+ const transforms = worldInternal._getArrayView(entity, SpriteInstances, 'transforms');
4312
+ const regions = worldInternal._getArrayView(entity, SpriteInstances, 'regions');
4313
+ if (transforms !== undefined && regions !== undefined) {
4314
+ const transformsLength = transforms.length;
4315
+ const regionsLength = regions.length;
4316
+ // Stride sanity: transforms must be mod 16, regions must be mod 4.
4317
+ // A stride violation expresses as a count mismatch under the
4318
+ // canonical derivation transforms/16 vs regions/4 — fire the
4319
+ // count-mismatch code (the same code carries detail.expectedStride).
4320
+ const tCount = transformsLength / 16;
4321
+ const rCount = regionsLength / 4;
4322
+ if (transformsLength % 16 !== 0 || regionsLength % 4 !== 0 || tCount !== rCount) {
4323
+ worldInternal._routeError(
4324
+ new SpriteInstancesCountMismatchError(transformsLength, regionsLength),
4325
+ {
4326
+ severity: Severity.Error,
4327
+ systemName: 'RenderSystem.extract (sprite-instances-count-mismatch)',
4328
+ },
4329
+ );
4330
+ continue;
4331
+ }
4332
+ // Validation passes — build the snapshot. transforms.length === 0
4333
+ // is lawful (zero-instance) and produces instanceCount=0; the
4334
+ // record stage skips drawIndexed when instanceCount===0.
4335
+ const transformsCopy = new Float32Array(transforms);
4336
+ const regionsCopy = new Float32Array(regions);
4337
+ spriteInstancesSnap = {
4338
+ transforms: transformsCopy,
4339
+ regions: regionsCopy,
4340
+ instanceCount: tCount,
4341
+ cacheKey: entity,
4342
+ archVersion,
4343
+ };
4344
+ }
4345
+ }
4346
+
4347
+ let localAabb: Float32Array | undefined;
4348
+ let morph: MorphSnapshot | undefined;
4349
+ const assetHandleRaw = Math.round(fAssetHandle ?? 0);
4350
+ if (assetHandleRaw !== 0) {
4351
+ const meshRes = resolveAssetHandle(world, toShared<'MeshAsset'>(assetHandleRaw));
4352
+ if (meshRes.ok && meshRes.value.kind === 'mesh') {
4353
+ const meshAsset = meshRes.value as MeshAsset;
4354
+ morph = morphSnapshotFor(meshAsset, morphWeightsView);
4355
+ const meshAabb = meshAsset.aabb;
4356
+ // Morph targets can expand the authored bounds. Keep the
4357
+ // specialized lane conservative until a target-aware bounds
4358
+ // projection is available; a false-positive draw is preferable
4359
+ // to culling a valid deformed vertex.
4360
+ if (morph === undefined && hasFiniteOrderedLocalAabb(meshAabb)) {
4361
+ localAabb = new Float32Array(meshAabb);
4362
+ }
4363
+ }
4364
+ }
4365
+
4366
+ const pointsLinesStyle: PointsLinesStyle | undefined =
4367
+ points !== undefined
4368
+ ? (() => {
4369
+ const shape = pointShapeFromU32(points.shape);
4370
+ return shape === undefined
4371
+ ? undefined
4372
+ : { kind: 'points' as const, sizePx: points.sizePx, shape };
4373
+ })()
4374
+ : lines === undefined
4375
+ ? undefined
4376
+ : { kind: 'lines' as const, widthPx: lines.widthPx };
4377
+ const cullingLocalAabb = expandPointsLinesBounds(localAabb ?? [], pointsLinesStyle);
4378
+ const pointsLines =
4379
+ pointsLinesComponent === undefined
4380
+ ? undefined
4381
+ : ({
4382
+ worldId: 0,
4383
+ entityKey: entity,
4384
+ component: pointsLinesComponent,
4385
+ meshHandle: assetHandleRaw,
4386
+ meshGeneration: assets?.catalogEpoch ?? 0,
4387
+ materialHandle: handleRaw,
4388
+ materialGeneration: assets?.catalogEpoch ?? 0,
4389
+ style: pointsLinesStyle,
4390
+ layer: layerVal,
4391
+ sortKey,
4392
+ visible: true,
4393
+ sourceBounds: cullingLocalAabb,
4394
+ viewport: { width: 0, height: 0, dpr: 1 },
4395
+ projection: identityProjection(),
4396
+ } satisfies PointsLinesRetainedSnapshot);
4397
+
4398
+ let nonIndexedFirst = 0;
4399
+ const gpuDrivenDraws = gpuDrivenSubmeshes.flatMap((submesh) => {
4400
+ const drawMaterial = materialsArr[submesh.materialSlot] ?? materialSnap;
4401
+ const first = gpuDrivenIndexed ? submesh.indexOffset : nonIndexedFirst;
4402
+ nonIndexedFirst += submesh.vertexCount;
4403
+ if (drawMaterial.transparent === true) return [];
4404
+ return [
4405
+ {
4406
+ kind: gpuDrivenIndexed ? ('indexed' as const) : ('non-indexed' as const),
4407
+ first,
4408
+ count: gpuDrivenIndexed ? submesh.indexCount : submesh.vertexCount,
4409
+ baseVertex: 0,
4410
+ materialSlot: submesh.materialSlot,
4411
+ topology: submesh.topology,
4412
+ pipelineClass: `${drawMaterial.materialShaderId ?? 'forgeax::default-unlit'}|${submesh.topology}|${JSON.stringify(drawMaterial.renderState ?? null)}`,
4413
+ materialResourceClass: gpuDrivenMaterialResourceClass(drawMaterial),
4414
+ },
4415
+ ];
4416
+ });
4417
+ const baseRenderable: RenderableSnapshot = {
4418
+ assetHandle: Math.round(fAssetHandle ?? 0),
4419
+ transform: transformSnap,
4420
+ ...(localAabb !== undefined ? { localAabb: cullingLocalAabb } : {}),
4421
+ material: materialSnap,
4422
+ materials: materialsArr,
4423
+ materialBindingSources,
4424
+ materialBindingDiagnostics,
4425
+ ...(morph === undefined && gpuDrivenDraws.length > 0 ? { gpuDrivenDraws } : {}),
4426
+ worldId: 0,
4427
+ entityKey: entity,
4428
+ ...(skinSlice !== undefined ? { skin: skinSlice } : {}),
4429
+ ...(morph !== undefined ? { morph } : {}),
4430
+ ...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),
4431
+ ...(pointsLines !== undefined ? { pointsLines } : {}),
4432
+ };
4433
+
4434
+ // feat-20260528-frustum-culling M3 / w10: frustum culling check.
4435
+ // Skip the entity if a valid AABB exists AND ALL cameras' frusta
4436
+ // reject the world-space AABB. Missing or malformed AABBs are
4437
+ // conservative always-visible fallbacks; valid culling bounds are
4438
+ // producer-owned finite local-space AABBs. Culling is unconditional
4439
+ // engine behavior; there is no per-entity opt-out.
4440
+ if (localAabb !== undefined) {
4441
+ // feat-20260601 D-3: cull AABB uses the resolved world mat4
4442
+ // directly (no compose) -- same source the record stage feeds
4443
+ // the mesh SSBO, so cull stays same-source with render (AC-05).
4444
+ const worldAabb = box3.create();
4445
+ box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
4446
+
4447
+ // Test against all cameras. Entity is visible if one camera
4448
+ // frustum intersects the world-space AABB (or planes are empty
4449
+ // from degenerate projection).
4450
+ frustumTotal += 1;
4451
+ let visible = frustumPlanes.length === 0;
4452
+ for (let ci = 0; ci < frustumPlanes.length; ci++) {
4453
+ const planes = frustumPlanes[ci] as Float32Array;
4454
+ if (planes.length === 0) {
4455
+ visible = true;
4456
+ break;
4457
+ }
4458
+ if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
4459
+ visible = true;
4460
+ break;
4461
+ }
4462
+ }
4463
+ if (!visible) {
4464
+ frustumCulled += 1;
4465
+ continue;
4466
+ }
4467
+ }
4468
+
4469
+ if (hasInstances) {
4470
+ const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
4471
+ if (transforms === undefined) {
4472
+ flushPendingDispatch(pendingDispatch);
4473
+ renderables.push(baseRenderable);
4474
+ } else {
4475
+ const actualLength = transforms.length;
4476
+ if (actualLength % 16 !== 0) {
4477
+ worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
4478
+ severity: Severity.Error,
4479
+ systemName: 'RenderSystem.extract (Instances stride)',
4480
+ });
4481
+
4482
+ continue;
4483
+ }
4484
+ const snapshotCopy = new Float32Array(transforms);
4485
+ const instanceCount = Math.max(1, Math.floor(actualLength / 16));
4486
+ flushPendingDispatch(pendingDispatch);
4487
+ renderables.push({
4488
+ ...baseRenderable,
4489
+ instances: {
4490
+ transforms: snapshotCopy,
4491
+ instanceCount,
4492
+ cacheKey: entity,
4493
+ archVersion,
4494
+ },
4495
+ });
4496
+ }
4497
+ } else {
4498
+ flushPendingDispatch(pendingDispatch);
4499
+ renderables.push(baseRenderable);
4500
+ }
4501
+ }
4502
+
4503
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w22 + w25: finalise the
4504
+ // pending TransparentEntry with the renderableIndex pointing at the
4505
+ // RenderableSnapshot we just pushed (when isRenderable === true).
4506
+ // The check below also covers a sprite entity that survives the
4507
+ // dangling-Instances branch (silent skip with `dispatchEntry !==
4508
+ // null` early-continue) — in that case renderableIndex is still set
4509
+ // to the just-pushed slot which is correct because materialDispatch
4510
+ // already captures the dispatch position.
4511
+ }
4512
+ }
4513
+
4514
+ // M3 / w26: sort dispatch entries by queue (ascending, stable sort)
4515
+ // per plan-strategy D-3.
4516
+ dispatch = sortDispatchByQueue(dispatch);
4517
+
4518
+ // D-1: collect PostProcessParams entities into Map<shaderId, Uint8Array>.
4519
+ // Last-one-wins when multiple entities bear the same shader id (mirrors
4520
+ // Camera.exposure -> CameraSnapshot pattern; extract stage only reads).
4521
+ const postProcessParams: Map<string, Uint8Array> = new Map();
4522
+ const postProcessParamsQuery = world.query({ with: [PostProcessParams] }).unwrap();
4523
+ for (const row of postProcessParamsQuery) {
4524
+ const entity = row.entity;
4525
+ const read = world.get(entity, PostProcessParams);
4526
+ if (!read.ok) continue;
4527
+ postProcessParams.set(read.value.shader, read.value.data);
4528
+ }
4529
+
4530
+ // feat-20260621 M-A3 / w13 (D-5): engine built-in tonemap data-driven
4531
+ // provider. The engine bridges the active camera's `Camera.exposure /
4532
+ // whitePoint / tonemap` onto the SAME unified params channel custom
4533
+ // post-processes use — `Camera.exposure` stays the AI-user-facing SSOT (D-5),
4534
+ // the engine itself acts as the provider for the Standard tonemap shader
4535
+ // id. The 16B layout is byte-identical to the prior recordTonemapPass packing
4536
+ // (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _, pad]
4537
+ // with the mode u32 occupying the third 4-byte slot via tonemapToU32 (SSOT in
4538
+ // camera.ts). Run AFTER the user-entity collection above so the engine's
4539
+ // built-in provider is authoritative for its own reserved key (a user entity
4540
+ // can never shadow the Standard tonemap identity. The single active camera mirrors
4541
+ // recordFrame's `activeCameras[0]` selection.
4542
+ const tonemapCamera = cameras[0];
4543
+ if (tonemapCamera !== undefined) {
4544
+ postProcessParams.set(STANDARD_TONEMAP_FEATURE_ID, tonemapParams(tonemapCamera));
4545
+ }
4546
+
4547
+ return {
4548
+ cameras,
4549
+ lights,
4550
+ renderables,
4551
+ dispatch,
4552
+ skylight,
4553
+ skylightCount,
4554
+ skybox,
4555
+ skyboxCount,
4556
+ frustumStats: { culled: frustumCulled, total: frustumTotal },
4557
+ visibilityStats: { explicitlyHidden: explicitlyHidden.size },
4558
+ postProcessParams,
4559
+ visibilitySnapshots: [context.visibility],
4560
+ featureVisibilitySnapshots: [{ world, snapshot: context.visibility }],
4561
+ hiddenEntityReports: [...explicitlyHidden].map((entity) => ({ world, entity })),
4562
+ };
4563
+ }
4564
+
4565
+ export function defaultMaterialSnapshot(materialHandle = 0): MaterialSnapshot {
4566
+ // Mid-grey unlit fallback (D-Q7 case B + extract-stage missing-spec),
4567
+ // matching the pre-w6 visual outcome where the record stage's force-cast
4568
+ // read of `firstMaterial.baseColorTexture` returned undefined and the
4569
+ // unlit fallback shader was selected.
4570
+ return {
4571
+ baseColor: vec3.create(0.5, 0.5, 0.5),
4572
+ metallic: 0,
4573
+ roughness: 1,
4574
+ materialHandle,
4575
+ };
4576
+ }
4577
+
4578
+ function identityProjection(): Float32Array {
4579
+ const projection = new Float32Array(16);
4580
+ projection[0] = 1;
4581
+ projection[5] = 1;
4582
+ projection[10] = 1;
4583
+ projection[15] = 1;
4584
+ return projection;
4585
+ }