Поля расстояний Raymarching-а: объяснение и реализация в Unity

Автор оригинала: Adrian Biagioli
  • Перевод
image

Raymarching — это достаточно новая техника, используемая для рендеринга сцен реального времени. Она особенно интересна тем, что полностью вычисляется в шейдере экранного пространства. Другими словами, рендерер не получает доступа к данным мешей и сцена отрисовывается на одном четырёхугольнике, покрывающем всю область видимости камеры. Объекты в сцене задаются аналитическим уравнением, описывающим кратчайшее расстояние между точкой и поверхностью всех объектов сцены (поэтому полностью техника называется полями расстояний Raymarching-а). Оказывается, что даже при наличии только этой информации мы можем создавать поразительно сложные и красивые сцены. Более того, благодаря тому, что мы не используем полигональные меши (и вместо них применяются математические уравнения), здесь, в отличие от традиционного рендерера, можно задавать идеально плавные поверхности.


Snail Иниго Килеза была полностью создана при помощи raymarching. Другие примеры подвергнутых raymarching-у сцен можно найти на Shadertoy.

В этой статье мы сначала расскажем о фундаментальных понятиях и теории raymarching, а затем покажем, как реализовать простейший raymarcher в игровом движке Unity. Далее мы продемонстрируем, как на практике встроить raymarching в настоящую игру на Unity, позволив объектам с raymarching-ом перекрываться обычными GameObjects.

Полный код можно найти в этом репозитории Github.

Введение в Raymarching


Raymarching похож на традиционную трассировку лучей (raytracing) тем, что луч в сцену испускается для каждого пикселя. В трассировщике лучей у нас есть набор уравнений, определяющих пересечение луча и рендерящихся объектов. Благодаря этому можно найти объекты, которые пересекает луч (то есть объекты, которые видит камера). Также таким образом можно рендерить неполигональные объекты, например, сферы, потому что достаточно только знать формулу сферы и луча. Однако raytracing очень затратен, особенно когда в сцене есть множество объектов и сложное освещение. Кроме того, невозможно выполнять трассировку лучей через объёмные материалы, например, облака и воду. Поэтому трассировка лучей редко соответствует требованиям приложений реального времени.


Рисунок 1: упрощённое представление трассировщика лучей. Жирная чёрная линия — это пример испущенного из камеры луча для рендеринга пикселя.

Raymarching предлагает другой способ решения задачи пересечения луча и объекта. Raymarching не пытается напрямую вычислить это пересечение аналитически. При нём мы «шагаем» точкой вдоль луча, пока не найдём, где точка пересекает объект. Оказывается, сэмплирование этой точки вдоль луча является относительно простой и малозатратной операцией, гораздо более практичной в реальном времени. Как можно увидеть на рисунке 2, этот способ менее точен, чем raytracing (если приглядеться, то заметно что точка пересечения слегка смещена). Однако для игр он более чем подходит и является отличным компромиссом между эффективностью полигонального рендеринга и точностью традиционного raytracing.


Рисунок 2: простейшая реализация функции raymarching-а (называемой raymarcher) с фиксированным интервалом шага. Красными точками обозначены все сэмплируемые точки.

Поля расстояний


Функция raymarching-а с фиксированным интервалом, такая например, как показана на рисунке 2, вполне достаточна для множества областей применения, например, объёмных и прозрачных поверхностей. Однако для непрозрачных объектов мы можем ввести ещё одну оптимизацию. Для этой оптимизации требуется использование полей расстояний со знаком (signed distance fields). Поле расстояний — это функция, получающая на входе точку и возвращающая кратчайшее расстояние от этой точки до поверхности каждого объекта в сцене. Поле расстояний со знаком дополнительно возвращает отрицательное число, если точка находится внутри объекта. Поля расстояний — это отличный инструмент, поскольку они позволяют нам ограничить количество сэмплов при движении raymarching-ом вдоль луча. См. пример:


Рисунок 3: визуализация raymarcher-а, использующего поля расстояний со знаком. Красными точками показаны все сэмплируемые точки. Синими кругами показаны области, которые гарантированно не содержат объектов (потому что они находятся в результатах функции поля расстояний). Пунктирными зелёными линиями показаны истинные кратчайшие векторы между каждой сэмплируемой точкой и сценой.

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

Реализация простейшего Raymarcher-а


Так как алгоритм raymarching-а выполняется для каждого пикселя, raymarcher в Unity по сути будет являться шейдером постобработки. Из-за этого основная часть кода на C#, который мы будем писать, похожа на то, что мы использовали для полноэкранного эффекта изображения.

Подготовка скрипта эффекта изображения


Давайте реализуем скрипт загрузки простейшего эффекта изображения. Примечание: я использую скрипт SceneViewFilter для автоматического применения фильтров изображения в режиме Scene. Это упрощает отладку шейдера. Чтобы использовать его, в скрипте эффекта изображения вместо MonoBehaviour нужно расширить SceneViewFilter.

Чтобы вы не мучились с boilerplate-кодом, ниже записан скрипт простейшего эффекта изображения:

using UnityEngine;
using System.Collections;

[ExecuteInEditMode]
[RequireComponent(typeof(Camera))]
[AddComponentMenu("Effects/Raymarch (Generic)")]
public class TutorialRaymarch : SceneViewFilter {

    [SerializeField]
    private Shader _EffectShader;

    public Material EffectMaterial
    {
        get
        {
            if (!_EffectMaterial && _EffectShader)
            {
                _EffectMaterial = new Material(_EffectShader);
                _EffectMaterial.hideFlags = HideFlags.HideAndDontSave;
            }

            return _EffectMaterial;
        }
    }
    private Material _EffectMaterial;

