跳转至

对逐物体阴影的一些改进

对之前 逐物体阴影的实现方法 做一些改进。

改正 View Matrix 的计算

约定

  • \(\mathbf{T}\) 表示 translate(aabbCenter)
  • \(\mathbf{R}\) 表示 rotate(lightRotation)
  • \(\mathbf{S}\) 表示 scale(1, 1, 1)
  • \(\mathbf{Z}\) 表示翻转 Z 轴
  • \(\mathbf{w}\) 表示 World Space 的点
  • \(\mathbf{v}\) 表示将 \(\mathbf{w}\) 变换到 View Space 后得到的点

有公式

\[ \mathbf{w}=\mathbf{T}\mathbf{R}\mathbf{S}\mathbf{Z}\mathbf{v} \]

所以

\[ \text{View Matrix}=\mathbf{Z}^{-1}\mathbf{S}^{-1}\mathbf{R}^{-1}\mathbf{T}^{-1} \]

考虑到 \(\mathbf{Z}^{-1}=\mathbf{Z}\)\(\mathbf{S}^{-1}=\mathbf{S}\),所以下面几个公式也对

\[ \text{View Matrix}=\mathbf{Z}\mathbf{S}\mathbf{R}^{-1}\mathbf{T}^{-1}=\mathbf{Z} (\mathbf{T}\mathbf{R}\mathbf{S})^{-1} \]

之前脑抽了,写成

float4x4 viewMatrix = float4x4.TRS(-aabbCenter, inverse(lightRotation), 1);
viewMatrix = mul(s_FlipZMatrix, viewMatrix); // 翻转 z 轴

\[ \mathbf{Z}\mathbf{T}^{-1}\mathbf{R}^{-1}\mathbf{S} \]

显然是错的。正确的计算方法是

float4x4 viewMatrix = inverse(float4x4.TRS(aabbCenter, lightRotation, 1));
viewMatrix = mul(s_FlipZMatrix, viewMatrix); // 翻转 z 轴

这个问题在之前的文章里也修改了。

改进 Projection Matrix 的计算

这里只考虑主平行光源,也就是正交投影。

剔除效果
剔除效果

这个方法的优势:

  1. 准确地剔除看不见的阴影。
  2. zNearzFar 的距离是最小的,不浪费 ShadowMap 的精度。

思路

把角色的包围盒用之前算的 View Matrix 变换到光源 View Space 后,看起来就像下面这样。

光源 View Space 中的角色包围盒
光源 View Space 中的角色包围盒

这个包围盒就是角色可投射阴影的区域,也是光源的视锥体。为了让阴影投射到更远的地方,需要把 zFar 拉远一点。但如果 zFar 距离 zNear 过远,会导致 ShadowMap 中深度都集中在 0 或者 1 附近,浪费精度,阴影质量也差。

比较好的算法是,在光源的 View Space 中对主相机视锥体进行切割,得到处于上图中矩形范围内的部分(切割时不考虑 Z 轴),再调整 zFar 把切出来的那部分包起来。这样,光源视锥体恰好能包住主相机视锥体的有效部分。

切割的结果还能用来判断阴影的可见性。如果切割后什么都没剩,或者 zFar 算出来跑到 zNear 后面,则说明阴影不可见。

考虑到有时候主相机的视锥体比较长,用上面的方法算出的 zFar 距离 zNear 也很远,所以最后还是要再限制一下 zFar - zNear 的值。

计算阴影包围盒

将角色的 World Space 包围盒变换到光源 View Space。

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe void GetViewSpaceShadowAABB(in ShadowCasterCullingArgs args,
    in float4x4 viewMatrix, out float3 shadowMin, out float3 shadowMax)
{
    // 8 个顶点
    float4* points = stackalloc float4[8]
    {
        float4(args.AABBMin, 1),
        float4(args.AABBMax.x, args.AABBMin.y, args.AABBMin.z, 1),
        float4(args.AABBMin.x, args.AABBMax.y, args.AABBMin.z, 1),
        float4(args.AABBMin.x, args.AABBMin.y, args.AABBMax.z, 1),
        float4(args.AABBMax.x, args.AABBMax.y, args.AABBMin.z, 1),
        float4(args.AABBMax.x, args.AABBMin.y, args.AABBMax.z, 1),
        float4(args.AABBMin.x, args.AABBMax.y, args.AABBMax.z, 1),
        float4(args.AABBMax, 1),
    };

    shadowMin = float3(float.PositiveInfinity);
    shadowMax = float3(float.NegativeInfinity);

    for (int i = 0; i < 8; i++)
    {
        float3 p = mul(viewMatrix, points[i]).xyz;
        shadowMin = min(shadowMin, p);
        shadowMax = max(shadowMax, p);
    }

    if (args.Usage == ShadowUsage.Scene)
    {
        // 理论上场景阴影可以打到无穷远处,但包围盒太长的话深度都集中在 0 或者 1 处,精度不够
        // 目前限制最多向后扩展 100 个单位
        shadowMin.z = min(shadowMin.z, shadowMax.z - 100);
    }
}

上面的代码在最后把 shadowMin.z 向后扩展到 100 个单位,表示阴影最多往后投射 100 米,超出这个包围盒就没有阴影了。

简化主相机视锥体

主相机的视锥体是个棱台,直接用它计算很麻烦,所以将它拆成一堆三角形。每个面沿任意对角线拆成 2 个三角形,一共 12 个。

实现时,采用 Mesh 里 Vertex Buffer 和 Index Buffer 的思想,先算视锥体的 8 个顶点。

private static readonly Vector3[] s_FrustumCornerBuffer = new Vector3[4];

public static void SetFrustumEightCorners(float4* frustumEightCorners, Camera camera)
{
    Transform transform = camera.transform;
    float near = camera.nearClipPlane;
    float far = camera.farClipPlane;

    if (camera.orthographic)
    {
        // Camera.CalculateFrustumCorners 不支持正交投影

        // The orthographicSize is half the size of the vertical viewing volume.
        // The horizontal size of the viewing volume depends on the aspect ratio.
        float top = camera.orthographicSize;
        float right = top * camera.aspect;

        // 顺序要和下一个分支里的一致
        frustumEightCorners[0] = TransformPoint(transform, -right, -top, near);
        frustumEightCorners[1] = TransformPoint(transform, -right, +top, near);
        frustumEightCorners[2] = TransformPoint(transform, +right, +top, near);
        frustumEightCorners[3] = TransformPoint(transform, +right, -top, near);
        frustumEightCorners[4] = TransformPoint(transform, -right, -top, far);
        frustumEightCorners[5] = TransformPoint(transform, -right, +top, far);
        frustumEightCorners[6] = TransformPoint(transform, +right, +top, far);
        frustumEightCorners[7] = TransformPoint(transform, +right, -top, far);
    }
    else
    {
        // https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Camera.CalculateFrustumCorners.html
        // The order of the corners is lower left, upper left, upper right, lower right.

        Rect viewport = new Rect(0, 0, 1, 1);
        const Camera.MonoOrStereoscopicEye eye = Camera.MonoOrStereoscopicEye.Mono;

        camera.CalculateFrustumCorners(viewport, near, eye, s_FrustumCornerBuffer);
        for (int i = 0; i < 4; i++)
        {
            frustumEightCorners[i] = TransformPoint(transform, s_FrustumCornerBuffer[i]);
        }

        camera.CalculateFrustumCorners(viewport, far, eye, s_FrustumCornerBuffer);
        for (int i = 0; i < 4; i++)
        {
            frustumEightCorners[i + 4] = TransformPoint(transform, s_FrustumCornerBuffer[i]);
        }
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float4 TransformPoint(Transform transform, float x, float y, float z)
{
    return TransformPoint(transform, new Vector3(x, y, z));
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float4 TransformPoint(Transform transform, Vector3 point)
{
    return new float4(transform.TransformPoint(point), 1);
}

然后,给出一个索引列表,这个不唯一,也不用考虑顺时针或逆时针。我是用下面的方式拆分的。

拆分成三角形
拆分成三角形

public const int FrustumTriangleCount = 12;

public static readonly int[] FrustumTriangleIndices = new int[FrustumTriangleCount * 3]
{
    0, 3, 1,
    1, 3, 2,
    2, 3, 7,
    2, 7, 6,
    0, 5, 4,
    0, 1, 5,
    1, 2, 5,
    2, 6, 5,
    0, 7, 3,
    0, 4, 7,
    4, 7, 5,
    5, 7, 6,
};

裁剪主相机视锥体

将刚才那 12 个三角形变换到光源 View Space 后,暂时不考虑 Z 轴,裁剪出阴影包围盒矩形区域中的部分。

先考虑一个三角形被一条线裁剪的情况。

一个点在内部
一个点在内部

三个点都在内部,或者都不在内部的情况就不说了。只有一个点 \(A\) 在内部时,如上图。根据相似三角形很容易算出 \(P\)\(Q\) 点的坐标,进而将 \(\triangle ABC\) 裁剪为 \(\triangle APQ\)。两个点在内部的情况和上面类似,只是裁剪出来是一个四边形,要再拆成两个三角形。

一个三角形被一个矩形裁剪,相当于依次被这个矩形的 4 条边裁剪。最坏情况下,每次三角形都是两个点在内部,然后三角形数量翻倍,最后变成 16(2 的 4 次方)个三角形。

对 12 个三角形依次做上述裁剪,视锥体就被裁剪完成了。

private ref struct TriangleData
{
    public float3 P0;
    public float3 P1;
    public float3 P2;
    public bool IsCulled;
}

private enum EdgeType
{
    Min,
    Max,
}

private ref struct EdgeData
{
    public int ComponentIndex;
    public float Value;
    public EdgeType Type;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe bool AdjustViewSpaceShadowAABB(in ShadowCasterCullingArgs args,
    in float4x4 viewMatrix, ref float3 shadowMin, ref float3 shadowMax)
{
    float3* frustumCorners = stackalloc float3[ShadowCasterCullingArgs.FrustumCornerCount];

    for (int i = 0; i < ShadowCasterCullingArgs.FrustumCornerCount; i++)
    {
        frustumCorners[i] = mul(viewMatrix, args.FrustumEightCorners[i]).xyz;
    }

    EdgeData* edges = stackalloc EdgeData[4]
    {
        new() { ComponentIndex = 0, Value = shadowMin.x, Type = EdgeType.Min },
        new() { ComponentIndex = 0, Value = shadowMax.x, Type = EdgeType.Max },
        new() { ComponentIndex = 1, Value = shadowMin.y, Type = EdgeType.Min },
        new() { ComponentIndex = 1, Value = shadowMax.y, Type = EdgeType.Max },
    };

    // 最坏情况:1 个三角形被拆成 2**4 = 16 个三角形
    TriangleData* triangles = stackalloc TriangleData[16];

    bool isVisibleXY = false;
    float minZ = float.PositiveInfinity;
    float maxZ = float.NegativeInfinity;

    for (int i = 0; i < ShadowCasterCullingArgs.FrustumTriangleCount; i++)
    {
        int triangleCount = 1;
        triangles[0].P0 = frustumCorners[ShadowCasterCullingArgs.FrustumTriangleIndices[i * 3 + 0]];
        triangles[0].P1 = frustumCorners[ShadowCasterCullingArgs.FrustumTriangleIndices[i * 3 + 1]];
        triangles[0].P2 = frustumCorners[ShadowCasterCullingArgs.FrustumTriangleIndices[i * 3 + 2]];
        triangles[0].IsCulled = false;

        for (int j = 0; j < 4; j++)
        {
            for (int k = 0; k < triangleCount; k++)
            {
                CullTriangle(triangles, ref k, ref triangleCount, in edges[j]);
            }
        }

        for (int j = 0; j < triangleCount; j++)
        {
            ref TriangleData tri = ref triangles[j];

            if (tri.IsCulled)
            {
                continue;
            }

            isVisibleXY = true;
            minZ = min(minZ, min(tri.P0.z, min(tri.P1.z, tri.P2.z)));
            maxZ = max(maxZ, max(tri.P0.z, max(tri.P1.z, tri.P2.z)));
        }
    }

    if (isVisibleXY && minZ < shadowMax.z && maxZ > shadowMin.z)
    {
        // 为了阴影的完整性,不应该修改 shadowMax.z
        shadowMin.z = max(shadowMin.z, minZ);
        return true;
    }

    return false;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe void CullTriangle([NoAlias] TriangleData* triangles,
    ref int triangleIndex, ref int triangleCount, in EdgeData edge)
{
    ref TriangleData tri = ref triangles[triangleIndex];

    if (tri.IsCulled)
    {
        return;
    }

    int insideInfo = 0b000;
    if (IsPointInsideEdge(in edge, in tri.P0)) insideInfo |= 0b001;
    if (IsPointInsideEdge(in edge, in tri.P1)) insideInfo |= 0b010;
    if (IsPointInsideEdge(in edge, in tri.P2)) insideInfo |= 0b100;

    bool isOnePointInside;

    // 将在边界里的点移动到 [P0, P1, P2] 列表的前面
    switch (insideInfo)
    {
        // 没有点在里面
        case 0b000: tri.IsCulled = true; return;

        // 有一个点在里面
        case 0b001: isOnePointInside = true; break;
        case 0b010: isOnePointInside = true; Swap(ref tri.P0, ref tri.P1); break;
        case 0b100: isOnePointInside = true; Swap(ref tri.P0, ref tri.P2); break;

        // 有两个点在里面
        case 0b011: isOnePointInside = false; break;
        case 0b101: isOnePointInside = false; Swap(ref tri.P1, ref tri.P2); break;
        case 0b110: isOnePointInside = false; Swap(ref tri.P0, ref tri.P2); break;

        // 所有点在里面
        case 0b111: return;

        // Unreachable
        default: Debug.LogError("Unknown triangleInsideInfo"); return;
    }

    if (isOnePointInside)
    {
        // 只有 P0 在里面
        float3 v01 = tri.P1 - tri.P0;
        float3 v02 = tri.P2 - tri.P0;

        float dist = edge.Value - tri.P0[edge.ComponentIndex];
        tri.P1 = v01 * rcp(v01[edge.ComponentIndex]) * dist + tri.P0;
        tri.P2 = v02 * rcp(v02[edge.ComponentIndex]) * dist + tri.P0;
    }
    else
    {
        // 只有 P2 在外面
        float3 v20 = tri.P0 - tri.P2;
        float3 v21 = tri.P1 - tri.P2;

        float dist = edge.Value - tri.P2[edge.ComponentIndex];
        float3 p0 = v20 * rcp(v20[edge.ComponentIndex]) * dist + tri.P2;
        float3 p1 = v21 * rcp(v21[edge.ComponentIndex]) * dist + tri.P2;

        // 第一个三角形
        tri.P2 = p0;

        // 把下一个三角形拷贝到列表最后新的位置上,然后把新三角形数据写入到下个位置
        // 新的三角形必定三个点都在边界内,所以 ++triangleIndex 跳过检查
        ref TriangleData newTri = ref triangles[++triangleIndex];
        triangles[triangleCount++] = newTri;

        // 第二个三角形
        newTri.P0 = p0;
        newTri.P1 = tri.P1;
        newTri.P2 = p1;
        newTri.IsCulled = false;
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsPointInsideEdge(in EdgeData edge, in float3 p)
{
    // EdgeType.Min => p[edge.ComponentIndex] > edge.Value
    // EdgeType.Max => p[edge.ComponentIndex] < edge.Value

    float delta = p[edge.ComponentIndex] - edge.Value;
    return select(-delta, delta, edge.Type == EdgeType.Min) > 0;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void Swap(ref float3 a, ref float3 b) => (a, b) = (b, a);

计算 Projection Matrix

阴影包围盒在 XY 方向上是中心对称的,因为光源 View Space 是以包围盒中心为原点的(参考前面 View Matrix 的计算)。

float width = shadowMax.x * 2;
float height = shadowMax.y * 2;
float zNear = -shadowMax.z;
float zFar = -shadowMin.z;
float4x4 projectionMatrix = float4x4.Ortho(width, height, zNear, zFar);

动态计算包围盒 vs 静态包围盒

在我的实现中,角色包围盒是根据 Renderer.bounds 动态计算的。有人说,动态计算会使包围盒一直变化,导致投影矩阵一直变化,进而出现阴影抖动,所以直接在 Inspector 里指定一个固定大小的包围盒更好。

我这样写的初衷是减少插件暴露出去的参数数量,降低使用门槛。不然,总有人不看文档,乱填参数,然后来私信问我。固定大小的包围盒要是设置的不够大,会导致阴影不完整。

另外,动态计算的包围盒不会一直变化,只有 Renderer.bounds 变了,计算结果才会变(可以看上面的动图)。Renderer.bounds 通常只在播放 AnimationClip 时才会变化,角色在做各种动作时,阴影肯定会有一点抖动,包围盒的轻微变化带来的影响,肉眼看不出来。

不过,能用静态包围盒还是用静态的,毕竟计算量少,代码也好写。

完整代码

GitHub: stalomeow/StarRailNPRShader

评论