☰
Unity ShaderLab 格式详解:四层骨架与 CGPROGRAM
2026/9/29 3:59:34 网站建设 项目流程

刚接触 Unity Shader 那会儿,我在 Assets 里新建出一个 .shader 文件,盯着满屏的大括号、中括号和看起来像 JSON 又不像 JSON 的东西看了半天。写着写着编译器就报错,报错信息还特别含糊,只说某一行 unexpected token,具体是哪的问题得自己一格格去猜。后来我把 Unity 手册里 ShaderLab 那一章翻来覆去看了几遍,又在小项目里连续摔了几个跟头,才算真正把 shader 语言的格式规律摸清楚。这一篇是 shader 基础系列的第二集,专讲格式,不碰光照模型也不推数学公式。上一集我们聊了 shader 是什么、它在渲染流程里处在哪个环节;这一集把 ShaderLab 这门语言的骨架拆开:一个 shader 文件由哪几层嵌套组成、每一层的括号和分号怎么摆、Properties 和 Pass 内部有哪些约定俗成的写法,以及那些让新手反复踩的格式坑。

1. 从大括号说起:ShaderLab文件的四层骨架

1.1 最外层的 Shader 块承担了什么角色

任何一个 Unity shader 文件,第一个非注释的语句一定是Shader关键字,后面跟一个用双引号包起来的名字,然后是一对大括号把所有内容罩住。这个最外层的块不是可有可无的装饰,它决定了这个 shader 在 Unity 眼里是一个不可分割的资产——一个 .shader 文件只能有一个顶层Shader块,你没办法在同一个文件里并排写两个,写了会直接编译失败。

名字那一串字符串要按路径来理解。写"Custom/MyShader",它就会出现在材质面板的 shader 下拉菜单里,路径是 Custom 下面一个叫 MyShader 的条目;写成"Hidden/MyShader",它就会从材质面板里藏起来,只能靠代码或者特定组件引用,很多后处理和后端工具用的就是这种隐藏命名。斜杠可以有多层,"MyFolder/SubFolder/Effect"也是合法的,层级越深在菜单里嵌套越深。命名这件事看着随意,实际建议从一开始就养成习惯:把项目自己的 shader 统一放在一个自己起的前缀下面,比如"Game/Water/River",将来 shader 一多,找起来不抓狂。

顶层Shader块里面允许出现的东西是固定的几种——Properties、SubShader、FallBack、CustomEditor。除了这四类,你往里塞别的东西基本都会报错。其中Properties只能出现一次,SubShader可以出现任意多次,FallBack和CustomEditor各至多一次。

1.2 Properties、SubShader、Pass 各自的分工

这三兄弟经常被新手搞混,其实分工非常清楚。Properties块负责的是“这个 shader 在材质面板上长什么样”——它声明的是材质层面的可调参数,也就是美术同学在 Inspector 里能看到、能拖滑块、能选贴图的那一堆东西。它本质上只是 UI 声明,不参与真正的渲染逻辑,你在里面写什么名字、什么类型,直接决定材质面板的呈现。

SubShader块负责“在不同硬件能力下跑哪一段逻辑”。Unity 会从上往下检查每一个SubShader,找到第一个当前显卡能支持的,然后就用它,后面的全部忽略。这就是为什么你可以为高端机写一个复杂效果、为老设备补一个简化版,把它们按顺序摆进同一个文件里。

Pass块藏在SubShader内部,是最贴近渲染管线的部分。一个SubShader里可以有多个Pass,每个Pass代表一次完整的绘制调用。前向渲染里常见的 ForwardBase 加 ForwardAdd 组合,就是同一个SubShader里两个不同Pass各管一段光照计算。可以这么理解:Properties是给美术看的菜单,SubShader是按设备能力分层跑的方案,Pass是方案里真正干活的每一次绘制。

1.3 为什么 SubShader 可以有很多个而 Properties 只能有一个

