GLSL · Комп'ютерна графіка · Рендеринг
📅 Березень 2026 ⏱ ≈ 13 хв читання 🎯 Середній–просунутий рівень · Останнє оновлення: 23 червня 2026 р.

Ray Marching та знакові функції відстані — сферичне трасування у GLSL

Ray marching зі знаковими функціями відстані — це суто процедурна техніка рендерингу: тут немає ані сіток, ані трикутників, ані растеризації. Сцену задає єдина математична функція float sdf(vec3 p), що повертає відстань від будь-якої точки до найближчої поверхні. Цикл сферичного трасування потім крокує вздовж кожного променя огляду, сягаючи поверхонь із гарантованою коректністю та природною підтримкою тіней, навколишнього затінення й фрактальних геометрій.

Коротко: Ray marching зі знаковими функціями відстані (SDF) рендерить 3D-сцени без сіток чи трикутників: одна математична функція повертає відстань до найближчої поверхні, а цикл сферичного трасування просуває промінь уперед на цю безпечну відстань, доки він не досягне поверхні. Це природно підтримує м'які тіні, навколишнє затінення та нескінченно деталізовану фрактальну геометрію.

1. Що таке знакова функція відстані?

Знакова функція відстані (SDF) — це функція d = sdf(p), визначена в усьому 3D-просторі. Вона повертає знакову евклідову відстань від точки p до найближчої поверхні фігури:

Ключова властивість, яку використовує сферичне трасування, — це межа Ліпшиця: SDF сцени гарантує, що в будь-якій точці p, що лежить поза всією геометрією, ми можемо безпечно просунути промінь рівно на |sdf(p)|, не «перестрибнувши» жодної поверхні.

Не кожна фігура має SDF у замкненій формі. Довільні сітки потребують наближених SDF (запечених у 3D-текстури або згенерованих з обходу BVH). Примітивні фігури та їхні CSG-комбінації точні, ефективні й вільні від числового шуму.

2. Примітивні SDF

Усі стандартні примітиви мають витончені реалізації на GLSL у один– п'ять рядків. Усі центровані на початку координат; для зсуву передайте p - centre.

// Сфера — радіус r
float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

// Паралелепіпед, вирівняний за осями — піврозміри b
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);
}

// Тор — великий радіус R, радіус труби r
float sdTorus(vec3 p, float R, float r) {
    vec2 q = vec2(length(p.xz) - R, p.y);
    return length(q) - r;
}

// Нескінченна площина — нормаль n (нормована), зсув d
float sdPlane(vec3 p, vec3 n, float d) {
    return dot(p, n) + d;
}

// Циліндр — радіус r, піввисота h
float sdCylinder(vec3 p, float r, float h) {
    vec2 d = abs(vec2(length(p.xz), p.y)) - vec2(r, h);
    return min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
}

// Капсула — кінцеві точки a, b; радіус r
float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
    vec3 ab = b - a, ap = p - a;
    float t = clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0);
    return length(ap - ab * t) - r;
}

Зсувайте, замінюючи p на p - centre усередині виклику. Повертайте, множачи p на обернену матрицю обертання перед передаванням у SDF. Масштабуйте рівномірно за допомогою sdf(p/s)*s.

3. Операції SDF — CSG в одному рядку

Булеві операції конструктивної суцільної геометрії зводяться до простої арифметики над значеннями SDF. Вони точні для опуклих фігур і наближені для увігнутої геометрії, але вільні від артефактів.

Об'єднання

min(a, b)
— взяти ближчу з двох поверхонь.

Віднімання

max(-a, b)
— вирізати фігуру A з фігури B.

Перетин

max(a, b)
— залишити лише область перекриття.

Плавне об'єднання

smin(a, b, k)
— злити з плавним заокругленням радіуса k.

// Плавне об'єднання — поліноміальна версія (Inigo Quilez)
float smin(float a, float b, float k) {
    float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
    return mix(b, a, h) - k * h * (1.0 - h);
}

