@forgeax/engine-runtime 0.1.20 → 0.1.23

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 (86) hide show
  1. package/README.md +105 -3
  2. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  5. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  7. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  9. package/dist/createRenderer.d.ts.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.mjs +4 -2
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/sprite-param-values.d.ts +1 -1
  14. package/package.json +30 -30
  15. package/src/__tests__/active-camera.unit.test.ts +6 -8
  16. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  17. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  18. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  19. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  20. package/src/__tests__/asset.unit.test.ts +5 -2
  21. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  22. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  23. package/src/__tests__/components.test-d.ts +19 -9
  24. package/src/__tests__/components.unit.test.ts +36 -26
  25. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  26. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  27. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  28. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  29. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  30. package/src/__tests__/errors.unit.test.ts +54 -20
  31. package/src/__tests__/extract-frames-merge.test.ts +51 -40
  32. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  33. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  34. package/src/__tests__/geometry.unit.test.ts +7 -10
  35. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  36. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  37. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
  38. package/src/__tests__/light-snapshot.test-d.ts +35 -1
  39. package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
  40. package/src/__tests__/lights.unit.test.ts +223 -308
  41. package/src/__tests__/loader-name-survival.test.ts +8 -9
  42. package/src/__tests__/package-promotion.test.ts +7 -11
  43. package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
  44. package/src/__tests__/pipeline.unit.test.ts +68 -226
  45. package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
  46. package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
  47. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  48. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  49. package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
  50. package/src/__tests__/render-system-extract.test.ts +5 -5
  51. package/src/__tests__/render-system-mega.test.ts +30 -41
  52. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
  53. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
  54. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  55. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  56. package/src/__tests__/renderer-surface.unit.test.ts +229 -5
  57. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  58. package/src/__tests__/resolveName.test.ts +14 -2
  59. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  60. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  61. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  62. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  63. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  64. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  65. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  66. package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
  67. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  68. package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
  69. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  70. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  71. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
  72. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  73. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  74. package/src/__tests__/ssao-bgl.test.ts +12 -17
  75. package/src/__tests__/ssao-passes.test.ts +19 -2
  76. package/src/__tests__/systems.unit.test.ts +69 -205
  77. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  78. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  79. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  80. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  81. package/src/collect-scene-asset.ts +1 -1
  82. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  83. package/src/createRenderer.ts +2 -0
  84. package/src/index.ts +1 -2
  85. package/src/sprite-param-values.ts +1 -1
  86. package/dist/.tsbuildinfo +0 -1
@@ -40,6 +40,8 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
40
40
  return code;
41
41
  case 'taa-unavailable':
42
42
  return code;
43
+ case 'standard-profile-invalid':
44
+ return code;
43
45
  case 'frame-receipt-stale':
44
46
  return code;
45
47
  case 'renderer-contract-failed':
@@ -64,11 +66,23 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
64
66
  return code;
65
67
  case 'equirect-projection-failed':
66
68
  return code;
67
- case 'hdrp-light-budget-exceeded':
69
+ case 'standard-light-budget-exceeded':
70
+ return code;
71
+ case 'standard-cluster-index-overflow':
72
+ return code;
73
+ case 'standard-cluster-transport-unavailable':
74
+ return code;
75
+ case 'render-target-descriptor-invalid':
76
+ return code;
77
+ case 'render-target-capability-missing':
78
+ return code;
79
+ case 'render-target-state-invalid':
68
80
  return code;
69
- case 'hdrp-index-list-overflow':
81
+ case 'render-target-operation-failed':
70
82
  return code;
71
- case 'hdrp-deferred-caps-insufficient':
83
+ case 'reflection-probe-budget-exceeded':
84
+ return code;
85
+ case 'render-intent-invalid':
72
86
  return code;
73
87
  case 'point-shadow-atlas-uninitialized':
74
88
  return code;
@@ -114,6 +128,7 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
114
128
  return code;
115
129
  case 'points-lines-prepare-failed':
116
130
  return code;
131
+ case 'light-resource-unavailable':
117
132
  case 'projector-binding-failed':
