Deep Read

逐物体阴影

原理

  Unity 自带的 Shadow Map 要覆盖整个场景,分给场景中重要角色的分辨率就不够了,镜头拉近时锯齿感明显。级联(CSM)虽能缓解,但精度受级联距离和分配策略牵制,很难专门照顾某个角色。

本方案换个思路:给每个指定物体单独算一个紧贴其包围盒的正交阴影视锥,把多个物体的深度渲染进同一张图集(Atlas);再利用相机深度重建屏幕像素的世界坐标,把这些阴影以屏幕空间的方式投射回画面。

这样每个物体都独占一块阴影分辨率,与主光阴影解耦;产出的又是一张屏幕空间 mask,可以直接乘进画面,也能交给任意业务 Shader 使用。

整体分三步:

  1. 收集参与阴影的物体
  2. 在 Render Feature 中生成阴影图集(Atlas)
  3. 采样阴影,投射回屏幕

原理示意

整个功能由 PerObjectShadowFeature 挂两个 Pass 串起来:PerObjectShadowCasterPass(生成图集,BeforeRenderingShadows)和 PerObjectScreenSpaceShadowPass(屏幕空间应用,AfterRenderingOpaques)。场景里没有任何投影物体时直接跳过,不产生开销。

1. 收集参与阴影的物体

需要投影的物体挂载 PerObjectShadowProjector.cs。它会:

  • casterRoot(默认自身节点)收集所有子 Renderer
  • 合并这些 Renderer.bounds,得到一个世界空间 AABB
  • OnEnable 时注册到全局列表 PerObjectShadowRegistry
  • 每帧在 Update 里刷新包围盒(角色移动/换动作后 AABB 会变)

这个 AABB 决定了该物体阴影贴图的覆盖范围和精度——盒子越紧,同样分辨率下阴影越清晰。

PerObjectShadowRegistry.cs 只是一个全局静态列表,让 Render Feature 知道当前有哪些对象需要产生独立阴影:

private static readonly List<PerObjectShadowProjector> s_ActiveProjectors = new List<PerObjectShadowProjector>();
public static IReadOnlyList<PerObjectShadowProjector> ActiveProjectors => s_ActiveProjectors;

public static void Add(PerObjectShadowProjector projector)
{
    if (projector == null || s_ActiveProjectors.Contains(projector))
        return;
    s_ActiveProjectors.Add(projector);
}

public static void Remove(PerObjectShadowProjector projector)
{
    if (projector == null)
        return;
    s_ActiveProjectors.Remove(projector);
}

2. 生成阴影图集

阴影图集在 PerObjectShadowCasterPass 中生成,执行时机为 BeforeRenderingShadows。Pass 先从 Registry 里筛出有效对象(包围盒有效、材质存在,且不超过 maxShadowCount),再逐个渲染。

获取 Shadow RT

根据本帧要投影的物体数量,把 Atlas 切成 grid × grid 个 Tile,刚好放下所有对象:

int grid = Mathf.CeilToInt(Mathf.Sqrt(m_Valid.Count));
int tileSize = m_AtlasResolution / grid;
ShadowUtils.ShadowRTReAllocateIfNeeded(ref m_ShadowAtlas, m_AtlasResolution, m_AtlasResolution,
    k_ShadowmapBufferBits, name: "_PerObjectShadowmapTexture");
N(投影数)√Ngrid(向上取整)网格tileSize(2048/grid)
11.011×12048
42.022×21024
52.2433×3682
93.033×3682
103.1644×4512
164.044×4512

然后把 RenderTarget 切到这张 Atlas,清深度:

CoreUtils.SetRenderTarget(cmd, m_ShadowAtlas, RenderBufferLoadAction.DontCare,
    RenderBufferStoreAction.Store, ClearFlag.Depth, Color.clear);

绘制每个对象前,用 cmd.SetViewport 把绘制限制在它所属的那个 Tile 矩形内,各对象互不干扰。

构建阴影采样矩阵

