Path Tracing — фізично коректний рендеринг

🎨 Комп'ютерна графіка ⏱ ~12 хв читання Складний рівень
Рівняння рендерингу Monte Carlo Importance Sampling WebGL BRDF

Path tracing — алгоритм рендерингу, що моделює фізичний транспорт фотонів. На відміну від rasterization з хаками (shadow maps, ambient occlusion), він автоматично генерує м'які тіні, глобальне освітлення, каустики та кольорове кровотечення — просто з першопринципів рівняння рендерингу. Кожен сучасний фільм рендериться саме так.

1. Рівняння рендерингу Кайдзіями

Кайдзіяма (1986) формалізував транспорт світла у закритому просторі як інтегральне рівняння. Вихідна радіанс L_o у точці x в напрямку ω_o:

L_o(x, ω_o) = L_e(x, ω_o) + ∫_Ω f_r(x, ω_i → ω_o) · L_i(x, ω_i) · (ω_i · n̂) dω_i

де:

L_e — власне випромінення поверхні

f_r — BRDF (двонаправлена функція відбиття)

L_i(x, ω_i) = L_o(x', −ω_i) — вхідна радіанс від видимої точки x'

ω_i · n̂ = cos θ_i — ламбертівський kosine factor

∫_Ω — інтеграл по верхній півсфері

Рівняння рекурсивне: L_i сама залежить від L_o в іншій точці. Розгортання — повний граф транспорту фотонів: L = E + LE + LGE + LGGE + … (E = emission, G = geometric term, L = light transport kernel).

Фізичний зміст радіансу

Radiance L = dΦ / (dA · dω · cos θ) [Вт / (м² · ср)]

Irradiance E = ∫ L·cos θ dω [Вт/м²] (освітленість)

Pixel value ∝ ∫_pixel L(x,y) dA — усереднення по пікселі

2. Monte Carlo інтегрування

Інтеграл ∫_Ω f(ω) dω апроксимуємо через N випадкових зразків:

∫ f(x) dx ≈ (1/N) Σ f(x_i) / p(x_i) — оцінка Монте-Карло

де x_i ~ p(x) — зразки з розподілу p

Незміщена (unbiased): E[estimate] = true integral

Стандартне відхилення: σ ∝ 1/√N → 4× більше зразків = 2× менше шуму

Для path tracing кожен піксель є оцінкою інтеграла освітленості. N зразків = N шляхів (paths) через цей піксель. Шум зменшується як 1/√N — дуже повільно. Вирішення: importance sampling, denoising, advanced sampling.

Дисперсія та вибір N

Сцена Прийнятний N/піксель Час (RTX 4090)
Проста (дифузна) 64–256 ~0.1 с
Складна (скло, метал) 1024–4096 ~1–5 с
Каустики 8192–32768 ~30–120 с
Кіно, архвіз 1024–8192 + denoiser ~1–10 хв

3. BRDF: лямбертівський та GGX матеріали

BRDF f_r(ω_i, ω_o) — функція, що описує як поверхня розсіює світло. Фізичні обмеження: невід'ємна, реципрокна (ω_i ↔ ω_o), енергозберігаюча.

Ламберт (diffuse)

f_r = albedo / π

Ламбертівська поверхня: відбиває рівномірно у всі напрямки півсфери

Нормалізація: ∫_Ω (albedo/π) cos θ dω = albedo ∈ [0,1] ✓

GGX (Trowbridge-Reitz) — мікрофасетна модель

f_r = D(h) · G(l,v) · F(v,h) / (4 · (n·l) · (n·v))

D — Normal Distribution Function (GGX): D(h) = α² / (π(n·h)²(α²−1)+1)²

G — геометричне загасання (Smith GGX): G(l,v) = G1(l)·G1(v)

F — Fresnel (Schlick): F = F₀ + (1−F₀)(1−v·h)⁵

α = roughness² (remapping для більш перцептивно лінійного контролю)

Diffuse (Lambertian)

Рівномірний розсій. Глина, кераміка, матова фарба. f_r = albedo/π — найпростіша BRDF.

Specular (Mirror)

Дзеркальне відбиття: ω_r = 2(n·ω_i)n − ω_i. BRDF — дельта-функція. Метал, полована поверхня.

GGX Glossy

Розмазане specular. roughness ∈ [0,1] від дзеркала до майже дифузного. Більшість реальних матеріалів.

Glass (Диелектрик)

Дзеркальне заломлення за законом Снеля + Fresnel blend між відбиттям і пропусканням. IOR воска ≈ 1.46.

4. Алгоритм path tracing

Базовий однобічний path tracer: шлях від камери до джерела.

function tracePath(ray, scene, maxDepth = 8) {
  let throughput = vec3(1, 1, 1);  // накопичений ваговий коефіцієнт
  let radiance  = vec3(0, 0, 0);  // акумульована яскравість

  for (let depth = 0; depth < maxDepth; depth++) {
    const hit = scene.intersect(ray);
    if (!hit) {
      radiance = add(radiance, mul(throughput, scene.envmap(ray.dir)));
      break;
    }

    // Додаємо emission якщо потрапили на світильник
    radiance = add(radiance, mul(throughput, hit.material.emission));

    // Вибираємо новий напрямок відповідно до BRDF
    const { wi, pdf, brdfVal } = hit.material.sample(hit.normal, ray.dir);
    if (pdf < 1e-6) break;

    // Оновлюємо throughput: BRDF * cos / pdf
    const cosTheta = Math.max(0, dot(hit.normal, wi));
    throughput = mul(throughput, scale(brdfVal, cosTheta / pdf));

    // Russian Roulette для обрізки (якщо throughput малий)
    const rrProb = Math.min(0.95, luminance(throughput));
    if (random() > rrProb) break;
    throughput = scale(throughput, 1 / rrProb);

    ray = { origin: offset(hit.point, hit.normal), dir: wi };
  }
  return radiance;
}

Накопичення у framebuffer

class Accumulator {
  constructor(w, h) {
    this.buf = new Float32Array(w * h * 4);
    this.count = 0;
    this.w = w; this.h = h;
  }

  add(x, y, r, g, b) {
    const i = (y * this.w + x) * 4;
    this.buf[i]   += r;
    this.buf[i+1] += g;
    this.buf[i+2] += b;
    this.buf[i+3] += 1;
  }

  toCanvas(canvas) {
    const ctx = canvas.getContext('2d');
    const img = ctx.createImageData(this.w, this.h);
    for (let i = 0; i < this.buf.length; i += 4) {
      const s = 1 / (this.buf[i+3] || 1);
      // Tone mapping (ACES filmic) + gamma 2.2
      img.data[i]   = tonemap(this.buf[i]   * s) * 255;
      img.data[i+1] = tonemap(this.buf[i+1] * s) * 255;
      img.data[i+2] = tonemap(this.buf[i+2] * s) * 255;
      img.data[i+3] = 255;
    }
    ctx.putImageData(img, 0, 0);
  }
}

5. Importance sampling та cosine-lobe

Якщо зразки вибирати рівномірно по сфері, більшість потрапляє в ділянки з малим вкладом (стелі, глибокі кути). Importance sampling вибирає зразки пропорційно до підінтегральної функції, зменшуючи дисперсію.

Cosine-weighted sampling (diffuse)

Для ламбертівської BRDF підінтегральна функція ∝ cos θ → вибираємо ω з розподілом p(ω) = cos θ / π. Відображення Мальмоса (Malley) через відкидання або аналітично:

r₁, r₂ ~ Uniform(0,1)

φ = 2π·r₁

θ = arccos(√(1 − r₂)) або через disk mapping:

u = √r₂·cos φ, v = √r₂·sin φ

w = √(1 − r₂) (компонента вздовж нормалі)

pdf(ω) = cos θ / π → BRDF·cos θ / pdf = albedo (спрощується!)

GGX sampling

Вибираємо "half-vector" h з розподілу GGX NDF D(h), потім обчислюємо відбитий напрямок. PDF у напрямку ω: p(ω) = D(h) · (n·h) / (4 · h·ω).

function sampleGGX(alpha, n, v) {
  const r1 = random(), r2 = random();
  // Вибир half-vector за анізотропним GGX
  const phi = 2 * Math.PI * r1;
  const cosTheta = Math.sqrt((1 - r2) / (r2 * (alpha * alpha - 1) + 1));
  const sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
  // local half-vector → world space
  const h = localToWorld(n, sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), cosTheta);
  // відбитий напрямок
  const wi = reflect(neg(v), h);
  const D  = ggxNDF(alpha, dot(n, h));
  const pdf = D * dot(n, h) / (4 * dot(h, wi));
  return { wi, pdf };
}