// Приклад сцени: сфера з паралелепіпедом зверху, з відрізаним циліндром
float scene(vec3 p) {
    float sphere = sdSphere(p - vec3(0, 0.5, 0), 0.5);
    float box    = sdBox   (p - vec3(0, 0,   0), vec3(0.4, 0.4, 0.4));
    float hole   = sdCylinder(p, 0.2, 1.0);
    float merged = smin(sphere, box, 0.15); // плавне злиття
    return max(-hole, merged);              // відняти циліндр
}
Артефакт віднімання: max(-a, b) точне лише тоді, коли поверхня A повністю міститься всередині B. Якщо A виступає крізь межу B, отримана SDF не є Ліпшицевою з константою 1, і сферичне трасування може пропускати поверхні — додайте невелику корекцію EPSILON або зменшіть крок у цих областях.

4. Алгоритм сферичного трасування

Класичний ray marching просувається фіксованим кроком за ітерацію — витратно й неточно. Сферичне трасування (розроблене Джоном К. Гартом, 1996) використовує гарантію SDF: ми завжди можемо крокнути рівно на |sdf(pos)|, не «перестрибнувши» поверхню.

t = t_near
цикл до MAX_STEPS разів:
pos = ro + t * rd ← поточна точка на промені
d = scene(pos) ← безпечна відстань кроку
якщо d < EPSILON → ПОПАДАННЯ на відстані t
якщо t > t_far → ПРОМАХ (небо)
t += d
const int   MAX_STEPS = 128;
const float EPSILON   = 0.001;
const float T_FAR     = 100.0;

float raymarch(vec3 ro, vec3 rd) {
    float t = 0.001;
    for (int i = 0; i < MAX_STEPS; i++) {
        float d = scene(ro + t * rd);
        if (d < EPSILON) return t;
        if (t > T_FAR)   break;
        t += d;
    }
    return T_FAR; // немає попадання
}

Збіжність квадратична поблизу гладких поверхонь, бо SDF локально лінійна. Домінантна вартість — обчислення scene() — тримайте його дешевим для частоти кадрів у реальному часі.

Застереження щодо тонкої геометрії: дуже тонкі деталі (наприклад, плаский диск завтовшки 0.01) мають малий градієнт SDF перпендикулярно до диска. Сферичне трасування робитиме багато крихітних кроків — задайте суворіший EPSILON і змиріться з трохи більшою кількістю пропущених попадань, або застосуйте обмежений крок для відомих областей із тонкими деталями.

5. Оцінка нормалей

Нормаль до поверхні в точці попадання — це градієнт поля SDF. Еталонний підхід — тетраедрична вибірка центральними різницями (4 обчислення, а не 6):

// Тетраедрична нормаль за центральними різницями (Inigo Quilez)
vec3 calcNormal(vec3 p) {
    const vec2 k = vec2(1, -1);
    return normalize(
        k.xyy * scene(p + k.xyy * 0.001) +
        k.yyx * scene(p + k.yyx * 0.001) +
        k.yxy * scene(p + k.yxy * 0.001) +
        k.xxx * scene(p + k.xxx * 0.001)
    );
}

// Простіша версія з 6 вибірками (інтуїтивніша, та сама вартість на мобільних)
vec3 calcNormalSimple(vec3 p) {
    float e = 0.001;
    return normalize(vec3(
        scene(p + vec3( e, 0, 0)) - scene(p - vec3( e, 0, 0)),
        scene(p + vec3( 0, e, 0)) - scene(p - vec3( 0, e, 0)),
        scene(p + vec3( 0, 0, e)) - scene(p - vec3( 0, 0, e))
    ));
}

Значення epsilon 0.001 має збігатися з порогом поверхні EPSILON. Більше значення дає вигляд «фаски»; менші значення ризикують числовим шумом від скорочення значущих розрядів у плаваючій комі.