矩阵计算集中在 PerObjectShadowMath.ComputeMatrices,是整套方案的核心。它把「物体世界包围盒 + 主光方向」变成一组阴影相机参数,输出三个矩阵:

  • viewMatrix:世界 → 阴影相机(渲染用)
  • projMatrix:正交投影,非 reverse-Z(渲染用)
  • worldToShadow:世界 → 阴影贴图 UV/Depth,已含 reverse-Z 与 [0,1] 偏移(Shader 采样用)

先确定正交相机的位置——沿光照反方向退到包围球外,保证整个物体落在相机前方(−Z)。相机不能放在世界原点,否则物体可能跑到相机背后,深度全乱:

Vector3 center = worldBounds.center;
float radius = worldBounds.extents.magnitude;
Vector3 camPos = center - lightDir * (radius + 1f);

相机位置示意

再算 view 矩阵:由相机的世界变换取逆得到 world → 相机本地,再翻转 Z,变成图形管线需要的右手观察空间:

Matrix4x4 camLocalToWorld = Matrix4x4.TRS(camPos, camRot, Vector3.one);
viewMatrix = Matrix4x4.Scale(new Vector3(1f, 1f, -1f)) * camLocalToWorld.inverse;

Unity 场景里相机的本地空间是左手系,+Z 指向相机看的方向(forward);而图形 API 的 view / 观察空间是右手系,相机看向 −Z。这一步翻 Z 就是在对齐这个约定,等价于 Camera.worldToCameraMatrix 的构造方式。

然后确定正交框的大小:把包围盒 8 个顶点变换到观察空间,量出 x/y/z 的精确范围,让正交框刚好贴合物体(不浪费分辨率):

Vector3 c = worldBounds.center;
Vector3 e = worldBounds.extents;
for (int i = 0; i < 8; i++)
{
    Vector3 corner = c + new Vector3(
        (i & 1) == 0 ? -e.x : e.x,
        (i & 2) == 0 ? -e.y : e.y,
        (i & 4) == 0 ? -e.z : e.z);

    Vector3 v = viewMatrix.MultiplyPoint(corner);
    min = Vector3.Min(min, v);
    max = Vector3.Max(max, v);
}
  • min.x/max.xmin.y/max.y → 正交框的左右、上下边界
  • min.z/max.z → 近远平面(观察空间看向 −Z,取负转成正的距离)

远平面额外加 shadowClipDistance,让影子能延伸投到物体脚下:

float near = -max.z - 0.1f;
float far  = -min.z + shadowClipDistance;
projMatrix = Matrix4x4.Ortho(min.x, max.x, min.y, max.y, near, far);
worldToShadow = GetShadowTransform(projMatrix, viewMatrix);

GetShadowTransform() 复刻自 URP 私有的 ShadowUtils.GetShadowTransform()(原函数无法外部调用)。它做两件事:

  1. projMatrix 手动做 reverse-Z(在支持反向 Z 缓冲的平台上翻转第三行),对齐 GPU 实际光栅化的深度约定;
  2. 乘上 view 得到 VP,再左乘一个 textureScaleAndBias,把 NDC 的 [-1, 1] 映射到贴图采样用的 [0, 1]。
textureScaleAndBias =
┌ 0.5   0    0    0.5 ┐   m00=0.5  m03=0.5   ← x
│  0   0.5   0    0.5 │   m11=0.5  m13=0.5   ← y
│  0    0   0.5   0.5 │   m22=0.5  m23=0.5   ← z
└  0    0    0     1  ┘

最后叠加 Tile 偏移,把这个对象的 [0,1] 输出重映射进 Atlas 里它自己那一格。这样 Shader 直接用 sc.xy 采样即可,不用再传 Tile 的 uniform:

worldToShadow = PerObjectShadowMath.ApplyTileTransform(worldToShadow, col, row, grid);

关键一致性(易踩坑):渲染用的是非 reverse-ZprojMatrix,而 cmd.SetViewProjectionMatrices 不会自动转 GPU 投影约定,所以渲染前必须手动 GL.GetGPUProjectionMatrix(projMatrix, true);采样用的 worldToShadow 里则手动做了 reverse-Z。渲染侧与采样侧的深度约定必须一致对上,否则整片阴影错乱或全黑。

