@forgeax/engine-runtime 0.1.7 → 0.1.19

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 (49) hide show
  1. package/README.md +45 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/helpers/standard-material-manifest.d.ts +12 -0
  4. package/dist/__tests__/helpers/standard-material-manifest.d.ts.map +1 -0
  5. package/dist/__tests__/render-environment-consumer.integration.test.d.ts +2 -0
  6. package/dist/__tests__/render-environment-consumer.integration.test.d.ts.map +1 -0
  7. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  8. package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -1
  9. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts +2 -0
  10. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts.map +1 -0
  11. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts +2 -0
  12. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts.map +1 -0
  13. package/dist/backend-selection.d.ts.map +1 -1
  14. package/dist/index.mjs +20 -1
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/renderer-host.d.ts +2 -1
  17. package/dist/renderer-host.d.ts.map +1 -1
  18. package/dist/renderer-host.mjs +21 -2
  19. package/dist/renderer-host.mjs.map +1 -1
  20. package/package.json +30 -30
  21. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
  22. package/src/__tests__/errors.unit.test.ts +36 -0
  23. package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
  24. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
  25. package/src/__tests__/geometry.unit.test.ts +3 -1
  26. package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
  27. package/src/__tests__/material-texture-uv-scale.unit.test.ts +8 -14
  28. package/src/__tests__/materials.unit.test.ts +2 -0
  29. package/src/__tests__/pbr-pipeline.unit.test.ts +8 -8
  30. package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
  31. package/src/__tests__/pipeline.unit.test.ts +5 -2
  32. package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
  33. package/src/__tests__/render-error-exhaustive.test-d.ts +57 -0
  34. package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
  35. package/src/__tests__/render-system-mega.test.ts +3 -1
  36. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +3 -1
  37. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +3 -1
  38. package/src/__tests__/render-system-record.test.ts +8 -6
  39. package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
  40. package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
  41. package/src/__tests__/renderer-surface.unit.test.ts +66 -0
  42. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
  43. package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
  44. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +383 -0
  45. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +7 -7
  46. package/src/__tests__/ssao-passes.test.ts +28 -12
  47. package/src/__tests__/systems.unit.test.ts +48 -31
  48. package/src/backend-selection.ts +4 -0
  49. package/src/renderer-host.ts +30 -3
@@ -197,6 +197,8 @@ describe('Materials.standard multi-pass (w14)', () => {
197
197
  { name: 'specularTintTexture', type: 'texture', optional: true },
198
198
  { name: 'emissiveTexture', type: 'texture', optional: true },
199
199
  { name: 'occlusionTexture', type: 'texture', optional: true },
200
+ { name: 'transmissionTexture', type: 'texture', optional: true },
201
+ { name: 'thicknessTexture', type: 'texture', optional: true },
200
202
  ]);
201
203
  });
202
204
 
@@ -82,22 +82,22 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
82
82
  });
83
83
 
84
84
  describe('pbr-material-merged', () => {
85
- it('24 entries: user-region 13 (derived) + ibl 7 + lightmap 4', () => {
85
+ it('26 entries: user-region 17 (derived) + ibl 7 + transmission 2', () => {
86
86
  const spec = makeSpec();
87
87
  const out = buildBindGroupLayoutDescriptor(spec, {
88
88
  kind: 'pbr-material-merged',
89
89
  });
90
90
  // Post-M2 (D-1): user-region comes from derive(paramSchema).bglEntries
91
- // (built-in standard-PBR 4-texture fallback), then IBL + lightmap are
92
- // appended at start = userRegion.length.
91
+ // (built-in standard-PBR 8-texture contract), then IBL + transmission
92
+ // are appended at start = userRegion.length.
93
93
  const userRegion = buildPbrMaterialUserRegionEntries();
94
94
  const afterIbl = [...userRegion, ...appendInjection(userRegion, 'ibl')];
95
95
  const expected = {
96
96
  label: 'pbr-material-skylight-bgl',
97
- entries: [...afterIbl, ...appendInjection(afterIbl, 'lightmap')],
97
+ entries: [...afterIbl, ...appendInjection(afterIbl, 'transmission')],
98
98
  };
99
99
  expect(out).toEqual(expected);
100
- expect(out.entries.length).toBe(24);
100
+ expect(out.entries.length).toBe(26);
101
101
  });
102
102
  });
103
103
 
@@ -142,7 +142,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
142
142
  entries: [
143
143
  {
144
144
  binding: 0,
145
- visibility: 0x1,
145
+ visibility: 0x3,
146
146
  buffer: { type: 'read-only-storage', hasDynamicOffset: false },
147
147
  },
148
148
  ],
@@ -185,7 +185,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
185
185
  });
