@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,88 @@
1
+ import type { Result, RhiDevice, ShaderModule } from '@forgeax/engine-rhi';
2
+ import { RhiError } from '@forgeax/engine-rhi';
3
+ import type { ShaderCatalog } from '@forgeax/engine-shader';
4
+
5
+ import { invokeDeviceCreateShaderModule } from './material-shader-policy';
6
+ import { runShimStep } from './renderer-helpers';
7
+
8
+ type AsyncCreateShaderModule = (
9
+ device: RhiDevice,
10
+ desc: { code: string; label?: string | undefined },
11
+ ) => Promise<Result<ShaderModule, RhiError>>;
12
+
13
+ /**
14
+ * Prewarm producer-declared material shaders and all published capability
15
+ * variants before the first prepared frame. Variant labels mirror the lazy
16
+ * material pipeline adapter's module lookup contract.
17
+ */
18
+ export async function prewarmRequiredMaterialShaders({
19
+ rhiDevice,
20
+ registry,
21
+ asyncCreateShaderModule,
22
+ requiredMaterialShaders,
23
+ seedShaderModule,
24
+ }: {
25
+ readonly rhiDevice: RhiDevice;
26
+ readonly registry: ShaderCatalog;
27
+ readonly asyncCreateShaderModule: AsyncCreateShaderModule | undefined;
28
+ readonly requiredMaterialShaders: readonly string[];
29
+ readonly seedShaderModule: (label: string, module: ShaderModule) => void;
30
+ }): Promise<void> {
31
+ for (const materialShaderId of requiredMaterialShaders) {
32
+ const lookup = registry.findMaterialArtifact(materialShaderId);
33
+ if (!lookup.ok) {
34
+ throw new RhiError({
35
+ code: 'shader-compile-failed',
36
+ expected: `declared render feature material shader '${materialShaderId}' is present in the loaded manifest`,
37
+ hint: `add material shader '${materialShaderId}' to the shader manifest or remove it from the feature declaration`,
38
+ });
39
+ }
40
+ const label = `module-${materialShaderId}`;
41
+ const shaderResult = await runShimStep(
42
+ () =>
43
+ asyncCreateShaderModule
44
+ ? asyncCreateShaderModule(rhiDevice, { code: lookup.value.source, label })
45
+ : invokeDeviceCreateShaderModule(rhiDevice, { code: lookup.value.source, label }),
46
+ 'shader-compile-failed',
47
+ `declared render feature material shader '${materialShaderId}' compiled`,
48
+ `inspect the composed WGSL for '${materialShaderId}' and check device.features`,
49
+ );
50
+ if (!shaderResult.ok) throw shaderResult.error;
51
+ seedShaderModule(label, shaderResult.value);
52
+
53
+ const prewarmedSources = new Map<string, ShaderModule>([
54
+ [lookup.value.source, shaderResult.value],
55
+ ]);
56
+ const manifestEntry = [...registry.materialShaderManifestEntries()].find(
57
+ (candidate) => candidate.identifier === materialShaderId,
58
+ );
59
+ if (manifestEntry === undefined) continue;
60
+
61
+ for (const variant of manifestEntry.variants) {
62
+ const variantLabel = `module-${materialShaderId}#${variant.definesKey}`;
63
+ if (variantLabel === label) continue;
64
+ let variantModule = prewarmedSources.get(variant.composedWgsl);
65
+ if (variantModule === undefined) {
66
+ const variantResult = await runShimStep(
67
+ () =>
68
+ asyncCreateShaderModule
69
+ ? asyncCreateShaderModule(rhiDevice, {
70
+ code: variant.composedWgsl,
71
+ label: variantLabel,
72
+ })
73
+ : invokeDeviceCreateShaderModule(rhiDevice, {
74
+ code: variant.composedWgsl,
75
+ label: variantLabel,
76
+ }),
77
+ 'shader-compile-failed',
78
+ `declared render feature material shader variant '${variantLabel}' compiled`,
79
+ `inspect the composed WGSL for '${materialShaderId}' variant '${variant.definesKey}' and check device.features`,
80
+ );
81
+ if (!variantResult.ok) throw variantResult.error;
82
+ variantModule = variantResult.value;
83
+ prewarmedSources.set(variant.composedWgsl, variantModule);
84
+ }
85
+ seedShaderModule(variantLabel, variantModule);
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,270 @@
1
+ // Dynamic mesh/material SSBO growth owner.
2
+ // Keeps the stable wrapper identity and capacity policy together with the
3
+ // device allocation producer; renderer lifetime only consumes this contract.
4
+
5
+ import {
6
+ MeshSsboCapacityExceededError,
7
+ MeshSsboCeilingReachedError,
8
+ } from '@forgeax/engine-assets-runtime';
9
+ import type { Buffer } from '@forgeax/engine-rhi';
10
+
11
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 / T-M2-05:
12
+ // the legacy MESH_SSBO_SLOT_COUNT / MATERIAL_UBO_TOTAL_BYTES /
13
+ // MESH_SSBO_TOTAL_BYTES literal-1024 module constants are gone. The
14
+ // renderer now starts at INITIAL_MESH_SSBO_SLOT_COUNT and grows on demand
15
+ // via `createMeshSsboGrowController` (pow2 doubling, ceiling =
16
+ // device.limits.maxStorageBufferBindingSize / MATERIAL_PER_ENTITY_STRIDE per
17
+ // plan-strategy §2.D-1). The shared allocation stride remains the material
18
+ // owner; only slotCount grows.
19
+ export const INITIAL_MESH_SSBO_SLOT_COUNT = 1024;
20
+
21
+ // ── createMeshSsboGrowController ───────────────────────────────────────────
22
+ //
23
+ // feat-20260608-mesh-ssbo-dynamic-grow-l1-lift-1024-entity-cap M2 / T-M2-05:
24
+ // pure factory that owns the mesh-SSBO + material-UBO grow state. State is
25
+ // closure-local (plan-strategy §2.D-4: no separate allocator class — state
26
+ // stays beside its createBuffer producer). Outer wrapper-object identity is
27
+ // stable across grow so PipelineState.meshStorageBuffer / materialUniformBuffer
28
+ // references never dangle (research §F8 / §3.1 R1); inner `.buffer` is
29
+ // replaced by a fresh createBuffer return value when the grow path runs.
30
+ //
31
+ // Grow algorithm (AC-05 / AC-06):
32
+ // 1. If `slotCount >= needed` → idempotent guard short-circuits with `{ ok: true }`.
33
+ // 2. Compute `targetSlots = nextPow2 doubling from slotCount until >= needed`.
34
+ // 3. If `targetSlots * stride > device.limits.maxStorageBufferBindingSize`
35
+ // → fire `MeshSsboCeilingReachedError` and return `{ ok: false, code: 'mesh-ssbo-ceiling-reached' }`.
36
+ // No createBuffer is called; recordFrame is expected to skip the frame.
37
+ // 4. createBuffer × 2 (mesh + material) sized at `targetSlots * stride`,
38
+ // using the usage flags captured at construction (mesh = STORAGE|COPY_DST,
39
+ // material = UNIFORM|COPY_DST — both unchanged from initialBuild).
40
+ // 5. Replace `state.mesh.buffer` and `state.material.buffer` in-place;
41
+ // update `state.mesh.sizeInBytes` and `state.material.sizeInBytes`;
42
+ // bump `state.slotCount = targetSlots`.
43
+ // 6. Defensive belt-and-suspenders: if the grow somehow lands with
44
+ // `state.slotCount < needed` (shouldn't happen given the pow2 invariant)
45
+ // → fire `MeshSsboCapacityExceededError` and return capacity-exceeded.
46
+ //
47
+ // Errors flow through `errorRegistry.fire` only (D-5: never throw — keeps the
48
+ // grow surface compatible with the recordFrame outer try/catch without
49
+ // dual-firing through 'webgpu-runtime-error').
50
+
51
+ /** Pow2 round-up — smallest power of 2 >= n (n>=1). */
52
+ function nextPow2(n: number): number {
53
+ if (n <= 1) return 1;
54
+ let v = 1;
55
+ while (v < n) v <<= 1;
56
+ return v;
57
+ }
58
+
59
+ /**
60
+ * WebGPU spec floor for `maxStorageBufferBindingSize` (128 MiB).
61
+ * @see {@link https://www.w3.org/TR/webgpu/#dom-supported-limits-maxstoragebufferbindingsize}
62
+ */
63
+ const WEBGPU_SPEC_FLOOR_MAX_STORAGE_BUFFER_BINDING_SIZE = 134217728;
64
+
65
+ /**
66
+ * Derive a usable storage-buffer ceiling from device limits.
67
+ *
68
+ * Mirrors the `SKIN_PALETTE_MAX_BINDING_BYTES` 0/undefined floor pattern
69
+ * (createRenderer.ts:4070-4073): when `maxStorageBufferBindingSize` is 0 or
70
+ * undefined (WebKit `downlevel_webgl2_defaults`), climb the fallback chain —
71
+ * `maxBufferSize` → `maxUniformBufferBindingSize` → WebGPU spec floor
72
+ * 134217728 (128 MiB).
73
+ *
74
+ * Pure helper — no device dependency, directly testable in node.
75
+ */
76
+ export function deriveStorageBufferCeiling(
77
+ limits: Readonly<{
78
+ maxStorageBufferBindingSize?: number;
79
+ maxBufferSize?: number;
80
+ maxUniformBufferBindingSize?: number;
81
+ }>,
82
+ ): number {
83
+ // Preferred: the device-reported storage-buffer binding size (when > 0).
84
+ if (
85
+ typeof limits.maxStorageBufferBindingSize === 'number' &&
86
+ limits.maxStorageBufferBindingSize > 0
87
+ ) {
88
+ return limits.maxStorageBufferBindingSize;
89
+ }
90
+ // Fallback 1: maxBufferSize (device-level buffer allocation limit).
91
+ if (typeof limits.maxBufferSize === 'number' && limits.maxBufferSize > 0) {
92
+ return limits.maxBufferSize;
93
+ }
94
+ // Fallback 2: maxUniformBufferBindingSize (uniform binding limit, lower
95
+ // but still a real device capacity signal).
96
+ if (
97
+ typeof limits.maxUniformBufferBindingSize === 'number' &&
98
+ limits.maxUniformBufferBindingSize > 0
99
+ ) {
100
+ return limits.maxUniformBufferBindingSize;
101
+ }
102
+ // Fallback 3: WebGPU spec floor — always non-zero, safe as last resort.
103
+ return WEBGPU_SPEC_FLOOR_MAX_STORAGE_BUFFER_BINDING_SIZE;
104
+ }
105
+
106
+ /**
107
+ * Mesh + material buffer wrapper carrying the inner `Buffer` handle plus
108
+ * the byte-size at the time of allocation. The wrapper-object identity is
109
+ * stable across grow events — only `.buffer` and `.sizeInBytes` are mutated
110
+ * in place — so `PipelineState.meshStorageBuffer` / `materialUniformBuffer`
111
+ * fields capture the wrapper once and survive grow events without a re-bind
112
+ * cycle through the public Renderer surface (research §F8 R1).
113
+ */
114
+ export interface MeshSsboBufferWrapper {
115
+ buffer: Buffer | null;
116
+ sizeInBytes: number;
117
+ }
118
+
119
+ export function requireMeshSsboBuffer(
120
+ wrapper: MeshSsboBufferWrapper,
121
+ ): asserts wrapper is MeshSsboBufferWrapper & { buffer: Buffer } {
122
+ if (wrapper.buffer === null) throw new Error('mesh SSBO initial allocation was not completed');
123
+ }
124
+
125
+ /** Closure-local grow state surfaced for spy assertions in unit tests. */
126
+ export interface MeshSsboState {
127
+ slotCount: number;
128
+ mesh: MeshSsboBufferWrapper;
129
+ material: MeshSsboBufferWrapper;
130
+ }
131
+
132
+ /** `growMeshSsbo` return shape (D-5: Result-like, never throws). */
133
+ export type MeshSsboGrowResult =
134
+ | { readonly ok: true }
135
+ | {
136
+ readonly ok: false;
137
+ readonly code: 'mesh-ssbo-ceiling-reached' | 'mesh-ssbo-capacity-exceeded';
138
+ /** Pre-grow slotCount — the caller can render up to this many slots (degraded subset). */
139
+ readonly degradedToSlotCount: number;
140
+ };
141
+
142
+ /** Minimal device surface the grow controller needs (mocked in unit tests). */
143
+ export interface MeshSsboGrowDevice {
144
+ readonly limits: { readonly maxStorageBufferBindingSize: number };
145
+ readonly createBuffer: (descriptor: {
146
+ readonly label?: string;
147
+ readonly size: number;
148
+ readonly usage: number;
149
+ readonly mappedAtCreation?: boolean;
150
+ }) => Buffer;
151
+ }
152
+
153
+ /** Minimal error-registry surface (just `fire` — `RhiErrorListenerRegistry` matches). */
154
+ export interface MeshSsboGrowErrorRegistry {
155
+ fire: (e: MeshSsboCeilingReachedError | MeshSsboCapacityExceededError) => void;
156
+ }
157
+
158
+ export interface MeshSsboGrowControllerInit {
159
+ readonly device: MeshSsboGrowDevice;
160
+ readonly errorRegistry: MeshSsboGrowErrorRegistry;
161
+ readonly initialSlotCount: number;
162
+ readonly perEntityStride: number;
163
+ readonly meshUsage: number;
164
+ readonly materialUsage: number;
165
+ }
166
+
167
+ export interface MeshSsboGrowController {
168
+ readonly state: MeshSsboState;
169
+ /** Allocate the initial mesh + material buffer pair at `initialSlotCount`. */
170
+ readonly initialBuild: () => void;
171
+ /** Grow to satisfy `neededSlots`; idempotent + ceiling-aware. */
172
+ readonly growMeshSsbo: (neededSlots: number) => MeshSsboGrowResult;
173
+ }
174
+
175
+ /**
176
+ * Build a closure-scoped mesh-SSBO grow controller. Module-scope so unit
177
+ * tests (`__tests__/mesh-ssbo-grow.test.ts`) can construct it with a fake
178
+ * device + spy errorRegistry without spinning up the full WebGPU renderer
179
+ * (charter F2 minimal surface — the controller is the testable seam).
180
+ */
181
+ export function createMeshSsboGrowController(
182
+ init: MeshSsboGrowControllerInit,
183
+ ): MeshSsboGrowController {
184
+ const { device, errorRegistry, initialSlotCount, perEntityStride, meshUsage, materialUsage } =
185
+ init;
186
+ // Wrapper-object identity is set once and shared with PipelineState.
187
+ // Inner buffer + sizeInBytes are mutated in place during grow.
188
+ const meshWrapper: MeshSsboBufferWrapper = {
189
+ buffer: null,
190
+ sizeInBytes: 0,
191
+ };
192
+ const materialWrapper: MeshSsboBufferWrapper = {
193
+ buffer: null,
194
+ sizeInBytes: 0,
195
+ };
196
+ const state: MeshSsboState = {
197
+ slotCount: 0,
198
+ mesh: meshWrapper,
199
+ material: materialWrapper,
200
+ };
201
+ let initialised = false;
202
+
203
+ const allocBufferPair = (slots: number): void => {
204
+ const sizeInBytes = slots * perEntityStride;
205
+ const meshBuf = device.createBuffer({
206
+ label: 'pbr-mesh-ssbo',
207
+ size: sizeInBytes,
208
+ usage: meshUsage,
209
+ mappedAtCreation: false,
210
+ });
211
+ const materialBuf = device.createBuffer({
212
+ label: 'pbr-material-ubo',
213
+ size: sizeInBytes,
214
+ usage: materialUsage,
215
+ mappedAtCreation: false,
216
+ });
217
+ meshWrapper.buffer = meshBuf;
218
+ meshWrapper.sizeInBytes = sizeInBytes;
219
+ materialWrapper.buffer = materialBuf;
220
+ materialWrapper.sizeInBytes = sizeInBytes;
221
+ state.slotCount = slots;
222
+ };
223
+
224
+ const initialBuild = (): void => {
225
+ if (initialised) return;
226
+ initialised = true;
227
+ allocBufferPair(initialSlotCount);
228
+ };
229
+
230
+ const growMeshSsbo = (neededSlots: number): MeshSsboGrowResult => {
231
+ // (1) idempotent guard — already large enough.
232
+ if (state.slotCount >= neededSlots) {
233
+ return { ok: true };
234
+ }
235
+ // (2) pow2 double until >= needed.
236
+ let target = state.slotCount > 0 ? state.slotCount : 1;
237
+ while (target < neededSlots) target = target * 2;
238
+ // Round up to nextPow2 of needed in case slotCount is 0 / not pow2.
239
+ target = Math.max(target, nextPow2(neededSlots));
240
+ const ceilingBytes = deriveStorageBufferCeiling(device.limits);
241
+ const targetBytes = target * perEntityStride;
242
+ // (3) ceiling check — refuse + fire structured error.
243
+ if (targetBytes > ceilingBytes) {
244
+ errorRegistry.fire(
245
+ new MeshSsboCeilingReachedError(neededSlots, state.slotCount, ceilingBytes),
246
+ );
247
+ return {
248
+ ok: false,
249
+ code: 'mesh-ssbo-ceiling-reached',
250
+ degradedToSlotCount: state.slotCount,
251
+ };
252
+ }
253
+ // (4) + (5) allocate fresh buffers + replace inner refs.
254
+ allocBufferPair(target);
255
+ // (6) defensive belt-and-suspenders.
256
+ if (state.slotCount < neededSlots) {
257
+ errorRegistry.fire(
258
+ new MeshSsboCapacityExceededError(neededSlots, state.slotCount, ceilingBytes),
259
+ );
260
+ return {
261
+ ok: false,
262
+ code: 'mesh-ssbo-capacity-exceeded',
263
+ degradedToSlotCount: state.slotCount,
264
+ };
265
+ }
266
+ return { ok: true };
267
+ };
268
+
269
+ return { state, initialBuild, growMeshSsbo };
270
+ }
@@ -0,0 +1,89 @@
1
+ import type { RhiDevice } from '@forgeax/engine-rhi';
2
+ import { RhiError } from '@forgeax/engine-rhi';
3
+ import type {
4
+ HealthListenerRegistry,
5
+ LostListenerRegistry,
6
+ RhiErrorListenerRegistry,
7
+ } from '../../lifecycle';
8
+ import type { RhiBackendPack } from '../backend-contract';
9
+
10
+ /** Wire one device's loss promise into the Renderer-owned observability channels. */
11
+ export function attachDeviceLostFanout(
12
+ device: RhiDevice,
13
+ pack: RhiBackendPack,
14
+ registries: {
15
+ lostRegistry: LostListenerRegistry;
16
+ errorRegistry: RhiErrorListenerRegistry;
17
+ healthRegistry: HealthListenerRegistry;
18
+ generation: number;
19
+ currentGeneration: () => number;
20
+ onStaleLoss?: () => void;
21
+ onDeviceLost?: (detail: string) => void;
22
+ },
23
+ ): void {
24
+ const {
25
+ lostRegistry,
26
+ errorRegistry,
27
+ healthRegistry,
28
+ generation,
29
+ currentGeneration,
30
+ onStaleLoss,
31
+ onDeviceLost,
32
+ } = registries;
33
+ const dispatch = (safe: {
34
+ readonly reason: 'destroyed' | 'unknown';
35
+ readonly message: string;
36
+ }) => {
37
+ const current = currentGeneration();
38
+ if (generation < current) {
39
+ onStaleLoss?.();
40
+ return;
41
+ }
42
+ const detail = `device.lost: ${safe.reason}; ${safe.message || '<empty>'}`;
43
+ // A candidate loss is not an active renderer loss until its generation is
44
+ // published. The candidate callback remains available to the recovery
45
+ // owner, while the public lost/health/error channels stay on the active
46
+ // generation only.
47
+ if (generation > current) {
48
+ onDeviceLost?.(detail);
49
+ return;
50
+ }
51
+ onDeviceLost?.(detail);
52
+ lostRegistry.fire(safe);
53
+ if (safe.reason !== 'destroyed') {
54
+ pack.instrumentation?.onDeviceLost?.();
55
+ healthRegistry.fire({
56
+ reason: 'device-lost',
57
+ detail: { lostReason: safe.reason, message: safe.message },
58
+ recoverable: true,
59
+ });
60
+ if (pack.translateErrorEventToRhiError) {
61
+ const translated = pack.translateErrorEventToRhiError(safe);
62
+ errorRegistry.fire(translated.error);
63
+ } else {
64
+ errorRegistry.fire(
65
+ new RhiError({
66
+ code: 'device-lost',
67
+ expected: 'device must remain alive (driver / browser must not destroy the GPUDevice)',
68
+ hint: `device-lost reason: ${safe.reason}; message: ${safe.message || '<empty>'}`,
69
+ }),
70
+ );
71
+ }
72
+ }
73
+ };
74
+ // Deterministic recovery fixtures may project a replacement loss promise
75
+ // without mutating the backend-owned device. Production packs leave this
76
+ // hook unset, so the native RHI promise remains the default source.
77
+ const loss = pack.instrumentation?.deviceLost?.(device) ?? device.lost;
78
+ loss
79
+ .then((info) => {
80
+ const safe = {
81
+ reason: info?.reason ?? 'unknown',
82
+ message: info?.message ?? '',
83
+ };
84
+ dispatch(safe);
85
+ })
86
+ .catch((cause: unknown) => {
87
+ dispatch({ reason: 'unknown', message: String(cause) });
88
+ });
89
+ }
@@ -0,0 +1,141 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import {
3
+ type DeviceResourceKind,
4
+ type DeviceScope,
5
+ type LifecycleCleanupFailure,
6
+ type LifecycleResourceSpec,
7
+ LifecycleTransaction,
8
+ } from '../../device/device-scope';
9
+
10
+ /** The private value published by one complete renderer device generation. */
11
+ export interface GenerationAggregate<
12
+ TDevice = unknown,
13
+ TContext = unknown,
14
+ TPipeline = unknown,
15
+ TProducerBindings = unknown,
16
+ TGraph = unknown,
17
+ > {
18
+ readonly generation: number;
19
+ readonly scope: DeviceScope;
20
+ readonly device: TDevice;
21
+ readonly context: TContext;
22
+ readonly pipeline: TPipeline;
23
+ readonly producerBindings: TProducerBindings;
24
+ readonly graph: TGraph | undefined;
25
+ }
26
+
27
+ export interface GenerationAssemblyInput<
28
+ TAggregate extends GenerationAggregate = GenerationAggregate,
29
+ > {
30
+ readonly scope: TAggregate['scope'];
31
+ readonly device: TAggregate['device'];
32
+ readonly context: TAggregate['context'];
33
+ readonly pipeline: TAggregate['pipeline'];
34
+ readonly producerBindings: TAggregate['producerBindings'];
35
+ readonly graph?: TAggregate['graph'];
36
+ readonly roots: readonly LifecycleResourceSpec<unknown>[];
37
+ }
38
+
39
+ export interface GenerationAssemblyFailure {
40
+ readonly code: 'generation-assembly-failed';
41
+ readonly generation: number;
42
+ readonly resourceKind: DeviceResourceKind;
43
+ readonly cause: unknown;
44
+ readonly cleanupFailures: readonly LifecycleCleanupFailure[];
45
+ }
46
+
47
+ export interface GenerationPublication<TAggregate> {
48
+ current: TAggregate;
49
+ }
50
+
51
+ export interface GenerationAllocator {
52
+ readonly activeGeneration: number;
53
+ next(): number;
54
+ }
55
+
56
+ const ROOT_ORDER = new Map<DeviceResourceKind, number>([
57
+ ['listener', 0],
58
+ ['surface', 1],
59
+ ['shader', 2],
60
+ ['pipeline', 3],
61
+ ['buffer', 4],
62
+ ['texture', 5],
63
+ ['binding', 6],
64
+ ['scene-table', 7],
65
+ ['feature', 8],
66
+ ['post-effect', 9],
67
+ ]);
68
+
69
+ function sortRoots(
70
+ roots: readonly LifecycleResourceSpec<unknown>[],
71
+ ): readonly LifecycleResourceSpec<unknown>[] {
72
+ return roots
73
+ .map((root, index) => ({ root, index }))
74
+ .sort((left, right) => {
75
+ const leftOrder = ROOT_ORDER.get(left.root.kind) ?? ROOT_ORDER.size;
76
+ const rightOrder = ROOT_ORDER.get(right.root.kind) ?? ROOT_ORDER.size;
77
+ return leftOrder - rightOrder || left.index - right.index;
78
+ })
79
+ .map(({ root }) => root);
80
+ }
81
+
82
+ /**
83
+ * Build all device-bound roots before exposing their generation aggregate.
84
+ * LifecycleTransaction owns creation failure rollback; this function only
85
+ * projects its failure into the generation assembly boundary.
86
+ */
87
+ export async function buildGenerationAggregate<
88
+ TAggregate extends GenerationAggregate = GenerationAggregate,
89
+ >(
90
+ input: GenerationAssemblyInput<TAggregate>,
91
+ ): Promise<Result<TAggregate, GenerationAssemblyFailure>> {
92
+ const transaction = new LifecycleTransaction(input.scope);
93
+ for (const root of sortRoots(input.roots)) transaction.add(root);
94
+
95
+ const lifecycle = await transaction.commit();
96
+ if (!lifecycle.ok) {
97
+ input.scope.abandon();
98
+ return err({
99
+ code: 'generation-assembly-failed',
100
+ generation: input.scope.generation,
101
+ resourceKind: lifecycle.error.primary.detail.resourceKind,
102
+ cause: lifecycle.error.primary.detail.cause,
103
+ cleanupFailures: lifecycle.error.cleanupFailures,
104
+ });
105
+ }
106
+
107
+ const aggregate = Object.freeze({
108
+ generation: input.scope.generation,
109
+ scope: input.scope,
110
+ device: input.device,
111
+ context: input.context,
112
+ pipeline: input.pipeline,
113
+ producerBindings: input.producerBindings,
114
+ graph: input.graph,
115
+ }) as TAggregate;
116
+ return ok(aggregate);
117
+ }
118
+
119
+ /** Replace the owner slot only after the complete candidate has been checked. */
120
+ export function publishGeneration<TAggregate>(
121
+ publication: GenerationPublication<TAggregate | undefined>,
122
+ candidate: TAggregate,
123
+ isPublishable: (candidate: TAggregate) => boolean,
124
+ ): void {
125
+ if (!isPublishable(candidate)) {
126
+ throw new Error('Generation candidate failed its final publication check.');
127
+ }
128
+ publication.current = candidate;
129
+ }
130
+
131
+ /** Allocate unique candidate generations while leaving the active value unchanged. */
132
+ export function createGenerationAllocator(activeGeneration: number): GenerationAllocator {
133
+ if (!Number.isSafeInteger(activeGeneration) || activeGeneration < 0) {
134
+ throw new RangeError('Active generation must be a non-negative safe integer.');
135
+ }
136
+ let nextGeneration = activeGeneration + 1;
137
+ return {
138
+ activeGeneration,
139
+ next: () => nextGeneration++,
140
+ };
141
+ }