@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
@@ -47,7 +47,7 @@ function makeSpec(shaderId: string): PipelineSpec {
47
47
 
48
48
  describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
49
49
  describe('pbr-view BGL (light buffers; binding 1+2)', () => {
50
- it('sprite-lit vs sprite share the same 9-entry pbr-view BGL under storage-buffer caps', () => {
50
+ it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under storage-buffer caps', () => {
51
51
  const spriteSpec = makeSpec('forgeax::sprite');
52
52
  const spriteLitSpec = makeSpec('forgeax::sprite-lit');
53
53
  const sprite = buildBindGroupLayoutDescriptor(spriteSpec, {
@@ -59,10 +59,10 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
59
59
  caps: { storageBuffer: true },
60
60
  });
61
61
  expect(JSON.stringify(spriteLit)).toBe(JSON.stringify(sprite));
62
- expect(spriteLit.entries.length).toBe(9); // feat-20260625 spot shadow added binding 8 view BGL grew 8→9
62
+ expect(spriteLit.entries.length).toBe(10); // binding 10 is the shared Points/Lines view UBO
63
63
  });
64
64
 
65
- it('sprite-lit vs sprite share the same 9-entry pbr-view BGL under uniform fallback caps (AC-10)', () => {
65
+ it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under uniform fallback caps (AC-10)', () => {
66
66
  const spriteSpec = makeSpec('forgeax::sprite');
67
67
  const spriteLitSpec = makeSpec('forgeax::sprite-lit');
68
68
  const sprite = buildBindGroupLayoutDescriptor(spriteSpec, {
@@ -118,24 +118,24 @@ describe('sprite-lit BGL byte-identical to sprite (AC-07, w4/w5 close)', () => {
118
118
  });
119
119
 
120
120
  describe('material BGL congruence (pbr-material-merged / unlit-material)', () => {
121
- it('pbr-material-user-region entries are 13 (PBR layout reused by sprite & sprite-lit)', () => {
121
+ it('pbr-material-user-region entries are 17 (PBR layout reused by sprite & sprite-lit)', () => {
122
122
  // sprite + sprite-lit use the default standard-PBR user-region
123
- // schema (5 user fields produce 7 BGL entries after std140 merge).
123
+ // schema (20 value fields + 8 texture fields produce 17 BGL entries).
124
124
  // Both share the same userRegion shape and therefore the same BGL.
125
125
  const base = buildPbrMaterialUserRegionEntries();
126
- expect(base.length).toBe(13);
126
+ expect(base.length).toBe(17);
127
127
  });
128
128
 
129
- it('pbr-material-merged stays 24 entries (sprite/sprite-lit do not change material BGL shape)', () => {
129
+ it('pbr-material-merged stays 26 entries (sprite/sprite-lit do not change material BGL shape)', () => {
130
130
  const merged = buildBindGroupLayoutDescriptor(makeSpec('forgeax::sprite-lit'), {
131
131
  kind: 'pbr-material-merged',
132
132
  });
133
- expect(merged.entries.length).toBe(24);
134
- // mergeSkylightIntoMaterialBgl + lightmap injection must stay
133
+ expect(merged.entries.length).toBe(26);
134
+ // mergeSkylightIntoMaterialBgl + transmission injection must stay
135
135
  // append-only against the 7-entry PBR base.
136
136
  const base = buildPbrMaterialUserRegionEntries();
137
137
  const afterSky = mergeSkylightIntoMaterialBgl(base);
138
- const expected = [...afterSky, ...appendInjection(afterSky, 'lightmap')];
138
+ const expected = [...afterSky, ...appendInjection(afterSky, 'transmission')];
139
139
  expect(merged.entries).toEqual(expected);
140
140
  });
141
141
  });
@@ -8,6 +8,8 @@
8
8
  import { RenderGraph } from '@forgeax/engine-render-graph';
9
9
  import type { RhiDevice } from '@forgeax/engine-rhi';
10
10
  import { describe, expect, it, vi } from 'vitest';
11
+ import { DeviceScope } from '../../../render/src/device/device-scope';
12
+ import type { BloomPersistentBundle } from '../../../render/src/record/render-context';
11
13
  import {
12
14
  recordBloomBlurHPass,
13
15
  recordBloomBlurVPass,
@@ -680,6 +682,31 @@ function makePostProcessRecordCtx(): {
680
682
  queue: { writeBuffer: vi.fn(() => ({ ok: true, value: undefined })) },
681
683
  };
682
684
  const sampler = { label: 'postprocess-sampler' };
685
+ const bloomScope = DeviceScope.create(1, 'runtime-ssao-bloom-fixture');
686
+ const ownBloomHandle = (
687
+ kind: 'pipeline' | 'binding' | 'post-effect' | 'buffer',
688
+ label: string,
689
+ ) => {
690
+ const handle = { label };
691
+ bloomScope._adopt(kind, handle, () => undefined);
692
+ return handle;
693
+ };
694
+ const bloomResources = {
695
+ scope: bloomScope,
696
+ generation: bloomScope.generation,
697
+ bloomBrightPipeline: ownBloomHandle('pipeline', 'bloom-bright-pipeline'),
698
+ bloomBlurHPipeline: ownBloomHandle('pipeline', 'bloom-blur-h-pipeline'),
699
+ bloomBlurVPipeline: ownBloomHandle('pipeline', 'bloom-blur-v-pipeline'),
700
+ bloomCompositePipeline: ownBloomHandle('pipeline', 'bloom-composite-pipeline'),
701
+ bloomBrightBindGroupLayout: ownBloomHandle('binding', 'bloom-bright-bgl'),
702
+ bloomBlurBindGroupLayout: ownBloomHandle('binding', 'bloom-blur-bgl'),
703
+ bloomCompositeBindGroupLayout: ownBloomHandle('binding', 'bloom-composite-bgl'),
704
+ bloomSampler: ownBloomHandle('post-effect', 'bloom-sampler'),
705
+ bloomBrightParamsBuffer: ownBloomHandle('buffer', 'bloom-bright-params'),
706
+ bloomBlurHParamsBuffer: ownBloomHandle('buffer', 'bloom-blur-h-params'),
707
+ bloomBlurVParamsBuffer: ownBloomHandle('buffer', 'bloom-blur-v-params'),
708
+ bloomCompositeParamsBuffer: ownBloomHandle('buffer', 'bloom-composite-params'),
709
+ } as unknown as BloomPersistentBundle;
683
710
  const ctx = {
684
711
  runtime: {
685
712
  device: { ...device, caps: { storageBuffer: true } },
@@ -702,23 +729,12 @@ function makePostProcessRecordCtx(): {
702
729
  skyboxBindGroupLayout: { label: 'skybox-bgl' },
703
730
  skyboxSampler: sampler,
704
731
  skyboxRotationBuffer: { label: 'skybox-rotation' },
705
- bloomBrightPipeline: { label: 'bloom-bright-pipeline' },
706
- bloomBrightBindGroupLayout: { label: 'bloom-bright-bgl' },
707
- bloomBrightParamsBuffer: { label: 'bloom-bright-params' },
708
- bloomBlurHPipeline: { label: 'bloom-blur-h-pipeline' },
709
- bloomBlurVPipeline: { label: 'bloom-blur-v-pipeline' },
710
- bloomBlurBindGroupLayout: { label: 'bloom-blur-bgl' },
711
- bloomBlurHParamsBuffer: { label: 'bloom-blur-h-params' },
712
- bloomBlurVParamsBuffer: { label: 'bloom-blur-v-params' },
713
- bloomCompositePipeline: { label: 'bloom-composite-pipeline' },
714
- bloomCompositeBindGroupLayout: { label: 'bloom-composite-bgl' },
715
- bloomCompositeParamsBuffer: { label: 'bloom-composite-params' },
716
- bloomSampler: sampler,
717
732
  fxaaPipeline: { label: 'fxaa-pipeline' },
718
733
  fxaaBindGroupLayout: { label: 'fxaa-bgl' },
719
734
  fxaaSampler: sampler,
720
735
  },
721
736
  },
737
+ bloomResources,
722
738
  frameState: { postProcessBgCache: new WeakMap() },
723
739
  bindGroupCounts: { createBindGroup: 0, keys: [] },
724
740
  camera: {
@@ -778,6 +794,10 @@ describe('post-process bindgroup identity cache (issue #670)', () => {
778
794
  views.bloomBlurV,
779
795
  );
780
796
  expect(bindGroupCreates[5]?.entries[0]?.resource.value).toBe(views.cubemap);
797
+ expect(bindGroupCreates[5]?.entries[2]?.resource.value).toEqual({
798
+ buffer: ctx.pipelineState.viewUniformBuffer,
799
+ size: 960,
800
+ });
781
801
 
782
802
  recordAll();
783
803
  expect(bindGroupCreates).toHaveLength(6);
@@ -120,6 +120,7 @@ import { INSTANCE_STORAGE_STRIDE_FLOATS } from '../../../render/src/record/mesh-
120
120
  import { selectSwapChainFormat } from '../../../render/src/render-system';
121
121
  import { createSkinPaletteAllocator } from '../../../render/src/systems/skin-palette-allocator';
122
122
  import type { TransparentEntry } from '../../../render/src/systems/transparent-sort-config';
123
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
123
124
  import { drawWithOwners } from './renderer-test-utils';
124
125
 
125
126
  function componentFieldType(component: { fields: Record<string, { type: string }> }, name: string) {
@@ -1645,7 +1646,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1645
1646
  sourcePath: `${identifier}.wgsl`,
1646
1647
  composedWgsl,
1647
1648
  paramSchema: '[]',
1648
- variants: [],
1649
+ variants:
1650
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
1649
1651
  });
1650
1652
  const manifest = {
1651
1653
  schemaVersion: '1.0.0',
@@ -2359,10 +2361,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2359
2361
  });
2360
2362
  });
2361
2363
 
2362
- // ─── Assertion (d): assembleMaterialWithSkylightEntries merges 7 + 7 = 14 ───
2364
+ // ─── Assertion (d): assembleMaterialWithSkylightEntries adds the engine-owned pair ───
2363
2365
 
2364
2366
  describe('t40 round-4 (d) assembleMaterialWithSkylightEntries shape', () => {
2365
- it('returns 14 BindGroupEntry values; entry 7..13 reference the skylight resources', () => {
2367
+ it('returns 16 BindGroupEntry values; entry 7..13 reference the skylight resources', () => {
2366
2368
  const materialEntries = makeMaterialBindGroupEntries();
2367
2369
  const irrView = { __brand: 'TextureView', id: 1 } as unknown as TextureView;
2368
2370
  const irrSampler = { __brand: 'Sampler', id: 1 } as unknown as Sampler;
@@ -2382,7 +2384,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2382
2384
  intensityBuffer: intensityBuf,
2383
2385
  });
2384
2386
 
2385
- expect(merged).toHaveLength(14);
2387
+ expect(merged).toHaveLength(16);
2386
2388
 
2387
2389
  // Skylight entries at binding 7..13 in D-5 order
2388
2390
  expect(merged[7]?.binding).toBe(7);
@@ -2402,6 +2404,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2402
2404
  kind: 'buffer',
2403
2405
  value: { buffer: intensityBuf },
2404
2406
  });
2407
+ expect(merged[14]?.binding).toBe(14);
2408
+ expect(merged[15]?.binding).toBe(15);
2405
2409
  });
2406
2410
 
2407
2411
  it('derives IBL injection start from materialEntries.length (per-shader user-region, not a fixed 7)', () => {
@@ -2418,9 +2422,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2418
2422
  brdfLutSampler: {} as Sampler,
2419
2423
  intensityBuffer: {} as Buffer,
2420
2424
  });
2421
- expect(merged).toHaveLength(7);
2425
+ expect(merged).toHaveLength(9);
2422
2426
  expect(merged[0]?.binding).toBe(0);
2423
2427
  expect(merged[6]?.binding).toBe(6);
2428
+ expect(merged[7]?.binding).toBe(7);
2429
+ expect(merged[8]?.binding).toBe(8);
2424
2430
  });
2425
2431
  });
2426
2432
  }
@@ -2515,7 +2521,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2515
2521
  }
2516
2522
 
2517
2523
  describe('t58 (M4 round-4) -- material BG fallback path', () => {
2518
- it('(a) assemble with fallback returns 14 entries; binding 7..13 reference fallback resources', () => {
2524
+ it('(a) assemble with fallback returns 16 entries; binding 7..13 reference fallback resources', () => {
2519
2525
  const materialEntries = makeMaterialBindGroupEntries();
2520
2526
  const fallback = makeFallback();
2521
2527
  const fallbackAsActive: SkylightBindGroupResources = {
@@ -2528,7 +2534,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2528
2534
  intensityBuffer: fallback.intensityBuffer,
2529
2535
  };
2530
2536
  const merged = assembleMaterialWithSkylightEntries(materialEntries, fallbackAsActive);
2531
- expect(merged).toHaveLength(14);
2537
+ expect(merged).toHaveLength(16);
2532
2538
  expect(merged[7]?.binding).toBe(7);
2533
2539
  expect((merged[7]?.resource as { value: unknown }).value).toBe(fallback.irradianceView);
2534
2540
  expect((merged[8]?.resource as { value: unknown }).value).toBe(fallback.sampler);
@@ -2541,11 +2547,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2541
2547
  );
2542
2548
  });
2543
2549
 
2544
- it('(b) assemble with active returns 14 entries; binding 7..13 reference active IblPipelineCache resources', () => {
2550
+ it('(b) assemble with active returns 16 entries; binding 7..13 reference active IblPipelineCache resources', () => {
2545
2551
  const materialEntries = makeMaterialBindGroupEntries();
2546
2552
  const active = makeActive();
2547
2553
  const merged = assembleMaterialWithSkylightEntries(materialEntries, active);
2548
- expect(merged).toHaveLength(14);
2554
+ expect(merged).toHaveLength(16);
2549
2555
  expect((merged[7]?.resource as { value: unknown }).value).toBe(active.irradianceView);
2550
2556
  expect((merged[8]?.resource as { value: unknown }).value).toBe(active.irradianceSampler);
2551
2557
  expect((merged[9]?.resource as { value: unknown }).value).toBe(active.prefilterView);
@@ -2754,47 +2760,43 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2754
2760
  expect(layout?.label).toBe('pbr-pl');
2755
2761
  });
2756
2762
 
2757
- it('(b) PBR material BGL entry count === 24 (user region 0..12 + Skylight 13..19 + lightmap 20..23)', () => {
2763
+ it('(b) PBR material BGL entry count === 26 (user region 0..16 + Skylight 17..23 + transmission 24..25)', () => {
2758
2764
  const device = makeMockDevice();
2759
2765
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
2760
2766
  buildPbrPipelineLayouts(device as any, STORAGE_CAPS);
2761
2767
  const materialBgl = device.capturedBgls.find((b) => b.label === 'pbr-material-skylight-bgl');
2762
2768
  expect(materialBgl).toBeDefined();
2763
- expect(materialBgl?.entries).toHaveLength(24);
2769
+ expect(materialBgl?.entries).toHaveLength(26);
2764
2770
  });
2765
2771
 
2766
- it('(c) binding indices 0..23 in order; 13..19 resource types in D-5 round-4 order; 20..23 lightmap', () => {
2772
+ it('(c) binding indices 0..25 in order; 17..23 resource types in D-5 round-4 order; 24..25 transmission', () => {
2767
2773
  const device = makeMockDevice();
2768
2774
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
2769
2775
  buildPbrPipelineLayouts(device as any, STORAGE_CAPS);
2770
2776
  const materialBgl = device.capturedBgls.find((b) => b.label === 'pbr-material-skylight-bgl');
2771
2777
  expect(materialBgl).toBeDefined();
2772
2778
  const entries = materialBgl?.entries ?? [];
2773
- // binding indices 0..23 in order
2774
- for (let i = 0; i < 24; i++) {
2779
+ // binding indices 0..25 in order
2780
+ for (let i = 0; i < 26; i++) {
2775
2781
  expect(entries[i]?.binding).toBe(i);
2776
2782
  }
2777
- // 13..19 resource types
2778
- expect((entries[13] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2783
+ // 17..23 resource types
2784
+ expect((entries[17] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2779
2785
  'cube',
2780
2786
  );
2781
- expect((entries[14] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2782
- expect((entries[15] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2787
+ expect((entries[18] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2788
+ expect((entries[19] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2783
2789
  'cube',
2784
2790
  );
2785
- expect((entries[16] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2786
- expect((entries[17] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2787
- '2d',
2788
- );
2789
- expect((entries[18] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2790
- expect((entries[19] as { buffer?: { type: string } }).buffer?.type).toBe('uniform');
2791
- // 20..23 lightmap resource types
2792
2791
  expect((entries[20] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2793
2792
  expect((entries[21] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2794
2793
  '2d',
2795
2794
  );
2796
2795
  expect((entries[22] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2797
- expect((entries[23] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2796
+ expect((entries[23] as { buffer?: { type: string } }).buffer?.type).toBe('uniform');
2797
+ // 24..25 transmission resource types
2798
+ expect((entries[24] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2799
+ expect((entries[25] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2798
2800
  '2d',
2799
2801
  );
2800
2802
  });
@@ -2820,13 +2822,13 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2820
2822
  // ─── (e)(f) Unlit material BGL shape + name ─────────────────────────────────
2821
2823
 
2822
2824
  describe('t57 (M4 round-4) -- unlitPipeline material BGL shape', () => {
2823
- it('(e) unlit material BGL entry count === 13 (no Skylight binding contamination)', () => {
2825
+ it('(e) unlit material BGL entry count === 17 (no Skylight binding contamination)', () => {
2824
2826
  const device = makeMockDevice();
2825
2827
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
2826
2828
  buildUnlitMaterialBgl(device as any);
2827
2829
  const unlitBgl = device.capturedBgls.find((b) => b.label === 'unlit-material-bgl');
2828
2830
  expect(unlitBgl).toBeDefined();
2829
- expect(unlitBgl?.entries).toHaveLength(13);
2831
+ expect(unlitBgl?.entries).toHaveLength(17);
2830
2832
  });
2831
2833
 
2832
2834
  it("(f) PBR material BGL labelled 'pbr-material-skylight-bgl'; unlit labelled 'unlit-material-bgl'", () => {
@@ -2986,6 +2988,14 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2986
2988
  }
2987
2989
 
2988
2990
  function buildManifestDataUrl(): string {
2991
+ const materialShaderStub = (identifier: string) => ({
2992
+ identifier,
2993
+ sourcePath: `${identifier}.wgsl`,
2994
+ composedWgsl: '/* stub */',
2995
+ paramSchema: '[]',
2996
+ variants:
2997
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
2998
+ });
2989
2999
  const manifest = {
2990
3000
  schemaVersion: '1.0.0',
2991
3001
  entries: [
@@ -3008,6 +3018,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3008
3018
  bindings: '',
3009
3019
  },
3010
3020
  ],
3021
+ materialShaders: [
3022
+ materialShaderStub('forgeax::default-standard-pbr'),
3023
+ materialShaderStub('forgeax::default-unlit'),
3024
+ ],
3011
3025
  };
3012
3026
  return `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
3013
3027
  }
@@ -7292,7 +7306,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
7292
7306
  sourcePath: `${identifier}.wgsl`,
7293
7307
  composedWgsl,
7294
7308
  paramSchema: '[]',
7295
- variants: [],
7309
+ variants:
7310
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
7296
7311
  });
7297
7312
  const manifest = {
7298
7313
  schemaVersion: '1.0.0',
@@ -7608,7 +7623,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
7608
7623
  sourcePath: `${identifier}.wgsl`,
7609
7624
  composedWgsl: '/* stub */',
7610
7625
  paramSchema: '[]',
7611
- variants: [],
7626
+ variants:
7627
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
7612
7628
  });
7613
7629
  const manifest = {
7614
7630
  schemaVersion: '1.0.0',
@@ -8693,7 +8709,8 @@ type ExtractFramesWithPipeline = (
8693
8709
  sourcePath: `${identifier}.wgsl`,
8694
8710
  composedWgsl: '/* stub */',
8695
8711
  paramSchema: '[]',
8696
- variants: [],
8712
+ variants:
8713
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
8697
8714
  });
8698
8715
  const entries: Array<{ hash: string; wgsl: string; glsl: string; bindings: string }> = [
8699
8716
  { hash: 'pbr00000', wgsl: '/* pbr stub - calls f_schlick( */', glsl: '', bindings: '' },
@@ -11,6 +11,9 @@ export function loadRhiPack(
11
11
  const createShaderModule = mod.createShaderModule as
12
12
  | RhiBackendPack['createShaderModule']
13
13
  | undefined;
14
+ const createShaderModuleImmediate = mod.createShaderModuleImmediate as
15
+ | RhiBackendPack['createShaderModuleImmediate']
16
+ | undefined;
14
17
  const translateErrorEventToRhiError = mod.translateErrorEventToRhiError as
15
18
  | RhiBackendPack['translateErrorEventToRhiError']
16
19
  | undefined;
@@ -23,6 +26,7 @@ export function loadRhiPack(
23
26
  const pack: RhiBackendPack = {
24
27
  rhi,
25
28
  ...(createShaderModule === undefined ? {} : { createShaderModule }),
29
+ ...(createShaderModuleImmediate === undefined ? {} : { createShaderModuleImmediate }),
26
30
  ...(translateErrorEventToRhiError === undefined ? {} : { translateErrorEventToRhiError }),
27
31
  ...(rawDeviceAccessor === undefined ? {} : { _internal_getRawDevice: rawDeviceAccessor }),
28
32
  };
@@ -44,8 +44,8 @@
44
44
  // proven to be a mount entity, components is left undefined (one-time
45
45
  // normalization on first reload; fixed-point from second collect onward).
46
46
 
47
- import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
48
47
  import {
48
+ builtinMeshGuid,
49
49
  resolveAssetHandle,
50
50
  SceneCollectAssetGuidUnresolvedError,
51
51
  SceneCollectEntityRefOutOfClosureError,
@@ -78,6 +78,11 @@ import type {
78
78
  } from '@forgeax/engine-types';
79
79
  import { foldMountOverrides } from './scene-utils/mount-override-fold';
80
80
 
81
+ /** The collector needs identity lookup, not an authoring registry implementation. */
82
+ export interface SceneAssetGuidLookup {
83
+ guidOf(asset: Asset): string | undefined;
84
+ }
85
+
81
86
  // Shared helpers
82
87
  function _isArrayLike(value: unknown): value is ArrayLike<unknown> {
83
88
  // SceneAsset values are ordinary JSON arrays at the boundary. Use the
@@ -116,14 +121,16 @@ function classifyFieldSchema(fieldType: string | undefined): SchemaFieldClass |
116
121
  // lives in exactly one place; `field` names the failing field in the error.
117
122
  function _handleToGuid(
118
123
  world: World,
119
- registry: AssetRegistry,
124
+ registry: SceneAssetGuidLookup,
120
125
  handle: number,
121
126
  field: string,
122
127
  ): Result<string | undefined, SceneCollectAssetGuidUnresolvedError> {
123
128
  if (handle === 0) return ok(undefined); // NULL sentinel
129
+ const builtinGuid = builtinMeshGuid(handle as unknown as Handle<string, 'shared'>);
130
+ if (builtinGuid !== undefined) return ok(builtinGuid);
124
131
  const assetRes = resolveAssetHandle(world, handle as unknown as Handle<string, 'shared'>);
125
132
  if (!assetRes.ok) return err(new SceneCollectAssetGuidUnresolvedError(field, handle));
126
- const guid = registry._guidForAsset(assetRes.value as Asset);
133
+ const guid = registry.guidOf(assetRes.value as Asset);
127
134
  if (guid === undefined) return err(new SceneCollectAssetGuidUnresolvedError(field, handle));
128
135
  return ok(guid);
129
136
  }
@@ -135,7 +142,7 @@ function _handleToGuid(
135
142
  // values pass through untouched.
136
143
  function _serializeSharedFieldValue(
137
144
  world: World,
138
- registry: AssetRegistry,
145
+ registry: SceneAssetGuidLookup,
139
146
  classification: SchemaFieldClass,
140
147
  value: unknown,
141
148
  field: string,
@@ -166,7 +173,7 @@ function _serializeSharedFieldValue(
166
173
  // override IS that field so it cannot be omitted). Non-shared fields pass through.
167
174
  function _serializeOverrideValueHandles(
168
175
  world: World,
169
- registry: AssetRegistry,
176
+ registry: SceneAssetGuidLookup,
170
177
  ov: MountOverride,
171
178
  ): Result<unknown, SceneCollectAssetGuidUnresolvedError> {
172
179
  const comp = world.components.resolve(ov.comp);
@@ -253,7 +260,7 @@ export function serializeSceneAssetToPack(
253
260
  * filter (not subtree pruning): graft entities under members survive as owned.
254
261
  */
255
262
  export function rootsToSceneAsset(
256
- registry: AssetRegistry,
263
+ registry: SceneAssetGuidLookup,
257
264
  world: World,
258
265
  roots: EntityHandle[],
259
266
  ): Result<
@@ -415,7 +422,7 @@ export function rootsToSceneAsset(
415
422
  sh.value as unknown as number,
416
423
  ),
417
424
  );
418
- const g = registry._guidForAsset(pr.value as Asset);
425
+ const g = registry.guidOf(pr.value as Asset);
419
426
  if (g === undefined)
420
427
  return err(
421
428
  new SceneCollectAssetGuidUnresolvedError(
package/src/index.ts CHANGED
@@ -326,7 +326,11 @@ export { quat } from '@forgeax/engine-math';
326
326
  // collectSceneAsset removed — replaced by rootsToSceneAsset (forest entry,
327
327
  // schema-derived field dispatch, engine-self-contained GUID resolution).
328
328
  // serializeSceneAssetToPack now uses schema-derived refs[] index (D-1/D-2).
329
- export { rootsToSceneAsset, serializeSceneAssetToPack } from './collect-scene-asset';
329
+ export {
330
+ rootsToSceneAsset,
331
+ type SceneAssetGuidLookup,
332
+ serializeSceneAssetToPack,
333
+ } from './collect-scene-asset';
330
334
  // feat-20260626 M6 / m6-4: debug-draw auto-attach glue is re-exported from the
331
335
  // main barrel (was a separate tsup entry). The separate entry produced a SECOND
332
336
  // module copy of the mutable `registeredDebugDraw` registry: createApp set it on
@@ -4,7 +4,10 @@ import type {
4
4
  BundlerOptions,
5
5
  RendererHostAssembly,
6
6
  } from '@forgeax/engine-render/internal/construct-renderer';
7
- import { constructRendererHost } from '@forgeax/engine-render/internal/construct-renderer';
7
+ import {
8
+ constructRendererHost,
9
+ EngineEnvironmentError,
10
+ } from '@forgeax/engine-render/internal/construct-renderer';
8
11
  import { loadBackendPack } from './backend-selection';
9
12
 
10
13
  export type {
@@ -12,16 +15,40 @@ export type {
12
15
  RendererHostAssembly,
13
16
  } from '@forgeax/engine-render/internal/construct-renderer';
14
17
 
18
+ const FALLBACK_ERROR_CODES = new Set([
19
+ 'adapter-unavailable',
20
+ 'request-adapter-threw',
21
+ 'feature-not-enabled',
22
+ 'limit-exceeded',
23
+ 'rhi-not-available',
24
+ 'device-lost',
25
+ 'oom',
26
+ ]);
27
+
28
+ function canFallbackToWgpu(error: unknown): boolean {
29
+ if (!(error instanceof EngineEnvironmentError)) return false;
30
+ const webgpuError = error.detail.webgpuError;
31
+ if (webgpuError === undefined || typeof webgpuError !== 'object') return false;
32
+ if (!('code' in webgpuError)) return false;
33
+ const code = webgpuError.code;
34
+ return typeof code === 'string' && FALLBACK_ERROR_CODES.has(code);
35
+ }
36
+
15
37
  /** Assemble renderer plus asset services in the Runtime-owned backend host. */
16
38
  export async function constructRuntimeRendererHost(
17
39
  canvas: unknown,
18
40
  options?: RendererOptions,
19
41
  bundler?: BundlerOptions,
20
- ): Promise<RenderResult<RendererHostAssembly, RenderError>> {
42
+ ): Promise<RenderResult<RendererHostAssembly, RenderError | EngineEnvironmentError>> {
21
43
  const first = await loadBackendPack(options);
22
44
  if (!first.ok) throw first.error;
23
45
  const constructed = await constructRendererHost(canvas, options, bundler, first.value);
24
- if (constructed.ok || options?.rhi !== undefined || typeof globalThis === 'undefined') {
46
+ if (
47
+ constructed.ok ||
48
+ options?.rhi !== undefined ||
49
+ typeof globalThis === 'undefined' ||
50
+ !canFallbackToWgpu(constructed.error)
51
+ ) {
25
52
  return constructed;
26
53
  }
27
54
  const fallback = await loadBackendPack(options, true);