DDGI: camera-centered multi-cascade clipmap with probe relocation and scalable ray budgeting - #1674
DDGI: camera-centered multi-cascade clipmap with probe relocation and scalable ray budgeting#1674Anthony-Gaudino wants to merge 21 commits into
Conversation
Replace the scene-bounds-fitted DDGI grid with a camera-centered grid that scrolls by whole cells as the camera moves, addressing probe buffers toroidally so only the newly-entered slab reconverges. Fixes probes drifting through geometry on camera movement and keeps a fixed probe density near the viewer regardless of map size.
Previously, the raytrace computed
surface.IsBackface() for every ray but discarded
the information, leaving no way to detect when a
probe was stuck inside a mesh. Now, backface hits
store negative depth (magnitude = distance) while
frontface hits store positive depth; the sign is
free to mean front/back.
The depth update pass reads this signal: a probe
is "inside" when >25% of its rays hit backfaces (a
ray only sees a backface from behind the surface).
Inside probes:
- Relocate toward the nearest backface (the
closest exit) instead of pushing off
frontfaces, clamped to ±half cell
- Are marked !VALID (0.02 down-weighting in
sampling) so their tainted radiance doesn't
leak
Self-correcting: once relocation moves a probe
into open space, the backface ratio drops below
25% and it goes VALID again. Probes too deep to
escape (beyond the offset clamp) stay
invalid/ignored, eliminating leaky dark blotches
from buried probes.
Collapse buried/invalid probes to a point in ddgi_debugVS so they are not rasterized. Declutters the debug view and skips their overdraw. Toggle via the DDGI_DEBUG_SHOW_INVALID constant to inspect all probes.
Invalid (buried) probes are down-weighted to near-zero when sampled, yet their noisy backface shading inflates the variance-driven ray budget. Cap them at a fixed 32-ray floor instead. Enough to keep re-measuring the backface ratio and relocating (so exposed probes recover to full budget next frame), but far below the up-to-512 they could otherwise consume.
…nalysis
Buried probe detection to handle edge cases: probe
clipping planes, large boxes, and flickering at
boundaries.
Key changes:
- Hysteretic enclosure: enter buried at >=87.5%
backfaces, exit at <25% backfaces or >=25%
escapes, hold previous state in-between. Stops
boundary flickering (ray counts jitter
frame-to-frame).
- Escape-count analysis: a ray "escapes" only
when it's a miss or hits a far frontface
(!is_backface && hit_dist >= max_range). A far
backface is NOT an escape (probe still behind
it). This separates a large solid box (few
escapes, far walls are backfaces) from a large
open room (many escapes, far walls are
frontfaces), even when a plane clips through
both and confuses the backface ratio.
- Removed unstable backface-exit relocation that
steered buried probes toward their nearest
exit. That oscillated badly at dead ends (e.g.
box resting on ground: probe steered down out
-> sandwiched -> flips valid -> steered back
in -> repeat). Relocation now only uses
surface_push (push away from any near
surface), flag-independent and stable.
- Debug viz hides probes by luminance in
addition to the valid flag: a probe sealed in
a solid receives no light -> converges to
black -> stable and visible independent of
geometry. Catches plane-clip-through interior
probes whose backface ratio was ambiguous.
- Ray budget floor (32 rays) for invalid probes
keeps re-measuring the backface ratio and
relocation so exposed probes recover to full
budget.
Probes at enclosure boundaries jitter between
valid/invalid each frame because the per-frame
decision (~32 re-randomized rays) is noisy. The
decision would cross thresholds unpredictably when
a probe sat right at the edge (e.g. inside a box
with a plane protruding, allowing variable
escapes).
Add temporal smoothing via an EMA of enclosure
confidence stored per probe:
- DDGIProbe now holds enclosure_confidence [0,1]
(repurposed the padding field, same size, no
serialization change)
- Per-frame verdict (enter_buried/clearly_open)
feeds an EMA with 0.1 blend factor. Fresh
probes classify immediately on their full ray
budget.
- VALID flag derived via dead-band: buried if
conf>0.6, open if <0.4, else hold previous
state. This prevents crossing back and forth
at the midpoint.
- Relaxed escape thresholds (boxed-in now <25%,
open now >=40%) so a box with a grazing-escape
plane still reads buried while open water
stays valid.
Result: a boundary probe settles to a stable
confidence and state rather than blinking. EMA
rate (0.1) and dead-band (0.4/0.6) are tunable.
Extend the camera-centered DDGI grid to multiple
concentric cascades (currently DDGI_CASCADE_COUNT
= 2). Cascade 0 is the fine near-camera grid; each
higher cascade keeps the same probe dimensions but
doubles cell spacing and coverage, so probe
density is high near the camera and falls off with
distance while total covered area grows
exponentially with cascade count for only linear
probe cost.
Design: unified probe index space. All cascades
share the probe/variance/ray/ raycount/rayalloc
buffers (concatenated by probe_offset) and one
depth atlas (cascades stacked vertically at
depth_atlas_offset). Every compute pass and the
debug draw dispatch over total_probe_count; each
thread decodes its cascade from the global probe
index via ddgi_decode_probe(). All shader helpers
are now parameterized by cascade index.
Sampling: ddgi_sample_irradiance picks the finest
cascade containing the point and blends into the
next coarser one over the outer 15% of its volume,
so the density transition is seamless. Sampling
touches at most two cascades regardless of cascade
count, so shading cost is independent of
CASCADE_COUNT.
Per-cascade teleport reset: frame_index is shared
across cascades, so a mid-run teleport of one
cascade sets a per-cascade `reset` flag instead;
ddgi_scrollCS marks every probe in a reset cascade
FRESH (otherwise only the scrolled-in slab). The
scroll pass runs when any cascade scrolled or was
reset.
- ShaderInterop_Renderer.h: DDGI holds Cascade
cascades[DDGI_CASCADE_COUNT] plus shared fields
(probe_buffer, depth_texture, total_probe_count,
...)
- ShaderInterop_DDGI.h: cascade-parameterized
helpers, ddgi_decode_probe, ddgi_gather_sh
(out-param), cascade-blended
ddgi_sample_irradiance
- wiScene.{h,cpp}: DDGI::Cascade +
Compute_Cascade_Parameters /
Get_Total_Probe_Count; total-probe buffer
allocation; stacked depth atlas; per-cascade
grid snapping; per-cascade shader upload
- wiScene_Serializers.cpp: serialize cascade 0
config; size-guard probe/depth blobs against the
new layout
- ddgi_scrollCS/rayallocationCS/raytraceCS/updateCS/debugVS:
decode cascade from global probe index
- wiRenderer.cpp: dispatch over total_probe_count;
scroll when any cascade scrolled/reset;
debug-draw all cascades
Note: two DXC (spirv) crash workarounds in the
sampler - an ascending unrolled cascade-selection
loop (a descending one crashes), and an
unconditional second cascade gather (a
[branch]-guarded call with out params crashes).
The unused coarse gather is zeroed via its blend
weight.
Move the DDGI constant-buffer struct out of ShaderInterop_Renderer.h into its own file, matching how ShaderTerrain / ShaderVoxelGrid are organized, and follow the engine's ShaderInterop_*.h (shared data) + *HF.hlsli (GPU helper functions) convention. - ShaderInterop_DDGI.h becomes a leaf data header: constants, DDGI_CASCADE_COUNT, ShaderDDGI (+ Cascade), push constants and packed ray/variance records. It no longer includes ShaderInterop_Renderer.h or references GetScene(). - ShaderInterop_Renderer.h includes the data header and holds `ShaderDDGI ddgi;` instead of a large inline struct. - New ddgiHF.hlsli holds every GetScene()-based helper (probe addressing, ddgi_sample_irradiance, MultiscaleMeanEstimator, depth-border table). - DDGIProbe and its flags stay in ShaderInterop_Renderer.h: they depend on the SH namespace defined there, so moving them would create a circular include. - Shaders that call the helpers include ddgiHF.hlsli; objectHF and ddgi_indirectprepareCS use only the data structs and keep ShaderInterop_DDGI.h.
Cull DDGI debug probe spheres that can't be seen, reducing debug-view overdraw (which grows with cascade count). Frustum culling (on by default) collapses a probe's sphere to a point when it lies entirely outside the camera frustum; an optional DDGI_DEBUG_MAX_DISTANCE hides probes beyond a world-space distance. Both reuse the existing radius=0 hide mechanism and are visualization-only - they do not affect the GI.
DDGI debug probes were drawn as a 2880-vertex uvsphere each; that is a huge amount of debug geometry that tanks FPS when the overlay is on. Add ddgi_debug_icosphere.hlsli - a 60-vertex (20-face icosahedron) unit sphere, winding matched to uvsphere/icosphere so it front-faces under RSTYPE_FRONT. The DDGI debug VS uses it and the draw is DrawInstanced(60, ...), a 48x reduction. Kept in its own DDGI-scoped header (array DDGI_DEBUG_ICOSPHERE) rather than the shared icosphere.hlsli (240 verts, used by the sky and volumetric-light shaders) so the probe gizmo can be tuned without touching those. Visualization only; does not affect GI.
Bump DDGI_CASCADE_COUNT to 6 (coverage now ~±2km at the coarsest level) and add a staggered update schedule so the extra cascades stay affordable. Each cascade gets an `active` flag: cascade 0 refreshes every frame; cascades 1..N-1 round-robin one per frame (c == 1 + frame_index % (N-1)); frame 0 refreshes all so the field converges at once. Per-frame trace/update cost is therefore ~constant (cascade 0 + one coarse) regardless of cascade count, and spike-free (unlike a 2^c schedule which periodically aligns all cascades on one frame). An inactive cascade is frozen: the CPU snapping loop skips it (grid does not scroll), ddgi_rayallocationCS allocates it 0 rays, and ddgi_updateCS (color and depth) early-returns - so its probes simply hold their last result. Raytracing is indirect off the ray allocation, so it skips inactive cascades automatically. Note: buffers are still sized for all probes at DDGI_MAX_RAYCOUNT, so 6 cascades at 32x8x32 use ~600MB VRAM (ray buffer ~384MB). Staggering reduces per-frame compute, not allocation.
…ount The ray and ray-allocation buffers were sized for every probe of every cascade at DDGI_MAX_RAYCOUNT (~480MB at 6 cascades of 32x8x32), even though they are transient (rays are traced and integrated within one frame) and the staggered schedule only refreshes cascade 0 + one coarse cascade per frame. Size them for the per-frame refresh worst case instead (Get_Ray_Buffer_Capacity = min(2, CASCADE_COUNT) x probes_per_cascade x DDGI_MAX_RAYCOUNT) and compact rays contiguously: the allocation pass reserves each probe's range atomically, clamps to capacity (bucket alignment preserved; overflow is impossible by schedule design, the clamp is a safety net), and records the base offset in a new per-probe raybase_buffer. The raytrace writes each ray to its compacted slot (the allocation record index), and the update passes read rays at raybase + i. Indirect prepare clamps the dispatch the same way. Ray memory at 6 cascades: 384+96MB -> 128+32MB (~320MB saved, DDGI total ~595MB -> ~275MB), and it is now independent of DDGI_CASCADE_COUNT. Frame 0 consequence: all cascades can no longer refresh at once (they would not fit), so frame 0 refreshes the normal schedule while still snapping every cascade's grid. A new DDGIPROBE_FLAG_INITIALIZED (set after a probe's first depth-pass update; absent in the zeroed creation state) makes never-refreshed probes take the full fresh path on their first round-robin activation, so each coarse cascade converges in one cycle (~CASCADE_COUNT-1 frames) after creation; distant GI is briefly dark during initial convergence.
ddgi_sample_irradiance always gathered two cascades (fine + coarse) because a conditional gather crashed DXC, so it paid ~2x the probe-cage cost across the whole screen even where the coarse cascade contributes nothing (blend == 0). Gate the coarse gather via a data-dependent loop bound instead of a branch: ddgi_gather_sh takes a `bool enabled` and runs its 8-probe cage loop for (enabled ? 8 : 0) iterations, so a disabled gather does no probe-buffer reads or depth-atlas samples. The out params are still written unconditionally at the end (Zero) - the crash is triggered by ANY conditional control flow tied to the SH out param (conditional call, early return, or by-value return called twice), but a varying loop trip count compiles. The sampler enables the fine gather always and the coarse gather only near a cascade's outer edge (blend > 0 and a distinct cascade), so a wave deep inside a cascade now pays for a single gather. Visualization/behaviour unchanged once converged; ~2x cheaper GI sampling over the interior of each cascade, still blending across cascade boundaries.
Coarse cascades refresh once every CASCADE_COUNT-1
frames (staggered updates) but used the same
per-update temporal blend as cascade 0, so they
converged that many times slower in wall-clock
time. This showed as lighting that lags/darkens
while moving and a jump at cascade boundaries
(worst at cascade 2<->3).
Add a per-cascade blend_scale = update period (1
for cascade 0, CASCADE_COUNT-1 for the round-robin
cascades), stored in ShaderDDGI::Cascade and
uploaded with the other cascade parameters. The
update pass scales its temporal blend factors by
pow(period, strength), with separate strengths for
colour and depth (shader constants in
ddgi_updateCS.hlsl, tunable with a shader-only
recompile):
- DDGI_BLEND_COMPENSATION_COLOR (0.5): kept
gentle - pushing it harder lets more
per-update radiance noise through on the
coarse cascades = shimmer/flicker.
- DDGI_BLEND_COMPENSATION_DEPTH (1.0): full -
depth/visibility moments are smooth so faster
convergence does not shimmer, and it speeds
the Chebyshev visibility test's recovery when
surfaces move. Fresh probes still bypass blending,
so only steady-state convergence is affected.
Fixes the cascade-boundary jump and the shimmer;
reduces (but does not eliminate) the transient
darkening on moving surfaces - the residual is a
separate probe relocation/validity issue.
Probe relocation pushed the probe away from every nearby surface, front or back. For a probe sitting just inside a face - e.g. the one a moving box leaves behind on its trailing side - the nearest surface is that face, hit as a backface, so "push away" drove the probe deeper into the solid. It stayed enclosed, was down-weighted to ~0 when sampled, and the trailing face went black. The leading face never showed this because an external probe ahead of it is pinned against it by the same push. Make the push direction depend on which side of the surface the probe is on: keep pushing away from front faces (hold keep-distance on the correct side), but push toward back faces so the probe exits through the nearest face. Once it pokes through it reads as a front face and the away-push stabilizes it at keep-distance outside. Deep inside a solid the opposite backfaces cancel, so genuinely buried probes are not yanked toward an arbitrary face, and the offset is still clamped to half a cell.
…eighbors Two relocation follow-ups to the backface-ejection fix, both in the probe update push field, plus the flag they share. Squeeze flicker: a probe pinched between two nearby FRONT faces (a thin gap) gets opposing pushes that cancel to a small, noisy net vector, so the re-randomized rays swing its sign each frame and the probe oscillates between the faces. Track the summed push magnitude alongside the vector push and scale the push by its coherence (net length / summed magnitude): ~1 when a single surface dominates (ejection and keep-distance unaffected), ~0 when surfaces oppose, so a pinched probe settles at the balance point instead of chasing an unreachable keep-distance from both. This also damps deep-buried jitter (opposite backfaces cancel). Black patch on ejection: a buried probe has no valid colour - integrating its backface-only rays drives it to black. When such a probe was ejected into open space its whole cage could be black and the revealed surface showed a black patch until it slowly reconverged. Give buried probes a real colour source instead: - While invalid, a probe copies the radiance of its nearest valid neighbour (6 straight then 12 diagonal), so the region a solid occupies shows surrounding GI rather than black, both while buried and during ejection. Deep-interior probes with no valid neighbour are left unchanged (not meaningfully sampled). - On the invalid->valid transition the depth pass sets a one-shot DDGIPROBE_FLAG_COLOR_RESET (distinct from FRESH so it does NOT reset the offset and undo the ejection). The ray allocation pass then hands the probe the full ray budget and the colour pass takes that result directly as its mean, so it converges from its new open position in one step instead of trickling up from black.
The ray allocation, update, and update-depth passes dispatched one group per probe over every cascade (total_probe_count, ~49k at 6 cascades) and had inactive cascades early-out. With staggered updates only two cascades run per frame, so ~2/3 of those group launches did nothing but return - millions of update-depth threads (256 per group) launched to immediately exit. Dispatch only the active cascades instead. wiRenderer loops the cascades and, for each active one, dispatches its probe_count groups starting at its probe_offset, passed via a new DDGIPushConstants::probeIndexOffset; the shaders compute probeIndex = SV_GroupID.x + probeIndexOffset and the active early-outs are removed. This scales with cascade count - adding cascades no longer adds launches to these passes. The compacted-ray InterlockedAdd counter is cleared once per frame and accumulates across the two active-cascade dispatches (disjoint, capacity-clamped ranges), so no barrier between them is needed. The per-probe raycount/raybase buffers were previously zeroed for inactive probes by the removed early-out, so they are now host-cleared every frame before ray allocation. Raytrace is unchanged (indirect, reads the probe index from the allocation records); the scroll pass and debug draw still cover all probes.
The DDGI probe buffer and depth atlas were serialized as raw GPU dumps, but the runtime rebuilds them zero-initialized whenever a serialized scene loads (the transient ray_buffer is never serialized, so Scene::Update recreates every DDGI resource and resets frame_index). The blobs were always discarded on load yet bloated every scene file. Read now consumes the blob vectors for stream compatibility and discards them instead of creating GPU resources; write emits empty vectors instead of dumping the buffers. The scalar fields are unchanged, so the stream layout stays stable and old/new scene files interoperate both ways.
A probe with open space in every direction (no geometry within max_distance) sees only the smooth sky and lights no surface within range, yet per-frame sun/sky sampling noise could inflate its variance-driven ray budget - the same failure mode the buried-probe cap already handles. The depth pass flags such probes (DDGIPROBE_FLAG_OPEN, set when every ray escapes) and ray allocation caps them to 32 rays (min, so a quiet converged sky keeps its lower variance-driven value). Visually free: a fully-open probe is never in a shaded point's interpolation cage.
Follow-ups to the neighbor-seed black-face fix, all targeting the transient brightening on a moving object's newly-revealed faces. They reduce (do not eliminate) it - the residual is inherent to DDGI's world-static probe grid vs. moving geometry. - Seed direction: a buried probe now seeds its color from the neighbor in its ejection (relocation-offset) direction - the probe directly out from the face it is escaping - and skips diagonal (edge/corner) neighbors, instead of taking the first valid neighbor, which near an object's edge grabbed a much brighter probe sitting past the edge in open space. - Trust gate: a probe that was just ejected (COLOR_RESET pending) is still carrying its provisional borrowed color, so it stays down-weighted like a buried probe when sampled until its own rays land, instead of being trusted at full weight. - Ejected-neighbor propagation: a new one-frame DDGIPROBE_FLAG_EJECTED marks a probe that just transitioned buried->valid, and a valid probe bordering such a marker also takes the fast color-reset path - the exterior probes lighting the object's newly-covered faces would otherwise lag through the noise-damped estimator. Gated on ejection events, which only occur where a solid is moving, so static geometry is never affected.
Coarser cascades cover distant, low-frequency areas and refresh only once every CASCADE_COUNT-1 frames, yet each probe could still allocate the full DDGI_MAX_RAYCOUNT on a fresh/scroll frame, and the transient ray buffers were sized for two cascades at that full budget. Add a shared ddgi_cascade_ray_budget(cascade) = max(DDGI_MAX_RAYCOUNT >> cascade, DDGI_MIN_RAYCOUNT): cascade 0 keeps the full 512, each coarser cascade halves down to a 64-ray floor. The ray allocation pass caps the variance-driven and fresh-probe budgets by it, and Get_Ray_Buffer_Capacity sizes for the heaviest single-frame pairing (cascade 0 + cascade 1) instead of two full-budget cascades - shrinking the ray and allocation buffers ~25% and trimming raytrace cost on scroll/teleport frames. Cascade 0 (what the viewer sees most) is unchanged, and coarse cascades are distant and blur-blended, so quality is unaffected. Every cap is a power of two >= the floor, so all budgets, the capacity, and every compacted ray base stay DDGI_RAY_BUCKET_COUNT-aligned.
|
Didn't spend a lot of time to check yet, but few things I noticed:
I'd like DDGI to remain a more light-weight solution for fallback effects and especially useable as completely baked. I think instead of cascades it should be supplemented with a good screen space probe GI. SSGI rewrite for probe-based one is planned, however there could be a version of that with screen-probes + raytrace. |
|
It's not slower on my system. Reducing the number of cascades would probably help, 6 cascades create a very large number of probes, enough to cover massive worlds. Using 3 or 4 cascades instead would likely provide a significant performance improvement. You're also right that baking wouldn't work with the current cascade implementation, since the probe density is tied to the camera position. My goal was to move toward something similar to [UE 5.8](https://www.youtube.com/watch?v=pJhWyxVBMX8): a fully dynamic solution. However, my implementation isn't complete yet, and I think there's still plenty of room for improvement. From what I can tell, Unreal uses clipmaps rather than cascades, and it only places or activates probes around nearby geometry, which greatly improves performance. If the goal is baked DDGI, I think a better approach would be to let users define DDGI volumes and configure the DDGI settings independently for each volume. That would allow, for example, placing high-density volumes in interior rooms while using different settings elsewhere. The current approach of fitting a single DDGI volume to the entire scene AABB has a few drawbacks:
|
Summary
Reworks DDGI from a single grid fitted to the scene AABB into a camera-centered, toroidally-scrolling, N-cascade clipmap. This decouples probe density (fine, near the camera) from coverage (extended outward by cascades), so GI works in both small scenes and large multi-km levels without the old failure modes: coarse probe density in big scenes, probes drifting through geometry when the scene bounds changed.
On top of the new grid it adds a probe validity/relocation system (so probes cope with enclosed and moving geometry), and a set of performance/scalability changes that keep 6 cascades affordable. The whole DDGI update chain already runs on the async compute queue.
1. Grid architecture
probe_offset); sampling picks the finest cascade containing the point and blends into the next coarser one over the outer edge for a seamless density transition. Per-cascade teleport reset (each cascade has its own reset flag) avoids wiping other cascades' history.ShaderInterop_DDGI.his now a leaf data header;GetScene()-based helpers moved to a newddgiHF.hlsli. Debug rendering uses a dedicated DDGI-only low-poly icosphere header instead of the shared one.2. Probe validity & relocation
3. Performance & scalability
DDGI_CASCADE_COUNT.4. Convergence quality
5. Cleanup
Known limitation
Fast-moving occluders still show a brief GI transient on their newly-revealed faces.
Only tested on Linux