@forgeax/engine-render 0.1.32 → 0.1.33
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/README.md +379 -33
- package/dist/assembly/analytic-fog-registration.d.ts +3 -0
- package/dist/assembly/analytic-fog-registration.d.ts.map +1 -0
- package/dist/assembly/fxaa-registration.d.ts +4 -0
- package/dist/assembly/fxaa-registration.d.ts.map +1 -0
- package/dist/assembly/lod-observation.d.ts +4 -0
- package/dist/assembly/lod-observation.d.ts.map +1 -0
- package/dist/assembly/motion-blur-registration.d.ts +3 -0
- package/dist/assembly/motion-blur-registration.d.ts.map +1 -0
- package/dist/assembly/renderer-facade.d.ts.map +1 -1
- package/dist/assembly/temporal-post-process-prewarm.d.ts +13 -0
- package/dist/assembly/temporal-post-process-prewarm.d.ts.map +1 -0
- package/dist/assembly/webgpu-ready-contract.d.ts +4 -3
- package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -1
- package/dist/assembly/webgpu-ready.d.ts +1 -1
- package/dist/assembly/webgpu-ready.d.ts.map +1 -1
- package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
- package/dist/bloom-admission.d.ts +13 -0
- package/dist/bloom-admission.d.ts.map +1 -0
- package/dist/{chunk-DRR7D3KX.mjs → chunk-3RTCJTH4.mjs} +12 -9
- package/dist/chunk-3RTCJTH4.mjs.map +1 -0
- package/dist/{chunk-S665NJNF.mjs → chunk-LEAXOMYJ.mjs} +3 -3
- package/dist/{chunk-S665NJNF.mjs.map → chunk-LEAXOMYJ.mjs.map} +1 -1
- package/dist/{chunk-6TFBXLRJ.mjs → chunk-NB2S7ZRX.mjs} +155 -11
- package/dist/chunk-NB2S7ZRX.mjs.map +1 -0
- package/dist/{chunk-LULCA3OF.mjs → chunk-NNQL7CB3.mjs} +4 -4
- package/dist/{chunk-LULCA3OF.mjs.map → chunk-NNQL7CB3.mjs.map} +1 -1
- package/dist/{chunk-4NNZQCL3.mjs → chunk-OO3DRYK7.mjs} +66 -200
- package/dist/chunk-OO3DRYK7.mjs.map +1 -0
- package/dist/{chunk-GWCEMDVR.mjs → chunk-PLAXOOX5.mjs} +232 -7
- package/dist/chunk-PLAXOOX5.mjs.map +1 -0
- package/dist/{chunk-JRIPCMYC.mjs → chunk-VOZWDM3L.mjs} +283 -193
- package/dist/chunk-VOZWDM3L.mjs.map +1 -0
- package/dist/{chunk-JCIRYQ4V.mjs → chunk-ZDQWGQW7.mjs} +10 -4
- package/dist/chunk-ZDQWGQW7.mjs.map +1 -0
- package/dist/components/atmosphere.d.ts +2 -0
- package/dist/components/atmosphere.d.ts.map +1 -1
- package/dist/components/camera.d.ts +20 -4
- package/dist/components/camera.d.ts.map +1 -1
- package/dist/components/mesh-filter.d.ts +6 -5
- package/dist/components/mesh-filter.d.ts.map +1 -1
- package/dist/components/motion-blur.d.ts +1 -0
- package/dist/components/motion-blur.d.ts.map +1 -1
- package/dist/construct-renderer.mjs +2865 -526
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/device/gpu-residency.d.ts +6 -0
- package/dist/device/gpu-residency.d.ts.map +1 -1
- package/dist/environment/analytic-fog.d.ts +6 -0
- package/dist/environment/analytic-fog.d.ts.map +1 -0
- package/dist/errors/render.d.ts +6 -2
- package/dist/errors/render.d.ts.map +1 -1
- package/dist/extract/camera.d.ts.map +1 -1
- package/dist/extract/contracts.d.ts +7 -0
- package/dist/extract/contracts.d.ts.map +1 -1
- package/dist/extract/environment.d.ts +2 -0
- package/dist/extract/environment.d.ts.map +1 -1
- package/dist/features/error-reporting.d.ts +13 -0
- package/dist/features/error-reporting.d.ts.map +1 -0
- package/dist/features/host.d.ts +5 -0
- package/dist/features/host.d.ts.map +1 -1
- package/dist/features/motion-blur/motion-blur-feature.d.ts +20 -2
- package/dist/features/motion-blur/motion-blur-feature.d.ts.map +1 -1
- package/dist/features/motion-blur/motion-blur-params.d.ts +21 -0
- package/dist/features/motion-blur/motion-blur-params.d.ts.map +1 -1
- package/dist/features/motion-blur/motion-blur-runtime.d.ts +54 -0
- package/dist/features/motion-blur/motion-blur-runtime.d.ts.map +1 -0
- package/dist/features/plan.d.ts +7 -4
- package/dist/features/plan.d.ts.map +1 -1
- package/dist/features/prepared-gpu-work.d.ts +9 -5
- package/dist/features/prepared-gpu-work.d.ts.map +1 -1
- package/dist/features/render-graph-compute.d.ts.map +1 -1
- package/dist/features/render-graph-contribution.d.ts.map +1 -1
- package/dist/gpu-driven/material-artifacts.d.ts.map +1 -1
- package/dist/gpu-driven/prepared-draw.d.ts +7 -0
- package/dist/gpu-driven/prepared-draw.d.ts.map +1 -1
- package/dist/gpu-driven/production-raster.d.ts +3 -1
- package/dist/gpu-driven/production-raster.d.ts.map +1 -1
- package/dist/gpu-driven/view-gpu.d.ts +9 -0
- package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
- package/dist/gpu-scene.d.ts +6 -1
- package/dist/gpu-scene.d.ts.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +5 -5
- package/dist/inspection-types.d.ts +43 -2
- package/dist/inspection-types.d.ts.map +1 -1
- package/dist/instances.d.ts +8 -1
- package/dist/instances.d.ts.map +1 -1
- package/dist/internal.mjs +6 -6
- package/dist/pipeline/standard-post.d.ts +28 -19
- package/dist/pipeline/standard-post.d.ts.map +1 -1
- package/dist/pipeline-spec.d.ts +2 -2
- package/dist/record/frame-snapshot.d.ts +3 -0
- 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 +5 -2
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/main-pass-geometry.d.ts.map +1 -1
- package/dist/record/mesh-ssbo.d.ts +6 -5
- package/dist/record/mesh-ssbo.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +12 -28
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/skybox-post-pass.d.ts +5 -3
- package/dist/record/skybox-post-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts +1 -1
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/recovery/render-system-candidate.d.ts.map +1 -1
- package/dist/render-contract.d.ts +24 -4
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-pipeline.d.ts +3 -1
- package/dist/render-pipeline.d.ts.map +1 -1
- package/dist/render-system-extract-tail.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +8 -1
- 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/scene/render-scene.d.ts +2 -0
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/dist/temporal/frame-coordinator.d.ts.map +1 -1
- package/dist/temporal/frame.d.ts +7 -0
- package/dist/temporal/frame.d.ts.map +1 -1
- package/dist/temporal/index.mjs +5 -5
- package/dist/temporal/view.d.ts +6 -1
- package/dist/temporal/view.d.ts.map +1 -1
- package/dist/typed-render-graph-primitives.d.ts +10 -7
- package/dist/typed-render-graph-primitives.d.ts.map +1 -1
- package/dist/volume/component.d.ts +0 -1
- package/dist/volume/component.d.ts.map +1 -1
- package/dist/volume/passes.d.ts.map +1 -1
- package/dist/volume/temporal.d.ts +3 -1
- package/dist/volume/temporal.d.ts.map +1 -1
- package/package.json +21 -21
- package/src/__tests__/analytic-fog-graph.unit.test.ts +199 -0
- package/src/__tests__/analytic-fog-optics.unit.test.ts +38 -0
- package/src/__tests__/bloom-exact-zero.unit.test.ts +112 -18
- package/src/__tests__/bloom-lifecycle.integration.test.ts +17 -4
- package/src/__tests__/dynamic-resolution-plugin.integration.test.ts +20 -0
- package/src/__tests__/environment-fog-lanes.integration.test.ts +14 -0
- package/src/__tests__/environment-selection.unit.test.ts +12 -0
- package/src/__tests__/factory-contract.integration.test.ts +331 -68
- package/src/__tests__/frame-camera-selection.unit.test.ts +39 -1
- package/src/__tests__/frame-plan-contract.unit.test.ts +2 -0
- package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +2 -1
- package/src/__tests__/gpu-driven-shadow-views.dawn.test.ts +1 -1
- package/src/__tests__/gpu-driven-shadow-views.integration.test.ts +1 -1
- package/src/__tests__/gpu-driven-view-gpu-evidence.ts +11 -0
- package/src/__tests__/gpu-resource-store-stride.unit.test.ts +12 -0
- package/src/__tests__/gpu-scene.unit.test.ts +91 -0
- package/src/__tests__/instances-store.unit.test.ts +75 -0
- package/src/__tests__/mesh-submission-lifetime.unit.test.ts +31 -0
- package/src/__tests__/motion-blur-compute.dawn.test.ts +583 -0
- package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +21 -0
- package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +86 -0
- package/src/__tests__/public-surface.test-d.ts +8 -0
- package/src/__tests__/render-error-code-owner.test-d.ts +2 -0
- package/src/__tests__/render-feature-error-reporting.unit.test.ts +24 -0
- package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +23 -0
- package/src/__tests__/render-scene-temporal-retry.unit.test.ts +31 -0
- package/src/__tests__/renderer-frame-transaction.integration.test.ts +7 -1
- package/src/__tests__/shader-manifest-fixture.ts +4 -4
- package/src/__tests__/skinned-shadow-caster.test.ts +2 -0
- package/src/__tests__/standard-lighting-write.unit.test.ts +2 -1
- package/src/__tests__/standard-pipeline.dawn.test.ts +1 -1
- package/src/__tests__/standard-pipeline.integration.test.ts +18 -8
- package/src/__tests__/temporal-reset-matrix.unit.test.ts +10 -0
- package/src/__tests__/temporal-view.unit.test.ts +2 -1
- package/src/__tests__/typed-pipeline-topology.unit.test.ts +20 -4
- package/src/__tests__/volume-optics-oracle.unit.test.ts +3 -1
- package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +177 -13
- package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +447 -61
- package/src/__tests__/volumetric-fog-temporal.unit.test.ts +27 -0
- package/src/__tests__/volumetric-fog-topology.unit.test.ts +19 -3
- package/src/assembly/analytic-fog-registration.ts +14 -0
- package/src/assembly/fxaa-registration.ts +17 -0
- package/src/assembly/lod-observation.ts +12 -0
- package/src/assembly/motion-blur-registration.ts +17 -0
- package/src/assembly/renderer-facade.ts +2 -0
- package/src/assembly/temporal-post-process-prewarm.ts +40 -0
- package/src/assembly/webgpu-ready-contract.ts +8 -3
- package/src/assembly/webgpu-ready.ts +181 -245
- package/src/assembly/webgpu-renderer.ts +16 -31
- package/src/bloom-admission.ts +21 -0
- package/src/components/__tests__/motion-blur.unit.test.ts +1 -0
- package/src/components/atmosphere.ts +4 -0
- package/src/components/camera.ts +66 -10
- package/src/components/mesh-filter.ts +11 -9
- package/src/components/motion-blur.ts +1 -0
- package/src/device/gpu-residency.ts +56 -12
- package/src/environment/analytic-fog.ts +44 -0
- package/src/environment/background.ts +2 -2
- package/src/errors/render.ts +14 -1
- package/src/extract/camera.ts +20 -7
- package/src/extract/contracts.ts +7 -0
- package/src/extract/environment.ts +4 -0
- package/src/features/error-reporting.ts +78 -0
- package/src/features/host.ts +25 -12
- package/src/features/motion-blur/__tests__/motion-blur-feature.unit.test.ts +111 -1
- package/src/features/motion-blur/__tests__/motion-blur-params.unit.test.ts +58 -3
- package/src/features/motion-blur/motion-blur-feature.ts +1519 -15
- package/src/features/motion-blur/motion-blur-params.ts +70 -2
- package/src/features/motion-blur/motion-blur-runtime.ts +176 -0
- package/src/features/plan.ts +25 -8
- package/src/features/prepared-gpu-work.ts +86 -35
- package/src/features/render-graph-compute.ts +13 -5
- package/src/features/render-graph-contribution.ts +2 -0
- package/src/gpu-driven/material-artifacts.ts +2 -7
- package/src/gpu-driven/prepared-draw.ts +15 -10
- package/src/gpu-driven/production-raster.ts +83 -7
- package/src/gpu-driven/view-gpu.ts +17 -0
- package/src/gpu-scene.ts +188 -21
- package/src/index.ts +32 -0
- package/src/inspection-types.ts +60 -2
- package/src/instances.ts +80 -1
- package/src/pipeline/standard-post.ts +307 -78
- package/src/pipeline-spec.ts +5 -5
- package/src/points-lines/__tests__/view-ubo.unit.test.ts +2 -1
- package/src/record/__tests__/temporal-payload.unit.test.ts +2 -1
- package/src/record/frame-snapshot.ts +5 -1
- package/src/record/frame-targets.ts +10 -6
- package/src/record/frame.ts +163 -22
- package/src/record/main-pass-geometry.ts +30 -1
- package/src/record/main-pass-sprite-draws.ts +6 -1
- package/src/record/mesh-ssbo.ts +43 -6
- package/src/record/render-context.ts +12 -28
- package/src/record/shadow-pass.ts +6 -1
- package/src/record/skybox-post-pass.ts +122 -196
- package/src/record/typed-frame-graph.ts +153 -9
- package/src/recovery/render-system-candidate.ts +22 -10
- package/src/render-contract.ts +23 -4
- package/src/render-pipeline.ts +6 -1
- package/src/render-system-extract-tail.ts +16 -0
- package/src/render-system-extract.ts +45 -13
- package/src/render-system.ts +255 -120
- package/src/scene/render-scene.ts +85 -4
- package/src/temporal/__tests__/temporal-frame-transaction.unit.test.ts +36 -0
- package/src/temporal/frame-coordinator.ts +8 -1
- package/src/temporal/frame.ts +20 -0
- package/src/temporal/view.ts +13 -2
- package/src/typed-render-graph-primitives.ts +121 -48
- package/src/volume/component.ts +0 -1
- package/src/volume/passes.ts +8 -4
- package/src/volume/resources.ts +1 -1
- package/src/volume/temporal.ts +11 -0
- package/dist/chunk-4NNZQCL3.mjs.map +0 -1
- package/dist/chunk-6TFBXLRJ.mjs.map +0 -1
- package/dist/chunk-DRR7D3KX.mjs.map +0 -1
- package/dist/chunk-GWCEMDVR.mjs.map +0 -1
- package/dist/chunk-JCIRYQ4V.mjs.map +0 -1
- package/dist/chunk-JRIPCMYC.mjs.map +0 -1
|
@@ -3,6 +3,7 @@ import { createShaderModule, rhi } from '@forgeax/engine-rhi-webgpu';
|
|
|
3
3
|
import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import { BYTES_PER_DIRECT_LIGHT_SLOT } from '../light-buffer-layout';
|
|
6
|
+
import { VOLUMETRIC_FOG_PARAMS_BYTES } from '../volume/resources';
|
|
6
7
|
|
|
7
8
|
const MAP_READ = 0x0001;
|
|
8
9
|
const BUFFER_COPY_DST = 0x0008;
|
|
@@ -19,6 +20,13 @@ const BYTES_PER_PIXEL = 8;
|
|
|
19
20
|
const MAX_CLUSTER_LIGHTS = 256;
|
|
20
21
|
const CLUSTER_LIGHT_DATA_BYTES = MAX_CLUSTER_LIGHTS * BYTES_PER_DIRECT_LIGHT_SLOT;
|
|
21
22
|
const CLUSTER_UNIFORM_BYTES = 32;
|
|
23
|
+
const VOLUME_BOUNDS_MIN_Z = 0.1;
|
|
24
|
+
const VOLUME_BOUNDS_MAX_Z = 0.9;
|
|
25
|
+
const NON_UNIFORM_BOUNDS_MAX_Z = 8.1;
|
|
26
|
+
const MAX_NON_UNIFORM_BEER_ERROR = 0.001;
|
|
27
|
+
const NON_UNIFORM_EXTINCTION = 0.05;
|
|
28
|
+
const MIN_NON_UNIFORM_REFERENCE_SEPARATION = 0.05;
|
|
29
|
+
const MIN_NON_UNIFORM_GPU_SEPARATION = 0.05;
|
|
22
30
|
const engineManifest = await buildEngineShaderManifest();
|
|
23
31
|
|
|
24
32
|
interface StageValues {
|
|
@@ -33,6 +41,106 @@ interface VolumeStageReadbacks {
|
|
|
33
41
|
readonly integrate: StageValues;
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
interface StageReadbackOptions {
|
|
45
|
+
readonly sceneDepthClear?: number;
|
|
46
|
+
readonly densityByte?: number;
|
|
47
|
+
/** Optional RGBA8 density field for the production integrate stage. */
|
|
48
|
+
readonly densityData?: Uint8Array;
|
|
49
|
+
readonly boundsMinZ?: number;
|
|
50
|
+
readonly boundsMaxZ?: number;
|
|
51
|
+
readonly maxDistance?: number;
|
|
52
|
+
readonly viewDepthScale?: number;
|
|
53
|
+
readonly extinction?: number;
|
|
54
|
+
readonly frameIndex?: number;
|
|
55
|
+
/** Depth clear used by the stable shadow receiver fixture. */
|
|
56
|
+
readonly shadowDepthClear?: number;
|
|
57
|
+
/** Unshadowed punctual light isolates ray integration from packed visibility. */
|
|
58
|
+
readonly localLight?: 'point' | 'spot';
|
|
59
|
+
/** Uniform-step diagnostic; omitted means the untouched production shader. */
|
|
60
|
+
readonly integratorStepCount?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface DensityField {
|
|
64
|
+
readonly data: Uint8Array;
|
|
65
|
+
readonly zRed: readonly number[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createNonUniformDensityField(redValues?: readonly number[]): DensityField {
|
|
69
|
+
const data = new Uint8Array(4 * SIZE * SIZE * SIZE);
|
|
70
|
+
const zRed: number[] = [];
|
|
71
|
+
for (let z = 0; z < SIZE; z += 1) {
|
|
72
|
+
// Keep X/Y constant. The default field changes only the first Z texel;
|
|
73
|
+
// the fine-density probe supplies a faster, seam-matched sequence.
|
|
74
|
+
// The exercised camera ray crosses the first-to-second texel transition
|
|
75
|
+
// under all three production density scales, so flattening the field to
|
|
76
|
+
// its first texel produces a measurable, independently falsifiable result.
|
|
77
|
+
const red = redValues?.[z] ?? (z === 0 ? 128 : 255);
|
|
78
|
+
zRed.push(red / 255);
|
|
79
|
+
for (let y = 0; y < SIZE; y += 1) {
|
|
80
|
+
for (let x = 0; x < SIZE; x += 1) {
|
|
81
|
+
const offset = ((z * SIZE + y) * SIZE + x) * 4;
|
|
82
|
+
data[offset] = red;
|
|
83
|
+
data[offset + 1] = 255;
|
|
84
|
+
data[offset + 2] = 255;
|
|
85
|
+
data[offset + 3] = 255;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { data, zRed };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createFlattenedDensityField(field: DensityField): DensityField {
|
|
93
|
+
const red = Math.round((field.zRed[0] ?? 0) * 255);
|
|
94
|
+
const data = new Uint8Array(4 * SIZE * SIZE * SIZE);
|
|
95
|
+
for (let z = 0; z < SIZE; z += 1) {
|
|
96
|
+
for (let y = 0; y < SIZE; y += 1) {
|
|
97
|
+
for (let x = 0; x < SIZE; x += 1) {
|
|
98
|
+
const offset = ((z * SIZE + y) * SIZE + x) * 4;
|
|
99
|
+
data[offset] = red;
|
|
100
|
+
data[offset + 1] = 255;
|
|
101
|
+
data[offset + 2] = 255;
|
|
102
|
+
data[offset + 3] = 255;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { data, zRed: Array.from({ length: SIZE }, () => red / 255) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sampleClampedLinear(values: readonly number[], coordinate: number): number {
|
|
110
|
+
const scaled = coordinate * values.length - 0.5;
|
|
111
|
+
const lower = Math.floor(scaled);
|
|
112
|
+
const upper = lower + 1;
|
|
113
|
+
const interpolation = scaled - lower;
|
|
114
|
+
const clampIndex = (index: number): number => Math.max(0, Math.min(values.length - 1, index));
|
|
115
|
+
const lowerValue = values[clampIndex(lower)] ?? 0;
|
|
116
|
+
const upperValue = values[clampIndex(upper)] ?? 0;
|
|
117
|
+
return lowerValue + (upperValue - lowerValue) * interpolation;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function nonUniformScatteringDensity(field: DensityField, worldZ: number): number {
|
|
121
|
+
let grain = sampleClampedLinear(field.zRed, (worldZ * 0.1) % 1) + 0.5;
|
|
122
|
+
grain *= sampleClampedLinear(field.zRed, (worldZ * 0.05) % 1) + 0.5;
|
|
123
|
+
grain *= sampleClampedLinear(field.zRed, (worldZ * 0.02) % 1) + 0.5;
|
|
124
|
+
return Math.max(2 * grain - 1, 0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nonUniformBeerReference(
|
|
128
|
+
field: DensityField,
|
|
129
|
+
extinction = 1,
|
|
130
|
+
sampleCount = 16_384,
|
|
131
|
+
boundsMinZ = VOLUME_BOUNDS_MIN_Z,
|
|
132
|
+
boundsMaxZ = VOLUME_BOUNDS_MAX_Z,
|
|
133
|
+
): number {
|
|
134
|
+
const interval = boundsMaxZ - boundsMinZ;
|
|
135
|
+
const segmentLength = interval / sampleCount;
|
|
136
|
+
let opticalDepth = 0;
|
|
137
|
+
for (let sample = 0; sample < sampleCount; sample += 1) {
|
|
138
|
+
const worldZ = boundsMinZ + (sample + 0.5) * segmentLength;
|
|
139
|
+
opticalDepth += nonUniformScatteringDensity(field, worldZ) * segmentLength;
|
|
140
|
+
}
|
|
141
|
+
return Math.exp(-extinction * opticalDepth);
|
|
142
|
+
}
|
|
143
|
+
|
|
36
144
|
function halfToFloat(bits: number): number {
|
|
37
145
|
const sign = (bits & 0x8000) === 0 ? 1 : -1;
|
|
38
146
|
const exponent = (bits >>> 10) & 0x1f;
|
|
@@ -42,12 +150,50 @@ function halfToFloat(bits: number): number {
|
|
|
42
150
|
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
|
43
151
|
}
|
|
44
152
|
|
|
153
|
+
function firstStageValue(values: readonly number[], label: string): number {
|
|
154
|
+
const value = values[0];
|
|
155
|
+
if (value === undefined) throw new Error(`missing stage readback value: ${label}`);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
45
159
|
function manifestShader(marker: string): string {
|
|
46
160
|
const entry = engineManifest.entries.find(({ wgsl }) => wgsl.includes(marker));
|
|
47
161
|
if (entry === undefined) throw new Error(`missing cooked shader entry: ${marker}`);
|
|
48
162
|
return entry.wgsl;
|
|
49
163
|
}
|
|
50
164
|
|
|
165
|
+
function productionIntegratorShader(stepCount?: number): string {
|
|
166
|
+
const source = manifestShader('fn volume_integrate');
|
|
167
|
+
if (stepCount === undefined) return source;
|
|
168
|
+
if (!Number.isInteger(stepCount) || stepCount < 1) {
|
|
169
|
+
throw new Error(`invalid production integrator step count: ${stepCount}`);
|
|
170
|
+
}
|
|
171
|
+
const functionStart = source.indexOf('fn volume_integrate');
|
|
172
|
+
if (functionStart < 0) throw new Error('production integrator function missing');
|
|
173
|
+
const prefix = source.slice(0, functionStart);
|
|
174
|
+
const body = source.slice(functionStart);
|
|
175
|
+
// The cooked manifest constant-folds the source-level loop count into the
|
|
176
|
+
// step-length divisor and loop guard. Replace both literals in that one
|
|
177
|
+
// production function to compile the same WGSL with a diagnostic schedule;
|
|
178
|
+
// an omitted override always exercises the unmodified production manifest.
|
|
179
|
+
// Diagnostic schedules disable local refinement to expose the old defect.
|
|
180
|
+
const productionCount = body.match(/< (\d+)u\b/)?.[1];
|
|
181
|
+
if (productionCount === undefined) throw new Error('production ray loop count missing');
|
|
182
|
+
const stepLengthMarker = new RegExp(`f32\\(${productionCount}u\\)`, 'g');
|
|
183
|
+
const loopGuardMarker = new RegExp(`< ${productionCount}u\\b`, 'g');
|
|
184
|
+
const stepLengthOccurrences = body.match(stepLengthMarker)?.length ?? 0;
|
|
185
|
+
const loopGuardOccurrences = body.match(loopGuardMarker)?.length ?? 0;
|
|
186
|
+
if (stepLengthOccurrences !== 1 || loopGuardOccurrences !== 1) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`production integrator step markers: length=${stepLengthOccurrences}, guard=${loopGuardOccurrences}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return `${prefix}${body
|
|
192
|
+
.replace(stepLengthMarker, `f32(${stepCount}u)`)
|
|
193
|
+
.replace(loopGuardMarker, `< ${stepCount}u`)
|
|
194
|
+
.replace(/select\(1u, 4u,/, 'select(1u, 1u,')} `;
|
|
195
|
+
}
|
|
196
|
+
|
|
51
197
|
function identityMatrix(target: Float32Array, offset: number): void {
|
|
52
198
|
target[offset] = 1;
|
|
53
199
|
target[offset + 5] = 1;
|
|
@@ -74,12 +220,18 @@ async function requestDevice(): Promise<RhiDevice> {
|
|
|
74
220
|
}
|
|
75
221
|
|
|
76
222
|
async function runInjectReadback(
|
|
77
|
-
|
|
78
|
-
densitySource: number | 'height-gradient' | 'depth-gradient' = 255,
|
|
79
|
-
heightOffset = 0,
|
|
80
|
-
frameIndex = 0,
|
|
81
|
-
slopedShadow = false,
|
|
223
|
+
options: StageReadbackOptions = {},
|
|
82
224
|
): Promise<VolumeStageReadbacks> {
|
|
225
|
+
const sceneDepthClear = options.sceneDepthClear ?? 1;
|
|
226
|
+
const densityByte = options.densityByte ?? 255;
|
|
227
|
+
const boundsMinZ = options.boundsMinZ ?? VOLUME_BOUNDS_MIN_Z;
|
|
228
|
+
const boundsMaxZ = options.boundsMaxZ ?? VOLUME_BOUNDS_MAX_Z;
|
|
229
|
+
const maxDistance = options.maxDistance ?? 2;
|
|
230
|
+
const viewDepthScale = options.viewDepthScale ?? 1;
|
|
231
|
+
const extinction = options.extinction ?? 1;
|
|
232
|
+
const frameIndex = options.frameIndex ?? 0;
|
|
233
|
+
const shadowDepthClear = options.shadowDepthClear ?? 1;
|
|
234
|
+
const integratorStepCount = options.integratorStepCount;
|
|
83
235
|
const device = await requestDevice();
|
|
84
236
|
const moduleResult = await createShaderModule(device, {
|
|
85
237
|
code: manifestShader('fn volume_inject'),
|
|
@@ -91,7 +243,7 @@ async function runInjectReadback(
|
|
|
91
243
|
label: 'stage-readback.volume-temporal',
|
|
92
244
|
});
|
|
93
245
|
const integrateModule = await createShaderModule(device, {
|
|
94
|
-
code:
|
|
246
|
+
code: productionIntegratorShader(integratorStepCount),
|
|
95
247
|
label: 'stage-readback.volume-integrate',
|
|
96
248
|
});
|
|
97
249
|
if (!temporalModule.ok) throw temporalModule.error;
|
|
@@ -191,7 +343,7 @@ async function runInjectReadback(
|
|
|
191
343
|
});
|
|
192
344
|
const paramsBuffer = device.createBuffer({
|
|
193
345
|
label: 'stage-readback.params',
|
|
194
|
-
size:
|
|
346
|
+
size: VOLUMETRIC_FOG_PARAMS_BYTES,
|
|
195
347
|
usage: 0x0040 | BUFFER_COPY_DST,
|
|
196
348
|
});
|
|
197
349
|
const lightDataBuffer = device.createBuffer({
|
|
@@ -232,28 +384,36 @@ async function runInjectReadback(
|
|
|
232
384
|
|
|
233
385
|
const viewData = new Float32Array(240);
|
|
234
386
|
for (const offset of [0, 28, 44, 60, 76, 92, 196, 212]) identityMatrix(viewData, offset);
|
|
387
|
+
viewData[44 + 10] = viewDepthScale;
|
|
235
388
|
viewData[24] = 0;
|
|
236
|
-
viewData[25] =
|
|
237
|
-
viewData[44 + 13] = heightOffset;
|
|
389
|
+
viewData[25] = 0;
|
|
238
390
|
viewData[26] = -1;
|
|
239
|
-
viewData[124] =
|
|
240
|
-
viewData[108] = 10;
|
|
241
|
-
if (slopedShadow) viewData[28 + 2] = 0.5;
|
|
391
|
+
viewData[124] = 0;
|
|
242
392
|
viewData[228] = 0.1;
|
|
243
393
|
viewData[229] = 10;
|
|
244
394
|
viewData[230] = 0;
|
|
245
395
|
viewData[235] = 1;
|
|
246
|
-
const paramsData = new Float32Array(
|
|
247
|
-
paramsData.set([-1,
|
|
248
|
-
paramsData.set([1,
|
|
249
|
-
paramsData.set([
|
|
396
|
+
const paramsData = new Float32Array(VOLUMETRIC_FOG_PARAMS_BYTES / 4);
|
|
397
|
+
paramsData.set([-1, -1, boundsMinZ, 0], 0);
|
|
398
|
+
paramsData.set([1, 1, boundsMaxZ, 0], 4);
|
|
399
|
+
paramsData.set([extinction, extinction, extinction, 0], 8);
|
|
250
400
|
paramsData.set([1, 1, 1, 0], 12);
|
|
251
401
|
paramsData.set([0, 0, 0, 0], 16);
|
|
252
|
-
paramsData.set([0, -1, 0,
|
|
402
|
+
paramsData.set([0, -1, 0, frameIndex], 20);
|
|
253
403
|
paramsData.set([1, 1, 1, 0], 24);
|
|
254
|
-
paramsData.set([
|
|
255
|
-
paramsData[23] = frameIndex;
|
|
404
|
+
paramsData.set([maxDistance, 0, 0, 0], 28);
|
|
256
405
|
const lightData = new Uint8Array(CLUSTER_LIGHT_DATA_BYTES);
|
|
406
|
+
if (options.localLight !== undefined) {
|
|
407
|
+
paramsData[30] = options.localLight === 'point' ? 1 : 2;
|
|
408
|
+
const light = new Float32Array(lightData.buffer);
|
|
409
|
+
light.set([0, 0, 5.37, 0, 1, 1, 1, Math.cos(Math.PI / 8), 0, 0, -1, Math.cos(Math.PI / 6)]);
|
|
410
|
+
// Zero shadow intensity makes this a pure punctual-light integral. No
|
|
411
|
+
// projected image or shadow map can hide an undersampled light peak.
|
|
412
|
+
new Uint32Array(lightData.buffer).set(
|
|
413
|
+
[options.localLight === 'point' ? 0 : 1, 0xffffffff, 0xffffffff, 0xffffffff],
|
|
414
|
+
16,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
257
417
|
const clusterUniformData = new Float32Array(8);
|
|
258
418
|
const clusterGrid = new Uint32Array(clusterUniformData.buffer);
|
|
259
419
|
clusterGrid[0] = 1;
|
|
@@ -270,22 +430,12 @@ async function runInjectReadback(
|
|
|
270
430
|
);
|
|
271
431
|
if (!viewWrite.ok || !paramsWrite.ok || !lightDataWrite.ok || !clusterUniformWrite.ok)
|
|
272
432
|
throw new Error('Dawn volume stage readback unavailable: uniform upload failed');
|
|
273
|
-
const densityData =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
? Math.round((y / (SIZE - 1)) * 255)
|
|
280
|
-
: densitySource === 'depth-gradient'
|
|
281
|
-
? Math.round((z / (SIZE - 1)) * 255)
|
|
282
|
-
: densitySource;
|
|
283
|
-
densityData.fill(
|
|
284
|
-
value,
|
|
285
|
-
((z * SIZE + y) * SIZE + x) * 4,
|
|
286
|
-
((z * SIZE + y) * SIZE + x + 1) * 4,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
433
|
+
const densityData =
|
|
434
|
+
options.densityData ?? new Uint8Array((BYTES_PER_PIXEL / 2) * SIZE * SIZE * SIZE);
|
|
435
|
+
if (options.densityData === undefined) densityData.fill(densityByte);
|
|
436
|
+
if (densityData.length !== (BYTES_PER_PIXEL / 2) * SIZE * SIZE * SIZE) {
|
|
437
|
+
throw new Error(`Dawn volume stage readback density field length: ${densityData.length}`);
|
|
438
|
+
}
|
|
289
439
|
const densityWrite = device.queue.writeTexture(
|
|
290
440
|
{ texture: densityTexture.value },
|
|
291
441
|
densityData,
|
|
@@ -462,7 +612,7 @@ async function runInjectReadback(
|
|
|
462
612
|
colorAttachments: [],
|
|
463
613
|
depthStencilAttachment: {
|
|
464
614
|
view,
|
|
465
|
-
depthClearValue: index === 0 ? sceneDepthClear :
|
|
615
|
+
depthClearValue: index === 0 ? sceneDepthClear : shadowDepthClear,
|
|
466
616
|
depthLoadOp: 'clear',
|
|
467
617
|
depthStoreOp: 'store',
|
|
468
618
|
},
|
|
@@ -543,23 +693,112 @@ async function runInjectReadback(
|
|
|
543
693
|
};
|
|
544
694
|
}
|
|
545
695
|
|
|
696
|
+
// Independent radiative-transfer quadrature: a homogeneous medium and an
|
|
697
|
+
// unshadowed punctual source. The near-light peak and spot cone make the old
|
|
698
|
+
// 24 midpoint schedule visibly wrong even though its Beer transmittance passes.
|
|
699
|
+
function punctualReference(kind: 'point' | 'spot', samples = 16_384): number[] {
|
|
700
|
+
const sigma = 0.01 * (2 * (128 / 255 + 0.5) ** 3 - 1);
|
|
701
|
+
return Array.from({ length: SIZE * SIZE }, (_, pixel) => {
|
|
702
|
+
const x = (((pixel % SIZE) + 0.5) / SIZE) * 2 - 1;
|
|
703
|
+
const y = 1 - ((Math.floor(pixel / SIZE) + 0.5) / SIZE) * 2;
|
|
704
|
+
const ds = 20 / samples;
|
|
705
|
+
let radiance = 0;
|
|
706
|
+
for (let i = 0; i < samples; i += 1) {
|
|
707
|
+
const s = (i + 0.5) * ds;
|
|
708
|
+
const z = 0.1 + s;
|
|
709
|
+
const distanceSquared = x * x + y * y + (5.37 - z) ** 2;
|
|
710
|
+
const cosine = (5.37 - z) / Math.sqrt(distanceSquared);
|
|
711
|
+
const t = Math.max(
|
|
712
|
+
0,
|
|
713
|
+
Math.min(
|
|
714
|
+
1,
|
|
715
|
+
(cosine - Math.cos(Math.PI / 6)) / (Math.cos(Math.PI / 8) - Math.cos(Math.PI / 6)),
|
|
716
|
+
),
|
|
717
|
+
);
|
|
718
|
+
const cone = kind === 'point' ? 1 : t * t * (3 - 2 * t);
|
|
719
|
+
radiance += ((sigma * Math.exp(-sigma * s) * cone) / distanceSquared) * ds;
|
|
720
|
+
}
|
|
721
|
+
return radiance;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
546
725
|
describe('volumetric fog production stage readback on Dawn', () => {
|
|
547
726
|
it('keeps static shadow visibility stable across renderer frame phases', async () => {
|
|
548
|
-
const first = await runInjectReadback(
|
|
549
|
-
const next = await runInjectReadback(1,
|
|
727
|
+
const first = await runInjectReadback({ frameIndex: 0, shadowDepthClear: 0.5 });
|
|
728
|
+
const next = await runInjectReadback({ frameIndex: 1, shadowDepthClear: 0.5 });
|
|
729
|
+
expect(first.inject).toEqual(next.inject);
|
|
550
730
|
expect(first.inject.red.some((value) => value === 0)).toBe(true);
|
|
551
731
|
expect(first.inject.red.some((value) => value > 0.9)).toBe(true);
|
|
552
|
-
expect(next.inject).toEqual(first.inject);
|
|
553
|
-
});
|
|
554
|
-
it('keeps a static depth-density field stable across renderer frame phases', async () => {
|
|
555
|
-
const first = await runInjectReadback(1, 'depth-gradient', 0, 0);
|
|
556
|
-
for (const frameIndex of [1, 7]) {
|
|
557
|
-
const next = await runInjectReadback(1, 'depth-gradient', 0, frameIndex);
|
|
558
|
-
expect(next.integrate.alpha).toEqual(first.integrate.alpha);
|
|
559
|
-
expect(next.integrate.red).toEqual(first.integrate.red);
|
|
560
|
-
}
|
|
561
|
-
expect(first.integrate.alpha[0]).toBeCloseTo(Math.exp(-0.4), 2);
|
|
562
732
|
});
|
|
733
|
+
|
|
734
|
+
it.each([
|
|
735
|
+
'point',
|
|
736
|
+
'spot',
|
|
737
|
+
] as const)('resolves the %s light peak without coherent ray bands', async (localLight) => {
|
|
738
|
+
const reference = punctualReference(localLight);
|
|
739
|
+
const refined = punctualReference(localLight, 32_768);
|
|
740
|
+
expect(Math.max(...reference.map((v, i) => Math.abs(v - (refined[i] ?? 0)) / v))).toBeLessThan(
|
|
741
|
+
0.0001,
|
|
742
|
+
);
|
|
743
|
+
const options = {
|
|
744
|
+
localLight,
|
|
745
|
+
densityByte: 128,
|
|
746
|
+
extinction: 0.01,
|
|
747
|
+
boundsMaxZ: 20.1,
|
|
748
|
+
maxDistance: 20.1,
|
|
749
|
+
viewDepthScale: 21,
|
|
750
|
+
};
|
|
751
|
+
const maximumRelativeError = (values: VolumeStageReadbacks): number =>
|
|
752
|
+
Math.max(
|
|
753
|
+
...values.integrate.red.map(
|
|
754
|
+
(v, i) => Math.abs(v - (reference[i] ?? 0)) / (reference[i] ?? 1),
|
|
755
|
+
),
|
|
756
|
+
);
|
|
757
|
+
const production = await runInjectReadback(options);
|
|
758
|
+
const undersampled = await runInjectReadback({ ...options, integratorStepCount: 24 });
|
|
759
|
+
const resolved = await runInjectReadback({ ...options, integratorStepCount: 96 });
|
|
760
|
+
const refinedGpu = await runInjectReadback({ ...options, integratorStepCount: 384 });
|
|
761
|
+
const errors = {
|
|
762
|
+
refined: maximumRelativeError(refinedGpu),
|
|
763
|
+
production: maximumRelativeError(production),
|
|
764
|
+
undersampled: maximumRelativeError(undersampled),
|
|
765
|
+
resolved: maximumRelativeError(resolved),
|
|
766
|
+
};
|
|
767
|
+
// biome-ignore lint/suspicious/noConsole: production GPU spatial regression evidence.
|
|
768
|
+
console.info('[volumetric-fog-stage-readback] punctual-convergence', localLight, errors);
|
|
769
|
+
expect(errors.undersampled).toBeGreaterThan(0.1);
|
|
770
|
+
expect(errors.refined).toBeLessThan(0.003);
|
|
771
|
+
expect(errors.production).toBeLessThan(0.03);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('retains fine density variation away from punctual light refinement', async () => {
|
|
775
|
+
// Matching end texels avoid an artificial discontinuity at the repeated
|
|
776
|
+
// texture seam. The interior stripes expose coarse-step density aliasing.
|
|
777
|
+
const field = createNonUniformDensityField([0, 255, 0, 255, 0, 255, 0, 0]);
|
|
778
|
+
const reference = nonUniformBeerReference(field, 0.05, 16_384, 0.1, 20.1);
|
|
779
|
+
const refined = nonUniformBeerReference(field, 0.05, 32_768, 0.1, 20.1);
|
|
780
|
+
expect(Math.abs(reference - refined)).toBeLessThan(0.0001);
|
|
781
|
+
const options = {
|
|
782
|
+
densityData: field.data,
|
|
783
|
+
extinction: 0.05,
|
|
784
|
+
boundsMaxZ: 20.1,
|
|
785
|
+
maxDistance: 20.1,
|
|
786
|
+
viewDepthScale: 21,
|
|
787
|
+
};
|
|
788
|
+
const production = firstStageValue(
|
|
789
|
+
(await runInjectReadback(options)).integrate.alpha,
|
|
790
|
+
'fine density',
|
|
791
|
+
);
|
|
792
|
+
const coarse = firstStageValue(
|
|
793
|
+
(await runInjectReadback({ ...options, integratorStepCount: 24 })).integrate.alpha,
|
|
794
|
+
'coarse density',
|
|
795
|
+
);
|
|
796
|
+
// biome-ignore lint/suspicious/noConsole: independent fine-density regression evidence.
|
|
797
|
+
console.info('[volumetric-fog-stage-readback] fine-density', { reference, production, coarse });
|
|
798
|
+
expect(Math.abs(coarse - reference)).toBeGreaterThan(0.005);
|
|
799
|
+
expect(Math.abs(production - reference)).toBeLessThan(0.002);
|
|
800
|
+
});
|
|
801
|
+
|
|
563
802
|
it('injects raw density then resolves a 2d integrated volume', async () => {
|
|
564
803
|
const values = await runInjectReadback();
|
|
565
804
|
// biome-ignore lint/suspicious/noConsole: stage readback is machine evidence
|
|
@@ -580,23 +819,170 @@ describe('volumetric fog production stage readback on Dawn', () => {
|
|
|
580
819
|
});
|
|
581
820
|
|
|
582
821
|
it('clips the final ray step to the scene-depth segment', async () => {
|
|
583
|
-
const values = await runInjectReadback(0.57);
|
|
822
|
+
const values = await runInjectReadback({ sceneDepthClear: 0.57 });
|
|
584
823
|
const transmittance = values.integrate.alpha[0];
|
|
585
|
-
//
|
|
586
|
-
|
|
824
|
+
// The fixture uploads an all-white source texture. The authored Three
|
|
825
|
+
// smoke expression therefore evaluates to 2 * 1.5^3 - 1 = 5.75 before
|
|
826
|
+
// Beer-Lambert consumes it; only the final 0.47-unit segment is allowed
|
|
827
|
+
// to contribute after the scene-depth clip.
|
|
828
|
+
const sourceDensity = 2 * 1.5 ** 3 - 1;
|
|
829
|
+
expect(transmittance).toBeCloseTo(Math.exp(-0.47 * sourceDensity), 2);
|
|
587
830
|
expect(values.integrate.red[0]).toBeGreaterThan(0);
|
|
588
831
|
});
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
832
|
+
|
|
833
|
+
it('keeps a zero medium transparent and applies Beer-Lambert monotonically', async () => {
|
|
834
|
+
const empty = await runInjectReadback({ densityByte: 0 });
|
|
835
|
+
const medium = await runInjectReadback({ densityByte: 128 });
|
|
836
|
+
const dense = await runInjectReadback({ densityByte: 255 });
|
|
837
|
+
const emptyScattering = firstStageValue(empty.integrate.red, 'empty scattering');
|
|
838
|
+
const denseScattering = firstStageValue(dense.integrate.red, 'dense scattering');
|
|
839
|
+
const mediumScattering = firstStageValue(medium.integrate.red, 'medium scattering');
|
|
840
|
+
const emptyTransmission = firstStageValue(empty.integrate.alpha, 'empty transmission');
|
|
841
|
+
const mediumTransmission = firstStageValue(medium.integrate.alpha, 'medium transmission');
|
|
842
|
+
const denseTransmission = firstStageValue(dense.integrate.alpha, 'dense transmission');
|
|
843
|
+
expect(emptyScattering).toBeCloseTo(0, 2);
|
|
844
|
+
expect(emptyTransmission).toBeCloseTo(1, 2);
|
|
845
|
+
expect(denseTransmission).toBeLessThan(mediumTransmission);
|
|
846
|
+
expect(mediumTransmission).toBeLessThan(emptyTransmission);
|
|
847
|
+
expect(denseScattering).toBeGreaterThan(mediumScattering);
|
|
593
848
|
});
|
|
594
849
|
|
|
595
|
-
it
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
expect(
|
|
850
|
+
it('keeps extinction monotonic and bounds temporal jitter spread', async () => {
|
|
851
|
+
const lowExtinction = await runInjectReadback({ densityByte: 128, extinction: 0.25 });
|
|
852
|
+
const highExtinction = await runInjectReadback({ densityByte: 128, extinction: 1 });
|
|
853
|
+
const lowTransmission = firstStageValue(lowExtinction.integrate.alpha, 'low extinction');
|
|
854
|
+
const highTransmission = firstStageValue(highExtinction.integrate.alpha, 'high extinction');
|
|
855
|
+
expect(highTransmission).toBeLessThan(lowTransmission);
|
|
856
|
+
|
|
857
|
+
const samples: number[] = [];
|
|
858
|
+
for (let frameIndex = 0; frameIndex < 4; frameIndex += 1) {
|
|
859
|
+
const value = await runInjectReadback({ densityByte: 128, frameIndex });
|
|
860
|
+
const transmission = firstStageValue(value.integrate.alpha, `frame ${frameIndex}`);
|
|
861
|
+
expect(Number.isFinite(transmission)).toBe(true);
|
|
862
|
+
samples.push(transmission);
|
|
863
|
+
}
|
|
864
|
+
const spread = Math.max(...samples) - Math.min(...samples);
|
|
865
|
+
// Frame ordinals must not change a fixed World-time density integral.
|
|
866
|
+
// This remains separate from continuous-frame history and spatial accuracy.
|
|
867
|
+
expect(spread).toBeLessThan(0.2);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('converges production step-count variants to the homogeneous Beer oracle', async () => {
|
|
871
|
+
const sceneDepthClear = 0.57;
|
|
872
|
+
const sourceDensity = 2 * 1.5 ** 3 - 1;
|
|
873
|
+
const oracle = Math.exp(-0.47 * sourceDensity);
|
|
874
|
+
const transmissions = await Promise.all(
|
|
875
|
+
[6, 12, 24].map(async (integratorStepCount) => {
|
|
876
|
+
const values = await runInjectReadback({
|
|
877
|
+
sceneDepthClear,
|
|
878
|
+
integratorStepCount,
|
|
879
|
+
});
|
|
880
|
+
return firstStageValue(values.integrate.alpha, `${integratorStepCount}-step transmission`);
|
|
881
|
+
}),
|
|
882
|
+
);
|
|
883
|
+
for (const transmission of transmissions) {
|
|
884
|
+
expect(transmission).toBeCloseTo(oracle, 2);
|
|
885
|
+
}
|
|
886
|
+
expect(Math.max(...transmissions) - Math.min(...transmissions)).toBeLessThan(0.01);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it('converges production steps against an independent non-uniform Beer reference', async () => {
|
|
890
|
+
const field = createNonUniformDensityField();
|
|
891
|
+
const flattenedField = createFlattenedDensityField(field);
|
|
892
|
+
const reference = nonUniformBeerReference(
|
|
893
|
+
field,
|
|
894
|
+
NON_UNIFORM_EXTINCTION,
|
|
895
|
+
16_384,
|
|
896
|
+
VOLUME_BOUNDS_MIN_Z,
|
|
897
|
+
NON_UNIFORM_BOUNDS_MAX_Z,
|
|
898
|
+
);
|
|
899
|
+
const refinedReference = nonUniformBeerReference(
|
|
900
|
+
field,
|
|
901
|
+
NON_UNIFORM_EXTINCTION,
|
|
902
|
+
32_768,
|
|
903
|
+
VOLUME_BOUNDS_MIN_Z,
|
|
904
|
+
NON_UNIFORM_BOUNDS_MAX_Z,
|
|
905
|
+
);
|
|
906
|
+
const flattenedReference = nonUniformBeerReference(
|
|
907
|
+
flattenedField,
|
|
908
|
+
NON_UNIFORM_EXTINCTION,
|
|
909
|
+
16_384,
|
|
910
|
+
VOLUME_BOUNDS_MIN_Z,
|
|
911
|
+
NON_UNIFORM_BOUNDS_MAX_Z,
|
|
912
|
+
);
|
|
913
|
+
expect(Math.abs(reference - refinedReference)).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR / 10);
|
|
914
|
+
expect(Math.abs(reference - flattenedReference)).toBeGreaterThan(
|
|
915
|
+
MIN_NON_UNIFORM_REFERENCE_SEPARATION,
|
|
916
|
+
);
|
|
917
|
+
const stepCounts = [24, 48, 96] as const;
|
|
918
|
+
const transmissions = await Promise.all(
|
|
919
|
+
stepCounts.map(async (integratorStepCount) => {
|
|
920
|
+
const values = await runInjectReadback({
|
|
921
|
+
boundsMaxZ: NON_UNIFORM_BOUNDS_MAX_Z,
|
|
922
|
+
densityData: field.data,
|
|
923
|
+
extinction: NON_UNIFORM_EXTINCTION,
|
|
924
|
+
integratorStepCount,
|
|
925
|
+
maxDistance: NON_UNIFORM_BOUNDS_MAX_Z,
|
|
926
|
+
viewDepthScale: 10,
|
|
927
|
+
});
|
|
928
|
+
return firstStageValue(
|
|
929
|
+
values.integrate.alpha,
|
|
930
|
+
`${integratorStepCount}-step non-uniform transmission`,
|
|
931
|
+
);
|
|
932
|
+
}),
|
|
933
|
+
);
|
|
934
|
+
const flattenedValues = await runInjectReadback({
|
|
935
|
+
boundsMaxZ: NON_UNIFORM_BOUNDS_MAX_Z,
|
|
936
|
+
densityData: flattenedField.data,
|
|
937
|
+
extinction: NON_UNIFORM_EXTINCTION,
|
|
938
|
+
integratorStepCount: 96,
|
|
939
|
+
maxDistance: NON_UNIFORM_BOUNDS_MAX_Z,
|
|
940
|
+
viewDepthScale: 10,
|
|
941
|
+
});
|
|
942
|
+
const flattenedTransmission = firstStageValue(
|
|
943
|
+
flattenedValues.integrate.alpha,
|
|
944
|
+
'flattened-field transmission',
|
|
945
|
+
);
|
|
946
|
+
const defaultValues = await runInjectReadback({
|
|
947
|
+
boundsMaxZ: NON_UNIFORM_BOUNDS_MAX_Z,
|
|
948
|
+
densityData: field.data,
|
|
949
|
+
extinction: NON_UNIFORM_EXTINCTION,
|
|
950
|
+
maxDistance: NON_UNIFORM_BOUNDS_MAX_Z,
|
|
951
|
+
viewDepthScale: 10,
|
|
952
|
+
});
|
|
953
|
+
const defaultTransmission = firstStageValue(
|
|
954
|
+
defaultValues.integrate.alpha,
|
|
955
|
+
'production transmission',
|
|
956
|
+
);
|
|
957
|
+
const errors = transmissions.map((transmission) => Math.abs(transmission - reference));
|
|
958
|
+
const flattenedError = Math.abs(flattenedTransmission - flattenedReference);
|
|
959
|
+
const defaultError = Math.abs(defaultTransmission - reference);
|
|
960
|
+
// biome-ignore lint/suspicious/noConsole: non-uniform production convergence receipt.
|
|
961
|
+
console.info(
|
|
962
|
+
'[volumetric-fog-stage-readback] non-uniform-convergence',
|
|
963
|
+
JSON.stringify({
|
|
964
|
+
reference,
|
|
965
|
+
refinedReference,
|
|
966
|
+
flattenedReference,
|
|
967
|
+
stepCounts,
|
|
968
|
+
transmissions,
|
|
969
|
+
errors,
|
|
970
|
+
flattenedTransmission,
|
|
971
|
+
flattenedError,
|
|
972
|
+
defaultTransmission,
|
|
973
|
+
defaultError,
|
|
974
|
+
}),
|
|
975
|
+
);
|
|
976
|
+
for (const error of errors) expect(error).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
|
|
977
|
+
expect(flattenedError).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
|
|
978
|
+
expect(defaultError).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
|
|
979
|
+
expect(Math.abs((transmissions[2] ?? NaN) - flattenedTransmission)).toBeGreaterThan(
|
|
980
|
+
MIN_NON_UNIFORM_GPU_SEPARATION,
|
|
981
|
+
);
|
|
982
|
+
const spread = Math.max(...transmissions) - Math.min(...transmissions);
|
|
983
|
+
// rgba16float readback quantizes nearby step variants onto the same value,
|
|
984
|
+
// so strict error ordering is not a stable production contract. Keep the
|
|
985
|
+
// independent reference and require a bounded cross-step spread instead.
|
|
986
|
+
expect(spread).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
|
|
601
987
|
});
|
|
602
988
|
});
|
|
@@ -8,6 +8,7 @@ const signature: VolumeTemporalSignature = {
|
|
|
8
8
|
densityGeneration: 3,
|
|
9
9
|
width: 1280,
|
|
10
10
|
height: 720,
|
|
11
|
+
worldTimeSeconds: 4,
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
describe('volumetric fog temporal contract', () => {
|
|
@@ -23,11 +24,37 @@ describe('volumetric fog temporal contract', () => {
|
|
|
23
24
|
['fog-revision', { ...signature, fogRevision: 3 }],
|
|
24
25
|
['light-revision', { ...signature, lightRevision: 8 }],
|
|
25
26
|
['density-generation', { ...signature, densityGeneration: 4 }],
|
|
27
|
+
['simulation-time', { ...signature, worldTimeSeconds: 4.5 }],
|
|
26
28
|
['resize', { ...signature, width: 640, height: 360 }],
|
|
27
29
|
] as const)('resets history for %s', (reason, next) => {
|
|
28
30
|
expect(resolveVolumeTemporalReset(signature, next)).toEqual({ reset: true, reason });
|
|
29
31
|
});
|
|
30
32
|
|
|
33
|
+
it.each([1 / 144, 1 / 60, 1 / 30, 1 / 15])('retains animated history at dt=%s', (delta) => {
|
|
34
|
+
expect(
|
|
35
|
+
resolveVolumeTemporalReset(signature, { ...signature, worldTimeSeconds: 4 + delta }),
|
|
36
|
+
).toEqual({ reset: false });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it.each([3.9, 4.101, NaN, Infinity])('rejects discontinuous time %s', (worldTimeSeconds) => {
|
|
40
|
+
expect(resolveVolumeTemporalReset(signature, { ...signature, worldTimeSeconds })).toEqual({
|
|
41
|
+
reset: true,
|
|
42
|
+
reason: 'simulation-time',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects changed clock availability', () => {
|
|
47
|
+
const { worldTimeSeconds: _time, ...withoutTime } = signature;
|
|
48
|
+
expect(resolveVolumeTemporalReset(signature, withoutTime)).toEqual({
|
|
49
|
+
reset: true,
|
|
50
|
+
reason: 'simulation-time',
|
|
51
|
+
});
|
|
52
|
+
expect(resolveVolumeTemporalReset(withoutTime, signature)).toEqual({
|
|
53
|
+
reset: true,
|
|
54
|
+
reason: 'simulation-time',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
31
58
|
it('rejects out-of-screen reprojection and depth discontinuity without NaN', () => {
|
|
32
59
|
expect(
|
|
33
60
|
resolveVolumeTemporalReset(signature, signature, { reprojection: 'out-of-screen' }),
|