@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
@@ -1,8 +1,8 @@
1
1
  // shadow-fields-observable.dawn.test.ts
2
2
  // feat-20260621-merge-directionallightshadow-into-directionallight M6
3
3
  //
4
- // Observability gate: proves depthBias, normalBias, pcfKernelSize --
5
- // three merged DirectionalLight shadow fields -- produce real pixel A/B diffs.
4
+ // Observability gate: proves depthBias, normalBias, and the directional filter
5
+ // carrier -- three merged DirectionalLight shadow fields -- produce real pixel A/B diffs.
6
6
  //
7
7
  // Root cause of prior "0 diff everywhere" failure: (a) materials without
8
8
  // ShadowCaster pass (nothing written into shadow depth atlas), (b) floor
@@ -29,11 +29,18 @@ import { validateDirectionalLightData } from '../../../render/src/components/lig
29
29
  import { constructRuntimeRendererHost } from '../renderer-host';
30
30
  import { drawPublished } from './draw-published';
31
31
 
32
- const WIDTH = 256;
33
- const HEIGHT = 256;
32
+ const LIGHTWEIGHT_DAWN = process.env.FORGEAX_DAWN_LIGHTWEIGHT === '1';
33
+ // The CI observability lane only needs enough settled frames to prove the
34
+ // field-owned pixel/UBO deltas. Keep the full 300-frame, 256x256 window for
35
+ // local and nightly runs, while the isolated PR process uses a bounded 60-frame
36
+ // 128x128 target to avoid retaining a large lavapipe command backlog.
37
+ const SHADOW_FIELDS_RENDER_FRAMES = LIGHTWEIGHT_DAWN ? 60 : 300;
38
+ const WIDTH = LIGHTWEIGHT_DAWN ? 128 : 256;
39
+ const HEIGHT = LIGHTWEIGHT_DAWN ? 128 : 256;
34
40
  const BPR = Math.ceil((WIDTH * 4) / 256) * 256;
35
41
 
36
- // Each falsifier performs four fresh 256x256 Dawn readbacks. Windows' Dawn
42
+ // Each falsifier performs four fresh Dawn readbacks at the active target size
43
+ // (256x256 by default, 128x128 in the lightweight CI lane). Windows' Dawn
37
44
  // prebuild can exceed Vitest's 90-second bound for that sequence while still
38
45
  // completing well inside the nightly platform job's 45-minute budget.
39
46
  const SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS = 300_000;
@@ -44,6 +51,59 @@ const ENGINE_MANIFEST = await (async () => {
44
51
  })();
