DEV/MATH[ V0.0.1-ALPHA ]//articles/sdf

SDF и raymarching по-простому: от smin до мешей и обратно

Всем привет! Меня зовут Гриша Дядиченко, и я технический продюсер. Уже больше десяти лет работаю с компьютерной графикой, AR/VR и технологиями компьютерного зрения — в основном это заказная разработка и время от времени собственные прототипы, до которых дотягиваются руки в свободное время.

На этой неделе я писал в своём телеграм-канале три коротких поста про SDF — про то, в чём идея «полей расстояний», как две сферы сливаются в каплю через одну функцию smin, и про sphere tracing. И в комментариях, что предсказуемо, прилетели два вопроса: «А цена этого sdf(p) какая?» и «А что с мешами в SDF?» Хорошие вопросы. Честно сказать, без ответов на них вся история про «магию шейдеров за 60 строк» превращается в маркетинговый ролик, а не разбор.

Сталкивались ли вы с ситуацией, когда красивая демка с Shadertoy идеально летает на десктопе, а на телефоне выдаёт пять кадров в секунду — и непонятно, где именно она сломалась? Или с тем, что хочется добавить в игру жидкое золото, слизь или blob-эффект из аниме — а как — непонятно, потому что обычный меш такое не вытягивает, а городить кастомную физику жалко? А за тем и за другим стоит, собственно, одна штука — signed distance fields с raymarching'ом. И, как у любой техники, у этой есть свои условия применимости. С ними и разберёмся.

Чтож, давайте по порядку. В шести разделах: что такое SDF и зачем нужно «поле расстояний», как работает sphere tracing, как из двух сфер делается одна капля через smin, откуда берутся «почти бесплатные» soft shadows, чего реально стоит каждый вызов sdf(p) — и что происходит, когда вместо аналитической сферы мы пытаемся скормить SDF меш на пятьдесят тысяч треугольников. В каждом разделе — интерактив, который работает прямо в браузере (в том числе на телефоне), плюс рабочие сниппеты на GLSL.

Если интересна тема — добро пожаловать.

[больше интересного в телеграм]

Часть 1. Что такое SDF и зачем нужны «поля расстояний»

Начнём с определения, без него никуда. SDF, signed distance function — это функция f(p) → float. На вход — точка в пространстве, на выход — расстояние от этой точки до ближайшей поверхности. Со знаком: внутри объекта возвращается отрицательное число, снаружи — положительное, ровно на поверхности — ноль.

Сразу пример, на котором всё держится. Возьмём сферу радиуса r с центром в нуле. Расстояние от произвольной точки p до этой сферы — это длина вектора p минус радиус: length(p) - r. Если точка внутри сферы — length(p) < r, и результат отрицательный. Если снаружи — положительный.

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

Стандартный рендер vs SDF

Привычный взгляд на геометрию в играх: модель — это набор треугольников, рендер — это «попадает ли пиксель в какой-нибудь треугольник, и если попадает — какой ближе». Видеокарта десятилетиями вылизывалась именно под эту картину мира: пайплайн вершин, растеризация, z-buffer.

В SDF-мире всё перевёрнуто. Геометрия — это функция. Рендер — это не «попал ли пиксель в треугольник», а «насколько эта точка близка к поверхности». Чтобы построить мысленный мост: треугольный меш — это «список координат вершин на параболе». SDF — это y = x². И то, и другое описывает параболу, но в одном случае у вас точки, а во втором — уравнение. Точки нужно где-то хранить и куда-то посылать в шейдер. Уравнение можно вычислить в любой точке прямо на лету.

Из этого вырастает вся остальная история, в том числе — оба больших вопроса этой статьи. Если геометрия — функция, то её цена — цена вычисления функции, а не цена её хранения. И вот тут начинается интересное.

Базовые примитивы

В SDF, в отличие от трёхмерного моделирования в Maya, никто формулы для примитивов каждый раз не выводит — их выписали один раз и копируют. Стандарт — статья Inigo Quilez «3D SDFs». Там и сфера, и бокс, и тор, и капсула, и все варианты, какие могут понадобиться. Ниже — четыре, которые встретятся в этой статье.

Сфера радиуса r. Формула выше, повторять не буду.

Бокс с полуразмерами b (то есть бокс размера 2*b.x × 2*b.y × 2*b.z):

f(p)=max(pb,0)+min(max(px,py,pz)max(b),0)f(p) = \|\max(|p| - b, 0)\| + \min(\max(|p|_x, |p|_y, |p|_z) - \max(b), 0)
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 (то есть «бублик»):

f(p)=((px,pz)R, py)rf(p) = \|(\|(p_x, p_z)\| - R,\ p_y)\| - r
float sdTorus(vec3 p, vec2 t) { // t = vec2(R, r)
  vec2 q = vec2(length(p.xz) - t.x, p.y);
  return length(q) - t.y;
}

Здесь видно красоту записи: сначала проецируем точку на «осевую окружность» тора в плоскости XZ, потом расстояние до этой окружности в 3D — это просто length(q) - r, тор как «толстая окружность».

Плоскость, заданная нормалью n и смещением d от нуля:

float sdPlane(vec3 p, vec3 n, float d) {
  return dot(p, n) + d;
}

Знак dot(p, n) + d сразу даёт «по какую сторону от плоскости точка», и это и есть signed distance — без какой-либо дополнительной работы.

Что значит «поле расстояний» визуально

А почему вообще «поле»? Очень буквально: в каждой точке пространства у нас лежит конкретное число — расстояние до поверхности. Удобная аналогия — температура в комнате. Тоже поле: в каждой точке своё значение.

