@forgeax/engine-render 0.1.32 → 0.1.34

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 (624) hide show
  1. package/README.md +1374 -142
  2. package/dist/assembly/analytic-fog-registration.d.ts +3 -0
  3. package/dist/assembly/analytic-fog-registration.d.ts.map +1 -0
  4. package/dist/assembly/backend-contract.d.ts +2 -0
  5. package/dist/assembly/backend-contract.d.ts.map +1 -1
  6. package/dist/assembly/barrel-distortion-inspection.d.ts +3 -0
  7. package/dist/assembly/barrel-distortion-inspection.d.ts.map +1 -0
  8. package/dist/assembly/canvas-draw-guard.d.ts +3 -0
  9. package/dist/assembly/canvas-draw-guard.d.ts.map +1 -0
  10. package/dist/assembly/cloud-shadow-entry.d.ts +4 -0
  11. package/dist/assembly/cloud-shadow-entry.d.ts.map +1 -0
  12. package/dist/assembly/dynamic-geometry-host.d.ts +4 -1
  13. package/dist/assembly/dynamic-geometry-host.d.ts.map +1 -1
  14. package/dist/assembly/dynamic-geometry-runtime.d.ts +1 -1
  15. package/dist/assembly/dynamic-geometry-runtime.d.ts.map +1 -1
  16. package/dist/assembly/factory.d.ts +1 -1
  17. package/dist/assembly/factory.d.ts.map +1 -1
  18. package/dist/assembly/fxaa-registration.d.ts +4 -0
  19. package/dist/assembly/fxaa-registration.d.ts.map +1 -0
  20. package/dist/assembly/host-contract.d.ts +3 -3
  21. package/dist/assembly/host-contract.d.ts.map +1 -1
  22. package/dist/assembly/lod-observation.d.ts +4 -0
  23. package/dist/assembly/lod-observation.d.ts.map +1 -0
  24. package/dist/assembly/material/assembly.d.ts.map +1 -1
  25. package/dist/assembly/material/pipeline-helpers.d.ts +5 -0
  26. package/dist/assembly/material/pipeline-helpers.d.ts.map +1 -1
  27. package/dist/assembly/material-shader-policy.d.ts +16 -2
  28. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  29. package/dist/assembly/material-shader-prewarm.d.ts.map +1 -1
  30. package/dist/assembly/motion-blur-registration.d.ts +3 -0
  31. package/dist/assembly/motion-blur-registration.d.ts.map +1 -0
  32. package/dist/assembly/recovery/device-loss-fanout.d.ts.map +1 -1
  33. package/dist/assembly/recovery/renderer-recover.d.ts +2 -0
  34. package/dist/assembly/recovery/renderer-recover.d.ts.map +1 -1
  35. package/dist/assembly/render-feature-post-processes.d.ts +6 -0
  36. package/dist/assembly/render-feature-post-processes.d.ts.map +1 -0
  37. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  38. package/dist/assembly/renderer-feature-inspection.d.ts +1 -1
  39. package/dist/assembly/renderer-feature-inspection.d.ts.map +1 -1
  40. package/dist/assembly/renderer-frame-transaction.d.ts +12 -0
  41. package/dist/assembly/renderer-frame-transaction.d.ts.map +1 -1
  42. package/dist/assembly/renderer-inspection.d.ts +11 -0
  43. package/dist/assembly/renderer-inspection.d.ts.map +1 -1
  44. package/dist/assembly/temporal-post-process-prewarm.d.ts +13 -0
  45. package/dist/assembly/temporal-post-process-prewarm.d.ts.map +1 -0
  46. package/dist/assembly/webgpu-pbr-ready.d.ts +1 -0
  47. package/dist/assembly/webgpu-pbr-ready.d.ts.map +1 -1
  48. package/dist/assembly/webgpu-ready-contract.d.ts +4 -3
  49. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -1
  50. package/dist/assembly/webgpu-ready-per-pass.d.ts +39 -0
  51. package/dist/assembly/webgpu-ready-per-pass.d.ts.map +1 -0
  52. package/dist/assembly/webgpu-ready.d.ts +2 -2
  53. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  54. package/dist/assembly/webgpu-renderer-bootstrap.d.ts.map +1 -1
  55. package/dist/assembly/webgpu-renderer-observation-owner.d.ts +35 -0
  56. package/dist/assembly/webgpu-renderer-observation-owner.d.ts.map +1 -0
  57. package/dist/assembly/webgpu-renderer.d.ts +1 -1
  58. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  59. package/dist/authoring.mjs +1 -1
  60. package/dist/barrel-distortion.d.ts +47 -0
  61. package/dist/barrel-distortion.d.ts.map +1 -0
  62. package/dist/bloom-admission.d.ts +13 -0
  63. package/dist/bloom-admission.d.ts.map +1 -0
  64. package/dist/{chunk-PA5DRJNI.mjs → chunk-ELJFJ2BU.mjs} +2 -2
  65. package/dist/chunk-ELJFJ2BU.mjs.map +1 -0
  66. package/dist/{chunk-JCIRYQ4V.mjs → chunk-HEB564WF.mjs} +10 -4
  67. package/dist/chunk-HEB564WF.mjs.map +1 -0
  68. package/dist/{chunk-JRIPCMYC.mjs → chunk-MKDIT2EM.mjs} +1584 -375
  69. package/dist/chunk-MKDIT2EM.mjs.map +1 -0
  70. package/dist/{chunk-S665NJNF.mjs → chunk-OCDLEI6I.mjs} +3 -3
  71. package/dist/{chunk-S665NJNF.mjs.map → chunk-OCDLEI6I.mjs.map} +1 -1
  72. package/dist/chunk-ODZWR6JQ.mjs +4927 -0
  73. package/dist/chunk-ODZWR6JQ.mjs.map +1 -0
  74. package/dist/{chunk-DRR7D3KX.mjs → chunk-SHFUFJ5N.mjs} +192 -18
  75. package/dist/chunk-SHFUFJ5N.mjs.map +1 -0
  76. package/dist/{chunk-4NNZQCL3.mjs → chunk-UI5SFODX.mjs} +223 -277
  77. package/dist/chunk-UI5SFODX.mjs.map +1 -0
  78. package/dist/{chunk-GWCEMDVR.mjs → chunk-UINHLCZA.mjs} +787 -80
  79. package/dist/chunk-UINHLCZA.mjs.map +1 -0
  80. package/dist/{chunk-6TFBXLRJ.mjs → chunk-WEX45HKH.mjs} +222 -337
  81. package/dist/chunk-WEX45HKH.mjs.map +1 -0
  82. package/dist/cloud/density.d.ts +55 -0
  83. package/dist/cloud/density.d.ts.map +1 -0
  84. package/dist/cloud/extract.d.ts +25 -0
  85. package/dist/cloud/extract.d.ts.map +1 -0
  86. package/dist/cloud/feature.d.ts +57 -0
  87. package/dist/cloud/feature.d.ts.map +1 -0
  88. package/dist/cloud/frame-context.d.ts +8 -0
  89. package/dist/cloud/frame-context.d.ts.map +1 -0
  90. package/dist/cloud/inspection.d.ts +59 -0
  91. package/dist/cloud/inspection.d.ts.map +1 -0
  92. package/dist/cloud/optics.d.ts +56 -0
  93. package/dist/cloud/optics.d.ts.map +1 -0
  94. package/dist/cloud/parameters.d.ts +73 -0
  95. package/dist/cloud/parameters.d.ts.map +1 -0
  96. package/dist/cloud/resources.d.ts +60 -0
  97. package/dist/cloud/resources.d.ts.map +1 -0
  98. package/dist/cloud/shadow.d.ts +42 -0
  99. package/dist/cloud/shadow.d.ts.map +1 -0
  100. package/dist/cloud/temporal.d.ts +74 -0
  101. package/dist/cloud/temporal.d.ts.map +1 -0
  102. package/dist/components/atmosphere.d.ts +2 -0
  103. package/dist/components/atmosphere.d.ts.map +1 -1
  104. package/dist/components/barrel-distortion.d.ts +13 -0
  105. package/dist/components/barrel-distortion.d.ts.map +1 -0
  106. package/dist/components/camera.d.ts +20 -4
  107. package/dist/components/camera.d.ts.map +1 -1
  108. package/dist/components/cloud-layer.d.ts +29 -0
  109. package/dist/components/cloud-layer.d.ts.map +1 -0
  110. package/dist/components/index.d.ts +2 -0
  111. package/dist/components/index.d.ts.map +1 -1
  112. package/dist/components/instances.d.ts +20 -6
  113. package/dist/components/instances.d.ts.map +1 -1
  114. package/dist/components/mesh-filter.d.ts +6 -5
  115. package/dist/components/mesh-filter.d.ts.map +1 -1
  116. package/dist/components/motion-blur.d.ts +1 -0
  117. package/dist/components/motion-blur.d.ts.map +1 -1
  118. package/dist/construct-renderer.d.ts +1 -1
  119. package/dist/construct-renderer.d.ts.map +1 -1
  120. package/dist/construct-renderer.mjs +9751 -3528
  121. package/dist/construct-renderer.mjs.map +1 -1
  122. package/dist/device/gpu-residency.d.ts +6 -0
  123. package/dist/device/gpu-residency.d.ts.map +1 -1
  124. package/dist/dynamic-geometry.d.ts +5 -1
  125. package/dist/dynamic-geometry.d.ts.map +1 -1
  126. package/dist/environment/analytic-fog.d.ts +6 -0
  127. package/dist/environment/analytic-fog.d.ts.map +1 -0
  128. package/dist/errors/cloud.d.ts +59 -0
  129. package/dist/errors/cloud.d.ts.map +1 -0
  130. package/dist/errors/render.d.ts +23 -5
  131. package/dist/errors/render.d.ts.map +1 -1
  132. package/dist/examples/single-layer-medium-surface.d.ts +47 -0
  133. package/dist/examples/single-layer-medium-surface.d.ts.map +1 -0
  134. package/dist/extract/camera.d.ts.map +1 -1
  135. package/dist/extract/contracts.d.ts +12 -1
  136. package/dist/extract/contracts.d.ts.map +1 -1
  137. package/dist/extract/environment.d.ts +2 -0
  138. package/dist/extract/environment.d.ts.map +1 -1
  139. package/dist/features/barrel-distortion.d.ts +29 -0
  140. package/dist/features/barrel-distortion.d.ts.map +1 -0
  141. package/dist/features/error-reporting.d.ts +17 -0
  142. package/dist/features/error-reporting.d.ts.map +1 -0
  143. package/dist/features/host.d.ts +15 -0
  144. package/dist/features/host.d.ts.map +1 -1
  145. package/dist/features/motion-blur/motion-blur-feature.d.ts +20 -2
  146. package/dist/features/motion-blur/motion-blur-feature.d.ts.map +1 -1
  147. package/dist/features/motion-blur/motion-blur-params.d.ts +21 -0
  148. package/dist/features/motion-blur/motion-blur-params.d.ts.map +1 -1
  149. package/dist/features/motion-blur/motion-blur-runtime.d.ts +54 -0
  150. package/dist/features/motion-blur/motion-blur-runtime.d.ts.map +1 -0
  151. package/dist/features/plan.d.ts +15 -4
  152. package/dist/features/plan.d.ts.map +1 -1
  153. package/dist/features/prepared-gpu-work.d.ts +9 -5
  154. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  155. package/dist/features/render-graph-compute.d.ts.map +1 -1
  156. package/dist/features/render-graph-contribution.d.ts +5 -0
  157. package/dist/features/render-graph-contribution.d.ts.map +1 -1
  158. package/dist/features/render-graph-raster.d.ts +1 -0
  159. package/dist/features/render-graph-raster.d.ts.map +1 -1
  160. package/dist/features/targets.d.ts +2 -0
  161. package/dist/features/targets.d.ts.map +1 -1
  162. package/dist/features/types.d.ts +44 -0
  163. package/dist/features/types.d.ts.map +1 -1
  164. package/dist/fullscreen-post-process-pass.d.ts +15 -7
  165. package/dist/fullscreen-post-process-pass.d.ts.map +1 -1
  166. package/dist/gpu-dirty-ranges.d.ts +11 -0
  167. package/dist/gpu-dirty-ranges.d.ts.map +1 -0
  168. package/dist/gpu-driven/batch-topology.d.ts +17 -0
  169. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  170. package/dist/gpu-driven/material-artifacts.d.ts +6 -0
  171. package/dist/gpu-driven/material-artifacts.d.ts.map +1 -1
  172. package/dist/gpu-driven/prepared-draw.d.ts +7 -0
  173. package/dist/gpu-driven/prepared-draw.d.ts.map +1 -1
  174. package/dist/gpu-driven/production-raster-material.d.ts +178 -0
  175. package/dist/gpu-driven/production-raster-material.d.ts.map +1 -0
  176. package/dist/gpu-driven/production-raster.d.ts +50 -4
  177. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  178. package/dist/gpu-driven/resource-allocation.d.ts +31 -0
  179. package/dist/gpu-driven/resource-allocation.d.ts.map +1 -0
  180. package/dist/gpu-driven/shadow-ownership.d.ts +14 -0
  181. package/dist/gpu-driven/shadow-ownership.d.ts.map +1 -0
  182. package/dist/gpu-driven/view-gpu.d.ts +35 -2
  183. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  184. package/dist/gpu-scene.d.ts +11 -1
  185. package/dist/gpu-scene.d.ts.map +1 -1
  186. package/dist/hdrp-buffers.d.ts +2 -2
  187. package/dist/hdrp-buffers.d.ts.map +1 -1
  188. package/dist/ibl/skylight-bind-group.d.ts +10 -1
  189. package/dist/ibl/skylight-bind-group.d.ts.map +1 -1
  190. package/dist/index.d.ts +31 -8
  191. package/dist/index.d.ts.map +1 -1
  192. package/dist/index.mjs +258 -8
  193. package/dist/index.mjs.map +1 -1
  194. package/dist/inspection-types.d.ts +178 -3
  195. package/dist/inspection-types.d.ts.map +1 -1
  196. package/dist/instance-buffer-cache.d.ts +4 -4
  197. package/dist/instance-buffer-cache.d.ts.map +1 -1
  198. package/dist/instances.d.ts +49 -94
  199. package/dist/instances.d.ts.map +1 -1
  200. package/dist/internal.mjs +7 -7
  201. package/dist/pbr-pipeline.d.ts +19 -1
  202. package/dist/pbr-pipeline.d.ts.map +1 -1
  203. package/dist/pipeline/single-layer-medium-passes.d.ts +73 -0
  204. package/dist/pipeline/single-layer-medium-passes.d.ts.map +1 -0
  205. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  206. package/dist/pipeline/standard-output/graph.d.ts.map +1 -1
  207. package/dist/pipeline/standard-output/types.d.ts +4 -2
  208. package/dist/pipeline/standard-output/types.d.ts.map +1 -1
  209. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  210. package/dist/pipeline/standard-post.d.ts +28 -19
  211. package/dist/pipeline/standard-post.d.ts.map +1 -1
  212. package/dist/pipeline-builder.d.ts +1 -1
  213. package/dist/pipeline-builder.d.ts.map +1 -1
  214. package/dist/pipeline-spec.d.ts +2 -2
  215. package/dist/plugin.d.ts.map +1 -1
  216. package/dist/prepare/prepared-graphics-resolver.d.ts +2 -0
  217. package/dist/prepare/prepared-graphics-resolver.d.ts.map +1 -1
  218. package/dist/record/barrel-distortion-frame.d.ts +15 -0
  219. package/dist/record/barrel-distortion-frame.d.ts.map +1 -0
  220. package/dist/record/dynamic-geometry-consumption.d.ts +10 -2
  221. package/dist/record/dynamic-geometry-consumption.d.ts.map +1 -1
  222. package/dist/record/fold-buckets.d.ts +95 -0
  223. package/dist/record/fold-buckets.d.ts.map +1 -0
  224. package/dist/record/fold-instance-buffer.d.ts +6 -0
  225. package/dist/record/fold-instance-buffer.d.ts.map +1 -0
  226. package/dist/record/frame-lighting.d.ts +1 -0
  227. package/dist/record/frame-lighting.d.ts.map +1 -1
  228. package/dist/record/frame-snapshot.d.ts +40 -3
  229. package/dist/record/frame-snapshot.d.ts.map +1 -1
  230. package/dist/record/frame-targets.d.ts.map +1 -1
  231. package/dist/record/frame.d.ts +14 -62
  232. package/dist/record/frame.d.ts.map +1 -1
  233. package/dist/record/gpu-draw-receipts.d.ts +11 -0
  234. package/dist/record/gpu-draw-receipts.d.ts.map +1 -0
  235. package/dist/record/gpu-timing.d.ts +8 -0
  236. package/dist/record/gpu-timing.d.ts.map +1 -1
  237. package/dist/record/main-pass-geometry.d.ts +21 -5
  238. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  239. package/dist/record/main-pass-material.d.ts +7 -1
  240. package/dist/record/main-pass-material.d.ts.map +1 -1
  241. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  242. package/dist/record/main-pass.d.ts +13 -2
  243. package/dist/record/main-pass.d.ts.map +1 -1
  244. package/dist/record/mesh-ssbo.d.ts +6 -5
  245. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  246. package/dist/record/motion-blur-receipt.d.ts +11 -0
  247. package/dist/record/motion-blur-receipt.d.ts.map +1 -0
  248. package/dist/record/probe-blend-buffer.d.ts +16 -0
  249. package/dist/record/probe-blend-buffer.d.ts.map +1 -0
  250. package/dist/record/recovery-pipeline.d.ts.map +1 -1
  251. package/dist/record/render-context.d.ts +34 -31
  252. package/dist/record/render-context.d.ts.map +1 -1
  253. package/dist/record/shadow-pass.d.ts +2 -0
  254. package/dist/record/shadow-pass.d.ts.map +1 -1
  255. package/dist/record/skybox-post-pass.d.ts +6 -4
  256. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  257. package/dist/record/typed-frame-graph.d.ts +22 -2
  258. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  259. package/dist/record/view-ubo.d.ts +11 -3
  260. package/dist/record/view-ubo.d.ts.map +1 -1
  261. package/dist/record/volume-params.d.ts +9 -0
  262. package/dist/record/volume-params.d.ts.map +1 -0
  263. package/dist/recovery/render-system-candidate.d.ts +10 -0
  264. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  265. package/dist/reflection/record-owner.d.ts.map +1 -1
  266. package/dist/render-contract.d.ts +66 -8
  267. package/dist/render-contract.d.ts.map +1 -1
  268. package/dist/render-graph-primitives.d.ts.map +1 -1
  269. package/dist/render-pipeline.d.ts +106 -3
  270. package/dist/render-pipeline.d.ts.map +1 -1
  271. package/dist/render-system-extract-tail.d.ts +3 -2
  272. package/dist/render-system-extract-tail.d.ts.map +1 -1
  273. package/dist/render-system-extract.d.ts +24 -10
  274. package/dist/render-system-extract.d.ts.map +1 -1
  275. package/dist/render-system-presentation.d.ts +23 -0
  276. package/dist/render-system-presentation.d.ts.map +1 -0
  277. package/dist/render-system-producer-roots.d.ts +10 -0
  278. package/dist/render-system-producer-roots.d.ts.map +1 -0
  279. package/dist/render-system-residency.d.ts +12 -0
  280. package/dist/render-system-residency.d.ts.map +1 -0
  281. package/dist/render-system.d.ts +26 -15
  282. package/dist/render-system.d.ts.map +1 -1
  283. package/dist/scene/probe-blend.d.ts +27 -0
  284. package/dist/scene/probe-blend.d.ts.map +1 -1
  285. package/dist/scene/render-scene.d.ts +14 -5
  286. package/dist/scene/render-scene.d.ts.map +1 -1
  287. package/dist/ssao-buffers.d.ts +9 -0
  288. package/dist/ssao-buffers.d.ts.map +1 -1
  289. package/dist/surface/dynamic-input.d.ts +133 -0
  290. package/dist/surface/dynamic-input.d.ts.map +1 -0
  291. package/dist/surface/gpu-driven.d.ts +41 -0
  292. package/dist/surface/gpu-driven.d.ts.map +1 -0
  293. package/dist/surface/submission-observation.d.ts +136 -0
  294. package/dist/surface/submission-observation.d.ts.map +1 -0
  295. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  296. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  297. package/dist/temporal/frame.d.ts +7 -0
  298. package/dist/temporal/frame.d.ts.map +1 -1
  299. package/dist/temporal/gpu.d.ts +13 -1
  300. package/dist/temporal/gpu.d.ts.map +1 -1
  301. package/dist/temporal/index.mjs +5 -5
  302. package/dist/temporal/inspection.d.ts.map +1 -1
  303. package/dist/temporal/standard-scene-data.d.ts.map +1 -1
  304. package/dist/temporal/target-coverage-attachment.d.ts.map +1 -1
  305. package/dist/temporal/view.d.ts +6 -1
  306. package/dist/temporal/view.d.ts.map +1 -1
  307. package/dist/transmission/single-layer-medium.d.ts +89 -0
  308. package/dist/transmission/single-layer-medium.d.ts.map +1 -0
  309. package/dist/typed-render-graph-primitives.d.ts +24 -9
  310. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  311. package/dist/volume/component.d.ts +9 -5
  312. package/dist/volume/component.d.ts.map +1 -1
  313. package/dist/volume/extract.d.ts +1 -1
  314. package/dist/volume/extract.d.ts.map +1 -1
  315. package/dist/volume/inspection.d.ts +2 -0
  316. package/dist/volume/inspection.d.ts.map +1 -1
  317. package/dist/volume/passes.d.ts +4 -1
  318. package/dist/volume/passes.d.ts.map +1 -1
  319. package/dist/volume/resources.d.ts.map +1 -1
  320. package/dist/volume/temporal.d.ts +3 -1
  321. package/dist/volume/temporal.d.ts.map +1 -1
  322. package/package.json +22 -21
  323. package/src/__tests__/analytic-fog-graph.unit.test.ts +199 -0
  324. package/src/__tests__/analytic-fog-optics.unit.test.ts +38 -0
  325. package/src/__tests__/atmosphere-daylight.dawn.test.ts +72 -0
  326. package/src/__tests__/barrel-distortion-lkg.integration.test.ts +353 -0
  327. package/src/__tests__/barrel-distortion.dawn.test.ts +41 -0
  328. package/src/__tests__/barrel-distortion.unit.test.ts +364 -0
  329. package/src/__tests__/bloom-exact-zero.unit.test.ts +112 -18
  330. package/src/__tests__/bloom-lifecycle.integration.test.ts +17 -4
  331. package/src/__tests__/construct-renderer-identity.unit.test.ts +49 -0
  332. package/src/__tests__/detached-recovery-graph.unit.test.ts +277 -0
  333. package/src/__tests__/device-loss-fanout.unit.test.ts +36 -1
  334. package/src/__tests__/docs-gate-green.unit.test.ts +112 -1
  335. package/src/__tests__/dynamic-resolution-plugin.integration.test.ts +20 -0
  336. package/src/__tests__/environment-fog-lanes.integration.test.ts +14 -0
  337. package/src/__tests__/environment-selection.unit.test.ts +12 -0
  338. package/src/__tests__/factory-contract.integration.test.ts +626 -113
  339. package/src/__tests__/feature-depth-input.dawn.test.ts +129 -2
  340. package/src/__tests__/frame-camera-selection.unit.test.ts +39 -1
  341. package/src/__tests__/frame-plan-contract.unit.test.ts +2 -0
  342. package/src/__tests__/fullscreen-feature-plan.unit.test.ts +14 -3
  343. package/src/__tests__/gpu-dirty-ranges.unit.test.ts +22 -0
  344. package/src/__tests__/gpu-driven-baseline.characterization.test.ts +16 -3
  345. package/src/__tests__/gpu-driven-batch-topology-pbr.unit.test.ts +12 -0
  346. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +2 -1
  347. package/src/__tests__/gpu-driven-material-artifacts.unit.test.ts +143 -0
  348. package/src/__tests__/gpu-driven-pbr.dawn.test.ts +2 -2
  349. package/src/__tests__/gpu-driven-production.integration.test.ts +482 -3
  350. package/src/__tests__/gpu-driven-shadow-views.dawn.test.ts +7 -6
  351. package/src/__tests__/gpu-driven-shadow-views.integration.test.ts +1 -1
  352. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +103 -4
  353. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +18 -2
  354. package/src/__tests__/gpu-driven-view.browser.test.ts +5 -8
  355. package/src/__tests__/gpu-driven-view.dawn.test.ts +6 -2
  356. package/src/__tests__/gpu-driven-view.unit.test.ts +1 -1
  357. package/src/__tests__/gpu-pass-timing-lifecycle.integration.test.ts +1 -0
  358. package/src/__tests__/gpu-pass-timing.browser.test.ts +6 -4
  359. package/src/__tests__/gpu-pass-timing.rhi-null.unit.test.ts +1 -0
  360. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +12 -0
  361. package/src/__tests__/gpu-scene.unit.test.ts +210 -9
  362. package/src/__tests__/instance-allocation-failure.unit.test.ts +57 -0
  363. package/src/__tests__/instances-culling.integration.test.ts +10 -36
  364. package/src/__tests__/instances-store.unit.test.ts +105 -238
  365. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  366. package/src/__tests__/main-pass-material-medium-selection.unit.test.ts +146 -0
  367. package/src/__tests__/material-shader-prewarm.unit.test.ts +59 -0
  368. package/src/__tests__/material-snapshot-mutation.integration.test.ts +39 -1
  369. package/src/__tests__/mesh-submission-lifetime.unit.test.ts +31 -0
  370. package/src/__tests__/morph-culling-reentry.dawn.test.ts +5 -5
  371. package/src/__tests__/morph-visual-evidence.integration.test.ts +8 -7
  372. package/src/__tests__/motion-blur-compute.dawn.test.ts +589 -0
  373. package/src/__tests__/motion-blur.pixel.integration.test.ts +2 -2
  374. package/src/__tests__/nineslice-handle-surface.unit.test.ts +4 -4
  375. package/src/__tests__/pbr-view-layout.unit.test.ts +9 -0
  376. package/src/__tests__/physical-clearcoat.integration.test.ts +58 -0
  377. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +21 -0
  378. package/src/__tests__/prepared-graphics-capability.unit.test.ts +66 -2
  379. package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +86 -0
  380. package/src/__tests__/probe-bindings.unit.test.ts +582 -1
  381. package/src/__tests__/probe-scene-dirty.integration.test.ts +28 -1
  382. package/src/__tests__/public-surface.test-d.ts +8 -0
  383. package/src/__tests__/receipt-observation-retirement.unit.test.ts +65 -0
  384. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +69 -0
  385. package/src/__tests__/render-error-code-owner.test-d.ts +9 -0
  386. package/src/__tests__/render-feature-diagnostics.unit.test.ts +15 -2
  387. package/src/__tests__/render-feature-error-reporting.unit.test.ts +44 -0
  388. package/src/__tests__/render-feature-prepared-diagnostics.unit.test.ts +10 -1
  389. package/src/__tests__/render-feature-retry.unit.test.ts +12 -1
  390. package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +23 -0
  391. package/src/__tests__/render-scene-mixed-updates.integration.test.ts +145 -19
  392. package/src/__tests__/render-scene-projection.unit.test.ts +36 -11
  393. package/src/__tests__/render-scene-temporal-retry.unit.test.ts +37 -6
  394. package/src/__tests__/render-target-probe-soak.perf.test.ts +2 -2
  395. package/src/__tests__/render-temporal-environment-consumer.browser.test.ts +3 -3
  396. package/src/__tests__/render-temporal-environment-consumer.dawn.test.ts +5 -2
  397. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +41 -7
  398. package/src/__tests__/renderer-frame-generation-fence.integration.test.ts +16 -0
  399. package/src/__tests__/renderer-frame-transaction.integration.test.ts +33 -11
  400. package/src/__tests__/resource-allocation.unit.test.ts +123 -0
  401. package/src/__tests__/shader-manifest-fixture.ts +4 -4
  402. package/src/__tests__/single-layer-medium-passes.unit.test.ts +275 -0
  403. package/src/__tests__/skin-motion-regression.unit.test.ts +70 -1
  404. package/src/__tests__/skin-palette-recycling.dawn.test.ts +97 -0
  405. package/src/__tests__/skinned-shadow-caster.test.ts +17 -0
  406. package/src/__tests__/ssr-carrier-public-contract.unit.test.ts +21 -0
  407. package/src/__tests__/ssr-gpu-dispatch.browser.test.ts +4 -4
  408. package/src/__tests__/ssr-gpu-dispatch.dawn.test.ts +2 -2
  409. package/src/__tests__/ssr-gpu-dispatch.ts +1 -1
  410. package/src/__tests__/ssr-receiver-coverage.dawn.test.ts +6 -2
  411. package/src/__tests__/standard-lighting-write.unit.test.ts +2 -1
  412. package/src/__tests__/standard-output-chain-contract.unit.test.ts +28 -0
  413. package/src/__tests__/standard-pbr-artifact-assembly.unit.test.ts +46 -1
  414. package/src/__tests__/standard-pipeline.dawn.test.ts +1 -1
  415. package/src/__tests__/standard-pipeline.integration.test.ts +200 -9
  416. package/src/__tests__/surface-dynamic-buffer-lifecycle.unit.test.ts +506 -0
  417. package/src/__tests__/surface-submission-observation.unit.test.ts +812 -0
  418. package/src/__tests__/temporal-history-lifecycle.unit.test.ts +32 -0
  419. package/src/__tests__/temporal-performance.integration.test.ts +3 -3
  420. package/src/__tests__/temporal-reset-matrix.unit.test.ts +10 -0
  421. package/src/__tests__/temporal-view.unit.test.ts +2 -1
  422. package/src/__tests__/typed-pipeline-topology.unit.test.ts +20 -4
  423. package/src/__tests__/visibility-instances.integration.test.ts +7 -19
  424. package/src/__tests__/visibility-producer-matrix.integration.test.ts +9 -20
  425. package/src/__tests__/volume-optics-oracle.unit.test.ts +7 -3
  426. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +189 -15
  427. package/src/__tests__/volumetric-fog-errors.unit.test.ts +4 -3
  428. package/src/__tests__/volumetric-fog-multiple.unit.test.ts +55 -0
  429. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +1 -1
  430. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +558 -68
  431. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +27 -0
  432. package/src/__tests__/volumetric-fog-topology.unit.test.ts +23 -5
  433. package/src/assembly/analytic-fog-registration.ts +14 -0
  434. package/src/assembly/backend-contract.ts +30 -0
  435. package/src/assembly/barrel-distortion-inspection.ts +22 -0
  436. package/src/assembly/canvas-draw-guard.ts +11 -0
  437. package/src/assembly/cloud-shadow-entry.ts +22 -0
  438. package/src/assembly/dynamic-geometry-host.ts +65 -37
  439. package/src/assembly/dynamic-geometry-runtime.ts +7 -1
  440. package/src/assembly/factory.ts +2 -30
  441. package/src/assembly/fxaa-registration.ts +17 -0
  442. package/src/assembly/host-contract.ts +3 -3
  443. package/src/assembly/lod-observation.ts +12 -0
  444. package/src/assembly/material/assembly.ts +25 -13
  445. package/src/assembly/material/pipeline-helpers.ts +53 -12
  446. package/src/assembly/material-shader-policy.ts +55 -0
  447. package/src/assembly/material-shader-prewarm.ts +21 -10
  448. package/src/assembly/motion-blur-registration.ts +17 -0
  449. package/src/assembly/recovery/device-loss-fanout.ts +13 -0
  450. package/src/assembly/recovery/renderer-recover.ts +41 -18
  451. package/src/assembly/render-feature-post-processes.ts +54 -0
  452. package/src/assembly/renderer-facade.ts +6 -1
  453. package/src/assembly/renderer-feature-inspection.ts +8 -2
  454. package/src/assembly/renderer-frame-transaction.ts +23 -0
  455. package/src/assembly/renderer-inspection.ts +28 -0
  456. package/src/assembly/skin-palette-owner.ts +1 -1
  457. package/src/assembly/temporal-post-process-prewarm.ts +40 -0
  458. package/src/assembly/webgpu-pbr-ready.ts +9 -1
  459. package/src/assembly/webgpu-ready-contract.ts +8 -3
  460. package/src/assembly/webgpu-ready-per-pass.ts +104 -0
  461. package/src/assembly/webgpu-ready.ts +296 -306
  462. package/src/assembly/webgpu-renderer-bootstrap.ts +0 -12
  463. package/src/assembly/webgpu-renderer-observation-owner.ts +222 -0
  464. package/src/assembly/webgpu-renderer.ts +228 -281
  465. package/src/assembly/webgpu-vertex-layouts.ts +1 -1
  466. package/src/barrel-distortion.ts +247 -0
  467. package/src/bloom-admission.ts +21 -0
  468. package/src/cloud/__tests__/cloud-density-parity.dawn.test.ts +173 -0
  469. package/src/cloud/__tests__/cloud-layer-framebuffer-y.dawn.test.ts +265 -0
  470. package/src/cloud/__tests__/cloud-layer.browser.test.ts +432 -0
  471. package/src/cloud/__tests__/cloud-layer.unit.test.ts +634 -0
  472. package/src/cloud/density.ts +600 -0
  473. package/src/cloud/extract.ts +110 -0
  474. package/src/cloud/feature.ts +1810 -0
  475. package/src/cloud/frame-context.ts +42 -0
  476. package/src/cloud/inspection.ts +130 -0
  477. package/src/cloud/optics.ts +285 -0
  478. package/src/cloud/parameters.ts +255 -0
  479. package/src/cloud/resources.ts +122 -0
  480. package/src/cloud/shadow.ts +167 -0
  481. package/src/cloud/temporal.ts +226 -0
  482. package/src/components/__tests__/motion-blur.unit.test.ts +1 -0
  483. package/src/components/atmosphere.ts +4 -0
  484. package/src/components/barrel-distortion.ts +46 -0
  485. package/src/components/camera.ts +66 -10
  486. package/src/components/cloud-layer.ts +47 -0
  487. package/src/components/index.ts +2 -0
  488. package/src/components/instances.ts +18 -45
  489. package/src/components/mesh-filter.ts +11 -9
  490. package/src/components/motion-blur.ts +1 -0
  491. package/src/components/sprite-instances.ts +1 -1
  492. package/src/construct-renderer.ts +1 -1
  493. package/src/device/gpu-residency.ts +56 -12
  494. package/src/dynamic-geometry.ts +12 -1
  495. package/src/environment/analytic-fog.ts +44 -0
  496. package/src/environment/background.ts +2 -2
  497. package/src/errors/cloud.ts +103 -0
  498. package/src/errors/render.ts +58 -8
  499. package/src/examples/single-layer-medium-surface.ts +59 -0
  500. package/src/extract/camera.ts +35 -8
  501. package/src/extract/contracts.ts +12 -0
  502. package/src/extract/environment.ts +4 -0
  503. package/src/features/barrel-distortion.ts +172 -0
  504. package/src/features/error-reporting.ts +98 -0
  505. package/src/features/host.ts +138 -24
  506. package/src/features/motion-blur/__tests__/motion-blur-feature.unit.test.ts +111 -1
  507. package/src/features/motion-blur/__tests__/motion-blur-params.unit.test.ts +58 -3
  508. package/src/features/motion-blur/motion-blur-feature.ts +1519 -15
  509. package/src/features/motion-blur/motion-blur-params.ts +70 -2
  510. package/src/features/motion-blur/motion-blur-runtime.ts +176 -0
  511. package/src/features/plan.ts +33 -8
  512. package/src/features/prepared-gpu-work.ts +86 -35
  513. package/src/features/render-graph-compute.ts +13 -5
  514. package/src/features/render-graph-contribution.ts +11 -0
  515. package/src/features/render-graph-raster.ts +45 -3
  516. package/src/features/targets.ts +66 -0
  517. package/src/features/types.ts +47 -0
  518. package/src/fullscreen-post-process-pass.ts +41 -9
  519. package/src/gpu-dirty-ranges.ts +48 -0
  520. package/src/gpu-driven/batch-topology.ts +53 -0
  521. package/src/gpu-driven/material-artifacts.ts +35 -32
  522. package/src/gpu-driven/prepared-draw.ts +27 -14
  523. package/src/gpu-driven/production-raster-material.ts +1894 -0
  524. package/src/gpu-driven/production-raster.ts +904 -787
  525. package/src/gpu-driven/resource-allocation.ts +142 -0
  526. package/src/gpu-driven/shadow-ownership.ts +71 -0
  527. package/src/gpu-driven/view-gpu.ts +181 -10
  528. package/src/gpu-scene.ts +259 -101
  529. package/src/hdrp-buffers.ts +4 -3
  530. package/src/ibl/skylight-bind-group.ts +49 -7
  531. package/src/index.ts +228 -9
  532. package/src/inspection-types.ts +222 -2
  533. package/src/instance-buffer-cache.ts +4 -4
  534. package/src/instances.ts +151 -492
  535. package/src/pbr-pipeline.ts +219 -13
  536. package/src/pipeline/single-layer-medium-passes.ts +267 -0
  537. package/src/pipeline/standard-forward-lane.ts +313 -57
  538. package/src/pipeline/standard-output/graph.ts +8 -2
  539. package/src/pipeline/standard-output/types.ts +12 -1
  540. package/src/pipeline/standard-pipeline.ts +291 -18
  541. package/src/pipeline/standard-post.ts +397 -102
  542. package/src/pipeline-builder.ts +1 -1
  543. package/src/pipeline-spec.ts +5 -5
  544. package/src/plugin.ts +4 -0
  545. package/src/points-lines/__tests__/points-lines.bench.ts +17 -8
  546. package/src/points-lines/__tests__/view-ubo.unit.test.ts +2 -1
  547. package/src/prepare/prepared-graphics-resolver.ts +2 -0
  548. package/src/record/__tests__/main-pass-geometry.unit.test.ts +30 -0
  549. package/src/record/__tests__/sprite-material-abi.unit.test.ts +1 -1
  550. package/src/record/__tests__/temporal-payload.unit.test.ts +3 -2
  551. package/src/record/__tests__/transparent-group2-selection.unit.test.ts +11 -0
  552. package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +1 -0
  553. package/src/record/__tests__/view-ubo-layout.unit.test.ts +4 -3
  554. package/src/record/barrel-distortion-frame.ts +110 -0
  555. package/src/record/dynamic-geometry-consumption.ts +39 -9
  556. package/src/record/fold-buckets.ts +852 -0
  557. package/src/record/fold-instance-buffer.ts +51 -0
  558. package/src/record/frame-lighting.ts +36 -2
  559. package/src/record/frame-snapshot.ts +44 -4
  560. package/src/record/frame-targets.ts +10 -6
  561. package/src/record/frame.ts +373 -1099
  562. package/src/record/gpu-draw-receipts.ts +38 -0
  563. package/src/record/gpu-timing.ts +48 -5
  564. package/src/record/main-pass-geometry.ts +281 -83
  565. package/src/record/main-pass-material.ts +66 -4
  566. package/src/record/main-pass-sprite-draws.ts +13 -66
  567. package/src/record/main-pass.ts +156 -20
  568. package/src/record/mesh-ssbo.ts +43 -6
  569. package/src/record/motion-blur-receipt.ts +91 -0
  570. package/src/record/probe-blend-buffer.ts +180 -0
  571. package/src/record/recovery-pipeline.ts +35 -3
  572. package/src/record/render-context.ts +51 -31
  573. package/src/record/shadow-pass.ts +36 -2
  574. package/src/record/skybox-post-pass.ts +150 -200
  575. package/src/record/typed-frame-graph.ts +824 -44
  576. package/src/record/view-ubo.ts +26 -3
  577. package/src/record/volume-params.ts +225 -0
  578. package/src/recovery/render-system-candidate.ts +257 -52
  579. package/src/reflection/record-owner.ts +7 -3
  580. package/src/render-contract.ts +71 -8
  581. package/src/render-graph-primitives.ts +7 -1
  582. package/src/render-pipeline.ts +136 -1
  583. package/src/render-system-extract-tail.ts +72 -60
  584. package/src/render-system-extract.ts +117 -87
  585. package/src/render-system-presentation.ts +56 -0
  586. package/src/render-system-producer-roots.ts +96 -0
  587. package/src/render-system-residency.ts +159 -0
  588. package/src/render-system.ts +601 -482
  589. package/src/scene/probe-blend.ts +96 -4
  590. package/src/scene/render-scene.ts +431 -281
  591. package/src/ssao-buffers.ts +95 -2
  592. package/src/surface/__tests__/dynamic-input.unit.test.ts +374 -0
  593. package/src/surface/__tests__/gpu-driven.unit.test.ts +87 -0
  594. package/src/surface/dynamic-input.ts +658 -0
  595. package/src/surface/gpu-driven.ts +147 -0
  596. package/src/surface/submission-observation.ts +517 -0
  597. package/src/systems/skin-palette-allocator.ts +4 -1
  598. package/src/temporal/__tests__/temporal-frame-transaction.unit.test.ts +36 -0
  599. package/src/temporal/frame-coordinator.ts +8 -1
  600. package/src/temporal/frame.ts +20 -0
  601. package/src/temporal/gpu.ts +101 -17
  602. package/src/temporal/inspection.ts +7 -1
  603. package/src/temporal/standard-scene-data.ts +5 -0
  604. package/src/temporal/target-coverage-attachment.ts +4 -0
  605. package/src/temporal/view.ts +13 -2
  606. package/src/transmission/__tests__/single-layer-medium.unit.test.ts +149 -0
  607. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +12 -11
  608. package/src/transmission/single-layer-medium.ts +322 -0
  609. package/src/typed-render-graph-primitives.ts +191 -50
  610. package/src/volume/component.ts +10 -10
  611. package/src/volume/extract.ts +11 -5
  612. package/src/volume/inspection.ts +3 -0
  613. package/src/volume/passes.ts +95 -8
  614. package/src/volume/resources.ts +2 -1
  615. package/src/volume/temporal.ts +11 -0
  616. package/dist/chunk-4NNZQCL3.mjs.map +0 -1
  617. package/dist/chunk-6TFBXLRJ.mjs.map +0 -1
  618. package/dist/chunk-DRR7D3KX.mjs.map +0 -1
  619. package/dist/chunk-GWCEMDVR.mjs.map +0 -1
  620. package/dist/chunk-JCIRYQ4V.mjs.map +0 -1
  621. package/dist/chunk-JRIPCMYC.mjs.map +0 -1
  622. package/dist/chunk-LULCA3OF.mjs +0 -1713
  623. package/dist/chunk-LULCA3OF.mjs.map +0 -1
  624. package/dist/chunk-PA5DRJNI.mjs.map +0 -1
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # `@forgeax/engine-render`
2
2
 
