我第一次认真啃Unity Shader基础概念,是因为一个很丢人的场面:在一个个人Demo里,我把透明水面的混合模式换了好几种,物体却还是一会儿出现在水前、一会儿藏到水后。后来发现根本不是参数不对,而是我没有搞懂渲染队列和深度写入这两件事。那次之后我才明白,Shader基础概念看起来只是几个名词,其实它是"模型到图像的完整规则"。
这篇内容不打算带你抄一个酷炫的溶解或者扭曲效果。我想把新人最需要的那几个概念讲透:渲染管线里Shader站在哪一层、一个Shader文件里每个块在干嘛、参数怎么从Inspector和C#塞进GPU、颜色光照透明这些视觉结果背后的数学,还有出错时怎么一步步定位。我会以内置渲染管线为例,但其中很多核心概念在URP、HDRP里依然成立。适合刚接触Unity、看到Shader就发怵的入门者,也适合那些抄过几十个效果、却始终觉得没真正理解的人。
1. 渲染管线到底做了什么:Shader不是魔法,只是流水线里的一环
1.1 从网格到像素,中间有四段路
很多人第一次打开Shader,脑子里想的是"给网格涂颜色"。这个理解不能说错,但会把后面所有概念都带偏。GPU并不会直接对着模型上色,而是经历一条非常固定的流水线:CPU先把模型网格、材质参数、灯光和相机信息整理好,提交给GPU;GPU拿到顶点后,先逐顶点运行顶点着色器,把顶点从模型自身的坐标系转换到裁剪空间;然后做图元装配,把顶点连成三角形;接着光栅化,把三角形离散成屏幕上的片元;最后逐片元运行片元着色器,得到每个像素该显示的颜色,再经过深度测试、混合,写进帧缓冲。
这条流水线就是常说的渲染管线。我习惯把它类比成厨房出餐:网格数据是生鲜食材,材质参数是调味料,Shader就是菜谱,管线是整个厨房流程。菜谱写得再好,厨房流程不配合,摆盘也会乱;反过来,菜谱只写"放盐",而不告诉你盐是先放还是后放,照样做出黑暗料理。所以学Shader基础概念时,不能只看代码,还要知道你写的那段程序正站在流水线的哪个工位上。
这里有个很重要的结论:顶点着色器和片元着色器不是"自己写一段就能随便跑"的代码,它们各有严格的输入输出约定。顶点着色器必须输出裁剪空间坐标,这个坐标会被光栅化阶段拿去决定三角形覆盖了哪些屏幕区域;片元着色器必须输出颜色值,这个颜色会继续参与后续的深度测试和混合。基础阶段不理解管线,看到别人把POSITION语义改成NORMAL,顶多觉得奇怪;但一旦遇到黑屏、错位、穿插,就完全没法定位问题。
1.2 顶点着色器与片元着色器的分工
顶点着色器的特点是"每个顶点跑一次",片元着色器是"每个片元跑一次"。顶点数通常比像素数少几个数量级,所以同样的计算放在顶点着色器里便宜很多,但代价是精度不够。顶点之间的数据会被插值,逐像素光照才是细腻的。入门者最需要记住的分工是:顶点着色器做空间变换、顶点动画、传UV、传法线;片元着色器做光照、采样、颜色混合这类需要逐像素细节的操作。
拿"被灯光照亮的材质"举例:法线必须传递给片元着色器,因为法线方向在像素之间是连续变化的(法线原本是逐顶点数据,插值到像素后还要归一化),如果错误地在顶点着色器里采样纹理或算光照,高光边缘就会出现明显的锯齿。这就是"顶点域"和"像素域"的区别,也是后面所有光照公式要不要放在哪一段的基础判断依据。
1.3 空间变换:模型空间、世界空间、观察空间、裁剪空间
Shader里最常见的矩阵乘法就是空间变换。Unity提供的UnityObjectToClipPos函数把顶点从模型空间直接送到裁剪空间,内部包裹了模型矩阵、观察矩阵和投影矩阵的组合。为什么不能直接在模型空间里画?因为每个物体有自己的原点,灯光在世界场景里,相机又有自己的位置和朝向,各说各话永远对不齐。先通过世界矩阵把所有模型放进同一场景,再通过观察矩阵把整个场景转到相机视角,最后通过投影矩阵把可见范围压成一个裁剪立方体,GPU只保留落在立方体里的三角形。
基础学习阶段不需要自己推导矩阵,但必须知道"裁剪空间"为什么存在。顶点只有转进裁剪空间,GPU才知道三角形会不会在屏幕外,以及距离相机太近或太远的物体该不该被裁掉。以后做URP或者写自定义效果,你会发现所有的变换最终都要绕回这套坐标系,绕不开。
2. 一个Shader文件的结构拆解:从ShaderLab到CGPROGRAM
2.1 ShaderLab外壳:Properties、SubShader、Fallback
Shader文件前端是ShaderLab语言,你可以把它理解成一个"房间清单"。最外层的大括号里有三件大事:Properties块、一个或多个SubShader块、Fallback。Properties块负责把参数暴露到材质Inspector面板上,方便美术调整。注意,Properties只是一层编辑器接口,它不会自动把变量送进GPU;你必须在下面的CGPROGRAM里写同名变量声明,数据才会真正进入着色程序。新手最常见的报错"undeclared identifier _MainTex",九成是只写了Properties,忘了在CG里声明sampler2D _MainTex。
SubShader块是实际干活的代码块。Unity会从第一个SubShader开始检测当前GPU能不能执行,不行就跳下一个,再不行就走Fallback。所以多写几个SubShader,本质上是给不同性能档位的设备准备不同版本。Pass则是一次完整渲染流程,也就是"用这组状态和着色器把模型画一遍"。基础渲染一个不透明物体通常一个Pass就够了,但绘制描边、透明、特殊贴图时会有多个Pass,比如第一个Pass画背面外扩,第二个Pass画正面主体。
看一个最简单的Shader骨架就能明白这些块的位置:
Shader "Custom/FirstShader" { Properties { _Color ("Main Color", Color) = (1,1,1,1) _MainTex ("Main Texture", 2D) = "white" {} } SubShader { Tags { "Queue"="Geometry" "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" fixed4 _Color; sampler2D _MainTex; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; return col; } ENDCG } } Fallback "Diffuse" }这个Shader做的事情很简单:把模型顶点送到裁剪空间,把UV传给片元,然后在片元阶段采样纹理并乘上一个颜色。但它包含了完整的文件结构,也是理解更复杂效果的地基。
2.2 Tags:告诉引擎"我是什么类型的物体"
SubShader和Pass都能写Tags。最常用的两个Tag是Queue和RenderType。Queue决定这个物体在渲染队列里的位置,RenderType决定这个物体能不能被某些后处理或深度重构捕获。初学者常把Queue和RenderType混在一起,以为都是排序。其实Queue直接影响绘制顺序,RenderType更像是给场景管理打标签。后处理(比如景深、边缘光)需要知道哪些物体是透明、哪些是不透明,就靠RenderType区分。
基础阶段先记住,透明材质至少需要Tags {"Queue"="Transparent" "RenderType"="Transparent"},不透明材质通常用Queue"="Geometry" "RenderType"="Opaque"。写对了能少踩一半透明排序的坑。后面我还会专门讲透明的问题,这里只需要建立"Shader外壳的标签会参与引擎级状态管理"这个认知。
2.3 三种Shader写法的选择:表面、顶点/片元、固定函数
要说清Unity的Shader基础概念,绕不开三种写法:表面着色器、顶点/片元着色器、固定函数着色器。表面着色器是Unity内置管线的特化写法,它帮你把光照、阴影、前向渲染路径的很多细节自动生成出来,你只需要填一个surf函数输入标准数据、输出表面属性。优点是上手快、光照短路自然;缺点是生成代码量大、可控性低,遇到很特殊的渲染需求会被框架绑住。
顶点/片元着色器是最基础、最直接的写法,所有光照公式自己算,所有渲染状态自己设。性能最好掌控,思路也最接近GPU本来的样子。固定函数着色器是老历史了,现在基本别碰,只会在老项目里见到。我整理了一张对照表,方便你选型时快速判断:
| 写法 | 上手难度 | 可控性 | 典型场景 |
|---|---|---|---|
| 表面着色器 | 低 | 中 | 快速做受光材质、标准光照下的物体 |
| 顶点/片元着色器 | 中 | 高 | 特效、后处理、自定义光照、UI |
| 固定函数着色器 | 低 | 极低 | 老项目兼容,新项目不建议 |
我在项目里的选择习惯是:不想花时间折腾光照细节、希望快速得到一个自然受光的材质,用表面着色器;需要做扭曲、溶解、描边、序列帧动画、UI特效、后处理,一律用顶点/片元,自己控制每一步。URP推出以后,更多人会直接选Shader Graph拖节点或者写HLSL,但底层的空间变换、法线点积、混合状态,其实还是这一套基础。
3. 参数是怎么从编辑器一路送进GPU的
3.1 Properties里的类型和CG变量的对照关系
Properties里的每一项都有自己的类型,对应到CG里也要写成匹配的变量类型。我列几个最常见的:
_Color (Color)->float4 _Color_MainTex (2D)->sampler2D _MainTex,并且自动多一个float4 _MainTex_ST,四个分量分别是Tiling的xy和Offset的xy_Glossiness (Range(0,1))->float _Glossiness_Hue (Float)->float _Hue_Direction (Vector)->float4 _Direction
用个生活化的比喻:Property是"面板上的旋钮",CG声明是"机器内部接收旋钮信号的接口"。两者名字必须一样,类型也得对得上,否则要么报错,要么值永远传不进来。这里最容易被忽略的是纹理的_ST变量。只要你在Properties里写了2D贴图,并且需要做Tiling/Offset,就要在代码里声明对应的float4 _MainTex_ST,然后用TRANSFORM_TEX(i.uv, _MainTex)把平铺偏移应用进去。
3.2 C#动态赋值和PropertyToID
除了在Inspector里手调,更多时候需要在C#里中途改参数。标准做法是material.SetFloat("_Glossiness", 0.8f)、material.SetColor("_Color", Color.red)。但字符串查找在每帧很多物体时会有额外开销,更好的做法是用Shader.PropertyToID把字符串换成整数ID,缓存起来再调用:
private static readonly int GlossID = Shader.PropertyToID("_Glossiness"); void ChangeGloss(Material mat, float value) { mat.SetFloat(GlossID, value); }实际项目里我会在某个静态类里统一声明所有Shader属性ID,这样运行时既快又不容易拼错字符串。MaterialPropertyBlock也是好东西:多个物体共用同一份材质,但需要不同颜色或贴图参数时,比如一大堆颜色各异的发光小球,用同一个材质加PropertyBlock,比给每个小球创建一份材质高效得多。不过PropertyBlock在某些SRP Batcher场景下会有限制,基础阶段先记住这个工具,等以后优化时再深入了解。
3.3 Shader代码里最常见的"属性丢失"问题
属性传不进来,表现是颜色全白、纹理变灰,或者看起来跟面板数值完全没关系。排查顺序通常是:Properties和CG声明名字是否一致;C#代码里拼写的字符串对不对;该声明的_ST结构变量有没有漏掉;纹理有没有成功赋到材质上。
还有一个容易被忽略的坑:在图形API按变体编译时,如果属性在Shader里声明了但完全没使用,编译器可能会把它优化掉,这时候面板上虽然还显示参数,实际调试时却查不到值。总之第一步永远是"把当前参数临时写进片元颜色里看数值",这比盯着代码猜要高效得多。后面我会专门讲这种引变量进颜色的调试方法。
4. 光照为什么是数学:法线、点积、反射
4.1 NdotL:一个点积解释所有带光表面
漫反射光照模型非常朴素:表面越正对光源,看起来越亮;越侧向光源,亮度越低;背向光源就变黑。这个"正对程度"在数学上就是法线N和光线方向L的点积。点积结果等于两者夹角的余弦,范围从1(完全朝向光源)到-1(完全背对光源)。因此一个最简单的漫反射公式可以写成:
float ndotl = saturate(dot(normalDir, lightDir)); float3 diffuse = albedo * _LightColor0.rgb * ndotl;saturate把负数钳制到0,避免背光面出现负亮度变成奇怪的颜色。这个公式看起来简单,却解释了为什么法线贴图能影响光照:贴图只要改变了每个像素的法线方向,NdotL就会跟着变化,凹凸细节就出来了。很多新手以为法线贴图是"贴了一层砖缝颜色",不是的,它贴的是法线方向,真正改变了光在表面上的分布。
4.2 从Lambert到Blinn-Phong:高光并不是靠"更亮"
Lambert解决的是粗糙表面的漫反射。如果想让材质有一点镜面高光,就得引入半角向量H。Blinn-Phong模型用H = normalize(光线方向 + 视线方向),再计算N和H的点积,最后用pow控制高光范围。高光参数越大,pow会让高光范围越窄,看起来越像抛光表面;高光参数越小,高光越散,看起来越像磨砂。
见过不少初学者抄Blinn-Phong代码,却不知道为什么一定要normalize,也不知道为什么pow之后高光会变成一团。归一化是为了保证向量长度是1,点积结果才纯粹反映夹角;pow压缩亮度曲线,让接近1的角度被"高亮",离得远的角度快速衰减,于是形成集中的亮点。自己动手改一遍高光系数,看几次球面上的变化,比背十遍公式都管用。
4.3 真实项目里还需要环境光和更多光源
上面这些是"单一方向光"的理想情况。真实场景里还有环境光、阴影、多光源、反射探针,Unity内置变量里也会涉及_WorldSpaceLightPos0、_LightColor0之类。但学基础的时候,千万别一上来就追求PBR那套金属度、粗糙度、高光反射的完整公式。先把两件事理清:方向光凭什么照亮一个面;法线和视线如何决定高光的位置与强弱。这两件事搞清楚,再看Unity内置的标准着色器,至少能顺着参数找到对应的计算位置。
顺便给一个检查法线的实用方法:在片元里直接输出fixed4(normalDir * 0.5 + 0.5, 1),把范围在-1到1的法线映射成0到1的颜色。蓝偏Z轴、绿偏Y轴、红偏X轴。哪个物体出现大块异常颜色?用法线可视化一眼就能判断,比按空格猜位置强多了。
5. 透明混合和渲染队列:基础概念里最容易翻车的角落
5.1 Blend、ZWrite、ZTest分别管什么
透明和不透明最本质的区别:不透明物体可以直接覆盖后面的像素;半透明物体必须把当前颜色和已经画好的颜色混在一起。所以透明Shader要明确写混合模式:
Blend SrcAlpha OneMinusSrcAlpha这句的意思是:最终颜色 = 当前片元颜色 × 自己的Alpha + 缓冲里的颜色 × (1 - Alpha)。这就是最常见的水、玻璃、烟雾用的alpha混合。如果要加法混合或柔和光晕,可以用Blend One One,颜色相加会越来越亮。用途不同,混合公式完全不同。
但很多新手栽的跟头不在混合公式,而在ZWrite和ZTest。ZTest决定什么时候"有资格画"这个片元,ZWrite决定要不要往深度缓冲里写深度。半透明物体如果继续写深度,它后面的半透明片元会因为深度测试过不了而被丢掉,结果同一个透明球里层与层之间会互相裁剪,出现让人抓狂的"一半脸没了"的效果。因此透明材质普遍要ZWrite Off。但关掉ZWrite之后,不透明物体画完以后,多个透明物体之间就只能靠绘制顺序来决定谁盖谁,这是另一个大坑。
5.2 Queue=Transparent和Overlay为什么存在
渲染队列是一组预设数字:Background约1000,Geometry约2000,AlphaTest约2450,Transparent约3000,Overlay约4000。Unity会先绘制队列号小的物体,所以不透明物体几乎都会画在透明物体之前,天空盒又排在最前面。透明物体内部,则靠与相机的距离排序。写过一段时间Unity后你会发现,大部分半透明穿插问题都源自"距离排序"这件并不完美的事情。
我看到过有人把Queue写成9999,想让某物体最后画,结果反而出现更奇怪的穿插。正确做法是:普通不透明用Geometry,UI或最高层叠加用Overlay,半透明材质用Transparent。不要把排序当成"想让谁在后面画就让他后面画",深度和队列是两个维度,混着调迟早出bug。多个半透明物体同时出现时,比较实用的工程方案是把它们合并材质、合并Mesh、共用贴图,或者调整到同一个渲染批次,让引擎按同一规则排序。
5.3 clip()裁剪边缘锯齿问题
半透明还有一种常见实现:用clip(alpha - cutoff)做Alpha Test,比如树叶、铁丝网、粒子。它在片元里把低于某个阈值的像素整个舍弃,好处是深度正常、半透明物体不会互相穿插;坏处是边缘会出现硬锯齿,因为没有透明度过渡。解决办法通常是把边缘做一圈透明度渐变,或者依赖MSAA、后处理抗锯齿。
基础阶段只要知道一点:clip是砍像素,不是混合,它和Blend是两条逻辑完全不同的路。选错的话,视觉表现往往一开始还能看,一旦物体多了、场景复杂了,问题就会集中爆发。
6. Shader出了问题怎么定位:调试习惯比技巧重要
6.1 最朴素的调试手段:把中间变量画出来
GPU没有断点,也不能Print。最简单粗暴的调试法,是把你想确认的中间值当作颜色返回。比如看UV:return fixed4(frac(i.uv), 0, 1);看法线:return fixed4(i.normal * 0.5 + 0.5, 1);看时间:return fixed4(frac(_Time.y * 0.5), 0, 0, 1),如果画面出现闪烁变化,说明时间变量生效了。
很多看起来玄学的问题,被这种直接方法逼问两三个变量就现形了。我会在一开始就把待调试Shader挂到球体或立方体上,然后从上到下逐段替换frag返回表达式,快速锁定是哪一步计算出了问题。这个方法不高级,但效率极高,特别是当你还不知道到底是UV错了、法线错了还是颜色空间错了的时候。
6.2 Frame Debugger和错误信息怎么读
Unity的Frame Debugger可以逐DrawCall查看。点击某条绘制命令,右侧会显示当前Shader、SubShader、Pass、队列、深度状态、混合状态等信息。比如你怀疑透明排序出错,可以在Frame Debugger里看某个物体的Queue和RenderQueue实际值,对比一下到底是材质设置没生效,还是排序本身把它排到了另一层。这是很多基础教程没提过的检查方法,但极其实用。
Shader报错经常带行号,比如"Program 'frag' at line 45: undeclared identifier"。拿到行号后不要马上删代码,先看一眼是不是函数签名、return路径、变量声明顺序的问题。最典型的是片元函数里写了一个if分支,某个分支忘了return,就会报"not all code paths return a value"。这种错误靠逐层注释也能很快找到。
6.3 最小复现场景和抄代码三件套
最后说一个工作习惯:永远准备一个只包含球体、立方体、平面和方向光的测试场景。当Shader效果怪的时候,把这个场景放上去,去掉阴影、去掉后处理、去掉雾效、关掉颜色空间疑点,再一项一项加东西。绝大多数Shader问题不是"代码错了",而是周围环境干扰太多,导致你误判。
抄网上的Shader代码时也一样。先放到隔离场景里跑,确认"不依赖任何特殊组件就能跑出效果",再挪进正式项目。这套流程看起来很笨,但能救你很多个晚上。尤其当别人提供的效果依赖了后处理、特定模型或特定灯光时,直接拖进正式场景几乎一定会翻车。
拿我自己来说,Unity Shader基础概念真正开始成为体系,不是在某一天看完某个教程之后,而是我被迫把法线、UV、顶点位置、混合状态都变成颜色和数字看了一遍之后。建议你也试着做一个"参数可视化"Shader:把UV放进R通道,把时间放进G通道,把法线放进B通道,拖到球上转着看。等你能一眼说出屏幕上某块颜色对应什么数据来源,那些教程里原本像咒语一样的公式,自然就变成解题思路了。以上这些基础虽然不算炫技,但以后不管是写UI特效、做半透明水面,还是接URP的Shader Graph,你都会发现,所有复杂效果最后都在跟这几个老概念反复打交道。