@forgeax/engine-render 0.1.21 → 0.1.23

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 (426) hide show
  1. package/README.md +150 -29
  2. package/dist/assembly/backend-contract.d.ts +13 -0
  3. package/dist/assembly/backend-contract.d.ts.map +1 -1
  4. package/dist/assembly/device-feature-admission.d.ts.map +1 -1
  5. package/dist/assembly/factory.d.ts +14 -6
  6. package/dist/assembly/factory.d.ts.map +1 -1
  7. package/dist/assembly/host-contract.d.ts +10 -0
  8. package/dist/assembly/host-contract.d.ts.map +1 -1
  9. package/dist/authoring.mjs +1 -1
  10. package/dist/{chunk-PCHBPYML.mjs → chunk-4L3P6DT5.mjs} +2 -2
  11. package/dist/chunk-4L3P6DT5.mjs.map +1 -0
  12. package/dist/{chunk-VFPLTKCU.mjs → chunk-4NPUASZR.mjs} +15 -4
  13. package/dist/chunk-4NPUASZR.mjs.map +1 -0
  14. package/dist/chunk-DIYW3M5D.mjs +5085 -0
  15. package/dist/chunk-DIYW3M5D.mjs.map +1 -0
  16. package/dist/chunk-FBC4R44J.mjs +1003 -0
  17. package/dist/chunk-FBC4R44J.mjs.map +1 -0
  18. package/dist/{chunk-FS2KNUQH.mjs → chunk-FF7K3FSO.mjs} +253 -489
  19. package/dist/chunk-FF7K3FSO.mjs.map +1 -0
  20. package/dist/chunk-HZUPSYW7.mjs +1104 -0
  21. package/dist/chunk-HZUPSYW7.mjs.map +1 -0
  22. package/dist/{chunk-6OPXDQ2E.mjs → chunk-QODA5UKU.mjs} +33 -294
  23. package/dist/chunk-QODA5UKU.mjs.map +1 -0
  24. package/dist/chunk-YALPS44A.mjs +296 -0
  25. package/dist/chunk-YALPS44A.mjs.map +1 -0
  26. package/dist/{chunk-5YADUCA7.mjs → chunk-ZUWKAZ2I.mjs} +585 -991
  27. package/dist/chunk-ZUWKAZ2I.mjs.map +1 -0
  28. package/dist/cluster-binner.d.ts +5 -0
  29. package/dist/cluster-binner.d.ts.map +1 -1
  30. package/dist/components/index.d.ts +3 -0
  31. package/dist/components/index.d.ts.map +1 -1
  32. package/dist/components/light-helpers.d.ts +4 -0
  33. package/dist/components/light-helpers.d.ts.map +1 -1
  34. package/dist/components/light-probe.d.ts +9 -0
  35. package/dist/components/light-probe.d.ts.map +1 -0
  36. package/dist/components/rect-area-light.d.ts +12 -0
  37. package/dist/components/rect-area-light.d.ts.map +1 -0
  38. package/dist/components/scene-instance.d.ts +4 -0
  39. package/dist/components/scene-instance.d.ts.map +1 -1
  40. package/dist/components/spot-light.d.ts +3 -0
  41. package/dist/components/spot-light.d.ts.map +1 -1
  42. package/dist/construct-renderer.mjs +10437 -10044
  43. package/dist/construct-renderer.mjs.map +1 -1
  44. package/dist/device/gpu-residency.d.ts +8 -1
  45. package/dist/device/gpu-residency.d.ts.map +1 -1
  46. package/dist/device/lifecycle-transaction.d.ts +2 -0
  47. package/dist/device/lifecycle-transaction.d.ts.map +1 -0
  48. package/dist/environment/lifecycle.d.ts.map +1 -1
  49. package/dist/errors/gpu-driven.d.ts +26 -0
  50. package/dist/errors/gpu-driven.d.ts.map +1 -0
  51. package/dist/errors/index.d.ts +1 -0
  52. package/dist/errors/index.d.ts.map +1 -1
  53. package/dist/errors/recover.d.ts +9 -1
  54. package/dist/errors/recover.d.ts.map +1 -1
  55. package/dist/errors/render.d.ts +26 -1
  56. package/dist/errors/render.d.ts.map +1 -1
  57. package/dist/extract/gpu-driven.d.ts +50 -0
  58. package/dist/extract/gpu-driven.d.ts.map +1 -0
  59. package/dist/extract/visibility.d.ts.map +1 -1
  60. package/dist/gpu-driven/batch-topology.d.ts +34 -0
  61. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  62. package/dist/gpu-driven/prepared-draw.d.ts +63 -0
  63. package/dist/gpu-driven/prepared-draw.d.ts.map +1 -0
  64. package/dist/gpu-driven/production-raster.d.ts +98 -2
  65. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  66. package/dist/gpu-driven/view-gpu.d.ts +64 -1
  67. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  68. package/dist/gpu-scene-schema.d.ts +14 -0
  69. package/dist/gpu-scene-schema.d.ts.map +1 -1
  70. package/dist/gpu-scene.d.ts +8 -1
  71. package/dist/gpu-scene.d.ts.map +1 -1
  72. package/dist/gpu-usage.d.ts +0 -1
  73. package/dist/gpu-usage.d.ts.map +1 -1
  74. package/dist/hdrp-buffers.d.ts +3 -3
  75. package/dist/hdrp-buffers.d.ts.map +1 -1
  76. package/dist/index.d.ts +12 -1
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.mjs +90 -5
  79. package/dist/index.mjs.map +1 -1
  80. package/dist/inspection-types.d.ts +163 -1
  81. package/dist/inspection-types.d.ts.map +1 -1
  82. package/dist/internal.d.ts +9 -0
  83. package/dist/internal.d.ts.map +1 -1
  84. package/dist/internal.mjs +23 -2
  85. package/dist/internal.mjs.map +1 -1
  86. package/dist/light-buffer-layout.d.ts +58 -149
  87. package/dist/light-buffer-layout.d.ts.map +1 -1
  88. package/dist/materials.d.ts.map +1 -1
  89. package/dist/pbr-pipeline.d.ts +26 -12
  90. package/dist/pbr-pipeline.d.ts.map +1 -1
  91. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  92. package/dist/pipeline/standard-lighting/graph.d.ts.map +1 -1
  93. package/dist/pipeline/standard-lighting/light-frame.d.ts +3 -3
  94. package/dist/pipeline/standard-lighting/light-frame.d.ts.map +1 -1
  95. package/dist/pipeline/standard-lighting/topology.d.ts +1 -1
  96. package/dist/pipeline/standard-lighting/topology.d.ts.map +1 -1
  97. package/dist/pipeline/standard-lighting/transport.d.ts.map +1 -1
  98. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  99. package/dist/pipeline-builder.d.ts +2 -0
  100. package/dist/pipeline-builder.d.ts.map +1 -1
  101. package/dist/pipeline-spec.d.ts +1 -1
  102. package/dist/pipeline-spec.d.ts.map +1 -1
  103. package/dist/plugin.d.ts.map +1 -1
  104. package/dist/prepare/extended-lighting/ltc-resources.d.ts +31 -0
  105. package/dist/prepare/extended-lighting/ltc-resources.d.ts.map +1 -0
  106. package/dist/prepare/extended-lighting/resources.d.ts +72 -0
  107. package/dist/prepare/extended-lighting/resources.d.ts.map +1 -0
  108. package/dist/prepare/extended-lighting/spot-modifiers.d.ts +42 -0
  109. package/dist/prepare/extended-lighting/spot-modifiers.d.ts.map +1 -0
  110. package/dist/prepare/extended-lighting/state.d.ts +24 -0
  111. package/dist/prepare/extended-lighting/state.d.ts.map +1 -0
  112. package/dist/record/frame-lighting.d.ts +8 -6
  113. package/dist/record/frame-lighting.d.ts.map +1 -1
  114. package/dist/record/frame-snapshot.d.ts +50 -10
  115. package/dist/record/frame-snapshot.d.ts.map +1 -1
  116. package/dist/record/frame.d.ts +7 -3
  117. package/dist/record/frame.d.ts.map +1 -1
  118. package/dist/record/gpu-timing.d.ts +19 -7
  119. package/dist/record/gpu-timing.d.ts.map +1 -1
  120. package/dist/record/helpers.d.ts +0 -20
  121. package/dist/record/helpers.d.ts.map +1 -1
  122. package/dist/record/main-pass-geometry.d.ts +6 -1
  123. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  124. package/dist/record/main-pass-material.d.ts +3 -1
  125. package/dist/record/main-pass-material.d.ts.map +1 -1
  126. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  127. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  128. package/dist/record/render-context.d.ts +57 -6
  129. package/dist/record/render-context.d.ts.map +1 -1
  130. package/dist/record/shadow-pass.d.ts.map +1 -1
  131. package/dist/record/typed-frame-graph.d.ts +49 -2
  132. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  133. package/dist/record/view-ubo.d.ts +1 -2
  134. package/dist/record/view-ubo.d.ts.map +1 -1
  135. package/dist/reflection/inspection.d.ts +10 -0
  136. package/dist/reflection/inspection.d.ts.map +1 -0
  137. package/dist/reflection/projection.d.ts +49 -0
  138. package/dist/reflection/projection.d.ts.map +1 -1
  139. package/dist/reflection/record-owner.d.ts +60 -0
  140. package/dist/reflection/record-owner.d.ts.map +1 -0
  141. package/dist/render-contract.d.ts +14 -8
  142. package/dist/render-contract.d.ts.map +1 -1
  143. package/dist/render-pipeline.d.ts +7 -0
  144. package/dist/render-pipeline.d.ts.map +1 -1
  145. package/dist/render-system-extract.d.ts +104 -16
  146. package/dist/render-system-extract.d.ts.map +1 -1
  147. package/dist/render-system.d.ts +12 -1
  148. package/dist/render-system.d.ts.map +1 -1
  149. package/dist/scene/probe-blend-record.d.ts +22 -0
  150. package/dist/scene/probe-blend-record.d.ts.map +1 -0
  151. package/dist/scene/probe-blend.d.ts +142 -0
  152. package/dist/scene/probe-blend.d.ts.map +1 -0
  153. package/dist/scene/render-scene-types.d.ts +0 -6
  154. package/dist/scene/render-scene-types.d.ts.map +1 -1
  155. package/dist/scene/render-scene.d.ts +103 -9
  156. package/dist/scene/render-scene.d.ts.map +1 -1
  157. package/dist/scene/visibility/bounds.d.ts +20 -0
  158. package/dist/scene/visibility/bounds.d.ts.map +1 -0
  159. package/dist/scene/visibility/budget.d.ts +16 -0
  160. package/dist/scene/visibility/budget.d.ts.map +1 -0
  161. package/dist/scene/visibility/facet.d.ts +73 -0
  162. package/dist/scene/visibility/facet.d.ts.map +1 -0
  163. package/dist/scene/visibility/gpu-frame-timing.d.ts +41 -0
  164. package/dist/scene/visibility/gpu-frame-timing.d.ts.map +1 -0
  165. package/dist/scene/visibility/gpu-lod.d.ts +46 -0
  166. package/dist/scene/visibility/gpu-lod.d.ts.map +1 -0
  167. package/dist/scene/visibility/inspection.d.ts +132 -0
  168. package/dist/scene/visibility/inspection.d.ts.map +1 -0
  169. package/dist/scene/visibility/lod-selector.d.ts +25 -0
  170. package/dist/scene/visibility/lod-selector.d.ts.map +1 -0
  171. package/dist/scene/visibility/occlusion-confidence.d.ts +81 -0
  172. package/dist/scene/visibility/occlusion-confidence.d.ts.map +1 -0
  173. package/dist/scene/visibility/occlusion-pass.d.ts +57 -0
  174. package/dist/scene/visibility/occlusion-pass.d.ts.map +1 -0
  175. package/dist/scene/visibility/occlusion-query-pool.d.ts +57 -0
  176. package/dist/scene/visibility/occlusion-query-pool.d.ts.map +1 -0
  177. package/dist/scene/visibility/occlusion-runtime.d.ts +96 -0
  178. package/dist/scene/visibility/occlusion-runtime.d.ts.map +1 -0
  179. package/dist/scene/visibility/types.d.ts +34 -0
  180. package/dist/scene/visibility/types.d.ts.map +1 -0
  181. package/dist/ssr/admission.d.ts +122 -0
  182. package/dist/ssr/admission.d.ts.map +1 -0
  183. package/dist/ssr/composition.d.ts +32 -0
  184. package/dist/ssr/composition.d.ts.map +1 -0
  185. package/dist/ssr/errors.d.ts +39 -0
  186. package/dist/ssr/errors.d.ts.map +1 -0
  187. package/dist/ssr/identity.d.ts +13 -0
  188. package/dist/ssr/identity.d.ts.map +1 -0
  189. package/dist/systems/skin-palette-allocator.d.ts +1 -1
  190. package/dist/temporal/frame-coordinator.d.ts +13 -0
  191. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  192. package/dist/temporal/frame.d.ts +2 -0
  193. package/dist/temporal/frame.d.ts.map +1 -1
  194. package/dist/temporal/index.mjs +6 -5
  195. package/dist/temporal/view.d.ts +7 -0
  196. package/dist/temporal/view.d.ts.map +1 -1
  197. package/dist/tilemap-chunk-extract-system.d.ts +1 -1
  198. package/dist/typed-render-graph-primitives.d.ts +4 -0
  199. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  200. package/dist/volume/passes.d.ts +8 -4
  201. package/dist/volume/passes.d.ts.map +1 -1
  202. package/package.json +20 -20
  203. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +5 -0
  204. package/src/__tests__/detached-inspection.integration.test.ts +6 -0
  205. package/src/__tests__/device-feature-admission.unit.test.ts +13 -1
  206. package/src/__tests__/direct-light-snapshot.unit.test.ts +59 -3
  207. package/src/__tests__/environment-generation.integration.test.ts +16 -0
  208. package/src/__tests__/evidence-identity.ts +13 -0
  209. package/src/__tests__/evidence-identity.unit.test.ts +24 -0
  210. package/src/__tests__/extended-lighting-recovery.browser.test.ts +14 -0
  211. package/src/__tests__/extended-lighting-recovery.dawn.test.ts +14 -0
  212. package/src/__tests__/extended-lighting-recovery.integration.test.ts +81 -0
  213. package/src/__tests__/extended-lighting-resources.unit.test.ts +51 -0
  214. package/src/__tests__/extended-lighting-zero-feature.integration.test.ts +52 -0
  215. package/src/__tests__/factory-contract.integration.test.ts +33 -0
  216. package/src/__tests__/frame-observation.contract.test.ts +15 -0
  217. package/src/__tests__/gpu-driven-batch-topology-pbr.unit.test.ts +146 -0
  218. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +1 -0
  219. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +1 -0
  220. package/src/__tests__/gpu-driven-production.integration.test.ts +572 -9
  221. package/src/__tests__/gpu-driven-scaling.unit.test.ts +66 -2
  222. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +26 -1
  223. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +89 -2
  224. package/src/__tests__/gpu-driven-view.browser.test.ts +3 -3
  225. package/src/__tests__/gpu-driven-view.dawn.test.ts +3 -3
  226. package/src/__tests__/gpu-driven-view.unit.test.ts +50 -0
  227. package/src/__tests__/gpu-lod-evidence.ts +188 -0
  228. package/src/__tests__/gpu-lod-fallback.integration.test.ts +10 -0
  229. package/src/__tests__/gpu-lod-indirect.integration.test.ts +40 -0
  230. package/src/__tests__/gpu-lod-parity.dawn.test.ts +29 -0
  231. package/src/__tests__/gpu-lod-readiness.integration.test.ts +52 -0
  232. package/src/__tests__/gpu-lod-schema.unit.test.ts +66 -0
  233. package/src/__tests__/gpu-lod-selector.unit.test.ts +28 -0
  234. package/src/__tests__/gpu-lod-silhouette.browser.test.ts +13 -0
  235. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +66 -1
  236. package/src/__tests__/gpu-scene.dawn.test.ts +8 -1
  237. package/src/__tests__/gpu-scene.unit.test.ts +49 -3
  238. package/src/__tests__/light-buffer-layout.unit.test.ts +113 -3
  239. package/src/__tests__/light-inspection.unit.test.ts +55 -0
  240. package/src/__tests__/light-resource-error.unit.test.ts +43 -0
  241. package/src/__tests__/lighting-scene-roundtrip.integration.test.ts +109 -0
  242. package/src/__tests__/lod-bounds.unit.test.ts +42 -0
  243. package/src/__tests__/lod-cpu-lane.integration.test.ts +55 -0
  244. package/src/__tests__/lod-cpu-no-change.perf.test.ts +19 -0
  245. package/src/__tests__/lod-gpu-frame-timing.unit.test.ts +49 -0
  246. package/src/__tests__/lod-multi-view.integration.test.ts +63 -0
  247. package/src/__tests__/lod-occlusion-device-loss.integration.test.ts +27 -0
  248. package/src/__tests__/lod-occlusion-final-decision.integration.test.ts +47 -0
  249. package/src/__tests__/lod-occlusion-inspection.integration.test.ts +217 -0
  250. package/src/__tests__/lod-occlusion-inspection.schema.test-d.ts +14 -0
  251. package/src/__tests__/lod-occlusion-inspection.unit.test.ts +72 -0
  252. package/src/__tests__/lod-occlusion-visual-evidence.ts +91 -0
  253. package/src/__tests__/lod-occlusion.browser.test.ts +15 -0
  254. package/src/__tests__/lod-occlusion.dawn.test.ts +20 -0
  255. package/src/__tests__/lod-selector.profile.test.ts +35 -0
  256. package/src/__tests__/lod-selector.unit.test.ts +68 -0
  257. package/src/__tests__/lod-visibility-identity.unit.test.ts +198 -0
  258. package/src/__tests__/lod-visibility-lifecycle.integration.test.ts +132 -0
  259. package/src/__tests__/ltc-resources.unit.test.ts +32 -0
  260. package/src/__tests__/material-contract-inventory.unit.test.ts +17 -0
  261. package/src/__tests__/occlusion-confidence.unit.test.ts +168 -0
  262. package/src/__tests__/occlusion-invalidation.integration.test.ts +37 -0
  263. package/src/__tests__/occlusion-pass-routing.unit.test.ts +16 -0
  264. package/src/__tests__/occlusion-pass.dawn.test.ts +20 -0
  265. package/src/__tests__/occlusion-pass.integration.test.ts +43 -0
  266. package/src/__tests__/occlusion-pass.rhi-null.test.ts +22 -0
  267. package/src/__tests__/occlusion-query-pool.rhi-null.test.ts +21 -0
  268. package/src/__tests__/occlusion-query-pool.unit.test.ts +95 -0
  269. package/src/__tests__/occlusion-query-ticket.integration.test.ts +47 -0
  270. package/src/__tests__/occlusion-query.dawn.test.ts +16 -0
  271. package/src/__tests__/occlusion-query.evidence.ts +178 -0
  272. package/src/__tests__/occlusion-query.rhi-null.test.ts +31 -0
  273. package/src/__tests__/occlusion-runtime.unit.test.ts +120 -0
  274. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +196 -0
  275. package/src/__tests__/probe-bindings.unit.test.ts +200 -0
  276. package/src/__tests__/probe-blend-numeric.unit.test.ts +67 -0
  277. package/src/__tests__/probe-blend-oracle.unit.test.ts +136 -0
  278. package/src/__tests__/probe-capacity.integration.test.ts +97 -0
  279. package/src/__tests__/probe-recovery.integration.test.ts +104 -0
  280. package/src/__tests__/probe-scene-dirty.integration.test.ts +211 -0
  281. package/src/__tests__/profiler-phase-catalog.test.ts +4 -1
  282. package/src/__tests__/profiler-phase-token.test.ts +1 -0
  283. package/src/__tests__/rect-area-boundary.unit.test.ts +24 -0
  284. package/src/__tests__/reflection-fallback-graph.unit.test.ts +67 -0
  285. package/src/__tests__/reflection-fallback-inspection.unit.test.ts +77 -0
  286. package/src/__tests__/reflection-fallback-lifecycle.integration.test.ts +21 -0
  287. package/src/__tests__/reflection-fallback-projection.unit.test.ts +90 -0
  288. package/src/__tests__/reflection-fallback-source.unit.test.ts +32 -0
  289. package/src/__tests__/reflection-probe-owner-placement.unit.test.ts +39 -0
  290. package/src/__tests__/reflection-probe-record-owner.characterization.unit.test.ts +120 -0
  291. package/src/__tests__/render-error-code-owner.test-d.ts +1 -0
  292. package/src/__tests__/render-feature-docs.unit.test.ts +12 -7
  293. package/src/__tests__/render-graph-fallback.integration.test.ts +24 -0
  294. package/src/__tests__/render-perf/extended-lighting.bench.test.ts +66 -0
  295. package/src/__tests__/render-scene-projection.unit.test.ts +447 -18
  296. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +76 -0
  297. package/src/__tests__/skin-motion-regression.unit.test.ts +22 -0
  298. package/src/__tests__/skinned-shadow-caster.test.ts +2 -2
  299. package/src/__tests__/spot-modifier-projection.unit.test.ts +70 -0
  300. package/src/__tests__/ssr-admission-matrix.integration.test.ts +206 -0
  301. package/src/__tests__/ssr-composition.unit.test.ts +46 -0
  302. package/src/__tests__/ssr-fallback-zero-work.bench.test.ts +94 -0
  303. package/src/__tests__/ssr-recovery-errors.unit.test.ts +58 -0
  304. package/src/__tests__/standard-cluster-variant-resolution.unit.test.ts +67 -0
  305. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +7 -0
  306. package/src/__tests__/standard-lighting-inspection.integration.test.ts +1 -1
  307. package/src/__tests__/standard-lighting-write.unit.test.ts +66 -2
  308. package/src/__tests__/temporal-fallback-generation.integration.test.ts +16 -0
  309. package/src/__tests__/temporal-view.unit.test.ts +3 -3
  310. package/src/__tests__/transmission-prewarm.unit.test.ts +69 -0
  311. package/src/__tests__/visibility-budget.integration.test.ts +140 -0
  312. package/src/__tests__/visibility-budget.unit.test.ts +70 -0
  313. package/src/__tests__/visibility-extract.integration.test.ts +11 -6
  314. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +6 -3
  315. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +31 -28
  316. package/src/assembly/backend-contract.ts +13 -0
  317. package/src/assembly/device-feature-admission.ts +16 -9
  318. package/src/assembly/factory.ts +547 -93
  319. package/src/assembly/host-contract.ts +10 -0
  320. package/src/cluster-binner.ts +31 -0
  321. package/src/components/__tests__/lighting-authoring.unit.test.ts +39 -0
  322. package/src/components/__tests__/rect-area-light.unit.test.ts +33 -0
  323. package/src/components/index.ts +3 -0
  324. package/src/components/light-helpers.ts +52 -1
  325. package/src/components/light-probe.ts +13 -0
  326. package/src/components/rect-area-light.ts +15 -0
  327. package/src/components/scene-instance.ts +6 -0
  328. package/src/components/spot-light.ts +3 -0
  329. package/src/device/__tests__/extended-lighting-recovery.unit.test.ts +24 -0
  330. package/src/device/gpu-residency.ts +140 -9
  331. package/src/device/lifecycle-transaction.ts +12 -0
  332. package/src/environment/lifecycle.ts +8 -1
  333. package/src/errors/gpu-driven.ts +92 -0
  334. package/src/errors/index.ts +1 -0
  335. package/src/errors/recover.ts +32 -0
  336. package/src/errors/render.ts +55 -1
  337. package/src/extract/camera.ts +3 -3
  338. package/src/extract/gpu-driven.ts +108 -0
  339. package/src/extract/visibility.ts +0 -23
  340. package/src/gpu-driven/batch-topology.ts +206 -28
  341. package/src/gpu-driven/prepared-draw.ts +208 -0
  342. package/src/gpu-driven/production-raster.ts +953 -41
  343. package/src/gpu-driven/view-gpu.ts +411 -29
  344. package/src/gpu-scene-schema.ts +13 -0
  345. package/src/gpu-scene.ts +134 -65
  346. package/src/gpu-usage.ts +0 -1
  347. package/src/hdrp-buffers.ts +6 -6
  348. package/src/index.ts +74 -0
  349. package/src/inspection-types.ts +220 -1
  350. package/src/internal.ts +38 -0
  351. package/src/light-buffer-layout.ts +154 -324
  352. package/src/materials.ts +16 -2
  353. package/src/pbr-pipeline.ts +151 -45
  354. package/src/pipeline/standard-forward-lane.ts +47 -0
  355. package/src/pipeline/standard-lighting/graph.ts +2 -1
  356. package/src/pipeline/standard-lighting/light-frame.ts +3 -2
  357. package/src/pipeline/standard-lighting/topology.ts +1 -1
  358. package/src/pipeline/standard-lighting/transport.ts +2 -1
  359. package/src/pipeline/standard-pipeline.ts +2 -0
  360. package/src/pipeline-builder.ts +5 -2
  361. package/src/pipeline-spec.ts +12 -1
  362. package/src/plugin.ts +4 -0
  363. package/src/prepare/__tests__/extended-lighting-capability.unit.test.ts +56 -0
  364. package/src/prepare/extended-lighting/ltc-resources.ts +136 -0
  365. package/src/prepare/extended-lighting/resources.ts +389 -0
  366. package/src/prepare/extended-lighting/spot-modifiers.ts +194 -0
  367. package/src/prepare/extended-lighting/state.ts +92 -0
  368. package/src/record/__tests__/gpu-timing.unit.test.ts +177 -0
  369. package/src/record/__tests__/temporal-payload.unit.test.ts +23 -1
  370. package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +20 -1
  371. package/src/record/frame-lighting.ts +252 -53
  372. package/src/record/frame-snapshot.ts +57 -12
  373. package/src/record/frame.ts +410 -93
  374. package/src/record/gpu-timing.ts +108 -44
  375. package/src/record/helpers.ts +0 -58
  376. package/src/record/main-pass-geometry.ts +204 -39
  377. package/src/record/main-pass-material.ts +8 -1
  378. package/src/record/main-pass-sprite-draws.ts +52 -85
  379. package/src/record/mesh-ssbo.ts +47 -36
  380. package/src/record/render-context.ts +65 -4
  381. package/src/record/shadow-pass.ts +66 -40
  382. package/src/record/typed-frame-graph.ts +225 -83
  383. package/src/record/view-ubo.ts +14 -24
  384. package/src/reflection/inspection.ts +67 -0
  385. package/src/reflection/projection.ts +196 -1
  386. package/src/reflection/record-owner.ts +1283 -0
  387. package/src/render-contract.ts +17 -5
  388. package/src/render-pipeline.ts +5 -0
  389. package/src/render-system-extract.ts +493 -134
  390. package/src/render-system.ts +811 -615
  391. package/src/scene/__tests__/changed-block-summary.candidate.test.ts +36 -0
  392. package/src/scene/probe-blend-record.ts +54 -0
  393. package/src/scene/probe-blend.ts +813 -0
  394. package/src/scene/render-scene-types.ts +0 -7
  395. package/src/scene/render-scene.ts +848 -183
  396. package/src/scene/visibility/bounds.ts +57 -0
  397. package/src/scene/visibility/budget.ts +40 -0
  398. package/src/scene/visibility/facet.ts +400 -0
  399. package/src/scene/visibility/gpu-frame-timing.ts +101 -0
  400. package/src/scene/visibility/gpu-lod.ts +119 -0
  401. package/src/scene/visibility/inspection.ts +313 -0
  402. package/src/scene/visibility/lod-selector.ts +84 -0
  403. package/src/scene/visibility/occlusion-confidence.ts +478 -0
  404. package/src/scene/visibility/occlusion-pass.ts +165 -0
  405. package/src/scene/visibility/occlusion-query-pool.ts +178 -0
  406. package/src/scene/visibility/occlusion-runtime.ts +672 -0
  407. package/src/scene/visibility/types.ts +50 -0
  408. package/src/ssr/admission.ts +451 -0
  409. package/src/ssr/composition.ts +73 -0
  410. package/src/ssr/errors.ts +76 -0
  411. package/src/ssr/identity.ts +12 -0
  412. package/src/systems/skin-palette-allocator.ts +1 -1
  413. package/src/temporal/frame-coordinator.ts +26 -0
  414. package/src/temporal/frame.ts +14 -1
  415. package/src/temporal/view.ts +11 -0
  416. package/src/tilemap-chunk-extract-system.ts +4 -4
  417. package/src/typed-render-graph-primitives.ts +48 -0
  418. package/src/volume/passes.ts +46 -49
  419. package/dist/.tsbuildinfo +0 -1
  420. package/dist/chunk-5YADUCA7.mjs.map +0 -1
  421. package/dist/chunk-6OPXDQ2E.mjs.map +0 -1
  422. package/dist/chunk-BXBQQKFQ.mjs +0 -67
  423. package/dist/chunk-BXBQQKFQ.mjs.map +0 -1
  424. package/dist/chunk-FS2KNUQH.mjs.map +0 -1
  425. package/dist/chunk-PCHBPYML.mjs.map +0 -1
  426. package/dist/chunk-VFPLTKCU.mjs.map +0 -1
