Builtin Library
readme.md
radianceCache system
Lighting results that survive the frame that produced them.
A lighting technique that gathers per pixel throws its answer away when the frame ends and pays for it again the next one — the same wall gathered by a thousand pixels is a thousand gathers, and a thousand more next frame even if nothing in the scene moved. A cache turns that into one gather per patch of world, reused by every later frame and by every pixel standing on that patch. That is what lets a technique spread its cost over frames instead of paying it all inside one.
The cache is a fixed table addressed by hashing a world-space cell, so it covers
an unbounded world in bounded memory: capacity slots at 68 bytes each and
nothing about the size of the scene changes that. Cells coarsen with distance
from the camera, and a slot no pixel has asked for in maxAge frames is
reclaimed for one that is being looked at, so what the table holds tracks the
view rather than accumulating everything ever seen.
Using the effect
CachedIndirect is the shipped user of the cache: indirect diffuse light,
gathered once per patch and reused.
local id = entity.spawn("cached_indirect").id
entity(id).component.add("CachedIndirect", {
intensity = 1.0, -- how much of the cached bounce reaches the image
radius = 2.0, -- how far a patch gathers, in WORLD units
samples = 12, -- gather taps a patch takes when it is visited
stride = 8, -- one patch in this many is gathered per frame
})
Every field is live. Setting intensity to 0 turns it off without losing the
rest. For runtime control, drive the module:
local cachedIndirect = require("@builtin::systems.radianceCache.cachedIndirect")
cachedIndirect.set({ intensity = 1.0, stride = 8 })
cachedIndirect.invalidate() -- after moving or recolouring a light
cachedIndirect.cache():stats() -- what the cache did last frame
cachedIndirect.cache():memoryBytes() -- what it costs
cachedIndirect.clear()
| field | effect |
|---|---|
intensity | How much of the cached bounce reaches the image. 0 turns it off and releases the passes. |
radius | How far a patch gathers, in world units — the scale of bounce supplied. |
samples | Gather taps a patch takes on the frame it is visited. |
stride | One patch in this many is gathered per frame. This is the amortisation: raising it spreads the same work over more frames, and the converged image is the same. |
cellSize | Size of a cached patch near the camera, in world units. Smaller resolves finer bounce and needs more of the table. |
history | The most gathers a patch averages over. Higher converges smoother and follows an unannounced lighting change more slowly. |
transfer | How much of the averaged incoming radiance is admitted, in [0, 1]. |
Using the cache from another technique
The cache is a primitive any technique can drive. A technique that computes radiance some other way — from rays, from probes, from a simulation — keeps the addressing, the eviction and the temporal accumulation and supplies only the gather:
local radianceCache = require("@builtin::systems.radianceCache.radianceCache")
local cache = radianceCache.create("mine", { capacity = 65536, stride = 8 })
-- in the feature's render(ctx):
cache:allocate(ctx, { phase = "afterLighting", order = 80 })
ctx.enqueue { -- your own producer
kind = "compute", program = myGather,
buffers = cache:bindings(),
dispatch = { x = math.ceil(cache:get().capacity / 64), y = 1, z = 1 },
phase = "afterLighting", order = 81,
}
local gi = cache:resolve(ctx, { phase = "afterLighting", order = 82 })
The producing shader includes the addressing:
#include "@builtin::systems.radianceCache.radiance_cache"
which brings in the cell/level/face/hash functions, the slot layout constants
and rc_lookup. bindings() supplies rc_params, rc_cells, rc_keys and
rc_stats under the identifiers the shaders declare.
How it renders
cachedIndirect.renderFeature runs four passes after lighting.
allocate walks the frame at a two-pixel stride, works out which patch each pixel stands on, and either stamps a patch already in the table with this frame or claims a slot for it. The pixel that stamped a patch is recorded with it.
gather runs over cache slots, one thread each. A slot whose index falls on
this frame's residue takes samples taps around its recorded pixel — the same
form-factor sum a screen-space bounce uses, weighted by how far the receiver
turns toward each tap and how far the tap turns back — and averages the result
into what the slot already holds. The sample pattern turns with the frame, so
successive visits cover the hemisphere instead of re-measuring one set of
directions. A slot nothing has asked for in maxAge frames is released here.
resolve reads the cache back per pixel, interpolating over the eight cells surrounding the shaded point and rejecting the ones sitting on another surface.
apply multiplies by the receiver's own albedo and adds.
What the cache changes about the cost
A per-pixel gather does pixels * samples taps every frame. This does
capacity / stride * samples, plus a lookup per pixel that gathers nothing. At
1280x720 with 12 taps that is 11M taps a frame against 98k at the defaults — the
gather stops scaling with resolution and starts scaling with how much world is
in view.
The result also converges rather than being redrawn: a patch averages over up to
history gathers taken on different frames with different sample directions, so
the cached bounce is smoother than any single frame's gather and holds still
without a history buffer or motion-vector reprojection.
Following a change in the lighting
A converged patch still moves by 1/history of the difference on every visit,
so light that changed with nothing said about it is followed within
history * stride frames. invalidate() says it outright: every patch takes
its next gather whole instead of averaging it into light that no longer exists,
and the cache is rebuilt within stride frames.
What the cache holds
The gather is screen-space, so a patch is filled only while a pixel can see it. A patch that leaves the frame keeps the light it had until it either comes back or ages out — which is what makes the cache useful across a moving camera, and also means light that changed off screen is not noticed until the patch is looked at again.
@scene.normal and @scene.material are the deferred G-buffer, so this needs the
deferred path (the default).
Files
radianceCache.package/
radianceCache.module/ -- the cache primitive: create / run / bindings / stats
radiance_cache.shaderModule/ -- the addressing and the lookup, in WGSL
rc_alloc.computeShader/ -- claims a slot per patch in view
rc_gather.computeShader/ -- fills this frame's share of the table
rc_resolve.computeShader/ -- reads the cache back out per pixel
rc_apply.computeShader/ -- composites against albedo
CachedIndirect.component/ -- the scene's cached bounce, authored and persisted
cachedIndirect.module/ -- .set / .get / .clear / .active / .invalidate / .cache
cachedIndirect.renderFeature/-- the four passes