1. 项目概述为什么Unity Shader是图形程序员的必修课如果你正在用Unity做游戏或者任何需要图形表现的项目迟早有一天你会遇到一个坎内置的Standard Shader或者Asset Store里买来的特效包怎么调都达不到你想要的效果。可能是角色皮肤缺少那种通透的次表面散射感可能是UI界面想要一种独特的溶解动画也可能是场景里的水体看起来总像一块塑料。这时候你就得直面Shader了。很多人一听到Shader就头大觉得这是图形学大佬的领域满屏的数学公式和晦涩术语。但我想告诉你的是对于Unity开发者而言Shader入门并没有想象中那么难尤其是当你掌握了从ShaderLab语法到亲手编写顶点/片元着色器这条路径之后很多看似复杂的效果其底层逻辑都是清晰且可掌控的。这个“完整指南”的目标就是帮你跨过从“只会拖拽材质球”到“能动手修改甚至创作简单Shader”的这道门槛。我们不追求一下子成为着色器大师而是聚焦于实战理解Unity Shader的基本结构ShaderLab弄懂数据是如何从CPU传递到GPU并在顶点着色器和片元着色器中流转的最后亲手实现几个有代表性的效果。你会发现一旦打通了这个流程你对Unity渲染管线的理解会深刻得多排查渲染问题比如为什么模型是粉色的为什么阴影不见了也会变得有章可循。无论是为了面试时能聊点深入的图形话题还是为了真正做出差异化、让人眼前一亮的美术效果这份投入都绝对值得。2. 核心概念与ShaderLabUnity Shader的“外壳”与“骨架”在直接写CG/HLSL代码之前我们必须先理解ShaderLab。你可以把它看作是Unity Shader的“包装纸”和“说明书”它定义了Shader的属性Properties、子着色器SubShader、通道Pass等元信息是Unity引擎识别和组织着色器代码的框架。一个完整的Unity Shader文件.shader就是由ShaderLab语法包裹着核心的CGPROGRAM代码块构成的。2.1 ShaderLab的基本结构从属性块到回退一个最基础的Unity Shader结构如下所示。我们逐段拆解Shader Custom/MyFirstShader // 1. Shader路径名 { Properties // 2. 属性块暴露给材质面板 { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _Glossiness (Smoothness, Range(0,1)) 0.5 } SubShader // 3. 子着色器核心 { Tags { RenderTypeOpaque } // 渲染队列和类型标签 LOD 100 // 细节级别 Pass // 4. 通道一个完整的渲染流程 { CGPROGRAM // 5. 开始CG/HLSL代码 // ... 着色器程序代码在这里 ENDCG } } FallBack Diffuse // 6. 回退Shader }1. Shader路径名”Custom/MyFirstShader”。这决定了在材质球的Shader下拉菜单里如何找到它。“Custom/”是分类你可以按项目需要创建如“MyProject/Character/”。2. Properties属性块这是Shader与美术策划沟通的桥梁。在这里定义的变量会显示在材质面板上支持实时调节。常见类型有_MainTex (“Texture”, 2D) “white” {}一张2D纹理默认白色。_Color (“Tint”, Color) (1,1,1,1)颜色默认白色。_FloatValue (“Intensity”, Float) 1.0浮点数。_RangeValue (“Threshold”, Range(0, 1)) 0.5范围滑块。注意Properties中的变量名如_MainTex与后面CG代码中使用的变量名必须严格一致包括下划线。这是数据从材质面板传递到GPU代码的关键。3. SubShader子着色器一个Shader可以包含多个SubShaderUnity会从上到下选择第一个能被当前显卡支持的SubShader。这常用于为不同性能平台如PC和移动端提供不同复杂度的实现。Tags定义了Shader的渲染顺序如”Queue””Transparent”和类型”RenderType”供摄像机深度纹理、后处理等系统使用。LOD是Level of Detail在代码中可以通过Shader.globalMaximumLOD来动态切换实现性能分级。4. Pass通道这是渲染的原子操作。一个SubShader可以包含多个Pass每个Pass意味着模型会被渲染一次。多Pass常用于实现复杂效果如轮廓光第一个Pass渲染正常颜色第二个Pass渲染放大的背面作为轮廓。但要注意每个Pass都有Draw Call开销需谨慎使用。5. CGPROGRAM/ENDCG在这对指令之间就是我们编写核心着色器代码顶点/片元着色器的地方使用CG/HLSL语言。6. FallBack当所有SubShader都不被支持时回退到一个更简单的、兼容性更好的Shader如内置的”Diffuse”或”VertexLit”。这是一个很好的兼容性保障。2.2 属性、标签与状态设置详解属性Properties的深入理解 属性不仅仅是界面控件。当你通过材质面板调整一个属性时Unity会自动生成一个与该属性同名的**材质属性块MaterialPropertyBlock**值。在着色器代码中我们需要使用与属性同名的变量来声明并接收这个值。对于纹理类型还需要配套声明一个_MainTex_ST变量ST代表Scale和Tiling用于处理纹理的缩放和偏移这是一个Unity的约定。标签Tags的实战意义”Queue”至关重要。它决定了渲染顺序。常见值有”Background”(1000)最先渲染如天空盒。”Geometry”(2000)默认值不透明物体。”AlphaTest”(2450)进行了透明度测试的物体如镂空贴图。”Transparent”(3000)半透明物体从后往前渲染。”Overlay”(4000)最后渲染如UI、镜头光晕。 正确设置Queue可以解决很多半透明物体的渲染错乱问题。”RenderType”用于替换着色器Replacement Shader或后处理。例如你想为所有不透明物体单独绘制一张深度图可以在相机上设置一个替换Shader条件是RenderType”Opaque”。状态设置在Pass内部你可以用Cull、ZWrite、ZTest、Blend等命令来设置GPU渲染状态。Cull Back | Front | Off剔除背面/正面/不剔除。制作双面渲染的树叶或玻璃时常用Cull Off。ZWrite On | Off是否写入深度缓冲。半透明物体通常设为Off防止后面绘制的透明片段被错误剔除。Blend SrcAlpha OneMinusSrcAlpha这是最常用的Alpha混合模式实现半透明效果。理解ShaderLab是控制Shader行为的基础它就像乐高积木的底板后续所有的CG代码都是搭建在上面的零件。3. 顶点着色器与片元着色器GPU流水线的核心工位现在我们进入CGPROGRAM内部这里是着色器程序员的主战场。现代可编程渲染管线中我们最常打交道的就是顶点着色器Vertex Shader和片元着色器Fragment Shader 在DirectX中常称为Pixel Shader。它们分别在GPU渲染管线的不同阶段工作。3.1 数据流从模型顶点到屏幕像素想象一下渲染一个三角形的过程CPU阶段Unity将模型的顶点数据位置、法线、UV等和当前变换矩阵MVP矩阵准备好通过Draw Call指令“打包”发送给GPU。顶点着色器阶段GPU对每个顶点独立执行顶点着色器程序。它的核心任务是将顶点的模型空间坐标经过一系列变换模型-世界-观察-裁剪空间最终输出到齐次裁剪空间。这个空间下的坐标用于后续的裁剪和屏幕映射。此外它还可以处理顶点的法线、UV、颜色等数据并传递给片元着色器。图元装配与光栅化GPU将顶点连接成三角形图元然后把这个三角形“栅格化”即确定它覆盖了屏幕上的哪些像素或更准确地说片元。片元着色器阶段对光栅化产生的每一个片元可以理解为候选像素执行片元着色器程序。它的核心任务是计算这个片元最终的颜色值。这里会进行纹理采样、光照计算、颜色混合等所有决定像素外观的操作。逐片元操作片元着色器输出的颜色需要经过深度测试、模板测试、混合等操作最终才会被写入帧缓冲区成为屏幕上的一个像素。关键区别顶点着色器处理每个顶点输出裁剪空间位置。顶点数量相对较少。片元着色器处理每个片元输出颜色。片元数量即屏幕像素分辨率相关通常远多于顶点。3.2 编写第一个顶点/片元着色器让我们看一个最简单的、不采样纹理、只输出纯色的Shader代码结构它位于一个Pass的CGPROGRAM块内CGPROGRAM // 1. 声明着色器编译指令 #pragma vertex vert // 指定顶点着色器函数名为 vert #pragma fragment frag // 指定片元着色器函数名为 frag #include UnityCG.cginc // 包含Unity内置的变量和函数 // 2. 定义从CPU应用阶段输入到顶点着色器的数据结构 struct appdata { float4 vertex : POSITION; // 模型空间顶点位置语义绑定POSITION float3 normal : NORMAL; // 模型空间法线语义绑定NORMAL float2 uv : TEXCOORD0; // 第一套纹理坐标语义绑定TEXCOORD0 }; // 3. 定义从顶点着色器输出到片元着色器的数据结构顶点着色器的输出就是片元着色器的输入 struct v2f // “vertex to fragment”的缩写 { float4 pos : SV_POSITION; // 裁剪空间位置SV_POSITION是系统值语义 float3 worldNormal : TEXCOORD0; // 自定义数据如世界空间法线用TEXCOORD系列语义传递 }; // 4. 顶点着色器函数 v2f vert (appdata v) { v2f o; // 声明输出结构 // 核心操作将顶点从模型空间变换到裁剪空间 o.pos UnityObjectToClipPos(v.vertex); // 将法线从模型空间变换到世界空间并归一化 o.worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); return o; } // 5. 片元着色器函数 fixed4 frag (v2f i) : SV_Target // 返回值语义SV_Target表示输出到渲染目标 { // 简单的基于世界法线的漫反射光照假设光源方向为世界空间向上 float3 lightDir float3(0, 1, 0); float diff max(0, dot(normalize(i.worldNormal), lightDir)); fixed4 col fixed4(diff, diff, diff, 1.0); // 灰度值作为颜色 return col; } ENDCG代码逐行解析#pragma指令告诉Unity编译器哪个函数是顶点着色器哪个是片元着色器。appdata结构体定义了模型网格提供的顶点数据。: POSITION这种写法叫做语义它告诉GPU这个变量对应哪种数据流。这是CG/HLSL语言连接GPU固定功能管线的方式。v2f结构体用于在顶点和片元着色器之间传递数据。SV_POSITION是系统值语义表示这是裁剪空间位置必须由顶点着色器赋值。其他自定义数据如worldNormal可以用TEXCOORD0、TEXCOORD1等语义传递它们本质上是些可以存放浮点数的寄存器。在vert函数中UnityObjectToClipPos是UnityCG.cginc内置的一个函数它等价于mul(UNITY_MATRIX_MVP, v.vertex)旧版本帮你完成了从模型空间到裁剪空间的完整变换矩阵乘法。这是顶点着色器必须完成的任务。在frag函数中我们利用从顶点着色器插值过来的世界法线计算了一个简单的漫反射光照。fixed4是低精度通常8位的颜色类型在移动端使用有助于优化。SV_Target语义表示输出到渲染目标通常是屏幕。这个简单的例子揭示了数据流动的全过程CPU提供appdata- 顶点着色器处理并填充v2f- GPU对v2f进行插值 - 片元着色器接收插值后的v2f数据并计算颜色。4. 实战进阶实现经典着色器效果理解了基础流程后我们通过实现几个经典效果来巩固知识并引入更多关键概念。4.1 效果一纹理采样与颜色混合这是最基础也是最常用的功能。我们扩展之前的Shader加入纹理和颜色调节。Properties中新增_MainTex (Albedo (RGB), 2D) white {} _Color (Color Tint, Color) (1,1,1,1)CG代码部分修改 首先需要在CGPROGRAM外部声明与Properties同名的变量以便建立连接sampler2D _MainTex; float4 _MainTex_ST; // 自动用于纹理的缩放和偏移 fixed4 _Color;_MainTex_ST是一个float4其xy分量是纹理的缩放Tilingzw分量是偏移Offset。这是Unity的约定。接着修改v2f结构将UV坐标传递下去struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; // 传递UV };在顶点着色器中使用TRANSFORM_TEX宏来处理UV的缩放和偏移v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理的缩放和偏移 return o; }最后在片元着色器中进行纹理采样并与颜色混合fixed4 frag (v2f i) : SV_Target { // tex2D函数对纹理进行采样i.uv是采样坐标 fixed4 texColor tex2D(_MainTex, i.uv); // 将纹理颜色与色调颜色相乘实现染色效果 fixed4 finalColor texColor * _Color; return finalColor; }实操心得tex2D是CG标准库函数在片元着色器中使用。如果在顶点着色器中采样纹理较少见需要使用tex2Dlod。另外对于透明纹理需要确保在SubShader的Tags中设置正确的”Queue”和”RenderType”并在Pass中设置混合模式Blend SrcAlpha OneMinusSrcAlpha。4.2 效果二基于法线贴图Normal Map的凹凸细节法线贴图通过改变片元法线方向来模拟表面凹凸而不增加模型顶点数。我们需要第二张纹理法线贴图和切线空间相关的计算。Properties新增_BumpMap (Normal Map, 2D) bump {} // “bump”是内置的灰色法线贴图 _BumpScale (Bump Scale, Float) 1.0数据结构与变量 为了进行切线空间计算appdata需要增加切线信息struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; // 新增切线w分量用于决定副切线的方向 float2 uv : TEXCOORD0; };v2f需要传递更多信息struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; // 世界空间位置 float3 tspace0 : TEXCOORD2; // 切线空间矩阵行0 float3 tspace1 : TEXCOORD3; // 切线空间矩阵行1 float3 tspace2 : TEXCOORD4; // 切线空间矩阵行2 };顶点着色器构建从切线空间到世界空间的变换矩阵。v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 计算世界坐标 // 计算世界空间下的法线、切线、副切线 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); float3 worldBinormal cross(worldNormal, worldTangent) * v.tangent.w; // 构建切线-世界矩阵按列存储 o.tspace0 float3(worldTangent.x, worldBinormal.x, worldNormal.x); o.tspace1 float3(worldTangent.y, worldBinormal.y, worldNormal.y); o.tspace2 float3(worldTangent.z, worldBinormal.z, worldNormal.z); return o; }片元着色器采样法线贴图将法线从切线空间变换到世界空间然后用于光照计算。fixed4 frag (v2f i) : SV_Target { // 从法线贴图采样并解包到范围[-1, 1] fixed3 tangentNormal UnpackNormal(tex2D(_BumpMap, i.uv)); tangentNormal.xy * _BumpScale; tangentNormal.z sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 将法线从切线空间变换到世界空间 float3 worldNormal; worldNormal.x dot(i.tspace0, tangentNormal); worldNormal.y dot(i.tspace1, tangentNormal); worldNormal.z dot(i.tspace2, tangentNormal); worldNormal normalize(worldNormal); // 简单的漫反射光照计算使用世界法线和世界光源方向 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 float diff max(0, dot(worldNormal, lightDir)); fixed4 albedo tex2D(_MainTex, i.uv) * _Color; fixed4 finalColor albedo * diff; return finalColor; }注意事项法线贴图通常需要标记为“Normal Map”类型Unity才会在导入时进行正确压缩并且UnpackNormal函数才能正确解码。UnpackNormal内部处理了不同平台如移动端使用DXT5nm压缩格式的解码差异务必使用它而不是直接使用tex2D的原始结果。4.3 效果三卡通渲染Cel Shading与边缘光卡通渲染的核心是色阶化光照和描边。这里实现一个简单的版本。色阶化漫反射在片元着色器中将连续的光照计算结果diff通过ceil或步进函数离散化。fixed4 frag (v2f i) : SV_Target { float3 worldNormal normalize(i.worldNormal); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float diff dot(worldNormal, lightDir); // 色阶化将连续值映射到几个离散的色阶 float cel floor(diff * _CelBands) / _CelBands; // _CelBands是色阶数如3 cel max(0, cel); // 确保非负 fixed4 albedo tex2D(_MainTex, i.uv); fixed4 col albedo * cel * _LightColor0; // 乘以光源颜色 return col; }边缘光Rim Light基于视线方向与法线方向的夹角。夹角越大越接近边缘边缘光强度越强。// 在顶点着色器中计算世界空间视图方向 o.worldViewDir normalize(_WorldSpaceCameraPos.xyz - o.worldPos); // 在片元着色器中计算边缘光强度 float3 viewDir normalize(i.worldViewDir); float rim 1.0 - saturate(dot(viewDir, worldNormal)); // 点积越小夹角越大rim越大 rim smoothstep(_RimMin, _RimMax, rim); // 使用smoothstep控制边缘光范围和过渡 fixed3 rimColor rim * _RimColor.rgb * _RimIntensity; col.rgb rimColor; // 将边缘光颜色加到最终颜色上描边效果描边通常通过两个Pass实现。第一个Pass正常渲染模型内部。第二个Pass用正面剔除Cull Front的方式渲染一个稍微放大的模型并赋予纯色如黑色。// Pass 1: 渲染内部卡通着色 Pass { Cull Back // ... 正常的卡通着色代码 } // Pass 2: 渲染描边 Pass { Cull Front // 剔除正面只渲染背面即模型向外扩张的部分 ZWrite Off // 可选防止描边影响深度具体看需求 CGPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline float _OutlineWidth; float4 _OutlineColor; v2f_simple vert_outline (appdata v) { v2f_simple o; // 将顶点沿法线方向挤出 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; worldPos worldNormal * _OutlineWidth; // 挤出 o.pos mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); // 变换到裁剪空间 return o; } fixed4 frag_outline (v2f_simple i) : SV_Target { return _OutlineColor; } ENDCG }常见问题描边的宽度_OutlineWidth在世界空间下设置可能更直观但需要注意性能。对于复杂模型顶点法线挤出法可能在拐角处出现断线更高级的方法可以使用屏幕空间法线外扩在片元着色器中处理但实现更复杂。5. 性能优化与调试技巧写完Shader只是第一步让它高效、稳定地运行同样重要。5.1 移动端Shader优化要点移动设备的GPU如Adreno, Mali, PowerVR与PC GPU架构不同对带宽和ALU算术逻辑单元更敏感。精度选择尽可能使用低精度变量。fixed最低精度通常-2到2用于颜色和单位向量。half中等精度16位浮点用于短向量、方向、纹理UV。float全精度32位浮点用于位置、坐标变换。 在片元着色器中将不需要高精度的计算降为half或fixed能显著提升性能。减少纹理采样纹理采样是昂贵的操作。合并纹理将金属度、光滑度、环境光遮蔽AO等灰度图打包到一张纹理的RGBA通道中。使用纹理图集Atlas。避免在顶点着色器中采样纹理除非必要。简化数学运算使用内置函数如dot,cross,normalize它们通常有硬件优化。避免pow,sin,cos等复杂函数或预先计算成纹理查找表。将可以在顶点着色器计算的内容如世界坐标、法线移到顶点着色器利用顶点到片元的插值减少片元着色器的计算量。慎用分支和循环GPU是并行架构分支if-else和循环可能导致线程束内的线程执行不同路径严重降低效率。尽量用数学函数如saturate,step,lerp替代简单分支。利用Shader变体Variants和多重编译指令使用#pragma multi_compile或shader_feature来为不同情况如是否有阴影、是否启用雾效编译不同版本的Shader代码避免在运行时用if判断。5.2 Shader调试方法论当Shader效果不符合预期时系统性的调试至关重要。使用Frame DebuggerUnity的Frame Debugger窗口-分析-Frame Debugger是神器。它可以暂停游戏逐帧、逐个Draw Call查看渲染状态包括当前激活的Shader、渲染目标、纹理、混合状态等。你可以清晰地看到你的Shader是在哪个Pass被调用输入数据是否正确。颜色调试法这是最直观的方法。在片元着色器中将你怀疑有问题的中间变量直接作为颜色输出。return float4(worldNormal * 0.5 0.5, 1.0); // 可视化法线从[-1,1]映射到[0,1] return float4(i.uv, 0, 1.0); // 可视化UV return float4(diff, diff, diff, 1.0); // 可视化光照强度通过观察屏幕颜色可以快速定位问题是出在数据输入如UV错误、法线错误还是计算过程。检查输入数据确保Properties中的变量名与CG代码中声明的完全一致。检查模型网格是否包含了Shader所需的顶点属性如法线、切线。可以在顶点着色器开头将appdata的内容输出到颜色检查是否接收到数据。理解插值从顶点着色器到片元着色器v2f中除了SV_POSITION其他所有变量都会在三角形的三个顶点之间进行线性插值。如果你的效果在三角形内部出现非预期的渐变可能是插值前在顶点着色器中没有将向量归一化normalize。通常法线、视线方向等需要在片元着色器中重新归一化。平台差异某些函数或语义在OpenGL ESiOS/Android和DirectXPC上行为略有不同。例如纹理坐标的V方向可能相反。使用Unity内置的宏如UNITY_UV_STARTS_AT_TOP和函数如UnpackNormal可以最大程度地避免跨平台问题。6. 从Built-in到URP现代渲染管线的迁移思考随着Unity版本更新内置渲染管线Built-in正逐渐被可编程渲染管线SRP尤其是通用渲染管线URP和高清渲染管线HDRP所取代。对于新手从Built-in入门概念是很好的选择但了解URP的差异是必要的。核心差异Shader编写框架URP使用ShaderGraph可视化或HLSL代码但代码结构不同。URP Shader不直接使用ShaderLab的Properties和SubShader而是使用HLSLINCLUDE和Pass并且属性通过CBUFFER声明。光照模型Built-in是前向渲染光照计算在Shader内完成。URP是单Pass前向渲染很多光照信息如主光源、附加光源通过Lighting.hlsl等库函数提供需要包含不同的头文件和函数。坐标系与内置变量很多Built-in的内置变量和函数如_WorldSpaceLightPos0,UNITY_MATRIX_MVP在URP中已废弃或改名。URP使用TransformObjectToHClip、GetVertexPositionInputs等新的函数体系。迁移建议概念先行在Built-in下扎实理解顶点/片元着色器、坐标空间变换、光照模型等核心概念。这些知识在URP中完全通用。学习ShaderGraph对于不追求极致性能或复杂控制的效果URP的ShaderGraph是快速原型制作的强大工具它直观地展示了节点化的着色器逻辑有助于理解数据流。阅读URP Lit Shader源码这是最好的学习资料。在Unity安装目录或Package Manager中导入URP后找到Lit.shader研究其结构、属性和HLSL代码是掌握URP Shader编写方式的最快途径。逐步转换迁移时从一个最简单的、只输出颜色的Shader开始逐步添加纹理、法线、光照。重点关注如何包含正确的头文件如Packages/com.unity.render-pipelines.universal/ShaderLibrary/…和使用新的变换函数。从ShaderLab到顶点/片元着色器的学习是理解实时图形渲染的基石。这个过程可能会充满挑战但每一次成功实现一个自定义效果所带来的成就感以及由此带来的对引擎渲染黑盒的掌控感都是无可替代的。不要试图一次性掌握所有高级特性从修改一个颜色开始到叠加一张纹理再到实现简单的光照每一步都亲手实践并理解其背后的原理你的图形编程能力就会在这个过程中稳步建立起来。当你能独立调试一个渲染Bug或为项目创造出一个独特的视觉风格时你就会发现之前所有的投入都是值得的。
Unity Shader入门指南:从ShaderLab到顶点片元着色器实战
1. 项目概述为什么Unity Shader是图形程序员的必修课如果你正在用Unity做游戏或者任何需要图形表现的项目迟早有一天你会遇到一个坎内置的Standard Shader或者Asset Store里买来的特效包怎么调都达不到你想要的效果。可能是角色皮肤缺少那种通透的次表面散射感可能是UI界面想要一种独特的溶解动画也可能是场景里的水体看起来总像一块塑料。这时候你就得直面Shader了。很多人一听到Shader就头大觉得这是图形学大佬的领域满屏的数学公式和晦涩术语。但我想告诉你的是对于Unity开发者而言Shader入门并没有想象中那么难尤其是当你掌握了从ShaderLab语法到亲手编写顶点/片元着色器这条路径之后很多看似复杂的效果其底层逻辑都是清晰且可掌控的。这个“完整指南”的目标就是帮你跨过从“只会拖拽材质球”到“能动手修改甚至创作简单Shader”的这道门槛。我们不追求一下子成为着色器大师而是聚焦于实战理解Unity Shader的基本结构ShaderLab弄懂数据是如何从CPU传递到GPU并在顶点着色器和片元着色器中流转的最后亲手实现几个有代表性的效果。你会发现一旦打通了这个流程你对Unity渲染管线的理解会深刻得多排查渲染问题比如为什么模型是粉色的为什么阴影不见了也会变得有章可循。无论是为了面试时能聊点深入的图形话题还是为了真正做出差异化、让人眼前一亮的美术效果这份投入都绝对值得。2. 核心概念与ShaderLabUnity Shader的“外壳”与“骨架”在直接写CG/HLSL代码之前我们必须先理解ShaderLab。你可以把它看作是Unity Shader的“包装纸”和“说明书”它定义了Shader的属性Properties、子着色器SubShader、通道Pass等元信息是Unity引擎识别和组织着色器代码的框架。一个完整的Unity Shader文件.shader就是由ShaderLab语法包裹着核心的CGPROGRAM代码块构成的。2.1 ShaderLab的基本结构从属性块到回退一个最基础的Unity Shader结构如下所示。我们逐段拆解Shader Custom/MyFirstShader // 1. Shader路径名 { Properties // 2. 属性块暴露给材质面板 { _MainTex (Texture, 2D) white {} _Color (Color, Color) (1,1,1,1) _Glossiness (Smoothness, Range(0,1)) 0.5 } SubShader // 3. 子着色器核心 { Tags { RenderTypeOpaque } // 渲染队列和类型标签 LOD 100 // 细节级别 Pass // 4. 通道一个完整的渲染流程 { CGPROGRAM // 5. 开始CG/HLSL代码 // ... 着色器程序代码在这里 ENDCG } } FallBack Diffuse // 6. 回退Shader }1. Shader路径名”Custom/MyFirstShader”。这决定了在材质球的Shader下拉菜单里如何找到它。“Custom/”是分类你可以按项目需要创建如“MyProject/Character/”。2. Properties属性块这是Shader与美术策划沟通的桥梁。在这里定义的变量会显示在材质面板上支持实时调节。常见类型有_MainTex (“Texture”, 2D) “white” {}一张2D纹理默认白色。_Color (“Tint”, Color) (1,1,1,1)颜色默认白色。_FloatValue (“Intensity”, Float) 1.0浮点数。_RangeValue (“Threshold”, Range(0, 1)) 0.5范围滑块。注意Properties中的变量名如_MainTex与后面CG代码中使用的变量名必须严格一致包括下划线。这是数据从材质面板传递到GPU代码的关键。3. SubShader子着色器一个Shader可以包含多个SubShaderUnity会从上到下选择第一个能被当前显卡支持的SubShader。这常用于为不同性能平台如PC和移动端提供不同复杂度的实现。Tags定义了Shader的渲染顺序如”Queue””Transparent”和类型”RenderType”供摄像机深度纹理、后处理等系统使用。LOD是Level of Detail在代码中可以通过Shader.globalMaximumLOD来动态切换实现性能分级。4. Pass通道这是渲染的原子操作。一个SubShader可以包含多个Pass每个Pass意味着模型会被渲染一次。多Pass常用于实现复杂效果如轮廓光第一个Pass渲染正常颜色第二个Pass渲染放大的背面作为轮廓。但要注意每个Pass都有Draw Call开销需谨慎使用。5. CGPROGRAM/ENDCG在这对指令之间就是我们编写核心着色器代码顶点/片元着色器的地方使用CG/HLSL语言。6. FallBack当所有SubShader都不被支持时回退到一个更简单的、兼容性更好的Shader如内置的”Diffuse”或”VertexLit”。这是一个很好的兼容性保障。2.2 属性、标签与状态设置详解属性Properties的深入理解 属性不仅仅是界面控件。当你通过材质面板调整一个属性时Unity会自动生成一个与该属性同名的**材质属性块MaterialPropertyBlock**值。在着色器代码中我们需要使用与属性同名的变量来声明并接收这个值。对于纹理类型还需要配套声明一个_MainTex_ST变量ST代表Scale和Tiling用于处理纹理的缩放和偏移这是一个Unity的约定。标签Tags的实战意义”Queue”至关重要。它决定了渲染顺序。常见值有”Background”(1000)最先渲染如天空盒。”Geometry”(2000)默认值不透明物体。”AlphaTest”(2450)进行了透明度测试的物体如镂空贴图。”Transparent”(3000)半透明物体从后往前渲染。”Overlay”(4000)最后渲染如UI、镜头光晕。 正确设置Queue可以解决很多半透明物体的渲染错乱问题。”RenderType”用于替换着色器Replacement Shader或后处理。例如你想为所有不透明物体单独绘制一张深度图可以在相机上设置一个替换Shader条件是RenderType”Opaque”。状态设置在Pass内部你可以用Cull、ZWrite、ZTest、Blend等命令来设置GPU渲染状态。Cull Back | Front | Off剔除背面/正面/不剔除。制作双面渲染的树叶或玻璃时常用Cull Off。ZWrite On | Off是否写入深度缓冲。半透明物体通常设为Off防止后面绘制的透明片段被错误剔除。Blend SrcAlpha OneMinusSrcAlpha这是最常用的Alpha混合模式实现半透明效果。理解ShaderLab是控制Shader行为的基础它就像乐高积木的底板后续所有的CG代码都是搭建在上面的零件。3. 顶点着色器与片元着色器GPU流水线的核心工位现在我们进入CGPROGRAM内部这里是着色器程序员的主战场。现代可编程渲染管线中我们最常打交道的就是顶点着色器Vertex Shader和片元着色器Fragment Shader 在DirectX中常称为Pixel Shader。它们分别在GPU渲染管线的不同阶段工作。3.1 数据流从模型顶点到屏幕像素想象一下渲染一个三角形的过程CPU阶段Unity将模型的顶点数据位置、法线、UV等和当前变换矩阵MVP矩阵准备好通过Draw Call指令“打包”发送给GPU。顶点着色器阶段GPU对每个顶点独立执行顶点着色器程序。它的核心任务是将顶点的模型空间坐标经过一系列变换模型-世界-观察-裁剪空间最终输出到齐次裁剪空间。这个空间下的坐标用于后续的裁剪和屏幕映射。此外它还可以处理顶点的法线、UV、颜色等数据并传递给片元着色器。图元装配与光栅化GPU将顶点连接成三角形图元然后把这个三角形“栅格化”即确定它覆盖了屏幕上的哪些像素或更准确地说片元。片元着色器阶段对光栅化产生的每一个片元可以理解为候选像素执行片元着色器程序。它的核心任务是计算这个片元最终的颜色值。这里会进行纹理采样、光照计算、颜色混合等所有决定像素外观的操作。逐片元操作片元着色器输出的颜色需要经过深度测试、模板测试、混合等操作最终才会被写入帧缓冲区成为屏幕上的一个像素。关键区别顶点着色器处理每个顶点输出裁剪空间位置。顶点数量相对较少。片元着色器处理每个片元输出颜色。片元数量即屏幕像素分辨率相关通常远多于顶点。3.2 编写第一个顶点/片元着色器让我们看一个最简单的、不采样纹理、只输出纯色的Shader代码结构它位于一个Pass的CGPROGRAM块内CGPROGRAM // 1. 声明着色器编译指令 #pragma vertex vert // 指定顶点着色器函数名为 vert #pragma fragment frag // 指定片元着色器函数名为 frag #include UnityCG.cginc // 包含Unity内置的变量和函数 // 2. 定义从CPU应用阶段输入到顶点着色器的数据结构 struct appdata { float4 vertex : POSITION; // 模型空间顶点位置语义绑定POSITION float3 normal : NORMAL; // 模型空间法线语义绑定NORMAL float2 uv : TEXCOORD0; // 第一套纹理坐标语义绑定TEXCOORD0 }; // 3. 定义从顶点着色器输出到片元着色器的数据结构顶点着色器的输出就是片元着色器的输入 struct v2f // “vertex to fragment”的缩写 { float4 pos : SV_POSITION; // 裁剪空间位置SV_POSITION是系统值语义 float3 worldNormal : TEXCOORD0; // 自定义数据如世界空间法线用TEXCOORD系列语义传递 }; // 4. 顶点着色器函数 v2f vert (appdata v) { v2f o; // 声明输出结构 // 核心操作将顶点从模型空间变换到裁剪空间 o.pos UnityObjectToClipPos(v.vertex); // 将法线从模型空间变换到世界空间并归一化 o.worldNormal normalize(mul(v.normal, (float3x3)unity_WorldToObject)); return o; } // 5. 片元着色器函数 fixed4 frag (v2f i) : SV_Target // 返回值语义SV_Target表示输出到渲染目标 { // 简单的基于世界法线的漫反射光照假设光源方向为世界空间向上 float3 lightDir float3(0, 1, 0); float diff max(0, dot(normalize(i.worldNormal), lightDir)); fixed4 col fixed4(diff, diff, diff, 1.0); // 灰度值作为颜色 return col; } ENDCG代码逐行解析#pragma指令告诉Unity编译器哪个函数是顶点着色器哪个是片元着色器。appdata结构体定义了模型网格提供的顶点数据。: POSITION这种写法叫做语义它告诉GPU这个变量对应哪种数据流。这是CG/HLSL语言连接GPU固定功能管线的方式。v2f结构体用于在顶点和片元着色器之间传递数据。SV_POSITION是系统值语义表示这是裁剪空间位置必须由顶点着色器赋值。其他自定义数据如worldNormal可以用TEXCOORD0、TEXCOORD1等语义传递它们本质上是些可以存放浮点数的寄存器。在vert函数中UnityObjectToClipPos是UnityCG.cginc内置的一个函数它等价于mul(UNITY_MATRIX_MVP, v.vertex)旧版本帮你完成了从模型空间到裁剪空间的完整变换矩阵乘法。这是顶点着色器必须完成的任务。在frag函数中我们利用从顶点着色器插值过来的世界法线计算了一个简单的漫反射光照。fixed4是低精度通常8位的颜色类型在移动端使用有助于优化。SV_Target语义表示输出到渲染目标通常是屏幕。这个简单的例子揭示了数据流动的全过程CPU提供appdata- 顶点着色器处理并填充v2f- GPU对v2f进行插值 - 片元着色器接收插值后的v2f数据并计算颜色。4. 实战进阶实现经典着色器效果理解了基础流程后我们通过实现几个经典效果来巩固知识并引入更多关键概念。4.1 效果一纹理采样与颜色混合这是最基础也是最常用的功能。我们扩展之前的Shader加入纹理和颜色调节。Properties中新增_MainTex (Albedo (RGB), 2D) white {} _Color (Color Tint, Color) (1,1,1,1)CG代码部分修改 首先需要在CGPROGRAM外部声明与Properties同名的变量以便建立连接sampler2D _MainTex; float4 _MainTex_ST; // 自动用于纹理的缩放和偏移 fixed4 _Color;_MainTex_ST是一个float4其xy分量是纹理的缩放Tilingzw分量是偏移Offset。这是Unity的约定。接着修改v2f结构将UV坐标传递下去struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; // 传递UV };在顶点着色器中使用TRANSFORM_TEX宏来处理UV的缩放和偏移v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); // 应用纹理的缩放和偏移 return o; }最后在片元着色器中进行纹理采样并与颜色混合fixed4 frag (v2f i) : SV_Target { // tex2D函数对纹理进行采样i.uv是采样坐标 fixed4 texColor tex2D(_MainTex, i.uv); // 将纹理颜色与色调颜色相乘实现染色效果 fixed4 finalColor texColor * _Color; return finalColor; }实操心得tex2D是CG标准库函数在片元着色器中使用。如果在顶点着色器中采样纹理较少见需要使用tex2Dlod。另外对于透明纹理需要确保在SubShader的Tags中设置正确的”Queue”和”RenderType”并在Pass中设置混合模式Blend SrcAlpha OneMinusSrcAlpha。4.2 效果二基于法线贴图Normal Map的凹凸细节法线贴图通过改变片元法线方向来模拟表面凹凸而不增加模型顶点数。我们需要第二张纹理法线贴图和切线空间相关的计算。Properties新增_BumpMap (Normal Map, 2D) bump {} // “bump”是内置的灰色法线贴图 _BumpScale (Bump Scale, Float) 1.0数据结构与变量 为了进行切线空间计算appdata需要增加切线信息struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; // 新增切线w分量用于决定副切线的方向 float2 uv : TEXCOORD0; };v2f需要传递更多信息struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; // 世界空间位置 float3 tspace0 : TEXCOORD2; // 切线空间矩阵行0 float3 tspace1 : TEXCOORD3; // 切线空间矩阵行1 float3 tspace2 : TEXCOORD4; // 切线空间矩阵行2 };顶点着色器构建从切线空间到世界空间的变换矩阵。v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; // 计算世界坐标 // 计算世界空间下的法线、切线、副切线 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); float3 worldBinormal cross(worldNormal, worldTangent) * v.tangent.w; // 构建切线-世界矩阵按列存储 o.tspace0 float3(worldTangent.x, worldBinormal.x, worldNormal.x); o.tspace1 float3(worldTangent.y, worldBinormal.y, worldNormal.y); o.tspace2 float3(worldTangent.z, worldBinormal.z, worldNormal.z); return o; }片元着色器采样法线贴图将法线从切线空间变换到世界空间然后用于光照计算。fixed4 frag (v2f i) : SV_Target { // 从法线贴图采样并解包到范围[-1, 1] fixed3 tangentNormal UnpackNormal(tex2D(_BumpMap, i.uv)); tangentNormal.xy * _BumpScale; tangentNormal.z sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); // 将法线从切线空间变换到世界空间 float3 worldNormal; worldNormal.x dot(i.tspace0, tangentNormal); worldNormal.y dot(i.tspace1, tangentNormal); worldNormal.z dot(i.tspace2, tangentNormal); worldNormal normalize(worldNormal); // 简单的漫反射光照计算使用世界法线和世界光源方向 float3 lightDir normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 float diff max(0, dot(worldNormal, lightDir)); fixed4 albedo tex2D(_MainTex, i.uv) * _Color; fixed4 finalColor albedo * diff; return finalColor; }注意事项法线贴图通常需要标记为“Normal Map”类型Unity才会在导入时进行正确压缩并且UnpackNormal函数才能正确解码。UnpackNormal内部处理了不同平台如移动端使用DXT5nm压缩格式的解码差异务必使用它而不是直接使用tex2D的原始结果。4.3 效果三卡通渲染Cel Shading与边缘光卡通渲染的核心是色阶化光照和描边。这里实现一个简单的版本。色阶化漫反射在片元着色器中将连续的光照计算结果diff通过ceil或步进函数离散化。fixed4 frag (v2f i) : SV_Target { float3 worldNormal normalize(i.worldNormal); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float diff dot(worldNormal, lightDir); // 色阶化将连续值映射到几个离散的色阶 float cel floor(diff * _CelBands) / _CelBands; // _CelBands是色阶数如3 cel max(0, cel); // 确保非负 fixed4 albedo tex2D(_MainTex, i.uv); fixed4 col albedo * cel * _LightColor0; // 乘以光源颜色 return col; }边缘光Rim Light基于视线方向与法线方向的夹角。夹角越大越接近边缘边缘光强度越强。// 在顶点着色器中计算世界空间视图方向 o.worldViewDir normalize(_WorldSpaceCameraPos.xyz - o.worldPos); // 在片元着色器中计算边缘光强度 float3 viewDir normalize(i.worldViewDir); float rim 1.0 - saturate(dot(viewDir, worldNormal)); // 点积越小夹角越大rim越大 rim smoothstep(_RimMin, _RimMax, rim); // 使用smoothstep控制边缘光范围和过渡 fixed3 rimColor rim * _RimColor.rgb * _RimIntensity; col.rgb rimColor; // 将边缘光颜色加到最终颜色上描边效果描边通常通过两个Pass实现。第一个Pass正常渲染模型内部。第二个Pass用正面剔除Cull Front的方式渲染一个稍微放大的模型并赋予纯色如黑色。// Pass 1: 渲染内部卡通着色 Pass { Cull Back // ... 正常的卡通着色代码 } // Pass 2: 渲染描边 Pass { Cull Front // 剔除正面只渲染背面即模型向外扩张的部分 ZWrite Off // 可选防止描边影响深度具体看需求 CGPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline float _OutlineWidth; float4 _OutlineColor; v2f_simple vert_outline (appdata v) { v2f_simple o; // 将顶点沿法线方向挤出 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; worldPos worldNormal * _OutlineWidth; // 挤出 o.pos mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); // 变换到裁剪空间 return o; } fixed4 frag_outline (v2f_simple i) : SV_Target { return _OutlineColor; } ENDCG }常见问题描边的宽度_OutlineWidth在世界空间下设置可能更直观但需要注意性能。对于复杂模型顶点法线挤出法可能在拐角处出现断线更高级的方法可以使用屏幕空间法线外扩在片元着色器中处理但实现更复杂。5. 性能优化与调试技巧写完Shader只是第一步让它高效、稳定地运行同样重要。5.1 移动端Shader优化要点移动设备的GPU如Adreno, Mali, PowerVR与PC GPU架构不同对带宽和ALU算术逻辑单元更敏感。精度选择尽可能使用低精度变量。fixed最低精度通常-2到2用于颜色和单位向量。half中等精度16位浮点用于短向量、方向、纹理UV。float全精度32位浮点用于位置、坐标变换。 在片元着色器中将不需要高精度的计算降为half或fixed能显著提升性能。减少纹理采样纹理采样是昂贵的操作。合并纹理将金属度、光滑度、环境光遮蔽AO等灰度图打包到一张纹理的RGBA通道中。使用纹理图集Atlas。避免在顶点着色器中采样纹理除非必要。简化数学运算使用内置函数如dot,cross,normalize它们通常有硬件优化。避免pow,sin,cos等复杂函数或预先计算成纹理查找表。将可以在顶点着色器计算的内容如世界坐标、法线移到顶点着色器利用顶点到片元的插值减少片元着色器的计算量。慎用分支和循环GPU是并行架构分支if-else和循环可能导致线程束内的线程执行不同路径严重降低效率。尽量用数学函数如saturate,step,lerp替代简单分支。利用Shader变体Variants和多重编译指令使用#pragma multi_compile或shader_feature来为不同情况如是否有阴影、是否启用雾效编译不同版本的Shader代码避免在运行时用if判断。5.2 Shader调试方法论当Shader效果不符合预期时系统性的调试至关重要。使用Frame DebuggerUnity的Frame Debugger窗口-分析-Frame Debugger是神器。它可以暂停游戏逐帧、逐个Draw Call查看渲染状态包括当前激活的Shader、渲染目标、纹理、混合状态等。你可以清晰地看到你的Shader是在哪个Pass被调用输入数据是否正确。颜色调试法这是最直观的方法。在片元着色器中将你怀疑有问题的中间变量直接作为颜色输出。return float4(worldNormal * 0.5 0.5, 1.0); // 可视化法线从[-1,1]映射到[0,1] return float4(i.uv, 0, 1.0); // 可视化UV return float4(diff, diff, diff, 1.0); // 可视化光照强度通过观察屏幕颜色可以快速定位问题是出在数据输入如UV错误、法线错误还是计算过程。检查输入数据确保Properties中的变量名与CG代码中声明的完全一致。检查模型网格是否包含了Shader所需的顶点属性如法线、切线。可以在顶点着色器开头将appdata的内容输出到颜色检查是否接收到数据。理解插值从顶点着色器到片元着色器v2f中除了SV_POSITION其他所有变量都会在三角形的三个顶点之间进行线性插值。如果你的效果在三角形内部出现非预期的渐变可能是插值前在顶点着色器中没有将向量归一化normalize。通常法线、视线方向等需要在片元着色器中重新归一化。平台差异某些函数或语义在OpenGL ESiOS/Android和DirectXPC上行为略有不同。例如纹理坐标的V方向可能相反。使用Unity内置的宏如UNITY_UV_STARTS_AT_TOP和函数如UnpackNormal可以最大程度地避免跨平台问题。6. 从Built-in到URP现代渲染管线的迁移思考随着Unity版本更新内置渲染管线Built-in正逐渐被可编程渲染管线SRP尤其是通用渲染管线URP和高清渲染管线HDRP所取代。对于新手从Built-in入门概念是很好的选择但了解URP的差异是必要的。核心差异Shader编写框架URP使用ShaderGraph可视化或HLSL代码但代码结构不同。URP Shader不直接使用ShaderLab的Properties和SubShader而是使用HLSLINCLUDE和Pass并且属性通过CBUFFER声明。光照模型Built-in是前向渲染光照计算在Shader内完成。URP是单Pass前向渲染很多光照信息如主光源、附加光源通过Lighting.hlsl等库函数提供需要包含不同的头文件和函数。坐标系与内置变量很多Built-in的内置变量和函数如_WorldSpaceLightPos0,UNITY_MATRIX_MVP在URP中已废弃或改名。URP使用TransformObjectToHClip、GetVertexPositionInputs等新的函数体系。迁移建议概念先行在Built-in下扎实理解顶点/片元着色器、坐标空间变换、光照模型等核心概念。这些知识在URP中完全通用。学习ShaderGraph对于不追求极致性能或复杂控制的效果URP的ShaderGraph是快速原型制作的强大工具它直观地展示了节点化的着色器逻辑有助于理解数据流。阅读URP Lit Shader源码这是最好的学习资料。在Unity安装目录或Package Manager中导入URP后找到Lit.shader研究其结构、属性和HLSL代码是掌握URP Shader编写方式的最快途径。逐步转换迁移时从一个最简单的、只输出颜色的Shader开始逐步添加纹理、法线、光照。重点关注如何包含正确的头文件如Packages/com.unity.render-pipelines.universal/ShaderLibrary/…和使用新的变换函数。从ShaderLab到顶点/片元着色器的学习是理解实时图形渲染的基石。这个过程可能会充满挑战但每一次成功实现一个自定义效果所带来的成就感以及由此带来的对引擎渲染黑盒的掌控感都是无可替代的。不要试图一次性掌握所有高级特性从修改一个颜色开始到叠加一张纹理再到实现简单的光照每一步都亲手实践并理解其背后的原理你的图形编程能力就会在这个过程中稳步建立起来。当你能独立调试一个渲染Bug或为项目创造出一个独特的视觉风格时你就会发现之前所有的投入都是值得的。