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

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

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

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.

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