@forgeax/engine-render 0.1.20 → 0.1.21

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 (372) hide show
  1. package/README.md +215 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/assembly/device-feature-admission.d.ts +18 -0
  4. package/dist/assembly/device-feature-admission.d.ts.map +1 -0
  5. package/dist/assembly/directional-shadow-inspection.d.ts +38 -0
  6. package/dist/assembly/directional-shadow-inspection.d.ts.map +1 -0
  7. package/dist/assembly/factory.d.ts +21 -18
  8. package/dist/assembly/factory.d.ts.map +1 -1
  9. package/dist/assembly/host-contract.d.ts +9 -0
  10. package/dist/assembly/host-contract.d.ts.map +1 -1
  11. package/dist/assembly/render-target-host.d.ts +46 -0
  12. package/dist/assembly/render-target-host.d.ts.map +1 -0
  13. package/dist/assets/render-asset-port.d.ts.map +1 -1
  14. package/dist/authoring.mjs +3 -0
  15. package/dist/authoring.mjs.map +1 -1
  16. package/dist/capture/cube-views.d.ts +21 -0
  17. package/dist/capture/cube-views.d.ts.map +1 -0
  18. package/dist/capture/scheduler.d.ts +54 -0
  19. package/dist/capture/scheduler.d.ts.map +1 -0
  20. package/dist/{chunk-4XINDLAY.mjs → chunk-5YADUCA7.mjs} +1552 -1361
  21. package/dist/chunk-5YADUCA7.mjs.map +1 -0
  22. package/dist/{chunk-TBQJOK2L.mjs → chunk-6OPXDQ2E.mjs} +364 -51
  23. package/dist/chunk-6OPXDQ2E.mjs.map +1 -0
  24. package/dist/{chunk-M2MP4ZOU.mjs → chunk-BXBQQKFQ.mjs} +3 -9
  25. package/dist/chunk-BXBQQKFQ.mjs.map +1 -0
  26. package/dist/{chunk-CDTWTHZR.mjs → chunk-FS2KNUQH.mjs} +548 -6
  27. package/dist/chunk-FS2KNUQH.mjs.map +1 -0
  28. package/dist/components/camera.d.ts +1 -0
  29. package/dist/components/camera.d.ts.map +1 -1
  30. package/dist/components/cube-camera.d.ts +20 -0
  31. package/dist/components/cube-camera.d.ts.map +1 -0
  32. package/dist/components/directional-light.d.ts +11 -2
  33. package/dist/components/directional-light.d.ts.map +1 -1
  34. package/dist/components/directional-shadow-filter.d.ts +25 -0
  35. package/dist/components/directional-shadow-filter.d.ts.map +1 -0
  36. package/dist/components/index.d.ts +2 -0
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/light-helpers.d.ts.map +1 -1
  39. package/dist/components/reflection-probe.d.ts +17 -0
  40. package/dist/components/reflection-probe.d.ts.map +1 -0
  41. package/dist/construct-renderer.mjs +5826 -1003
  42. package/dist/construct-renderer.mjs.map +1 -1
  43. package/dist/environment/frame.d.ts.map +1 -1
  44. package/dist/environment/lifecycle.d.ts +5 -0
  45. package/dist/environment/lifecycle.d.ts.map +1 -1
  46. package/dist/errors/render.d.ts +141 -93
  47. package/dist/errors/render.d.ts.map +1 -1
  48. package/dist/extract/directional-shadow-projection.d.ts +23 -0
  49. package/dist/extract/directional-shadow-projection.d.ts.map +1 -0
  50. package/dist/extract/environment.d.ts +14 -0
  51. package/dist/extract/environment.d.ts.map +1 -1
  52. package/dist/extract/visibility.d.ts.map +1 -1
  53. package/dist/features/fullscreen.d.ts.map +1 -1
  54. package/dist/features/targets.d.ts.map +1 -1
  55. package/dist/features/types.d.ts +10 -0
  56. package/dist/features/types.d.ts.map +1 -1
  57. package/dist/fullscreen-post-process-pass.d.ts +13 -2
  58. package/dist/fullscreen-post-process-pass.d.ts.map +1 -1
  59. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  60. package/dist/gpu-driven/production-raster.d.ts +0 -1
  61. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  62. package/dist/gpu-scene.d.ts.map +1 -1
  63. package/dist/hdrp-buffers.d.ts +19 -17
  64. package/dist/hdrp-buffers.d.ts.map +1 -1
  65. package/dist/ibl/IblPipelineCache.d.ts +4 -0
  66. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  67. package/dist/ibl/kernel-cache.d.ts +36 -0
  68. package/dist/ibl/kernel-cache.d.ts.map +1 -0
  69. package/dist/index.d.ts +28 -3
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.mjs +4 -4
  72. package/dist/index.mjs.map +1 -1
  73. package/dist/inspection-types.d.ts +51 -0
  74. package/dist/inspection-types.d.ts.map +1 -1
  75. package/dist/instances-derived-bounds.d.ts +50 -0
  76. package/dist/instances-derived-bounds.d.ts.map +1 -0
  77. package/dist/internal.mjs +1 -1
  78. package/dist/light-buffer-layout.d.ts +10 -1
  79. package/dist/light-buffer-layout.d.ts.map +1 -1
  80. package/dist/mesh-material-bindings.d.ts +54 -0
  81. package/dist/mesh-material-bindings.d.ts.map +1 -1
  82. package/dist/pbr-pipeline.d.ts +14 -4
  83. package/dist/pbr-pipeline.d.ts.map +1 -1
  84. package/dist/pipeline/standard-forward-lane.d.ts +2 -1
  85. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  86. package/dist/pipeline/standard-lighting/errors.d.ts +4 -0
  87. package/dist/pipeline/standard-lighting/errors.d.ts.map +1 -0
  88. package/dist/pipeline/standard-lighting/graph.d.ts +24 -0
  89. package/dist/pipeline/standard-lighting/graph.d.ts.map +1 -0
  90. package/dist/pipeline/standard-lighting/inspection.d.ts +38 -0
  91. package/dist/pipeline/standard-lighting/inspection.d.ts.map +1 -0
  92. package/dist/pipeline/standard-lighting/layout.d.ts +28 -0
  93. package/dist/pipeline/standard-lighting/layout.d.ts.map +1 -0
  94. package/dist/pipeline/standard-lighting/light-frame.d.ts +33 -0
  95. package/dist/pipeline/standard-lighting/light-frame.d.ts.map +1 -0
  96. package/dist/pipeline/standard-lighting/membership.d.ts +14 -0
  97. package/dist/pipeline/standard-lighting/membership.d.ts.map +1 -0
  98. package/dist/pipeline/standard-lighting/prepare.d.ts +16 -0
  99. package/dist/pipeline/standard-lighting/prepare.d.ts.map +1 -0
  100. package/dist/pipeline/standard-lighting/topology.d.ts +45 -0
  101. package/dist/pipeline/standard-lighting/topology.d.ts.map +1 -0
  102. package/dist/pipeline/standard-lighting/transport.d.ts +36 -0
  103. package/dist/pipeline/standard-lighting/transport.d.ts.map +1 -0
  104. package/dist/pipeline/standard-pipeline.d.ts +4 -13
  105. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  106. package/dist/pipeline/standard-post.d.ts.map +1 -1
  107. package/dist/pipeline/standard-profile.d.ts +5 -26
  108. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  109. package/dist/pipeline-spec.d.ts +40 -7
  110. package/dist/pipeline-spec.d.ts.map +1 -1
  111. package/dist/point-shadow-inspection.d.ts +21 -0
  112. package/dist/point-shadow-inspection.d.ts.map +1 -0
  113. package/dist/record/frame-lighting.d.ts +27 -32
  114. package/dist/record/frame-lighting.d.ts.map +1 -1
  115. package/dist/record/frame-snapshot.d.ts +28 -14
  116. package/dist/record/frame-snapshot.d.ts.map +1 -1
  117. package/dist/record/frame-targets.d.ts +5 -0
  118. package/dist/record/frame-targets.d.ts.map +1 -1
  119. package/dist/record/frame.d.ts +10 -4
  120. package/dist/record/frame.d.ts.map +1 -1
  121. package/dist/record/gpu-pass-timing/contract.d.ts +96 -0
  122. package/dist/record/gpu-pass-timing/contract.d.ts.map +1 -0
  123. package/dist/record/gpu-pass-timing/errors.d.ts +18 -0
  124. package/dist/record/gpu-pass-timing/errors.d.ts.map +1 -0
  125. package/dist/record/gpu-pass-timing/index.d.ts +8 -0
  126. package/dist/record/gpu-pass-timing/index.d.ts.map +1 -0
  127. package/dist/record/gpu-pass-timing/parser.d.ts +11 -0
  128. package/dist/record/gpu-pass-timing/parser.d.ts.map +1 -0
  129. package/dist/record/gpu-pass-timing/session.d.ts +43 -0
  130. package/dist/record/gpu-pass-timing/session.d.ts.map +1 -0
  131. package/dist/record/gpu-timing.d.ts +8 -3
  132. package/dist/record/gpu-timing.d.ts.map +1 -1
  133. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  134. package/dist/record/main-pass-material.d.ts +22 -1
  135. package/dist/record/main-pass-material.d.ts.map +1 -1
  136. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  137. package/dist/record/main-pass.d.ts +2 -0
  138. package/dist/record/main-pass.d.ts.map +1 -1
  139. package/dist/record/render-context.d.ts +43 -2
  140. package/dist/record/render-context.d.ts.map +1 -1
  141. package/dist/record/shadow-pass.d.ts.map +1 -1
  142. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  143. package/dist/record/typed-frame-graph.d.ts +72 -4
  144. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  145. package/dist/record/view-ubo.d.ts +7 -4
  146. package/dist/record/view-ubo.d.ts.map +1 -1
  147. package/dist/reflection/filter.d.ts +22 -0
  148. package/dist/reflection/filter.d.ts.map +1 -0
  149. package/dist/reflection/gpu-table.d.ts +25 -0
  150. package/dist/reflection/gpu-table.d.ts.map +1 -0
  151. package/dist/reflection/projection.d.ts +91 -0
  152. package/dist/reflection/projection.d.ts.map +1 -0
  153. package/dist/render-contract.d.ts +49 -7
  154. package/dist/render-contract.d.ts.map +1 -1
  155. package/dist/render-graph-primitives.d.ts +1 -0
  156. package/dist/render-graph-primitives.d.ts.map +1 -1
  157. package/dist/render-pipeline.d.ts +42 -2
  158. package/dist/render-pipeline.d.ts.map +1 -1
  159. package/dist/render-system-extract.d.ts +58 -17
  160. package/dist/render-system-extract.d.ts.map +1 -1
  161. package/dist/render-system.d.ts +17 -2
  162. package/dist/render-system.d.ts.map +1 -1
  163. package/dist/renderer.d.ts +43 -0
  164. package/dist/renderer.d.ts.map +1 -0
  165. package/dist/scene/render-scene.d.ts +14 -0
  166. package/dist/scene/render-scene.d.ts.map +1 -1
  167. package/dist/shadow-atlas.d.ts +3 -3
  168. package/dist/shadow-atlas.d.ts.map +1 -1
  169. package/dist/ssao-buffers.d.ts +3 -0
  170. package/dist/ssao-buffers.d.ts.map +1 -1
  171. package/dist/targets/contracts.d.ts +66 -0
  172. package/dist/targets/contracts.d.ts.map +1 -0
  173. package/dist/targets/graph-projection.d.ts +29 -0
  174. package/dist/targets/graph-projection.d.ts.map +1 -0
  175. package/dist/targets/material-source.d.ts +34 -0
  176. package/dist/targets/material-source.d.ts.map +1 -0
  177. package/dist/targets/owner.d.ts +32 -0
  178. package/dist/targets/owner.d.ts.map +1 -0
  179. package/dist/targets/physical.d.ts +18 -0
  180. package/dist/targets/physical.d.ts.map +1 -0
  181. package/dist/targets/readback.d.ts +39 -0
  182. package/dist/targets/readback.d.ts.map +1 -0
  183. package/dist/temporal/index.mjs +3 -3
  184. package/dist/typed-render-graph-primitives.d.ts +1 -1
  185. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  186. package/dist/typed-shadow-passes.d.ts +1 -1
  187. package/dist/typed-shadow-passes.d.ts.map +1 -1
  188. package/dist/volume/passes.d.ts +2 -2
  189. package/dist/volume/passes.d.ts.map +1 -1
  190. package/package.json +20 -20
  191. package/src/__tests__/bloom-exact-zero.unit.test.ts +48 -19
  192. package/src/__tests__/bloom-lifecycle.integration.test.ts +41 -13
  193. package/src/__tests__/camera-target-consumption.unit.test.ts +53 -0
  194. package/src/__tests__/cluster-binner.unit.test.ts +35 -0
  195. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +7 -2
  196. package/src/__tests__/cube-camera-scheduler.unit.test.ts +70 -0
  197. package/src/__tests__/cube-camera-single-submit.perf.test.ts +75 -0
  198. package/src/__tests__/cube-camera-view-matrices.unit.test.ts +41 -0
  199. package/src/__tests__/cube-camera-vocabulary.unit.test.ts +47 -0
  200. package/src/__tests__/cube-camera.integration.test.ts +42 -0
  201. package/src/__tests__/device-feature-admission.unit.test.ts +73 -0
  202. package/src/__tests__/direct-light-snapshot.unit.test.ts +3 -3
  203. package/src/__tests__/docs-gate-green.unit.test.ts +38 -0
  204. package/src/__tests__/factory-contract.integration.test.ts +255 -29
  205. package/src/__tests__/fog-component.unit.test.ts +67 -0
  206. package/src/__tests__/fog.integration.test.ts +86 -0
  207. package/src/__tests__/fullscreen-feature-plan.unit.test.ts +37 -0
  208. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +10 -9
  209. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +17 -1
  210. package/src/__tests__/gpu-driven-production.integration.test.ts +16 -17
  211. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +12 -4
  212. package/src/__tests__/gpu-pass-timing-browser-runner.ts +281 -0
  213. package/src/__tests__/gpu-pass-timing-coexistence.integration.test.ts +80 -0
  214. package/src/__tests__/gpu-pass-timing-contract.unit.test.ts +243 -0
  215. package/src/__tests__/gpu-pass-timing-exact-zero.unit.test.ts +131 -0
  216. package/src/__tests__/gpu-pass-timing-lifecycle.integration.test.ts +230 -0
  217. package/src/__tests__/gpu-pass-timing-observation.unit.test.ts +233 -0
  218. package/src/__tests__/gpu-pass-timing-session.unit.test.ts +438 -0
  219. package/src/__tests__/gpu-pass-timing.browser.test.ts +26 -0
  220. package/src/__tests__/gpu-pass-timing.rhi-null.unit.test.ts +125 -0
  221. package/src/__tests__/hdrp-group2-visibility.unit.test.ts +24 -0
  222. package/src/__tests__/instances-culling.integration.test.ts +94 -0
  223. package/src/__tests__/instances-derived-bounds.unit.test.ts +118 -0
  224. package/src/__tests__/material-observation.integration.test.ts +101 -0
  225. package/src/__tests__/pbr-view-layout.unit.test.ts +16 -0
  226. package/src/__tests__/point-shadow-inspection.unit.test.ts +60 -0
  227. package/src/__tests__/public-surface.test-d.ts +10 -0
  228. package/src/__tests__/reflection-probe-authoring.unit.test.ts +71 -0
  229. package/src/__tests__/reflection-probe-filter.unit.test.ts +54 -0
  230. package/src/__tests__/reflection-probe-ibl-topology.unit.test.ts +45 -0
  231. package/src/__tests__/reflection-probe-pass-encode.integration.test.ts +101 -0
  232. package/src/__tests__/reflection-probe-performance.perf.test.ts +60 -0
  233. package/src/__tests__/reflection-probe-recovery.integration.test.ts +55 -0
  234. package/src/__tests__/reflection-probe-selection.unit.test.ts +46 -0
  235. package/src/__tests__/render-error-code-owner.test-d.ts +18 -3
  236. package/src/__tests__/render-feature-docs.unit.test.ts +1 -1
  237. package/src/__tests__/render-target-admission.unit.test.ts +80 -0
  238. package/src/__tests__/render-target-graph-projection.unit.test.ts +102 -0
  239. package/src/__tests__/render-target-inspection.unit.test.ts +52 -0
  240. package/src/__tests__/render-target-material-source.test-d.ts +30 -0
  241. package/src/__tests__/render-target-material-source.unit.test.ts +97 -0
  242. package/src/__tests__/render-target-owner-placement.unit.test.ts +203 -0
  243. package/src/__tests__/render-target-owner.unit.test.ts +122 -0
  244. package/src/__tests__/render-target-probe-soak.perf.test.ts +94 -0
  245. package/src/__tests__/render-target-public-schema.json +107 -0
  246. package/src/__tests__/render-target-public-schema.unit.test.ts +91 -0
  247. package/src/__tests__/render-target-public-surface.test-d.ts +83 -0
  248. package/src/__tests__/render-target-readback.integration.test.ts +90 -0
  249. package/src/__tests__/render-target-recovery.integration.test.ts +85 -0
  250. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +48 -0
  251. package/src/__tests__/renderer-lifecycle.integration.test.ts +14 -0
  252. package/src/__tests__/shader-stage-surface.unit.test.ts +19 -4
  253. package/src/__tests__/skin-motion-regression.unit.test.ts +132 -0
  254. package/src/__tests__/skinned-shadow-caster.test.ts +11 -2
  255. package/src/__tests__/standard-cluster-capability.integration.test.ts +63 -0
  256. package/src/__tests__/standard-cluster-transport.unit.test.ts +121 -0
  257. package/src/__tests__/standard-cluster-variant-resolution.unit.test.ts +232 -0
  258. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +8 -5
  259. package/src/__tests__/standard-lighting-inspection.integration.test.ts +138 -0
  260. package/src/__tests__/standard-lighting-ssot.unit.test.ts +18 -0
  261. package/src/__tests__/standard-lighting-write.unit.test.ts +377 -0
  262. package/src/__tests__/standard-lighting.integration.test.ts +67 -0
  263. package/src/__tests__/standard-pipeline.dawn.test.ts +206 -5
  264. package/src/__tests__/standard-pipeline.integration.test.ts +181 -150
  265. package/src/__tests__/standard-probe-binding.unit.test.ts +25 -0
  266. package/src/__tests__/standard-profile-oracle.unit.test.ts +23 -38
  267. package/src/__tests__/typed-pipeline-topology.unit.test.ts +395 -14
  268. package/src/__tests__/urp-pipeline-targets.unit.test.ts +2 -2
  269. package/src/__tests__/visibility-resolver.unit.test.ts +15 -0
  270. package/src/assembly/device-feature-admission.ts +73 -0
  271. package/src/assembly/directional-shadow-inspection.ts +122 -0
  272. package/src/assembly/factory.ts +877 -286
  273. package/src/assembly/host-contract.ts +25 -0
  274. package/src/assembly/render-target-host.ts +847 -0
  275. package/src/assets/asset-decoders.ts +1 -1
  276. package/src/assets/render-asset-port.ts +7 -1
  277. package/src/capture/cube-views.ts +69 -0
  278. package/src/capture/scheduler.ts +265 -0
  279. package/src/components/__tests__/schema-contract.unit.test.ts +12 -0
  280. package/src/components/__tests__/schema-parity.unit.test.ts +9 -0
  281. package/src/components/camera.ts +4 -0
  282. package/src/components/cube-camera.ts +52 -0
  283. package/src/components/directional-light.ts +22 -2
  284. package/src/components/directional-shadow-filter.ts +57 -0
  285. package/src/components/index.ts +2 -0
  286. package/src/components/light-helpers.ts +81 -7
  287. package/src/components/reflection-probe.ts +49 -0
  288. package/src/environment/frame.ts +6 -24
  289. package/src/environment/lifecycle.ts +12 -0
  290. package/src/errors/render.ts +286 -142
  291. package/src/extract/directional-shadow-projection.ts +61 -0
  292. package/src/extract/environment.ts +57 -0
  293. package/src/extract/visibility.ts +25 -1
  294. package/src/features/fullscreen.ts +3 -0
  295. package/src/features/targets.ts +4 -1
  296. package/src/features/types.ts +10 -0
  297. package/src/fullscreen-post-process-pass.ts +43 -2
  298. package/src/gpu-driven/batch-topology.ts +1 -0
  299. package/src/gpu-driven/production-raster.ts +1 -3
  300. package/src/gpu-scene.ts +8 -1
  301. package/src/hdrp-buffers.ts +165 -59
  302. package/src/ibl/IblPipelineCache.ts +11 -0
  303. package/src/ibl/kernel-cache.ts +88 -0
  304. package/src/index.ts +148 -0
  305. package/src/inspection-types.ts +56 -0
  306. package/src/instances-derived-bounds.ts +171 -0
  307. package/src/light-buffer-layout.ts +20 -5
  308. package/src/mesh-material-bindings.ts +132 -0
  309. package/src/pbr-pipeline.ts +106 -17
  310. package/src/pipeline/standard-forward-lane.ts +46 -16
  311. package/src/pipeline/standard-lighting/errors.ts +6 -0
  312. package/src/pipeline/standard-lighting/graph.ts +157 -0
  313. package/src/pipeline/standard-lighting/inspection.ts +88 -0
  314. package/src/pipeline/standard-lighting/layout.ts +31 -0
  315. package/src/pipeline/standard-lighting/light-frame.ts +34 -0
  316. package/src/pipeline/standard-lighting/membership.ts +43 -0
  317. package/src/pipeline/standard-lighting/prepare.ts +37 -0
  318. package/src/pipeline/standard-lighting/topology.ts +120 -0
  319. package/src/pipeline/standard-lighting/transport.ts +77 -0
  320. package/src/pipeline/standard-pipeline.ts +122 -193
  321. package/src/pipeline/standard-post.ts +26 -7
  322. package/src/pipeline/standard-profile.ts +17 -47
  323. package/src/pipeline-spec.ts +84 -21
  324. package/src/point-shadow-inspection.ts +55 -0
  325. package/src/record/__tests__/temporal-payload.unit.test.ts +42 -1
  326. package/src/record/__tests__/transparent-group2-selection.unit.test.ts +73 -0
  327. package/src/record/__tests__/view-ubo-layout.unit.test.ts +40 -0
  328. package/src/record/frame-lighting.ts +367 -427
  329. package/src/record/frame-snapshot.ts +35 -27
  330. package/src/record/frame-targets.ts +17 -0
  331. package/src/record/frame.ts +261 -74
  332. package/src/record/gpu-pass-timing/contract.ts +180 -0
  333. package/src/record/gpu-pass-timing/errors.ts +34 -0
  334. package/src/record/gpu-pass-timing/index.ts +37 -0
  335. package/src/record/gpu-pass-timing/parser.ts +97 -0
  336. package/src/record/gpu-pass-timing/session.ts +938 -0
  337. package/src/record/gpu-timing.ts +21 -12
  338. package/src/record/main-pass-geometry.ts +120 -80
  339. package/src/record/main-pass-material.ts +95 -9
  340. package/src/record/main-pass-sprite-draws.ts +54 -25
  341. package/src/record/main-pass.ts +71 -15
  342. package/src/record/render-context.ts +72 -0
  343. package/src/record/shadow-pass.ts +17 -28
  344. package/src/record/skybox-post-pass.ts +20 -4
  345. package/src/record/typed-frame-graph.ts +893 -44
  346. package/src/record/view-ubo.ts +63 -28
  347. package/src/reflection/filter.ts +81 -0
  348. package/src/reflection/gpu-table.ts +44 -0
  349. package/src/reflection/projection.ts +281 -0
  350. package/src/render-contract.ts +76 -5
  351. package/src/render-graph-primitives.ts +9 -4
  352. package/src/render-pipeline.ts +117 -2
  353. package/src/render-system-extract.ts +557 -100
  354. package/src/render-system.ts +1095 -66
  355. package/src/renderer.ts +222 -0
  356. package/src/scene/render-scene.ts +115 -9
  357. package/src/shadow-atlas.ts +5 -5
  358. package/src/ssao-buffers.ts +9 -2
  359. package/src/targets/contracts.ts +182 -0
  360. package/src/targets/graph-projection.ts +169 -0
  361. package/src/targets/material-source.ts +143 -0
  362. package/src/targets/owner.ts +214 -0
  363. package/src/targets/physical.ts +18 -0
  364. package/src/targets/readback.ts +153 -0
  365. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +59 -20
  366. package/src/typed-render-graph-primitives.ts +5 -3
  367. package/src/typed-shadow-passes.ts +46 -40
  368. package/src/volume/passes.ts +15 -9
  369. package/dist/chunk-4XINDLAY.mjs.map +0 -1
  370. package/dist/chunk-CDTWTHZR.mjs.map +0 -1
  371. package/dist/chunk-M2MP4ZOU.mjs.map +0 -1
  372. package/dist/chunk-TBQJOK2L.mjs.map +0 -1
