Unity Shader终极教程:从ShaderLab到URP,掌握图形渲染核心

发布时间:2026/7/23 4:33:55
Unity Shader终极教程:从ShaderLab到URP,掌握图形渲染核心 1. 项目概述为什么Shader是Unity开发者的分水岭如果你在Unity里做过几个项目从UI搭建到角色控制都玩得挺溜但每次看到那些炫酷的粒子特效、风格化的卡通渲染或者仅仅是让水面泛起真实的涟漪时心里总会咯噔一下——这玩意儿到底是怎么做出来的答案大概率指向同一个东西Shader。很多开发者把Shader编程视为游戏开发领域的“黑魔法”觉得它门槛高、概念抽象是图形程序员的专属领域。但我想告诉你的是Shader并非遥不可及它更像是一把钥匙能彻底打开你在Unity中创造视觉表现力的大门。掌握它意味着你从“功能实现者”向“体验塑造者”迈进了一大步。这个“终极Shader教程”的目标就是帮你把这把钥匙拿到手。我们不谈空洞的理论也不搞复杂的数学推导而是从Unity ShaderLab这个最接地气的语法开始一步步拆解Shader是如何工作的。你会发现那些看似神秘的顶点变换、片元着色、光照计算背后都有清晰的逻辑和可复现的步骤。无论你是想优化项目性能比如用Shader合并Draw Call还是想实现独特的艺术风格比如赛博朋克的霓虹光效甚至是解决一个具体的渲染Bug比如透明物体的排序问题Shader知识都是你不可或缺的武器库。2. 核心概念拆解Shader、材质与渲染管线的关系在动手写代码之前我们必须先理清几个最基础也最容易混淆的概念。很多新手会直接把Shader和材质球Material划等号这会导致后续学习遇到很多障碍。2.1 Shader渲染的“菜谱”你可以把Shader理解为一本“菜谱”。这本菜谱详细规定了GPU图形处理器应该如何加工3D模型这个“原材料”最终把它变成屏幕上一个个有颜色的像素点。Shader本身不包含任何纹理图片或颜色参数它只定义了加工的流程和规则。在Unity中Shader通常以.shader为后缀的文件存在里面用ShaderLab语言和HLSL/Cg代码编写。2.2 材质球按“菜谱”烹饪的“一道菜”材质球Material则是根据某本特定“菜谱”Shader烹饪出来的一道具体的菜。它包含了这道菜所需的所有具体“食材”和“调料”比如使用哪张纹理贴图Texture、颜色Color调成什么值、光滑度Smoothness设为多少。一个Shader可以被无数个材质球引用每个材质球通过调整这些参数可以呈现出截然不同的视觉效果。比如同一个“标准光照Shader”既可以调出粗糙的石材质感也能调出光滑的金属质感。2.3 渲染管线厨房的“生产线”渲染管线Rendering Pipeline就是整个厨房的“生产线”。它规定了从原材料模型顶点数据到成品屏幕像素需要经过哪些固定的工序。Unity目前主要有两种内置管线Built-in Render Pipeline内置渲染管线和Universal Render PipelineURP通用渲染管线以及高端的High Definition Render PipelineHDRP。不同的管线其“生产线工序”有所不同。例如URP为了移动端和高性能优化简化并固定了部分光照计算流程。你写的Shader必须适配目标渲染管线否则可能无法编译或渲染错误。注意这是新手最容易踩的第一个坑。在Unity 2019及以后版本中新建项目务必明确选择使用Built-in RP、URP还是HDRP。一旦项目中期切换管线所有自定义Shader都可能需要重写或调整工作量巨大。对于从零开始的学习我强烈建议使用URP因为它代表了Unity未来的方向且文档和社区支持越来越好。理解了这三者的关系我们就能明白学习Shader编程本质上是学习如何编写能被特定渲染管线理解的“菜谱”Shader程序并通过材质球这个载体将你的创意施加到3D模型上。3. 环境准备与第一个Shader理论说再多不如动手做一遍。让我们从创建一个最简单的Shader开始感受一下完整的流程。3.1 创建你的第一个Shader文件在Unity项目的Assets文件夹中右键选择Create - Shader - Unlit Shader。Unity会为你生成一个模板文件命名为NewUnlitShader.shader。将其改名为MyFirstShader.shader并双击在代码编辑器如VSCode中打开。你会看到一大堆代码。先别慌我们暂时不深究每一行的含义。这个模板Shader已经是一个完整可用的、不受光照影响的Shader。接下来我们创建一个材质球来使用它。3.2 创建材质并关联Shader在Assets文件夹右键选择Create - Material命名为MyFirstMat。在Inspector面板中点击Shader下拉菜单找到你刚创建的MyFirstShader通常位于“Custom”分类下。现在你得到了一个使用自定义Shader的材质球。3.3 让Shader“动”起来修改颜色属性回到MyFirstShader.shader文件。找到Properties代码块这里定义了材质球面板上可调节的参数。我们添加一个颜色属性Properties { _MainTex (Texture, 2D) white {} // 添加一个名为 _Color显示为“Tint”的颜色属性默认红色 _Color (Tint, Color) (1, 0, 0, 1) }然后在CGPROGRAM代码段中找到fixed4 frag (v2f i)函数这个函数决定了每个像素的颜色。修改其返回语句fixed4 frag (v2f i) : SV_Target { // 采样主纹理 fixed4 col tex2D(_MainTex, i.uv); // 将采样结果与我们的 _Color 属性相乘 col * _Color; return col; }保存文件回到Unity。Unity会自动编译Shader。此时选中MyFirstMat材质球你会发现Inspector面板多了一个“Tint”颜色选择器。拖动滑块或点击色块改变颜色并将其拖到一个3D物体如Cube上你会看到物体的颜色随之改变。实操心得很多教程会跳过创建材质和关联这一步直接让人看代码导致初学者不知道代码改完后效果体现在哪里。务必遵循“修改Shader代码 - Unity自动编译 - 在材质球面板调整参数 - 在场景中观察效果”这个闭环流程这是学习Shader最有效的反馈循环。4. ShaderLab语法精讲从结构到语义现在我们来系统性地拆解一个Shader文件的结构。以上面的Unlit Shader模板为基础。4.1 Shader根命令与属性块PropertiesShader文件的第一行是Shader Custom/MyFirstShader。这里的路径Custom/MyFirstShader决定了它在材质球Shader下拉菜单中的位置。你可以把它改成Learning/FirstShader它就会出现在Learning分类下。Properties块是Shader与美术师或开发者交互的界面。这里定义的每一个属性都会显示在材质球的Inspector面板上。语法是_PropertyName (Display Name, PropertyType) DefaultValue_PropertyName在CG/HLSL代码中引用的变量名。Display Name在Inspector面板上显示的名称。PropertyType属性类型如Color颜色、2D2D纹理、Range(min, max)范围滑块、Float浮点数等。DefaultValue默认值如颜色(1,1,1,1)纹理white{}或black{}范围值0.5。4.2 SubShader与Pass渲染的次与遍一个Shader可以包含多个SubShader。Unity会从上到下尝试使用第一个能被当前显卡支持的SubShader。这常用于为不同性能等级的硬件提供不同复杂度的渲染方案。每个SubShader包含一个或多个Pass渲染遍。一次完整的物体绘制至少需要一个Pass。复杂的效果如描边填充可能需要多个Pass。在每个Pass中我们编写真正的着色器程序顶点/片元着色器。4.3 CGPROGRAM与着色器程序在Pass内部CGPROGRAM和ENDCG之间是使用HLSL/Cg语言编写的核心程序。主要包含两部分顶点着色器Vertex Shadervert函数。它的输入是模型原始的顶点数据位置、法线、UV等输出是经过变换如模型-视图-投影矩阵变换后的顶点数据以及需要传递给片元着色器的数据如UV坐标。片元着色器Fragment Shader / Pixel Shaderfrag函数。它的输入是顶点着色器插值后的数据例如一个三角形三个顶点的UV坐标在三角形内部进行平滑插值输出是该像素最终的颜色值。它们之间的数据传递通过一个结构体通常叫v2f即vertex-to-fragment来完成。关键点解析为什么需要插值GPU渲染的基本单位是三角形。顶点着色器只在三角形的三个顶点上执行。而三角形内部可能有成千上万个像素片元。为了得到内部每个像素的数据如UV、颜色GPU会对三个顶点的输出数据进行线性插值然后将插值后的结果作为输入送给每个像素单独执行的片元着色器。这是理解很多渲染效果如渐变、纹理采样的基础。5. 核心数学与空间变换Shader编程离不开一些基础的向量和矩阵数学。但别怕我们只聚焦最实用、最高频的几个概念。5.1 坐标空间模型、世界、观察、裁剪、屏幕一个顶点的位置需要经过一系列坐标空间变换才能从3D软件中的模型位置变成屏幕上对应的2D像素位置。模型空间Object Space顶点在原始3D模型文件如.fbx中定义的坐标。原点通常是模型的中心或底部。世界空间World Space顶点在Unity场景世界中的绝对坐标。通过乘以unity_ObjectToWorld矩阵模型矩阵实现从模型空间到世界空间的变换。观察空间View Space / Eye Space以摄像机为原点的坐标系。通过乘以UNITY_MATRIX_V视图矩阵实现从世界空间到观察空间的变换。裁剪空间Clip Space一个中心在(0,0,0)边界在(-1,1)之间的立方体空间。在此空间外的顶点将被“裁剪”掉不参与渲染。通过乘以UNITY_MATRIX_VP视图投影矩阵或分别乘以UNITY_MATRIX_V和UNITY_MATRIX_P投影矩阵实现变换。屏幕空间Screen Space最终的2D像素坐标。在顶点着色器中最标准的操作就是将顶点从模型空间变换到裁剪空间v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); // Unity内置宏等同于 mul(UNITY_MATRIX_MVP, v.vertex) (旧版) // ... 传递其他数据如UV return o; }UnityObjectToClipPos是Unity提供的一个内置函数/宏它帮你完成了从模型空间到裁剪空间的矩阵乘法。在URP中你可能需要使用TransformObjectToHClip等新的变换函数。5.2 法线变换一个常见的坑法线Normal表示表面的朝向用于光照计算。将法线从模型空间变换到世界空间不能直接使用变换顶点的unity_ObjectToWorld矩阵。因为如果模型存在非均匀缩放Scale的x,y,z值不同直接使用该矩阵会导致法线方向不正确光照出错。正确的做法是使用逆转置矩阵。幸运的是Unity提供了内置变量unity_WorldToObject世界到模型的逆矩阵或函数UnityObjectToWorldNormal来处理这个问题float3 worldNormal UnityObjectToWorldNormal(v.normal); // 正确方式 // 错误方式float3 worldNormal mul(v.normal, (float3x3)unity_ObjectToWorld);注意这是新手在实现自定义光照时最容易忽略的问题会导致模型在缩放后光照“撕裂”或显示异常。务必使用Unity提供的辅助函数来处理法线变换。6. 纹理采样与UV动画纹理是Shader的灵魂。理解纹理采样和UV操作你就解锁了绝大部分表面细节的表现能力。6.1 基础纹理采样在Properties中声明一个2D类型属性_MainTex后需要在CGPROGRAM中声明一个同名变量通常还会附带一个_MainTex_ST变量ST代表纹理的缩放Scale和平移Translation。在片元着色器中使用tex2D函数进行采样fixed4 frag (v2f i) : SV_Target { // 应用纹理的缩放和平移偏移 float2 uv i.uv * _MainTex_ST.xy _MainTex_ST.zw; fixed4 col tex2D(_MainTex, uv); return col; }6.2 UV动画让纹理“动”起来UV动画的原理就是在每一帧对UV坐标进行某种数学变换。最简单的平移动画float2 uv i.uv; // 让UV随着时间在X和Y方向上移动 uv.x _Time.y * _SpeedX; // _Time.y是自场景加载以来的时间秒 uv.y _Time.y * _SpeedY; fixed4 col tex2D(_MainTex, uv);你可以将_SpeedX和_SpeedY作为属性暴露出来在材质球面板调节移动速度。同理通过旋转、缩放UV坐标可以实现漩涡、脉冲等动态效果。实操心得_Time是Unity提供的内置时间变量它是一个float4类型_Time.x是时间/20_Time.y是时间_Time.z是时间2_Time.w是时间3。根据动画快慢需求选择不同的分量。对于需要周期性变化的效果如闪烁结合sin(_Time.y)函数会非常有用。7. 实现基础光照模型无光照Unlit的Shader适合UI、特效等。但对于大多数3D物体我们需要光照来体现体积感。我们从最经典的兰伯特Lambert漫反射模型开始。7.1 漫反射光照原理漫反射的光照强度取决于光线方向与表面法线方向的夹角。夹角越小越垂直表面越亮。计算公式为光照强度 max(0, dot(法线, 光线方向))。dot是点积运算结果就是两个向量夹角的余弦值。在片元着色器中实现fixed4 frag (v2f i) : SV_Target { // 假设已经获得世界空间法线 i.worldNormal 和世界空间光线方向 _WorldSpaceLightPos0.xyz float3 lightDir normalize(_WorldSpaceLightPos0.xyz - i.worldPos); // 对于点光源 // 对于平行光_WorldSpaceLightPos0.xyz 直接就是方向 float diff max(0, dot(i.worldNormal, lightDir)); fixed4 col _Color * diff; // 基础颜色乘以光照强度 return col; }这里_WorldSpaceLightPos0是Unity内置变量代表第一个平行光的位置或方向。在Forward渲染路径下我们可以在Pass开头添加#pragma multi_compile _ _MAIN_LIGHT_SHADOWS等编译指令来获取主光源信息URP中方式不同。7.2 在URP中实现简单光照URP的编程方式与内置管线有较大差异。它使用Lighting.hlsl库和UniversalForwardPass。一个最简单的URP Lit Shader框架如下// 在Properties中定义基础属性... // URP中需要包含的核心库 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { ... }; // 输入结构体 struct Varyings { ... }; // 输出结构体 Varyings vert(Attributes input) { ... } half4 frag(Varyings input) : SV_Target { // 获取表面数据 SurfaceData surfaceData (SurfaceData)0; surfaceData.albedo _BaseColor.rgb * SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb; surfaceData.alpha ...; // 获取光照数据 InputData lightingInput (InputData)0; lightingInput.normalWS normalize(input.normalWS); lightingInput.positionWS input.positionWS; // 计算主光源光照 Light mainLight GetMainLight(); half4 color UniversalFragmentPBR(lightingInput, surfaceData); // 或者使用简单的兰伯特计算: half3 diffuse mainLight.color * saturate(dot(lightingInput.normalWS, mainLight.direction)); return half4(color.rgb, surfaceData.alpha); }URP封装了更复杂的PBR基于物理的渲染光照计算UniversalFragmentPBR函数帮你处理了漫反射、高光、环境光等所有细节。对于初学者理解如何获取SurfaceData和InputData并调用光照函数是关键。注意内置管线与URP/HDRP的Shader写法差异很大API和内置变量名都不同。学习时一定要明确你的目标管线并查找对应管线的官方文档或社区教程。试图将一个管线的Shader直接用在另一个管线上几乎肯定会失败。8. 透明、混合与深度测试让物体透明如玻璃、水、烟雾是常见需求这涉及到渲染状态Render State的设置。8.1 混合Blending默认情况下新绘制的像素会直接覆盖旧的像素Blend One Zero。要实现透明我们需要将当前像素的颜色与背景颜色进行混合。最常见的混合模式是Alpha混合// 在Pass中添加 Blend SrcAlpha OneMinusSrcAlpha这行代码开启了混合并设置了混合公式最终颜色 当前片元颜色 * 当前片元Alpha 背景颜色 * (1 - 当前片元Alpha)。这样Alpha值越小越透明对背景颜色的影响就越小。8.2 深度写入ZWrite与测试ZTest深度写入ZWrite控制是否将当前像素的深度值写入深度缓冲区。对于半透明物体通常需要关闭深度写入ZWrite Off因为半透明物体后面的物体也可能需要被看到。如果开启一个半透明物体绘制后会阻止它后面的其他半透明物体或部分不透明物体的渲染。深度测试ZTest控制当前像素是否被绘制取决于其深度值与深度缓冲区中已有值的比较。默认是ZTest LEqual小于等于则通过即离相机更近或一样近的物体才能覆盖远处的。对于类似UI的始终在最前面的效果可以使用ZTest Always。一个典型的半透明Pass设置如下Pass { Tags { QueueTransparent RenderTypeTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest LEqual // ... 着色器程序 }这里Tags中的QueueTransparent非常重要它告诉Unity这个物体应该在所有不透明物体QueueGeometry绘制完之后再绘制并且按从后到前的顺序绘制以确保混合效果正确。常见问题实录为什么我的透明物体看起来顺序错乱或者边缘有黑色缝隙顺序错乱确保所有透明材质的渲染队列Render Queue都设置为Transparent值3000并且关闭深度写入。Unity会基于物体到相机的距离对Transparent队列的物体进行从后到前的排序绘制。黑色缝隙这通常是由于深度测试引起的。一个透明物体的背面可能因为深度测试失败而被裁剪掉。可以尝试将ZTest设置为LEqual默认并确保模型法线正确。对于双面透明如玻璃杯可能需要两个Pass一个渲染背面Cull Front一个渲染正面Cull Back。9. 顶点动画与曲面细分除了在片元着色器中操作我们还可以在顶点着色器中改变顶点的位置实现模型形变。9.1 简单的顶点波动一个经典的例子是模拟旗帜飘动或水面波浪v2f vert (appdata v) { v2f o; // 在模型空间下对顶点y坐标施加一个基于x、z坐标和时间的正弦波影响 float wave sin(_Time.y * _WaveSpeed v.vertex.x * _WaveFrequencyX v.vertex.z * _WaveFrequencyZ) * _WaveAmplitude; v.vertex.y wave; o.vertex UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); return o; }通过调整_WaveSpeed波速、_WaveFrequency频率、_WaveAmplitude振幅这些属性可以控制波动的效果。这种计算在顶点着色器中进行性能消耗远低于在片元着色器中进行逐像素计算。9.2 曲面细分Tessellation当模型本身顶点数不足时简单的顶点动画会导致形变生硬。曲面细分技术可以在GPU上动态增加模型的顶点数量。这属于进阶话题在Shader中需要定义Hull Shader和Domain Shader。在Unity中可以通过在#pragma surface指令中添加tessellate:tessFunc来启用表面着色器或者在HLSL中明确编写Hull和Domain着色器阶段。它适用于需要动态增加细节的场景如随着镜头拉近而增加地形细节。10. 表面着色器Surface Shader快速上手Unity内置管线提供了一个更高级的抽象层——表面着色器Surface Shader。它帮你处理了多光源光照、阴影、全局光照等复杂逻辑你只需要关注表面的属性颜色、法线、高光等即可。一个最简单的表面着色器如下Shader Custom/SimpleSurface { Properties { _MainTex (Albedo (RGB), 2D) white {} _Glossiness (Smoothness, Range(0,1)) 0.5 _Metallic (Metallic, Range(0,1)) 0.0 } SubShader { Tags { RenderTypeOpaque } CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; half _Glossiness; half _Metallic; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c tex2D (_MainTex, IN.uv_MainTex); o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; } ENDCG } FallBack Diffuse }#pragma surface surf Standard声明这是一个表面着色器surf是表面处理函数名Standard指定使用标准PBR光照模型。surf函数这是核心。你在这里计算表面的各种属性Albedo漫反射颜色、Metallic金属度、Smoothness光滑度等并输出到SurfaceOutputStandard结构体中。光照计算由Unity自动完成。实操心得表面着色器是快速实现高质量、支持多光源和阴影的PBR材质的利器特别适合美术师或需要快速原型开发的场景。但它的缺点是生成的代码量较大且对渲染流程的控制粒度较粗。当你有非常定制化的渲染需求如特殊的光照模型、非标准的混合模式时还是需要回归到顶点/片元着色器。11. Shader Graph可视化着色器编辑对于不习惯写代码的开发者或美术人员Unity提供了强大的可视化工具——Shader GraphURP/HDRP。它通过节点连线的方式构建Shader逻辑所见即所得。11.1 Shader Graph的核心优势直观可视所有运算、纹理采样、数学操作都变成节点连接关系一目了然。快速迭代调整参数或连接方式效果实时更新无需编译等待大部分情况下。降低门槛无需记忆HLSL语法和内置变量名通过搜索和选择即可使用大部分功能。易于分享美术和TA可以独立创建和调整复杂的Shader效果。11.2 从Shader Graph到代码尽管使用方便但深入理解Shader Graph背后对应的代码逻辑能让你更好地调试和优化。例如一个简单的PBR Master节点连接了Albedo、Normal、Metallic输入本质上就是在构建SurfaceData并调用URP的PBR光照函数。你可以通过Shader Graph生成的.shader文件在Show Generated Shader菜单中查看来学习它对应的HLSL代码是如何组织的。个人体会Shader Graph和手写代码并非对立关系而是相辅相成。我通常的 workflow 是用Shader Graph快速搭建效果原型和进行美术效果迭代当遇到性能瓶颈或需要实现Graph不支持的底层功能时再转入手写代码进行深度优化和定制。理解手写Shader的原理能让你在使用Shader Graph时更加得心应手知道每个节点在做什么以及如何组合它们实现更复杂的效果。12. 性能优化与调试技巧写出能跑的Shader只是第一步写出跑得快的Shader才是真正的挑战。12.1 性能优化要点精度选择在HLSL中float32位精度最高half16位适用于颜色和方向fixed低精度约11位适用于简单的颜色计算。在片元着色器中对非关键计算使用half或fixed能显著提升性能尤其是在移动端。减少纹理采样纹理采样是GPU中最耗时的操作之一。尽量避免在片元着色器中多次采样同一张纹理可通过采样一次存入变量复用。考虑使用纹理图集Texture Atlas来合并多次采样。简化数学运算sin,cos,pow,exp等复杂函数比加减乘除慢得多。考虑使用查找表LUT或近似计算。mad乘加指令通常比单独的乘法和加法更快。避免分支if语句GPU是并行处理器同一批像素会同步执行相同的指令。如果存在分支所有像素实际上可能会执行所有分支的代码只是丢弃不需要的结果导致性能下降。尽量用数学函数如saturate,step,lerp来替代条件判断。利用顶点着色器如果某些计算在顶点之间变化平滑如一些简单的变形尽量放在顶点着色器中计算然后通过插值传递给片元着色器这比在片元着色器中对每个像素计算一次要高效得多。12.2 调试Shader的实用方法输出颜色调试法这是最粗暴有效的方法。当你不知道某个中间值是多少时直接把它作为最终颜色输出。例如你想看法线是否正确可以在片元着色器中return float4(worldNormal * 0.5 0.5, 1.0);将法线从(-1,1)映射到(0,1)的颜色空间。Unity Frame DebuggerWindow - Analysis - Frame Debugger。这个工具可以让你逐帧、逐个Draw Call查看渲染状态包括当前使用的Shader、渲染目标、纹理和着色器属性。对于理解渲染顺序和诊断Draw Call异常飙升非常有用。平台差异始终在目标平台如真机上测试Shader性能。PC上流畅的效果在移动设备上可能卡顿。使用Unity的SHADER_API_MOBILE等编译指令可以为不同平台编写差异化的代码。复杂度分析URP提供了Shader Variant Collection来管理变体避免生成过多的Shader变体导致包体膨胀和加载时间增加。使用#pragma skip_variants来跳过不需要的变体。学习Shader编程是一场漫长的旅程但它带来的回报是巨大的——你对实时图形渲染的理解将达到一个新的层次并且能够创造出独一无二的视觉体验。不要试图一次掌握所有内容从一个简单的效果开始实现它理解它然后挑战更复杂的效果。多阅读优秀的开源Shader如Unity Standard Assets、Asset Store中的优秀资源多动手修改和实验积累的经验将成为你最宝贵的财富。当你第一次看到自己亲手编写的Shader在屏幕上完美运行时那种成就感是无与伦比的。