@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.
Files changed (86) hide show
  1. package/README.md +105 -3
  2. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  5. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  7. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  9. package/dist/createRenderer.d.ts.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.mjs +4 -2
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/sprite-param-values.d.ts +1 -1
  14. package/package.json +30 -30
  15. package/src/__tests__/active-camera.unit.test.ts +6 -8
  16. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  17. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  18. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  19. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  20. package/src/__tests__/asset.unit.test.ts +5 -2
  21. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  22. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  23. package/src/__tests__/components.test-d.ts +19 -9
  24. package/src/__tests__/components.unit.test.ts +36 -26
  25. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  26. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  27. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  28. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  29. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  30. package/src/__tests__/errors.unit.test.ts +54 -20
  31. package/src/__tests__/extract-frames-merge.test.ts +51 -40
  32. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  33. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  34. package/src/__tests__/geometry.unit.test.ts +7 -10
  35. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  36. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  37. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +10 -11
  38. package/src/__tests__/light-snapshot.test-d.ts +35 -1
  39. package/src/__tests__/lights-preservation.fixup.test.ts +125 -0
  40. package/src/__tests__/lights.unit.test.ts +223 -308
  41. package/src/__tests__/loader-name-survival.test.ts +8 -9
  42. package/src/__tests__/package-promotion.test.ts +7 -11
  43. package/src/__tests__/pbr-pipeline.unit.test.ts +22 -17
  44. package/src/__tests__/pipeline.unit.test.ts +68 -226
  45. package/src/__tests__/point-light-shadow.browser.test.ts +209 -83
  46. package/src/__tests__/point-light-shadow.unit.test.ts +24 -47
  47. package/src/__tests__/propagate-transforms.dawn.test.ts +15 -16
  48. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  49. package/src/__tests__/render-error-exhaustive.test-d.ts +73 -9
  50. package/src/__tests__/render-system-extract.test.ts +5 -5
  51. package/src/__tests__/render-system-mega.test.ts +30 -41
  52. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +5 -1
  53. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -1
  54. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  55. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  56. package/src/__tests__/renderer-surface.unit.test.ts +229 -5
  57. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  58. package/src/__tests__/resolveName.test.ts +14 -2
  59. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  60. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  61. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  62. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  63. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  64. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  65. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  66. package/src/__tests__/shadow-csm-ubo.test.ts +43 -45
  67. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  68. package/src/__tests__/shadow-fields-observable.dawn.test.ts +100 -24
  69. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +1 -1
  70. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  71. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +4 -7
  72. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  73. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -13
  74. package/src/__tests__/ssao-bgl.test.ts +12 -17
  75. package/src/__tests__/ssao-passes.test.ts +19 -2
  76. package/src/__tests__/systems.unit.test.ts +69 -205
  77. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  78. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  79. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  80. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  81. package/src/collect-scene-asset.ts +1 -1
  82. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  83. package/src/createRenderer.ts +2 -0
  84. package/src/index.ts +1 -2
  85. package/src/sprite-param-values.ts +1 -1
  86. 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
- BYTES_PER_LIGHT_SLOT,
36
- LIGHTSLOT_LAYOUT,
37
- LightSlotKind,
38
- packLightArrayHeader,
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.pcfKernelSize).toBe(3);
350
+ expect(defaults.shadowFilter).toBe(2);
281
351
 
282
352
  // Merged component: 3 light (direction + color arrays + intensity) +
283
- // 1 castShadow + 8 shadow = 12 fields (feat-20260709 M2 collapsed
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(12);
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(true);
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
- pcfKernelSize: 5,
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.pcfKernelSize).toBe(5);
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('packPointLight - std430 32B layout (M3 w17 + feat-20260612 M1 T-M1-8)', () => {
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 = packPointLight(snap);
1255
+ const out = packDirectLightSlot(snap);
1188
1256
  expect(out).toBeInstanceOf(Float32Array);
1189
- expect(out.length).toBe(8);
1190
- expect(out.byteLength).toBe(32);
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[7]).toBe(-1);
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 = packPointLight(snap);
1285
+ const out = packDirectLightSlot(snap);
1218
1286
  const i32 = new Int32Array(out.buffer);
1219
- expect(i32[7]).toBe(0);
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 = packPointLight(snap);
1299
+ const out = packDirectLightSlot(snap);
1232
1300
  const i32 = new Int32Array(out.buffer);
1233
- expect(i32[7]).toBe(3);
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 = packPointLight(snap);
1318
+ const out = packDirectLightSlot(snap);
1251
1319
  const i32 = new Int32Array(out.buffer);
1252
- expect(i32[7]).toBe(layer);
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 = packPointLight(snap);
1275
- // Byte length is 32; the backing buffer is exactly the 8 f32 lanes.
1276
- expect(out.byteLength).toBe(32);
1277
- // i32 view starting at byte offset 28 (= slot 7 * 4 B / lane).
1278
- const i32 = new Int32Array(out.buffer, 28, 1);
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 = packPointLight(snap);
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[7]).toBe(-1);
1362
+ expect(i32[17]).toBe(-1);
1295
1363
  });