В двух измерениях это легко нарисовать. Возьмите плоскость, поставьте в её центр кружок. Теперь покрасьте каждую точку плоскости в цвет, который зависит от расстояния до кружка. Внутри кружка — синий с разной насыщенностью (чем глубже внутрь, тем темнее). Снаружи — красный (чем дальше — тем темнее). На самой границе — белый. Получится симметричная градиентная картинка, на которой граница кружка читается как «линия ноль».

Это и есть SDF в визуализации. Изолинии — линии равного расстояния — на такой картинке выглядят концентрическими окружностями. Для бокса они будут «обходить» углы, для тора — образуют интересный рисунок с двумя «глазами». В демке ниже можно покрутить параметры и посмотреть, как поле выглядит для разных примитивов.

[ DEMO 1.1 ]//

Поле расстояний (2D-сечение)

Окей. Поле расстояний у нас есть. Но как из этого получить картинку? Чтоб мы видели сферу, а не разноцветный градиент. Этим и займёмся в следующей части.

Часть 2. Sphere tracing: главный фокус всей техники

Итак, у нас есть SDF — функция, которая для любой точки возвращает расстояние до ближайшей поверхности. И есть луч: для каждого пикселя экрана мы пускаем его из камеры через этот пиксель в сцену. Задача — найти точку, в которой этот луч упирается в поверхность. Получим точку — получим пиксель: посчитаем нормаль, освещение, цвет.

Самый очевидный способ найти пересечение: идти вдоль луча маленькими шагами и на каждом шаге проверять, не попали ли мы внутрь объекта (то есть sdf(p) < 0). Звучит разумно. Давайте напишем.

Наивный код и его ловушка

// плохо: фиксированный шаг
for (int i = 0; i < 1000; i++) {
  if (sdf(p) < EPS) return p;
  p += dir * 0.01;
}

Загвоздка — в шаге, и хорошего значения для него попросту не существует. Сейчас покажу.

Возьмём шаг 0.01. Это значит, что если объект находится на расстоянии 10 единиц от камеры, мы сделаем тысячу шагов, прежде чем до него добраться. Каждый шаг — это вызов sdf(p), то есть честная функция расстояния со всеми её формулами. Тысяча вызовов на пиксель, миллион-два пикселей на экране — и шейдер уходит куда-то в район пяти кадров в секунду. Не вариант.

Хорошо, возьмём шаг побольше, скажем 0.1. Стало быстрее в десять раз — но появилась другая проблема. Если в сцене есть тонкий объект, например лезвие или провод толщиной меньше 0.1, луч может между двумя соседними шагами перескочить его насквозь — и мы получим артефакт «пиксель видит фон сквозь объект». На картинке это смотрится как дыры в геометрии.

Получается классический выбор без выбора: либо медленно и точно, либо быстро и с дырами. А хочется быстро и точно.

Ключевая идея

Собственно, в этом и весь фокус. Мы же сами сказали: SDF возвращает расстояние до ближайшей поверхности. То есть гарантирует: ближе этого расстояния от текущей точки никакой поверхности нет. А раз так — можно шагнуть ровно на это расстояние, и мы гарантированно ни во что не уткнёмся. Если бы что-то было ближе — функция бы вернула меньшее значение.

Это и называется sphere tracing. Метод предложил John C. Hart в 1996 году в статье «Sphere Tracing: A Geometric Method for the Antialiased Ray Tracing of Implicit Surfaces» (оригинал — в журнале The Visual Computer; современное практическое изложение лучше всего читается у Quilez в статье «Raymarching distance fields»). Название — потому что на каждом шаге метод как бы рисует невидимую сферу радиуса sdf(p) вокруг текущей точки и говорит: «внутри этой сферы поверхности нет, можно лететь до её края».

Правильный код

// хорошо: sphere tracing
for (int i = 0; i < 64; i++) {
  float d = sdf(p);
  if (d < EPS) return p;
  p += dir * d;  // ← шаг ровно SDF
}

Разница с наивным кодом — буквально одна строка. Вместо p += dir * 0.01 (фиксированный шаг) теперь p += dir * d (шаг равен SDF). И вместо тысячи итераций — 64.

Почему это работает

Близко к поверхности шаги маленькие — поле говорит «осторожно, рядом что-то есть». В пустом пространстве, далеко от любой геометрии, шаги большие — «лети спокойно, ничего на пути нет». Метод сам адаптирует размер шага под локальную плотность сцены, и это бесплатно.

Эмпирически — 30–60 итераций обычно хватает на всю сцену с разумной сложностью. Запомним эту цифру: количество вызовов sdf(p) на пиксель — это та метрика, от которой зависит производительность всего шейдера. Она обязательно вернётся в Разделе 5, когда мы будем считать честную цену картинки.

[ DEMO 2.1 ]//

Шаги sphere tracing (2D вид сбоку)

Когда sphere tracing ломается

Чтоб не выглядело так, будто метод универсальный — давайте честно про места, где он не работает.

Первое — Lipschitz violation. Sphere tracing предполагает, что SDF возвращает истинное расстояние, или хотя бы оценку снизу. Если ваша функция возвращает большее число, чем настоящее расстояние, луч может «перепрыгнуть» поверхность — потому что метод поверит вашему числу и шагнёт дальше, чем безопасно. Это случается, когда поверх SDF навешивают displacement (выдавливание по нормали через шум) или нелинейные деформации пространства. Лечится домножением шага на коэффициент меньше единицы (p += dir * d * 0.8) — буквально страховка «не доверяй полю на всю катушку». Цена — итераций становится больше.

Второе — касательные лучи. Если луч идёт почти параллельно поверхности, на каждом шаге sdf(p) будет маленьким — поверхность всё время рядом, но не пересекается. И мы будем шагать-шагать-шагать, ни во что не упираясь. Реальные сцены такие случаи дают часто: горизонт, скользящие лучи по полу, тангенциальные касания. Спасает hard cap — MAX_STEPS = 64 или 128, и если за это число итераций не нашли пересечение — рисуем фон. Решается просто, но если ваше железо посчитало 128 шагов на пиксель — это уже не «дёшево».

