Трасування шляхів (Path Tracing) у WebGL (GLSL): рей-трейсер у реальному часі з нуля
Растеризація апроксимує перенесення світла хитрощами — картами тіней, кубічними картами, screen-space-відбиттями. Трасування шляхів (path tracing) натомість напряму симулює перенесення світла, розв'язуючи рівняння рендерингу методом інтегрування Монте-Карло. У цьому туторіалі ми побудуємо фізично коректний трасувальник шляхів, який повністю працює в одному GLSL фрагментному шейдері, накопичуючи семпли в реальному часі просто в браузері.
1. Рівняння рендерингу
Будь-який фізично коректний рендерер по суті намагається розв'язати рівняння рендерингу Каджия. Воно стверджує, що вихідна яскравість (radiance) Lo з точки x у напрямку ωo дорівнює випроміненій яскравості плюс інтеграл вхідної яскравості, зваженої BRDF поверхні та косинусним членом:
Цей інтеграл не має аналітичного розв'язку для довільних сцен — Li сама рекурсивно залежить від того самого рівняння, обчисленого на тій поверхні, у яку влучить промінь із точки x далі. Ми апроксимуємо його інтегруванням Монте-Карло: трасуємо багато випадкових шляхів світла, усереднюємо результати, і шум скасовується з часом. Саме таке усереднення кадр за кадром і виконує трасувальник шляхів у реальному часі.
2. Повноекранний квад і буфер накопичення
Сам трасувальник шляхів — це фрагментний шейдер, «намальований» на повноекранному трикутнику. Оскільки один кадр з 1 семплом на піксель надзвичайно шумний, ми рендеримо у текстуру з плаваючою комою та накопичуємо семпли між кадрами, а при відображенні ділимо на кількість кадрів:
const gl = canvas.getContext("webgl2");
gl.getExtension("EXT_color_buffer_float"); // потрібно для рендер-таргетів RGBA32F
function createAccumTarget(w, h) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
return { tex, fbo };
}
// Ping-pong: читаємо попереднє накопичення, поки пишемо нове
let targets = [createAccumTarget(width, height), createAccumTarget(width, height)];
let frameIndex = 0;
function render() {
const src = targets[frameIndex % 2];
const dst = targets[(frameIndex + 1) % 2];
gl.bindFramebuffer(gl.FRAMEBUFFER, dst.fbo);
gl.useProgram(pathTraceProgram);
gl.uniform1i(uPrevSample, 0); // прив'язано до src.tex
gl.uniform1i(uFrame, frameIndex);
gl.drawArrays(gl.TRIANGLES, 0, 3); // повноекранний трикутник, VBO не потрібен
// Другий прохід: ділимо на frameIndex+1 і робимо тонмапінг у видимий canvas
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(displayProgram);
gl.drawArrays(gl.TRIANGLES, 0, 3);
frameIndex++;
requestAnimationFrame(render);
}
gl_VertexID у
вершинному шейдері — жодного буфера вершин не потрібно, і це
уникає діагонального шва, який деякі GPU показують на квадах.
3. Генерація променів камери
Кожен фрагмент відповідає одному пікселю. Ми переводимо його екранну координату в нормалізовану координату пристрою (NDC) у діапазоні [-1, 1], потім депроєктуємо її в напрямок променя, використовуючи поле зору камери та співвідношення сторін:
struct Ray { vec3 origin; vec3 dir; };
Ray getCameraRay(vec2 uv, vec3 camPos, vec3 camTarget, float fovY) {
vec2 ndc = uv * 2.0 - 1.0; // [0,1] -> [-1,1]
ndc.x *= uResolution.x / uResolution.y; // корекція співвідношення сторін
vec3 fwd = normalize(camTarget - camPos);
vec3 right = normalize(cross(fwd, vec3(0.0, 1.0, 0.0)));
vec3 up = cross(right, fwd);
float tanHalfFov = tan(radians(fovY) * 0.5);
vec3 dir = normalize(fwd + ndc.x * tanHalfFov * right + ndc.y * tanHalfFov * up);
return Ray(camPos, dir);
}
Для згладжування (anti-aliasing) і для потреб оцінювача
Монте-Карло зсуньте координату пікселя на випадковий субпіксельний
зсув (rand() - 0.5) перед перетворенням у
NDC — кожен накопичений кадр семплить трохи іншу точку в межах
площі пікселя, тож за сотні кадрів краї природно згладжуються
безкоштовно.
4. Представлення сцени: аналітичні сфери
Мінімальна сцена трасувальника шляхів — це список сфер (і площини-«землі») з властивостями матеріалу. Перетин променя зі сферою має замкнене рішення з квадратного рівняння:
struct Sphere { vec3 center; float radius; vec3 albedo; float roughness; vec3 emissive; };
bool intersectSphere(Ray ray, Sphere s, out float t, out vec3 n) {
vec3 oc = ray.origin - s.center;
float b = dot(oc, ray.dir);
float c = dot(oc, oc) - s.radius * s.radius;
float disc = b * b - c;
if (disc < 0.0) return false;
float sq = sqrt(disc);
t = -b - sq;
if (t < 0.001) t = -b + sq; // пробуємо дальший корінь, якщо ближній позаду початку
if (t < 0.001) return false;
n = normalize(ray.origin + t * ray.dir - s.center);
return true;
}
// Обхід сцени до найближчого влучення: перебираємо усі сфери, лишаємо найменший t
bool intersectScene(Ray ray, out float tHit, out vec3 nHit, out Sphere hit) {
tHit = 1e9;
bool found = false;
for (int i = 0; i < NUM_SPHERES; i++) {
float t; vec3 n;
if (intersectSphere(ray, spheres[i], t, n) && t < tHit) {
tHit = t; nHit = n; hit = spheres[i]; found = true;
}
}
return found;
}
5. Косинус-зважена вибірка на півсфері
Для дифузних (ламбертівських) поверхонь відбита яскравість пропорційна cos θ між напрямком виходу та нормаллю. Якщо семплити напрямки відскоку рівномірно по півсфері, більшість семплів потрапляють поблизу горизонту, де їхній внесок мінімальний — марна робота. Натомість ми семплимо з важливістю пропорційно до cos θ, що повністю скасовує косинусний член в оцінювачі:
// PRNG: дешевий, детермінований по-пікселю хеш-генератор (у стилі PCG)
float rand(inout uint seed) {
seed = seed * 747796405u + 2891336453u;
uint word = ((seed >> ((seed >> 28u) + 4u)) ^ seed) * 277803737u;
return float((word >> 22u) ^ word) / 4294967295.0;
}
// Перетворює два рівномірних випадкових числа у косинус-зважену точку
// на півсфері навколо нормалі n методом Меллі: рівномірний диск + проєкція вгору
vec3 cosineSampleHemisphere(vec3 n, inout uint seed) {
float u1 = rand(seed);
float u2 = rand(seed);
float r = sqrt(u1);
float theta = 6.2831853 * u2; // 2*PI
float x = r * cos(theta);
float y = r * sin(theta);
float z = sqrt(max(0.0, 1.0 - u1)); // висота над дотичною площиною
// Будуємо ортонормований базис (дотична, бідотична) навколо n
vec3 up = abs(n.z) < 0.999 ? vec3(0.0,0.0,1.0) : vec3(1.0,0.0,0.0);
vec3 tangent = normalize(cross(up, n));
vec3 bitangent = cross(n, tangent);
return normalize(x * tangent + y * bitangent + z * n);
}
6. Цикл відскоків і руська рулетка
Трасувальник шляхів проходить промінь через декілька відскоків, множачи поточний коефіцієнт пропускної здатності (throughput) на альбедо кожної поверхні та додаючи будь-яке випромінене світло, зустрінуте на шляху. GLSL (до ES 3.1) забороняє справжню рекурсію, тож ланцюг відскоків — це явний цикл із фіксованою максимальною глибиною:
vec3 tracePath(Ray ray, inout uint seed) {
vec3 radiance = vec3(0.0);
vec3 throughput = vec3(1.0);
for (int bounce = 0; bounce < MAX_BOUNCES; bounce++) {
float t; vec3 n; Sphere hit;
if (!intersectScene(ray, t, n, hit)) {
radiance += throughput * skyColor(ray.dir); // промінь пішов у середовище
break;
}
radiance += throughput * hit.emissive; // влучили в джерело світла
throughput *= hit.albedo; // дифузна BRDF, косинус уже скасовано
vec3 hitPoint = ray.origin + t * ray.dir;
vec3 newDir = cosineSampleHemisphere(n, seed);
ray = Ray(hitPoint + n * 0.001, newDir); // зсув-епсилон уникає самоперетину
// Руська рулетка: стохастично зупиняємо шляхи з малим внеском
if (bounce > 3) {
float p = max(throughput.r, max(throughput.g, throughput.b));
if (rand(seed) > p) break;
throughput /= p; // незміщено: перемасштабовуємо шляхи, що вижили
}
}
return radiance;
}
7. Прогресивне накопичення та шумозаглушення
Один семпл на піксель — надто шумний, щоб виглядати прийнятно —
кожен піксель є незалежною випадковою величиною з високою
дисперсією. Оскільки похибка Монте-Карло зменшується як
1/√N для N семплів, запуск того самого шейдера
щокадру з усередненням результатів збігається до правильного
зображення:
out vec4 fragColor;
void main() {
uint seed = uint(gl_FragCoord.x) * 1973u + uint(gl_FragCoord.y) * 9277u + uint(uFrame) * 26699u | 1u;
vec2 jitter = vec2(rand(seed), rand(seed)) - 0.5;
vec2 uv = (gl_FragCoord.xy + jitter) / uResolution;
Ray ray = getCameraRay(uv, uCamPos, uCamTarget, 45.0);
vec3 sample = tracePath(ray, seed);
vec3 prev = texelFetch(uPrevSample, ivec2(gl_FragCoord.xy), 0).rgb;
vec3 accumulated = prev * float(uFrame) + sample; // поточна сума, не поточне середнє
fragColor = vec4(accumulated, 1.0);
}
// Прохід відображення ділить на (uFrame + 1) і застосовує тонмапінг Рейнхарда + гамму:
// vec3 color = accumulated / float(uFrame + 1);
// color = color / (1.0 + color); // Рейнхард
// fragColor = vec4(pow(color, vec3(1.0/2.2)), 1.0); // гамма-корекція
Будь-який рух камери робить накопичення недійсним — скидайте
frameIndex в 0 щоразу, коли змінюється камера чи
сцена, а потім дайте йому знову зростати, поки вигляд статичний.
При 200+ накопичених кадрах проста сцена з кількох сфер виглядає
практично без шуму при 1080p на середньому GPU.
8. Повний фрагментний шейдер
Поєднавши всі частини — сцену, промені камери, косинусну вибірку, цикл відскоків та накопичення — отримуємо самодостатній фрагментний шейдер GLSL ES 3.0. Уніформи для даних сфер, камери та індексу кадру встановлюються з JavaScript щокадру:
#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform vec3 uCamPos;
uniform vec3 uCamTarget;
uniform int uFrame;
uniform sampler2D uPrevSample;
out vec4 fragColor;
#define NUM_SPHERES 4
#define MAX_BOUNCES 6
struct Ray { vec3 origin; vec3 dir; };
struct Sphere { vec3 center; float radius; vec3 albedo; vec3 emissive; };
Sphere spheres[NUM_SPHERES] = Sphere[](
Sphere(vec3(0.0, -100.5, 0.0), 100.0, vec3(0.6), vec3(0.0)), // земля
Sphere(vec3(-1.0, 0.0, 0.0), 0.5, vec3(0.9,0.2,0.2), vec3(0.0)),
Sphere(vec3( 1.0, 0.0, 0.0), 0.5, vec3(0.2,0.5,0.9), vec3(0.0)),
Sphere(vec3( 0.0, 5.0, 0.0), 1.5, vec3(1.0), vec3(4.0,3.8,3.5)) // площинне джерело світла
);
// hash / rand / cosineSampleHemisphere / intersectSphere / intersectScene
// / getCameraRay / tracePath — як визначено у розділах 3-6 вище
vec3 skyColor(vec3 dir) {
float t = 0.5 * (dir.y + 1.0);
return mix(vec3(1.0), vec3(0.5,0.7,1.0), t) * 0.3; // тьмяний градієнт, роботу виконує світло
}
void main() {
uint seed = uint(gl_FragCoord.x) * 1973u + uint(gl_FragCoord.y) * 9277u + uint(uFrame) * 26699u | 1u;
vec2 jitter = vec2(rand(seed), rand(seed)) - 0.5;
vec2 uv = (gl_FragCoord.xy + jitter) / uResolution;
Ray ray = getCameraRay(uv, uCamPos, uCamTarget, 45.0);
vec3 col = tracePath(ray, seed);
vec3 prev = texelFetch(uPrevSample, ivec2(gl_FragCoord.xy), 0).rgb;
fragColor = vec4(prev * float(uFrame) + col, 1.0);
}
Уся ця сцена — три дифузні сфери й одне випромінююче площинне джерело світла — не потребує ні текстур, ні структур прискорення, ні зовнішніх ресурсів. Заміните список аналітичних сфер на невеликий BVH над трикутниками — і матимете ядро WebGPU- чи WebGL-компʼют-трасувальника шляхів, здатного працювати з довільними мешами.