☰
Unity实时图像处理实战:从RenderTexture到后处理Shader框架
2026/10/12 5:47:42 网站建设 项目流程

1. 摄像机渲染与图像处理的核心链路

1.1 一个画面从摄像机到屏幕到底经历了什么

很多朋友一开始接触Unity的实时画面处理,第一反应都是“我直接拿到摄像机画面,然后一帧一帧处理不就行了”。这个思路本身没错,但真正落地的时候会发现,Unity并不会把每一帧的像素数据直接交到你手上,而是走了一条更底层的路。

摄像机把场景渲染出来,最终其实是一个“画布”的过程。这个画布在Unity里叫RenderTexture,本质上是一块显存里的纹理。默认情况下,摄像机把画面画到屏幕上这块“画布”,也就是Back Buffer。我们想处理画面,就得在它被送到屏幕之前截下来,改一改,再交给屏幕去显示。这个“拦路截胡”的时机,就是后处理(Post Processing)流程介入的位置。

理解这条链路,是后面所有操作的地基。你不清楚画面流经哪些环节,调试的时候真的会一头雾水。我见过不少人改了半天的Shader,结果发现效果没生效,最后排查下来是挂载顺序不对,画面压根没走过自己的处理函数,白折腾一晚上。

1.2 为什么RenderTexture是实时图像处理的钥匙

摄像机的输出目标是可以指定的。默认目标是无,也就是屏幕。一旦你把摄像机的Target Texture指定为一张RenderTexture,画面就会渲染到这张纹理上,而不是直接显示到屏幕。这一下子就打开了处理空间。

你可以把RenderTexture当作一张“临时画布”,它存在于GPU显存里,读写速度非常快。通过把摄像机输出目标改成它,再通过OnRenderImage这样的回调把画面取出来,用Shader做一轮逐像素运算,最后再把处理结果放回屏幕。整个过程都在显存里完成,几乎不涉及CPU和内存的数据搬运,所以能做到实时。

选型的时候,用RenderTexture而不是用Texture2D.ReadPixels这种方式去抓屏,核心原因就是性能。ReadPixels会把GPU上的数据拷回到CPU,那是同步操作,一帧卡一下,移动端基本承受不了。而且你还要手动管理像素格式转换,处理RGB和YUV的差异,想想就头大。RenderTexture完全在GPU侧流动,这才是实时的真正解法。

1.3 后处理脚本应该在哪个环节介入

Unity为后处理提供了一个非常关键的生命周期函数:OnRenderImage。它有两个参数,source是摄像机刚渲染出来的画面,dest是最终要输出的目标。你在函数里做处理,然后通过Graphics.Blit把结果输出到dest,Unity再把dest呈现到屏幕。

这里有个细节容易忽略:OnRenderImage的执行时机是在所有透明物体渲染完成之后,也就是说,它拿到的是场景的完整最终画面。如果你要做的是深度相关的效果,比如扫描线、描边、景深,在同一帧里还能通过_CameraDepthTexture取到深度信息,前提是摄像机开启了Depth Texture选项。

我举个实际例子。一个标准后处理脚本的骨架长这样:

using UnityEngine; public class PostProcessBase : MonoBehaviour { public Shader processShader; private Material processMaterial; protected Material GetOrCreateMaterial() { if (processMaterial == null) { processMaterial = new Material(processShader); processMaterial.hideFlags = HideFlags.HideAndDontSave; } return processMaterial; } private void OnRenderImage(RenderTexture source, RenderTexture destination) { var mat = GetOrCreateMaterial(); Graphics.Blit(source, destination, mat); } private void OnDisable() { if (processMaterial != null) { DestroyImmediate(processMaterial); } } }

Graphics.Blit这个函数,你说它是“把一张纹理拷贝到另一张”也行,但更准确地说,它是“用指定材质把source作为主纹理渲染到一个全屏四边形上”。引擎会生成一个覆盖整个屏幕的四边形,让Shader在这个四边形上逐像素执行片段着色器逻辑。source会作为_MainTex传入Shader,你在Shader里对每个像素采样、计算、输出颜色,就是这么回事。

理解了这层,你就能明白为什么后处理的效果完全由Shader决定。C#脚本只是控制什么时候处理、用什么材质、传什么参数,真正干活的永远是GPU上的Shader代码。

2. 从零搭建实时图像处理框架

2.1 项目结构设计与Shader基础建立

搞实时图像处理,项目结构一开始就要立好,不然后面效果越加越多,文件夹乱成一锅粥,找东西能找到怀疑人生。

我建议按功能去分目录,而不是按资源类型分。每个后处理效果独立成一个文件夹,里面放Shader、C#脚本、可能的预设和说明文档。比如这样:

Assets/ Effects/ ColorAdjustment/ ColorAdjustment.shader ColorAdjustment.cs GaussianBlur/ GaussianBlur.shader GaussianBlur.cs Pixelate/ Pixelate.shader Pixelate.cs Scripts/ Utility/ PostProcessUtil.cs

Shader这边,后处理Shader有个固定套路。Pass里面关闭深度写入和深度测试,顶点着色器不用做任何复杂的变换,只需要把顶点坐标和UV传递下去。为什么?因为我们渲染的是一个全屏四边形,不需要光照、不需要顶点法线、不需要任何场景相关的计算。

一个后处理Shader的最低限度的结构是这样的:

Shader "Custom/PostEffect/Base" { Properties { _MainTex ("Texture", 2D) = "white" {} _Intensity ("Intensity", Range(0, 1)) = 1 } 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 *= _Intensity; return col; } ENDCG } } }

注意ZTest Always这一行。它告诉GPU不管深度缓冲区里是什么,这个全屏四边形都能通过深度测试。如果不加,万一场景里有物体写入了深度,后处理画面可能被遮挡一部分,表现就是屏幕上有些区域没被处理。默认的Blit内部模式虽然在多数情况下没这个问题,但在自定义Shader里明确写上,能省掉很多怪问题。

2.2 多Pass处理的一个通用框架

单个Pass能做的事终究有限。比如你先做一个颜色调整,再做模糊,再叠加一个暗角,按顺序来。如果各写各的Pass,然后C#脚本里调用多次Blit,性能会成倍下降,因为每次Blit都是一次全屏渲染,改几次就多几次GPU开销。

更聪明的办法是走“多Pass但只调用一次Blit”的路线。Unity的Shader支持在一个SubShader里声明多个Pass,渲染时可以用下面的结构控制流程:

Shader "Custom/PostEffect/MultiPass" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Cull Off ZWrite Off ZTest Always Pass { Name "ColorAdjust" // 颜色调整逻辑 } Pass { Name "Blur" // 模糊逻辑 } } }

但这里有个问题:同一次Blit只能执行一个Pass,多Pass并不能在同一帧内自动串联。要让多个后处理串联,一个常规方案是在C#里逐级处理,中间用临时RenderTexture作为过渡:

public void Execute(RenderTexture source, RenderTexture destination) { var tmp1 = RenderTexture.GetTemporary(source.width, source.height, 0, source.format); var tmp2 = RenderTexture.GetTemporary(source.width, source.height, 0, source.format); Graphics.Blit(source, tmp1, effectMatA); Graphics.Blit(tmp1, tmp2, effectMatB); Graphics.Blit(tmp2, destination); RenderTexture.ReleaseTemporary(tmp1); RenderTexture.ReleaseTemporary(tmp2); }

GetTemporary和ReleaseTemporary这个配对是性能关键。如果你自己new RenderTexture,每次分配都会有开销,GC压力也跟着上来。GetTemporary走的是引擎内部的缓存池,用完还回去,下次还能复用,这才是实时处理的正确姿势。

所以我的建议是:不要试图在一个Blit里做完所有事情。图形的流水线本身就是每一帧从头到尾执行一遍,你把多个效果串起来,本质上就是一遍又一遍地“全屏读写”。中间用临时纹理过渡,虽然看着多了一次Blit,但换来的是每条逻辑清晰、可单独调节和复用,这个代价完全值得。

3. 四类高频图像处理效果实战

3.1 颜色调整:亮度、对比度、饱和度

颜色类的后处理是最常用、也最容易上手的。它逐像素独立,不依赖邻域像素,数学上非常简单。你先取到像素的原始颜色,然后按公式算一遍新颜色,输出即可。

亮度调整本质上是乘以一个系数,0.5就是变暗一半,1.5就是亮一半。对比度调整则围绕0.5这个中间值做拉伸。举个例子,对比度系数为2,那比0.5亮的地方更亮,比0.5暗的地方更暗,画面层次感就出来了。饱和度的常见做法是先算出一个灰度值,再把原色和灰度按权重混合。

这里给出一份可以直接合到一起的Shader代码:

Shader "Custom/PostEffect/ColorAdjust" { Properties { _MainTex ("Texture", 2D) = "white" {} _Brightness ("Brightness", Float) = 1 _Contrast ("Contrast", Float) = 1 _Saturation ("Saturation", Float) = 1 } 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 _Brightness; float _Contrast; float _Saturation; fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // 亮度 col.rgb *= _Brightness; // 对比度,以0.5为中点拉伸 col.rgb = (col.rgb - 0.5) * _Contrast + 0.5; // 饱和度,先算灰度再混合 fixed gray = dot(col.rgb, fixed3(0.299, 0.587, 0.114)); col.rgb = lerp(gray, col.rgb, _Saturation); return col; } ENDCG } } }

这个灰度系数是亮度权重的标准值,人眼对绿色最敏感,蓝色最不敏感,所以绿色权重最高。你要是为了省事直接三个通道均值,做出来的灰度会有明显的偏色,画面看着脏。这点在后期调色里特别容易踩。

3.2 高斯模糊与横向分离式采样

模糊是另一种核心需求,用在UI背景、景深模拟、画面柔化这些场景。高斯模糊的核心思路是“加权平均”,每个像素的新颜色,是它周围一圈像素按高斯权重算出来的平均值。

最容易想到的是在一个Pass里做N次采样。比如取上下左右四个方向,各带不同权重。但这样效果很粗糙,因为采样点太稀疏。真正接近高斯模糊的,需要在一个像素周围取很多个点,而且越靠近中心权重越大。

实际项目里常用的是“分离式高斯模糊”。因为二维高斯核可以分解成水平方向和垂直方向两个一维核,所以先用水平方向模糊一遍,再用垂直方向模糊一遍,效果跟全方向一次模糊基本一致,但采样次数从N乘以N降到了N加N。这个优化是数量级的。

Shader "Custom/PostEffect/GaussianBlur" { Properties { _MainTex ("Texture", 2D) = "white" {} _BlurSize ("Blur Size", Range(0, 10)) = 1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { Name "Horizontal" 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; float4 _MainTex_TexelSize; float _BlurSize; 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) * 0.227; col += tex2D(_MainTex, i.uv + float2(_MainTex_TexelSize.x * 1 * _BlurSize, 0)) * 0.076; col += tex2D(_MainTex, i.uv - float2(_MainTex_TexelSize.x * 1 * _BlurSize, 0)) * 0.076; col += tex2D(_MainTex, i.uv + float2(_MainTex_TexelSize.x * 2 * _BlurSize, 0)) * 0.016; col += tex2D(_MainTex, i.uv - float2(_MainTex_TexelSize.x * 2 * _BlurSize, 0)) * 0.016; return col; } ENDCG } Pass { Name "Vertical" // 结构跟Horizontal一致,只把偏移从x改到y } } }

关键点是_MainTex_TexelSize。这是什么?它是纹理单个像素的大小,也就是1除以纹理宽高。采样的偏移量要乘以它,才能保证偏移的是“一个物理像素”的距离,而不是UV坐标里的0.01这种相对值。忘了这个,模糊半径在不同分辨率下会完全不一致。

C#这边处理模糊,需要两趟。第一趟Pass输出到临时纹理,第二趟用这张临时纹理作为输入,才算完成一次完整模糊。如果觉得还不够糊,可以重复迭代多次。每迭代一次就再执行一遍横纵两趟。

3.3 边缘检测:Sobel算子与描边效果

边缘检测是画面风格化里很有用的一个效果,做卡通渲染、线稿、扫描高亮都会用到。它的核心是找到像素变化剧烈的位置。怎么判断变化剧烈?看周围像素的亮度差异。

Sobel算子是比较经典的方法。它包含两个卷积核,一个检测横向变化,一个检测纵向变化。把当前像素周围3乘3范围内的灰度值,分别和这两个核做卷积,得到X方向和Y方向的梯度值,然后算梯度幅值。幅值大于阈值,就认为这里是边缘。

Shader实现时,以当前像素为中心,采样周围8个点,把它们的灰度值合在一起做卷积运算。注意这个灰度值,要提前把颜色转成灰度,常见做法是dot操作。然后边缘判断得到一个0到1之间的值,用它对原色和边缘色做插值:

Shader "Custom/PostEffect/EdgeDetect" { Properties { _MainTex ("Texture", 2D) = "white" {} _EdgeColor ("Edge Color", Color) = (0, 0, 0, 1) _EdgeOnly ("Edge Only", Range(0, 1)) = 0 _Threshold ("Threshold", Range(0, 1)) = 0.1 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_TexelSize; fixed4 _EdgeColor; float _EdgeOnly; float _Threshold; fixed luminance(fixed4 color) { return dot(color.rgb, fixed3(0.299, 0.587, 0.114)); } float sobel(v2f i) { float texelX = _MainTex_TexelSize.x; float texelY = _MainTex_TexelSize.y; float u00 = luminance(tex2D(_MainTex, i.uv + float2(-texelX, -texelY))); float u10 = luminance(tex2D(_MainTex, i.uv + float2(0, -texelY))); float u20 = luminance(tex2D(_MainTex, i.uv + float2(texelX, -texelY))); float u01 = luminance(tex2D(_MainTex, i.uv + float2(-texelX, 0))); float u21 = luminance(tex2D(_MainTex, i.uv + float2(texelX, 0))); float u02 = luminance(tex2D(_MainTex, i.uv + float2(-texelX, texelY))); float u12 = luminance(tex2D(_MainTex, i.uv + float2(0, texelY))); float u22 = luminance(tex2D(_MainTex, i.uv + float2(texelX, texelY))); float gx = -u00 + u20 - 2 * u01 + 2 * u21 - u02 + u22; float gy = u00 + 2 * u10 + u20 - u02 - 2 * u12 - u22; return sqrt(gx * gx + gy * gy); } // ... ENDCG } } }

Sobel算子最关键的是系数别搞错。横向核的中间行系数更大,因为横向检测看重的是水平方向的导数,中间行的像素离中心最近,贡献应该最大。搞反了或者丢了2倍系数,检测出来的边缘会不对称,一边特别明显一边很淡。

阈值参数也很讲究。设太大,实际比较淡的边缘会被吞掉;设太小,画面会布满噪点,到处都是“边缘”,看起来像花屏。我的经验是,阈值先用0.1起步,在真机上对比效果再调整。不同场景的灯光明暗差异很大,不能一次调死。

3.4 像素化与暗角风格化

像素化是图像处理里的复古玩法,原理很简单:把一个区域内的所有像素颜色,统一为这个区域中心点的颜色。相当于把画面分辨率强行降下来,再用邻近插值放大回去。

实现方式很取巧,不需要真的降分辨率。你只要在采样的时候,把UV坐标做一步“对齐”操作:每个像素的UV,先除以块大小再取整再乘回去,这样uv就被钉在了若干个离散点上。然后只用这个离散的uv去采样纹理,画面自然就变成一格一格的了。

Shader "Custom/PostEffect/Pixelate" { Properties { _MainTex ("Texture", 2D) = "white" {} _PixelSize ("Pixel Size", Range(1, 100)) = 10 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float _PixelSize; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { float2 uv = i.uv; uv = floor(uv * _PixelSize) / _PixelSize; fixed4 col = tex2D(_MainTex, uv); return col; } ENDCG } } }

这里有个问题:uv是0到1的范围,如果屏幕分辨率是1080p,画面宽度是1920像素,那_PixelSize设置成10,意味着每个块覆盖192个物理像素,差不多是2个文字的宽度,效果非常明显。你可以根据自己的分辨率换算一下,找到合适的“块大小”才能得到想要的风格。

暗角就更有意思了,它模拟的是镜头边缘光线衰减的效果。做法是算UV到画面中心的距离,越远颜色越暗。但是直接用距离会有个问题,画面是矩形,四角比四边远得多,暗角会呈椭圆形。想要正圆形的暗角,得把UV坐标换成以中心为原点,同时按宽高比例做校正:

float2 centeredUV = i.uv - 0.5; centeredUV.x *= _MainTex_TexelSize.z / _MainTex_TexelSize.w; float dist = length(centeredUV); float vignette = 1 - smoothstep(_InnerRadius, _OuterRadius, dist);

_MainTex_TexelSize.z和w分别是纹理的宽和高,两者相除得到宽高比。把x乘上宽高比,相当于把所有点放到一个“方形坐标系”里,再用length算距离,暗角就是正圆形的。不懂这层,直接算距离,出来的暗角永远是变形的,而且在不同屏幕比例下表现还不一样。

4. 实时处理的性能优化与设备适配

4.1 渲染纹理格式与分辨率的选择策略

实时图像处理,性能永远是第一位的。你的效果再炫,帧率掉到20,用户直接卸载。移动端的GPU能力有限,带宽更是瓶颈,所以每一帧要尽可能减少数据量的读写。

渲染纹理的格式选择,直接影响带宽。最常用的格式有RGB24、RGBA32、ARGB32、RGBA Half、RGB565等等。RGB24占3个字节,RGBA32占4个字节,RGB565占2个字节。在色彩精度要求不高的情况下,用RGB565能省三分之一到一半的带宽开销。

但RGB565有个坑:它没有Alpha通道,而且只有5位红色、6位绿色、5位蓝色。你做颜色渐变的时候,可能会出现明显的色阶断层,尤其是暗部区域。这种情况下,宁可多花一点带宽用RGBA32,也别让画面出现一条一条的色带,观感非常廉价。

分辨率这边,不建议直接全分辨率处理。尤其是移动端,后处理完全可以先把source降采样到一半分辨率,处理完再放大回来。原理是:后处理通常处理的是画面的整体风格、模糊、色调这些低频信息,降采样虽然损失了一些高频细节,但对最终观感影响很小,而性能能提升好几倍。

实现上你不需要额外写代码。拿一张分辨率减半的临时纹理,用Graphics.Blit把source缩放进去,后续的处理都基于这张半分辨率纹理,最后再通过一次Blit放大到目标:

var scaled = RenderTexture.GetTemporary(source.width / 2, source.height / 2, 0, source.format); Graphics.Blit(source, scaled); Graphics.Blit(scaled, destination, effectMat); RenderTexture.ReleaseTemporary(scaled);

我实际测试过一个模糊效果,全分辨率处理1080p的帧耗时大概3.2毫秒,降到540p后只有1.1毫秒,视觉差异几乎看不出。你要是做游戏UI背景模糊这种非核心画面,甚至可以降到四分之一分辨率,省下来的预算能多放好几个特效。

4.2 减少Draw Call与Pass数量

图像处理里最大的开销不是Shader里的算法复杂度,而是每多一次全屏绘制带来的Draw Call增长。一次Blit就是一次全屏Draw Call,你串联了5个效果,不做优化就是5次Blit,在移动端是相当大的开销。

合并效果是优先策略。把颜色调整、暗角、胶片颗粒这类逐像素独立的效果,统统写进一个Pass,只要Sampler一次,后续都在寄存器里操作,最后输出一个颜色。这样一次Blit就完成了好几个视觉效果。

另一个思路是利用Shader的Keyword做条件编译。比如你的应用在低端机上要关闭额外的效果,通过material.EnableKeyword或者DisableKeyword控制Shader里的变体,GPU只执行当前启用的分支,没有多余的开销。

有些人会在Shader片段里用if判断来切换逻辑。严格说这也是一种办法,但现代GPU的SIMD架构下,同一个波次里的像素会同时执行所有分支,if代价是全部计算而不仅仅是选中分支。所以能用多Pass或者Keyword,就不要依赖运行时的if。

4.3 移动端发热与平台差异处理

移动端是实时图像处理的主战场,但也是坑最多的地方。发热降频是最大的敌人。处理性能再强,芯片因为过热降频,画面立刻掉帧。所以我做移动端后处理,一定会做一个“动态性能档位”:

  • 帧率连续高于目标值(比如60帧)时,保持全分辨率处理。
  • 帧率连续低于目标值超过几秒,自动把后处理分辨率降到一半。
  • 如果再掉,直接关闭某些高开销效果,比如模糊迭代次数减半。

实现方式也简单,在Update或者LateUpdate里累计帧率,用滑动窗口算平均帧耗时。一旦触发降级,就改C#里的一个分辨率系数,Shader代码完全不用动。

平台差异方面,iOS和Android的差异比想象中大。iOS的Metal渲染管线对某些UV坐标处理方式跟OpenGL ES不一样,容易出现画面翻转问题。如果你发现后处理在Android上正常,iOS上画面上下颠倒,很可能是渲染坐标体系差异导致的,需要在Blit时做一次UV翻转。最常见的解法是在Shader顶点的uv上乘以一个facing系数:

o.uv = v.uv; #if UNITY_UV_STARTS_AT_TOP if (_MainTex_TexelSize.y < 0) o.uv.y = 1 - o.uv.y; #endif

这段老代码很丑,但确实有效。_MainTex_TexelSize.y在OpenGL系里可能是正数,在DirectX和Metal里是负数,通过这个符号能判断当前需要哪种UV方向。我看过太多项目在iOS上画面倒了,最后加上这个判断就恢复正常,属于非常典型的平台坑。

4.4 用Profiler精准定位瓶颈

调性能不能靠感觉,必须靠数据。Unity自带的Profiler和Frame Debugger,是你排查问题的最强利器。

Frame Debugger可以看到每一帧执行的Draw Call数量和顺序。如果你的后处理串联了很多次Blit,Frame Debugger里会显示很多次“Draw Mesh Fullscreen”的条目。数一数就知道自己到底画了几次全屏三角形,有没有多余的调用。

Profiler窗口能看到每个系统函数消耗的时间。你要找到后处理脚本里的OnRenderImage,看它占了多少CPU时间。不过这里注意,OnRenderImage本身只是提交了一些GPU命令,真正的开销在GPU管线里。所以看GPU端的时间更准确一些,Unity的Profiler在支持GPU事件的平台上能看到GPU耗时分布。

移动端真机调试,我推荐用RenderDoc或者Xcode的Metal调试器,逐帧抓取画面,能看到每个Pass的输入输出。如果你做模糊却发现画面全黑,十有八九是采样坐标出了问题,用这些工具一看就知道哪一步丢了数据。

实际调优的顺序,我总结下来是:先看分辨率是不是过高,再看Pass数是不是过多,最后才是Shader里的具体算法。前两个是结构性的大头,改一次能省下一大半时间。算法层面的细节,比如可不可以把多次采样合并、能不能用低精度half计算、可不可以提前return,都是锦上添花的优化。

5. 实战中的问题复盘与解决思路

5.1 后处理画面全黑或者不生效

这是我遇到最多的新手问题。现象是脚本挂上了,摄像头也开着,但是画面全黑,或者效果完全没反应。排查思路要按顺序来。

第一查Shader有没有编译报错。Console窗口的红色报错别忽略,很多Shader的语法错误只有在运行时才暴露。你把Shader写好后,建议先在材质球上试试效果,如果材质球上就显示粉红色,说明Shader没编过,直接看Console里的错误信息。

第二查脚本有没有挂对位置。OnRenderImage必须挂在处理画面的那台摄像机上。如果你有多个摄像机,子相机和主相机的关系也要理清楚。后处理脚本在子相机上执行,但输出目标却是父相机的渲染结果,这就乱了。

第三查Graphics.Blit的调用。如果你的脚本里调用了多次Blit,中间某个环节的target传错了,很容易黑。我见过一个案例,开发者做多Pass串联,结果第二次Blit的source和destination写反了,画面就全黑了。还有个很容易踩的坑是OnRenderImage里没有调用Blit,直接return了,那Unity就不会输出画面。

5.2 效果出现闪烁或者画面错乱

闪烁的常见原因是临时纹理没有正确释放或者复用。RenderTexture.GetTemporary虽然带缓存池,但如果你在OnRenderImage里每次拿了一些临时纹理,却没有用ReleaseTemporary还回去,缓存池会不断申请新的空间,GC压力暴涨,可能造成卡顿。更严重的,如果你在同一帧里多次调用GetTemporary但忘了配对ReleaseTemporary,纹理空间可能被占用,导致画面内容错乱。

另一种闪烁场景是后处理使用了深度信息,但深度纹理没有开启或者延迟一帧才生成。你要在Camera上勾选Depth Texture选项,或者确保摄像机设置了depthTextureMode。如果你用了自定义Shader采样_CameraDepthTexture,没开这个选项,采到的深度是无效数据,表现出来就是画面上随机出现黑点或者闪烁的亮斑。

5.3 不同分辨率下效果不一致

Shader里的很多数值是跟分辨率相关的。比如模糊半径如果用固定的像素偏移量,在720p和1440p下的模糊范围就完全不一样。原因前面说过,偏移要乘以_MainTex_TexelSize,让偏移量跟物理像素挂钩。道理很简单:一个物理像素的偏移,在任何分辨率下都是“一个像素”,视觉效果自然保持一致。

但有些效果是希望跟视口比例挂钩的,比如暗角。我的做法是,把半径参数做成基于屏幕高度的一个比例值。比如暗角半径是屏幕高度的0.8倍,这个值在任何分辨率下看起来都是同一个位置开始变暗。如果你写死数值,在竖屏和横屏下观感差异会非常明显。

5.4 移动端纹理坐标翻转

前面提过,Unity在OpenGL和Metal底层的屏幕坐标起点方向不同。OpenGL里屏幕坐标原点在左下角,Metal和DirectX里原点在左上角。后处理Blit的时候,如果不做处理,采样的UV方向可能跟实际画面不一致。

如果你只做了PC开发,这个问题完全碰不到,因为PC默认走DirectX路径。一旦打包到Mac或者iOS,画面就翻转了。不想每个Shader都改,可以在C#脚本里根据平台设置一个全局的uv翻转开关。不过最稳妥的还是理解根因,在Shader里用UNITY_UV_STARTS_AT_TOP这个宏做兼容,一劳永逸。

我个人在实际开发里,会在项目立项时就把平台兼容性考虑到后处理框架里。早期花半小时把这些条件编译写好,能省掉后期一堆奇怪的“俄方平台黑屏”“iOS画面倒了”的反馈。

6. 一些经验补充

做Unity实时摄像机图像处理,我的核心建议可以总结成四句话:搞清楚数据流动的方向再动手写代码,能用临时纹理绝不自建纹理,能用一次Blit绝不用两次,必须真机调试不要只在编辑里看效果。

RenderTexture的生命周期管理是个容易被轻视的点。写一个统一的管理工具类,封装GetTemporary和ReleaseTemporary的调用,统计当前未释放的数量,异常时打日志。这套东西在项目早期可能看起来“过度设计”,但到后期二三十个效果叠加时,你就会发现它帮你少排查了无数个图片闪烁问题。

Shader的变体管理也是后期必然要面对的事。每次增加一个Keyword,Unity都会为Shader生成额外的变体,变体数量膨胀会增加打包体积和加载耗时。建议为Shader声明变体数量上限,并定期清理未使用的变体集合。

再有一个容易被忽略的细节:后处理在编辑器里看到的画质,跟真机上的画质差异很大。编辑器里用的是桌面GPU,带宽和处理能力远强于移动端。你在编辑器里调好一套参数,装到手机上很可能是另一种表现。所以我每次开发完都会预留一个参数调整面板,在真机上统一调整,而不是在编辑模式下抠半天数值。

最后分享一个习惯:每做一个效果,都会截一张“效果前后对比图”,记录该效果的Shader变体数量、GPU耗时、平均帧耗时增量。日积月累下来,这个对比库就成了团队的宝贵资产,新需求来的时候,翻一翻就知道哪些效果能叠加、哪些效果在低端机上绝对不能开。

图像处理是个越做越有意思的领域,从最简单的颜色调整做到后面对比的风格化滤镜、景深、大气透视,每一步踩过的坑都在积累。希望你从这些基础内容起步,少走一些弯路,多留一些时间去做真正有创意的画面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询