Path tracing

패스 트레이싱

An extension of ray tracing that fires many randomly-directed rays per pixel and averages them — noisy at first, converging toward a realistic result as samples accumulate.

Also known as: Monte Carlo path tracingUnbiased rendering
···
html
<canvas id="pt-canvas"></canvas><span class="pt-cap">progressive samples · noisy → converged</span>
css
#pt-canvas{width:82%;height:auto;aspect-ratio:48/32;image-rendering:pixelated;border-radius:4px;box-shadow:0 0 0 1px var(--line)}
.pt-cap{position:absolute;left:0;right:0;bottom:4%;text-align:center;font-size:clamp(9px,2.4vmin,12px);color:var(--muted)}
js
const canvas = document.getElementById('pt-canvas');
const ctx = canvas.getContext('2d');
const W = 48, H = 32;
canvas.width = W; canvas.height = H;
const sum = new Float32Array(W * H * 3);
let samples = 0;
let startedAt = performance.now();
const sphereC = [0, -0.05, -1.6], sphereR = 0.62;
const floorY = -0.62;

function normalize(v) { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
function hitSphere(o, d) {
  const ox = o[0] - sphereC[0], oy = o[1] - sphereC[1], oz = o[2] - sphereC[2];
  const b = 2 * (d[0] * ox + d[1] * oy + d[2] * oz);
  const c = ox * ox + oy * oy + oz * oz - sphereR * sphereR;
  const disc = b * b - 4 * c;
  if (disc < 0) return null;
  const t = (-b - Math.sqrt(disc)) / 2;
  return t > 0.001 ? t : null;
}
function hitFloor(o, d) {
  if (Math.abs(d[1]) < 1e-4) return null;
  const t = (floorY - o[1]) / d[1];
  return t > 0.001 ? t : null;
}
function sky(d) {
  const up = Math.max(0, d[1]);
  const b = 0.15 + up * 0.95;
  return [b * 0.85, b * 0.9, b];
}
function cosineSample(n) {
  const r1 = Math.random(), r2 = Math.random();
  const r = Math.sqrt(r1), theta = 2 * Math.PI * r2;
  const x = r * Math.cos(theta), y = r * Math.sin(theta), z = Math.sqrt(Math.max(0, 1 - r1));
  const a = Math.abs(n[0]) > 0.9 ? [0, 1, 0] : [1, 0, 0];
  const tt = normalize([a[1] * n[2] - a[2] * n[1], a[2] * n[0] - a[0] * n[2], a[0] * n[1] - a[1] * n[0]]);
  const bz = [n[1] * tt[2] - n[2] * tt[1], n[2] * tt[0] - n[0] * tt[2], n[0] * tt[1] - n[1] * tt[0]];
  return [tt[0] * x + bz[0] * y + n[0] * z, tt[1] * x + bz[1] * y + n[1] * z, tt[2] * x + bz[2] * y + n[2] * z];
}
function trace(o, d) {
  const ts = hitSphere(o, d), tf = hitFloor(o, d);
  let t = null, isSphere = false;
  if (ts !== null && (tf === null || ts < tf)) { t = ts; isSphere = true; }
  else if (tf !== null) { t = tf; isSphere = false; }
  if (t === null) return sky(d);
  const p = [o[0] + d[0] * t, o[1] + d[1] * t, o[2] + d[2] * t];
  let n, albedo;
  if (isSphere) { n = normalize([p[0] - sphereC[0], p[1] - sphereC[1], p[2] - sphereC[2]]); albedo = [0.85, 0.55, 0.35]; }
  else { n = [0, 1, 0]; albedo = [0.72, 0.72, 0.78]; }
  const bounceDir = cosineSample(n);
  const bo = [p[0] + n[0] * 0.001, p[1] + n[1] * 0.001, p[2] + n[2] * 0.001];
  const t2s = hitSphere(bo, bounceDir), t2f = hitFloor(bo, bounceDir);
  const light = (t2s !== null || t2f !== null) ? [0.06, 0.06, 0.07] : sky(bounceDir);
  return [albedo[0] * light[0], albedo[1] * light[1], albedo[2] * light[2]];
}
function render(now) {
  if (now - startedAt > 4200) { sum.fill(0); samples = 0; startedAt = now; }
  samples++;
  const aspect = W / H;
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const px = ((x + Math.random()) / W) * 2 - 1;
      const py = 1 - ((y + Math.random()) / H) * 2;
      const dir = normalize([px * aspect * 0.62, py * 0.62, -1]);
      const c = trace([0, 0.15, 0.9], dir);
      const i = (y * W + x) * 3;
      sum[i] += c[0]; sum[i + 1] += c[1]; sum[i + 2] += c[2];
    }
  }
  const img = ctx.createImageData(W, H);
  for (let p = 0; p < W * H; p++) {
    const i = p * 3;
    img.data[p * 4] = Math.min(255, (sum[i] / samples) * 255 * 1.15);
    img.data[p * 4 + 1] = Math.min(255, (sum[i + 1] / samples) * 255 * 1.15);
    img.data[p * 4 + 2] = Math.min(255, (sum[i + 2] / samples) * 255 * 1.15);
    img.data[p * 4 + 3] = 255;
  }
  ctx.putImageData(img, 0, 0);
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

Plain ray tracing only needs to follow one mirror-perfect reflected ray, but most light in the real world scatters in every direction off rough surfaces (indirect light, global illumination). Path tracing fires not one ray per pixel but dozens to thousands, in random directions, and stochastically accumulates and averages whether those paths eventually reach a light.

With few samples, the result looks like flickering noise — a random sample average hasn’t settled yet. As the renderer keeps accumulating more frames, more samples, the noise dies down and reveals soft indirect light, gentle shadows, and color bleeding. This “noise → converge” cycle is the familiar sight every time a path-tracing renderer — Cycles, Arnold, Redshift, V-Ray — starts up.

The common way to buy back time instead of just adding samples is an AI denoiser, and even real-time ray tracing in games fires very few samples per pixel and leans heavily on one. The demo runs an actual tiny path tracer on a small canvas, computing a few real bounces, and loops through noise settling down and then resetting.

When to use

Use it for a final render that needs realistic indirect light and soft shadows. Real-time use cuts the sample count way down and leans on a denoiser instead.