    public Camera CurrentCamera
    {
        get
        {
            if (!_CurrentCamera)
                _CurrentCamera = GetComponent<Camera>();
            return _CurrentCamera;
        }
    }
    private Camera _CurrentCamera;

    [ImageEffectOpaque]
    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (!EffectMaterial)
        {
            Graphics.Blit(source, destination); // do nothing
            return;
        }

        Graphics.Blit(source, destination, EffectMaterial, 0); // use given effect shader as image effect
    }
}

Чтобы использовать этот скрипт, нужно прикрепить его к камере и перетащить шейдер эффекта изображения в поле «Effect Shader». В качестве теста можете использовать стандартный шейдер эффекта изображения (Assets > Create > Shader > Image Effects Shader), который просто инвертирует экран. Разобравшись с этим, мы можем начать разбираться с более техническими аспектами реализации.

Передача лучей фрагментному шейдеру


Первым шагом по реализации raymarcher станет вычисление луча, который мы будем использовать для каждого пикселя. Кроме того, нам нужно, чтобы эти лучи соответствовали параметрам рендеринга Unity (такими, как позиция и поворот камеры, FOV и т.п.).


Рисунок 4: визуализация лучей, отправляемых из камеры

Сделать это многими способами, но я решил использовать в каждом кадре следующую процедуру:

  1. Вычислять массив из четырёх векторов, составляющих пирамиду видимости камеры (Camera View Frustum). Эти четыре вектора можно воспринимать как «углы» пирамиды видимости:


    Четыре угла пирамиды видимости, которые позже передаются шейдеру
  2. При рендеринге нашего raymarcher в качестве шейдера эффекта изображения используем нашу собственную замену Graphics.Blit(). По сути, Graphics.Blit рендерит поверх всего экрана четырёхугольник, и этот четырёхугольник рендерится шейдером эффекта изображения. Мы добавим его, передавая для каждой вершины соответствующие индексы массива, созданного нами на этапе 1. Теперь вершинный шейдер будет знать о том, какие лучи нужно испускать в каждый из углов экрана!
  3. В шейдере мы передаём направления шейдеров из этапа 2 во фрагментный шейдер. Cg автоматически интерполирует направления лучей для каждого пикселя, создавая истинное направление луча.

Итак, теперь давайте реализуем описанный выше процесс.

Этап 1: вычисление углов пирамиды видимости


Для вычисления лучей углов пирамиды камеры нужно учесть область видимости камеры, а также её соотношение сторон. Я сделал это в показанной ниже функции GetFrustumCorners:

/// \brief Stores the normalized rays representing the camera frustum in a 4x4 matrix.  Each row is a vector.
/// 
/// The following rays are stored in each row (in eyespace, not worldspace):
/// Top Left corner:     row=0
/// Top Right corner:    row=1
/// Bottom Right corner: row=2
/// Bottom Left corner:  row=3
private Matrix4x4 GetFrustumCorners(Camera cam)
{
    float camFov = cam.fieldOfView;
    float camAspect = cam.aspect;

    Matrix4x4 frustumCorners = Matrix4x4.identity;

    float fovWHalf = camFov * 0.5f;

    float tan_fov = Mathf.Tan(fovWHalf * Mathf.Deg2Rad);

    Vector3 toRight = Vector3.right * tan_fov * camAspect;
    Vector3 toTop = Vector3.up * tan_fov;

    Vector3 topLeft = (-Vector3.forward - toRight + toTop);
    Vector3 topRight = (-Vector3.forward + toRight + toTop);
    Vector3 bottomRight = (-Vector3.forward + toRight - toTop);
    Vector3 bottomLeft = (-Vector3.forward - toRight - toTop);

    frustumCorners.SetRow(0, topLeft);
    frustumCorners.SetRow(1, topRight);
    frustumCorners.SetRow(2, bottomRight);
    frustumCorners.SetRow(3, bottomLeft);

    return frustumCorners;
}

Стоит также сказать пару слов об этой функции. Во-первых, она возвращает Matrix4x4, а не массив из Vector3. Благодаря этому мы можем передать векторы шейдеру в одной переменной (без необходимости использования массивов). Во-вторых, она возвращает лучи углов пирамиды видимости в пространстве глаза (eye space). То есть подразумевается, что (0,0,0) является позицией камеры, а сами лучи испускаются с точки зрения камеры (а не, например, в мировом пространстве (worldspace)).

Этап 2: передаём лучи в GPU


Чтобы передать матрицу шейдеру, нам нужно внести небольшое изменение в скрипт эффекта изображения:

[ImageEffectOpaque]
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    if (!EffectMaterial)
    {
        Graphics.Blit(source, destination); // do nothing
        return;
    }

    // pass frustum rays to shader
    EffectMaterial.SetMatrix("_FrustumCornersES", GetFrustumCorners(CurrentCamera));
    EffectMaterial.SetMatrix("_CameraInvViewMatrix", CurrentCamera.cameraToWorldMatrix);
    EffectMaterial.SetVector("_CameraWS", CurrentCamera.transform.position);

    Graphics.Blit(source, destination, EffectMaterial, 0); // use given effect shader as image effect
}

Позже, когда мы начнём работать над самим шейдером эффекта изображения, мы можем получить доступ к матрице при помощи uniform _FrustumCornersES. Также я добавил немного относящейся к камере информации, которая понадобится нам позже (_CameraInvViewMatrix будет использоваться для преобразования лучей из пространства глаза в мировое пространство, а _CameraWS — это позиция камеры).

