对逐物体阴影的一些改进¶
对之前 逐物体阴影的实现方法 做一些改进。
改正 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{Z}^{-1}=\mathbf{Z}\) 且 \(\mathbf{S}^{-1}=\mathbf{S}\),所以下面几个公式也对
之前脑抽了,写成
float4x4 viewMatrix = float4x4.TRS(-aabbCenter, inverse(lightRotation), 1);
viewMatrix = mul(s_FlipZMatrix, viewMatrix); // 翻转 z 轴
即
显然是错的。正确的计算方法是
float4x4 viewMatrix = inverse(float4x4.TRS(aabbCenter, lightRotation, 1));
viewMatrix = mul(s_FlipZMatrix, viewMatrix); // 翻转 z 轴
这个问题在之前的文章里也修改了。
改进 Projection Matrix 的计算¶
这里只考虑主平行光源,也就是正交投影。
这个方法的优势:
- 准确地剔除看不见的阴影。
zNear
和zFar
的距离是最小的,不浪费 ShadowMap 的精度。
思路¶
把角色的包围盒用之前算的 View Matrix 变换到光源 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