如果你刚开始做Unity渲染,看到“Shader”这个词应该不会太陌生:美术那边每天都在调材质,程序这边偶尔要处理“模型变粉了”的事故,朋友圈里也有人放出风格化水面、卡通描边之类的截图。但真正自己去写一个Shader的时候,很多人第一反应是“这东西到底是干嘛的?怎么跟C#完全不是一回事?”
这篇文章就是来把这层窗户纸捅破的。我会用做项目时的实际视角,把Unity Shader涉及的基础概念完整捋一遍:渲染管线里GPU做了什么、ShaderLab这个“壳子”怎么组织、顶点和片元着色器分别承担什么、光照计算从哪来、透明队列为什么搞错就穿帮。内容适合两类人:一类是刚开始学渲染、被教程里各种词汇绕晕的开发者;另一类是美术或策划出身、想知道“我应该关注Shader的哪些部分”的人。看完以后,你至少能看懂一个基础Shader的每一行在干什么,也知道碰到报错和异常效果时该去哪里排查。
1. 先搞清楚:Unity Shader到底是什么东西
1.1 渲染管线:GPU到底在干什么
很多人把Shader当成“一段画颜色的代码”,这个理解不算错,但太窄了。Shader本质上是渲染管线里的一个可编程阶段,而渲染管线是一整套“把三维物体变成屏幕像素”的流程。
我们简化一下这条流水线:首先是应用阶段,CPU把模型顶点、法线、UV、相机参数这些数据准备好,交给GPU;然后是几何阶段,GPU拿到顶点数据,在顶点着色器里做坐标变换,把物体从模型空间一步步挪到裁剪空间,再经过裁剪、屏幕映射,屏幕上就有了“哪些区域属于这个三角形”;最后是光栅化阶段,三角形被填成像素,然后在片元着色器里决定每个像素最终的颜色。
这里面最容易被忽略的一点是:流水线是并行的,同一时间可能有一万个片元在跑你的Shader代码。所以Shader里的“性能开销”和普通C#不同,任何一句复杂的循环或数学函数,都可能被放大一万倍甚至更高。这也是后面反复强调“能少算就少算”的原因。
注意:顶点着色器运行在几何阶段,片元着色器运行在光栅化之后。教程里经常说“Fragment Shader逐像素计算”,严格来说应该是逐片元,但在Unity里不深入底层的话,你可以暂时理解成“每个可见像素执行一次”。
1.2 为什么不是直接写OpenGL,而是用Shader
有图形学基础的人可能会问:为什么不直接在Unity里写GLSL或者HLSL?为什么不直接用Graphics API管整个管线?
答案是:Unity帮我们屏蔽掉了平台差异。同一个Shader,你要跑在PC、手机、主机上,底层可能是OpenGL、DirectX、Vulkan或Metal。如果每个人都直接写GLSL,那安卓一套、iOS一套、Windows一套,维护成本直接起飞。Unity的做法是提供一套ShaderLab,然后在这套语言里嵌入CG/HLSL代码,由引擎在编译时翻译成对应平台的着色器语言。
所以从概念上可以这么理解:
| 层级 | 作用 | 例子 |
|---|---|---|
| ShaderLab | Unity自己的组织框架,负责属性、渲染状态、Pass、Fallback | Shader "Custom/MyShader" { Properties { ... } SubShader { ... } } |
| CG/HLSL | 真正的着色器代码,写顶点和片元逻辑 | float4 vert(...)、fixed4 frag(...) |
| 平台语言 | 最终给GPU跑的代码,由Unity编译生成 | GLSL、HLSL、MSL等 |
咱们平时在Unity里写的.shader文件,最外面那层花括号结构是ShaderLab,里面被CGPROGRAM...ENDCG包住的才是CG/HLSL。这个概念搞清楚了,别人发你一段Shader代码,你至少知道先看哪一层。
2. Shader的三种写法,怎么选
2.1 Surface Shader:美术友好的默认选项
Unity里最老牌也最容易上手的写法是Surface Shader。它出现的时候,Unity主攻的是“让美术也能写材质效果”这个方向。Surface Shader最大的特点是:你只需要描述“表面长什么样”,光照、阴影这些交给Unity去生成。
一个最简Surface Shader长这样:
Shader "Custom/SimpleSurface" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" }你用#pragma surface告诉Unity,我定义的是suf这个函数,光照模型用Lambert。光照模型这个词听起来吓人,其实就是在问你一个问题:这个表面的反光应该按照什么规则来计算。Lambert是比较基础的漫反射,Diffuse也用得多。如果你是做室内展示、建筑漫游这类需要快速出效果的项目,Surface Shader往往是性价比最高的,它生成的代码比手写要稳,而且老项目里大量资源都是这么写的。
但我必须说清楚Surface Shader的边界:它内部还是要走顶点和片元,只是Unity帮你生成了一大段Cg代码。你一旦需要多Pass渲染(比如描边、贴花)、需要自定义深度操作、或者要精确控制渲染顺序,Surface Shader就不够直接了。另外Unity未来主推的方向是Shader Graph,Surface Shader算是过渡产物,学习它主要为了读旧项目。
2.2 Vertex/Fragment Shader:最能掌控性能的写法
如果你已经不想被Unity的自动生成绑架,或者要做的效果本身就是非物理的、风格化的,那就直接写Vertex/Fragment Shader。
这两个着色器一个管顶点,一个管管片元(像素),你的所有想法都必须拆成“在顶点上做什么”和“在像素上做什么”。比如顶点动画:
v2f vert(appdata v) { v2f o; float4 worldPos = mul(unity_ObjectToWorld, v.vertex); worldPos.y += sin(_Time.y * _Speed + worldPos.x) * _Amount; o.vertex = mul(UNITY_MATRIX_VP, worldPos); o.uv = v.uv; return o; }这样做的好处是,你能精确知道每个阶段做了什么,也能避免Surface Shader里自动生成的那些不必要的统一计算。比如很多场景下你根本不需要雾效,不想要接收阴影的代码,手写可以全部砍掉。性能敏感的项目,比如弱机Android上的战斗场景,手写Vertex/Fragment通常是最终归宿。
坏处也明显:你需要自己处理光照、雾、阴影、贴图变换这些基础能力。因此新手如果一上来就写Vertex/Fragment,很容易在“模型的暗面死黑”“阴影不显示”这种事上卡很久。我的建议是先用Surface Shader跑通概念,再一个个替换成手写实现,每次只替换一个功能。
2.3 固定管线与Shader Graph:被遗忘的和被热捧的
固定管线Shader在旧版本Unity里叫“无CG/HLSL代码的Shader”,直接在Pass里用SetTexture之类的命令控制纹理混合。Unity 5.0之后已经基本不推荐使用,很多指令在新版本中被移除。如果你是刚学,我建议直接跳过,除非你在维护远古项目。
Shader Graph则是Unity 2018之后主推的可视化节点方案。它适合美术做材质迭代,节点图比代码更直观。但节点图背后翻译出来的还是顶点/片元着色器,所以本文所有的概念,你搞清楚了再去拖节点,效率会高得多。否则你会发现,节点连上了,颜色不对,你都不知道问题出在哪个环节。
3. 把第一个Shader拆开看(关键实操)
3.1 ShaderLab的基本骨架
刚才提到Shader文件最外层是ShaderLab。一个完整的Shader文件结构是这样的:
Shader "Custom/Basic" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 Pass { Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float4 _MainTex_ST; 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 } } FallBack "Diffuse" }Properties块的作用是在材质面板上暴露参数,方便你在Inspector里拖贴图、调颜色。注意这里只是声明,真正在CG代码里要再声明一遍同名变量才能用。很多新手漏掉CG里的那行sampler2D _MainTex;,结果材质面板有字段,但代码里编译报错或一直白色。
SubShader是给不同档次设备准备的渲染方案。Unity会从上往下找第一个能跑起来的SubShader,如果全都跑不了,就用FallBack指定的简化Shader。这个机制在PC上不明显,但在移动端很重要:高端机上跑了完整特效,低端机自动降级成简单版本,不至于全屏粉。
Pass是一趟独立的渲染。一个Shader可以包含多个Pass,比如物件先渲染一遍本体,再渲染一遍描边。每个Pass之间互相独立,有各自的顶点和片元函数。
3.2 顶点着色器在干嘛
顶点着色器负责处理顶点属性:位置、法线、切线、UV、顶点色。最常见的操作是把模型顶点从模型空间变换到裁剪空间,也就是让GPU知道这个点在屏幕的哪个位置。
UnityShader里你会看到这一行:
o.vertex = UnityObjectToClipPos(v.vertex);它的作用等价于“先乘model矩阵,再乘view矩阵,再乘projection矩阵”,Unity把它封装成了内置函数。新手写手写Shader时这个函数几乎比用不可少。
顶点着色器的粒度是“每个顶点执行一次”。一个网格几千个顶点,就会执行几千次。所以如果要对顶点做动画,比如草随风摇摆、水波顶点起伏,都要放这里。片元里的效果跟顶点数量无关,跟屏幕像素有关,这个边界要清楚。
顶点数据的语义(Semantic)也很重要。代码里POSITION、TEXCOORD0这些后缀并不是摆设,它们告诉GPU“这个float4是位置”“这个float2是第0组UV”。如果你把UV写到了POSITION上,图形不会报错,但形状会乱掉。这类错误尤其隐蔽。
3.3 片元着色器在干嘛
片元着色器负责决定每个像素最终显示的颜色。光栅化阶段会为每个三角形生成一片片元,然后进入片元着色器。
我经常跟新人说一句话:片元着色器是一个跟屏幕分辨率挂钩的“超并发循环体”,屏幕上几百个像素,你的frag函数就要跑几百次。所以这里不要写这种代码:
fixed4 frag (v2f i) : SV_Target { for (int k = 0; k < 100; k++) { // 千万不要这样写 } return fixed4(1,1,1,1); }图形学里的循环通常都会有替代方案,要么用采样,要么用数学表达式。比如做模糊效果,一般是多次采样的纹理合,但次数也要控制。在移动端,片元着色器的计算量往往是整个渲染流程的瓶颈。
片元输出的语义是SV_Target,它表示这个值会作为渲染目标(通常是屏幕)的颜色。如果你看到函数签名里的: SV_Target,那就是“我要输出颜色了”。
3.4 属性到底怎么写
Properties里每行定义的格式是“名字(类型)= 默认值”。常用类型有:
| 类型 | 写法示例 | 说明 |
|---|---|---|
| Color | _Color ("颜色", Color) = (1,1,1,1) | RGBA,范围0-1 |
| Vector | _Direction ("方向", Vector) = (1,0,0,0) | 四分量 |
| Range | _Gloss ("光滑度", Range(0,1)) = 0.5 | 带滑杆的浮点 |
| Float | _Strength ("强度", Float) = 1 | 浮点数 |
| 2D | _MainTex ("贴图", 2D) = "white" {} | 2D纹理,默认白色 |
| Cube | _Cube ("环境贴图", Cube) = "" {} | 立方体纹理 |
名字前的下划线不要省略,这是Unity规范,避免跟内置变量冲突。即使Material面板显示的是英文括号里的内容,Shader内部引用时用的仍是下划线开头的变量名。
还有一个坑:Properties里写Range(0,1),但CG代码里如果声明成float没问题;如果你声明成int,滑杆会突然像跳档一样。声明类型尽量与Properties一致,或用fixed处理颜色。
4. 光照、透明、UV:基础概念里的高频题
4.1 模型为什么有明暗:光照模型基础
一个模型如果直接返回固定颜色,看起来就是个平面剪影。要产生立体感,需要计算光照。传统的前向渲染中,一个顶点/像素的最终颜色可以拆成几部分:环境光(Ambient)、漫反射(Diffuse)、高光(Specular)。最经典的Lambert漫反射公式是:
Cdiffuse = Clight * Calbedo * max(0, dot(N, L))其中N是法线方向,L是光方向。dot(N, L)表示法线和光线方向的接近程度。如果光线垂直照到表面,dot接近1,表面最亮;如果光线平行擦过表面,dot接近0,表面变暗;当光线在背面时,dot为负,用max(0, ...)截断,就形成了暗面。
这个公式不用背,但要理解为什么写max(0, dot(N, L))。
高光部分通常用Blinn-Phong模型,用半程向量H来计算:
H = normalize(L + V); spec = pow(max(0, dot(N, H)), gloss);这里的gloss是光滑度,值越大高光越小越锐利。你可以在Shader里把Glossiness从0调大,看高光缩成一个亮点的过程,这比空看公式直观得多。
提示:Unity内置了很多函数,像
UnityObjectToWorldNormal可以把法线从模型空间转到世界空间,Unity_WorldSpaceLightDir可以获得世界空间中的光方向。新手先学会组合这些内置函数,比从零手写要稳。
4.2 半透明效果:RenderType与Queue的关系
Shader里的Tags是给渲染管线打的标签。最常见的两个是Queue和RenderType。
Queue控制渲染顺序。不透明物体的Queue是Geometry(大概2000),透明物体的Queue是Transparent(大概3000)。GPU默认画家算法一样,先画远处的,再画近处的,通过深度测试决定遮挡。但如果一个玻璃杯和一个半透明烟雾都要渲染,顺序错了,背景就不会被正确混合。
所以写透明Shader时,至少要有这两个Tag:
Tags { "Queue"="Transparent" "RenderType"="Transparent" }然后还要在Pass里开启混合:
Blend SrcAlpha OneMinusSrcAlpha代码里还要关闭深度写入,也就是:
ZWrite Off否则透明物体自己会挡住自己,背面看起来像出现“网格漏洞”。
RenderType本身不改变渲染行为,它主要给Unity的后期处理或者相机深度纹理做分类标识。比如做描边时,你可能希望只对RenderType=Opaque的物体描边,不希望对草和头发描边,这时就可以按RenderType来筛选。
4.3 UV坐标:贴图采样前必须想明白的事
UV坐标是纹理与模型表面之间的映射关系。它的范围通常是0到1,u朝右,v朝上。你在Shader里采样贴图,最常见的是:
fixed4 col = tex2D(_MainTex, i.uv);这里tex2D是纹理采样函数,i.uv是从顶点着色器传过来的UV。
UV很容易被忽略的一个地方是Tiling和Offset。材质面板上每张贴图都有Tiling和Offset两个参数,它们对应Shader里的一对ST变量。比如_MainTex_ST由Unity自动生成,TRANSFORM_TEX(v.uv, _MainTex)会把Tiling和Offset计算进UV里。如果你忘了用TRANSFORM_TEX,那么材质面板上拖Tiling参数不会有任何反应。
o.uv = TRANSFORM_TEX(v.uv, _MainTex);这一点在老教程中反复出现,因为你复制别人Shader时很容易把这句话漏掉。
5. 新手排错指南
5.1 遇到粉红色材质
粉红色是Unity的“Shader编译失败”标识。它不会像C#那样直接弹个窗口告诉你行号,但Console窗口会输出编译错误。原因基本是以下几种:
- 语法错误,比如漏了分号或括号;
- 用了不存在的函数或变量;
- Pass内部没有
CGPROGRAM...ENDCG; - 声明的Properties属性和CG代码里的变量类型不一致。
排查时先看Console窗口的具体报错,双击错误会跳转到Shader文件对应行。如果报错信息很抽象,把Shader片段注释掉一半再试,二分定位。
5.2 属性值不生效
常见原因是CG代码里没有重新声明同名属性,或者声明类型不对。比如Properties里是Float,CG里写成fixed,Inspector可能不显示异常,但改动后数值会被截断或错乱。
还有一种情况是Shader被多个材质共用,你在一个Material上改了参数,另一个Material也变样了。这本质上是Shader参数的传递问题,每个材质会自己存一份属性值,但如果脚本里用Shader.SetGlobalFloat写了全局值,会覆盖所有材质的同名属性。这种隐藏的全局变量拦截问题,排查时要多看代码里有没有SetGlobal系列的调用。
5.3 模型全黑或颜色不对
模型全黑通常是因为光照计算没做对。有可能法线变换错误,比如忘了用unity_WorldToObject的转置矩阵处理法线;有可能你的Shader根本没有设置LightMode,导致Unity里的光照数据没传进来。
Tags { "LightMode"="ForwardBase" }如果你在写Vertex/Fragment Shader且需要逐像素光照,这个Tag几乎是必需的。否则你调用的内置光照变量可能是零。
颜色不对则要先看颜色空间。Unity的Color Space如果是Linear,采样出来的贴图会经过sRGB转换,你做某些数学运算和混合时,结果可能会跟Gamma空间不一样。如果发现同一个Shader在PC和手机颜色有差异,先从Player Settings里的Color Space找原因。
5.4 透明排序乱
透明物体渲染顺序错乱是最常见的视觉问题之一。原因在于:透明物体之间还存在前后关系,但Pipeline按Queue排序时,只保证透明在所有不透明之后,不保证透明物体之间的局部顺序。两个半透明面如果交叉,后写的会覆盖先写的,看起来就像闪烁或穿帮。
解法没有完美方案。实际项目中通常按距离对材质排序,或者把分开的面改成在同一Mesh里整体提交,或者干脆避免半透明物体互相穿插。如果你只是做一个X-Ray或者软粒子效果,记得对透明物体设置ZWrite Off,至少能让裁切边缘不那么生硬。
我个人在实际排查中习惯先做“减法”:把片元函数的结果直接返回成纯色,如果颜色对了,问题就在采样和数学运算;如果颜色不对,问题就在数据传入或语义上。Shader调试不像C#可以打点断点,最常见的调试方式就是用fixed4(...)临时输出一个变量到屏幕上,比如把UV输出成颜色,看看UV是否正常展开:
return fixed4(i.uv, 0, 1);如果你看到模型上出现的是正常的红绿渐变,说明UV没问题;如果看到奇怪的条带,说明UV数据可能被破坏。这种做法很土,但真比对着代码发呆高效得多。
Shader本身是个熟能生巧的领域,概念确实多,但核心脉络很清晰:先理解渲染管线的两个可编程阶段,再学会读ShaderLab的组织结构,最后通过不断手写和调试把光照、贴图、透明度这些基础模块吃透。刚开始不要贪多,把一个最简单的不透明Shader彻底搞明白,比收藏一百份炫酷代码有用得多。