Vulkan CascadeShadowMap中的常见问题与解决方案
在先前的Vulkan CascadeShadowMap功能开发过程中,做了一个快速的CascadeShadowmap版本,其中还存在不少问题,这几天则将我的渲染器从前向管线升级到了延迟管线。正好一起把这些问题解决一遍。
问题一:ShadowMap Flicking
当视口相机移动时,由级联视锥角点算出来的级联阴影正交投影矩阵不断发生变化,并且因为ShadowMap的分辨率不等与Fragment像素的分辨率,因此采样得到的ShadowDepth结果是在不停变化的,表现到屏幕上的效果就是每帧Cascade投影的范围在不停闪动。
解决方法:
- 使用球形包围盒来缓解Filcking现象。
- 使用Align ShadowMap Resolution的方式来计算正交投影矩阵(可以彻底解决该问题)。
详细代码如下:
// 计算视锥包围球
glm::vec3 intervalCenter = glm::vec3(0.0f);
for(uint32 i = 0; i < 8; i++)
{
intervalCenter += intervalCorners[i];
}
intervalCenter /= 8.0f;
// 计算视锥半径
float radius = 0.0f;
for(uint32 i = 0; i < 8; i++)
{
float distance = glm::length(intervalCorners[i] - intervalCenter);
radius = glm::max(radius,distance);
}
radius = std::ceil(radius * 16.0f) / 16.0f;
// 计算球形包围盒角点
glm::vec3 maxExtents = glm::vec3(radius);
glm::vec3 minExtents = - maxExtents;
glm::vec3 lightDir = normalize(inLightDir);
// 计算LookAt矩阵
glm::mat4 lightViewMatrix = glm::lookAt(
intervalCenter - lightDir * -minExtents.z,
intervalCenter,
glm::vec3(0.0f,1.0f,0.0f) // stable
);
// 计算正交矩阵
glm::mat4 lightOrthoMatrix;
lightOrthoMatrix = glm::ortho(
minExtents.x,
maxExtents.x,
minExtents.y,
maxExtents.y,
0.0f,
maxExtents.z - minExtents.z
);
inout[cascadeIndex].viewProj = lightOrthoMatrix * lightViewMatrix;
// ShadowMap Texel Align
vec4 shadowOrigin = glm::vec4(0.0f,0.0f,0.0f,1.0f);
shadowOrigin = inout[cascadeIndex].viewProj * shadowOrigin;
shadowOrigin = shadowOrigin * ((float)cVarSingleShadowMapSize.Get() * 0.5f);
vec4 roundOrign = glm::round(shadowOrigin);
vec4 roundOffset = roundOrign - shadowOrigin;
roundOffset = roundOffset * 2.0f / (float)cVarSingleShadowMapSize.Get();
// 添加texel align round offset.
lightOrthoMatrix[3][0] = lightOrthoMatrix[3][0] + roundOffset.x;
lightOrthoMatrix[3][1] = lightOrthoMatrix[3][1] + roundOffset.y;
inout[cascadeIndex].viewProj = lightOrthoMatrix * lightViewMatrix;
问题二:Depth Clip
如何选择每个Cascade的Near Z和Far Z ?
如果单纯的使用Cascade Bounds的MinZ和MaxZ来计算Cascade的Near Z和Far Z,极易形成顶部物体消失的情况,表现在画面中就是屋顶不再投影:
如上图所示,Cascade 4 中所有的物体均投影了,而Cascade 3 、 Cascade 1 、Cascade 0屋顶不再投影,导致出现室内阴影镂空。
解决方案是遍历场景中所有的投影体,计算其最大FarZ并作为投影计算的FarZ:
// 与场景中所有的相交投影体计算最大的z分量
const auto& meshDC = inScene->GetMeshpassDrawCalls().GetMeshDC();
for(const auto& staticDC : meshDC.staticDrawCall)
{
const auto& modelMat = staticDC->model2WorldMatrix;
const auto& meshRenderBounds = staticDC->meshReference->renderBounds;
vec3 meshBoundsCenter = meshRenderBounds.origin;
vec3 meshCenterLightSpace = lightViewMatrix * modelMat * glm::vec4(meshBoundsCenter,1.0f);
if(meshCenterLightSpace.x + meshRenderBounds.radius >= minExtents.x &&
meshCenterLightSpace.x - meshRenderBounds.radius <= maxExtents.x &&
meshCenterLightSpace.y + meshRenderBounds.radius >= minExtents.y &&
meshCenterLightSpace.y - meshRenderBounds.radius <= maxExtents.y &&
meshCenterLightSpace.z + meshRenderBounds.radius > maxExtents.z)
{
for(const auto& subMesh : staticDC->meshReference->subMeshes)
{
vec3 subMeshBoundsCenter = subMesh.renderBounds.origin;
vec3 subMeshCenterLightSpace = lightViewMatrix * modelMat * glm::vec4(subMeshBoundsCenter,1.0f);
if(subMeshCenterLightSpace.x + meshRenderBounds.radius >= minExtents.x &&
subMeshCenterLightSpace.x - meshRenderBounds.radius <= maxExtents.x &&
subMeshCenterLightSpace.y + meshRenderBounds.radius >= minExtents.y &&
subMeshCenterLightSpace.y - meshRenderBounds.radius <= maxExtents.y &&
subMeshCenterLightSpace.z + subMesh.renderBounds.radius > maxExtents.z)
{
maxExtents.z = subMeshCenterLightSpace.z + subMesh.renderBounds.radius;
}
}
}
}
for(const auto& dynamicDC : meshDC.staticDrawCall)
{
const auto& modelMat = dynamicDC->model2WorldMatrix;
const auto& meshRenderBounds = dynamicDC->meshReference->renderBounds;
vec3 meshBoundsCenter = meshRenderBounds.origin;
vec3 meshCenterLightSpace = lightViewMatrix * modelMat * glm::vec4(meshBoundsCenter,1.0f);
if(meshCenterLightSpace.x + meshRenderBounds.radius >= minExtents.x &&
meshCenterLightSpace.x - meshRenderBounds.radius <= maxExtents.x &&
meshCenterLightSpace.y + meshRenderBounds.radius >= minExtents.y &&
meshCenterLightSpace.y - meshRenderBounds.radius <= maxExtents.y &&
meshCenterLightSpace.z + meshRenderBounds.radius > maxExtents.z)
{
for(const auto& subMesh : dynamicDC->meshReference->subMeshes)
{
vec3 subMeshBoundsCenter = subMesh.renderBounds.origin;
vec3 subMeshCenterLightSpace = lightViewMatrix * modelMat * glm::vec4(subMeshBoundsCenter,1.0f);
if(subMeshCenterLightSpace.x + meshRenderBounds.radius >= minExtents.x &&
subMeshCenterLightSpace.x - meshRenderBounds.radius <= maxExtents.x &&
subMeshCenterLightSpace.y + meshRenderBounds.radius >= minExtents.y &&
subMeshCenterLightSpace.y - meshRenderBounds.radius <= maxExtents.y &&
subMeshCenterLightSpace.z + subMesh.renderBounds.radius > maxExtents.z)
{
maxExtents.z = subMeshCenterLightSpace.z + subMesh.renderBounds.radius;
}
}
}
}
lightViewMatrix = glm::lookAt(
intervalCenter - lightDir * maxExtents.z,
intervalCenter,
glm::vec3(0.0f,1.0f,0.0f)
);
这种解决方案并不好,虽然完美解决了Depth Clip的问题,但每帧的一次遍历代价过高,并且虽然我已经做了一次Bounds Clip,但是计算出来的MaxZ还是过大,会导致ShadowMap的深度精度下降:
可以使用硬件特性depthClampEnable,也就是创建Vulkan pipeline时,将rasterizer.depthClampEnable设为True:
这样,会把近、远平面的物体深度渲染为 0 或 1,而不是丢弃它们的深度,这样就可以保留下高度了。
该特性需要硬件支持,可以通过VkPhysicalDeviceFeatures.depthClamp来获取是否支持。
问题三:Shadow Acne
级联ShadowMap如果开启硬件 Depth Bias,会导致渲染出来的不同级Cascade Depth Bias差距过大,体现在画面中,就是Cascade 接缝非常的明显:
如上图所示。两级Cascade相交处硬件DepthBias计算出来的差距过大,形成了明显的交接缝隙。
关闭掉硬件Depth Bias后,基本看不出两者的交接缝隙:
因此我们需要在Shader中手动计算Depth Bias了。
Learn OpenGL中有一个根据场景法线和光线方向的夹角来计算 Depth Bias 的算法:
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
这在延迟渲染管线中需要根据WorldPosition或SceneDepth等几何信息重建场景法线,否则直接用GBuffer中的世界空间法线极易导致法线纹理中的凹凸信息影响该算法的计算:
Unity和UE4中的解决思路都是计算 ShadowCoords的偏导,并用叉积近似计算受Slope影响的Bias因子:
// NOTE: In my test, this method on deffered rendering it's derivatives compute result is error and cause a lot of artifacts.
// AMD jacobian chain rule to compute shadow map receover plane depth bias.
vec3 AMDGetReceiverPlaneDepthBias(vec3 inProjCoords,vec2 inShadowMapTexelSize)
{
// Packing derivatives of u,v, and distance to light source w.r.t. screen space x, and y
vec3 duvdist_dx = dFdx(inProjCoords);
vec3 duvdist_dy = dFdy(inProjCoords);
// Invert texture Jacobian and use chain rule to compute ddist/du and ddist/dv
// |ddist/du| = |du/dx du/dy|-T * |ddist/dx|
// |ddist/dv| |dv/dx dv/dy| |ddist/dy|
// Multiply ddist/dx and ddist/dy by inverse transpose of Jacobian
float invDet = 1.0f / ((duvdist_dx.x * duvdist_dy.y) - (duvdist_dx.y * duvdist_dy.x));
vec3 ddist_duv;
// Top row of 2x2
ddist_duv.x = duvdist_dy.y * duvdist_dx.z; // invJtrans[0][0] * ddist_dx
ddist_duv.x -= duvdist_dx.y * duvdist_dy.z; // invJtrans[0][1] * ddist_dy
// Bottom row of 2x2
ddist_duv.y = duvdist_dx.x * duvdist_dy.z; // invJtrans[1][1] * ddist_dy
ddist_duv.y -= duvdist_dy.x * duvdist_dx.z; // invJtrans[1][0] * ddist_dx
ddist_duv.xy *= invDet;
const float minFractionalError = 0.01f;
float fractionalSamplingError = dot(inShadowMapTexelSize, abs(ddist_duv.xy));
ddist_duv.z = -min(fractionalSamplingError, minFractionalError);
return ddist_duv;
// usage:
// float bias = dot( uvBias,ddist_duv.xy ) + sceneDepth;
}
// Unreal depth bias dot factors.
vec2 UnrealDepthBiasDotFactors(vec3 inProjCoords)
{
vec3 dx = dFdx(inProjCoords);
vec3 dy = dFdy(inProjCoords);
vec3 depthBiasPlaneNormal = cross(dx, dy);
float depthBiasFactor = 1 / max(abs(depthBiasPlaneNormal.z), length(depthBiasPlaneNormal) * 0.0872665);
vec2 depthBiasDotFactors = depthBiasPlaneNormal.xy * depthBiasFactor;
return depthBiasDotFactors;
// usage:
// float SampleDepthBias = max(dot(depthBiasDotFactors, SampleUVOffset), 0);
}