Туторіал · Рендеринг · GLSL · WebGL2
📅 Липень 2026 ⏱ ≈ 28 хв 🎯 Просунутий рівень

Трасування шляхів (Path Tracing) у WebGL (GLSL): рей-трейсер у реальному часі з нуля

Растеризація апроксимує перенесення світла хитрощами — картами тіней, кубічними картами, screen-space-відбиттями. Трасування шляхів (path tracing) натомість напряму симулює перенесення світла, розв'язуючи рівняння рендерингу методом інтегрування Монте-Карло. У цьому туторіалі ми побудуємо фізично коректний трасувальник шляхів, який повністю працює в одному GLSL фрагментному шейдері, накопичуючи семпли в реальному часі просто в браузері.

1. Рівняння рендерингу

Будь-який фізично коректний рендерер по суті намагається розв'язати рівняння рендерингу Каджия. Воно стверджує, що вихідна яскравість (radiance) Lo з точки x у напрямку ωo дорівнює випроміненій яскравості плюс інтеграл вхідної яскравості, зваженої BRDF поверхні та косинусним членом:

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 = вхідна яскравість з напрямку ω_i Ω = півсфера над нормаллю поверхні n (ω_i · n) = косинусний член Ламберта (форшортенінг)

Цей інтеграл не має аналітичного розв'язку для довільних сцен — Li сама рекурсивно залежить від того самого рівняння, обчисленого на тій поверхні, у яку влучить промінь із точки x далі. Ми апроксимуємо його інтегруванням Монте-Карло: трасуємо багато випадкових шляхів світла, усереднюємо результати, і шум скасовується з часом. Саме таке усереднення кадр за кадром і виконує трасувальник шляхів у реальному часі.

Чому GLSL? Фрагментний шейдер запускається один раз на піксель, паралельно, на GPU. Це майже ідеально накладається на трасування шляхів: кожен піксель незалежно трасує власний шлях через сцену й записує один семпл кольору. Ніякого BVH на CPU, ніякого зчитування назад — весь цикл живе на GPU.

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);
}
Трюк з повноекранним трикутником: Замість квада (2 трикутники, 4 вершини) малюємо один завеликий трикутник, що покриває весь viewport, використовуючи 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. Представлення сцени: аналітичні сфери

Мінімальна сцена трасувальника шляхів — це список сфер (і площини-«землі») з властивостями матеріалу. Перетин променя зі сферою має замкнене рішення з квадратного рівняння:

|O + tD − C|² = r² розкривається у: t² (D·D) + 2t D·(O−C) + (O−C)·(O−C) − r² = 0 a = 1 (D нормалізований) b = 2 · D·(O−C) c = (O−C)·(O−C) − r² дискримінант = b² − 4ac t = (−b − √дискримінант) / 2 (найближчий корінь)
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);
}
Косинус-зважена PDF: p(ω) = cos θ / π Ламбертівська BRDF: f_r = albedo / π Оцінювач на кожен відскок спрощується до: throughput *= f_r · cos θ / p(ω) = (albedo/π) · cosθ / (cosθ/π) = albedo → жоден косинус чи член π ніколи не з'являється у робочому коді — вони аналітично скасовуються, коли форма вибірки збігається з формою BRDF.

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;
}
Руська рулетка зберігає незміщеність оцінювача, обмежуючи середню вартість: шлях з throughput 0.1 виживає з імовірністю 10%, але якщо виживає — його внесок ділиться на 0.1 (×10) — очікуване значення не змінюється, але у 90% випадків ми економимо роботу з подальшого трасування.

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.

Понад просте накопичення: Промислові трасувальники шляхів реального часу (і цей, якщо його розширити) поєднують кілька семплів на піксель зі спатіо-темпоральним денойзером у стилі SVGF, який повторно використовує буфери геометрії (нормаль, глибина, альбедо), щоб згладити шум без розмиття країв — саме накопичення лишається найпростішою й найнадійнішою відправною точкою.

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-компʼют-трасувальника шляхів, здатного працювати з довільними мешами.