@forgeax/engine-runtime 0.1.20 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -3
- 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/register-runtime-components.d.ts.map +1 -1
- package/dist/__tests__/helpers/register-scene-components.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__/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 +4 -2
- package/dist/index.mjs.map +1 -1
- package/dist/sprite-param-values.d.ts +1 -1
- package/package.json +30 -30
- package/src/__tests__/active-camera.unit.test.ts +6 -8
- package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
- package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
- package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
- package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
- package/src/__tests__/asset.unit.test.ts +5 -2
- package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
- package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
- package/src/__tests__/components.test-d.ts +19 -9
- package/src/__tests__/components.unit.test.ts +36 -26
- 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 +54 -20
- package/src/__tests__/extract-frames-merge.test.ts +51 -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__/helpers/register-runtime-components.ts +9 -1
- package/src/__tests__/helpers/register-scene-components.ts +9 -2
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
- package/src/__tests__/light-snapshot.test-d.ts +35 -1
- package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
- package/src/__tests__/lights.unit.test.ts +223 -308
- package/src/__tests__/loader-name-survival.test.ts +8 -9
- package/src/__tests__/package-promotion.test.ts +7 -11
- package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
- package/src/__tests__/pipeline.unit.test.ts +68 -226
- package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
- package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
- package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
- package/src/__tests__/record-worldid-isolation.test.ts +16 -32
- package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
- package/src/__tests__/render-system-extract.test.ts +5 -5
- package/src/__tests__/render-system-mega.test.ts +30 -41
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -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 -5
- package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
- package/src/__tests__/resolveName.test.ts +14 -2
- package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
- package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -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-tile-consistency.test.ts +1 -0
- package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
- package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
- package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
- package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
- package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
- package/src/__tests__/ssao-bgl.test.ts +12 -17
- package/src/__tests__/ssao-passes.test.ts +19 -2
- package/src/__tests__/systems.unit.test.ts +69 -205
- package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
- package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
- package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
- package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
- package/src/collect-scene-asset.ts +1 -1
- package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
- package/src/createRenderer.ts +2 -0
- package/src/index.ts +1 -2
- package/src/sprite-param-values.ts +1 -1
- package/dist/.tsbuildinfo +0 -1
|
@@ -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({
|
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
// independent of runtime cascadeCount. Validates std140 byte offsets for
|
|
6
6
|
// lightViewProj[4], splitPlanes[4], cascadeCount, cascadeBlend.
|
|
7
7
|
//
|
|
8
|
-
// feat-
|
|
9
|
-
//
|
|
8
|
+
// feat-20260827-directional-csm-pcss-quality M1: the View UBO keeps its
|
|
9
|
+
// existing 960 B payload and 1024 B slot while the directional filter carrier
|
|
10
|
+
// reuses the tail pad. The directional cascade
|
|
10
11
|
// offsets [0..128] are byte-for-byte UNCHANGED; the per-spot fragment-read
|
|
11
12
|
// `spotLightViewProj` array<mat4x4<f32>,4> (256 B) folded into the tail at
|
|
12
13
|
// byte 528 (float 132), replacing the former zero tail-pad and removing the
|
|
13
14
|
// standalone @group(0) binding 9 uniform buffer that overflowed the WebGL2
|
|
14
15
|
// fallback fragment uniform-buffer budget.
|
|
15
16
|
//
|
|
16
|
-
// Layout (
|
|
17
|
+
// Layout (960 B View payload, 1024 B slot; the directional prefix remains
|
|
18
|
+
// byte-compatible with the 784 B Spot-matrix boundary):
|
|
17
19
|
// [ 0.. 16) worldViewProj mat4x4<f32> (align 16, size 64)
|
|
18
20
|
// [ 16.. 19) lightDir vec3<f32> (align 16, 12 + 4 pad)
|
|
19
21
|
// [ 20.. 22) lightColor vec3<f32> (align 16, 12 + 4 pad)
|
|
@@ -31,30 +33,27 @@
|
|
|
31
33
|
// [125] cascadeBlend f32 (align 4, size 4)
|
|
32
34
|
// [126] depthBias f32 (align 4, size 4)
|
|
33
35
|
// [127] normalBias f32 (align 4, size 4)
|
|
34
|
-
// [128]
|
|
35
|
-
// [129..131] align padding f32[3] (mat4 array align 16)
|
|
36
|
+
// [128..131] directionalShadowFilter vec4<f32> (profile, radius, penumbra, reserved)
|
|
36
37
|
// [132..195] spotLightViewProj array<mat4x4<f32>,4> (align 16, size 256)
|
|
37
38
|
|
|
38
39
|
import { readFileSync } from 'node:fs';
|
|
39
40
|
import { fileURLToPath } from 'node:url';
|
|
40
41
|
import { describe, expect, it } from 'vitest';
|
|
41
42
|
|
|
42
|
-
const VIEW_UBO_FLOAT_COUNT =
|
|
43
|
-
const VIEW_UBO_BYTES =
|
|
43
|
+
const VIEW_UBO_FLOAT_COUNT = 240;
|
|
44
|
+
const VIEW_UBO_BYTES = 960;
|
|
44
45
|
// feat-20260625 w25: spot lightViewProj array folded into the View UBO tail.
|
|
45
|
-
const OFFSET_SPOT_LIGHT_VIEW_PROJ = 132; // byte 528 (16 B-aligned after
|
|
46
|
+
const OFFSET_SPOT_LIGHT_VIEW_PROJ = 132; // byte 528 (16 B-aligned after filter carrier)
|
|
46
47
|
const SPOT_LVP_LANE_COUNT = 4;
|
|
47
48
|
const SPOT_LVP_FLOATS_PER_LANE = 16;
|
|
48
49
|
|
|
49
|
-
// feat-
|
|
50
|
+
// feat-20260827-directional-csm-pcss-quality M1:
|
|
50
51
|
// the merged DirectionalLight's shadow tail-pad slots — depthBias [126]/byte504,
|
|
51
|
-
// normalBias [127]/byte508,
|
|
52
|
-
//
|
|
53
|
-
// demos AC-14 pcfKernelSize lane moved from [126] to [128] when depthBias/normalBias
|
|
54
|
-
// merged in ahead of it.
|
|
52
|
+
// normalBias [127]/byte508, and the four-float directional filter carrier
|
|
53
|
+
// [128..131]. The Spot matrix array remains at [132..195].
|
|
55
54
|
const OFFSET_DEPTH_BIAS = 126; // byte 504
|
|
56
55
|
const OFFSET_NORMAL_BIAS = 127; // byte 508
|
|
57
|
-
const
|
|
56
|
+
const OFFSET_DIRECTIONAL_FILTER = 128; // byte 512
|
|
58
57
|
|
|
59
58
|
const F32_BYTES = 4;
|
|
60
59
|
|
|
@@ -79,30 +78,30 @@ const SPLIT_STRIDE_FLOATS = 4;
|
|
|
79
78
|
|
|
80
79
|
describe('View UBO std140 layout (w14)', () => {
|
|
81
80
|
describe('size invariants', () => {
|
|
82
|
-
it('UBO total size is
|
|
83
|
-
expect(VIEW_UBO_FLOAT_COUNT).toBe(
|
|
81
|
+
it('UBO total size is 960 B (240 f32)', () => {
|
|
82
|
+
expect(VIEW_UBO_FLOAT_COUNT).toBe(240);
|
|
84
83
|
expect(VIEW_UBO_BYTES).toBe(VIEW_UBO_FLOAT_COUNT * F32_BYTES);
|
|
85
|
-
expect(VIEW_UBO_BYTES).toBe(
|
|
84
|
+
expect(VIEW_UBO_BYTES).toBe(960);
|
|
86
85
|
});
|
|
87
86
|
|
|
88
87
|
it('UBO size is fixed — independent of cascadeCount', () => {
|
|
89
|
-
// The host always allocates
|
|
88
|
+
// The host always allocates 960 B regardless of whether cascadeCount
|
|
90
89
|
// is 1, 2, 3, or 4 at runtime. This validates AC-08.
|
|
91
90
|
const sizeForN1 = VIEW_UBO_FLOAT_COUNT * F32_BYTES; // cascadeCount=1
|
|
92
91
|
const sizeForN4 = VIEW_UBO_FLOAT_COUNT * F32_BYTES; // cascadeCount=4
|
|
93
92
|
expect(sizeForN1).toBe(sizeForN4);
|
|
94
|
-
expect(sizeForN1).toBe(
|
|
93
|
+
expect(sizeForN1).toBe(960);
|
|
95
94
|
});
|
|
96
95
|
|
|
97
96
|
it('spotLightViewProj array lands at byte 528 (float 132), 16 B-aligned, last field', () => {
|
|
98
97
|
// feat-20260625 w25: the 4-lane spot perspective matrix array folds into
|
|
99
|
-
// the View UBO tail after
|
|
98
|
+
// the View UBO tail after the directional filter carrier, 16 B-aligned
|
|
100
99
|
// so it starts at byte 528 (float 132). It is the last struct field.
|
|
101
100
|
expect(OFFSET_SPOT_LIGHT_VIEW_PROJ * F32_BYTES).toBe(528);
|
|
102
101
|
expect((OFFSET_SPOT_LIGHT_VIEW_PROJ * F32_BYTES) % 16).toBe(0);
|
|
103
102
|
const spotArrayEndFloat =
|
|
104
103
|
OFFSET_SPOT_LIGHT_VIEW_PROJ + SPOT_LVP_LANE_COUNT * SPOT_LVP_FLOATS_PER_LANE;
|
|
105
|
-
expect(spotArrayEndFloat).toBe(
|
|
104
|
+
expect(spotArrayEndFloat).toBe(196);
|
|
106
105
|
expect(spotArrayEndFloat * F32_BYTES).toBe(784);
|
|
107
106
|
});
|
|
108
107
|
});
|
|
@@ -220,15 +219,8 @@ describe('View UBO std140 layout (w14)', () => {
|
|
|
220
219
|
});
|
|
221
220
|
});
|
|
222
221
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
// WGSL View struct must declare the matching `pcfKernelSize : f32` immediately
|
|
226
|
-
// after cascadeBlend (single SSOT, append-at-tail, plan-strategy D-0). These
|
|
227
|
-
// are read against the real source the compiler/host use (mirrors the
|
|
228
|
-
// shadow-csm-tile-consistency antipattern guard: never re-declare and test a
|
|
229
|
-
// formula against itself). RED until M0-T-IMPL-RECORD + M0-T-IMPL-WGSL-STRUCT
|
|
230
|
-
// land; the slot was previously a zero tail-pad float (no record write existed).
|
|
231
|
-
describe('pcfKernelSize tail-pad slot wiring (M0, AC-14)', () => {
|
|
222
|
+
// M1-T05: read the real host and WGSL sources so the frozen ABI cannot drift.
|
|
223
|
+
describe('directional filter carrier tail-pad wiring (M1-T05)', () => {
|
|
232
224
|
const recordSrc = readFileSync(
|
|
233
225
|
fileURLToPath(new URL('../../../render/src/record/view-ubo.ts', import.meta.url)),
|
|
234
226
|
'utf8',
|
|
@@ -238,26 +230,25 @@ describe('pcfKernelSize tail-pad slot wiring (M0, AC-14)', () => {
|
|
|
238
230
|
'utf8',
|
|
239
231
|
);
|
|
240
232
|
|
|
241
|
-
it('shadow tail floats [126..
|
|
233
|
+
it('shadow tail floats [126..131] at fixed offsets, before the spot matrix array', () => {
|
|
242
234
|
expect(OFFSET_DEPTH_BIAS * F32_BYTES).toBe(504);
|
|
243
235
|
expect(OFFSET_NORMAL_BIAS * F32_BYTES).toBe(508);
|
|
244
|
-
expect(
|
|
236
|
+
expect(OFFSET_DIRECTIONAL_FILTER * F32_BYTES).toBe(512);
|
|
245
237
|
expect(OFFSET_DEPTH_BIAS).toBeGreaterThan(OFFSET_CASCADE_BLEND);
|
|
246
|
-
expect(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
expect(OFFSET_PCF_KERNEL_SIZE).toBeLessThan(OFFSET_SPOT_LIGHT_VIEW_PROJ);
|
|
251
|
-
expect(VIEW_UBO_FLOAT_COUNT).toBe(196);
|
|
252
|
-
expect(VIEW_UBO_BYTES).toBe(784);
|
|
238
|
+
expect(OFFSET_DIRECTIONAL_FILTER + 4).toBe(OFFSET_SPOT_LIGHT_VIEW_PROJ);
|
|
239
|
+
expect(OFFSET_DIRECTIONAL_FILTER).toBeLessThan(OFFSET_SPOT_LIGHT_VIEW_PROJ);
|
|
240
|
+
expect(VIEW_UBO_FLOAT_COUNT).toBe(240);
|
|
241
|
+
expect(VIEW_UBO_BYTES).toBe(960);
|
|
253
242
|
});
|
|
254
243
|
|
|
255
|
-
it('record writes the merged shadow tail-pad floats [126]/[127]/[128]', () => {
|
|
244
|
+
it('record writes the merged shadow tail-pad floats [126]/[127]/[128..131]', () => {
|
|
256
245
|
expect(recordSrc).toMatch(/viewPayload\[126\]\s*=\s*lights\.depthBias/);
|
|
257
246
|
expect(recordSrc).toMatch(/viewPayload\[127\]\s*=\s*lights\.normalBias/);
|
|
258
|
-
expect(recordSrc).toMatch(
|
|
259
|
-
|
|
260
|
-
);
|
|
247
|
+
expect(recordSrc).toMatch(/viewPayload\[128\]/);
|
|
248
|
+
expect(recordSrc).toMatch(/viewPayload\[129\]/);
|
|
249
|
+
expect(recordSrc).toMatch(/viewPayload\[130\]/);
|
|
250
|
+
expect(recordSrc).toMatch(/viewPayload\[131\]/);
|
|
251
|
+
expect(recordSrc).not.toMatch(/lights\.pcfKernelSize/);
|
|
261
252
|
});
|
|
262
253
|
|
|
263
254
|
it('record carries the folded spot matrices in the complete View UBO payload (w25)', () => {
|
|
@@ -269,10 +260,17 @@ describe('pcfKernelSize tail-pad slot wiring (M0, AC-14)', () => {
|
|
|
269
260
|
expect(recordSrc).toMatch(/SPOT_LVP_BASE_FLOAT\s*=\s*132/);
|
|
270
261
|
});
|
|
271
262
|
|
|
272
|
-
it('common.wgsl View struct declares
|
|
263
|
+
it('common.wgsl View struct declares the directional filter vec4 after biases', () => {
|
|
273
264
|
const m = wgslSrc.match(
|
|
274
|
-
/cascadeBlend\s*:\s*f32\s*,\s*depthBias\s*:\s*f32\s*,\s*normalBias\s*:\s*f32\s*,\s*
|
|
265
|
+
/cascadeBlend\s*:\s*f32\s*,\s*depthBias\s*:\s*f32\s*,\s*normalBias\s*:\s*f32\s*,\s*directionalShadowFilter\s*:\s*vec4<f32>\s*,/,
|
|
275
266
|
);
|
|
276
267
|
expect(m).not.toBeNull();
|
|
277
268
|
});
|
|
269
|
+
|
|
270
|
+
it('keeps one View binding and removes the retired spot matrix binding', () => {
|
|
271
|
+
expect(wgslSrc.match(/@group\(0\) @binding\(0\) var<uniform> view/g)).toHaveLength(1);
|
|
272
|
+
expect(wgslSrc).not.toMatch(
|
|
273
|
+
/@group\(0\)\s*@binding\(9\)\s*var<uniform>\s+spotLightViewProj\s*:/,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
278
276
|
});
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// D-6: pass names shadowCascade0..3.
|
|
12
12
|
|
|
13
13
|
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
|
|
15
|
+
import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
|
|
14
16
|
|
|
15
17
|
const browserReady = typeof navigator !== 'undefined' && navigator.gpu !== undefined;
|
|
16
18
|
|
|
@@ -83,3 +85,30 @@ describe('CSM per-cascade viewport (w30)', () => {
|
|
|
83
85
|
});
|
|
84
86
|
});
|
|
85
87
|
});
|
|
88
|
+
|
|
89
|
+
describe('M3 Browser backend admission', () => {
|
|
90
|
+
it('declares WebGL2 PCSS fallback without changing the atlas viewport contract', () => {
|
|
91
|
+
const inspection = projectDirectionalShadowInspection({
|
|
92
|
+
admission: resolveDirectionalShadowBackendAdmission({
|
|
93
|
+
backendKind: 'wgpu-webgl2',
|
|
94
|
+
requested: 'pcssHigh',
|
|
95
|
+
candidate: 'accepted',
|
|
96
|
+
}),
|
|
97
|
+
cascadeCount: 4,
|
|
98
|
+
mapSize: 2048,
|
|
99
|
+
atlasBytes: 67_108_864,
|
|
100
|
+
writerPasses: 4,
|
|
101
|
+
blockerTaps: 0,
|
|
102
|
+
filterTapUpperBound: 25,
|
|
103
|
+
seamTapUpperBound: 25,
|
|
104
|
+
deviceGeneration: 1,
|
|
105
|
+
graphGeneration: 1,
|
|
106
|
+
});
|
|
107
|
+
expect(inspection).toMatchObject({
|
|
108
|
+
effective: 'pcf5',
|
|
109
|
+
fallbackReason: 'webgl2-unsupported',
|
|
110
|
+
cascadeCount: 4,
|
|
111
|
+
mapSize: 2048,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|