@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-runtime",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,37 +26,37 @@
26
26
  "LICENSE"
27
27
  ],
28
28
  "dependencies": {
29
- "@forgeax/engine-animation": "0.1.20",
30
- "@forgeax/engine-assets-runtime": "0.1.20",
31
- "@forgeax/engine-audio-webaudio": "0.1.20",
32
- "@forgeax/engine-codec": "0.1.20",
33
- "@forgeax/engine-debug-draw": "0.1.20",
34
- "@forgeax/engine-ecs": "0.1.20",
35
- "@forgeax/engine-font": "0.1.20",
36
- "@forgeax/engine-geometry": "0.1.20",
37
- "@forgeax/engine-graphics-extras": "0.1.20",
38
- "@forgeax/engine-image": "0.1.20",
39
- "@forgeax/engine-input": "0.1.20",
40
- "@forgeax/engine-math": "0.1.20",
41
- "@forgeax/engine-pack": "0.1.20",
42
- "@forgeax/engine-profiler": "0.1.20",
43
- "@forgeax/engine-render": "0.1.20",
44
- "@forgeax/engine-render-graph": "0.1.20",
45
- "@forgeax/engine-rhi": "0.1.20",
46
- "@forgeax/engine-rhi-null": "0.1.20",
47
- "@forgeax/engine-rhi-webgpu": "0.1.20",
48
- "@forgeax/engine-rhi-wgpu": "0.1.20",
49
- "@forgeax/engine-scene": "0.1.20",
50
- "@forgeax/engine-shader": "0.1.20",
51
- "@forgeax/engine-skinning": "0.1.20",
52
- "@forgeax/engine-types": "0.1.20",
53
- "@forgeax/engine-vfx": "0.1.20"
29
+ "@forgeax/engine-animation": "0.1.21",
30
+ "@forgeax/engine-assets-runtime": "0.1.21",
31
+ "@forgeax/engine-audio-webaudio": "0.1.21",
32
+ "@forgeax/engine-codec": "0.1.21",
33
+ "@forgeax/engine-debug-draw": "0.1.21",
34
+ "@forgeax/engine-ecs": "0.1.21",
35
+ "@forgeax/engine-font": "0.1.21",
36
+ "@forgeax/engine-geometry": "0.1.21",
37
+ "@forgeax/engine-graphics-extras": "0.1.21",
38
+ "@forgeax/engine-image": "0.1.21",
39
+ "@forgeax/engine-input": "0.1.21",
40
+ "@forgeax/engine-math": "0.1.21",
41
+ "@forgeax/engine-pack": "0.1.21",
42
+ "@forgeax/engine-profiler": "0.1.21",
43
+ "@forgeax/engine-render": "0.1.21",
44
+ "@forgeax/engine-render-graph": "0.1.21",
45
+ "@forgeax/engine-rhi": "0.1.21",
46
+ "@forgeax/engine-rhi-null": "0.1.21",
47
+ "@forgeax/engine-rhi-webgpu": "0.1.21",
48
+ "@forgeax/engine-rhi-wgpu": "0.1.21",
49
+ "@forgeax/engine-scene": "0.1.21",
50
+ "@forgeax/engine-shader": "0.1.21",
51
+ "@forgeax/engine-skinning": "0.1.21",
52
+ "@forgeax/engine-types": "0.1.21",
53
+ "@forgeax/engine-vfx": "0.1.21"
54
54
  },
55
55
  "devDependencies": {
56
- "@forgeax/engine-import": "0.1.20",
57
- "@forgeax/engine-naga": "0.1.20",
58
- "@forgeax/engine-state": "0.1.20",
59
- "@forgeax/engine-vite-plugin-shader": "0.1.20",
56
+ "@forgeax/engine-import": "0.1.21",
57
+ "@forgeax/engine-naga": "0.1.21",
58
+ "@forgeax/engine-state": "0.1.21",
59
+ "@forgeax/engine-vite-plugin-shader": "0.1.21",
60
60
  "@types/node": "^20.14.0"
61
61
  },