@@ -1,9 +1,9 @@
1
- import type { EntityHandle, World } from '@forgeax/engine-ecs';
1
+ import type { Query, World } from '@forgeax/engine-ecs';
2
2
  import { componentId } from '@forgeax/engine-ecs/internal';
3
- import type { RenderReadLease } from '@forgeax/engine-ecs/projection';
3
+ import type { RenderReadLease, RenderReadVersion } from '@forgeax/engine-ecs/projection';
4
4
  import { box3, frustum, mat4 } from '@forgeax/engine-math';
5
5
  import { type RhiDevice, RhiError } from '@forgeax/engine-rhi';
6
- import { ChildOf, Transform } from '@forgeax/engine-scene';
6
+ import { ChildOf, GlobalTransform, Transform } from '@forgeax/engine-scene';
7
7
  import { Skin } from '@forgeax/engine-skinning';
8
8
  import { err, ok, type Result } from '@forgeax/engine-types';
9
9
  import {
@@ -11,8 +11,10 @@ import {
11
11
  DirectionalLight,
12
12
  Instances,
13
13
  Layer,
14
+ LightProbe,
14
15
  MeshFilter,
15
16
  MeshRenderer,
17
+ MotionBlur,
16
18
  PointLight,
17
19
  PointLightShadow,
18
20
  PostProcessParams,
@@ -34,6 +36,7 @@ import type { PersistentRenderSceneInspection } from '../inspection-types';
34
36
  import { fingerprintNumericArray, InstanceBoundsCache } from '../instances-derived-bounds';
35
37
  import type { PointsLinesInspection } from '../points-lines/inspection';
36
38
  import type { PointsLinesRetainedSnapshot } from '../points-lines/snapshot';
39
+ import { worldEntityKey } from '../record/frame-snapshot';
37
40
  import {
38
41
  type ReflectionProbeFact,
39
42
  ReflectionProbeProjection,
@@ -55,21 +58,30 @@ import {
55
58
  type TransmissionDemandOperation,
56
59
  } from '../transmission/projection';
57
60
  import { VolumetricFog } from '../volume/component';
61
+ import { ProbeBlendSceneProjection, type ProbeSceneObjectInput } from './probe-blend';
58
62
  import type {
59
63
  RenderSceneApplyResult,
60
64
  RenderSceneBounds,
61
- RenderSceneDelta,
62
65
  RenderSceneInspection,
63
66
  RenderSceneOperation,
64
67
  RenderSceneRecord,
65
68
  RenderSceneResyncReason,
66
69
  RenderSceneSlot,
67
70
  } from './render-scene-types';
71
+ import { createVisibilityBudget, type VisibilityBudget } from './visibility/budget';
72
+ import {
73
+ primitiveKey,
74
+ primitiveKeyId,
75
+ VisibilityFacetStore,
76
+ viewKey,
77
+ viewKeyId,
78
+ } from './visibility/facet';
79
+ import { decideVisibility } from './visibility/occlusion-confidence';
80
+ import type { PrimitiveKey, ViewKey, VisibilityCandidate } from './visibility/types';
68
81
 
69
82
  export type {
70
83
  RenderSceneApplyResult,
71
84
  RenderSceneBounds,
72
- RenderSceneDelta,
73
85
  RenderSceneIdentity,
74
86
  RenderSceneInspection,
75
87
  RenderSceneOperation,
@@ -86,6 +98,14 @@ interface PendingIdentity {
86
98
  removed: boolean;
87
99
  }
88
100
 
101
+ interface WorldTransformSpan {
102
+ readonly worldId: number;
103
+ readonly entities: Readonly<Uint32Array>;
104
+ readonly worlds: Readonly<Float32Array>;
105
+ }
106
+
107
+ type GlobalTransformChangeQuery = Query<readonly [typeof GlobalTransform]>;
108
+
89
109
  export interface RenderSceneSubmissionCapture {
90
110
  readonly revision: number;
91
111
  readonly slots: readonly {
@@ -200,31 +220,55 @@ function intersects(left: RenderSceneBounds, right: RenderSceneBounds): boolean
200
220
  );
201
221
  }
202
222
 
203
- function worldBounds(snapshot: RenderableSnapshot): RenderSceneBounds | undefined {
223
+ function worldBounds(
224
+ snapshot: RenderableSnapshot,
225
+ target?: RenderSceneBounds,
226
+ ): RenderSceneBounds | undefined {
204
227
  const local = snapshot.localAabb;
205
228
  if (local === undefined || local.length < 6) return undefined;
206
229
  const world = snapshot.transform.world;
207
- const min: [number, number, number] = [Infinity, Infinity, Infinity];
208
- const max: [number, number, number] = [-Infinity, -Infinity, -Infinity];
209
- for (const x of [local[0] ?? 0, local[3] ?? 0]) {
210
- for (const y of [local[1] ?? 0, local[4] ?? 0]) {
211
- for (const z of [local[2] ?? 0, local[5] ?? 0]) {
212
- const tx =
213
- (world[0] ?? 0) * x + (world[4] ?? 0) * y + (world[8] ?? 0) * z + (world[12] ?? 0);
214
- const ty =
215
- (world[1] ?? 0) * x + (world[5] ?? 0) * y + (world[9] ?? 0) * z + (world[13] ?? 0);
216
- const tz =
217
- (world[2] ?? 0) * x + (world[6] ?? 0) * y + (world[10] ?? 0) * z + (world[14] ?? 0);
218
- min[0] = Math.min(min[0], tx);
219
- min[1] = Math.min(min[1], ty);
220
- min[2] = Math.min(min[2], tz);
221
- max[0] = Math.max(max[0], tx);
222
- max[1] = Math.max(max[1], ty);
223
- max[2] = Math.max(max[2], tz);
224
- }
225
- }
226
- }
227
- return { min, max };
230
+ const min = (target?.min ?? [0, 0, 0]) as [number, number, number];
231
+ const max = (target?.max ?? [0, 0, 0]) as [number, number, number];
232
+ const centerX = ((local[0] ?? 0) + (local[3] ?? 0)) * 0.5;
233
+ const centerY = ((local[1] ?? 0) + (local[4] ?? 0)) * 0.5;
234
+ const centerZ = ((local[2] ?? 0) + (local[5] ?? 0)) * 0.5;
235
+ const extentX = ((local[3] ?? 0) - (local[0] ?? 0)) * 0.5;
236
+ const extentY = ((local[4] ?? 0) - (local[1] ?? 0)) * 0.5;
237
+ const extentZ = ((local[5] ?? 0) - (local[2] ?? 0)) * 0.5;
238
+ const worldCenterX =
239
+ (world[0] ?? 0) * centerX +
240
+ (world[4] ?? 0) * centerY +
241
+ (world[8] ?? 0) * centerZ +
242
+ (world[12] ?? 0);
243
+ const worldCenterY =
244
+ (world[1] ?? 0) * centerX +
245
+ (world[5] ?? 0) * centerY +
246
+ (world[9] ?? 0) * centerZ +
247
+ (world[13] ?? 0);
248
+ const worldCenterZ =
249
+ (world[2] ?? 0) * centerX +
250
+ (world[6] ?? 0) * centerY +
251
+ (world[10] ?? 0) * centerZ +
252
+ (world[14] ?? 0);
253
+ const worldExtentX =
254
+ Math.abs(world[0] ?? 0) * extentX +
255
+ Math.abs(world[4] ?? 0) * extentY +
256
+ Math.abs(world[8] ?? 0) * extentZ;
257
+ const worldExtentY =
258
+ Math.abs(world[1] ?? 0) * extentX +
259
+ Math.abs(world[5] ?? 0) * extentY +
260
+ Math.abs(world[9] ?? 0) * extentZ;
261
+ const worldExtentZ =
262
+ Math.abs(world[2] ?? 0) * extentX +
263
+ Math.abs(world[6] ?? 0) * extentY +
264
+ Math.abs(world[10] ?? 0) * extentZ;
265
+ min[0] = worldCenterX - worldExtentX;
266
+ min[1] = worldCenterY - worldExtentY;
267
+ min[2] = worldCenterZ - worldExtentZ;
268
+ max[0] = worldCenterX + worldExtentX;
269
+ max[1] = worldCenterY + worldExtentY;
270
+ max[2] = worldCenterZ + worldExtentZ;
271
+ return target ?? { min, max };
228
272
  }
229
273
 
230
274
  function boundsFromArray(bounds: ArrayLike<number>): RenderSceneBounds | undefined {
@@ -253,20 +297,23 @@ function boundsFromArray(bounds: ArrayLike<number>): RenderSceneBounds | undefin
253
297
  export class RenderScene {
254
298
  private readonly slots: Array<RenderSceneSlot | undefined> = [];
255
299
  private readonly lastSubmittedSlots: Array<RenderSceneSlot | undefined> = [];
300
+ private readonly previousWorldBySlot: Array<Float32Array | undefined> = [];
256
301
  private readonly generations: number[] = [];
257
302
  private readonly freeSlots: number[] = [];
258
303
  private readonly slotsByWorld = new Map<number, Map<number, number>>();
259
304
  private readonly slotsByMaterial = new Map<number, Set<number>>();
260
305
  private readonly dirtySinceSubmitted = new Set<number>();
306
+ private readonly transformUpdatedSlots: RenderSceneSlot[] = [];
307
+ private temporalTracking = false;
261
308
  private lastSubmittedVisible = new Set<string>();
262
309
  private submittedEpoch = 0;
263
- // World bounds are a pure projection of each immutable snapshot. Cache the
264
- // derived value by snapshot identity so repeated view queries do not redo
265
- // the eight-corner transform for every view in the same frame.
266
- private readonly worldBoundsCache = new WeakMap<RenderableSnapshot, RenderSceneBounds | null>();
310
+ // Bounds are a derived column of the stable render slot, not metadata of a
311
+ // transient snapshot wrapper. Undefined means dirty; null means that the
312
+ // retained row has no usable bounds and must be conservatively visible.
313
+ private readonly worldBoundsBySlot: Array<RenderSceneBounds | null | undefined> = [];
267
314
  private readonly instanceBoundsCache = new InstanceBoundsCache();
268
315
  private orderedSlots: number[] = [];
269
- private materialized: readonly RenderableSnapshot[] | undefined;
316
+ private materialized: RenderableSnapshot[] | undefined;
270
317
  private slotSnapshot: readonly RenderSceneSlot[] | undefined;
271
318
  private revision = 0;
272
319
  private noChangeFrames = 0;
@@ -374,6 +421,7 @@ export class RenderScene {
374
421
  };
375
422
  this.unindexMaterials(pending.initial);
376
423
  this.slots[pending.initial.slot] = updatedSlot;
424
+ this.worldBoundsBySlot[updatedSlot.slot] = undefined;
377
425
  this.indexMaterials(updatedSlot);
378
426
  this.dirtySinceSubmitted.add(updatedSlot.slot);
379
427
  updatedSlots.push(updatedSlot);
@@ -399,19 +447,55 @@ export class RenderScene {
399
447
  };
400
448
  }
401
449
 
402
- applyDelta(delta: RenderSceneDelta): RenderSceneApplyResult {
403
- if (delta.overflowed || delta.resync !== undefined) {
404
- const snapshots = delta.resync ?? [];
405
- this.reset(snapshots);
406
- this.resyncs += 1;
407
- this.lastResyncReason = delta.reason ?? 'journal-overflow';
408
- return {
409
- ...this.emptyResult(),
410
- created: snapshots.length,
411
- resynced: 1,
412
- };
450
+ /** Apply version-selected GlobalTransform columns without allocating per-entity operations. */
451
+ applyTransformSpans(spans: readonly WorldTransformSpan[]): RenderSceneApplyResult {
452
+ this.deltaFrames += 1;
453
+ let scanned = 0;
454
+ let updated = 0;
455
+ let ignoredLateUpdates = 0;
456
+ const updatedSlots = this.transformUpdatedSlots;
457
+ updatedSlots.length = 0;
458
+ for (const span of spans) {
459
+ scanned += span.entities.length;
460
+ for (let row = 0; row < span.entities.length; row += 1) {
461
+ const entity = span.entities[row];
462
+ if (entity === undefined) continue;
463
+ const slot = this.lookup(span.worldId, entity);
464
+ if (slot === undefined) {
465
+ ignoredLateUpdates += 1;
466
+ continue;
467
+ }
468
+ const target = slot.snapshot.transform.world;
469
+ const sourceStart = row * 16;
470
+ for (let index = 0; index < 16; index += 1) {
471
+ target[index] = span.worlds[sourceStart + index] ?? target[index] ?? 0;
472
+ }
473
+ if (slot.snapshot.instances === undefined) {
474
+ const cached = this.worldBoundsBySlot[slot.slot];
475
+ const bounds = worldBounds(slot.snapshot, cached ?? undefined);
476
+ this.worldBoundsBySlot[slot.slot] = bounds ?? null;
477
+ } else {
478
+ this.worldBoundsBySlot[slot.slot] = undefined;
479
+ }
480
+ if (this.temporalTracking) this.dirtySinceSubmitted.add(slot.slot);
481
+ updatedSlots.push(slot);
482
+ updated += 1;
483
+ }
413
484
  }
414
- return this.apply(delta.operations);
485
+ this.renderableScans = scanned;
486
+ if (updated > 0) this.revision += 1;
487
+ return {
488
+ created: 0,
489
+ updated,
490
+ removed: 0,
491
+ recreated: 0,
492
+ ignoredLateUpdates,
493
+ createdSlots: [],
494
+ updatedSlots,
495
+ removedSlots: [],
496
+ recreatedSlots: [],
497
+ resynced: 0,
498
+ };
415
499
  }
416
500
 
417
501
  /** Reconcile the current identity set while retaining slots for survivors. */
@@ -446,6 +530,7 @@ export class RenderScene {
446
530
  };
447
531
  this.unindexMaterials(existing);
448
532
  this.slots[existing.slot] = updated;
533
+ this.worldBoundsBySlot[updated.slot] = undefined;
449
534
  this.indexMaterials(updated);
450
535
  this.dirtySinceSubmitted.add(updated.slot);
451
536
  this.orderedSlots.push(existing.slot);
@@ -460,14 +545,25 @@ export class RenderScene {
460
545
  this.reset(snapshots);
461
546
  }
462
547
 
463
- materialize(): readonly RenderableSnapshot[] {
548
+ /** Enable previous-frame publication only while a temporal feature consumes it. */
549
+ setTemporalTracking(enabled: boolean): void {
550
+ if (enabled === this.temporalTracking) return;
551
+ this.temporalTracking = enabled;
552
+ if (!enabled) {
553
+ this.dirtySinceSubmitted.clear();
554
+ return;
555
+ }
556
+ for (const slot of this.orderedSlots) this.dirtySinceSubmitted.add(slot);
557
+ }
558
+
559
+ materialize(): RenderableSnapshot[] {
464
560
  if (this.materialized !== undefined) return this.materialized;
465
561
  const snapshots: RenderableSnapshot[] = [];
466
562
  for (const slot of this.orderedSlots) {
467
563
  const record = this.slots[slot];
468
564
  if (record !== undefined) snapshots.push(record.snapshot);
469
565
  }
470
- this.materialized = Object.freeze(snapshots);
566
+ this.materialized = snapshots;
471
567
  return this.materialized;
472
568
  }
473
569
 
@@ -513,6 +609,15 @@ export class RenderScene {
513
609
  }
514
610
  for (const update of capture.slots) {
515
611
  this.lastSubmittedSlots[update.slot] = update.current;
612
+ const currentWorld = update.current?.snapshot.transform.world;
613
+ if (currentWorld !== undefined) {
614
+ let previousWorld = this.previousWorldBySlot[update.slot];
615
+ if (previousWorld === undefined) {
616
+ previousWorld = new Float32Array(16);
617
+ this.previousWorldBySlot[update.slot] = previousWorld;
618
+ }
619
+ previousWorld.set(currentWorld);
620
+ }
516
621
  this.dirtySinceSubmitted.delete(update.slot);
517
622
  }
518
623
  this.lastSubmittedVisible = new Set(capture.visible);
@@ -575,8 +680,7 @@ export class RenderScene {
575
680
  for (const slot of this.orderedSlots) {
576
681
  const record = this.slots[slot];
577
682
  if (record === undefined) continue;
578
- const snapshot = record.snapshot;
579
- const candidate = this.cullingWorldBounds(snapshot);
683
+ const candidate = this.cullingWorldBoundsAt(record);
580
684
  if (candidate === null || candidate === undefined) continue;
581
685
  if (intersects(candidate, bounds)) records.push(record);
582
686
  }
@@ -634,8 +738,15 @@ export class RenderScene {
634
738
  entityKey: snapshot.entityKey,
635
739
  snapshot: ownSnapshot(snapshot),
636
740
  };
741
+ let previousWorld = this.previousWorldBySlot[slot];
742
+ if (previousWorld === undefined) {
743
+ previousWorld = new Float32Array(16);
744
+ this.previousWorldBySlot[slot] = previousWorld;
745
+ }
746
+ previousWorld.set(record.snapshot.transform.world);
637
747
  this.generations[slot] = generation;
638
748
  this.slots[slot] = record;
749
+ this.worldBoundsBySlot[slot] = undefined;
639
750
  let entities = this.slotsByWorld.get(snapshot.worldId);
640
751
  if (entities === undefined) {
641
752
  entities = new Map<number, number>();
@@ -649,6 +760,7 @@ export class RenderScene {
649
760
 
650
761
  private release(record: RenderSceneSlot): void {
651
762
  this.slots[record.slot] = undefined;
763
+ this.worldBoundsBySlot[record.slot] = undefined;
652
764
  this.instanceBoundsCache.invalidate(record.entityKey, record.worldId);
653
765
  this.unindexMaterials(record);
654
766
  const entities = this.slotsByWorld.get(record.worldId);
@@ -711,12 +823,16 @@ export class RenderScene {
711
823
  reasons.includes('geometry-revision') ||
712
824
  reasons.includes('material-revision');
713
825
  const previous = seedCurrent || submitted === undefined ? slot.snapshot : submitted.snapshot;
826
+ const previousWorld = this.previousWorldBySlot[slot.slot];
714
827
  return {
715
828
  previousEpoch: seedCurrent ? undefined : this.submittedEpoch,
716
829
  previousSource: seedCurrent ? 'current-seed' : 'last-submitted',
717
830
  reactive: reasons.length > 0,
718
831
  reactiveReasons: reasons,
719
- previousTransform: previous.transform,
832
+ previousTransform:
833
+ seedCurrent || previousWorld === undefined
834
+ ? previous.transform
835
+ : { ...previous.transform, world: previousWorld },
720
836
  previousInstances: previous.instances,
721
837
  previousSkin: previous.skin,
722
838
  previousMorphWeights: previous.morph?.weights,
@@ -734,15 +850,14 @@ export class RenderScene {
734
850
  * bounds for its independent per-instance visibility pass. Unknown or
735
851
  * empty instance facts deliberately return undefined (conservative no-cull).
736
852
  */
737
- cullingWorldBounds(snapshot: RenderableSnapshot): RenderSceneBounds | undefined {
738
- let candidate = this.worldBoundsCache.get(snapshot);
739
- if (candidate !== undefined || this.worldBoundsCache.has(snapshot)) {
740
- return candidate ?? undefined;
741
- }
853
+ cullingWorldBoundsAt(slot: RenderSceneSlot): RenderSceneBounds | undefined {
854
+ let candidate = this.worldBoundsBySlot[slot.slot];
855
+ if (candidate !== undefined) return candidate ?? undefined;
856
+ const snapshot = slot.snapshot;
742
857
  if (snapshot.instances !== undefined) {
743
858
  const instanceCount = snapshot.instances.instanceCount;
744
859
  if (instanceCount === 0 || snapshot.localAabb === undefined) {
745
- this.worldBoundsCache.set(snapshot, null);
860
+ this.worldBoundsBySlot[slot.slot] = null;
746
861
  return undefined;
747
862
  }
748
863
  const derived = this.instanceBoundsCache.get({
@@ -757,28 +872,75 @@ export class RenderScene {
757
872
  });
758
873
  candidate = boundsFromArray(derived ?? []);
759
874
  } else {
760
- candidate = worldBounds(snapshot);
875
+ candidate = worldBounds(snapshot, candidate ?? undefined);
761
876
  }
762
- this.worldBoundsCache.set(snapshot, candidate ?? null);
877
+ this.worldBoundsBySlot[slot.slot] = candidate ?? null;
763
878
  return candidate;
764
879
  }
765
- }
766
880
 
767
- function readTransformWorld(world: World, entity: EntityHandle): Float32Array | undefined {
768
- const transform = world.get(entity, Transform);
769
- return transform.ok ? new Float32Array(transform.value.world) : undefined;
881
+ cullingWorldBounds(snapshot: RenderableSnapshot): RenderSceneBounds | undefined {
882
+ const slot = this.lookup(snapshot.worldId, snapshot.entityKey);
883
+ return slot === undefined ? worldBounds(snapshot) : this.cullingWorldBoundsAt(slot);
884
+ }
770
885
  }
771
886
 
772
887
  export interface PersistentRenderSceneOptions {
773
888
  readonly getDevice?: (() => RhiDevice) | undefined;
774
889
  readonly onGpuError?: ((error: RhiError) => void) | undefined;
775
890
  readonly onSharedRefMutation?: ((worldId: number, handle: number) => void) | undefined;
891
+ readonly configuredQueryBudget?: number | undefined;
776
892
  }
777
893
 
778
894
  export interface PersistentGpuDrivenState {
779
895
  readonly scene: GpuScene;
780
896
  readonly plan: SubmissionPlan;
781
897
  readonly slots: readonly RenderSceneSlot[];
898
+ /** Stable renderer-local World keys aligned with RenderSceneSlot.worldId. */
899
+ readonly worldKeys?: readonly number[];
900
+ }
901
+
902
+ export interface CpuDirectViewInput {
903
+ readonly view: ViewKey;
904
+ readonly primitives: readonly PrimitiveKey[];
905
+ }
906
+
907
+ export interface CpuDirectCandidate {
908
+ readonly view: ViewKey;
909
+ readonly primitive: PrimitiveKey;
910
+ readonly candidate: VisibilityCandidate;
911
+ }
912
+
913
+ /**
914
+ * The one renderer-owned submission projection for a display view.
915
+ *
916
+ * Occlusion completion updates the facet asynchronously, so extraction must
917
+ * retain every query candidate while record/prepare consume this projection.
918
+ * Keeping the dispatch reindex here prevents CPU direct and GPU indirect
919
+ * lanes from rebuilding a second visibility map with different identity
920
+ * semantics.
921
+ */
922
+ export interface PersistentVisibilityProjection {
923
+ readonly renderables: readonly RenderableSnapshot[];
924
+ readonly dispatch: readonly DispatchEntry[];
925
+ readonly activeEntityKeys: ReadonlySet<number>;
926
+ /** Monotonic owner revision for exactly this active-key projection. */
927
+ readonly activeEntityRevision: number;
928
+ readonly suppressed: number;
929
+ }
930
+
931
+ /** Project one selected candidate per view and primitive from the facet owner. */
932
+ export function projectCpuDirectCandidates(
933
+ facets: VisibilityFacetStore,
934
+ views: readonly CpuDirectViewInput[],
935
+ ): readonly CpuDirectCandidate[] {
936
+ const candidates: CpuDirectCandidate[] = [];
937
+ for (const { view, primitives } of views) {
938
+ for (const primitive of primitives) {
939
+ const candidate = facets.getCandidate(view, primitive);
940
+ if (candidate !== undefined) candidates.push({ view, primitive, candidate });
941
+ }
942
+ }
943
+ return candidates;
782
944
  }
783
945
 
784
946
  type PersistentGpuSceneInspection = PersistentRenderSceneInspection['gpu'];
@@ -791,16 +953,29 @@ interface PersistentCompositionEntry {
791
953
  readonly projection: RenderScene;
792
954
  readonly topology: BatchTopology;
793
955
  readonly transmissionDemand: PersistentTransmissionDemandProjection;
794
- readonly readCursors: number[];
956
+ readonly structure: Pick<
957
+ ExtractedFrame,
958
+ | 'dispatch'
959
+ | 'visibilityStats'
960
+ | 'visibilitySnapshots'
961
+ | 'featureVisibilitySnapshots'
962
+ | 'hiddenEntityReports'
963
+ >;
964
+ /** Stable renderer-local World keys; unlike RenderableSnapshot.worldId they survive reorder. */
965
+ readonly worldKeys: readonly number[];
966
+ readonly readVersions: RenderReadVersion[];
795
967
  readonly leaseIdentities: readonly string[];
968
+ readonly transformQueries: readonly GlobalTransformChangeQuery[];
796
969
  catalogEpoch: number;
797
970
  readonly rigidOnly: boolean;
798
- gpuDrivenCoverageKey: string | undefined;
799
- gpuDrivenOwnsAll: boolean;
971
+ readonly probeProjection: ProbeBlendSceneProjection;
972
+ temporalDemanded: boolean;
973
+ gpuDrivenSceneRequired: boolean;
800
974
  }
801
975
 
802
976
  const RENDER_RELEVANT_COMPONENT_IDS = new Set([
803
977
  componentId(Transform),
978
+ componentId(GlobalTransform),
804
979
  componentId(ChildOf),
805
980
  componentId(MeshFilter),
806
981
  componentId(MeshRenderer),
@@ -811,7 +986,9 @@ const RENDER_RELEVANT_COMPONENT_IDS = new Set([
811
986
  componentId(Layer),
812
987
  componentId(Visibility),
813
988
  componentId(Camera),
989
+ componentId(MotionBlur),
814
990
  componentId(DirectionalLight),
991
+ componentId(LightProbe),
815
992
  componentId(PointLight),
816
993
  componentId(PointLightShadow),
817
994
  componentId(SpotLight),
@@ -822,12 +999,20 @@ const RENDER_RELEVANT_COMPONENT_IDS = new Set([
822
999
  componentId(VolumetricFog),
823
1000
  ]);
824
1001
 
825
- const NON_RENDERABLE_TRANSFORM_CONSUMERS = [
826
- Camera,
827
- DirectionalLight,
828
- PointLight,
829
- SpotLight,
830
- ] as const;
1002
+ function createGlobalTransformChangeQuery(world: World): GlobalTransformChangeQuery {
1003
+ const result = world.query({ read: [GlobalTransform], changed: [GlobalTransform] });
1004
+ if (!result.ok) throw result.error;
1005
+ const query = result.value as GlobalTransformChangeQuery;
1006
+ for (const _span of query.spans().unwrap()) {
1007
+ // The full rebuild consumed these values; establish the observation baseline.
1008
+ }
1009
+ return query;
1010
+ }
1011
+
1012
+ function frameDemandsTemporal(frame: ExtractedFrame): boolean {
1013
+ const camera = frame.cameras[0];
1014
+ return camera?.antialias === 'taa' || (camera?.motionBlur?.shutterAngle ?? 0) > 0;
1015
+ }
831
1016
 
832
1017
  function cameraFrusta(cameras: readonly CameraSnapshot[]): readonly Float32Array[] {
833
1018
  const planes: Float32Array[] = [];
@@ -864,15 +1049,23 @@ function cameraFrusta(cameras: readonly CameraSnapshot[]): readonly Float32Array
864
1049
  return planes;
865
1050
  }
866
1051
 
1052
+ let cullProjectedIndexScratch = new Int32Array(0);
1053
+
867
1054
  function cullPersistentFrame(
868
1055
  frame: ExtractedFrame,
869
1056
  candidates: readonly RenderableSnapshot[],
870
- boundsOf: (snapshot: RenderableSnapshot) => RenderSceneBounds | undefined = worldBounds,
1057
+ boundsOf: (snapshot: RenderableSnapshot, index: number) => RenderSceneBounds | undefined = (
1058
+ snapshot,
1059
+ ) => worldBounds(snapshot),
871
1060
  ): ExtractedFrame {
872
1061
  const planes = cameraFrusta(frame.cameras);
873
1062
  const visible: RenderableSnapshot[] = [];
874
- const projectedIndex = new Int32Array(candidates.length);
875
- projectedIndex.fill(-1);
1063
+ if (cullProjectedIndexScratch.length < candidates.length) {
1064
+ cullProjectedIndexScratch = new Int32Array(candidates.length);
1065
+ }
1066
+ const projectedIndex = cullProjectedIndexScratch;
1067
+ projectedIndex.fill(-1, 0, candidates.length);
1068
+ const worldAabb = box3.create();
876
1069
  let total = 0;
877
1070
  let culled = 0;
878
1071
 
@@ -880,10 +1073,9 @@ function cullPersistentFrame(
880
1073
  const candidate = candidates[index];
881
1074
  if (candidate === undefined) continue;
882
1075
  let isVisible = true;
883
- const candidateBounds = boundsOf(candidate);
1076
+ const candidateBounds = boundsOf(candidate, index);
884
1077
  if (candidateBounds !== undefined) {
885
1078
  total += 1;
886
- const worldAabb = box3.create();
887
1079
  worldAabb[0] = candidateBounds.min[0] ?? 0;
888
1080
  worldAabb[1] = candidateBounds.min[1] ?? 0;
889
1081
  worldAabb[2] = candidateBounds.min[2] ?? 0;
@@ -906,7 +1098,12 @@ function cullPersistentFrame(
906
1098
  continue;
907
1099
  }
908
1100
  projectedIndex[index] = visible.length;
909
- visible.push(candidate);
1101
+ // The culling candidate is the persistent projection snapshot used for
1102
+ // bounds, while `frame.renderables[index]` may carry frame-owned
1103
+ // attachments (for example the probe blend record). Preserve that
1104
+ // attached renderable in the output; pushing the bare candidate here
1105
+ // silently drops the object-level Probe ABI before record selection.
1106
+ visible.push(frame.renderables[index] ?? candidate);
910
1107
  }
911
1108
 
912
1109
  const dispatch: DispatchEntry[] = [];
@@ -924,8 +1121,33 @@ function cullPersistentFrame(
924
1121
  };
925
1122
  }
926
1123
 
1124
+ function attachProbeRecords(
1125
+ frame: ExtractedFrame,
1126
+ candidates: RenderableSnapshot[],
1127
+ slots: readonly RenderSceneSlot[],
1128
+ projection: ProbeBlendSceneProjection,
1129
+ ): ExtractedFrame {
1130
+ if (!projection.hasRecords()) {
1131
+ return frame.renderables === candidates ? frame : { ...frame, renderables: candidates };
1132
+ }
1133
+ const objectKeys = new Map<string, number>();
1134
+ for (const slot of slots)
1135
+ objectKeys.set(identityKey(slot.worldId, slot.snapshot.entityKey), slot.slot);
1136
+ const renderables = candidates.map((candidate) => {
1137
+ const slot = objectKeys.get(identityKey(candidate.worldId, candidate.entityKey));
1138
+ const record = slot === undefined ? undefined : projection.getRecord(slot);
1139
+ return record === undefined ? candidate : { ...candidate, probeBlendRecord: record };
1140
+ });
1141
+ return { ...frame, renderables };
1142
+ }
1143
+
927
1144
  /** Persistent scene projection used by the renderer's multi-World composition path. */
928
1145
  export class PersistentRenderScene {
1146
+ private readonly visibilityBudget: VisibilityBudget;
1147
+ private readonly visibilityFacets: VisibilityFacetStore;
1148
+ /** World identity is the author/runtime identity; array position is only a frame routing detail. */
1149
+ private readonly visibilityWorldKeys = new Map<string, number>();
1150
+ private nextVisibilityWorldKey = 0;
929
1151
  private readonly materialSnapshotCaches: MaterialSnapshotCachesByWorld = new WeakMap();
930
1152
  private fullRebuilds = 0;
931
1153
  private worldEntitiesScanned = 0;
@@ -942,28 +1164,300 @@ export class PersistentRenderScene {
942
1164
  private temporalCapture:
943
1165
  | { readonly projection: RenderScene; readonly capture: RenderSceneSubmissionCapture }
944
1166
  | undefined;
1167
+ /** Monotonic token for camera/dynamic frame facts consumed by visibility caches. */
1168
+ private frameEpoch = 0;
1169
+ private visibilityFacetFrame:
1170
+ | {
1171
+ readonly frameEpoch: number;
1172
+ readonly viewId: string;
1173
+ readonly renderables: readonly RenderableSnapshot[];
1174
+ }
1175
+ | undefined;
1176
+ private visibilityProjectionCache:
1177
+ | {
1178
+ readonly frameEpoch: number;
1179
+ readonly drawRevision: number;
1180
+ readonly viewId: string;
1181
+ readonly renderables: readonly RenderableSnapshot[];
1182
+ readonly dispatch: readonly DispatchEntry[];
1183
+ readonly projection: PersistentVisibilityProjection;
1184
+ }
1185
+ | undefined;
1186
+ private visibilityProjectionRevision = 0;
1187
+ private lastActiveEntityKeys: ReadonlySet<number> | undefined;
1188
+ /** Stable renderer-world/entity lookup; rebuilt only with composition topology. */
1189
+ private readonly stableSlotByEntity = new Map<number, RenderSceneSlot>();
945
1190
  private pointsLinesInspections: readonly PointsLinesInspection[] = [];
946
1191
  private readonly reflectionProbes = new ReflectionProbeProjection();
947
1192
 
948
- constructor(private readonly options: PersistentRenderSceneOptions = {}) {}
1193
+ constructor(private readonly options: PersistentRenderSceneOptions = {}) {
1194
+ this.visibilityBudget = createVisibilityBudget(options.configuredQueryBudget);
1195
+ this.visibilityFacets = new VisibilityFacetStore(this.visibilityBudget);
1196
+ }
949
1197
 
950
1198
  /** RenderScene owns the per-World material fact cache used during extraction. */
951
1199
  materialSnapshotCacheStore(): MaterialSnapshotCachesByWorld {
952
1200
  return this.materialSnapshotCaches;
953
1201
  }
954
1202
 
1203
+ /** The sole renderer-owned ViewKey x PrimitiveKey visibility state. */
1204
+ visibilityFacetStore(): VisibilityFacetStore {
1205
+ return this.visibilityFacets;
1206
+ }
1207
+
1208
+ visibilityBudgetValue(): VisibilityBudget {
1209
+ return this.visibilityBudget;
1210
+ }
1211
+
1212
+ private visibilityWorldKey(world: World | undefined, fallback: number): number {
1213
+ if (world === undefined) return fallback;
1214
+ const existing = this.visibilityWorldKeys.get(world.identity);
1215
+ if (existing !== undefined) return existing;
1216
+ const key = this.nextVisibilityWorldKey;
1217
+ this.nextVisibilityWorldKey += 1;
1218
+ this.visibilityWorldKeys.set(world.identity, key);
1219
+ return key;
1220
+ }
1221
+
1222
+ private worldKeyAt(worlds: readonly World[], worldId: number): number {
1223
+ return this.visibilityWorldKey(worlds[worldId], worldId);
1224
+ }
1225
+
1226
+ /** Stable World routing keys for the current worlds[] frame projection. */
1227
+ visibilityWorldKeysFor(worlds: readonly World[]): readonly number[] {
1228
+ return worlds.map((world, worldId) => this.visibilityWorldKey(world, worldId));
1229
+ }
1230
+
1231
+ /** Current retained slots, available to CPU query transport as well as GPU. */
1232
+ compositionSlots(): readonly RenderSceneSlot[] {
1233
+ return this.composition?.projection.slotsSnapshot() ?? [];
1234
+ }
1235
+
1236
+ /** Stable renderer-world/entity lookup shared by query and final projection paths. */
1237
+ compositionSlotByStableEntity(): ReadonlyMap<number, RenderSceneSlot> {
1238
+ return this.stableSlotByEntity;
1239
+ }
1240
+
1241
+ private stableEntityKey(
1242
+ worlds: readonly World[],
1243
+ renderable: Pick<RenderableSnapshot, 'worldId' | 'entityKey'>,
1244
+ ): number {
1245
+ return worldEntityKey(this.worldKeyAt(worlds, renderable.worldId), renderable.entityKey);
1246
+ }
1247
+
1248
+ /** Feed the facet from extracted renderables using persistent slot identity. */
1249
+ updateVisibilityFacet(
1250
+ worlds: readonly World[],
1251
+ camera: CameraSnapshot | undefined,
1252
+ renderables: readonly RenderableSnapshot[],
1253
+ ): void {
1254
+ const world = worlds[camera?.worldId ?? 0];
1255
+ if (world === undefined || camera === undefined) return;
1256
+ const view = viewKey({
1257
+ attachmentId: world.identity,
1258
+ cameraEntity: camera.entityKey ?? 0,
1259
+ viewRole: 'main',
1260
+ viewGeneration: camera.historyVersion ?? 0,
1261
+ });
1262
+ const viewId = viewKeyId(view);
1263
+ const previous = this.visibilityFacetFrame;
1264
+ if (
1265
+ previous?.frameEpoch === this.frameEpoch &&
1266
+ previous.viewId === viewId &&
1267
+ previous.renderables === renderables
1268
+ ) {
1269
+ return;
1270
+ }
1271
+ this.visibilityFacets.activateView(view);
1272
+ const activePrimitiveIds = new Set<string>();
1273
+ for (const renderable of renderables) {
1274
+ const bounds = renderable.localAabb;
1275
+ if (
1276
+ renderable.lods === undefined ||
1277
+ renderable.lods.length === 0 ||
1278
+ bounds === undefined ||
1279
+ bounds.length < 6 ||
1280
+ [...bounds].some((value) => !Number.isFinite(value))
1281
+ ) {
1282
+ continue;
1283
+ }
1284
+ const slot = this.stableSlotByEntity.get(this.stableEntityKey(worlds, renderable));
1285
+ if (slot === undefined) continue;
1286
+ const primitive = primitiveKey({
1287
+ attachmentId: world.identity,
1288
+ worldGeneration: this.worldKeyAt(worlds, renderable.worldId),
1289
+ primitiveSlot: slot.slot,
1290
+ slotGeneration: slot.generation,
1291
+ });
1292
+ activePrimitiveIds.add(primitiveKeyId(primitive));
1293
+ this.visibilityFacets.setCandidate(view, primitive, { level: 0, confidence: 1 });
1294
+ }
1295
+ this.visibilityFacets.pruneCandidates(view, activePrimitiveIds);
1296
+ this.visibilityFacetFrame = { frameEpoch: this.frameEpoch, viewId, renderables };
1297
+ }
1298
+
1299
+ /**
1300
+ * Project the current facet into the final primary-raster submission.
1301
+ *
1302
+ * Only renderables carrying authored lower-detail levels are queryable. A
1303
+ * plain mesh therefore remains on the ordinary visibility path, while an
1304
+ * LOD candidate with two accepted zero-sample results is suppressed until
1305
+ * the confidence scheduler requests its bounded re-test. Unknown identity,
1306
+ * invalid bounds, and non-queryable confidence all remain conservatively
1307
+ * visible through `decideVisibility`.
1308
+ */
1309
+ projectVisibility(
1310
+ worlds: readonly World[],
1311
+ camera: CameraSnapshot | undefined,
1312
+ renderables: readonly RenderableSnapshot[],
1313
+ dispatch: readonly DispatchEntry[],
1314
+ ): PersistentVisibilityProjection {
1315
+ if (camera === undefined) {
1316
+ const activeEntityKeys = new Set(
1317
+ renderables.map((renderable) => this.stableEntityKey(worlds, renderable)),
1318
+ );
1319
+ return {
1320
+ renderables,
1321
+ dispatch,
1322
+ activeEntityKeys,
1323
+ activeEntityRevision: this.activeEntityRevision(activeEntityKeys),
1324
+ suppressed: 0,
1325
+ };
1326
+ }
1327
+ const cameraWorld = worlds[camera.worldId ?? 0];
1328
+ if (cameraWorld === undefined) {
1329
+ const activeEntityKeys = new Set(
1330
+ renderables.map((renderable) => this.stableEntityKey(worlds, renderable)),
1331
+ );
1332
+ return {
1333
+ renderables,
1334
+ dispatch,
1335
+ activeEntityKeys,
1336
+ activeEntityRevision: this.activeEntityRevision(activeEntityKeys),
1337
+ suppressed: 0,
1338
+ };
1339
+ }
1340
+ const view = viewKey({
1341
+ attachmentId: cameraWorld.identity,
1342
+ cameraEntity: camera.entityKey ?? 0,
1343
+ viewRole: 'main',
1344
+ viewGeneration: camera.historyVersion ?? 0,
1345
+ });
1346
+ const viewId = viewKeyId(view);
1347
+ const cached = this.visibilityProjectionCache;
1348
+ if (
1349
+ cached?.frameEpoch === this.frameEpoch &&
1350
+ cached.drawRevision === this.visibilityFacets.drawRevisionValue &&
1351
+ cached.viewId === viewId &&
1352
+ cached.renderables === renderables &&
1353
+ cached.dispatch === dispatch
1354
+ ) {
1355
+ return cached.projection;
1356
+ }
1357
+ const projectedIndex = new Int32Array(renderables.length);
1358
+ projectedIndex.fill(-1);
1359
+ const projected: RenderableSnapshot[] = [];
1360
+ const activeEntityKeys = new Set<number>();
1361
+ let suppressed = 0;
1362
+ for (let index = 0; index < renderables.length; index += 1) {
1363
+ const renderable = renderables[index];
1364
+ if (renderable === undefined) continue;
1365
+ const slot = this.stableSlotByEntity.get(this.stableEntityKey(worlds, renderable));
1366
+ let draw = true;
1367
+ if (slot !== undefined && (renderable.lods?.length ?? 0) > 0) {
1368
+ const primitive = primitiveKey({
1369
+ attachmentId: cameraWorld.identity,
1370
+ worldGeneration: this.worldKeyAt(worlds, renderable.worldId),
1371
+ primitiveSlot: slot.slot,
1372
+ slotGeneration: slot.generation,
1373
+ });
1374
+ const bounds = renderable.localAabb;
1375
+ const validBounds =
1376
+ bounds !== undefined &&
1377
+ bounds.length >= 6 &&
1378
+ [...bounds].every((value) => Number.isFinite(value));
1379
+ draw = decideVisibility({
1380
+ authorVisible: true,
1381
+ validBounds,
1382
+ frustumVisible: true,
1383
+ lodReady: true,
1384
+ occlusion: this.visibilityFacets.getConfidence(view, primitive),
1385
+ lane: 'gpu',
1386
+ }).draw;
1387
+ }
1388
+ if (!draw) {
1389
+ suppressed += 1;
1390
+ continue;
1391
+ }
1392
+ projectedIndex[index] = projected.length;
1393
+ projected.push(renderable);
1394
+ activeEntityKeys.add(this.stableEntityKey(worlds, renderable));
1395
+ }
1396
+ const projectedDispatch: DispatchEntry[] = [];
1397
+ for (const entry of dispatch) {
1398
+ const renderableIndex = projectedIndex[entry.renderableIndex];
1399
+ if (renderableIndex === undefined || renderableIndex < 0) continue;
1400
+ projectedDispatch.push({ ...entry, renderableIndex });
1401
+ }
1402
+ const projection = {
1403
+ renderables: Object.freeze(projected),
1404
+ dispatch: Object.freeze(projectedDispatch),
1405
+ activeEntityKeys,
1406
+ activeEntityRevision: this.activeEntityRevision(activeEntityKeys),
1407
+ suppressed,
1408
+ };
1409
+ this.visibilityProjectionCache = {
1410
+ frameEpoch: this.frameEpoch,
1411
+ drawRevision: this.visibilityFacets.drawRevisionValue,
1412
+ viewId,
1413
+ renderables,
1414
+ dispatch,
1415
+ projection,
1416
+ };
1417
+ return projection;
1418
+ }
1419
+
1420
+ /**
1421
+ * Advance the projection revision only when the active stable-entity set
1422
+ * changes. Camera fingerprints and scene-plan identity cover view/topology
1423
+ * changes; keeping this token set-stable preserves the GPU filtered-plan
1424
+ * cache on ordinary no-change frames.
1425
+ */
1426
+ private activeEntityRevision(activeEntityKeys: ReadonlySet<number>): number {
1427
+ const previous = this.lastActiveEntityKeys;
1428
+ if (
1429
+ previous === undefined ||
1430
+ previous.size !== activeEntityKeys.size ||
1431
+ [...previous].some((key) => !activeEntityKeys.has(key))
1432
+ ) {
1433
+ this.lastActiveEntityKeys = new Set(activeEntityKeys);
1434
+ this.visibilityProjectionRevision += 1;
1435
+ }
1436
+ return this.visibilityProjectionRevision;
1437
+ }
1438
+
1439
+ private invalidateActiveEntityRevision(): void {
1440
+ this.lastActiveEntityKeys = undefined;
1441
+ this.visibilityProjectionRevision += 1;
1442
+ }
1443
+
955
1444
  /** Project probe facts and per-primitive selection into the persistent scene owner. */
956
1445
  projectReflectionProbes(probes: readonly ReflectionProbeFact[]): void {
957
1446
  const projection = this.composition?.projection;
958
1447
  if (projection === undefined) return;
1448
+ if (probes.length === 0) {
1449
+ this.reflectionProbes.update([], []);
1450
+ return;
1451
+ }
959
1452
  const primitives = projection
960
1453
  .slotsSnapshot()
961
1454
  .map((slot) => {
962
- const resolved = worldBounds(slot.snapshot);
1455
+ const resolved = projection.cullingWorldBoundsAt(slot);
963
1456
  if (resolved === undefined || resolved === null) return undefined;
964
1457
  return {
965
1458
  worldId: slot.worldId,
966
1459
  entityKey: slot.entityKey,
1460
+ renderableKey: `${slot.worldId}:${slot.entityKey}`,
967
1461
  center: [
968
1462
  (resolved.min[0] + resolved.max[0]) / 2,
969
1463
  (resolved.min[1] + resolved.max[1]) / 2,
@@ -987,11 +1481,32 @@ export class PersistentRenderScene {
987
1481
  worlds: readonly World[],
988
1482
  owner: { readonly cameraOwner: number; readonly resourceOwner: number },
989
1483
  catalogEpoch: number,
990
- buildCandidateFrame: () => ExtractedFrame,
1484
+ buildCandidateFrame: (renderables: 'full' | 'none') => ExtractedFrame,
991
1485
  leases?: readonly RenderReadLease[],
992
1486
  ): ExtractedFrame {
993
1487
  this.worldEntitiesScanned = 0;
994
1488
  const entry = this.composition;
1489
+ const sameWorldSet =
1490
+ entry !== undefined &&
1491
+ entry.worlds.length === worlds.length &&
1492
+ entry.worlds.every((world) => worlds.includes(world));
1493
+ const worldOrderChanged =
1494
+ entry !== undefined &&
1495
+ sameWorldSet &&
1496
+ entry.worlds.some((world, index) => world !== worlds[index]);
1497
+ const leaseSetPreserved =
1498
+ entry !== undefined &&
1499
+ leases !== undefined &&
1500
+ leases.length === worlds.length &&
1501
+ entry.leaseIdentities.length === leases.length &&
1502
+ entry.leaseIdentities.every((identity) =>
1503
+ leases.some((lease) => lease.worldIdentity === identity),
1504
+ );
1505
+ const preserveVisibilityOnReorder =
1506
+ entry !== undefined &&
1507
+ worldOrderChanged &&
1508
+ entry.catalogEpoch === catalogEpoch &&
1509
+ leaseSetPreserved;
995
1510
  if (
996
1511
  entry === undefined ||
997
1512
  entry.catalogEpoch !== catalogEpoch ||
@@ -1002,13 +1517,19 @@ export class PersistentRenderScene {
1002
1517
  leases === undefined ||
1003
1518
  leases.length !== worlds.length
1004
1519
  ) {
1005
- return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1520
+ return this.rebuildComposition(
1521
+ worlds,
1522
+ owner,
1523
+ catalogEpoch,
1524
+ buildCandidateFrame,
1525
+ leases,
1526
+ preserveVisibilityOnReorder,
1527
+ );
1006
1528
  }
1007
1529
  if (!entry.rigidOnly) {
1008
1530
  return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1009
1531
  }
1010
1532
 
1011
- const operations: RenderSceneOperation[] = [];
1012
1533
  let requiresRebuild = false;
1013
1534
  for (let worldId = 0; worldId < worlds.length; worldId += 1) {
1014
1535
  const world = worlds[worldId];
@@ -1018,14 +1539,19 @@ export class PersistentRenderScene {
1018
1539
  requiresRebuild = true;
1019
1540
  break;
1020
1541
  }
1542
+ const version = entry.readVersions[worldId];
1543
+ if (version === undefined) {
1544
+ requiresRebuild = true;
1545
+ break;
1546
+ }
1021
1547
  let read: ReturnType<RenderReadLease['readChanges']>;
1022
1548
  try {
1023
- read = lease.readChanges(entry.readCursors[worldId] ?? 0);
1549
+ read = lease.readChanges(version);
1024
1550
  } catch {
1025
1551
  requiresRebuild = true;
1026
1552
  break;
1027
1553
  }
1028
- if (read.status === 'overflow') {
1554
+ if (read.status !== 'ok') {
1029
1555
  requiresRebuild = true;
1030
1556
  break;
1031
1557
  }
@@ -1038,119 +1564,86 @@ export class PersistentRenderScene {
1038
1564
  requiresRebuild = true;
1039
1565
  break;
1040
1566
  }
1041
- const derivedTransforms = new Set<EntityHandle>();
1042
- for (const record of changes.records) {
1043
- if (
1044
- record.kind === 'derived-component-changed' &&
1045
- record.componentId === componentId(Transform)
1046
- ) {
1047
- derivedTransforms.add(record.entity as EntityHandle);
1048
- }
1049
- }
1050
- for (const record of changes.records) {
1051
- if (
1052
- record.kind !== 'entity-removed' &&
1053
- record.componentId !== undefined &&
1054
- !RENDER_RELEVANT_COMPONENT_IDS.has(record.componentId)
1055
- ) {
1056
- continue;
1057
- }
1058
- const authoredTransform =
1059
- record.kind === 'component-changed' && record.componentId === componentId(Transform);
1060
- const derivedTransform =
1061
- record.kind === 'derived-component-changed' &&
1062
- record.componentId === componentId(Transform);
1063
- if (derivedTransform) continue;
1064
- if (authoredTransform && derivedTransforms.has(record.entity as EntityHandle)) continue;
1567
+ for (const changedComponentId of changes.changedComponentIds) {
1568
+ if (!RENDER_RELEVANT_COMPONENT_IDS.has(changedComponentId)) continue;
1065
1569
  if (
1066
- (authoredTransform || record.componentId === componentId(Transform)) &&
1067
- entry.projection.has(worldId, record.entity)
1570
+ changedComponentId !== componentId(Transform) &&
1571
+ changedComponentId !== componentId(GlobalTransform)
1068
1572
  ) {
1069
- const worldView = readTransformWorld(world, record.entity as EntityHandle);
1070
- if (worldView === undefined) {
1071
- requiresRebuild = true;
1072
- break;
1073
- }
1074
- operations.push({
1075
- kind: 'update-transform',
1076
- worldId,
1077
- entityKey: record.entity,
1078
- world: new Float32Array(worldView),
1079
- });
1080
- continue;
1573
+ requiresRebuild = true;
1574
+ break;
1081
1575
  }
1082
- requiresRebuild = true;
1083
- break;
1084
1576
  }
1085
- if (!requiresRebuild) {
1086
- for (const entity of derivedTransforms) {
1087
- if (entry.projection.has(worldId, entity)) {
1088
- const worldView = readTransformWorld(world, entity);
1089
- if (worldView === undefined) {
1090
- requiresRebuild = true;
1091
- break;
1092
- }
1093
- operations.push({
1094
- kind: 'update-transform',
1095
- worldId,
1096
- entityKey: entity,
1097
- world: new Float32Array(worldView),
1098
- });
1099
- continue;
1100
- }
1101
- // Camera snapshots are rebuilt from the current candidate frame and
1102
- // drive culling there; moving a camera must not invalidate the
1103
- // retained scene projection or recreate its GPU tables.
1104
- if (
1105
- !world.hasComponent(entity, Camera) &&
1106
- NON_RENDERABLE_TRANSFORM_CONSUMERS.some((component) =>
1107
- world.hasComponent(entity, component),
1108
- )
1109
- ) {
1110
- requiresRebuild = true;
1111
- break;
1112
- }
1577
+ if (requiresRebuild) break;
1578
+ entry.readVersions[worldId] = read.version;
1579
+ }
1580
+ if (requiresRebuild) {
1581
+ return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1582
+ }
1583
+ const transformSpans: WorldTransformSpan[] = [];
1584
+ for (let worldId = 0; worldId < entry.transformQueries.length; worldId += 1) {
1585
+ const query = entry.transformQueries[worldId];
1586
+ if (query === undefined) continue;
1587
+ for (const span of query.spans().unwrap()) {
1588
+ const changedWorlds = span.get(GlobalTransform).world;
1589
+ if (!(changedWorlds instanceof Float32Array)) {
1590
+ requiresRebuild = true;
1591
+ break;
1113
1592
  }
1593
+ transformSpans.push({ worldId, entities: span.entities, worlds: changedWorlds });
1114
1594
  }
1115
1595
  if (requiresRebuild) break;
1116
- entry.readCursors[worldId] = read.cursor;
1117
1596
  }
1118
1597
  if (requiresRebuild) {
1119
1598
  return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1120
1599
  }
1121
- if (operations.length === 0) {
1600
+ if (transformSpans.length === 0) {
1122
1601
  this.noChangeFrames += 1;
1123
1602
  this.syncGpuOwner(entry.token, entry.projection);
1124
1603
  return this.deriveFramePlan(entry, buildCandidateFrame);
1125
1604
  }
1126
- const delta = entry.projection.apply(operations);
1605
+ const delta = entry.projection.applyTransformSpans(transformSpans);
1127
1606
  entry.topology.apply(delta);
1128
1607
  this.syncGpuOwner(entry.token, entry.projection, delta);
1129
1608
  this.deltaFrames += 1;
1130
- this.transformUpdates += operations.length;
1609
+ this.transformUpdates += delta.updated;
1131
1610
  return this.deriveFramePlan(entry, buildCandidateFrame);
1132
1611
  }
1133
1612
 
1134
1613
  invalidate(): void {
1135
1614
  this.composition = undefined;
1136
1615
  this.temporalCapture = undefined;
1616
+ this.visibilityFacets.clear();
1617
+ this.invalidateActiveEntityRevision();
1618
+ this.visibilityFacetFrame = undefined;
1619
+ this.visibilityProjectionCache = undefined;
1620
+ this.stableSlotByEntity.clear();
1137
1621
  this.lastResyncReason = 'explicit-invalidate';
1138
1622
  }
1139
1623
 
1140
- setCompositionGpuDrivenCoverage(key: string, ownsAll: boolean): void {
1624
+ setCompositionGpuSceneDemand(sceneRowsRequired: boolean): void {
1141
1625
  const entry = this.composition;
1142
1626
  if (entry === undefined) return;
1143
- entry.gpuDrivenCoverageKey = key;
1144
- entry.gpuDrivenOwnsAll = ownsAll;
1627
+ entry.gpuDrivenSceneRequired = sceneRowsRequired;
1145
1628
  }
1146
1629
 
1147
1630
  detach(world: World): void {
1631
+ this.visibilityFacets.detachAttachment(world.identity);
1148
1632
  this.materialSnapshotCaches.delete(world);
1149
1633
  const detachedComposition = this.composition?.worlds.includes(world)
1150
1634
  ? this.composition
1151
1635
  : undefined;
1152
1636
  if (detachedComposition !== undefined) this.composition = undefined;
1153
1637
  if (detachedComposition !== undefined) this.temporalCapture = undefined;
1638
+ if (detachedComposition !== undefined) {
1639
+ this.visibilityFacets.clear();
1640
+ this.invalidateActiveEntityRevision();
1641
+ }
1642
+ if (detachedComposition !== undefined) {
1643
+ this.visibilityFacetFrame = undefined;
1644
+ this.visibilityProjectionCache = undefined;
1645
+ this.stableSlotByEntity.clear();
1646
+ }
1154
1647
  if (this.gpuOwner !== world && this.gpuOwner !== detachedComposition?.token) return;
1155
1648
  this.gpuScene?.dispose();
1156
1649
  this.gpuScene = undefined;
@@ -1161,7 +1654,7 @@ export class PersistentRenderScene {
1161
1654
 
1162
1655
  /** Drop only device-owned tables; the CPU projection remains the recovery authority. */
1163
1656
  resetGpuForRecover(): void {
1164
- if (this.composition !== undefined) this.composition.gpuDrivenOwnsAll = false;
1657
+ if (this.composition !== undefined) this.composition.gpuDrivenSceneRequired = true;
1165
1658
  this.gpuScene = undefined;
1166
1659
  this.gpuOwner = undefined;
1167
1660
  this.gpuDevice = undefined;
@@ -1184,6 +1677,11 @@ export class PersistentRenderScene {
1184
1677
  this.gpuStatus = 'inactive';
1185
1678
  this.composition = undefined;
1186
1679
  this.temporalCapture = undefined;
1680
+ this.visibilityFacets.clear();
1681
+ this.invalidateActiveEntityRevision();
1682
+ this.visibilityFacetFrame = undefined;
1683
+ this.visibilityProjectionCache = undefined;
1684
+ this.stableSlotByEntity.clear();
1187
1685
  this.pointsLinesInspections = [];
1188
1686
  }
1189
1687
 
@@ -1198,6 +1696,7 @@ export class PersistentRenderScene {
1198
1696
  transformUpdates: this.transformUpdates,
1199
1697
  lastResyncReason: this.lastResyncReason,
1200
1698
  projectionRecords: entry?.projection.inspect().records.length ?? 0,
1699
+ ...(entry === undefined ? {} : { probeBlend: entry.probeProjection.inspect() }),
1201
1700
  topology:
1202
1701
  entry?.topology.inspect() ??
1203
1702
  ({
@@ -1222,12 +1721,15 @@ export class PersistentRenderScene {
1222
1721
  scene: this.gpuScene,
1223
1722
  plan: entry.topology.plan(),
1224
1723
  slots: entry.projection.slotsSnapshot(),
1724
+ worldKeys: entry.worldKeys,
1225
1725
  };
1226
1726
  }
1227
1727
 
1228
1728
  /** Publish renderer-owned previous transforms after queue submission. */
1229
1729
  commitTemporalFrame(): Result<void, RhiError> {
1230
- const gpuCommit = this.gpuScene?.commitTemporalFrame();
1730
+ // Previous transforms are consumed only by TAA and motion blur. Ordinary
1731
+ // frames must not copy and upload the complete GPU transform table.
1732
+ const gpuCommit = this.gpuScene?.commitTemporalFrame(this.temporalCapture !== undefined);
1231
1733
  if (gpuCommit !== undefined && !gpuCommit.ok) return gpuCommit;
1232
1734
  const temporalCapture = this.temporalCapture;
1233
1735
  if (temporalCapture !== undefined) {
@@ -1279,19 +1781,76 @@ export class PersistentRenderScene {
1279
1781
  worlds: readonly World[],
1280
1782
  owner: { readonly cameraOwner: number; readonly resourceOwner: number },
1281
1783
  catalogEpoch: number,
1282
- buildCandidateFrame: () => ExtractedFrame,
1784
+ buildCandidateFrame: (renderables: 'full' | 'none') => ExtractedFrame,
1283
1785
  leases?: readonly RenderReadLease[],
1786
+ preserveVisibilityOnReorder = false,
1284
1787
  ): ExtractedFrame {
1285
- const candidateFrame = buildCandidateFrame();
1788
+ const previousEntry = this.composition;
1789
+ const candidateFrame = buildCandidateFrame('full');
1286
1790
  this.temporalCapture = undefined;
1287
1791
  this.worldEntitiesScanned = candidateFrame.renderables.length;
1792
+ const worldKeys = this.visibilityWorldKeysFor(worlds);
1793
+ if (!preserveVisibilityOnReorder) {
1794
+ this.visibilityFacets.clear();
1795
+ this.invalidateActiveEntityRevision();
1796
+ } else if (previousEntry !== undefined) {
1797
+ const previousKeys = new Set(
1798
+ previousEntry.projection
1799
+ .slotsSnapshot()
1800
+ .map((slot) =>
1801
+ worldEntityKey(previousEntry.worldKeys[slot.worldId] ?? slot.worldId, slot.entityKey),
1802
+ ),
1803
+ );
1804
+ const currentKeys = new Set(
1805
+ candidateFrame.renderables.map((renderable) =>
1806
+ worldEntityKey(worldKeys[renderable.worldId] ?? renderable.worldId, renderable.entityKey),
1807
+ ),
1808
+ );
1809
+ if (
1810
+ previousKeys.size !== currentKeys.size ||
1811
+ [...previousKeys].some((key) => !currentKeys.has(key))
1812
+ ) {
1813
+ this.visibilityFacets.clear();
1814
+ this.invalidateActiveEntityRevision();
1815
+ }
1816
+ }
1817
+ // Slot allocation is renderer-owned identity. Sort by stable World identity
1818
+ // before allocating so a worlds[] reorder does not silently change a
1819
+ // primitive's slot/generation. Dispatch is remapped below to the sorted
1820
+ // renderable projection and remains frame-order independent.
1821
+ const sorted = candidateFrame.renderables
1822
+ .map((snapshot, sourceIndex) => ({ snapshot, sourceIndex }))
1823
+ .sort((left, right) => {
1824
+ const leftWorld = worldKeys[left.snapshot.worldId] ?? left.snapshot.worldId;
1825
+ const rightWorld = worldKeys[right.snapshot.worldId] ?? right.snapshot.worldId;
1826
+ return leftWorld - rightWorld || left.snapshot.entityKey - right.snapshot.entityKey;
1827
+ });
1828
+ const sortedRenderables = sorted.map(({ snapshot }) => snapshot);
1829
+ const renderableIndex = new Int32Array(candidateFrame.renderables.length);
1830
+ for (let index = 0; index < sorted.length; index += 1) {
1831
+ const sourceIndex = sorted[index]?.sourceIndex;
1832
+ if (sourceIndex !== undefined) renderableIndex[sourceIndex] = index;
1833
+ }
1834
+ const sortedDispatch = candidateFrame.dispatch.flatMap((entry) => {
1835
+ const index = renderableIndex[entry.renderableIndex];
1836
+ return index === undefined ? [] : [{ ...entry, renderableIndex: index }];
1837
+ });
1838
+ const stableCandidateFrame = {
1839
+ ...candidateFrame,
1840
+ renderables: sortedRenderables,
1841
+ dispatch: sortedDispatch,
1842
+ };
1288
1843
  const projection = new RenderScene();
1289
- projection.reset(candidateFrame.renderables);
1844
+ projection.reset(sortedRenderables);
1845
+ this.stableSlotByEntity.clear();
1846
+ for (const slot of projection.slotsSnapshot()) {
1847
+ const stableWorld = worldKeys[slot.worldId] ?? slot.worldId;
1848
+ this.stableSlotByEntity.set(worldEntityKey(stableWorld, slot.entityKey), slot);
1849
+ }
1290
1850
  const transmissionDemand = new PersistentTransmissionDemandProjection();
1291
1851
  transmissionDemand.apply(transmissionDemandOperations(candidateFrame.renderables));
1292
1852
  const topology = new BatchTopology();
1293
1853
  topology.rebuild(projection.slotsSnapshot());
1294
- this.projectReflectionProbes(candidateFrame.reflectionProbes ?? []);
1295
1854
  const token = {};
1296
1855
  const entry: PersistentCompositionEntry = {
1297
1856
  token,
@@ -1301,31 +1860,69 @@ export class PersistentRenderScene {
1301
1860
  projection,
1302
1861
  topology,
1303
1862
  transmissionDemand,
1304
- readCursors:
1863
+ structure: {
1864
+ dispatch: stableCandidateFrame.dispatch,
1865
+ visibilityStats: stableCandidateFrame.visibilityStats,
1866
+ visibilitySnapshots: stableCandidateFrame.visibilitySnapshots,
1867
+ featureVisibilitySnapshots: stableCandidateFrame.featureVisibilitySnapshots,
1868
+ hiddenEntityReports: stableCandidateFrame.hiddenEntityReports,
1869
+ },
1870
+ worldKeys,
1871
+ readVersions:
1305
1872
  leases === undefined || leases.length !== worlds.length
1306
1873
  ? []
1307
- : leases.map((lease) => lease.inspectCursor()),
1874
+ : leases.map((lease) => lease.captureVersion()),
1308
1875
  leaseIdentities:
1309
1876
  leases === undefined || leases.length !== worlds.length
1310
1877
  ? []
1311
1878
  : leases.map((lease) => lease.worldIdentity),
1879
+ transformQueries:
1880
+ leases === undefined || leases.length !== worlds.length
1881
+ ? []
1882
+ : worlds.map(createGlobalTransformChangeQuery),
1312
1883
  catalogEpoch,
1313
1884
  rigidOnly: candidateFrame.renderables.every((renderable) => renderable.skin === undefined),
1314
- gpuDrivenCoverageKey: undefined,
1315
- gpuDrivenOwnsAll: false,
1885
+ probeProjection: new ProbeBlendSceneProjection(),
1886
+ temporalDemanded: frameDemandsTemporal(candidateFrame),
1887
+ gpuDrivenSceneRequired: true,
1316
1888
  };
1317
1889
  this.composition = entry;
1890
+ projection.setTemporalTracking(entry.temporalDemanded);
1891
+ // The projection must be installed before deriving probe primitives. On
1892
+ // the first composition rebuild `projectReflectionProbes` otherwise sees
1893
+ // no composition and silently drops every per-renderable selection,
1894
+ // leaving the producer on its neutral/no-demand path until an unrelated
1895
+ // later rebuild.
1896
+ this.projectReflectionProbes(candidateFrame.reflectionProbes ?? []);
1897
+ this.projectProbes(entry, candidateFrame);
1318
1898
  this.syncGpuOwner(token, projection, undefined, true);
1319
1899
  this.fullRebuilds += 1;
1320
1900
  this.lastResyncReason = 'attach';
1321
- const candidates = projection
1322
- .materialize()
1323
- .map((snapshot) => projection.withTemporal(snapshot));
1324
- return cullPersistentFrame(
1325
- { ...candidateFrame, renderables: [...candidates] },
1901
+ const materialized = projection.materialize();
1902
+ const candidates = entry.temporalDemanded
1903
+ ? materialized.map((snapshot) => projection.withTemporal(snapshot))
1904
+ : materialized;
1905
+ const frameWithProbes = attachProbeRecords(
1906
+ { ...stableCandidateFrame, renderables: candidates },
1326
1907
  candidates,
1327
- (snapshot) => projection.cullingWorldBounds(snapshot),
1908
+ projection.slotsSnapshot(),
1909
+ entry.probeProjection,
1910
+ );
1911
+ const slots = projection.slotsSnapshot();
1912
+ const result = cullPersistentFrame(
1913
+ frameWithProbes,
1914
+ frameWithProbes.renderables,
1915
+ (snapshot, index) => {
1916
+ const slot = slots[index];
1917
+ return slot === undefined
1918
+ ? projection.cullingWorldBounds(snapshot)
1919
+ : projection.cullingWorldBoundsAt(slot);
1920
+ },
1328
1921
  );
1922
+ this.frameEpoch += 1;
1923
+ this.visibilityFacetFrame = undefined;
1924
+ this.visibilityProjectionCache = undefined;
1925
+ return result;
1329
1926
  }
1330
1927
 
1331
1928
  /** Internal topology fact; the renderer does not expose demand as public API. */
@@ -1340,22 +1937,89 @@ export class PersistentRenderScene {
1340
1937
 
1341
1938
  /**
1342
1939
  * Derive an ephemeral frame plan from persistent scene facts. The scene
1343
- * retains identity/topology/cursors; no prior frame object is an authority.
1940
+ * retains identity/topology/read versions; no prior frame object is an authority.
1344
1941
  */
1345
1942
  private deriveFramePlan(
1346
1943
  entry: PersistentCompositionEntry,
1347
- buildCandidateFrame: () => ExtractedFrame,
1944
+ buildCandidateFrame: (renderables: 'full' | 'none') => ExtractedFrame,
1348
1945
  ): ExtractedFrame {
1349
- const candidateFrame = buildCandidateFrame();
1946
+ const resourceFrame = buildCandidateFrame('none');
1947
+ const candidateFrame: ExtractedFrame = { ...resourceFrame, ...entry.structure };
1948
+ entry.temporalDemanded = frameDemandsTemporal(candidateFrame);
1949
+ entry.projection.setTemporalTracking(entry.temporalDemanded);
1950
+ this.projectProbes(entry, candidateFrame);
1350
1951
  this.projectReflectionProbes(candidateFrame.reflectionProbes ?? []);
1351
- const candidates = entry.projection
1352
- .materialize()
1353
- .map((snapshot) => entry.projection.withTemporal(snapshot));
1354
- return cullPersistentFrame(
1355
- { ...candidateFrame, renderables: [...candidates] },
1952
+ const materialized = entry.projection.materialize();
1953
+ const candidates = entry.temporalDemanded
1954
+ ? materialized.map((snapshot) => entry.projection.withTemporal(snapshot))
1955
+ : materialized;
1956
+ const frameWithProbes = attachProbeRecords(
1957
+ { ...candidateFrame, renderables: candidates },
1356
1958
  candidates,
1357
- (snapshot) => entry.projection.cullingWorldBounds(snapshot),
1959
+ entry.projection.slotsSnapshot(),
1960
+ entry.probeProjection,
1961
+ );
1962
+ const slots = entry.projection.slotsSnapshot();
1963
+ const result = cullPersistentFrame(
1964
+ frameWithProbes,
1965
+ frameWithProbes.renderables,
1966
+ (snapshot, index) => {
1967
+ const slot = slots[index];
1968
+ return slot === undefined
1969
+ ? entry.projection.cullingWorldBounds(snapshot)
1970
+ : entry.projection.cullingWorldBoundsAt(slot);
1971
+ },
1358
1972
  );
1973
+ this.frameEpoch += 1;
1974
+ this.visibilityFacetFrame = undefined;
1975
+ this.visibilityProjectionCache = undefined;
1976
+ return result;
1977
+ }
1978
+
1979
+ private projectProbes(entry: PersistentCompositionEntry, frame: ExtractedFrame): void {
1980
+ const sky =
1981
+ frame.skylight === undefined
1982
+ ? {
1983
+ available: false,
1984
+ irradiance: [0, 0, 0] as const,
1985
+ fallbackReason: 'no-skylight',
1986
+ }
1987
+ : {
1988
+ available: true,
1989
+ identity: `skylight:${frame.skylight.entityHandle}`,
1990
+ sourceKey:
1991
+ frame.skylight.equirectHandle > 0
1992
+ ? `equirect:${frame.skylight.equirectHandle}`
1993
+ : `skylight:${frame.skylight.entityHandle}`,
1994
+ irradiance: [
1995
+ frame.skylight.color[0] * frame.skylight.intensity,
1996
+ frame.skylight.color[1] * frame.skylight.intensity,
1997
+ frame.skylight.color[2] * frame.skylight.intensity,
1998
+ ] as [number, number, number],
1999
+ };
2000
+ const probes = frame.lightProbes ?? [];
2001
+ // Sky is evaluated by the global environment path. With no local light
2002
+ // probes there can be no per-object ProbeBlendRecord, regardless of how
2003
+ // many dynamic objects moved this frame. Preserve the projection call so
2004
+ // a transition from probes to no probes still clears retained records.
2005
+ if (probes.length === 0) {
2006
+ entry.probeProjection.apply({ objects: [], probes, sky });
2007
+ return;
2008
+ }
2009
+ const objects: ProbeSceneObjectInput[] = entry.projection.slotsSnapshot().map((slot) => ({
2010
+ objectKey: slot.slot,
2011
+ generation: slot.generation,
2012
+ position: [
2013
+ slot.snapshot.transform.world[12] ?? 0,
2014
+ slot.snapshot.transform.world[13] ?? 0,
2015
+ slot.snapshot.transform.world[14] ?? 0,
2016
+ ],
2017
+ }));
2018
+ entry.probeProjection.apply({
2019
+ objects,
2020
+ probes,
2021
+ sky,
2022
+ });
1359
2023
  }
1360
2024
 
1361
2025
  private syncGpuOwner(
@@ -1364,6 +2028,7 @@ export class PersistentRenderScene {
1364
2028
  delta?: RenderSceneApplyResult,
1365
2029
  full = false,
1366
2030
  ): void {
2031
+ if (this.composition?.token === owner && !this.composition.gpuDrivenSceneRequired) return;
1367
2032
  const acquired = this.acquireGpuScene(owner, projection);
1368
2033
  if (acquired === undefined || acquired.rebuilt) return;
1369
2034
  const result = full