尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity URP后处理实战:亮度、饱和度、对比度滤镜开发指南

Unity URP后处理实战:亮度、饱和度、对比度滤镜开发指南 1. 项目概述从“所见即所得”到“所见即所调”在游戏开发中我们常常不满足于引擎默认渲染出的画面。无论是想营造复古的CRT显示器扫描线效果还是打造冷峻的赛博朋克色调亦或是简单地让画面看起来更“通透”直接修改场景中成千上万个物体的材质和灯光是不现实的。这时“后处理”技术就成了我们的利器。它像是一个位于最终渲染画面和玩家屏幕之间的万能滤镜层允许我们对整个屏幕图像进行二次加工。今天要聊的就是其中最基础、最实用也是几乎所有风格化画面起点的一套组合拳亮度、饱和度、对比度调整。这不仅仅是三个简单的滑块更是你掌控游戏视觉情绪的基石。通过编写一个后处理Shader我们可以实现运行时一键调整让同一套美术资源焕发出截然不同的生命力无论是适配不同设备显示效果还是快速切换游戏内“白天/黑夜”模式都变得轻而易举。2. 核心原理与管线选择URP还是Built-in在动手写代码之前我们必须先搞清楚Unity的渲染管线因为这直接决定了我们的Shader怎么写、脚本怎么挂。Unity目前主要有两种管线传统的Built-in Render Pipeline和现代的Universal Render Pipeline。2.1 两种管线的后处理实现差异对于后处理两种管线的核心思想一致在摄像机完成所有不透明和透明物体渲染后将最终图像存储在名为_CameraColorTexture的渲染目标中传递给我们自定义的Shader进行处理。但它们的接入方式截然不同。在Built-in管线中我们通常通过编写一个继承自MonoBehaviour的脚本并实现OnRenderImage方法。在这个方法里我们使用Graphics.Blit函数将源纹理src和我们材质Material中的Shader进行处理输出到目标纹理dest。这种方式直接、古老但灵活。而在URP中过程更模块化和可配置。我们需要创建一个ScriptableRendererFeature如Blit并将其添加到URP Asset的Renderer中。这个Feature会在渲染管线的特定注入点如AfterRenderingPostProcessing执行一个ScriptableRenderPass。在这个Pass中我们使用cmd.Blit或BlitterAPI来完成纹理拷贝和Shader处理。URP方式更复杂但能与URP自身的体积系统、渲染器缩放等特性更好地集成也是未来的方向。为什么选择URP除非你的项目是遗留项目或对Built-in有强依赖否则新项目强烈建议从URP开始。它性能更好支持更多现代图形特性如SRP Batcher并且后处理框架更健壮。本文的实战将以URP 14.x (对应Unity 2022.3 LTS)版本为例进行讲解因为这是目前长期支持且生态稳定的版本。理解了URP的实现回头看Built-in会非常简单。2.2 屏幕后处理Shader的本质无论管线如何后处理Shader的核心都是一个片段着色器。它的任务很简单对于屏幕上的每一个像素即片段根据该像素在源纹理中的颜色值通过我们定义的数学公式计算出一个新的颜色值输出。一个最基础的后处理Shader结构如下顶点着色器通常非常简单就是计算将整个屏幕覆盖的四边形两个三角形的顶点位置和纹理坐标。片段着色器接收从顶点着色器传递来的纹理坐标用这个坐标对源纹理屏幕图像进行采样得到原始颜色col。然后对col应用亮度、饱和度、对比度的变换公式得到最终颜色并返回。这个Shader会被包装在一个材质球中由我们的C#脚本或URP Renderer Feature在正确的时机调用。3. 实战构建URP下的亮度、饱和度、对比度滤镜我们从头开始一步步构建这个滤镜系统。3.1 第一步创建后处理Shader与材质首先在Unity项目中创建一个Shader文件。在URP中我们通常使用Unlit Shader模板作为后处理Shader的起点因为它不包含复杂的光照模型。// 文件ScreenFilter.shader Shader Hidden/ScreenFilter { Properties { _MainTex (Texture, 2D) white {} _Brightness (Brightness, Range(0.0, 3.0)) 1.0 _Saturation (Saturation, Range(0.0, 3.0)) 1.0 _Contrast (Contrast, Range(0.0, 3.0)) 1.0 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Name ScreenFilterPass ZTest Always ZWrite Off Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; // 虽然后处理通常用不到_ST但保留以兼容 float _Brightness; float _Saturation; float _Contrast; v2f vert (appdata v) { v2f o; o.vertex TransformObjectToHClip(v.vertex.xyz); o.uv v.uv; return o; } float3 ApplyBrightnessContrastSaturation(float3 color) { // 1. 应用亮度直接乘系数 color * _Brightness; // 2. 应用饱和度 float luminance dot(color, float3(0.2126, 0.7152, 0.0722)); // 标准灰度公式 color lerp(luminance.rrr, color, _Saturation); // 3. 应用对比度 float midpoint 0.5; // 对比度调整的中间点通常为0.5 color (color - midpoint) * _Contrast midpoint; return color; } half4 frag (v2f i) : SV_Target { half4 col tex2D(_MainTex, i.uv); col.rgb ApplyBrightnessContrastSaturation(col.rgb); return col; } ENDHLSL } } }关键点解析Tags { RenderPipelineUniversalPipeline }声明此Shader用于URP管线。ZTest Always ZWrite Off Cull Off这是后处理Pass的标准配置。ZTest Always确保这个Pass永远执行不进行深度测试ZWrite Off禁止写入深度Cull Off关闭背面剔除因为全屏四边形没有“背面”。#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl引入URP的核心HLSL库其中包含了TransformObjectToHClip等常用函数。饱和度计算我们首先计算颜色的亮度Luminance使用经典的0.2126, 0.7152, 0.0722权重符合人眼对绿光最敏感的特性。然后使用lerp函数在原始颜色color和灰度值luminance.rrr之间进行插值。当_Saturation为1时结果为color为0时结果为luminance完全去色大于1时颜色会更鲜艳。对比度计算公式(color - midpoint) * _Contrast midpoint是线性对比度调整的经典实现。当_Contrast1时颜色不变大于1时颜色值会向两端0和1拉伸中间调midpoint附近变化大对比增强小于1时颜色值向中间点压缩对比减弱画面变灰。创建完Shader后在Project窗口右键该Shader文件选择Create - MaterialUnity会自动生成一个使用该Shader的材质球我们将其命名为Mat_ScreenFilter。3.2 第二步创建URP Renderer Feature这是URP后处理的核心接入点。我们需要创建一个C#脚本定义如何将我们的Shader插入到URP的渲染流程中。// 文件ScreenFilterFeature.cs using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class ScreenFilterFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material null; public RenderPassEvent renderPassEvent RenderPassEvent.AfterRenderingPostProcessing; } public Settings settings new Settings(); private ScreenFilterPass _renderPass; public override void Create() { _renderPass new ScreenFilterPass(settings.material, settings.renderPassEvent); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.material null) { Debug.LogWarning(ScreenFilterFeature: Material is not assigned.); return; } renderer.EnqueuePass(_renderPass); } protected override void Dispose(bool disposing) { _renderPass?.Dispose(); } private class ScreenFilterPass : ScriptableRenderPass { private Material _material; private RTHandle _cameraColorTarget; private RTHandle _tempTexture; public ScreenFilterPass(Material material, RenderPassEvent renderPassEvent) { this.renderPassEvent renderPassEvent; _material material; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { // 获取当前相机的颜色纹理句柄 _cameraColorTarget renderingData.cameraData.renderer.cameraColorTargetHandle; // 分配一个临时的RTHandle用于Blit操作 RenderTextureDescriptor descriptor renderingData.cameraData.cameraTargetDescriptor; descriptor.depthBufferBits 0; // 后处理不需要深度 RenderingUtils.ReAllocateIfNeeded(ref _tempTexture, descriptor, name: _TempScreenFilterTexture); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (_material null || renderingData.cameraData.isSceneViewCamera) { // 场景预览相机可能不需要后处理或者Material为空时跳过 return; } CommandBuffer cmd CommandBufferPool.Get(ScreenFilterPass); // 核心Blit操作将相机颜色纹理通过我们的材质处理输出到临时纹理再拷贝回来 Blitter.BlitCameraTexture(cmd, _cameraColorTarget, _tempTexture, _material, 0); Blitter.BlitCameraTexture(cmd, _tempTexture, _cameraColorTarget); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { // 清理资源可选由RTHandle系统管理亦可 } public void Dispose() { _tempTexture?.Release(); } } }关键点解析ScriptableRendererFeature这是URP中自定义渲染功能的入口类。一个Feature可以包含多个ScriptableRenderPass。RenderPassEvent决定了这个Pass在渲染流程中的执行时机。AfterRenderingPostProcessing是一个常用选择它确保在所有内置的URP后处理如Bloom, Tonemapping之后执行这样我们的滤镜效果会叠加在它们之上。你也可以选择BeforeRenderingPostProcessing或AfterRendering等。RTHandleURP推荐使用的渲染纹理句柄它比直接使用RenderTexture更高效能更好地处理动态分辨率缩放。Blitter.BlitCameraTexture这是URP 14.x中推荐的Blit方法它自动处理了纹理坐标的Y方向翻转等问题不同图形API的纹理坐标系可能不同比直接使用cmd.Blit更可靠。执行流程OnCameraSetup中分配临时纹理Execute中执行实际的绘制命令先将相机原图_cameraColorTarget用我们的材质处理后画到临时纹理_tempTexture然后再将临时纹理的内容画回相机目标。这是一个典型的“Ping-Pong”操作因为我们不能在读取纹理的同时写入同一个纹理。3.3 第三步配置URP渲染器并添加Feature在Project窗口找到你的URP Asset文件通常名为UniversalRP-HighQuality或类似。选中它在Inspector窗口找到Renderer List点击进入你正在使用的Renderer通常是Universal Renderer Data。在Renderer的Inspector底部找到Renderer Features列表点击Add Renderer Feature选择我们刚创建的ScreenFilterFeature。将之前创建的Mat_ScreenFilter材质球拖拽到Feature的Material槽位中。至此后处理管线已经接通。运行游戏你应该能看到画面但目前还没有任何效果因为Shader中的参数_Brightness等还是默认值1.0。3.4 第四步创建控制脚本并动态调整参数我们需要一个脚本来在运行时动态修改材质球的属性实现“一键调”的效果。// 文件ScreenFilterController.cs using UnityEngine; [ExecuteAlways] // 在编辑模式下也执行方便预览 public class ScreenFilterController : MonoBehaviour { [Header(滤镜材质)] [SerializeField] private Material _filterMaterial; [Header(滤镜参数)] [Range(0.0f, 3.0f)] [SerializeField] private float _brightness 1.0f; [Range(0.0f, 3.0f)] [SerializeField] private float _saturation 1.0f; [Range(0.0f, 3.0f)] [SerializeField] private float _contrast 1.0f; // 用于在Inspector中实时预览变化的属性 public float Brightness { get _brightness; set { _brightness value; UpdateMaterialProperties(); } } public float Saturation { get _saturation; set { _saturation value; UpdateMaterialProperties(); } } public float Contrast { get _contrast; set { _contrast value; UpdateMaterialProperties(); } } private void OnValidate() { // 当Inspector中的值发生变化时立即更新材质 UpdateMaterialProperties(); } private void Start() { if (_filterMaterial null) { Debug.LogError(ScreenFilterController: Filter Material is not assigned!); enabled false; return; } UpdateMaterialProperties(); } private void UpdateMaterialProperties() { if (_filterMaterial ! null) { _filterMaterial.SetFloat(_Brightness, _brightness); _filterMaterial.SetFloat(_Saturation, _saturation); _filterMaterial.SetFloat(_Contrast, _contrast); } } // 示例快速切换预设 public void ApplyPreset_Normal() { Brightness 1f; Saturation 1f; Contrast 1f; } public void ApplyPreset_Vivid() { Brightness 1.1f; Saturation 1.3f; Contrast 1.2f; } public void ApplyPreset_Moody() { Brightness 0.8f; Saturation 0.7f; Contrast 1.4f; } public void ApplyPreset_Bleach() { Brightness 1.2f; Saturation 0.1f; Contrast 1.1f; } }将这个脚本挂载到场景中的任意GameObject上例如一个空的“PostProcessManager”然后将之前创建的Mat_ScreenFilter材质球拖拽到脚本的Filter Material字段。现在在Play模式下你可以在Inspector中实时拖动三个滑块观察游戏画面的即时变化。注意材质球实例化问题。直接修改_filterMaterial的属性会修改这个材质球资产本身。如果你希望每个摄像机或不同场景使用不同的参数并且互不干扰应该在Start或Awake中使用MaterialPropertyBlock或者动态实例化一个材质副本new Material(_filterMaterial)并赋值给Renderer Feature。对于全局后处理直接修改通常可以接受。4. 效果调试与参数详解现在我们已经有了一个可运行的滤镜系统。让我们深入理解每个参数并看看如何调试出理想的效果。4.1 亮度、饱和度、对比度的视觉影响这三个参数相互独立又相互影响调整时需要综合考量。亮度最直观的参数。提高亮度让画面整体变亮降低则变暗。但要注意过度提高亮度会导致亮部区域高光细节丢失变成一片“死白”过度降低则会让暗部变成一片“死黑”。它是对RGB三个通道进行统一的乘法操作。饱和度控制颜色的鲜艳程度。降低饱和度会趋向黑白提高则让颜色更浓烈。一个常见技巧在调整饱和度时要观察画面中的肤色如果存在。过高的饱和度会让肤色看起来不自然像晒伤或中毒。通常将饱和度维持在0.8到1.5之间是比较安全且有效的范围。对比度增强或减弱明暗区域的差异。高对比度让亮部更亮、暗部更暗画面显得“硬朗”、“清晰”但也可能损失中间调的细节。低对比度让画面显得“灰蒙蒙”、“平淡”。对比度是塑造画面“力度”和“情绪”的关键。恐怖游戏常用高对比度制造强烈的阴影和不安感而一些唯美的叙事游戏可能采用稍低的对比度来营造柔和、梦幻的氛围。4.2 使用颜色分级工具进行参考调试在Unity编辑器中有一个强大的内置工具可以帮助我们直观地理解这些参数颜色分级Color Grading它是URP Volume组件的一部分。在场景中创建一个GameObject - Volume。选中该Volume在Inspector中点击Add Override选择Unity Engine - Color Adjustments。你会看到Post Exposure类似亮度、Contrast、Color Filter色调和Saturation等滑块。你可以先用Volume的Color Adjustments快速拖拽找到一个你觉得好看的画面感觉记下对应的Post Exposure、Contrast、Saturation的大致数值。然后将这些数值映射到我们自定义的Shader控制器上。注意Volume的Post Exposure是曝光值其变化是非线性的类似摄影中的EV值而我们的_Brightness是线性乘法两者不完全等同但可以作为强烈的视觉参考。为什么不用Volume而自己写Volume系统非常强大但对于一些定制化需求比如将滤镜效果与游戏机制绑定或需要极致的性能控制拥有自己的Shader和控制器会更加灵活。例如你可以根据玩家血量动态降低饱和度并增加对比度受伤效果或者根据场景区域无缝过渡不同的滤镜参数。4.3 参数联动与常见预设单一参数的调整往往不够组合起来才能形成风格。下面是一些经典预设的参数思路预设风格亮度饱和度对比度视觉感受与适用场景标准 (Normal)1.01.01.0原汁原味忠实还原美术资源。鲜艳 (Vivid)1.05 - 1.11.2 - 1.51.1 - 1.3色彩明快充满活力。适用于卡通、休闲、运动类游戏。电影感 (Cinematic)0.9 - 1.00.8 - 1.01.3 - 1.6对比强烈色彩沉稳。常用于写实风格的3A大作或叙事游戏增加厚重感。褪色/怀旧 (Faded)1.1 - 1.20.4 - 0.70.9 - 1.1饱和度低亮度稍高模拟老照片或回忆片段。阴沉/恐怖 (Gloomy)0.7 - 0.80.6 - 0.81.4 - 1.8整体偏暗色彩压抑对比度高阴影浓重。漂白 (Bleach Bypass)1.1 - 1.30.1 - 0.31.2 - 1.5极低的饱和度高对比度产生一种粗糙、高反差的银幕效果常用于战争、犯罪题材。实操心得不要死记硬背这些数字。最好的方法是准备一张具有丰富色彩和明暗过渡的参考图比如包含天空、植被、建筑、人物在游戏运行时对着参考图调整你的三个滑块直到达到满意的效果然后记录下此时的数值作为该场景或该风格的预设。5. 性能优化与进阶技巧一个基础的后处理效果性能开销很小但养成良好的习惯和了解进阶可能性至关重要。5.1 性能考量与最佳实践带宽是瓶颈后处理Shader对每个像素执行一次因此其性能主要消耗在纹理采样和带宽上。我们的Shader每个像素采样一次_MainTex计算量很小在主流平台上几乎可以忽略不计。检查Shader复杂度在Unity编辑器中选中你的ScreenFilter.shader在Inspector面板可以预览其编译后的复杂度。确保它保持在“低”或“中”级别。避免在后处理Shader中使用循环、大量分支判断或复杂的数学函数。使用RTHandle系统正如我们在Feature中做的使用RTHandle而非RenderTexture能自动受益于URP的动态分辨率缩放等功能更高效。控制执行范围在ScreenFilterPass.Execute中我们通过renderingData.cameraData.isSceneViewCamera跳过了场景相机。你还可以扩展这个逻辑例如只对主摄像机生效或者通过LayerMask和摄像机体积Volume来触发避免对UI摄像机、反射探针相机等不必要的摄像机执行后处理。材质属性块如果同一个Shader需要被多个控制器以不同参数调用务必使用MaterialPropertyBlock来传递参数而不是创建多个材质实例。MaterialPropertyBlock可以覆盖材质的属性而无需创建新的材质资源更节省内存。5.2 进阶添加色调、渐晕等效果掌握了基础三要素后你可以轻松地扩展这个Shader加入更多电影级滤镜效果。色调调整在ApplyBrightnessContrastSaturation函数的最后可以添加一个色调叠加。常见方法是使用一个_TintColorVector4然后与处理后的颜色进行某种混合比如柔光混合、颜色叠加或者简单的乘加操作。float3 _TintColor; float _TintStrength; // ... color lerp(color, color * _TintColor.rgb, _TintStrength); // 简单的乘法染色渐晕效果模拟镜头边缘变暗的效果。这需要知道当前像素在屏幕中心的位置。float2 uvCentered i.uv * 2.0 - 1.0; // 将UV从[0,1]映射到[-1,1] float vignette 1.0 - dot(uvCentered, uvCentered) * _VignetteIntensity; // 基于距离中心的平方衰减 vignette saturate(vignette); // 钳制到[0,1] color.rgb * vignette;颗粒感添加微弱的噪声来模拟胶片颗粒。这需要引入一个噪声纹理或者使用程序化噪声函数。float noise tex2D(_GrainTex, i.uv * _GrainTiling _Time.y * _GrainSpeed).r; noise (noise - 0.5) * _GrainStrength; // 将[0,1]的噪声映射到[-strength/2, strength/2] color.rgb noise;将这些效果组合你可以在Shader中定义多个属性并在Fragment Shader中按顺序应用它们。注意应用顺序通常亮度/对比度/饱和度调整应在色调和渐晕之前而颗粒感可以最后添加。5.3 常见问题排查实录问题1画面全黑或全紫粉色。排查这是Shader编译失败或纹理绑定错误的典型表现。首先检查Unity编辑器Console窗口是否有Shader编译错误。然后在ScreenFilterPass.Execute中在Blit之前添加一句调试if(_material null) return;。确保在Renderer Feature中正确分配了材质球。问题2后处理效果没有出现。排查步骤确认Renderer Feature已添加到URP Renderer中且材质球已赋值。确认ScreenFilterController脚本挂载的材质球和Renderer Feature使用的是同一个材质实例。在Game视图打开Stats面板查看Draw Calls和SetPass Calls。执行后处理会新增Draw Calls。如果没增加说明Pass可能没被执行检查RenderPassEvent设置是否正确以及摄像机是否被意外排除如Layer Mask。在ScreenFilterPass.Execute中尝试用Debug.Log输出信息确认Pass是否被调用。问题3效果在Game视图正常但Build后失效。排查这通常是Shader没有正确被打包进构建造成的。确保Shader所在的目录或Shader本身没有被设置为“不参与构建”。检查Project Settings - Graphics - Always Included Shaders列表中是否包含了你的Hidden/ScreenFilterShader通常Hidden路径下的需要手动添加。更稳妥的做法是在编辑器中右键你的Shader文件选择Reimport并确保其Inspector中的Shader设置无误。问题4移动设备上效果异常或性能差。排查精度问题移动设备GPU如OpenGL ES对精度更敏感。确保在Shader中为变量使用了足够的精度限定符。对于颜色计算half通常足够在支持的情况下对于UV和位置使用float。可以尝试将half4 col改为float4 col看看问题是否解决。Overdraw确保你的后处理Pass设置了正确的RenderStateBlockZTest Always, ZWrite Off避免不必要的深度测试和写入。纹理格式检查_MainTex即相机纹理的格式。在URP中这通常由URP Asset的HDR设置决定。非HDR下是ARGB32HDR下可能是ARGBHalf。确保你的Shader计算能处理HDR高动态范围颜色即颜色值可能大于1。我们的简单乘法公式在HDR下依然有效但更复杂的操作如色调映射需要额外处理。问题5与UICanvas的叠加问题。现象后处理效果应用在了整个屏幕上包括UI导致UI文字和图标也变了色。解决方案这是预期行为因为后处理处理的是整个相机缓冲。如果希望UI不受影响有几种方案使用两个摄像机一个摄像机Main Camera渲染3D场景并应用后处理另一个摄像机UI Camera只渲染UI且其Clear Flags设为Depth only深度值比主摄像机高。这是最标准、性能最好的方案。修改后处理时机将Renderer Feature的RenderPassEvent设置为BeforeRenderingUI如果存在或BeforeRenderingOverlays。但这需要URP版本支持且可能影响其他后期效果。使用Render Texture较复杂将主摄像机输出到一张Render Texture对此纹理进行后处理然后在一个只渲染此纹理的平面上显示。UI则正常渲染在屏幕上。这种方法控制灵活但增加Draw Call和内存。我个人在多个项目中实践下来最稳定、兼容性最好的方案永远是双摄像机方案。它逻辑清晰性能可控并且是Unity UI系统的标准设计模式。在设置UI摄像机时务必将其Culling Mask设置为只包含UI层并且将其Depth设置为比主摄像机更大的值以确保UI绘制在后处理后的场景之上。
返回列表