Далее нам нужно предоставить вершинному шейдеру инструменты для правильной интерпретации этой матрицы. Помните: эффект изображения — это просто четырёхугольник, отрисовываемый поверх всего экрана, поэтому нам каким-то образом нужно передать каждой вершине вершинного шейдера соответствующий индекс _FrustumCornersES. Для этого нам нужно использовать нашу собственную замену Graphics.Blit (см. строку 13 выше). В этой изменённой версии мы проделали хитрый трюк: так как четырёхугольник в Graphics.Blit отрисовывается при помощи ортогональной проекции, позиция каждой вершины по z не влияет на готовое изображение. Поэтому мы можем просто передать соответствующие индексы _FrustumCornersES через координату z каждой вершины! Звучит сложно, но на практике это довольно легко:

/// \brief Custom version of Graphics.Blit that encodes frustum corner indices into the input vertices.
/// 
/// In a shader you can expect the following frustum cornder index information to get passed to the z coordinate:
/// Top Left vertex:     z=0, u=0, v=0
/// Top Right vertex:    z=1, u=1, v=0
/// Bottom Right vertex: z=2, u=1, v=1
/// Bottom Left vertex:  z=3, u=1, v=0
/// 
/// \warning You may need to account for flipped UVs on DirectX machines due to differing UV semantics
///          between OpenGL and DirectX.  Use the shader define UNITY_UV_STARTS_AT_TOP to account for this.
static void CustomGraphicsBlit(RenderTexture source, RenderTexture dest, Material fxMaterial, int passNr)
{
    RenderTexture.active = dest;

    fxMaterial.SetTexture("_MainTex", source);

    GL.PushMatrix();
    GL.LoadOrtho(); // Note: z value of vertices don't make a difference because we are using ortho projection

    fxMaterial.SetPass(passNr);

    GL.Begin(GL.QUADS);

    // Here, GL.MultitexCoord2(0, x, y) assigns the value (x, y) to the TEXCOORD0 slot in the shader.
    // GL.Vertex3(x,y,z) queues up a vertex at position (x, y, z) to be drawn.  Note that we are storing
    // our own custom frustum information in the z coordinate.
    GL.MultiTexCoord2(0, 0.0f, 0.0f);
    GL.Vertex3(0.0f, 0.0f, 3.0f); // BL

    GL.MultiTexCoord2(0, 1.0f, 0.0f);
    GL.Vertex3(1.0f, 0.0f, 2.0f); // BR

    GL.MultiTexCoord2(0, 1.0f, 1.0f);
    GL.Vertex3(1.0f, 1.0f, 1.0f); // TR

    GL.MultiTexCoord2(0, 0.0f, 1.0f);
    GL.Vertex3(0.0f, 1.0f, 0.0f); // TL
    
    GL.End();
    GL.PopMatrix();
}

// ...

void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    // ...
    EffectMaterial.SetMatrix("_FrustumCornersES", GetFrustumCorners(CurrentCamera));

    CustomGraphicsBlit(source, destination, EffectMaterial, 0); // Replace Graphics.Blit with CustomGraphicsBlit
}

В обычной реализации Graphics.Blit все четыре вызова GL.Vertex3 будут иметь координаты z, равные 0. Однако после внесения нашего изменения мы назначаем координате z значения соответствующих индексов в _FrustumCornersES.

Этап 3: получение направлений лучей в шейдере


Теперь мы наконец готовы начинать писать шейдер raymarching-а. В качестве основы я начну со стандартного шейдера эффекта изображения (Assets > Create > Shader > Image Effects Shader). Сначала нам нужно отредактировать вершинный шейдер, чтобы он правильно интерпретировал _FrustumCornersES:

// Provided by our script
uniform float4x4 _FrustumCornersES;
uniform sampler2D _MainTex;
uniform float4 _MainTex_TexelSize;
uniform float4x4 _CameraInvViewMatrix;

// Input to vertex shader
struct appdata
{
    // Remember, the z value here contains the index of _FrustumCornersES to use
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
};

// Output of vertex shader / input to fragment shader
struct v2f
{
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float3 ray : TEXCOORD1;
};

v2f vert (appdata v)
{
    v2f o;
    
    // Index passed via custom blit function in RaymarchGeneric.cs
    half index = v.vertex.z;
    v.vertex.z = 0.1;
    
    o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
    o.uv = v.uv.xy;
    
    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
        o.uv.y = 1 - o.uv.y;
    #endif

    // Get the eyespace view ray (normalized)
    o.ray = _FrustumCornersES[(int)index].xyz;

    // Transform the ray from eyespace to worldspace
    // Note: _CameraInvViewMatrix was provided by the script
    o.ray = mul(_CameraInvViewMatrix, o.ray);
    return o;
}

Основная часть вершинного шейдера должна быть знакомой программистам графики Unity: как и в большинстве шейдеров эффектов изображения, мы передаём позиции вершин и данные UV фрагментному шейдеру. Также в некоторых случаях нам нужно переворачивать UV по оси Y, чтобы избежать перевёрнутого отображения выходных данных. Разумеется, мы также извлекаем соответствующий интересный нам луч из _FrustumCornersES при помощи координаты Z входящей вершины (эти значения были вставлены в координаты Z на этапе 2). После завершения вершинного шейдера GPU интерполирует лучи для каждого пикселя. Теперь мы можем использовать эти интерполированные лучи во фрагментном шейдере!

В качестве теста попробуем просто возвращать во фрагментном шейдере направление луча:

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = fixed4(i.ray, 1);
    return col;
}

В Unity это должно создать такую визуализацию:


Визуализация направления луча в мировом пространстве для каждого пикселя. Например, посмотрите на результат, показанный зелёным. Он соответствует направлению луча (0, 1, 0).

Построение поля расстояний


Следующим шагом будет построение поля расстояний, которое мы используем в дальнейшем. Напомню, что поле расстояний определяет то, что мы будем рендерить (в отличие от 3D-моделей/мешей традиционного рендерера). Наша функция полей расстояний получает на входе точку и возвращает расстояние от этой точки до поверхности ближайшего объекта в сцене. Если точка находится внутри объекта, то поле расстояний отрицательно.

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

// Torus
// t.x: diameter
// t.y: thickness
// Adapted from: http://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdTorus(float3 p, float2 t)
{
    float2 q = float2(length(p.xz) - t.x, p.y);
    return length(q) - t.y;
}

// This is the distance field function.  The distance field represents the closest distance to the surface
// of any object we put in the scene.  If the given point (point p) is inside of an object, we return a
// negative answer.
float map(float3 p) {
    return sdTorus(p, float2(1, 0.2));
}

В данном случае map задаёт поле расстояний, описывающее тор диаметром 1.0 и толщиной 0.2, расположенный в центре начала координат сцены. Эта функция map — вероятно, наиболее творческий и интересный аспект raymarching-а, поэтому рекомендую поиграться с ней! Попробуйте новые примитивы, комбинации примитивов или даже собственные странные фигуры! Повторюсь, вам стоит изучить этот ресурс, чтобы узнать другие уравнения полей расстояний.

Пишем функцию Raymarch-а


Теперь, когда мы создали поле расстояний, которое будем сэмплировать, можно написать базовый цикл raymarch-а. Этот цикл будет вызываться из фрагментного шейдера; как объяснено в начале этого поста, он будет отвечать за «шагание» (marching) сэмплируемой точки вдоль луча текущего пикселя. Функция raymarch-а возвращает цвет того объекта, с которым столкнётся луч (или полностью прозрачный цвет, если объект не найден). По сути, функция raymarch-а сводится к простому циклу for, показанному ниже:

// Raymarch along given ray
// ro: ray origin
// rd: ray direction
fixed4 raymarch(float3 ro, float3 rd) {
    fixed4 ret = fixed4(0,0,0,0);

    const int maxstep = 64;
    float t = 0; // current distance traveled along ray
    for (int i = 0; i < maxstep; ++i) {
        float3 p = ro + rd * t; // World space position of sample
        float d = map(p);       // Sample of distance field (see map())

        // If the sample <= 0, we have hit something (see map()).
        if (d < 0.001) {
            // Simply return a gray color if we have hit an object
            // We will deal with lighting later.
            ret = fixed4(0.5, 0.5, 0.5, 1);
            break;
        }

        // If the sample > 0, we haven't hit anything yet so we should march forward
        // We step forward by distance d, because d is the minimum distance possible to intersect
        // an object (see map()).
        t += d;
    }

    return ret;
}

На каждой итерации цикла raymarch-а мы сэмплируем точку вдоль луча. Если мы с чем-то столкнёмся, то выходим из цикла и возвращаем цвет (другими словами, цвет объекта). Если мы ни с чем не столкнёмся (результат map больше нуля), то перемещаемся вперёд на расстояние, сообщаемое нам полем расстояний. Если вы запутались, то вернитесь к теории, объяснённой в начале статьи.

Если вы создаёте чрезвычайно сложные сцены со множеством мелких деталей. то вам может потребоваться увеличить константу maxstep в строке 7 (но расплачиваться за это придётся снижением производительности). Также можно попробовать тщательно настроить maxstep, чтобы понять, каким количеством сэмплов можно обойтись (в случае нашего простого тора 64 сэмплов будет перебором, но ради примера я оставлю это значение).

Теперь нам только осталось вызвать raymarch() из фрагментного шейдера. Это делается легко:

// Provided by our script
uniform float3 _CameraWS;

// ...

fixed4 frag (v2f i) : SV_Target
{
    // ray direction
    float3 rd = normalize(i.ray.xyz);
    // ray origin (camera position)
    float3 ro = _CameraWS;

    fixed3 col = tex2D(_MainTex,i.uv); // Color of the scene before this shader was run
    fixed4 add = raymarch(ro, rd);

    // Returns final color using alpha blending
    return fixed4(col*(1.0 - add.w) + add.xyz * add.w,1.0);
}

Всё, что мы здесь делаем — это получаем данные лучей из вершинного шейдер и передаём их функции raymarch(). В конце мы смешиваем результат с _MainTex (сценой, отрендеренной до применения этого шейдера) при помощи стандартного альфа-смешивания. Помните, что _CameraWS представляет позицию камеры в мировом пространстве и что ранее в нашем скрипте C# она передавалась шейдеру как uniform.

Снова откройте Unity, и узрите! Вот он — тор!


И никаких полигонов!

Добавляем освещение


Мы уже совершили серьёзный прогресс: теперь при помощи raymarching-а мы можем рендерить произвольные фигуры с бесконечным разрешением. Однако, разумеется, будет сложно использовать отрендеренные raymarching-ом объекты в игре без возможности наложения на них освещения (если только это не какая-то абстрактная игра).

Чтобы иметь возможность выполнять любые вычисления освещения объектов, нам сначала нужно вычислить нормали объекта. Свет отражается от объектов как функция их нормалей. Если конкретнее, то любая BDRF требует в качестве входных данных нормаль поверхности. У обычного полигонального 3D-меша нормали объекта найти просто, потому что поиск нормалей треугольника — это легко решаемая задача. Однако в нашем случае определение нормалей объекта внутри поля расстояний не так очевидно.

