@forgeax/engine-runtime 0.1.7 → 0.1.20

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 (88) 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__/helpers/tilemap-assets.d.ts.map +1 -1
  6. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  7. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-environment-consumer.integration.test.d.ts +2 -0
  9. package/dist/__tests__/render-environment-consumer.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  11. package/dist/__tests__/render-feature-prepared-graphics.fixture.d.ts.map +1 -1
  12. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts +2 -0
  13. package/dist/__tests__/renderer-host-fallback.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts +2 -0
  15. package/dist/__tests__/skinned-shadow-mixed.dawn.test.d.ts.map +1 -0
  16. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  17. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  18. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  19. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  20. package/dist/backend-selection.d.ts.map +1 -1
  21. package/dist/createRenderer.d.ts.map +1 -1
  22. package/dist/index.mjs +21 -1
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/renderer-host.d.ts +2 -1
  25. package/dist/renderer-host.d.ts.map +1 -1
  26. package/dist/renderer-host.mjs +21 -2
  27. package/dist/renderer-host.mjs.map +1 -1
  28. package/package.json +30 -30
  29. package/src/__tests__/asset-registry.recursive.spec.ts +12 -18
  30. package/src/__tests__/asset.unit.test.ts +28 -25
  31. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  32. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  33. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
  34. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
  35. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  36. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  37. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  38. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  39. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  40. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  41. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  42. package/src/__tests__/errors.unit.test.ts +56 -0
  43. package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
  44. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  45. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
  46. package/src/__tests__/geometry.unit.test.ts +3 -1
  47. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  48. package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
  49. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  50. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
  51. package/src/__tests__/lights.unit.test.ts +38 -17
  52. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  53. package/src/__tests__/loader-name-survival.test.ts +2 -3
  54. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  55. package/src/__tests__/material-texture-uv-scale.unit.test.ts +18 -16
  56. package/src/__tests__/materials.unit.test.ts +2 -0
  57. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  58. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  59. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  60. package/src/__tests__/pbr-pipeline.unit.test.ts +31 -12
  61. package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
  62. package/src/__tests__/pipeline.unit.test.ts +23 -21
  63. package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
  64. package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
  65. package/src/__tests__/render-error-exhaustive.test-d.ts +86 -0
  66. package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
  67. package/src/__tests__/render-system-extract.test.ts +4 -6
  68. package/src/__tests__/render-system-mega.test.ts +3 -1
  69. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +7 -7
  70. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +5 -4
  71. package/src/__tests__/render-system-record.test.ts +8 -6
  72. package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
  73. package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
  74. package/src/__tests__/renderer-surface.unit.test.ts +66 -0
  75. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
  76. package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
  77. package/src/__tests__/shadow-fields-observable.dawn.test.ts +56 -39
  78. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +385 -0
  79. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  80. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -9
  81. package/src/__tests__/ssao-passes.test.ts +28 -12
  82. package/src/__tests__/systems.unit.test.ts +63 -33
  83. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  84. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  85. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  86. package/src/backend-selection.ts +4 -0
  87. package/src/createRenderer.ts +3 -0
  88. package/src/renderer-host.ts +30 -3
@@ -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':
@@ -96,6 +114,16 @@ function exhaustiveSwitchOnRenderCode(code: RenderErrorCode): string {
96
114
  return code;
97
115
  case 'points-lines-prepare-failed':
98
116
  return code;
117
+ case 'projector-binding-failed':
118
+ return code;
119
+ case 'volume-owner-conflict':
120
+ return code;
121
+ case 'volume-density-shape-mismatch':
122
+ return code;
123
+ case 'volume-invalid-bounds':
124
+ return code;
125
+ case 'volume-invalid-parameters':
126
+ return code;
99
127
  default: {
100
128
  const exhaustive: never = code;
101
129
  return exhaustive;
@@ -171,10 +199,41 @@ function narrowRenderError(err: RenderError): void {
171
199
  void err.detail.reason;
172
200
  void err.detail.recovery;
173
201
  break;
202
+ case 'scene-data-unavailable':
203
+ void err.detail.featureIdentity;
204
+ void err.detail.schema;
205
+ void err.detail.lane;
206
+ void err.detail.reason;
207
+ void err.detail.missingContributorIds;
208
+ void err.detail.omittedMissingContributorCount;
209
+ void err.detail.recovery;
210
+ break;
174
211
  case 'shadow-invalid-config':
175
212
  void err.detail.field; // string
176
213
  void err.detail.value; // number
177
214
  break;
215
+ case 'environment-source-conflict':
216
+ void err.detail.owners;
217
+ break;
218
+ case 'fog-cardinality':
219
+ void err.detail.count;
220
+ break;
221
+ case 'taa-caps-insufficient':
222
+ void err.detail.required;
223
+ void err.detail.available;
224
+ break;
225
+ case 'environment-generation-failed':
226
+ void err.detail.sourceKey;
227
+ void err.detail.stage;
228
+ break;
229
+ case 'atmosphere-invalid-parameter':
230
+ void err.detail.field;
231
+ void err.detail.value;
232
+ break;
233
+ case 'owner-stage-failed':
234
+ void err.detail.owner;
235
+ void err.detail.stage;
236
+ break;
178
237
  case 'equirect-projection-failed':
179
238
  void err.detail.handle; // number
180
239
  break;
@@ -247,6 +306,9 @@ function narrowRenderError(err: RenderError): void {
247
306
  void err.detail.backendReason;
248
307
  void err.detail.operation;
249
308
  break;
309
+ case 'taa-unavailable':
310
+ void err.detail.reason;
311
+ break;
250
312
  case 'vertex-color-variant-conflict':
251
313
  void err.detail.authored;
252
314
  void err.detail.authoredValue;
@@ -276,6 +338,30 @@ function narrowRenderError(err: RenderError): void {
276
338
  void err.detail.owner;
277
339
  void err.detail.generation;
278
340
  break;
341
+ case 'transmission-capability-missing':
342
+ void err.detail.material;
343
+ void err.detail.capability;
344
+ void err.detail.stage;
345
+ break;
346
+ case 'projector-binding-failed':
347
+ void err.detail.guid;
348
+ void err.detail.status;
349
+ break;
350
+ case 'volume-owner-conflict':
351
+ void err.detail.ownerCount;
352
+ break;
353
+ case 'volume-density-shape-mismatch':
354
+ void err.detail.guid;
355
+ void err.detail.viewDimension;
356
+ break;
357
+ case 'volume-invalid-bounds':
358
+ void err.detail.min;
359
+ void err.detail.max;
360
+ break;
361
+ case 'volume-invalid-parameters':
362
+ void err.detail.field;
363
+ void err.detail.value;
364
+ break;
279
365
  default: {
280
366
  const exhaustive: never = err;
281
367
  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
 
@@ -1193,12 +1193,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1193
1193
  'TextureAsset',
1194
1194
  {
1195
1195
  kind: 'texture',
1196
- width: 1,
1197
- height: 1,
1196
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
1198
1197
  format: 'rgba8unorm',
1199
1198
  data: new Uint8Array([255, 255, 255, 255]),
1200
1199
  colorSpace: 'linear',
1201
- mipmap: false,
1200
+ mips: { kind: 'none' },
1202
1201
  },
1203
1202
  );
1204
1203
  }
@@ -1211,12 +1210,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1211
1210
  const textureGuid = AssetGuid.random();
1212
1211
  assets.catalog(textureGuid, {
1213
1212
  kind: 'texture',
1214
- width: 1,
1215
- height: 1,
1213
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
1216
1214
  format: 'rgba8unorm',
1217
1215
  data: new Uint8Array([255, 255, 255, 255]),
1218
1216
  colorSpace: 'linear',
1219
- mipmap: false,
1217
+ mips: { kind: 'none' },
1220
1218
  } satisfies TextureAsset);
1221
1219
  const matHandle = world.allocSharedRef<'MaterialAsset', MaterialAsset>('MaterialAsset', {
1222
1220
  kind: 'material',
@@ -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
@@ -321,8 +323,7 @@ function makeChequerTexture(): TextureAsset {
321
323
  // textureView sentinel.
322
324
  return {
323
325
  kind: 'texture',
324
- width: 2,
325
- height: 2,
326
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
326
327
  // colorSpace=linear+rgba8unorm to satisfy the runtime's
327
328
  // srgb-format consistency validator (layer 7c-1: srgb requires
328
329
  // rgba8unorm-srgb). The test only cares that each texture's
@@ -330,7 +331,7 @@ function makeChequerTexture(): TextureAsset {
330
331
  format: 'rgba8unorm',
331
332
  data: new Uint8Array(2 * 2 * 4),
332
333
  colorSpace: 'linear',
333
- mipmap: false,
334
+ mips: { kind: 'none' },
334
335
  } as unknown as TextureAsset;
335
336
  }
336
337
 
@@ -584,12 +585,11 @@ async function spawnPbrMissingTextureScene(): Promise<unknown> {
584
585
  // existing debug-pink fallback.
585
586
  const unresidentTexture = {
586
587
  kind: 'texture',
587
- width: 2,
588
- height: 2,
588
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
589
589
  format: 'rgba8unorm-srgb',
590
590
  data: new Uint8Array(2 * 2 * 4),
591
591
  colorSpace: 'srgb',
592
- mipmap: false,
592
+ mips: { kind: 'none' },
593
593
  } as unknown as TextureAsset;
594
594
  const badTexHandle = world.allocSharedRef('TextureAsset', unresidentTexture) as unknown as Handle<
595
595
  'TextureAsset',
@@ -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] },
@@ -254,12 +256,11 @@ function cameraTransform() {
254
256
  function makeTex(): TextureAsset {
255
257
  return {
256
258
  kind: 'texture',
257
- width: 2,
258
- height: 2,
259
+ shape: { viewDimension: '2d', extent: { width: 2, height: 2 } },
259
260
  format: 'rgba8unorm',
260
261
  data: new Uint8Array(2 * 2 * 4),
261
262
  colorSpace: 'linear',
262
- mipmap: false,
263
+ mips: { kind: 'none' },
263
264
  } as unknown as TextureAsset;
264
265
  }
265
266
 
@@ -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',