@forgeax/engine-render 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/README.md +231 -10
  2. package/dist/assembly/bundler-contract.d.ts +9 -0
  3. package/dist/assembly/bundler-contract.d.ts.map +1 -0
  4. package/dist/assembly/device-feature-admission.d.ts +8 -0
  5. package/dist/assembly/device-feature-admission.d.ts.map +1 -1
  6. package/dist/assembly/extended-lighting-fallback.d.ts +13 -0
  7. package/dist/assembly/extended-lighting-fallback.d.ts.map +1 -0
  8. package/dist/assembly/factory.d.ts +7 -243
  9. package/dist/assembly/factory.d.ts.map +1 -1
  10. package/dist/assembly/material/assembly.d.ts +3 -2
  11. package/dist/assembly/material/assembly.d.ts.map +1 -1
  12. package/dist/assembly/material/standard-layer-projection.d.ts +10 -0
  13. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -0
  14. package/dist/assembly/material/surface-projection.d.ts +24 -0
  15. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  16. package/dist/assembly/material-shader-policy.d.ts +188 -0
  17. package/dist/assembly/material-shader-policy.d.ts.map +1 -0
  18. package/dist/assembly/material-shader-prewarm.d.ts +21 -0
  19. package/dist/assembly/material-shader-prewarm.d.ts.map +1 -0
  20. package/dist/assembly/mesh-ssbo-grow.d.ts +88 -0
  21. package/dist/assembly/mesh-ssbo-grow.d.ts.map +1 -0
  22. package/dist/assembly/recovery/device-loss-fanout.d.ts +14 -0
  23. package/dist/assembly/recovery/device-loss-fanout.d.ts.map +1 -0
  24. package/dist/assembly/recovery/generation.d.ts +46 -0
  25. package/dist/assembly/recovery/generation.d.ts.map +1 -0
  26. package/dist/assembly/recovery/recovery-attempt.d.ts +63 -0
  27. package/dist/assembly/recovery/recovery-attempt.d.ts.map +1 -0
  28. package/dist/assembly/recovery/renderer-recover.d.ts +115 -0
  29. package/dist/assembly/recovery/renderer-recover.d.ts.map +1 -0
  30. package/dist/assembly/renderer-facade.d.ts +10 -0
  31. package/dist/assembly/renderer-facade.d.ts.map +1 -0
  32. package/dist/assembly/renderer-frame-transaction.d.ts +17 -0
  33. package/dist/assembly/renderer-frame-transaction.d.ts.map +1 -1
  34. package/dist/assembly/renderer-helpers.d.ts +19 -0
  35. package/dist/assembly/renderer-helpers.d.ts.map +1 -0
  36. package/dist/assembly/renderer-lifecycle.d.ts +34 -0
  37. package/dist/assembly/renderer-lifecycle.d.ts.map +1 -1
  38. package/dist/assembly/shader-prewarm-policy.d.ts +15 -0
  39. package/dist/assembly/shader-prewarm-policy.d.ts.map +1 -0
  40. package/dist/assembly/webgpu-ready-contract.d.ts +9 -0
  41. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -0
  42. package/dist/assembly/webgpu-ready.d.ts +55 -0
  43. package/dist/assembly/webgpu-ready.d.ts.map +1 -0
  44. package/dist/assembly/webgpu-renderer.d.ts +152 -0
  45. package/dist/assembly/webgpu-renderer.d.ts.map +1 -0
  46. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  47. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  48. package/dist/assets/asset-decoders.d.ts.map +1 -1
  49. package/dist/authoring.mjs +1 -1
  50. package/dist/{chunk-FF7K3FSO.mjs → chunk-ADHHVYLW.mjs} +89 -89
  51. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  52. package/dist/{chunk-YALPS44A.mjs → chunk-E34VL5VI.mjs} +47 -47
  53. package/dist/chunk-E34VL5VI.mjs.map +1 -0
  54. package/dist/{chunk-4L3P6DT5.mjs → chunk-GE3SDD72.mjs} +37 -5
  55. package/dist/chunk-GE3SDD72.mjs.map +1 -0
  56. package/dist/{chunk-HZUPSYW7.mjs → chunk-HKXTW355.mjs} +105 -15
  57. package/dist/chunk-HKXTW355.mjs.map +1 -0
  58. package/dist/chunk-JSEDTZYS.mjs +413 -0
  59. package/dist/chunk-JSEDTZYS.mjs.map +1 -0
  60. package/dist/{chunk-FBC4R44J.mjs → chunk-KM2NOX2I.mjs} +57 -57
  61. package/dist/chunk-KM2NOX2I.mjs.map +1 -0
  62. package/dist/{chunk-DIYW3M5D.mjs → chunk-N3RT2EUR.mjs} +2288 -2238
  63. package/dist/chunk-N3RT2EUR.mjs.map +1 -0
  64. package/dist/{chunk-ZUWKAZ2I.mjs → chunk-TCLDX7SZ.mjs} +1167 -368
  65. package/dist/chunk-TCLDX7SZ.mjs.map +1 -0
  66. package/dist/{chunk-QODA5UKU.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  67. package/dist/chunk-ZZ4YQ474.mjs.map +1 -0
  68. package/dist/components/instances.d.ts +13 -31
  69. package/dist/components/instances.d.ts.map +1 -1
  70. package/dist/construct-renderer.mjs +20688 -16314
  71. package/dist/construct-renderer.mjs.map +1 -1
  72. package/dist/device/device-scope.d.ts +2 -0
  73. package/dist/device/device-scope.d.ts.map +1 -1
  74. package/dist/device/gpu-residency.d.ts +24 -4
  75. package/dist/device/gpu-residency.d.ts.map +1 -1
  76. package/dist/environment/frame.d.ts +5 -0
  77. package/dist/environment/frame.d.ts.map +1 -1
  78. package/dist/environment/inspection.d.ts +7 -0
  79. package/dist/environment/inspection.d.ts.map +1 -1
  80. package/dist/environment/lifecycle.d.ts +20 -2
  81. package/dist/environment/lifecycle.d.ts.map +1 -1
  82. package/dist/errors/recover.d.ts +58 -2
  83. package/dist/errors/recover.d.ts.map +1 -1
  84. package/dist/errors/render.d.ts +13 -0
  85. package/dist/errors/render.d.ts.map +1 -1
  86. package/dist/extract/contracts.d.ts +899 -0
  87. package/dist/extract/contracts.d.ts.map +1 -0
  88. package/dist/features/host.d.ts +2 -0
  89. package/dist/features/host.d.ts.map +1 -1
  90. package/dist/features/prepared-gpu-work.d.ts +3 -0
  91. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  92. package/dist/features/prepared-graphics-store.d.ts +2 -0
  93. package/dist/features/prepared-graphics-store.d.ts.map +1 -1
  94. package/dist/gpu-driven/production-raster.d.ts +34 -1
  95. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  96. package/dist/gpu-scene.d.ts +5 -0
  97. package/dist/gpu-scene.d.ts.map +1 -1
  98. package/dist/ibl/IblPipelineCache.d.ts +7 -6
  99. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  100. package/dist/ibl/skylight-bind-group.d.ts +7 -1
  101. package/dist/ibl/skylight-bind-group.d.ts.map +1 -1
  102. package/dist/index.d.ts +2 -1
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.mjs +27 -9
  105. package/dist/index.mjs.map +1 -1
  106. package/dist/instance-buffer-cache.d.ts +7 -1
  107. package/dist/instance-buffer-cache.d.ts.map +1 -1
  108. package/dist/instances.d.ts +101 -0
  109. package/dist/instances.d.ts.map +1 -0
  110. package/dist/internal.mjs +6 -6
  111. package/dist/light-buffer-layout.d.ts +3 -0
  112. package/dist/light-buffer-layout.d.ts.map +1 -1
  113. package/dist/materials.d.ts +96 -11
  114. package/dist/materials.d.ts.map +1 -1
  115. package/dist/mesh-material-bindings.d.ts +56 -0
  116. package/dist/mesh-material-bindings.d.ts.map +1 -1
  117. package/dist/occlusion-proxy.d.ts +4 -0
  118. package/dist/occlusion-proxy.d.ts.map +1 -0
  119. package/dist/pbr-pipeline.d.ts +32 -9
  120. package/dist/pbr-pipeline.d.ts.map +1 -1
  121. package/dist/pipeline-builder.d.ts.map +1 -1
  122. package/dist/pipeline-spec.d.ts.map +1 -1
  123. package/dist/points-lines/prepare.d.ts +3 -0
  124. package/dist/points-lines/prepare.d.ts.map +1 -1
  125. package/dist/points-lines/standard-owner.d.ts +33 -0
  126. package/dist/points-lines/standard-owner.d.ts.map +1 -0
  127. package/dist/record/frame-snapshot.d.ts +18 -2
  128. package/dist/record/frame-snapshot.d.ts.map +1 -1
  129. package/dist/record/frame-targets.d.ts.map +1 -1
  130. package/dist/record/frame.d.ts +14 -2
  131. package/dist/record/frame.d.ts.map +1 -1
  132. package/dist/record/main-pass-geometry.d.ts +36 -4
  133. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  134. package/dist/record/main-pass-material.d.ts +7 -17
  135. package/dist/record/main-pass-material.d.ts.map +1 -1
  136. package/dist/record/main-pass.d.ts.map +1 -1
  137. package/dist/record/mesh-ssbo.d.ts +11 -3
  138. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  139. package/dist/record/recovery-pipeline.d.ts +30 -0
  140. package/dist/record/recovery-pipeline.d.ts.map +1 -0
  141. package/dist/record/render-context.d.ts +28 -5
  142. package/dist/record/render-context.d.ts.map +1 -1
  143. package/dist/record/shadow-pass.d.ts +11 -1
  144. package/dist/record/shadow-pass.d.ts.map +1 -1
  145. package/dist/record/typed-frame-graph.d.ts +1 -1
  146. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  147. package/dist/recovery/render-system-candidate.d.ts +65 -0
  148. package/dist/recovery/render-system-candidate.d.ts.map +1 -0
  149. package/dist/render-contract.d.ts +68 -7
  150. package/dist/render-contract.d.ts.map +1 -1
  151. package/dist/render-system-extract-tail.d.ts +16 -0
  152. package/dist/render-system-extract-tail.d.ts.map +1 -0
  153. package/dist/render-system-extract.d.ts +180 -22
  154. package/dist/render-system-extract.d.ts.map +1 -1
  155. package/dist/render-system.d.ts +131 -4
  156. package/dist/render-system.d.ts.map +1 -1
  157. package/dist/scene/render-scene-types.d.ts +2 -0
  158. package/dist/scene/render-scene-types.d.ts.map +1 -1
  159. package/dist/scene/render-scene.d.ts +31 -1
  160. package/dist/scene/render-scene.d.ts.map +1 -1
  161. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  162. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  163. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  164. package/dist/temporal/index.mjs +6 -6
  165. package/dist/temporal/taa-history-store.d.ts +15 -1
  166. package/dist/temporal/taa-history-store.d.ts.map +1 -1
  167. package/dist/temporal/transaction.d.ts +5 -0
  168. package/dist/temporal/transaction.d.ts.map +1 -1
  169. package/package.json +20 -20
  170. package/src/__tests__/device-feature-admission.unit.test.ts +6 -0
  171. package/src/__tests__/device-loss-fanout.unit.test.ts +107 -0
  172. package/src/__tests__/device-scope-lifecycle.unit.test.ts +21 -0
  173. package/src/__tests__/environment-generation.integration.test.ts +23 -0
  174. package/src/__tests__/expose-renderer-errors.unit.test.ts +34 -0
  175. package/src/__tests__/factory-contract.integration.test.ts +122 -7
  176. package/src/__tests__/fallback-row-stride-surface.unit.test.ts +4 -1
  177. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  178. package/src/__tests__/ibl/face-uniforms.unit.test.ts +2 -0
  179. package/src/__tests__/ibl-residency-publication.unit.test.ts +113 -0
  180. package/src/__tests__/instances-store.unit.test.ts +46 -0
  181. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  182. package/src/__tests__/material-contract-inventory.unit.test.ts +29 -22
  183. package/src/__tests__/material-cooked-projection.unit.test.ts +19 -1
  184. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  185. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  186. package/src/__tests__/material-static-dynamic-route.unit.test.ts +29 -0
  187. package/src/__tests__/material-stride-surface.unit.test.ts +7 -3
  188. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  189. package/src/__tests__/materials-standard-contract.unit.test.ts +104 -0
  190. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  191. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -5
  192. package/src/__tests__/physical-clearcoat.integration.test.ts +89 -0
  193. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +1 -1
  194. package/src/__tests__/prepared-graphics-pipeline-warmup.unit.test.ts +1 -1
  195. package/src/__tests__/recover-error-policy-owner.unit.test.ts +6 -3
  196. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +140 -0
  197. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +127 -18
  198. package/src/__tests__/renderer-frame-generation-fence.integration.test.ts +47 -0
  199. package/src/__tests__/renderer-generation-assembly.unit.test.ts +77 -0
  200. package/src/__tests__/renderer-generation-failure.unit.test.ts +108 -0
  201. package/src/__tests__/renderer-producer-root-matrix.integration.test.ts +33 -0
  202. package/src/__tests__/renderer-recovery-capability-matrix.unit.test.ts +59 -0
  203. package/src/__tests__/renderer-recovery-contract.unit.test.ts +73 -0
  204. package/src/__tests__/renderer-recovery-lifecycle.unit.test.ts +216 -0
  205. package/src/__tests__/renderer-target-history-recovery.integration.test.ts +80 -0
  206. package/src/__tests__/shader-manifest-fixture.ts +13 -16
  207. package/src/__tests__/shader-stage-surface.unit.test.ts +1 -1
  208. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  209. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  210. package/src/__tests__/standard-layer-projection.unit.test.ts +23 -0
  211. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  212. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  213. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  214. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  215. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  216. package/src/__tests__/transmission-prewarm.unit.test.ts +95 -85
  217. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  218. package/src/__tests__/volumetric-fog-topology.unit.test.ts +3 -1
  219. package/src/assembly/bundler-contract.ts +9 -0
  220. package/src/assembly/device-feature-admission.ts +15 -5
  221. package/src/assembly/extended-lighting-fallback.ts +216 -0
  222. package/src/assembly/factory.ts +83 -10088
  223. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  224. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  225. package/src/assembly/material/assembly.ts +18 -2
  226. package/src/assembly/material/standard-layer-projection.ts +16 -0
  227. package/src/assembly/material/surface-projection.ts +90 -0
  228. package/src/assembly/material-shader-policy.ts +932 -0
  229. package/src/assembly/material-shader-prewarm.ts +88 -0
  230. package/src/assembly/mesh-ssbo-grow.ts +270 -0
  231. package/src/assembly/recovery/device-loss-fanout.ts +89 -0
  232. package/src/assembly/recovery/generation.ts +141 -0
  233. package/src/assembly/recovery/recovery-attempt.ts +800 -0
  234. package/src/assembly/recovery/renderer-recover.ts +906 -0
  235. package/src/assembly/renderer-facade.ts +342 -0
  236. package/src/assembly/renderer-frame-transaction.ts +45 -0
  237. package/src/assembly/renderer-helpers.ts +99 -0
  238. package/src/assembly/renderer-lifecycle.ts +159 -0
  239. package/src/assembly/shader-prewarm-policy.ts +157 -0
  240. package/src/assembly/webgpu-ready-contract.ts +14 -0
  241. package/src/assembly/webgpu-ready.ts +4066 -0
  242. package/src/assembly/webgpu-renderer.ts +4027 -0
  243. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  244. package/src/assets/asset-decoders.ts +27 -4
  245. package/src/components/camera.ts +1 -1
  246. package/src/components/instances.ts +14 -197
  247. package/src/debug-draw-glue.ts +1 -1
  248. package/src/device/device-scope.ts +35 -6
  249. package/src/device/gpu-residency.ts +194 -26
  250. package/src/environment/frame.ts +6 -0
  251. package/src/environment/inspection.ts +10 -0
  252. package/src/environment/lifecycle.ts +114 -8
  253. package/src/errors/recover.ts +76 -5
  254. package/src/errors/render.ts +17 -0
  255. package/src/extract/contracts.ts +974 -0
  256. package/src/features/host.ts +16 -0
  257. package/src/features/prepared-gpu-work.ts +20 -2
  258. package/src/features/prepared-graphics-store.ts +17 -0
  259. package/src/gpu-driven/production-raster.ts +131 -3
  260. package/src/gpu-scene.ts +87 -31
  261. package/src/ibl/IblPipelineCache.ts +134 -50
  262. package/src/ibl/skylight-bind-group.ts +26 -0
  263. package/src/index.ts +10 -1
  264. package/src/instance-buffer-cache.ts +14 -1
  265. package/src/instances.ts +288 -0
  266. package/src/light-buffer-layout.ts +3 -0
  267. package/src/materials.ts +365 -53
  268. package/src/mesh-material-bindings.ts +59 -0
  269. package/src/occlusion-proxy.ts +64 -0
  270. package/src/pbr-pipeline.ts +139 -15
  271. package/src/pipeline-builder.ts +14 -10
  272. package/src/pipeline-spec.ts +6 -14
  273. package/src/points-lines/__tests__/cache-recovery.unit.test.ts +22 -0
  274. package/src/points-lines/prepare.ts +9 -0
  275. package/src/points-lines/standard-owner.ts +403 -0
  276. package/src/record/__tests__/frame-targets-recovery.unit.test.ts +60 -0
  277. package/src/record/__tests__/main-pass-geometry.unit.test.ts +26 -1
  278. package/src/record/__tests__/mesh-ssbo.unit.test.ts +38 -0
  279. package/src/record/__tests__/recovery-pipeline.unit.test.ts +124 -0
  280. package/src/record/__tests__/sprite-material-abi.unit.test.ts +28 -13
  281. package/src/record/__tests__/standard-pbr-ubo-layout.unit.test.ts +47 -4
  282. package/src/record/__tests__/temporal-payload.unit.test.ts +22 -43
  283. package/src/record/__tests__/typed-frame-graph-plan.unit.test.ts +1 -1
  284. package/src/record/frame-snapshot.ts +34 -2
  285. package/src/record/frame-targets.ts +11 -1
  286. package/src/record/frame.ts +37 -4
  287. package/src/record/helpers.ts +1 -1
  288. package/src/record/main-pass-geometry.ts +727 -63
  289. package/src/record/main-pass-material.ts +316 -209
  290. package/src/record/main-pass-sprite-draws.ts +7 -8
  291. package/src/record/main-pass.ts +94 -6
  292. package/src/record/mesh-ssbo.ts +32 -10
  293. package/src/record/recovery-pipeline.ts +695 -0
  294. package/src/record/render-context.ts +39 -4
  295. package/src/record/shadow-pass.ts +46 -99
  296. package/src/record/typed-frame-graph.ts +45 -1
  297. package/src/record/view-ubo.ts +3 -3
  298. package/src/recovery/render-system-candidate.ts +876 -0
  299. package/src/render-contract.ts +69 -7
  300. package/src/render-system-extract-tail.ts +3039 -0
  301. package/src/render-system-extract.ts +177 -3003
  302. package/src/render-system.ts +906 -662
  303. package/src/scene/render-scene-types.ts +2 -0
  304. package/src/scene/render-scene.ts +703 -37
  305. package/src/systems/skin-palette-allocator.ts +129 -7
  306. package/src/temporal/frame-coordinator.ts +4 -1
  307. package/src/temporal/taa-history-store.ts +46 -2
  308. package/src/temporal/temporal-view.ts +1 -1
  309. package/src/temporal/transaction.ts +9 -0
  310. package/src/tilemap-chunk-extract-system.ts +1 -1
  311. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +19 -12
  312. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  313. package/dist/chunk-4L3P6DT5.mjs.map +0 -1
  314. package/dist/chunk-4NPUASZR.mjs +0 -203
  315. package/dist/chunk-4NPUASZR.mjs.map +0 -1
  316. package/dist/chunk-DIYW3M5D.mjs.map +0 -1
  317. package/dist/chunk-FBC4R44J.mjs.map +0 -1
  318. package/dist/chunk-FF7K3FSO.mjs.map +0 -1
  319. package/dist/chunk-HZUPSYW7.mjs.map +0 -1
  320. package/dist/chunk-QODA5UKU.mjs.map +0 -1
  321. package/dist/chunk-YALPS44A.mjs.map +0 -1
  322. package/dist/chunk-ZUWKAZ2I.mjs.map +0 -1
@@ -0,0 +1,932 @@
1
+ // Material shader policy and async adapter.
2
+ // Variant selection, module caching, and manifest material preparation stay
3
+ // independent from renderer lifetime and generation-scoped ready state.
4
+
5
+ import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
6
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
7
+ import type { PipelineLayout, Result, RhiDevice, ShaderModule } from '@forgeax/engine-rhi';
8
+ import { err, ok, RhiError } from '@forgeax/engine-rhi';
9
+ import {
10
+ DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
11
+ findVariantByKey,
12
+ type MaterialShaderManifestEntry,
13
+ type ShaderCatalog,
14
+ type ShaderCatalogDevice,
15
+ STANDARD_PIPELINE_PARAM_SCHEMA,
16
+ standardPhysicalTextureFields,
17
+ } from '@forgeax/engine-shader';
18
+ import type { AssetGuid as AssetGuidType, ParamSchemaEntry } from '@forgeax/engine-types';
19
+ import { derive } from '@forgeax/engine-types';
20
+ import type { RenderFeatureMaterialShaderBindingContract } from '../features/plan';
21
+ import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../features/prepared-graphics';
22
+ import type { RhiErrorListenerRegistry } from '../lifecycle';
23
+ import { assertStorageBufferCap } from '../light-buffer-layout';
24
+ import { buildPbrMaterialUserRegionEntries } from '../pbr-pipeline';
25
+ import {
26
+ deriveExtendedLightingCapability,
27
+ extendedLightingSampledTextureCapacityAvailable,
28
+ } from '../prepare/extended-lighting/resources';
29
+ import { STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES } from './shader-prewarm-policy';
30
+
31
+ /**
32
+ * Sync-shaped `ShaderCatalogDevice` adapter wrapping the backend pack's
33
+ * async `createShaderModule(rhiDevice, desc)`.
34
+ *
35
+ * Shape: fire-and-forget pre-bake — the first call for hash X returns
36
+ * `Result.err(RhiError)` (a pending signal) while kicking off the async
37
+ * createShaderModule + cache write; subsequent calls for the same hash hit
38
+ * the cache and return `Result.ok(ShaderModule)` (charter proposition 9
39
+ * graceful degradation).
40
+ *
41
+ * @placeholder M3 scope limit: the full async pre-bake wiring is delivered
42
+ * by the ECS-driven render pipeline (later closure). The current shape is
43
+ * already enough to support MVP-2.2 instance-per-engine + AI consumer
44
+ * onboarding (charter proposition 1 progressive disclosure / proposition 4
45
+ * explicit failure with retry guidance in the error hint).
46
+ */
47
+ /**
48
+ * Extended ShaderCatalogDevice contract for engine internals: adds a
49
+ * `seedModule(label, module)` to populate the lazy adapter cache from an
50
+ * already-compiled module, so eager-built modules (PBR / unlit / shadow
51
+ * caster) and the lazy MaterialShader pipeline cache share one cache and
52
+ * the lazy path is hit-on-first-call (no 1-frame warmup) for engine-shipped
53
+ * shaders. Externally still typed as ShaderCatalogDevice (see callers in
54
+ * ShaderCatalog).
55
+ */
56
+ export interface ShaderDeviceAdapterInternal extends ShaderCatalogDevice {
57
+ /**
58
+ * feat-20260609 R3-fixup: seed the adapter's moduleCache with an
59
+ * already-compiled ShaderModule under `label`. Used by the eager
60
+ * shadow_caster pre-bake so the lazy PSO build hit on
61
+ * `module-forgeax::default-shadow-caster` returns OK on frame 1.
62
+ */
63
+ seedModule(label: string, module: ShaderModule): void;
64
+ invalidateModule(label: string): void;
65
+ }
66
+
67
+ type ShaderModuleAdapterMode = 'validated' | 'immediate';
68
+
69
+ export function makeShaderDeviceAdapter(
70
+ rhiDevice: RhiDevice,
71
+ _errorRegistry: RhiErrorListenerRegistry,
72
+ asyncCreateShaderModule:
73
+ | ((
74
+ device: RhiDevice,
75
+ desc: { code: string; label?: string | undefined },
76
+ ) => Promise<Result<ShaderModule, RhiError>>)
77
+ | undefined,
78
+ immediateCreateShaderModule:
79
+ | ((
80
+ device: RhiDevice,
81
+ desc: { code: string; label?: string | undefined },
82
+ ) => Result<ShaderModule, RhiError>)
83
+ | undefined,
84
+ mode: ShaderModuleAdapterMode = 'validated',
85
+ ): ShaderDeviceAdapterInternal {
86
+ const moduleCache = new Map<string, ShaderModule>();
87
+ const errorCache = new Map<string, RhiError>();
88
+ const pending = new Map<string, number>();
89
+ const epochs = new Map<string, number>();
90
+
91
+ return {
92
+ createShaderModule(desc): Result<ShaderModule, RhiError> {
93
+ const key = desc.label ?? desc.code;
94
+ const cachedModule = moduleCache.get(key);
95
+ if (cachedModule !== undefined) return ok(cachedModule);
96
+ const cachedError = errorCache.get(key);
97
+ if (cachedError !== undefined) return err(cachedError);
98
+
99
+ // WebGPU's raw createShaderModule is synchronous. Only the explicitly
100
+ // selected immediate adapter uses that entry: generated feature programs
101
+ // can avoid a slow getCompilationInfo() round trip, while the default
102
+ // validated adapter keeps the compiler-readiness barrier for ordinary
103
+ // render pipelines. The public async factory still owns diagnostics;
104
+ // pipeline creation remains the validation point for the fast path.
105
+ if (mode === 'immediate' && immediateCreateShaderModule !== undefined) {
106
+ const immediateResult = immediateCreateShaderModule(rhiDevice, {
107
+ code: desc.code,
108
+ ...(desc.label === undefined ? {} : { label: desc.label }),
109
+ });
110
+ if (immediateResult.ok) {
111
+ moduleCache.set(key, immediateResult.value);
112
+ return immediateResult;
113
+ }
114
+ errorCache.set(key, immediateResult.error);
115
+ return immediateResult;
116
+ }
117
+
118
+ // Fire async creation; first sync call returns pending error (the AI consumer retry pattern).
119
+ // M3 D-P4: rhi-webgpu supplies the async factory; rhi-wgpu (no top-level
120
+ // async factory) falls back to the synchronous device.createShaderModule
121
+ // entry which already returns Result<ShaderModule, RhiError>.
122
+ if (!pending.has(key)) {
123
+ const epoch = epochs.get(key) ?? 0;
124
+ pending.set(key, epoch);
125
+ const desc2: { code: string; label?: string | undefined } = { code: desc.code };
126
+ if (desc.label !== undefined) desc2.label = desc.label;
127
+ const asyncResult: Promise<Result<ShaderModule, RhiError>> = asyncCreateShaderModule
128
+ ? asyncCreateShaderModule(rhiDevice, desc2)
129
+ : invokeDeviceCreateShaderModule(rhiDevice, desc2);
130
+ void asyncResult.then((result) => {
131
+ if (pending.get(key) !== epoch || (epochs.get(key) ?? 0) !== epoch) return;
132
+ if (result.ok) {
133
+ moduleCache.set(key, result.value);
134
+ } else {
135
+ errorCache.set(key, result.error);
136
+ }
137
+ pending.delete(key);
138
+ });
139
+ }
140
+
141
+ return err(makeRhiNotAvailableError(key));
142
+ },
143
+ seedModule(label: string, module: ShaderModule): void {
144
+ moduleCache.set(label, module);
145
+ },
146
+ invalidateModule(label: string): void {
147
+ epochs.set(label, (epochs.get(label) ?? 0) + 1);
148
+ moduleCache.delete(label);
149
+ errorCache.delete(label);
150
+ pending.delete(label);
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Fallback path for the M3 D-P4 auto-select facade when the backend pack
157
+ * does not supply a top-level async `createShaderModule` (e.g. the rhi-wgpu
158
+ * path which routes through the synchronous `device.createShaderModule`
159
+ * entry, or an explicit escape-hatch instance that omits the async
160
+ * factory). The forgeax `RhiDevice` interface intentionally does not expose
161
+ * a sync `createShaderModule` (fix-f3; see packages/rhi/src/index.ts line
162
+ * 1164), so this helper performs a structural probe + returns a structured
163
+ * error when neither the async factory nor a structural sync entry exists
164
+ * (charter proposition 4 explicit failure baseline).
165
+ */
166
+ export function invokeDeviceCreateShaderModule(
167
+ rhiDevice: RhiDevice,
168
+ desc: { code: string; label?: string | undefined },
169
+ ): Promise<Result<ShaderModule, RhiError>> {
170
+ const candidate = (
171
+ rhiDevice as RhiDevice & {
172
+ createShaderModule?: (d: {
173
+ code: string;
174
+ label?: string | undefined;
175
+ }) => Result<ShaderModule, RhiError>;
176
+ }
177
+ ).createShaderModule;
178
+ if (typeof candidate === 'function') {
179
+ try {
180
+ return Promise.resolve(candidate.call(rhiDevice, desc));
181
+ } catch (caught) {
182
+ const message = caught instanceof Error ? caught.message : String(caught);
183
+ return Promise.resolve(
184
+ err(
185
+ new RhiError({
186
+ code: 'shader-compile-failed',
187
+ expected: 'device.createShaderModule returns Result<ShaderModule, RhiError>',
188
+ hint: `synchronous fallback threw: ${message}`,
189
+ }),
190
+ ),
191
+ );
192
+ }
193
+ }
194
+ return Promise.resolve(
195
+ err(
196
+ new RhiError({
197
+ code: 'rhi-not-available',
198
+ expected:
199
+ 'either RhiBackendPack.createShaderModule (async) or device.createShaderModule (sync) available',
200
+ hint: 'the explicit RhiInstance escape hatch must expose a top-level createShaderModule(device, desc) or RhiDevice.createShaderModule(desc) entry',
201
+ }),
202
+ ),
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Synthesizes a `RhiError` with code 'rhi-not-available' for the
208
+ * sync-adapter pending path. AI-consumer onboarding: consume
209
+ * `.code === 'rhi-not-available'` → after `await loadManifest` retry
210
+ * `registry.get` (charter proposition 4 explicit failure + proposition 9).
211
+ *
212
+ * 'rhi-not-available' is the placeholder member of the 5-member closed
213
+ * union (plan-decisions OQ-P2).
214
+ */
215
+ function makeRhiNotAvailableError(key: string): RhiError {
216
+ return new RhiError({
217
+ code: 'rhi-not-available',
218
+ expected: 'shader module pre-bake to complete asynchronously',
219
+ hint: `shader module '${key}' is still pending; await registry.loadManifest() finished and retry registry.get(hash) on next frame`,
220
+ });
221
+ }
222
+
223
+ /**
224
+ * bug-20260601-hello-tonemap-material-register M1: prepare engine-shipped
225
+ * material shaders (cap gate + manifest load + registration) before the
226
+ * renderer is returned so that `register<MaterialAsset>` referencing an
227
+ * engine shader (e.g. `forgeax::default-standard-pbr`) succeeds without
228
+ * waiting for `renderer.initialization` (plan-strategy D-1/D-2/D-5).
229
+ *
230
+ * Failures (cap-gate insufficient, manifest-malformed, shader-not-found)
231
+ * throw structured `RhiError` / `ShaderError` which propagate through
232
+ * `createRenderer`'s synchronous reject path (D-2 structured reject).
233
+ */
234
+ export async function prepareMaterialShaders(
235
+ rhiDevice: RhiDevice,
236
+ getShader: () => ShaderCatalog,
237
+ assets: AssetRegistry,
238
+ // feat-20260629 M4: populated with uvSetCount from naga reflection JSON
239
+ // keyed by materialShaderId; consumed by getMaterialShaderPipeline for
240
+ // auto-fill of shaderUvSetCount clamp-to-last alias parameter.
241
+ materialShaderUvSetCounts: Map<string, number>,
242
+ ): Promise<void> {
243
+ const builtinGuid = (result: ReturnType<typeof AssetGuid.parse>): AssetGuidType => {
244
+ if (!result.ok) throw result.error;
245
+ return result.value;
246
+ };
247
+ // feat-20260528-material-shader-registration-unification M3 / w15:
248
+ // pre-computed UUIDv5 GUIDs for engine-shipped material shaders.
249
+ // Derived from FORGEAX_NAMESPACE (9a09805a-7623-482e-b322-9fc3591f2a38)
250
+ // with SHA-1 per RFC 4122 section 4.3.
251
+ const pbrGuidRes = AssetGuid.parse('94d85ce4-650c-54b1-a86a-eaf22696ecbc');
252
+ const unlitGuidRes = AssetGuid.parse('37f593ea-0c79-528c-b7f7-23d17045d776');
253
+ const spriteGuidRes = AssetGuid.parse('658234f6-a605-5fff-957d-7149b48fd0f4');
254
+ // feat-20260624-sprite-lit-shading-model-pure-2d-lighting M1' / t7: stable
255
+ // UUID for sprite-lit material shader (matches sprite-lit.material.json
256
+ // subAssets[0].guid; shader-id catalog SSOT).
257
+ const spriteLitGuidRes = AssetGuid.parse('f0ec6a4b-cad1-5a3d-9b4e-6d2b0fa14d8e');
258
+ const pbrSkinGuidRes = AssetGuid.parse('5ad0833e-2f17-56e5-a3d2-dab543afae65');
259
+ // feat-20260531-world-space-msdf-text-rendering M5 / w21: stable UUIDv5 for
260
+ // the world-space MSDF text material shader (deriveBuiltin('forgeax::msdf-text')
261
+ // under FORGEAX_NAMESPACE). Registered via the manifest materialShaders[]
262
+ // loop below alongside sprite / unlit (D-7 -- materialShaderId path).
263
+ const msdfTextGuidRes = AssetGuid.parse('b8f92146-3b24-519b-97a2-271419b53563');
264
+ const shadowCasterGuidRes = AssetGuid.parse('2e167c2a-1747-5bd7-b56d-aea9bc3f436e');
265
+ const pbrGuid = builtinGuid(pbrGuidRes);
266
+ const unlitGuid = builtinGuid(unlitGuidRes);
267
+ const spriteGuid = builtinGuid(spriteGuidRes);
268
+ const spriteLitGuid = builtinGuid(spriteLitGuidRes);
269
+ const pbrSkinGuid = builtinGuid(pbrSkinGuidRes);
270
+ const msdfTextGuid = builtinGuid(msdfTextGuidRes);
271
+ const shadowCasterGuid = builtinGuid(shadowCasterGuidRes);
272
+ const ENGINE_SHADER_GUIDS = new Map<string, AssetGuidType>([
273
+ ['forgeax::default-standard-pbr', pbrGuid],
274
+ ['forgeax::default-unlit', unlitGuid],
275
+ ['forgeax::sprite', spriteGuid],
276
+ ['forgeax::sprite-lit', spriteLitGuid],
277
+ ['forgeax::default-standard-pbr-skin', pbrSkinGuid],
278
+ ['forgeax::msdf-text', msdfTextGuid],
279
+ ['forgeax::default-shadow-caster', shadowCasterGuid],
280
+ ]);
281
+
282
+ // ── Step 0: storage-buffer cap gate ───────────────────────────────────────
283
+ const capValue = (rhiDevice.limits as Readonly<Record<string, number>>)
284
+ .maxStorageBuffersPerShaderStage;
285
+ const webgl2Downlevel = rhiDevice.caps.backendKind === 'wgpu-webgl2';
286
+ const storageBufferCapable = rhiDevice.caps.storageBuffer;
287
+ if (storageBufferCapable && typeof capValue === 'number') {
288
+ const capCheck = assertStorageBufferCap(capValue);
289
+ if (!capCheck.ok) throw capCheck.error;
290
+ }
291
+ const extendedLightingShaderAvailable = deriveExtendedLightingCapability(rhiDevice).admitted;
292
+ const projectorAvailable =
293
+ (rhiDevice.limits.maxSampledTexturesPerShaderStage ?? 0) >=
294
+ STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
295
+
296
+ // ── Step 1: manifest load ─────────────────────────────────────────────────
297
+ const registry = getShader();
298
+ const loaded = await registry.loadManifest();
299
+ if (!loaded.ok) {
300
+ throw loaded.error;
301
+ }
302
+
303
+ // ── Step 1b: material shader variant resolution + registration ───────────
304
+ // feat-20260609-hdrp-cluster-fragment-ggx M1 / w7: dual-axis variant
305
+ // resolution. At boot time isHdrpActive is always false (URP default),
306
+ // so CLUSTER_FORWARD_AVAILABLE=false variant is selected. HDRP variant
307
+ // (CLUSTER_FORWARD_AVAILABLE=true) is resolved later when
308
+ // clustered Standard profile activation selects the clustered variant.
309
+ const isHdrpActive = false; // M4: wire to actual renderSystem.frameState.isHdrpActive
310
+ for (const msEntry of registry.materialShaderManifestEntries()) {
311
+ // bug-20260610: skip synthetic non-material engine entries piggy-backing
312
+ // on the materialShaders channel for variant surfacing (shadow_caster).
313
+ // They use the `forgeax::engine-` prefix and are consumed by Step 2's
314
+ // engine-entry compile path, NOT by installMaterialArtifact.
315
+ if (msEntry.identifier.startsWith('forgeax::engine-')) continue;
316
+ // buildVariantKey logic: sorted key=value pairs joined with +; all-true = ''.
317
+ const variantDefines: Record<string, boolean> = {};
318
+ if (msEntry.variants.some((variant) => 'STORAGE_BUFFER_AVAILABLE' in variant.defines)) {
319
+ variantDefines.STORAGE_BUFFER_AVAILABLE = storageBufferCapable;
320
+ }
321
+ // User material manifests may declare WEBGL2_COMPAT alongside the shared
322
+ // mesh-storage axis. The fallback backend must select that authored
323
+ // branch at boot; otherwise it asks for the impossible bare
324
+ // `STORAGE_BUFFER_AVAILABLE=false` key and fails before the first frame.
325
+ if (msEntry.variants.some((v) => 'WEBGL2_COMPAT' in v.defines)) {
326
+ variantDefines.WEBGL2_COMPAT = webgl2Downlevel;
327
+ }
328
+ // Only include CLUSTER_FORWARD_AVAILABLE when the material shader declares
329
+ // the axis (has a variant with this key), preserving backward compat for
330
+ // entries without the pragma.
331
+ if (msEntry.variants.some((v) => 'CLUSTER_FORWARD_AVAILABLE' in v.defines)) {
332
+ variantDefines.CLUSTER_FORWARD_AVAILABLE = isHdrpActive;
333
+ }
334
+ if (msEntry.variants.some((v) => 'DIRECTIONAL_PCSS_AVAILABLE' in v.defines)) {
335
+ variantDefines.DIRECTIONAL_PCSS_AVAILABLE = directionalPcssCapable(
336
+ rhiDevice.caps.backendKind,
337
+ );
338
+ }
339
+ if (msEntry.variants.some((v) => 'VERTEX_COLOR_AVAILABLE' in v.defines)) {
340
+ variantDefines.VERTEX_COLOR_AVAILABLE = false;
341
+ }
342
+ if (msEntry.variants.some((v) => 'PROBE_BLEND_AVAILABLE' in v.defines)) {
343
+ variantDefines.PROBE_BLEND_AVAILABLE = false;
344
+ }
345
+ if (msEntry.variants.some((v) => 'EXTENDED_LIGHTING_AVAILABLE' in v.defines)) {
346
+ variantDefines.EXTENDED_LIGHTING_AVAILABLE = extendedLightingShaderAvailable;
347
+ }
348
+ if (
349
+ msEntry.identifier === 'forgeax::default-standard-pbr' &&
350
+ msEntry.variants.some((v) => 'PROJECTOR_AVAILABLE' in v.defines)
351
+ ) {
352
+ variantDefines.PROJECTOR_AVAILABLE = projectorAvailable;
353
+ }
354
+ if (msEntry.variants.some((v) => 'TRANSMISSION_AVAILABLE' in v.defines)) {
355
+ variantDefines.TRANSMISSION_AVAILABLE = false;
356
+ }
357
+ // Record-time fallback MRT is opt-in; boot the ordinary single-target
358
+ // Standard artifact even when the manifest declares this axis.
359
+ if (
360
+ msEntry.identifier === 'forgeax::default-standard-pbr' &&
361
+ msEntry.variants.some((v) => 'REFLECTION_FALLBACK_AVAILABLE' in v.defines)
362
+ ) {
363
+ variantDefines.REFLECTION_FALLBACK_AVAILABLE = false;
364
+ }
365
+ // bug-20260708 M2 (a): PER_INSTANCE_REGION axis default = false at boot.
366
+ // Per-entity spritePH path (`main-pass-sprite-draws.ts:511-513`) expects
367
+ // `variantSet=undefined` to resolve to the PIR=false shader source (64B
368
+ // InstanceData) via `lookup.value.source` fallback in
369
+ // `getMaterialShaderPipeline`. The PIR=true canonical variant (80B) is
370
+ // reserved for SpriteInstances batches which explicitly request it via
371
+ // `SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET === ''` (canonical all-
372
+ // true) — resolved through `findVariantByKey(msEntry, '')` in the same
373
+ // `getMaterialShaderPipeline` substitution branch. Mirrors the
374
+ // CLUSTER_FORWARD_AVAILABLE pattern above: axis default false unless
375
+ // explicitly requested by a caller variantSet.
376
+ if (msEntry.variants.some((v) => 'PER_INSTANCE_REGION' in v.defines)) {
377
+ variantDefines.PER_INSTANCE_REGION = false;
378
+ }
379
+ if (msEntry.variants.some((v) => 'SKINNING_DISABLED' in v.defines)) {
380
+ variantDefines.SKINNING_DISABLED = true;
381
+ }
382
+ const sortedEntries = Object.entries(variantDefines).sort(([a], [b]) =>
383
+ a < b ? -1 : a > b ? 1 : 0,
384
+ );
385
+ const definesKey = sortedEntries.every(([, v]) => v === true)
386
+ ? ''
387
+ : sortedEntries.map(([k, v]) => `${k}=${v}`).join('+');
388
+ const chosen = findVariantByKey(msEntry, definesKey);
389
+ if (msEntry.variants.length > 0 && chosen === undefined) {
390
+ throw new RhiError({
391
+ code: 'shader-compile-failed',
392
+ expected: `material shader '${msEntry.identifier}' declares the exact boot variant '${definesKey}'`,
393
+ hint: 'regenerate the shader manifest with the complete variant Cartesian product',
394
+ });
395
+ }
396
+ const wgsl = chosen?.composedWgsl ?? msEntry.composedWgsl;
397
+ if (wgsl.length > 0) {
398
+ const existing = registry.findMaterialArtifact(msEntry.identifier);
399
+ if (existing.ok) continue;
400
+ const paramSchema = JSON.parse(
401
+ msEntry.paramSchema,
402
+ ) as readonly import('@forgeax/engine-types').ParamSchemaEntry[];
403
+ // feat-20260613-material-paramschema-driven-binding M3 / w12-w13:
404
+ // paramSchema is the SSOT; the BGL is derived on demand via
405
+ // `derive(paramSchema).bglEntries` and consumed by
406
+ // buildPbrPipelineLayouts at pipeline-build time. The historical
407
+ // separate bind-layout sidecar field has been deleted from
408
+ // MaterialShaderEntry / MaterialRuntimeInfo (D-1 / D-2).
409
+ registry.installMaterialArtifact(msEntry.identifier, {
410
+ source: wgsl,
411
+ paramSchema,
412
+ });
413
+ // feat-20260629 M4: store uvSetCount from naga reflection for
414
+ // auto-fill in getMaterialShaderPipeline clamp-to-last alias.
415
+ if (msEntry.uvSetCount !== undefined) {
416
+ materialShaderUvSetCounts.set(msEntry.identifier, msEntry.uvSetCount);
417
+ }
418
+ // Sanity check the schema parses through derive — a malformed sidecar
419
+ // schema (e.g. unknown type literal) should fail loud at register time
420
+ // (charter P3 explicit failure). The derived output is discarded; the
421
+ // SSOT consumer is buildPbrPipelineLayouts later.
422
+ derive(paramSchema);
423
+
424
+ const shaderGuid = ENGINE_SHADER_GUIDS.get(msEntry.identifier);
425
+ if (shaderGuid !== undefined) {
426
+ const shaderAsset = {
427
+ kind: 'shader' as const,
428
+ name: msEntry.identifier,
429
+ source: wgsl,
430
+ paramSchema,
431
+ };
432
+ // feat-20260614 M8 (D-17): catalogue the shader asset by GUID so it is
433
+ // GUID-addressable; no handle minted. A shader without an engine GUID
434
+ // lives only in the ShaderCatalog (the installMaterialArtifact SSOT
435
+ // above) -- there is no GUID to catalogue it under.
436
+ assets.catalog(shaderGuid, shaderAsset);
437
+ }
438
+ }
439
+ }
440
+
441
+ // ── Step 1c: register forgeax::default-shadow-caster from its manifest identity ──
442
+ // feat-20260609-pipeline-driven-pass-selector-shadowcaster-via-mat M3 / T-007:
443
+ // The reserved material-shader identifier is the SSOT. Content heuristics are
444
+ // invalid here: a game-authored vertex-only shader can legitimately expose
445
+ // position without normal (billboards and procedural VFX commonly do), and
446
+ // must never be mistaken for the engine shadow caster merely because it is
447
+ // encountered first in the flat manifest.
448
+ const shadowCasterIdentifier = 'forgeax::default-shadow-caster';
449
+ if (!registry.findMaterialArtifact(shadowCasterIdentifier).ok) {
450
+ const manifestEntry = Array.from(registry.materialShaderManifestEntries()).find(
451
+ (candidate) => candidate.identifier === shadowCasterIdentifier,
452
+ );
453
+ if (manifestEntry !== undefined) {
454
+ // shadow_caster evaluates the same default Surface as Standard. Keep
455
+ // its generated MaterialParameters contract aligned with the Standard
456
+ // pipeline even when a legacy manifest omitted the material entry.
457
+ registry.installMaterialArtifact(shadowCasterIdentifier, {
458
+ source: manifestEntry.composedWgsl,
459
+ paramSchema: STANDARD_PIPELINE_PARAM_SCHEMA,
460
+ });
461
+ const shaderGuid = ENGINE_SHADER_GUIDS.get(shadowCasterIdentifier);
462
+ if (shaderGuid !== undefined) {
463
+ const shaderAsset = {
464
+ kind: 'shader' as const,
465
+ name: shadowCasterIdentifier,
466
+ source: manifestEntry.composedWgsl,
467
+ paramSchema: STANDARD_PIPELINE_PARAM_SCHEMA,
468
+ };
469
+ // feat-20260614 M8 (D-17): catalogue by GUID; no handle minted.
470
+ assets.catalog(shaderGuid, shaderAsset);
471
+ }
472
+ }
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Per-variant PipelineLayout selector (M4.5 / D-10 option A).
478
+ *
479
+ * The HDRP variant (`CLUSTER_FORWARD_AVAILABLE=true` or canonical `''`
480
+ * all-true when the shader declares the cluster axis) needs the 7-slot
481
+ * group(2) BGL chain (`hdrpPbrPipelineLayout`); URP variants
482
+ * (`CLUSTER_FORWARD_AVAILABLE=false` / undefined / no axis) keep the
483
+ * 1-slot mesh-array BGL chain (`pbrPipelineLayout`).
484
+ *
485
+ * Returns `null` only when the URP fallback layout itself is null
486
+ * (Camera-only / empty-manifest path). When the HDRP layout is null but
487
+ * URP exists, the HDRP variant gracefully falls back to URP — the WGSL
488
+ * resolved upstream by `findVariantByKey` is the URP variant in that
489
+ * case (manifest entry with all-true definesKey === '' was registered
490
+ * with isHdrpActive=false at boot when storage-buffer caps are absent).
491
+ *
492
+ * Pure function — exported for unit-test access (M4.5 / w35).
493
+ */
494
+ /**
495
+ * Closed enum of pipeline-layout kinds the selector can dispatch to.
496
+ *
497
+ * `pbr` — standard PBR (1-entry mesh-array BGL).
498
+ * `pbr-skin` — bug-20260611-skin-pipeline-layout: 2-entry mesh-array BGL
499
+ * (binding 0 meshes + binding 1 palette) for the `forgeax::pbr-skin`
500
+ * material shader.
501
+ * `hdrp-pbr` — feat-20260609 HDRP cluster-forward variant (7-slot group(2)
502
+ * BGL substituted at slot 2).
503
+ * `sprite-urp` — bug-20260708 M2 (c): sprite / sprite-lit shaders reuse
504
+ * the URP 1-slot mesh-array BGL (they don't declare
505
+ * CLUSTER_FORWARD_AVAILABLE) but their canonical all-true variant key
506
+ * `''` (`SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET`) MUST NOT trigger
507
+ * the `variantSet===''` → HDRP branch of the selector. Callers
508
+ * (`buildPipelineContext`) resolve `sprite-urp` from the sprite
509
+ * `materialShaderId` and pass it through; the selector short-circuits
510
+ * to `pbrPipelineLayout` before the HDRP check.
511
+ *
512
+ * AC-09 grep gate: the selector body (`selectPipelineLayoutForVariant`)
513
+ * **MUST NOT** contain literal `'forgeax::pbr-skin'` (or another
514
+ * shader-id literal). The caller resolves `LayoutKind` upstream and passes
515
+ * the structured value through (charter P4 consistent abstraction). New
516
+ * material-shader layouts add a `LayoutKind` member + a PipelineState slot
517
+ * + a switch arm here in one cut (charter P3 extensibility).
518
+ */
519
+ export type LayoutKind = 'pbr' | 'pbr-skin' | 'hdrp-pbr' | 'hdrp-skin' | 'sprite-urp' | 'unlit-urp';
520
+
521
+ function directionalPcssCapable(backendKind: RhiDevice['caps']['backendKind']): boolean {
522
+ return backendKind === 'webgpu' || backendKind === 'wgpu-native';
523
+ }
524
+
525
+ export function isSharedMaterialUserRegionCompatible(
526
+ paramSchema: readonly ParamSchemaEntry[],
527
+ ): boolean {
528
+ const withoutPhysicalMaps = (
529
+ schema: readonly ParamSchemaEntry[],
530
+ ): readonly ParamSchemaEntry[] => {
531
+ const excluded = new Set<string>(standardPhysicalTextureFields(schema));
532
+ return schema.filter((entry) => !excluded.has(entry.name) || !entry.type.startsWith('texture'));
533
+ };
534
+ const sharedSchema = withoutPhysicalMaps(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
535
+ const candidateSchema = withoutPhysicalMaps(paramSchema);
536
+ const shared = buildPbrMaterialUserRegionEntries(sharedSchema);
537
+ const candidate = buildPbrMaterialUserRegionEntries(candidateSchema);
538
+ const sharedTextureNames = [...derive(sharedSchema).textureFieldNames];
539
+ const candidateTextureNames = [...derive(candidateSchema).textureFieldNames];
540
+ if (
541
+ candidateTextureNames.length !== sharedTextureNames.length ||
542
+ candidateTextureNames.some((name, index) => name !== sharedTextureNames[index])
543
+ ) {
544
+ return false;
545
+ }
546
+ if (candidate.length > shared.length) return false;
547
+ return candidate.every((entry, index) => {
548
+ const canonical = shared[index];
549
+ if (canonical === undefined) return false;
550
+ return (
551
+ JSON.stringify({
552
+ binding: entry.binding,
553
+ visibility: entry.visibility,
554
+ buffer: entry.buffer ?? null,
555
+ sampler: entry.sampler ?? null,
556
+ texture: entry.texture ?? null,
557
+ storageTexture: entry.storageTexture ?? null,
558
+ }) ===
559
+ JSON.stringify({
560
+ binding: canonical.binding,
561
+ visibility: canonical.visibility,
562
+ buffer: canonical.buffer ?? null,
563
+ sampler: canonical.sampler ?? null,
564
+ texture: canonical.texture ?? null,
565
+ storageTexture: canonical.storageTexture ?? null,
566
+ })
567
+ );
568
+ });
569
+ }
570
+
571
+ /**
572
+ * Select the PBR variant that matches a fallback mesh with no COLOR_0 input.
573
+ * Unknown/custom material IDs may still use the legacy PBR fallback, so that
574
+ * path must obey the same geometry-owned vertex-color axis as registered PBR.
575
+ */
576
+ export function selectNoColorPbrVariant(
577
+ manifestEntry: MaterialShaderManifestEntry | undefined,
578
+ storageBufferCapable: boolean,
579
+ variantSet: string | undefined,
580
+ extendedLightingShaderAvailable = true,
581
+ directionalPcssAvailable = true,
582
+ projectorAvailable: boolean = true,
583
+ ): MaterialShaderManifestEntry['variants'][number] | undefined {
584
+ const hdrpRequested = variantSet === '' || requestsClusterVariant(variantSet);
585
+ const probeBlendRequested = requestsProbeBlendVariant(variantSet);
586
+ return manifestEntry?.variants.find(
587
+ (variant) =>
588
+ variant.defines.STORAGE_BUFFER_AVAILABLE === storageBufferCapable &&
589
+ (!('EXTENDED_LIGHTING_AVAILABLE' in variant.defines) ||
590
+ variant.defines.EXTENDED_LIGHTING_AVAILABLE === extendedLightingShaderAvailable) &&
591
+ variant.defines.VERTEX_COLOR_AVAILABLE === false &&
592
+ (!('DIRECTIONAL_PCSS_AVAILABLE' in variant.defines) ||
593
+ variant.defines.DIRECTIONAL_PCSS_AVAILABLE === directionalPcssAvailable) &&
594
+ (!('PROJECTOR_AVAILABLE' in variant.defines) ||
595
+ variant.defines.PROJECTOR_AVAILABLE === projectorAvailable) &&
596
+ (!('PROBE_BLEND_AVAILABLE' in variant.defines) ||
597
+ variant.defines.PROBE_BLEND_AVAILABLE === probeBlendRequested) &&
598
+ (!('CLUSTER_FORWARD_AVAILABLE' in variant.defines) ||
599
+ variant.defines.CLUSTER_FORWARD_AVAILABLE === hdrpRequested),
600
+ );
601
+ }
602
+
603
+ function requestsClusterVariant(variantSet: string | undefined): boolean {
604
+ return variantSet?.includes('CLUSTER_FORWARD_AVAILABLE=true') === true;
605
+ }
606
+
607
+ function requestsProbeBlendVariant(variantSet: string | undefined): boolean {
608
+ return variantSet?.includes('PROBE_BLEND_AVAILABLE=true') === true;
609
+ }
610
+
611
+ export type MaterialShaderBindingContract = RenderFeatureMaterialShaderBindingContract;
612
+ export type MaterialShaderVertexInputContract = 'none' | 'render-material';
613
+
614
+ export function resolveMaterialShaderBindingContract(
615
+ source: string,
616
+ ): MaterialShaderBindingContract {
617
+ const withoutComments = source.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/.*$/gmu, '');
618
+ const groups = [...withoutComments.matchAll(/@group\s*\(\s*(\d+)\s*\)/gu)].map((match) =>
619
+ Number(match[1]),
620
+ );
621
+ if (groups.length === 0) return 'group-0';
622
+ const declaresView =
623
+ /@group\s*\(\s*0\s*\)\s*@binding\s*\(\s*0\s*\)\s*var\s*<\s*uniform\s*>\s*view(?:X_naga_oil_mod_[A-Z0-9]+)?\b/u.test(
624
+ withoutComments,
625
+ );
626
+ const declaresSceneDepth =
627
+ /@group\s*\(\s*0\s*\)\s*@binding\s*\(\s*1\s*\)\s*var\s+scene_depth\s*:\s*texture_depth_2d\b/u.test(
628
+ withoutComments,
629
+ );
630
+ if (declaresView && declaresSceneDepth && groups.every((group) => group === 0)) {
631
+ return 'view-and-scene-depth';
632
+ }
633
+ if (declaresView && groups.every((group) => group === 0)) return 'view-only';
634
+ return groups.every((group) => group === 0) ? 'group-0-resource' : 'render-material';
635
+ }
636
+
637
+ export function resolveMaterialShaderVertexInputContract(
638
+ source: string,
639
+ ): MaterialShaderVertexInputContract {
640
+ const withoutComments = source.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/.*$/gmu, '');
641
+ const inputStructs = [...withoutComments.matchAll(/struct\s+([A-Za-z_]\w*)\s*\{([^}]*)\}/gu)]
642
+ .filter((match) => /@location\s*\(/u.test(match[2] ?? ''))
643
+ .map((match) => match[1]);
644
+ // WGSL vertex inputs are commonly declared in a named struct before the
645
+ // `@vertex` function (the engine uses `VsIn`, while package shaders often
646
+ // use `VertexInput`). Match the type actually passed as the first vertex
647
+ // parameter so fragment/output structs do not accidentally enable a
648
+ // vertex buffer layout for fullscreen shaders.
649
+ for (const inputStruct of inputStructs) {
650
+ if (inputStruct === undefined) continue;
651
+ const firstParameter = new RegExp(
652
+ `@vertex\\s*fn\\s+\\w+\\s*\\(\\s*\\w+\\s*:\\s*${inputStruct}\\b`,
653
+ 'u',
654
+ );
655
+ if (firstParameter.test(withoutComments)) return 'render-material';
656
+ }
657
+ // A shader may declare a location directly on a vertex parameter rather
658
+ // than through a struct. Keep that contract explicit as well.
659
+ return /@vertex\s*fn[^{]*\([^)]*@location\s*\(/u.test(withoutComments)
660
+ ? 'render-material'
661
+ : 'none';
662
+ }
663
+
664
+ /**
665
+ * Resolve the number of UV sets declared by one material artifact.
666
+ *
667
+ * Runtime-published Surface programs do not necessarily have a manifest
668
+ * reflection row, so the authored vertex input is the fallback source of
669
+ * truth. The renderer then feeds this count into the geometry alias
670
+ * projection, which clamps missing mesh UV sets to the last available set.
671
+ */
672
+ export function resolveMaterialShaderUvSetCount(
673
+ source: string | undefined,
674
+ reflectedCount?: number,
675
+ ): number | undefined {
676
+ if (reflectedCount !== undefined) return reflectedCount;
677
+ if (source === undefined) return undefined;
678
+
679
+ const withoutComments = source.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/\/\/.*$/gmu, '');
680
+ const inputStructs = [
681
+ ...withoutComments.matchAll(/struct\s+([A-Za-z_]\w*)\s*\{([^}]*)\}/gu),
682
+ ].filter((match) => /@location\s*\(/u.test(match[2] ?? ''));
683
+ for (const inputStruct of inputStructs) {
684
+ const name = inputStruct[1];
685
+ const body = inputStruct[2];
686
+ if (name === undefined || body === undefined) continue;
687
+ const firstParameter = new RegExp(
688
+ `@vertex\\s*fn\\s+\\w+\\s*\\(\\s*\\w+\\s*:\\s*${name}\\b`,
689
+ 'u',
690
+ );
691
+ if (!firstParameter.test(withoutComments)) continue;
692
+ let uvSetCount = 0;
693
+ for (const location of body.matchAll(/@location\s*\(\s*(\d+)\s*\)\s+([A-Za-z_]\w*)/gu)) {
694
+ const field = location[2];
695
+ if (field === 'uv') {
696
+ uvSetCount = Math.max(uvSetCount, 1);
697
+ continue;
698
+ }
699
+ const uvIndex = field?.match(/^uv(\d+)_?$/u)?.[1];
700
+ if (uvIndex !== undefined) uvSetCount = Math.max(uvSetCount, Number(uvIndex) + 1);
701
+ }
702
+ return uvSetCount > 0 ? uvSetCount : undefined;
703
+ }
704
+ return undefined;
705
+ }
706
+
707
+ /**
708
+ * Select a published material artifact that matches a downlevel backend.
709
+ *
710
+ * Cooked Surface publications carry immutable backend variants in their
711
+ * metadata. WebGL2 must select the uniform-fallback specialization before
712
+ * pipeline construction so its shader, bind-group layout, and cache key stay
713
+ * capability-consistent.
714
+ */
715
+ export function resolveMaterialShaderBackendArtifactKey(
716
+ materialShaderId: string,
717
+ backendKind: 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null',
718
+ artifact:
719
+ | {
720
+ readonly metadata?: Readonly<Record<string, unknown>>;
721
+ }
722
+ | undefined,
723
+ ): string {
724
+ if (backendKind !== 'wgpu-webgl2') return materialShaderId;
725
+ const variants = artifact?.metadata?.variants;
726
+ if (!Array.isArray(variants)) return materialShaderId;
727
+ const fallback = variants.find((variant): variant is { specializationKey: string } => {
728
+ if (typeof variant !== 'object' || variant === null) return false;
729
+ const candidate = variant as Record<string, unknown>;
730
+ return (
731
+ candidate.backend === 'webgl2' &&
732
+ candidate.capability === 'uniform-fallback' &&
733
+ typeof candidate.specializationKey === 'string' &&
734
+ candidate.specializationKey.length > 0
735
+ );
736
+ });
737
+ return fallback?.specializationKey ?? materialShaderId;
738
+ }
739
+
740
+ export function allowsUnlitPreparedFallback(
741
+ depthFormatOverride: GPUTextureFormat | null | undefined,
742
+ vertexLayout: string | undefined,
743
+ materialShaderId?: string,
744
+ ): boolean {
745
+ return (
746
+ depthFormatOverride === null &&
747
+ (vertexLayout === undefined || !materialShaderId?.startsWith('forgeax::'))
748
+ );
749
+ }
750
+
751
+ /**
752
+ * Prepared VFX draws own their vertex inputs and bind groups. A missing
753
+ * authored material shader is therefore retryable, but it is never safe to
754
+ * substitute the built-in PBR module: PBR declares mesh/instance groups that
755
+ * the VFX draw does not bind. Returning true keeps the feature on its
756
+ * structured next-frame recovery path instead of creating an invalid PSO.
757
+ */
758
+ export function shouldDeferMissingPreparedMaterialShader(
759
+ vertexLayout: string | undefined,
760
+ ): boolean {
761
+ return (
762
+ vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.billboardMaterialInstance ||
763
+ vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.topologySegmentInstance ||
764
+ vertexLayout === RENDER_FEATURE_VERTEX_LAYOUTS.meshGeometryMaterialInstance
765
+ );
766
+ }
767
+
768
+ export function selectPipelineLayoutForVariant(
769
+ state: {
770
+ readonly pbrPipelineLayout: PipelineLayout | null;
771
+ readonly pbrProbePipelineLayout?: PipelineLayout | null;
772
+ readonly hdrpPbrPipelineLayout: PipelineLayout | null;
773
+ readonly hdrpProbePbrPipelineLayout?: PipelineLayout | null;
774
+ readonly pbrSkinPipelineLayout: PipelineLayout | null;
775
+ readonly pbrSkinProbePipelineLayout?: PipelineLayout | null;
776
+ readonly hdrpSkinPipelineLayout?: PipelineLayout | null;
777
+ } | null,
778
+ variantSet: string | undefined,
779
+ layoutKind?: LayoutKind,
780
+ ): PipelineLayout | null {
781
+ if (state === null) return null;
782
+ const probeBlendVariant = variantSet?.includes('PROBE_BLEND_AVAILABLE=true') === true;
783
+ // bug-20260611: skin layout selection takes precedence over HDRP variant
784
+ // resolution. HDRP × skin is OOS-1 (plan-strategy R-2 — left for a
785
+ // dedicated feat); when an HDRP-variant skin call ever lands, we fail
786
+ // fast with `null` rather than silently returning the URP layout
787
+ // (charter P3 explicit failure, mirrors memory anchor
788
+ // `hdrp-active-must-not-fallback-to-urp-pipeline`).
789
+ if (layoutKind === 'pbr-skin') {
790
+ return probeBlendVariant
791
+ ? (state.pbrSkinProbePipelineLayout ?? null)
792
+ : state.pbrSkinPipelineLayout;
793
+ }
794
+ if (layoutKind === 'hdrp-skin') {
795
+ return state.hdrpSkinPipelineLayout ?? null;
796
+ }
797
+ // bug-20260708 M2 (c): sprite / sprite-lit shaders reuse the URP 1-slot
798
+ // mesh-array pipeline layout but their canonical all-true variant key
799
+ // `''` (SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET) must NOT trigger
800
+ // the `variantSet===''` → HDRP branch below. Caller-side
801
+ // (`buildPipelineContext`) resolves LayoutKind='sprite-urp' from the
802
+ // sprite `materialShaderId`; the selector short-circuits here before
803
+ // the HDRP variant check reads `variantSet`. Prevents R-1'
804
+ // device-lost when a SpriteInstances batch requests the PIR=true
805
+ // canonical variant.
806
+ if (layoutKind === 'sprite-urp') {
807
+ return state.pbrPipelineLayout;
808
+ }
809
+ if (layoutKind === 'unlit-urp') {
810
+ return state.pbrPipelineLayout;
811
+ }
812
+ // Static PBR callers can use the canonical all-true variant key (`''`)
813
+ // without becoming HDRP. Keep the shader variant identity independent from
814
+ // the pipeline-layout identity; shadow-caster static draws use this explicit
815
+ // kind so their ordinary mesh bind group remains compatible with the PSO.
816
+ if (layoutKind === 'pbr') {
817
+ return probeBlendVariant ? (state.pbrProbePipelineLayout ?? null) : state.pbrPipelineLayout;
818
+ }
819
+ // HDRP variant matches when:
820
+ // - layoutKind === 'hdrp-pbr' (caller-driven, future-proof), OR
821
+ // - variantSet is the canonical all-true key '' (boot-time HDRP
822
+ // registration produces '' when both axes resolve to true), OR
823
+ // - variantSet contains `CLUSTER_FORWARD_AVAILABLE=true` substring
824
+ // (record-stage caller emits the expanded form).
825
+ // URP variants have CLUSTER_FORWARD_AVAILABLE=false explicitly, or omit
826
+ // the axis altogether, or pass undefined (no variant routing).
827
+ // G-14 (D-11): empty-string is the canonical all-true HDRP key, so we
828
+ // explicitly distinguish '' vs undefined rather than using an optional-chain
829
+ // (charter P3: explicit failure / no implicit empty-vs-missing collapse).
830
+ const isHdrpVariant = layoutKind === 'hdrp-pbr';
831
+ if (isHdrpVariant) {
832
+ return probeBlendVariant
833
+ ? (state.hdrpProbePbrPipelineLayout ?? null)
834
+ : state.hdrpPbrPipelineLayout;
835
+ }
836
+ return probeBlendVariant ? (state.pbrProbePipelineLayout ?? null) : state.pbrPipelineLayout;
837
+ }
838
+
839
+ /**
840
+ * Resolve a requested material variant against the actual backend capabilities.
841
+ * Record callers use the native-capability request; the backend-owned axes
842
+ * must be rewritten before the request reaches shader lookup and PSO layout
843
+ * selection.
844
+ */
845
+ export function resolveMaterialShaderVariantSet(
846
+ requestedVariantSet: string | undefined,
847
+ variants: ReadonlyArray<{
848
+ readonly defines: Readonly<Record<string, boolean>>;
849
+ }>,
850
+ backendKind: RhiDevice['caps']['backendKind'],
851
+ storageBuffer: boolean,
852
+ maxSampledTexturesPerShaderStageOrProjectorAvailable?: number | boolean,
853
+ ): string | undefined {
854
+ const declaredAxes = new Set(variants.flatMap((variant) => Object.keys(variant.defines)));
855
+ const declaresCapabilityVariants =
856
+ declaredAxes.has('WEBGL2_COMPAT') ||
857
+ declaredAxes.has('STORAGE_BUFFER_AVAILABLE') ||
858
+ declaredAxes.has('EXTENDED_LIGHTING_AVAILABLE') ||
859
+ declaredAxes.has('DIRECTIONAL_PCSS_AVAILABLE') ||
860
+ declaredAxes.has('PROJECTOR_AVAILABLE');
861
+ if (!declaresCapabilityVariants) return requestedVariantSet;
862
+
863
+ const requested: Record<string, boolean> = {};
864
+ for (const part of requestedVariantSet?.split('+') ?? []) {
865
+ const separator = part.indexOf('=');
866
+ if (separator > 0) requested[part.slice(0, separator)] = part.slice(separator + 1) === 'true';
867
+ }
868
+ const sampledTextureLimit =
869
+ typeof maxSampledTexturesPerShaderStageOrProjectorAvailable === 'number'
870
+ ? maxSampledTexturesPerShaderStageOrProjectorAvailable
871
+ : undefined;
872
+ const extendedLightingAvailable =
873
+ sampledTextureLimit === undefined
874
+ ? true
875
+ : backendKind !== 'wgpu-webgl2' &&
876
+ storageBuffer &&
877
+ extendedLightingSampledTextureCapacityAvailable(sampledTextureLimit);
878
+ const projectorAvailable =
879
+ typeof maxSampledTexturesPerShaderStageOrProjectorAvailable === 'boolean'
880
+ ? maxSampledTexturesPerShaderStageOrProjectorAvailable
881
+ : sampledTextureLimit === undefined
882
+ ? true
883
+ : sampledTextureLimit >= STANDARD_PBR_REQUIRED_SAMPLED_TEXTURES;
884
+ const filtered: Record<string, boolean> = {};
885
+ for (const axis of declaredAxes) {
886
+ filtered[axis] =
887
+ axis === 'WEBGL2_COMPAT'
888
+ ? backendKind === 'wgpu-webgl2'
889
+ : axis === 'STORAGE_BUFFER_AVAILABLE'
890
+ ? backendKind !== 'wgpu-webgl2' && storageBuffer
891
+ : axis === 'SKINNING_DISABLED'
892
+ ? (requested[axis] ?? true)
893
+ : axis === 'CLUSTER_FORWARD_AVAILABLE'
894
+ ? storageBuffer && backendKind !== 'wgpu-webgl2' && (requested[axis] ?? false)
895
+ : axis === 'PER_INSTANCE_REGION'
896
+ ? requestedVariantSet === '' || (requested[axis] ?? false)
897
+ : axis === 'VERTEX_COLOR_AVAILABLE'
898
+ ? requestedVariantSet === '' || (requested[axis] ?? false)
899
+ : axis === 'EXTENDED_LIGHTING_AVAILABLE'
900
+ ? extendedLightingAvailable
901
+ : axis === 'PROBE_BLEND_AVAILABLE'
902
+ ? storageBuffer && (requested[axis] ?? false)
903
+ : axis === 'TRANSMISSION_AVAILABLE'
904
+ ? (requested[axis] ?? false)
905
+ : axis === 'DIRECTIONAL_PCSS_AVAILABLE'
906
+ ? directionalPcssCapable(backendKind)
907
+ : axis === 'PROJECTOR_AVAILABLE'
908
+ ? projectorAvailable
909
+ : (requested[axis] ?? false);
910
+ }
911
+ const sorted = Object.entries(filtered).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
912
+ return sorted.every(([, value]) => value)
913
+ ? requestedVariantSet === undefined
914
+ ? undefined
915
+ : ''
916
+ : sorted.map(([name, value]) => `${name}=${value}`).join('+');
917
+ }
918
+
919
+ /**
920
+ * Keep a single-source material on its canonical module identity. A manifest
921
+ * entry with no variants cannot consume a synthesized capability or geometry
922
+ * axis, so carrying one into the pipeline cache only creates an uncached lazy
923
+ * compile beside the prewarmed source module.
924
+ */
925
+ export function normalizeMaterialShaderVariantSet(
926
+ requestedVariantSet: string | undefined,
927
+ manifestEntry: MaterialShaderManifestEntry | undefined,
928
+ ): string | undefined {
929
+ return manifestEntry !== undefined && manifestEntry.variants.length === 0
930
+ ? undefined
931
+ : requestedVariantSet;
932
+ }