@forgeax/engine-runtime 0.1.19 → 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 (54) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
  3. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
  4. package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  6. package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
  7. package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
  8. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
  9. package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
  10. package/dist/createRenderer.d.ts.map +1 -1
  11. package/dist/index.mjs +1 -0
  12. package/dist/index.mjs.map +1 -1
  13. package/package.json +30 -30
  14. package/src/__tests__/asset-registry.recursive.spec.ts +12 -18
  15. package/src/__tests__/asset.unit.test.ts +28 -25
  16. package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
  17. package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
  18. package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
  19. package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
  20. package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
  21. package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
  22. package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
  23. package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
  24. package/src/__tests__/derive-bgl-integration.test.ts +11 -0
  25. package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
  26. package/src/__tests__/errors.unit.test.ts +20 -0
  27. package/src/__tests__/four-path-compression.integration.test.ts +2 -2
  28. package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
  29. package/src/__tests__/helpers/tilemap-assets.ts +3 -4
  30. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
  31. package/src/__tests__/lights.unit.test.ts +38 -17
  32. package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
  33. package/src/__tests__/loader-name-survival.test.ts +2 -3
  34. package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
  35. package/src/__tests__/material-texture-uv-scale.unit.test.ts +10 -2
  36. package/src/__tests__/mip-gate.unit.test.ts +11 -8
  37. package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
  38. package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
  39. package/src/__tests__/pbr-pipeline.unit.test.ts +23 -4
  40. package/src/__tests__/pipeline.unit.test.ts +18 -19
  41. package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
  42. package/src/__tests__/render-error-exhaustive.test-d.ts +29 -0
  43. package/src/__tests__/render-system-extract.test.ts +4 -6
  44. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -6
  45. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +2 -3
  46. package/src/__tests__/shadow-fields-observable.dawn.test.ts +56 -39
  47. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
  48. package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
  49. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
  50. package/src/__tests__/systems.unit.test.ts +15 -2
  51. package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
  52. package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
  53. package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
  54. package/src/createRenderer.ts +3 -0
@@ -33,6 +33,11 @@ const WIDTH = 256;
33
33
  const HEIGHT = 256;
34
34
  const BPR = Math.ceil((WIDTH * 4) / 256) * 256;
35
35
 
