@woosh/meep-engine 3.11.0 → 3.11.2
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/package.json +1 -1
- package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
- package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +294 -290
- package/src/engine/graphics3/ParticipatingMedia.d.ts +111 -0
- package/src/engine/graphics3/ParticipatingMedia.d.ts.map +1 -0
- package/src/engine/graphics3/ParticipatingMedia.js +203 -0
- package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts +34 -0
- package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts.map +1 -0
- package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.js +73 -0
- package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +87 -0
- package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -0
- package/src/engine/graphics3/ParticipatingMediaSystem3.js +184 -0
- package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
- package/src/shade/playground/volumetrics_froxel/README.md +191 -0
- package/src/shade/playground/volumetrics_froxel/index.html +270 -0
- package/src/shade/playground/volumetrics_froxel/main.d.ts +2 -0
- package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -0
- package/src/shade/playground/volumetrics_froxel/main.js +917 -0
- package/src/shade/playground/volumetrics_froxel/measure.d.ts +119 -0
- package/src/shade/playground/volumetrics_froxel/measure.d.ts.map +1 -0
- package/src/shade/playground/volumetrics_froxel/measure.js +559 -0
- package/src/shade/renderer/Renderer.d.ts +13 -0
- package/src/shade/renderer/Renderer.d.ts.map +1 -1
- package/src/shade/renderer/Renderer.js +45 -18
- package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
- package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts +14 -13
- package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts.map +1 -1
- package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.js +133 -129
- package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +480 -315
- package/src/shade/renderer/volumetrics/NOTES.md +691 -9
- package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts +5 -0
- package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.js +21 -2
- package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts +26 -0
- package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/SceneVolumetrics.js +64 -21
- package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.js +84 -28
- package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts +98 -0
- package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.js +111 -0
- package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts +84 -0
- package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.js +252 -0
- package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts +21 -0
- package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.js +47 -0
- package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.js +75 -64
- package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts +37 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.js +56 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts +43 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.js +145 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts +20 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.js +48 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts +38 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.js +107 -0
- package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +4 -1
- package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +577 -465
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +418 -324
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.js +14 -3
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts +8 -0
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts.map +1 -0
- package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.js +174 -0
- package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.d.ts.map +1 -1
- package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.js +48 -36
- package/src/shade/wgsl/emulator/CPUBitmapData.d.ts +25 -0
- package/src/shade/wgsl/emulator/CPUBitmapData.d.ts.map +1 -1
- package/src/shade/wgsl/emulator/CPUBitmapData.js +33 -0
- package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +2221 -2215
- package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
- package/src/shade/wgsl/emulator/WGSLLanguageCore.js +1297 -1117
- package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts +69 -0
- package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts.map +1 -0
- package/src/shade/wgsl/emulator/cpu_texture_gather.js +148 -0
- package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts +16 -0
- package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts.map +1 -1
- package/src/shade/wgsl/emulator/cpu_texture_sample.js +176 -172
|
@@ -1,9 +1,691 @@
|
|
|
1
|
-
* https://shaderbits.com/blog/creating-volumetric-ray-marcher
|
|
2
|
-
* "Physically Based and Unified Volumetric Rendering in Frostbite" SIGGRAPH 2015
|
|
3
|
-
* "Creating the Atmospheric World of Red Dead Redemption 2: A Complete and Integrated Solution" SIGGRAPH 2019
|
|
4
|
-
* "Volumetric Fog in Enshrouded" GPC 2024 (https://www.youtube.com/watch?v=OR8HbFnQdlk)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Check out "A Novel Sampling Algorithm for Fast and Stable Real-Time Volume Rendering" SIGGRAPH 2015
|
|
9
|
-
* https://github.com/huwb/volsample/blob/master/src/shadertoy/clouds.shader
|
|
1
|
+
* https://shaderbits.com/blog/creating-volumetric-ray-marcher
|
|
2
|
+
* "Physically Based and Unified Volumetric Rendering in Frostbite" SIGGRAPH 2015
|
|
3
|
+
* "Creating the Atmospheric World of Red Dead Redemption 2: A Complete and Integrated Solution" SIGGRAPH 2019
|
|
4
|
+
* "Volumetric Fog in Enshrouded" GPC 2024 (https://www.youtube.com/watch?v=OR8HbFnQdlk)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Check out "A Novel Sampling Algorithm for Fast and Stable Real-Time Volume Rendering" SIGGRAPH 2015
|
|
9
|
+
* https://github.com/huwb/volsample/blob/master/src/shadertoy/clouds.shader
|
|
10
|
+
## Blue noise for the froxel column jitter — measured, 2026-08-29
|
|
11
|
+
|
|
12
|
+
Considered as a fix for C-05 (adjacent columns carry independent jitter, so a trilinear XY fetch
|
|
13
|
+
mixes slabs at different depths) and as groundwork for cutting `SAMPLE_COUNT`. Measured against the
|
|
14
|
+
shipped `stbn_vec*.bin` volumes rather than argued. Three findings, one of which kills the original
|
|
15
|
+
rationale.
|
|
16
|
+
|
|
17
|
+
**Reusing `stbn_vec3` is not an option — it biases, and the bias does not converge away.**
|
|
18
|
+
The aerial pass already reads `stbn_sample_vec3(vec3(voxel_coord.xy, view.frame_index))` and
|
|
19
|
+
consumes all three channels (`.xy` for the tile UV, `.z` for the slice dither). A column jitter
|
|
20
|
+
indexed the same way is *the same number* — correlation 1.0000, not merely "the same set". Two
|
|
21
|
+
nuisance variables locked together stop exploring the unit square and only ever visit its diagonal,
|
|
22
|
+
so the estimator converges to the wrong integral:
|
|
23
|
+
|
|
24
|
+
| integrand over (j, d) | truth | naive `stbn_vec3.x` converges to | error |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| `j*d` | 0.250 | 0.334 | +34% |
|
|
27
|
+
| `exp(-3(j+d))` | 0.100 | 0.167 | +67% |
|
|
28
|
+
| `step(j > d)` | 0.500 | 0.000 | −100% |
|
|
29
|
+
|
|
30
|
+
RMSE is flat from N=32 to N=128 in every case. That is bias, not noise — no TAA window fixes it.
|
|
31
|
+
|
|
32
|
+
**An independent source is fine, and converges faster than the hash.** `stbn_vec1` is a separate
|
|
33
|
+
scalar 128×128×64 volume, already loaded by `STATIC_GRAPHICS_ENGINE_ASSETS`, uniform to 0.75%, and
|
|
34
|
+
decorrelated from all three `stbn_vec3` channels (|r| ≤ 0.0018). RMSE on `j*d`: 0.0478 vs the
|
|
35
|
+
murmur hash's 0.0632 at N=8, 0.0149 vs 0.0219 at N=64 — roughly 1.3–1.5× better across the window
|
|
36
|
+
the 0.95 TAA blend actually spans (~39 effective samples). Bias ≤ 0.0025.
|
|
37
|
+
|
|
38
|
+
Caveat: STBN stops improving past N=64, the volume's temporal depth, while white noise keeps going —
|
|
39
|
+
at N=128 the hash wins. The TAA never reaches that regime, but anything accumulating longer would.
|
|
40
|
+
|
|
41
|
+
**It does not fix C-05, it makes it slightly worse.** Blue noise is built to make neighbours
|
|
42
|
+
*maximally different*; C-05 wants them *similar*. Measured adjacent-column jitter difference:
|
|
43
|
+
hash 0.3331 (neighbour r = 0.0005, i.e. independent), `stbn_vec1` 0.3656 (neighbour r = −0.112,
|
|
44
|
+
anti-correlated). So it widens the cross-column depth mixing by ~10%.
|
|
45
|
+
|
|
46
|
+
Conclusion: C-05 stays closed as a documented non-issue — it is zero-mean, the TAA integrates it
|
|
47
|
+
away, and the obvious fix aggravates the stated mechanism. Genuinely removing it means dropping the
|
|
48
|
+
XY jitter (the TAA's 3×3×3 Blackman–Harris resample already does spatial reconstruction), not
|
|
49
|
+
changing the noise source. Adopt `stbn_vec1` when cutting `SAMPLE_COUNT` (P-01), where better
|
|
50
|
+
per-pixel temporal distribution is the actual goal — and never `stbn_vec3`, for the reason above.
|
|
51
|
+
|
|
52
|
+
## Shadowing the clustered lights — measured, 2026-08-29
|
|
53
|
+
|
|
54
|
+
Q-01. The point and spot loops in `shader_volumetrics_build_lighting` never applied a shadow map, so
|
|
55
|
+
geometry did not occlude a local light in the volume at all; only the medium's own optical depth
|
|
56
|
+
did. The directional loop always had. Fixed by `chunk_volumetrics_local_light_shadow`, one nearest
|
|
57
|
+
atlas tap per light.
|
|
58
|
+
|
|
59
|
+
Two things about it were expected to be problems and measured not to be. The numbers below came from
|
|
60
|
+
a `local_light_shadow_mode` word on the metadata that switched between off, that tap, and the
|
|
61
|
+
deferred path's filter kernels; it and the two playground checkboxes over it have since been removed,
|
|
62
|
+
which is why nothing in the tree reproduces these rows any more. Restoring the comparison means
|
|
63
|
+
re-adding that switch.
|
|
64
|
+
|
|
65
|
+
**The cost is nearly nothing, even at 130 shadow-casting lights.** Whole-frame median over 18
|
|
66
|
+
interleaved rounds in the playground scene at 3840x2160 (the froxel grid is 4x the 1080p one, so
|
|
67
|
+
this magnifies any per-froxel cost by 4x):
|
|
68
|
+
|
|
69
|
+
| sampling | frame (ms) | vs. off |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| off | 10.29 | — |
|
|
72
|
+
| one atlas tap (what ships) | 10.42 | +0.13 (+1.2%) |
|
|
73
|
+
| deferred kernels (8 cone taps / 9-gather PCF) | 10.71 | +0.42 (+4.0%) |
|
|
74
|
+
|
|
75
|
+
Scaled back to 1080p that is +0.03 ms and +0.10 ms, both far below the run-to-run spread. Which
|
|
76
|
+
means the thing P-02 exists to make affordable was already affordable — do not reach for a
|
|
77
|
+
pre-filtered ESM/VSM target on Q-01's behalf. It also means the "skip the shadow test for lights
|
|
78
|
+
whose contribution is negligible" idea has nothing left to buy and would only add a popping risk.
|
|
79
|
+
|
|
80
|
+
**The cheap tap is not a quality compromise here — it is indistinguishable, and marginally
|
|
81
|
+
quieter.** Under the measurement protocol in the playground README (auto exposure off, 45 frames of
|
|
82
|
+
re-convergence, 32-frame average, noise floor from an identical no-op toggle):
|
|
83
|
+
|
|
84
|
+
| comparison | signed mean | mean abs |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| noise floor (mode 1 vs itself) | -0.053 | 1.110 |
|
|
87
|
+
| mode 1 vs off | **-0.852** | 1.826 |
|
|
88
|
+
| mode 2 vs off | **-0.853** | 2.010 |
|
|
89
|
+
| mode 1 vs mode 2 | +0.0004 | 1.439 |
|
|
90
|
+
|
|
91
|
+
The two paths remove the same light to four decimal places — 0.0004 against a floor of 0.05 — and
|
|
92
|
+
their per-pixel difference sits at the floor. Residual noise after the TAA (mean abs of two settled
|
|
93
|
+
captures of the same setting) is 1.100 off, 1.343 for one tap, 1.381 for the kernels: the 8-tap cone
|
|
94
|
+
is *noisier*, because its taps are drawn stochastically per frame. Repeated with a shadow-casting
|
|
95
|
+
spot light added to the scene: mode 1 vs mode 2 signed mean -0.061, mean abs 1.092, against that
|
|
96
|
+
run's floor of +0.016 / 1.124 — below the floor on both statistics.
|
|
97
|
+
|
|
98
|
+
So the PCF kernel really was filtering something about to be filtered three more times, and the
|
|
99
|
+
deferred path keeps it for surfaces, where it belongs.
|
|
100
|
+
|
|
101
|
+
One caveat came out of this, recorded because it bit the spec, and since fixed — though not the way
|
|
102
|
+
it first looked. `shadowmap_sample_point` rounds its taps stochastically from
|
|
103
|
+
`uv * face_size - 0.5`, and an axis-aligned light-to-sample direction encodes to an octahedral uv of
|
|
104
|
+
exactly 0 or 1, so some taps land at texel -1 or `face_size`, one texel outside the rect the record
|
|
105
|
+
publishes. Latent in the deferred path (mode 2), not introduced here; mode 1 clamps and never had it.
|
|
106
|
+
Not an exotic input either: the xy axes are the four edge midpoints of the square and the -Z pole is
|
|
107
|
+
its corner, so a lamp above the floor beneath it overshoots on roughly half its taps.
|
|
108
|
+
|
|
109
|
+
**That overshoot is provided for, and the first read of this was wrong.** `shadow_point` publishes
|
|
110
|
+
the INNER octahedral surface — `make_octahedral_point_shadow_record` subtracts
|
|
111
|
+
`SHADOWMAP_ATLAS_BORDER` (4) from every side of the slot — and the cube → octahedral remap fills the
|
|
112
|
+
ring around it with `inner[texture_octahedral_wrap_texel_coordinates(local)]`, folding each border
|
|
113
|
+
fragment through the wrap before decoding a direction from it. One texel of overshoot therefore
|
|
114
|
+
reads this light's own wrapped data, four texels inside its slot. Nothing was ever reading a
|
|
115
|
+
neighbouring light, and the `face_size` overshoot was never a defect at all.
|
|
116
|
+
|
|
117
|
+
What *was* wrong is narrower: the sampler reached the skirt through
|
|
118
|
+
`vec2<u32>(rounded_local_texel) + vec2<u32>(atlas_aabb.xy)`, and `vec2<u32>` of `-1.0` is a
|
|
119
|
+
negative-to-`u32` conversion WGSL leaves indeterminate. Land on `0xFFFFFFFF` and the wrapping add
|
|
120
|
+
gets to `inner - 1`, which is right; land on `0`, equally legal, and the tap silently reads the
|
|
121
|
+
opposite edge of the inner surface. Fixed by doing the add in `i32` — exact for an integral `f32` in
|
|
122
|
+
`[-1, size]`, and the inner origin is at least `SHADOWMAP_ATLAS_BORDER`, so the `u32` at the end is
|
|
123
|
+
defined.
|
|
124
|
+
|
|
125
|
+
`shadow/map/shader/shadowmap_sample_point_seam.spec.js` builds a synthetic atlas with the skirt the
|
|
126
|
+
remap would have written, and drives the shader under *both* legal readings of that conversion,
|
|
127
|
+
requiring the same answer. That is the assertion that failed before; a checkerboard inner surface
|
|
128
|
+
makes the two readings disagree on every affected tap, because the skirt texel at local `(x, -1)`
|
|
129
|
+
holds `inner[size - 1 - x, 0]`, of opposite parity to `inner[x, 0]`.
|
|
130
|
+
|
|
131
|
+
Worth knowing if the border is ever revisited: `chunk_shadow2d_pcf_multiple_tap_pcf_contact_hardened`,
|
|
132
|
+
the DPCF disk kernel `SHADOWMAP_ATLAS_BORDER`'s doc names as its reason to exist, is imported by no
|
|
133
|
+
shader — `chunk_shadowmap_sample_point2` replaced it with cone sampling. So the skirt's only current
|
|
134
|
+
reader is that one-texel overshoot, and 8 texels per slot (half of a 16-slot) are otherwise idle.
|
|
135
|
+
Cutting the border is therefore possible but not free: the overshoot needs at least one texel of it.
|
|
136
|
+
|
|
137
|
+
## Amortizing the sun's optical depth into a light-aligned volume — measured, 2026-08-29
|
|
138
|
+
|
|
139
|
+
> **Superseded in two places by the last section of this file.** What kept this switched off is not
|
|
140
|
+
> the frustum-clipped medium; the field-of-view measurement that argued it was is confounded, because
|
|
141
|
+
> widening the lens also coarsens the box. And the structure is cascaded now, not a single volume.
|
|
142
|
+
|
|
143
|
+
Q-04. `integrate_optical_depth` marches 16 steps from every froxel toward every light. For two
|
|
144
|
+
froxels on the same light ray the answers differ by one increment, and the pass recomputes the whole
|
|
145
|
+
integral for each: a prefix sum evaluated as N independent sums.
|
|
146
|
+
`shader_volumetrics_build_sun_shadow_volume` computes it once instead, into a voxel grid whose third
|
|
147
|
+
axis is the sun, and `chunk_volumetrics_sun_optical_depth` reads it back in a single fetch.
|
|
148
|
+
`__VOL_SUN_SHADOW` switches between them. **Off by default**, for the one reason recorded at the end.
|
|
149
|
+
|
|
150
|
+
### What the literature actually does, including a correction
|
|
151
|
+
|
|
152
|
+
The report this came from credited both RDR2 and Enshrouded with amortizing the per-froxel march
|
|
153
|
+
into a volumetric shadow representation. Read against the released material, one of those is right
|
|
154
|
+
and the other is not.
|
|
155
|
+
|
|
156
|
+
**Enshrouded (GPC 2024) is doing exactly this, cascaded.** From the talk: "for rendering shadows …
|
|
157
|
+
we are using 3D volumes in a cascaded fashion and align their z-axis to the direction of the primary
|
|
158
|
+
light source, so in a first pass we would just initialize them with participating media properties
|
|
159
|
+
and in a second pass integrate from the light source down … and that gives us information about how
|
|
160
|
+
much shadowing there is from the fog for any position inside those cascades." And on the shipping
|
|
161
|
+
version: "we're initializing that one in one 3D dispatch and then do a 2D dispatch for each cascade
|
|
162
|
+
in the direction of the light … each cascade as an initial shadow value reads the upper cascade, so
|
|
163
|
+
in the end we can just sample the one cascade and with one sample we get the full shadow volume, and
|
|
164
|
+
the outer cascade is initialized with approximate volumetric shadows for clouds." Same structure,
|
|
165
|
+
same build shape — a 2D dispatch walking columns along the light — with cascades on top and the
|
|
166
|
+
outermost seeded from a cloud approximation. Their fog lives in a world-space voxel grid at 1 m per
|
|
167
|
+
voxel, which is why they do not have the caveat below.
|
|
168
|
+
|
|
169
|
+
**RDR2 (SIGGRAPH 2019) is not.** It does have a *Shadow Volume* — R16F, one of three volumes in a
|
|
170
|
+
160x88x64 frustum grid, temporally filtered — but the slide and its speaker notes say what it holds:
|
|
171
|
+
"the directional light shadow term sampled from the shadow map cascade as well as cloud and terrain
|
|
172
|
+
shadow map". That is the **shadow map** lookup amortized, not the medium's own optical depth. The
|
|
173
|
+
medium's self-shadowing there is a short secondary sample on cloud material ("we also sample
|
|
174
|
+
extinction towards the sun or moon to generate high frequency shadowing") plus the cloud shadow map,
|
|
175
|
+
which is a light-space ESM built by ray-marching the cloudscape from the light — a genuine
|
|
176
|
+
light-space transmittance structure, but for clouds only. The talk's own Future Outlook slide lists
|
|
177
|
+
**"Volumetric shadows"** as work not done. The review document is corrected accordingly.
|
|
178
|
+
|
|
179
|
+
Costs from that talk, for calibration, on PS4: cloud ESM 0.156 ms, shadow volume 0.204 ms, material
|
|
180
|
+
volume 0.33 ms, scattering volume 0.34 ms, far-field raymarch 1.31 ms.
|
|
181
|
+
|
|
182
|
+
**Fourier Opacity Mapping is the famous answer and the wrong one here.** FOM (Jansen & Bavoil, I3D
|
|
183
|
+
2010) projects log-transmittance along each light-space texel onto a truncated Fourier basis. It
|
|
184
|
+
exists because the medium usually arrives as *unordered rasterized particles*, where the only thing
|
|
185
|
+
available is a fixed-size, order-independent, additively blendable summary — and it pays for that
|
|
186
|
+
with ringing at sharp density transitions, which is why Adaptive Volumetric Shadow Maps (Salvi et
|
|
187
|
+
al., EGSR 2010) answer the same problem with a variable-error compressed curve instead. This engine's
|
|
188
|
+
medium is already a 3D texture with a defined order along any axis, so the scan is available, exact
|
|
189
|
+
for the piecewise-constant field a voxel grid *is*, and has nothing to ring. In the shadow-map
|
|
190
|
+
taxonomy what is implemented here is an Opacity Shadow Map (Kim & Neumann 2001) with enough slices
|
|
191
|
+
and hardware trilinear reconstruction that the slice artefacts it is remembered for do not appear.
|
|
192
|
+
|
|
193
|
+
### The structure, and why it does not swim
|
|
194
|
+
|
|
195
|
+
A cube in sun space, sized from the camera frustum's **bounding sphere** and snapped to whole voxels.
|
|
196
|
+
Both halves matter and neither is decoration:
|
|
197
|
+
|
|
198
|
+
- The sphere's radius is a function of `fov`, `aspect`, `near` and `far` only, so the extent does not
|
|
199
|
+
change when the camera turns. A tight fit of the frustum corners in sun space — which is what this
|
|
200
|
+
engine's CSM deliberately does, for reasons its own comment gives — would breathe under rotation,
|
|
201
|
+
moving every voxel centre continuously. The CSM can afford that because it is two orders of
|
|
202
|
+
magnitude denser and its consumer is a filtered depth comparison; this is neither.
|
|
203
|
+
- With a fixed extent, snapping the origin makes the world-space voxel lattice *the same set of
|
|
204
|
+
points* every frame. Only the indexing changes. `build_volumetrics_sun_shadow_transform.spec.js`
|
|
205
|
+
asserts that directly: voxel centres generated for one camera pose land on voxel centres of the
|
|
206
|
+
grid generated for another, under translation, rotation and both together.
|
|
207
|
+
|
|
208
|
+
Confirmed on the device, with the volumetric TAA off so nothing could hide a discontinuity. Creeping
|
|
209
|
+
the camera across three voxels of translation, and separately across 20 degrees of yaw, the volume
|
|
210
|
+
path's mean absolute second difference of frame luminance is *lower* than the march's — 0.0140 vs
|
|
211
|
+
0.0165 translating, 0.0051 vs 0.0253 rotating. It does not snap.
|
|
212
|
+
|
|
213
|
+
Six scripted motions (yaw, pitch, forward, backward, strafe, combined), 90 frames each, at two
|
|
214
|
+
viewpoints, comparing the volume against the march on flicker while moving, on flicker and drift
|
|
215
|
+
while settling, and on the converged image after the motion against the converged image from a
|
|
216
|
+
standing start at the same pose. Every pair agrees to within a few percent; the largest gap in
|
|
217
|
+
"flown vs still" is 0.06 mean luminance against a floor of 0.05. The structure adds no temporal
|
|
218
|
+
instability of its own. `playground/volumetrics_froxel/measure.js` is that harness.
|
|
219
|
+
|
|
220
|
+
### Cost, from the profiler rather than from frame time
|
|
221
|
+
|
|
222
|
+
Per pass, `WORKLOAD` capture decoded in the page, 50 frames, two runs each:
|
|
223
|
+
|
|
224
|
+
| pass | 1920x1080, march -> volume | 3840x2160, march -> volume |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| build sun shadow volume | 0.003 -> **0.048** | 0.003 -> **0.053** |
|
|
227
|
+
| build lighting | 0.675 -> **0.499** | 1.857 -> **1.274** |
|
|
228
|
+
| net | **-0.13 ms** | **-0.53 ms** |
|
|
229
|
+
|
|
230
|
+
The whole-frame delta at 2160p is -0.55 ms with disjoint spreads over seven interleaved rounds,
|
|
231
|
+
matching the per-pass sum. At 1080p whole-frame timing could not resolve it at all: the run-to-run
|
|
232
|
+
spread is about +-0.5 ms and three interleaved runs read +0.11, +0.31 and +0.37 — the *wrong sign*.
|
|
233
|
+
The README's advice to attribute cost to passes rather than infer it from frame time earned itself
|
|
234
|
+
here.
|
|
235
|
+
|
|
236
|
+
The shape is the point. The build is fixed at ~0.05 ms, and the same at 64^3 as at 128^3 — it is not
|
|
237
|
+
where the time goes — while what it removes scales with the froxel count. Double the resolution and
|
|
238
|
+
the saving doubles while the cost does not move.
|
|
239
|
+
|
|
240
|
+
### Why it is off by default
|
|
241
|
+
|
|
242
|
+
At seven of the nine playground viewpoints the two paths agree to within about four times the noise
|
|
243
|
+
floor (|signed mean| <= 0.20 against a floor of 0.01-0.09). "Under the island" reads -0.73. **"Inside
|
|
244
|
+
the fog" reads +1.92**, and carries a single **+3.8 pop** around 8 degrees into a yaw, which the
|
|
245
|
+
0.95-blend TAA passes straight through rather than absorbing.
|
|
246
|
+
|
|
247
|
+
That is not resolution, and the measurement that settles it is the field of view. At that same
|
|
248
|
+
viewpoint, widening to 110 degrees brings the disagreement to -0.05 against a floor of -0.06 — gone —
|
|
249
|
+
while *raising* the volume's resolution does not converge at all (32^3 through 192^3 oscillates
|
|
250
|
+
between +7.0 and -1.9). The pop moves when `__VOL_FROXEL_FAR` moves and does not move with the box's
|
|
251
|
+
snapping: the transform is bit-identical either side of it.
|
|
252
|
+
|
|
253
|
+
> **Read the next section before trusting the paragraph below.** It calls the march "the converged
|
|
254
|
+
> one", on the strength of `STEP_COUNT = 256` moving it by only -0.24 at *that* viewpoint. That is
|
|
255
|
+
> true there and false in general — sixteen steps step clean over a thin fog slab, and there are
|
|
256
|
+
> viewpoints in this same scene where the march is 58 luminance from its own converged answer while
|
|
257
|
+
> the volume is 29 from it. Which of the two is nearer the truth is a property of the geometry, not
|
|
258
|
+
> of the method.
|
|
259
|
+
|
|
260
|
+
All of which points at the medium rather than the structure. `tParticipatingMedia1` is hard clipped
|
|
261
|
+
to the camera frustum (Q-05), so the field being scanned has an artificial discontinuity in it. The
|
|
262
|
+
march resolves that edge at its own step size, which is anchored at the froxel and shrinks to nothing
|
|
263
|
+
exactly where the exit is near; a world-space lattice resolves it at a fixed 2.3 units. Where the sun
|
|
264
|
+
ray leaves the frustum within a voxel or two of the froxel — inside dense fog, looking toward a low
|
|
265
|
+
sun — that edge *is* the integral, and the two disagree. Widening the FOV pushes it away and they
|
|
266
|
+
agree again. For a reference: the march at `STEP_COUNT = 256` differs from the shipping 16 by only
|
|
267
|
+
-0.24 mean luminance, so the march is the converged one and the volume is the one in error there.
|
|
268
|
+
|
|
269
|
+
So the fix is not more voxels and not a smarter fit. It is to stop sampling a frustum-shaped medium —
|
|
270
|
+
build the volume from the scene's participating-media volumes directly, which removes the
|
|
271
|
+
discontinuity, closes Q-05, and is the arrangement Enshrouded has. That is one shader's worth of work
|
|
272
|
+
now that the structure and the measurements exist, and flipping the default is a line.
|
|
273
|
+
|
|
274
|
+
Cascades are the other half of what Enshrouded ships, and this is shaped to take them: an inner
|
|
275
|
+
cascade's scan starts from the outer cascade's value at its entry face instead of from zero. Worth it
|
|
276
|
+
when the near field needs resolution the uniform grid cannot give — which, on this evidence, is not
|
|
277
|
+
the binding constraint yet.
|
|
278
|
+
|
|
279
|
+
## The sun volume strobed, and the march was not the reference — measured, 2026-08-29
|
|
280
|
+
|
|
281
|
+
> **Read the last section of this file for what closed this.** The strobe diagnosis here is right
|
|
282
|
+
> and the fix for it stands. The paragraph at the end proposing temporal accumulation is answered
|
|
283
|
+
> there, and the answer is no; so is the claim that the box is thin in two axes.
|
|
284
|
+
|
|
285
|
+
Reported from the playground: at a camera parked beside one of the small fog boxes, with the sun
|
|
286
|
+
ahead, `__VOL_SUN_SHADOW = true` made the light pulse. Two poses, both reproducible, both with the
|
|
287
|
+
camera *stationary* — which rules out everything the section above establishes about the box moving,
|
|
288
|
+
since a still camera means a bit-identical transform every frame.
|
|
289
|
+
|
|
290
|
+
Frame-to-frame mean luminance, 24 frames, camera static:
|
|
291
|
+
|
|
292
|
+
| | march (16 steps) | volume, 1 sample per voxel |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| mean | 120.19 | 91.59 |
|
|
295
|
+
| mean absolute frame-to-frame step | 0.0023 | 0.131 |
|
|
296
|
+
| largest single-frame step | 0.004 | **0.623** |
|
|
297
|
+
|
|
298
|
+
At the second pose the largest step was **1.81**. That is a visible pulse, and the temporal filter
|
|
299
|
+
passes it through rather than absorbing it.
|
|
300
|
+
|
|
301
|
+
### The march was not the reference, and the earlier note leant on it as if it were
|
|
302
|
+
|
|
303
|
+
Raising `STEP_COUNT` to 512 to get a converged answer at these poses:
|
|
304
|
+
|
|
305
|
+
| | pose 1 | pose 2 |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| march, 16 steps (shipping) | 120.18 | 149.47 |
|
|
308
|
+
| **march, 512 steps** | **62.19** | **79.03** |
|
|
309
|
+
| volume, 128^3, 1 sample per voxel | 91.59 | 119.23 |
|
|
310
|
+
| volume, 256^3 | 63.20 | — |
|
|
311
|
+
|
|
312
|
+
So at these poses the shipping march is 58 luminance from its own converged answer and the volume is
|
|
313
|
+
29 from it: the amortized path is the *more* accurate of the two, and the resolution sweep that
|
|
314
|
+
looked like divergence — 64^3 giving 120.3, 128^3 giving 91.8, 256^3 giving 63.2 — was convergence
|
|
315
|
+
all along, toward a reference the earlier work never computed at this geometry. It read as
|
|
316
|
+
divergence only because the shipping march was being used as ground truth, and here it is nowhere
|
|
317
|
+
near it.
|
|
318
|
+
|
|
319
|
+
Worth saying plainly, because it inverts the last paragraph of the section above: **the march at 16
|
|
320
|
+
steps is converged when the medium is thick and spread out, and badly wrong when it is thin.** Its
|
|
321
|
+
step is the distance to the frustum exit over sixteen, which at these poses is about 8 units, and
|
|
322
|
+
the fog box it is meant to find is 3 units thick. It steps over it. A stable wrong answer does not
|
|
323
|
+
strobe, which is the only reason the march looks better on the flicker column.
|
|
324
|
+
|
|
325
|
+
### Why it strobed, and the fix
|
|
326
|
+
|
|
327
|
+
The fog box here is 10x3x20 and the camera sits about a third of a unit outside its face. The sun
|
|
328
|
+
travels roughly along -X, so what the ray toward the sun has to cross is the 3-unit dimension — and
|
|
329
|
+
the 128^3 volume's voxel is 2.28 units. Walking the column that passes through the camera and asking
|
|
330
|
+
which lattice points fall inside any scene volume: **two of them.** The entire shadow rested on two
|
|
331
|
+
samples, and the froxel grid's per-frame column jitter moves the medium under both.
|
|
332
|
+
|
|
333
|
+
The fix is to stop conflating the sampling rate with the storage resolution. A voxel has to hold the
|
|
334
|
+
medium's *integral across its slab*, and one point sample at its centre is not that. The build pass
|
|
335
|
+
now takes four sub-steps per voxel along the sun axis and stores the running integral at the voxel
|
|
336
|
+
centre as before — 0.57-unit sample spacing behind a 2.28-unit voxel, one loop rather than 64x the
|
|
337
|
+
memory:
|
|
338
|
+
|
|
339
|
+
| | 1 sample/voxel | 4 sub-steps/voxel |
|
|
340
|
+
|---|---|---|
|
|
341
|
+
| pose 1, largest frame step | 0.623 | **0.083** |
|
|
342
|
+
| pose 2, largest frame step | 1.81 | **0.151** |
|
|
343
|
+
| pose 1, mean abs step | 0.131 | 0.020 |
|
|
344
|
+
| pose 2, mean abs step | 0.468 | 0.037 |
|
|
345
|
+
|
|
346
|
+
Six to thirteen times quieter, and it improves the static disagreement at *Inside the fog* as well,
|
|
347
|
+
+1.92 -> +1.30. The cost, per pass at `WORKLOAD`:
|
|
348
|
+
|
|
349
|
+
| | 1920x1080 | 3840x2160 |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| build sun shadow volume | 0.008 -> 0.115 | 0.003 -> 0.133 |
|
|
352
|
+
| build lighting | 0.427 -> 0.318 | 1.772 -> 1.068 |
|
|
353
|
+
| net | **~0.00 ms** | **-0.57 ms** |
|
|
354
|
+
|
|
355
|
+
So the strobe fix spends the 1080p win — it is now a wash there — and keeps the 2160p one. The build
|
|
356
|
+
grew 2.7x rather than 4x, the rest being fixed overhead.
|
|
357
|
+
|
|
358
|
+
### The switch is gone, and what that costs
|
|
359
|
+
|
|
360
|
+
Q-04 is approved, so the cascades are the sun's only path and the A/B that measured them has been
|
|
361
|
+
removed: the `__VOL_SUN_SHADOW` global, the playground checkbox over it, and the `sun_shadow_mode`
|
|
362
|
+
word in the metadata, along with the branch in `shader_volumetrics_build_lighting` that read it.
|
|
363
|
+
Same treatment Q-01 got, and it has the same consequence — **nothing in the tree reproduces the
|
|
364
|
+
tables above any more.** Restoring the comparison means re-adding that word and its branch.
|
|
365
|
+
|
|
366
|
+
Restoring the rest of it means more than that, and it is worth writing down while it is fresh:
|
|
367
|
+
|
|
368
|
+
* the converged reference needs `STEP_COUNT` in `chunk_integrate_optical_depth` raised to 512;
|
|
369
|
+
* the single-box rows need `VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO` set to 1, which makes the three
|
|
370
|
+
cascades coincide and the structure exactly one box, built by the same code;
|
|
371
|
+
* the resolution ladder needs `VOLUMETRICS_SUN_SHADOW_RESOLUTION` edited. There was a
|
|
372
|
+
`__VOL_SUN_SHADOW_RESOLUTION` override for it; it went with the rest, because a global that only
|
|
373
|
+
exists to A/B a constant is the same thing as the switch and it was inconsistent to keep one
|
|
374
|
+
without the other — the ratio never had one.
|
|
375
|
+
|
|
376
|
+
What replaces the mode word is one field rather than two. `sun_shadow_light_index` already said
|
|
377
|
+
which directional light the cascades were built for, so that a second sun degrades to the march
|
|
378
|
+
instead of being shadowed by the first one's boxes; it now also carries
|
|
379
|
+
`VOLUMETRICS_SUN_SHADOW_LIGHT_NONE` for the two cases that can place no box at all — a scene with no
|
|
380
|
+
directional light, and a camera with no field of view to derive a bounding sphere from. The sentinel
|
|
381
|
+
matches no index, so those degrade every directional light the same way. "Not built this frame" is a
|
|
382
|
+
property of the light, not a mode to select, and saying it that way removes the failure mode a mode
|
|
383
|
+
word has: left at zero it selects the march, silently, everywhere.
|
|
384
|
+
|
|
385
|
+
The build passes still run in those cases, collapsed to one voxel per cascade. Not for measurement
|
|
386
|
+
symmetry any more — that argument went with the A/B — but because the lighting pass binds their
|
|
387
|
+
outputs unconditionally and WebGPU has no optional bindings.
|
|
388
|
+
|
|
389
|
+
### What is left, and it is not more sub-steps
|
|
390
|
+
|
|
391
|
+
Sub-stepping fixed the *temporal* aliasing and left most of the *magnitude* gap: pose 1 went 91.59 ->
|
|
392
|
+
92.97 against a reference of 62.19. That is because sub-stepping refines one axis and the fog box is
|
|
393
|
+
thin in two of them — perpendicular to the sun its cross-section is the 3x20 face, so the 3-unit
|
|
394
|
+
dimension is under-sampled laterally as well, and only 256^3, which refines all three, converges.
|
|
395
|
+
Lateral sub-sampling would blur the shadow rather than sharpen it, so that is not the answer either.
|
|
396
|
+
|
|
397
|
+
The answer is *not* what the first draft of this section said, which was to build the volume from
|
|
398
|
+
the scene's media volumes and integrate each box analytically, "since the chord a sun ray cuts
|
|
399
|
+
through a box has a closed form". That assumes the medium inside a box is uniform, and it is not.
|
|
400
|
+
|
|
401
|
+
`compute_volume_fade_factor` ramps the density near every face:
|
|
402
|
+
`smoothstep(0, fade_distance, d.x) * smoothstep(0, fade_distance, d.y) * smoothstep(0, fade_distance, d.z)`
|
|
403
|
+
over the world-space distance to each face. So a box's interior is a separable cubic-Hermite ramp,
|
|
404
|
+
and `chord_length * sigma` is wrong by whatever the fade profile does along that chord. The volumes
|
|
405
|
+
overlap too, though that half is harmless on its own — optical depth is additive, so
|
|
406
|
+
`tau = integral of sum(sigma_i) = sum of integral of sigma_i`, and each volume could in principle be
|
|
407
|
+
integrated separately. It is the non-uniformity inside each one that kills it.
|
|
408
|
+
|
|
409
|
+
And there is a worse problem underneath, which is that **the participating-media pass does not
|
|
410
|
+
discretise a field — it evaluates an estimator**. `compute_volume_fade_factor` takes the *froxel's
|
|
411
|
+
frustum*, intersects it with the box, and samples the fade at the midpoint of that intersection
|
|
412
|
+
segment; `frustum_unit_box_coverage` multisamples the froxel/box overlap to get a coverage fraction.
|
|
413
|
+
Both answers depend on the shape of the froxel, not only on where it is. There is therefore no
|
|
414
|
+
`sigma(x)` that the PM grid is sampling and that something else could integrate exactly: an analytic
|
|
415
|
+
sun-volume build would be computing a different quantity from the one the froxel grid holds, and the
|
|
416
|
+
two would disagree by construction, everywhere, for a reason no measurement would attribute
|
|
417
|
+
correctly.
|
|
418
|
+
|
|
419
|
+
`ParticipatingMediaVolume` also carries `noise_scale`, `noise_amplitude` and `noise_evolution_speed`.
|
|
420
|
+
They are not in `PARTICIPATING_MEDIA_VOLUME_STRUCT`, so nothing reaches the GPU and no noise is
|
|
421
|
+
applied today — but the data model plainly intends it, and an analytic scheme would die the day it is
|
|
422
|
+
wired.
|
|
423
|
+
|
|
424
|
+
So: sample the medium, do not try to solve it. What makes that affordable is the one property this
|
|
425
|
+
structure already has and the froxel grid does not — **the world-space voxel lattice does not move**,
|
|
426
|
+
which the transform spec asserts and the device measurements confirm. A lattice that does not move
|
|
427
|
+
can be accumulated in place: one *jittered* sample per voxel per frame, a ping-pong pair of volumes,
|
|
428
|
+
and a blend. No reprojection, because there is nothing to reproject; the effective sample count grows
|
|
429
|
+
with the frame count instead of with the per-frame cost, and it converges on the true cell average of
|
|
430
|
+
whatever the medium turns out to be, fade profiles and noise included. The sub-steps this commit adds
|
|
431
|
+
would then drop back toward one.
|
|
432
|
+
|
|
433
|
+
It is not free of conditions, and they are the usual ones for anything temporal:
|
|
434
|
+
|
|
435
|
+
* the lattice is invariant to *camera* motion, not to the sun turning (the basis changes) nor to
|
|
436
|
+
`fov`/`near`/`far` changing (the extent changes) — both need a reset, and both are rare;
|
|
437
|
+
* a volume that moves or animates needs the accumulation invalidated, which `SceneVolumetrics` can
|
|
438
|
+
now signal since it gained `invalidate`;
|
|
439
|
+
* it buys accuracy with lag, which is the trade the froxel TAA already makes one pass downstream.
|
|
440
|
+
|
|
441
|
+
What none of this fixes is the *spatial* resolution of the stored shadow: a three-unit box behind a
|
|
442
|
+
2.3-unit voxel gives a blurry shadow no matter how well each voxel's content is estimated. That is
|
|
443
|
+
what cascades are for, and it is a separate axis from this.
|
|
444
|
+
|
|
445
|
+
## The sun volume needed cascades, and nothing else would do — measured, 2026-08-29
|
|
446
|
+
|
|
447
|
+
Q-04, third pass. The section above ends by proposing temporal accumulation, and blames the
|
|
448
|
+
frustum-clipped medium (Q-05) for the disagreement that kept the structure switched off. Both are
|
|
449
|
+
wrong, and the measurements that show it also show what the structure actually needed. It now ships
|
|
450
|
+
**on by default**, as three cascades.
|
|
451
|
+
|
|
452
|
+
Everything below is against a converged reference: `chunk_integrate_optical_depth` with `STEP_COUNT`
|
|
453
|
+
raised to 512, rendered by the same code path as the shipping march so the two differ in nothing but
|
|
454
|
+
the step count. 1280x720, auto exposure off, 45 frames of re-convergence and a 32-frame average,
|
|
455
|
+
noise floor from a repeat of the reference inside the same run — the protocol in the playground
|
|
456
|
+
README. Numbers are signed mean luminance against that reference.
|
|
457
|
+
|
|
458
|
+
### Where the three paths stand
|
|
459
|
+
|
|
460
|
+
| viewpoint | reference | march, 16 steps | one box, 128^3 | **cascades, 3 x 96^3** |
|
|
461
|
+
|---|---|---|---|---|
|
|
462
|
+
| beside the fog bank, pose 1 | 62.39 | **+58.2** | +30.5 | **-1.13** |
|
|
463
|
+
| beside the fog bank, pose 2 | 79.05 | **+70.5** | +37.6 | **-0.53** |
|
|
464
|
+
| Inside the fog | 114.68 | +0.29 | +1.51 | +0.04 |
|
|
465
|
+
| Fog bank, wide | 110.22 | +0.01 | 0.00 | -0.02 |
|
|
466
|
+
| Under the island | 92.02 | +0.62 | -0.19 | -0.20 |
|
|
467
|
+
| Dead overview | 100.88 | +0.12 | -0.16 | -0.08 |
|
|
468
|
+
|
|
469
|
+
Floors 0.01 to 0.16. The amortized path is not a cheaper approximation of the march any more. It is
|
|
470
|
+
the accurate one, by a factor of fifty at the two viewpoints where the two disagree at all, and it
|
|
471
|
+
is also the cheaper one.
|
|
472
|
+
|
|
473
|
+
### The error was resolution, on one axis, and the axis is not the sun's
|
|
474
|
+
|
|
475
|
+
Isolating axes at pose 1, one box, four sub-steps throughout so the quadrature is held fixed. The
|
|
476
|
+
sun travels along -X at 12 degrees of elevation; the volume's basis puts `u` horizontal and `v`
|
|
477
|
+
within 12 degrees of vertical, and the fog bank is a slab three units thick in world Y.
|
|
478
|
+
|
|
479
|
+
| grid | lateral voxel (u, v) | sun voxel | signed |
|
|
480
|
+
|---|---|---|---|
|
|
481
|
+
| `[128,128,128]` | 2.28, 2.28 | 2.28 | +30.5 |
|
|
482
|
+
| `[128,128,256]` — sun axis x2 | 2.28, 2.28 | 1.14 | +30.3 |
|
|
483
|
+
| `[256,128,128]` — u x2 | 1.14, 2.28 | 2.28 | +30.9 |
|
|
484
|
+
| **`[128,256,128]` — v x2** | 2.28, **1.14** | 2.28 | **+6.1** |
|
|
485
|
+
| `[128,512,128]` — v x4 | 2.28, 0.57 | 2.28 | +4.8 |
|
|
486
|
+
| `[256,256,128]` | 1.14, 1.14 | 2.28 | +3.5 |
|
|
487
|
+
|
|
488
|
+
Doubling the sun axis buys nothing. Doubling the *one* lateral axis that runs across the slab's
|
|
489
|
+
three-unit dimension removes 80% of the error. That is a Nyquist threshold and it sits where one
|
|
490
|
+
would predict: 1.52 units (a `[192,192,*]` grid) still reads +21.9, and 1.14 units reads +6.1.
|
|
491
|
+
|
|
492
|
+
So the previous section's account — "it refines one axis and the box is thin in two of them", and
|
|
493
|
+
"only 256^3, which refines all three, converges" — is wrong on both counts. As the sun sees it the
|
|
494
|
+
box is thin in *one* axis, and `[256,256,32]` converges at exactly the memory `[128,128,128]` was
|
|
495
|
+
already spending.
|
|
496
|
+
|
|
497
|
+
### Prefiltering cannot fix it, and neither can temporal accumulation
|
|
498
|
+
|
|
499
|
+
This is the measurement that closes the proposal the section above ends on. Replace the single
|
|
500
|
+
lateral sample per sub-step with a 4x4 stratified average over the voxel's lateral footprint —
|
|
501
|
+
sixteen taps, deterministic, and therefore the *converged limit* of any scheme that reaches the same
|
|
502
|
+
cell average stochastically over time. Quadrature held at a constant 0.6 unit spacing.
|
|
503
|
+
|
|
504
|
+
| | 1 lateral sample | 4x4 lateral |
|
|
505
|
+
|---|---|---|
|
|
506
|
+
| `[128,128,128]`, pose 1 | +30.5 | **+25.1** |
|
|
507
|
+
| `[128,128,128]`, pose 2 | +37.5 | **+29.7** |
|
|
508
|
+
| `[256,256,32]`, pose 1 | +0.35 | +4.09 |
|
|
509
|
+
| `[256,256,32]`, pose 2 | +7.59 | +10.05 |
|
|
510
|
+
|
|
511
|
+
Sixteen times the sampling recovers 18% of the error, and at a resolution that already resolves the
|
|
512
|
+
medium it makes matters *worse*, because there it blurs a shadow that was resolved. That is what a
|
|
513
|
+
prefilter is for and all it is for: it removes aliasing, not the resolution that was aliased away. A
|
|
514
|
+
box filter preserves the integral of `tau` and lowers its peak, and the froxels that carry the image
|
|
515
|
+
sit at the peak.
|
|
516
|
+
|
|
517
|
+
**One jittered sample per voxel per frame, accumulated in place, converges on that same 4x4 column.**
|
|
518
|
+
The world-space lattice really does not move — that half of the proposal is sound and its spec still
|
|
519
|
+
asserts it — but what it does not move *toward* is the reference. Temporal accumulation would spend
|
|
520
|
+
a settling window, a reset on every sun rotation and projection change, and an invalidation on every
|
|
521
|
+
moving volume, to arrive at +25.1 instead of +30.5. It is the wrong instrument for this defect. It
|
|
522
|
+
stays the right instrument for the residual per-frame noise, which is a different and much smaller
|
|
523
|
+
problem — the stability table below puts that noise at 0.03 to 0.05.
|
|
524
|
+
|
|
525
|
+
The literature says the same thing from the other end. Optical depth is *linear* in `sigma`, which is
|
|
526
|
+
why every light-space structure in this family stores it: Fourier Opacity Mapping (Jansen & Bavoil,
|
|
527
|
+
I3D 2010) projects absorption along the ray onto a Fourier basis and exponentiates on read, and its
|
|
528
|
+
own framing is that it is for media whose spatial opacity variations are smooth. Deep Shadow Maps
|
|
529
|
+
(Lokovic & Veach, SIGGRAPH 2000) prefilter in *transmittance* instead, because their medium is hair
|
|
530
|
+
and the sub-texel variation is binary. And averaging the extinction of a heterogeneous medium to
|
|
531
|
+
downsample it is known to be wrong in a specific direction — Zhao et al., "Downsampling Scattering
|
|
532
|
+
Parameters for Rendering Anisotropic Media" (SIGGRAPH Asia 2016), put it as linear downsampling
|
|
533
|
+
weakening the object's intrinsic shadowing structures, which is precisely the +25.1 above.
|
|
534
|
+
|
|
535
|
+
### And the frustum clip is not what kept it switched off
|
|
536
|
+
|
|
537
|
+
The section above attributes the *Inside the fog* disagreement to Q-05, on the strength of widening
|
|
538
|
+
the field of view to 110 degrees making it vanish while raising the resolution did not. That
|
|
539
|
+
inference does not hold, because **widening the field of view changes the resolution**: the box is
|
|
540
|
+
sized from the frustum's bounding sphere, whose radius runs 93 units at 40 degrees, 141 at 60, 245
|
|
541
|
+
at 90 and 350 at 110. Opening the lens by a factor of 2.5 coarsens every voxel by the same factor.
|
|
542
|
+
The disagreement went away at 110 degrees because the box happened to land on a size that suited
|
|
543
|
+
that geometry, which the next table makes plain.
|
|
544
|
+
|
|
545
|
+
### Why no single box could ever have been right
|
|
546
|
+
|
|
547
|
+
Pose 2, against a converged reference computed at each field of view. The cascade ratio is forced to
|
|
548
|
+
1 so the three cascades coincide and the structure is exactly a single box, built by the same code
|
|
549
|
+
with the same sub-steps — the only variable is the shape.
|
|
550
|
+
|
|
551
|
+
| field of view | reference | one box 96^3 | one box 128^3 | one box `[256,256,32]` | 3 x 64^3 | **3 x 96^3** | 3 x 128^3 |
|
|
552
|
+
|---|---|---|---|---|---|---|---|
|
|
553
|
+
| 40 deg | 84.96 | **+49.97** | +6.43 | +28.09 | -0.61 | **-1.35** | -1.77 |
|
|
554
|
+
| 60 deg | 79.05 | +5.87 | **+37.68** | +2.47 | +1.01 | **-0.53** | -1.14 |
|
|
555
|
+
| 90 deg | 76.21 | +5.13 | +1.43 | **+38.96** | +1.29 | **+0.32** | -0.41 |
|
|
556
|
+
| 110 deg | 74.94 | **+39.46** | +2.07 | **+39.46** | +5.86 | **+1.77** | +0.13 |
|
|
557
|
+
|
|
558
|
+
Floor ±0.12 throughout. **Every single-box configuration measured has a field of view at which it is
|
|
559
|
+
28 to 50 luminance from the truth, and which one that is moves with the resolution.** That is not
|
|
560
|
+
tuning, it is arithmetic: the box has to contain the frustum under any rotation, so its side is
|
|
561
|
+
proportional to `far` and to `tan(fov/2)`, while a three-unit fog slab is three units whatever the
|
|
562
|
+
camera is doing. The ratio between what the structure can resolve and what it has to resolve is a
|
|
563
|
+
free parameter of the *camera*, and no constant pins it.
|
|
564
|
+
|
|
565
|
+
Buying the near field uniformly is not an option either. A 0.28 unit voxel over a 283 unit box is
|
|
566
|
+
1024 per axis: 268 MB.
|
|
567
|
+
|
|
568
|
+
### Cascades, which is what both shipped references do
|
|
569
|
+
|
|
570
|
+
Three boxes, each sized from the bounding sphere of the sub-frustum out to `far / 3^(2-i)` — 13.3,
|
|
571
|
+
40 and 120 units at the playground's projection, so 0.34, 1.02 and 3.07 unit voxels at 96 per axis.
|
|
572
|
+
7.1 MB each, 21.2 MB in total, against the 16.8 MB the single 128^3 box was already spending.
|
|
573
|
+
|
|
574
|
+
Every property the single box had transfers, because each cascade is placed by the same call with a
|
|
575
|
+
different `far`: the extent is a function of the projection alone, so it cannot breathe under
|
|
576
|
+
rotation, and the snapped origin makes each cascade's world-space voxel lattice the same set of
|
|
577
|
+
points from frame to frame. `build_volumetrics_sun_shadow_transform.spec.js` now asserts that per
|
|
578
|
+
cascade, along with the containment the seeding depends on.
|
|
579
|
+
|
|
580
|
+
Enshrouded (GPC 2024) ships exactly this: "each cascade as an initial shadow value reads the upper
|
|
581
|
+
cascade, so in the end we can just sample the one cascade and with one sample we get the full shadow
|
|
582
|
+
volume". So does Frostbite (SIGGRAPH 2015) one level down the stack — its extinction volume is a
|
|
583
|
+
three-level clip-map and its volumetric shadow maps are 32^3 3D textures storing *transmittance*,
|
|
584
|
+
built at 0.04 ms for a spot light and 0.14 ms for a point light on PS4. Both cascade, and neither
|
|
585
|
+
has this engine's problem of a medium that exists only inside the camera frustum.
|
|
586
|
+
|
|
587
|
+
Three things about the construction are decisions rather than transcription:
|
|
588
|
+
|
|
589
|
+
- **The seed is one fetch of the outer cascade at the world position where this column enters this
|
|
590
|
+
box**, not at the box centre and not a re-march of the approach. Everything between the sun and
|
|
591
|
+
that plane has already been integrated out there. It is what makes an inner cascade a *refinement*
|
|
592
|
+
of the outer rather than a truncation of the medium — without it every near-field froxel would be
|
|
593
|
+
lit as if the fog behind it did not exist, and the volume would still come out perfectly smooth,
|
|
594
|
+
with the correct slope, and wrong.
|
|
595
|
+
- **A texture per cascade, not one texture with the cascades stacked along w.** Stacking puts a
|
|
596
|
+
pass's own output and its input in the same texture, which WebGPU rejects: a subresource cannot be
|
|
597
|
+
bound as writable storage and as a sampled texture at once, and a 3D texture has no subresource
|
|
598
|
+
finer than the whole thing to split it on.
|
|
599
|
+
- **Selection is on view depth, with a blend band over the last 10% of each cascade's range.** The
|
|
600
|
+
two cascades do not agree where they meet — one is more accurate, which is the reason there are
|
|
601
|
+
two — so a hard switch is a ring at a fixed view depth, and the 0.95-blend froxel TAA smears such
|
|
602
|
+
an edge rather than removing it. Depth also makes the branch uniform: the lighting pass dispatches
|
|
603
|
+
one froxel slice per workgroup in z, and a slice is a surface of constant depth.
|
|
604
|
+
|
|
605
|
+
The boxes are **cubic**, and that too is measured rather than assumed. `[256,256,32]` is tempting —
|
|
606
|
+
at 60 degrees it beats `[128,128,128]` by an order of magnitude at identical memory, for the good
|
|
607
|
+
reason that this scene's medium is eight times finer across the sun than along it. The table above
|
|
608
|
+
is what that costs at other fields of view. Two axes, two Nyquist limits, and which one is starved
|
|
609
|
+
is a property of the fog and the sun's elevation rather than of the method.
|
|
610
|
+
|
|
611
|
+
The sub-step count is per cascade — 4, 2, 2 from the outermost inward — so that the *world-space
|
|
612
|
+
sample spacing* is held constant across cascades rather than the count per voxel. It has to be even:
|
|
613
|
+
the build stores its running integral at sub-step `count / 2`, which is the voxel's centre only when
|
|
614
|
+
the halves are equal, and a count of one stores it at the near face, half a slab of missing shadow
|
|
615
|
+
on whichever cascade drew the short straw.
|
|
616
|
+
|
|
617
|
+
### Cost
|
|
618
|
+
|
|
619
|
+
Per pass, `WORKLOAD` capture decoded in the page — `measure.js` grew a `profile` for it, so this no
|
|
620
|
+
longer needs a downloaded `.sgpt` — medians over interleaved rounds at pose 1.
|
|
621
|
+
|
|
622
|
+
| 1920x1080 | build | build lighting | total |
|
|
623
|
+
|---|---|---|---|
|
|
624
|
+
| march, 16 steps | 0.010 | 0.521 | **0.531** |
|
|
625
|
+
| cascades 3 x 64^3 | 0.120 | 0.282 | **0.422** |
|
|
626
|
+
| cascades 3 x 96^3 | 0.187 | 0.190 | **0.370** |
|
|
627
|
+
|
|
628
|
+
| 3840x2160 | build | build lighting | total |
|
|
629
|
+
|---|---|---|---|
|
|
630
|
+
| march, 16 steps | 0.011 | 1.503 | **1.515** |
|
|
631
|
+
| cascades 3 x 64^3 | 0.131 | 0.866 | **0.997** |
|
|
632
|
+
| cascades 3 x 96^3 | 0.188 | 0.617 | **0.805** |
|
|
633
|
+
|
|
634
|
+
The single box with four sub-steps was break-even at 1080p; three cascades are 0.16 ms *ahead* of
|
|
635
|
+
the march there and 0.71 ahead at 2160p. Not a paradox: the build is bound by the length of its
|
|
636
|
+
serial dependency chain rather than by its sample count, so splitting one 128-slice column into
|
|
637
|
+
three 96-slice ones with three times as many columns is a better shape for the machine even at 1.7x
|
|
638
|
+
the samples. Measured directly on the single box, `[128,128,128]` and `[256,256,32]` carry the same
|
|
639
|
+
8.4M samples and cost 0.094 ms and 0.032 ms.
|
|
640
|
+
|
|
641
|
+
One caveat in those tables. The lighting pass does the same work for 64^3 and 96^3 — one fetch
|
|
642
|
+
either way — and reads 0.282 against 0.190. Every round put 64^3 immediately after the march, so
|
|
643
|
+
read that gap as ordering within the round rather than as a property of the change.
|
|
644
|
+
|
|
645
|
+
### Temporal stability
|
|
646
|
+
|
|
647
|
+
Static camera, 24 frames, largest single-frame step in mean luminance:
|
|
648
|
+
|
|
649
|
+
| | pose 1 | pose 2 | Inside the fog |
|
|
650
|
+
|---|---|---|---|
|
|
651
|
+
| march (512 steps) | 0.012 | 0.011 | 0.004 |
|
|
652
|
+
| cascades 3 x 96^3 | 0.051 | 0.033 | 0.003 |
|
|
653
|
+
| cascades 3 x 64^3 | 0.023 | 0.049 | 0.004 |
|
|
654
|
+
| *one box, one sample per voxel — the reported strobe* | *0.595* | *0.636* | — |
|
|
655
|
+
|
|
656
|
+
An order of magnitude below the pulse that was reported as visible, and three to four times the
|
|
657
|
+
march's own floor.
|
|
658
|
+
|
|
659
|
+
Six scripted motions — yaw, pitch, forward, backward, strafe, and all three together — 90 frames of
|
|
660
|
+
motion plus 40 of hold, at two viewpoints, comparing the cascades against the march on flicker while
|
|
661
|
+
moving, on flicker and drift while settling, and on the converged image after the motion against the
|
|
662
|
+
converged image from a standing start at the same pose. At pose 1:
|
|
663
|
+
|
|
664
|
+
| motion | settling flicker, cascades / march | flown vs still, cascades / march |
|
|
665
|
+
|---|---|---|
|
|
666
|
+
| yaw | 0.011 / 0.008 | +0.056 / +0.004 |
|
|
667
|
+
| pitch | 0.005 / 0.003 | -0.007 / +0.013 |
|
|
668
|
+
| forward | 0.043 / 0.042 | +0.037 / +0.013 |
|
|
669
|
+
| backward | 0.007 / 0.008 | -0.047 / -0.043 |
|
|
670
|
+
| strafe | 0.022 / 0.022 | +0.024 / +0.016 |
|
|
671
|
+
| combined | 0.012 / 0.013 | +0.009 / -0.016 |
|
|
672
|
+
|
|
673
|
+
At *Inside the fog* the two agree to within 0.01 on every motion and every statistic. The forward
|
|
674
|
+
and backward runs are the ones to look at: they sweep the cascade boundaries eight units through the
|
|
675
|
+
fog, which is the one instability this change could have introduced, and they read identically to
|
|
676
|
+
the march. The **+3.8 pop** the section above reports around 8 degrees into a yaw at that viewpoint
|
|
677
|
+
is gone — the largest single-frame step across that motion is 0.233 for the cascades against 0.235
|
|
678
|
+
for the march.
|
|
679
|
+
|
|
680
|
+
### What is left
|
|
681
|
+
|
|
682
|
+
- **Q-05 still stands, and is now the largest defect here.** The medium exists only inside the camera
|
|
683
|
+
frustum, so fog outside it casts no shadow. Both paths have that, so it appears nowhere in the
|
|
684
|
+
tables above: every one of them compares two readings of the same clipped medium.
|
|
685
|
+
- A systematic **-1.2 luminance** at the higher resolutions, converging rather than shrinking — 64^3
|
|
686
|
+
reads -0.32 at pose 1, 96^3 -1.13, 112^3 -1.23. Small, and part of it is the reference's own bias
|
|
687
|
+
(the march's first sample carries 0.9 of a step's weight), but it is not noise.
|
|
688
|
+
- The cascade count is capped at 4 because the per-cascade scalars are packed into a `vec4f`. Past
|
|
689
|
+
that the metadata wants a real array, whose uniform-space stride is 16 bytes per `f32`.
|
|
690
|
+
- The build is latency-bound, not bandwidth-bound. If it ever has to be cheaper, the lever is the
|
|
691
|
+
serial chain — fewer slices per cascade, or a cooperative scan — and not fewer samples.
|