@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/helpers/tilemap-assets.d.ts.map +1 -1
- package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts +2 -0
- package/dist/__tests__/material-texture-dimension-mismatch.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
- package/dist/__tests__/texture-dimensions.browser.test.d.ts +2 -0
- package/dist/__tests__/texture-dimensions.browser.test.d.ts.map +1 -0
- package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/volumetric-fog-record-recovery.integration.test.d.ts.map +1 -0
- package/dist/createRenderer.d.ts.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +30 -30
- package/src/__tests__/asset-registry.recursive.spec.ts +12 -18
- package/src/__tests__/asset.unit.test.ts +28 -25
- package/src/__tests__/basis-catalog-dispatch.integration.test.ts +10 -9
- package/src/__tests__/compressed-texture-physical-extent.integration.test.ts +12 -10
- package/src/__tests__/dawn/hello-sprite-nineslice-section.dawn.test.ts +2 -3
- package/src/__tests__/dawn/material-alpha.dawn.test.ts +2 -3
- package/src/__tests__/dawn/mipmap-srgb-linear.dawn.test.ts +2 -3
- package/src/__tests__/dawn/per-entity-material-texture-isolation.dawn.test.ts +2 -3
- package/src/__tests__/dawn/sprite-nineslice-mesh-bind.dawn.test.ts +2 -3
- package/src/__tests__/dawn/upload-texture.dawn.test.ts +4 -6
- package/src/__tests__/derive-bgl-integration.test.ts +11 -0
- package/src/__tests__/equirect-bc6h.integration.test.ts +6 -4
- package/src/__tests__/errors.unit.test.ts +20 -0
- package/src/__tests__/four-path-compression.integration.test.ts +2 -2
- package/src/__tests__/gpu-resource-cow-survivor.test.ts +2 -3
- package/src/__tests__/helpers/tilemap-assets.ts +3 -4
- package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +4 -2
- package/src/__tests__/lights.unit.test.ts +38 -17
- package/src/__tests__/loadbyguid-texture-intern.unit.test.ts +3 -4
- package/src/__tests__/loader-name-survival.test.ts +2 -3
- package/src/__tests__/material-texture-dimension-mismatch.unit.test.ts +18 -0
- package/src/__tests__/material-texture-uv-scale.unit.test.ts +10 -2
- package/src/__tests__/mip-gate.unit.test.ts +11 -8
- package/src/__tests__/msaa-sprite-pixel-diff.dawn.test.ts +2 -3
- package/src/__tests__/new-kind-refs-contract.test.ts +2 -3
- package/src/__tests__/pbr-pipeline.unit.test.ts +23 -4
- package/src/__tests__/pipeline.unit.test.ts +18 -19
- package/src/__tests__/point-light-shadow.unit.test.ts +6 -5
- package/src/__tests__/render-error-exhaustive.test-d.ts +29 -0
- package/src/__tests__/render-system-extract.test.ts +4 -6
- package/src/__tests__/render-system-record-multi-material-textureview.test.ts +4 -6
- package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +2 -3
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +56 -39
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +3 -1
- package/src/__tests__/sprite-cullmode-flip.dawn.test.ts +4 -6
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +4 -2
- package/src/__tests__/systems.unit.test.ts +15 -2
- package/src/__tests__/texture-dimensions.browser.test.ts +127 -0
- package/src/__tests__/transcode-fallback.unit.test.ts +5 -1
- package/src/__tests__/volumetric-fog-record-recovery.integration.test.ts +180 -0
- 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(
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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(
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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::
|
|
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: {
|
|
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
|
+
});
|
package/src/createRenderer.ts
CHANGED
|
@@ -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 }),
|