@forgeax/engine-runtime 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +102 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts +2 -0
  4. package/dist/__tests__/create-renderer-gpu-pass-timing.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/lights-preservation.fixup.test.d.ts +2 -0
  6. package/dist/__tests__/lights-preservation.fixup.test.d.ts.map +1 -0
  7. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  8. package/dist/createRenderer.d.ts.map +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.mjs +2 -1
  11. package/dist/index.mjs.map +1 -1
  12. package/package.json +30 -30
  13. package/src/__tests__/active-camera.unit.test.ts +6 -8
  14. package/src/__tests__/asset-registry.recursive.spec.ts +1 -1
  15. package/src/__tests__/clamp-to-last.e2e.browser.test.ts +2 -2
  16. package/src/__tests__/components.test-d.ts +10 -3
  17. package/src/__tests__/components.unit.test.ts +13 -7
  18. package/src/__tests__/create-renderer-assembly.integration.test.ts +24 -0
  19. package/src/__tests__/create-renderer-gpu-pass-timing.unit.test.ts +32 -0
  20. package/src/__tests__/createRenderer.browser-no-webgpu.test.ts +66 -0
  21. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +9 -1
  22. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +15 -2
  23. package/src/__tests__/errors.unit.test.ts +51 -20
  24. package/src/__tests__/extract-frames-merge.test.ts +48 -40
  25. package/src/__tests__/extract-skin-mismatch.unit.test.ts +6 -2
  26. package/src/__tests__/frame-targets-shadow-regression.unit.test.ts +13 -0
  27. package/src/__tests__/geometry.unit.test.ts +7 -10
  28. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +2 -1
  29. package/src/__tests__/light-snapshot.test-d.ts +8 -0
  30. package/src/__tests__/lights-preservation.fixup.test.ts +127 -0
  31. package/src/__tests__/lights.unit.test.ts +75 -2235
  32. package/src/__tests__/pbr-pipeline.unit.test.ts +6 -1
  33. package/src/__tests__/pipeline.unit.test.ts +48 -202
  34. package/src/__tests__/point-light-shadow.browser.test.ts +208 -83
  35. package/src/__tests__/point-light-shadow.unit.test.ts +61 -4
  36. package/src/__tests__/render-error-exhaustive.test-d.ts +63 -9
  37. package/src/__tests__/render-system-mega.test.ts +30 -41
  38. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -1
  39. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +4 -1
  40. package/src/__tests__/render-system-skin-bg.unit.test.ts +14 -9
  41. package/src/__tests__/renderer-recover.unit.test.ts +8 -0
  42. package/src/__tests__/renderer-surface.unit.test.ts +229 -3
  43. package/src/__tests__/shadow-csm-atlas.test.ts +41 -0
  44. package/src/__tests__/shadow-csm-component.test.ts +31 -24
  45. package/src/__tests__/shadow-csm-extract.test.ts +156 -47
  46. package/src/__tests__/shadow-csm-runtime-vary.dawn.test.ts +72 -0
  47. package/src/__tests__/shadow-csm-shader.dawn.test.ts +19 -0
  48. package/src/__tests__/shadow-csm-ubo.test.ts +41 -45
  49. package/src/__tests__/shadow-csm-viewport.browser.test.ts +29 -0
  50. package/src/__tests__/shadow-fields-observable.dawn.test.ts +85 -20
  51. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
  52. package/src/__tests__/skylight-lazy-projection.test.ts +8 -5
  53. package/src/__tests__/ssao-bgl.test.ts +10 -16
  54. package/src/__tests__/ssao-passes.test.ts +19 -2
  55. package/src/__tests__/systems.unit.test.ts +44 -31
  56. package/src/__tests__/view-ubo-layout.browser.test.ts +20 -10
  57. package/src/createRenderer.ts +1 -0
  58. package/src/index.ts +1 -2
@@ -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
@@ -44,6 +44,59 @@ const ENGINE_MANIFEST = await (async () => {
44
44
  })();
45
45
  const ENGINE_MANIFEST_URL = `data:application/json,${encodeURIComponent(JSON.stringify(ENGINE_MANIFEST))}`;
46
46
 
47
+ describe('M3 Dawn candidate admission', () => {
48
+ it('requires accepted production candidate to preserve the requested PCSS profile', async () => {
49
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
50
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
51
+ expect(typeof resolver).toBe('function');
52
+ const result = (
53
+ resolver as (input: {
54
+ backendKind: 'webgpu';
55
+ requested: 'pcssMedium' | 'pcssHigh';
56
+ candidate: 'accepted' | 'failed';
57
+ }) => { effective: string; status: string; pixelEvidence: string }
58
+ )({ backendKind: 'webgpu', requested: 'pcssHigh', candidate: 'accepted' });
59
+ expect(result).toMatchObject({
60
+ effective: 'pcssHigh',
61
+ status: 'accepted',
62
+ pixelEvidence: 'available',
63
+ });
64
+ });
65
+
66
+ it('requires the same bounded Directional inspection projection used by Dawn', async () => {
67
+ const module = (await import(
68
+ '../../../render/src/assembly/directional-shadow-inspection'
69
+ )) as Record<string, unknown>;
70
+ const project = module.projectDirectionalShadowInspection;
71
+ expect(typeof project).toBe('function');
72
+ const result = (project as (input: Record<string, unknown>) => Record<string, unknown>)({
73
+ admission: {
74
+ requested: 'pcssMedium',
75
+ effective: 'pcssMedium',
76
+ status: 'accepted',
77
+ pixelEvidence: 'available',
78
+ },
79
+ cascadeCount: 4,
80
+ mapSize: 1024,
81
+ atlasBytes: 16_777_216,
82
+ writerPasses: 4,
83
+ blockerTaps: 8,
84
+ filterTapUpperBound: 16,
85
+ seamTapUpperBound: 48,
86
+ deviceGeneration: 1,
87
+ graphGeneration: 3,
88
+ });
89
+ expect(result).toMatchObject({
90
+ requested: 'pcssMedium',
91
+ effective: 'pcssMedium',
92
+ blockerTaps: 8,
93
+ filterTapUpperBound: 16,
94
+ seamTapUpperBound: 48,
95
+ });
96
+ expect(Object.keys(result)).not.toContain('perPixel');
97
+ });
98
+ });
99
+
47
100
  // Helper types
48
101
  interface WriteCapture {
49
102
  f32: Float32Array;
@@ -177,7 +230,9 @@ function spawnScene(
177
230
  depthBias,
178
231
  normalBias,
179
232
  shadowDistance: 50,
180
- pcfKernelSize: pcf,
233
+ shadowFilter: pcf === 1 ? 1 : pcf === 3 ? 2 : 3,
234
+ shadowAngularRadius: 0.00465,
235
+ maxPenumbraTexels: 32,
181
236
  },
182
237
  });
