
1. 项目概述为什么要在URP里复刻PS的色相/饱和度做游戏美术或者技术美术的朋友对Photoshop里那个“色相/饱和度”调整面板快捷键CtrlU肯定不陌生。它几乎是图像后期处理的“万金油”动动滑块就能让画面氛围大变样。在Unity里做渲染尤其是用URP管线我们经常需要实现类似的效果——可能是为了做动态的天气系统比如从正午到黄昏的色调变化也可能是为了角色状态变化比如中毒、隐身、无敌闪烁或者仅仅是给美术同学一个直观的、像PS一样的后期调色工具。但直接拿PS的算法往Shader里套往往会发现效果对不上或者性能开销大。这个项目的核心目标就是在Unity URP的Shader框架下精准地复现Photoshop中“色相/饱和度/明度”调整的视觉效果和参数逻辑。这不仅仅是把颜色变深变浅那么简单它涉及到颜色空间的理解、RGB与HSL/HSV的转换、以及如何在Shader中高效且正确地应用这些变换。网上很多教程给的公式要么效果有偏差要么没讲清楚原理调起来总感觉“差点意思”。今天我就把自己在项目中踩过坑、验证过的完整方案和底层逻辑拆解给你让你不仅能做出和PS一模一样的效果更能明白背后的每一个“为什么”。2. 核心原理拆解从RGB到HSL再到调整矩阵要实现PS级别的调整我们必须先理解PS操作的是什么。当我们拖动“色相”滑块时PS并不是在直接修改RGB值而是先将颜色转换到HSL色相、饱和度、明度或HSV色相、饱和度、明度色彩模型对H、S、L分量进行调整后再转换回RGB。HSL模型更符合人类对颜色的直觉感知这也是PS选择它的原因。2.1 RGB与HSL的相互转换这是整个效果的基础必须精确。网上流传的转换公式版本很多我们需要采用与PS算法一致的那一个。RGB转HSL的步骤将RGB值归一化到[0, 1]范围。找出R、G、B中的最大值max和最小值min。计算亮度LightnessL (max min) / 2。计算饱和度Saturation如果max等于min则饱和度为0灰度色。否则如果L 0.5则S (max - min) / (max min)。如果L 0.5则S (max - min) / (2.0 - max - min)。计算色相Hue如果max等于min则色相为0未定义通常设为0。否则根据哪个通道是最大值来计算如果R是最大值H (G - B) / (max - min)。如果G是最大值H 2.0 (B - R) / (max - min)。如果B是最大值H 4.0 (R - G) / (max - min)。将H乘以60转换到[0, 360]度的范围。如果H为负值则加上360。HSL转RGB的步骤这个过程稍复杂需要根据色相扇区进行处理。这里给出一个在Shader中实现的高效方法如果饱和度S接近于0那么RGB三个通道都等于亮度L。否则需要先根据H确定其在色相环上的扇区0-5对应红、黄、绿、青、蓝、洋红。计算中间变量。一个常见的优化算法是float hueToRgb(float p, float q, float t) { if(t 0.0) t 1.0; if(t 1.0) t - 1.0; if(t 1.0/6.0) return p (q - p) * 6.0 * t; if(t 1.0/2.0) return q; if(t 2.0/3.0) return p (q - p) * (2.0/3.0 - t) * 6.0; return p; }然后根据L和S计算q和p再根据色相扇区调用这个函数计算出最终的R、G、B值。注意精度问题。在Shader中浮点数精度有限尤其在移动端。在比较max和min是否相等时不要用而应该使用一个极小的阈值如1e-5来判断避免因精度误差导致计算出错例如把灰度色算出了色相。2.2 PS调整参数的数学本质理解了HSL我们再来看PS面板上三个滑块的本质色相Hue直接对HSL中的H分量进行加法操作。H_new H_original Hue_Offset。因为色相是环形的0-360度所以结果需要对360取模。饱和度Saturation对HSL中的S分量进行乘法操作。S_new S_original * (1 Saturation_Factor)。这里Saturation_Factor的范围通常是[-1, 1]PS里-100到100对应这个范围。需要将结果钳制在[0, 1]。明度Lightness对HSL中的L分量进行加法操作。L_new L_original Lightness_Offset。同样需要将结果钳制在[0, 1]。但是这里有一个巨大的坑PS的“明度”滑块在调整负值变暗和正值变亮时算法并不是对称的线性加减法。根据实测和逆向分析PS的明度调整更接近在RGB空间进行非线性的混合增加明度向原色混合白色。RGB_new lerp(RGB_original, float3(1,1,1), Lightness_Factor)其中Lightness_Factor为正值。减少明度向原色混合黑色。RGB_new lerp(RGB_original, float3(0,0,0), -Lightness_Factor)其中Lightness_Factor为负值。 这个细节是很多自制效果和PS对不上的关键原因。我们稍后在Shader实现中会采用这个更准确的模型。2.3 性能考量全屏后处理 vs. 材质参数在URP中实现通常有两种思路全屏后处理Post-processing通过URP的RenderFeature添加一个全屏Pass对摄像机最终图像进行处理。优点是效果全局、统一适合画面整体色调调整。缺点是所有像素都会计算有性能开销且难以对场景中特定物体单独调整。材质Shader参数编写一个包含色相/饱和度调整节点的URP Lit或Unlit Shader Graph或者手写HLSL代码。优点是灵活可以对不同材质、不同物体单独控制性能开销更精细。缺点是如果需要全局效果需要给所有材质都应用管理复杂。对于追求和PS一致且需要动态调整参数的场景如游戏内滤镜系统我强烈推荐使用全屏后处理方案。它更符合PS“对最终图像进行调整”的直觉也便于通过一个全局参数控制器来管理。接下来我们就以URP全屏后处理为例进行详细实现。3. URP全屏后处理Shader完整实现我们将创建一个完整的、可复用的URP后处理特效。3.1 创建Renderer Feature与Shader首先在URP渲染器配置UniversalRendererData中创建一个ScreenSpacePostProcess类型的Renderer Feature。我们将其命名为HueSaturationFeature。然后创建对应的Shader文件。这里我们直接手写HLSL Shader以便进行最精细的控制。创建一个新的Shader文件比如HueSaturationPostProcess.shader。Shader Hidden/Universal Render Pipeline/HueSaturation { HLSLINCLUDE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); float _Hue; float _Saturation; float _Lightness; // RGB转HSL函数 float3 RGBToHSL(float3 rgb) { float3 hsl; float maxVal max(rgb.r, max(rgb.g, rgb.b)); float minVal min(rgb.r, min(rgb.g, rgb.b)); float delta maxVal - minVal; // 计算亮度 (Lightness) hsl.z (maxVal minVal) * 0.5; // 计算饱和度 (Saturation) if (delta 1e-5) { hsl.y 0.0; hsl.x 0.0; // 色相未定义 } else { hsl.y (hsl.z 0.5) ? (delta / (maxVal minVal)) : (delta / (2.0 - maxVal - minVal)); // 计算色相 (Hue) if (abs(maxVal - rgb.r) 1e-5) hsl.x (rgb.g - rgb.b) / delta (rgb.g rgb.b ? 6.0 : 0.0); else if (abs(maxVal - rgb.g) 1e-5) hsl.x (rgb.b - rgb.r) / delta 2.0; else hsl.x (rgb.r - rgb.g) / delta 4.0; hsl.x / 6.0; // 归一化到 [0, 1) } return hsl; } // Hue辅助函数与上文一致 float HueToRGB(float p, float q, float t) { if (t 0.0) t 1.0; if (t 1.0) t - 1.0; if (t 1.0 / 6.0) return p (q - p) * 6.0 * t; if (t 1.0 / 2.0) return q; if (t 2.0 / 3.0) return p (q - p) * (2.0 / 3.0 - t) * 6.0; return p; } // HSL转RGB函数 float3 HSLToRGB(float3 hsl) { float3 rgb; if (hsl.y 1e-5) { rgb hsl.zzz; // 灰度色 } else { float q (hsl.z 0.5) ? (hsl.z * (1.0 hsl.y)) : (hsl.z hsl.y - hsl.z * hsl.y); float p 2.0 * hsl.z - q; rgb.r HueToRGB(p, q, hsl.x 1.0 / 3.0); rgb.g HueToRGB(p, q, hsl.x); rgb.b HueToRGB(p, q, hsl.x - 1.0 / 3.0); } return rgb; } // 核心调整函数模拟PS的色相/饱和度/明度 float3 ApplyHueSaturationLightness(float3 color) { // 1. 转换到HSL空间 float3 hsl RGBToHSL(color); // 2. 应用色相偏移 (环形加法) hsl.x _Hue / 360.0; // 假设_Hue是角度值例如-180到180 hsl.x frac(hsl.x); // 取小数部分相当于取模1.0。因为色相是归一化的[0,1) if (hsl.x 0.0) hsl.x 1.0; // 3. 应用饱和度乘法 float satFactor 1.0 _Saturation; // 假设_Saturation范围[-1, 1] hsl.y * satFactor; hsl.y saturate(hsl.y); // 钳制到[0,1] // 4. 应用明度调整 (PS风格的非线性混合) float3 adjustedRGB HSLToRGB(hsl); if (_Lightness 0.0) { // 混合白色 adjustedRGB lerp(adjustedRGB, float3(1, 1, 1), _Lightness); } else if (_Lightness 0.0) { // 混合黑色 adjustedRGB lerp(adjustedRGB, float3(0, 0, 0), -_Lightness); } // 如果_Lightness 0则保持原样 return adjustedRGB; } // 顶点着色器 struct Attributes { uint vertexID : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS GetFullScreenTriangleVertexPosition(input.vertexID); output.uv GetFullScreenTriangleTexCoord(input.vertexID); return output; } // 片元着色器 float4 Frag(Varyings input) : SV_Target { float4 color SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); float3 adjustedColor ApplyHueSaturationLightness(color.rgb); return float4(adjustedColor, color.a); } ENDHLSL SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } LOD 100 ZTest Always ZWrite Off Cull Off Pass { Name HueSaturationPass HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag ENDHLSL } } }3.2 创建可配置的Volume组件为了让美术和策划能在场景中或运行时动态调整参数我们需要创建一个VolumeComponent脚本。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [System.Serializable, VolumeComponentMenu(Custom/Hue Saturation)] public class HueSaturationVolume : VolumeComponent, IPostProcessComponent { // 色相 (-180, 180) public ClampedFloatParameter hue new ClampedFloatParameter(0f, -180f, 180f); // 饱和度 (-100, 100)对应PS的-100到100 public ClampedFloatParameter saturation new ClampedFloatParameter(0f, -100f, 100f); // 明度 (-100, 100)对应PS的-100到100 public ClampedFloatParameter lightness new ClampedFloatParameter(0f, -100f, 100f); public bool IsActive() hue.value ! 0f || saturation.value ! 0f || lightness.value ! 0f; public bool IsTileCompatible() true; // 通常设为true除非有特殊平铺需求 }3.3 实现Renderer Feature的渲染逻辑最后在之前创建的HueSaturationFeature中编写渲染逻辑将Volume参数传递给Shader并执行Blit操作。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class HueSaturationFeature : ScriptableRendererFeature { class HueSaturationPass : ScriptableRenderPass { private Material m_Material; private HueSaturationVolume m_VolumeComponent; private RTHandle m_CameraColorTarget; public void Setup(RTHandle colorHandle) { m_CameraColorTarget colorHandle; m_VolumeComponent VolumeManager.instance.stack.GetComponentHueSaturationVolume(); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { ConfigureTarget(m_CameraColorTarget); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_VolumeComponent null || !m_VolumeComponent.IsActive()) return; if (m_Material null) { Shader shader Shader.Find(Hidden/Universal Render Pipeline/HueSaturation); if (shader null) return; m_Material CoreUtils.CreateEngineMaterial(shader); } var cameraData renderingData.cameraData; if (cameraData.camera.cameraType ! CameraType.Game) return; CommandBuffer cmd CommandBufferPool.Get(HueSaturation Post Process); cmd.SetGlobalFloat(_Hue, m_VolumeComponent.hue.value); // 将PS的[-100,100]范围映射到Shader的[-1,1]范围 cmd.SetGlobalFloat(_Saturation, m_VolumeComponent.saturation.value / 100f); cmd.SetGlobalFloat(_Lightness, m_VolumeComponent.lightness.value / 100f); Blitter.BlitCameraTexture(cmd, m_CameraColorTarget, m_CameraColorTarget, m_Material, 0); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { } } HueSaturationPass m_ScriptablePass; public override void Create() { m_ScriptablePass new HueSaturationPass(); m_ScriptablePass.renderPassEvent RenderPassEvent.BeforeRenderingPostProcessing; // 在URP内置后处理之前执行 } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (renderingData.cameraData.postProcessEnabled) { renderer.EnqueuePass(m_ScriptablePass); } } public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData) { if (renderingData.cameraData.postProcessEnabled) { m_ScriptablePass.Setup(renderer.cameraColorTargetHandle); } } }关键参数映射说明_Hue直接使用角度值-180到180对应PS的-180到180。_SaturationPS的-100到100在C#端除以100映射到Shader的-1到1用于乘法因子(1 _Saturation)。_LightnessPS的-100到100在C#端除以100映射到Shader的-1到1作为lerp的混合系数。至此一个完整的、与PS效果一致的URP色相/饱和度/明度后处理特效就完成了。你可以在场景中添加Volume和HueSaturationVolume覆盖组件实时拖动滑块观察画面变化。4. 效果对比、优化与高级技巧实现基本功能后我们还需要确保效果的正确性并考虑性能优化和扩展性。4.1 与Photoshop效果对比验证这是最关键的一步。我建议建立一个标准的测试流程准备测试图选择一张包含丰富色彩、中间调和纯色块的图片比如色彩斑斓的风景照加上红、绿、蓝、青、品、黄、黑、白、灰的色块。在PS中调整在Photoshop中打开测试图使用“色相/饱和度”调整图层将色相设为60饱和度设为50明度设为30。保存调整后的图片为reference.png。在Unity中复现将原图导入Unity作为测试纹理。在场景中设置Volume参数Hue60Saturation50Lightness30。截图对比在Game视图以相同的分辨率截图保存为result.png。像素级比对可以使用工具如Python的PIL库或编写简单的脚本计算reference.png和result.png的像素差异。由于编码和压缩的细微差别允许有极小的误差比如RGB值差异在2以内。如果差异过大就需要回头检查RGB-HSL转换函数或明度混合算法。实操心得明度Lightness的调整是最容易出偏差的。很多开源代码或教程使用在HSL空间直接加减L值的方法这在调整负明度变暗时会导致颜色向黑色收敛时饱和度信息丢失过快效果与PS明显不同。使用向黑/白混合的lerp方法能更好地保留原色的“味道”变暗时颜色更沉稳变亮时颜色更通透与PS的结果几乎一致。4.2 性能优化策略全屏后处理每个像素都要执行一次RGB-HSL转换和调整在低端设备上可能成为性能瓶颈。以下是一些优化思路使用近似计算或查找表LUT对于移动端或需要极高性能的场景可以考虑使用简化公式或预计算的3D LUT。你可以预计算所有可能的输入RGB在经过特定调整参数后的输出RGB存储在一张纹理如32x32x32中。在Shader中只需要将输入RGB作为纹理坐标进行三次线性采样即可得到结果。这能极大减少计算量但会占用一些内存且调整参数改变时需要重新生成LUT。降低计算精度在片元着色器中如果不需要极高的色彩保真度可以考虑使用半精度浮点数half来存储中间变量。将代码中的float改为half在移动GPU上通常能获得更好的性能。但要注意转换函数中的除法、比较等操作对精度敏感改动后需仔细测试。利用线性空间确保你的项目颜色空间设置为Linear。我们的计算都是在线性空间下进行的如果工作在Gamma空间需要先进行pow(2.2)解码调整后再进行pow(1/2.2)编码这会增加计算量且可能引入误差。URP默认使用线性空间这一点通常没问题。分辩率缩放对于非关键摄像机如画中画、UI摄像机或在高分辨率下可以考虑先将渲染目标降采样Downsample到一半分辨率进行处理然后再上采样Upsample回来。这能减少3/4的像素处理量虽然会损失一些锐度但对于色彩调整这类效果往往是可以接受的。4.3 扩展着色Colorize功能的实现PS的色相/饱和度面板底部有一个“着色”Colorize复选框。勾选后它会将图像转换为单色调同时保留明暗关系。这个功能在游戏里做老照片滤镜、夜视仪效果等非常有用。实现原理是将图像去色转换为灰度图然后将这个灰度值作为一个目标色相的饱和度全满、亮度中等的颜色的亮度输入。在Shader中我们可以这样扩展ApplyHueSaturationLightness函数float3 ApplyHueSaturationLightness(float3 color, bool colorize, float colorizeHue) { float3 hsl RGBToHSL(color); if (colorize) { // 1. 计算原色的亮度灰度值可以使用亮度公式如dot(color, float3(0.299, 0.587, 0.114)) float luminance dot(color, float3(0.299, 0.587, 0.114)); // 2. 将目标色相和全饱和度与计算出的亮度组合成新的HSL hsl float3(colorizeHue / 360.0, 1.0, luminance); // 注意这里跳过了原有的_Hue, _Saturation, _Lightness调整或者你可以选择让它们仍然影响结果 // 例如仍然应用用户设置的饱和度和明度偏移 hsl.y * (1.0 _Saturation); hsl.y saturate(hsl.y); } else { // 原有的非着色调整逻辑... hsl.x _Hue / 360.0; hsl.x frac(hsl.x); hsl.y * (1.0 _Saturation); hsl.y saturate(hsl.y); } float3 adjustedRGB HSLToRGB(hsl); // 原有的明度调整逻辑... if (_Lightness 0.0) { /* ... */ } else if (_Lightness 0.0) { /* ... */ } return adjustedRGB; }然后在Volume组件中添加一个BoolParametercolorize和一个ClampedFloatParametercolorizeHue即可。5. 常见问题与排查技巧实录在实际集成和使用过程中你肯定会遇到各种问题。下面是我总结的一些典型问题和解决方法。5.1 效果与PS有肉眼可见的色差问题描述调整参数后Unity中的画面整体色调、鲜艳度或明暗感觉和PS里不一样。排查步骤检查颜色空间这是最常见的原因。确认Unity项目设置Player - Other Settings中的Color Space为Linear。在Gamma空间下做色彩运算结果几乎必然是错误的。验证转换函数使用一组已知的RGB-HSL对应值进行单元测试。例如纯红色(1,0,0)对应的HSL应该是(0°, 1.0, 0.5)。在Shader中输出中间变量到屏幕比如用return float4(hsl, 1);看计算是否正确。聚焦明度调整将色相和饱和度设为0只调整明度。对比PS和Unity中纯色块特别是红、绿、蓝变暗和变亮的过程。如果差异明显问题几乎肯定出在明度算法上。确保你使用的是lerp混合算法而不是在HSL空间直接加减L。检查参数范围确认C#脚本中将PS的[-100,100]映射到Shader的[-1,1]时没有计算错误。特别是饱和度Shader中的公式是hsl.y * (1.0 _Saturation)所以_Saturation为-1时饱和度会变为0。5.2 后处理效果没有生效问题描述Game视图没有任何变化Volume参数调整了也没用。排查步骤检查Renderer Feature在URP Asset的Renderer列表里确认你的HueSaturationFeature已经被添加并启用。检查Volume场景中是否有包含HueSaturationVolume组件的Volume该Volume的Weight和Priority是否设置正确确保主摄像机在其影响范围内如果是全局Volume则没问题。检查Shader编译在Console窗口查看是否有Shader编译错误。有时Shader代码有语法错误Unity会使用一个粉色的错误材质导致效果不显示。使用Frame Debugger打开Window - Analysis - Frame Debugger。逐帧查看渲染过程找到你的HueSaturationPass检查其输入和输出的Render Texture是否正确。这是诊断渲染管线问题最强大的工具。5.3 在UI或特定物体上不想生效问题描述后处理影响了全屏但我想让UI或者某些特效保持原色。解决方案方案A使用渲染层级Render Layer将不想受影响的物体如UI Canvas的渲染层级设为单独的Layer如UI。然后修改你的HueSaturationPass在Execute方法中通过renderingData.cameraData.camera获取摄像机并检查其cullingMask或者通过DrawingSettings和FilteringSettings来排除该Layer的物体。但这对于后处理来说比较麻烦因为后处理作用于最终图像。方案B使用双摄像机渲染这是更清晰的做法。使用两个摄像机一个Main Camera渲染3D场景并附加后处理另一个UI Camera只渲染UI层且清除标志设为Depth only或Dont Clear深度值比主摄像机更高并且不附加任何后处理。这样UI就会叠加在已经过处理的场景画面之上保持原色。方案C在Shader中增加遮罩可以传入一张遮罩纹理Render Texture白色区域应用效果黑色区域保持原样。在片元着色器中根据遮罩值混合原色和调整后的颜色。这需要额外的渲染步骤来生成遮罩。5.4 性能开销过大问题描述在移动设备或低端PC上开启此效果后帧率明显下降。优化建议Profile定位使用Unity Profiler的GPU模块确认是否是HueSaturationPass占用了主要时间。尝试半精度如前所述将Shader中的float改为half进行测试。注意测试效果是否可接受。降低渲染分辨率如果效果用于非主摄像机或者对锐度要求不高这是最有效的办法。考虑按需启用例如只在角色释放大招、进入特定区域时才通过脚本动态启用对应的Volume平时则关闭。这个项目从原理到实现再到调试优化基本涵盖了在URP中定制高级色彩后处理的全流程。最关键的收获是不要盲目相信网上找到的第一个公式特别是对于“明度”这种有歧义的操作一定要以Photoshop这个行业标准为基准进行实测和逆向。当你成功复刻出那一模一样的色彩变化时那种成就感就是技术美术工作的乐趣所在。