Radiance Cascades

Draw light with the pointer and watch it bounce: a jump-flooded distance field feeds six radiance cascades — base 4, geometric intervals, linear RGBA16F — merged top-down with visibility alpha into 2D global illumination.

Open fullscreen
import { rc_atlas_texel, rc_block_size, rc_ray_count } from "./rc-directions.wgsl";
import { distance_ramp, grid_albedo, linear_to_srgb, tonemap_aces } from "./scene-grid.wgsl";
 
// The only pass that leaves linear radiance. It resolves cascade 0 into irradiance, lights
// the grid with it, adds the emitters' own glow, and encodes once — tonemap and sRGB happen
// here and nowhere else, because merging in sRGB is what produces ringing and halos.
//
// `view` also makes the debug extraction visible: every option shows a real render target,
// not a re-derived approximation.
 
struct Present {
  /** Surface size in pixels. */
  size: vec2f,
  /** Cascade atlas size in texels. */
  atlas_size: vec2f,
  exposure: f32,
  /** 0 final, 1 emitters, 2 sdf, 3 cascade atlas. */
  view: f32,
  /** Distance, in pixels, at which the SDF debug ramp completes one band. */
  sdf_period: f32,
  /** Grid cell size in pixels. */
  grid_cell: f32,
  grid_line_width: f32,
  grid_base: f32,
  grid_line: f32,
  /** Light that never came from a probe: keeps unlit corners from being pure black. */
  ambient: f32,
};
 
@group(0) @binding(0) var<uniform> present: Present;
@group(0) @binding(1) var cascade_tex: texture_2d<f32>;
@group(0) @binding(2) var emitter_tex: texture_2d<f32>;
@group(0) @binding(3) var sdf_tex: texture_2d<f32>;
 
/**
 * Irradiance at a pixel: the mean of cascade 0's four rays.
 *
 * Cascade 0 has one probe per pixel, so no spatial interpolation is needed here — the
 * bilinear work all happened during the merges above.
 */
fn resolve_cascade0(pixel: vec2f) -> vec3f {
  let block = rc_block_size(0.0);
  let rays = rc_ray_count(0.0);
  let probe = clamp(floor(pixel), vec2f(0.0), present.atlas_size / block - 1.0);
  var total = vec3f(0.0);
  for (var i = 0.0; i < rays; i = i + 1.0) {
    let coord = rc_atlas_texel(probe, i, block);
    total += textureLoad(cascade_tex, vec2i(coord), 0).rgb;
  }
  return total / rays;
}
 
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  let pixel = uv * present.size;
  let texel = vec2i(clamp(floor(pixel), vec2f(0.0), present.size - 1.0));
  let view = i32(present.view + 0.5);
 
  if (view == 1) {
    let emitter = textureLoad(emitter_tex, texel, 0);
    return vec4f(linear_to_srgb(tonemap_aces(emitter.rgb * present.exposure)), 1.0);
  }
  if (view == 2) {
    let distance_px = textureLoad(sdf_tex, texel, 0).r;
    return vec4f(linear_to_srgb(distance_ramp(distance_px, present.sdf_period)), 1.0);
  }
  if (view == 3) {
    // The raw atlas of whichever cascade the chain stopped at, stretched over the canvas:
    // probe blocks and their direction slots show up as the checker inside each block.
    let coord = vec2i(clamp(uv * present.atlas_size, vec2f(0.0), present.atlas_size - 1.0));
    let radiance = textureLoad(cascade_tex, coord, 0);
    return vec4f(linear_to_srgb(tonemap_aces(radiance.rgb * present.exposure)), 1.0);
  }
 
  let irradiance = resolve_cascade0(pixel);
  let albedo = grid_albedo(pixel, present.grid_cell, present.grid_line_width, present.grid_base, present.grid_line);
  let emitter = textureLoad(emitter_tex, texel, 0);
  // Emitters are opaque: they show their own radiance instead of the light landing on them.
  let lit = mix(albedo * (irradiance + present.ambient), emitter.rgb, clamp(emitter.a, 0.0, 1.0));
  return vec4f(linear_to_srgb(tonemap_aces(lit * present.exposure)), 1.0);
}