@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
@@ -120,6 +120,8 @@ 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 { createTemporalFrameTransaction } from '../../../render/src/temporal/frame';
124
+ import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
123
125
  import { drawWithOwners } from './renderer-test-utils';
124
126
 
125
127
  function componentFieldType(component: { fields: Record<string, { type: string }> }, name: string) {
@@ -761,6 +763,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
761
763
  [],
762
764
  {
763
765
  frameNumber: 0,
766
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
767
+ temporalFrameInput: undefined,
764
768
  compiledFrameGraph: null,
765
769
  compiledFrameGraphTopologyKey: null,
766
770
  retiredCompiledFrameGraphs: new Set(),
@@ -816,6 +820,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
816
820
  [],
817
821
  {
818
822
  frameNumber: 0,
823
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
824
+ temporalFrameInput: undefined,
819
825
  compiledFrameGraph: null,
820
826
  compiledFrameGraphTopologyKey: null,
821
827
  retiredCompiledFrameGraphs: new Set(),
@@ -1645,7 +1651,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
1645
1651
  sourcePath: `${identifier}.wgsl`,
1646
1652
  composedWgsl,
1647
1653
  paramSchema: '[]',
1648
- variants: [],
1654
+ variants:
1655
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
1649
1656
  });
1650
1657
  const manifest = {
1651
1658
  schemaVersion: '1.0.0',
@@ -2079,8 +2086,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2079
2086
  );
2080
2087
  });
2081
2088
 
2082
- it('imports View from forgeax_view::common', () => {
2083
- expect(source).toEqual(expect.stringContaining('#import forgeax_view::common::{View'));
2089
+ it('imports View from forgeax_view::common as the sole view ABI', () => {
2090
+ expect(source).toEqual(expect.stringContaining('#import forgeax_view::common::View'));
2091
+ expect(source).not.toContain('FogRay');
2092
+ expect(source).not.toContain('fogRayFromNdc');
2084
2093
  });
2085
2094
 
2086
2095
  it('imports fullscreen_triangle from forgeax_view::common', () => {
@@ -2359,10 +2368,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2359
2368
  });
2360
2369
  });
2361
2370
 
2362
- // ─── Assertion (d): assembleMaterialWithSkylightEntries merges 7 + 7 = 14 ───
2371
+ // ─── Assertion (d): assembleMaterialWithSkylightEntries adds the engine-owned pair ───
2363
2372
 
2364
2373
  describe('t40 round-4 (d) assembleMaterialWithSkylightEntries shape', () => {
2365
- it('returns 14 BindGroupEntry values; entry 7..13 reference the skylight resources', () => {
2374
+ it('returns 16 BindGroupEntry values; entry 7..13 reference the skylight resources', () => {
2366
2375
  const materialEntries = makeMaterialBindGroupEntries();
2367
2376
  const irrView = { __brand: 'TextureView', id: 1 } as unknown as TextureView;
2368
2377
  const irrSampler = { __brand: 'Sampler', id: 1 } as unknown as Sampler;
@@ -2382,7 +2391,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2382
2391
  intensityBuffer: intensityBuf,
2383
2392
  });
2384
2393
 
2385
- expect(merged).toHaveLength(14);
2394
+ expect(merged).toHaveLength(16);
2386
2395
 
2387
2396
  // Skylight entries at binding 7..13 in D-5 order
2388
2397
  expect(merged[7]?.binding).toBe(7);
@@ -2402,6 +2411,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2402
2411
  kind: 'buffer',
2403
2412
  value: { buffer: intensityBuf },
2404
2413
  });
2414
+ expect(merged[14]?.binding).toBe(14);
2415
+ expect(merged[15]?.binding).toBe(15);
2405
2416
  });
2406
2417
 
2407
2418
  it('derives IBL injection start from materialEntries.length (per-shader user-region, not a fixed 7)', () => {
@@ -2418,9 +2429,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2418
2429
  brdfLutSampler: {} as Sampler,
2419
2430
  intensityBuffer: {} as Buffer,
2420
2431
  });
2421
- expect(merged).toHaveLength(7);
2432
+ expect(merged).toHaveLength(9);
2422
2433
  expect(merged[0]?.binding).toBe(0);
2423
2434
  expect(merged[6]?.binding).toBe(6);
