Path Tracing — фізично коректний рендеринг
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).