
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#脚本DynamicColorControllerusing 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 GetComponentRenderer(); // 重要这里使用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 GetComponentRenderer(); _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 DebuggerUnity的Frame DebuggerWindow - Analysis - Frame Debugger可以让你一步步查看渲染过程精确看到Draw Call、使用的Shader和传递的参数。当颜色不对时打开它选中对应的Draw Call查看其Shader Properties确认_ColorTint的值是否如你预期地被传递了进去。颜色干预是Shader编程的敲门砖它直接触及了片元着色器的本质——决定屏幕上每一个像素点的最终颜色。通过这个案例你不仅学会了如何用代码控制颜色更重要的是理解了数据从CPU到GPU再到屏幕的流动路径。掌握了这个基础后续再去学习光照模型、法线贴图、屏幕后处理等高级主题就会觉得有迹可循因为它们都是在不同阶段、以不同方式对这片元着色器输出的“最终颜色”进行干预和塑造。