1. 项目概述从“调色”到“着色”的思维跃迁刚接触Unity Shader的新手往往会被各种复杂的数学公式和图形学概念吓退。我记得自己最初面对那些满屏的float3、dot和saturate时也是一头雾水。但后来我发现理解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会超出originalColor和gray的连线向更纯的方向拉伸从而提高饱和度。但需要注意直接线性拉伸可能导致颜色分量超出 [0, 1] 范围产生过饱和失真因此通常需要配合saturate()函数进行钳制。那么如何计算亮度gray呢常见的有几种加权平均方法模拟人眼对不同颜色的敏感度简单平均gray (r g b) / 3.0。计算简单但不符合人眼感知。心理学权重推荐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 { RenderTypeOpaque } 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 }代码解析与实操要点属性块Properties我们添加了_Saturation属性类型为Range(0, 2)默认值1.0。范围设为0到2意味着我们可以将饱和度降至0完全灰度也可以提升至2倍过度饱和用于特殊效果。变量传递在CGPROGRAM内部我们使用half _Saturation;再次声明了同名变量。half是中等精度的浮点数对于范围在0-2的饱和度值足够用且比float更高效。核心函数ApplySaturation我们将算法封装成一个独立的函数提高代码可读性和复用性。dot(col, float3(0.299, 0.587, 0.114))这是核心计算用点积操作高效地计算加权亮度。lerp(grayColor, col, saturation)lerp是线性插值函数当saturation1时返回col为0时返回grayColor。saturate(...)这是一个内置函数将所有分量钳制在 [0, 1] 区间。当saturation 1时lerp结果可能超出1导致颜色过曝发白saturate能防止这种情况但也会损失部分高饱和度的色彩差异。对于艺术性需要极高饱和度的场景可以考虑移除saturate但需接受可能出现的“泛白”现象。在surf函数中调用在采样纹理和颜色之后我们将得到的c.rgb传入ApplySaturation函数进行处理然后将结果赋值给o.Albedo。3.2 材质创建与效果测试在Project窗口中右键点击刚才创建的Shader文件选择Create - Material。Unity会自动创建一个同名的材质球。将新建的材质球拖拽到场景中的一个物体上比如一个Cube或Sphere。选中该材质球在Inspector面板中你应该能看到一个名为“Saturation”的滑块。尝试拖动滑块滑向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 { RenderTypeOpaque IgnoreProjectorTrue RenderPipelineUniversalPipeline}对于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)表示当t0时返回at1时返回b。1. 检查点积权重系数是否正确应是三个和为1的正数。2. 确认lerp(grayColor, col, saturation)的顺序。如果想用saturation0得到灰度图这个顺序是正确的。饱和度调高后颜色“发白”或“过曝”未使用saturate钳制结果且颜色值超过1在显示时被截断为1导致所有高亮区域都变成纯白色失去细节。在lerp函数外包裹saturate()。如果因HDR需要不能钳制则需在后期或色调映射阶段处理过亮区域。饱和度效果“不对劲”灰色区域也变了色使用的亮度权重不符合人眼感知如用了简单平均(rgb)/3导致灰度基准不准。确保使用心理学权重float3(0.299, 0.587, 0.114)。这是最关键的一步。在URP/HDRP中Shader不工作表面着色器是Built-in渲染管线的特性。URP/HDRP使用Shader Graph或手写HLSL Unlit/Lit Shader。对于URP/HDRP1.使用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的参数使用half或fixed精度通常足够且比float更快。在移动端GPU上这点优化有积累效应。避免冗余计算如果_Saturation在材质实例中是不变的但我们的代码在每个像素都计算了dot(..., float3(0.299,0.587,0.114))中的常量向量。虽然GPU很快但理论上可以将这个常量向量声明为static const。不过现代着色器编译器非常智能通常会自动优化这种常量。为了代码清晰保持原样即可。复杂度权衡始终记住线性插值模型约4次乘加运算1次插值的性能远优于HSV转换模型包含条件判断和更多运算。除非艺术效果明确要求否则坚持使用简单模型。通过这个从原理到实践再到调试和进阶思考的完整流程你不仅学会了一个饱和度Shader的写法更重要的是掌握了修改和调试Unity表面着色器的基本方法论。下次当你需要实现对比度、色调偏移或者更复杂的颜色效果时就可以举一反三沿着同样的路径——理解原理、定位修改点、编写函数、集成测试、调试优化——去攻克它了。Shader学习之路就是这样从一个又一个具体的小效果积累起来最终汇聚成对图形管线的深刻理解。