@forgeax/engine-render 0.1.20 → 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 (372) 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 +21 -18
  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-4XINDLAY.mjs → chunk-5YADUCA7.mjs} +1552 -1361
  21. package/dist/chunk-5YADUCA7.mjs.map +1 -0
  22. package/dist/{chunk-TBQJOK2L.mjs → chunk-6OPXDQ2E.mjs} +364 -51
  23. package/dist/chunk-6OPXDQ2E.mjs.map +1 -0
  24. package/dist/{chunk-M2MP4ZOU.mjs → chunk-BXBQQKFQ.mjs} +3 -9
  25. package/dist/chunk-BXBQQKFQ.mjs.map +1 -0
  26. package/dist/{chunk-CDTWTHZR.mjs → chunk-FS2KNUQH.mjs} +548 -6
  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/construct-renderer.mjs +5826 -1003
  42. package/dist/construct-renderer.mjs.map +1 -1
  43. package/dist/environment/frame.d.ts.map +1 -1
  44. package/dist/environment/lifecycle.d.ts +5 -0
  45. package/dist/environment/lifecycle.d.ts.map +1 -1
  46. package/dist/errors/render.d.ts +141 -93
  47. package/dist/errors/render.d.ts.map +1 -1
  48. package/dist/extract/directional-shadow-projection.d.ts +23 -0
  49. package/dist/extract/directional-shadow-projection.d.ts.map +1 -0
  50. package/dist/extract/environment.d.ts +14 -0
  51. package/dist/extract/environment.d.ts.map +1 -1
  52. package/dist/extract/visibility.d.ts.map +1 -1
  53. package/dist/features/fullscreen.d.ts.map +1 -1
  54. package/dist/features/targets.d.ts.map +1 -1
  55. package/dist/features/types.d.ts +10 -0
  56. package/dist/features/types.d.ts.map +1 -1
  57. package/dist/fullscreen-post-process-pass.d.ts +13 -2
  58. package/dist/fullscreen-post-process-pass.d.ts.map +1 -1
  59. package/dist/gpu-driven/batch-topology.d.ts.map +1 -1
  60. package/dist/gpu-driven/production-raster.d.ts +0 -1
  61. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  62. package/dist/gpu-scene.d.ts.map +1 -1
  63. package/dist/hdrp-buffers.d.ts +19 -17
  64. package/dist/hdrp-buffers.d.ts.map +1 -1
  65. package/dist/ibl/IblPipelineCache.d.ts +4 -0
  66. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  67. package/dist/ibl/kernel-cache.d.ts +36 -0
  68. package/dist/ibl/kernel-cache.d.ts.map +1 -0
  69. package/dist/index.d.ts +28 -3
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.mjs +4 -4
  72. package/dist/index.mjs.map +1 -1
  73. package/dist/inspection-types.d.ts +51 -0
  74. package/dist/inspection-types.d.ts.map +1 -1
  75. package/dist/instances-derived-bounds.d.ts +50 -0
  76. package/dist/instances-derived-bounds.d.ts.map +1 -0
  77. package/dist/internal.mjs +1 -1
  78. package/dist/light-buffer-layout.d.ts +10 -1
  79. package/dist/light-buffer-layout.d.ts.map +1 -1
  80. package/dist/mesh-material-bindings.d.ts +54 -0
  81. package/dist/mesh-material-bindings.d.ts.map +1 -1
  82. package/dist/pbr-pipeline.d.ts +14 -4
  83. package/dist/pbr-pipeline.d.ts.map +1 -1
  84. package/dist/pipeline/standard-forward-lane.d.ts +2 -1
  85. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  86. package/dist/pipeline/standard-lighting/errors.d.ts +4 -0
  87. package/dist/pipeline/standard-lighting/errors.d.ts.map +1 -0
  88. package/dist/pipeline/standard-lighting/graph.d.ts +24 -0
  89. package/dist/pipeline/standard-lighting/graph.d.ts.map +1 -0
  90. package/dist/pipeline/standard-lighting/inspection.d.ts +38 -0
  91. package/dist/pipeline/standard-lighting/inspection.d.ts.map +1 -0
  92. package/dist/pipeline/standard-lighting/layout.d.ts +28 -0
  93. package/dist/pipeline/standard-lighting/layout.d.ts.map +1 -0
  94. package/dist/pipeline/standard-lighting/light-frame.d.ts +33 -0
  95. package/dist/pipeline/standard-lighting/light-frame.d.ts.map +1 -0
  96. package/dist/pipeline/standard-lighting/membership.d.ts +14 -0
  97. package/dist/pipeline/standard-lighting/membership.d.ts.map +1 -0
  98. package/dist/pipeline/standard-lighting/prepare.d.ts +16 -0
  99. package/dist/pipeline/standard-lighting/prepare.d.ts.map +1 -0
  100. package/dist/pipeline/standard-lighting/topology.d.ts +45 -0
  101. package/dist/pipeline/standard-lighting/topology.d.ts.map +1 -0
  102. package/dist/pipeline/standard-lighting/transport.d.ts +36 -0
  103. package/dist/pipeline/standard-lighting/transport.d.ts.map +1 -0
  104. package/dist/pipeline/standard-pipeline.d.ts +4 -13
  105. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  106. package/dist/pipeline/standard-post.d.ts.map +1 -1
  107. package/dist/pipeline/standard-profile.d.ts +5 -26
  108. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  109. package/dist/pipeline-spec.d.ts +40 -7
  110. package/dist/pipeline-spec.d.ts.map +1 -1
  111. package/dist/point-shadow-inspection.d.ts +21 -0
  112. package/dist/point-shadow-inspection.d.ts.map +1 -0
  113. package/dist/record/frame-lighting.d.ts +27 -32
  114. package/dist/record/frame-lighting.d.ts.map +1 -1
  115. package/dist/record/frame-snapshot.d.ts +28 -14
  116. package/dist/record/frame-snapshot.d.ts.map +1 -1
  117. package/dist/record/frame-targets.d.ts +5 -0
  118. package/dist/record/frame-targets.d.ts.map +1 -1
  119. package/dist/record/frame.d.ts +10 -4
  120. package/dist/record/frame.d.ts.map +1 -1
  121. package/dist/record/gpu-pass-timing/contract.d.ts +96 -0
  122. package/dist/record/gpu-pass-timing/contract.d.ts.map +1 -0
  123. package/dist/record/gpu-pass-timing/errors.d.ts +18 -0
  124. package/dist/record/gpu-pass-timing/errors.d.ts.map +1 -0
  125. package/dist/record/gpu-pass-timing/index.d.ts +8 -0
  126. package/dist/record/gpu-pass-timing/index.d.ts.map +1 -0
  127. package/dist/record/gpu-pass-timing/parser.d.ts +11 -0
  128. package/dist/record/gpu-pass-timing/parser.d.ts.map +1 -0
  129. package/dist/record/gpu-pass-timing/session.d.ts +43 -0
  130. package/dist/record/gpu-pass-timing/session.d.ts.map +1 -0
  131. package/dist/record/gpu-timing.d.ts +8 -3
  132. package/dist/record/gpu-timing.d.ts.map +1 -1
  133. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  134. package/dist/record/main-pass-material.d.ts +22 -1
  135. package/dist/record/main-pass-material.d.ts.map +1 -1
  136. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  137. package/dist/record/main-pass.d.ts +2 -0
  138. package/dist/record/main-pass.d.ts.map +1 -1
  139. package/dist/record/render-context.d.ts +43 -2
  140. package/dist/record/render-context.d.ts.map +1 -1
  141. package/dist/record/shadow-pass.d.ts.map +1 -1
  142. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  143. package/dist/record/typed-frame-graph.d.ts +72 -4
  144. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  145. package/dist/record/view-ubo.d.ts +7 -4
  146. package/dist/record/view-ubo.d.ts.map +1 -1
  147. package/dist/reflection/filter.d.ts +22 -0
  148. package/dist/reflection/filter.d.ts.map +1 -0
  149. package/dist/reflection/gpu-table.d.ts +25 -0
  150. package/dist/reflection/gpu-table.d.ts.map +1 -0
  151. package/dist/reflection/projection.d.ts +91 -0
  152. package/dist/reflection/projection.d.ts.map +1 -0
  153. package/dist/render-contract.d.ts +49 -7
  154. package/dist/render-contract.d.ts.map +1 -1
  155. package/dist/render-graph-primitives.d.ts +1 -0
  156. package/dist/render-graph-primitives.d.ts.map +1 -1
  157. package/dist/render-pipeline.d.ts +42 -2
  158. package/dist/render-pipeline.d.ts.map +1 -1
  159. package/dist/render-system-extract.d.ts +58 -17
  160. package/dist/render-system-extract.d.ts.map +1 -1
  161. package/dist/render-system.d.ts +17 -2
  162. package/dist/render-system.d.ts.map +1 -1
  163. package/dist/renderer.d.ts +43 -0
  164. package/dist/renderer.d.ts.map +1 -0
  165. package/dist/scene/render-scene.d.ts +14 -0
  166. package/dist/scene/render-scene.d.ts.map +1 -1
  167. package/dist/shadow-atlas.d.ts +3 -3
  168. package/dist/shadow-atlas.d.ts.map +1 -1
  169. package/dist/ssao-buffers.d.ts +3 -0
  170. package/dist/ssao-buffers.d.ts.map +1 -1
  171. package/dist/targets/contracts.d.ts +66 -0
  172. package/dist/targets/contracts.d.ts.map +1 -0
  173. package/dist/targets/graph-projection.d.ts +29 -0
  174. package/dist/targets/graph-projection.d.ts.map +1 -0
  175. package/dist/targets/material-source.d.ts +34 -0
  176. package/dist/targets/material-source.d.ts.map +1 -0
  177. package/dist/targets/owner.d.ts +32 -0
  178. package/dist/targets/owner.d.ts.map +1 -0
  179. package/dist/targets/physical.d.ts +18 -0
  180. package/dist/targets/physical.d.ts.map +1 -0
  181. package/dist/targets/readback.d.ts +39 -0
  182. package/dist/targets/readback.d.ts.map +1 -0
  183. package/dist/temporal/index.mjs +3 -3
  184. package/dist/typed-render-graph-primitives.d.ts +1 -1
  185. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  186. package/dist/typed-shadow-passes.d.ts +1 -1
  187. package/dist/typed-shadow-passes.d.ts.map +1 -1
  188. package/dist/volume/passes.d.ts +2 -2
  189. package/dist/volume/passes.d.ts.map +1 -1
  190. package/package.json +20 -20
  191. package/src/__tests__/bloom-exact-zero.unit.test.ts +48 -19
  192. package/src/__tests__/bloom-lifecycle.integration.test.ts +41 -13
  193. package/src/__tests__/camera-target-consumption.unit.test.ts +53 -0
  194. package/src/__tests__/cluster-binner.unit.test.ts +35 -0
  195. package/src/__tests__/cluster-grid-default-surface.unit.test.ts +7 -2
  196. package/src/__tests__/cube-camera-scheduler.unit.test.ts +70 -0
  197. package/src/__tests__/cube-camera-single-submit.perf.test.ts +75 -0
  198. package/src/__tests__/cube-camera-view-matrices.unit.test.ts +41 -0
  199. package/src/__tests__/cube-camera-vocabulary.unit.test.ts +47 -0
  200. package/src/__tests__/cube-camera.integration.test.ts +42 -0
  201. package/src/__tests__/device-feature-admission.unit.test.ts +73 -0
  202. package/src/__tests__/direct-light-snapshot.unit.test.ts +3 -3
  203. package/src/__tests__/docs-gate-green.unit.test.ts +38 -0
  204. package/src/__tests__/factory-contract.integration.test.ts +255 -29
  205. package/src/__tests__/fog-component.unit.test.ts +67 -0
  206. package/src/__tests__/fog.integration.test.ts +86 -0
  207. package/src/__tests__/fullscreen-feature-plan.unit.test.ts +37 -0
  208. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +10 -9
  209. package/src/__tests__/gpu-driven-indirect-raster.dawn.test.ts +17 -1
  210. package/src/__tests__/gpu-driven-production.integration.test.ts +16 -17
  211. package/src/__tests__/gpu-driven-view-graph.integration.test.ts +12 -4
  212. package/src/__tests__/gpu-pass-timing-browser-runner.ts +281 -0
  213. package/src/__tests__/gpu-pass-timing-coexistence.integration.test.ts +80 -0
  214. package/src/__tests__/gpu-pass-timing-contract.unit.test.ts +243 -0
  215. package/src/__tests__/gpu-pass-timing-exact-zero.unit.test.ts +131 -0
  216. package/src/__tests__/gpu-pass-timing-lifecycle.integration.test.ts +230 -0
  217. package/src/__tests__/gpu-pass-timing-observation.unit.test.ts +233 -0
  218. package/src/__tests__/gpu-pass-timing-session.unit.test.ts +438 -0
  219. package/src/__tests__/gpu-pass-timing.browser.test.ts +26 -0
  220. package/src/__tests__/gpu-pass-timing.rhi-null.unit.test.ts +125 -0
  221. package/src/__tests__/hdrp-group2-visibility.unit.test.ts +24 -0
  222. package/src/__tests__/instances-culling.integration.test.ts +94 -0
  223. package/src/__tests__/instances-derived-bounds.unit.test.ts +118 -0
  224. package/src/__tests__/material-observation.integration.test.ts +101 -0
  225. package/src/__tests__/pbr-view-layout.unit.test.ts +16 -0
  226. package/src/__tests__/point-shadow-inspection.unit.test.ts +60 -0
  227. package/src/__tests__/public-surface.test-d.ts +10 -0
  228. package/src/__tests__/reflection-probe-authoring.unit.test.ts +71 -0
  229. package/src/__tests__/reflection-probe-filter.unit.test.ts +54 -0
  230. package/src/__tests__/reflection-probe-ibl-topology.unit.test.ts +45 -0
  231. package/src/__tests__/reflection-probe-pass-encode.integration.test.ts +101 -0
  232. package/src/__tests__/reflection-probe-performance.perf.test.ts +60 -0
  233. package/src/__tests__/reflection-probe-recovery.integration.test.ts +55 -0
  234. package/src/__tests__/reflection-probe-selection.unit.test.ts +46 -0
  235. package/src/__tests__/render-error-code-owner.test-d.ts +18 -3
  236. package/src/__tests__/render-feature-docs.unit.test.ts +1 -1
  237. package/src/__tests__/render-target-admission.unit.test.ts +80 -0
  238. package/src/__tests__/render-target-graph-projection.unit.test.ts +102 -0
  239. package/src/__tests__/render-target-inspection.unit.test.ts +52 -0
  240. package/src/__tests__/render-target-material-source.test-d.ts +30 -0
  241. package/src/__tests__/render-target-material-source.unit.test.ts +97 -0
  242. package/src/__tests__/render-target-owner-placement.unit.test.ts +203 -0
  243. package/src/__tests__/render-target-owner.unit.test.ts +122 -0
  244. package/src/__tests__/render-target-probe-soak.perf.test.ts +94 -0
  245. package/src/__tests__/render-target-public-schema.json +107 -0
  246. package/src/__tests__/render-target-public-schema.unit.test.ts +91 -0
  247. package/src/__tests__/render-target-public-surface.test-d.ts +83 -0
  248. package/src/__tests__/render-target-readback.integration.test.ts +90 -0
  249. package/src/__tests__/render-target-recovery.integration.test.ts +85 -0
  250. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +48 -0
  251. package/src/__tests__/renderer-lifecycle.integration.test.ts +14 -0
  252. package/src/__tests__/shader-stage-surface.unit.test.ts +19 -4
  253. package/src/__tests__/skin-motion-regression.unit.test.ts +132 -0
  254. package/src/__tests__/skinned-shadow-caster.test.ts +11 -2
  255. package/src/__tests__/standard-cluster-capability.integration.test.ts +63 -0
  256. package/src/__tests__/standard-cluster-transport.unit.test.ts +121 -0
  257. package/src/__tests__/standard-cluster-variant-resolution.unit.test.ts +232 -0
  258. package/src/__tests__/standard-clustered-pipeline.unit.test.ts +8 -5
  259. package/src/__tests__/standard-lighting-inspection.integration.test.ts +138 -0
  260. package/src/__tests__/standard-lighting-ssot.unit.test.ts +18 -0
  261. package/src/__tests__/standard-lighting-write.unit.test.ts +377 -0
  262. package/src/__tests__/standard-lighting.integration.test.ts +67 -0
  263. package/src/__tests__/standard-pipeline.dawn.test.ts +206 -5
  264. package/src/__tests__/standard-pipeline.integration.test.ts +181 -150
  265. package/src/__tests__/standard-probe-binding.unit.test.ts +25 -0
  266. package/src/__tests__/standard-profile-oracle.unit.test.ts +23 -38
  267. package/src/__tests__/typed-pipeline-topology.unit.test.ts +395 -14
  268. package/src/__tests__/urp-pipeline-targets.unit.test.ts +2 -2
  269. package/src/__tests__/visibility-resolver.unit.test.ts +15 -0
  270. package/src/assembly/device-feature-admission.ts +73 -0
  271. package/src/assembly/directional-shadow-inspection.ts +122 -0
  272. package/src/assembly/factory.ts +877 -286
  273. package/src/assembly/host-contract.ts +25 -0
  274. package/src/assembly/render-target-host.ts +847 -0
  275. package/src/assets/asset-decoders.ts +1 -1
  276. package/src/assets/render-asset-port.ts +7 -1
  277. package/src/capture/cube-views.ts +69 -0
  278. package/src/capture/scheduler.ts +265 -0
  279. package/src/components/__tests__/schema-contract.unit.test.ts +12 -0
  280. package/src/components/__tests__/schema-parity.unit.test.ts +9 -0
  281. package/src/components/camera.ts +4 -0
  282. package/src/components/cube-camera.ts +52 -0
  283. package/src/components/directional-light.ts +22 -2
  284. package/src/components/directional-shadow-filter.ts +57 -0
  285. package/src/components/index.ts +2 -0
  286. package/src/components/light-helpers.ts +81 -7
  287. package/src/components/reflection-probe.ts +49 -0
  288. package/src/environment/frame.ts +6 -24
  289. package/src/environment/lifecycle.ts +12 -0
  290. package/src/errors/render.ts +286 -142
  291. package/src/extract/directional-shadow-projection.ts +61 -0
  292. package/src/extract/environment.ts +57 -0
  293. package/src/extract/visibility.ts +25 -1
  294. package/src/features/fullscreen.ts +3 -0
  295. package/src/features/targets.ts +4 -1
  296. package/src/features/types.ts +10 -0
  297. package/src/fullscreen-post-process-pass.ts +43 -2
  298. package/src/gpu-driven/batch-topology.ts +1 -0
  299. package/src/gpu-driven/production-raster.ts +1 -3
  300. package/src/gpu-scene.ts +8 -1
  301. package/src/hdrp-buffers.ts +165 -59
  302. package/src/ibl/IblPipelineCache.ts +11 -0
  303. package/src/ibl/kernel-cache.ts +88 -0
  304. package/src/index.ts +148 -0
  305. package/src/inspection-types.ts +56 -0
  306. package/src/instances-derived-bounds.ts +171 -0
  307. package/src/light-buffer-layout.ts +20 -5
  308. package/src/mesh-material-bindings.ts +132 -0
  309. package/src/pbr-pipeline.ts +106 -17
  310. package/src/pipeline/standard-forward-lane.ts +46 -16
  311. package/src/pipeline/standard-lighting/errors.ts +6 -0
  312. package/src/pipeline/standard-lighting/graph.ts +157 -0
  313. package/src/pipeline/standard-lighting/inspection.ts +88 -0
  314. package/src/pipeline/standard-lighting/layout.ts +31 -0
  315. package/src/pipeline/standard-lighting/light-frame.ts +34 -0
  316. package/src/pipeline/standard-lighting/membership.ts +43 -0
  317. package/src/pipeline/standard-lighting/prepare.ts +37 -0
  318. package/src/pipeline/standard-lighting/topology.ts +120 -0
  319. package/src/pipeline/standard-lighting/transport.ts +77 -0
  320. package/src/pipeline/standard-pipeline.ts +122 -193
  321. package/src/pipeline/standard-post.ts +26 -7
  322. package/src/pipeline/standard-profile.ts +17 -47
  323. package/src/pipeline-spec.ts +84 -21
  324. package/src/point-shadow-inspection.ts +55 -0
  325. package/src/record/__tests__/temporal-payload.unit.test.ts +42 -1
  326. package/src/record/__tests__/transparent-group2-selection.unit.test.ts +73 -0
  327. package/src/record/__tests__/view-ubo-layout.unit.test.ts +40 -0
  328. package/src/record/frame-lighting.ts +367 -427
  329. package/src/record/frame-snapshot.ts +35 -27
  330. package/src/record/frame-targets.ts +17 -0
  331. package/src/record/frame.ts +261 -74
  332. package/src/record/gpu-pass-timing/contract.ts +180 -0
  333. package/src/record/gpu-pass-timing/errors.ts +34 -0
  334. package/src/record/gpu-pass-timing/index.ts +37 -0
  335. package/src/record/gpu-pass-timing/parser.ts +97 -0
  336. package/src/record/gpu-pass-timing/session.ts +938 -0
  337. package/src/record/gpu-timing.ts +21 -12
  338. package/src/record/main-pass-geometry.ts +120 -80
  339. package/src/record/main-pass-material.ts +95 -9
  340. package/src/record/main-pass-sprite-draws.ts +54 -25
  341. package/src/record/main-pass.ts +71 -15
  342. package/src/record/render-context.ts +72 -0
  343. package/src/record/shadow-pass.ts +17 -28
  344. package/src/record/skybox-post-pass.ts +20 -4
  345. package/src/record/typed-frame-graph.ts +893 -44
  346. package/src/record/view-ubo.ts +63 -28
  347. package/src/reflection/filter.ts +81 -0
  348. package/src/reflection/gpu-table.ts +44 -0
  349. package/src/reflection/projection.ts +281 -0
  350. package/src/render-contract.ts +76 -5
  351. package/src/render-graph-primitives.ts +9 -4
  352. package/src/render-pipeline.ts +117 -2
  353. package/src/render-system-extract.ts +557 -100
  354. package/src/render-system.ts +1095 -66
  355. package/src/renderer.ts +222 -0
  356. package/src/scene/render-scene.ts +115 -9
  357. package/src/shadow-atlas.ts +5 -5
  358. package/src/ssao-buffers.ts +9 -2
  359. package/src/targets/contracts.ts +182 -0
  360. package/src/targets/graph-projection.ts +169 -0
  361. package/src/targets/material-source.ts +143 -0
  362. package/src/targets/owner.ts +214 -0
  363. package/src/targets/physical.ts +18 -0
  364. package/src/targets/readback.ts +153 -0
  365. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +59 -20
  366. package/src/typed-render-graph-primitives.ts +5 -3
  367. package/src/typed-shadow-passes.ts +46 -40
  368. package/src/volume/passes.ts +15 -9
  369. package/dist/chunk-4XINDLAY.mjs.map +0 -1
  370. package/dist/chunk-CDTWTHZR.mjs.map +0 -1
  371. package/dist/chunk-M2MP4ZOU.mjs.map +0 -1
  372. package/dist/chunk-TBQJOK2L.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,
