@forgeax/engine-runtime 0.1.19 → 0.1.21

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 (91) hide show
  1. package/README.md +102 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  4. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/tilemap-assets.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__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  9. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  11. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  12. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  13. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  14. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  15. package/dist/createRenderer.d.ts.map +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.mjs +3 -1
  18. package/dist/index.mjs.map +1 -1
  19. package/package.json +30 -30
  20. package/src/__tests__/active-camera.unit.test.ts +6 -8
  21. package/src/__tests__/asset-registry.recursive.spec.ts +13 -19
  22. package/src/__tests__/asset.unit.test.ts +28 -25
  23. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  24. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  25. package/src/__tests__/components.test-d.ts +10 -3
  26. package/src/__tests__/components.unit.test.ts +13 -7
  27. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  28. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  29. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  30. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  31. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +11 -4
  32. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  33. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  34. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  35. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  36. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  37. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  38. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  39. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  40. package/src/__tests__/errors.unit.test.ts +71 -20
  41. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  42. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  43. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  44. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  45. package/src/__tests__/geometry.unit.test.ts +7 -10
  46. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  47. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  48. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +6 -3
  49. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  50. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  51. package/src/__tests__/lights.unit.test.ts +75 -2214
  52. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  53. package/src/__tests__/loader-name-survival.test.ts +2 -3
  54. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  55. package/src/__tests__/material-texture-uv-scale.unit.test.ts +10 -2
  56. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  57. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  58. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  59. package/src/__tests__/pbr-pipeline.unit.test.ts +29 -5
  60. package/src/__tests__/pipeline.unit.test.ts +66 -221
  61. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  62. package/src/__tests__/point-light-shadow.unit.test.ts +67 -9
  63. package/src/__tests__/render-error-exhaustive.test-d.ts +92 -9
  64. package/src/__tests__/render-system-extract.test.ts +4 -6
  65. package/src/__tests__/render-system-mega.test.ts +30 -41
  66. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +8 -7
  67. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +6 -4
  68. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  69. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  70. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  71. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  72. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  73. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  74. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  75. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  76. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  77. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +140 -58
  79. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +6 -2
  80. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  81. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  82. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
  83. package/src/__tests__/ssao-bgl.test.ts +10 -16
  84. package/src/__tests__/ssao-passes.test.ts +19 -2
  85. package/src/__tests__/systems.unit.test.ts +59 -33
  86. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  87. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  88. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  89. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  90. package/src/createRenderer.ts +4 -0
  91. package/src/index.ts +1 -2
@@ -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
  },
