На этом уроке мы рассмотрим тему отображения теней в OpenGL.
Тени
Тени — это результат отсутствия света. Например, если между лучами, исходящими от источника света, и объектом расположено какое-нибудь препятствие, то объект будет находиться в тени. Тени придают освещенной сцене дополнительную глубину и большую реалистичность, тем самым облегчая зрителю восприятие пространственного расположения объектов, которые присутствуют в ней. Например, посмотрите на следующее изображение сцены с применением теней и без них:
Вы можете видеть, что с тенями становится гораздо более очевидным соотношение расположения объектов относительно друг друга. Например, тот факт, что один из кубов находится в воздухе, действительно заметен только тогда, когда в сцене присутствуют тени.
При всем при этом, реализация теней является довольно сложной задачей отчасти потому, что в данный момент еще не разработан идеальный алгоритм рендеринга теней в режиме реального времени. Существует несколько хороших методов приближенного описания теней, но у всех них есть свои нюансы и недостатки в использовании, которые мы должны учитывать.
Одним из методов, используемых большинством видеоигр, дающим достойные результаты и являющимся при этом относительно простым в своей реализации, считается метод теневых карт. К плюсам данного метода можно отнести то, что его не слишком сложно понять, он не слишком затратный по производительности и довольно легко распространяется на более продвинутые алгоритмы (такие, как всенаправленные и каскадные теневые карты).
Отображение теней
Идея, лежащая в основе теневых карт, довольно проста: мы визуализируем сцену с позиции, из которой её «видит» источник света, и все те предметы (или их части), которые при этом будут видны — будут освещены, а всё остальное, что мы не сможем увидеть — будет находиться в тени. Представьте себе пол с большим ящиком на нем. Когда источник света будет направлен на ящик, то сам ящик он сможет увидеть, а вот то, что находится под ящиком (пол, другие объекты или их части) — нет.
Синим цветом обозначены фрагменты, находящиеся в поле зрения источника света, а черным — те фрагменты, которые спрятаны от него за различными преградами: данные фрагменты визуализируются как затененные. Если мы проведем линию или луч от источника света до фрагмента крайнего правого ящика, то увидим, что луч сначала попадает в нависающий сверху ящик, а затем — в крайний правый. В результате фрагмент нависающего ящика будет освещен, а фрагмент крайнего правого ящика — нет, т.е. будет находиться в тени.
Чтобы определить, освещен ли фрагмент или он находится в тени, нам нужно взять такую точку, в которой луч впервые встречается с объектом, и сравнить данную ближайшую точку с другими точками на данном луче. Если проверяемая точка расположена дальше по лучу относительно ближайшей точки, то она должна находиться в тени. Перебор всех возможных световых лучей (а их может быть больше тысячи штук) от такого источника света является крайне неэффективным способом и не слишком хорошо подходит для рендеринга в реальном времени. Мы можем сделать нечто подобное без выбрасывания во все стороны множества световых лучей с помощью буфера глубины.
Как мы уже знаем из соответствующего урока, значение в буфере глубины соответствует значению из диапазона [0,1]
глубины фрагмента, наблюдаемого с точки зрения камеры. А что, если бы мы визуализировали сцену с точки зрения источника света и сохранили полученные значения глубины в текстуре? В таком случае, мы можем произвести выборку ближайших значений глубины, наблюдаемых с точки зрения света. После этого, значения глубины будут соответствовать ближайшему видимому с точки зрения света фрагменту. А далее, сохраним все эти значения глубины в текстуре, которую будем называть теневой картой (или «картой глубины»):
На изображении слева показан направленный источник света (все лучи света параллельны друг другу), отбрасывающий тень на поверхность, находящуюся под ящиком. Используя значения глубины, хранящиеся в карте глубины, мы находим ближайшую точку поверхности и используем её, чтобы определить, находятся ли остальные фрагменты в тени. Затем создаем карту глубины, визуализируя сцену (с точки зрения света) с использованием матриц вида и проекции, соответствующих источнику света. Данные матрицы проекции и вида вместе составляют преобразование T, которое трансформирует любую трехмерную позицию в (видимое) координатное пространство света.
Примечание: Направленный свет не имеет положения, поскольку он моделируется в виде бесконечно удаленной точки. Тем не менее, для отображения теней мы должны визуализировать сцену с точки зрения света и, таким образом, визуализировать сцену с позиции, находящейся где-то вдоль линий направления света.
В правой части картинки мы видим тот же направленный свет и наблюдателя. Мы визуализируем фрагмент, для которого необходимо определить, находится ли он в тени, в точке P. Для этого мы сначала переводим точку P в координатное пространство источника света с помощью преобразования T. Поскольку точка P теперь не видна с точки зрения света, её z-координата соответствует её глубине, которая в данном примере равна 0.9
. Используя точку P, мы также можем индексировать карту глубины/тени, чтобы получить ближайшую видимую (относительно источника света) точку C, имеющую значение глубины, равное 0.4
. Поскольку индексирование карты глубины возвращает значение глубины, меньшее соответствующего значения глубины точки P, то мы можем заключить, что точка P скрыта за преградой и, следовательно, находится в тени.
Таким образом, алгоритм работы с теневыми картами будет содержать два этапа: на первом этапе мы визуализируем карту глубины, а на втором — визуализируем сцену как обычно и используем сгенерированную на предыдущем этапе карту глубины, чтобы вычислить, находятся ли фрагменты в тени. Это может показаться немного сложным, но как только мы проделаем все эти шаги друг за другом, то вы увидите, что не всё так уж сложно.
Карта глубины
Первый проход требует, чтобы мы создали карту глубины. Карта глубины (или «теневая карта», «карта теней») — это текстура глубины, визуализируемая с точки зрения света, которую мы будем использовать для теста теней. Поскольку нам нужно сохранить визуализированный результат сцены в текстуре, то нам снова понадобятся фреймбуферы.
Сначала мы создадим объект фреймбуфера для рендеринга карты глубины:
1 2 |
unsigned int depthMapFBO; glGenFramebuffers(1, &depthMapFBO); |
Затем мы создадим 2D-текстуру, которую будем использовать в качестве буфера глубины фреймбуфера:
1 2 3 4 5 6 7 8 9 10 |
const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024; unsigned int depthMap; glGenTextures(1, &depthMap); glBindTexture(GL_TEXTURE_2D, depthMap); glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); 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_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); |
Создание карты глубины не должно выглядеть слишком сложным. Поскольку для нас важны только значения глубины, то указываем формат текстуры как GL_DEPTH_COMPONENT
. Мы указываем ширину и высоту текстуры как 1024
(это будет разрешением карты глубины).
Теперь сгенерированную текстуру глубины прикрепляем к фреймбуферу в качестве буфера глубины:
1 2 3 4 5 |
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0); glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE); glBindFramebuffer(GL_FRAMEBUFFER, 0); |
При рендеринге сцены с точки зрения света нам нужна только информация о глубине, поэтому нет необходимости создавать цветовой буфер. Однако без цветового буфера создание объекта фреймбуфера не будет считаться завершенным, поэтому нам нужно явно указать OpenGL, что мы не собираемся визуализировать какие-либо цветовые данные. Мы сделаем это, установив тип буферов визуализации и чтения с помощью соответствующих функций glDrawBuffer() и glReadbuffer() в GL_NONE
.
С правильно настроенным фреймбуфером, который рендерит в текстуру значения глубины, мы можем начать первый проход, а именно: создать карту глубины. В сочетании со вторым проходом полная стадия рендеринга будет выглядеть примерно так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 1. Сначала рендерим в карту глубины glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT); glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); glClear(GL_DEPTH_BUFFER_BIT); ConfigureShaderAndMatrices(); RenderScene(); glBindFramebuffer(GL_FRAMEBUFFER, 0); // 2. Затем рендерим сцену, как обычно, но уже с отображением теней (используя для этого карту глубины) glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); ConfigureShaderAndMatrices(); glBindTexture(GL_TEXTURE_2D, depthMap); RenderScene(); |
В данном коде опущены некоторые детали, но у нас есть общее представление об отображении теней. Что важно здесь отметить, так это вызовы функции glViewport(). Поскольку часто теневые карты имеют разрешение, отличное от того, в котором мы изначально визуализируем сцену (обычно — разрешение окна), то нам нужно изменить параметры окна просмотра, чтобы приспособиться к размеру теневой карты. Если мы забудем обновить параметры окна просмотра, то полученная карта глубины будет либо неполной, либо слишком маленькой.
Преобразование светового пространства
В предыдущем фрагменте кода упоминалась функция ConfigureShaderAndMatrices(). Вместо нее мы расположим непосредственно код, отвечающий (как видно из названия функции) за конфигурирование шейдеров и матриц. Для выполнения второго прохода рендеринга необходимо выполнить следующие шаги: убедиться, что установлены правильные матрицы проекции и вида, а также установлены соответствующие матрицы модели для каждого объекта. Однако в первом проходе, чтобы визуализировать сцену с точки зрения света, необходимо использовать другие матрицы проекции и вида.
Поскольку мы моделируем направленный источник света, то все его лучи должны быть параллельны. По этой причине для источника света мы собираемся использовать матрицу ортографической проекции, чтобы избежать искажений перспективы:
1 2 |
float near_plane = 1.0f, far_plane = 7.5f; glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane); |
Вот пример матрицы ортографической проекции, используемой в демонстрационной сцене данного урока. Поскольку матрица проекции косвенно определяет диапазон видимости, то необходимо убедиться, что размер пирамиды видимости проекции задан корректно, и она содержит те объекты, информация о которых должна отобразиться в карте глубины. Те объекты или фрагменты, информация о которых не попала в карту глубины, не будут создавать тени.
При создании матрицы вида для преобразования каждого объекта, чтобы он был виден с точки зрения света, подразумевается использование известной нам функции glm::lookAt(), но на этот раз с положением источника света, смотрящего в центр сцены:
1 2 3 |
glm::mat4 lightView = glm::lookAt(glm::vec3(-2.0f, 4.0f, -1.0f), glm::vec3( 0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 1.0f, 0.0f)); |
Сочетание этих двух матриц дает нам матрицу преобразования светового пространства, которая преобразует каждый вектор мирового пространства в пространство, обозреваемое с позиции источника света; именно то, что нам нужно для визуализации карты глубины:
1 |
glm::mat4 lightSpaceMatrix = lightProjection * lightView; |
Переменная lightSpaceMatrix
является матрицей преобразования, которую мы ранее обозначили как T. С помощью lightSpaceMatrix
мы можем визуализировать сцену, как обычно, если предоставим каждому шейдеру эквиваленты матриц проекции и вида для светового пространства. Однако нас интересуют только значения глубины, а не выполнение всех остальных дорогостоящих расчетов фрагментов (освещения). Чтобы сохранить производительность, мы для рендеринга в карту глубины будем использовать другой (гораздо более простой) шейдер.
Рендеринг в карту глубины
Когда мы рендерим сцену с точки зрения света, то предпочтительнее использовать самый простой шейдер, преобразующий в координаты светового пространства только вершины, не более того. Для такого шейдера под одноименным названием simpleDepthShader
мы будем использовать следующий вершинный шейдер:
1 2 3 4 5 6 7 8 9 10 |
#version 330 core layout (location = 0) in vec3 aPos; uniform mat4 lightSpaceMatrix; uniform mat4 model; void main() { gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0); } |
Данный вершинный шейдер берет матрицу модели каждого объекта, вершину и преобразует её в световое пространство с помощью lightSpaceMatrix
.
Поскольку у нас нет цветового буфера и отключены буферы визуализации и чтения, то полученные фрагменты не требуют никакой обработки, поэтому мы можем просто использовать пустой фрагментный шейдер:
1 2 3 4 5 6 |
#version 330 core void main() { // gl_FragDepth = gl_FragCoord.z; } |
Данный пустой фрагментный шейдер не выполняет вообще никакой обработки, и в конце его работы будет обновлен буфер глубины. Мы могли бы явно задать значение глубины, раскомментировав одну строку, но фактически значение глубины и так будет вычислено в любом случае.
Рендеринг карты глубины/тени теперь обретает следующий вид:
1 2 3 4 5 6 7 8 |
simpleDepthShader.use(); glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix)); glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT); glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); glClear(GL_DEPTH_BUFFER_BIT); RenderScene(simpleDepthShader); glBindFramebuffer(GL_FRAMEBUFFER, 0); |
Здесь функция RenderScene() принимает шейдерную программу, вызывает все соответствующие функции рисования и устанавливает соответствующие матрицы моделей там, где это необходимо.
В результате получается красиво заполненный буфер глубины, содержащий значение глубины ближайшего пикселя каждого видимого фрагмента с точки зрения света. При рендеринге этой текстуры на 2D-прямоугольник (далее «2D-плоскость»), который будет отображаться на экране, мы получаем что-то вроде этого:
Для рендеринга карты глубины на 2D-плоскость мы использовали следующий фрагментный шейдер:
1 2 3 4 5 6 7 8 9 10 11 12 |
#version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D depthMap; void main() { float depthValue = texture(depthMap, TexCoords).r; FragColor = vec4(vec3(depthValue), 1.0); } |
Обратите внимание, что есть некоторые нюансы при отображении глубины с использованием матрицы перспективной проекции вместо матрицы ортогональной проекции, так как значения глубины при использовании перспективной проекции будут нелинейными. В конце этого урока мы это обсудим.
GitHub / Урок №32. Отображение теней в OpenGL — Исходный код №1
Рендеринг теней
С помощью правильно сгенерированной карты глубины мы можем начать рендеринг настоящих теней. Код для проверки того, находится ли фрагмент в тени, выполняется (совершенно очевидно) во фрагментном шейдере, но преобразование светового пространства мы будем выполнять в вершинном шейдере:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; layout (location = 2) in vec2 aTexCoords; out VS_OUT { vec3 FragPos; vec3 Normal; vec2 TexCoords; vec4 FragPosLightSpace; } vs_out; uniform mat4 projection; uniform mat4 view; uniform mat4 model; uniform mat4 lightSpaceMatrix; void main() { vs_out.FragPos = vec3(model * vec4(aPos, 1.0)); vs_out.Normal = transpose(inverse(mat3(model))) * aNormal; vs_out.TexCoords = aTexCoords; vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0); gl_Position = projection * view * vec4(vs_out.FragPos, 1.0); } |
Что здесь нового, так это дополнительный выходной вектор FragPosLightSpace
. Мы берем ту же самую матрицу lightSpaceMatrix
(используемую на этапе создания карты глубины для преобразования вершин в световое пространство) и преобразуем положение вершин мирового пространства в координаты светового пространства для дальнейшего их использования фрагментным шейдером.
Основной фрагментный шейдер, который мы задействуем для визуализации сцены, использует модель освещения Блинна-Фонга. В рамках данного шейдера мы вычисляем значение переменной shadow
, которая имеет значение 1.0
, когда фрагмент находится в тени, и 0.0
— когда он не находится в тени. Полученные компоненты diffuse
и specular
затем умножаются на компонент тени shadow
. Поскольку тени редко бывают полностью темными (из-за рассеивания света), то мы оставляем ambient
-компонент без умножения на shadow
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
#version 330 core out vec4 FragColor; in VS_OUT { vec3 FragPos; vec3 Normal; vec2 TexCoords; vec4 FragPosLightSpace; } fs_in; uniform sampler2D diffuseTexture; uniform sampler2D shadowMap; uniform vec3 lightPos; uniform vec3 viewPos; float ShadowCalculation(vec4 fragPosLightSpace) { [...] } void main() { vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb; vec3 normal = normalize(fs_in.Normal); vec3 lightColor = vec3(1.0); // Фоновая составляющая vec3 ambient = 0.15 * color; // Диффузная составляющая vec3 lightDir = normalize(lightPos - fs_in.FragPos); float diff = max(dot(lightDir, normal), 0.0); vec3 diffuse = diff * lightColor; // Отраженная составляющая vec3 viewDir = normalize(viewPos - fs_in.FragPos); float spec = 0.0; vec3 halfwayDir = normalize(lightDir + viewDir); spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0); vec3 specular = spec * lightColor; // Вычисляем тень float shadow = ShadowCalculation(fs_in.FragPosLightSpace); vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color; FragColor = vec4(lighting, 1.0); } |
По большей части фрагментный шейдер является копией того шейдера, который мы использовали на уроке о продвинутом освещении в OpenGL, но с добавлением вычисления теней. Мы объявили функцию ShadowCalculation(), которая выполняет большую часть работы с тенями. В конце фрагментного шейдера мы умножаем диффузную и зеркальную составляющие на значение (1.0 − shadow)
, т.е. на то, насколько фрагмент НЕ находится в тени. Используемый фрагментный шейдер принимает в качестве дополнительных входных данных положение фрагмента светового пространства и карту глубины, созданную в результате первого прохода рендеринга.
Первое, что нужно сделать, чтобы проверить, находится ли фрагмент в тени — это преобразовать позицию фрагмента светового пространства в нормализованные координаты устройства. Когда мы в вершинном шейдере выводим позицию вершины gl_Position
, определяемую координатами отсеченного пространства, OpenGL автоматически выполняет деление перспективы, например, преобразует координаты отсеченного пространства из диапазона [-w, w]
в диапазон [-1,1]
, выполняя деление x-, y- и z-компонент вектора на w-компоненту. Поскольку переменная FragPosLightSpace
не передается фрагментному шейдеру через gl_Position
, то мы должны сами выполнить деление перспективы:
1 2 3 4 5 6 |
float ShadowCalculation(vec4 fragPosLightSpace) { // Выполняем деление перспективы vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; [...] } |
Данный код возвращает положение фрагмента в световом пространстве в диапазоне [-1,1]
.
Примечание: При использовании матрицы ортографической проекции w-компонента вершины остается нетронутой, так что этот шаг на самом деле совершенно бессмысленный. Однако он необходим в случае использования перспективной проекции, поэтому сохранение данной строки кода гарантирует, что шейдер будет работать с обоими вариантами матриц проекции.
Поскольку значение глубины из карты глубины лежит в диапазоне [0,1]
, и мы также хотим использовать projCoords
для выборки из карты глубины, то преобразуем NDC-координаты в диапазон [0,1]
:
1 |
projCoords = projCoords * 0.5 + 0.5; |
С помощью этих координат проекции мы можем получить карту глубины, так как результирующие координаты [0,1]
из projCoords
непосредственно соответствуют преобразованным NDC-координатам после первого прохода рендеринга. Это дает нам самое близкое значение глубины относительно позиции источника света:
1 |
float closestDepth = texture(shadowMap, projCoords.xy).r; |
Чтобы получить текущую глубину выбранного фрагмента, мы просто присваиваем ей значение z-координаты cпроецированного вектора, которая равна глубине этого фрагмента с точки зрения света:
1 |
float currentDepth = projCoords.z; |
А далее просто сравниваем значения переменных currentDepth
и closestDepth
, и если значение переменной currentDepth
больше значения переменной closestDepth
, то фрагмент находится в тени:
1 |
float shadow = currentDepth > closestDepth ? 1.0 : 0.0; |
В итоге, функция ShadowCalculation() принимает следующий вид:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
float ShadowCalculation(vec4 fragPosLightSpace) { // Выполняем деление перспективы vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w; // Преобразуем в диапазон [0,1] projCoords = projCoords * 0.5 + 0.5; // Получаем наиболее близкое значение глубины, исходя из перспективы с точки зрения источника света (используя диапазон [0,1] fragPosLight в качестве координат) float closestDepth = texture(shadowMap, projCoords.xy).r; // Получаем глубину текущего фрагмента, исходя из перспективы с точки зрения источника света float currentDepth = projCoords.z; // Проверяем, находится ли текущий фрагмент в тени float shadow = currentDepth > closestDepth ? 1.0 : 0.0; return shadow; } |
Активация данного шейдера, привязка соответствующих текстур и активация заданных по умолчанию матриц проекции и вида на втором этапе рендеринга должны дать вам результат, аналогичный следующему изображению:
GitHub / Урок №32. Отображение теней в OpenGL — Исходный код №2
Если вы всё сделали правильно, то должны увидеть (хотя и с довольно большим количеством артефактов) тени на полу и ящиках.
Улучшение карт глубины
Нам удалось получить базовые результаты работы с картами теней, но, как вы можете видеть, при этом появились несколько четко видимых артефактов, которые необходимо исправить. На этом мы и сосредоточимся.
Теневые угри
Очевидно, что с предыдущим изображением что-то не так. При более детальном приближении явно заметен муар:
Пол стал отображаться с очевидными черными чередующимися линиями. Данный артефакт использования карт теней называется теневыми угрями (англ. «shadow acne»), и может быть объяснен следующим изображением:
Поскольку карта теней ограничена своим разрешением, то несколько фрагментов, находящихся относительно далеко от источника света, могут брать из карты глубины одно и то же значение. Изображение, приведенное выше, показывает пол, где каждая желтая наклонная панель представляет собой отдельно взятый тексель карты глубины. Как вы можете видеть, несколько фрагментов используют одно и то же значение глубины.
В целом это нормально, но данная ситуация начинает превращаться в проблему, когда источник света смотрит под углом к поверхности, так как в этом случае карта глубины также рендерится под углом. Затем несколько фрагментов обращаются к одному и тому же наклонному текселю глубины, в то время как некоторые находятся выше, а некоторые — ниже пола; мы получаем теневое несоответствие. Из-за этого некоторые фрагменты считаются находящимися в тени, а некоторые — нет, создавая при этом полосатый узор на изображении.
Мы можем решить эту проблему с помощью небольшого трюка, называемого теневым смещением (англ. «shadow bias»), при котором мы просто смещаем глубину поверхности (или теневой карты) на небольшую величину смещения, чтобы фрагменты, находящиеся ниже поверхности, учитывались корректно.
При применении смещения все выборки получают глубину меньшую, чем глубина поверхности, и таким образом вся поверхность правильно освещается без каких-либо теней. Мы можем реализовать описываемое смещение следующим образом:
1 2 |
float bias = 0.005; float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0 |
Значение смещения тени, равное 0.005
, в значительной степени решает проблемы нашей сцены. Величина смещения сильно зависит от угла между источником света и поверхностью. Если угол между поверхностью и источником света будет довольно большим, то тени могут продолжить отображать эффект теневых угрей. Более основательным подходом было бы изменять величину смещения в зависимости от угла между поверхностью и светом (т.е. с помощью того, что мы можем вычислить, используя скалярное умножение):
1 |
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); |
В данном случае мы имеем максимальное значение смещения, равное 0.05
, и минимальное — 0.005
. Они вычисляются на основе нормали поверхности и направления света. Таким образом, такие поверхности, как пол, которые почти перпендикулярны источнику света, получают небольшое смещение, в то время как такие поверхности, как боковые грани ящика, получают гораздо большее смещение. На следующем рисунке показана та же сцена, но теперь с теневым смещением:
Выбор правильного значения смещения требует некоторой подстройки под каждую конкретную сцену, но в большинстве случаев это просто вопрос постепенного увеличения смещения, пока все артефакты теней не исчезнут.
Эффект «Питера Пэна»
Недостатком использования теневого смещения является то, что вы применяете смещение к фактической глубине объектов. В результате оно может стать достаточно большим, что будет явно заметно по сравнению с фактическим местоположением объекта, как вы можете видеть ниже (с завышенным значением смещения):
Данный артефакт называется эффектом «Питера Пэна», так как объекты кажутся слегка отделенными от своих теней. Мы можем использовать небольшой трюк, который в большинстве случаев помогает решить данную проблему, используя механизм отсечения передней грани в момент рендеринга карты глубины. Возможно, вы помните из урока об отсечении граней, что OpenGL по умолчанию проводит отсечения задних (тыльных) граней. Сообщая OpenGL, что мы хотим отсекать фронтальные грани на этапе построения теневой карты, мы изменяем данный порядок.
Поскольку для карты глубины нам нужны только значения глубины, для сплошных объектов не должно иметь значения, берем ли мы глубину их передних или задних граней. Использование глубины задней грани не дает неверных результатов, поскольку не имеет значения, есть ли у нас тени внутри объектов или нет; мы все равно не можем их видеть.
Чтобы исправить эффект «Питера Пэна», мы во время генерации теневой карты отсекаем все передние грани. Обратите внимание, что сначала нам нужно подключить GL_CULL_FACE
:
1 2 3 |
glCullFace(GL_FRONT); RenderSceneToDepthMap(); glCullFace(GL_BACK); // не забудьте сбросить режим отсечения граней в исходное состояние |
Благодаря этому решаются проблемы, связанные с эффектом «Питера Пэна», но только для сплошных объектов, которые действительно имеют внутреннюю поверхность без отверстий. В нашей сцене, например, это прекрасно работает на кубиках. Однако для объекта пола это не будет работать так же хорошо, потому что, применив к полу отсечение его передней грани, мы полностью удалим его из наших расчетов. Пол представляет собой единую плоскость и, таким образом, будет полностью отсечен. Если кто-то хочет решить проблему эффекта «Питера Пэна» с помощью этого трюка, то нужно позаботиться о том, чтобы выбирались только передние грани у тех объектов, где это имеет смысл.
Другое соображение заключается в том, что объекты, находящиеся близко к затеняемой поверхности, все еще могут давать неверные результаты. Однако при нормальных значениях смещения вы обычно можете избежать описываемого эффекта «Питера Пэна».
Избыточная выборка
Еще одно визуальное несоответствие, которое вам может понравиться или не понравиться, заключается в том, что области за пределами пирамиды видимости (c точки зрения света) считаются находящимися в тени, в то время как они (обычно) в тени не находятся. Это происходит потому, что проекции координат вне пирамиды видимости имеют значения выше 1.0
, и, таким образом, при выборке значения из текстуры глубины происходит выход за пределы её диапазона, заданного по умолчанию как [0,1]
. В зависимости от метода наложения текстуры, мы получим некорректные результаты глубины.
Вы можете видеть, что на изображении существует некая воображаемая область света, и большая часть вне этой области находится в тени; данная область имеет размер карты глубины, проецируемой на пол. Причина, по которой часть пола затемнена, заключается в том, что мы ранее установили параметр наложения карты глубины как GL_REPEAT
.
Мы бы предпочли, чтобы все координаты вне диапазона карты глубины имели значение глубины 1.0
, в результате чего данные координаты никогда не будут находиться в тени (так как ни один объект не будет иметь глубину больше 1.0
). Мы можем сделать это, настроив цвет границы текстуры глубины и установив параметры наложения карты глубины как GL_CLAMP_TO_BORDER
:
1 2 3 4 |
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f }; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor); |
Теперь всякий раз, когда мы будем производить выборку за пределами диапазона [0,1]
координат карты глубины, функция texture() всегда будет возвращать значение глубины 1.0
, в результате чего значение переменной shadow
будет равно 0.0
. Результат теперь выглядит более правдоподобным:
Кажется, что все еще есть одна часть, отображающая темную область. Это координаты вне дальней плоскости пирамиды видимости ортографической проекции относительно позиции источника света. Вы можете видеть, что данная темная область всегда возникает в дальней части пирамиды видимости.
Координата фрагмента, спроектированная в световое пространство, находится дальше дальней плоскости пирамиды видимости относительно точки зрения источника света, когда z-координата фрагмента имеет значение больше 1.0
. В этом случае метод наложения GL_CLAMP_TO_BORDER
больше не работает, так как мы сравниваем z-координату со значениями карты глубины; для z-координаты больше 1.0
всегда возвращается true
.
Исправить это также относительно легко, поскольку мы просто принудительно выставляем значение переменной shadow
как 0.0
всякий раз, когда z-координата спроектированного вектора больше 1.0
:
1 2 3 4 5 6 7 8 |
float ShadowCalculation(vec4 fragPosLightSpace) { [...] if(projCoords.z > 1.0) shadow = 0.0; return shadow; } |
Проверка дальней плоскости и сужение диапазона карты глубины решают проблему избыточной выборки карты глубины. Это, наконец, дает нам результат, который мы ищем:
Результат всего этого означает, что у нас есть только тени, где спроектированные координаты фрагмента находятся внутри диапазона карты глубины, поэтому всё, что находится за пределами пирамиды видимости источника света, не будет иметь видимых теней. Данный метод выдает гораздо более правдоподобный эффект, чем очевидные черные области, которые мы имели раньше.
Процентно-приближенная фильтрация (PCF)
В данный момент наши тени являются прекрасным дополнением к сцене, но это все еще не совсем то, что мы хотим получить. Если вы увеличите масштаб теней, то соответствие качества теней, в зависимости от разрешения, быстро станет очевидным:
Поскольку карта глубины имеет фиксированное разрешение, то глубина часто охватывает более одного фрагмента на тексель. В результате, несколько фрагментов берут одно и то же значение глубины из карты глубины, и приводятся к одним и тем же теням, что вызывает появление этих зазубренных угловатых краев.
Мы можем уменьшить данные угловатые края, увеличив разрешение карты глубины или попытавшись подогнать пирамиду видимости как можно точнее к сцене.
Другой подход для частичного удаления данных зазубренных краев называется процентно-приближенная фильтрация (сокр. «PCF» от англ. «Percentage-Closer Filtering»), которая содержит множество различных функций фильтрации, создающих более мягкие тени или делая их менее жесткими. Идея состоит в том, чтобы сделать выборку из карты глубины более одного раза, но каждый раз с немного разными координатами текстуры. Для каждой отдельной выборки мы проверяем, находится ли она в тени или нет. Затем все промежуточные результаты объединяются и усредняются, и мы получаем красивую мягкую тень.
Одной из простых реализаций PCF является простая выборка из карты глубины окружающих текселей и усреднение результатов:
1 2 3 4 5 6 7 8 9 10 11 |
float shadow = 0.0; vec2 texelSize = 1.0 / textureSize(shadowMap, 0); for(int x = -1; x <= 1; ++x) { for(int y = -1; y <= 1; ++y) { float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0; } } shadow /= 9.0; |
Здесь textureSize
возвращает вектор типа vec2 с шириной и высотой переданной сэмплером текстуры глубины, соответствующей нулевому мипмап-уровню. Единица, деленная на данный вектор, возвращает размер одного текселя, который мы используем для смещения текстурных координат, убедившись, что каждая новая выборка имеет отличное от других значение глубины. В нашем случае мы выберем 9 значений вокруг пары координат (x;y)
, проверим наличие тени и, наконец, усредним результаты по общему числу взятых образцов.
Используя большее количество выборок и/или изменяя переменную texelSize
, вы можете повысить качество мягких теней. Ниже наглядно представлено то, как выглядят тени с простым применением PCF:
GitHub / Урок №32. Отображение теней в OpenGL — Исходный код №3
На расстоянии тени выглядят намного лучше и менее жесткими. Если вы увеличите масштаб, то все еще сможете увидеть артефакты разрешения отображения теней, но в целом это дает хорошие результаты для большинства приложений.
На самом деле существует гораздо больше разновидностей PCF и довольно много методов, позволяющих значительно улучшить качество мягких теней, но, чтобы не делать данный урок слишком длинным, мы оставим это для дальнейшего обсуждения.
Ортографическая проекция vs. Перспективная проекция
Существует разница между отображением карты глубины с помощью матрицы ортографической проекции или же матрицы перспективной проекции. Матрица ортографической проекции не деформирует сцену с перспективой, поэтому все лучи от наблюдателя/света параллельны. Это делает её отличной матрицей проекции для направленного освещения. В то же время матрица перспективной проекции деформирует все вершины, в зависимости от перспективы, что дает отличные от ортографической матрицы результаты. На следующем рисунке показаны различные области теней обоих методов проекции:
Чаще всего перспективные проекции используются в сочетании с источниками света, которые имеют заданное местоположение (в отличие от направленного света), например, с прожекторами и точечными источниками света. В то время как ортографические проекции используются для направленных источников света.
Еще одно тонкое отличие использования матрицы перспективной проекции заключается в том, что визуализация буфера глубины часто дает почти полностью белый результат. Это происходит потому, что при перспективной проекции глубина преобразуется в нелинейные значения, причем большая часть её диапазона расположена у ближней плоскости пирамиды видимости. Чтобы правильно просмотреть значения глубины, как это было сделано в варианте с ортографической проекцией, сначала необходимо преобразовать нелинейные значения глубины в линейные, как мы это обсуждали на уроке о тестировании глубины:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#version 330 core out vec4 FragColor; in vec2 TexCoords; uniform sampler2D depthMap; uniform float near_plane; uniform float far_plane; float LinearizeDepth(float depth) { float z = depth * 2.0 - 1.0; // обратно к NDC return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane)); } void main() { float depthValue = texture(depthMap, TexCoords).r; FragColor = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0); // перспективная проекция // FragColor = vec4(vec3(depthValue), 1.0); // ортографическая проекция } |
Данный фрагмент кода отображает значения глубины, аналогичные тому, что мы видели с ортографической проекцией. Обратите внимание, что это полезно только для отладки; проверка глубины остается той же самой, что с матрицей ортографической проекции, и что с матрицей перспективной проекции, поскольку относительные глубины не изменяются.
Дополнительные ресурсы
Tutorial 16: Shadow mapping — учебник по отображению теней с несколькими дополнительными заметками.
Shadow Mapping — Part 1 — еще один туториал по отображению теней.
How Shadow Mapping Works — YouTube-туториал от TheBennyBox по отображению теней.
Common Techniques to Improve Shadow Depth Maps — отличная статья от Microsoft, перечисляющая большое количество методов для улучшения качества теневых карт.