186
186
 
187
187
  describe('unlit-material', () => {
188
- it('13 entries: base PBR material only (no skylight injection)', () => {
188
+ it('17 entries: base PBR material only (no skylight injection)', () => {
189
189
  const spec = makeSpec();
190
190
  const out = buildBindGroupLayoutDescriptor(spec, {
191
191
  kind: 'unlit-material',
@@ -194,7 +194,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
194
194
  label: 'unlit-material-bgl',
195
195
  entries: buildPbrMaterialUserRegionEntries(),
196
196
  });
197
- expect(out.entries.length).toBe(13);
197
+ expect(out.entries.length).toBe(17);
198
198
  });
199
199
  });
200
200
  });
@@ -127,14 +127,16 @@ describe('cacheKeyOf passKind dimension', () => {
127
127
  describe('PassKind open string + KNOWN_PASS_KINDS (feat-20260615 D-10)', () => {
128
128
  // M2-T4 expanded KNOWN_PASS_KINDS from 4 to 6 entries by adding 'post-process'
129
129
  // and 'skybox'; point-shadow-caster is the engine's depth-only point-light
130
- // pass and is part of the discoverable catalogue as well.
131
- it('KNOWN_PASS_KINDS has exactly 7 entries', () => {
132
- expect(KNOWN_PASS_KINDS).toHaveLength(7);
130
+ // pass and is part of the discoverable catalogue as well. M3-T4 adds the
131
+ // Standard temporal producer to the shipped pass catalogue.
132
+ it('KNOWN_PASS_KINDS has exactly 8 entries', () => {
133
+ expect(KNOWN_PASS_KINDS).toHaveLength(8);
133
134
  });
134
135
 
135
- it('KNOWN_PASS_KINDS contains the 7 engine-shipped pass kinds', () => {
136
+ it('KNOWN_PASS_KINDS contains the 8 engine-shipped pass kinds', () => {
136
137
  expect(KNOWN_PASS_KINDS).toContain('forward');
137
138
  expect(KNOWN_PASS_KINDS).toContain('deferred');
139
+ expect(KNOWN_PASS_KINDS).toContain('temporal');
138
140
  expect(KNOWN_PASS_KINDS).toContain('lighting');
139
141
  expect(KNOWN_PASS_KINDS).toContain('shadow-caster');
140
142
  expect(KNOWN_PASS_KINDS).toContain('point-shadow-caster');
@@ -138,6 +138,7 @@ import {
138
138
  import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
139
139
  import { matchPass } from '../../../render/src/systems/pass-selector';
140
140
  import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
141
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
141
142
 
142
143
  function makeExplicitNullRhi(spies: {
143
144
  setIndexBuffer: ReturnType<typeof vi.fn>;
@@ -2407,7 +2408,8 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
2407
2408
  sourcePath: `${identifier}.wgsl`,
2408
2409
  composedWgsl: '/* stub */',
2409
2410
  paramSchema: '[]',
2410
- variants: [],
2411
+ variants:
2412
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
2411
2413
  });
2412
2414
  const manifest = {
2413
2415
  schemaVersion: '1.0.0',
@@ -2697,7 +2699,8 @@ vi.mock('@forgeax/engine-rhi-wgpu', () => {
2697
2699
  sourcePath: `${identifier}.wgsl`,
2698
2700
  composedWgsl: '/* stub */',
2699
2701
  paramSchema: '[]',
2700
- variants: [],
2702
+ variants:
2703
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
2701
2704
  });
2702
2705
  const manifest = {
2703
2706
  schemaVersion: '1.0.0',
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const environmentSource = readFileSync(
6
+ fileURLToPath(new URL('../../../render/src/extract/environment.ts', import.meta.url)),
7
+ 'utf8',
8
+ );
9
+ const vfxSource = readFileSync(
10
+ fileURLToPath(
11
+ new URL('../../../vfx-render/src/feature/gpu-particle-feature.ts', import.meta.url),
12
+ ),
13
+ 'utf8',
14
+ );
15
+
16
+ describe('runtime Environment consumer ownership', () => {
17
+ it('consumes extracted Environment facts without a second Fog resource topology', () => {
18
+ expect(environmentSource).toContain('createEnvironmentExtractionContext');
19
+ expect(environmentSource).not.toContain('createFullscreenRenderFeature');
20
+ expect(vfxSource).not.toMatch(/fog.*(history|counter|upload)/i);
21
+ });
22
+
23
+ it('indexes the public recovery sequence without exposing graph or device state', () => {
24
+ const runtimeReadme = readFileSync(
25
+ fileURLToPath(new URL('../../README.md', import.meta.url)),
26
+ 'utf8',
27
+ );
28
+ expect(runtimeReadme).toContain('inspect()');
29
+ expect(runtimeReadme).toContain('typed `detail`');
30
+ expect(runtimeReadme).toMatch(/retry the same\s+request/);
31
+ expect(runtimeReadme).toContain('graph, device, or');
32
+ });
33
+ });
@@ -38,14 +38,30 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
38
38
  return code;
39
39
  case 'cleanup-failed':
40
40
  return code;
41
+ case 'taa-unavailable':
42
+ return code;
41
43
  case 'frame-receipt-stale':
42
44
  return code;
43
45
  case 'renderer-contract-failed':
44
46
  return code;
45
47
  case 'observation-unavailable':
46
48
  return code;
49
+ case 'scene-data-unavailable':
50
+ return code;
47
51
  case 'shadow-invalid-config':
48
52
  return code;
53
+ case 'environment-source-conflict':
54
+ return code;
55
+ case 'fog-cardinality':
56
+ return code;
57
+ case 'taa-caps-insufficient':
58
+ return code;
59
+ case 'environment-generation-failed':
60
+ return code;
61
+ case 'atmosphere-invalid-parameter':
62
+ return code;
63
+ case 'owner-stage-failed':
64
+ return code;
49
65
  case 'equirect-projection-failed':
50
66
  return code;
51
67
  case 'hdrp-light-budget-exceeded':
@@ -84,6 +100,8 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
84
100
  return code;
85
101
  case 'render-feature-draw-recording-failed':
86
102
  return code;
103
+ case 'transmission-capability-missing':
104
+ return code;
87
105
  case 'points-lines-invalid-style':
88
106
  return code;
89
107
  case 'points-lines-topology-mismatch':
@@ -171,10 +189,41 @@ function narrowRenderError(err: RenderError): void {
171
189
  void err.detail.reason;
172
190
  void err.detail.recovery;
173
191
  break;
192
+ case 'scene-data-unavailable':
193
+ void err.detail.featureIdentity;
194
+ void err.detail.schema;
195
+ void err.detail.lane;
196
+ void err.detail.reason;
197
+ void err.detail.missingContributorIds;
198
+ void err.detail.omittedMissingContributorCount;
199
+ void err.detail.recovery;
200
+ break;
174
201
  case 'shadow-invalid-config':
175
202
  void err.detail.field; // string
176
203
  void err.detail.value; // number
177
204
  break;
205
+ case 'environment-source-conflict':
206
+ void err.detail.owners;
207
+ break;
208
+ case 'fog-cardinality':
209
+ void err.detail.count;
210
+ break;
211
+ case 'taa-caps-insufficient':
212
+ void err.detail.required;
213
+ void err.detail.available;
214
+ break;
215
+ case 'environment-generation-failed':
216
+ void err.detail.sourceKey;
217
+ void err.detail.stage;
218
+ break;
219
+ case 'atmosphere-invalid-parameter':
220
+ void err.detail.field;
221
+ void err.detail.value;
222
+ break;
223
+ case 'owner-stage-failed':
224
+ void err.detail.owner;
225
+ void err.detail.stage;
226
+ break;
178
227
  case 'equirect-projection-failed':
179
228
  void err.detail.handle; // number
180
229
  break;
@@ -247,6 +296,9 @@ function narrowRenderError(err: RenderError): void {
247
296
  void err.detail.backendReason;
248
297
  void err.detail.operation;
249
298
  break;
299
+ case 'taa-unavailable':
300
+ void err.detail.reason;
301
+ break;
250
302
  case 'vertex-color-variant-conflict':
251
303
  void err.detail.authored;
252
304
  void err.detail.authoredValue;
@@ -276,6 +328,11 @@ function narrowRenderError(err: RenderError): void {
276
328
  void err.detail.owner;
277
329
  void err.detail.generation;
278
330
  break;
331
+ case 'transmission-capability-missing':
332
+ void err.detail.material;
333
+ void err.detail.capability;
334
+ void err.detail.stage;
335
+ break;
279
336
  default: {
280
337
  const exhaustive: never = err;
281
338
  void exhaustive;
@@ -8,6 +8,7 @@ import type {
8
8
  import { Camera } from '@forgeax/engine-render';
9
9
  import { Transform } from '@forgeax/engine-scene';
10
10
  import { ok } from '@forgeax/engine-types';
11
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
11
12
 
12
13
  export type PreparedFeatureMode = 'accepted' | 'empty' | 'mismatch' | 'recovery';
13
14
 
@@ -19,6 +20,15 @@ export const preparedManifest = `data:application/json,${encodeURIComponent(
19
20
  { hash: 'unlit000', wgsl: '/* unlit stub */', glsl: '', bindings: '' },
20
21
  { hash: 'tonemap0', wgsl: '/* tonemap stub */', glsl: '', bindings: '' },
21
22
  ],
23
+ materialShaders: [
24
+ {
25
+ identifier: 'forgeax::default-standard-pbr',
26
+ sourcePath: 'forgeax::default-standard-pbr.wgsl',
27
+ composedWgsl: '/* stub */',
28
+ paramSchema: '[]',
29
+ variants: standardMaterialShaderVariants(),
30
+ },
31
+ ],
22
32
  }),
23
33
  )}`;
24
34
 
@@ -20,6 +20,7 @@ import { Transform } from '@forgeax/engine-scene';
20
20
  import type { Handle } from '@forgeax/engine-types';
21
21
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
22
  import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
23
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
23
24
  import { drawWithOwners } from './renderer-test-utils';
24
25
 
25
26
  type RendererErrorObservation = {
@@ -278,7 +279,8 @@ function drawPublished(renderer: RendererType, world: WorldType) {
278
279
  sourcePath: `${identifier}.wgsl`,
279
280
  composedWgsl: '/* stub */',
280
281
  paramSchema: '[]',
281
- variants: [],
282
+ variants:
283
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
282
284
  });
283
285
  const manifest = {
284
286
  schemaVersion: '1.0.0',
@@ -33,6 +33,7 @@ import type { World as WorldType } from '@forgeax/engine-ecs';
33
33
  import type { Renderer as RendererType } from '@forgeax/engine-render';
34
34
  import type { Handle, MaterialAsset, MeshAsset, TextureAsset } from '@forgeax/engine-types';
35
35
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
36
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
36
37
 
37
38
  function canonicalTestMeshAttributes(vertexCount: number) {
38
39
  return {
@@ -195,7 +196,8 @@ function buildManifestDataUrl(): string {
195
196
  sourcePath: `${identifier}.wgsl`,
196
197
  composedWgsl: '/* stub */',
197
198
  paramSchema,
198
- variants: [],
199
+ variants:
200
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
199
201
  });
200
202
  // The standard-PBR shader declares baseColorTexture / metallicRoughnessTexture
201
203
  // / normalTexture as texture2d params. Texture resolution is now schema-driven
@@ -26,6 +26,7 @@ import type { World as WorldType } from '@forgeax/engine-ecs';
26
26
  import type { Renderer as RendererType } from '@forgeax/engine-render';
27
27
  import type { Handle, MaterialAsset, MeshAsset, TextureAsset } from '@forgeax/engine-types';
28
28
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
29
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
29
30
 
30
31
  function canonicalTestMeshAttributes(vertexCount: number) {
31
32
  return {
@@ -161,7 +162,8 @@ function buildManifestDataUrl(): string {
161
162
  sourcePath: `${identifier}.wgsl`,
162
163
  composedWgsl: '/* stub */',
163
164
  paramSchema,
164
- variants: [],
165
+ variants:
166
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
165
167
  });
166
168
  const pbrParamSchema = JSON.stringify([
167
169
  { name: 'baseColor', type: 'color', default: [1, 1, 1, 1] },
@@ -55,7 +55,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
55
55
  // asserts the PBR baseline against literal byte values.
56
56
  //
57
57
  // Coverage:
58
- // - 304 B payload (STANDARD_PBR_UBO_SIZE)
58
+ // - 384 B payload (STANDARD_PBR_UBO_SIZE)
59
59
  // - slot 0 = baseColor.rgb + 1 (alpha hardcoded)
60
60
  // - slot 1 first 8 B = metallic, roughness (f32x2)
61
61
  // - slot 1 last 4 u32 (channelMap) = [2, 1, 0, 0]
@@ -108,7 +108,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
108
108
  expect(typeof mod.buildPbrMaterialUboPayload).toBe('function');
109
109
  });
110
110
 
111
- it('304 B payload size + standard PBR slot layout (baseline)', () => {
111
+ it('384 B payload size + standard PBR slot layout (baseline)', () => {
112
112
  if (typeof mod.buildPbrMaterialUboPayload !== 'function') {
113
113
  throw new Error('helper not exported yet');
114
114
  }
@@ -124,7 +124,7 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
124
124
  specularTint: [0.11, 0.22, 0.33],
125
125
  });
126
126
  const buf = mod.buildPbrMaterialUboPayload(snap);
127
- expect(buf.byteLength).toBe(304);
127
+ expect(buf.byteLength).toBe(384);
128
128
  const f32 = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
129
129
  // feat-20260613 fix-issue-1 (D-8 channelMap split): the 4 channelMap
130
130
  // u32 slots collapse into 4 independent f32 channel selectors at
@@ -161,7 +161,9 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
161
161
  expect(f32[20]).toBeCloseTo(0.11);
162
162
  expect(f32[21]).toBeCloseTo(0.22);
163
163
  expect(f32[22]).toBeCloseTo(0.33);
164
- expect(f32[72]).toBe(1);
164
+ // normalScale remains at the scalar end of the authored region after
165
+ // transmission fields moved the engine-owned coordinate records.
166
+ expect(f32[23]).toBe(1);
165
167
  });
166
168
 
167
169
  it('writes schema-carried alphaCutoff into the baseline PBR payload', () => {
@@ -180,15 +182,15 @@ import { extractFrame, prepareExtractContext } from '../../../render/src/render-
180
182
  const apply = mod.applyMaterialTextureUvScales;
181
183
  if (typeof apply !== 'function') throw new Error('helper not exported yet');
182
184
  const world = new World();
183
- const pbrPayload = new ArrayBuffer(304);
185
+ const pbrPayload = new ArrayBuffer(384);
184
186
  apply(pbrPayload, makePbrSnapshot({}), world);
185
187
  const pbrF32 = new Float32Array(pbrPayload);
186
- expect(pbrF32[26]).toBe(1);
187
188
  expect(pbrF32[34]).toBe(1);
188
189
  expect(pbrF32[42]).toBe(1);
189
190
  expect(pbrF32[50]).toBe(1);
190
191
  expect(pbrF32[58]).toBe(1);
191
192
  expect(pbrF32[66]).toBe(1);
193
+ expect(pbrF32[74]).toBe(1);
192
194
 
193
195
  const spritePayload = new ArrayBuffer(128);
194
196
  apply(
@@ -0,0 +1,112 @@
1
+ import { EngineEnvironmentError } from '@forgeax/engine-render/internal/construct-renderer';
2
+ import { RhiError } from '@forgeax/engine-rhi';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ constructRendererHost: vi.fn(),
7
+ loadBackendPack: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@forgeax/engine-render/internal/construct-renderer', async (importOriginal) => {
11
+ const actual =
12
+ await importOriginal<typeof import('@forgeax/engine-render/internal/construct-renderer')>();
13
+ return { ...actual, constructRendererHost: mocks.constructRendererHost };
14
+ });
15
+
16
+ vi.mock('../backend-selection', async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import('../backend-selection')>();
18
+ return { ...actual, loadBackendPack: mocks.loadBackendPack };
19
+ });
20
+
21
+ import { constructRuntimeRendererHost } from '../renderer-host';
22
+
23
+ function environmentError(code: 'adapter-unavailable' | 'limit-exceeded') {
24
+ return new EngineEnvironmentError('no usable rendering backend', {
25
+ webgpuError: new RhiError({
26
+ code,
27
+ expected: 'a usable WebGPU backend',
28
+ hint: 'select another backend',
29
+ }),
30
+ });
31
+ }
32
+
33
+ function canvas() {
34
+ return { getContext: vi.fn((_kind?: string) => null) };
35
+ }
36
+
37
+ describe('runtime renderer backend fallback boundary', () => {
38
+ beforeEach(() => {
39
+ vi.resetAllMocks();
40
+ });
41
+
42
+ it('preserves ordinary construction errors and does not enter Channel 3', async () => {
43
+ const firstPack = { name: 'webgpu' };
44
+ const fallbackPack = { name: 'wgpu' };
45
+ const target = canvas();
46
+ const ordinaryError = new Error('shader compile failed');
47
+ mocks.loadBackendPack
48
+ .mockResolvedValueOnce({ ok: true, value: firstPack })
49
+ .mockResolvedValueOnce({ ok: true, value: fallbackPack });
50
+ mocks.constructRendererHost.mockImplementation(
51
+ async (hostCanvas: typeof target, _options: unknown, _bundler: unknown, pack: unknown) => {
52
+ if (pack === fallbackPack) hostCanvas.getContext('webgl2');
53
+ return { ok: false, error: ordinaryError };
54
+ },
55
+ );
56
+
57
+ const result = await constructRuntimeRendererHost(target);
58
+
59
+ expect(result).toEqual({ ok: false, error: ordinaryError });
60
+ expect(mocks.loadBackendPack).toHaveBeenCalledTimes(1);
61
+ expect(mocks.constructRendererHost).toHaveBeenCalledTimes(1);
62
+ expect(target.getContext).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('uses one Channel 3 attempt for an environment-class WebGPU failure', async () => {
66
+ const firstPack = { name: 'webgpu' };
67
+ const fallbackPack = { name: 'wgpu' };
68
+ const target = canvas();
69
+ const environmentFailure = environmentError('adapter-unavailable');
70
+ mocks.loadBackendPack
71
+ .mockResolvedValueOnce({ ok: true, value: firstPack })
72
+ .mockResolvedValueOnce({ ok: true, value: fallbackPack });
73
+ mocks.constructRendererHost
74
+ .mockResolvedValueOnce({ ok: false, error: environmentFailure })
75
+ .mockImplementationOnce(async (hostCanvas: typeof target, _options, _bundler, pack) => {
76
+ if (pack === fallbackPack) hostCanvas.getContext('webgl2');
77
+ return { ok: true, value: {} };
78
+ });
79
+
80
+ const result = await constructRuntimeRendererHost(target);
81
+
82
+ expect(result.ok).toBe(true);
83
+ expect(mocks.loadBackendPack).toHaveBeenCalledTimes(2);
84
+ expect(mocks.loadBackendPack).toHaveBeenNthCalledWith(2, undefined, true);
85
+ expect(mocks.constructRendererHost).toHaveBeenNthCalledWith(
86
+ 2,
87
+ target,
88
+ undefined,
89
+ undefined,
90
+ fallbackPack,
91
+ );
92
+ expect(target.getContext).toHaveBeenCalledOnce();
93
+ });
94
+
95
+ it('never falls back for an explicitly injected RHI and retains structured detail', async () => {
96
+ const firstPack = { name: 'explicit' };
97
+ const target = canvas();
98
+ const environmentFailure = environmentError('limit-exceeded');
99
+ const explicitRhi = {};
100
+ mocks.loadBackendPack.mockResolvedValueOnce({ ok: true, value: firstPack });
101
+ mocks.constructRendererHost.mockResolvedValueOnce({ ok: false, error: environmentFailure });
102
+
103
+ const result = await constructRuntimeRendererHost(target, { rhi: explicitRhi as never });
104
+
105
+ expect(result).toEqual({ ok: false, error: environmentFailure });
106
+ expect(result.ok ? undefined : result.error).toBe(environmentFailure);
107
+ expect(environmentFailure.detail.webgpuError).toMatchObject({ code: 'limit-exceeded' });
108
+ expect(mocks.loadBackendPack).toHaveBeenCalledTimes(1);
109
+ expect(mocks.constructRendererHost).toHaveBeenCalledTimes(1);
110
+ expect(target.getContext).not.toHaveBeenCalled();
111
+ });
112
+ });
@@ -1,6 +1,7 @@
1
1
  import { World } from '@forgeax/engine-ecs';
2
2
  import { rhi } from '@forgeax/engine-rhi-null';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
4
5
  import { requireRenderer } from './renderer-test-utils';
5
6
 
6
7
  function canvas(): HTMLCanvasElement {
@@ -15,6 +16,15 @@ const manifest = `data:application/json,${encodeURIComponent(
15
16
  { hash: 'unlit000', wgsl: '/* unlit */', glsl: '', bindings: '' },
16
17
  { hash: 'tonemap0', wgsl: '/* tonemap */', glsl: '', bindings: '' },
17
18
  ],
19
+ materialShaders: [
20
+ {
21
+ identifier: 'forgeax::default-standard-pbr',
22
+ sourcePath: 'forgeax::default-standard-pbr.wgsl',
23
+ composedWgsl: '/* stub */',
24
+ paramSchema: '[]',
25
+ variants: standardMaterialShaderVariants(),
26
+ },
27
+ ],
18
28
  }),
19
29
  )}`;
20
30
 
@@ -345,6 +345,72 @@ describe('Surface retry (w5)', () => {
345
345
  expect(ctxCalls.n).toBe(1);
346
346
  expect(cfgCalls.n).toBe(0);
347
347
  });
348
+
349
+ it('keeps an existing configured LKG when a reconfigure candidate fails proof validation', () => {
350
+ const cfgCalls = { n: 0 };
351
+ const ctxCalls = { n: 0 };
352
+ const ps = makePipelineState();
353
+ const reg = new HealthListenerRegistry();
354
+ const mockCtx = {
355
+ ...makeSurfaceCtx(cfgCalls, ctxCalls, 1),
356
+ presentationProof: { descriptor: true, acquisition: false, validation: true },
357
+ };
358
+ const pipelineState = ps as unknown as Parameters<typeof acquireSwapChainTarget>[1];
359
+ const dev = makeMockDevice(ps);
360
+ dev.caps = { backendKind: 'wgpu-webgl2', storageBuffer: false };
361
+ const errors: unknown[] = [];
362
+
363
+ // biome-ignore lint/suspicious/noExplicitAny: mock internals
364
+ const internals: any = {
365
+ canvas: { width: 800, height: 600 },
366
+ device: dev,
367
+ context: mockCtx,
368
+ getPipelineState: () => ps,
369
+ errorRegistry: {
370
+ add: () => () => {},
371
+ fire: (error: unknown) => errors.push(error),
372
+ clear: () => {},
373
+ },
374
+ healthRegistry: reg,
375
+ };
376
+
377
+ const target = acquireSwapChainTarget(internals, pipelineState);
378
+
379
+ expect(target).toBeNull();
380
+ expect(cfgCalls.n).toBe(1);
381
+ expect(ctxCalls.n).toBe(1);
382
+ expect(ps.perPassResources.configured).toBe(true);
383
+ expect(errors).toHaveLength(1);
384
+ });
385
+
386
+ it('keeps the first surface failure unconfigured and skips the retry', () => {
387
+ const cfgCalls = { n: 0 };
388
+ const ctxCalls = { n: 0 };
389
+ const ps = makePipelineState();
390
+ ps.perPassResources.configured = false;
391
+ const mockCtx = {
392
+ ...makeSurfaceCtx(cfgCalls, ctxCalls, 1),
393
+ presentationProof: { descriptor: true, acquisition: false, validation: true },
394
+ };
395
+ const pipelineState = ps as unknown as Parameters<typeof acquireSwapChainTarget>[1];
396
+ const dev = makeMockDevice(ps);
397
+ dev.caps = { backendKind: 'wgpu-webgl2', storageBuffer: false };
398
+
399
+ // biome-ignore lint/suspicious/noExplicitAny: mock internals
400
+ const internals: any = {
401
+ canvas: { width: 800, height: 600 },
402
+ device: dev,
403
+ context: mockCtx,
404
+ getPipelineState: () => ps,
405
+ errorRegistry: { add: () => () => {}, fire: () => {}, clear: () => {} },
406
+ healthRegistry: new HealthListenerRegistry(),
407
+ };
408
+
409
+ expect(acquireSwapChainTarget(internals, pipelineState)).toBeNull();
410
+ expect(cfgCalls.n).toBe(1);
411
+ expect(ctxCalls.n).toBe(1);
412
+ expect(ps.perPassResources.configured).toBe(false);
413
+ });
348
414
  });
349
415
 
350
416
  // ── w6: consecutive surface failure -> internal-fault (AC-04) ────────────────
@@ -21,6 +21,7 @@ import type { World as WorldType } from '@forgeax/engine-ecs';
21
21
  import type { Renderer as RendererType } from '@forgeax/engine-render';
22
22
  import type { Handle } from '@forgeax/engine-types';
23
23
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
24
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
24
25
 
25
26
  const ENGINE = '../createRenderer';
26
27
 
@@ -152,7 +153,10 @@ function buildManifestDataUrl(): string {
152
153
  sourcePath: `${identifier}.wgsl`,
153
154
  composedWgsl,
154
155
  paramSchema: '[]',
155
- variants: [],
156
+ variants:
157
+ identifier === 'forgeax::default-standard-pbr'
158
+ ? standardMaterialShaderVariants(composedWgsl)
159
+ : [],
156
160
  });
157
161
  const manifest = {
158
162
  schemaVersion: '1.0.0',
@@ -28,6 +28,7 @@ import type { World as WorldType } from '@forgeax/engine-ecs';
28
28
  import type { Renderer as RendererType } from '@forgeax/engine-render';
29
29
  import type { Handle } from '@forgeax/engine-types';
30
30
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
31
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
31
32
 
32
33
  const ENGINE = '../createRenderer';
33
34
 
@@ -143,7 +144,10 @@ function buildManifestDataUrl(): string {
143
144
  sourcePath: `${identifier}.wgsl`,
144
145
  composedWgsl,
145
146
  paramSchema: '[]',
146
- variants: [],
147
+ variants:
148
+ identifier === 'forgeax::default-standard-pbr'
149
+ ? standardMaterialShaderVariants(composedWgsl)
150
+ : [],
147
151
  });
148
152
  const manifest = {
149
153
  schemaVersion: '1.0.0',