这里有个新手常问的问题:既然渲染逻辑在 SubShader 里,为什么属性反而只能声明一遍,不能在每个 SubShader 里各写一套?原因在于属性的作用域是材质,而材质是跨 SubShader 存在的。你在场景里挂了一个材质,材质上保存的是属性值;运行时切到哪个 SubShader 是设备决定的,如果属性每个 SubShader 各写一套,那材质里的值到底该按哪套来存就说不清了。所以 Unity 的设计是:属性在顶层声明一次,所有 SubShader 共享同一份材质数值。

反过来说,SubShader 之所以要支持多个,是因为“能力探测”这件事没法在单个方案里完成。不同显卡支持的指令数量、纹理采样上限、渲染目标格式都不一样,Unity 没法用一个 SubShader 覆盖所有情况,只能给你一个按顺序兜底的机制。理解了这一点,你写 shader 时的心理模型就顺了:属性块是全局的一份声明,SubShader 是排好队的备选方案。

2. Properties属性块:名字、显示名与类型的书写规则

2.1 一条属性声明的四个组成部分

Properties块里每一行声明,格式都是固定的四段式:内部名字、显示名、类型、默认值。写成模板就是_Name ("Display Name", Type) = DefaultValue。内部名字必须以大写或小写字母或下划线开头,社区约定俗成用下划线开头加驼峰,比如_MainTex、_BaseColor、_Glossiness,这不是强制要求,但跟着来能省很多沟通成本,因为 C# 脚本里取值时用的就是这个名字,一眼能对上。

显示名是给美术看的,可以带空格、可以写中文,Unity 会原样印在 Inspector 面板上。类型决定了这一项在面板上的交互方式:是颜色选择器、是贴图槽、还是滑块。默认值则是材质刚创建、还没被美术调整过时的初始数值。

这里第一个容易踩的坑:ShaderLab 的属性声明行末尾不要写分号。很多人从 C# 或者 CG 代码段过来,手会自动补一个分号,结果编译报错还找不到方向。属性块里的每一行就是声明,语法上不接受尾部分号。反过来,等会儿进入CGPROGRAM之后,代码段里的语句又必须写分号。同一个文件里两种写法,正是新手最容易被绊倒的地方。

2.2 六种基础类型和它们的默认值格式

Properties 支持的类型看着不少,常用的其实就几种,我列个对照表,把写法和默认值格式一起摆出来,方便直接抄。

类型关键字面板呈现默认值写法示例说明
Color颜色选择器(1,1,1,1)四通道 RGBA,范围 0 到 1
Vector四个数值框(1,2,3,4)四分量向量
Float单个数值框0.5浮点数,无范围限制
Range滑块Range(0,1) = 0.5类型位置写 Range(min,max)
Int整数框1整数,实际底层仍以 float 传递
2D贴图槽"white" {}二维纹理
Cube立方体贴图槽"white" {}环境贴图常用
3D三维纹理槽"black" {}体积纹理

贴图类型的默认值字符串有几个内置取值,"white"是白色贴图、"black"是黑色、"gray"是中灰、"bump"是默认法线图、"red"是纯红。后面那对空大括号{}是贴图类型的固定尾巴,Unity 官方格式里一直保留着它,写漏了会报语法错误。这个小细节几乎所有老手都被坑过一次,因为它看起来完全多余,但编译器不认情面。

Range 这个类型值得单独说一句:它的默认值必须落在你声明的区间内,写成Range(0,1) = 2会报错。它的实际用途是给美术一个视觉上不容易调飞的范围。Float 则没有任何限制,你想给多大给多大,但也就意味着美术可能拖一个离谱的数进去,所以能用 Range 的地方尽量用 Range。

2.3 方括号里的属性特性

在类型前面还可以套一层方括号,叫属性特性,用来微调这一项在面板上的行为。常用的有这么几个:

  • [HideInInspector]:把这一项从面板上藏起来,但代码里依然能取到值,常用于脚本自动填充的参数。
  • [NoScaleOffset]:贴图槽默认会带一组 Tiling 和 Offset 控件,加上这个就把它们去掉,适合那些不需要平铺的贴图。
  • [Normal]:声明这是一张法线贴图,面板上会有相应提示,导入设置不匹配时也更好排查。
  • [HDR]:颜色会以 HDR 方式处理,取色器会带上强度倍率,做自发光的时候很有用。
  • [MainTexture]和[MainColor]:标记主贴图和主颜色,Unity 在生成材质缩略图、以及在 C# 里用material.mainTexture取贴图时会参考这两个标记。