45
52
  const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(JSON.stringify(ENGINE_MANIFEST))}`;
46
53
 
54
+ describe('M3 Dawn candidate admission', () => {
55
+ it('requires accepted production candidate to preserve the requested PCSS profile', async () => {
56
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
57
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
58
+ expect(typeof resolver).toBe('function');
59
+ const result = (
60
+ resolver as (input: {
61
+ backendKind: 'webgpu';
62
+ requested: 'pcssMedium' | 'pcssHigh';
63
+ candidate: 'accepted' | 'failed';
64
+ }) => { effective: string; status: string; pixelEvidence: string }
65
+ )({ backendKind: 'webgpu', requested: 'pcssHigh', candidate: 'accepted' });
66
+ expect(result).toMatchObject({
67
+ effective: 'pcssHigh',
68
+ status: 'accepted',
69
+ pixelEvidence: 'available',
70
+ });
71
+ });
72
+
73
+ it('requires the same bounded Directional inspection projection used by Dawn', async () => {
74
+ const module = (await import(
75
+ '../../../render/src/assembly/directional-shadow-inspection'
76
+ )) as Record<string, unknown>;
77
+ const project = module.projectDirectionalShadowInspection;
78
+ expect(typeof project).toBe('function');
79
+ const result = (project as (input: Record<string, unknown>) => Record<string, unknown>)({
80
+ admission: {
81
+ requested: 'pcssMedium',
82
+ effective: 'pcssMedium',
83
+ status: 'accepted',
84
+ pixelEvidence: 'available',
85
+ },
86
+ cascadeCount: 4,
87
+ mapSize: 1024,
88
+ atlasBytes: 16_777_216,
89
+ writerPasses: 4,
90
+ blockerTaps: 8,
91
+ filterTapUpperBound: 16,
92
+ seamTapUpperBound: 48,
93
+ deviceGeneration: 1,
94
+ graphGeneration: 3,
95
+ });
96
+ expect(result).toMatchObject({
97
+ requested: 'pcssMedium',
98
+ effective: 'pcssMedium',
99
+ blockerTaps: 8,
100
+ filterTapUpperBound: 16,
101
+ seamTapUpperBound: 48,
102
+ });
103
+ expect(Object.keys(result)).not.toContain('perPixel');
104
+ });
105
+ });
106
+
47
107
  // Helper types
48
108
  interface WriteCapture {
49
109
  f32: Float32Array;
@@ -177,7 +237,9 @@ function spawnScene(
177
237
  depthBias,
178
238
  normalBias,
179
239
  shadowDistance: 50,
180
- pcfKernelSize: pcf,
240
+ shadowFilter: pcf === 1 ? 1 : pcf === 3 ? 2 : 3,
241
+ shadowAngularRadius: 0.00465,
242
+ maxPenumbraTexels: 32,
181
243
  },
182
244
  });
183
245
  world.spawn(
@@ -300,9 +362,13 @@ async function renderConfig(
300
362
  const w = new World();
301
363
  spawnScene(w, castShadow, depthBias, normalBias, pcf, mapSize);
302
364
  let de = 0;
303
- for (let i = 0; i < 300; i++) {
365
+ for (let i = 0; i < SHADOW_FIELDS_RENDER_FRAMES; i++) {
304
366
  const r = drawPublished(renderer, w);
305
367
  if (!r.ok) de++;
368
+ // Keep the native Dawn carrier paced like a real frame loop. Without a
369
+ // bounded drain, a long shadow-map leg can retain an unbounded lavapipe
370
+ // command/resource backlog before the final fence below.
371
+ if (i % 16 === 15) await dev.queue.onSubmittedWorkDone();
306
372
  }
307
373
  if (de > 0) throw new Error(`draw errors: ${de}`);
308
374
  // rt is created lazily by the canvas configure()/getCurrentTexture path during
@@ -437,15 +503,15 @@ async function renderConfigWithSpy(
437
503
 
438
504
  describe('M6 shadow fields observability', () => {
439
505
  // --- Gate A: structural WGSL check ---
440
- it('composed default-standard-pbr WGSL reads view.depthBias, view.normalBias, view.pcfKernelSize', () => {
506
+ it('composed default-standard-pbr WGSL reads view.depthBias, view.normalBias, and directionalShadowFilter', () => {
441
507
  const wgsl = composedStandardPbr();
442
508
  expect(wgsl).toContain('depthBias');
443
509
  expect(wgsl).toContain('normalBias');
444
- expect(wgsl).toContain('pcfKernelSize');
510
+ expect(wgsl).toContain('directionalShadowFilter');
445
511
  });
446
512
 
447
- // --- Gate B: UBO spy — slots [126]=depthBias, [127]=normalBias, [128]=pcfKernelSize ---
448
- it('View UBO slots [126]=depthBias, [127]=normalBias, [128]=pcfKernelSize', async () => {
513
+ // --- Gate B: UBO spy — slots [126]=depthBias, [127]=normalBias, [128]=filter profile ---
514
+ it('View UBO slots [126]=depthBias, [127]=normalBias, [128]=filter profile', async () => {
449
515
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
450
516
  const { captured } = await renderConfigWithSpy(true, 0.123, 0.456, 3);
451
517
  const vw = captured.filter((c) => c.f32.length >= 148);
@@ -455,22 +521,22 @@ describe('M6 shadow fields observability', () => {
455
521
  const f32 = last.f32;
456
522
  expect(Math.abs((f32[126] ?? Number.NaN) - 0.123)).toBeLessThan(0.001);
457
523
  expect(Math.abs((f32[127] ?? Number.NaN) - 0.456)).toBeLessThan(0.001);
458
- expect(f32[128]).toBe(3);
524
+ expect(f32[128]).toBe(2);
459
525
  }, 60000);
460
526
 
461
- // --- Gate D: pcfKernelSize clamp (9 -> 5; cap matches WGSL MAX_PCF_HALF=2) ---
462
- it('pcfKernelSize=9 clamped to 5 in View UBO[128]', async () => {
527
+ // --- Gate D: pcf5 maps to the accepted profile carrier ---
528
+ it('pcf5 maps to profile 3 in View UBO[128]', async () => {
463
529
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
464
- const { captured } = await renderConfigWithSpy(true, 0.005, 0.05, 9);
530
+ const { captured } = await renderConfigWithSpy(true, 0.005, 0.05, 5);
465
531
  const vw9 = captured.filter((c) => c.f32.length >= 148);
466
532
  expect(vw9.length).toBeGreaterThan(0);
467
533
  const last9 = vw9[vw9.length - 1];
468
534
  if (last9 === undefined) throw new Error('no view UBO write captured');
469
- expect(last9.f32[128]).toBe(5);
535
+ expect(last9.f32[128]).toBe(3);
470
536
  }, 60000);
471
537
 
472
- // --- Gate D: validate() rejections ---
473
- it('validate() accepts pcfKernelSize=9 (odd), rejects 2 (even) and 0', () => {
538
+ // --- Gate D: Directional filter validation ---
539
+ it('validate() accepts pcf5 and rejects an unknown directional filter', () => {
474
540
  const valid = validateDirectionalLightData({
475
541
  direction: [0, -1, 0],
476
542
  castShadow: true,
@@ -479,7 +545,7 @@ describe('M6 shadow fields observability', () => {
479
545
  cascadeBlend: 0.2,
480
546
  mapSize: 2048,
481
547
  shadowDistance: 50,
482
- pcfKernelSize: 9,
548
+ shadowFilter: 3,
483
549
  });
484
550
  expect(valid.ok).toBe(true);
485
551
 
@@ -491,7 +557,7 @@ describe('M6 shadow fields observability', () => {
491
557
  cascadeBlend: 0.2,
492
558
  mapSize: 2048,
493
559
  shadowDistance: 50,
494
- pcfKernelSize: 2,
560
+ shadowFilter: 99,
495
561
  });
496
562
  expect(even.ok).toBe(false);
497
563
  if (!even.ok) expect(even.error.code).toBe('shadow-invalid-config');
@@ -504,7 +570,7 @@ describe('M6 shadow fields observability', () => {
504
570
  cascadeBlend: 0.2,
505
571
  mapSize: 2048,
506
572
  shadowDistance: 50,
507
- pcfKernelSize: 0,
573
+ shadowFilter: 0,
508
574
  });
509
575
  expect(zero.ok).toBe(false);
510
576
  if (!zero.ok) expect(zero.error.code).toBe('shadow-invalid-config');
@@ -519,6 +585,16 @@ describe('M6 shadow fields observability', () => {
519
585
  expect(d.diff).toBeGreaterThan(0);
520
586
  }, 60000);
521
587
 
588
+ it('castShadow=false emits a zero directional cascade count in the View UBO', async () => {
589
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
590
+ const { captured } = await renderConfigWithSpy(false, 0.005, 0.05, 3);
591
+ const viewWrites = captured.filter((capture) => capture.f32.length >= 148);
592
+ expect(viewWrites.length).toBeGreaterThan(0);
593
+ const last = viewWrites[viewWrites.length - 1];
594
+ if (last === undefined) throw new Error('no view UBO write captured');
595
+ expect(last.f32[124]).toBe(0);
596
+ }, 60000);
597
+
522
598
  // --- Gate C: pixel A/B — depthBias 0.0 vs 0.5 ---
523
599
  it('pixel A/B: depthBias 0.0 vs 0.5', async () => {
524
600
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
@@ -537,8 +613,8 @@ describe('M6 shadow fields observability', () => {
537
613
  expect(d.diff).toBeGreaterThan(0);
538
614
  }, 60000);
539
615
 
540
- // --- Gate C: pixel A/B — pcfKernelSize 1 vs 5 (cap; MAX_PCF_HALF=2) ---
541
- it('pixel A/B: pcfKernelSize 1 vs 5', async () => {
616
+ // --- Gate C: pixel A/B — directional filter profile 1 vs 3 ---
617
+ it('pixel A/B: directional filter profile 1 vs 3', async () => {
542
618
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
543
619
  const pxA = await renderConfig(true, 0.005, 0.05, 1);
544
620
  const pxB = await renderConfig(true, 0.005, 0.05, 5);
@@ -624,7 +700,7 @@ describe('M6 shadow fields observability', () => {
624
700
  );
625
701
 
626
702
  it(
627
- 'falsification: pcfKernelSize varied differs, held does not',
703
+ 'falsification: directional filter varied differs, held does not',
628
704
  async () => {
629
705
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
630
706
  const varied = diff(
@@ -15,7 +15,7 @@ const extractPath = (() => {
15
15
  describe('_getArrayView count (AC-03 gate)', () => {
16
16
  it('keeps skin and instance hot fields on column views', () => {
17
17
  const src = readFileSync(extractPath, 'utf8');
18
- expect(src).toContain("_getArrayView(jointEntity, Transform, 'world')");
18
+ expect(src).toContain("_getArrayView(jointEntity, GlobalTransform, 'world')");
19
19
  expect(src).toContain("_getArrayView(entity, Instances, 'transforms')");
20
20
  expect(src).toContain("_getArrayView(entity, SpriteInstances, 'transforms')");
21
21
  expect(src).toContain("_getArrayView(entity, SpriteInstances, 'regions')");
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url';
34
34
  import { World } from '@forgeax/engine-ecs';
35
35
  import type { Mat4, Vec3 } from '@forgeax/engine-math';
36
36
  import { mat4, vec3 } from '@forgeax/engine-math';
37
- import { ChildOf, propagateTransforms, Transform } from '@forgeax/engine-scene';
37
+ import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '@forgeax/engine-scene';
38
38
  import { Skin } from '@forgeax/engine-skinning';
39
39
  import { describe, expect, it } from 'vitest';
40
40
 
@@ -125,13 +125,13 @@ describe('skin parented double transform (AC-01)', () => {
125
125
  const r = propagateTransforms(world);
126
126
  expect(r.ok).toBe(true);
127
127
 
128
- // Read Transform.world for skin entity and joint entity.
129
- const st = world.get(skinEntity, Transform);
128
+ // Read derived GlobalTransform.world for skin entity and joint entity.
129
+ const st = world.get(skinEntity, GlobalTransform);
130
130
  expect(st.ok).toBe(true);
131
131
  if (!st.ok) return;
132
132
  const meshWorldMat = mat4FromFloat32Array(st.value.world as unknown as Float32Array);
133
133
 
134
- const jt = world.get(jointEntity, Transform);
134
+ const jt = world.get(jointEntity, GlobalTransform);
135
135
  expect(jt.ok).toBe(true);
136
136
  if (!jt.ok) return;
137
137
  const jointWorldMat = mat4FromFloat32Array(jt.value.world as unknown as Float32Array);
@@ -202,7 +202,7 @@ describe('skin parented double transform (AC-01)', () => {
202
202
  const r = propagateTransforms(world);
203
203
  expect(r.ok).toBe(true);
204
204
 
205
- const jt = world.get(jointEntity, Transform);
205
+ const jt = world.get(jointEntity, GlobalTransform);
206
206
  expect(jt.ok).toBe(true);
207
207
  if (!jt.ok) return;
208
208
  const jointWorldMat = mat4FromFloat32Array(jt.value.world as unknown as Float32Array);
@@ -203,7 +203,9 @@ function spawnMixedScene(world: World, castShadow: boolean): void {
203
203
  depthBias: 0.005,
204
204
  normalBias: 0.05,
205
205
  shadowDistance: 50,
206
- pcfKernelSize: 3,
206
+ shadowFilter: 2,
207
+ shadowAngularRadius: 0.00465,
208
+ maxPenumbraTexels: 32,
207
209
  },
208
210
  });
209
211
 
@@ -216,12 +218,7 @@ function spawnMixedScene(world: World, castShadow: boolean): void {
216
218
  };
217
219
  // Entity order is intentional: static A -> skinned B -> static C.
218
220
  spawnStaticCaster(-3);
219
- const joint = world
220
- .spawn({
221
- component: Transform,
222
- data: identityTransform([0, 7, 5]),
223
- })
224
- .unwrap();
221
+ const joint = world.spawn({ component: Transform, data: identityTransform([0, 7, 5]) }).unwrap();
225
222
  world.spawn(
226
223
  { component: Transform, data: identityTransform() },
227
224
  { component: MeshFilter, data: { assetHandle: skinMesh } },
@@ -260,10 +260,10 @@ describe('driveLazyEquirectProjection — lazy projection state machine (M3 / w2
260
260
  const world = new World();
261
261
  const handle = catalogEquirect(world, equirectPod());
262
262
  const reg = new RhiErrorListenerRegistry();
263
- const fired: Array<{ code: string; handle: number | undefined }> = [];
263
+ const fired: Array<{ code: string; handle: number | undefined; hint?: string }> = [];
264
264
  reg.add((e) => {
265
265
  if (e.code === 'equirect-projection-failed') {
266
- fired.push({ code: e.code, handle: e.detail.handle });
266
+ fired.push({ code: e.code, handle: e.detail.handle, hint: e.hint });
267
267
  } else {
268
268
  fired.push({ code: e.code, handle: undefined });
269
269
  }
@@ -281,9 +281,12 @@ describe('driveLazyEquirectProjection — lazy projection state machine (M3 / w2
281
281
 
282
282
  // Frame 2: status:'failed' observed -> fire the structured error ONCE.
283
283
  driveLazyEquirectProjection(internals, world, frameState, handle as unknown as number);
284
- expect(fired).toEqual([
285
- { code: 'equirect-projection-failed', handle: handle as unknown as number },
286
- ]);
284
+ expect(fired).toHaveLength(1);
285
+ expect(fired[0]).toMatchObject({
286
+ code: 'equirect-projection-failed',
287
+ handle: handle as unknown as number,
288
+ });
289
+ expect(fired[0]?.hint).toContain('declare Skylight');
287
290
 
288
291
  // Frame 3+: the latch keeps the channel quiet -- no re-fire, no retry
289
292
  // (the store's status:'failed' short-circuit + the frameState latch).
@@ -46,22 +46,22 @@ function makeSpec(shaderId: string): PipelineSpec {
46
46
  }
47
47
 
48
48
  describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
49
- describe('pbr-view BGL (light buffers; binding 1+2)', () => {
50
- it('sprite-lit vs sprite share the same 12-entry pbr-view BGL under storage-buffer caps', () => {
49
+ describe('pbr-view BGL (Cluster lights live in group(2))', () => {
50
+ it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under storage-buffer caps', () => {
51
51
  const spriteSpec = makeSpec('forgeax::sprite');
52
52
  const spriteLitSpec = makeSpec('forgeax::sprite-lit');
53
53
  const sprite = buildBindGroupLayoutDescriptor(spriteSpec, {
54
54
  kind: 'pbr-view',
55
- caps: { storageBuffer: true },
55
+ caps: { storageBuffer: true, extendedLighting: false },
56
56
  });
57
57
  const spriteLit = buildBindGroupLayoutDescriptor(spriteLitSpec, {
58
58
  kind: 'pbr-view',
59
- caps: { storageBuffer: true },
59
+ caps: { storageBuffer: true, extendedLighting: false },
60
60
  });
61
61
  expect(JSON.stringify(spriteLit)).toBe(JSON.stringify(sprite));
62
62
  // binding 10 is the shared Points/Lines view UBO; bindings 11/12 are the
63
63
  // optional SpotLight projector texture and sampler on capable devices.
64
- expect(spriteLit.entries.length).toBe(12);
64
+ expect(spriteLit.entries.length).toBe(10);
65
65
  });
66
66
 
67
67
  it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under uniform fallback caps (AC-10)', () => {
@@ -69,25 +69,23 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
69
69
  const spriteLitSpec = makeSpec('forgeax::sprite-lit');
70
70
  const sprite = buildBindGroupLayoutDescriptor(spriteSpec, {
71
71
  kind: 'pbr-view',
72
- caps: { storageBuffer: false },
72
+ caps: { storageBuffer: false, extendedLighting: false },
73
73
  });
74
74
  const spriteLit = buildBindGroupLayoutDescriptor(spriteLitSpec, {
75
75
  kind: 'pbr-view',
76
- caps: { storageBuffer: false },
76
+ caps: { storageBuffer: false, extendedLighting: false },
77
77
  });
78
78
  expect(JSON.stringify(spriteLit)).toBe(JSON.stringify(sprite));
79
79
  });
80
80
 
81
- it('binding 1 + 2 are read-only-storage on storageBuffer=true (point/spot light buffers)', () => {
81
+ it('does not expose point/spot light bindings on storageBuffer=true', () => {
82
82
  const entries = buildPbrViewBglEntries({ storageBuffer: true });
83
- expect(entries[1]?.buffer?.type).toBe('read-only-storage');
84
- expect(entries[2]?.buffer?.type).toBe('read-only-storage');
83
+ expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
85
84
  });
86
85
 
87
- it('binding 1 + 2 fall back to uniform on storageBuffer=false (AC-10 fallback)', () => {
86
+ it('does not expose point/spot light bindings on storageBuffer=false', () => {
88
87
  const entries = buildPbrViewBglEntries({ storageBuffer: false });
89
- expect(entries[1]?.buffer?.type).toBe('uniform');
90
- expect(entries[2]?.buffer?.type).toBe('uniform');
88
+ expect(entries.some((entry) => entry.binding === 1 || entry.binding === 2)).toBe(false);
91
89
  });
92
90
  });
93
91
 
@@ -39,6 +39,7 @@ import {
39
39
  getOrCreateSsaoFallbackTexture,
40
40
  packClusterUniform,
41
41
  } from '../../../render/src/hdrp-buffers';
42
+ import { BYTES_PER_DIRECT_LIGHT_SLOT } from '../../../render/src/light-buffer-layout';
42
43
  import { createHdrpBindGroupLayoutDescriptor } from '../../../render/src/pipeline-spec';
43
44
  import type { RenderSystemRuntime } from '../../../render/src/render-system';
44
45
 
@@ -131,9 +132,9 @@ function makeMockRuntime(capsOverride: Partial<RhiCaps> = {}): {
131
132
 
132
133
  function fakeHdrpBuffers(): HdrpBuffers {
133
134
  return {
134
- storageBuffer: true,
135
+ device: {} as HdrpBuffers['device'],
135
136
  lightDataBuffer: mockBuffer('hdrp-light-data'),
136
- lightDataBytes: 16384,
137
+ lightDataBytes: 256 * BYTES_PER_DIRECT_LIGHT_SLOT,
137
138
  clusterGridBuffer: mockBuffer('hdrp-cluster-grid'),
138
139
  clusterGridBytes: 1024,
139
140
  lightIndexListBuffer: mockBuffer('hdrp-light-index-list'),
@@ -150,15 +151,9 @@ function fakeHdrpBuffers(): HdrpBuffers {
150
151
  }
151
152
 
152
153
  describe('HDRP GPU membership producer bind-group contract', () => {
153
- it('preserves the 256-light storage count while keeping the 128-light uniform default', () => {
154
- const uniformPayload = new Uint32Array(
155
- packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256),
156
- );
157
- const storagePayload = new Uint32Array(
158
- packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256, 256),
159
- );
160
- expect(uniformPayload[3]).toBe(128);
161
- expect(storagePayload[3]).toBe(256);
154
+ it('preserves the full 256-light Standard count in the cluster UBO', () => {
155
+ const payload = new Uint32Array(packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256));
156
+ expect(payload[3]).toBe(256);
162
157
  });
163
158
 
164
159
  it('keeps enabled SSAO intensity in the zero-punctual-light uniform path', () => {
@@ -209,12 +204,12 @@ describe('HDRP GPU membership producer bind-group contract', () => {
209
204
 
210
205
  describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
211
206
  it('descriptor entries.length === 7 (5 cluster + 2 ssao)', () => {
212
- const desc = createHdrpBindGroupLayoutDescriptor(true);
207
+ const desc = createHdrpBindGroupLayoutDescriptor();
213
208
  expect(desc.entries?.length).toBe(7);
214
209
  });
215
210
 
216
211
  it('binding 7 is texture_2d<f32> with FRAGMENT visibility (ssaoBlurred)', () => {
217
- const desc = createHdrpBindGroupLayoutDescriptor(true);
212
+ const desc = createHdrpBindGroupLayoutDescriptor();
218
213
  const b7 = desc.entries?.find((e) => e.binding === 7);
219
214
  expect(b7).toBeDefined();
220
215
  expect(b7?.visibility).toBe(FRAGMENT_VISIBILITY);
@@ -223,7 +218,7 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
223
218
  });
224
219
 
225
220
  it('binding 8 is sampler with FRAGMENT visibility (ssaoSampler)', () => {
226
- const desc = createHdrpBindGroupLayoutDescriptor(true);
221
+ const desc = createHdrpBindGroupLayoutDescriptor();
227
222
  const b8 = desc.entries?.find((e) => e.binding === 8);
228
223
  expect(b8).toBeDefined();
229
224
  expect(b8?.visibility).toBe(FRAGMENT_VISIBILITY);
@@ -231,13 +226,13 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
231
226
  });
232
227
 
233
228
  it('binding 9 is absent (scope-amend-webgl2-ubo: intensity folded into binding 6)', () => {
234
- const desc = createHdrpBindGroupLayoutDescriptor(true);
229
+ const desc = createHdrpBindGroupLayoutDescriptor();
235
230
  const bindings = desc.entries?.map((e) => e.binding) ?? [];
236
231
  expect(bindings).not.toContain(9);
237
232
  });
238
233
 
239
234
  it('existing bindings 0/3/4/5/6 remain unchanged', () => {
240
- const desc = createHdrpBindGroupLayoutDescriptor(true);
235
+ const desc = createHdrpBindGroupLayoutDescriptor();
241
236
  const b0 = desc.entries?.find((e) => e.binding === 0);
242
237
  expect(b0?.buffer?.hasDynamicOffset).toBe(true);
243
238
  for (const b of [3, 4, 5, 6]) {
@@ -247,7 +242,7 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
247
242
  });
248
243
 
249
244
  it('all bindings exactly { 0, 3, 4, 5, 6, 7, 8 }', () => {
250
- const desc = createHdrpBindGroupLayoutDescriptor(true);
245
+ const desc = createHdrpBindGroupLayoutDescriptor();
251
246
  const bindings = new Set(desc.entries?.map((e) => e.binding));
252
247
  expect(bindings).toEqual(new Set([0, 3, 4, 5, 6, 7, 8]));
253
248
  });
@@ -63,7 +63,14 @@ interface SpyCtx {
63
63
  }
64
64
 
65
65
  function makeDispatchSpyCtx(
66
- opts: { ssaoCalcPipeline?: unknown; ssaoBlurPipeline?: unknown; ssaoBgl?: unknown } = {},
66
+ opts: {
67
+ ssaoCalcPipeline?: unknown;
68
+ ssaoBlurPipeline?: unknown;
69
+ ssaoBgl?: unknown;
70
+ fxaaPipeline?: unknown;
71
+ fxaaBindGroupLayout?: unknown;
72
+ fxaaSampler?: unknown;
73
+ } = {},
67
74
  ): SpyCtx {
68
75
  const spy: DispatchSpy = {
69
76
  beginRenderPassCalls: [],
@@ -138,10 +145,12 @@ function makeDispatchSpyCtx(
138
145
  removeEventListener: vi.fn(),
139
146
  };
140
147
 
148
+ const fxaaParamsBuffer = { __label: 'fxaa-params' };
141
149
  const runtime = {
142
150
  device,
143
151
  errorRegistry,
144
152
  shaderCache: { get: vi.fn() },
153
+ getPostProcessParamsBuffer: vi.fn(() => fxaaParamsBuffer),
145
154
  } as unknown as RenderSystemRuntime;
146
155
 
147
156
  // Pre-seed ssao-buffers cache so the record closure resolves a stable
@@ -162,6 +171,13 @@ function makeDispatchSpyCtx(
162
171
  ssaoFilteringSampler: null,
163
172
  ssaoDepthSampler: null,
164
173
  ssaoFallbackRawView: null,
174
+ // FXAA is part of the same Standard post chain. Keep the fixture's
175
+ // fullscreen resources complete so recordAll exercises the real FXAA
176
+ // bind-group path instead of returning early at its capability gate.
177
+ fxaaPipeline: 'fxaaPipeline' in opts ? opts.fxaaPipeline : { __label: 'fxaa-pipeline' },
178
+ fxaaBindGroupLayout:
179
+ 'fxaaBindGroupLayout' in opts ? opts.fxaaBindGroupLayout : { __label: 'fxaa-bgl' },
180
+ fxaaSampler: 'fxaaSampler' in opts ? opts.fxaaSampler : { __label: 'fxaa-sampler' },
165
181
  },
166
182
  };
167
183
 
@@ -215,7 +231,6 @@ function makeDispatchSpyCtx(
215
231
  meshBindGroup: null,
216
232
  frameState: {
217
233
  perFrameGraph: mockPerFrameGraph,
218
- isHdrpActive: true,
219
234
  installedPipelineConfig: { ssao: { enabled: true, intensity: 1.0 } },
220
235
  // Post-process bind group identity cache (bloom / fxaa / ssao). Keyed on
221
236
  // the graph-resolved views so resize rebuilds automatically.
@@ -673,6 +688,7 @@ function makePostProcessRecordCtx(): {
673
688
  draw: vi.fn(),
674
689
  end: vi.fn(),
675
690
  };
691
+ const fxaaParamsBuffer = { label: 'fxaa-params' };
676
692
  const device = {
677
693
  createBindGroup: vi.fn((desc: PostProcessBindGroupCreate) => {
678
694
  bindGroupCreates.push(desc);
@@ -711,6 +727,7 @@ function makePostProcessRecordCtx(): {
711
727
  runtime: {
712
728
  device: { ...device, caps: { storageBuffer: true } },
713
729
  errorRegistry: { fire: vi.fn() },
730
+ getPostProcessParamsBuffer: vi.fn(() => fxaaParamsBuffer),
714
731
  },
715
732
  store: { getCubemapGpuView: vi.fn() },
716
733
  encoder: {