В прошлом уроке мы изучили основной принцип, лежащий в методе отображения теней, и увидели как рендерить глубину в текстуру, а затем отображать ее на экран посредством сэмплера из буфера глубины. В этом уроке мы увидим как ее использовать для отображения тени.
Мы уже знаем, что отображение теней проходит в 2 этапа: в первом сцена рендерится с позиции источника света. Давайте вспомним, что в нем происходит с Z координатой вершины:
В методе выше мы увидели как вычисляется и записывается значение глубины относительно позиции источника света. Во втором проходе мы рендерим из позиции камеры, поэтому очевидно, что мы получим различные значения глубины. Но нам требуются оба значения - 1 для правильного расположения треугольников на экране, и другое для проверки что в тени, а что - нет. Трюк отображения теней в том, что будут поддерживаться сразу 2 позиции вектора и 2 матрицы WVP в проходе по 3D конвейеру. Одна матрица WVP вычисляется из позиции источника света, а другая из позиции камеры. Вершинный шейдер будет получать один вектор позиции в локальных координатах, как обычно, но на выход пойдут сразу 2 вектора:
Первый вектор пойдет по плану выше (–> пространство NDC … и т.д.) и будет использован для обычной растеризации. Второй вектор так же будет интерполирован растеризатором по поверхности треугольника и каждый вызов фрагментного шейдера будет получать собственное значение. Поэтому теперь для каждого физического пикселя мы имеем координаты в пространстве клипа одной и той же точки, когда смотрим на нее из позиции источника света. Высока вероятность, что физические пиксели из 2 точек зрения различаются, но в целом позиция треугольника не изменилась. Все что осталось, это как то использовать координаты пространства клипа, и если записанное значение меньше, то это значит, что пиксель в тени (поскольку другой пиксель имеет те же координаты клипа, но с меньшей глубиной).
Так как мы можем получить глубину в фрагментном шейдере через координаты пространства клипа, которые вычислили умножив позицию на матрицу WVP источника света? Мы начинаем со 2 шага выше.
lighting_technique.h:80
class LightingTechnique : public Technique {
public:
...
void SetLightWVP(const Matrix4f& LightWVP);
void SetShadowMapTextureUnit(unsigned int TextureUnit);
...
private:
GLuint m_LightWVPLocation;
GLuint m_shadowMapLocation;
...
Классу света требуется набор новых свойств. Матрица WVP, которая вычисляется из позиции источника света, и модуль текстуры для карты теней. Мы продолжим использовать модуль 0 для обычной текстуры, которая накладывается на объект, и забронируем модуль 1 для карты.
lighting_technique.cpp:26
#version 330
layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
uniform mat4 gWVP;
uniform mat4 gLightWVP;
uniform mat4 gWorld;
out vec4 LightSpacePos;
out vec2 TexCoord0;
out vec3 Normal0;
out vec3 WorldPos0;
void main()
{
gl_Position= gWVP * vec4(Position, 1.0);
LightSpacePos= gLightWVP * vec4(Position, 1.0);
TexCoord0= TexCoord;
Normal0= (gWorld * vec4(Normal, 0.0)).xyz;
WorldPos0= (gWorld * vec4(Position, 1.0)).xyz;
}
Это обновленный шейдер класса LightingTechnique. Мы добавили матрицу WVP как uniform-переменную и 4-вектор в качестве выходного параметра, который содержит координаты в пространстве клипа, вычисленные через преобразование позиции матрицей WVP источника света. Как вы можете увидеть, в вершинном шейдере в первом проходе переменная gWVP хранит такую же матрицу, как и gLightWVP здесь, и gl_Position получит то же значение, что и LightSpacePos. Но так как LightSpacePos простой вектор, он не получит деления перспективы как у gl_Position. Мы сделаем это вручную в фрагментном шейдере ниже.
lighting_technique.cpp:108
float CalcShadowFactor(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, UVCoords).x;
if (Depth < (z + 0.00001))
return 0.5;
else
return 1.0;
}
Эта функция используется в фрагментном шейдере для вычисления эффекта затенения для пикселя. Коэффициент тени - это новый параметр в формуле света. Мы просто умножаем результат нашего текущего значения света на этот коэффициент, и это вызовет некоторое затенение света в пикселе, который определен как в тени. Функция принимает интерполированный вектор LightSpacePos, который передается из вершинного шейдера. Первый этап - деление перспективы - мы делим координаты XYZ на W компонент. Это переведет вектор в пространство NDC. Далее мы подготавливаем 2D вектор, который будет использован для координат текстуры и инициализируем его через преобразование вектора LightSpacePos из NDC в пространство текстуры согласно формуле в разделе теории. Координаты текстуры используются для получения глубины из карты теней. Это глубина ближайшей позиции из всех точек сцены, которые проецируются в этот пиксель. Мы сравниваем эту глубину с глубиной текущего пикселя, и если она меньше, возвращаем коэффициент тени равный 0.5, иначе коэффициент тени равен 1.0 (нет тени). Z из пространства NDC так же проходит преобразование из отрезка (-1,1) в (0,1), потому что мы должны находится в одном пространстве для сравнения. Заметим, что мы добавили небольшое значение для глубины текущего пикселя. Это для избежания ошибок, которые бывают при работе с вещественными числами.
lighting_technique.cpp:121
vec4 CalcLightInternal(struct BaseLight Light, vec3 LightDirection, vec3 Normal, float ShadowFactor)
{
...
return (AmbientColor + ShadowFactor * (DiffuseColor + SpecularColor));
}
Изменения в главной функции вычисления света минимальны. Вызов должен вернуть рассеянный и отраженный свет, умножаный на коэффициент теней. Фоновый свет остается без изменений - он всюду по определению.
lighting_technique.cpp:146
vec4 CalcDirectionalLight(vec3 Normal)
{
return CalcLightInternal(gDirectionalLight.Base, gDirectionalLight.Direction, Normal, 1.0);
}
Наша реализация отображения теней ограниченна прожектором. Для того, что бы найти матрицу WVP света нам требуются из позиция и направление, из-за которых нельзя использовать точечный и рассеянный свет. Мы добавим этот функционал в будущем, пока что мы просто указываем коэффициент теней равным 1 для направленного света.
lighting_technique.cpp:151
vec4 CalcPointLight(struct PointLight l, vec3 Normal, vec4 LightSpacePos)
{
vec3 LightDirection = WorldPos0 - l.Position;
float Distance = length(LightDirection);
LightDirection = normalize(LightDirection);
float ShadowFactor = CalcShadowFactor(LightSpacePos);
vec4 Color = CalcLightInternal(l.Base, LightDirection, Normal, ShadowFactor);
float Attenuation =l.Atten.Constant +
l.Atten.Linear * Distance +
l.Atten.Exp * Distance * Distance;
return Color / Attenuation;
}
Так как прожектор вычисляется используя точечный свет, эта функция принимает дополнительный параметр позиции источника света и вычисляет коэффициент теней. Он передается в CalcLightInternal(), которая описана выше.
lighting_technique.cpp:166
vec4 CalcSpotLight(struct SpotLight l, vec3 Normal, vec4 LightSpacePos)
{
vec3 LightToPixel = normalize(WorldPos0 - l.Base.Position);
float SpotFactor = dot(LightToPixel, l.Direction);
if (SpotFactor > l.Cutoff) {
vec4 Color = CalcPointLight(l.Base, Normal, LightSpacePos);
return Color * (1.0 - (1.0 - SpotFactor) * 1.0/(1.0 - l.Cutoff));
}
else {
return vec4(0,0,0,0);
}
}
Функция прожектора просто передает позицию в пространстве источника света в функцию точечного света.
lighting_technique.cpp:180
void main()
{
vec3 Normal = normalize(Normal0);
vec4 TotalLight = CalcDirectionalLight(Normal);
for (int i = 0 ; i < gNumPointLights ; i++) {
TotalLight += CalcPointLight(gPointLights[i], Normal, LightSpacePos);
}
for (int i = 0 ; i < gNumSpotLights ; i++) {
TotalLight += CalcSpotLight(gSpotLights[i], Normal, LightSpacePos);
}
vec4 SampledColor = texture2D(gSampler, TexCoord0.xy);
FragColor = SampledColor * TotalLight;
}
Наконец, главная функция фрагментного шейдера. Мы используем один и тот же вектор позиции и для прожектора и для точечного света, даже если поддерживается только прожектор. Это ограничение будет исправлено в будущем. Мы закончили осмотр изменений в методе света и теперь обратим внимание на код приложения.
main.cpp:86
m_pLightingEffect = new LightingTechnique();
if (!m_pLightingEffect->Init()) {
printf("Error initializing the lighting technique\n");
return false;
}
m_pLightingEffect->Enable();
m_pLightingEffect->SetSpotLights(1, &m_spotLight);
m_pLightingEffect->SetTextureUnit(0);
m_pLightingEffect->SetShadowMapTextureUnit(1);
Этот код настраивает часть LightingTechnique в функции Init(), поэтому он вызывается только раз при старте. Здесь мы устанавливаем uniform-значения, которые не изменяются из кадра в кадр. Наш модуль текстур по умолчанию имеет номер 0, и мы решили, что модуль 1 будет для карты теней. Вспомним, что программа шейдера должна быть разрешена, прежде чем устанавливать ее uniform-переменные, и они останутся не низменными до тех пор, пока программа не будет слинкована еще раз. Это удобно, поскольку вам может потребоваться переключиться на другой шейдер, а значения у старого не сбросятся. Uniform-переменные, которые не изменяются в течении всей программы, могут быть установлены лишь раз при запуске.
main.cpp:129
virtual void RenderSceneCB()
{
m_pGameCamera->OnRender();
m_scale += 0.05f;
ShadowMapPass();
RenderPass();
glutSwapBuffers();
}
Главной функции рендера никаких изменений - сначала заботимся о глобальных вещах, таких как камера и коэффициент масштабирования, который используется для вращения меша. А затем идет проход для теней, перед проходом рендера.
main.cpp:141
virtual void ShadowMapPass()
{
m_shadowMapFBO.BindForWriting();
glClear(GL_DEPTH_BUFFER_BIT);
m_pShadowMapEffect->Enable();
Pipeline p;
p.Scale(0.1f, 0.1f, 0.1f);
p.Rotate(0.0f, m_scale, 0.0f);
p.WorldPos(0.0f, 0.0f, 3.0f);
p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
m_pShadowMapEffect->SetWVP(p.GetWVPTrans());
m_pMesh->Render();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
Это практически тот же код прохода теней, что и в предыдущем уроке. Единственное изменение - это то, что мы разрешаем метод отображения теней каждый раз, поскольку мы переключаемся от метода теней к методу света. Заметим, что хоть мы и используем и меш и квадрат, который служит землей, только меш рендерится в карту теней. Причина в том, что земля не может давать тень. Это один из способов оптимизации, когда мы знаем что-либо о типе объекта.
main.cpp:162
virtual void RenderPass()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
m_pLightingEffect->Enable();
m_shadowMapFBO.BindForReading(GL_TEXTURE1);
Pipeline p;
p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
p.Scale(10.0f, 10.0f, 10.0f);
p.WorldPos(0.0f, 0.0f, 1.0f);
p.Rotate(90.0f, 0.0f, 0.0f);
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
m_pLightingEffect->SetWVP(p.GetWVPTrans());
m_pLightingEffect->SetWorldMatrix(p.GetWorldTrans());
p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
m_pLightingEffect->SetLightWVP(p.GetWVPTrans());
m_pLightingEffect->SetEyeWorldPos(m_pGameCamera->GetPos());
m_pGroundTex->Bind(GL_TEXTURE0);
m_pQuad->Render();
p.Scale(0.1f, 0.1f, 0.1f);
p.Rotate(0.0f, m_scale, 0.0f);
p.WorldPos(0.0f, 0.0f, 3.0f);
p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
m_pLightingEffect->SetWVP(p.GetWVPTrans());
m_pLightingEffect->SetWorldMatrix(p.GetWorldTrans());
p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
m_pLightingEffect->SetLightWVP(p.GetWVPTrans());
m_pMesh->Render();
}
Проход рендера начинается с того же, что и в прошлом уроке - мы очищаем и буфер глубины и буфер цвета, заменяем метод теней на свет и привязываем карту теней для чтения в модуль текстур 1. Далее мы рендерим плоскость так, что бы она служила землей, на которую падает тень. Она немного увеличена, повернута на 90 градусов вокруг оси Х (потому, что изначально она вертикальная) и размещаем. Заметим как обновляется WVP полагаясь на позицию света через перемещение камеры в его позицию. Так как модель квадрата идет без текстуры, мы в ручную привязываем собственную. Меш рендерится тем же способом.