Привет, меня зовут Герман, я занимаюсь С++ разработкой анимаций и графического движка в Константе.

Введение

Если ты сейчас здесь, скорее всего, тебе интересна компьютерная графика и фотореалистичный рендеринг. В этой статье я постарался рассказать об основных понятиях и объяснить базовые принципы трассировки лучей. Если внимательно ее прочитать, в конце можно получить правдоподобную фотографию мыльного пузыря и не только.

рис. 1 - Рендеринг мыльного пузыря
рис. 1 - Рендеринг мыльного пузыря
рис. 2 - Рендеринг черный дыры "Gargantua" из к/ф "Interstellar"
рис. 2 - Рендеринг черный дыры "Gargantua" из к/ф "Interstellar"

Что такое трассировка лучей

Я предполагаю, что мои читатели уже знакомы с понятиями вектора, матрицы, системы координат и имеют базовое представление о графическом пайплайне и шейдерах GLSL. Мы будем заниматься трассировкой лучей, поэтому для начала хорошо бы понять, что это за зверь. Итак, трассировка лучей - это набор алгоритмов и инструментов в компьютерной графике, позволяющий получать реалистичные рендеры. Название метода говорит само за себя: мы берем источник света и следим за тем, как испускаемые им лучи взаимодействуют с окружающей средой (отражаются и преломляются) перед тем, как быть воспринятыми человеческим глазом.

рис. 3 - Распространение света от источника к наблюдателю
рис. 3 - Распространение света от источника к наблюдателю

При этом далеко не все лучи достигают наблюдателя. При программировании такого алгоритма трассировки лучей, который еще называется "прямой", нам придется выпускать очень много лучей для получения изображения на экране. Посмотрим на рисунок 4: в первые секунды рендера большая часть лучей не достигла наблюдателя, из-за чего наблюдается картина с черными пятнами на изображении. Спустя же некоторое время, основная часть лучей, составляющих кадр сцены, достигает наблюдателя (рис. 5), и мы получаем итоговый рендер. Проблема в том, что прямой алгоритм, хотя и лучше описывает процесс распространения света, достаточно трудоемкий, и его не получится использовать для графики в реальном времени на большинстве современных систем. Но у нас есть решение этой проблемы: а что, если мы будем выпускать лучи не из источника света, а от наблюдателя, т.е. рассматривать только те лучи, которые нас достигли. Тогда нам не придется тратить лишнее время на "ожидание" прихода луча к наблюдателю. Такой алгоритм называется маршевым методом или обратной трассировкой лучей.

рис. 4 - Первая секунда рендера кадра прямой трассировкой лучей
рис. 4 - Первая секунда рендера кадра прямой трассировкой лучей
рис. 5 - Рендер кадра прямой трассировкой лучей спустя 10 секунд
рис. 5 - Рендер кадра прямой трассировкой лучей спустя 10 секунд

Маршевый метод или обратная трассировка лучей

Прежде чем говорить о самом алгоритме, нам нужно рассмотреть очень важный объект: знаковую функцию расстояния (Signed Distance Function, далее SDF). Она возвращает кратчайшее расстояние между заданной точкой в пространстве и некоторой поверхностью. Знак возвращаемого значения указывает, находится ли точка внутри этой поверхности или снаружи (отсюда название "функция расстояния со знаком"). Давайте покажу на примере.

Пример

В качестве примера рассмотрим SDF для сферы с радиусомRи центром в точке C = (x_0, y_0, z_0). Тогда расстояние от точки P = (x, y, z)до сферы будет определяться следующим образом:

\ f(x, y, z) = \sqrt{(x - x_0)^2 + (y -y_0)^2 + (z-z_0)^2} - R \

Главной особенностью SDF является то, что по знаку значения функции в точке в пространстве сцены можно понять расположение этой точки относительно объекта (сфера, куб, плоскость и тп), расстояния до которого возвращает SDF. Возьмем случайную точку P = (x, y, z), если значение SDF(P) > 0, то точка лежит вне объекта, если же SDF(P) < 0, то точка находится внутри объекта и, наконец, если SDF(P) = 0, точка лежит в точности на поверхности объекта. Заметим, что в реальных вычислениях можно считать, что точка лежит на поверхности объекта если значение |SDF| < \varepsilon, т.е. достаточно мало.

Так как дальше мы будем оперировать векторами, нам будут нужны векторные варианты записи формул для нахождения расстояний. В качестве примера векторный вид формулы SDF для сферы будет выглядеть так:

\begin{equation}     f(\vec{P}) = ||\vec{P} - \vec{C}|| - R \end{equation}

На языке glsl это запишется так:

float sphereSDF(vec3 p) {
    return length(p) - 1.0;
}

Функции расстояния для других объектов вы можете посмотреть здесь.

Теперь мы можем приступить к самому алгоритму обратной трассировки лучей. Прежде всего у нас есть плоскость экрана, которая состоит из пикселей. Давайте определим положение взгляда (камеры), тогда изображение, которое мы хотим получить, будет проецироваться на сетку (рис. 6), причем каждый узел сетки совпадает с пикселем выходного изображения. Таким образом, мы выпускаем лучи через каждый пиксель изображения и, если луч во что-то врезался (т.е. оказался на поверхности объекта, т.е. значение |SDF| < \varepsilon), красим этот пиксель в цвет объекта.

Прежде всего мы должны определить направление лучей для трассировки. Для этого воспользуемся значением координаты пикселя изображения (gl_FragCoord). Векторы, образованные позицией взгляда и координатой конкретного пикселя (уже в нормализованных координатах), мы будет умножать на матрицы view и projection о которых можно более подробно прочитать здесь.

В обратной трассировке вся сцена определяется в терминах функции расстояния со знаком. Чтобы найти пересечение между лучом обзора и объектами на сцене, мы начинаем из стартовой позиции, совпадающей с положением взгляда в направлении вычисленного выше луча. На каждом шаге мы вычисляем значение SDF для каждого из объектов на сцене, получая значения D_1, D_2, D_3, ..., D_N.Для того, чтобы понять, какой объект ближе всего к лучу, мы находим минимальное D \ такое,\ что \  D = min(D_1, D_2, ..., D_N).Далее, в зависимости от найденного значения, мы понимаем положение луча на сцене. Если значениеSDF достаточно мало, значит мы во что-то врезались, и можно заканчивать цикл, иначе мы будем двигаться дальше, пока не достигнем максимального числа итераций.

рис. 6 - Сетка, на которую проектируется изображение
рис. 6 - Сетка, на которую проектируется изображение

Мы могли бы каждый раз делать по очень маленькому шагу вдоль луча, но мы можем "шагать" более эффективно, используя алгоритм «трассировки сферами». Вместо того, чтобы на каждой итерации делать крошечный шаг, мы делаем максимальный возможный шаг. Этот максимально возможный шаг равен минимальному расстоянию из полученных (с помощью SDF) для каждого объекта на сцене.

рис. 7 - Алгоритм трассировки сферами
рис. 7 - Алгоритм трассировки сферами

На рис. 7 p_0 - это камера. Синяя линия проходит вдоль направления луча, идущего от камеры через плоскость экрана. Первый сделанный шаг довольно большой, так как расстояние до ближайшей поверхности тоже велико. Поскольку точка на ближайшей поверхности не лежит на луче, мы продолжаем шагать до тех пор, пока в конце концов не столкнемся с поверхностью в точке p_4

Реализованный на GLSL алгоритм марширования лучей выглядит так:

float depth = start;
for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    float dist = sceneSDF(eye + depth * viewRayDirection);
    if (dist < EPSILON) {
        // We're inside the scene surface!
        return depth;
    }
    // Move along the view ray
    depth += dist;

    if (depth >= end) {
        // Gone too far; give up
        return end;
    }
}
return end;

Если ты все сделал правильно, должно получиться что-то похожее:

рис. 8 - Рендер сферы без модели освещения
рис. 8 - Рендер сферы без модели освещения

Для того, чтобы сфера стала сферой, нам нужна модель освещения. Возьмем самую простую - освещение по Ламберту, для этого мы должны вычислить угол между источником света и нормалью к поверхности в точки пересечения луча взгляда и домножить цвет поверхности на величину этого угла. Для вычисления нормали к поверхности вспомним понятия градиента. Градиентом функции f(x, y, z) называется такой вектор:

\begin{equation} \bigtriangledown \vec{f} = (\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}, \frac{\partial f}{\partial z}) \end{equation}

Но для рендера в реальном времени мы не можем считать производные. Вместо того, чтобы брать действительную производную функции, мы будем считать ее аппроксимацию:

\begin{equation}     \vec{n}=\left[\begin{array}{l}     f(x+\varepsilon, y, z)-f(x-\varepsilon, y, z) \\     f(x, y+\varepsilon, z)-f(x, y-\varepsilon, z) \\     f(x, y, z+\varepsilon)-f(x, y, z-\varepsilon)     \end{array}\right] \end{equation}

где \varepsilon- очень маленькая величина.

Умножая цвет сферы на величину угла между нормалью и источником освещения, мы получим:

рис. 9 - Рендер сферы с моделью освещения по Ламберт
рис. 9 - Рендер сферы с моделью освещения по Ламберт

Готово! Мы познакомились с базовыми инструментами трассировки лучей и смогли нарисовать объемную сферу. Во второй части моей статьи познакомимся с более сложной моделью освещения, тенями, и начнем изучать преломление света и понятие интерференции, которые нам пригодятся в рендере мыльного пузыря.

Исходный код фрагментного шейдера для плоского изображения

const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0001;
const float MAX_DIST = 100.0;
const float EPSILON = 0.0001;

float sphereSDF(vec3 samplePoint) {
    return length(samplePoint) - 1.0;
}

vec3 rayDirection(float fieldOfView, vec2 size, vec2 fragCoord) {
    vec2 xy = fragCoord - size / 2.0;
    float z = size.y / tan(radians(fieldOfView) / 2.0);
    return normalize(vec3(xy, -z));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
	vec3 dir = rayDirection(45.0, iResolution.xy, fragCoord);
    vec3 eye = vec3(0.0, 0.0, 5.0);
    float dist = shortestDistanceToSurface(eye, dir, MIN_DIST, MAX_DIST);
    
    if (dist > MAX_DIST - EPSILON) {
        // Didn't hit anything
        fragColor = vec4(0.0, 0.0, 0.0, 0.0);
		return;
    }
    
    fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Исходный код фрагментного шейдера для объемного изображения

const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0001;
const float MAX_DIST = 100.0;
const float EPSILON = 0.0001;

float sphereSDF(vec3 samplePoint) {
    return length(samplePoint) - 1.0;
}

float shortestDistanceToSurface(vec3 eye, vec3 marchingDirection, float start, float end) {
    float depth = start;
    for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
        float dist = sceneSDF(eye + depth * marchingDirection);
        if (dist < EPSILON) {
			return depth;
        }
        depth += dist;
        if (depth >= end) {
            return end;
        }
    }
    return end;
}
            
vec3 rayDirection(float fieldOfView, vec2 size, vec2 fragCoord) {
    vec2 xy = fragCoord - size / 2.0;
    float z = size.y / tan(radians(fieldOfView) / 2.0);
    return normalize(vec3(xy, -z));
}

vec3 estimateNormal(vec3 p) {
    return normalize(vec3(
        sphereSDF(vec3(p.x + EPSILON, p.y, p.z)) - sphereSDF(vec3(p.x - EPSILON, p.y, p.z)),
        sphereSDF(vec3(p.x, p.y + EPSILON, p.z)) - sphereSDF(vec3(p.x, p.y - EPSILON, p.z)),
        sphereSDF(vec3(p.x, p.y, p.z  + EPSILON)) - sphereSDF(vec3(p.x, p.y, p.z - EPSILON))
    ));
}

vec3 lambertIllumination(vec3 p, vec3 lightPos) {
    
    vec3 lightVector = normalize(lightPos - p);
    vec3 n = estimateNormal(p);
    return vec3(1.0, 0.0, 0.0) * max(0.0, dot(n, lightVector));
}


void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
	vec3 dir = rayDirection(45.0, iResolution.xy, fragCoord);
    vec3 eye = vec3(0.0, 0.0, 5.0);
    float dist = shortestDistanceToSurface(eye, dir, MIN_DIST, MAX_DIST);
    
    if (dist > MAX_DIST - EPSILON) {
        // Didn't hit anything
        fragColor = vec4(0.0, 0.0, 0.0, 0.0);
		return;
    }
    
    // The closest point on the surface to the eyepoint along the view ray
    vec3 p = eye + dist * dir;
    
    vec3 color = lambertIllumination(p, vec3(3.0, 2.0, 4.0));
    
    fragColor = vec4(color, 1.0);
}

Комментарии (11)


  1. Tzimie
    04.08.2022 20:01
    +2

    Это все хорошо, но для черной дыры не подходит)


    1. justmara
      04.08.2022 20:54
      +11

      draw the rest of fucking owl


    1. Readme
      04.08.2022 20:59

      Да норм, там своя уличная магия, про это тоже была статья-перевод.


      1. Tzimie
        04.08.2022 23:09

        Спасибо, там по делу, но увы, я никогда не видел рендеринг вращающийся черной дыры, что видит наблюдатель изнутри, там где кольцевая сингулярность


        1. Readme
          05.08.2022 12:10

          Кажется, вращающиеся ЧД тоже умеют рендерить.


          • Недавно залип здесь: https://jila.colorado.edu/~ajsh/insidebh/intro.html — разные рендеры с пояснениями. Правда, сам автор признаётся, что вращающиеся ЧД (Kerr) пока не запилены, но есть заряженные (Reissner-Nordström).
          • Гугл вполне выдаёт что-то дельное по "Kerr black hole render", например: http://www.madore.org/~david/math/kerr.html — занимательное месиво.


  1. Katcinskiy
    05.08.2022 07:42
    +1

    Интересно! Ждем больше статей по PBR


  1. V1RuS
    05.08.2022 13:56

    image


    а зачем использовать трассировку "шагами", как на картинке, вместо того чтобы за один "шаг" найти точку пересечения линии с объектом? все равно на каждом шаге нужно перебирать все объекты.


    и кстати исходник неполный, не хватает как минимум функции sceneSDF, где как раз и должен быть перебор объектов.


    1. GermanZvezdin Автор
      05.08.2022 14:42

      Вы правы, в коде содержалась ошибка, сейчас она исправлена.
      Давайте представим, что объект на сцене один, тогда алгоритм будет работать, как, Вы описали, т.е. лучи, распространяющиеся в направление объекта за один шаг его пересекут.
      Если объектов много, важно понимать, что sdf дает лишь расстояние до объекта, который описывает, и мы понимает, только какой из объектов ближайший, но не понимаем, в какой именно объект мы движемся, поэтому, чтобы гарантировать, что мы "не пропустим" объект, сделав слишком большой шаг, мы делаем минимально возможный, чтобы ни во что "не врезаться".


      1. V1RuS
        05.08.2022 15:20

        Мой вопрос как раз о том, зачем использовать sdf (и много шагов) вместо непосредственного поиска пересечения (один шаг). Много простых итераций получаются быстрее одной сложной? Какие границы применимости у этих способов?


        1. DimPal
          05.08.2022 16:17
          +4

          Постановка задачи такая: дан некий луч, выходящий из некой точки (например из глаза) в некотором направлении, необходимо найти координаты пересечения с некоторым объектом. Если объект примитвный (шар, тругольник), то найти пересечение за один шаг действительно можно. Но если объект сложно составной (или constructive solid geometry), то простых решений (за один шаг) нет.


        1. GermanZvezdin Автор
          05.08.2022 16:35
          +2

          SDF - гибкий инструмент, который позволяет реализовывать в едином стиле большое количество примитивов. Не всегда много простых итераций лучше одной сложной, все зависит от конкретной сцены, из личного опыта скажу, что для сцены с объектами одного типа можно не использовать sdf, но когда сцена сложная с различной геометрией, то без SDF не обойтись. Одним из главных ограничений является, как мне кажется, тот факт, что поверхности, описываемые sdf должны обладать достаточной степенью гладкости, что сильно сужает количество различных примитивов. Так же существенным ограничением является проблема, называемая "alising", решений которой сильно просаживает fps и даже может нивелировать плюсы от ускорения за счет обратного распространения лучей.