2435
+ expect(merged[7]?.binding).toBe(7);
2436
+ expect(merged[8]?.binding).toBe(8);
2424
2437
  });
2425
2438
  });
2426
2439
  }
@@ -2515,7 +2528,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2515
2528
  }
2516
2529
 
2517
2530
  describe('t58 (M4 round-4) -- material BG fallback path', () => {
2518
- it('(a) assemble with fallback returns 14 entries; binding 7..13 reference fallback resources', () => {
2531
+ it('(a) assemble with fallback returns 16 entries; binding 7..13 reference fallback resources', () => {
2519
2532
  const materialEntries = makeMaterialBindGroupEntries();
2520
2533
  const fallback = makeFallback();
2521
2534
  const fallbackAsActive: SkylightBindGroupResources = {
@@ -2528,7 +2541,7 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2528
2541
  intensityBuffer: fallback.intensityBuffer,
2529
2542
  };
2530
2543
  const merged = assembleMaterialWithSkylightEntries(materialEntries, fallbackAsActive);
2531
- expect(merged).toHaveLength(14);
2544
+ expect(merged).toHaveLength(16);
2532
2545
  expect(merged[7]?.binding).toBe(7);
2533
2546
  expect((merged[7]?.resource as { value: unknown }).value).toBe(fallback.irradianceView);
2534
2547
  expect((merged[8]?.resource as { value: unknown }).value).toBe(fallback.sampler);
@@ -2541,11 +2554,11 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2541
2554
  );
2542
2555
  });
2543
2556
 
2544
- it('(b) assemble with active returns 14 entries; binding 7..13 reference active IblPipelineCache resources', () => {
2557
+ it('(b) assemble with active returns 16 entries; binding 7..13 reference active IblPipelineCache resources', () => {
2545
2558
  const materialEntries = makeMaterialBindGroupEntries();
2546
2559
  const active = makeActive();
2547
2560
  const merged = assembleMaterialWithSkylightEntries(materialEntries, active);
2548
- expect(merged).toHaveLength(14);
2561
+ expect(merged).toHaveLength(16);
2549
2562
  expect((merged[7]?.resource as { value: unknown }).value).toBe(active.irradianceView);
2550
2563
  expect((merged[8]?.resource as { value: unknown }).value).toBe(active.irradianceSampler);
2551
2564
  expect((merged[9]?.resource as { value: unknown }).value).toBe(active.prefilterView);
@@ -2754,47 +2767,43 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2754
2767
  expect(layout?.label).toBe('pbr-pl');
2755
2768
  });
2756
2769
 
2757
- it('(b) PBR material BGL entry count === 24 (user region 0..12 + Skylight 13..19 + lightmap 20..23)', () => {
2770
+ it('(b) PBR material BGL entry count === 26 (user region 0..16 + Skylight 17..23 + transmission 24..25)', () => {
2758
2771
  const device = makeMockDevice();
2759
2772
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
2760
2773
  buildPbrPipelineLayouts(device as any, STORAGE_CAPS);
2761
2774
  const materialBgl = device.capturedBgls.find((b) => b.label === 'pbr-material-skylight-bgl');
2762
2775
  expect(materialBgl).toBeDefined();
2763
- expect(materialBgl?.entries).toHaveLength(24);
2776
+ expect(materialBgl?.entries).toHaveLength(26);
2764
2777
  });
2765
2778
 
2766
- it('(c) binding indices 0..23 in order; 13..19 resource types in D-5 round-4 order; 20..23 lightmap', () => {
2779
+ it('(c) binding indices 0..25 in order; 17..23 resource types in D-5 round-4 order; 24..25 transmission', () => {
2767
2780
  const device = makeMockDevice();
2768
2781
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
2769
2782
  buildPbrPipelineLayouts(device as any, STORAGE_CAPS);
2770
2783
  const materialBgl = device.capturedBgls.find((b) => b.label === 'pbr-material-skylight-bgl');
2771
2784
  expect(materialBgl).toBeDefined();
2772
2785
  const entries = materialBgl?.entries ?? [];
2773
- // binding indices 0..23 in order
2774
- for (let i = 0; i < 24; i++) {
2786
+ // binding indices 0..25 in order
2787
+ for (let i = 0; i < 26; i++) {
2775
2788
  expect(entries[i]?.binding).toBe(i);
2776
2789
  }
2777
- // 13..19 resource types
2778
- expect((entries[13] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2790
+ // 17..23 resource types
2791
+ expect((entries[17] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2779
2792
  'cube',
2780
2793
  );
2781
- expect((entries[14] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2782
- expect((entries[15] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2794
+ expect((entries[18] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2795
+ expect((entries[19] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2783
2796
  'cube',
2784
2797
  );
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
2798
  expect((entries[20] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2793
2799
  expect((entries[21] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2794
2800
  '2d',
2795
2801
  );
2796
2802
  expect((entries[22] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2797
- expect((entries[23] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2803
+ expect((entries[23] as { buffer?: { type: string } }).buffer?.type).toBe('uniform');
2804
+ // 24..25 transmission resource types
2805
+ expect((entries[24] as { sampler?: { type: string } }).sampler?.type).toBe('filtering');
2806
+ expect((entries[25] as { texture?: { viewDimension: string } }).texture?.viewDimension).toBe(
2798
2807
  '2d',
2799
2808
  );
2800
2809
  });
@@ -2820,13 +2829,13 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2820
2829
  // ─── (e)(f) Unlit material BGL shape + name ─────────────────────────────────
2821
2830
 
2822
2831
  describe('t57 (M4 round-4) -- unlitPipeline material BGL shape', () => {
2823
- it('(e) unlit material BGL entry count === 13 (no Skylight binding contamination)', () => {
2832
+ it('(e) unlit material BGL entry count === 17 (no Skylight binding contamination)', () => {
2824
2833
  const device = makeMockDevice();
2825
2834
  // biome-ignore lint/suspicious/noExplicitAny: structural mock
2826
2835
  buildUnlitMaterialBgl(device as any);
2827
2836
  const unlitBgl = device.capturedBgls.find((b) => b.label === 'unlit-material-bgl');
2828
2837
  expect(unlitBgl).toBeDefined();
2829
- expect(unlitBgl?.entries).toHaveLength(13);
2838
+ expect(unlitBgl?.entries).toHaveLength(17);
2830
2839
  });
2831
2840
 
2832
2841
  it("(f) PBR material BGL labelled 'pbr-material-skylight-bgl'; unlit labelled 'unlit-material-bgl'", () => {
@@ -2986,6 +2995,14 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2986
2995
  }
2987
2996
 
2988
2997
  function buildManifestDataUrl(): string {
2998
+ const materialShaderStub = (identifier: string) => ({
2999
+ identifier,
3000
+ sourcePath: `${identifier}.wgsl`,
3001
+ composedWgsl: '/* stub */',
3002
+ paramSchema: '[]',
3003
+ variants:
3004
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
3005
+ });
2989
3006
  const manifest = {
2990
3007
  schemaVersion: '1.0.0',
2991
3008
  entries: [
@@ -3008,6 +3025,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3008
3025
  bindings: '',
3009
3026
  },
3010
3027
  ],
3028
+ materialShaders: [
3029
+ materialShaderStub('forgeax::default-standard-pbr'),
3030
+ materialShaderStub('forgeax::default-unlit'),
3031
+ ],
3011
3032
  };
3012
3033
  return `data:application/json,${encodeURIComponent(JSON.stringify(manifest))}`;
3013
3034
  }
@@ -3436,6 +3457,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3436
3457
  [],
3437
3458
  {
3438
3459
  frameNumber: 0,
3460
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3461
+ temporalFrameInput: undefined,
3439
3462
  compiledFrameGraph: null,
3440
3463
  compiledFrameGraphTopologyKey: null,
3441
3464
  retiredCompiledFrameGraphs: new Set(),
@@ -3555,6 +3578,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3555
3578
  [],
3556
3579
  {
3557
3580
  frameNumber: 0,
3581
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3582
+ temporalFrameInput: undefined,
3558
3583
  compiledFrameGraph: null,
3559
3584
  compiledFrameGraphTopologyKey: null,
3560
3585
  retiredCompiledFrameGraphs: new Set(),
@@ -3628,6 +3653,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3628
3653
  [],
3629
3654
  {
3630
3655
  frameNumber: 0,
3656
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3657
+ temporalFrameInput: undefined,
3631
3658
  compiledFrameGraph: null,
3632
3659
  compiledFrameGraphTopologyKey: null,
3633
3660
  retiredCompiledFrameGraphs: new Set(),
@@ -7292,7 +7319,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
7292
7319
  sourcePath: `${identifier}.wgsl`,
7293
7320
  composedWgsl,
7294
7321
  paramSchema: '[]',
7295
- variants: [],
7322
+ variants:
7323
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
7296
7324
  });
7297
7325
  const manifest = {
7298
7326
  schemaVersion: '1.0.0',
@@ -7608,7 +7636,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
7608
7636
  sourcePath: `${identifier}.wgsl`,
7609
7637
  composedWgsl: '/* stub */',
7610
7638
  paramSchema: '[]',
7611
- variants: [],
7639
+ variants:
7640
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
7612
7641
  });
7613
7642
  const manifest = {
7614
7643
  schemaVersion: '1.0.0',
@@ -8693,7 +8722,8 @@ type ExtractFramesWithPipeline = (
8693
8722
  sourcePath: `${identifier}.wgsl`,
8694
8723
  composedWgsl: '/* stub */',
8695
8724
  paramSchema: '[]',
8696
- variants: [],
8725
+ variants:
8726
+ identifier === 'forgeax::default-standard-pbr' ? standardMaterialShaderVariants() : [],
8697
8727
  });
8698
8728
  const entries: Array<{ hash: string; wgsl: string; glsl: string; bindings: string }> = [
8699
8729
  { hash: 'pbr00000', wgsl: '/* pbr stub - calls f_schlick( */', glsl: '', bindings: '' },
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ const TEXTURE_BINDING = 0x04;
4
+ const COPY_DST = 0x08;
5
+ const COPY_SRC = 0x01;
6
+ const STORAGE_BINDING = 0x80;
7
+ const MAP_READ = 0x0001;
8
+ const COMPUTE_STAGE = 1;
9
+
10
+ type ProbeResult =
11
+ | { readonly status: 'ready'; readonly samples: readonly (readonly number[])[] }
12
+ | { readonly status: 'unavailable'; readonly reason: string };
13
+
14
+ async function probe(): Promise<ProbeResult> {
15
+ if (!('gpu' in navigator)) return { status: 'unavailable', reason: 'webgpu-unavailable' };
16
+ const adapter = await navigator.gpu.requestAdapter();
17
+ if (adapter === null) return { status: 'unavailable', reason: 'adapter-unavailable' };
18
+ const device = await adapter.requestDevice();
19
+ const texture = device.createTexture({
20
+ size: { width: 2, height: 2, depthOrArrayLayers: 2 },
21
+ dimension: '3d',
22
+ format: 'rgba8unorm',
23
+ usage: TEXTURE_BINDING | COPY_DST,
24
+ });
25
+ const arrayTexture = device.createTexture({
26
+ size: { width: 2, height: 2, depthOrArrayLayers: 2 },
27
+ dimension: '2d',
28
+ format: 'rgba8unorm',
29
+ usage: TEXTURE_BINDING | COPY_DST,
30
+ });
31
+ const samples = new Uint8Array(256 * 2 * 2);
32
+ for (let point = 0; point < 8; point += 1) {
33
+ const z = Math.floor(point / 4);
34
+ const y = Math.floor((point % 4) / 2);
35
+ const x = point % 2;
36
+ const offset = z * 512 + y * 256 + x * 4;
37
+ samples[offset] = point + 1;
38
+ samples[offset + 3] = 255;
39
+ }
40
+ const layout = { offset: 0, bytesPerRow: 256, rowsPerImage: 2 };
41
+ const extent = { width: 2, height: 2, depthOrArrayLayers: 2 };
42
+ device.queue.writeTexture({ texture: arrayTexture }, samples, layout, extent);
43
+ device.queue.writeTexture({ texture }, samples, layout, extent);
44
+ const output = device.createBuffer({ size: 256, usage: STORAGE_BINDING | COPY_SRC });
45
+ const readback = device.createBuffer({ size: 256, usage: MAP_READ | COPY_DST });
46
+ const module = device.createShaderModule({
47
+ code: `
48
+ @group(0) @binding(0) var array_tex: texture_2d_array<f32>;
49
+ @group(0) @binding(1) var volume_tex: texture_3d<f32>;
50
+ @group(0) @binding(2) var<storage, read_write> output: array<vec4<u32>>;
51
+ fn to_byte(value: f32) -> u32 {
52
+ return u32(round(value * 255.0));
53
+ }
54
+ @compute @workgroup_size(1)
55
+ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
56
+ let point = id.x;
57
+ let coord = vec3<i32>(i32(point % 2u), i32((point / 2u) % 2u), i32(point / 4u));
58
+ let array_value = textureLoad(array_tex, coord.xy, coord.z, 0);
59
+ let volume_value = textureLoad(volume_tex, coord, 0);
60
+ output[point] = vec4<u32>(to_byte(array_value.r), 0u, 0u, 0u);
61
+ output[point + 8u] = vec4<u32>(to_byte(volume_value.r), 0u, 0u, 0u);
62
+ }
63
+ `,
64
+ });
65
+ const bindGroupLayout = device.createBindGroupLayout({
66
+ entries: [
67
+ { binding: 0, visibility: COMPUTE_STAGE, texture: { viewDimension: '2d-array' } },
68
+ { binding: 1, visibility: COMPUTE_STAGE, texture: { viewDimension: '3d' } },
69
+ {
70
+ binding: 2,
71
+ visibility: COMPUTE_STAGE,
72
+ buffer: { type: 'storage' },
73
+ },
74
+ ],
75
+ });
76
+ const pipeline = device.createComputePipeline({
77
+ layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
78
+ compute: { module, entryPoint: 'main' },
79
+ });
80
+ const bindGroup = device.createBindGroup({
81
+ layout: bindGroupLayout,
82
+ entries: [
83
+ { binding: 0, resource: arrayTexture.createView({ dimension: '2d-array' }) },
84
+ { binding: 1, resource: texture.createView({ dimension: '3d' }) },
85
+ { binding: 2, resource: { buffer: output } },
86
+ ],
87
+ });
88
+ const encoder = device.createCommandEncoder();
89
+ const pass = encoder.beginComputePass();
90
+ pass.setPipeline(pipeline);
91
+ pass.setBindGroup(0, bindGroup);
92
+ pass.dispatchWorkgroups(8);
93
+ pass.end();
94
+ encoder.copyBufferToBuffer(output, 0, readback, 0, 256);
95
+ device.queue.submit([encoder.finish()]);
96
+ await readback.mapAsync(MAP_READ);
97
+ const actual = new Uint8Array(readback.getMappedRange()).slice();
98
+ readback.unmap();
99
+ const observed = Array.from({ length: 8 }, (_, point) => [
100
+ actual[point * 16] ?? 0,
101
+ actual[(point + 8) * 16] ?? 0,
102
+ ]);
103
+ const expected = Array.from({ length: 8 }, (_, point) => point + 1);
104
+ if (
105
+ observed.some(
106
+ ([arraySample, volumeSample], point) =>
107
+ arraySample !== expected[point] || volumeSample !== expected[point],
108
+ )
109
+ ) {
110
+ return { status: 'unavailable', reason: 'gpu-readback-mismatch' };
111
+ }
112
+ return { status: 'ready', samples: observed };
113
+ }
114
+
115
+ describe('browser texture dimensions', () => {
116
+ it('returns an observable WebGPU status instead of silently accepting a bad view', async () => {
117
+ const result = await probe();
118
+ expect(['ready', 'unavailable']).toContain(result.status);
119
+ if (result.status === 'unavailable') expect(result.reason.length).toBeGreaterThan(0);
120
+ if (result.status === 'ready') {
121
+ expect(result.samples).toHaveLength(8);
122
+ expect(
123
+ result.samples.map(([arraySample, volumeSample]) => [arraySample, volumeSample]),
124
+ ).toEqual(Array.from({ length: 8 }, (_, point) => [point + 1, point + 1]));
125
+ }
126
+ });
127
+ });
@@ -126,7 +126,11 @@ function wireFetch(
126
126
  {
127
127
  guid: GUID_TEX,
128
128
  kind: 'texture',
129
- payload: { width: 4, height: 4, colorSpace },
129
+ payload: {
130
+ shape: { viewDimension: '2d', extent: { width: 4, height: 4 } },
131
+ colorSpace,
132
+ mips: { kind: 'none' },
133
+ },
130
134
  refs: [],
131
135
  artifacts: {
132
136
  body: {
@@ -0,0 +1,180 @@
1
+ // @perf-budget-skip: intentional full Renderer submit-failure and LKG recovery integration gate.
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import {
4
+ Camera,
5
+ DirectionalLight,
6
+ perspective,
7
+ TONEMAP_ACES_FILMIC,
8
+ VolumetricFog,
9
+ } from '@forgeax/engine-render';
10
+ import type { CommandBuffer, RhiQueue } from '@forgeax/engine-rhi';
11
+ import { RhiError } from '@forgeax/engine-rhi';
12
+ import { rhi as nullRhi, RhiNullAdapter } from '@forgeax/engine-rhi-null';
13
+ import { Transform } from '@forgeax/engine-scene';
14
+ import { err, type Result, type TextureAsset } from '@forgeax/engine-types';
15
+ import { describe, expect, it } from 'vitest';
16
+ import { createRenderer } from '../createRenderer';
17
+
18
+ const VOLUME_MANIFEST = `data:application/json,${encodeURIComponent(
19
+ JSON.stringify({
20
+ entries: [
21
+ { hash: 'pbr', wgsl: 'f_schlick', glsl: '', bindings: '' },
22
+ { hash: 'unlit', wgsl: 'unlit', glsl: '', bindings: '' },
23
+ { hash: 'tonemap', wgsl: 'struct TonemapParams {}', glsl: '', bindings: '' },
24
+ { hash: 'volume-inject', wgsl: 'fn volume_inject() {}', glsl: '', bindings: '' },
25
+ { hash: 'volume-integrate', wgsl: 'fn volume_integrate() {}', glsl: '', bindings: '' },
26
+ { hash: 'volume-temporal', wgsl: 'fn volume_temporal() {}', glsl: '', bindings: '' },
27
+ { hash: 'volume-composite', wgsl: 'fn volume_fs() {}', glsl: '', bindings: '' },
28
+ ],
29
+ }),
30
+ )}`;
31
+
32
+ function canvas(): HTMLCanvasElement {
33
+ return { width: 64, height: 64, getContext: () => null } as unknown as HTMLCanvasElement;
34
+ }
35
+
36
+ function density(): TextureAsset {
37
+ return {
38
+ kind: 'texture',
39
+ shape: { viewDimension: '3d', extent: { width: 64, height: 64, depth: 64 } },
40
+ format: 'r8unorm',
41
+ colorSpace: 'linear',
42
+ mips: { kind: 'none' },
43
+ data: new Uint8Array(64 * 64 * 64).fill(32),
44
+ };
45
+ }
46
+
47
+ function failingNullRhi(controller: { failNext: boolean }) {
48
+ const adapter = new RhiNullAdapter();
49
+ const requestDevice = adapter.requestDevice.bind(adapter);
50
+ adapter.requestDevice = async (options) => {
51
+ const result = await requestDevice(options);
52
+ if (!result.ok) return result;
53
+ const queue: RhiQueue = result.value.queue;
54
+ const submit = queue.submit.bind(queue);
55
+ queue.submit = (buffers: readonly CommandBuffer[]): Result<void, RhiError> => {
56
+ if (controller.failNext) {
57
+ controller.failNext = false;
58
+ return err(
59
+ new RhiError({
60
+ code: 'webgpu-runtime-error',
61
+ expected: 'queue submission to succeed',
62
+ hint: 'the recovery regression intentionally rejects one submit',
63
+ }),
64
+ );
65
+ }
66
+ return submit(buffers);
67
+ };
68
+ return result;
69
+ };
70
+ return {
71
+ ...nullRhi,
72
+ requestAdapter: async () => ({ ok: true, value: adapter }),
73
+ };
74
+ }
75
+
76
+ describe('volumetric fog record recovery projection', () => {
77
+ it('keeps accepted LKG inspection on a failed submit and clears it after repair', async () => {
78
+ const controller = { failNext: false };
79
+ const created = await createRenderer(
80
+ canvas(),
81
+ { rhi: failingNullRhi(controller) as never },
82
+ { shaderManifestUrl: VOLUME_MANIFEST },
83
+ );
84
+ expect(created.ok).toBe(true);
85
+ if (!created.ok) return;
86
+ const renderer = created.value;
87
+
88
+ const world = new World();
89
+ const densityHandle = world.allocSharedRef('TextureAsset', density());
90
+ world
91
+ .spawn(
92
+ { component: Transform, data: { pos: [0, 0, 3] } },
93
+ {
94
+ component: Camera,
95
+ data: {
96
+ ...perspective({ fov: Math.PI / 4, aspect: 1 }),
97
+ tonemap: TONEMAP_ACES_FILMIC,
98
+ },
99
+ },
100
+ )
101
+ .unwrap();
102
+ const directionalLight = world
103
+ .spawn({
104
+ component: DirectionalLight,
105
+ data: { direction: [-0.4, -0.8, -0.3], castShadow: true },
106
+ })
107
+ .unwrap();
108
+ const fogEntity = world
109
+ .spawn({
110
+ component: VolumetricFog,
111
+ data: {
112
+ light: directionalLight,
113
+ density: densityHandle,
114
+ boundsMin: [-1, -1, -1],
115
+ boundsMax: [1, 1, 1],
116
+ extinction: [0.2, 0.2, 0.2],
117
+ albedo: [0.8, 0.8, 0.8],
118
+ emission: [0, 0, 0],
119
+ anisotropy: 0,
120
+ maxDistance: 50,
121
+ },
122
+ })
123
+ .unwrap();
124
+ expect(world.update(1 / 60).ok).toBe(true);
125
+
126
+ const lease = renderer.attach(world);
127
+ expect(lease.ok).toBe(true);
128
+ if (!lease.ok) return;
129
+ const request = {
130
+ leases: [lease.value],
131
+ camera: { lease: lease.value },
132
+ environment: { lease: lease.value },
133
+ } as const;
134
+
135
+ expect(renderer.draw(request).ok).toBe(true);
136
+ expect(renderer.inspect().volumetricFog).toMatchObject({
137
+ status: 'available',
138
+ resourceStage: 'accepted',
139
+ generation: 1,
140
+ passCount: 4,
141
+ });
142
+
143
+ controller.failNext = true;
144
+ expect(renderer.draw(request).ok).toBe(false);
145
+ expect(renderer.inspect().volumetricFog).toMatchObject({
146
+ status: 'degraded',
147
+ resourceStage: 'lkg',
148
+ generation: 1,
149
+ lkgGeneration: 1,
150
+ candidateGeneration: 1,
151
+ candidateFailure: 'submit-failed',
152
+ passCount: 4,
153
+ // The packed high profile uses a 64x64x16 physical froxel texture,
154
+ // representing 64 logical slices per pixel. Inspection reports the
155
+ // descriptor-derived allocation bytes for this recovery fixture.
156
+ sampleCount: 64 * 64 * 16,
157
+ memoryBytes: 71808,
158
+ });
159
+
160
+ expect(renderer.draw(request).ok).toBe(true);
161
+ expect(renderer.inspect().volumetricFog).toMatchObject({
162
+ status: 'available',
163
+ resourceStage: 'accepted',
164
+ generation: 1,
165
+ passCount: 4,
166
+ candidateFailure: undefined,
167
+ });
168
+
169
+ expect(world.removeComponent(fogEntity, VolumetricFog).ok).toBe(true);
170
+ expect(world.update(1 / 60).ok).toBe(true);
171
+ expect(renderer.draw(request).ok).toBe(true);
172
+ expect(renderer.inspect().volumetricFog).toMatchObject({
173
+ status: 'off',
174
+ passCount: 0,
175
+ sampleCount: 0,
176
+ memoryBytes: 0,
177
+ });
178
+ expect((await renderer.dispose()).ok).toBe(true);
179
+ });
180
+ });
@@ -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
  };
@@ -30,6 +30,9 @@ export async function createRenderer(
30
30
  ...(options.rhi === undefined ? {} : { rhi: options.rhi }),
31
31
  ...(options.features === undefined ? {} : { features: options.features }),
32
32
  ...(options.profiler === undefined ? {} : { profiler: options.profiler }),
33
+ ...(options.captureGpuTimings === undefined
34
+ ? {}
35
+ : { captureGpuTimings: options.captureGpuTimings }),
33
36
  ...(options.rhiInstrumentation === undefined
34
37
  ? {}
35
38
  : { rhiInstrumentation: options.rhiInstrumentation }),