Окей, лучи пускать умеем. Поле описывать — тоже. В следующей части — самое интересное: как из этих двух кусочков собирать составные объекты, и как сделать так, чтобы две сферы превращались в одну каплю, а не в «два камня, лежащих друг в друге».

Часть 3. Boolean operations и smooth blending

Один примитив — это, конечно, красиво, но в реальных сценах объектов больше. И первая хорошая новость про SDF — составлять сцены из примитивов в этой парадигме до неприличия просто.

Constructive Solid Geometry на min и max

Хотим объединение двух объектов: точка принадлежит «единому объекту», если она внутри хоть одного из них. На языке SDF это значит «минимальное из двух расстояний». Если хоть одно из расстояний отрицательное (то есть точка внутри объекта) — min тоже будет отрицательным. То есть:

float sdUnion(float a, float b) { return min(a, b); }

Пересечение — точка принадлежит обоим объектам. То есть оба расстояния должны быть отрицательными. max от двух чисел даст отрицательное число, только когда оба отрицательные. Значит:

float sdIntersection(float a, float b) { return max(a, b); }

Вычитание — точка принадлежит первому объекту, но не второму. То есть a отрицательное, а b — положительное. Эквивалентно: a < 0 И -b < 0. Получаем max(a, -b):

float sdSubtraction(float a, float b) { return max(a, -b); }

Три операции, шесть строк кода — и у нас в руках constructive solid geometry. Цилиндр минус сфера — получили вмятину в металле. Бокс плюс сфера — получили пухлый кирпич с закруглением. Цилиндр пересекает бокс — отлили деталь сложной формы. И всё это без единого треугольника в файле сцены.

Проблема жёсткого стыка

С тремя операциями выше есть один косметический нюанс. Сам по себе min(a, b) — это «возьми что меньше», и в точке, где a и b ровно равны, функция резко переключается с одной на другую. Никакого плавного перехода между ними нет. Если поставить две сферы вплотную и сделать min, в месте их соприкосновения будет острая граница — как будто два камня лежат впритык.

А очень часто хочется не камня. Хочется ртути, слизи, жидкого золота, сливающейся капли воды, blob-эффекта из аниме. Чтоб граница между двумя объектами была не резкой, а гладко перетекала. Для этого нужен другой min — не «жёсткий», а с плавным стыком.

Polynomial smin

Эта формула — отдельная гордость демосцены. Её много где вывели и переписали, но канонический вид — у Inigo Quilez. Он сам разбирал в статье «smooth minimum» пять вариантов: polynomial, exponential, power, root и quadratic. Самый ходовой — polynomial smin: дешёвый, гладкий, без exp или log.

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);
}

Параметр k — ширина «шва». Если k = 0 — формула вырождается обратно в обычный min. Если k = 0.3 — на границе двух объектов появляется лёгкий перешеек, видно, что они «склеиваются». Если k = 1 — два соприкасающихся объекта почти полностью сливаются в одну общую каплю.

Геометрически это работает так. Когда a и b далеко друг от друга — функция почти ровно min(a, b). Когда они близко (разница меньше k) — она их «смешивает» через h, и добавляет коррекцию -k*h*(1-h), которая делает результат меньше, чем простой минимум. Меньше — значит «поверхность ближе», то есть объект как будто разрастается в зоне склейки. Это и даёт перешеек.

Из вариантов Quilez стоит упомянуть ещё exponential smin — он плавнее polynomial на больших значениях k, переход смотрится мягче, но внутри использует exp, который дороже на порядок. В playground'е ниже включил оба, чтоб можно было сравнить руками.

Domain operations: тратим строчку, получаем бесконечность

Поверх композиции через min/max есть ещё одна большая группа приёмов, которая часто оказывается важнее, чем сама CSG constructive solid geometry — построение объектов из примитивов через объединение, пересечение и вычитание. Это операции не над функциями, а над аргументом — то есть над точкой p, прежде чем мы скармливаем её в SDF.

Domain repetition. Хотим бесконечный массив одинаковых объектов? Хитрость в том, чтобы не плодить объекты, а сворачивать саму точку перед тем, как скармливать её в SDF.

Представьте, что пространство нарезано на одинаковые ячейки размера c, и в центре каждой лежит одна и та же сфера. Чтобы посчитать расстояние до ближайшей сферы — не важно, в какой именно ячейке мы находимся, — достаточно перевести p в локальные координаты её ячейки (то есть отсчитать смещение от её центра) и спросить про эту одну сферу.

p = mod(p + 0.5 * c, c) - 0.5 * c;
return sdSphere(p, r);

mod(p + c/2, c) - c/2 как раз и делает это «сворачивание»: берёт любую координату и оставляет от неё остаток в диапазоне [-c/2, c/2) — то есть смещение от центра ближайшей ячейки. На сцене получается решётка из тысячи сфер, а в памяти как лежала одна, так и лежит.

Важная оговорка: эта операция, строго говоря, ломает Lipschitz-свойство SDF на границах ячеек. На стыке двух ячеек поле может «перескочить», и sphere tracing промахнётся. На практике, если объекты внутри ячейки заметно меньше самой ячейки — всё работает. Если объекты впритык — нужны более аккуратные варианты (Quilez разбирает их в отдельной статье про domain repetition).

Domain warping — искажение аргумента шумом перед вызовом SDF:

p += 0.1 * vec3(noise3(p), noise3(p + 17.0), noise3(p + 31.0));
return sdSphere(p, r);

