
1. 项目概述先说清楚这个项目到底在做什么Unity实时摄像机渲染图像处理说白了就是把摄像机拍到的画面拿到GPU里过一遍Shader再输出回屏幕或RenderTexture。这个过程不只是加滤镜那么简单它涵盖了画面抓取、像素级修改、后处理特效叠加、性能开销控制等一系列工程问题。我最早接触这个需求是因为做一个模拟项目X的监控画面效果需要在游戏里模拟一个老旧监控器的画面扫描线、噪点、色彩偏移、画面扭曲。当时第一反应是用全屏UI叠加或者贴图遮罩结果发现静态叠加根本骗不过眼睛画面里的物体在动噪点和扭曲却完全静止违和感极强。后来才真正开始研究Unity的渲染管线用Shader对实时画面做逐帧处理。这个项目适合谁参考如果你是以下情况这篇文章应该对你有用刚接触Unity后处理想搞清楚RenderTexture和OnRenderImage之间的关系需要在运行时动态抓取屏幕画面用于小地图、画中画、投影墙等效果想写自定义屏幕Shader但总感觉流程不完整、踩坑没踩明白从Asset Store用过几个后处理插件但想脱离插件自己掌控效果不夸张地说实时摄像机图像处理是Unity图形编程里最容易被低估的一块它不复杂但牵扯的知识点非常碎而且官方文档写得比较分散。这篇文章把我实际做项目时验证过的流程、参数、坑全部整理出来你可以直接照着抄。2. 核心思路拆解渲染图像处理的整体设计2.1 处理链路摄像机画面是怎么一步步“被处理”的先把流程理清楚。Unity里一台摄像机激活后每帧會做这几件事根据摄像机的视野FOV、近远裁剪面生成视锥体将场景内的物体经过顶点变换、光栅化写入颜色缓冲最后将颜色缓冲输出到目标设备或RenderTexture实时图像处理要干预的就是第3步到“输出”之间的环节。我们有两条路可以插手OnRenderImageUnity内置的MonoBehaviour回调可以直接用Graphics.Blit把画面传给Shader处理CommandBuffer更底层可以在渲染管线的指定节点插入自定义命令适合多相机、多Pass的复杂场景这两条路我在项目里都实际用过。从学习路径来说我建议先吃透OnRenderImage它虽然被一些人认为是“老方法”但恰恰是理解整个处理链最短的路径。CommandBuffer等你理解了“画面到底在哪一步被截获”之后上手会快得多。2.2 为什么用Shader而不是在C#里处理像素这是个新手最容易问的问题画面都拿到CPU上了直接用C#遍历像素数组改颜色不行吗行但只适合小画面和实验场景。比如一张1920x1080的图1024x1024的全屏画面用C#逐像素处理即使优化到用指针操作单帧耗时也轻松超过10毫秒直接掉到60帧以下。而GPU Shader是并行处理的一个全屏Shader可能在1-2毫秒内完成同等级操作并且不占用主线程。所以项目里只要涉及“每一帧都要处理”的画面效果一律交给Shader。C#只负责逻辑调度、参数传递和后期资源管理。2.3 项目方案的取舍完全自己写还是用插件我知道很多人第一反应是装一个现成的后处理插件。插件方案的优势是快但它有一个实际问题你无法精确控制它在不同设备上每一帧的开销而且遇到不满足需求的细节比如自定义扫描线密度、逐帧变化的噪点强度改动插件源码的成本有时候比从头写还高。我的建议是如果只是做通用效果如Bloom、AO直接上成熟插件如果你要做的是带定制逻辑的画面处理比如我们项目里的监控效果、热成像效果、夜视仪效果自己写Shader和管线控制反而维护成本更低。这篇文章后面给的代码框架就是按照第二种场景来的。3. 关键环节解析渲染图像的抓取、传递与修改3.1 RenderTexture画面数据的中转站RenderTexture是Unity里专门用于渲染目标的纹理类型。你可以把它理解为一块GPU显存里的“画布”摄像机可以把画面画上去Shader也可以从上面读数据。实际使用中需要配置的参数有参数推荐值说明分辨率与目标画面一致或按比例缩放全屏效果用屏幕分辨率即可Depth Buffer24位或32位需要深度信息时使用否则可以设0格式RGBA Half Float 或 RGBA32高动态范围效果用Half Float普通颜色处理RGBA32够用Filter ModeBilinear 或 Point做像素风特效用Point其他情况Bilinear抗锯齿根据目标设备需要平滑边缘时配合MSAA创建RT的代码很简单RenderTexture rt new RenderTexture(Screen.width, Screen.height, 24); rt.name SceneCaptureRT; rt.Create();用完记得释放否则内存会持续占用if (rt ! null) { rt.Release(); Destroy(rt); }3.2 OnRenderImage最快的实现路径OnRenderImage是Unity遗留但完全可用的接口签名如下void OnRenderImage(RenderTexture source, RenderTexture destination) { Graphics.Blit(source, destination, material); }source就是摄像机渲染出的画面destination是最终要输出到的目标。material是包含自定义处理Shader的材质。Graphics.Blit会把source传给ShaderShader处理完写入destination。如果要用多个Shader连续处理可以链式调用void OnRenderImage(RenderTexture source, RenderTexture destination) { RenderTexture tmp1 RenderTexture.GetTemporary(source.width, source.height); RenderTexture tmp2 RenderTexture.GetTemporary(source.width, source.height); Graphics.Blit(source, tmp1, material1); // 第一次处理比如扭曲 Graphics.Blit(tmp1, tmp2, material2); // 第二次处理比如调色 Graphics.Blit(tmp2, destination); // 输出到屏幕 RenderTexture.ReleaseTemporary(tmp1); RenderTexture.ReleaseTemporary(tmp2); }这里有个细节每多一次处理就多一次全屏Blit开销是线性增加的。我们的监控效果最初是4个Pass在低端安卓机上直接掉到30帧后来把4个Pass合并成2个Pass一个负责画面扭曲扫描线一个负责调色噪点帧率恢复到了55帧以上。合并Pass的核心思路把能在一个Shader里用多Pass或单Pass多采样的效果尽量合并。3.3 CommandBuffer更精细的管线控制如果你的需求不只是在“画面渲染完之后”处理还想在渲染过程中的某个节点插入操作比如只处理不透明物体跳过透明物体CommandBuffer是正解。核心用法示例CommandBuffer cb new CommandBuffer(); cb.name CustomPostProcess; camera.AddCommandBuffer(CameraEvent.AfterImageEffect, cb);但这里我提醒一句CommandBuffer的调试成本更高。有一次我把命令插到了AfterShadowPass结果屏幕上所有物体阴影区域出现异常颜色排查了半天才发现是Pass顺序的问题。所以建议先用OnRenderImage跑通效果再迁移到CommandBuffer精调时序。3.4 Shader编写要点像素级处理的核心屏幕后处理Shader本质上是拿屏幕UV去采样原图做各种数学变换后输出。一个最基本的模板Shader Custom/ScreenEffectBase { Properties { _MainTex (Texture, 2D) white {} _Intensity (Intensity, Range(0, 1)) 0.5 } 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; }; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } sampler2D _MainTex; float _Intensity; fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 这里是自定义处理逻辑 col.rgb lerp(col.rgb, col.rrr, _Intensity); return col; } ENDCG } } }注意几个容易出错的配置Cull Off是必须的否则背面剔除会让部分像素丢失ZWrite Off和ZTest Always确保Blit时不会被深度缓冲阻挡。这几个配置是Graphics.Blit正常工作的大前提。4. 实操过程从零搭建一个监控画面效果4.1 场景与脚本基础搭建我们以“老式监控画面”为例完整跑一遍实时摄像机图像处理的流程。这个效果包含轻微画面扭曲、扫描线、动态噪点、黑白偏色。第一步创建场景一个主摄像机一个立方体摆在镜头前方便观察一盏平行光第二步创建一个C#脚本ScreenEffectController.cs挂在主摄像机上using UnityEngine; [RequireComponent(typeof(Camera))] public class ScreenEffectController : MonoBehaviour { public Shader effectShader; private Material effectMaterial; [Range(0, 1)] public float scanLineIntensity 0.6f; [Range(0, 1)] public float noiseIntensity 0.3f; [Range(0, 2)] public float distortionStrength 0.15f; void Awake() { if (effectShader null) { effectShader Shader.Find(Custom/CCTVEffect); } effectMaterial new Material(effectShader); } void OnRenderImage(RenderTexture source, RenderTexture destination) { if (effectMaterial ! null) { effectMaterial.SetFloat(_ScanIntensity, scanLineIntensity); effectMaterial.SetFloat(_NoiseIntensity, noiseIntensity); effectMaterial.SetFloat(_Distortion, distortionStrength); Graphics.Blit(source, destination, effectMaterial); } else { Graphics.Blit(source, destination); } } void OnDestroy() { if (effectMaterial ! null) { Destroy(effectMaterial); } } }这里我习惯在Awake里用Shader.Find不用Inspector手动拖因为发布后Shader引用经常因为打包设置丢失。你可以在Project Settings的Graphics里把Shader加进Always Included Shaders列表。4.2 编写监控效果的Shader接下来是核心Shader。老式监控画面的特点偏色偏绿或偏蓝、扫描线、动态噪点、轻微畸变。Shader Custom/CCTVEffect { Properties { _MainTex (Texture, 2D) white {} _ScanIntensity (Scan Intensity, Range(0, 1)) 0.5 _NoiseIntensity (Noise Intensity, Range(0, 1)) 0.3 _Distortion (Distortion, Range(0, 1)) 0.1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; float _ScanIntensity; float _NoiseIntensity; float _Distortion; // 伪随机函数用于生成动态噪点 float hash(float2 p) { return frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453); } fixed4 frag (v2f_img i) : SV_Target { float2 uv i.uv; // 1. 轻微畸变 float2 center float2(0.5, 0.5); float dist length(uv - center); uv center (uv - center) * (1.0 _Distortion * dist * dist); fixed4 col tex2D(_MainTex, uv); // 2. 扫描线 float scanLine sin(uv.y * _ScreenParams.y * 1.2 _Time.y * 3.0) * 0.5 0.5; col.rgb * 1.0 - _ScanIntensity * scanLine * 0.3; // 3. 动态噪点 float noise hash(uv * _ScreenParams.xy _Time.y); col.rgb (noise - 0.5) * _NoiseIntensity; // 4. 偏色与对比度 col.rgb lerp(col.rgb, float3(col.r * 1.1, col.g * 1.3, col.b * 0.9), 0.7); return col; } ENDCG } } }这段Shader的关键点在于畸变处理中uv先围绕画面中心计算距离再做径向拉伸。这个公式模仿了老式镜头边缘畸变的形态。扫描线用了sin函数乘以屏幕像素高度确保扫描线密度跟分辨率无关。如果你直接用uv.y乘以固定值不同分辨率下扫描线的粗细完全不同这是我在项目里踩过的坑。噪点用hash函数驱动输入里加了_Time.y保证逐帧变化。注意噪点强度别加太猛否则压缩编码时码率会飙升。4.3 参数调节与效果验证Shader写完后回到编辑器里调参数。我的建议是准备一个静态场景至少有一个静止物体和一个缓慢移动的物体这样能同时验证静态和动态效果。调节参数时关注这几件事噪点是否太密或太疏调_NoiseIntensity0.2-0.35之间比较自然扫描线是否闪烁过快观察_Time.y系数我用的3.0如果觉得晃眼降到1.5畸变是否让画面边缘穿帮畸变太强时画面边缘会出现采样到UV范围外的黑色区域需要把Distortion控制在0.15以下如果你想让效果更真实还可以叠加色差效果即把RGB分量按照不同偏移量采样float2 uvR uv float2(0.001, 0.0); float2 uvB uv - float2(0.001, 0.0); col.r tex2D(_MainTex, uvR).r; col.b tex2D(_MainTex, uvB).b;色差偏移量的单位是UV坐标0.001在1080p下大概是2个像素在720p下可能是4个像素需要按分辨率缩放。可以用_MainTex_TexelSize.x做基准float2 offset float2(_MainTex_TexelSize.x * 2.0, 0.0);4.4 进阶叠加多个后处理效果时的时机控制做监控效果只是第一步大多数实际项目需要的不只是一个效果。比如你既要Bloom泛光又要这个监控滤镜顺序就很关键。我推荐的顺序Bloom这类基于亮度的特效放在前面然后再叠加风格化滤镜。原因很简单Bloom处理的是画面中高亮区域的扩散如果先加噪点和偏色Bloom会把这些噪声当成高亮细节扩散出去画面就脏了。多效果串联的代码用我前面说的临时RT链式Blit即可。但要注意RenderTexture.GetTemporary的线程安全别在多个协程里同时申请Unity的RT池在处理同时调用时可能返回同一个RT导致画面串帧。5. 性能优化让实时处理不吃掉帧率5.1 分辨率与全屏Pass的开销评估每多一个全屏Pass开销大概是与屏幕分辨率呈正比的像素着色成本。举个实测数据某中端安卓机骁龙7系屏幕2244x1080一个普通的后处理Pass采样调色大约耗时0.8-1.2ms增加到四五个Pass总耗时直接到4ms以上配合游戏本身的渲染耗时很容易超过垂直同步的16.7ms预算。降开销的几个有效手段降低处理分辨率创建半分辨率RT先缩放再处理最后Blit回全屏。对于噪点、模糊类效果视觉差异很小性能收益大合并Pass能在同一个Shader里完成的效果不要拆成多个Blit按需启用效果只在需要时启用OnRenderImage通过脚本控制material引用为空直接Blit绕过后处理逻辑半分辨率实现的代码模式void OnRenderImage(RenderTexture source, RenderTexture destination) { int halfW source.width / 2; int halfH source.height / 2; RenderTexture halfRT RenderTexture.GetTemporary(halfW, halfH); // 先把源画面缩放一半 Graphics.Blit(source, halfRT); // 半分辨率下执行后处理 Graphics.Blit(halfRT, destination, effectMaterial); RenderTexture.ReleaseTemporary(halfRT); }注意如果你的效果涉及像素级锐利细节比如扫描线降分辨率会导致扫描线变粗、闪烁加剧。所以扫描线类效果我建议在全分辨率下做噪点和调色可以降分辨率。5.2 RenderTexture的复用与内存管理项目里最容易出现的内存问题就是在Update或协程里频繁new RenderTexture又不及时释放。Unity有内置的临时RT池GetTemporary/ReleaseTemporary但对于“每一帧都要用”的RT你甚至可以自己在Awake里创建好固定RT在OnDestroy里释放避免每帧申请和回收。我踩过一个特别典型的坑在OnRenderImage里用了GetTemporary但忘记在函数末尾ReleaseTemporary结果内存每隔几分钟涨一次最终在移动端直接闪退。排查工具用了Memory Profiler发现大量RenderTexture堆积在显存里。一个安全的模式- 在Awake中创建需要用到的临时RT - 在每一帧OnRenderImage开始前确保RT的尺寸与Screen.width/height匹配 - 在OnRenderImage结束时不要释放这些RT - 在OnDestroy中统一Release并Destroy5.3 参数动态调整时的性能陷阱连续给Shader SetFloat也有开销虽然单次开销小但如果每帧设置5个参数乘以多个材质CPU端的Dirty标记和Shader传参就会积累成可见的耗时。解决办法只在参数变化时SetFloat参数不变时跳过。大多数参数来自Inspector或运行时UI可以用一个缓存值做比较if (Mathf.Abs(currentScanLine - lastScanLine) 0.001f) { effectMaterial.SetFloat(_ScanIntensity, currentScanLine); lastScanLine currentScanLine; }6. 常见问题与排查技巧实录6.1 屏幕黑屏最常见的四大原因黑屏是后处理入门的第一个拦路虎。我整理一下我遇到过的黑屏原因按出现概率排序现象原因解决办法整个屏幕全黑但有UIShader编译报错材质无效查看Console报错检查Shader语法屏幕黑但有物体轮廓深度测试问题确认ZTest Always、ZWrite Off黑屏且Console无报错material为null且Source没有被正确Blit检查OnRenderImage里是否调用了Graphics.Blit仅移动端黑屏Shader用了PC专属指令切换API验证OpenGL ES/DX关于Shader编译错误我推荐在编辑器里直接双击Shader文件打开Shader Inspector查看GPU编译日志。有时候代码在CGPROGRAM里用了一个不存在的函数Unity不会直接在Console报错而是Shdaer Inspector里显示编译失败但运行时材质已经变成默认错误材质输出品红或全黑。6.2 画面撕裂与UV坐标翻转问题在部分平台尤其是OpenGL系RT的UV原点跟DX平台不同。当你用Blit处理时source和destination的UV取向可能不一致导致画面上下颠倒或者采样错位。遇到画面颠倒的情况用UnityCG里的flip UV方法#if UNITY_UV_STARTS_AT_TOP if (_MainTex_TexelSize.y 0) uv.y 1.0 - uv.y; #endif这段代码统一在frag开头执行即可。注意有些插件里写的是“if (_MainTex_TexelSize.y 0) uv.y 1 - uv.y;”这个判断是对的TexelSize.y在OpenGL下是负值说明纹理V轴是反向的。6.3 后处理对UI的影响与处理方案OnRenderImage默认会影响整个摄像机渲染结果包括Screen Space的UI。如果你只想处理3D场景UI保持锐利有两个方案用两个摄像机一个渲染场景并挂后处理脚本另一个用高优先级渲染UIClear Flags设为Depth Only。这是最常用的方案。在Shader里判断UI区域不推荐因为UI的分布不规则Shader里做区域遮罩的维护成本太高。我用方案1实测效果很好但有一个细节UI摄像机的Culling Mask要设置为UI层并且场景摄像机的Culling Mask不要包含UI层否则同一物体被两个摄像机各渲染一次浪费性能。6.4 时间参数驱动的动态效果在暂停时的问题如果你用_Time.y驱动噪点和扫描线Unity的Time在Time.timeScale 0时也会暂停。某些需要“暂停但画面继续动”的场景中比如游戏暂停菜单背后的画面仍然有动态监控效果不能让噪点停止。解法在C#里维护一个不受Time.timeScale影响的独立时间把它传给Shaderfloat unscaledTime Time.realtimeSinceStartup; effectMaterial.SetFloat(_UnscaledTime, unscaledTime);Shader里把_Time.y替换为_UnscaledTime即可。6.5 多相机叠加处理导致的效果重复场景里有多个摄像机时如果你给两个摄像机都挂了后处理脚本画面会被处理两次。常见的错误是主摄像机已经做了监控效果UI摄像机因为继承或复制组件也挂了同一个脚本结果UI也带上了噪点和扫描线看起来像花屏。检查方法打开Frame DebuggerWindow - Analysis - Frame Debugger逐帧查看每个摄像机的渲染该次确认哪台摄像机执行了OnRenderImage。处理方案是给UI摄像机单独建一个脚本不做任何图像处理。7. 工具与调试手段项目效率的关键7.1 Frame Debugger看清每一帧的真实流程Frame Debugger是我项目中用的最多的调试工具。启用后你可以看到每一帧的渲染事件列表包括每一次Draw Call、每一个Blit操作。当你怀疑后处理Pass没有生效时用Frame Debugger查看事件列表中是否有你Shader的Pass名字比在代码里打日志快得多。实际操作中我发现一个规律如果你在Frame Debugger里看到Graphics.Blit的条目但看不清中间RT的实时内容可以点击该事件后查看RenderTexture的预览确认处理前后的画面差异。这个功能在颜色空间问题排查时能救你一命。7.2 自制的直观调试开关开发一个后处理效果时我最喜欢做的一件事是加一个Debug开关直接用材质面板控制把中间步骤的RT可视化。比如在监控效果里我想看“畸变后的中间帧”就可以在Shader里加一个Keyword#pragma multi_compile _ DEBUG_SCANLINE_ON然后在C#里通过Material.EnableKeyword切换把中间结果输出到屏幕。不用改代码就能对比效果这在调参数的时候反馈速度特别快。7.3 移动端真机调试的注意事项编辑器里效果正常一上真机就出问题的案例我遇到不止一次。建议你在开发初期就把真机测试纳入节奏特别是真机分辨率可能与编辑器Game视图不一致扫描线密度要做分辨率适配移动平台上线性空间和Gamma空间的采样结果和编辑器预览可能有差异部分低端机上FP16的精度不足Half Float RT会出现banding色带色带这个坑值得单独说做颜色渐变类效果时如果用RGBA Half Float RT做中间缓冲低端GPU的Half精度不够画面上会出现明显的色阶断层。降级方案是把关键RT换成RGBA32虽然增加带宽但能消除色带。8. 经验总结我在多次迭代后留下的几条心得先说说我在项目推进过程中最深刻的体会实时摄像机图像处理的难点不在写Shader而在工程化管理。Shader本身有范例可循真正让你熬夜的是RT生命周期、平台差异、性能预算这些“看不见的约束”。我个人建议你养成几个习惯一是所有临时RT的创建和释放必须配对。我后来直接用了一个小的工具类专门管理RT池的申请和回收在OnRenderImage里只传尺寸参数工具类负责从池里取、用完归还这样杜绝了泄漏。二是每一个Shader效果都写一个独立的C#控制脚本参数面板完整暴露。不要把所有效果揉在一个大脚本里用if-else分支切换后期维护会非常痛苦。我早期项目就是这样一个脚本里挂了四五个效果的参数和逻辑每次改一个效果的参数都要反复确认没影响其他效果。拆分之后清爽多了。三是善用Unity的帧率统计。写后处理效果时除了看Game视图的流畅度还要用Profiler看GPU的Render Time。我一般设置一个标准全屏后处理总耗时控制在2.5ms以内超过就要考虑降分辨率或合并Pass。四是不要在移动端使用计算数量过多的Shader指令。比如我们监控效果Shdaer里的hash函数虽然看起来简单但在低端GPU上大量调用会造成ALU瓶颈。实测中我把hash改为一个基于屏幕坐标的简单算式帧率提升了约10%。这类优化只有在真机上才能感知到编辑器里完全看不出来。最后再分享一个小技巧如果你要做动态噪点类效果用两张交替的噪点纹理比用数学函数每次随机更省性能。提前在初始化时生成两张半透明的噪点SpriteShader里用_Time.y的整数部分做奇偶切换虽然多占用一些内存但GPU计算压力小得多。这个方法我沿用了好几年在各种项目中都验证过效果稳定。