@@ -323,8 +324,7 @@ function makeChequerTexture(): TextureAsset {
323
324
  // textureView sentinel.
324
325
  return {
325
326
  kind: 'texture',
326
- width: 2,
327
- height: 2,
327
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
328
328
  // colorSpace=linear+rgba8unorm to satisfy the runtime's
329
329
  // srgb-format consistency validator (layer 7c-1: srgb requires
330
330
  // rgba8unorm-srgb). The test only cares that each texture's
@@ -332,7 +332,7 @@ function makeChequerTexture(): TextureAsset {
332
332
  format: 'rgba8unorm',
333
333
  data: new Uint8Array(2 * 2 * 4),
334
334
  colorSpace: 'linear',
335
- mipmap: false,
335
+ mips: { kind: 'none' },
336
336
  } as unknown as TextureAsset;
337
337
  }
338
338
 
@@ -487,7 +487,9 @@ describe('record: per-submesh PBR material BG textureView (bug-20260610 D2 regre
487
487
  const { renderer } = await setupRenderer(spies);
488
488
  const errors: string[] = [];
489
489
  renderer.subscribe((event) => {
490
- if (event.kind === 'error') errors.push(event.error.code);
490
+ if (event.kind === 'error') {
491
+ errors.push(event.error.code);
492
+ }
491
493
  });
492
494
 
493
495
  const world = await spawnPbrMultiMaterialScene();
@@ -586,12 +588,11 @@ async function spawnPbrMissingTextureScene(): Promise<unknown> {
586
588
  // existing debug-pink fallback.
587
589
  const unresidentTexture = {
588
590
  kind: 'texture',
589
- width: 2,
590
- height: 2,
591
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
591
592
  format: 'rgba8unorm-srgb',
592
593
  data: new Uint8Array(2 * 2 * 4),
593
594
  colorSpace: 'srgb',
594
- mipmap: false,
595
+ mips: { kind: 'none' },
595
596
  } as unknown as TextureAsset;
596
597
  const badTexHandle = world.allocSharedRef('TextureAsset', unresidentTexture) as unknown as Handle<
597
598
  'TextureAsset',
@@ -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
  },
@@ -256,12 +257,11 @@ function cameraTransform() {
256
257
  function makeTex(): TextureAsset {
257
258
  return {
258
259
  kind: 'texture',
259
- width: 2,
260
- height: 2,
260
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
261
261
  format: 'rgba8unorm',
262
262
  data: new Uint8Array(2 * 2 * 4),
263
263
  colorSpace: 'linear',
264
- mipmap: false,
264
+ mips: { kind: 'none' },
265
265
  } as unknown as TextureAsset;
266
266
  }
267
267
 
@@ -414,7 +414,9 @@ describe('record: per-submesh transparency (feat-city-glb Bug 5)', () => {
414
414
  const { renderer } = await setupRenderer(spies);
415
415
  const errors: string[] = [];
416
416
  renderer.subscribe((event) => {
417
- if (event.kind === 'error') errors.push(event.error.code);
417
+ if (event.kind === 'error') {
418
+ errors.push(event.error.code);
419
+ }
418
420
  });
419
421
 
420
422
  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);
@@ -164,7 +164,7 @@ function makeLights(): MockObj {
164
164
  shadowMapSize: 0,
165
165
  depthBias: 0,
166
166
  normalBias: 0,
167
- pcfKernelSize: 0,
167
+ directionalShadowQuality: undefined,
168
168
  pointShadow: [],
169
169
  };
170
170
  }
@@ -174,6 +174,7 @@ function makeFrameState(): MockObj {
174
174
  frameNumber: 1,
175
175
  compiledFrameGraph: null,
176
176
  compiledFrameGraphTopologyKey: null,
177
+ compiledFrameGraphGeneration: 0,
177
178
  retiredCompiledFrameGraphs: new Set(),
178
179
  instanceBuffers: new Map(),
179
180
  transientInstanceBuffers: [],
@@ -193,8 +194,7 @@ function makeFrameState(): MockObj {
193
194
  installedPipelineHandle: 0,
194
195
  activePipeline: { buildGraph: () => null, execute: () => undefined },
195
196
  installedPipelineConfig: undefined,
196
- isHdrpActive: false,
197
- hdrpOncePerFrameFired: new Set(),
197
+ standardOncePerFrameFired: new Set(),
198
198
  pointShadowAtlas: null,
199
199
  pointShadowSnapshots: [],
200
200
  lastFoldBucketCount: 0,
@@ -231,6 +231,232 @@ function callRecordFrame(
231
231
  acquireSwapChainTarget(internals, internals.getPipelineState());
232
232
  }
233
233
 
234
+ type DirectionalShadowAdmission = {
235
+ readonly requested: 'off' | 'pcf1' | 'pcf3' | 'pcf5' | 'pcssMedium' | 'pcssHigh';
236
+ readonly effective:
237
+ | 'off'
238
+ | 'pcf1'
239
+ | 'pcf3'
240
+ | 'pcf5'
241
+ | 'pcssMedium'
242
+ | 'pcssHigh'
243
+ | 'rhi-null-structural';
244
+ readonly status: 'accepted' | 'fallback' | 'rejected';
245
+ readonly fallbackReason?: 'webgl2-unsupported' | 'rhi-null-structural' | 'candidate-failed';
246
+ readonly lastKnownGood: boolean;
247
+ readonly pixelEvidence: 'available' | 'not-available';
248
+ };
249
+
250
+ async function resolveDirectionalShadowAdmission(input: {
251
+ readonly backendKind: 'webgpu' | 'wgpu-webgl2' | 'null';
252
+ readonly requested: DirectionalShadowAdmission['requested'];
253
+ readonly candidate: 'accepted' | 'failed';
254
+ readonly lastKnownGood?: Exclude<
255
+ DirectionalShadowAdmission['effective'],
256
+ 'off' | 'rhi-null-structural'
257
+ >;
258
+ }): Promise<DirectionalShadowAdmission> {
259
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
260
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
261
+ expect(typeof resolver).toBe('function');
262
+ return (resolver as (value: typeof input) => DirectionalShadowAdmission)(input);
263
+ }
264
+
265
+ describe('M3 backend admission and fallback truthfulness', () => {
266
+ it('keeps an explicitly disabled Directional shadow off without fabricating a candidate', async () => {
267
+ await expect(
268
+ resolveDirectionalShadowAdmission({
269
+ backendKind: 'webgpu',
270
+ requested: 'off',
271
+ candidate: 'failed',
272
+ }),
273
+ ).resolves.toMatchObject({
274
+ requested: 'off',
275
+ effective: 'off',
276
+ status: 'accepted',
277
+ lastKnownGood: false,
278
+ pixelEvidence: 'not-available',
279
+ });
280
+ });
281
+
282
+ it('admits a production WebGPU/Dawn candidate without converting PCSS to PCF', async () => {
283
+ await expect(
284
+ resolveDirectionalShadowAdmission({
285
+ backendKind: 'webgpu',
286
+ requested: 'pcssMedium',
287
+ candidate: 'accepted',
288
+ }),
289
+ ).resolves.toMatchObject({
290
+ requested: 'pcssMedium',
291
+ effective: 'pcssMedium',
292
+ status: 'accepted',
293
+ lastKnownGood: false,
294
+ pixelEvidence: 'available',
295
+ });
296
+ });
297
+
298
+ it.each([
299
+ ['pcssMedium', 'pcf3'],
300
+ ['pcssHigh', 'pcf5'],
301
+ ] as const)('maps WebGL2 %s to fixed effective %s', async (requested, effective) => {
302
+ await expect(
303
+ resolveDirectionalShadowAdmission({
304
+ backendKind: 'wgpu-webgl2',
305
+ requested,
306
+ candidate: 'accepted',
307
+ }),
308
+ ).resolves.toMatchObject({
309
+ requested,
310
+ effective,
311
+ status: 'fallback',
312
+ fallbackReason: 'webgl2-unsupported',
313
+ pixelEvidence: 'available',
314
+ });
315
+ });
316
+
317
+ it('projects RhiNull as structural evidence and never as pixel evidence', async () => {
318
+ await expect(
319
+ resolveDirectionalShadowAdmission({
320
+ backendKind: 'null',
321
+ requested: 'pcssHigh',
322
+ candidate: 'accepted',
323
+ }),
324
+ ).resolves.toMatchObject({
325
+ requested: 'pcssHigh',
326
+ effective: 'rhi-null-structural',
327
+ status: 'fallback',
328
+ fallbackReason: 'rhi-null-structural',
329
+ pixelEvidence: 'not-available',
330
+ });
331
+ });
332
+
333
+ it('rejects a capable candidate failure while retaining compatible LKG', async () => {
334
+ const result = await resolveDirectionalShadowAdmission({
335
+ backendKind: 'webgpu',
336
+ requested: 'pcssHigh',
337
+ candidate: 'failed',
338
+ lastKnownGood: 'pcf3',
339
+ });
340
+ expect(result).toMatchObject({
341
+ requested: 'pcssHigh',
342
+ effective: 'pcf3',
343
+ status: 'rejected',
344
+ fallbackReason: 'candidate-failed',
345
+ lastKnownGood: true,
346
+ pixelEvidence: 'available',
347
+ });
348
+ expect(result.effective).not.toBe('pcf5');
349
+ });
350
+
351
+ it('keeps falsifiers observable instead of allowing fake effective mappings', async () => {
352
+ const result = await resolveDirectionalShadowAdmission({
353
+ backendKind: 'wgpu-webgl2',
354
+ requested: 'pcssMedium',
355
+ candidate: 'accepted',
356
+ });
357
+ expect(result.fallbackReason).toBe('webgl2-unsupported');
358
+ expect(result.effective).toBe('pcf3');
359
+ expect(result).not.toMatchObject({ effective: 'pcssMedium' });
360
+ });
361
+ });
362
+
363
+ describe('M3 bounded Directional shadow inspection', () => {
364
+ it('requires one detached projection with bounded tap and generation facts', async () => {
365
+ const module = (await import(
366
+ '../../../render/src/assembly/directional-shadow-inspection'
367
+ )) as Record<string, unknown>;
368
+ const project = module.projectDirectionalShadowInspection;
369
+ expect(typeof project).toBe('function');
370
+ const result = (project as (input: Record<string, unknown>) => Record<string, unknown>)({
371
+ admission: {
372
+ requested: 'pcssHigh',
373
+ effective: 'pcssHigh',
374
+ status: 'accepted',
375
+ pixelEvidence: 'available',
376
+ },
377
+ cascadeCount: 4,
378
+ mapSize: 2048,
379
+ atlasBytes: 67_108_864,
380
+ writerPasses: 4,
381
+ blockerTaps: 16,
382
+ filterTapUpperBound: 32,
383
+ seamTapUpperBound: 96,
384
+ deviceGeneration: 2,
385
+ graphGeneration: 7,
386
+ });
387
+ expect(result).toMatchObject({
388
+ requested: 'pcssHigh',
389
+ effective: 'pcssHigh',
390
+ cascadeCount: 4,
391
+ mapSize: 2048,
392
+ blockerTaps: 16,
393
+ filterTapUpperBound: 32,
394
+ seamTapUpperBound: 96,
395
+ deviceGeneration: 2,
396
+ graphGeneration: 7,
397
+ });
398
+ expect(Object.keys(result)).not.toContain('perPixel');
399
+ expect(Object.isFrozen(result)).toBe(true);
400
+ });
401
+
402
+ it('projects production facts instead of fixed request, taps, or generations', async () => {
403
+ const module = (await import(
404
+ '../../../render/src/assembly/directional-shadow-inspection'
405
+ )) as Record<string, unknown>;
406
+ const projectSource = module.projectDirectionalShadowInspectionSource;
407
+ expect(typeof projectSource).toBe('function');
408
+ const project = projectSource as (input: Record<string, unknown>) => Record<string, unknown>;
409
+ const first = project({
410
+ requested: 'pcf3',
411
+ candidate: 'accepted',
412
+ cascadeCount: 3,
413
+ mapSize: 1024,
414
+ atlasBytes: 12_582_912,
415
+ writerPasses: 3,
416
+ deviceGeneration: 4,
417
+ graphGeneration: 11,
418
+ shadowReady: true,
419
+ });
420
+ const second = project({
421
+ requested: 'pcssHigh',
422
+ candidate: 'failed',
423
+ lastKnownGood: 'pcf3',
424
+ cascadeCount: 4,
425
+ mapSize: 2048,
426
+ atlasBytes: 67_108_864,
427
+ writerPasses: 4,
428
+ deviceGeneration: 5,
429
+ graphGeneration: 12,
430
+ shadowReady: false,
431
+ });
432
+ expect(first).toMatchObject({
433
+ requested: 'pcf3',
434
+ effective: 'pcf3',
435
+ status: 'accepted',
436
+ filterTapUpperBound: 9,
437
+ seamTapUpperBound: 9,
438
+ cascadeCount: 3,
439
+ mapSize: 1024,
440
+ deviceGeneration: 4,
441
+ graphGeneration: 11,
442
+ });
443
+ expect(second).toMatchObject({
444
+ requested: 'pcssHigh',
445
+ effective: 'pcf3',
446
+ status: 'rejected',
447
+ lastKnownGood: true,
448
+ blockerTaps: 16,
449
+ filterTapUpperBound: 32,
450
+ seamTapUpperBound: 96,
451
+ cascadeCount: 4,
452
+ mapSize: 2048,
453
+ deviceGeneration: 5,
454
+ graphGeneration: 12,
455
+ });
456
+ expect(second).not.toMatchObject({ requested: 'pcf3', status: 'accepted', atlasBytes: 0 });
457
+ });
458
+ });
459
+
234
460
  // ── w5: surface retry — reconfigure + retry once (AC-03) ─────────────────────
235
461
 
236
462
  describe('Surface retry (w5)', () => {
@@ -6,6 +6,8 @@
6
6
  // cascade tile: col = i % tilesPerSide, row = floor(i / tilesPerSide).
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
10
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
9
11
 
10
12
  /**
11
13
  * Pure helper mirroring urp-pipeline.ts atlas sizing logic.
@@ -176,3 +178,42 @@ describe('CSM atlas layout (w13)', () => {
176
178
  });
177
179
  });
178
180
  });
181
+
182
+ describe('M3 inspection zero-delta structural budget', () => {
183
+ it('keeps PCF and PCSS on the same atlas and writer budget', () => {
184
+ const common = {
185
+ cascadeCount: 4,
186
+ mapSize: 2048,
187
+ atlasBytes: 67_108_864,
188
+ writerPasses: 4,
189
+ deviceGeneration: 1,
190
+ graphGeneration: 2,
191
+ } as const;
192
+ const pcf = projectDirectionalShadowInspection({
193
+ admission: resolveDirectionalShadowBackendAdmission({
194
+ backendKind: 'webgpu',
195
+ requested: 'pcf5',
196
+ candidate: 'accepted',
197
+ }),
198
+ ...common,
199
+ blockerTaps: 0,
200
+ filterTapUpperBound: 25,
201
+ seamTapUpperBound: 25,
202
+ });
203
+ const pcss = projectDirectionalShadowInspection({
204
+ admission: resolveDirectionalShadowBackendAdmission({
205
+ backendKind: 'webgpu',
206
+ requested: 'pcssHigh',
207
+ candidate: 'accepted',
208
+ }),
209
+ ...common,
210
+ blockerTaps: 16,
211
+ filterTapUpperBound: 32,
212
+ seamTapUpperBound: 96,
213
+ });
214
+ expect(pcss).toMatchObject({ effective: 'pcssHigh', cascadeCount: 4, mapSize: 2048 });
215
+ expect(pcf.writerPasses).toBe(pcss.writerPasses);
216
+ expect(pcf.atlasBytes).toBe(pcss.atlasBytes);
217
+ expect(pcf.graphGeneration).toBe(pcss.graphGeneration);
218
+ });
219
+ });