@forgeax/engine-render 0.1.19 → 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 (452) 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 +34 -20
  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-RJRUS55L.mjs → chunk-5YADUCA7.mjs} +1896 -1624
  21. package/dist/chunk-5YADUCA7.mjs.map +1 -0
  22. package/dist/{chunk-LOSO4CTV.mjs → chunk-6OPXDQ2E.mjs} +423 -46
  23. package/dist/chunk-6OPXDQ2E.mjs.map +1 -0
  24. package/dist/{chunk-IPXCABEP.mjs → chunk-BXBQQKFQ.mjs} +3 -9
  25. package/dist/chunk-BXBQQKFQ.mjs.map +1 -0
  26. package/dist/chunk-FS2KNUQH.mjs +1294 -0
  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/components/spot-light.d.ts +12 -0
  42. package/dist/components/spot-light.d.ts.map +1 -1
  43. package/dist/construct-renderer.mjs +8552 -1612
  44. package/dist/construct-renderer.mjs.map +1 -1
  45. package/dist/device/gpu-residency.d.ts +19 -2
  46. package/dist/device/gpu-residency.d.ts.map +1 -1
  47. package/dist/environment/frame.d.ts.map +1 -1
  48. package/dist/environment/lifecycle.d.ts +5 -0
  49. package/dist/environment/lifecycle.d.ts.map +1 -1
  50. package/dist/errors/render.d.ts +209 -93
  51. package/dist/errors/render.d.ts.map +1 -1
  52. package/dist/extract/directional-shadow-projection.d.ts +23 -0
  53. package/dist/extract/directional-shadow-projection.d.ts.map +1 -0
  54. package/dist/extract/environment.d.ts +14 -0
  55. package/dist/extract/environment.d.ts.map +1 -1
  56. package/dist/extract/visibility.d.ts.map +1 -1
  57. package/dist/features/fullscreen.d.ts.map +1 -1
  58. package/dist/features/targets.d.ts.map +1 -1
  59. package/dist/features/types.d.ts +10 -0
  60. package/dist/features/types.d.ts.map +1 -1
  61. package/dist/fullscreen-post-process-pass.d.ts +13 -2
  62. package/dist/fullscreen-post-process-pass.d.ts.map +1 -1
  63. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  64. package/dist/gpu-driven/production-raster.d.ts +0 -1
  65. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  66. package/dist/gpu-scene.d.ts.map +1 -1
  67. package/dist/gpu-usage.d.ts +2 -0
  68. package/dist/gpu-usage.d.ts.map +1 -1
  69. package/dist/hdrp-buffers.d.ts +19 -17
  70. package/dist/hdrp-buffers.d.ts.map +1 -1
  71. package/dist/ibl/IblPipelineCache.d.ts +4 -0
  72. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  73. package/dist/ibl/kernel-cache.d.ts +36 -0
  74. package/dist/ibl/kernel-cache.d.ts.map +1 -0
  75. package/dist/index.d.ts +35 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.mjs +4 -4
  78. package/dist/index.mjs.map +1 -1
  79. package/dist/inspection-types.d.ts +51 -0
  80. package/dist/inspection-types.d.ts.map +1 -1
  81. package/dist/instances-derived-bounds.d.ts +50 -0
  82. package/dist/instances-derived-bounds.d.ts.map +1 -0
  83. package/dist/internal.mjs +1 -1
  84. package/dist/light-buffer-layout.d.ts +30 -13
  85. package/dist/light-buffer-layout.d.ts.map +1 -1
  86. package/dist/mesh-material-bindings.d.ts +54 -0
  87. package/dist/mesh-material-bindings.d.ts.map +1 -1
  88. package/dist/pbr-pipeline.d.ts +20 -7
  89. package/dist/pbr-pipeline.d.ts.map +1 -1
  90. package/dist/pipeline/standard-forward-lane.d.ts +2 -1
  91. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  92. package/dist/pipeline/standard-lighting/errors.d.ts +4 -0
  93. package/dist/pipeline/standard-lighting/errors.d.ts.map +1 -0
  94. package/dist/pipeline/standard-lighting/graph.d.ts +24 -0
  95. package/dist/pipeline/standard-lighting/graph.d.ts.map +1 -0
  96. package/dist/pipeline/standard-lighting/inspection.d.ts +38 -0
  97. package/dist/pipeline/standard-lighting/inspection.d.ts.map +1 -0
  98. package/dist/pipeline/standard-lighting/layout.d.ts +28 -0
  99. package/dist/pipeline/standard-lighting/layout.d.ts.map +1 -0
  100. package/dist/pipeline/standard-lighting/light-frame.d.ts +33 -0
  101. package/dist/pipeline/standard-lighting/light-frame.d.ts.map +1 -0
  102. package/dist/pipeline/standard-lighting/membership.d.ts +14 -0
  103. package/dist/pipeline/standard-lighting/membership.d.ts.map +1 -0
  104. package/dist/pipeline/standard-lighting/prepare.d.ts +16 -0
  105. package/dist/pipeline/standard-lighting/prepare.d.ts.map +1 -0
  106. package/dist/pipeline/standard-lighting/topology.d.ts +45 -0
  107. package/dist/pipeline/standard-lighting/topology.d.ts.map +1 -0
  108. package/dist/pipeline/standard-lighting/transport.d.ts +36 -0
  109. package/dist/pipeline/standard-lighting/transport.d.ts.map +1 -0
  110. package/dist/pipeline/standard-pipeline.d.ts +4 -13
  111. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  112. package/dist/pipeline/standard-post.d.ts.map +1 -1
  113. package/dist/pipeline/standard-profile.d.ts +12 -20
  114. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  115. package/dist/pipeline-spec.d.ts +40 -7
  116. package/dist/pipeline-spec.d.ts.map +1 -1
  117. package/dist/point-shadow-inspection.d.ts +21 -0
  118. package/dist/point-shadow-inspection.d.ts.map +1 -0
  119. package/dist/record/frame-lighting.d.ts +29 -32
  120. package/dist/record/frame-lighting.d.ts.map +1 -1
  121. package/dist/record/frame-snapshot.d.ts +65 -15
  122. package/dist/record/frame-snapshot.d.ts.map +1 -1
  123. package/dist/record/frame-targets.d.ts +5 -0
  124. package/dist/record/frame-targets.d.ts.map +1 -1
  125. package/dist/record/frame.d.ts +12 -5
  126. package/dist/record/frame.d.ts.map +1 -1
  127. package/dist/record/gpu-pass-timing/contract.d.ts +96 -0
  128. package/dist/record/gpu-pass-timing/contract.d.ts.map +1 -0
  129. package/dist/record/gpu-pass-timing/errors.d.ts +18 -0
  130. package/dist/record/gpu-pass-timing/errors.d.ts.map +1 -0
  131. package/dist/record/gpu-pass-timing/index.d.ts +8 -0
  132. package/dist/record/gpu-pass-timing/index.d.ts.map +1 -0
  133. package/dist/record/gpu-pass-timing/parser.d.ts +11 -0
  134. package/dist/record/gpu-pass-timing/parser.d.ts.map +1 -0
  135. package/dist/record/gpu-pass-timing/session.d.ts +43 -0
  136. package/dist/record/gpu-pass-timing/session.d.ts.map +1 -0
  137. package/dist/record/gpu-timing.d.ts +37 -0
  138. package/dist/record/gpu-timing.d.ts.map +1 -0
  139. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  140. package/dist/record/main-pass-material.d.ts +23 -2
  141. package/dist/record/main-pass-material.d.ts.map +1 -1
  142. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  143. package/dist/record/main-pass.d.ts +2 -0
  144. package/dist/record/main-pass.d.ts.map +1 -1
  145. package/dist/record/render-context.d.ts +48 -3
  146. package/dist/record/render-context.d.ts.map +1 -1
  147. package/dist/record/shadow-pass.d.ts.map +1 -1
  148. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  149. package/dist/record/typed-frame-graph.d.ts +81 -5
  150. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  151. package/dist/record/view-ubo.d.ts +7 -4
  152. package/dist/record/view-ubo.d.ts.map +1 -1
  153. package/dist/reflection/filter.d.ts +22 -0
  154. package/dist/reflection/filter.d.ts.map +1 -0
  155. package/dist/reflection/gpu-table.d.ts +25 -0
  156. package/dist/reflection/gpu-table.d.ts.map +1 -0
  157. package/dist/reflection/projection.d.ts +91 -0
  158. package/dist/reflection/projection.d.ts.map +1 -0
  159. package/dist/render-contract.d.ts +114 -7
  160. package/dist/render-contract.d.ts.map +1 -1
  161. package/dist/render-data.d.ts +10 -6
  162. package/dist/render-data.d.ts.map +1 -1
  163. package/dist/render-graph-primitives.d.ts +1 -0
  164. package/dist/render-graph-primitives.d.ts.map +1 -1
  165. package/dist/render-pipeline.d.ts +78 -2
  166. package/dist/render-pipeline.d.ts.map +1 -1
  167. package/dist/render-system-extract.d.ts +113 -20
  168. package/dist/render-system-extract.d.ts.map +1 -1
  169. package/dist/render-system.d.ts +24 -3
  170. package/dist/render-system.d.ts.map +1 -1
  171. package/dist/renderer-inspect.d.ts +29 -0
  172. package/dist/renderer-inspect.d.ts.map +1 -0
  173. package/dist/renderer.d.ts +43 -0
  174. package/dist/renderer.d.ts.map +1 -0
  175. package/dist/scene/render-scene.d.ts +14 -0
  176. package/dist/scene/render-scene.d.ts.map +1 -1
  177. package/dist/shadow-atlas.d.ts +3 -3
  178. package/dist/shadow-atlas.d.ts.map +1 -1
  179. package/dist/ssao-buffers.d.ts +3 -0
  180. package/dist/ssao-buffers.d.ts.map +1 -1
  181. package/dist/targets/contracts.d.ts +66 -0
  182. package/dist/targets/contracts.d.ts.map +1 -0
  183. package/dist/targets/graph-projection.d.ts +29 -0
  184. package/dist/targets/graph-projection.d.ts.map +1 -0
  185. package/dist/targets/material-source.d.ts +34 -0
  186. package/dist/targets/material-source.d.ts.map +1 -0
  187. package/dist/targets/owner.d.ts +32 -0
  188. package/dist/targets/owner.d.ts.map +1 -0
  189. package/dist/targets/physical.d.ts +18 -0
  190. package/dist/targets/physical.d.ts.map +1 -0
  191. package/dist/targets/readback.d.ts +39 -0
  192. package/dist/targets/readback.d.ts.map +1 -0
  193. package/dist/temporal/frame.d.ts +44 -0
  194. package/dist/temporal/frame.d.ts.map +1 -0
  195. package/dist/temporal/index.mjs +3 -3
  196. package/dist/typed-render-graph-primitives.d.ts +6 -1
  197. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  198. package/dist/typed-shadow-atlas.d.ts +10 -0
  199. package/dist/typed-shadow-atlas.d.ts.map +1 -0
  200. package/dist/typed-shadow-passes.d.ts +1 -1
  201. package/dist/typed-shadow-passes.d.ts.map +1 -1
  202. package/dist/volume/capability.d.ts +57 -0
  203. package/dist/volume/capability.d.ts.map +1 -0
  204. package/dist/volume/component.d.ts +61 -0
  205. package/dist/volume/component.d.ts.map +1 -0
  206. package/dist/volume/extract.d.ts +11 -0
  207. package/dist/volume/extract.d.ts.map +1 -0
  208. package/dist/volume/inspection.d.ts +38 -0
  209. package/dist/volume/inspection.d.ts.map +1 -0
  210. package/dist/volume/passes.d.ts +38 -0
  211. package/dist/volume/passes.d.ts.map +1 -0
  212. package/dist/volume/recovery.d.ts +38 -0
  213. package/dist/volume/recovery.d.ts.map +1 -0
  214. package/dist/volume/resources.d.ts +57 -0
  215. package/dist/volume/resources.d.ts.map +1 -0
  216. package/dist/volume/temporal.d.ts +35 -0
  217. package/dist/volume/temporal.d.ts.map +1 -0
  218. package/package.json +20 -20
  219. package/src/__tests__/bloom-exact-zero.unit.test.ts +48 -19
  220. package/src/__tests__/bloom-lifecycle.integration.test.ts +41 -13
  221. package/src/__tests__/camera-target-consumption.unit.test.ts +53 -0
  222. package/src/__tests__/cluster-binner.unit.test.ts +35 -0
  223. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +7 -2
  224. package/src/__tests__/cube-camera-scheduler.unit.test.ts +70 -0
  225. package/src/__tests__/cube-camera-single-submit.perf.test.ts +75 -0
  226. package/src/__tests__/cube-camera-view-matrices.unit.test.ts +41 -0
  227. package/src/__tests__/cube-camera-vocabulary.unit.test.ts +47 -0
  228. package/src/__tests__/cube-camera.integration.test.ts +42 -0
  229. package/src/__tests__/device-feature-admission.unit.test.ts +73 -0
  230. package/src/__tests__/direct-light-snapshot.unit.test.ts +3 -3
  231. package/src/__tests__/docs-gate-green.unit.test.ts +38 -0
  232. package/src/__tests__/factory-contract.integration.test.ts +255 -29
  233. package/src/__tests__/fog-component.unit.test.ts +67 -0
  234. package/src/__tests__/fog.integration.test.ts +86 -0
  235. package/src/__tests__/fullscreen-feature-plan.unit.test.ts +37 -0
  236. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +10 -9
  237. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +17 -1
  238. package/src/__tests__/gpu-driven-production.integration.test.ts +16 -17
  239. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +12 -4
  240. package/src/__tests__/gpu-pass-timing-browser-runner.ts +281 -0
  241. package/src/__tests__/gpu-pass-timing-coexistence.integration.test.ts +80 -0
  242. package/src/__tests__/gpu-pass-timing-contract.unit.test.ts +243 -0
  243. package/src/__tests__/gpu-pass-timing-exact-zero.unit.test.ts +131 -0
  244. package/src/__tests__/gpu-pass-timing-lifecycle.integration.test.ts +230 -0
  245. package/src/__tests__/gpu-pass-timing-observation.unit.test.ts +233 -0
  246. package/src/__tests__/gpu-pass-timing-session.unit.test.ts +438 -0
  247. package/src/__tests__/gpu-pass-timing.browser.test.ts +26 -0
  248. package/src/__tests__/gpu-pass-timing.rhi-null.unit.test.ts +125 -0
  249. package/src/__tests__/hdrp-group2-visibility.unit.test.ts +24 -0
  250. package/src/__tests__/instances-culling.integration.test.ts +94 -0
  251. package/src/__tests__/instances-derived-bounds.unit.test.ts +118 -0
  252. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
  253. package/src/__tests__/material-observation.integration.test.ts +101 -0
  254. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  255. package/src/__tests__/pbr-view-layout.unit.test.ts +16 -0
  256. package/src/__tests__/point-shadow-inspection.unit.test.ts +60 -0
  257. package/src/__tests__/public-surface.test-d.ts +10 -0
  258. package/src/__tests__/reflection-probe-authoring.unit.test.ts +71 -0
  259. package/src/__tests__/reflection-probe-filter.unit.test.ts +54 -0
  260. package/src/__tests__/reflection-probe-ibl-topology.unit.test.ts +45 -0
  261. package/src/__tests__/reflection-probe-pass-encode.integration.test.ts +101 -0
  262. package/src/__tests__/reflection-probe-performance.perf.test.ts +60 -0
  263. package/src/__tests__/reflection-probe-recovery.integration.test.ts +55 -0
  264. package/src/__tests__/reflection-probe-selection.unit.test.ts +46 -0
  265. package/src/__tests__/render-error-code-owner.test-d.ts +23 -3
  266. package/src/__tests__/render-feature-docs.unit.test.ts +1 -1
  267. package/src/__tests__/render-target-admission.unit.test.ts +80 -0
  268. package/src/__tests__/render-target-graph-projection.unit.test.ts +102 -0
  269. package/src/__tests__/render-target-inspection.unit.test.ts +52 -0
  270. package/src/__tests__/render-target-material-source.test-d.ts +30 -0
  271. package/src/__tests__/render-target-material-source.unit.test.ts +97 -0
  272. package/src/__tests__/render-target-owner-placement.unit.test.ts +203 -0
  273. package/src/__tests__/render-target-owner.unit.test.ts +122 -0
  274. package/src/__tests__/render-target-probe-soak.perf.test.ts +94 -0
  275. package/src/__tests__/render-target-public-schema.json +107 -0
  276. package/src/__tests__/render-target-public-schema.unit.test.ts +91 -0
  277. package/src/__tests__/render-target-public-surface.test-d.ts +83 -0
  278. package/src/__tests__/render-target-readback.integration.test.ts +90 -0
  279. package/src/__tests__/render-target-recovery.integration.test.ts +85 -0
  280. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +76 -0
  281. package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
  282. package/src/__tests__/renderer-lifecycle.integration.test.ts +14 -0
  283. package/src/__tests__/shader-stage-surface.unit.test.ts +19 -4
  284. package/src/__tests__/skin-motion-regression.unit.test.ts +132 -0
  285. package/src/__tests__/skinned-shadow-caster.test.ts +32 -2
  286. package/src/__tests__/standard-cluster-capability.integration.test.ts +63 -0
  287. package/src/__tests__/standard-cluster-transport.unit.test.ts +121 -0
  288. package/src/__tests__/standard-cluster-variant-resolution.unit.test.ts +232 -0
  289. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +8 -5
  290. package/src/__tests__/standard-lighting-inspection.integration.test.ts +138 -0
  291. package/src/__tests__/standard-lighting-ssot.unit.test.ts +18 -0
  292. package/src/__tests__/standard-lighting-write.unit.test.ts +377 -0
  293. package/src/__tests__/standard-lighting.integration.test.ts +67 -0
  294. package/src/__tests__/standard-pipeline.dawn.test.ts +206 -5
  295. package/src/__tests__/standard-pipeline.integration.test.ts +181 -150
  296. package/src/__tests__/standard-probe-binding.unit.test.ts +25 -0
  297. package/src/__tests__/standard-profile-oracle.unit.test.ts +23 -36
  298. package/src/__tests__/typed-pipeline-topology.unit.test.ts +395 -14
  299. package/src/__tests__/urp-pipeline-targets.unit.test.ts +2 -2
  300. package/src/__tests__/visibility-resolver.unit.test.ts +15 -0
  301. package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
  302. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
  303. package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
  304. package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
  305. package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
  306. package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
  307. package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
  308. package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
  309. package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
  310. package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
  311. package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
  312. package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
  313. package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
  314. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
  315. package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
  316. package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
  317. package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
  318. package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
  319. package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
  320. package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
  321. package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
  322. package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
  323. package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
  324. package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
  325. package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
  326. package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
  327. package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
  328. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
  329. package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
  330. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
  331. package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
  332. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
  333. package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
  334. package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
  335. package/src/assembly/device-feature-admission.ts +73 -0
  336. package/src/assembly/directional-shadow-inspection.ts +122 -0
  337. package/src/assembly/factory.ts +1093 -246
  338. package/src/assembly/host-contract.ts +25 -0
  339. package/src/assembly/render-target-host.ts +847 -0
  340. package/src/assets/asset-decoders.ts +1 -1
  341. package/src/assets/render-asset-port.ts +7 -1
  342. package/src/capture/cube-views.ts +69 -0
  343. package/src/capture/scheduler.ts +265 -0
  344. package/src/components/__tests__/schema-contract.unit.test.ts +12 -0
  345. package/src/components/__tests__/schema-parity.unit.test.ts +9 -0
  346. package/src/components/camera.ts +4 -0
  347. package/src/components/cube-camera.ts +52 -0
  348. package/src/components/directional-light.ts +22 -2
  349. package/src/components/directional-shadow-filter.ts +57 -0
  350. package/src/components/index.ts +2 -0
  351. package/src/components/light-helpers.ts +85 -7
  352. package/src/components/reflection-probe.ts +49 -0
  353. package/src/components/spot-light.ts +22 -3
  354. package/src/device/gpu-residency.ts +168 -58
  355. package/src/environment/frame.ts +6 -24
  356. package/src/environment/lifecycle.ts +12 -0
  357. package/src/errors/render.ts +405 -143
  358. package/src/extract/directional-shadow-projection.ts +61 -0
  359. package/src/extract/environment.ts +57 -0
  360. package/src/extract/visibility.ts +25 -1
  361. package/src/features/fullscreen.ts +3 -0
  362. package/src/features/targets.ts +4 -1
  363. package/src/features/types.ts +10 -0
  364. package/src/fullscreen-post-process-pass.ts +43 -2
  365. package/src/gpu-driven/batch-topology.ts +1 -0
  366. package/src/gpu-driven/production-raster.ts +1 -3
  367. package/src/gpu-scene.ts +8 -1
  368. package/src/gpu-usage.ts +2 -0
  369. package/src/hdrp-buffers.ts +165 -59
  370. package/src/ibl/IblPipelineCache.ts +11 -0
  371. package/src/ibl/kernel-cache.ts +88 -0
  372. package/src/index.ts +186 -1
  373. package/src/inspection-types.ts +56 -0
  374. package/src/instances-derived-bounds.ts +171 -0
  375. package/src/light-buffer-layout.ts +74 -26
  376. package/src/mesh-material-bindings.ts +132 -0
  377. package/src/pbr-pipeline.ts +139 -27
  378. package/src/pipeline/standard-forward-lane.ts +65 -15
  379. package/src/pipeline/standard-lighting/errors.ts +6 -0
  380. package/src/pipeline/standard-lighting/graph.ts +157 -0
  381. package/src/pipeline/standard-lighting/inspection.ts +88 -0
  382. package/src/pipeline/standard-lighting/layout.ts +31 -0
  383. package/src/pipeline/standard-lighting/light-frame.ts +34 -0
  384. package/src/pipeline/standard-lighting/membership.ts +43 -0
  385. package/src/pipeline/standard-lighting/prepare.ts +37 -0
  386. package/src/pipeline/standard-lighting/topology.ts +120 -0
  387. package/src/pipeline/standard-lighting/transport.ts +77 -0
  388. package/src/pipeline/standard-pipeline.ts +141 -192
  389. package/src/pipeline/standard-post.ts +26 -7
  390. package/src/pipeline/standard-profile.ts +23 -30
  391. package/src/pipeline-spec.ts +84 -21
  392. package/src/point-shadow-inspection.ts +55 -0
  393. package/src/record/__tests__/temporal-payload.unit.test.ts +42 -1
  394. package/src/record/__tests__/transparent-group2-selection.unit.test.ts +73 -0
  395. package/src/record/__tests__/view-ubo-layout.unit.test.ts +40 -0
  396. package/src/record/frame-lighting.ts +470 -516
  397. package/src/record/frame-snapshot.ts +76 -28
  398. package/src/record/frame-targets.ts +23 -2
  399. package/src/record/frame.ts +1169 -79
  400. package/src/record/gpu-pass-timing/contract.ts +180 -0
  401. package/src/record/gpu-pass-timing/errors.ts +34 -0
  402. package/src/record/gpu-pass-timing/index.ts +37 -0
  403. package/src/record/gpu-pass-timing/parser.ts +97 -0
  404. package/src/record/gpu-pass-timing/session.ts +938 -0
  405. package/src/record/gpu-timing.ts +233 -0
  406. package/src/record/main-pass-geometry.ts +120 -80
  407. package/src/record/main-pass-material.ts +101 -11
  408. package/src/record/main-pass-sprite-draws.ts +54 -25
  409. package/src/record/main-pass.ts +71 -15
  410. package/src/record/render-context.ts +77 -0
  411. package/src/record/shadow-pass.ts +104 -66
  412. package/src/record/skybox-post-pass.ts +20 -4
  413. package/src/record/typed-frame-graph.ts +1016 -32
  414. package/src/record/view-ubo.ts +63 -28
  415. package/src/reflection/filter.ts +81 -0
  416. package/src/reflection/gpu-table.ts +44 -0
  417. package/src/reflection/projection.ts +281 -0
  418. package/src/render-contract.ts +147 -4
  419. package/src/render-data.ts +50 -22
  420. package/src/render-graph-primitives.ts +9 -4
  421. package/src/render-pipeline.ts +161 -2
  422. package/src/render-system-extract.ts +886 -103
  423. package/src/render-system.ts +1174 -66
  424. package/src/renderer-inspect.ts +66 -0
  425. package/src/renderer.ts +222 -0
  426. package/src/scene/render-scene.ts +117 -9
  427. package/src/shadow-atlas.ts +5 -5
  428. package/src/ssao-buffers.ts +9 -2
  429. package/src/targets/contracts.ts +182 -0
  430. package/src/targets/graph-projection.ts +169 -0
  431. package/src/targets/material-source.ts +143 -0
  432. package/src/targets/owner.ts +214 -0
  433. package/src/targets/physical.ts +18 -0
  434. package/src/targets/readback.ts +153 -0
  435. package/src/temporal/frame.ts +128 -0
  436. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +61 -23
  437. package/src/typed-render-graph-primitives.ts +34 -2
  438. package/src/typed-shadow-atlas.ts +17 -0
  439. package/src/typed-shadow-passes.ts +47 -40
  440. package/src/volume/capability.ts +173 -0
  441. package/src/volume/component.ts +110 -0
  442. package/src/volume/extract.ts +21 -0
  443. package/src/volume/inspection.ts +78 -0
  444. package/src/volume/passes.ts +686 -0
  445. package/src/volume/recovery.ts +86 -0
  446. package/src/volume/resources.ts +236 -0
  447. package/src/volume/temporal.ts +89 -0
  448. package/dist/chunk-IPXCABEP.mjs.map +0 -1
  449. package/dist/chunk-LOSO4CTV.mjs.map +0 -1
  450. package/dist/chunk-RJRUS55L.mjs.map +0 -1
  451. package/dist/chunk-YTQ5C7YQ.mjs +0 -584
  452. package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
@@ -142,6 +142,7 @@ import type {
142
142
  ParamSchemaEntry,
143
143
  PrimitiveTopology,
144
144
  SkeletonAsset,
145
+ TextureAsset,
145
146
  } from '@forgeax/engine-types';
146
147
  import {
147
148
  ASSET_ERROR_HINTS,
@@ -153,6 +154,12 @@ import {
153
154
  } from '@forgeax/engine-types';
154
155
  import {
155
156
  Atmosphere,
157
+ antialiasFromF32,
158
+ bloomEnabledFromF32,
159
+ Camera,
160
+ CubeCamera,
161
+ cameraProjectionFromF32,
162
+ cubeCameraUpdateIntentFromF32,
156
163
  DirectionalLight,
157
164
  Fog,
158
165
  Instances,
@@ -160,33 +167,50 @@ import {
160
167
  Lines,
161
168
  MeshFilter,
162
169
  MeshRenderer,
170
+ MotionBlur,
163
171
  PointLight,
164
172
  PointLightShadow,
165
173
  Points,
166
174
  PostProcessParams,
167
175
  pointShapeFromU32,
176
+ ReflectionProbe,
177
+ reflectionProbeUpdateIntentFromF32,
168
178
  SkyboxBackground,
169
179
  Skylight,
170
180
  SortKey,
171
181
  SpotLight,
172
182
  SpriteInstances,
173
183
  SpriteRegionOverride,
184
+ tonemapFromF32,
174
185
  tonemapToU32,
175
186
  } from './components';
187
+ import {
188
+ type DirectionalShadowQuality,
189
+ directionalShadowQualityFromF32,
190
+ } from './components/directional-shadow-filter';
176
191
  import { GlyphText } from './components/glyph-text';
177
- import { computeInvRangeSquared, degToCos } from './components/light-helpers';
192
+ import {
193
+ computeInvRangeSquared,
194
+ degToCos,
195
+ type LightValidationError,
196
+ validateDirectionalLightData,
197
+ } from './components/light-helpers';
178
198
  import { selectEnvironment } from './environment/frame';
179
199
  import {
180
200
  MaterialSkinAttrMissingError,
181
201
  ShadowInvalidConfigError,
182
202
  SkinMaterialMismatchError,
183
203
  } from './errors/render';
184
- import { extractCameraSnapshots } from './extract/camera';
204
+ import {
205
+ type DirectionalShadowCascadeFit,
206
+ projectDirectionalShadow,
207
+ } from './extract/directional-shadow-projection';
185
208
  import type {
186
209
  EnvironmentCandidate,
187
210
  EnvironmentFrame,
188
211
  FogCandidate,
189
212
  FogFrame,
213
+ FogSelectionFailure,
190
214
  } from './extract/environment';
191
215
  import { resolveVisibility, type VisibilitySnapshot } from './extract/visibility';
192
216
  import type {
@@ -194,6 +218,7 @@ import type {
194
218
  RenderFeatureWorldVisibilitySnapshot,
195
219
  } from './features/types';
196
220
  import { ensureGlyphMeshMaterialSlots } from './glyph-text-layout-system';
221
+ import { deriveInstancesUnionBounds } from './instances-derived-bounds';
197
222
  import {
198
223
  type MeshMaterialBindingDiagnostic,
199
224
  type MeshMaterialBindingSource,
@@ -202,13 +227,29 @@ import {
202
227
  import { isStandardPbrMaterialShader } from './pbr-pipeline';
203
228
  import { expandPointsLinesBounds } from './points-lines/bounds';
204
229
  import type { PointsLinesRetainedSnapshot, PointsLinesStyle } from './points-lines/snapshot';
205
- 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';
206
238
  import { selectPasses } from './systems/pass-selector';
207
239
 
208
- export { extractCameraSnapshots } from './extract/camera';
209
-
210
240
  import type { SkinPaletteAllocator } from './systems/skin-palette-allocator';
211
241
  import type { SkinPaletteSlice } from './systems/skin-palette-types';
242
+ import type { RenderTarget, RenderTargetTextureSource } from './targets/contracts';
243
+ import { resolveRenderTargetMaterialSource } from './targets/material-source';
244
+ import { resolveSelectedVolumetricLight } from './volume/capability';
245
+ import {
246
+ type ValidatedVolumetricFog,
247
+ type VolumeDensityBinding,
248
+ VolumetricFog,
249
+ type VolumetricFogAuthoring,
250
+ } from './volume/component';
251
+ import { extractVolumetricFog } from './volume/extract';
252
+ import type { VolumeProjectorTuple } from './volume/temporal';
212
253
 
213
254
  /**
214
255
  * DirectionalLightSnapshot — sun-like infinite light variant of the
@@ -221,6 +262,7 @@ import type { SkinPaletteSlice } from './systems/skin-palette-types';
221
262
  */
222
263
  export interface DirectionalLightSnapshot {
223
264
  readonly kind: 'directional';
265
+ readonly entity?: EntityHandle;
224
266
  readonly direction: Vec3;
225
267
  readonly color: Vec3;
226
268
  readonly intensity: number;
@@ -234,14 +276,16 @@ export interface DirectionalLightSnapshot {
234
276
  */
235
277
  export interface PointLightSnapshot {
236
278
  readonly kind: 'point';
279
+ readonly entity?: EntityHandle | number;
237
280
  readonly position: Vec3;
238
281
  readonly color: Vec3;
239
282
  readonly intensity: number;
240
283
  readonly invRangeSquared: number;
241
284
  /**
242
285
  * feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-8 + M4 / T-M4-4:
243
- * cube_array atlas layer index for this light's shadow map (0..3 for shadow
244
- * 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
245
289
  * plan-strategy §D-2 — record stage / shader skips shadow sampling when the
246
290
  * lane equals the sentinel. Joined with `pointShadow[]` by entity at the
247
291
  * end of extract; HDRP record stage threads `shadowAtlasLayer + shadowNear +
@@ -278,8 +322,9 @@ export interface PointLightSnapshot {
278
322
  *
279
323
  * `mapSize` / `nearPlane` / `farPlane` ride here so record stage can size the
280
324
  * cube atlas faces and feed shader the proj constants without re-querying ECS.
281
- * `shadowAtlasLayer` is assigned by extract in spawn order (0 .. cap-1) so the
282
- * 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
283
328
  * pointPadW (T-M1-8 will rename to shadowAtlasLayer).
284
329
  */
285
330
  export interface PointShadowSnapshot {
@@ -292,9 +337,9 @@ export interface PointShadowSnapshot {
292
337
  readonly nearPlane: number;
293
338
  readonly farPlane: number;
294
339
  /**
295
- * Atlas layer assigned by extract in spawn order (0..cap-1, where cap=4
296
- * matches PointLightShadow cardinality). Sentinel -1 is reserved by the
297
- * 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).
298
343
  */
299
344
  readonly shadowAtlasLayer: number;
300
345
  /**
@@ -317,10 +362,15 @@ export interface PointShadowSnapshot {
317
362
  * is false, dir degenerates, or the light is clipped). shadowAtlasTile
318
363
  * (i32 sentinel -1) is the allocated tile index 0..3 or -1 for unassigned
319
364
  * (plan-strategy D-4). mapSize / nearPlane / farPlane are the source
320
- * component shadow parameters carried through to the record stage.
365
+ * component shadow parameters carried through to the record stage. The
366
+ * author-facing `shadowIntensity` remains in [0,1] and is copied unchanged;
367
+ * shader consumers apply it only to shadow visibility, never to candela
368
+ * radiance.
321
369
  */
322
370
  export interface SpotLightSnapshot {
323
371
  readonly kind: 'spot';
372
+ /** Entity identity is branded in extracted frames; numeric fixtures remain compatible. */
373
+ readonly entity?: EntityHandle | number;
324
374
  readonly position: Vec3;
325
375
  readonly direction: Vec3;
326
376
  readonly color: Vec3;
@@ -335,6 +385,17 @@ export interface SpotLightSnapshot {
335
385
  readonly nearPlane: number;
336
386
  readonly farPlane: number;
337
387
  readonly shadowAtlasTile: number;
388
+ /** Fraction of sampled shadow visibility to retain; 1 = fully opaque shadow. */
389
+ readonly shadowIntensity?: number;
390
+ readonly depthBias?: number;
391
+ readonly normalBias?: number;
392
+ readonly pcfKernelSize?: number;
393
+ /** Authoritative TextureAsset projector resolved from the SpotLight handle. */
394
+ readonly projectorHandle?: Handle<'TextureAsset', 'shared'>;
395
+ readonly projectorAsset?: TextureAsset;
396
+ readonly projectorGuid?: string;
397
+ readonly projectorGeneration?: number;
398
+ readonly projectorRevision?: number;
338
399
  }
339
400
 
340
401
  /**
@@ -376,6 +437,7 @@ export interface ExtractedLights {
376
437
  * unused slots are 0.0f. Undefined when castShadow=false or no directional
377
438
  * light.
378
439
  */
440
+ /** Four vec4 lanes: split, worldUnitsPerTexel, lightDepthWorldSpan, reserved. */
379
441
  readonly splitPlanes: Float32Array | undefined;
380
442
  /**
381
443
  * feat-20260613-csm-cascaded-shadow-maps M2 / w9: effective cascade count
@@ -411,13 +473,10 @@ export interface ExtractedLights {
411
473
  * undefined otherwise.
412
474
  */
413
475
  readonly normalBias: number | undefined;
414
- /**
415
- * feat-20260621-merge-directionallightshadow-into-directionallight M2:
416
- * pcfKernelSize from the merged DirectionalLight (PCF kernel width, odd>=1).
417
- * Populated when castShadow=true on the first-hit directional light;
418
- * undefined otherwise.
419
- */
420
- 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;
421
480
  /**
422
481
  * feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7:
423
482
  * shadow-casting point lights (PointLight + PointLightShadow + Transform
@@ -505,6 +564,37 @@ export interface SkyboxSnapshot {
505
564
  readonly entityHandle: number;
506
565
  }
507
566
 
567
+ /**
568
+ * Resource-owner projection for the single ECS VolumetricFog singleton.
569
+ *
570
+ * The renderer carries the validated authoring facts and the source POD all
571
+ * the way to the record stage. No profile flag or source/catalog byte is
572
+ * allowed to enable the graph: `status: 'available'` is produced only after
573
+ * the ECS component, its shared TextureAsset, and the volume validator all
574
+ * agree on one linear 3D density owner.
575
+ */
576
+ export interface ExtractedVolumetricFog {
577
+ readonly status: 'off' | 'available' | 'degraded';
578
+ readonly fog?: ValidatedVolumetricFog;
579
+ readonly densityHandle?: Handle<'TextureAsset', 'shared'>;
580
+ readonly densityAsset?: TextureAsset;
581
+ readonly guid?: string;
582
+ readonly generation?: number;
583
+ readonly digest?: string;
584
+ readonly worldId?: number;
585
+ /** Required same-World light selected by the authoring component. */
586
+ readonly lightEntity?: EntityHandle;
587
+ readonly lightKind?: 'directional' | 'point' | 'spot';
588
+ /** Optional Point+Spot pair identities consumed by the volume integrator. */
589
+ readonly pointLightEntity?: EntityHandle;
590
+ readonly spotLightEntity?: EntityHandle;
591
+ /** Accepted projector tuple shared by surface and volume when authored. */
592
+ readonly projector?: VolumeProjectorTuple;
593
+ /** The resolved projector payload shared with the surface light snapshot. */
594
+ readonly projectorHandle?: Handle<'TextureAsset', 'shared'>;
595
+ readonly projectorAsset?: TextureAsset;
596
+ }
597
+
508
598
  export interface RenderableSnapshot {
509
599
  readonly assetHandle: number;
510
600
  readonly transform: TransformSnapshot;
@@ -792,6 +882,8 @@ export interface MaterialSnapshot {
792
882
  * them in a lightmap injection region.
793
883
  */
794
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;
795
887
  /**
796
888
  * User-region texture field names whose paramValue resolved to a VideoAsset
797
889
  * (kind `'video'`) rather than a static TextureAsset
@@ -1001,11 +1093,19 @@ function appendMaterialDispatchEntries(
1001
1093
 
1002
1094
  export interface ExtractedFrame {
1003
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[];
1004
1102
  readonly lights: ExtractedLights;
1005
1103
  /** Resource-owner environment facts selected once at the extract boundary. */
1006
1104
  readonly environment: EnvironmentFrame | undefined;
1007
1105
  /** False when the resource-owner world failed environment/fog selection. */
1008
1106
  readonly environmentReady: boolean;
1107
+ /** One resource-owner volume projection; absent means no authored fog. */
1108
+ readonly volumetricFog?: ExtractedVolumetricFog;
1009
1109
  readonly renderables: RenderableSnapshot[];
1010
1110
  /**
1011
1111
  * Single dispatch list sorted by queue value (ascending, stable sort).
@@ -1019,6 +1119,8 @@ export interface ExtractedFrame {
1019
1119
  readonly skyboxCount: number;
1020
1120
  /** Resource-owner Fog facts selected once at the frame boundary. */
1021
1121
  readonly fog: FogFrame | undefined;
1122
+ /** Last invalid Fog update retained alongside the current valid/LKG frame. */
1123
+ readonly fogFailure?: FogSelectionFailure;
1022
1124
  /**
1023
1125
  * feat-20260528-frustum-culling M3 / w11: frustum culling statistics
1024
1126
  * collected during the extract phase. `total` is the count of entities
@@ -1044,6 +1146,65 @@ export interface ExtractedFrame {
1044
1146
  readonly hiddenEntityReports: readonly RenderFeatureHiddenEntityReport[];
1045
1147
  }
1046
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
+
1047
1208
  /**
1048
1209
  * Internal world surface used by extract for archetype-graph traversal and
1049
1210
  * error routing. The packed `Entity` handle for a row is read directly from
@@ -1086,6 +1247,14 @@ function createWorldInternalView(world: World): WorldInternalView {
1086
1247
  };
1087
1248
  }
1088
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
+
1089
1258
  /**
1090
1259
  * The user-region texture fields the built-in standard-PBR material declares.
1091
1260
  * Used as the fallback texture-field set when the shader id is not registered
@@ -1366,6 +1535,24 @@ function collectMaterialTextureCoordinates(
1366
1535
  return out;
1367
1536
  }
1368
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
+
1369
1556
  function collectMaterialTextureSamplers(
1370
1557
  values: Readonly<Record<string, unknown>>,
1371
1558
  resolveSampler: (value: unknown) => Handle<'SamplerAsset', 'shared'> | undefined,
@@ -1456,9 +1643,14 @@ function materialParametersToParamSchema(
1456
1643
  return parameters.flatMap((parameter): ParamSchemaEntry[] => {
1457
1644
  // Boolean material values are runtime-only and have no UBO representation.
1458
1645
  if (parameter.type === 'bool') return [];
1459
- if (parameter.type === 'texture') {
1646
+ if (parameter.type === 'texture' || parameter.type === 'texture_cube') {
1460
1647
  if (engineInjectedTextureFields?.has(parameter.name) === true) return [];
1461
- return [{ name: parameter.name, type: 'texture2d' }];
1648
+ return [
1649
+ {
1650
+ name: parameter.name,
1651
+ type: parameter.type === 'texture_cube' ? 'texture_cube' : 'texture2d',
1652
+ },
1653
+ ];
1462
1654
  }
1463
1655
  return [
1464
1656
  {
@@ -1718,6 +1910,7 @@ export function resolveMaterialSnapshot(
1718
1910
  const samplerHandles = collectMaterialTextureSamplers(pv, (value) =>
1719
1911
  resolveTexLike(materialTextureRef(value), 'SamplerAsset'),
1720
1912
  );
1913
+ const textureSources = collectMaterialTextureSources(pv, world);
1721
1914
  const emissiveTextureHandle = resolveTexLike(
1722
1915
  materialTextureRef(pv.emissiveTexture),
1723
1916
  'TextureAsset',
@@ -1753,6 +1946,7 @@ export function resolveMaterialSnapshot(
1753
1946
  ...(materialParamSchema.length > 0 && { materialParamSchema }),
1754
1947
  ...(textureCoordinates.size > 0 && { textureCoordinates }),
1755
1948
  ...(textureHandles.size > 0 && { textureHandles }),
1949
+ ...(textureSources.size > 0 && { textureSources }),
1756
1950
  ...(videoTextureFields.size > 0 && { videoTextureFields }),
1757
1951
  ...(samplerHandles.size > 0 && { samplerHandles }),
1758
1952
  ...(baseColorTextureHandle !== undefined && { baseColorTexture: baseColorTextureHandle }),
@@ -2056,6 +2250,9 @@ export interface DirectionalCsmConfig {
2056
2250
  readonly cascadeBlend: number;
2057
2251
  readonly mapSize: number;
2058
2252
  readonly shadowDistance: number;
2253
+ readonly shadowFilter: number;
2254
+ readonly shadowAngularRadius: number;
2255
+ readonly maxPenumbraTexels: number;
2059
2256
  }
2060
2257
 
2061
2258
  /**
@@ -2070,6 +2267,7 @@ export interface DirectionalCsmResult {
2070
2267
  readonly cascadeCount: number;
2071
2268
  readonly cascadeBlend: number;
2072
2269
  readonly shadowMapSize: number;
2270
+ readonly directionalShadowQuality: DirectionalShadowQuality;
2073
2271
  }
2074
2272
 
2075
2273
  /**
@@ -2102,9 +2300,6 @@ export function computeDirectionalCsm(
2102
2300
 
2103
2301
  // PSSM split planes: [camera near, shadowDistance], not the camera far.
2104
2302
  const splits = pssmSplit(sNear, sFar, cascadeCount, config.splitLambda);
2105
- const paddedSplits = new Float32Array(4);
2106
- for (let i = 0; i < splits.length; i++) paddedSplits[i] = splits[i] ?? 0;
2107
-
2108
2303
  // Light view matrix: camera at origin, looking along the light direction.
2109
2304
  const lightDirN = vec3.normalize(vec3.create(), direction);
2110
2305
  const lightTarget = vec3.create(lightDirN[0] ?? 0, lightDirN[1] ?? 0, lightDirN[2] ?? 0);
@@ -2135,6 +2330,7 @@ export function computeDirectionalCsm(
2135
2330
  mat4.multiply(cameraVP, camProj, camView);
2136
2331
 
2137
2332
  const resultLightViewProjs: Float32Array[] = [];
2333
+ const cascadeFits: DirectionalShadowCascadeFit[] = [];
2138
2334
 
2139
2335
  // bug-20260619 RC-2 (AC-05): toward-light Z reach. Extend the near (toward-
2140
2336
  // light) bound of EVERY cascade to the toward-light extreme of the WHOLE
@@ -2198,16 +2394,34 @@ export function computeDirectionalCsm(
2198
2394
  // lightViewProj = orthoProj * lightView — pure clip-space [-1,1]. Atlas
2199
2395
  // tile placement is handled by the per-cascade viewport + fragment-side UV
2200
2396
  // math (evalDirectional), so shadow_caster.gl_Position stays clip-space.
2201
- void cIdx;
2397
+ cascadeFits.push({
2398
+ split: splits[cIdx] ?? sFar,
2399
+ minX,
2400
+ maxX,
2401
+ minY,
2402
+ maxY,
2403
+ minZ,
2404
+ maxZ,
2405
+ });
2202
2406
  resultLightViewProjs.push(new Float32Array(mat4.multiply(mat4.create(), orthoProj, lightView)));
2203
2407
  }
2204
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
+
2205
2418
  return {
2206
2419
  lightViewProj: resultLightViewProjs,
2207
- splitPlanes: paddedSplits,
2420
+ splitPlanes: projection.splitPlanes,
2208
2421
  cascadeCount,
2209
2422
  cascadeBlend: config.cascadeBlend,
2210
2423
  shadowMapSize: config.mapSize,
2424
+ directionalShadowQuality: projection.directionalShadowQuality,
2211
2425
  };
2212
2426
  }
2213
2427
 
@@ -2239,6 +2453,161 @@ export interface PreparedExtractContext {
2239
2453
  readonly visibility: VisibilitySnapshot;
2240
2454
  }
2241
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
+
2242
2611
  function tonemapParams(camera: CameraSnapshot): Uint8Array {
2243
2612
  const bytes = new ArrayBuffer(16);
2244
2613
  const floats = new Float32Array(bytes);
@@ -2274,6 +2643,21 @@ export function prepareExtractContext(
2274
2643
  }
2275
2644
 
2276
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
+ }
2277
2661
 
2278
2662
  function selectFogFrame(world: World): FogFrame | undefined {
2279
2663
  // Query compilation is owned once per World; Query refreshes its matched
@@ -2301,13 +2685,197 @@ function selectFogFrame(world: World): FogFrame | undefined {
2301
2685
  suns: [],
2302
2686
  lane: 'direct',
2303
2687
  });
2304
- 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
+ };
2305
2700
  routeWorldError(world, fogSelection.error, {
2306
2701
  systemName: 'RenderSystem.extract (fog-selection)',
2307
2702
  });
2703
+ if (state.lkg !== undefined) return state.lkg;
2308
2704
  throw fogSelection.error;
2309
2705
  }
2310
2706
 
2707
+ function textureDigest(texture: TextureAsset): string {
2708
+ // The render inspection needs a stable identity token, not a second byte
2709
+ // store. FNV-1a is deterministic, synchronous, and deliberately labelled
2710
+ // as a non-cryptographic digest so it cannot be mistaken for Catalog proof.
2711
+ let hash = 2166136261;
2712
+ for (const byte of texture.data) hash = Math.imul(hash ^ byte, 16777619) >>> 0;
2713
+ return `fnv1a:${hash.toString(16).padStart(8, '0')}`;
2714
+ }
2715
+
2716
+ function extractVolumeSnapshot(
2717
+ world: World,
2718
+ assets: AssetRegistry | null | undefined,
2719
+ worldInternal: WorldInternalView,
2720
+ ): ExtractedVolumetricFog | undefined {
2721
+ const query = world.query({ read: [VolumetricFog] }).unwrap();
2722
+ const authorings: VolumetricFogAuthoring[] = [];
2723
+ let selectedLight:
2724
+ | {
2725
+ readonly entity: EntityHandle;
2726
+ readonly kind: 'directional' | 'point' | 'spot';
2727
+ readonly pointLightEntity?: EntityHandle;
2728
+ readonly spotLightEntity?: EntityHandle;
2729
+ }
2730
+ | undefined;
2731
+ let first:
2732
+ | {
2733
+ readonly densityHandle: Handle<'TextureAsset', 'shared'>;
2734
+ readonly densityAsset: TextureAsset;
2735
+ readonly guid: string;
2736
+ }
2737
+ | undefined;
2738
+
2739
+ for (const row of query) {
2740
+ const source = row.get(VolumetricFog);
2741
+ if (source.light === null) continue;
2742
+ const selected = resolveSelectedVolumetricLight(world, source.light);
2743
+ if (selected.status === 'available') {
2744
+ const pairSpot = source.spotLight === null ? undefined : source.spotLight;
2745
+ selectedLight ??= {
2746
+ entity: selected.entity,
2747
+ kind: selected.kind,
2748
+ ...(selected.kind === 'point' ? { pointLightEntity: selected.entity } : {}),
2749
+ ...(pairSpot === undefined ? {} : { spotLightEntity: pairSpot }),
2750
+ };
2751
+ }
2752
+ const densityHandle = toShared<'TextureAsset'>(Math.round(Number(source.density)));
2753
+ const resolved = resolveAssetHandle<TextureAsset>(world, densityHandle);
2754
+ if (!resolved.ok || resolved.value.kind !== 'texture') {
2755
+ worldInternal._routeError(
2756
+ resolved.ok
2757
+ ? new RhiError({
2758
+ code: 'asset-not-registered',
2759
+ expected: 'VolumetricFog.density resolves to a TextureAsset',
2760
+ hint: 'load a TextureAsset before assigning it to VolumetricFog.density',
2761
+ detail: { assetHandle: Number(densityHandle) },
2762
+ })
2763
+ : resolved.error,
2764
+ { severity: Severity.Error, systemName: 'RenderSystem.extract (volumetric-density)' },
2765
+ );
2766
+ return { status: 'degraded' };
2767
+ }
2768
+ const densityAsset = resolved.value;
2769
+ const guid = assets?.guidOf(densityAsset) ?? `handle:${Number(densityHandle)}`;
2770
+ const shape = densityAsset.shape;
2771
+ const density: VolumeDensityBinding =
2772
+ shape.viewDimension === '3d'
2773
+ ? {
2774
+ guid,
2775
+ generation: 1,
2776
+ shape: {
2777
+ viewDimension: '3d',
2778
+ extent: {
2779
+ width: shape.extent.width,
2780
+ height: shape.extent.height,
2781
+ depth: shape.extent.depth,
2782
+ },
2783
+ },
2784
+ format: densityAsset.format,
2785
+ colorSpace: densityAsset.colorSpace,
2786
+ }
2787
+ : {
2788
+ guid,
2789
+ generation: 1,
2790
+ shape: {
2791
+ viewDimension: shape.viewDimension,
2792
+ extent:
2793
+ shape.viewDimension === '2d-array'
2794
+ ? {
2795
+ width: shape.extent.width,
2796
+ height: shape.extent.height,
2797
+ layers: shape.extent.layers,
2798
+ }
2799
+ : { width: shape.extent.width, height: shape.extent.height },
2800
+ },
2801
+ format: densityAsset.format,
2802
+ colorSpace: densityAsset.colorSpace,
2803
+ };
2804
+ const authoring: VolumetricFogAuthoring = {
2805
+ light: source.light,
2806
+ density,
2807
+ bounds: {
2808
+ min: [source.boundsMin[0] ?? 0, source.boundsMin[1] ?? 0, source.boundsMin[2] ?? 0],
2809
+ max: [source.boundsMax[0] ?? 0, source.boundsMax[1] ?? 0, source.boundsMax[2] ?? 0],
2810
+ },
2811
+ extinction: [source.extinction[0] ?? 0, source.extinction[1] ?? 0, source.extinction[2] ?? 0],
2812
+ albedo: [source.albedo[0] ?? 0, source.albedo[1] ?? 0, source.albedo[2] ?? 0],
2813
+ emission: [source.emission[0] ?? 0, source.emission[1] ?? 0, source.emission[2] ?? 0],
2814
+ anisotropy: source.anisotropy,
2815
+ maxDistance: source.maxDistance,
2816
+ };
2817
+ authorings.push(authoring);
2818
+ first ??= { densityHandle, densityAsset, guid };
2819
+ }
2820
+
2821
+ if (authorings.length === 0) return undefined;
2822
+ const extracted = extractVolumetricFog(authorings);
2823
+ if (!extracted.ok) {
2824
+ worldInternal._routeError(extracted.error, {
2825
+ severity: Severity.Error,
2826
+ systemName: 'RenderSystem.extract (volumetric-fog)',
2827
+ });
2828
+ return {
2829
+ status: 'degraded',
2830
+ ...(first === undefined
2831
+ ? {}
2832
+ : {
2833
+ densityHandle: first.densityHandle,
2834
+ densityAsset: first.densityAsset,
2835
+ guid: first.guid,
2836
+ generation: 1,
2837
+ digest: textureDigest(first.densityAsset),
2838
+ ...(selectedLight === undefined
2839
+ ? {}
2840
+ : {
2841
+ lightEntity: selectedLight.entity,
2842
+ lightKind: selectedLight.kind,
2843
+ ...(selectedLight.pointLightEntity === undefined
2844
+ ? {}
2845
+ : { pointLightEntity: selectedLight.pointLightEntity }),
2846
+ ...(selectedLight.spotLightEntity === undefined
2847
+ ? {}
2848
+ : { spotLightEntity: selectedLight.spotLightEntity }),
2849
+ }),
2850
+ }),
2851
+ };
2852
+ }
2853
+ if (extracted.value.status === 'off' || first === undefined) return { status: 'off' };
2854
+ const selectedLightFields =
2855
+ selectedLight === undefined
2856
+ ? {}
2857
+ : {
2858
+ lightEntity: selectedLight.entity,
2859
+ lightKind: selectedLight.kind,
2860
+ ...(selectedLight.pointLightEntity === undefined
2861
+ ? {}
2862
+ : { pointLightEntity: selectedLight.pointLightEntity }),
2863
+ ...(selectedLight.spotLightEntity === undefined
2864
+ ? {}
2865
+ : { spotLightEntity: selectedLight.spotLightEntity }),
2866
+ };
2867
+ return {
2868
+ status: 'available',
2869
+ fog: extracted.value.fog,
2870
+ densityHandle: first.densityHandle,
2871
+ densityAsset: first.densityAsset,
2872
+ guid: first.guid,
2873
+ generation: 1,
2874
+ digest: textureDigest(first.densityAsset),
2875
+ ...selectedLightFields,
2876
+ };
2877
+ }
2878
+
2311
2879
  export function extractFrames(
2312
2880
  worlds: readonly World[],
2313
2881
  owner: number | ExtractFramesOwner,
@@ -2464,7 +3032,8 @@ export function extractFrames(
2464
3032
  let shadowMapSize: number | undefined;
2465
3033
  let depthBias: number | undefined;
2466
3034
  let normalBias: number | undefined;
2467
- let pcfKernelSize: number | undefined;
3035
+ let directionalShadowQuality: DirectionalShadowQuality | undefined;
3036
+ let directionalShadowError: LightValidationError | undefined;
2468
3037
  const pointShadow: PointShadowSnapshot[] = [];
2469
3038
  // bug-20260710-editor-cross-world-shadow: carry the raw CSM config +
2470
3039
  // direction of the first-hit directional so the merge layer can recompute
@@ -2486,7 +3055,8 @@ export function extractFrames(
2486
3055
  shadowMapSize = f.lights.shadowMapSize;
2487
3056
  depthBias = f.lights.depthBias;
2488
3057
  normalBias = f.lights.normalBias;
2489
- pcfKernelSize = f.lights.pcfKernelSize;
3058
+ directionalShadowQuality = f.lights.directionalShadowQuality;
3059
+ directionalShadowError = f.lights.directionalShadowError;
2490
3060
  directionalCsmConfig = f.lights.directionalCsmConfig;
2491
3061
  directionalCsmDirection = f.lights.directionalCsmDirection;
2492
3062
  }
@@ -2505,6 +3075,9 @@ export function extractFrames(
2505
3075
  if (succeededIndices[fi] === resourceOwner) resourceOwnerFrame = succeededFrames[fi];
2506
3076
  }
2507
3077
  const cameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cameras] : [];
3078
+ const auxiliaryCameras =
3079
+ cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.auxiliaryCameras] : [];
3080
+ const cubeCameras = cameraOwnerFrame !== undefined ? [...cameraOwnerFrame.cubeCameras] : [];
2508
3081
 
2509
3082
  // bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
2510
3083
  // against the surfaced camera. In a single-world app the per-world extract
@@ -2543,6 +3116,7 @@ export function extractFrames(
2543
3116
  cascadeCount = csm.cascadeCount;
2544
3117
  cascadeBlend = csm.cascadeBlend;
2545
3118
  shadowMapSize = csm.shadowMapSize;
3119
+ directionalShadowQuality = csm.directionalShadowQuality;
2546
3120
  }
2547
3121
  // csm === null (no surfaced camera at all): keep the per-world carry —
2548
3122
  // there is no better data, and a cameraless frame renders nothing anyway.
@@ -2560,16 +3134,19 @@ export function extractFrames(
2560
3134
  shadowMapSize,
2561
3135
  depthBias,
2562
3136
  normalBias,
2563
- pcfKernelSize,
3137
+ directionalShadowQuality,
3138
+ directionalShadowError,
2564
3139
  pointShadow,
2565
3140
  directionalCsmConfig,
2566
3141
  directionalCsmDirection,
2567
3142
  };
3143
+ const volumetricFog = resourceOwnerFrame?.volumetricFog;
2568
3144
  const skylight = resourceOwnerFrame?.skylight;
2569
3145
  const skylightCount = resourceOwnerFrame?.skylightCount ?? 0;
2570
3146
  const skybox = resourceOwnerFrame?.skybox;
2571
3147
  const skyboxCount = resourceOwnerFrame?.skyboxCount ?? 0;
2572
3148
  const fog = resourceOwnerFrame?.fog;
3149
+ const fogFailure = resourceOwnerFrame?.fogFailure;
2573
3150
  const environment = resourceOwnerFrame?.environment;
2574
3151
  // User-authored PostProcessParams entities are singleton scene resources, so
2575
3152
  // they come from the resourceOwner world (holistic snapshot selection).
@@ -2609,10 +3186,20 @@ export function extractFrames(
2609
3186
  const visibilityStats = {
2610
3187
  explicitlyHidden: succeededFrames.reduce((s, f) => s + f.visibilityStats.explicitlyHidden, 0),
2611
3188
  };
3189
+ const reflectionProbes = succeededFrames.flatMap((f, index) => {
3190
+ const worldId = succeededIndices[index] ?? 0;
3191
+ return (f.reflectionProbes ?? []).map((probe) => ({ ...probe, worldId }));
3192
+ });
2612
3193
 
2613
3194
  return {
2614
3195
  cameras,
3196
+ auxiliaryCameras,
3197
+ cubeCameras,
3198
+ reflectionProbes,
2615
3199
  lights,
3200
+ ...(volumetricFog === undefined
3201
+ ? {}
3202
+ : { volumetricFog: { ...volumetricFog, worldId: resourceOwner } }),
2616
3203
  renderables,
2617
3204
  dispatch: dispatchEntries,
2618
3205
  skylight,
@@ -2620,6 +3207,7 @@ export function extractFrames(
2620
3207
  skybox,
2621
3208
  skyboxCount,
2622
3209
  fog,
3210
+ ...(fogFailure === undefined ? {} : { fogFailure }),
2623
3211
  environment,
2624
3212
  environmentReady: resourceOwnerFrame !== undefined && environment !== undefined,
2625
3213
  frustumStats,
@@ -2715,6 +3303,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2715
3303
  // established at the frame boundary; record never revisits World state.
2716
3304
  const fog =
2717
3305
  resourceOwnerFog === undefined ? selectFogFrame(world) : (resourceOwnerFog ?? undefined);
3306
+ const fogFailure = fogState(world).failure;
2718
3307
  const visibility = context.visibility.hasAnyHiddenIntent ? context.visibility : undefined;
2719
3308
  const skinPaletteAllocator = pipelineState?.skinPaletteAllocator ?? null;
2720
3309
 
@@ -2732,8 +3321,13 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2732
3321
  // `row.get(X) !== undefined`); K-3 invariant preserved (`_getArrayView`
2733
3322
  // calls survive untouched, only the entity source changes).
2734
3323
  const worldInternal = createWorldInternalView(world);
3324
+ let volumetricFog = extractVolumeSnapshot(world, assets, worldInternal);
2735
3325
 
2736
- 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);
2737
3331
 
2738
3332
  // Three-query union (M2 / w16 / AC-03): directional has no Transform
2739
3333
  // dependency (sun-like infinite-source semantics); point + spot pull
@@ -2754,7 +3348,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2754
3348
  depthBias: number;
2755
3349
  normalBias: number;
2756
3350
  shadowDistance: number;
2757
- pcfKernelSize: number;
3351
+ shadowFilter: number;
3352
+ shadowAngularRadius: number;
3353
+ maxPenumbraTexels: number;
2758
3354
  }
2759
3355
  | undefined;
2760
3356
  for (const row of directionalLightQuery) {
@@ -2763,6 +3359,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2763
3359
  const intensity = l.intensity;
2764
3360
  const snapshot: DirectionalLightSnapshot = {
2765
3361
  kind: 'directional',
3362
+ entity: row.entity,
2766
3363
  direction: vec3.create(l.direction[0] ?? 0, l.direction[1] ?? -1, l.direction[2] ?? 0),
2767
3364
  color: vec3.create(
2768
3365
  (l.color[0] ?? 1) * intensity,
@@ -2783,11 +3380,29 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2783
3380
  depthBias: l.depthBias,
2784
3381
  normalBias: l.normalBias,
2785
3382
  shadowDistance: l.shadowDistance,
2786
- pcfKernelSize: l.pcfKernelSize,
3383
+ shadowFilter: l.shadowFilter,
3384
+ shadowAngularRadius: l.shadowAngularRadius,
3385
+ maxPenumbraTexels: l.maxPenumbraTexels,
2787
3386
  };
2788
3387
  }
2789
3388
  }
2790
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
+
2791
3406
  const pointSnapshots: PointLightSnapshot[] = [];
2792
3407
  // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4: track entity per
2793
3408
  // pointSnapshots index so the post-extract pointShadow join can stamp
@@ -2818,6 +3433,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2818
3433
  worldMat !== undefined ? mat4.getTranslation(vec3.create(), worldMat) : vec3.create(0, 0, 0);
2819
3434
  pointSnapshots.push({
2820
3435
  kind: 'point',
3436
+ entity: entityId,
2821
3437
  position,
2822
3438
  color: vec3.create(
2823
3439
  (p.color[0] ?? 1) * intensity,
@@ -2889,11 +3505,20 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2889
3505
  const sMapSize = s.mapSize;
2890
3506
  const sNearPlane = s.nearPlane;
2891
3507
  const sFarPlane = s.farPlane;
3508
+ const shadowIntensity = s.shadowIntensity;
3509
+ // `shared<T>` columns are u32-backed and zero-filled when the optional
3510
+ // field is absent. Treat only a positive handle as an authored projector;
3511
+ // checking nullish values alone would turn the zero sentinel into a
3512
+ // phantom projector matrix and an avoidable asset-not-found diagnostic.
3513
+ const hasProjector = Number(s.projector) > 0;
2892
3514
 
2893
3515
  let lightViewProj: Float32Array | undefined;
2894
3516
  let shadowAtlasTile = -1;
2895
3517
 
2896
- if (castShadow && hasValidDirection) {
3518
+ // A projector needs the same perspective transform even when its SpotLight
3519
+ // has no shadow atlas tile. The matrix remains a derived light fact; the
3520
+ // shadow tile stays -1 so shadow visibility is still independently gated.
3521
+ if ((castShadow || hasProjector) && hasValidDirection) {
2897
3522
  const target = vec3.create(
2898
3523
  (position[0] ?? 0) + (dirN[0] ?? 0),
2899
3524
  (position[1] ?? 0) + (dirN[1] ?? 0),
@@ -2912,14 +3537,40 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2912
3537
  mat4.multiply(lightViewProj as Mat4, proj, view);
2913
3538
 
2914
3539
  // D-4: allocate tile 0..3; 5th+ = -1 sentinel.
2915
- if (spotTileNext < 4) {
3540
+ if (castShadow && spotTileNext < 4) {
2916
3541
  shadowAtlasTile = spotTileNext;
2917
3542
  spotTileNext += 1;
2918
3543
  }
2919
3544
  }
2920
3545
 
3546
+ let projectorHandle: Handle<'TextureAsset', 'shared'> | undefined;
3547
+ let projectorAsset: TextureAsset | undefined;
3548
+ let projectorGuid: string | undefined;
3549
+ if (hasProjector) {
3550
+ const candidate = toShared<'TextureAsset'>(Math.round(Number(s.projector)));
3551
+ const resolvedProjector = resolveAssetHandle<TextureAsset>(world, candidate);
3552
+ if (!resolvedProjector.ok || resolvedProjector.value.kind !== 'texture') {
3553
+ worldInternal._routeError(
3554
+ resolvedProjector.ok
3555
+ ? new RhiError({
3556
+ code: 'asset-not-registered',
3557
+ expected: 'SpotLight.projector resolves to a TextureAsset',
3558
+ hint: 'load the authored projector TextureAsset before assigning SpotLight.projector',
3559
+ detail: { assetHandle: Number(candidate) },
3560
+ })
3561
+ : resolvedProjector.error,
3562
+ { severity: Severity.Error, systemName: 'RenderSystem.extract (spot-projector)' },
3563
+ );
3564
+ } else {
3565
+ projectorHandle = candidate;
3566
+ projectorAsset = resolvedProjector.value;
3567
+ projectorGuid = assets?.guidOf(projectorAsset) ?? `handle:${Number(candidate)}`;
3568
+ }
3569
+ }
3570
+
2921
3571
  spotSnapshots.push({
2922
3572
  kind: 'spot',
3573
+ entity: row.entity,
2923
3574
  // D-6: position reflects world transform; direction stays sourced
2924
3575
  // from SpotLight.direction (NOT rotated by the parent).
2925
3576
  position,
@@ -2940,9 +3591,59 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2940
3591
  nearPlane: sNearPlane,
2941
3592
  farPlane: sFarPlane,
2942
3593
  shadowAtlasTile,
3594
+ shadowIntensity,
3595
+ depthBias: s.depthBias,
3596
+ normalBias: s.normalBias,
3597
+ pcfKernelSize: s.pcfKernelSize,
3598
+ ...(projectorHandle === undefined ||
3599
+ projectorAsset === undefined ||
3600
+ projectorGuid === undefined
3601
+ ? {}
3602
+ : {
3603
+ projectorHandle,
3604
+ projectorAsset,
3605
+ projectorGuid,
3606
+ projectorGeneration: 1,
3607
+ projectorRevision: 1,
3608
+ }),
2943
3609
  });
2944
3610
  }
2945
3611
 
3612
+ // The SpotLight is the sole projector owner. Once all light snapshots have
3613
+ // been extracted, publish its accepted TextureAsset tuple on the volume
3614
+ // snapshot so surface and volume consume the same identity and revision.
3615
+ let volumeSpot: SpotLightSnapshot | undefined;
3616
+ const extractedFog = volumetricFog;
3617
+ if (extractedFog !== undefined) {
3618
+ if (extractedFog.spotLightEntity !== undefined) {
3619
+ volumeSpot = spotSnapshots.find((light) => light.entity === extractedFog.spotLightEntity);
3620
+ } else if (extractedFog.lightKind === 'spot') {
3621
+ volumeSpot = spotSnapshots.find((light) => light.entity === extractedFog.lightEntity);
3622
+ }
3623
+ }
3624
+ if (
3625
+ volumetricFog !== undefined &&
3626
+ volumetricFog.status === 'available' &&
3627
+ volumeSpot?.projectorAsset !== undefined &&
3628
+ volumeSpot.projectorHandle !== undefined
3629
+ ) {
3630
+ const projectorHandle = volumeSpot.projectorHandle;
3631
+ const projectorAsset = volumeSpot.projectorAsset;
3632
+ volumetricFog = {
3633
+ ...volumetricFog,
3634
+ projector: {
3635
+ guid: volumeSpot.projectorGuid ?? `handle:${Number(volumeSpot.projectorHandle)}`,
3636
+ generation: volumeSpot.projectorGeneration ?? 1,
3637
+ view: '2d',
3638
+ sampler: 'linear-clamp-to-edge',
3639
+ projection: 'spot-projection',
3640
+ revision: volumeSpot.projectorRevision ?? 1,
3641
+ },
3642
+ projectorHandle,
3643
+ projectorAsset,
3644
+ };
3645
+ }
3646
+
2946
3647
  // bug-20260710-editor-cross-world-shadow: CSM matrices are computed by the
2947
3648
  // shared pure {@link computeDirectionalCsm}, called here per-world with THIS
2948
3649
  // world's own `cameras[0]`. In a single-world app the light and camera share
@@ -2957,6 +3658,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2957
3658
  let cascadeCount: number | undefined;
2958
3659
  let cascadeBlend: number | undefined;
2959
3660
  let shadowMapSize: number | undefined;
3661
+ let directionalShadowQuality: DirectionalShadowQuality | undefined;
2960
3662
  let directionalCsmConfig: DirectionalCsmConfig | undefined;
2961
3663
  let directionalCsmDirection: Vec3 | undefined;
2962
3664
 
@@ -2982,7 +3684,11 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2982
3684
  // feat-20260621 M2: CSM computation gated on castShadow from the
2983
3685
  // merged DirectionalLight. castShadow defaults to true (first-hit-wins
2984
3686
  // semantics, D-6 no cardinality cap).
2985
- if (directional !== undefined && firstHitCastShadow !== false) {
3687
+ if (
3688
+ directional !== undefined &&
3689
+ firstHitCastShadow !== false &&
3690
+ directionalShadowError === undefined
3691
+ ) {
2986
3692
  const dirSnapshot = directional;
2987
3693
  const sf = firstHitShadowFields;
2988
3694
  if (sf !== undefined) {
@@ -2992,6 +3698,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2992
3698
  cascadeBlend: sf.cascadeBlend,
2993
3699
  mapSize: sf.mapSize,
2994
3700
  shadowDistance: sf.shadowDistance,
3701
+ shadowFilter: sf.shadowFilter,
3702
+ shadowAngularRadius: sf.shadowAngularRadius,
3703
+ maxPenumbraTexels: sf.maxPenumbraTexels,
2995
3704
  };
2996
3705
  directionalCsmDirection = dirSnapshot.direction;
2997
3706
  const csm = computeDirectionalCsm(dirSnapshot.direction, directionalCsmConfig, cameraData);
@@ -3006,13 +3715,19 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3006
3715
  if (csm !== null) {
3007
3716
  lightViewProj = csm.lightViewProj;
3008
3717
  splitPlanes = csm.splitPlanes;
3718
+ directionalShadowQuality = csm.directionalShadowQuality;
3009
3719
  } else {
3010
3720
  const sNear = cameraData?.near ?? 0.1;
3011
3721
  const splits = pssmSplit(sNear, sf.shadowDistance, cascadeCount, sf.splitLambda);
3012
- const padded = new Float32Array(4);
3013
- 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;
3014
3724
  splitPlanes = padded;
3015
3725
  }
3726
+ directionalShadowQuality = directionalShadowQualityFromF32(
3727
+ sf.shadowFilter,
3728
+ sf.shadowAngularRadius,
3729
+ sf.maxPenumbraTexels,
3730
+ );
3016
3731
  }
3017
3732
  }
3018
3733
 
@@ -3021,7 +3736,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3021
3736
  // the View UBO tail keeps a stable layout regardless of the runtime
3022
3737
  // cascadeCount. Host-side correctness invariant: only the first
3023
3738
  // cascadeCount slots are ever read by the WGSL kernel.
3024
- const paddedSplitPlanes = new Float32Array(4);
3739
+ const paddedSplitPlanes = new Float32Array(16);
3025
3740
  if (splitPlanes !== undefined) {
3026
3741
  for (let i = 0; i < splitPlanes.length; i++) {
3027
3742
  paddedSplitPlanes[i] = splitPlanes[i] ?? 0;
@@ -3031,9 +3746,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3031
3746
  // feat-20260612-point-light-shadows-urp-hdrp M1 / T-M1-7 (plan-strategy §D-3,
3032
3747
  // requirements §5.3): query (PointLight + PointLightShadow + Transform)
3033
3748
  // archetype join. For each shadow caster, build 6 face VP matrices and pack
3034
- // into Float32Array(96). Atlas layer assigned in spawn order (0..3); the
3035
- // sentinel -1 is shader-side and applies to non-shadow PointLights only.
3036
- // 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.
3037
3752
  const pointShadowSnapshots: PointShadowSnapshot[] = [];
3038
3753
  {
3039
3754
  const pointShadowQuery = world
@@ -3061,7 +3776,13 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3061
3776
  const mapSize = ps.mapSize;
3062
3777
  const nearPlane = ps.nearPlane;
3063
3778
  const farPlane = ps.farPlane;
3064
- 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;
3065
3786
 
3066
3787
  const matrices = buildPointShadowMatrices(lightPos, nearPlane, farPlane);
3067
3788
  const packed = new Float32Array(96);
@@ -3119,7 +3840,8 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
3119
3840
  shadowMapSize,
3120
3841
  depthBias: firstHitCastShadow !== false ? firstHitShadowFields?.depthBias : undefined,
3121
3842
  normalBias: firstHitCastShadow !== false ? firstHitShadowFields?.normalBias : undefined,
3122
- pcfKernelSize: firstHitCastShadow !== false ? firstHitShadowFields?.pcfKernelSize : undefined,
3843
+ directionalShadowQuality,
3844
+ directionalShadowError,
3123
3845
  pointShadow: pointShadowSnapshots,
3124
3846
  // bug-20260710-editor-cross-world-shadow: raw CSM config + light direction
3125
3847
  // so the merge layer can recompute matrices against the surfaced camera.
@@ -4011,6 +4733,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4011
4733
  pv.occlusionTexture,
4012
4734
  );
4013
4735
  const textureCoordinates = collectMaterialTextureCoordinates(pv);
4736
+ const textureSources = collectMaterialTextureSources(pv, world);
4014
4737
  const emissivePv = pv.emissive as readonly number[] | undefined;
4015
4738
  // feat-20260625 M2 / w6: first-pass transparency flag folds into
4016
4739
  // MaterialSnapshot.transparent so the record stage can drive the
@@ -4110,6 +4833,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4110
4833
  ...(textureCoordinates.size > 0 && { textureCoordinates }),
4111
4834
  ...(samplerHandles.size > 0 && { samplerHandles }),
4112
4835
  ...(textureHandles.size > 0 && { textureHandles }),
4836
+ ...(textureSources.size > 0 && { textureSources }),
4113
4837
  ...(videoTextureFields.size > 0 && { videoTextureFields }),
4114
4838
  ...(baseColorTextureHandle !== undefined && {
4115
4839
  baseColorTexture: baseColorTextureHandle,
@@ -4328,6 +5052,15 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4328
5052
  }
4329
5053
  }
4330
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
+
4331
5064
  // feat-20260601 D-3: read the resolved world mat4 (propagateTransforms
4332
5065
  // output) straight from the Transform.world column array view. The
4333
5066
  // record stage copies these 16 floats into the mesh SSBO with zero
@@ -4516,6 +5249,44 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4516
5249
  projection: identityProjection(),
4517
5250
  } satisfies PointsLinesRetainedSnapshot);
4518
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
+
4519
5290
  let nonIndexedFirst = 0;
4520
5291
  const gpuDrivenDraws = gpuDrivenSubmeshes.flatMap((submesh) => {
4521
5292
  const drawMaterial = materialsArr[submesh.materialSlot] ?? materialSnap;
@@ -4548,6 +5319,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4548
5319
  entityKey: entity,
4549
5320
  ...(skinSlice !== undefined ? { skin: skinSlice } : {}),
4550
5321
  ...(morph !== undefined ? { morph } : {}),
5322
+ ...(instancesSnap !== undefined ? { instances: instancesSnap } : {}),
4551
5323
  ...(spriteInstancesSnap !== undefined ? { spriteInstances: spriteInstancesSnap } : {}),
4552
5324
  ...(pointsLines !== undefined ? { pointsLines } : {}),
4553
5325
  };
@@ -4559,66 +5331,70 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4559
5331
  // producer-owned finite local-space AABBs. Culling is unconditional
4560
5332
  // engine behavior; there is no per-entity opt-out.
4561
5333
  if (localAabb !== undefined) {
4562
- // feat-20260601 D-3: cull AABB uses the resolved world mat4
4563
- // directly (no compose) -- same source the record stage feeds
4564
- // the mesh SSBO, so cull stays same-source with render (AC-05).
4565
- const worldAabb = box3.create();
4566
- box3.transformBox3(worldAabb, cullingLocalAabb, transformSnap.world);
4567
-
4568
- // Test against all cameras. Entity is visible if one camera
4569
- // frustum intersects the world-space AABB (or planes are empty
4570
- // from degenerate projection).
4571
- frustumTotal += 1;
4572
- let visible = frustumPlanes.length === 0;
4573
- for (let ci = 0; ci < frustumPlanes.length; ci++) {
4574
- const planes = frustumPlanes[ci] as Float32Array;
4575
- if (planes.length === 0) {
4576
- visible = true;
4577
- break;
4578
- }
4579
- if (frustum.intersectsBox(planes as frustum.Frustum, worldAabb as box3.Box3Like)) {
4580
- visible = true;
4581
- break;
4582
- }
4583
- }
4584
- if (!visible) {
4585
- frustumCulled += 1;
4586
- continue;
4587
- }
4588
- }
4589
-
4590
- if (hasInstances) {
4591
- const transforms = worldInternal._getArrayView(entity, Instances, 'transforms');
4592
- if (transforms === undefined) {
4593
- flushPendingDispatch(pendingDispatch);
4594
- 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.
4595
5357
  } else {
4596
- const actualLength = transforms.length;
4597
- if (actualLength % 16 !== 0) {
4598
- worldInternal._routeError(new InstanceTransformsStrideMismatchError(actualLength), {
4599
- severity: Severity.Error,
4600
- systemName: 'RenderSystem.extract (Instances stride)',
4601
- });
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
+ }
4602
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;
4603
5391
  continue;
4604
5392
  }
4605
- const snapshotCopy = new Float32Array(transforms);
4606
- const instanceCount = Math.max(1, Math.floor(actualLength / 16));
4607
- flushPendingDispatch(pendingDispatch);
4608
- renderables.push({
4609
- ...baseRenderable,
4610
- instances: {
4611
- transforms: snapshotCopy,
4612
- instanceCount,
4613
- cacheKey: entity,
4614
- archVersion,
4615
- },
4616
- });
4617
5393
  }
4618
- } else {
4619
- flushPendingDispatch(pendingDispatch);
4620
- renderables.push(baseRenderable);
4621
5394
  }
5395
+
5396
+ flushPendingDispatch(pendingDispatch);
5397
+ renderables.push(baseRenderable);
4622
5398
  }
4623
5399
 
4624
5400
  // feat-20260520-2d-sprite-layer-mvp M-3 / w22 + w25: finalise the
@@ -4653,10 +5429,12 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4653
5429
  // whitePoint / tonemap` onto the SAME unified params channel custom
4654
5430
  // post-processes use — `Camera.exposure` stays the AI-user-facing SSOT (D-5),
4655
5431
  // the engine itself acts as the provider for the Standard tonemap shader
4656
- // id. The 16B layout is byte-identical to the prior recordTonemapPass packing
4657
- // (render-system-record.ts pre-w14): Float32 [exposure, whitePoint, _, pad]
4658
- // with the mode u32 occupying the third 4-byte slot via tonemapToU32 (SSOT in
4659
- // 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
4660
5438
  // built-in provider is authoritative for its own reserved key (a user entity
4661
5439
  // can never shadow the Standard tonemap identity. The single active camera mirrors
4662
5440
  // recordFrame's `activeCameras[0]` selection.
@@ -4667,9 +5445,13 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4667
5445
 
4668
5446
  return {
4669
5447
  cameras,
5448
+ auxiliaryCameras,
5449
+ cubeCameras,
5450
+ reflectionProbes,
4670
5451
  lights,
4671
5452
  environment,
4672
5453
  environmentReady: true,
5454
+ ...(volumetricFog === undefined ? {} : { volumetricFog }),
4673
5455
  renderables,
4674
5456
  dispatch,
4675
5457
  skylight,
@@ -4677,6 +5459,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
4677
5459
  skybox,
4678
5460
  skyboxCount,
4679
5461
  fog,
5462
+ ...(fogFailure === undefined ? {} : { fogFailure }),
4680
5463
  frustumStats: { culled: frustumCulled, total: frustumTotal },
4681
5464
  visibilityStats: { explicitlyHidden: explicitlyHidden.size },
4682
5465
  postProcessParams,