Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

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()
fieldeffect
intensityHow much of the cached bounce reaches the image. 0 turns it off and releases the passes.
radiusHow far a patch gathers, in world units — the scale of bounce supplied.
samplesGather taps a patch takes on the frame it is visited.
strideOne 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.
cellSizeSize of a cached patch near the camera, in world units. Smaller resolves finer bounce and needs more of the table.
historyThe most gathers a patch averages over. Higher converges smoother and follows an unannounced lighting change more slowly.
transferHow 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