Unity Shader颜色控制:从片元着色器到动态调色实战
1. 项目概述:从“黑盒”到“画笔”
刚接触Unity Shader那会儿,总觉得它像个神秘的黑盒,尤其是看到别人用几行代码就能让材质流光溢彩,自己却连基本的颜色都调不明白。今天这个案例,就是帮你撬开这个黑盒的第一道缝。我们不做复杂的特效,就干一件事:用Shader脚本,直接干预材质的最终输出颜色。
听起来简单,但这恰恰是理解Shader工作原理的基石。无论是后面想实现角色换肤、环境色调变化,还是更高级的动态效果,都得先过了“控制颜色”这一关。这个案例适合所有已经了解Shader基本结构(比如知道顶点着色器和片元着色器是干嘛的)但还不知如何下手的开发者。我们将从一个最简单的着色器开始,逐步添加颜色控制逻辑,并拆解其中每一行代码的意义。你会发现,Shader不是魔法,它只是一支更底层的“画笔”,让你能直接在GPU上“作画”。
2. 案例核心思路拆解:数据流与干预点
在动手写代码之前,我们必须想清楚:颜色信息在Shader里是怎么流动的?我们能在哪个环节“截住”它并加以修改?
2.1 理解Shader的渲染管线(简化版)
可以把一个物体的渲染过程想象成一条流水线:
- 顶点阶段:CPU将模型的顶点数据(位置、法线、UV等)传递给顶点着色器。
- 顶点着色器:它的核心任务是坐标变换。将每个顶点的位置从模型空间转换到裁剪空间,以便后续GPU进行光栅化。在这个阶段,我们也可以对顶点数据进行一些预处理,比如计算一些随时间变化的顶点偏移(模拟旗帜飘动),但通常不在这里直接计算最终颜色。
- 光栅化:GPU将处理后的三角形转换为屏幕上的一个个像素片段。
- 片元着色器:这是我们的主战场。每个像素片段都会执行一次片元着色器中的代码。在这里,我们可以获取到该像素的纹理颜色、光照信息(如果涉及光照计算)、以及从顶点着色器传递过来的各种插值数据,并最终输出一个颜色值(RGBA)。我们要做的颜色干预,99%发生在这个阶段。
- 输出合并:将片元着色器输出的颜色与帧缓冲区中已有的颜色进行混合(例如处理透明度)。
所以,我们的核心思路非常明确:在片元着色器中,获取到材质原本应该显示的颜色(可能是纯色,也可能是纹理采样结果),然后通过某种数学运算或逻辑,修改这个颜色值,最后输出。
2.2 干预策略:从覆写到调制
干预颜色不是简单地替换,根据需求不同,主要有两种策略:
- 覆写:完全不管材质原本的颜色,直接输出我们指定的新颜色。比如,实现一个纯红色的警示框。
- 调制:在材质原有颜色的基础上进行调整。这是更常用、更灵活的方式。例如:
- 乘法:
newColor = originalColor * tintColor。tintColor为(0.5, 1.0, 1.0)时,会减弱红色分量,保持绿和蓝,常用于整体色调调整。 - 加法:
newColor = originalColor + addColor。用于提亮或添加光晕效果。 - 混合:
newColor = lerp(originalColor, targetColor, blendFactor)。根据一个混合因子,在原始颜色和目标颜色之间平滑过渡,常用于变色、溶解效果。
- 乘法:
本案例我们将重点实践乘法调制,因为它最直观,也最符合“干预”而非“替换”的初衷。
3. 基础着色器搭建与颜色属性曝光
理论清晰了,我们开始动手。首先,在Unity中创建一个最简单的Unlit Shader作为我们的画板。
3.1 创建Unlit Shader模板
在Project窗口右键 -> Create -> Shader -> Unlit Shader。将其命名为SimpleColorIntervention。Unity会生成一个基础的无光照着色器,它已经包含了最基本的顶点/片元着色器结构和纹理采样功能,是我们理想的起点。
双击打开这个Shader文件,你会看到一堆代码。我们先聚焦在Properties块和片元着色器。
3.2 定义可调节的颜色属性
在Shader的Properties块中,我们定义可以在材质Inspector面板上调节的参数。我们在默认的_MainTex后面添加一个颜色属性。
Properties { _MainTex ("Texture", 2D) = "white" {} // 新增:一个用于颜色干预的色调属性 _ColorTint ("Color Tint", Color) = (1,1,1,1) }_ColorTint:这是我们给属性起的变量名,在Shader内部代码中会用到。("Color Tint", Color):括号内第一部分"Color Tint"是显示在材质面板上的友好名称;Color指定了该属性的类型为颜色。= (1,1,1,1):这是默认值,对应RGBA,(1,1,1,1)表示纯白且不透明。设置为白色意味着默认情况下不会对原有颜色产生任何影响(因为任何颜色乘以白色等于它本身)。
3.3 在CGPROGRAM中声明对应变量
在SubShader内部的CGPROGRAM和ENDCG之间,我们需要再次声明在Properties中定义的、需要在着色器程序中使用的变量。这相当于C#中的变量声明。
CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" // 声明与Properties块对应的变量 sampler2D _MainTex; float4 _MainTex_ST; // 纹理的缩放偏移值,用于支持材质面板的Tiling/Offset float4 _ColorTint; // 声明我们的颜色干预变量 struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; }注意,_MainTex_ST是Unity为每个sampler2D自动提供的配套变量,ST代表Scale和Translation,用于处理纹理的平铺和偏移。我们通过TRANSFORM_TEX宏来应用它。
3.4 在片元着色器中实施干预
现在来到最关键的一步,修改片元着色器frag函数。
fixed4 frag (v2f i) : SV_Target { // 1. 采样主纹理,获取该像素点原本的颜色 fixed4 col = tex2D(_MainTex, i.uv); // 2. 实施颜色干预:将采样得到的颜色与我们定义的色调相乘 col *= _ColorTint; // 3. 输出最终颜色 return col; } ENDCG代码解读:
fixed4 col = tex2D(_MainTex, i.uv);:tex2D是CG/HLSL中的纹理采样函数。它根据输入的UV坐标i.uv,从纹理_MainTex中取出对应的颜色值,存储为fixed4类型(一种精度较低但效率较高的四分量向量,适合颜色计算)。col *= _ColorTint;:这就是我们的干预核心。*=是乘法赋值运算符,等同于col = col * _ColorTint;。每个像素的原始颜色col的RGB三个通道,会分别与_ColorTint的RGB通道相乘。- 如果
_ColorTint是(1.0, 0.5, 0.5, 1.0),那么结果就是红色通道不变,绿色和蓝色通道减半,整体会偏向红色。 - 如果
_ColorTint是(0.0, 1.0, 1.0, 1.0),那么红色通道乘以0后为0,绿色和蓝色保留,最终得到青色。
- 如果
return col;:将计算后的颜色输出给渲染管线。
注意:这里使用的是乘法调制。如果你想尝试“覆写”,可以直接
return _ColorTint;,这样会完全忽略纹理。但通常我们更常用乘法,因为它能保留纹理的细节。
4. 在Unity中测试与验证
理论和大纲都有了,是时候看到实际效果了。
4.1 创建材质并应用Shader
- 在Project窗口右键 -> Create -> Material,创建一个新材质球。
- 将材质球的Shader选择为我们刚修改的
SimpleColorIntervention(或者你自定义的名字)。 - 你会立刻在材质Inspector面板上看到两个属性:“Texture”和“Color Tint”。
- 给“Texture”属性拖拽一张图片(比如一个砖墙纹理)。
- 点击“Color Tint”旁边的色块,会弹出颜色选择器。
4.2 进行颜色干预测试
现在,你可以通过调节“Color Tint”来实时观察效果:
- 将颜色选为红色(如RGB: 1, 0, 0):你会发现整个纹理变成了黑红色调,因为只有红色通道被保留(乘以1),绿色和蓝色通道都被消除了(乘以0)。
- 将颜色选为浅蓝色(如RGB: 0.7, 0.7, 1.0):整体色调会偏蓝,同时亮度有所变化。
- 将颜色选为灰色(如RGB: 0.5, 0.5, 0.5):所有颜色通道减半,效果等同于将整个材质调暗。
- 将Alpha值(A)调低:如果你在
Properties中定义的_ColorTint是Color类型(默认包含Alpha),调整A值会影响最终输出的透明度。但注意,这需要混合模式(Blend)的支持,我们的基础Unlit Shader默认是不透明(Opaque)的。要实现透明度变化,需修改SubShader的Tags和添加Blend指令,这属于进阶内容。
实操心得:
- 颜色乘法的物理意义:可以把它理解为在物体表面覆盖一层有颜色的透明滤光片。
_ColorTint的每个通道值就是该颜色滤光片的透光率。1.0表示完全透过,0.0表示完全阻挡。 - HDR颜色:在颜色选择器中,你可能会看到数值超过1.0。这属于HDR(高动态范围)颜色,常用于发光体。在普通的颜色调制中,大于1的值会导致“过曝”变白的效果,可以用来模拟自发光。但在我们的简单乘法中,如果纹理颜色本身是0.5,乘以2.0会得到1.0(被钳制),实际效果可能不如使用加法(
col += _ColorTint)或专门的自发光属性来得直观。
5. 进阶:实现动态颜色变化与参数化控制
静态调整颜色只是开始。在游戏中,我们更需要颜色能随时间、玩家输入或其他游戏状态动态变化。这就需要我们在Shader中引入时间或外部脚本参数。
5.1 利用内置时间变量实现动态变化
Unity的Shader提供了内置的时间变量,最常用的是_Time,它是一个float4类型,其四个分量分别是:
_Time.y:自场景加载以来的时间(秒),适用于慢速循环。_Time.x:时间的10倍,变化更快。_Time.z:时间的100倍。_Time.w:时间的1000倍。
我们可以修改片元着色器,让_ColorTint的某个通道随时间正弦波变化,实现呼吸灯或脉动效果。
首先,在CGPROGRAM中不需要特别声明_Time,它是全局可用的。然后修改frag函数:
fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // 动态计算色调:在原有_ColorTint的基础上,让红色通道随时间正弦波动 // sin(_Time.y) 的值在 [-1, 1] 之间波动 // 将其映射到 [0.2, 1.0] 之间,避免完全变黑 float dynamicRedFactor = (sin(_Time.y * 2.0) * 0.4 + 0.6); float4 dynamicTint = _ColorTint; dynamicTint.r *= dynamicRedFactor; // 只干预红色通道 col *= dynamicTint; return col; }这段代码会让材质的红色分量以2赫兹的频率在0.6 * _ColorTint.r到1.0 * _ColorTint.r之间平滑波动。你可以将_Time.y * 2.0中的2.0理解为频率系数,数值越大变化越快。
5.2 通过脚本传递参数实现更复杂的控制
内置时间变量虽然方便,但控制权在Shader内部。更多时候,我们需要从C#脚本中动态设置Shader的属性值。这就要用到MaterialPropertyBlock或直接修改Material的SetColor方法。
方法一:直接修改Material(适用于单个材质实例)
创建一个C#脚本DynamicColorController:
using UnityEngine; public class DynamicColorController : MonoBehaviour { public Color targetColor = Color.white; public float changeSpeed = 1.0f; private Renderer _renderer; private Material _material; private Color _currentColor; void Start() { _renderer = GetComponent<Renderer>(); // 重要:这里使用MaterialPropertyBlock是更优做法,见方法二。 // 为了演示,我们先获取材质实例。 _material = _renderer.material; // 这会创建该材质的一个实例 _currentColor = _material.GetColor("_ColorTint"); } void Update() { // 每帧向目标颜色插值 _currentColor = Color.Lerp(_currentColor, targetColor, Time.deltaTime * changeSpeed); // 将计算好的颜色传递给Shader的 _ColorTint 属性 _material.SetColor("_ColorTint", _currentColor); } void OnDestroy() { // 如果是通过 .material 创建的实例,需要手动销毁以防内存泄漏 if (_material != null) { Destroy(_material); } } }将这个脚本挂载到使用我们Shader材质的物体上。在Inspector中,你可以实时修改targetColor,物体会平滑过渡到新的色调。
重要注意事项:直接使用
renderer.material会在运行时创建该材质的一个新实例(Instance)。如果场景中有很多相同材质的物体,每个都这样操作会产生大量材质实例,增加Draw Call和内存开销。对于性能敏感的项目,不推荐此方法。
方法二:使用MaterialPropertyBlock(推荐,高性能)
MaterialPropertyBlock允许你覆盖材质的属性,而无需创建新的材质实例。所有使用同一共享材质的物体,都可以独立设置自己的属性覆盖。
修改上面的脚本:
using UnityEngine; public class DynamicColorController : MonoBehaviour { public Color targetColor = Color.white; public float changeSpeed = 1.0f; private Renderer _renderer; private MaterialPropertyBlock _propBlock; private Color _currentColor; void Start() { _renderer = GetComponent<Renderer>(); _propBlock = new MaterialPropertyBlock(); // 初始获取当前颜色(如果材质有默认值) _renderer.GetPropertyBlock(_propBlock); _currentColor = _propBlock.GetColor("_ColorTint"); // 如果没设置过,初始化为白色 if (_currentColor == default(Color)) _currentColor = Color.white; } void Update() { _currentColor = Color.Lerp(_currentColor, targetColor, Time.deltaTime * changeSpeed); // 使用MaterialPropertyBlock _renderer.GetPropertyBlock(_propBlock); // 先获取现有的属性块 _propBlock.SetColor("_ColorTint", _currentColor); // 设置新颜色 _renderer.SetPropertyBlock(_propBlock); // 应用属性块 } }这种方法效率高,且不会破坏材质实例的共享。是动态修改物体材质属性的最佳实践。
6. 常见问题与深度排查技巧
在实际操作中,你肯定会遇到各种“颜色不对”的情况。下面是一些典型问题及排查思路。
6.1 颜色干预完全没生效
可能原因及排查步骤:
- 属性名不匹配:检查Shader中声明的变量名(如
_ColorTint)是否与脚本中SetColor或SetPropertyBlock使用的字符串完全一致(大小写敏感)。 - 着色器编译错误:在Unity Console窗口查看是否有Shader编译错误(粉色错误信息)。即使代码有语法错误,Unity有时也会生成一个“粉红”的默认材质,但你的属性可能不会被识别。
- 材质未应用正确的Shader:双击确认材质球使用的Shader是否是你修改的那个。
- 片元着色器未执行:如果物体被其他物体完全遮挡,或者相机的裁剪平面设置不当,片元着色器可能不会运行。确保物体在相机视野内。
6.2 颜色显示异常(过亮、过暗、色偏)
可能原因及排查步骤:
- 颜色空间问题:Unity默认使用线性颜色空间(Linear Color Space),但如果你或你使用的纹理工作在伽马空间(Gamma Space),会导致颜色计算不准确。在Player Settings中确认颜色空间设置。对于颜色调制,通常确保输入输出都在同一空间即可。复杂的颜色运算可能需要转到线性空间计算。
- HDR与Tonemapping:如果你在场景中使用了后处理(Post-Processing)中的Tonemapping,非常亮(>1.0)的颜色会被压缩,可能导致视觉上的非线性变化。调试时可以暂时关闭后处理栈。
- 纹理采样设置:检查纹理的导入设置。如果纹理是sRGB(颜色纹理),勾选“sRGB (Color Texture)”;如果是法线贴图等非颜色数据,则不要勾选。错误的设置会导致颜色值在被Shader读取前就被错误地转换。
- Shader精度问题:我们使用了
fixed4来存储颜色。在移动平台或某些情况下,fixed(低精度)的范围是[-2, 2],如果颜色值超出这个范围(比如HDR颜色),计算可能会出错。可以尝试改用half4(中精度)或float4(高精度)。将fixed4 col改为half4 col。
6.3 性能优化与小技巧
- 减少不必要的计算:动态颜色计算如果每帧都在片元着色器中进行(如使用
_Time),会对所有像素执行,消耗较大。如果效果允许,考虑在顶点着色器中计算一个颜色因子,然后通过v2f结构体插值到片元着色器,性能会好很多。 - 利用材质面板的预设:对于常用的色调(如受伤时的红色、中毒时的紫色),可以直接在材质面板上保存为不同的材质预设,通过脚本切换材质,而不是动态计算颜色。这在效果固定且物体不多时更简单。
- 调试利器:Frame Debugger:Unity的Frame Debugger(Window -> Analysis -> Frame Debugger)可以让你一步步查看渲染过程,精确看到Draw Call、使用的Shader和传递的参数。当颜色不对时,打开它,选中对应的Draw Call,查看其
Shader Properties,确认_ColorTint的值是否如你预期地被传递了进去。
颜色干预是Shader编程的敲门砖,它直接触及了片元着色器的本质——决定屏幕上每一个像素点的最终颜色。通过这个案例,你不仅学会了如何用代码控制颜色,更重要的是理解了数据从CPU到GPU,再到屏幕的流动路径。掌握了这个基础,后续再去学习光照模型、法线贴图、屏幕后处理等高级主题,就会觉得有迹可循,因为它们都是在不同阶段、以不同方式对这片元着色器输出的“最终颜色”进行干预和塑造。