@forgeax/engine-render 0.1.21 → 0.1.24

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