生成阴影图

准备好矩阵后,遍历对象每个 Renderer 的每个 SubMesh,用阴影材质的 Caster Pass 把它们光栅化进当前 Tile:

var renderers = projector.Renderers;
for (int r = 0; r < renderers.Count; r++)
{
    var rend = renderers[r];
    if (rend == null || !rend.enabled)
        continue;
    int subMeshCount = GetSubMeshCount(rend);
    for (int sub = 0; sub < subMeshCount; sub++)
        cmd.DrawRenderer(rend, projector.shadowMaterial, sub, 0); // pass 0 = PerObjectShadowCaster
}

Caster Pass 很简单:顶点用管线设好的阴影相机 VP 变换到裁剪空间,片元直接输出裁剪空间深度 positionCS.zColorMask R,只写一个通道):

half4 frag(Varyings IN) : SV_Target
{
    return IN.positionCS.z;
}

绘制前还设了深度偏移 cmd.SetGlobalDepthBias(1.0f, 2.5f),把深度往里推一点,缓解自遮挡产生的 shadow acne(画完立刻还原为 0,避免污染后续绘制)。这一步把深度写进图集对应的 Tile,就是生成阴影图(深度图)的过程;采样时拿这些深度做比较即可判断遮挡。

3. 采样阴影

将阴影投回场景采用 Screen Space Shadow,由 PerObjectScreenSpaceShadowPass 完成。它先申请一张屏幕大小的 mask(R8 足够,清成白色 1),再为每个对象画一次全屏三角形。

Screen Space Shadow 的核心思想:阴影不在物体表面算,而在屏幕的每个像素上算。 它把「这个像素在不在阴影里」从几何空间搬到屏幕空间——用屏幕深度反推出每个像素的世界坐标,再拿这个世界坐标去 Shadow Map 里查一次深度比较。好处是与被投影物体的几何解耦,且天然产出一张可复用的屏幕 mask。

单个对象的片元逻辑(Shader Pass 1):

  1. 采样 _CameraDepthTexture 拿到该像素深度
  2. 用相机逆 VP 矩阵 ComputeWorldSpacePosition 重建世界坐标
  3. 乘该对象的 worldToShadow,透视除法后得到 Atlas 的 sc.xyz(xy 是 UV,z 是深度)
  4. sc.xyz 落在 [0,1]³ 外,说明该像素不在这个对象的阴影柱体内,返回 1(亮)
  5. 在 Atlas 中做 3×3 PCF 深度比较,软化边缘
  6. 输出 1 - shadow × strength(越小越暗,strength 保证阴影不死黑)

多个对象怎么叠加?逐对象各画一次全屏,用 BlendOp Min 混合——重叠区自然取最暗的那个。mask 初始清成 1(Min 的单位元),每个对象只把自己覆盖到的像素压暗:

for (int i = 0; i < m_Data.Count; i++)
{
    var entry = m_Data.entries[i];
    if (entry.material == null) continue;
    m_Mpb.SetMatrix(s_WorldToShadowID, entry.worldToShadow); // 逐对象传各自的采样矩阵
    CoreUtils.DrawFullScreen(cmd, entry.material, m_Mpb, k_ShadowPassIndex);
}

最后如何把 mask 用上,有两种方式:

  • 临时合成(Pass 2,applyToScreen 打开):直接画一次全屏,用 Blend DstColor Zero 把 mask 乘进相机颜色,立刻能看到阴影,方便验证。
  • 正式做法:业务 Shader 采样这张全局 mask(_PerObjectScreenSpaceShadowmapTexture)自行使用,控制更灵活。

方案优点

  • 每个物体独立紧致投影,近景角色能获得较高阴影精度。
  • 不受 URP 主光级联阴影的距离与级联分配直接限制。
  • 多对象共用一张 Atlas,资源管理简单。
  • 产出屏幕空间 mask,可直接合成,也能方便地接入不同业务 Shader。
  • 只需要物体包围盒,不需要额外的阴影 Camera 对象。