Поверхность начинает «дышать», превращается из идеальной сферы в каплю воска с неровностями. Та же оговорка про Lipschitz, ещё жёстче (нелинейная деформация). Лечится коэффициентом «недоверия» к полю.

Симметрия — почти бесплатно через abs:

p.x = abs(p.x);  // ← зеркалим относительно YZ-плоскости
return sdEar(p);

Один глаз превращается в два глаза, одно ухо — в два уха. Удваивает геометрию ровно за одно умножение.

Где это реально применяется в продакшене

Тут стоит сделать большую оговорку, чтоб не строить иллюзий. SDF не пришёл и не вытеснил полигоны как основной формат геометрии. И, скорее всего, не вытеснит — об этом подробно в Разделах 5 и 6. Но у техники есть очень чёткая ниша, в которой ей нет альтернатив.

Ниша эта — всё, что меняет форму в реалтайме от ввода игрока. Боссы-слизни, которые делятся на части и сливаются обратно. Кровь и слизь, динамически стекающиеся в лужи под ногами. Терраморфинг от взрывов, налипание снега, тающий лёд по краю. Жгуты энергии из руки игрока к цели, заклинания, магические сферы. Всё это процедурное, всё реагирует на конкретные действия игрока, всё нельзя запечь в alembic-кэш заранее.

В AAA-катсценах, где сценарий жёстко зафиксирован, жидкость и сложные эффекты чаще всё-таки пекут симуляцией — Houdini посчитает её красивее любого реалтайм-шейдера, и можно не тратить GPU в рантайме на то, что и так известно. Но как только что-то зависит от того, что игрок делает прямо сейчас, — smin и его братья становятся практически безальтернативны. Запечённую симуляцию тут просто нечем подсунуть.

[ DEMO 3.1 ]//

smin: две сферы и параметр k

Хорошо. Поверхность мы строим, лучи пускаем. Дальше — освещение, потому что без него любая, даже самая сложная, геометрия выглядит как плоский силуэт.

Часть 4. Нормали через градиент и почти бесплатные soft shadows

Чтобы посчитать освещение по Lambert, нужна нормаль к поверхности. В растеризации мы её просто берём из вершинных данных и интерполируем по треугольнику. А что в SDF — у нас же вообще нет ни вершин, ни треугольников. Откуда брать?

Нормаль через градиент

Тут есть очень красивый ответ. Нормаль к поверхности SDF в точке p — это нормированный градиент функции расстояния в этой точке:

n = normalize(∇f(p))

Логика простая. Градиент — это направление, в котором функция растёт быстрее всего. Функция расстояния растёт быстрее всего перпендикулярно поверхности (двигаясь по нормали мы уходим от поверхности на максимальной скорости — каждый шаг на единицу даёт ровно единицу к расстоянию). Значит, направление градиента совпадает с нормалью.

Аналитически градиент считать неудобно — формулы примитивов разные, операции min/max/smin его не упрощают. Зато можно посчитать численно, через finite differences: четыре или шесть сэмплов SDF в окрестности точки. Канонический вариант — тетраэдрический (он же разобран у Quilez в статье «Normals for an SDF»):

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

Тут используется приём с четырьмя вершинами тетраэдра — он даёт оценку градиента в 3D всего за четыре вызова SDF, в отличие от шести при «по-осевом» варианте. Запомним эту цифру: плюс 4 вызова sdf(p) на каждый пиксель, в котором мы попали в поверхность. Понадобится в следующей части.

Освещение Lambert — без сюрпризов

Дальше — буквально как в обычной растеризации. Скалярное произведение нормали на направление к свету, обрезка ниже нуля:

float lambert = max(0.0, dot(n, lightDir));

Никаких отдельных формул для SDF тут нет — Lambert это Lambert, ему всё равно, как мы получили нормаль.

Soft shadows через минимум поля

А вот где SDF реально вылезает вперёд относительно растеризации — это в тенях. В обычном пайплайне мягкие тени стоят дорого: нужны shadow maps с PCF-фильтрацией, или contact-hardening shadows с переменным размером ядра, или вообще ray-traced shadows на современных GPU. Куча работы, и всё это поверх и так непростой техники.

В SDF мягкие тени — это шесть строчек, и снова идея из той же связки. Канонический разбор — у Quilez в статье «Free penumbra shadows for raymarching distance fields».

float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
  float res = 1.0;
  float t = mint;
  for (int i = 0; i < 32 && t < maxt; i++) {
    float h = sdf(ro + rd * t);
    if (h < 0.001) return 0.0;
    res = min(res, k * h / t);
    t += h;
  }
  return res;
}

Идея в том, что мы пускаем луч от поверхности к источнику света — обычным sphere tracing, без всяких хитростей. Но вместо того, чтобы просто вернуть «попал/не попал» (0.0 или 1.0), мы по пути отслеживаем минимальное отношение sdf(p) / t вдоль луча. Это число — «насколько близко луч прошёл к препятствию относительно пройденного расстояния». Чем ближе — тем темнее тень.

Множитель k управляет жёсткостью: k = 8 — мягкие, размытые тени с длинными переходами; k = 64 — почти резкие. Это тот же бесплатный параметр, что и k в smin — крутится прямо в реалтайме.

Стоимость — фиксированные 32 итерации цикла, то есть +32 вызова sdf(p) на каждый пиксель. И снова, это число нам ещё пригодится.

Почему в растеризации так нельзя

Это важно проговорить, потому что иначе непонятно, в чём фокус. В растеризации у нас в каждой точке экрана есть только то, что нарисовали в этот пиксель: его глубина, нормаль, цвет. Ничего о соседней геометрии в этот момент мы не знаем — она «не наша» с точки зрения пайплайна. Поэтому для теней приходится рендерить сцену отдельно из позиции источника света (shadow map), а потом из камеры спрашивать «а попал ли этот пиксель в карту теней». Для мягких теней — спрашивать с фильтром по нескольким соседним сэмплам карты.