Оказывается, что в любой точке поверхности, заданной в поле расстояний, градиент поля расстояний такой же, как нормали объекта в этой точке. Градиент скалярного поля (например, поле расстояний со знаком), по сути, является производной поля в направлениях x, y и z. Другими словами, для каждого измерения d мы фиксируем два других измерения и аппроксимируем производную поля вдоль d. Интуитивно понятно, что значение поля расстояний быстрее растёт при прямом движении вдаль от объекта (то есть вдоль его нормали). Поэтому, вычислив градиент в какой-то точке, мы также вычислим нормаль поверхности в этой точке.

Вот как аппроксимируется этот градиент в коде:

float3 calcNormal(in float3 pos)
{
    // epsilon - used to approximate dx when taking the derivative
    const float2 eps = float2(0.001, 0.0);

    // The idea here is to find the "gradient" of the distance field at pos
    // Remember, the distance field is not boolean - even if you are inside an object
    // the number is negative, so this calculation still works.
    // Essentially you are approximating the derivative of the distance field at this point.
    float3 nor = float3(
        map(pos + eps.xyy).x - map(pos - eps.xyy).x,
        map(pos + eps.yxy).x - map(pos - eps.yxy).x,
        map(pos + eps.yyx).x - map(pos - eps.yyx).x);
    return normalize(nor);
}

Однако будьте аккуратны, ведь эта техника очень затратна! Для нахождения градиента нужно вычислить поле расстояний ещё шесть раз для каждого пикселя.

Теперь, когда мы можем вычислить нормали объекта, можно начать освещать предметы! Разумеется, сначала нам понадобится источник освещения. Чтобы передать источник освещения нашему шейдеру, нужно немного изменить скрипты:

// ...

public class TutorialRaymarch : SceneViewFilter {

    // ...

    public Transform SunLight;

    // ...

    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        // ...

        EffectMaterial.SetVector("_LightDir", SunLight ? SunLight.forward : Vector3.down);

        // ...

        CustomGraphicsBlit(source, destination, EffectMaterial, 0);
    }

    // ...
}

Эти дополнения просто передают нашему шейдеру вектор, описывающий направление солнца. Если захотите, можете передавать и другую информацию (например, яркость света, цвет и т.д.), но мы пока оставим пример простым и будем считать, что это простое направленное освещение с яркостью 1.0. Этот вектор передаётся нашим скриптам через uniform _LightDir шейдера. Теперь мы можем использовать _LightDir вместе с calcNormal() для освещения объектов:

// ...

uniform float3 _LightDir;

// ...

fixed4 raymarch(float3 ro, float3 rd) {
    fixed4 ret = fixed4(0,0,0,0);

    const int maxstep = 64;
    float t = 0; // current distance traveled along ray
    for (int i = 0; i < maxstep; ++i) {
        float3 p = ro + rd * t; // World space position of sample
        float d = map(p);       // Sample of distance field (see map())

        // If the sample <= 0, we have hit something (see map()).
        if (d < 0.001) {
            // Lambertian Lighting
            float3 n = calcNormal(p);
            ret = fixed4(dot(-_LightDir.xyz, n).rrr, 1);
            break;
        }

        // If the sample > 0, we haven't hit anything yet so we should march forward
        // We step forward by distance d, because d is the minimum distance possible to intersect
        // an object (see map()).
        t += d;
    }
    return ret;
}

// ...

В строках 18-20 этого кода мы используем модель отражений по Ламберту, но вы можете использовать любую понравившуюся BDRF (прямо как с обычными 3D-моделями!). Вернёмся в редактор Unity и назначим атрибут скрипта «Sun Light» источнику направленного освещения в сцене, после чего действительно увидим красиво освещённый тор:


Наш тор с освещением по Ламберту

Взаимодействие с объектами из мешей


Итак, мы уже создали несколько объектов при помощи полей расстояний и готовы встроить их в в проект Unity. Однако очень быстро вы столкнётесь с серьёзной проблемой: объекты из мешей и объекты, полученные raymarching-ом, не могут взаимодействовать и касаться друг друга! На самом деле, полученные raymarching-ом объекты всегда плавают поверх всего остального, потому что raymarcher не учитывает глубину. Это можно увидеть в ролике:


Чтобы решить эту проблему, нам нужно найти расстояние вдоль каждого луча, на котором располагается ближайший объект из мешей. Если наш цикл raymarch-а прошагает дальше этой точки, то мы выполняем выход и рендерим этот объект (потому что он находится перед любыми потенциальными объектами, полученными raymarching-ом).

Чтобы найти это расстояние, нам нужно воспользоваться буфером глубин. Буфер глубин доступен для всех шейдеров эффектов изображений и хранит для каждого пикселя глубину ближайшего объекта в сцене в пространстве глаза. См. рисунок 5 ниже.


Рисунок 5: схема измерений, которые нам интересны при вычислении глубины. Красная линия — это луч для какого-то произвольного пикселя.

Величина r на рисунке 5 — это то значение, которое мы пытаемся найти (глубина, дальше которой мы должны выйти из цикла raymarch). Величина d — это глубина в пространстве глаза, которую мы берём для этого пикселя в буфере глубин (заметьте, что d короче, чем r, потому что d не учитывает перспективу).

Чтобы найти величину r, мы можем просто использовать правила подобия треугольников. Рассмотрим rn, вектор с тем же направлением, что и у r, но имеющий длину 1.0 в направлении z. Можно записать rn следующим образом:

rn = rd ÷ (rd).z

В представленном выше уравнении rd — это вектор с тем же направлением, что и r, но произвольной длины (другими словами, вектор луча, который передаётся нашему шейдеру). Из рисунка 5 очевидно, что r и rn образуют два подобных треугольника. Умножив rn на d (которую мы знаем из буфера глубин), можно получить r и его величину следующим образом:

| r | / d = rd / 1.0
| r | = rd × d

Используем буфер глубин в нашем шейдере


Теперь нам нужно внести кое-какие изменения в код, чтобы он соответствовал приведённой выше теории. Во-первых, нужно внести изменения в вершинный шейдер, чтобы он возвращал rn вместо rd:

v2f vert (appdata v)
{
    // ...

    // Dividing by z "normalizes" it in the z axis
    // Therefore multiplying the ray by some number i gives the viewspace position
    // of the point on the ray with [viewspace z]=i
    o.ray /= abs(o.ray.z);

    // Transform the ray from eyespace to worldspace
    o.ray = mul(_CameraInvViewMatrix, o.ray);

    return o;
}

Заметьте, что мы делим на abs(o.ray.z), а не просто на o.ray.z. Это нужно, потому что в координатах пространства глаза z < 0 соответствует направлению вперёд. Если мы разделим на отрицательное число, то при делении направление луча перевернётся (а значит, будет выглядеть перевёрнутой и сама сцена, полученная raymarching-ом).

Последним шагом будет встраивание глубины в фрагментный шейдер и цикл raymarch:

// Raymarch along given ray
// ro: ray origin
// rd: ray direction
// s: unity depth buffer
fixed4 raymarch(float3 ro, float3 rd, float s) {
    fixed4 ret = fixed4(0,0,0,0);

    const int maxstep = 64;
    float t = 0; // current distance traveled along ray
    for (int i = 0; i < maxstep; ++i) {
        // If we run past the depth buffer, stop and return nothing (transparent pixel)
        // this way raymarched objects and traditional meshes can coexist.
        if (t >= s) {
            ret = fixed4(0, 0, 0, 0);
            break;
        }

        // ...
    }

    return ret;
}

// ...
uniform sampler2D _CameraDepthTexture;
// ...

fixed4 frag (v2f i) : SV_Target
{
    // ray direction
    float3 rd = normalize(i.ray.xyz);
    // ray origin (camera position)
    float3 ro = _CameraWS;

    float2 duv = i.uv;
    #if UNITY_UV_STARTS_AT_TOP
    if (_MainTex_TexelSize.y < 0)
        duv.y = 1 - duv.y;
    #endif

    // Convert from depth buffer (eye space) to true distance from camera
    // This is done by multiplying the eyespace depth by the length of the "z-normalized"
    // ray (see vert()).  Think of similar triangles: the view-space z-distance between a point
    // and the camera is proportional to the absolute distance.
    float depth = LinearEyeDepth(tex2D(_CameraDepthTexture, duv).r);
    depth *= length(i.ray.xyz);

    fixed3 col = tex2D(_MainTex,i.uv);
    fixed4 add = raymarch(ro, rd, depth);

    // Returns final color using alpha blending
    return fixed4(col*(1.0 - add.w) + add.xyz * add.w,1.0);
}

В строке 45 мы получаем доступ к текстуре глубин Unity при помощи стандартного uniform _CameraDepthTexture шейдера Unity и преобразуем её в глубину пространства глаза с помощью LinearEyeDepth(). Подробнее о текстурах глубин и Unity можно прочитать на этой странице из руководства Unity. Далее, в строке 46 мы умножаем глубину на длину rn, переданного нам вершинным шейдером, выполнив всё в соответствии с описанными выше уравнениями.

Затем мы передаём глубину как новый параметр raymarch(). В цикле raymarch мы выполняем выход и возвращаем совершенно прозрачный цвет, если прошагали за значение, заданное буфером глубин (см. строки 13-16). Если мы теперь вернёмся в Unity, то наши полученные raymarching-ом объекты будут сосуществовать с обычными объектами из мешей:


Развлечения с полями расстояний


Теперь, когда наш raymarcher готов и работает, мы можем начать построение сцен! Как я говорил выше, это очень глубокая кроличья нора, и построение полей расстояний находится совершенно за рамками статьи. Однако ниже представлены некоторые простые механики, с которыми я экспериментировал. Рекомендую для вдохновения изучить примеры на Shadertoy. Как бы то ни было, ниже представлен небольшой набор того, что вы можете сделать:

Простейшие преобразования


Как и в случае с 3D-моделями из мешей, мы можем выполнять преобразования объекта с помощью матрицы модели. Однако в нашем случае нужно вычислить обратную матрицу модели, потому что мы на самом деле не преобразовываем саму модель. Вместо этого мы преобразуем точку, которая используется для сэмплирования поля расстояний.

Для реализации этих преобразований мы сначала построим матрицу модели в скрипте эффекта изображения, а потом передадим обратную матрицу шейдеру:

[ImageEffectOpaque]
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    // ...
    
    // Construct a Model Matrix for the Torus
    Matrix4x4 MatTorus = Matrix4x4.TRS(
        Vector3.right * Mathf.Sin(Time.time) * 5, 
        Quaternion.identity,
        Vector3.one);
    MatTorus *= Matrix4x4.TRS(
        Vector3.zero, 
        Quaternion.Euler(new Vector3(0, 0, (Time.time * 200) % 360)), 
        Vector3.one);
    // Send the torus matrix to our shader
    EffectMaterial.SetMatrix("_MatTorus_InvModel", MatTorus.inverse);

    // ...
}

Стоит учесть, что можно использовать Time.time для анимирования объектов. Также можно использовать любые переменные из нашего скрипта (в том числе и систему анимаций Unity) для передачи данных этим преобразованиям. Далее мы получаем в шейдере матрицу модели и применяем её к тору:

uniform float4x4 _MatTorus_InvModel;

float map(float3 p) {
    float4 q = mul(_MatTorus_InvModel, float4(p,1));
    
    return sdTorus(q.xyz, float2(1, 0.2));
}

Вы заметите, что тор в Unity красиво движется вперёд-назад (запустите режим Play, чтобы увидеть анимацию):


Комбинируем объекты


Мы можем также комбинировать объекты, чтобы создавать более сложные формы. Для этого нужно воспользоваться простыми операциями комбинирования полей расстояний: opU() (Union, сопряжение), opI() (Intersection, пересечение) и opS() (Subtraction, вычитание). Ниже приведён пример функции полей расстояний, демонстрирующий результаты этих операций:

// Box
// b: size of box in x/y/z
// Adapted from: http://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdBox(float3 p, float3 b)
{
    float3 d = abs(p) - b;
    return min(max(d.x, max(d.y, d.z)), 0.0) +
        length(max(d, 0.0));
}

// Union
// Adapted from: http://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float opU( float d1, float d2 )
{
    return min(d1,d2);
}

// Subtraction
// Adapted from: http://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float opS( float d1, float d2 )
{
    return max(-d1,d2);
}

// Intersection
// Adapted from: http://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float opI( float d1, float d2 )
{
    return max(d1,d2);
}

float map(float3 p) {
    float union_box = opU(
        sdBox(p - float3(-4.5, 0.5, 0), float3(1,1,1)), 
        sdBox(p - float3(-3.5, -0.5, 0), float3(1,1,1))
    );
    float subtr_box = opS(
        sdBox(p - float3(-0.5, 0.5, 0), float3(1,1,1.01)), 
        sdBox(p - float3(0.5, -0.5, 0), float3(1,1,1))
    );
    float insec_box = opI(
        sdBox(p - float3(3.5, 0.5, 0), float3(1,1,1)), 
        sdBox(p - float3(4.5, -0.5, 0), float3(1,1,1))
    );

    float ret = opU(union_box, subtr_box);
    ret = opU(ret, insec_box);
    
    return ret;
}

Результат операций в Unity:


Слева направо: сопряжение, вычитание и пересечение

Несколько материалов


Можно расширить функцию полей расстояний так, чтобы она возвращала и данные материалов. Просто заставим функцию map() возвращать соответствующую информацию материалов для каждого объекта — в примере ниже мы выбираем цвет каждого объекта при помощи текстуры линейно изменяющегося цветового шаблона. Также для поддержки нескольких материалов нам нужно изменить описанную выше функцию opU().


Используемый мной цветовой шаблон (Color Ramp).

Как обычно, мы должны передать color ramp нашему шейдеру через скрипт эффекта изображения:

[SerializeField]
private Texture2D _ColorRamp;

// ...

[ImageEffectOpaque]
void OnRenderImage(RenderTexture source, RenderTexture destination)
{
    // ...

    EffectMaterial.SetTexture("_ColorRamp", _ColorRamp);

    // ...
}

Далее мы можем использовать новый uniform _ColorRamp в шейдере. Как говорилось выше, чтобы использовать эти различные свойства материалов, нужно изменить map(), а также вычисление освещения в raymarch().

uniform sampler2D _ColorRamp;

// ...

// Union (with material data)
float2 opU( float2 d1, float2 d2 )
{
    return (d1.x < d2.x) ? d1 : d2;
}

// Notice how map() now returns a float2
// \return.x: Distance field value
// \return.y: Color of closest object (0 - 1)
float2 map(float3 p) {
    float2 d_torus = float2(sdTorus(p, float2(1, 0.2)), 0.5);
    float2 d_box = float2(sdBox(p - float3(-3,0,0), float3(0.75,0.5,0.5)), 0.25);
    float2 d_sphere = float2(sdSphere(p - float3(3,0,0), 1), 0.75);

    float2 ret = opU(d_torus, d_box);
    ret = opU(ret, d_sphere);
    
    return ret;
}

fixed4 raymarch(float3 ro, float3 rd, float s) {
    fixed4 ret = fixed4(0,0,0,0);

    const int maxstep = 64;
    float t = 0; // current distance traveled along ray
    for (int i = 0; i < maxstep; ++i) {
        // ...

        float3 p = ro + rd * t; // World space position of sample
        float2 d = map(p);      // Sample of distance field (see map())
                                // d.x: distance field output
                                // d.y: material data

        // If the sample <= 0, we have hit something (see map()).
        if (d.x < 0.001) {
            float3 n = calcNormal(p);
            float light = dot(-_LightDir.xyz, n);
            // Use y value given by map() to choose a color from our Color Ramp
            ret = fixed4(tex2D(_ColorRamp, float2(d.y,0)).xyz * light, 1);
            break;
        }

        // ...
    }

    return ret;
}

Теперь у нас есть три объекта с разными цветами:


Raymarching с несколькими материалами

Тестирование производительности


Часть нам бывает необходимо протестировать производительность raymarch-шейдера. Лучше всего это сделать, посмотрев, как часто в каждом кадре вызывается map(). Мы можем создать красивую визуализацию этого, изменив raymarch(), чтобы она выводила количество сэмплов на кадр. Просто привяжем количество сэмплов в текущем пикселе к Color Ramp, как и в предыдущем разделе.