1296
1364
  });
1297
1365
 
1298
- describe('packSpotLight - std430 80B layout (feat-20260625 M2 w6)', () => {
1366
+ describe('unified Spot direct-light layout', () => {
1299
1367
  // feat-20260625-spot-light-shadow-mapping M2 w6 (D-4): SpotLight std430
1300
- // stride 64 -> 80 (16 -> 20 floats). Slots 0..11 keep the prior layout;
1301
- // slots 12..14 carry receiver controls, slot 15 is shadowAtlasTile i32
1302
- // (sentinel -1 = unassigned/clipped), slot 16 carries shadowIntensity,
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 = packSpotLight(makeSnap(0));
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: shared receiver controls.
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]).toBe(3);
1350
- // Slot 16 carries the authored shadow visibility factor; the tail is
1351
- // explicit zero padding for the 80-byte std430 array stride.
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 = packSpotLight(makeSnap(0));
1422
+ const out = packDirectLightSlot(makeSnap(0));
1360
1423
  const i32 = new Int32Array(out.buffer);
1361
- expect(i32[15]).toBe(0);
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 = packSpotLight(makeSnap(-1));
1428
+ const out = packDirectLightSlot(makeSnap(-1));
1366
1429
  const i32 = new Int32Array(out.buffer);
1367
- expect(i32[15]).toBe(-1);
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[15]).toBe(0xffffffff);
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 = packSpotLight(makeSnap(3));
1437
+ const out = packDirectLightSlot(makeSnap(3));
1375
1438
  const i32 = new Int32Array(out.buffer);
1376
- expect(i32[15]).toBe(3);
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('packPointLight slots are stable across two invocations (no shared backing store)', () => {
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 = packPointLight(snap);
1429
- const b = packPointLight(snap);
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: BYTES_PER_LIGHT_SLOT === 64 absolute-value lock (AC-11 TS side) ─────
1535
+ // ── test: unified direct-light slot byte-size lock ─────
1502
1536
 
1503
- describe('BYTES_PER_LIGHT_SLOT', () => {
1504
- it('is exactly 64 (AC-11 absolute-value lock)', () => {
1505
- expect(BYTES_PER_LIGHT_SLOT).toBe(64);
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: LightSlotLayout byte-size lock ──────────────────────────────────────
1543
+ // ── test: DirectLightSlot layout byte-size lock ───────────────────────────────
1510
1544
 
1511
- describe('LIGHTSLOT_LAYOUT byte-size', () => {
1512
- it('declares byteSize === 64', () => {
1513
- expect(LIGHTSLOT_LAYOUT.byteSize).toBe(64);
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 === 16 (64 bytes / 4 bytes per f32)', () => {
1517
- expect(LIGHTSLOT_LAYOUT.floatCount).toBe(16);
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 === 4 (64 bytes / 16 bytes per vec4)', () => {
1521
- expect(LIGHTSLOT_LAYOUT.vec4Count).toBe(4);
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: LightSlotLayout field offsets match WGSL LightSlot struct ────────────
1559
+ // ── test: DirectLightSlot field offsets match the WGSL struct ─────────────────
1526
1560
 
1527
- describe('LIGHTSLOT_LAYOUT field offsets', () => {
1561
+ describe('DIRECT_LIGHT_SLOT_LAYOUT field offsets', () => {
1528
1562
  it('position at byte 0', () => {
1529
- expect(LIGHTSLOT_LAYOUT.positionOffset).toBe(0);
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(LIGHTSLOT_LAYOUT.invRangeSquaredOffset).toBe(12);
1566
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.rangeOffset).toBe(12);
1533
1567
  });
1534
1568
  it('color at byte 16', () => {
1535
- expect(LIGHTSLOT_LAYOUT.colorOffset).toBe(16);
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(LIGHTSLOT_LAYOUT.cosInnerOffset).toBe(28);
1572
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.firstAngleOffset).toBe(28);
1539
1573
  });
1540
1574
  it('direction at byte 32', () => {
1541
- expect(LIGHTSLOT_LAYOUT.directionOffset).toBe(32);
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(LIGHTSLOT_LAYOUT.cosOuterOffset).toBe(44);
1578
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.secondAngleOffset).toBe(44);
1545
1579
  });
1546
1580
  it('kind at byte 48', () => {
1547
- expect(LIGHTSLOT_LAYOUT.kindOffset).toBe(48);
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(LIGHTSLOT_LAYOUT.shadowPayloadOffset).toBe(52);
1551
- });
1552
- // feat-20260612-point-light-shadows-urp-hdrp M4 / T-M4-4 (plan-strategy §D-8):
1553
- // pad lanes carry the per-light shadow triple on the HDRP path.
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
- // Place a non-default value to verify byte mapping.
1562
- i32[13] = 7;
1563
- expect(i32[13]).toBe(7);
1564
- // Float lane access on the same backing buffer is unambiguous.
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(16).byteLength === 64 (one LightSlot strided) ──────────
1596
+ // ── test: Float32Array(20).byteLength === 80 (one DirectLightSlot) ───────────
1573
1597
 
1574
- describe('Float32Array representation of one LightSlot', () => {
1575
- it('Float32Array(16).byteLength === 64', () => {
1576
- const buf = new Float32Array(16);
1577
- expect(buf.byteLength).toBe(64);
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: LightSlotKind closed enum (AC-12) ───────────────────────────────────
1605
+ // ── test: DirectLightSlotKind closed enum (AC-12) ────────────────────────────
1582
1606
 
1583
- describe('LightSlotKind closed enum', () => {
1607
+ describe('DirectLightSlotKind closed enum', () => {
1584
1608
  it('POINT === 0', () => {
1585
- expect(LightSlotKind.POINT).toBe(0);
1609
+ expect(DirectLightSlotKind.POINT).toBe(0);
1586
1610
  });
1587
1611
  it('SPOT === 1', () => {
1588
- expect(LightSlotKind.SPOT).toBe(1);
1612
+ expect(DirectLightSlotKind.SPOT).toBe(1);
1589
1613
  });
1590
1614
  it('kind values are disjoint (0 vs 1)', () => {
1591
- expect(LightSlotKind.POINT).not.toBe(LightSlotKind.SPOT);
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(LightSlotKind);
1595
- // LightSlotKind has 2 const keys + TypeScript adds nothing else
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.pcfKernelSize).toBe(3);
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.pcfKernelSize).toBe(3);
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
- pcfKernelSize: 5,
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.pcfKernelSize).toBe(5);
1920
+ expect(view.shadowFilter).toBe(5);
1897
1921
  });
1898
1922
 
1899
- it('schema has 12 fields (3 light + 1 castShadow + 8 shadow)', () => {
1900
- expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(12);
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(true);
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.value).toBe(5);
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({ storageBuffer: true });
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({ storageBuffer: true });
2259
- const noStorage = buildPbrViewBglEntries({ storageBuffer: false });
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
- // storageBuffer=true: bindings 1+2 are read-only-storage (not uniform).
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
- // On the WebGL2 fallback path bindings 1+2 (point/spot light arrays)
2302
- // become uniform buffers. The fragment-stage uniform-buffer count must
2303
- // stay <= 11 (GLES 3.0 max_uniform_buffers_per_shader_stage). View BGL
2304
- // fragment uniform buffers after w25: binding 0 (view), 1 (pointLights),
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
- // 5 view-BG fragment uniform buffers (was 6 before the fold).
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) plus optional projector filtering sampler', () => {
2319
- it('keeps binding 4 as the comparison sampler and binding 12 as the projector filtering sampler', () => {
2320
- const entries = buildPbrViewBglEntries({ storageBuffer: true });
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(2);
2323
- expect(samplers.find((entry) => entry.binding === 4)?.sampler?.type).toBe('comparison');
2324
- expect(samplers.find((entry) => entry.binding === 12)?.sampler?.type).toBe('filtering');
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((x) => x.binding === 3);
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((x) => x.binding === 4);
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((x) => x.binding === 5);
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((x) => x.binding === 6);
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((x) => x.binding === 7);
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('full binding roster reserves 9 for Points/Lines at 10', () => {
2370
- it('exposes bindings 0..8 plus optional projector 11+12 and binding 10', () => {
2371
- const bindings = buildPbrViewBglEntries({ storageBuffer: true })
2372
- .map((e) => e.binding)
2373
- .sort((a, b) => a - b);
2374
- expect(bindings).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12]);
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, 1, 2, 3, 4, 5, 6, 7, 8, 10]);
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.value).toBe(2);
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