1. 项目概述:为什么Unity着色器是每个开发者必须跨越的门槛?
如果你在Unity里做过一些项目,尤其是涉及到视觉效果的部分,大概率已经和Shader打过交道了。你可能用过Standard、URP Lit这些内置材质,也尝试过在Asset Store下载一些炫酷的粒子或水面特效。但当你想要实现一个独特的视觉效果,比如让角色在特定条件下发光、让场景物体呈现X光透视效果,或者仅仅是调整一下高光的形状时,你会发现内置材质和现成资源突然“失灵”了。这时,Unity着色器——这个听起来有些神秘的技术——就成了你绕不开的坎。
很多人对Shader(着色器)望而却步,觉得它涉及图形学、数学,是“大神”的领域。但我想说,掌握Unity着色器,尤其是从ShaderLab语法到顶点/片元着色器这一套流程,并没有想象中那么难。它更像是一套“翻译规则”,你告诉GPU如何绘制屏幕上的每一个像素。理解这套规则,你就能从“资源使用者”转变为“效果创造者”,彻底摆脱对现成资源的依赖,实现任何你脑海中想象的画面。无论是为了优化性能(比如合并渲染批次),还是为了在面试中脱颖而出(“Unity八股文”里Shader可是常客),亦或是为了做出更具风格化的独立游戏,这门技术都至关重要。
本章的目标,就是为你搭建一条从零到一的清晰路径。我们将从最上层的ShaderLab配置文件开始,理解Unity如何组织一个Shader;然后深入到CG/HLSL代码中,亲手编写一个最简单的顶点/片元着色器,弄明白模型数据是如何从3D空间一步步变成屏幕上绚丽的2D图像的。我们会避开那些一上来就大谈矩阵和光照模型的“劝退”教学,而是从“能跑起来、能看到效果”的最小实例出发,在实践中逐步理解核心概念。
2. ShaderLab:Unity着色器的“总说明书”
在深入GPU代码之前,我们必须先理解ShaderLab。你可以把它看作是Unity着色器的“外壳”或“总说明书”。一个.shader文件的主体结构就是由ShaderLab语言定义的。它不负责具体的像素计算,而是告诉Unity引擎:这个着色器叫什么名字、属于哪个渲染管线、有哪些属性可以暴露给材质面板、需要准备哪些纹理和数值给GPU、以及如何设置渲染状态(比如是否透明、是否写入深度等)。
2.1 ShaderLab的基本结构与语义
一个最基础的ShaderLab文件结构如下所示。即使你暂时看不懂每一行的具体含义,也请先感受一下它的组织方式:
Shader "Custom/MyFirstShader" { Properties { // 在这里定义暴露在材质面板上的属性 _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Glossiness ("Smoothness", Range(0,1)) = 0.5 } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM // 真正的顶点/片元着色器代码写在这里 #pragma vertex vert #pragma fragment frag // ... 其他CG代码 ENDCG } } FallBack "Diffuse" }我们来逐块拆解:
Shader "Custom/MyFirstShader": 这是着色器的声明和它在材质下拉菜单中的路径。Custom/是分类,MyFirstShader是具体名称。好的命名和分类能让你的Shader在庞大的项目里易于管理。Properties块: 这是着色器与美术师、策划沟通的“窗口”。所有在这里定义的变量,都会出现在材质球的Inspector面板上。_MainTex定义了一张2D纹理,默认是白色;_Color定义了一个颜色;_Glossiness定义了一个0到1范围的滑杆。这些属性值可以在脚本中动态修改,实现游戏逻辑与视觉效果的联动。SubShader块: 一个Shader可以包含多个SubShader。Unity会从上到下检查每个SubShader是否满足当前运行平台的“需求”(比如渲染管线、Shader Model版本),并使用第一个满足条件的。这常用于为高端和低端设备提供不同复杂度的着色器版本。Tags和LOD是SubShader的元数据。Pass块: 这是渲染的核心。一个SubShader可以包含多个Pass,每个Pass代表一次完整的绘制流程。多Pass常用于实现复杂的叠加效果,比如先画轮廓再画主体。但请注意,每个Pass都意味着一次Draw Call,滥用会严重损害性能。对于大多数情况,单Pass就足够了。CGPROGRAM和ENDCG: 这对标签包裹着用CG或HLSL(Unity支持两者)编写的高阶着色器语言代码。这就是我们常说的“顶点着色器”和“片元着色器”程序所在的地方。ShaderLab负责“搭台”,CG/HLSL负责“唱戏”。FallBack: 当所有SubShader都不支持当前设备时,就会使用这个后备着色器。通常指定为Diffuse或Standard,确保物体至少能被看见,而不是变成可怕的粉红色(Missing Shader)。
注意: Unity正在推动从传统的CG语言向HLSL过渡。在较新的Unity版本和URP/HDRP管线中,推荐直接使用HLSL。但两者的基础语法非常相似,本章的核心理念完全通用。在
CGPROGRAM块内,你可以使用#include "UnityCG.cginc"来引入Unity内置的辅助函数和数据结构,这是你编写Shader的强大后盾。
2.2 关键属性(Properties)类型与渲染状态设置
Properties块支持多种数据类型,理解它们对制作友好的材质面板至关重要:
2D: 纹理采样器。"white" {},"black" {},"gray" {},"bump" {}是常用的默认纹理。Color/Vector: 颜色或四维向量。颜色在面板上以拾色器显示。Float/Range(min, max): 浮点数。Range会显示为一个滑杆,非常适合控制强度、阈值等参数。Int: 整数。Cube: 立方体贴图,用于天空盒或反射。
在Pass块中,你可以使用ShaderLab命令设置渲染状态。这些命令直接影响GPU的渲染行为:
Cull Back | Front | Off: 设置多边形剔除模式。Back(默认)剔除背面,Front剔除正面(用于从内向外看的模型,如球体内壁),Off用于双面渲染(如树叶、玻璃),但性能消耗更高。ZWrite On | Off: 是否写入深度缓冲。对于透明物体,通常设为Off,并配合ZTest使用,以防止后面的透明像素错误地遮挡前面的。Blend SrcFactor DstFactor: 设置混合模式。这是实现透明、叠加等效果的关键。例如,Blend SrcAlpha OneMinusSrcAlpha是最常见的透明混合公式。AlphaToMask On: 用于解决透明物体边缘锯齿(Alpha Test)的质量问题,尤其在植被渲染中常用。
实操心得: 不要一开始就尝试把所有高级状态都用上。从一个最简单的、Cull BackZWrite On的不透明Pass开始。当你需要透明效果时,再引入Blend和调整ZWrite。每增加一个状态,都要问自己是否真的需要,因为复杂的混合和深度测试会增加GPU的负担和出错的概率。
3. 顶点与片元着色器核心原理拆解
现在,我们穿过ShaderLab的外壳,进入真正的可编程着色器阶段:顶点着色器(Vertex Shader)和片元着色器(Fragment Shader, 在DirectX中常称为Pixel Shader)。这是GPU渲染管线的两个核心可编程阶段。理解数据在这两者之间的流动,是编写任何自定义着色器的基石。
3.1 渲染管线中的数据流:从模型到像素
想象一下GPU渲染一个三角形的过程:
- 输入装配: GPU从内存中读取模型的顶点数据(位置、法线、UV、颜色等)。
- 顶点着色器(我们编写):每个顶点执行一次。我们在这里接收一个顶点的所有输入数据,并必须输出该顶点在齐次裁剪空间中的位置。这是我们能做很多“魔法”的地方:比如顶点动画(让旗帜飘动)、程序化变形、或者仅仅是基础的模型-视图-投影变换。
- 图元装配与光栅化: GPU将顶点连接成三角形(图元),然后将这个三角形“分解”成覆盖在屏幕上的一个个片元。你可以把片元理解为“候选像素”,它包含了屏幕位置、深度、以及从顶点着色器输出并经过插值后的各种数据(如颜色、UV)。
- 片元着色器(我们编写):每个片元执行一次。这是我们决定屏幕上最终像素颜色的地方。我们在这里进行纹理采样、光照计算、颜色混合等所有复杂的运算。输出的是一个颜色值(RGBA)。
- 逐片元操作: 测试深度、模板,执行在ShaderLab中设置的混合(Blend)操作,最终将颜色写入帧缓冲区。
关键理解: 顶点着色器处理“点”,片元着色器处理“面(上的点)”。顶点着色器中输出的数据(除了必须的裁剪空间位置),会被GPU自动在三角形面上进行插值,然后传递给片元着色器。这就是为什么我们在顶点着色器里计算光照可能不准确(只在顶点亮,面内部暗),而应该在片元着色器里计算的原因(每个像素都算,效果平滑)。
3.2 编写第一个自定义顶点/片元着色器
让我们动手写一个最简单的、带纹理和颜色调制的着色器。请在Unity中创建一个新的Shader文件,并清空内容,粘贴以下代码:
Shader "Custom/BasicTexture" { Properties { _MainTex ("Main Texture", 2D) = "white" {} _TintColor ("Tint Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 确保在声明变量前包含必要的库 #include "UnityCG.cginc" // 定义与Properties块对应的变量 sampler2D _MainTex; float4 _MainTex_ST; // 非常重要!包含了纹理的缩放(Scale)和平移(Translation)值 fixed4 _TintColor; // 从模型网格传入的数据 struct appdata { float4 vertex : POSITION; // 顶点位置(模型空间) float2 uv : TEXCOORD0; // 第一套UV坐标 }; // 从顶点着色器传递给片元着色器的数据 struct v2f { float2 uv : TEXCOORD0; // 传递UV float4 vertex : SV_POSITION; // 裁剪空间位置(必须) }; // 顶点着色器函数 v2f vert (appdata v) { v2f o; // 将顶点从模型空间变换到裁剪空间 o.vertex = UnityObjectToClipPos(v.vertex); // 应用纹理的缩放和平移偏移到UV上 o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } // 片元着色器函数 fixed4 frag (v2f i) : SV_Target { // 采样主纹理 fixed4 col = tex2D(_MainTex, i.uv); // 与色调颜色相乘 col *= _TintColor; return col; } ENDCG } } FallBack "Diffuse" }代码逐行解析:
#pragma指令: 告诉Unity编译哪个函数是顶点着色器(vert),哪个是片元着色器(frag)。函数名可以自定义,但必须与这里声明的对应。#include "UnityCG.cginc": 引入Unity内置的CG库。里面包含了UnityObjectToClipPos、TRANSFORM_TEX等极其有用的函数和宏定义。- 变量声明:
sampler2D _MainTex对应Properties中的纹理。float4 _MainTex_ST是一个隐式变量,xy分量是纹理缩放(Tiling),zw分量是纹理偏移(Offset),由材质面板上的设置驱动。fixed4是一种低精度(但足够用于颜色)的数据类型,有利于优化。 - 结构体
appdata: 定义了从3D模型(如Mesh Filter)输入到顶点着色器的数据结构。POSITION、TEXCOORD0、NORMAL、TANGENT等都是语义,告诉GPU这个数据是什么用途。你可以根据需要添加更多数据。 - 结构体
v2f: 顶点着色器输出、并经过插值后输入到片元着色器的数据结构。SV_POSITION是系统值语义,表示裁剪空间位置,必须赋值。 UnityObjectToClipPos函数: 它等价于mul(UNITY_MATRIX_MVP, v.vertex)(旧版),帮你一次性完成从模型空间到裁剪空间的矩阵乘法。这是顶点着色器里最核心的一行代码。TRANSFORM_TEX宏: 它等价于v.uv * _MainTex_ST.xy + _MainTex_ST.zw。这是一个好习惯,确保材质面板上的Tiling和Offset设置生效。tex2D函数: 在片元着色器中,使用UV坐标对纹理进行采样,获取该点的颜色。SV_Target语义: 表示片元着色器的输出目标是渲染目标(通常是屏幕)。
将这个Shader赋给一个材质,并拖拽一张纹理,你就能看到效果了。尝试调整材质面板上的Tint Color,观察颜色如何与纹理混合。
4. 核心功能进阶:法线、光照与简单特效
掌握了基础纹理映射后,我们可以向更真实、更动态的效果迈进。引入法线和光照计算,是让物体从“贴图”变成“实体”的关键一步。
4.1 引入法线信息与基础光照模型
在真实的渲染中,颜色很大程度上取决于光线与物体表面法线的夹角。我们需要修改输入结构体,将法线信息从顶点着色器传递到片元着色器。
首先,更新appdata和v2f结构体,添加法线信息。同时,我们还需要将法线从模型空间变换到世界空间(因为光照计算通常在世界空间进行)。
struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 添加法线 float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; // 用于传递世界空间法线 float3 worldPos : TEXCOORD2; // 传递世界空间位置,用于计算光线方向 };接着,在顶点着色器中计算世界空间下的法线和位置:
v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 将法线从模型空间变换到世界空间 o.worldNormal = UnityObjectToWorldNormal(v.normal); // 将顶点位置从模型空间变换到世界空间 o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; }现在,在片元着色器中,我们可以实现一个简单的漫反射光照模型(Lambert)。我们需要一个光源的方向和颜色。在Unity中,最简单的方法是使用内置变量_WorldSpaceLightPos0(对于平行光,其xyz分量是方向,w为0)和_LightColor0。
fixed4 frag (v2f i) : SV_Target { // 采样纹理 fixed4 col = tex2D(_MainTex, i.uv); // 归一化插值后的世界法线(重要!) float3 worldNormal = normalize(i.worldNormal); // 获取平行光方向(假设是第一个平行光) float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); // 计算兰伯特漫反射系数: max(0, dot(N, L)) float lambert = max(0, dot(worldNormal, lightDir)); // 结合光照颜色 fixed3 diffuse = _LightColor0.rgb * lambert; // 最终颜色 = 纹理颜色 * 色调 * (环境光 + 漫反射光) // 使用Unity内置的环境光变量 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb; col.rgb *= _TintColor.rgb * (ambient + diffuse); return col; }这个Shader现在会对场景中的主平行光产生反应。旋转物体或改变光源方向,你会发现明暗变化。这就是最基本的逐片元漫反射光照。
注意事项:
- 法线归一化: 在片元着色器中必须对
i.worldNormal进行normalize。因为顶点法线在插值过程中长度可能会发生变化,不再是单位向量。 - 光源依赖: 这个简单的例子依赖于场景中存在一个有效的平行光,并且Shader需要编译正确的光照模式标签。通常需要在SubShader的Tags中添加
"LightMode"="ForwardBase",以获取前向渲染基础通道的光照信息。 - 性能考量: 逐片元计算光照比逐顶点计算更耗性能,但效果也细腻得多。对于移动平台或大量重复物体,需要权衡。
4.2 实现动态顶点动画与边缘光特效
着色器的魅力在于动态效果。顶点动画可以直接在顶点着色器中修改顶点位置。例如,实现一个简单的上下波动:
v2f vert (appdata v) { v2f o; // 顶点动画:在模型空间Y轴方向添加正弦波动 float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float wave = sin(_Time.y * _WaveSpeed + worldPos.x * _WaveFrequency) * _WaveAmplitude; v.vertex.y += wave; o.vertex = UnityObjectToClipPos(v.vertex); // ... 其余计算不变 return o; }你需要在Properties中定义_WaveSpeed,_WaveFrequency,_WaveAmplitude等参数来控制波动效果。_Time是Unity内置的时间变量(float4 _Time; // (t/20, t, t*2, t*3)),常用于驱动动画。
另一个常见的特效是边缘光(Rim Light)。它的原理是计算视线方向与表面法线的夹角,夹角越大(即我们看向物体的边缘),光强越强。
在片元着色器中添加:
// 在frag函数内计算边缘光 float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos); float rim = 1.0 - max(0, dot(worldNormal, viewDir)); // 法线与视线垂直时rim为1 rim = pow(smoothstep(0.0, _RimWidth, rim), _RimPower); // 使用smoothstep和pow控制范围和衰减 fixed3 rimColor = _RimColor.rgb * rim; col.rgb += rimColor; // 将边缘光颜色加到最终颜色上同样,需要在Properties中定义_RimColor,_RimWidth,_RimPower等参数来调整边缘光的颜色和形状。pow函数常用于制造非线性衰减,让光晕更集中。
5. 性能优化、调试与常见问题排查
写出一个能用的Shader只是第一步,写出一个高效、健壮、易调试的Shader才是进阶之路。这部分内容往往是实战中最宝贵的经验。
5.1 Shader性能优化核心准则
GPU是并行计算的怪兽,但带宽和ALU(算术逻辑单元)资源并非无限。优化Shader主要围绕减少计算和减少数据传递展开。
精度选择: 善用数据类型。
float: 全精度浮点数,用于世界坐标、矩阵运算等需要高精度的计算。half: 半精度浮点数(16位),适用于颜色、UV、法线等大多数情况。在移动平台能显著提升性能。fixed: 低精度(通常为11位),范围-2到2,仅适用于颜色和单位向量。在老的移动GPU上有益。
现代建议: 在PC和现代移动设备上,
half和float的性能差距已经很小,而fixed的支持度在降低。一个稳妥的策略是:颜色用half3/4,坐标和向量用float,只在明确需要时使用fixed。减少纹理采样: 纹理采样是Shader中最耗时的操作之一。
- 合并纹理: 将金属度、光滑度、环境光遮蔽(AO)等单通道数据打包到一张纹理的R、G、B通道中(即Metallic-Glossiness工作流中的遮罩贴图)。
- 使用纹理图集: 将多个小纹理合并成一张大纹理,通过不同的UV区域访问,可以减少纹理切换带来的性能损耗。
- 慎用
tex2D导数指令: 像ddx,ddy或fwidth用于计算mipmap层级或实现某些过滤效果,但计算开销较大。
简化数学运算:
- 用
mad(乘加)指令替代单独的乘法和加法(编译器通常会优化,但心中有数)。 - 避免在片元着色器中进行复杂的循环或分支(
if语句)。GPU的SIMD架构导致分支可能造成所有路径都被执行,严重降低效率。尽量用数学函数(如saturate,step,lerp)来替代条件判断。 - 将可以提前的计算移到顶点着色器。例如,如果某个向量在整个三角形面上变化不大,可以在顶点着色器计算后插值,而不是在每个片元都算一次。
- 用
利用Shader变体与多编译指令: 通过
#pragma multi_compile或shader_feature为Shader生成多个变体,以应对不同情况(如是否有阴影、是否启用雾效)。这避免了在运行时用if判断,但会增加编译后的Shader文件大小。
5.2 调试Shader的实用技巧
Shader调试不像普通代码那样可以设断点,主要依靠“可视化”数据。
颜色调试法: 这是最直接的方法。将你想查看的中间变量(比如法线、光照强度、UV)直接作为片元着色器的输出颜色。
// 调试法线:将[-1,1]的范围映射到[0,1]的颜色范围 return fixed4(worldNormal * 0.5 + 0.5, 1.0); // 调试UV return fixed4(i.uv, 0, 1.0); // 调试某个计算值 return fixed4(rim, rim, rim, 1.0);通过观察屏幕上的颜色,你可以判断向量方向是否正确、数值范围是否在预期内。
使用Frame Debugger: Unity的Frame Debugger(窗口 > 分析 > Frame Debugger)是神器。它可以截取一帧的完整渲染过程,让你看到每一个Draw Call,并预览该次绘制时Shader的输出。你可以清晰地看到你的Shader在哪个Pass、以什么状态被调用,输出结果是什么。
检查编译日志与警告: 在Inspector面板中选中Shader文件,查看底部的编译信息。任何警告(如精度转换丢失)或错误都需严肃对待。对于复杂的
#if分支,可以点击“Compile and show code”查看最终生成的底层Shader汇编代码(虽然难懂,但有时能发现意想不到的问题)。
5.3 常见问题速查与解决方案
下表汇总了新手编写Unity Shader时最常遇到的“坑”及其解决方法:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 模型显示为粉红色(Missing) | Shader编译失败或当前SubShader不被支持。 | 1. 查看Shader Inspector底部的错误信息。 2. 检查语法错误,特别是CGPROGRAM/ENDCG块内。 3. 检查 #include路径是否正确。4. 检查使用的Shader Model版本是否过高(如用了 SV_POSITION但声明了低版本#pragma target 2.0)。 |
| 纹理采样错乱,出现拉伸或重复图案 | UV坐标传递或变换错误。 | 1. 在片元着色器中直接输出fixed4(i.uv, 0, 1)调试UV。2. 确认在顶点着色器中使用了 TRANSFORM_TEX宏应用了_MainTex_ST。3. 检查模型本身的UV是否在建模软件中已正确展开。 |
| 光照效果异常,全黑或全亮 | 法线数据错误或光照计算空间不一致。 | 1. 输出世界法线颜色fixed4(worldNormal*0.5+0.5, 1),检查是否为平滑渐变。2. 确认法线在片元着色器中已归一化( normalize)。3. 确认光源方向向量已归一化。 4. 检查 dot点积结果是否被saturate或max(0, )限制在合理范围。 |
| 透明渲染排序错误,该透的没透 | 渲染队列和深度写入设置不当。 | 1. 对于透明物体,SubShader的Tags应设为"Queue"="Transparent"。2. 在Pass中,通常需要设置 ZWrite Off,并启用混合Blend SrcAlpha OneMinusSrcAlpha。3. 考虑使用 AlphaTest或AlphaToMask来处理硬边透明。 |
| 在移动设备上效果不对或性能极差 | 使用了不支持的精度或复杂运算。 | 1. 将float改为half或fixed进行测试。2. 避免在片元着色器中使用 for循环或复杂的if分支。3. 使用 #pragma target 3.0或#pragma target es3.0确保特性支持。4. 使用Unity的Shader性能分析工具(如GPU Profiler)。 |
| 边缘有锯齿(Aliasing) | 纹理采样或屏幕空间导数问题。 | 1. 确保纹理导入设置中,生成Mip Maps选项被勾选。 2. 对于透明边缘,尝试使用 AlphaToMask On。3. 对于特别锐利的线条,可以考虑在片元着色器中手动进行抗锯齿处理(如 smoothstep)。 |
最后一点个人体会: 学习Shader是一个“螺旋上升”的过程。不要指望看一遍就全部记住。最好的方法是,从一个能运行的基础模板开始(比如我们上面写的BasicTexture),每次只添加或修改一个功能(比如加光照、加边缘光、做顶点动画)。每实现一个效果,就彻底理解它涉及的几行代码。遇到问题,多用颜色调试法,把中间变量可视化出来看。积累多了,你就会发现那些复杂的Shader无非是这些基础模块的组合与变体。当你能够不假思索地写出一个带法线贴图、高光反射和边缘光的自定义Shader时,Unity渲染世界的大门才真正向你敞开。