36
+ // Each falsifier performs four fresh 256x256 Dawn readbacks. Windows' Dawn
37
+ // prebuild can exceed Vitest's 90-second bound for that sequence while still
38
+ // completing well inside the nightly platform job's 45-minute budget.
39
+ const SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS = 300_000;
40
+
36
41
  const ENGINE_MANIFEST = await (async () => {
37
42
  const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
38
43
  return buildEngineShaderManifest();
@@ -582,45 +587,57 @@ describe('M6 shadow fields observability', () => {
582
587
  // (physically removing the UBO write), comment out the matching
583
588
  // `viewPayload[126|127|128] = ...` line in render-system-record.ts and re-run
584
589
  // the corresponding `varied` leg — its diff collapses to 0, matching `held`.
585
- it('falsification: depthBias varied differs, held does not (signal attributable to the field)', async () => {
586
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
587
- const varied = diff(
588
- await renderConfig(true, 0.0, 0.05, 3),
589
- await renderConfig(true, 0.5, 0.05, 3),
590
- );
591
- const held = diff(
592
- await renderConfig(true, 0.0, 0.05, 3),
593
- await renderConfig(true, 0.0, 0.05, 3),
594
- );
595
- expect(varied.diff).toBeGreaterThan(0);
596
- expect(held.diff).toBe(0);
597
- }, 90000);
590
+ it(
591
+ 'falsification: depthBias varied differs, held does not (signal attributable to the field)',
592
+ async () => {
593
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
594
+ const varied = diff(
595
+ await renderConfig(true, 0.0, 0.05, 3),
596
+ await renderConfig(true, 0.5, 0.05, 3),
597
+ );
598
+ const held = diff(
599
+ await renderConfig(true, 0.0, 0.05, 3),
600
+ await renderConfig(true, 0.0, 0.05, 3),
601
+ );
602
+ expect(varied.diff).toBeGreaterThan(0);
603
+ expect(held.diff).toBe(0);
604
+ },
605
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
606
+ );
598
607
 
599
- it('falsification: normalBias varied differs, held does not', async () => {
600
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
601
- const varied = diff(
602
- await renderConfig(true, 0.005, 0.0, 3),
603
- await renderConfig(true, 0.005, 0.5, 3),
604
- );
605
- const held = diff(
606
- await renderConfig(true, 0.005, 0.0, 3),
607
- await renderConfig(true, 0.005, 0.0, 3),
608
- );
609
- expect(varied.diff).toBeGreaterThan(0);
610
- expect(held.diff).toBe(0);
611
- }, 90000);
608
+ it(
609
+ 'falsification: normalBias varied differs, held does not',
610
+ async () => {
611
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
612
+ const varied = diff(
613
+ await renderConfig(true, 0.005, 0.0, 3),
614
+ await renderConfig(true, 0.005, 0.5, 3),
615
+ );
616
+ const held = diff(
617
+ await renderConfig(true, 0.005, 0.0, 3),
618
+ await renderConfig(true, 0.005, 0.0, 3),
619
+ );
620
+ expect(varied.diff).toBeGreaterThan(0);
621
+ expect(held.diff).toBe(0);
622
+ },
623
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
624
+ );
612
625
 
613
- it('falsification: pcfKernelSize varied differs, held does not', async () => {
614
- if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
615
- const varied = diff(
616
- await renderConfig(true, 0.005, 0.05, 1),
617
- await renderConfig(true, 0.005, 0.05, 5),
618
- );
619
- const held = diff(
620
- await renderConfig(true, 0.005, 0.05, 1),
621
- await renderConfig(true, 0.005, 0.05, 1),
622
- );
623
- expect(varied.diff).toBeGreaterThan(0);
624
- expect(held.diff).toBe(0);
625
- }, 90000);
626
+ it(
627
+ 'falsification: pcfKernelSize varied differs, held does not',
628
+ async () => {
629
+ if (typeof globalThis.navigator?.gpu?.requestAdapter !== 'function') return;
630
+ const varied = diff(
631
+ await renderConfig(true, 0.005, 0.05, 1),
632
+ await renderConfig(true, 0.005, 0.05, 5),
633
+ );
634
+ const held = diff(
635
+ await renderConfig(true, 0.005, 0.05, 1),
636
+ await renderConfig(true, 0.005, 0.05, 1),
637
+ );
638
+ expect(varied.diff).toBeGreaterThan(0);
639
+ expect(held.diff).toBe(0);
640
+ },
641
+ SHADOW_FIELDS_FALSIFIER_TIMEOUT_MS,
642
+ );
626
643
  });
@@ -312,7 +312,9 @@ async function renderMixed(castShadow: boolean): Promise<MixedCapture> {
312
312
  if (!host.ok) throw host.error;
313
313
  renderer = host.value.renderer;
314
314
  unsubscribe = renderer.subscribe((event) => {
315
- if (event.kind === 'error') errors.push(event.error.code);
315
+ if (event.kind === 'error') {
316
+ errors.push(event.error.code);
317
+ }
316
318
  });
317
319
  const world = new World();
318
320
  spawnMixedScene(world, castShadow);
@@ -229,12 +229,11 @@ describe('feat-20260608 M2 m2-t5: sprite pipeline cullMode "none" lets H/V flipp
229
229
  const synth = buildSyntheticRgba();
230
230
  const synthPod = {
231
231
  kind: 'texture' as const,
232
- width: synth.width,
233
- height: synth.height,
232
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
234
233
  format: 'rgba8unorm-srgb' as const,
235
234
  data: synth.data,
236
235
  colorSpace: 'srgb' as const,
237
- mipmap: false,
236
+ mips: { kind: 'none' as const },
238
237
  };
239
238
  const textureHandle = worldFlip.allocSharedRef('TextureAsset', synthPod as never);
240
239
  const samplerHandle = worldFlip.allocSharedRef('SamplerAsset', {
@@ -382,12 +381,11 @@ describe('feat-20260608 M2 m2-t5: sprite pipeline cullMode "none" lets H/V flipp
382
381
  const synth = buildSyntheticRgba();
383
382
  const synthPod = {
384
383
  kind: 'texture' as const,
385
- width: synth.width,
386
- height: synth.height,
384
+ shape: { viewDimension: '2d' as const, extent: { width: synth.width, height: synth.height } },
387
385
  format: 'rgba8unorm-srgb' as const,
388
386
  data: synth.data,
389
387
  colorSpace: 'srgb' as const,
390
- mipmap: false,
388
+ mips: { kind: 'none' as const },
391
389
  };
392
390
  const textureHandle = worldFlipBoth.allocSharedRef('TextureAsset', synthPod as never);
393
391
  const samplerHandle = worldFlipBoth.allocSharedRef('SamplerAsset', {
@@ -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 10-entry pbr-view BGL under storage-buffer caps', () => {
50
+ it('sprite-lit vs sprite share the same 12-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,7 +59,9 @@ 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(10); // binding 10 is the shared Points/Lines view UBO
62
+ // binding 10 is the shared Points/Lines view UBO; bindings 11/12 are the
63
+ // optional SpotLight projector texture and sampler on capable devices.
64
+ expect(spriteLit.entries.length).toBe(12);
63
65
  });
64
66
 
65
67
  it('sprite-lit vs sprite share the same 10-entry pbr-view BGL under uniform fallback caps (AC-10)', () => {
@@ -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 { createTemporalFrameTransaction } from '../../../render/src/temporal/frame';
123
124
  import { standardMaterialShaderVariants } from './helpers/standard-material-manifest';
124
125
  import { drawWithOwners } from './renderer-test-utils';
125
126
 
@@ -762,6 +763,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
762
763
  [],
763
764
  {
764
765
  frameNumber: 0,
766
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
767
+ temporalFrameInput: undefined,
765
768
  compiledFrameGraph: null,
766
769
  compiledFrameGraphTopologyKey: null,
767
770
  retiredCompiledFrameGraphs: new Set(),
@@ -817,6 +820,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
817
820
  [],
818
821
  {
819
822
  frameNumber: 0,
823
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
824
+ temporalFrameInput: undefined,
820
825
  compiledFrameGraph: null,
821
826
  compiledFrameGraphTopologyKey: null,
822
827
  retiredCompiledFrameGraphs: new Set(),
@@ -2081,8 +2086,10 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
2081
2086
  );
2082
2087
  });
2083
2088
 
2084
- it('imports View from forgeax_view::common', () => {
2085
- 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');
2086
2093
  });
2087
2094
 
2088
2095
  it('imports fullscreen_triangle from forgeax_view::common', () => {
@@ -3450,6 +3457,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3450
3457
  [],
3451
3458
  {
3452
3459
  frameNumber: 0,
3460
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3461
+ temporalFrameInput: undefined,
3453
3462
  compiledFrameGraph: null,
3454
3463
  compiledFrameGraphTopologyKey: null,
3455
3464
  retiredCompiledFrameGraphs: new Set(),
@@ -3569,6 +3578,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3569
3578
  [],
3570
3579
  {
3571
3580
  frameNumber: 0,
3581
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3582
+ temporalFrameInput: undefined,
3572
3583
  compiledFrameGraph: null,
3573
3584
  compiledFrameGraphTopologyKey: null,
3574
3585
  retiredCompiledFrameGraphs: new Set(),
@@ -3642,6 +3653,8 @@ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
3642
3653
  [],
3643
3654
  {
3644
3655
  frameNumber: 0,
3656
+ temporalFrameTransaction: createTemporalFrameTransaction({ deviceEpoch: 0 }),
3657
+ temporalFrameInput: undefined,
3645
3658
  compiledFrameGraph: null,
3646
3659
  compiledFrameGraphTopologyKey: null,
3647
3660
  retiredCompiledFrameGraphs: new Set(),
@@ -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
+ });
@@ -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 }),