阴影贴图 & PCSS

Featured image

阴影贴图是一个常规且好用的渲染阴影的方法。阴影贴图背后的想法非常简单:我们从光的角度渲染场景,我们从光的角度看到的所有东西都被照亮,而我们看不到的所有东西都必须在阴影中。想象一下地板部分,其自身和光源之间有一个大盒子。由于光源在朝其方向看时会看到此框而不是地板部分,因此特定地板部分应处于阴影中。1 在现实世界中靠近物体的阴影偏硬,远离物体的阴影偏软,PCSS很好地模拟了这一现象。

使用阴影贴图渲染阴影分为两个pass。先在第一个pass中从光源的方向对场景物体进行渲染,获得一张深度贴图,之后在第二个pass中通过对比当前深度和深度贴图中的深度来判断当前像素是否在阴影中。

Pass 1 阴影贴图:

在opengl中实现时需要创建一个texture和一个FBO,texture需要指定为深度图格式:glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL); 因为在这个pass中只需要深度信息,所以不需要颜色数据,为了FBO的完整性我们要告诉opengGL我们不会绘制颜色:glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE);

因为我们是从光源方向渲染,相当于光源处有一个相机,又因为我们是平行光,所以用正交相机。设置光源正交相机的view和投影矩阵:

	// only for directional light
	glm::mat4 lightProjection = glm::ortho(-5.0f, 5.0f, -5.0f, 5.0f, 1.0f, 7.5f);
	glm::mat4 lightView = glm::lookAt(glm::normalize(lightDir) * 3.0f,
		glm::vec3(0.0f),
		glm::vec3(0.0f, 1.0f, 0.0f));
	m_lightSpaceMatrix = lightProjection * lightView;

因为是平行光,所以直接在光源方向的某处选择一个位置作为相机位置:glm::normalize(lightDir) * 3.0f 相机参数要根据实际情况来调。这个pass我们只需要vertex shader,将物体转换到光源空间就行了:

void main() 
{ 
	gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0); 
}

这个pass之后可以得到这样一张阴影贴图(从光源方向渲染的深度贴图):

Pass 2 渲染阴影

在接下来的Pass中就可以使用前面生成的阴影贴图。将接下来Pass中的物体转换到光源空间,用z分量与阴影贴图作比较,大于就是处于阴影中。

    // perform perspective divide
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    projCoords = projCoords * 0.5 + 0.5;

    float closestDepth = texture(shadowMap, projCoords.xy).r;
    float currentDepth = projCoords.z;

要先做一个透视除法,将光照照空间坐标转移到NDC坐标中。只这样做的话可以看到地面出现明显的黑线和摩尔纹,这是因为光与地面的角度,以及阴影贴图分辨率限制,在实际渲染阴影的时候会有一部分地面被认为深度值比阴影贴图大:(图中一个斜着的黄色线相当于一个深度值)。 可以通过加一个bias值来解决这个问题: 这样就不会错误地被认为在阴影中。再根据角度来决定使用多少的bias

    float bias = max(0.05 * (1.0 - dot(n, l)), 0.005);  
    float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;  
    if(projCoords.z > 1.0)
        shadow = 0.0;
    return shadow;

但这可能会造成Peter panning,也就是阴影和物体有偏移: 需要找到合适的Bias值,之后实现的PCSS也可以一定程度上解决这个问题。 当物体超过光源相机的视锥时我们不渲染阴影,于是需要将阴影贴图过采样部分全部设置成白色:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f }; glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

超出光源相机far plane的物体也不需要阴影:if(projCoords.z > 1.0) shadow = 0.0; 最后得到了一个硬阴影:

PCF:

实现PCSS之前先看一下PCF。可以看到我们之前的阴影锯齿很多,模糊阴影贴图是没有用的,因为渲染阴影的时候非0即1。 PCF是去找当前像素周围几个像素,和多个像素的深度比较,再平均所有的shadow值,就可以得到一个模糊的深度。

    float shadow = 0.0;
    vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
    for(int x = -2; x <= 2; ++x)
    {
        for(int y = -2; y <= 2; ++y)
        {
            float pcfDepth = texture(shadowMap, shadowCoord.xy + vec2(x, y) * texelSize).r; 
            shadow += currentDepth - bias> pcfDepth ? 0.0 : 1.0;        
        }    
    }
    shadow /= 9.0;

这样可以得到一个还不错的模糊软阴影,和反走样。PCF会加大一些计算量。

PCSS:

要做到离物体越近越硬,离物体越远阴影越软,和物体的距离有关。 通过一个相似三角形就可以算出软阴影的范围,软阴影范围和离遮挡物的距离,光源的宽度有关。

PCSS:

  1. Blocker search: 在一定范围内获得平均blocer depth。
  2. 用blocker depth去估计软阴影范围大小。
  3. PCF,filter大小在之前已经决定。

实现主要参考英伟达2


float filterRadius = penumbra;
float shadow = PCF_Filter(projCoords.xy, receiverDepth, filterRadius, bias);
    
float PCF_Filter( vec2 shadowCoord, float receiverDepth, float radius, float bias )
{
    float sum = 0.0;

    for ( int i = 0; i < PCF_NUM_SAMPLES; ++i )
    {
        vec2 offset = poisson64[i] * radius;
        float pcfDepth = texture(shadowMap, shadowCoord.xy + offset).r;
        sum += pcfDepth < receiverDepth - bias ? 1.0 : 0.0;
    }
    return sum / float(PCF_NUM_SAMPLES);
} 

最后的结果,可以看到我们得到了一个比较真实的软阴影效果(和硬阴影对比):

参考:

  1. Shadow-Mapping https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping 

  2. Integrating Realistic Soft Shadows into Your Game Engine: https://developer.download.nvidia.com/whitepapers/2008/PCSS_Integration.pdf