Unity屏幕后处理实战:亮度、饱和度、对比度调节全解析
1. 项目概述从屏幕到画布用代码雕琢视觉做Unity开发尤其是涉及UI、特效或者需要营造特定氛围的游戏时我们常常会感叹要是这里的颜色能再鲜艳一点就好了或者这个场景的明暗对比能再强烈一些整个感觉就出来了。直接去调美术资源那太笨重了而且无法动态变化。这时候屏幕后处理技术就该登场了。它就像给游戏世界的“最终成像”加了一层可编程的滤镜在不修改原始模型、贴图和灯光的前提下实时地调整整个屏幕画面的视觉效果。今天要聊的就是其中最基础也最实用的一个滤镜组合亮度、饱和度、对比度调节。别看名字基础它们是所有高级色彩效果如色调映射、风格化渲染的基石。通过Unity内置的OnRenderImage函数和自定义Shader我们可以像在Photoshop里调整图层一样自由地操控游戏画面的色彩属性。无论是想让阴森的洞穴更暗让阳光下的草原色彩更饱和还是为了叙事需要瞬间切换为高对比度的黑白画面这套技术都能轻松实现。这篇文章我将从一个实际开发者的角度带你从零开始完整实现这套屏幕滤镜系统。我会解释清楚每一步背后的“为什么”而不仅仅是“怎么做”。你会学到如何搭建后处理框架如何编写核心的Shader代码以及如何将它们串联成一个稳定、高效且易于使用的组件。文末会提供完整的、可直接复用的C#和Shader代码。无论你是刚接触Shader的新手还是想系统梳理后处理流程的老手相信都能有所收获。2. 核心架构与设计思路拆解在动手写代码之前我们必须先理解Unity屏幕后处理的基本工作流。这就像一条生产线摄像机捕捉到3D场景经过一系列渲染管线剔除、光照、着色等生成一张原始的“照片”这张照片就是渲染纹理Render Texture。屏幕后处理就是在这张“照片”被最终显示到屏幕之前对它进行的最后一次加工。2.1 为什么是OnRenderImageUnity为我们提供了OnRenderImage这个MonoBehaviour生命周期函数它是接入这条“加工流水线”的标准接口。当摄像机完成所有渲染准备将图像提交给显示设备时OnRenderImage会被调用。它的函数签名是void OnRenderImage(RenderTexture source, RenderTexture destination)source: 输入纹理即摄像机渲染完成的原始图像。destination: 输出纹理最终要显示的画面。如果我们不进行任何处理最简单的做法就是调用Graphics.Blit(source, destination)这相当于把原图直接拷贝到输出。OnRenderImage的魅力在于我们可以在这个环节插入自己的处理逻辑。通过Graphics.Blit函数并传入一个自定义的材质Material我们就能用Shader对source纹理进行各种像素级操作然后将结果写入destination。这个过程是全屏的意味着会对屏幕上的每一个像素都应用我们的Shader计算。注意OnRenderImage在Built-in渲染管线中是标准做法。如果你使用的是URP通用渲染管线或HDRP高清渲染管线官方推荐使用Renderer Features和Volume框架来实现后处理其底层原理相通但接入方式更现代化、更易与管线设置集成。本文基于Built-in管线讲解因其原理最为直观是理解后处理的绝佳起点。2.2 Shader图像处理的“核芯”算法如果说OnRenderImage是流水线的控制台那么Shader就是执行具体加工任务的“机器手”。对于亮度、饱和度、对比度调节我们需要一个片段着色器Fragment Shader在Unity ShaderLab中通常写在surf函数或frag函数中。其核心思路是对输入的每个像素颜色值一个RGB向量可能还包含Alpha应用一系列数学变换。这些变换需要是逐像素独立且无状态的即输出颜色只依赖于当前输入颜色和几个全局参数亮度、饱和度、对比度系数不依赖周围像素或其他帧的信息。这保证了处理的高效性。亮度最简单通常是对RGB三个通道同时进行乘法或加法运算。饱和度涉及将颜色从RGB色彩空间转换到更能分离亮度与色度的空间如HSV或YCbCr调整色度分量后再转换回来。我们通常会采用一种在RGB空间内直接计算亮度然后进行插值的更高效方法。对比度需要定义一个中间灰阶如0.5让比它亮的更亮比它暗的更暗本质上是一个基于灰阶的缩放操作。设计Shader时我们需要在效果、性能和灵活性之间权衡。一个完整的后处理Shader通常包含属性定义、顶点/片段着色器、以及用于多平台兼容的编译指令。2.3 工程结构设计一个健壮的后处理系统不应该把所有的逻辑和参数都堆在一个脚本里。我推荐的结构如下一个核心C#脚本如BrightnessSaturationContrast.cs继承自MonoBehaviour挂载在摄像机上。负责声明亮度、饱和度、对比度等可调节的参数。在OnRenderImage中检查条件、创建临时渲染纹理、调用Graphics.Blit。提供参数验证如确保材质已创建。一个Shader文件如BrightnessSaturationContrast.shader包含完整的着色器代码定义属性、子着色器、Pass以及核心的色彩变换函数。一个材质球Material在运行时由C#脚本根据Shader动态创建作为参数传递给Graphics.Blit。我们不建议在编辑器中手动拖拽赋值以避免资源引用丢失的问题。这种分离使得Shader开发者和脚本逻辑开发者可以相对独立地工作也便于复用和调试。3. 核心细节解析与实操要点理解了框架我们来深入每个环节的细节这里有很多值得注意的“坑”和技巧。3.1 OnRenderImage的执行时机与性能OnRenderImage在每一帧都会调用这意味着我们的后处理操作是每帧执行的。因此性能是首要考虑因素。避免每帧新建材质和纹理这是一个常见的性能陷阱。正确的做法是在Start或OnEnable方法中检查并创建所需的材质和纹理在OnDisable中释放它们。在OnRenderImage中只进行使用和参数传递。利用临时渲染纹理Graphics.Blit会自动处理输入输出。但如果你的效果需要多个Pass比如先模糊再调色就需要创建中间临时纹理。务必使用RenderTexture.GetTemporary和RenderTexture.ReleaseTemporary来管理Unity会高效地复用这些纹理内存。早期退出如果所有调节参数都处于默认值即不做任何改变我们应该直接Graphics.Blit(source, destination)并返回跳过不必要的Shader计算。即使参数有微小变化也要评估其视觉影响有时可以通过阈值判断来跳过。3.2 色彩调节的数学原理这是Shader部分的核心。我们来实现一个在RGB空间内高效计算亮度、饱和度、对比度的算法。1. 亮度Brightness亮度调整最简单通常使用乘法finalColor originalColor * brightness其中brightness是一个标量。1.0表示原样输出大于1.0变亮小于1.0变暗。有时也会加入一个偏移量加法来调整绝对明暗但乘法更符合感知。2. 饱和度Saturation饱和度的调整需要先计算当前像素的“亮度”或“明度”Luminance然后让颜色向量向这个灰度值靠拢或远离。首先计算灰度值。一个常用的亮度公式是luminance dot(originalColor, float3(0.2126, 0.7152, 0.0722))。这个权重来自人眼对绿光最敏感红光次之蓝光最不敏感。然后在原始颜色和灰度值之间进行线性插值LerpfinalColor lerp(gray, originalColor, saturation)当saturation为1时finalColor originalColor为0时finalColor gray完全去色成黑白大于1时颜色会过度饱和。3. 对比度Contrast对比度调整需要一个中间灰阶点通常为0.5然后以此为中心进行缩放。首先将颜色值从[0,1]范围转换到以0.5为中心的范围adjustedColor originalColor - 0.5。然后进行缩放adjustedColor * contrast。最后转换回去并钳制到[0,1]finalColor clamp(adjustedColor 0.5, 0, 1)。 当contrast为1时不变大于1时差异被放大高对比度在0到1之间时差异被缩小低对比度为0时所有颜色都变成0.5的灰色。这三个操作有顺序吗通常我们按亮度 - 饱和度 - 对比度的顺序应用。因为调整亮度可能会改变颜色的整体明度进而影响饱和度计算中的灰度感知。而对比度操作通常放在最后因为它会剧烈地改变颜色的分布。这个顺序并非绝对但符合大多数图像处理软件的流程视觉效果也最直观。3.3 Shader编写的关键技巧使用Properties块暴露参数这样C#脚本可以通过material.SetFloat(“_PropertyName”, value)来动态修改。精确定义精度在移动平台使用half或fixed来代替float处理颜色值可以提升性能。但要注意精度损失。包含必要的编译指令#pragma vertex vert和#pragma fragment frag是指定着色器程序的必须指令。对于后处理我们通常使用一个简单的顶点着色器仅仅传递纹理坐标。处理Gamma和Linear空间这是一个大坑如果你的项目使用的是Gamma颜色空间那么颜色值就是直接可用的。但如果使用的是Linear颜色空间更物理真实你在Shader中进行计算前可能需要对输入的sRGB纹理进行解码pow(color, 2.2)近似在输出前再进行编码pow(color, 1/2.2)。更规范的做法是使用UnityCG.cginc中的GammaToLinearSpace和LinearToGammaSpace函数或者确保你的Shader中所有计算都在Linear空间进行通过UNITY_COLORSPACE_GAMMA宏来判断。为了简化本文示例默认在Gamma空间工作但你必须清楚自己项目的设置。使用Fallback在Shader最后加上Fallback Off防止Unity找不到合适的替代Shader时使用不正确的默认Shader导致效果错误或性能下降。4. 实操过程与核心环节实现接下来我们一步步把理论变成代码。我会先给出完整的代码然后解释关键部分。4.1 完整的Shader代码实现创建一个新的Shader文件命名为BrightnessSaturationContrast.shader。Shader Hidden/BrightnessSaturationContrast { Properties { _MainTex (Base (RGB), 2D) white {} // 主纹理由OnRenderImage传入 _Brightness (Brightness, Float) 1.0 // 亮度默认1.0 _Saturation (Saturation, Float) 1.0 // 饱和度默认1.0 _Contrast (Contrast, Float) 1.0 // 对比度默认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 _Saturation; float _Contrast; // 简单的顶点着色器将模型空间顶点转换到裁剪空间并传递UV 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); // 应用亮度 (乘法操作) fixed3 finalColor col.rgb * _Brightness; // 应用饱和度 // 1. 计算亮度值使用标准权重 fixed luminance 0.2126 * finalColor.r 0.7152 * finalColor.g 0.0722 * finalColor.b; // 2. 创建灰度颜色 fixed3 luminanceColor fixed3(luminance, luminance, luminance); // 3. 在灰度与彩色之间线性插值 finalColor lerp(luminanceColor, finalColor, _Saturation); // 应用对比度 // 1. 以0.5为中心 finalColor finalColor - 0.5; // 2. 缩放 finalColor * _Contrast; // 3. 移回并钳制到[0,1]范围 finalColor finalColor 0.5; finalColor saturate(finalColor); // saturate相当于clamp(x, 0, 1) // 返回处理后的颜色保持原始Alpha通道不变 return fixed4(finalColor, col.a); } ENDCG } } Fallback Off }代码解读与技巧Shader路径Hidden/BrightnessSaturationContrast。以Hidden/开头的Shader不会出现在材质球的选择列表里这很合适因为我们通常不手动创建这个材质。SubShader设置Cull Off ZWrite Off ZTest Always是后处理Shader的标准配置。关闭背面剔除和深度写入并且让深度测试永远通过确保全屏四边形能正确渲染。亮度计算简单的乘法。你可以尝试finalColor col.rgb * _Brightness (_Brightness - 1.0) * 0.1;这种带偏移的公式来获得不同的亮度曲线但乘法最直观。饱和度计算我们使用了lerp函数。当_Saturation为0时结果是完全的luminanceColor黑白为1时是原始的finalColor大于1时lerp函数会进行线性外推颜色会过度饱和可能产生超出[0,1]范围的值所以最后需要saturate。对比度计算saturate函数是CG/HLSL内置的比clamp更方便。确保颜色值在有效范围内防止出现“过曝”或“死黑”区域出现不正常的颜色。Alpha通道我们直接保留了原始颜色的Alpha通道 (col.a)。在大多数屏幕后处理中Alpha通道意义不大因为最终输出到屏幕的是RGB。但保留它是一个好习惯。4.2 完整的C#脚本实现创建一个C#脚本命名为BrightnessSaturationContrast.cs并挂载到需要进行后处理的摄像机上。using UnityEngine; [ExecuteInEditMode] // 允许在编辑器模式下预览效果 [RequireComponent(typeof(Camera))] // 确保挂载对象有Camera组件 public class BrightnessSaturationContrast : MonoBehaviour { [Range(0.0f, 3.0f)] public float brightness 1.0f; // 亮度参数范围0-3 [Range(0.0f, 3.0f)] public float saturation 1.0f; // 饱和度参数范围0-3 [Range(0.0f, 3.0f)] public float contrast 1.0f; // 对比度参数范围0-3 // 运行时动态创建的材质 private Material _material; // Shader的属性ID用于高效设置参数 private static readonly int BrightnessShaderID Shader.PropertyToID(_Brightness); private static readonly int SaturationShaderID Shader.PropertyToID(_Saturation); private static readonly int ContrastShaderID Shader.PropertyToID(_Contrast); // 用于创建材质的Shader public Shader shader; // 材质属性确保材质被正确创建和销毁 private Material material { get { if (_material null) { if (shader null || !shader.isSupported) { Debug.LogWarning(Shader is missing or not supported.); return null; } _material new Material(shader); _material.hideFlags HideFlags.HideAndDontSave; // 不保存到场景防止资源泄露 } return _material; } } void Start() { // 检查Shader是否支持如果编辑器里没赋值尝试查找 if (shader null) { shader Shader.Find(Hidden/BrightnessSaturationContrast); } if (shader null || !shader.isSupported) { this.enabled false; // 禁用脚本 Debug.LogError(BrightnessSaturationContrast shader is not found or not supported on this platform.); } } // 每一帧渲染完成后调用 void OnRenderImage(RenderTexture source, RenderTexture destination) { // 如果材质创建失败或者所有参数都是默认值直接拷贝原图 if (material null || (Mathf.Approximately(brightness, 1.0f) Mathf.Approximately(saturation, 1.0f) Mathf.Approximately(contrast, 1.0f))) { Graphics.Blit(source, destination); return; } // 将参数传递给Shader material.SetFloat(BrightnessShaderID, brightness); material.SetFloat(SaturationShaderID, saturation); material.SetFloat(ContrastShaderID, contrast); // 执行后处理Blit操作 Graphics.Blit(source, destination, material); } // 脚本禁用或对象销毁时清理动态创建的材质 void OnDisable() { if (_material ! null) { DestroyImmediate(_material); } _material null; } }代码解读与技巧[ExecuteInEditMode]这个属性非常有用它允许脚本在编辑器非运行状态下也执行OnRenderImage。这样你直接在Inspector面板上滑动brightness、saturation、contrast的滑块就能实时在Scene视图或Game视图需要摄像机渲染中看到效果极大方便了参数调试。[Range]属性它为Inspector中的公共变量添加了一个滑动条并限制了输入范围。这比直接输入float更友好也防止了设置不合理的值。材质属性property我们使用一个属性property来封装材质的获取逻辑。它在第一次访问时创建材质并设置了HideFlags.HideAndDontSave。这非常重要它意味着这个材质是纯运行时动态生成的不会保存到场景或预制件中避免了资源管理混乱。Shader.PropertyToID在Start方法中我们预计算了Shader中属性的ID。在OnRenderImage中使用这个IDBrightnessShaderID来设置参数比直接传递字符串如“_Brightness”效率高得多因为避免了每次调用时的字符串哈希计算。早期退出优化在OnRenderImage开头我们检查了材质是否存在以及所有参数是否都接近默认值1.0使用Mathf.Approximately处理浮点数精度。如果是就直接Graphics.Blit(source, destination)跳过后处理节省性能。资源清理在OnDisable中我们销毁动态创建的材质。这是一个好习惯防止内存泄漏。使用DestroyImmediate是因为在编辑模式下也需要正确销毁。4.3 在Unity编辑器中的设置与使用将BrightnessSaturationContrast.cs脚本拖到主摄像机上。在摄像机的Inspector面板你会看到脚本组件和三个滑块。你需要将之前创建的BrightnessSaturationContrast.shader文件拖拽到脚本的Shader变量槽中。或者你可以保持它为None脚本会在Start时通过Shader.Find自动查找名为Hidden/BrightnessSaturationContrast的Shader。确保摄像机没有启用MSAA抗锯齿或者启用了HDR。在Built-in管线中屏幕后处理通常需要在无MSAA的渲染纹理上工作或者需要特殊的处理。对于这个简单的色彩调整Shader通常没问题但如果遇到奇怪的效果如边缘闪烁可以尝试在摄像机上关闭MSAA。现在运行游戏或者在编辑器模式下因为有了[ExecuteInEditMode]直接拖动滑块你就能实时看到屏幕亮度、饱和度和对比度的变化了5. 常见问题与排查技巧实录在实际使用中你可能会遇到一些问题。下面是我总结的一些常见情况及解决方法。5.1 效果不生效或屏幕变黑/变紫这是最常见的问题。检查1Shader编译是否成功在Project窗口中选中你的Shader文件查看Inspector底部的编译信息和错误。如果有错误通常是红色感叹号Shader就无法使用。检查语法错误特别是CGPROGRAM/ENDCG块内的代码。检查2材质是否成功创建在脚本的Start或OnRenderImage方法开始处添加Debug.Log(material);查看材质是否为null。如果是null可能是Shader路径不对或Shader不支持当前平台。确保脚本中Shader.Find的路径与Shader文件中的Shader “路径/名称”完全一致。检查3参数传递是否正确在Shader的frag函数开头添加return fixed4(_Brightness, _Saturation, _Contrast, 1);临时将屏幕颜色替换为参数值。如果屏幕显示的颜色随着滑块变化说明参数传递成功问题出在后面的色彩计算逻辑。如果屏幕是固定颜色说明参数没传进来检查C#脚本中SetFloat使用的属性名是否与Shader中Properties块定义的名称完全一致包括下划线。检查4渲染纹理格式如果你的项目开启了HDR高动态范围摄像机渲染的source纹理可能是RenderTextureFormat.ARGBHalf或ARGBFloat格式。而我们的Shader默认处理的是0-1范围的fixed或half精度颜色值。对于HDR颜色值可能超过1。简单的色彩调整公式可能产生异常结果。一个快速的测试是临时在摄像机设置中关闭HDR看效果是否正常。如果必须用HDR需要在Shader中做色调映射Tone Mapping将HDR值压缩到LDR范围这属于更高级的后处理。5.2 性能开销过大后处理每帧执行对性能敏感尤其是在移动设备上。优化1减少不必要的绘制调用确保你的脚本包含了“早期退出”逻辑当参数为默认值时跳过。这对于移动端或WebGL平台至关重要。优化2简化Shader计算检查Shader中是否有多余的计算。我们的示例Shader已经非常精简。考虑将亮度、饱和度、对比度的计算合并到一个更优化的公式中减少中间变量和计算步骤。不过对于现代GPU这点优化微乎其微代码清晰更重要。确保使用的数据精度合适。对于颜色fixed或half通常足够。优化3降低分辨率对于全屏后处理一个巨大的性能提升点是降低处理分辨率。你可以在OnRenderImage中不直接Blit到destination而是先Blit到一个更低分辨率的临时渲染纹理然后再将这个低分辨率的结果上采样缩放到目标分辨率。这对于模糊、Bloom等效果特别有效但对于色彩调整可能会带来明显的模糊感需谨慎使用。void OnRenderImage(RenderTexture source, RenderTexture destination) { if (material null || /* 参数检查 */) { Graphics.Blit(source, destination); return; } // 创建宽度高度各一半的临时纹理 int rtW source.width / 2; int rtH source.height / 2; RenderTexture buffer RenderTexture.GetTemporary(rtW, rtH, 0); buffer.filterMode FilterMode.Bilinear; // 第一次Blit降采样处理 Graphics.Blit(source, buffer, material); // 第二次Blit上采样到目标 Graphics.Blit(buffer, destination); RenderTexture.ReleaseTemporary(buffer); }5.3 在编辑器模式下效果预览异常问题在Scene视图能看到效果但在Game视图看不到或者反过来。原因OnRenderImage只对挂载脚本的摄像机生效。在编辑器模式下Scene视图和Game视图可能使用不同的摄像机。解决确保脚本挂载在正在渲染你所要观察的视图的摄像机上。对于Game视图通常是主摄像机。对于Scene视图它是场景预览摄像机你无法直接挂脚本。[ExecuteInEditMode]主要影响的是脚本所在摄像机的渲染结果。如果想在Scene视图也看到效果需要更复杂的编辑器脚本扩展这超出了基础范围。一个简单的测试方法是在Game视图运行或者将摄像机对准一个容易观察颜色变化的物体。5.4 与其他后处理效果的叠加顺序如果你有多个后处理效果比如先做模糊再调色最后加晕影顺序很重要。实现方式通常你会为每种效果写一个单独的脚本和Shader。这些脚本按顺序挂载在同一个摄像机上。Unity会按照脚本在Inspector中的顺序从上到下依次调用它们的OnRenderImage方法。技巧你可以创建一个“后处理管理器”脚本来管理和排序这些效果并控制它们的启用/禁用。但简单的叠加直接调整脚本顺序即可。记住前一个效果的destination会成为下一个效果的source。5.5 完整代码整合与使用清单为了确保你能一次成功这里提供一个快速检查清单创建Shader文件将4.1节的代码完整复制保存为BrightnessSaturationContrast.shader。创建C#脚本文件将4.2节的代码完整复制保存为BrightnessSaturationContrast.cs。场景设置将BrightnessSaturationContrast.cs脚本拖到主摄像机Main Camera上。可选将BrightnessSaturationContrast.shader拖到脚本的Shader变量槽。不拖拽脚本也会自动查找。确保主摄像机的Allow MSAA选项关闭对于简单色彩调整开启通常也无妨但关闭可排除一个潜在问题。运行与测试进入Play模式或直接在编辑器模式下。选中主摄像机在Inspector中调整Brightness、Saturation、Contrast滑块。观察Game视图或该摄像机渲染的视图中画面的变化。如果按照以上步骤操作你应该能立即看到一个功能完整的、可实时调节的屏幕色彩滤镜效果。这个小小的系统是你进入Unity屏幕后处理世界的第一块坚实的基石。理解了它你就能更容易地理解更复杂的景深、运动模糊、Bloom等效果的实现原理因为它们都共享着OnRenderImageShaderGraphics.Blit这一核心模式。