183
238
  world.spawn(
@@ -437,15 +492,15 @@ async function renderConfigWithSpy(
437
492
 
438
493
  describe('M6 shadow fields observability', () => {
439
494
  // --- Gate A: structural WGSL check ---
440
- it('composed default-standard-pbr WGSL reads view.depthBias, view.normalBias, view.pcfKernelSize', () => {
495
+ it('composed default-standard-pbr WGSL reads view.depthBias, view.normalBias, and directionalShadowFilter', () => {
441
496
  const wgsl = composedStandardPbr();
442
497
  expect(wgsl).toContain('depthBias');
443
498
  expect(wgsl).toContain('normalBias');
444
- expect(wgsl).toContain('pcfKernelSize');
499
+ expect(wgsl).toContain('directionalShadowFilter');
445
500
  });
446
501
 
447
- // --- Gate B: UBO spy — slots [126]=depthBias, [127]=normalBias, [128]=pcfKernelSize ---
448
- it('View UBO slots [126]=depthBias, [127]=normalBias, [128]=pcfKernelSize', async () => {
502
+ // --- Gate B: UBO spy — slots [126]=depthBias, [127]=normalBias, [128]=filter profile ---
503
+ it('View UBO slots [126]=depthBias, [127]=normalBias, [128]=filter profile', async () => {
449
504
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
450
505
  const { captured } = await renderConfigWithSpy(true, 0.123, 0.456, 3);
451
506
  const vw = captured.filter((c) => c.f32.length >= 148);
@@ -455,22 +510,22 @@ describe('M6 shadow fields observability', () => {
455
510
  const f32 = last.f32;
456
511
  expect(Math.abs((f32[126] ?? Number.NaN) - 0.123)).toBeLessThan(0.001);
457
512
  expect(Math.abs((f32[127] ?? Number.NaN) - 0.456)).toBeLessThan(0.001);
458
- expect(f32[128]).toBe(3);
513
+ expect(f32[128]).toBe(2);
459
514
  }, 60000);
460
515
 
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 () => {
516
+ // --- Gate D: pcf5 maps to the accepted profile carrier ---
517
+ it('pcf5 maps to profile 3 in View UBO[128]', async () => {
463
518
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
464
- const { captured } = await renderConfigWithSpy(true, 0.005, 0.05, 9);
519
+ const { captured } = await renderConfigWithSpy(true, 0.005, 0.05, 5);
465
520
  const vw9 = captured.filter((c) => c.f32.length >= 148);
466
521
  expect(vw9.length).toBeGreaterThan(0);
467
522
  const last9 = vw9[vw9.length - 1];
468
523
  if (last9 === undefined) throw new Error('no view UBO write captured');
469
- expect(last9.f32[128]).toBe(5);
524
+ expect(last9.f32[128]).toBe(3);
470
525
  }, 60000);
471
526
 
472
- // --- Gate D: validate() rejections ---
473
- it('validate() accepts pcfKernelSize=9 (odd), rejects 2 (even) and 0', () => {
527
+ // --- Gate D: Directional filter validation ---
528
+ it('validate() accepts pcf5 and rejects an unknown directional filter', () => {
474
529
  const valid = validateDirectionalLightData({
475
530
  direction: [0, -1, 0],
476
531
  castShadow: true,
@@ -479,7 +534,7 @@ describe('M6 shadow fields observability', () => {
479
534
  cascadeBlend: 0.2,
480
535
  mapSize: 2048,
481
536
  shadowDistance: 50,
482
- pcfKernelSize: 9,
537
+ shadowFilter: 3,
483
538
  });
484
539
  expect(valid.ok).toBe(true);
485
540
 
@@ -491,7 +546,7 @@ describe('M6 shadow fields observability', () => {
491
546
  cascadeBlend: 0.2,
492
547
  mapSize: 2048,
493
548
  shadowDistance: 50,
494
- pcfKernelSize: 2,
549
+ shadowFilter: 99,
495
550
  });
496
551
  expect(even.ok).toBe(false);
497
552
  if (!even.ok) expect(even.error.code).toBe('shadow-invalid-config');
@@ -504,7 +559,7 @@ describe('M6 shadow fields observability', () => {
504
559
  cascadeBlend: 0.2,
505
560
  mapSize: 2048,
506
561
  shadowDistance: 50,
507
- pcfKernelSize: 0,
562
+ shadowFilter: 0,
508
563
  });
509
564
  expect(zero.ok).toBe(false);
510
565
  if (!zero.ok) expect(zero.error.code).toBe('shadow-invalid-config');
@@ -519,6 +574,16 @@ describe('M6 shadow fields observability', () => {
519
574
  expect(d.diff).toBeGreaterThan(0);
520
575
  }, 60000);
521
576
 
577
+ it('castShadow=false emits a zero directional cascade count in the View UBO', async () => {
578
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
579
+ const { captured } = await renderConfigWithSpy(false, 0.005, 0.05, 3);
580
+ const viewWrites = captured.filter((capture) => capture.f32.length >= 148);
581
+ expect(viewWrites.length).toBeGreaterThan(0);
582
+ const last = viewWrites[viewWrites.length - 1];
583
+ if (last === undefined) throw new Error('no view UBO write captured');
584
+ expect(last.f32[124]).toBe(0);
585
+ }, 60000);
586
+
522
587
  // --- Gate C: pixel A/B — depthBias 0.0 vs 0.5 ---
523
588
  it('pixel A/B: depthBias 0.0 vs 0.5', async () => {
524
589
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
@@ -537,8 +602,8 @@ describe('M6 shadow fields observability', () => {
537
602
  expect(d.diff).toBeGreaterThan(0);
538
603
  }, 60000);
539
604
 
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 () => {
605
+ // --- Gate C: pixel A/B — directional filter profile 1 vs 3 ---
606
+ it('pixel A/B: directional filter profile 1 vs 3', async () => {
542
607
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
543
608
  const pxA = await renderConfig(true, 0.005, 0.05, 1);
544
609
  const pxB = await renderConfig(true, 0.005, 0.05, 5);
@@ -624,7 +689,7 @@ describe('M6 shadow fields observability', () => {
624
689
  );
625
690
 
626
691
  it(
627
- 'falsification: pcfKernelSize varied differs, held does not',
692
+ 'falsification: directional filter varied differs, held does not',
628
693
  async () => {
629
694
  if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
630
695
  const varied = diff(
@@ -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
 
@@ -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).
@@ -131,7 +131,7 @@ function makeMockRuntime(capsOverride: Partial<RhiCaps> = {}): {
131
131
 
132
132
  function fakeHdrpBuffers(): HdrpBuffers {
133
133
  return {
134
- storageBuffer: true,
134
+ device: {} as HdrpBuffers['device'],
135
135
  lightDataBuffer: mockBuffer('hdrp-light-data'),
136
136
  lightDataBytes: 16384,
137
137
  clusterGridBuffer: mockBuffer('hdrp-cluster-grid'),
@@ -150,15 +150,9 @@ function fakeHdrpBuffers(): HdrpBuffers {
150
150
  }
151
151
 
152
152
  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);
153
+ it('preserves the full 256-light Standard count in the cluster UBO', () => {
154
+ const payload = new Uint32Array(packClusterUniform({ x: 16, y: 9, z: 24 }, 0.1, 50, 0, 256));
155
+ expect(payload[3]).toBe(256);
162
156
  });
163
157
 
164
158
  it('keeps enabled SSAO intensity in the zero-punctual-light uniform path', () => {
@@ -209,12 +203,12 @@ describe('HDRP GPU membership producer bind-group contract', () => {
209
203
 
210
204
  describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
211
205
  it('descriptor entries.length === 7 (5 cluster + 2 ssao)', () => {
212
- const desc = createHdrpBindGroupLayoutDescriptor(true);
206
+ const desc = createHdrpBindGroupLayoutDescriptor();
213
207
  expect(desc.entries?.length).toBe(7);
214
208
  });
215
209
 
216
210
  it('binding 7 is texture_2d<f32> with FRAGMENT visibility (ssaoBlurred)', () => {
217
- const desc = createHdrpBindGroupLayoutDescriptor(true);
211
+ const desc = createHdrpBindGroupLayoutDescriptor();
218
212
  const b7 = desc.entries?.find((e) => e.binding === 7);
219
213
  expect(b7).toBeDefined();
220
214
  expect(b7?.visibility).toBe(FRAGMENT_VISIBILITY);
@@ -223,7 +217,7 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
223
217
  });
224
218
 
225
219
  it('binding 8 is sampler with FRAGMENT visibility (ssaoSampler)', () => {
226
- const desc = createHdrpBindGroupLayoutDescriptor(true);
220
+ const desc = createHdrpBindGroupLayoutDescriptor();
227
221
  const b8 = desc.entries?.find((e) => e.binding === 8);
228
222
  expect(b8).toBeDefined();
229
223
  expect(b8?.visibility).toBe(FRAGMENT_VISIBILITY);
@@ -231,13 +225,13 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
231
225
  });
232
226
 
233
227
  it('binding 9 is absent (scope-amend-webgl2-ubo: intensity folded into binding 6)', () => {
234
- const desc = createHdrpBindGroupLayoutDescriptor(true);
228
+ const desc = createHdrpBindGroupLayoutDescriptor();
235
229
  const bindings = desc.entries?.map((e) => e.binding) ?? [];
236
230
  expect(bindings).not.toContain(9);
237
231
  });
238
232
 
239
233
  it('existing bindings 0/3/4/5/6 remain unchanged', () => {
240
- const desc = createHdrpBindGroupLayoutDescriptor(true);
234
+ const desc = createHdrpBindGroupLayoutDescriptor();
241
235
  const b0 = desc.entries?.find((e) => e.binding === 0);
242
236
  expect(b0?.buffer?.hasDynamicOffset).toBe(true);
243
237
  for (const b of [3, 4, 5, 6]) {
@@ -247,7 +241,7 @@ describe('w28 — HDRP unified BGL SSAO entries (binding 7/8)', () => {
247
241
  });
248
242
 
249
243
  it('all bindings exactly { 0, 3, 4, 5, 6, 7, 8 }', () => {
250
- const desc = createHdrpBindGroupLayoutDescriptor(true);
244
+ const desc = createHdrpBindGroupLayoutDescriptor();
251
245
  const bindings = new Set(desc.entries?.map((e) => e.binding));
252
246
  expect(bindings).toEqual(new Set([0, 3, 4, 5, 6, 7, 8]));
253
247
  });
@@ -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: {
@@ -448,9 +448,9 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
448
448
  ];
449
449
  }
450
450
 
451
- describe('Camera schema (19 fields: 17 f32 + clearColor array + autoAspect bool after w9 + tonemap-mvp + fxaa + bloom + clearColor + aspect-sync extensions)', () => {
452
- it('Camera.schema has 19 fields (17 f32 + clearColor array + autoAspect bool: perspective quartet + projection + ortho quartet + tonemap trio + antialias + bloom quartet + clearColor + autoAspect)', () => {
453
- expect(Object.keys(Camera.fields).length).toBe(20);
451
+ describe('Camera schema (21 fields: 17 f32 + clearColor array + autoAspect bool + target after render-target extension)', () => {
452
+ it('Camera.schema has 21 fields (17 f32 + clearColor array + autoAspect bool + target: perspective quartet + projection + ortho quartet + tonemap trio + antialias + bloom quartet + clearColor + autoAspect)', () => {
453
+ expect(Object.keys(Camera.fields).length).toBe(21);
454
454
  expect(componentFieldType(Camera, 'fov')).toBe('f32');
455
455
  expect(componentFieldType(Camera, 'aspect')).toBe('f32');
456
456
  expect(componentFieldType(Camera, 'near')).toBe('f32');
@@ -476,6 +476,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
476
476
  // feat-20260617-host-engine-contract-and-video-cutscene / M3: aspect-sync
477
477
  // opt-out flag (bool column tier).
478
478
  expect(componentFieldType(Camera, 'autoAspect')).toBe('bool');
479
+ expect(componentFieldType(Camera, 'target')).toBe('shared<RenderTarget>');
479
480
  });
480
481
  });
481
482
 
@@ -574,6 +575,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
574
575
  directionalCount: 0,
575
576
  point: [],
576
577
  spot: [],
578
+ directionalShadowError: undefined,
577
579
  lightSpaceMatrix: undefined,
578
580
  shadowMapSize: undefined,
579
581
  pointShadow: [],
@@ -767,6 +769,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
767
769
  temporalFrameInput: undefined,
768
770
  compiledFrameGraph: null,
769
771
  compiledFrameGraphTopologyKey: null,
772
+ compiledFrameGraphGeneration: 0,
770
773
  retiredCompiledFrameGraphs: new Set(),
771
774
  instanceBuffers: new Map(),
772
775
  transientInstanceBuffers: [],
@@ -787,8 +790,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
787
790
  installedPipelineHandle: 0,
788
791
  activePipeline: urpPipeline,
789
792
  installedPipelineConfig: undefined,
790
- isHdrpActive: false,
791
- hdrpOncePerFrameFired: new Set(),
793
+ standardOncePerFrameFired: new Set(),
792
794
  },
793
795
  { unlit: 0 },
794
796
  { createBindGroup: 0, keys: [] },
@@ -824,6 +826,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
824
826
  temporalFrameInput: undefined,
825
827
  compiledFrameGraph: null,
826
828
  compiledFrameGraphTopologyKey: null,
829
+ compiledFrameGraphGeneration: 0,
827
830
  retiredCompiledFrameGraphs: new Set(),
828
831
  instanceBuffers: new Map(),
829
832
  transientInstanceBuffers: [],
@@ -844,8 +847,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
844
847
  installedPipelineHandle: 0,
845
848
  activePipeline: urpPipeline,
846
849
  installedPipelineConfig: undefined,
847
- isHdrpActive: false,
848
- hdrpOncePerFrameFired: new Set(),
850
+ standardOncePerFrameFired: new Set(),
849
851
  },
850
852
  { unlit: 0 },
851
853
  { createBindGroup: 0, keys: [] },
@@ -914,8 +916,9 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
914
916
  // --- from fxaa-pipeline.test.ts ---
915
917
 
916
918
  describe('feat-20260528-fxaa-post-processing M2 w8: FXAA pipeline construction (success path)', () => {
917
- it('D-2: FXAA BGL has exactly 2 entries: @binding(0) texture_2d<f32> + @binding(1) sampler, no UBO', () => {
918
- // plan-strategy D-2 specifies 2-entry BGL with no UBO entry.
919
+ it('D-2: FXAA BGL has exactly 3 entries: texture + sampler + dither policy UBO', () => {
920
+ // FXAA owns a per-pass output-policy UBO so pipeline asset config can
921
+ // disable dither without rebuilding the shader or graph topology.
919
922
  // The sampler entry uses { type: 'filtering' } (linear sampling).
920
923
  const bglEntries = [
921
924
  {
@@ -928,15 +931,21 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
928
931
  visibility: 2, // GPU_SHADER_STAGE_FRAGMENT = 0x2
929
932
  sampler: { type: 'filtering' },
930
933
  },
934
+ {
935
+ binding: 2,
936
+ visibility: 2,
937
+ buffer: { type: 'uniform' },
938
+ },
931
939
  ];
932
- expect(bglEntries).toHaveLength(2);
940
+ expect(bglEntries).toHaveLength(3);
933
941
  expect(bglEntries[0]?.binding).toBe(0);
934
942
  expect(bglEntries[0]?.texture).toBeDefined();
935
943
  expect(bglEntries[1]?.binding).toBe(1);
936
944
  expect(bglEntries[1]?.sampler).toBeDefined();
937
- // D-2 explicitly: no UBO entry (no buffer-type binding).
945
+ expect(bglEntries[2]?.binding).toBe(2);
946
+ expect(bglEntries[2]?.buffer).toEqual({ type: 'uniform' });
938
947
  const hasBuffer = bglEntries.some((e) => 'buffer' in (e as Record<string, unknown>));
939
- expect(hasBuffer).toBe(false);
948
+ expect(hasBuffer).toBe(true);
940
949
  });
941
950
 
942
951
  it('D-3: FXAA pipeline target format = swap-chain storage format (non-srgb, helper Channel 2 truth)', () => {
@@ -1920,10 +1929,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1920
1929
  return 'mesh-ssbo-capacity';
1921
1930
  case 'mesh-ssbo-ceiling-reached':
1922
1931
  return 'mesh-ssbo-ceiling';
1923
- case 'hdrp-light-budget-exceeded':
1924
- return 'hdrp-budget';
1925
- case 'hdrp-index-list-overflow':
1926
- return 'hdrp-overflow';
1932
+ case 'standard-light-budget-exceeded':
1933
+ return 'standard-budget';
1934
+ case 'standard-cluster-index-overflow':
1935
+ return 'standard-overflow';
1927
1936
  }
1928
1937
  }
1929
1938
  expect(exhaustive('skin-joint-count-exceeded')).toBe('joint-count');
@@ -3186,6 +3195,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3186
3195
  directionalCount: 0,
3187
3196
  point: [],
3188
3197
  spot: [],
3198
+ directionalShadowError: undefined,
3189
3199
  lightSpaceMatrix: undefined,
3190
3200
  shadowMapSize: undefined,
3191
3201
  pointShadow: [],
@@ -3368,11 +3378,12 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3368
3378
  }
3369
3379
 
3370
3380
  function makePipelineState(
3371
- internals: { _fakes: Record<string, unknown> },
3381
+ internals: { _fakes: Record<string, unknown>; device?: unknown },
3372
3382
  hdrAlreadyAllocated: boolean,
3373
3383
  ): unknown {
3374
3384
  const f = internals._fakes;
3375
3385
  return {
3386
+ device: internals.device,
3376
3387
  meshes: new Map(),
3377
3388
  format: 'bgra8unorm',
3378
3389
  colorAttachmentFormat: 'bgra8unorm-srgb',
@@ -3461,6 +3472,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3461
3472
  temporalFrameInput: undefined,
3462
3473
  compiledFrameGraph: null,
3463
3474
  compiledFrameGraphTopologyKey: null,
3475
+ compiledFrameGraphGeneration: 0,
3464
3476
  retiredCompiledFrameGraphs: new Set(),
3465
3477
  instanceBuffers: new Map(),
3466
3478
  transientInstanceBuffers: [],
@@ -3481,8 +3493,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3481
3493
  installedPipelineHandle: 0,
3482
3494
  activePipeline: urpPipeline,
3483
3495
  installedPipelineConfig: undefined,
3484
- isHdrpActive: false,
3485
- hdrpOncePerFrameFired: new Set(),
3496
+ standardOncePerFrameFired: new Set(),
3486
3497
  },
3487
3498
  { unlit: 0 },
3488
3499
  { createBindGroup: 0, keys: [] },
@@ -3582,6 +3593,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3582
3593
  temporalFrameInput: undefined,
3583
3594
  compiledFrameGraph: null,
3584
3595
  compiledFrameGraphTopologyKey: null,
3596
+ compiledFrameGraphGeneration: 0,
3585
3597
  retiredCompiledFrameGraphs: new Set(),
3586
3598
  instanceBuffers,
3587
3599
  warnedZeroLightStandard: false,
@@ -3601,8 +3613,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3601
3613
  installedPipelineHandle: 0,
3602
3614
  activePipeline: urpPipeline,
3603
3615
  installedPipelineConfig: undefined,
3604
- isHdrpActive: false,
3605
- hdrpOncePerFrameFired: new Set(),
3616
+ standardOncePerFrameFired: new Set(),
3606
3617
  },
3607
3618
  { unlit: 0 },
3608
3619
  { createBindGroup: 0, keys: [] },
@@ -3657,6 +3668,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3657
3668
  temporalFrameInput: undefined,
3658
3669
  compiledFrameGraph: null,
3659
3670
  compiledFrameGraphTopologyKey: null,
3671
+ compiledFrameGraphGeneration: 0,
3660
3672
  retiredCompiledFrameGraphs: new Set(),
3661
3673
  instanceBuffers,
3662
3674
  warnedZeroLightStandard: false,
@@ -3676,8 +3688,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3676
3688
  installedPipelineHandle: 0,
3677
3689
  activePipeline: urpPipeline,
3678
3690
  installedPipelineConfig: undefined,
3679
- isHdrpActive: false,
3680
- hdrpOncePerFrameFired: new Set(),
3691
+ standardOncePerFrameFired: new Set(),
3681
3692
  },
3682
3693
  { unlit: 0 },
3683
3694
  { createBindGroup: 0, keys: [] },
@@ -8336,10 +8347,10 @@ type ExtractFramesWithPipeline = (
8336
8347
  }
8337
8348
 
8338
8349
  // ── feat-20260612-skin-palette-per-frame-upload M3 / m3-1: record-stage ──
8339
- // dynOffset[1] truth-value + skin BG cache miss=1 + hit=N-1 stats counter.
8350
+ // dynOffset[1]/[2] truth-value + skin BG cache miss=1 + hit=N-1 stats counter.
8340
8351
  //
8341
8352
  // Why this block exists:
8342
- // M3 lifts the record-stage `group2DynamicOffsets` second slot from a
8353
+ // M3 lifts the record-stage `group2DynamicOffsets` palette slots from a
8343
8354
  // hard-coded `0` (PR #353 stub) to the per-entity `entry.source.skin
8344
8355
  // .byteOffset` cursor that M2 m2-6 wired into the extract output. The
8345
8356
  // acceptanceCheck calls for the literal text `group2DynamicOffsets[1]
@@ -8351,8 +8362,8 @@ type ExtractFramesWithPipeline = (
8351
8362
  // What the assertions cover:
8352
8363
  // (a) The test-local offset oracle mirrors the production main-pass
8353
8364
  // geometry contract. With skinByteOffset !== undefined the returned
8354
- // tuple's [1] slot equals the supplied byteOffset (the contract:
8355
- // `group2DynamicOffsets[1] === byteOffset`); with skinByteOffset ===
8365
+ // tuple's [1] and [2] slots equal the supplied byteOffset (the
8366
+ // temporal contract); with skinByteOffset ===
8356
8367
  // undefined it returns a length-1 tuple (non-skin paths preserved).
8357
8368
  // (b) N=3 sequential lookups
8358
8369
  // against the same `(meshStorageBuffer, skinPaletteAllocator
@@ -8379,7 +8390,9 @@ type ExtractFramesWithPipeline = (
8379
8390
  skinByteOffset: number | undefined,
8380
8391
  ): readonly number[] => {
8381
8392
  const meshOffset = meshSlotIdx * MESH_PER_ENTITY_STRIDE;
8382
- return skinByteOffset === undefined ? [meshOffset] : [meshOffset, skinByteOffset];
8393
+ return skinByteOffset === undefined
8394
+ ? [meshOffset]
8395
+ : [meshOffset, skinByteOffset, skinByteOffset];
8383
8396
  };
8384
8397
 
8385
8398
  // Skin path: second slot must equal the per-entity byteOffset cursor
@@ -8388,12 +8401,12 @@ type ExtractFramesWithPipeline = (
8388
8401
  // (first allocateSlice), cursor=256 (second 1-joint slice), cursor
8389
8402
  // =16320 (the worst-case 255-joint slice end).
8390
8403
  const skinAt0 = computeSkinGroup2DynOffsets(0, 0);
8391
- expect(skinAt0).toHaveLength(2);
8404
+ expect(skinAt0).toHaveLength(3);
8392
8405
  // group2DynamicOffsets[1] === byteOffset (literal assertion text per acceptanceCheck)
8393
8406
  expect(skinAt0[1]).toBe(0);
8394
8407
 
8395
8408
  const skinAt256 = computeSkinGroup2DynOffsets(1, 256);
8396
- expect(skinAt256).toHaveLength(2);
8409
+ expect(skinAt256).toHaveLength(3);
8397
8410
  // group2DynamicOffsets[1] === byteOffset
8398
8411
  expect(skinAt256[1]).toBe(256);
8399
8412
  // mesh slot continues to follow the 256-byte stride: meshSlotIdx * 256.
@@ -8401,7 +8414,7 @@ type ExtractFramesWithPipeline = (
8401
8414
 
8402
8415
  const byteOffsetWorst = 254 * 256;
8403
8416
  const skinAtEnd = computeSkinGroup2DynOffsets(2, byteOffsetWorst);
8404
- expect(skinAtEnd).toHaveLength(2);
8417
+ expect(skinAtEnd).toHaveLength(3);
8405
8418
  // group2DynamicOffsets[1] === byteOffset
8406
8419
  expect(skinAtEnd[1]).toBe(byteOffsetWorst);
8407
8420
  expect(skinAtEnd[0]).toBe(2 * 256);