# DX11-CSM

本文为 DX11 阴影专栏第二篇。讲解 CSM 的主要实现过程。前置基础为 ShadowMap 的实现

# CSM

先来看看之前简单使用 2048x2048 的 ShadowMap 的效果。

锯齿、漏光、条纹自遮挡,这些都是采样频率太低导致的。可是我近处顶多 25x25 大小的场景,使用 2048 的分辨率还有这么严重的走样是不能接受的。究其原因是这 2048x2048 的深度图并没有被完全利用。明明我只看到了一部分的场景,但是这一部分场景在光源 NDC 中只占一部分,只利用了一部分分辨率;其次,由于我们的光源矩阵是手动设定的,那么就可能会有物体在视锥内但是在光源 NDC 之外,无法渲染阴影。还有一点,我们只在意近处的阴影精度,远处的阴影错误我们很少会关注。因此我们需要一个能够动态调节光源 NDC 的、贴合可见物体的、能够根据距离分级渲染的一个方案。那么也就引出了我们的 CSM, 级联阴影贴图。

# CSM 的原理

CSM 的原理是将视锥以一定的方式进行切分,并对每个视锥分别渲染阴影贴图。近处的小范围可以使用更高精度的阴影贴图,远处的可以使用低分辨率的。
视锥划分方式等原理在此不再赘述,感兴趣可以查看 Xjun 教程,这里注重实践实现分析 \

# CSM 步骤分析

  1. 视锥划分 - 划分主视锥为 4 或 8 级小视锥
  2. 对每一个视锥进行以下相同的操作
    • 渲染 ShadowMap
      • 构建面朝光源的 AABB 包围盒
      • 以 AABB 包围盒作为 Ortho 投影边界构建投影矩阵,包围视锥
  3. 最终渲染中,根据某一策略(如深度)来确定 CSM 层级,确定采样哪一个 ShadowMap

# 视锥划分

根据一定比例划分 nearZ,farZ 即可。这里直接采用 Unity 的四级 CSM 的比例。

// CSM 视锥划分
static std::vector<DirectX::XMFLOAT4> FrustumColors = {
  DirectX::XMFLOAT4(0.2f, 0.3f, 0.6f, 1.0f), DirectX::XMFLOAT4(0.2f, 0.6f, 0.2f, 1.0f), //
  DirectX::XMFLOAT4(0.4f, 0.3f, 0.9f, 1.0f), DirectX::XMFLOAT4(0.9f, 0.2f, 0.4f, 1.0f), //
  DirectX::XMFLOAT4(0.4f, 0.1f, 0.6f, 1.0f), DirectX::XMFLOAT4(0.2f, 0.3f, 0.6f, 1.0f), //
  DirectX::XMFLOAT4(0.2f, 0.3f, 0.6f, 1.0f), DirectX::XMFLOAT4(0.2f, 0.3f, 0.6f, 1.0f)  //
};
// 各层级占比
static std::vector<float> CSMpercent = {6.7f, 13.3f, 26.7f, 53.3f};
//...  创建 camera
float nz = nearZ;
float fz = 0.0f;
for (int i = 0; i < nCSM; i++)
{
  Frustums.push_back(GameObject());
  fz = nz + CSMpercent[i] * 0.01 * (farZ - nearZ);
  camera->SetFrustum(XM_PI / 3, AspectRatio(), nz, fz);
  nz = fz;
  
  camera->GetFrustumPoints(points);   // 获取视锥坐标
  Frustums[i].SetBuffer(m_pd3dDevice.Get(), Geometry::CreateOtherBox<VertexPosNormalTex>(points)); // 视锥
  // ...
}

这里如何优雅的书写 GetFrustumPoints 呢?我们知道经过 MVP 变换后物体坐标进入裁剪空间,经过透视除法后变为 NDC 六面体。我们完全可以根据已知的 NDC 六面体来乘上逆矩阵来得到世界坐标。
透视投影矩阵和逆透视投影矩阵都会改变 w 分量,我们反变换也要注意对于 w 分量的归一化。但是在 dx11 中,XMVector3TransformCoord 会自动给我们处理 w 分量。
因此我们可以如下构建 GetFrustumPoints 函数

