В этой статье мы рассмотрим один из вариантов реализации отложенного освещения на OpenGL ES 2.0.



Deferred Lighting


Традиционный способ рендеринга сцены (forward rendering), предполагает отрисовку отдельного объекта за один или несколько проходов, в зависимости от количества и природы обрабатываемых источников света (на каждом проходе объект получает освещение от одного или нескольких источников). Это означает, что количество ресурсов, затрачиваемое на один попиксельный источник света, имеет порядок роста O(L*N), где N количество освещаемых объектов, а L количество освещаемых пикселей.


Основная задача отложенного освещения (deferred shading/lighting/rendering) более эффективно обрабатывать большое количество источников света, средствами отделения просчета геометрии сцены от просчета освещения. Тем самым, сократив количество затрачиваемых ресурсов до O(L).


В общем случае, техника состоит из двух проходов


  • Geometry pass. Объекты отрисовываются для создания буферов экранного пространства (G-buffer) c глубиной, нормалями, позициями, альбедо и степенью зеркальности.
  • Lighting pass. Буферы, созданные на предыдущем проходе, используются для расчёта освещения и получения финального изображения.

Для использования данной техники в полном объеме и построения буферов, необходима аппаратная поддержка Multiple Render Targets (MRT).


К сожелению MRT, поддерживается только на процессорах совместимых с OpenGL ES 3.0.


Для того, чтобы обойти это аппаратное ограничение мы будем использовать модифицированную технику отложенного освещения, известную как Light Pre-Pass (LPP).


Light Pre-Pass


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


  • Geometry pass. Объекты отрисовываются для создания буферов экранного пространства только с глубиной и нормалями.
  • Lighting pass. Строится буфер освещенности. Для расчета освещенности, помимо данных с предыдущего прохода (буферы глубины и нормалей), нам также необходима трехмерная позиция пикселя. Техника предпологает восстановление позиции по буферу глубины.
  • Final pass. Объекты отрисовываются для создания финального изображения, на основе буфера освещенности и цветовых текстур объектов.

Прежде чем подробно разобрать каждый проход, следует упомянуть дополнительные ограничения, накладываемые на буферы, в рамках OpenGL ES 2.0. Нам потребуется поддрержка двух дополнительных расширения OES_rgb8_rgba8 и OES_packed_depth_stencil.


  • OES_rgb8_rgba8. Позволит нам поддерживать в качестве буферов, текстуры с форматом пикесельных данных RGBA8. Такие текстуры мы будем использовать в качестве буфера нормалей и буфера освещенности.
  • OES_packed_depth_stencil. Позволит нам использовать буфер глубины вместе с буфером трафарета и форматом пиксельных данных UNSIGNED_INT_24_8_OES. (24 бита — глубина, 8 бит — значение трафарета). Буфер траферета понадобится на втором этапе для оптимизации. Также, из-за 24х битного буфера глубины нам понадобится поддержка вычислений с высокой точностью во фрагментном шейдере.

На данный момент эти расширения поддерживают более 95% существующих устройств.


Geometry pass


Создание буфера для закадровой отрисовки


// Создаем текстуру для буфера глубины
glBindTexture(GL_TEXTURE_2D, shared_depth_buffer);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24_OES, cx,  cy, 0,
    GL_DEPTH_STENCIL_OES, GL_UNSIGNED_INT_24_8_OES, nullptr);
glBindTexture(GL_TEXTURE_2D, 0);

// создаем текстуру для буфера нормалей
glBindTexture(GL_TEXTURE_2D, normal_buffer);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, cx,  cy, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_2D, 0);

// Привязываем текстуры к фреймбуферу прохода.
glBindFramebuffer(GL_FRAMEBUFFER, pass_fbo);
// Привязываем текстуру в качестве цветового буфера
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
    GL_TEXTURE_2D, normal_buffer, 0);
// Привязываем тестуру в качестве буфера глубины.
// Эту же текстуру мы привяжем в качестве буфера глубины и буфера трафарета во втором проходе
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
    GL_TEXTURE_2D, shared_depth_buffer, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

Отрисовка.


// Вершинный шейдер
attribute vec3 a_vertex_pos;
attribute vec3 a_vertex_normal;

