1. 项目概述从Photoshop到Unity URP的调色迁移在游戏开发或者实时渲染应用中美术效果的精细调整是提升视觉品质的关键一环。很多美术同学和开发者都熟悉PhotoshopPS中那套直观的“色相/饱和度/明度”调整工具它通过一个色相环和几个滑块就能对画面的色彩氛围进行全局或分通道的精准控制。然而当我们试图在Unity的URPUniversal 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*BsRGB空间的亮度系数然后根据明度增量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)*CrnewCr sin(hueAngle)*Cb cos(hueAngle)*Cr。再将(Y, newCb, newCr)转换回RGB空间。为什么用YCbCr因为它将亮度和颜色信息分离得很好旋转色度向量直接对应色相变化且计算量比在RGB空间进行复杂矩阵变换要小结果也更接近PS。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 { RenderTypeOpaque RenderPipelineUniversalPipeline } 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.0HDR。我们的饱和度算法基于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。5.4 性能问题后处理导致帧率下降明显排查使用Unity Profiler的GPU模块查看我们的后处理Pass占用的时间。优化策略降低分辨率URP的后处理可以通过RenderScale降低渲染分辨率这是最有效的优化。启用Bilinear滤波确保纹理采样器使用双线性滤波利用硬件优化。简化计算如果不需要着色化或分通道调整移除相关代码。终极方案LUT如前所述对于固定风格的调色预计算LUT纹理是性能最优解。将复杂的逐像素计算转化为一次纹理采样性能提升一个数量级。实现一个与行业标准工具像素级匹配的效果从来都不是简单的公式搬运。它要求开发者深入理解工具背后的物理原理、色彩科学和工程妥协。这次在URP中复现PS色相/饱和度效果的过程再次印证了这一点——核心难点不在于算法本身而在于对sRGB/Linear色彩空间差异的认知以及对PS那套“不完美但符合直觉”的明度处理逻辑的逆向工程。最终的Shader代码虽然只有百来行但其中包含的色彩空间转换、YCbCr旋转、以及针对明度的幂函数修正都是经过反复对比调试才确定下来的。如果你也在做类似的功能我的建议是准备一张包含从黑到白、六种基色以及各种中间色的测试图在PS和Unity中并排打开用滑块反复对比用吸管工具核对数值。这个过程很枯燥但却是通往“准确”的唯一路径。