【实践LearnOpenGL项目】高级光照篇
练习一、阴影贴图
阴影贴图实际上是基于光线是否能看到某个地方,而是否能看到可以通过深度信息的计算来实现。因此,首先我们需要配置深度缓冲区和深度纹理。代码如下
unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
// 创建深度(阴影)纹理
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);
// 将深度纹理附加到帧缓冲对象depthMapFBO
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);
然后我们需要从光源视角去绘制物体,实现阴影的效果。由于我们只需要填充深度信息,因此这里对应的阴影片段着色器不需要执行任何操作,我们重点看下场景的搭建,也就是构建MVP矩阵,代码如下
// 对于平行光,使用正交矩阵
lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
lightView = glm::lookAt(lightPos, glm::vec3(0.0f), glm::vec3(0.0, 1.0, 0.0));
lightSpaceMatrix = lightProjection * lightView;
// 渲染光源视角场景
simpleDepthShader.use();
simpleDepthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
//填充深度缓冲区
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
renderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
其中的renderScene函数这里是渲染正方体、平面等场景物体。填充好后,我们先对深度贴图进行可视化(用于调试),代码如下
// 重置视图
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 可视化深度贴图
debugDepthQuad.use();
debugDepthQuad.setFloat("near_plane", near_plane);
debugDepthQuad.setFloat("far_plane", far_plane);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, depthMap);
renderQuad();
其中renderQuad函数是渲染全屏四边形(Quad),将阴影以纹理的形式,对光源视角的场景进行输出,效果如下

接下来添加普通着色器,渲染带有阴影的场景,代码如下
// 渲染带有阴影的场景
shader.use();
glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
glm::mat4 view = camera.GetViewMatrix();
shader.setMat4("projection", projection);
shader.setMat4("view", view);
// 光源分量
shader.setVec3("viewPos", camera.Position);
shader.setVec3("lightPos", lightPos);
shader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, woodTexture);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, depthMap); //绑定深度(阴影)贴图
renderScene(shader);
这里的lightSpaceMatrix矩阵只包含视图和投影矩阵,因此在这个shader的顶点着色器中,还要与模型矩形右乘,接下来重点看下片段着色器的代码
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 齐次坐标转标准设备坐标(NDC)
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// 转纹理坐标
projCoords = projCoords * 0.5 + 0.5;
// 获取光源视角下最近表面的深度值
float closestDepth = texture(shadowMap, projCoords.xy).r;
float currentDepth = projCoords.z;
// 当前片元深度大于采样深度则判定为阴影(1.0)
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
return shadow;
}
void main()
{
...
// specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
//vec3 reflectDir = reflect(-lightDir, normal); Phong模型
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
// Blinn-Phong模型
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// shadow
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0);
}
这里用的Blinn-Phong模型,比之前用的Phong模型计算效率更高,并且高光效果也柔和一点。最终效果如下

虽然实现了阴影,但画面有很多的线条,称为阴影伪影(具体为波纹伪影),是由纹理贴图和深度计算之间的精度差引起的,如何解决呢,上代码!
// 修改创建深度贴图部分
...
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, /*GL_REPEAT*/GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, /*GL_REPEAT*/GL_CLAMP_TO_BORDER);
float borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
首先将纹理换行模式换成了“夹紧到边界”,避免重复阴影,然后将边界颜色设为白色(1.0)。也就是说,当纹理坐标超出有效范围(如阴影区域外),自动返回白色的borderColor而非进行纹理插值,确保阴影边界清晰。接下来看片段着色器的修改部分
//修改ShadowCalculation部分
...
// 计算动态的阴影偏移bias
vec3 normal = normalize(fs_in.Normal);
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
// 设置最小偏移0.005防止过校正
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
// PCF百分比渐进过滤
float shadow = 0.0;
// texelSize确保采样偏移与阴影贴图分辨率匹配
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
// 3×3 进行多次深度采样
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
//projCoords.xy + vec2(x, y) * texelSize计算偏移采样坐标
//texture().r 读取阴影贴图中对应位置的深度值
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
//取平均
shadow /= 9.0;
// 处理超出光源视锥体的区域
if(projCoords.z > 1.0)
shadow = 0.0;
这里的PCF(Percentage Closer Filtering)百分比渐进过滤,用来减少锯齿线伪影,后续练习二还能用来生成柔和阴影,最终效果如下~

