@forgeax/engine-render 0.1.23 → 0.1.24

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