阴影映射:从原理到PCF软阴影

阴影,是让3D场景「活」起来的关键。没有阴影的物体就像漂浮在空中的纸片,毫无真实感可言。今天我们来聊聊OpenGL ES里最经典的阴影技术——阴影映射(Shadow Mapping)。

说实话,我第一次接触阴影映射时,觉得这玩意儿挺玄乎的。后来自己动手实现了一遍,才发现核心思想其实很简单:从光源看过去,能看到的地方就是亮的,看不到的地方就是阴影

阴影映射的核心思想

想象一下,你站在一个手电筒的位置,朝场景里看。你能看到的物体表面,手电筒的光就能照到;你看不到的地方(被其他物体挡住了),自然就是阴影区域。

阴影映射就是把这个「从光源看场景」的过程,用一张纹理记录下来。这张纹理就叫阴影贴图(Shadow Map)。

核心流程就两步:

  1. 从光源位置渲染场景,只记录每个像素的深度值,存到纹理里
  2. 从相机位置正常渲染场景,把每个片元转换到光源空间,比较深度值

嗯,说白了就是「先记录深度,再比较深度」。我在项目中第一次实现时,卡在了坐标变换上,后面会详细说。

步骤1:光源视角 渲染场景,记录深度 → 生成阴影贴图 步骤2:相机视角 正常渲染场景 → 逐片元比较深度 输出 阴影结果 深度比较逻辑 片元在光源空间中的深度值:d_fragment 阴影贴图中记录的最近深度值:d_shadowMap 如果 d_fragment > d_shadowMap → 阴影

实现定向光的阴影

定向光(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