练习二、点光源阴影
练习一实际上是模拟的平行光产生的阴影,现在我们来看下点光源产生的阴影。
与平行光不同的是,由于点光源需要全向辐射,因此需要六个正交方向的深度数据去覆盖所有的投射角度!部分代码如下
// 创建立方体贴图
unsigned int depthCubemap;
glGenTextures(1, &depthCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
for (unsigned int i = 0; i < 6; ++i)
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
这里还设置了R坐标,也就是Z坐标的环绕。接下来我们要给出立方体贴图六个面对应的变换矩阵,方便后续几何着色器单次完成六个面的顶点变换。
float near_plane = 1.0f;
float far_plane = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), (float)SHADOW_WIDTH / (float)SHADOW_HEIGHT, near_plane, far_plane);
std::vector<glm::mat4> shadowTransforms;
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)));
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)));
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)));
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)));
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)));
shadowTransforms.push_back(shadowProj * glm::lookAt(lightPos, lightPos + glm::vec3( 0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f)));
然后将场景填充到立方体贴图中,同时传递上面的变换矩阵。这里的场景包含了一个大的正方体,模拟在房间内的感觉,因此需要调用 glDisable(GL_CULL_FACE),去禁用剔除功能,让正方体的内外都渲染出来(因此调用后也会产生额外计算),填充代码如下
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
simpleDepthShader.use();
for (unsigned int i = 0; i < 6; ++i)
simpleDepthShader.setMat4("shadowMatrices[" + std::to_string(i) + "]", shadowTransforms[i]);
simpleDepthShader.setFloat("far_plane", far_plane);
simpleDepthShader.setVec3("lightPos", lightPos);
renderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
紧接着,我们看下几何着色器的代码
...
void main()
{
for(int face = 0; face < 6; ++face)
{
gl_Layer = face; // 指定渲染的目标面
for(int i = 0; i < 3; ++i)
{
FragPos = gl_in[i].gl_Position;
gl_Position = shadowMatrices[face] * FragPos;
EmitVertex();
}
EndPrimitive();
}
}
这里遍历六个面,以及每个面用一个三角形去表示,因此顶点缓存最大数量为18。由于这里用的透视矩阵perspective,因此在片段着色器中还需要计算下深度缓冲值gl_FragDepth。其他代码部分和平行光阴影类似,就不赘述了,最终效果如下~

但是实际生活中的话,在阴影的边缘部分,其阴影效果是会比较浅比较模糊的,因此我们还需要再优化一下,达到软阴影的效果。需要修改下片段着色器中计算阴影的部分,代码如下
vec3 fragToLight = fragPos - lightPos;
float currentDepth = length(fragToLight);
float shadow = 0.0;
// 固定偏移量
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
//根据观察距离动态调整采样范围
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;
for(int i = 0; i < samples; ++i)
{
float closestDepth = texture(depthMap, fragToLight + gridSamplingDisk[i] * diskRadius).r;
closestDepth *= far_plane; // 还原为实际深度值
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);
不太一样的地方是,首先使用了动态采样半径,与练习一的固定一个纹素texelSize相比远景阴影更加自然。其次使用了PCF方式,并且采样次数为20次,使得阴影边缘过渡更加平滑,也就是我们实现柔和阴影的核心方式。最终效果如下

练习三、法线贴图
现在用2D纹理贴图实现了一面墙,如下所示

如果想要增强凹凸细节和光照真实感,,就需要用到我们的法线贴图~
法线贴图不需要细分曲面等复杂计算,其核心点是采用TBN矩阵,用来连接模型空间和切线空间。其中,T (Tangent)矩阵分量用来表示纹理坐标U轴的变化情况,B (Bitangent/副切线)用来表示纹理坐标V轴的变化情况,N (Normal)是模型表面的法线,并且满足B = cross(N, T)。我们重点看下着色器的代码
// 顶点着色器
...
mat3 normalMatrix = transpose(inverse(mat3(model))); // 避免不均匀操作后,法线失效
vec3 T = normalize(normalMatrix * aTangent);
vec3 N = normalize(normalMatrix * aNormal);
T = normalize(T - dot(T, N) * N); // 施密特正交化,确保T和N正交
vec3 B = cross(N, T);
mat3 TBN = transpose(mat3(T, B, N)); // 分量从行向量转置为列向量
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vs_out.FragPos; // 转换到切线空间
顶点着色器的核心目标是获取切线空间的相应坐标,方便在片段着色器中计算出漫反射和镜面反射光。接下来看片段着色器如何计算法向量
// 从法向贴图中获取RGB值,范围[0,1]
vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
// 转换为法线空间, 范围[-1,1]
normal = normalize(normal * 2.0 - 1.0);
最终效果如下~

最后,水平有限,欢迎指正和交流~觉得有用的话点点赞或收藏吧~
更多推荐


所有评论(0)