@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
|
@@ -28,16 +28,15 @@ import {
|
|
|
28
28
|
computeInvRangeSquared,
|
|
29
29
|
degToCos,
|
|
30
30
|
validateDirectionalLightData,
|
|
31
|
+
validatePointLightShadowData,
|
|
31
32
|
validateSpotLightData,
|
|
32
33
|
} from '../../../render/src/components/light-helpers';
|
|
33
34
|
import type { ShadowInvalidConfigError } from '../../../render/src/errors/render';
|
|
34
35
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
packPointLight,
|
|
40
|
-
packSpotLight,
|
|
36
|
+
BYTES_PER_DIRECT_LIGHT_SLOT,
|
|
37
|
+
DIRECT_LIGHT_SLOT_LAYOUT,
|
|
38
|
+
DirectLightSlotKind,
|
|
39
|
+
packDirectLightSlot,
|
|
41
40
|
} from '../../../render/src/light-buffer-layout';
|
|
42
41
|
import { buildPbrViewBglEntries } from '../../../render/src/pbr-pipeline';
|
|
43
42
|
import type {
|
|
@@ -62,6 +61,77 @@ function spawnValidatedLight(
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
{
|
|
64
|
+
describe('M1 directional closed shadow quality contract', () => {
|
|
65
|
+
it('publishes the five closed labels and PCSS defaults', () => {
|
|
66
|
+
const schema = componentSchema(DirectionalLight);
|
|
67
|
+
expect(schema.shadowFilter).toBe('enum');
|
|
68
|
+
expect(componentDefinition(DirectionalLight).defaults).toMatchObject({
|
|
69
|
+
shadowFilter: 2,
|
|
70
|
+
shadowAngularRadius: 0.00465,
|
|
71
|
+
maxPenumbraTexels: 32,
|
|
72
|
+
});
|
|
73
|
+
expect(DirectionalLight.fields.shadowFilter.labels).toEqual({
|
|
74
|
+
pcf1: 1,
|
|
75
|
+
pcf3: 2,
|
|
76
|
+
pcf5: 3,
|
|
77
|
+
pcssMedium: 4,
|
|
78
|
+
pcssHigh: 5,
|
|
79
|
+
});
|
|
80
|
+
expect('pcfKernelSize' in schema).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([
|
|
84
|
+
['shadowAngularRadius', Number.NaN],
|
|
85
|
+
['shadowAngularRadius', 0.00009],
|
|
86
|
+
['shadowAngularRadius', 0.05001],
|
|
87
|
+
['maxPenumbraTexels', Number.NaN],
|
|
88
|
+
['maxPenumbraTexels', 0],
|
|
89
|
+
['maxPenumbraTexels', 65],
|
|
90
|
+
['maxPenumbraTexels', 1.5],
|
|
91
|
+
] as const)('rejects invalid PCSS field %s=%s with one structured detail shape', (field, value) => {
|
|
92
|
+
const result = spawnValidatedLight(new World(), DirectionalLight, {
|
|
93
|
+
direction: [0, -1, 0],
|
|
94
|
+
shadowFilter: 4,
|
|
95
|
+
[field]: value,
|
|
96
|
+
});
|
|
97
|
+
expect(result.ok).toBe(false);
|
|
98
|
+
if (result.ok) throw new Error('expected validation to fail');
|
|
99
|
+
const error = result.error as unknown as ShadowInvalidConfigError;
|
|
100
|
+
expect(error.code).toBe('shadow-invalid-config');
|
|
101
|
+
expect(Object.keys(error.detail)).toEqual(['field', 'actual', 'bound', 'reason']);
|
|
102
|
+
expect(error.detail.field).toBe(field);
|
|
103
|
+
expect(error.detail.actual).toBe(value);
|
|
104
|
+
expect(['range', 'lower-bound', 'allowed-values']).toContain(error.detail.bound.kind);
|
|
105
|
+
expect(typeof error.detail.reason).toBe('string');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('skips PCSS validation when Directional shadows are disabled', () => {
|
|
109
|
+
const result = validateDirectionalLightData({
|
|
110
|
+
direction: [0, -1, 0],
|
|
111
|
+
castShadow: false,
|
|
112
|
+
shadowFilter: 99,
|
|
113
|
+
shadowAngularRadius: Number.NaN,
|
|
114
|
+
maxPenumbraTexels: 0,
|
|
115
|
+
});
|
|
116
|
+
expect(result.ok).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('keeps Point and Spot pcfKernelSize validation as their own contract', () => {
|
|
120
|
+
const point = validatePointLightShadowData({
|
|
121
|
+
mapSize: 512,
|
|
122
|
+
nearPlane: 0.1,
|
|
123
|
+
farPlane: 10,
|
|
124
|
+
pcfKernelSize: 3,
|
|
125
|
+
});
|
|
126
|
+
expect(point.ok).toBe(true);
|
|
127
|
+
const spot = validateSpotLightData({
|
|
128
|
+
direction: [0, -1, 0],
|
|
129
|
+
pcfKernelSize: 3,
|
|
130
|
+
});
|
|
131
|
+
expect(spot.ok).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
65
135
|
// ─── feat-20260709 M2 / w4: vec-collapse schema shape + default equivalence ───
|
|
66
136
|
// AC-01 + E1: direction is array<f32,3> with NO default (D-5 -- the only
|
|
67
137
|
// field deliberately left defaultless so an omitted/zero direction is a
|
|
@@ -277,13 +347,13 @@ function spawnValidatedLight(
|
|
|
277
347
|
expect(defaults.depthBias).toBeCloseTo(0.005, 5);
|
|
278
348
|
expect(defaults.normalBias).toBeCloseTo(0.05, 5);
|
|
279
349
|
expect(defaults.shadowDistance).toBeCloseTo(200, 5);
|
|
280
|
-
expect(defaults.
|
|
350
|
+
expect(defaults.shadowFilter).toBe(2);
|
|
281
351
|
|
|
282
352
|
// Merged component: 3 light (direction + color arrays + intensity) +
|
|
283
|
-
// 1 castShadow +
|
|
353
|
+
// 1 castShadow + 10 closed shadow-quality fields = 14 fields (feat-20260709 M2 collapsed
|
|
284
354
|
// direction/color from 6 per-axis scalars to 2 array<f32,3> columns;
|
|
285
355
|
// nearPlane removed — derived from camera near; farPlane -> shadowDistance)
|
|
286
|
-
expect(Object.keys(componentSchema(dl)).length).toBe(
|
|
356
|
+
expect(Object.keys(componentSchema(dl)).length).toBe(14);
|
|
287
357
|
expect('direction' in componentSchema(dl)).toBe(true);
|
|
288
358
|
expect('color' in componentSchema(dl)).toBe(true);
|
|
289
359
|
expect('cascadeCount' in componentSchema(dl)).toBe(true);
|
|
@@ -295,7 +365,7 @@ function spawnValidatedLight(
|
|
|
295
365
|
expect('shadowDistance' in componentSchema(dl)).toBe(true);
|
|
296
366
|
expect('nearPlane' in componentSchema(dl)).toBe(false);
|
|
297
367
|
expect('farPlane' in componentSchema(dl)).toBe(false);
|
|
298
|
-
expect('pcfKernelSize' in componentSchema(dl)).toBe(
|
|
368
|
+
expect('pcfKernelSize' in componentSchema(dl)).toBe(false);
|
|
299
369
|
// DirectionalLightShadow is deleted; the old orthoHalfExtent field is gone
|
|
300
370
|
});
|
|
301
371
|
|
|
@@ -320,7 +390,6 @@ function spawnValidatedLight(
|
|
|
320
390
|
expect(lightData.depthBias).toBeCloseTo(0.005, 5);
|
|
321
391
|
expect(lightData.normalBias).toBeCloseTo(0.05, 5);
|
|
322
392
|
expect(lightData.shadowDistance).toBeCloseTo(200, 5);
|
|
323
|
-
expect(lightData.pcfKernelSize).toBe(3);
|
|
324
393
|
});
|
|
325
394
|
|
|
326
395
|
it('AC-02: spawn with empty data gets all defaults (single-component)', () => {
|
|
@@ -341,7 +410,6 @@ function spawnValidatedLight(
|
|
|
341
410
|
expect(light.depthBias).toBeCloseTo(0.005, 5);
|
|
342
411
|
expect(light.normalBias).toBeCloseTo(0.05, 5);
|
|
343
412
|
expect(light.shadowDistance).toBeCloseTo(200, 5);
|
|
344
|
-
expect(light.pcfKernelSize).toBe(3);
|
|
345
413
|
});
|
|
346
414
|
|
|
347
415
|
it('AC-02: spawn with full explicit data overrides all defaults (single-component)', () => {
|
|
@@ -358,7 +426,7 @@ function spawnValidatedLight(
|
|
|
358
426
|
depthBias: 0.01,
|
|
359
427
|
normalBias: 0.1,
|
|
360
428
|
shadowDistance: 100,
|
|
361
|
-
|
|
429
|
+
shadowFilter: 5,
|
|
362
430
|
},
|
|
363
431
|
});
|
|
364
432
|
expect(r.ok).toBe(true);
|
|
@@ -372,7 +440,7 @@ function spawnValidatedLight(
|
|
|
372
440
|
expect(light.depthBias).toBeCloseTo(0.01, 5);
|
|
373
441
|
expect(light.normalBias).toBeCloseTo(0.1, 5);
|
|
374
442
|
expect(light.shadowDistance).toBeCloseTo(100, 5);
|
|
375
|
-
expect(light.
|
|
443
|
+
expect(light.shadowFilter).toBe(5);
|
|
376
444
|
});
|
|
377
445
|
|
|
378
446
|
it('AC-05: single-component spawn bundles light + shadow fields (no dual-component needed)', () => {
|
|
@@ -1174,7 +1242,7 @@ function spawnValidatedLight(
|
|
|
1174
1242
|
describe('light-buffer-layout.test.ts', () => {
|
|
1175
1243
|
const EPSILON = 1e-6;
|
|
1176
1244
|
|
|
1177
|
-
describe('
|
|
1245
|
+
describe('unified Point direct-light layout', () => {
|
|
1178
1246
|
it('emits 8 floats / 32 bytes byte-for-byte (position + invRangeSquared + color + shadowAtlasLayer)', () => {
|
|
1179
1247
|
const snap: PointLightSnapshot = {
|
|
1180
1248
|
kind: 'point',
|
|
@@ -1184,10 +1252,10 @@ function spawnValidatedLight(
|
|
|
1184
1252
|
intensity: 2,
|
|
1185
1253
|
invRangeSquared: 0.04,
|
|
1186
1254
|
};
|
|
1187
|
-
const out =
|
|
1255
|
+
const out = packDirectLightSlot(snap);
|
|
1188
1256
|
expect(out).toBeInstanceOf(Float32Array);
|
|
1189
|
-
expect(out.length).toBe(
|
|
1190
|
-
expect(out.byteLength).toBe(
|
|
1257
|
+
expect(out.length).toBe(20);
|
|
1258
|
+
expect(out.byteLength).toBe(80);
|
|
1191
1259
|
// Slot 0..2: position vec3.
|
|
1192
1260
|
expect(out[0]).toBeCloseTo(1.5, 6);
|
|
1193
1261
|
expect(out[1]).toBeCloseTo(-2.25, 6);
|
|
@@ -1202,7 +1270,7 @@ function spawnValidatedLight(
|
|
|
1202
1270
|
// Slot 7: shadowAtlasLayer i32; sentinel -1 (0xFFFFFFFF) when omitted.
|
|
1203
1271
|
// Read via Int32Array view to confirm the i32 bits.
|
|
1204
1272
|
const i32 = new Int32Array(out.buffer);
|
|
1205
|
-
expect(i32[
|
|
1273
|
+
expect(i32[17]).toBe(-1);
|
|
1206
1274
|
});
|
|
1207
1275
|
|
|
1208
1276
|
it('shadowAtlasLayer=0 packs as i32 0 in slot 7 (first shadow caster)', () => {
|
|
@@ -1214,9 +1282,9 @@ function spawnValidatedLight(
|
|
|
1214
1282
|
invRangeSquared: 0,
|
|
1215
1283
|
shadowAtlasLayer: 0,
|
|
1216
1284
|
};
|
|
1217
|
-
const out =
|
|
1285
|
+
const out = packDirectLightSlot(snap);
|
|
1218
1286
|
const i32 = new Int32Array(out.buffer);
|
|
1219
|
-
expect(i32[
|
|
1287
|
+
expect(i32[17]).toBe(0);
|
|
1220
1288
|
});
|
|
1221
1289
|
|
|
1222
1290
|
it('shadowAtlasLayer=3 packs as i32 3 in slot 7 (4th / last shadow caster, cap=4)', () => {
|
|
@@ -1228,9 +1296,9 @@ function spawnValidatedLight(
|
|
|
1228
1296
|
invRangeSquared: 0,
|
|
1229
1297
|
shadowAtlasLayer: 3,
|
|
1230
1298
|
};
|
|
1231
|
-
const out =
|
|
1299
|
+
const out = packDirectLightSlot(snap);
|
|
1232
1300
|
const i32 = new Int32Array(out.buffer);
|
|
1233
|
-
expect(i32[
|
|
1301
|
+
expect(i32[17]).toBe(3);
|
|
1234
1302
|
});
|
|
1235
1303
|
|
|
1236
1304
|
it('T-M3-8: 4 shadow lights pack as layers 0/1/2/3 in spawn order', () => {
|
|
@@ -1247,9 +1315,9 @@ function spawnValidatedLight(
|
|
|
1247
1315
|
invRangeSquared: 0.04,
|
|
1248
1316
|
shadowAtlasLayer: layer,
|
|
1249
1317
|
};
|
|
1250
|
-
const out =
|
|
1318
|
+
const out = packDirectLightSlot(snap);
|
|
1251
1319
|
const i32 = new Int32Array(out.buffer);
|
|
1252
|
-
expect(i32[
|
|
1320
|
+
expect(i32[17]).toBe(layer);
|
|
1253
1321
|
// Non-shadow lanes 0..6 stay f32 (slot 7 is the only i32 lane —
|
|
1254
1322
|
// research L1.7 byte offset 28..32 i32 sentinel discriminator).
|
|
1255
1323
|
expect(out[0]).toBeCloseTo(layer, 6);
|
|
@@ -1271,11 +1339,11 @@ function spawnValidatedLight(
|
|
|
1271
1339
|
invRangeSquared: 0,
|
|
1272
1340
|
shadowAtlasLayer: 2,
|
|
1273
1341
|
};
|
|
1274
|
-
const out =
|
|
1275
|
-
// Byte length is
|
|
1276
|
-
expect(out.byteLength).toBe(
|
|
1277
|
-
// i32 view
|
|
1278
|
-
const i32 = new Int32Array(out.buffer,
|
|
1342
|
+
const out = packDirectLightSlot(snap);
|
|
1343
|
+
// Byte length is 80; the metadata row starts at byte offset 64.
|
|
1344
|
+
expect(out.byteLength).toBe(80);
|
|
1345
|
+
// i32 view at byte offset 68 (= metadata shadow word).
|
|
1346
|
+
const i32 = new Int32Array(out.buffer, 68, 1);
|
|
1279
1347
|
expect(i32[0]).toBe(2);
|
|
1280
1348
|
});
|
|
1281
1349
|
|
|
@@ -1287,20 +1355,19 @@ function spawnValidatedLight(
|
|
|
1287
1355
|
intensity: 0,
|
|
1288
1356
|
invRangeSquared: 0,
|
|
1289
1357
|
};
|
|
1290
|
-
const out =
|
|
1358
|
+
const out = packDirectLightSlot(snap);
|
|
1291
1359
|
for (let i = 0; i < 7; i++) expect(out[i]).toBe(0);
|
|
1292
1360
|
// Slot 7 is shadowAtlasLayer sentinel -1 (no longer zero pad).
|
|
1293
1361
|
const i32 = new Int32Array(out.buffer);
|
|
1294
|
-
expect(i32[
|
|
1362
|
+
expect(i32[17]).toBe(-1);
|
|
1295
1363
|
});
|
|
1296
1364
|
});
|
|
1297
1365
|
|
|
1298
|
-
describe('
|
|
1366
|
+
describe('unified Spot direct-light layout', () => {
|
|
1299
1367
|
// feat-20260625-spot-light-shadow-mapping M2 w6 (D-4): SpotLight std430
|
|
1300
|
-
// stride
|
|
1301
|
-
// slots 12..14
|
|
1302
|
-
// (sentinel -1 = unassigned/clipped)
|
|
1303
|
-
// and slots 17..19 are the std430 tail pad.
|
|
1368
|
+
// stride 48 -> 64 (12 -> 16 floats). Slots 0..11 keep the prior layout;
|
|
1369
|
+
// slots 12..14 are vec4-alignment padding; slot 15 is shadowAtlasTile i32
|
|
1370
|
+
// (sentinel -1 = unassigned/clipped) written via an Int32Array view.
|
|
1304
1371
|
function makeSnap(shadowAtlasTile: number): SpotLightSnapshot {
|
|
1305
1372
|
return {
|
|
1306
1373
|
kind: 'spot',
|
|
@@ -1321,7 +1388,7 @@ function spawnValidatedLight(
|
|
|
1321
1388
|
}
|
|
1322
1389
|
|
|
1323
1390
|
it('emits 20 floats / 80 bytes; slots 0..11 unchanged from the 48B layout', () => {
|
|
1324
|
-
const out =
|
|
1391
|
+
const out = packDirectLightSlot(makeSnap(0));
|
|
1325
1392
|
expect(out).toBeInstanceOf(Float32Array);
|
|
1326
1393
|
expect(out.length).toBe(20);
|
|
1327
1394
|
expect(out.byteLength).toBe(80);
|
|
@@ -1343,66 +1410,33 @@ function spawnValidatedLight(
|
|
|
1343
1410
|
expect(out[10]).toBeCloseTo(0.0, 6);
|
|
1344
1411
|
// Slot 11: cosOuter (packed into direction.w lane).
|
|
1345
1412
|
expect(out[11]).toBeCloseTo(0.866, 6);
|
|
1346
|
-
// Slot 12..14:
|
|
1413
|
+
// Slot 12..14: default Spot receiver controls carried by row 3.
|
|
1347
1414
|
expect(out[12]).toBeCloseTo(0.005, 6);
|
|
1348
1415
|
expect(out[13]).toBeCloseTo(0.05, 6);
|
|
1349
|
-
expect(out[14]).
|
|
1350
|
-
// Slot
|
|
1351
|
-
|
|
1352
|
-
expect(out[16]).toBe(1);
|
|
1353
|
-
expect(out[17]).toBe(0);
|
|
1354
|
-
expect(out[18]).toBe(0);
|
|
1355
|
-
expect(out[19]).toBe(0);
|
|
1416
|
+
expect(out[14]).toBeCloseTo(1, 6);
|
|
1417
|
+
// Slot 15: roll angle defaults to zero.
|
|
1418
|
+
expect(out[15]).toBe(0);
|
|
1356
1419
|
});
|
|
1357
1420
|
|
|
1358
1421
|
it('slot 15 carries shadowAtlasTile as i32 (tile=0)', () => {
|
|
1359
|
-
const out =
|
|
1422
|
+
const out = packDirectLightSlot(makeSnap(0));
|
|
1360
1423
|
const i32 = new Int32Array(out.buffer);
|
|
1361
|
-
expect(i32[
|
|
1424
|
+
expect(i32[17]).toBe(0);
|
|
1362
1425
|
});
|
|
1363
1426
|
|
|
1364
1427
|
it('slot 15 carries shadowAtlasTile sentinel -1 (0xFFFFFFFF bit pattern)', () => {
|
|
1365
|
-
const out =
|
|
1428
|
+
const out = packDirectLightSlot(makeSnap(-1));
|
|
1366
1429
|
const i32 = new Int32Array(out.buffer);
|
|
1367
|
-
expect(i32[
|
|
1430
|
+
expect(i32[17]).toBe(-1);
|
|
1368
1431
|
// -1 as i32 is 0xFFFFFFFF: reading the same lane as u32 confirms bits.
|
|
1369
1432
|
const u32 = new Uint32Array(out.buffer);
|
|
1370
|
-
expect(u32[
|
|
1433
|
+
expect(u32[17]).toBe(0xffffffff);
|
|
1371
1434
|
});
|
|
1372
1435
|
|
|
1373
1436
|
it('slot 15 carries a valid tile index (tile=3, last cap slot)', () => {
|
|
1374
|
-
const out =
|
|
1437
|
+
const out = packDirectLightSlot(makeSnap(3));
|
|
1375
1438
|
const i32 = new Int32Array(out.buffer);
|
|
1376
|
-
expect(i32[
|
|
1377
|
-
});
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
describe('packLightArrayHeader - 16B std430 header (M3 w17)', () => {
|
|
1381
|
-
it('emits 16 bytes: count u32 at offset 0; remaining 12 bytes zero-pad to 16B alignment', () => {
|
|
1382
|
-
const buf = packLightArrayHeader(3);
|
|
1383
|
-
expect(buf).toBeInstanceOf(ArrayBuffer);
|
|
1384
|
-
expect(buf.byteLength).toBe(16);
|
|
1385
|
-
const u32 = new Uint32Array(buf);
|
|
1386
|
-
expect(u32[0]).toBe(3);
|
|
1387
|
-
// Slots 1..3 (12B pad) zero-initialised.
|
|
1388
|
-
expect(u32[1]).toBe(0);
|
|
1389
|
-
expect(u32[2]).toBe(0);
|
|
1390
|
-
expect(u32[3]).toBe(0);
|
|
1391
|
-
});
|
|
1392
|
-
|
|
1393
|
-
it('count = 0 emits all-zero 16B header', () => {
|
|
1394
|
-
const buf = packLightArrayHeader(0);
|
|
1395
|
-
const u32 = new Uint32Array(buf);
|
|
1396
|
-
expect(u32[0]).toBe(0);
|
|
1397
|
-
expect(u32[1]).toBe(0);
|
|
1398
|
-
expect(u32[2]).toBe(0);
|
|
1399
|
-
expect(u32[3]).toBe(0);
|
|
1400
|
-
});
|
|
1401
|
-
|
|
1402
|
-
it('count = 4 (first-slice cap maximum) round-trips', () => {
|
|
1403
|
-
const buf = packLightArrayHeader(4);
|
|
1404
|
-
const u32 = new Uint32Array(buf);
|
|
1405
|
-
expect(u32[0]).toBe(4);
|
|
1439
|
+
expect(i32[17]).toBe(3);
|
|
1406
1440
|
});
|
|
1407
1441
|
});
|
|
1408
1442
|
|
|
@@ -1417,7 +1451,7 @@ function spawnValidatedLight(
|
|
|
1417
1451
|
expect(f.byteLength).toBe(48);
|
|
1418
1452
|
});
|
|
1419
1453
|
|
|
1420
|
-
it('
|
|
1454
|
+
it('unified direct slots are stable across two invocations (no shared backing store)', () => {
|
|
1421
1455
|
const snap: PointLightSnapshot = {
|
|
1422
1456
|
kind: 'point',
|
|
1423
1457
|
position: vec3.create(1, 2, 3),
|
|
@@ -1425,8 +1459,8 @@ function spawnValidatedLight(
|
|
|
1425
1459
|
intensity: 1,
|
|
1426
1460
|
invRangeSquared: 0.5,
|
|
1427
1461
|
};
|
|
1428
|
-
const a =
|
|
1429
|
-
const b =
|
|
1462
|
+
const a = packDirectLightSlot(snap);
|
|
1463
|
+
const b = packDirectLightSlot(snap);
|
|
1430
1464
|
expect(a.buffer).not.toBe(b.buffer);
|
|
1431
1465
|
// Compare slots 0..6 as f32 (point/color/invRangeSquared are floats);
|
|
1432
1466
|
// slot 7 is i32 (shadowAtlasLayer; reading as f32 yields NaN by design
|
|
@@ -1498,104 +1532,94 @@ function spawnValidatedLight(
|
|
|
1498
1532
|
{
|
|
1499
1533
|
// ─── from lightslot-layout.test.ts ───
|
|
1500
1534
|
describe('lightslot-layout.test.ts', () => {
|
|
1501
|
-
// ── test:
|
|
1535
|
+
// ── test: unified direct-light slot byte-size lock ─────
|
|
1502
1536
|
|
|
1503
|
-
describe('
|
|
1504
|
-
it('is exactly
|
|
1505
|
-
expect(
|
|
1537
|
+
describe('BYTES_PER_DIRECT_LIGHT_SLOT', () => {
|
|
1538
|
+
it('is exactly 80 (unified ABI lock)', () => {
|
|
1539
|
+
expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
|
|
1506
1540
|
});
|
|
1507
1541
|
});
|
|
1508
1542
|
|
|
1509
|
-
// ── test:
|
|
1543
|
+
// ── test: DirectLightSlot layout byte-size lock ───────────────────────────────
|
|
1510
1544
|
|
|
1511
|
-
describe('
|
|
1512
|
-
it('declares byteSize ===
|
|
1513
|
-
expect(
|
|
1545
|
+
describe('DIRECT_LIGHT_SLOT_LAYOUT byte-size', () => {
|
|
1546
|
+
it('declares byteSize === 80', () => {
|
|
1547
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.byteSize).toBe(80);
|
|
1514
1548
|
});
|
|
1515
1549
|
|
|
1516
|
-
it('declares floatCount ===
|
|
1517
|
-
expect(
|
|
1550
|
+
it('declares floatCount === 20 (80 bytes / 4 bytes per f32)', () => {
|
|
1551
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.floatCount).toBe(20);
|
|
1518
1552
|
});
|
|
1519
1553
|
|
|
1520
|
-
it('declares vec4Count ===
|
|
1521
|
-
expect(
|
|
1554
|
+
it('declares vec4Count === 5 (80 bytes / 16 bytes per vec4)', () => {
|
|
1555
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.vec4Count).toBe(5);
|
|
1522
1556
|
});
|
|
1523
1557
|
});
|
|
1524
1558
|
|
|
1525
|
-
// ── test:
|
|
1559
|
+
// ── test: DirectLightSlot field offsets match the WGSL struct ─────────────────
|
|
1526
1560
|
|
|
1527
|
-
describe('
|
|
1561
|
+
describe('DIRECT_LIGHT_SLOT_LAYOUT field offsets', () => {
|
|
1528
1562
|
it('position at byte 0', () => {
|
|
1529
|
-
expect(
|
|
1563
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.positionOffset).toBe(0);
|
|
1530
1564
|
});
|
|
1531
1565
|
it('invRangeSquared at byte 12 (lane .w of vec4[0])', () => {
|
|
1532
|
-
expect(
|
|
1566
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.rangeOffset).toBe(12);
|
|
1533
1567
|
});
|
|
1534
1568
|
it('color at byte 16', () => {
|
|
1535
|
-
expect(
|
|
1569
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.colorOffset).toBe(16);
|
|
1536
1570
|
});
|
|
1537
1571
|
it('cosInner at byte 28 (lane .w of vec4[1])', () => {
|
|
1538
|
-
expect(
|
|
1572
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.firstAngleOffset).toBe(28);
|
|
1539
1573
|
});
|
|
1540
1574
|
it('direction at byte 32', () => {
|
|
1541
|
-
expect(
|
|
1575
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.primaryAxisOffset).toBe(32);
|
|
1542
1576
|
});
|
|
1543
1577
|
it('cosOuter at byte 44 (lane .w of vec4[2])', () => {
|
|
1544
|
-
expect(
|
|
1578
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.secondAngleOffset).toBe(44);
|
|
1545
1579
|
});
|
|
1546
1580
|
it('kind at byte 48', () => {
|
|
1547
|
-
expect(
|
|
1581
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.auxiliaryAxisOffset).toBe(48);
|
|
1548
1582
|
});
|
|
1549
1583
|
it('pad at byte 52 (3 x u32 = 12 bytes of padding)', () => {
|
|
1550
|
-
expect(
|
|
1551
|
-
});
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
it('shadowAtlasLayer at byte 52 (i32 sentinel; pad lane .y of vec4[3])', () => {
|
|
1555
|
-
// Pad layout reads:
|
|
1556
|
-
// byte 52..56 = shadowAtlasLayer (i32; default sentinel -1)
|
|
1557
|
-
// byte 56..60 = near (f32 bits)
|
|
1558
|
-
// byte 60..64 = far (f32 bits)
|
|
1559
|
-
const buf = new Float32Array(16);
|
|
1584
|
+
expect(DIRECT_LIGHT_SLOT_LAYOUT.metadataByteOffset).toBe(64);
|
|
1585
|
+
});
|
|
1586
|
+
it('metadata starts at byte 64 after four payload rows', () => {
|
|
1587
|
+
const buf = new Float32Array(20);
|
|
1560
1588
|
const i32 = new Int32Array(buf.buffer);
|
|
1561
|
-
|
|
1562
|
-
i32[
|
|
1563
|
-
expect(i32[
|
|
1564
|
-
|
|
1565
|
-
buf[14] = 0.25; // near
|
|
1566
|
-
buf[15] = 50; // far
|
|
1567
|
-
expect(buf[14]).toBeCloseTo(0.25, 5);
|
|
1568
|
-
expect(buf[15]).toBeCloseTo(50, 5);
|
|
1589
|
+
i32[16] = DirectLightSlotKind.SPOT;
|
|
1590
|
+
i32[17] = 3;
|
|
1591
|
+
expect(i32[16]).toBe(DirectLightSlotKind.SPOT);
|
|
1592
|
+
expect(i32[17]).toBe(3);
|
|
1569
1593
|
});
|
|
1570
1594
|
});
|
|
1571
1595
|
|
|
1572
|
-
// ── test: Float32Array(
|
|
1596
|
+
// ── test: Float32Array(20).byteLength === 80 (one DirectLightSlot) ───────────
|
|
1573
1597
|
|
|
1574
|
-
describe('Float32Array representation of one
|
|
1575
|
-
it('Float32Array(
|
|
1576
|
-
const buf = new Float32Array(
|
|
1577
|
-
expect(buf.byteLength).toBe(
|
|
1598
|
+
describe('Float32Array representation of one DirectLightSlot', () => {
|
|
1599
|
+
it('Float32Array(20).byteLength === 80', () => {
|
|
1600
|
+
const buf = new Float32Array(20);
|
|
1601
|
+
expect(buf.byteLength).toBe(80);
|
|
1578
1602
|
});
|
|
1579
1603
|
});
|
|
1580
1604
|
|
|
1581
|
-
// ── test:
|
|
1605
|
+
// ── test: DirectLightSlotKind closed enum (AC-12) ────────────────────────────
|
|
1582
1606
|
|
|
1583
|
-
describe('
|
|
1607
|
+
describe('DirectLightSlotKind closed enum', () => {
|
|
1584
1608
|
it('POINT === 0', () => {
|
|
1585
|
-
expect(
|
|
1609
|
+
expect(DirectLightSlotKind.POINT).toBe(0);
|
|
1586
1610
|
});
|
|
1587
1611
|
it('SPOT === 1', () => {
|
|
1588
|
-
expect(
|
|
1612
|
+
expect(DirectLightSlotKind.SPOT).toBe(1);
|
|
1589
1613
|
});
|
|
1590
1614
|
it('kind values are disjoint (0 vs 1)', () => {
|
|
1591
|
-
expect(
|
|
1615
|
+
expect(DirectLightSlotKind.POINT).not.toBe(DirectLightSlotKind.SPOT);
|
|
1592
1616
|
});
|
|
1593
1617
|
it('only two members exist (0 and 1)', () => {
|
|
1594
|
-
const keys = Object.keys(
|
|
1595
|
-
|
|
1596
|
-
expect(keys.length).toBe(2);
|
|
1618
|
+
const keys = Object.keys(DirectLightSlotKind);
|
|
1619
|
+
expect(keys.length).toBe(3);
|
|
1597
1620
|
expect(keys).toContain('POINT');
|
|
1598
1621
|
expect(keys).toContain('SPOT');
|
|
1622
|
+
expect(keys).toContain('RECT_AREA');
|
|
1599
1623
|
});
|
|
1600
1624
|
});
|
|
1601
1625
|
});
|
|
@@ -1848,7 +1872,7 @@ function spawnValidatedLight(
|
|
|
1848
1872
|
expect(view.depthBias).toBeCloseTo(0.005, 5);
|
|
1849
1873
|
expect(view.normalBias).toBeCloseTo(0.05, 5);
|
|
1850
1874
|
expect(view.shadowDistance).toBeCloseTo(200, 5);
|
|
1851
|
-
expect(view.
|
|
1875
|
+
expect(view.shadowFilter).toBe(2);
|
|
1852
1876
|
});
|
|
1853
1877
|
|
|
1854
1878
|
it('spawn with direction-only data gets all 8 shadow defaults', () => {
|
|
@@ -1864,7 +1888,7 @@ function spawnValidatedLight(
|
|
|
1864
1888
|
expect(view.depthBias).toBeCloseTo(0.005, 5);
|
|
1865
1889
|
expect(view.normalBias).toBeCloseTo(0.05, 5);
|
|
1866
1890
|
expect(view.shadowDistance).toBeCloseTo(200, 5);
|
|
1867
|
-
expect(view.
|
|
1891
|
+
expect(view.shadowFilter).toBe(2);
|
|
1868
1892
|
});
|
|
1869
1893
|
|
|
1870
1894
|
it('spawn with full explicit shadow data overrides all 9 defaults', () => {
|
|
@@ -1881,7 +1905,7 @@ function spawnValidatedLight(
|
|
|
1881
1905
|
depthBias: 0.01,
|
|
1882
1906
|
normalBias: 0.1,
|
|
1883
1907
|
shadowDistance: 100,
|
|
1884
|
-
|
|
1908
|
+
shadowFilter: 5,
|
|
1885
1909
|
},
|
|
1886
1910
|
})
|
|
1887
1911
|
.unwrap();
|
|
@@ -1893,11 +1917,11 @@ function spawnValidatedLight(
|
|
|
1893
1917
|
expect(view.depthBias).toBeCloseTo(0.01, 5);
|
|
1894
1918
|
expect(view.normalBias).toBeCloseTo(0.1, 5);
|
|
1895
1919
|
expect(view.shadowDistance).toBeCloseTo(100, 5);
|
|
1896
|
-
expect(view.
|
|
1920
|
+
expect(view.shadowFilter).toBe(5);
|
|
1897
1921
|
});
|
|
1898
1922
|
|
|
1899
|
-
it('schema has
|
|
1900
|
-
expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(
|
|
1923
|
+
it('schema has 14 fields (3 light + 1 castShadow + 10 shadow-quality)', () => {
|
|
1924
|
+
expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(14);
|
|
1901
1925
|
expect('direction' in componentSchema(DirectionalLight)).toBe(true);
|
|
1902
1926
|
expect('color' in componentSchema(DirectionalLight)).toBe(true);
|
|
1903
1927
|
expect('castShadow' in componentSchema(DirectionalLight)).toBe(true);
|
|
@@ -1910,7 +1934,7 @@ function spawnValidatedLight(
|
|
|
1910
1934
|
expect('shadowDistance' in componentSchema(DirectionalLight)).toBe(true);
|
|
1911
1935
|
expect('nearPlane' in componentSchema(DirectionalLight)).toBe(false);
|
|
1912
1936
|
expect('farPlane' in componentSchema(DirectionalLight)).toBe(false);
|
|
1913
|
-
expect('pcfKernelSize' in componentSchema(DirectionalLight)).toBe(
|
|
1937
|
+
expect('pcfKernelSize' in componentSchema(DirectionalLight)).toBe(false);
|
|
1914
1938
|
});
|
|
1915
1939
|
});
|
|
1916
1940
|
});
|
|
@@ -1920,79 +1944,6 @@ function spawnValidatedLight(
|
|
|
1920
1944
|
// ─── from feat-20260621-merge-directionallightshadow-into-directionallight M1-t2 ───
|
|
1921
1945
|
describe('feat-20260621-merge-directionallightshadow-into-directionallight M1-t2', () => {
|
|
1922
1946
|
describe('DirectionalLight.validate() shadow field enforcement', () => {
|
|
1923
|
-
it('rejects even pcfKernelSize (2) with ShadowInvalidConfigError', () => {
|
|
1924
|
-
const world = new World();
|
|
1925
|
-
const r = spawnValidatedLight(world, DirectionalLight, {
|
|
1926
|
-
direction: [0, -1, 0],
|
|
1927
|
-
pcfKernelSize: 2,
|
|
1928
|
-
});
|
|
1929
|
-
expect(r.ok).toBe(false);
|
|
1930
|
-
if (r.ok) throw new Error('expected spawn to fail validation');
|
|
1931
|
-
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
1932
|
-
expect(err.code).toBe('shadow-invalid-config');
|
|
1933
|
-
expect(err.detail.field).toBe('pcfKernelSize');
|
|
1934
|
-
expect(err.detail.value).toBe(2);
|
|
1935
|
-
expect(err.detail.min).toBe(1);
|
|
1936
|
-
// The hint must name the odd constraint so an AI retry does not loop
|
|
1937
|
-
// 2 -> 3 wrong-way (4 -> 6); verify-step AI-user finding F-6.
|
|
1938
|
-
expect(err.hint).toBe('set pcfKernelSize to an odd integer >= 1; got 2');
|
|
1939
|
-
});
|
|
1940
|
-
|
|
1941
|
-
it('rejects pcfKernelSize < 1 (0) with ShadowInvalidConfigError', () => {
|
|
1942
|
-
const world = new World();
|
|
1943
|
-
const r = spawnValidatedLight(world, DirectionalLight, {
|
|
1944
|
-
direction: [0, -1, 0],
|
|
1945
|
-
pcfKernelSize: 0,
|
|
1946
|
-
});
|
|
1947
|
-
expect(r.ok).toBe(false);
|
|
1948
|
-
if (r.ok) throw new Error('expected spawn to fail validation');
|
|
1949
|
-
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
1950
|
-
expect(err.code).toBe('shadow-invalid-config');
|
|
1951
|
-
expect(err.detail.field).toBe('pcfKernelSize');
|
|
1952
|
-
expect(err.detail.value).toBe(0);
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
it('rejects pcfKernelSize < 1 (-1) with ShadowInvalidConfigError', () => {
|
|
1956
|
-
const world = new World();
|
|
1957
|
-
const r = spawnValidatedLight(world, DirectionalLight, {
|
|
1958
|
-
direction: [0, -1, 0],
|
|
1959
|
-
pcfKernelSize: -1,
|
|
1960
|
-
});
|
|
1961
|
-
expect(r.ok).toBe(false);
|
|
1962
|
-
if (r.ok) throw new Error('expected spawn to fail validation');
|
|
1963
|
-
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
1964
|
-
expect(err.code).toBe('shadow-invalid-config');
|
|
1965
|
-
expect(err.detail.field).toBe('pcfKernelSize');
|
|
1966
|
-
expect(err.detail.value).toBe(-1);
|
|
1967
|
-
});
|
|
1968
|
-
|
|
1969
|
-
it('accepts pcfKernelSize=1 (smallest valid odd integer)', () => {
|
|
1970
|
-
const world = new World();
|
|
1971
|
-
const r = world.spawn({
|
|
1972
|
-
component: DirectionalLight,
|
|
1973
|
-
data: { direction: [0, -1, 0], pcfKernelSize: 1 },
|
|
1974
|
-
});
|
|
1975
|
-
expect(r.ok).toBe(true);
|
|
1976
|
-
});
|
|
1977
|
-
|
|
1978
|
-
it('accepts pcfKernelSize=3 (default odd integer)', () => {
|
|
1979
|
-
const world = new World();
|
|
1980
|
-
const r = world.spawn({
|
|
1981
|
-
component: DirectionalLight,
|
|
1982
|
-
data: { direction: [0, -1, 0], pcfKernelSize: 3 },
|
|
1983
|
-
});
|
|
1984
|
-
expect(r.ok).toBe(true);
|
|
1985
|
-
});
|
|
1986
|
-
|
|
1987
|
-
it('accepts pcfKernelSize=7 (larger odd integer)', () => {
|
|
1988
|
-
const world = new World();
|
|
1989
|
-
const r = world.spawn({
|
|
1990
|
-
component: DirectionalLight,
|
|
1991
|
-
data: { direction: [0, -1, 0], pcfKernelSize: 7 },
|
|
1992
|
-
});
|
|
1993
|
-
expect(r.ok).toBe(true);
|
|
1994
|
-
});
|
|
1995
|
-
|
|
1996
1947
|
it('rejects mapSize < 1 with ShadowInvalidConfigError', () => {
|
|
1997
1948
|
const world = new World();
|
|
1998
1949
|
const r = spawnValidatedLight(world, DirectionalLight, {
|
|
@@ -2030,7 +1981,7 @@ function spawnValidatedLight(
|
|
|
2030
1981
|
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
2031
1982
|
expect(err.code).toBe('shadow-invalid-config');
|
|
2032
1983
|
expect(err.detail.field).toBe('cascadeCount');
|
|
2033
|
-
expect(err.detail.
|
|
1984
|
+
expect(err.detail.actual).toBe(5);
|
|
2034
1985
|
});
|
|
2035
1986
|
|
|
2036
1987
|
it('rejects non-integer cascadeCount (1.5) with ShadowInvalidConfigError', () => {
|
|
@@ -2141,24 +2092,6 @@ function spawnValidatedLight(
|
|
|
2141
2092
|
// ─── from feat-20260621-merge-directionallightshadow-into-directionallight M1-t3 ───
|
|
2142
2093
|
describe('feat-20260621-merge-directionallightshadow-into-directionallight M1-t3', () => {
|
|
2143
2094
|
describe('DirectionalLight.validate() skips shadow validation when castShadow=false', () => {
|
|
2144
|
-
it('castShadow=false tolerates even pcfKernelSize (2) because unused', () => {
|
|
2145
|
-
const world = new World();
|
|
2146
|
-
const r = world.spawn({
|
|
2147
|
-
component: DirectionalLight,
|
|
2148
|
-
data: { direction: [0, -1, 0], castShadow: false, pcfKernelSize: 2 },
|
|
2149
|
-
});
|
|
2150
|
-
expect(r.ok).toBe(true);
|
|
2151
|
-
});
|
|
2152
|
-
|
|
2153
|
-
it('castShadow=false tolerates pcfKernelSize=0 because unused', () => {
|
|
2154
|
-
const world = new World();
|
|
2155
|
-
const r = world.spawn({
|
|
2156
|
-
component: DirectionalLight,
|
|
2157
|
-
data: { direction: [0, -1, 0], castShadow: false, pcfKernelSize: 0 },
|
|
2158
|
-
});
|
|
2159
|
-
expect(r.ok).toBe(true);
|
|
2160
|
-
});
|
|
2161
|
-
|
|
2162
2095
|
it('castShadow=false tolerates mapSize=0 because unused', () => {
|
|
2163
2096
|
const world = new World();
|
|
2164
2097
|
const r = world.spawn({
|
|
@@ -2194,33 +2127,6 @@ function spawnValidatedLight(
|
|
|
2194
2127
|
});
|
|
2195
2128
|
expect(r.ok).toBe(true);
|
|
2196
2129
|
});
|
|
2197
|
-
|
|
2198
|
-
it('castShadow=true (explicit) still enforces pcfKernelSize odd>=1', () => {
|
|
2199
|
-
const world = new World();
|
|
2200
|
-
const r = spawnValidatedLight(world, DirectionalLight, {
|
|
2201
|
-
direction: [0, -1, 0],
|
|
2202
|
-
castShadow: true,
|
|
2203
|
-
pcfKernelSize: 2,
|
|
2204
|
-
});
|
|
2205
|
-
expect(r.ok).toBe(false);
|
|
2206
|
-
if (r.ok) throw new Error('expected spawn to fail validation');
|
|
2207
|
-
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
2208
|
-
expect(err.code).toBe('shadow-invalid-config');
|
|
2209
|
-
expect(err.detail.field).toBe('pcfKernelSize');
|
|
2210
|
-
});
|
|
2211
|
-
|
|
2212
|
-
it('omitted castShadow (treated as default true) still enforces pcfKernelSize odd>=1', () => {
|
|
2213
|
-
const world = new World();
|
|
2214
|
-
const r = spawnValidatedLight(world, DirectionalLight, {
|
|
2215
|
-
direction: [0, -1, 0],
|
|
2216
|
-
pcfKernelSize: 2,
|
|
2217
|
-
});
|
|
2218
|
-
expect(r.ok).toBe(false);
|
|
2219
|
-
if (r.ok) throw new Error('expected spawn to fail validation');
|
|
2220
|
-
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
2221
|
-
expect(err.code).toBe('shadow-invalid-config');
|
|
2222
|
-
expect(err.detail.field).toBe('pcfKernelSize');
|
|
2223
|
-
});
|
|
2224
2130
|
});
|
|
2225
2131
|
});
|
|
2226
2132
|
|
|
@@ -2245,7 +2151,11 @@ function spawnValidatedLight(
|
|
|
2245
2151
|
|
|
2246
2152
|
describe('binding 8 = spotShadowMap (D-5 always-on shadow entry)', () => {
|
|
2247
2153
|
it('declares binding 8 as a depth 2D texture, FRAGMENT-only', () => {
|
|
2248
|
-
const entries = buildPbrViewBglEntries({
|
|
2154
|
+
const entries = buildPbrViewBglEntries({
|
|
2155
|
+
storageBuffer: true,
|
|
2156
|
+
extendedLighting: false,
|
|
2157
|
+
projectorAvailable: false,
|
|
2158
|
+
});
|
|
2249
2159
|
const b8 = entries.find((e) => e.binding === 8);
|
|
2250
2160
|
expect(b8).toBeDefined();
|
|
2251
2161
|
if (b8 === undefined) throw new Error('binding 8 missing from view BGL');
|
|
@@ -2255,14 +2165,17 @@ function spawnValidatedLight(
|
|
|
2255
2165
|
});
|
|
2256
2166
|
|
|
2257
2167
|
it('declares binding 8 regardless of storageBuffer caps (always-on, no gate)', () => {
|
|
2258
|
-
const withStorage = buildPbrViewBglEntries({
|
|
2259
|
-
|
|
2168
|
+
const withStorage = buildPbrViewBglEntries({
|
|
2169
|
+
storageBuffer: true,
|
|
2170
|
+
extendedLighting: false,
|
|
2171
|
+
});
|
|
2172
|
+
const noStorage = buildPbrViewBglEntries({ storageBuffer: false, extendedLighting: false });
|
|
2260
2173
|
expect(withStorage.some((e) => e.binding === 8)).toBe(true);
|
|
2261
2174
|
expect(noStorage.some((e) => e.binding === 8)).toBe(true);
|
|
2262
2175
|
});
|
|
2263
2176
|
|
|
2264
2177
|
it('reserves binding 9 and keeps Points/Lines at binding 10', () => {
|
|
2265
|
-
const entries = buildPbrViewBglEntries({ storageBuffer: true });
|
|
2178
|
+
const entries = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false });
|
|
2266
2179
|
expect(entries.some((e) => e.binding === 9)).toBe(false);
|
|
2267
2180
|
const pointsLines = entries.find((e) => e.binding === 10);
|
|
2268
2181
|
expect(pointsLines?.buffer?.type).toBe('uniform');
|
|
@@ -2288,97 +2201,98 @@ function spawnValidatedLight(
|
|
|
2288
2201
|
// in its tail), binding 6 (point shadowParams), binding 7
|
|
2289
2202
|
// (shadowCasterCascade), and binding 10 (Points/Lines view). No
|
|
2290
2203
|
// standalone spot-matrix uniform buffer exists.
|
|
2291
|
-
const entries = buildPbrViewBglEntries({ storageBuffer: true });
|
|
2204
|
+
const entries = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false });
|
|
2292
2205
|
const uniformBufferBindings = entries
|
|
2293
2206
|
.filter((e) => e.buffer?.type === 'uniform')
|
|
2294
2207
|
.map((e) => e.binding)
|
|
2295
2208
|
.sort((a, b) => a - b);
|
|
2296
|
-
//
|
|
2209
|
+
// Local lights are not in the view group; only the view, shadow
|
|
2210
|
+
// params, cascade selector, and vertex-only Points/Lines UBO remain.
|
|
2297
2211
|
expect(uniformBufferBindings).toEqual([0, 6, 7, 10]);
|
|
2298
2212
|
});
|
|
2299
2213
|
|
|
2300
2214
|
it('storageBuffer=false: WebGL2 fallback uniform-buffer bindings stay within budget', () => {
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
|
|
2305
|
-
// 2 (spotLights), 6 (shadowParams), 7 (shadowCasterCascade) = 5. There is
|
|
2306
|
-
// NO standalone spot-matrix uniform buffer (the 12th that overflowed).
|
|
2307
|
-
const entries = buildPbrViewBglEntries({ storageBuffer: false });
|
|
2215
|
+
// The no-storage shape keeps local lights out of the view group. The
|
|
2216
|
+
// remaining fragment uniform buffers are view (0), point shadow
|
|
2217
|
+
// params (6), and cascade selector (7).
|
|
2218
|
+
const entries = buildPbrViewBglEntries({ storageBuffer: false, extendedLighting: false });
|
|
2308
2219
|
const fragmentUniformBuffers = entries.filter(
|
|
2309
2220
|
(e) => e.buffer?.type === 'uniform' && (e.visibility & VISIBILITY_FRAGMENT) !== 0,
|
|
2310
2221
|
);
|
|
2311
|
-
|
|
2312
|
-
expect(fragmentUniformBuffers.length).toBe(5);
|
|
2222
|
+
expect(fragmentUniformBuffers.length).toBe(3);
|
|
2313
2223
|
// No binding 9 (the folded-away standalone spot-matrix UBO).
|
|
2314
2224
|
expect(entries.some((e) => e.binding === 9)).toBe(false);
|
|
2315
2225
|
});
|
|
2316
2226
|
});
|
|
2317
2227
|
|
|
2318
|
-
describe('sampler reuse (binding 4)
|
|
2319
|
-
it('keeps binding 4 as the comparison sampler
|
|
2320
|
-
const entries = buildPbrViewBglEntries({
|
|
2228
|
+
describe('sampler reuse (binding 4) — spot adds no new sampler (D-5)', () => {
|
|
2229
|
+
it('keeps binding 4 as the single comparison sampler (shared by directional/point/spot)', () => {
|
|
2230
|
+
const entries = buildPbrViewBglEntries({
|
|
2231
|
+
storageBuffer: true,
|
|
2232
|
+
extendedLighting: false,
|
|
2233
|
+
projectorAvailable: false,
|
|
2234
|
+
});
|
|
2321
2235
|
const samplers = entries.filter((e) => e.sampler !== undefined);
|
|
2322
|
-
expect(samplers).toHaveLength(
|
|
2323
|
-
expect(samplers
|
|
2324
|
-
expect(samplers
|
|
2325
|
-
});
|
|
2326
|
-
|
|
2327
|
-
it('omits the projector filtering sampler on the 16-texture capability path', () => {
|
|
2328
|
-
const entries = buildPbrViewBglEntries({ storageBuffer: true, projectorAvailable: false });
|
|
2329
|
-
expect(entries.find((entry) => entry.binding === 11)).toBeUndefined();
|
|
2330
|
-
expect(entries.find((entry) => entry.binding === 12)).toBeUndefined();
|
|
2331
|
-
expect(entries.find((entry) => entry.binding === 4)?.sampler?.type).toBe('comparison');
|
|
2236
|
+
expect(samplers).toHaveLength(1);
|
|
2237
|
+
expect(samplers[0]?.binding).toBe(4);
|
|
2238
|
+
expect(samplers[0]?.sampler?.type).toBe('comparison');
|
|
2332
2239
|
});
|
|
2333
2240
|
});
|
|
2334
2241
|
|
|
2335
2242
|
describe('bindings 3/4/5/6/7 unchanged (AC-08 no-regress)', () => {
|
|
2336
2243
|
it('binding 3 = directional depth 2D, VERTEX|FRAGMENT', () => {
|
|
2337
|
-
const e = buildPbrViewBglEntries({ storageBuffer: true }).find(
|
|
2244
|
+
const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
|
|
2245
|
+
(x) => x.binding === 3,
|
|
2246
|
+
);
|
|
2338
2247
|
expect(e?.texture?.sampleType).toBe('depth');
|
|
2339
2248
|
expect(e?.texture?.viewDimension).toBe('2d');
|
|
2340
2249
|
expect(e?.visibility).toBe(VISIBILITY_VERTEX_FRAGMENT);
|
|
2341
2250
|
});
|
|
2342
2251
|
|
|
2343
2252
|
it('binding 4 = comparison sampler, FRAGMENT', () => {
|
|
2344
|
-
const e = buildPbrViewBglEntries({ storageBuffer: true }).find(
|
|
2253
|
+
const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
|
|
2254
|
+
(x) => x.binding === 4,
|
|
2255
|
+
);
|
|
2345
2256
|
expect(e?.sampler?.type).toBe('comparison');
|
|
2346
2257
|
expect(e?.visibility).toBe(VISIBILITY_FRAGMENT);
|
|
2347
2258
|
});
|
|
2348
2259
|
|
|
2349
2260
|
it('binding 5 = point cube-array depth, FRAGMENT', () => {
|
|
2350
|
-
const e = buildPbrViewBglEntries({ storageBuffer: true }).find(
|
|
2261
|
+
const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
|
|
2262
|
+
(x) => x.binding === 5,
|
|
2263
|
+
);
|
|
2351
2264
|
expect(e?.texture?.sampleType).toBe('depth');
|
|
2352
2265
|
expect(e?.texture?.viewDimension).toBe('cube-array');
|
|
2353
2266
|
expect(e?.visibility).toBe(VISIBILITY_FRAGMENT);
|
|
2354
2267
|
});
|
|
2355
2268
|
|
|
2356
2269
|
it('binding 6 = point shadow params UBO, FRAGMENT', () => {
|
|
2357
|
-
const e = buildPbrViewBglEntries({ storageBuffer: true }).find(
|
|
2270
|
+
const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
|
|
2271
|
+
(x) => x.binding === 6,
|
|
2272
|
+
);
|
|
2358
2273
|
expect(e?.buffer?.type).toBe('uniform');
|
|
2359
2274
|
expect(e?.visibility).toBe(VISIBILITY_FRAGMENT);
|
|
2360
2275
|
});
|
|
2361
2276
|
|
|
2362
2277
|
it('binding 7 = shadowCasterCascade UBO, VERTEX|FRAGMENT', () => {
|
|
2363
|
-
const e = buildPbrViewBglEntries({ storageBuffer: true }).find(
|
|
2278
|
+
const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
|
|
2279
|
+
(x) => x.binding === 7,
|
|
2280
|
+
);
|
|
2364
2281
|
expect(e?.buffer?.type).toBe('uniform');
|
|
2365
2282
|
expect(e?.visibility).toBe(VISIBILITY_VERTEX_FRAGMENT);
|
|
2366
2283
|
});
|
|
2367
2284
|
});
|
|
2368
2285
|
|
|
2369
|
-
describe('
|
|
2370
|
-
it('exposes bindings
|
|
2371
|
-
const bindings = buildPbrViewBglEntries({
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
it('drops only optional projector bindings on the low sampled-texture path', () => {
|
|
2378
|
-
const bindings = buildPbrViewBglEntries({ storageBuffer: true, projectorAvailable: false })
|
|
2286
|
+
describe('base binding roster keeps Points/Lines at 10', () => {
|
|
2287
|
+
it('exposes camera/shadow bindings plus binding 10', () => {
|
|
2288
|
+
const bindings = buildPbrViewBglEntries({
|
|
2289
|
+
storageBuffer: true,
|
|
2290
|
+
extendedLighting: false,
|
|
2291
|
+
projectorAvailable: false,
|
|
2292
|
+
})
|
|
2379
2293
|
.map((e) => e.binding)
|
|
2380
2294
|
.sort((a, b) => a - b);
|
|
2381
|
-
expect(bindings).toEqual([0,
|
|
2295
|
+
expect(bindings).toEqual([0, 3, 4, 5, 6, 7, 8, 10]);
|
|
2382
2296
|
});
|
|
2383
2297
|
});
|
|
2384
2298
|
});
|
|
@@ -2401,7 +2315,8 @@ function spawnValidatedLight(
|
|
|
2401
2315
|
const err = r.error as unknown as ShadowInvalidConfigError;
|
|
2402
2316
|
expect(err.code).toBe('shadow-invalid-config');
|
|
2403
2317
|
expect(err.detail.field).toBe('pcfKernelSize');
|
|
2404
|
-
expect(err.detail.
|
|
2318
|
+
expect(err.detail.actual).toBe(2);
|
|
2319
|
+
expect(err.detail.bound).toEqual({ kind: 'lower-bound', operator: '>=', value: 1 });
|
|
2405
2320
|
expect(err.hint).toBe('set pcfKernelSize to an odd integer >= 1; got 2');
|
|
2406
2321
|
});
|
|
2407
2322
|
|