@@ -92,12 +91,16 @@ import {
92
91
  RendererOperationError,
93
92
  RenderFeatureCapabilityMissingError,
94
93
  RenderFeatureStageFailedError,
94
+ StandardProfileInvalidError,
95
95
  } from '../errors/render';
96
96
  import { createRenderFeatureHost, type RenderFeatureHost } from '../features/host';
97
97
  import type { RenderFeatureMaterialShaderBindingContract } from '../features/plan';
98
98
  import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../features/prepared-graphics';
99
99
  import type { RenderFeature, RenderFeatureShaderModuleMode } from '../features/types';
100
- import type { PostProcessShaderEntry } from '../fullscreen-post-process-pass';
100
+ import {
101
+ type PostProcessShaderEntry,
102
+ postProcessShaderModuleLabel,
103
+ } from '../fullscreen-post-process-pass';
101
104
  import { glyphTextLayoutSystem } from '../glyph-text-layout-system';
102
105
  import { GpuBuffer } from '../gpu-resource';
103
106
  import { GPU_SHADER_STAGE_FRAGMENT, GPU_SHADER_STAGE_VERTEX } from '../gpu-stage';
@@ -138,7 +141,9 @@ import {
138
141
  buildPbrMaterialUserRegionEntries,
139
142
  buildPbrPipelineLayouts,
140
143
  buildPbrSkinLayouts,
141
- isSkinnedShadowCasterVariant,
144
+ createHdrpSkinBindGroupLayoutDescriptor,
145
+ type PipelineGroup2Contract,
146
+ resolvePipelineGroup2Contract,
142
147
  SKIN_MATERIAL_SHADER_ID,
143
148
  } from '../pbr-pipeline';
144
149
  import { STANDARD_CLUSTER_MEMBERSHIP_WGSL, standardPipeline } from '../pipeline/standard-pipeline';
@@ -165,6 +170,14 @@ import {
165
170
  } from '../pipeline-spec';
166
171
  import { POINTS_LINES_MATERIAL_SHADER_ID } from '../points-lines/record';
167
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';
168
181
  import { GpuTimingCapture, type VolumeTimingObservation } from '../record/gpu-timing';
169
182
  import type { BloomPersistentBundle } from '../record/render-context';
170
183
  import { SHADOW_CASTER_BUFFER_SIZE } from '../record/shadow-pass';
@@ -190,8 +203,10 @@ import type {
190
203
  RenderWorldLease,
191
204
  VolumetricFogShaderSources,
192
205
  } from '../render-contract';