特性的位置是写在类型前面的,比如[NoScaleOffset] _MainTex ("Main Tex", 2D) = "white" {}。一个属性可以叠加多个特性,顺序无所谓。有个细节值得提:属性名称里不能带中文字符和空格,只能字母数字下划线组合,显示名才允许随便写。见过有人在内部名字里写中文,编译直接挂,报错还看不出所以然。

3. SubShader与Pass的状态格式:标签、渲染开关、LOD

3.1 Tags 的花括号语法和常用键值对

Tags这个块是 ShaderLab 里少数长得像字典的地方,格式是Tags { "Key1"="Value1" "Key2"="Value2" }。注意键和值都是字符串,中间用等号连接,键值对之间不用逗号也不用分号,直接空格隔开就行。SubShader 级别的 Tags 和 Pass 级别的 Tags 用的是同一套语法,但支持的键不一样。

SubShader 层级最常用的几个键:

  • "RenderType":给渲染类型打标签,取值有 Opaque、Transparent、TransparentCutout、Background、Overlay 等,替换渲染、后处理筛选都会参考它。
  • "Queue":控制渲染顺序,写字符串如 "Geometry"、"Transparent",也可以直接写数字。数值越小越先渲染。
  • "IgnoreProjector":设为 "True" 时,这个物体不受投影器影响,适合角色以外的杂物。
  • "DisableBatching":在需要拿到模型原始顶点数据、又怕 Unity 合批打乱数据时,设成 "True"。
  • "ForceNoShadowCasting":强制不投射阴影。

Pass 层级最关键的键是"LightMode"。它决定这个 Pass 在前向渲染流程里什么时候被调用,ForwardBase 管主方向光和环境光,ForwardAdd 管额外的逐像素光源,ShadowCaster 负责投射阴影,Always 则是不管什么情况都渲染。写多 Pass 前向光照的时候,LightMode 写错是效果不对的头号原因,因为漏一个 Pass 可能就少了一段光照贡献,画面看着就差一口气,但又不报错,非常难查。

3.2 渲染状态的写法:Cull、ZWrite、Blend

渲染状态指令写在 Pass 里(也可以写在 SubShader 里,会作为默认值向下传递),它们控制的是 GPU 在绘制这个 Pass 时的固定管线行为。这些指令的语法是关键字加参数,末尾同样不写分号。

  • Cull Back / Front / Off:决定剔除哪一面。默认是 Back,也就是不渲染背面。做双面材质时写Cull Off,性能会降一点,因为片元数量翻倍。
  • ZWrite On / Off:是否写入深度缓冲。不透明物体开,半透明物体通常关,否则会挡住它后面的东西。
  • ZTest LEqual / Always / Greater...:深度测试的比较方式。"LEqual" 是默认值,"Always" 常用于描边或者需要在物体前面强行绘制的东西。
  • Blend系列:混合模式,像Blend SrcAlpha OneMinusSrcAlpha是常规透明混合,Blend One One是叠加。做发光效果时这个参数怎么配,直接决定最终亮不亮。

这里想多说一句ZWrite和Blend配合的经验。很多新手做半透明效果,把Queue改成 "Transparent"、Blend配好,看着像那么回事了,但侧面看模型会发现前后关系错乱,一半原因是ZWrite没关,另一半是模型自己的三角形排序问题。半透明明明是渲染里最难伺候的一类,能不做就不做,非要做也得先把这两项调对。

3.3 LOD 与 FallBack 的位置讲究

LOD是一个整数指令,写在 SubShader 内部,用来配合 C# 里的Shader.globalMaximumLOD做整体降级。比如你给复杂效果标LOD 300、简化版标LOD 100,运行时把全局上限设成 100,Unity 就会跳过那个 300 的 SubShader。这个机制在需要动态降画质、给低端机兜底时非常好用,代价是要提前把每个档位的方案都写出来。