uniform mat4 u_matrix_mvp;
uniform mat4 u_matrix_model_view;

varying vec3 v_normal;

void main()
{
    gl_Position = u_matrix_mvp * vec4(a_vertex_pos, 1.0);
    v_normal = vec3(u_matrix_model_view * vec4(a_vertex_normal, 0.0));
}

// Фрагментный шейдер
precision lowp float;

varying vec3 v_normal;

void main()
{
    // сохраняем нормаль в видовых координатах 
    gl_FragColor = vec4(v_normal * 0.5 + 0.5, 1.0);
}

Результат



Lighting pass


Создание буфера для закадровой отрисовки


// тектура для буфера освещенности создается также как для буфера нормалей на первом проходе
...
// Привязываем текстуры к фреймбуферу прохода.
glBindFramebuffer(GL_FRAMEBUFFER, pass_fbo);
// Привязываем текстуру в качестве цветового буфера
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
    GL_TEXTURE_2D, light_buffer, 0);
// Привязываем тестуру в качестве буфера глубины и буфера трафарета.
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
    GL_TEXTURE_2D, shared_depth_buffer, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT,
    GL_TEXTURE_2D, shared_depth_buffer, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

Отрисовка


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


В качестве апроксимации световыx объемов точечных источников света мы будем использовать примитив под названием Icosphere



Шейдеры
// Вершинный шейдер
attribute vec3 a_vertex_pos;
uniform mat4 u_matrix_mvp;
varying vec4 v_pos;

void main()
{
    v_pos = u_matrix_mvp * vec4(a_vertex_pos, 1.0);
    gl_Position = v_pos;
}

// Фрагментный шейдер
precision highp float;

// ближняя плоскость отсечения
uniform float u_camera_near;
// дальняя плоскость отсечения
uniform float u_camera_far;
// параметры для воcстановления трехмерной позиции пискселя по глубине
// u_camera_view_param.x = tan(fov / 2.0) * aspect;
// u_camera_view_param.y = tan(fov / 2.0);
uniform vec2  u_camera_view_param;

// u_light_inv_range_square = 1.0 / light_radius^2
// используется для раcчета ослабления света
uniform float u_light_inv_range_square;
// интенсивность света
uniform vec3  u_light_intensity;
// позиция источника в видовых координатах
uniform vec3  u_light_pos;

// буфер нормалей
uniform sampler2D u_map_geometry;
// буфер глубины
uniform sampler2D u_map_depth;

varying vec4 v_pos;

const float shininess = 32.0;

// рассчитываем ослабление света
float fn_get_attenuation(vec3 pos)
{
    vec3 direction = u_light_pos - pos;
    float value = dot(direction, direction) * u_light_inv_range_square;
    return 1.0 - clamp(value, 0.0, 1.0);
}

// получаем значение глубины в диапазоне от near до far
float fn_get_linearize_depth(float depth)
{
    return 2.0 * u_camera_near * u_camera_far /
             (u_camera_far + u_camera_near -
             (depth * 2.0 - 1.0) * (u_camera_far - u_camera_near));
}

// получаем текстурные координаты для доступа к буферам
vec2 fn_get_uv(vec4 pos)
{
    return (pos.xy / pos.w) * 0.5 + 0.5;
}

// получаем нормаль
vec3 fn_get_view_normal(vec2 uv)
{
    return texture2D(u_map_geometry, uv).xyz * 2.0 - 1.0;
}

// восстанавливаем трехмерную позицию пикселя по буферу глубины
vec3 fn_get_view_pos(vec2 uv)
{
    float depth = texture2D(u_map_depth, uv).x;
    depth = fn_get_linearize_depth(depth);
    return vec3(u_camera_view_param * (uv * 2.0 - 1.0) * depth, -depth);
}

void main()
{
    // рассчитываем освещение
    vec2 uv = fn_get_uv(v_pos);
    vec3 normal = fn_get_view_normal(uv);
    vec3 pos = fn_get_view_pos(uv);
    float attenuation = fn_get_attenuation(pos);

    vec3 lightdir = normalize(u_light_pos - pos);
    float nl = dot(normal, lightdir) * attenuation;

    vec3 reflectdir = reflect(lightdir, normal);
    float spec = pow(clamp(dot(normalize(pos), reflectdir), 0.0, 1.0), shininess);

    gl_FragColor = vec4(u_light_intensity * nl, spec * nl);
}