@@ -154,6 +154,12 @@ import {
154
154
  } from '@forgeax/engine-types';
155
155
  import {
156
156
  Atmosphere,
157
+ antialiasFromF32,
158
+ bloomEnabledFromF32,
159
+ Camera,
160
+ CubeCamera,
161
+ cameraProjectionFromF32,
162
+ cubeCameraUpdateIntentFromF32,
157
163
  DirectionalLight,
158
164
  Fog,
159
165
  Instances,
@@ -161,33 +167,50 @@ import {
161
167
  Lines,
162
168
  MeshFilter,
163
169
  MeshRenderer,
170
+ MotionBlur,
164
171
  PointLight,
165
172
  PointLightShadow,
166
173
  Points,
167
174
  PostProcessParams,
168
175
  pointShapeFromU32,
176
+ ReflectionProbe,
177
+ reflectionProbeUpdateIntentFromF32,
169
178
  SkyboxBackground,
170
179
  Skylight,
171
180
  SortKey,
172
181
  SpotLight,
173
182
  SpriteInstances,
174
183
  SpriteRegionOverride,
184
+ tonemapFromF32,
175
185
  tonemapToU32,
176
186
  } from './components';
187
+ import {
188
+ type DirectionalShadowQuality,
189
+ directionalShadowQualityFromF32,
190
+ } from './components/directional-shadow-filter';
177
191
  import { GlyphText } from './components/glyph-text';
178
- import { computeInvRangeSquared, degToCos } from './components/light-helpers';
192
+ import {
193
+ computeInvRangeSquared,
194
+ degToCos,
195
+ type LightValidationError,
196
+ validateDirectionalLightData,
197
+ } from './components/light-helpers';
179
198
  import { selectEnvironment } from './environment/frame';
180
199
  import {
181
200
  MaterialSkinAttrMissingError,
182
201
  ShadowInvalidConfigError,
183
202
  SkinMaterialMismatchError,
184
203
  } from './errors/render';
185
- import { extractCameraSnapshots } from './extract/camera';
204
+ import {
205
+ type DirectionalShadowCascadeFit,
206
+ projectDirectionalShadow,
207
+ } from './extract/directional-shadow-projection';
186
208
  import type {
187
209
  EnvironmentCandidate,
188
210
  EnvironmentFrame,
189
211
  FogCandidate,
190
212
  FogFrame,
213
+ FogSelectionFailure,
191
214
  } from './extract/environment';
192
215
  import { resolveVisibility, type VisibilitySnapshot } from './extract/visibility';
193
216
  import type {
@@ -195,6 +218,7 @@ import type {
195
218
  RenderFeatureWorldVisibilitySnapshot,
196
219
  } from './features/types';
197
220
  import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
221
+ import { deriveInstancesUnionBounds } from './instances-derived-bounds';
198
222
  import {
199
223
  type MeshMaterialBindingDiagnostic,
200
224
  type MeshMaterialBindingSource,
@@ -203,13 +227,20 @@ import {
203
227
  import { isStandardPbrMaterialShader } from './pbr-pipeline';
204
228
  import { expandPointsLinesBounds } from './points-lines/bounds';
205
229
  import type { PointsLinesRetainedSnapshot, PointsLinesStyle } from './points-lines/snapshot';
206
- import { type CameraSnapshot, STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from './render-contract';
230
+ import type { ReflectionProbeFact } from './reflection/projection';
231
+ import {
232
+ type CameraSnapshot,
233
+ type CubeCameraSnapshot,
234
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
235
+ } from './render-contract';
236
+ import { SHADOW_ATLAS_DEFAULT_LAYERS } from './shadow-atlas';
237
+ import { getActiveCamera, selectActiveCameraIndex } from './systems/active-camera';
207
238
  import { selectPasses } from './systems/pass-selector';
208
239
 
209
- export { extractCameraSnapshots } from './extract/camera';
210
-
211
240
  import type { SkinPaletteAllocator } from './systems/skin-palette-allocator';
212
241
  import type { SkinPaletteSlice } from './systems/skin-palette-types';
242
+ import type { RenderTarget, RenderTargetTextureSource } from './targets/contracts';
243
+ import { resolveRenderTargetMaterialSource } from './targets/material-source';
213
244
  import { resolveSelectedVolumetricLight } from './volume/capability';
214
245
  import {
215
246
  type ValidatedVolumetricFog,
@@ -252,8 +283,9 @@ export interface PointLightSnapshot {
252
283
  readonly invRangeSquared: number;
253
284
  /**
254
285
  * feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-8 + M4 / T-M4-4:
255
- * cube_array atlas layer index for this light's shadow map (0..3 for shadow
256
- * casters; sentinel `-1` for non-shadow lights). Default `-1` per
286
+ * cube_array atlas layer index for this light's shadow map (0..capacity-1
287
+ * for admitted shadow casters; sentinel `-1` for non-admitted lights).
288
+ * Default `-1` per
257
289
  * plan-strategy §D-2 — record stage / shader skips shadow sampling when the
258
290
  * lane equals the sentinel. Joined with `pointShadow[]` by entity at the
259
291
  * end of extract; HDRP record stage threads `shadowAtlasLayer + shadowNear +
@@ -290,8 +322,9 @@ export interface PointLightSnapshot {
290
322
  *
291
323
  * `mapSize` / `nearPlane` / `farPlane` ride here so record stage can size the
292
324
  * cube atlas faces and feed shader the proj constants without re-querying ECS.
293
- * `shadowAtlasLayer` is assigned by extract in spawn order (0 .. cap-1) so the
294
- * downstream LightSlot / packPointLight packers carry the layer index in
325
+ * `shadowAtlasLayer` is assigned by extract in spawn order up to the renderer
326
+ * atlas capacity; over-budget requests retain the sentinel `-1` so the
327
+ * downstream LightSlot / packPointLight packers carry no invalid layer in
295
328
  * pointPadW (T-M1-8 will rename to shadowAtlasLayer).
296
329
  */
297
330
  export interface PointShadowSnapshot {
@@ -304,9 +337,9 @@ export interface PointShadowSnapshot {
304
337
  readonly nearPlane: number;
305
338
  readonly farPlane: number;
306
339
  /**
307
- * Atlas layer assigned by extract in spawn order (0..cap-1, where cap=4
308
- * matches PointLightShadow cardinality). Sentinel -1 is reserved by the
309
- * shader-side LightSlot for no-shadow lights (T-M1-8).
340
+ * Atlas layer assigned by extract in spawn order while capacity remains.
341
+ * Sentinel -1 is used for a request that cannot be admitted and is
342
+ * reserved by the shader-side LightSlot for no-shadow lights (T-M1-8).
310
343
  */
311
344
  readonly shadowAtlasLayer: number;
312
345
  /**
@@ -404,6 +437,7 @@ export interface ExtractedLights {
404
437
  * unused slots are 0.0f. Undefined when castShadow=false or no directional
405
438
  * light.
406
439
  */
440
+ /** Four vec4 lanes: split, worldUnitsPerTexel, lightDepthWorldSpan, reserved. */
407
441
  readonly splitPlanes: Float32Array | undefined;
408
442
  /**
409
443
  * feat-20260613-csm-cascaded-shadow-maps M2 / w9: effective cascade count
@@ -439,13 +473,10 @@ export interface ExtractedLights {
439
473
  * undefined otherwise.
440
474
  */
441
475
  readonly normalBias: number | undefined;
442
- /**
443
- * feat-20260621-merge-directionallightshadow-into-directionallight M2:
444
- * pcfKernelSize from the merged DirectionalLight (PCF kernel width, odd>=1).
445
- * Populated when castShadow=true on the first-hit directional light;
446
- * undefined otherwise.
447
- */
448
- readonly pcfKernelSize: number | undefined;
476
+ /** Accepted closed Directional filter projected from the first query result. */
477
+ readonly directionalShadowQuality: DirectionalShadowQuality | undefined;
478
+ /** Structured author-config failure; the candidate is withheld from record. */
479
+ readonly directionalShadowError: LightValidationError | undefined;
449
480
  /**
450
481
  * feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7:
451
482
  * shadow-casting point lights (PointLight + PointLightShadow + Transform
@@ -851,6 +882,8 @@ export interface MaterialSnapshot {
851
882
  * them in a lightmap injection region.
852
883
  */
853
884
  readonly textureHandles?: ReadonlyMap<string, Handle<'TextureAsset', 'shared'>> | undefined;
885
+ /** Renderer-local target sources; never projected as TextureAsset handles. */
886
+ readonly textureSources?: ReadonlyMap<string, RenderTargetTextureSource> | undefined;
854
887
  /**
855
888
  * User-region texture field names whose paramValue resolved to a VideoAsset
856
889
  * (kind `'video'`) rather than a static TextureAsset
@@ -1060,6 +1093,12 @@ function appendMaterialDispatchEntries(
1060
1093
 
1061
1094
  export interface ExtractedFrame {
1062
1095
  readonly cameras: CameraSnapshot[];
1096
+ /** Auxiliary target cameras selected after the display camera is fixed. */
1097
+ readonly auxiliaryCameras: readonly CameraSnapshot[];
1098
+ /** Cube capture intents extracted beside the display camera in one pass. */
1099
+ readonly cubeCameras: readonly CubeCameraSnapshot[];
1100
+ /** Reflection probe facts projected from the same World extraction. */
1101
+ readonly reflectionProbes?: readonly ReflectionProbeFact[];
1063
1102
  readonly lights: ExtractedLights;
1064
1103
  /** Resource-owner environment facts selected once at the extract boundary. */
1065
1104
  readonly environment: EnvironmentFrame | undefined;
@@ -1080,6 +1119,8 @@ export interface ExtractedFrame {
1080
1119
  readonly skyboxCount: number;
1081
1120
  /** Resource-owner Fog facts selected once at the frame boundary. */
1082
1121
  readonly fog: FogFrame | undefined;
1122
+ /** Last invalid Fog update retained alongside the current valid/LKG frame. */
1123
+ readonly fogFailure?: FogSelectionFailure;
1083
1124
  /**
1084
1125
  * feat-20260528-frustum-culling M3 / w11: frustum culling statistics
1085
1126
  * collected during the extract phase. `total` is the count of entities
@@ -1105,6 +1146,65 @@ export interface ExtractedFrame {
1105
1146
  readonly hiddenEntityReports: readonly RenderFeatureHiddenEntityReport[];
1106
1147
  }
1107
1148
 
1149
+ export type CameraTargetUpdate = 'once' | 'on-demand' | 'continuous';
1150
+
1151
+ export interface CameraTargetCandidate {
1152
+ readonly worldId: number;
1153
+ readonly entityKey: number;
1154
+ readonly target?: RenderTarget;
1155
+ readonly requestVersion: number;
1156
+ readonly update: CameraTargetUpdate;
1157
+ }
1158
+
1159
+ export interface CameraTargetRejection {
1160
+ readonly entityKey: number;
1161
+ readonly reason: 'budget' | 'display-target' | 'duplicate-target';
1162
+ }
1163
+
1164
+ export interface CameraTargetSelection {
1165
+ readonly display: CameraTargetCandidate | undefined;
1166
+ readonly auxiliary: readonly CameraTargetCandidate[];
1167
+ readonly rejected: readonly CameraTargetRejection[];
1168
+ }
1169
+
1170
+ export function selectCameraTargetViews(
1171
+ candidates: readonly CameraTargetCandidate[],
1172
+ options: { readonly displayEntityKey?: number; readonly budget: number },
1173
+ ): CameraTargetSelection {
1174
+ const display = candidates.find(
1175
+ (candidate) =>
1176
+ candidate.entityKey === options.displayEntityKey && candidate.target === undefined,
1177
+ );
1178
+ const rejected: CameraTargetRejection[] = [];
1179
+ const ordered = candidates
1180
+ .filter((candidate) => candidate.target !== undefined)
1181
+ .sort((left, right) => left.worldId - right.worldId || left.entityKey - right.entityKey);
1182
+ const auxiliary: CameraTargetCandidate[] = [];
1183
+ const seenTargets = new Set<RenderTarget>();
1184
+ for (const candidate of ordered) {
1185
+ const duplicate = candidate.target !== undefined && seenTargets.has(candidate.target);
1186
+ if (candidate.target !== undefined) seenTargets.add(candidate.target);
1187
+ if (candidate.entityKey === options.displayEntityKey) {
1188
+ rejected.push({ entityKey: candidate.entityKey, reason: 'display-target' });
1189
+ continue;
1190
+ }
1191
+ if (candidate.target === undefined || duplicate) {
1192
+ rejected.push({ entityKey: candidate.entityKey, reason: 'duplicate-target' });
1193
+ continue;
1194
+ }
1195
+ if (auxiliary.length >= Math.max(0, options.budget)) {
1196
+ rejected.push({ entityKey: candidate.entityKey, reason: 'budget' });
1197
+ continue;
1198
+ }
1199
+ auxiliary.push(candidate);
1200
+ }
1201
+ return {
1202
+ display,
1203
+ auxiliary: Object.freeze(auxiliary),
1204
+ rejected: Object.freeze(rejected),
1205
+ };
1206
+ }
1207
+
1108
1208
  /**
1109
1209
  * Internal world surface used by extract for archetype-graph traversal and
1110
1210
  * error routing. The packed `Entity` handle for a row is read directly from
@@ -1147,6 +1247,14 @@ function createWorldInternalView(world: World): WorldInternalView {
1147
1247
  };
1148
1248
  }
1149
1249
 
1250
+ function resolveCameraTarget(world: World, raw: number | undefined): RenderTarget | undefined {
1251
+ if (raw === undefined || raw <= 0) return undefined;
1252
+ const resolved = world.sharedRefs.resolve<'RenderTarget', RenderTarget>(
1253
+ toShared<'RenderTarget'>(Math.round(raw)),
1254
+ );
1255
+ return resolved.ok ? resolved.value : undefined;
1256
+ }
1257
+
1150
1258
  /**
1151
1259
  * The user-region texture fields the built-in standard-PBR material declares.
1152
1260
  * Used as the fallback texture-field set when the shader id is not registered
@@ -1427,6 +1535,24 @@ function collectMaterialTextureCoordinates(
1427
1535
  return out;
1428
1536
  }
1429
1537
 
1538
+ function collectMaterialTextureSources(
1539
+ values: Readonly<Record<string, unknown>>,
1540
+ world: World,
1541
+ ): Map<string, RenderTargetTextureSource> {
1542
+ const out = new Map<string, RenderTargetTextureSource>();
1543
+ for (const [field, value] of Object.entries(values)) {
1544
+ const reference = materialTextureRef(value);
1545
+ if (typeof reference !== 'number') continue;
1546
+ const resolved = world.sharedRefs.resolve<
1547
+ 'RenderTargetTextureSource',
1548
+ RenderTargetTextureSource
1549
+ >(toShared<'RenderTargetTextureSource'>(reference));
1550
+ if (!resolved.ok || resolveRenderTargetMaterialSource(resolved.value) === undefined) continue;
1551
+ out.set(field, resolved.value);
1552
+ }
1553
+ return out;
1554
+ }
1555
+
1430
1556
  function collectMaterialTextureSamplers(
1431
1557
  values: Readonly<Record<string, unknown>>,
1432
1558
  resolveSampler: (value: unknown) => Handle<'SamplerAsset', 'shared'> | undefined,
@@ -1517,9 +1643,14 @@ function materialParametersToParamSchema(
1517
1643
  return parameters.flatMap((parameter): ParamSchemaEntry[] => {
1518
1644
  // Boolean material values are runtime-only and have no UBO representation.
1519
1645
  if (parameter.type === 'bool') return [];
1520
- if (parameter.type === 'texture') {
1646
+ if (parameter.type === 'texture' || parameter.type === 'texture_cube') {
1521
1647
  if (engineInjectedTextureFields?.has(parameter.name) === true) return [];
1522
- return [{ name: parameter.name, type: 'texture2d' }];
1648
+ return [
1649
+ {
1650
+ name: parameter.name,
1651
+ type: parameter.type === 'texture_cube' ? 'texture_cube' : 'texture2d',
1652
+ },
1653
+ ];
1523
1654
  }
1524
1655
  return [
1525
1656
  {
@@ -1779,6 +1910,7 @@ export function resolveMaterialSnapshot(
1779
1910
  const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
1780
1911
  resolveTexLike(materialTextureRef(value), 'SamplerAsset'),
1781
1912
  );
1913
+ const textureSources = collectMaterialTextureSources(pv, world);
1782
1914
  const emissiveTextureHandle = resolveTexLike(
1783
1915
  materialTextureRef(pv.emissiveTexture),
1784
1916
  'TextureAsset',
@@ -1814,6 +1946,7 @@ export function resolveMaterialSnapshot(
1814
1946
  ...(materialParamSchema.length > 0 && { materialParamSchema }),
1815
1947
  ...(textureCoordinates.size > 0 && { textureCoordinates }),
1816
1948
  ...(textureHandles.size > 0 && { textureHandles }),
1949
+ ...(textureSources.size > 0 && { textureSources }),
1817
1950
  ...(videoTextureFields.size > 0 && { videoTextureFields }),
1818
1951
  ...(samplerHandles.size > 0 && { samplerHandles }),
1819
1952
  ...(baseColorTextureHandle !== undefined && { baseColorTexture: baseColorTextureHandle }),
@@ -2117,6 +2250,9 @@ export interface DirectionalCsmConfig {
2117
2250
  readonly cascadeBlend: number;
2118
2251
  readonly mapSize: number;
2119
2252
  readonly shadowDistance: number;
2253
+ readonly shadowFilter: number;
2254
+ readonly shadowAngularRadius: number;
2255
+ readonly maxPenumbraTexels: number;
2120
2256
  }
2121
2257
 
2122
2258
  /**
@@ -2131,6 +2267,7 @@ export interface DirectionalCsmResult {
2131
2267
  readonly cascadeCount: number;
2132
2268
  readonly cascadeBlend: number;
2133
2269
  readonly shadowMapSize: number;
2270
+ readonly directionalShadowQuality: DirectionalShadowQuality;
2134
2271
  }
2135
2272
 
2136
2273
  /**
@@ -2163,9 +2300,6 @@ export function computeDirectionalCsm(
2163
2300
 
2164
2301
  // PSSM split planes: [camera near, shadowDistance], not the camera far.
2165
2302
  const splits = pssmSplit(sNear, sFar, cascadeCount, config.splitLambda);
2166
- const paddedSplits = new Float32Array(4);
2167
- for (let i = 0; i < splits.length; i++) paddedSplits[i] = splits[i] ?? 0;
2168
-
2169
2303
  // Light view matrix: camera at origin, looking along the light direction.
2170
2304
  const lightDirN = vec3.normalize(vec3.create(), direction);
2171
2305
  const lightTarget = vec3.create(lightDirN[0] ?? 0, lightDirN[1] ?? 0, lightDirN[2] ?? 0);
@@ -2196,6 +2330,7 @@ export function computeDirectionalCsm(
2196
2330
  mat4.multiply(cameraVP, camProj, camView);
2197
2331
 
2198
2332
  const resultLightViewProjs: Float32Array[] = [];
2333
+ const cascadeFits: DirectionalShadowCascadeFit[] = [];
2199
2334
 
2200
2335
  // bug-20260619 RC-2 (AC-05): toward-light Z reach. Extend the near (toward-
2201
2336
  // light) bound of EVERY cascade to the toward-light extreme of the WHOLE
@@ -2259,16 +2394,34 @@ export function computeDirectionalCsm(
2259
2394
  // lightViewProj = orthoProj * lightView — pure clip-space [-1,1]. Atlas
2260
2395
  // tile placement is handled by the per-cascade viewport + fragment-side UV
2261
2396
  // math (evalDirectional), so shadow_caster.gl_Position stays clip-space.
2262
- void cIdx;
2397
+ cascadeFits.push({
2398
+ split: splits[cIdx] ?? sFar,
2399
+ minX,
2400
+ maxX,
2401
+ minY,
2402
+ maxY,
2403
+ minZ,
2404
+ maxZ,
2405
+ });
2263
2406
  resultLightViewProjs.push(new Float32Array(mat4.multiply(mat4.create(), orthoProj, lightView)));
2264
2407
  }
2265
2408
 
2409
+ const projection = projectDirectionalShadow(
2410
+ cascadeFits,
2411
+ config.mapSize,
2412
+ config.shadowFilter ?? 2,
2413
+ config.shadowAngularRadius ?? 0.00465,
2414
+ config.maxPenumbraTexels ?? 32,
2415
+ );
2416
+ if (projection === undefined) return null;
2417
+
2266
2418
  return {
2267
2419
  lightViewProj: resultLightViewProjs,
2268
- splitPlanes: paddedSplits,
2420
+ splitPlanes: projection.splitPlanes,
2269
2421
  cascadeCount,
2270
2422
  cascadeBlend: config.cascadeBlend,
2271
2423
  shadowMapSize: config.mapSize,
2424
+ directionalShadowQuality: projection.directionalShadowQuality,
2272
2425
  };
2273
2426
  }
2274
2427
 
@@ -2300,6 +2453,161 @@ export interface PreparedExtractContext {
2300
2453
  readonly visibility: VisibilitySnapshot;
2301
2454
  }
2302
2455
 
2456
+ function collectCameraSnapshots(world: World): CameraSnapshot[] {
2457
+ const worldInternal = createWorldInternalView(world);
2458
+ const cameras: CameraSnapshot[] = [];
2459
+ const cameraQuery = world
2460
+ .query({ read: [Camera], optional: [MotionBlur], with: [Transform] })
2461
+ .unwrap();
2462
+ for (const row of cameraQuery) {
2463
+ const cam = row.get(Camera);
2464
+ const motionBlur = row.get(MotionBlur);
2465
+ const entity = row.entity;
2466
+ const view = worldInternal._getArrayView(entity, Transform, 'world');
2467
+ if (view === undefined) continue;
2468
+ const worldMat = new Float32Array(view);
2469
+ const target = resolveCameraTarget(world, cam.target);
2470
+ cameras.push({
2471
+ entityKey: entity as number,
2472
+ ...(target === undefined ? {} : { target }),
2473
+ historyVersion: cam.historyVersion,
2474
+ position: mat4.getTranslation(vec3.create(), worldMat),
2475
+ world: worldMat,
2476
+ fov: cam.fov,
2477
+ aspect: cam.aspect,
2478
+ near: cam.near,
2479
+ far: cam.far,
2480
+ projection: cameraProjectionFromF32(cam.projection),
2481
+ orthoLeft: cam.left,
2482
+ orthoRight: cam.right,
2483
+ orthoBottom: cam.bottom,
2484
+ orthoTop: cam.top,
2485
+ tonemap: tonemapFromF32(cam.tonemap),
2486
+ exposure: cam.exposure,
2487
+ whitePoint: cam.whitePoint,
2488
+ antialias: antialiasFromF32(cam.antialias),
2489
+ bloom: bloomEnabledFromF32(cam.bloom),
2490
+ bloomThreshold: cam.bloomThreshold,
2491
+ bloomIntensity: cam.bloomIntensity,
2492
+ bloomBlurRadius: cam.bloomBlurRadius,
2493
+ clearColor: [
2494
+ cam.clearColor[0] ?? 0,
2495
+ cam.clearColor[1] ?? 0,
2496
+ cam.clearColor[2] ?? 0,
2497
+ cam.clearColor[3] ?? 1,
2498
+ ],
2499
+ ...(motionBlur === undefined
2500
+ ? {}
2501
+ : {
2502
+ motionBlur: {
2503
+ shutterAngle: motionBlur.shutterAngle,
2504
+ maxRadiusPixels: motionBlur.maxRadiusPixels,
2505
+ sampleCount: motionBlur.sampleCount,
2506
+ },
2507
+ }),
2508
+ });
2509
+ }
2510
+ return cameras;
2511
+ }
2512
+
2513
+ function collectCubeCameraSnapshots(world: World): CubeCameraSnapshot[] {
2514
+ const worldInternal = createWorldInternalView(world);
2515
+ const snapshots: CubeCameraSnapshot[] = [];
2516
+ const query = world.query({ read: [CubeCamera], with: [Transform] }).unwrap();
2517
+ for (const row of query) {
2518
+ const camera = row.get(CubeCamera);
2519
+ const target = resolveCameraTarget(world, camera.target);
2520
+ if (target === undefined) continue;
2521
+ const view = worldInternal._getArrayView(row.entity, Transform, 'world');
2522
+ if (view === undefined) continue;
2523
+ snapshots.push({
2524
+ entityKey: row.entity as number,
2525
+ target,
2526
+ position: [view[12] ?? 0, view[13] ?? 0, view[14] ?? 0],
2527
+ near: camera.near,
2528
+ far: camera.far,
2529
+ updateIntent: cubeCameraUpdateIntentFromF32(camera.updateIntent),
2530
+ requestVersion: camera.requestVersion,
2531
+ faceBudget: camera.faceBudget,
2532
+ });
2533
+ }
2534
+ return snapshots;
2535
+ }
2536
+
2537
+ function collectReflectionProbeFacts(world: World): ReflectionProbeFact[] {
2538
+ const worldInternal = createWorldInternalView(world);
2539
+ const facts: ReflectionProbeFact[] = [];
2540
+ const query = world.query({ read: [ReflectionProbe], with: [Transform] }).unwrap();
2541
+ for (const row of query) {
2542
+ const probe = row.get(ReflectionProbe);
2543
+ const view = worldInternal._getArrayView(row.entity, Transform, 'world');
2544
+ if (view === undefined) {
2545
+ continue;
2546
+ }
2547
+ facts.push({
2548
+ worldId: 0,
2549
+ entityKey: row.entity as number,
2550
+ center: [view[12] ?? 0, view[13] ?? 0, view[14] ?? 0],
2551
+ halfExtents: [
2552
+ probe.halfExtents[0] ?? 0,
2553
+ probe.halfExtents[1] ?? 0,
2554
+ probe.halfExtents[2] ?? 0,
2555
+ ],
2556
+ priority: probe.priority,
2557
+ intensity: probe.intensity,
2558
+ resolution: probe.resolution,
2559
+ revision: probe.invalidationVersion,
2560
+ updateIntent: reflectionProbeUpdateIntentFromF32(probe.updateIntent),
2561
+ invalidationVersion: probe.invalidationVersion,
2562
+ });
2563
+ }
2564
+ return facts;
2565
+ }
2566
+
2567
+ interface CameraRoleSelection {
2568
+ readonly display: readonly CameraSnapshot[];
2569
+ readonly auxiliary: readonly CameraSnapshot[];
2570
+ }
2571
+
2572
+ function selectCameraRoles(world: World): CameraRoleSelection {
2573
+ const cameras = collectCameraSnapshots(world);
2574
+ const displayEntities = cameras
2575
+ .filter((camera) => camera.target === undefined)
2576
+ .map((camera) => camera.entityKey ?? 0);
2577
+ const activeCameraIndex = selectActiveCameraIndex(
2578
+ displayEntities,
2579
+ getActiveCamera(world)?.entity,
2580
+ );
2581
+ const displayEntityKey = displayEntities[activeCameraIndex] ?? displayEntities[0];
2582
+ const display =
2583
+ displayEntityKey === undefined
2584
+ ? []
2585
+ : cameras.filter((camera) => camera.entityKey === displayEntityKey);
2586
+ const candidates: CameraTargetCandidate[] = cameras.map((camera) => ({
2587
+ worldId: 0,
2588
+ entityKey: camera.entityKey ?? 0,
2589
+ ...(camera.target === undefined ? {} : { target: camera.target }),
2590
+ requestVersion: 0,
2591
+ update: 'continuous',
2592
+ }));
2593
+ const selected = selectCameraTargetViews(candidates, {
2594
+ ...(displayEntityKey === undefined ? {} : { displayEntityKey }),
2595
+ budget: 1,
2596
+ });
2597
+ const auxiliary = selected.auxiliary.flatMap((candidate) =>
2598
+ cameras.filter((camera) => camera.entityKey === candidate.entityKey),
2599
+ );
2600
+ return { display, auxiliary };
2601
+ }
2602
+
2603
+ export function extractCameraSnapshots(world: World): CameraSnapshot[] {
2604
+ return [...selectCameraRoles(world).display];
2605
+ }
2606
+
2607
+ export function extractCameraTargetSnapshots(world: World): readonly CameraSnapshot[] {
2608
+ return selectCameraRoles(world).auxiliary;
2609
+ }
2610
+
2303
2611
  function tonemapParams(camera: CameraSnapshot): Uint8Array {
2304
2612
  const bytes = new ArrayBuffer(16);
2305
2613
  const floats = new Float32Array(bytes);
@@ -2335,6 +2643,21 @@ export function prepareExtractContext(
2335
2643
  }
2336
2644
 
2337
2645
  const fogQueries = new WeakMap<World, Query<readonly [typeof Fog]>>();
2646
+ const fogLkgStates = new WeakMap<
2647
+ World,
2648
+ { lkg: FogFrame | undefined; failure: FogSelectionFailure | undefined }
2649
+ >();
2650
+
2651
+ function fogState(world: World): {
2652
+ lkg: FogFrame | undefined;
2653
+ failure: FogSelectionFailure | undefined;
2654
+ } {
2655
+ const existing = fogLkgStates.get(world);
2656
+ if (existing !== undefined) return existing;
2657
+ const created = { lkg: undefined, failure: undefined };
2658
+ fogLkgStates.set(world, created);
2659
+ return created;
2660
+ }
2338
2661
 
2339
2662
  function selectFogFrame(world: World): FogFrame | undefined {
2340
2663
  // Query compilation is owned once per World; Query refreshes its matched
@@ -2362,10 +2685,22 @@ function selectFogFrame(world: World): FogFrame | undefined {
2362
2685
  suns: [],
2363
2686
  lane: 'direct',
2364
2687
  });
2365
- if (fogSelection.ok) return fogSelection.value.fog;
2688
+ const state = fogState(world);
2689
+ if (fogSelection.ok) {
2690
+ state.lkg = fogSelection.value.fog;
2691
+ state.failure = undefined;
2692
+ return fogSelection.value.fog;
2693
+ }
2694
+ state.failure = {
2695
+ code: fogSelection.error.code,
2696
+ expected: fogSelection.error.expected,
2697
+ hint: fogSelection.error.hint,
2698
+ detail: Object.freeze({ ...fogSelection.error.detail }),
2699
+ };
2366
2700
  routeWorldError(world, fogSelection.error, {
2367
2701
  systemName: 'RenderSystem.extract (fog-selection)',
2368
2702
  });
2703
+ if (state.lkg !== undefined) return state.lkg;
2369
2704
  throw fogSelection.error;
2370
2705
  }
2371
2706
 
@@ -2697,7 +3032,8 @@ export function extractFrames(
2697
3032
  let shadowMapSize: number | undefined;
2698
3033
  let depthBias: number | undefined;
2699
3034
  let normalBias: number | undefined;
2700
- let pcfKernelSize: number | undefined;
3035
+ let directionalShadowQuality: DirectionalShadowQuality | undefined;
3036
+ let directionalShadowError: LightValidationError | undefined;
2701
3037
  const pointShadow: PointShadowSnapshot[] = [];
2702
3038
  // bug-20260710-editor-cross-world-shadow: carry the raw CSM config +
2703
3039
  // direction of the first-hit directional so the merge layer can recompute
@@ -2719,7 +3055,8 @@ export function extractFrames(
2719
3055
  shadowMapSize = f.lights.shadowMapSize;
2720
3056
  depthBias = f.lights.depthBias;
2721
3057
  normalBias = f.lights.normalBias;
2722
- pcfKernelSize = f.lights.pcfKernelSize;
3058
+ directionalShadowQuality = f.lights.directionalShadowQuality;
3059
+ directionalShadowError = f.lights.directionalShadowError;
2723
3060
  directionalCsmConfig = f.lights.directionalCsmConfig;
2724
3061
  directionalCsmDirection = f.lights.directionalCsmDirection;
2725
3062
  }
@@ -2738,6 +3075,9 @@ export function extractFrames(
2738
3075
  if (succeededIndices[fi] === resourceOwner) resourceOwnerFrame = succeededFrames[fi];
2739
3076
  }
2740
3077
  const cameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cameras] : [];
3078
+ const auxiliaryCameras =
3079
+ cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.auxiliaryCameras] : [];
3080
+ const cubeCameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cubeCameras] : [];
2741
3081
 
2742
3082
  // bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
2743
3083
  // against the surfaced camera. In a single-world app the per-world extract
@@ -2776,6 +3116,7 @@ export function extractFrames(
2776
3116
  cascadeCount = csm.cascadeCount;
2777
3117
  cascadeBlend = csm.cascadeBlend;
2778
3118
  shadowMapSize = csm.shadowMapSize;
3119
+ directionalShadowQuality = csm.directionalShadowQuality;
2779
3120
  }
2780
3121
  // csm === null (no surfaced camera at all): keep the per-world carry —
2781
3122
  // there is no better data, and a cameraless frame renders nothing anyway.
@@ -2793,7 +3134,8 @@ export function extractFrames(
2793
3134
  shadowMapSize,
2794
3135
  depthBias,
2795
3136
  normalBias,
2796
- pcfKernelSize,
3137
+ directionalShadowQuality,
3138
+ directionalShadowError,
2797
3139
  pointShadow,
2798
3140
  directionalCsmConfig,
2799
3141
  directionalCsmDirection,
@@ -2804,6 +3146,7 @@ export function extractFrames(
2804
3146
  const skybox = resourceOwnerFrame?.skybox;
2805
3147
  const skyboxCount = resourceOwnerFrame?.skyboxCount ?? 0;
2806
3148
  const fog = resourceOwnerFrame?.fog;
3149
+ const fogFailure = resourceOwnerFrame?.fogFailure;
2807
3150
  const environment = resourceOwnerFrame?.environment;
2808
3151
  // User-authored PostProcessParams entities are singleton scene resources, so
2809
3152
  // they come from the resourceOwner world (holistic snapshot selection).
@@ -2843,9 +3186,16 @@ export function extractFrames(
2843
3186
  const visibilityStats = {
2844
3187
  explicitlyHidden: succeededFrames.reduce((s, f) => s + f.visibilityStats.explicitlyHidden, 0),
2845
3188
  };
3189
+ const reflectionProbes = succeededFrames.flatMap((f, index) => {
3190
+ const worldId = succeededIndices[index] ?? 0;
3191
+ return (f.reflectionProbes ?? []).map((probe) => ({ ...probe, worldId }));
3192
+ });
2846
3193
 
2847
3194
  return {
2848
3195
  cameras,
3196
+ auxiliaryCameras,
3197
+ cubeCameras,
3198
+ reflectionProbes,
2849
3199
  lights,
2850
3200
  ...(volumetricFog === undefined
2851
3201
  ? {}
@@ -2857,6 +3207,7 @@ export function extractFrames(
2857
3207
  skybox,
2858
3208
  skyboxCount,
2859
3209
  fog,
3210
+ ...(fogFailure === undefined ? {} : { fogFailure }),
2860
3211
  environment,
2861
3212
  environmentReady: resourceOwnerFrame !== undefined && environment !== undefined,
2862
3213
  frustumStats,
@@ -2952,6 +3303,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2952
3303
  // established at the frame boundary; record never revisits World state.
2953
3304
  const fog =
2954
3305
  resourceOwnerFog === undefined ? selectFogFrame(world) : (resourceOwnerFog ?? undefined);
3306
+ const fogFailure = fogState(world).failure;
2955
3307
  const visibility = context.visibility.hasAnyHiddenIntent ? context.visibility : undefined;
2956
3308
  const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
2957
3309
 
@@ -2971,7 +3323,11 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2971
3323
  const worldInternal = createWorldInternalView(world);
2972
3324
  let volumetricFog = extractVolumeSnapshot(world, assets, worldInternal);
2973
3325
 
2974
- const cameras = extractCameraSnapshots(world);
3326
+ const cameraRoles = selectCameraRoles(world);
3327
+ const cameras = [...cameraRoles.display];
3328
+ const auxiliaryCameras = cameraRoles.auxiliary;
3329
+ const cubeCameras = collectCubeCameraSnapshots(world);
3330
+ const reflectionProbes = collectReflectionProbeFacts(world);
2975
3331
 
2976
3332
  // Three-query union (M2 / w16 / AC-03): directional has no Transform
2977
3333
  // dependency (sun-like infinite-source semantics); point + spot pull
@@ -2992,7 +3348,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2992
3348
  depthBias: number;
2993
3349
  normalBias: number;
2994
3350
  shadowDistance: number;
2995
- pcfKernelSize: number;
3351
+ shadowFilter: number;
3352
+ shadowAngularRadius: number;
3353
+ maxPenumbraTexels: number;
2996
3354
  }
2997
3355
  | undefined;
2998
3356
  for (const row of directionalLightQuery) {
@@ -3022,11 +3380,29 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3022
3380
  depthBias: l.depthBias,
3023
3381
  normalBias: l.normalBias,
3024
3382
  shadowDistance: l.shadowDistance,
3025
- pcfKernelSize: l.pcfKernelSize,
3383
+ shadowFilter: l.shadowFilter,
3384
+ shadowAngularRadius: l.shadowAngularRadius,
3385
+ maxPenumbraTexels: l.maxPenumbraTexels,
3026
3386
  };
3027
3387
  }
3028
3388
  }
3029
3389
 
3390
+ let directionalShadowError: LightValidationError | undefined;
3391
+ if (directional !== undefined && firstHitShadowFields !== undefined) {
3392
+ const validation = validateDirectionalLightData({
3393
+ direction: directional.direction,
3394
+ castShadow: firstHitCastShadow,
3395
+ ...firstHitShadowFields,
3396
+ });
3397
+ if (!validation.ok) {
3398
+ directionalShadowError = validation.error;
3399
+ worldInternal._routeError(directionalShadowError, {
3400
+ severity: Severity.Error,
3401
+ systemName: 'RenderSystem.extract (directional-shadow)',
3402
+ });
3403
+ }
3404
+ }
3405
+
3030
3406
  const pointSnapshots: PointLightSnapshot[] = [];
3031
3407
  // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4: track entity per
3032
3408
  // pointSnapshots index so the post-extract pointShadow join can stamp
@@ -3282,6 +3658,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3282
3658
  let cascadeCount: number | undefined;
3283
3659
  let cascadeBlend: number | undefined;
3284
3660
  let shadowMapSize: number | undefined;
3661
+ let directionalShadowQuality: DirectionalShadowQuality | undefined;
3285
3662
  let directionalCsmConfig: DirectionalCsmConfig | undefined;
3286
3663
  let directionalCsmDirection: Vec3 | undefined;
3287
3664
 
@@ -3307,7 +3684,11 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3307
3684
  // feat-20260621 M2: CSM computation gated on castShadow from the
3308
3685
  // merged DirectionalLight. castShadow defaults to true (first-hit-wins
3309
3686
  // semantics, D-6 no cardinality cap).
3310
- if (directional !== undefined && firstHitCastShadow !== false) {
3687
+ if (
3688
+ directional !== undefined &&
3689
+ firstHitCastShadow !== false &&
3690
+ directionalShadowError === undefined
3691
+ ) {
3311
3692
  const dirSnapshot = directional;
3312
3693
  const sf = firstHitShadowFields;
3313
3694
  if (sf !== undefined) {
@@ -3317,6 +3698,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3317
3698
  cascadeBlend: sf.cascadeBlend,
3318
3699
  mapSize: sf.mapSize,
3319
3700
  shadowDistance: sf.shadowDistance,
3701
+ shadowFilter: sf.shadowFilter,
3702
+ shadowAngularRadius: sf.shadowAngularRadius,
3703
+ maxPenumbraTexels: sf.maxPenumbraTexels,
3320
3704
  };
3321
3705
  directionalCsmDirection = dirSnapshot.direction;
3322
3706
  const csm = computeDirectionalCsm(dirSnapshot.direction, directionalCsmConfig, cameraData);
@@ -3331,13 +3715,19 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3331
3715
  if (csm !== null) {
3332
3716
  lightViewProj = csm.lightViewProj;
3333
3717
  splitPlanes = csm.splitPlanes;
3718
+ directionalShadowQuality = csm.directionalShadowQuality;
3334
3719
  } else {
3335
3720
  const sNear = cameraData?.near ?? 0.1;
3336
3721
  const splits = pssmSplit(sNear, sf.shadowDistance, cascadeCount, sf.splitLambda);
3337
- const padded = new Float32Array(4);
3338
- for (let i = 0; i < splits.length; i++) padded[i] = splits[i] ?? 0;
3722
+ const padded = new Float32Array(16);
3723
+ for (let i = 0; i < splits.length; i++) padded[i * 4] = splits[i] ?? 0;
3339
3724
  splitPlanes = padded;
3340
3725
  }
3726
+ directionalShadowQuality = directionalShadowQualityFromF32(
3727
+ sf.shadowFilter,
3728
+ sf.shadowAngularRadius,
3729
+ sf.maxPenumbraTexels,
3730
+ );
3341
3731
  }
3342
3732
  }
3343
3733
 
@@ -3346,7 +3736,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3346
3736
  // the View UBO tail keeps a stable layout regardless of the runtime
3347
3737
  // cascadeCount. Host-side correctness invariant: only the first
3348
3738
  // cascadeCount slots are ever read by the WGSL kernel.
3349
- const paddedSplitPlanes = new Float32Array(4);
3739
+ const paddedSplitPlanes = new Float32Array(16);
3350
3740
  if (splitPlanes !== undefined) {
3351
3741
  for (let i = 0; i < splitPlanes.length; i++) {
3352
3742
  paddedSplitPlanes[i] = splitPlanes[i] ?? 0;
@@ -3356,9 +3746,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3356
3746
  // feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7 (plan-strategy §D-3,
3357
3747
  // requirements §5.3): query (PointLight + PointLightShadow + Transform)
3358
3748
  // archetype join. For each shadow caster, build 6 face VP matrices and pack
3359
- // into Float32Array(96). Atlas layer assigned in spawn order (0..3); the
3360
- // sentinel -1 is shader-side and applies to non-shadow PointLights only.
3361
- // Cap of 4 is enforced by ECS cardinality on PointLightShadow.
3749
+ // into Float32Array(96). Atlas layer is assigned in spawn order while the
3750
+ // renderer-owned atlas has capacity; the sentinel -1 is shader-side and
3751
+ // applies to non-admitted PointLightShadow requests as well.
3362
3752
  const pointShadowSnapshots: PointShadowSnapshot[] = [];
3363
3753
  {
3364
3754
  const pointShadowQuery = world
@@ -3386,7 +3776,13 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3386
3776
  const mapSize = ps.mapSize;
3387
3777
  const nearPlane = ps.nearPlane;
3388
3778
  const farPlane = ps.farPlane;
3389
- const layer = pointShadowSnapshots.length; // 0, 1, 2, 3 in spawn order
3779
+ // The renderer, not ECS, owns the atlas budget. Preserve every request
3780
+ // for inspection, but emit the shader sentinel for entries that cannot
3781
+ // acquire a layer so record never calls faceView outside the atlas.
3782
+ const layer =
3783
+ pointShadowSnapshots.length < SHADOW_ATLAS_DEFAULT_LAYERS
3784
+ ? pointShadowSnapshots.length
3785
+ : -1;
3390
3786
 
3391
3787
  const matrices = buildPointShadowMatrices(lightPos, nearPlane, farPlane);
3392
3788
  const packed = new Float32Array(96);
@@ -3444,7 +3840,8 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3444
3840
  shadowMapSize,
3445
3841
  depthBias: firstHitCastShadow !== false ? firstHitShadowFields?.depthBias : undefined,
3446
3842
  normalBias: firstHitCastShadow !== false ? firstHitShadowFields?.normalBias : undefined,
3447
- pcfKernelSize: firstHitCastShadow !== false ? firstHitShadowFields?.pcfKernelSize : undefined,
3843
+ directionalShadowQuality,
3844
+ directionalShadowError,
3448
3845
  pointShadow: pointShadowSnapshots,
3449
3846
  // bug-20260710-editor-cross-world-shadow: raw CSM config + light direction
3450
3847
  // so the merge layer can recompute matrices against the surfaced camera.
@@ -4336,6 +4733,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4336
4733
  pv.occlusionTexture,
4337
4734
  );
4338
4735
  const textureCoordinates = collectMaterialTextureCoordinates(pv);
4736
+ const textureSources = collectMaterialTextureSources(pv, world);
4339
4737
  const emissivePv = pv.emissive as readonly number[] | undefined;
4340
4738
  // feat-20260625 M2 / w6: first-pass transparency flag folds into
4341
4739
  // MaterialSnapshot.transparent so the record stage can drive the
@@ -4435,6 +4833,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4435
4833
  ...(textureCoordinates.size > 0 && { textureCoordinates }),
4436
4834
  ...(samplerHandles.size > 0 && { samplerHandles }),
4437
4835
  ...(textureHandles.size > 0 && { textureHandles }),
4836
+ ...(textureSources.size > 0 && { textureSources }),
4438
4837
  ...(videoTextureFields.size > 0 && { videoTextureFields }),
4439
4838
  ...(baseColorTextureHandle !== undefined && {
4440
4839
  baseColorTexture: baseColorTextureHandle,
@@ -4653,6 +5052,15 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4653
5052
  }
4654
5053
  }
4655
5054
 
5055
+ // A pbr-skin PSO requires the complete three-binding group(2) skin
5056
+ // projection. Do not publish a renderable or its staged dispatch when
5057
+ // palette extraction was unavailable; the record stage must never
5058
+ // reinterpret a skinned vertex stream through an ordinary mesh group.
5059
+ if (materialSnap.materialShaderId === 'forgeax::pbr-skin' && skinSlice === undefined) {
5060
+ pendingDispatch.length = 0;
5061
+ continue;
5062
+ }
5063
+
4656
5064
  // feat-20260601 D-3: read the resolved world mat4 (propagateTransforms
4657
5065
  // output) straight from the Transform.world column array view. The
4658
5066
  // record stage copies these 16 floats into the mesh SSBO with zero
@@ -4841,6 +5249,44 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4841
5249
  projection: identityProjection(),
4842
5250
  } satisfies PointsLinesRetainedSnapshot);
4843
5251
 
5252
+ // Instances are snapshotted before the CPU visibility decision so the
5253
+ // culling owner can derive the union of every instance's transformed
5254
+ // mesh bounds. The same detached snapshot is then carried to record;
5255
+ // do not re-read the ECS array after culling (a structural write must
5256
+ // not make the cull and draw disagree within one frame).
5257
+ let instancesSnap: InstancesSnapshot | undefined;
5258
+ if (hasInstances) {
5259
+ const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
5260
+ if (transforms === undefined) {
5261
+ // A present component with an unavailable array is an empty
5262
+ // instance contribution, not an absent component. Preserve the
5263
+ // zero-draw contract so the record stage cannot synthesize one
5264
+ // identity instance from an incomplete row.
5265
+ instancesSnap = {
5266
+ transforms: new Float32Array(),
5267
+ instanceCount: 0,
5268
+ cacheKey: entity,
5269
+ archVersion,
5270
+ };
5271
+ } else {
5272
+ const actualLength = transforms.length;
5273
+ if (actualLength % 16 !== 0) {
5274
+ worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
5275
+ severity: Severity.Error,
5276
+ systemName: 'RenderSystem.extract (Instances stride)',
5277
+ });
5278
+ continue;
5279
+ }
5280
+ const snapshotCopy = new Float32Array(transforms);
5281
+ instancesSnap = {
5282
+ transforms: snapshotCopy,
5283
+ instanceCount: actualLength / 16,
5284
+ cacheKey: entity,
5285
+ archVersion,
5286
+ };
5287
+ }
5288
+ }
5289
+
4844
5290
  let nonIndexedFirst = 0;
4845
5291
  const gpuDrivenDraws = gpuDrivenSubmeshes.flatMap((submesh) => {
4846
5292
  const drawMaterial = materialsArr[submesh.materialSlot] ?? materialSnap;
@@ -4873,6 +5319,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4873
5319
  entityKey: entity,
4874
5320
  ...(skinSlice !== undefined ? { skin: skinSlice } : {}),
4875
5321
  ...(morph !== undefined ? { morph } : {}),
5322
+ ...(instancesSnap !== undefined ? { instances: instancesSnap } : {}),
4876
5323
  ...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),
4877
5324
  ...(pointsLines !== undefined ? { pointsLines } : {}),
4878
5325
  };
@@ -4884,66 +5331,70 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4884
5331
  // producer-owned finite local-space AABBs. Culling is unconditional
4885
5332
  // engine behavior; there is no per-entity opt-out.
4886
5333
  if (localAabb !== undefined) {
4887
- // feat-20260601 D-3: cull AABB uses the resolved world mat4
4888
- // directly (no compose) -- same source the record stage feeds
4889
- // the mesh SSBO, so cull stays same-source with render (AC-05).
4890
- const worldAabb = box3.create();
4891
- box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
4892
-
4893
- // Test against all cameras. Entity is visible if one camera
4894
- // frustum intersects the world-space AABB (or planes are empty
4895
- // from degenerate projection).
4896
- frustumTotal += 1;
4897
- let visible = frustumPlanes.length === 0;
4898
- for (let ci = 0; ci < frustumPlanes.length; ci++) {
4899
- const planes = frustumPlanes[ci] as Float32Array;
4900
- if (planes.length === 0) {
4901
- visible = true;
4902
- break;
4903
- }
4904
- if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
4905
- visible = true;
4906
- break;
4907
- }
4908
- }
4909
- if (!visible) {
4910
- frustumCulled += 1;
4911
- continue;
4912
- }
4913
- }
4914
-
4915
- if (hasInstances) {
4916
- const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
4917
- if (transforms === undefined) {
4918
- flushPendingDispatch(pendingDispatch);
4919
- renderables.push(baseRenderable);
5334
+ // An empty or malformed Instances payload has no meaningful bounds.
5335
+ // Keep it conservative (the draw path later emits zero instances)
5336
+ // instead of manufacturing the entity's identity bounds. For a
5337
+ // valid payload, derive one world-space union for CPU culling while
5338
+ // leaving the mesh-local AABB untouched for GPU per-instance cull.
5339
+ const derivedInstancesBounds =
5340
+ instancesSnap === undefined || instancesSnap.instanceCount === 0
5341
+ ? undefined
5342
+ : deriveInstancesUnionBounds({
5343
+ meshAabb: cullingLocalAabb,
5344
+ entityWorld: transformSnap.world,
5345
+ transforms: instancesSnap.transforms,
5346
+ });
5347
+ if (instancesSnap?.instanceCount === 0) {
5348
+ // No instance can contribute a visible bound. The retained
5349
+ // snapshot still records instanceCount=0 for the record stage.
5350
+ } else if (
5351
+ hasInstances &&
5352
+ (instancesSnap === undefined || derivedInstancesBounds === undefined)
5353
+ ) {
5354
+ // Missing/malformed derived facts are a conservative no-cull
5355
+ // result. The stride validator above already rejects malformed
5356
+ // lengths; this branch covers non-finite producer matrices/AABB.
4920
5357
  } else {
4921
- const actualLength = transforms.length;
4922
- if (actualLength % 16 !== 0) {
4923
- worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
4924
- severity: Severity.Error,
4925
- systemName: 'RenderSystem.extract (Instances stride)',
4926
- });
5358
+ // feat-20260601 D-3: cull AABB uses the resolved world mat4
5359
+ // directly (no compose) -- same source the record stage feeds
5360
+ // the mesh SSBO, so cull stays same-source with render (AC-05).
5361
+ const worldAabb = box3.create();
5362
+ if (derivedInstancesBounds !== undefined) {
5363
+ worldAabb[0] = derivedInstancesBounds[0] ?? 0;
5364
+ worldAabb[1] = derivedInstancesBounds[1] ?? 0;
5365
+ worldAabb[2] = derivedInstancesBounds[2] ?? 0;
5366
+ worldAabb[3] = derivedInstancesBounds[3] ?? 0;
5367
+ worldAabb[4] = derivedInstancesBounds[4] ?? 0;
5368
+ worldAabb[5] = derivedInstancesBounds[5] ?? 0;
5369
+ } else {
5370
+ box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
5371
+ }
4927
5372
 
5373
+ // Test against all cameras. Entity is visible if one camera
5374
+ // frustum intersects the world-space AABB (or planes are empty
5375
+ // from degenerate projection).
5376
+ frustumTotal += 1;
5377
+ let visible = frustumPlanes.length === 0;
5378
+ for (let ci = 0; ci < frustumPlanes.length; ci++) {
5379
+ const planes = frustumPlanes[ci] as Float32Array;
5380
+ if (planes.length === 0) {
5381
+ visible = true;
5382
+ break;
5383
+ }
5384
+ if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
5385
+ visible = true;
5386
+ break;
5387
+ }
5388
+ }
5389
+ if (!visible) {
5390
+ frustumCulled += 1;
4928
5391
  continue;
4929
5392
  }
4930
- const snapshotCopy = new Float32Array(transforms);
4931
- const instanceCount = Math.max(1, Math.floor(actualLength / 16));
4932
- flushPendingDispatch(pendingDispatch);
4933
- renderables.push({
4934
- ...baseRenderable,
4935
- instances: {
4936
- transforms: snapshotCopy,
4937
- instanceCount,
4938
- cacheKey: entity,
4939
- archVersion,
4940
- },
4941
- });
4942
5393
  }
4943
- } else {
4944
- flushPendingDispatch(pendingDispatch);
4945
- renderables.push(baseRenderable);
4946
5394
  }
5395
+
5396
+ flushPendingDispatch(pendingDispatch);
5397
+ renderables.push(baseRenderable);
4947
5398
  }
4948
5399
 
4949
5400
  // feat-20260520-2d-sprite-layer-mvp M-3 / w22 + w25: finalise the
@@ -4978,10 +5429,12 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4978
5429
  // whitePoint / tonemap` onto the SAME unified params channel custom
4979
5430
  // post-processes use — `Camera.exposure` stays the AI-user-facing SSOT (D-5),
4980
5431
  // the engine itself acts as the provider for the Standard tonemap shader
4981
- // id. The 16B layout is byte-identical to the prior recordTonemapPass packing
4982
- // (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _, pad]
4983
- // with the mode u32 occupying the third 4-byte slot via tonemapToU32 (SSOT in
4984
- // camera.ts). Run AFTER the user-entity collection above so the engine's
5432
+ // id. The 16B layout keeps the prior recordTonemapPass packing
5433
+ // (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _,
5434
+ // ditherEnabled], with the mode u32 occupying the third 4-byte slot via
5435
+ // tonemapToU32 (SSOT in camera.ts). The final Output Transform makes a
5436
+ // per-pass copy and writes ditherEnabled at byte offset 12; the extracted
5437
+ // camera snapshot remains the SSOT. Run AFTER the user-entity collection above so the engine's
4985
5438
  // built-in provider is authoritative for its own reserved key (a user entity
4986
5439
  // can never shadow the Standard tonemap identity. The single active camera mirrors
4987
5440
  // recordFrame's `activeCameras[0]` selection.
@@ -4992,6 +5445,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4992
5445
 
4993
5446
  return {
4994
5447
  cameras,
5448
+ auxiliaryCameras,
5449
+ cubeCameras,
5450
+ reflectionProbes,
4995
5451
  lights,
4996
5452
  environment,
4997
5453
  environmentReady: true,
@@ -5003,6 +5459,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
5003
5459
  skybox,
5004
5460
  skyboxCount,
5005
5461
  fog,
5462
+ ...(fogFailure === undefined ? {} : { fogFailure }),
5006
5463
  frustumStats: { culled: frustumCulled, total: frustumTotal },
5007
5464
  visibilityStats: { explicitlyHidden: explicitlyHidden.size },
5008
5465
  postProcessParams,