@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,4066 @@
1
+ // WebGPU generation-scoped ready-state builder.
2
+ // This owner performs one complete device-generation promotion; it receives
3
+ // policy/adapters and returns a fully prepared PipelineState.
4
+
5
+ import {
6
+ BuiltinAssetRegistry,
7
+ HANDLE_CUBE,
8
+ HANDLE_NINESLICE_QUAD,
9
+ HANDLE_QUAD,
10
+ HANDLE_SPHERE,
11
+ HANDLE_TRIANGLE,
12
+ } from '@forgeax/engine-assets-runtime';
13
+ import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
14
+ import type {
15
+ BindGroupLayout,
16
+ Buffer,
17
+ ComputePipeline,
18
+ PipelineLayout,
19
+ RenderPipeline,
20
+ Result,
21
+ RhiDevice,
22
+ Sampler,
23
+ ShaderModule,
24
+ } from '@forgeax/engine-rhi';
25
+ import { ok, RhiError } from '@forgeax/engine-rhi';
26
+ import { findVariantByKey, type ShaderCatalog } from '@forgeax/engine-shader';
27
+ import type { ManifestEntry } from '@forgeax/engine-types';
28
+ import { handleSlot } from '@forgeax/engine-types';
29
+
30
+ export type { MaterialShaderManifestEntry } from '@forgeax/engine-shader';
31
+
32
+ import type { GpuResidencyCache, MeshGpuHandles } from '../device/gpu-residency';
33
+ import { postProcessShaderModuleLabel } from '../fullscreen-post-process-pass';
34
+ import { GpuBuffer } from '../gpu-resource';
35
+ import { GPU_SHADER_STAGE_FRAGMENT } from '../gpu-stage';
36
+ import {
37
+ GPU_TEXTURE_USAGE_COPY_DST,
38
+ GPU_TEXTURE_USAGE_RENDER_ATTACHMENT_AND_TEXTURE_BINDING,
39
+ GPU_TEXTURE_USAGE_TEXTURE_BINDING,
40
+ } from '../gpu-texture-usage';
41
+ import {
42
+ GPU_BUFFER_USAGE_COPY_DST,
43
+ GPU_BUFFER_USAGE_INDEX,
44
+ GPU_BUFFER_USAGE_STORAGE,
45
+ GPU_BUFFER_USAGE_UNIFORM,
46
+ GPU_BUFFER_USAGE_VERTEX,
47
+ } from '../gpu-usage';
48
+ import { createHdrpClusterMembershipBindGroupLayoutDescriptor } from '../hdrp-buffers';
49
+ import { setIblComposedShaders } from '../ibl/IblPipelineCache';
50
+ import {
51
+ createSkylightFallback,
52
+ FALLBACK_BYTES_PER_ROW,
53
+ type SkylightFallback,
54
+ } from '../ibl/skylight-bind-group';
55
+ import type { BloomInspection } from '../inspection-types';
56
+ import type { DeviceScope, RhiErrorListenerRegistry } from '../lifecycle';
57
+ import {
58
+ buildPbrPipelineLayouts,
59
+ buildPbrSkinLayouts,
60
+ createHdrpSkinBindGroupLayoutDescriptor,
61
+ type PipelineGroup2Contract,
62
+ resolvePipelineGroup2Contract,
63
+ } from '../pbr-pipeline';
64
+ import { STANDARD_CLUSTER_MEMBERSHIP_WGSL } from '../pipeline/standard-pipeline';
65
+ import { inspectStandardBloomGraph } from '../pipeline/standard-post';
66
+ import {
67
+ buildLinearLdrMaterialSpecTable,
68
+ buildSpecConstTable,
69
+ cacheKeyOf,
70
+ createHdrpBindGroupLayoutDescriptor,
71
+ getOrBuildPipeline,
72
+ type PipelineCache,
73
+ type PipelineDeviceProvider,
74
+ type PipelineSpec,
75
+ PipelineSpecError,
76
+ } from '../pipeline-spec';
77
+ import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
78
+ import { deriveExtendedLightingCapability } from '../prepare/extended-lighting/resources';
79
+ import type { BloomPersistentBundle } from '../record/render-context';
80
+ import { SHADOW_CASTER_BUFFER_SIZE } from '../record/shadow-pass';
81
+ import { POINTS_LINES_VIEW_BUFFER_SIZE } from '../record/view-ubo';
82
+ import {
83
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
84
+ type VolumetricFogShaderSources,
85
+ } from '../render-contract';
86
+ import {
87
+ MATERIAL_PER_ENTITY_STRIDE,
88
+ type PipelineState,
89
+ selectSwapChainFormat,
90
+ } from '../render-system';
91
+ import {
92
+ createSkinPaletteAllocator,
93
+ type SkinPaletteAllocator,
94
+ } from '../systems/skin-palette-allocator';
95
+ import { createExtendedLightingFallbackResources } from './extended-lighting-fallback';
96
+ import { invokeDeviceCreateShaderModule } from './material-shader-policy';
97
+ import { prewarmRequiredMaterialShaders } from './material-shader-prewarm';
98
+ import {
99
+ createMeshSsboGrowController,
100
+ INITIAL_MESH_SSBO_SLOT_COUNT,
101
+ type MeshSsboGrowDevice,
102
+ type MeshSsboGrowResult,
103
+ type MeshSsboState,
104
+ requireMeshSsboBuffer,
105
+ } from './mesh-ssbo-grow';
106
+ import { runShimStep, runShimSyncStep } from './renderer-helpers';
107
+ import {
108
+ prewarmMaterialShaderVariants,
109
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
110
+ selectHdrpPbrPrewarmVariants,
111
+ selectProbePrewarmVariants,
112
+ selectStandardPbrTransmissionPrewarmVariants,
113
+ } from './shader-prewarm-policy';
114
+ import {
115
+ BLUR_PARAMS_BYTES,
116
+ BRIGHT_PARAMS_BYTES,
117
+ COMPOSITE_PARAMS_BYTES,
118
+ HDR_COLOR_ATTACHMENT_FORMAT,
119
+ MIPMAP_PREWARM_FORMATS,
120
+ VIEW_UBO_BYTES,
121
+ } from './webgpu-ready-contract';
122
+
123
+ /**
124
+ * Build the `Renderer.initialization` Promise (D-S3 three-step strict-serial chain).
125
+ *
126
+ * Steps run in order; each rejection short-circuits the chain and the
127
+ * resulting Promise rejects with a structured `RhiError` / `ShaderError`.
128
+ * AI users `await renderer.initialization` once before the first `draw(world)` call;
129
+ * subsequent frames may skip the await (the Promise stays resolved).
130
+ *
131
+ * Step 1 (manifest load): `shader.loadManifest()` populates the runtime
132
+ * registry. Failure = `ShaderError 'manifest-malformed'` /
133
+ * `'shader-not-found'`.
134
+ *
135
+ * Step 2 (pipeline compile): synthesises the PBR pipeline (3 BindGroupLayout
136
+ * + 1 PipelineLayout + 1 ShaderModule + 1 RenderPipeline). Failure =
137
+ * `RhiError 'shader-compile-failed'` / `'feature-not-enabled'` /
138
+ * `'limit-exceeded'`.
139
+ *
140
+ * Step 3 (asset upload): allocates GPU buffers for the builtin cube and
141
+ * triangle meshes via `device.createBuffer` + `queue.writeBuffer`. Failure
142
+ * = `RhiError 'limit-exceeded'` / `'webgpu-runtime-error'` /
143
+ * `'queue-write-buffer-out-of-bounds'`.
144
+ */
145
+ export async function buildReadyWebGPU(
146
+ rhiDevice: RhiDevice,
147
+ rendererScope: DeviceScope,
148
+ getShader: () => ShaderCatalog,
149
+ gpuStore: GpuResidencyCache,
150
+ asyncCreateShaderModule:
151
+ | ((
152
+ device: RhiDevice,
153
+ desc: { code: string; label?: string | undefined },
154
+ ) => Promise<Result<ShaderModule, RhiError>>)
155
+ | undefined,
156
+ errorRegistry: RhiErrorListenerRegistry,
157
+ /**
158
+ * Material shader modules declared by producer features. These are compiled
159
+ * during Renderer.initialization so prepared graphics do not fail on their first
160
+ * frame while the shared async shader adapter is still warming up.
161
+ */
162
+ requiredMaterialShaders: readonly string[],
163
+ /** Fullscreen feature modules compiled before the first synchronous frame. */
164
+ requiredFullscreenPostProcesses: readonly {
165
+ readonly identity: string;
166
+ readonly source: string;
167
+ }[],
168
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
169
+ // T-M2-05 + M3 / T-M3-04: surface for setting `internals.growMeshSsbo`
170
+ // + `internals.meshSsboState` so the record stage's
171
+ // `ensureMeshSsboCapacity` hook can reach both. The mesh-SSBO grow
172
+ // controller is owned in this function's scope, so the cleanest expose
173
+ // path is a callback that buildReadyWebGPU invokes once after
174
+ // `meshSsboController` is wired (the alternative — returning the
175
+ // function + state alongside the PipelineState — bloats every
176
+ // successful call site for two optional hooks).
177
+ setGrowMeshSsboHook: (
178
+ hook: (neededSlots: number) => MeshSsboGrowResult,
179
+ state: MeshSsboState,
180
+ ) => void,
181
+ // feat-20260609 R3-fixup: seed-shader-module hook the lazy
182
+ // MaterialShader pipeline cache adapter exposes (see
183
+ // makeShaderDeviceAdapter / ShaderDeviceAdapterInternal). Used to seed
184
+ // the shadow_caster module under
185
+ // `module-forgeax::default-shadow-caster` so the lazy PSO build hits
186
+ // OK on frame 1 (no 1-frame warmup for the engine-shipped shadow
187
+ // caster).
188
+ seedShaderModule: (label: string, module: ShaderModule) => void,
189
+ // M6 fix-up (feat-20260615-pipeline-spec-ssot): seed-pipeline hook the
190
+ // outer `makeWebGPURenderer` exposes for `materialShaderPipelineCache`.
191
+ // Invoked once per URP-variant SPEC_CONST entry after the boot-time
192
+ // prewarm completes, so the URP record path's first-frame
193
+ // `getMaterialShaderPipeline` lookup hits a live PSO instead of falling
194
+ // into a 1-frame async-compile skip-draw window. Idempotent: caller
195
+ // guards against re-seeding when a key already exists.
196
+ seedMaterialShaderPipelineCache: (
197
+ key: string,
198
+ pso: RenderPipeline,
199
+ group2Contract: PipelineGroup2Contract,
200
+ ) => void,
201
+ // feat-20260621 M-A3 (D-5): register the engine built-in tonemap onto the
202
+ // unified post-process channel once the tonemap manifest entry's composed
203
+ // WGSL is resolved. Invoked with the tonemap WGSL source string; the outer
204
+ // `makeWebGPURenderer` closure forwards it to
205
+ // the fullscreen feature host with `{ source, params }`.
206
+ // Scope bridge mirrors `setGrowMeshSsboHook` / `seedShaderModule`: the
207
+ // tonemap source resolves inside this async function (after the manifest-load
208
+ // await), by which point the synchronous `renderSystem` const is defined.
209
+ registerBuiltinTonemap: (source: string) => void,
210
+ /** Register the special FXAA pass' final-output policy params UBO. */
211
+ registerBuiltinFxaa: (source: string) => void,
212
+ registerBuiltinTemporalPostProcesses: (entries: {
213
+ readonly motionBlur?: string;
214
+ readonly taaResolve?: string;
215
+ }) => void,
216
+ /** Install manifest-owned volume utility sources only after all four modules compile. */
217
+ setVolumetricFogShaderSources: (sources: VolumetricFogShaderSources) => void,
218
+ ): Promise<PipelineState> {
219
+ const extendedLightingShaderAvailable = deriveExtendedLightingCapability(rhiDevice).admitted;
220
+ // Shader layouts and record-stage buffer usage share the RHI capability.
221
+ // Backend names and numeric limits must not independently re-enable storage.
222
+ const storageBufferCapable = rhiDevice.caps.storageBuffer;
223
+ const webgl2Downlevel = rhiDevice.caps.backendKind === 'wgpu-webgl2';
224
+ const directionalPcssAvailable =
225
+ rhiDevice.caps.backendKind === 'webgpu' || rhiDevice.caps.backendKind === 'wgpu-native';
226
+ const projectorAvailable =
227
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
228
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
229
+ // bug-20260612: choose swap-chain storage / view formats by backend.
230
+ // Channel 2 (native WebGPU) follows navigator.gpu.getPreferredCanvasFormat();
231
+ // Channel 3 (wgpu-wasm GLES, storageBufferCapable=false) hard-codes
232
+ // rgba8unorm. The pair flows into PipelineState.format /
233
+ // .colorAttachmentFormat below and through it into every downstream
234
+ // pipeline target, configure() call, and color-attachment format —
235
+ // single SSOT, no scattered branches. See selectSwapChainFormat
236
+ // (above the SWAP_CHAIN_*_FORMAT historical constants).
237
+ const surfaceViewFormats =
238
+ (rhiDevice as unknown as { readonly surfaceViewFormats?: boolean }).surfaceViewFormats ??
239
+ !webgl2Downlevel;
240
+ const swapChainFormats = selectSwapChainFormat(storageBufferCapable, surfaceViewFormats);
241
+ // The 'null' (headless RhiNull) backend has no UA preferred-canvas-format by
242
+ // design; the rgba8unorm fallback is its intended steady state, not a
243
+ // degraded one — firing 'rhi-not-available' there is territorially wrong
244
+ // (the backend IS available) and only pollutes Renderer error events in headless
245
+ // CI. Skip the diagnostic for it; Channel 2/3 still report a missing
246
+ // getPreferredCanvasFormat as before.
247
+ if (swapChainFormats.fallbackReason !== undefined && rhiDevice.caps.backendKind !== 'null') {
248
+ // Step ③ in selectSwapChainFormat fired — surface a structured
249
+ // diagnostic through the RhiError channel so AI users subscribed via
250
+ // Renderer error subscribers can detect "extremely-old UA / missing
251
+ // navigator.gpu.getPreferredCanvasFormat" and react. The renderer
252
+ // continues with the rgba8unorm fallback (charter §9 graceful
253
+ // degradation; charter P3 explicit failure — no silent fallback).
254
+ errorRegistry.fire(
255
+ new RhiError({
256
+ code: 'rhi-not-available',
257
+ expected: 'navigator.gpu.getPreferredCanvasFormat is callable on Channel 2',
258
+ hint: 'browser is too old or WebGPU implementation incomplete; falling back to rgba8unorm swap-chain format. Update the UA or use a Channel-3-compatible canvas configuration.',
259
+ }),
260
+ );
261
+ }
262
+ // ── Step 2: PBR + unlit pipeline compile ───────────────────────────────────
263
+ // bug-20260519 D-1 + D-3: gate the entire PBR / unlit shader-compile block
264
+ // behind `manifestEntries.length > 0`. The Camera-only / clear-pass-only
265
+ // path (LO 1.1 hello-window equivalent) ships an empty manifest -- no
266
+ // PBR/unlit entry to find, no shader module to compile. When the gate is
267
+ // skipped both `unlitModule` and `pbrModule` stay `null` and the later
268
+ // unlit / standard `createRenderPipeline` calls are skipped in turn so
269
+ // the returned `PipelineState.{unlitPipeline,standardPipeline}` fields
270
+ // are written `null` (D-3 nullable). The render-time access point in
271
+ // `render-system-record.ts` narrows on `=== null` and fires a structured
272
+ // `RhiError shader-compile-failed` (charter P3 explicit failure;
273
+ // AC-03). Other PipelineState fields (BindGroupLayout chain / shared
274
+ // buffers / defaultSampler / fallbackTextureView / depthTexture* /
275
+ // identityInstanceBuffer / mesh handles) keep their existing
276
+ // construction so the clear-pass path remains fully wired (D-3
277
+ // explicit scope).
278
+ const registry = getShader();
279
+ //
280
+ // feat-20260518-pbr-direct-lighting-mvp M5 / w22.9 (AC-05 + plan-strategy
281
+ // D-3 + D-4 + dual-pipeline contract w12): the manifest now ships the
282
+ // pbr.wgsl + unlit.wgsl entries written by `@forgeax/engine-vite-plugin-shader`'s
283
+ // `buildStart` hook (engine-entries eager compile via naga_oil). Identify
284
+ // them by content marker (charter P3 explicit failure: silent fallback to
285
+ // a wrong entry would produce mis-shaded pixels indistinguishable from
286
+ // success). pbr.wgsl is the only entry whose composed body contains the
287
+ // `f_schlick(` BRDF helper call; the other engine entry is unlit.
288
+ // M3 D-P4: rhi-webgpu supplies the async factory; rhi-wgpu and the
289
+ // explicit escape hatch fall back to the synchronous device entry.
290
+ const manifestEntries: ManifestEntry[] = [];
291
+ for (const entry of registry.entries()) {
292
+ manifestEntries.push(entry);
293
+ }
294
+ let pbrModule: ShaderModule | null = null;
295
+ let unlitModule: ShaderModule | null = null;
296
+ let spriteModule: ShaderModule | null = null;
297
+ let spriteLitModule: ShaderModule | null = null;
298
+ let fxaaModule: ShaderModule | null = null;
299
+ let skyboxModule: ShaderModule | null = null;
300
+ let bloomBrightModule: ShaderModule | null = null;
301
+ let bloomBlurModule: ShaderModule | null = null;
302
+ let bloomCompositeModule: ShaderModule | null = null;
303
+ let ssaoModule: ShaderModule | null = null;
304
+ const findEngineManifestEntry = (identifier: string): ManifestEntry | undefined => {
305
+ const materialEntry = Array.from(registry.materialShaderManifestEntries()).find(
306
+ (candidate) => candidate.identifier === identifier,
307
+ );
308
+ if (materialEntry === undefined) return undefined;
309
+ return (
310
+ manifestEntries.find((entry) => entry.wgsl === materialEntry.composedWgsl) ?? {
311
+ hash: `engine:${identifier}`,
312
+ wgsl: materialEntry.composedWgsl,
313
+ glsl: undefined,
314
+ bindings: '',
315
+ }
316
+ );
317
+ };
318
+ if (manifestEntries.length > 0) {
319
+ // Merge of bug-20260519 D-1 + D-3 (manifest-zero gate, this branch's
320
+ // outer `if (manifestEntries.length > 0)`) + main feat-20260519-tonemap
321
+ // T-M2.5 (engine SSOT triple — pbr + unlit + tonemap) +
322
+ // feat-20260520-directional-light-shadow-mapping M1c / w9 (shadow_caster
323
+ // as additional engine entry) + feat-20260520-2d-sprite-layer-mvp M-3 / w24
324
+ // (sprite as additional engine entry) + feat-20260520-skylight-ibl-cubemap
325
+ // M5-amend Gap A (4 IBL precompute entries).
326
+ //
327
+ // Manifest non-empty: require pbr + unlit + tonemap; shadow_caster +
328
+ // sprite + IBL entries are optional — absent ones leave their module
329
+ // null and the dependent pipeline stays null (callers fail-fast at
330
+ // dispatch if they relied on a missing entry).
331
+ //
332
+ // Marker triage (charter P3 explicit failure):
333
+ // - tonemap.wgsl: declares `struct TonemapParams`
334
+ // - sprite.wgsl: declares `pivotAndSize` Material field
335
+ // - pbr.wgsl: composes `f_schlick`
336
+ // - shadow_caster.wgsl: only position input (no normal/uv/tangent)
337
+ // - IBL entries: identified by their fragment entry-point markers
338
+ // (equirectToCube_fs / irradianceConvolve_fs / prefilterEnv_fs /
339
+ // brdfLutBake_fs) which survive naga_oil composition unchanged.
340
+ // - unlit.wgsl: none of the above markers → falls into the unlit slot.
341
+ let pbrEntry = findEngineManifestEntry('forgeax::default-standard-pbr');
342
+ let unlitEntry = findEngineManifestEntry('forgeax::default-unlit');
343
+ let tonemapEntry: ManifestEntry | undefined;
344
+ let motionBlurEntry: ManifestEntry | undefined;
345
+ let spriteEntry: ManifestEntry | undefined;
346
+ // sprite-lit identification marker is the `spriteLitShadeAccum`
347
+ // helper (defined in sprite-lit.wgsl, absent from sprite.wgsl). The
348
+ // outer shading accumulator name is stable across shading-formula
349
+ // changes and unique to sprite-lit. Identified BEFORE sprite so the
350
+ // `pivotAndSize` marker (shared between sprite + sprite-lit since
351
+ // their paramSchema mirror) does not mis-classify sprite-lit as
352
+ // sprite.
353
+ let spriteLitEntry: ManifestEntry | undefined;
354
+ let taaResolveEntry: ManifestEntry | undefined;
355
+ let iblEquirectEntry: ManifestEntry | undefined;
356
+ let iblIrradianceEntry: ManifestEntry | undefined;
357
+ let iblPrefilterEntry: ManifestEntry | undefined;
358
+ let iblBrdfLutEntry: ManifestEntry | undefined;
359
+ let fxaaEntry: ManifestEntry | undefined;
360
+ let skyboxEntry: ManifestEntry | undefined;
361
+ let bloomBrightEntry: ManifestEntry | undefined;
362
+ let bloomBlurEntry: ManifestEntry | undefined;
363
+ let bloomCompositeEntry: ManifestEntry | undefined;
364
+ let ssaoEntry: ManifestEntry | undefined;
365
+ // feat-20260609 R3-fixup: shadow_caster module pre-bake. T-009 deleted
366
+ // the hardcoded shadowCasterPipeline; the lazy
367
+ // getMaterialShaderPipeline path (passKind='shadow-caster') uses
368
+ // the shared adapter cache, which is unwarmed for shadow_caster on
369
+ // frame 1. We eagerly compile + seed the adapter cache so the lazy
370
+ // build hit on `module-forgeax::default-shadow-caster` returns OK
371
+ // without a 1-frame warmup. Resolve it by its reserved manifest identity;
372
+ // game shaders are free to use each vertex-input subset.
373
+ let shadowCasterEntry = findEngineManifestEntry('forgeax::default-shadow-caster');
374
+ for (const entry of manifestEntries) {
375
+ if (entry.wgsl.includes('TonemapParams')) {
376
+ tonemapEntry ??= entry;
377
+ continue;
378
+ }
379
+ if (entry.wgsl.includes('MotionBlurParams')) {
380
+ motionBlurEntry ??= entry;
381
+ continue;
382
+ }
383
+ if (entry.wgsl.includes('fs_taa_resolve')) {
384
+ taaResolveEntry ??= entry;
385
+ continue;
386
+ }
387
+ if (entry.wgsl.includes('spriteLitShadeAccum')) {
388
+ // sprite-lit identification — must precede the `pivotAndSize`
389
+ // marker since sprite-lit also carries that field (paramSchema
390
+ // mirror with sprite.wgsl).
391
+ spriteLitEntry ??= entry;
392
+ continue;
393
+ }
394
+ if (entry.wgsl.includes('pivotAndSize')) {
395
+ spriteEntry ??= entry;
396
+ continue;
397
+ }
398
+ if (entry.wgsl.includes('equirectToCube_fs')) {
399
+ iblEquirectEntry ??= entry;
400
+ continue;
401
+ }
402
+ if (entry.wgsl.includes('irradianceConvolve_fs')) {
403
+ iblIrradianceEntry ??= entry;
404
+ continue;
405
+ }
406
+ if (entry.wgsl.includes('prefilterEnv_fs')) {
407
+ iblPrefilterEntry ??= entry;
408
+ continue;
409
+ }
410
+ if (entry.wgsl.includes('brdfLutBake_fs')) {
411
+ iblBrdfLutEntry ??= entry;
412
+ continue;
413
+ }
414
+ if (entry.wgsl.includes('f_schlick')) {
415
+ pbrEntry ??= entry;
416
+ continue;
417
+ }
418
+ // feat-20260528-fxaa-post-processing: fxaa marker — the composed
419
+ // WGSL contains the rgb2luma helper unique to the FXAA algorithm
420
+ // (plan-strategy D-5). Identified before shadow_caster / unlit
421
+ // fallback so the marker takes priority over generic position-only
422
+ // heuristics.
423
+ if (entry.wgsl.includes('rgb2luma')) {
424
+ fxaaEntry ??= entry;
425
+ continue;
426
+ }
427
+ // feat-20260531-skybox-env-background M3 / w15: skybox marker --
428
+ // the composed WGSL contains the skybox_fs fragment entry point
429
+ // unique to skybox.wgsl (plan-strategy D-7).
430
+ if (entry.wgsl.includes('skybox_fs')) {
431
+ skyboxEntry ??= entry;
432
+ continue;
433
+ }
434
+ // feat-20260531-bloom-first-declarative-render-graph-pass / w13:
435
+ // bloom marker triage (D-7). Identify the 3 bloom WGSL modules by their
436
+ // unique uniform-struct names ('BloomBrightParams' in bloom-bright.wgsl,
437
+ // 'BloomBlurParams' in bloom-blur.wgsl shared by H/V pipelines,
438
+ // 'BloomCompositeParams' in bloom-composite.wgsl). Struct names are naga
439
+ // IR and survive naga_oil composition unchanged -- unlike the original
440
+ // `// bloomBrightExtract` content-marker COMMENTS, which naga's WGSL
441
+ // writeback drops (comments are not part of the IR), leaving the markers
442
+ // absent from the composed `entry.wgsl` so the triage never matched and
443
+ // bloom silently never initialised (bug-20260625). Same survives-naga
444
+ // rationale as the SSAO `fs_ssao_calc` entry-point marker below.
445
+ if (entry.wgsl.includes('BloomBrightParams')) {
446
+ bloomBrightEntry ??= entry;
447
+ continue;
448
+ }
449
+ if (entry.wgsl.includes('BloomBlurParams')) {
450
+ bloomBlurEntry ??= entry;
451
+ continue;
452
+ }
453
+ if (entry.wgsl.includes('BloomCompositeParams')) {
454
+ bloomCompositeEntry ??= entry;
455
+ continue;
456
+ }
457
+ // feat-20260612-hdrp-ssao M6 / w27: SSAO marker triage (D-E).
458
+ // The composed WGSL contains 'fs_ssao_calc' fragment entry point
459
+ // which survives naga_oil composition unchanged. Same pattern as
460
+ // the bloom struct-name markers above.
461
+ if (entry.wgsl.includes('fs_ssao_calc')) {
462
+ ssaoEntry ??= entry;
463
+ continue;
464
+ }
465
+ unlitEntry ??= entry;
466
+ }
467
+ // Null is structural-only; real backends require the shipped manifest.
468
+ if (
469
+ rhiDevice.caps.backendKind === 'null' &&
470
+ (pbrEntry === undefined || unlitEntry === undefined || tonemapEntry === undefined)
471
+ ) {
472
+ const fallback = (hash: string) =>
473
+ ({ hash, wgsl: '', glsl: '', bindings: '' }) as ManifestEntry;
474
+ pbrEntry ??= fallback('null-pbr');
475
+ unlitEntry ??= fallback('null-unlit');
476
+ tonemapEntry ??= fallback('null-tonemap');
477
+ }
478
+ if (pbrEntry === undefined || unlitEntry === undefined || tonemapEntry === undefined) {
479
+ throw new RhiError({
480
+ code: 'shader-compile-failed',
481
+ expected:
482
+ 'manifest entries include pbr.wgsl + unlit.wgsl + tonemap.wgsl (engine SSOT triple)',
483
+ hint: 'verify @forgeax/engine-vite-plugin-shader emits manifest.json with the 3 engine entries; check vite plugin engineEntries option',
484
+ });
485
+ }
486
+ // WebGL2 must compile the storage=false material variants against its
487
+ // uniform fallback layouts; patch the flat engine entries accordingly.
488
+ if (!storageBufferCapable) {
489
+ // Match by fields because multi-axis keys are compound and order-sensitive.
490
+ const idToVariantWgsl = new Map<string, string>();
491
+ for (const ms of registry.materialShaderManifestEntries()) {
492
+ const exact = ms.variants.find(
493
+ (v) =>
494
+ v.defines.STORAGE_BUFFER_AVAILABLE === false &&
495
+ (!('CLUSTER_FORWARD_AVAILABLE' in v.defines) ||
496
+ v.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
497
+ (!('VERTEX_COLOR_AVAILABLE' in v.defines) ||
498
+ v.defines.VERTEX_COLOR_AVAILABLE === false) &&
499
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in v.defines) ||
500
+ v.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
501
+ (!('PROJECTOR_AVAILABLE' in v.defines) ||
502
+ v.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
503
+ (!('REFLECTION_FALLBACK_AVAILABLE' in v.defines) ||
504
+ v.defines.REFLECTION_FALLBACK_AVAILABLE === false) &&
505
+ v.defines.PROBE_BLEND_AVAILABLE !== true &&
506
+ (!('SKINNING_DISABLED' in v.defines) || v.defines.SKINNING_DISABLED === true),
507
+ );
508
+ const v = exact === undefined ? undefined : findVariantByKey(ms, exact.definesKey);
509
+ if (v !== undefined) {
510
+ idToVariantWgsl.set(ms.identifier, v.composedWgsl);
511
+ }
512
+ }
513
+ const patch = (entry: ManifestEntry, identifier: string): ManifestEntry => {
514
+ const wgsl = idToVariantWgsl.get(identifier);
515
+ return wgsl !== undefined ? { ...entry, wgsl } : entry;
516
+ };
517
+ pbrEntry = patch(pbrEntry, 'forgeax::default-standard-pbr');
518
+ unlitEntry = patch(unlitEntry, 'forgeax::default-unlit');
519
+ if (shadowCasterEntry !== undefined) {
520
+ // Shadow caster also needs the storage=false uniform fallback.
521
+ shadowCasterEntry = patch(shadowCasterEntry, 'forgeax::default-shadow-caster');
522
+ }
523
+ if (spriteEntry !== undefined) {
524
+ spriteEntry = patch(spriteEntry, 'forgeax::sprite');
525
+ }
526
+ if (spriteLitEntry !== undefined) {
527
+ spriteLitEntry = patch(spriteLitEntry, 'forgeax::sprite-lit');
528
+ }
529
+ }
530
+ // Patch eager entries to the exact device/URP variant; canonical sources
531
+ // may declare optional vertex inputs absent from the boot layout.
532
+ const idToRuntimeVariantWgsl = new Map<string, string>();
533
+ for (const ms of registry.materialShaderManifestEntries()) {
534
+ const exact = ms.variants.find(
535
+ (variant) =>
536
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
537
+ (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
538
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
539
+ (!('VERTEX_COLOR_AVAILABLE' in variant.defines) ||
540
+ variant.defines.VERTEX_COLOR_AVAILABLE === false) &&
541
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
542
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
543
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
544
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
545
+ (!('EXTENDED_LIGHTING_AVAILABLE' in variant.defines) ||
546
+ variant.defines.EXTENDED_LIGHTING_AVAILABLE === extendedLightingShaderAvailable) &&
547
+ variant.defines.PROBE_BLEND_AVAILABLE !== true &&
548
+ (!('SKINNING_DISABLED' in variant.defines) ||
549
+ variant.defines.SKINNING_DISABLED === true) &&
550
+ (!('TRANSMISSION_AVAILABLE' in variant.defines) ||
551
+ variant.defines.TRANSMISSION_AVAILABLE === false) &&
552
+ (!('REFLECTION_FALLBACK_AVAILABLE' in variant.defines) ||
553
+ variant.defines.REFLECTION_FALLBACK_AVAILABLE === false),
554
+ );
555
+ const variant = exact === undefined ? undefined : findVariantByKey(ms, exact.definesKey);
556
+ if (variant !== undefined) {
557
+ idToRuntimeVariantWgsl.set(ms.identifier, variant.composedWgsl);
558
+ }
559
+ }
560
+ const patchRuntimeVariant = (entry: ManifestEntry, identifier: string): ManifestEntry => {
561
+ const wgsl = idToRuntimeVariantWgsl.get(identifier);
562
+ return wgsl !== undefined ? { ...entry, wgsl } : entry;
563
+ };
564
+ pbrEntry = patchRuntimeVariant(pbrEntry, 'forgeax::default-standard-pbr');
565
+ unlitEntry = patchRuntimeVariant(unlitEntry, 'forgeax::default-unlit');
566
+ if (shadowCasterEntry !== undefined) {
567
+ shadowCasterEntry = patchRuntimeVariant(shadowCasterEntry, 'forgeax::default-shadow-caster');
568
+ }
569
+ if (spriteEntry !== undefined) {
570
+ spriteEntry = patchRuntimeVariant(spriteEntry, 'forgeax::sprite');
571
+ }
572
+ if (spriteLitEntry !== undefined) {
573
+ spriteLitEntry = patchRuntimeVariant(spriteLitEntry, 'forgeax::sprite-lit');
574
+ }
575
+ // bug-20260708 M2 (a): sprite entry WGSL patch — always substitute the
576
+ // PIR=false variant so the eager compile at line ~4272 (which produces
577
+ // `spriteModule`, seeded under `module-forgeax::sprite` at line ~4297 and
578
+ // exposed via `shaderModuleMap.set('forgeax::sprite', ...)` at line
579
+ // ~5438) yields the 5575B PIR=false shader module. The character /
580
+ // sprite-atlas per-entity path (`main-pass-sprite-draws.ts:511-513`)
581
+ // routes `variantSet=undefined` through `getMaterialShaderPipeline`
582
+ // with `moduleLabel='module-forgeax::sprite'` — hitting this seeded
583
+ // module. SpriteInstances batches request `variantSet=''` →
584
+ // `moduleLabel='module-forgeax::sprite#'` (distinct label) → compile
585
+ // fresh from `findVariantByKey(msEntry, '')` returning the PIR=true
586
+ // 5653B variant. The upstream `manifestEntries` populated
587
+ // `spriteEntry.wgsl` with the plugin default (all-true canonical =
588
+ // PIR=true+SBA=true); we substitute the matching-SBA PIR=false variant
589
+ // here. Runs regardless of `storageBufferCapable` — the WebGL2 fallback
590
+ // patch above (bug-20260610) already substitutes SBA=false; we now
591
+ // substitute PIR=false on top so the boot-time seeded module for
592
+ // sprite is aligned with the boot-registered material-shader source
593
+ // (patched at line ~3710 via the PER_INSTANCE_REGION extension above).
594
+ if (spriteEntry !== undefined) {
595
+ for (const msEntry of registry.materialShaderManifestEntries()) {
596
+ if (msEntry.identifier === 'forgeax::sprite') {
597
+ const pirFalseVariant = msEntry.variants.find(
598
+ (v) =>
599
+ v.defines.PER_INSTANCE_REGION === false &&
600
+ v.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable,
601
+ );
602
+ if (pirFalseVariant !== undefined) {
603
+ spriteEntry = { ...spriteEntry, wgsl: pirFalseVariant.composedWgsl };
604
+ }
605
+ break;
606
+ }
607
+ }
608
+ }
609
+ // Wire composed IBL shaders into IblPipelineCache before the cache's
610
+ // createIblPipelines runs (called downstream by the internal
611
+ // GpuResidencyCache equirect-to-cubemap projection during the first Skylight
612
+ // dispatch). When all 4 are present, register them; otherwise leave
613
+ // the cache untouched (charter F1: tests / non-IBL hosts that ship
614
+ // empty / 3-entry manifests still boot, and the IBL pipeline cache's
615
+ // own error surfacing covers the "missing" case downstream).
616
+ if (
617
+ iblEquirectEntry !== undefined &&
618
+ iblIrradianceEntry !== undefined &&
619
+ iblPrefilterEntry !== undefined &&
620
+ iblBrdfLutEntry !== undefined
621
+ ) {
622
+ setIblComposedShaders({
623
+ equirectToCube: iblEquirectEntry.wgsl,
624
+ irradiance: iblIrradianceEntry.wgsl,
625
+ prefilter: iblPrefilterEntry.wgsl,
626
+ brdfLut: iblBrdfLutEntry.wgsl,
627
+ });
628
+ }
629
+ // Points/Lines are an engine-owned record path rather than a producer
630
+ // RenderFeature, so they cannot declare `requiredMaterialShaders`. Their
631
+ // material pipeline still goes through the same lazy shader-module adapter
632
+ // as producer features. Prewarm the optional manifest artifact here so a
633
+ // first frame cannot race async module compilation and silently skip its
634
+ // draw while the adapter is still pending. Older/minimal manifests may not
635
+ // carry the optional entry; those hosts retain their existing behavior.
636
+ const pointsLinesLookup = registry.findMaterialArtifact(POINTS_LINES_MATERIAL_SHADER_ID);
637
+ if (pointsLinesLookup.ok) {
638
+ const pointsLinesLabel = `module-${POINTS_LINES_MATERIAL_SHADER_ID}`;
639
+ const pointsLinesShaderResult = await runShimStep(
640
+ () =>
641
+ asyncCreateShaderModule
642
+ ? asyncCreateShaderModule(rhiDevice, {
643
+ code: pointsLinesLookup.value.source,
644
+ label: pointsLinesLabel,
645
+ })
646
+ : invokeDeviceCreateShaderModule(rhiDevice, {
647
+ code: pointsLinesLookup.value.source,
648
+ label: pointsLinesLabel,
649
+ }),
650
+ 'shader-compile-failed',
651
+ `engine material shader '${POINTS_LINES_MATERIAL_SHADER_ID}' compiled`,
652
+ `inspect the composed WGSL for '${POINTS_LINES_MATERIAL_SHADER_ID}' and check device.features`,
653
+ );
654
+ if (!pointsLinesShaderResult.ok) throw pointsLinesShaderResult.error;
655
+ seedShaderModule(pointsLinesLabel, pointsLinesShaderResult.value);
656
+ }
657
+ await prewarmRequiredMaterialShaders({
658
+ rhiDevice,
659
+ registry,
660
+ asyncCreateShaderModule,
661
+ requiredMaterialShaders,
662
+ seedShaderModule,
663
+ });
664
+ for (const postProcess of requiredFullscreenPostProcesses) {
665
+ const label = postProcessShaderModuleLabel(postProcess.identity, postProcess.source);
666
+ const shaderResult = await runShimStep(
667
+ () =>
668
+ asyncCreateShaderModule
669
+ ? asyncCreateShaderModule(rhiDevice, { code: postProcess.source, label })
670
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: postProcess.source, label }),
671
+ 'shader-compile-failed',
672
+ `declared fullscreen render feature '${postProcess.identity}' compiled`,
673
+ `inspect the composed WGSL for '${postProcess.identity}' and check device.features`,
674
+ );
675
+ if (!shaderResult.ok) throw shaderResult.error;
676
+ seedShaderModule(label, shaderResult.value);
677
+ }
678
+ if (rhiDevice.caps.compute && rhiDevice.caps.storageTexture) {
679
+ const volumeSources = [
680
+ ['inject', 'volume_inject', (source: string) => source.includes('fn volume_inject(')],
681
+ ['temporal', 'volume_temporal', (source: string) => source.includes('fn volume_temporal(')],
682
+ [
683
+ 'integrate',
684
+ 'volume_integrate',
685
+ (source: string) => source.includes('fn volume_integrate('),
686
+ ],
687
+ ['composite', 'volume_composite', (source: string) => source.includes('fn volume_fs(')],
688
+ ] as const;
689
+ const resolved = volumeSources.map(([stage, label, matches]) => ({
690
+ stage,
691
+ label,
692
+ entry: manifestEntries.find((candidate) => matches(candidate.wgsl)),
693
+ }));
694
+ const present = resolved.filter(({ entry }) => entry !== undefined).length;
695
+ if (present > 0) {
696
+ const missing = resolved.find(({ entry }) => entry === undefined);
697
+ if (missing !== undefined) {
698
+ throw new RhiError({
699
+ code: 'shader-compile-failed',
700
+ expected: 'shader manifest contains all four volumetric fog utility entries',
701
+ hint: `add the package-owned WGSL entry for '${missing.label}' to the engine shader manifest`,
702
+ });
703
+ }
704
+ const compiled: Array<{
705
+ readonly stage: (typeof volumeSources)[number][0];
706
+ readonly label: string;
707
+ readonly source: string;
708
+ readonly module: ShaderModule;
709
+ }> = [];
710
+ for (const { stage, label, entry } of resolved) {
711
+ if (entry === undefined) throw new Error(`missing volumetric shader ${label}`);
712
+ const shaderResult = await runShimStep(
713
+ () =>
714
+ asyncCreateShaderModule
715
+ ? asyncCreateShaderModule(rhiDevice, { code: entry.wgsl, label })
716
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: entry.wgsl, label }),
717
+ 'shader-compile-failed',
718
+ `volumetric fog shader module '${label}' compiled`,
719
+ `inspect manifest WGSL for '${label}' and check device.features`,
720
+ );
721
+ if (!shaderResult.ok) throw shaderResult.error;
722
+ compiled.push({ stage, label, source: entry.wgsl, module: shaderResult.value });
723
+ }
724
+ for (const { label, module } of compiled) seedShaderModule(label, module);
725
+ setVolumetricFogShaderSources({
726
+ inject: compiled.find(({ stage }) => stage === 'inject')?.source as string,
727
+ temporal: compiled.find(({ stage }) => stage === 'temporal')?.source as string,
728
+ integrate: compiled.find(({ stage }) => stage === 'integrate')?.source as string,
729
+ composite: compiled.find(({ stage }) => stage === 'composite')?.source as string,
730
+ });
731
+ }
732
+ }
733
+ const pbrShaderResult = await runShimStep(
734
+ () =>
735
+ asyncCreateShaderModule
736
+ ? asyncCreateShaderModule(rhiDevice, { code: pbrEntry.wgsl, label: 'pbr' })
737
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: pbrEntry.wgsl, label: 'pbr' }),
738
+ 'shader-compile-failed',
739
+ 'PBR shader module compiled',
740
+ 'inspect manifest pbr entry composed wgsl; check device.features',
741
+ );
742
+ if (!pbrShaderResult.ok) throw pbrShaderResult.error;
743
+ pbrModule = pbrShaderResult.value;
744
+ // feat-20260629-multi-uv-set-support: a real extra-UV mesh creates a
745
+ // layout-specific material PSO after the boot-time standard-layout PSO
746
+ // has been pre-warmed. Seed the shared shader-module adapter under the
747
+ // exact lazy-build labels so that this first-touch PSO can reuse the
748
+ // already compiled PBR variant instead of returning the transient
749
+ // `rhi-not-available` pending signal in a tight draw loop.
750
+ seedShaderModule('module-forgeax::default-standard-pbr', pbrModule);
751
+ const pbrManifestEntry = [...registry.materialShaderManifestEntries()].find(
752
+ (entry) => entry.identifier === 'forgeax::default-standard-pbr',
753
+ );
754
+ const pbrVariant = pbrManifestEntry?.variants.find(
755
+ (variant) => variant.composedWgsl === pbrEntry.wgsl,
756
+ );
757
+ if (pbrVariant !== undefined) {
758
+ seedShaderModule(`module-forgeax::default-standard-pbr#${pbrVariant.definesKey}`, pbrModule);
759
+ }
760
+ const transmissionCapable =
761
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
762
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
763
+ const transmissionVariants = transmissionCapable
764
+ ? selectStandardPbrTransmissionPrewarmVariants(
765
+ pbrManifestEntry,
766
+ storageBufferCapable,
767
+ directionalPcssAvailable,
768
+ projectorAvailable,
769
+ extendedLightingShaderAvailable,
770
+ )
771
+ : [];
772
+
773
+ // HDRP uses the canonical all-true variant key (`''`) and the lazy
774
+ // material pipeline adapter therefore requests a distinct module label.
775
+ // Compile every device-capability-matched HDRP variant and seed its exact
776
+ // label. Color availability is part of the geometry-owned variant axis, so
777
+ // both colored and no-color HDRP sources must be retained.
778
+ const prewarmedPbrModules = new Map<string, ShaderModule>([[pbrEntry.wgsl, pbrModule]]);
779
+ for (const transmissionVariant of transmissionVariants) {
780
+ const moduleLabel = `module-forgeax::default-standard-pbr#${transmissionVariant.definesKey}`;
781
+ let transmissionModule = prewarmedPbrModules.get(transmissionVariant.composedWgsl);
782
+ if (transmissionModule === undefined) {
783
+ const transmissionModuleResult = await runShimStep(
784
+ () =>
785
+ asyncCreateShaderModule
786
+ ? asyncCreateShaderModule(rhiDevice, {
787
+ code: transmissionVariant.composedWgsl,
788
+ label: moduleLabel,
789
+ })
790
+ : invokeDeviceCreateShaderModule(rhiDevice, {
791
+ code: transmissionVariant.composedWgsl,
792
+ label: moduleLabel,
793
+ }),
794
+ 'shader-compile-failed',
795
+ 'Standard transmission shader module compiled',
796
+ 'inspect the composed Standard transmission variant WGSL and check device.features',
797
+ );
798
+ if (!transmissionModuleResult.ok) throw transmissionModuleResult.error;
799
+ transmissionModule = transmissionModuleResult.value;
800
+ prewarmedPbrModules.set(transmissionVariant.composedWgsl, transmissionModule);
801
+ }
802
+ seedShaderModule(moduleLabel, transmissionModule);
803
+ }
804
+ for (const hdrpVariant of selectHdrpPbrPrewarmVariants(
805
+ pbrManifestEntry,
806
+ storageBufferCapable,
807
+ extendedLightingShaderAvailable,
808
+ transmissionCapable,
809
+ directionalPcssAvailable,
810
+ projectorAvailable,
811
+ )) {
812
+ const moduleLabel = `module-forgeax::default-standard-pbr#${hdrpVariant.definesKey}`;
813
+ let hdrpModule = prewarmedPbrModules.get(hdrpVariant.composedWgsl);
814
+ if (hdrpModule === undefined) {
815
+ const hdrpModuleResult = await runShimStep(
816
+ () =>
817
+ asyncCreateShaderModule
818
+ ? asyncCreateShaderModule(rhiDevice, {
819
+ code: hdrpVariant.composedWgsl,
820
+ label: moduleLabel,
821
+ })
822
+ : invokeDeviceCreateShaderModule(rhiDevice, {
823
+ code: hdrpVariant.composedWgsl,
824
+ label: moduleLabel,
825
+ }),
826
+ 'shader-compile-failed',
827
+ 'HDRP PBR shader module compiled',
828
+ 'inspect the composed HDRP PBR variant WGSL and check device.features',
829
+ );
830
+ if (!hdrpModuleResult.ok) throw hdrpModuleResult.error;
831
+ hdrpModule = hdrpModuleResult.value;
832
+ prewarmedPbrModules.set(hdrpVariant.composedWgsl, hdrpModule);
833
+ }
834
+ seedShaderModule(moduleLabel, hdrpModule);
835
+ }
836
+ // ProbeBlend is an object-level opt-in. Keep its device-matched shader
837
+ // modules warm without adding probe variants to the boot PSO table.
838
+ await prewarmMaterialShaderVariants(
839
+ selectProbePrewarmVariants(
840
+ pbrManifestEntry,
841
+ storageBufferCapable,
842
+ extendedLightingShaderAvailable,
843
+ transmissionCapable,
844
+ directionalPcssAvailable,
845
+ projectorAvailable,
846
+ webgl2Downlevel,
847
+ ),
848
+ prewarmedPbrModules,
849
+ (probeVariant, moduleLabel) =>
850
+ runShimStep(
851
+ () =>
852
+ asyncCreateShaderModule
853
+ ? asyncCreateShaderModule(rhiDevice, {
854
+ code: probeVariant.composedWgsl,
855
+ label: moduleLabel,
856
+ })
857
+ : invokeDeviceCreateShaderModule(rhiDevice, {
858
+ code: probeVariant.composedWgsl,
859
+ label: moduleLabel,
860
+ }),
861
+ 'shader-compile-failed',
862
+ `Standard PBR probe variant ${probeVariant.definesKey || '<default>'} compiled`,
863
+ 'inspect the selected Standard PBR probe variant WGSL and device.features',
864
+ ),
865
+ seedShaderModule,
866
+ );
867
+ // M2-07-c: Materials.standard with an explicit renderState reaches the
868
+ // lazy material pipeline path, whose module label includes the requested
869
+ // variant set. Reuse the boot-compiled PBR module only for manifest
870
+ // variants with byte-identical composed WGSL; HDRP and WebGL2 variants
871
+ // keep their own module identities and are never aliased here.
872
+ for (const materialEntry of registry.materialShaderManifestEntries()) {
873
+ for (const variant of materialEntry.variants) {
874
+ if (variant.composedWgsl === pbrEntry.wgsl) {
875
+ seedShaderModule(`module-${materialEntry.identifier}#${variant.definesKey}`, pbrModule);
876
+ }
877
+ }
878
+ }
879
+ const unlitShaderResult = await runShimStep(
880
+ () =>
881
+ asyncCreateShaderModule
882
+ ? asyncCreateShaderModule(rhiDevice, { code: unlitEntry.wgsl, label: 'unlit' })
883
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: unlitEntry.wgsl, label: 'unlit' }),
884
+ 'shader-compile-failed',
885
+ 'unlit shader module compiled',
886
+ 'inspect manifest unlit entry composed wgsl; check device.features',
887
+ );
888
+ if (!unlitShaderResult.ok) throw unlitShaderResult.error;
889
+ unlitModule = unlitShaderResult.value;
890
+ // Seed the shared lazy adapter with the eagerly compiled module so
891
+ // prepared color-only PSOs build synchronously on their first frame.
892
+ seedShaderModule('unlit', unlitModule);
893
+ // Material render-state variants use the canonical material id as their
894
+ // module-cache label. Seed that alias too so a first-frame stencil,
895
+ // blend, or cull variant does not skip its draw while the adapter warms.
896
+ seedShaderModule('module-forgeax::default-unlit', unlitModule);
897
+ // The canonical colored unlit variant uses a different composed WGSL from
898
+ // the boot-time no-color module. Seed its exact lazy label so the first
899
+ // COLOR_0 draw does not wait for an asynchronous module compile.
900
+ const unlitManifestEntry = [...registry.materialShaderManifestEntries()].find(
901
+ (entry) => entry.identifier === 'forgeax::default-unlit',
902
+ );
903
+ const coloredUnlitVariant = unlitManifestEntry?.variants.find(
904
+ (variant) =>
905
+ variant.defines.VERTEX_COLOR_AVAILABLE === true &&
906
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable,
907
+ );
908
+ if (coloredUnlitVariant !== undefined && coloredUnlitVariant.composedWgsl !== unlitEntry.wgsl) {
909
+ const coloredUnlitShaderResult = await runShimStep(
910
+ () =>
911
+ asyncCreateShaderModule
912
+ ? asyncCreateShaderModule(rhiDevice, {
913
+ code: coloredUnlitVariant.composedWgsl,
914
+ label: 'unlit-vertex-color',
915
+ })
916
+ : invokeDeviceCreateShaderModule(rhiDevice, {
917
+ code: coloredUnlitVariant.composedWgsl,
918
+ label: 'unlit-vertex-color',
919
+ }),
920
+ 'shader-compile-failed',
921
+ 'colored unlit shader module compiled',
922
+ 'inspect the VERTEX_COLOR_AVAILABLE=true unlit variant and device.features',
923
+ );
924
+ if (!coloredUnlitShaderResult.ok) throw coloredUnlitShaderResult.error;
925
+ seedShaderModule(
926
+ `module-forgeax::default-unlit#${coloredUnlitVariant.definesKey}`,
927
+ coloredUnlitShaderResult.value,
928
+ );
929
+ }
930
+ const uncoloredUnlitVariant = unlitManifestEntry?.variants.find(
931
+ (variant) =>
932
+ variant.defines.VERTEX_COLOR_AVAILABLE === false &&
933
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable,
934
+ );
935
+ if (uncoloredUnlitVariant !== undefined) {
936
+ seedShaderModule(
937
+ `module-forgeax::default-unlit#${uncoloredUnlitVariant.definesKey}`,
938
+ unlitModule,
939
+ );
940
+ }
941
+ // feat-20260609 R3-fixup: eagerly compile shadow_caster module + seed
942
+ // the lazy MaterialShader pipeline cache adapter so the first frame's
943
+ // shadow PSO build (passKind='shadow-caster') hits OK without a
944
+ // 1-frame warmup. T-009 deleted the hardcoded shadowCasterPipeline;
945
+ // the lazy path replaces it but the adapter cache key
946
+ // ('module-forgeax::default-shadow-caster') was previously unwarmed,
947
+ // causing the first frame's createShaderModule to return
948
+ // 'rhi-not-available' and the shadow PSO build to fail (which left
949
+ // the shadow depth attachment unwritten). Optional: when the manifest
950
+ // omits shadow_caster (legacy hosts), the lazy path simply falls
951
+ // through to the existing 1-frame retry; no regression.
952
+ if (shadowCasterEntry !== undefined) {
953
+ const shadowCasterShaderResult = await runShimStep(
954
+ () =>
955
+ asyncCreateShaderModule
956
+ ? asyncCreateShaderModule(rhiDevice, {
957
+ code: shadowCasterEntry.wgsl,
958
+ label: 'shadow_caster',
959
+ })
960
+ : invokeDeviceCreateShaderModule(rhiDevice, {
961
+ code: shadowCasterEntry.wgsl,
962
+ label: 'shadow_caster',
963
+ }),
964
+ 'shader-compile-failed',
965
+ 'shadow_caster shader module compiled',
966
+ 'inspect manifest shadow_caster entry composed wgsl; check device.features',
967
+ );
968
+ if (!shadowCasterShaderResult.ok) throw shadowCasterShaderResult.error;
969
+ // Seed the adapter cache under the same label
970
+ // (`module-${materialShaderId}`) that getMaterialShaderPipeline
971
+ // uses so the lazy build's createShaderModule call hits OK on
972
+ // frame 1.
973
+ seedShaderModule('module-forgeax::default-shadow-caster', shadowCasterShaderResult.value);
974
+ // The shadow caster now has independent storage and skinning axes. The
975
+ // canonical all-true entry remains the ordinary static source through
976
+ // SKINNING_DISABLED=true; skinned meshes request the explicit false
977
+ // variant. Prewarm both for the active storage capability so neither
978
+ // path loses its first shadow frame to the lazy module retry contract.
979
+ const shadowCasterManifestEntry = Array.from(registry.materialShaderManifestEntries()).find(
980
+ (candidate) => candidate.identifier === 'forgeax::default-shadow-caster',
981
+ );
982
+ const activeShadowVariants = shadowCasterManifestEntry?.variants.filter(
983
+ (variant) =>
984
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
985
+ typeof variant.defines.SKINNING_DISABLED === 'boolean',
986
+ );
987
+ for (const variant of activeShadowVariants ?? []) {
988
+ if (variant.definesKey === '') {
989
+ seedShaderModule(
990
+ 'module-forgeax::default-shadow-caster#',
991
+ shadowCasterShaderResult.value,
992
+ );
993
+ continue;
994
+ }
995
+ const variantShaderResult = await runShimStep(
996
+ () =>
997
+ asyncCreateShaderModule
998
+ ? asyncCreateShaderModule(rhiDevice, {
999
+ code: variant.composedWgsl,
1000
+ label: `shadow_caster#${variant.definesKey}`,
1001
+ })
1002
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1003
+ code: variant.composedWgsl,
1004
+ label: `shadow_caster#${variant.definesKey}`,
1005
+ }),
1006
+ 'shader-compile-failed',
1007
+ `shadow_caster variant ${variant.definesKey || '<default>'} compiled`,
1008
+ 'inspect the selected shadow_caster storage/skinning variant and device.features',
1009
+ );
1010
+ if (!variantShaderResult.ok) throw variantShaderResult.error;
1011
+ seedShaderModule(
1012
+ `module-forgeax::default-shadow-caster#${variant.definesKey}`,
1013
+ variantShaderResult.value,
1014
+ );
1015
+ }
1016
+ }
1017
+
1018
+ // feat-20260621 M-A3 (D-5): register the built-in tonemap onto the unified
1019
+ // post-process channel instead of building a dedicated pipeline. The composed
1020
+ // tonemap WGSL (`tonemapEntry.wgsl`, @group(1) bindings after w16) becomes the
1021
+ // registered `entry.source`; fullscreen effect registration eager-creates the 16 B
1022
+ // params UBO (fail-fast). The empty-manifest path never reaches here (the
1023
+ // manifest triple guard above throws when tonemapEntry is undefined), so
1024
+ // registration is unconditional within this gate.
1025
+ //
1026
+ // Eager pre-warm (zero-regression): the dedicated tonemap pipeline used to be
1027
+ // built during `ready`, so tonemap rendered correctly on frame 1 with NO
1028
+ // event-loop yield. The unified `getPostProcessPipeline` lazy path otherwise
1029
+ // returns `rhi-not-available` until the async shader-compile promise resolves
1030
+ // -- a consumer driving `draw()` in a tight loop without an `await` between
1031
+ // frames (e.g. the hello-tonemap dawn smoke) would stall on a black frame
1032
+ // forever. Mirror the shadow_caster prewarm: await-compile the tonemap module
1033
+ // here and `seedShaderModule` it under the exact label
1034
+ // `buildPostProcessPipeline` requests (`post-process-${id}-module`), so the
1035
+ // first `getPostProcessPipeline(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, …)` hits the module cache
1036
+ // synchronously and builds the pipeline on frame 1.
1037
+ const tonemapPrewarm = await runShimStep(
1038
+ () =>
1039
+ asyncCreateShaderModule
1040
+ ? asyncCreateShaderModule(rhiDevice, {
1041
+ code: tonemapEntry.wgsl,
1042
+ label: postProcessShaderModuleLabel(
1043
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
1044
+ tonemapEntry.wgsl,
1045
+ ),
1046
+ })
1047
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1048
+ code: tonemapEntry.wgsl,
1049
+ label: postProcessShaderModuleLabel(
1050
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
1051
+ tonemapEntry.wgsl,
1052
+ ),
1053
+ }),
1054
+ 'shader-compile-failed',
1055
+ 'tonemap shader module compiled (unified post-process prewarm)',
1056
+ 'inspect manifest tonemap entry composed wgsl; check device.features',
1057
+ );
1058
+ if (!tonemapPrewarm.ok) throw tonemapPrewarm.error;
1059
+ seedShaderModule(
1060
+ postProcessShaderModuleLabel(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, tonemapEntry.wgsl),
1061
+ tonemapPrewarm.value,
1062
+ );
1063
+ registerBuiltinTonemap(tonemapEntry.wgsl);
1064
+
1065
+ // Temporal fullscreen passes execute synchronously from the compiled graph,
1066
+ // so their shader modules must be warmed before the initialization barrier
1067
+ // resolves. Otherwise the first tight draw loop sees an asynchronous
1068
+ // `rhi-not-available` result and leaves the temporal targets cleared even
1069
+ // though the graph topology is present.
1070
+ const prewarmTemporal = async (entry: ManifestEntry | undefined, id: string) => {
1071
+ if (entry === undefined) return;
1072
+ const prewarm = await runShimStep(
1073
+ () =>
1074
+ asyncCreateShaderModule
1075
+ ? asyncCreateShaderModule(rhiDevice, {
1076
+ code: entry.wgsl,
1077
+ label: postProcessShaderModuleLabel(id, entry.wgsl),
1078
+ })
1079
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1080
+ code: entry.wgsl,
1081
+ label: postProcessShaderModuleLabel(id, entry.wgsl),
1082
+ }),
1083
+ 'shader-compile-failed',
1084
+ `${id} shader module compiled (unified post-process prewarm)`,
1085
+ `inspect manifest ${id} entry composed wgsl; check device.features`,
1086
+ );
1087
+ if (!prewarm.ok) throw prewarm.error;
1088
+ seedShaderModule(postProcessShaderModuleLabel(id, entry.wgsl), prewarm.value);
1089
+ };
1090
+ await prewarmTemporal(motionBlurEntry, 'forgeax.motion-blur');
1091
+ await prewarmTemporal(taaResolveEntry, 'forgeax.taa-resolve');
1092
+ registerBuiltinTemporalPostProcesses({
1093
+ ...(motionBlurEntry === undefined ? {} : { motionBlur: motionBlurEntry.wgsl }),
1094
+ ...(taaResolveEntry === undefined ? {} : { taaResolve: taaResolveEntry.wgsl }),
1095
+ });
1096
+
1097
+ // feat-20260520-2d-sprite-layer-mvp M-3 / w24: sprite shader module
1098
+ // is optional in the 3-tuple legacy manifest (back-compat for apps
1099
+ // that locked their manifest URL before this feat). With the
1100
+ // vite-plugin-shader 4-entry surface (M-3 / w20), spriteEntry is
1101
+ // present and we build both LDR + HDR sprite pipeline variants.
1102
+ if (spriteEntry !== undefined) {
1103
+ const spriteShaderResult = await runShimStep(
1104
+ () =>
1105
+ asyncCreateShaderModule
1106
+ ? asyncCreateShaderModule(rhiDevice, { code: spriteEntry.wgsl, label: 'sprite' })
1107
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1108
+ code: spriteEntry.wgsl,
1109
+ label: 'sprite',
1110
+ }),
1111
+ 'shader-compile-failed',
1112
+ 'sprite shader module compiled',
1113
+ 'inspect manifest sprite entry composed wgsl; check device.features',
1114
+ );
1115
+ if (!spriteShaderResult.ok) throw spriteShaderResult.error;
1116
+ spriteModule = spriteShaderResult.value;
1117
+ // feat-20260625-refactor-sprite-as-transparent-mesh CI-fix: seed the
1118
+ // lazy adapter cache under the same label (`module-forgeax::sprite`)
1119
+ // that `getMaterialShaderPipeline -> buildAndCachePipeline ->
1120
+ // buildPipelineForMaterialShader` uses, so the first lazy build of
1121
+ // the sprite PSO at LDR transparent-split time hits the module cache
1122
+ // on frame 1 and does NOT trip the 1-frame `rhi-not-available` skip
1123
+ // (which surfaces as `shader-compile-failed` "manifest entries
1124
+ // include sprite.wgsl" at the spritePH===null branch in
1125
+ // render-system-record.ts §spritePass). Mirrors the
1126
+ // `module-forgeax::default-shadow-caster` prewarm above; the pre-
1127
+ // feat-20260625 dedicated `spritePipeline` baked the module into a
1128
+ // boot-time PSO so this seed step was implicit. Post-w14 the sprite
1129
+ // shares the generic per-MaterialShader lazy build path, which now
1130
+ // needs the explicit seed to preserve frame-1 readiness.
1131
+ seedShaderModule('module-forgeax::sprite', spriteShaderResult.value);
1132
+ const spriteManifestEntry = Array.from(registry.materialShaderManifestEntries()).find(
1133
+ (entry) => entry.identifier === 'forgeax::sprite',
1134
+ );
1135
+ const regionVariant = spriteManifestEntry?.variants.find(
1136
+ (variant) =>
1137
+ variant.defines.PER_INSTANCE_REGION === true &&
1138
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable,
1139
+ );
1140
+ if (regionVariant !== undefined) {
1141
+ const regionShaderResult = await runShimStep(
1142
+ () =>
1143
+ asyncCreateShaderModule
1144
+ ? asyncCreateShaderModule(rhiDevice, {
1145
+ code: regionVariant.composedWgsl,
1146
+ label: `sprite#${regionVariant.definesKey}`,
1147
+ })
1148
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1149
+ code: regionVariant.composedWgsl,
1150
+ label: `sprite#${regionVariant.definesKey}`,
1151
+ }),
1152
+ 'shader-compile-failed',
1153
+ `sprite variant ${regionVariant.definesKey || '<default>'} compiled`,
1154
+ 'inspect the selected sprite PER_INSTANCE_REGION variant and device.features',
1155
+ );
1156
+ if (!regionShaderResult.ok) throw regionShaderResult.error;
1157
+ seedShaderModule(
1158
+ `module-forgeax::sprite#${regionVariant.definesKey}`,
1159
+ regionShaderResult.value,
1160
+ );
1161
+ }
1162
+ }
1163
+
1164
+ // feat-20260624-sprite-lit-shading-model-pure-2d-lighting M1' / t7:
1165
+ // sprite-lit shader module — mirrors sprite registration shape (same
1166
+ // lazy-build seed under `module-forgeax::sprite-lit`). Optional in the
1167
+ // manifest (back-compat for apps locked to a pre-sprite-lit manifest URL).
1168
+ if (spriteLitEntry !== undefined) {
1169
+ const spriteLitEntryConst = spriteLitEntry;
1170
+ const spriteLitShaderResult = await runShimStep(
1171
+ () =>
1172
+ asyncCreateShaderModule
1173
+ ? asyncCreateShaderModule(rhiDevice, {
1174
+ code: spriteLitEntryConst.wgsl,
1175
+ label: 'sprite-lit',
1176
+ })
1177
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1178
+ code: spriteLitEntryConst.wgsl,
1179
+ label: 'sprite-lit',
1180
+ }),
1181
+ 'shader-compile-failed',
1182
+ 'sprite-lit shader module compiled',
1183
+ 'inspect manifest sprite-lit entry composed wgsl; check device.features',
1184
+ );
1185
+ if (!spriteLitShaderResult.ok) throw spriteLitShaderResult.error;
1186
+ spriteLitModule = spriteLitShaderResult.value;
1187
+ seedShaderModule('module-forgeax::sprite-lit', spriteLitShaderResult.value);
1188
+ // Sprite-lit selects its Standard capability variant at record time.
1189
+ // Warm every device-matched variant before initialization resolves so a
1190
+ // tight smoke/browser frame loop can build the selected PSO synchronously.
1191
+ const spriteLitManifestEntry = Array.from(registry.materialShaderManifestEntries()).find(
1192
+ (entry) => entry.identifier === 'forgeax::sprite-lit',
1193
+ );
1194
+ const prewarmedSpriteLitModules = new Map<string, ShaderModule>([
1195
+ [spriteLitEntryConst.wgsl, spriteLitShaderResult.value],
1196
+ ]);
1197
+ for (const variant of spriteLitManifestEntry?.variants ?? []) {
1198
+ if (variant.defines.STORAGE_BUFFER_AVAILABLE !== storageBufferCapable) continue;
1199
+ const moduleLabel = `module-forgeax::sprite-lit#${variant.definesKey}`;
1200
+ let variantModule = prewarmedSpriteLitModules.get(variant.composedWgsl);
1201
+ if (variantModule === undefined) {
1202
+ const variantResult = await runShimStep(
1203
+ () =>
1204
+ asyncCreateShaderModule
1205
+ ? asyncCreateShaderModule(rhiDevice, {
1206
+ code: variant.composedWgsl,
1207
+ label: moduleLabel,
1208
+ })
1209
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1210
+ code: variant.composedWgsl,
1211
+ label: moduleLabel,
1212
+ }),
1213
+ 'shader-compile-failed',
1214
+ `sprite-lit variant ${variant.definesKey || '<default>'} compiled`,
1215
+ 'inspect the selected sprite-lit Standard capability variant and device.features',
1216
+ );
1217
+ if (!variantResult.ok) throw variantResult.error;
1218
+ variantModule = variantResult.value;
1219
+ prewarmedSpriteLitModules.set(variant.composedWgsl, variantModule);
1220
+ }
1221
+ seedShaderModule(moduleLabel, variantModule);
1222
+ }
1223
+ }
1224
+
1225
+ // feat-20260528-fxaa-post-processing: fxaa shader module is optional
1226
+ // (apps with legacy manifests without fxaa.wgsl continue to boot).
1227
+ // Identified by rgb2luma content marker (plan-strategy D-5). When
1228
+ // present, the module is compiled here; pipeline construction happens
1229
+ // alongside the tonemap pipeline below (step 2 prebuild).
1230
+ if (fxaaEntry !== undefined) {
1231
+ const fxaaShaderResult = await runShimStep(
1232
+ () =>
1233
+ asyncCreateShaderModule
1234
+ ? asyncCreateShaderModule(rhiDevice, { code: fxaaEntry.wgsl, label: 'fxaa' })
1235
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1236
+ code: fxaaEntry.wgsl,
1237
+ label: 'fxaa',
1238
+ }),
1239
+ 'shader-compile-failed',
1240
+ 'fxaa shader module compiled',
1241
+ 'inspect manifest fxaa entry composed wgsl; check device.features',
1242
+ );
1243
+ if (!fxaaShaderResult.ok) throw fxaaShaderResult.error;
1244
+ fxaaModule = fxaaShaderResult.value;
1245
+ registerBuiltinFxaa(fxaaEntry.wgsl);
1246
+ }
1247
+
1248
+ // feat-20260531-skybox-env-background M3 / w15: skybox shader module.
1249
+ // Optional (apps with legacy manifests without skybox.wgsl continue to
1250
+ // boot). Identified by skybox_fs content marker (plan-strategy D-7).
1251
+ // Compiled here; pipeline construction happens alongside tonemap/fxaa
1252
+ // in step 2 prebuild below.
1253
+ if (skyboxEntry !== undefined) {
1254
+ const skyboxShaderResult = await runShimStep(
1255
+ () =>
1256
+ asyncCreateShaderModule
1257
+ ? asyncCreateShaderModule(rhiDevice, { code: skyboxEntry.wgsl, label: 'skybox' })
1258
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1259
+ code: skyboxEntry.wgsl,
1260
+ label: 'skybox',
1261
+ }),
1262
+ 'shader-compile-failed',
1263
+ 'skybox shader module compiled',
1264
+ 'inspect manifest skybox entry composed wgsl; check device.features',
1265
+ );
1266
+ if (!skyboxShaderResult.ok) throw skyboxShaderResult.error;
1267
+ skyboxModule = skyboxShaderResult.value;
1268
+ }
1269
+
1270
+ // Bloom shader modules remain available to the lazy Bloom pipeline owner.
1271
+ // Their construction is still part of renderer readiness because the
1272
+ // backend may expose only an asynchronous module factory; the expensive
1273
+ // pipeline, binding, and parameter resources stay deferred until admission.
1274
+ if (bloomBrightEntry !== undefined) {
1275
+ const result = await runShimStep(
1276
+ () =>
1277
+ asyncCreateShaderModule
1278
+ ? asyncCreateShaderModule(rhiDevice, {
1279
+ code: bloomBrightEntry.wgsl,
1280
+ label: 'bloom-bright',
1281
+ })
1282
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1283
+ code: bloomBrightEntry.wgsl,
1284
+ label: 'bloom-bright',
1285
+ }),
1286
+ 'shader-compile-failed',
1287
+ 'bloom-bright shader module compiled',
1288
+ 'inspect manifest bloom-bright entry composed wgsl; check device.features',
1289
+ );
1290
+ if (!result.ok) throw result.error;
1291
+ bloomBrightModule = result.value;
1292
+ }
1293
+ if (bloomBlurEntry !== undefined) {
1294
+ const result = await runShimStep(
1295
+ () =>
1296
+ asyncCreateShaderModule
1297
+ ? asyncCreateShaderModule(rhiDevice, { code: bloomBlurEntry.wgsl, label: 'bloom-blur' })
1298
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1299
+ code: bloomBlurEntry.wgsl,
1300
+ label: 'bloom-blur',
1301
+ }),
1302
+ 'shader-compile-failed',
1303
+ 'bloom-blur shader module compiled',
1304
+ 'inspect manifest bloom-blur entry composed wgsl; check device.features',
1305
+ );
1306
+ if (!result.ok) throw result.error;
1307
+ bloomBlurModule = result.value;
1308
+ }
1309
+ if (bloomCompositeEntry !== undefined) {
1310
+ const result = await runShimStep(
1311
+ () =>
1312
+ asyncCreateShaderModule
1313
+ ? asyncCreateShaderModule(rhiDevice, {
1314
+ code: bloomCompositeEntry.wgsl,
1315
+ label: 'bloom-composite',
1316
+ })
1317
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1318
+ code: bloomCompositeEntry.wgsl,
1319
+ label: 'bloom-composite',
1320
+ }),
1321
+ 'shader-compile-failed',
1322
+ 'bloom-composite shader module compiled',
1323
+ 'inspect manifest bloom-composite entry composed wgsl; check device.features',
1324
+ );
1325
+ if (!result.ok) throw result.error;
1326
+ bloomCompositeModule = result.value;
1327
+ }
1328
+ // feat-20260612-hdrp-ssao M6 / w27: SSAO shader module compilation.
1329
+ // Optional manifest entry — absent on legacy manifests (zero-overhead
1330
+ // opt-out, same as bloom). Identified by 'fs_ssao_calc' content marker.
1331
+ if (ssaoEntry !== undefined) {
1332
+ const entry = ssaoEntry;
1333
+ const ssaoShaderResult = await runShimStep(
1334
+ () =>
1335
+ asyncCreateShaderModule
1336
+ ? asyncCreateShaderModule(rhiDevice, {
1337
+ code: entry.wgsl,
1338
+ label: 'hdrp-ssao',
1339
+ })
1340
+ : invokeDeviceCreateShaderModule(rhiDevice, {
1341
+ code: entry.wgsl,
1342
+ label: 'hdrp-ssao',
1343
+ }),
1344
+ 'shader-compile-failed',
1345
+ 'hdrp-ssao shader module compiled',
1346
+ 'inspect manifest hdrp-ssao entry composed wgsl; check device.features',
1347
+ );
1348
+ if (!ssaoShaderResult.ok) throw ssaoShaderResult.error;
1349
+ ssaoModule = ssaoShaderResult.value;
1350
+ }
1351
+ }
1352
+
1353
+ // feat-20260520-skylight-ibl-cubemap M4 round-4 / t59: the PBR pipeline
1354
+ // layout construction migrated to `pbr-pipeline.ts buildPbrPipelineLayouts`.
1355
+ // The factory builds 4 BindGroupLayouts (view + material + mesh-array +
1356
+ // instances) and the 4-slot pipeline layout in one call, throwing on each
1357
+ // device.createBindGroupLayout / device.createPipelineLayout failure.
1358
+ // material BGL now carries 24 entries (canonical material 0..14 + Skylight
1359
+ // 15..21 + transmission 22..23); authored physical maps append after the
1360
+ // engine-owned injections. The pipeline layout itself stays at 4 slots, no
1361
+ // @group(4) allocated (D-5 round-4 fix for the round-2 maxBindGroups=4
1362
+ // BLOCKER). The standalone unlit BGL is the 15-entry user region exposed by
1363
+ // `buildUnlitMaterialBgl`; the shared boot path keeps its own fallback
1364
+ // resources at the material slot.
1365
+ //
1366
+ // feat-20260520-directional-light-shadow-mapping merge: view BGL entries
1367
+ // include binding(3) shadowMap + binding(4) comparison sampler (extended
1368
+ // in `buildPbrViewBglEntries`); the shadow caster pipeline + shadow RT
1369
+ // remain owned by createRenderer below, but the layout is shared.
1370
+ // bug-20260610: storageBufferCapable is hoisted to the top of
1371
+ // buildReadyWebGPU (above) so Step 2 (variant patch) and this layout
1372
+ // build read the same value.
1373
+ const pbrLayouts = runShimSyncStep(
1374
+ () =>
1375
+ ok(
1376
+ buildPbrPipelineLayouts(rhiDevice, {
1377
+ storageBuffer: storageBufferCapable,
1378
+ extendedLighting: extendedLightingShaderAvailable,
1379
+ projectorAvailable,
1380
+ }),
1381
+ ),
1382
+ 'webgpu-runtime-error',
1383
+ 'buildPbrPipelineLayouts succeeded',
1384
+ 'check device.limits.maxBindingsPerBindGroup (need >=14) and maxBindGroupsPerPipelineLayout',
1385
+ );
1386
+ if (!pbrLayouts.ok) throw pbrLayouts.error;
1387
+ const viewBglResult = ok(pbrLayouts.value.viewBgl);
1388
+ const materialBglResult = ok(pbrLayouts.value.materialBgl);
1389
+ const meshArrayBglResult = ok(pbrLayouts.value.meshArrayBgl);
1390
+ const instancesBglResult = ok(pbrLayouts.value.instancesBgl);
1391
+ const probeInstancesBglResult = ok(pbrLayouts.value.probeInstancesBgl);
1392
+ const pipelineLayoutResult = ok(pbrLayouts.value.pipelineLayout);
1393
+
1394
+ // feat-20260609-hdrp-cluster-fragment-ggx M4.5 / w36 (D-10 option A):
1395
+ // boot-time build of the HDRP-variant PipelineLayout. The 4-BGL chain is
1396
+ // [view, material, hdrp-unified-7-slot, instances]; the HDRP unified BGL
1397
+ // (createHdrpBindGroupLayoutDescriptor) replaces the 1-slot pbr-mesh-array
1398
+ // BGL at group(2) so an HDRP-variant PSO validates against the 7-slot
1399
+ // group(2) bindGroup that the record stage sets via hdrpClusterBindGroup.
1400
+ // Stays null when createBindGroupLayout / createPipelineLayout fails;
1401
+ // selectPipelineLayoutForVariant gracefully falls back to pbrPipelineLayout
1402
+ // (URP layout) so the manifest entry's URP variant WGSL still builds a
1403
+ // valid PSO instead of hard-disabling the entire HDRP-variant build path.
1404
+ let hdrpPbrPipelineLayoutHandle: PipelineLayout | null = null;
1405
+ let hdrpProbePbrPipelineLayoutHandle: PipelineLayout | null = null;
1406
+ // The cluster layout is storage-buffer backed. On the WebGL2/fallback
1407
+ // capability tier its uniform-buffer projection exceeds the backend's
1408
+ // fragment-stage limit, and HDRP is not a valid route there anyway.
1409
+ if (storageBufferCapable) {
1410
+ const hdrpUnifiedBglDesc = createHdrpBindGroupLayoutDescriptor();
1411
+ const hdrpUnifiedBglRes = rhiDevice.createBindGroupLayout(hdrpUnifiedBglDesc);
1412
+ if (hdrpUnifiedBglRes.ok) {
1413
+ const hdrpPlRes = rhiDevice.createPipelineLayout({
1414
+ label: 'hdrp-pbr-pl',
1415
+ bindGroupLayouts: [
1416
+ viewBglResult.value,
1417
+ materialBglResult.value,
1418
+ hdrpUnifiedBglRes.value,
1419
+ instancesBglResult.value,
1420
+ ],
1421
+ });
1422
+ if (hdrpPlRes.ok) {
1423
+ hdrpPbrPipelineLayoutHandle = hdrpPlRes.value;
1424
+ }
1425
+ if (storageBufferCapable) {
1426
+ const hdrpProbePlRes = rhiDevice.createPipelineLayout({
1427
+ label: 'hdrp-probe-pbr-pl',
1428
+ bindGroupLayouts: [
1429
+ viewBglResult.value,
1430
+ materialBglResult.value,
1431
+ hdrpUnifiedBglRes.value,
1432
+ probeInstancesBglResult.value,
1433
+ ],
1434
+ });
1435
+ if (hdrpProbePlRes.ok) hdrpProbePbrPipelineLayoutHandle = hdrpProbePlRes.value;
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ // The storage path can move the repeated light/cluster membership materializer
1441
+ // into the same command buffer as the deferred lighting pass. This is an
1442
+ // optional producer: module or pipeline creation failure leaves the CPU
1443
+ // binner active, and non-storage backends never attempt the path.
1444
+ let hdrpClusterMembershipPipeline: ComputePipeline | null = null;
1445
+ let hdrpClusterMembershipBindGroupLayout: BindGroupLayout | null = null;
1446
+ if (
1447
+ storageBufferCapable &&
1448
+ rhiDevice.caps.compute &&
1449
+ pbrModule !== null &&
1450
+ unlitModule !== null
1451
+ ) {
1452
+ const producerBgl = rhiDevice.createBindGroupLayout(
1453
+ createHdrpClusterMembershipBindGroupLayoutDescriptor(),
1454
+ );
1455
+ if (producerBgl.ok) {
1456
+ hdrpClusterMembershipBindGroupLayout = producerBgl.value;
1457
+ }
1458
+ const membershipModule = asyncCreateShaderModule
1459
+ ? await asyncCreateShaderModule(rhiDevice, {
1460
+ code: STANDARD_CLUSTER_MEMBERSHIP_WGSL,
1461
+ label: 'hdrp-cluster-membership',
1462
+ })
1463
+ : await invokeDeviceCreateShaderModule(rhiDevice, {
1464
+ code: STANDARD_CLUSTER_MEMBERSHIP_WGSL,
1465
+ label: 'hdrp-cluster-membership',
1466
+ });
1467
+ if (membershipModule.ok && hdrpClusterMembershipBindGroupLayout !== null) {
1468
+ const producerLayout = rhiDevice.createPipelineLayout({
1469
+ label: 'hdrp-cluster-membership-pl',
1470
+ bindGroupLayouts: [hdrpClusterMembershipBindGroupLayout],
1471
+ });
1472
+ if (producerLayout.ok) {
1473
+ const membershipPipeline = rhiDevice.createComputePipeline({
1474
+ label: 'hdrp-cluster-membership',
1475
+ layout: producerLayout.value,
1476
+ compute: {
1477
+ module: membershipModule.value,
1478
+ entryPoint: 'cs_cluster_membership',
1479
+ },
1480
+ });
1481
+ if (membershipPipeline.ok) {
1482
+ hdrpClusterMembershipPipeline = membershipPipeline.value;
1483
+ }
1484
+ } else {
1485
+ hdrpClusterMembershipBindGroupLayout = null;
1486
+ }
1487
+ }
1488
+ }
1489
+
1490
+ // bug-20260611-skin-pipeline-layout-mesh-array-bgl-2bindings: boot-time
1491
+ // build of the skin-variant PipelineLayout. Mirrors the HDRP block above
1492
+ // (D-2 / D-3 in plan-strategy): reuse view / material / instances BGLs
1493
+ // from `pbrLayouts` and only create a 2-entry mesh-array BGL (binding 0
1494
+ // meshes + binding 1 palette) so the `forgeax::pbr-skin` shader's
1495
+ // `@group(2) @binding(1) palette` declaration validates. Stays null when
1496
+ // createBindGroupLayout / createPipelineLayout fails;
1497
+ // selectPipelineLayoutForVariant returns null in that case (charter P3
1498
+ // explicit failure -- no silent fallback to URP layout, mirroring memory
1499
+ // anchor `hdrp-active-must-not-fallback-to-urp-pipeline`).
1500
+ let pbrSkinPipelineLayoutHandle: PipelineLayout | null = null;
1501
+ let pbrSkinProbePipelineLayoutHandle: PipelineLayout | null = null;
1502
+ // feat-20260611 R2 / M8 / w28: capture the 2-binding skin mesh-array BGL
1503
+ // produced by `buildPbrSkinLayouts` so the record stage can build a BG
1504
+ // matching `pbr-skin-pl` (pipeline-layout BGL[2] is this 2-entry skin
1505
+ // BGL, NOT the 1-entry `pbr-mesh-array-bgl`). The BGL handle stays null
1506
+ // when the skin pipeline layout itself failed to build, keeping the skin
1507
+ // path explicitly disabled (charter P3 — record-stage falls back to URP
1508
+ // path which is correct for non-skin entries; skin entries hit the
1509
+ // explicit-failure branch).
1510
+ let pbrSkinMeshBindGroupLayoutHandle: BindGroupLayout | null = null;
1511
+ let hdrpSkinPipelineLayoutHandle: PipelineLayout | null = null;
1512
+ let hdrpSkinMeshBindGroupLayoutHandle: BindGroupLayout | null = null;
1513
+ {
1514
+ const skinLayoutsResult = runShimSyncStep(
1515
+ () =>
1516
+ ok(
1517
+ buildPbrSkinLayouts(rhiDevice, { storageBuffer: storageBufferCapable }, pbrLayouts.value),
1518
+ ),
1519
+ 'webgpu-runtime-error',
1520
+ 'buildPbrSkinLayouts succeeded',
1521
+ 'check device.limits.maxBindingsPerBindGroup (need >=14) and maxBindGroupsPerPipelineLayout',
1522
+ );
1523
+ if (skinLayoutsResult.ok) {
1524
+ pbrSkinPipelineLayoutHandle = skinLayoutsResult.value.pipelineLayout;
1525
+ pbrSkinProbePipelineLayoutHandle = skinLayoutsResult.value.probePipelineLayout;
1526
+ pbrSkinMeshBindGroupLayoutHandle = skinLayoutsResult.value.meshArrayBgl;
1527
+ }
1528
+ }
1529
+ if (storageBufferCapable) {
1530
+ const skinBglResult = rhiDevice.createBindGroupLayout(
1531
+ createHdrpSkinBindGroupLayoutDescriptor(),
1532
+ );
1533
+ if (skinBglResult.ok) {
1534
+ const skinPlResult = rhiDevice.createPipelineLayout({
1535
+ label: 'hdrp-skin-pl',
1536
+ bindGroupLayouts: [
1537
+ viewBglResult.value,
1538
+ materialBglResult.value,
1539
+ skinBglResult.value,
1540
+ instancesBglResult.value,
1541
+ ],
1542
+ });
1543
+ if (skinPlResult.ok) {
1544
+ hdrpSkinPipelineLayoutHandle = skinPlResult.value;
1545
+ hdrpSkinMeshBindGroupLayoutHandle = skinBglResult.value;
1546
+ }
1547
+ }
1548
+ }
1549
+
1550
+ // feat-20260612-skin-palette-per-frame-upload M1 / m1-2: skin palette
1551
+ // allocator. Replaces the prior 16320 B identity-seeded UBO stub (PR #353,
1552
+ // feat-20260611 R2 / M8 / w28 IS-14, retired identity-buffer field) with
1553
+ // the animator-ready `SkinPaletteAllocator` from
1554
+ // `./systems/skin-palette-allocator`. Per
1555
+ // plan-strategy D-1 candidate (b) the allocator is the single
1556
+ // authoritative carrier of the palette GPU resource -- no parallel
1557
+ // identity-fallback buffer; the boot code only constructs the allocator
1558
+ // and the per-frame extract / record stages drive `allocateSlice` +
1559
+ // `writeJointPalette` (M2 / M3) to land animated palette data. The
1560
+ // allocator's `buffer` is `null` until the first `allocateSlice` call
1561
+ // grows it, and the record stage gates skin entries on `pbrSkinPipelineLayout`
1562
+ // + `skinPaletteAllocator.buffer !== null` (charter P3 explicit failure).
1563
+ //
1564
+ // M6 fix: the cap is the device's max BUFFER binding size for the
1565
+ // selected usage path -- NOT 16320 B (that's the static BG @binding(1)
1566
+ // ENTRY size, a per-draw window slid by dynamic offset; the underlying
1567
+ // buffer must span every entity's window so `dynOffset + entry.size <=
1568
+ // buffer.size` holds for the last skinned draw). Pre-M6 conflated the
1569
+ // two and rejected the 2nd skin entity with SkinPaletteOverflowError.
1570
+ //
1571
+ // Storage path -> `maxStorageBufferBindingSize`; uniform fallback ->
1572
+ // `maxUniformBufferBindingSize` (WebGPU spec floor 64 KiB; 16320 still
1573
+ // fits 4 entities back-to-back even on the floor).
1574
+ const skinPaletteLimitKey = storageBufferCapable
1575
+ ? 'maxStorageBufferBindingSize'
1576
+ : 'maxUniformBufferBindingSize';
1577
+ const skinPaletteDeviceLimit = (rhiDevice.limits as Readonly<Record<string, number>>)[
1578
+ skinPaletteLimitKey
1579
+ ];
1580
+ const SKIN_PALETTE_MAX_BINDING_BYTES =
1581
+ typeof skinPaletteDeviceLimit === 'number' && skinPaletteDeviceLimit > 0
1582
+ ? skinPaletteDeviceLimit
1583
+ : 65536; // WebGPU spec floor for maxUniformBufferBindingSize
1584
+ const skinPaletteAllocatorHandle: SkinPaletteAllocator = createSkinPaletteAllocator(
1585
+ rhiDevice,
1586
+ SKIN_PALETTE_MAX_BINDING_BYTES,
1587
+ storageBufferCapable,
1588
+ );
1589
+
1590
+ // bug-20260519: the legacy `pbr-pipeline-unlit-builtin` (6F-stride + a
1591
+ // 24-byte zero-fill dummy VBO that hard-coded uv=(0,0)) is gone. BUILTIN
1592
+ // geometry now ships 12-floats per vertex (pos + normal + uv + tangent)
1593
+ // identical to procedural meshes, so a single (`unlit-procedural` /
1594
+ // `standard`) pipeline pair covers every renderable.
1595
+
1596
+ // ── Step 3: AssetRegistry builtin mesh GPU upload ─────────────────────────
1597
+ // M5 / w22.7 (feat-20260518-pbr-direct-lighting-mvp): the hard-coded
1598
+ // `[HANDLE_CUBE, HANDLE_TRIANGLE]` loop has moved to AssetRegistry —
1599
+ // `configureGpuDevice` (already invoked above the buildReadyWebGPU call)
1600
+ // replays every registered MeshAsset (incl. BUILTIN_CUBE / HANDLE_TRIANGLE
1601
+ // seeded by the constructor). Step 3 here only seeds the legacy
1602
+ // `pipelineState.meshes` Map for backward compat with existing fixtures
1603
+ // (render-system-record-instances.browser.test.ts etc.); render-system-record
1604
+ // queries `gpuStore.getMeshGpuHandles` first and falls back to this Map only
1605
+ // for the builtins — which keeps user-mesh registrations flowing through the
1606
+ // store's pull path (`ensureResident`) without a createRenderer rebuild
1607
+ // (AGENTS.md "Demo failures route to engine fixes").
1608
+ const queue = rhiDevice.queue;
1609
+ const meshHandles = new Map<number, MeshGpuHandles>();
1610
+ // feat-20260520-2d-sprite-layer-mvp post-merge fix: HANDLE_QUAD joins the
1611
+ // builtin upload loop so sprite materials referencing the unit-quad
1612
+ // through MeshFilter.assetHandle resolve to GPU vertex/index buffers
1613
+ // (record stage's `gpuStore.getMeshGpuHandles(handle)` returns the
1614
+ // GPU pair instead of firing `asset-not-registered`). Builtins are seeded
1615
+ // here (createRenderer step-3 direct upload), not via the store pull path
1616
+ // (D-1), so the upload chain ends here. Same explicit-upload intent as the
1617
+ // pre-existing HANDLE_CUBE / HANDLE_TRIANGLE pair (charter P5 consistent
1618
+ // abstraction).
1619
+ // feat-20260527-sprite-nineslice M2 / w12: HANDLE_NINESLICE_QUAD joins
1620
+ // the explicit-upload list so the 16-vertex / 54-index 9-slice quad has
1621
+ // GPU-resident vertex / index buffers when render-system-record routes
1622
+ // sprite + non-zero-slices entities to it (D-2). Closes feat-20260527
1623
+ // round-1 issue #2 dangling-slot root cause: the prior implement only
1624
+ // added the skip-list entry without the upload, leaving a registered
1625
+ // handle whose `pipelineState.meshes.get(id)` returned undefined.
1626
+ for (const handle of [
1627
+ HANDLE_CUBE,
1628
+ HANDLE_TRIANGLE,
1629
+ HANDLE_QUAD,
1630
+ HANDLE_SPHERE,
1631
+ HANDLE_NINESLICE_QUAD,
1632
+ ]) {
1633
+ const id = handleSlot(handle);
1634
+ const gpu = gpuStore.getMeshGpuHandles(handle);
1635
+ if (gpu !== undefined) {
1636
+ meshHandles.set(id, gpu as MeshGpuHandles);
1637
+ continue;
1638
+ }
1639
+ // Fallback: AssetRegistry was constructed without a wired device
1640
+ // (e.g. test fixtures bypass `configureGpuDevice`); fall back to the
1641
+ // legacy direct-upload path so the BUILTIN seed retains GPU buffers
1642
+ // even on the unwired path.
1643
+ // M5 / w19: HANDLE_CUBE / HANDLE_TRIANGLE are now
1644
+ // `Handle<'MeshAsset','shared'>` (the unified SSOT brand from
1645
+ // `@forgeax/engine-types` per feat-20260517-handle-type-unify) which
1646
+ // matches AssetRegistry.get<MeshAsset>'s parameter type
1647
+ // `Handle<'MeshAsset', 'shared'>` directly — no cross-brand cast.
1648
+ // feat-20260614 M8 (D-15): the builtin seed handles are builtin-tier
1649
+ // (slot < BUILTIN_BASE); resolve their PODs directly from the process-
1650
+ // static BuiltinAssetRegistry (no World needed at boot).
1651
+ const asset = BuiltinAssetRegistry.resolve(handle);
1652
+ if (asset === null) continue;
1653
+ if (asset.kind !== 'mesh') continue;
1654
+ // Builtins always carry indices; the `?? 0` keeps typecheck happy now that
1655
+ // MeshAsset.indices is optional, and supports a vertex-only fallback mesh
1656
+ // if one is ever added here (indexBuffer: null path below).
1657
+ const meshIndices = asset.indices;
1658
+ const vertexBytes = asset.vertices.byteLength;
1659
+ const indexBytesUnpadded = meshIndices?.byteLength ?? 0;
1660
+ const indexBytes = ((indexBytesUnpadded + 3) >> 2) << 2; // round up to multiple of 4
1661
+ const vboLabel =
1662
+ handle === HANDLE_NINESLICE_QUAD
1663
+ ? 'nineslice-quad-vbo'
1664
+ : handle === HANDLE_SPHERE
1665
+ ? 'sphere-vbo'
1666
+ : handle === HANDLE_CUBE
1667
+ ? 'cube-vbo'
1668
+ : handle === HANDLE_QUAD
1669
+ ? 'quad-vbo'
1670
+ : 'triangle-vbo';
1671
+ const iboLabel =
1672
+ handle === HANDLE_NINESLICE_QUAD
1673
+ ? 'nineslice-quad-ibo'
1674
+ : handle === HANDLE_SPHERE
1675
+ ? 'sphere-ibo'
1676
+ : handle === HANDLE_CUBE
1677
+ ? 'cube-ibo'
1678
+ : handle === HANDLE_QUAD
1679
+ ? 'quad-ibo'
1680
+ : 'triangle-ibo';
1681
+ const vboResult = runShimSyncStep(
1682
+ () =>
1683
+ rhiDevice.createBuffer({
1684
+ label: vboLabel,
1685
+ size: vertexBytes,
1686
+ usage: GPU_BUFFER_USAGE_VERTEX | GPU_BUFFER_USAGE_COPY_DST,
1687
+ mappedAtCreation: false,
1688
+ }),
1689
+ 'webgpu-runtime-error',
1690
+ 'createBuffer (vbo) succeeded',
1691
+ 'check device.limits.maxBufferSize and remaining VRAM',
1692
+ );
1693
+ if (!vboResult.ok) throw vboResult.error;
1694
+ const vboWrite = runShimSyncStep(
1695
+ () => queue.writeBuffer(vboResult.value, 0, asset.vertices),
1696
+ 'queue-write-buffer-out-of-bounds',
1697
+ 'queue.writeBuffer (vbo) succeeded',
1698
+ 'verify offset alignment and bounds against buffer.size',
1699
+ );
1700
+ if (!vboWrite.ok) throw vboWrite.error;
1701
+ // Vertex-only mesh: skip the index buffer (indexBuffer: null below). The
1702
+ // indexed path is unchanged byte-for-byte when `meshIndices` is present.
1703
+ let ibo: Buffer | null = null;
1704
+ if (meshIndices !== undefined) {
1705
+ const iboResult = runShimSyncStep(
1706
+ () =>
1707
+ rhiDevice.createBuffer({
1708
+ label: iboLabel,
1709
+ size: indexBytes,
1710
+ usage: GPU_BUFFER_USAGE_INDEX | GPU_BUFFER_USAGE_COPY_DST,
1711
+ mappedAtCreation: false,
1712
+ }),
1713
+ 'webgpu-runtime-error',
1714
+ 'createBuffer (ibo) succeeded',
1715
+ 'check device.limits.maxBufferSize and remaining VRAM',
1716
+ );
1717
+ if (!iboResult.ok) throw iboResult.error;
1718
+ const indexBuffer = iboResult.value;
1719
+ ibo = indexBuffer;
1720
+ // Pad the source view up to indexBytes (multiple of 4) so writeBuffer's
1721
+ // 4-byte alignment requirement is satisfied even when the index byte
1722
+ // count itself is not a multiple of 4 (e.g. triangle = 6 bytes).
1723
+ const indexSrc = new Uint8Array(indexBytes);
1724
+ indexSrc.set(new Uint8Array(meshIndices.buffer, meshIndices.byteOffset, indexBytesUnpadded));
1725
+ const iboWrite = runShimSyncStep(
1726
+ () => queue.writeBuffer(indexBuffer, 0, indexSrc),
1727
+ 'queue-write-buffer-out-of-bounds',
1728
+ 'queue.writeBuffer (ibo) succeeded',
1729
+ 'verify offset alignment and bounds against buffer.size',
1730
+ );
1731
+ if (!iboWrite.ok) throw iboWrite.error;
1732
+ }
1733
+ // M-3 / w12: builtin direct-upload fallback path mirrors the gpuStore
1734
+ // mesh entry shape -- raw RHI Buffer handles are wrapped in GpuBuffer so
1735
+ // the dispose chain (M-5) can walk them via `.destroy()`.
1736
+ meshHandles.set(id, {
1737
+ vertexBuffer: new GpuBuffer(rhiDevice, vboResult.value),
1738
+ indexBuffer: ibo === null ? null : new GpuBuffer(rhiDevice, ibo),
1739
+ vboBytes: vertexBytes,
1740
+ iboBytes: meshIndices === undefined ? 0 : indexBytes,
1741
+ indexCount: meshIndices?.length ?? 0,
1742
+ indexFormat: meshIndices instanceof Uint32Array ? 'uint32' : 'uint16',
1743
+ // bug-20260519: BUILTIN_CUBE / BUILTIN_TRIANGLE migrated to 12F
1744
+ // (position + normal + uv + tangent), so the fallback literal mirrors
1745
+ // every other mesh upload site.
1746
+ layout: '12F',
1747
+ layoutProjection: deriveVertexLayoutProjection(asset.attributes),
1748
+ // BUILTIN_CUBE / BUILTIN_TRIANGLE are single-UV (set 0 only).
1749
+ uvSetCount: 1,
1750
+ vertexCount:
1751
+ asset.vertices.length /
1752
+ (deriveVertexLayoutProjection(asset.attributes).arrayStride /
1753
+ Float32Array.BYTES_PER_ELEMENT),
1754
+ indexed: meshIndices !== undefined,
1755
+ topology: asset.submeshes[0]?.topology ?? 'triangle-list',
1756
+ submeshes: asset.submeshes,
1757
+ });
1758
+ }
1759
+
1760
+ // ── Step 3.b: per-pipeline shared UBO / SSBO buffers ──────────────────────
1761
+ // The 3 BindGroups (view / material / mesh) the pbr.wgsl pipeline expects
1762
+ // are built per draw(world) frame in render-system.ts; the underlying
1763
+ // buffers are pipeline-scoped (allocated once, queue.writeBuffer-updated
1764
+ // per frame). The mesh storage path uses a runtime-sized
1765
+ // array<Mesh> bound up to instanceCount * 64 B per draw
1766
+ // (feat-20260511-tetris-retro-followups M4 D-P9); the buffer is initially
1767
+ // sized for INITIAL_MESH_SSBO_SLOT_COUNT = 1024 slots and grows on demand
1768
+ // via `meshSsboController.growMeshSsbo(neededSlots)` (M2 / T-M2-05;
1769
+ // pow2 doubling, ceiling = device.limits.maxStorageBufferBindingSize).
1770
+ const viewUboResult = runShimSyncStep(
1771
+ () =>
1772
+ rhiDevice.createBuffer({
1773
+ label: 'pbr-view-ubo',
1774
+ size: VIEW_UBO_BYTES,
1775
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1776
+ mappedAtCreation: false,
1777
+ }),
1778
+ 'webgpu-runtime-error',
1779
+ 'createBuffer (view ubo) succeeded',
1780
+ 'check device.limits.maxUniformBufferBindingSize',
1781
+ );
1782
+ if (!viewUboResult.ok) throw viewUboResult.error;
1783
+
1784
+ const pointsLinesViewBufferResult = runShimSyncStep(
1785
+ () =>
1786
+ rhiDevice.createBuffer({
1787
+ label: 'points-lines-view-ubo',
1788
+ size: POINTS_LINES_VIEW_BUFFER_SIZE,
1789
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1790
+ mappedAtCreation: false,
1791
+ }),
1792
+ 'webgpu-runtime-error',
1793
+ 'createBuffer (points-lines view ubo) succeeded',
1794
+ 'check device.limits.maxUniformBufferBindingSize',
1795
+ );
1796
+ if (!pointsLinesViewBufferResult.ok) throw pointsLinesViewBufferResult.error;
1797
+
1798
+ // feat-20260613-csm-cascaded-shadow-maps M5 / w28: per-pass cascade-index
1799
+ // UBO consumed by shadow_caster.wgsl. Stable singleton; the record stage
1800
+ // queue.writeBuffer-overwrites the fields immediately before each pass's
1801
+ // command-encoder submit, and the per-pass submits serialize host writes
1802
+ // against GPU reads.
1803
+ //
1804
+ // feat-20260625-spot-light-shadow-mapping M2 / w10 + w11 (D-1): grew 16 -> 80
1805
+ // B. First 16 B carry `index` u32 + `isSpot` u32 + 2 pad u32 (keeps the
1806
+ // mat4 16 B-aligned for WebGL2); the trailing 64 B carry `spotLightViewProj`
1807
+ // mat4x4<f32>, written per spot tile pass by recordSpotShadowPass and read by
1808
+ // the shadow_caster spot branch. Directional cascade passes leave isSpot=0
1809
+ // and ignore the matrix lanes.
1810
+ const shadowCasterCascadeUboResult = runShimSyncStep(
1811
+ () =>
1812
+ rhiDevice.createBuffer({
1813
+ label: 'shadow-caster-cascade-ubo',
1814
+ size: SHADOW_CASTER_BUFFER_SIZE,
1815
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1816
+ mappedAtCreation: false,
1817
+ }),
1818
+ 'webgpu-runtime-error',
1819
+ 'createBuffer (shadow-caster cascade ubo) succeeded',
1820
+ 'check device.limits.maxUniformBufferBindingSize',
1821
+ );
1822
+ if (!shadowCasterCascadeUboResult.ok) throw shadowCasterCascadeUboResult.error;
1823
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 / T-M2-05:
1824
+ // mesh + material buffer pair owned by `meshSsboController`; the
1825
+ // controller closes over `rhiDevice` + `internals.errorRegistry` so
1826
+ // `growMeshSsbo(neededSlots)` (called by record stage in M3) can rebuild
1827
+ // both buffers in lock-step (AC-06). Initial allocation lands at
1828
+ // `INITIAL_MESH_SSBO_SLOT_COUNT = 1024` slots (parity with the pre-feat
1829
+ // legacy literal); subsequent grow events pow2-double in one shot
1830
+ // (AC-05). MATERIAL_PER_ENTITY_STRIDE stays at 512 B for the shared
1831
+ // allocation — only slot count grows (OOS-10). Wrapper-object identity (`meshSsboState.mesh` /
1832
+ // `meshSsboState.material`) is stable across grow so PipelineState
1833
+ // fields below reference these wrappers once and survive grow events
1834
+ // (research §F8 R1).
1835
+ // M5 / T-M5-02 (P0 fix surfaced by GRID_SIZE=46 stress smoke):
1836
+ // `MeshSsboGrowDevice.createBuffer` returns a raw `Buffer`, but
1837
+ // `rhiDevice.createBuffer` returns `Result<Buffer, RhiError>` (RHI
1838
+ // explicit-failure contract). The adapter below unwraps that Result
1839
+ // before storing the buffer, preventing a Result wrapper from reaching
1840
+ // queue.submit as
1841
+ // "no overload matched for writeBuffer: object is not of the correct
1842
+ // interface type" once the 1024-slot grow path actually fired (a
1843
+ // workload >= 1024 entities; M5 culling stress is the first to land).
1844
+ // Unit tests at M2-02/03 mock createBuffer to return `Buffer` directly,
1845
+ // so they never caught the mismatch. Adapter unwraps the Result here
1846
+ // (errors bubble to the surrounding `runShimSyncStep` / record-stage
1847
+ // outer try/catch as `webgpu-runtime-error`).
1848
+ const meshSsboGrowDeviceAdapter: MeshSsboGrowDevice = {
1849
+ limits: rhiDevice.limits,
1850
+ createBuffer: (descriptor) => {
1851
+ const result = rhiDevice.createBuffer(descriptor);
1852
+ if (!result.ok) throw result.error;
1853
+ return result.value;
1854
+ },
1855
+ };
1856
+ const meshSsboController = createMeshSsboGrowController({
1857
+ device: meshSsboGrowDeviceAdapter,
1858
+ errorRegistry: errorRegistry,
1859
+ initialSlotCount: INITIAL_MESH_SSBO_SLOT_COUNT,
1860
+ perEntityStride: MATERIAL_PER_ENTITY_STRIDE,
1861
+ // bug-20260610: WebGL2 fallback uses uniform-buffer for the mesh array
1862
+ // (matches the STORAGE_BUFFER_AVAILABLE=false shader variant which
1863
+ // declares `var<uniform> meshes : array<Mesh, 128>` instead of
1864
+ // `var<storage> meshes : array<Mesh>`).
1865
+ meshUsage: storageBufferCapable
1866
+ ? GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST
1867
+ : GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1868
+ materialUsage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1869
+ });
1870
+ const initialBuildResult = runShimSyncStep<true>(
1871
+ () => {
1872
+ meshSsboController.initialBuild();
1873
+ return ok(true as const);
1874
+ },
1875
+ 'webgpu-runtime-error',
1876
+ 'createBuffer (mesh ssbo + material ubo initial build) succeeded',
1877
+ 'check device.limits.maxStorageBufferBindingSize / maxUniformBufferBindingSize',
1878
+ );
1879
+ if (!initialBuildResult.ok) throw initialBuildResult.error;
1880
+ const meshSsboState = meshSsboController.state;
1881
+ requireMeshSsboBuffer(meshSsboState.mesh);
1882
+ requireMeshSsboBuffer(meshSsboState.material);
1883
+ // Expose the grow hook + state on internals (via the setGrowMeshSsboHook
1884
+ // callback) so M3's record stage `ensureMeshSsboCapacity` can call it
1885
+ // (T-M2-05 acceptanceCheck #3 + T-M3-04 wiring: `growMeshSsbo` mention
1886
+ // count >= 2 in createRenderer.ts — definition + this exposure).
1887
+ setGrowMeshSsboHook(meshSsboController.growMeshSsbo, meshSsboController.state);
1888
+
1889
+ // feat-20260513-instanced-mesh M3 (T-M3-2): identity-mat4 fallback
1890
+ // storage buffer. Single 64-byte storage buffer carrying one identity
1891
+ // mat4 column-major. Renderables without an `Instances` component bind
1892
+ // this buffer at @group(3) so the shader's
1893
+ // `instances_local[instance_index]` lookup at idx=0 returns I (no
1894
+ // additional transform), giving the consistent-abstraction single
1895
+ // branch (charter prop 5; plan D-7 fallback semantics). The buffer is
1896
+ // seeded once at pipeline creation; the record-stage never rewrites
1897
+ // it.
1898
+ // bug-20260610: WebGL2 fallback uses uniform-buffer for instances
1899
+ // (the shader's `STORAGE_BUFFER_AVAILABLE=false` variant declares
1900
+ // `var<uniform> instances : array<InstanceData, 128>`).
1901
+ //
1902
+ // fix: the shared identity instance buffer is bound at @group(3) for EVERY
1903
+ // pipeline variant that lacks per-entity Instances, including the sprite
1904
+ // `PER_INSTANCE_REGION=true` variant whose InstanceData is 80 bytes
1905
+ // (mat4 64B + region vec4 16B) rather than the base PBR 64B (mat4 only).
1906
+ // The transparent split pass reaches that 80B-min-binding pipeline as soon
1907
+ // as transparent geometry exists (e.g. a glTF alphaMode=BLEND material),
1908
+ // and a 64B buffer fails WebGPU's min-binding-size validation
1909
+ // ("requires a buffer binding which is at least 80 bytes"), invalidating the
1910
+ // frame's command buffer. Size the shared identity buffer to the largest
1911
+ // InstanceData variant (80B) so it satisfies both the 64B PBR and 80B
1912
+ // sprite-region layouts; the extra 16 region bytes stay zeroed (a valid
1913
+ // empty UV region) and the PBR shader never reads past its mat4.
1914
+ const IDENTITY_INSTANCE_BYTES = storageBufferCapable ? 128 : 80 * 128;
1915
+ const identityInstanceResult = runShimSyncStep(
1916
+ () =>
1917
+ rhiDevice.createBuffer({
1918
+ label: 'pbr-identity-instance-ssbo',
1919
+ size: IDENTITY_INSTANCE_BYTES,
1920
+ usage: storageBufferCapable
1921
+ ? GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST
1922
+ : GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
1923
+ mappedAtCreation: false,
1924
+ }),
1925
+ 'webgpu-runtime-error',
1926
+ 'createBuffer (identity instance ssbo) succeeded',
1927
+ 'check device.limits.maxStorageBufferBindingSize',
1928
+ );
1929
+ if (!identityInstanceResult.ok) throw identityInstanceResult.error;
1930
+ // Seed the identity mat4 (column-major; diagonal 1s). Storage-backed
1931
+ // InstanceData carries current + previous transforms, so mirror the mat4
1932
+ // into both slots; uniform-backed variants consume only the first slot.
1933
+ const identityPayload = new Float32Array(storageBufferCapable ? 32 : 16);
1934
+ for (const base of storageBufferCapable ? [0, 16] : [0]) {
1935
+ identityPayload[base] = 1;
1936
+ identityPayload[base + 5] = 1;
1937
+ identityPayload[base + 10] = 1;
1938
+ identityPayload[base + 15] = 1;
1939
+ }
1940
+ const identityWrite = runShimSyncStep(
1941
+ () => queue.writeBuffer(identityInstanceResult.value, 0, identityPayload),
1942
+ 'queue-write-buffer-out-of-bounds',
1943
+ 'queue.writeBuffer (identity instance ssbo) succeeded',
1944
+ 'verify offset alignment and bounds against buffer.size',
1945
+ );
1946
+ if (!identityWrite.ok) throw identityWrite.error;
1947
+
1948
+ // feat-20260515 M3 / T-M3-05 (research F-6 fix): default sampler + fallback
1949
+ // 1x1 white texture seed the materialBindGroup sampler / textureView
1950
+ // entries when MaterialAsset.baseColorTexture is undefined. Default
1951
+ // sampler matches research F-5 SSOT three-source convergence (linear
1952
+ // min/mag/mipmap; repeat addressMode); the fallback texture is a 1x1
1953
+ // RGBA8 white pixel so unlit / standard materials with no texture
1954
+ // multiply by 1 in M5 once UV-driven sampling lands.
1955
+ const defaultSamplerResult = runShimSyncStep(
1956
+ () =>
1957
+ rhiDevice.createSampler({
1958
+ label: 'default-sampler',
1959
+ magFilter: 'linear',
1960
+ minFilter: 'linear',
1961
+ mipmapFilter: 'linear',
1962
+ addressModeU: 'repeat',
1963
+ addressModeV: 'repeat',
1964
+ }),
1965
+ 'webgpu-runtime-error',
1966
+ 'createSampler (default) succeeded',
1967
+ 'check device.limits.maxSamplersPerShaderStage',
1968
+ );
1969
+ if (!defaultSamplerResult.ok) throw defaultSamplerResult.error;
1970
+
1971
+ const nearestSamplerResult = runShimSyncStep(
1972
+ () =>
1973
+ rhiDevice.createSampler({
1974
+ label: 'nearest-sampler',
1975
+ magFilter: 'nearest',
1976
+ minFilter: 'nearest',
1977
+ mipmapFilter: 'nearest',
1978
+ addressModeU: 'clamp-to-edge',
1979
+ addressModeV: 'clamp-to-edge',
1980
+ }),
1981
+ 'webgpu-runtime-error',
1982
+ 'createSampler (nearest) succeeded',
1983
+ 'check device.limits.maxSamplersPerShaderStage',
1984
+ );
1985
+ if (!nearestSamplerResult.ok) throw nearestSamplerResult.error;
1986
+
1987
+ // feat-20260520-directional-light-shadow-mapping M1c / w8 + M2 / w14:
1988
+ // shadow comparison sampler — clamp-to-edge, linear filter, compare:'less'.
1989
+ // Used for shadow map sampling in M2/M3 and shadow depth pass. comparison
1990
+ // sampler enables textureSampleCompareLevel in pbr.wgsl's evalDirectional().
1991
+ // Created once at pipeline build time; reused across frames.
1992
+ const shadowSamplerResult = runShimSyncStep(
1993
+ () =>
1994
+ rhiDevice.createSampler({
1995
+ label: 'shadow-sampler',
1996
+ magFilter: 'linear',
1997
+ minFilter: 'linear',
1998
+ mipmapFilter: 'linear',
1999
+ addressModeU: 'clamp-to-edge',
2000
+ addressModeV: 'clamp-to-edge',
2001
+ compare: 'less',
2002
+ }),
2003
+ 'webgpu-runtime-error',
2004
+ 'createSampler (shadow) succeeded',
2005
+ 'check device.limits.maxSamplersPerShaderStage',
2006
+ );
2007
+ if (!shadowSamplerResult.ok) throw shadowSamplerResult.error;
2008
+
2009
+ const fallbackTextureResult = runShimSyncStep(
2010
+ () =>
2011
+ rhiDevice.createTexture({
2012
+ label: 'fallback-white-1x1',
2013
+ size: { width: 1, height: 1, depthOrArrayLayers: 1 },
2014
+ mipLevelCount: 1,
2015
+ sampleCount: 1,
2016
+ dimension: '2d',
2017
+ format: 'rgba8unorm',
2018
+ usage: GPU_TEXTURE_USAGE_TEXTURE_BINDING | GPU_TEXTURE_USAGE_COPY_DST,
2019
+ viewFormats: [],
2020
+ textureBindingViewDimension: undefined,
2021
+ }),
2022
+ 'webgpu-runtime-error',
2023
+ 'createTexture (fallback white) succeeded',
2024
+ 'check device.limits.maxTextureDimension2D',
2025
+ );
2026
+ if (!fallbackTextureResult.ok) throw fallbackTextureResult.error;
2027
+
2028
+ // The fallback white pixel is a 1x1 RGBA8 sample. The forgeax rhi shim
2029
+ // enforces `bytesPerRow % 256 === 0` regardless of row count (spec
2030
+ // normative for multi-row copies, but the shim is uniformly strict).
2031
+ // Pad the source buffer to a 256-byte row stride; the upload still
2032
+ // writes only 1x1 because the destination size is 1x1.
2033
+ const fallbackPixel = new Uint8Array(FALLBACK_BYTES_PER_ROW);
2034
+ fallbackPixel[0] = 255;
2035
+ fallbackPixel[1] = 255;
2036
+ fallbackPixel[2] = 255;
2037
+ fallbackPixel[3] = 255;
2038
+ const fallbackWriteResult = runShimSyncStep(
2039
+ () =>
2040
+ queue.writeTexture(
2041
+ {
2042
+ texture: fallbackTextureResult.value,
2043
+ mipLevel: 0,
2044
+ origin: { x: 0, y: 0, z: 0 },
2045
+ },
2046
+ fallbackPixel,
2047
+ { offset: 0, bytesPerRow: FALLBACK_BYTES_PER_ROW, rowsPerImage: 1 },
2048
+ { width: 1, height: 1, depthOrArrayLayers: 1 },
2049
+ ),
2050
+ 'queue-write-buffer-out-of-bounds',
2051
+ 'queue.writeTexture (fallback white pixel) succeeded',
2052
+ 'verify bytesPerRow / rowsPerImage alignment',
2053
+ );
2054
+ if (!fallbackWriteResult.ok) throw fallbackWriteResult.error;
2055
+
2056
+ const fallbackTextureViewResult = runShimSyncStep(
2057
+ () =>
2058
+ rhiDevice.createTextureView(fallbackTextureResult.value, {
2059
+ label: 'fallback-white-view',
2060
+ dimension: '2d',
2061
+ }),
2062
+ 'webgpu-runtime-error',
2063
+ 'createTextureView (fallback white) succeeded',
2064
+ 'check fallback texture format / usage',
2065
+ );
2066
+ if (!fallbackTextureViewResult.ok) throw fallbackTextureViewResult.error;
2067
+
2068
+ const extendedLightingFallback = createExtendedLightingFallbackResources(
2069
+ rhiDevice,
2070
+ extendedLightingShaderAvailable,
2071
+ );
2072
+
2073
+ // Normal-slot fallback: 1x1 RGBA8 (128, 128, 255, 255). pbr.wgsl decodes
2074
+ // sample.rg * 2 - 1 + z = sqrt(1 - x^2 - y^2), so RG=(128,128)=0.5 maps
2075
+ // to tangent (0, 0, 1) -- zero perturbation when normalTexture is absent.
2076
+ // Cannot share the white fallback (255,255,...) because RG=(255,255)=1.0
2077
+ // gives tangent.xy=(1,1) -> 1 - 2 = -1 under the sqrt -> NaN (saturate
2078
+ // clamps to 0 z=0, still wrong). White-on-missing semantics for baseColor
2079
+ // / metallicRoughness slots is preserved by keeping those bound to the
2080
+ // shared fallbackTextureView; only the normal slot uses this view.
2081
+ const fallbackNormalTextureResult = runShimSyncStep(
2082
+ () =>
2083
+ rhiDevice.createTexture({
2084
+ label: 'fallback-normal-1x1',
2085
+ size: { width: 1, height: 1, depthOrArrayLayers: 1 },
2086
+ mipLevelCount: 1,
2087
+ sampleCount: 1,
2088
+ dimension: '2d',
2089
+ format: 'rgba8unorm',
2090
+ usage: GPU_TEXTURE_USAGE_TEXTURE_BINDING | GPU_TEXTURE_USAGE_COPY_DST,
2091
+ viewFormats: [],
2092
+ textureBindingViewDimension: undefined,
2093
+ }),
2094
+ 'webgpu-runtime-error',
2095
+ 'createTexture (fallback normal) succeeded',
2096
+ 'check device.limits.maxTextureDimension2D',
2097
+ );
2098
+ if (!fallbackNormalTextureResult.ok) throw fallbackNormalTextureResult.error;
2099
+
2100
+ const fallbackNormalPixel = new Uint8Array(FALLBACK_BYTES_PER_ROW);
2101
+ fallbackNormalPixel[0] = 128;
2102
+ fallbackNormalPixel[1] = 128;
2103
+ fallbackNormalPixel[2] = 255;
2104
+ fallbackNormalPixel[3] = 255;
2105
+ const fallbackNormalWriteResult = runShimSyncStep(
2106
+ () =>
2107
+ queue.writeTexture(
2108
+ {
2109
+ texture: fallbackNormalTextureResult.value,
2110
+ mipLevel: 0,
2111
+ origin: { x: 0, y: 0, z: 0 },
2112
+ },
2113
+ fallbackNormalPixel,
2114
+ { offset: 0, bytesPerRow: FALLBACK_BYTES_PER_ROW, rowsPerImage: 1 },
2115
+ { width: 1, height: 1, depthOrArrayLayers: 1 },
2116
+ ),
2117
+ 'queue-write-buffer-out-of-bounds',
2118
+ 'queue.writeTexture (fallback normal pixel) succeeded',
2119
+ 'verify bytesPerRow / rowsPerImage alignment',
2120
+ );
2121
+ if (!fallbackNormalWriteResult.ok) throw fallbackNormalWriteResult.error;
2122
+
2123
+ const fallbackNormalTextureViewResult = runShimSyncStep(
2124
+ () =>
2125
+ rhiDevice.createTextureView(fallbackNormalTextureResult.value, {
2126
+ label: 'fallback-normal-view',
2127
+ dimension: '2d',
2128
+ }),
2129
+ 'webgpu-runtime-error',
2130
+ 'createTextureView (fallback normal) succeeded',
2131
+ 'check fallback normal texture format / usage',
2132
+ );
2133
+ if (!fallbackNormalTextureViewResult.ok) throw fallbackNormalTextureViewResult.error;
2134
+
2135
+ // feat-20260520-directional-light-shadow-mapping M2 / w14 (D-1):
2136
+ // shadowFallbackTextureView is a 1x1 depth32float fallback bound at
2137
+ // viewBindGroup entry 3 when no shadow RT exists (castShadow:false
2138
+ // or allocation failed). Cleared to 1.0 (far plane) via a minimal
2139
+ // render pass so textureSampleCompareLevel always returns 1.0 (fully lit).
2140
+ // Uses RENDER_ATTACHMENT for the clear pass + TEXTURE_BINDING for sampling.
2141
+ const shadowFallbackTexResult = runShimSyncStep(
2142
+ () =>
2143
+ rhiDevice.createTexture({
2144
+ label: 'shadow-fallback-depth-1x1',
2145
+ size: { width: 1, height: 1, depthOrArrayLayers: 1 },
2146
+ mipLevelCount: 1,
2147
+ sampleCount: 1,
2148
+ dimension: '2d',
2149
+ format: 'depth32float',
2150
+ usage: GPU_TEXTURE_USAGE_RENDER_ATTACHMENT_AND_TEXTURE_BINDING,
2151
+ viewFormats: [],
2152
+ textureBindingViewDimension: undefined,
2153
+ }),
2154
+ 'webgpu-runtime-error',
2155
+ 'createTexture (shadow fallback depth) succeeded',
2156
+ 'check device.limits.maxTextureDimension2D',
2157
+ );
2158
+ if (!shadowFallbackTexResult.ok) throw shadowFallbackTexResult.error;
2159
+
2160
+ const shadowFallbackViewResult = runShimSyncStep(
2161
+ () =>
2162
+ rhiDevice.createTextureView(shadowFallbackTexResult.value, {
2163
+ label: 'shadow-fallback-depth-view',
2164
+ dimension: '2d',
2165
+ }),
2166
+ 'webgpu-runtime-error',
2167
+ 'createTextureView (shadow fallback depth) succeeded',
2168
+ 'check shadow fallback texture format / usage',
2169
+ );
2170
+ if (!shadowFallbackViewResult.ok) throw shadowFallbackViewResult.error;
2171
+
2172
+ // Clear the 1x1 depth fallback to 1.0 (far plane) via a 1-pixel render pass.
2173
+ const shadowFallbackClearEncResult = rhiDevice.createCommandEncoder({
2174
+ label: 'shadow-fallback-clear-encoder',
2175
+ });
2176
+ if (!shadowFallbackClearEncResult.ok) throw shadowFallbackClearEncResult.error;
2177
+ const shadowFallbackPass = shadowFallbackClearEncResult.value.beginRenderPass({
2178
+ colorAttachments: [],
2179
+ depthStencilAttachment: {
2180
+ view: shadowFallbackViewResult.value,
2181
+ depthClearValue: 1,
2182
+ depthLoadOp: 'clear',
2183
+ depthStoreOp: 'store',
2184
+ },
2185
+ } as never);
2186
+ shadowFallbackPass.end();
2187
+ const shadowFallbackClearFinish = shadowFallbackClearEncResult.value.finish();
2188
+ if (!shadowFallbackClearFinish.ok) throw shadowFallbackClearFinish.error;
2189
+ const shadowFallbackClearSubmit = queue.submit([shadowFallbackClearFinish.value]);
2190
+ if (!shadowFallbackClearSubmit.ok) throw shadowFallbackClearSubmit.error;
2191
+
2192
+ // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: 1x1x6
2193
+ // depth32float cube_array fallback bound at viewBindGroup entry 5 when no
2194
+ // PointLightShadow snapshots are active. dimension: '2d' with
2195
+ // depthOrArrayLayers: 6 produces a 6-layer 2D array that
2196
+ // `dimension: 'cube-array'` views can target (one cube, layers=1). The
2197
+ // texture is cleared to depth=1.0 (far plane) so
2198
+ // textureSampleCompareLevel always returns 1.0 (fully lit) regardless of
2199
+ // the depthRef the shader passes. AC-09 zero-allocation invariant
2200
+ // preserved: the real cube_array atlas in ShadowAtlas is still
2201
+ // lazy-allocated (the fallback is always created, but it only takes 24
2202
+ // bytes of GPU memory).
2203
+ const shadowAtlasFallbackTexResult = runShimSyncStep(
2204
+ () =>
2205
+ rhiDevice.createTexture({
2206
+ label: 'shadow-atlas-fallback-cube-1x1',
2207
+ size: { width: 1, height: 1, depthOrArrayLayers: 6 },
2208
+ mipLevelCount: 1,
2209
+ sampleCount: 1,
2210
+ dimension: '2d',
2211
+ format: 'depth32float',
2212
+ usage: GPU_TEXTURE_USAGE_RENDER_ATTACHMENT_AND_TEXTURE_BINDING,
2213
+ viewFormats: [],
2214
+ textureBindingViewDimension: 'cube',
2215
+ }),
2216
+ 'webgpu-runtime-error',
2217
+ 'createTexture (shadow atlas fallback cube) succeeded',
2218
+ 'check device.limits.maxTextureDimension2D and cube-array support',
2219
+ );
2220
+ if (!shadowAtlasFallbackTexResult.ok) throw shadowAtlasFallbackTexResult.error;
2221
+
2222
+ const shadowAtlasFallbackViewResult = runShimSyncStep(
2223
+ () =>
2224
+ rhiDevice.createTextureView(shadowAtlasFallbackTexResult.value, {
2225
+ label: 'shadow-atlas-fallback-cube-array-view',
2226
+ dimension: 'cube-array',
2227
+ aspect: 'depth-only',
2228
+ baseArrayLayer: 0,
2229
+ arrayLayerCount: 6,
2230
+ baseMipLevel: 0,
2231
+ mipLevelCount: 1,
2232
+ }),
2233
+ 'webgpu-runtime-error',
2234
+ 'createTextureView (shadow atlas fallback cube-array) succeeded',
2235
+ 'check shadow atlas fallback texture format / usage / dimension',
2236
+ );
2237
+ if (!shadowAtlasFallbackViewResult.ok) throw shadowAtlasFallbackViewResult.error;
2238
+
2239
+ // Clear all 6 fallback faces to 1.0 (far plane). One pass per face
2240
+ // (WebGPU forbids cube views as render-pass attachments; per-face 2D view
2241
+ // is required).
2242
+ for (let face = 0; face < 6; face++) {
2243
+ const faceViewRes = runShimSyncStep(
2244
+ () =>
2245
+ rhiDevice.createTextureView(shadowAtlasFallbackTexResult.value, {
2246
+ label: `shadow-atlas-fallback-face-${face}`,
2247
+ dimension: '2d',
2248
+ aspect: 'depth-only',
2249
+ baseArrayLayer: face,
2250
+ arrayLayerCount: 1,
2251
+ baseMipLevel: 0,
2252
+ mipLevelCount: 1,
2253
+ }),
2254
+ 'webgpu-runtime-error',
2255
+ `createTextureView (shadow atlas fallback face ${face}) succeeded`,
2256
+ 'check shadow atlas fallback texture format / usage',
2257
+ );
2258
+ if (!faceViewRes.ok) throw faceViewRes.error;
2259
+ const encRes = rhiDevice.createCommandEncoder({
2260
+ label: `shadow-atlas-fallback-clear-encoder-face-${face}`,
2261
+ });
2262
+ if (!encRes.ok) throw encRes.error;
2263
+ const pass = encRes.value.beginRenderPass({
2264
+ colorAttachments: [],
2265
+ depthStencilAttachment: {
2266
+ view: faceViewRes.value,
2267
+ depthClearValue: 1,
2268
+ depthLoadOp: 'clear',
2269
+ depthStoreOp: 'store',
2270
+ },
2271
+ } as never);
2272
+ pass.end();
2273
+ const finRes = encRes.value.finish();
2274
+ if (!finRes.ok) throw finRes.error;
2275
+ const subRes = queue.submit([finRes.value]);
2276
+ if (!subRes.ok) throw subRes.error;
2277
+ }
2278
+
2279
+ // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: shadowParams
2280
+ // uniform buffer = `array<vec4<f32>, 4>` (4 lanes x 16 B = 64 B). One
2281
+ // lane per PointLightShadow slot (cap = 4). Each lane stores
2282
+ // `(near, far, 1/(far-near), 0)` for depth-ref reconstruction in
2283
+ // lighting-punctual.wgsl evalPointShadowed. Written per frame in the
2284
+ // record stage from `frameState.pointShadowSnapshots`. Initial contents
2285
+ // are zero (writeBuffer at create time is implicit per spec); zero lanes
2286
+ // are safe because the WGSL sample path is gated on
2287
+ // `PointLight.shadowAtlasLayer >= 0` -- a non-shadow-casting light
2288
+ // cannot read its lane.
2289
+ const SHADOW_PARAMS_BYTES = 4 * 16;
2290
+ const shadowParamsBufferResult = runShimSyncStep(
2291
+ () =>
2292
+ rhiDevice.createBuffer({
2293
+ label: 'shadow-params-ubo',
2294
+ size: SHADOW_PARAMS_BYTES,
2295
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
2296
+ mappedAtCreation: false,
2297
+ }),
2298
+ 'webgpu-runtime-error',
2299
+ 'createBuffer (shadow params UBO) succeeded',
2300
+ 'check device.limits.maxUniformBufferBindingSize (need >= 64)',
2301
+ );
2302
+ if (!shadowParamsBufferResult.ok) throw shadowParamsBufferResult.error;
2303
+ // Zero-initialize the buffer so the no-shadow path reads deterministic
2304
+ // zeros (writeBuffer with 64 B of zeros).
2305
+ const SHADOW_PARAMS_ZEROES = new Uint8Array(SHADOW_PARAMS_BYTES);
2306
+ const shadowParamsZeroWriteRes = queue.writeBuffer(
2307
+ shadowParamsBufferResult.value,
2308
+ 0,
2309
+ SHADOW_PARAMS_ZEROES,
2310
+ );
2311
+ if (!shadowParamsZeroWriteRes.ok) throw shadowParamsZeroWriteRes.error;
2312
+
2313
+ // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-fallback):
2314
+ // the per-spot fragment-read perspective lightViewProj matrices no longer have
2315
+ // a standalone uniform buffer — they fold into the View UBO tail
2316
+ // (`view.spotLightViewProj`, bytes 528..784, allocated as part of
2317
+ // VIEW_UBO_BYTES = 784 above). The View UBO's createBuffer already zero-fills
2318
+ // on first writeBuffer; render-system-record writes the spot lanes inside the
2319
+ // per-frame viewPayload. Removing the dedicated buffer drops the WebGL2
2320
+ // fallback fragment uniform-buffer count from 12 back to 11.
2321
+
2322
+ // feat-20260520-skylight-ibl-cubemap M2 round-4 / t40 amend
2323
+ // (plan-strategy D-5 round-4 REVISED): allocate the fallback Skylight
2324
+ // identity resource bundle -- 1x1 all-zero rgba16float texture_cube * 2
2325
+ // (irradiance + prefilter) + 1x1 approximate rg16float brdfLut +
2326
+ // intensity=0 uniform buffer + a single linear/clamp sampler reused
2327
+ // across the three texture slots. No stand-alone BindGroupLayout /
2328
+ // BindGroup is allocated -- those roles moved into the PBR material
2329
+ // BGL factory (entries 7..13 inside @group(1)). The M4 record-stage
2330
+ // material BG assembly site feeds these resources through
2331
+ // `assembleMaterialWithSkylightEntries` when `skylightCount === 0` so
2332
+ // the active standard material path dispatches with ambient = 0 --
2333
+ // physical convergence with D-4 (charter F1: AI users writing demos do
2334
+ // not need a "is there a skylight?" branch).
2335
+ //
2336
+ // Keep the helper's narrow mockable contracts at the RHI boundary while
2337
+ // forwarding the concrete device and queue methods without a structural cast.
2338
+ let skylightFallback: SkylightFallback | null = null;
2339
+ try {
2340
+ const skylightDevice: Parameters<typeof createSkylightFallback>[0] = {
2341
+ createSampler: (descriptor) => rhiDevice.createSampler(descriptor),
2342
+ createTexture: (descriptor) => rhiDevice.createTexture(descriptor),
2343
+ createTextureView: (texture, descriptor) => rhiDevice.createTextureView(texture, descriptor),
2344
+ createBuffer: (descriptor) => rhiDevice.createBuffer(descriptor),
2345
+ };
2346
+ const skylightQueue: Parameters<typeof createSkylightFallback>[1] = {
2347
+ writeTexture: (destination, data, layout, size) =>
2348
+ queue.writeTexture(destination, data, layout, size),
2349
+ writeBuffer: (buffer, offset, data) => queue.writeBuffer(buffer, offset, data),
2350
+ };
2351
+ skylightFallback = createSkylightFallback(skylightDevice, skylightQueue);
2352
+ } catch (caught) {
2353
+ if (caught instanceof RhiError) throw caught;
2354
+ throw new RhiError({
2355
+ code: 'webgpu-runtime-error',
2356
+ expected: 'createSkylightFallback succeeded',
2357
+ hint: `verify texture_cube + uniform allocation (cause: ${
2358
+ caught instanceof Error ? caught.message : String(caught)
2359
+ })`,
2360
+ });
2361
+ }
2362
+
2363
+ // feat-20260615-pipeline-spec-ssot M2-T4: PipelineCache is the single PSO container
2364
+ // (SSOT axiom — plan D-12). Created once at boot; shared across all call sites.
2365
+ // The provider is a thin wrapper over rhiDevice that fills in vertex buffers +
2366
+ // pipeline layout on the descriptor produced by buildPipelineDescriptor.
2367
+ const pipelineCache: PipelineCache = new Map();
2368
+
2369
+ // Shader module mapping — maps spec.shader.id to compiled shader modules +
2370
+ // optional per-pass metadata (layout, entry points). Material shaders fill
2371
+ // only vertex/fragment; fullscreen-post passes add layout + entry points
2372
+ // for the boot-time SPEC_CONST pre-warm and lazy-build getOrBuildPipeline.
2373
+ const shaderModuleMap = new Map<
2374
+ string,
2375
+ {
2376
+ vertex: unknown;
2377
+ fragment: unknown;
2378
+ vertexEntryPoint?: string;
2379
+ fragmentEntryPoint?: string;
2380
+ layout?: unknown;
2381
+ label?: string;
2382
+ }
2383
+ >();
2384
+ if (unlitModule !== null)
2385
+ shaderModuleMap.set('forgeax::default-unlit', {
2386
+ vertex: unlitModule,
2387
+ fragment: unlitModule,
2388
+ });
2389
+ if (pbrModule !== null)
2390
+ shaderModuleMap.set('forgeax::default-standard-pbr', {
2391
+ vertex: pbrModule,
2392
+ fragment: pbrModule,
2393
+ });
2394
+ if (spriteModule !== null)
2395
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w14 (D-7):
2396
+ // sprite shader registers under the single canonical id `forgeax::sprite`
2397
+ // (previously double-mapped via the engine-built default-sprite id for
2398
+ // the deleted boot-time pre-warm path). The generic per-MaterialShader
2399
+ // pipeline cache reads this entry when a sprite material lands a
2400
+ // transparent pass through the LDR split.
2401
+ shaderModuleMap.set('forgeax::sprite', {
2402
+ vertex: spriteModule,
2403
+ fragment: spriteModule,
2404
+ });
2405
+ // feat-20260624-sprite-lit-shading-model-pure-2d-lighting M1' / t7:
2406
+ // sprite-lit shader registers under canonical `forgeax::sprite-lit` —
2407
+ // mirror sprite, parallel cache slot (D-11 string isolation).
2408
+ if (spriteLitModule !== null)
2409
+ shaderModuleMap.set('forgeax::sprite-lit', {
2410
+ vertex: spriteLitModule,
2411
+ fragment: spriteLitModule,
2412
+ });
2413
+ // M2-T4: fullscreen-post shader modules (tonemap + skybox) are registered
2414
+ // in their respective blocks after pipeline layout creation — layout is
2415
+ // required for the boot-time SPEC_CONST pre-warm to succeed. The entries
2416
+ // in SPEC_CONST_TABLE will be pre-warmed per-block.
2417
+
2418
+ // M2-T4: provider no longer overwrites vertex buffers — buildPipelineDescriptor
2419
+ // derives them from spec.geometry.vertexLayout (SSOT). Layout is only filled
2420
+ // when the descriptor doesn't already carry one (fullscreen-post passes set it
2421
+ // via modules.layout). Fullscreen-post module detection added for label generation.
2422
+ const pipelineDeviceProvider: PipelineDeviceProvider = {
2423
+ createRenderPipeline(descriptor: Record<string, unknown>):
2424
+ | {
2425
+ ok: true;
2426
+ value: unknown;
2427
+ }
2428
+ | { ok: false; error: unknown } {
2429
+ const d = { ...descriptor } as Record<string, unknown>;
2430
+
2431
+ // Layout: only fill if buildPipelineDescriptor did not already set it.
2432
+ if (d.layout === undefined) {
2433
+ d.layout = pipelineLayoutResult.value;
2434
+ }
2435
+
2436
+ // Sprite HDR uses fragment entry point 'fs_main_hdr'; all other
2437
+ // standard material pipelines (unlit/standard LDR/HDR, sprite LDR)
2438
+ // use 'fs_main'. The spec is business-agnostic (plan D-7) so the
2439
+ // provider resolves this by checking the fragment module reference
2440
+ // against the compiled sprite module + the attachment format.
2441
+ //
2442
+ // Also generates a label for each PSO so integration tests and GPU
2443
+ // debug captures can identify the pipeline variant. The label shape
2444
+ // is derived from the fragment module + color format (backward-
2445
+ // compatible with pre-M2 descriptor literals).
2446
+ let label = 'pbr-pipeline';
2447
+ if (d.fragment) {
2448
+ const f = { ...(d.fragment as Record<string, unknown>) } as Record<string, unknown>;
2449
+ const fragModule = f.module;
2450
+ const targets = f.targets as Array<Record<string, unknown>> | undefined;
2451
+ const isHdr = targets?.[0]?.format === HDR_COLOR_ATTACHMENT_FORMAT;
2452
+
2453
+ if (fragModule === spriteModule) {
2454
+ label = isHdr ? 'sprite-pipeline-hdr' : 'sprite-pipeline';
2455
+ if (isHdr) {
2456
+ f.entryPoint = 'fs_main_hdr';
2457
+ }
2458
+ } else if (fragModule === spriteLitModule) {
2459
+ // feat-20260624 M1' / t7: sprite-lit mirrors sprite's HDR
2460
+ // entry swap — `fs_main` (LDR clamped) vs `fs_main_hdr`
2461
+ // (HDR pass-through). Labels keep the same shape.
2462
+ label = isHdr ? 'sprite-lit-pipeline-hdr' : 'sprite-lit-pipeline';
2463
+ if (isHdr) {
2464
+ f.entryPoint = 'fs_main_hdr';
2465
+ }
2466
+ } else if (fragModule === unlitModule || fragModule === pbrModule) {
2467
+ const prefix = fragModule === unlitModule ? 'unlit' : 'standard';
2468
+ label = isHdr ? `pbr-pipeline-${prefix}-hdr` : `pbr-pipeline-${prefix}`;
2469
+ } else if (fragModule === fxaaModule) {
2470
+ label = 'fxaa-pipeline';
2471
+ } else if (fragModule === skyboxModule) {
2472
+ const msaa = d.multisample as Record<string, unknown> | undefined;
2473
+ label = msaa !== undefined ? 'skybox-pipeline-msaa' : 'skybox-pipeline';
2474
+ } else if (fragModule === bloomBrightModule) {
2475
+ label = 'bloom-bright-pipeline';
2476
+ } else if (fragModule === bloomBlurModule) {
2477
+ label = (d.label as string) ?? 'bloom-blur-h-pipeline';
2478
+ } else if (fragModule === bloomCompositeModule) {
2479
+ label = 'bloom-composite-pipeline';
2480
+ } else if (fragModule === ssaoModule) {
2481
+ label = (d.label as string) ?? 'ssao-calc-pipeline';
2482
+ }
2483
+
2484
+ d.fragment = f;
2485
+ }
2486
+ d.label = (d.label as string | undefined) ?? label;
2487
+
2488
+ return rhiDevice.createRenderPipeline(
2489
+ d as Parameters<typeof rhiDevice.createRenderPipeline>[0],
2490
+ );
2491
+ },
2492
+ };
2493
+
2494
+ // bug-20260615 fix-up: build the SPEC_CONST table with the runtime-resolved
2495
+ // LDR view format. Hard-coding `bgra8unorm-srgb` at module load made the
2496
+ // pre-warmed PSOs incompatible with the actual swap-chain format on
2497
+ // backends where `getPreferredCanvasFormat()` (or the wgpu-wasm GLES path)
2498
+ // returns `rgba8unorm` (Channel 3 + dawn-node) — every frame's whole
2499
+ // commandBuffer was being rejected. Calling `buildSpecConstTable` after
2500
+ // `selectSwapChainFormat` resolves keeps the pre-warmed key (`cacheKeyOf`)
2501
+ // and the runtime tonemap call site (uses the same `swapChainFormats.view`)
2502
+ // identical, so the cache lookup hits instead of double-building.
2503
+ // Pass both view (unlit / standard / tonemap LDR target) and storage
2504
+ // (sprite LDR target — pre-feat sprite PSO targeted swapChainFormats.storage
2505
+ // directly so the alpha-blend pass writes the raw, non-srgb view of the
2506
+ // swap-chain texture; see pipeline-spec.ts SPRITE_ATTACHMENTS jsdoc).
2507
+ const runtimeSpecConstTable = buildSpecConstTable(swapChainFormats.view);
2508
+ const runtimeLinearLdrMaterialSpecTable = buildLinearLdrMaterialSpecTable(swapChainFormats.view);
2509
+ const runtimeMaterialPrewarmTable = [
2510
+ ...runtimeSpecConstTable,
2511
+ ...runtimeLinearLdrMaterialSpecTable,
2512
+ ];
2513
+ const group2ContractForSpec = (spec: PipelineSpec): PipelineGroup2Contract => {
2514
+ const material = [...registry.materialShaderManifestEntries()].find(
2515
+ (entry) => entry.identifier === spec.shader.id,
2516
+ );
2517
+ const source =
2518
+ material === undefined
2519
+ ? undefined
2520
+ : spec.shader.variantSet === undefined
2521
+ ? material?.variants[0]?.composedWgsl
2522
+ : findVariantByKey(material, spec.shader.variantSet)?.composedWgsl;
2523
+ return resolvePipelineGroup2Contract(source ?? '');
2524
+ };
2525
+
2526
+ // Boot-time pre-warm: build SPEC_CONST entries whose shader modules are
2527
+ // compiled. Entries referencing a missing module are silently skipped
2528
+ // (the empty-manifest path — D-3). Each build failure for an available
2529
+ // module throws PipelineSpecError (fail-fast, charter P3).
2530
+ if (
2531
+ unlitModule !== null ||
2532
+ pbrModule !== null ||
2533
+ spriteModule !== null ||
2534
+ spriteLitModule !== null
2535
+ ) {
2536
+ for (const spec of runtimeMaterialPrewarmTable) {
2537
+ const modules = shaderModuleMap.get(spec.shader.id);
2538
+ if (modules === undefined) {
2539
+ // Module not compiled (empty-manifest for this shader): skip.
2540
+ continue;
2541
+ }
2542
+ try {
2543
+ getOrBuildPipeline(spec, pipelineDeviceProvider, pipelineCache, modules);
2544
+ } catch (err) {
2545
+ if (err instanceof PipelineSpecError) throw err;
2546
+ throw new PipelineSpecError({
2547
+ code: 'pipeline-build-failed',
2548
+ detail: { cause: err },
2549
+ hint: `Boot-time SPEC_CONST pre-warm failed for shader '${spec.shader.id}'; inspect gpuMessage on the cause`,
2550
+ });
2551
+ }
2552
+ }
2553
+
2554
+ // M6 fix-up: seed `materialShaderPipelineCache` (owned by the outer
2555
+ // `makeWebGPURenderer` scope) from the prewarmed `pipelineCache` for
2556
+ // SPEC_CONST entries whose `variantSet !== undefined`. The URP record
2557
+ // path queries `getMaterialShaderPipeline(...)` keyed off
2558
+ // `cacheKeyOf(spec)` with `variantSet=URP_PBR_VARIANT_SET`; both caches
2559
+ // generate keys via the same `cacheKeyOf` so the lookup hits the
2560
+ // boot-time prewarmed PSO instead of triggering a 1-frame async-compile
2561
+ // skip-draw. Seeding only variantSet-bearing entries keeps the
2562
+ // no-variant entries flowing through the original
2563
+ // `pipelineState.standardPipeline*` channel (consumed by sprite /
2564
+ // unlit-fallback paths), so URP-vs-no-variant cache identity stays
2565
+ // explicit instead of collapsing into one map (charter P3).
2566
+ for (const spec of runtimeSpecConstTable) {
2567
+ if (spec.shader.variantSet === undefined) continue;
2568
+ if (shaderModuleMap.get(spec.shader.id) === undefined) continue;
2569
+ const key = cacheKeyOf(spec);
2570
+ const built = pipelineCache.get(key);
2571
+ if (built !== undefined) {
2572
+ seedMaterialShaderPipelineCache(key, built as RenderPipeline, group2ContractForSpec(spec));
2573
+ }
2574
+ }
2575
+
2576
+ // The linear-LDR geometry target is intentionally separate from the
2577
+ // swap-chain view target. Seed every material entry, including no-variant
2578
+ // unlit, because tonemap=none reaches this cache directly on frame one.
2579
+ for (const spec of runtimeLinearLdrMaterialSpecTable) {
2580
+ if (shaderModuleMap.get(spec.shader.id) === undefined) continue;
2581
+ const key = cacheKeyOf(spec);
2582
+ const built = pipelineCache.get(key);
2583
+ if (built !== undefined) {
2584
+ seedMaterialShaderPipelineCache(key, built as RenderPipeline, group2ContractForSpec(spec));
2585
+ }
2586
+ }
2587
+ }
2588
+
2589
+ // helper: look up a pre-warmed PSO from cache by (shaderId, isHdr, sampleCount).
2590
+ // Resolves the matching runtimeSpecConstTable entry, computes cacheKeyOf,
2591
+ // and returns the cached handle. Returns null when the spec entry's module
2592
+ // was not compiled (empty-manifest path) or the cache is cold.
2593
+ const getCachedPipelineOrNull = (
2594
+ shaderId: string,
2595
+ isHdr: boolean,
2596
+ sampleCount: 1 | 4,
2597
+ ): RenderPipeline | null => {
2598
+ for (const entry of runtimeSpecConstTable) {
2599
+ if (entry.shader.id === shaderId && entry.attachments.sampleCount === sampleCount) {
2600
+ const color0 = entry.attachments.colorFormats[0];
2601
+ const entryIsHdr = color0 === HDR_COLOR_ATTACHMENT_FORMAT;
2602
+ if (entryIsHdr === isHdr) {
2603
+ const key = cacheKeyOf(entry);
2604
+ return (pipelineCache.get(key) ?? null) as RenderPipeline | null;
2605
+ }
2606
+ }
2607
+ }
2608
+ return null;
2609
+ };
2610
+
2611
+ // ── feat-20260520-2d-sprite-layer-mvp / M-3 / w24 ────────────────────────
2612
+ //
2613
+ // Sprite alpha-blend pipeline pair — LDR (`bgra8unorm-srgb` swap-chain
2614
+ // view) + HDR (`rgba16float` offscreen view; routed when active camera
2615
+ // carries `tonemap !== 'none'`, same as unlit/standard HDR siblings).
2616
+ //
2617
+ // @new-surface sprite alpha-blend pipeline (4th + 5th GPU render-pipeline
2618
+ // handles on PipelineState; the engine grows 5 -> 9 distinct pipelines:
2619
+ // unlit + standard + tonemap each existed before; sprite adds LDR + HDR).
2620
+ // The blend op is premultiplied alpha (charter P5 consistent abstraction
2621
+ // with the OpenGL / WebGPU industry default; sprite.wgsl fragment outputs
2622
+ // premultiplied RGB so srcFactor='one' / dstFactor='one-minus-src-alpha'
2623
+ // composes correctly).
2624
+ //
2625
+ // @reuses pipelineLayoutResult (the 4-BindGroupLayout chain shared with
2626
+ // unlit / standard / pbr — view + material + meshArray + instances).
2627
+ // @reuses defaultSampler — sprite material BindGroup entries 3 + 5
2628
+ // (metallicRoughnessSampler / normalSampler placeholders bound to
2629
+ // `pipelineState.defaultSampler`; D-1 candidate b; zero new sampler
2630
+ // created).
2631
+ // @reuses defaultWhiteTextureView — sprite material BindGroup entries
2632
+ // 4 + 6 (metallicRoughnessTexture / normalTexture placeholders bound
2633
+ // to `pipelineState.defaultWhiteTextureView`; D-1 candidate b; the
2634
+ // 1x1 white view was already provisioned for unlit / standard fallback
2635
+ // so the sprite path adds 0 lines of new GPU resource code, only
2636
+ // binding references in render-system-record.ts w25).
2637
+ // Sprite material BindGroup populates entries 0..2 with sprite's own
2638
+ // uniform / sampler / texture, and entries 3..6 with
2639
+ // pipelineState.defaultSampler + pipelineState.defaultWhiteTextureView
2640
+ // (D-1 candidate b — zero new GPU resource; 4-line binding wiring lives
2641
+ // in render-system-record.ts w25). The unused entries are physically
2642
+ // bound to ensure WebGPU's BindGroupLayout congruence (declared in the
2643
+ // shader at @binding 3..6 even though the sprite fragment never reads
2644
+ // them; plan-strategy D-1 + sprite.wgsl JSDoc head).
2645
+ //
2646
+ // @derives unlit / standard LDR+HDR dual-pipeline structure (lines 2058-
2647
+ // 2147 above + 2174-2253 below). The sprite pair mirrors the unlit
2648
+ // pair byte-for-byte except for:
2649
+ // - module: spriteModule (vs unlitModule)
2650
+ // - fragment.targets[0].blend: premultiplied alpha (vs no blend)
2651
+ // - depthStencil.depthWriteEnabled: false (vs true)
2652
+ // - depthStencil.depthCompare: 'less-equal' (vs 'less')
2653
+ // The vertex stride stays 12F (HANDLE_QUAD passes through the same
2654
+ // 12-float interleaved layout as procedural meshes), so no new vertex
2655
+ // pipeline branch is needed in the record stage (plan-strategy §3 RT4).
2656
+ //
2657
+ // Premultiplied alpha blend op (`{ srcFactor: 'one', dstFactor:
2658
+ // 'one-minus-src-alpha', operation: 'add' }`) is the industry-default for
2659
+ // sprite atlases; sprite.wgsl emits premultiplied RGB so the over-
2660
+ // composite math (`dst' = src + dst * (1 - src.a)`) is direct.
2661
+ // feat-20260615-pipeline-spec-ssot M2-T4: sprite pipelines are pre-warmed in
2662
+ // SPEC_CONST_TABLE (4 entries: LDR/HDR x S1/S4). Cache lookup replaces the
2663
+ // prior local-handle variables + createMsaaVariant closure.
2664
+ // The pre-existing sprite-build-failure defer-to-null semantics are now
2665
+ // handled by the boot-time SPEC_CONST pre-warm block above: if the sprite
2666
+ // module exists but the SPEC_CONST build fails, the fail-fast throw blocks
2667
+ // the engine from entering the first frame (charter P3: no silent fallback).
2668
+ // AI users who need sprite tolerance for lavapipe / dawn-vulkan validation
2669
+ // can skip SPEC_CONST entries at their own peril via a future M7 opt-out
2670
+ // gate; the current M2 contract is fail-fast.
2671
+ // feat-20260608-tilemap-object-layer-rendering M2 / m2-t6 (D-8): SPEC_CONST
2672
+ // sprite entries set cullMode='none' so H/V flip via negative scale x/y
2673
+ // (tilemap per-cell entity TRS form, D-1) does not get culled when winding
2674
+ // inverts. See pipeline-spec.ts sprite LDR S1/S4 + HDR S1/S4 entries.
2675
+
2676
+ // ── feat-20260519-tonemap-reinhard-mvp / M2 / T-M2.5 ──────────────────────
2677
+ //
2678
+ // HDR variants of the unlit + standard pipelines (rgba16float colour
2679
+ // attachment instead of bgra8unorm-srgb) plus the post-process tonemap
2680
+ // pipeline + 3-entry BGL + sampler + 16 B params UBO. Routed by record-
2681
+ // stage when the active camera carries `tonemap !== 'none'` (AC-03(a) /
2682
+ // AC-11). Sharing the geometry shader modules across the sRGB + HDR
2683
+ // pipelines keeps the shader compile cost flat and the WGSL byte-for-byte
2684
+ // identical between the two routes — the only difference is the colour-
2685
+ // attachment format declaration in the fragment state target list (charter
2686
+ // P5 consistent abstraction; plan-strategy D-2 + D-3).
2687
+ //
2688
+ // bug-20260519 D-3 nullable extension: the HDR pipeline block is gated on
2689
+ // `pbrModule + unlitModule !== null` so the empty-manifest path skips every
2690
+ // device.create* call below and writes `null` into the corresponding
2691
+ // PipelineState fields. feat-20260621 M-A3 (D-5): the dedicated tonemap
2692
+ // pipeline / BGL / sampler / params-UBO handles are gone — the built-in
2693
+ // tonemap registers through the unified post-process channel (see the
2694
+ // `registerBuiltinTonemap` callback above; pipeline + BGL + sampler + UBO
2695
+ // are owned by dispatchFullscreenPass / the fullscreen feature host).
2696
+ let fxaaPipelineHandle: RenderPipeline | null = null;
2697
+ let fxaaBglHandle: BindGroupLayout | null = null;
2698
+ let fxaaSamplerHandle: Sampler | null = null;
2699
+ let skyboxPipelineHandle: RenderPipeline | null = null;
2700
+ let skyboxBglHandle: BindGroupLayout | null = null;
2701
+ let skyboxSamplerHandle: Sampler | null = null;
2702
+ let skyboxRotationBufferHandle: Buffer | null = null;
2703
+ let skyboxPipelineMsaaHandle: RenderPipeline | null = null;
2704
+ // feat-20260531-bloom-first-declarative-render-graph-pass / w13:
2705
+ // bloom pipeline handles (D-1, D-4, D-6). Bright + 2x blur (H/V per-axis)
2706
+ // + 1x composite = 4 pipelines. Blur H/V share the same WGSL module but
2707
+ // are separate pipelines with per-axis texelSize baked at creation (D-1).
2708
+ // All 4 use rgba16float target format (D-6).
2709
+ let bloomBrightPipelineHandle: RenderPipeline | null = null;
2710
+ let bloomBlurHPipelineHandle: RenderPipeline | null = null;
2711
+ let bloomBlurVPipelineHandle: RenderPipeline | null = null;
2712
+ let bloomCompositePipelineHandle: RenderPipeline | null = null;
2713
+ let bloomBrightBglHandle: BindGroupLayout | null = null;
2714
+ let bloomBlurBglHandle: BindGroupLayout | null = null;
2715
+ let bloomCompositeBglHandle: BindGroupLayout | null = null;
2716
+ let bloomSamplerHandle: Sampler | null = null;
2717
+ let bloomBrightParamsBufferHandle: Buffer | null = null;
2718
+ // bug-20260625: separate H/V blur params UBOs (see PerPassResources comment).
2719
+ let bloomBlurHParamsBufferHandle: Buffer | null = null;
2720
+ let bloomBlurVParamsBufferHandle: Buffer | null = null;
2721
+ let bloomCompositeParamsBufferHandle: Buffer | null = null;
2722
+ let bloomResourcesReady = false;
2723
+ let ensureBloomResources: (() => void) | undefined;
2724
+ let bloomActiveBundle: BloomPersistentBundle | undefined;
2725
+ let bloomCandidateBundle: BloomPersistentBundle | undefined;
2726
+ const retiringBloomBundles = new Set<BloomPersistentBundle>();
2727
+ let bloomGeneration = 0;
2728
+ let lastSuccessfulBloomReceipts:
2729
+ | import('../record/frame-snapshot').BloomFrameReceipts
2730
+ | undefined;
2731
+ const getBloomResourcesStable = (): BloomPersistentBundle | null =>
2732
+ bloomCandidateBundle ?? bloomActiveBundle ?? null;
2733
+ let commitBloomResources: (() => void) | undefined;
2734
+ let discardBloomResources: (() => void) | undefined;
2735
+ let retireBloomResources: ((completion: Promise<unknown>) => void) | undefined;
2736
+ let drainBloomResources: (() => void) | undefined;
2737
+ let inspectBloomResources:
2738
+ | ((graph?: import('@forgeax/engine-render-graph').CompiledRenderGraphInfo) => BloomInspection)
2739
+ | undefined;
2740
+ const ensureBloomResourcesStable = (): void => {
2741
+ ensureBloomResources?.();
2742
+ };
2743
+ const retireBloomResourcesStable = (completion: Promise<unknown>): void => {
2744
+ retireBloomResources?.(completion);
2745
+ };
2746
+ const commitBloomResourcesStable = (): void => {
2747
+ commitBloomResources?.();
2748
+ };
2749
+ const discardBloomResourcesStable = (): void => {
2750
+ discardBloomResources?.();
2751
+ };
2752
+ const drainBloomResourcesStable = (): void => {
2753
+ drainBloomResources?.();
2754
+ };
2755
+ const inspectBloomResourcesStable = (
2756
+ graph?: import('@forgeax/engine-render-graph').CompiledRenderGraphInfo,
2757
+ ): BloomInspection => {
2758
+ return (
2759
+ inspectBloomResources?.(graph) ?? {
2760
+ graphStatus: 'empty',
2761
+ enabled: false,
2762
+ targetCount: 0,
2763
+ targetBytes: 0,
2764
+ resourceCount: 0,
2765
+ passCount: 0,
2766
+ encodeCount: 0,
2767
+ bindGroupCount: 0,
2768
+ uploadCount: 0,
2769
+ residentChildBytes: 0,
2770
+ generation: 0,
2771
+ state: 'off',
2772
+ }
2773
+ );
2774
+ };
2775
+ // feat-20260612-hdrp-ssao M6 / w26 + w43: SSAO pipeline handles (D-A).
2776
+ // calc + blur RenderPipeline pair sharing a dedicated 6-entry BGL.
2777
+ // Optional — null when manifest lacks hdrp-ssao entry.
2778
+ let ssaoCalcPipelineHandle: RenderPipeline | null = null;
2779
+ let ssaoBlurPipelineHandle: RenderPipeline | null = null;
2780
+ let ssaoBglHandle: BindGroupLayout | null = null;
2781
+ if (unlitModule !== null && pbrModule !== null) {
2782
+ // feat-20260615-pipeline-spec-ssot M2-T4: unlit/standard HDR pipeline
2783
+ // variants are pre-warmed in SPEC_CONST_TABLE (4 entries: unlit/standard
2784
+ // HDR x S1/S4). Cache lookup replaces prior local-handle variables.
2785
+ // The fxaa / skybox / bloom / SSAO fullscreen pipelines below
2786
+ // are NOT in SPEC_CONST_TABLE and remain boot-time lazy-built here.
2787
+ // feat-20260621 M-A3 (D-5): tonemap is no longer built here — it registers
2788
+ // through the unified post-process channel (registerBuiltinTonemap).
2789
+
2790
+ // feat-20260528-fxaa-post-processing M2 / w10: FXAA pipeline prebuilt.
2791
+ // When the manifest contains the fxaa entry (rgb2luma marker, D-5),
2792
+ // construct the 3-entry BGL (texture + sampler + output-policy UBO),
2793
+ // pipeline layout, fullscreen render pipeline (vertex = fullscreen
2794
+ // triangle from fxaa.wgsl, fragment = FXAA 3.11 algorithm), and
2795
+ // linear clamp-to-edge sampler. Mirrors the tonemap pipeline
2796
+ // construction pattern directly above.
2797
+ if (fxaaModule !== null) {
2798
+ // FXAA BindGroupLayout: 3 entries (texture + sampler + output-policy UBO).
2799
+ // D-2: the fxaa.wgsl fragment stage declares @binding(0) texture_2d<f32>
2800
+ // + @binding(1) sampler + @binding(2) FxaaParams.
2801
+ const fxaaBglResult = runShimSyncStep(
2802
+ () =>
2803
+ rhiDevice.createBindGroupLayout({
2804
+ label: 'fxaa-bgl',
2805
+ entries: [
2806
+ {
2807
+ binding: 0,
2808
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2809
+ texture: { sampleType: 'float', viewDimension: '2d' },
2810
+ },
2811
+ {
2812
+ binding: 1,
2813
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2814
+ sampler: { type: 'filtering' },
2815
+ },
2816
+ {
2817
+ binding: 2,
2818
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2819
+ buffer: { type: 'uniform' },
2820
+ },
2821
+ ],
2822
+ }),
2823
+ 'webgpu-runtime-error',
2824
+ 'createBindGroupLayout(fxaa) succeeded',
2825
+ 'check device.limits.maxBindGroupsPerPipelineLayout',
2826
+ );
2827
+ if (!fxaaBglResult.ok) throw fxaaBglResult.error;
2828
+ fxaaBglHandle = fxaaBglResult.value;
2829
+
2830
+ const fxaaPipelineLayoutResult = runShimSyncStep(
2831
+ () =>
2832
+ rhiDevice.createPipelineLayout({
2833
+ label: 'fxaa-pl',
2834
+ bindGroupLayouts: [fxaaBglResult.value],
2835
+ }),
2836
+ 'webgpu-runtime-error',
2837
+ 'createPipelineLayout(fxaa) succeeded',
2838
+ 'verify fxaa BindGroupLayout matches shader @group(0) bindings',
2839
+ );
2840
+ if (!fxaaPipelineLayoutResult.ok) throw fxaaPipelineLayoutResult.error;
2841
+
2842
+ // M2-T4: FXAA pipeline via getOrBuildPipeline (lazy-build, not in SPEC_CONST_TABLE).
2843
+ // Color-space contract: FXAA writes bgra8unorm (NON-srgb) storage format.
2844
+ // Lazy-build via cache miss on first access.
2845
+ {
2846
+ const fxaaSpec: PipelineSpec = {
2847
+ shader: { id: 'forgeax::post::fxaa', passKind: 'post-process', variantSet: undefined },
2848
+ attachments: {
2849
+ colorFormats: [storageBufferCapable ? swapChainFormats.storage : swapChainFormats.view],
2850
+ depthFormat: undefined,
2851
+ sampleCount: 1,
2852
+ },
2853
+ geometry: {
2854
+ topology: 'triangle-list',
2855
+ stripIndexFormat: undefined,
2856
+ vertexLayout: {},
2857
+ },
2858
+ renderState: { cullMode: 'none' },
2859
+ };
2860
+ const modules = {
2861
+ vertex: fxaaModule,
2862
+ fragment: fxaaModule,
2863
+ layout: fxaaPipelineLayoutResult.value,
2864
+ };
2865
+ try {
2866
+ fxaaPipelineHandle = getOrBuildPipeline(
2867
+ fxaaSpec,
2868
+ pipelineDeviceProvider,
2869
+ pipelineCache,
2870
+ modules,
2871
+ ) as RenderPipeline;
2872
+ } catch (err) {
2873
+ if (err instanceof PipelineSpecError) throw err;
2874
+ throw new PipelineSpecError({
2875
+ code: 'pipeline-build-failed',
2876
+ detail: { cause: err },
2877
+ hint: 'createRenderPipeline (fxaa fullscreen) failed; inspect gpuMessage',
2878
+ });
2879
+ }
2880
+ }
2881
+
2882
+ // FXAA sampler: linear filter + clamp-to-edge. Clamp-to-edge
2883
+ // prevents edge bleed when sampling at the screen extents.
2884
+ const fxaaSamplerResult = runShimSyncStep(
2885
+ () =>
2886
+ rhiDevice.createSampler({
2887
+ label: 'fxaa-sampler',
2888
+ magFilter: 'linear',
2889
+ minFilter: 'linear',
2890
+ mipmapFilter: 'linear',
2891
+ addressModeU: 'clamp-to-edge',
2892
+ addressModeV: 'clamp-to-edge',
2893
+ }),
2894
+ 'webgpu-runtime-error',
2895
+ 'createSampler (fxaa) succeeded',
2896
+ 'check device.limits.maxSamplersPerShaderStage',
2897
+ );
2898
+ if (!fxaaSamplerResult.ok) throw fxaaSamplerResult.error;
2899
+ fxaaSamplerHandle = fxaaSamplerResult.value;
2900
+ }
2901
+
2902
+ // feat-20260531-skybox-env-background M3 / w15: skybox pipeline prebuilt.
2903
+ // When the manifest contains the skybox entry (skybox_fs marker, D-7),
2904
+ // construct the 4-entry BGL (texture_cube + sampler + View UBO + rotation UBO),
2905
+ // pipeline layout, fullscreen render pipeline (vertex = fullscreen
2906
+ // triangle from skybox.wgsl, fragment = cubemap sample + write HDR),
2907
+ // and linear clamp-to-edge sampler. Mirrors tonemap/fxaa construction
2908
+ // pattern. Skybox writes to hdrColor rgba16float, NOT to the swap-chain
2909
+ // (plan-strategy D-2: tonemap pass reads hdrColor and maps to LDR).
2910
+ if (skyboxModule !== null) {
2911
+ const skyboxBglResult = runShimSyncStep(
2912
+ () =>
2913
+ rhiDevice.createBindGroupLayout({
2914
+ label: 'skybox-bgl',
2915
+ entries: [
2916
+ {
2917
+ binding: 0,
2918
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2919
+ texture: { sampleType: 'float', viewDimension: 'cube' },
2920
+ },
2921
+ {
2922
+ binding: 1,
2923
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2924
+ sampler: { type: 'filtering' },
2925
+ },
2926
+ {
2927
+ binding: 2,
2928
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2929
+ buffer: { type: 'uniform' },
2930
+ },
2931
+ {
2932
+ binding: 3,
2933
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
2934
+ buffer: { type: 'uniform' },
2935
+ },
2936
+ ],
2937
+ }),
2938
+ 'webgpu-runtime-error',
2939
+ 'createBindGroupLayout(skybox) succeeded',
2940
+ 'check device.limits.maxBindGroupsPerPipelineLayout',
2941
+ );
2942
+ if (!skyboxBglResult.ok) throw skyboxBglResult.error;
2943
+ skyboxBglHandle = skyboxBglResult.value;
2944
+
2945
+ const skyboxPipelineLayoutResult = runShimSyncStep(
2946
+ () =>
2947
+ rhiDevice.createPipelineLayout({
2948
+ label: 'skybox-pl',
2949
+ bindGroupLayouts: [skyboxBglResult.value],
2950
+ }),
2951
+ 'webgpu-runtime-error',
2952
+ 'createPipelineLayout(skybox) succeeded',
2953
+ 'verify skybox BindGroupLayout matches shader @group(0) bindings',
2954
+ );
2955
+ if (!skyboxPipelineLayoutResult.ok) throw skyboxPipelineLayoutResult.error;
2956
+
2957
+ // Skybox pipeline writes to hdrColor rgba16float render target (NOT
2958
+ // M2-T4: skybox pipeline via getOrBuildPipeline + SPEC_CONST_TABLE pre-warm.
2959
+ // Skybox HDR S1 + S4 entries are in SPEC_CONST_TABLE. Register module with
2960
+ // layout + fragmentEntryPoint 'skybox_fs'. The S1 variant is built via
2961
+ // getOrBuildPipeline; MSAA S4 variant catches failure gracefully (same as
2962
+ // pre-M2 behavior: warn + fire error, set handle to null).
2963
+ shaderModuleMap.set('forgeax::skybox::cube', {
2964
+ vertex: skyboxModule,
2965
+ fragment: skyboxModule,
2966
+ fragmentEntryPoint: 'skybox_fs',
2967
+ layout: skyboxPipelineLayoutResult.value,
2968
+ });
2969
+ // S1 (non-MSAA)
2970
+ {
2971
+ const skyboxSpec: PipelineSpec = {
2972
+ shader: { id: 'forgeax::skybox::cube', passKind: 'skybox', variantSet: undefined },
2973
+ attachments: {
2974
+ colorFormats: [HDR_COLOR_ATTACHMENT_FORMAT],
2975
+ depthFormat: undefined,
2976
+ sampleCount: 1,
2977
+ },
2978
+ geometry: {
2979
+ topology: 'triangle-list',
2980
+ stripIndexFormat: undefined,
2981
+ vertexLayout: {},
2982
+ },
2983
+ renderState: { cullMode: 'none' },
2984
+ };
2985
+ const modules = shaderModuleMap.get('forgeax::skybox::cube');
2986
+ if (modules === undefined) throw new Error('expected skybox module in shaderModuleMap');
2987
+ skyboxPipelineHandle = getOrBuildPipeline(
2988
+ skyboxSpec,
2989
+ pipelineDeviceProvider,
2990
+ pipelineCache,
2991
+ modules,
2992
+ ) as RenderPipeline;
2993
+ }
2994
+ // S4 (MSAA variant — graceful failure, same as pre-M2)
2995
+ {
2996
+ const skyboxMsaaSpec: PipelineSpec = {
2997
+ shader: { id: 'forgeax::skybox::cube', passKind: 'skybox', variantSet: undefined },
2998
+ attachments: {
2999
+ colorFormats: [HDR_COLOR_ATTACHMENT_FORMAT],
3000
+ depthFormat: undefined,
3001
+ sampleCount: 4,
3002
+ },
3003
+ geometry: {
3004
+ topology: 'triangle-list',
3005
+ stripIndexFormat: undefined,
3006
+ vertexLayout: {},
3007
+ },
3008
+ renderState: { cullMode: 'none' },
3009
+ };
3010
+ const modules = shaderModuleMap.get('forgeax::skybox::cube');
3011
+ if (modules === undefined) throw new Error('expected skybox module in shaderModuleMap');
3012
+ try {
3013
+ skyboxPipelineMsaaHandle = getOrBuildPipeline(
3014
+ skyboxMsaaSpec,
3015
+ pipelineDeviceProvider,
3016
+ pipelineCache,
3017
+ modules,
3018
+ ) as RenderPipeline;
3019
+ } catch (msaaErr) {
3020
+ // The MSAA variant is a graceful-degradation path -- console.warn is the
3021
+ // canonical signal for "feature degrades, not fails" (noConsole allows warn).
3022
+ console.warn(
3023
+ `[forgeax] skybox MSAA pipeline variant build failed at renderer init; ` +
3024
+ `non-MSAA skybox unaffected. (cause: ${String(msaaErr)})`,
3025
+ );
3026
+ // PipelineSpecError carries the underlying cause in `.detail.cause`;
3027
+ // the warn above already surfaces the message.
3028
+ skyboxPipelineMsaaHandle = null;
3029
+ }
3030
+ }
3031
+
3032
+ // Skybox sampler: filterable (linear/linear/clamp). Clamp-to-edge
3033
+ // prevents seam artifacts at cubemap face boundaries.
3034
+ const skyboxSamplerResult = runShimSyncStep(
3035
+ () =>
3036
+ rhiDevice.createSampler({
3037
+ label: 'skybox-sampler',
3038
+ magFilter: 'linear',
3039
+ minFilter: 'linear',
3040
+ mipmapFilter: 'linear',
3041
+ addressModeU: 'clamp-to-edge',
3042
+ addressModeV: 'clamp-to-edge',
3043
+ }),
3044
+ 'webgpu-runtime-error',
3045
+ 'createSampler (skybox) succeeded',
3046
+ 'check device.limits.maxSamplersPerShaderStage',
3047
+ );
3048
+ if (!skyboxSamplerResult.ok) throw skyboxSamplerResult.error;
3049
+ skyboxSamplerHandle = skyboxSamplerResult.value;
3050
+
3051
+ const skyboxRotationBufferResult = runShimSyncStep(
3052
+ () =>
3053
+ rhiDevice.createBuffer({
3054
+ label: 'skybox-rotation-ubo',
3055
+ size: 16,
3056
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
3057
+ mappedAtCreation: false,
3058
+ }),
3059
+ 'webgpu-runtime-error',
3060
+ 'createBuffer (skybox rotation) succeeded',
3061
+ 'check device.limits.maxUniformBufferBindingSize',
3062
+ );
3063
+ if (!skyboxRotationBufferResult.ok) throw skyboxRotationBufferResult.error;
3064
+ skyboxRotationBufferHandle = skyboxRotationBufferResult.value;
3065
+ }
3066
+
3067
+ // feat-20260531-bloom-first-declarative-render-graph-pass / w13:
3068
+ // bloom pipeline assembly (D-1, D-4, D-6). Assembled inside the
3069
+ // (unlit+pbr+tonemap) gate for rhiDevice access but bloom modules
3070
+ // are optional — each guard is independent. When bloom modules are
3071
+ // absent (legacy manifest), handles stay null and execute closures
3072
+ // skip the bloom passes entirely (zero-overhead opt-out).
3073
+ //
3074
+ // Pipeline roster:
3075
+ // bloom-bright : 1-tex + sampler + UBO@2 BGL, rgba16float target
3076
+ // bloom-blur-h : same BGL, same module as blur-v, H-axis texelSize
3077
+ // bloom-blur-v : same BGL, same module as blur-h, V-axis texelSize
3078
+ // bloom-composite: 2-tex + sampler + UBO@3 BGL, rgba16float target
3079
+
3080
+ ensureBloomResources = (): void => {
3081
+ if (bloomResourcesReady) return;
3082
+ const candidateScope = rendererScope.createChild(`${rendererScope.owner}:standard-bloom`);
3083
+ const adopt = <T>(
3084
+ kind: Parameters<DeviceScope['_adopt']>[0],
3085
+ value: T,
3086
+ cleanup: (value: T) => void,
3087
+ ): T => {
3088
+ candidateScope._adopt(kind, value, cleanup);
3089
+ return value;
3090
+ };
3091
+ try {
3092
+ // Shared bloom sampler: linear filter + clamp-to-edge (all 4 passes
3093
+ // sample from textures using fullscreen triangle UVs).
3094
+ if (
3095
+ bloomBrightModule !== null ||
3096
+ bloomBlurModule !== null ||
3097
+ bloomCompositeModule !== null
3098
+ ) {
3099
+ const bloomSamplerResult = runShimSyncStep(
3100
+ () =>
3101
+ rhiDevice.createSampler({
3102
+ label: 'bloom-sampler',
3103
+ magFilter: 'linear',
3104
+ minFilter: 'linear',
3105
+ mipmapFilter: 'linear',
3106
+ addressModeU: 'clamp-to-edge',
3107
+ addressModeV: 'clamp-to-edge',
3108
+ }),
3109
+ 'webgpu-runtime-error',
3110
+ 'createSampler (bloom) succeeded',
3111
+ 'check device.limits.maxSamplersPerShaderStage',
3112
+ );
3113
+ if (!bloomSamplerResult.ok) throw bloomSamplerResult.error;
3114
+ bloomSamplerHandle = adopt('binding', bloomSamplerResult.value, () => undefined);
3115
+ }
3116
+
3117
+ // Bloom bright: 1-tex + sampler + UBO@2 BGL (D-4).
3118
+ if (bloomBrightModule !== null && bloomSamplerHandle !== null) {
3119
+ const brightBglResult = runShimSyncStep(
3120
+ () =>
3121
+ rhiDevice.createBindGroupLayout({
3122
+ label: 'bloom-bright-bgl',
3123
+ entries: [
3124
+ {
3125
+ binding: 0,
3126
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3127
+ texture: { sampleType: 'float', viewDimension: '2d' },
3128
+ },
3129
+ {
3130
+ binding: 1,
3131
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3132
+ sampler: { type: 'filtering' },
3133
+ },
3134
+ {
3135
+ binding: 2,
3136
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3137
+ buffer: { type: 'uniform' },
3138
+ },
3139
+ ],
3140
+ }),
3141
+ 'webgpu-runtime-error',
3142
+ 'createBindGroupLayout(bloom-bright) succeeded',
3143
+ 'check device.limits.maxBindGroupsPerPipelineLayout',
3144
+ );
3145
+ if (!brightBglResult.ok) throw brightBglResult.error;
3146
+ bloomBrightBglHandle = adopt('binding', brightBglResult.value, () => undefined);
3147
+
3148
+ const brightPlResult = runShimSyncStep(
3149
+ () =>
3150
+ rhiDevice.createPipelineLayout({
3151
+ label: 'bloom-bright-pl',
3152
+ bindGroupLayouts: [brightBglResult.value],
3153
+ }),
3154
+ 'webgpu-runtime-error',
3155
+ 'createPipelineLayout(bloom-bright) succeeded',
3156
+ 'verify bloom-bright BindGroupLayout matches shader @group(0) bindings',
3157
+ );
3158
+ if (!brightPlResult.ok) throw brightPlResult.error;
3159
+ adopt('binding', brightPlResult.value, () => undefined);
3160
+
3161
+ // M2-T4: bloom-bright pipeline via getOrBuildPipeline (lazy-build).
3162
+ {
3163
+ const brightSpec: PipelineSpec = {
3164
+ shader: {
3165
+ id: 'forgeax::post::bloom-bright',
3166
+ passKind: 'post-process',
3167
+ variantSet: undefined,
3168
+ },
3169
+ attachments: {
3170
+ colorFormats: [HDR_COLOR_ATTACHMENT_FORMAT],
3171
+ depthFormat: undefined,
3172
+ sampleCount: 1,
3173
+ },
3174
+ geometry: {
3175
+ topology: 'triangle-list',
3176
+ stripIndexFormat: undefined,
3177
+ vertexLayout: {},
3178
+ },
3179
+ renderState: { cullMode: 'none' },
3180
+ };
3181
+ const modules = {
3182
+ vertex: bloomBrightModule,
3183
+ fragment: bloomBrightModule,
3184
+ layout: brightPlResult.value,
3185
+ };
3186
+ try {
3187
+ bloomBrightPipelineHandle = getOrBuildPipeline(
3188
+ brightSpec,
3189
+ pipelineDeviceProvider,
3190
+ pipelineCache,
3191
+ modules,
3192
+ ) as RenderPipeline;
3193
+ bloomBrightPipelineHandle = adopt(
3194
+ 'pipeline',
3195
+ bloomBrightPipelineHandle,
3196
+ () => undefined,
3197
+ );
3198
+ } catch (err) {
3199
+ if (err instanceof PipelineSpecError) throw err;
3200
+ throw new PipelineSpecError({
3201
+ code: 'pipeline-build-failed',
3202
+ detail: { cause: err },
3203
+ hint: 'createRenderPipeline (bloom-bright fullscreen) failed; inspect gpuMessage',
3204
+ });
3205
+ }
3206
+ }
3207
+
3208
+ // Bright params UBO: 16 B std140 (threshold f32 + 12 B pad).
3209
+ const brightParamsResult = runShimSyncStep(
3210
+ () =>
3211
+ rhiDevice.createBuffer({
3212
+ label: 'bloom-bright-params-ubo',
3213
+ size: BRIGHT_PARAMS_BYTES,
3214
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
3215
+ mappedAtCreation: false,
3216
+ }),
3217
+ 'webgpu-runtime-error',
3218
+ 'createBuffer (bloom-bright params UBO) succeeded',
3219
+ 'check device.limits.maxUniformBufferBindingSize',
3220
+ );
3221
+ if (!brightParamsResult.ok) throw brightParamsResult.error;
3222
+ bloomBrightParamsBufferHandle = adopt('buffer', brightParamsResult.value, (value) => {
3223
+ rhiDevice.destroyBuffer(value);
3224
+ });
3225
+ }
3226
+
3227
+ // Bloom blur H/V: same BGL (1-tex + sampler + UBO@2), same module,
3228
+ // two separate pipelines with per-axis texelSize (D-1, D-4).
3229
+ if (bloomBlurModule !== null && bloomSamplerHandle !== null) {
3230
+ const blurBglResult = runShimSyncStep(
3231
+ () =>
3232
+ rhiDevice.createBindGroupLayout({
3233
+ label: 'bloom-blur-bgl',
3234
+ entries: [
3235
+ {
3236
+ binding: 0,
3237
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3238
+ texture: { sampleType: 'float', viewDimension: '2d' },
3239
+ },
3240
+ {
3241
+ binding: 1,
3242
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3243
+ sampler: { type: 'filtering' },
3244
+ },
3245
+ {
3246
+ binding: 2,
3247
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3248
+ buffer: { type: 'uniform' },
3249
+ },
3250
+ ],
3251
+ }),
3252
+ 'webgpu-runtime-error',
3253
+ 'createBindGroupLayout(bloom-blur) succeeded',
3254
+ 'check device.limits.maxBindGroupsPerPipelineLayout',
3255
+ );
3256
+ if (!blurBglResult.ok) throw blurBglResult.error;
3257
+ bloomBlurBglHandle = adopt('binding', blurBglResult.value, () => undefined);
3258
+
3259
+ const blurPlResult = runShimSyncStep(
3260
+ () =>
3261
+ rhiDevice.createPipelineLayout({
3262
+ label: 'bloom-blur-pl',
3263
+ bindGroupLayouts: [blurBglResult.value],
3264
+ }),
3265
+ 'webgpu-runtime-error',
3266
+ 'createPipelineLayout(bloom-blur) succeeded',
3267
+ 'verify bloom-blur BindGroupLayout matches shader @group(0) bindings',
3268
+ );
3269
+ if (!blurPlResult.ok) throw blurPlResult.error;
3270
+ adopt('binding', blurPlResult.value, () => undefined);
3271
+
3272
+ // M2-T4: bloom-blur H/V pipelines via getOrBuildPipeline (lazy-build).
3273
+ // H and V share the same PSO descriptor — only per-axis texelSize UBO
3274
+ // distinguishes them at record time. getOrBuildPipeline cache-hit on the
3275
+ // second call returns the same handle (identical spec, identical PSO).
3276
+ {
3277
+ const blurSpec: PipelineSpec = {
3278
+ shader: {
3279
+ id: 'forgeax::post::bloom-blur',
3280
+ passKind: 'post-process',
3281
+ variantSet: undefined,
3282
+ },
3283
+ attachments: {
3284
+ colorFormats: [HDR_COLOR_ATTACHMENT_FORMAT],
3285
+ depthFormat: undefined,
3286
+ sampleCount: 1,
3287
+ },
3288
+ geometry: {
3289
+ topology: 'triangle-list',
3290
+ stripIndexFormat: undefined,
3291
+ vertexLayout: {},
3292
+ },
3293
+ renderState: { cullMode: 'none' },
3294
+ };
3295
+ const modules = {
3296
+ vertex: bloomBlurModule,
3297
+ fragment: bloomBlurModule,
3298
+ layout: blurPlResult.value,
3299
+ label: 'bloom-blur-h-pipeline',
3300
+ };
3301
+ try {
3302
+ bloomBlurHPipelineHandle = getOrBuildPipeline(
3303
+ blurSpec,
3304
+ pipelineDeviceProvider,
3305
+ pipelineCache,
3306
+ modules,
3307
+ ) as RenderPipeline;
3308
+ bloomBlurHPipelineHandle = adopt(
3309
+ 'pipeline',
3310
+ bloomBlurHPipelineHandle,
3311
+ () => undefined,
3312
+ );
3313
+ } catch (err) {
3314
+ if (err instanceof PipelineSpecError) throw err;
3315
+ throw new PipelineSpecError({
3316
+ code: 'pipeline-build-failed',
3317
+ detail: { cause: err },
3318
+ hint: 'createRenderPipeline (bloom-blur-h fullscreen) failed; inspect gpuMessage',
3319
+ });
3320
+ }
3321
+ // V is cache-hit on the same spec (identical PSO; per-axis UBO differentiates at record time).
3322
+ bloomBlurVPipelineHandle = bloomBlurHPipelineHandle;
3323
+ }
3324
+
3325
+ // Blur params UBOs: 16 B std140 (texelSize.xy + radius + pad) each.
3326
+ // bug-20260625: one per axis -- H and V must not share a buffer (the
3327
+ // shared-buffer writeBuffer race made both passes blur vertically).
3328
+ const blurHParamsResult = runShimSyncStep(
3329
+ () =>
3330
+ rhiDevice.createBuffer({
3331
+ label: 'bloom-blur-h-params-ubo',
3332
+ size: BLUR_PARAMS_BYTES,
3333
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
3334
+ mappedAtCreation: false,
3335
+ }),
3336
+ 'webgpu-runtime-error',
3337
+ 'createBuffer (bloom-blur-h params UBO) succeeded',
3338
+ 'check device.limits.maxUniformBufferBindingSize',
3339
+ );
3340
+ if (!blurHParamsResult.ok) throw blurHParamsResult.error;
3341
+ bloomBlurHParamsBufferHandle = adopt('buffer', blurHParamsResult.value, (value) => {
3342
+ rhiDevice.destroyBuffer(value);
3343
+ });
3344
+
3345
+ const blurVParamsResult = runShimSyncStep(
3346
+ () =>
3347
+ rhiDevice.createBuffer({
3348
+ label: 'bloom-blur-v-params-ubo',
3349
+ size: BLUR_PARAMS_BYTES,
3350
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
3351
+ mappedAtCreation: false,
3352
+ }),
3353
+ 'webgpu-runtime-error',
3354
+ 'createBuffer (bloom-blur-v params UBO) succeeded',
3355
+ 'check device.limits.maxUniformBufferBindingSize',
3356
+ );
3357
+ if (!blurVParamsResult.ok) throw blurVParamsResult.error;
3358
+ bloomBlurVParamsBufferHandle = adopt('buffer', blurVParamsResult.value, (value) => {
3359
+ rhiDevice.destroyBuffer(value);
3360
+ });
3361
+ }
3362
+
3363
+ // Bloom composite: 2-tex + sampler + UBO@3 BGL (D-4, D-5).
3364
+ if (bloomCompositeModule !== null && bloomSamplerHandle !== null) {
3365
+ const compositeBglResult = runShimSyncStep(
3366
+ () =>
3367
+ rhiDevice.createBindGroupLayout({
3368
+ label: 'bloom-composite-bgl',
3369
+ entries: [
3370
+ {
3371
+ binding: 0,
3372
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3373
+ texture: { sampleType: 'float', viewDimension: '2d' },
3374
+ },
3375
+ {
3376
+ binding: 1,
3377
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3378
+ texture: { sampleType: 'float', viewDimension: '2d' },
3379
+ },
3380
+ {
3381
+ binding: 2,
3382
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3383
+ sampler: { type: 'filtering' },
3384
+ },
3385
+ {
3386
+ binding: 3,
3387
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3388
+ buffer: { type: 'uniform' },
3389
+ },
3390
+ ],
3391
+ }),
3392
+ 'webgpu-runtime-error',
3393
+ 'createBindGroupLayout(bloom-composite) succeeded',
3394
+ 'check device.limits.maxBindGroupsPerPipelineLayout',
3395
+ );
3396
+ if (!compositeBglResult.ok) throw compositeBglResult.error;
3397
+ bloomCompositeBglHandle = adopt('binding', compositeBglResult.value, () => undefined);
3398
+
3399
+ const compositePlResult = runShimSyncStep(
3400
+ () =>
3401
+ rhiDevice.createPipelineLayout({
3402
+ label: 'bloom-composite-pl',
3403
+ bindGroupLayouts: [compositeBglResult.value],
3404
+ }),
3405
+ 'webgpu-runtime-error',
3406
+ 'createPipelineLayout(bloom-composite) succeeded',
3407
+ 'verify bloom-composite BindGroupLayout matches shader @group(0) bindings',
3408
+ );
3409
+ if (!compositePlResult.ok) throw compositePlResult.error;
3410
+ adopt('binding', compositePlResult.value, () => undefined);
3411
+
3412
+ // M2-T4: bloom-composite pipeline via getOrBuildPipeline (lazy-build).
3413
+ {
3414
+ const compositeSpec: PipelineSpec = {
3415
+ shader: {
3416
+ id: 'forgeax::post::bloom-composite',
3417
+ passKind: 'post-process',
3418
+ variantSet: undefined,
3419
+ },
3420
+ attachments: {
3421
+ colorFormats: [HDR_COLOR_ATTACHMENT_FORMAT],
3422
+ depthFormat: undefined,
3423
+ sampleCount: 1,
3424
+ },
3425
+ geometry: {
3426
+ topology: 'triangle-list',
3427
+ stripIndexFormat: undefined,
3428
+ vertexLayout: {},
3429
+ },
3430
+ renderState: { cullMode: 'none' },
3431
+ };
3432
+ const modules = {
3433
+ vertex: bloomCompositeModule,
3434
+ fragment: bloomCompositeModule,
3435
+ layout: compositePlResult.value,
3436
+ };
3437
+ try {
3438
+ bloomCompositePipelineHandle = getOrBuildPipeline(
3439
+ compositeSpec,
3440
+ pipelineDeviceProvider,
3441
+ pipelineCache,
3442
+ modules,
3443
+ ) as RenderPipeline;
3444
+ bloomCompositePipelineHandle = adopt(
3445
+ 'pipeline',
3446
+ bloomCompositePipelineHandle,
3447
+ () => undefined,
3448
+ );
3449
+ } catch (err) {
3450
+ if (err instanceof PipelineSpecError) throw err;
3451
+ throw new PipelineSpecError({
3452
+ code: 'pipeline-build-failed',
3453
+ detail: { cause: err },
3454
+ hint: 'createRenderPipeline (bloom-composite fullscreen) failed; inspect gpuMessage',
3455
+ });
3456
+ }
3457
+ }
3458
+
3459
+ // Composite params UBO: 16 B std140 (intensity f32 + 12 B pad).
3460
+ const compositeParamsResult = runShimSyncStep(
3461
+ () =>
3462
+ rhiDevice.createBuffer({
3463
+ label: 'bloom-composite-params-ubo',
3464
+ size: COMPOSITE_PARAMS_BYTES,
3465
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
3466
+ mappedAtCreation: false,
3467
+ }),
3468
+ 'webgpu-runtime-error',
3469
+ 'createBuffer (bloom-composite params UBO) succeeded',
3470
+ 'check device.limits.maxUniformBufferBindingSize',
3471
+ );
3472
+ if (!compositeParamsResult.ok) throw compositeParamsResult.error;
3473
+ bloomCompositeParamsBufferHandle = adopt(
3474
+ 'buffer',
3475
+ compositeParamsResult.value,
3476
+ (value) => {
3477
+ rhiDevice.destroyBuffer(value);
3478
+ },
3479
+ );
3480
+ }
3481
+
3482
+ bloomCandidateBundle = {
3483
+ scope: candidateScope,
3484
+ generation: bloomGeneration + 1,
3485
+ bloomBrightPipeline: bloomBrightPipelineHandle,
3486
+ bloomBlurHPipeline: bloomBlurHPipelineHandle,
3487
+ bloomBlurVPipeline: bloomBlurVPipelineHandle,
3488
+ bloomCompositePipeline: bloomCompositePipelineHandle,
3489
+ bloomBrightBindGroupLayout: bloomBrightBglHandle,
3490
+ bloomBlurBindGroupLayout: bloomBlurBglHandle,
3491
+ bloomCompositeBindGroupLayout: bloomCompositeBglHandle,
3492
+ bloomSampler: bloomSamplerHandle,
3493
+ bloomBrightParamsBuffer: bloomBrightParamsBufferHandle,
3494
+ bloomBlurHParamsBuffer: bloomBlurHParamsBufferHandle,
3495
+ bloomBlurVParamsBuffer: bloomBlurVParamsBufferHandle,
3496
+ bloomCompositeParamsBuffer: bloomCompositeParamsBufferHandle,
3497
+ };
3498
+ inspectBloomResources = (graph): BloomInspection => {
3499
+ const retiring = [...retiringBloomBundles].sort(
3500
+ (left, right) => left.generation - right.generation,
3501
+ );
3502
+ const active = bloomActiveBundle?.scope.isAlive() ? bloomActiveBundle : undefined;
3503
+ const liveRetiring = retiring.filter((bundle) => bundle.scope.state === 'retiring');
3504
+ const selected = active ?? liveRetiring[liveRetiring.length - 1];
3505
+ if (selected === undefined) {
3506
+ return {
3507
+ graphStatus: 'empty',
3508
+ enabled: false,
3509
+ targetCount: 0,
3510
+ targetBytes: 0,
3511
+ resourceCount: 0,
3512
+ passCount: 0,
3513
+ encodeCount: 0,
3514
+ bindGroupCount: 0,
3515
+ uploadCount: 0,
3516
+ residentChildBytes: 0,
3517
+ generation: 0,
3518
+ state: 'off',
3519
+ };
3520
+ }
3521
+ const state = active === undefined ? 'retiring' : 'active';
3522
+ const ownedBundles = [...(active === undefined ? [] : [active]), ...liveRetiring];
3523
+ const graphInspection = inspectStandardBloomGraph(graph);
3524
+ const receipts = lastSuccessfulBloomReceipts ?? {
3525
+ uploadCount: 0,
3526
+ bindGroupCount: 0,
3527
+ encodeCount: 0,
3528
+ };
3529
+ return {
3530
+ graphStatus: active === undefined ? 'empty' : graphInspection.status,
3531
+ enabled: active !== undefined,
3532
+ targetCount: active === undefined ? 0 : graphInspection.targetCount,
3533
+ targetBytes: active === undefined ? 0 : graphInspection.targetBytes,
3534
+ resourceCount: ownedBundles.reduce(
3535
+ (count, bundle) => count + bundle.scope.resourceDelta(),
3536
+ 0,
3537
+ ),
3538
+ passCount: active === undefined ? 0 : graphInspection.passCount,
3539
+ encodeCount: active === undefined ? 0 : receipts.encodeCount,
3540
+ bindGroupCount: active === undefined ? 0 : receipts.bindGroupCount,
3541
+ uploadCount: active === undefined ? 0 : receipts.uploadCount,
3542
+ residentChildBytes: 0,
3543
+ generation: selected?.generation ?? 0,
3544
+ state,
3545
+ };
3546
+ };
3547
+ commitBloomResources = () => {
3548
+ if (bloomCandidateBundle?.scope !== candidateScope) return;
3549
+ bloomActiveBundle = bloomCandidateBundle;
3550
+ bloomCandidateBundle = undefined;
3551
+ bloomGeneration += 1;
3552
+ bloomResourcesReady = true;
3553
+ };
3554
+ discardBloomResources = () => {
3555
+ if (bloomCandidateBundle?.scope !== candidateScope) return;
3556
+ candidateScope.abandon();
3557
+ bloomCandidateBundle = undefined;
3558
+ bloomResourcesReady = false;
3559
+ };
3560
+ const reportBloomFenceFailure = (cause: unknown): void => {
3561
+ if (cause instanceof RhiError) {
3562
+ errorRegistry.fire(cause);
3563
+ return;
3564
+ }
3565
+ const detailError = {
3566
+ code: 'bloom-fence-rejected',
3567
+ message: String(cause),
3568
+ ...(cause instanceof Error ? { name: cause.name } : {}),
3569
+ };
3570
+ errorRegistry.fire(
3571
+ new RhiError({
3572
+ code: 'webgpu-runtime-error',
3573
+ expected: 'Bloom queue completion resolves after the submitted frame retires',
3574
+ hint: 'inspect renderer errors for the queue completion cause',
3575
+ detail: { error: detailError },
3576
+ }),
3577
+ );
3578
+ };
3579
+ retireBloomResources = (completion) => {
3580
+ const retiring = bloomActiveBundle;
3581
+ if (retiring === undefined) return;
3582
+ bloomActiveBundle = undefined;
3583
+ bloomResourcesReady = false;
3584
+ retiringBloomBundles.add(retiring);
3585
+ retiring.scope.beginRetire();
3586
+ void completion.then(
3587
+ () => {
3588
+ if (!retiringBloomBundles.has(retiring)) return;
3589
+ retiring.scope.retire();
3590
+ retiringBloomBundles.delete(retiring);
3591
+ },
3592
+ (cause) => {
3593
+ if (!retiringBloomBundles.has(retiring)) return;
3594
+ reportBloomFenceFailure(cause);
3595
+ retiring.scope.retire();
3596
+ retiringBloomBundles.delete(retiring);
3597
+ },
3598
+ );
3599
+ };
3600
+ drainBloomResources = () => {
3601
+ if (bloomCandidateBundle !== undefined) {
3602
+ bloomCandidateBundle.scope.abandon();
3603
+ bloomCandidateBundle = undefined;
3604
+ }
3605
+ if (bloomActiveBundle !== undefined) {
3606
+ bloomActiveBundle.scope.retire();
3607
+ bloomActiveBundle = undefined;
3608
+ }
3609
+ for (const retiring of retiringBloomBundles) retiring.scope.retire();
3610
+ retiringBloomBundles.clear();
3611
+ bloomResourcesReady = false;
3612
+ };
3613
+ } catch (cause) {
3614
+ candidateScope.abandon();
3615
+ if (bloomCandidateBundle?.scope === candidateScope) bloomCandidateBundle = undefined;
3616
+ commitBloomResources = undefined;
3617
+ discardBloomResources = undefined;
3618
+ drainBloomResources = undefined;
3619
+ bloomBrightPipelineHandle = null;
3620
+ bloomBlurHPipelineHandle = null;
3621
+ bloomBlurVPipelineHandle = null;
3622
+ bloomCompositePipelineHandle = null;
3623
+ bloomBrightBglHandle = null;
3624
+ bloomBlurBglHandle = null;
3625
+ bloomCompositeBglHandle = null;
3626
+ bloomSamplerHandle = null;
3627
+ bloomBrightParamsBufferHandle = null;
3628
+ bloomBlurHParamsBufferHandle = null;
3629
+ bloomBlurVParamsBufferHandle = null;
3630
+ bloomCompositeParamsBufferHandle = null;
3631
+ throw cause;
3632
+ }
3633
+ };
3634
+
3635
+ // ── feat-20260612-hdrp-ssao M6 / w26 + w43 + M8 / w37 ───────────────────
3636
+ //
3637
+ // SSAO post-processing chain: 2 passes (calc + blur) with a dedicated
3638
+ // 9-entry BGL matching hdrp-ssao.wgsl @group(0) bindings 0-8 (D-A + D-D).
3639
+ // Both pipelines share the same BGL (calc binds 0-6, blur binds 7-8 +
3640
+ // reuses the 256B uniform write); the WGSL declares all entries even when
3641
+ // a given pass leaves some unused, so wgpu/dawn pipeline-layout matching
3642
+ // is one-shot.
3643
+ //
3644
+ // w37 dawn-blocker fix (carry from w27-a): pre-M8 the BGL had a single
3645
+ // sampler at binding 3 typed 'filtering' that paired with the depth
3646
+ // texture at binding 5. WebGPU requires depth textures to be sampled with
3647
+ // a non-filtering / comparison sampler — the mismatch crashed every HDRP
3648
+ // PSO build on dawn (7 dawn tests red unrelated to SSAO itself).
3649
+ // ssao_depth_sampler at binding 6 (non-filtering) is dedicated to
3650
+ // hdr_depth; the existing filtering sampler at binding 3 stays for the
3651
+ // float noise / gbuffer_normal textures.
3652
+ //
3653
+ // Fullscreen triangle vertex (vs_ssao), R8 scalar fragment output. Cull
3654
+ // none, no depth/stencil (fullscreen post-process pass).
3655
+ if (ssaoModule !== null) {
3656
+ // Dedicated SSAO BGL: 9 entries (bindings 0-8 per current WGSL).
3657
+ // 0 = uniform (SsaoUniform 256B)
3658
+ // 1 = uniform (kernel UBO)
3659
+ // 2 = texture_2d (noise)
3660
+ // 3 = sampler (filtering, for noise / normal float textures)
3661
+ // 4 = texture_2d (gbuffer_normal)
3662
+ // 5 = texture_depth_2d (hdrDepth)
3663
+ // 6 = sampler (non-filtering, dedicated to depth) -- w37
3664
+ // 7 = texture_2d (ssaoRaw, blur input) -- w37
3665
+ // 8 = sampler (filtering, for ssaoRaw) -- w37
3666
+ const ssaoBglResult = runShimSyncStep(
3667
+ () =>
3668
+ rhiDevice.createBindGroupLayout({
3669
+ label: 'ssao-bgl',
3670
+ entries: [
3671
+ {
3672
+ binding: 0,
3673
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3674
+ buffer: { type: 'uniform' },
3675
+ },
3676
+ {
3677
+ binding: 1,
3678
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3679
+ buffer: { type: 'uniform' },
3680
+ },
3681
+ {
3682
+ binding: 2,
3683
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3684
+ // hdrp-ssao-noise is rgba32float; without the
3685
+ // float32-filterable extension this format is unfilterable.
3686
+ // The SSAO noise generator uses NEAREST/REPEAT sampling
3687
+ // so unfilterable-float is sufficient and works on dawn.
3688
+ texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
3689
+ },
3690
+ {
3691
+ binding: 3,
3692
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3693
+ // The noise sampler (binding 2 + 4) must be non-filtering
3694
+ // because binding 2 is unfilterable-float. WebGPU validation
3695
+ // pairs sampler 'filtering' kind with filterable textures
3696
+ // only; using non-filtering for the noise + gbuffer_normal
3697
+ // path keeps both samples valid.
3698
+ sampler: { type: 'non-filtering' },
3699
+ },
3700
+ {
3701
+ binding: 4,
3702
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3703
+ texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
3704
+ },
3705
+ {
3706
+ binding: 5,
3707
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3708
+ texture: { sampleType: 'depth', viewDimension: '2d' },
3709
+ },
3710
+ {
3711
+ binding: 6,
3712
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3713
+ sampler: { type: 'non-filtering' },
3714
+ },
3715
+ {
3716
+ binding: 7,
3717
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3718
+ texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
3719
+ },
3720
+ {
3721
+ binding: 8,
3722
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
3723
+ // Shared with binding 3 (single sampler resource); both BGL
3724
+ // entries must declare the same sampler-type kind.
3725
+ sampler: { type: 'non-filtering' },
3726
+ },
3727
+ ],
3728
+ }),
3729
+ 'webgpu-runtime-error',
3730
+ 'createBindGroupLayout(ssao) succeeded',
3731
+ 'check device.limits.maxBindGroupsPerPipelineLayout',
3732
+ );
3733
+ if (!ssaoBglResult.ok) throw ssaoBglResult.error;
3734
+ ssaoBglHandle = ssaoBglResult.value;
3735
+
3736
+ const ssaoPipelineLayoutResult = runShimSyncStep(
3737
+ () =>
3738
+ rhiDevice.createPipelineLayout({
3739
+ label: 'ssao-pl',
3740
+ bindGroupLayouts: [ssaoBglResult.value],
3741
+ }),
3742
+ 'webgpu-runtime-error',
3743
+ 'createPipelineLayout(ssao) succeeded',
3744
+ 'verify SSAO BindGroupLayout matches shader @group(2) bindings',
3745
+ );
3746
+ if (!ssaoPipelineLayoutResult.ok) throw ssaoPipelineLayoutResult.error;
3747
+
3748
+ // M2-T4: SSAO calc + blur pipelines via getOrBuildPipeline (lazy-build).
3749
+ // Different fragment entry points distinguish calc (fs_ssao_calc) from
3750
+ // blur (fs_ssao_blur); distinct synthetic shader IDs ensure correct cache
3751
+ // key separation (same module, different entry points → different PSOs).
3752
+ {
3753
+ const layout = ssaoPipelineLayoutResult.value;
3754
+ const baseModules = {
3755
+ vertex: ssaoModule,
3756
+ fragment: ssaoModule,
3757
+ vertexEntryPoint: 'vs_ssao',
3758
+ layout,
3759
+ };
3760
+ // SSAO calc
3761
+ {
3762
+ const calcSpec: PipelineSpec = {
3763
+ shader: {
3764
+ id: 'forgeax::post::ssao-calc',
3765
+ passKind: 'post-process',
3766
+ variantSet: undefined,
3767
+ },
3768
+ attachments: {
3769
+ colorFormats: ['r8unorm'],
3770
+ depthFormat: undefined,
3771
+ sampleCount: 1,
3772
+ },
3773
+ geometry: {
3774
+ topology: 'triangle-list',
3775
+ stripIndexFormat: undefined,
3776
+ vertexLayout: {},
3777
+ },
3778
+ renderState: { cullMode: 'none' },
3779
+ };
3780
+ try {
3781
+ ssaoCalcPipelineHandle = getOrBuildPipeline(
3782
+ calcSpec,
3783
+ pipelineDeviceProvider,
3784
+ pipelineCache,
3785
+ { ...baseModules, fragmentEntryPoint: 'fs_ssao_calc' },
3786
+ ) as RenderPipeline;
3787
+ } catch (err) {
3788
+ if (err instanceof PipelineSpecError) throw err;
3789
+ throw new PipelineSpecError({
3790
+ code: 'pipeline-build-failed',
3791
+ detail: { cause: err },
3792
+ hint: 'createRenderPipeline (ssao-calc fullscreen) failed; inspect gpuMessage',
3793
+ });
3794
+ }
3795
+ }
3796
+ // SSAO blur
3797
+ {
3798
+ const blurSpec: PipelineSpec = {
3799
+ shader: {
3800
+ id: 'forgeax::post::ssao-blur',
3801
+ passKind: 'post-process',
3802
+ variantSet: undefined,
3803
+ },
3804
+ attachments: {
3805
+ colorFormats: ['r8unorm'],
3806
+ depthFormat: undefined,
3807
+ sampleCount: 1,
3808
+ },
3809
+ geometry: {
3810
+ topology: 'triangle-list',
3811
+ stripIndexFormat: undefined,
3812
+ vertexLayout: {},
3813
+ },
3814
+ renderState: { cullMode: 'none' },
3815
+ };
3816
+ try {
3817
+ ssaoBlurPipelineHandle = getOrBuildPipeline(
3818
+ blurSpec,
3819
+ pipelineDeviceProvider,
3820
+ pipelineCache,
3821
+ { ...baseModules, fragmentEntryPoint: 'fs_ssao_blur' },
3822
+ ) as RenderPipeline;
3823
+ } catch (err) {
3824
+ if (err instanceof PipelineSpecError) throw err;
3825
+ throw new PipelineSpecError({
3826
+ code: 'pipeline-build-failed',
3827
+ detail: { cause: err },
3828
+ hint: 'createRenderPipeline (ssao-blur fullscreen) failed; inspect gpuMessage',
3829
+ });
3830
+ }
3831
+ }
3832
+ }
3833
+ }
3834
+ }
3835
+
3836
+ // feat-20260601-device/gpu-residency-extraction M1 (D-9 sub-contract 1): prewarm
3837
+ // the mipmap pipeline cache for the smoke texture formats while still on the
3838
+ // async `renderer.initialization` path. This builds the one-time mipmap shader module +
3839
+ // per-format pipeline into the deviceCache so the record-stage texture
3840
+ // ensureResident (sync) reproduces the pre-extraction async uploadTexture
3841
+ // byte-for-byte without an async stall in the synchronous draw frame. A build
3842
+ // failure here is surfaced through ready's reject channel (structured RhiError).
3843
+ //
3844
+ // Gated on `manifestEntries.length > 0` (the same Camera-only / clear-pass
3845
+ // skip as the Step-2 pipeline compile, bug-20260519 D-3): a zero-manifest
3846
+ // world renders no material geometry, so no texture is ever made resident and
3847
+ // the mipmap shader-module build (a `createShaderModule` call) must not fire
3848
+ // -- preserving the zero-manifest "0 createShaderModule" invariant
3849
+ // (renderer-ready.test.ts AC-02).
3850
+ if (manifestEntries.length > 0) {
3851
+ const prewarmRes = await gpuStore.prewarmMipmapPipeline(rhiDevice, MIPMAP_PREWARM_FORMATS);
3852
+ if (!prewarmRes.ok) throw prewarmRes.error;
3853
+ }
3854
+
3855
+ const pipelineState: PipelineState = {
3856
+ device: rhiDevice,
3857
+ // feat-20260518-pbr-direct-lighting-mvp M5 / w22.10 (AC-06 dual->triple
3858
+ // pipeline + D-2 + D-10): three distinct GPU render-pipeline handles
3859
+ // backed by 2 distinct shader modules (unlit + pbr) x 2 vertex stride
3860
+ // configurations (6F builtin + 12F procedural; the 6F + pbr combination
3861
+ // does not exist per D-2). All three share the identical 4-BindGroupLayout
3862
+ // chain so material BG entries built once compose for each of the three
3863
+ // pipelines (charter P5 consistent abstraction). The legacy `pipeline`
3864
+ // alias field has been retired; consumers select per
3865
+ // (mat.materialShaderId, mesh.layout) tuple via the record-stage three-way
3866
+ // setPipeline branch (w22.11).
3867
+ // bug-20260519: BUILTIN cube migrated to 12F so the legacy
3868
+ // `unlitBuiltinPipeline` (+ its zero-stride `unlitBuiltinDummyAttrBuffer`)
3869
+ // is gone; consumers pick per `mat.materialShaderId` only via the record-stage
3870
+ // 2-way `setPipeline` branch.
3871
+ // feat-20260615-pipeline-spec-ssot M2-T4: standard material PSOs are
3872
+ // pre-warmed in SPEC_CONST_TABLE and cached in pipelineCache. Cache
3873
+ // lookup replaces the prior local-handle variables (SSOT axiom D-12).
3874
+ // lookupSpecInTable resolves entries by (shaderId, isHdr, sampleCount);
3875
+ // null when the spec entry's module was not compiled (empty-manifest path).
3876
+ // (see definition near SPEC_CONST boot-time pre-warm block above)
3877
+ unlitPipeline: getCachedPipelineOrNull('forgeax::default-unlit', false, 1),
3878
+ standardPipeline: getCachedPipelineOrNull('forgeax::default-standard-pbr', false, 1),
3879
+ unlitPipelineMsaa: getCachedPipelineOrNull('forgeax::default-unlit', false, 4),
3880
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w14 (D-7):
3881
+ // four sprite-dedicated boot-time pre-warms are gone. Sprite PSO now
3882
+ // lands lazily through the generic per-MaterialShader pipeline cache
3883
+ // (`getMaterialShaderPipeline('forgeax::sprite', ...)`) keyed on
3884
+ // premultiplied-alpha renderState at draw time. -4 entries off
3885
+ // SPEC_CONST_TABLE (15 from 19); -4 fields off PipelineState.
3886
+ unlitPipelineHdrMsaa: getCachedPipelineOrNull('forgeax::default-unlit', true, 4),
3887
+ // feat-20260523-shader-template-instance-split M9-T03 (D-PipelineBuilder):
3888
+ // expose the shared pbr/unlit/sprite pipeline layout so the per-
3889
+ // MaterialShader pipeline cache callback (createRenderer.ts
3890
+ // getMaterialShaderPipeline) can reuse it at lazy build time without
3891
+ // re-running pbrLayouts construction. `null` when the manifest is empty
3892
+ // (Camera-only path; bug-20260519 D-3 nullable parallel to the unlit /
3893
+ // standard pipeline fields above).
3894
+ pbrPipelineLayout:
3895
+ unlitModule !== null && pbrModule !== null ? pipelineLayoutResult.value : null,
3896
+ // HDRP variant uses the unified group(2) layout built above.
3897
+ hdrpPbrPipelineLayout:
3898
+ unlitModule !== null && pbrModule !== null ? hdrpPbrPipelineLayoutHandle : null,
3899
+ hdrpProbePbrPipelineLayout:
3900
+ unlitModule !== null && pbrModule !== null ? hdrpProbePbrPipelineLayoutHandle : null,
3901
+ hdrpClusterMembershipPipeline,
3902
+ hdrpClusterMembershipBindGroupLayout,
3903
+ // Skin variant uses its dedicated mesh-array layout; both handles are
3904
+ // gated on the same boot modules and may be null for camera-only startup.
3905
+ pbrSkinPipelineLayout:
3906
+ unlitModule !== null && pbrModule !== null ? pbrSkinPipelineLayoutHandle : null,
3907
+ pbrSkinProbePipelineLayout:
3908
+ unlitModule !== null && pbrModule !== null ? pbrSkinProbePipelineLayoutHandle : null,
3909
+ pbrSkinMeshBindGroupLayout:
3910
+ unlitModule !== null && pbrModule !== null ? pbrSkinMeshBindGroupLayoutHandle : null,
3911
+ hdrpSkinPipelineLayout:
3912
+ unlitModule !== null && pbrModule !== null ? hdrpSkinPipelineLayoutHandle : null,
3913
+ hdrpSkinMeshBindGroupLayout:
3914
+ unlitModule !== null && pbrModule !== null ? hdrpSkinMeshBindGroupLayoutHandle : null,
3915
+ // feat-20260612-skin-palette-per-frame-upload M1 / m1-2: animator-ready
3916
+ // skin-palette allocator (replaces the prior identity-buffer stub).
3917
+ // Same gating as `pbrSkinPipelineLayout` -- `null` when the skin
3918
+ // pipeline-layout build itself failed.
3919
+ skinPaletteAllocator:
3920
+ unlitModule !== null && pbrModule !== null ? skinPaletteAllocatorHandle : null,
3921
+ meshes: meshHandles,
3922
+ format: swapChainFormats.storage,
3923
+ colorAttachmentFormat: swapChainFormats.view,
3924
+ surfaceProfile: surfaceViewFormats ? 'dual-view' : 'raw-only',
3925
+ viewBindGroupLayout: viewBglResult.value,
3926
+ extendedLightingAvailable: extendedLightingShaderAvailable,
3927
+ projectorAvailable,
3928
+ materialBindGroupLayout: materialBglResult.value,
3929
+ meshBindGroupLayout: meshArrayBglResult.value,
3930
+ viewUniformBuffer: viewUboResult.value,
3931
+ pointsLinesViewBuffer: pointsLinesViewBufferResult.value,
3932
+ shadowCasterCascadeBuffer: shadowCasterCascadeUboResult.value,
3933
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
3934
+ materialUniformBuffer: meshSsboState.material,
3935
+ meshStorageBuffer: meshSsboState.mesh,
3936
+ instancesBindGroupLayout: instancesBglResult.value,
3937
+ probeInstancesBindGroupLayout: probeInstancesBglResult.value,
3938
+ identityInstanceBuffer: identityInstanceResult.value,
3939
+ defaultSampler: defaultSamplerResult.value,
3940
+ nearestSampler: nearestSamplerResult.value,
3941
+ fallbackTextureView: fallbackTextureViewResult.value,
3942
+ defaultWhiteTextureView: fallbackTextureViewResult.value,
3943
+ // Normal-slot fallback view (1x1 RGBA8 (128,128,255,255)). RG=(128,128)
3944
+ // decodes to tangent (0,0,1) under pbr.wgsl's RG-only normal decoder.
3945
+ // Distinct from defaultWhiteTextureView because RG=(255,255)=1.0 gives
3946
+ // sqrt(1 - 1 - 1) = NaN, breaking the no-normal-map case.
3947
+ defaultNormalTextureView: fallbackNormalTextureViewResult.value,
3948
+ // feat-20260519-tonemap-reinhard-mvp M2 / T-M2.5: HDR fallback for the
3949
+ // built-in unlit pipeline (rgba16float colour attachment). Standard
3950
+ // material variants are resolved by the material-shader cache.
3951
+ unlitPipelineHdr: getCachedPipelineOrNull('forgeax::default-unlit', true, 1),
3952
+ // feat-20260520-directional-light-shadow-mapping M2 / w14 (D-1):
3953
+ // 1x1 depth32float fallback bound at viewBindGroup binding(3).
3954
+ shadowFallbackTextureView: shadowFallbackViewResult.value,
3955
+ ...extendedLightingFallback,
3956
+ // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: 1x1x6
3957
+ // depth32float cube_array fallback bound at viewBindGroup binding(5)
3958
+ // when no PointLightShadow snapshots are active. Always-present
3959
+ // (24 B GPU footprint); ShadowAtlas takes over when a real frame has
3960
+ // pointShadowSnapshots.length > 0.
3961
+ shadowAtlasFallbackTextureView: shadowAtlasFallbackViewResult.value,
3962
+ // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: 64 B point
3963
+ // shadow params UBO bound at viewBindGroup binding(6). Written per
3964
+ // frame from `frameState.pointShadowSnapshots`.
3965
+ shadowParamsBuffer: shadowParamsBufferResult.value,
3966
+ // feat-20260625-spot-light-shadow-mapping w25: the spot lightViewProj
3967
+ // matrices fold into the View UBO tail (`view.spotLightViewProj`); no
3968
+ // dedicated buffer / view-BG binding 9 (WebGL2 uniform-buffer budget).
3969
+ skylightFallback,
3970
+ // feat-20260529-rendergraph-pass-abstraction M3 / w11 (D-2 + Finding 3):
3971
+ // per-pass mutable resource slots moved to PerPassResources.
3972
+ perPassResources: {
3973
+ depthTexture: null,
3974
+ depthTextureView: null,
3975
+ depthTextureWidth: 0,
3976
+ depthTextureHeight: 0,
3977
+ configured: false,
3978
+ hdrColorTexture: null,
3979
+ hdrColorView: null,
3980
+ hdrDepthTexture: null,
3981
+ hdrDepthView: null,
3982
+ hdrTextureWidth: 0,
3983
+ hdrTextureHeight: 0,
3984
+ hdrDepthSampleCount: 1,
3985
+ fxaaPipeline: fxaaPipelineHandle,
3986
+ fxaaBindGroupLayout: fxaaBglHandle,
3987
+ fxaaSampler: fxaaSamplerHandle,
3988
+ // feat-20260604-learn-render-4.10-anti-aliasing-msaa M2 / w7: MSAA
3989
+ // attachment slots. All null/0 until the first antialias='msaa' frame.
3990
+ msaaColorTexture: null,
3991
+ msaaColorView: null,
3992
+ msaaSpriteColorTexture: null,
3993
+ msaaSpriteColorView: null,
3994
+ msaaDepthTexture: null,
3995
+ msaaDepthView: null,
3996
+ msaaTextureWidth: 0,
3997
+ msaaTextureHeight: 0,
3998
+ hdrColorMsaaTexture: null,
3999
+ hdrColorMsaaView: null,
4000
+ skyboxPipeline: skyboxPipelineHandle,
4001
+ skyboxPipelineMsaa: skyboxPipelineMsaaHandle,
4002
+ skyboxBindGroupLayout: skyboxBglHandle,
4003
+ skyboxSampler: skyboxSamplerHandle,
4004
+ skyboxRotationBuffer: skyboxRotationBufferHandle,
4005
+ shadowTexture: null,
4006
+ shadowMapSize: 0,
4007
+ shadowCascadeCount: 0,
4008
+ shadowSampler: shadowSamplerResult.value,
4009
+ shadowLightSpaceMatrix: null,
4010
+ shadowCsmLightViewProj: null,
4011
+ shadowCsmSelection: null,
4012
+ // feat-20260531-bloom-first-declarative-render-graph-pass / w13 + w16:
4013
+ // bloom per-pass resource slots. Pipeline handles assembled during
4014
+ // buildReadyWebGPU (marker-triage + compile + createRenderPipeline).
4015
+ // Intermediate textures are allocate in the execute closures at 1/2-res
4016
+ // (ensureLazyTexture, slot width/height tracking for size-drift rebuild).
4017
+ // BindGroup caches survive until the intermediate view is invalidated
4018
+ // by a resize (width/height drift forces null).
4019
+ bloomBrightPipeline: null,
4020
+ bloomBlurHPipeline: null,
4021
+ bloomBlurVPipeline: null,
4022
+ bloomCompositePipeline: null,
4023
+ bloomBrightBindGroupLayout: null,
4024
+ bloomBlurBindGroupLayout: null,
4025
+ bloomCompositeBindGroupLayout: null,
4026
+ bloomSampler: null,
4027
+ bloomBrightParamsBuffer: null,
4028
+ bloomBlurHParamsBuffer: null,
4029
+ bloomBlurVParamsBuffer: null,
4030
+ bloomCompositeParamsBuffer: null,
4031
+ ensureBloomResources: ensureBloomResourcesStable,
4032
+ getBloomResources: getBloomResourcesStable,
4033
+ commitBloomResources: commitBloomResourcesStable,
4034
+ commitBloomFrameReceipts: (receipts) => {
4035
+ lastSuccessfulBloomReceipts = receipts;
4036
+ },
4037
+ discardBloomResources: discardBloomResourcesStable,
4038
+ retireBloomResources: retireBloomResourcesStable,
4039
+ drainBloomResources: drainBloomResourcesStable,
4040
+ inspectBloomResources: inspectBloomResourcesStable,
4041
+ bloomBrightTexture: null,
4042
+ bloomBrightView: null,
4043
+ bloomBrightWidth: 0,
4044
+ bloomBrightHeight: 0,
4045
+ bloomBlurHTexture: null,
4046
+ bloomBlurHView: null,
4047
+ bloomBlurHWidth: 0,
4048
+ bloomBlurHHeight: 0,
4049
+ bloomBlurVTexture: null,
4050
+ bloomBlurVView: null,
4051
+ bloomBlurVWidth: 0,
4052
+ bloomBlurVHeight: 0,
4053
+ // feat-20260612-hdrp-ssao M6 / w26 + M8 / w38: SSAO pipeline slots.
4054
+ ssaoCalcPipeline: ssaoCalcPipelineHandle,
4055
+ ssaoBlurPipeline: ssaoBlurPipelineHandle,
4056
+ ssaoBgl: ssaoBglHandle,
4057
+ // M8 / w38: lazy-allocated on first SSAO record frame. Sampler kinds
4058
+ // and the 1x1 ssaoRaw fallback view are constant across frames and
4059
+ // cached after first construction.
4060
+ ssaoFilteringSampler: null,
4061
+ ssaoDepthSampler: null,
4062
+ ssaoFallbackRawView: null,
4063
+ },
4064
+ };
4065
+ return pipelineState;
4066
+ }