118
133
  return code;
119
134
  case 'volume-owner-conflict':
@@ -186,6 +201,10 @@ function narrowRenderError(err: RenderError): void {
186
201
  void err.detail.operation;
187
202
  void err.detail.causes;
188
203
  break;
204
+ case 'standard-profile-invalid':
205
+ void err.detail.field;
206
+ void err.detail.actual;
207
+ break;
189
208
  case 'frame-receipt-stale':
190
209
  void err.detail.frameId;
191
210
  void err.detail.receiptGeneration;
@@ -210,7 +229,9 @@ function narrowRenderError(err: RenderError): void {
210
229
  break;
211
230
  case 'shadow-invalid-config':
212
231
  void err.detail.field; // string
213
- void err.detail.value; // number
232
+ void err.detail.actual; // number
233
+ void err.detail.bound;
234
+ void err.detail.reason; // string
214
235
  break;
215
236
  case 'environment-source-conflict':
216
237
  void err.detail.owners;
@@ -237,17 +258,51 @@ function narrowRenderError(err: RenderError): void {
237
258
  case 'equirect-projection-failed':
238
259
  void err.detail.handle; // number
239
260
  break;
240
- case 'hdrp-light-budget-exceeded':
261
+ case 'standard-light-budget-exceeded':
241
262
  void err.detail.actual; // number
242
263
  void err.detail.budget; // number
243
264
  break;
244
- case 'hdrp-index-list-overflow':
265
+ case 'standard-cluster-index-overflow':
245
266
  void err.detail.actual; // number
246
267
  void err.detail.capacity; // number
247
268
  break;
248
- case 'hdrp-deferred-caps-insufficient':
249
- void err.detail.actual; // number
250
- void err.detail.expected; // number
269
+ case 'standard-cluster-transport-unavailable':
270
+ void err.detail.requested; // number
271
+ void err.detail.admitted; // 0
272
+ break;
273
+ case 'render-target-descriptor-invalid':
274
+ void err.detail.field;
275
+ void err.detail.value;
276
+ void err.detail.expected;
277
+ break;
278
+ case 'render-target-capability-missing':
279
+ void err.detail.operation;
280
+ void err.detail.requested;
281
+ void err.detail.capability;
282
+ void err.detail.actual;
283
+ break;
284
+ case 'render-target-state-invalid':
285
+ void err.detail.operation;
286
+ void err.detail.reason;
287
+ void err.detail.state;
288
+ void err.detail.generation;
289
+ break;
290
+ case 'render-target-operation-failed':
291
+ void err.detail.operation;
292
+ void err.detail.stage;
293
+ void err.detail.generation;
294
+ void err.detail.cause;
295
+ void err.detail.recovery;
296
+ break;
297
+ case 'reflection-probe-budget-exceeded':
298
+ void err.detail.actual;
299
+ void err.detail.budget;
300
+ break;
301
+ case 'render-intent-invalid':
302
+ void err.detail.component;
303
+ void err.detail.field;
304
+ void err.detail.value;
305
+ void err.detail.allowed;
251
306
  break;
252
307
  case 'point-shadow-atlas-uninitialized':
253
308
  // No detail on this class.
@@ -338,6 +393,15 @@ function narrowRenderError(err: RenderError): void {
338
393
  void err.detail.owner;
339
394
  void err.detail.generation;
340
395
  break;
396
+ case 'light-resource-unavailable':
397
+ void err.detail.entity;
398
+ void err.detail.feature;
399
+ void err.detail.generation;
400
+ void err.detail.sourceKey;
401
+ void err.detail.reason;
402
+ void err.expected;
403
+ void err.hint;
404
+ break;
341
405
  case 'transmission-capability-missing':
342
406
  void err.detail.material;
343
407
  void err.detail.capability;
@@ -40,22 +40,22 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
40
40
  {
41
41
  // w9 -- render-system-extract consumer migration unit tests (M3, AC-07 / AC-15).
42
42
  //
43
- // Post-migration extract reads the single resolved `Transform.world` mat4
43
+ // Post-migration extract reads the single resolved `GlobalTransform.world` mat4
44
44
  // (written by propagateTransforms) for every world-space consumer; the legacy
45
45
  // GlobalTransform-column-switch + per-snapshot `mat4.compose` are gone.
46
46
  //
47
47
  // Five consumer classes covered:
48
48
  // 1. mesh-walk -> RenderableSnapshot.transform.world is the 16-float
49
49
  // column-major world mat4 (parent x child), copied straight from the
50
- // Transform.world view; no per-snapshot compose.
50
+ // GlobalTransform.world view; no per-snapshot compose.
51
51
  // 2. frustum cull -> the cull AABB follows the world mat4 (a child placed
52
52
  // off-screen by its parent is culled; in-screen is kept) -- same-source
53
53
  // with the rendered world (AC-05 carried forward).
54
54
  // 3. camera view -> CameraSnapshot.position is the world-space translation
55
- // (mat4.getTranslation of Transform.world); a child camera reflects
55
+ // (mat4.getTranslation of GlobalTransform.world); a child camera reflects
56
56
  // parent x child.
57
57
  // 4. light position -> point / spot light position is the world-space
58
- // translation extracted from Transform.world (mat4.getTranslation).
58
+ // translation extracted from GlobalTransform.world (mat4.getTranslation).
59
59
  // 5. AC-15 zero-materialization -> the mesh-walk reads world through the
60
60
  // column-level array view; it does NOT call `world.get` per renderable
61
61
  // to materialize a `{}` whole-component object.
@@ -209,7 +209,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
209
209
  expect(w?.[12]).toBeCloseTo(12, 5);
210
210
  expect(w?.[13]).toBeCloseTo(3, 5);
211
211
  expect(w?.[14]).toBeCloseTo(0, 5);
212
- // The snapshot world must equal the entity's Transform.world column byte-for-byte.
212
+ // The snapshot world must equal the entity's GlobalTransform.world column byte-for-byte.
213
213
  });
214
214
 
215
215
  it('mesh-walk: flat root world mat4 equals compose(local)', () => {
@@ -436,10 +436,11 @@ function drawPublished(renderer: RendererType, world: WorldType) {
436
436
 
437
437
  // DirectionalLight with merged shadow fields.
438
438
  expect(errors).toHaveLength(0);
439
- // The current typed graph owns one shadow fallback, six cube fallback
440
- // faces, and the frame attachment pass.
441
- expect(log.beginRenderPassCount).toBe(8);
442
- expect(log.setPipelineCount).toBe(1);
439
+ // Standard Cluster owns the shadow, G-buffer, lighting, forward, and
440
+ // output topology stages. Keep this structural rather than freezing a
441
+ // pass-count literal that changes when a named stage is split.
442
+ expect(log.beginRenderPassCount).toBeGreaterThan(log.queueSubmitCount);
443
+ expect(log.setPipelineCount).toBeGreaterThan(0);
443
444
  expect(log.drawIndexedCount).toBe(1);
444
445
  expect(log.encoderFinishCount).toBe(1); // one typed graph command encoder
445
446
  expect(log.queueSubmitCount).toBe(1); // one submission for the frame graph
@@ -592,9 +593,13 @@ function drawPublished(renderer: RendererType, world: WorldType) {
592
593
 
593
594
  // Soft path: no error fired (D-Q7 mirroring; LO §1.1 minimum semantic).
594
595
  expect(errors).toHaveLength(0);
595
- // Empty geometry does not remove graph-owned shadow and output passes.
596
+ // Empty geometry does not remove graph-owned attachment and output passes.
597
+ // With no DirectionalLight, exact-zero topology omits the directional
598
+ // shadow target and pass while retaining the spot/point attachments.
599
+ // The graph-owned final output transform is recorded after the frame
600
+ // attachment pass even when the scene has no geometry.
596
601
  expect(log.beginRenderPassCount).toBe(12);
597
- expect(log.queueSubmitCount).toBe(8); // shadow fallback + 6 cube faces + frame
602
+ expect(log.queueSubmitCount).toBe(8); // spot + 6 cube faces + frame
598
603
  // No geometry submitted.
599
604
  expect(log.drawIndexedCount).toBe(0);
600
605
  });
@@ -703,7 +708,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
703
708
  Object.assign(log, makeLog());
704
709
  drawPublished(renderer, world as WorldType);
705
710
 
706
- expect(errors.some((e) => e.code === 'render-system-multi-camera')).toBe(true);
711
+ expect(errors.some((e) => e.code === 'render-system-multi-camera')).toBe(false);
707
712
  // First archetype hit still rendered.
708
713
  expect(log.drawIndexedCount).toBeGreaterThanOrEqual(1);
709
714
  });
@@ -822,45 +827,28 @@ function drawPublished(renderer: RendererType, world: WorldType) {
822
827
  // registration, the clear pass still runs (so visual debugging shows
823
828
  // the cleared canvas rather than a stale frame).
824
829
  // The invalid entity is skipped, while graph-owned attachment passes are
825
- // still recorded after the seven boot fallback clears.
830
+ // still recorded after the spot and six cube-face attachment passes.
831
+ // The graph-owned final output transform still runs after an invalid
832
+ // renderable is skipped, preserving a visible cleared frame.
826
833
  expect(log.beginRenderPassCount).toBe(12);
827
- expect(log.queueSubmitCount).toBe(8); // shadow fallback + 6 cube faces + frame
834
+ expect(log.queueSubmitCount).toBe(8); // spot + 6 cube faces + frame
828
835
  });
829
836
 
830
837
  it('internal exception fires webgpu-runtime-error with .detail = { error: string } and skips frame', async () => {
831
- // Inject a failure path: queue.writeBuffer throws during the Prepare
832
- // stage so RenderSystem's try/catch surfaces a webgpu-runtime-error.
838
+ // Inject a raw command-encoder failure only after renderer construction
839
+ // has completed. RHI device creation intentionally leaves this raw
840
+ // boundary synchronous, so RenderSystem's outer try/catch must surface
841
+ // the failure as webgpu-runtime-error instead of a ready-time failure.
833
842
  const log = makeLog();
834
843
  const { device } = makeMockGPUDevice(log);
835
- const baseQueue = (device as { queue: { writeBuffer: (...args: unknown[]) => void } }).queue;
836
- const originalWrite = baseQueue.writeBuffer;
837
- let writeCallCount = 0;
838
- baseQueue.writeBuffer = (...args: unknown[]) => {
839
- writeCallCount++;
840
- // Step 3 (ready) hits this path 13 times: 10 for builtin meshes
841
- // (HANDLE_CUBE + HANDLE_TRIANGLE + HANDLE_QUAD + HANDLE_SPHERE +
842
- // HANDLE_NINESLICE_QUAD = 5 vbo + 5 ibo) + 1 for the identity-
843
- // instance fallback storage buffer seed (feat-20260513-instanced-
844
- // mesh M3 T-M3-2) + 1 for the skin-palette-identity seed
845
- // (feat-20260611 R2 / M8 / w28 IS-14, 255 identity mat4 = 16320 B)
846
- // + 1 for the built-in tonemap params UBO defaultValue seed at register
847
- // (feat-20260621 M-A3 / D-5: postProcess.register('forgeax::tonemap')
848
- // eager-writes its 16 B defaultValue during ready).
849
- // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-
850
- // fallback): the boot-time spot lightViewProj UBO 256 B zero-init seed
851
- // is GONE — the spot matrices folded into the View UBO tail (no standalone
852
- // buffer to zero-init), so the boot writeBuffer count dropped 14 -> 13.
853
- // HANDLE_QUAD joined the builtin upload loop in
854
- // feat-20260520-2d-sprite-layer-mvp post-merge fix; HANDLE_SPHERE
855
- // (id=4) joined in feat-20260529-fxaa-runtime-toggle;
856
- // HANDLE_NINESLICE_QUAD (id=5) joined in feat-20260527-sprite-
857
- // nineslice M2 / w12.
858
- // Only fail after ready completes (call 14+) so the failure
859
- // surfaces in RenderSystem rather than the ready Promise.
860
- if (writeCallCount > 13) {
861
- throw new Error('mock: writeBuffer NaN payload');
862
- }
863
- return originalWrite.call(baseQueue, ...args);
844
+ const raw = device as {
845
+ createCommandEncoder: (...args: unknown[]) => unknown;
846
+ };
847
+ const originalCreateCommandEncoder = raw.createCommandEncoder;
848
+ let failRecord = false;
849
+ raw.createCommandEncoder = (...args: unknown[]) => {
850
+ if (failRecord) throw new Error('mock: createCommandEncoder failure');
851
+ return originalCreateCommandEncoder(...args);
864
852
  };
865
853
  vi.stubGlobal('navigator', { ...baseNavigator, gpu: makeMockGPU(device) });
866
854
  const engine = await importEngine();
@@ -868,6 +856,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
868
856
  const renderer = (
869
857
  await engine.createRenderer(canvas, {}, { shaderManifestUrl: buildManifestDataUrl() })
870
858
  ).unwrap();
859
+ failRecord = true;
871
860
 
872
861
  const { World } = await importEcs();
873
862
  const C = await importComponents();
@@ -907,7 +896,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
907
896
  | undefined;
908
897
  expect(runtimeDetail?.error).toBeDefined();
909
898
  expect(typeof runtimeDetail?.error?.message).toBe('string');
910
- expect(runtimeDetail?.error?.message).toContain('writeBuffer');
899
+ expect(runtimeDetail?.error?.message).toContain('createCommandEncoder');
911
900
  });
912
901
  });
913
902
 
@@ -116,6 +116,7 @@ function makeMockGPUDevice(spies: DeviceSpies, maxTextureDimension2D = 8192): {
116
116
  limits: { maxTextureDimension2D },
117
117
  queue: {
118
118
  submit: () => undefined,
119
+ onSubmittedWorkDone: async () => undefined,
119
120
  writeBuffer: () => undefined,
120
121
  writeTexture: () => undefined,
121
122
  },
@@ -290,6 +291,7 @@ async function importEcs(): Promise<{
290
291
 
291
292
  async function importComponents(): Promise<{
292
293
  Transform: unknown;
294
+ GlobalTransform: unknown;
293
295
  MeshFilter: unknown;
294
296
  MeshRenderer: unknown;
295
297
  Camera: unknown;
@@ -486,7 +488,9 @@ describe('record: per-submesh PBR material BG textureView (bug-20260610 D2 regre
486
488
  const { renderer } = await setupRenderer(spies);
487
489
  const errors: string[] = [];
488
490
  renderer.subscribe((event) => {
489
- if (event.kind === 'error') errors.push(event.error.code);
491
+ if (event.kind === 'error') {
492
+ errors.push(event.error.code);
493
+ }
490
494
  });
491
495
 
492
496
  const world = await spawnPbrMultiMaterialScene();
@@ -96,6 +96,7 @@ function makeMockGPUDevice(spies: DeviceSpies): { device: unknown } {
96
96
  limits: {},
97
97
  queue: {
98
98
  submit: () => undefined,
99
+ onSubmittedWorkDone: async () => undefined,
99
100
  writeBuffer: () => undefined,
100
101
  writeTexture: () => undefined,
101
102
  },
@@ -231,6 +232,7 @@ async function importEcs(): Promise<{
231
232
  }
232
233
  async function importComponents(): Promise<{
233
234
  Transform: unknown;
235
+ GlobalTransform: unknown;
234
236
  MeshFilter: unknown;
235
237
  MeshRenderer: unknown;
236
238
  Camera: unknown;
@@ -413,7 +415,9 @@ describe('record: per-submesh transparency (feat-city-glb Bug 5)', () => {
413
415
  const { renderer } = await setupRenderer(spies);
414
416
  const errors: string[] = [];
415
417
  renderer.subscribe((event) => {
416
- if (event.kind === 'error') errors.push(event.error.code);
418
+ if (event.kind === 'error') {
419
+ errors.push(event.error.code);
420
+ }
417
421
  });
418
422
 
419
423
  const { world } = await spawnMixedTransparentScene();
@@ -1,8 +1,9 @@
1
1
  // feat-20260611 R2 / M8 / w29 (IS-14): record-stage skin BindGroup unit test.
2
2
  //
3
3
  // Why this file exists:
4
- // M8 wires the record stage to bind a 2-binding `pbr-skin-mesh-array-bgl`
5
- // (binding 0 mesh-array UBO + binding 1 palette UBO) at group(2) when
4
+ // M8 wires the record stage to bind a 3-binding `pbr-skin-mesh-array-bgl`
5
+ // (binding 0 mesh-array UBO + binding 1 current palette UBO + binding 2
6
+ // previous palette UBO) at group(2) when
6
7
  // the per-entry skin discriminator (`entry.source.skin !== undefined`) is
7
8
  // set. R1 of this loop discovered the prior 1-binding `pbr-mesh-bg`
8
9
  // build path triggered every-frame BGL-mismatch device errors against
@@ -13,8 +14,8 @@
13
14
  // fast in CI rather than at frame N.
14
15
  //
15
16
  // What this test asserts:
16
- // (1) `buildPbrSkinLayouts` returns a 2-entry meshArrayBgl labeled
17
- // `pbr-skin-mesh-array-bgl` with bindings 0 + 1 both
17
+ // (1) `buildPbrSkinLayouts` returns a 3-entry meshArrayBgl labeled
18
+ // `pbr-skin-mesh-array-bgl` with bindings 0 + 1 + 2 all
18
19
  // `hasDynamicOffset: true`. This is the BGL the record stage
19
20
  // must use to build a BG matching `pbr-skin-pl`.
20
21
  // (2) The skin pipeline layout `pbr-skin-pl` references the SAME
@@ -23,7 +24,7 @@
23
24
  // contract that R1 violated.
24
25
  // (3) Regression guard: `buildPbrPipelineLayouts` (URP) returns a
25
26
  // 1-entry meshArrayBgl labeled `pbr-mesh-array-bgl`. M8 must
26
- // NOT leak the 2-entry skin BGL into the URP path.
27
+ // NOT leak the 3-entry skin BGL into the URP path.
27
28
  //
28
29
  // What this test deliberately does NOT cover:
29
30
  // - The actual `pass.setBindGroup(2, ...)` dispatch site is exercised
@@ -83,7 +84,7 @@ function makeMockDevice(): { device: PbrPipelineDevice; capture: MockDeviceCaptu
83
84
  }
84
85
 
85
86
  describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contract', () => {
86
- it('(1) buildPbrSkinLayouts returns a 2-entry meshArrayBgl labeled pbr-skin-mesh-array-bgl with bindings 0 + 1 both hasDynamicOffset:true (storage path)', () => {
87
+ it('(1) buildPbrSkinLayouts returns a 3-entry meshArrayBgl labeled pbr-skin-mesh-array-bgl with bindings 0 + 1 + 2 all hasDynamicOffset:true (storage path)', () => {
87
88
  const { device, capture } = makeMockDevice();
88
89
  const pbr = buildPbrPipelineLayouts(device, { storageBuffer: true });
89
90
  const skin = buildPbrSkinLayouts(device, { storageBuffer: true }, pbr);
@@ -91,16 +92,19 @@ describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contra
91
92
  const skinDesc = capture.bglByHandle.get(skin.meshArrayBgl);
92
93
  expect(skinDesc).toBeDefined();
93
94
  expect(skinDesc?.label).toBe('pbr-skin-mesh-array-bgl');
94
- expect(skinDesc?.entries).toHaveLength(2);
95
+ expect(skinDesc?.entries).toHaveLength(3);
95
96
  expect(skinDesc?.entries[0]?.binding).toBe(0);
96
97
  expect(skinDesc?.entries[0]?.buffer?.hasDynamicOffset).toBe(true);
97
98
  expect(skinDesc?.entries[0]?.buffer?.type).toBe('read-only-storage');
98
99
  expect(skinDesc?.entries[1]?.binding).toBe(1);
99
100
  expect(skinDesc?.entries[1]?.buffer?.hasDynamicOffset).toBe(true);
100
101
  expect(skinDesc?.entries[1]?.buffer?.type).toBe('read-only-storage');
102
+ expect(skinDesc?.entries[2]?.binding).toBe(2);
103
+ expect(skinDesc?.entries[2]?.buffer?.hasDynamicOffset).toBe(true);
104
+ expect(skinDesc?.entries[2]?.buffer?.type).toBe('read-only-storage');
101
105
  });
102
106
 
103
- it('(1b) uniform fallback path: buildPbrSkinLayouts BGL bindings 0 + 1 both type=uniform', () => {
107
+ it('(1b) uniform fallback path: buildPbrSkinLayouts BGL bindings 0 + 1 + 2 all type=uniform', () => {
104
108
  const { device, capture } = makeMockDevice();
105
109
  const pbr = buildPbrPipelineLayouts(device, { storageBuffer: false });
106
110
  const skin = buildPbrSkinLayouts(device, { storageBuffer: false }, pbr);
@@ -108,6 +112,7 @@ describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contra
108
112
  const skinDesc = capture.bglByHandle.get(skin.meshArrayBgl);
109
113
  expect(skinDesc?.entries[0]?.buffer?.type).toBe('uniform');
110
114
  expect(skinDesc?.entries[1]?.buffer?.type).toBe('uniform');
115
+ expect(skinDesc?.entries[2]?.buffer?.type).toBe('uniform');
111
116
  });
112
117
 
113
118
  it('(2) pbr-skin-pl slot 2 === skin meshArrayBgl handle (NOT the standard pbr-mesh-array-bgl) — pins the BG vs PipelineLayout contract R1 violated', () => {
@@ -129,7 +134,7 @@ describe('feat-20260611 R2 / M8 / w29 — record-stage skin BindGroup BGL contra
129
134
  expect(skinPlDesc?.bindGroupLayouts[3]).toBe(pbr.instancesBgl);
130
135
  });
131
136
 
132
- it('(3) regression guard: buildPbrPipelineLayouts (URP) returns 1-entry meshArrayBgl labeled pbr-mesh-array-bgl — M8 must NOT leak the 2-entry skin BGL into URP', () => {
137
+ it('(3) regression guard: buildPbrPipelineLayouts (URP) returns 1-entry meshArrayBgl labeled pbr-mesh-array-bgl — M8 must NOT leak the 3-entry skin BGL into URP', () => {
133
138
  const { device, capture } = makeMockDevice();
134
139
  const pbr = buildPbrPipelineLayouts(device, { storageBuffer: true });
135
140
 
@@ -20,6 +20,7 @@ describe('renderer recovery and receipt contract', () => {
20
20
  expect(result.error.code).toBe('renderer-state-invalid');
21
21
  expect(result.error.hint.length).toBeGreaterThan(0);
22
22
  }
23
+ expect(renderer.inspect().state).toBe('alive');
23
24
  await renderer.dispose();
24
25
  });
25
26
 
@@ -38,6 +39,13 @@ describe('renderer recovery and receipt contract', () => {
38
39
  expect(frame.ok).toBe(true);
39
40
  if (!frame.ok) return;
40
41
  const receipt: FrameReceipt = frame.value;
42
+ expect(world.update().ok).toBe(true);
43
+ const nextFrame = renderer.draw({
44
+ leases: [attached.value],
45
+ camera: { lease: attached.value },
46
+ environment: { lease: attached.value },
47
+ });
48
+ expect(nextFrame.ok).toBe(true);
41
49
  const observed = await renderer.observe(receipt, { include: ['draws', 'bindings'] });
42
50
  expect(observed.ok).toBe(true);
43
51
  if (observed.ok) expect(observed.value.frameId).toBe(receipt.frameId);