6. Russian Roulette та обрізка шляхів

Глибина рекурсії обмежена, але фіксований обрізок вносить систематичну похибку (bias) — ми втрачаємо вклад довгих шляхів. Russian Roulette — стохастичне обрізання без bias:

Продовжуємо шлях з ймовірністю q (наприклад, q = luminance(throughput))

Якщо продовжуємо: ваговий коефіцієнт := throughput / q

Якщо обрізаємо: внесок = 0

Очікуване значення: E[throughput/q · q] = throughput → незміщений!

Типово q = clamp(luminance(throughput), 0.1, 0.95). Нижня межа 0.1 гарантує що шляхи не обрізаємо майже завжди на перших бою (уникаємо нескінченного очікуваного часу ітерації).

Next Event Estimation (NEE)

Покращення: при кожном відбитті явно сендимо shadow ray до джерела світла. Це MIS (multiple importance sampling) між BRDF-семплінгом та light-семплінгом — ключова техніка для зменшення шуму у bright-light сценах.

7. WebGL реалізація: ping-pong + TAA

Повноекранний path tracer у WebGL 2.0: кожен кадр додає один зразок до framebuffer, що накопичується між кадрами (ping-pong buffers).

class WebGLPathTracer {
  constructor(canvas) {
    const gl = canvas.getContext('webgl2');
    this.gl = gl;
    this.sampleCount = 0;
    // ping-pong: два FBO, почергово читаємо і пишемо
    this.fbo  = [this.createFBO(gl), this.createFBO(gl)];
    this.prog = this.compileShader(VERT_PASSTHROUGH, PATH_TRACE_FRAG);
    this.toneProg = this.compileShader(VERT_PASSTHROUGH, TONEMAP_FRAG);
    this.buildSceneBVH(); // BVH у texture buffer
  }