fixed4 raymarch(float3 ro, float3 rd, float s) {
    const int maxstep = 64;
    float t = 0; // current distance traveled along ray

    for (int i = 0; i < maxstep; ++i) {
        float3 p = ro + rd * t; // World space position of sample
        float2 d = map(p);      // Sample of distance field (see map())

        // If the sample <= 0, we have hit something (see map()).
        if (d.x < 0.001) {
            // Simply return the number of steps taken, mapped to a color ramp.
            float perf = (float)i / maxstep;
            return fixed4(tex2D(_ColorRamp, float2(perf, 0)).xyz, 1);
        }

        t += d;
    }

    // By this point the loop guard (i < maxstep) is false.  Therefore
    // we have reached maxstep steps.
    return fixed4(tex2D(_ColorRamp, float2(1, 0)).xyz, 1);
}

Вот как выглядит визуализация этого в Unity:


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

Показанная выше визуализация подчёркивает серьёзную проблему нашего алгоритма. Пиксели, не показывающие образованных raymarching-ом объектов (а таких пикселей большинство) демонстрируют максимальный размер шагов! Это вполне логично: испущенные из этих пикселей лучи ни с чем не сталкиваются, поэтому бесконечно шагают вперёд. Чтобы устранить эту проблему производительности, мы добавим максимальное расстояние отрисовки:

fixed4 raymarch(float3 ro, float3 rd, float s) {
    const int maxstep = 64;
    const float drawdist = 40; // draw distance in unity units

    float t = 0; // current distance traveled along ray

    for (int i = 0; i < maxstep; ++i) {
        float3 p = ro + rd * t; // World space position of sample
        float2 d = map(p);      // Sample of distance field (see map())

        // If the sample <= 0, we have hit something (see map()).
        // If t > drawdist, we can safely bail because we have reached the max draw distance
        if (d.x < 0.001 || t > drawdist) {
            // Simply return the number of steps taken, mapped to a color ramp.
            float perf = (float)i / maxstep;
            return fixed4(tex2D(_ColorRamp, float2(perf, 0)).xyz, 1);
        }

        t += d;
    }

    // By this point the loop guard (i < maxstep) is false.  Therefore
    // we have reached maxstep steps.
    return fixed4(tex2D(_ColorRamp, float2(1, 0)).xyz, 1);
}

Вот как выглядит наша тепловая карта после этой оптимизации:


Ещё одна визуализация производительности после внесения оптимизации.

Гораздо лучше! Мы можем внести эту оптимизацию в обычный цикл raymarch, добавив проверку расстояния отрисовки к проверке усечения буфера глубин:

fixed4 raymarch(float3 ro, float3 rd, float s) {
    fixed4 ret = fixed4(0,0,0,0);

    const int maxstep = 64;
    const float drawdist = 40; // draw distance in unity units

    float t = 0; // current distance traveled along ray
    for (int i = 0; i < maxstep; ++i) {
        if (t >= s || t > drawdist) { // check draw distance in additon to depth
            ret = fixed4(0, 0, 0, 0);
            break;
        }

        // ...
    }

    return ret;
}

В заключение


Надеюсь, эта статья стала для вас хорошим введением в поля расстояний Raymarching-а. Полную реализацию можно найти в этом репозитории Github. Если хотите узнать подробности, то рекомендую изучить примеры на Shadertoy и на ресурсах по ссылкам ниже. Большинство техник, используемых в Distance Field Raymarching не задокументировано формально, поэтому придётся искать их самостоятельно. С теоретической точки зрения, я не касался целой сферы интересных тем, связанных с raymarching-ом, в том числе теней, ambient occlusion, операций с комплексными областями, техник сложного процедурного текстурирования, и т.д. Рекомендую вас самим изучить все эти трюки!

Ссылки


  • По-моему, блог Иниго Килеса — важнейший ресурс по полям расстояний Raymarching-а. В его статьях рассказывается о сложных техниках raymarching-а.
  • Эта статья 9bit Science — отличное чтение по теории, лежащей в основе raymarching-а.
  • Shadertoy — это сайт для просмотра шейдеров, содержащий множество поразительных примеров полей расстояний raymarching-а (а также других способов применения raymarching-а, например, объёмного освещения). На сайте есть полный доступ к исходному коду каждого шейдера, поэтому это отличный способ изучения различных техник.
  • Это обсуждение на Gamedev Stackexchange даёт интересные сведения о фундаментальных основах работы шейдеров raymarching-а, а также предлагает альтернативные варианты использования raymarching-а, например, в объёмном освещении.

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 5

    0

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

      0
      У каждого метода есть свои сильные и слабые стороны.
      При использовании SDF, сделать GI, AO, отражения, и прочие эффекты тривиально и это занимает несколько строчек кода. В то время как с полигонами на это могут уйти годы и результат будет ужасный.
      Произвольный объект можно представить как сумму аналитически заданных поверхностей.

      Вот тут, к примеру, нет ни единого полигона.
      www.youtube.com/watch?v=NOGwN7l5epA
      Сцена воссоздана во внутриигровом редакторе Dreams.
      twitter.com/MartinNebelong

      Да, для скорости тут используется point splatting, но у них был и чисто SDF рендер.
        +1
        А что это за point splatting? можно ссылочку?
        +2
        любая полигональная модель можен быть использована для генерации разреженной, адаптивной воксельной сетки, где будет храниться sdf.
        www.openvdb.org/download
        все примеры внизу, которые выглядит как полигональные — на самом деле sdf
        0
        DimPal
        А что это за point splatting? можно ссылочку?


        Просто рисуется куча точек (спрайтов) на экране. При достаточной плотности получается изображение без дыр.

        Вот момент в презентации про рендеринг в Dreams, хотя лучше посмотреть её целиком.
        youtu.be/u9KNtnCZDMI?t=1455

        Также есть ещё такое видео (подробно не смотрел)
        www.youtube.com/watch?v=yZM_Ij9aeOA

        upd: не туда ответил

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое