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
/**
 * CPU mirror of every WGSL helper in this example.
 *
 * The pipeline is six merged cascades on top of a jump-flooded distance field: nothing
 * about it can be judged by looking at the final image, so each piece has a reference here
 * that `math.test.ts` pins down and that `scripts/radiance-cascades-math-harness.mjs`
 * diffs the shaders against, pixel by pixel, inside the deterministic container.
 */
 
/** Branching factor: 4x the rays and 4x the distance on every level. */
export const RC_BASE = 4;
/** Length of cascade 0's ray interval, in scene pixels. */
export const RC_INTERVAL0 = 2;
/** Intervals overlap by 2% so neighbouring levels cannot leave a seam. */
export const RC_OVERLAP = 0.02;
/** The research's quality floor and ceiling for a screen-sized 2D cascade hierarchy. */
export const RC_MIN_CASCADES = 5;
export const RC_MAX_CASCADES = 6;
/** Weight of each child direction when four upper rays fold into one lower ray. */
export const RC_BRANCH_WEIGHT = 0.25;
 
export const SDF_HIT_EPSILON = 0.5;
export const SDF_MIN_STEP = 0.35;
export const SDF_MAX_STEPS = 16;
 
export type Vec2 = readonly [number, number];
export type Vec4 = readonly [number, number, number, number];
 
/** Rays per probe: `4^(c+1)`. */
export function rayCount(cascade: number): number {
  return RC_BASE ** (cascade + 1);
}
 
/** Probe spacing in scene pixels: `2^c`. */
export function probeSpacing(cascade: number): number {
  return 2 ** cascade;
}
 
/** Side of a probe's direction block in the atlas: `2^(c+1)`. */
export function blockSize(cascade: number): number {
  return 2 ** (cascade + 1);
}
 
export function rayDirection(index: number, rays: number): Vec2 {
  const theta = (2 * Math.PI * (index + 0.5)) / rays;
  return [Math.cos(theta), Math.sin(theta)];
}
 
/** Atlas texel -> `[probeX, probeY, directionIndex]`. */
export function atlasDecode(texel: Vec2, block: number): readonly [number, number, number] {
  const probeX = Math.floor(texel[0] / block);
  const probeY = Math.floor(texel[1] / block);
  const slotX = texel[0] - probeX * block;
  const slotY = texel[1] - probeY * block;
  return [probeX, probeY, slotY * block + slotX];
}
 
/** Probe + direction -> atlas texel. */
export function atlasTexel(probe: Vec2, directionIndex: number, block: number): Vec2 {
  return [probe[0] * block + (directionIndex % block), probe[1] * block + Math.floor(directionIndex / block)];
}
 
export function probeOrigin(probe: Vec2, spacing: number): Vec2 {
  return [(probe[0] + 0.5) * spacing, (probe[1] + 0.5) * spacing];
}
 
export function intervalStart(cascade: number, interval0 = RC_INTERVAL0): number {
  return (interval0 * (RC_BASE ** cascade - 1)) / 3;
}
 
export function intervalLength(cascade: number, interval0 = RC_INTERVAL0): number {
  return interval0 * RC_BASE ** cascade;
}
 
export function intervalEnd(cascade: number, interval0 = RC_INTERVAL0, overlap = RC_OVERLAP): number {
  return intervalStart(cascade, interval0) + intervalLength(cascade, interval0) * (1 + overlap);
}
 
/** Distance the whole hierarchy reaches, ignoring the overlap. */
export function coveredDistance(count: number, interval0 = RC_INTERVAL0): number {
  return (interval0 * (RC_BASE ** count - 1)) / 3;
}
 
/**
 * Levels needed to reach `diagonal` pixels, clamped to 5..6.
 *
 * Solving `interval0 * (4^n - 1) / 3 >= diagonal` exactly — instead of the usual
 * `log4(D / interval0)` approximation — is what keeps the last interval from stopping
 * short of the far corner and leaving an unlit border.
 */
export function cascadeCount(diagonal: number, interval0 = RC_INTERVAL0): number {
  const exact = Math.ceil(Math.log(1 + (3 * diagonal) / interval0) / Math.log(4));
  return Math.min(RC_MAX_CASCADES, Math.max(RC_MIN_CASCADES, exact));
}
 
export function cascadeCountForSize(width: number, height: number, interval0 = RC_INTERVAL0): number {
  return cascadeCount(Math.hypot(width, height), interval0);
}
 
/**
 * Atlas dimensions shared by every cascade.
 *
 * The scene is rounded up to a whole number of coarsest probes so that
 * `probes(c) * block(c) = 2 * padded` exactly on every level; without the rounding the
 * per-level `ceil` would give each cascade a slightly different atlas and the two
 * recycled targets could not be shared.
 */
export function atlasSizeFor(width: number, height: number, count: number): { scene: Vec2; atlas: Vec2 } {
  const coarsest = probeSpacing(count - 1);
  const paddedWidth = Math.ceil(Math.max(1, width) / coarsest) * coarsest;
  const paddedHeight = Math.ceil(Math.max(1, height) / coarsest) * coarsest;
  return { scene: [paddedWidth, paddedHeight], atlas: [paddedWidth * 2, paddedHeight * 2] };
}
 
