@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
|
@@ -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
|
|
|
@@ -6,16 +6,22 @@
|
|
|
6
6
|
// This test asserts the extract stage:
|
|
7
7
|
// (a) castShadow:true → CSM path runs (lightViewProj + cascades populated)
|
|
8
8
|
// (b) castShadow:false → CSM path skipped (no cascade output)
|
|
9
|
-
// (c) ExtractedLights carries depthBias/normalBias
|
|
10
|
-
// (d) bias/
|
|
9
|
+
// (c) ExtractedLights carries depthBias/normalBias and accepted filter quality
|
|
10
|
+
// (d) bias/filter fields are undefined when castShadow:false
|
|
11
11
|
// RED before m2-t2/m2-t3: extract still queries the deleted
|
|
12
12
|
// castShadow: true; lightViewProj won't populate.
|
|
13
13
|
|
|
14
14
|
import { World } from '@forgeax/engine-ecs';
|
|
15
|
+
import { vec3 } from '@forgeax/engine-math';
|
|
15
16
|
import { Camera, DirectionalLight } from '@forgeax/engine-render';
|
|
16
17
|
import { Transform } from '@forgeax/engine-scene';
|
|
17
18
|
import { describe, expect, it } from 'vitest';
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type CsmCameraData,
|
|
21
|
+
computeDirectionalCsm,
|
|
22
|
+
extractFrame,
|
|
23
|
+
prepareExtractContext,
|
|
24
|
+
} from '../../../render/src/render-system-extract';
|
|
19
25
|
|
|
20
26
|
function makeWorld(castShadow?: boolean): World {
|
|
21
27
|
const world = new World();
|
|
@@ -26,7 +32,9 @@ function makeWorld(castShadow?: boolean): World {
|
|
|
26
32
|
castShadow: castShadow ?? true,
|
|
27
33
|
depthBias: 0.01,
|
|
28
34
|
normalBias: 0.08,
|
|
29
|
-
|
|
35
|
+
shadowFilter: 3,
|
|
36
|
+
shadowAngularRadius: 0.01,
|
|
37
|
+
maxPenumbraTexels: 48,
|
|
30
38
|
},
|
|
31
39
|
});
|
|
32
40
|
world.spawn(
|
|
@@ -56,16 +64,16 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
|
|
|
56
64
|
}
|
|
57
65
|
});
|
|
58
66
|
|
|
59
|
-
it('splitPlanes is a Float32Array of
|
|
67
|
+
it('splitPlanes is a Float32Array of four vec4 metric lanes', () => {
|
|
60
68
|
const world = makeWorld(true);
|
|
61
69
|
const frame = extractFrame(world, prepareExtractContext(world));
|
|
62
70
|
const { splitPlanes } = frame.lights;
|
|
63
71
|
expect(splitPlanes).toBeDefined();
|
|
64
72
|
if (splitPlanes === undefined) return;
|
|
65
73
|
expect(splitPlanes).toBeInstanceOf(Float32Array);
|
|
66
|
-
expect(splitPlanes.length).toBe(
|
|
74
|
+
expect(splitPlanes.length).toBe(16);
|
|
67
75
|
for (let i = 0; i < 4; i++) {
|
|
68
|
-
const v = splitPlanes[i];
|
|
76
|
+
const v = splitPlanes[i * 4];
|
|
69
77
|
expect(typeof v).toBe('number');
|
|
70
78
|
expect(Number.isFinite(v)).toBe(true);
|
|
71
79
|
}
|
|
@@ -95,7 +103,7 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
|
|
|
95
103
|
});
|
|
96
104
|
});
|
|
97
105
|
|
|
98
|
-
describe('ExtractedLights carries bias
|
|
106
|
+
describe('ExtractedLights carries bias and accepted Directional quality', () => {
|
|
99
107
|
it('depthBias populated when castShadow=true', () => {
|
|
100
108
|
const world = makeWorld(true);
|
|
101
109
|
const frame = extractFrame(world, prepareExtractContext(world));
|
|
@@ -108,18 +116,46 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
|
|
|
108
116
|
expect(frame.lights.normalBias).toBeCloseTo(0.08, 5);
|
|
109
117
|
});
|
|
110
118
|
|
|
111
|
-
it('
|
|
119
|
+
it('accepted Directional quality is populated when castShadow=true', () => {
|
|
112
120
|
const world = makeWorld(true);
|
|
113
121
|
const frame = extractFrame(world, prepareExtractContext(world));
|
|
114
|
-
expect(frame.lights.
|
|
122
|
+
expect(frame.lights.directionalShadowQuality).toEqual({ kind: 'pcf', kernel: 5 });
|
|
115
123
|
});
|
|
116
124
|
|
|
117
|
-
it('
|
|
125
|
+
it('quality is undefined when castShadow=false', () => {
|
|
118
126
|
const world = makeWorld(false);
|
|
119
127
|
const frame = extractFrame(world, prepareExtractContext(world));
|
|
120
128
|
expect(frame.lights.depthBias).toBeUndefined();
|
|
121
129
|
expect(frame.lights.normalBias).toBeUndefined();
|
|
122
|
-
expect(frame.lights.
|
|
130
|
+
expect(frame.lights.directionalShadowQuality).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('withholds an invalid PCSS candidate and exposes a structured error', () => {
|
|
134
|
+
const world = new World();
|
|
135
|
+
const spawned = world.spawn({
|
|
136
|
+
component: DirectionalLight,
|
|
137
|
+
data: {
|
|
138
|
+
direction: [0, -1, 0],
|
|
139
|
+
shadowFilter: 4,
|
|
140
|
+
shadowAngularRadius: 999,
|
|
141
|
+
maxPenumbraTexels: 32,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
expect(spawned.ok).toBe(true);
|
|
145
|
+
world.spawn(
|
|
146
|
+
{ component: Transform, data: {} },
|
|
147
|
+
{ component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const frame = extractFrame(world, prepareExtractContext(world));
|
|
151
|
+
expect(frame.lights.directionalShadowError?.code).toBe('shadow-invalid-config');
|
|
152
|
+
expect(frame.lights.directionalShadowError?.detail).toMatchObject({
|
|
153
|
+
field: 'shadowAngularRadius',
|
|
154
|
+
actual: 999,
|
|
155
|
+
});
|
|
156
|
+
expect(frame.lights.directionalShadowQuality).toBeUndefined();
|
|
157
|
+
expect(frame.lights.directionalCsmConfig).toBeUndefined();
|
|
158
|
+
expect(frame.lights.lightViewProj).toBeUndefined();
|
|
123
159
|
});
|
|
124
160
|
});
|
|
125
161
|
|
|
@@ -174,24 +210,18 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
|
|
|
174
210
|
});
|
|
175
211
|
});
|
|
176
212
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// struct (symmetric with cascadeCount/cascadeBlend). Before M0 this field is
|
|
180
|
-
// dead (research F7): extract never read it, so frame.lights.pcfKernelSize
|
|
181
|
-
// is undefined regardless of the component value -- these cases are RED
|
|
182
|
-
// until M0-T-IMPL-EXTRACT lands.
|
|
183
|
-
describe('pcfKernelSize wiring (M0, AC-14; merged DirectionalLight)', () => {
|
|
184
|
-
function setupWorldWithPcf(pcfKernelSize: number): World {
|
|
213
|
+
describe('CSM fit derives world-scale metrics and accepts one quality projection', () => {
|
|
214
|
+
function setupWorldWithQuality(shadowFilter: number): World {
|
|
185
215
|
const world = new World();
|
|
186
|
-
// feat-20260621: shadow fields merged onto DirectionalLight (castShadow
|
|
187
|
-
// defaults true); pcfKernelSize is read from the same component.
|
|
188
216
|
world.spawn({
|
|
189
217
|
component: DirectionalLight,
|
|
190
218
|
data: {
|
|
191
219
|
direction: [0, -1, 0],
|
|
192
220
|
cascadeCount: 1,
|
|
193
221
|
shadowDistance: 100,
|
|
194
|
-
|
|
222
|
+
shadowFilter,
|
|
223
|
+
shadowAngularRadius: 0.01,
|
|
224
|
+
maxPenumbraTexels: 32,
|
|
195
225
|
},
|
|
196
226
|
});
|
|
197
227
|
world.spawn(
|
|
@@ -201,36 +231,115 @@ describe('ExtractedLights interface (M2 castShadow gate)', () => {
|
|
|
201
231
|
return world;
|
|
202
232
|
}
|
|
203
233
|
|
|
204
|
-
it('
|
|
205
|
-
const world =
|
|
206
|
-
const frame = extractFrame(world, prepareExtractContext(world));
|
|
207
|
-
expect(frame.lights.pcfKernelSize).toBe(1);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('reads pcfKernelSize=3 from the merged component', () => {
|
|
211
|
-
const world = setupWorldWithPcf(3);
|
|
234
|
+
it('projects PCSS quality from the same Directional query', () => {
|
|
235
|
+
const world = setupWorldWithQuality(4);
|
|
212
236
|
const frame = extractFrame(world, prepareExtractContext(world));
|
|
213
|
-
expect(frame.lights.
|
|
237
|
+
expect(frame.lights.directionalShadowQuality).toEqual({
|
|
238
|
+
kind: 'pcss',
|
|
239
|
+
preset: 'medium',
|
|
240
|
+
angularRadiusRadians: expect.closeTo(0.01, 0.000001),
|
|
241
|
+
maxPenumbraTexels: 32,
|
|
242
|
+
});
|
|
214
243
|
});
|
|
215
244
|
|
|
216
|
-
it('
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
245
|
+
it('exposes four finite vec4 cascade metric lanes from the CSM fit', () => {
|
|
246
|
+
const camera: CsmCameraData = {
|
|
247
|
+
world: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
248
|
+
fov: Math.PI / 4,
|
|
249
|
+
aspect: 1,
|
|
250
|
+
near: 0.1,
|
|
251
|
+
far: 100,
|
|
252
|
+
projection: 'perspective',
|
|
253
|
+
orthoLeft: -1,
|
|
254
|
+
orthoRight: 1,
|
|
255
|
+
orthoBottom: -1,
|
|
256
|
+
orthoTop: 1,
|
|
257
|
+
};
|
|
258
|
+
for (const cascadeCount of [1, 2, 3, 4]) {
|
|
259
|
+
const result = computeDirectionalCsm(
|
|
260
|
+
vec3.create(0, -1, 0),
|
|
261
|
+
{
|
|
262
|
+
cascadeCount,
|
|
263
|
+
splitLambda: 0.75,
|
|
264
|
+
cascadeBlend: 0.2,
|
|
265
|
+
mapSize: 1024,
|
|
266
|
+
shadowDistance: 100,
|
|
267
|
+
shadowFilter: 3,
|
|
268
|
+
shadowAngularRadius: 0.01,
|
|
269
|
+
maxPenumbraTexels: 32,
|
|
270
|
+
},
|
|
271
|
+
camera,
|
|
272
|
+
);
|
|
273
|
+
expect(result).not.toBeNull();
|
|
274
|
+
const lanes = result?.splitPlanes ?? new Float32Array();
|
|
275
|
+
expect(lanes).toHaveLength(16);
|
|
276
|
+
for (let index = 0; index < cascadeCount; index += 1) {
|
|
277
|
+
expect(Number.isFinite(lanes[index * 4 + 1])).toBe(true);
|
|
278
|
+
expect(lanes[index * 4 + 1]).toBeGreaterThan(0);
|
|
279
|
+
expect(Number.isFinite(lanes[index * 4 + 2])).toBe(true);
|
|
280
|
+
expect(lanes[index * 4 + 2]).toBeGreaterThan(0);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
220
283
|
});
|
|
221
284
|
|
|
222
|
-
it('
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
285
|
+
it('recomputes worldUnitsPerTexel when mapSize changes and rejects non-finite config', () => {
|
|
286
|
+
const camera: CsmCameraData = {
|
|
287
|
+
world: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
|
|
288
|
+
fov: Math.PI / 4,
|
|
289
|
+
aspect: 1,
|
|
290
|
+
near: 0.1,
|
|
291
|
+
far: 100,
|
|
292
|
+
projection: 'perspective',
|
|
293
|
+
orthoLeft: -1,
|
|
294
|
+
orthoRight: 1,
|
|
295
|
+
orthoBottom: -1,
|
|
296
|
+
orthoTop: 1,
|
|
297
|
+
};
|
|
298
|
+
const coarse = computeDirectionalCsm(
|
|
299
|
+
vec3.create(0, -1, 0),
|
|
300
|
+
{
|
|
301
|
+
cascadeCount: 1,
|
|
302
|
+
splitLambda: 0.75,
|
|
303
|
+
cascadeBlend: 0.2,
|
|
304
|
+
mapSize: 512,
|
|
305
|
+
shadowDistance: 100,
|
|
306
|
+
shadowFilter: 3,
|
|
307
|
+
shadowAngularRadius: 0.01,
|
|
308
|
+
maxPenumbraTexels: 32,
|
|
309
|
+
},
|
|
310
|
+
camera,
|
|
231
311
|
);
|
|
232
|
-
const
|
|
233
|
-
|
|
312
|
+
const fine = computeDirectionalCsm(
|
|
313
|
+
vec3.create(0, -1, 0),
|
|
314
|
+
{
|
|
315
|
+
cascadeCount: 1,
|
|
316
|
+
splitLambda: 0.75,
|
|
317
|
+
cascadeBlend: 0.2,
|
|
318
|
+
mapSize: 2048,
|
|
319
|
+
shadowDistance: 100,
|
|
320
|
+
shadowFilter: 3,
|
|
321
|
+
shadowAngularRadius: 0.01,
|
|
322
|
+
maxPenumbraTexels: 32,
|
|
323
|
+
},
|
|
324
|
+
camera,
|
|
325
|
+
);
|
|
326
|
+
expect(fine?.splitPlanes[1]).toBeCloseTo((coarse?.splitPlanes[1] ?? 0) / 4, 4);
|
|
327
|
+
expect(
|
|
328
|
+
computeDirectionalCsm(
|
|
329
|
+
vec3.create(0, -1, 0),
|
|
330
|
+
{
|
|
331
|
+
cascadeCount: 1,
|
|
332
|
+
splitLambda: 0.75,
|
|
333
|
+
cascadeBlend: 0.2,
|
|
334
|
+
mapSize: Number.NaN,
|
|
335
|
+
shadowDistance: 100,
|
|
336
|
+
shadowFilter: 3,
|
|
337
|
+
shadowAngularRadius: 0.01,
|
|
338
|
+
maxPenumbraTexels: 32,
|
|
339
|
+
},
|
|
340
|
+
camera,
|
|
341
|
+
),
|
|
342
|
+
).toBeNull();
|
|
234
343
|
});
|
|
235
344
|
});
|
|
236
345
|
});
|
|
@@ -20,6 +20,8 @@ import { World } from '@forgeax/engine-ecs';
|
|
|
20
20
|
import { Camera, DirectionalLight } from '@forgeax/engine-render';
|
|
21
21
|
import { Transform } from '@forgeax/engine-scene';
|
|
22
22
|
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
|
|
24
|
+
import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
|
|
23
25
|
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
24
26
|
import { drawPublished } from './draw-published';
|
|
25
27
|
|
|
@@ -34,6 +36,51 @@ const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(
|
|
|
34
36
|
// biome-ignore lint/suspicious/noExplicitAny: dawn-node detection guard
|
|
35
37
|
const dawnReady = typeof navigator !== 'undefined' && (navigator as any).gpu !== undefined;
|
|
36
38
|
|
|
39
|
+
describe('M3 RhiNull structural admission', () => {
|
|
40
|
+
it('requires the structural-only RhiNull projection and explicit no-pixel claim', async () => {
|
|
41
|
+
const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
|
|
42
|
+
const resolver = module.resolveDirectionalShadowBackendAdmission;
|
|
43
|
+
expect(typeof resolver).toBe('function');
|
|
44
|
+
const result = (
|
|
45
|
+
resolver as (input: {
|
|
46
|
+
backendKind: 'null';
|
|
47
|
+
requested: 'pcssMedium' | 'pcssHigh';
|
|
48
|
+
candidate: 'accepted' | 'failed';
|
|
49
|
+
}) => { effective: string; fallbackReason?: string; pixelEvidence: string }
|
|
50
|
+
)({ backendKind: 'null', requested: 'pcssMedium', candidate: 'accepted' });
|
|
51
|
+
expect(result).toMatchObject({
|
|
52
|
+
effective: 'rhi-null-structural',
|
|
53
|
+
fallbackReason: 'rhi-null-structural',
|
|
54
|
+
pixelEvidence: 'not-available',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps RhiNull recovery structural and generation-scoped', () => {
|
|
59
|
+
const inspection = projectDirectionalShadowInspection({
|
|
60
|
+
admission: resolveDirectionalShadowBackendAdmission({
|
|
61
|
+
backendKind: 'null',
|
|
62
|
+
requested: 'pcssMedium',
|
|
63
|
+
candidate: 'accepted',
|
|
64
|
+
}),
|
|
65
|
+
cascadeCount: 4,
|
|
66
|
+
mapSize: 1024,
|
|
67
|
+
atlasBytes: 16_777_216,
|
|
68
|
+
writerPasses: 4,
|
|
69
|
+
blockerTaps: 0,
|
|
70
|
+
filterTapUpperBound: 0,
|
|
71
|
+
seamTapUpperBound: 0,
|
|
72
|
+
deviceGeneration: 4,
|
|
73
|
+
graphGeneration: 9,
|
|
74
|
+
});
|
|
75
|
+
expect(inspection).toMatchObject({
|
|
76
|
+
effective: 'rhi-null-structural',
|
|
77
|
+
pixelEvidence: 'not-available',
|
|
78
|
+
deviceGeneration: 4,
|
|
79
|
+
graphGeneration: 9,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
37
84
|
const WIDTH = 320;
|
|
38
85
|
const HEIGHT = 240;
|
|
39
86
|
|
|
@@ -200,4 +247,29 @@ describe('CSM runtime cascade + mapSize variation (M5/w26)', () => {
|
|
|
200
247
|
const { world } = buildScene(1, 1024);
|
|
201
248
|
expect(drawPublished(renderer, world).ok).toBe(true);
|
|
202
249
|
});
|
|
250
|
+
|
|
251
|
+
it('castShadow true -> false -> true toggles the directional graph without device error', async () => {
|
|
252
|
+
if (!dawnReady) return;
|
|
253
|
+
const canvas = createMockCanvas();
|
|
254
|
+
const host = await constructRuntimeRendererHost(
|
|
255
|
+
canvas as unknown as HTMLCanvasElement,
|
|
256
|
+
{},
|
|
257
|
+
{ shaderManifestUrl: ENGINE_MANIFEST_URL },
|
|
258
|
+
);
|
|
259
|
+
expect(host.ok).toBe(true);
|
|
260
|
+
if (!host.ok) throw host.error;
|
|
261
|
+
const { renderer } = host.value;
|
|
262
|
+
const { world, shadowEntity } = buildScene(4, 1024);
|
|
263
|
+
|
|
264
|
+
expect(drawPublished(renderer, world).ok).toBe(true);
|
|
265
|
+
world.set(shadowEntity, DirectionalLight, { castShadow: false });
|
|
266
|
+
expect(drawPublished(renderer, world).ok).toBe(true);
|
|
267
|
+
world.set(shadowEntity, DirectionalLight, {
|
|
268
|
+
castShadow: true,
|
|
269
|
+
mapSize: 1024,
|
|
270
|
+
shadowDistance: 50,
|
|
271
|
+
cascadeCount: 4,
|
|
272
|
+
});
|
|
273
|
+
expect(drawPublished(renderer, world).ok).toBe(true);
|
|
274
|
+
});
|
|
203
275
|
});
|
|
@@ -20,8 +20,15 @@
|
|
|
20
20
|
// regression in the kernel logic itself. The kernel is the executable AC-06
|
|
21
21
|
// spec; w18 implementation must derive identical behavior.
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
23
25
|
import { describe, expect, it } from 'vitest';
|
|
24
26
|
|
|
27
|
+
const directionalShader = readFileSync(
|
|
28
|
+
fileURLToPath(new URL('../../../shader/src/lighting-directional.wgsl', import.meta.url)),
|
|
29
|
+
'utf8',
|
|
30
|
+
);
|
|
31
|
+
|
|
25
32
|
// biome-ignore lint/suspicious/noExplicitAny: dawn-node detection guard
|
|
26
33
|
const dawnReady = typeof navigator !== 'undefined' && (navigator as any).gpu !== undefined;
|
|
27
34
|
|
|
@@ -228,6 +235,18 @@ describe('CSM shader cascade selection + atlas UV + blend (M5/w17)', () => {
|
|
|
228
235
|
expect(dawnReady).toBe(true);
|
|
229
236
|
});
|
|
230
237
|
|
|
238
|
+
it('production Directional PCSS uses raw depth and comparison without new bindings', () => {
|
|
239
|
+
expect(directionalShader).toContain('fn _samplePcssForCascade');
|
|
240
|
+
expect(directionalShader).toContain('shadow_load_raw_depth');
|
|
241
|
+
expect(directionalShader).toContain('shadow_sample_compare');
|
|
242
|
+
expect(directionalShader).toContain('PCSS_MEDIUM_RAW_TAPS : u32 = 8u');
|
|
243
|
+
expect(directionalShader).toContain('PCSS_MEDIUM_COMPARE_TAPS : u32 = 16u');
|
|
244
|
+
expect(directionalShader).toContain('PCSS_HIGH_RAW_TAPS : u32 = 16u');
|
|
245
|
+
expect(directionalShader).toContain('PCSS_HIGH_COMPARE_TAPS : u32 = 32u');
|
|
246
|
+
expect(directionalShader).not.toMatch(/@binding\(/);
|
|
247
|
+
expect(directionalShader).not.toMatch(/frameIndex|frame_index|taaJitter|taa_jitter|jitter/);
|
|
248
|
+
});
|
|
249
|
+
|
|
231
250
|
describe('cascade selection by viewZ (AC-03 / AC-06)', () => {
|
|
232
251
|
it('viewZ < splits[0] -> layer 0', async () => {
|
|
233
252
|
const r = await runKernel({
|