ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity Shader入门:从零到一掌握渲染管线与着色器编程

Unity Shader入门:从零到一掌握渲染管线与着色器编程 1. 项目概述为什么Unity Shader值得你从零开始如果你刚接触Unity或者已经能用C#写一些游戏逻辑但每次看到Material Inspector里那些复杂的参数和Shader选项就感到头疼甚至看到模型“变紫”就手足无措那么你找对地方了。Shader这个听起来很“图形学”、很“底层”的东西其实是打通你从“功能实现”到“视觉表现”这最后一公里的关键。很多人觉得Shader是高级特效师或TA技术美术的领域自己作为程序或独立开发者没必要深究。但我的经验是哪怕只懂一点Shader基础你解决日常开发中视觉问题的效率会提升一个数量级。举个例子你遇到过UI图片边缘锯齿严重、角色在特定灯光下颜色发黑、或者想做一个简单的溶解效果却无从下手的情况吗这些问题的根源和解决方案往往都落在Shader上。所谓“从零开始”不是要你立刻成为图形学专家而是帮你建立起一套可用的知识框架和动手能力让你能看懂、能修改、甚至能亲手写出解决实际问题的Shader。这就像学开车你不必先成为汽车工程师但必须知道油门、刹车和方向盘怎么用。Unity Shader就是你控制游戏画面这辆“车”的方向盘。网络上很多教程要么过于理论直接从矩阵变换和光照模型讲起要么过于零散只给一个特效代码却不解释为什么。我希望通过这篇总结结合我踩过的无数个坑带你走一条更平缓、更实用的上手路径。2. 核心概念扫盲Shader、Material、渲染管线与常见“紫”坑在动手写代码之前我们必须把几个最基础、也最容易混淆的概念理清楚。很多新手问题都源于对这些概念的模糊理解。2.1 Shader、Material与Shader Asset它们到底是什么关系这是最经典的入门第一问。你可以这样理解Shader着色器程序这是一套指令和算法。它运行在GPU上定义了顶点如何变换、像素如何着色的数学规则。它就像一份“菜谱”详细说明了做一道菜的步骤和原料配比。在Unity中这份“菜谱”通常写在.shader后缀的文件里这就是Shader Asset。Material材质这是一个参数集合和资源引用的容器。它引用了某一个Shader菜谱并为其各项参数如颜色、纹理、数值填充了具体的值。它就像按照某份菜谱准备好的一份具体食材油盐酱醋各放多少都定好了。你把Material拖给一个3D模型模型就知道该怎么被画出来了。Shader ObjectShader对象这是Unity内部管理的一个C#对象Shader类实例它封装了GPU需要的着色器程序代码以及Unity如何使用这些代码的元信息。我们通常不直接操作它而是通过Material来间接使用。一个常见的误区是直接在代码里new一个Material。这没错但如果你没同时指定一个有效的Shader这个Material就是“无菜谱的食材”Unity不知道如何渲染模型就会显示成标志性的粉色或紫色。这是Unity提示“Shader丢失或出错”的默认颜色。所以创建Material的基本操作是Material mat new Material(Shader.Find(Standard));。这里的Standard就是内置标准着色器的路径名。2.2 渲染管线Render Pipeline的选择与影响这是近年来Unity Shader学习路上最大的“路障”之一。Unity提供了不同的渲染管线它们底层架构不同内置的Shader和编写规则也大相径庭。内置渲染管线Built-inUnity传统管线兼容性好学习资料最多。我们常说的Surface Shader就是它的产物。如果你的项目是旧项目或者目标平台非常广泛尤其是低端移动设备可能还在用它。通用渲染管线URPUnity主推的现代轻量级管线性能较好适合移动端和PC。它使用Shader Graph可视化编辑和HLSL代码编写ShaderLab。重要URP的Shader结构和内置管线不兼容内置的StandardShader在URP项目里直接无效这也是导致“材质变紫”的常见原因。高清渲染管线HDRP面向PC和主机的高保真管线效果强大但配置复杂。实操心得对于新手我强烈建议直接从URP开始新项目。它的架构更清晰与Shader Graph集成好代表了Unity未来的方向。当你新建项目时选择“Universal RP”模板即可。这会自动配置好URP所需的资源和设置避免很多坑。如果你打开一个老项目发现所有材质都紫了很可能需要升级或转换渲染管线。2.3 Shader编程语言ShaderLab、HLSL与CGUnity的Shader文件.shader是一种混合体。ShaderLab这是Unity自己创造的包装和组织语言。它用大括号{}定义Shader的结构比如属性Properties、子着色器SubShader、通道Pass等。它不直接执行计算而是搭建一个框架。HLSL/CG这是真正在GPU上执行计算的编程语言。代码写在CGPROGRAM和ENDCG内置管线或HLSLPROGRAM和ENDHLSLURP块中。HLSL是微软的标准CG是NVIDIA的旧标准两者语法高度相似。在URP中基本只用HLSL。一个最简单的Shader框架长这样Shader MyCustom/SimpleShader // ShaderLab定义在材质下拉菜单中的路径 { Properties // ShaderLab定义在Inspector中可调的参数 { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) } SubShader { Pass { CGPROGRAM // 开始CG/HLSL代码块 #pragma vertex vert // 指定顶点着色器函数名 #pragma fragment frag // 指定片元着色器函数名 // ... 包含头文件、定义变量、编写vert和frag函数 ... ENDCG // 结束代码块 } } }3. 动手第一步创建你的第一个可运行Shader理论说再多不如动手。我们抛开复杂的Surface Shader从最基础的顶点/片元着色器Vertex/Fragment Shader开始。这是理解Shader工作原理最直接的方式。3.1 环境准备与项目设置创建项目打开Unity Hub新建一个3D项目使用URP模板。命名为“ShaderLearning”。创建Shader文件在Project窗口右键 - Create - Shader - Unlit Shader。Unity会创建一个基础的无光照Shader模板。命名为FirstShader.shader。创建Material右键刚才创建的Shader文件 - Create - Material。会自动生成一个使用该Shader的材质球命名为Mat_FirstShader。搭建测试场景在场景中创建一个Cube立方体或Sphere球体将Mat_FirstShader材质拖给它。现在双击打开FirstShader.shader文件你会看到Unity生成的模板代码。我们一步步来解读和修改它。3.2 解读模板一个最简单的色彩输出模板代码已经是一个完整的Shader。我们重点关注CGPROGRAM块内的部分。// 这些是“语义绑定”告诉Unity从哪里获取数据把数据输出到哪里。 struct appdata // 输入到顶点着色器的数据结构 { float4 vertex : POSITION; // POSITION语义获取模型的顶点位置局部空间 float2 uv : TEXCOORD0; // TEXCOORD0语义获取第一套UV坐标 }; struct v2f // 顶点着色器输出给片元着色器的数据结构 { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; // SV_POSITION语义输出裁剪空间下的顶点位置必须 }; sampler2D _MainTex; // 声明在Properties中定义的纹理变量 float4 _MainTex_ST; // 纹理的缩放平移参数ST Scale, Translate 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); // 根据UV采样纹理颜色 return col; }核心解析vert函数每个顶点执行一次。它最核心的任务是把顶点位置从模型自己的坐标系模型空间转换到屏幕投影所需的坐标系裁剪空间。UnityObjectToClipPos这个函数帮我们完成了这个复杂的矩阵乘法运算。这是固定步骤。frag函数每个屏幕上的像素更准确说是片元执行一次。它决定这个像素最终输出什么颜色。这里简单地采样了纹理。语义: POSITION, : SV_POSITION这是连接CPU和GPU数据的桥梁。POSITION告诉Unity“把模型顶点位置数据填充到这里”SV_POSITION告诉GPU“这个值是裁剪空间坐标用于光栅化”。第一个修改让我们输出纯色而不是纹理。注释掉采样纹理的代码直接返回一个颜色。fixed4 frag (v2f i) : SV_Target { // fixed4 col tex2D(_MainTex, i.uv); // return col; return fixed4(1, 0, 0, 1); // 返回不透明的红色 (R1, G0, B0, A1) }保存Shader文件Unity会自动编译。回到场景你会发现Cube变成了纯红色。恭喜你完成了第一个Shader修改3.3 添加可控属性与Material Inspector交互让颜色可调更有用。这需要修改Properties块和CGPROGRAM内的变量声明。Shader Unlit/FirstShader { Properties { _MainTex (Texture, 2D) white {} // 添加一个颜色属性 _MyColor (My Color, Color) (0, 0.5, 1, 1) // 默认蓝绿色 } SubShader { ... // SubShader和Pass标签保持不变 CGPROGRAM ... // 声明与Properties中同名的变量以便在CG代码中使用 sampler2D _MainTex; float4 _MainTex_ST; fixed4 _MyColor; // 声明颜色变量 v2f vert (appdata v) { ... } // 顶点着色器不变 fixed4 frag (v2f i) : SV_Target { // 使用_MyColor代替固定值 return _MyColor; } ENDCG } }保存后查看Mat_FirstShader材质球的Inspector你会发现多了一个“My Color”的颜色选择框。调整它Cube的颜色会随之改变。这就建立了从材质面板到Shader程序的动态连接。注意Properties中定义的变量名如_MyColor必须与CGPROGRAM中声明的变量名完全一致包括下划线。这是数据传递的约定。4. 核心原理深入顶点变换、片元着色与基础光照模型理解了基本流程后我们需要深入两个核心函数并尝试实现一个简单但完整的光照效果。4.1 顶点着色器的空间变换之旅为什么vert函数里要做空间变换因为GPU需要知道顶点最终在屏幕上的位置。这个过程通常分为几步模型空间 (Object Space)顶点数据最初的位置相对于模型原点。世界空间 (World Space)将模型摆放到游戏世界中的位置。通过乘以unity_ObjectToWorld矩阵实现。观察空间 (View Space / Eye Space)以摄像机为原点的坐标系。通过乘以unity_MatrixV世界到观察矩阵实现。裁剪空间 (Clip Space)一个标准化的立方体空间用于判断顶点是否在视锥体内。通过乘以投影矩阵如UNITY_MATRIX_P实现。屏幕空间 (Screen Space)通过“透视除法”x,y,z除以w分量和视口变换得到最终的2D像素坐标。在Unity中内置函数UnityObjectToClipPos(v.vertex)帮我们一次性完成了从模型空间到裁剪空间的转换即上述步骤2、3、4的组合。在URP中这个函数名可能变为TransformObjectToHClip但作用类似。实操心得在写自定义顶点动画如波浪、草摆动时我们通常在世界空间进行计算这样不同位置的同一模型动画才能同步。这时就需要手动拆分变换步骤v2f vert (appdata v) { v2f o; float4 worldPos mul(unity_ObjectToWorld, v.vertex); // 变换到世界空间 // 在世界空间对worldPos做偏移例如正弦波 worldPos.y sin(_Time.y worldPos.x) * 0.1; // 再从世界空间变换到裁剪空间 o.vertex mul(UNITY_MATRIX_VP, worldPos); o.uv v.uv; return o; }4.2 片元着色器颜色计算的舞台片元着色器是表现力的核心。除了返回固定色更常见的是基于UV采样纹理、混合颜色、计算光照。纹理采样进阶tex2D(sampler2D tex, float2 uv)是最基本的采样。但有时我们需要处理纹理环绕Wrap Mode和过滤Filter Mode。纹理偏移与平铺之前提到的TRANSFORM_TEX(v.uv, _MainTex)宏其实就是计算了uv * _MainTex_ST.xy _MainTex_ST.zw。你可以在材质面板的纹理槽旁设置Tiling和Offset对应的就是_ST的xy缩放和zw偏移。双线性过滤 vs 最近邻在纹理导入设置中Filter Mode选择Bilinear平滑过渡或Point像素风无过渡。在Shader中tex2D会自动使用纹理设置的过滤模式。4.3 实现一个简易的Lambert漫反射光照无光照UnlitShader很简单但真实感差。我们来实现一个最简单的漫反射光照模型它只需要法线信息和光源方向。首先我们需要让顶点着色器将法线信息传递给片元着色器。修改输入/输出结构体struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; // 添加法线语义 float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; // 新增一个变量存储世界空间法线 };在顶点着色器中计算世界空间法线v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; // 将法线从模型空间变换到世界空间并归一化 // 注意如果模型有非均匀缩放需要使用逆转置矩阵这里用UnityObjectToWorldNormal处理 o.worldNormal UnityObjectToWorldNormal(v.normal); return o; }在片元着色器中计算漫反射fixed4 frag (v2f i) : SV_Target { // 归一化世界空间法线插值后可能不再是单位长度 float3 normal normalize(i.worldNormal); // 获取主平行光方向世界空间已归一化且指向光源 float3 lightDir _WorldSpaceLightPos0.xyz; // 计算兰伯特光照系数max(0, dot(N, L)) float lambert max(0, dot(normal, lightDir)); // 结合灯光颜色 fixed4 lightColor _LightColor0; // 主平行光颜色 fixed4 col _MyColor * lightColor * lambert; // 添加一点点环境光避免完全背光面全黑 col.rgb unity_AmbientSky.rgb * _MyColor.rgb * 0.1; return col; }修改Tags和添加Include为了让Shader能接收到光照数据需要在Pass开头添加标签并包含光照相关的头文件。SubShader { Tags { RenderTypeOpaque LightModeForwardBase } // 重要LightModeForwardBase表示此Pass用于前向渲染的基础Pass能获取主平行光信息。 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 包含Unity内置变量和光照辅助函数 #include UnityCG.cginc #include Lighting.cginc // 包含_LightColor0等变量 ...保存后将场景中的Directional Light平行光旋转一下你会发现Cube的表面亮度随着光线角度变化了这就是最基本的漫反射效果。注意事项这个简易光照模型只在有ForwardBase标签且场景中有激活的平行光时有效。它没有计算阴影也没有处理多光源。对于复杂光照Unity的Surface Shader或URP的Lit Shader是更好的选择它们封装了这些复杂计算。5. Shader Graph可视化编程入门如果你觉得写代码太抽象Unity提供的Shader Graph工具是绝佳的替代和辅助。它是一个基于节点的可视化着色器编辑器特别适合美术人员和偏好可视化思维的开发者。URP对它的支持最好。5.1 创建与配置你的第一个Shader Graph在Project窗口右键 - Create - Shader Graph - URP - Lit Shader Graph或Unlit Shader Graph。命名为MyFirstGraph。双击打开你会看到一个节点编辑窗口。中间有Master Stack主堆栈它定义了Shader的最终输出如表面颜色、法线、金属度等。我们从最简单的开始创建一个纯色输出。在空白处右键 - Create Node - Input - Basic - Color。这会创建一个Color节点。点击Color节点上的颜色块选择一个颜色比如绿色。将Color节点的输出端口拖拽到Master Stack的Base Color输入端口上。点击左上角的Save Asset然后像使用普通Shader一样用它创建一个Material并赋给物体。物体应该显示为你选择的颜色。5.2 实现一个动态变化的颜色让颜色随时间变化体验Shader Graph的动态能力。删除或断开Color节点与Base Color的连接。右键创建节点Time-Time节点。它输出一个不断增长的Time值。右键创建节点Math-Sine正弦函数节点。将Time节点的Time输出口连接到Sine节点的In输入口。Sine节点会输出一个在-1到1之间周期性变化的值。我们需要把(-1,1)映射到颜色范围(0,1)。再创建一个Math-Remap重映射节点。将Sine的输出连接到Remap的In将Out Min Max设置为(0, 1)。或者更简单用Math-Add加一个1再用Math-Multiply乘以0.5(sin(time) 1) * 0.5。将这个在0到1之间变化的值同时连接到Base Color的R、G、B三个通道。你需要一个Combine节点Vector - Combine来合成RGB颜色。创建Combine节点模式选择Vector 3。将重映射后的输出线分别拖到Combine节点的R、G、B三个输入口上。将Combine节点的输出连接到Master Stack的Base Color。保存后进入Play模式你会看到物体的颜色在黑色和白色之间平滑过渡因为RGB值同步变化。你可以修改计算让R、G、B通道变化不同步产生彩虹色效果。Shader Graph的优势直观、迭代快、易于实现基于数学函数的动态效果和复杂的纹理混合。局限性对于极其定制化的算法、需要复杂循环或分支判断的逻辑代码Shader更灵活高效。两者结合使用最佳用Shader Graph做原型和美术效果用代码Shader实现底层复杂功能。6. 实战问题排查与性能优化指南掌握了基础最终要落到解决问题和项目实际上。下面是一些高频问题和优化技巧。6.1 常见“紫”坑与解决方案速查表问题现象可能原因解决方案材质显示为粉色/紫色1. Shader编译错误。2. Shader丢失如引用了内置管线Shader的URP项目。3. Properties中变量未在CG代码中声明或声明类型不匹配。1. 查看Console窗口的错误信息修正Shader语法。2. 检查材质使用的Shader名称在URP项目中使用URP兼容的Shader如“Universal Render Pipeline/Lit”。3. 检查变量名和类型是否完全一致。模型显示全黑或全白1. 光照计算错误法线错误、光源方向错误。2. 颜色/纹理采样值超出预期范围。1. 检查法线变换是否正确是否归一化。在片元着色器中使用return float4(normal, 1);可视化法线检查。2. 逐步简化Shader先返回固定值或纹理采样值定位问题环节。纹理不显示或错乱1. UV坐标错误或未传递。2. 纹理采样器sampler2D未正确声明或绑定。3. 纹理导入设置问题Read/Write未开启、压缩格式不支持。1. 在片元着色器中return float4(i.uv.x, i.uv.y, 0, 1);可视化UV检查。2. 确保Properties中的纹理变量名与代码中一致。3. 在Project窗口选中纹理在Inspector中确认Texture Type正确并尝试勾选“Read/Write Enabled”。透明效果异常1. 渲染队列Queue设置不正确。2. 混合Blend模式未开启或设置错误。3. 深度写入ZWrite与深度测试ZTest冲突。1. 在SubShader或Pass的Tags中设置QueueTransparent。2. 在Pass中添加Blend SrcAlpha OneMinusSrcAlpha标准Alpha混合。3. 对于透明物体通常设置ZWrite Off。在移动设备上性能极差1. 片元着色器计算过于复杂每像素高开销。2. 使用了discard操作在移动GPU上昂贵。3. 纹理采样次数过多或纹理尺寸过大。1. 将计算尽可能移到顶点着色器结果插值。使用查找表LUT替代实时计算。2. 避免使用discard实现透明改用Alpha混合。3. 合并纹理使用Mipmap降低纹理分辨率。6.2 Shader性能优化核心思路对于移动平台或大型项目Shader性能至关重要。精度选择在移动端尽量使用低精度浮点数。float高精度32位用于世界坐标、纹理坐标等。half中精度16位用于颜色、向量、HDR颜色等。在移动端这是最常用的精度。fixed低精度11位仅用于标准颜色0-1范围。在较新的GPU和Shader Model中它通常被当作half处理但显式使用有助于表明意图。half3 diffuse _LightColor0.rgb * lambert; // 颜色计算用half足够 float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 位置用float减少纹理采样Texture Fetch纹理采样是GPU的主要开销之一。合并纹理将金属度、光滑度、环境光遮蔽AO等灰度图打包到一张纹理的R、G、B通道中。使用Mipmap确保纹理启用了Mipmap远距离物体使用小图采样提升缓存命中率。避免条件采样如非必要不要在分支语句if/else内部进行纹理采样因为GPU可能会执行所有分支的采样。简化数学运算用mad乘加指令替代单独的乘法和加法但编译器通常会优化。优先使用内置函数如dot,normalize,saturate它们通常经过高度优化。对于复杂的函数如pow,sin考虑使用近似计算或查找表。利用顶点着色器顶点着色器的执行频率远低于片元着色器顶点数 vs 像素数。能将计算从片元着色器移到顶点着色器就尽量移。例如简单的雾效、波浪动画可以在顶点着色器计算因子然后通过v2f结构体插值到片元着色器使用。针对URP的优化使用SRP Batcher确保Shader符合SRP Batcher条件声明CBUFFER_START(UnityPerMaterial)包裹Properties变量可以大幅提升包含大量材质但Shader变体不多的场景的渲染效率。减少Shader变体谨慎使用#pragma multi_compile和shader_feature不必要的变体会增加构建时间和内存占用。使用shader_feature_local替代multi_compile来生成仅本地需要的变体。6.3 调试技巧眼见为实当Shader效果不符合预期时有效的调试手段能快速定位问题。颜色调试法这是最直接的方法。在片元着色器中将你怀疑有问题的中间变量作为颜色输出。// 检查法线 return float4(normal * 0.5 0.5, 1); // 将(-1,1)的法线映射到(0,1)的颜色空间 // 检查UV return float4(i.uv.x, i.uv.y, 0, 1); // 检查光照系数 return float4(lambert, lambert, lambert, 1);使用Frame DebuggerWindow - Analysis - Frame Debugger。它可以截取一帧的渲染过程查看每个Draw Call使用的Shader、状态和渲染目标对于理解渲染顺序和Overdraw非常有用。检查编译后的代码在Inspector中选中Shader文件底部有“Compiled code”选项卡可以查看为不同图形API如GLSL、HLSL编译后的底层代码适合高级优化和问题排查。从看懂一个Shader到修改它再到自己从头实现一个功能这个过程会不断遇到问题和惊喜。Shader的世界很深但入门并解决80%的日常问题并没有想象中那么难。关键是动手不断地改参数、看效果、查文档、解问题。当你第一次用自己的Shader让角色在雪地上留下动态的脚印或者让UI界面产生优雅的流光效果时那种成就感是纯粹的代码逻辑难以比拟的。这份对画面最终呈现的直接控制力正是学习Shader最大的乐趣和回报所在。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进