Отложенное освещение

Введение

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

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

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

Отложенное освещение

Решением описанных выше проблем является технология отложенного освещения (deffered shading или deffered lighting).

Суть технологии заключается в том, что во время отрисовки геометрии (geometry pass) не производится расчёт освещения. Вместо этого в текстуры записывается позиция, нормаль, цвет, другие атрибуты...в общем, всё что нужно для вычисления итоговой освещённости. Эти текстуры суммарно называются g-буфер (g-buffer). После этого с использованием данных из g-буфера происходит вычисление итоговой картинки (т.н. lighting pass).

G-буфер представляет из себя набор двумерных текстур. То есть освещение рассчитывается уже не в трёхмерной сцене, а на двумерной картинке. Каждая точка, для которой рассчитывается освещение, является точкой итогового кадра. Таким образом, освещение вычисляется только для тех точек, которые прошли тест глубины и будут отображаться в кадре, а для остальных (закрытых другими объектами) не вычисляется ничего, что даёт существенный прирост производительности.

Фактически, deffered shading можно назвать особым видом пост-процессинга, при котором на вход шейдеру передаётся необходимая для расчёта освещения информация в виде двумерных текстур, а на выходе получается уже итоговый кадр.

Плюсы и минусы технологии

Рассмотрим плюсы технологии отложенного освещения:

  • Подготовку g-буфера можно уложить в один проход, то есть сцена рисуется один раз
  • Освещённость рассчитывается только для видимых пикселей
  • Поддержка большого количества источников света (и низкие затраты на расчёт освещения с большим кол-вом источников света)
  • Простота реализации различных эффектов пост-процессинга
  • Простота при реализации сцены с различными материалами

Недостатки технологии (как их обходить поговорим в конце статьи):

  • Сложность реализации прозрачных и полупрозрачных материалов
  • Сложность реализации сглаживания (AntiAliasing)

Подготовка данных

Исходная сцена представляет из себя комнату с кубиками и трёхмерным логотипом, освещённую стандартным шейдером Unity (по сути освещение по Фонгу).

Реализация технологии deffered shading состоит из следующих шагов:

  • Сформировать пустой g-буфер и объяснить Unity что результат работы geometry pass (результат обычной отрисовки сцены) должен быть записан в g-буфер
  • Написать шейдер материала (surface шейдер), который будет работать вместо стандартного и вместо расчёта освещения будет заполнять g-буфер

  • На этом шаге имеется заполненный g-буфер и абсолютно чёрный экран, так как во-первых, сцена отрисовывается в g-буфер, а во-вторых, шейдер материала не занимается освещением, его задача заполнить g-буфер

  • Написать deffered shader, который примет на вход информацию из g-буфера, рассчитает освещение и выведет итоговую картинку

Исходная сцена

Рендер в несколько текстур в Unity (MRT)

Технология, при которой отрисовка сцены происходит не сразу на экран, а в текстуру, называется render target. В случае отложенного освещения за один проход нужно заполнить не одну, а сразу несколько текстур. Такая технология называется multiple render target, или кратко MRT.

Напишем класс, который будет цепляться к камере Unity и «переключать» её в режим работы MRT.

Инициализация переменных:

Развернуть
// количество текстур в нашем g-буфере
private const int MRT_COUNT = 2;

private Camera camera;

private RenderBuffer[] buffers = new RenderBuffer[MRT_COUNT];
private RenderTexture[] texes = new RenderTexture[MRT_COUNT];

Настройка рендера

Далее необходимо инициализировать g-буфер и указать камере Unity, что отрисовка должна происходить в g-буфер, а не на экран.

Развернуть
void OnEnable()
{
    // формируем пустой буфер
    for (int i = 0; i < MRT_COUNT; i++) {
        texes[i] = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.ARGB32);
        buffers[i] = texes[i].colorBuffer;
    }

    // сообщаем камере что отрисовывать надо в буфер
    // в буфер также надо записывать значение глубины (оно нам понадобится позже)
    camera = GetComponent();
    camera.depthTextureMode = DepthTextureMode.Depth;
    camera.SetTargetBuffers(buffers, texes[0].depthBuffer);
}

Запись в G-буфер (geometry pass)

На данном шаге имеется пустой g-буфер. Следующая задача – написать шейдер для простого материала, который вместо освещения будет заполнять g-буфер.

Вершинный шейдер:

Развернуть
struct fragment_in
{
    float4 pos: POSITION;
    float2 uv : TEXCOORD1;
    float3 normal: TEXCOORD2;
};

fragment_in vert(vertex_data v)
{
    fragment_in o;

    o.pos = UnityObjectToClipPos(v.vertex);
    o.uv = v.uv;
    o.normal = UnityObjectToWorldNormal(v.normal);

    return o;
}

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

Развернуть
struct fragment_out
{
    float4 color: COLOR0;
    float4 normal: COLOR1;
};

