Урок · Рендеринг · GLSL · WebGL2
📅 Липень 2026 ⏱ ≈ 60 хв 🎯 Середній рівень

Ray marching SDF сцена за 60 хвилин

Жодного меша, жодного вершинного буфера, жодного растеризатора трикутників — лише один повноекранний фрагментний шейдер і функція, що відповідає «наскільки далеко я від найближчої поверхні?». До кінця цієї години у вас буде освітлена, затінена, з ambient occlusion сцена, зроблена повністю з математики.

1. Налаштування шейдера на весь екран

Ray marching потребує рівно одного виклику малювання: трикутника (чи квада), що покриває весь viewport. Уся робота відбувається для кожного пікселя у фрагментному шейдері, який відновлює промінь камери з нормалізованих координат пристрою пікселя:

// Вершинний шейдер: повноекранний трикутник, буфери не потрібні
void main() {
  vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
}

// Фрагментний шейдер: побудова променя камери для кожного пікселя
uniform vec2 uResolution;
uniform vec3 uCamPos;
uniform float uTime;

void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;

  vec3 ro = uCamPos;                              // початок променя
  vec3 forward = normalize(-uCamPos);
  vec3 right   = normalize(cross(forward, vec3(0,1,0)));
  vec3 up      = cross(right, forward);
  vec3 rd = normalize(forward + uv.x * right + uv.y * up); // напрямок променя

  vec3 color = render(ro, rd);
  fragColor = vec4(color, 1.0);
}
Чому лише фрагментний? Кожен піксель незалежний, тож це ідеально лягає на масово-паралельну SIMD-архітектуру GPU — немає вершинних даних, бо немає геометрії, лише неявна поверхня, задана функцією відстані.

2. Signed Distance Functions

Функція знакової відстані (SDF) бере точку в просторі і повертає найкоротшу відстань до поверхні — додатну зовні, нуль на поверхні, від'ємну всередині. Найпоширеніші примітиви (з канонічного списку Ініго Кілеза):

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

float sdPlane(vec3 p, float height) {
  return p.y - height; // нескінченна площина землі
}

float sdTorus(vec3 p, vec2 t) { // t = (великий радіус, малий радіус)
  vec2 q = vec2(length(p.xz) - t.x, p.y);
  return length(q) - t.y;
}

Щоб розмістити форми будь-де у сцені, трансформуйте точку запиту перед обчисленням: sdSphere(p - center, r) зсуває, а множення p на обернену матрицю обертання обертає форму.

3. Цикл sphere tracing

Sphere tracing (Hart, 1996) використовує ключову властивість SDF: повернута відстань — безпечний радіус — жодна поверхня не може бути ближче за неї. Тож можна просувати промінь вперед точно на цю відстань кожну ітерацію, ніколи не проскочивши крізь тонку стіну:

const int   MAX_STEPS = 100;
const float MAX_DIST  = 100.0;
const float SURF_DIST = 0.001;

float rayMarch(vec3 ro, vec3 rd) {
  float dO = 0.0; // пройдена відстань від початку

  for (int i = 0; i < MAX_STEPS; i++) {
    vec3  p  = ro + rd * dO;
    float dS = sceneSDF(p); // відстань до найближчої поверхні
    dO += dS;
    if (dO > MAX_DIST || dS < SURF_DIST) break;
  }
  return dO;
}
Кількість кроків проти якості: замало кроків (MAX_STEPS) призводить до «танення» форм під похилими кутами, де промінь вичерпує бюджет до досягнення поверхні. 100 — хороша відправна точка при 800×600; зменшіть удвічі для мобільних GPU, подвойте для сцен з тонкими деталями (фрактали, тонкі тори).

4. Поєднання форм: об'єднання, віднімання, гладке змішування

Оскільки SDF — просто числа, булеві операції стають тривіальними операціями min/max, а поліноміальний smooth-min Ініго Кілеза замінює гострий шов простого об'єднання органічним змішуванням — саме так дешево ліпити розплавлені, схожі на краплі форми (метаболи):

float opUnion(float d1, float d2) { return min(d1, d2); }
float opSubtract(float d1, float d2) { return max(-d1, d2); }
float opIntersect(float d1, float d2) { return max(d1, d2); }

// Поліноміальний smooth minimum (Quilez) — k керує радіусом змішування
float opSmoothUnion(float d1, float d2, float k) {
  float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
  return mix(d2, d1, h) - k * h * (1.0 - h);
}

float sceneSDF(vec3 p) {
  float ground = sdPlane(p, 0.0);
  float sphere = sdSphere(p - vec3(0, 1, 0), 1.0);
  float box    = sdBox(p - vec3(2.2, 0.7, 0), vec3(0.7));
  float blobs  = opSmoothUnion(sphere, box, 0.4);
  return opUnion(ground, blobs);
}

5. Нормалі поверхні через градієнт

SDF — це скалярне поле, і на поверхні його градієнт вказує вздовж зовнішньої нормалі. Оскільки GLSL не має автоматичного диференціювання, ми наближаємо його «трюком з тетраедром» — чотирма обчисленнями SDF при малих зміщеннях навколо точки, що дешевше за наївний метод центральної різниці з 6 семплами:

vec3 calcNormal(vec3 p) {
  const float h = 0.0001;
  const vec2 k = vec2(1, -1);
  return normalize(
    k.xyy * sceneSDF(p + k.xyy * h) +
    k.yyx * sceneSDF(p + k.yyx * h) +
    k.yxy * sceneSDF(p + k.yxy * h) +
    k.xxx * sceneSDF(p + k.xxx * h)
  );
}

6. Освітлення: дифузія, відблиск і м'які тіні

Стандартне затінення Lambert + Blinn-Phong потребує лише нормалі та напрямку світла. Тіні виникають від проведення другого променя з точки поверхні до світла — якщо він щось зачіпає перш ніж досягти світла, точка в тіні:

// М'які тіні (Quilez): відстежує мінімальне відношення відстані до пройденого шляху
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
  float res = 1.0;
  float t   = mint;
  for (int i = 0; i < 64 && t < maxt; i++) {
    float h = sceneSDF(ro + rd * t);
    if (h < 0.001) return 0.0; // повністю затінено
    res = min(res, k * h / t); // напівтінь розширюється з відстанню
    t += h;
  }
  return clamp(res, 0.0, 1.0);
}

vec3 shade(vec3 p, vec3 rd, vec3 lightPos) {
  vec3 n = calcNormal(p);
  vec3 l = normalize(lightPos - p);
  vec3 v = -rd;
  vec3 h = normalize(l + v);

  float diff = max(dot(n, l), 0.0);
  float spec = pow(max(dot(n, h), 0.0), 32.0);
  float shadow = softShadow(p + n * 0.01, l, 0.02, 20.0, 16.0);

  vec3 albedo = vec3(0.75);
  return albedo * diff * shadow + vec3(1.0) * spec * shadow;
}
Чому зсув на n * 0.01? Початок променя тіні точно на поверхні (відстань 0) може негайно знову перетнути ту саму поверхню через похибку floating-point — баг, відомий як «shadow acne». Невелике зміщення початку вздовж нормалі уникає цього.

7. Ambient Occlusion

Ambient occlusion затемнює тріщини та точки дотику, де сусідня геометрія блокувала б фонове світло. Замість дорогого інтеграла по півсфері, дешеве наближення Кілеза семплює SDF на зростаючих кроках вздовж нормалі і порівнює фактичну відстань з ідеальною незатіненою відстанню:

float calcAO(vec3 p, vec3 n) {
  float occ = 0.0;
  float sca = 1.0;
  for (int i = 0; i < 5; i++) {
    float h = 0.01 + 0.12 * float(i) / 4.0;
    float d = sceneSDF(p + n * h);
    occ += (h - d) * sca;
    sca *= 0.95;
  }
  return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}

Помножте цей результат на дифузний член освітлення (і окремо на будь-який фоновий/небесний внесок), щоб дешево додати глибину контактних тіней — без потреби в G-buffer чи додаткових проходах, на відміну від screen-space AO.

8. Повна сцена

Складання всіх частин у повну функцію рендерингу:

vec3 render(vec3 ro, vec3 rd) {
  vec3 lightPos = vec3(3.0, 5.0, -2.0);
  float d = rayMarch(ro, rd);

  if (d >= MAX_DIST) {
    return vec3(0.05, 0.06, 0.09); // фон неба
  }

  vec3 p = ro + rd * d;
  vec3 n = calcNormal(p);
  vec3 color = shade(p, rd, lightPos);

  float ao = calcAO(p, n);
  color *= ao;
  color += vec3(0.02, 0.03, 0.05) * ao; // слабкий фоновий небесний член

  color = pow(color, vec3(1.0 / 2.2)); // гамма-корекція
  return color;
}
Наступні кроки: додайте обертання камери мишею через матрицю обертання над rd, анімуйте позицію форми з uTime, або замініть sceneSDF на фрактальний estimator відстані (Mandelbulb, Menger sponge) — цикл marching залишається ідентичним, змінюється лише функція відстані.

Часті запитання

Чого я навчуся в цьому уроці?

Створіть повну ray-marched сцену з нуля в одному GLSL fragment-шейдері: signed distance functions, sphere tracing, нормалі через градієнт, м'які тіні та ambient occlusion.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Налаштування шейдера на весь екран, Signed Distance Functions, Цикл sphere tracing, Поєднання форм: об'єднання, віднімання, гладке змішування, Нормалі поверхні через градієнт, Освітлення: дифузія, відблиск і м'які тіні, Ambient Occlusion, Повна сцена.

Скільки часу займає цей урок?

Цей урок займає приблизно 60 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.