@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,107 @@
1
+ import type { RhiDevice } from '@forgeax/engine-rhi';
2
+ import { rhi } from '@forgeax/engine-rhi-null';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { attachDeviceLostFanout } from '../assembly/recovery/device-loss-fanout';
5
+ import {
6
+ HealthListenerRegistry,
7
+ LostListenerRegistry,
8
+ RhiErrorListenerRegistry,
9
+ } from '../lifecycle';
10
+
11
+ function deferred<T>(): {
12
+ readonly promise: Promise<T>;
13
+ readonly resolve: (value: T) => void;
14
+ } {
15
+ let resolve!: (value: T) => void;
16
+ const promise = new Promise<T>((next) => {
17
+ resolve = next;
18
+ });
19
+ return { promise, resolve };
20
+ }
21
+
22
+ describe('device loss fan-out generation fence', () => {
23
+ it('counts a late old-generation notification without changing active health', async () => {
24
+ const lost = deferred<{ readonly reason: 'unknown'; readonly message: string }>();
25
+ const lostRegistry = new LostListenerRegistry();
26
+ const errorRegistry = new RhiErrorListenerRegistry();
27
+ const healthRegistry = new HealthListenerRegistry();
28
+ const currentGeneration = 2;
29
+ let staleLossEvents = 0;
30
+ let lostEvents = 0;
31
+ let healthEvents = 0;
32
+ let errorEvents = 0;
33
+ const device = { lost: lost.promise } as unknown as RhiDevice;
34
+ lostRegistry.add(() => {
35
+ lostEvents += 1;
36
+ });
37
+ healthRegistry.add(() => {
38
+ healthEvents += 1;
39
+ });
40
+ errorRegistry.add(() => {
41
+ errorEvents += 1;
42
+ });
43
+
44
+ attachDeviceLostFanout(
45
+ device,
46
+ { rhi },
47
+ {
48
+ lostRegistry,
49
+ errorRegistry,
50
+ healthRegistry,
51
+ generation: 1,
52
+ currentGeneration: () => currentGeneration,
53
+ onStaleLoss: () => {
54
+ staleLossEvents += 1;
55
+ },
56
+ },
57
+ );
58
+
59
+ lost.resolve({ reason: 'unknown', message: 'late retired-device notification' });
60
+ await Promise.resolve();
61
+ await Promise.resolve();
62
+
63
+ expect(currentGeneration).toBe(2);
64
+ expect(staleLossEvents).toBe(1);
65
+ expect(lostEvents).toBe(0);
66
+ expect(healthEvents).toBe(0);
67
+ expect(errorEvents).toBe(0);
68
+ expect(healthRegistry.getLastSnapshot().reason).toBe('alive');
69
+ });
70
+
71
+ it('uses the backend loss projection for deterministic recovery fixtures', async () => {
72
+ const native = deferred<{ readonly reason: 'unknown'; readonly message: string }>();
73
+ const projected = deferred<{ readonly reason: 'unknown'; readonly message: string }>();
74
+ const lostRegistry = new LostListenerRegistry();
75
+ const errorRegistry = new RhiErrorListenerRegistry();
76
+ const healthRegistry = new HealthListenerRegistry();
77
+ let lostEvents = 0;
78
+ lostRegistry.add(() => {
79
+ lostEvents += 1;
80
+ });
81
+ const device = { lost: native.promise } as unknown as RhiDevice;
82
+
83
+ attachDeviceLostFanout(
84
+ device,
85
+ {
86
+ rhi,
87
+ instrumentation: {
88
+ deviceLost: () => projected.promise,
89
+ },
90
+ },
91
+ {
92
+ lostRegistry,
93
+ errorRegistry,
94
+ healthRegistry,
95
+ generation: 0,
96
+ currentGeneration: () => 0,
97
+ },
98
+ );
99
+
100
+ projected.resolve({ reason: 'unknown', message: 'fixture-projected loss' });
101
+ await Promise.resolve();
102
+ await Promise.resolve();
103
+
104
+ expect(lostEvents).toBe(1);
105
+ expect(healthRegistry.getLastSnapshot().reason).toBe('device-lost');
106
+ });
107
+ });
@@ -148,4 +148,25 @@ describe('DeviceScope lifecycle transaction', () => {
148
148
  expect(scope.state).toBe('disposed');
149
149
  expect(scope.resourceDelta()).toBe(0);
150
150
  });