FallBack写在所有 SubShader 之后、最外层Shader块的末尾。它告诉 Unity:如果前面所有 SubShader 当前设备都跑不了,那就退而用哪个内置 shader。常见的写法是FallBack "Diffuse"或者FallBack "VertexLit"。还有一种是FallBack Off,意思是明确表示没有兜底方案,跑不了就算了。什么时候用哪个,取决于你对效果的坚持程度:能接受画质降级的用前者,宁可不显示也不能出错就用后者。

CustomEditor是可选的,用来指定一个自定义的材质面板编辑器脚本,类名用字符串写在这个指令后面。做工具化 shader 的时候才会用到,日常基础 shader 不写也完全没问题。

4. CGPROGRAM到ENDCG:代码段的格式约定

4.1 #pragma 指令的顺序和必填项

从CGPROGRAM到ENDCG这一大段,是真正用 CG 或 HLSL 语法写的着色器代码。这里的语法规则和上面 ShaderLab 那套完全不同,该写分号写分号,该挖括号挖括号,一点都马虎不得。

代码段开头通常是若干条#pragma指令。最基础的两条是#pragma vertex 函数名和#pragma fragment 函数名,分别指定顶点着色器和片元着色器的入口函数。函数名可以随便起,但必须和下面实际定义的函数对得上,写错了编译器会明确告诉你找不到入口,这个错误反而好查。

其余常用的 pragma 有这么几类:

  • #pragma target 3.0:声明所需的 shader model 版本,涉及的指令越多、分支越复杂,这个数就得往上升。手机上写 3.0 一般够用,用到计算、几何着色器才需要 4.0 以上。
  • #pragma multi_compile和#pragma shader_feature:声明变体。做多光源、多开关的时候靠它来分支。它们直接影响打包体积,用之前最好想清楚变体数量。
  • 光照相关的 pragma,比如声明需要完整前向阴影、需要光照贴图等,这些属于光照模型范畴,格式上就是多写几行而已。

pragma 的顺序没有严格强制要求,但整个项目里保持一致会让维护舒服很多,我的习惯是入口函数放最前,target 和变体声明放后面,include 放最后。

4.2 顶点输入结构体与语义冒号

代码段里第一件事通常是声明顶点输入结构体,用来接收 Unity 传进来的模型数据。结构体里的每个成员后面要跟一个冒号加语义名,比如:

struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; float4 tangent : TANGENT; float4 color : COLOR; };

这些语义名都是大写保留字,Unity 认识了之后才知道每个字段该填什么数据。POSITION 是模型空间的顶点坐标,NORMAL 是法线,TANGENT 是切线,COLOR 是顶点色,TEXCOORD0 到 TEXCOORD7 是八组 UV 通道。这里有个格式细节:语义前面的空格有没有都行,但冒号不能省,省了直接报错。而且语义必须是大写,写小写: position编译器不认。

UV 通道数量这块要留个心:模型上实际有几套 UV 取决于建模软件,你声明了 TEXCOORD1 但模型没导出来,读到的是全零值,不会报错,表现出来就是贴图采样全黑或者全白。这种问题最容易在接过别人模型的时候遇到,排查第一站就是确认 UV 通道对不对。

4.3 v2f 结构体与片元函数返回值

顶点着色器的输出结构体一般叫 v2f,意思是 vertex to fragment。它的字段语义和输入不完全一样,位置信息必须用SV_POSITION,这是裁剪空间坐标的专用语义;其他需要插值传给片元的数据,用TEXCOORD系列语义,编号从 0 往后排就行,编译器并不关心你第几号通道装的是世界法线还是视方向,只关心它能不能插值。

struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; };

片元函数的返回值同样要带语义。常规颜色输出用fixed4或float4,后面跟: SV_Target;如果要写多个渲染目标,就写SV_Target0、SV_Target1。这里有个细节:早年间 Unity 的写法是SV_Target和COLOR两种都能用,SL_DX11 之后的平台统一走 SV_Target,所以新代码就跟着这个写,兼容性没问题。