В SDF другая модель мира. Функция sdf(p) глобальна: она знает про всю сцену из любой точки. Поэтому мы можем «по пути к источнику света» проверить, насколько мы прошли близко к любому объекту, — это просто несколько сэмплов одной функции. Никаких отдельных проходов, никаких текстур карт теней. Это магия не производительности, а программной модели — но именно она и даёт ту самую короткую запись.

AO — почти то же самое

В довесок — ambient occlusion. Хотим, чтобы углы и впадины были темнее. В SDF это решается похожим трюком: пускаем несколько коротких лучей от поверхности в направлении нормали и смотрим, как быстро sdf(p) начинает уменьшаться. Если мы в углу — поверхности близко, AO тёмный. Если на открытом месте — AO светлый.

Хватает 5–8 сэмплов на пиксель, формула простая, реализация — десять строк. Это ещё +5–8 вызовов sdf(p) на пиксель.

[ DEMO 4.1 ]//

Hard vs soft shadows

А что в итоге со стоимостью пикселя

И вот теперь честное наблюдение, которое я просил запомнить во всех предыдущих частях. Посчитаем, что у нас уже выходит на один пиксель в простой SDF-сцене с тенями и AO:

  • Sphere tracing до первого попадания: 30–60 вызовов sdf(p).
  • Нормаль через тетраэдрический градиент: +4 вызова.
  • Soft shadow к одному источнику света: +32 вызова.
  • Ambient occlusion: +5–8 вызовов.

Итого: примерно 70–100 вызовов sdf(p) на каждый пиксель. И это для одного источника света и без эффектов посложнее. Добавите второй источник — плюс ещё 32 вызова на soft shadow. Включите отражения или преломления — там каждый отскок луча запускает весь цикл заново, и счёт умножается кратно.

Тут самое время вернуться к вопросу из комментариев: «а цена этого sdf(p) какая?» Идём считать.

Часть 5. Цена sdf(p): отвечаем на вопрос из комментариев

Что значит «один вызов sdf(p)»

Начнём с самого мелкого — стоимости одного вычисления функции расстояния для одного примитива. Это, действительно, копейки.

  • Сфера: одно вычитание векторов, length (это два умножения и sqrt), вычитание скаляра. Грубо — пять-шесть ALU-операций.
  • Бокс: abs, вычитание вектора, max и min по компонентам, length. Грубо в полтора-два раза дороже сферы.
  • Тор: два length подряд, проекция координат. Сопоставимо с боксом.
  • Плоскость: одно скалярное произведение. Совсем копейка.

На GPU 2020-х годов один такой вызов занимает доли наносекунды на ядро. Сами формулы дешёвые, и если бы вся история ограничивалась одним вызовом — мы бы давно отказались от полигонов.

Но в этом «бы» зарыто всё. Полная цена техники складывается не из одного вызова, а из их количества.

Стоимость композитной сцены

Что происходит, когда в сцене не один примитив, а десять? Каждый вызов sdf(p) теперь — это вычисление всех десяти SDF-функций, и потом ещё min (или smin) поверх них:

float sdf(vec3 p) {
  float d = 1e10;
  d = min(d, sdSphere(p - pos1, r1));
  d = min(d, sdSphere(p - pos2, r2));
  d = min(d, sdBox(p - pos3, b3));
  // ... ещё семь штук
  return d;
}

Один вызов теперь стоит в десять раз больше, чем для одной сферы. Это и есть O(N) — линейная зависимость от количества примитивов. Десять примитивов в десять раз дороже одного. Сто — в сто раз.

Если вместо обычного min стоит polynomial smin — каждая «склейка» примерно вдвое дороже из-за clamp и mix. Если exponential smin — там внутри exp, который ещё на порядок дороже остальных арифметических операций. То есть тридцать примитивов через exponential smin — это уже не лёгкий шейдер.

Складываем бюджет пикселя

Теперь — табличка, к которой мы шли все предыдущие разделы. Допустим, в сцене десять примитивов через polynomial smin. Один вызов sdf(p) стоит примерно столько же, сколько десять отдельных вызовов простых сфер. Возьмём 50 ALU за такой составной вызов — это очень примерная оценка, но порядок верный.

ЭтапВызовов sdf(p)ALU при N = 10
Sphere tracing до поверхности30–601500–3000
Нормаль (тетраэдрический градиент)4200
Soft shadow (32 итерации к свету)321600
Ambient occlusion (6 коротких лучей)6300
Итого на пиксель70–1003500–5000

На FullHD-экране это два миллиона пикселей. При 60 FPS — десятки миллиардов ALU-операций в секунду только на distance evaluation. Точные цифры зависят от железа, но порядок такой: для сцены с composing и тенями, на mid-range десктопном GPU 2020-х годов, чистый SDF-рендер съедает примерно столько же бюджета, сколько целый растеризационный пайплайн AAA-игры на том же железе. И это при том, что в нашей сцене десять объектов, а в AAA-игре — миллионы треугольников.

Вот почему демки Inigo Quilez на Shadertoy — это обычно 20–40 примитивов в кадре, а не четыреста. И почему его персонажи в «Happy Jumping» — это компактная композиция, а не детальный меш. Бюджет упирается в количество примитивов умноженное на количество вызовов SDF, и эта функция растёт быстро.

Что с этим делают (стандартные оптимизации)

Если вы решили всё-таки делать что-то на SDF в продакшене, есть стандартный набор приёмов, который применяют все.