3
+ > [!NOTE]
4
+ > This package-owner README contains physical `@forgeax/engine-render` imports
5
+ > in internal ownership examples. Game-facing code should use the public
6
+ > facade `@forgeax/engine/render`; the Bloom consumer route is documented in
7
+ > [hello-bloom](../../apps/hello/bloom/README.md).
8
+
3
9
  > [!IMPORTANT]
4
10
  > The Standard pipeline keeps dark-gradient color in `rgba16float` until one
5
11
  > `outputTransform` reaches the `surface.storage.raw` endpoint; `inspect()` and
@@ -8,6 +14,77 @@
8
14
 
9
15
  ## Public frame contract
10
16
 
17
+ Every accepted `FrameReceipt` with a submitted camera context carries an
18
+ immutable `barrelDistortion` mapping, including the exact identity mapping when
19
+ the camera has no active distortion. The optional property is a transport and
20
+ pre-first-frame shape: `undefined` means that no accepted submitted display
21
+ context exists. It is fail-closed, never an identity guess; display consumers
22
+ must wait for a new submitted frame.
23
+ The mapping is tied to the submitted output extent, camera matrices, device
24
+ generation, graph generation, and frame identity. Consumers must keep those
25
+ facts together; a mapping from a newer World edit cannot be applied to an older
26
+ picture. The renderer publishes the mapping only after queue submission, and a
27
+ candidate that fails validation keeps the previous accepted picture and
28
+ mapping. The public projection is deeply frozen and contains no device, graph,
29
+ texture, or mutable resource handle.
30
+
31
+ `renderer.inspect().barrelDistortion` projects the same accepted context as
32
+ `effectiveMapping`, `extent`, `frameId`, `deviceGeneration`,
33
+ `graphGeneration`, and `lastKnownGood`. Before the first accepted frame, after
34
+ surface retirement, or during device loss, `effectiveMapping` is unavailable;
35
+ display consumers must wait for a new submitted frame. An invalid authoring
36
+ value returns `barrel-distortion-invalid-parameter` with its field and bound.
37
+ Unsupported float attachment capability is reported only for an active plan;
38
+ an empty or zero-strength plan stays zero-work. A pending or failed disable
39
+ keeps the old nonzero mapping with its picture, while a successful disable
40
+ publishes identity and retires the old feature resources through the normal
41
+ in-flight fence.
42
+
43
+ `Fog` provides distance fog with exponential height density through one
44
+ finite-depth fullscreen pass before temporal resolve, Bloom and tone mapping.
45
+ `density`, `heightFalloff`, linear `color` and `maxOpacity` are selected from
46
+ the frame's resource owner. Disabled or zero-density fog allocates no fog target;
47
+ there is no 3D texture, froxel grid or temporal history. The authored sky remains
48
+ unchanged. FXAA, TAA and no-AA use single-sample depth; MSAA is rejected until a
49
+ matching depth resolve is available. Translucent surfaces that do not write depth
50
+ use the opaque background depth. Use `VolumetricFog` for spatial density and
51
+ lighting/scattering effects; the meadow-style distance haze needs only `Fog`.
52
+
53
+ ### Local volumetric fog
54
+
55
+ Spawn one `VolumetricFog` per local medium in the rendered environment World.
56
+ Each owner selects its own light, 3D density texture, world-space bounds, and
57
+ optical coefficients. Up to `MAX_VOLUMETRIC_FOG_OWNERS` (8) owners share the
58
+ renderer-owned inject, integrate, temporal, and composite passes. Overlapping
59
+ media add coefficients before integration; removing one owner keeps the others.
60
+ Removing the last owner retires the volume graph resources.
61
+
62
+ | Author field | Contract |
63
+ |:--|:--|
64
+ | `density` | Shared linear `TextureAsset`, `viewDimension: '3d'` |
65
+ | `sampling` | ECS enum: `VolumetricFogSamplingValue.noise` (default animated atmospheric grain), or `.density` (local normalized bounds sample) |
66
+ | `boundsMin`, `boundsMax` | Finite world coordinates, strictly increasing on each axis; not transformed by `Transform` |
67
+ | `extinction`, `albedo`, `emission` | Nonnegative RGB; albedo additionally at most 1 |
68
+ | `anisotropy`, `maxDistance` | Finite, respectively `(-1, 1)` and positive world units |
69
+ | `light`, `spotLight` | Same-World selected light and optional Point/Spot pair, as in the existing single-volume contract |
70
+
71
+ Pure `VolumetricFogAuthoring` validation uses the string sampling labels, not
72
+ the ECS numeric values. `extractVolumetricFog()` returns an available `fogs`
73
+ collection and validates every member atomically. Overflow returns
74
+ `volume-owner-conflict` with `detail.ownerCount`; invalid author parameters
75
+ retain the existing structured error contract.
76
+
77
+ Read `renderer.inspect().volumetricFog.ownerCount` after a completed frame for
78
+ the accepted collection, alongside status, pass count, memory, and generation.
79
+ It is not an echo of a rejected candidate. Light projectors retain the shared
80
+ renderer-selected projector contract; only its selected light samples the map.
81
+
82
+ > [!IMPORTANT]
83
+ > This is local-medium integration against scene depth, not a claim of
84
+ > depth-aware fogging of every transparent or VFX feature. Those consumers
85
+ > require their own rendered integration evidence. Software GPU readback is
86
+ > correctness evidence, not hardware performance evidence.
87
+
11
88
  ## 灯光最短入口
