
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. ShaderLabUnity着色器的“总说明书”在深入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 { RenderTypeOpaque } 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或HLSLUnity支持两者编写的高阶着色器语言代码。这就是我们常说的“顶点着色器”和“片元着色器”程序所在的地方。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 { RenderTypeOpaque } 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分量是纹理缩放Tilingzw分量是纹理偏移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中添加LightModeForwardBase以获取前向渲染基础通道的光照信息。性能考量 逐片元计算光照比逐顶点计算更耗性能但效果也细腻得多。对于移动平台或大量重复物体需要权衡。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时最常遇到的“坑”及其解决方法问题现象可能原因排查步骤与解决方案模型显示为粉红色MissingShader编译失败或当前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.50.5, 1)检查是否为平滑渐变。2. 确认法线在片元着色器中已归一化normalize。3. 确认光源方向向量已归一化。4. 检查dot点积结果是否被saturate或max(0, )限制在合理范围。透明渲染排序错误该透的没透渲染队列和深度写入设置不当。1. 对于透明物体SubShader的Tags应设为QueueTransparent。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渲染世界的大门才真正向你敞开。