151
+
152
+ it('runs an adopted cleanup once across repeated scope termination', async () => {
153
+ const scope = DeviceScope.create(31, 'renderer');
154
+ let cleanupCount = 0;
155
+ const transaction = new LifecycleTransaction(scope);
156
+ transaction.add({
157
+ kind: 'pipeline',
158
+ create: () => ({ id: 'candidate-pipeline' }),
159
+ cleanup: () => {
160
+ cleanupCount += 1;
161
+ },
162
+ });
163
+
164
+ const result = await transaction.commit();
165
+
166
+ expect(result.ok).toBe(true);
167
+ scope.retire();
168
+ scope.dispose();
169
+ scope.abandon();
170
+ expect(cleanupCount).toBe(1);
171
+ });
151
172
  });
@@ -154,4 +154,27 @@ describe('Environment generation candidate and LKG lifecycle (M2)', () => {
154
154
  expect(recovered.value.generation).not.toBe(old.value.generation);
155
155
  expect(recovered.value.liveHandle).not.toBe(old.value.liveHandle);
156
156
  });
157
+
158
+ it('prepares a recovery root on a detached lifecycle without changing the active owner', async () => {
159
+ const active = new EnvironmentLifecycle(DeviceScope.create(20, 'environment-active'));
160
+ const initial = await active.ensure(frame('sky-active'));
161
+ expect(initial.ok).toBe(true);
162
+ if (!initial.ok) return;
163
+ active.publish(initial.value);
164
+ active.resetForRecover(21);
165
+ const before = active.inspect();
166
+
167
+ const candidateScope = DeviceScope.create(21, 'environment-candidate');
168
+ const candidate = active.createRecoveryCandidate(candidateScope);
169
+ const root = candidate.createRecoveryRoot(candidateScope);
170
+ const recovered =
171
+ (await root.create()) as import('../environment/generation').EnvironmentGeneration;
172
+
173
+ expect(active.inspect()).toEqual(before);
174
+ expect(candidate.inspect().candidate?.generation).toBe(recovered.generation);
175
+
176
+ await root.cleanup(recovered);
177
+ expect(candidate.inspect().candidate).toBeUndefined();
178
+ expect(active.inspect()).toEqual(before);
179
+ });
157
180
  });
@@ -0,0 +1,34 @@
1
+ import { err } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { exposeRenderer } from '../assembly/factory';
4
+ import type { RendererHostImplementation } from '../assembly/host-contract';
5
+ import { RendererContractFailureError } from '../errors/render';
6
+
7
+ describe('exposeRenderer error projection', () => {
8
+ it('reports an internal draw contract failure as a device operation failure', () => {
9
+ const host = {
10
+ inspect: () => ({ state: 'alive' }),
11
+ subscribeHostEvents: () => () => undefined,
12
+ drawFrame: () =>
13
+ err(
14
+ new RendererContractFailureError(
15
+ 'draw',
16
+ 'the Standard render owner did not submit a command buffer',
17
+ ),
18
+ ),
19
+ } as unknown as RendererHostImplementation;
20
+ const renderer = exposeRenderer(host);
21
+
22
+ const result = renderer.draw({} as never);
23
+
24
+ expect(result.ok).toBe(false);
25
+ if (!result.ok) {
26
+ expect(result.error.code).toBe('device-operation-failed');
27
+ if (result.error.code !== 'device-operation-failed') return;
28
+ expect(result.error.detail.cause).toMatchObject({
29
+ code: 'renderer-contract-failed',
30
+ detail: { operation: 'draw' },
31
+ });
32
+ }
33
+ });
34
+ });
@@ -32,6 +32,7 @@ import { renderLifecycleManifestUrl } from './shader-manifest-fixture';
32
32
 
33
33
  const assemblyDirectory = dirname(fileURLToPath(import.meta.url));
34
34
  const factorySourcePath = resolve(assemblyDirectory, '../assembly/factory.ts');