Bounding spheres. Группы примитивов оборачиваются в общую большую сферу. На каждом шаге sphere tracing сначала проверяется расстояние до bounding sphere — если оно большое (мимо группы), весь её внутренний SDF не считается. Алгоритмически это превращает O(N) в что-то ближе к O(log N) для разреженных сцен.

Запекание в 3D-текстуру. Раз посчитанный SDF можно сохранить в volume-текстуре и потом просто сэмплировать. Trilinear sample стоит O(1) — он не зависит ни от количества примитивов в исходной сцене, ни от их сложности. Это ключевой приём для не-аналитической геометрии, и про него — весь следующий раздел.

Cone tracing. Вместо одного луча на пиксель — один «конус» на группу соседних пикселей. Уменьшает количество шагов sphere tracing за счёт меньшей точности. В реальности — это снижение качества теней и AO в обмен на скорость.

Локальные SDF. Сцену режут на пространственные ячейки — что-то вроде кубической сетки в воздухе. Для каждой ячейки заранее запоминаем «короткий список» — какие из объектов сцены реально близко к ней, а какие далеко. Когда луч проходит через ячейку, sdf(p) пересчитывается только по её короткому списку, а не по всем 100 объектам сцены. Та же идея, что и BVH в обычном рейтрейсинге — в серьёзных SDF-движках без этого приёма никак.

MAX_STEPS как hard cap. 64 или 128 шагов — потолок, после которого мы сдаёмся и возвращаем фон. Без этого касательные лучи могут вытащить кадр в десятки тысяч итераций на пиксель.

Прямой ответ на вопрос

Возвращаемся к комментарию: «А цена этого sdf(p) какая?»

Сам sdf(p) для одного отдельного примитива — действительно копейки, пять-десять ALU на современном GPU. Но в реальной сцене с композицией и освещением sdf(p) вызывается 70–100 раз на каждый пиксель (sphere tracing + нормаль + soft shadows + AO), и стоимость каждого такого вызова растёт линейно с количеством примитивов в сцене. Поэтому на Shadertoy лучшие шейдеры — это композиции из 20–40 SDF, а не из четырёхсот. И поэтому в реальных играх SDF используют как локальный эффект (одна жидкость, один blob, один заклинательный жгут), а не как способ нарисовать всю сцену. Это и есть та цена, которая делает технику нишевой, а не универсальной.

[ DEMO 5.1 ]//

Профилировщик: что реально стоит SDF

Хорошо. С аналитическими примитивами разобрались — для них SDF получается дорогой, но всё-таки реальный. А что, если геометрия — это не сфера и не тор, а импортированный меш персонажа на пятьдесят тысяч треугольников? Можно ли его засунуть в SDF? Сколько это будет стоить? Это второй большой вопрос из комментариев, и про него — последний большой раздел.

Часть 6. Не-аналитическая геометрия: меш → SDF и обратно

Постановка проблемы

Всё, что мы делали до сих пор, держалось на одном допущении: SDF для каждого примитива у нас есть в виде короткой формулы. Сфера — length(p) - r. Бокс — три строчки. Тор — пять. Эти формулы помещаются в шейдер, считаются за наносекунды, и всё работает.

А что с импортированным мешем? Допустим, у вас в проекте лежит персонаж — пятьдесят тысяч треугольников, экспортировано из Maya. Формулы для него нет. Есть массив вершин, массив индексов треугольников, нормали, UV. Какой у этого SDF?

Прямой ответ: SDF меша в точке p — это минимальное расстояние от точки до любого треугольника меша. Формально записать можно:

sdf(p) = min over all triangles T: distance(p, T)

Считается это так: для каждого треугольника находим ближайшую к p точку (это или вершина, или точка на ребре, или точка внутри треугольника — три случая), берём расстояние до неё, берём минимум по всем треугольникам. Алгоритмически — O(N), где N — количество треугольников.

И вот тут начинается катастрофа. Помним из предыдущего раздела, что один пиксель требует 70–100 вызовов sdf(p). Помним, что в кадре два миллиона пикселей. Помним, что на каждый вызов нужно пройти по всем пятидесяти тысячам треугольников. Перемножаем — получаем порядок десять-двадцать триллионов операций на кадр. Это вообще не запустится в realtime ни на одном железе, которое можно купить.

Поэтому в продакшене в лоб никто так не делает. И поэтому история не заканчивается тут, а становится интереснее.

Baking SDF в 3D-текстуру

Главный приём для не-аналитической геометрии — запекание. Идея простая: SDF для меша считается один раз, заранее, в редакторе, на регулярной трёхмерной сетке. Результат сохраняется как 3D-текстура — на каждый воксель приходится одно число (расстояние со знаком). Это используется в UE5 для distance field AO и Lumen, в Dreams для всей геометрии мира, в Claybook для пластилиновой физики. В рантайме sdf(p) становится не вычислением миллиона треугольников, а одной операцией:

float sdf(vec3 p) {
  return texture(sdfVolume, p * volumeScale + volumeOffset).r;
}

Trilinear sampling трёхмерной текстуры — это аппаратно ускоренная операция на любой современной GPU. Стоимость — O(1), не зависит ни от количества треугольников исходного меша, ни от его топологии. Качество — зависит от разрешения voxel-сетки.

Это и есть то, что сделало SDF применимым к реальной геометрии в продакшене. Без запекания техника не работала бы для мешей в принципе. С запеканием — работает, но плата за это — память.

Цена baking'а

Память растёт кубически от разрешения. Это математика, против которой не попрёшь:

  • 64³ (262 144 вокселей × 2 байт fp16) = 512 КБ на объект. Подходит для болванки или удалённого LOD.
  • 128³ = 4 МБ на объект. Хороший компромисс для крупных объектов сцены.
  • 256³ = 32 МБ на объект. Для героя крупным планом.
  • 512³ = 256 МБ на объект. Уже не реалистично хранить такое в проекте, кроме особых случаев.

