1. 项目概述为什么我们要深入OnRenderImage与RenderTexture的组合在Unity里做后处理就像给游戏画面做“数字化妆”。你肯定用过Unity内置的Post-Processing Stack v2或者URP/HDRP里的Volume系统它们功能强大开箱即用。但当你需要一些“骚操作”比如实现一个市面上没有的、风格独特的滤镜或者对渲染管线有极致的性能控制需求时自定义后处理就成了必经之路。而OnRenderImage配合RenderTexture就是这条路上最经典、也最考验基本功的“手动挡”驾驶技术。我见过不少开发者包括几年前的我自己对这块的理解停留在“抄一段Blit代码能跑就行”的阶段。结果就是要么效果不对比如Alpha通道诡异、多相机打架要么性能莫名其妙地崩掉内存泄漏、冗余拷贝。问题的根源往往在于没有吃透OnRenderImage的回调时机以及RenderTexture的7种核心创建与使用组合。这7种组合每一种都对应着不同的应用场景、性能开销和潜在陷阱。搞明白它们你不仅能写出健壮高效的后处理更能深刻理解Unity的渲染命令队列和GPU资源管理机制这是从“功能实现者”迈向“系统设计者”的关键一步。2. 核心原理拆解OnRenderImage的调用时机与责任链在深入组合之前我们必须先成为OnRenderImage的“知情人”。这个方法不是随便调用的。2.1 OnRenderImage的生命周期与调用规则OnRenderImage是一个特殊的MonoBehaviour回调。它的执行时机非常明确在某个Camera完成所有不透明和透明物体的渲染即将把最终图像提交到屏幕或渲染目标之前。你可以把它想象成流水线上的最后一道质检和包装工序。这里有几个关键细节是官方文档不会着重强调但实战中至关重要的每帧每相机调用如果你的场景中有N个激活的Camera且它们都挂载了包含OnRenderImage的脚本那么这一帧就会调用N次。这对于分屏游戏或画中画UI至关重要。渲染纹理的传递它接收两个RenderTexture参数src源和dest目标。src是相机当前渲染好的图像dest是期望你输出结果的地方。你的责任就是把src处理后的结果输出到dest。必须调用Graphics.Blit或绘制全屏四边形这是硬性规定。如果你重写了OnRenderImage却没有在其中调用Graphics.Blit或类似的渲染指令Unity会报错。因为你需要明确告知图形API如何完成这次图像传递。后处理链Filter Chain当一个相机挂载了多个脚本都实现了OnRenderImage它们会按照脚本在GameObject上的组件顺序依次执行形成一个处理链。上一个脚本的dest会成为下一个脚本的src。理解这一点是设计复杂多层效果的基础。注意在URP/HDRP中OnRenderImage默认是不被调用的因为它们使用了新的RenderPipelineManager事件系统和RenderPass。本文聚焦于Built-in Render Pipeline内置渲染管线这是理解原理的基石。2.2 RenderTexture不仅仅是“一张纹理”RenderTexture是CPU和GPU之间的一个桥梁是GPU上一块特殊的内存区域既可以作为渲染操作的目标Render Target也可以作为着色器读取的纹理Shader Texture。它的状态决定了性能。创建RenderTexture时以下几个参数是性能与功能的关键width/height分辨率。全屏后处理通常使用相机目标尺寸但降采样half, quarter是常见的优化手段。depth深度缓冲区位数。0、16、24、32。做需要深度信息的后处理如景深、雾效时必须0。format纹理格式如RenderTextureFormat.ARGB32,RenderTextureFormat.ARGBHalf,RenderTextureFormat.DefaultHDR。决定了颜色精度、HDR支持和Alpha通道。antiAliasing抗锯齿采样数。与相机的MSAA设置配合不匹配会导致额外的Resolve开销。wrapMode, filterMode环绕和过滤模式。对于全屏效果通常Clamp和Bilinear就够了。enableRandomWrite启用Compute Shader或Shader Model 5.0的随机读写。这是实现一些高级效果如粒子更新的钥匙。memoryless仅在部分移动平台如iOS Metal有效。渲染目标内容在渲染通道结束后可被GPU丢弃节省带宽但之后无法再读取。3. 七种RenderTexture组合效果实战对比下面我们进入核心通过7种具体的代码组合来剖析每种做法的意图、实现和优劣。假设我们要实现一个简单的灰度化Grayscale后处理。3.1 组合一最简Blit直接应用材质这是教科书式的入门写法也是理解其他组合的基准。public Material postProcessMaterial; // 一个执行灰度转换的Shader材质 void OnRenderImage(RenderTexture src, RenderTexture dest) { if (postProcessMaterial ! null) { Graphics.Blit(src, dest, postProcessMaterial); } else { // 如果材质为空直接拷贝避免画面丢失 Graphics.Blit(src, dest); } }原理与意图直接将相机渲染结果src通过postProcessMaterial这个全屏Shader进行处理输出到相机最终的目标dest通常是屏幕或另一个RT。Graphics.Blit内部会完成一个全屏四边形的绘制。效果与性能效果正确应用灰度效果。性能一次全屏绘制调用。如果dest就是屏幕且你的Shader很简单这是最高效的方式。陷阱材质为空务必检查否则画面会变黑或异常。Alpha通道如果src没有Alpha或你的Shader错误处理了Alpha可能会导致UI等半透明元素合成出错。对于仅做颜色转换的效果通常需要在Shader中强制o.a 1.0。3.2 组合二临时RTIntermediate RT—— 多Pass效果的基石当你的后处理需要多个Pass例如先模糊再边缘检测或者需要将中间结果保存下来供后续使用就需要创建临时RT。public Material multiPassMaterial; // 假设这个材质有多个Pass或者我们需要分步操作 void OnRenderImage(RenderTexture src, RenderTexture dest) { // 1. 创建一个临时RT格式与src相同但不保留深度因为我们不需要深度信息进行后续处理 RenderTexture tempRT RenderTexture.GetTemporary(src.width, src.height, 0, src.format); // 2. 第一遍处理src - tempRT Graphics.Blit(src, tempRT, multiPassMaterial, 0); // 使用材质的第0个Pass例如模糊 // 3. 第二遍处理tempRT - dest Graphics.Blit(tempRT, dest, multiPassMaterial, 1); // 使用材质的第1个Pass例如叠加边缘 // 4. 释放临时RT这是关键否则会造成内存泄漏。 RenderTexture.ReleaseTemporary(tempRT); }原理与意图RenderTexture.GetTemporary是从Unity管理的全局缓存池中获取一个RT比new RenderTexture()性能高得多因为它避免了频繁的内存分配与回收。ReleaseTemporary是将其归还缓存池而非立即销毁。效果与性能效果实现了多Pass的复杂效果。性能增加了一次全屏Blit和额外的RT内存占用。但使用缓存池降低了GC压力。核心技巧缓存池是双刃剑Unity会根据参数宽、高、深度、格式复用RT。如果同一帧内你需要多个不同尺寸的临时RT这很高效。但如果你需要的RT参数变化非常频繁可能导致缓存池无法命中反而引发新的分配。务必配对使用Get/Release必须在同一帧内或同一渲染循环中释放获取的临时RT。通常建议在OnRenderImage末尾释放。忘记释放是常见的性能杀手。深度缓冲区处理GetTemporary的第三个参数是深度位数。对于纯图像处理不需要深度测试传0。如果需要基于深度的后处理如景深则需要与源RT相同的深度位数。3.3 组合三降采样Downsample—— 性能优化的王牌全屏效果在移动端或处理高分辨率时开销巨大。降采样是立竿见影的优化将图像缩小处理再放大回去牺牲少量锐度换取数倍的性能提升。常用于模糊、Bloom等效果。public Material blurMaterial; // 一个进行模糊的材质 void OnRenderImage(RenderTexture src, RenderTexture dest) { int downsampleFactor 2; // 降采样2倍 int rtW src.width / downsampleFactor; int rtH src.height / downsampleFactor; // 获取一个更小的临时RT RenderTexture tempRT RenderTexture.GetTemporary(rtW, rtH, 0, src.format); // 关键将源纹理过滤到更小的RT上。这里使用默认的Blit相当于一次双线性下采样。 Graphics.Blit(src, tempRT); // 在小尺寸RT上进行昂贵的操作如多次模糊迭代 // ... 这里可以对tempRT进行多次Blit操作因为尺寸小开销大幅降低 ... Graphics.Blit(tempRT, tempRT, blurMaterial); // 最后将处理好的小图Blit回目标dest。Graphics.Blit会自动进行上采样拉伸。 Graphics.Blit(tempRT, dest); RenderTexture.ReleaseTemporary(tempRT); }原理与意图利用Graphics.Blit在拷贝时自带的纹理过滤功能实现快速降采样和上采样。核心思想是“在低分辨率下做重活”。效果与性能效果画面会略微变“柔”因为下采样和上采样的滤波。对于模糊、辉光这类本身就需要平滑过渡的效果这个副作用反而是有益的。性能像素着色器的计算量减少为原来的1/(downsampleFactor²)。例如4倍降采样计算量降至1/16。实操心得降采样因子选择通常是2的幂次248。需要权衡性能与画质。Bloom效果常用1/4或1/8分辨率。滤波模式影响临时RT的filterMode设置为FilterMode.Bilinear默认即可获得平滑的拉伸效果。如果设置为FilterMode.Point上采样后会有明显的像素颗粒感可用于实现特殊的复古风格。与组合二结合降采样后的小图可以作为多Pass处理的输入进一步优化复杂效果。3.4 组合四双缓冲Double Buffering—— Ping-Pong技术当后处理算法的某一步需要同时读取上一次处理的结果和原始输入或另一个中间结果时就需要两个RT来回交换使用这就是“乒乓缓冲”。图像混合、某些自定义图像卷积如复杂模糊会用到。public Material blendMaterial; // 一个需要两个输入纹理的材质例如 _MainTex 和 _LastTex void OnRenderImage(RenderTexture src, RenderTexture dest) { // 创建两个临时RT作为乒乓缓冲区 RenderTexture bufferA RenderTexture.GetTemporary(src.width, src.height, 0, src.format); RenderTexture bufferB RenderTexture.GetTemporary(src.width, src.height, 0, src.format); // 第一轮src - bufferA (初始状态) Graphics.Blit(src, bufferA); // 假设我们有一个需要迭代混合的过程例如模拟扩散 for (int i 0; i iterations; i) { // 乒乓交换将上一轮结果作为输入输出到另一个缓冲区 // 奇数轮: bufferA - bufferB // 偶数轮: bufferB - bufferA if (i % 2 0) { blendMaterial.SetTexture(_LastTex, bufferA); Graphics.Blit(src, bufferB, blendMaterial); // 将src和bufferA混合到bufferB } else { blendMaterial.SetTexture(_LastTex, bufferB); Graphics.Blit(src, bufferA, blendMaterial); // 将src和bufferB混合到bufferA } } // 最终结果在迭代结束后的活动缓冲区中假设iterations为偶数则在bufferA Graphics.Blit((iterations % 2 0) ? bufferA : bufferB, dest); // 释放两个缓冲区 RenderTexture.ReleaseTemporary(bufferA); RenderTexture.ReleaseTemporary(bufferB); }原理与意图因为GPU渲染是“写入即覆盖”你无法同时将一个纹理既作为输入又作为输出。乒乓缓冲通过两个RT轮换充当输入和输出角色来解决这个问题。效果与性能效果实现了需要历史帧数据或自身迭代反馈的复杂效果。性能需要双倍的RT内存以及额外的Blit开销。迭代次数越多开销越大。注意事项迭代次数控制乒乓操作通常在循环中必须严格控制迭代次数避免性能失控。纹理绑定每次Blit前记得通过Material.SetTexture正确更新Shader中代表“上一帧”或“另一个缓冲区”的纹理属性。释放顺序确保两个RT都被正确释放。3.5 组合五深度与法线纹理的采样DepthNormal Texture许多高级后处理如屏幕空间环境光遮蔽SSAO、边缘检测、软粒子需要场景的深度和法线信息。这需要相机渲染深度纹理。public Camera mainCamera; public Material depthBasedMaterial; // 一个使用_CameraDepthTexture的Shader材质 void Start() { // 关键启用相机的深度纹理模式 mainCamera.depthTextureMode | DepthTextureMode.DepthNormals; // 或者仅需要深度mainCamera.depthTextureMode | DepthTextureMode.Depth; } void OnRenderImage(RenderTexture src, RenderTexture dest) { if (depthBasedMaterial ! null) { // 在OnRenderImage中_CameraDepthTexture等内置全局纹理已经由Unity设置好 // 我们只需要直接Blit即可Shader中可以直接声明 sampler2D _CameraDepthTexture; Graphics.Blit(src, dest, depthBasedMaterial); } else { Graphics.Blit(src, dest); } }对应的Shader示例片段Shader Custom/DepthBasedEffect { Properties { ... } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; // 颜色纹理 sampler2D _CameraDepthNormalsTexture; // Unity提供的深度法线纹理 // 如果只设置了Depth则使用 sampler2D _CameraDepthTexture; struct v2f { ... }; v2f vert (appdata_img v) { ... } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); // 解码深度和法线 float depth; float3 normal; DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal); // 使用depth和normal进行后处理计算... // 例如根据深度做雾效根据法线做边缘增强 return processedCol; } ENDCG } } }原理与意图通过设置camera.depthTextureModeUnity会在渲染过程中额外生成一张包含深度和法线信息的纹理。在OnRenderImage中这张纹理会作为全局属性_CameraDepthTexture或_CameraDepthNormalsTexture提供给Shader。效果与性能效果解锁了基于场景几何信息的后处理效果效果质量高。性能这是有代价的。生成深度纹理意味着额外的渲染通道或利用Depth Prepass会增加GPU负担特别是移动端。DepthNormals比单纯的Depth开销更大。避坑指南按需启用不是所有相机都需要。只为真正需要深度后处理的相机设置depthTextureMode。精度问题深度纹理的精度是有限的在远距离时可能产生Z-fighting深度冲突导致的噪点。在Shader中计算世界空间位置时要注意。透明物体深度纹理通常不包含透明物体的深度信息因为它们在透明队列渲染。基于深度的后处理在透明物体处可能不准确。3.6 组合六可读写纹理EnableRandomWrite与Compute Shader联动当后处理算法极度复杂或者需要与Compute Shader计算着色器结合进行通用计算如粒子模拟、物理模拟、高级滤镜时就需要创建支持随机读写的RT。public ComputeShader computeEffect; public Material displayMaterial; void OnRenderImage(RenderTexture src, RenderTexture dest) { // 1. 创建一个支持随机读写的RenderTexture RenderTexture computeRT new RenderTexture(src.width, src.height, 0, RenderTextureFormat.ARGBFloat); computeRT.enableRandomWrite true; // 关键属性 computeRT.Create(); // 必须显式调用Create // 2. 将源纹理数据复制到计算纹理可选取决于Compute Shader需求 Graphics.Blit(src, computeRT); // 3. 设置Compute Shader参数并派发线程组 int kernelHandle computeEffect.FindKernel(CSMain); computeEffect.SetTexture(kernelHandle, Result, computeRT); computeEffect.SetFloat(deltaTime, Time.deltaTime); // 计算线程组大小假设每个线程处理一个像素 computeEffect.Dispatch(kernelHandle, Mathf.CeilToInt(src.width / 8.0f), Mathf.CeilToInt(src.height / 8.0f), 1); // 4. 将Compute Shader处理后的结果显示出来 // 注意此时computeRT已经由Compute Shader更新。我们可以用它作为材质输入。 displayMaterial.SetTexture(_ProcessedTex, computeRT); Graphics.Blit(src, dest, displayMaterial); // 可以将原图与处理图混合 // 5. 销毁RT因为是new出来的不是GetTemporary的 computeRT.Release(); }原理与意图普通的RT在Shader中只能通过采样sampler读取不能任意写入某个像素。enableRandomWrite true打破了这一限制允许Compute Shader或支持无序访问视图(UAV)的像素着色器对其进行任意位置的读写。这开启了GPU通用计算与图形渲染融合的大门。效果与性能效果可以实现传统像素着色器难以实现或效率低下的效果如复杂模拟、自定义数据结构处理。性能Compute Shader并行效率极高适合大数据并行处理。但enableRandomWrite的RT创建和管理开销比普通RT大且需要更现代的GPU支持Shader Model 5.0。重要警告必须手动管理生命周期通过new创建并设置enableRandomWrite的RT不受Unity缓存池管理。你必须自己调用Create()来分配资源并在使用完毕后调用Release()或Destroy()来释放资源否则会造成严重的内存泄漏。同步问题GPU计算是异步的。在Dispatch之后立即在CPU端读取RT数据如Texture2D.ReadPixels是未定义行为因为计算可能还没完成。通常需要通过AsyncGPUReadback或安排好在渲染队列中的顺序来保证同步。格式支持不是所有RenderTextureFormat都支持随机写入通常需要使用ARGBFloat、RGBAFloat等浮点格式。3.7 组合七混合渲染目标Multiple Render Targets, MRT模拟严格来说Unity的OnRenderImage接口本身不直接暴露MRT。但我们可以通过一些技巧在单个后处理Pass中向多个RT输出数据模拟MRT行为用于分离渲染元素如将颜色、亮度、特定ID分别输出。这通常需要更底层的CommandBuffer配合但在OnRenderImage的思维框架下可以理解为我们创建多个RT然后在Shader中通过多个Render Target Output同时写入它们。由于Graphics.Blit不支持MRT我们需要自己绘制一个全屏四边形。public Material mrtOutputMaterial; // Shader中声明了多个SV_Target void OnRenderImage(RenderTexture src, RenderTexture dest) { // 创建多个输出RT RenderTexture colorRT RenderTexture.GetTemporary(src.width, src.height, 0, RenderTextureFormat.ARGB32); RenderTexture brightnessRT RenderTexture.GetTemporary(src.width, src.height, 0, RenderTextureFormat.RHalf); // 单通道浮点存亮度 // 我们需要手动设置这些RT为渲染目标 // 这里简化演示实际中需要更复杂的设置可能涉及CommandBuffer // 以下代码仅为概念示意无法直接运行 // RenderBuffer[] rb { colorRT.colorBuffer, brightnessRT.colorBuffer }; // Graphics.SetRenderTarget(rb, colorRT.depthBuffer); // 然后使用 mrtOutputMaterial 绘制全屏四边形... // 更实际的OnRenderImage方案分两次Blit到不同的RT但这并非真正的单Pass MRT。 // 方案A使用Shader变体一次输出到一个RT通过开关控制。 // 方案B如果真需要高效MRT应考虑使用CommandBuffer插入到相机渲染管线中而非OnRenderImage。 // 假设我们分两次处理 Graphics.Blit(src, colorRT, mrtOutputMaterial, 0); // Pass 0 输出颜色 Graphics.Blit(src, brightnessRT, mrtOutputMaterial, 1); // Pass 1 输出亮度 // 后续可以使用colorRT和brightnessRT进行其他合成... Graphics.Blit(colorRT, dest); RenderTexture.ReleaseTemporary(colorRT); RenderTexture.ReleaseTemporary(brightnessRT); }原理与意图模拟单Pass内输出多个图像数据的能力减少渲染通道提升带宽利用率。常用于延迟渲染、光线追踪的G-Buffer生成或后处理中的多信息提取。效果与性能效果可以高效地分离和存储不同的渲染属性。性能真正的MRT能减少Pass数量提升性能。但在OnRenderImage框架下完美模拟比较困难通常意味着需要更底层的渲染控制。实战建议如果你在Built-in管线中迫切需要MRT进行后处理更推荐研究CommandBuffer的Blit方法接受一个RenderTargetIdentifier数组或者考虑升级到URP/HDRP它们对MRT有更好的原生支持。OnRenderImage更适合做全屏图像处理而非复杂的多目标渲染。4. 性能分析与实战避坑指南理解了7种组合我们还需要从全局视角评估性能并避开那些常见的“坑”。4.1 性能开销排序与选型建议粗略的性能开销排序从低到高组合一直接Blit开销最小仅一次全屏绘制。首选方案。组合三降采样通过降低分辨率大幅减少像素计算量净性能收益通常为正。强烈推荐的优化手段。组合二临时RT增加一次RT内存和Blit开销。在需要多Pass时无法避免。组合五深度纹理额外的深度渲染通道开销。按需启用。组合四双缓冲双倍RT内存多次Blit。迭代次数是性能放大器。组合七模拟MRT取决于实现方式分多Pass模拟则开销大真MRT则开销小但实现复杂。组合六Compute Shader RTRT创建开销大但Compute Shader本身并行效率高。适用于将复杂计算从CPU卸载到GPU的场景。特定场景的利器。选型心法从最简单的组合一开始尝试。问自己三个问题我的效果需要中间结果吗是 - 组合二我的计算非常昂贵吗是 - 考虑组合三降采样我的效果需要场景几何信息吗是 - 组合五并承受其开销我的算法需要迭代或历史数据吗是 - 组合四我的效果传统着色器做不了或效率太低吗是 - 研究组合六4.2 常见问题排查与修复实录问题1后处理导致屏幕变黑或闪烁。排查首先检查材质球Material是否赋值Shader是否编译正确。在OnRenderImage开头加入Debug.Log(src - dest)确认参数不为空。检查相机是否同时启用了MSAA和HDR与RT格式是否冲突。修复确保材质有效。对于src或dest可能为null的情况某些特殊相机做安全判断if (src null || dest null) return;。问题2移动设备上后处理耗电发热严重。排查使用Unity Profiler的GPU模块查看后处理Pass的耗时。检查是否使用了高精度格式如ARGBHalf、过高的分辨率、或未释放的临时RT。修复强制降采样移动端默认使用组合三将分辨率降至一半甚至四分之一。简化Shader减少复杂数学运算、纹理采样次数和分支判断。格式降级非HDR场景使用ARGB32代替ARGBHalf。合批将多个简单的后处理效果合并到一个Shader的多个Pass中减少Blit次数。问题3后处理效果在Scene视图正常Game视图异常。排查OnRenderImage在Scene视图和Game视图的相机上都会调用。检查你的效果是否依赖于某些只在Game模式初始化的变量。检查OnRenderImage中是否有依赖于Time.time等变量但未考虑不同相机调用顺序的逻辑。修复使用camera.gameObject.CompareTag(MainCamera)来限定只对主相机生效或者通过camera.cameraType来判断。问题4使用了深度纹理但透明物体后面的效果不对。原因深度纹理不包含透明物体的深度信息。缓解对于依赖于深度的后处理如雾效可以考虑将重要的透明物体写入深度修改其Shader的ZWrite为On并小心处理Alpha Test。或者接受这个限制将其作为艺术风格的一部分。问题5临时RT导致的内存泄漏Memory Leak。症状游戏运行一段时间后内存持续增长在Profiler的Memory模块中看到大量未释放的RenderTexture。根治确保每一个RenderTexture.GetTemporary()调用都在同一帧内有对应的RenderTexture.ReleaseTemporary()调用。将其写在finally块或using语句如果封装成可Dispose对象中是良好实践。对于enableRandomWrite的RT确保调用Release()。5. 从Built-in到可编程管线OnRenderImage的定位思考最后聊聊OnRenderImage的现状与未来。在Built-in渲染管线中它是自定义全屏后处理的“标准入口”简单直接。但它的局限性也很明显插入点固定、对渲染管线控制力弱、难以实现基于多Pass的复杂效果链。SRP可编程渲染管线包括URP和HDRP的兴起提供了更强大、更灵活的RenderPass系统。你可以精确控制Pass的执行时机在Opaque渲染后在Transparent渲染前可以轻松管理多个RT原生支持MRT并且能更好地与管线的其他阶段如阴影、光照集成。那么现在学习OnRenderImage还有意义吗我的答案是绝对有。它是理解“全屏后处理”这一概念最直观的模型。Graphics.Blit、RenderTexture的创建与释放、深度纹理的使用、多缓冲区的思想这些核心知识在SRP中依然通用只是API包装不同。掌握了这些底层原理你再去看URP的FullScreen Pass Renderer Feature或HDRP的Custom Post Process会发现它们不过是更优雅、更强大的“升级版”工具。从OnRenderImage起步扎实地理解这7种组合是你构建任何复杂后处理效果时脚下最稳固的基石。