void Camera::GetFrustumPoints(std::vector<DirectX::XMFLOAT3> &points, bool isOrtho) const
{
    // 拿到 pro * view 的逆矩阵
    XMMATRIX view = GetViewXM();
    XMMATRIX proj = GetProjXM();
    XMMATRIX invView = DirectX::XMMatrixInverse(nullptr, view);
    XMMATRIX invProj = DirectX::XMMatrixInverse(nullptr, proj);
    points[0] = XMFLOAT3(1.0f, -1.0f, 0.0f);
    points[1] = XMFLOAT3(1.0f, 1.0f, 0.0f);
    points[2] = XMFLOAT3(1.0f, 1.0f, 1.0f);
    points[3] = XMFLOAT3(1.0f, -1.0f, 1.0f);
    points[4] = XMFLOAT3(-1.0f, -1.0f, 1.0f);
    points[5] = XMFLOAT3(-1.0f, 1.0f, 1.0f);
    points[6] = XMFLOAT3(-1.0f, 1.0f, 0.0f);
    points[7] = XMFLOAT3(-1.0f, -1.0f, 0.0f);
    for (int i = 0; i < 8; i++)
    {
        XMVECTOR v = XMLoadFloat3(&points[i]);
        v = XMVector3TransformCoord(v, invProj);
        v = XMVector3TransformCoord(v, invView);
        XMStoreFloat3(&points[i], v);
    }
}

四分视锥体使用线框绘制如下:

# AABB 包围盒

构建视锥体的 AABB 包围盒是 CSM 的核心内容,是构建分级 ShadowMap 的投影矩阵的来源。
我们要构建的包围盒是面向光源方向的,也就是在光源空间中垂直于三坐标轴的。这样可以使得 AABB 包围盒刚好能够作为我们的正交投影范围使用,来构建较为紧密贴合视锥体的 ShadowMap。
AABB 的构建也非常简单,视锥是有八个点的六边形,我们只要求出八个点在各个坐标轴上的最大和最小值即可。不过,需要是在光源空间中的哦!
因此,我们可以拆分步骤如下:

  1. 将视锥体六个顶点转入光源空间(乘 LightView 矩阵)
  2. 构建光源空间 AABB
  3. 根据光源空间 AABB 确定每一级 CSM 的正交投影矩阵
  4. (可选)将 AABB 八个点转回世界空间并绘制输出
    代码如下
