阴影的重要意义
阴影是光线被阻挡的结果,它能够使场景看起来真实很多,可以让观察者获得物体之间的空间位置关系。如下图所示:
图1
可以看到,有阴影的时候能够更容易的看出立方体是悬浮在地板上的。
当前实时渲染领域还没找到一种完美的阴影算法,目前有几种近似阴影技术,但他它们都有自己的弱点和不足。游戏中常用的技术是阴影贴图(shadow mapping),效果不错,而且相对容易实现,性能也挺高,比较容易扩展为更高级的算法,如 Omnidirectional Shadow Maps和 Cascaded Shadow Maps。
阴影映射原理
在绘制物体的某个片元时,要确定它是否在阴影中,就是要判断它是否被别的片元挡住了。而这个挡住其实是光线被挡住了,所以应该从光源位置看过去,看这个片元是否被其他片元挡住。如下图所示:
图2
判断是否被遮挡可以用深度贴图来实现:从光源处看过去(相当于把摄像机调整到光源的位置,即更改观察矩阵和投影矩阵,只是不渲染场景颜色而已),渲染一次场景(开启深度测试),将场景的深度值渲染到自定义帧缓冲的深度纹理附件中,此时深度纹理中存储的深度值就是离光源(或者说摄像机)最近的深度值,然后再渲染一次场景,这次渲染过程中判断当前片元的深度是否比对应位置上深度纹理中的深度更靠近光源(在屏幕空间里就是深度值更小),如果不是则说明该片元被挡住了,在阴影里。如下图所示:
图3
右图中,在光源看来C点和P点处在同一xy位置(以光源为原点的坐标系)上,但是深度z不同,P点的深度是0.9,C点的深度是0.4,存储在深度纹理中的应该是最靠近光源的0.4,在绘制点P时由于其深度值0.9比从深度纹理中取出的0.4大,所以判定点P被挡住了,应该位于阴影里。
综上,深度映射通过两个步骤完成:
- 渲染深度纹理。
- 正常渲染场景,同时采样深度纹理来判断片元是否在阴影中。
用代码表示如下:
// 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();
渲染深度纹理
我们需要从光源的视角去渲染得到一张场景的深度纹理,最后需要用它来计算阴影。为了将场景的深度保存到纹理中,我们需要用到帧缓冲,并且为它添加深度纹理附件:
GLuint DepthMap;
glGenTextures(1, &DepthMap);
glBindTexture(GL_TEXTURE_2D, DepthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WIDTH, 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);
glBindTexture(GL_TEXTURE_2D, 0);
GLuint DepthMapFBO;
glGenFramebuffers(1, &DepthMapFBO);
glBindFramebuffer(GL_FRAMEBUFFER, DepthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, DepthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "Framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
上面的代码首先创建了一张GL_DEPTH_COMPONENT
格式的纹理,然后将它绑定到帧缓冲的深度附件上。
接下来我们需要从光源视角去渲染场景。先来看看着色器怎么写吧:
#version 330 core
layout (location = 0) in vec3 position;
uniform mat4 LightSpaceMVP; //projection * view * model
void main()
{
gl_Position = LightSpaceMVP * vec4(position,1.0f);
}
#version 330 core
void main()
{
}
可以看到渲染深度纹理的着色器相当简单,在顶点着色器里只是需要一个在光源视角下的MVP矩阵(投影矩阵、观察矩阵和物体模型矩阵的乘积),来计算在光源视角下的顶点坐标。片元着色器可以是空的,因为我们只想得到深度,所以没有必要在片元着色器里输出颜色。
【注】:
- 直接使用MVP矩阵,是为了避免每一个顶点着色器都去执行模型矩阵、观察矩阵、投影矩阵的乘法运算,减少GPU的运算量,只需要每帧在应用程序里计算一次MVP矩阵,然后传给顶点着色器即可。这样还减少了传输带宽,毕竟只需要给GPU传一个MVP矩阵,而不是三个矩阵。
加下来需要我们在应用程序里算好这个LightSpaceMVP矩阵了:
mat4 View = lookAt(lightPos, lightPos + lightDirection, vec3(0, 1, 0));
mat4 Projection = ortho(-6.0, 6.0, -6.0, 6.0, 0.1, 20.0);
mat4 LightSpaceVP = Projection * View;
mat4 CubeModel;
CubeModel = translate(CubeModel, glm::vec3(-1.0f, 0.0f, -1.0f));
mat4 LightSpaceMVPCube = LightSpaceVP * CubeModel;
mat4 PlaneModel;
PlaneModel = mat4();
mat4 LightSpaceMVPPlane = LightSpaceVP * PlaneModel;
场景里面有两个物体:地面和箱子,它们都需要在上面的着色器下绘制一次,由于它们的模型矩阵不同,所以它们的MVP矩阵需要分开算。观察矩阵通过平行光源的位置和方向来计算,投影矩阵是一个正交投影矩阵(因为场景里用的是平行光源)。
然后我们绑定自定义帧缓冲,激活着色器渲染场景,就可以渲染出深度纹理了:
glBindFramebuffer(GL_FRAMEBUFFER, DepthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
GenerateDepthMap_Shader.Use();
glUniformMatrix4fv(glGetUniformLocation(GenerateDepthMap_Shader.shaderProgram, "LightSpaceMVP"), 1, GL_FALSE, value_ptr(LightSpaceMVPCube));
glBindVertexArray(cubeVAO);
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0);
glUniformMatrix4fv(glGetUniformLocation(GenerateDepthMap_Shader.shaderProgram, "LightSpaceMVP"), 1, GL_FALSE, value_ptr(LightSpaceMVPPlane));
glBindVertexArray(planeVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
我们可以用一张窗口四边形来渲染这张深度贴图:
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClearColor(0.3f, 0.4f, 0.5f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
RenderDepthMap_Shader.Use();
glBindVertexArray(windowQuadVAO);
glBindTexture(GL_TEXTURE_2D, DepthMap);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
渲染结果如下:
所有源码在这里。
渲染阴影
我们先来看看着色器怎么写。
顶点着色器和正常渲染场景时一样,只是多了计算顶点在光源视角下的裁剪坐标这一步:
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec2 texCoords;
layout (location = 2) in vec3 normal;
out vec2 VS_TexCoords;
out vec3 VS_Normal;
out vec3 VS_WorldPos;
out vec4 VS_LightSpacePos;
uniform mat4 u_LightSpaceMVP; //projection * view * model
uniform mat4 u_Model;
uniform mat4 u_View;
uniform mat4 u_Projection;
void main()
{
VS_TexCoords = texCoords;
VS_Normal = transpose(inverse(mat3(u_Model))) * normal;
VS_WorldPos = vec3(u_Model * vec4(position, 1.0f));
gl_Position = u_Projection * u_View * vec4(VS_WorldPos, 1.0f);
VS_LightSpacePos = u_LightSpaceMVP * vec4(position, 1.0f);
}
其中u_LightSpaceMVP是光源视角下的模型矩阵、观察矩阵和投影矩阵的乘积。
将计算得到的顶点在光源视角下的裁剪坐标VS_LightSpacePos,传递给片元着色器,来计算阴影:
#version 330 core
in vec2 VS_TexCoords;
in vec3 VS_Normal;
in vec3 VS_WorldPos;
in vec4 VS_LightSpacePos;
out vec4 Color;
uniform sampler2D u_DiffuseMapSampler1;
uniform sampler2D u_DepthMapSampler2;
uniform vec3 u_LightPos;
uniform vec3 u_LightDirection;
uniform vec3 u_ViewPos;
uniform vec3 u_LightColor;
vec3 getDepthInLightSpace(vec4 vLightSpacePos)
{
vec3 Temp = (vLightSpacePos / vLightSpacePos.w).xyz;
Temp = Temp * 0.5 + 0.5;
return Temp;
}
void main()
{
vec3 ObjectColor = texture(u_DiffuseMapSampler1, VS_TexCoords).rgb;
float AmbientStrength = 0.3f;
vec3 AmbientColor = AmbientStrength * ObjectColor;
vec3 LightClipSpacePos = getDepthInLightSpace(VS_LightSpacePos);
if(LightClipSpacePos.z >= texture(u_DepthMapSampler2, LightClipSpacePos.xy).r + 0.01)
{
Color = vec4(AmbientColor * ObjectColor, 1.0);
return;
}
vec3 Normal = normalize(VS_Normal);
vec3 LightDir = normalize(-u_LightDirection);
float DiffuseFactor = max(dot(Normal, LightDir), 0.0);
vec3 DiffuseColor = DiffuseFactor * u_LightColor;
vec3 ViewDir = normalize(u_ViewPos - VS_WorldPos);
vec3 HalfDir = normalize(LightDir + ViewDir);
float SpecularFactor = pow(max(dot(HalfDir, Normal), 0.0f), 32);
vec3 SpecularColor = SpecularFactor * u_LightColor;
Color = vec4((AmbientColor + DiffuseColor + SpecularColor) * ObjectColor, 1.0);
}
其中在片元着色器里,我们需要计算插值后的片元在光源视角下的深度:
vec3 getDepthInLightSpace(vec4 vLightSpacePos)
{
vec3 Temp = (vLightSpacePos / vLightSpacePos.w).xyz;
Temp = Temp * 0.5 + 0.5;
return Temp;
}