6. Освітлення, тіні та навколишнє затінення

Освітлення Фонга

Стандартне освітлення Блінна–Фонга використовує нормаль, напрямок огляду та напрямок світла для обчислення дифузного й дзеркального внесків:

vec3 shade(vec3 p, vec3 n, vec3 rd, vec3 lPos, vec3 albedo) {
    vec3  l   = normalize(lPos - p);
    vec3  h   = normalize(l - rd);           // напіввектор
    float dif = clamp(dot(n, l), 0.0, 1.0);
    float spc = pow(clamp(dot(n, h), 0.0, 1.0), 32.0);
    vec3  col = albedo * (0.1 + 0.9 * dif) + 0.4 * spc;
    return col;
}

М'які тіні

Просувайте вторинний промінь до джерела світла, відстежуючи мінімальне відношення SDF уздовж шляху, щоб оцінити, яку частку світла заблоковано. Параметр k керує різкістю тіні.

float softShadow(vec3 ro, vec3 rd, float tMin, float tMax, float k) {
    float res = 1.0;
    float t   = tMin;
    for (int i = 0; i < 64; i++) {
        float d = scene(ro + t * rd);
        res = min(res, k * d / t);
        t  += clamp(d, 0.02, 0.2);
        if (res < 0.001 || t > tMax) break;
    }
    return clamp(res, 0.0, 1.0);
}

Навколишнє затінення

Крокніть на коротку відстань уздовж нормалі до поверхні й порівняйте реальне значення SDF з очікуваним незатіненим значенням. Малі різниці означають, що поверхня близька до затіняючої геометрії.

float ambientOcclusion(vec3 p, vec3 n) {
    float occ = 0.0, weight = 1.0;
    for (int i = 1; i <= 5; i++) {
        float dist = 0.08 * float(i);
        float d    = scene(p + n * dist);
        occ   += (dist - d) * weight;
        weight *= 0.7;
    }
    return clamp(1.0 - 2.0 * occ, 0.0, 1.0);
}

7. Повний фрагментний шейдер GLSL

Нижче наведено самодостатній фрагментний шейдер WebGL2 (GLSL ES 3.00), що рендерить площину підлоги, плавне об'єднання сфери та паралелепіпеда з дифузним освітленням, м'якими тінями й AO. Помістіть його в повноекранний квад.

// Фрагментний шейдер — GLSL ES 3.00
#version 300 es
precision highp float;

uniform vec2  uResolution;
uniform float uTime;
out vec4 fragColor;

// ── Примітиви 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 smin(float a, float b, float k) {
    float h = clamp(0.5+0.5*(b-a)/k, 0.0, 1.0);
    return mix(b, a, h) - k*h*(1.0-h);
}

// ── Сцена ─────────────────────────────────────────────────────────
float scene(vec3 p) {
    float t   = uTime * 0.5;
    vec3  sph = p - vec3(sin(t)*0.6, 0.5, cos(t)*0.6);
    float d1  = sdSphere(sph, 0.45);
    float d2  = sdBox(p - vec3(0, 0.3, 0), vec3(0.35));
    float dFloor = p.y + 0.01;
    return min(smin(d1, d2, 0.2), dFloor);
}

// ── Сферичне трасування ─────────────────────────────────────────────────
float raymarch(vec3 ro, vec3 rd) {
    float t = 0.001;
    for (int i = 0; i < 128; i++) {
        float d = scene(ro + t * rd);
        if (d < 0.001) return t;
        if (t > 100.0) break;
        t += d;
    }
    return 100.0;
}

// ── Нормаль, AO, тіні ────────────────────────────────────────────
vec3 calcNormal(vec3 p) {
    const vec2 k = vec2(1, -1);
    return normalize(
        k.xyy*scene(p+k.xyy*0.001) + k.yyx*scene(p+k.yyx*0.001) +
        k.yxy*scene(p+k.yxy*0.001) + k.xxx*scene(p+k.xxx*0.001));
}