  render() {
    const gl = this.gl;
    const curr = this.sampleCount % 2;
    const prev = 1 - curr;

    // Pass 1: зробити один зразок і змішати з накопиченим
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo[curr].fbo);
    gl.useProgram(this.prog);
    gl.uniform1i(this.loc('uAccum'), 0);
    gl.bindTexture(gl.TEXTURE_2D, this.fbo[prev].texture);
    gl.uniform1i(this.loc('uSampleCount'), this.sampleCount);
    gl.uniform2f(this.loc('uJitter'), random(), random()); // sub-pixel jitter
    drawFullscreenQuad(gl);

    // Pass 2: tonemap і записати в canvas
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.useProgram(this.toneProg);
    gl.bindTexture(gl.TEXTURE_2D, this.fbo[curr].texture);
    gl.uniform1f(this.loc('uExposure'), 1.0);
    drawFullscreenQuad(gl);

    this.sampleCount++;
  }
}

TAA (Temporal Anti-Aliasing)

Sub-pixel jitter кожного кадру + накопичення = безкоштовний TAA. По суті це і є path tracing: семплі поступово сходяться до правильного результату. Blend weight можна збільшувати з часом: 1/N decay.

8. Bidirectional PT та MIS

Стандартний unidirectional PT поганий для каустик і скляних сцен: шляхи, що проходять крізь скло до джерела, мають малу ймовірність. Bidirectional PT (BDPT) будує шляхи з обох кінців одночасно.

BDPT: шлях з k+1 вершин = з'єднайте (k-вершинний субшлях від камери) і (l-вершинний субшлях від джерела)

Комбінацій: (k+1)×(l+1) стратегій для шляху довжини k+l

Multiple Importance Sampling (MIS)

Кожна стратегія (BRDF-sampling, light-sampling, тощо) дає незміщену оцінку, але з різними дисперсіями. MIS комбінує їх з вагами Веаха (balance heuristic або power heuristic β=2):

w_i(x) = p_i(x)^β / Σ_j p_j(x)^β

MIS estimator: L = Σ_i w_i(x_i) · f(x_i) / p_i(x_i)

Верхня межа дисперсії: Var[L_MIS] ≤ (Σ √Var[L_i]) + Var[L_opt]

Unidirectional PT

Простий, дає правильний результат для дифузних сцен. Поганий для каустик та glass-caustics.

BDPT

Краще для дифузно-specular шляхів. Складніша реалізація. Основа для VCM та MLT.

Metropolis Light Transport

MCMC-сімплінг у просторі шляхів. Адаптивно зосереджується на складних ділянках (каустики, SDS шляхи).

VCM / UPM

Vertex Connection & Merging: поєднує photon merging + BDPT. Сучасний стандарт у продуктивних рендерерах (Appleseed, Lux).

🌈 Open Rainbow Ray Tracer →

🔗 Related Simulations

🌈Rainbow 🌊Ocean ✳️Fractal 🌌Galaxy