// 根据视锥顶点 和 LightView 转换矩阵 构建 AABB 包围盒
AABB GetFrustumAABB(const std::vector<DirectX::XMFLOAT3> &frustum, DirectX::XMMATRIX lightMatrix) const
{
    std::vector<DirectX::XMFLOAT3> LightSpaceFrustum(8);
    // 1. 将点全部转到光照空间
    for (int i = 0; i < 8; i++)
    {
        XMVECTOR v = XMLoadFloat3(&frustum[i]);
        v = XMVector3TransformCoord(v, lightMatrix);
        XMStoreFloat3(&LightSpaceFrustum[i], v);
    }
    // 2. 计算光照空间下的 AABB
    float minX = LightSpaceFrustum[0].x, maxX = LightSpaceFrustum[0].x, minY = LightSpaceFrustum[0].y,
          maxY = LightSpaceFrustum[0].y, minZ = LightSpaceFrustum[0].z, maxZ = LightSpaceFrustum[0].z;
    for (int i = 1; i < 8; i++)
    {
        if (LightSpaceFrustum[i].x < minX)
            minX = LightSpaceFrustum[i].x;
        if (LightSpaceFrustum[i].x > maxX)
            maxX = LightSpaceFrustum[i].x;
        if (LightSpaceFrustum[i].y < minY)
            minY = LightSpaceFrustum[i].y;
        if (LightSpaceFrustum[i].y > maxY)
            maxY = LightSpaceFrustum[i].y;
        if (LightSpaceFrustum[i].z < minZ)
            minZ = LightSpaceFrustum[i].z;
        if (LightSpaceFrustum[i].z > maxZ)
            maxZ = LightSpaceFrustum[i].z;
    }
    return AABB(XMFLOAT3(minX, minY, minZ), XMFLOAT3(maxX, maxY, maxZ));
}
for (int i = 0; i < nCSM; i++){
  //... 构建好视锥存入 Frustums [i] 中 视锥顶点坐标存入 points 中
  auto aabb = GetFrustumAABB(points, LightView); // 通过视锥体计算 AABB
  std::vector<XMFLOAT3> aabbPoints;
  aabb.GetPoints(aabbPoints);
  // 根据 AABB 的大小确定正交投影范围
  auto pro = DirectX::XMMatrixOrthographicOffCenterLH(aabb.min.x, aabb.max.x, aabb.min.y, aabb.max.y, aabb.min.z,
                                                      aabb.max.z);
  // LightMatrix 写入 ShadowMap 和 主渲染 的缓冲区中
  m_LightBuffers[i].view = XMMatrixTranspose(LightView);
  m_LightBuffers[i].pro = XMMatrixTranspose(pro);
  m_PSConstantBuffer.LightViewMatrix[i] = XMMatrixTranspose(LightView);
  m_PSConstantBuffer.LightProMatrix[i] = XMMatrixTranspose(pro);
  // AABB 转回世界空间绘制
  for (auto &p : aabbPoints)
  {
      XMVECTOR v = XMLoadFloat3(&p);
      v = XMVector3Transform(v, InvLightView);
      XMStoreFloat3(&p, v);
  }
  FrustumAABBs[i].SetBuffer(m_pd3dDevice.Get(), Geometry::CreateOtherBox<VertexPosNormalTex>(aabbPoints));
  FrustumAABBs[i].GetMaterial().diffuse = XMFLOAT4(0.8f, 0.3f, 0.2f, 1.0f);
  FrustumAABBs[i].GetMaterial().reflect.x = 1.0f;
}

AABB 可视化如下:

# 渲染更改

  1. 要渲染每一张 ShadowMap。只需要正确设置每次 ShadowMap 的 lightMatrix 即可。
  2. 计算着色点 CSM 层级。在 shader 中根据深度来确定 csm 层级即可。区分层级如下:
int CalculateCSMLevel(float depth){
     float csmIndex = (depth - g_nearZ) / (g_farZ - g_nearZ) * 100.0f;
    [flatten]
    if(csmIndex < 6.7f){
        return 0;
    }
    [flatten]
    if(csmIndex < 13.3f + 6.7f){
        return 1;
    }
    [flatten]
    if(csmIndex < 26.7f + 13.3f + 6.7f){
        return 2;
    }
    return 3;
}

可视化一下:

# 阶段性效果图对比

原阴影(2048x2048)效果:

CSM (4 级 2048) 效果:

让我们和文章开头,同样是使用 2048x2048 的 shadowMap 进行一下对比。整个的效果提升非常明显。
四级 2048: 2048x2048x (1+0.25+0.0625+0.015625) = 2048x2048x1.328125. 显存提升了约 1.33 倍但是效果提升如此显著。

# 阴影抖动分析解决

如果只按照以上方式进行 CSM 实现,那么将会出现比较明显的阴影抖动现象。这里我们降低分辨率至 512 来观察。

原因主要是 AABB 视锥跟随着主视锥即时移动,由于 ShadowMap 的点采样,本来一个着色点采样的深度是像素 A 的,在 ShadowMap 进行平移半个像素后瞬间变成了采样邻近的像素 B 导致边缘阴影的抖动。
因此,我们要想办法限制 AABB 视锥的移动。由于 AABB 永远在光源空间中和三坐标轴垂直,所以一切 AABB 的变化看起来就是缩放和平移。我们首先要限制 AABB 不能缩放,并且要以一定步进进行平移。
不能缩放的意思就是要确定好长宽 (xy) 的值不变,这里为了方便之后确定一定步进的平移,因此 AABB 的宽高比必须和 ShadowMap 的分辨率宽高比一致。ShadowMap 我设置了 1:1,因此要求 AABB 的长宽必须是个正方形。
那么怎么确定这个值呢?AABB 的定义是必须包围该视锥,也就是这个视锥在 AABB 里边任意转动应该都是能够被包围的,这也是 UE,Unity 中采用包围球的概念的原因,其实本质上是一样的。我们取子视锥的最长对角线作为包围盒的长宽,也就是包围球的直径。
确定了尺寸之后就能够很容易的确定步进。我们以一个纹素的大小作为最小单位,平移必须移动整数倍。这里的 “平移” 我们实际上使用取余操作来实现。这样的话,对于同一个着色点,本来落在像素点 A 内,那么视锥体发生变化后,由于他和像素点 A 同时移动纹素大小的整数倍到另外一个像素中,他们最终还是在一个像素中并且相对位置不会变化,保证了这个着色点一定还是采样像素点 A。也就不会闪烁了。

p
AABB GetFrustumAABB(const std::vector<DirectX::XMFLOAT3> &frustum, DirectX::XMMATRIX lightMatrix) const
{
std::vector<DirectX::XMFLOAT3> LightSpaceFrustum(8);
    // 1. 将点全部转到光照空间
    for (int i = 0; i < 8; i++)
    {
        XMVECTOR v = XMLoadFloat3(&frustum[i]);
        v = XMVector3TransformCoord(v, lightMatrix);
        XMStoreFloat3(&LightSpaceFrustum[i], v);
    }
    //frustum [0] & frustum [5] 是近平面和远平面对角的对角线  frustum [5] & frustum [3] 是远平面对角线
    static auto dis = [](DirectX::XMFLOAT3 a, DirectX::XMFLOAT3 b) -> float
    { return sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + (a.z - b.z) * (a.z - b.z)); };
    float maxDis = std::max(dis(frustum[0], frustum[5]), dis(frustum[5], frustum[3]));
    // 2. 计算光照空间下的 AABB
    float minX = LightSpaceFrustum[0].x, maxX = LightSpaceFrustum[0].x, minY = LightSpaceFrustum[0].y,
          maxY = LightSpaceFrustum[0].y, minZ = LightSpaceFrustum[0].z, maxZ = LightSpaceFrustum[0].z;
    for (int i = 1; i < 8; i++)
    {
        if (LightSpaceFrustum[i].x < minX)
            minX = LightSpaceFrustum[i].x;
        if (LightSpaceFrustum[i].x > maxX)
            maxX = LightSpaceFrustum[i].x;
        if (LightSpaceFrustum[i].y < minY)
            minY = LightSpaceFrustum[i].y;
        if (LightSpaceFrustum[i].y > maxY)
            maxY = LightSpaceFrustum[i].y;
        if (LightSpaceFrustum[i].z < minZ)
            minZ = LightSpaceFrustum[i].z;
        if (LightSpaceFrustum[i].z > maxZ)
            maxZ = LightSpaceFrustum[i].z;
    }
    // 取余限制平移
    float TexelSize = maxDis / 512.0f;
    float posX = (minX + maxX) * 0.5f;
    posX /= TexelSize;
    posX = floor(posX);
    posX *= TexelSize;
    float posY = (minY + maxY) * 0.5f;
    posY /= TexelSize;
    posY = floor(posY);
    posY *= TexelSize;
    float posZ = minZ;
    posZ /= TexelSize;
    posZ = floor(posZ);
    posZ *= TexelSize;
    return AABB(XMFLOAT3(posX - maxDis / 2, posY - maxDis / 2, minZ),
                XMFLOAT3(posX + maxDis / 2, posY + maxDis / 2, maxZ));
}

防抖效果如下:

# 级联边界

眼尖的朋友肯定能发现上图中前后移动的时候还是可以看见明显的级联边界更换。
这里主要是我的 ShadowMap 分辨率太低(512), 并且主视锥太短(200),所以非常明显。
如果我们将分辨率提高,视锥拉长,也就是 0 级级联会覆盖更多内容,这种情况也就变的肉眼难以捕捉了。

# 最终效果


这里我因为是延迟渲染,没加 FXAA,后边闪烁是因为这个走样。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Yian Zhao 微信支付

微信支付

Yian Zhao 支付宝

支付宝

Yian Zhao 贝宝

贝宝