Результат для одного источника.



Слева показан объем — это пиксели, для которых была рассчитана освещенность. Справа показаны пиксели, которые фактически получили освещенность. Можно заметить, что освещенных пикселей гораздо меньше, чем тех, которые учавствовали в расчетах.


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


Тогда весь процесс отрисовки источника светы будет выглядеть так:


// 0. включаем тест трафарета и запрещаем запись в буфер глубины 
//    (общие действия для отрисовки всех источников)
glEnable(GL_STENCIL_TEST);
glDepthMask(GL_FALSE);

// настраиваем опции и заполняем буфер трафарета
// ------------------------------------------------------------------------
// 1. включаем тест глубины
glEnable(GL_DEPTH_TEST)
// 2. запрещаем запись в цветовой буфер, мы будем заполнять только буфер трафарета
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
// 3. очищаем трафарет. Буфер заполняется для каждого источника
glClear(GL_STENCIL_BUFFER_BIT);
// 4. настраиваем опции заполнения трафарета
glStencilFunc(GL_ALWAYS, 0, 0);
glStencilOpSeparate(GL_BACK, GL_KEEP, GL_INCR_WRAP, GL_KEEP);
glStencilOpSeparate(GL_FRONT, GL_KEEP, GL_DECR_WRAP, GL_KEEP);
// 5. Отрисовываем объем
...

// 6. Разрешаем обрабатывать только те пиксели, 
//    значение буфера траферета для которых, не равно нулю
glStencilFunc(GL_NOTEQUAL, 0, 0xFF);
// 7. Выключаем тест глубины
glDisable(GL_DEPTH_TEST);
// 8. Разрешаем запись в цветовой буфер
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);

// отрисовываем источник света
// ----------------------------------------------------------------------
// 1. Разрешаем возможность исключать из отрисовки переднюю или заднюю поверхности полигонов
glEnable(GL_CULL_FACE);
// 2. Исключаем из отрисовки переднюю поверхность полигонов
glCullFace(GL_FRONT);
// 3. Отрисовываем объем (добавляем его в буфер освещенности адитивным смешиванием).
//    Пиксели, которые не прошли тест траферета будут отброшены
...
// 4. Возвращаем исключения по умолчанию
glCullFace(GL_BACK);
// 5. Отключаем возможность исключения
glDisable(GL_CULL_FACE);

Шейдеры для отрисовки объема в буфер трафарета
// Вершинный шейдер
attribute vec3 a_vertex_pos;
uniform mat4 u_matrix_mvp;

void main()
{
    gl_Position = u_matrix_mvp * vec4(a_vertex_pos, 1.0);
}

// Фрагментный шейдер
precision lowp float;
void main()
{
}

Результат



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


Final pass


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


// Вершинный шейдер
attribute vec3 a_vertex_pos;
uniform mat4 u_matrix_mvp;

varying vec4 v_pos;

void main()
{
    v_pos = u_matrix_mvp * vec4(a_vertex_pos, 1.0);
    gl_Position = v_pos;
}

// Фрагментный шейдер
precision lowp float;

uniform sampler2D u_map_light;
uniform vec3 u_color_diffuse;
uniform vec3 u_color_specular;

varying vec4 v_pos;

vec2 fn_get_uv(vec4 pos)
{
    return (pos.xy / pos.w) * 0.5 + 0.5;
}

void main()
{
    vec4 color_light = texture2D(u_map_light, fn_get_uv(v_pos));

    vec3 color = color_light.rgb;
    gl_FragColor = vec4(u_color_diffuse * color + 
        u_color_specular * (color * color_light.a), 1.0);
}



Код проекта можно найти на GitHub.


Буду рад комментариям и предложениям (можно по почте yegorov.alex@gmail.com)
Спасибо!

