阴影映射:从原理到PCF软阴影
阴影,是让3D场景「活」起来的关键。没有阴影的物体就像漂浮在空中的纸片,毫无真实感可言。今天我们来聊聊OpenGL ES里最经典的阴影技术——阴影映射(Shadow Mapping)。
说实话,我第一次接触阴影映射时,觉得这玩意儿挺玄乎的。后来自己动手实现了一遍,才发现核心思想其实很简单:从光源看过去,能看到的地方就是亮的,看不到的地方就是阴影。
阴影映射的核心思想
想象一下,你站在一个手电筒的位置,朝场景里看。你能看到的物体表面,手电筒的光就能照到;你看不到的地方(被其他物体挡住了),自然就是阴影区域。
阴影映射就是把这个「从光源看场景」的过程,用一张纹理记录下来。这张纹理就叫阴影贴图(Shadow Map)。
核心流程就两步:
- 从光源位置渲染场景,只记录每个像素的深度值,存到纹理里
- 从相机位置正常渲染场景,把每个片元转换到光源空间,比较深度值
嗯,说白了就是「先记录深度,再比较深度」。我在项目中第一次实现时,卡在了坐标变换上,后面会详细说。
实现定向光的阴影
定向光(Directional Light)模拟的是太阳光,所有光线方向平行。对于定向光,我们使用正交投影来生成阴影贴图。
为什么用正交投影?因为平行光的光线是平行的,正交投影的视锥体是个长方体,正好匹配这种光照特性。我刚开始做的时候用了透视投影,结果阴影边缘扭曲得不成样子,后来才意识到这个问题。
第一步:生成阴影贴图
我们需要创建一个帧缓冲对象(FBO),绑定一张深度纹理。然后从光源位置渲染场景。
// 创建深度纹理
GLuint depthTexture;
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
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_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
// 创建FBO
GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
GL_TEXTURE_2D, depthTexture, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
小技巧: 阴影贴图的分辨率直接影响阴影质量。我一般用 1024x1024 起步,移动端可以降到 512x512。分辨率太高对性能影响很大,尤其是老设备。
第二步:光源空间的变换矩阵
这是最容易出错的地方。我们需要把世界坐标转换到光源的裁剪空间,然后映射到 [0,1] 范围,才能和阴影贴图里的深度值比较。
// 光源空间变换矩阵
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f,
-10.0f, 10.0f,
1.0f, 20.0f);
glm::mat4 lightView = glm::lookAt(lightPos,
glm::vec3(0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
注意这个 lightSpaceMatrix,在渲染阴影贴图时用它作为 VP 矩阵。在正常渲染时,也要用它把片元坐标转换到光源空间。
第三步:在片元着色器中比较深度
// 片元着色器中的阴影计算
float ShadowCalculation(vec4 fragPosLightSpace) {
// 执行透视除法,得到 NDC 坐标
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// 映射到 [0,1] 范围
projCoords = projCoords * 0.5 + 0.5;
// 从阴影贴图中采样深度
float closestDepth = texture(shadowMap, projCoords.xy).r;
// 当前片元的深度
float currentDepth = projCoords.z;
// 比较深度,判断是否在阴影中
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
return shadow;
}
注意: 当片元在光源视锥体外时,projCoords.xy 会超出 [0,1] 范围。这时候纹理采样会出问题。我建议用 GL_CLAMP_TO_BORDER 并设置边框颜色为白色(1.0),这样超出范围的区域默认不在阴影中。
阴影失真(Shadow Acne)问题
如果你直接运行上面的代码,会发现阴影表面出现很多条纹状的噪点。这就是著名的阴影失真(Shadow Acne)。
为什么会这样?因为阴影贴图是离散采样的,多个片元可能对应同一个纹素。当光线几乎平行于表面时,深度比较会出现「自遮挡」现象。
解决办法很简单——加一个深度偏移(Depth Bias):
float bias = 0.005;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
但这个偏移量不好调。太小了还有失真,太大了又会出现「Peter Panning」——阴影和物体分离,像彼得潘一样飘起来。
我个人的习惯是根据表面斜率动态调整偏移量:
float bias = max(0.005 * (1.0 - dot(normal, lightDir)), 0.001);
这样,表面越倾斜,偏移量越大,能有效避免失真,又不会让阴影飘得太厉害。
PCF软阴影优化
硬阴影的边缘太锐利了,现实中几乎不存在。PCF(Percentage Closer Filtering)就是用来让阴影边缘变柔和的。
PCF的核心思想很简单:不要只采样一个点,而是采样周围多个点,然后取平均值。
float ShadowCalculation_PCF(vec4 fragPosLightSpace) {
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
projCoords = projCoords * 0.5 + 0.5;
float currentDepth = projCoords.z;
float bias = 0.005;
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
// 3x3 采样核
for(int x = -1; x <= 1; ++x) {
for(int y = -1; y <= 1; ++y) {
vec2 offset = vec2(x, y) * texelSize;
float closestDepth = texture(shadowMap, projCoords.xy + offset).r;
shadow += currentDepth - bias > closestDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
return shadow;
}
3x3的采样核效果已经不错了。如果想更柔和,可以用5x5甚至7x7,但性能开销会直线上升。
性能优化建议: 在移动端,3x3 PCF 是比较好的平衡点。如果觉得边缘还是太硬,可以配合泊松采样(Poisson Sampling)——用一组预计算的随机偏移位置来采样,视觉上更自然,而且采样次数可以少一些。
我踩过的坑
最后分享几个实战中容易翻车的地方:
- 深度纹理格式: 一定要用
GL_DEPTH_COMPONENT,别用GL_RGBA去存深度,精度不够。 - 纹理比较模式: 如果用了
GL_COMPARE_REF_TO_TEXTURE,硬件会自动做深度比较,但PCF实现起来反而更麻烦。我建议手动比较,更灵活。 - 光源视锥体范围: 定向光的正交投影范围要刚好包住场景,太大了浪费精度,太小了阴影被裁切。我一般先计算场景的包围盒,再设置投影参数。
- 阴影贴图更新频率: 如果光源和场景都不动,阴影贴图只需要生成一次。动态场景下,每帧都要重新生成,这是主要的性能瓶颈。
阴影映射是个经典技术,虽然现在有更先进的方案(如CSM、VSM),但理解它的原理是进阶的基础。PCF软阴影虽然简单,但在很多项目中已经够用了。我记得有一次做户外场景,就靠PCF加一个3x3采样核,效果客户非常满意。
好了,阴影映射的核心内容就这些。动手实现一遍,踩踩坑,你会理解得更深。
公众号:蓝海资料掘金营,微信deep3321