@forgeax/engine-runtime 0.1.6 → 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 (61) hide show
  1. package/README.md +61 -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/collect-scene-asset.d.ts +6 -3
  15. package/dist/collect-scene-asset.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.mjs +25 -4
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/renderer-host.d.ts +2 -1
  21. package/dist/renderer-host.d.ts.map +1 -1
  22. package/dist/renderer-host.mjs +21 -2
  23. package/dist/renderer-host.mjs.map +1 -1
  24. package/package.json +30 -30
  25. package/src/__tests__/asset-registry-guid-reverse.test.ts +33 -0
  26. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +12 -11
  27. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +39 -6
  28. package/src/__tests__/errors.unit.test.ts +60 -0
  29. package/src/__tests__/extract-record-no-hardcoded-texture-fields.test.ts +4 -5
  30. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +461 -18
  31. package/src/__tests__/geometry.unit.test.ts +3 -1
  32. package/src/__tests__/helpers/standard-material-manifest.ts +45 -0
  33. package/src/__tests__/lights.unit.test.ts +15 -11
  34. package/src/__tests__/material-texture-uv-scale.unit.test.ts +8 -14
  35. package/src/__tests__/materials.unit.test.ts +2 -0
  36. package/src/__tests__/pbr-pipeline.unit.test.ts +15 -12
  37. package/src/__tests__/pipeline-cache-keying.unit.test.ts +6 -4
  38. package/src/__tests__/pipeline.unit.test.ts +5 -2
  39. package/src/__tests__/render-environment-consumer.integration.test.ts +33 -0
  40. package/src/__tests__/render-error-exhaustive.test-d.ts +93 -0
  41. package/src/__tests__/render-feature-prepared-graphics.browser.test.ts +5 -1
  42. package/src/__tests__/render-feature-prepared-graphics.fixture.ts +10 -0
  43. package/src/__tests__/render-system-mega.test.ts +3 -1
  44. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +3 -1
  45. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +3 -1
  46. package/src/__tests__/render-system-record.test.ts +8 -6
  47. package/src/__tests__/renderer-host-fallback.unit.test.ts +112 -0
  48. package/src/__tests__/renderer-lifecycle.integration.test.ts +10 -0
  49. package/src/__tests__/renderer-surface.unit.test.ts +66 -0
  50. package/src/__tests__/renderer.test-d.ts +2 -0
  51. package/src/__tests__/rhi-null-command-flow.unit.test.ts +14 -0
  52. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +5 -1
  53. package/src/__tests__/shadow-csm-tile-consistency.test.ts +5 -1
  54. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +383 -0
  55. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +10 -10
  56. package/src/__tests__/ssao-passes.test.ts +32 -12
  57. package/src/__tests__/systems.unit.test.ts +48 -31
  58. package/src/backend-selection.ts +4 -0
  59. package/src/collect-scene-asset.ts +14 -7
  60. package/src/index.ts +5 -1
  61. package/src/renderer-host.ts +30 -3
@@ -2232,10 +2232,11 @@ function spawnValidatedLight(
2232
2232
  //
2233
2233
  // visibility flags mirror the WebGPU GPUShaderStage bitmask:
2234
2234
  // VERTEX = 0x1, FRAGMENT = 0x2.
2235
+ const VISIBILITY_VERTEX = 0x1;
2235
2236
  const VISIBILITY_FRAGMENT = 0x2;
2236
2237
  const VISIBILITY_VERTEX_FRAGMENT = 0x1 | 0x2;
2237
2238
 
2238
- describe('binding 8 = spotShadowMap (D-5 always-on, last view-BG entry)', () => {
2239
+ describe('binding 8 = spotShadowMap (D-5 always-on shadow entry)', () => {
2239
2240
  it('declares binding 8 as a depth 2D texture, FRAGMENT-only', () => {
2240
2241
  const entries = buildPbrViewBglEntries({ storageBuffer: true });
2241
2242
  const b8 = entries.find((e) => e.binding === 8);
@@ -2253,10 +2254,12 @@ function spawnValidatedLight(
2253
2254
  expect(noStorage.some((e) => e.binding === 8)).toBe(true);
2254
2255
  });
2255
2256
 
2256
- it('binding 8 is the last entry (no binding 9)', () => {
2257
+ it('reserves binding 9 and keeps Points/Lines at binding 10', () => {
2257
2258
  const entries = buildPbrViewBglEntries({ storageBuffer: true });
2258
2259
  expect(entries.some((e) => e.binding === 9)).toBe(false);
2259
- expect(entries.some((e) => e.binding === 10)).toBe(false);
2260
+ const pointsLines = entries.find((e) => e.binding === 10);
2261
+ expect(pointsLines?.buffer?.type).toBe('uniform');
2262
+ expect(pointsLines?.visibility).toBe(VISIBILITY_VERTEX);
2260
2263
  });
2261
2264
  });
2262
2265
 
@@ -2272,18 +2275,19 @@ function spawnValidatedLight(
2272
2275
  // UBO (binding 0). This block locks that the spot matrices add ZERO new view
2273
2276
  // BGL buffer bindings (the WebGL2 budget invariant).
2274
2277
  describe('spotLightViewProj folded into View UBO (binding 0), not a new binding', () => {
2275
- it('view BGL declares no uniform buffer beyond binding 7 (only binding 0 = view, 6/7 = point/cascade)', () => {
2278
+ it('view BGL includes only the vertex Points/Lines UBO beyond binding 7', () => {
2276
2279
  // Enumerate uniform-buffer entries on the view BGL. After the w25 fold,
2277
- // the only uniform buffers are binding 0 (View UBO, carries the spot
2278
- // matrices in its tail), binding 6 (point shadowParams) and binding 7
2279
- // (shadowCasterCascade). No standalone spot-matrix uniform buffer.
2280
+ // uniform buffers are binding 0 (View UBO, carries the spot matrices
2281
+ // in its tail), binding 6 (point shadowParams), binding 7
2282
+ // (shadowCasterCascade), and binding 10 (Points/Lines view). No
2283
+ // standalone spot-matrix uniform buffer exists.
2280
2284
  const entries = buildPbrViewBglEntries({ storageBuffer: true });
2281
2285
  const uniformBufferBindings = entries
2282
2286
  .filter((e) => e.buffer?.type === 'uniform')
2283
2287
  .map((e) => e.binding)
2284
2288
  .sort((a, b) => a - b);
2285
2289
  // storageBuffer=true: bindings 1+2 are read-only-storage (not uniform).
2286
- expect(uniformBufferBindings).toEqual([0, 6, 7]);
2290
+ expect(uniformBufferBindings).toEqual([0, 6, 7, 10]);
2287
2291
  });
2288
2292
 
2289
2293
  it('storageBuffer=false: WebGL2 fallback uniform-buffer bindings stay within budget', () => {
@@ -2348,12 +2352,12 @@ function spawnValidatedLight(
2348
2352
  });
2349
2353
  });
2350
2354
 
2351
- describe('full binding roster is contiguous 0..8', () => {
2352
- it('exposes exactly bindings 0..8 with no gaps (binding 9 folded into View UBO, w25)', () => {
2355
+ describe('full binding roster reserves 9 for Points/Lines at 10', () => {
2356
+ it('exposes bindings 0..8 plus binding 10, with binding 9 reserved', () => {
2353
2357
  const bindings = buildPbrViewBglEntries({ storageBuffer: true })
2354
2358
  .map((e) => e.binding)
2355
2359
  .sort((a, b) => a - b);
2356
- expect(bindings).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
2360
+ expect(bindings).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 10]);
2357
2361
  });
2358
2362
  });
2359
2363
  });
@@ -3,7 +3,6 @@ import { vec3 } from '@forgeax/engine-math';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import {
5
5
  applyMaterialTextureUvScales,
6
- BUILTIN_USER_REGION_TEXTURE_FIELDS,
7
6
  buildPbrMaterialUboPayload,
8
7
  materialTextureUvScale,
9
8
  userRegionTextureFieldOrder,
@@ -22,11 +21,9 @@ describe('material texture UV scale [w37]', () => {
22
21
  expect(materialTextureUvScale({ width: 17, height: 9, format: 'rgba8unorm' })).toEqual([1, 1]);
23
22
  });
24
23
 
25
- it('keeps builtin texture slots when compatibility param schemas are empty or numeric-only', () => {
26
- expect(userRegionTextureFieldOrder([])).toEqual(BUILTIN_USER_REGION_TEXTURE_FIELDS);
27
- expect(userRegionTextureFieldOrder([{ name: 'baseColor', type: 'color' }])).toEqual(
28
- BUILTIN_USER_REGION_TEXTURE_FIELDS,
29
- );
24
+ it('keeps compatibility param schemas compact when they have no textures', () => {
25
+ expect(userRegionTextureFieldOrder([])).toEqual([]);
26
+ expect(userRegionTextureFieldOrder([{ name: 'baseColor', type: 'color' }])).toEqual([]);
30
27
  });
31
28
 
32
29
  it('writes each slot set and transform into the builtin PBR payload', () => {
@@ -61,8 +58,8 @@ describe('material texture UV scale [w37]', () => {
61
58
  new World(),
62
59
  );
63
60
  const f32 = new Float32Array(payload.buffer, payload.byteOffset, payload.byteLength / 4);
64
- expect(Array.from(f32.slice(24, 32))).toEqual([0.125, 0.25, 2, 3, 1, 0.5, 1, 1]);
65
- expect(Array.from(f32.slice(40, 48))).toEqual([0.75, 0.25, 4, 5, 0, 0, 1, 1]);
61
+ expect(Array.from(f32.slice(32, 40))).toEqual([0.125, 0.25, 2, 3, 1, 0.5, 1, 1]);
62
+ expect(Array.from(f32.slice(48, 56))).toEqual([0.75, 0.25, 4, 5, 0, 0, 1, 1]);
66
63
  });
67
64
 
68
65
  it('keeps identity UV records for metadata-free standard and builtin schema materials', () => {
@@ -85,14 +82,11 @@ describe('material texture UV scale [w37]', () => {
85
82
  expect(
86
83
  Array.from(
87
84
  new Float32Array(standard.buffer, standard.byteOffset, standard.byteLength / 4).slice(
88
- 24,
89
- 72,
85
+ 32,
86
+ 96,
90
87
  ),
91
88
  ),
92
- ).toEqual([
93
- 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1,
94
- 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1,
95
- ]);
89
+ ).toEqual(Array.from({ length: 8 }, () => [0, 0, 1, 1, 0, 0, 1, 1]).flat());
96
90
 
97
91
  const unlit = buildPbrMaterialUboPayload({
98
92
  baseColor: vec3.create(1, 1, 1),
@@ -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
 
@@ -44,7 +44,7 @@ function makeSpec(): PipelineSpec {
44
44
 
45
45
  describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', () => {
46
46
  describe('pbr-view', () => {
47
- it('storageBuffer=true: 9 entries with read-only-storage on bindings 1+2', () => {
47
+ it('storageBuffer=true: 10 entries with read-only-storage on bindings 1+2', () => {
48
48
  const spec = makeSpec();
49
49
  const out = buildBindGroupLayoutDescriptor(spec, {
50
50
  kind: 'pbr-view',
@@ -56,12 +56,15 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
56
56
  };
57
57
  expect(out).toEqual(expected);
58
58
  // feat-20260625-spot-light-shadow-mapping: binding 8 (spot shadow atlas,
59
- // w14) raised the view BGL to 9 entries (always-on, D-5). w25 (scope-amend
59
+ // w14) raised the view BGL to 9 entries (always-on, D-5). The
60
+ // Points/Lines viewport UBO occupies binding 10, while w25 (scope-amend
60
61
  // webkit-fallback) folded the former binding 9 spotLightViewProj matrices
61
62
  // UBO into the View UBO (`view.spotLightViewProj`), so the BGL stays at 9
62
- // entries (bindings 0..8) — keeping the WebGL2 fallback fragment uniform-
63
+ // entries (bindings 0..8 plus binding 10) — keeping the WebGL2 fallback fragment uniform-
63
64
  // buffer count <= 11 (GLES 3.0).
64
- expect(out.entries.length).toBe(9);
65
+ expect(out.entries.length).toBe(10);
66
+ expect(out.entries.find((entry) => entry.binding === 9)).toBeUndefined();
67
+ expect(out.entries.find((entry) => entry.binding === 10)?.buffer?.type).toBe('uniform');
65
68
  });
66
69
 
67
70
  it('storageBuffer=false: bindings 1+2 fall back to uniform', () => {
@@ -79,22 +82,22 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
79
82
  });
80
83
 
81
84
  describe('pbr-material-merged', () => {
82
- it('24 entries: user-region 13 (derived) + ibl 7 + lightmap 4', () => {
85
+ it('26 entries: user-region 17 (derived) + ibl 7 + transmission 2', () => {
83
86
  const spec = makeSpec();
84
87
  const out = buildBindGroupLayoutDescriptor(spec, {
85
88
  kind: 'pbr-material-merged',
86
89
  });
87
90
  // Post-M2 (D-1): user-region comes from derive(paramSchema).bglEntries
88
- // (built-in standard-PBR 4-texture fallback), then IBL + lightmap are
89
- // appended at start = userRegion.length.
91
+ // (built-in standard-PBR 8-texture contract), then IBL + transmission
92
+ // are appended at start = userRegion.length.
90
93
  const userRegion = buildPbrMaterialUserRegionEntries();
91
94
  const afterIbl = [...userRegion, ...appendInjection(userRegion, 'ibl')];
92
95
  const expected = {
93
96
  label: 'pbr-material-skylight-bgl',
94
- entries: [...afterIbl, ...appendInjection(afterIbl, 'lightmap')],
97
+ entries: [...afterIbl, ...appendInjection(afterIbl, 'transmission')],
95
98
  };
96
99
  expect(out).toEqual(expected);
97
- expect(out.entries.length).toBe(24);
100
+ expect(out.entries.length).toBe(26);
98
101
  });
99
102
  });
100
103
 
@@ -139,7 +142,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
139
142
  entries: [
140
143
  {
141
144
  binding: 0,
142
- visibility: 0x1,
145
+ visibility: 0x3,
143
146
  buffer: { type: 'read-only-storage', hasDynamicOffset: false },
144
147
  },
145
148
  ],
@@ -182,7 +185,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
182
185
  });
183
186
 
184
187
  describe('unlit-material', () => {
185
- it('13 entries: base PBR material only (no skylight injection)', () => {
188
+ it('17 entries: base PBR material only (no skylight injection)', () => {
186
189
  const spec = makeSpec();
187
190
  const out = buildBindGroupLayoutDescriptor(spec, {
188
191
  kind: 'unlit-material',
@@ -191,7 +194,7 @@ describe('buildBindGroupLayoutDescriptor — pbr-pipeline 6 sites byte-equiv', (
191
194
  label: 'unlit-material-bgl',
192
195
  entries: buildPbrMaterialUserRegionEntries(),
193
196
  });
194
- expect(out.entries.length).toBe(13);
197
+ expect(out.entries.length).toBe(17);
195
198
  });
196
199
  });
197
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,20 @@ 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;
105
+ case 'points-lines-invalid-style':
106
+ return code;
107
+ case 'points-lines-topology-mismatch':
108
+ return code;
109
+ case 'points-lines-style-unsupported':
110
+ return code;
111
+ case 'points-lines-material-unsupported':
112
+ return code;
113
+ case 'points-lines-budget-exceeded':
114
+ return code;
115
+ case 'points-lines-prepare-failed':
116
+ return code;
87
117
  default: {
88
118
  const exhaustive: never = code;
89
119
  return exhaustive;
@@ -159,10 +189,41 @@ function narrowRenderError(err: RenderError): void {
159
189
  void err.detail.reason;
160
190
  void err.detail.recovery;
161
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;
162
201
  case 'shadow-invalid-config':
163
202
  void err.detail.field; // string
164
203
  void err.detail.value; // number
165
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;
166
227
  case 'equirect-projection-failed':
167
228
  void err.detail.handle; // number
168
229
  break;
@@ -235,11 +296,43 @@ function narrowRenderError(err: RenderError): void {
235
296
  void err.detail.backendReason;
236
297
  void err.detail.operation;
237
298
  break;
299
+ case 'taa-unavailable':
300
+ void err.detail.reason;
301
+ break;
238
302
  case 'vertex-color-variant-conflict':
239
303
  void err.detail.authored;
240
304
  void err.detail.authoredValue;
241
305
  void err.detail.projected;
242
306
  break;
307
+ case 'points-lines-invalid-style':
308
+ void err.detail.component;
309
+ void err.detail.field;
310
+ break;
311
+ case 'points-lines-topology-mismatch':
312
+ void err.detail.submesh;
313
+ void err.detail.actual;
314
+ break;
315
+ case 'points-lines-style-unsupported':
316
+ void err.detail.member;
317
+ void err.detail.supported;
318
+ break;
319
+ case 'points-lines-material-unsupported':
320
+ void err.detail.material;
321
+ void err.detail.pass;
322
+ break;
323
+ case 'points-lines-budget-exceeded':
324
+ void err.detail.requested;
325
+ void err.detail.limit;
326
+ break;
327
+ case 'points-lines-prepare-failed':
328
+ void err.detail.owner;
329
+ void err.detail.generation;
330
+ break;
331
+ case 'transmission-capability-missing':
332
+ void err.detail.material;
333
+ void err.detail.capability;
334
+ void err.detail.stage;
335
+ break;
243
336
  default: {
244
337
  const exhaustive: never = err;
245
338
  void exhaustive;
@@ -45,6 +45,10 @@ describe('prepared graphics browser contract', () => {
45
45
  canvas.remove();
46
46
  });
47
47
 
48
+ // This negative path intentionally exercises a real WebGPU pipeline before
49
+ // the renderer reports the structured stage error. Its cold lavapipe start
50
+ // has exceeded Vitest's 15s default in CI; keep the assertion bounded at the
51
+ // owner rather than weakening the global browser timeout.
48
52
  it('reports forged prepared state through subscribe without renderer diagnostics getters', async () => {
49
53
  if (typeof navigator?.gpu?.requestAdapter !== 'function') {
50
54
  throw new Error('WebGPU is unavailable');
@@ -80,5 +84,5 @@ describe('prepared graphics browser contract', () => {
80
84
  });
81
85
  expect(renderer).not.toHaveProperty('renderFeatureDiagnostics');
82
86
  canvas.remove();
83
- });
87
+ }, 30_000);
84
88
  });
@@ -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(