62
62
  "forgeax": {
@@ -147,16 +147,14 @@ describe('w7 — getActiveCamera / setActiveCamera', () => {
147
147
  // ═══════════════════════════════════════════════════════════════════════════════
148
148
 
149
149
  describe('w7 — extractFrame ActiveCamera selection', () => {
150
- it('(2) no ActiveCamera + multiple cameras -> all cameras surface (first-hit fallback preserved)', () => {
150
+ it('(2) no ActiveCamera + multiple display cameras -> first display camera surfaces', () => {
151
151
  const world = new World();
152
152
  const assets = makeAssets();
153
153
  spawnCamera(world, 1.0);
154
154
  spawnCamera(world, 2.0);
155
155
  const frame = extractFrame(world, prepareExtractContext(world, { assets }));
156
- // No selection -> existing behavior: both cameras flow through; record
157
- // stage fires multi-camera diagnostic + uses first hit. We assert extract
158
- // does NOT prune when there is no ActiveCamera.
159
- expect(frame.cameras.length).toBe(2);
156
+ // No selection -> the first display camera remains the display authority.
157
+ expect(frame.cameras.length).toBe(1);
160
158
  });
161
159
 
162
160
  it('(1) ActiveCamera pointing at 2nd camera -> only that camera surfaces', () => {
@@ -171,15 +169,15 @@ describe('w7 — extractFrame ActiveCamera selection', () => {
171
169
  expect(frame.cameras[0]?.fov).toBeCloseTo(2.0, 5);
172
170
  });
173
171
 
174
- it('(3) ActiveCamera pointing at non-existent entity -> fallback to first-hit (all cameras)', () => {
172
+ it('(3) ActiveCamera pointing at non-existent entity -> fallback to first display camera', () => {
175
173
  const world = new World();
176
174
  const assets = makeAssets();
177
175
  spawnCamera(world, 1.0);
178
176
  spawnCamera(world, 2.0);
179
177
  setActiveCamera(world, 999999);
180
178
  const frame = extractFrame(world, prepareExtractContext(world, { assets }));
181
- // Unresolvable -> behave as if no ActiveCamera (do not prune).
182
- expect(frame.cameras.length).toBe(2);
179
+ // Unresolvable -> behave as if no ActiveCamera.
180
+ expect(frame.cameras.length).toBe(1);
183
181
  });
184
182
 
185
183
  it('(4) single camera + ActiveCamera pointing at it -> behavior unchanged (one camera)', () => {
@@ -1166,7 +1166,7 @@ const LEAF_FIXTURES: readonly LeafFixture[] = [
1166
1166
  kind: 'render-pipeline',
1167
1167
  makeAsset: () => ({
1168
1168
  kind: 'render-pipeline' as const,
1169
- pipelineId: 'forgeax::urp' as const,
1169
+ pipelineId: 'forgeax::standard' as const,
1170
1170
  }),
1171
1171
  },
1172
1172
  ];
@@ -61,8 +61,8 @@ const SUT_ATTRIBUTABLE_RENDER_CODES: ReadonlySet<string> = new Set([
61
61
  'vertex-storage-buffer-unavailable',
62
62
  'mesh-ssbo-capacity-exceeded',
63
63
  'mesh-ssbo-ceiling-reached',
64
- 'hdrp-light-budget-exceeded',
65
- 'hdrp-index-list-overflow',
64
+ 'standard-light-budget-exceeded',
65
+ 'standard-cluster-index-overflow',
66
66
  ]);
67
67
 
68
68
  // Suppress WebGPU teardown race: chromium fires unhandled OperationError
@@ -67,7 +67,7 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
67
67
  expectTypeOf<{ materials: readonly [0] }>().not.toMatchTypeOf<SpawnData>();
68
68
  });
69
69
 
70
- it('Camera data shape has 20 fields (18 number + clearColor array + autoAspect boolean)', () => {
70
+ it('Camera data shape has 21 fields (18 number + clearColor array + autoAspect boolean + target)', () => {
71
71
  type Data = ShapeOf<SchemaOf<typeof Camera>>;
72
72
  expectTypeOf<keyof Data>().toEqualTypeOf<
73
73
  | 'fov'
@@ -90,6 +90,7 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
90
90
  | 'bloomBlurRadius'
91
91
  | 'clearColor'
92
92
  | 'autoAspect'
93
+ | 'target'
93
94
  >();
94
95
  expectTypeOf<Data['fov']>().toEqualTypeOf<number>();
95
96
  expectTypeOf<Data['far']>().toEqualTypeOf<number>();
@@ -107,9 +108,10 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
107
108
  expectTypeOf<Data['clearColor']>().toEqualTypeOf<Float32Array>();
108
109
  // feat-20260617 / M3: AC-09 -- bool column narrows to boolean, not number.
109
110
  expectTypeOf<Data['autoAspect']>().toEqualTypeOf<boolean>();
111
+ expectTypeOf<Data['target']>().toEqualTypeOf<Handle<'RenderTarget', 'shared'>>();
110
112
  });
111
113
 
112
- it('DirectionalLight data shape: 3 light fields + castShadow bool + 8 merged shadow fields', () => {
114
+ it('DirectionalLight data shape includes closed shadow quality fields', () => {
113
115
  // feat-20260621: DirectionalLightShadow merged into DirectionalLight via castShadow toggle.
114
116
  // shadowDistance replaced the nearPlane/farPlane pair (near derives from camera).
115
117
  // feat-20260709 M2: direction/color collapsed to array<f32,3> columns.
@@ -126,13 +128,18 @@ describe('w7 type-level - 5 component schemas yield exact data shapes via ShapeO
126
128
  | 'depthBias'
127
129
  | 'normalBias'
128
130
  | 'shadowDistance'
129
- | 'pcfKernelSize'
131
+ | 'shadowFilter'
132
+ | 'shadowAngularRadius'
133
+ | 'maxPenumbraTexels'
130
134
  >();
131
135
  expectTypeOf<Data['direction']>().toEqualTypeOf<Float32Array>();
132
136
  expectTypeOf<Data['color']>().toEqualTypeOf<Float32Array>();
133
137
  expectTypeOf<Data['intensity']>().toEqualTypeOf<number>();
134
138
  // bool column narrows to boolean, not number.
135
139
  expectTypeOf<Data['castShadow']>().toEqualTypeOf<boolean>();
140
+ expectTypeOf<Data['shadowFilter']>().toEqualTypeOf<number>();
141
+ expectTypeOf<Data['shadowAngularRadius']>().toEqualTypeOf<number>();
142
+ expectTypeOf<Data['maxPenumbraTexels']>().toEqualTypeOf<number>();
136
143
  });
137
144
  });
138
145
 
@@ -176,9 +176,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
176
176
  expect(schemaRecord.materials).toBe('array<shared<MaterialAsset>>');
177
177
  });
178
178
 
179
- it('Camera has 20 fields (17 f32 + historyVersion u32 + clearColor array<f32,4> + autoAspect bool)', () => {
179
+ it('Camera has 21 fields (17 f32 + historyVersion u32 + clearColor array<f32,4> + autoAspect bool + target)', () => {
180
180
  expect(Camera.name).toBe('Camera');
181
- expect(Object.keys(componentSchema(Camera)).length).toBe(20);
181
+ expect(Object.keys(componentSchema(Camera)).length).toBe(21);
182
182
  expect(componentSchema(Camera).fov).toBe('f32');
183
183
  expect(componentSchema(Camera).aspect).toBe('f32');
184
184
  expect(componentSchema(Camera).near).toBe('f32');
@@ -211,13 +211,13 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
211
211
  expect(componentSchema(Camera).autoAspect).toBe('bool');
212
212
  });
213
213
 
214
- it('DirectionalLight has 12 fields: 3 light + castShadow bool + 8 merged shadow f32', () => {
214
+ it('DirectionalLight has 14 fields: 3 light + castShadow bool + closed shadow quality fields', () => {
215
215
  // feat-20260621: DirectionalLightShadow merged into DirectionalLight via castShadow toggle.
216
216
  // shadowDistance replaced the nearPlane/farPlane pair (near derives from camera).
217
217
  // feat-20260709 M2: direction/color collapsed from 6 per-axis scalars to
218
218
  // two array<f32,3> columns (3 light fields: direction + color + intensity).
219
219
  expect(DirectionalLight.name).toBe('DirectionalLight');
220
- expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(12);
220
+ expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(14);
221
221
  // 3 light fields
222
222
  expect(componentSchema(DirectionalLight).direction).toBe('array<f32, 3>');
223
223
  expect(componentSchema(DirectionalLight).color).toBe('array<f32, 3>');
@@ -231,7 +231,10 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
231
231
  expect(componentSchema(DirectionalLight).depthBias).toBe('f32');
232
232
  expect(componentSchema(DirectionalLight).normalBias).toBe('f32');
233
233
  expect(componentSchema(DirectionalLight).shadowDistance).toBe('f32');
234
- expect(componentSchema(DirectionalLight).pcfKernelSize).toBe('f32');
234
+ expect(componentSchema(DirectionalLight).shadowFilter).toBe('enum');
235
+ expect(componentSchema(DirectionalLight).shadowAngularRadius).toBe('f32');
236
+ expect(componentSchema(DirectionalLight).maxPenumbraTexels).toBe('f32');
237
+ expect('pcfKernelSize' in componentSchema(DirectionalLight)).toBe(false);
235
238
  });
236
239
 
237
240
  it('all 5 components are frozen tokens with owner-assigned identities', () => {
@@ -1408,7 +1411,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1408
1411
  // ────────────────────────────────────────────────────────────────────────────
1409
1412
 
1410
1413
  describe('Camera.fields reflection (w14 AC-07 SSOT)', () => {
1411
- it('Camera.fields has exactly 20 keys matching the Camera column set', () => {
1414
+ it('Camera.fields has exactly 21 keys matching the Camera column set', () => {
1412
1415
  const keys = Object.keys(Camera.fields).sort();
1413
1416
  expect(keys).toEqual([
1414
1417
  'antialias',
@@ -1428,6 +1431,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1428
1431
  'near',
1429
1432
  'projection',
1430
1433
  'right',
1434
+ 'target',
1431
1435
  'tonemap',
1432
1436
  'top',
1433
1437
  'whitePoint',
@@ -1443,7 +1447,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
1443
1447
  ? 'array<f32, 4>'
1444
1448
  : key === 'historyVersion'
1445
1449
  ? 'u32'
1446
- : 'f32';
1450
+ : key === 'target'
1451
+ ? 'shared<RenderTarget>'
1452
+ : 'f32';
1447
1453
  expect(Camera.fields[key].type).toBe(expected);
1448
1454
  }
1449
1455
  });
@@ -36,6 +36,30 @@ describe('runtime renderer host assembly', () => {
36
36
  host.value.renderer.dispose();
37
37
  });
38
38
 
39
+ it('repeats null-device assembly and frame observation without device loss', async () => {
40
+ for (let attempt = 0; attempt < 2; attempt += 1) {
41
+ const host = await constructRendererHost(canvas(), { rhi }, { shaderManifestUrl: manifest });
42
+ expect(host.ok).toBe(true);
43
+ if (!host.ok) continue;
44
+ const world = new World();
45
+ const attached = host.value.renderer.attach(world);
46
+ expect(attached.ok).toBe(true);
47
+ if (!attached.ok) {
48
+ host.value.renderer.dispose();
49
+ continue;
50
+ }
51
+ expect(world.update().ok).toBe(true);
52
+ const frame = host.value.renderer.draw(frameRequest(attached.value));
53
+ expect(frame.ok).toBe(true);
54
+ if (frame.ok) {
55
+ expect((await host.value.renderer.observe(frame.value, { include: ['timings'] })).ok).toBe(
56
+ true,
57
+ );
58
+ }
59
+ host.value.renderer.dispose();
60
+ }
61
+ });
62
+
39
63
  it('keeps runtime construction errors structured', async () => {
40
64
  const result = await createRenderer(canvas(), { rhi: undefined });
41
65
  expect(result.ok).toBe(false);
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { GpuPassTimingOptions, RendererOptions } from '@forgeax/engine-render';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ describe('runtime GPU pass timing forwarding', () => {
7
+ it('keeps one option object and projects the exact field to Render', () => {
8
+ const gpuPassTiming = { maxPassesPerFrame: 8 } satisfies GpuPassTimingOptions;
9
+ const options = { gpuPassTiming } satisfies RendererOptions;
10
+ expect(options.gpuPassTiming).toBe(gpuPassTiming);
11
+ });
12
+
13
+ it('forwards without owning admission, sessions, handles, or CPU capture', () => {
14
+ const source = readFileSync(
15
+ fileURLToPath(new URL('../createRenderer.ts', import.meta.url)),
16
+ 'utf8',
17
+ );
18
+ expect(source).toContain('gpuPassTiming: options.gpuPassTiming');
19
+ expect(source).not.toContain('createGpuPassTimingSession');
20
+ expect(source).not.toContain('ProfileCapture');
21
+ expect(source).not.toContain('passCatalog');
22
+ });
23
+
24
+ it('keeps the no-option path free of a timing field', () => {
25
+ const source = readFileSync(
26
+ fileURLToPath(new URL('../createRenderer.ts', import.meta.url)),
27
+ 'utf8',
28
+ );
29
+ expect(source).toContain('options === undefined');
30
+ expect(source).toContain('gpuPassTiming: options.gpuPassTiming');
31
+ });
32
+ });
@@ -13,8 +13,74 @@
13
13
 
14
14
  import { createRenderer, EngineEnvironmentError } from '@forgeax/engine-runtime';
15
15
  import { describe, expect, it } from 'vitest';
16
+ import { projectDirectionalShadowInspection } from '../../../render/src/assembly/directional-shadow-inspection';
17
+ import { resolveDirectionalShadowBackendAdmission } from '../../../render/src/render-pipeline';
16
18
 
17
19
  describe('createRenderer without navigator.gpu (browser-no-webgpu)', () => {
20
+ it('requires the shared backend admission truth table for WebGL2 fallback and capable failure', async () => {
21
+ const module = (await import('../../../render/src/render-pipeline')) as Record<string, unknown>;
22
+ const resolver = module.resolveDirectionalShadowBackendAdmission;
23
+ expect(typeof resolver).toBe('function');
24
+ const resolve = resolver as (input: {
25
+ backendKind: 'webgpu' | 'wgpu-webgl2';
26
+ requested: 'pcssMedium' | 'pcssHigh';
27
+ candidate: 'accepted' | 'failed';
28
+ lastKnownGood?: 'pcf3' | 'pcf5';
29
+ }) => {
30
+ effective: string;
31
+ status: string;
32
+ fallbackReason?: string;
33
+ };
34
+ expect(
35
+ resolve({ backendKind: 'wgpu-webgl2', requested: 'pcssMedium', candidate: 'accepted' }),
36
+ ).toMatchObject({
37
+ effective: 'pcf3',
38
+ status: 'fallback',
39
+ fallbackReason: 'webgl2-unsupported',
40
+ });
41
+ expect(
42
+ resolve({
43
+ backendKind: 'webgpu',
44
+ requested: 'pcssHigh',
45
+ candidate: 'failed',
46
+ lastKnownGood: 'pcf3',
47
+ }),
48
+ ).toMatchObject({ effective: 'pcf3', status: 'rejected', fallbackReason: 'candidate-failed' });
49
+ });
50
+
51
+ it('keeps capable candidate failure explicit while retaining the LKG inspection facts', () => {
52
+ const inspection = projectDirectionalShadowInspection({
53
+ admission: resolveDirectionalShadowBackendAdmission({
54
+ backendKind: 'webgpu',
55
+ requested: 'pcssHigh',
56
+ candidate: 'failed',
57
+ lastKnownGood: 'pcf3',
58
+ }),
59
+ cascadeCount: 4,
60
+ mapSize: 2048,
61
+ atlasBytes: 67_108_864,
62
+ writerPasses: 4,
63
+ blockerTaps: 0,
64
+ filterTapUpperBound: 9,
65
+ seamTapUpperBound: 9,
66
+ deviceGeneration: 2,
67
+ graphGeneration: 6,
68
+ error: {
69
+ code: 'shadow-candidate-failed',
70
+ expected: 'the capable backend candidate compiles and validates',
71
+ hint: 'fix the authoring input, then retry or recover',
72
+ },
73
+ });
74
+ expect(inspection).toMatchObject({
75
+ effective: 'pcf3',
76
+ status: 'rejected',
77
+ fallbackReason: 'candidate-failed',
78
+ lastKnownGood: true,
79
+ graphGeneration: 6,
80
+ error: { hint: expect.stringContaining('retry') },
81
+ });
82
+ });
83
+
18
84
  it('navigator.gpu is absent or non-functional in this browser environment', async () => {
19
85
  const nav = globalThis.navigator as { gpu?: GPU };
20
86
  if (nav.gpu === undefined) return;
@@ -62,6 +62,12 @@ const BUFFER_USAGE_MAP_READ = 0x0001;
62
62
  const BUFFER_USAGE_COPY_DST = 0x0008;
63
63
  const MAP_MODE_READ = 0x0001;
64
64
 
65
+ // Windows Dawn first-touch construction for the two renderer/readback probes
66
+ // can exceed Vitest's project-wide 30-second timeout while still completing
67
+ // well inside the nightly platform budget. Keep this owner-specific gate
68
+ // bounded so a valid cold-start probe is not retried as a false failure.
69
+ const SPRITE_NINESLICE_DAWN_TEST_TIMEOUT_MS = 120_000;
70
+
65
71
  // Distinctive 4x4 texture: corners RED (cells (0,0)/(3,0)/(0,3)/(3,3)),
66
72
  // remaining cells BLUE. With slices=[0.25,0.25,0.25,0.25] in atlas-uv space
67
73
  // the 4 corners line up with the 4 corner texels -- making the central band
@@ -325,7 +331,9 @@ function midBandDistance(a: CapturedFrame, b: CapturedFrame): number {
325
331
  }
326
332
 
327
333
  describe('feat-20260527-sprite-nineslice w14 dawn smoke (stretch vs tile divergence)', () => {
328
- it('stretch + tile sprite frames diverge in the mid-band after w15 (red until w15 lands)', async () => {
334
+ it('stretch + tile sprite frames diverge in the mid-band after w15 (red until w15 lands)', {
335
+ timeout: SPRITE_NINESLICE_DAWN_TEST_TIMEOUT_MS,
336
+ }, async () => {
329
337
  // Asymmetric scale: x=4 vs y=1. With slices=[0.25,0.25,0.25,0.25] and the
330
338
  // 4x4 corner-checker texture, the post-w15 9-slice path keeps corner
331
339
  // zones at fixed world dimensions while the legacy pre-w15 path stretches
@@ -55,6 +55,17 @@ const MAP_MODE_READ = 0x0001;
55
55
  const INSTANCE_COUNT = 5;
56
56
  const SPACING = 2.5;
57
57
 
58
+ const SHADOW_FILTER = (() => {
59
+ const value = (
60
+ globalThis as unknown as { process?: { env?: Record<string, string | undefined> } }
61
+ ).process?.env?.FORGEAX_SHADOW_FILTER;
62
+ if (value === 'pcssMedium') return 4;
63
+ if (value === 'pcssHigh') return 5;
64
+ if (value === 'pcf1') return 1;
65
+ if (value === 'pcf5') return 3;
66
+ return 2;
67
+ })();
68
+
58
69
  function buildTranslationGrid(): Float32Array {
59
70
  const out = new Float32Array(INSTANCE_COUNT * 16);
60
71
  const half = ((INSTANCE_COUNT - 1) * SPACING) / 2;
@@ -263,7 +274,9 @@ describe('w10 -- shadow instanced dawn smoke (AC-05 behavioral)', () => {
263
274
  depthBias: 0.005,
264
275
  normalBias: 0.05,
265
276
  shadowDistance: 50,
266
- pcfKernelSize: 3,
277
+ shadowFilter: SHADOW_FILTER,
278
+ shadowAngularRadius: 0.00465,
279
+ maxPenumbraTexels: 32,
267
280
  },
268
281
  });
269
282
 
@@ -285,7 +298,7 @@ describe('w10 -- shadow instanced dawn smoke (AC-05 behavioral)', () => {
285
298
  { component: Instances, data: { transforms: instanceTransforms } },
286
299
  );
287
300
 
288
- // Render 300 frames for temporal stability (PCF shadow, light accumulation)
301
+ // Render 300 frames for temporal stability (PCF or PCSS shadow, light accumulation)
289
302
  const attachment = renderer.attach(world);
290
303
  expect(attachment.ok).toBe(true);
291
304
  if (!attachment.ok) throw attachment.error;
@@ -349,7 +349,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
349
349
  });
350
350
  });
351
351
 
352
- describe('t2 - AC-15 ImageErrorCode exhaustive switch (new member)', () => {
352
+ describe('t2 - AC-15 ImageErrorCode exhaustive switch (new members)', () => {
353
353
  it('ImageErrorCode includes image-hdr-decode-failed', () => {
354
354
  const code: ImageErrorCode = 'image-hdr-decode-failed';
355
355
  expect(code).toBe('image-hdr-decode-failed');
@@ -378,10 +378,17 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
378
378
  return 'atlas size exceeded';
379
379
  case 'atlas-region-mismatch':
380
380
  return 'atlas region mismatch';
381
+ // feat-20260829-deep-agent-game-feedback-resolution M5 —
382
+ // PixelSurface authoring errors remain part of the closed image
383
+ // error vocabulary and must stay covered by every exhaustive
384
+ // consumer switch.
385
+ case 'image-surface-invalid':
386
+ return 'surface invalid';
381
387
  }
382
388
  }
383
389
  expect(exhaustive('image-decode-failed')).toBe('decode failed');
384
390
  expect(exhaustive('image-hdr-decode-failed')).toBe('hdr decode failed');
391
+ expect(exhaustive('image-surface-invalid')).toBe('surface invalid');
385
392
  });
386
393
  });
387
394
  });
@@ -420,6 +427,8 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
420
427
  return 'cleanup failed';
421
428
  case 'scene-data-unavailable':
422
429
  return 'scene data unavailable';
430
+ case 'standard-profile-invalid':
431
+ return 'standard profile invalid';
423
432
  case 'frame-receipt-stale':
424
433
  return 'frame receipt stale';
425
434
  case 'renderer-contract-failed':
@@ -464,14 +473,24 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
464
473
  return 'mesh ssbo capacity exceeded';
465
474
  case 'mesh-ssbo-ceiling-reached':
466
475
  return 'mesh ssbo ceiling reached';
467
- case 'hdrp-light-budget-exceeded':
468
- return 'hdrp light budget exceeded';
469
- case 'hdrp-index-list-overflow':
470
- return 'hdrp index list overflow';
471
- // feat-20260612-hdrp-deferred-shading-learn-render-5-8 M1 / w6:
472
- // 3 new deferred-path error codes.
473
- case 'hdrp-deferred-caps-insufficient':
474
- return 'hdrp deferred caps insufficient';
476
+ case 'render-target-descriptor-invalid':
477
+ return 'render target descriptor invalid';
478
+ case 'render-target-capability-missing':
479
+ return 'render target capability missing';
480
+ case 'render-target-state-invalid':
481
+ return 'render target state invalid';
482
+ case 'render-target-operation-failed':
483
+ return 'render target operation failed';
484
+ case 'reflection-probe-budget-exceeded':
485
+ return 'reflection probe budget exceeded';
486
+ case 'render-intent-invalid':
487
+ return 'render intent invalid';
488
+ case 'standard-light-budget-exceeded':
489
+ return 'standard light budget exceeded';
490
+ case 'standard-cluster-index-overflow':
491
+ return 'standard cluster index overflow';
492
+ case 'standard-cluster-transport-unavailable':
493
+ return 'standard cluster transport unavailable';
475
494
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
476
495
  // bidirectional Skin <-> pbr-skin material mismatch detected at extract.
477
496
  case 'skin-material-mismatch':
@@ -551,16 +570,15 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
551
570
  });
552
571
 
553
572
  /*
554
- * feat-20260612-hdrp-deferred-shading-learn-render-5-8 M1 / w3:
555
- * RuntimeErrorCode +3 members — exhaustive narrow test.
573
+ * Standard lighting closed error codes — exhaustive narrow test.
556
574
  *
557
- * AC-07: three new error codes must be members of RuntimeErrorCode closed union.
575
+ * AC-07: three Standard lighting codes are members of the closed render union.
558
576
  * Each error code narrows without a default branch in switch (err.code).
559
577
  */
560
- describe('RuntimeErrorCode +3 new members (w3)', () => {
561
- it('hdrp-deferred-caps-insufficient is a valid RuntimeErrorCode', () => {
562
- const code: RuntimeLayerErrorCode = 'hdrp-deferred-caps-insufficient';
563
- expect(code).toBe('hdrp-deferred-caps-insufficient');
578
+ describe('Standard lighting error members', () => {
579
+ it('standard-cluster-transport-unavailable is a valid RenderErrorCode', () => {
580
+ const code: RuntimeLayerErrorCode = 'standard-cluster-transport-unavailable';
581
+ expect(code).toBe('standard-cluster-transport-unavailable');
564
582
  });
565
583
 
566
584
  it('exhaustive switch covers all 3 new members alongside existing members', () => {
@@ -591,6 +609,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
591
609
  case 'cleanup-failed':
592
610
  return 'ok';
593
611
  case 'scene-data-unavailable':
612
+ case 'standard-profile-invalid':
594
613
  return 'ok';
595
614
  case 'frame-receipt-stale':
596
615
  return 'ok';
@@ -636,9 +655,9 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
636
655
  return 'ok';
637
656
  case 'mesh-ssbo-ceiling-reached':
638
657
  return 'ok';
639
- case 'hdrp-light-budget-exceeded':
658
+ case 'standard-light-budget-exceeded':
640
659
  return 'ok';
641
- case 'hdrp-index-list-overflow':
660
+ case 'standard-cluster-index-overflow':
642
661
  return 'ok';
643
662
  case 'skin-material-mismatch':
644
663
  return 'ok';
@@ -650,7 +669,19 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
650
669
  return 'ok';
651
670
  case 'joint-entity-dangling':
652
671
  return 'ok';
653
- case 'hdrp-deferred-caps-insufficient':
672
+ case 'standard-cluster-transport-unavailable':
673
+ return 'ok';
674
+ case 'render-target-descriptor-invalid':
675
+ return 'ok';
676
+ case 'render-target-capability-missing':
677
+ return 'ok';
678
+ case 'render-target-state-invalid':
679
+ return 'ok';
680
+ case 'render-target-operation-failed':
681
+ return 'ok';
682
+ case 'reflection-probe-budget-exceeded':
683
+ return 'ok';
684
+ case 'render-intent-invalid':
654
685
  return 'ok';
655
686
  case 'point-shadow-atlas-uninitialized':
656
687
  return 'ok';
@@ -702,7 +733,7 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
702
733
  return 'ok';
703
734
  }
704
735
  }
705
- expect(exhaustive('hdrp-deferred-caps-insufficient')).toBe('ok');
736
+ expect(exhaustive('standard-cluster-transport-unavailable')).toBe('ok');
706
737
  expect(exhaustive('point-shadow-atlas-uninitialized')).toBe('ok');
707
738
  expect(exhaustive('point-shadow-atlas-bounds-violation')).toBe('ok');
708
739
  });