35
+ const webgpuRendererSourcePath = resolve(assemblyDirectory, '../assembly/webgpu-renderer.ts');
35
36
  const renderTargetHostSourcePath = resolve(assemblyDirectory, '../assembly/render-target-host.ts');
36
37
 
37
38
  function manifestUrl(): string {
@@ -127,7 +128,7 @@ describe('factory contract', () => {
127
128
  });
128
129
 
129
130
  it('forwards the bundler build identity into RenderSystem inspections', () => {
130
- const source = readFileSync(factorySourcePath, 'utf8');
131
+ const source = readFileSync(webgpuRendererSourcePath, 'utf8');
131
132
  expect(source).toContain('get build() {\n return internals.bundler?.build;\n },');
132
133
  });
133
134
 
@@ -1700,6 +1701,15 @@ describe('factory contract', () => {
1700
1701
  expect((await renderer.initialization).ok).toBe(true);
1701
1702
  expect(world.update().ok).toBe(true);
1702
1703
  expect(renderer.draw(input()).ok).toBe(true);
1704
+ expect(renderer.inspect().recovery).toMatchObject({
1705
+ phase: null,
1706
+ fromGeneration: 0,
1707
+ candidateGeneration: 0,
1708
+ attempt: 0,
1709
+ lastOutcome: 'none',
1710
+ rehydratedRoots: 0,
1711
+ staleLossEvents: 0,
1712
+ });
1703
1713
  const beforeLoss = renderer.inspect().bloom;
1704
1714
  const beforeDeviceGeneration = renderer.inspect().temporal.deviceGeneration;
1705
1715
  expect(beforeLoss.state).toBe('active');
@@ -1710,15 +1720,34 @@ describe('factory contract', () => {
1710
1720
  const failed = await renderer.recover();
1711
1721
  expect(failed.ok).toBe(false);
1712
1722
  expect(renderer.health().reason).toBe('device-lost');
1723
+ expect(renderer.inspect().recovery).toMatchObject({
1724
+ phase: null,
1725
+ fromGeneration: 0,
1726
+ candidateGeneration: 1,
1727
+ attempt: 1,
1728
+ lastOutcome: 'failed',
1729
+ rehydratedRoots: 0,
1730
+ staleLossEvents: 0,
1731
+ });
1713
1732
  expect(renderer.inspect().perFramePassNames).toEqual([]);
1714
1733
  expect(renderer.inspect().standardLighting).toBeUndefined();
1715
1734
  const recovered = await renderer.recover();
1716
1735
  expect(recovered.ok).toBe(true);
1717
- // Recovery publishes a device but not a graph. The first post-recovery
1718
- // draw must rebuild both the graph accessors and Standard inspection
1719
- // against the replacement device.
1720
- expect(renderer.inspect().perFramePassNames).toEqual([]);
1721
- expect(renderer.inspect().standardLighting).toBeUndefined();
1736
+ expect(renderer.inspect().recovery).toMatchObject({
1737
+ phase: null,
1738
+ fromGeneration: 0,
1739
+ candidateGeneration: 1,
1740
+ attempt: 2,
1741
+ lastOutcome: 'succeeded',
1742
+ rehydratedRoots: expect.any(Number),
1743
+ staleLossEvents: 0,
1744
+ });
1745
+ expect(renderer.inspect().recovery.rehydratedRoots).toBeGreaterThan(0);
1746
+ // Recovery publishes only after the retained CPU/LKG frame has prepared
1747
+ // the replacement graph and visible residency. The first post-recovery
1748
+ // draw therefore starts with graph accessors and Standard inspection.
1749
+ expect(renderer.inspect().perFramePassNames.length).toBeGreaterThan(0);
1750
+ expect(renderer.inspect().standardLighting).toBeDefined();
1722
1751
  expect(world.update().ok).toBe(true);
1723
1752
  expect(renderer.draw(input()).ok).toBe(true);
1724
1753
  expect(renderer.inspect().perFramePassNames.length).toBeGreaterThan(0);
@@ -1868,7 +1897,11 @@ describe('factory contract', () => {
1868
1897
  await Promise.resolve();
1869
1898
  await Promise.resolve();
1870
1899
  expect(renderer.health().reason).toBe('device-lost');
1871
- expect((await renderer.recover()).ok).toBe(false);
1900
+ const failedRecovery = await renderer.recover();
1901
+ expect(failedRecovery.ok).toBe(false);
1902
+ if (!failedRecovery.ok) {
1903
+ expect(failedRecovery.error.code).toBe('recovery-failed');
1904
+ }
1872
1905
  expect(renderer.health().reason).toBe('device-lost');
1873
1906
  expect(createdReplacementHandles.size).toBeGreaterThan(0);
1874
1907
  expect(
@@ -1907,4 +1940,86 @@ describe('factory contract', () => {
1907
1940
  await renderer.dispose();
1908
1941
  }
1909
1942
  });
1943
+
1944
+ it('reports a structured disposed outcome when recovery is interrupted by dispose', async () => {
1945
+ const adapter = new RhiNullAdapter();
1946
+ const lost = deferred<{ readonly reason: 'unknown'; readonly message: string }>();
1947
+ const recoveryAdapterEntered = deferred<void>();
1948
+ const releaseRecoveryAdapter = deferred<void>();
1949
+ const firstDevice = (await adapter.requestDevice()).unwrap();
1950
+ const lostProxy = new Proxy(firstDevice, {
1951
+ get(target, property) {
1952
+ if (property === 'lost') return lost.promise;
1953
+ const value = Reflect.get(target, property, target);
1954
+ return typeof value === 'function' ? value.bind(target) : value;
1955
+ },
1956
+ });
1957
+ let requestCount = 0;
1958
+ const recoverableRhi = {
1959
+ ...rhi,
1960
+ requestAdapter: async () => {
1961
+ requestCount += 1;
1962
+ if (requestCount === 1) {
1963
+ return ok({
1964
+ features: adapter.features,
1965
+ limits: adapter.limits,
1966
+ requestDevice: async () => ok(lostProxy),
1967
+ } as never);
1968
+ }
1969
+ recoveryAdapterEntered.resolve();
1970
+ await releaseRecoveryAdapter.promise;
1971
+ return ok({
1972
+ features: adapter.features,
1973
+ limits: adapter.limits,
1974
+ requestDevice: async () => ok(firstDevice),
1975
+ } as never);
1976
+ },
1977
+ } as never;
1978
+ const renderer = await constructRenderer(
1979
+ { width: 64, height: 64, getContext: () => null },
1980
+ { rhi: recoverableRhi },
1981
+ { shaderManifestUrl: renderLifecycleManifestUrl() },
1982
+ );
1983
+
1984
+ try {
1985
+ expect((await renderer.initialization).ok).toBe(true);
1986
+ lost.reject({ reason: 'unknown', message: 'forced device loss' });
1987
+ await Promise.resolve();
1988
+ await Promise.resolve();
1989
+ expect(renderer.health().reason).toBe('device-lost');
1990
+
1991
+ const recovery = renderer.recover();
1992
+ await recoveryAdapterEntered.promise;
1993
+ expect(renderer.inspect().recovery.phase).toBe('acquire-adapter');
1994
+ expect((await renderer.dispose()).ok).toBe(true);
1995
+ releaseRecoveryAdapter.resolve();
1996
+
1997
+ const result = await recovery;
1998
+ expect(result.ok).toBe(false);
1999
+ if (!result.ok) {
2000
+ if (result.error.code !== 'recovery-failed') {
2001
+ throw new Error(`Expected recovery-failed, received ${result.error.code}`);
2002
+ }
2003
+ expect(result.error.detail).toMatchObject({
2004
+ phase: 'cleanup',
2005
+ oldGeneration: 0,
2006
+ candidateGeneration: 1,
2007
+ attempt: 1,
2008
+ lastOutcome: 'disposed',
2009
+ rehydratedRoots: 0,
2010
+ staleLossEvents: 0,
2011
+ });
2012
+ }
2013
+ expect(renderer.inspect().recovery).toMatchObject({
2014
+ phase: null,
2015
+ attempt: 1,
2016
+ lastOutcome: 'disposed',
2017
+ rehydratedRoots: 0,
2018
+ staleLossEvents: 0,
2019
+ });
2020
+ } finally {
2021
+ releaseRecoveryAdapter.resolve();
2022
+ await renderer.dispose();
2023
+ }
2024
+ });
1910
2025
  });
@@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest';
3
3
  import { FALLBACK_BYTES_PER_ROW } from '../ibl/skylight-bind-group';
4
4
 
5
5
  const ownerSource = readFileSync(new URL('../ibl/skylight-bind-group.ts', import.meta.url), 'utf8');
6
- const rendererSource = readFileSync(new URL('../assembly/factory.ts', import.meta.url), 'utf8');
6
+ const rendererSource = readFileSync(
7
+ new URL('../assembly/webgpu-ready.ts', import.meta.url),
8
+ 'utf8',
9
+ );
7
10
 
8
11
  describe('fallback texture row-stride owner', () => {
9
12
  it('keeps both 1x1 fallback upload paths on one owner', () => {
@@ -120,9 +120,17 @@ describe('GpuScene', () => {
120
120
  expect(created.scene.inspect().uploadBytes).toBe(bytesBefore);
121
121
 
122
122
  created.scene.commitTemporalFrame(true).unwrap();
123
- expect(queue.writes).toHaveLength(writesBefore + 1);
123
+ expect(queue.writes).toHaveLength(writesBefore);
124
+ expect(created.scene.inspect().uploadBytes).toBe(bytesBefore);
125
+
126
+ const dirty = projection.apply([
127
+ { kind: 'update-transform', worldId: 0, entityKey: 0, world: snapshot(0, 3).transform.world },
128
+ ]);
129
+ created.scene.sync(dirty).unwrap();
130
+ created.scene.commitTemporalFrame(true).unwrap();
131
+ expect(queue.writes).toHaveLength(writesBefore + 2);
124
132
  expect(created.scene.inspect().uploadBytes).toBe(
125
- bytesBefore + created.scene.inspect().tables.transform.bytes,
133
+ bytesBefore + 2 * GPU_SCENE_LAYOUTS.transform.stride,
126
134
  );
127
135
  });
128
136
 
@@ -200,4 +208,32 @@ describe('GpuScene', () => {
200
208
  gpuSceneFieldOffset(GPU_SCENE_LAYOUTS.transform, 'previousWorld') + 12 * 4;
201
209
  expect(new DataView(transformWrite.bytes.buffer).getFloat32(previousTranslation, true)).toBe(9);
202
210
  });
211
+
212
+ it('keeps a recycled slot allocated for the replacement identity', () => {
213
+ const queue = new RecordingQueue();
214
+ const created = GpuScene.create(createDevice(queue), 1).unwrap();
215
+ if (created.status !== 'available') return;
216
+ const projection = new RenderScene();
217
+ created.scene.sync(projection.apply([{ kind: 'create', snapshot: snapshot(1, 2) }])).unwrap();
218
+
219
+ const recycled = projection.apply([
220
+ { kind: 'remove', worldId: 0, entityKey: 1 },
221
+ { kind: 'create', snapshot: snapshot(2, 7) },
222
+ ]);
223
+ expect(recycled.removedSlots[0]?.slot).toBe(recycled.createdSlots[0]?.slot);
224
+ created.scene.sync(recycled).unwrap();
225
+
226
+ expect(() =>
227
+ created.scene.sync(
228
+ projection.apply([
229
+ {
230
+ kind: 'update-transform',
231
+ worldId: 0,
232
+ entityKey: 2,
233
+ world: snapshot(2, 11).transform.world,
234
+ },
235
+ ]),
236
+ ),
237
+ ).not.toThrow();
238
+ });
203
239
  });
@@ -38,9 +38,11 @@ describe('IBL face uniform RHI contract', () => {
38
38
  const badFace = writeFaceUniforms(device, bufferResult.value, 6, new Float32Array(16));
39
39
  const badMatrix = writeFaceUniforms(device, bufferResult.value, 0, new Float32Array(15));
40
40
  const badSubPass = writePrefilterUniforms(device, bufferResult.value, 30, 0.5, 128);
41
+ const badMipCount = writePrefilterUniforms(device, bufferResult.value, 0, 0.5, 128, 0);
41
42
 
42
43
  expect(badFace).toMatchObject({ ok: false, error: { code: 'webgpu-runtime-error' } });
43
44
  expect(badMatrix).toMatchObject({ ok: false, error: { code: 'webgpu-runtime-error' } });
44
45
  expect(badSubPass).toMatchObject({ ok: false, error: { code: 'webgpu-runtime-error' } });
46
+ expect(badMipCount).toMatchObject({ ok: false, error: { code: 'webgpu-runtime-error' } });
45
47
  });
46
48
  });
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const source = readFileSync(new URL('../device/gpu-residency.ts', import.meta.url), 'utf8');
5
+ const iblSource = readFileSync(new URL('../ibl/IblPipelineCache.ts', import.meta.url), 'utf8');
6
+
7
+ describe('IBL residency publication', () => {
8
+ it('publishes ready and the idempotent cube only after submission succeeds', () => {
9
+ const precomputeCall = source.indexOf('const runResult = await runIblPrecompute({');
10
+ const precomputeSuccess = source.indexOf('if (!runResult.ok)', precomputeCall);
11
+ const sourceReady = source.indexOf("status: 'ready'", precomputeCall);
12
+ const cubeRegistration = source.indexOf('const cubeHandle = regResult.value;', precomputeCall);
13
+ const mapPublication = source.indexOf(
14
+ 'this.cubemapIdempotentMap.set(sourceId, cubeHandle);',
15
+ precomputeCall,
16
+ );
17
+ expect(precomputeCall).toBeGreaterThan(-1);
18
+ expect(precomputeSuccess).toBeGreaterThan(precomputeCall);
19
+ expect(sourceReady).toBeGreaterThan(precomputeSuccess);
20
+ expect(cubeRegistration).toBeGreaterThan(precomputeSuccess);
21
+ expect(mapPublication).toBeGreaterThan(precomputeSuccess);
22
+ });
23
+
24
+ it('keeps pending publication before asynchronous pipeline work', () => {
25
+ const pending = source.indexOf("status: 'pending'");
26
+ const precompute = source.indexOf('const runResult = await runIblPrecompute({');
27
+ expect(pending).toBeGreaterThan(-1);
28
+ expect(pending).toBeLessThan(precompute);
29
+ });
30
+
31
+ it('promotes the IBL candidate only after the completion fence', () => {
32
+ const runStart = iblSource.indexOf('export async function runIblPrecompute(');
33
+ const fence = iblSource.indexOf('await device.queue.onSubmittedWorkDone()', runStart);
34
+ const promotion = iblSource.indexOf(
35
+ 'cache.irradianceTexture = promoted.irradianceTexture',
36
+ runStart,
37
+ );
38
+ const ready = source.indexOf("status: 'ready'", source.indexOf('_uploadCubemapFromEquirect'));
39
+ expect(runStart).toBeGreaterThan(-1);
40
+ expect(fence).toBeGreaterThan(runStart);
41
+ expect(promotion).toBeGreaterThan(fence);
42
+ expect(ready).toBeGreaterThan(fence);
43
+ expect(iblSource.slice(runStart, fence)).not.toContain('cache.irradianceTexture =');
44
+ expect(iblSource.slice(runStart, fence)).not.toContain('cache.prefilterTexture =');
45
+ expect(iblSource.slice(runStart, fence)).not.toContain('cache.brdfLutTexture =');
46
+ });
47
+
48
+ it('awaits precompute before residency publication', () => {
49
+ const call = source.indexOf('const runResult = await runIblPrecompute({');
50
+ const success = source.indexOf('if (!runResult.ok)', call);
51
+ const ready = source.indexOf("status: 'ready'", call);
52
+ const map = source.indexOf('this.cubemapIdempotentMap.set(sourceId, cubeHandle);', call);
53
+ expect(call).toBeGreaterThan(-1);
54
+ expect(success).toBeGreaterThan(call);
55
+ expect(ready).toBeGreaterThan(success);
56
+ expect(map).toBeGreaterThan(success);
57
+ });
58
+
59
+ it('keeps candidate and transient resources under the DeviceScope owner', () => {
60
+ expect(iblSource).toContain("scope._adopt('texture', texture");
61
+ expect(source).toContain("scope._adopt('texture', equirectGpuTex");
62
+ expect(source).toContain("scope._adopt('buffer', faceBuf");
63
+ expect(source).toContain("scope._adopt('buffer', prefBuf");
64
+ expect(source).toContain("scope._adopt('buffer', cubeVertex");
65
+ const fence = iblSource.indexOf('await device.queue.onSubmittedWorkDone()');
66
+ const candidateWrites = iblSource.indexOf('candidate.irradianceTexture = adoptIblTexture');
67
+ expect(candidateWrites).toBeLessThan(fence);
68
+ });
69
+
70
+ it('fences each IBL stage before recording the next stage', () => {
71
+ const runStart = iblSource.indexOf('export async function runIblPrecompute(');
72
+ const stages = ['equirect-to-cube', 'prefilter', 'brdf-lut'];
73
+ let previous = runStart;
74
+ for (const stage of stages) {
75
+ const marker = iblSource.indexOf(`await submitStage('${stage}'`, previous);
76
+ expect(marker).toBeGreaterThan(previous);
77
+ previous = marker;
78
+ }
79
+ expect(iblSource.match(/await submitStage\('/g)).toHaveLength(stages.length);
80
+ expect(iblSource).toContain('await device.queue.onSubmittedWorkDone()');
81
+ expect(
82
+ iblSource.indexOf('cache.irradianceTexture = promoted.irradianceTexture'),
83
+ ).toBeGreaterThan(previous);
84
+ });
85
+
86
+ it('does not promote or increment counters after a failed stage', () => {
87
+ const runStart = iblSource.indexOf('export async function runIblPrecompute(');
88
+ const promotion = iblSource.indexOf(
89
+ 'cache.irradianceTexture = promoted.irradianceTexture',
90
+ runStart,
91
+ );
92
+ const counters = iblSource.indexOf('cache.irradianceBakeCount += 1', runStart);
93
+ const stageFailure = iblSource.indexOf('return err(badAlloc', runStart);
94
+ expect(stageFailure).toBeGreaterThan(runStart);
95
+ expect(stageFailure).toBeLessThan(promotion);
96
+ expect(promotion).toBeLessThan(counters);
97
+ expect(iblSource).toContain('stage}-device-scope-generation');
98
+ });
99
+
100
+ it('fences each irradiance face before the next face', () => {
101
+ const runStart = iblSource.indexOf('export async function runIblPrecompute(');
102
+ const irradianceStart = iblSource.indexOf('// (b) irradiance convolve', runStart);
103
+ const prefilterStart = iblSource.indexOf('// (c) prefilter env', irradianceStart);
104
+ const faceMarkers = iblSource.match(/submitStage\(`irradiance-face-\$\{face\}`/g) ?? [];
105
+ expect(faceMarkers).toHaveLength(1);
106
+ expect(iblSource.slice(irradianceStart, prefilterStart)).toMatch(
107
+ /await submitStage\(`irradiance-face-\$\{face\}`, true\)/,
108
+ );
109
+ expect(iblSource.slice(irradianceStart, prefilterStart)).not.toContain(
110
+ "await submitStage('irradiance', true)",
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,46 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { InstanceProjectionStore } from '../instances';
4
+
5
+ function matrices(x: number): Float32Array {
6
+ return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, 0, 0, 1]);
7
+ }
8
+
9
+ describe('renderer instance projection', () => {
10
+ it('derives independent complete snapshots for consumers that skip different updates', () => {
11
+ const world = new World();
12
+ const first = new InstanceProjectionStore();
13
+ const late = new InstanceProjectionStore();
14
+ const before = first.project(world, 1, matrices(0));
15
+ late.project(world, 1, matrices(0));
16
+ first.project(world, 1, matrices(1));
17
+ first.project(world, 1, matrices(2));
18
+ const latest = late.project(world, 1, matrices(2));
19
+ expect(before.transforms[12]).toBe(0);
20
+ expect(latest.transforms[12]).toBe(2);
21
+ const stable = late.project(world, 1, matrices(2));
22
+ expect(stable.transforms).toBe(latest.transforms);
23
+ expect(stable.revision).toBe(latest.revision);
24
+ });
25
+
26
+ it('does not alias identical entity numbers across World identities', () => {
27
+ const projection = new InstanceProjectionStore();
28
+ const a = projection.project(new World(), 1, matrices(10));
29
+ const b = projection.project(new World(), 1, matrices(99));
30
+ expect(a.collectionId).not.toBe(b.collectionId);
31
+ expect(a.transforms[12]).toBe(10);
32
+ expect(b.transforms[12]).toBe(99);
33
+ });
34
+
35
+ it('rebuilds from unchanged source after dropping renderer resources', () => {
36
+ const world = new World();
37
+ const transforms = matrices(4);
38
+ const projection = new InstanceProjectionStore();
39
+ projection.project(world, 1, transforms);
40
+ projection.dispose();
41
+ expect(projection.project(world, 1, transforms).transforms).toEqual(transforms);
42
+ expect(new InstanceProjectionStore().project(world, 1, transforms).transforms).toEqual(
43
+ transforms,
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,62 @@
1
+ import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import { registerPropagateTransforms, Transform } from '@forgeax/engine-scene';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { Camera } from '../components/camera';
6
+ import { Instances } from '../components/instances';
7
+ import { MeshFilter } from '../components/mesh-filter';
8
+ import { MeshRenderer } from '../components/mesh-renderer';
9
+ import { extractFrames } from '../render-system-extract';
10
+
11
+ function matrices(count: number, x: number): Float32Array {
12
+ const out = new Float32Array(count * 16);
13
+ for (let i = 0; i < count; i++) {
14
+ out[i * 16] = out[i * 16 + 5] = out[i * 16 + 10] = out[i * 16 + 15] = 1;
15
+ out[i * 16 + 12] = x;
16
+ }
17
+ return out;
18
+ }
19
+
20
+ function scene(count: number, x: number) {
21
+ const world = new World();
22
+ registerPropagateTransforms(world);
23
+ world
24
+ .spawn(
25
+ { component: Transform, data: { pos: [0, 0, 5] } },
26
+ { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
27
+ )
28
+ .unwrap();
29
+ const entity = world
30
+ .spawn(
31
+ { component: Transform, data: {} },
32
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
33
+ { component: MeshRenderer, data: {} },
34
+ { component: Instances, data: { transforms: matrices(count, x) } },
35
+ )
36
+ .unwrap();
37
+ world.update(0).unwrap();
38
+ return { world, entity };
39
+ }
40
+
41
+ describe('World-owned instance authoring', () => {
42
+ it.each([1500, 10000, 20000])('extracts %s matrices without creating a Renderer', (count) => {
43
+ const { world } = scene(count, 0);
44
+ const snapshot = extractFrames([world], 0).renderables[0]?.instances;
45
+ expect(snapshot?.instanceCount).toBe(count);
46
+ expect(snapshot?.transforms).toEqual(matrices(count, 0));
47
+ });
48
+
49
+ it('keeps worlds isolated and lets independent consumers observe skipped updates', () => {
50
+ const a = scene(2, 0);
51
+ const b = scene(2, 1);
52
+ const before = extractFrames([a.world], 0).renderables[0]?.instances;
53
+ expect(extractFrames([b.world], 0).renderables[0]?.instances?.transforms[12]).toBe(1);
54
+ a.world.set(a.entity, Instances, { transforms: matrices(2, 0.5) }).unwrap();
55
+ extractFrames([a.world], 0);
56
+ a.world.set(a.entity, Instances, { transforms: matrices(2, -0.5) }).unwrap();
57
+ const late = extractFrames([a.world], 0).renderables[0]?.instances;
58
+ expect(late?.transforms[12]).toBe(-0.5);
59
+ expect(before?.transforms[12]).toBe(0);
60
+ expect(extractFrames([b.world], 0).renderables[0]?.instances?.transforms[12]).toBe(1);
61
+ });
62
+ });