@forgeax/engine-render 0.1.19 → 0.1.21

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 (452) hide show
  1. package/README.md +215 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/assembly/device-feature-admission.d.ts +18 -0
  4. package/dist/assembly/device-feature-admission.d.ts.map +1 -0
  5. package/dist/assembly/directional-shadow-inspection.d.ts +38 -0
  6. package/dist/assembly/directional-shadow-inspection.d.ts.map +1 -0
  7. package/dist/assembly/factory.d.ts +34 -20
  8. package/dist/assembly/factory.d.ts.map +1 -1
  9. package/dist/assembly/host-contract.d.ts +9 -0
  10. package/dist/assembly/host-contract.d.ts.map +1 -1
  11. package/dist/assembly/render-target-host.d.ts +46 -0
  12. package/dist/assembly/render-target-host.d.ts.map +1 -0
  13. package/dist/assets/render-asset-port.d.ts.map +1 -1
  14. package/dist/authoring.mjs +3 -0
  15. package/dist/authoring.mjs.map +1 -1
  16. package/dist/capture/cube-views.d.ts +21 -0
  17. package/dist/capture/cube-views.d.ts.map +1 -0
  18. package/dist/capture/scheduler.d.ts +54 -0
  19. package/dist/capture/scheduler.d.ts.map +1 -0
  20. package/dist/{chunk-RJRUS55L.mjs → chunk-5YADUCA7.mjs} +1896 -1624
  21. package/dist/chunk-5YADUCA7.mjs.map +1 -0
  22. package/dist/{chunk-LOSO4CTV.mjs → chunk-6OPXDQ2E.mjs} +423 -46
  23. package/dist/chunk-6OPXDQ2E.mjs.map +1 -0
  24. package/dist/{chunk-IPXCABEP.mjs → chunk-BXBQQKFQ.mjs} +3 -9
  25. package/dist/chunk-BXBQQKFQ.mjs.map +1 -0
  26. package/dist/chunk-FS2KNUQH.mjs +1294 -0
  27. package/dist/chunk-FS2KNUQH.mjs.map +1 -0
  28. package/dist/components/camera.d.ts +1 -0
  29. package/dist/components/camera.d.ts.map +1 -1
  30. package/dist/components/cube-camera.d.ts +20 -0
  31. package/dist/components/cube-camera.d.ts.map +1 -0
  32. package/dist/components/directional-light.d.ts +11 -2
  33. package/dist/components/directional-light.d.ts.map +1 -1
  34. package/dist/components/directional-shadow-filter.d.ts +25 -0
  35. package/dist/components/directional-shadow-filter.d.ts.map +1 -0
  36. package/dist/components/index.d.ts +2 -0
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/light-helpers.d.ts.map +1 -1
  39. package/dist/components/reflection-probe.d.ts +17 -0
  40. package/dist/components/reflection-probe.d.ts.map +1 -0
  41. package/dist/components/spot-light.d.ts +12 -0
  42. package/dist/components/spot-light.d.ts.map +1 -1
  43. package/dist/construct-renderer.mjs +8552 -1612
  44. package/dist/construct-renderer.mjs.map +1 -1
  45. package/dist/device/gpu-residency.d.ts +19 -2
  46. package/dist/device/gpu-residency.d.ts.map +1 -1
  47. package/dist/environment/frame.d.ts.map +1 -1
  48. package/dist/environment/lifecycle.d.ts +5 -0
  49. package/dist/environment/lifecycle.d.ts.map +1 -1
  50. package/dist/errors/render.d.ts +209 -93
  51. package/dist/errors/render.d.ts.map +1 -1
  52. package/dist/extract/directional-shadow-projection.d.ts +23 -0
  53. package/dist/extract/directional-shadow-projection.d.ts.map +1 -0
  54. package/dist/extract/environment.d.ts +14 -0
  55. package/dist/extract/environment.d.ts.map +1 -1
  56. package/dist/extract/visibility.d.ts.map +1 -1
  57. package/dist/features/fullscreen.d.ts.map +1 -1
  58. package/dist/features/targets.d.ts.map +1 -1
  59. package/dist/features/types.d.ts +10 -0
  60. package/dist/features/types.d.ts.map +1 -1
  61. package/dist/fullscreen-post-process-pass.d.ts +13 -2
  62. package/dist/fullscreen-post-process-pass.d.ts.map +1 -1
  63. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  64. package/dist/gpu-driven/production-raster.d.ts +0 -1
  65. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  66. package/dist/gpu-scene.d.ts.map +1 -1
  67. package/dist/gpu-usage.d.ts +2 -0
  68. package/dist/gpu-usage.d.ts.map +1 -1
  69. package/dist/hdrp-buffers.d.ts +19 -17
  70. package/dist/hdrp-buffers.d.ts.map +1 -1
  71. package/dist/ibl/IblPipelineCache.d.ts +4 -0
  72. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  73. package/dist/ibl/kernel-cache.d.ts +36 -0
  74. package/dist/ibl/kernel-cache.d.ts.map +1 -0
  75. package/dist/index.d.ts +35 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.mjs +4 -4
  78. package/dist/index.mjs.map +1 -1
  79. package/dist/inspection-types.d.ts +51 -0
  80. package/dist/inspection-types.d.ts.map +1 -1
  81. package/dist/instances-derived-bounds.d.ts +50 -0
  82. package/dist/instances-derived-bounds.d.ts.map +1 -0
  83. package/dist/internal.mjs +1 -1
  84. package/dist/light-buffer-layout.d.ts +30 -13
  85. package/dist/light-buffer-layout.d.ts.map +1 -1
  86. package/dist/mesh-material-bindings.d.ts +54 -0
  87. package/dist/mesh-material-bindings.d.ts.map +1 -1
  88. package/dist/pbr-pipeline.d.ts +20 -7
  89. package/dist/pbr-pipeline.d.ts.map +1 -1
  90. package/dist/pipeline/standard-forward-lane.d.ts +2 -1
  91. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  92. package/dist/pipeline/standard-lighting/errors.d.ts +4 -0
  93. package/dist/pipeline/standard-lighting/errors.d.ts.map +1 -0
  94. package/dist/pipeline/standard-lighting/graph.d.ts +24 -0
  95. package/dist/pipeline/standard-lighting/graph.d.ts.map +1 -0
  96. package/dist/pipeline/standard-lighting/inspection.d.ts +38 -0
  97. package/dist/pipeline/standard-lighting/inspection.d.ts.map +1 -0
  98. package/dist/pipeline/standard-lighting/layout.d.ts +28 -0
  99. package/dist/pipeline/standard-lighting/layout.d.ts.map +1 -0
  100. package/dist/pipeline/standard-lighting/light-frame.d.ts +33 -0
  101. package/dist/pipeline/standard-lighting/light-frame.d.ts.map +1 -0
  102. package/dist/pipeline/standard-lighting/membership.d.ts +14 -0
  103. package/dist/pipeline/standard-lighting/membership.d.ts.map +1 -0
  104. package/dist/pipeline/standard-lighting/prepare.d.ts +16 -0
  105. package/dist/pipeline/standard-lighting/prepare.d.ts.map +1 -0
  106. package/dist/pipeline/standard-lighting/topology.d.ts +45 -0
  107. package/dist/pipeline/standard-lighting/topology.d.ts.map +1 -0
  108. package/dist/pipeline/standard-lighting/transport.d.ts +36 -0
  109. package/dist/pipeline/standard-lighting/transport.d.ts.map +1 -0
  110. package/dist/pipeline/standard-pipeline.d.ts +4 -13
  111. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  112. package/dist/pipeline/standard-post.d.ts.map +1 -1
  113. package/dist/pipeline/standard-profile.d.ts +12 -20
  114. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  115. package/dist/pipeline-spec.d.ts +40 -7
  116. package/dist/pipeline-spec.d.ts.map +1 -1
  117. package/dist/point-shadow-inspection.d.ts +21 -0
  118. package/dist/point-shadow-inspection.d.ts.map +1 -0
  119. package/dist/record/frame-lighting.d.ts +29 -32
  120. package/dist/record/frame-lighting.d.ts.map +1 -1
  121. package/dist/record/frame-snapshot.d.ts +65 -15
  122. package/dist/record/frame-snapshot.d.ts.map +1 -1
  123. package/dist/record/frame-targets.d.ts +5 -0
  124. package/dist/record/frame-targets.d.ts.map +1 -1
  125. package/dist/record/frame.d.ts +12 -5
  126. package/dist/record/frame.d.ts.map +1 -1
  127. package/dist/record/gpu-pass-timing/contract.d.ts +96 -0
  128. package/dist/record/gpu-pass-timing/contract.d.ts.map +1 -0
  129. package/dist/record/gpu-pass-timing/errors.d.ts +18 -0
  130. package/dist/record/gpu-pass-timing/errors.d.ts.map +1 -0
  131. package/dist/record/gpu-pass-timing/index.d.ts +8 -0
  132. package/dist/record/gpu-pass-timing/index.d.ts.map +1 -0
  133. package/dist/record/gpu-pass-timing/parser.d.ts +11 -0
  134. package/dist/record/gpu-pass-timing/parser.d.ts.map +1 -0
  135. package/dist/record/gpu-pass-timing/session.d.ts +43 -0
  136. package/dist/record/gpu-pass-timing/session.d.ts.map +1 -0
  137. package/dist/record/gpu-timing.d.ts +37 -0
  138. package/dist/record/gpu-timing.d.ts.map +1 -0
  139. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  140. package/dist/record/main-pass-material.d.ts +23 -2
  141. package/dist/record/main-pass-material.d.ts.map +1 -1
  142. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  143. package/dist/record/main-pass.d.ts +2 -0
  144. package/dist/record/main-pass.d.ts.map +1 -1
  145. package/dist/record/render-context.d.ts +48 -3
  146. package/dist/record/render-context.d.ts.map +1 -1
  147. package/dist/record/shadow-pass.d.ts.map +1 -1
  148. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  149. package/dist/record/typed-frame-graph.d.ts +81 -5
  150. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  151. package/dist/record/view-ubo.d.ts +7 -4
  152. package/dist/record/view-ubo.d.ts.map +1 -1
  153. package/dist/reflection/filter.d.ts +22 -0
  154. package/dist/reflection/filter.d.ts.map +1 -0
  155. package/dist/reflection/gpu-table.d.ts +25 -0
  156. package/dist/reflection/gpu-table.d.ts.map +1 -0
  157. package/dist/reflection/projection.d.ts +91 -0
  158. package/dist/reflection/projection.d.ts.map +1 -0
  159. package/dist/render-contract.d.ts +114 -7
  160. package/dist/render-contract.d.ts.map +1 -1
  161. package/dist/render-data.d.ts +10 -6
  162. package/dist/render-data.d.ts.map +1 -1
  163. package/dist/render-graph-primitives.d.ts +1 -0
  164. package/dist/render-graph-primitives.d.ts.map +1 -1
  165. package/dist/render-pipeline.d.ts +78 -2
  166. package/dist/render-pipeline.d.ts.map +1 -1
  167. package/dist/render-system-extract.d.ts +113 -20
  168. package/dist/render-system-extract.d.ts.map +1 -1
  169. package/dist/render-system.d.ts +24 -3
  170. package/dist/render-system.d.ts.map +1 -1
  171. package/dist/renderer-inspect.d.ts +29 -0
  172. package/dist/renderer-inspect.d.ts.map +1 -0
  173. package/dist/renderer.d.ts +43 -0
  174. package/dist/renderer.d.ts.map +1 -0
  175. package/dist/scene/render-scene.d.ts +14 -0
  176. package/dist/scene/render-scene.d.ts.map +1 -1
  177. package/dist/shadow-atlas.d.ts +3 -3
  178. package/dist/shadow-atlas.d.ts.map +1 -1
  179. package/dist/ssao-buffers.d.ts +3 -0
  180. package/dist/ssao-buffers.d.ts.map +1 -1
  181. package/dist/targets/contracts.d.ts +66 -0
  182. package/dist/targets/contracts.d.ts.map +1 -0
  183. package/dist/targets/graph-projection.d.ts +29 -0
  184. package/dist/targets/graph-projection.d.ts.map +1 -0
  185. package/dist/targets/material-source.d.ts +34 -0
  186. package/dist/targets/material-source.d.ts.map +1 -0
  187. package/dist/targets/owner.d.ts +32 -0
  188. package/dist/targets/owner.d.ts.map +1 -0
  189. package/dist/targets/physical.d.ts +18 -0
  190. package/dist/targets/physical.d.ts.map +1 -0
  191. package/dist/targets/readback.d.ts +39 -0
  192. package/dist/targets/readback.d.ts.map +1 -0
  193. package/dist/temporal/frame.d.ts +44 -0
  194. package/dist/temporal/frame.d.ts.map +1 -0
  195. package/dist/temporal/index.mjs +3 -3
  196. package/dist/typed-render-graph-primitives.d.ts +6 -1
  197. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  198. package/dist/typed-shadow-atlas.d.ts +10 -0
  199. package/dist/typed-shadow-atlas.d.ts.map +1 -0
  200. package/dist/typed-shadow-passes.d.ts +1 -1
  201. package/dist/typed-shadow-passes.d.ts.map +1 -1
  202. package/dist/volume/capability.d.ts +57 -0
  203. package/dist/volume/capability.d.ts.map +1 -0
  204. package/dist/volume/component.d.ts +61 -0
  205. package/dist/volume/component.d.ts.map +1 -0
  206. package/dist/volume/extract.d.ts +11 -0
  207. package/dist/volume/extract.d.ts.map +1 -0
  208. package/dist/volume/inspection.d.ts +38 -0
  209. package/dist/volume/inspection.d.ts.map +1 -0
  210. package/dist/volume/passes.d.ts +38 -0
  211. package/dist/volume/passes.d.ts.map +1 -0
  212. package/dist/volume/recovery.d.ts +38 -0
  213. package/dist/volume/recovery.d.ts.map +1 -0
  214. package/dist/volume/resources.d.ts +57 -0
  215. package/dist/volume/resources.d.ts.map +1 -0
  216. package/dist/volume/temporal.d.ts +35 -0
  217. package/dist/volume/temporal.d.ts.map +1 -0
  218. package/package.json +20 -20
  219. package/src/__tests__/bloom-exact-zero.unit.test.ts +48 -19
  220. package/src/__tests__/bloom-lifecycle.integration.test.ts +41 -13
  221. package/src/__tests__/camera-target-consumption.unit.test.ts +53 -0
  222. package/src/__tests__/cluster-binner.unit.test.ts +35 -0
  223. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +7 -2
  224. package/src/__tests__/cube-camera-scheduler.unit.test.ts +70 -0
  225. package/src/__tests__/cube-camera-single-submit.perf.test.ts +75 -0
  226. package/src/__tests__/cube-camera-view-matrices.unit.test.ts +41 -0
  227. package/src/__tests__/cube-camera-vocabulary.unit.test.ts +47 -0
  228. package/src/__tests__/cube-camera.integration.test.ts +42 -0
  229. package/src/__tests__/device-feature-admission.unit.test.ts +73 -0
  230. package/src/__tests__/direct-light-snapshot.unit.test.ts +3 -3
  231. package/src/__tests__/docs-gate-green.unit.test.ts +38 -0
  232. package/src/__tests__/factory-contract.integration.test.ts +255 -29
  233. package/src/__tests__/fog-component.unit.test.ts +67 -0
  234. package/src/__tests__/fog.integration.test.ts +86 -0
  235. package/src/__tests__/fullscreen-feature-plan.unit.test.ts +37 -0
  236. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +10 -9
  237. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +17 -1
  238. package/src/__tests__/gpu-driven-production.integration.test.ts +16 -17
  239. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +12 -4
  240. package/src/__tests__/gpu-pass-timing-browser-runner.ts +281 -0
  241. package/src/__tests__/gpu-pass-timing-coexistence.integration.test.ts +80 -0
  242. package/src/__tests__/gpu-pass-timing-contract.unit.test.ts +243 -0
  243. package/src/__tests__/gpu-pass-timing-exact-zero.unit.test.ts +131 -0
  244. package/src/__tests__/gpu-pass-timing-lifecycle.integration.test.ts +230 -0
  245. package/src/__tests__/gpu-pass-timing-observation.unit.test.ts +233 -0
  246. package/src/__tests__/gpu-pass-timing-session.unit.test.ts +438 -0
  247. package/src/__tests__/gpu-pass-timing.browser.test.ts +26 -0
  248. package/src/__tests__/gpu-pass-timing.rhi-null.unit.test.ts +125 -0
  249. package/src/__tests__/hdrp-group2-visibility.unit.test.ts +24 -0
  250. package/src/__tests__/instances-culling.integration.test.ts +94 -0
  251. package/src/__tests__/instances-derived-bounds.unit.test.ts +118 -0
  252. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
  253. package/src/__tests__/material-observation.integration.test.ts +101 -0
  254. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  255. package/src/__tests__/pbr-view-layout.unit.test.ts +16 -0
  256. package/src/__tests__/point-shadow-inspection.unit.test.ts +60 -0
  257. package/src/__tests__/public-surface.test-d.ts +10 -0
  258. package/src/__tests__/reflection-probe-authoring.unit.test.ts +71 -0
  259. package/src/__tests__/reflection-probe-filter.unit.test.ts +54 -0
  260. package/src/__tests__/reflection-probe-ibl-topology.unit.test.ts +45 -0
  261. package/src/__tests__/reflection-probe-pass-encode.integration.test.ts +101 -0
  262. package/src/__tests__/reflection-probe-performance.perf.test.ts +60 -0
  263. package/src/__tests__/reflection-probe-recovery.integration.test.ts +55 -0
  264. package/src/__tests__/reflection-probe-selection.unit.test.ts +46 -0
  265. package/src/__tests__/render-error-code-owner.test-d.ts +23 -3
  266. package/src/__tests__/render-feature-docs.unit.test.ts +1 -1
  267. package/src/__tests__/render-target-admission.unit.test.ts +80 -0
  268. package/src/__tests__/render-target-graph-projection.unit.test.ts +102 -0
  269. package/src/__tests__/render-target-inspection.unit.test.ts +52 -0
  270. package/src/__tests__/render-target-material-source.test-d.ts +30 -0
  271. package/src/__tests__/render-target-material-source.unit.test.ts +97 -0
  272. package/src/__tests__/render-target-owner-placement.unit.test.ts +203 -0
  273. package/src/__tests__/render-target-owner.unit.test.ts +122 -0
  274. package/src/__tests__/render-target-probe-soak.perf.test.ts +94 -0
  275. package/src/__tests__/render-target-public-schema.json +107 -0
  276. package/src/__tests__/render-target-public-schema.unit.test.ts +91 -0
  277. package/src/__tests__/render-target-public-surface.test-d.ts +83 -0
  278. package/src/__tests__/render-target-readback.integration.test.ts +90 -0
  279. package/src/__tests__/render-target-recovery.integration.test.ts +85 -0
  280. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +76 -0
  281. package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
  282. package/src/__tests__/renderer-lifecycle.integration.test.ts +14 -0
  283. package/src/__tests__/shader-stage-surface.unit.test.ts +19 -4
  284. package/src/__tests__/skin-motion-regression.unit.test.ts +132 -0
  285. package/src/__tests__/skinned-shadow-caster.test.ts +32 -2
  286. package/src/__tests__/standard-cluster-capability.integration.test.ts +63 -0
  287. package/src/__tests__/standard-cluster-transport.unit.test.ts +121 -0
  288. package/src/__tests__/standard-cluster-variant-resolution.unit.test.ts +232 -0
  289. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +8 -5
  290. package/src/__tests__/standard-lighting-inspection.integration.test.ts +138 -0
  291. package/src/__tests__/standard-lighting-ssot.unit.test.ts +18 -0
  292. package/src/__tests__/standard-lighting-write.unit.test.ts +377 -0
  293. package/src/__tests__/standard-lighting.integration.test.ts +67 -0
  294. package/src/__tests__/standard-pipeline.dawn.test.ts +206 -5
  295. package/src/__tests__/standard-pipeline.integration.test.ts +181 -150
  296. package/src/__tests__/standard-probe-binding.unit.test.ts +25 -0
  297. package/src/__tests__/standard-profile-oracle.unit.test.ts +23 -36
  298. package/src/__tests__/typed-pipeline-topology.unit.test.ts +395 -14
  299. package/src/__tests__/urp-pipeline-targets.unit.test.ts +2 -2
  300. package/src/__tests__/visibility-resolver.unit.test.ts +15 -0
  301. package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
  302. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
  303. package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
  304. package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
  305. package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
  306. package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
  307. package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
  308. package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
  309. package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
  310. package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
  311. package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
  312. package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
  313. package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
  314. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
  315. package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
  316. package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
  317. package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
  318. package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
  319. package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
  320. package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
  321. package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
  322. package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
  323. package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
  324. package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
  325. package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
  326. package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
  327. package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
  328. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
  329. package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
  330. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
  331. package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
  332. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
  333. package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
  334. package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
  335. package/src/assembly/device-feature-admission.ts +73 -0
  336. package/src/assembly/directional-shadow-inspection.ts +122 -0
  337. package/src/assembly/factory.ts +1093 -246
  338. package/src/assembly/host-contract.ts +25 -0
  339. package/src/assembly/render-target-host.ts +847 -0
  340. package/src/assets/asset-decoders.ts +1 -1
  341. package/src/assets/render-asset-port.ts +7 -1
  342. package/src/capture/cube-views.ts +69 -0
  343. package/src/capture/scheduler.ts +265 -0
  344. package/src/components/__tests__/schema-contract.unit.test.ts +12 -0
  345. package/src/components/__tests__/schema-parity.unit.test.ts +9 -0
  346. package/src/components/camera.ts +4 -0
  347. package/src/components/cube-camera.ts +52 -0
  348. package/src/components/directional-light.ts +22 -2
  349. package/src/components/directional-shadow-filter.ts +57 -0
  350. package/src/components/index.ts +2 -0
  351. package/src/components/light-helpers.ts +85 -7
  352. package/src/components/reflection-probe.ts +49 -0
  353. package/src/components/spot-light.ts +22 -3
  354. package/src/device/gpu-residency.ts +168 -58
  355. package/src/environment/frame.ts +6 -24
  356. package/src/environment/lifecycle.ts +12 -0
  357. package/src/errors/render.ts +405 -143
  358. package/src/extract/directional-shadow-projection.ts +61 -0
  359. package/src/extract/environment.ts +57 -0
  360. package/src/extract/visibility.ts +25 -1
  361. package/src/features/fullscreen.ts +3 -0
  362. package/src/features/targets.ts +4 -1
  363. package/src/features/types.ts +10 -0
  364. package/src/fullscreen-post-process-pass.ts +43 -2
  365. package/src/gpu-driven/batch-topology.ts +1 -0
  366. package/src/gpu-driven/production-raster.ts +1 -3
  367. package/src/gpu-scene.ts +8 -1
  368. package/src/gpu-usage.ts +2 -0
  369. package/src/hdrp-buffers.ts +165 -59
  370. package/src/ibl/IblPipelineCache.ts +11 -0
  371. package/src/ibl/kernel-cache.ts +88 -0
  372. package/src/index.ts +186 -1
  373. package/src/inspection-types.ts +56 -0
  374. package/src/instances-derived-bounds.ts +171 -0
  375. package/src/light-buffer-layout.ts +74 -26
  376. package/src/mesh-material-bindings.ts +132 -0
  377. package/src/pbr-pipeline.ts +139 -27
  378. package/src/pipeline/standard-forward-lane.ts +65 -15
  379. package/src/pipeline/standard-lighting/errors.ts +6 -0
  380. package/src/pipeline/standard-lighting/graph.ts +157 -0
  381. package/src/pipeline/standard-lighting/inspection.ts +88 -0
  382. package/src/pipeline/standard-lighting/layout.ts +31 -0
  383. package/src/pipeline/standard-lighting/light-frame.ts +34 -0
  384. package/src/pipeline/standard-lighting/membership.ts +43 -0
  385. package/src/pipeline/standard-lighting/prepare.ts +37 -0
  386. package/src/pipeline/standard-lighting/topology.ts +120 -0
  387. package/src/pipeline/standard-lighting/transport.ts +77 -0
  388. package/src/pipeline/standard-pipeline.ts +141 -192
  389. package/src/pipeline/standard-post.ts +26 -7
  390. package/src/pipeline/standard-profile.ts +23 -30
  391. package/src/pipeline-spec.ts +84 -21
  392. package/src/point-shadow-inspection.ts +55 -0
  393. package/src/record/__tests__/temporal-payload.unit.test.ts +42 -1
  394. package/src/record/__tests__/transparent-group2-selection.unit.test.ts +73 -0
  395. package/src/record/__tests__/view-ubo-layout.unit.test.ts +40 -0
  396. package/src/record/frame-lighting.ts +470 -516
  397. package/src/record/frame-snapshot.ts +76 -28
  398. package/src/record/frame-targets.ts +23 -2
  399. package/src/record/frame.ts +1169 -79
  400. package/src/record/gpu-pass-timing/contract.ts +180 -0
  401. package/src/record/gpu-pass-timing/errors.ts +34 -0
  402. package/src/record/gpu-pass-timing/index.ts +37 -0
  403. package/src/record/gpu-pass-timing/parser.ts +97 -0
  404. package/src/record/gpu-pass-timing/session.ts +938 -0
  405. package/src/record/gpu-timing.ts +233 -0
  406. package/src/record/main-pass-geometry.ts +120 -80
  407. package/src/record/main-pass-material.ts +101 -11
  408. package/src/record/main-pass-sprite-draws.ts +54 -25
  409. package/src/record/main-pass.ts +71 -15
  410. package/src/record/render-context.ts +77 -0
  411. package/src/record/shadow-pass.ts +104 -66
  412. package/src/record/skybox-post-pass.ts +20 -4
  413. package/src/record/typed-frame-graph.ts +1016 -32
  414. package/src/record/view-ubo.ts +63 -28
  415. package/src/reflection/filter.ts +81 -0
  416. package/src/reflection/gpu-table.ts +44 -0
  417. package/src/reflection/projection.ts +281 -0
  418. package/src/render-contract.ts +147 -4
  419. package/src/render-data.ts +50 -22
  420. package/src/render-graph-primitives.ts +9 -4
  421. package/src/render-pipeline.ts +161 -2
  422. package/src/render-system-extract.ts +886 -103
  423. package/src/render-system.ts +1174 -66
  424. package/src/renderer-inspect.ts +66 -0
  425. package/src/renderer.ts +222 -0
  426. package/src/scene/render-scene.ts +117 -9
  427. package/src/shadow-atlas.ts +5 -5
  428. package/src/ssao-buffers.ts +9 -2
  429. package/src/targets/contracts.ts +182 -0
  430. package/src/targets/graph-projection.ts +169 -0
  431. package/src/targets/material-source.ts +143 -0
  432. package/src/targets/owner.ts +214 -0
  433. package/src/targets/physical.ts +18 -0
  434. package/src/targets/readback.ts +153 -0
  435. package/src/temporal/frame.ts +128 -0
  436. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +61 -23
  437. package/src/typed-render-graph-primitives.ts +34 -2
  438. package/src/typed-shadow-atlas.ts +17 -0
  439. package/src/typed-shadow-passes.ts +47 -40
  440. package/src/volume/capability.ts +173 -0
  441. package/src/volume/component.ts +110 -0
  442. package/src/volume/extract.ts +21 -0
  443. package/src/volume/inspection.ts +78 -0
  444. package/src/volume/passes.ts +686 -0
  445. package/src/volume/recovery.ts +86 -0
  446. package/src/volume/resources.ts +236 -0
  447. package/src/volume/temporal.ts +89 -0
  448. package/dist/chunk-IPXCABEP.mjs.map +0 -1
  449. package/dist/chunk-LOSO4CTV.mjs.map +0 -1
  450. package/dist/chunk-RJRUS55L.mjs.map +0 -1
  451. package/dist/chunk-YTQ5C7YQ.mjs +0 -584
  452. package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
