资讯详情

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

📅 2026/10/17 18:37:23 | 华诺云谱 👁 阅读
Unity后处理投影:从投影矩阵到Shader实现全解析
1. 投影效果的核心思路与方案选型1.1 为什么要在后处理阶段做投影常规的实时投影做法是在场景里放一盏带 Cookie 贴图的聚光灯或者用 Projector 组件把一张贴图投到接收面上。这两种方式在多数项目里够用但它们有个共同的麻烦投影是真实参与光照计算的会受接收面材质、法线、光照贴图影响想单独调投影的强度、颜色、模糊程度就得反复改材质和灯光参数牵一发动全身。把投影搬到后处理阶段逻辑就完全不一样了。后处理拿到的是已经渲染完成的屏幕图像此时场景的几何、光照、阴影都已经算完我们只需要在屏幕空间里再叠一层投影。这样做的好处很直接投影的透明度、颜色、边缘柔化、叠加方式全部可以在一个 Shader 里控制和场景光照解耦调起来快也不会污染原有的光照结果。对于做风格化渲染、做剧情演出里那种从窗户投进来的光斑树影晃动角色脚下的软阴影这类需求后处理投影是非常顺手的一条路。需要提前说清楚的是后处理投影本质上是屏幕空间的一张遮罩叠加它不参与真实的光照衰减也不会被场景物体正确遮挡除非你自己在 Shader 里做深度判断。所以它适合做氛围、做装饰、做演出不适合替代真实的动态阴影系统。想清楚这个边界后面选型和实现才不会跑偏。1.2 三种常见实现路径的取舍在 Unity 里做后处理投影我实际用过、也见团队用过的主要有三条路各自的适用场景差别挺大。第一条是自定义后处理 CommandBuffer/Blit。核心是自己写一个投影 Shader通过OnRenderImage或者 URP 的ScriptableRendererFeature把投影贴图叠到相机图像上。这条路最灵活投影的位置、大小、旋转、模糊全部自己算适合需要精确控制投影形态的项目。缺点是得自己处理投影矩阵和屏幕坐标的映射数学部分要理清楚。第二条是用 Projector 组件 后处理做二次加工。Projector 负责把贴图投到几何表面后处理只做颜色校正和模糊。这条路实现快但投影仍然受接收面影响后处理能做的有限属于半后处理。第三条是全屏 Shader 里用世界坐标反投影。在后处理 Shader 里根据屏幕 UV 反算出世界坐标再判断这个点是否落在投影体积内落在里面就采样投影贴图。这条路最纯投影完全在屏幕空间生成不受任何场景材质影响但每个像素都要做一次世界坐标重建性能开销比前两条高移动端要谨慎。综合下来如果目标是投影形态可控、和场景光照解耦、性能还能接受我推荐第一条路自定义后处理投影矩阵在 C# 侧算好传给 ShaderShader 里只做采样和叠加。下面所有实操都围绕这条路展开。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 里左右眼的世界坐标反投影要用各自的相机矩阵不能共用。如果项目要支持 VRShader 里得用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 的次数。投影这个效果本身不复杂难的是把它调得像那么回事——位置、颜色、强度、边缘每一项都要反复试。我个人的经验是先把投影调到一个夸张的状态比如强度拉满、颜色纯红确认位置和形态对了再慢慢往回收比一上来就微调效率高得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