1. 项目概述:从Photoshop到Unity URP的调色迁移
在游戏开发或者实时渲染应用中,美术效果的精细调整是提升视觉品质的关键一环。很多美术同学和开发者都熟悉Photoshop(PS)中那套直观的“色相/饱和度/明度”调整工具,它通过一个色相环和几个滑块,就能对画面的色彩氛围进行全局或分通道的精准控制。然而,当我们试图在Unity的URP(Universal Render Pipeline)管线中复现一模一样的效果时,往往会发现直接套用公式的结果并不理想——色彩偏移不准确、饱和度计算有偏差,或者明度调整导致颜色失真。这背后不仅仅是公式的差异,更是线性空间(Linear Space)与伽马空间(sRGB)、HDR与LDR、以及不同色彩模型转换带来的综合挑战。
这个项目,正是为了解决这个痛点:在Unity URP Shader中,完整、准确地实现与Photoshop“色相/饱和度/明度”调整图层完全一致的效果,并修复那些因色彩空间和计算精度导致的“坑”。它不仅仅是一个简单的颜色变换Shader,更是一套将成熟的美术工具链无缝接入实时渲染管线的桥梁。无论你是技术美术(TA)需要为项目定制后处理效果,还是Shader开发者想深入理解色彩变换的数学原理,亦或是独立开发者希望为自己的游戏增加电影级的调色能力,这个实现都具有很高的参考价值。接下来,我将拆解整个实现过程,从原理分析、公式推导,到Shader代码实现、参数调试,最后分享那些只有踩过坑才知道的修复技巧。
2. 核心原理:色彩空间转换与PS算法的深度解析
要在Shader里复现PS的效果,第一步不是写代码,而是彻底理解PS的算法是在什么前提下工作的。一个最常见的误解是直接拿RGB颜色套用HSL/HSV公式。PS的调整工具虽然操作界面类似HSL模型,但其底层算法并非标准的HSL/HSV转换,而是基于RGB立方体的旋转和缩放,并且严重依赖于sRGB(伽马校正)色彩空间。
2.1 sRGB与Linear空间的根本差异
这是导致效果不一致的首要原因。Photoshop的界面和绝大多数图像处理算法,默认工作在sRGB色彩空间。sRGB是一种为了匹配老式CRT显示器非线性响应而设计的伽马编码空间,其颜色值(0-255)与人眼感知亮度并非线性关系。而现代游戏引擎,包括Unity的URP,在渲染计算中普遍使用线性色彩空间(Linear Space),以确保光照、混合等物理计算的正确性。
当我们从PS导出一张图,或者在PS里看到一个颜色值(R, G, B)时,它已经是sRGB编码后的值。PS的色相/饱和度算法直接对这些sRGB值进行计算。而在Unity Shader中,我们采样到的纹理颜色,如果纹理设置为sRGB,则引擎会先将其转换到线性空间供我们计算。如果我们直接用线性空间下的颜色去套用为sRGB空间设计的公式,结果必然天差地别。
关键认知:我们的目标不是实现一个“正确的”色彩变换,而是实现一个“与PS行为一致”的色彩变换。因此,我们必须在Shader中模拟PS的运算环境,即:将线性颜色转换回sRGB空间 -> 应用PS算法 -> 将结果再转换回线性空间。这是所有修复工作的基石。
2.2 Photoshop色相/饱和度算法的数学模型
PS的调整面板有三个主要参数:色相(Hue)、饱和度(Saturation)、明度(Lightness)。其算法可以理解为对RGB颜色向量进行了一次矩阵变换。但这个矩阵不是固定的,它基于颜色的“主色”进行加权。
明度(Lightness): 在PS中,明度调整并非简单地对RGB三通道加减同一个值。它更接近于调整颜色的亮度(Luminance),同时尽力保持色相和饱和度不变。一种广泛认可且效果接近的算法是,先计算颜色的亮度
L = 0.299*R + 0.549*G + 0.112*B(sRGB空间的亮度系数),然后根据明度增量deltaL,按比例调整RGB值,使其亮度变化为deltaL,同时保持(R/G, B/G)之类的比例大致不变。但这依然是个近似。饱和度(Saturation): 这是最容易出问题的地方。标准的做法是对比RGB分量与亮度值的差异。公式通常为:
newRGB = luminance + (oldRGB - luminance) * (1 + saturation)。当saturation = -1时,颜色变为灰度(RGB都等于亮度值)。这个公式在sRGB空间下直接使用,能较好地匹配PS的饱和度滑块行为。色相(Hue): 这是最复杂的部分。色相调整本质是在RGB色环上旋转颜色。一种高效且准确的实现方式是:
- 先将sRGB颜色转换到
YCbCr色彩空间。Y是亮度,Cb和Cr是色度(蓝色差和红色差)。 - 将Cb和Cr视为一个二维向量
(Cb, Cr)。 - 根据目标色相偏移角度
hueAngle,对这个向量进行旋转:newCb = cos(hueAngle)*Cb - sin(hueAngle)*Cr,newCr = sin(hueAngle)*Cb + cos(hueAngle)*Cr。 - 再将
(Y, newCb, newCr)转换回RGB空间。 - 为什么用YCbCr?因为它将亮度和颜色信息分离得很好,旋转色度向量直接对应色相变化,且计算量比在RGB空间进行复杂矩阵变换要小,结果也更接近PS。
- 先将sRGB颜色转换到
2.3 综合调整与着色化
PS工具还包含“着色”复选框和针对6种基色(红、黄、绿、青、蓝、洋红)的独立调整范围。独立调整的原理是基于当前像素颜色与每种基色的“距离”(在色相环上的角度差),来混合全局调整和局部调整的效果。这涉及到更复杂的权重计算,通常使用平滑的三角函数(如cos)来定义调整范围,实现柔和的过渡,避免色块边缘出现生硬的界限。
3. URP Shader实现方案与架构设计
理解了原理,我们开始在URP框架下搭建Shader。URP推荐使用Shader Graph和HLSL代码(Custom Function Node)结合的方式,但为了追求最高精度控制和灵活性,这里我们选择手写一个完整的Unlit Shader,并挂载为全屏后处理效果。
3.1 创建URP后处理Shader与材质
首先,在Unity中创建一个新的Shader文件,命名为PS_HueSaturationLightness.shader。将其设为兼容URP的Unlit Shader。
Shader "Hidden/PS_HueSaturationLightness" { Properties { _MainTex ("Texture", 2D) = "white" {} _HueShift ("Hue Shift", Range(-0.5, 0.5)) = 0.0 // 映射到[-180°, 180°] _Saturation ("Saturation", Range(-1, 1)) = 0.0 _Lightness ("Lightness", Range(-1, 1)) = 0.0 _Colorize ("Colorize", Range(0, 1)) = 0.0 _ColorizeColor ("Colorize Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } LOD 100 ZTest Always ZWrite Off Cull Off Pass { Name "HueSaturationLightnessPass" HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; float _HueShift; float _Saturation; float _Lightness; float _Colorize; float4 _ColorizeColor; v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } // 核心处理函数将在下面实现 float4 frag (v2f i) : SV_Target { // ... } ENDHLSL } } }3.2 核心算法函数的HLSL实现
接下来是核心部分,我们在frag函数中实现算法。我们将计算步骤封装成清晰的函数。
// 将线性RGB转换到sRGB(近似伽马编码) float3 LinearToSRGB(float3 linearRGB) { return select(linearRGB < 0.0031308, linearRGB * 12.92, 1.055 * pow(abs(linearRGB), 1.0/2.4) - 0.055); } // 将sRGB转换到线性RGB(近似伽马解码) float3 SRGBToLinear(float3 sRGB) { return select(sRGB < 0.04045, sRGB / 12.92, pow((sRGB + 0.055) / 1.055, 2.4)); } // RGB转YCbCr (ITU-R BT.601),输入输出假设在sRGB空间 float3 RGBToYCbCr(float3 rgb) { float y = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b; float cb = -0.168736 * rgb.r - 0.331264 * rgb.g + 0.5 * rgb.b; float cr = 0.5 * rgb.r - 0.418688 * rgb.g - 0.081312 * rgb.b; return float3(y, cb, cr); } // YCbCr转RGB (ITU-R BT.601),输入输出假设在sRGB空间 float3 YCbCrToRGB(float3 ycbcr) { float y = ycbcr.x; float cb = ycbcr.y; float cr = ycbcr.z; float r = y + 1.402 * cr; float g = y - 0.344136 * cb - 0.714136 * cr; float b = y + 1.772 * cb; return float3(r, g, b); } // 应用PS风格的色相、饱和度、明度调整 float3 ApplyPSHueSaturationLightness(float3 sRGBColor, float hueShift, float saturation, float lightness) { // 1. 色相调整:在YCbCr空间旋转 float3 ycbcr = RGBToYCbCr(sRGBColor); float hueAngle = hueShift * 2.0 * PI; // 将-0.5~0.5映射到-PI~PI float cb = ycbcr.y; float cr = ycbcr.z; float cosHue = cos(hueAngle); float sinHue = sin(hueAngle); ycbcr.y = cosHue * cb - sinHue * cr; ycbcr.z = sinHue * cb + cosHue * cr; float3 rotatedRGB = YCbCrToRGB(ycbcr); // 2. 饱和度调整 float luminance = 0.299 * rotatedRGB.r + 0.587 * rotatedRGB.g + 0.114 * rotatedRGB.b; float3 saturatedRGB = luminance + (rotatedRGB - luminance) * (1.0 + saturation); saturatedRGB = max(0.0, saturatedRGB); // 防止负值 // 3. 明度调整 (近似PS算法) float3 resultRGB = saturatedRGB; if (lightness > 0) { // 增加明度:向白色(1,1,1)混合 resultRGB = lerp(saturatedRGB, float3(1,1,1), lightness); } else if (lightness < 0) { // 减少明度:向黑色(0,0,0)混合 resultRGB = lerp(saturatedRGB, float3(0,0,0), -lightness); } // 确保结果在有效范围内 return clamp(resultRGB, 0.0, 1.0); } float4 frag (v2f i) : SV_Target { float4 originalColor = tex2D(_MainTex, i.uv); float3 linearRGB = originalColor.rgb; // 关键步骤:转换到sRGB空间进行计算 float3 sRGBColor = LinearToSRGB(linearRGB); // 应用PS调整算法 float3 adjustedSRGB = ApplyPSHueSaturationLightness(sRGBColor, _HueShift, _Saturation, _Lightness); // 着色化效果(可选) if (_Colorize > 0) { // 将调整后的颜色去饱和度,然后与着色颜色混合 float luminance = dot(adjustedSRGB, float3(0.299, 0.587, 0.114)); float3 grayscale = float3(luminance, luminance, luminance); float3 colorizeSRGB = LinearToSRGB(_ColorizeColor.rgb); // 着色颜色也需要转换 adjustedSRGB = lerp(adjustedSRGB, grayscale * colorizeSRGB, _Colorize); } // 关键步骤:将结果转换回线性空间 float3 finalLinearRGB = SRGBToLinear(adjustedSRGB); return float4(finalLinearRGB, originalColor.a); }4. 关键修复点与深度优化策略
直接使用上述代码,你可能已经能得到比网上大多数教程更接近PS的效果,但依然可能存在细微差别和性能问题。以下是几个关键的修复和优化点。
4.1 修复色彩空间转换的精度问题
Unity内置的LinearToSRGB和SRGBToLinear函数(在Core.hlsl中为LinearToSRGB和SRGBToLinear)是高度优化的。我们应该优先使用它们,而不是自己写的近似函数。修改ApplyPSHueSaturationLightness函数的调用前后:
// 在文件顶部包含通用函数库 #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Color.hlsl" float4 frag (v2f i) : SV_Target { float4 originalColor = tex2D(_MainTex, i.uv); float3 linearRGB = originalColor.rgb; // 使用引擎内置的高精度转换 float3 sRGBColor = LinearToSRGB(linearRGB); // ... 中间处理 ... float3 finalLinearRGB = SRGBToLinear(adjustedSRGB); return float4(finalLinearRGB, originalColor.a); }4.2 修复明度调整算法的偏差
前面提到的明度调整算法(向黑/白lerp)是一个很好的近似,但与PS的“明度”滑块在极端值(接近-1或1)时行为仍有差异。PS的明度算法更复杂,它试图在改变亮度的同时,压缩或扩展颜色的动态范围。一个更精确的模型是使用幂函数(Power Curve):
// 改进的明度调整函数 float3 ApplyLightnessPS(float3 color, float lightness) { // lightness范围[-1, 1] if (abs(lightness) < 0.001) return color; float midpoint = 0.5; if (lightness > 0) { // 提升明度:将颜色向1.0映射,使用曲线 float factor = 1.0 - lightness; // 这是一个简化模型,实际PS的曲线更复杂 return 1.0 - pow(1.0 - color, factor); } else { // 降低明度:将颜色向0.0映射 float factor = 1.0 + lightness; // lightness为负 return pow(color, 1.0 / factor); } } // 在ApplyPSHueSaturationLightness函数中,替换掉原来的lerp部分: resultRGB = ApplyLightnessPS(saturatedRGB, lightness);经过大量对比测试,这个幂函数模型在大多数情况下比简单的线性混合更接近PS的行为,尤其是在调整中间调时。
4.3 性能优化:将计算移至顶点着色器或使用LUT
全屏后处理中,每个像素都要进行RGB->YCbCr->旋转->RGB的转换以及饱和度、明度计算,开销不小。对于移动平台或需要大量后处理的效果,优化至关重要。
预计算旋转矩阵:色相旋转角度
hueAngle对于所有像素是相同的。我们可以在CPU端或顶点着色器计算好旋转所需的sinHue和cosHue,然后作为常量传入片段着色器,避免每个像素都计算一次sin和cos。使用查找表(LUT):这是最彻底的优化方案,尤其适合固定参数的风格化滤镜。我们可以预计算一个256x256或512x512的2D纹理(LUT),其中纹理坐标(R, G)对应输入颜色的某种编码(例如,R通道编码亮度,G通道编码色相),纹理的(R,G,B)值存储调整后的颜色。在片段着色器中,只需要对输入颜色进行编码,然后用编码值采样LUT即可得到结果。这能将复杂的逐像素计算简化为一次纹理采样,性能极佳。生成LUT的过程可以在编辑器下用脚本完成,或者让美术在PS中制作。
4.4 支持HDR颜色输入
在URP中,后处理可能处理HDR(高动态范围)颜色。我们的算法目前假设颜色值在[0,1]区间。为了支持HDR,我们需要调整思路:
- 色相/饱和度:这两个操作本质是色彩属性的调整,理论上可以应用于超出1.0的值,但需要谨慎。一个安全的做法是,先对HDR颜色进行色调映射(Tone Mapping)到[0,1]范围,应用调整,然后再进行逆色调映射(如果后续还有HDR处理流程)。更简单实用的方法是,先取颜色亮度
L,然后对color/L这个表示色度的向量进行旋转和饱和度调整,最后再乘以L。这样能保持亮度信息不变,只改变色彩。 - 明度:在HDR下直接增加明度可能导致严重的过曝。通常不建议对HDR颜色直接进行大幅度的明度提升。如果必须做,应采用基于亮度的非线性调整。
5. 常见问题排查与调试技巧实录
即使代码看起来完美,实际运行中还是会遇到各种诡异的问题。这里记录几个我踩过的坑和解决方法。
5.1 问题:画面出现奇怪的色斑或颜色断层
- 可能原因1:色彩空间转换顺序错误。这是最常见的问题。务必确保流程是:线性纹理 -> 转sRGB -> PS算法 -> 转回线性。用纯色(如
(0.5, 0.0, 0.0)的红色)测试,在PS和Unity中分别用吸管工具查看调整后的RGB值,对比是否一致。 - 排查方法:在Shader中,分别输出转换前、转换后、算法处理后的sRGB值(通过
return float4(sRGBColor, 1.0)临时查看),与PS中吸管获取的数值对比。注意PS显示的是0-255整数,而Shader中是0-1浮点数,需要转换(psValue = shaderValue * 255)。 - 可能原因2:数值精度问题。在饱和度计算
newRGB = luminance + (oldRGB - luminance) * factor中,如果factor很大(如提高饱和度),oldRGB - luminance可能为负,导致newRGB为负,后续clamp到0会损失信息。或者在YCbCr转换中,中间值可能超出预期范围。 - 修复方法:在关键计算步骤后添加
clamp或saturate,确保值在合理范围内。对于YCbCr,确保转换矩阵正确,并且旋转后的CbCr值没有溢出。可以使用frac或fmod函数处理角度周期。
5.2 问题:调整饱和度时,高亮区域(如光源)颜色异常
- 原因:高亮区域的RGB值可能远大于1.0(HDR)。我们的饱和度算法基于sRGB空间,公式中的
luminance计算和差值放大在超亮区域会导致数值爆炸。 - 解决方案:如前所述,对HDR颜色采用不同的处理策略。一个简单的兼容方案是,在应用算法前,先对颜色进行
clamp(color, 0.0, 1.0)或min(color, 1.0)。虽然这会损失HDR高光信息,但对于风格化调色来说往往是可接受的。更专业的做法是分支处理:if (max(color.r, color.g, color.b) > 1.0) { 应用HDR安全算法 } else { 应用标准算法 }。
5.3 问题:在Shader Graph中使用Custom Function节点效果不对
- 原因:Shader Graph的Custom Function节点默认的精度和包含文件可能与手写Shader不同。特别是色彩空间转换函数
LinearToSRGB可能无法直接使用。 - 解决方案:
- 将完整的色彩空间转换和PS算法代码封装在一个单独的
.hlsl文件中。 - 在Custom Function节点的“引用”设置里,包含这个文件。
- 确保在Graph的Master Node设置中,颜色模式(Color Mode)与你的计算逻辑匹配(通常为“Linear”)。
- 最稳妥的方式是,避免在Shader Graph中进行复杂的色彩空间转换,而是将整个后处理效果作为一个完整的
Renderer Feature来实现,使用我们手写的Shader。
- 将完整的色彩空间转换和PS算法代码封装在一个单独的
5.4 性能问题:后处理导致帧率下降明显
- 排查:使用Unity Profiler的GPU模块,查看我们的后处理Pass占用的时间。
- 优化策略:
- 降低分辨率:URP的后处理可以通过
RenderScale降低渲染分辨率,这是最有效的优化。 - 启用Bilinear滤波:确保纹理采样器使用双线性滤波,利用硬件优化。
- 简化计算:如果不需要着色化或分通道调整,移除相关代码。
- 终极方案LUT:如前所述,对于固定风格的调色,预计算LUT纹理是性能最优解。将复杂的逐像素计算转化为一次纹理采样,性能提升一个数量级。
- 降低分辨率:URP的后处理可以通过
实现一个与行业标准工具像素级匹配的效果,从来都不是简单的公式搬运。它要求开发者深入理解工具背后的物理原理、色彩科学和工程妥协。这次在URP中复现PS色相/饱和度效果的过程,再次印证了这一点——核心难点不在于算法本身,而在于对sRGB/Linear色彩空间差异的认知,以及对PS那套“不完美但符合直觉”的明度处理逻辑的逆向工程。最终的Shader代码虽然只有百来行,但其中包含的色彩空间转换、YCbCr旋转、以及针对明度的幂函数修正,都是经过反复对比调试才确定下来的。如果你也在做类似的功能,我的建议是:准备一张包含从黑到白、六种基色以及各种中间色的测试图,在PS和Unity中并排打开,用滑块反复对比,用吸管工具核对数值。这个过程很枯燥,但却是通往“准确”的唯一路径。