193
- import { STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from '../render-contract';
206
+ import { FXAA_POST_PROCESS_ID, STANDARD_OUTPUT_TRANSFORM_FEATURE_ID } from '../render-contract';
207
+
194
208
  import {
209
+ attachGpuPassTimingSession,
195
210
  configureSurface,
196
211
  createRenderSystem,
197
212
  MATERIAL_PER_ENTITY_STRIDE,
@@ -199,6 +214,7 @@ import {
199
214
  type RenderSystem,
200
215
  selectSwapChainFormat,
201
216
  } from '../render-system';
217
+ import { createGpuPassTimingObservationStore, observeGpuPassTimingDisabled } from '../renderer.js';
202
218
  import { postSpawnResolveJoints } from '../scene-instances/post-spawn-resolve-joints';
203
219
  import {
204
220
  createSkinPaletteAllocator,
@@ -206,7 +222,12 @@ import {
206
222
  } from '../systems/skin-palette-allocator';
207
223
  import { tilemapChunkExtractSystem } from '../tilemap-chunk-extract-system';
208
224
  import { EngineEnvironmentError, type RhiBackendPack } from './backend-contract';
225
+ import {
226
+ deviceOptionsForAdapter,
227
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
228
+ } from './device-feature-admission';
209
229
  import type { RendererAssemblyImplementation, RendererHostImplementation } from './host-contract';
230
+ import { createRenderTargetHost } from './render-target-host';
210
231
  import { projectRendererOutputInspection } from './renderer-inspection';
211
232
  import { isRendererStateOperational } from './renderer-lifecycle';
212
233
 
@@ -226,6 +247,19 @@ const GPU_ENVIRONMENT_ERROR_CODES = new Set([
226
247
  'oom',
227
248
  ]);
228
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
+
229
263
  function classifyEnvErrorReason(
230
264
  base: string,
231
265
  primary: { code?: unknown; name?: unknown } | undefined,
@@ -450,18 +484,14 @@ export interface BundlerOptions {
450
484
  /**
451
485
  * Per-variant PipelineLayout selector (M4.5 / D-10 option A).
452
486
  *
453
- * The HDRP variant (`CLUSTER_FORWARD_AVAILABLE=true` or canonical `''`
454
- * all-true when the shader declares the cluster axis) needs the 7-slot
455
- * group(2) BGL chain (`hdrpPbrPipelineLayout`); URP variants
456
- * (`CLUSTER_FORWARD_AVAILABLE=false` / undefined / no axis) keep the
457
- * 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.
458
491
  *
459
- * Returns `null` only when the URP fallback layout itself is null
460
- * (Camera-only / empty-manifest path). When the HDRP layout is null but
461
- * URP exists, the HDRP variant gracefully falls back to URP — the WGSL
462
- * resolved upstream by `findVariantByKey` is the URP variant in that
463
- * case (manifest entry with all-true definesKey === '' was registered
464
- * 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.
465
495
  *
466
496
  * Pure function — exported for unit-test access (M4.5 / w35).
467
497
  */
@@ -469,8 +499,9 @@ export interface BundlerOptions {
469
499
  * Closed enum of pipeline-layout kinds the selector can dispatch to.
470
500
  *
471
501
  * `pbr` — standard PBR (1-entry mesh-array BGL).
472
- * `pbr-skin` — bug-20260611-skin-pipeline-layout: 2-entry mesh-array BGL
473
- * (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`
474
505
  * material shader.
475
506
  * `hdrp-pbr` — feat-20260609 HDRP cluster-forward variant (7-slot group(2)
476
507
  * BGL substituted at slot 2).
@@ -490,7 +521,15 @@ export interface BundlerOptions {
490
521
  * material-shader layouts add a `LayoutKind` member + a PipelineState slot
491
522
  * + a switch arm here in one cut (charter P3 extensibility).
492
523
  */
493
- 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
+ }
494
533
 
495
534
  /**
496
535
  * Return every device-capability HDRP PBR variant that needs a lazy module
@@ -503,13 +542,21 @@ export function selectHdrpPbrPrewarmVariants(
503
542
  manifestEntry: MaterialShaderManifestEntry | undefined,
504
543
  storageBufferCapable: boolean,
505
544
  transmissionCapable: boolean,
545
+ directionalPcssAvailable = true,
506
546
  projectorAvailable: boolean = true,
507
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 [];
508
553
  return (
509
554
  manifestEntry?.variants.filter(
510
555
  (variant) =>
511
556
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
512
557
  variant.defines.CLUSTER_FORWARD_AVAILABLE === true &&
558
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
559
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
513
560
  (!('PROJECTOR_AVAILABLE' in variant.defines) ||
514
561
  variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
515
562
  (transmissionCapable || variant.defines.TRANSMISSION_AVAILABLE !== true),
@@ -517,6 +564,22 @@ export function selectHdrpPbrPrewarmVariants(
517
564
  );
518
565
  }
519
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
+
520
583
  /**
521
584
  * Select both Standard transmission states from the manifest for exact-label
522
585
  * prewarming. The requested keys come from declared variants; no key is
@@ -525,6 +588,7 @@ export function selectHdrpPbrPrewarmVariants(
525
588
  export function selectStandardPbrTransmissionPrewarmVariants(
526
589
  manifestEntry: MaterialShaderManifestEntry | undefined,
527
590
  storageBufferCapable: boolean,
591
+ directionalPcssAvailable = true,
528
592
  projectorAvailable: boolean = true,
529
593
  ): readonly MaterialShaderManifestEntry['variants'][number][] {
530
594
  if (manifestEntry === undefined) {
@@ -535,8 +599,10 @@ export function selectStandardPbrTransmissionPrewarmVariants(
535
599
  const declared = manifestEntry.variants.find(
536
600
  (variant) =>
537
601
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
538
- variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
602
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === standardBootClusterAxis() &&
539
603
  variant.defines.VERTEX_COLOR_AVAILABLE === false &&
604
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
605
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
540
606
  (!('PROJECTOR_AVAILABLE' in variant.defines) ||
541
607
  variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
542
608
  variant.defines.TRANSMISSION_AVAILABLE === transmissionAvailable,
@@ -566,16 +632,16 @@ export function selectNoColorPbrVariant(
566
632
  manifestEntry: MaterialShaderManifestEntry | undefined,
567
633
  storageBufferCapable: boolean,
568
634
  variantSet: string | undefined,
635
+ directionalPcssAvailable = true,
569
636
  projectorAvailable: boolean = true,
570
637
  ): MaterialShaderManifestEntry['variants'][number] | undefined {
571
- const hdrpRequested =
572
- variantSet === '' ||
573
- // biome-ignore lint/complexity/useOptionalChain: G-14 requires an explicit undefined guard so no variant request becomes false.
574
- (variantSet !== undefined && variantSet.includes('CLUSTER_FORWARD_AVAILABLE=true'));
638
+ const hdrpRequested = variantSet === '' || requestsClusterVariant(variantSet);
575
639
  return manifestEntry?.variants.find(
576
640
  (variant) =>
577
641
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
578
642
  variant.defines.VERTEX_COLOR_AVAILABLE === false &&
643
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
644
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
579
645
  (!('PROJECTOR_AVAILABLE' in variant.defines) ||
580
646
  variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
581
647
  (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
@@ -583,6 +649,11 @@ export function selectNoColorPbrVariant(
583
649
  );
584
650
  }
585
651
 
652
+ function requestsClusterVariant(variantSet: string | undefined): boolean {
653
+ if (variantSet === undefined) return false;
654
+ return variantSet.includes('CLUSTER_FORWARD_AVAILABLE=true');
655
+ }
656
+
586
657
  export type MaterialShaderBindingContract = RenderFeatureMaterialShaderBindingContract;
587
658
  export type MaterialShaderVertexInputContract = 'none' | 'render-material';
588
659
 
@@ -709,8 +780,9 @@ export function selectPipelineLayoutForVariant(
709
780
  readonly pbrPipelineLayout: PipelineLayout | null;
710
781
  readonly hdrpPbrPipelineLayout: PipelineLayout | null;
711
782
  readonly pbrSkinPipelineLayout: PipelineLayout | null;
783
+ readonly hdrpSkinPipelineLayout?: PipelineLayout | null;
712
784
  } | null,
713
- variantSet: string | undefined,
785
+ _variantSet: string | undefined,
714
786
  layoutKind?: LayoutKind,
715
787
  ): PipelineLayout | null {
716
788
  if (state === null) return null;
@@ -723,6 +795,9 @@ export function selectPipelineLayoutForVariant(
723
795
  if (layoutKind === 'pbr-skin') {
724
796
  return state.pbrSkinPipelineLayout;
725
797
  }
798
+ if (layoutKind === 'hdrp-skin') {
799
+ return state.hdrpSkinPipelineLayout ?? null;
800
+ }
726
801
  // bug-20260708 M2 (c): sprite / sprite-lit shaders reuse the URP 1-slot
727
802
  // mesh-array pipeline layout but their canonical all-true variant key
728
803
  // `''` (SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET) must NOT trigger
@@ -738,33 +813,19 @@ export function selectPipelineLayoutForVariant(
738
813
  if (layoutKind === 'unlit-urp') {
739
814
  return state.pbrPipelineLayout;
740
815
  }
741
- // Static PBR callers can use the canonical all-true variant key (`''`)
742
- // without becoming HDRP. Keep the shader variant identity independent from
743
- // the pipeline-layout identity; shadow-caster static draws use this explicit
744
- // 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.
745
820
  if (layoutKind === 'pbr') {
746
821
  return state.pbrPipelineLayout;
747
822
  }
748
- // HDRP variant matches when:
749
- // - layoutKind === 'hdrp-pbr' (caller-driven, future-proof), OR
750
- // - variantSet is the canonical all-true key '' (boot-time HDRP
751
- // registration produces '' when both axes resolve to true), OR
752
- // - variantSet contains `CLUSTER_FORWARD_AVAILABLE=true` substring
753
- // (record-stage caller emits the expanded form).
754
- // URP variants have CLUSTER_FORWARD_AVAILABLE=false explicitly, or omit
755
- // the axis altogether, or pass undefined (no variant routing).
756
- // G-14 (D-11): empty-string is the canonical all-true HDRP key, so we
757
- // explicitly distinguish '' vs undefined rather than using an optional-chain
758
- // (charter P3: explicit failure / no implicit empty-vs-missing collapse).
759
- const isHdrpVariant =
760
- layoutKind === 'hdrp-pbr' ||
761
- variantSet === '' ||
762
- // biome-ignore lint/complexity/useOptionalChain: G-14 grep gate forbids the optional-chain falsy collapse on variantSet.
763
- (variantSet !== undefined && variantSet.includes('CLUSTER_FORWARD_AVAILABLE=true'));
764
- if (isHdrpVariant && state.hdrpPbrPipelineLayout !== null) {
765
- return state.hdrpPbrPipelineLayout;
766
- }
767
- 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;
768
829
  }
769
830
 
770
831
  /**
@@ -786,6 +847,7 @@ export function resolveMaterialShaderVariantSet(
786
847
  const declaresCapabilityVariants =
787
848
  declaredAxes.has('WEBGL2_COMPAT') ||
788
849
  declaredAxes.has('STORAGE_BUFFER_AVAILABLE') ||
850
+ declaredAxes.has('DIRECTIONAL_PCSS_AVAILABLE') ||
789
851
  declaredAxes.has('PROJECTOR_AVAILABLE');
790
852
  if (!declaresCapabilityVariants) return requestedVariantSet;
791
853
 
@@ -804,22 +866,24 @@ export function resolveMaterialShaderVariantSet(
804
866
  : axis === 'SKINNING_DISABLED'
805
867
  ? (requested[axis] ?? true)
806
868
  : axis === 'CLUSTER_FORWARD_AVAILABLE'
807
- ? backendKind !== 'wgpu-webgl2' && (requested[axis] ?? false)
869
+ ? storageBuffer && backendKind !== 'wgpu-webgl2' && (requested[axis] ?? false)
808
870
  : axis === 'PER_INSTANCE_REGION'
809
871
  ? requestedVariantSet === '' || (requested[axis] ?? false)
810
872
  : axis === 'VERTEX_COLOR_AVAILABLE'
811
873
  ? requestedVariantSet === '' || (requested[axis] ?? false)
812
874
  : axis === 'TRANSMISSION_AVAILABLE'
813
875
  ? (requested[axis] ?? false)
814
- : axis === 'PROJECTOR_AVAILABLE'
815
- ? projectorAvailable
816
- : (requested[axis] ?? true);
876
+ : axis === 'DIRECTIONAL_PCSS_AVAILABLE'
877
+ ? directionalPcssCapable(backendKind)
878
+ : axis === 'PROJECTOR_AVAILABLE'
879
+ ? projectorAvailable
880
+ : (requested[axis] ?? true);
817
881
  }
818
882
  const sorted = Object.entries(filtered).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
819
883
  return sorted.every(([, value]) => value)
820
- ? requestedVariantSet === ''
821
- ? ''
822
- : undefined
884
+ ? requestedVariantSet === undefined
885
+ ? undefined
886
+ : ''
823
887
  : sorted.map(([name, value]) => `${name}=${value}`).join('+');
824
888
  }
825
889
 
@@ -883,6 +947,28 @@ export async function createRenderer(
883
947
  });
884
948
  }
885
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
+
886
972
  // ─── WebGPU branch ──────────────────────────────────────────────────────────
887
973
 
888
974
  /**
@@ -993,50 +1079,6 @@ type WebGPUOutcome =
993
1079
  | { kind: 'rhi-err'; error: RhiError }
994
1080
  | { kind: 'throw'; error: Error };
995
1081
 
996
- // Optional compression is exposed on a device only when it is requested from
997
- // the adapter. Keep this capability projection shared by initial construction
998
- // and device-loss recovery; otherwise recovery silently drops BC/ETC2/ASTC
999
- // and already-catalogued compressed textures fail on the first new frame.
1000
- const COMPRESSION_FEATURES: GPUFeatureName[] = [
1001
- 'texture-compression-bc',
1002
- 'texture-compression-etc2',
1003
- 'texture-compression-astc',
1004
- ];
1005
-
1006
- // The Standard transmission fragment ABI needs 17 sampled textures across its
1007
- // material, IBL, clustered-light, and SSAO bindings. Ordinary Standard draws
1008
- // use the non-transmission variant and remain valid on the WebGPU minimum of
1009
- // 16; only request the elevated limit when the adapter can provide it.
1010
- const STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES = 17;
1011
-
1012
- function deviceOptionsForAdapter(
1013
- adapter: Pick<RhiAdapter, 'features' | 'limits'>,
1014
- options?: Pick<RendererOptions, 'captureGpuTimings'>,
1015
- ):
1016
- | {
1017
- requiredFeatures?: GPUFeatureName[];
1018
- requiredLimits?: GPUDeviceDescriptor['requiredLimits'];
1019
- }
1020
- | undefined {
1021
- const requiredFeatures = COMPRESSION_FEATURES.filter((feature) => adapter.features.has(feature));
1022
- const supportsTransmission =
1023
- (adapter.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1024
- STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
1025
- if (options?.captureGpuTimings === true && adapter.features.has('timestamp-query')) {
1026
- requiredFeatures.push('timestamp-query');
1027
- }
1028
- return {
1029
- ...(requiredFeatures.length > 0 ? { requiredFeatures } : {}),
1030
- ...(supportsTransmission
1031
- ? {
1032
- requiredLimits: {
1033
- maxSampledTexturesPerShaderStage: STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1034
- },
1035
- }
1036
- : {}),
1037
- };
1038
- }
1039
-
1040
1082
  /**
1041
1083
  * Goes through the strict two-step path exposed by the injected backend pack:
1042
1084
  * `rhi.requestAdapter()` -> `adapter.requestDevice()`
@@ -1077,7 +1119,12 @@ async function tryCreateWebGPURenderer(
1077
1119
  // backend singleton (rhi-webgpu / rhi-wgpu / explicit escape hatch).
1078
1120
  // w19: thread canvas as compatibleSurface so the wgpu GL backend can
1079
1121
  // enumerate adapters against it (escape hatch, capability-gated).
1122
+ markRendererBootstrapStage('adapter-request-start');
1080
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
+ });
1081
1128
  if (!adapterResult.ok) {
1082
1129
  return { kind: 'rhi-err', error: adapterResult.error };
1083
1130
  }
@@ -1085,11 +1132,19 @@ async function tryCreateWebGPURenderer(
1085
1132
  // M4 w28: filter compression features from adapter.features (AC-07). The
1086
1133
  // same helper is used by recover() below so both paths share one profile.
1087
1134
  const deviceOpts = deviceOptionsForAdapter(adapter, options);
1135
+ markRendererBootstrapStage('device-request-start');
1088
1136
  const result = await adapter.requestDevice(deviceOpts);
1137
+ markRendererBootstrapStage('device-request-end', {
1138
+ ok: result.ok,
1139
+ ...(result.ok ? {} : { code: result.error.code }),
1140
+ });
1089
1141
  if (!result.ok) {
1090
1142
  return { kind: 'rhi-err', error: result.error };
1091
1143
  }
1092
1144
  const device: RhiDevice = result.value;
1145
+ markRendererBootstrapStage('device-acquired', {
1146
+ backendKind: device.caps.backendKind,
1147
+ });
1093
1148
 
1094
1149
  // Acquire the canvas context through the backend pack (M3 / w16).
1095
1150
  // rhi-webgpu: internally calls canvas.getContext('webgpu') + branded wrap.
@@ -1220,12 +1275,24 @@ interface WebGPURendererInternals {
1220
1275
  featureHost?: RenderFeatureHost;
1221
1276
  /** App-owned overlay capability read by the typed graph at frame time. */
1222
1277
  debugOverlay?: RenderDebugOverlay;
1278
+ gpuPassTimingSubmittedWork?: Promise<void> | undefined;
1223
1279
  }
1224
1280
 
1225
1281
  async function makeWebGPURenderer(
1226
1282
  internals: WebGPURendererInternals,
1227
1283
  ): Promise<RendererAssemblyImplementation> {
1228
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
+ });
1229
1296
  const featureHostResult = createRenderFeatureHost(internals.options?.features ?? []);
1230
1297
  if (!featureHostResult.ok) throw featureHostResult.error;
1231
1298
  internals.featureHost = featureHostResult.value;
@@ -1240,6 +1307,28 @@ async function makeWebGPURenderer(
1240
1307
  featureHostResult.value.features.flatMap((feature) => feature.requiredMaterialShaders ?? []),
1241
1308
  );
1242
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
+ );
1243
1332
  // Lazy ShaderCatalog instance (plan-strategy section S-10 / D-R10 / OQ-5
1244
1333
  // close): constructed on first access; subsequent accesses return the
1245
1334
  // same instance.
@@ -1282,6 +1371,28 @@ async function makeWebGPURenderer(
1282
1371
  }
1283
1372
  return sharedImmediateShaderModuleAdapter;
1284
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
+ };
1285
1396
  const getShader = (): ShaderCatalog => {
1286
1397
  if (shaderInstance === null) {
1287
1398
  // feat-20260608-create-app-param-surface-trim / M2 / D-2 q5-A:
@@ -1346,6 +1457,56 @@ async function makeWebGPURenderer(
1346
1457
  // lives in a standalone store; `assets` keeps the CPU POD registry only.
1347
1458
  const gpuStore = new GpuResidencyCache();
1348
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();
1349
1510
  // feat-20260623-world-space-video-asset M4 / w16 (D-3): transient per-frame
1350
1511
  // video texture store, fully independent of gpuStore (AC-08). Configured with
1351
1512
  // the device alongside gpuStore below; threaded into the record stage via the
@@ -1479,7 +1640,10 @@ async function makeWebGPURenderer(
1479
1640
  // (the seeding closure captures this Map by reference; ordering is safe
1480
1641
  // because the prewarm step runs strictly before the first frame's
1481
1642
  // `getMaterialShaderPipeline` lookup that would consume it).
1482
- const materialShaderPipelineCache = new Map<string, RenderPipeline>();
1643
+ const materialShaderPipelineCache = new Map<
1644
+ string,
1645
+ { readonly pipeline: RenderPipeline; readonly group2Contract: PipelineGroup2Contract }
1646
+ >();
1483
1647
  const materialShaderManifestEntryCache = new Map<string, MaterialShaderManifestEntry>();
1484
1648
  // Variant resolution only depends on the requested axes, device capability
1485
1649
  // axes, and the manifest entry that owns the declarations. Cache it by the
@@ -1798,6 +1962,7 @@ async function makeWebGPURenderer(
1798
1962
  // `internals.device` / `internals.pack` are read live, so a rebuild after a
1799
1963
  // device swap compiles against the new device.
1800
1964
  let tonemapRegistered = false;
1965
+ let fxaaRegistered = false;
1801
1966
  let temporalPostProcessesRegistered = false;
1802
1967
  const buildPipeline = (scope: DeviceScope = activeDeviceScope): Promise<PipelineState> =>
1803
1968
  buildReadyWebGPU(
@@ -1808,6 +1973,7 @@ async function makeWebGPURenderer(
1808
1973
  internals.pack.createShaderModule,
1809
1974
  internals.errorRegistry,
1810
1975
  requiredMaterialShaders,
1976
+ requiredFullscreenPostProcesses,
1811
1977
  // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
1812
1978
  // T-M2-05 + M3 / T-M3-04: callback set by buildReadyWebGPU once
1813
1979
  // meshSsboController is wired. Both the grow hook AND the read-only
@@ -1827,9 +1993,12 @@ async function makeWebGPURenderer(
1827
1993
  // SPEC_CONST prewarm. Closure captures the Map declared just above so
1828
1994
  // the URP-variant prewarmed PSOs become visible to the URP record path
1829
1995
  // immediately (no 1-frame async-compile skip-draw window).
1830
- (key, pso) => {
1996
+ (key, pso, group2Contract) => {
1831
1997
  if (!materialShaderPipelineCache.has(key)) {
1832
- materialShaderPipelineCache.set(key, pso);
1998
+ materialShaderPipelineCache.set(key, {
1999
+ pipeline: pso,
2000
+ group2Contract,
2001
+ });
1833
2002
  }
1834
2003
  },
1835
2004
  // feat-20260621 M-A3 (D-5): register the built-in tonemap on the unified
@@ -1848,6 +2017,19 @@ async function makeWebGPURenderer(
1848
2017
  });
1849
2018
  tonemapRegistered = true;
1850
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
+ },
1851
2033
  (entries: { readonly motionBlur?: string; readonly taaResolve?: string }) => {
1852
2034
  if (temporalPostProcessesRegistered) return;
1853
2035
  if (entries.motionBlur !== undefined) {
@@ -1870,14 +2052,19 @@ async function makeWebGPURenderer(
1870
2052
  internals.volumetricFogShaders = sources;
1871
2053
  },
1872
2054
  );
2055
+ markRendererBootstrapStage('renderer-initialization-start');
1873
2056
  const ready: Promise<Result<void, RhiError>> = buildPipeline().then(
1874
2057
  (state): Result<void, RhiError> => {
1875
2058
  pipelineState = state;
1876
2059
  readySettled = true;
2060
+ markRendererBootstrapStage('renderer-initialized');
1877
2061
  return ok(undefined);
1878
2062
  },
1879
2063
  (e: unknown): Result<void, RhiError> => {
1880
2064
  readySettled = true;
2065
+ markRendererBootstrapStage('renderer-initialization-failed', {
2066
+ message: e instanceof Error ? e.message : String(e),
2067
+ });
1881
2068
  if (e instanceof RhiError) return err(e);
1882
2069
  const message = e instanceof Error ? e.message : String(e);
1883
2070
  return err(
@@ -1914,6 +2101,7 @@ async function makeWebGPURenderer(
1914
2101
  pbrManifestEntry,
1915
2102
  internals.device.caps.storageBuffer,
1916
2103
  variantSet,
2104
+ directionalPcssCapable(internals.device.caps.backendKind),
1917
2105
  (internals.device.limits.maxSampledTexturesPerShaderStage ?? 0) >=
1918
2106
  STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES,
1919
2107
  );
@@ -1931,10 +2119,10 @@ async function makeWebGPURenderer(
1931
2119
  // paths (D-3). Null when manifest is empty (Camera-only path).
1932
2120
  //
1933
2121
  // feat-20260609-hdrp-cluster-fragment-ggx M4.5 / w37 (D-10 option A):
1934
- // `variantSet` parameter selects between URP `pbrPipelineLayout` (1-slot
1935
- // group(2) BGL) and HDRP `hdrpPbrPipelineLayout` (7-slot group(2) BGL)
1936
- // via `selectPipelineLayoutForVariant`. When undefined (legacy callers
1937
- // / 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.
1938
2126
  const buildPipelineContext = (
1939
2127
  variantSet?: string,
1940
2128
  materialShaderId?: string,
@@ -1945,40 +2133,31 @@ async function makeWebGPURenderer(
1945
2133
  // consume. A caller passing a real `MeshAsset.attributes` produces the
1946
2134
  // exact 6-attribute / 72-byte layout the skin shader expects; passing
1947
2135
  // undefined falls into the synthetic 6-key sentinel below (the skin
1948
- // path is fully determined by layoutKind, so key-presence is the only
1949
- // 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).
1950
2138
  meshAttributes?: VertexAttributeMap,
1951
2139
  vertexLayout?: string,
1952
2140
  vertexLayoutProjection?: VertexLayoutProjection,
1953
2141
  shaderModuleMode?: RenderFeatureShaderModuleMode,
2142
+ group2Contract: PipelineGroup2Contract = 'mesh',
1954
2143
  layoutKindOverride?: LayoutKind,
1955
2144
  ) => {
1956
- // bug-20260611: derive LayoutKind from materialShaderId (caller-driven)
1957
- // so the selector body stays free of literal shader-id strings (AC-09).
1958
- // Skin shader gets its own 4-slot layout chain with 2-entry mesh-array
1959
- // BGL; everything else routes through the URP/HDRP variantSet logic.
1960
- // bug-20260708 M2 (c): sprite / sprite-lit map to 'sprite-urp' so the
1961
- // canonical all-true variant key `''` (used by SpriteInstances batches
1962
- // via SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET) does NOT route
1963
- // through the HDRP layout branch of the selector — sprite shaders have
1964
- // no HDRP variant, plan-strategy R-1' guard.
1965
- const layoutKind: LayoutKind | undefined =
1966
- layoutKindOverride ??
1967
- (materialShaderId === SKIN_MATERIAL_SHADER_ID ||
1968
- isSkinnedShadowCasterVariant(materialShaderId, variantSet)
1969
- ? 'pbr-skin'
1970
- : materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit'
1971
- ? 'sprite-urp'
1972
- : materialShaderId === 'forgeax::default-unlit'
1973
- ? 'unlit-urp'
1974
- : 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;
1975
2154
  const usesSharedBootMaterialLayout =
1976
2155
  materialShaderId !== undefined && isEngineOwnedMaterialShader(materialShaderId);
1977
2156
  // Built-in PBR/unlit/skin layouts are assembled at boot. Re-deriving a
1978
2157
  // per-shader layout from the expanded PBR schema would bypass the HDRP
1979
2158
  // group(2) selector and bind the URP mesh BGL to the cluster pipeline.
1980
2159
  const perShaderLayout =
1981
- !usesSharedBootMaterialLayout && layoutKind !== 'pbr-skin' && materialShaderId !== undefined
2160
+ !usesSharedBootMaterialLayout && materialShaderId !== undefined
1982
2161
  ? getOrBuildPerShaderMaterialLayout(materialShaderId)
1983
2162
  : null;
1984
2163
  // feat-20260621-learn-render-5-5-parallax M2 / w6 (D-1): an authored
@@ -2030,7 +2209,7 @@ async function makeWebGPURenderer(
2030
2209
  ? preparedMaterialLayout
2031
2210
  : perShaderLayout !== null
2032
2211
  ? perShaderLayout.pipelineLayout
2033
- : selectPipelineLayoutForVariant(pipelineState, variantSet, layoutKind);
2212
+ : selectPipelineLayoutForVariant(pipelineState, variantSet, resolvedLayoutKind);
2034
2213
  if (pipelineLayout === null) return null;
2035
2214
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2036
2215
  // pbr-skin -> deriveVertexBufferLayout (single SSOT for skin/non-skin
@@ -2081,7 +2260,7 @@ async function makeWebGPURenderer(
2081
2260
  : undefined,
2082
2261
  ),
2083
2262
  )
2084
- : layoutKind === 'pbr-skin'
2263
+ : materialShaderId === SKIN_MATERIAL_SHADER_ID
2085
2264
  ? toGpuVertexBufferLayouts(
2086
2265
  deriveVertexBufferLayout(
2087
2266
  meshAttributes ??
@@ -2127,7 +2306,7 @@ async function makeWebGPURenderer(
2127
2306
  : getShaderModuleAdapter(),
2128
2307
  pipelineLayout,
2129
2308
  vertexBuffers,
2130
- ...(layoutKind === 'pbr-skin' ? { layoutKind } : {}),
2309
+ ...(resolvedLayoutKind === 'pbr-skin' ? { layoutKind: 'pbr-skin' as const } : {}),
2131
2310
  };
2132
2311
  };
2133
2312
  /**
@@ -2151,7 +2330,7 @@ async function makeWebGPURenderer(
2151
2330
  passKind: PassKind = 'forward',
2152
2331
  // bug-20260611-skin-pipeline-layout: thread materialShaderId so
2153
2332
  // buildPipelineContext can derive `LayoutKind === 'pbr-skin'` and pick
2154
- // the 2-entry mesh-array BGL chain.
2333
+ // the 3-entry mesh-array BGL chain.
2155
2334
  materialShaderId?: string,
2156
2335
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2157
2336
  // pass the per-mesh `VertexAttributeMap` so the pbr-skin path's
@@ -2187,7 +2366,11 @@ async function makeWebGPURenderer(
2187
2366
  vertexLayoutProjection?: VertexLayoutProjection,
2188
2367
  shaderModuleMode?: RenderFeatureShaderModuleMode,
2189
2368
  layoutKindOverride?: LayoutKind,
2190
- ): RenderPipeline | null => {
2369
+ ): {
2370
+ readonly pipeline: RenderPipeline;
2371
+ readonly group2Contract: PipelineGroup2Contract;
2372
+ } | null => {
2373
+ const group2Contract = resolvePipelineGroup2Contract(entry.source);
2191
2374
  const ctx = buildPipelineContext(
2192
2375
  variantSet,
2193
2376
  materialShaderId,
@@ -2195,6 +2378,7 @@ async function makeWebGPURenderer(
2195
2378
  vertexLayout,
2196
2379
  vertexLayoutProjection,
2197
2380
  shaderModuleMode,
2381
+ group2Contract,
2198
2382
  layoutKindOverride,
2199
2383
  );
2200
2384
  if (ctx === null) return null;
@@ -2254,10 +2438,14 @@ async function makeWebGPURenderer(
2254
2438
  }
2255
2439
  return null;
2256
2440
  }
2257
- materialShaderPipelineCache.set(cacheKey, built.value);
2258
- return built.value;
2441
+ const pipelineEntry = {
2442
+ pipeline: built.value,
2443
+ group2Contract,
2444
+ } as const;
2445
+ materialShaderPipelineCache.set(cacheKey, pipelineEntry);
2446
+ return pipelineEntry;
2259
2447
  };
2260
- const getMaterialShaderPipeline = (
2448
+ const resolveMaterialShaderPipelineEntry = (
2261
2449
  materialShaderId: string,
2262
2450
  isHdr: boolean,
2263
2451
  renderState?: MaterialRenderState,
@@ -2304,7 +2492,10 @@ async function makeWebGPURenderer(
2304
2492
  vertexLayoutProjection?: VertexLayoutProjection,
2305
2493
  shaderModuleMode: RenderFeatureShaderModuleMode = 'validated',
2306
2494
  layoutKindOverride?: LayoutKind,
2307
- ): RenderPipeline | null => {
2495
+ ): {
2496
+ readonly pipeline: RenderPipeline;
2497
+ readonly group2Contract: PipelineGroup2Contract;
2498
+ } | null => {
2308
2499
  // WebGL2 is a downlevel backend even when the record stage asks for the
2309
2500
  // native-capability variant string. Resolve the variant axes at this
2310
2501
  // shared PSO seam so the shader source, BGL layout, and cache key agree on
@@ -2424,6 +2615,11 @@ async function makeWebGPURenderer(
2424
2615
  if (pipelineState === null) return null;
2425
2616
  const lookup = getShader().findMaterialArtifact(materialShaderId);
2426
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;
2427
2623
  if (shouldDeferMissingPreparedMaterialShader(vertexLayout)) {
2428
2624
  // Prepared VFX draws bind only their feature-owned groups. The
2429
2625
  // built-in PBR fallback declares mesh/instance groups 2/3 and would
@@ -2544,24 +2740,26 @@ async function makeWebGPURenderer(
2544
2740
  // Use `!== undefined` so the empty-string case enters the lookup.
2545
2741
  if (effectiveVariantSet !== undefined) {
2546
2742
  const registry = getShader();
2743
+ let matchedVariant = false;
2547
2744
  for (const msEntry of registry.materialShaderManifestEntries()) {
2548
2745
  if (msEntry.identifier === materialShaderId) {
2549
2746
  const variant = findVariantByKey(msEntry, effectiveVariantSet);
2550
- if (variant) {
2551
- // M3 / w12-w13: variant substitution carries the same source +
2552
- // paramSchema as the boot-registered entry; the binding layout
2553
- // is no longer carried on MaterialShaderEntry (deleted in
2554
- // w13) buildPbrPipelineLayouts is the BGL SSOT and reads
2555
- // derive(paramSchema).bglEntries on demand.
2556
- shaderEntry = {
2557
- source: variant.composedWgsl,
2558
- paramSchema: lookup.value.paramSchema,
2559
- paramSchemaProjection: lookup.value.paramSchemaProjection,
2560
- };
2561
- }
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
+ };
2562
2759
  break;
2563
2760
  }
2564
2761
  }
2762
+ if (!matchedVariant) return null;
2565
2763
  }
2566
2764
  // feat-20260609 M4 / R3-fixup: append `-${passKind}` to the PSO label so
2567
2765
  // GPU debug captures (and the shadow-caster branch in the builder)
@@ -2602,7 +2800,7 @@ async function makeWebGPURenderer(
2602
2800
  passKind,
2603
2801
  // bug-20260611-skin-pipeline-layout: thread the registered materialShaderId
2604
2802
  // so buildPipelineContext can resolve LayoutKind='pbr-skin' for the skin
2605
- // shader (2-entry mesh-array BGL).
2803
+ // shader (3-entry mesh-array BGL).
2606
2804
  materialShaderId,
2607
2805
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w16 (D-4):
2608
2806
  // forward meshAttributes so the pbr-skin path's vertex buffer layout
@@ -2619,6 +2817,12 @@ async function makeWebGPURenderer(
2619
2817
  layoutKindOverride,
2620
2818
  );
2621
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;
2622
2826
  const getParamSchema = (materialShaderId: string) => {
2623
2827
  const lookup = getShader().findMaterialArtifact(materialShaderId);
2624
2828
  return lookup.ok ? lookup.value.paramSchema : undefined;
@@ -2739,6 +2943,10 @@ async function makeWebGPURenderer(
2739
2943
  shaderModuleFactory: {
2740
2944
  createShaderModule: (descriptor) => getShaderModuleAdapter().createShaderModule(descriptor),
2741
2945
  },
2946
+ createShaderModule: (device, descriptor) =>
2947
+ internals.pack.createShaderModule === undefined
2948
+ ? Promise.resolve(invokeDeviceCreateShaderModule(device, descriptor))
2949
+ : internals.pack.createShaderModule(device, descriptor),
2742
2950
  immediateShaderModuleFactory: {
2743
2951
  createShaderModule: (descriptor) =>
2744
2952
  getImmediateShaderModuleAdapter().createShaderModule(descriptor),
@@ -2775,6 +2983,7 @@ async function makeWebGPURenderer(
2775
2983
  errorRegistry: internals.errorRegistry,
2776
2984
  healthRegistry: internals.healthRegistry,
2777
2985
  getMaterialShaderPipeline,
2986
+ getMaterialShaderPipelineEntry,
2778
2987
  getMaterialShaderBindingContract: getCachedMaterialShaderBindingContract,
2779
2988
  getParamSchema,
2780
2989
  getMaterialBindGroupLayout,
@@ -2794,10 +3003,93 @@ async function makeWebGPURenderer(
2794
3003
  get meshSsboState() {
2795
3004
  return internals.meshSsboState;
2796
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),
2797
3087
  get volumetricFogShaders() {
2798
3088
  return internals.volumetricFogShaders;
2799
3089
  },
2800
3090
  });
3091
+ attachGpuPassTimingSession(internals, gpuPassTimingSession);
3092
+ renderSystemForTargetPromotion = renderSystem;
2801
3093
  if (
2802
3094
  internals.device.caps.backendKind === 'null' &&
2803
3095
  Array.from(getShader().entries()).length === 0
@@ -2822,6 +3114,10 @@ async function makeWebGPURenderer(
2822
3114
  const transformReleases = new Map<World, () => void>();
2823
3115
  const attachmentOwner = {};
2824
3116
  let frameId = 0;
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>();
2825
3121
  const issuedReceipts = new WeakSet<FrameReceipt>();
2826
3122
  const receiptTimings = new WeakMap<FrameReceipt, Promise<VolumeTimingObservation>>();
2827
3123
  let surfaceReleased = false;
@@ -2837,13 +3133,21 @@ async function makeWebGPURenderer(
2837
3133
  leasesByWorld.set(world, lease);
2838
3134
  return ok(lease);
2839
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),
2840
3144
  setProfile(profile: RenderProfile): RenderResult<void, RenderError> {
2841
3145
  const invalid = validateRenderProfile(profile);
2842
3146
  if (invalid !== undefined) {
2843
3147
  return err(
2844
3148
  new RendererOperationError('frame-input-invalid', {
2845
3149
  operation: 'set-profile',
2846
- cause: new RendererContractFailureError('draw', invalid),
3150
+ cause: new StandardProfileInvalidError(invalid),
2847
3151
  }),
2848
3152
  );
2849
3153
  }
@@ -2888,6 +3192,7 @@ async function makeWebGPURenderer(
2888
3192
  : { presentationProof: internals.context.presentationProof }),
2889
3193
  });
2890
3194
  const volumetricFog = renderSystem.volumetricFog;
3195
+ const directionalShadow = renderSystem.directionalShadow;
2891
3196
  return Object.freeze({
2892
3197
  state,
2893
3198
  surface: surfaceReleased ? 'released' : 'available',
@@ -2905,9 +3210,11 @@ async function makeWebGPURenderer(
2905
3210
  frustumStats: Object.freeze({ ...renderSystem.frustumStats }),
2906
3211
  visibilityStats: Object.freeze({ ...renderSystem.visibilityStats }),
2907
3212
  renderScene: renderSystem.renderScene,
3213
+ reflectionProbes: renderSystem.reflectionProbes,
2908
3214
  environment: renderSystem.environment,
2909
3215
  temporal: renderSystem.temporal,
2910
3216
  bloom: renderSystem.bloom,
3217
+ directionalShadow,
2911
3218
  ...(renderSystem.temporalTargetInspection === undefined
2912
3219
  ? {}
2913
3220
  : { temporalTarget: Object.freeze(renderSystem.temporalTargetInspection) }),
@@ -2917,6 +3224,12 @@ async function makeWebGPURenderer(
2917
3224
  ...(renderSystem.transmission === undefined
2918
3225
  ? {}
2919
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 }) }),
2920
3233
  meshMaterialBindings: Object.freeze(
2921
3234
  renderSystem.meshMaterialBindings.map((observation) =>
2922
3235
  Object.freeze({
@@ -3065,6 +3378,9 @@ async function makeWebGPURenderer(
3065
3378
  get bindGroupCounts() {
3066
3379
  return renderSystem.bindGroupCounts;
3067
3380
  },
3381
+ configureStandard(config) {
3382
+ renderSystem.configureStandard(config);
3383
+ },
3068
3384
  renderFeatureDiagnostics() {
3069
3385
  return internals.featureHost?.diagnostics() ?? [];
3070
3386
  },
@@ -3090,6 +3406,35 @@ async function makeWebGPURenderer(
3090
3406
  new RenderFeatureCapabilityMissingError(feature.identity, host.size, missingCapability),
3091
3407
  );
3092
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
+ }
3093
3438
  for (const materialShaderId of feature.requiredMaterialShaders ?? []) {
3094
3439
  const lookup = getShader().findMaterialArtifact(materialShaderId);
3095
3440
  if (!lookup.ok) {
@@ -3128,6 +3473,7 @@ async function makeWebGPURenderer(
3128
3473
  requiredMaterialShaderSet.add(materialShaderId);
3129
3474
  }
3130
3475
  requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
3476
+ requiredFullscreenPostProcesses = nextRequiredFullscreenPostProcesses;
3131
3477
  }
3132
3478
  return installed;
3133
3479
  },
@@ -3156,6 +3502,7 @@ async function makeWebGPURenderer(
3156
3502
  }
3157
3503
  }
3158
3504
  requiredMaterialShaders = Object.freeze([...requiredMaterialShaderSet]);
3505
+ requiredFullscreenPostProcesses = collectRequiredFullscreenPostProcesses(host.features);
3159
3506
  }
3160
3507
  return removed;
3161
3508
  },
@@ -3329,23 +3676,52 @@ async function makeWebGPURenderer(
3329
3676
  // through onError separately; the facade Result is the synchronous
3330
3677
  // summary AI users can ignore or branch on).
3331
3678
  try {
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
+ };
3332
3693
  const timingRequested =
3333
3694
  isFrameRequest &&
3334
3695
  (frameRequest?.profileFrame !== undefined ||
3335
3696
  internals.options?.captureGpuTimings === true);
3336
- let timingCapture: GpuTimingCapture | undefined;
3337
- let timingUnavailableReason = timingRequested
3697
+ let volumeTimingCapture: GpuTimingCapture | undefined;
3698
+ const volumeTimingUnavailableReason = timingRequested
3338
3699
  ? 'timestamp capture is unavailable on this device'
3339
3700
  : 'timestamp capture was not requested for this frame';
3340
- if (timingRequested) {
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) {
3341
3706
  const createdTiming = GpuTimingCapture.create(internals.device);
3342
- if (createdTiming.ok) timingCapture = createdTiming.value;
3343
- else
3344
- timingUnavailableReason = `timestamp capture setup failed: ${createdTiming.error.code}`;
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
+ };
3345
3715
  }
3346
- const submitted = renderSystem.draw(worlds, drawOptions, renderReadLeases, timingCapture);
3716
+ const submitted = renderSystem.draw(
3717
+ worlds,
3718
+ drawOptions,
3719
+ renderReadLeases,
3720
+ volumeTimingCapture,
3721
+ );
3722
+ timingHost.gpuPassTimingFrameIdentity = undefined;
3347
3723
  if (!submitted) {
3348
- timingCapture?.discard();
3724
+ volumeTimingCapture?.discard();
3349
3725
  return err(
3350
3726
  new RendererContractFailureError(
3351
3727
  'draw',
@@ -3353,36 +3729,77 @@ async function makeWebGPURenderer(
3353
3729
  ),
3354
3730
  );
3355
3731
  }
3732
+ const timingCompletion = timingHost.gpuPassTimingSubmittedWork;
3733
+ timingHost.gpuPassTimingSubmittedWork = undefined;
3356
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);
3357
3752
  if (isFrameRequest) {
3358
- const receiptFrameId = ++frameId;
3359
- const receiptGeneration = activeDeviceScope.generation;
3360
- const completed: FrameReceipt['completed'] = internals.device.queue
3361
- .onSubmittedWorkDone()
3362
- .then(() => ok(undefined))
3363
- .catch((cause: unknown) =>
3364
- err(
3365
- new RendererOperationError('device-operation-failed', {
3366
- operation: 'complete-frame',
3367
- frameId: receiptFrameId,
3368
- deviceGeneration: receiptGeneration,
3369
- cause: structuredRendererCause(cause, 'complete-frame'),
3370
- }),
3371
- ),
3372
- );
3373
3753
  const receipt = Object.freeze({
3374
3754
  frameId: receiptFrameId,
3375
3755
  deviceGeneration: receiptGeneration,
3376
3756
  completed,
3377
3757
  });
3378
- receiptTimings.set(
3379
- receipt,
3380
- timingCapture?.observation() ??
3381
- Promise.resolve({
3382
- status: 'unavailable',
3383
- reason: timingUnavailableReason,
3384
- }),
3385
- );
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
+ }
3386
3803
  issuedReceipts.add(receipt);
3387
3804
  return ok(receipt);
3388
3805
  }
@@ -3407,7 +3824,16 @@ async function makeWebGPURenderer(
3407
3824
  receipt: FrameReceipt,
3408
3825
  request: FrameObservationRequest,
3409
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
+ }
3410
3835
  if (
3836
+ !currentReceipts.has(receipt) ||
3411
3837
  !issuedReceipts.has(receipt) ||
3412
3838
  receipt.deviceGeneration !== activeDeviceScope.generation
3413
3839
  ) {
@@ -3432,11 +3858,16 @@ async function makeWebGPURenderer(
3432
3858
  }),
3433
3859
  );
3434
3860
  }
3435
- const timings = request.include.includes('timings')
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')
3436
3867
  ? await (receiptTimings.get(receipt) ??
3437
- Promise.resolve({
3868
+ Promise.resolve<VolumeTimingObservation>({
3438
3869
  status: 'unavailable',
3439
- reason: 'timing receipt was not retained',
3870
+ reason: 'timestamp capture was not requested for this frame',
3440
3871
  }))
3441
3872
  : undefined;
3442
3873
  return ok(
@@ -3444,7 +3875,10 @@ async function makeWebGPURenderer(
3444
3875
  frameId: receipt.frameId,
3445
3876
  deviceGeneration: receipt.deviceGeneration,
3446
3877
  include: Object.freeze([...request.include]),
3447
- ...(timings === undefined ? {} : { timings }),
3878
+ ...(targetReadbacks === undefined
3879
+ ? {}
3880
+ : { targetReadbacks: Object.freeze(targetReadbacks.value) }),
3881
+ ...(volumeTimings === undefined ? {} : { volumeTimings }),
3448
3882
  }),
3449
3883
  );
3450
3884
  },
@@ -3519,6 +3953,8 @@ async function makeWebGPURenderer(
3519
3953
  dispose(): RenderResult<void, RenderError> {
3520
3954
  if (disposed) return ok(undefined);
3521
3955
  disposed = true;
3956
+ currentReceipts.clear();
3957
+ renderTargetHost.dispose();
3522
3958
  const cleanupFailures: RendererOperationCause[] = [];
3523
3959
  for (const world of attachedWorlds) {
3524
3960
  try {
@@ -3699,6 +4135,8 @@ async function makeWebGPURenderer(
3699
4135
  // a layout from the replacement device.
3700
4136
  emptyPostProcessBgl = null;
3701
4137
  internals.context.unconfigure();
4138
+ retireGpuPassTimingSession();
4139
+ attachGpuPassTimingSession(internals, undefined);
3702
4140
 
3703
4141
  // Step (b) / B-2 / B-AC-02: shed device-bound RenderSystem state minted by
3704
4142
  // the lost device — the render-graph pendingDestroy queue (its PooledTextures
@@ -3845,8 +4283,12 @@ async function makeWebGPURenderer(
3845
4283
  const previousDeviceScope = activeDeviceScope;
3846
4284
  activeDeviceScope = recoveryScope;
3847
4285
  pipelineState = candidatePipelineState;
4286
+ currentReceipts.clear();
3848
4287
  gpuStore.bindDeviceScope(activeDeviceScope);
4288
+ renderTargetHost.recover();
3849
4289
  previousDeviceScope.retire();
4290
+ establishGpuPassTimingSession();
4291
+ attachGpuPassTimingSession(internals, gpuPassTimingSession);
3850
4292
 
3851
4293
  // Re-evaluate feature capability gates against the replacement device.
3852
4294
  // Feature recovery is isolated from renderer recovery: a feature that is
@@ -3932,7 +4374,7 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3932
4374
  });
3933
4375
 
3934
4376
  return {
3935
- attach: (world) => {
4377
+ attach: (world: World) => {
3936
4378
  const result = renderer.attach(world);
3937
4379
  return result.ok
3938
4380
  ? result
@@ -3943,6 +4385,12 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3943
4385
  }),
3944
4386
  );
3945
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),
3946
4394
  setProfile: (profile) => {
3947
4395
  if (!isRendererStateOperational(currentState)) {
3948
4396
  return err(
@@ -3955,7 +4403,7 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3955
4403
  return renderer.setProfile(profile);
3956
4404
  },
3957
4405
  state: () => currentState,
3958
- draw: (request) => {
4406
+ draw: (request: RenderFrameInput) => {
3959
4407
  const result = renderer.drawFrame(request);
3960
4408
  if (!result.ok) return err(drawError(result.error));
3961
4409
  emit(
@@ -3968,8 +4416,9 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
3968
4416
  return ok(result.value);
3969
4417
  },
3970
4418
  inspect: () => renderer.inspect(),
3971
- observe: (receipt, request) => renderer.observe(receipt, request),
3972
- subscribe: (listener) => {
4419
+ observe: (receipt: FrameReceipt, request: FrameObservationRequest) =>
4420
+ renderer.observe(receipt, request),
4421
+ subscribe: (listener: RendererEventListener) => {
3973
4422
  if (disposed) return () => undefined;
3974
4423
  listeners.add(listener);
3975
4424
  return () => listeners.delete(listener);
@@ -4083,17 +4532,11 @@ function structuredRendererCause(cause: unknown, operation: string): RendererOpe
4083
4532
  const RENDER_PROFILE_KEYS = new Set([
4084
4533
  'pipelineId',
4085
4534
  'lightCount',
4086
- 'lighting',
4535
+ 'renderPath',
4087
4536
  'shadows',
4088
4537
  'pbr',
4089
4538
  'ibl',
4090
4539
  'ssao',
4091
- 'bloom',
4092
- 'tone',
4093
- 'antialias',
4094
- 'sky',
4095
- 'volumetricFog',
4096
- 'fallback',
4097
4540
  'postStages',
4098
4541
  ]);
4099
4542
 
@@ -4107,36 +4550,15 @@ function validateRenderProfile(profile: RenderProfile): string | undefined {
4107
4550
  if (!STANDARD_LIGHT_COUNTS.some((count) => count === profile.lightCount)) {
4108
4551
  return 'RenderProfile.lightCount must be 1, 32, or 256';
4109
4552
  }
4110
- if (profile.lighting !== 'direct' && profile.lighting !== 'clustered') {
4111
- 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'";
4112
4555
  }
4113
4556
  if (profile.shadows !== 'off' && profile.shadows !== 'hard' && profile.shadows !== 'filtered') {
4114
4557
  return "RenderProfile.shadows must be 'off', 'hard', or 'filtered'";
4115
4558
  }
4116
- if (profile.fallback !== 'native' && profile.fallback !== 'cpu-webgl2') {
4117
- return "RenderProfile.fallback must be 'native' or 'cpu-webgl2'";
4118
- }
4119
4559
  for (const field of ['pbr', 'ibl', 'ssao'] as const) {
4120
4560
  if (typeof profile[field] !== 'boolean') return `RenderProfile.${field} must be boolean`;
4121
4561
  }
4122
- if (profile.volumetricFog !== undefined) {
4123
- if (profile.volumetricFog.quality !== 'low' && profile.volumetricFog.quality !== 'high')
4124
- return "RenderProfile.volumetricFog.quality must be 'low' or 'high'";
4125
- if (profile.volumetricFog.depth !== 48 && profile.volumetricFog.depth !== 64) {
4126
- return 'RenderProfile.volumetricFog.depth must be 48 or 64';
4127
- }
4128
- if (profile.volumetricFog.tileSize !== 4 && profile.volumetricFog.tileSize !== 16) {
4129
- return 'RenderProfile.volumetricFog.tileSize must be 4 or 16';
4130
- }
4131
- if (profile.volumetricFog.quality === 'low' && profile.volumetricFog.depth !== 48)
4132
- return 'RenderProfile.volumetricFog.low must use depth 48';
4133
- if (profile.volumetricFog.quality === 'high' && profile.volumetricFog.depth !== 64)
4134
- return 'RenderProfile.volumetricFog.high must use depth 64';
4135
- if (profile.volumetricFog.quality === 'low' && profile.volumetricFog.tileSize !== 16)
4136
- return 'RenderProfile.volumetricFog.low must use tileSize 16';
4137
- if (profile.volumetricFog.quality === 'high' && profile.volumetricFog.tileSize !== 4)
4138
- return 'RenderProfile.volumetricFog.high must use tileSize 4';
4139
- }
4140
4562
  const stages = STANDARD_POST_STAGE_NAMES;
4141
4563
  if (
4142
4564
  !Array.isArray(profile.postStages) ||
@@ -4409,23 +4831,20 @@ function makeRhiNotAvailableError(): RhiError {
4409
4831
  // inverseViewProj mat4x4<f32> (64 B) appended at tail (offset 176, align 16,
4410
4832
  // total 176 + 64 = 240 B). Field added to common.wgsl View struct; host
4411
4833
  // writes the tail via queue.writeBuffer in render-system-record.
4412
- // 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.
4413
4835
  // lightSpaceMatrix replaced by lightViewProj_A at offset 112; inverseViewProj
4414
4836
  // stays at offset 176; lightViewProj_B..D + splitPlanes[4] + cascadeCount +
4415
- // cascadeBlend + tail padding appended after inverseViewProj. Total 148 f32
4416
- // = 592 B, fixed independent of cascadeCount (AC-08).
4417
- // feat-20260621-merge-directionallightshadow-into-directionallight M3 / m3-t3:
4418
- // depthBias / normalBias / pcfKernelSize from the merged DirectionalLight
4419
- // append at the formerly-free tail pad (bytes 504/508/512, floats 126/127/128).
4420
- // VIEW_UBO_BYTES stays 592 (the host tail pad shrinks 88 B -> 64 B); the WGSL
4421
- // 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.
4422
4841
  // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-fallback):
4423
- // 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
4424
4843
  // (`array<mat4x4<f32>, 4>` = 256 B) fold from a standalone @group(0) binding 9
4425
4844
  // uniform buffer into the View UBO tail (bytes 528..784, 16 B-aligned after
4426
- // 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
4427
4846
  // buffer that overflowed GLES 3.0's max_uniform_buffers_per_shader_stage = 11 on
4428
- // 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;
4429
4848
  // common.wgsl `View.spotLightViewProj` is the matching SSOT.
4430
4849
  const VIEW_UBO_BYTES = VIEW_UNIFORM_BUFFER_SIZE;
4431
4850
 
@@ -4922,7 +5341,6 @@ async function prepareMaterialShaders(
4922
5341
  const projectorAvailable =
4923
5342
  (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
4924
5343
  STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
4925
-
4926
5344
  // ── Step 1: manifest load ─────────────────────────────────────────────────
4927
5345
  const registry = getShader();
4928
5346
  const loaded = await registry.loadManifest();
@@ -4931,12 +5349,11 @@ async function prepareMaterialShaders(
4931
5349
  }
4932
5350
 
4933
5351
  // ── Step 1b: material shader variant resolution + registration ───────────
4934
- // feat-20260609-hdrp-cluster-fragment-ggx M1 / w7: dual-axis variant
4935
- // resolution. At boot time isHdrpActive is always false (URP default),
4936
- // so CLUSTER_FORWARD_AVAILABLE=false variant is selected. HDRP variant
4937
- // (CLUSTER_FORWARD_AVAILABLE=true) is resolved later when
4938
- // clustered Standard profile activation selects the clustered variant.
4939
- 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();
4940
5357
  for (const msEntry of registry.materialShaderManifestEntries()) {
4941
5358
  // bug-20260610: skip synthetic non-material engine entries piggy-backing
4942
5359
  // on the materialShaders channel for variant surfacing (shadow_caster).
@@ -4947,6 +5364,11 @@ async function prepareMaterialShaders(
4947
5364
  const variantDefines: Record<string, boolean> = {
4948
5365
  STORAGE_BUFFER_AVAILABLE: storageBufferCapable,
4949
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
+ }
4950
5372
  // User material manifests may declare WEBGL2_COMPAT alongside the shared
4951
5373
  // mesh-storage axis. The fallback backend must select that authored
4952
5374
  // branch at boot; otherwise it asks for the impossible bare
@@ -4958,7 +5380,7 @@ async function prepareMaterialShaders(
4958
5380
  // the axis (has a variant with this key), preserving backward compat for
4959
5381
  // entries without the pragma.
4960
5382
  if (msEntry.variants.some((v) => 'CLUSTER_FORWARD_AVAILABLE' in v.defines)) {
4961
- variantDefines.CLUSTER_FORWARD_AVAILABLE = isHdrpActive;
5383
+ variantDefines.CLUSTER_FORWARD_AVAILABLE = bootClusterAxis;
4962
5384
  }
4963
5385
  if (msEntry.variants.some((v) => 'VERTEX_COLOR_AVAILABLE' in v.defines)) {
4964
5386
  variantDefines.VERTEX_COLOR_AVAILABLE = false;
@@ -5103,6 +5525,11 @@ async function buildReadyWebGPU(
5103
5525
  * frame while the shared async shader adapter is still warming up.
5104
5526
  */
5105
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
+ }[],
5106
5533
  // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 /
5107
5534
  // T-M2-05 + M3 / T-M3-04: surface for setting `internals.growMeshSsbo`
5108
5535
  // + `internals.meshSsboState` so the record stage's
@@ -5131,7 +5558,11 @@ async function buildReadyWebGPU(
5131
5558
  // `getMaterialShaderPipeline` lookup hits a live PSO instead of falling
5132
5559
  // into a 1-frame async-compile skip-draw window. Idempotent: caller
5133
5560
  // guards against re-seeding when a key already exists.
5134
- seedMaterialShaderPipelineCache: (key: string, pso: RenderPipeline) => void,
5561
+ seedMaterialShaderPipelineCache: (
5562
+ key: string,
5563
+ pso: RenderPipeline,
5564
+ group2Contract: PipelineGroup2Contract,
5565
+ ) => void,
5135
5566
  // feat-20260621 M-A3 (D-5): register the engine built-in tonemap onto the
5136
5567
  // unified post-process channel once the tonemap manifest entry's composed
5137
5568
  // WGSL is resolved. Invoked with the tonemap WGSL source string; the outer
@@ -5141,6 +5572,8 @@ async function buildReadyWebGPU(
5141
5572
  // tonemap source resolves inside this async function (after the manifest-load
5142
5573
  // await), by which point the synchronous `renderSystem` const is defined.
5143
5574
  registerBuiltinTonemap: (source: string) => void,
5575
+ /** Register the special FXAA pass' final-output policy params UBO. */
5576
+ registerBuiltinFxaa: (source: string) => void,
5144
5577
  registerBuiltinTemporalPostProcesses: (entries: {
5145
5578
  readonly motionBlur?: string;
5146
5579
  readonly taaResolve?: string;
@@ -5167,7 +5600,6 @@ async function buildReadyWebGPU(
5167
5600
  const projectorAvailable =
5168
5601
  (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
5169
5602
  STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
5170
-
5171
5603
  // bug-20260612: choose swap-chain storage / view formats by backend.
5172
5604
  // Channel 2 (native WebGPU) follows navigator.gpu.getPreferredCanvasFormat();
5173
5605
  // Channel 3 (wgpu-wasm GLES, storageBufferCapable=false) hard-codes
@@ -5238,6 +5670,10 @@ async function buildReadyWebGPU(
5238
5670
  let unlitModule: ShaderModule | null = null;
5239
5671
  let spriteModule: ShaderModule | null = null;
5240
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;
5241
5677
  let fxaaModule: ShaderModule | null = null;
5242
5678
  let skyboxModule: ShaderModule | null = null;
5243
5679
  let bloomBrightModule: ShaderModule | null = null;
@@ -5456,22 +5892,25 @@ async function buildReadyWebGPU(
5456
5892
  // CLUSTER_FORWARD_AVAILABLE in PR #344) only emits compound keys like
5457
5893
  // `CLUSTER_FORWARD_AVAILABLE=false+STORAGE_BUFFER_AVAILABLE=false`, so
5458
5894
  // findVariantByKey('STORAGE_BUFFER_AVAILABLE=false') silently misses.
5459
- // The fallback path forces isHdrpActive=false (Step 1b above), so the
5460
- // matching PBR variant is the (CFA=false, SBA=false) one pick by
5461
- // defines fields and prefer CLUSTER_FORWARD_AVAILABLE=false when present;
5462
- // 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.
5463
5898
  const idToVariantWgsl = new Map<string, string>();
5464
5899
  for (const ms of registry.materialShaderManifestEntries()) {
5465
5900
  const exact = ms.variants.find(
5466
- (v) =>
5467
- v.defines.STORAGE_BUFFER_AVAILABLE === false &&
5468
- (!('CLUSTER_FORWARD_AVAILABLE' in v.defines) ||
5469
- v.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
5470
- (!('VERTEX_COLOR_AVAILABLE' in v.defines) ||
5471
- v.defines.VERTEX_COLOR_AVAILABLE === false) &&
5472
- (!('PROJECTOR_AVAILABLE' in v.defines) ||
5473
- v.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
5474
- (!('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),
5475
5914
  );
5476
5915
  const v = exact === undefined ? undefined : findVariantByKey(ms, exact.definesKey);
5477
5916
  if (v !== undefined) {
@@ -5500,8 +5939,8 @@ async function buildReadyWebGPU(
5500
5939
  }
5501
5940
  }
5502
5941
  // The flat `registry.entries()` map carries the canonical composed source,
5503
- // while the boot layout is the URP/device-capability contract. Select the
5504
- // 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
5505
5944
  // WebGPU. Without this step the canonical PBR/Unlit source declares the
5506
5945
  // optional COLOR_0 vertex input while the boot SPEC_CONST layouts for
5507
5946
  // ordinary meshes do not provide location 13.
@@ -5511,13 +5950,16 @@ async function buildReadyWebGPU(
5511
5950
  (variant) =>
5512
5951
  variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
5513
5952
  (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
5514
- variant.defines.CLUSTER_FORWARD_AVAILABLE === false) &&
5953
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === standardBootClusterAxis()) &&
5515
5954
  (!('VERTEX_COLOR_AVAILABLE' in variant.defines) ||
5516
5955
  variant.defines.VERTEX_COLOR_AVAILABLE === false) &&
5517
5956
  (!('PROJECTOR_AVAILABLE' in variant.defines) ||
5518
5957
  variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
5519
5958
  (!('SKINNING_DISABLED' in variant.defines) ||
5520
5959
  variant.defines.SKINNING_DISABLED === true) &&
5960
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
5961
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE ===
5962
+ directionalPcssCapable(rhiDevice.caps.backendKind)) &&
5521
5963
  (!('TRANSMISSION_AVAILABLE' in variant.defines) ||
5522
5964
  variant.defines.TRANSMISSION_AVAILABLE === false),
5523
5965
  );
@@ -5650,6 +6092,27 @@ async function buildReadyWebGPU(
5650
6092
  seedShaderModule(label, shaderResult.value);
5651
6093
  }
5652
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
+
5653
6116
  // The volume graph is authored from ECS state and therefore cannot be
5654
6117
  // compiled while the static material manifest is assembled. Its pass
5655
6118
  // encoder nevertheless uses the same shared lazy shader adapter, so the
@@ -5735,6 +6198,7 @@ async function buildReadyWebGPU(
5735
6198
  );
5736
6199
  if (!pbrShaderResult.ok) throw pbrShaderResult.error;
5737
6200
  pbrModule = pbrShaderResult.value;
6201
+ pbrGroup2Contract = resolvePipelineGroup2Contract(pbrEntry.wgsl);
5738
6202
  // feat-20260629-multi-uv-set-support: a real extra-UV mesh creates a
5739
6203
  // layout-specific material PSO after the boot-time standard-layout PSO
5740
6204
  // has been pre-warmed. Seed the shared shader-module adapter under the
@@ -5758,6 +6222,7 @@ async function buildReadyWebGPU(
5758
6222
  ? selectStandardPbrTransmissionPrewarmVariants(
5759
6223
  pbrManifestEntry,
5760
6224
  storageBufferCapable,
6225
+ directionalPcssCapable(rhiDevice.caps.backendKind),
5761
6226
  projectorAvailable,
5762
6227
  )
5763
6228
  : [];
@@ -5797,6 +6262,7 @@ async function buildReadyWebGPU(
5797
6262
  pbrManifestEntry,
5798
6263
  storageBufferCapable,
5799
6264
  transmissionCapable,
6265
+ directionalPcssCapable(rhiDevice.caps.backendKind),
5800
6266
  projectorAvailable,
5801
6267
  )) {
5802
6268
  const moduleLabel = `module-forgeax::default-standard-pbr#${hdrpVariant.definesKey}`;
@@ -5824,6 +6290,43 @@ async function buildReadyWebGPU(
5824
6290
  seedShaderModule(moduleLabel, hdrpModule);
5825
6291
  }
5826
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
+
5827
6330
  // M2-07-c: Materials.standard with an explicit renderState reaches the
5828
6331
  // lazy material pipeline path, whose module label includes the requested
5829
6332
  // variant set. Reuse the boot-compiled PBR module only for manifest
@@ -5848,6 +6351,7 @@ async function buildReadyWebGPU(
5848
6351
  );
5849
6352
  if (!unlitShaderResult.ok) throw unlitShaderResult.error;
5850
6353
  unlitModule = unlitShaderResult.value;
6354
+ unlitGroup2Contract = resolvePipelineGroup2Contract(unlitEntry.wgsl);
5851
6355
  // Seed the shared lazy adapter with the eagerly compiled module so
5852
6356
  // prepared color-only PSOs build synchronously on their first frame.
5853
6357
  seedShaderModule('unlit', unlitModule);
@@ -5994,7 +6498,8 @@ async function buildReadyWebGPU(
5994
6498
  // frames (e.g. the hello-tonemap dawn smoke) would stall on a black frame
5995
6499
  // forever. Mirror the shadow_caster prewarm: await-compile the tonemap module
5996
6500
  // here and `seedShaderModule` it under the exact label
5997
- // `buildPostProcessPipeline` requests (`post-process-${id}-module`), so the
6501
+ // `buildPostProcessPipeline` requests the content-addressed module label,
6502
+ // so the
5998
6503
  // first `getPostProcessPipeline(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, …)` hits the module cache
5999
6504
  // synchronously and builds the pipeline on frame 1.
6000
6505
  const tonemapPrewarm = await runShimStep(
@@ -6002,11 +6507,17 @@ async function buildReadyWebGPU(
6002
6507
  asyncCreateShaderModule
6003
6508
  ? asyncCreateShaderModule(rhiDevice, {
6004
6509
  code: tonemapEntry.wgsl,
6005
- label: `post-process-${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}-module`,
6510
+ label: postProcessShaderModuleLabel(
6511
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
6512
+ tonemapEntry.wgsl,
6513
+ ),
6006
6514
  })
6007
6515
  : invokeDeviceCreateShaderModule(rhiDevice, {
6008
6516
  code: tonemapEntry.wgsl,
6009
- label: `post-process-${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}-module`,
6517
+ label: postProcessShaderModuleLabel(
6518
+ STANDARD_OUTPUT_TRANSFORM_FEATURE_ID,
6519
+ tonemapEntry.wgsl,
6520
+ ),
6010
6521
  }),
6011
6522
  'shader-compile-failed',
6012
6523
  'tonemap shader module compiled (unified post-process prewarm)',
@@ -6014,7 +6525,7 @@ async function buildReadyWebGPU(
6014
6525
  );
6015
6526
  if (!tonemapPrewarm.ok) throw tonemapPrewarm.error;
6016
6527
  seedShaderModule(
6017
- `post-process-${STANDARD_OUTPUT_TRANSFORM_FEATURE_ID}-module`,
6528
+ postProcessShaderModuleLabel(STANDARD_OUTPUT_TRANSFORM_FEATURE_ID, tonemapEntry.wgsl),
6018
6529
  tonemapPrewarm.value,
6019
6530
  );
6020
6531
  registerBuiltinTonemap(tonemapEntry.wgsl);
@@ -6031,18 +6542,18 @@ async function buildReadyWebGPU(
6031
6542
  asyncCreateShaderModule
6032
6543
  ? asyncCreateShaderModule(rhiDevice, {
6033
6544
  code: entry.wgsl,
6034
- label: `post-process-${id}-module`,
6545
+ label: postProcessShaderModuleLabel(id, entry.wgsl),
6035
6546
  })
6036
6547
  : invokeDeviceCreateShaderModule(rhiDevice, {
6037
6548
  code: entry.wgsl,
6038
- label: `post-process-${id}-module`,
6549
+ label: postProcessShaderModuleLabel(id, entry.wgsl),
6039
6550
  }),
6040
6551
  'shader-compile-failed',
6041
6552
  `${id} shader module compiled (unified post-process prewarm)`,
6042
6553
  `inspect manifest ${id} entry composed wgsl; check device.features`,
6043
6554
  );
6044
6555
  if (!prewarm.ok) throw prewarm.error;
6045
- seedShaderModule(`post-process-${id}-module`, prewarm.value);
6556
+ seedShaderModule(postProcessShaderModuleLabel(id, entry.wgsl), prewarm.value);
6046
6557
  };
6047
6558
  await prewarmTemporal(motionBlurEntry, 'forgeax.motion-blur');
6048
6559
  await prewarmTemporal(taaResolveEntry, 'forgeax.taa-resolve');
@@ -6071,6 +6582,7 @@ async function buildReadyWebGPU(
6071
6582
  );
6072
6583
  if (!spriteShaderResult.ok) throw spriteShaderResult.error;
6073
6584
  spriteModule = spriteShaderResult.value;
6585
+ spriteGroup2Contract = resolvePipelineGroup2Contract(spriteEntry.wgsl);
6074
6586
  // feat-20260625-refactor-sprite-as-transparent-mesh CI-fix: seed the
6075
6587
  // lazy adapter cache under the same label (`module-forgeax::sprite`)
6076
6588
  // that `getMaterialShaderPipeline -> buildAndCachePipeline ->
@@ -6141,7 +6653,44 @@ async function buildReadyWebGPU(
6141
6653
  );
6142
6654
  if (!spriteLitShaderResult.ok) throw spriteLitShaderResult.error;
6143
6655
  spriteLitModule = spriteLitShaderResult.value;
6656
+ spriteLitGroup2Contract = resolvePipelineGroup2Contract(spriteLitEntryConst.wgsl);
6144
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
+ }
6145
6694
  }
6146
6695
 
6147
6696
  // feat-20260528-fxaa-post-processing: fxaa shader module is optional
@@ -6164,6 +6713,7 @@ async function buildReadyWebGPU(
6164
6713
  );
6165
6714
  if (!fxaaShaderResult.ok) throw fxaaShaderResult.error;
6166
6715
  fxaaModule = fxaaShaderResult.value;
6716
+ registerBuiltinFxaa(fxaaEntry.wgsl);
6167
6717
  }
6168
6718
 
6169
6719
  // feat-20260531-skybox-env-background M3 / w15: skybox shader module.
@@ -6316,15 +6866,14 @@ async function buildReadyWebGPU(
6316
6866
  // BGL at group(2) so an HDRP-variant PSO validates against the 7-slot
6317
6867
  // group(2) bindGroup that the record stage sets via hdrpClusterBindGroup.
6318
6868
  // Stays null when createBindGroupLayout / createPipelineLayout fails;
6319
- // selectPipelineLayoutForVariant gracefully falls back to pbrPipelineLayout
6320
- // (URP layout) so the manifest entry's URP variant WGSL still builds a
6321
- // 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.
6322
6871
  let hdrpPbrPipelineLayoutHandle: PipelineLayout | null = null;
6323
6872
  // The cluster layout is storage-buffer backed. On the WebGL2/fallback
6324
6873
  // capability tier its uniform-buffer projection exceeds the backend's
6325
6874
  // fragment-stage limit, and HDRP is not a valid route there anyway.
6326
6875
  if (storageBufferCapable) {
6327
- const hdrpUnifiedBglDesc = createHdrpBindGroupLayoutDescriptor(storageBufferCapable);
6876
+ const hdrpUnifiedBglDesc = createHdrpBindGroupLayoutDescriptor();
6328
6877
  const hdrpUnifiedBglRes = rhiDevice.createBindGroupLayout(hdrpUnifiedBglDesc);
6329
6878
  if (hdrpUnifiedBglRes.ok) {
6330
6879
  const hdrpPlRes = rhiDevice.createPipelineLayout({
@@ -6395,23 +6944,26 @@ async function buildReadyWebGPU(
6395
6944
  // bug-20260611-skin-pipeline-layout-mesh-array-bgl-2bindings: boot-time
6396
6945
  // build of the skin-variant PipelineLayout. Mirrors the HDRP block above
6397
6946
  // (D-2 / D-3 in plan-strategy): reuse view / material / instances BGLs
6398
- // from `pbrLayouts` and only create a 2-entry mesh-array BGL (binding 0
6399
- // 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
6400
6950
  // `@group(2) @binding(1) palette` declaration validates. Stays null when
6401
6951
  // createBindGroupLayout / createPipelineLayout fails;
6402
6952
  // selectPipelineLayoutForVariant returns null in that case (charter P3
6403
6953
  // explicit failure -- no silent fallback to URP layout, mirroring memory
6404
6954
  // anchor `hdrp-active-must-not-fallback-to-urp-pipeline`).
6405
6955
  let pbrSkinPipelineLayoutHandle: PipelineLayout | null = null;
6406
- // 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
6407
6957
  // produced by `buildPbrSkinLayouts` so the record stage can build a BG
6408
- // 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
6409
6959
  // BGL, NOT the 1-entry `pbr-mesh-array-bgl`). The BGL handle stays null
6410
6960
  // when the skin pipeline layout itself failed to build, keeping the skin
6411
6961
  // path explicitly disabled (charter P3 — record-stage falls back to URP
6412
6962
  // path which is correct for non-skin entries; skin entries hit the
6413
6963
  // explicit-failure branch).
6414
6964
  let pbrSkinMeshBindGroupLayoutHandle: BindGroupLayout | null = null;
6965
+ let hdrpSkinPipelineLayoutHandle: PipelineLayout | null = null;
6966
+ let hdrpSkinMeshBindGroupLayoutHandle: BindGroupLayout | null = null;
6415
6967
  {
6416
6968
  const skinLayoutsResult = runShimSyncStep(
6417
6969
  () =>
@@ -6427,6 +6979,29 @@ async function buildReadyWebGPU(
6427
6979
  pbrSkinMeshBindGroupLayoutHandle = skinLayoutsResult.value.meshArrayBgl;
6428
6980
  }
6429
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
+ }
6430
7005
 
6431
7006
  // feat-20260612-skin-palette-per-frame-upload M1 / m1-2: skin palette
6432
7007
  // allocator. Replaces the prior 16320 B identity-seeded UBO stub (PR #353,
@@ -7303,17 +7878,20 @@ async function buildReadyWebGPU(
7303
7878
  fragmentEntryPoint?: string;
7304
7879
  layout?: unknown;
7305
7880
  label?: string;
7881
+ group2Contract?: PipelineGroup2Contract;
7306
7882
  }
7307
7883
  >();
7308
7884
  if (unlitModule !== null)
7309
7885
  shaderModuleMap.set('forgeax::default-unlit', {
7310
7886
  vertex: unlitModule,
7311
7887
  fragment: unlitModule,
7888
+ ...(unlitGroup2Contract === undefined ? {} : { group2Contract: unlitGroup2Contract }),
7312
7889
  });
7313
7890
  if (pbrModule !== null)
7314
7891
  shaderModuleMap.set('forgeax::default-standard-pbr', {
7315
7892
  vertex: pbrModule,
7316
7893
  fragment: pbrModule,
7894
+ ...(pbrGroup2Contract === undefined ? {} : { group2Contract: pbrGroup2Contract }),
7317
7895
  });
7318
7896
  if (spriteModule !== null)
7319
7897
  // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w14 (D-7):
@@ -7325,6 +7903,7 @@ async function buildReadyWebGPU(
7325
7903
  shaderModuleMap.set('forgeax::sprite', {
7326
7904
  vertex: spriteModule,
7327
7905
  fragment: spriteModule,
7906
+ ...(spriteGroup2Contract === undefined ? {} : { group2Contract: spriteGroup2Contract }),
7328
7907
  });
7329
7908
  // feat-20260624-sprite-lit-shading-model-pure-2d-lighting M1' / t7:
7330
7909
  // sprite-lit shader registers under canonical `forgeax::sprite-lit` —
@@ -7333,6 +7912,7 @@ async function buildReadyWebGPU(
7333
7912
  shaderModuleMap.set('forgeax::sprite-lit', {
7334
7913
  vertex: spriteLitModule,
7335
7914
  fragment: spriteLitModule,
7915
+ ...(spriteLitGroup2Contract === undefined ? {} : { group2Contract: spriteLitGroup2Contract }),
7336
7916
  });
7337
7917
  // M2-T4: fullscreen-post shader modules (tonemap + skybox) are registered
7338
7918
  // in their respective blocks after pipeline layout creation — layout is
@@ -7434,7 +8014,6 @@ async function buildReadyWebGPU(
7434
8014
  ...runtimeSpecConstTable,
7435
8015
  ...runtimeLinearLdrMaterialSpecTable,
7436
8016
  ];
7437
-
7438
8017
  // Boot-time pre-warm: build SPEC_CONST entries whose shader modules are
7439
8018
  // compiled. Entries referencing a missing module are silently skipped
7440
8019
  // (the empty-manifest path — D-3). Each build failure for an available
@@ -7467,7 +8046,7 @@ async function buildReadyWebGPU(
7467
8046
  // `makeWebGPURenderer` scope) from the prewarmed `pipelineCache` for
7468
8047
  // SPEC_CONST entries whose `variantSet !== undefined`. The URP record
7469
8048
  // path queries `getMaterialShaderPipeline(...)` keyed off
7470
- // `cacheKeyOf(spec)` with `variantSet=URP_PBR_VARIANT_SET`; both caches
8049
+ // `cacheKeyOf(spec)` with the non-clustered boot variant; both caches
7471
8050
  // generate keys via the same `cacheKeyOf` so the lookup hits the
7472
8051
  // boot-time prewarmed PSO instead of triggering a 1-frame async-compile
7473
8052
  // skip-draw. Seeding only variantSet-bearing entries keeps the
@@ -7477,11 +8056,12 @@ async function buildReadyWebGPU(
7477
8056
  // explicit instead of collapsing into one map (charter P3).
7478
8057
  for (const spec of runtimeSpecConstTable) {
7479
8058
  if (spec.shader.variantSet === undefined) continue;
7480
- if (shaderModuleMap.get(spec.shader.id) === undefined) continue;
8059
+ const modules = shaderModuleMap.get(spec.shader.id);
8060
+ if (modules === undefined) continue;
7481
8061
  const key = cacheKeyOf(spec);
7482
8062
  const built = pipelineCache.get(key);
7483
- if (built !== undefined) {
7484
- seedMaterialShaderPipelineCache(key, built as RenderPipeline);
8063
+ if (built !== undefined && modules.group2Contract !== undefined) {
8064
+ seedMaterialShaderPipelineCache(key, built as RenderPipeline, modules.group2Contract);
7485
8065
  }
7486
8066
  }
7487
8067
 
@@ -7489,11 +8069,12 @@ async function buildReadyWebGPU(
7489
8069
  // swap-chain view target. Seed every material entry, including no-variant
7490
8070
  // unlit, because tonemap=none reaches this cache directly on frame one.
7491
8071
  for (const spec of runtimeLinearLdrMaterialSpecTable) {
7492
- if (shaderModuleMap.get(spec.shader.id) === undefined) continue;
8072
+ const modules = shaderModuleMap.get(spec.shader.id);
8073
+ if (modules === undefined) continue;
7493
8074
  const key = cacheKeyOf(spec);
7494
8075
  const built = pipelineCache.get(key);
7495
- if (built !== undefined) {
7496
- seedMaterialShaderPipelineCache(key, built as RenderPipeline);
8076
+ if (built !== undefined && modules.group2Contract !== undefined) {
8077
+ seedMaterialShaderPipelineCache(key, built as RenderPipeline, modules.group2Contract);
7497
8078
  }
7498
8079
  }
7499
8080
  }
@@ -7701,15 +8282,15 @@ async function buildReadyWebGPU(
7701
8282
 
7702
8283
  // feat-20260528-fxaa-post-processing M2 / w10: FXAA pipeline prebuilt.
7703
8284
  // When the manifest contains the fxaa entry (rgb2luma marker, D-5),
7704
- // construct the 2-entry BGL (texture + sampler, no UBO per D-2),
8285
+ // construct the 3-entry BGL (texture + sampler + output-policy UBO),
7705
8286
  // pipeline layout, fullscreen render pipeline (vertex = fullscreen
7706
8287
  // triangle from fxaa.wgsl, fragment = FXAA 3.11 algorithm), and
7707
8288
  // linear clamp-to-edge sampler. Mirrors the tonemap pipeline
7708
8289
  // construction pattern directly above.
7709
8290
  if (fxaaModule !== null) {
7710
- // FXAA BindGroupLayout: 2 entries (texture + sampler, no UBO).
7711
- // D-2: the fxaa.wgsl fragment stage declares @binding(0) texture_2d<f32>
7712
- // + @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.
7713
8294
  const fxaaBglResult = runShimSyncStep(
7714
8295
  () =>
7715
8296
  rhiDevice.createBindGroupLayout({
@@ -7725,6 +8306,11 @@ async function buildReadyWebGPU(
7725
8306
  visibility: GPU_SHADER_STAGE_FRAGMENT,
7726
8307
  sampler: { type: 'filtering' },
7727
8308
  },
8309
+ {
8310
+ binding: 2,
8311
+ visibility: GPU_SHADER_STAGE_FRAGMENT,
8312
+ buffer: { type: 'uniform' },
8313
+ },
7728
8314
  ],
7729
8315
  }),
7730
8316
  'webgpu-runtime-error',
@@ -8760,6 +9346,7 @@ async function buildReadyWebGPU(
8760
9346
  }
8761
9347
 
8762
9348
  const pipelineState: PipelineState = {
9349
+ device: rhiDevice,
8763
9350
  // feat-20260518-pbr-direct-lighting-mvp M5 / w22.10 (AC-06 dual->triple
8764
9351
  // pipeline + D-2 + D-10): three distinct GPU render-pipeline handles
8765
9352
  // backed by 2 distinct shader modules (unlit + pbr) x 2 vertex stride
@@ -8814,7 +9401,7 @@ async function buildReadyWebGPU(
8814
9401
  hdrpClusterMembershipPipeline,
8815
9402
  hdrpClusterMembershipBindGroupLayout,
8816
9403
  // bug-20260611-skin-pipeline-layout: skin-variant PipelineLayout (4-BGL
8817
- // 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
8818
9405
  // mesh-array BGL). Built at boot above (see pbrSkinPipelineLayoutHandle);
8819
9406
  // gated on the same unlit/pbr module presence so Camera-only and empty-
8820
9407
  // manifest paths see null. Selected per call by
@@ -8826,13 +9413,17 @@ async function buildReadyWebGPU(
8826
9413
  // Mirror the gating used by `pbrSkinPipelineLayout` -- both fields
8827
9414
  // come from the same `buildPbrSkinLayouts` call so they live or die
8828
9415
  // together. Surfaced on PipelineState so render-system-record can
8829
- // 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
8830
9417
  // `pbr-mesh-bg` + `pbr-mesh-array-bgl`, which the device rejected
8831
9418
  // every frame with `Bind group layout pbr-skin-mesh-array-bgl ... does
8832
9419
  // not match layout pbr-mesh-array-bgl` + Invalid CommandBuffer +
8833
9420
  // queue-submit-failed).
8834
9421
  pbrSkinMeshBindGroupLayout:
8835
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,
8836
9427
  // feat-20260612-skin-palette-per-frame-upload M1 / m1-2: animator-ready
8837
9428
  // skin-palette allocator (replaces the prior identity-buffer stub).
8838
9429
  // Same gating as `pbrSkinPipelineLayout` -- `null` when the skin