做Unity开发这件事,你迟早会遇到一个让人头皮发麻的时刻:材质好好的,模型突然变成亮粉色;或者同一个材质在手机上显示黑色,在电脑上却正常;又或者你想做个简单的半透明物体,怎么调都不对。这些问题的答案往往都指向同一个东西——Shader。作为“Unity基础”系列的第三篇,这篇就来聊聊Shader到底是什么,它凭什么能决定你屏幕上每一颗像素的颜色,以及作为一个普通开发者,你需要掌握哪些核心概念才能驾驭它。
这篇文章适合刚接触Unity不久、被术语劝退的新手,也适合那些已经在用第三方Shader插件、但一旦出了问题就完全不知道从哪里下手的开发者。我会用尽量直白的方式讲清楚Shader的原理,然后给出手写Shader的完整流程。看完不说让你成为图形大佬,至少以后再遇到粉色材质,你能心里有数:这是Shader出问题了。
Shader的中文翻译叫“着色器”,这个词很容易让人误会,以为它只管颜色。其实它管的是GPU上所有可编程的计算环节,从模型的顶点位置计算,到每个像素最终输出什么颜色,全在它的职权范围内。你可以把Shader理解为一份“给显卡的指令说明书”,CPU负责告诉GPU“你该渲染这个模型了”,而Shader则告诉GPU“这个模型的每一个顶点应该摆在哪里、每一个像素该用什么颜色”。
1. 从一个像素的诞生说起:Shader在渲染管线里的位置
1.1 屏幕上那几百万颗像素是怎么算出来的
先说个最简单的场景:你在Unity场景里放了一个Cube,相机对着它,点击Play,屏幕上出现了这个Cube。这个过程看起来瞬间完成,实际上GPU内部跑了一条非常完整的流水线,业内叫“渲染管线”。Shader就嵌在流水线最关键的两个环节里:顶点阶段和片元阶段。
想象一下你是一个雕塑家,要做一批一模一样的雕像。你得先找一批粗糙的石料,按照图纸把每个石料削成大致形状,再运到指定位置摆好;然后才轮到油漆工上场,给每个雕像表面细致地刷上颜色、纹理、高光。顶点阶段就是“粗加工”,片元阶段就是“精装修”。顶点Shader处理的是模型的顶点数据,比如把模型从自己所在的坐标位置挪到世界坐标、再挪到摄像机视角坐标、最后投到屏幕上;片元Shader处理的是光栅化之后生成的每一个像素片元,决定每个像素最终显示什么颜色。
这里的“片元”(Fragment)你可以简单理解成“待定像素”。一个三角形经过光栅化之后,被转换成屏幕上的一系列小块,每一个小块就是一个片元。片元经过片元Shader计算后,如果通过了深度测试和模板测试,就会变成真正显示在屏幕上的像素。
1.2 CPU和GPU的分工:为什么Shader要跑在GPU上
很多人刚接触Shader时会不理解:“我直接用C#代码在Update里给每个像素赋值不就行了?”行倒是行,但效率会非常感人。一个1080p的画面有大概两百万颗像素,如果你用一个CPU核心逐颗计算,每帧要做两百万次完整的颜色计算,还要考虑光照、贴图、深度,CPU直接原地爆炸。
GPU之所以快,是因为它厉害在“人多”。GPU里面有上千个计算单元,可以同时处理几千个顶点和几万个片元。Shader就是为这种并行架构设计的:你写的Shader代码,不是在一个像素上跑一遍,而是在成千上万个顶点和像素上同时跑。用生活里的例子打比方,CPU是一个全能型选手,什么复杂任务都能做,但只有一个人,处理速度有上限;GPU是一群只会做基础操作的流水线工人,单个人能力一般,但架不住人多,几千人一起干活,效率极高。
所以Shader必须被打包成GPU能运行的指令,然后一次性提交给显卡,由显卡上千个核心并行执行。这也是为什么Shader不能在CPU上算——它不是不能算,而是算得慢到没法用。
1.3 固定管线与可编程管线:Shader解放了什么
在很早期的图形API时代,GPU的功能是固化死的。当时有两种现成的Shader,一个叫“顶点光照”,一个叫“像素填充”,开发者只能在固定选项里选来选去,想做水面的波纹、草地的起伏、卡通的描边效果,只能靠各种奇技淫巧硬凑,效果还非常粗糙。
后来显卡硬件演进了,出现了可编程管线,相当于GPU把一部分工作外包出来,给你一张白纸说:“这段计算你自己写”。Shader就是这张白纸上写下的代码。写完编译之后提交给GPU执行。从此,风格化渲染、次表面散射、体积云、程序化动画这些效果才真正成为可能。
Shader的出现本质上是把渲染的控制权从硬件厂商交到了开发者手里。当然,这也意味着你得多学一些东西。但你一旦理解了Shader的工作方式,就不会再把游戏画面当成一个黑盒来用,而是能看懂画面上每一处质感变化背后的原因。
2. 在Unity里写Shader:先看懂ShaderLab的基本结构
2.1 三种Shader写法,先分清再动手
在Unity引擎里,Shader文件的后缀是.shader,里面的主体语言叫ShaderLab,这是一种描述性语言,用来组织标签、属性、SubShader和Pass这些结构。而真正执行计算的代码块,用的是CG/HLSL语言。Unity把这一套整合在了一起,写下这些代码用的是CGPROGRAM和ENDCG包裹的区域。
你可能经常听到“表面着色器”(Surface Shader)、“顶点/片元着色器”(Vertex/Fragment Shader)、“固定管线着色器”这三个词。它们不是完全不同的语言,而是Unity提供的三种不同包装方式。
- 固定管线Shader:最老的一种,语法简单,但能力极其有限,只能在非常老的设备和教学场合看到,完全不建议新项目使用。
- 表面着色器:Unity特有的封装方式,让你不用自己写一大堆光照计算的样板代码,只要告诉Unity“我这个材质是带法线的、要不受光照影响”之类的配置,Unity会在背后帮你生成完整的光照计算。适合美术向的开发者,能快速出一版效果。
- 顶点/片元着色器:手写全套流程,可控性最高、性能上限也最高,是真正理解Shader底层必经的路。本文后面实操部分用的就是这个。
我的建议是:新手直接从顶点/片元着色器开始学习,因为表面着色器虽然省事,但它会藏起太多细节,出了问题你连错在哪一层都找不到。等把顶点/片元流程吃透了,再回头用表面着色器反而会觉得轻松很多。
2.2 ShaderLab文件的三层骨架
在Unity里新建一个Shader,默认代码长这样:
Shader "Custom/NewShader" { Properties { _Color ("Main Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; }; fixed4 _Color; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); return o; } fixed4 frag(v2f i) : SV_Target { return _Color; } ENDCG } } }别看代码少,里面藏了Shader的完整骨架。最外层是Shader "名称"{},名称会出现在材质面板的Shader下拉菜单里,建议用斜杠做层级,比如“Custom/MyShader”,这样在材质面板上就能按层级找到它。
Properties块里声明的是暴露在材质面板上的参数。比如上面代码里的_Color,类型是Color,默认值是白色(1,1,1,1)。你可以把Shader参数理解成“提供给美术同事使用的旋钮”。一个连了Shader的材质球,在Inspector面板上能看到这些旋钮,通过它们调节颜色、贴图、缩放、数值等,而不用进代码改。
SubShader块是Shader的核心主体。一个Shader可以包含多个SubShader,这是做硬件兼容用的:显卡若是吃不了第一套SubShader,就尝试下一套,直到找到能运行的为止。每一套SubShader内部由若干个Pass组成。一个Pass就是一次完整的渲染过程,相当于说“我需要GPU按这个方式画我一遍”。需要多遍渲染时(比如先画背面再画前面、先画轮廓再画本体),就写多个Pass。
硬件的实际渲染状态,比如深度测试开不开、用哪种混合方式、是否需要写入深度缓冲,都是在SubShader的Tags和Pass里配置的。Tags是键值对,告诉渲染管线这个物体属于哪个渲染队列、是否受光照影响这些信息。
2.3 CGPROGRAM里藏着的语义来龙去脉
新手看Shader代码经常懵在一件事上:struct里那些大写的POSITION、SV_POSITION、TEXCOORD0是啥?这是HLSL/CG的“语义”(Semantics),本质上是一种数据对接约定。它告诉GPU:“这个结构体成员的数据从哪个数据槽里取”,或者说“把这个成员的数据放到哪个输出寄存槽里”。
比如vertex定义为float4 vertex : POSITION,意思是把它绑定到顶点属性槽里的“位置”数据。Graphics API从模型的顶点缓冲区里取出位置数据,填入这个结构体。片元Shader的输出SV_Target则表示最终写入到渲染目标颜色缓冲区的颜色值。SV_前缀的语义属于系统语义,比如SV_POSITION是系统坐标位置,通常用来标记顶点Shader的输出位置,它会告诉光栅器“这是个齐次裁剪空间坐标”。
理解这一点很重要,因为这些语义决定了你的数据怎么在CPU、顶点Shader、光栅器、片元Shader之间流动。哪一步数据对应不上,最终画面就会出现“模型乱飞”或者“颜色全黑”这种怪毛病。
3. 手写一个带光照和纹理的Shader,实操全过程
3.1 准备材质和环境:拿到手里的工具
实操开始前,先在Unity工程里建好基础环境:一个模型(比如标准球体)、一个平行光、一个相机。然后在Project窗口点击Create -> Shader -> Standard Surface Shader创建新文件,再点击Create -> Material创建一个材质。把Shader拖到材质上,把材质拖到球体上。为了后面方便,建议把Shader文件命名成Custom/ColoredLight,这样材质面板里能找到自己的Shader,避免和内置的混在一起。
接下来我会把默认的表面着色器删掉,换成顶点/片元着色器,一步步加颜色、光照、纹理,以及一个半透明混合效果。整个过程中,代码会逐步变得完整。
3.2 从纯色到漫反射光照:让模型立体起来
先把刚才那个纯色Shader改一改,把它从“死板的单色”变成一个会受光影响的材质。漫反射(Diffuse)计算在图形学里用的是朗伯余弦定律:表面接收到的光照强度,跟光线方向和表面法线之间的夹角余弦成正比。换算成Shader代码就是取光线方向与法线方向的点积。
在Unity里,光照方向可以从_LightDir0(内置变量)拿到,它已经在世界空间里归一化好了。模型顶点的法线信息则从顶点输入结构体的NORMAL字段里拿。但要注意,模型在场景里会被缩放、旋转和平移,顶点的位置和法线方向都需要变换到世界空间再参与光照计算。
代码里我新建一个v2f结构体,用它来承载从顶点Shader到片元Shader的传递数据。顶点Shader的职责是:把模型顶点坐标变换到裁剪空间、把模型法线变换到世界空间、把UV坐标原样透传。片元Shader的职责是:采样纹理颜色、计算光照值、把两者相乘输出。
Shader "Custom/DiffuseTexture" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" #include "Lighting.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float2 uv : TEXCOORD1; }; v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag(v2f i) : SV_Target { fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb; float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float ndotl = saturate(dot(i.worldNormal, lightDir)); fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo; fixed3 diffuse = ndotl * albedo; return fixed4(ambient + diffuse, 1.0); } ENDCG } } }这里要解释几个新手容易卡住的地方:
首先是_WorldSpaceLightPos0这个变量。当场景里只有平行光时,它表示光照方向(因为平行光没有具体位置,只有方向)。这时用它的xyz分量直接当方向用是可以的。如果场景里有点光源、聚光灯这类有位置的光源,需要另外计算光源方向和顶点之间的距离衰减。
其次是UnityObjectToWorldNormal函数。模型空间里的法线,在模型经过缩放旋转之后,直接用顶点变换矩阵去变换可能会得到错误的方向,尤其在非等比缩放的情况下。Unity的这个函数内部做了正规化修正,把法线正确地转到世界空间。很多画面偏黑、高光位置不对的问题,都出在这里。
再次是TRANSFORM_TEX。它根据材质面板上Tiling和Offset值,把原始UV做一个缩放和平移,保证纹理能正确平铺和偏移。如果忘了这个宏,材质面板上调节纹理的Tiling和Offset将完全不生效。
最后是片元Shader里的光照计算。saturate是CG里的钳制函数,把运算结果约束在0到1之间,暗面不会出现负数的光。环境光项UNITY_LIGHTMODEL_AMBIENT是Unity根据环境设置注入的一个常量,让暗部不至于死黑。加上它之后,阴影面看起来会舒服很多。
3.3 实现半透明效果:用Queue和Blend说服渲染管线
做完不透明材质,下一个高频需求就是半透明,比如玻璃、能量罩、水波。在Shader里做半透明,核心是两个概念:渲染队列(Queue)和混合方式(Blend)。
Queue决定这个物体在什么时候被渲染。不透明物体全部按正常顺序渲染,但半透明物体需要“从远到近”排序:远的先画,近的后画,这样颜色混合才正确。Unity预定义了几个队列,Background最底,Geometry是不透明物体常用的,Transparent是半透明物体用的,Overlay最顶层。要让透明效果正确,你得在SubShader的Tags里写Tags {"Queue"="Transparent"},不能什么都不写。这就是我说的“透明物体不受控”的常见原因,材质确实能透明,但你忘了告诉引擎把它放进透明队列。
Blend则描述当前物体像素和后面已经画好的像素如何叠加。比如Blend SrcAlpha OneMinusSrcAlpha是标准的Alpha混合,意思是“新像素的RGB乘上自己的Alpha,加上背景像素的RGB乘上(1-Alpha)”。这样,透过玻璃看到的背景颜色会跟着玻璃的透明度线性变化。
同时还要注意,半透明物体通常不能向深度缓冲里写深度(ZWrite Off),否则后面再画的半透明物体就没办法正确叠到它前面了。但完全关闭深度写入也可能引发排序问题,这是图形开发里出了名的老大难,需要结合项目的实际情况微调RenderQueue和深度偏移参数。
SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Pass { CGPROGRAM // 顶点片元代码与上面相似 // 区别主要在片元函数的颜色输出上带上alpha值 fixed4 frag(v2f i) : SV_Target { fixed4 color = tex2D(_MainTex, i.uv) * _Color; color.rgb *= 0.8; // 举例而已,实际业务里可以改成其他算法 color.a = _Alpha; // 用一个材质参数控制整体透明度 return color; } ENDCG } }一个容易踩的坑是:你设置了Queue=Transparent,也开了混合,但物体还是“完全透明”或“完全不透明”。这往往是因为输出颜色的alpha值根本没被设置,或者设置了但贴图本身alpha通道是纯白。建议在调试透明效果时,先把alpha固定在0.5看效果,排除贴图因素的干扰之后再接贴图alpha。
3.4 理解坐标变换:顶点从模型空间走到屏幕的四步路
顶点Shader里最重要的一件事,是把模型的顶点坐标从“模型自己的局部坐标”一步步转换成屏幕坐标。这个流程没法绕开,所以我专门拿出来讲。
一个顶点要经历四个空间:模型空间(模型本地)、世界空间(场景全局)、观察空间(以相机为原点)、裁剪空间(齐次裁剪坐标系)。每个空间之间的转换靠矩阵完成,Unity提供了对应的内置函数:
- UnityObjectToClipPos:一步到位从模型空间到裁剪空间,这也是新手最常用的函数。
- UnityObjectToWorldPos:模型空间到世界空间,常用于计算离光源的距离。
- WorldSpaceViewDir:世界空间指向相机的方向,做视差、菲涅尔效果时常用。
- UnityObjectToWorldNormal:模型空间法线转世界空间法线。
裁剪空间之所以叫“裁剪”,是因为在这个空间里,GPU会判断顶点是否落在视锥体内,完全在视锥体之外的三角形会被切掉,跨界的会被裁出新的边。之后GPU再做透视除法,把齐次坐标的w分量除掉,得到标准设备坐标(NDC),最后映射到屏幕像素坐标。大部分新手不需要手动做最后两步,GPU会帮你处理,但你要知道顶点Shader输出SV_POSITION时,用的应该是裁剪空间坐标,不是世界坐标也不是模型坐标。这是初学者最常犯的坐标错误:把没转换过的模型坐标直接输出,结果模型要么消失,要么钉在原地跟着摄像机视角一起移动。
4. 那些年我踩过的Shader坑:常见问题排查速查
4.1 粉色材质:Shader编译失败的报警信号
Unity里材质变粉,是图形开发最著名的“死亡粉”。它本质上是Unity的Shader编译或绑定失败,渲染管线认为这个材质没有合法程序,干脆输出一个纯洋红色报警颜色告诉你“我画不了”。
造成粉色最常见的原因有几种:代码里使用了当前平台不支持的语法或函数;CGPROGRAM区块里遗漏了ENDCG;语义写错导致数据没传进来;或者在较新的Unity版本里使用了很多老旧的固定管线语法,直接被编译器拒了。出现粉色时,先看Console窗口的报错,它会具体指向哪一行代码或哪一个符号。我的习惯是先检查代码里的Semantics有没有写全,再检查Properties参数名和代码里的变量名是不是完全一致——大小写不同也会静默出错。
4.2 模型全黑、没有光照:光照到底算到哪去了
模型能显示,但黑得像块炭,通常问题不在Shader崩溃,而在光照计算。我排查的顺序是:先看法线对不对,再看光照方向是不是被归一化,再看法线和光线方向是不是在同一个坐标空间里。
如果你在顶点Shader里把一个法线用模型空间的矩阵变换,而在片元Shader里用世界空间的光照方向去跟它点积,算出来的光照值就完全没有意义。这种情况下,画面往往是明一块暗一块、或者整个模型只有一半有光,很多“莫名其妙黑一半”的模型都是这个原因。
另一个原因是忘了在片元Shader里加环境光。朗伯漫反射在法线垂直于光线的区域计算结果接近于0,如果环境光也是0,那一片区域就直接死黑了。这不是引擎的问题,而是物理计算里真实存在的“死黑区”,靠加环境光项解决。
4.3 半透明物体的排序地狱:为什么透明总在互相穿透
做半透明时,你很快会碰到一种情况:两个半透明物体叠在一起,后面那个却把前面那个给盖住了;或者透明物体跟镜面反射结合起来,各种穿透感拉满。这几乎是每个做半透明的人都绕不过去的坑。
根子在渲染顺序上。Unity拿到一个半透明队列的物体列表,会按它们距离相机的远近来排序,远的先画。但这是按物体的包围球中心来算的,对于大物体、弯曲物体或者互相穿插的模型,这个排序在像素级别上依然可能是错的。加上ZWrite Off之后,你本来就没往深度缓冲里写深度,后画的像素自然可以覆盖先画的像素。
我自己的处理经验是:如果项目里透明物体数量很少,效果能用就千万不要放大排序的修改力度;如果确实出现严重的穿插问题,考虑把半透明物体拆成多个平面、或者在后处理的深度遮罩里做文章,而不要指望光靠改RenderQueue能一劳永逸。另外,同一个SubShader内多个Pass之间的混合、同一材质不同部分的透明度排序,也要留意。曾经我就因为在同材质上先画了正面再画了背面,结果前半部分透明叠上去盖住自己另一侧的模型结构,调试了整整半天。
4.4 性能开销:为什么同一个Shader在不同手机上帧率天差地别
写Shader不能只图功能正确,还要考虑目标设备的性能。Shader是在GPU上千个核心上并行的,这一点我先前提过。但不是说代码随便写GPU都能扛得住。GPU的并行能力再强,也有几个明显怕的东西。
第一个是分支。如果Shader里写了if,并且这个分支条件在不同像素之间不统一,GPU就不能让整个渲染单元走同一条路径,只能逐个像素判断或者把两条路径全走一遍再选一个,这会造成吞吐量的暴跌。能避免的if尽量用数学函数替代,比如用step函数做条件选择,或者用lerp做平滑混合。
第二个是动态循环。能预先知道循环次数的展开式循环还好,如果循环次数依赖外部变量,GPU上的执行效率会非常不稳定。做后处理、采样数的循环尤其明显。
第三个是纹理采样次数。每一次采样都要从显存取数据,采样频率过高会打满带宽。很多“远看不觉得、一转视角就卡一下”的Shader,大概率是采样数太离谱了。
至于具体的数值优化,我的建议是:在保证画面差异肉眼几乎看不出来的前提下,能用半精度(half/fixed)的地方不要用float。不过这个习惯也要随着平台变化而变,有些新平台对float的优化反而更好,所以最靠谱的还是实机Profile。用引擎自带的Frame Debugger和性能分析器抓过一轮,再对照Shader里的运算密集点来改,比盲目听别人的“优化秘籍”可靠得多。
5. 如何继续深入:我给新人的三条路线
如果你已经能看懂上面的代码,并且做出了属于自己的一两个Shader,那这次“什么是Shader”的目标就算达成了。但如果你还想继续往图形方向走,我给三条可以落地的路线。
第一条路:把Unity内置Shader的源码翻一遍。引擎自带的资源里其实藏着不少写好的Shader,有些版本可以直接找到内置Shader包,里面是标准材质、UI、天空球这些组件的完整Shader实现。你想知道“水面怎么做的”、“草被风吹动怎么写的”,去翻源码比任何博客都直接,因为那是最权威的参考。
第二条路:学会用Frame Debugger和RenderDoc抓帧分析。这两个工具能看到每一帧里引擎到底发了多少个Draw Call、每个Pass用的Shader是什么、每个顶点数据长什么样。很多“为什么画面比预期多了一道光”的疑问,在抓帧数据里一眼就能看出来。
第三条路:动手还原一个具体效果。你可以从最经典的“玻璃”、“边缘光”、“溶解消失”这三个效果里挑一个,给自己定一个目标:完全手写完成,只用最基础的纹理和数学函数,不许用现成插件。这个过程会让你把顶点和片元的各种细节彻底理顺。做完之后你会发现,再看其他Shader的源码时,脑子里不再是乱糟糟的碎片,而是一条清晰的流水线。
我在实际做项目时,见过不少把图形学理论背得滚瓜烂熟的同事,一上手调Shader还是会被“物体飞到画面外”“颜色偏得离谱”这种基础问题绊住。说到底,Shader不是背出来的,是跑出来的。每一行代码的变化,在Scene视图和Game视图里都会有直接反馈,你要做的就是不停地改、不停地看、不停地想这一步改的是顶点数据还是像素颜色。等这个思维习惯建立起来,Shader在你的眼里就不再是从网上抄来的一段代码,而是一套完全由你控制的视觉逻辑。图形世界的大门,到这一步才算真正打开。