OpenGL ES 2.0. Отложенное освещение
В этой статье мы рассмотрим один из вариантов реализации отложенного освещения на 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)
Спасибо!