12
89
 
13
90
  三条最短入口:
@@ -57,6 +134,10 @@ payload; the first-draw guard still rejects lazy cold uploads or pipeline builds
57
134
  inside rendering. Preparation does not make an old-generation receipt current.
58
135
  Recovery prepares the last successfully submitted workset; the next draw still
59
136
  consumes current World edits and admits new resources through normal residency.
137
+ Fullscreen recovery warms only active declared post-process identities; graph-local
138
+ resource aliases are resolved by the prepared feature and are not separate pipelines.
139
+ Ordinary graph replacement retains the previous graph until submission settles,
140
+ using the same retirement boundary for resize and post-process changes.
60
141
 
61
142
  `inspect().recovery` is always present. Its `phase` is `null` outside an active
62
143
  attempt and otherwise follows `quiesce`, `acquire-adapter`, `acquire-device`,
@@ -76,10 +157,39 @@ uninitialized until a successful receipt; a neutral target or last-known-good
76
157
  fallback is not proof of real recovery. Public consumers never receive graph
77
158
  nodes, devices, history textures, or prepared handles.
78
159
 
79
- The current-source manifest and schema identify `source`, `build`, `backend`,
80
- `runner`, and `frameIdentity`. Structural graph receipts, Browser/Dawn
81
- readback or PNG evidence, and historical oracle data are separate evidence
82
- classes. An unavailable backend is reported as unavailable.
160
+ The current-source manifest and schema identify `source`, `build`, `backend`,
161
+ `runner`, and `frameIdentity`. Structural graph receipts, Browser/Dawn
162
+ readback or PNG evidence, and historical oracle data are separate evidence
163
+ classes. An unavailable backend is reported as unavailable.
164
+
165
+ ### Renderer-wide graph allocation inspection
166
+
167
+ `renderer.inspect().renderGraphGenerationAllocation` is the renderer-owned
168
+ logical allocation receipt across active, volumetric candidate, retiring, and
169
+ detached recovery graph generations:
170
+
171
+ ```ts
172
+ const allocation = renderer.inspect().renderGraphGenerationAllocation;
173
+ if (allocation?.availability === 'complete') {
174
+ console.log(allocation.liveBytes, allocation.pendingRetirementBytes, allocation.peakBytes);
175
+ for (const entry of allocation.entries) {
176
+ console.log(entry.generation, entry.roles, entry.retirement, entry.allocation);
177
+ }
178
+ }
179
+ ```
180
+
181
+ `roles` identifies `active`, `candidate`, and `retiring` ownership; `retirement`
182
+ is `active`, `pending`, or `failed`. `liveBytes` and
183
+ `pendingRetirementBytes` are current logical bytes, while `peakBytes` is the
184
+ simultaneous logical high-water mark captured at compile, replacement, and
185
+ retirement events. `failedRetirementCount` and `failedRetirementBytes` keep a
186
+ destroy refusal, throw, or fence failure visible until its owner is repaired.
187
+ `availability` is `complete`, `partial`, or `unavailable`, with
188
+ `unavailableGenerationCount` explaining omitted graph facts. `unit` is
189
+ `engine-allocation-bytes` and `physicalResidency` is always `unknown`: imported
190
+ resources remain with their importing owner and logical byte size is not a VRAM
191
+ measurement. After a device-loss recovery, inspect the replacement generation
192
+ and retry the frame; a retained failed or pending row is recovery evidence.
83
193
 
84
194
  ## RenderFeature: the producer seam (first-read index)
85
195
 
@@ -92,6 +202,589 @@ fallback material. The projection preserves `layoutIdentity`,
92
202
  `programIdentity`, `cookIdentity`, and `materialPublicationIdentity` so a
93
203
  stale draw can be traced to the first producer divergence.
94
204
 
