@forgeax/engine-render 0.1.23 → 0.1.25

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 (322) hide show
  1. package/README.md +231 -10
  2. package/dist/assembly/bundler-contract.d.ts +9 -0
  3. package/dist/assembly/bundler-contract.d.ts.map +1 -0
  4. package/dist/assembly/device-feature-admission.d.ts +8 -0
  5. package/dist/assembly/device-feature-admission.d.ts.map +1 -1
  6. package/dist/assembly/extended-lighting-fallback.d.ts +13 -0
  7. package/dist/assembly/extended-lighting-fallback.d.ts.map +1 -0
  8. package/dist/assembly/factory.d.ts +7 -243
  9. package/dist/assembly/factory.d.ts.map +1 -1
  10. package/dist/assembly/material/assembly.d.ts +3 -2
  11. package/dist/assembly/material/assembly.d.ts.map +1 -1
  12. package/dist/assembly/material/standard-layer-projection.d.ts +10 -0
  13. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -0
  14. package/dist/assembly/material/surface-projection.d.ts +24 -0
  15. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  16. package/dist/assembly/material-shader-policy.d.ts +188 -0
  17. package/dist/assembly/material-shader-policy.d.ts.map +1 -0
  18. package/dist/assembly/material-shader-prewarm.d.ts +21 -0
  19. package/dist/assembly/material-shader-prewarm.d.ts.map +1 -0
  20. package/dist/assembly/mesh-ssbo-grow.d.ts +88 -0
  21. package/dist/assembly/mesh-ssbo-grow.d.ts.map +1 -0
  22. package/dist/assembly/recovery/device-loss-fanout.d.ts +14 -0
  23. package/dist/assembly/recovery/device-loss-fanout.d.ts.map +1 -0
  24. package/dist/assembly/recovery/generation.d.ts +46 -0
  25. package/dist/assembly/recovery/generation.d.ts.map +1 -0
  26. package/dist/assembly/recovery/recovery-attempt.d.ts +63 -0
  27. package/dist/assembly/recovery/recovery-attempt.d.ts.map +1 -0
  28. package/dist/assembly/recovery/renderer-recover.d.ts +115 -0
  29. package/dist/assembly/recovery/renderer-recover.d.ts.map +1 -0
  30. package/dist/assembly/renderer-facade.d.ts +10 -0
  31. package/dist/assembly/renderer-facade.d.ts.map +1 -0
  32. package/dist/assembly/renderer-frame-transaction.d.ts +17 -0
  33. package/dist/assembly/renderer-frame-transaction.d.ts.map +1 -1
  34. package/dist/assembly/renderer-helpers.d.ts +19 -0
  35. package/dist/assembly/renderer-helpers.d.ts.map +1 -0
  36. package/dist/assembly/renderer-lifecycle.d.ts +34 -0
  37. package/dist/assembly/renderer-lifecycle.d.ts.map +1 -1
  38. package/dist/assembly/shader-prewarm-policy.d.ts +15 -0
  39. package/dist/assembly/shader-prewarm-policy.d.ts.map +1 -0
  40. package/dist/assembly/webgpu-ready-contract.d.ts +9 -0
  41. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -0
  42. package/dist/assembly/webgpu-ready.d.ts +55 -0
  43. package/dist/assembly/webgpu-ready.d.ts.map +1 -0
  44. package/dist/assembly/webgpu-renderer.d.ts +152 -0
  45. package/dist/assembly/webgpu-renderer.d.ts.map +1 -0
  46. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  47. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  48. package/dist/assets/asset-decoders.d.ts.map +1 -1
  49. package/dist/authoring.mjs +1 -1
  50. package/dist/{chunk-FF7K3FSO.mjs → chunk-ADHHVYLW.mjs} +89 -89
  51. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  52. package/dist/{chunk-YALPS44A.mjs → chunk-E34VL5VI.mjs} +47 -47
  53. package/dist/chunk-E34VL5VI.mjs.map +1 -0
  54. package/dist/{chunk-4L3P6DT5.mjs → chunk-GE3SDD72.mjs} +37 -5
  55. package/dist/chunk-GE3SDD72.mjs.map +1 -0
  56. package/dist/{chunk-HZUPSYW7.mjs → chunk-HKXTW355.mjs} +105 -15
  57. package/dist/chunk-HKXTW355.mjs.map +1 -0
  58. package/dist/chunk-JSEDTZYS.mjs +413 -0
  59. package/dist/chunk-JSEDTZYS.mjs.map +1 -0
  60. package/dist/{chunk-FBC4R44J.mjs → chunk-KM2NOX2I.mjs} +57 -57
  61. package/dist/chunk-KM2NOX2I.mjs.map +1 -0
  62. package/dist/{chunk-DIYW3M5D.mjs → chunk-N3RT2EUR.mjs} +2288 -2238
  63. package/dist/chunk-N3RT2EUR.mjs.map +1 -0
  64. package/dist/{chunk-ZUWKAZ2I.mjs → chunk-TCLDX7SZ.mjs} +1167 -368
  65. package/dist/chunk-TCLDX7SZ.mjs.map +1 -0
  66. package/dist/{chunk-QODA5UKU.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  67. package/dist/chunk-ZZ4YQ474.mjs.map +1 -0
  68. package/dist/components/instances.d.ts +13 -31
  69. package/dist/components/instances.d.ts.map +1 -1
  70. package/dist/construct-renderer.mjs +20688 -16314
  71. package/dist/construct-renderer.mjs.map +1 -1
  72. package/dist/device/device-scope.d.ts +2 -0
  73. package/dist/device/device-scope.d.ts.map +1 -1
  74. package/dist/device/gpu-residency.d.ts +24 -4
  75. package/dist/device/gpu-residency.d.ts.map +1 -1
  76. package/dist/environment/frame.d.ts +5 -0
  77. package/dist/environment/frame.d.ts.map +1 -1
  78. package/dist/environment/inspection.d.ts +7 -0
  79. package/dist/environment/inspection.d.ts.map +1 -1
  80. package/dist/environment/lifecycle.d.ts +20 -2
  81. package/dist/environment/lifecycle.d.ts.map +1 -1
  82. package/dist/errors/recover.d.ts +58 -2
  83. package/dist/errors/recover.d.ts.map +1 -1
  84. package/dist/errors/render.d.ts +13 -0
  85. package/dist/errors/render.d.ts.map +1 -1
  86. package/dist/extract/contracts.d.ts +899 -0
  87. package/dist/extract/contracts.d.ts.map +1 -0
  88. package/dist/features/host.d.ts +2 -0
  89. package/dist/features/host.d.ts.map +1 -1
  90. package/dist/features/prepared-gpu-work.d.ts +3 -0
  91. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  92. package/dist/features/prepared-graphics-store.d.ts +2 -0
  93. package/dist/features/prepared-graphics-store.d.ts.map +1 -1
  94. package/dist/gpu-driven/production-raster.d.ts +34 -1
  95. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  96. package/dist/gpu-scene.d.ts +5 -0
  97. package/dist/gpu-scene.d.ts.map +1 -1
  98. package/dist/ibl/IblPipelineCache.d.ts +7 -6
  99. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  100. package/dist/ibl/skylight-bind-group.d.ts +7 -1
  101. package/dist/ibl/skylight-bind-group.d.ts.map +1 -1
  102. package/dist/index.d.ts +2 -1
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.mjs +27 -9
  105. package/dist/index.mjs.map +1 -1
  106. package/dist/instance-buffer-cache.d.ts +7 -1
  107. package/dist/instance-buffer-cache.d.ts.map +1 -1
  108. package/dist/instances.d.ts +101 -0
  109. package/dist/instances.d.ts.map +1 -0
  110. package/dist/internal.mjs +6 -6
  111. package/dist/light-buffer-layout.d.ts +3 -0
  112. package/dist/light-buffer-layout.d.ts.map +1 -1
  113. package/dist/materials.d.ts +96 -11
  114. package/dist/materials.d.ts.map +1 -1
  115. package/dist/mesh-material-bindings.d.ts +56 -0
  116. package/dist/mesh-material-bindings.d.ts.map +1 -1
  117. package/dist/occlusion-proxy.d.ts +4 -0
  118. package/dist/occlusion-proxy.d.ts.map +1 -0
  119. package/dist/pbr-pipeline.d.ts +32 -9
  120. package/dist/pbr-pipeline.d.ts.map +1 -1
  121. package/dist/pipeline-builder.d.ts.map +1 -1
  122. package/dist/pipeline-spec.d.ts.map +1 -1
  123. package/dist/points-lines/prepare.d.ts +3 -0
  124. package/dist/points-lines/prepare.d.ts.map +1 -1
  125. package/dist/points-lines/standard-owner.d.ts +33 -0
  126. package/dist/points-lines/standard-owner.d.ts.map +1 -0
  127. package/dist/record/frame-snapshot.d.ts +18 -2
  128. package/dist/record/frame-snapshot.d.ts.map +1 -1
  129. package/dist/record/frame-targets.d.ts.map +1 -1
  130. package/dist/record/frame.d.ts +14 -2
  131. package/dist/record/frame.d.ts.map +1 -1
  132. package/dist/record/main-pass-geometry.d.ts +36 -4
  133. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  134. package/dist/record/main-pass-material.d.ts +7 -17
  135. package/dist/record/main-pass-material.d.ts.map +1 -1
  136. package/dist/record/main-pass.d.ts.map +1 -1
  137. package/dist/record/mesh-ssbo.d.ts +11 -3
  138. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  139. package/dist/record/recovery-pipeline.d.ts +30 -0
  140. package/dist/record/recovery-pipeline.d.ts.map +1 -0
  141. package/dist/record/render-context.d.ts +28 -5
  142. package/dist/record/render-context.d.ts.map +1 -1
  143. package/dist/record/shadow-pass.d.ts +11 -1
  144. package/dist/record/shadow-pass.d.ts.map +1 -1
  145. package/dist/record/typed-frame-graph.d.ts +1 -1
  146. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  147. package/dist/recovery/render-system-candidate.d.ts +65 -0
  148. package/dist/recovery/render-system-candidate.d.ts.map +1 -0
  149. package/dist/render-contract.d.ts +68 -7
  150. package/dist/render-contract.d.ts.map +1 -1
  151. package/dist/render-system-extract-tail.d.ts +16 -0
  152. package/dist/render-system-extract-tail.d.ts.map +1 -0
  153. package/dist/render-system-extract.d.ts +180 -22
  154. package/dist/render-system-extract.d.ts.map +1 -1
  155. package/dist/render-system.d.ts +131 -4
  156. package/dist/render-system.d.ts.map +1 -1
  157. package/dist/scene/render-scene-types.d.ts +2 -0
  158. package/dist/scene/render-scene-types.d.ts.map +1 -1
  159. package/dist/scene/render-scene.d.ts +31 -1
  160. package/dist/scene/render-scene.d.ts.map +1 -1
  161. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  162. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  163. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  164. package/dist/temporal/index.mjs +6 -6
  165. package/dist/temporal/taa-history-store.d.ts +15 -1
  166. package/dist/temporal/taa-history-store.d.ts.map +1 -1
  167. package/dist/temporal/transaction.d.ts +5 -0
  168. package/dist/temporal/transaction.d.ts.map +1 -1
  169. package/package.json +20 -20
  170. package/src/__tests__/device-feature-admission.unit.test.ts +6 -0
  171. package/src/__tests__/device-loss-fanout.unit.test.ts +107 -0
  172. package/src/__tests__/device-scope-lifecycle.unit.test.ts +21 -0
  173. package/src/__tests__/environment-generation.integration.test.ts +23 -0
  174. package/src/__tests__/expose-renderer-errors.unit.test.ts +34 -0
  175. package/src/__tests__/factory-contract.integration.test.ts +122 -7
  176. package/src/__tests__/fallback-row-stride-surface.unit.test.ts +4 -1
  177. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  178. package/src/__tests__/ibl/face-uniforms.unit.test.ts +2 -0
  179. package/src/__tests__/ibl-residency-publication.unit.test.ts +113 -0
  180. package/src/__tests__/instances-store.unit.test.ts +46 -0
  181. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  182. package/src/__tests__/material-contract-inventory.unit.test.ts +29 -22
  183. package/src/__tests__/material-cooked-projection.unit.test.ts +19 -1
  184. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  185. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  186. package/src/__tests__/material-static-dynamic-route.unit.test.ts +29 -0
  187. package/src/__tests__/material-stride-surface.unit.test.ts +7 -3
  188. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  189. package/src/__tests__/materials-standard-contract.unit.test.ts +104 -0
  190. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  191. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -5
  192. package/src/__tests__/physical-clearcoat.integration.test.ts +89 -0
  193. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +1 -1
  194. package/src/__tests__/prepared-graphics-pipeline-warmup.unit.test.ts +1 -1
  195. package/src/__tests__/recover-error-policy-owner.unit.test.ts +6 -3
  196. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +140 -0
  197. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +127 -18
  198. package/src/__tests__/renderer-frame-generation-fence.integration.test.ts +47 -0
  199. package/src/__tests__/renderer-generation-assembly.unit.test.ts +77 -0
  200. package/src/__tests__/renderer-generation-failure.unit.test.ts +108 -0
  201. package/src/__tests__/renderer-producer-root-matrix.integration.test.ts +33 -0
  202. package/src/__tests__/renderer-recovery-capability-matrix.unit.test.ts +59 -0
  203. package/src/__tests__/renderer-recovery-contract.unit.test.ts +73 -0
  204. package/src/__tests__/renderer-recovery-lifecycle.unit.test.ts +216 -0
  205. package/src/__tests__/renderer-target-history-recovery.integration.test.ts +80 -0
  206. package/src/__tests__/shader-manifest-fixture.ts +13 -16
  207. package/src/__tests__/shader-stage-surface.unit.test.ts +1 -1
  208. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  209. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  210. package/src/__tests__/standard-layer-projection.unit.test.ts +23 -0
  211. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  212. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  213. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  214. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  215. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  216. package/src/__tests__/transmission-prewarm.unit.test.ts +95 -85
  217. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  218. package/src/__tests__/volumetric-fog-topology.unit.test.ts +3 -1
  219. package/src/assembly/bundler-contract.ts +9 -0
  220. package/src/assembly/device-feature-admission.ts +15 -5
  221. package/src/assembly/extended-lighting-fallback.ts +216 -0
  222. package/src/assembly/factory.ts +83 -10088
  223. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  224. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  225. package/src/assembly/material/assembly.ts +18 -2
  226. package/src/assembly/material/standard-layer-projection.ts +16 -0
  227. package/src/assembly/material/surface-projection.ts +90 -0
  228. package/src/assembly/material-shader-policy.ts +932 -0
  229. package/src/assembly/material-shader-prewarm.ts +88 -0
  230. package/src/assembly/mesh-ssbo-grow.ts +270 -0
  231. package/src/assembly/recovery/device-loss-fanout.ts +89 -0
  232. package/src/assembly/recovery/generation.ts +141 -0
  233. package/src/assembly/recovery/recovery-attempt.ts +800 -0
  234. package/src/assembly/recovery/renderer-recover.ts +906 -0
  235. package/src/assembly/renderer-facade.ts +342 -0
  236. package/src/assembly/renderer-frame-transaction.ts +45 -0
  237. package/src/assembly/renderer-helpers.ts +99 -0
  238. package/src/assembly/renderer-lifecycle.ts +159 -0
  239. package/src/assembly/shader-prewarm-policy.ts +157 -0
  240. package/src/assembly/webgpu-ready-contract.ts +14 -0
  241. package/src/assembly/webgpu-ready.ts +4066 -0
  242. package/src/assembly/webgpu-renderer.ts +4027 -0
  243. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  244. package/src/assets/asset-decoders.ts +27 -4
  245. package/src/components/camera.ts +1 -1
  246. package/src/components/instances.ts +14 -197
  247. package/src/debug-draw-glue.ts +1 -1
  248. package/src/device/device-scope.ts +35 -6
  249. package/src/device/gpu-residency.ts +194 -26
  250. package/src/environment/frame.ts +6 -0
  251. package/src/environment/inspection.ts +10 -0
  252. package/src/environment/lifecycle.ts +114 -8
  253. package/src/errors/recover.ts +76 -5
  254. package/src/errors/render.ts +17 -0
  255. package/src/extract/contracts.ts +974 -0
  256. package/src/features/host.ts +16 -0
  257. package/src/features/prepared-gpu-work.ts +20 -2
  258. package/src/features/prepared-graphics-store.ts +17 -0
  259. package/src/gpu-driven/production-raster.ts +131 -3
  260. package/src/gpu-scene.ts +87 -31
  261. package/src/ibl/IblPipelineCache.ts +134 -50
  262. package/src/ibl/skylight-bind-group.ts +26 -0
  263. package/src/index.ts +10 -1
  264. package/src/instance-buffer-cache.ts +14 -1
  265. package/src/instances.ts +288 -0
  266. package/src/light-buffer-layout.ts +3 -0
  267. package/src/materials.ts +365 -53
  268. package/src/mesh-material-bindings.ts +59 -0
  269. package/src/occlusion-proxy.ts +64 -0
  270. package/src/pbr-pipeline.ts +139 -15
  271. package/src/pipeline-builder.ts +14 -10
  272. package/src/pipeline-spec.ts +6 -14
  273. package/src/points-lines/__tests__/cache-recovery.unit.test.ts +22 -0
  274. package/src/points-lines/prepare.ts +9 -0
  275. package/src/points-lines/standard-owner.ts +403 -0
  276. package/src/record/__tests__/frame-targets-recovery.unit.test.ts +60 -0
  277. package/src/record/__tests__/main-pass-geometry.unit.test.ts +26 -1
  278. package/src/record/__tests__/mesh-ssbo.unit.test.ts +38 -0
  279. package/src/record/__tests__/recovery-pipeline.unit.test.ts +124 -0
  280. package/src/record/__tests__/sprite-material-abi.unit.test.ts +28 -13
  281. package/src/record/__tests__/standard-pbr-ubo-layout.unit.test.ts +47 -4
  282. package/src/record/__tests__/temporal-payload.unit.test.ts +22 -43
  283. package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +1 -1
  284. package/src/record/frame-snapshot.ts +34 -2
  285. package/src/record/frame-targets.ts +11 -1
  286. package/src/record/frame.ts +37 -4
  287. package/src/record/helpers.ts +1 -1
  288. package/src/record/main-pass-geometry.ts +727 -63
  289. package/src/record/main-pass-material.ts +316 -209
  290. package/src/record/main-pass-sprite-draws.ts +7 -8
  291. package/src/record/main-pass.ts +94 -6
  292. package/src/record/mesh-ssbo.ts +32 -10
  293. package/src/record/recovery-pipeline.ts +695 -0
  294. package/src/record/render-context.ts +39 -4
  295. package/src/record/shadow-pass.ts +46 -99
  296. package/src/record/typed-frame-graph.ts +45 -1
  297. package/src/record/view-ubo.ts +3 -3
  298. package/src/recovery/render-system-candidate.ts +876 -0
  299. package/src/render-contract.ts +69 -7
  300. package/src/render-system-extract-tail.ts +3039 -0
  301. package/src/render-system-extract.ts +177 -3003
  302. package/src/render-system.ts +906 -662
  303. package/src/scene/render-scene-types.ts +2 -0
  304. package/src/scene/render-scene.ts +703 -37
  305. package/src/systems/skin-palette-allocator.ts +129 -7
  306. package/src/temporal/frame-coordinator.ts +4 -1
  307. package/src/temporal/taa-history-store.ts +46 -2
  308. package/src/temporal/temporal-view.ts +1 -1
  309. package/src/temporal/transaction.ts +9 -0
  310. package/src/tilemap-chunk-extract-system.ts +1 -1
  311. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +19 -12
  312. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  313. package/dist/chunk-4L3P6DT5.mjs.map +0 -1
  314. package/dist/chunk-4NPUASZR.mjs +0 -203
  315. package/dist/chunk-4NPUASZR.mjs.map +0 -1
  316. package/dist/chunk-DIYW3M5D.mjs.map +0 -1
  317. package/dist/chunk-FBC4R44J.mjs.map +0 -1
  318. package/dist/chunk-FF7K3FSO.mjs.map +0 -1
  319. package/dist/chunk-HZUPSYW7.mjs.map +0 -1
  320. package/dist/chunk-QODA5UKU.mjs.map +0 -1
  321. package/dist/chunk-YALPS44A.mjs.map +0 -1
  322. package/dist/chunk-ZUWKAZ2I.mjs.map +0 -1
@@ -0,0 +1,4027 @@
1
+ // @forgeax/engine-render — concrete Renderer lifecycle and frame interpreter.
2
+ //
3
+ // The Runtime host selects one typed backend pack before this assembly runs.
4
+ //
5
+ // **D-P4 three-channel error propagation** (RK-04 + plan-strategy §7.3):
6
+ // (a) Construction-time → `createRenderer(...)` rejects with
7
+ // `EngineEnvironmentError` whose `detail.webgpuError` is the Channel 2
8
+ // `RhiError` and `detail.wgpuError` is the Channel 3 failure (when both
9
+ // channels fail; AI users `try { await createRenderer(...) } catch (e) {
10
+ // switch (e.detail.webgpuError?.code) { ... } }`).
11
+ // (b) Run-time pipeline build → `renderer.initialization` rejects with the
12
+ // structured `RhiError` (`shader-compile-failed` / `limit-exceeded` /
13
+ // `webgpu-runtime-error` / ...); AI users `await renderer.initialization`.
14
+ // (c) error fan-out → `renderer.subscribe(listener)` captures the same
15
+ // errors as a fallback observability channel (charter proposition 4
16
+ // structured + proposition 9 graceful degradation).
17
+ //
18
+ // AC-15 source-level: this file uses `globalThis.navigator` rather than
19
+ // `navigator` directly; it never touches `window` or `document`.
20
+
21
+ import type { MipmapShaderModuleFactory } from '@forgeax/engine-assets-runtime';
22
+ import {
23
+ AssetRegistry,
24
+ adaptDynamicTextureDevice,
25
+ DynamicTextureStore,
26
+ } from '@forgeax/engine-assets-runtime';
27
+ import { audioLoader } from '@forgeax/engine-audio-webaudio';
28
+ import { Update, type World } from '@forgeax/engine-ecs';
29
+ import { createRenderReadLease, type RenderReadLease } from '@forgeax/engine-ecs/projection';
30
+ import {
31
+ deriveVertexBufferLayout,
32
+ deriveVertexBufferLayoutFromProjection,
33
+ type VertexLayoutProjection,
34
+ } from '@forgeax/engine-geometry';
35
+ import type {
36
+ BindGroupLayout,
37
+ PipelineLayout,
38
+ RenderPipeline,
39
+ Result,
40
+ RhiCanvasContext,
41
+ RhiDevice,
42
+ } from '@forgeax/engine-rhi';
43
+ import { err, ok, RhiError, validateDrawArgs } from '@forgeax/engine-rhi';
44
+ import { registerPropagateTransforms } from '@forgeax/engine-scene';
45
+ import {
46
+ findVariantByKey,
47
+ type MaterialShaderEntry,
48
+ type MaterialShaderManifestEntry,
49
+ ShaderCatalog,
50
+ standardPhysicalTextureFields,
51
+ } from '@forgeax/engine-shader';
52
+ import type {
53
+ ImportTransport,
54
+ MaterialRenderState,
55
+ ParamSchemaEntry,
56
+ PassKind,
57
+ PrimitiveTopology,
58
+ VertexAttributeMap,
59
+ } from '@forgeax/engine-types';
60
+
61
+ export type { MaterialShaderManifestEntry } from '@forgeax/engine-shader';
62
+
63
+ import { GpuResidencyCache } from '../device/gpu-residency';
64
+ import type { DeviceResourceKind } from '../device/resource-types';
65
+ import { createEngineMetrics } from '../engine-metrics';
66
+ import { createRecoveryFailedError, RecoverError, type RecoverFailure } from '../errors/recover';
67
+ import {
68
+ FrameReceiptStaleError,
69
+ type RenderError,
70
+ RendererContractFailureError,
71
+ type RendererOperationCause,
72
+ RendererOperationError,
73
+ RenderFeatureCapabilityMissingError,
74
+ RenderFeatureStageFailedError,
75
+ } from '../errors/render';
76
+ import { createRenderFeatureHost, type RenderFeatureHost } from '../features/host';
77
+ import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../features/prepared-graphics';
78
+ import type { RenderFeature, RenderFeatureShaderModuleMode } from '../features/types';
79
+ import {
80
+ type PostProcessShaderEntry,
81
+ postProcessShaderModuleLabel,
82
+ } from '../fullscreen-post-process-pass';
83
+ import { glyphTextLayoutSystem } from '../glyph-text-layout-system';
84
+ import { GPU_SHADER_STAGE_FRAGMENT, GPU_SHADER_STAGE_VERTEX } from '../gpu-stage';
85
+ import type { BloomInspection } from '../inspection-types';
86
+ import {
87
+ DeviceScope,
88
+ HealthListenerRegistry,
89
+ LostListenerRegistry,
90
+ RhiErrorListenerRegistry,
91
+ } from '../lifecycle';
92
+ import {
93
+ isCanonicalStandardPbrMaterialShader,
94
+ isStandardPbrMaterialShader,
95
+ type PipelineGroup2Contract,
96
+ resolvePipelineGroup2Contract,
97
+ SKIN_MATERIAL_SHADER_ID,
98
+ } from '../pbr-pipeline';
99
+ import { standardPipeline } from '../pipeline/standard-pipeline';
100
+ import { DEFAULT_STANDARD_PROFILE } from '../pipeline/standard-profile';
101
+ import { buildPipelineForMaterialShader } from '../pipeline-builder';
102
+ import {
103
+ buildBindGroupLayoutDescriptor,
104
+ cacheKeyOf,
105
+ colorFormatsForPassKind,
106
+ type PipelineSpec,
107
+ passKindPolicyTable,
108
+ } from '../pipeline-spec';
109
+ import type { ExtendedLightingResourceCandidate } from '../prepare/extended-lighting/resources';
110
+ import {
111
+ COOKIE_MATRIX_BYTES,
112
+ COOKIE_SLICE_SIZE,
113
+ deriveExtendedLightingCapability,
114
+ EXTENDED_LIGHTING_TOPOLOGY,
115
+ IES_SLICE_HEIGHT,
116
+ IES_SLICE_WIDTH,
117
+ } from '../prepare/extended-lighting/resources';
118
+ import {
119
+ createExtendedLightingState,
120
+ projectExtendedLightingInspection,
121
+ promoteExtendedLightingCandidate,
122
+ } from '../prepare/extended-lighting/state';
123
+ import type { FrameObservationOptions } from '../record/frame';
124
+ import type { GpuPassTimingReason } from '../record/gpu-pass-timing/errors.js';
125
+ import {
126
+ createGpuPassTimingSession,
127
+ DEFAULT_GPU_PASS_TIMING_OPTIONS,
128
+ type GpuPassTimingCapture,
129
+ type GpuPassTimingObservation,
130
+ type GpuPassTimingSession,
131
+ } from '../record/gpu-pass-timing/index.js';
132
+ import { GpuTimingCapture, type VolumeTimingObservation } from '../record/gpu-timing';
133
+ import type { RecoveryColdWorkGuard } from '../record/render-context';
134
+ import type {
135
+ DrawOwnerOptions,
136
+ FrameObservationRequest,
137
+ FrameReceipt,
138
+ FrameReceiptObservation,
139
+ HealthSnapshot,
140
+ RenderDebugOverlay,
141
+ RendererErrorListener,
142
+ RendererLostListener,
143
+ RendererOptions,
144
+ RenderFrameInput,
145
+ RenderInspection,
146
+ RenderProfile,
147
+ RenderResult,
148
+ RenderWorldLease,
149
+ VolumetricFogShaderSources,
150
+ } from '../render-contract';
151
+ import { FXAA_POST_PROCESS_ID, STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from '../render-contract';
152
+ import {
153
+ attachGpuPassTimingSession,
154
+ createRenderSystem,
155
+ type PipelineState,
156
+ type RecoveryGraphCandidate,
157
+ type RecoveryPostProcessResources,
158
+ type RecoveryRootBundle,
159
+ type RenderSystem,
160
+ } from '../render-system';
161
+ import { createGpuPassTimingObservationStore, observeGpuPassTimingDisabled } from '../renderer.js';
162
+ import { postSpawnResolveJoints } from '../scene-instances/post-spawn-resolve-joints';
163
+ import type { RenderTarget } from '../targets/contracts';
164
+ import { tilemapChunkExtractSystem } from '../tilemap-chunk-extract-system';
165
+ import type { RhiBackendPack } from './backend-contract';
166
+ import type { BundlerOptions } from './bundler-contract';
167
+ import { deviceOptionsForAdapter } from './device-feature-admission';
168
+ import type { RendererAssemblyImplementation } from './host-contract';
169
+ import {
170
+ allowsUnlitPreparedFallback,
171
+ invokeDeviceCreateShaderModule,
172
+ isSharedMaterialUserRegionCompatible,
173
+ type LayoutKind,
174
+ type MaterialShaderBindingContract,
175
+ makeShaderDeviceAdapter,
176
+ normalizeMaterialShaderVariantSet,
177
+ prepareMaterialShaders,
178
+ resolveMaterialShaderBackendArtifactKey,
179
+ resolveMaterialShaderBindingContract,
180
+ resolveMaterialShaderUvSetCount,
181
+ resolveMaterialShaderVariantSet,
182
+ resolveMaterialShaderVertexInputContract,
183
+ type ShaderDeviceAdapterInternal,
184
+ selectNoColorPbrVariant,
185
+ selectPipelineLayoutForVariant,
186
+ shouldDeferMissingPreparedMaterialShader,
187
+ } from './material-shader-policy';
188
+ import type { MeshSsboGrowResult, MeshSsboState } from './mesh-ssbo-grow';
189
+ import { attachDeviceLostFanout } from './recovery/device-loss-fanout';
190
+ import {
191
+ type GenerationAggregate,
192
+ type GenerationPublication,
193
+ publishGeneration,
194
+ } from './recovery/generation';
195
+ import { createRendererRecovery, type RendererRecovery } from './recovery/renderer-recover';
196
+ import { createRenderTargetHost } from './render-target-host';
197
+ import {
198
+ freezeRenderProfile,
199
+ structuredRendererCause,
200
+ validateRenderProfile,
201
+ } from './renderer-facade';
202
+ import {
203
+ type ContinuationTerminator,
204
+ createContinuationTerminator,
205
+ } from './renderer-frame-transaction';
206
+ import { ensureContextConfigured, wrapDisposeError } from './renderer-helpers';
207
+ import { projectRendererOutputInspection } from './renderer-inspection';
208
+ import {
209
+ createRecoveryContinuation,
210
+ createRecoveryDeadline,
211
+ createSingleFlight,
212
+ type RecoveryGuidance,
213
+ type RecoveryPhase,
214
+ } from './renderer-lifecycle';
215
+ import { STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES } from './shader-prewarm-policy';
216
+ import { buildReadyWebGPU } from './webgpu-ready';
217
+ import { DEPTH_TEXTURE_FORMAT, HDR_COLOR_ATTACHMENT_FORMAT } from './webgpu-ready-contract';
218
+ import {
219
+ BILLBOARD_MATERIAL_INSTANCE_VERTEX_BUFFERS,
220
+ DEFAULT_VERTEX_ATTRS,
221
+ MESH_GEOMETRY_MATERIAL_INSTANCE_VERTEX_BUFFERS,
222
+ PBR_SKIN_SENTINEL_ATTR_BUFFER,
223
+ POSITION_SIZE_COLOR_INSTANCE_VERTEX_BUFFERS,
224
+ PREPARED_INSTANCE_VERTEX_ATTRS,
225
+ PREPARED_MATERIAL_INSTANCE_VERTEX_ATTRS,
226
+ TOPOLOGY_SEGMENT_INSTANCE_VERTEX_BUFFERS,
227
+ toGpuVertexBufferLayouts,
228
+ } from './webgpu-vertex-layouts';
229
+
230
+ export type { BundlerOptions } from './bundler-contract';
231
+ export { assembleMaterialProjection } from './material/assembly';
232
+ export {
233
+ projectMaterialPipeline,
234
+ routeMaterialPipeline,
235
+ updateMaterialRuntimeValues,
236
+ } from './material/pipeline-projection';
237
+ export type {
238
+ LayoutKind,
239
+ MaterialShaderBindingContract,
240
+ MaterialShaderVertexInputContract,
241
+ } from './material-shader-policy';
242
+ export {
243
+ allowsUnlitPreparedFallback,
244
+ isSharedMaterialUserRegionCompatible,
245
+ normalizeMaterialShaderVariantSet,
246
+ resolveMaterialShaderBackendArtifactKey,
247
+ resolveMaterialShaderBindingContract,
248
+ resolveMaterialShaderUvSetCount,
249
+ resolveMaterialShaderVariantSet,
250
+ resolveMaterialShaderVertexInputContract,
251
+ selectNoColorPbrVariant,
252
+ selectPipelineLayoutForVariant,
253
+ shouldDeferMissingPreparedMaterialShader,
254
+ } from './material-shader-policy';
255
+ export type {
256
+ MeshSsboBufferWrapper,
257
+ MeshSsboGrowController,
258
+ MeshSsboGrowControllerInit,
259
+ MeshSsboGrowDevice,
260
+ MeshSsboGrowErrorRegistry,
261
+ MeshSsboGrowResult,
262
+ MeshSsboState,
263
+ } from './mesh-ssbo-grow';
264
+ export {
265
+ createMeshSsboGrowController,
266
+ deriveStorageBufferCeiling,
267
+ INITIAL_MESH_SSBO_SLOT_COUNT,
268
+ requireMeshSsboBuffer,
269
+ } from './mesh-ssbo-grow';
270
+ export { exposeRenderer } from './renderer-facade';
271
+ export {
272
+ selectHdrpPbrPrewarmVariants,
273
+ selectProbePrewarmVariants,
274
+ selectSkinPrewarmVariants,
275
+ selectStandardPbrTransmissionPrewarmVariants,
276
+ } from './shader-prewarm-policy';
277
+
278
+ function adaptMipmapShaderModuleFactory(
279
+ factory: RhiBackendPack['createShaderModule'],
280
+ ): MipmapShaderModuleFactory | undefined {
281
+ if (factory === undefined) return undefined;
282
+ return (device, descriptor) => factory(device as RhiDevice, descriptor);
283
+ }
284
+
285
+ // Re-export registerAdvanceAnimationPlayer so consumers can wire it.
286
+ // Re-export registerPropagateTransforms so consumers can wire the
287
+ // Transform.world mat4 derivation (audio listener sync + picking read the
288
+ // derived Transform.world from scripts).
289
+
290
+ /**
291
+ * Bundler-layer injection accepted by `createRenderer` (and proxied by
292
+ * `createApp`) as the optional third argument.
293
+ *
294
+ * feat-20260608-create-app-param-surface-trim / M2 / D-3: aggregates the two
295
+ * host-injected build-tool channels:
296
+ *
297
+ * - shaderManifestUrl: the URL the host's vite-plugin-shader emit step
298
+ * wrote `manifest.json` to. When this field is omitted (or `bundler`
299
+ * itself is omitted), createRenderer falls back to
300
+ * '/shaders/manifest.json' (D-2 q5-A) so the LO 1.1 zero-config takeoff
301
+ * path keeps working without explicit injection. Tests can inject via a
302
+ * `data:application/json,...` URL to bypass fetch.
303
+ *
304
+ * - importTransport: dev-only ImportTransport forwarded verbatim to the
305
+ * AssetRegistry third ctor slot so DDC-miss assets can lazy-import.
306
+ * Absent => shipped form (a DDC miss fails fast with `asset-not-imported`).
307
+ *
308
+ * The interface is structurally shared with `@forgeax/engine-app` while this
309
+ * package keeps the renderer independent from the host package.
310
+ */
311
+ // ─── Backend pack — M3 auto-select internal shape ───────────────────────────
312
+
313
+ // ─── WebGPU branch ──────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Wire the spec `device.lost` Promise into the lost / error / health channels
317
+ * (research §F-4 / R2). A genuine device loss fans out through `errorRegistry`
318
+ * so the public `renderer.subscribe` error event triggers, and
319
+ * through `healthRegistry` so `health().reason` flips to `'device-lost'`
320
+ * (feat-20260622-s5 M1/M2). Explicit renderer teardown is observable through
321
+ * `onLost`, but is not a runtime error and must not manufacture an RhiError.
322
+ *
323
+ * Extracted to a single helper (SSOT) so the createRenderer assembly path AND
324
+ * the recover() rebuild path attach byte-identical fan-out to whichever device
325
+ * is current — recover() mints a fresh device whose own `lost` Promise must be
326
+ * re-wired to the SAME registries the host already subscribed to.
327
+ */
328
+ // Device loss wiring lives in the recovery assembly seam so boot and recovery
329
+ // cannot drift into separate lifecycle event paths.
330
+
331
+ /**
332
+ * tryCreateWebGPURenderer returns three kinds of outcome (fix-f1 / w16):
333
+ * - `ok` → Renderer created successfully
334
+ * - `rhi-err` → RHI Result.err path (preserves the original RhiError
335
+ * for AI consumers to read; now includes context acquisition
336
+ * failures via pack.rhi.acquireCanvasContext)
337
+ * - `throw` → exception path (caught and wrapped by the caller as
338
+ * an Error)
339
+ */
340
+ export type WebGPUOutcome =
341
+ | { kind: 'ok'; renderer: RendererAssemblyImplementation }
342
+ | { kind: 'rhi-err'; error: RhiError }
343
+ | { kind: 'throw'; error: Error };
344
+
345
+ /**
346
+ * Goes through the strict two-step path exposed by the injected backend pack:
347
+ * `rhi.requestAdapter()` -> `adapter.requestDevice()`
348
+ * (M6 fix-up [w51] retires the legacy single-step factory per plan-strategy
349
+ * §6 M3 break-point #2 + AGENTS.md break-point list 2026-05-10 #2). Returns
350
+ * `Result<RhiAdapter, RhiError>` then `Result<RhiDevice, RhiError>`; each
351
+ * failure / failure to acquire the canvas WebGPU context returns a
352
+ * structured outcome so the caller can decide on fallback and error-
353
+ * preservation strategy (plan-strategy §7.3).
354
+ *
355
+ * fix-f1: on either Result.err path the original `RhiError` is preserved
356
+ * via the `rhi-err` outcome — when fallback also fails, AI consumers read
357
+ * it via `EngineEnvironmentError.detail.webgpuError.code`.
358
+ *
359
+ * w19 / M4: context acquisition goes through `pack.rhi.acquireCanvasContext(canvas)`
360
+ * — a polymorphic entry owned by the selected backend adapter.
361
+ * The backend pack is selected BEFORE this call (in `loadBackendPack` /
362
+ * the Channel 2→3 fallback in `createRenderer`), so the canvas context
363
+ * is never type-locked by a wrong backend's acquisition attempt.
364
+ */
365
+ let nextDerivedSystemId = 0;
366
+
367
+ export async function tryCreateWebGPURenderer(
368
+ canvas: HTMLCanvasElement | OffscreenCanvas,
369
+ options: RendererOptions | undefined,
370
+ pack: RhiBackendPack,
371
+ // feat-20260608 M2 / D-3: BundlerOptions threaded verbatim. Carries
372
+ // shaderManifestUrl (consumed in getShader fallback ~30 lines below) and
373
+ // importTransport (forwarded to the AssetRegistry third ctor slot;
374
+ // undefined keeps the shipped form, AC-08).
375
+ bundler: BundlerOptions | undefined,
376
+ ): Promise<WebGPUOutcome> {
377
+ const importTransport = bundler?.importTransport;
378
+ // M6 fix-up [w51]: spec-aligned two-step path. Step 1 - requestAdapter()
379
+ // surfaces adapter.features / adapter.limits (capability pre-screen,
380
+ // charter proposition 4 forward-reachable). Step 2 - adapter.requestDevice()
381
+ // requests the actual device. M3 D-P4: `pack.rhi` is the auto-selected
382
+ // backend singleton (rhi-webgpu / rhi-wgpu / explicit escape hatch).
383
+ // w19: thread canvas as compatibleSurface so the wgpu GL backend can
384
+ // enumerate adapters against it (escape hatch, capability-gated).
385
+ const adapterResult = await pack.rhi.requestAdapter(undefined, canvas);
386
+ if (!adapterResult.ok) {
387
+ return { kind: 'rhi-err', error: adapterResult.error };
388
+ }
389
+ const adapter = adapterResult.value;
390
+ // M4 w28: filter compression features from adapter.features (AC-07). The
391
+ // same helper is used by recover() below so both paths share one profile.
392
+ const deviceOpts = deviceOptionsForAdapter(adapter, options);
393
+ const result = await adapter.requestDevice(deviceOpts);
394
+ if (!result.ok) {
395
+ return { kind: 'rhi-err', error: result.error };
396
+ }
397
+ const device: RhiDevice = result.value;
398
+
399
+ // Acquire the canvas context through the backend pack (M3 / w16).
400
+ // rhi-webgpu: internally calls canvas.getContext('webgpu') + branded wrap.
401
+ // rhi-wgpu: internally calls wasm createSurface(canvas) + configure.
402
+ // When context acquisition fails (null from canvas.getContext or wasm
403
+ // createSurface throws), the pack returns Result.err(RhiError).
404
+ const ctxResult = pack.rhi.acquireCanvasContext(canvas);
405
+ if (!ctxResult.ok) {
406
+ return { kind: 'rhi-err', error: ctxResult.error };
407
+ }
408
+ const context: RhiCanvasContext = ctxResult.value;
409
+
410
+ const lostRegistry = new LostListenerRegistry();
411
+ const errorRegistry = new RhiErrorListenerRegistry();
412
+ const healthRegistry = new HealthListenerRegistry();
413
+ const lossObserver: {
414
+ current?: (detail: string) => void;
415
+ } = {};
416
+ const generationState: {
417
+ current: number;
418
+ onStaleLoss?: () => void;
419
+ } = { current: 0 };
420
+ // device.lost dual-track (research §F-4 / R2 countermeasure): the spec
421
+ // Promise is passed through to the lost-fan-out registry (extracted to
422
+ // attachDeviceLostFanout so the recover() rebuild path re-attaches the
423
+ // SAME wiring to the freshly-minted device — SSOT, one fan-out shape).
424
+ attachDeviceLostFanout(device, pack, {
425
+ lostRegistry,
426
+ errorRegistry,
427
+ healthRegistry,
428
+ generation: 0,
429
+ currentGeneration: () => generationState.current,
430
+ onStaleLoss: () => generationState.onStaleLoss?.(),
431
+ onDeviceLost: (detail) => lossObserver.current?.(detail),
432
+ });
433
+
434
+ // D-VD2 Round 2 wire-up part 2: register the spec `onuncapturederror`
435
+ // listener on the raw GPUDevice so GPUUncapturedErrorEvent (validation /
436
+ // oom / internal) is translated to the 17-member RhiErrorCode union +
437
+ // dispatched through the public Renderer event subscription. When the pack does not expose a
438
+ // raw-device escape hatch (rhi-wgpu wasm path, custom escape hatch), the
439
+ // listener registration is skipped — the device.lost dual-channel above
440
+ // still fires through `RhiDevice.lost` Promise on every path (graceful
441
+ // degradation per charter proposition 9; per-path coverage matrix in
442
+ // packages/engine/README.md `## Error model`).
443
+ if (pack._internal_getRawDevice && pack.translateErrorEventToRhiError) {
444
+ const backendDevice = pack._internal_getRawDevice(device);
445
+ if (backendDevice && typeof backendDevice === 'object') {
446
+ const target = backendDevice as {
447
+ onuncapturederror?: ((event: unknown) => void) | null;
448
+ };
449
+ // Spec form: `device.onuncapturederror = (event) => ...`. The listener
450
+ // sets a property handler (spec normative GPUDevice extends EventTarget
451
+ // and exposes `onuncapturederror` as a settable callback property);
452
+ // engine writes through the property so each pre-existing handler that
453
+ // an AI user assigned would be replaced (last-write-wins; spec lifetime
454
+ // is device-scoped so engine ownership is OK — AI users observe via
455
+ // the Renderer event subscription, not the raw property).
456
+ target.onuncapturederror = (event: unknown): void => {
457
+ const translated = pack.translateErrorEventToRhiError?.(event);
458
+ if (translated && !translated.ok) {
459
+ errorRegistry.fire(translated.error);
460
+ }
461
+ };
462
+ }
463
+ }
464
+ return {
465
+ kind: 'ok',
466
+ renderer: await makeWebGPURenderer({
467
+ canvas,
468
+ device,
469
+ context,
470
+ options,
471
+ bundler,
472
+ lostRegistry,
473
+ errorRegistry,
474
+ healthRegistry,
475
+ pack,
476
+ importTransport,
477
+ lossObserver,
478
+ generationState,
479
+ }),
480
+ };
481
+ }
482
+
483
+ export interface WebGPURendererInternals {
484
+ canvas: HTMLCanvasElement | OffscreenCanvas;
485
+ device: RhiDevice;
486
+ // M6 / w41 (feat-20260510-rhi-resource-creation): forgeax RhiCanvasContext
487
+ // brand; RenderSystem + ensureContextConfigured both go through the RHI
488
+ // surface (charter proposition 5 consistent abstraction red line).
489
+ context: RhiCanvasContext;
490
+ options: RendererOptions | undefined;
491
+ /**
492
+ * feat-20260608-create-app-param-surface-trim / M2 / D-3: BundlerOptions
493
+ * forwarded verbatim from createRenderer's third arg. Carries
494
+ * shaderManifestUrl (host-injected vite-plugin-shader emit URL; absent =>
495
+ * createRenderer falls back to '/shaders/manifest.json') and the
496
+ * importTransport that previously rode a dedicated internal slot.
497
+ */
498
+ bundler: BundlerOptions | undefined;
499
+ lostRegistry: LostListenerRegistry;
500
+ errorRegistry: RhiErrorListenerRegistry;
501
+ healthRegistry: HealthListenerRegistry;
502
+ /** M3 D-P4 auto-select pack — carries the dynamic-imported rhi-webgpu / rhi-wgpu singleton + optional async shader factory. */
503
+ pack: RhiBackendPack;
504
+ /**
505
+ * feat-20260604-hdr-equirect-cube-importer-loader M4 / w16 (D-3): the
506
+ * dev-only ImportTransport, forwarded verbatim from createRenderer to the
507
+ * AssetRegistry third ctor slot. `undefined` keeps the shipped form (DDC
508
+ * miss fails fast with `asset-not-imported`, AC-08).
509
+ */
510
+ importTransport?: ImportTransport | undefined;
511
+ /**
512
+ * feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 / T-M2-05:
513
+ * mesh-SSBO grow hook surfaced by `makeWebGPURenderer` after the controller
514
+ * is constructed. Undefined before `makeWebGPURenderer` runs; populated by
515
+ * the time the renderer is returned to the caller. M3's record stage reads
516
+ * this field via the runtime path that bridges into render-system-record.ts
517
+ * (the bridging plumbing is in M3's targetFiles).
518
+ *
519
+ * Returns `{ ok: true }` when the grow completed (or short-circuited
520
+ * idempotently); `{ ok: false, code }` with a fired structured error
521
+ * (`MeshSsboCeilingReachedError` or `MeshSsboCapacityExceededError`) when
522
+ * the request cannot be satisfied. Never throws (D-5).
523
+ */
524
+ growMeshSsbo?: (neededSlots: number) => MeshSsboGrowResult;
525
+ /**
526
+ * feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M3 / T-M3-04:
527
+ * read-only handle to the grow controller's state (slotCount + wrapper
528
+ * refs), surfaced alongside `growMeshSsbo` so `ensureMeshSsboCapacity`
529
+ * (record-stage wiring) can short-circuit when slotCount already covers the
530
+ * frame's renderable count and so the dev-mode info log can report the
531
+ * before/after slot count transition. Undefined until buildReadyWebGPU
532
+ * has wired the controller; identity stable across grow events.
533
+ */
534
+ meshSsboState?: MeshSsboState;
535
+ /** Host-owned producer features; created before device-bound assembly starts. */
536
+ featureHost?: RenderFeatureHost;
537
+ /** App-owned overlay capability read by the typed graph at frame time. */
538
+ debugOverlay?: RenderDebugOverlay;
539
+ /** Composed renderer utility sources installed atomically at build-ready. */
540
+ volumetricFogShaders?: VolumetricFogShaderSources | undefined;
541
+ gpuPassTimingSubmittedWork?: Promise<void> | undefined;
542
+ lossObserver: { current?: (detail: string) => void };
543
+ generationState: {
544
+ current: number;
545
+ onStaleLoss?: () => void;
546
+ };
547
+ recoveryColdWorkGuard?: RecoveryColdWorkGuard | undefined;
548
+ getMaterialShaderUvSetCount?: (materialShaderId: string) => number | undefined;
549
+ }
550
+
551
+ export interface RecoveryFailureLocation {
552
+ readonly phase: RecoveryPhase;
553
+ readonly retryable: boolean;
554
+ readonly guidance: RecoveryGuidance;
555
+ readonly owner: string;
556
+ readonly resourceKind: DeviceResourceKind;
557
+ readonly cause: unknown;
558
+ }
559
+
560
+ function recoveryCauseCode(cause: unknown): string | undefined {
561
+ if (typeof cause !== 'object' || cause === null) return undefined;
562
+ const directCode = (cause as { readonly code?: unknown }).code;
563
+ if (typeof directCode === 'string') return directCode;
564
+ const detail = (cause as { readonly detail?: unknown }).detail;
565
+ if (typeof detail !== 'object' || detail === null) return undefined;
566
+ const detailCode = (detail as { readonly code?: unknown }).code;
567
+ if (typeof detailCode === 'string') return detailCode;
568
+ return recoveryCauseCode((detail as { readonly cause?: unknown }).cause);
569
+ }
570
+
571
+ function createRecoveryFailureLocation(
572
+ phase: RecoveryPhase,
573
+ cause: unknown,
574
+ overrides: Partial<Omit<RecoveryFailureLocation, 'phase' | 'cause'>> = {},
575
+ ): RecoveryFailureLocation {
576
+ const code = recoveryCauseCode(cause) ?? '';
577
+ const resourceKind: DeviceResourceKind = code.includes('shader')
578
+ ? 'shader'
579
+ : code.includes('buffer')
580
+ ? 'buffer'
581
+ : code.includes('texture')
582
+ ? 'texture'
583
+ : code.includes('binding')
584
+ ? 'binding'
585
+ : phase === 'rehydrate'
586
+ ? 'shader'
587
+ : phase === 'acquire-adapter' || phase === 'acquire-device'
588
+ ? 'surface'
589
+ : phase === 'publish' || phase === 'cleanup'
590
+ ? 'surface'
591
+ : 'pipeline';
592
+ const owner =
593
+ code.includes('shader') || phase === 'rehydrate'
594
+ ? 'shader'
595
+ : phase === 'acquire-adapter' || phase === 'acquire-device'
596
+ ? 'backend'
597
+ : phase === 'compile-graph'
598
+ ? 'pipeline'
599
+ : 'renderer';
600
+ return {
601
+ phase,
602
+ retryable: false,
603
+ guidance: 'repair-owner',
604
+ owner,
605
+ resourceKind,
606
+ cause,
607
+ ...overrides,
608
+ };
609
+ }
610
+
611
+ async function makeWebGPURenderer(
612
+ internals: WebGPURendererInternals,
613
+ ): Promise<RendererAssemblyImplementation> {
614
+ let disposed = false;
615
+ const frameContinuations = new Set<ContinuationTerminator>();
616
+ type RecoveryInspection = RenderInspection['recovery'];
617
+ let recoveryAttempt = 0;
618
+ let recoveryStaleLossEvents = 0;
619
+ let recoveryInspection: RecoveryInspection = Object.freeze({
620
+ phase: null,
621
+ fromGeneration: 0,
622
+ candidateGeneration: 0,
623
+ attempt: 0,
624
+ elapsedMs: 0,
625
+ lastOutcome: 'none',
626
+ rehydratedRoots: 0,
627
+ staleLossEvents: 0,
628
+ });
629
+ const updateRecoveryInspection = (
630
+ patch: Partial<RecoveryInspection>,
631
+ clearFailureLocation = false,
632
+ ): void => {
633
+ const next = { ...recoveryInspection, ...patch, staleLossEvents: recoveryStaleLossEvents };
634
+ if (clearFailureLocation) {
635
+ delete next.failedOwner;
636
+ delete next.failedResourceKind;
637
+ }
638
+ recoveryInspection = Object.freeze(next);
639
+ };
640
+ internals.generationState.onStaleLoss = () => {
641
+ recoveryStaleLossEvents = Math.min(Number.MAX_SAFE_INTEGER, recoveryStaleLossEvents + 1);
642
+ updateRecoveryInspection({});
643
+ };
644
+ internals.lossObserver.current = (detail) => {
645
+ const code = detail.includes('destroyed') ? 'disposed' : 'device-lost';
646
+ for (const continuation of frameContinuations) continuation.terminate({ code });
647
+ };
648
+ let renderTargetDevice = internals.device;
649
+ let renderTargetGeneration = 0;
650
+ let renderSystemForTargetPromotion: RenderSystem | undefined;
651
+ const featureHostResult = createRenderFeatureHost(internals.options?.features ?? []);
652
+ if (!featureHostResult.ok) throw featureHostResult.error;
653
+ internals.featureHost = featureHostResult.value;
654
+ let rendererRecovery: RendererRecovery | undefined;
655
+ const recoveryFlight = createSingleFlight<Result<void, RecoverFailure>>(async () => {
656
+ if (disposed) return err(new RecoverError('recover-not-needed'));
657
+ if (internals.healthRegistry.getLastSnapshot().reason !== 'device-lost') {
658
+ return err(new RecoverError('recover-not-needed'));
659
+ }
660
+ const attempt = ++recoveryAttempt;
661
+ const deadline = createRecoveryDeadline(Date.now());
662
+ const fromGeneration = activeDeviceScope.generation;
663
+ const candidateGeneration = fromGeneration + 1;
664
+ let recoveryFailurePhase: RecoveryPhase = 'quiesce';
665
+ updateRecoveryInspection(
666
+ {
667
+ phase: 'quiesce',
668
+ fromGeneration,
669
+ candidateGeneration,
670
+ attempt,
671
+ elapsedMs: 0,
672
+ lastOutcome: 'none',
673
+ rehydratedRoots: 0,
674
+ },
675
+ true,
676
+ );
677
+ const setRecoveryPhase = (phase: RecoveryPhase): void => {
678
+ if (phase !== 'cleanup') recoveryFailurePhase = phase;
679
+ updateRecoveryInspection({ phase, elapsedMs: deadline.elapsed(Date.now()) });
680
+ };
681
+ let candidateCleanup = (): void => undefined;
682
+ const continuation = createRecoveryContinuation(deadline, () => candidateCleanup());
683
+ const deadlineExpired = (phase: RecoveryPhase): boolean => {
684
+ if (continuation.isValid(phase, Date.now())) return false;
685
+ continuation.abandon(Date.now());
686
+ continuation.cleanupOnce();
687
+ return true;
688
+ };
689
+ let recoveryFailureLocation: RecoveryFailureLocation | undefined;
690
+ let result: Result<void, RecoverFailure>;
691
+ try {
692
+ const recovery = rendererRecovery;
693
+ if (recovery === undefined) {
694
+ return err(new RecoverError('recover-device-unavailable'));
695
+ }
696
+ result = await recovery.recoverOnce(
697
+ deadline,
698
+ deadlineExpired,
699
+ (cleanup) => {
700
+ candidateCleanup = cleanup;
701
+ },
702
+ continuation,
703
+ setRecoveryPhase,
704
+ (count) => updateRecoveryInspection({ rehydratedRoots: count }),
705
+ (failure) => {
706
+ recoveryFailureLocation = failure;
707
+ },
708
+ );
709
+ } catch (cause) {
710
+ recoveryFailureLocation ??= createRecoveryFailureLocation('compile-graph', cause);
711
+ candidateCleanup();
712
+ result = err(new RecoverError('recover-device-unavailable'));
713
+ }
714
+ if (result.ok) {
715
+ updateRecoveryInspection(
716
+ {
717
+ phase: null,
718
+ fromGeneration,
719
+ candidateGeneration: activeDeviceScope.generation,
720
+ elapsedMs: deadline.elapsed(Date.now()),
721
+ lastOutcome: 'succeeded',
722
+ },
723
+ true,
724
+ );
725
+ return result;
726
+ }
727
+ if (result.error.code === 'recover-not-needed' && !disposed) {
728
+ updateRecoveryInspection(
729
+ { phase: null, elapsedMs: deadline.elapsed(Date.now()), lastOutcome: 'none' },
730
+ true,
731
+ );
732
+ return result;
733
+ }
734
+ const outcome = disposed ? 'disposed' : 'failed';
735
+ const failureLocation = outcome === 'disposed' ? undefined : recoveryFailureLocation;
736
+ const failurePhase =
737
+ outcome === 'disposed' ? 'cleanup' : (failureLocation?.phase ?? recoveryFailurePhase);
738
+ const isAcquisitionPhase = (phase: RecoveryPhase): boolean =>
739
+ phase === 'acquire-adapter' || phase === 'acquire-device';
740
+ const retryable =
741
+ outcome === 'failed' && (failureLocation?.retryable ?? isAcquisitionPhase(failurePhase));
742
+ const failure = createRecoveryFailedError({
743
+ phase: failurePhase,
744
+ oldGeneration: fromGeneration,
745
+ candidateGeneration,
746
+ attempt,
747
+ elapsedMs: deadline.elapsed(Date.now()),
748
+ retryable,
749
+ guidance:
750
+ outcome === 'disposed'
751
+ ? 'rebuild-renderer'
752
+ : (failureLocation?.guidance ?? (retryable ? 'retry' : 'repair-owner')),
753
+ owner: outcome === 'disposed' ? 'renderer' : (failureLocation?.owner ?? 'renderer'),
754
+ resourceKind:
755
+ outcome === 'disposed' ? 'surface' : (failureLocation?.resourceKind ?? 'pipeline'),
756
+ lastOutcome: outcome,
757
+ rehydratedRoots: recoveryInspection.rehydratedRoots,
758
+ staleLossEvents: recoveryStaleLossEvents,
759
+ cause: outcome === 'disposed' ? result.error : (failureLocation?.cause ?? result.error),
760
+ cleanupFailures: [],
761
+ receipt: activeDeviceScope._receipt(),
762
+ });
763
+ updateRecoveryInspection(
764
+ {
765
+ phase: null,
766
+ elapsedMs: failure.detail.elapsedMs,
767
+ lastOutcome: outcome,
768
+ rehydratedRoots: 0,
769
+ },
770
+ false,
771
+ );
772
+ return err(failure);
773
+ });
774
+ // Keep producer-declared shader identities live across the renderer
775
+ // lifetime. Features can be installed after boot (the public late-install
776
+ // seam used by asset-driven hosts), so a recovery rebuild must include their
777
+ // modules in the same prewarm set as boot. A boot-only snapshot would let the
778
+ // first recovered frame observe a cold async shader adapter and emit a
779
+ // spurious prepared-pipeline failure before the next-frame retry succeeds.
780
+ const requiredMaterialShaderSet = new Set(
781
+ featureHostResult.value.features.flatMap((feature) => feature.requiredMaterialShaders ?? []),
782
+ );
783
+ let requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
784
+ const collectRequiredFullscreenPostProcesses = (
785
+ features: readonly RenderFeature<unknown>[],
786
+ ): readonly { readonly identity: string; readonly source: string }[] => {
787
+ const entries = new Map<string, { readonly identity: string; readonly source: string }>();
788
+ for (const feature of features) {
789
+ for (const entry of feature.requiredFullscreenPostProcesses ?? []) {
790
+ const existing = entries.get(entry.identity);
791
+ if (existing !== undefined && existing.source !== entry.source) {
792
+ throw new RhiError({
793
+ code: 'internal-error',
794
+ expected: `fullscreen identity '${entry.identity}' has one source across render features`,
795
+ hint: 'rename the conflicting fullscreen identity or make its WGSL source identical',
796
+ });
797
+ }
798
+ entries.set(entry.identity, entry);
799
+ }
800
+ }
801
+ return Object.freeze([...entries.values()]);
802
+ };
803
+ let requiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses(
804
+ featureHostResult.value.features,
805
+ );
806
+ // Lazy ShaderCatalog instance (plan-strategy section S-10 / D-R10 / OQ-5
807
+ // close): constructed on first access; subsequent accesses return the
808
+ // same instance. Recovery owns a second, candidate-only state object while
809
+ // the active state continues serving frames. The two states are swapped
810
+ // only by the synchronous generation publication boundary below; no
811
+ // recovery await can expose a partially rebuilt adapter or catalog.
812
+ type RendererShaderState = {
813
+ readonly device: RhiDevice;
814
+ shaderInstance: ShaderCatalog | null;
815
+ sharedShaderModuleAdapter: ShaderDeviceAdapterInternal | null;
816
+ sharedImmediateShaderModuleAdapter: ShaderDeviceAdapterInternal | null;
817
+ };
818
+ let activeShaderState: RendererShaderState = {
819
+ device: internals.device,
820
+ shaderInstance: null,
821
+ sharedShaderModuleAdapter: null,
822
+ sharedImmediateShaderModuleAdapter: null,
823
+ };
824
+ let candidateShaderState: RendererShaderState | undefined;
825
+ const getShaderModuleAdapter = (): ShaderDeviceAdapterInternal => {
826
+ const state = candidateShaderState ?? activeShaderState;
827
+ if (state.sharedShaderModuleAdapter === null) {
828
+ state.sharedShaderModuleAdapter = makeShaderDeviceAdapter(
829
+ state.device,
830
+ internals.errorRegistry,
831
+ internals.pack.createShaderModule,
832
+ internals.pack.createShaderModuleImmediate,
833
+ );
834
+ }
835
+ return state.sharedShaderModuleAdapter;
836
+ };
837
+ const getImmediateShaderModuleAdapter = (): ShaderDeviceAdapterInternal => {
838
+ const state = candidateShaderState ?? activeShaderState;
839
+ if (state.sharedImmediateShaderModuleAdapter === null) {
840
+ state.sharedImmediateShaderModuleAdapter = makeShaderDeviceAdapter(
841
+ state.device,
842
+ internals.errorRegistry,
843
+ internals.pack.createShaderModule,
844
+ internals.pack.createShaderModuleImmediate,
845
+ 'immediate',
846
+ );
847
+ }
848
+ return state.sharedImmediateShaderModuleAdapter;
849
+ };
850
+ const prewarmFullscreenFeatureModules = async (
851
+ feature: RenderFeature<unknown>,
852
+ ): Promise<Result<void, RhiError>> => {
853
+ for (const postProcess of feature.requiredFullscreenPostProcesses ?? []) {
854
+ const label = postProcessShaderModuleLabel(postProcess.identity, postProcess.source);
855
+ const shaderResult = internals.pack.createShaderModule
856
+ ? await internals.pack.createShaderModule(internals.device, {
857
+ code: postProcess.source,
858
+ label,
859
+ })
860
+ : await invokeDeviceCreateShaderModule(internals.device, {
861
+ code: postProcess.source,
862
+ label,
863
+ });
864
+ if (!shaderResult.ok) {
865
+ internals.errorRegistry.fire(shaderResult.error);
866
+ return err(shaderResult.error);
867
+ }
868
+ getShaderModuleAdapter().seedModule(label, shaderResult.value);
869
+ }
870
+ return ok(undefined);
871
+ };
872
+ const getShader = (): ShaderCatalog => {
873
+ const state = candidateShaderState ?? activeShaderState;
874
+ if (state.shaderInstance === null) {
875
+ // feat-20260608-create-app-param-surface-trim / M2 / D-2 q5-A:
876
+ // shaderManifestUrl moved to BundlerOptions (third arg). The fallback
877
+ // literal '/shaders/manifest.json' stays here so the LO 1.1
878
+ // hello-window zero-config takeoff path keeps working without an
879
+ // bundler injection. The 'shaderManifestUrl' in (...) check preserves
880
+ // the zero-entry opt-in: explicitly passing
881
+ // `bundler: { shaderManifestUrl: undefined }` retains the
882
+ // old "no manifest fetched" mode used by tests / camera-only worlds.
883
+ const bundler = internals.bundler;
884
+ const explicitUrl = bundler?.shaderManifestUrl;
885
+ state.shaderInstance = new ShaderCatalog({
886
+ device: getShaderModuleAdapter(),
887
+ manifestUrl:
888
+ bundler !== undefined && 'shaderManifestUrl' in bundler
889
+ ? explicitUrl
890
+ : '/shaders/manifest.json',
891
+ });
892
+ }
893
+ return state.shaderInstance;
894
+ };
895
+ // D-S9: AssetRegistry instance shared with RenderSystem. RenderSystem
896
+ // looks up MeshFilter.assetHandle here per frame; buildReadyWebGPU step 3
897
+ // uploads the registry's builtin mesh geometry to GPU buffers so the
898
+ // RenderSystem can route MeshFilter.assetHandle to a real (vbo, ibo) pair.
899
+ //
900
+ // feat-20260527 M1 / w1: ShaderCatalog is now eager-constructed and
901
+ // constructor-injected into AssetRegistry (plan-strategy D-1). The
902
+ // ShaderCatalogDevice adapter (shared between the catalog and per-MaterialShader
903
+ // pipeline cache) is already available at this point.
904
+ const shaderCatalog = getShader();
905
+
906
+ // feat-20260528-material-shader-registration-unification M3 / w14:
907
+ // placeholder hardcoded installMaterialArtifact calls deleted.
908
+ //
909
+ // bug-20260601-hello-tonemap-material-register M1 (plan-strategy D-1):
910
+ // All material-shader entries are now registered from the manifest
911
+ // BEFORE the renderer is returned, so `register<MaterialAsset>` referencing
912
+ // an engine shader succeeds without waiting for `renderer.initialization`.
913
+
914
+ // feat-20260623-asset-payload-generic-open-registry M3 / w10: host apps
915
+ // that need custom loaders register them on `assets.loaders.register(...)`.
916
+ // feat-20260604-hdr-equirect-cube-importer-loader M4 / w16 (D-3 / AC-05):
917
+ // the host-injected ImportTransport (or undefined for the shipped form) is
918
+ // threaded into the AssetRegistry ctor -- the construction-time-
919
+ // only single injection point (no setter, no illegal intermediate state).
920
+ // feat-20260705-runtime-tier2-decomposition M1 / w10 (D-1 / D-2): the sole
921
+ // production assembly point injects (a) the post-spawn hook
922
+ // `postSpawnResolveJoints` (auto-wire Skin.joints; D-1) and the audio
923
+ // catalog-entry loader. The latter stays at this assembly boundary so
924
+ // assets-runtime remains independent of the concrete Web Audio backend.
925
+ // videoLoader is wired internally from graphics-extras; the full registry has
926
+ // 11 engine kinds. This is the only `new AssetRegistry(...)` call in the repo.
927
+ const assets = new AssetRegistry(
928
+ shaderCatalog,
929
+ internals.importTransport,
930
+ [audioLoader],
931
+ postSpawnResolveJoints,
932
+ );
933
+ // feat-20260601-device/gpu-residency-extraction M1: the GPU residency layer
934
+ // lives in a standalone store; `assets` keeps the CPU POD registry only.
935
+ let gpuStore = new GpuResidencyCache();
936
+ let activeDeviceScope = DeviceScope.create(0, 'renderer');
937
+ // feat-20260623-world-space-video-asset M4 / w16 (D-3): transient per-frame
938
+ // video texture store, fully independent of gpuStore (AC-08). Configured with
939
+ // the device alongside gpuStore below; threaded into the record stage via the
940
+ // RenderSystemRuntime so a `videoTextureFields` material field uploads its
941
+ // frame here instead of entering the static ensureResident cache.
942
+ let dynamicTextureStore = new DynamicTextureStore();
943
+ const renderTargetHost = createRenderTargetHost({
944
+ rendererId: Symbol('renderer'),
945
+ getGeneration: () => renderTargetGeneration,
946
+ getDevice: () => renderTargetDevice,
947
+ canPromoteTarget: (target: RenderTarget) =>
948
+ renderSystemForTargetPromotion?.isCubeCapturePending(target) !== true,
949
+ });
950
+ // feat-20260527-sprite-nineslice M4 / w16 (D-5): detached host-owned
951
+ // EngineMetrics counter, threaded to the record
952
+ // stage via `RenderSystemRuntime.metrics` so soft-warns
953
+ // (`nineslice.scale-too-small`, `nineslice.tile-needs-repeat-sampler`) bump
954
+ // counters through the owning service (charter P3 machine-readable signals
955
+ // over a per-frame console.warn flood). Each renderer assembly owns its own
956
+ // counter Map (D-5 candidate 1 isolation); Renderer does not expose it.
957
+ const metrics = createEngineMetrics();
958
+ let extendedLightingState = createExtendedLightingState(activeDeviceScope.generation);
959
+ const gpuPassTimingOptions = internals.options?.gpuPassTiming;
960
+ const timingRetentionFrames =
961
+ gpuPassTimingOptions?.retentionFrames !== undefined &&
962
+ Number.isInteger(gpuPassTimingOptions.retentionFrames) &&
963
+ gpuPassTimingOptions.retentionFrames >= 1 &&
964
+ gpuPassTimingOptions.retentionFrames <= 8
965
+ ? gpuPassTimingOptions.retentionFrames
966
+ : DEFAULT_GPU_PASS_TIMING_OPTIONS.retentionFrames;
967
+ let gpuPassTimingSession: GpuPassTimingSession | undefined;
968
+ let gpuPassTimingUnavailable: GpuPassTimingObservation | undefined;
969
+ const timingObservationStore =
970
+ gpuPassTimingOptions === undefined
971
+ ? undefined
972
+ : createGpuPassTimingObservationStore({
973
+ retentionFrames: timingRetentionFrames,
974
+ currentDeviceGeneration: () => activeDeviceScope.generation,
975
+ });
976
+ const retireGpuPassTimingSession = (): void => {
977
+ const retired = gpuPassTimingSession;
978
+ gpuPassTimingSession = undefined;
979
+ retired?.dispose();
980
+ };
981
+ const failedGpuPassTimingObservation = (error: GpuPassTimingReason): GpuPassTimingObservation => {
982
+ const latestKnownGood = timingObservationStore?.inspect().latestKnownGood;
983
+ return {
984
+ status: 'failed',
985
+ error,
986
+ ...(latestKnownGood === undefined ? {} : { latestKnownGood }),
987
+ };
988
+ };
989
+ const establishGpuPassTimingSession = (): void => {
990
+ retireGpuPassTimingSession();
991
+ gpuPassTimingUnavailable = undefined;
992
+ if (gpuPassTimingOptions === undefined) return;
993
+ const created = createGpuPassTimingSession(internals.device, gpuPassTimingOptions);
994
+ if (created.ok) {
995
+ gpuPassTimingSession = created.value;
996
+ return;
997
+ }
998
+ gpuPassTimingUnavailable = {
999
+ status: 'unavailable',
1000
+ reason: created.error,
1001
+ capability: {
1002
+ timestampQuery: internals.device.caps.timestampQuery,
1003
+ timestampPeriodNanoseconds: internals.device.caps.timestampPeriodNanoseconds,
1004
+ },
1005
+ };
1006
+ };
1007
+ establishGpuPassTimingSession();
1008
+ // feat-20260629 M4: per-material-shader UV set count from naga vertex
1009
+ // @location reflection. Populated during prepareMaterialShaders from
1010
+ // MaterialShaderManifestEntry.uvSetCount. Read by getMaterialShaderPipeline
1011
+ // to auto-fill shaderUvSetCount for clamp-to-last alias.
1012
+ let materialShaderUvSetCounts = new Map<string, number>();
1013
+ // feat-20260527-sprite-nineslice M4 / w18 prep (D-9): hand the same
1014
+ // EngineMetrics instance to AssetRegistry so register-time soft-warns
1015
+ // (sliceMode=1 + sampler.addressMode !== 'repeat') bump
1016
+ // 'nineslice.tile-needs-repeat-sampler' on the SAME counter the runtime
1017
+ // reads.
1018
+ assets.setMetrics(metrics);
1019
+ // feat-20260707 M5 / w33 (D-11 + D-8): project the RhiCaps three-way
1020
+ // compression triple into the codec-facing `TranscodeCaps` and wire it into
1021
+ // the registry so the texture / equirect Basis arms can pick a transcode
1022
+ // target. One-line projection; the loader stays a pure consumer of the
1023
+ // declared ctx input (Pipeline Isolation).
1024
+ assets.setTranscodeCaps({
1025
+ bc: internals.device.caps.textureCompressionBc,
1026
+ etc2: internals.device.caps.textureCompressionEtc2,
1027
+ astc: internals.device.caps.textureCompressionAstc,
1028
+ });
1029
+
1030
+ // M1 (bug-20260601-hello-tonemap-material-register D-1/D-2): prepare
1031
+ // engine-shipped material shaders (cap gate + manifest load + registration)
1032
+ // so they are available in ShaderCatalog before `register<MaterialAsset>`.
1033
+ // Failures throw structured RhiError / ShaderError through `createRenderer`.
1034
+ await prepareMaterialShaders(internals.device, getShader, assets, materialShaderUvSetCounts);
1035
+ // MaterialAsset per-slot texCoord: the built-in standard PBR + skin shaders
1036
+ // unconditionally declare all eight supported UV sets so they can honor
1037
+ // per-slot coordinate selection. Their vertex layout must therefore always
1038
+ // carry the declared UV slots or CreateRenderPipeline rejects the module.
1039
+ // in VertexState"). `materialShaderUvSetCounts` is the SSOT both PSO paths read
1040
+ // (buildPipelineContext + getMaterialShaderPipeline) to drive the clamp-to-last
1041
+ // alias; naga reflection can be stale for engine-shipped modules, so the
1042
+ // count is asserted explicitly. Missing mesh sets are clamp-to-last aliases.
1043
+ materialShaderUvSetCounts.set('forgeax::default-standard-pbr', 8);
1044
+ materialShaderUvSetCounts.set(SKIN_MATERIAL_SHADER_ID, 8);
1045
+ // M5 wiring (feat-20260517-vite-plugin-image-build-time-cook w14b): hand
1046
+ // the RhiDevice to the AssetRegistry so `loadByGuid<TextureAsset>` ->
1047
+ // `loadTextureFromEntry` -> `uploadTexture` actually runs the GPU
1048
+ // upload step plan-strategy section 3.2 sequence specifies. Without
1049
+ // this call uploadTexture step 3 short-circuits with `Result.ok(undefined)`
1050
+ // and the render-system materialBindGroup falls through to the 1x1
1051
+ // white fallback view (charter P3 violation: silent loss of texture).
1052
+ // bug-20260518 D-1: configureGpuDevice gains a 2nd parameter --
1053
+ // `pack.createShaderModule`, the top-level async shader-module factory
1054
+ // (rhi-webgpu / rhi-wgpu both expose it). The runtime mipmap utility uses
1055
+ // this factory inside `generateMipmaps` instead of the (now-deleted)
1056
+ // synchronous `device.createShaderModule` member. The parameter is
1057
+ // structurally optional at this call-site so the explicit-rhi escape
1058
+ // hatch (D-R5) without a top-level factory still routes through the
1059
+ // existing `invokeDeviceCreateShaderModule` fallback in
1060
+ // buildReadyWebGPU; AssetRegistry.uploadTexture's mipmap branch surfaces
1061
+ // the missing-factory case as a structured `rhi-not-available` error
1062
+ // (charter P3 explicit failure) rather than throwing here.
1063
+ //
1064
+ const packShaderFactory = adaptMipmapShaderModuleFactory(internals.pack.createShaderModule);
1065
+ // The RhiDevice surface satisfies `MipmapBlitDevice` (createTexture +
1066
+ // createTextureView + createCommandEncoder + createBindGroup +
1067
+ // queue.submit + queue.writeTexture all live on RhiDevice).
1068
+ // feat-20260601-device/gpu-residency-extraction M1 (D-3 / D-8): device +
1069
+ // shader-module factory + cube-POD register relay are wired onto the store
1070
+ // together. feat-20260614 M8 (D-15 / D-17): `registerCube` is the wire-layer
1071
+ // closure `(world, pod) => world.allocSharedRef('EquirectAsset', pod)` --
1072
+ // the runtime-minted cube POD lands in the draw-time world's user-tier
1073
+ // SharedRefStore (the AssetRegistry owns no handles). feat-20260630 M2 / w11:
1074
+ // the retired cube-texture asset kind is gone; the relay mints an EquirectAsset
1075
+ // shared ref as the cubemap GPU residency's identity token.
1076
+ gpuStore.configureGpuDevice(
1077
+ internals.device,
1078
+ packShaderFactory,
1079
+ (world, pod) => {
1080
+ const handle = world.allocSharedRef('EquirectAsset', pod);
1081
+ return ok(handle);
1082
+ },
1083
+ internals.device.caps,
1084
+ );
1085
+ gpuStore.configureIblDevice(
1086
+ internals.device,
1087
+ internals.pack.createShaderModule === undefined
1088
+ ? undefined
1089
+ : (
1090
+ (createShaderModule) => (device, descriptor) =>
1091
+ createShaderModule(device, descriptor)
1092
+ )(internals.pack.createShaderModule),
1093
+ );
1094
+ gpuStore.bindDeviceScope(activeDeviceScope);
1095
+ // feat-20260623-world-space-video-asset M4 / w16 (D-3): wire the same device
1096
+ // into the transient video texture store (createTexture / createTextureView /
1097
+ // destroyTexture / queue.copyExternalImageToTexture all live on RhiDevice).
1098
+ dynamicTextureStore.configureGpuDevice(adaptDynamicTextureDevice(internals.device));
1099
+ // D-S3: Renderer.initialization three-step strict-serial Promise. Kicked off
1100
+ // synchronously here so `await renderer.initialization` is the AI-user-facing
1101
+ // barrier; failure is structured and goes through Promise reject (no
1102
+ // throw, no silent skip — charter proposition 4 explicit failure).
1103
+ let pipelineState: PipelineState | null = null;
1104
+ type RendererGeneration = GenerationAggregate<
1105
+ RhiDevice,
1106
+ RhiCanvasContext,
1107
+ PipelineState,
1108
+ RendererGenerationBindings,
1109
+ undefined
1110
+ >;
1111
+ const generationPublication: GenerationPublication<RendererGeneration | undefined> = {
1112
+ current: undefined,
1113
+ };
1114
+ const publishRendererGeneration = (candidate: RendererGeneration): void => {
1115
+ publishGeneration(generationPublication, candidate, (value) => value.scope.isAlive());
1116
+ activeDeviceScope = candidate.scope;
1117
+ internals.generationState.current = activeDeviceScope.generation;
1118
+ pipelineState = candidate.pipeline;
1119
+ const bindings = candidate.producerBindings;
1120
+ gpuStore = bindings.gpuStore;
1121
+ dynamicTextureStore = bindings.dynamicTextureStore;
1122
+ activeShaderState = bindings.shaderState;
1123
+ activePipelineCacheState = bindings.pipelineCacheState;
1124
+ materialShaderUvSetCounts = bindings.materialShaderUvSetCounts;
1125
+ emptyPostProcessBgl = bindings.emptyPostProcessBgl;
1126
+ if (bindings.growMeshSsbo === undefined) delete internals.growMeshSsbo;
1127
+ else internals.growMeshSsbo = bindings.growMeshSsbo;
1128
+ if (bindings.meshSsboState === undefined) delete internals.meshSsboState;
1129
+ else internals.meshSsboState = bindings.meshSsboState;
1130
+ gpuStore.bindDeviceScope(activeDeviceScope);
1131
+ };
1132
+ let readySettled = false;
1133
+ // w24 — Renderer.initialization returns Promise<Result<void, RhiError>>: resolve
1134
+ // ok(undefined) on success / resolve err(RhiError) on failure. The legacy
1135
+ // reject path is converted by a `.then() / .catch()` wrap so the inner
1136
+ // factory (`buildReadyWebGPU`) keeps its reject-on-error shape (research
1137
+ // F-3 internal contract preserved) while the public surface settles
1138
+ // strictly resolve-only (charter proposition 4 explicit failure - AI
1139
+ // users branch on `.ok` instead of try/catch).
1140
+ // feat-20260523-shader-template-instance-split M9-T03 (D-PipelineBuilder):
1141
+ // per-MaterialShader pipeline cache (LDR + HDR variants keyed by
1142
+ // `${materialShaderId}:${ldr|hdr}`). Map is owned by the renderer (lifetime
1143
+ // = Renderer) so cache hits persist across draw calls but reset between
1144
+ // renderers. Map.get / .set are sync; the underlying shader-module compile
1145
+ // is async-cached inside `makeShaderDeviceAdapter` (1-frame warmup -- first
1146
+ // miss returns Result.err('rhi-not-available'); after the async build
1147
+ // resolves the adapter caches the module, the next miss-then-build returns
1148
+ // Ok and lands in this Map).
1149
+ //
1150
+ // M6 fix-up: declared BEFORE `buildReadyWebGPU` so the boot-time SPEC_CONST
1151
+ // prewarm step inside that promise can seed URP-variant entries directly
1152
+ // (the seeding closure captures this Map by reference; ordering is safe
1153
+ // because the prewarm step runs strictly before the first frame's
1154
+ // `getMaterialShaderPipeline` lookup that would consume it).
1155
+ const materialShaderPipelineCache = new Map<string, RenderPipeline>();
1156
+ const materialShaderPipelineGroup2Contracts = new WeakMap<object, PipelineGroup2Contract>();
1157
+ const materialShaderManifestEntryCache = new Map<string, MaterialShaderManifestEntry>();
1158
+ // Variant resolution only depends on the requested axes, device capability
1159
+ // axes, and the manifest entry that owns the declarations. Cache it by the
1160
+ // manifest's variants-array identity so hot replacement naturally gets a
1161
+ // fresh result without adding a global strong reference to shader metadata.
1162
+ const materialShaderVariantResolutionCache = new WeakMap<
1163
+ object,
1164
+ Map<string, string | undefined>
1165
+ >();
1166
+ const group0MaterialLayout: {
1167
+ materialBgl: BindGroupLayout;
1168
+ pipelineLayout: PipelineLayout;
1169
+ } | null = null;
1170
+ const viewOnlyMaterialPipelineLayout: PipelineLayout | null = null;
1171
+ const viewAndSceneDepthMaterialPipelineLayout: PipelineLayout | null = null;
1172
+ const group0ResourceLayouts = new Map<
1173
+ string,
1174
+ { materialBgl: BindGroupLayout; pipelineLayout: PipelineLayout }
1175
+ >();
1176
+ const preparedMaterialPipelineLayoutCache = new Map<string, PipelineLayout>();
1177
+ // feat-20260621-learn-render-5-5-parallax M2 / w6 (D-1): per-shader material
1178
+ // BGL + pipeline layout cache. Every authored material shader owns the
1179
+ // user-region binding order emitted by its paramSchema, plus the shared
1180
+ // engine-injection suffix. A custom shader with four-or-fewer textures can
1181
+ // still differ from the standard PBR order (for example, baseColorTexture
1182
+ // followed directly by normalTexture), so it cannot safely reuse the
1183
+ // standard material BGL. Built lazily on first request and cached by
1184
+ // shaderId; built-in shader IDs continue to use the boot-time shared layout.
1185
+ type PerShaderMaterialLayout = {
1186
+ materialBgl: BindGroupLayout;
1187
+ pipelineLayout: PipelineLayout;
1188
+ };
1189
+ type PerShaderMaterialLayoutCacheEntry = {
1190
+ readonly source: string;
1191
+ readonly paramSchema: readonly ParamSchemaEntry[];
1192
+ readonly layoutKind: LayoutKind;
1193
+ readonly layout: PerShaderMaterialLayout | null;
1194
+ };
1195
+ const perShaderMaterialLayoutCache = new Map<string, PerShaderMaterialLayoutCacheEntry>();
1196
+ const isEngineOwnedMaterialShader = (materialShaderId: string): boolean =>
1197
+ materialShaderId === 'forgeax::default-unlit' ||
1198
+ materialShaderId === 'forgeax::default-shadow-caster' ||
1199
+ materialShaderId === 'forgeax::sprite' ||
1200
+ materialShaderId === 'forgeax::sprite-lit' ||
1201
+ materialShaderId === 'forgeax::default-sprite' ||
1202
+ materialShaderId === 'forgeax::msdf-text' ||
1203
+ materialShaderId === 'forgeax::default-standard-pbr' ||
1204
+ materialShaderId === 'forgeax::pbr-skin' ||
1205
+ materialShaderId === 'forgeax::default-standard-pbr-skin';
1206
+ // Material binding contracts are derived from WGSL source, but the lookup
1207
+ // is also used by the per-submesh bind-group path. Cache the derived value
1208
+ // per renderer so a frame does not re-run the comment stripping and regex
1209
+ // scan for every visible submesh. Keep the source alongside the result so
1210
+ // shader hot-replacement invalidates the entry naturally.
1211
+ const materialShaderBindingContractCache = new Map<
1212
+ string,
1213
+ { source: string; contract: MaterialShaderBindingContract }
1214
+ >();
1215
+ type RendererPipelineCacheState = {
1216
+ materialShaderPipelineCache: typeof materialShaderPipelineCache;
1217
+ materialShaderManifestEntryCache: typeof materialShaderManifestEntryCache;
1218
+ materialShaderVariantResolutionCache: typeof materialShaderVariantResolutionCache;
1219
+ group0MaterialLayout: {
1220
+ materialBgl: BindGroupLayout;
1221
+ pipelineLayout: PipelineLayout;
1222
+ } | null;
1223
+ viewOnlyMaterialPipelineLayout: PipelineLayout | null;
1224
+ viewAndSceneDepthMaterialPipelineLayout: PipelineLayout | null;
1225
+ group0ResourceLayouts: typeof group0ResourceLayouts;
1226
+ preparedMaterialPipelineLayoutCache: typeof preparedMaterialPipelineLayoutCache;
1227
+ perShaderMaterialLayoutCache: typeof perShaderMaterialLayoutCache;
1228
+ materialShaderBindingContractCache: typeof materialShaderBindingContractCache;
1229
+ };
1230
+ const createRendererPipelineCacheState = (): RendererPipelineCacheState => ({
1231
+ materialShaderPipelineCache: new Map(),
1232
+ materialShaderManifestEntryCache: new Map(),
1233
+ materialShaderVariantResolutionCache: new WeakMap(),
1234
+ group0MaterialLayout: null,
1235
+ viewOnlyMaterialPipelineLayout: null,
1236
+ viewAndSceneDepthMaterialPipelineLayout: null,
1237
+ group0ResourceLayouts: new Map(),
1238
+ preparedMaterialPipelineLayoutCache: new Map(),
1239
+ perShaderMaterialLayoutCache: new Map(),
1240
+ materialShaderBindingContractCache: new Map(),
1241
+ });
1242
+ let activePipelineCacheState: RendererPipelineCacheState = {
1243
+ materialShaderPipelineCache,
1244
+ materialShaderManifestEntryCache,
1245
+ materialShaderVariantResolutionCache,
1246
+ group0MaterialLayout,
1247
+ viewOnlyMaterialPipelineLayout,
1248
+ viewAndSceneDepthMaterialPipelineLayout,
1249
+ group0ResourceLayouts,
1250
+ preparedMaterialPipelineLayoutCache,
1251
+ perShaderMaterialLayoutCache,
1252
+ materialShaderBindingContractCache,
1253
+ };
1254
+ let candidatePipelineCacheState: RendererPipelineCacheState | undefined;
1255
+ let candidateMaterialShaderUvSetCounts: Map<string, number> | undefined;
1256
+ let emptyPostProcessBgl: BindGroupLayout | null = null;
1257
+ let candidateEmptyPostProcessBgl: BindGroupLayout | null | undefined;
1258
+ type RendererCandidateState = {
1259
+ readonly device: RhiDevice;
1260
+ readonly context: RhiCanvasContext;
1261
+ readonly scope: DeviceScope;
1262
+ readonly gpuStore: GpuResidencyCache;
1263
+ readonly dynamicTextureStore: DynamicTextureStore;
1264
+ readonly shaderState: RendererShaderState;
1265
+ readonly pipelineCacheState: RendererPipelineCacheState;
1266
+ readonly materialShaderUvSetCounts: Map<string, number>;
1267
+ pipelineState: PipelineState | null;
1268
+ emptyPostProcessBgl: BindGroupLayout | null;
1269
+ growMeshSsbo: ((neededSlots: number) => MeshSsboGrowResult) | undefined;
1270
+ meshSsboState: MeshSsboState | undefined;
1271
+ };
1272
+ type RendererGenerationBindings = {
1273
+ readonly gpuStore: GpuResidencyCache;
1274
+ readonly dynamicTextureStore: DynamicTextureStore;
1275
+ readonly shaderState: RendererShaderState;
1276
+ readonly pipelineCacheState: RendererPipelineCacheState;
1277
+ readonly materialShaderUvSetCounts: Map<string, number>;
1278
+ readonly emptyPostProcessBgl: BindGroupLayout | null;
1279
+ readonly growMeshSsbo: ((neededSlots: number) => MeshSsboGrowResult) | undefined;
1280
+ readonly meshSsboState: MeshSsboState | undefined;
1281
+ };
1282
+ let candidateBuildState: RendererCandidateState | undefined;
1283
+ const currentPipelineCacheState = (): RendererPipelineCacheState =>
1284
+ candidatePipelineCacheState ?? activePipelineCacheState;
1285
+ const currentPipelineState = (): PipelineState | null =>
1286
+ candidateBuildState === undefined ? pipelineState : candidateBuildState.pipelineState;
1287
+ const currentBuildDevice = (): RhiDevice => candidateBuildState?.device ?? internals.device;
1288
+ const currentMaterialShaderUvSetCounts = (): Map<string, number> =>
1289
+ candidateMaterialShaderUvSetCounts ?? materialShaderUvSetCounts;
1290
+ const currentEmptyPostProcessBgl = (): BindGroupLayout | null =>
1291
+ candidateEmptyPostProcessBgl === undefined ? emptyPostProcessBgl : candidateEmptyPostProcessBgl;
1292
+ const getCachedMaterialShaderBindingContract = (
1293
+ materialShaderId: string,
1294
+ ): MaterialShaderBindingContract => {
1295
+ const cacheState = currentPipelineCacheState();
1296
+ const lookup = getShader().findMaterialArtifact(materialShaderId);
1297
+ if (!lookup.ok) return 'render-material';
1298
+ const cached = cacheState.materialShaderBindingContractCache.get(materialShaderId);
1299
+ if (cached?.source === lookup.value.source) return cached.contract;
1300
+ const contract = resolveMaterialShaderBindingContract(lookup.value.source);
1301
+ cacheState.materialShaderBindingContractCache.set(materialShaderId, {
1302
+ source: lookup.value.source,
1303
+ contract,
1304
+ });
1305
+ return contract;
1306
+ };
1307
+ const getOrBuildGroup0MaterialLayout = (): {
1308
+ materialBgl: BindGroupLayout;
1309
+ pipelineLayout: PipelineLayout;
1310
+ } | null => {
1311
+ const cacheState = currentPipelineCacheState();
1312
+ if (cacheState.group0MaterialLayout !== null) return cacheState.group0MaterialLayout;
1313
+ if (currentPipelineState() === null) return null;
1314
+ const bglRes = currentBuildDevice().createBindGroupLayout({ entries: [] });
1315
+ if (!bglRes.ok) {
1316
+ internals.errorRegistry.fire(bglRes.error);
1317
+ return null;
1318
+ }
1319
+ const plRes = currentBuildDevice().createPipelineLayout({
1320
+ label: 'material-group-0-pipeline-layout',
1321
+ bindGroupLayouts: [bglRes.value],
1322
+ });
1323
+ if (!plRes.ok) {
1324
+ internals.errorRegistry.fire(plRes.error);
1325
+ return null;
1326
+ }
1327
+ cacheState.group0MaterialLayout = { materialBgl: bglRes.value, pipelineLayout: plRes.value };
1328
+ return cacheState.group0MaterialLayout;
1329
+ };
1330
+ const getOrBuildViewOnlyMaterialPipelineLayout = (): PipelineLayout | null => {
1331
+ const cacheState = currentPipelineCacheState();
1332
+ if (cacheState.viewOnlyMaterialPipelineLayout !== null) {
1333
+ return cacheState.viewOnlyMaterialPipelineLayout;
1334
+ }
1335
+ const currentState = currentPipelineState();
1336
+ if (currentState === null) return null;
1337
+ const plRes = currentBuildDevice().createPipelineLayout({
1338
+ label: 'material-view-only-pipeline-layout',
1339
+ bindGroupLayouts: [currentState.viewBindGroupLayout],
1340
+ });
1341
+ if (!plRes.ok) {
1342
+ internals.errorRegistry.fire(plRes.error);
1343
+ return null;
1344
+ }
1345
+ cacheState.viewOnlyMaterialPipelineLayout = plRes.value;
1346
+ return cacheState.viewOnlyMaterialPipelineLayout;
1347
+ };
1348
+ const getOrBuildViewAndSceneDepthMaterialPipelineLayout = (): PipelineLayout | null => {
1349
+ const cacheState = currentPipelineCacheState();
1350
+ if (cacheState.viewAndSceneDepthMaterialPipelineLayout !== null) {
1351
+ return cacheState.viewAndSceneDepthMaterialPipelineLayout;
1352
+ }
1353
+ const bglRes = currentBuildDevice().createBindGroupLayout({
1354
+ label: 'material-view-scene-depth-bgl',
1355
+ entries: [
1356
+ {
1357
+ binding: 0,
1358
+ visibility: GPU_SHADER_STAGE_VERTEX | GPU_SHADER_STAGE_FRAGMENT,
1359
+ buffer: { type: 'uniform' },
1360
+ },
1361
+ {
1362
+ binding: 1,
1363
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
1364
+ texture: { sampleType: 'depth', viewDimension: '2d', multisampled: false },
1365
+ },
1366
+ ],
1367
+ });
1368
+ if (!bglRes.ok) {
1369
+ internals.errorRegistry.fire(bglRes.error);
1370
+ return null;
1371
+ }
1372
+ const plRes = currentBuildDevice().createPipelineLayout({
1373
+ label: 'material-view-scene-depth-pipeline-layout',
1374
+ bindGroupLayouts: [bglRes.value],
1375
+ });
1376
+ if (!plRes.ok) {
1377
+ internals.errorRegistry.fire(plRes.error);
1378
+ return null;
1379
+ }
1380
+ cacheState.viewAndSceneDepthMaterialPipelineLayout = plRes.value;
1381
+ return cacheState.viewAndSceneDepthMaterialPipelineLayout;
1382
+ };
1383
+ const getOrBuildGroup0ResourceLayout = (
1384
+ materialShaderId: string,
1385
+ ): { materialBgl: BindGroupLayout; pipelineLayout: PipelineLayout } | null => {
1386
+ const cacheState = currentPipelineCacheState();
1387
+ const cached = cacheState.group0ResourceLayouts.get(materialShaderId);
1388
+ if (cached !== undefined) return cached;
1389
+ const lookup = getShader().findMaterialArtifact(materialShaderId);
1390
+ if (
1391
+ !lookup.ok ||
1392
+ !/@group\s*\(\s*0\s*\)\s*@binding\s*\(\s*0\s*\)[^;]*texture_depth_2d/u.test(
1393
+ lookup.value.source,
1394
+ )
1395
+ ) {
1396
+ return null;
1397
+ }
1398
+ const bgl = currentBuildDevice().createBindGroupLayout({
1399
+ label: `material-group-0-resource-${materialShaderId}`,
1400
+ entries: [
1401
+ {
1402
+ binding: 0,
1403
+ visibility: 2,
1404
+ texture: { sampleType: 'depth', viewDimension: '2d', multisampled: false },
1405
+ },
1406
+ ],
1407
+ });
1408
+ if (!bgl.ok) return null;
1409
+ const pipelineLayout = currentBuildDevice().createPipelineLayout({
1410
+ label: `material-group-0-resource-pl-${materialShaderId}`,
1411
+ bindGroupLayouts: [bgl.value],
1412
+ });
1413
+ if (!pipelineLayout.ok) return null;
1414
+ const built = { materialBgl: bgl.value, pipelineLayout: pipelineLayout.value };
1415
+ cacheState.group0ResourceLayouts.set(materialShaderId, built);
1416
+ return built;
1417
+ };
1418
+ const getOrBuildPreparedMaterialPipelineLayout = (
1419
+ materialShaderId: string,
1420
+ ): PipelineLayout | null => {
1421
+ const cacheState = currentPipelineCacheState();
1422
+ const cached = cacheState.preparedMaterialPipelineLayoutCache.get(materialShaderId);
1423
+ if (cached !== undefined) return cached;
1424
+ const currentState = currentPipelineState();
1425
+ if (currentState === null) return null;
1426
+ const materialBgl =
1427
+ (isEngineOwnedMaterialShader(materialShaderId)
1428
+ ? undefined
1429
+ : getOrBuildPerShaderMaterialLayout(materialShaderId)?.materialBgl) ??
1430
+ currentState.materialBindGroupLayout;
1431
+ const result = currentBuildDevice().createPipelineLayout({
1432
+ label: `prepared-material-pl-${materialShaderId}`,
1433
+ bindGroupLayouts: [currentState.viewBindGroupLayout, materialBgl],
1434
+ });
1435
+ if (!result.ok) {
1436
+ internals.errorRegistry.fire(result.error);
1437
+ return null;
1438
+ }
1439
+ cacheState.preparedMaterialPipelineLayoutCache.set(materialShaderId, result.value);
1440
+ return result.value;
1441
+ };
1442
+ const getOrBuildPerShaderMaterialLayout = (
1443
+ materialShaderId: string,
1444
+ explicitParamSchema?: readonly ParamSchemaEntry[],
1445
+ layoutKind: LayoutKind = 'pbr',
1446
+ ): PerShaderMaterialLayout | null => {
1447
+ const cacheState = currentPipelineCacheState();
1448
+ const currentState = currentPipelineState();
1449
+ if (currentState === null) return null;
1450
+ if (isEngineOwnedMaterialShader(materialShaderId)) return null;
1451
+ const lookup = getShader().findMaterialArtifact(materialShaderId);
1452
+ const source = lookup.ok ? lookup.value.source : '';
1453
+ const paramSchema = lookup.ok ? lookup.value.paramSchema : explicitParamSchema;
1454
+ if (paramSchema === undefined) return null;
1455
+ const cached = cacheState.perShaderMaterialLayoutCache.get(materialShaderId);
1456
+ if (
1457
+ cached?.source === source &&
1458
+ cached.paramSchema === paramSchema &&
1459
+ cached.layoutKind === layoutKind
1460
+ ) {
1461
+ return cached.layout;
1462
+ }
1463
+ // A custom schema may reuse the shared PBR layout only when every derived
1464
+ // binding is an identical canonical prefix. Otherwise build its own layout
1465
+ // so texture dimensions and sampler/storage shapes cannot drift.
1466
+ const requiresStandardMapLayout =
1467
+ isCanonicalStandardPbrMaterialShader(materialShaderId) ||
1468
+ isStandardPbrMaterialShader(materialShaderId) ||
1469
+ standardPhysicalTextureFields(paramSchema).length > 0;
1470
+ const isStandardMapLayout = requiresStandardMapLayout;
1471
+ if (!isStandardMapLayout && isSharedMaterialUserRegionCompatible(paramSchema)) {
1472
+ cacheState.perShaderMaterialLayoutCache.set(materialShaderId, {
1473
+ source,
1474
+ paramSchema,
1475
+ layoutKind,
1476
+ layout: null,
1477
+ });
1478
+ return null;
1479
+ }
1480
+ const spec: PipelineSpec = {
1481
+ shader: { id: materialShaderId, passKind: 'forward', variantSet: undefined },
1482
+ attachments: { colorFormats: [], depthFormat: undefined, sampleCount: 1 },
1483
+ geometry: { topology: 'triangle-list', vertexLayout: {} },
1484
+ renderState: undefined,
1485
+ };
1486
+ const desc = buildBindGroupLayoutDescriptor(spec, {
1487
+ kind: 'pbr-material-merged',
1488
+ materialParamSchema: paramSchema,
1489
+ });
1490
+ const bglRes = currentBuildDevice().createBindGroupLayout(desc);
1491
+ if (!bglRes.ok) {
1492
+ internals.errorRegistry.fire(bglRes.error);
1493
+ return null;
1494
+ }
1495
+ const meshLayout =
1496
+ layoutKind === 'pbr-skin'
1497
+ ? currentState.pbrSkinMeshBindGroupLayout
1498
+ : layoutKind === 'hdrp-skin'
1499
+ ? currentState.hdrpSkinMeshBindGroupLayout
1500
+ : currentState.meshBindGroupLayout;
1501
+ if (meshLayout === null) return null;
1502
+ const plRes = currentBuildDevice().createPipelineLayout({
1503
+ label: `pbr-pl-${materialShaderId}`,
1504
+ bindGroupLayouts: [
1505
+ currentState.viewBindGroupLayout,
1506
+ bglRes.value,
1507
+ meshLayout,
1508
+ currentState.instancesBindGroupLayout,
1509
+ ],
1510
+ });
1511
+ if (!plRes.ok) {
1512
+ internals.errorRegistry.fire(plRes.error);
1513
+ return null;
1514
+ }
1515
+ const built = { materialBgl: bglRes.value, pipelineLayout: plRes.value };
1516
+ cacheState.perShaderMaterialLayoutCache.set(materialShaderId, {
1517
+ source,
1518
+ paramSchema,
1519
+ layoutKind,
1520
+ layout: built,
1521
+ });
1522
+ return built;
1523
+ };
1524
+ const resolveCachedMaterialShaderVariantSet = (
1525
+ requestedVariantSet: string | undefined,
1526
+ manifestEntry: import('@forgeax/engine-shader').MaterialShaderManifestEntry | undefined,
1527
+ ): string | undefined => {
1528
+ // Keep lazy PSO resolution aligned with buildReadyWebGPU's sampled-texture gate.
1529
+ const device = currentBuildDevice();
1530
+ const sampledTextureLimit = device.limits.maxSampledTexturesPerShaderStage;
1531
+ if (manifestEntry === undefined) {
1532
+ return resolveMaterialShaderVariantSet(
1533
+ requestedVariantSet,
1534
+ [],
1535
+ device.caps.backendKind,
1536
+ device.caps.storageBuffer,
1537
+ sampledTextureLimit,
1538
+ );
1539
+ }
1540
+ const variants = manifestEntry.variants;
1541
+ let byRequest = materialShaderVariantResolutionCache.get(variants);
1542
+ if (byRequest === undefined) {
1543
+ byRequest = new Map();
1544
+ materialShaderVariantResolutionCache.set(variants, byRequest);
1545
+ }
1546
+ const cacheKey = `${requestedVariantSet ?? '\u0000'}|${device.caps.backendKind}|${device.caps.storageBuffer ? '1' : '0'}|${sampledTextureLimit ?? '\u0000'}`;
1547
+ if (byRequest.has(cacheKey)) return byRequest.get(cacheKey);
1548
+ const resolved = resolveMaterialShaderVariantSet(
1549
+ requestedVariantSet,
1550
+ variants,
1551
+ device.caps.backendKind,
1552
+ device.caps.storageBuffer,
1553
+ sampledTextureLimit,
1554
+ );
1555
+ byRequest.set(cacheKey, resolved);
1556
+ return resolved;
1557
+ };
1558
+ const findMaterialShaderManifestEntry = (
1559
+ materialShaderId: string,
1560
+ ): MaterialShaderManifestEntry | undefined => {
1561
+ const cacheState = currentPipelineCacheState();
1562
+ const cached = cacheState.materialShaderManifestEntryCache.get(materialShaderId);
1563
+ if (cached !== undefined) return cached;
1564
+ for (const candidate of getShader().materialShaderManifestEntries()) {
1565
+ if (candidate.identifier === materialShaderId) {
1566
+ cacheState.materialShaderManifestEntryCache.set(materialShaderId, candidate);
1567
+ return candidate;
1568
+ }
1569
+ }
1570
+ return undefined;
1571
+ };
1572
+ // feat-20260622-s5 M3 / w17: the pipeline build is factored into a closure so
1573
+ // the recover() rebuild can re-run the SAME three-step assembly against the
1574
+ // freshly-acquired device (SSOT — one build path, one set of seed callbacks).
1575
+ // All captured references (getShader / gpuStore / getShaderModuleAdapter /
1576
+ // materialShaderPipelineCache / renderSystem) are stable across recover; only
1577
+ // `internals.device` / `internals.pack` are read live, so a rebuild after a
1578
+ // device swap compiles against the new device.
1579
+ const promotePipelineExtendedLighting = (state: PipelineState, scope: DeviceScope): void => {
1580
+ if (
1581
+ state.extendedLightingAvailable !== true ||
1582
+ state.iesProfileTexture === undefined ||
1583
+ state.cookieTexture === undefined ||
1584
+ state.cookieMatrixBuffer === undefined ||
1585
+ state.iesProfileTextureView === undefined ||
1586
+ state.cookieTextureView === undefined ||
1587
+ state.ltcLambertTextureView === undefined ||
1588
+ state.ltcGgxTextureView === undefined
1589
+ ) {
1590
+ return;
1591
+ }
1592
+ const candidate: ExtendedLightingResourceCandidate = {
1593
+ topology: EXTENDED_LIGHTING_TOPOLOGY,
1594
+ generation: scope.generation,
1595
+ scope,
1596
+ iesSliceCount: 0,
1597
+ cookieSliceCount: 0,
1598
+ cookieMatrices: 0,
1599
+ sampler: state.defaultSampler,
1600
+ iesTexture: state.iesProfileTexture,
1601
+ cookieTexture: state.cookieTexture,
1602
+ cookieMatrixBuffer: state.cookieMatrixBuffer,
1603
+ descriptorBytes:
1604
+ IES_SLICE_WIDTH * IES_SLICE_HEIGHT * 2 * 32 +
1605
+ COOKIE_SLICE_SIZE * COOKIE_SLICE_SIZE * 4 * 32 +
1606
+ COOKIE_MATRIX_BYTES,
1607
+ uploadCount: 5,
1608
+ };
1609
+ extendedLightingState = promoteExtendedLightingCandidate(extendedLightingState, candidate);
1610
+ };
1611
+ let tonemapRegistered = false;
1612
+ let fxaaRegistered = false;
1613
+ let temporalPostProcessesRegistered = false;
1614
+ const buildPipeline = (
1615
+ scope: DeviceScope = activeDeviceScope,
1616
+ device: RhiDevice = currentBuildDevice(),
1617
+ residencyStore: GpuResidencyCache = gpuStore,
1618
+ ): Promise<PipelineState> =>
1619
+ buildReadyWebGPU(
1620
+ device,
1621
+ scope,
1622
+ getShader,
1623
+ residencyStore,
1624
+ internals.pack.createShaderModule,
1625
+ internals.errorRegistry,
1626
+ requiredMaterialShaders,
1627
+ requiredFullscreenPostProcesses,
1628
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
1629
+ // T-M2-05 + M3 / T-M3-04: callback set by buildReadyWebGPU once
1630
+ // meshSsboController is wired. Both the grow hook AND the read-only
1631
+ // state ref land on `internals` so the record stage (M3
1632
+ // ensureMeshSsboCapacity) can read slotCount + call grow through
1633
+ // RenderSystemInternals.
1634
+ (hook, state) => {
1635
+ if (candidateBuildState !== undefined) {
1636
+ candidateBuildState.growMeshSsbo = hook;
1637
+ candidateBuildState.meshSsboState = state;
1638
+ } else {
1639
+ internals.growMeshSsbo = hook;
1640
+ internals.meshSsboState = state;
1641
+ }
1642
+ },
1643
+ // feat-20260609 R3-fixup: seed the lazy adapter cache with the
1644
+ // eagerly-compiled shadow_caster module.
1645
+ (label, module) => {
1646
+ getShaderModuleAdapter().seedModule(label, module);
1647
+ },
1648
+ // M6 fix-up: seed the active or candidate material pipeline cache from
1649
+ // the boot-time SPEC_CONST prewarm. Recovery builds must never put a
1650
+ // candidate-device PSO into the retired generation's cache.
1651
+ (key, pso, group2Contract) => {
1652
+ const pipelineCache = currentPipelineCacheState().materialShaderPipelineCache;
1653
+ if (!pipelineCache.has(key)) {
1654
+ pipelineCache.set(key, pso);
1655
+ }
1656
+ materialShaderPipelineGroup2Contracts.set(pso as object, group2Contract);
1657
+ },
1658
+ // feat-20260621 M-A3 (D-5): register the built-in tonemap on the unified
1659
+ // post-process channel once buildReadyWebGPU resolves the tonemap manifest
1660
+ // entry's composed WGSL. Fires after the manifest-load await, by which point
1661
+ // `renderSystem` (declared synchronously below) is defined. The 16 B params
1662
+ // schema mirrors the prior dedicated UBO: [exposure(f32), whitePoint(f32),
1663
+ // mode(u32), pad(f32)]; the extract stage bridges Camera.exposure/whitePoint/
1664
+ // tonemap into this channel each frame (render-system-extract.ts w13).
1665
+ (source: string) => {
1666
+ if (candidateBuildState !== undefined || tonemapRegistered) return;
1667
+ renderSystem.registerBuiltinPostProcess(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, {
1668
+ source,
1669
+ params: { byteSize: 16, defaultValue: new Uint8Array(16) },
1670
+ reads: ['hdrColor'],
1671
+ });
1672
+ tonemapRegistered = true;
1673
+ },
1674
+ (source: string) => {
1675
+ if (fxaaRegistered) return;
1676
+ renderSystem.registerBuiltinPostProcess(FXAA_POST_PROCESS_ID, {
1677
+ source,
1678
+ params: { byteSize: 16, defaultValue: new Uint8Array(16) },
1679
+ reads: ['ldrColor'],
1680
+ });
1681
+ fxaaRegistered = true;
1682
+ },
1683
+ (entries: { readonly motionBlur?: string; readonly taaResolve?: string }) => {
1684
+ if (candidateBuildState !== undefined || temporalPostProcessesRegistered) return;
1685
+ if (entries.motionBlur !== undefined) {
1686
+ renderSystem.registerBuiltinPostProcess('forgeax.motion-blur', {
1687
+ source: entries.motionBlur,
1688
+ params: { byteSize: 16, defaultValue: new Uint8Array(16) },
1689
+ reads: ['scene-color', 'scene-temporal'],
1690
+ });
1691
+ }
1692
+ if (entries.taaResolve !== undefined) {
1693
+ renderSystem.registerBuiltinPostProcess('forgeax.taa-resolve', {
1694
+ source: entries.taaResolve,
1695
+ params: { byteSize: 16, defaultValue: new Uint8Array(16) },
1696
+ reads: ['scene-color', 'scene-temporal', 'taa-history-color', 'taa-history-temporal'],
1697
+ });
1698
+ }
1699
+ temporalPostProcessesRegistered = true;
1700
+ },
1701
+ (sources: VolumetricFogShaderSources) => {
1702
+ internals.volumetricFogShaders = sources;
1703
+ },
1704
+ );
1705
+ const ready: Promise<Result<void, RhiError>> = buildPipeline().then(
1706
+ (state): Result<void, RhiError> => {
1707
+ pipelineState = state;
1708
+ promotePipelineExtendedLighting(state, activeDeviceScope);
1709
+ readySettled = true;
1710
+ return ok(undefined);
1711
+ },
1712
+ (e: unknown): Result<void, RhiError> => {
1713
+ readySettled = true;
1714
+ if (e instanceof RhiError) return err(e);
1715
+ const message = e instanceof Error ? e.message : String(e);
1716
+ return err(
1717
+ new RhiError({
1718
+ code: 'webgpu-runtime-error',
1719
+ expected: 'Renderer.initialization three-step strict-serial succeeds',
1720
+ hint: `pipeline build raised: ${message}`,
1721
+ }),
1722
+ );
1723
+ },
1724
+ );
1725
+ /**
1726
+ * bug-20260527-renderstate-pipeline-dispatch-gap D-3:
1727
+ * finds the engine-shipped PBR manifest entry from the shader registry
1728
+ * by content marker (f_schlick BRDF helper call, same marker
1729
+ * buildReadyWebGPU uses). Returns undefined when the manifest is
1730
+ * empty (Camera-only path) or the pbr entry is not found.
1731
+ */
1732
+ const findStandardPbrEntry = (
1733
+ variantSet?: string,
1734
+ ): import('@forgeax/engine-types').ManifestEntry | undefined => {
1735
+ const device = currentBuildDevice();
1736
+ let standardEntry: import('@forgeax/engine-types').ManifestEntry | undefined;
1737
+ for (const entry of getShader().entries()) {
1738
+ if (entry.wgsl.includes('f_schlick')) {
1739
+ standardEntry = entry;
1740
+ break;
1741
+ }
1742
+ }
1743
+ if (standardEntry === undefined) return undefined;
1744
+ const pbrManifestEntry = [...getShader().materialShaderManifestEntries()].find(
1745
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
1746
+ );
1747
+ const noColorVariant = selectNoColorPbrVariant(
1748
+ pbrManifestEntry,
1749
+ device.caps.storageBuffer,
1750
+ variantSet,
1751
+ deriveExtendedLightingCapability(device).admitted,
1752
+ device.caps.backendKind === 'webgpu' || device.caps.backendKind === 'wgpu-native',
1753
+ (device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1754
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1755
+ );
1756
+ if (noColorVariant !== undefined && noColorVariant.composedWgsl !== standardEntry.wgsl) {
1757
+ return { ...standardEntry, wgsl: noColorVariant.composedWgsl };
1758
+ }
1759
+ const runtimePbr = getShader().findMaterialArtifact('forgeax::default-standard-pbr');
1760
+ if (runtimePbr.ok && runtimePbr.value.source !== standardEntry.wgsl) {
1761
+ return { ...standardEntry, wgsl: runtimePbr.value.source };
1762
+ }
1763
+ return standardEntry;
1764
+ };
1765
+ // Shared builder context for the per-MaterialShader pipeline cache.
1766
+ // Built once; reused for both registered shader IDs and fallback
1767
+ // paths (D-3). Null when manifest is empty (Camera-only path).
1768
+ //
1769
+ // feat-20260609-hdrp-cluster-fragment-ggx M4.5 / w37 (D-10 option A):
1770
+ // `variantSet` parameter selects between URP `pbrPipelineLayout` (1-slot
1771
+ // group(2) BGL) and HDRP `hdrpPbrPipelineLayout` (7-slot group(2) BGL)
1772
+ // via `selectPipelineLayoutForVariant`. When undefined (legacy callers
1773
+ // / fallback path) the URP layout is used preserving prior behaviour.
1774
+ const buildPipelineContext = (
1775
+ variantSet?: string,
1776
+ materialShaderId?: string,
1777
+ group2Contract: PipelineGroup2Contract = 'mesh',
1778
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
1779
+ // when layoutKind === 'pbr-skin' the SSOT for the vertex buffer layout
1780
+ // is `deriveVertexBufferLayout` (vertex-attribute-layout.ts) — the same
1781
+ // function that the WGSL @location(N) declarations / naga reflect tests
1782
+ // consume. A caller passing a real `MeshAsset.attributes` produces the
1783
+ // exact 6-attribute / 72-byte layout the skin shader expects; passing
1784
+ // undefined falls into the synthetic 6-key sentinel below (the skin
1785
+ // path is fully determined by layoutKind, so key-presence is the only
1786
+ // signal `deriveVertexBufferLayout` reads — values never matter).
1787
+ meshAttributes?: VertexAttributeMap,
1788
+ vertexLayout?: string,
1789
+ vertexLayoutProjection?: VertexLayoutProjection,
1790
+ shaderModuleMode?: RenderFeatureShaderModuleMode,
1791
+ layoutKindOverride?: LayoutKind,
1792
+ ) => {
1793
+ const contractLayoutKind: LayoutKind =
1794
+ group2Contract === 'cluster'
1795
+ ? 'hdrp-pbr'
1796
+ : group2Contract === 'skin-cluster'
1797
+ ? 'hdrp-skin'
1798
+ : group2Contract === 'skin'
1799
+ ? 'pbr-skin'
1800
+ : 'pbr';
1801
+ const layoutKind = layoutKindOverride ?? contractLayoutKind;
1802
+ const usesSharedBootMaterialLayout =
1803
+ materialShaderId !== undefined && isEngineOwnedMaterialShader(materialShaderId);
1804
+ // Built-in PBR/unlit/skin layouts are assembled at boot. Re-deriving a
1805
+ // per-shader layout from the expanded PBR schema would bypass the HDRP
1806
+ // group(2) selector and bind the URP mesh BGL to the cluster pipeline.
1807
+ const perShaderLayout =
1808
+ !usesSharedBootMaterialLayout && materialShaderId !== undefined
1809
+ ? getOrBuildPerShaderMaterialLayout(materialShaderId, undefined, layoutKind)
1810
+ : null;
1811
+ // feat-20260621-learn-render-5-5-parallax M2 / w6 (D-1): an authored
1812
+ // shader owns a per-shader pipeline layout derived from its paramSchema,
1813
+ // because its compact texture order may differ from standard PBR even
1814
+ // when it declares only a few textures. Skin / HDRP keep their dedicated
1815
+ // layouts; built-in material IDs remain on the shared boot-time layout.
1816
+ const bindingContract =
1817
+ materialShaderId === undefined
1818
+ ? 'render-material'
1819
+ : getCachedMaterialShaderBindingContract(materialShaderId);
1820
+ const group0Layout = bindingContract === 'group-0' ? getOrBuildGroup0MaterialLayout() : null;
1821
+ const group0ResourceLayout =
1822
+ bindingContract === 'group-0-resource' && materialShaderId !== undefined
1823
+ ? getOrBuildGroup0ResourceLayout(materialShaderId)
1824
+ : null;
1825
+ const viewOnlyLayout =
1826
+ bindingContract === 'view-only' ? getOrBuildViewOnlyMaterialPipelineLayout() : null;
1827
+ const viewAndSceneDepthLayout =
1828
+ bindingContract === 'view-and-scene-depth'
1829
+ ? getOrBuildViewAndSceneDepthMaterialPipelineLayout()
1830
+ : null;
1831
+ const preparedMaterialLayout =
1832
+ bindingContract === 'render-material' &&
1833
+ materialShaderId !== undefined &&
1834
+ (vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance ||
1835
+ vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance)
1836
+ ? getOrBuildPreparedMaterialPipelineLayout(materialShaderId)
1837
+ : null;
1838
+ const materialShaderLookup =
1839
+ materialShaderId === undefined
1840
+ ? undefined
1841
+ : getShader().findMaterialArtifact(materialShaderId);
1842
+ const materialShaderSource = materialShaderLookup?.ok
1843
+ ? materialShaderLookup.value.source
1844
+ : undefined;
1845
+ const vertexInputContract =
1846
+ materialShaderId === undefined
1847
+ ? 'render-material'
1848
+ : materialShaderSource !== undefined
1849
+ ? resolveMaterialShaderVertexInputContract(materialShaderSource)
1850
+ : 'render-material';
1851
+ const currentState = currentPipelineState();
1852
+ const pipelineLayout =
1853
+ group0Layout !== null
1854
+ ? group0Layout.pipelineLayout
1855
+ : group0ResourceLayout !== null
1856
+ ? group0ResourceLayout.pipelineLayout
1857
+ : viewOnlyLayout !== null
1858
+ ? viewOnlyLayout
1859
+ : viewAndSceneDepthLayout !== null
1860
+ ? viewAndSceneDepthLayout
1861
+ : preparedMaterialLayout !== null
1862
+ ? preparedMaterialLayout
1863
+ : perShaderLayout !== null
1864
+ ? perShaderLayout.pipelineLayout
1865
+ : selectPipelineLayoutForVariant(currentState, variantSet, layoutKind);
1866
+ if (pipelineLayout === null) return null;
1867
+ const currentMaterialUvSetCounts = currentMaterialShaderUvSetCounts();
1868
+ const resolvedUvSetCount =
1869
+ materialShaderId !== undefined
1870
+ ? (currentMaterialUvSetCounts.get(materialShaderId) ??
1871
+ (materialShaderSource !== undefined
1872
+ ? resolveMaterialShaderUvSetCount(
1873
+ materialShaderSource,
1874
+ currentMaterialUvSetCounts.get(materialShaderId),
1875
+ )
1876
+ : undefined))
1877
+ : undefined;
1878
+ const vertexBuffers: readonly GPUVertexBufferLayout[] =
1879
+ vertexInputContract === 'none'
1880
+ ? []
1881
+ : vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.positionSizeColorInstance
1882
+ ? POSITION_SIZE_COLOR_INSTANCE_VERTEX_BUFFERS
1883
+ : vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance
1884
+ ? BILLBOARD_MATERIAL_INSTANCE_VERTEX_BUFFERS
1885
+ : vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance
1886
+ ? TOPOLOGY_SEGMENT_INSTANCE_VERTEX_BUFFERS
1887
+ : vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance
1888
+ ? MESH_GEOMETRY_MATERIAL_INSTANCE_VERTEX_BUFFERS
1889
+ : vertexLayoutProjection !== undefined
1890
+ ? toGpuVertexBufferLayouts(
1891
+ deriveVertexBufferLayoutFromProjection(
1892
+ vertexLayoutProjection,
1893
+ resolvedUvSetCount !== undefined
1894
+ ? { shaderUvSetCount: resolvedUvSetCount }
1895
+ : undefined,
1896
+ ),
1897
+ )
1898
+ : layoutKind === 'pbr-skin'
1899
+ ? toGpuVertexBufferLayouts(
1900
+ deriveVertexBufferLayout(
1901
+ meshAttributes ??
1902
+ ({
1903
+ position: PBR_SKIN_SENTINEL_ATTR_BUFFER,
1904
+ normal: PBR_SKIN_SENTINEL_ATTR_BUFFER,
1905
+ uv: PBR_SKIN_SENTINEL_ATTR_BUFFER,
1906
+ tangent: PBR_SKIN_SENTINEL_ATTR_BUFFER,
1907
+ skinIndex: PBR_SKIN_SENTINEL_ATTR_BUFFER,
1908
+ skinWeight: PBR_SKIN_SENTINEL_ATTR_BUFFER,
1909
+ } satisfies VertexAttributeMap),
1910
+ resolvedUvSetCount !== undefined
1911
+ ? { shaderUvSetCount: resolvedUvSetCount }
1912
+ : undefined,
1913
+ ),
1914
+ )
1915
+ : meshAttributes !== undefined ||
1916
+ (resolvedUvSetCount !== undefined && resolvedUvSetCount > 1)
1917
+ ? toGpuVertexBufferLayouts(
1918
+ deriveVertexBufferLayout(
1919
+ meshAttributes ?? DEFAULT_VERTEX_ATTRS,
1920
+ resolvedUvSetCount !== undefined && resolvedUvSetCount > 1
1921
+ ? { shaderUvSetCount: resolvedUvSetCount }
1922
+ : undefined,
1923
+ ),
1924
+ )
1925
+ : ([
1926
+ {
1927
+ arrayStride: 12 * 4,
1928
+ attributes: [
1929
+ { shaderLocation: 0, offset: 0, format: 'float32x3' as const },
1930
+ { shaderLocation: 1, offset: 3 * 4, format: 'float32x3' as const },
1931
+ { shaderLocation: 2, offset: 6 * 4, format: 'float32x2' as const },
1932
+ { shaderLocation: 3, offset: 8 * 4, format: 'float32x4' as const },
1933
+ ],
1934
+ },
1935
+ ] satisfies readonly GPUVertexBufferLayout[]);
1936
+ return {
1937
+ device: currentBuildDevice(),
1938
+ shaderModuleFactory:
1939
+ shaderModuleMode === 'immediate'
1940
+ ? getImmediateShaderModuleAdapter()
1941
+ : getShaderModuleAdapter(),
1942
+ pipelineLayout,
1943
+ vertexBuffers,
1944
+ ...(layoutKind === 'pbr-skin' ? { layoutKind } : {}),
1945
+ };
1946
+ };
1947
+ /**
1948
+ * Builds a pipeline for the given entry, caches it keyed by cacheKey,
1949
+ * and returns it. Returns null on build failure (firing errorRegistry
1950
+ * for non-transient errors).
1951
+ */
1952
+ const buildAndCachePipeline = (
1953
+ cacheKey: string,
1954
+ entry: MaterialShaderEntry,
1955
+ label: string,
1956
+ moduleLabel: string,
1957
+ isHdr: boolean,
1958
+ renderState: MaterialRenderState | undefined,
1959
+ topology: PrimitiveTopology | undefined,
1960
+ stripIndexFormat: 'uint16' | 'uint32' | undefined,
1961
+ // feat-20260609 M4.5 / w37 (D-10): thread variantSet to the layout selector
1962
+ // so HDRP-variant PSOs build with `hdrpPbrPipelineLayout` (7-slot group(2))
1963
+ // and URP-variant PSOs build with `pbrPipelineLayout` (1-slot group(2)).
1964
+ variantSet?: string,
1965
+ passKind: PassKind = 'forward',
1966
+ // bug-20260611-skin-pipeline-layout: thread materialShaderId so
1967
+ // buildPipelineContext can derive `LayoutKind === 'pbr-skin'` and pick
1968
+ // the 2-entry mesh-array BGL chain.
1969
+ materialShaderId?: string,
1970
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
1971
+ // pass the per-mesh `VertexAttributeMap` so the pbr-skin path's
1972
+ // vertex buffer layout flows from the SSOT `deriveVertexBufferLayout`
1973
+ // (vertex-attribute-layout.ts) instead of a parallel hardcoded copy.
1974
+ // Undefined falls into the synthetic 6-key sentinel inside
1975
+ // `buildPipelineContext` (key-presence-only); non-skin layoutKinds
1976
+ // ignore this parameter entirely.
1977
+ meshAttributes?: VertexAttributeMap,
1978
+ // bug-20260615 M2 / m2-1: sampleCount drives the multisample descriptor
1979
+ // field in buildPipelineForMaterialShader — it is a CAMERA fact (per-frame
1980
+ // antialias setting), not a material renderState value. Default 1 preserves
1981
+ // byte-identity of every existing pre-M2 cache slot + descriptor.
1982
+ sampleCount: number = 1,
1983
+ // feat-20260625-refactor-sprite-as-transparent-mesh R2 fix-up: LDR-color
1984
+ // override for sub-passes that write to a non-default attachment view.
1985
+ // Pre-feat the dedicated `forgeax::default-sprite` SPEC_CONST entries
1986
+ // (SPRITE_ATTACHMENTS_LDR_S1/S4) targeted the swap-chain STORAGE format
1987
+ // (non-sRGB) directly. Post-w14 sprite falls into the generic lazy build
1988
+ // path which defaults to `pipelineState.colorAttachmentFormat` (the sRGB
1989
+ // VIEW format used by the geometry pass) — incompatible with the sprite
1990
+ // sub-pass's non-sRGB attachment view (bgra8unorm / rgba8unorm), firing
1991
+ // a per-frame "Attachment state ... not compatible" validation error.
1992
+ // The sprite sub-pass call site passes `pipelineState.format` (storage,
1993
+ // non-sRGB) here so the resulting PSO matches the encoder's attachment
1994
+ // state. Undefined preserves the default (sRGB view) path used by every
1995
+ // pre-fix-up caller. Ignored when `isHdr=true` or `passKind='shadow-caster'`.
1996
+ colorFormatOverride?: GPUTextureFormat,
1997
+ // Prepared graphics passes use `null` to request an explicit color-only
1998
+ // pipeline; omitted preserves the material forward depth default.
1999
+ depthFormatOverride?: GPUTextureFormat | null,
2000
+ vertexLayout?: string,
2001
+ vertexLayoutProjection?: VertexLayoutProjection,
2002
+ shaderModuleMode?: RenderFeatureShaderModuleMode,
2003
+ layoutKindOverride?: LayoutKind,
2004
+ additionalColorFormats?: readonly GPUTextureFormat[],
2005
+ ): RenderPipeline | null => {
2006
+ const group2Contract = resolvePipelineGroup2Contract(entry.source);
2007
+ const ctx = buildPipelineContext(
2008
+ variantSet,
2009
+ materialShaderId,
2010
+ group2Contract,
2011
+ meshAttributes,
2012
+ vertexLayout,
2013
+ vertexLayoutProjection,
2014
+ shaderModuleMode,
2015
+ layoutKindOverride,
2016
+ );
2017
+ if (ctx === null) return null;
2018
+ // bug-20260612: route the LDR color format through pipelineState so the
2019
+ // backend-aware swap-chain format chosen at buildReadyWebGPU flows here.
2020
+ // Callers gate on pipelineState !== null before reaching this lambda
2021
+ // (search "buildAndCachePipeline" call sites — every one is preceded by
2022
+ // the explicit null check).
2023
+ const currentState = currentPipelineState();
2024
+ if (currentState === null) return null;
2025
+ const ldrColorFormat = colorFormatOverride ?? currentState.colorAttachmentFormat;
2026
+ const fragmentEntry =
2027
+ isHdr &&
2028
+ (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
2029
+ ? 'fs_main_hdr'
2030
+ : undefined;
2031
+ const built = buildPipelineForMaterialShader(
2032
+ cacheKey,
2033
+ // The catalog entry is projected into the material-shader entry shape.
2034
+ entry,
2035
+ {
2036
+ ...ctx,
2037
+ colorFormat: isHdr ? HDR_COLOR_ATTACHMENT_FORMAT : ldrColorFormat,
2038
+ colorFormats:
2039
+ additionalColorFormats === undefined
2040
+ ? undefined
2041
+ : [isHdr ? HDR_COLOR_ATTACHMENT_FORMAT : ldrColorFormat, ...additionalColorFormats],
2042
+ depthFormat:
2043
+ depthFormatOverride === null ? undefined : (depthFormatOverride ?? DEPTH_TEXTURE_FORMAT),
2044
+ label,
2045
+ // feat-20260604 w16-b: the shader-MODULE cache identity. Stable across
2046
+ // topology / renderState / isHdr / indexFormat (all baked into the PSO,
2047
+ // not the module), so every pipeline variant of the same shader source
2048
+ // reuses one compiled module instead of forcing a fresh async compile
2049
+ // per variant. See PipelineBuilderContext.moduleLabel.
2050
+ moduleLabel,
2051
+ },
2052
+ renderState,
2053
+ // w8/w15: pack topology (+ stripIndexFormat) into the builder's geometry
2054
+ // param. Strip topologies bake stripIndexFormat into the immutable PSO
2055
+ // (WebGPU spec: only valid for line-strip / triangle-strip). The record
2056
+ // stage (w9 + w15) threads each mesh's topology + indexFormat here; when
2057
+ // the caller omits stripIndexFormat we fall back to 'uint32' (the engine
2058
+ // procedural index width: createBoxGeometry etc. emit Uint32 indices).
2059
+ topology !== undefined
2060
+ ? {
2061
+ topology,
2062
+ ...(topology === 'line-strip' || topology === 'triangle-strip'
2063
+ ? { stripIndexFormat: stripIndexFormat ?? ('uint32' as const) }
2064
+ : {}),
2065
+ }
2066
+ : undefined,
2067
+ undefined, // vertexEntry — default vs_main
2068
+ fragmentEntry,
2069
+ undefined, // defines — none
2070
+ passKind,
2071
+ sampleCount,
2072
+ );
2073
+ if (!built.ok) {
2074
+ if (built.error.code !== 'rhi-not-available') {
2075
+ internals.errorRegistry.fire(built.error);
2076
+ }
2077
+ return null;
2078
+ }
2079
+ currentPipelineCacheState().materialShaderPipelineCache.set(cacheKey, built.value);
2080
+ materialShaderPipelineGroup2Contracts.set(
2081
+ built.value as object,
2082
+ resolvePipelineGroup2Contract(entry.source),
2083
+ );
2084
+ return built.value;
2085
+ };
2086
+ const getMaterialShaderPipeline = (
2087
+ materialShaderId: string,
2088
+ isHdr: boolean,
2089
+ renderState?: MaterialRenderState,
2090
+ topology?: PrimitiveTopology,
2091
+ indexFormat?: 'uint16' | 'uint32',
2092
+ variantSet?: string,
2093
+ passKind: PassKind = 'forward',
2094
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2095
+ // forwarded to `buildAndCachePipeline -> buildPipelineContext` so the
2096
+ // pbr-skin layout chain reads from the `deriveVertexBufferLayout` SSOT.
2097
+ // Optional: existing callers (URP / HDRP / shadow-depth) pass undefined
2098
+ // and the hardcoded 4-attribute layout is preserved (AC-04 zero-regression).
2099
+ meshAttributes?: VertexAttributeMap,
2100
+ // bug-20260615 M2 / m2-1: sampleCount is threaded through to the cache key,
2101
+ // buildAndCachePipeline, and ultimately buildPipelineForMaterialShader which
2102
+ // sets the multisample descriptor field. Default 1 preserves byte-identity
2103
+ // of every pre-M2 caller.
2104
+ sampleCount: number = 1,
2105
+ // feat-20260625-refactor-sprite-as-transparent-mesh R2 fix-up: LDR-color
2106
+ // override for sub-passes that write to a non-default attachment view.
2107
+ // The LDR sprite split sub-pass writes through the storage (non-sRGB)
2108
+ // view of the swap-chain texture; the encoder's beginRenderPass
2109
+ // colorFormats uses `pipelineState.format` (storage), so the PSO must
2110
+ // build with the same non-sRGB format or WebGPU rejects SetPipeline
2111
+ // with "Attachment state ... not compatible". Pre-w14 the dedicated
2112
+ // `forgeax::default-sprite` SPEC_CONST entries (deleted) baked this
2113
+ // mapping into SPRITE_ATTACHMENTS_LDR_S1/S4; the generic lazy build
2114
+ // path that replaced them defaults to `colorAttachmentFormat` (the
2115
+ // sRGB view used by the geometry pass), so transparent-split callers
2116
+ // must override. Threaded into both the cache key (via the spec's
2117
+ // `attachments.colorFormats`) and the actual PSO descriptor. Ignored
2118
+ // for `isHdr=true` (HDR sub-pass uses rgba16float) and
2119
+ // `passKind='shadow-caster'` (depth-only, no color attachment).
2120
+ colorFormatOverride?: GPUTextureFormat,
2121
+ // feat-20260629-multi-uv-set-support m3-w5: shader-declared UV set count,
2122
+ // forwarded to PipelineSpec.geometry.shaderUvSetCount for clamp-to-last
2123
+ // alias. Undefined = fallback to mesh-provided count (no clamping).
2124
+ // m4-w3: auto-filled from naga reflection when caller passes undefined.
2125
+ shaderUvSetCount?: number,
2126
+ // Prepared graphics passes use `null` to request an explicit color-only
2127
+ // pipeline; omitted preserves the material forward depth default.
2128
+ depthFormatOverride?: GPUTextureFormat | null,
2129
+ vertexLayout?: string,
2130
+ vertexLayoutProjection?: VertexLayoutProjection,
2131
+ shaderModuleMode: RenderFeatureShaderModuleMode = 'validated',
2132
+ layoutKindOverride?: LayoutKind,
2133
+ additionalColorFormats?: readonly GPUTextureFormat[],
2134
+ ): RenderPipeline | null => {
2135
+ // WebGL2 is a downlevel backend even when the record stage asks for the
2136
+ // native-capability variant string. Resolve the variant axes at this
2137
+ // shared PSO seam so the shader source, BGL layout, and cache key agree on
2138
+ // the actual device capabilities. Without this, WebGL2 can build a
2139
+ // storage-buffer PSO around a uniform-buffer shader and only reject it at
2140
+ // queue submit as an invalid RenderPipeline.
2141
+ const currentState = currentPipelineState();
2142
+ const ldrColorFormat: GPUTextureFormat =
2143
+ colorFormatOverride ??
2144
+ (currentState !== null ? currentState.colorAttachmentFormat : 'bgra8unorm-srgb');
2145
+ // feat-20260629 M4: auto-fill shaderUvSetCount from naga reflection
2146
+ // (stored in materialShaderUvSetCounts during prepareMaterialShaders).
2147
+ const pipelineShaderId = resolveMaterialShaderBackendArtifactKey(
2148
+ materialShaderId,
2149
+ internals.device.caps.backendKind,
2150
+ assets.getMaterialArtifact(materialShaderId),
2151
+ );
2152
+ const materialShaderLookup = getShader().findMaterialArtifact(pipelineShaderId);
2153
+ const materialShaderSource = materialShaderLookup.ok
2154
+ ? materialShaderLookup.value.source
2155
+ : undefined;
2156
+ const currentMaterialUvSetCounts = currentMaterialShaderUvSetCounts();
2157
+ const materialUvSetCount = resolveMaterialShaderUvSetCount(
2158
+ materialShaderSource,
2159
+ currentMaterialUvSetCounts.get(pipelineShaderId),
2160
+ );
2161
+ if (materialUvSetCount !== undefined) {
2162
+ currentMaterialUvSetCounts.set(pipelineShaderId, materialUvSetCount);
2163
+ }
2164
+ const resolvedUvSetCount = shaderUvSetCount ?? materialUvSetCount;
2165
+ const manifestEntry = findMaterialShaderManifestEntry(pipelineShaderId);
2166
+ // Sprite's omitted request is the boot-selected default artifact. The
2167
+ // explicit empty key remains the PER_INSTANCE_REGION=true request used by
2168
+ // SpriteInstances, so only that path is capability-rewritten here.
2169
+ const resolvedVariantSet =
2170
+ variantSet === undefined &&
2171
+ (pipelineShaderId === 'forgeax::sprite' || pipelineShaderId === 'forgeax::sprite-lit')
2172
+ ? undefined
2173
+ : resolveCachedMaterialShaderVariantSet(variantSet, manifestEntry);
2174
+ // The record stage also composes engine capability axes for user shaders.
2175
+ // Keep only manifest-declared axes for non-engine material IDs: a plain
2176
+ // custom shader must not collapse that request to '' because '' is the
2177
+ // canonical HDRP variant key and would pair an HDRP PSO with the URP mesh
2178
+ // bind group. Builtin forgeax:: shaders keep the compatibility request
2179
+ // when a reduced test/legacy manifest omits variant metadata.
2180
+ // A single-source manifest entry must stay on its canonical module key;
2181
+ // otherwise a synthesized capability axis races the prewarmed module.
2182
+ let effectiveVariantSet = normalizeMaterialShaderVariantSet(resolvedVariantSet, manifestEntry);
2183
+ // Runtime-published authored materials are installed from their immutable
2184
+ // cooked artifact, not from the build-time material-shader manifest. The
2185
+ // published artifact identity already selects the backend-specific bytes;
2186
+ // an absent manifest therefore means the source itself is the canonical
2187
+ // pipeline module and must not carry a synthesized capability key.
2188
+ if (manifestEntry === undefined) effectiveVariantSet = undefined;
2189
+ if (
2190
+ vertexLayoutProjection === undefined &&
2191
+ manifestEntry?.variants.some((variant) => 'VERTEX_COLOR_AVAILABLE' in variant.defines)
2192
+ ) {
2193
+ if (effectiveVariantSet === '') {
2194
+ const noColorVariant = manifestEntry.variants.find(
2195
+ (variant) =>
2196
+ variant.defines.VERTEX_COLOR_AVAILABLE === false &&
2197
+ Object.entries(variant.defines).every(
2198
+ ([axis, value]) => axis === 'VERTEX_COLOR_AVAILABLE' || value === true,
2199
+ ),
2200
+ );
2201
+ effectiveVariantSet = noColorVariant?.definesKey ?? 'VERTEX_COLOR_AVAILABLE=false';
2202
+ } else {
2203
+ effectiveVariantSet = effectiveVariantSet?.replace(
2204
+ 'VERTEX_COLOR_AVAILABLE=true',
2205
+ 'VERTEX_COLOR_AVAILABLE=false',
2206
+ );
2207
+ }
2208
+ }
2209
+ if (
2210
+ manifestEntry !== undefined &&
2211
+ !pipelineShaderId.startsWith('forgeax::') &&
2212
+ effectiveVariantSet !== undefined &&
2213
+ effectiveVariantSet !== ''
2214
+ ) {
2215
+ const declaredAxes = new Set<string>();
2216
+ for (const variant of manifestEntry.variants) {
2217
+ for (const axis of Object.keys(variant.defines)) declaredAxes.add(axis);
2218
+ }
2219
+ const filteredVariantParts = effectiveVariantSet
2220
+ .split('+')
2221
+ .filter((part) => declaredAxes.has(part.slice(0, part.indexOf('='))))
2222
+ .sort();
2223
+ effectiveVariantSet =
2224
+ filteredVariantParts.length === 0 ? undefined : filteredVariantParts.join('+');
2225
+ }
2226
+ // feat-20260615-pipeline-spec-ssot M2-T2: cache key derived from PipelineSpec
2227
+ // 4-axis SSOT via cacheKeyOf(spec). The spec carries all 4 axes (shader /
2228
+ // attachments / geometry / renderState), replacing the legacy 8-segment string
2229
+ // construction. vertexLayout is now included in the cache key (was previously
2230
+ // threaded but not hashed — research F1 R-VertexLayout-Cache). sampleCount is
2231
+ // cast to 1 | 4 for the closed spec axis.
2232
+ // M2-fixup follow-up: LDR color format SSOT is `pipelineState.colorAttachmentFormat`
2233
+ // (set in buildReadyWebGPU from selectSwapChainFormat). Hardcoding 'bgra8unorm-srgb'
2234
+ // here let the cache key drift from the prewarm key on backends where
2235
+ // getPreferredCanvasFormat returns rgba8unorm (dawn-node, lavapipe, wgpu-wasm GLES),
2236
+ // forcing a redundant second PSO build on first-frame URP record path.
2237
+ const colorFormat: GPUTextureFormat = isHdr ? 'rgba16float' : ldrColorFormat;
2238
+ const isDepthOnlyPass = passKindPolicyTable[passKind]?.shape === 'depth-only';
2239
+ const spec: PipelineSpec = {
2240
+ shader: { id: pipelineShaderId, passKind, variantSet: effectiveVariantSet },
2241
+ attachments: {
2242
+ colorFormats:
2243
+ additionalColorFormats === undefined
2244
+ ? colorFormatsForPassKind(passKind, colorFormat)
2245
+ : [colorFormat, ...additionalColorFormats],
2246
+ depthFormat: isDepthOnlyPass
2247
+ ? 'depth32float'
2248
+ : depthFormatOverride === null
2249
+ ? undefined
2250
+ : (depthFormatOverride ?? 'depth24plus-stencil8'),
2251
+ sampleCount: (sampleCount === 4 ? 4 : 1) as 1 | 4,
2252
+ },
2253
+ geometry: {
2254
+ topology: topology ?? 'triangle-list',
2255
+ stripIndexFormat: indexFormat,
2256
+ vertexLayout:
2257
+ meshAttributes ??
2258
+ (vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.positionSizeColorInstance
2259
+ ? PREPARED_INSTANCE_VERTEX_ATTRS
2260
+ : vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance ||
2261
+ vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance ||
2262
+ vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance
2263
+ ? PREPARED_MATERIAL_INSTANCE_VERTEX_ATTRS
2264
+ : DEFAULT_VERTEX_ATTRS),
2265
+ // Thread the shader-declared count whenever it is greater than one.
2266
+ // Built-in PBR reserves all eight supported UV inputs; single-UV meshes
2267
+ // remain byte-stable because missing sets are clamp-to-last aliases.
2268
+ ...(resolvedUvSetCount !== undefined && resolvedUvSetCount > 1
2269
+ ? { shaderUvSetCount: resolvedUvSetCount }
2270
+ : {}),
2271
+ ...(vertexLayoutProjection === undefined ? {} : { vertexLayoutProjection }),
2272
+ },
2273
+ renderState,
2274
+ };
2275
+ const cacheKey = cacheKeyOf(spec);
2276
+ const cached = currentPipelineCacheState().materialShaderPipelineCache.get(cacheKey);
2277
+ if (cached !== undefined) return cached;
2278
+ if (pipelineState === null) return null;
2279
+ const lookup = getShader().findMaterialArtifact(pipelineShaderId);
2280
+ if (!lookup.ok) {
2281
+ if (shouldDeferMissingPreparedMaterialShader(vertexLayout)) {
2282
+ // Prepared VFX draws bind only their feature-owned groups. The
2283
+ // built-in PBR fallback declares mesh/instance groups 2/3 and would
2284
+ // consequently emit an invalid pipeline before the authored shader
2285
+ // becomes available. Let the feature retry on the next frame.
2286
+ return null;
2287
+ }
2288
+ // bug-20260527-renderstate-pipeline-dispatch-gap D-3:
2289
+ // fallback path parity -- when renderState is defined and the
2290
+ // shader id is not registered, build a renderState-variant of
2291
+ // the standard pipeline from the engine-shipped PBR entry.
2292
+ if (
2293
+ renderState === undefined &&
2294
+ !allowsUnlitPreparedFallback(depthFormatOverride, vertexLayout, pipelineShaderId)
2295
+ ) {
2296
+ // A prepared feature declares the vertex layout it owns. Falling back
2297
+ // to default-unlit here would pair that data with the unlit shader's
2298
+ // position/normal/uv/tangent inputs and let WebGPU reject the
2299
+ // pipeline at validation time. Keep the missing shader retryable and
2300
+ // preserve the declared input contract instead.
2301
+ return null;
2302
+ }
2303
+ if (renderState === undefined) {
2304
+ const unlitLookup = getShader().findMaterialArtifact('forgeax::default-unlit');
2305
+ if (!unlitLookup.ok) return null;
2306
+ return buildAndCachePipeline(
2307
+ cacheKey,
2308
+ unlitLookup.value,
2309
+ `pbr-pipeline-prepared-${pipelineShaderId}`,
2310
+ // Reuse the eagerly compiled engine unlit module. The adapter cache
2311
+ // is seeded under this label during renderer readiness; a new label
2312
+ // would keep color-only prepared passes in perpetual warm-up.
2313
+ 'unlit',
2314
+ isHdr,
2315
+ undefined,
2316
+ topology,
2317
+ indexFormat,
2318
+ effectiveVariantSet,
2319
+ passKind,
2320
+ 'forgeax::default-unlit',
2321
+ meshAttributes,
2322
+ sampleCount,
2323
+ colorFormatOverride,
2324
+ depthFormatOverride,
2325
+ vertexLayout,
2326
+ vertexLayoutProjection,
2327
+ shaderModuleMode,
2328
+ layoutKindOverride,
2329
+ additionalColorFormats,
2330
+ );
2331
+ }
2332
+ const pbrEntry = findStandardPbrEntry(effectiveVariantSet);
2333
+ if (pbrEntry === undefined) return null;
2334
+ return buildAndCachePipeline(
2335
+ cacheKey,
2336
+ { source: pbrEntry.wgsl, paramSchema: [] },
2337
+ `pbr-pipeline-fallback-${pipelineShaderId}${isHdr ? '-hdr' : ''}`,
2338
+ // w16-b: module identity is the fallback PBR source, stable across
2339
+ // topology / renderState / HDR (all baked into the PSO) so every
2340
+ // variant reuses one compiled module.
2341
+ 'module-fallback-pbr',
2342
+ isHdr,
2343
+ renderState,
2344
+ topology,
2345
+ indexFormat,
2346
+ // M4.5 / w37 (D-10): the fallback path also threads variantSet so the
2347
+ // layout selector picks HDRP layout when an HDRP caller falls into
2348
+ // this branch (registered shader id missing).
2349
+ effectiveVariantSet,
2350
+ // feat-20260609 / T-002: passKind threaded through the fallback path
2351
+ // for parity with the main path; default 'forward' keeps every prior
2352
+ // fallback caller byte-identical.
2353
+ passKind,
2354
+ // bug-20260611-skin-pipeline-layout: passing materialShaderId here is
2355
+ // intentional even on the fallback (registered-id-missing) branch --
2356
+ // a missing skin shader registration should not silently pick the
2357
+ // wrong BGL chain. With LayoutKind='pbr-skin' the selector returns
2358
+ // null when pbrSkinPipelineLayout is null (charter P3 explicit fail).
2359
+ //
2360
+ // MaterialAsset per-slot texCoord: this branch always compiles the
2361
+ // built-in PBR module (`pbrEntry.wgsl` / 'module-fallback-pbr'), whose
2362
+ // vertex stage declares all eight UV inputs. The vertex-buffer layout
2363
+ // therefore has to be the PBR layout with all declared slots present,
2364
+ // not the caller shader's -- e.g. a transparent sprite / sprite-lit
2365
+ // material lands here with meshAttributes carrying only uv0, so the
2366
+ // sprite id would resolve a 48-byte 4-attribute layout and the PBR
2367
+ // module would reject a missing UV slot. Pass the built-in PBR id for
2368
+ // layout resolution so buildPipelineContext derives the layout that
2369
+ // matches the compiled module. Skin keeps its
2370
+ // own id so the pbr-skin fail-fast (null layout) is preserved.
2371
+ pipelineShaderId === SKIN_MATERIAL_SHADER_ID
2372
+ ? pipelineShaderId
2373
+ : 'forgeax::default-standard-pbr',
2374
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2375
+ // forward meshAttributes through the fallback path so the pbr-skin
2376
+ // layout chain reads from the deriveVertexBufferLayout SSOT here too.
2377
+ meshAttributes,
2378
+ sampleCount,
2379
+ colorFormatOverride,
2380
+ depthFormatOverride,
2381
+ vertexLayout,
2382
+ vertexLayoutProjection,
2383
+ shaderModuleMode,
2384
+ layoutKindOverride,
2385
+ additionalColorFormats,
2386
+ );
2387
+ }
2388
+ // feat-20260609 M4 / w31: resolve variant WGSL from manifest when
2389
+ // variantSet is non-empty. The boot-time registered shader (from
2390
+ // `installMaterialArtifact` at line ~2473) carries the default (all-true)
2391
+ // variant's WGSL. For URP callers that want a different variant
2392
+ // (e.g. STORAGE_BUFFER_AVAILABLE=true without CLUSTER_FORWARD_AVAILABLE),
2393
+ // we look up the manifest entry, find the matching variant, and
2394
+ // substitute its composedWgsl into the PSO build path. The pipeline
2395
+ // layout itself is built by buildPbrPipelineLayouts (M3 / w12).
2396
+ // When variantSet is empty/undefined, the boot-time registered entry
2397
+ // (which is the all-true default) is used verbatim — backward compat.
2398
+ let shaderEntry = lookup.value;
2399
+ // M4.5 / w38 (D-11): `variantSet === ''` is canonical all-true (HDRP
2400
+ // path) and MUST hit the manifest variant lookup -- treat it as a
2401
+ // first-class variant request, not a falsy "no variant" signal.
2402
+ // Use `!== undefined` so the empty-string case enters the lookup.
2403
+ if (effectiveVariantSet !== undefined) {
2404
+ const registry = getShader();
2405
+ for (const msEntry of registry.materialShaderManifestEntries()) {
2406
+ if (msEntry.identifier === pipelineShaderId) {
2407
+ const variant = findVariantByKey(msEntry, effectiveVariantSet);
2408
+ if (variant) {
2409
+ // M3 / w12-w13: variant substitution carries the same source +
2410
+ // paramSchema as the boot-registered entry; the binding layout
2411
+ // is no longer carried on MaterialShaderEntry (deleted in
2412
+ // w13) — buildPbrPipelineLayouts is the BGL SSOT and reads
2413
+ // derive(paramSchema).bglEntries on demand.
2414
+ shaderEntry = {
2415
+ source: variant.composedWgsl,
2416
+ paramSchema: lookup.value.paramSchema,
2417
+ paramSchemaProjection: lookup.value.paramSchemaProjection,
2418
+ };
2419
+ }
2420
+ break;
2421
+ }
2422
+ }
2423
+ }
2424
+ // feat-20260609 M4 / R3-fixup: append `-${passKind}` to the PSO label so
2425
+ // GPU debug captures (and the shadow-caster branch in the builder)
2426
+ // make the cache variant visible. The fallback path above keeps the
2427
+ // pre-existing fallback-* label shape (no shadow-caster fallback
2428
+ // exists today; the only shadow caller registers shadowCaster directly).
2429
+ const passKindLabelSegment = passKind === 'forward' ? '' : `-${passKind}`;
2430
+ return buildAndCachePipeline(
2431
+ cacheKey,
2432
+ shaderEntry,
2433
+ `pbr-pipeline-${pipelineShaderId}${isHdr ? '-hdr' : ''}${passKindLabelSegment}`,
2434
+ // feat-20260609 M4 / w31: when variantSet is non-empty, the module identity
2435
+ // includes the variant key so URP (STORAGE_BUFFER_AVAILABLE=true) and HDRP
2436
+ // (CLUSTER_FORWARD_AVAILABLE=true+STORAGE_BUFFER_AVAILABLE=true) variants
2437
+ // compile as separate shader modules (they have different WGSL sources).
2438
+ // When variantSet is empty/undefined, the module identity stays pre-M4
2439
+ // backward-compatible for all PSOs of the default variant.
2440
+ // M4.5 / w38 (D-11): same `!== undefined` discipline as the cache
2441
+ // key -- `''` (canonical all-true) gets its own module-label slot,
2442
+ // distinct from the no-variant path. Trailing `#` for the empty
2443
+ // case is intentional (parallel to the cache key's `:variant:`
2444
+ // empty-tail segment); module identity stays a function of the
2445
+ // exact variantSet string.
2446
+ effectiveVariantSet !== undefined
2447
+ ? `module-${pipelineShaderId}#${effectiveVariantSet}`
2448
+ : `module-${pipelineShaderId}`,
2449
+ isHdr,
2450
+ renderState,
2451
+ topology,
2452
+ indexFormat,
2453
+ // M4.5 / w37 (D-10): main path threads variantSet so HDRP-variant PSO
2454
+ // builds against `hdrpPbrPipelineLayout` (7-slot group(2) BGL) and URP
2455
+ // builds against `pbrPipelineLayout` (1-slot group(2) BGL).
2456
+ effectiveVariantSet,
2457
+ // feat-20260609 / T-002: passKind selects createRenderPipeline
2458
+ // attachment shape (forward color+DS vs shadow-caster depth32float
2459
+ // no-color). Orthogonal to variantSet (which selects the BGL chain).
2460
+ passKind,
2461
+ // bug-20260611-skin-pipeline-layout: thread the registered materialShaderId
2462
+ // so buildPipelineContext can resolve LayoutKind='pbr-skin' for the skin
2463
+ // shader (2-entry mesh-array BGL).
2464
+ pipelineShaderId,
2465
+ // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2466
+ // forward meshAttributes so the pbr-skin path's vertex buffer layout
2467
+ // is derived via the SSOT (deriveVertexBufferLayout). For URP/HDRP
2468
+ // callers (and for a caller passing undefined) the synthetic 6-key
2469
+ // sentinel inside buildPipelineContext keeps the layout deterministic.
2470
+ meshAttributes,
2471
+ sampleCount,
2472
+ colorFormatOverride,
2473
+ depthFormatOverride,
2474
+ vertexLayout,
2475
+ vertexLayoutProjection,
2476
+ shaderModuleMode,
2477
+ layoutKindOverride,
2478
+ additionalColorFormats,
2479
+ );
2480
+ };
2481
+ const getMaterialShaderPipelineEntry = (
2482
+ ...args: Parameters<typeof getMaterialShaderPipeline>
2483
+ ) => {
2484
+ const pipeline = getMaterialShaderPipeline(...args);
2485
+ if (pipeline === null) return null;
2486
+ return {
2487
+ pipeline,
2488
+ group2Contract:
2489
+ materialShaderPipelineGroup2Contracts.get(pipeline as object) ?? ('mesh' as const),
2490
+ };
2491
+ };
2492
+ const getParamSchema = (materialShaderId: string) => {
2493
+ const lookup = getShader().findMaterialArtifact(materialShaderId);
2494
+ return lookup.ok ? lookup.value.paramSchema : undefined;
2495
+ };
2496
+ // feat-20260621-learn-render-5-5-parallax M2 / w6 (D-1): expose the per-shader
2497
+ // material BGL so the record stage creates the material bind group against
2498
+ // the matching paramSchema-derived layout. Returns undefined only for
2499
+ // group-0 / view-only contracts or an unavailable shader layout; built-in
2500
+ // material IDs continue to resolve through the shared layout path.
2501
+ const getMaterialBindGroupLayout = (
2502
+ materialShaderId: string,
2503
+ materialParamSchema?: readonly ParamSchemaEntry[],
2504
+ ): BindGroupLayout | undefined => {
2505
+ if (isEngineOwnedMaterialShader(materialShaderId)) return undefined;
2506
+ const contract = getCachedMaterialShaderBindingContract(materialShaderId);
2507
+ if (contract === 'group-0') {
2508
+ return getOrBuildGroup0MaterialLayout()?.materialBgl;
2509
+ }
2510
+ if (contract === 'group-0-resource') {
2511
+ return getOrBuildGroup0ResourceLayout(materialShaderId)?.materialBgl;
2512
+ }
2513
+ if (contract === 'view-and-scene-depth') return undefined;
2514
+ return (
2515
+ getOrBuildPerShaderMaterialLayout(materialShaderId, materialParamSchema)?.materialBgl ??
2516
+ undefined
2517
+ );
2518
+ };
2519
+ // feat-20260609 M4 / T-10-a: post-process pipeline factory backing
2520
+ // RenderSystemRuntime.getPostProcessPipeline. Solves M1 CONCERN-1: previously
2521
+ // the dispatcher in render-graph-primitives.ts passed `pipeline=null` to
2522
+ // built.createHandle because per-frame execute closures cannot await
2523
+ // device.createShaderModule (async). This factory uses the same shared
2524
+ // makeShaderDeviceAdapter the material-shader pipeline cache uses (sync
2525
+ // wrapper + 1-frame warmup); first-call returns null while the async compile
2526
+ // is in flight; second frame onward returns the built pipeline.
2527
+ //
2528
+ // The pipeline layout is fixed:
2529
+ // group(0) = empty BGL (reserved per render-graph-primitives.ts convention
2530
+ // for view bind groups; populated by future post-process passes
2531
+ // that need view UBOs)
2532
+ // group(1) = the input-texture BGL the dispatcher already composed via
2533
+ // buildFullscreenPostProcessPass (texture + sampler)
2534
+ // Vertex stage: vs_main (no vertex buffers); fragment stage: fs_main targeting
2535
+ // `colorFormat`. Topology: triangle-list with cullMode='none' (3-vertex
2536
+ // fullscreen draw via the canonical fullscreen_triangle pattern).
2537
+ const buildPostProcessPipeline = (
2538
+ entry: PostProcessShaderEntry,
2539
+ bgl: BindGroupLayout,
2540
+ colorFormat: GPUTextureFormat,
2541
+ label: string,
2542
+ ): RenderPipeline | null => {
2543
+ const moduleFactory = getShaderModuleAdapter();
2544
+ const moduleResult = moduleFactory.createShaderModule({
2545
+ code: entry.source,
2546
+ label: `${label}-module`,
2547
+ });
2548
+ if (!moduleResult.ok) {
2549
+ // 'rhi-not-available' = async compile in flight: caller falls back one frame.
2550
+ // Other codes are real failures: surface through the error registry so AI
2551
+ // users see a structured RhiError instead of a silent black screen (charter P3).
2552
+ if (moduleResult.error.code !== 'rhi-not-available') {
2553
+ internals.errorRegistry.fire(moduleResult.error);
2554
+ }
2555
+ return null;
2556
+ }
2557
+ if (currentEmptyPostProcessBgl() === null) {
2558
+ const bglRes = currentBuildDevice().createBindGroupLayout({ entries: [] });
2559
+ if (!bglRes.ok) {
2560
+ internals.errorRegistry.fire(bglRes.error);
2561
+ return null;
2562
+ }
2563
+ if (candidateBuildState !== undefined) {
2564
+ candidateBuildState.emptyPostProcessBgl = bglRes.value;
2565
+ candidateEmptyPostProcessBgl = bglRes.value;
2566
+ } else {
2567
+ emptyPostProcessBgl = bglRes.value;
2568
+ }
2569
+ }
2570
+ const layoutRes = currentBuildDevice().createPipelineLayout({
2571
+ label: `${label}-layout`,
2572
+ bindGroupLayouts: [currentEmptyPostProcessBgl() as BindGroupLayout, bgl],
2573
+ });
2574
+ if (!layoutRes.ok) {
2575
+ internals.errorRegistry.fire(layoutRes.error);
2576
+ return null;
2577
+ }
2578
+ const pipelineRes = currentBuildDevice().createRenderPipeline({
2579
+ label,
2580
+ layout: layoutRes.value,
2581
+ vertex: {
2582
+ module: moduleResult.value,
2583
+ entryPoint: 'vs_main',
2584
+ buffers: [],
2585
+ },
2586
+ fragment: {
2587
+ module: moduleResult.value,
2588
+ entryPoint: 'fs_main',
2589
+ targets:
2590
+ entry.source.includes('@location(1) temporal') &&
2591
+ entry.source.includes('TaaResolveOutput')
2592
+ ? [{ format: colorFormat }, { format: colorFormat }]
2593
+ : [{ format: colorFormat }],
2594
+ },
2595
+ primitive: { topology: 'triangle-list', cullMode: 'none', frontFace: 'ccw' },
2596
+ depthStencil: undefined,
2597
+ multisample: undefined,
2598
+ });
2599
+ if (!pipelineRes.ok) {
2600
+ internals.errorRegistry.fire(pipelineRes.error);
2601
+ return null;
2602
+ }
2603
+ return pipelineRes.value;
2604
+ };
2605
+ let activeProfile = freezeRenderProfile(
2606
+ internals.options?.standardProfile ?? DEFAULT_STANDARD_PROFILE,
2607
+ );
2608
+ const renderSystem: RenderSystem = createRenderSystem({
2609
+ canvas: internals.canvas,
2610
+ get build() {
2611
+ return internals.bundler?.build;
2612
+ },
2613
+ ssrIdentity: internals.options?.ssrIdentity,
2614
+ get standardProfile() {
2615
+ return activeProfile;
2616
+ },
2617
+ standardPipeline,
2618
+ get featureHost() {
2619
+ return internals.featureHost;
2620
+ },
2621
+ setFeatureHost: (host) => {
2622
+ internals.featureHost = host;
2623
+ },
2624
+ // Keep the feature-facing factory stable while resolving the adapter at
2625
+ // call time. Renderer recovery swaps the device and invalidates the
2626
+ // per-device adapter; a boot-time snapshot would feed recovered VFX
2627
+ // programs shader modules owned by the lost device.
2628
+ shaderModuleFactory: {
2629
+ createShaderModule: (descriptor) => getShaderModuleAdapter().createShaderModule(descriptor),
2630
+ },
2631
+ immediateShaderModuleFactory: {
2632
+ createShaderModule: (descriptor) =>
2633
+ getImmediateShaderModuleAdapter().createShaderModule(descriptor),
2634
+ },
2635
+ profiler: internals.options?.profiler,
2636
+ // feat-20260622-s5 M3 / w17: device + context read live off `internals` via
2637
+ // getters so the recover() rebuild (which swaps internals.device /
2638
+ // internals.context for a freshly-acquired pair) is observed by the record
2639
+ // stage without reconstructing the RenderSystem (RenderSystemRuntime.device
2640
+ // / RenderSystemInternals.context are read at frame time, not cached).
2641
+ get device() {
2642
+ return internals.device;
2643
+ },
2644
+ get beforeSubmit() {
2645
+ return internals.pack.instrumentation?.beforeSubmit;
2646
+ },
2647
+ createShaderModule: (device, descriptor) =>
2648
+ internals.pack.createShaderModule === undefined
2649
+ ? Promise.resolve(invokeDeviceCreateShaderModule(device, descriptor))
2650
+ : internals.pack.createShaderModule(device, descriptor),
2651
+ get deviceScope() {
2652
+ return activeDeviceScope;
2653
+ },
2654
+ ...(internals.pack.instrumentation?.resolveSurfaceDevice === undefined
2655
+ ? {}
2656
+ : { resolveSurfaceDevice: internals.pack.instrumentation.resolveSurfaceDevice }),
2657
+ invalidateShaderModule: (label: string) => {
2658
+ activeShaderState.sharedShaderModuleAdapter?.invalidateModule(label);
2659
+ },
2660
+ get context() {
2661
+ return internals.context;
2662
+ },
2663
+ get debugOverlay() {
2664
+ return internals.debugOverlay;
2665
+ },
2666
+ // feat-20260608-create-app-param-surface-trim / M1 / AC-02: clearColor
2667
+ // is no longer threaded through createRenderSystem; the record stage
2668
+ // reads `camera.clearColor` straight from the Camera SoA column
2669
+ // (array<f32,4>, feat-20260709 M3).
2670
+ getPipelineState: () => pipelineState,
2671
+ assets,
2672
+ get gpuStore() {
2673
+ return gpuStore;
2674
+ },
2675
+ get dynamicTextureStore() {
2676
+ return dynamicTextureStore;
2677
+ },
2678
+ errorRegistry: internals.errorRegistry,
2679
+ healthRegistry: internals.healthRegistry,
2680
+ getMaterialShaderPipeline,
2681
+ getMaterialShaderPipelineEntry,
2682
+ getMaterialShaderBindingContract: getCachedMaterialShaderBindingContract,
2683
+ getParamSchema,
2684
+ getMaterialBindGroupLayout,
2685
+ metrics,
2686
+ // feat-20260609 M4 / T-10-a: post-process pipeline factory (CONCERN-1 fix).
2687
+ // createRenderSystem wraps this in a per-RenderSystem cache + the public
2688
+ // getPostProcessPipeline lookup the dispatcher reads at frame time.
2689
+ buildPostProcessPipeline,
2690
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M3 / T-M3-04:
2691
+ // forward the grow hook + state via getter closures — buildReadyWebGPU
2692
+ // sets `internals.growMeshSsbo` / `internals.meshSsboState` after this
2693
+ // factory call, so the record stage reads them through the closures
2694
+ // (read at frame time, when ready has already settled).
2695
+ get growMeshSsbo() {
2696
+ return internals.growMeshSsbo;
2697
+ },
2698
+ get meshSsboState() {
2699
+ return internals.meshSsboState;
2700
+ },
2701
+ get gpuPassTimingSession() {
2702
+ return (
2703
+ internals as WebGPURendererInternals & {
2704
+ gpuPassTimingSession?: GpuPassTimingSession | undefined;
2705
+ }
2706
+ ).gpuPassTimingSession;
2707
+ },
2708
+ set gpuPassTimingSession(session: GpuPassTimingSession | undefined) {
2709
+ (
2710
+ internals as WebGPURendererInternals & {
2711
+ gpuPassTimingSession?: GpuPassTimingSession | undefined;
2712
+ }
2713
+ ).gpuPassTimingSession = session;
2714
+ },
2715
+ get gpuPassTimingCapture() {
2716
+ return (
2717
+ internals as WebGPURendererInternals & {
2718
+ gpuPassTimingCapture?: GpuPassTimingCapture | undefined;
2719
+ }
2720
+ ).gpuPassTimingCapture;
2721
+ },
2722
+ set gpuPassTimingCapture(capture: GpuPassTimingCapture | undefined) {
2723
+ (
2724
+ internals as WebGPURendererInternals & {
2725
+ gpuPassTimingCapture?: GpuPassTimingCapture | undefined;
2726
+ }
2727
+ ).gpuPassTimingCapture = capture;
2728
+ },
2729
+ get gpuPassTimingSubmittedWork() {
2730
+ return internals.gpuPassTimingSubmittedWork;
2731
+ },
2732
+ set gpuPassTimingSubmittedWork(completion: Promise<void> | undefined) {
2733
+ internals.gpuPassTimingSubmittedWork = completion;
2734
+ },
2735
+ get gpuPassTimingFrameIdentity() {
2736
+ return (
2737
+ internals as WebGPURendererInternals & {
2738
+ gpuPassTimingFrameIdentity?:
2739
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingFrameIdentity
2740
+ | undefined;
2741
+ }
2742
+ ).gpuPassTimingFrameIdentity;
2743
+ },
2744
+ set gpuPassTimingFrameIdentity(identity:
2745
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingFrameIdentity
2746
+ | undefined,) {
2747
+ (
2748
+ internals as WebGPURendererInternals & {
2749
+ gpuPassTimingFrameIdentity?:
2750
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingFrameIdentity
2751
+ | undefined;
2752
+ }
2753
+ ).gpuPassTimingFrameIdentity = identity;
2754
+ },
2755
+ get gpuPassTimingBeginReason() {
2756
+ return (
2757
+ internals as WebGPURendererInternals & {
2758
+ gpuPassTimingBeginReason?: GpuPassTimingReason | undefined;
2759
+ }
2760
+ ).gpuPassTimingBeginReason;
2761
+ },
2762
+ set gpuPassTimingBeginReason(reason: GpuPassTimingReason | undefined) {
2763
+ (
2764
+ internals as WebGPURendererInternals & {
2765
+ gpuPassTimingBeginReason?: GpuPassTimingReason | undefined;
2766
+ }
2767
+ ).gpuPassTimingBeginReason = reason;
2768
+ },
2769
+ getRenderTargetPhysical: (target) => renderTargetHost.getPhysicalTarget(target),
2770
+ resolveRenderTargetTextureSource: (source) =>
2771
+ renderTargetHost.resolveRenderTargetTextureSource(source),
2772
+ encodeRenderTargetReadbacks: (encoder, faces) =>
2773
+ renderTargetHost.encodePendingReadbacks(encoder, faces),
2774
+ get volumetricFogShaders() {
2775
+ return internals.volumetricFogShaders;
2776
+ },
2777
+ });
2778
+ attachGpuPassTimingSession(internals, gpuPassTimingSession);
2779
+ renderSystemForTargetPromotion = renderSystem;
2780
+ if (
2781
+ internals.device.caps.backendKind === 'null' &&
2782
+ Array.from(getShader().entries()).length === 0
2783
+ ) {
2784
+ // RhiNull validates graph topology only; keep the built-in identity
2785
+ // registered so structural frames do not fail before graph inspection.
2786
+ renderSystem.registerBuiltinPostProcess(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, {
2787
+ source: '',
2788
+ params: { byteSize: 16, defaultValue: new Uint8Array(16) },
2789
+ reads: ['hdrColor'],
2790
+ });
2791
+ }
2792
+ // The Standard pipeline is the sole built-in graph owner. Feature plans are
2793
+ // projected into that graph directly; no second post-process registry is
2794
+ // installed for fullscreen producers.
2795
+ renderSystem.configureStandard(undefined);
2796
+ renderSystem.restorePostProcessResources();
2797
+ const attachedWorlds = new Set<World>();
2798
+ const attachedLeases = new Map<RenderReadLease, World>();
2799
+ const leasesByWorld = new Map<World, RenderReadLease>();
2800
+ const derivedSystemNames = new Map<World, string>();
2801
+ const transformReleases = new Map<World, () => void>();
2802
+ const attachmentOwner = {};
2803
+ let frameId = 0;
2804
+ // Receipts remain observable until their device generation is retired. A
2805
+ // registry avoids invalidating a still-valid frame merely because another
2806
+ // submission completed in the meantime.
2807
+ const currentReceipts = new Set<FrameReceipt>();
2808
+ const issuedReceipts = new WeakSet<FrameReceipt>();
2809
+ const receiptTimings = new WeakMap<FrameReceipt, Promise<VolumeTimingObservation>>();
2810
+ let latestReceipt: FrameReceipt | undefined;
2811
+ let surfaceReleased = false;
2812
+
2813
+ const renderer: RendererAssemblyImplementation = {
2814
+ attach(world: World): RenderResult<RenderWorldLease, RenderError> {
2815
+ const attached = this.attachScene(world);
2816
+ if (!attached.ok) {
2817
+ return err(new RendererContractFailureError('attach', attached.error.hint));
2818
+ }
2819
+ const lease = createRenderReadLease(world, attachmentOwner);
2820
+ attachedLeases.set(lease, world);
2821
+ leasesByWorld.set(world, lease);
2822
+ return ok(lease);
2823
+ },
2824
+ createRenderTarget: (descriptor) => renderTargetHost.createRenderTarget(descriptor),
2825
+ resizeRenderTarget: (target, descriptor) =>
2826
+ renderTargetHost.resizeRenderTarget(target, descriptor),
2827
+ createRenderTargetTextureSource: (target, options) =>
2828
+ renderTargetHost.createRenderTargetTextureSource(target, options),
2829
+ requestTargetReadback: (target, request) =>
2830
+ renderTargetHost.requestTargetReadback(target, request),
2831
+ destroyRenderTarget: (target) => renderTargetHost.destroyRenderTarget(target),
2832
+ setProfile(profile: RenderProfile): RenderResult<void, RenderError> {
2833
+ const invalid = validateRenderProfile(profile);
2834
+ if (invalid !== undefined) {
2835
+ return err(
2836
+ new RendererOperationError('frame-input-invalid', {
2837
+ operation: 'set-profile',
2838
+ cause: new RendererContractFailureError('draw', invalid),
2839
+ }),
2840
+ );
2841
+ }
2842
+ const previous = activeProfile;
2843
+ activeProfile = freezeRenderProfile(profile);
2844
+ try {
2845
+ renderSystem.configureStandard(undefined);
2846
+ return ok(undefined);
2847
+ } catch (cause) {
2848
+ activeProfile = previous;
2849
+ return err(
2850
+ new RendererOperationError('graph-build-failed', {
2851
+ operation: 'set-profile',
2852
+ cause: structuredRendererCause(cause, 'set-profile'),
2853
+ }),
2854
+ );
2855
+ }
2856
+ },
2857
+ inspectLodOcclusion() {
2858
+ return {
2859
+ lodOcclusion: renderSystem.lodOcclusionInspection,
2860
+ gpuDriven: renderSystem.gpuDrivenInspection,
2861
+ };
2862
+ },
2863
+ inspect(): RenderInspection {
2864
+ const health = internals.healthRegistry.getLastSnapshot();
2865
+ const deviceLost = health.reason === 'device-lost';
2866
+ const state = disposed
2867
+ ? 'disposed'
2868
+ : deviceLost
2869
+ ? 'device-lost'
2870
+ : health.reason === 'internal-fault' || !readySettled
2871
+ ? 'faulted'
2872
+ : 'alive';
2873
+ const graph = deviceLost ? undefined : renderSystem.perFrameGraphInfo;
2874
+ const standardLighting = deviceLost ? undefined : renderSystem.standardLightingInspection;
2875
+ const bloom: BloomInspection = deviceLost
2876
+ ? {
2877
+ graphStatus: 'empty',
2878
+ enabled: false,
2879
+ targetCount: 0,
2880
+ targetBytes: 0,
2881
+ resourceCount: 0,
2882
+ passCount: 0,
2883
+ encodeCount: 0,
2884
+ bindGroupCount: 0,
2885
+ uploadCount: 0,
2886
+ residentChildBytes: 0,
2887
+ generation: 0,
2888
+ state: 'off',
2889
+ }
2890
+ : renderSystem.bloom;
2891
+ const surfaceStorage = pipelineState?.format ?? 'rgba8unorm';
2892
+ const surfaceDisplay = pipelineState?.colorAttachmentFormat ?? 'rgba8unorm';
2893
+ const observationId = `${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}:frame-${frameId}`;
2894
+ const outputInspection = projectRendererOutputInspection({
2895
+ graph,
2896
+ surfaceAvailable: !surfaceReleased && pipelineState !== null,
2897
+ surfaceStorage,
2898
+ surfaceDisplay,
2899
+ ...(pipelineState?.surfaceProfile === undefined
2900
+ ? {}
2901
+ : { surfaceProfile: pipelineState.surfaceProfile }),
2902
+ rgba16floatRenderable: internals.device.caps.rgba16floatRenderable,
2903
+ ...(internals.context?.presentationProof === undefined
2904
+ ? {}
2905
+ : { presentationProof: internals.context.presentationProof }),
2906
+ });
2907
+ const volumetricFog = renderSystem.volumetricFog;
2908
+ const directionalShadow = renderSystem.directionalShadow;
2909
+ const recoveryEvidence = renderSystem.recoveryEvidence;
2910
+ return Object.freeze({
2911
+ state,
2912
+ recovery: recoveryInspection,
2913
+ surface: surfaceReleased ? 'released' : 'available',
2914
+ profile: activeProfile,
2915
+ capabilities: Object.freeze({ ...internals.device.caps }),
2916
+ frame: Object.freeze({
2917
+ frameId,
2918
+ deviceGeneration: activeDeviceScope.generation,
2919
+ }),
2920
+ features: Object.freeze(
2921
+ (internals.featureHost?.features ?? []).map((feature) => feature.identity),
2922
+ ),
2923
+ featureDiagnostics: internals.featureHost?.diagnostics() ?? Object.freeze([]),
2924
+ frustumStats: Object.freeze({ ...renderSystem.frustumStats }),
2925
+ visibilityStats: Object.freeze({ ...renderSystem.visibilityStats }),
2926
+ instanceCollections: Object.freeze(
2927
+ renderSystem.instanceCollectionsInspection.map((collection) =>
2928
+ Object.freeze({
2929
+ ...collection,
2930
+ uploadRanges: Object.freeze(
2931
+ collection.uploadRanges.map((range) => Object.freeze({ ...range })),
2932
+ ),
2933
+ ...(collection.error === undefined
2934
+ ? {}
2935
+ : {
2936
+ error: Object.freeze({
2937
+ ...collection.error,
2938
+ detail: Object.freeze({ ...collection.error.detail }),
2939
+ }),
2940
+ }),
2941
+ }),
2942
+ ),
2943
+ ),
2944
+ renderScene: renderSystem.renderScene,
2945
+ reflectionProbes: renderSystem.reflectionProbes,
2946
+ ssrDependencies: renderSystem.ssrDependencies,
2947
+ volumetricFog,
2948
+ environment: renderSystem.environment,
2949
+ temporal: renderSystem.temporal,
2950
+ bloom,
2951
+ extendedLighting: projectExtendedLightingInspection(extendedLightingState),
2952
+ directionalShadow,
2953
+ ...(renderSystem.temporalTargetInspection === undefined
2954
+ ? {}
2955
+ : { temporalTarget: Object.freeze(renderSystem.temporalTargetInspection) }),
2956
+ ...(renderSystem.motionBlurInspection === undefined
2957
+ ? {}
2958
+ : { motionBlur: Object.freeze(renderSystem.motionBlurInspection) }),
2959
+ ...(renderSystem.lodOcclusionInspection === undefined
2960
+ ? {}
2961
+ : { lodOcclusion: Object.freeze(renderSystem.lodOcclusionInspection) }),
2962
+ ...(renderSystem.transmission === undefined
2963
+ ? {}
2964
+ : { transmission: renderSystem.transmission }),
2965
+ ...(standardLighting === undefined
2966
+ ? {}
2967
+ : { standardLighting: Object.freeze({ ...standardLighting }) }),
2968
+ ...(renderSystem.pointShadowInspection === undefined
2969
+ ? {}
2970
+ : { pointShadow: Object.freeze({ ...renderSystem.pointShadowInspection }) }),
2971
+ meshMaterialBindings: Object.freeze(
2972
+ renderSystem.meshMaterialBindings.map((observation) =>
2973
+ Object.freeze({
2974
+ ...observation,
2975
+ bindings: Object.freeze(
2976
+ observation.bindings.map((binding) => Object.freeze({ ...binding })),
2977
+ ),
2978
+ diagnostics: Object.freeze(
2979
+ observation.diagnostics.map((diagnostic) => Object.freeze({ ...diagnostic })),
2980
+ ),
2981
+ }),
2982
+ ),
2983
+ ),
2984
+ perFramePassNames: Object.freeze(graph?.passes.map((pass) => pass.name) ?? []),
2985
+ bindGroupCounts: Object.freeze({
2986
+ createBindGroup: renderSystem.bindGroupCounts.createBindGroup,
2987
+ keys: Object.freeze([...renderSystem.bindGroupCounts.keys]),
2988
+ }),
2989
+ recoveryEvidence: Object.freeze({
2990
+ ...recoveryEvidence,
2991
+ graph: deviceLost
2992
+ ? { ready: false, generation: 0, passCount: 0, resourceCount: 0 }
2993
+ : recoveryEvidence.graph,
2994
+ producerRoots: Object.freeze([...recoveryEvidence.producerRoots]),
2995
+ receipts: Object.freeze({
2996
+ count: frameId,
2997
+ lastGeneration: latestReceipt?.deviceGeneration,
2998
+ }),
2999
+ }),
3000
+ ...outputInspection,
3001
+ observation: Object.freeze({
3002
+ observationId,
3003
+ frameId,
3004
+ ...(renderSystem.lastSuccessfulCameraAntialias === undefined
3005
+ ? {}
3006
+ : { antialias: renderSystem.lastSuccessfulCameraAntialias }),
3007
+ ...(outputInspection.surfaceProfile === undefined
3008
+ ? {}
3009
+ : { surfaceProfile: outputInspection.surfaceProfile }),
3010
+ rgba16floatRenderable: internals.device.caps.rgba16floatRenderable,
3011
+ passNames: Object.freeze([...outputInspection.graphPassNames]),
3012
+ ...(outputInspection.standardOutputColor === undefined
3013
+ ? {}
3014
+ : { standardOutputColor: Object.freeze({ ...outputInspection.standardOutputColor }) }),
3015
+ }),
3016
+ });
3017
+ },
3018
+ get device(): RhiDevice {
3019
+ return internals.device;
3020
+ },
3021
+ // Keep engine-owned shader consumers on the exact backend pack selected
3022
+ // for this renderer. Importing a backend adapter again from a
3023
+ // feature glue module can produce a second bundled module instance whose
3024
+ // RAW_DEVICE_MAP does not contain this opaque RhiDevice handle.
3025
+ _internal_createShaderModule:
3026
+ internals.pack.createShaderModule ??
3027
+ ((device, desc) => invokeDeviceCreateShaderModule(device, desc)),
3028
+ _internal_setRenderOverlay(overlay) {
3029
+ if (overlay === undefined) {
3030
+ delete internals.debugOverlay;
3031
+ } else {
3032
+ internals.debugOverlay = overlay;
3033
+ }
3034
+ },
3035
+ assetRegistry: assets,
3036
+ initialization: ready,
3037
+ attachScene(world: World): Result<void, RhiError> {
3038
+ if (disposed) {
3039
+ return err(
3040
+ new RhiError({
3041
+ code: 'rhi-not-available',
3042
+ expected: 'renderer not disposed before attaching a World',
3043
+ hint: 'rebuild the renderer before attaching another World',
3044
+ }),
3045
+ );
3046
+ }
3047
+ if (attachedWorlds.has(world)) return ok(undefined);
3048
+ try {
3049
+ const releaseTransforms = registerPropagateTransforms(world);
3050
+ const derivedSystemName = `renderDerived:${++nextDerivedSystemId}`;
3051
+ const derived = world.addSystem(Update, {
3052
+ name: derivedSystemName,
3053
+ queries: [],
3054
+ fn: (world) => {
3055
+ tilemapChunkExtractSystem(world, (guid) => assets.lookup(guid));
3056
+ const glyphResult = glyphTextLayoutSystem(world, gpuStore);
3057
+ if (!glyphResult.ok) throw glyphResult.error;
3058
+ },
3059
+ });
3060
+ if (!derived.ok) {
3061
+ releaseTransforms();
3062
+ throw new Error(`failed to register ${derivedSystemName}: ${derived.error.code}`);
3063
+ }
3064
+ transformReleases.set(world, releaseTransforms);
3065
+ derivedSystemNames.set(world, derivedSystemName);
3066
+ attachedWorlds.add(world);
3067
+ return ok(undefined);
3068
+ } catch (cause) {
3069
+ const error = new RhiError({
3070
+ code: 'webgpu-runtime-error',
3071
+ expected: 'renderer.attach(world) installs derived-state systems once',
3072
+ hint: 'inspect the World schedule registration or derived-state system failure',
3073
+ detail: {
3074
+ error: {
3075
+ code: 'unknown',
3076
+ message: cause instanceof Error ? cause.message : String(cause),
3077
+ ...(cause instanceof Error ? { name: cause.name } : {}),
3078
+ },
3079
+ },
3080
+ });
3081
+ internals.errorRegistry.fire(error);
3082
+ return err(error);
3083
+ }
3084
+ },
3085
+ detachScene(world: World): void {
3086
+ if (!attachedWorlds.delete(world)) return;
3087
+ for (const [lease, leaseWorld] of attachedLeases) {
3088
+ if (leaseWorld === world) {
3089
+ lease.dispose();
3090
+ attachedLeases.delete(lease);
3091
+ leasesByWorld.delete(world);
3092
+ }
3093
+ }
3094
+ renderSystem.detachScene(world);
3095
+ const derivedSystemName = derivedSystemNames.get(world);
3096
+ if (derivedSystemName !== undefined) {
3097
+ world.removeSystem(Update, derivedSystemName);
3098
+ derivedSystemNames.delete(world);
3099
+ }
3100
+ transformReleases.get(world)?.();
3101
+ transformReleases.delete(world);
3102
+ },
3103
+ observeCurrentFrame(options: FrameObservationOptions) {
3104
+ return renderSystem.observeCurrentFrame(options);
3105
+ },
3106
+ getCurrentGraphTarget(name: string) {
3107
+ return renderSystem.getCurrentGraphTarget(name);
3108
+ },
3109
+ requestGraphTargetCapture(request) {
3110
+ renderSystem.requestGraphTargetCapture(request);
3111
+ },
3112
+ get frustumStats() {
3113
+ return renderSystem.frustumStats;
3114
+ },
3115
+ get visibilityStats() {
3116
+ return renderSystem.visibilityStats;
3117
+ },
3118
+ get renderScene() {
3119
+ return renderSystem.renderScene;
3120
+ },
3121
+ get meshMaterialBindings() {
3122
+ return renderSystem.meshMaterialBindings;
3123
+ },
3124
+ get perFramePassNames() {
3125
+ return renderSystem.perFramePassNames;
3126
+ },
3127
+ get bindGroupCounts() {
3128
+ return renderSystem.bindGroupCounts;
3129
+ },
3130
+ configureStandard(config) {
3131
+ renderSystem.configureStandard(config);
3132
+ },
3133
+ renderFeatureDiagnostics() {
3134
+ return internals.featureHost?.diagnostics() ?? [];
3135
+ },
3136
+ async installRenderFeature(
3137
+ feature: RenderFeature<unknown>,
3138
+ ): Promise<RenderResult<void, RenderError>> {
3139
+ if (disposed) {
3140
+ return err(
3141
+ new RenderFeatureStageFailedError(feature.identity, -1, 'extract', 'registration'),
3142
+ );
3143
+ }
3144
+ const host = internals.featureHost;
3145
+ if (host === undefined) {
3146
+ return err(
3147
+ new RenderFeatureStageFailedError(feature.identity, -1, 'extract', 'registration'),
3148
+ );
3149
+ }
3150
+ const missingCapability = feature.requiredCapabilities?.find(
3151
+ (capability) => internals.device.caps[capability] !== true,
3152
+ );
3153
+ if (missingCapability !== undefined) {
3154
+ return err(
3155
+ new RenderFeatureCapabilityMissingError(feature.identity, host.size, missingCapability),
3156
+ );
3157
+ }
3158
+ let nextRequiredFullscreenPostProcesses: readonly {
3159
+ readonly identity: string;
3160
+ readonly source: string;
3161
+ }[];
3162
+ try {
3163
+ nextRequiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses([
3164
+ ...host.features,
3165
+ feature,
3166
+ ]);
3167
+ } catch (cause) {
3168
+ const conflict =
3169
+ cause instanceof RhiError
3170
+ ? cause
3171
+ : new RhiError({
3172
+ code: 'internal-error',
3173
+ expected: 'fullscreen feature declarations are internally consistent',
3174
+ hint: 'repair the conflicting fullscreen identity/source declarations',
3175
+ });
3176
+ internals.errorRegistry.fire(conflict);
3177
+ return err(
3178
+ new RenderFeatureStageFailedError(feature.identity, -1, 'prepare', 'registration'),
3179
+ );
3180
+ }
3181
+ const fullscreenPrewarm = await prewarmFullscreenFeatureModules(feature);
3182
+ if (!fullscreenPrewarm.ok) {
3183
+ return err(
3184
+ new RenderFeatureStageFailedError(feature.identity, -1, 'prepare', 'registration'),
3185
+ );
3186
+ }
3187
+ for (const materialShaderId of feature.requiredMaterialShaders ?? []) {
3188
+ const lookup = getShader().findMaterialArtifact(materialShaderId);
3189
+ if (!lookup.ok) {
3190
+ const error = new RhiError({
3191
+ code: 'shader-compile-failed',
3192
+ expected: `declared render feature material shader '${materialShaderId}' is present in the loaded manifest`,
3193
+ hint: `add material shader '${materialShaderId}' to the shader manifest or remove it from the feature declaration`,
3194
+ });
3195
+ internals.errorRegistry.fire(error);
3196
+ return err(
3197
+ new RenderFeatureStageFailedError(feature.identity, -1, 'prepare', 'registration'),
3198
+ );
3199
+ }
3200
+ const label = `module-${materialShaderId}`;
3201
+ const moduleResult = internals.pack.createShaderModule
3202
+ ? await internals.pack.createShaderModule(internals.device, {
3203
+ code: lookup.value.source,
3204
+ label,
3205
+ })
3206
+ : await invokeDeviceCreateShaderModule(internals.device, {
3207
+ code: lookup.value.source,
3208
+ label,
3209
+ });
3210
+ if (!moduleResult.ok) {
3211
+ internals.errorRegistry.fire(moduleResult.error);
3212
+ return err(
3213
+ new RenderFeatureStageFailedError(feature.identity, -1, 'prepare', 'registration'),
3214
+ );
3215
+ }
3216
+ getShaderModuleAdapter().seedModule(label, moduleResult.value);
3217
+ }
3218
+ const installed = host.install(feature);
3219
+ if (!installed.ok) return installed;
3220
+ if (installed.ok) {
3221
+ for (const materialShaderId of feature.requiredMaterialShaders ?? []) {
3222
+ requiredMaterialShaderSet.add(materialShaderId);
3223
+ }
3224
+ requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
3225
+ requiredFullscreenPostProcesses = nextRequiredFullscreenPostProcesses;
3226
+ }
3227
+ return installed;
3228
+ },
3229
+ async uninstallRenderFeature(
3230
+ feature: RenderFeature<unknown>,
3231
+ ): Promise<RenderResult<void, RenderError>> {
3232
+ const host = internals.featureHost;
3233
+ if (disposed || host === undefined) {
3234
+ return err(
3235
+ new RenderFeatureStageFailedError(feature.identity, -1, 'dispose', 'registration'),
3236
+ );
3237
+ }
3238
+ try {
3239
+ await internals.device.queue.onSubmittedWorkDone();
3240
+ } catch {
3241
+ return err(
3242
+ new RenderFeatureStageFailedError(feature.identity, -1, 'dispose', 'registration'),
3243
+ );
3244
+ }
3245
+ const removed = host.uninstall(feature);
3246
+ if (removed.ok) {
3247
+ requiredMaterialShaderSet.clear();
3248
+ for (const remaining of host.features) {
3249
+ for (const materialShaderId of remaining.requiredMaterialShaders ?? []) {
3250
+ requiredMaterialShaderSet.add(materialShaderId);
3251
+ }
3252
+ }
3253
+ requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
3254
+ requiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses(host.features);
3255
+ }
3256
+ return removed;
3257
+ },
3258
+ drawFrame(request: RenderFrameInput): RenderResult<FrameReceipt, RhiError | RenderError> {
3259
+ const result = this.draw(request);
3260
+ if (!result.ok) return result;
3261
+ return result.value === undefined
3262
+ ? err(
3263
+ new RendererContractFailureError(
3264
+ 'draw',
3265
+ 'the lease-bound draw path must return a FrameReceipt after submit',
3266
+ ),
3267
+ )
3268
+ : ok(result.value);
3269
+ },
3270
+ draw(
3271
+ worldsOrRequest: readonly World[] | RenderFrameInput,
3272
+ options?: DrawOwnerOptions,
3273
+ ): Result<void | FrameReceipt, RhiError | RenderError> {
3274
+ const isFrameRequest = !Array.isArray(worldsOrRequest);
3275
+ const frameRequest = isFrameRequest ? (worldsOrRequest as RenderFrameInput) : undefined;
3276
+ const worlds: readonly World[] =
3277
+ frameRequest !== undefined
3278
+ ? frameRequest.leases
3279
+ .map((lease) => attachedLeases.get(lease))
3280
+ .filter((world): world is World => world !== undefined)
3281
+ : (worldsOrRequest as readonly World[]);
3282
+ const readLeases: readonly RenderReadLease[] | undefined =
3283
+ frameRequest !== undefined
3284
+ ? frameRequest.leases
3285
+ : worlds.map((world) => leasesByWorld.get(world)).every((lease) => lease !== undefined)
3286
+ ? worlds.map((world) => leasesByWorld.get(world) as RenderReadLease)
3287
+ : undefined;
3288
+ if (frameRequest !== undefined && worlds.length !== frameRequest.leases.length) {
3289
+ return err(
3290
+ new RendererOperationError('world-lease-invalid', {
3291
+ operation: 'draw',
3292
+ cause: new RendererContractFailureError(
3293
+ 'draw',
3294
+ 'every RenderFrameInput lease must be attached to this Renderer',
3295
+ ),
3296
+ }),
3297
+ );
3298
+ }
3299
+ const cameraOwner =
3300
+ frameRequest === undefined ? -1 : frameRequest.leases.indexOf(frameRequest.camera.lease);
3301
+ const resourceOwner =
3302
+ frameRequest === undefined
3303
+ ? -1
3304
+ : frameRequest.leases.indexOf(frameRequest.environment.lease);
3305
+ if (frameRequest !== undefined && (cameraOwner < 0 || resourceOwner < 0)) {
3306
+ return err(
3307
+ new RendererOperationError('frame-input-invalid', {
3308
+ operation: 'draw',
3309
+ cause: new RendererContractFailureError(
3310
+ 'draw',
3311
+ cameraOwner < 0
3312
+ ? 'camera lease must be present in RenderFrameInput.leases'
3313
+ : 'environment lease must be present in RenderFrameInput.leases',
3314
+ ),
3315
+ }),
3316
+ );
3317
+ }
3318
+ const drawOptions: DrawOwnerOptions =
3319
+ frameRequest !== undefined
3320
+ ? {
3321
+ cameraOwner,
3322
+ resourceOwner,
3323
+ ...(frameRequest.profileFrame === undefined
3324
+ ? {}
3325
+ : { profileFrame: frameRequest.profileFrame }),
3326
+ }
3327
+ : (options ?? { cameraOwner: 0, resourceOwner: 0 });
3328
+ // feat-20260612-rhi-destroy-renderer-dispose-gpu-lifecycle / M5 / w21
3329
+ // (plan-strategy D-1, D-8): post-dispose the renderer is dead. AI
3330
+ // users observing `result.ok === false && err.code === 'rhi-not-
3331
+ // available'` know to rebuild the renderer (mirrors the "ready not
3332
+ // settled" + "pipelineState null" fail-fast paths below; reuses the
3333
+ // existing closed-union member, no new ErrorCode introduced).
3334
+ if (disposed) {
3335
+ const e = new RhiError({
3336
+ code: 'rhi-not-available',
3337
+ expected:
3338
+ 'renderer not disposed before calling renderer.draw(worlds, { cameraOwner, resourceOwner })',
3339
+ hint: 'renderer.dispose() flipped the lifecycle latch; rebuild via createRenderer / Engine.create',
3340
+ });
3341
+ internals.errorRegistry.fire(e);
3342
+ return err(e);
3343
+ }
3344
+ if (surfaceReleased) {
3345
+ return err(
3346
+ new RhiError({
3347
+ code: 'rhi-not-available',
3348
+ expected: 'renderer surface restored before drawing',
3349
+ hint: 'call renderer.restoreSurface() after the temporary surface owner stops',
3350
+ }),
3351
+ );
3352
+ }
3353
+ // M2 / w9 (A-IN-5): device-lost guard — draw() silently returns err
3354
+ // without firing onError each frame. The device-lost channel fires once
3355
+ // through the dual-channel fan-out (:750-797); draw() does not repeat it
3356
+ // (canvas holds previous frame). Host observes health().reason ===
3357
+ // 'device-lost' and calls recover() when ready.
3358
+ if (internals.healthRegistry.getLastSnapshot().reason === 'device-lost') {
3359
+ return err(
3360
+ new RhiError({
3361
+ code: 'rhi-not-available',
3362
+ expected: 'GPUDevice is lost; recover() to rebuild and resume rendering',
3363
+ hint: 'call renderer.recover() after a host-chosen delay; camera holds previous frame',
3364
+ }),
3365
+ );
3366
+ }
3367
+ // D-S4: ready not settled => fire onError + skip frame. Uses
3368
+ // 'rhi-not-available' (closed union placeholder semantics; charter
3369
+ // proposition 4 explicit failure - AI users observe through onError
3370
+ // and decide whether to retry).
3371
+ if (!readySettled) {
3372
+ const e = new RhiError({
3373
+ code: 'rhi-not-available',
3374
+ expected:
3375
+ 'await renderer.initialization before calling renderer.draw(worlds, { cameraOwner, resourceOwner })',
3376
+ hint: 'await renderer.initialization resolves once the manifest / pipeline / asset upload chain completes',
3377
+ });
3378
+ internals.errorRegistry.fire(e);
3379
+ return err(e);
3380
+ }
3381
+ // pipeline build rejected: ready Promise has already surfaced the
3382
+ // structured error to AI users through `await renderer.initialization`. Skip
3383
+ // to keep draw(world) idempotent; a transient retry next frame is
3384
+ // the responsibility of the AI user (charter proposition 9).
3385
+ if (pipelineState === null) {
3386
+ const e = new RhiError({
3387
+ code: 'rhi-not-available',
3388
+ expected: 'pipelineState built during Renderer.initialization',
3389
+ hint: 'await renderer.initialization resolved successfully; rebuild renderer or fix the upstream RhiError',
3390
+ });
3391
+ internals.errorRegistry.fire(e);
3392
+ return err(e);
3393
+ }
3394
+ // feat-20260708-composited-multi-world-rendering M3 / D-5: draw-args
3395
+ // entry validation runs before each extract or context configuration.
3396
+ // Empty worlds / owner out of range returns a structured Result.err
3397
+ // (never silent, charter P3) without touching GPU state. The two codes
3398
+ // are non-exclusive: an empty array short-circuits to empty-worlds. The
3399
+ // check is defensive against JS callers passing a non-array despite the
3400
+ // compile-time World[] type (red-window migration safety).
3401
+ const worldCount = Array.isArray(worlds) ? worlds.length : 0;
3402
+ if (worldCount === 0 && internals.device.caps.backendKind === 'null') {
3403
+ return ok(undefined);
3404
+ }
3405
+ // Validate both owner indices (cameraOwner before resourceOwner, first
3406
+ // offender wins). The empty object only protects the JS boundary; it is
3407
+ // rejected by the same validation and never becomes another draw shape.
3408
+ const drawOwners = drawOptions;
3409
+ const argsCheck = validateDrawArgs(worldCount, drawOwners);
3410
+ if (!argsCheck.ok) {
3411
+ internals.errorRegistry.fire(argsCheck.error);
3412
+ return argsCheck;
3413
+ }
3414
+ // Configure context lazily on first draw (D-S1 single-point
3415
+ // exemption): GPUCanvasContext.configure({device}) needs a raw
3416
+ // The canvas context is configured through the RHI device scope.
3417
+ const contextConfigured = ensureContextConfigured(
3418
+ internals,
3419
+ pipelineState,
3420
+ internals.errorRegistry,
3421
+ );
3422
+ if (!contextConfigured.ok) return contextConfigured;
3423
+ renderTargetHost.beginFrame();
3424
+ // w24 — facade-level try/catch produces Result.err on unexpected throw
3425
+ // (D-P6 dual-channel preserved: per-stage RhiError continues to fan out
3426
+ // through onError separately; the facade Result is the synchronous
3427
+ // summary AI users can ignore or branch on).
3428
+ try {
3429
+ const timingHost = internals as WebGPURendererInternals & {
3430
+ gpuPassTimingFrameIdentity?:
3431
+ | {
3432
+ readonly frameId: number;
3433
+ readonly deviceGeneration: number;
3434
+ readonly graphGeneration: number;
3435
+ }
3436
+ | undefined;
3437
+ gpuPassTimingCapture?: GpuPassTimingCapture | undefined;
3438
+ gpuPassTimingSubmittedWork?: Promise<void> | undefined;
3439
+ gpuPassTimingBeginReason?: GpuPassTimingReason | undefined;
3440
+ };
3441
+ const timingRequested =
3442
+ isFrameRequest &&
3443
+ (frameRequest?.profileFrame !== undefined ||
3444
+ internals.options?.captureGpuTimings === true);
3445
+ let volumeTimingCapture: GpuTimingCapture | undefined;
3446
+ const volumeTimingUnavailableReason = timingRequested
3447
+ ? 'timestamp capture is unavailable on this device'
3448
+ : 'timestamp capture was not requested for this frame';
3449
+ if (timingRequested && gpuPassTimingOptions === undefined) {
3450
+ const createdTiming = GpuTimingCapture.create(internals.device);
3451
+ if (createdTiming.ok) volumeTimingCapture = createdTiming.value;
3452
+ }
3453
+ if (isFrameRequest) {
3454
+ timingHost.gpuPassTimingFrameIdentity = {
3455
+ frameId: frameId + 1,
3456
+ deviceGeneration: activeDeviceScope.generation,
3457
+ graphGeneration: 0,
3458
+ };
3459
+ }
3460
+ const submitted = renderSystem.draw(worlds, drawOptions, readLeases, volumeTimingCapture);
3461
+ timingHost.gpuPassTimingFrameIdentity = undefined;
3462
+ if (!submitted) {
3463
+ volumeTimingCapture?.discard();
3464
+ return err(
3465
+ new RendererContractFailureError(
3466
+ 'draw',
3467
+ 'the Standard render owner did not submit a command buffer; inspect Renderer error events and recover the owning frame path',
3468
+ ),
3469
+ );
3470
+ }
3471
+ const timingCompletion = timingHost.gpuPassTimingSubmittedWork;
3472
+ timingHost.gpuPassTimingSubmittedWork = undefined;
3473
+ const reflectionFallbackCompletion = renderSystem.reflectionFallbackCompletion;
3474
+ internals.pack.instrumentation?.onFrameBoundary?.();
3475
+ if (!isFrameRequest) {
3476
+ renderTargetHost.onFrameSubmitted();
3477
+ return ok(undefined);
3478
+ }
3479
+ const receiptFrameId = ++frameId;
3480
+ const receiptGeneration = activeDeviceScope.generation;
3481
+ const queueCompletion = timingCompletion ?? internals.device.queue.onSubmittedWorkDone();
3482
+ const completion: FrameReceipt['completed'] = Promise.all([
3483
+ queueCompletion,
3484
+ reflectionFallbackCompletion ?? Promise.resolve(),
3485
+ ])
3486
+ .then(() => ok(undefined))
3487
+ .catch((cause: unknown) =>
3488
+ err(
3489
+ new RendererOperationError('device-operation-failed', {
3490
+ operation: 'complete-frame',
3491
+ frameId: receiptFrameId,
3492
+ deviceGeneration: receiptGeneration,
3493
+ cause: structuredRendererCause(cause, 'complete-frame'),
3494
+ }),
3495
+ ),
3496
+ );
3497
+ const continuation = createContinuationTerminator();
3498
+ frameContinuations.add(continuation);
3499
+ const guardedCompletion = completion.then(
3500
+ () =>
3501
+ continuation.guard('queue-completion')
3502
+ ? ok(undefined)
3503
+ : err(
3504
+ new RendererOperationError('device-operation-failed', {
3505
+ operation: 'complete-frame',
3506
+ frameId: receiptFrameId,
3507
+ deviceGeneration: receiptGeneration,
3508
+ cause: structuredRendererCause(
3509
+ {
3510
+ code: 'stale-generation',
3511
+ expected: 'frame completion belongs to its device generation',
3512
+ hint: 'discard the stale receipt',
3513
+ },
3514
+ 'complete-frame',
3515
+ ),
3516
+ }),
3517
+ ),
3518
+ (cause: unknown) =>
3519
+ err(
3520
+ new RendererOperationError('device-operation-failed', {
3521
+ operation: 'complete-frame',
3522
+ frameId: receiptFrameId,
3523
+ deviceGeneration: receiptGeneration,
3524
+ cause: structuredRendererCause(cause, 'complete-frame'),
3525
+ }),
3526
+ ),
3527
+ );
3528
+ const terminated = continuation.promise().then((reason) =>
3529
+ err(
3530
+ new RendererOperationError('device-operation-failed', {
3531
+ operation: 'complete-frame',
3532
+ frameId: receiptFrameId,
3533
+ deviceGeneration: receiptGeneration,
3534
+ cause: structuredRendererCause(
3535
+ {
3536
+ code: reason.code,
3537
+ expected: 'a frame continuation completes before device loss or disposal',
3538
+ hint: 'discard the stale receipt and inspect the current renderer generation',
3539
+ detail: reason,
3540
+ },
3541
+ 'complete-frame',
3542
+ ),
3543
+ }),
3544
+ ),
3545
+ );
3546
+ const completed: FrameReceipt['completed'] = Promise.race([
3547
+ guardedCompletion,
3548
+ terminated,
3549
+ ]).finally(() => {
3550
+ frameContinuations.delete(continuation);
3551
+ });
3552
+ const receipt = Object.freeze({
3553
+ frameId: receiptFrameId,
3554
+ deviceGeneration: receiptGeneration,
3555
+ completed,
3556
+ });
3557
+ renderTargetHost.onFrameSubmitted(completed);
3558
+ latestReceipt = receipt;
3559
+ const passTimingCapture = timingHost.gpuPassTimingCapture;
3560
+ const timingBeginReason = timingHost.gpuPassTimingBeginReason;
3561
+ const timingSource =
3562
+ passTimingCapture === undefined
3563
+ ? gpuPassTimingUnavailable === undefined && timingBeginReason === undefined
3564
+ ? undefined
3565
+ : async (): Promise<GpuPassTimingObservation> =>
3566
+ gpuPassTimingUnavailable ??
3567
+ failedGpuPassTimingObservation(timingBeginReason as GpuPassTimingReason)
3568
+ : async (): Promise<GpuPassTimingObservation> => {
3569
+ const observed = await passTimingCapture.observe();
3570
+ if (!observed.ok) return failedGpuPassTimingObservation(observed.error);
3571
+ const frame = observed.value;
3572
+ const unmeasured = frame.passes.find((pass) => pass.status === 'unmeasured');
3573
+ if (unmeasured !== undefined) {
3574
+ return { status: 'partial', frame, reason: unmeasured.reason };
3575
+ }
3576
+ if (frame.droppedPassCount > 0) {
3577
+ return {
3578
+ status: 'partial',
3579
+ frame,
3580
+ reason: {
3581
+ code: 'query-budget-exceeded',
3582
+ expected: 'all executed passes fit within the bounded query budget',
3583
+ hint: 'increase maxPassesPerFrame or simplify the pass graph',
3584
+ detail: { droppedPassCount: frame.droppedPassCount },
3585
+ },
3586
+ };
3587
+ }
3588
+ return { status: 'complete', frame };
3589
+ };
3590
+ timingObservationStore?.register(receipt, timingSource);
3591
+ currentReceipts.add(receipt);
3592
+ if (timingRequested && gpuPassTimingOptions === undefined) {
3593
+ receiptTimings.set(
3594
+ receipt,
3595
+ volumeTimingCapture?.observation() ??
3596
+ Promise.resolve({
3597
+ status: 'unavailable',
3598
+ reason: volumeTimingUnavailableReason,
3599
+ }),
3600
+ );
3601
+ }
3602
+ issuedReceipts.add(receipt);
3603
+ return ok(receipt);
3604
+ } catch (cause) {
3605
+ const error =
3606
+ cause instanceof Error
3607
+ ? { code: 'unknown' as const, message: cause.message, name: cause.name }
3608
+ : { code: 'unknown' as const, message: String(cause) };
3609
+ const e = new RhiError({
3610
+ code: 'webgpu-runtime-error',
3611
+ expected:
3612
+ 'renderSystem.draw(worlds, { cameraOwner, resourceOwner }) completes without throwing',
3613
+ hint: `RenderSystem internal error: ${error.message}`,
3614
+ detail: { error },
3615
+ });
3616
+ internals.errorRegistry.fire(e);
3617
+ return err(e);
3618
+ }
3619
+ },
3620
+ async observe(
3621
+ receipt: FrameReceipt,
3622
+ request: FrameObservationRequest,
3623
+ ): Promise<RenderResult<FrameReceiptObservation, RenderError>> {
3624
+ if (gpuPassTimingOptions !== undefined) {
3625
+ return timingObservationStore === undefined
3626
+ ? observeGpuPassTimingDisabled(receipt, request, () => activeDeviceScope.generation)
3627
+ : timingObservationStore.observe(receipt, request);
3628
+ }
3629
+ if (
3630
+ !currentReceipts.has(receipt) ||
3631
+ !issuedReceipts.has(receipt) ||
3632
+ receipt.deviceGeneration !== activeDeviceScope.generation
3633
+ ) {
3634
+ const currentGeneration = activeDeviceScope.generation;
3635
+ return err(
3636
+ new FrameReceiptStaleError({
3637
+ frameId: receipt.frameId,
3638
+ receiptGeneration: receipt.deviceGeneration,
3639
+ currentGeneration,
3640
+ }),
3641
+ );
3642
+ }
3643
+ const completed = await receipt.completed;
3644
+ if (!completed.ok) return completed;
3645
+ const currentGeneration = activeDeviceScope.generation;
3646
+ if (receipt.deviceGeneration !== currentGeneration) {
3647
+ return err(
3648
+ new FrameReceiptStaleError({
3649
+ frameId: receipt.frameId,
3650
+ receiptGeneration: receipt.deviceGeneration,
3651
+ currentGeneration,
3652
+ }),
3653
+ );
3654
+ }
3655
+ await renderSystem.observeLodOcclusion();
3656
+ const targetReadbacks =
3657
+ request.targetReadbacks === undefined || request.targetReadbacks.length === 0
3658
+ ? undefined
3659
+ : await renderTargetHost.observeTargetReadbacks(receipt, request.targetReadbacks);
3660
+ if (targetReadbacks !== undefined && !targetReadbacks.ok) return targetReadbacks;
3661
+ const volumeTimings = request.include.includes('timings')
3662
+ ? await (receiptTimings.get(receipt) ??
3663
+ Promise.resolve<VolumeTimingObservation>({
3664
+ status: 'unavailable',
3665
+ reason: 'timestamp capture was not requested for this frame',
3666
+ }))
3667
+ : undefined;
3668
+ return ok(
3669
+ Object.freeze({
3670
+ frameId: receipt.frameId,
3671
+ deviceGeneration: receipt.deviceGeneration,
3672
+ include: Object.freeze([...request.include]),
3673
+ ...(targetReadbacks === undefined
3674
+ ? {}
3675
+ : { targetReadbacks: Object.freeze(targetReadbacks.value) }),
3676
+ ...(volumeTimings === undefined ? {} : { volumeTimings }),
3677
+ }),
3678
+ );
3679
+ },
3680
+ releaseSurface(): Result<void, RhiError> {
3681
+ if (surfaceReleased) return ok(undefined);
3682
+ if (disposed) {
3683
+ return err(
3684
+ new RhiError({
3685
+ code: 'rhi-not-available',
3686
+ expected: 'live renderer before releasing its surface',
3687
+ hint: 'create a new Renderer; disposed renderers are terminal',
3688
+ }),
3689
+ );
3690
+ }
3691
+ try {
3692
+ internals.context.unconfigure();
3693
+ if (pipelineState !== null) pipelineState.perPassResources.configured = false;
3694
+ surfaceReleased = true;
3695
+ return ok(undefined);
3696
+ } catch (cause) {
3697
+ return err(wrapDisposeError(cause, 'context.unconfigure'));
3698
+ }
3699
+ },
3700
+ restoreSurface(): Result<void, RhiError> {
3701
+ if (!surfaceReleased) return ok(undefined);
3702
+ if (disposed) {
3703
+ return err(
3704
+ new RhiError({
3705
+ code: 'rhi-not-available',
3706
+ expected: 'live renderer before restoring its surface',
3707
+ hint: 'create a new Renderer; disposed renderers are terminal',
3708
+ }),
3709
+ );
3710
+ }
3711
+ surfaceReleased = false;
3712
+ return ok(undefined);
3713
+ },
3714
+ /**
3715
+ * Release every GPU resource the renderer owns + detach the listener
3716
+ * registries; flip the `disposed` latch so subsequent `draw(world)`
3717
+ * calls fail-fast with `'rhi-not-available'`.
3718
+ *
3719
+ * feat-20260612-rhi-destroy-renderer-dispose-gpu-lifecycle / M5 / w21
3720
+ * 6-step cascade (plan-strategy D-2 ordering):
3721
+ * 1. `context.unconfigure()` -- release the current surface image
3722
+ * 2. `gpuStore.destroyAll()` -- texture / cubemap / mesh maps
3723
+ * 3. `renderSystem.disposeFrameState()` -- graph.drain() + instanceBuffers
3724
+ * 4. `featureHost.dispose()` -- feature resources + lifecycle
3725
+ * 5. DeviceScope retirement -- generation-keyed IBL state becomes stale
3726
+ * 6. `lostRegistry.clear() / errorRegistry.clear()`
3727
+ *
3728
+ * Each step runs inside its own try/catch (D-3 method A): a sub-step
3729
+ * failure DOES NOT halt the cascade; the structured RhiError (or wrapped
3730
+ * runtime exception) fans out through `errorRegistry.fire` so AI users
3731
+ * observing Renderer error events see every dispose-time fault. The
3732
+ * `disposed` latch flips up-front so a re-entrant dispose (or a draw
3733
+ * that races with the cascade) short-circuits.
3734
+ *
3735
+ * NOT calling `device.destroy()` on the raw GPUDevice (w25 lesson from
3736
+ * feat-20260517-vite-plugin-image-build-time-cook CI-fix v4): w23
3737
+ * attempted to evict stale devices from the chromium WebGPU adapter
3738
+ * pool via `_internal_getRawDevice`, but the explicit destroy
3739
+ * accelerated pool poisoning -- after the first `dispose()` the
3740
+ * chromium adapter pool started recycling the just-destroyed device
3741
+ * into the next `requestDevice` so the NEXT test's `Engine.create`
3742
+ * resolved with `ready.error.code: 'device-lost'`. Cross-test isolation
3743
+ * moved to the vitest infra layer (`browser.isolate: true`) instead of
3744
+ * test code self-managing device lifecycle.
3745
+ *
3746
+ * Stays in sync with the `Renderer.dispose` row of the README "API index".
3747
+ */
3748
+ dispose(): RenderResult<void, RenderError> {
3749
+ if (disposed) return ok(undefined);
3750
+ disposed = true;
3751
+ for (const continuation of frameContinuations) {
3752
+ continuation.terminate({ code: 'disposed' });
3753
+ }
3754
+ currentReceipts.clear();
3755
+ const cleanupFailures: RendererOperationCause[] = [];
3756
+ for (const world of attachedWorlds) {
3757
+ try {
3758
+ const derivedSystemName = derivedSystemNames.get(world);
3759
+ if (derivedSystemName !== undefined) {
3760
+ world.removeSystem(Update, derivedSystemName);
3761
+ derivedSystemNames.delete(world);
3762
+ }
3763
+ transformReleases.get(world)?.();
3764
+ transformReleases.delete(world);
3765
+ } catch (cause) {
3766
+ const error = wrapDisposeError(cause, 'world.removeSystem(renderDerived)');
3767
+ cleanupFailures.push(error);
3768
+ internals.errorRegistry.fire(error);
3769
+ }
3770
+ }
3771
+ attachedWorlds.clear();
3772
+ for (const lease of attachedLeases.keys()) {
3773
+ try {
3774
+ lease.dispose();
3775
+ } catch (cause) {
3776
+ const error = wrapDisposeError(cause, 'read-lease.dispose');
3777
+ cleanupFailures.push(error);
3778
+ internals.errorRegistry.fire(error);
3779
+ }
3780
+ }
3781
+ attachedLeases.clear();
3782
+ leasesByWorld.clear();
3783
+ // Release the current swap-chain image before destroying a resource
3784
+ // wrappers that may share its underlying device allocation. The wgpu
3785
+ // WebGL2 surface owns an explicit SurfaceTexture; leaving this until
3786
+ // after the resource sweep lets wasm drop it against a dead Surface.
3787
+ if (!surfaceReleased) {
3788
+ try {
3789
+ internals.context.unconfigure();
3790
+ } catch (cause) {
3791
+ const error = wrapDisposeError(cause, 'context.unconfigure');
3792
+ cleanupFailures.push(error);
3793
+ internals.errorRegistry.fire(error);
3794
+ }
3795
+ }
3796
+ try {
3797
+ renderTargetHost.dispose();
3798
+ } catch (cause) {
3799
+ const error = wrapDisposeError(cause, 'renderTargetHost.dispose');
3800
+ cleanupFailures.push(error);
3801
+ internals.errorRegistry.fire(error);
3802
+ }
3803
+ // Step 2: release every Buffer / Texture handle owned by the runtime
3804
+ // GPU residency layer (feat-20260601-device/gpu-residency-extraction).
3805
+ try {
3806
+ gpuStore.destroyAll();
3807
+ } catch (cause) {
3808
+ const error = wrapDisposeError(cause, 'gpuStore.destroyAll');
3809
+ cleanupFailures.push(error);
3810
+ internals.errorRegistry.fire(error);
3811
+ }
3812
+ // Step 3: drain the per-frame render-graph pool + the per-entity
3813
+ // instanceBuffers GPU storage cache. Both walks live on the
3814
+ // RenderSystem closure (frameState is closure-private).
3815
+ try {
3816
+ renderSystem.disposeFrameState();
3817
+ } catch (cause) {
3818
+ const error = wrapDisposeError(cause, 'renderSystem.disposeFrameState');
3819
+ cleanupFailures.push(error);
3820
+ internals.errorRegistry.fire(error);
3821
+ }
3822
+ try {
3823
+ renderSystem.releaseProfilerCatalog();
3824
+ } catch (cause) {
3825
+ const error = wrapDisposeError(cause, 'renderSystem.releaseProfilerCatalog');
3826
+ cleanupFailures.push(error);
3827
+ internals.errorRegistry.fire(error);
3828
+ }
3829
+ // Step 4: release feature-owned resources and invoke feature disposal
3830
+ // hooks after render-graph state has been drained. The host is already
3831
+ // idempotent, and its structured cleanup detail is preserved by the
3832
+ // error registry when a feature cleanup fails.
3833
+ try {
3834
+ const featureDispose = internals.featureHost?.dispose();
3835
+ if (featureDispose !== undefined && !featureDispose.ok) {
3836
+ cleanupFailures.push(featureDispose.error);
3837
+ internals.errorRegistry.fire(featureDispose.error);
3838
+ }
3839
+ } catch (cause) {
3840
+ const error = wrapDisposeError(cause, 'featureHost.dispose');
3841
+ cleanupFailures.push(error);
3842
+ internals.errorRegistry.fire(error);
3843
+ }
3844
+ // Retire assembly-owned device resources only after the surface and
3845
+ // renderer-owned GPU pools have been released. The wgpu WebGL2 surface
3846
+ // still needs a live device while `unconfigure()` presents its pending
3847
+ // image; disposing the scope first lets wasm finalizers race that
3848
+ // presentation and can surface a parking_lot panic in WebKit.
3849
+ try {
3850
+ activeDeviceScope.dispose();
3851
+ } catch (cause) {
3852
+ const error = wrapDisposeError(cause, 'activeDeviceScope.dispose');
3853
+ cleanupFailures.push(error);
3854
+ internals.errorRegistry.fire(error);
3855
+ }
3856
+ // Step 5: the IBL cache is keyed by the retired DeviceScope generation.
3857
+ // It is no longer reachable through the active renderer scope, while
3858
+ // the device teardown owns the child GPU handles.
3859
+ // Step 6: detach the listener registries so a post-dispose error
3860
+ // event (race with the spec layer) does not fan out to user-supplied
3861
+ // listeners (charter P3 explicit failure: post-dispose the renderer
3862
+ // is dead, no observable side-effects). Performed last so steps 1-5
3863
+ // can still surface failures through `errorRegistry.fire`.
3864
+ try {
3865
+ internals.lostRegistry.clear();
3866
+ internals.errorRegistry.clear();
3867
+ } catch (cause) {
3868
+ cleanupFailures.push(structuredRendererCause(cause, 'listenerRegistry.clear'));
3869
+ }
3870
+ return cleanupFailures.length === 0
3871
+ ? ok(undefined)
3872
+ : err(
3873
+ new RendererOperationError('cleanup-failed', {
3874
+ operation: 'dispose',
3875
+ causes: Object.freeze(cleanupFailures),
3876
+ }),
3877
+ );
3878
+ },
3879
+ onError(listener: RendererErrorListener): () => void {
3880
+ return internals.errorRegistry.add(listener);
3881
+ },
3882
+ subscribeHostEvents(listener) {
3883
+ const offError = internals.errorRegistry.add((error) => {
3884
+ listener(Object.freeze({ kind: 'error', error }));
3885
+ });
3886
+ const offHealth = internals.healthRegistry.add((health) => {
3887
+ listener(Object.freeze({ kind: 'health', health }));
3888
+ });
3889
+ let subscribed = true;
3890
+ return () => {
3891
+ if (!subscribed) return;
3892
+ subscribed = false;
3893
+ offError();
3894
+ offHealth();
3895
+ };
3896
+ },
3897
+ onLost(listener: RendererLostListener): () => void {
3898
+ return internals.lostRegistry.add(listener);
3899
+ },
3900
+ health(): HealthSnapshot {
3901
+ return internals.healthRegistry.getLastSnapshot();
3902
+ },
3903
+ recover(): Promise<Result<void, RecoverFailure>> {
3904
+ return recoveryFlight.run();
3905
+ },
3906
+ };
3907
+ /**
3908
+ * Publish a fully prepared generation and retire the previous owner set.
3909
+ *
3910
+ * This is deliberately synchronous: all candidate state is complete before
3911
+ * entering the function, and no callback or await is allowed between the
3912
+ * active-reference swap and the old-generation retirement. The candidate
3913
+ * carries its own shader adapters, pipeline caches, stores, hooks, and
3914
+ * post-process handles; a failed build never reaches this boundary.
3915
+ */
3916
+ const publishAndRetireRendererGeneration = (
3917
+ candidate: RendererGeneration,
3918
+ candidatePostProcessResources: RecoveryPostProcessResources,
3919
+ recoveryRootBundle: RecoveryRootBundle,
3920
+ recoveryGraphCandidate: RecoveryGraphCandidate | undefined,
3921
+ recoveryColdWorkGuard: RecoveryColdWorkGuard,
3922
+ ): void => {
3923
+ if (!candidate.scope.isAlive()) {
3924
+ throw new Error('Renderer generation candidate is not publishable.');
3925
+ }
3926
+ const previous = generationPublication.current;
3927
+ const previousBindings = previous?.producerBindings;
3928
+ const previousGpuStore = previousBindings?.gpuStore ?? gpuStore;
3929
+ const previousDynamicTextureStore =
3930
+ previousBindings?.dynamicTextureStore ?? dynamicTextureStore;
3931
+ const previousScope = previous?.scope ?? activeDeviceScope;
3932
+ // This is the first line of the synchronous publication boundary. The
3933
+ // candidate was already compiled and its setup work completed; now shed
3934
+ // lost-device RenderSystem owners before installing the candidate state.
3935
+ renderSystem.resetForRecover(previous?.pipeline, candidate.device);
3936
+ // Candidate readiness is the first point where the target owner may shed
3937
+ // old physical handles. Keep its device getter pinned to the old device,
3938
+ // but advance its generation so active targets enter their rebuild state;
3939
+ // this call is inside the same synchronous publication boundary.
3940
+ renderTargetGeneration = candidate.scope.generation;
3941
+ renderTargetHost.recover();
3942
+ // GPUCanvasContext is canvas-owned rather than generation-owned: the
3943
+ // candidate and previous RHI wrappers address the same underlying
3944
+ // context. Detach the previous configuration first, then configure the
3945
+ // candidate again after that detach. Otherwise the old wrapper's
3946
+ // unconfigure() can silently revoke the candidate's configuration while
3947
+ // candidate.perPassResources.configured still says true, producing a
3948
+ // submitted-but-black recovery frame.
3949
+ const previousContext = previous?.context ?? internals.context;
3950
+ candidate.pipeline.perPassResources.configured = false;
3951
+ previousContext.unconfigure();
3952
+ const configured = ensureContextConfigured(
3953
+ internals,
3954
+ candidate.pipeline,
3955
+ internals.errorRegistry,
3956
+ candidate.context,
3957
+ candidate.device,
3958
+ );
3959
+ if (!configured.ok) throw configured.error;
3960
+ internals.device = candidate.device;
3961
+ internals.context = candidate.context;
3962
+ publishRendererGeneration(candidate);
3963
+ renderTargetDevice = candidate.device;
3964
+ // Arm the first-frame guard before any publication code can resolve a
3965
+ // fallback pipeline or residency entry. Candidate preparation populated
3966
+ // these caches already; a miss here is a hard recovery failure, not an
3967
+ // opportunity to hide cold work inside the first visible frame.
3968
+ candidate.producerBindings.gpuStore.setRecoveryColdWorkGuard(recoveryColdWorkGuard);
3969
+ internals.recoveryColdWorkGuard = recoveryColdWorkGuard;
3970
+ if (recoveryGraphCandidate !== undefined) recoveryColdWorkGuard.arm();
3971
+ renderSystem.publishRecoveryPostProcessResources(candidatePostProcessResources);
3972
+ if (recoveryGraphCandidate !== undefined) {
3973
+ renderSystem.publishRecoveryGraphCandidate(recoveryGraphCandidate);
3974
+ }
3975
+ candidate.pipeline.perPassResources.commitBloomResources?.();
3976
+ recoveryRootBundle.publish();
3977
+ recoveryGraphCandidate?.markPublished();
3978
+ // RenderSystem owns frame/feature GPU state; the generation bindings own
3979
+ // residency stores; DeviceScope owns the remaining lifecycle roots. Their
3980
+ // retirement is intentionally centralized and ordered after publication.
3981
+ previousGpuStore.destroyAll();
3982
+ previousDynamicTextureStore.destroyAll();
3983
+ previousScope.retire();
3984
+ };
3985
+ rendererRecovery = createRendererRecovery({
3986
+ isDisposed: () => disposed,
3987
+ internals,
3988
+ getActiveDeviceScope: () => activeDeviceScope,
3989
+ getActiveShaderState: () => activeShaderState,
3990
+ getMaterialShaderUvSetCounts: () => materialShaderUvSetCounts,
3991
+ createRendererPipelineCacheState,
3992
+ getCandidateBuildState: () => candidateBuildState,
3993
+ setCandidateBuildState(state) {
3994
+ candidateBuildState = state;
3995
+ },
3996
+ getCandidateShaderState: () => candidateShaderState,
3997
+ setCandidateShaderState(state) {
3998
+ candidateShaderState = state;
3999
+ },
4000
+ getCandidatePipelineCacheState: () => candidatePipelineCacheState,
4001
+ setCandidatePipelineCacheState(state) {
4002
+ candidatePipelineCacheState = state;
4003
+ },
4004
+ getCandidateMaterialShaderUvSetCounts: () => candidateMaterialShaderUvSetCounts,
4005
+ setCandidateMaterialShaderUvSetCounts(state) {
4006
+ candidateMaterialShaderUvSetCounts = state;
4007
+ },
4008
+ setCandidateEmptyPostProcessBgl(layout) {
4009
+ candidateEmptyPostProcessBgl = layout;
4010
+ },
4011
+ getShader,
4012
+ assets,
4013
+ adaptMipmapShaderModuleFactory,
4014
+ buildPipeline,
4015
+ getMaterialShaderPipeline,
4016
+ getMaterialShaderPipelineEntry,
4017
+ getCachedMaterialShaderBindingContract,
4018
+ getParamSchema,
4019
+ getMaterialBindGroupLayout,
4020
+ metrics,
4021
+ buildPostProcessPipeline,
4022
+ renderSystem,
4023
+ createRecoveryFailureLocation,
4024
+ publishAndRetireRendererGeneration,
4025
+ });
4026
+ return renderer;
4027
+ }