逐物体阴影
目录+
原理
Unity 自带的 Shadow Map 要覆盖整个场景,分给场景中重要角色的分辨率就不够了,镜头拉近时锯齿感明显。级联(CSM)虽能缓解,但精度受级联距离和分配策略牵制,很难专门照顾某个角色。
本方案换个思路:给每个指定物体单独算一个紧贴其包围盒的正交阴影视锥,把多个物体的深度渲染进同一张图集(Atlas);再利用相机深度重建屏幕像素的世界坐标,把这些阴影以屏幕空间的方式投射回画面。
这样每个物体都独占一块阴影分辨率,与主光阴影解耦;产出的又是一张屏幕空间 mask,可以直接乘进画面,也能交给任意业务 Shader 使用。
整体分三步:
- 收集参与阴影的物体
- 在 Render Feature 中生成阴影图集(Atlas)
- 采样阴影,投射回屏幕

整个功能由 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(投影数) | √N | grid(向上取整) | 网格 | tileSize(2048/grid) |
|---|---|---|---|---|
| 1 | 1.0 | 1 | 1×1 | 2048 |
| 4 | 2.0 | 2 | 2×2 | 1024 |
| 5 | 2.24 | 3 | 3×3 | 682 |
| 9 | 3.0 | 3 | 3×3 | 682 |
| 10 | 3.16 | 4 | 4×4 | 512 |
| 16 | 4.0 | 4 | 4×4 | 512 |
然后把 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.x、min.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()(原函数无法外部调用)。它做两件事:
- 对
projMatrix手动做 reverse-Z(在支持反向 Z 缓冲的平台上翻转第三行),对齐 GPU 实际光栅化的深度约定; - 乘上
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-Z 的
projMatrix,而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.z(ColorMask 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):
- 采样
_CameraDepthTexture拿到该像素深度 - 用相机逆 VP 矩阵
ComputeWorldSpacePosition重建世界坐标 - 乘该对象的
worldToShadow,透视除法后得到 Atlas 的sc.xyz(xy 是 UV,z 是深度) sc.xyz落在[0,1]³外,说明该像素不在这个对象的阴影柱体内,返回 1(亮)- 在 Atlas 中做 3×3 PCF 深度比较,软化边缘
- 输出
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 对象。