205
+ ### Single-layer medium Surface
206
+
207
+ `MaterialAsset.surface` selects the Engine template and one imported Surface
208
+ implementation. A Standard Surface imports
209
+ `forgeax_material::surface_v1::{SurfaceInput, SurfaceData}` and exports
210
+ `evaluate_surface(SurfaceInput) -> SurfaceData`. A
211
+ `model: 'single-layer-medium'` Surface instead imports
212
+ `forgeax_material::single_layer_medium_surface_v1::{SingleLayerMediumSurfaceInput,
213
+ SingleLayerMediumSurfaceData}` and exports that separate input/data ABI. The
214
+ Surface returns normal, roughness, coverage, foam, absorption, scattering, IOR,
215
+ and phase facts; Render performs the finite Beer-Lambert/single-scatter
216
+ integral, one Fresnel allocation, and consumes renderer-owned paired
217
+ color/depth frame facts. `maxDistanceMeters` is a finite authored upper bound
218
+ in metres for the sky-miss case. A valid value is carried through the Surface
219
+ ABI and Cook output, while invalid or absent input uses the conservative Engine
220
+ bound; it never turns an unavailable depth producer into a synthetic sample.
221
+ The Surface does not declare a stage, binding, attachment, BRDF, or vertex
222
+ displacement.
223
+
224
+ ```ts
225
+ import { definePack, definePackageId } from '@forgeax/engine/pack/source';
226
+ import { ok, type MaterialAsset } from '@forgeax/engine/types';
227
+
228
+ const packageId = definePackageId('01900000-0000-7000-8000-000000000001');
229
+
230
+ const material = {
231
+ kind: 'material',
232
+ surface: {
233
+ model: 'single-layer-medium',
234
+ module: 'game::water_surface_a',
235
+ dynamicInput: {
236
+ name: 'waterEvents',
237
+ fields: [
238
+ { name: 'position', type: 'vec3<f32>' },
239
+ { name: 'time', type: 'f32' },
240
+ { name: 'eventId', type: 'u32' },
241
+ ],
242
+ maxRecords: 64,
243
+ maxDomains: 8,
244
+ maxPageBytes: 2048,
245
+ maxBindings: 1,
246
+ maxEventsPerSample: 8,
247
+ },
248
+ },
249
+ passes: [{ name: 'color', program: { module: 'forgeax::single-layer-medium' } }],
250
+ parameters: [
251
+ { name: 'coverage', type: 'f32' },
252
+ { name: 'absorption', type: 'vec3' },
253
+ ],
254
+ values: { coverage: 0.85, absorption: [0.22, 0.07, 0.025] },
255
+ } satisfies MaterialAsset;
256
+
257
+ export default definePack({
258
+ schemaVersion: '2.0.0',
259
+ packageId,
260
+ name: 'Water',
261
+ build: () => ok({ 'material/water': material }),
262
+ });
263
+ ```
264
+
265
+ The authored module reads that schema through the compiler-generated accessor;
266
+ parameter names are fields on `material`, not free uniforms or string lookups:
267
+
268
+ ```wgsl
269
+ #define_import_path game::water_surface_a
270
+ #import forgeax_material::parameters::{material}
271
+ #import forgeax_material::single_layer_medium_surface_v1::{SingleLayerMediumSurfaceInput, SingleLayerMediumSurfaceData}
272
+
273
+ fn evaluate_surface(input: SingleLayerMediumSurfaceInput) -> SingleLayerMediumSurfaceData {
274
+ return SingleLayerMediumSurfaceData(
275
+ normalize(input.geometricNormalWS),
276
+ 0.16,
277
+ material.coverage,
278
+ 0.0,
279
+ material.absorption,
280
+ vec3<f32>(0.018, 0.04, 0.085),
281
+ 1.333,
282
+ 0.24,
283
+ 900.0,
284
+ );
285
+ }
286
+ ```
287
+
288
+ Cook validates this accessor against `parameters`, the Pack transport writes
289
+ the cooked material through JSON while preserving its GUID, and runtime loads
290
+ that GUID before allocating the `MaterialAsset` handle used by `MeshRenderer`.
291
+ The executable [LightProbe to ProbeBlend to Surface example](#lightprobe-to-probeblend-to-surface-example)
292
+ shows the `loadByGuid<MaterialAsset>` → World → `MeshRenderer` → `draw` half of
293
+ the same route.
294
+
295
+ The generated dynamic page is a bounded, read-only storage record. Create one
296
+ `ReadonlyDynamicInputPage`, write only changed records, then reserve an explicit
297
+ `{ domain, recordStart, recordCount, instanceIndex, member }` range for each draw.
298
+ `member` is the stable public address
299
+ `{ worldIdentity: world.identity, entityKey, drawItemIndex, instanceOrdinal }`.
300
+ Its
301
+ `contentRevision`, `bufferGeneration`, and `deviceGeneration` are separate
302
+ facts; stale ranges and consumption before an upload return a structured error.
303
+ The page uses the existing instance bind group's free binding and never uses
304
+ skinning's `customDataStart`. Publish the page to the existing Renderer owner
305
+ before drawing:
306
+ `renderer.setSurfaceDynamicInput({ page, ranges, projectionRevision, frameTime })`.
307
+ Advance `projectionRevision` only when range membership or addresses change;
308
+ record values and World time keep the current revision so stable frames reuse
309
+ the retained validation and consumption projection.
310
+ Range order is irrelevant: Render joins each range to the admitted candidate by
311
+ `member` and rejects missing, duplicate, or non-admitted identities.
312
+
313
+ #### Linear HDR optical oracle
314
+
315
+ The physical oracle observes the real Pack → GUID → World → medium nearest/color
316
+ path before exposure, tone mapping, and anti-aliasing. Arm the next successful
317
+ submit with `renderer.requestObservation(['linear-hdr'])`, keep its `FrameReceipt`,
318
+ then call `renderer.observe(receipt, { include: ['linear-hdr'] })`. The returned
319
+ bytes are padded-row `rgba16float`; `frameId`, device and graph generations,
320
+ texture identity, and readback identity bind them to that exact completed frame.
321
+ When no observation is requested, the renderer does not copy, map, or synchronously
322
+ read this attachment.
323
+
324
+ > [!IMPORTANT]
325
+ > Compare optical math in this linear attachment. A canvas screenshot is an
326
+ > exposed, tone-mapped, anti-aliased presentation artifact and cannot prove the
327
+ > Beer-Lambert, scatter, or Fresnel contract.
328
+
329
+ The permanent fixture loads authored water through the Preview Pack, freezes the
330
+ camera, medium parameters, paired background, light, event time, and fixed ROI,
331
+ then compares the observed half-floats with an independent double-precision
332
+ screen-space reference. Its evidence also carries the actual nearest/color
333
+ program and resource generations plus the completed submission receipt.
334
+
335
+ Author 4x coverage through the Camera component. ECS fields retain their numeric
336
+ schema representation; `ANTIALIAS_MSAA` extracts to the public closed
337
+ `antialias: 'msaa'` render fact:
338
+
339
+ ```ts
340
+ import { ANTIALIAS_MSAA, Camera, orthographic } from '@forgeax/engine/render';
341
+
342
+ world.spawn({
343
+ component: Camera,
344
+ data: {
345
+ ...orthographic({ left: -3, right: 3, bottom: -2, top: 2 }),
346
+ antialias: ANTIALIAS_MSAA,
347
+ },
348
+ });
349
+ ```
350
+
351
+ The edge oracle independently projects the authored rod, wall, shoreline, and
352
+ rough-water rectangles onto the canonical four sample positions. Fully covered
353
+ control regions calibrate each authored linear-HDR endpoint. For opaque edges,
354
+ the nearest depth sample selects its matching color before the full-coverage
355
+ water pass. For partial water coverage, the final hardware resolve averages the
356
+ independently predicted covered samples. The oracle rejects the opposite policy,
357
+ checks a small moving-camera interval, repeats after an 800-by-450 backing resize,
358
+ and keeps the physical maximum per-channel error at `0.05`. Its pure four-sample
359
+ counterexample separately proves why averaging color and then choosing the
360
+ nearest depth loses the color/depth pair.
361
+
362
+ ```sh
363
+ FORGEAX_SURFACE_LANE_PARITY=1 pnpm exec vitest run --project=dawn --retry=0 \
364
+ --maxWorkers=1 --no-file-parallelism \
365
+ packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts
366
+
367
+ FORGEAX_SURFACE_LANE_PARITY=1 pnpm exec vitest run \
368
+ --config vitest.browser.config.ts --project=browser --retry=0 \
369
+ --maxWorkers=1 --no-file-parallelism \
370
+ packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts
371
+
372
+ FORGEAX_SURFACE_MSAA4X=1 FORGEAX_SURFACE_LANE_PARITY=1 \
373
+ pnpm exec vitest run --project=dawn --retry=0 --maxWorkers=1 \
374
+ --no-file-parallelism \
375
+ packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts
376
+
377
+ VITE_FORGEAX_SURFACE_MSAA4X=1 FORGEAX_SURFACE_LANE_PARITY=1 \
378
+ pnpm exec vitest run --project=browser --retry=0 --maxWorkers=1 \
379
+ --no-file-parallelism \
380
+ packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts
381
+
382
+ # Public App lifecycle: World-time water events plus a cooked GPU splash.
383
+ FORGEAX_SURFACE_APP_LIFECYCLE_ONLY=1 pnpm exec vitest run --project=dawn \
384
+ --retry=0 --maxWorkers=1 --no-file-parallelism \
385
+ packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts
386
+
387
+ VITE_FORGEAX_SURFACE_APP_LIFECYCLE_ONLY=1 pnpm exec vitest run --project=browser \
388
+ --retry=0 --maxWorkers=1 --no-file-parallelism \
389
+ packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts
390
+ ```
391
+
392
+ The lifecycle gate enqueues two deduplicated impacts into one attached World.
393
+ Its Update system takes one `Time.elapsed` snapshot, stamps both Surface records,
394
+ and starts the matching `ParticleEffectPlayer` edges. The splash is the authored
395
+ `surface-water-splash.pack.json` asset: the Pack transport resolves its GUID,
396
+ `loadVfxGpuEffect` loads its cooked program, and `createVfxRuntimeHost` contributes
397
+ the production renderer feature. The gate rejects an effect without a renderer;
398
+ an intent-only program is not visible evidence.
399
+
400
+ Before each target submit, the gate arms the existing `linear-hdr` observation.
401
+ It reads pre-frozen 16-by-16 water-A, water-B, and above-water splash ROIs from
402
+ that completed receipt, and compares every pixel. The mask never depends on
403
+ brightness, medians, or observed error. Evidence carries the frame, device,
404
+ graph, texture, and readback identities. The active splash ROI must change and
405
+ return to its baseline after the effect expires; the VFX host must also retire
406
+ both players. `rgba16float` samples remain unclamped linear values, including
407
+ values above 1, and use the same maximum per-channel error of `0.05` at every
408
+ lifecycle stage. The oracle does not round them through an 8-bit domain.
409
+
410
+ A real wait while paused preserves World time, committed VFX state, receipt
411
+ count, and the exact active completed image: `paused` reuses the active receipt
412
+ and does not submit a zero-delta frame. `resume()` continues from retained World
413
+ time without adding the paused host interval or replaying either splash. The
414
+ later `stepFrame(0.05)` calls are explicit deterministic simulation steps used
415
+ to reach camera coverage and expiry; they advance World time and therefore are
416
+ not evidence that a paused App remains frozen. In Dawn only, a timer supplies
417
+ the missing host scheduling primitive; the same App remains the sole owner of
418
+ World update and Renderer draw.
419
+
420
+ ### LightProbe to ProbeBlend to Surface example
421
+
422
+ The following scene uses one authored water material for two entities. Each
423
+ entity selects a different local `LightProbe` domain, while both consume the
424
+ same bounded dynamic-input page. The renderer derives `ProbeBlendRecord`, slot,
425
+ generation, and the direct or scene-index consumer lane from the attached
426
+ World; application code does not build a second probe table.
427
+
428
+ `drawProbeLitWater` receives an already-constructed Runtime `Renderer`.
429
+ Construct it at the Runtime boundary with `await createRenderer(canvas)`, check
430
+ the returned `Result` (`if (!result.ok) throw result.error`), and pass
431
+ `result.value` to this helper. Runtime completes renderer initialization before
432
+ returning that value, so the render helper does not own a second initialization
433
+ promise.
434
+
435
+ ```ts
436
+ import { Time, World } from '@forgeax/engine-ecs';
437
+ import { createPlaneGeometry } from '@forgeax/engine-geometry';
438
+ import {
439
+ Camera,
440
+ DirectionalLight,
441
+ LightProbe,
442
+ MeshFilter,
443
+ MeshRenderer,
444
+ ReadonlyDynamicInputPage,
445
+ type Renderer,
446
+ } from '@forgeax/engine-render';
447
+ import { Transform } from '@forgeax/engine-scene';
448
+ import { type AssetRegistry } from '@forgeax/engine-assets-runtime';
449
+ import type { AssetGuid, MaterialAsset } from '@forgeax/engine-types';
450
+
451
+ export async function drawProbeLitWater(
452
+ renderer: Renderer,
453
+ assets: AssetRegistry,
454
+ waterGuid: AssetGuid,
455
+ ) {
456
+ // The Pack producer cooks the authored MaterialAsset and its Surface module;
457
+ // runtime consumers load only the GUID-addressed publication.
458
+ const loaded = await assets.loadByGuid<MaterialAsset>(waterGuid);
459
+ if (!loaded.ok) throw loaded.error;
460
+ const water = loaded.value;
461
+ const dynamicInput = water.surface?.dynamicInput;
462
+ if (water.surface?.model !== 'single-layer-medium' || dynamicInput === undefined) {
463
+ throw new Error('expected an authored single-layer-medium Surface');
464
+ }
465
+
466
+ const world = new World();
467
+ const plane = createPlaneGeometry(1.8, 1.8).unwrap();
468
+ const mesh = world.allocSharedRef('MeshAsset', plane);
469
+ const material = world.allocSharedRef('MaterialAsset', water);
470
+ const waterEntities = [-1.2, 1.2].map((x) =>
471
+ world
472
+ .spawn(
473
+ { component: Transform, data: { pos: [x, 0, 0] } },
474
+ { component: MeshFilter, data: { assetHandle: mesh } },
475
+ { component: MeshRenderer, data: { materials: [material] } },
476
+ )
477
+ .unwrap(),
478
+ );
479
+
480
+ const red = new Float32Array(27);
481
+ red[0] = 1.8;
482
+ const blue = new Float32Array(27);
483
+ blue[2] = 1.8;
484
+ world.spawn(
485
+ { component: Transform, data: { pos: [-1.2, 0, 0] } },
486
+ { component: LightProbe, data: { irradiance: red, radius: 1 } },
487
+ );
488
+ world.spawn(
489
+ { component: Transform, data: { pos: [1.2, 0, 0] } },
490
+ { component: LightProbe, data: { irradiance: blue, radius: 1 } },
491
+ );
492
+ world.spawn(
493
+ { component: Transform, data: { pos: [0, 0, 6] } },
494
+ {
495
+ component: Camera,
496
+ data: { fov: Math.PI / 4, aspect: 16 / 9, near: 0.1, far: 20 },
497
+ },
498
+ );
499
+ world.spawn({
500
+ component: DirectionalLight,
501
+ data: { direction: [0, 0, -1], color: [1, 1, 1], intensity: 1 },
502
+ });
503
+
504
+ const page = ReadonlyDynamicInputPage.create({
505
+ sourceId: 'water-events',
506
+ pageId: 1,
507
+ schema: dynamicInput,
508
+ }).unwrap();
509
+ const eventLifetimeSeconds = 0.45;
510
+ const events = [
511
+ { position: [-1.2, 0, 0] as const, time: 0, eventId: 1 },
512
+ { position: [1.2, 0, 0] as const, time: 0, eventId: 2 },
513
+ ] as const;
514
+ let publishedEventCount = 0;
515
+ let projectionRevision = 1;
516
+ let publishedDeviceGeneration = renderer.inspect().frame.deviceGeneration;
517
+ page.reconfigureDevice(publishedDeviceGeneration).unwrap();
518
+
519
+ const reserveFreshRanges = () =>
520
+ waterEntities.map((entityKey, index) =>
521
+ page
522
+ .reserveRange({
523
+ domain: index === 0 ? 'water-left' : 'water-right',
524
+ recordStart: index,
525
+ recordCount: 1,
526
+ instanceIndex: index,
527
+ member: {
528
+ worldIdentity: world.identity,
529
+ entityKey,
530
+ drawItemIndex: 0,
531
+ instanceOrdinal: 0,
532
+ },
533
+ })
534
+ .unwrap(),
535
+ );
536
+ let ranges = reserveFreshRanges();
537
+ let deviceLost = renderer.state() === 'device-lost';
538
+
539
+ const publishDynamicInput = (now: number): void => {
540
+ const activeEvents = events.filter(
541
+ (event) => now >= event.time && now - event.time < eventLifetimeSeconds,
542
+ );
543
+ const recordsToRewrite = Math.max(publishedEventCount, activeEvents.length);
544
+ for (let index = 0; index < recordsToRewrite; index += 1) {
545
+ page
546
+ .writeRecord(
547
+ index,
548
+ activeEvents[index] ?? { position: [0, 0, 0], time: -1_000, eventId: 0 },
549
+ )
550
+ .unwrap();
551
+ }
552
+ publishedEventCount = activeEvents.length;
553
+ renderer.setSurfaceDynamicInput({ page, ranges, projectionRevision, frameTime: now });
554
+ };
555
+
556
+ const recoverAndRepublish = async (): Promise<number> => {
557
+ if (renderer.state() !== 'device-lost') {
558
+ throw new Error('water recovery requires the Renderer device-lost state');
559
+ }
560
+ const recovered = await renderer.recover();
561
+ if (!recovered.ok) throw recovered.error;
562
+ const nextDeviceGeneration = renderer.inspect().frame.deviceGeneration;
563
+ if (nextDeviceGeneration <= publishedDeviceGeneration) {
564
+ throw new Error('water recovery did not publish a new device generation');
565
+ }
566
+ const previousRanges = ranges;
567
+ page.reconfigureDevice(nextDeviceGeneration).unwrap();
568
+ ranges = reserveFreshRanges();
569
+ if (
570
+ ranges.some(
571
+ (range, index) =>
572
+ range === previousRanges[index] ||
573
+ range.deviceGeneration !== nextDeviceGeneration ||
574
+ range.bufferGeneration === previousRanges[index]?.bufferGeneration,
575
+ )
576
+ ) {
577
+ throw new Error('water recovery reused a pre-recovery dynamic range');
578
+ }
579
+ publishedDeviceGeneration = nextDeviceGeneration;
580
+ projectionRevision += 1;
581
+ publishDynamicInput(world.getResource(Time).elapsed);
582
+ deviceLost = false;
583
+ return nextDeviceGeneration;
584
+ };
585
+
586
+ const unsubscribe = renderer.subscribe((event) => {
587
+ if (event.kind === 'state-changed' && event.current === 'device-lost') {
588
+ deviceLost = true;
589
+ }
590
+ });
591
+ try {
592
+ const attachment = renderer.attach(world);
593
+ if (!attachment.ok) throw attachment.error;
594
+ const attached = attachment.value;
595
+ publishDynamicInput(world.getResource(Time).elapsed);
596
+ world.update(1 / 60).unwrap();
597
+ const submitAndObserve = async () => {
598
+ // The Renderer keeps this logical World lease valid across recovery;
599
+ // retry the same lease after the page and ranges are revalidated.
600
+ const submitted = renderer.draw({
601
+ leases: [attached],
602
+ camera: { lease: attached },
603
+ environment: { lease: attached },
604
+ });
605
+ if (!submitted.ok) throw submitted.error;
606
+ const completed = await submitted.value.completed;
607
+ if (!completed.ok) throw completed.error;
608
+ const observed = await renderer.observe(submitted.value, { include: ['draws'] });
609
+ if (!observed.ok) throw observed.error;
610
+ return submitted.value;
611
+ };
612
+
613
+ let submitted: Awaited<ReturnType<typeof submitAndObserve>>;
614
+ try {
615
+ submitted = await submitAndObserve();
616
+ } catch (cause) {
617
+ // A first frame can fail before it yields a receipt. Recover only when
618
+ // the public lifecycle reports the actual device-lost state.
619
+ if (renderer.state() !== 'device-lost') throw cause;
620
+ await recoverAndRepublish();
621
+ submitted = await submitAndObserve();
622
+ }
623
+ if (deviceLost || renderer.state() === 'device-lost') {
624
+ await recoverAndRepublish();
625
+ submitted = await submitAndObserve();
626
+ }
627
+ const inspection = renderer.inspect();
628
+ if (submitted.deviceGeneration !== publishedDeviceGeneration) {
629
+ throw new Error('water draw receipt does not match the published device generation');
630
+ }
631
+ return {
632
+ world,
633
+ attached,
634
+ page,
635
+ receipt: submitted,
636
+ submission: inspection.renderScene.submission,
637
+ deviceGeneration: inspection.frame.deviceGeneration,
638
+ };
639
+ } finally {
640
+ unsubscribe();
641
+ }
642
+ }
643
+ ```
644
+
645
+ The repository exercises this route through real Pack/GUID publication and a
646
+ physical texture or compositor readback:
647
+
648
+ ```bash
649
+ pnpm exec vitest run --project=dawn \
650
+ packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts --retry=0
651
+ pnpm exec vitest run --config vitest.browser.config.ts --project=browser \
652
+ packages/runtime/src/__tests__/surface-standard-pipeline.browser.test.ts --retry=0
653
+
654
+ # Same material, scene, time, events, ProbeBlend domains, exposure, and ROI;
655
+ # cold direct -> GPU -> direct. The original 0.05 RGB oracle remains active.
656
+ FORGEAX_SURFACE_LANE_PARITY=1 FORGEAX_DAWN_LIGHTWEIGHT=1 \
657
+ pnpm exec vitest run --project=dawn \
658
+ packages/runtime/src/__tests__/surface-standard-pipeline.dawn.test.ts --retry=0
659
+ ```
660
+
661
+ Direct medium draws use a dedicated group(3): ordinary instance transforms,
662
+ the retained ProbeBlend record, dynamic page, per-member frame rows, shared
663
+ frame time, and a producer-owned per-draw frame-base uniform. The vertex shader
664
+ addresses `frameBase + instance_index`; neither GUID order nor a scene-index
665
+ visible slot is used to infer that address. Missing member identity, page,
666
+ generation, or frame-base publication fails the draw through the structured
667
+ Render error path.
668
+
669
+ After a successful submission, `renderer.inspect().renderScene.gpuDriven.surfaceArtifact`
670
+ exposes the selected direct/scene-index entries and artifact/resource
671
+ generation. `renderer.inspect().renderScene.submission` separates
672
+ `requestedLane` from the command-derived `actualLane` and publishes only
673
+ nearest/color passes that encoded a real `draw*` or `draw*Indirect` command.
674
+ When the current Standard frame carries the reflection fallback MRT, the same
675
+ submission projection reports `actualLane: 'direct'` with
676
+ `actualLaneReason: 'reflection-fallback-mrt'`. This is a pass-ownership
677
+ constraint, not a device-capability result; the reason is absent again when a
678
+ later frame resumes the ordinary GPU-driven lane.
679
+ Direct rows contain the actual command range (`firstInstance` remains `0`),
680
+ the producer's separate `surfaceFrameBase`, and the member identities bound by
681
+ that command. Every command reports the exact selected artifact's
682
+ `receiptIdentity`/`receiptGeneration`; `programEvidence: 'missing'` means the
683
+ recorder could not prove that producer identity. Indirect rows contain the
684
+ actual indirect buffer identity and byte offset. `status: 'completed'` is published only after the existing queue
685
+ completion fence resolves for the same device generation.
686
+
687
+ GPU member identity is opt-in because it requires readback. Call
688
+ `await renderer.observe(frameReceipt, { include: ['draws'] })` before reading
689
+ the inspection row. Until that call, an indirect pass reports
690
+ `memberEvidence: 'indirect-readback-required'` and omits members. The observe
691
+ path reuses the selector telemetry copy, reads its actual visible rows, and
692
+ then reports `indirect-visible-readback`. The readback carries its recording
693
+ sequence, frame/device/resource/view identity, and per-pass indirect ranges;
694
+ an older or reordered shared-buffer result is rejected even within one device
695
+ generation. Nearest and color share the same
696
+ visible member set because both consume the same submitted selector/indirect
697
+ projection; each pass still has its own encoded command receipt. CPU candidate
698
+ lists are never substituted for this GPU readback. The lane-parity gates pair
699
+ these command facts with completed fixed-ROI pixels at the unchanged `0.05`
700
+ RGB epsilon.
701
+
702
+ The public consumer sequence is `draw` -> `observe` -> `inspect`:
703
+
704
+ ```ts
705
+ import type { SurfaceGpuIndirectParameters } from '@forgeax/engine/render';
706
+
707
+ const receipt = renderer.draw(frameInput);
708
+ if (!receipt.ok) throw receipt.error;
709
+ const observed = await renderer.observe(receipt.value, { include: ['draws'] });
710
+ if (!observed.ok) throw observed.error;
711
+ const inspection = renderer.inspect();
712
+ const completePasses = (inspection.renderScene.submission?.passes ?? []).filter(
713
+ (pass) => pass.memberEvidence === 'indirect-visible-readback',
714
+ );
715
+ const indirectParameters: readonly SurfaceGpuIndirectParameters[] = completePasses.flatMap(
716
+ (pass) => {
717
+ if (pass.indirectParameters === undefined) throw new Error('complete GPU readback is missing');
718
+ return pass.indirectParameters;
719
+ },
720
+ );
721
+ void indirectParameters;
722
+ ```
723
+
724
+ `pass.indirectParameters` appears only after the requested observation resolves
725
+ and the renderer accepts a complete matching GPU readback. A pass before
726
+ readback, with no complete identity match, reports
727
+ `memberEvidence: 'indirect-readback-required'` and has no parameters. A valid
728
+ readback whose bounded command sample is truncated reports
729
+ `memberEvidence: 'indirect-visible-readback-truncated'` and also has no
730
+ parameters; its saved and dropped counts remain diagnostic facts. GPU-byte
731
+ truncation, a stale/reordered copy, or an identity mismatch is rejected and
732
+ leaves the pass in `indirect-readback-required`. Only a complete valid copy
733
+ reports `memberEvidence: 'indirect-visible-readback'` with parameters. These
734
+ states are diagnostic facts and never proof when the required evidence is
735
+ absent. Each record is bound to the same
736
+ `sequence`, `frameId`, `deviceGeneration`, `resourceGeneration`, `viewIdentity`,
737
+ and Surface `pass` as the published submission, plus the exact indirect
738
+ `indirectBufferIdentity` and `indirectOffset`. For indexed commands, `count`,
739
+ `first`, `instanceCount`, signed `baseVertex`, and `firstInstance` decode the
740
+ five WebGPU indirect words. For non-indexed commands, `count`, `first`,
741
+ `instanceCount`, and `firstInstance` decode the four words and `baseVertex` is
742
+ zero by definition.
743
+
744
+ Each pass exposes `totalCommandCount`, bounded `savedCommandCount`,
745
+ `droppedCommandCount`, and `truncated`. A truncated indirect row is diagnostic
746
+ evidence, never a complete member set; a valid readback labels it
747
+ `indirect-visible-readback-truncated` and omits `indirectParameters`. A direct
748
+ row uses `direct-command-members-truncated` for the same bounded-sample limit.
749
+
750
+ The Render owner validates the cooked layout, source/page identity, generation,
751
+ range bounds, and device storage limit. It begins a detached upload and commits
752
+ the page revision only after every queue write succeeds; graph submission then
753
+ consumes the validated ranges. The same input is carried through GPU
754
+ preparation and the recovery candidate. In the scene-index lane, the renderer
755
+ maps each GPU-written visible member's stable candidate row to a frame row and
756
+ keeps each producer `instanceIndex` in the row; the atomic compact draw slot is
757
+ never used as a dynamic-input address.
758
+
759
+ On a device with compute, storage-buffer, and indirect-drawing capabilities,
760
+ GPU-driven preparation records the published Surface submission and binds the
761
+ Surface page and frame records through the existing GPU-driven instance owner.
762
+ The Standard Forward and Deferred graph owners allocate the two
763
+ `nearest-layer`/`color` targets and share the transmission backdrop producer.
764
+ The backdrop is the paired opaque color input. At 1x, both Standard paths copy
765
+ the completed depth into an `r32float` sampled target. At 4x MSAA, Render does
766
+ not use the hardware-averaged color resolve as the medium backdrop. A typed
767
+ fullscreen producer examines all four depth samples, selects the nearest
768
+ sample, and publishes both that depth and the color at the same sample index.
769
+ The nearest-water pass uses 4x color and depth attachments and the identical
770
+ paired rule before the color pass consumes it. Both resolved depth facts are
771
+ `r32float` with an unfilterable-float/non-filtering binding, so the color pass
772
+ never samples a depth attachment while writing scene depth. Inputs other than
773
+ the admitted 1x or paired 4x forms fail with `resource-descriptor-invalid`;
774
+ `addSingleLayerMediumPasses` never creates a synthetic depth value. This policy
775
+ is a nearest-sample coverage resolve, not an average of already-resolved color.
776
+ The color pass binds the copied depth, the preceding nearest-layer color/depth
777
+ pair, and the shared backdrop through the existing material bind-group owner.
778
+ GPU-driven recording reuses the existing Standard raster
779
+ dispatch with a Surface-family filter; devices without the required
780
+ capability use the existing CPU record lane and its structured admission
781
+ reason. These tests prove the owner boundary and fail-closed behavior;
782
+ Browser, Dawn, and physical GPU frame evidence remain separate gates. A
783
+ missing Surface ABI, pass, resource, or prepared generation is a
784
+ producer/preparation error. The public paired math and two example Surface
785
+ modules live in
786
+ [`src/examples/single-layer-medium-surface.ts`](src/examples/single-layer-medium-surface.ts).
787
+
95
788
  The public route is one `RenderFeature<FrameData>` through the Standard
96
789
  Pipeline and the active RenderGraph pass. In the examples below, `type FrameData`
97
790
  is the producer-owned extracted value. A feature extracts one frame value,
@@ -109,6 +802,14 @@ that was still pending. This is a receipt of admitted commands, not a GPU
109
802
  readback or proof that an indirect draw produced non-zero instances; producers
110
803
  that need those facts must use their existing readback or tape evidence.
111
804
 
805
+ `BarrelDistortion` is supplied by the ordinary ordered RenderFeature host. A
806
+ missing or zero-strength component produces no allocation, upload, or pass;
807
+ the feature still declares its shader for production validation. A positive
808
+ component adds one fullscreen pass after linear-LDR LUT output and before FXAA
809
+ and final encoding. There is no separate built-in registration path, so feature
810
+ ordering and retirement use the same graph and in-flight lifetime rules as
811
+ other post-process features.
812
+
112
813
  ## RenderFeature: the producer seam (first-read index)
113
814
 
114
815
  The public route is one `RenderFeature<FrameData>` through the active RenderGraph
@@ -207,6 +908,8 @@ index path, not a second schema.
207
908
  import {
208
909
  ANTIALIAS_TAA,
209
910
  Atmosphere,
911
+ BLOOM_DISABLED,
912
+ CAMERA_EXPOSURE_MODE_MANUAL,
210
913
  Camera,
211
914
  Fog,
212
915
  type FramePlan,
@@ -229,6 +932,17 @@ code-specific `detail`; they do not create a second registry or silently pick
229
932
  the first entity. Frame facts contain IDs, revisions, and POD values only, not
230
933
  textures, buffers, devices, or other live GPU objects.
231
934
 
935
+ An Atmosphere also requires exactly one `DirectionalLight` sun. A direct render
936
+ extraction with no sun routes `SunCardinalityError` through the World-owned
937
+ error boundary (`console.error` with the structured error) before extraction
938
+ throws; that internal route is deliberately separate from `app.onError`.
939
+ `app.onError` receives errors returned by the App frame loop and renderer event
940
+ stream. Inspect `error.code` and `error.detail.value`, repair the owning World,
941
+ then retry the same extraction or the next frame. For zero suns, spawn one
942
+ `DirectionalLight`; for multiple suns, remove the extras so the Atmosphere has
943
+ one owner. The renderer does not manufacture a fallback sun or retain a
944
+ partially selected environment.
945
+
232
946
  The Standard graph renders an Atmosphere source as a 128-by-128, six-face
233
947
  `rgba16float` sky cube and a background pass before scene geometry. The selected
234
948
  DirectionalLight supplies the sun direction, color, and illuminance; the sun
@@ -240,6 +954,52 @@ Its color and intensity remain the lighting controls; no Skylight means no
240
954
  global ambient contribution. An explicit equirect keeps its image source.
241
955
  Use a neutral Skylight tint when comparing local captures against the sky.
242
956
 
957
+ The cube uses one bounded analytic Rayleigh/Mie daylight evaluator. Rayleigh
958
+ and Mie controls affect their spectral scattering and extinction, while solar
959
+ radiance scales linearly with the selected light. A 1.5 exponent shapes the
960
+ Rayleigh color response before solar scaling; the Mie lobe remains additive.
961
+ A smooth effective air-mass
962
+ bound (four zenith columns) avoids a saturated neutral horizon. This is an
963
+ explicit game-oriented clear-sky approximation, not the retired Perez fit or a
964
+ full spherical multiple-scattering solution. The existing cache owns all work;
965
+ background pixels still sample the cube, and the separate sun disc is unchanged.
966
+
967
+ ### Atmosphere controls and fixed-exposure comparisons
968
+
969
+ `Atmosphere` keeps the visible disc and the analytic sky response as separate
970
+ controls. `circumsolarStrength` and `circumsolarWidth` affect only the Mie
971
+ forward lobe used while producing the cached sky cube. The defaults preserve the
972
+ baseline response; `sunAngularRadius` still controls the separate disc in the
973
+ background pass and does not grow a halo.
974
+
975
+ | Field | Default | Valid range | Meaning |
976
+ |:--|:--:|:--|:--|
977
+ | `circumsolarStrength` | `1` | `[0, 4]` | Multiplier for the circumsolar Mie lobe |
978
+ | `circumsolarWidth` | `1` | `[0.25, 4]` | Relative lobe width; larger values broaden it, smaller values narrow it |
979
+ | `sunAngularRadius` | `0.004675` rad | `[0, +∞)` | Radius of the separate visible sun disc; `0` disables the disc |
980
+
981
+ For an angular or sunset sweep, hold the camera exposure in manual mode and
982
+ disable Bloom so the measurement belongs to the Atmosphere response. Vary the
983
+ two circumsolar fields while leaving `sunAngularRadius` unchanged:
984
+
985
+ ```ts
986
+ world.set(atmosphere, Atmosphere, {
987
+ circumsolarStrength: 1.5,
988
+ circumsolarWidth: 2,
989
+ sunAngularRadius: 0.004675,
990
+ }).unwrap();
991
+ world.set(camera, Camera, {
992
+ exposureMode: CAMERA_EXPOSURE_MODE_MANUAL,
993
+ exposure: 1,
994
+ bloom: BLOOM_DISABLED,
995
+ }).unwrap();
996
+ ```
997
+
998
+ The fixed exposure and Bloom setting are comparison setup, not additional
999
+ Atmosphere state. Use the renderer's submitted frame or a real Browser/Dawn
1000
+ readback for visual claims; source values and a sky-cube upload do not prove a
1001
+ pixel response.
1002
+
243
1003
  The cube has 786,432 texture payload bytes. Its 16-square irradiance cube and
244
1004
  64-square, five-mip prefilter add 274,176 bytes; parameters and vertices total
245
1005
  1,560 bytes, before backend allocation alignment. DeviceScope owns one cached
@@ -347,6 +1107,315 @@ before publication; an invalid update keeps the renderer's last-known-good
347
1107
  fog frame and exposes the structured failure through the existing inspection
348
1108
  path. There is no app-local fog state.
349
1109
 
1110
+ ### VolumetricFog authoring, World time, and recovery
1111
+
1112
+ `VolumetricFog` is an authored, one-owner component backed by a linear 3D
1113
+ `TextureAsset`. The selected light must be a live same-World
1114
+ `DirectionalLight`, `PointLight`, or `SpotLight`; Point/Spot selection also
1115
+ requires the corresponding `Transform`. Validate the authoring POD before
1116
+ spawning the component, then let the existing RenderSystem extract it. The
1117
+ World `Time` resource is the only simulation clock: `world.update(delta)`
1118
+ advances it, and the volume parameter block receives that elapsed value.
1119
+
1120
+ ```ts
1121
+ import { Time, World } from '@forgeax/engine-ecs';
1122
+ import {
1123
+ Atmosphere,
1124
+ BLOOM_DISABLED,
1125
+ CAMERA_EXPOSURE_MODE_MANUAL,
1126
+ Camera,
1127
+ DirectionalLight,
1128
+ VolumetricFog,
1129
+ extractVolumetricFog,
1130
+ perspective,
1131
+ resolveSelectedVolumetricLight,
1132
+ validateVolumetricFog,
1133
+ type VolumetricFogAuthoring,
1134
+ type Renderer,
1135
+ } from '@forgeax/engine-render';
1136
+ import { Transform } from '@forgeax/engine-scene';
1137
+ import type { TextureAsset } from '@forgeax/engine-types';
1138
+
1139
+ const world = new World();
1140
+ const densityAsset: TextureAsset = {
1141
+ kind: 'texture',
1142
+ shape: { viewDimension: '3d', extent: { width: 4, height: 4, depth: 4 } },
1143
+ format: 'r8unorm',
1144
+ colorSpace: 'linear',
1145
+ mips: { kind: 'none' },
1146
+ data: new Uint8Array(4 * 4 * 4).fill(32),
1147
+ };
1148
+ const density = world.allocSharedRef('TextureAsset', densityAsset);
1149
+ // A production project normally obtains this TextureAsset from its existing
1150
+ // Pack/Catalog load path, then allocates the same shared handle in this World.
1151
+ // The inline payload keeps this authoring example executable and deterministic.
1152
+ const camera = world.spawn(
1153
+ { component: Transform, data: { pos: [0, 0, 3] } },
1154
+ { component: Camera, data: {
1155
+ ...perspective({ fov: Math.PI / 4, aspect: 16 / 9 }),
1156
+ exposureMode: CAMERA_EXPOSURE_MODE_MANUAL,
1157
+ exposure: 1,
1158
+ bloom: BLOOM_DISABLED,
1159
+ } },
1160
+ ).unwrap();
1161
+ const sun = world.spawn({
1162
+ component: DirectionalLight,
1163
+ data: { direction: [-0.4, -0.8, -0.3], castShadow: true },
1164
+ }).unwrap();
1165
+ const atmosphere = world.spawn({
1166
+ component: Atmosphere,
1167
+ data: { circumsolarStrength: 1, circumsolarWidth: 1, sunAngularRadius: 0.004675 },
1168
+ }).unwrap();
1169
+
1170
+ const authored: VolumetricFogAuthoring = {
1171
+ light: sun,
1172
+ density: {
1173
+ guid: 'density-demo',
1174
+ generation: 1,
1175
+ shape: { viewDimension: '3d', extent: { width: 4, height: 4, depth: 4 } },
1176
+ format: 'r8unorm',
1177
+ colorSpace: 'linear',
1178
+ },
1179
+ bounds: { min: [-1, -1, -1], max: [1, 1, 1] },
1180
+ extinction: [0.2, 0.2, 0.2],
1181
+ albedo: [0.8, 0.8, 0.8],
1182
+ emission: [0, 0, 0],
1183
+ anisotropy: 0,
1184
+ maxDistance: 50,
1185
+ };
1186
+ const checked = validateVolumetricFog(authored);
1187
+ if (!checked.ok) throw checked.error;
1188
+ const extracted = extractVolumetricFog([checked.value]);
1189
+ if (!extracted.ok) throw extracted.error;
1190
+ const selected = resolveSelectedVolumetricLight(world, sun);
1191
+ if (selected.status === 'unresolved') {
1192
+ throw new Error(`${selected.reason}: ${selected.hint}`);
1193
+ }
1194
+ world.spawn({
1195
+ component: VolumetricFog,
1196
+ data: {
1197
+ light: sun,
1198
+ density,
1199
+ boundsMin: authored.bounds.min,
1200
+ boundsMax: authored.bounds.max,
1201
+ extinction: authored.extinction,
1202
+ albedo: authored.albedo,
1203
+ emission: authored.emission,
1204
+ anisotropy: authored.anisotropy,
1205
+ maxDistance: authored.maxDistance,
1206
+ },
1207
+ }).unwrap();
1208
+
1209
+ world.update(1 / 60).unwrap();
1210
+ const elapsed = world.getResource(Time).elapsed; // the projected volume time
1211
+ void camera;
1212
+ void atmosphere;
1213
+ void elapsed;
1214
+ ```
1215
+
1216
+ If validation fails, read its structured `code`, `expected`, `hint`, and typed
1217
+ `detail`. If light resolution is unresolved, read its `reason`, `expected`,
1218
+ `actual`, and `hint`; repair the owning authoring field, and extract again.
1219
+ Do not manufacture a light or a density fallback. After a submitted frame,
1220
+ `renderer.inspect().volumetricFog` reports `status`, `resourceStage`, the
1221
+ selected-light facts, and candidate/LKG generations. For a renderer device
1222
+ loss, wait for the host recovery boundary, call `await renderer.recover()`,
1223
+ then submit the same World again and inspect the replacement generation:
1224
+
1225
+ ```ts
1226
+ import type { World } from '@forgeax/engine-ecs';
1227
+ import type { Renderer } from '@forgeax/engine-render';
1228
+
1229
+ // After the existing createRenderer(canvas) happy path returns a Renderer,
1230
+ // call `await recoverAndDraw(renderer, world)` with the World above.
1231
+ async function recoverAndDraw(renderer: Renderer, world: World): Promise<void> {
1232
+ const attached = renderer.attach(world);
1233
+ if (!attached.ok) throw attached.error;
1234
+ const lease = attached.value;
1235
+ if (renderer.state() === 'device-lost') {
1236
+ const recovered = await renderer.recover();
1237
+ if (!recovered.ok) throw recovered.error;
1238
+ }
1239
+ const retry = renderer.draw({
1240
+ leases: [lease],
1241
+ camera: { lease },
1242
+ environment: { lease },
1243
+ });
1244
+ if (!retry.ok) throw retry.error;
1245
+ const completed = await retry.value.completed;
1246
+ if (!completed.ok) throw completed.error;
1247
+ const volume = renderer.inspect().volumetricFog;
1248
+ console.log(volume?.status, volume?.resourceStage, volume?.generation);
1249
+ }
1250
+ ```
1251
+
1252
+ The recovery call rebuilds renderer-owned resources; it is not a second volume
1253
+ clock or registry. Pausing the simulation means withholding `world.update`.
1254
+ Equal time and normal monotonic World ticks up to 100 ms retain eligible
1255
+ volume history. Clock rollback, a larger jump, or changed time availability
1256
+ invalidates it. Camera, light, medium, size and resource changes still invalidate
1257
+ history. Integration and shadow visibility use stable cell centers, so a reset
1258
+ does not expose render-frame random noise. The ray uses 96 base intervals;
1259
+ segments within four interval lengths of a selected punctual source use four
1260
+ midpoints to resolve its inverse-square peak and spot cone. This local refinement
1261
+ keeps the density expression and packed shadow resolution unchanged. A real GPU
1262
+ punctual-scattering oracle protects spatial accuracy alongside the continuous-frame
1263
+ browser check; homogeneous transmittance alone cannot detect light-band artifacts.
1264
+ History is clipped to the current
1265
+ neighborhood and its weight decays with elapsed World time (at most 0.875 per
1266
+ frame, about 87 ms half-life at 60 Hz or slower), limiting trails from animated
1267
+ density. `CloudLayer` is the renderer-owned procedural cloud route described
1268
+ below; it does not change the volumetric fog component or add a second clock.
1269
+
1270
+ ### CloudLayer authoring and derived transport
1271
+
1272
+ `CloudLayer` is a single World-authored component. Its schema keeps the seed,
1273
+ layer bounds, noise scale, coverage, density, wind and quality as serializable
1274
+ source facts. `renderComponentsPlugin()` registers it with the normal ECS
1275
+ component lease. Cache bytes, light-space shadow projections, per-view history
1276
+ and GPU handles remain Render-derived state and can be reconstructed from the
1277
+ validated source key.
1278
+
1279
+ Coverage is monotonic: zero clears the layer and increasing it admits more of
1280
+ its weather-shaped body. Detail erodes this body after altitude shaping; it
1281
+ cannot create detached density outside the body. Formation cache version 5
1282
+ uses the same integer hash and density composition as both analytic WGSL
1283
+ paths, with center-aligned periodic interpolation. Layer thickness controls
1284
+ the physical height of two vertical body cells; scale controls four horizontal
1285
+ cells per world-space period. Choose comparable body dimensions for cumulus
1286
+ rather than compressing a broad cloud into a thin layer. The broad field owns
1287
+ 85% of body shaping; attenuated high-frequency octaves and bounded subtractive
1288
+ erosion preserve connected masses instead of dense detached fragments.
1289
+
1290
+ Install the producer through the existing RenderFeature seam when a scene uses
1291
+ clouds:
1292
+
1293
+ ```ts
1294
+ import { World } from '@forgeax/engine-ecs';
1295
+ import { createRenderer } from '@forgeax/engine-runtime';
1296
+ import {
1297
+ Camera,
1298
+ CloudLayer,
1299
+ CloudQualityValue,
1300
+ DirectionalLight,
1301
+ createCloudLayerFeature,
1302
+ perspective,
1303
+ } from '@forgeax/engine-render';
1304
+ import { Transform } from '@forgeax/engine-scene';
1305
+
1306
+ const canvas = document.querySelector('canvas');
1307
+ if (!(canvas instanceof HTMLCanvasElement)) throw new Error('canvas is required');
1308
+ const world = new World();
1309
+ const camera = world.spawn(
1310
+ { component: Transform, data: { pos: [0, 140, 240] } },
1311
+ { component: Camera, data: { ...perspective({ fov: Math.PI / 4, aspect: 16 / 9 }) } },
1312
+ ).unwrap();
1313
+ const sun = world.spawn({
1314
+ component: DirectionalLight,
1315
+ data: { direction: [-0.4, -0.8, -0.3], color: [1, 0.95, 0.9], intensity: 2 },
1316
+ }).unwrap();
1317
+ const cloud = world.spawn({
1318
+ component: CloudLayer,
1319
+ data: {
1320
+ seed: 1337,
1321
+ baseHeight: 120,
1322
+ thickness: 80,
1323
+ scale: 0.004,
1324
+ coverage: 0.48,
1325
+ density: 1,
1326
+ wind: [8, 0, 2],
1327
+ quality: CloudQualityValue.medium,
1328
+ shadowRange: 512,
1329
+ },
1330
+ }).unwrap();
1331
+ const created = await createRenderer(canvas, { features: [createCloudLayerFeature()] });
1332
+ if (!created.ok) throw created.error;
1333
+ const renderer = created.value;
1334
+ const attached = renderer.attach(world);
1335
+ if (!attached.ok) throw attached.error;
1336
+ world.update(1 / 60).unwrap();
1337
+ const drawn = renderer.draw({
1338
+ leases: [attached.value],
1339
+ camera: { lease: attached.value },
1340
+ environment: { lease: attached.value },
1341
+ });
1342
+ if (!drawn.ok) throw drawn.error;
1343
+ console.log(camera, sun, cloud, renderer.inspect().cloudLayer);
1344
+
1345
+ world.set(cloud, CloudLayer, { wind: [10, 0, 2] }).unwrap();
1346
+ const changed = renderer.draw({
1347
+ leases: [attached.value],
1348
+ camera: { lease: attached.value },
1349
+ environment: { lease: attached.value },
1350
+ });
1351
+ if (!changed.ok) {
1352
+ console.error(renderer.state(), renderer.inspect().cloudLayer, changed.error);
1353
+ throw changed.error;
1354
+ }
1355
+ ```
1356
+
1357
+ If the capability report does not admit the cloud lane, disable that feature at
1358
+ assembly time and keep the rest of the renderer running:
1359
+
1360
+ ```ts
1361
+ const created = await createRenderer(canvas, {
1362
+ features: [createCloudLayerFeature({ enabled: false })],
1363
+ });
1364
+ ```
1365
+
1366
+ The runtime `createRenderer()` result is returned after the renderer's internal
1367
+ initialization barrier has completed, so the public `Renderer` can attach and
1368
+ draw immediately after the `Result` check above. The `initialization` Promise
1369
+ belongs to the lower-level internal construction seam.
1370
+
1371
+ The feature extracts the authored component, World `Time`, and the selected
1372
+ directional sun through the normal frame owner. It declares a bounded packed
1373
+ `u32` 3D formation cache with three contiguous R8 planes (`weather`, `body`,
1374
+ `erosion`; `ceil(3 * R8 texels / 4)` elements and `elements * 4` storage
1375
+ bytes), then composites scene-linear HDR color with inverse-view-projection
1376
+ rays, scene depth termination, bounded Beer extinction, solar-column
1377
+ transmittance, and cloud-interior phase scattering. The same formation and
1378
+ coverage contract is used by the CPU reference and the production WGSL path;
1379
+ coverage, density and wind are evaluation inputs and do not rebuild the
1380
+ reusable planes. The erosion plane repeats three times across the body period,
1381
+ adding smaller boundary structure without increasing cache bytes or density
1382
+ reads. Direct and approximate multiple scattering use the same light/phase unit
1383
+ convention as the volume path and CPU optical reference; view and solar
1384
+ sample limits are unchanged. The exported CPU helpers (`buildCloudDensityCache`,
1385
+ `integrateCloudCameraPath`,
1386
+ `integrateCloudSolarColumn`, `integrateCloudInterior`, and
1387
+ `sampleCloudShadow`) remain deterministic reconstruction and recovery tools.
1388
+
1389
+ The cloud shadow target is a square, quality-derived `rgba16float` map whose
1390
+ world-space projection is texel-snapped around the selected sun anchor. The
1391
+ cloud view, shadow and interior consumers share that projection and attenuate
1392
+ direct solar transport once. A cloud-enabled view opts into six additional
1393
+ `rgba16float` temporal surfaces: radiance, transmittance and representative
1394
+ cloud depth, each with current/previous ping-pong slots at `ceil(surface / 2)`
1395
+ resolution. One half-resolution transport raster writes the three surfaces as
1396
+ an MRT from the same camera integral. A full-resolution resolve upsamples those
1397
+ fields, rejects history by world depth, clamps radiance and transmittance to a
1398
+ four-neighbour history envelope, and performs the one HDR composite. The
1399
+ transport samples the world-space cloud-shadow cache for remaining solar
1400
+ optical depth; invalid projection or out-of-range samples use the bounded
1401
+ analytic column fallback. There is no second full-resolution raymarch solely
1402
+ for cloud depth.
1403
+
1404
+ Temporal slots are created only when a view demands them; submit advances the
1405
+ transaction from the resolve pass, while abort, resize, recovery and unload
1406
+ retire the old generation through the normal queue-fence owner.
1407
+
1408
+ Transparent geometry remains depth-read-only unless its authored render state
1409
+ explicitly enables `depthWriteEnabled`; this keeps ordinary alpha smoke from
1410
+ truncating later volume/cloud segments while allowing an intentional cutout or
1411
+ water surface to publish its own depth. Use `inspectCloudLayer()` and the
1412
+ renderer's normal feature inspection to read capability, generation,
1413
+ temporal-reset and resource facts. Inspection distinguishes CPU cache bytes,
1414
+ declared GPU target bytes, and measured GPU bytes; physical adapter timing and
1415
+ residency are reported as unavailable until a prepared adapter submits those
1416
+ receipts. Unavailable compute or storage support is data rather than a hidden
1417
+ fallback texture.
1418
+
350
1419
  Point shadows use one renderer-owned cube-array `ShadowAtlas`. The public
351
1420
  `SHADOW_ATLAS_DEFAULT_FACE_SIZE` and `SHADOW_ATLAS_DEFAULT_LAYERS` constants
352
1421
  are the only capacity owner; extract assigns `shadowAtlasLayer: -1` to
@@ -496,7 +1565,7 @@ switch (spatial.status) {
496
1565
  `history.state`, `history.bytes`, `history.resetCount`, `passRoster`, and
497
1566
  `fallbackSource` are bounded POD facts. They are evidence about the current
498
1567
  owner state, not an acceptance claim: M1/M2 remain an implementation checkpoint
499
- until the paired Browser/Dawn 300-frame carrier, readback, falsifiers, and
1568
+ until the paired Browser/Dawn 60-frame carrier, readback, falsifiers, and
500
1569
  performance gates are present.
501
1570
 
502
1571
  SSR temporal feedback and presentation use distinct coordinates without another
@@ -749,7 +1818,7 @@ that a physical Browser/Dawn workload or timing gate completed.
749
1818
  `renderer.inspect()` and `renderer.observe()` never carry ROI pixels, scanlines,
750
1819
  brightness or level metrics. The hello-fxaa `hello-fxaa/dark-gradient/v1`
751
1820
  fixture report owns those fields and records the fixed camera, low-light scene,
752
- 800x600 resolution, 300-frame schedule, ROI, scanline, backend/lane, surface
1821
+ 800x600 resolution, 60-frame schedule, ROI, scanline, backend/lane, surface
753
1822
  formats, pixel hash, validation errors and mutation falsifiers.
754
1823
 
755
1824
  The report gates are explicit: unique-color ratio $r_U \ge 0.75$, level ratio
@@ -911,20 +1980,131 @@ not from inspecting shader source text for a particular output structure.
911
1980
 
912
1981
  ## Motion Blur temporal consumer
913
1982
 
914
- The Standard camera may carry the presence-enabled `MotionBlur` component. Its
915
- validated parameters are `shutterAngle` in `[0, 360]`, `maxRadiusPixels` in
916
- `[0, 64]`, and integer `sampleCount` in `[4, 16]`. Zero shutter is an explicit
917
- zero-work case. The feature reads the shared `scene-data-temporal` sampled token
918
- and contributes one raster pass after optional TAA and before Bloom. It does
919
- not own a previous frame, write TAA history, allocate storage/compute state, or
920
- cache an RHI handle.
921
-
922
- `renderer.inspect()` exposes only detached Motion Blur POD facts when the
923
- component is active: status, bounded parameters, demand, pass identity, and
924
- the invariant `historyWrites: 0`. Invalid parameters and unavailable temporal
925
- data are structured failures with owner-specific recovery; callers should fix
926
- the component or capability and retry the same frame. The hello-taa carrier
927
- contains the four-lane smoke and falsifier evidence.
1983
+ The Standard camera enables Motion Blur by carrying the presence-enabled
1984
+ component. Authoring stays bounded and validated at the component boundary:
1985
+
1986
+ ```ts
1987
+ import { MotionBlur } from '@forgeax/engine-render';
1988
+
1989
+ const result = world.addComponent(cameraEntity, {
1990
+ component: MotionBlur,
1991
+ data: { shutterAngle: 180, maxRadiusPixels: 32, sampleCount: 8, targetFps: 60 },
1992
+ });
1993
+ if (!result.ok) throw result.error;
1994
+ ```
1995
+
1996
+ | Input / state | Contract |
1997
+ |:--|:--|
1998
+ | Parameters | `shutterAngle ∈ [0, 360]`, `maxRadiusPixels ∈ [0, 64]`, integer `sampleCount ∈ [4, 16]`, integer `targetFps ∈ [0, 240]`; zero shutter/radius is zero work. |
1999
+ | Temporal source | One renderer-owned `scene-data-temporal` sampled target. Motion validity and color reactivity are separate fields. |
2000
+ | Post order | TAA resolve → Motion Blur → auto exposure/Bloom/tone output. Motion Blur never writes TAA history. |
2001
+ | WebGPU/native lane | One 16×16 tile summary dispatch plus one fused reconstruction dispatch. Long vectors use an even-pixel half-resolution source lattice with depth guidance. |
2002
+ | Bounded work | Effective color tiers are exactly `0|4|8|16`; metadata probes are fixed and color work is linear in the tier. There is no radius-squared neighborhood, per-pixel scatter, or atomic accumulation. |
2003
+ | WebGL2 fallback | One directional raster gather, explicitly reported as `raster-limited`; it does not claim compute quality or timing parity. |
2004
+
2005
+ The authored integer sample count maps to one of the four execution tiers:
2006
+ `4..7 → 4`, `8..15 → 8`, and `16 → 16`; a zero-work bypass is `0`. The
2007
+ selected tier is the total color budget for every direction, including a
2008
+ bounded fallback when all candidates are rejected. There is no implicit
2009
+ center tap added to the budget.
2010
+
2011
+ For an uncovered moving support, accepted foreground coverage reconstructs a
2012
+ bounded portion of an opaque silhouette edge rather than averaging rejected
2013
+ support as black. The matching empty-background trail is attenuated by the
2014
+ same fixed factor, so the correction redistributes energy and does not add
2015
+ another sample. If no source is accepted, the receiver's center color remains
2016
+ the conservative fallback. A recovered midpoint anchor is still only partial
2017
+ evidence. Compute and raster-limited lanes share the rule without an extra
2018
+ pass or radius-sized neighborhood.
2019
+
2020
+ Reset and recovery remain observable through the existing temporal owner:
2021
+
2022
+ ```ts
2023
+ const inspection = renderer.inspect();
2024
+ if (
2025
+ inspection.motionBlur?.status === 'reset' &&
2026
+ inspection.motionBlur.resetReason === 'time-discontinuity' &&
2027
+ inspection.temporal.resetReason === 'time-discontinuity'
2028
+ ) {
2029
+ // The next successful draw commits the new baseline; retry the same renderer.
2030
+ renderer.draw(frameInput());
2031
+ }
2032
+ ```
2033
+
2034
+ After a rejected graph or queue submit, `motionBlur.lastFailure` is
2035
+ `'submit-failure'` while the previous successful temporal view remains the
2036
+ baseline. A `'scene-data-unavailable'` failure means the shared
2037
+ `standard-scene-data` producer or its `rgba16float` capability was not
2038
+ admitted; repair that producer/capability and retry the same frame. It never
2039
+ creates a private Motion Blur history target.
2040
+
2041
+ The renderer carries the raw host render sample timestamp through App/Worker
2042
+ and accepts it only after a successful submit. A gap greater than `100 ms`
2043
+ creates a `time-discontinuity` reset; a normal `30 Hz` interval remains valid,
2044
+ and a failed submit cannot advance the accepted temporal clock. Pause/resume
2045
+ also marks the next frame as a new baseline. Instance pose updates preserve
2046
+ the last submitted transform. Collection replacement mints new identity
2047
+ generations by default; a producer that keeps an instance through compaction
2048
+ or reorder can pass its prior generation tokens in the new order, allowing the
2049
+ record stage to pair each current transform with the real prior transform.
2050
+
2051
+ `renderer.inspect().motionBlur` exposes detached POD facts: status (`off`,
2052
+ `active`, `reset`, `limited`, or `invalid`), selected lane, effective tier,
2053
+ pass count, reset reason, last failure, and `historyWrites: 0`. Invalid public
2054
+ parameters return `MotionBlurValidationError` with code
2055
+ `motion-blur-invalid-params`; repair the named component field or capability
2056
+ and retry the same frame. The typed cause is preserved by the public draw
2057
+ result, so a caller can repair the named field without parsing `Error.message`:
2058
+
2059
+ ```ts
2060
+ const result = renderer.draw(frameInput);
2061
+ if (!result.ok && result.error.code === 'motion-blur-invalid-params') {
2062
+ console.error(result.error.detail.field, result.error.detail.value);
2063
+ // Repair the component field, then retry the same frame.
2064
+ }
2065
+ ```
2066
+
2067
+ The hello-taa carrier contains the Dawn, Browser, WebGL2, RhiNull, performance,
2068
+ and source-level falsifier paths.
2069
+
2070
+ ## Multiscale HDR Bloom
2071
+
2072
+ Bloom is a Camera-owned Standard post feature. `bloom` selects the enabled
2073
+ path, while `bloomIntensity` is bounded to `[0, 8]`, `bloomThreshold` to
2074
+ `[0, 65504]`, `bloomSoftKnee` to `[0, 1]`, and `bloomScatter` to `[0, 0.95]`.
2075
+ The defaults are intensity `1`, threshold `1`, soft knee `0.5`, and scatter
2076
+ `0.7`; the disabled value and intensity `0` are exact zero-work cases.
2077
+
2078
+ Game authors import this contract through the public umbrella facade:
2079
+
2080
+ ```ts
2081
+ import {
2082
+ BLOOM_ENABLED,
2083
+ Camera,
2084
+ TONEMAP_REINHARD_EXTENDED,
2085
+ } from '@forgeax/engine/render';
2086
+ ```
2087
+
2088
+ The physical `@forgeax/engine-render` package remains the repository ownership
2089
+ unit; it is not the recommended consumer import path.
2090
+
2091
+ An enabled frame stays in the linear HDR domain and uses one typed graph:
2092
+ five ceil-halved downsample levels `D0..D4`, four tent upsample levels
2093
+ `U3..U0`, then one HDR composite before tone mapping. `D0` applies the soft
2094
+ threshold per source texel and weights each texel by its exact source/target
2095
+ rectangle overlap, including odd extents. `D1..D4` use the shared 13-tap
2096
+ reduction; each upsample blends the current level with the next coarser level
2097
+ using the authored scatter. Composite adds only RGB bloom and preserves the
2098
+ scene alpha.
2099
+
2100
+ `renderer.inspect().bloom` is detached POD evidence from the compiled graph.
2101
+ Its level dimensions, `targetBytes`, pass topology, and
2102
+ `residentChildBytes` come from validated descriptors and active resource
2103
+ ownership, not from an expected-size calculation. A missing, forged, or
2104
+ invalid descriptor graph reports `invalid`; an off camera reports `empty` and
2105
+ zero target/resident bytes. Candidate generations remain behind the existing
2106
+ LKG fence and are released only after the submit fence; device recovery
2107
+ rehydrates the same Bloom bundle before a new receipt is accepted.
928
2108
 
929
2109
  > [!IMPORTANT]
930
2110
  > Render consumes the effective MaterialAsset snapshot produced by extract. Each texture slot carries its own coordinate set and transform into the built-in PBR binding layout; render records do not reinterpret authoring fields or manufacture shader artifacts. The effective `passes` are already validated.
@@ -962,7 +2142,7 @@ base-only root keeps `forward + deferred + shadow`, while declaring any
962
2142
  second-stage layer or physical texture selects `forward + shadow`.
963
2143
 
964
2144
  ```ts
965
- import { Materials } from '@forgeax/engine-render';
2145
+ import { Materials } from '@forgeax/engine/render';
966
2146
 
967
2147
  const material = Materials.standard({
968
2148
  baseColor: [0.72, 0.48, 0.22, 1],
@@ -1065,113 +2245,75 @@ infer state from URLs, or repair a producer failure. Aggregate counts are
1065
2245
  derived from the observations, so an AI can inspect first, repair or recook the
1066
2246
  producer, and retry without guessing at hidden renderer state.
1067
2247
 
1068
- ### Renderer-owned Instances and CPU bounds
2248
+ ### World-owned Instances and CPU bounds
1069
2249
 
1070
- `Instances` is a lightweight ECS association containing only a
1071
- renderer-owned `collectionId`. Create and mutate the canonical packed matrices
1072
- through the same `renderer.instances` owner; it validates mat4 stride, keeps
1073
- revision/capacity/dirty ranges, and returns detached inspection snapshots.
2250
+ `Instances.transforms` holds packed column-major mat4 values in World-managed
2251
+ storage. Author the same field in keyed Scene assets, glTF imports, or ECS;
2252
+ neither creation nor saving requires a Renderer.
1074
2253
 
1075
2254
  ```ts
1076
- function identityMatrices(count: number): Float32Array {
1077
- const matrices = new Float32Array(count * 16);
1078
- for (let index = 0; index < count; index += 1) {
1079
- const offset = index * 16;
1080
- // Column-major identity, with a deterministic layout so the instances are visible.
1081
- matrices[offset] = 1;
1082
- matrices[offset + 5] = 1;
1083
- matrices[offset + 10] = 1;
1084
- matrices[offset + 15] = 1;
1085
- matrices[offset + 12] = (index % 100) * 2;
1086
- matrices[offset + 14] = Math.floor(index / 100) * 2;
1087
- }
1088
- return matrices;
2255
+ const transforms = new Float32Array(20_000 * 16);
2256
+ for (let index = 0; index < 20_000; index++) {
2257
+ const offset = index * 16;
2258
+ transforms[offset] = transforms[offset + 5] =
2259
+ transforms[offset + 10] = transforms[offset + 15] = 1;
2260
+ transforms[offset + 12] = (index % 100) * 2;
2261
+ transforms[offset + 14] = Math.floor(index / 100) * 2;
1089
2262
  }
1090
-
1091
- const collection = renderer.instances.create({
1092
- transforms: identityMatrices(instanceCount),
1093
- }).unwrap();
1094
- world.spawn(
2263
+ const entity = world.spawn(
2264
+ { component: Transform, data: {} },
1095
2265
  { component: MeshFilter, data: { assetHandle: cube } },
1096
2266
  { component: MeshRenderer, data: {} },
1097
- { component: Instances, data: { collectionId: collection.collectionId } },
1098
- );
1099
-
1100
- const patch = identityMatrices(1);
1101
- patch[12] = 4;
1102
- renderer.instances.update(collection.collectionId, {
1103
- start: 0,
1104
- transforms: patch,
1105
- });
2267
+ { component: Instances, data: { transforms } },
2268
+ ).unwrap();
1106
2269
 
1107
- // Replacement keeps the logical identity and publishes a new revision.
1108
- renderer.instances.replace(collection.collectionId, identityMatrices(instanceCount + 1));
1109
- const current = renderer.instances.inspect(collection.collectionId).unwrap();
1110
- const detached = renderer.instances.snapshot(collection.collectionId).unwrap();
1111
- // A read-only observation does not advance the renderer upload cursor.
1112
- const observed = renderer.instances.peek(collection.collectionId).unwrap();
1113
- renderer.instances.release(collection.collectionId);
2270
+ transforms[12] = 4;
2271
+ world.set(entity, Instances, { transforms }).unwrap();
1114
2272
  ```
1115
2273
 
1116
- The lifecycle is deliberately one owner: `create` allocates canonical CPU
1117
- storage, `replace` publishes a complete new revision (and grows capacity when
1118
- needed), `update` validates a bounded matrix interval and records its dirty
1119
- range, `inspect` returns identity/count/capacity/revision, `snapshot` returns a
1120
- detached matrix copy plus dirty ranges for extraction, and `release` makes the
1121
- collection id terminal. `peek` returns the same detached matrices without
1122
- advancing the renderer upload cursor, so read-only inspection cannot hide a
1123
- later dirty range. A caller never provides a GPU buffer or maintains
1124
- backend-specific chunks. `detached.transforms` is an observation, not mutable
1125
- renderer state.
1126
-
1127
- Render snapshots one collection per frame and derives CPU union bounds from the
1128
- mesh AABB, entity world matrix, and detached matrices. Direct, GPU-driven, and
1129
- backend fallback lanes consume that same projection; internal GPU chunks never
1130
- become ECS or game-side state. Missing, malformed, or empty collections fail
1131
- closed or remain a conservative no-cull result without manufacturing an
1132
- identity instance. The initial payload must contain valid matrices: an
1133
- all-zero mat4 has a zero homogeneous `w` and can produce a blank frame. Do not
1134
- add matrix bytes, bounds, or chunk fields to the public `Instances` component or
1135
- duplicate this renderer fact in Pack/asset state.
2274
+ World copies external input and publishes changes through its normal component
2275
+ write path. Mutating the input array after spawn/set does not edit the World.
2276
+ Use `world.set` or a declared writable query; do not mutate inspection snapshots.
2277
+
2278
+ | Boundary | Owner and invariant |
2279
+ |:--|:--|
2280
+ | Authoring and Scene save | World/Scene retains matrices, not renderer-local IDs. Saving and reopening preserves the layout. |
2281
+ | Renderer projection | Current World change evidence drives the existing persistent RenderScene. Independent Renderers accept independently; stable matrices reuse the detached snapshot and revision. |
2282
+ | CPU bounds and drawing | Mesh bounds, holder world transform and instance-local matrices feed one projection shared by direct, GPU-driven and capability fallback lanes. |
2283
+ | Temporal identity | Ordinals retain identity across pose edits. Count changes seed new identities; previous matrices come from the last successful submission. |
2284
+ | GPU lifetime | Record owns buffer capacity, upload revisions, internal chunks and device generation. No game-side collection manager is required. |
2285
+
2286
+ Render derives CPU union bounds from the mesh AABB and instance-local matrices,
2287
+ then applies the holder's world transform. Bounds are not a second authored
2288
+ field. A missing or invalid mesh AABB yields a conservative no-cull result;
2289
+ repair the mesh producer and reload the same asset instead of inventing a
2290
+ game-side bounds override.
2291
+
2292
+ An empty array means zero instances. Invalid stride reports
2293
+ `instance-transforms-stride-mismatch`; non-finite values report
2294
+ `instance-transforms-invalid` before rendering that entity. Repair the World or
2295
+ source Scene data and retry. All-zero matrices are finite but have zero
2296
+ homogeneous `w`; initialize valid transforms for visible geometry.
1136
2297
 
1137
2298
  #### Instance inspection and recovery
1138
2299
 
1139
- `renderer.inspect().instanceCollections` is detached, bounded evidence for
1140
- every live collection. Each row contains:
2300
+ `renderer.inspect().instanceCollections` reports detached runtime evidence.
2301
+ Its `collectionId` is a private projection identity, not a Scene field or an
2302
+ authoring handle. There is no public `renderer.instances` mutation API.
1141
2303
 
1142
2304
  | Field | Meaning |
1143
2305
  |:--|:--|
1144
- | `collectionId`, `count`, `capacity`, `revision` | Logical identity and canonical authoring revision. |
1145
- | `residentGeneration`, `lane` | Device generation and the selected `direct-storage`, `chunked-storage`, `direct-uniform`, `chunked-uniform`, `unresident`, or `unavailable` lane. |
1146
- | `dirtyRanges`, `uploadRanges` | Canonical pending edits and the ranges uploaded by the current resident. |
1147
- | `uploadedBytes`, `requestedBytes`, `supportedBytes` | Measured upload and admission byte facts; unsupported limits remain explicit. |
1148
- | `backend`, `owner` | Backend identity and the fixed owner `renderer.instances`. |
1149
- | `error` | A structured record-stage failure with `code`, `expected`, `hint`, and typed `detail` facts. |
1150
-
1151
- Authoring failures use the same closed `InstanceCollectionError` contract.
1152
- For example, an invalid stride is rejected before publication and carries
1153
- typed facts instead of requiring message parsing:
1154
-
1155
- ```ts
1156
- const result = renderer.instances.create({ transforms: new Float32Array(17) });
1157
- if (!result.ok) {
1158
- const { code, expected, hint, detail, facts } = result.error;
1159
- // facts expose requestedBytes, supportedBytes, backend, owner, cause, and
1160
- // recovery; detail retains operation metadata. Repair the payload and retry.
1161
- void code;
1162
- void expected;
1163
- void hint;
1164
- void detail;
1165
- void facts;
1166
- }
1167
- ```
1168
-
1169
- After a `device-lost` transition, call `await renderer.recover()` and retry the
1170
- same draw request. The renderer drops generation-owned residents, keeps the
1171
- canonical collection, and uploads every matrix into the new resident before
1172
- recording; it does not reuse an old partial dirty range for uninitialized GPU
1173
- memory. If inspection reports `unavailable`, follow its `error.detail.recovery`
1174
- and repair the named capability/producer before retrying.
2306
+ | `collectionId`, `count`, `revision` | Renderer-local projection identity, matrix count and accepted content revision. |
2307
+ | `residentGeneration`, `lane` | Device generation and direct/chunked storage/uniform residency, or unresident/unavailable state. |
2308
+ | `uploadRanges`, `uploadedBytes` | Upload work observed in the latest rendered frame; stable frames report zero. |
2309
+ | `requestedBytes`, `supportedBytes`, `backend`, `owner` | Admission facts. The diagnostic owner label `renderer.instances` names internal residency, not a public API. |
2310
+ | `error` | Structured record failure with `code`, `expected`, `hint` and `detail`. |
2311
+
2312
+ After `device-lost`, call `await renderer.recover()` and retry the same draw.
2313
+ Recovery prepares new device resources from the retained CPU projection, without
2314
+ rescanning World or reusing old GPU handles. Failed candidates release their
2315
+ instance buffers. Writes made while the device is lost remain World-owned and
2316
+ are observed on the next draw. Disposing the Renderer never deletes author data.
1175
2317
 
1176
2318
  The large-instance smoke exercises the real Dawn path for all admitted sizes:
1177
2319
 
@@ -1189,7 +2331,7 @@ transport through the same production fixture and parameterizes the same three
1189
2331
  populations:
1190
2332
 
1191
2333
  ```bash
1192
- pnpm exec vitest run --config vitest.browser.config.ts --project=browser \
2334
+ pnpm exec vitest run --config config/vitest.browser.config.ts --project=browser \
1193
2335
  apps/parity/instancing-static/src/__tests__/instances.browser.test.ts
1194
2336
  ```
1195
2337
 
@@ -1350,7 +2492,9 @@ same forward-only policy. This section is about the Standard lane, not the
1350
2492
  separate Points/Lines admission rule above. See the
1351
2493
  [`shader Surface contract`](../shader/README.md#standard-surface-contract) and
1352
2494
  the [`game-3d` import example](../../templates/game-3d/README.md#import-first-surface-material-example)
1353
- for the source and Pack route.
2495
+ for the source and Pack route. Water and other participating media use the
2496
+ separate [Single-layer medium Surface](#single-layer-medium-surface) ABI and
2497
+ runtime route.
1354
2498
 
1355
2499
  The compiler and renderer share one pure `StandardLayerPlan`:
1356
2500
  `deriveStandardLayerPlan(effectiveParameters)`. It is the only derivation used
@@ -1412,8 +2556,8 @@ separate cache/recovery authority.
1412
2556
  > Owner: render vocabulary and the extract → prepare → record frame boundary. Runtime selects concrete services and calls this package; it does not re-own these tokens.
1413
2557
 
1414
2558
  ```ts
1415
- import { Camera, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
1416
- import { createRenderer } from '@forgeax/engine-runtime';
2559
+ import { Camera, MeshFilter, MeshRenderer } from '@forgeax/engine/render';
2560
+ import { createRenderer } from '@forgeax/engine/runtime';
1417
2561
 
1418
2562
  const renderer = await createRenderer(canvas);
1419
2563
  const attached = renderer.attach(world);
@@ -1435,23 +2579,31 @@ state published by `World.update()`.
1435
2579
  ## Persistent render scene
1436
2580
 
1437
2581
  Every attached World composition bootstraps one renderer-owned CPU projection,
1438
- then consumes each World's component and shared-reference change versions. One identity-based update
2582
+ then consumes each World's current membership and component block revisions. One identity-based update
1439
2583
  publication merges content, root transforms, and instance changes, including
1440
2584
  when all occur in the same frame. Its GPU projection compares affected matrix
1441
- and metadata rows before uploading them. An unchanged frame retains its existing
2585
+ and metadata rows before uploading them. Shared material rows are packed once per
2586
+ GPU synchronization; Primitive, DrawTemplate, and Material rows upload only when
2587
+ their packed bytes change. Failed uploads retain their dirty rows for retry. An unchanged frame retains its existing
1442
2588
  snapshot; unrelated gameplay component writes do not invalidate render state.
1443
2589
  There is no exclusive transform/instance scene admission followed by a separate
1444
2590
  rebuild implementation. Missing producer evidence causes conservative source
1445
2591
  extraction into the same retained projection. World reordering and catalog
1446
2592
  reconciliation preserve surviving slots and their submitted temporal history.
1447
2593
 
1448
- Mutable shared payloads use the same explicit-dirty rule: mutate the resolved
1449
- payload, then call `world.sharedRefs.markChanged(handle)`. The renderer compares
1450
- one monotonic shared-ref epoch on the no-change path, reads changed handles
1451
- when it advances, and refreshes the projection slots indexed by changed
1452
- material or mesh handles. Missing source evidence requests conservative
1453
- source reconciliation. Camera, light, and environment facts refresh independently
1454
- of geometry publication; their edits do not discard retained geometry.
2594
+ Shared runtime material parameters and mesh buffers are authored through
2595
+ `RuntimeMaterialValue` and `RuntimeMeshVertices` from `assets-runtime`, using
2596
+ ordinary managed `world.set` writes. The shared handle remains identity-only.
2597
+ Renderer reverse dependencies wake all users of both the old and new handle
2598
+ when content is rebound or removed. Each Renderer independently accepts its
2599
+ source candidate; one consumer cannot drain another's updates. See the
2600
+ [content contract](../assets-runtime/README.md#runtime-content).
2601
+
2602
+ Camera, light, and environment facts refresh independently of geometry.
2603
+ Initial attachment and updates use the same block candidate discovery and direct
2604
+ entity lookup. GPU dirty row addresses use fixed-width radix ordering before
2605
+ adjacent ranges are coalesced. A failed GPU upload retires that resident; source
2606
+ acceptance and submitted temporal history remain separate boundaries.
1455
2607
 
1456
2608
  Instance collection revisions refresh only their consumers. Visibility and
1457
2609
  parent changes refresh the affected subtree, and joint changes refresh the
@@ -1502,6 +2654,13 @@ The shortest public declaration uses the same `Materials.standard` producer as
1502
2654
  the runtime and imported-skin carriers. `alphaCutoff` is the Alpha Mask
1503
2655
  contract; `castShadow` publishes the matching ShadowCaster pass.
1504
2656
 
2657
+ Frame preparation and device recovery share the material-artifact collector.
2658
+ It indexes ShadowCaster dispatch once by renderable index and material handle;
2659
+ the selected last matching dispatch owns both the program identity and its
2660
+ vertex/fragment entries. Per-draw lookup does not rescan the dispatch roster,
2661
+ and the index is rebuilt for each collection so replacement publications are
2662
+ observed without a persistent cache.
2663
+
1505
2664
  ```ts
1506
2665
  import { Materials } from '@forgeax/engine/render';
1507
2666
 
@@ -1903,6 +3062,10 @@ consume the structured `error.detail` context.
1903
3062
  Use `renderer.inspect()` for a read-only snapshot. Call `renderer.dispose()`
1904
3063
  once or repeatedly; disposal is idempotent. Feature plans are assembled once
1905
3064
  and the Standard host owns graph replacement and last-known-good recovery.
3065
+ Repeated recoverable preparation errors are reported at most once per 60 frames.
3066
+ A changed stage exception name/message is reported immediately, retaining one
3067
+ report record per feature/error owner. Normal asynchronous resource warm-up
3068
+ remains silent.
1906
3069
 
1907
3070
  For a temporary presentation-owner handoff, call `renderer.releaseSurface()`.
1908
3071
  It unconfigures the canvas and makes `draw()` fail closed without disposing the
@@ -1927,10 +3090,10 @@ ownership is [`@forgeax/engine-render-graph`](../render-graph/README.md).
1927
3090
  Dynamic consumers use the same boundary explicitly:
1928
3091
 
1929
3092
  ```ts
1930
- const { Camera, MeshFilter, MeshRenderer } = await import('@forgeax/engine-render');
3093
+ const { Camera, MeshFilter, MeshRenderer } = await import('@forgeax/engine/render');
1931
3094
  ```
1932
3095
 
1933
- `@forgeax/engine-runtime` remains the host assembly entry for `createRenderer` and backend policy. Import `Materials` from this package. Runtime is not a compatibility barrel for render components.
3096
+ `@forgeax/engine/runtime` remains the host assembly entry for `createRenderer` and backend policy. Import `Materials` from `@forgeax/engine/render`. Runtime is not a compatibility barrel for render components.
1934
3097
 
1935
3098
  Optional text, tilemap, and sprite authoring is intentionally isolated from the
1936
3099
  base vocabulary:
@@ -1949,6 +3112,12 @@ consumer that composes sprites with an existing 3D game can call
1949
3112
  `TransparentSort.configure(world, { mode: TransparentSort.layerY, yzAlpha: 1 })`;
1950
3113
  it should not reach into `/internal`.
1951
3114
 
3115
+ Eligible sprite buckets share one fold instance buffer across the typed transparent
3116
+ geometry pass and the legacy sprite pass. Both consume the same head/skip plan;
3117
+ 10,000 equal-depth sprites remain one instanced draw rather than 10,000 submissions.
3118
+ The sprite-atlas Dawn smoke checks this for 60 frames, and its browser probe
3119
+ checks the instance count, typed upload payload and WebGPU validation.
3120
+
1952
3121
  The root barrel does not expose frame stores or extract/plan/record owners.
1953
3122
  Applications contribute work through `RenderFeature` plans; the Standard
1954
3123
  renderer owns graph compilation and submission.
@@ -2049,7 +3218,7 @@ before promoting that status.
2049
3218
 
2050
3219
  The canonical paired carrier is `apps/hello/ssr`. It runs the same fixture at
2051
3220
  `http://127.0.0.1:4173/?forgeax-evidence=ssr` and
2052
- `dawn://hello/ssr?forgeax-evidence=ssr`, records 300-frame identity-bound
3221
+ `dawn://hello/ssr?forgeax-evidence=ssr`, records 60-frame identity-bound
2053
3222
  readbacks, and keeps visual rows in the form `observed` / `verdict` /
2054
3223
  `confidence`. Its performance lane derives the 1920x1080 descriptor and checks
2055
3224
  it against `estimateSsrSpatialMemory`; timestamp or paired-lane absence remains
@@ -2309,19 +3478,24 @@ existing observable state; resource disposal is distinct from JavaScript GC.
2309
3478
 
2310
3479
  ### Volumetric density coordinates
2311
3480
 
2312
- `VolumetricFog.density` is a linear scalar field over `boundsMin` to `boundsMax`.
2313
- World positions map to `(position - boundsMin) / (boundsMax - boundsMin)` and
2314
- sample with clamp-to-edge addressing. The renderer clamps negative density at
2315
- the optical-depth boundary, but adds no bias, octave synthesis, repetition or
2316
- animation. White density means one; zero density contributes no extinction or
2317
- scattering. Noise synthesis belongs to the asset producer. The existing volume
2318
- passes integrate this field against scene depth before output tonemapping.
2319
-
2320
- Density integration uses fixed segment midpoints and the profile's logical
2321
- froxel depth, including the clipped final segment. It does not rotate density
2322
- samples with frame number: static authored air must not create temporal noise.
2323
- Shadow visibility uses matching froxel centers without per-frame XY jitter or
2324
- stochastic byte dithering. Shared PCF shadow filtering remains in effect.
3481
+ `VolumetricFog.density` is a producer-owned linear 3D source tile. In the default
3482
+ `noise` mode the integrator samples repeated world-space coordinates, combines the
3483
+ same three scales used by the approved fog fixture, and uses World time for the
3484
+ continuous advection term. The renderer keeps the source texture resident; it
3485
+ does not rebuild noise data for each frame. Its source expression is mapped to a
3486
+ signed density and clamped only at the optical-depth boundary so Beer-Lambert
3487
+ extinction remains non-negative.
3488
+
3489
+ The local `density` mode samples normalized owner bounds directly. Each owner
3490
+ retains its own texture and optics; overlap sums coefficients before integration.
3491
+ Density integration partitions at owner entry/exit boundaries and uses 96
3492
+ fixed midpoint steps per interval, including the clipped final segment.
3493
+ Segments near a selected PointLight or SpotLight
3494
+ receive four local midpoints to resolve the inverse-square peak without making
3495
+ the whole ray uniformly expensive. Frame identity does not rotate density
3496
+ samples. Shadow visibility uses matching froxel centers without per-frame XY
3497
+ jitter or stochastic byte dithering; shared PCF shadow filtering remains in
3498
+ effect.
2325
3499
 
2326
3500
  ## Camera Depth of Field
2327
3501
 
@@ -2370,3 +3544,61 @@ orthographic camera stay on the normal camera snapshot path with structured
2370
3544
  The Engine Preview/game capability lab uses this same component on its ordinary
2371
3545
  mesh camera, and its settings control toggles component presence through the
2372
3546
  normal World path.
3547
+
3548
+ ## Standard camera barrel distortion
3549
+
3550
+ `BarrelDistortion` is the bounded output-space camera companion. Its
3551
+ `strength` is finite in `[0, 0.35]`; `centerX` and `centerY` are finite fractions
3552
+ in `[0, 1]` with a top-left origin. The Standard stage samples linear-LDR color
3553
+ after LUT and before FXAA and the single output encoding. Positive strength uses
3554
+ the automatic crop rule, so the fixed-FOV camera sees a narrower region. Missing
3555
+ or zero strength preserves the exact zero-work path.
3556
+
3557
+ If a DOM or `OffscreenCanvas` drawing buffer is temporarily zero-sized (for
3558
+ example while hidden or detached), `renderer.draw()` stops before configuring
3559
+ or submitting a zero-sized swapchain texture. The public result is a structured
3560
+ `device-operation-failed` whose nested cause is `rhi-not-available`; the last
3561
+ accepted `FrameReceipt` remains the LKG and a later positive-size draw resumes
3562
+ the same camera mapping without a GPU validation cascade.
3563
+
3564
+ ```ts
3565
+ import { BarrelDistortion } from '@forgeax/engine-render';
3566
+
3567
+ world.addComponent(camera, {
3568
+ component: BarrelDistortion,
3569
+ data: { strength: 0.2, centerX: 0.5, centerY: 0.5 },
3570
+ });
3571
+ ```
3572
+
3573
+ Invalid authoring is a closed error-code path. Narrow on `error.code` and read
3574
+ the structured fields; do not parse `error.message`:
3575
+
3576
+ ```ts
3577
+ import { createBarrelDistortionMapping } from '@forgeax/engine-render';
3578
+
3579
+ const mapping = createBarrelDistortionMapping(1920, 1080, { strength: 0.4 });
3580
+ if (!mapping.ok && mapping.error.code === 'barrel-distortion-invalid-parameter') {
3581
+ const error = mapping.error;
3582
+ const field = error.detail.field;
3583
+ const value = error.detail.value;
3584
+ const detailExpected = error.detail.expected;
3585
+ const expected = error.expected;
3586
+ const hint = error.hint;
3587
+ console.error({ field, value, detailExpected, expected, hint });
3588
+ }
3589
+ ```
3590
+
3591
+ In the design language, `actual` and `bound` map to the public Barrel members
3592
+ `error.detail.value` and `error.detail.expected`. Barrel errors do not expose
3593
+ `error.detail.actual` or `error.detail.bound`; those member names remain owned by
3594
+ `shadow-invalid-config` only.
3595
+
3596
+ `createBarrelDistortionMapping(outputWidth, outputHeight, data)` derives the
3597
+ immutable effective mapping. Reuse the mapping attached to the submitted
3598
+ `FrameReceipt` or the serialized worker frame signal. `mapDisplayToScene` and
3599
+ `mapSceneToDisplay` write continuous output-viewport physical pixels through
3600
+ out-parameters; inverse points cropped outside the display rectangle return
3601
+ `false`. The public `@forgeax/engine-picking` display entrypoints use this
3602
+ mapping once, while legacy picking continues to accept unwarped viewport
3603
+ coordinates. DOM/ShadowRoot HUD layout remains unchanged; world labels and
3604
+ vertex radius sorting must use the inverse mapping in display pixels.