/** Visibility merge, near over far. */
export function mergeRadiance(near: Vec4, far: Vec4): Vec4 {
  return [
    near[0] + near[3] * far[0],
    near[1] + near[3] * far[1],
    near[2] + near[3] * far[2],
    near[3] * far[3],
  ];
}
 
/** Bilinear weights, ordered (0,0) (1,0) (0,1) (1,1); they sum to 1. */
export function bilinearWeights(fx: number, fy: number): Vec4 {
  const x = Math.min(1, Math.max(0, fx));
  const y = Math.min(1, Math.max(0, fy));
  return [(1 - x) * (1 - y), x * (1 - y), (1 - x) * y, x * y];
}
 
export function clampProbe(probe: Vec2, grid: Vec2): Vec2 {
  return [
    Math.min(grid[0] - 1, Math.max(0, probe[0])),
    Math.min(grid[1] - 1, Math.max(0, probe[1])),
  ];
}
 
/**
 * Jump distances of the flood: `size/2, size/4 ... 1`, then two more rounds at 1 (JFA+2).
 *
 * Plain jump flooding is an approximation, and the harness caught it: on an 8x8 scene the
 * far corner ended up 6.40 px from the wrong emitter instead of 6.08 px from the right one,
 * because the correct seed never reached a texel it could be read from at jump 1. Two extra
 * unit rounds — the standard JFA+2 — let the last neighbourhood settle, and cost two
 * fullscreen passes out of thirteen.
 */
export function jfaJumps(size: number): number[] {
  const passes = Math.ceil(Math.log2(Math.max(size, 2)));
  return [...Array.from({ length: passes }, (_, index) => Math.max(1, 2 ** (passes - index - 1))), 1, 1];
}
 
export function jfaPick(current: Vec4, candidate: Vec4, position: Vec2): Vec4 {
  if (candidate[3] < 0.5) return current;
  if (current[3] < 0.5) return candidate;
  const currentDistance = Math.hypot(current[0] - position[0], current[1] - position[1]);
  const candidateDistance = Math.hypot(candidate[0] - position[0], candidate[1] - position[1]);
  return candidateDistance < currentDistance ? candidate : current;
}
 
export function jfaDistance(seed: Vec4, position: Vec2, far: number): number {
  return seed[3] >= 0.5 ? Math.hypot(seed[0] - position[0], seed[1] - position[1]) : far;
}
 
export function segmentDistance(p: Vec2, a: Vec2, b: Vec2): number {
  const pax = p[0] - a[0];
  const pay = p[1] - a[1];
  const bax = b[0] - a[0];
  const bay = b[1] - a[1];
  const h = Math.min(1, Math.max(0, (pax * bax + pay * bay) / Math.max(bax * bax + bay * bay, 1e-6)));
  return Math.hypot(pax - bax * h, pay - bay * h);
}
 
/** Signed distance to a triangle; negative inside. */
export function triangleDistance(p: Vec2, p0: Vec2, p1: Vec2, p2: Vec2): number {
  const edges: Vec2[] = [
    [p1[0] - p0[0], p1[1] - p0[1]],
    [p2[0] - p1[0], p2[1] - p1[1]],
    [p0[0] - p2[0], p0[1] - p2[1]],
  ];
  const vectors: Vec2[] = [
    [p[0] - p0[0], p[1] - p0[1]],
    [p[0] - p1[0], p[1] - p1[1]],
    [p[0] - p2[0], p[1] - p2[1]],
  ];
  const sign = Math.sign(edges[0]![0] * edges[2]![1] - edges[0]![1] * edges[2]![0]);
  let squared = Infinity;
  let winding = Infinity;
  for (let i = 0; i < 3; i++) {
    const e = edges[i]!;
    const v = vectors[i]!;
    const h = Math.min(1, Math.max(0, (v[0] * e[0] + v[1] * e[1]) / (e[0] * e[0] + e[1] * e[1])));
    const qx = v[0] - e[0] * h;
    const qy = v[1] - e[1] * h;
    const candidate = qx * qx + qy * qy;
    const side = sign * (v[0] * e[1] - v[1] * e[0]);
    if (candidate < squared) squared = candidate;
    if (side < winding) winding = side;
  }
  return -Math.sqrt(squared) * Math.sign(winding);
}
 
/** The scene triangle in pixel space; uv.y grows downwards, so the apex is at -y. */
export function trianglePoints(size: Vec2, radius: number): readonly [Vec2, Vec2, Vec2] {
  const centre: Vec2 = [size[0] * 0.5, size[1] * 0.5];
  return [
    [centre[0], centre[1] - radius],
    [centre[0] + radius * 0.8660254, centre[1] + radius * 0.5],
    [centre[0] - radius * 0.8660254, centre[1] + radius * 0.5],
  ];
}
 
export interface TextureView {
  readonly width: number;
  readonly height: number;
  readonly channels: number;
  readonly data: ArrayLike<number>;
}
 