还有一个很常见的坑:片元函数如果声明了返回值语义,函数体里就一定要有return,而且返回类型要和签名一致。声明成fixed4却返回了一个 float3,编译器不一定直接报错,可能会截断或补零,表现出来是颜色偏色或者 alpha 全零导致看不见东西,这种问题比语法错误难查十倍。

5. 那些让编译器骂人的格式细节

5.1 分号:ShaderLab里不用,CG里必须用

前面提过一次,这里再强调一遍,因为它是新手阶段最高频的报错源。判断规则其实很简单:在CGPROGRAM和ENDCG之间的代码段里,一切按 C 语言来,语句末尾写分号;在 ShaderLab 的部分——也就是Properties、Tags、状态指令、FallBack——一律不写分号。属性行末尾加分号、状态指令末尾加分号,都是语法错误。

记忆方法可以这样:ShaderLab 里带括号的写法(属性声明、Tags 块)是声明式语法,像写配置,配置行不需要分号;状态指令像Cull Off是开关式语法,也不需要分号。真正需要分号的是代码段里的赋值、函数调用、返回语句。刚上手的时候我建议每写一行都停一下想想它属于哪一侧,写过几十行之后肌肉记忆就有了。

5.2 中括号、圆括号、花括号的配对

ShaderLab 里三种括号各有明确用途,混用会报错。方括号用在属性特性上和函数属性的语义之外,实际最常出现在特性声明[NoScaleOffset]、[HDR]里;圆括号出现在属性类型Color、Vector、Range的默认值和中括号后的类型参数里;花括号出现在 Tags 块、贴图默认值尾巴、以及所有代码块和结构体定义里。

新手最容易错的地方是贴图默认值后面那对{}忘写,以及 Tags 块里忘记闭合。这两种错误编译器的提示都不算友好,经常只指一个大范围的区域。我的做法是写 Tags 的时候先敲Tags { }再往里面填,写结构体的时候先写好一对花括号再补字段,能明显降低漏括号的概率。文本编辑器的括号匹配高亮功能在这个场景下几乎是必需品,装一个好用的 shader 语法插件能省大量时间。

5.3 类型关键字与精度:fixed、half、float

CG 语法里除了 float,还有 half 和 fixed 两个精度类型。这三位在 PC 平台上基本等价于 float,但到了移动端就不一样了:half 是 16 位,fixed 是更低的定点数,范围大约在 -2 到 2、精度约 1/256。历史上一段时间里社区都在劝把颜色、UV 之类的量用 fixed 来省性能,但现在的移动 GPU 大多把 half 和 float 同样处理,盲目降精度带来的画面问题反而比省下的性能更头疼。

我自己的判断标准是:顶点位置、世界坐标这类对精度敏感的量,老老实实用 float;法线、方向、光照结果用 half 一般够;颜色插值可以用 fixed4,但如果发现颜色出现色带、渐变跳变,第一反应应该就是把它换成 half4 试试。这种类型选择没有绝对正确答案,只能靠实测,而且不同设备表现还不一样,所以拿不准的时候宁可先用 float 跑对效果,再针对性降精度。

另外还要注意,CGPROGRAM 会自动包含UnityCG.cginc里的一堆常用函数,比如UnityObjectToClipPos、TRANSFORM_TEX、UnityObjectToWorldNormal,这也是为什么基础 shader 里可以直接调用它们。如果你用 HLSLPROGRAM 写,这些函数不会自动引入,得自己手动 include 对应的库文件,否则会报未定义。URP 项目里推荐统一用 HLSLPROGRAM,原因是它的包含关系更透明,出问题容易定位。

5.4 命名与大小写

最后说一个看起来不起眼但很耽误事的点:大小写敏感。ShaderLab 的关键字、CG 里的语义名、内置函数名,全都是区分大小写的。写SHADER不行,写sv_position不行,写unityobjecttoclippos也不行。Unity 的报错有时候只会告诉你某个标识符未定义,不会提醒你是大小写问题,新手经常在这上面绕圈。