Обратите внимание, что графический конвейер сам поймёт, что COLOR0 – первая текстура в G-буфере, COLOR1 — вторая, и т.д. Кроме этого, текстура глубины не заполняется «вручную»: это произойдёт автоматически, т.к. соответствующие инструкции для Unity уже были заданы в скриптовой части выше.

Фрагментный шейдер:

Развернуть
fragment_out frag(fragment_in i): COLOR
{
    fragment_out o;

    float3 color = tex2D(_MainTex, i.uv * _MainTex_ST.xy);
    // если текстура не назначена, то берём цвет из переменной _Color
    if (distance(color, 1.0) == 0) {
        color = _Color;
    }

    float3 N = normalize(i.normal);

    o.color = float4(color, 1.0);
    o.normal = float4(N * 0.5 + 0.5, 1.0);

    return o;
}

Компоненты вектора нормали могут иметь как положительное, так и отрицательное значение (от -1 до 1). В базовом варианте в текстуру нельзя записать отрицательное значение, поэтому вектор нормали умножается на 0.5 (получается диапазон от -0.5 и до 0.5), после чего к нему прибавляется 0.5, тем самым получается диапазон от 0 до 1. В deffered шейдере необходимо проделать обратную операцию, получив исходное значение нормали.

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

Если всё было сделано правильно, то g-буфер должен содержать в себе следующую информацию:

На этом отрисовка сцены (geometry pass) закончена, в g-буфере содержится вся необходимая для расчёта освещённости информация, и можно переходить к следующему шагу: расчёту освещения (lighting pass).

Расчёт освещения

Восстановление позиции из карты глубины

Поговорим о том, как строится 2D изображение из трёхмерной сцены. Как известно, точка в трёхмерном пространстве переводится сначала из локального пространства в пространство модели, затем в мировые координаты, и далее проектируется на двумерную плоскость. Схематически данный алгоритм можно представить в следующем виде:

Model → View → Projection

В случае с deffered shading'ом перед разработчиком стоит обратная задача: имея двумерные координаты точки на экране и значение из карты глубины нужно получить координаты точки в мировом пространстве. Для этого требуется построить матрицу, обратную матрице MVP. Строить её необходимо в скриптовой части (не в шейдере), т.е. в нашем контроллере, так как эта матрица одинаковая для всех точек, да и вычисление обратной матрицы - достаточно дорогая операция.

Развернуть
// матрица перехода из пространства камеры (!) в мировое пространство
Matrix4x4 matrixCameraToWorld = camera.cameraToWorldMatrix;

// обратная projection матрица
Matrix4x4 matrixProjectionInverse = GL.GetGPUProjectionMatrix(camera.projectionMatrix, false).inverse;

// итоговый результат: матрица перехода из экранных координат в мировое пространство
Matrix4x4 matrixHClipToWorld = matrixCameraToWorld * matrixProjectionInverse;

Эту матрицу необходимо передать в deffered шейдер, который будет рассчитывать итоговое освещение.

Передача данных в deffered шейдер

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

Развернуть
private Material deffered_material;

if (!deffered_material) {
    var shader = Shader.Find("karonator/Deffered");

    if (shader != null) {
        deffered_material = new Material(shader);
        deffered_material.hideFlags = HideFlags.DontSave;
        
        for (int i = 0; i < texes.Length; i++) {
            // передаём последовательно текстуры из g-буфера в шейдер
            // с названием _Tex0, _Tex1
            deffered_material.SetTexture("_Tex" + i, texes[i]);
        }
    }
}

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

Развернуть
void OnPreRender()
{
    LightsManager LM = GetComponent();
    deffered_material.SetVectorArray("_LightsPositions", LM.lightsPositions());
    deffered_material.SetVectorArray("_LightsColors", LM.lightsColors());
}

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

Развернуть
void OnRenderImage (RenderTexture source, RenderTexture destination)
{
    // каждый кадр вычисляем матрицу перехода из экранного пространства в мировое
    Matrix4x4 matrixCameraToWorld = camera.cameraToWorldMatrix;
    Matrix4x4 matrixProjectionInverse = GL.GetGPUProjectionMatrix(camera.projectionMatrix, false).inverse;
    Matrix4x4 matrixHClipToWorld = matrixCameraToWorld * matrixProjectionInverse;

    // передаём матрицу в шейдер
    deffered_material.SetMatrix("clipToWorld", matrixHClipToWorld);

    // применяем шейдер к кадру
    Graphics.Blit(source, destination, deffered_material, 0);
}

Вся необходимая информация передана в deffered шейдер и можно перейти к финальному шагу: расчёту освещения.

Расчёт освещения (lighting pass)

Входные данные:

Развернуть
uniform sampler2D _Tex0; // первая текстура из g-буфера (цвет)
uniform sampler2D _Tex1; // вторая текстура из g-буфера (нормали)
uniform sampler2D _CameraDepthTexture; // карта глубины

uniform float3 _LightsPositions[32]; // позиции источников света
uniform float3 _LightsColors[32]; // цвета источников света

uniform float4x4 clipToWorld; // матрица перехода

Функция перехода из пространства экрана в мировое пространство:

Развернуть
float3 screenToWorld(float2 uv) {
    float depth = tex2D(_CameraDepthTexture, uv).x;

    // формируем точку в пространстве экрана со значением из карты глубины
    float4 clipSpacePosition = float4(uv * 2.0 - 1.0, depth, 1.0);
    // переводим её в мировые координаты
    float4 worldPosition = mul(clipToWorld, clipSpacePosition);
    // после практически любой операции с матрицами и координатами
    // не забываем делить результат на w
    return worldPosition.xyz / worldPosition.w;
}

Функция расчёта освещения:

Развернуть
float4 frag(v2f_img i): COLOR {
    float4 color = tex2D(_Tex0, i.uv);
    float4 raw_normal = tex2D(_Tex1, i.uv);
    float3 pos_world = screenToWorld(i.uv);

    float3 N = normalize(2 * (raw_normal.xyz - 0.5));

    float3 result = float3(0, 0, 0);
    for (int i = 0; i < 32; ++i)
    {
        float3 L = normalize(_LightsPositions[i] - pos_world);
        float dist = distance(_LightsPositions[i], pos_world);

        float contribution = 1.0 / (pow(dist, 2.0) + 0.0001);
        result += max(dot(L, N) * contribution, 0) * normalize(_LightsColors[i]);
    }

    return float4(result * color.xyz, 1.0);
}

Функция освещения достаточно простая и представляет из себя обычную функцию Ламберта, однако вклад каждого источника света умножается на переменную contribution, обратно пропорциональную квадрату расстояния от точки до источника света: таким образом чем дальше источник света, тем меньший вклад он вносит в освещённость точки.

Не забудьте, что в буфере нормали хранятся в сжатом виде, т.е. перед использованием нормали необходимо провести обратное преобразование, чтобы восстановить исходное значение вектора нормали и правильно рассчитать освещение.

Normal mapping

В качестве небольшого бонуса можно добавить к нашему deffered shading’у normal mapping. При этом даже не понадобится менять deffered шейдер: достаточно «исказить» нормали ещё в момент записи их в g-буфер с использованием информации из карты нормалей, то есть чуть чуть усложнить surface шейдер.

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

Нормаль (синий), тангент (красный), бинормаль (зелёный)

Вершинный шейдер:

Развернуть
o.normal = UnityObjectToWorldNormal(v.normal);
o.tangent = UnityObjectToWorldDir(v.tangent.xyz);
o.binormal = normalize(cross(o.normal, o.tangent)) * v.tangent.w;

Фрагментный шейдер:

Считываем значение из карты нормалей и умножаем на TBN матрицу.

Развернуть
float3 tangentNormal = UnpackNormal(tex2D(_NormalMap, i.uv * _MainTex_ST.xy));
float3x3 TBN = float3x3(normalize(i.tangent), normalize(i.binormal), normalize(i.normal));
TBN = transpose(TBN);

float3 N = normalize(mul(TBN, tangentNormal));

Подробный разбор того, как работает normal mapping и что такое TBN матрица, вероятнее всего появится на этом сайте позже в виде статьи. Пока же, внизу статьи будет приложена ссылка с описанием алгоритма работы normal mapping'а.

Проблемы технологии

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

Полноэкранное сглаживание (AntiAliasing):

Стандартное полноэкранное сглаживание (MSAA) при отложенном освещении работать не будет, однако может быть реализовано: при этом текстуры g-буфера нужно будет создавать кратно большими чем размер экрана, и сглаживание реализовывать выборками из этих текстур в deffered шейдере.

Однако так сейчас делать не модно (описанный выше подход вычислительно очень неэффективен). Большинство современных методов сглаживания основано на алгоритмах поиска линий излома в кадре (по сути это применение технологии edge detecting к карте глубины). Подобная технология уже описывалась на этом сайте в статье про мультяшный рендеринг, однако там мы границы обводили, а в случае со сглаживанием они размываются, там самым становясь менее резкими.

Полупрозрачные объекты:

Визуализация полупрозрачных объектов при использовании отложенного освещения - не самая тривиальная задача, так как всё сцена рисуется за один проход в текстуру, а для визуализации полупрозрачных объектов нам нужно нарисовать и сам объект, и то что находится за ним.

Не самым эффективным, но рабочим способом решения этой проблемы является визуализация полупрозрачных объектов объектов «обычным» способом (forward rendering) после deffered рендеринга. Такой способ требует программной поддержки как deffered так и forward подхода, что повышает общую сложность визуализации.

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

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

Оптимизация освещения:

Отложенное освещение само по себе является очень неплохой оптимизацией рендера, но его тоже можно ускорить. Самый часто используемый способ оптимизации deffered shading'а это реализации технологии light volumes.

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

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

Такие методы оптимизации могут дать очень существенный прирост производительности при грамотной реализации.

Разные материалы в кадре:

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

Ссылки и файлы

Хотелось бы выразить искреннюю благодарность пользователю refroqus с форума gamedev.ru за замечательную 3D модель моего логотипа. Спасибо, друг.

Deffered shading