Unity Shader饱和度调整:从原理到表面着色器实战实现
2026/8/9 2:20:08 网站建设 项目流程

1. 项目概述:从“调色”到“着色”的思维跃迁

刚接触Unity Shader的新手,往往会被各种复杂的数学公式和图形学概念吓退。我记得自己最初面对那些满屏的float3dotsaturate时,也是一头雾水。但后来我发现,理解Shader最好的切入点,恰恰是那些我们日常最熟悉的概念,比如今天要聊的饱和度。你可能在Photoshop里拉过饱和度滑块,让一张灰蒙蒙的照片瞬间变得鲜艳生动。在Unity Shader的世界里,实现同样的效果,不仅是给游戏画面“美颜”,更是理解颜色如何在GPU中被运算和操控的绝佳范例。这个项目,就是带你亲手修改一个Unity的表面着色器,深入其内部,去实现一个可控的饱和度调整功能。这不仅仅是复制一段代码,而是通过这个具体的“手术”,让你看清表面着色器的结构,理解片元着色器如何操作每一个像素的颜色,并掌握饱和度算法背后的数学原理。无论你是想为你的游戏角色添加一个“中毒”时的去色效果,还是想实现场景季节变换时植被色彩的浓淡变化,亦或是单纯想攻克对Shader的恐惧,这篇内容都将是一份直击要害的实操指南。

2. 核心原理:饱和度到底是什么?

在动手写代码之前,我们必须先搞清楚要操作的对象。饱和度,在色彩学中指的是颜色的纯度或鲜艳程度。一个完全饱和的颜色是其最纯粹、最鲜艳的状态,不含任何灰色成分。当我们向一种颜色中加入灰色(等量的红、绿、蓝),它的饱和度就会降低,颜色会变得浑浊、暗淡。反之,减少灰色成分,颜色就会变得更鲜艳。

在RGB色彩模型中,我们如何量化这种“灰色成分”呢?这里就需要引入一个关键概念:亮度。亮度可以粗略地理解为颜色中红、绿、蓝分量的平均值,它代表了颜色的明暗程度,也近似于我们感知到的灰色值。一个经典的饱和度调整算法思路就是:将当前颜色向它的亮度值(即灰色)方向混合,以降低饱和度;或者远离亮度值方向,以提高饱和度。

2.1 算法拆解:从直觉到公式

最常用且直观的饱和度调整算法是基于亮度进行线性插值。其核心公式可以表述为:

finalColor = lerp(gray, originalColor, saturationFactor)

这里,gray是原始颜色的亮度值(一个标量),我们需要将它扩展为一个RGB颜色(gray, gray, gray)originalColor是原始像素颜色。saturationFactor是饱和度系数。

  • saturationFactor = 1时,finalColor = originalColor,饱和度不变。
  • saturationFactor = 0时,finalColor = gray,图像完全去色,变为灰度图。
  • saturationFactor > 1时,finalColor会超出originalColorgray的连线,向更纯的方向拉伸,从而提高饱和度。但需要注意,直接线性拉伸可能导致颜色分量超出 [0, 1] 范围,产生过饱和失真,因此通常需要配合saturate()函数进行钳制。

那么,如何计算亮度gray呢?常见的有几种加权平均方法,模拟人眼对不同颜色的敏感度:

  1. 简单平均gray = (r + g + b) / 3.0。计算简单,但不符合人眼感知。
  2. 心理学权重(推荐)gray = dot(originalColor, float3(0.299, 0.587, 0.114))。这是最常用的公式,源于ITU-R BT.601标准,它认为人眼对绿色最敏感,红色次之,蓝色最不敏感。

在我们的Shader实现中,将采用第二种方法,因为它能产生更自然、符合人眼观察的灰度图。

2.2 表面着色器框架初探

Unity的表面着色器是一种高级抽象,它帮你处理了光照模型、阴影、前向/延迟渲染路径等复杂问题。一个基本的表面着色器模板通常包含以下关键部分:

  • Properties块:定义在材质面板中可调节的属性,如颜色、纹理、浮点数等。我们的饱和度系数将在这里声明。
  • SubShader & CGPROGRAM:着色器代码的核心区域。
  • 表面函数(surf):这是你编写的主要函数。它的输入是一个结构体Input,通常包含UV坐标等信息;它的输出是SurfaceOutputStandard(或类似)结构体,用于填充表面的各项属性(如漫反射颜色Albedo、法线Normal、金属度Metallic等)。
  • 顶点函数(可选):用于修改顶点数据,如实现顶点动画。

我们的目标,就是在片元着色的阶段(具体体现在surf函数中计算Albedo时),对采样到的纹理颜色应用上述饱和度算法。

注意:很多新手会混淆“在Properties里声明了变量”和“在CG代码里使用它”这两个步骤。声明只是创建了材质面板的接口,要在CG程序中使用,必须在CGPROGRAM内部用同名的变量再次声明一次,这被称为“变量传递”。

3. 实战:修改表面着色器实现饱和度控制

接下来,我们从一个标准的Standard Surface Shader开始,逐步将其改造为支持饱和度调节的着色器。我会假设你使用的是Unity内置的Standard光照模型,这是最通用的情况。

3.1 创建基础着色器与属性定义

首先,在Unity项目中创建一个新的Shader文件,命名为“Custom/SaturationShader”。打开后,清空内容,我们从最基础的模板开始。

Shader "Custom/SaturationShader" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Metallic ("Metallic", Range(0,1)) = 0.0 _Smoothness ("Smoothness", Range(0,1)) = 0.5 // 添加我们的饱和度属性 _Saturation ("Saturation", Range(0, 2)) = 1.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM // 使用Standard光照模型,并启用阴影 #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; fixed4 _Color; half _Metallic; half _Smoothness; // 在CGPROGRAM中再次声明饱和度变量,使其在着色器程序中可用 half _Saturation; struct Input { float2 uv_MainTex; }; // 饱和度调整函数 float3 ApplySaturation(float3 col, float saturation) { // 计算心理学权重下的亮度 float luminance = dot(col, float3(0.299, 0.587, 0.114)); // 构造灰度颜色 float3 grayColor = float3(luminance, luminance, luminance); // 根据饱和度系数,在灰度色和原色之间插值 // saturate函数确保结果在0-1范围内,防止过饱和时出现异常颜色 return saturate(lerp(grayColor, col, saturation)); } void surf (Input IN, inout SurfaceOutputStandard o) { // 采样主纹理并乘以色调颜色 fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; // 对RGB通道应用饱和度调整 float3 saturatedColor = ApplySaturation(c.rgb, _Saturation); // 输出到表面结构体 o.Albedo = saturatedColor; o.Metallic = _Metallic; o.Smoothness = _Smoothness; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" }

代码解析与实操要点:

  1. 属性块(Properties):我们添加了_Saturation属性,类型为Range(0, 2),默认值1.0。范围设为0到2,意味着我们可以将饱和度降至0(完全灰度),也可以提升至2倍(过度饱和,用于特殊效果)。
  2. 变量传递:在CGPROGRAM内部,我们使用half _Saturation;再次声明了同名变量。half是中等精度的浮点数,对于范围在0-2的饱和度值足够用,且比float更高效。
  3. 核心函数ApplySaturation:我们将算法封装成一个独立的函数,提高代码可读性和复用性。
    • dot(col, float3(0.299, 0.587, 0.114)):这是核心计算,用点积操作高效地计算加权亮度。
    • lerp(grayColor, col, saturation)lerp是线性插值函数,当saturation=1时返回col,为0时返回grayColor
    • saturate(...):这是一个内置函数,将所有分量钳制在 [0, 1] 区间。当saturation > 1时,lerp结果可能超出1,导致颜色过曝发白,saturate能防止这种情况,但也会损失部分高饱和度的色彩差异。对于艺术性需要极高饱和度的场景,可以考虑移除saturate,但需接受可能出现的“泛白”现象。
  4. 在surf函数中调用:在采样纹理和颜色之后,我们将得到的c.rgb传入ApplySaturation函数进行处理,然后将结果赋值给o.Albedo

3.2 材质创建与效果测试

  1. 在Project窗口中,右键点击刚才创建的Shader文件,选择Create -> Material。Unity会自动创建一个同名的材质球。
  2. 将新建的材质球拖拽到场景中的一个物体上(比如一个Cube或Sphere)。
  3. 选中该材质球,在Inspector面板中,你应该能看到一个名为“Saturation”的滑块。
  4. 尝试拖动滑块:
    • 滑向0:物体会逐渐失去色彩,最终变成灰度图像。
    • 保持在1:物体保持原色。
    • 滑向2:物体会变得更加鲜艳,颜色对比更强烈。

实操心得:

  • 测试用例选择:使用一张色彩丰富但包含中性灰的纹理进行测试效果最好。你可以找一张有蓝天、绿树、红花和灰色岩石的图片。调整饱和度时,观察灰色部分(如岩石)应该几乎不变,而彩色部分(花、树叶)变化明显,这证明我们的亮度权重计算是有效的。
  • 性能考量ApplySaturation函数中的操作非常轻量(一次点积、一次构造、一次插值、一次钳制),即使在移动设备上,对每个像素执行这样的操作开销也微乎其微。这是实时图形学中性价比极高的后处理效果之一。

4. 进阶:整合到片元着色器与性能优化

上面的实现已经可以工作,但它是作用在Albedo(漫反射颜色)上。有时我们可能希望饱和度只影响纹理本身,而不影响后来叠加的_Color。这需要对计算顺序稍作调整。

4.1 调整计算顺序

void surf (Input IN, inout SurfaceOutputStandard o) { // 先采样纹理 fixed4 texColor = tex2D (_MainTex, IN.uv_MainTex); // 对纹理颜色应用饱和度调整 float3 saturatedTexColor = ApplySaturation(texColor.rgb, _Saturation); // 再将调整后的纹理颜色与色调颜色相乘 fixed3 finalAlbedo = saturatedTexColor * _Color.rgb; o.Albedo = finalAlbedo; o.Metallic = _Metallic; o.Smoothness = _Smoothness; o.Alpha = texColor.a * _Color.a; }

这样修改后,_Saturation滑块将只控制纹理本身的饱和度,之后再与_Color色调混合。这种顺序更符合“先调整基础纹理属性,再应用整体染色”的直觉。

4.2 探讨更复杂的饱和度模型

线性插值模型简单高效,但有时可能不符合所有艺术需求。例如,在提高饱和度时,我们可能希望保护肤色不过度变橙,或者让天空的蓝色更突出而不影响其他颜色。这就引出了更复杂的饱和度调整算法,例如基于HSV色彩空间的调整,或者在YCbCr颜色空间中进行操作。

HSV空间调整示例(概念性代码):

float3 ApplySaturation_HSV(float3 rgb, float saturation) { float3 hsv = RgbToHsv(rgb); // 假设有一个RGB转HSV的函数 hsv.y *= saturation; // HSV中的.y分量是饱和度S hsv.y = clamp(hsv.y, 0, 1); return HsvToRgb(hsv); // HSV转回RGB }

HSV模型更直观,因为它直接将饱和度作为一个独立分量。然而,RGB和HSV之间的转换涉及更多分支和计算(如求最大值、最小值、差值、模运算),性能开销远大于线性插值模型。在移动端或需要处理大量像素的后处理中,应谨慎使用。

注意事项:除非有明确的、线性模型无法满足的艺术指导需求,否则建议始终优先使用基于亮度加权平均的线性插值模型。它的视觉结果在大多数情况下足够好,且性能优异。过早优化是万恶之源,但无谓的复杂化也是。

4.3 在片元着色器中处理HDR颜色

如果你的项目启用了HDR(高动态范围),颜色值可能超过1。我们的ApplySaturation函数中的saturate()会破坏HDR信息。对于HDR管线,我们需要调整策略:

float3 ApplySaturation_HDR(float3 col, float saturation) { float luminance = dot(col, float3(0.299, 0.587, 0.114)); float3 grayColor = float3(luminance, luminance, luminance); // 移除saturate,允许结果超出1,保留高亮信息 return lerp(grayColor, col, saturation); }

同时,确保着色器标签支持HDR混合:Tags { "RenderType"="Opaque" "IgnoreProjector"="True" "RenderPipeline"="UniversalPipeline"}(对于URP)或在Built-in管线中正确设置HDR。

5. 常见问题与排查技巧实录

即使跟着步骤做,你也可能会遇到一些“坑”。下面是我在实际开发和教学过程中,学员们最常遇到的问题及解决方法。

5.1 问题速查表

问题现象可能原因解决方案
材质面板上没有Saturation滑块1. Properties块中的变量名与CGPROGRAM中声明的变量名不一致。
2. 着色器编译错误,导致整个属性面板无法正常显示。
1. 仔细检查拼写,确保_Saturation在两个地方完全一致(包括下划线)。
2. 查看Console窗口中的错误信息,修正所有编译错误(如缺少分号、括号不匹配等)。
调整饱和度滑块没有任何效果1. 在surf函数中没有使用_Saturation变量。
2. 饱和度计算函数ApplySaturation逻辑有误,或返回值未被正确赋值给o.Albedo
3._Saturation值被固定为1(例如在代码中写死了)。
1. 确认_Saturation被传入ApplySaturation函数。
2. 在ApplySaturation函数内部添加简单的测试,如return float3(_Saturation, 0, 0);看物体是否变红,以确定函数被调用。
3. 检查是否有_Saturation = 1.0;这样的赋值语句覆盖了材质面板的值。
物体变成全黑或全白1. 亮度计算错误,导致luminance为0或极大值。
2.lerp函数参数顺序错误。lerp(a, b, t)表示当t=0时返回a,t=1时返回b。
1. 检查点积权重系数是否正确,应是三个和为1的正数。
2. 确认lerp(grayColor, col, saturation)的顺序。如果想用saturation=0得到灰度图,这个顺序是正确的。
饱和度调高后颜色“发白”或“过曝”未使用saturate钳制结果,且颜色值超过1,在显示时被截断为1,导致所有高亮区域都变成纯白色,失去细节。lerp函数外包裹saturate()。如果因HDR需要不能钳制,则需在后期或色调映射阶段处理过亮区域。
饱和度效果“不对劲”,灰色区域也变了色使用的亮度权重不符合人眼感知(如用了简单平均(r+g+b)/3),导致灰度基准不准。确保使用心理学权重:float3(0.299, 0.587, 0.114)。这是最关键的一步。
在URP/HDRP中Shader不工作表面着色器是Built-in渲染管线的特性。URP/HDRP使用Shader Graph或手写HLSL Unlit/Lit Shader。对于URP/HDRP
1.使用Shader Graph:创建PBR Graph或Unlit Graph,使用“Adjustment”分类下的“Saturation”节点,这是最推荐的方式。
2.手写HLSL:需要编写一个URP Lit Shader,在片元着色器中对surfaceData.albedo进行相同的饱和度计算。框架完全不同,需参考URP官方文档。

5.2 调试技巧:可视化中间值

当你对效果不确定时,一个强大的技巧是可视化中间计算值。你可以临时修改surf函数的输出,来检查某个变量的状态。

例如,你想确认亮度计算是否正确:

void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; float luminance = dot(c.rgb, float3(0.299, 0.587, 0.114)); // 将亮度值直接输出为颜色(灰度图) o.Albedo = float3(luminance, luminance, luminance); // 暂时注释掉Metallic和Smoothness,避免干扰 // o.Metallic = _Metallic; // o.Smoothness = _Smoothness; o.Alpha = c.a; }

如果此时物体显示为一张正确的灰度图,说明你的亮度计算没问题。然后再逐步恢复后续的饱和度插值计算。

5.3 性能分析与优化

对于这个简单的操作,性能通常不是瓶颈。但养成好习惯很重要:

  • 精度选择:对于颜色和范围在0-2的参数,使用halffixed精度通常足够,且比float更快。在移动端GPU上,这点优化有积累效应。
  • 避免冗余计算:如果_Saturation在材质实例中是不变的,但我们的代码在每个像素都计算了dot(..., float3(0.299,0.587,0.114))中的常量向量。虽然GPU很快,但理论上可以将这个常量向量声明为static const。不过,现代着色器编译器非常智能,通常会自动优化这种常量。为了代码清晰,保持原样即可。
  • 复杂度权衡:始终记住,线性插值模型(约4次乘加运算+1次插值)的性能远优于HSV转换模型(包含条件判断和更多运算)。除非艺术效果明确要求,否则坚持使用简单模型。

通过这个从原理到实践,再到调试和进阶思考的完整流程,你不仅学会了一个饱和度Shader的写法,更重要的是掌握了修改和调试Unity表面着色器的基本方法论。下次当你需要实现对比度、色调偏移或者更复杂的颜色效果时,就可以举一反三,沿着同样的路径——理解原理、定位修改点、编写函数、集成测试、调试优化——去攻克它了。Shader学习之路就是这样,从一个又一个具体的小效果积累起来,最终汇聚成对图形管线的深刻理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询