ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity后处理投影:从投影矩阵到Shader实现全解析

Unity后处理投影:从投影矩阵到Shader实现全解析

1. 投影效果的核心思路与方案选型

1.1 为什么要在后处理阶段做投影

常规的实时投影做法,是在场景里放一盏带 Cookie 贴图的聚光灯,或者用 Projector 组件把一张贴图投到接收面上。这两种方式在多数项目里够用,但它们有个共同的麻烦:投影是"真实"参与光照计算的,会受接收面材质、法线、光照贴图影响,想单独调投影的强度、颜色、模糊程度,就得反复改材质和灯光参数,牵一发动全身。

把投影搬到后处理阶段,逻辑就完全不一样了。后处理拿到的是已经渲染完成的屏幕图像,此时场景的几何、光照、阴影都已经算完,我们只需要在屏幕空间里"再叠一层投影"。这样做的好处很直接:投影的透明度、颜色、边缘柔化、叠加方式全部可以在一个 Shader 里控制,和场景光照解耦,调起来快,也不会污染原有的光照结果。对于做风格化渲染、做剧情演出里那种"从窗户投进来的光斑""树影晃动""角色脚下的软阴影"这类需求,后处理投影是非常顺手的一条路。

需要提前说清楚的是,后处理投影本质上是"屏幕空间的一张遮罩叠加",它不参与真实的光照衰减,也不会被场景物体正确遮挡(除非你自己在 Shader 里做深度判断)。所以它适合做氛围、做装饰、做演出,不适合替代真实的动态阴影系统。想清楚这个边界,后面选型和实现才不会跑偏。

1.2 三种常见实现路径的取舍

在 Unity 里做后处理投影,我实际用过、也见团队用过的主要有三条路,各自的适用场景差别挺大。

第一条是自定义后处理 + CommandBuffer/Blit。核心是自己写一个投影 Shader,通过OnRenderImage或者 URP 的ScriptableRendererFeature把投影贴图叠到相机图像上。这条路最灵活,投影的位置、大小、旋转、模糊全部自己算,适合需要精确控制投影形态的项目。缺点是得自己处理投影矩阵和屏幕坐标的映射,数学部分要理清楚。

第二条是用 Projector 组件 + 后处理做二次加工。Projector 负责把贴图投到几何表面,后处理只做颜色校正和模糊。这条路实现快,但投影仍然受接收面影响,后处理能做的有限,属于"半后处理"。

第三条是全屏 Shader 里用世界坐标反投影。在后处理 Shader 里根据屏幕 UV 反算出世界坐标,再判断这个点是否落在投影体积内,落在里面就采样投影贴图。这条路最"纯",投影完全在屏幕空间生成,不受任何场景材质影响,但每个像素都要做一次世界坐标重建,性能开销比前两条高,移动端要谨慎。

综合下来,如果目标是"投影形态可控、和场景光照解耦、性能还能接受",我推荐第一条路:自定义后处理,投影矩阵在 C# 侧算好传给 Shader,Shader 里只做采样和叠加。下面所有实操都围绕这条路展开。

1.3 投影矩阵的数学直觉

很多人卡在投影这块,其实是被矩阵吓住了。用生活化的说法:投影就是"把一张幻灯片,从一个点,打到一个平面上"。这个"打"的过程,数学上就是一个投影矩阵。

假设投影源在点 P,投影方向是 D(一个归一化的向量),投影贴图的张角是 FOV。对于屏幕上的任意一个像素,我们想知道它对应的世界位置 W 是否落在投影锥体内。做法是:从 P 出发,向 W 连一条线,看这条线和投影方向 D 的夹角是否小于 FOV/2。如果是,就说明这个点被投影覆盖,再根据夹角和方位算出贴图 UV,采样即可。

实际写的时候,我们不会真的对每个像素做这套判断,而是反过来:在 C# 里构造一个投影矩阵,把世界空间的一个"投影盒"变换到屏幕空间,Shader 里直接用屏幕 UV 去采样这个变换后的结果。这样每个像素只需要一次矩阵乘法和一次贴图采样,效率高很多。矩阵的具体构造在下一节展开。

2. 核心细节解析与实操要点

2.1 投影贴图的准备与导入设置

投影贴图是整个效果的"内容源",它的质量直接决定最终观感。我一般用一张带 Alpha 通道的 PNG,白色区域是投影亮的部分,黑色或透明区域是不投影的部分。如果是彩色投影(比如彩色玻璃投下来的光斑),就用 RGB 通道存颜色,Alpha 存强度。