Видно, что один скачок разрешения в два раза — это умножение расходов на память на восемь. Поэтому в реальных проектах разумное разрешение — 64³ для большинства объектов и 128³–256³ для тех, где деталь важна.

С качеством та же история: на малом разрешении теряются мелкие детали. Уши у персонажа становятся бесформенными. Пальцы сливаются. Острые углы скругляются. На 64³ от меша остаётся «болванка», по которой ещё можно узнать силуэт. На 256³ — большинство деталей сохраняется, но мелкая фактура (швы на одежде, кончики волос) всё равно потеряны. Это не «такой же меш, только в SDF» — это упрощённая аппроксимация.

Время на запекание: для меша на 50 тысяч треугольников в разрешении 128³ — это секунды, может быть, десятки секунд на CPU; миллисекунды на GPU compute. Делается один раз при импорте ассета в редактор, в рантайме уже готовая текстура.

Sparse и иерархические структуры

Хранить полный 256³ volume для каждого объекта — расточительно. Большинство вокселей далеко от поверхности, и хранить в них точные расстояния не нужно: для sphere tracing нам нужна точность только рядом с поверхностью, а в пустых регионах достаточно «здесь поверхности нет, лети дальше».

Из этого наблюдения вырастает целое семейство приёмов:

Sparse Voxel Octree. Сцена разбивается на октодерево, и детальное разрешение хранится только в тех узлах, где оно нужно — около поверхностей. Пустые регионы — один узел верхнего уровня с грубым «здесь ничего нет». Глубина дерева логарифмическая, объём памяти линейно зависит от площади поверхностей, а не от объёма сцены. Это позволило студии Media Molecule в игре Dreams влезть со всей пользовательской геометрией в восемь гигабайт оперативной памяти PS4 — про это есть отдельный доклад Alex Evans «Learning From Failure» на курсе Advances in Real-Time Rendering на SIGGRAPH 2015.

Hierarchical bricks. Похоже на SVO, но вместо узлов дерева — «кирпичи» фиксированного размера (например, 8×8×8 вокселей), которые подгружаются и выгружаются по мере нужды. Используется в больших открытых мирах, где вся геометрия в память не влезает в принципе.

BVH над треугольниками. Если запекать не хочется (например, геометрия деформируется и таблицу пришлось бы пересчитывать каждый кадр), можно построить BVH над треугольниками меша и делать distance query за O(log N_triangles). Это не realtime для каждого пикселя, но позволяет посчитать SDF на лету в редких точках — например, для физических запросов «куда поставить ногу».

Гибриды в продакшене — кто и как реально это применяет

Тут — три истории про то, как разные команды решили задачу «сделать SDF на не-аналитической геометрии», и каждая решила её по-своему. Все упомянутые техники подробно разбираются в публичных докладах разработчиков на SIGGRAPH и других конференциях — конкретные ссылки в эпилоге.

Dreams — целиком на SDF. Студия Media Molecule, PS4-эксклюзив, доклад Alex Evans «Learning From Failure» на SIGGRAPH 2015. Это, наверное, самый радикальный пример: вся геометрия пользовательских ассетов в игре хранится не как меши, а как иерархические SDF в SVO. Рендер — sphere tracing по этому дереву. Заплачено за это ограничениями арт-стиля: в Dreams нет скелетной анимации в обычном смысле (фигуры «анимируются» через деформацию своих SDF), нет тонких деталей (отсюда характерный «пластилиновый» вид всех созданных пользователями моделей), нет тонких рёбер и острых углов. И это прямой ответ на вопрос «а что с мешами в SDF?»: Dreams не оптимизировала SDF до уровня меша — она ограничила арт-стиль до уровня, на котором SDF тянет.

Claybook — пластилин по принципу. Sebastian Aaltonen, маленькая команда. Вся игра построена на SDF: уровни хранятся как voxel grid с distance values, физика реализована через CSG-операции над SDF (то есть мяч из теста буквально объединяется и разделяется через min и max), рендер — sphere tracing. Тут, как и в Dreams, арт-стиль изначально подогнан под формат: пластилиновая стилистика делает мягкие, скругленные формы плюсом, а не ограничением.

UE5 Lumen / mesh distance fields. Технология Epic Games, разбирается в их официальной документации по Mesh Distance Fields. Здесь подход самый прагматичный из четырёх: каждый меш в проекте при импорте автоматически запекается в свой SDF-volume (mesh distance field, обычно 64³–256³ в зависимости от настроек). Lumen использует эти distance fields для приближённых вычислений ambient occlusion, soft shadows и indirect lighting — но не для прямого рендера геометрии. Геометрия по-прежнему рисуется обычным растеризационным пайплайном через треугольники. SDF тут — слой освещения, не слой геометрии. По сути, это и есть та самая «локальная роль для SDF», которую применяют в большинстве современных AAA-движков.

Прямой ответ на вопрос

Возвращаемся ко второму комментарию: «А что с мешами в SDF?»

В лоб — да, проблема реальная. Если попытаться считать SDF меша как минимум расстояний до всех треугольников на каждом вызове, упрётесь не в просадку фпс, а в «не запускается вообще». Поэтому в продакшене никто в лоб этого не делает. Меш запекают в 3D-текстуру (от полумегабайта до десятков мегабайт на объект), и sdf(p) становится trilinear sample — O(1), не зависит от треугольников. Но это перемещает проблему производительности в проблему памяти и качества: 128³ — грубые контуры, 512³ — детально, но 256 МБ на один объект. Поэтому SDF для меша в реалтайме делает смысл ровно в трёх ситуациях. Первая — мешей мало, но они большие, и память не главное ограничение (mesh distance fields в UE5 для крупной статической геометрии). Вторая — запечённый SDF используется как локальный слой поверх обычной растеризации (Lumen для AO и soft shadows). Третья — вы готовы под формат подстроить арт-стиль и принять характерные ограничения (Dreams, Claybook).

