@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.
- package/README.md +102 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
- package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
- package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
- package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
- package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
- package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
- package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
- package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
- package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +3 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +30 -30
- package/src/__tests__/active-camera.unit.test.ts +6 -8
- package/src/__tests__/asset-registry.recursive.spec.ts +13 -19
- package/src/__tests__/asset.unit.test.ts +28 -25
- package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
- package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
- package/src/__tests__/components.test-d.ts +10 -3
- package/src/__tests__/components.unit.test.ts +13 -7
- package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
- package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
- package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
- package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
- package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +11 -4
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
- package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
- package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
- package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
- package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
- package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
- package/src/__tests__/derive-bgl-integration.test.ts +11 -0
- package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
- package/src/__tests__/errors.unit.test.ts +71 -20
- package/src/__tests__/extract-frames-merge.test.ts +48 -40
- package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
- package/src/__tests__/four-path-compression.integration.test.ts +2 -2
- package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
- package/src/__tests__/geometry.unit.test.ts +7 -10
- package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
- package/src/__tests__/helpers/tilemap-assets.ts +3 -4
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +6 -3
- package/src/__tests__/light-snapshot.test-d.ts +8 -0
- package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
- package/src/__tests__/lights.unit.test.ts +75 -2214
- package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
- package/src/__tests__/loader-name-survival.test.ts +2 -3
- package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
- package/src/__tests__/material-texture-uv-scale.unit.test.ts +10 -2
- package/src/__tests__/mip-gate.unit.test.ts +11 -8
- package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
- package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
- package/src/__tests__/pbr-pipeline.unit.test.ts +29 -5
- package/src/__tests__/pipeline.unit.test.ts +66 -221
- package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
- package/src/__tests__/point-light-shadow.unit.test.ts +67 -9
- package/src/__tests__/render-error-exhaustive.test-d.ts +92 -9
- package/src/__tests__/render-system-extract.test.ts +4 -6
- package/src/__tests__/render-system-mega.test.ts +30 -41
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +8 -7
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +6 -4
- package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
- package/src/__tests__/renderer-recover.unit.test.ts +8 -0
- package/src/__tests__/renderer-surface.unit.test.ts +229 -3
- package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
- package/src/__tests__/shadow-csm-component.test.ts +31 -24
- package/src/__tests__/shadow-csm-extract.test.ts +156 -47
- package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
- package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
- package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
- package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +140 -58
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +6 -2
- package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
- package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
- package/src/__tests__/ssao-bgl.test.ts +10 -16
- package/src/__tests__/ssao-passes.test.ts +19 -2
- package/src/__tests__/systems.unit.test.ts +59 -33
- package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
- package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
- package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
- package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
- package/src/createRenderer.ts +4 -0
- 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
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
expect(log.
|
|
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
|
|
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); //
|
|
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(
|
|
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
|
|
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); //
|
|
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
|
|
832
|
-
//
|
|
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
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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('
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
|
5
|
-
// (binding 0 mesh-array UBO + binding 1 palette UBO
|
|
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
|
|
17
|
-
// `pbr-skin-mesh-array-bgl` with bindings 0 + 1
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|