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
// Scene distance functions and the sphere tracer every cascade ray runs.
//
// Textures and samplers travel as function parameters instead of `@group` variables, so
// this stays a pure WGSL module (importable, and runnable from the math harness against a
// CPU reference) while still doing real texture work.
 
/** Epsilon that counts as a hit, in scene pixels. Half a texel: below the SDF's own resolution. */
export const SDF_HIT_EPSILON: f32 = 0.5;
/** Floor on the marching step so a ray that grazes a surface still terminates. */
export const SDF_MIN_STEP: f32 = 0.35;
/** Sphere-tracing budget per ray. Distance fields converge fast; 16 is plenty at 4x intervals. */
export const SDF_MAX_STEPS: i32 = 16;
 
/** Unsigned distance from `p` to the segment `a`-`b`. */
export fn sdf_segment(p: vec2f, a: vec2f, b: vec2f) -> f32 {
  let pa = p - a;
  let ba = b - a;
  let h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
  return length(pa - ba * h);
}
 
/** Signed distance to the triangle `p0,p1,p2` (negative inside). */
export fn sdf_triangle(p: vec2f, p0: vec2f, p1: vec2f, p2: vec2f) -> f32 {
  let e0 = p1 - p0;
  let e1 = p2 - p1;
  let e2 = p0 - p2;
  let v0 = p - p0;
  let v1 = p - p1;
  let v2 = p - p2;
  let pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0);
  let pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0);
  let pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0);
  let s = sign(e0.x * e2.y - e0.y * e2.x);
  let d = min(
    min(
      vec2f(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x)),
      vec2f(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x)),
    ),
    vec2f(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x)),
  );
  return -sqrt(d.x) * sign(d.y);
}
 
/** Half-texel-clamped UV for a point given in pixels. */
export fn sdf_pixel_uv(pixel: vec2f, size: vec2f) -> vec2f {
  let half_texel = 0.5 / size;
  return clamp(pixel / size, half_texel, vec2f(1.0) - half_texel);
}
 
/**
 * Bilinear read of the distance field, in pixels.
 *
 * `scale` decodes the storage format: the live pipeline keeps pixels in an rgba16float
 * target and passes 1, while the harness encodes distance into rgba8unorm and passes the
 * matching range so the same code can be diffed against the CPU.
 */
export fn sdf_sample(tex: texture_2d<f32>, samp: sampler, pixel: vec2f, size: vec2f, scale: f32) -> f32 {
  return textureSampleLevel(tex, samp, sdf_pixel_uv(pixel, size), 0.0).r * scale;
}
 
/**
 * Marches `[t_start, t_end]` of one ray through the distance field.
 *
 * Returns `vec4f(radiance, visibility)`: on a hit, the emitter's linear radiance with
 * visibility 0 — the merge below will not add anything behind it; on an escape, black with
 * visibility 1, so the next cascade's longer interval continues the same ray.
 */
export fn sphere_trace(
  sdf_tex: texture_2d<f32>,
  sdf_samp: sampler,
  emitter_tex: texture_2d<f32>,
  emitter_samp: sampler,
  size: vec2f,
  origin: vec2f,
  direction: vec2f,
  t_start: f32,
  t_end: f32,
  sdf_scale: f32,
) -> vec4f {
  var t = t_start;
  for (var step = 0; step < SDF_MAX_STEPS; step = step + 1) {
    let p = origin + direction * t;
    // Off-screen is empty space: nothing to hit, and the field outside is extrapolated.
    if (p.x < -1.0 || p.y < -1.0 || p.x > size.x + 1.0 || p.y > size.y + 1.0) { break; }
    let d = sdf_sample(sdf_tex, sdf_samp, p, size, sdf_scale);
    if (d <= SDF_HIT_EPSILON) {
      let emitter = textureSampleLevel(emitter_tex, emitter_samp, sdf_pixel_uv(p, size), 0.0);
      return vec4f(emitter.rgb, 0.0);
    }
    t = t + max(d, SDF_MIN_STEP);
    if (t > t_end) { break; }
  }
  return vec4f(0.0, 0.0, 0.0, 1.0);
}
 
/**
 * Fixed-step reference march — the debug oracle only.
 *
 * Production always uses `sphere_trace`; this exists so the harness can prove the sphere
 * tracer finds the same hit as a brute-force walk, and it is never bound in the pipeline.
 */
export fn fixed_step_trace(
  sdf_tex: texture_2d<f32>,
  sdf_samp: sampler,
  emitter_tex: texture_2d<f32>,
  emitter_samp: sampler,
  size: vec2f,
  origin: vec2f,
  direction: vec2f,
  t_start: f32,
  t_end: f32,
  sdf_scale: f32,
  steps: i32,
) -> vec4f {
  let dt = (t_end - t_start) / f32(steps);
  for (var i = 0; i < steps; i = i + 1) {
    let t = t_start + dt * (f32(i) + 0.5);
    let p = origin + direction * t;
    if (p.x < -1.0 || p.y < -1.0 || p.x > size.x + 1.0 || p.y > size.y + 1.0) { break; }
    if (sdf_sample(sdf_tex, sdf_samp, p, size, sdf_scale) <= SDF_HIT_EPSILON) {
      let emitter = textureSampleLevel(emitter_tex, emitter_samp, sdf_pixel_uv(p, size), 0.0);
      return vec4f(emitter.rgb, 0.0);
    }
  }
  return vec4f(0.0, 0.0, 0.0, 1.0);
}