Unity实时摄像机渲染图像处理:后处理框架与Shader实战
做Unity客户端的这些年我几乎每个项目都会碰到“把摄像机画面处理一下”的需求。要么是要给屏幕叠加一层风格化滤镜要么是做一个故障、扫描线的演出效果要么是在UI底层实现毛玻璃再要么干脆需要把画面反向渲染到模型表面。刚开始我用的是最笨的办法——直接改相机渲染出来的RawImage但接踵而至的是性能问题、平台差异、还有各种诡异的黑屏。后来我终于把“Unity实时摄像机渲染图像处理”这条路彻底走通从Built-in管线到URP从OnRenderImage到Render Feature从简单的颜色调整到边缘检测和高斯模糊踩过的坑足够写成一篇文章了。这篇文章就把我的完整思路、代码框架和排查方法一次性讲清楚适合Unity开发者、技术美术以及任何想在画面呈现层面做文章的朋友参考。1. 项目概述与需求拆解1.1 实时摄像机渲染图像处理到底解决什么问题在Unity里摄像机就是玩家的眼睛。它的输出可以是屏幕、RenderTexture也可以是UI层需要使用的背景。所谓“实时摄像机渲染图像处理”本质上是把你拿到手的像素数据在渲染流程的某个节点上插入一次“再加工”。这不只是一个滤镜系统它能做的事情比大多数开发者想象中大得多风格化画面老电影颗粒、黑白、复古色、渐变映射视觉增强锐化、对比度拉伸、降噪、HDR压缩演出特效扫描线、故障毛刺、局部模糊、区域缩放UI交互毛玻璃背景、高斯模糊底图、动态模糊特殊渲染屏幕空间反射、景深、边缘光、卡通描边调试工具伪彩色显示深度、可视化法线、显示AO或阴影遮罩。这类处理的关键在于它不能离线做必须跟着摄像机实时走。每一帧都面对新的画面、新的光照、新的摄像机姿态处理逻辑必须高效、稳定、可复用。这也是许多新手项目从“改贴图”过渡到“改摄像机输出”的真正分水岭。1.2 项目的核心目标与适用场景我这次要讲的是一个典型的“通用后处理框架”项目。核心目标有四个拿到摄像机渲染的完整彩色画面插入多道可叠加的图像处理Pass每道Pass都用独立的Shader控制效果最终把处理结果输出到屏幕或指定RenderTexture。适用场景非常广。独立游戏想给整个世界加一层风格滤镜商业项目想在手机端做出毛玻璃UI和故障效果互动装置想把屏幕图像做颜色识别后再叠加视觉反馈都可以套用这套框架。其实你只需要理解一条主链路摄像机渲染 - 截获画面 - 写入RenderTexture - 像素着色 - 输出目标。后面所有花哨的效果都是在这条链路的不同环节做文章。2. 方案选型与技术原理2.1 为什么不能只改Shader或者贴图刚开始接触这个需求的人经常会问一句话“既然要做滤镜为什么不能直接改相机上所有物体的材质”这个问题背后藏着对Unity渲染流程的一个常见误解。场景里的每个物体有自己的材质你修改它们的Shader确实能影响局部画面。但屏幕级别的处理比如扭曲、全屏模糊、全局色调映射、扫描线无法通过修改单个材质完成。原因很简单这类效果需要完整的帧信息而单个物体着色时根本不知道旁边物体是什么颜色更不知道整张画面每个像素的亮度分布。举个例子你想给屏幕中间区域做高斯模糊Shader在某个物体表面根本无法判断某个像素是否处于屏幕中间因为每个物体都是独立绘制的。只有等整帧画面全部画完之后把这张完整的图片重新作为输入启动一个新的渲染Pass才能对一个像素及其周围像素做统一运算。所以实时摄像机图像处理的核心套路是把摄像机渲染结果存到一张RenderTexture里然后创建一个全屏四边形Quad把它覆盖到整个视口重新采样这张RenderTexture在采样过程中完成像素级的数学运算。2.2 三条主路线的对比OnRenderImage、CommandBuffer、URP Render Feature具体到Unity的实现有三条主流路线它们的适用管线完全不同选错就会踩坑。方案适用管线复杂度灵活度备注OnRenderImageBuilt-in最低中只要挂脚本写Graphics.Blit即可适合原型和内部工具CommandBufferBuilt-in / 部分管线中高插入到摄像机渲染前后的CommandBuffer中适合要做Engine层面的定制Render FeatureURP中高高官方推荐方式可以做成可复用组件控制渲染顺序如果你的项目还停留在Built-in管线OnRenderImage是上手最快、代码量最少的方案。这个MonoBehaviour有一个默认回调函数在所有不透明和透明物体绘制完成后被Unity自动调用。你只需要在函数里做一次Graphics.Blit操作把源贴图处理成目标贴图即可。而如果你用的是URP事情会不一样。URP默认是单Pass前向渲染OnRenderImage这个函数在默认情况下不会有效执行。因为URP把渲染流程切分成了很多个Render Pass必须以Scriptable Render Pass的方式插入到渲染管线的特定位置。这也是很多从Built-in迁移到URP的团队突然发现“后处理不生效”的根本原因不是代码写错了而是你的处理代码挂错了位置。2.3 核心概念RenderTexture、Graphics.Blit、UV坐标理解这套处理流程有几个绕不开的概念支撑建议先记住再动手。RenderTexture是一张“存在于显存中的纹理”它不止能存颜色还能存深度和模板信息。摄像机可以把渲染结果直接写入RenderTexture这张贴图既不能被普通Sprite直接显示也不能被当作UI的Image直接使用你要么用专用材质把它绘制到屏幕上要么把它再Blit到另一张RenderTexture上继续处理。Graphics.Blit是Unity内置的纹理拷贝与处理命令。它的用法是Blit(sourceTexture, destTexture, material, pass)。这个命令会把sourceTexture以全屏四边形的方式渲染也可以理解为它把一个纹理“画”到另一个纹理上但用的是指定Shader的片段着色器。如果你不改材质它就是普通拷贝如果你挂一个带Pass的材质那就是后处理Pass的开端。UV坐标在这里扮演了“像素定位器”的角色。每个全屏像素在Shader里都有一个0到1的uv值左上角是(0,0)右下角是(1,1)。你要做颜色调整就直接采样uv位置的颜色你要做模糊就采样uv周围若干偏移量的颜色。理解uv后处理Shader就等于看懂了一半。3. 实操过程从零搭建图像处理通道3.1 环境准备与工程创建我先说环境。我自己常用的是Unity 2021或2022版本都支持Built-in和URP。为了让你一次看懂整套逻辑我先用Built-in管线的OnRenderImage方式搭建一个最简框架然后在后面专门讲URP版本的迁移方法。创建一个新3D工程然后做三步准备在场景里放一个Cube或胶囊体并确保摄像机能看到它创建一段简单的旋转动画方便后面观察画面变化新建一个Shader类型选择Unlit因为后处理不需要考虑光照。在Unity里后处理Shader和普通Shader的区别在于它不关心物体法线、切线、顶点颜色它只需要一个全屏四边形的顶点数据。因此Vertex Shader往往只有一行代码——把裁剪空间的顶点位置直接透传。Fragment Shader则完全聚焦在uv采样与像素计算上。3.2 编写第一个屏幕后期处理脚本直接在工程里新建一个C#脚本命名为PostProcessor.cs挂到主摄像机上。代码框架就固定在下面这个骨架里using UnityEngine; using System.Collections; public class PostProcessor : MonoBehaviour { public Material postMat; void OnRenderImage(RenderTexture source, RenderTexture destination) { if (postMat ! null) { Graphics.Blit(source, destination, postMat); } else { Graphics.Blit(source, destination); } } }这段代码的意思非常直白Unity在完成本帧的所有渲染后把摄像机输出的画面存进source然后调用你传入的材质postMat对画面再处理一遍再把结果交给destination最终呈现在屏幕上。如果材质为空就原样拷贝。你可能会问为什么我要建议挂一个空判断因为开发阶段经常会有“材质没拖进Inspector”的情况直接空引用会让人误以为是渲染流程出了问题加上一个空判断至少能保证画面还在方便排查。3.3 用Shader实现第一版颜色滤镜后处理Shader的模板结构极为关键后面扩展所有效果都基于这个模板。我直接把最常用的“亮度、对比度、饱和度”调整Shader写出来它也是我测试任何后处理脚手架是否正常时的“Hello World”。Shader Custom/ColorAdjust { Properties { _MainTex (Texture, 2D) white {} _Brightness (Brightness, Range(0, 2)) 1.0 _Contrast (Contrast, Range(0, 2)) 1.0 _Saturation (Saturation, Range(0, 2)) 1.0 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float _Brightness; float _Contrast; float _Saturation; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 亮度 col.rgb * _Brightness; // 对比度 col.rgb (col.rgb - 0.5) * _Contrast 0.5; // 饱和度用亮度值混合原色 float luminance dot(col.rgb, fixed3(0.299, 0.587, 0.114)); col.rgb lerp(luminance, col.rgb, _Saturation); return col; } ENDCG } } }这里有几个细节必须解释清楚。首先Cull Off是为了关闭背面剔除后处理全屏Quad无论摄像机怎么翻转都必须可见。ZWrite Off是避免这个全屏Quad写入深度缓冲破坏后面其他渲染。ZTest Always意味着不管深度缓存里的值是多少这个Quad永远绘制这是为了确保后处理覆盖整个画面不会因为深度测试被丢弃。颜色的亮度权重为何要选(0.299, 0.587, 0.114)这是Rec.709标准的亮度权重更符合人眼对绿色敏感、对蓝色迟钝的生理特征。如果你想要平均权重(0.333, 0.333, 0.333)也可以只是饱和度感会不够“自然”。把材质挂到之前PostProcessor脚本的postMat槽位上运行场景你就可以拖动_Brightness、_Contrast、_Saturation三个参数实时看到画面变化。3.4 多Pass效果如何按顺序叠加滤镜单个后处理效果通常不够用真实项目里可能需要先做模糊再做颜色映射最后叠加扫描线。你当然可以建三个材质、三个脚本Unity也会在每次OnRenderImage之间自动串联。但我更推荐的做法是把多段逻辑放进同一个材质的不同Pass里然后用Graphics.Blit手动控制执行顺序。举个例子做一个“先模糊再锐化”的流程。Shader里第一个Pass是模糊第二个Pass是锐化。C#脚本里这么写void OnRenderImage(RenderTexture source, RenderTexture destination) { RenderTexture tmp1 RenderTexture.GetTemporary(source.width / 2, source.height / 2, 0); RenderTexture tmp2 RenderTexture.GetTemporary(source.width, source.height, 0); // 第一段把画面缩小并降采样配合模糊Pass Graphics.Blit(source, tmp1, blurMat, 0); // 第二段把模糊结果放大回全尺寸配合锐化Pass Graphics.Blit(tmp1, tmp2, sharpenMat, 1); // 最终拷贝 Graphics.Blit(tmp2, destination); RenderTexture.ReleaseTemporary(tmp1); RenderTexture.ReleaseTemporary(tmp2); }这是一个非常重要的优化思路。很多开发者一上来就在全分辨率上做模糊性能很容易崩掉。但使用降采样先缩小到一半甚至四分之一分辨率模糊后再放大回去画面观感几乎不变性能却能提升数倍。在我的项目里移动端做全屏高斯模糊几乎永远遵循这个习惯先按1/4分辨率降采样做两三次小半径模糊再按双线性过滤放大。实测下来骁龙系列中端芯片也能轻松跑到60帧。3.5 高斯模糊的核心实现与权重计算高斯模糊是后处理里最常用的效果很多新手把它理解成“把一张图均匀涂糊”这个理解不够准确。真正的模糊必须具有“近邻影响大、远离影响小”的特性也就是高斯分布。二维高斯分布可以拆成两个一维Pass先在水平方向做一次模糊再在垂直方向做一次模糊。这样比直接在一个Pass里采样周围所有点要快得多。对于一个半径5像素的模糊二维需要采样121次纹理而拆成两个一维Pass只需要采样11次性能差距接近一个数量级。权重计算直接套高斯公式W(x) (1 / sqrt(2 * PI * sigma^2)) * exp(-(x^2) / (2 * sigma^2))实际开发中不需要每次都算指数我习惯把权重预计算成C#数组传入Shader或者直接在Shader里用常量数组定义。以3x3核为例归一化后的权重大致是中间0.25上下左右各0.15对角四角各0.075。这里的关键是所有权重之和必须等于1否则图像会整体变亮或变暗。下面是一个5-tap横向高斯模糊代码片段配合一个类似的纵向Pass即可完成一个完整的模糊效果fixed4 fragH (v2f i) : SV_Target { fixed4 col fixed4(0, 0, 0, 0); float weights[5] {0.0545, 0.2442, 0.4026, 0.2442, 0.0545}; float offsets[5] {-2.0, -1.0, 0.0, 1.0, 2.0}; for (int index 0; index 5; index) { col tex2D(_MainTex, i.uv float2(offsets[index] * _MainTex_TexelSize.x, 0)) * weights[index]; } return col; }注意_MainTex_TexelSize是Unity自动注入的变量它代表纹理单个像素的宽高用来把像素偏移换算成uv偏移。如果不乘它你的采样坐标会直接偏离几个像素纹理的范围导致模糊结果完全是乱的。4. 进阶画面分析与特效叠加4.1 边缘检测与轮廓描边边缘检测是实时图像处理里最有意思的一块。它可以从普通画面里提取出轮廓信息既可以用作卡通渲染的描边也可以用来做检查工具判断画面中是否有明显边界。最经典的边缘检测算子是Sobel算子它通过计算相邻像素的亮度梯度来判断边界。如果某个像素与周边像素的亮度差异很大就认为它是边缘。实现时通常先把彩色图转为灰度图然后对九个相邻像素做加权差法运算。横向梯度Gx和纵向梯度Gy定义如下Gx (左上 2左 左下) – (右上 2右 右下) Gy (左下 2下 右下) – (左上 2上 右上)最终边缘强度 sqrt(Gx^2 Gy^2)。如果强度大于某个阈值就判定为边缘填充为黑色或者你指定的描边颜色。在Shader里写这段逻辑时只需在Fragment中采样周围八个点float l tex2D(_MainTex, i.uv float2(-_MainTex_TexelSize.x, 0)).r; float r tex2D(_MainTex, i.uv float2(_MainTex_TexelSize.x, 0)).r; float t tex2D(_MainTex, i.uv float2(0, _MainTex_TexelSize.y)).r; float b tex2D(_MainTex, i.uv float2(0, -_MainTex_TexelSize.y)).r; float lt tex2D(_MainTex, i.uv float2(-_MainTex_TexelSize.x, _MainTex_TexelSize.y)).r; // 略去rt、lb、rb float gx (lt 2*l lb) - (rt 2*r rb); float gy (lb 2*b rb) - (lt 2*t rt); float edge sqrt(gx * gx gy * gy);实际调试时最需要调整的是阈值。阈值太低整张画面全是“边缘”噪声严重阈值太高轮廓断断续续。我习惯把阈值做成Material属性用滑条实时调节直到画面呈现出“刚刚好”的效果。4.2 扫描线、故障与马赛克效果这类演出效果看起来炫酷实际上核心逻辑并不复杂。它们都有一个共同点根据uv坐标的数学规律对像素进行破坏或重组。扫描线效果就是每隔若干像素高度压暗一层颜色。伪代码只有一行float scanline sin(i.uv.y * _ScreenParams.y * _Density); col.rgb * (1 - _Strength * max(0, scanline));这里_ScreenParams.y是屏幕像素高度乘以密度后sin的周期就能以像素为单位控制。仔细理解会发现sin产生的正负值会让扫描线忽明忽暗而max(0, ...)可以只让暗部生效画面就不会过度闪烁。故障效果可以用噪波纹理或随机函数生成一块块的偏移区域。比较有张力的做法是先用一个随时间变化的噪声值确定故障块的位置然后把uv做一个水平位移再偏移采样画面。为了让画面看起来像真的错误还应加入RGB通道分离把红色通道和蓝色通道的采样坐标分别偏移几个像素。马赛克效果本质上是把uv坐标网格化。对uv乘以一个大数再floor取整再除以大数等价于一块块区域只采样同一个位置画面自然产生像素块感。这个做法在像素风游戏中非常常见。4.3 摄像机画面的局部放大与景深模拟还有一个我刚入行时觉得非常高级的效果——屏幕局部放大。比如射击游戏里狙击镜开镜时画面中央会拉近放大。很多人以为要改摄像机FOV其实用后处理就能实现而且只影响画面的一部分不影响整个场景的透视关系。核心思想很简单把uv坐标以画面中心点为基准做一个缩放偏移。以中心点(0.5, 0.5)为例缩放因子为s时新的采样uv为uv_center (0.5, 0.5) uv_scaled (uv - uv_center) * s uv_center当s大于1时相当于把画面局部拉大因为周边信息被移出可视区域并被裁掉。如果要让放大区域与周围平滑衔接还可以在边缘做一个渐变遮罩让放大范围只作用于中心圆形区域内。这个思路也可以用到“屏幕空间折射”模拟。让uv根据场景深度或者法线贴图做微小扰动再采样颜色效果类似玻璃折射虽然不够物理正确但在移动端上性能表现远优于真实的屏幕空间反射。5. 常见问题与排查技巧实录5.1 后处理不生效或全屏黑漆漆这是我被问过最多的问题出现频率几乎占了一半。先说结论后处理不生效九成是Shader或材质配置问题不是脚本逻辑问题。排查顺序我建议这样确认摄像机组件上真的挂了脚本且没有报错确认材质Shaderr没有编译错误打开Inspector底部检查“Shader error”字样确认Shader里ZTest Always没有被改成默认的LEqual否则在全屏Quad位置深度可能被场景物体遮挡确认C#脚本里传的Material使用的是引脚号正确的Pass如果画面黑屏但界面还在重点检查Shader里是否用了_MainTex作为贴图名以及材质是否在属性面板赋值了贴图。我遇到过最隐蔽的一次是Shader里写了Cull Back。全屏Quad从背面看时直接被剔除结果画面全黑但只要把摄像机旋转一下画面又恢复。平时根本不会想到是剔除方向的问题。从此以后所有后处理Shader我都强制写Cull Off ZWrite Off ZTest Always这条三件套已经成了我的肌肉记忆。5.2 在URP管线中OnRenderImage不执行URP项目最大的坑就是把Built-in工程直接升级后发现所有OnRenderImage都不执行了。原因我在前面已经提到URP通过Scriptable Render Pass来控制每一帧的渲染执行序列MonoBehaviour的OnRenderImage在整个URP渲染流程里没有对应的事件入口。解决办法是改用Custom Renderer Feature。在URP Asset里找到Renderer Data添加一个Renderer Feature然后在C#脚本中继承ScriptableRendererFeature和ScriptableRenderPass并在Execute中调用Blit方法或CommandBuffer来完成图像处理。这里有个更简洁的路径UBR的官方包提供了一个“Full Screen Pass Renderer Feature”版本较新的URP直接支持你在材质上挂一个后处理Shader通过全屏pass渲染。你只需要做一个材质配置好Feature甚至不用写C#脚本。缺点是它只支持单Pass如果要做多Pass叠加还是需要自定义Feature。我建议团队从Built-in迁移到URP时不要尝试把旧后处理脚本一个个移植而是花一天时间把通用后处理接口封装成一套Render Feature未来所有效果都挂到同一个Feature列表上后续管理成本会低很多。5.3 性能开销过大与带宽消耗后处理是在全屏像素上做运算的所以最直接的性能瓶颈就是每个像素的工作量。屏幕分辨率越高填充率压力越大。尤其在移动端GPU的填充率有限任何无谓的全屏纹理采样都会成倍增加耗电量。优化手段优先级如下降采样优先把RenderTexture的分辨率降到屏幕分辨率的1/2或1/4减少采样次数能用5-tap模糊就不要用9-tap能用二分离线就不要用全二维卷积合并Pass把两三个简单效果放到一个Shader Pass里完成减少RenderTexture拷贝次数避免高端功能例如避免在移动端用高精度的pow运算或过长的循环用half精度在移动端fixed4和half4能比float4快很多但注意部分平台half精度范围较小容易产生banding。还有一个很多人忽略的点RenderTexture.GetTemporary带来的显存分配。频繁申请和释放虽然能避免内存泄漏但分配粒度过大会影响GC和GPU同步。建议在脚本中缓存固定分辨率的临时RT避免每帧创建销毁。我记得有个项目在真机上做毛玻璃UI用了四张贴图整个模糊Pass帧率掉到20帧。后来我把模糊分辨率降到屏幕宽的1/4再把混合模式从多次Blit改为单Pass叠加采样帧率直接回升到55帧。可见优化的关键并不是技术多花哨而是“少做事”。5.4 不同API与平台上的坐标翻转问题平台差异也是后处理开发必须面对的难题。在OpenGL平台上图像坐标系的原点在左下角而DirectX平台的纹理坐标原点在左上角。对于全屏后处理有时画面会出现颠倒或上下错位的情况。解决方式通常是检测当前平台并调整uv翻转。Unity内置的_ProjectionParams.x变量可以帮忙如果值为1表示投影轴未翻转如果是-1则需要翻转uv的y轴。#if UNITY_UV_STARTS_AT_TOP if (_ProjectionParams.x 0) uv.y 1 - uv.y; #endif这句判断需要放在采样前避免直接从uv采样后出现画面翻转。网上很多后处理模板都包含这段代码但很少有人讲清楚为什么。简单记一句D3D系平台默认uv顶部起始但经过Unity翻转后在后处理里可能变成底部起始用这个判断强制校正即可。6. 个人经验总结与建议做了这么多项目后我对Unity实时摄像机渲染图像处理的最大感受是它既是技术问题也是审美问题。如果你只注重技术实现不考虑效果取舍最终做出来的画面常常会“古板又生硬”。比如模糊强度、扫描线密度、描边阈值这些参数每个看起来都无关紧要但组合起来往往决定了一个游戏的气质。实际操作中我建议每个团队都维护一套自己的“后处理预设库”。同样一个黑白滤镜在暗黑风游戏和赛博朋克游戏里的参数完全不一样同样一个径向模糊用在爆炸特效里和用在场景切换里半径和中心点位置也不同。把参数和预设分离让策划或美术可以自由调整才是框架的核心价值。如果让我重新选择技术路线我会直接一开始就基于URP的Render Feature搭框架即使项目短期不需要URP也为未来保留扩展空间。然后再实现一套纯C#的Pass调度器让每个效果都表现为一个可排序的Pass对象。这样无论加多少效果都不会让代码变成一团乱麻。最后再分享一个调试小技巧后处理Shader调试时不要直接只看最终画面。你可以在Shader里把中间结果直接输出为颜色比如把边缘强度值映射到(edge, edge, edge, 1)就能看到黑白的边缘检测图。这种可视化调试法能让你把问题定位到具体Pass而不是在层层叠叠的效果中猜来猜去。我每次开发新效果都会先做这一步。如果看完后你能动手写一个自己的颜色滤镜再用降采样做一个模糊UI背景那这套知识就已经真正进入你的工具箱了。后面无论做独立游戏还是商业项目再去接屏幕特效需求都不会再摸不着头脑。