Поделиться с друзьями
-->

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


  1. sborisov
    04.10.2016 20:29

    А можете посоветовать хороший учебник по шейдерам? А то сейчас шейдеры сложнее OpenGL самого стали…


    1. iOrange
      04.10.2016 20:46
      +1

      Вот здесь много статей по шейдерам
      У этого же автора и книги есть. Они немного староваты, но последняя все еще полезна.


  1. badimao
    04.10.2016 21:50

    Странно что цвета не смешиваются.
    почему свет от дверей ни и окон(?) ни как не влияет на желтый хотя бы на границе смешения?
    Общее освещение только разбавляет цвета.
    Но самое большое разочарование — на последних кадрах желтый кубик под синим освещением остался желтым, а зеленый — зеленым. Это не правильно.


    1. PkXwmpgN
      04.10.2016 21:55

      Мне кажется смешиваются.
      Вот так эта сцена выглядит, отрендериная blender'ом


      Все источники


      1. PkXwmpgN
        04.10.2016 22:02

        Ну и да, там нет окон — это дырки(отверстия) просто.


  1. Konstontin
    05.10.2016 00:19

    Никак не пойму, почему нельзя обойтись двумя отрисовками всех объектов: 1) глубина, нормали 2) цвет. Ну а затем ренедерим шары источников света в буфер цвета, используя буферы из 1. В итоге есть готовая текстура — ее мы показываем полноэкранным квадом.


    1. PkXwmpgN
      05.10.2016 00:19

      Phong reflection model


      Обратите внимание на формулу.
      Как вы предлагаете это считать, если будите рендерить шары в буфер цвета? Ну точнее, каким образом нужно рендерить эти шары?


      1. Konstontin
        05.10.2016 14:16

        Так же с помощью буфера трафарета. Шейдер источника света будет брать пиксель из буфера цвета и добавлять к нему свой цвет. Или типо нельзя одновременно рендерить и читать из текстуры?


        1. PkXwmpgN
          05.10.2016 14:51

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


          <цвет_пикселя> = <изначальный_цвет> + <цвет_источника_1> + ... + <цвет_источника_n>

          А должно быть так:


          <цвет_пикселя> = <изначальный_цвет> * (<цвет_источника_1> + ... + <цвет_источника_n>)

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


          <цвет_пикселя> = <изначальный_цвет> * <цвет_источника_1> + ... + <изначальный_цвет> * <цвет_источника_n>

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


          1. Konstontin
            05.10.2016 15:51

            Все равно непонятно почему должно быть так:

            <цвет_пикселя> = <изначальный_цвет> * (<цвет_источника_1> +… + <цвет_источника_n>)


            В википедии как раз указана сумма:
            <цвет_пикселя> = <фоновой (ambient)> + (<рассеянной (diffuse) источник 1> + <блики (specular) источник 1> + ... + <рассеянной (diffuse) источник n> + <блики (specular) источник n>) 
            


            Ambient мы сохраняем в текстуру и потом постепенно добавляем диффузные и бликовые составляющие для каждого источника. Что-то тут не то.


            1. PkXwmpgN
              05.10.2016 16:01

              Нет, посмотрите внимательно, там указано вот так


              <цвет_пикселя> = <фоновой (ambient)> * Ka + (<рассеянной (diffuse) источник 1> * Kd + <блики (specular) источник 1> * Ka + ... + <рассеянной (diffuse) источник n> * Kd + <блики (specular) источник n> * Ka)

              где Ka, Kd, Ks — это свойства метериала отражать соответствующий свет — т.е. то что у вас в цветовом буфере.


  1. DrZlodberg
    05.10.2016 09:04

    А нельзя вместо геометрии для сферы (особенно если источников много) использовать спрайт сферы с глубиной? Точность будет меньше, но разницу вряд-ли будет заметно.


    1. Torvald3d
      05.10.2016 12:28
      +1

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


      1. DrZlodberg
        05.10.2016 13:15

        Об этом я не подумал. Но по факту высокого разрешения, скорее всего, и не понадобится. Границы будут размытые + само освещение плавное. Хотя надо проверять.


  1. PkXwmpgN
    05.10.2016 21:08

    Еще был вопрос в комментариях (я его отклонил, случайно), "почему такие засветы на потолке?"
    Это потому что нет тененй пока.


    вот так будет с тенями