导入设置有几个坑必须注意。第一,Wrap Mode 要设成 Clamp,不能是 Repeat,否则投影边缘会出现重复的接缝。第二,Filter Mode 用 Bilinear,投影通常需要柔和的边缘,Point 会出锯齿。第三,如果贴图是作为 Cookie 使用,Alpha Source 要选 From Input,并且勾选 Alpha Is Transparency,否则透明区域会被当成黑色处理。第四,关闭 sRGB 的自动转换要视情况而定:如果投影是纯亮度遮罩,保持 sRGB 即可;如果投影带颜色且你希望颜色精确,考虑用 Linear 并在 Shader 里手动处理。

提示:投影贴图不要做得太大,1024x1024 对绝大多数投影场景足够。贴图越大,采样时的缓存压力越大,移动端尤其明显。

2.2 投影体积的定义:位置、朝向、张角

投影体积决定了"投影投到哪里、投多大"。我习惯用一个结构体来定义它:

[System.Serializable] public struct ProjectionVolume { public Vector3 position; // 投影源位置 public Vector3 direction; // 投影朝向(归一化) public float fov; // 张角(度) public float aspect; // 宽高比 public float nearClip; // 近裁剪 public float farClip; // 远裁剪 public Color color; // 投影颜色 public float intensity; // 投影强度 }

这里每个参数都有讲究。position是投影的"灯泡"位置,不是投影落点的位置。direction决定投影往哪打,通常用transform.forward。fov是投影锥体的张角,和相机 FOV 概念一样,越大投影越"散"。aspect用来处理非正方形投影,比如宽银幕比例的光斑。nearClip和farClip限制投影的有效距离,超出范围的像素不参与投影,避免投影"穿透"到不该出现的地方。

intensity和color我单独拎出来,是因为它们经常需要在运行时动态调整——比如剧情里投影突然变亮,或者角色走进投影区域时投影颜色偏移。把它们放在结构体里,配合一个MaterialPropertyBlock或者直接改 Material 属性,就能实时生效。

2.3 投影矩阵的构造过程

投影矩阵的构造是整条链路里最需要动脑的部分。我把它拆成三步。

第一步,构造一个"视图矩阵",把世界空间变换到以投影源为原点、以投影方向为 -Z 轴的坐标系。这其实就是Matrix4x4.LookAt的逆:

Matrix4x4 view = Matrix4x4.LookAt( volume.position, volume.position + volume.direction, Vector3.up ).inverse;

第二步,构造投影矩阵。这里用Matrix4x4.Perspective,注意 Unity 的 Perspective 生成的是 OpenGL 风格的投影矩阵(NDC 的 Z 范围是 -1 到 1),而不同图形 API 的裁剪空间 Z 范围不一样,所以后面在 Shader 里采样时要统一处理。

Matrix4x4 proj = Matrix4x4.Perspective( volume.fov, volume.aspect, volume.nearClip, volume.farClip );

第三步,把两个矩阵乘起来,得到"世界空间到投影裁剪空间"的变换矩阵:

Matrix4x4 projectionMatrix = proj * view;

这个矩阵传给 Shader 后,Shader 里对每个像素的世界坐标做一次mul(projectionMatrix, float4(worldPos, 1.0)),得到裁剪空间坐标,再除以 w 得到 NDC,最后映射到 0-1 的 UV 去采样投影贴图。整个过程和相机投影的原理完全一致,只是把"相机"换成了"投影源"。

2.4 屏幕空间反投影世界坐标

后处理 Shader 拿到的是屏幕 UV,要判断这个像素是否在投影体积内,就得先把它还原成世界坐标。这一步用相机的逆投影矩阵:

float4 clipPos = float4(uv * 2.0 - 1.0, depth, 1.0); float4 worldPos = mul(unity_CameraInvProjection, clipPos); worldPos /= worldPos.w; worldPos = mul(unity_CameraToWorld, worldPos);

这里depth是从深度图采样的值。如果项目里没有开深度图,可以用SAMPLE_DEPTH_TEXTURE从_CameraDepthTexture拿。注意 URP 和内置管线的深度图获取方式不同,URP 里要在 Renderer Feature 里配置ConfigureInput(ScriptableRenderPassInput.Depth),否则深度图是空的,反投影出来的世界坐标全是错的。

注意:反投影世界坐标这一步是性能大头。如果投影只需要覆盖屏幕的一小块区域,可以在 C# 侧先算出一个屏幕空间的包围盒,Shader 里只对包围盒内的像素做反投影,能省不少算力。

3. 完整实操流程与关键环节实现

3.1 搭建后处理框架

先搭一个最小的后处理框架。内置管线用OnRenderImage最省事:

[ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class ProjectionPostProcess : MonoBehaviour { public Shader projectionShader; public ProjectionVolume volume; public Texture2D projectionTexture; private Material _mat; Material Mat { get { if (_mat == null && projectionShader != null) _mat = new Material(projectionShader) { hideFlags = HideFlags.HideAndDontSave }; return _mat; } } void OnRenderImage(RenderTexture src, RenderTexture dst) { if (Mat == null) { Graphics.Blit(src, dst); return; } Matrix4x4 proj = BuildProjectionMatrix(volume); Mat.SetMatrix("_ProjectionMatrix", proj); Mat.SetTexture("_ProjectionTex", projectionTexture); Mat.SetColor("_ProjectionColor", volume.color); Mat.SetFloat("_ProjectionIntensity", volume.intensity); Graphics.Blit(src, dst, Mat); } }

URP 的话,把这段逻辑搬到一个ScriptableRendererFeature里,在Execute阶段用Blitter.BlitCameraTexture做同样的操作。框架本身不复杂,关键是矩阵和贴图要每帧更新。

3.2 投影 Shader 的编写

Shader 部分我拆成"反投影世界坐标"和"采样投影贴图"两段。完整的关键片段如下:

sampler2D _MainTex; sampler2D _CameraDepthTexture; sampler2D _ProjectionTex; float4x4 _ProjectionMatrix; float4 _ProjectionColor; float _ProjectionIntensity; v2f vert(appdata v) { ... } fixed4 frag(v2f i) : SV_Target { fixed4 sceneColor = tex2D(_MainTex, i.uv); // 采样深度并反投影世界坐标 float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); float4 clipPos = float4(i.uv * 2.0 - 1.0, depth, 1.0); float4 worldPos = mul(unity_CameraInvProjection, clipPos); worldPos /= worldPos.w; worldPos = mul(unity_CameraToWorld, worldPos); // 变换到投影空间 float4 projPos = mul(_ProjectionMatrix, float4(worldPos.xyz, 1.0)); // 判断是否在投影锥体内 if (projPos.w <= 0) return sceneColor; float2 projUV = projPos.xy / projPos.w * 0.5 + 0.5; // 超出 0-1 范围说明不在投影内 if (projUV.x < 0 || projUV.x > 1 || projUV.y < 0 || projUV.y > 1) return sceneColor; // 采样投影贴图 fixed4 projColor = tex2D(_ProjectionTex, projUV); // 边缘柔化:用 UV 到边界的距离做衰减 float2 edge = min(projUV, 1.0 - projUV); float edgeFade = saturate(min(edge.x, edge.y) * 20.0); // 叠加 fixed3 finalColor = sceneColor.rgb + projColor.rgb * _ProjectionColor.rgb * _ProjectionIntensity * projColor.a * edgeFade; return fixed4(finalColor, sceneColor.a); }

这段代码里有几个细节值得展开。projPos.w <= 0的判断是为了排除投影源背后的像素,否则会出现"投影穿透到背后"的诡异现象。edgeFade用 UV 到边界的距离做线性衰减,能让投影边缘自然淡出,避免硬边。如果你想要更柔和的边缘,可以把edgeFade换成smoothstep,或者对投影贴图本身做一次高斯模糊。

3.3 投影的旋转与动态跟随

静态投影好做,动态投影才是实际项目里的常态。比如投影要跟着一个角色走,或者投影要随时间旋转。做法很简单:每帧更新volume.position和volume.direction,然后重新构造矩阵。

void Update() { if (followTarget != null) { volume.position = followTarget.position + followOffset; volume.direction = followTarget.forward; } if (rotateSpeed != 0) { volume.direction = Quaternion.Euler(0, rotateSpeed * Time.time, 0) * volume.direction; } }

这里有个性能上的小技巧:如果投影参数没变,就不要每帧重建矩阵和重设 Material 属性。用一个脏标记(dirty flag)来判断,能省下不少 CPU 开销。我实测过一个场景,投影参数每帧都重设的话,CPU 侧大概多花 0.3ms,虽然不多,但在移动端积少成多。

3.4 多投影叠加的处理

一个场景里往往不止一个投影。比如一个房间里有三扇窗户,每扇都投一个光斑。这时候有两种做法:一是把多个投影合并到一次 Blit 里,Shader 里循环处理;二是每个投影单独 Blit 一次。

第一种做法性能好,但 Shader 里要写循环,投影数量固定的话可以用宏展开。第二种做法灵活,投影数量可以动态增减,但多次 Blit 的带宽开销大。我的经验是:投影数量少于 4 个时,用多次 Blit 更省心;超过 4 个,考虑合并到一次 Blit,用一个StructuredBuffer或者数组传投影参数。

#define MAX_PROJECTIONS 8 float4x4 _ProjectionMatrices[MAX_PROJECTIONS]; float4 _ProjectionColors[MAX_PROJECTIONS]; int _ProjectionCount; // 在 frag 里循环 for (int i = 0; i < _ProjectionCount; i++) { // 对每个投影做一次采样和叠加 }

提示:数组传矩阵在部分移动 GPU 上效率不高,如果目标平台是移动端,建议把投影数量控制在 4 个以内,或者改用纹理存投影参数。

4. 常见问题与排查技巧实录

4.1 投影位置偏移或翻转

这是最常见的问题,八成是矩阵构造时坐标系没对齐。Unity 的Matrix4x4.LookAt生成的是右手坐标系下的视图矩阵,而Matrix4x4.Perspective生成的是左手系投影矩阵,两者直接相乘会出现翻转。解决办法是在构造视图矩阵后,对 Z 轴做一次翻转:

Matrix4x4 view = Matrix4x4.LookAt(...).inverse; view = Matrix4x4.Scale(new Vector3(1, 1, -1)) * view;

另一个常见原因是投影贴图的 UV 原点和屏幕 UV 原点不一致。屏幕 UV 原点在左下角,而很多贴图工具导出的贴图原点在左上角,采样时 Y 要翻转:projUV.y = 1.0 - projUV.y;。这个坑我踩过不止一次,排查时先检查这两处,基本能解决 90% 的偏移问题。

4.2 投影边缘出现硬切或锯齿

硬切通常来自两个地方。一是edgeFade的衰减范围太窄,把* 20.0改成* 8.0或者用smoothstep(0, 0.1, min(edge.x, edge.y))能让边缘更柔和。二是投影贴图本身的边缘就是硬的,这时候要么换贴图,要么在 Shader 里对投影贴图做一次额外的模糊采样。

锯齿则多半是深度图精度不够导致的。反投影世界坐标时,深度值的微小误差会被放大成世界坐标的抖动,投影边缘就会出现锯齿。解决办法是提高深度图精度,或者在 Shader 里对深度做一次平滑。URP 里可以在 Renderer Feature 里把深度图格式设成DepthBits.Depth24,比默认的 Depth16 好很多。

4.3 投影穿透物体

后处理投影不参与深度测试,所以默认情况下投影会"穿透"所有物体,包括投影源和接收面之间的遮挡物。要解决这个问题,得在 Shader 里手动做深度比较:把当前像素的世界坐标和投影源到该像素的距离做对比,如果中间有遮挡物,就跳过投影。

// 从投影源向当前像素发射射线,检查是否有遮挡 float distToPixel = distance(volume.position, worldPos); float shadowDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); // 把 shadowDepth 转成世界距离,和 distToPixel 比较

这套逻辑其实就是软阴影的简化版,做起来不难,但每个像素多一次深度采样和比较,性能会下降。如果项目里投影穿透不是大问题,可以不做,靠美术摆放投影位置来规避。

4.4 常见问题速查表

问题现象可能原因排查方向
投影完全不可见矩阵未传入或贴图为空检查 Material 属性是否设置,贴图是否赋值
投影位置偏移坐标系未对齐检查视图矩阵 Z 翻转和 UV Y 翻转
投影边缘硬切衰减范围太窄调整 edgeFade 系数或改用 smoothstep
投影边缘锯齿深度图精度不足提高深度图格式,或对深度做平滑
投影穿透物体未做深度比较在 Shader 里加深度比较逻辑
移动端掉帧严重反投影开销大限制投影屏幕包围盒,减少 Blit 次数
投影颜色偏暗sRGB/Linear 空间不匹配检查贴图导入设置和 Shader 里的颜色空间转换
多投影互相覆盖叠加顺序问题调整 Blit 顺序或改用加法混合

4.5 几个我踩过的坑

第一个坑是在 Editor 里预览正常,打包后投影消失。原因是 Shader 变体被剥离了,_CameraDepthTexture相关的变体没被包含进去。解决办法是在 Project Settings 的 Graphics 里把相关 Shader 加入 Always Included Shaders,或者在 Shader 里加#pragma multi_compile强制包含变体。

第二个坑是投影在场景切换后错位。原因是投影矩阵缓存了上一帧的值,场景切换时相机参数变了但矩阵没更新。解决办法是在OnEnable和场景加载回调里强制重建矩阵。

第三个坑是投影在 VR 下双眼不一致。VR 里左右眼的世界坐标反投影要用各自的相机矩阵,不能共用。如果项目要支持 VR,Shader 里得用unity_StereoEyeIndex区分左右眼,分别传入投影矩阵。

5. 性能优化与扩展玩法

5.1 性能优化的几个实用手段

后处理投影的性能瓶颈主要在反投影世界坐标和多次 Blit 上。我常用的优化手段有这么几个。

限制处理区域。在 C# 侧算出投影在屏幕上的包围盒,Shader 里只对包围盒内的像素做反投影。包围盒外的像素直接返回原图。这个优化在投影只占屏幕一小块时效果显著,我实测过一个投影占屏幕 1/6 的场景,优化后 GPU 时间从 1.8ms 降到 0.6ms。

降低反投影频率。如果投影变化不快,可以每两帧更新一次投影矩阵,中间帧复用上一帧的结果。这个做法在投影缓慢移动时几乎看不出差别,但能省一半的 CPU 开销。

用半分辨率做投影。把投影渲染到一张半分辨率的 RenderTexture 上,再上采样叠加到全分辨率图像。投影本身是柔和的,半分辨率的损失肉眼几乎看不出来,但带宽和算力能省一大半。

合并 Blit。多个投影尽量合并到一次 Blit 里,减少 RenderTexture 的切换次数。每次 Blit 都涉及一次全屏的读写,切换次数多了带宽压力很大。

5.2 投影的扩展玩法

投影做熟之后,可以玩出不少花样。比如投影动画:让投影贴图的 UV 随时间偏移,做出"树影晃动"的效果。再比如投影遮罩:用一张额外的遮罩图控制投影哪些区域可见,做出"投影被云挡住"的效果。

还可以把投影和屏幕空间反射结合,让投影在光滑地面上产生倒影。做法是在反射 Pass 里也采样一次投影贴图,把投影叠加到反射结果上。这个玩法在雨天场景里特别出效果。

另一个方向是投影与粒子结合。把投影贴图换成一张噪声图,配合粒子的生命周期做淡入淡出,能做出"魔法阵""能量场"这类效果。我见过一个项目用这套做法做角色的技能指示器,投影随技能蓄力逐渐变亮,视觉反馈非常直观。

5.3 不同渲染管线的适配要点

内置管线、URP、HDRP 在后处理这块的写法差别不小。内置管线用OnRenderImage,最直接,但和内置的其它后处理(如 Bloom)叠加时要注意执行顺序。URP 用ScriptableRendererFeature,需要在AddRenderPasses里注册,并且要正确配置ScriptableRenderPassInput.Depth才能拿到深度图。HDRP 用CustomPass,写法和前两者又不一样,而且 HDRP 的深度图获取方式更复杂,要用HDCamera的深度缓冲。

我的建议是:新项目直接上 URP,生态成熟,文档全,遇到问题好查。老项目如果已经在内置管线上,没必要为了一个投影效果迁移管线,用OnRenderImage就够了。HDRP 的话,除非项目本来就是 HDRP,否则不建议为了投影单独上 HDRP,学习成本太高。

5.4 一个完整的参数配置示例

最后给一个我实际项目里用过的参数配置,可以直接抄:

volume.position = new Vector3(0, 5, 0); volume.direction = new Vector3(0, -1, 0.3f).normalized; volume.fov = 45f; volume.aspect = 1.5f; volume.nearClip = 0.5f; volume.farClip = 20f; volume.color = new Color(1f, 0.95f, 0.8f); volume.intensity = 1.2f;

这套参数做出来的是一个从高处斜向下投的暖色光斑,张角 45 度,覆盖范围大概 10 米见方,适合做室内窗光或者舞台顶光。实际用的时候根据场景尺度等比缩放就行,关键是fov和farClip要匹配,farClip太小投影会被截断,太大则投影会投到很远的物体上,失去层次感。

投影贴图我用的是 1024x1024 的径向渐变图,中心亮边缘暗,配合edgeFade的衰减,边缘过渡非常自然。如果要做硬边投影(比如百叶窗的光斑),就把贴图换成条纹图,同时把edgeFade的系数调大,让边缘更锐利。

这套方案我在几个不同规模的项目里都用过,从 PC 到移动端都跑得动,核心就是控制好反投影的开销和 Blit 的次数。投影这个效果本身不复杂,难的是把它调得"像那么回事"——位置、颜色、强度、边缘,每一项都要反复试。我个人的经验是,先把投影调到一个夸张的状态(比如强度拉满、颜色纯红),确认位置和形态对了,再慢慢往回收,比一上来就微调效率高得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进