Давайте всмотримся в тени из урока 47:
Как вы можете заметить, качество теней не высоко. Слишком пиксилизированно. Мы уже разобрались с причиной такого эффекта и назвали его Perspective Aliasing, представляющей собой отображение большого числа пикселей из пространства сцены на один пиксель карты теней. Это значит, что все эти пиксели будут либо в тени, либо освещены одновременно. Другими словами, поскольку разрешение карты теней недостаточно высоко, она не может достаточно покрыть все пространство сцены. Самый простой способ решения этой проблемы - это увеличить разрешение карты теней, но это увеличит потребление памяти нашим приложением, так что этот метод не самый лучший.
Другой способ решить эту проблему - это заметить, что тени ближе к камере в плане качества куда важнее, чем тени далеко находящихся объектов. В любом случае, объекты на расстоянии меньше по размеру, а глаза как раз фокусируются на том, что происходит на первом плане, а остальное воспринимается как фон. Если бы мы могли использовать детализированную карту теней для близких объектов, и другую для удаленных, то первая карта теней должна будет покрыть только небольшой участок, то есть, уменьшая соотношение пикселей, которое мы обсудили ранее. Короче говоря, так и работают Каскадные карты теней (Cascaded Shadow Mapping a.k.a CSM). На момент написания этого урока, CSM считается одним из лучших способов для борьбы с Perspective Aliasing. Что же, давайте подумаем как мы могли бы его реализовать.
В целом мы собираемся разбить конус обзора на несколько частей - каскадов (их не обязательно должно быть два как в предыдущем примере). В данном уроке мы будем использовать три каскада: ближний, средний и дальний. Сам алгоритм достаточно обобщенный, так что не составит проблем увеличить число каскадов, если понадобится. Каждый каскад будет рендериться в его собственную карту теней. Сам алгоритм теней остаётся без изменений, за исключением того, что взятие значения глубины из карты теней должно выбирать подходящую карту, в зависимости от расстояния до зрителя. Давайте посмотрим на усеченную пирамиду обзора:
Как обычно, у нас есть маленькая ближняя и большая дальняя плоскости. Теперь давайте посмотрим на сцену сверху:
Следующим шагом мы разбиваем расстояние от ближней плоскости до дальней на три части. Мы будем называть их ближней, средней и дальней. И ещё давайте добавим направление света (стрелка справа):
Итак, как же мы собираемся рендерить каждый каскад в отдельную карту теней? Вспомним этап теней в алгоритме карты теней. Мы настраиваем сцену для рендера с позиции источника света. Это заключается в создании матрицы WVP с мировыми преобразованиями объекта, преобразованиями пространства света и матрицы проекции. Так как этот урок основывается на уроке 47, который работает с тенями направленного света, то матрица проекции будет ортогональной. Обычно CSM используется для открытых сцен, где главный источник света это солнце, и использование направленного света здесь естественно. Если вы посмотрите на матрицу WVP выше, то вы заметите, что первые две части (мировая и обзора) одинаковые для всех каскадов. В конце концов, позиция объекта на сцене и параметры камеры относительно источника света не зависят от разбиения пирамиды на каскады. Так что важна здесь только матрица проекции, поскольку она задает область, которая будет отрендерена. А поскольку ортогональная матрица проекции задается параллелепипедом, то нам нужно задать три различных параллелепипеда, которые будут отображены в три разных ортогональных матрицы проекции. Все три матрицы будут использованы для получения трёх матриц WVP для рендера каждого каскада в его отдельную карту теней.
Логичнее всего было бы сделать эти рамки настолько маленькими, насколько это возможно для получения наименьшего коэффициента отношения пикселей пространства сцены к карте теней. Для этого создадим ограничивающую рамку для каждого каскада вдоль вектора света. Давайте добавим её к первому каскаду:
Давайте теперь добавим ограничивающую рамку для второго каскада:
И ещё одну для последнего каскада:
Как вы можете заметить, из-за положения света в пространстве границы рамок слегка пересекаются, и как следствие, некоторые пиксели будут отрендерены сразу на несколько карт теней. Но до тех пор, пока все пиксели одного каскада находятся целиков в одной карте теней, нас это не волнует. Выбор карты теней для вычислений в шейдере будет основан на расстоянии до пикселя от самого зрителя.
Самой сложной частью алгоритма является нахождение ограничивающих рамок, которые и будут основой для ортогональной проекции в теневом проходе. Они должны быть заданы в пространстве источника света (в котором источник расположен в начале координат и направлен вдоль оси Z) так как проекции идут после мировых и камерных преобразований. Параллелепипеды будут заданы своими размерами по всем трем осям и выровнены вдоль направления света - то что нужно для проекции. Для нахождения рамок нам нужно знать как каждый каскад выглядит в пространстве света. Для этого проделаем следующие шаги:
Находим восемь вершин в пространстве обзора. Это не сложно, требуется лишь немного тригонометрии:
На изображение выше представлен произвольный каскад (так как каждый каскад является такой же усеченной пирамидой с таким же углом обзора, как и остальные). Заметим, что мы смотрим сверху вниз на плоскость XZ. Нам нужно найти X1 и X2:
Таким образом мы получаем координаты X и Z всех восьми вершин каскада в пространстве обзора. Используя аналогичные вычисления для вертикального угла обзора мы можем найти координату Y.
Теперь нам нужно преобразовать координаты каскада из пространства обзора обратно в мировое пространство. Предположим, что зритель расположен в мировом пространстве таким образом, что пирамида выглядит так (красная стрелка обозначает источник света, но пока что мы можем её проигнорировать):
Для того, что бы перенести из мирового пространства в пространство камеры, мы умножаем вектор позиции в мировом пространстве на матрицу камеры (получаемую из позиции камеры и её угла поворота). Это значит, что если мы уже имеем координаты каскада в пространстве камеры, то мы просто умножаем их на обратную матрицу камеры для переноса в мировое пространство:
Как и любой другой объект, мы можем преобразовать координаты пирамиды из мирового пространства в пространство света. Вспомним, что пространство света абсолютно идентично пространству камеры, разве что вместо камеры используется источник света. Так как в нашем случае используется направленный свет, у которого нет позиции в пространстве, нам требуется только повернуть сцену таким образом, чтобы свет был направлен вдоль положительного направления оси Z. А положение света можно задать в начале координат пространства света (то есть, нам не нужно преобразований смещения). Если мы сделаем это для рисунка выше (где красная стрелка задает источник света), то каскады в пространстве света будут выглядеть следующим образом:
Наконец, получив координаты каскадов в пространстве света, нам остается только найти границы рамок. Для этого возьмем наибольшие и наименьшие значения компонент X/Y/Z для всех восьми вершин. Такой параллелепипед содержит значения, необходимые для ортогональной проекции для рендера каскада на карту теней. Получив для каждого каскада отдельную матрицу проекции, мы можем рендерить каждый каскад в отдельную карту. На световом этапе мы будем вычислять коэффициент теней выбирая карту теней ориентируясь на расстоянии от зрителя.
ogldev_shadow_map_fbo.cpp:104
bool CascadedShadowMapFBO::Init(unsigned int WindowWidth, unsigned int WindowHeight)
{
// Создаем FBO
glGenFramebuffers(1, &m_fbo);
// Создаем буфер глубины
glGenTextures(ARRAY_SIZE_IN_ELEMENTS(m_shadowMap), m_shadowMap);
for (uint i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(m_shadowMap) ; i++) {
glBindTexture(GL_TEXTURE_2D, m_shadowMap[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
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_COMPARE_MODE, GL_NONE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap[0], 0);
// Отключаем запись в буфер цвета
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (Status != GL_FRAMEBUFFER_COMPLETE) {
printf("FB error, status: 0x%x\n", Status);
return false;
}
return true;
}
void CascadedShadowMapFBO::BindForWriting(uint CascadeIndex)
{
assert(CascadeIndex < ARRAY_SIZE_IN_ELEMENTS(m_shadowMap));
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap[CascadeIndex], 0);
}
void CascadedShadowMapFBO::BindForReading()
{
glActiveTexture(CASCACDE_SHADOW_TEXTURE_UNIT0);
glBindTexture(GL_TEXTURE_2D, m_shadowMap[0]);
glActiveTexture(CASCACDE_SHADOW_TEXTURE_UNIT1);
glBindTexture(GL_TEXTURE_2D, m_shadowMap[1]);
glActiveTexture(CASCACDE_SHADOW_TEXTURE_UNIT2);
glBindTexture(GL_TEXTURE_2D, m_shadowMap[2]);
}
Выше описан класс CascadedShadowMapFBO, который является модификацией класса ShadowMapFBO, используемого в предыдущих уроках. Главное отличие в том, что массив m_shadowMap содержит три карты теней - ровно столько, сколько у нас каскадов. Также приведены три основных метода для инициализации, для привязки на запись в проходе теней и на чтение в проходе света.
tutorial49.cpp:197
virtual void RenderSceneCB()
{
for (int i = 0; i < NUM_MESHES ; i++) {
m_meshOrientation[i].m_rotation.y += 0.5f;
}
m_pGameCamera->OnRender();
ShadowMapPass();
RenderPass();
OgldevBackendSwapBuffers();
}
Главная функция алгоритма CCM такая же, как и для обычного алгоритма карт теней - сначала рендерим на карту теней, а затем используем её для вычисления света.
tutorial49.cpp:211
void ShadowMapPass()
{
CalcOrthoProjs();
m_ShadowMapEffect.Enable();
Pipeline p;
// Камера помещается на позицию источника света и не меняет на протежении этого этапа
p.SetCamera(Vector3f(0.0f, 0.0f, 0.0f), m_dirLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
for (uint i = 0 ; i < NUM_CASCADES ; i++) {
// Привязываем и очищаем текущий каскад
m_csmFBO.BindForWriting(i);
glClear(GL_DEPTH_BUFFER_BIT);
p.SetOrthographicProj(m_shadowOrthoProjInfo[i]);
for (int i = 0; i < NUM_MESHES ; i++) {
p.Orient(m_meshOrientation[i]);
m_ShadowMapEffect.SetWVP(p.GetWVOrthoPTrans());
m_mesh.Render();
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
В этапе теней добавлена парочка изменений, которые заслуживают внимания. Первое, вызов CalOrthoProjs() в начале этапа. Эта функция отвечает за вычисление ограничивающих рамок, используемых для ортогональной проекции. Следующее отличие это цикл по каскадом. Каждый из них по отдельности должен быть привязан на запись, очищен и отрендерен. Каждый каскад имеет свою проекцию в массиве m_shadowOrthoProjInfo (который заполняет CalcOrthoProjs). Так как мы не знаем в какой каскад попадет каждый меш (а их может быть больше одного), то мы вынуждены рендерить всю сцену для каждого каскада.
tutorial49.cpp:238
void RenderPass()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
m_LightingTech.Enable();
m_LightingTech.SetEyeWorldPos(m_pGameCamera->GetPos());
m_csmFBO.BindForReading();
Pipeline p;
p.Orient(m_quad.GetOrientation());
p.SetCamera(Vector3f(0.0f, 0.0f, 0.0f), m_dirLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
for (uint i = 0 ; i < NUM_CASCADES ; i++) {
p.SetOrthographicProj(m_shadowOrthoProjInfo[i]);
m_LightingTech.SetLightWVP(i, p.GetWVOrthoPTrans());
}
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
p.SetPerspectiveProj(m_persProjInfo);
m_LightingTech.SetWVP(p.GetWVPTrans());
m_LightingTech.SetWorldMatrix(p.GetWorldTrans());
m_pGroundTex->Bind(COLOR_TEXTURE_UNIT);
m_quad.Render();
for (int i = 0; i < NUM_MESHES ; i++) {
p.Orient(m_meshOrientation[i]);
m_LightingTech.SetWVP(p.GetWVPTrans());
m_LightingTech.SetWorldMatrix(p.GetWorldTrans());
m_mesh.Render();
}
}
Единственное отличие в проходе света в том, что для света вместо одной матрицы WVP их стало три. Они отличаются только проекциями. Мы получаем их в цикле в середине этапа.
tutorial49.cpp:80
m_cascadeEnd[0] = m_persProjInfo.zNear;
m_cascadeEnd[1] = 25.0f,
m_cascadeEnd[2] = 90.0f,
m_cascadeEnd[3] = m_persProjInfo.zFar;
Перед тем как мы займемся вычислением ортогональной проекции, нам следует обратить внимание на массив m_cascadeEnd (который инициализируется в конструкторе). Этот массив задает каскады записывая значения ближней и дальней Z в первый и последний слот соответственно и границы каскадов посередине. Таким образом первый каскад заканчивается в значении из первого слота, второй из второго и третий из последнего. А значение ближней Z плоскости в первом слоте позже поможет упростить вычисления.
tutorial49.cpp:317
void CalcOrthoProjs()
{
Pipeline p;
// Получаем обратные преобразования
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
Matrix4f Cam = p.GetViewTrans();
Matrix4f CamInv = Cam.Inverse();
// Получаем преобразования света
p.SetCamera(Vector3f(0.0f, 0.0f, 0.0f), m_dirLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
Matrix4f LightM = p.GetViewTrans();
float ar = m_persProjInfo.Height / m_persProjInfo.Width;
float tanHalfHFOV = tanf(ToRadian(m_persProjInfo.FOV / 2.0f));
float tanHalfVFOV = tanf(ToRadian((m_persProjInfo.FOV * ar) / 2.0f));
for (uint i = 0 ; i < NUM_CASCADES ; i++) {
float xn = m_cascadeEnd[i] * tanHalfHFOV;
float xf = m_cascadeEnd[i + 1] * tanHalfHFOV;
float yn = m_cascadeEnd[i] * tanHalfVFOV;
float yf = m_cascadeEnd[i + 1] * tanHalfVFOV;
Vector4f frustumCorners[NUM_FRUSTUM_CORNERS] = {
// Ближняя плоскость
Vector4f(xn, yn, m_cascadeEnd[i], 1.0),
Vector4f(-xn, yn, m_cascadeEnd[i], 1.0),
Vector4f(xn, -yn, m_cascadeEnd[i], 1.0),
Vector4f(-xn, -yn, m_cascadeEnd[i], 1.0),
// Дальняя плоскость
Vector4f(xf, yf, m_cascadeEnd[i + 1], 1.0),
Vector4f(-xf, yf, m_cascadeEnd[i + 1], 1.0),
Vector4f(xf, -yf, m_cascadeEnd[i + 1], 1.0),
Vector4f(-xf, -yf, m_cascadeEnd[i + 1], 1.0)
};
Выше мы видим первый шаг из блока теории о вычислении ортогональной проекции для каскада. Массив frustumCorners заполнен восемью вершинами каскада в пространсве экрана. Заметим, что так как задан только горизонтальный угол обзора, то вертикальный мы вычисляем вручную (например, если горизонтальный угол обзора равен 90°, а размеры окна 1000x500, то вертикальный улог обзора будет равен 45°).
Vector4f frustumCornersL[NUM_FRUSTUM_CORNERS];
float minX = std::numeric_limits<float>::max();
float maxX = std::numeric_limits<float>::min();
float minY = std::numeric_limits<float>::max();
float maxY = std::numeric_limits<float>::min();
float minZ = std::numeric_limits<float>::max();
float maxZ = std::numeric_limits<float>::min();
for (uint j = 0 ; j < NUM_FRUSTUM_CORNERS ; j++) {
// Преобразуем координаты усеченоой пирамиды из пространства камеры в мировое пространство
Vector4f vW = CamInv * frustumCorners[j];
// И ещё раз из мирового в пространство света
frustumCornersL[j] = LightM * vW;
minX = min(minX, frustumCornersL[j].x);
maxX = max(maxX, frustumCornersL[j].x);
minY = min(minY, frustumCornersL[j].y);
maxY = max(maxY, frustumCornersL[j].y);
minZ = min(minZ, frustumCornersL[j].z);
maxZ = max(maxZ, frustumCornersL[j].z);
}
Код выше выполняет шаги со #2 по #4. Каждая вершина каскада домнажается на обратную матрицу преобразований для перевода в мировое пространство. А после она домнажается на преобразования света для перевода в его пространство. И затем мы несколько раз используем функции min/max для вычисления ограничивающей рамки каскада в пространстве света.
m_shadowOrthoProjInfo[i].r = maxX;
m_shadowOrthoProjInfo[i].l = minX;
m_shadowOrthoProjInfo[i].b = minY;
m_shadowOrthoProjInfo[i].t = maxY;
m_shadowOrthoProjInfo[i].f = maxZ;
m_shadowOrthoProjInfo[i].n = minZ;
}
}
Текущая запись в массиве m_shadowOrthoProjInfo заполняется используя значения обрамляющей рамки.
csm.vs
#version 330
layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
uniform mat4 gWVP;
void main()
{
gl_Position = gWVP * vec4(Position, 1.0);
}
csm.fs
#version 330
void main()
{
}
Ничего нового в вершинном и фрагментном шейдерах этапа теней. Мы по прежнему просто рендерим глубину.
lighting.vs
#version 330
layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
const int NUM_CASCADES = 3;
uniform mat4 gWVP;
uniform mat4 gLightWVP[NUM_CASCADES];
uniform mat4 gWorld;
out vec4 LightSpacePos[NUM_CASCADES];
out float ClipSpacePosZ;
out vec2 TexCoord0;
out vec3 Normal0;
out vec3 WorldPos0;
void main()
{
vec4 Pos = vec4(Position, 1.0);
gl_Position = gWVP * Pos;
for (int i = 0 ; i < NUM_CASCADES ; i++) {
LightSpacePos[i] = gLightWVP[i] * Pos;
}
ClipSpacePosZ = gl_Position.z;
TexCoord0 = TexCoord;
Normal0 = (gWorld * vec4(Normal, 0.0)).xyz;
WorldPos0 = (gWorld * vec4(Position, 1.0)).xyz;
}
Давайте рассмотрим изменения в вершинном шейдере светового этапа. Вместо передачи одной вершины в пространстве света их теперь три - по одной для каждого каскада. Так же мы собираемся выбирать нужную в фрагментном шейдере. В дальнейшем вы можете захотеть оптимизировать этот этап, но для обучающих целей я решил что и так пойдет. Не забывайте, что вы не можете выбрать каскад в вершинном шейдере так как треугольник может располагаться сразу в нескольких каскадах. Итого, у нас есть матрицы WVP и три вершины пространства света. Кроме того, мы также передаем значение Z в пространстве клиппера. Оно пригодится нам при выборе каскада в фрагментном шейдере. Заметим, что величина вычислена в мировом пространстве, а не в пространстве света.
lighting.fs
const int NUM_CASCADES = 3;
in vec4 LightSpacePos[NUM_CASCADES];
in float ClipSpacePosZ;
uniform sampler2D gShadowMap[NUM_CASCADES];
uniform float gCascadeEndClipSpace[NUM_CASCADES];
Фрагментный шейдер прохода света содержит некоторые дополнения в основной секции. На вход мы получаем три вершины в пространстве света, которые вычислил вершинный шейдер, а так же значение Z в пространстве клиппера. Вместо одной карты теней их теперь три. Кроме того, приложение должно передавать конец каждого каскада в пространстве клиппера. Чуть позже мы увидим как он вычисляется. А пока просто предположим что значение уже есть.
float CalcShadowFactor(int CascadeIndex, vec4 LightSpacePos)
{
vec3 ProjCoords = LightSpacePos.xyz / LightSpacePos.w;
vec2 UVCoords;
UVCoords.x = 0.5 * ProjCoords.x + 0.5;
UVCoords.y = 0.5 * ProjCoords.y + 0.5;
float z = 0.5 * ProjCoords.z + 0.5;
float Depth = texture(gShadowMap[CascadeIndex], UVCoords).x;
if (Depth < z + 0.00001)
return 0.5;
else
return 1.0;
}
void main()
{
float ShadowFactor = 0.0;
for (int i = 0 ; i < NUM_CASCADES ; i++) {
if (ClipSpacePosZ <= gCascadeEndClipSpace[i]) {
ShadowFactor = CalcShadowFactor(i, LightSpacePos[i]);
break;
}
}
...
Для того что бы для пикселя выбрать подходящий каскад мы передаем uniform-массив gCascadeEndClipSpace и сравниваем Z компоненту координаты в пространстве клиппера с каждой записью в массиве. Массив отсортирован по возрастанию удаленности. Мы останавливаемся как только нашли запись, значение которой больше или равно текущей компоненте Z. Затем мы вызываем CalcShadowFactor() и передаем туда индекс найденного каскада. Единственное отличие в самой функции в том, что получаем значение глубины из той карты теней, индекс которой равен найденному. Остальное без изменений.
tutorial49.cpp:134
for (uint i = 0 ; i < NUM_CASCADES ; i++) {
Matrix4f Proj;
Proj.InitPersProjTransform(m_persProjInfo);
Vector4f vView(0.0f, 0.0f, m_cascadeEnd[i + 1], 1.0f);
Vector4f vClip = Proj * vView;
m_LightingTech.SetCascadeEndClipSpace(i, vClip.z);
}
Последний кусок мозайки - это подготовка значений для массива gCascadeEndClipSpace. Для этого возьмем координату (0, 0, Z), где Z это конец каскада в пространстве камеры. Для перевода значения в пространство экрана мы просто используем обычную проекции перспективы. Такая операция проводится для каждого каскада для поиска границы в пространстве клиппера.
Если вы посмотрите код урока, то вы увидите, что я добавил индикатор границы каскадов назначив каждому из них свой цвет (красный, зеленый или синий). Это очень полезно при отладке, так как вы явно можете видеть границы каждого каскада. С алгоритмом CSM и цветным индикатором сцена выглядит как-то так: