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);
}
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, не потрібна.