
1. 项目概述为什么我们要从零开始学Unity Shader如果你是一名Unity开发者或者正打算踏入游戏或实时渲染领域那么“Shader”这个词对你来说一定不陌生也一定让你感到过困惑。你可能见过那些酷炫的卡通渲染、水面波纹、全息特效或者仅仅是解决了一个“材质变紫”的问题背后都离不开Shader的身影。但当你打开一份Shader代码看到满屏的CGPROGRAM、复杂的数学运算和难以理解的语义时是不是感觉头都大了这正是“从零开始”这个系列存在的意义——我们不谈空中楼阁的理论就从你每天在Unity编辑器里右键创建的那个空白Shader文件开始一步步拆解直到你能亲手写出实现特定效果的代码。Shader简单来说就是运行在显卡GPU上的一段小程序。它的核心任务就是告诉GPU这个像素点最终应该显示成什么颜色。Unity中的Shader远不止是让模型“有颜色”那么简单它定义了光如何与表面交互、如何产生阴影、如何模拟各种物理材质如金属、布料、皮肤甚至是实现完全非真实的风格化渲染。无论是解决Unity Addressables打包后TMP材质紫了这种令人头疼的打包问题还是实现Shader Graph 扰动这样的动态效果抑或是优化Unity WebGL初始化很久的性能瓶颈对Shader有基础的理解都是不可或缺的一环。本系列面向所有希望真正理解并掌控Unity渲染外观的开发者。你可能是一个被UI材质问题困扰的UI工程师也可能是一个想为角色添加独特高光Unity中高亮Shader的TA技术美术或者是一个需要优化渲染性能Unity性能优化的主程。无论你的起点如何我们将从最基础的ShaderLab语法、渲染管线Built-in, URP, HDRP的选择到简单的顶点/片元着色器编写再到一些常见效果的实战剖析比如简单的溶解、滚动纹理、基础光照模型最终让你拥有独立阅读、修改甚至创作Shader的能力。记住学习Shader不是一蹴而就的它更像学习一门新的“图形编程语言”需要耐心和实践。让我们抛开恐惧从第一个“Hello World”级别的Shader开始。2. Shader核心概念与Unity中的生态位在深入代码之前我们必须先厘清几个关键概念否则很容易被Unity编辑器里各种以“Shader”命名的东西搞糊涂。这就像学做菜你得先分清楚菜谱、食材和炒菜过程。2.1 Shader、Shader Asset、Material与Shader Graph根据Unity官方手册的定义这几个术语有明确的区分Shader着色器程序 这是核心指那段运行在GPU上的代码。它是一系列指令定义了从3D模型数据顶点位置、法线、UV等到最终屏幕像素颜色的整个计算过程。Shader Asset.shader 文件 在Unity项目中的一个资产文件扩展名为.shader。它里面不仅仅包含GPU代码还包裹着一层Unity特有的ShaderLab声明代码。这个文件定义了一个Shader Object。Shader ObjectShader对象 可以理解为Shader Asset在Unity运行时加载后的一个实例是Unity引擎内部用于管理着色器程序、渲染状态和属性的容器。我们在材质球的Shader下拉菜单里选择的就是一个一个的Shader Object。Material材质 材质是Shader的参数实例。你可以把Shader看作一个函数公式而Material就是调用这个函数时传入的具体参数比如主纹理用哪张图高光强度设为0.5颜色设为红色。一个Shader可以被无数个Material复用。Shader Graph 这是一个可视化着色器编辑工具。它允许你通过连节点的方式创建Shader而无需直接编写代码。对于美术师或不想深究代码逻辑的开发者来说非常友好。它最终也会生成一个Shader Asset。注意 新手常犯的错误是混淆Shader和Material。你会听到有人说“把这个Shader拖到模型上”这通常是不准确的应该说是“把这个Material拖到模型上”。Material包含了Shader引用和具体的属性值。2.2 Unity中的三大着色器类型Unity将Shader分为三大类对应不同的应用场景图形管线着色器最常见 这就是我们通常学习、编写的用于渲染模型、特效的着色器。它们直接参与标准的渲染流水线处理顶点变换、光照、纹理采样等。我们后续的“从零开始”主要针对这类着色器。计算着色器Compute Shader 它们不直接参与图形绘制而是利用GPU强大的并行计算能力进行通用计算比如粒子系统模拟、网格处理、图像后处理等。在性能优化如Unity ECS、Unity Jobs Burst结合时中很有用。光线追踪着色器Ray Tracing Shader 用于实时光线追踪渲染管线模拟光线在场景中的物理行为产生极其真实的光影效果。这通常需要硬件支持如RTX系列显卡和特定的渲染管线如HDRP。2.3 渲染管线Render Pipeline的选择Built-in、URP、HDRP这是现代Unity开发中无法回避的一课。不同的渲染管线对Shader的编写方式有根本性的影响。内置渲染管线Built-in Unity的传统管线功能全面但架构较老定制复杂。许多老项目和老教程都基于它。它的Shader编写相对“自由”但不同平台和光源模式下的表现可能不一致。通用渲染管线URP Unity主推的现代轻量级管线。它提供了预设的良好性能支持移动端和PC端并且代码结构更清晰。对于新项目尤其是涉及移动平台如Unity发布抖音小游戏或需要兼顾性能与效果的强烈建议从URP开始。URP有自己的一套Shader库和编写规范。高清渲染管线HDRP 面向PC和主机平台的高保真图形管线功能强大但复杂对硬件要求高。用于制作3A级视觉效果的项目。为什么这很重要因为你为一个管线写的Shader通常不能直接在另一个管线里用。比如你从网上找到的一个Built-in管线的炫酷Shader直接导入URP项目材质十有八九会变成“粉红色”或“紫色”即Missing状态。你需要根据目标管线重写或转换它。在开始写Shader前首先明确你的项目用的是哪种管线。3. 第一个Shader拆解“Unlit Shader”的每一行理论说再多不如动手写一个。我们从一个最简单的、不受光照影响的“Unlit Shader”开始。在Unity中右键创建 Shader Unlit Shader命名为MyFirstShader.shader。让我们逐行分析这个模板。3.1 ShaderLab的基本结构Properties, SubShader, Pass一个.shader文件的主体是ShaderLab代码它像是一个包装盒定义了Shader的UI界面、渲染设置和真正的GPU代码块。Shader MyCustom/MyFirstShader // 1. Shader在材质下拉菜单中的路径 { Properties // 2. 属性块定义在材质面板中可调节的参数 { _MainTex (Texture, 2D) white {} // 定义一张2D纹理默认白色 _Color (Color, Color) (1,1,1,1) // 定义一个颜色默认白色 } SubShader // 3. 子着色器块针对不同显卡等级提供备选方案 { Tags { RenderTypeOpaque } // 渲染标签告诉Unity这是不透明物体 LOD 100 // 细节级别数字越小在性能不足时越可能被降级使用 Pass // 4. 通道一次完整的渲染绘制调用 { CGPROGRAM // 开始CG/HLSL代码段 #pragma vertex vert // 指定顶点着色器函数名为 vert #pragma fragment frag // 指定片元像素着色器函数名为 frag #include UnityCG.cginc // 包含Unity内置的辅助函数和宏 // 从Properties块传递数据到CG代码需要定义匹配的变量 sampler2D _MainTex; float4 _MainTex_ST; // 非常重要ST代表Scale和Tiling/Offset fixed4 _Color; // 应用阶段 - 顶点着色器的输入结构 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); // 核心变换模型空间-裁剪空间 o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理的缩放和偏移 return o; } // 片元像素着色器函数 fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * _Color; // 采样纹理并乘以颜色 return col; } ENDCG // 结束CG代码段 } } FallBack Diffuse // 5. 后备Shader当SubShader都不支持时使用 }3.2 关键代码行深度解析UnityObjectToClipPos(v.vertex) 这是整个顶点着色器最核心的一行。它完成了将顶点从模型本地空间Object Space变换到屏幕裁剪空间Clip Space的矩阵乘法。这个变换包含了模型变换Model、视图变换View和投影变换Projection简称MVP变换。Unity为我们封装好了这个函数在Built-in管线里是UnityObjectToClipPos在URP里通常是TransformObjectToHClip。TRANSFORM_TEX(v.uv, _MainTex) 另一个易错点。在材质面板中纹理有Tiling和Offset参数。_MainTex_ST是一个float4变量其中.xy存储了Tiling值.zw存储了Offset值。TRANSFORM_TEX这个宏就是用来计算应用了这些参数后的实际UV坐标。如果你忘记声明_MainTex_ST或者忘记使用这个宏纹理的平铺和偏移设置就会失效。tex2D(sampler2D tex, float2 uv) 片元着色器中用于对2D纹理进行采样的函数。根据UV坐标0到1的范围从纹理图上获取颜色值。语义: POSITION,: TEXCOORD0,: SV_POSITION,: SV_Target 这些冒号后的标识符是HLSL的语义它们告诉GPU这些数据是干什么用的。例如SV_POSITION表示这个值是裁剪空间的位置系统值SV_Target表示输出到渲染目标通常是屏幕。语义必须正确匹配。3.3 实操修改你的第一个Shader理解了结构后我们可以做几个小实验去掉光照 这个Shader本来就是Unlit的所以不受场景灯光影响。你可以创建一个球体赋予这个材质然后旋转方向光会发现球体颜色不变。改变颜色 在材质面板调整_Color例如调成红色(1,0,0,1)。你会发现整个纹理被染红了。尝试动画 在片元着色器中修改UV坐标。例如将tex2D(_MainTex, i.uv)改为tex2D(_MainTex, i.uv _Time.y * 0.1)。_Time是Unity内置的时间变量_Time.y是自场景加载以来的秒数。保存后返回Unity你会看到纹理在缓慢滚动。这就是许多动态效果的原理基础。实操心得 在编写或修改Shader后Unity有时不会立即编译。确保更改生效的最可靠方法是1保存Shader文件2在Unity编辑器中点击该Shader对应的Material材质球使其获得焦点。通常编辑器会自动检测并重新编译。如果还没变化可以尝试轻微修改一下Material的某个属性如Color强制刷新。4. 深入顶点与片元着色器从模型到像素的旅程现在我们已经见过了一个最简单的Shader流程。让我们更深入地看看数据是如何从3D模型流经GPU最终变成屏幕上的一个像素的。4.1 渲染流水线中的着色器阶段一个简化的可编程渲染管线流程如下顶点着色器Vertex Shader 对每个顶点执行一次。输入是顶点的属性位置、法线、UV、颜色等。它的核心任务是将顶点位置从模型空间变换到裁剪空间。它也可以修改其他顶点属性比如我们可以在顶点着色器里让顶点沿着法线方向膨胀实现简单的毛绒效果或者根据一个正弦波让顶点上下移动实现旗帜飘动或水面波动。图元装配与光栅化固定功能阶段 GPU将顶点连接成三角形图元然后将这些三角形离散化为一系列潜在的像素片元Fragment。这个过程是固定的。片元着色器Fragment Shader / Pixel Shader 对每个片元执行一次。输入是经过顶点着色器处理并插值后的数据例如一个三角形三个顶点的UV被平滑地插值到三角形内的每一个片元上。它的核心任务是计算该片元的最终输出颜色。我们之前做的纹理采样、颜色混合、光照计算都在这里完成。4.2 在顶点着色器中“动手脚”实现简单动画让我们写一个让模型顶点沿法线方向周期性膨胀收缩的Shader模拟呼吸或心跳感。Shader MyCustom/VertexPulse { Properties { _MainTex (Texture, 2D) white {} _PulseSpeed (Pulse Speed, Float) 1.0 _PulseStrength (Pulse Strength, Float) 0.1 } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 新增获取顶点法线 float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; float _PulseSpeed; float _PulseStrength; v2f vert (appdata v) { v2f o; // 核心在变换到裁剪空间前先修改模型空间顶点位置 float pulse sin(_Time.y * _PulseSpeed); // 生成-1到1的周期值 float3 offset v.normal * pulse * _PulseStrength; // 沿法线方向偏移 v.vertex.xyz offset; // 应用偏移 o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { // 简单纹理采样 return tex2D(_MainTex, i.uv); } ENDCG } } }原理sin(_Time.y * _PulseSpeed)产生一个随时间在-1到1之间平滑变化的值。我们将这个值乘以法线方向v.normal和强度系数_PulseStrength得到一个动态的偏移向量然后加到原始顶点位置上。因为每个顶点的法线方向不同膨胀效果会显得更自然。4.3 在片元着色器中玩转颜色实现边缘光Rim Light边缘光是一种常见的效果用于突出物体轮廓。我们在片元着色器中根据视线方向与法线方向的夹角来计算。fixed4 frag (v2f i) : SV_Target { fixed4 texColor tex2D(_MainTex, i.uv); // 计算边缘光系数 // 1. 获取世界空间法线需要在顶点着色器中计算并传递 // 2. 获取世界空间视线方向从顶点指向相机 // 这里假设v2f结构中已经包含了世界法线(i.worldNormal)和世界视线方向(i.worldViewDir) float rim 1.0 - saturate(dot(i.worldNormal, i.worldViewDir)); // dot点积两向量方向一致时为1垂直为0相反为-1。 // 视线与法线平行看向正面时rim接近0视线与法线垂直看向边缘时rim接近1。 rim pow(rim, _RimPower); // 使用指数控制边缘光的锐利度 fixed3 rimColor _RimColor.rgb * rim * _RimIntensity; // 将边缘光颜色叠加到纹理颜色上这里使用加法也可以使用混合模式 fixed3 finalColor texColor.rgb rimColor; return fixed4(finalColor, texColor.a); }注意 上述代码片段是一个原理示例。要让它工作你需要在顶点着色器中计算世界空间法线和视线方向并通过v2f结构传递给片元着色器。这涉及到更多的矩阵变换UnityObjectToWorldNormal等。完整的实现是一个很好的练习能让你深入理解空间变换。5. 与引擎交互使用Unity内置变量与函数Unity的CG/HLSL标准库通过#include UnityCG.cginc引入提供了大量宝藏避免了我们重复造轮子。5.1 常用的内置变量_Time float4类型(t/20, t, t*2, t*3)其中t是自场景加载以来的时间。_Time.y是最常用的秒数。_SinTime,_CosTime float4类型分别是(t/8, t/4, t/2, t)的正弦和余弦值。用于平滑周期动画。_WorldSpaceCameraPos float3世界空间下的相机位置。_ProjectionParams float4x1.0或-1.0如果使用翻转投影yNear Clip PlanezFar Clip Planew1.0Far/Near。unity_ObjectToWorld,unity_WorldToObject float4x4矩阵模型到世界、世界到模型的变换矩阵。用于在着色器中手动进行空间变换。5.2 常用的内置函数与宏空间变换函数UnityObjectToClipPos(v) 模型空间 - 裁剪空间Built-in。UnityObjectToWorldNormal(v) 将法线从模型空间变换到世界空间处理非统一缩放。WorldSpaceViewDir(pos) 给定一个世界空间位置返回指向相机的世界空间方向。工具函数saturate(x) 将x钳制在[0, 1]范围。非常常用因为颜色值通常需要在这个范围内。lerp(a, b, t) 线性插值当t0时返回at1时返回bt在中间时返回混合值。用于颜色、数值的平滑过渡。dot(a, b) 向量点积用于计算夹角余弦值、投影长度等。cross(a, b) 向量叉积得到垂直于a和b的向量。reflect(I, N) 根据入射方向I和法线N计算反射方向。pow(x, y) 幂运算常用于控制非线性变化如光泽度、衰减。5.3 实战利用内置变量实现广告牌Billboard效果广告牌效果让一个面片如树木、粒子始终面向相机。这通常在顶点着色器中完成。v2f vert (appdata v) { v2f o; // 1. 将模型中心点变换到世界空间 float3 centerWorld mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz; // 2. 构建面向相机的坐标系 float3 forward normalize(_WorldSpaceCameraPos.xyz - centerWorld); // 指向相机的向量 float3 up float3(0, 1, 0); // 假设世界空间向上为Y轴 float3 right normalize(cross(up, forward)); // 计算右向量 up normalize(cross(forward, right)); // 重新计算准确的向上向量保证正交 // 3. 将模型空间顶点偏移量用新的坐标系进行变换 // v.vertex.xyz 原本是相对于模型中心(0,0,0)的偏移 float3 localOffset v.vertex.xyz; float3 worldPos centerWorld right * localOffset.x up * localOffset.y forward * localOffset.z; // 4. 将计算出的世界空间位置变换到裁剪空间 o.vertex mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); // UNITY_MATRIX_VP 是视图投影矩阵 o.uv v.uv; return o; }这个例子展示了如何灵活运用矩阵和向量运算在Shader中实现复杂的顶点变换逻辑。6. 常见问题、调试与性能考量即使理解了原理在实际编写Shader时你依然会遇到各种“坑”。这里记录一些典型问题和排查思路。6.1 材质为什么是粉红色/紫色这是Unity的“Missing Shader”状态。原因可能有Shader编译错误 检查Console窗口通常会有红色的编译错误信息。可能是语法错误、未定义的变量、语义不匹配等。渲染管线不兼容 为Built-in管线写的Shader用在URP项目里或者反之。需要转换或重写。Shader文件丢失或引用损坏 确保.shader文件在项目中并且Material的Shader引用正确。6.2 纹理采样出现拉扯或错误忘记处理UV的Tiling/Offset 确保使用了TRANSFORM_TEX宏并且声明了_TexName_ST变量。UV坐标超出[0,1]范围 当UV大于1时纹理的Wrap Mode决定了行为Repeat, Clamp, Mirror。根据需求设置纹理的导入设置或使用frac(uv)函数来强制重复。法线纹理采样错误 法线纹理通常需要设置为“Normal map”类型并在Shader中使用UnpackNormal函数来解码。6.3 性能优化注意事项Shader是性能敏感区域特别是移动平台。精度选择 在片元着色器中尽量使用低精度类型fixed(通常11位) 用于颜色half(16位浮点) 用于短向量和UVfloat(32位) 用于位置、矩阵计算。错误使用高精度会显著增加功耗和降低帧率。减少纹理采样 纹理采样tex2D是昂贵的操作。尽可能复用采样结果合并纹理如将金属度、光滑度、环境光遮蔽打包到一张纹理的RGB通道。避免分支和循环 GPU是并行处理器分支if-else和复杂循环会导致性能急剧下降尤其是在片元着色器中。尽量用数学函数如step,smoothstep,lerp来替代简单分支。慎用discard操作 在片元着色器中使用clip()或discard丢弃像素会破坏GPU的深度优化如Early-Z可能反而降低性能。使用Shader LOD和多重编译变体 通过LOD和#pragma multi_compile或shader_feature来为不同性能等级的硬件提供不同复杂度的Shader版本。6.4 调试技巧输出颜色调试法 这是最直观的方法。在片元着色器中暂时将输出颜色固定为某个中间变量的值。例如如果你怀疑法线计算有问题可以return fixed4(worldNormal * 0.5 0.5, 1.0);将法线可视化法线分量范围是-1到1需要映射到0到1。使用Frame Debugger Unity的Frame Debugger窗口 分析 Frame Debugger可以逐帧、逐个Draw Call查看渲染状态包括当前使用的Shader和属性值。RenderDoc等外部工具 对于更底层的调试可以使用RenderDoc捕获一帧的GPU调用查看每个着色器阶段的输入输出是定位复杂问题的终极武器。7. 迈向进阶表面着色器与Shader Graph当你掌握了顶点/片元着色器的基础后可以探索Unity提供的更高级抽象它们能提升开发效率。7.1 表面着色器Surface Shader表面着色器是Unity内置管线Built-in特有的一种高级抽象。它不是一种新的着色器语言而是一个生成器。你编写一个“表面函数”Surface Function描述材质的表面属性如漫反射颜色、法线、高光等Unity会自动为你生成处理复杂光照如多光源、阴影、光照贴图的顶点和片元着色器代码。优点 编写光照Shader极其方便几行代码就能实现标准光照模型。缺点 生成的代码庞大且不易控制对定制化高的非真实感渲染或极端性能优化不友好。在URP/HDRP中表面着色器已不再被推荐使用URP提供了它自己的光照模型框架如Lit Shader。7.2 Shader Graph对于不擅长代码的美术师或程序员来说Shader Graph是革命性的工具。它通过节点图的方式连接各种操作数学运算、纹理采样、向量计算等来构建Shader。优点可视化直观易懂 效果实时预览连接关系一目了然。快速迭代 拖拽节点即可调整效果无需编译等待大部分情况下。跨管线兼容 URP和HDRP都有对应的Shader Graph版本生成的Shader兼容对应管线。降低门槛 让更多团队成员能够参与Shader制作。局限性表达能力有上限 极其复杂或需要特殊技巧的Shader如某些后处理、自定义顶点动画可能难以或无法用节点实现。性能黑盒 生成的代码可能不是最优的对于性能至关重要的移动端有时需要手写代码进行精细控制。调试稍显不便 虽然提供了节点预览但追踪深层逻辑问题不如代码直接。学习建议 即使你主要使用Shader Graph理解本文前面所述的基础概念如空间变换、UV、向量运算也至关重要。这能帮助你理解每个节点的含义并创造出更复杂、更高效的效果。很多Shader Graph 扰动、UI Shader模糊效果其背后的数学原理与手写Shader是一致的。从零开始学习Unity Shader是一段充满挑战但回报丰厚的旅程。它让你从渲染的“使用者”变为“掌控者”。不要指望一次就理解所有内容。最好的学习方式是从一个具体的小目标开始比如“让这个模型边缘发光”查阅资料动手实现遇到问题逐一解决。积累的每一个小效果最终都会汇聚成你图形渲染知识体系的坚实基石。当你再遇到Missing global shader或材质变紫的问题时你将不再迷茫而是能自信地打开Shader文件开始排查和修复。这就是“从零开始”的力量。