@forgeax/engine-runtime 0.1.20 → 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__/lights-preservation.fixup.test.d.ts +2 -0
- package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +2 -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 +1 -1
- 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__/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 +9 -1
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
- package/src/__tests__/errors.unit.test.ts +51 -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__/frame-targets-shadow-regression.unit.test.ts +13 -0
- package/src/__tests__/geometry.unit.test.ts +7 -10
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +2 -1
- 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 -2235
- package/src/__tests__/pbr-pipeline.unit.test.ts +6 -1
- package/src/__tests__/pipeline.unit.test.ts +48 -202
- package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
- package/src/__tests__/point-light-shadow.unit.test.ts +61 -4
- package/src/__tests__/render-error-exhaustive.test-d.ts +63 -9
- package/src/__tests__/render-system-mega.test.ts +30 -41
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -1
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +4 -1
- 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 +85 -20
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
- package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
- 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 +44 -31
- package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
- package/src/createRenderer.ts +1 -0
- package/src/index.ts +1 -2
|
@@ -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
|
},
|
|
@@ -413,7 +414,9 @@ describe('record: per-submesh transparency (feat-city-glb Bug 5)', () => {
|
|
|
413
414
|
const { renderer } = await setupRenderer(spies);
|
|
414
415
|
const errors: string[] = [];
|
|
415
416
|
renderer.subscribe((event) => {
|
|
416
|
-
if (event.kind === 'error')
|
|
417
|
+
if (event.kind === 'error') {
|
|
418
|
+
errors.push(event.error.code);
|
|
419
|
+
}
|
|
417
420
|
});
|
|
418
421
|
|
|
419
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
|
+
});
|
|
@@ -83,7 +83,11 @@ describe('CSM component schema (w1)', () => {
|
|
|
83
83
|
error: {
|
|
84
84
|
code?: string;
|
|
85
85
|
hint?: string;
|
|
86
|
-
detail?: {
|
|
86
|
+
detail?: {
|
|
87
|
+
field?: string;
|
|
88
|
+
actual?: number;
|
|
89
|
+
bound?: { kind?: string; min?: number; max?: number };
|
|
90
|
+
};
|
|
87
91
|
};
|
|
88
92
|
};
|
|
89
93
|
|
|
@@ -97,7 +101,7 @@ describe('CSM component schema (w1)', () => {
|
|
|
97
101
|
expect(r.error.code).toBe('shadow-invalid-config');
|
|
98
102
|
expect(r.error.hint).toContain('cascadeCount');
|
|
99
103
|
expect(r.error.detail?.field).toBe('cascadeCount');
|
|
100
|
-
expect(r.error.detail?.
|
|
104
|
+
expect(r.error.detail?.actual).toBe(0);
|
|
101
105
|
});
|
|
102
106
|
|
|
103
107
|
it('cascadeCount=5 -> ShadowInvalidConfigError (max=4)', () => {
|
|
@@ -110,8 +114,8 @@ describe('CSM component schema (w1)', () => {
|
|
|
110
114
|
expect(r.error.code).toBe('shadow-invalid-config');
|
|
111
115
|
expect(r.error.hint).toContain('cascadeCount');
|
|
112
116
|
expect(r.error.detail?.field).toBe('cascadeCount');
|
|
113
|
-
expect(r.error.detail?.
|
|
114
|
-
expect(r.error.detail?.
|
|
117
|
+
expect(r.error.detail?.actual).toBe(5);
|
|
118
|
+
expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 1, max: 4 });
|
|
115
119
|
});
|
|
116
120
|
|
|
117
121
|
it('splitLambda=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
|
|
@@ -124,7 +128,8 @@ describe('CSM component schema (w1)', () => {
|
|
|
124
128
|
expect(r.error.code).toBe('shadow-invalid-config');
|
|
125
129
|
expect(r.error.hint).toContain('splitLambda');
|
|
126
130
|
expect(r.error.detail?.field).toBe('splitLambda');
|
|
127
|
-
expect(r.error.detail?.
|
|
131
|
+
expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
|
|
132
|
+
expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
|
|
128
133
|
});
|
|
129
134
|
|
|
130
135
|
it('splitLambda=1.1 -> ShadowInvalidConfigError (max=1)', () => {
|
|
@@ -137,8 +142,8 @@ describe('CSM component schema (w1)', () => {
|
|
|
137
142
|
expect(r.error.code).toBe('shadow-invalid-config');
|
|
138
143
|
expect(r.error.hint).toContain('splitLambda');
|
|
139
144
|
expect(r.error.detail?.field).toBe('splitLambda');
|
|
140
|
-
expect(r.error.detail?.
|
|
141
|
-
expect(r.error.detail?.
|
|
145
|
+
expect(r.error.detail?.actual).toBeCloseTo(1.1, 5);
|
|
146
|
+
expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 1 });
|
|
142
147
|
});
|
|
143
148
|
|
|
144
149
|
it('cascadeBlend=-0.1 -> ShadowInvalidConfigError (min=0)', () => {
|
|
@@ -151,7 +156,8 @@ describe('CSM component schema (w1)', () => {
|
|
|
151
156
|
expect(r.error.code).toBe('shadow-invalid-config');
|
|
152
157
|
expect(r.error.hint).toContain('cascadeBlend');
|
|
153
158
|
expect(r.error.detail?.field).toBe('cascadeBlend');
|
|
154
|
-
expect(r.error.detail?.
|
|
159
|
+
expect(r.error.detail?.actual).toBeCloseTo(-0.1, 5);
|
|
160
|
+
expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
it('cascadeBlend=0.6 -> ShadowInvalidConfigError (max=0.5)', () => {
|
|
@@ -164,8 +170,8 @@ describe('CSM component schema (w1)', () => {
|
|
|
164
170
|
expect(r.error.code).toBe('shadow-invalid-config');
|
|
165
171
|
expect(r.error.hint).toContain('cascadeBlend');
|
|
166
172
|
expect(r.error.detail?.field).toBe('cascadeBlend');
|
|
167
|
-
expect(r.error.detail?.
|
|
168
|
-
expect(r.error.detail?.
|
|
173
|
+
expect(r.error.detail?.actual).toBeCloseTo(0.6, 5);
|
|
174
|
+
expect(r.error.detail?.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
|
|
169
175
|
});
|
|
170
176
|
|
|
171
177
|
it('all three new fields within valid range spawn succeeds', () => {
|
|
@@ -180,29 +186,28 @@ describe('CSM component schema (w1)', () => {
|
|
|
180
186
|
|
|
181
187
|
// ── w2: ShadowInvalidConfigDetail.max field constructor tests ──────────
|
|
182
188
|
|
|
183
|
-
describe('w2: ShadowInvalidConfigDetail.
|
|
184
|
-
it('cascadeCount=5
|
|
189
|
+
describe('w2: ShadowInvalidConfigDetail.bound constructor', () => {
|
|
190
|
+
it('cascadeCount=5 bound.max=4 via constructor', () => {
|
|
185
191
|
// TDD red: max param not yet on constructor.
|
|
186
192
|
const err = new (ShadowInvalidConfigError as any)('cascadeCount', 5, 1, 4);
|
|
187
193
|
expect(err.code).toBe('shadow-invalid-config');
|
|
188
194
|
expect(err.detail.field).toBe('cascadeCount');
|
|
189
|
-
expect(err.detail.
|
|
190
|
-
expect(err.detail.
|
|
191
|
-
expect(err.detail.max).toBe(4);
|
|
195
|
+
expect(err.detail.actual).toBe(5);
|
|
196
|
+
expect(err.detail.bound).toEqual({ kind: 'range', min: 1, max: 4 });
|
|
192
197
|
expect(err.hint).toContain('[1, 4]');
|
|
193
198
|
});
|
|
194
199
|
|
|
195
|
-
it('cascadeBlend=0.6
|
|
200
|
+
it('cascadeBlend=0.6 bound.max=0.5 via constructor', () => {
|
|
196
201
|
const err = new (ShadowInvalidConfigError as any)('cascadeBlend', 0.6, 0, 0.5);
|
|
197
202
|
expect(err.detail.field).toBe('cascadeBlend');
|
|
198
|
-
expect(err.detail.
|
|
203
|
+
expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 0.5 });
|
|
199
204
|
expect(err.hint).toContain('[0, 0.5]');
|
|
200
205
|
});
|
|
201
206
|
|
|
202
|
-
it('splitLambda=1.1
|
|
207
|
+
it('splitLambda=1.1 bound.max=1 via constructor', () => {
|
|
203
208
|
const err = new (ShadowInvalidConfigError as any)('splitLambda', 1.1, 0, 1);
|
|
204
209
|
expect(err.detail.field).toBe('splitLambda');
|
|
205
|
-
expect(err.detail.
|
|
210
|
+
expect(err.detail.bound).toEqual({ kind: 'range', min: 0, max: 1 });
|
|
206
211
|
expect(err.hint).toContain('[0, 1]');
|
|
207
212
|
});
|
|
208
213
|
});
|
|
@@ -260,15 +265,17 @@ describe('CSM component schema (w1)', () => {
|
|
|
260
265
|
const err = r.error as {
|
|
261
266
|
code?: string;
|
|
262
267
|
hint?: string;
|
|
263
|
-
detail?: {
|
|
268
|
+
detail?: {
|
|
269
|
+
field?: string;
|
|
270
|
+
actual?: number;
|
|
271
|
+
bound?: { kind?: string; min?: number; max?: number };
|
|
272
|
+
};
|
|
264
273
|
};
|
|
265
274
|
expect(err.code).toBe('shadow-invalid-config');
|
|
266
275
|
expect(err.hint).toContain('mapSize');
|
|
267
276
|
expect(err.detail?.field).toBe('mapSize');
|
|
268
|
-
expect(err.detail?.
|
|
269
|
-
expect(err.detail?.
|
|
270
|
-
// mapSize < 1 has no max (lower-bound only), compatible with old callers.
|
|
271
|
-
expect(err.detail?.max).toBeUndefined();
|
|
277
|
+
expect(err.detail?.actual).toBe(0);
|
|
278
|
+
expect(err.detail?.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
|
|
272
279
|
}
|
|
273
280
|
});
|
|
274
281
|
|