Ray Marching та знакові функції відстані — сферичне трасування у GLSL
Ray marching зі знаковими функціями відстані — це суто процедурна
техніка рендерингу: тут немає ані сіток, ані трикутників, ані
растеризації. Сцену задає єдина математична функція
float sdf(vec3 p),
що повертає відстань від будь-якої точки до найближчої поверхні. Цикл
сферичного трасування потім крокує вздовж кожного променя огляду,
сягаючи поверхонь із гарантованою коректністю та природною підтримкою
тіней, навколишнього затінення й фрактальних геометрій.
1. Що таке знакова функція відстані?
Знакова функція відстані (SDF) — це функція
d = sdf(p), визначена в усьому 3D-просторі. Вона повертає
знакову евклідову відстань від точки p до
найближчої поверхні фігури:
d < 0— точка всередині фігуриd = 0— точка на поверхніd > 0— точка зовні фігури
Ключова властивість, яку використовує сферичне трасування, — це
межа Ліпшиця: SDF сцени гарантує, що в будь-якій точці
p, що лежить поза всією геометрією, ми можемо безпечно
просунути промінь рівно на |sdf(p)|, не «перестрибнувши»
жодної поверхні.
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)|,
не «перестрибнувши» поверхню.
цикл до 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() —
тримайте його дешевим для частоти кадрів у реальному часі.
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)
);
}
Поради щодо оптимізації
- Обмежувальні об'єми для раннього виходу: оточіть витратні підсцени швидкою перевіркою SDF сфери. Пропустіть деталь, якщо обмежувальна SDF > кроку трасування.
- Рівень деталізації: зменшіть MAX_STEPS і збільшіть EPSILON для віддаленої геометрії — різниця непомітна за межами 10 одиниць сцени.
- Рендеринг у половинній роздільній здатності з масштабуванням: найефективніша окрема оптимізація для рендерингу SDF у реальному часі за вищих роздільних здатностей.
-
Надрелаксація: помножте крок
t += dна сталу 1,1–1,6. Швидше на відкритих сценах, але ризикує артефактами поблизу увігнутих країв.
🌌 Симуляція галактики
Подивіться на GLSL у дії — тисячі зіркових частинок із прискоренням на GPU, відрендерених через фрагментні шейдери WebGL.