news 2026/7/22 9:12:02

Unity URP中实现Photoshop级色相饱和度明度调整的Shader方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity URP中实现Photoshop级色相饱和度明度调整的Shader方案

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颜色向量进行了一次矩阵变换。但这个矩阵不是固定的,它基于颜色的“主色”进行加权。

  1. 明度(Lightness): 在PS中,明度调整并非简单地对RGB三通道加减同一个值。它更接近于调整颜色的亮度(Luminance),同时尽力保持色相和饱和度不变。一种广泛认可且效果接近的算法是,先计算颜色的亮度L = 0.299*R + 0.549*G + 0.112*B(sRGB空间的亮度系数),然后根据明度增量deltaL,按比例调整RGB值,使其亮度变化为deltaL,同时保持(R/G, B/G)之类的比例大致不变。但这依然是个近似。

  2. 饱和度(Saturation): 这是最容易出问题的地方。标准的做法是对比RGB分量与亮度值的差异。公式通常为:newRGB = luminance + (oldRGB - luminance) * (1 + saturation)。当saturation = -1时,颜色变为灰度(RGB都等于亮度值)。这个公式在sRGB空间下直接使用,能较好地匹配PS的饱和度滑块行为。

  3. 色相(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 { "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内置的LinearToSRGBSRGBToLinear函数(在Core.hlsl中为LinearToSRGBSRGBToLinear)是高度优化的。我们应该优先使用它们,而不是自己写的近似函数。修改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的转换以及饱和度、明度计算,开销不小。对于移动平台或需要大量后处理的效果,优化至关重要。

  1. 预计算旋转矩阵:色相旋转角度hueAngle对于所有像素是相同的。我们可以在CPU端或顶点着色器计算好旋转所需的sinHuecosHue,然后作为常量传入片段着色器,避免每个像素都计算一次sincos

  2. 使用查找表(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转换中,中间值可能超出预期范围。
  • 修复方法:在关键计算步骤后添加clampsaturate,确保值在合理范围内。对于YCbCr,确保转换矩阵正确,并且旋转后的CbCr值没有溢出。可以使用fracfmod函数处理角度周期。

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可能无法直接使用。
  • 解决方案
    1. 将完整的色彩空间转换和PS算法代码封装在一个单独的.hlsl文件中。
    2. 在Custom Function节点的“引用”设置里,包含这个文件。
    3. 确保在Graph的Master Node设置中,颜色模式(Color Mode)与你的计算逻辑匹配(通常为“Linear”)。
    4. 最稳妥的方式是,避免在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中并排打开,用滑块反复对比,用吸管工具核对数值。这个过程很枯燥,但却是通往“准确”的唯一路径。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/22 9:09:49

大语言模型智能路由系统设计与实践

1. 项目背景与核心思路 2026年春季&#xff0c;三大主流大语言模型&#xff08;LLM&#xff09;Claude 4.6、GPT-5.4和Gemini 3.1 Pro相继完成重大版本迭代后&#xff0c;出现了一个有趣的现象&#xff1a;没有任何一个模型能在所有场景下保持绝对优势。这促使我开始思考如何构…

作者头像 李华
网站建设 2026/7/22 9:09:36

机器生产一切,唯独生产不了意义

机器人不是替代你&#xff0c;而是释放了那个你从未见过的自己一种全新的思想起点过去三年&#xff0c;所有关于AI和机器人的讨论都共享一个隐秘的预设&#xff1a;人类是一套待保护的遗产&#xff0c;机器是一股待抵御的洪流。 于是“替代”“冲击”“危机”成了关键词&#x…

作者头像 李华
网站建设 2026/7/22 9:09:33

2026GEO优化哪家好?3家主流服务商特点一次性讲清

根据 2026 年中国信通院《人工智能发展白皮书》、艾瑞咨询 AI 营销产业调研报告综合显示&#xff1a;2026 年国内 GEO 优化市场规模突破 30 亿元。超过 65% 的商家采购决策会先通过豆包、文心一言、ChatGPT 等 AI 工具查询品牌信息。可见&#xff0c;AI 问答渠道已经成为企业不…

作者头像 李华
网站建设 2026/7/22 9:07:55

VS code连接linux服务器不成功解决案例分享

vscode可以直接编辑linux服务器上的文件。首先需要vscode上安装remote-ssh扩展工具。安装完成后按F1输入rootip地址&#xff0c;然后会让选择平台&#xff0c;当然是linux平台了。接着要输入密码&#xff0c;接着就是连接失败。试了好多次&#xff0c;都是报错。就是这么个报错…

作者头像 李华
网站建设 2026/7/22 9:07:05

HarmonyOS应用开发实战:萌宠日记 - 记录列表与状态标签

HarmonyOS应用开发实战&#xff1a;萌宠日记 - 记录列表与状态标签 前言 记录列表 是健康记录页中展示 历史数据 的核心组件。在 萌宠日记 的 HealthRecordPage 中&#xff0c;记录列表以 日期、体重值、状态标签 三列布局展示每次的体重记录。状态标签 使用 绿色徽章 标识“正…

作者头像 李华
网站建设 2026/7/22 9:06:35

VC++ ProE二次开发实战:从环境搭建到高级应用

1. 项目概述&#xff1a;为什么选择VC进行ProE二次开发&#xff1f;在机械设计、模具制造这些行当里混了十几年&#xff0c;Pro Engineer&#xff08;现在叫Creo Parametric&#xff09;和它的二次开发&#xff0c;一直是个绕不开的话题。很多朋友&#xff0c;尤其是刚入行的工…

作者头像 李华