/** Half-texel-clamped UV, matching `sdf_pixel_uv`. */
export function clampUv(pixel: Vec2, size: Vec2): Vec2 {
  return [
    Math.min(1 - 0.5 / size[0], Math.max(0.5 / size[0], pixel[0] / size[0])),
    Math.min(1 - 0.5 / size[1], Math.max(0.5 / size[1], pixel[1] / size[1])),
  ];
}
 
/** Bilinear texture fetch with clamp-to-edge, matching `textureSampleLevel(..., 0)`. */
export function sampleBilinear(texture: TextureView, uv: Vec2): number[] {
  const x = uv[0] * texture.width - 0.5;
  const y = uv[1] * texture.height - 0.5;
  const x0 = Math.floor(x);
  const y0 = Math.floor(y);
  const fx = x - x0;
  const fy = y - y0;
  const weights = bilinearWeights(fx, fy);
  const out = new Array<number>(texture.channels).fill(0);
  const corners: Vec2[] = [[x0, y0], [x0 + 1, y0], [x0, y0 + 1], [x0 + 1, y0 + 1]];
  for (let corner = 0; corner < 4; corner++) {
    const cx = Math.min(texture.width - 1, Math.max(0, corners[corner]![0]));
    const cy = Math.min(texture.height - 1, Math.max(0, corners[corner]![1]));
    const base = (cy * texture.width + cx) * texture.channels;
    for (let channel = 0; channel < texture.channels; channel++) {
      out[channel] += weights[corner]! * texture.data[base + channel]!;
    }
  }
  return out;
}
 
export interface TraceResult {
  readonly radiance: readonly [number, number, number];
  /** 1 when the ray escaped the interval, 0 when it hit an emitter. */
  readonly visibility: number;
  readonly steps: number;
  readonly hitDistance: number;
}
 
/**
 * CPU sphere tracer: the exact loop `sphere_trace` runs, against the same sampled data.
 *
 * `sdf` and `emitter` are read back from the GPU before this runs, so the comparison
 * isolates the marching logic from any difference in how the two sides store the field.
 */
export function sphereTrace(
  sdf: TextureView,
  emitter: TextureView,
  size: Vec2,
  origin: Vec2,
  direction: Vec2,
  tStart: number,
  tEnd: number,
  sdfScale: number,
): TraceResult {
  let t = tStart;
  for (let step = 0; step < SDF_MAX_STEPS; step++) {
    const p: Vec2 = [origin[0] + direction[0] * t, origin[1] + direction[1] * t];
    if (p[0] < -1 || p[1] < -1 || p[0] > size[0] + 1 || p[1] > size[1] + 1) {
      return { radiance: [0, 0, 0], visibility: 1, steps: step, hitDistance: -1 };
    }
    const uv = clampUv(p, size);
    const d = sampleBilinear(sdf, uv)[0]! * sdfScale;
    if (d <= SDF_HIT_EPSILON) {
      const sample = sampleBilinear(emitter, uv);
      return { radiance: [sample[0]!, sample[1]!, sample[2]!], visibility: 0, steps: step, hitDistance: t };
    }
    t += Math.max(d, SDF_MIN_STEP);
    if (t > tEnd) break;
  }
  return { radiance: [0, 0, 0], visibility: 1, steps: SDF_MAX_STEPS, hitDistance: -1 };
}
 
/** Brute-force nearest-emitter distance; the oracle the jump flood must reproduce. */
export function bruteForceDistance(mask: TextureView, position: Vec2, far: number, threshold = 0.5): number {
  let best = far;
  for (let y = 0; y < mask.height; y++) {
    for (let x = 0; x < mask.width; x++) {
      const value = mask.data[(y * mask.width + x) * mask.channels + (mask.channels - 1)]!;
      if (value <= threshold) continue;
      best = Math.min(best, Math.hypot(x + 0.5 - position[0], y + 0.5 - position[1]));
    }
  }
  return best;
}
 
/**
 * Linear radiance of stroke `index`.
 *
 * Deterministic: the palette walks the hue circle by the golden angle, so consecutive
 * strokes are easy to tell apart and a scripted stroke always paints the same colour.
 */
export function strokeRadiance(index: number, intensity = 2.7): readonly [number, number, number] {
  const hue = (index * 0.381966) % 1;
  const channel = (offset: number): number => {
    const t = Math.abs(((hue + offset) % 1) * 6 - 3) - 1;
    const clamped = Math.min(1, Math.max(0, t));
    // Never fully desaturate a channel: a pure primary emitter lights the grid in one hue
    // only, which reads as a bug rather than as coloured light.
    return (0.25 + 0.75 * clamped) * intensity;
  };
  return [channel(0), channel(2 / 3), channel(1 / 3)];
}
 
/** ACES filmic curve; the CPU twin of `tonemap_aces`. */
export function tonemapAces(value: number): number {
  const mapped = (value * (2.51 * value + 0.03)) / (value * (2.43 * value + 0.59) + 0.14);
  return Math.min(1, Math.max(0, mapped));
}
 
export function linearToSrgb(value: number): number {
  return value <= 0.0031308 ? value * 12.92 : 1.055 * Math.max(value, 0) ** (1 / 2.4) - 0.055;
}