@@ -50,7 +50,6 @@ import type {
50
50
  PipelineLayout,
51
51
  RenderPipeline,
52
52
  Result,
53
- RhiAdapter,
54
53
  RhiCanvasContext,
55
54
  RhiDevice,
56
55
  Sampler,
@@ -59,6 +58,7 @@ import type {
59
58
  import { err, ok, RhiError, validateDrawArgs } from '@forgeax/engine-rhi';
60
59
  import { registerPropagateTransforms } from '@forgeax/engine-scene';
61
60
  import {
61
+ DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
62
62
  findVariantByKey,
63
63
  type MaterialShaderEntry,
64
64
  type MaterialShaderManifestEntry,
@@ -91,12 +91,16 @@ import {
91
91
  RendererOperationError,
92
92
  RenderFeatureCapabilityMissingError,
93
93
  RenderFeatureStageFailedError,
94
+ StandardProfileInvalidError,
94
95
  } from '../errors/render';
95
96
  import { createRenderFeatureHost, type RenderFeatureHost } from '../features/host';
96
97
  import type { RenderFeatureMaterialShaderBindingContract } from '../features/plan';
97
98
  import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../features/prepared-graphics';
98
99
  import type { RenderFeature, RenderFeatureShaderModuleMode } from '../features/types';
99
- import type { PostProcessShaderEntry } from '../fullscreen-post-process-pass';
100
+ import {
101
+ type PostProcessShaderEntry,
102
+ postProcessShaderModuleLabel,
103
+ } from '../fullscreen-post-process-pass';
100
104
  import { glyphTextLayoutSystem } from '../glyph-text-layout-system';
101
105
  import { GpuBuffer } from '../gpu-resource';
102
106
  import { GPU_SHADER_STAGE_FRAGMENT, GPU_SHADER_STAGE_VERTEX } from '../gpu-stage';
@@ -134,9 +138,12 @@ import {
134
138
  SPOT_LIGHT_STD430_BYTES,
135
139
  } from '../light-buffer-layout';
136
140
  import {
141
+ buildPbrMaterialUserRegionEntries,
137
142
  buildPbrPipelineLayouts,
138
143
  buildPbrSkinLayouts,
139
- isSkinnedShadowCasterVariant,
144
+ createHdrpSkinBindGroupLayoutDescriptor,
145
+ type PipelineGroup2Contract,
146
+ resolvePipelineGroup2Contract,
140
147
  SKIN_MATERIAL_SHADER_ID,
141
148
  } from '../pbr-pipeline';
142
149
  import { STANDARD_CLUSTER_MEMBERSHIP_WGSL, standardPipeline } from '../pipeline/standard-pipeline';
@@ -163,6 +170,15 @@ import {
163
170
  } from '../pipeline-spec';
164
171
  import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
165
172
  import type { FrameObservationOptions } from '../record/frame';
173
+ import type { GpuPassTimingReason } from '../record/gpu-pass-timing/errors.js';
174
+ import {
175
+ createGpuPassTimingSession,
176
+ DEFAULT_GPU_PASS_TIMING_OPTIONS,
177
+ type GpuPassTimingCapture,
178
+ type GpuPassTimingObservation,
179
+ type GpuPassTimingSession,
180
+ } from '../record/gpu-pass-timing/index.js';
181
+ import { GpuTimingCapture, type VolumeTimingObservation } from '../record/gpu-timing';
166
182
  import type { BloomPersistentBundle } from '../record/render-context';
167
183
  import { SHADOW_CASTER_BUFFER_SIZE } from '../record/shadow-pass';
168
184
  import { POINTS_LINES_VIEW_BUFFER_SIZE, VIEW_UNIFORM_BUFFER_SIZE } from '../record/view-ubo';
@@ -185,9 +201,12 @@ import type {
185
201
  RenderProfile,
186
202
  RenderResult,
187
203
  RenderWorldLease,
204
+ VolumetricFogShaderSources,
188
205
  } from '../render-contract';
189
- import { STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from '../render-contract';
206
+ import { FXAA_POST_PROCESS_ID, STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from '../render-contract';
207
+
190
208
  import {
209
+ attachGpuPassTimingSession,
191
210
  configureSurface,
192
211
  createRenderSystem,
193
212
  MATERIAL_PER_ENTITY_STRIDE,
@@ -195,6 +214,7 @@ import {
195
214
  type RenderSystem,
196
215
  selectSwapChainFormat,
197
216
  } from '../render-system';
217
+ import { createGpuPassTimingObservationStore, observeGpuPassTimingDisabled } from '../renderer.js';
198
218
  import { postSpawnResolveJoints } from '../scene-instances/post-spawn-resolve-joints';
199
219
  import {
200
220
  createSkinPaletteAllocator,
@@ -202,7 +222,12 @@ import {
202
222
  } from '../systems/skin-palette-allocator';
203
223
  import { tilemapChunkExtractSystem } from '../tilemap-chunk-extract-system';
204
224
  import { EngineEnvironmentError, type RhiBackendPack } from './backend-contract';
225
+ import {
226
+ deviceOptionsForAdapter,
227
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
228
+ } from './device-feature-admission';
205
229
  import type { RendererAssemblyImplementation, RendererHostImplementation } from './host-contract';
230
+ import { createRenderTargetHost } from './render-target-host';
206
231
  import { projectRendererOutputInspection } from './renderer-inspection';
207
232
  import { isRendererStateOperational } from './renderer-lifecycle';
208
233
 
@@ -222,6 +247,19 @@ const GPU_ENVIRONMENT_ERROR_CODES = new Set([
222
247
  'oom',
223
248
  ]);
224
249
 
250
+ /**
251
+ * The boot artifact is only a registry/prewarm seed. It is deliberately not
252
+ * an active lighting decision: every frame derives its Standard variant from
253
+ * `RenderPipelineFrame.standardLighting.kind` at record time. Keeping this
254
+ * boot-only value named as such prevents the eager module from becoming a
255
+ * second topology authority.
256
+ */
257
+ const STANDARD_BOOT_TOPOLOGY_KIND: 'no-local-lights' | 'clustered' = 'no-local-lights';
258
+
259
+ function standardBootClusterAxis(): boolean {
260
+ return STANDARD_BOOT_TOPOLOGY_KIND === 'clustered';
261
+ }
262
+
225
263
  function classifyEnvErrorReason(
226
264
  base: string,
227
265
  primary: { code?: unknown; name?: unknown } | undefined,
@@ -446,18 +484,14 @@ export interface BundlerOptions {
446
484
  /**
447
485
  * Per-variant PipelineLayout selector (M4.5 / D-10 option A).
448
486
  *
449
- * The HDRP variant (`CLUSTER_FORWARD_AVAILABLE=true` or canonical `''`
450
- * all-true when the shader declares the cluster axis) needs the 7-slot
451
- * group(2) BGL chain (`hdrpPbrPipelineLayout`); URP variants
452
- * (`CLUSTER_FORWARD_AVAILABLE=false` / undefined / no axis) keep the
453
- * 1-slot mesh-array BGL chain (`pbrPipelineLayout`).
487
+ * The resolved HDRP group(2) contract needs the 7-slot BGL chain
488
+ * (`hdrpPbrPipelineLayout`); ordinary material contracts keep the 1-slot
489
+ * mesh-array BGL chain (`pbrPipelineLayout`). Variant keys select source
490
+ * artifacts only and never override this contract.
454
491
  *
455
- * Returns `null` only when the URP fallback layout itself is null
456
- * (Camera-only / empty-manifest path). When the HDRP layout is null but
457
- * URP exists, the HDRP variant gracefully falls back to URP — the WGSL
458
- * resolved upstream by `findVariantByKey` is the URP variant in that
459
- * case (manifest entry with all-true definesKey === '' was registered
460
- * with isHdrpActive=false at boot when storage-buffer caps are absent).
492
+ * Returns `null` when the selected layout is unavailable. An HDRP variant
493
+ * never falls back to the URP layout: its clustered WGSL and group(2) ABI
494
+ * must be admitted together or the draw fails closed.
461
495
  *
462
496
  * Pure function — exported for unit-test access (M4.5 / w35).
463
497
  */
@@ -465,8 +499,9 @@ export interface BundlerOptions {
465
499
  * Closed enum of pipeline-layout kinds the selector can dispatch to.
466
500
  *
467
501
  * `pbr` — standard PBR (1-entry mesh-array BGL).
468
- * `pbr-skin` — bug-20260611-skin-pipeline-layout: 2-entry mesh-array BGL
469
- * (binding 0 meshes + binding 1 palette) for the `forgeax::pbr-skin`
502
+ * `pbr-skin` — bug-20260611-skin-pipeline-layout: 3-entry mesh-array BGL
503
+ * (binding 0 meshes + binding 1 current palette + binding 2 previous
504
+ * palette) for the `forgeax::pbr-skin`
470
505
  * material shader.
471
506
  * `hdrp-pbr` — feat-20260609 HDRP cluster-forward variant (7-slot group(2)
472
507
  * BGL substituted at slot 2).
@@ -486,7 +521,15 @@ export interface BundlerOptions {
486
521
  * material-shader layouts add a `LayoutKind` member + a PipelineState slot
487
522
  * + a switch arm here in one cut (charter P3 extensibility).
488
523
  */
489
- export type LayoutKind = 'pbr' | 'pbr-skin' | 'hdrp-pbr' | 'sprite-urp' | 'unlit-urp';
524
+ export type LayoutKind = 'pbr' | 'pbr-skin' | 'hdrp-pbr' | 'hdrp-skin' | 'sprite-urp' | 'unlit-urp';
525
+
526
+ /** The closed group(2) contract resolved from the selected WGSL artifact. */
527
+ export type { PipelineGroup2Contract } from '../pbr-pipeline';
528
+ export { resolvePipelineGroup2Contract } from '../pbr-pipeline';
529
+
530
+ function directionalPcssCapable(backendKind: RhiDevice['caps']['backendKind']): boolean {
531
+ return backendKind === 'webgpu' || backendKind === 'wgpu-native';
532
+ }
490
533
 
491
534
  /**
492
535
  * Return every device-capability HDRP PBR variant that needs a lazy module
@@ -499,17 +542,44 @@ export function selectHdrpPbrPrewarmVariants(
499
542
  manifestEntry: MaterialShaderManifestEntry | undefined,
500
543
  storageBufferCapable: boolean,
501
544
  transmissionCapable: boolean,
545
+ directionalPcssAvailable = true,
546
+ projectorAvailable: boolean = true,
502
547
  ): readonly MaterialShaderManifestEntry['variants'][number][] {
548
+ // HDRP's clustered group(2) ABI is storage-buffer backed. A downlevel
549
+ // WebGL2 device must stay on the URP variant; prewarming an HDRP source
550
+ // here would advertise an inactive route and can exceed its fragment
551
+ // uniform-buffer budget before the first frame.
552
+ if (!storageBufferCapable) return [];
503
553
  return (
504
554
  manifestEntry?.variants.filter(
505
555
  (variant) =>
506
556
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
507
557
  variant.defines.CLUSTER_FORWARD_AVAILABLE === true &&
558
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
559
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
560
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
561
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
508
562
  (transmissionCapable || variant.defines.TRANSMISSION_AVAILABLE !== true),
509
563
  ) ?? []
510
564
  );
511
565
  }
512
566
 
567
+ /** Return every active-capability pbr-skin variant used by URP and HDRP draws. */
568
+ export function selectSkinPrewarmVariants(
569
+ manifestEntry: MaterialShaderManifestEntry | undefined,
570
+ storageBufferCapable: boolean,
571
+ ): readonly MaterialShaderManifestEntry['variants'][number][] {
572
+ return (
573
+ manifestEntry?.variants.filter(
574
+ (variant) =>
575
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
576
+ // The clustered skin ABI is only active on the storage-backed tier;
577
+ // storage=false must never prewarm or expose it as a usable variant.
578
+ (storageBufferCapable || variant.defines.CLUSTER_FORWARD_AVAILABLE !== true),
579
+ ) ?? []
580
+ );
581
+ }
582
+
513
583
  /**
514
584
  * Select both Standard transmission states from the manifest for exact-label
515
585
  * prewarming. The requested keys come from declared variants; no key is
@@ -518,6 +588,8 @@ export function selectHdrpPbrPrewarmVariants(
518
588
  export function selectStandardPbrTransmissionPrewarmVariants(
519
589
  manifestEntry: MaterialShaderManifestEntry | undefined,
520
590
  storageBufferCapable: boolean,
591
+ directionalPcssAvailable = true,
592
+ projectorAvailable: boolean = true,
521
593
  ): readonly MaterialShaderManifestEntry['variants'][number][] {
522
594
  if (manifestEntry === undefined) {
523
595
  throw new Error('Standard material shader manifest row is missing');
@@ -527,8 +599,12 @@ export function selectStandardPbrTransmissionPrewarmVariants(
527
599
  const declared = manifestEntry.variants.find(
528
600
  (variant) =>
529
601
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
530
- variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
602
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === standardBootClusterAxis() &&
531
603
  variant.defines.VERTEX_COLOR_AVAILABLE === false &&
604
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
605
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
606
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
607
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
532
608
  variant.defines.TRANSMISSION_AVAILABLE === transmissionAvailable,
533
609
  );
534
610
  if (declared === undefined) {
@@ -556,20 +632,28 @@ export function selectNoColorPbrVariant(
556
632
  manifestEntry: MaterialShaderManifestEntry | undefined,
557
633
  storageBufferCapable: boolean,
558
634
  variantSet: string | undefined,
635
+ directionalPcssAvailable = true,
636
+ projectorAvailable: boolean = true,
559
637
  ): MaterialShaderManifestEntry['variants'][number] | undefined {
560
- const hdrpRequested =
561
- variantSet === '' ||
562
- // biome-ignore lint/complexity/useOptionalChain: G-14 requires an explicit undefined guard so no variant request becomes false.
563
- (variantSet !== undefined && variantSet.includes('CLUSTER_FORWARD_AVAILABLE=true'));
638
+ const hdrpRequested = variantSet === '' || requestsClusterVariant(variantSet);
564
639
  return manifestEntry?.variants.find(
565
640
  (variant) =>
566
641
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
567
642
  variant.defines.VERTEX_COLOR_AVAILABLE === false &&
643
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
644
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
645
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
646
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
568
647
  (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
569
648
  variant.defines.CLUSTER_FORWARD_AVAILABLE === hdrpRequested),
570
649
  );
571
650
  }
572
651
 
652
+ function requestsClusterVariant(variantSet: string | undefined): boolean {
653
+ if (variantSet === undefined) return false;
654
+ return variantSet.includes('CLUSTER_FORWARD_AVAILABLE=true');
655
+ }
656
+
573
657
  export type MaterialShaderBindingContract = RenderFeatureMaterialShaderBindingContract;
574
658
  export type MaterialShaderVertexInputContract = 'none' | 'render-material';
575
659
 
@@ -623,6 +707,46 @@ export function resolveMaterialShaderVertexInputContract(
623
707
  : 'none';
624
708
  }
625
709
 
710
+ /**
711
+ * Decide whether a custom material's user-region can use the shared PBR
712
+ * bind-group layout. Resource names are not part of a WebGPU layout contract:
713
+ * the only safe reuse test is that every derived binding in the custom schema
714
+ * is an identical prefix of the canonical user-region descriptor, including
715
+ * binding kind and its full shape (sample type, view dimension, sampler mode,
716
+ * dynamic-offset flags, and visibility). A texture3d/array or comparison /
717
+ * storage resource must therefore take the per-shader layout path even when
718
+ * its field count is small.
719
+ */
720
+ export function isSharedMaterialUserRegionCompatible(
721
+ paramSchema: readonly ParamSchemaEntry[],
722
+ ): boolean {
723
+ const shared = buildPbrMaterialUserRegionEntries(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
724
+ const candidate = buildPbrMaterialUserRegionEntries(paramSchema);
725
+ if (candidate.length > shared.length) return false;
726
+ return candidate.every((entry, index) => {
727
+ const canonical = shared[index];
728
+ if (canonical === undefined) return false;
729
+ return (
730
+ JSON.stringify({
731
+ binding: entry.binding,
732
+ visibility: entry.visibility,
733
+ buffer: entry.buffer ?? null,
734
+ sampler: entry.sampler ?? null,
735
+ texture: entry.texture ?? null,
736
+ storageTexture: entry.storageTexture ?? null,
737
+ }) ===
738
+ JSON.stringify({
739
+ binding: canonical.binding,
740
+ visibility: canonical.visibility,
741
+ buffer: canonical.buffer ?? null,
742
+ sampler: canonical.sampler ?? null,
743
+ texture: canonical.texture ?? null,
744
+ storageTexture: canonical.storageTexture ?? null,
745
+ })
746
+ );
747
+ });
748
+ }
749
+
626
750
  export function allowsUnlitPreparedFallback(
627
751
  depthFormatOverride: GPUTextureFormat | null | undefined,
628
752
  vertexLayout: string | undefined,
@@ -656,8 +780,9 @@ export function selectPipelineLayoutForVariant(
656
780
  readonly pbrPipelineLayout: PipelineLayout | null;
657
781
  readonly hdrpPbrPipelineLayout: PipelineLayout | null;
658
782
  readonly pbrSkinPipelineLayout: PipelineLayout | null;
783
+ readonly hdrpSkinPipelineLayout?: PipelineLayout | null;
659
784
  } | null,
660
- variantSet: string | undefined,
785
+ _variantSet: string | undefined,
661
786
  layoutKind?: LayoutKind,
662
787
  ): PipelineLayout | null {
663
788
  if (state === null) return null;
@@ -670,6 +795,9 @@ export function selectPipelineLayoutForVariant(
670
795
  if (layoutKind === 'pbr-skin') {
671
796
  return state.pbrSkinPipelineLayout;
672
797
  }
798
+ if (layoutKind === 'hdrp-skin') {
799
+ return state.hdrpSkinPipelineLayout ?? null;
800
+ }
673
801
  // bug-20260708 M2 (c): sprite / sprite-lit shaders reuse the URP 1-slot
674
802
  // mesh-array pipeline layout but their canonical all-true variant key
675
803
  // `''` (SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET) must NOT trigger
@@ -685,33 +813,19 @@ export function selectPipelineLayoutForVariant(
685
813
  if (layoutKind === 'unlit-urp') {
686
814
  return state.pbrPipelineLayout;
687
815
  }
688
- // Static PBR callers can use the canonical all-true variant key (`''`)
689
- // without becoming HDRP. Keep the shader variant identity independent from
690
- // the pipeline-layout identity; shadow-caster static draws use this explicit
691
- // kind so their ordinary mesh bind group remains compatible with the PSO.
816
+ // The resolved group(2) contract is authoritative for custom material
817
+ // shaders. A mesh-only composed WGSL uses the one-entry mesh BGL even when
818
+ // the renderer is otherwise running its HDRP lighting variant; allowing the
819
+ // variant key to re-select HDRP here recreates the mesh-vs-cluster mismatch.
692
820
  if (layoutKind === 'pbr') {
693
821
  return state.pbrPipelineLayout;
694
822
  }
695
- // HDRP variant matches when:
696
- // - layoutKind === 'hdrp-pbr' (caller-driven, future-proof), OR
697
- // - variantSet is the canonical all-true key '' (boot-time HDRP
698
- // registration produces '' when both axes resolve to true), OR
699
- // - variantSet contains `CLUSTER_FORWARD_AVAILABLE=true` substring
700
- // (record-stage caller emits the expanded form).
701
- // URP variants have CLUSTER_FORWARD_AVAILABLE=false explicitly, or omit
702
- // the axis altogether, or pass undefined (no variant routing).
703
- // G-14 (D-11): empty-string is the canonical all-true HDRP key, so we
704
- // explicitly distinguish '' vs undefined rather than using an optional-chain
705
- // (charter P3: explicit failure / no implicit empty-vs-missing collapse).
706
- const isHdrpVariant =
707
- layoutKind === 'hdrp-pbr' ||
708
- variantSet === '' ||
709
- // biome-ignore lint/complexity/useOptionalChain: G-14 grep gate forbids the optional-chain falsy collapse on variantSet.
710
- (variantSet !== undefined && variantSet.includes('CLUSTER_FORWARD_AVAILABLE=true'));
711
- if (isHdrpVariant && state.hdrpPbrPipelineLayout !== null) {
712
- return state.hdrpPbrPipelineLayout;
713
- }
714
- return state.pbrPipelineLayout;
823
+ // Group(2) contract/layout kind is the sole layout authority. Variant keys
824
+ // identify WGSL artifacts, but must not independently select a bind-group
825
+ // layout; this prevents an empty/expanded key from bypassing the resolved
826
+ // storage contract.
827
+ const isHdrpVariant = layoutKind === 'hdrp-pbr';
828
+ return isHdrpVariant ? state.hdrpPbrPipelineLayout : state.pbrPipelineLayout;
715
829
  }
716
830
 
717
831
  /**
@@ -727,10 +841,14 @@ export function resolveMaterialShaderVariantSet(
727
841
  }>,
728
842
  backendKind: RhiDevice['caps']['backendKind'],
729
843
  storageBuffer: boolean,
844
+ projectorAvailable: boolean = true,
730
845
  ): string | undefined {
731
846
  const declaredAxes = new Set(variants.flatMap((variant) => Object.keys(variant.defines)));
732
847
  const declaresCapabilityVariants =
733
- declaredAxes.has('WEBGL2_COMPAT') || declaredAxes.has('STORAGE_BUFFER_AVAILABLE');
848
+ declaredAxes.has('WEBGL2_COMPAT') ||
849
+ declaredAxes.has('STORAGE_BUFFER_AVAILABLE') ||
850
+ declaredAxes.has('DIRECTIONAL_PCSS_AVAILABLE') ||
851
+ declaredAxes.has('PROJECTOR_AVAILABLE');
734
852
  if (!declaresCapabilityVariants) return requestedVariantSet;
735
853
 
736
854
  const requested: Record<string, boolean> = {};
@@ -748,20 +866,24 @@ export function resolveMaterialShaderVariantSet(
748
866
  : axis === 'SKINNING_DISABLED'
749
867
  ? (requested[axis] ?? true)
750
868
  : axis === 'CLUSTER_FORWARD_AVAILABLE'
751
- ? backendKind !== 'wgpu-webgl2' && (requested[axis] ?? false)
869
+ ? storageBuffer && backendKind !== 'wgpu-webgl2' && (requested[axis] ?? false)
752
870
  : axis === 'PER_INSTANCE_REGION'
753
871
  ? requestedVariantSet === '' || (requested[axis] ?? false)
754
872
  : axis === 'VERTEX_COLOR_AVAILABLE'
755
873
  ? requestedVariantSet === '' || (requested[axis] ?? false)
756
874
  : axis === 'TRANSMISSION_AVAILABLE'
757
875
  ? (requested[axis] ?? false)
758
- : (requested[axis] ?? true);
876
+ : axis === 'DIRECTIONAL_PCSS_AVAILABLE'
877
+ ? directionalPcssCapable(backendKind)
878
+ : axis === 'PROJECTOR_AVAILABLE'
879
+ ? projectorAvailable
880
+ : (requested[axis] ?? true);
759
881
  }
760
882
  const sorted = Object.entries(filtered).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
761
883
  return sorted.every(([, value]) => value)
762
- ? requestedVariantSet === ''
763
- ? ''
764
- : undefined
884
+ ? requestedVariantSet === undefined
885
+ ? undefined
886
+ : ''
765
887
  : sorted.map(([name, value]) => `${name}=${value}`).join('+');
766
888
  }
767
889
 
@@ -825,6 +947,28 @@ export async function createRenderer(
825
947
  });
826
948
  }
827
949
 
950
+ function markRendererBootstrapStage(
951
+ name: string,
952
+ detail?: Readonly<Record<string, unknown>>,
953
+ ): void {
954
+ const host = globalThis as {
955
+ __forgeaxRendererBootstrap?: {
956
+ readonly name: string;
957
+ readonly startedAt: number;
958
+ readonly previousElapsedMs: number;
959
+ readonly detail?: Readonly<Record<string, unknown>>;
960
+ };
961
+ };
962
+ const previous = host.__forgeaxRendererBootstrap;
963
+ const now = Date.now();
964
+ host.__forgeaxRendererBootstrap = {
965
+ name,
966
+ startedAt: now,
967
+ previousElapsedMs: previous === undefined ? 0 : now - previous.startedAt,
968
+ ...(detail === undefined ? {} : { detail }),
969
+ };
970
+ }
971
+
828
972
  // ─── WebGPU branch ──────────────────────────────────────────────────────────
829
973
 
830
974
  /**
@@ -935,44 +1079,6 @@ type WebGPUOutcome =
935
1079
  | { kind: 'rhi-err'; error: RhiError }
936
1080
  | { kind: 'throw'; error: Error };
937
1081
 
938
- // Optional compression is exposed on a device only when it is requested from
939
- // the adapter. Keep this capability projection shared by initial construction
940
- // and device-loss recovery; otherwise recovery silently drops BC/ETC2/ASTC
941
- // and already-catalogued compressed textures fail on the first new frame.
942
- const COMPRESSION_FEATURES: GPUFeatureName[] = [
943
- 'texture-compression-bc',
944
- 'texture-compression-etc2',
945
- 'texture-compression-astc',
946
- ];
947
-
948
- // The Standard transmission fragment ABI needs 17 sampled textures across its
949
- // material, IBL, clustered-light, and SSAO bindings. Ordinary Standard draws
950
- // use the non-transmission variant and remain valid on the WebGPU minimum of
951
- // 16; only request the elevated limit when the adapter can provide it.
952
- const STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES = 17;
953
-
954
- function deviceOptionsForAdapter(adapter: Pick<RhiAdapter, 'features' | 'limits'>):
955
- | {
956
- requiredFeatures?: GPUFeatureName[];
957
- requiredLimits?: GPUDeviceDescriptor['requiredLimits'];
958
- }
959
- | undefined {
960
- const requiredFeatures = COMPRESSION_FEATURES.filter((feature) => adapter.features.has(feature));
961
- const supportsTransmission =
962
- (adapter.limits.maxSampledTexturesPerShaderStage ?? 0) >=
963
- STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
964
- return {
965
- ...(requiredFeatures.length > 0 ? { requiredFeatures } : {}),
966
- ...(supportsTransmission
967
- ? {
968
- requiredLimits: {
969
- maxSampledTexturesPerShaderStage: STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
970
- },
971
- }
972
- : {}),
973
- };
974
- }
975
-
976
1082
  /**
977
1083
  * Goes through the strict two-step path exposed by the injected backend pack:
978
1084
  * `rhi.requestAdapter()` -> `adapter.requestDevice()`
@@ -1013,19 +1119,32 @@ async function tryCreateWebGPURenderer(
1013
1119
  // backend singleton (rhi-webgpu / rhi-wgpu / explicit escape hatch).
1014
1120
  // w19: thread canvas as compatibleSurface so the wgpu GL backend can
1015
1121
  // enumerate adapters against it (escape hatch, capability-gated).
1122
+ markRendererBootstrapStage('adapter-request-start');
1016
1123
  const adapterResult = await pack.rhi.requestAdapter(undefined, canvas);
1124
+ markRendererBootstrapStage('adapter-request-end', {
1125
+ ok: adapterResult.ok,
1126
+ ...(adapterResult.ok ? {} : { code: adapterResult.error.code }),
1127
+ });
1017
1128
  if (!adapterResult.ok) {
1018
1129
  return { kind: 'rhi-err', error: adapterResult.error };
1019
1130
  }
1020
1131
  const adapter = adapterResult.value;
1021
1132
  // M4 w28: filter compression features from adapter.features (AC-07). The
1022
1133
  // same helper is used by recover() below so both paths share one profile.
1023
- const deviceOpts = deviceOptionsForAdapter(adapter);
1134
+ const deviceOpts = deviceOptionsForAdapter(adapter, options);
1135
+ markRendererBootstrapStage('device-request-start');
1024
1136
  const result = await adapter.requestDevice(deviceOpts);
1137
+ markRendererBootstrapStage('device-request-end', {
1138
+ ok: result.ok,
1139
+ ...(result.ok ? {} : { code: result.error.code }),
1140
+ });
1025
1141
  if (!result.ok) {
1026
1142
  return { kind: 'rhi-err', error: result.error };
1027
1143
  }
1028
1144
  const device: RhiDevice = result.value;
1145
+ markRendererBootstrapStage('device-acquired', {
1146
+ backendKind: device.caps.backendKind,
1147
+ });
1029
1148
 
1030
1149
  // Acquire the canvas context through the backend pack (M3 / w16).
1031
1150
  // rhi-webgpu: internally calls canvas.getContext('webgpu') + branded wrap.
@@ -1150,16 +1269,30 @@ interface WebGPURendererInternals {
1150
1269
  * has wired the controller; identity stable across grow events.
1151
1270
  */
1152
1271
  meshSsboState?: MeshSsboState;
1272
+ /** Device-bound, manifest-owned volumetric utility shader sources. */
1273
+ volumetricFogShaders?: VolumetricFogShaderSources | undefined;
1153
1274
  /** Host-owned producer features; created before device-bound assembly starts. */
1154
1275
  featureHost?: RenderFeatureHost;
1155
1276
  /** App-owned overlay capability read by the typed graph at frame time. */
1156
1277
  debugOverlay?: RenderDebugOverlay;
1278
+ gpuPassTimingSubmittedWork?: Promise<void> | undefined;
1157
1279
  }
1158
1280
 
1159
1281
  async function makeWebGPURenderer(
1160
1282
  internals: WebGPURendererInternals,
1161
1283
  ): Promise<RendererAssemblyImplementation> {
1162
1284
  let disposed = false;
1285
+ let renderSystemForTargetPromotion: RenderSystem | undefined;
1286
+ // The host keeps the zero-option createRenderTargetHost() seam compatible
1287
+ // for package-local lifecycle tests while this assembly supplies the live
1288
+ // DeviceScope generation callback.
1289
+ const renderTargetHost = createRenderTargetHost({
1290
+ rendererId: Symbol('renderer'),
1291
+ getGeneration: () => activeDeviceScope.generation,
1292
+ getDevice: () => internals.device,
1293
+ canPromoteTarget: (target) =>
1294
+ renderSystemForTargetPromotion?.isCubeCapturePending(target) !== true,
1295
+ });
1163
1296
  const featureHostResult = createRenderFeatureHost(internals.options?.features ?? []);
1164
1297
  if (!featureHostResult.ok) throw featureHostResult.error;
1165
1298
  internals.featureHost = featureHostResult.value;
@@ -1174,6 +1307,28 @@ async function makeWebGPURenderer(
1174
1307
  featureHostResult.value.features.flatMap((feature) => feature.requiredMaterialShaders ?? []),
1175
1308
  );
1176
1309
  let requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
1310
+ const collectRequiredFullscreenPostProcesses = (
1311
+ features: readonly RenderFeature<unknown>[],
1312
+ ): readonly { readonly identity: string; readonly source: string }[] => {
1313
+ const entries = new Map<string, { readonly identity: string; readonly source: string }>();
1314
+ for (const feature of features) {
1315
+ for (const entry of feature.requiredFullscreenPostProcesses ?? []) {
1316
+ const existing = entries.get(entry.identity);
1317
+ if (existing !== undefined && existing.source !== entry.source) {
1318
+ throw new RhiError({
1319
+ code: 'internal-error',
1320
+ expected: `fullscreen identity '${entry.identity}' has one source across render features`,
1321
+ hint: 'rename the conflicting fullscreen identity or make its WGSL source identical',
1322
+ });
1323
+ }
1324
+ entries.set(entry.identity, entry);
1325
+ }
1326
+ }
1327
+ return Object.freeze([...entries.values()]);
1328
+ };
1329
+ let requiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses(
1330
+ featureHostResult.value.features,
1331
+ );
1177
1332
  // Lazy ShaderCatalog instance (plan-strategy section S-10 / D-R10 / OQ-5
1178
1333
  // close): constructed on first access; subsequent accesses return the
1179
1334
  // same instance.
@@ -1216,6 +1371,28 @@ async function makeWebGPURenderer(
1216
1371
  }
1217
1372
  return sharedImmediateShaderModuleAdapter;
1218
1373
  };
1374
+ const prewarmFullscreenFeatureModules = async (
1375
+ feature: RenderFeature<unknown>,
1376
+ ): Promise<Result<void, RhiError>> => {
1377
+ for (const postProcess of feature.requiredFullscreenPostProcesses ?? []) {
1378
+ const label = postProcessShaderModuleLabel(postProcess.identity, postProcess.source);
1379
+ const shaderResult = internals.pack.createShaderModule
1380
+ ? await internals.pack.createShaderModule(internals.device, {
1381
+ code: postProcess.source,
1382
+ label,
1383
+ })
1384
+ : await invokeDeviceCreateShaderModule(internals.device, {
1385
+ code: postProcess.source,
1386
+ label,
1387
+ });
1388
+ if (!shaderResult.ok) {
1389
+ internals.errorRegistry.fire(shaderResult.error);
1390
+ return err(shaderResult.error);
1391
+ }
1392
+ getShaderModuleAdapter().seedModule(label, shaderResult.value);
1393
+ }
1394
+ return ok(undefined);
1395
+ };
1219
1396
  const getShader = (): ShaderCatalog => {
1220
1397
  if (shaderInstance === null) {
1221
1398
  // feat-20260608-create-app-param-surface-trim / M2 / D-2 q5-A:
@@ -1280,6 +1457,56 @@ async function makeWebGPURenderer(
1280
1457
  // lives in a standalone store; `assets` keeps the CPU POD registry only.
1281
1458
  const gpuStore = new GpuResidencyCache();
1282
1459
  let activeDeviceScope = DeviceScope.create(0, 'renderer');
1460
+ const gpuPassTimingOptions = internals.options?.gpuPassTiming;
1461
+ const timingRetentionFrames =
1462
+ gpuPassTimingOptions?.retentionFrames !== undefined &&
1463
+ Number.isInteger(gpuPassTimingOptions.retentionFrames) &&
1464
+ gpuPassTimingOptions.retentionFrames >= 1 &&
1465
+ gpuPassTimingOptions.retentionFrames <= 8
1466
+ ? gpuPassTimingOptions.retentionFrames
1467
+ : DEFAULT_GPU_PASS_TIMING_OPTIONS.retentionFrames;
1468
+ let gpuPassTimingSession: GpuPassTimingSession | undefined;
1469
+ let gpuPassTimingUnavailable: GpuPassTimingObservation | undefined;
1470
+ const timingObservationStore =
1471
+ gpuPassTimingOptions === undefined
1472
+ ? undefined
1473
+ : createGpuPassTimingObservationStore({
1474
+ retentionFrames: timingRetentionFrames,
1475
+ currentDeviceGeneration: () => activeDeviceScope.generation,
1476
+ });
1477
+ const retireGpuPassTimingSession = (): void => {
1478
+ const retired = gpuPassTimingSession;
1479
+ gpuPassTimingSession = undefined;
1480
+ retired?.dispose();
1481
+ };
1482
+ const failedGpuPassTimingObservation = (error: GpuPassTimingReason): GpuPassTimingObservation => {
1483
+ const latestKnownGood = timingObservationStore?.inspect().latestKnownGood;
1484
+ return {
1485
+ status: 'failed',
1486
+ error,
1487
+ ...(latestKnownGood === undefined ? {} : { latestKnownGood }),
1488
+ };
1489
+ };
1490
+ const establishGpuPassTimingSession = (): void => {
1491
+ retireGpuPassTimingSession();
1492
+ gpuPassTimingUnavailable = undefined;
1493
+ const timingOptions = gpuPassTimingOptions;
1494
+ if (timingOptions === undefined) return;
1495
+ const created = createGpuPassTimingSession(internals.device, timingOptions);
1496
+ if (created.ok) {
1497
+ gpuPassTimingSession = created.value;
1498
+ return;
1499
+ }
1500
+ gpuPassTimingUnavailable = {
1501
+ status: 'unavailable',
1502
+ reason: created.error,
1503
+ capability: {
1504
+ timestampQuery: internals.device.caps.timestampQuery,
1505
+ timestampPeriodNanoseconds: internals.device.caps.timestampPeriodNanoseconds,
1506
+ },
1507
+ };
1508
+ };
1509
+ establishGpuPassTimingSession();
1283
1510
  // feat-20260623-world-space-video-asset M4 / w16 (D-3): transient per-frame
1284
1511
  // video texture store, fully independent of gpuStore (AC-08). Configured with
1285
1512
  // the device alongside gpuStore below; threaded into the record stage via the
@@ -1413,7 +1640,10 @@ async function makeWebGPURenderer(
1413
1640
  // (the seeding closure captures this Map by reference; ordering is safe
1414
1641
  // because the prewarm step runs strictly before the first frame's
1415
1642
  // `getMaterialShaderPipeline` lookup that would consume it).
1416
- const materialShaderPipelineCache = new Map<string, RenderPipeline>();
1643
+ const materialShaderPipelineCache = new Map<
1644
+ string,
1645
+ { readonly pipeline: RenderPipeline; readonly group2Contract: PipelineGroup2Contract }
1646
+ >();
1417
1647
  const materialShaderManifestEntryCache = new Map<string, MaterialShaderManifestEntry>();
1418
1648
  // Variant resolution only depends on the requested axes, device capability
1419
1649
  // axes, and the manifest entry that owns the declarations. Cache it by the
@@ -1628,6 +1858,18 @@ async function makeWebGPURenderer(
1628
1858
  }
1629
1859
  if (pipelineState === null) return null;
1630
1860
  const paramSchema = lookup.value.paramSchema;
1861
+ // Reuse the shared BGL only when the complete derived resource shape is a
1862
+ // canonical prefix. Field names alone are not a layout contract: a
1863
+ // texture3d/array, comparison sampler, or storage buffer must never be
1864
+ // paired with the PBR texture2d/filtering descriptor by accident.
1865
+ if (isSharedMaterialUserRegionCompatible(paramSchema)) {
1866
+ perShaderMaterialLayoutCache.set(materialShaderId, {
1867
+ source: lookup.value.source,
1868
+ paramSchema,
1869
+ layout: null,
1870
+ });
1871
+ return null;
1872
+ }
1631
1873
  const spec: PipelineSpec = {
1632
1874
  shader: { id: materialShaderId, passKind: 'forward', variantSet: undefined },
1633
1875
  attachments: { colorFormats: [], depthFormat: undefined, sampleCount: 1 },
@@ -1674,6 +1916,8 @@ async function makeWebGPURenderer(
1674
1916
  [],
1675
1917
  internals.device.caps.backendKind,
1676
1918
  internals.device.caps.storageBuffer,
1919
+ (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1920
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1677
1921
  );
1678
1922
  }
1679
1923
  const variants = manifestEntry.variants;
@@ -1682,13 +1926,17 @@ async function makeWebGPURenderer(
1682
1926
  byRequest = new Map();
1683
1927
  materialShaderVariantResolutionCache.set(variants, byRequest);
1684
1928
  }
1685
- const cacheKey = `${requestedVariantSet ?? '\u0000'}|${internals.device.caps.backendKind}|${internals.device.caps.storageBuffer ? '1' : '0'}`;
1929
+ const projectorAvailable =
1930
+ (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1931
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
1932
+ const cacheKey = `${requestedVariantSet ?? '\u0000'}|${internals.device.caps.backendKind}|${internals.device.caps.storageBuffer ? '1' : '0'}|${projectorAvailable ? '1' : '0'}`;
1686
1933
  if (byRequest.has(cacheKey)) return byRequest.get(cacheKey);
1687
1934
  const resolved = resolveMaterialShaderVariantSet(
1688
1935
  requestedVariantSet,
1689
1936
  variants,
1690
1937
  internals.device.caps.backendKind,
1691
1938
  internals.device.caps.storageBuffer,
1939
+ projectorAvailable,
1692
1940
  );
1693
1941
  byRequest.set(cacheKey, resolved);
1694
1942
  return resolved;
@@ -1714,6 +1962,7 @@ async function makeWebGPURenderer(
1714
1962
  // `internals.device` / `internals.pack` are read live, so a rebuild after a
1715
1963
  // device swap compiles against the new device.
1716
1964
  let tonemapRegistered = false;
1965
+ let fxaaRegistered = false;
1717
1966
  let temporalPostProcessesRegistered = false;
1718
1967
  const buildPipeline = (scope: DeviceScope = activeDeviceScope): Promise<PipelineState> =>
1719
1968
  buildReadyWebGPU(
@@ -1724,6 +1973,7 @@ async function makeWebGPURenderer(
1724
1973
  internals.pack.createShaderModule,
1725
1974
  internals.errorRegistry,
1726
1975
  requiredMaterialShaders,
1976
+ requiredFullscreenPostProcesses,
1727
1977
  // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
1728
1978
  // T-M2-05 + M3 / T-M3-04: callback set by buildReadyWebGPU once
1729
1979
  // meshSsboController is wired. Both the grow hook AND the read-only
@@ -1743,9 +1993,12 @@ async function makeWebGPURenderer(
1743
1993
  // SPEC_CONST prewarm. Closure captures the Map declared just above so
1744
1994
  // the URP-variant prewarmed PSOs become visible to the URP record path
1745
1995
  // immediately (no 1-frame async-compile skip-draw window).
1746
- (key, pso) => {
1996
+ (key, pso, group2Contract) => {
1747
1997
  if (!materialShaderPipelineCache.has(key)) {
1748
- materialShaderPipelineCache.set(key, pso);
1998
+ materialShaderPipelineCache.set(key, {
1999
+ pipeline: pso,
2000
+ group2Contract,
2001
+ });
1749
2002
  }
1750
2003
  },
1751
2004
  // feat-20260621 M-A3 (D-5): register the built-in tonemap on the unified
@@ -1764,6 +2017,19 @@ async function makeWebGPURenderer(
1764
2017
  });
1765
2018
  tonemapRegistered = true;
1766
2019
  },
2020
+ // FXAA is recorded by the special graph primitive, but its final-output
2021
+ // dither switch still uses the renderer-owned post-process params UBO.
2022
+ // Register the declaration through the same lifecycle so recovery
2023
+ // recreates the buffer on the replacement device.
2024
+ (source: string) => {
2025
+ if (fxaaRegistered) return;
2026
+ renderSystem.registerBuiltinPostProcess(FXAA_POST_PROCESS_ID, {
2027
+ source,
2028
+ params: { byteSize: 16, defaultValue: new Uint8Array(16) },
2029
+ reads: ['ldrColor'],
2030
+ });
2031
+ fxaaRegistered = true;
2032
+ },
1767
2033
  (entries: { readonly motionBlur?: string; readonly taaResolve?: string }) => {
1768
2034
  if (temporalPostProcessesRegistered) return;
1769
2035
  if (entries.motionBlur !== undefined) {
@@ -1782,15 +2048,23 @@ async function makeWebGPURenderer(
1782
2048
  }
1783
2049
  temporalPostProcessesRegistered = true;
1784
2050
  },
2051
+ (sources: VolumetricFogShaderSources) => {
2052
+ internals.volumetricFogShaders = sources;
2053
+ },
1785
2054
  );
2055
+ markRendererBootstrapStage('renderer-initialization-start');
1786
2056
  const ready: Promise<Result<void, RhiError>> = buildPipeline().then(
1787
2057
  (state): Result<void, RhiError> => {
1788
2058
  pipelineState = state;
1789
2059
  readySettled = true;
2060
+ markRendererBootstrapStage('renderer-initialized');
1790
2061
  return ok(undefined);
1791
2062
  },
1792
2063
  (e: unknown): Result<void, RhiError> => {
1793
2064
  readySettled = true;
2065
+ markRendererBootstrapStage('renderer-initialization-failed', {
2066
+ message: e instanceof Error ? e.message : String(e),
2067
+ });
1794
2068
  if (e instanceof RhiError) return err(e);
1795
2069
  const message = e instanceof Error ? e.message : String(e);
1796
2070
  return err(
@@ -1827,6 +2101,9 @@ async function makeWebGPURenderer(
1827
2101
  pbrManifestEntry,
1828
2102
  internals.device.caps.storageBuffer,
1829
2103
  variantSet,
2104
+ directionalPcssCapable(internals.device.caps.backendKind),
2105
+ (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
2106
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1830
2107
  );
1831
2108
  if (noColorVariant !== undefined && noColorVariant.composedWgsl !== standardEntry.wgsl) {
1832
2109
  return { ...standardEntry, wgsl: noColorVariant.composedWgsl };
@@ -1842,10 +2119,10 @@ async function makeWebGPURenderer(
1842
2119
  // paths (D-3). Null when manifest is empty (Camera-only path).
1843
2120
  //
1844
2121
  // feat-20260609-hdrp-cluster-fragment-ggx M4.5 / w37 (D-10 option A):
1845
- // `variantSet` parameter selects between URP `pbrPipelineLayout` (1-slot
1846
- // group(2) BGL) and HDRP `hdrpPbrPipelineLayout` (7-slot group(2) BGL)
1847
- // via `selectPipelineLayoutForVariant`. When undefined (legacy callers
1848
- // / fallback path) the URP layout is used preserving prior behaviour.
2122
+ // `variantSet` identifies the shader artifact, while the resolved group(2)
2123
+ // contract selects the matching Standard layout. A missing cluster layout
2124
+ // is an explicit unavailable result; it must not silently bind the mesh-only
2125
+ // layout to a clustered shader.
1849
2126
  const buildPipelineContext = (
1850
2127
  variantSet?: string,
1851
2128
  materialShaderId?: string,
@@ -1856,40 +2133,31 @@ async function makeWebGPURenderer(
1856
2133
  // consume. A caller passing a real `MeshAsset.attributes` produces the
1857
2134
  // exact 6-attribute / 72-byte layout the skin shader expects; passing
1858
2135
  // undefined falls into the synthetic 6-key sentinel below (the skin
1859
- // path is fully determined by layoutKind, so key-presence is the only
1860
- // signal `deriveVertexBufferLayout` reads — values never matter).
2136
+ // path is fully determined by the resolved group-2 contract, so
2137
+ // key-presence is the only signal `deriveVertexBufferLayout` reads).
1861
2138
  meshAttributes?: VertexAttributeMap,
1862
2139
  vertexLayout?: string,
1863
2140
  vertexLayoutProjection?: VertexLayoutProjection,
1864
2141
  shaderModuleMode?: RenderFeatureShaderModuleMode,
2142
+ group2Contract: PipelineGroup2Contract = 'mesh',
1865
2143
  layoutKindOverride?: LayoutKind,
1866
2144
  ) => {
1867
- // bug-20260611: derive LayoutKind from materialShaderId (caller-driven)
1868
- // so the selector body stays free of literal shader-id strings (AC-09).
1869
- // Skin shader gets its own 4-slot layout chain with 2-entry mesh-array
1870
- // BGL; everything else routes through the URP/HDRP variantSet logic.
1871
- // bug-20260708 M2 (c): sprite / sprite-lit map to 'sprite-urp' so the
1872
- // canonical all-true variant key `''` (used by SpriteInstances batches
1873
- // via SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET) does NOT route
1874
- // through the HDRP layout branch of the selector — sprite shaders have
1875
- // no HDRP variant, plan-strategy R-1' guard.
1876
- const layoutKind: LayoutKind | undefined =
1877
- layoutKindOverride ??
1878
- (materialShaderId === SKIN_MATERIAL_SHADER_ID ||
1879
- isSkinnedShadowCasterVariant(materialShaderId, variantSet)
1880
- ? 'pbr-skin'
1881
- : materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit'
1882
- ? 'sprite-urp'
1883
- : materialShaderId === 'forgeax::default-unlit'
1884
- ? 'unlit-urp'
1885
- : undefined);
2145
+ const contractLayoutKind: LayoutKind =
2146
+ group2Contract === 'cluster'
2147
+ ? 'hdrp-pbr'
2148
+ : group2Contract === 'skin-cluster'
2149
+ ? 'hdrp-skin'
2150
+ : group2Contract === 'skin'
2151
+ ? 'pbr-skin'
2152
+ : 'pbr';
2153
+ const resolvedLayoutKind = layoutKindOverride ?? contractLayoutKind;
1886
2154
  const usesSharedBootMaterialLayout =
1887
2155
  materialShaderId !== undefined && isEngineOwnedMaterialShader(materialShaderId);
1888
2156
  // Built-in PBR/unlit/skin layouts are assembled at boot. Re-deriving a
1889
2157
  // per-shader layout from the expanded PBR schema would bypass the HDRP
1890
2158
  // group(2) selector and bind the URP mesh BGL to the cluster pipeline.
1891
2159
  const perShaderLayout =
1892
- !usesSharedBootMaterialLayout && layoutKind !== 'pbr-skin' && materialShaderId !== undefined
2160
+ !usesSharedBootMaterialLayout && materialShaderId !== undefined
1893
2161
  ? getOrBuildPerShaderMaterialLayout(materialShaderId)
1894
2162
  : null;
1895
2163
  // feat-20260621-learn-render-5-5-parallax M2 / w6 (D-1): an authored
@@ -1941,7 +2209,7 @@ async function makeWebGPURenderer(
1941
2209
  ? preparedMaterialLayout
1942
2210
  : perShaderLayout !== null
1943
2211
  ? perShaderLayout.pipelineLayout
1944
- : selectPipelineLayoutForVariant(pipelineState, variantSet, layoutKind);
2212
+ : selectPipelineLayoutForVariant(pipelineState, variantSet, resolvedLayoutKind);
1945
2213
  if (pipelineLayout === null) return null;
1946
2214
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
1947
2215
  // pbr-skin -> deriveVertexBufferLayout (single SSOT for skin/non-skin
@@ -1992,7 +2260,7 @@ async function makeWebGPURenderer(
1992
2260
  : undefined,
1993
2261
  ),
1994
2262
  )
1995
- : layoutKind === 'pbr-skin'
2263
+ : materialShaderId === SKIN_MATERIAL_SHADER_ID
1996
2264
  ? toGpuVertexBufferLayouts(
1997
2265
  deriveVertexBufferLayout(
1998
2266
  meshAttributes ??
@@ -2038,7 +2306,7 @@ async function makeWebGPURenderer(
2038
2306
  : getShaderModuleAdapter(),
2039
2307
  pipelineLayout,
2040
2308
  vertexBuffers,
2041
- ...(layoutKind === 'pbr-skin' ? { layoutKind } : {}),
2309
+ ...(resolvedLayoutKind === 'pbr-skin' ? { layoutKind: 'pbr-skin' as const } : {}),
2042
2310
  };
2043
2311
  };
2044
2312
  /**
@@ -2062,7 +2330,7 @@ async function makeWebGPURenderer(
2062
2330
  passKind: PassKind = 'forward',
2063
2331
  // bug-20260611-skin-pipeline-layout: thread materialShaderId so
2064
2332
  // buildPipelineContext can derive `LayoutKind === 'pbr-skin'` and pick
2065
- // the 2-entry mesh-array BGL chain.
2333
+ // the 3-entry mesh-array BGL chain.
2066
2334
  materialShaderId?: string,
2067
2335
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2068
2336
  // pass the per-mesh `VertexAttributeMap` so the pbr-skin path's
@@ -2098,7 +2366,11 @@ async function makeWebGPURenderer(
2098
2366
  vertexLayoutProjection?: VertexLayoutProjection,
2099
2367
  shaderModuleMode?: RenderFeatureShaderModuleMode,
2100
2368
  layoutKindOverride?: LayoutKind,
2101
- ): RenderPipeline | null => {
2369
+ ): {
2370
+ readonly pipeline: RenderPipeline;
2371
+ readonly group2Contract: PipelineGroup2Contract;
2372
+ } | null => {
2373
+ const group2Contract = resolvePipelineGroup2Contract(entry.source);
2102
2374
  const ctx = buildPipelineContext(
2103
2375
  variantSet,
2104
2376
  materialShaderId,
@@ -2106,6 +2378,7 @@ async function makeWebGPURenderer(
2106
2378
  vertexLayout,
2107
2379
  vertexLayoutProjection,
2108
2380
  shaderModuleMode,
2381
+ group2Contract,
2109
2382
  layoutKindOverride,
2110
2383
  );
2111
2384
  if (ctx === null) return null;
@@ -2165,10 +2438,14 @@ async function makeWebGPURenderer(
2165
2438
  }
2166
2439
  return null;
2167
2440
  }
2168
- materialShaderPipelineCache.set(cacheKey, built.value);
2169
- return built.value;
2441
+ const pipelineEntry = {
2442
+ pipeline: built.value,
2443
+ group2Contract,
2444
+ } as const;
2445
+ materialShaderPipelineCache.set(cacheKey, pipelineEntry);
2446
+ return pipelineEntry;
2170
2447
  };
2171
- const getMaterialShaderPipeline = (
2448
+ const resolveMaterialShaderPipelineEntry = (
2172
2449
  materialShaderId: string,
2173
2450
  isHdr: boolean,
2174
2451
  renderState?: MaterialRenderState,
@@ -2215,7 +2492,10 @@ async function makeWebGPURenderer(
2215
2492
  vertexLayoutProjection?: VertexLayoutProjection,
2216
2493
  shaderModuleMode: RenderFeatureShaderModuleMode = 'validated',
2217
2494
  layoutKindOverride?: LayoutKind,
2218
- ): RenderPipeline | null => {
2495
+ ): {
2496
+ readonly pipeline: RenderPipeline;
2497
+ readonly group2Contract: PipelineGroup2Contract;
2498
+ } | null => {
2219
2499
  // WebGL2 is a downlevel backend even when the record stage asks for the
2220
2500
  // native-capability variant string. Resolve the variant axes at this
2221
2501
  // shared PSO seam so the shader source, BGL layout, and cache key agree on
@@ -2335,6 +2615,11 @@ async function makeWebGPURenderer(
2335
2615
  if (pipelineState === null) return null;
2336
2616
  const lookup = getShader().findMaterialArtifact(materialShaderId);
2337
2617
  if (!lookup.ok) {
2618
+ // An explicit variant request is an exact artifact contract. There is
2619
+ // no safe boot/PBR substitute: a substitute can pair the requested
2620
+ // source identity with a different group(2) ABI and only fail at GPU
2621
+ // validation. Let the caller retry after the manifest/artifact is fixed.
2622
+ if (effectiveVariantSet !== undefined) return null;
2338
2623
  if (shouldDeferMissingPreparedMaterialShader(vertexLayout)) {
2339
2624
  // Prepared VFX draws bind only their feature-owned groups. The
2340
2625
  // built-in PBR fallback declares mesh/instance groups 2/3 and would
@@ -2455,24 +2740,26 @@ async function makeWebGPURenderer(
2455
2740
  // Use `!== undefined` so the empty-string case enters the lookup.
2456
2741
  if (effectiveVariantSet !== undefined) {
2457
2742
  const registry = getShader();
2743
+ let matchedVariant = false;
2458
2744
  for (const msEntry of registry.materialShaderManifestEntries()) {
2459
2745
  if (msEntry.identifier === materialShaderId) {
2460
2746
  const variant = findVariantByKey(msEntry, effectiveVariantSet);
2461
- if (variant) {
2462
- // M3 / w12-w13: variant substitution carries the same source +
2463
- // paramSchema as the boot-registered entry; the binding layout
2464
- // is no longer carried on MaterialShaderEntry (deleted in
2465
- // w13) buildPbrPipelineLayouts is the BGL SSOT and reads
2466
- // derive(paramSchema).bglEntries on demand.
2467
- shaderEntry = {
2468
- source: variant.composedWgsl,
2469
- paramSchema: lookup.value.paramSchema,
2470
- paramSchemaProjection: lookup.value.paramSchemaProjection,
2471
- };
2472
- }
2747
+ if (variant === undefined) return null;
2748
+ matchedVariant = true;
2749
+ // M3 / w12-w13: variant substitution carries the same source +
2750
+ // paramSchema as the boot-registered entry; the binding layout
2751
+ // is no longer carried on MaterialShaderEntry (deleted in
2752
+ // w13) buildPbrPipelineLayouts is the BGL SSOT and reads
2753
+ // derive(paramSchema).bglEntries on demand.
2754
+ shaderEntry = {
2755
+ source: variant.composedWgsl,
2756
+ paramSchema: lookup.value.paramSchema,
2757
+ paramSchemaProjection: lookup.value.paramSchemaProjection,
2758
+ };
2473
2759
  break;
2474
2760
  }
2475
2761
  }
2762
+ if (!matchedVariant) return null;
2476
2763
  }
2477
2764
  // feat-20260609 M4 / R3-fixup: append `-${passKind}` to the PSO label so
2478
2765
  // GPU debug captures (and the shadow-caster branch in the builder)
@@ -2513,7 +2800,7 @@ async function makeWebGPURenderer(
2513
2800
  passKind,
2514
2801
  // bug-20260611-skin-pipeline-layout: thread the registered materialShaderId
2515
2802
  // so buildPipelineContext can resolve LayoutKind='pbr-skin' for the skin
2516
- // shader (2-entry mesh-array BGL).
2803
+ // shader (3-entry mesh-array BGL).
2517
2804
  materialShaderId,
2518
2805
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2519
2806
  // forward meshAttributes so the pbr-skin path's vertex buffer layout
@@ -2530,6 +2817,12 @@ async function makeWebGPURenderer(
2530
2817
  layoutKindOverride,
2531
2818
  );
2532
2819
  };
2820
+ const getMaterialShaderPipelineEntry = (
2821
+ ...args: Parameters<typeof resolveMaterialShaderPipelineEntry>
2822
+ ) => resolveMaterialShaderPipelineEntry(...args);
2823
+ const getMaterialShaderPipeline = (
2824
+ ...args: Parameters<typeof resolveMaterialShaderPipelineEntry>
2825
+ ): RenderPipeline | null => getMaterialShaderPipelineEntry(...args)?.pipeline ?? null;
2533
2826
  const getParamSchema = (materialShaderId: string) => {
2534
2827
  const lookup = getShader().findMaterialArtifact(materialShaderId);
2535
2828
  return lookup.ok ? lookup.value.paramSchema : undefined;
@@ -2650,6 +2943,10 @@ async function makeWebGPURenderer(
2650
2943
  shaderModuleFactory: {
2651
2944
  createShaderModule: (descriptor) => getShaderModuleAdapter().createShaderModule(descriptor),
2652
2945
  },
2946
+ createShaderModule: (device, descriptor) =>
2947
+ internals.pack.createShaderModule === undefined
2948
+ ? Promise.resolve(invokeDeviceCreateShaderModule(device, descriptor))
2949
+ : internals.pack.createShaderModule(device, descriptor),
2653
2950
  immediateShaderModuleFactory: {
2654
2951
  createShaderModule: (descriptor) =>
2655
2952
  getImmediateShaderModuleAdapter().createShaderModule(descriptor),
@@ -2686,6 +2983,7 @@ async function makeWebGPURenderer(
2686
2983
  errorRegistry: internals.errorRegistry,
2687
2984
  healthRegistry: internals.healthRegistry,
2688
2985
  getMaterialShaderPipeline,
2986
+ getMaterialShaderPipelineEntry,
2689
2987
  getMaterialShaderBindingContract: getCachedMaterialShaderBindingContract,
2690
2988
  getParamSchema,
2691
2989
  getMaterialBindGroupLayout,
@@ -2705,7 +3003,93 @@ async function makeWebGPURenderer(
2705
3003
  get meshSsboState() {
2706
3004
  return internals.meshSsboState;
2707
3005
  },
3006
+ get gpuPassTimingSession() {
3007
+ return (
3008
+ internals as WebGPURendererInternals & {
3009
+ gpuPassTimingSession?: GpuPassTimingSession | undefined;
3010
+ }
3011
+ ).gpuPassTimingSession;
3012
+ },
3013
+ set gpuPassTimingSession(session: GpuPassTimingSession | undefined) {
3014
+ (
3015
+ internals as WebGPURendererInternals & {
3016
+ gpuPassTimingSession?: GpuPassTimingSession | undefined;
3017
+ }
3018
+ ).gpuPassTimingSession = session;
3019
+ },
3020
+ get gpuPassTimingCapture() {
3021
+ return (
3022
+ internals as WebGPURendererInternals & {
3023
+ gpuPassTimingCapture?: GpuPassTimingCapture | undefined;
3024
+ }
3025
+ ).gpuPassTimingCapture;
3026
+ },
3027
+ set gpuPassTimingCapture(capture: GpuPassTimingCapture | undefined) {
3028
+ (
3029
+ internals as WebGPURendererInternals & {
3030
+ gpuPassTimingCapture?: GpuPassTimingCapture | undefined;
3031
+ }
3032
+ ).gpuPassTimingCapture = capture;
3033
+ },
3034
+ get gpuPassTimingSubmittedWork() {
3035
+ return (
3036
+ internals as WebGPURendererInternals & {
3037
+ gpuPassTimingSubmittedWork?: Promise<void> | undefined;
3038
+ }
3039
+ ).gpuPassTimingSubmittedWork;
3040
+ },
3041
+ set gpuPassTimingSubmittedWork(completion: Promise<void> | undefined) {
3042
+ (
3043
+ internals as WebGPURendererInternals & {
3044
+ gpuPassTimingSubmittedWork?: Promise<void> | undefined;
3045
+ }
3046
+ ).gpuPassTimingSubmittedWork = completion;
3047
+ },
3048
+ get gpuPassTimingFrameIdentity() {
3049
+ return (
3050
+ internals as WebGPURendererInternals & {
3051
+ gpuPassTimingFrameIdentity?:
3052
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingFrameIdentity
3053
+ | undefined;
3054
+ }
3055
+ ).gpuPassTimingFrameIdentity;
3056
+ },
3057
+ set gpuPassTimingFrameIdentity(identity:
3058
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingFrameIdentity
3059
+ | undefined) {
3060
+ (
3061
+ internals as WebGPURendererInternals & {
3062
+ gpuPassTimingFrameIdentity?:
3063
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingFrameIdentity
3064
+ | undefined;
3065
+ }
3066
+ ).gpuPassTimingFrameIdentity = identity;
3067
+ },
3068
+ get gpuPassTimingBeginReason() {
3069
+ return (
3070
+ internals as WebGPURendererInternals & {
3071
+ gpuPassTimingBeginReason?: GpuPassTimingReason | undefined;
3072
+ }
3073
+ ).gpuPassTimingBeginReason;
3074
+ },
3075
+ set gpuPassTimingBeginReason(reason: GpuPassTimingReason | undefined) {
3076
+ (
3077
+ internals as WebGPURendererInternals & {
3078
+ gpuPassTimingBeginReason?: GpuPassTimingReason | undefined;
3079
+ }
3080
+ ).gpuPassTimingBeginReason = reason;
3081
+ },
3082
+ getRenderTargetPhysical: (target) => renderTargetHost.getPhysicalTarget(target),
3083
+ resolveRenderTargetTextureSource: (source) =>
3084
+ renderTargetHost.resolveRenderTargetTextureSource(source),
3085
+ encodeRenderTargetReadbacks: (encoder, faces) =>
3086
+ renderTargetHost.encodePendingReadbacks(encoder, faces),
3087
+ get volumetricFogShaders() {
3088
+ return internals.volumetricFogShaders;
3089
+ },
2708
3090
  });
3091
+ attachGpuPassTimingSession(internals, gpuPassTimingSession);
3092
+ renderSystemForTargetPromotion = renderSystem;
2709
3093
  if (
2710
3094
  internals.device.caps.backendKind === 'null' &&
2711
3095
  Array.from(getShader().entries()).length === 0
@@ -2730,7 +3114,12 @@ async function makeWebGPURenderer(
2730
3114
  const transformReleases = new Map<World, () => void>();
2731
3115
  const attachmentOwner = {};
2732
3116
  let frameId = 0;
2733
- let latestReceipt: FrameReceipt | undefined;
3117
+ // Receipts remain valid for observation until their device generation is
3118
+ // retired. Keeping a renderer-owned registry avoids treating a concurrent
3119
+ // RAF submission as invalidation of an otherwise current receipt.
3120
+ const currentReceipts = new Set<FrameReceipt>();
3121
+ const issuedReceipts = new WeakSet<FrameReceipt>();
3122
+ const receiptTimings = new WeakMap<FrameReceipt, Promise<VolumeTimingObservation>>();
2734
3123
  let surfaceReleased = false;
2735
3124
 
2736
3125
  const renderer: RendererAssemblyImplementation = {
@@ -2744,13 +3133,21 @@ async function makeWebGPURenderer(
2744
3133
  leasesByWorld.set(world, lease);
2745
3134
  return ok(lease);
2746
3135
  },
3136
+ createRenderTarget: (descriptor) => renderTargetHost.createRenderTarget(descriptor),
3137
+ resizeRenderTarget: (target, descriptor) =>
3138
+ renderTargetHost.resizeRenderTarget(target, descriptor),
3139
+ createRenderTargetTextureSource: (target, options) =>
3140
+ renderTargetHost.createRenderTargetTextureSource(target, options),
3141
+ requestTargetReadback: (target, request) =>
3142
+ renderTargetHost.requestTargetReadback(target, request),
3143
+ destroyRenderTarget: (target) => renderTargetHost.destroyRenderTarget(target),
2747
3144
  setProfile(profile: RenderProfile): RenderResult<void, RenderError> {
2748
3145
  const invalid = validateRenderProfile(profile);
2749
3146
  if (invalid !== undefined) {
2750
3147
  return err(
2751
3148
  new RendererOperationError('frame-input-invalid', {
2752
3149
  operation: 'set-profile',
2753
- cause: new RendererContractFailureError('draw', invalid),
3150
+ cause: new StandardProfileInvalidError(invalid),
2754
3151
  }),
2755
3152
  );
2756
3153
  }
@@ -2794,6 +3191,8 @@ async function makeWebGPURenderer(
2794
3191
  ? {}
2795
3192
  : { presentationProof: internals.context.presentationProof }),
2796
3193
  });
3194
+ const volumetricFog = renderSystem.volumetricFog;
3195
+ const directionalShadow = renderSystem.directionalShadow;
2797
3196
  return Object.freeze({
2798
3197
  state,
2799
3198
  surface: surfaceReleased ? 'released' : 'available',
@@ -2807,12 +3206,15 @@ async function makeWebGPURenderer(
2807
3206
  (internals.featureHost?.features ?? []).map((feature) => feature.identity),
2808
3207
  ),
2809
3208
  featureDiagnostics: internals.featureHost?.diagnostics() ?? Object.freeze([]),
3209
+ volumetricFog,
2810
3210
  frustumStats: Object.freeze({ ...renderSystem.frustumStats }),
2811
3211
  visibilityStats: Object.freeze({ ...renderSystem.visibilityStats }),
2812
3212
  renderScene: renderSystem.renderScene,
3213
+ reflectionProbes: renderSystem.reflectionProbes,
2813
3214
  environment: renderSystem.environment,
2814
3215
  temporal: renderSystem.temporal,
2815
3216
  bloom: renderSystem.bloom,
3217
+ directionalShadow,
2816
3218
  ...(renderSystem.temporalTargetInspection === undefined
2817
3219
  ? {}
2818
3220
  : { temporalTarget: Object.freeze(renderSystem.temporalTargetInspection) }),
@@ -2822,6 +3224,12 @@ async function makeWebGPURenderer(
2822
3224
  ...(renderSystem.transmission === undefined
2823
3225
  ? {}
2824
3226
  : { transmission: renderSystem.transmission }),
3227
+ ...(renderSystem.standardLightingInspection === undefined
3228
+ ? {}
3229
+ : { standardLighting: Object.freeze({ ...renderSystem.standardLightingInspection }) }),
3230
+ ...(renderSystem.pointShadowInspection === undefined
3231
+ ? {}
3232
+ : { pointShadow: Object.freeze({ ...renderSystem.pointShadowInspection }) }),
2825
3233
  meshMaterialBindings: Object.freeze(
2826
3234
  renderSystem.meshMaterialBindings.map((observation) =>
2827
3235
  Object.freeze({
@@ -2970,6 +3378,9 @@ async function makeWebGPURenderer(
2970
3378
  get bindGroupCounts() {
2971
3379
  return renderSystem.bindGroupCounts;
2972
3380
  },
3381
+ configureStandard(config) {
3382
+ renderSystem.configureStandard(config);
3383
+ },
2973
3384
  renderFeatureDiagnostics() {
2974
3385
  return internals.featureHost?.diagnostics() ?? [];
2975
3386
  },
@@ -2995,6 +3406,35 @@ async function makeWebGPURenderer(
2995
3406
  new RenderFeatureCapabilityMissingError(feature.identity, host.size, missingCapability),
2996
3407
  );
2997
3408
  }
3409
+ let nextRequiredFullscreenPostProcesses: readonly {
3410
+ readonly identity: string;
3411
+ readonly source: string;
3412
+ }[];
3413
+ try {
3414
+ nextRequiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses([
3415
+ ...host.features,
3416
+ feature,
3417
+ ]);
3418
+ } catch (cause) {
3419
+ const conflict =
3420
+ cause instanceof RhiError
3421
+ ? cause
3422
+ : new RhiError({
3423
+ code: 'internal-error',
3424
+ expected: 'fullscreen feature declarations are internally consistent',
3425
+ hint: 'repair the conflicting fullscreen identity/source declarations',
3426
+ });
3427
+ internals.errorRegistry.fire(conflict);
3428
+ return err(
3429
+ new RenderFeatureStageFailedError(feature.identity, -1, 'prepare', 'registration'),
3430
+ );
3431
+ }
3432
+ const fullscreenPrewarm = await prewarmFullscreenFeatureModules(feature);
3433
+ if (!fullscreenPrewarm.ok) {
3434
+ return err(
3435
+ new RenderFeatureStageFailedError(feature.identity, -1, 'prepare', 'registration'),
3436
+ );
3437
+ }
2998
3438
  for (const materialShaderId of feature.requiredMaterialShaders ?? []) {
2999
3439
  const lookup = getShader().findMaterialArtifact(materialShaderId);
3000
3440
  if (!lookup.ok) {
@@ -3033,6 +3473,7 @@ async function makeWebGPURenderer(
3033
3473
  requiredMaterialShaderSet.add(materialShaderId);
3034
3474
  }
3035
3475
  requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
3476
+ requiredFullscreenPostProcesses = nextRequiredFullscreenPostProcesses;
3036
3477
  }
3037
3478
  return installed;
3038
3479
  },
@@ -3061,6 +3502,7 @@ async function makeWebGPURenderer(
3061
3502
  }
3062
3503
  }
3063
3504
  requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
3505
+ requiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses(host.features);
3064
3506
  }
3065
3507
  return removed;
3066
3508
  },
@@ -3234,8 +3676,52 @@ async function makeWebGPURenderer(
3234
3676
  // through onError separately; the facade Result is the synchronous
3235
3677
  // summary AI users can ignore or branch on).
3236
3678
  try {
3237
- const submitted = renderSystem.draw(worlds, drawOptions, renderReadLeases);
3679
+ renderTargetHost.beginFrame();
3680
+ const timingHost = internals as WebGPURendererInternals & {
3681
+ gpuPassTimingFrameIdentity?:
3682
+ | {
3683
+ readonly frameId: number;
3684
+ readonly deviceGeneration: number;
3685
+ readonly graphGeneration: number;
3686
+ }
3687
+ | undefined;
3688
+ gpuPassTimingCapture?:
3689
+ | import('../record/gpu-pass-timing/session.js').GpuPassTimingCapture
3690
+ | undefined;
3691
+ gpuPassTimingBeginReason?: GpuPassTimingReason | undefined;
3692
+ };
3693
+ const timingRequested =
3694
+ isFrameRequest &&
3695
+ (frameRequest?.profileFrame !== undefined ||
3696
+ internals.options?.captureGpuTimings === true);
3697
+ let volumeTimingCapture: GpuTimingCapture | undefined;
3698
+ const volumeTimingUnavailableReason = timingRequested
3699
+ ? 'timestamp capture is unavailable on this device'
3700
+ : 'timestamp capture was not requested for this frame';
3701
+ // The pass-timing session is authoritative when the feature is
3702
+ // enabled. Keep the volume-specific capture for its existing
3703
+ // benchmark path, but do not allocate two timestamp readbacks for a
3704
+ // single receipt when both options are present.
3705
+ if (timingRequested && gpuPassTimingOptions === undefined) {
3706
+ const createdTiming = GpuTimingCapture.create(internals.device);
3707
+ if (createdTiming.ok) volumeTimingCapture = createdTiming.value;
3708
+ }
3709
+ if (isFrameRequest) {
3710
+ timingHost.gpuPassTimingFrameIdentity = {
3711
+ frameId: frameId + 1,
3712
+ deviceGeneration: activeDeviceScope.generation,
3713
+ graphGeneration: 0,
3714
+ };
3715
+ }
3716
+ const submitted = renderSystem.draw(
3717
+ worlds,
3718
+ drawOptions,
3719
+ renderReadLeases,
3720
+ volumeTimingCapture,
3721
+ );
3722
+ timingHost.gpuPassTimingFrameIdentity = undefined;
3238
3723
  if (!submitted) {
3724
+ volumeTimingCapture?.discard();
3239
3725
  return err(
3240
3726
  new RendererContractFailureError(
3241
3727
  'draw',
@@ -3243,29 +3729,78 @@ async function makeWebGPURenderer(
3243
3729
  ),
3244
3730
  );
3245
3731
  }
3732
+ const timingCompletion = timingHost.gpuPassTimingSubmittedWork;
3733
+ timingHost.gpuPassTimingSubmittedWork = undefined;
3246
3734
  internals.pack.instrumentation?.onFrameBoundary?.();
3735
+ const receiptFrameId = ++frameId;
3736
+ const receiptGeneration = activeDeviceScope.generation;
3737
+ const completed: FrameReceipt['completed'] = (
3738
+ timingCompletion ?? internals.device.queue.onSubmittedWorkDone()
3739
+ )
3740
+ .then(() => ok(undefined))
3741
+ .catch((cause: unknown) =>
3742
+ err(
3743
+ new RendererOperationError('device-operation-failed', {
3744
+ operation: 'complete-frame',
3745
+ frameId: receiptFrameId,
3746
+ deviceGeneration: receiptGeneration,
3747
+ cause: structuredRendererCause(cause, 'complete-frame'),
3748
+ }),
3749
+ ),
3750
+ );
3751
+ renderTargetHost.onFrameSubmitted(completed);
3247
3752
  if (isFrameRequest) {
3248
- const receiptFrameId = ++frameId;
3249
- const receiptGeneration = activeDeviceScope.generation;
3250
- const completed: FrameReceipt['completed'] = internals.device.queue
3251
- .onSubmittedWorkDone()
3252
- .then(() => ok(undefined))
3253
- .catch((cause: unknown) =>
3254
- err(
3255
- new RendererOperationError('device-operation-failed', {
3256
- operation: 'complete-frame',
3257
- frameId: receiptFrameId,
3258
- deviceGeneration: receiptGeneration,
3259
- cause: structuredRendererCause(cause, 'complete-frame'),
3260
- }),
3261
- ),
3262
- );
3263
3753
  const receipt = Object.freeze({
3264
3754
  frameId: receiptFrameId,
3265
3755
  deviceGeneration: receiptGeneration,
3266
3756
  completed,
3267
3757
  });
3268
- latestReceipt = receipt;
3758
+ const passTimingCapture = timingHost.gpuPassTimingCapture;
3759
+ const timingBeginReason = timingHost.gpuPassTimingBeginReason;
3760
+ const timingSource =
3761
+ passTimingCapture === undefined
3762
+ ? gpuPassTimingUnavailable === undefined && timingBeginReason === undefined
3763
+ ? undefined
3764
+ : async (): Promise<GpuPassTimingObservation> =>
3765
+ gpuPassTimingUnavailable ??
3766
+ failedGpuPassTimingObservation(timingBeginReason as GpuPassTimingReason)
3767
+ : async (): Promise<GpuPassTimingObservation> => {
3768
+ const observed = await passTimingCapture.observe();
3769
+ if (!observed.ok) {
3770
+ return failedGpuPassTimingObservation(observed.error);
3771
+ }
3772
+ const frame = observed.value;
3773
+ const unmeasured = frame.passes.find((pass) => pass.status === 'unmeasured');
3774
+ if (unmeasured !== undefined) {
3775
+ return { status: 'partial', frame, reason: unmeasured.reason };
3776
+ }
3777
+ if (frame.droppedPassCount > 0) {
3778
+ return {
3779
+ status: 'partial',
3780
+ frame,
3781
+ reason: {
3782
+ code: 'query-budget-exceeded',
3783
+ expected: 'all executed passes fit within the bounded query budget',
3784
+ hint: 'increase maxPassesPerFrame or simplify the pass graph',
3785
+ detail: { droppedPassCount: frame.droppedPassCount },
3786
+ },
3787
+ };
3788
+ }
3789
+ return { status: 'complete', frame };
3790
+ };
3791
+ timingObservationStore?.register(receipt, timingSource);
3792
+ currentReceipts.add(receipt);
3793
+ if (timingRequested && gpuPassTimingOptions === undefined) {
3794
+ receiptTimings.set(
3795
+ receipt,
3796
+ volumeTimingCapture?.observation() ??
3797
+ Promise.resolve({
3798
+ status: 'unavailable',
3799
+ reason: volumeTimingUnavailableReason,
3800
+ }),
3801
+ );
3802
+ }
3803
+ issuedReceipts.add(receipt);
3269
3804
  return ok(receipt);
3270
3805
  }
3271
3806
  return ok(undefined);
@@ -3289,11 +3824,18 @@ async function makeWebGPURenderer(
3289
3824
  receipt: FrameReceipt,
3290
3825
  request: FrameObservationRequest,
3291
3826
  ): Promise<RenderResult<FrameReceiptObservation, RenderError>> {
3827
+ // The bounded pass contract owns `timings`; the volume timing producer
3828
+ // owns the separate `volumeTimings` field. Keeping the fields distinct
3829
+ // prevents consumers from having to discriminate an untagged union.
3830
+ if (gpuPassTimingOptions !== undefined) {
3831
+ return timingObservationStore === undefined
3832
+ ? observeGpuPassTimingDisabled(receipt, request, () => activeDeviceScope.generation)
3833
+ : timingObservationStore.observe(receipt, request);
3834
+ }
3292
3835
  if (
3293
- latestReceipt === undefined ||
3294
- latestReceipt !== receipt ||
3295
- latestReceipt.frameId !== receipt.frameId ||
3296
- latestReceipt.deviceGeneration !== receipt.deviceGeneration
3836
+ !currentReceipts.has(receipt) ||
3837
+ !issuedReceipts.has(receipt) ||
3838
+ receipt.deviceGeneration !== activeDeviceScope.generation
3297
3839
  ) {
3298
3840
  const currentGeneration = activeDeviceScope.generation;
3299
3841
  return err(
@@ -3316,11 +3858,27 @@ async function makeWebGPURenderer(
3316
3858
  }),
3317
3859
  );
3318
3860
  }
3861
+ const targetReadbacks =
3862
+ request.targetReadbacks === undefined || request.targetReadbacks.length === 0
3863
+ ? undefined
3864
+ : await renderTargetHost.observeTargetReadbacks(receipt, request.targetReadbacks);
3865
+ if (targetReadbacks !== undefined && !targetReadbacks.ok) return targetReadbacks;
3866
+ const volumeTimings = request.include.includes('timings')
3867
+ ? await (receiptTimings.get(receipt) ??
3868
+ Promise.resolve<VolumeTimingObservation>({
3869
+ status: 'unavailable',
3870
+ reason: 'timestamp capture was not requested for this frame',
3871
+ }))
3872
+ : undefined;
3319
3873
  return ok(
3320
3874
  Object.freeze({
3321
3875
  frameId: receipt.frameId,
3322
3876
  deviceGeneration: receipt.deviceGeneration,
3323
3877
  include: Object.freeze([...request.include]),
3878
+ ...(targetReadbacks === undefined
3879
+ ? {}
3880
+ : { targetReadbacks: Object.freeze(targetReadbacks.value) }),
3881
+ ...(volumeTimings === undefined ? {} : { volumeTimings }),
3324
3882
  }),
3325
3883
  );
3326
3884
  },
@@ -3395,6 +3953,8 @@ async function makeWebGPURenderer(
3395
3953
  dispose(): RenderResult<void, RenderError> {
3396
3954
  if (disposed) return ok(undefined);
3397
3955
  disposed = true;
3956
+ currentReceipts.clear();
3957
+ renderTargetHost.dispose();
3398
3958
  const cleanupFailures: RendererOperationCause[] = [];
3399
3959
  for (const world of attachedWorlds) {
3400
3960
  try {
@@ -3575,6 +4135,8 @@ async function makeWebGPURenderer(
3575
4135
  // a layout from the replacement device.
3576
4136
  emptyPostProcessBgl = null;
3577
4137
  internals.context.unconfigure();
4138
+ retireGpuPassTimingSession();
4139
+ attachGpuPassTimingSession(internals, undefined);
3578
4140
 
3579
4141
  // Step (b) / B-2 / B-AC-02: shed device-bound RenderSystem state minted by
3580
4142
  // the lost device — the render-graph pendingDestroy queue (its PooledTextures
@@ -3595,7 +4157,7 @@ async function makeWebGPURenderer(
3595
4157
  let device: RhiDevice;
3596
4158
  try {
3597
4159
  const deviceResult = await adapterResult.value.requestDevice(
3598
- deviceOptionsForAdapter(adapterResult.value),
4160
+ deviceOptionsForAdapter(adapterResult.value, internals.options),
3599
4161
  );
3600
4162
  if (!deviceResult.ok) {
3601
4163
  return err(new RecoverError('recover-device-unavailable'));
@@ -3721,8 +4283,12 @@ async function makeWebGPURenderer(
3721
4283
  const previousDeviceScope = activeDeviceScope;
3722
4284
  activeDeviceScope = recoveryScope;
3723
4285
  pipelineState = candidatePipelineState;
4286
+ currentReceipts.clear();
3724
4287
  gpuStore.bindDeviceScope(activeDeviceScope);
4288
+ renderTargetHost.recover();
3725
4289
  previousDeviceScope.retire();
4290
+ establishGpuPassTimingSession();
4291
+ attachGpuPassTimingSession(internals, gpuPassTimingSession);
3726
4292
 
3727
4293
  // Re-evaluate feature capability gates against the replacement device.
3728
4294
  // Feature recovery is isolated from renderer recovery: a feature that is
@@ -3808,7 +4374,7 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3808
4374
  });
3809
4375
 
3810
4376
  return {
3811
- attach: (world) => {
4377
+ attach: (world: World) => {
3812
4378
  const result = renderer.attach(world);
3813
4379
  return result.ok
3814
4380
  ? result
@@ -3819,6 +4385,12 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3819
4385
  }),
3820
4386
  );
3821
4387
  },
4388
+ createRenderTarget: (descriptor) => renderer.createRenderTarget(descriptor),
4389
+ resizeRenderTarget: (target, descriptor) => renderer.resizeRenderTarget(target, descriptor),
4390
+ createRenderTargetTextureSource: (target, options) =>
4391
+ renderer.createRenderTargetTextureSource(target, options),
4392
+ requestTargetReadback: (target, request) => renderer.requestTargetReadback(target, request),
4393
+ destroyRenderTarget: (target) => renderer.destroyRenderTarget(target),
3822
4394
  setProfile: (profile) => {
3823
4395
  if (!isRendererStateOperational(currentState)) {
3824
4396
  return err(
@@ -3831,7 +4403,7 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3831
4403
  return renderer.setProfile(profile);
3832
4404
  },
3833
4405
  state: () => currentState,
3834
- draw: (request) => {
4406
+ draw: (request: RenderFrameInput) => {
3835
4407
  const result = renderer.drawFrame(request);
3836
4408
  if (!result.ok) return err(drawError(result.error));
3837
4409
  emit(
@@ -3844,8 +4416,9 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3844
4416
  return ok(result.value);
3845
4417
  },
3846
4418
  inspect: () => renderer.inspect(),
3847
- observe: (receipt, request) => renderer.observe(receipt, request),
3848
- subscribe: (listener) => {
4419
+ observe: (receipt: FrameReceipt, request: FrameObservationRequest) =>
4420
+ renderer.observe(receipt, request),
4421
+ subscribe: (listener: RendererEventListener) => {
3849
4422
  if (disposed) return () => undefined;
3850
4423
  listeners.add(listener);
3851
4424
  return () => listeners.delete(listener);
@@ -3959,12 +4532,11 @@ function structuredRendererCause(cause: unknown, operation: string): RendererOpe
3959
4532
  const RENDER_PROFILE_KEYS = new Set([
3960
4533
  'pipelineId',
3961
4534
  'lightCount',
3962
- 'lighting',
4535
+ 'renderPath',
3963
4536
  'shadows',
3964
4537
  'pbr',
3965
4538
  'ibl',
3966
4539
  'ssao',
3967
- 'fallback',
3968
4540
  'postStages',
3969
4541
  ]);
3970
4542
 
@@ -3978,15 +4550,12 @@ function validateRenderProfile(profile: RenderProfile): string | undefined {
3978
4550
  if (!STANDARD_LIGHT_COUNTS.some((count) => count === profile.lightCount)) {
3979
4551
  return 'RenderProfile.lightCount must be 1, 32, or 256';
3980
4552
  }
3981
- if (profile.lighting !== 'direct' && profile.lighting !== 'clustered') {
3982
- return "RenderProfile.lighting must be 'direct' or 'clustered'";
4553
+ if (profile.renderPath !== 'forward' && profile.renderPath !== 'deferred') {
4554
+ return "RenderProfile.renderPath must be 'forward' or 'deferred'";
3983
4555
  }
3984
4556
  if (profile.shadows !== 'off' && profile.shadows !== 'hard' && profile.shadows !== 'filtered') {
3985
4557
  return "RenderProfile.shadows must be 'off', 'hard', or 'filtered'";
3986
4558
  }
3987
- if (profile.fallback !== 'native' && profile.fallback !== 'cpu-webgl2') {
3988
- return "RenderProfile.fallback must be 'native' or 'cpu-webgl2'";
3989
- }
3990
4559
  for (const field of ['pbr', 'ibl', 'ssao'] as const) {
3991
4560
  if (typeof profile[field] !== 'boolean') return `RenderProfile.${field} must be boolean`;
3992
4561
  }
@@ -4262,23 +4831,20 @@ function makeRhiNotAvailableError(): RhiError {
4262
4831
  // inverseViewProj mat4x4<f32> (64 B) appended at tail (offset 176, align 16,
4263
4832
  // total 176 + 64 = 240 B). Field added to common.wgsl View struct; host
4264
4833
  // writes the tail via queue.writeBuffer in render-system-record.
4265
- // feat-20260613-csm-cascaded-shadow-maps M4 / w16: extended to 592 B.
4834
+ // feat-20260613-csm-cascaded-shadow-maps M4 / w16: extended to 960 B.
4266
4835
  // lightSpaceMatrix replaced by lightViewProj_A at offset 112; inverseViewProj
4267
4836
  // stays at offset 176; lightViewProj_B..D + splitPlanes[4] + cascadeCount +
4268
- // cascadeBlend + tail padding appended after inverseViewProj. Total 148 f32
4269
- // = 592 B, fixed independent of cascadeCount (AC-08).
4270
- // feat-20260621-merge-directionallightshadow-into-directionallight M3 / m3-t3:
4271
- // depthBias / normalBias / pcfKernelSize from the merged DirectionalLight
4272
- // append at the formerly-free tail pad (bytes 504/508/512, floats 126/127/128).
4273
- // VIEW_UBO_BYTES stays 592 (the host tail pad shrinks 88 B -> 64 B); the WGSL
4274
- // View struct in common.wgsl carries the matching 3 f32 (SSOT comment synced).
4837
+ // cascadeBlend + tail padding appended after inverseViewProj. The directional
4838
+ // payload is 240 f32 = 960 B, placed in a 1024 B dynamic-uniform slot (AC-08).
4839
+ // M1 reserves [126]/[127] for depthBias/normalBias and [128..131] for the
4840
+ // directional filter carrier; the Spot matrix tail begins at float 132.
4275
4841
  // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-fallback):
4276
- // grew 592 -> 784. The per-spot fragment-read perspective lightViewProj matrices
4842
+ // grew from the directional 960 B payload to the 1024 B slot. The per-spot fragment-read perspective lightViewProj matrices
4277
4843
  // (`array<mat4x4<f32>, 4>` = 256 B) fold from a standalone @group(0) binding 9
4278
4844
  // uniform buffer into the View UBO tail (bytes 528..784, 16 B-aligned after
4279
- // pcfKernelSize at byte 512). This removes the 12th fragment-stage uniform
4845
+ // the directional filter carrier at bytes 512..528). This removes the 12th fragment-stage uniform
4280
4846
  // buffer that overflowed GLES 3.0's max_uniform_buffers_per_shader_stage = 11 on
4281
- // the WebGL2 fallback path. render-system-record writes the full 196 f32 payload;
4847
+ // the WebGL2 fallback path. render-system-record writes the full 240 f32 payload;
4282
4848
  // common.wgsl `View.spotLightViewProj` is the matching SSOT.
4283
4849
  const VIEW_UBO_BYTES = VIEW_UNIFORM_BUFFER_SIZE;
4284
4850
 
@@ -4767,7 +5333,14 @@ async function prepareMaterialShaders(
4767
5333
  if (!capCheck.ok) throw capCheck.error;
4768
5334
  storageBufferCapable = !webgl2Downlevel && capCheck.value;
4769
5335
  }
4770
-
5336
+ // The shared PBR view/material ABI consumes the WebGPU minimum of 16
5337
+ // fragment sampled textures. The authored SpotLight projector is the
5338
+ // optional 17th texture; select its shader/BGL pair from the same limit so
5339
+ // low-limit adapters keep analytic lighting instead of failing layout
5340
+ // creation at boot.
5341
+ const projectorAvailable =
5342
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
5343
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
4771
5344
  // ── Step 1: manifest load ─────────────────────────────────────────────────
4772
5345
  const registry = getShader();
4773
5346
  const loaded = await registry.loadManifest();
@@ -4776,12 +5349,11 @@ async function prepareMaterialShaders(
4776
5349
  }
4777
5350
 
4778
5351
  // ── Step 1b: material shader variant resolution + registration ───────────
4779
- // feat-20260609-hdrp-cluster-fragment-ggx M1 / w7: dual-axis variant
4780
- // resolution. At boot time isHdrpActive is always false (URP default),
4781
- // so CLUSTER_FORWARD_AVAILABLE=false variant is selected. HDRP variant
4782
- // (CLUSTER_FORWARD_AVAILABLE=true) is resolved later when
4783
- // clustered Standard profile activation selects the clustered variant.
4784
- const isHdrpActive = false; // M4: wire to actual renderSystem.frameState.isHdrpActive
5352
+ // Install the non-clustered artifact as the eager registry seed. This value
5353
+ // only makes the pre-frame registry usable; record-time Standard topology is
5354
+ // derived from the frame's prepared `standardLighting.kind` and never reads
5355
+ // this boot seed as an active lighting decision.
5356
+ const bootClusterAxis = standardBootClusterAxis();
4785
5357
  for (const msEntry of registry.materialShaderManifestEntries()) {
4786
5358
  // bug-20260610: skip synthetic non-material engine entries piggy-backing
4787
5359
  // on the materialShaders channel for variant surfacing (shadow_caster).
@@ -4792,6 +5364,11 @@ async function prepareMaterialShaders(
4792
5364
  const variantDefines: Record<string, boolean> = {
4793
5365
  STORAGE_BUFFER_AVAILABLE: storageBufferCapable,
4794
5366
  };
5367
+ if (msEntry.variants.some((v) => 'DIRECTIONAL_PCSS_AVAILABLE' in v.defines)) {
5368
+ variantDefines.DIRECTIONAL_PCSS_AVAILABLE = directionalPcssCapable(
5369
+ rhiDevice.caps.backendKind,
5370
+ );
5371
+ }
4795
5372
  // User material manifests may declare WEBGL2_COMPAT alongside the shared
4796
5373
  // mesh-storage axis. The fallback backend must select that authored
4797
5374
  // branch at boot; otherwise it asks for the impossible bare
@@ -4803,11 +5380,17 @@ async function prepareMaterialShaders(
4803
5380
  // the axis (has a variant with this key), preserving backward compat for
4804
5381
  // entries without the pragma.
4805
5382
  if (msEntry.variants.some((v) => 'CLUSTER_FORWARD_AVAILABLE' in v.defines)) {
4806
- variantDefines.CLUSTER_FORWARD_AVAILABLE = isHdrpActive;
5383
+ variantDefines.CLUSTER_FORWARD_AVAILABLE = bootClusterAxis;
4807
5384
  }
4808
5385
  if (msEntry.variants.some((v) => 'VERTEX_COLOR_AVAILABLE' in v.defines)) {
4809
5386
  variantDefines.VERTEX_COLOR_AVAILABLE = false;
4810
5387
  }
5388
+ if (
5389
+ msEntry.identifier === 'forgeax::default-standard-pbr' &&
5390
+ msEntry.variants.some((v) => 'PROJECTOR_AVAILABLE' in v.defines)
5391
+ ) {
5392
+ variantDefines.PROJECTOR_AVAILABLE = projectorAvailable;
5393
+ }
4811
5394
  if (
4812
5395
  msEntry.identifier === 'forgeax::default-standard-pbr' &&
4813
5396
  msEntry.variants.some((v) => 'TRANSMISSION_AVAILABLE' in v.defines)
@@ -4942,6 +5525,11 @@ async function buildReadyWebGPU(
4942
5525
  * frame while the shared async shader adapter is still warming up.
4943
5526
  */
4944
5527
  requiredMaterialShaders: readonly string[],
5528
+ /** Fullscreen feature modules compiled before the first synchronous frame. */
5529
+ requiredFullscreenPostProcesses: readonly {
5530
+ readonly identity: string;
5531
+ readonly source: string;
5532
+ }[],
4945
5533
  // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
4946
5534
  // T-M2-05 + M3 / T-M3-04: surface for setting `internals.growMeshSsbo`
4947
5535
  // + `internals.meshSsboState` so the record stage's
@@ -4970,7 +5558,11 @@ async function buildReadyWebGPU(
4970
5558
  // `getMaterialShaderPipeline` lookup hits a live PSO instead of falling
4971
5559
  // into a 1-frame async-compile skip-draw window. Idempotent: caller
4972
5560
  // guards against re-seeding when a key already exists.
4973
- seedMaterialShaderPipelineCache: (key: string, pso: RenderPipeline) => void,
5561
+ seedMaterialShaderPipelineCache: (
5562
+ key: string,
5563
+ pso: RenderPipeline,
5564
+ group2Contract: PipelineGroup2Contract,
5565
+ ) => void,
4974
5566
  // feat-20260621 M-A3 (D-5): register the engine built-in tonemap onto the
4975
5567
  // unified post-process channel once the tonemap manifest entry's composed
4976
5568
  // WGSL is resolved. Invoked with the tonemap WGSL source string; the outer
@@ -4980,10 +5572,14 @@ async function buildReadyWebGPU(
4980
5572
  // tonemap source resolves inside this async function (after the manifest-load
4981
5573
  // await), by which point the synchronous `renderSystem` const is defined.
4982
5574
  registerBuiltinTonemap: (source: string) => void,
5575
+ /** Register the special FXAA pass' final-output policy params UBO. */
5576
+ registerBuiltinFxaa: (source: string) => void,
4983
5577
  registerBuiltinTemporalPostProcesses: (entries: {
4984
5578
  readonly motionBlur?: string;
4985
5579
  readonly taaResolve?: string;
4986
5580
  }) => void,
5581
+ /** Install manifest-owned volume utility sources only after all three modules compile. */
5582
+ setVolumetricFogShaderSources: (sources: VolumetricFogShaderSources) => void,
4987
5583
  ): Promise<PipelineState> {
4988
5584
  // bug-20260610: WebGL2 fallback path — read storage-buffer capability up
4989
5585
  // front so both Step 2 (shader-module compile, where we patch engine
@@ -5001,7 +5597,9 @@ async function buildReadyWebGPU(
5001
5597
  const capCheck = assertStorageBufferCap(capValue);
5002
5598
  storageBufferCapable = capCheck.ok ? !webgl2Downlevel && capCheck.value : !webgl2Downlevel;
5003
5599
  }
5004
-
5600
+ const projectorAvailable =
5601
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
5602
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
5005
5603
  // bug-20260612: choose swap-chain storage / view formats by backend.
5006
5604
  // Channel 2 (native WebGPU) follows navigator.gpu.getPreferredCanvasFormat();
5007
5605
  // Channel 3 (wgpu-wasm GLES, storageBufferCapable=false) hard-codes
@@ -5072,6 +5670,10 @@ async function buildReadyWebGPU(
5072
5670
  let unlitModule: ShaderModule | null = null;
5073
5671
  let spriteModule: ShaderModule | null = null;
5074
5672
  let spriteLitModule: ShaderModule | null = null;
5673
+ let pbrGroup2Contract: PipelineGroup2Contract | undefined;
5674
+ let unlitGroup2Contract: PipelineGroup2Contract | undefined;
5675
+ let spriteGroup2Contract: PipelineGroup2Contract | undefined;
5676
+ let spriteLitGroup2Contract: PipelineGroup2Contract | undefined;
5075
5677
  let fxaaModule: ShaderModule | null = null;
5076
5678
  let skyboxModule: ShaderModule | null = null;
5077
5679
  let bloomBrightModule: ShaderModule | null = null;
@@ -5290,20 +5892,25 @@ async function buildReadyWebGPU(
5290
5892
  // CLUSTER_FORWARD_AVAILABLE in PR #344) only emits compound keys like
5291
5893
  // `CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=false`, so
5292
5894
  // findVariantByKey('STORAGE_BUFFER_AVAILABLE=false') silently misses.
5293
- // The fallback path forces isHdrpActive=false (Step 1b above), so the
5294
- // matching PBR variant is the (CFA=false, SBA=false) one pick by
5295
- // defines fields and prefer CLUSTER_FORWARD_AVAILABLE=false when present;
5296
- // otherwise each STORAGE_BUFFER_AVAILABLE=false variant.
5895
+ // The eager registry seed is the non-clustered boot artifact (the active
5896
+ // frame topology is not known here). Pick by defines fields and prefer
5897
+ // the boot cluster axis when present; otherwise use the storage axis.
5297
5898
  const idToVariantWgsl = new Map<string, string>();
5298
5899
  for (const ms of registry.materialShaderManifestEntries()) {
5299
5900
  const exact = ms.variants.find(
5300
- (v) =>
5301
- v.defines.STORAGE_BUFFER_AVAILABLE === false &&
5302
- (!('CLUSTER_FORWARD_AVAILABLE' in v.defines) ||
5303
- v.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
5304
- (!('VERTEX_COLOR_AVAILABLE' in v.defines) ||
5305
- v.defines.VERTEX_COLOR_AVAILABLE === false) &&
5306
- (!('SKINNING_DISABLED' in v.defines) || v.defines.SKINNING_DISABLED === true),
5901
+ (variant) =>
5902
+ variant.defines.STORAGE_BUFFER_AVAILABLE === false &&
5903
+ (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
5904
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === standardBootClusterAxis()) &&
5905
+ (!('VERTEX_COLOR_AVAILABLE' in variant.defines) ||
5906
+ variant.defines.VERTEX_COLOR_AVAILABLE === false) &&
5907
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
5908
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE ===
5909
+ directionalPcssCapable(rhiDevice.caps.backendKind)) &&
5910
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
5911
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
5912
+ (!('SKINNING_DISABLED' in variant.defines) ||
5913
+ variant.defines.SKINNING_DISABLED === true),
5307
5914
  );
5308
5915
  const v = exact === undefined ? undefined : findVariantByKey(ms, exact.definesKey);
5309
5916
  if (v !== undefined) {
@@ -5332,8 +5939,8 @@ async function buildReadyWebGPU(
5332
5939
  }
5333
5940
  }
5334
5941
  // The flat `registry.entries()` map carries the canonical composed source,
5335
- // while the boot layout is the URP/device-capability contract. Select the
5336
- // matching runtime variant for every eager engine module, including native
5942
+ // while this eager module uses the boot layout contract. Select the
5943
+ // matching boot variant for every eager engine module, including native
5337
5944
  // WebGPU. Without this step the canonical PBR/Unlit source declares the
5338
5945
  // optional COLOR_0 vertex input while the boot SPEC_CONST layouts for
5339
5946
  // ordinary meshes do not provide location 13.
@@ -5343,11 +5950,16 @@ async function buildReadyWebGPU(
5343
5950
  (variant) =>
5344
5951
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
5345
5952
  (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
5346
- variant.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
5953
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === standardBootClusterAxis()) &&
5347
5954
  (!('VERTEX_COLOR_AVAILABLE' in variant.defines) ||
5348
5955
  variant.defines.VERTEX_COLOR_AVAILABLE === false) &&
5956
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
5957
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
5349
5958
  (!('SKINNING_DISABLED' in variant.defines) ||
5350
5959
  variant.defines.SKINNING_DISABLED === true) &&
5960
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
5961
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE ===
5962
+ directionalPcssCapable(rhiDevice.caps.backendKind)) &&
5351
5963
  (!('TRANSMISSION_AVAILABLE' in variant.defines) ||
5352
5964
  variant.defines.TRANSMISSION_AVAILABLE === false),
5353
5965
  );
@@ -5480,6 +6092,101 @@ async function buildReadyWebGPU(
5480
6092
  seedShaderModule(label, shaderResult.value);
5481
6093
  }
5482
6094
 
6095
+ // A boot-installed fullscreen feature has the same synchronous record
6096
+ // contract as the built-in output transform: its module must be seeded
6097
+ // before the caller can drive a tight frame loop. Otherwise the typed
6098
+ // graph's first post-effect admission is correctly held back, but a
6099
+ // synchronous consumer would only see the base image until it yields.
6100
+ // Late-installed features use the matching registration path below.
6101
+ for (const postProcess of requiredFullscreenPostProcesses) {
6102
+ const label = postProcessShaderModuleLabel(postProcess.identity, postProcess.source);
6103
+ const shaderResult = await runShimStep(
6104
+ () =>
6105
+ asyncCreateShaderModule
6106
+ ? asyncCreateShaderModule(rhiDevice, { code: postProcess.source, label })
6107
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: postProcess.source, label }),
6108
+ 'shader-compile-failed',
6109
+ `declared fullscreen render feature '${postProcess.identity}' compiled`,
6110
+ `inspect the composed WGSL for '${postProcess.identity}' and check device.features`,
6111
+ );
6112
+ if (!shaderResult.ok) throw shaderResult.error;
6113
+ seedShaderModule(label, shaderResult.value);
6114
+ }
6115
+
6116
+ // The volume graph is authored from ECS state and therefore cannot be
6117
+ // compiled while the static material manifest is assembled. Its pass
6118
+ // encoder nevertheless uses the same shared lazy shader adapter, so the
6119
+ // four utility modules must be prewarmed before the first synchronous
6120
+ // draw. The sources come from the shader manifest (the package-owned WGSL
6121
+ // files are the sole source of truth); compile all four first and only
6122
+ // then publish the labels and source bundle. A partial bundle would let a
6123
+ // later frame mix generations after a device recovery.
6124
+ if (rhiDevice.caps.compute && rhiDevice.caps.storageTexture) {
6125
+ const volumeSources = [
6126
+ ['inject', 'volume_inject', (source: string) => source.includes('fn volume_inject(')],
6127
+ ['temporal', 'volume_temporal', (source: string) => source.includes('fn volume_temporal(')],
6128
+ [
6129
+ 'integrate',
6130
+ 'volume_integrate',
6131
+ (source: string) => source.includes('fn volume_integrate('),
6132
+ ],
6133
+ ['composite', 'volume_composite', (source: string) => source.includes('fn volume_fs(')],
6134
+ ] as const;
6135
+ const resolved = volumeSources.map(([stage, label, matches]) => {
6136
+ const entry = manifestEntries.find((candidate) => matches(candidate.wgsl));
6137
+ return { stage, label, entry };
6138
+ });
6139
+ // Volumetric fog is an optional graph feature. Minimal hosts/tests may
6140
+ // intentionally ship only material/post-process entries; keep those
6141
+ // renderers bootable while still rejecting a partially declared volume
6142
+ // bundle (which would otherwise fail later as a lazy pending pipeline).
6143
+ const present = resolved.filter((candidate) => candidate.entry !== undefined).length;
6144
+ if (present === 0) {
6145
+ // No volume manifest entries: leave the source/module bundle unset and
6146
+ // let the topology capability gate keep volume work disabled.
6147
+ } else {
6148
+ const missing = resolved.find((candidate) => candidate.entry === undefined);
6149
+ if (missing !== undefined) {
6150
+ throw new RhiError({
6151
+ code: 'shader-compile-failed',
6152
+ expected: 'shader manifest contains all four volumetric fog utility entries',
6153
+ hint: `add the package-owned WGSL entry for '${missing.label}' to the engine shader manifest`,
6154
+ });
6155
+ }
6156
+ const compiled = [] as Array<{
6157
+ readonly stage: (typeof volumeSources)[number][0];
6158
+ readonly label: string;
6159
+ readonly source: string;
6160
+ readonly module: ShaderModule;
6161
+ }>;
6162
+ for (const { stage, label, entry } of resolved) {
6163
+ // `missing` above proves the narrowing for this loop; keep the guard
6164
+ // explicit so a future refactor cannot publish an undefined source.
6165
+ if (entry === undefined) throw new Error(`missing volumetric shader ${label}`);
6166
+ const shaderResult = await runShimStep(
6167
+ () =>
6168
+ asyncCreateShaderModule
6169
+ ? asyncCreateShaderModule(rhiDevice, { code: entry.wgsl, label })
6170
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: entry.wgsl, label }),
6171
+ 'shader-compile-failed',
6172
+ `volumetric fog shader module '${label}' compiled`,
6173
+ `inspect manifest WGSL for '${label}' and check device.features`,
6174
+ );
6175
+ if (!shaderResult.ok) throw shaderResult.error;
6176
+ compiled.push({ stage, label, source: entry.wgsl, module: shaderResult.value });
6177
+ }
6178
+ for (const { label, module } of compiled) seedShaderModule(label, module);
6179
+ setVolumetricFogShaderSources({
6180
+ inject: compiled.find((candidate) => candidate.stage === 'inject')?.source as string,
6181
+ temporal: compiled.find((candidate) => candidate.stage === 'temporal')?.source as string,
6182
+ integrate: compiled.find((candidate) => candidate.stage === 'integrate')
6183
+ ?.source as string,
6184
+ composite: compiled.find((candidate) => candidate.stage === 'composite')
6185
+ ?.source as string,
6186
+ });
6187
+ }
6188
+ }
6189
+
5483
6190
  const pbrShaderResult = await runShimStep(
5484
6191
  () =>
5485
6192
  asyncCreateShaderModule
@@ -5491,6 +6198,7 @@ async function buildReadyWebGPU(
5491
6198
  );
5492
6199
  if (!pbrShaderResult.ok) throw pbrShaderResult.error;
5493
6200
  pbrModule = pbrShaderResult.value;
6201
+ pbrGroup2Contract = resolvePipelineGroup2Contract(pbrEntry.wgsl);
5494
6202
  // feat-20260629-multi-uv-set-support: a real extra-UV mesh creates a
5495
6203
  // layout-specific material PSO after the boot-time standard-layout PSO
5496
6204
  // has been pre-warmed. Seed the shared shader-module adapter under the
@@ -5511,7 +6219,12 @@ async function buildReadyWebGPU(
5511
6219
  (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
5512
6220
  STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
5513
6221
  const transmissionVariants = transmissionCapable
5514
- ? selectStandardPbrTransmissionPrewarmVariants(pbrManifestEntry, storageBufferCapable)
6222
+ ? selectStandardPbrTransmissionPrewarmVariants(
6223
+ pbrManifestEntry,
6224
+ storageBufferCapable,
6225
+ directionalPcssCapable(rhiDevice.caps.backendKind),
6226
+ projectorAvailable,
6227
+ )
5515
6228
  : [];
5516
6229
 
5517
6230
  // HDRP uses the canonical all-true variant key (`''`) and the lazy
@@ -5549,6 +6262,8 @@ async function buildReadyWebGPU(
5549
6262
  pbrManifestEntry,
5550
6263
  storageBufferCapable,
5551
6264
  transmissionCapable,
6265
+ directionalPcssCapable(rhiDevice.caps.backendKind),
6266
+ projectorAvailable,
5552
6267
  )) {
5553
6268
  const moduleLabel = `module-forgeax::default-standard-pbr#${hdrpVariant.definesKey}`;
5554
6269
  let hdrpModule = prewarmedHdrpModules.get(hdrpVariant.composedWgsl);
@@ -5575,6 +6290,43 @@ async function buildReadyWebGPU(
5575
6290
  seedShaderModule(moduleLabel, hdrpModule);
5576
6291
  }
5577
6292
 
6293
+ // Skin geometry uses the same lazy material-pipeline adapter as standard
6294
+ // PBR, but its dedicated module was previously never warmed. A record
6295
+ // frame therefore received a pending module result, skipped the skin PSO,
6296
+ // and silently emitted no draw. Compile and seed every active-capability
6297
+ // skin variant so both URP and HDRP resolve their exact PSO consumer on
6298
+ // frame one (including the geometry-owned COLOR_0=false variant).
6299
+ const pbrSkinManifestEntry = [...registry.materialShaderManifestEntries()].find(
6300
+ (entry) => entry.identifier === SKIN_MATERIAL_SHADER_ID,
6301
+ );
6302
+ const activeSkinVariants = selectSkinPrewarmVariants(
6303
+ pbrSkinManifestEntry,
6304
+ storageBufferCapable,
6305
+ );
6306
+ for (const skinVariant of activeSkinVariants) {
6307
+ const moduleLabel = `module-${SKIN_MATERIAL_SHADER_ID}#${skinVariant.definesKey}`;
6308
+ const skinModuleResult = await runShimStep(
6309
+ () =>
6310
+ asyncCreateShaderModule
6311
+ ? asyncCreateShaderModule(rhiDevice, {
6312
+ code: skinVariant.composedWgsl,
6313
+ label: moduleLabel,
6314
+ })
6315
+ : invokeDeviceCreateShaderModule(rhiDevice, {
6316
+ code: skinVariant.composedWgsl,
6317
+ label: moduleLabel,
6318
+ }),
6319
+ 'shader-compile-failed',
6320
+ `pbr-skin variant ${skinVariant.definesKey || '<default>'} compiled`,
6321
+ 'inspect the selected pbr-skin storage/cluster/color variant and device.features',
6322
+ );
6323
+ if (!skinModuleResult.ok) throw skinModuleResult.error;
6324
+ seedShaderModule(moduleLabel, skinModuleResult.value);
6325
+ if (skinVariant.definesKey === '') {
6326
+ seedShaderModule(`module-${SKIN_MATERIAL_SHADER_ID}`, skinModuleResult.value);
6327
+ }
6328
+ }
6329
+
5578
6330
  // M2-07-c: Materials.standard with an explicit renderState reaches the
5579
6331
  // lazy material pipeline path, whose module label includes the requested
5580
6332
  // variant set. Reuse the boot-compiled PBR module only for manifest
@@ -5599,6 +6351,7 @@ async function buildReadyWebGPU(
5599
6351
  );
5600
6352
  if (!unlitShaderResult.ok) throw unlitShaderResult.error;
5601
6353
  unlitModule = unlitShaderResult.value;
6354
+ unlitGroup2Contract = resolvePipelineGroup2Contract(unlitEntry.wgsl);
5602
6355
  // Seed the shared lazy adapter with the eagerly compiled module so
5603
6356
  // prepared color-only PSOs build synchronously on their first frame.
5604
6357
  seedShaderModule('unlit', unlitModule);
@@ -5745,7 +6498,8 @@ async function buildReadyWebGPU(
5745
6498
  // frames (e.g. the hello-tonemap dawn smoke) would stall on a black frame
5746
6499
  // forever. Mirror the shadow_caster prewarm: await-compile the tonemap module
5747
6500
  // here and `seedShaderModule` it under the exact label
5748
- // `buildPostProcessPipeline` requests (`post-process-${id}-module`), so the
6501
+ // `buildPostProcessPipeline` requests the content-addressed module label,
6502
+ // so the
5749
6503
  // first `getPostProcessPipeline(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, …)` hits the module cache
5750
6504
  // synchronously and builds the pipeline on frame 1.
5751
6505
  const tonemapPrewarm = await runShimStep(
@@ -5753,11 +6507,17 @@ async function buildReadyWebGPU(
5753
6507
  asyncCreateShaderModule
5754
6508
  ? asyncCreateShaderModule(rhiDevice, {
5755
6509
  code: tonemapEntry.wgsl,
5756
- label: `post-process-${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}-module`,
6510
+ label: postProcessShaderModuleLabel(
6511
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
6512
+ tonemapEntry.wgsl,
6513
+ ),
5757
6514
  })
5758
6515
  : invokeDeviceCreateShaderModule(rhiDevice, {
5759
6516
  code: tonemapEntry.wgsl,
5760
- label: `post-process-${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}-module`,
6517
+ label: postProcessShaderModuleLabel(
6518
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
6519
+ tonemapEntry.wgsl,
6520
+ ),
5761
6521
  }),
5762
6522
  'shader-compile-failed',
5763
6523
  'tonemap shader module compiled (unified post-process prewarm)',
@@ -5765,7 +6525,7 @@ async function buildReadyWebGPU(
5765
6525
  );
5766
6526
  if (!tonemapPrewarm.ok) throw tonemapPrewarm.error;
5767
6527
  seedShaderModule(
5768
- `post-process-${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}-module`,
6528
+ postProcessShaderModuleLabel(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, tonemapEntry.wgsl),
5769
6529
  tonemapPrewarm.value,
5770
6530
  );
5771
6531
  registerBuiltinTonemap(tonemapEntry.wgsl);
@@ -5782,18 +6542,18 @@ async function buildReadyWebGPU(
5782
6542
  asyncCreateShaderModule
5783
6543
  ? asyncCreateShaderModule(rhiDevice, {
5784
6544
  code: entry.wgsl,
5785
- label: `post-process-${id}-module`,
6545
+ label: postProcessShaderModuleLabel(id, entry.wgsl),
5786
6546
  })
5787
6547
  : invokeDeviceCreateShaderModule(rhiDevice, {
5788
6548
  code: entry.wgsl,
5789
- label: `post-process-${id}-module`,
6549
+ label: postProcessShaderModuleLabel(id, entry.wgsl),
5790
6550
  }),
5791
6551
  'shader-compile-failed',
5792
6552
  `${id} shader module compiled (unified post-process prewarm)`,
5793
6553
  `inspect manifest ${id} entry composed wgsl; check device.features`,
5794
6554
  );
5795
6555
  if (!prewarm.ok) throw prewarm.error;
5796
- seedShaderModule(`post-process-${id}-module`, prewarm.value);
6556
+ seedShaderModule(postProcessShaderModuleLabel(id, entry.wgsl), prewarm.value);
5797
6557
  };
5798
6558
  await prewarmTemporal(motionBlurEntry, 'forgeax.motion-blur');
5799
6559
  await prewarmTemporal(taaResolveEntry, 'forgeax.taa-resolve');
@@ -5822,6 +6582,7 @@ async function buildReadyWebGPU(
5822
6582
  );
5823
6583
  if (!spriteShaderResult.ok) throw spriteShaderResult.error;
5824
6584
  spriteModule = spriteShaderResult.value;
6585
+ spriteGroup2Contract = resolvePipelineGroup2Contract(spriteEntry.wgsl);
5825
6586
  // feat-20260625-refactor-sprite-as-transparent-mesh CI-fix: seed the
5826
6587
  // lazy adapter cache under the same label (`module-forgeax::sprite`)
5827
6588
  // that `getMaterialShaderPipeline -> buildAndCachePipeline ->
@@ -5892,7 +6653,44 @@ async function buildReadyWebGPU(
5892
6653
  );
5893
6654
  if (!spriteLitShaderResult.ok) throw spriteLitShaderResult.error;
5894
6655
  spriteLitModule = spriteLitShaderResult.value;
6656
+ spriteLitGroup2Contract = resolvePipelineGroup2Contract(spriteLitEntryConst.wgsl);
5895
6657
  seedShaderModule('module-forgeax::sprite-lit', spriteLitShaderResult.value);
6658
+ // Sprite-lit participates in the same Standard capability split as
6659
+ // PBR. Record requests the explicit URP/HDRP variant per frame, so seed
6660
+ // every device-matched artifact during readiness; a tight dawn/browser
6661
+ // frame loop cannot yield to the async module adapter between draws.
6662
+ const spriteLitManifestEntry = Array.from(registry.materialShaderManifestEntries()).find(
6663
+ (entry) => entry.identifier === 'forgeax::sprite-lit',
6664
+ );
6665
+ const prewarmedSpriteLitModules = new Map<string, ShaderModule>([
6666
+ [spriteLitEntryConst.wgsl, spriteLitShaderResult.value],
6667
+ ]);
6668
+ for (const variant of spriteLitManifestEntry?.variants ?? []) {
6669
+ if (variant.defines.STORAGE_BUFFER_AVAILABLE !== storageBufferCapable) continue;
6670
+ const moduleLabel = `module-forgeax::sprite-lit#${variant.definesKey}`;
6671
+ let variantModule = prewarmedSpriteLitModules.get(variant.composedWgsl);
6672
+ if (variantModule === undefined) {
6673
+ const variantResult = await runShimStep(
6674
+ () =>
6675
+ asyncCreateShaderModule
6676
+ ? asyncCreateShaderModule(rhiDevice, {
6677
+ code: variant.composedWgsl,
6678
+ label: moduleLabel,
6679
+ })
6680
+ : invokeDeviceCreateShaderModule(rhiDevice, {
6681
+ code: variant.composedWgsl,
6682
+ label: moduleLabel,
6683
+ }),
6684
+ 'shader-compile-failed',
6685
+ `sprite-lit variant ${variant.definesKey || '<default>'} compiled`,
6686
+ 'inspect the selected sprite-lit Standard capability variant and device.features',
6687
+ );
6688
+ if (!variantResult.ok) throw variantResult.error;
6689
+ variantModule = variantResult.value;
6690
+ prewarmedSpriteLitModules.set(variant.composedWgsl, variantModule);
6691
+ }
6692
+ seedShaderModule(moduleLabel, variantModule);
6693
+ }
5896
6694
  }
5897
6695
 
5898
6696
  // feat-20260528-fxaa-post-processing: fxaa shader module is optional
@@ -5915,6 +6713,7 @@ async function buildReadyWebGPU(
5915
6713
  );
5916
6714
  if (!fxaaShaderResult.ok) throw fxaaShaderResult.error;
5917
6715
  fxaaModule = fxaaShaderResult.value;
6716
+ registerBuiltinFxaa(fxaaEntry.wgsl);
5918
6717
  }
5919
6718
 
5920
6719
  // feat-20260531-skybox-env-background M3 / w15: skybox shader module.
@@ -6042,7 +6841,13 @@ async function buildReadyWebGPU(
6042
6841
  // buildReadyWebGPU (above) so Step 2 (variant patch) and this layout
6043
6842
  // build read the same value.
6044
6843
  const pbrLayouts = runShimSyncStep(
6045
- () => ok(buildPbrPipelineLayouts(rhiDevice, { storageBuffer: storageBufferCapable })),
6844
+ () =>
6845
+ ok(
6846
+ buildPbrPipelineLayouts(rhiDevice, {
6847
+ storageBuffer: storageBufferCapable,
6848
+ projectorAvailable,
6849
+ }),
6850
+ ),
6046
6851
  'webgpu-runtime-error',
6047
6852
  'buildPbrPipelineLayouts succeeded',
6048
6853
  'check device.limits.maxBindingsPerBindGroup (need >=14) and maxBindGroupsPerPipelineLayout',
@@ -6061,15 +6866,14 @@ async function buildReadyWebGPU(
6061
6866
  // BGL at group(2) so an HDRP-variant PSO validates against the 7-slot
6062
6867
  // group(2) bindGroup that the record stage sets via hdrpClusterBindGroup.
6063
6868
  // Stays null when createBindGroupLayout / createPipelineLayout fails;
6064
- // selectPipelineLayoutForVariant gracefully falls back to pbrPipelineLayout
6065
- // (URP layout) so the manifest entry's URP variant WGSL still builds a
6066
- // valid PSO instead of hard-disabling the entire HDRP-variant build path.
6869
+ // selectPipelineLayoutForVariant propagates that null so the clustered
6870
+ // material remains unavailable instead of binding an incompatible layout.
6067
6871
  let hdrpPbrPipelineLayoutHandle: PipelineLayout | null = null;
6068
6872
  // The cluster layout is storage-buffer backed. On the WebGL2/fallback
6069
6873
  // capability tier its uniform-buffer projection exceeds the backend's
6070
6874
  // fragment-stage limit, and HDRP is not a valid route there anyway.
6071
6875
  if (storageBufferCapable) {
6072
- const hdrpUnifiedBglDesc = createHdrpBindGroupLayoutDescriptor(storageBufferCapable);
6876
+ const hdrpUnifiedBglDesc = createHdrpBindGroupLayoutDescriptor();
6073
6877
  const hdrpUnifiedBglRes = rhiDevice.createBindGroupLayout(hdrpUnifiedBglDesc);
6074
6878
  if (hdrpUnifiedBglRes.ok) {
6075
6879
  const hdrpPlRes = rhiDevice.createPipelineLayout({
@@ -6140,23 +6944,26 @@ async function buildReadyWebGPU(
6140
6944
  // bug-20260611-skin-pipeline-layout-mesh-array-bgl-2bindings: boot-time
6141
6945
  // build of the skin-variant PipelineLayout. Mirrors the HDRP block above
6142
6946
  // (D-2 / D-3 in plan-strategy): reuse view / material / instances BGLs
6143
- // from `pbrLayouts` and only create a 2-entry mesh-array BGL (binding 0
6144
- // meshes + binding 1 palette) so the `forgeax::pbr-skin` shader's
6947
+ // from `pbrLayouts` and only create a 3-entry mesh-array BGL (binding 0
6948
+ // meshes + binding 1 current palette + binding 2 previous palette) so the
6949
+ // `forgeax::pbr-skin` shader's
6145
6950
  // `@group(2) @binding(1) palette` declaration validates. Stays null when
6146
6951
  // createBindGroupLayout / createPipelineLayout fails;
6147
6952
  // selectPipelineLayoutForVariant returns null in that case (charter P3
6148
6953
  // explicit failure -- no silent fallback to URP layout, mirroring memory
6149
6954
  // anchor `hdrp-active-must-not-fallback-to-urp-pipeline`).
6150
6955
  let pbrSkinPipelineLayoutHandle: PipelineLayout | null = null;
6151
- // feat-20260611 R2 / M8 / w28: capture the 2-binding skin mesh-array BGL
6956
+ // feat-20260611 R2 / M8 / w28: capture the 3-binding skin mesh-array BGL
6152
6957
  // produced by `buildPbrSkinLayouts` so the record stage can build a BG
6153
- // matching `pbr-skin-pl` (pipeline-layout BGL[2] is this 2-entry skin
6958
+ // matching `pbr-skin-pl` (pipeline-layout BGL[2] is this 3-entry skin
6154
6959
  // BGL, NOT the 1-entry `pbr-mesh-array-bgl`). The BGL handle stays null
6155
6960
  // when the skin pipeline layout itself failed to build, keeping the skin
6156
6961
  // path explicitly disabled (charter P3 — record-stage falls back to URP
6157
6962
  // path which is correct for non-skin entries; skin entries hit the
6158
6963
  // explicit-failure branch).
6159
6964
  let pbrSkinMeshBindGroupLayoutHandle: BindGroupLayout | null = null;
6965
+ let hdrpSkinPipelineLayoutHandle: PipelineLayout | null = null;
6966
+ let hdrpSkinMeshBindGroupLayoutHandle: BindGroupLayout | null = null;
6160
6967
  {
6161
6968
  const skinLayoutsResult = runShimSyncStep(
6162
6969
  () =>
@@ -6172,6 +6979,29 @@ async function buildReadyWebGPU(
6172
6979
  pbrSkinMeshBindGroupLayoutHandle = skinLayoutsResult.value.meshArrayBgl;
6173
6980
  }
6174
6981
  }
6982
+ // Keep HDRP skin construction on the same storage-capability gate as the
6983
+ // ordinary Standard cluster layout above. There is no uniform-backed
6984
+ // clustered skin route when storage buffers are unavailable.
6985
+ if (storageBufferCapable) {
6986
+ const skinBglResult = rhiDevice.createBindGroupLayout(
6987
+ createHdrpSkinBindGroupLayoutDescriptor(),
6988
+ );
6989
+ if (skinBglResult.ok) {
6990
+ const skinPlResult = rhiDevice.createPipelineLayout({
6991
+ label: 'hdrp-skin-pl',
6992
+ bindGroupLayouts: [
6993
+ viewBglResult.value,
6994
+ materialBglResult.value,
6995
+ skinBglResult.value,
6996
+ instancesBglResult.value,
6997
+ ],
6998
+ });
6999
+ if (skinPlResult.ok) {
7000
+ hdrpSkinPipelineLayoutHandle = skinPlResult.value;
7001
+ hdrpSkinMeshBindGroupLayoutHandle = skinBglResult.value;
7002
+ }
7003
+ }
7004
+ }
6175
7005
 
6176
7006
  // feat-20260612-skin-palette-per-frame-upload M1 / m1-2: skin palette
6177
7007
  // allocator. Replaces the prior 16320 B identity-seeded UBO stub (PR #353,
@@ -7048,17 +7878,20 @@ async function buildReadyWebGPU(
7048
7878
  fragmentEntryPoint?: string;
7049
7879
  layout?: unknown;
7050
7880
  label?: string;
7881
+ group2Contract?: PipelineGroup2Contract;
7051
7882
  }
7052
7883
  >();
7053
7884
  if (unlitModule !== null)
7054
7885
  shaderModuleMap.set('forgeax::default-unlit', {
7055
7886
  vertex: unlitModule,
7056
7887
  fragment: unlitModule,
7888
+ ...(unlitGroup2Contract === undefined ? {} : { group2Contract: unlitGroup2Contract }),
7057
7889
  });
7058
7890
  if (pbrModule !== null)
7059
7891
  shaderModuleMap.set('forgeax::default-standard-pbr', {
7060
7892
  vertex: pbrModule,
7061
7893
  fragment: pbrModule,
7894
+ ...(pbrGroup2Contract === undefined ? {} : { group2Contract: pbrGroup2Contract }),
7062
7895
  });
7063
7896
  if (spriteModule !== null)
7064
7897
  // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w14 (D-7):
@@ -7070,6 +7903,7 @@ async function buildReadyWebGPU(
7070
7903
  shaderModuleMap.set('forgeax::sprite', {
7071
7904
  vertex: spriteModule,
7072
7905
  fragment: spriteModule,
7906
+ ...(spriteGroup2Contract === undefined ? {} : { group2Contract: spriteGroup2Contract }),
7073
7907
  });
7074
7908
  // feat-20260624-sprite-lit-shading-model-pure-2d-lighting M1' / t7:
7075
7909
  // sprite-lit shader registers under canonical `forgeax::sprite-lit` —
@@ -7078,6 +7912,7 @@ async function buildReadyWebGPU(
7078
7912
  shaderModuleMap.set('forgeax::sprite-lit', {
7079
7913
  vertex: spriteLitModule,
7080
7914
  fragment: spriteLitModule,
7915
+ ...(spriteLitGroup2Contract === undefined ? {} : { group2Contract: spriteLitGroup2Contract }),
7081
7916
  });
7082
7917
  // M2-T4: fullscreen-post shader modules (tonemap + skybox) are registered
7083
7918
  // in their respective blocks after pipeline layout creation — layout is
@@ -7179,7 +8014,6 @@ async function buildReadyWebGPU(
7179
8014
  ...runtimeSpecConstTable,
7180
8015
  ...runtimeLinearLdrMaterialSpecTable,
7181
8016
  ];
7182
-
7183
8017
  // Boot-time pre-warm: build SPEC_CONST entries whose shader modules are
7184
8018
  // compiled. Entries referencing a missing module are silently skipped
7185
8019
  // (the empty-manifest path — D-3). Each build failure for an available
@@ -7212,7 +8046,7 @@ async function buildReadyWebGPU(
7212
8046
  // `makeWebGPURenderer` scope) from the prewarmed `pipelineCache` for
7213
8047
  // SPEC_CONST entries whose `variantSet !== undefined`. The URP record
7214
8048
  // path queries `getMaterialShaderPipeline(...)` keyed off
7215
- // `cacheKeyOf(spec)` with `variantSet=URP_PBR_VARIANT_SET`; both caches
8049
+ // `cacheKeyOf(spec)` with the non-clustered boot variant; both caches
7216
8050
  // generate keys via the same `cacheKeyOf` so the lookup hits the
7217
8051
  // boot-time prewarmed PSO instead of triggering a 1-frame async-compile
7218
8052
  // skip-draw. Seeding only variantSet-bearing entries keeps the
@@ -7222,11 +8056,12 @@ async function buildReadyWebGPU(
7222
8056
  // explicit instead of collapsing into one map (charter P3).
7223
8057
  for (const spec of runtimeSpecConstTable) {
7224
8058
  if (spec.shader.variantSet === undefined) continue;
7225
- if (shaderModuleMap.get(spec.shader.id) === undefined) continue;
8059
+ const modules = shaderModuleMap.get(spec.shader.id);
8060
+ if (modules === undefined) continue;
7226
8061
  const key = cacheKeyOf(spec);
7227
8062
  const built = pipelineCache.get(key);
7228
- if (built !== undefined) {
7229
- seedMaterialShaderPipelineCache(key, built as RenderPipeline);
8063
+ if (built !== undefined && modules.group2Contract !== undefined) {
8064
+ seedMaterialShaderPipelineCache(key, built as RenderPipeline, modules.group2Contract);
7230
8065
  }
7231
8066
  }
7232
8067
 
@@ -7234,11 +8069,12 @@ async function buildReadyWebGPU(
7234
8069
  // swap-chain view target. Seed every material entry, including no-variant
7235
8070
  // unlit, because tonemap=none reaches this cache directly on frame one.
7236
8071
  for (const spec of runtimeLinearLdrMaterialSpecTable) {
7237
- if (shaderModuleMap.get(spec.shader.id) === undefined) continue;
8072
+ const modules = shaderModuleMap.get(spec.shader.id);
8073
+ if (modules === undefined) continue;
7238
8074
  const key = cacheKeyOf(spec);
7239
8075
  const built = pipelineCache.get(key);
7240
- if (built !== undefined) {
7241
- seedMaterialShaderPipelineCache(key, built as RenderPipeline);
8076
+ if (built !== undefined && modules.group2Contract !== undefined) {
8077
+ seedMaterialShaderPipelineCache(key, built as RenderPipeline, modules.group2Contract);
7242
8078
  }
7243
8079
  }
7244
8080
  }
@@ -7446,15 +8282,15 @@ async function buildReadyWebGPU(
7446
8282
 
7447
8283
  // feat-20260528-fxaa-post-processing M2 / w10: FXAA pipeline prebuilt.
7448
8284
  // When the manifest contains the fxaa entry (rgb2luma marker, D-5),
7449
- // construct the 2-entry BGL (texture + sampler, no UBO per D-2),
8285
+ // construct the 3-entry BGL (texture + sampler + output-policy UBO),
7450
8286
  // pipeline layout, fullscreen render pipeline (vertex = fullscreen
7451
8287
  // triangle from fxaa.wgsl, fragment = FXAA 3.11 algorithm), and
7452
8288
  // linear clamp-to-edge sampler. Mirrors the tonemap pipeline
7453
8289
  // construction pattern directly above.
7454
8290
  if (fxaaModule !== null) {
7455
- // FXAA BindGroupLayout: 2 entries (texture + sampler, no UBO).
7456
- // D-2: the fxaa.wgsl fragment stage declares @binding(0) texture_2d<f32>
7457
- // + @binding(1) sampler.
8291
+ // FXAA BindGroupLayout: 3 entries (texture + sampler + output-policy UBO).
8292
+ // The fxaa.wgsl fragment stage declares @binding(0) texture_2d<f32>,
8293
+ // @binding(1) sampler, and @binding(2) FxaaParams.
7458
8294
  const fxaaBglResult = runShimSyncStep(
7459
8295
  () =>
7460
8296
  rhiDevice.createBindGroupLayout({
@@ -7470,6 +8306,11 @@ async function buildReadyWebGPU(
7470
8306
  visibility: GPU_SHADER_STAGE_FRAGMENT,
7471
8307
  sampler: { type: 'filtering' },
7472
8308
  },
8309
+ {
8310
+ binding: 2,
8311
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
8312
+ buffer: { type: 'uniform' },
8313
+ },
7473
8314
  ],
7474
8315
  }),
7475
8316
  'webgpu-runtime-error',
@@ -8505,6 +9346,7 @@ async function buildReadyWebGPU(
8505
9346
  }
8506
9347
 
8507
9348
  const pipelineState: PipelineState = {
9349
+ device: rhiDevice,
8508
9350
  // feat-20260518-pbr-direct-lighting-mvp M5 / w22.10 (AC-06 dual->triple
8509
9351
  // pipeline + D-2 + D-10): three distinct GPU render-pipeline handles
8510
9352
  // backed by 2 distinct shader modules (unlit + pbr) x 2 vertex stride
@@ -8559,7 +9401,7 @@ async function buildReadyWebGPU(
8559
9401
  hdrpClusterMembershipPipeline,
8560
9402
  hdrpClusterMembershipBindGroupLayout,
8561
9403
  // bug-20260611-skin-pipeline-layout: skin-variant PipelineLayout (4-BGL
8562
- // chain with 2-entry mesh-array BGL substituted for the standard 1-entry
9404
+ // chain with 3-entry mesh-array BGL substituted for the standard 1-entry
8563
9405
  // mesh-array BGL). Built at boot above (see pbrSkinPipelineLayoutHandle);
8564
9406
  // gated on the same unlit/pbr module presence so Camera-only and empty-
8565
9407
  // manifest paths see null. Selected per call by
@@ -8571,13 +9413,17 @@ async function buildReadyWebGPU(
8571
9413
  // Mirror the gating used by `pbrSkinPipelineLayout` -- both fields
8572
9414
  // come from the same `buildPbrSkinLayouts` call so they live or die
8573
9415
  // together. Surfaced on PipelineState so render-system-record can
8574
- // build a 2-binding BG matching `pbr-skin-pl` (was: 1-binding
9416
+ // build a 3-binding BG matching `pbr-skin-pl` (was: 1-binding
8575
9417
  // `pbr-mesh-bg` + `pbr-mesh-array-bgl`, which the device rejected
8576
9418
  // every frame with `Bind group layout pbr-skin-mesh-array-bgl ... does
8577
9419
  // not match layout pbr-mesh-array-bgl` + Invalid CommandBuffer +
8578
9420
  // queue-submit-failed).
8579
9421
  pbrSkinMeshBindGroupLayout:
8580
9422
  unlitModule !== null && pbrModule !== null ? pbrSkinMeshBindGroupLayoutHandle : null,
9423
+ hdrpSkinPipelineLayout:
9424
+ unlitModule !== null && pbrModule !== null ? hdrpSkinPipelineLayoutHandle : null,
9425
+ hdrpSkinMeshBindGroupLayout:
9426
+ unlitModule !== null && pbrModule !== null ? hdrpSkinMeshBindGroupLayoutHandle : null,
8581
9427
  // feat-20260612-skin-palette-per-frame-upload M1 / m1-2: animator-ready
8582
9428
  // skin-palette allocator (replaces the prior identity-buffer stub).
8583
9429
  // Same gating as `pbrSkinPipelineLayout` -- `null` when the skin
@@ -8589,6 +9435,7 @@ async function buildReadyWebGPU(
8589
9435
  colorAttachmentFormat: swapChainFormats.view,
8590
9436
  surfaceProfile: surfaceViewFormats ? 'dual-view' : 'raw-only',
8591
9437
  viewBindGroupLayout: viewBglResult.value,
9438
+ projectorAvailable,
8592
9439
  materialBindGroupLayout: materialBglResult.value,
8593
9440
  meshBindGroupLayout: meshArrayBglResult.value,
8594
9441
  viewUniformBuffer: viewUboResult.value,