float softShadow(vec3 ro, vec3 rd) {
    float res = 1.0, t = 0.02;
    for (int i = 0; i < 64; i++) {
        float d = scene(ro + t*rd);
        res = min(res, 8.0*d/t);
        t  += clamp(d, 0.02, 0.2);
        if (res < 0.001 || t > 20.0) break;
    }
    return clamp(res, 0.0, 1.0);
}

float ao(vec3 p, vec3 n) {
    float occ = 0.0, w = 1.0;
    for (int i = 1; i <= 5; i++) {
        float s = 0.08 * float(i);
        occ += (s - scene(p + n*s)) * w; w *= 0.7;
    }
    return clamp(1.0 - 2.0*occ, 0.0, 1.0);
}

// ── Камера ─────────────────────────────────────────────────────────
mat3 lookAt(vec3 eye, vec3 cen, vec3 up) {
    vec3 f = normalize(cen - eye);
    vec3 r = normalize(cross(up, f));
    vec3 u = cross(f, r);
    return mat3(r, u, f);
}

// ── Головна функція ───────────────────────────────────────────────────────────
void main() {
    vec2  uv  = (gl_FragCoord.xy * 2.0 - uResolution) / uResolution.y;
    vec3  eye = vec3(3.0*sin(uTime*0.25), 2.0, 3.0*cos(uTime*0.25));
    mat3  cam = lookAt(eye, vec3(0), vec3(0, 1, 0));
    vec3  rd  = normalize(cam * vec3(uv, 1.5));

    float t   = raymarch(eye, rd);
    vec3  col = vec3(0.05, 0.08, 0.14); // небо

    if (t < 100.0) {
        vec3  p   = eye + t * rd;
        vec3  n   = calcNormal(p);
        vec3  lig = normalize(vec3(2, 5, 3));
        float dif = clamp(dot(n, lig), 0.0, 1.0);
        float sha = softShadow(p + n*0.01, lig);
        float occ = ao(p, n);

        // Колір підлоги в шахову клітинку
        vec3  alb = mix(vec3(0.9), vec3(0.6, 0.5, 0.8),
                      step(0.5, fract(p.y + 0.01)));
        if (p.y < 0.01)
          alb = mix(vec3(0.9), vec3(0.3),
                     mod(floor(p.x)+floor(p.z), 2.0));

        col = alb * (0.1 + 0.9 * dif * sha * occ);
    }

    // Гамма-корекція
    col = pow(clamp(col, 0.0, 1.0), vec3(0.4545));
    fragColor = vec4(col, 1.0);
}

8. Поглиблено: повторення області, камера, оптимізації

Нескінченне повторення області

Додавання єдиного виклику mod до позиції перед обчисленням SDF мозаїчно повторює сцену нескінченно майже за нульову вартість:

// Повторити p у 2D-сітці з періодом (gx, gz), зберігаючи вихідний y
vec3 repeat(vec3 p, float gx, float gz) {
    p.xz = mod(p.xz + vec2(gx, gz)*0.5, vec2(gx, gz)) - vec2(gx, gz)*0.5;
    return p;
}
// Використання: sdSphere(repeat(p, 2.0, 2.0), 0.4)

Орбітальна камера

// Орбітальна камера: рискання/тангаж із дельти миші, радіус із прокручування
vec3 orbitCamera(float yaw, float pitch, float radius) {
    return radius * vec3(
        cos(pitch) * sin(yaw),
        sin(pitch),
        cos(pitch) * cos(yaw)
    );
}

Поради щодо оптимізації

🌌 Симуляція галактики

Подивіться на GLSL у дії — тисячі зіркових частинок із прискоренням на GPU, відрендерених через фрагментні шейдери WebGL.

Відкрити симуляцію галактики →