패스 트레이싱

Path tracing

픽셀마다 무작위 방향의 광선을 여러 개 쏘아 평균 내는 레이 트레이싱의 확장 — 처음엔 노이즈투성이지만 샘플이 쌓일수록 사실적인 결과로 수렴합니다.

다른 이름: 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);

기본 레이 트레이싱은 "거울처럼 정확히 반사되는 광선" 하나만 쫓아가면 되지만, 실제 세계의 빛은 대부분 거친 표면에서 사방으로 흩어집니다(간접광, 글로벌 일루미네이션). 패스 트레이싱은 각 픽셀에서 광선을 하나가 아니라 무작위 방향으로 수십~수천 개씩 쏘아, 그 경로들이 광원에 도달했는지를 확률적으로 누적해 평균 냅니다.

샘플이 적을 때는 결과가 지글거리는 노이즈로 보입니다 — 무작위 표본이라 아직 평균이 안정되지 않았기 때문입니다. 렌더러가 프레임을 계속 누적할수록(샘플을 더할수록) 노이즈가 가라앉고 부드러운 간접광·연한 그림자·색 번짐(color bleeding) 같은 디테일이 드러납니다. 이 "노이즈 → 수렴"은 패스 트레이싱 렌더러(Cycles, Arnold, Redshift, V-Ray)를 켤 때마다 보이는 익숙한 과정입니다.

노이즈를 줄이는 대신 시간을 버는 대표적 방법이 디노이저(AI 기반 노이즈 제거)이고, 실시간 게임의 레이 트레이싱도 사실 아주 적은 샘플만 쏘고 디노이저에 크게 의존합니다. 데모는 작은 캔버스에 실제로 몇 번의 튕김을 계산하는 미니 패스 트레이서를 돌려, 노이즈가 가라앉았다가 다시 리셋되는 주기를 보여줍니다.

언제 쓰나

최종 렌더에서 사실적인 간접광·부드러운 그림자가 필요할 때. 실시간에는 샘플 수를 줄이고 디노이저에 의존합니다.