命名习惯上我的建议是:属性名统一_加驼峰,结构体名小写,语义名全大写,内置函数名严格按文档抄。抄文档这步看着笨,但比记错大小写再回头查要快得多。写到后面你会发现自己常用的函数就那么十几个,抄熟之后自然就记住了。真记不准的时候,直接翻 Unity 手册里UnityCG.cginc那一页,比在搜索引擎里翻半天靠谱。

6. 我踩过的三个真实格式问题

6.1 属性声明后面的分号引发的一连串报错

第一次自己写带属性的 shader 时,我在_Color ("Color", Color) = (1,1,1,1)后面顺手加了分号,编译器报的不是这一行的问题,而是指向了下一行,说 parse error。当时我盯着下一行看了十几分钟,怎么都觉得没问题。后来把分号删掉,错误直接消失。

这件事给我的教训是:Unity 的 shader 编译器在遇到语法错误时,报错行号常常会往后偏一行。所以看到某一行报错但内容看着正常时,先怀疑上一行。这个经验后来帮我节省了大量时间,尤其在大段属性列表里找漏写或错写的地方。

6.2 LightMode 写错导致的光照缺失

有一次我写一个双 Pass 的前向光照 shader,主光那部分怎么调都不亮,环境光倒是正常。查了很久光照计算,最后发现是其中一个 Pass 的 LightMode 写成了 "Forward",而正确的写法应该是 "ForwardBase"。就一个字母的差别,画面差一半。

这个问题难就难在它不报错。Pass 里 LightMode 不匹配任何已知模式时,这个 Pass 会在常规光照流程里被跳过,但不会阻止编译。所以做多 Pass 光照的时候,我现在的习惯是先把两个 Pass 写出来,各自return一个纯色做验证,确认两个 Pass 都被调用了,再往里面填真正的光照计算。这个习惯让我避免了不少这种情况。

6.3 从 CGPROGRAM 切到 HLSLPROGRAM 时忘记 include

后来做 URP 项目,我把一个能跑的 CGPROGRAM shader 直接改成 HLSLPROGRAM,结果满屏的未定义函数错误,UnityObjectToClipPos、TRANSFORM_TEX 全都不认识。原因是 HLSLPROGRAM 不会自动引入 UnityCG.cginc,得手动加#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"这类包含。

这个切换看着只是改个关键字,实际上背后的库依赖完全变了。我的建议是:项目用什么管线就从头用哪套写法,别中途改。真要迁移,把 include 列表先补齐,再替换那些 Unity 内置函数,最后再跑效果验证,一步一步来,比一次性全改完再调试省事得多。

7. 写完格式之后该盯住的东西

格式这层东西说到底只是门槛,跨过去之后你会发现真正需要下功夫的是光照模型和数学。但门槛也得先跨过去,不然你在编辑器里连一个能编译通过的 shader 都跑不起来,后面的学习根本没法进行。我给自己的复习方式是把上面这些规则整理成一张小抄贴在显示器边上,写上三个月,后面写新 shader 基本不用再翻。

再提一个我觉得值得养成的习惯:每写一段新的 CGPROGRAM,先只让顶点着色器直通、片元着色器返回一个固定颜色,确认编译通过、物体能显示,再往里加 UV 采样、加光照、加各种效果。格式上的问题如果混在功能开发里一起查,会非常痛苦,而先用最小可运行版本把骨架跑通,出问题的时候你就知道答案一定在最近加的那几行里。这个排查思路在 shader 调试里几乎是通用法则,我用了很多年,现在还在用。

关于这个系列的下一集,我打算聊属性在 C# 脚本里怎么读写、material 和 sharedMaterial 到底有什么区别、以及为什么改材质会莫名多出一堆实例。这些和格式关系不大,但和实际项目里用 shader 关系极深,属于绕不过去的一环。等把这几块拼齐,一个完整的从写 shader 到在项目里用起来的闭环才算真正合上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询