[ DEMO 6.1 ]//

Меш → SDF: ресурс vs качество

Чтож, картина в общих чертах понятна. Сведём всё в одну таблицу и обсудим, куда копать дальше.

Эпилог: где SDF реально живёт, и куда копать дальше

По сути, в каждой части мы по чуть-чуть отвечали на один и тот же вопрос: «А когда это вообще применять?» И из всего разбора складывается понятная картинка. Соберём её в одну таблицу.

Сводная таблица

ПрименениеПодходСтоимостьВидно в
Локальный VFX (blob, слизь, лужа, жгут энергии)smin + локальный sphere tracing поверх обычного рендерасредняя, и только в кадре эффектабольшинство VFX-тяжёлых игр
Освещение поверх растеризации (AO, soft shadows, GI)baked mesh distance fields, sampled из растеризациинизкая per-frame, высокая по памятиUE5 Lumen, distance field AO
Полный рендер сцены через SDFhierarchical SVO + sphere tracing, ограниченный арт-стильочень высокая, требует подстройки контентаDreams, Claybook
Шрифты и иконки в UI2D SDF / MSDF, текстуры низкого разрешения дают резкие края на любом масштабекопеечнаявезде, где есть масштабируемый текст
Voxel-физика, collision queriesdistance queries в baked volumeнизкаяUE5 Chaos, инструменты типа Houdini
AI navigation, path querieseuclidean distance transform на сеткенизкаяRTS, открытые миры

Главный вывод: SDF — это не замена полигонам, а отдельный инструмент под рукой. Везде, где он используется в продакшене, он либо локальный (один эффект, один объект), либо запечённый (volume-текстура, не runtime composing), либо сцена изначально проектируется под его ограничения. Универсального «всё на SDF» в коммерческих проектах практически нет — Dreams и Claybook это исключения, и оба продавали скорее свой характерный арт-стиль, чем технологию как таковую.

Что почитать и посмотреть дальше

Я сознательно не лез в детали реализаций — если хочется глубже, ниже список первоисточников.

  • Inigo Quilez articlesiquilezles.org/articles. Это, без преувеличения, библия SDF. 3D SDFs, smooth minimum (с пятью вариантами smin), raymarching distance fields, normals, soft shadows, domain repetition — всё там, с интерактивными примерами, рабочим кодом и аккуратным разбором каждой формулы.
  • John C. Hart, «Sphere Tracing: A Geometric Method for the Antialiased Ray Tracing of Implicit Surfaces», 1996 — оригинальный пейпер, в котором собственно и предложен sphere tracing как метод. Журнальная версия — в The Visual Computer, гугл-академия находит по названию.
  • Alex Evans, «Learning From Failure», SIGGRAPH 2015 — главный доклад про то, как сделали Dreams, на курсе Advances in Real-Time Rendering. Очень рекомендую целиком: это редкая публикация, где разработчик честно рассказывает про путь техники от прототипа до финального продакшена.
  • Sebastian Aaltonen Claybook talks — доступны как разрозненные доклады и треды в твиттере; ищется по запросу «Aaltonen Claybook SIGGRAPH». Особенно интересно, как маленькая команда вытянула realtime SDF на консолях.
  • Chris Green, «Improved Alpha-Tested Magnification for Vector Textures and Special Effects», SIGGRAPH 2007 — оригинальная работа Valve по 2D SDF для шрифтов. Это материал, с которого началось «SDF в массы» — задолго до 3D-рендеринга через распределённые функции.
  • UE5 Mesh Distance Fieldsофициальная документация Epic Games. Подробно про то, как Lumen использует запечённые SDF для AO, soft shadows и indirect lighting.
  • Shadertoyshadertoy.com. Если хочется не теории, а живых шейдеров с кодом — это первое место, куда стоит зайти. Авторы, чьи работы стоит посмотреть прицельно: iq (тот же Inigo Quilez), Flockaroo, Kali. У каждого десятки разобранных сцен с открытым исходником.

Что осталось за скобками

Я сознательно не лез в несколько больших тем, иначе статья превратилась бы в книжку. Они, тем не менее, существуют и заслуживают отдельных разборов:

  • Полноценный path tracing по SDF — то есть не просто sphere tracing к первому попаданию, а отскоки лучей, преломления, full GI через distance fields. Технически возможно, но цена снова кратно растёт.
  • Volumetric SDF — distance field для не surface, а для объёма. Туман, облака, дым. Принципы похожи, но геометрия совсем другая.
  • Differentiable и neural SDF — NeRF, DeepSDF, неявные нейросетевые представления геометрии. Горячая исследовательская тема последних лет, на стыке графики и ML.
  • MSDF для UI и шрифтов — multi-channel SDF, который позволяет хранить угловые детали с малым разрешением. Стандарт де-факто для масштабируемого текста в современных UI.

Заключение

Надеюсь, статья была полезна. SDF — это техника, которая в массовом сознании немного перехвалена и одновременно недохвалена. Истина, как обычно, между этими двумя позициями. Это инструмент с очень чёткой нишей.

Если у вас есть свой кейс с SDF в продакшене или просто интересные находки — буду рад услышать в комментариях. А если хочется ещё разборов про математику в геймдеве — заходите в телеграм-канал.

[больше интересного в телеграм]
// нужна помощь в проекте?

Консультации и техническое сопровождение

Геймдев, графика, AR/VR и computer vision — с математикой как ядром. Разбор вашей задачи и предложение по формату — бесплатно.

Оставить заявку →