@forgeax/engine-render 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/assembly/factory.d.ts +16 -5
- package/dist/assembly/factory.d.ts.map +1 -1
- package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
- package/dist/chunk-4XINDLAY.mjs.map +1 -0
- package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
- package/dist/chunk-CDTWTHZR.mjs.map +1 -0
- package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
- package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
- package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
- package/dist/chunk-TBQJOK2L.mjs.map +1 -0
- package/dist/components/light-helpers.d.ts.map +1 -1
- package/dist/components/spot-light.d.ts +12 -0
- package/dist/components/spot-light.d.ts.map +1 -1
- package/dist/construct-renderer.mjs +2281 -164
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/device/gpu-residency.d.ts +19 -2
- package/dist/device/gpu-residency.d.ts.map +1 -1
- package/dist/errors/render.d.ts +69 -1
- package/dist/errors/render.d.ts.map +1 -1
- package/dist/gpu-usage.d.ts +2 -0
- package/dist/gpu-usage.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/internal.mjs +1 -1
- package/dist/light-buffer-layout.d.ts +20 -12
- package/dist/light-buffer-layout.d.ts.map +1 -1
- package/dist/pbr-pipeline.d.ts +6 -3
- package/dist/pbr-pipeline.d.ts.map +1 -1
- package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
- package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
- package/dist/pipeline/standard-profile.d.ts +13 -0
- package/dist/pipeline/standard-profile.d.ts.map +1 -1
- package/dist/record/frame-lighting.d.ts +2 -0
- package/dist/record/frame-lighting.d.ts.map +1 -1
- package/dist/record/frame-snapshot.d.ts +37 -1
- package/dist/record/frame-snapshot.d.ts.map +1 -1
- package/dist/record/frame-targets.d.ts.map +1 -1
- package/dist/record/frame.d.ts +3 -2
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/gpu-timing.d.ts +32 -0
- package/dist/record/gpu-timing.d.ts.map +1 -0
- package/dist/record/main-pass-material.d.ts +1 -1
- package/dist/record/main-pass-material.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +5 -1
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts +11 -3
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/render-contract.d.ts +66 -1
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-data.d.ts +10 -6
- package/dist/render-data.d.ts.map +1 -1
- package/dist/render-pipeline.d.ts +36 -0
- package/dist/render-pipeline.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +55 -3
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts +7 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/renderer-inspect.d.ts +29 -0
- package/dist/renderer-inspect.d.ts.map +1 -0
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/dist/temporal/frame.d.ts +44 -0
- package/dist/temporal/frame.d.ts.map +1 -0
- package/dist/temporal/index.mjs +3 -3
- package/dist/typed-render-graph-primitives.d.ts +6 -1
- package/dist/typed-render-graph-primitives.d.ts.map +1 -1
- package/dist/typed-shadow-atlas.d.ts +10 -0
- package/dist/typed-shadow-atlas.d.ts.map +1 -0
- package/dist/typed-shadow-passes.d.ts.map +1 -1
- package/dist/volume/capability.d.ts +57 -0
- package/dist/volume/capability.d.ts.map +1 -0
- package/dist/volume/component.d.ts +61 -0
- package/dist/volume/component.d.ts.map +1 -0
- package/dist/volume/extract.d.ts +11 -0
- package/dist/volume/extract.d.ts.map +1 -0
- package/dist/volume/inspection.d.ts +38 -0
- package/dist/volume/inspection.d.ts.map +1 -0
- package/dist/volume/passes.d.ts +38 -0
- package/dist/volume/passes.d.ts.map +1 -0
- package/dist/volume/recovery.d.ts +38 -0
- package/dist/volume/recovery.d.ts.map +1 -0
- package/dist/volume/resources.d.ts +57 -0
- package/dist/volume/resources.d.ts.map +1 -0
- package/dist/volume/temporal.d.ts +35 -0
- package/dist/volume/temporal.d.ts.map +1 -0
- package/package.json +20 -20
- package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
- package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
- package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
- package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
- package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
- package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
- package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
- package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
- package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
- package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
- package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
- package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
- package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
- package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
- package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
- package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
- package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
- package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
- package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
- package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
- package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
- package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
- package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
- package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
- package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
- package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
- package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
- package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
- package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
- package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
- package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
- package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
- package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
- package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
- package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
- package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
- package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
- package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
- package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
- package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
- package/src/assembly/factory.ts +271 -15
- package/src/components/light-helpers.ts +4 -0
- package/src/components/spot-light.ts +22 -3
- package/src/device/gpu-residency.ts +168 -58
- package/src/errors/render.ts +119 -1
- package/src/gpu-usage.ts +2 -0
- package/src/index.ts +38 -1
- package/src/light-buffer-layout.ts +59 -26
- package/src/pbr-pipeline.ts +33 -10
- package/src/pipeline/standard-forward-lane.ts +20 -0
- package/src/pipeline/standard-pipeline.ts +20 -0
- package/src/pipeline/standard-profile.ts +23 -0
- package/src/record/frame-lighting.ts +103 -89
- package/src/record/frame-snapshot.ts +41 -1
- package/src/record/frame-targets.ts +6 -2
- package/src/record/frame.ts +908 -5
- package/src/record/gpu-timing.ts +224 -0
- package/src/record/main-pass-material.ts +6 -2
- package/src/record/render-context.ts +5 -0
- package/src/record/shadow-pass.ts +87 -38
- package/src/record/typed-frame-graph.ts +143 -8
- package/src/render-contract.ts +72 -0
- package/src/render-data.ts +50 -22
- package/src/render-pipeline.ts +44 -0
- package/src/render-system-extract.ts +329 -3
- package/src/render-system.ts +79 -0
- package/src/renderer-inspect.ts +66 -0
- package/src/scene/render-scene.ts +2 -0
- package/src/temporal/frame.ts +128 -0
- package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
- package/src/typed-render-graph-primitives.ts +32 -2
- package/src/typed-shadow-atlas.ts +17 -0
- package/src/typed-shadow-passes.ts +6 -5
- package/src/volume/capability.ts +173 -0
- package/src/volume/component.ts +110 -0
- package/src/volume/extract.ts +21 -0
- package/src/volume/inspection.ts +78 -0
- package/src/volume/passes.ts +680 -0
- package/src/volume/recovery.ts +86 -0
- package/src/volume/resources.ts +236 -0
- package/src/volume/temporal.ts +89 -0
- package/dist/chunk-IPXCABEP.mjs.map +0 -1
- package/dist/chunk-LOSO4CTV.mjs.map +0 -1
- package/dist/chunk-RJRUS55L.mjs.map +0 -1
- package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type VolumetricFogAuthoring, validateVolumetricFog } from '../volume/component';
|
|
4
|
+
|
|
5
|
+
const density = {
|
|
6
|
+
guid: 'density-guid',
|
|
7
|
+
generation: 3,
|
|
8
|
+
shape: { viewDimension: '3d', extent: { width: 64, height: 64, depth: 64 } },
|
|
9
|
+
format: 'r8unorm',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
const validFog: VolumetricFogAuthoring = {
|
|
13
|
+
light: 1 as EntityHandle,
|
|
14
|
+
density,
|
|
15
|
+
bounds: { min: [-4, -2, -8], max: [4, 6, 8] },
|
|
16
|
+
extinction: [0.7, 0.8, 0.9],
|
|
17
|
+
albedo: [0.9, 0.92, 1],
|
|
18
|
+
emission: [0, 0, 0],
|
|
19
|
+
anisotropy: 0.2,
|
|
20
|
+
maxDistance: 80,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('VolumetricFog authoring contract', () => {
|
|
24
|
+
it('accepts one linear 3D density owner without ECS quality facts', () => {
|
|
25
|
+
const result = validateVolumetricFog(validFog);
|
|
26
|
+
expect(result.ok).toBe(true);
|
|
27
|
+
if (result.ok) {
|
|
28
|
+
expect(result.value.density.guid).toBe('density-guid');
|
|
29
|
+
expect(result.value.density.generation).toBe(3);
|
|
30
|
+
expect(result.value).not.toHaveProperty('quality');
|
|
31
|
+
expect(result.value).not.toHaveProperty('grid');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects a 2D or array density view dimension', () => {
|
|
36
|
+
for (const viewDimension of ['2d', '2d-array'] as const) {
|
|
37
|
+
const result = validateVolumetricFog({
|
|
38
|
+
...validFog,
|
|
39
|
+
density: { ...density, shape: { viewDimension, extent: { width: 64, height: 64 } } },
|
|
40
|
+
});
|
|
41
|
+
expect(result.ok).toBe(false);
|
|
42
|
+
if (!result.ok) expect(result.error.code).toBe('volume-density-shape-mismatch');
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects negative extinction, invalid anisotropy, and non-positive distance', () => {
|
|
47
|
+
const invalidInputs: VolumetricFogAuthoring[] = [
|
|
48
|
+
{ ...validFog, extinction: [-0.1, 0, 0] },
|
|
49
|
+
{ ...validFog, anisotropy: 1 },
|
|
50
|
+
{ ...validFog, maxDistance: 0 },
|
|
51
|
+
];
|
|
52
|
+
for (const input of invalidInputs) {
|
|
53
|
+
const result = validateVolumetricFog(input);
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
if (!result.ok) expect(result.error.code).toBe('volume-invalid-parameters');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects inverted, degenerate, and non-finite bounds', () => {
|
|
60
|
+
const invalidBounds = [
|
|
61
|
+
{ min: [4, -2, -8], max: [-4, 6, 8] },
|
|
62
|
+
{ min: [0, 0, 0], max: [0, 1, 1] },
|
|
63
|
+
{ min: [Number.NaN, 0, 0], max: [1, 1, 1] },
|
|
64
|
+
] as const;
|
|
65
|
+
for (const bounds of invalidBounds) {
|
|
66
|
+
const result = validateVolumetricFog({ ...validFog, bounds });
|
|
67
|
+
expect(result.ok).toBe(false);
|
|
68
|
+
if (!result.ok) expect(result.error.code).toBe('volume-invalid-bounds');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const SIZE = 4;
|
|
5
|
+
const ROW_BYTES = 256;
|
|
6
|
+
const MAP_READ = 0x0001;
|
|
7
|
+
const COPY_DST = 0x0008;
|
|
8
|
+
const COPY_SRC = 0x0001;
|
|
9
|
+
const TEXTURE_COPY_DST = 0x0002;
|
|
10
|
+
const TEXTURE_BINDING = 0x0004;
|
|
11
|
+
const RENDER_ATTACHMENT = 0x0010;
|
|
12
|
+
const engineManifest = await buildEngineShaderManifest();
|
|
13
|
+
|
|
14
|
+
function floatToHalf(value: number): number {
|
|
15
|
+
if (!Number.isFinite(value)) return value < 0 ? 0xfc00 : 0x7c00;
|
|
16
|
+
const sign = value < 0 ? 0x8000 : 0;
|
|
17
|
+
const absolute = Math.abs(value);
|
|
18
|
+
if (absolute === 0) return sign;
|
|
19
|
+
const exponent = Math.floor(Math.log2(absolute));
|
|
20
|
+
if (exponent < -14) return sign | Math.round(absolute / 2 ** -24);
|
|
21
|
+
if (exponent > 15) return sign | 0x7c00;
|
|
22
|
+
return sign | ((exponent + 15) << 10) | Math.round((absolute / 2 ** exponent - 1) * 1024);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function encodeHalfPixels(
|
|
26
|
+
values: readonly (readonly [number, number, number, number])[],
|
|
27
|
+
): Uint8Array {
|
|
28
|
+
const bytes = new Uint8Array(ROW_BYTES * SIZE);
|
|
29
|
+
for (let y = 0; y < SIZE; y += 1) {
|
|
30
|
+
for (let x = 0; x < SIZE; x += 1) {
|
|
31
|
+
const pixel = values[y * SIZE + x] ?? [0, 0, 0, 1];
|
|
32
|
+
const offset = y * ROW_BYTES + x * 8;
|
|
33
|
+
const channels = pixel.map(floatToHalf);
|
|
34
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
35
|
+
bytes[offset + channel * 2] = (channels[channel] ?? 0) & 0xff;
|
|
36
|
+
bytes[offset + channel * 2 + 1] = (channels[channel] ?? 0) >>> 8;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return bytes;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeHalf(bytes: Uint8Array, offset: number): number {
|
|
44
|
+
const bits = (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
|
|
45
|
+
const sign = (bits & 0x8000) === 0 ? 1 : -1;
|
|
46
|
+
const exponent = (bits >>> 10) & 0x1f;
|
|
47
|
+
const fraction = bits & 0x3ff;
|
|
48
|
+
if (exponent === 0) return sign * 2 ** -14 * (fraction / 1024);
|
|
49
|
+
if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : Number.NaN;
|
|
50
|
+
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function shader(marker: string): string {
|
|
54
|
+
const entry = engineManifest.entries.find(({ wgsl }) => wgsl.includes(marker));
|
|
55
|
+
if (entry === undefined) throw new Error(`missing cooked shader entry: ${marker}`);
|
|
56
|
+
return entry.wgsl;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function viewUniform(): Float32Array {
|
|
60
|
+
const values = new Float32Array(60);
|
|
61
|
+
values[44] = 1;
|
|
62
|
+
values[49] = 1;
|
|
63
|
+
values[54] = 1;
|
|
64
|
+
values[59] = 1;
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requestDevice(): Promise<GPUDevice> {
|
|
69
|
+
const adapter = await globalThis.navigator.gpu.requestAdapter();
|
|
70
|
+
if (adapter === null) throw new Error('Dawn composite readback unavailable: no adapter');
|
|
71
|
+
return adapter.requestDevice();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface CompositeReadback {
|
|
75
|
+
readonly pixels: Float32Array;
|
|
76
|
+
readonly center: [number, number, number, number];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type CompositeMode = 'production' | 'single-bilinear';
|
|
80
|
+
|
|
81
|
+
async function runComposite(
|
|
82
|
+
device: GPUDevice,
|
|
83
|
+
volumeValues: readonly (readonly [number, number, number, number])[],
|
|
84
|
+
edgeDepth: boolean,
|
|
85
|
+
mode: CompositeMode = 'production',
|
|
86
|
+
): Promise<CompositeReadback> {
|
|
87
|
+
const volume = device.createTexture({
|
|
88
|
+
label: 'composite-readback.resolved-volume',
|
|
89
|
+
size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
|
|
90
|
+
dimension: '2d',
|
|
91
|
+
format: 'rgba16float',
|
|
92
|
+
usage: TEXTURE_BINDING | TEXTURE_COPY_DST,
|
|
93
|
+
});
|
|
94
|
+
const depth = device.createTexture({
|
|
95
|
+
label: 'composite-readback.scene-depth',
|
|
96
|
+
size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
|
|
97
|
+
format: 'depth32float',
|
|
98
|
+
usage: TEXTURE_BINDING | RENDER_ATTACHMENT | TEXTURE_COPY_DST,
|
|
99
|
+
});
|
|
100
|
+
const target = device.createTexture({
|
|
101
|
+
label: 'composite-readback.linear-hdr',
|
|
102
|
+
size: { width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
|
|
103
|
+
format: 'rgba16float',
|
|
104
|
+
usage: RENDER_ATTACHMENT | COPY_SRC | TEXTURE_COPY_DST,
|
|
105
|
+
});
|
|
106
|
+
const volumeView = volume.createView({ dimension: '2d' });
|
|
107
|
+
const depthView = depth.createView();
|
|
108
|
+
const targetView = target.createView();
|
|
109
|
+
const sampler = device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
|
|
110
|
+
const uniform = device.createBuffer({ size: 240, usage: 0x40 | COPY_DST });
|
|
111
|
+
const readback = device.createBuffer({ size: ROW_BYTES * SIZE, usage: MAP_READ | COPY_DST });
|
|
112
|
+
const background = Array.from({ length: SIZE * SIZE }, () => [0.2, 0.3, 0.4, 1] as const);
|
|
113
|
+
device.queue.writeTexture(
|
|
114
|
+
{ texture: volume },
|
|
115
|
+
encodeHalfPixels(volumeValues),
|
|
116
|
+
{ bytesPerRow: ROW_BYTES, rowsPerImage: SIZE },
|
|
117
|
+
{ width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
|
|
118
|
+
);
|
|
119
|
+
device.queue.writeTexture(
|
|
120
|
+
{ texture: target },
|
|
121
|
+
encodeHalfPixels(background),
|
|
122
|
+
{ bytesPerRow: ROW_BYTES, rowsPerImage: SIZE },
|
|
123
|
+
{ width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
|
|
124
|
+
);
|
|
125
|
+
device.queue.writeBuffer(uniform, 0, viewUniform());
|
|
126
|
+
const productionSource = shader('fn volume_fs');
|
|
127
|
+
const source =
|
|
128
|
+
mode === 'single-bilinear'
|
|
129
|
+
? productionSource.replace(
|
|
130
|
+
'let sample = edge_aware_resolved_volume(input.uv);',
|
|
131
|
+
'let sample = textureSampleLevel(resolved_volume, volume_sampler, input.uv, 0.0);',
|
|
132
|
+
)
|
|
133
|
+
: productionSource;
|
|
134
|
+
const module = device.createShaderModule({ code: source });
|
|
135
|
+
const depthEdgePipeline = edgeDepth
|
|
136
|
+
? device.createRenderPipeline({
|
|
137
|
+
layout: 'auto',
|
|
138
|
+
vertex: {
|
|
139
|
+
module: device.createShaderModule({
|
|
140
|
+
code: `@vertex fn edge_vs(@builtin(vertex_index) index : u32) -> @builtin(position) vec4<f32> {
|
|
141
|
+
var points = array<vec2<f32>, 3>(vec2<f32>(-1.0, -1.0), vec2<f32>(0.0, -1.0), vec2<f32>(-1.0, 1.0));
|
|
142
|
+
return vec4<f32>(points[index], 0.25, 1.0);
|
|
143
|
+
}`,
|
|
144
|
+
}),
|
|
145
|
+
entryPoint: 'edge_vs',
|
|
146
|
+
buffers: [],
|
|
147
|
+
},
|
|
148
|
+
primitive: { topology: 'triangle-list' },
|
|
149
|
+
depthStencil: {
|
|
150
|
+
format: 'depth32float',
|
|
151
|
+
depthWriteEnabled: true,
|
|
152
|
+
depthCompare: 'always',
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
: undefined;
|
|
156
|
+
const layout = device.createBindGroupLayout({
|
|
157
|
+
entries: [
|
|
158
|
+
{ binding: 0, visibility: 2, texture: { sampleType: 'float', viewDimension: '2d' } },
|
|
159
|
+
{ binding: 1, visibility: 2, sampler: { type: 'filtering' } },
|
|
160
|
+
{ binding: 2, visibility: 2, texture: { sampleType: 'depth', viewDimension: '2d' } },
|
|
161
|
+
{ binding: 3, visibility: 2, buffer: { type: 'uniform' } },
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
const pipeline = device.createRenderPipeline({
|
|
165
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
166
|
+
vertex: { module, entryPoint: 'volume_vs', buffers: [] },
|
|
167
|
+
fragment: {
|
|
168
|
+
module,
|
|
169
|
+
entryPoint: 'volume_fs',
|
|
170
|
+
targets: [
|
|
171
|
+
{
|
|
172
|
+
format: 'rgba16float',
|
|
173
|
+
blend: {
|
|
174
|
+
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
175
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
primitive: { topology: 'triangle-list' },
|
|
181
|
+
});
|
|
182
|
+
const bindings = device.createBindGroup({
|
|
183
|
+
layout,
|
|
184
|
+
entries: [
|
|
185
|
+
{ binding: 0, resource: volumeView },
|
|
186
|
+
{ binding: 1, resource: sampler },
|
|
187
|
+
{ binding: 2, resource: depthView },
|
|
188
|
+
{ binding: 3, resource: { buffer: uniform } },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
const encoder = device.createCommandEncoder();
|
|
192
|
+
const depthClear = encoder.beginRenderPass({
|
|
193
|
+
colorAttachments: [],
|
|
194
|
+
depthStencilAttachment: {
|
|
195
|
+
view: depthView,
|
|
196
|
+
depthClearValue: 1,
|
|
197
|
+
depthLoadOp: 'clear',
|
|
198
|
+
depthStoreOp: 'store',
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
depthClear.end();
|
|
202
|
+
if (edgeDepth && depthEdgePipeline !== undefined) {
|
|
203
|
+
const edge = encoder.beginRenderPass({
|
|
204
|
+
colorAttachments: [],
|
|
205
|
+
depthStencilAttachment: {
|
|
206
|
+
view: depthView,
|
|
207
|
+
depthLoadOp: 'load',
|
|
208
|
+
depthStoreOp: 'store',
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
edge.setPipeline(depthEdgePipeline);
|
|
212
|
+
edge.draw(3);
|
|
213
|
+
edge.end();
|
|
214
|
+
}
|
|
215
|
+
const pass = encoder.beginRenderPass({
|
|
216
|
+
colorAttachments: [
|
|
217
|
+
{
|
|
218
|
+
view: targetView,
|
|
219
|
+
loadOp: 'load',
|
|
220
|
+
storeOp: 'store',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
pass.setPipeline(pipeline);
|
|
225
|
+
pass.setBindGroup(0, bindings);
|
|
226
|
+
pass.draw(3);
|
|
227
|
+
pass.end();
|
|
228
|
+
encoder.copyTextureToBuffer(
|
|
229
|
+
{ texture: target },
|
|
230
|
+
{ buffer: readback, bytesPerRow: ROW_BYTES, rowsPerImage: SIZE },
|
|
231
|
+
{ width: SIZE, height: SIZE, depthOrArrayLayers: 1 },
|
|
232
|
+
);
|
|
233
|
+
device.queue.submit([encoder.finish()]);
|
|
234
|
+
await device.queue.onSubmittedWorkDone();
|
|
235
|
+
await readback.mapAsync(MAP_READ);
|
|
236
|
+
const bytes = new Uint8Array(readback.getMappedRange().slice(0));
|
|
237
|
+
const pixels = new Float32Array(SIZE * SIZE * 4);
|
|
238
|
+
for (let y = 0; y < SIZE; y += 1) {
|
|
239
|
+
for (let x = 0; x < SIZE; x += 1) {
|
|
240
|
+
const source = y * ROW_BYTES + x * 8;
|
|
241
|
+
const targetOffset = (y * SIZE + x) * 4;
|
|
242
|
+
for (let channel = 0; channel < 4; channel += 1)
|
|
243
|
+
pixels[targetOffset + channel] = decodeHalf(bytes, source + channel * 2);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
readback.unmap();
|
|
247
|
+
volume.destroy();
|
|
248
|
+
depth.destroy();
|
|
249
|
+
target.destroy();
|
|
250
|
+
uniform.destroy();
|
|
251
|
+
readback.destroy();
|
|
252
|
+
const centerOffset = (2 * SIZE + 2) * 4;
|
|
253
|
+
return {
|
|
254
|
+
pixels,
|
|
255
|
+
center: [
|
|
256
|
+
pixels[centerOffset] ?? Number.NaN,
|
|
257
|
+
pixels[centerOffset + 1] ?? Number.NaN,
|
|
258
|
+
pixels[centerOffset + 2] ?? Number.NaN,
|
|
259
|
+
pixels[centerOffset + 3] ?? Number.NaN,
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
describe('volumetric fog composite HDR stage on Dawn', () => {
|
|
265
|
+
it('preserves premultiplied scatter over HDR and survives depth edges', async () => {
|
|
266
|
+
const device = await requestDevice();
|
|
267
|
+
const scatter = Array.from({ length: SIZE * SIZE }, () => [0.25, 0.1, 0.05, 0.5] as const);
|
|
268
|
+
const off = Array.from({ length: SIZE * SIZE }, () => [0, 0, 0, 1] as const);
|
|
269
|
+
const onFar = await runComposite(device, scatter, false);
|
|
270
|
+
const offFar = await runComposite(device, off, false);
|
|
271
|
+
const onEdge = await runComposite(device, scatter, true);
|
|
272
|
+
// biome-ignore lint/suspicious/noConsole: composite stage evidence
|
|
273
|
+
console.log(
|
|
274
|
+
JSON.stringify({ onFar: onFar.center, offFar: offFar.center, onEdge: onEdge.center }),
|
|
275
|
+
);
|
|
276
|
+
expect(onFar.center.every(Number.isFinite)).toBe(true);
|
|
277
|
+
expect(onFar.center.every((value) => value >= 0)).toBe(true);
|
|
278
|
+
expect(onFar.center[3]).toBeGreaterThanOrEqual(0);
|
|
279
|
+
const expectedScatter = [1, 0.4, 0.2] as const;
|
|
280
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
281
|
+
const expected = (onFar.center[channel] ?? 0) - (offFar.center[channel] ?? 0) * 0.5;
|
|
282
|
+
expect(expected).toBeGreaterThan(0);
|
|
283
|
+
expect(Math.abs(expected - 0.25 * (expectedScatter[channel] ?? 0))).toBeLessThan(0.03);
|
|
284
|
+
}
|
|
285
|
+
expect(onEdge.center.every(Number.isFinite)).toBe(true);
|
|
286
|
+
expect(onEdge.center[0]).toBeGreaterThan(0);
|
|
287
|
+
const source = shader('fn volume_fs');
|
|
288
|
+
expect(source).toContain('linear_scene_depth');
|
|
289
|
+
expect(source).toContain('depth_weight');
|
|
290
|
+
expect(source).toContain('scene_depth');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('compares production 9-tap reconstruction with a single-bilinear A/B', async () => {
|
|
294
|
+
const device = await requestDevice();
|
|
295
|
+
const values = Array.from({ length: SIZE * SIZE }, (_, index) =>
|
|
296
|
+
index % SIZE < 2 ? ([0.9, 0.45, 0.2, 0.35] as const) : ([0.02, 0.01, 0.005, 0.95] as const),
|
|
297
|
+
);
|
|
298
|
+
const nineTap = await runComposite(device, values, false, 'production');
|
|
299
|
+
const single = await runComposite(device, values, false, 'single-bilinear');
|
|
300
|
+
const luma = (pixels: Float32Array, x: number, y: number): number => {
|
|
301
|
+
const offset = (y * SIZE + x) * 4;
|
|
302
|
+
return (
|
|
303
|
+
0.2126 * (pixels[offset] ?? 0) +
|
|
304
|
+
0.7152 * (pixels[offset + 1] ?? 0) +
|
|
305
|
+
0.0722 * (pixels[offset + 2] ?? 0)
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
const edgeGradient = (pixels: Float32Array): number =>
|
|
309
|
+
Math.abs(luma(pixels, 1, 2) - luma(pixels, 2, 2));
|
|
310
|
+
const sideContrast = (pixels: Float32Array): number =>
|
|
311
|
+
Math.abs(luma(pixels, 0, 2) - luma(pixels, 3, 2));
|
|
312
|
+
const profile = (pixels: Float32Array): number[] =>
|
|
313
|
+
Array.from({ length: SIZE }, (_, x) => luma(pixels, x, 2));
|
|
314
|
+
const transitionWidth = (pixels: Float32Array): number => {
|
|
315
|
+
const samples = profile(pixels);
|
|
316
|
+
const low = Math.min(samples[0] ?? 0, samples[3] ?? 0);
|
|
317
|
+
const high = Math.max(samples[0] ?? 0, samples[3] ?? 0);
|
|
318
|
+
const span = Math.max(high - low, 1e-6);
|
|
319
|
+
return samples.filter((value) => value > low + span * 0.1 && value < low + span * 0.9).length;
|
|
320
|
+
};
|
|
321
|
+
const plateauCv = (pixels: Float32Array, start: number, end: number): number => {
|
|
322
|
+
const samples = profile(pixels).slice(start, end);
|
|
323
|
+
const mean = samples.reduce((sum, value) => sum + value, 0) / Math.max(1, samples.length);
|
|
324
|
+
const variance =
|
|
325
|
+
samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, samples.length);
|
|
326
|
+
return Math.sqrt(variance) / Math.max(Math.abs(mean), 1e-6);
|
|
327
|
+
};
|
|
328
|
+
const nineGradient = edgeGradient(nineTap.pixels);
|
|
329
|
+
const singleGradient = edgeGradient(single.pixels);
|
|
330
|
+
const nineContrast = sideContrast(nineTap.pixels);
|
|
331
|
+
const singleContrast = sideContrast(single.pixels);
|
|
332
|
+
const nineTransition = transitionWidth(nineTap.pixels);
|
|
333
|
+
const singleTransition = transitionWidth(single.pixels);
|
|
334
|
+
const ninePlateauCv = Math.max(
|
|
335
|
+
plateauCv(nineTap.pixels, 0, 2),
|
|
336
|
+
plateauCv(nineTap.pixels, 2, 4),
|
|
337
|
+
);
|
|
338
|
+
const singlePlateauCv = Math.max(
|
|
339
|
+
plateauCv(single.pixels, 0, 2),
|
|
340
|
+
plateauCv(single.pixels, 2, 4),
|
|
341
|
+
);
|
|
342
|
+
const peakRatio =
|
|
343
|
+
Math.min(nineGradient, singleGradient) / Math.max(nineGradient, singleGradient, 1e-6);
|
|
344
|
+
// Both paths use the same pending volume, depth, and HDR background. The
|
|
345
|
+
// A/B is deliberately test-owned: production remains the exact shader
|
|
346
|
+
// until this measured edge and halo evidence identifies its owner.
|
|
347
|
+
expect(nineTap.pixels.every(Number.isFinite)).toBe(true);
|
|
348
|
+
expect(single.pixels.every(Number.isFinite)).toBe(true);
|
|
349
|
+
expect(singleContrast).toBeGreaterThan(0.1);
|
|
350
|
+
expect(nineContrast).toBeGreaterThan(0.1);
|
|
351
|
+
expect(peakRatio).toBeGreaterThan(0.25);
|
|
352
|
+
expect(nineTransition).toBeLessThanOrEqual(2);
|
|
353
|
+
expect(singleTransition).toBeLessThanOrEqual(2);
|
|
354
|
+
expect(ninePlateauCv).toBeLessThanOrEqual(0.05);
|
|
355
|
+
expect(singlePlateauCv).toBeLessThanOrEqual(0.05);
|
|
356
|
+
// Premultiplied output must not create a negative or overshooting edge.
|
|
357
|
+
expect(Math.max(...nineTap.pixels)).toBeLessThanOrEqual(1.01);
|
|
358
|
+
expect(Math.max(...single.pixels)).toBeLessThanOrEqual(1.01);
|
|
359
|
+
// biome-ignore lint/suspicious/noConsole: composite A/B evidence
|
|
360
|
+
console.log(
|
|
361
|
+
JSON.stringify({
|
|
362
|
+
compositeAB: {
|
|
363
|
+
nineGradient,
|
|
364
|
+
singleGradient,
|
|
365
|
+
nineContrast,
|
|
366
|
+
singleContrast,
|
|
367
|
+
nineTransition,
|
|
368
|
+
singleTransition,
|
|
369
|
+
ninePlateauCv,
|
|
370
|
+
singlePlateauCv,
|
|
371
|
+
gradientPreservation: peakRatio,
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type VolumetricFogAuthoring, validateVolumetricFog } from '../volume/component';
|
|
4
|
+
|
|
5
|
+
const base: VolumetricFogAuthoring = {
|
|
6
|
+
light: 1 as EntityHandle,
|
|
7
|
+
density: {
|
|
8
|
+
guid: 'density-guid',
|
|
9
|
+
generation: 1,
|
|
10
|
+
shape: { viewDimension: '3d', extent: { width: 32, height: 32, depth: 32 } },
|
|
11
|
+
format: 'r16float',
|
|
12
|
+
},
|
|
13
|
+
bounds: { min: [-1, -1, -1], max: [1, 1, 1] },
|
|
14
|
+
extinction: [1, 1, 1],
|
|
15
|
+
albedo: [1, 1, 1],
|
|
16
|
+
emission: [0, 0, 0],
|
|
17
|
+
anisotropy: 0,
|
|
18
|
+
maxDistance: 10,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('VolumetricFog structured error contract', () => {
|
|
22
|
+
it('exposes code, expected, hint, and detail for every validation failure', () => {
|
|
23
|
+
const result = validateVolumetricFog({ ...base, maxDistance: -1 });
|
|
24
|
+
expect(result.ok).toBe(false);
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
expect(result.error).toMatchObject({
|
|
27
|
+
code: 'volume-invalid-parameters',
|
|
28
|
+
expected: expect.any(String),
|
|
29
|
+
hint: expect.any(String),
|
|
30
|
+
detail: expect.objectContaining({ field: 'maxDistance' }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps owner conflict as a closed error instead of creating a second volume', () => {
|
|
36
|
+
const result = validateVolumetricFog({ ...base, ownerCount: 2 });
|
|
37
|
+
expect(result.ok).toBe(false);
|
|
38
|
+
if (!result.ok) {
|
|
39
|
+
expect(result.error.code).toBe('volume-owner-conflict');
|
|
40
|
+
expect(result.error.detail).toMatchObject({ ownerCount: 2 });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('preserves density identity and generation in the success projection', () => {
|
|
45
|
+
const result = validateVolumetricFog(base);
|
|
46
|
+
expect(result).toMatchObject({
|
|
47
|
+
ok: true,
|
|
48
|
+
value: { density: { guid: 'density-guid', generation: 1 } },
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { inspectVolumetricFog } from '../volume/inspection';
|
|
3
|
+
|
|
4
|
+
const recovery = {
|
|
5
|
+
guid: 'density-guid',
|
|
6
|
+
generation: 4,
|
|
7
|
+
deviceEpoch: 2,
|
|
8
|
+
status: 'accepted' as const,
|
|
9
|
+
lkgGeneration: 4,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('volumetric fog inspection', () => {
|
|
13
|
+
it('distinguishes authored off from an accepted resource', () => {
|
|
14
|
+
expect(inspectVolumetricFog({ authored: false, capability: 'available' })).toMatchObject({
|
|
15
|
+
status: 'off',
|
|
16
|
+
resourceStage: 'none',
|
|
17
|
+
passCount: 0,
|
|
18
|
+
sampleCount: 0,
|
|
19
|
+
memoryBytes: 0,
|
|
20
|
+
});
|
|
21
|
+
expect(
|
|
22
|
+
inspectVolumetricFog({
|
|
23
|
+
authored: true,
|
|
24
|
+
capability: 'available',
|
|
25
|
+
recovery,
|
|
26
|
+
acceptedDigest: 'digest-4',
|
|
27
|
+
passCount: 4,
|
|
28
|
+
sampleCount: 2,
|
|
29
|
+
memoryBytes: 64,
|
|
30
|
+
}),
|
|
31
|
+
).toMatchObject({
|
|
32
|
+
status: 'available',
|
|
33
|
+
resourceStage: 'accepted',
|
|
34
|
+
guid: 'density-guid',
|
|
35
|
+
generation: 4,
|
|
36
|
+
digest: 'digest-4',
|
|
37
|
+
deviceEpoch: 2,
|
|
38
|
+
passCount: 4,
|
|
39
|
+
sampleCount: 2,
|
|
40
|
+
memoryBytes: 64,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('exposes candidate and LKG identity after a failed candidate', () => {
|
|
45
|
+
const result = inspectVolumetricFog({
|
|
46
|
+
authored: true,
|
|
47
|
+
capability: 'available',
|
|
48
|
+
recovery: {
|
|
49
|
+
...recovery,
|
|
50
|
+
status: 'degraded',
|
|
51
|
+
candidateGeneration: 5,
|
|
52
|
+
candidateDigest: 'digest-5',
|
|
53
|
+
candidateFailure: 'submit-failed',
|
|
54
|
+
},
|
|
55
|
+
acceptedDigest: 'digest-4',
|
|
56
|
+
});
|
|
57
|
+
expect(result).toMatchObject({
|
|
58
|
+
status: 'degraded',
|
|
59
|
+
resourceStage: 'lkg',
|
|
60
|
+
generation: 4,
|
|
61
|
+
digest: 'digest-4',
|
|
62
|
+
candidateGeneration: 5,
|
|
63
|
+
candidateDigest: 'digest-5',
|
|
64
|
+
lkgGeneration: 4,
|
|
65
|
+
candidateFailure: 'submit-failed',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('keeps capability unavailable distinct from degraded recovery', () => {
|
|
70
|
+
expect(inspectVolumetricFog({ authored: true, capability: 'unavailable' }).status).toBe(
|
|
71
|
+
'unavailable',
|
|
72
|
+
);
|
|
73
|
+
expect(
|
|
74
|
+
inspectVolumetricFog({ authored: true, capability: 'available', degraded: true }).status,
|
|
75
|
+
).toBe('degraded');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const source = readFileSync(new URL('../volume/capability.ts', import.meta.url), 'utf8');
|
|
5
|
+
|
|
6
|
+
describe('volumetric light capability boundary', () => {
|
|
7
|
+
it('keeps light selection separate from device capability', () => {
|
|
8
|
+
expect(source).toContain('resolveSelectedVolumetricLight');
|
|
9
|
+
expect(source).toContain("'directional' | 'point' | 'spot'");
|
|
10
|
+
expect(source).toContain("'wrong-component'");
|
|
11
|
+
expect(source).toContain("'ambiguous'");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('does not turn missing light facts into a synthetic light', () => {
|
|
15
|
+
expect(source).toContain("status: 'unresolved'");
|
|
16
|
+
expect(source).not.toContain('defaultDirectional');
|
|
17
|
+
expect(source).not.toContain('synthetic');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const capabilitySource = readFileSync(new URL('../volume/capability.ts', import.meta.url), 'utf8');
|
|
5
|
+
|
|
6
|
+
describe('volumetric fog light consumers', () => {
|
|
7
|
+
it('consume selected-light projection through the real-light resolvers', () => {
|
|
8
|
+
expect(capabilitySource).toContain('resolveSelectedVolumetricLight');
|
|
9
|
+
expect(capabilitySource).toContain('resolveVolumetricFogLightPair');
|
|
10
|
+
expect(capabilitySource).not.toContain('VolumetricFogDirectionalLight');
|
|
11
|
+
expect(capabilitySource).not.toContain('VolumetricFogCsmSnapshot');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('keeps the integrated result as the only consumer resource identity', () => {
|
|
15
|
+
expect(capabilitySource).toContain("identity: 'volume-integrated'");
|
|
16
|
+
expect(capabilitySource).not.toContain('pointLights');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const source = readFileSync(new URL('../volume/recovery.ts', import.meta.url), 'utf8');
|
|
5
|
+
|
|
6
|
+
describe('selected volumetric light errors', () => {
|
|
7
|
+
it('preserves candidate and accepted selected-light facts in recovery state', () => {
|
|
8
|
+
expect(source).toContain('selectedLight');
|
|
9
|
+
expect(source).toContain('candidateFailure');
|
|
10
|
+
expect(source).toContain('stale-generation');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps shadow degradation distinct from an absent selected light', () => {
|
|
14
|
+
expect(source).toContain('shadow');
|
|
15
|
+
expect(source).toContain('missing');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { volumetricFogTopology } from '../volume/passes';
|
|
3
|
+
|
|
4
|
+
describe('volumetric fog selected-light topology', () => {
|
|
5
|
+
it('publishes selected light kind and identity without changing the four-pass budget', () => {
|
|
6
|
+
const topology = volumetricFogTopology(true, {
|
|
7
|
+
lightKind: 'spot',
|
|
8
|
+
lightEntity: 17,
|
|
9
|
+
lightRevision: 4,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(topology.lightKind).toBe('spot');
|
|
13
|
+
expect(topology.lightEntity).toBe(17);
|
|
14
|
+
expect(topology.lightRevision).toBe(4);
|
|
15
|
+
expect(topology.passes).toHaveLength(4);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps selected light facts out of disabled topology', () => {
|
|
19
|
+
expect(volumetricFogTopology(false)).toMatchObject({ passes: [], resources: [] });
|
|
20
|
+
});
|
|
21
|
+
});
|