17.总结
17.1 核心要点速览
ShaderGUI 与整页材质 Inspector
Unity 默认会按 Properties 块顺序画材质面板。一旦项目里参数多、需要分组、条件显示或附带「一键重置」这类操作,就需要接管整张 Inspector。
做法是写一个继承 ShaderGUI 的类,重写 OnGUI(MaterialEditor materialEditor, MaterialProperty[] properties),并在 Shader 根级写 CustomEditor "你的类全名"。绑定后,该 Shader 的所有材质实例都会走你的 OnGUI。
materialEditor.target指向当前选中的材质(多选时需注意批量逻辑是否与你的 GUI 兼容)。MaterialEditor.FindProperty("_Name", properties)按名字拿到MaterialProperty,可读写floatValue、colorValue、textureValue等,再交给自定义控件或业务逻辑。materialEditor.ShaderProperty(prop, label)等价于「这一行仍用 Unity 默认控件画」,便于在自定义布局里穿插默认外观。- 若不调用
base.OnGUI,即使Properties里声明了属性,也可以一行都不画——版面完全由你决定。正文示例里对renderQueue用整数字段、用按钮重置纹理和 Float,说明MaterialEditor/ 材质 API 可以和EditorGUILayout混用:本质是 Editor 脚本,只是多了材质语义。
MaterialPropertyDrawer 与 ShaderGUI 的分工
| 维度 | ShaderGUI | MaterialPropertyDrawer |
|---|---|---|
| 作用范围 | 整张材质 Inspector | 单个属性在列表里那一行的绘制与交互 |
| 典型用途 | 折叠、分组、条件显示、额外按钮、与项目规范统一的排版 | 自定义滑条曲线、枚举、与关键字联动的单槽控件 |
| 入口签名 | OnGUI(MaterialEditor, MaterialProperty[]) |
OnGUI(Rect, MaterialProperty, string label, MaterialEditor editor) |
MaterialPropertyDrawer 不负责「这一页长什么样」,只负责「这个 _Foo 在列表里怎么画、怎么改值」。与 ShaderGUI 组合时,常见写法是在遍历 properties 时对特定属性手动调用某个 Drawer 的 OnGUI(EditorGUILayout.GetControlRect(), prop, prop.displayName, materialEditor),其余属性仍走 ShaderProperty。
属性声明前可以挂 [YourDrawerName(参数)],Unity 会按类型名找绘制器(特性里写的类名通常不带 Drawer 后缀)。仅依赖特性自动挂接时,个别版本或边缘情况下曾出现不稳定反馈;工程里更稳妥的是:内置 Drawer 直接用,或 ShaderGUI 里显式调用,减少「魔法挂接」带来的排查成本。
Shader 变体与关键字:原理与选型
变体是什么:同一份 HLSL/CG 源码在编译时,会按关键字组合、平台宏、渲染路径等切成多份 GPU 程序。运行时 Unity 根据材质(及全局)上启用的一组关键字,选中匹配的那一份。雾、光照模式、内置特性也会参与变体膨胀,所以「pragma 写得松」会直接反映在编译时间和包体上。
#pragma shader_feature 与 #pragma multi_compile 的差异(行为以当前 Unity 文档与 Stripping 设置为准,面试与工程讨论里通常按下面理解):
shader_feature:倾向于只为实际会被材质用到的关键字组合生成变体,有利于控制变体数量和资源体积,编译也相对轻;适合「可选功能开关少、希望包体可控」的场景。multi_compile:倾向于为声明中的组合提供更完整覆盖,变体更多、编译更慢、体积更大;适合「管线或系统要求任意组合都必须存在」的情况。
Shader 里用 #if defined(KEY) 做分支时,走的是编译期分岔,选中变体后 GPU 上通常不再为这一路付动态分支代价;代价是要维护关键字集合、注意变体剔除与加载。若选项每分钟都在变、且组合爆炸,有时反而更适合用 uniform + 运行时 if,在指令与分支之间做权衡。
全局与局部关键字:Shader.EnableKeyword / DisableKeyword 影响面大,容易和别的材质打架。更常见做法是用 material.EnableKeyword / DisableKeyword,把关键字绑在具体材质实例上,缩小冲突范围。
内置材质属性绘制(Drawer)
| 特性 / 类 | 面板行为 | 与 GPU 侧关系 | 典型声明 |
|---|---|---|---|
| Toggle | Float 0/1,可绑定关键字 | 勾选时启用约定名或自定义关键字,配合 shader_feature / multi_compile 切变体 |
[Toggle]、[Toggle(自定义关键字)] |
| Enum | 下拉,写入你配置的离散数值 | 一般配合运行时按数值分支;不自动引入 KeywordEnum 那套变体 | [Enum(显示,值,...)] |
| KeywordEnum | 下拉,每项映射到 _属性名_选项名 形式关键字 |
必须和 Pass 内 pragma 声明的关键字一致,用 #if defined 切变体 |
[KeywordEnum(A,B,...)] |
| PowerSlider | Range 滑条,拖动手感按指数曲线映射 | 仍是标量 uniform,只是 Inspector 映射非线性 | [PowerSlider(指数)] + Range(min,max) |
| IntRange | Range 在面板上按整数步进 | 写入整型语义上的 float uniform | [IntRange] + Range(...) |
Toggle、KeywordEnum 与 Pass 里的 #pragma shader_feature / multi_compile 名字必须对齐,否则面板改了关键字,Shader 里根本没有对应变体或宏名,往往表现为「不切换」「切了没效果」或启用了错误组合。正文示例里 KeywordEnum 生成的关键字形如 _KEYWORDTESTENUM_TEX,与 pragma 列表逐项对应,这是排查时的第一手对照表。
Properties 前的限制与装饰特性
| 特性 | 作用 |
|---|---|
| HideInInspector | 属性仍序列化在材质上,脚本可改,Inspector 不显示 |
| NoScaleOffset | 2D 纹理不显示 Tiling / Offset,适合不需要缩放的蒙版、数据纹理 |
| Normal | 非法线压缩格式时给提示,减少误用切线空间法线贴图 |
| HDR | 颜色或纹理按 HDR 范围拾取,常用于自发光、Bloom 输入 |
| Space / Space(n) | 属性前插入空行或 n 像素间距,做视觉分组 |
| Header(“文本”) | 在属性列表里插入分组标题,不参与编译 |
帧调试器(Frame Debugger)
路径:Window → Analysis → Frame Debugger。启用后,Unity 会抓取当前帧的绘制与清理事件序列,你可以从左到右逐步选中事件,看这一步画到了哪里、用了什么状态。运行时开启往往会暂停游戏,这是预期行为:它在记录与回放渲染命令,而不是单纯「叠加一层 HUD」。
左侧事件树:叶节点是一次具体事件;父节点旁的数字表示子事件数量。名称里出现 UpdateDepthTexture、Drawing、Render Shadow Map、ImageEffects 等,可以快速判断当前是在更新深度纹理、画几何、画阴影还是后处理链路。
中间视图:RT0 一般指当前查看的第一个颜色类渲染目标;多 MRT 或中间纹理时序号递增。深度、法线、遮罩常被编码在 0~1 区间,单通道显示或调 Levels 能把对比拉开,否则肉眼很难分辨。
Details 面板:汇总当前事件的 Shader 名、Pass、混合方程、深度测试等;Keywords、Textures、Floats 用来核对「我以为的材质变体」和「实际提交的一致不一致」。这和只看 Inspector 是互补的:Inspector 是资产侧意图,Frame Debugger 是这一帧真正送 GPU 的状态。
连真机或 Player 调试时,Editor 侧能力通常要求 Development Build,并视情况开启 Run In Background,否则帧抓取或连接行为可能不完整。
表面着色器:定位与生成物
表面着色器写在 SubShader 的 CGPROGRAM~ENDCG 之间,不手写 Pass。编译阶段 Unity 根据 #pragma surface 与可选参数,生成 Forward / 阴影投射 / 附加光等多组 Pass,并把光照、雾、Lightmap 等接到生成代码里。
相对手写 vert/frag:优点是常规受光材质写得快,少重复造轮子;缺点是生成代码量大、黑盒多,要做非常规混合、自定义管线或极致优化时,往往不如显式 Pass 清晰。移动端项目需要结合变体数量与指令数评估是否大面积使用。
骨架始终包含:#pragma surface、自定义 Input 结构、surf 函数;可选自定义 Lighting* 光照函数。Unity 编辑器里可以查看生成代码,对照 surf 输出的表面属性如何进入光照与最终颜色,这对排查「为什么和标准光照不一致」很有帮助。
#pragma surface 与光照模型
指令形式:#pragma surface 表面函数名 光照模型 [可选参数…]。
surf的SurfaceOutput类型必须与光照模型匹配:SurfaceOutput对应 Lambert / BlinnPhong;SurfaceOutputStandard对应 physically based 的Standard;SurfaceOutputStandardSpecular对应高光工作流。- 内置光照模型名字即
Lambert、BlinnPhong、Standard、StandardSpecular。自定义光照时写Lighting函数名,按是否需要视角相关项选择是否声明带viewDir的重载。
常用可选参数(按需选用,每个都会改变生成 Pass 集合):
vertex::修改appdata_full,可做位移、往Input写自定义字段。finalcolor::光照之后再改color,适合整体色调、后乘颜色。addshadow/fullforwardshadows/noshadow:控制阴影相关生成策略。alphatest:、noambient、noforwardadd、nofog、nolightmap:分别裁剪环境光、附加光、雾、光照贴图等路径上的贡献。exclude_path:forward|deferred|prepass:在确定项目只走某条路径时,砍掉不需要的路径生成,减小变体;排除错了会导致该路径下材质不渲染或渲染错误。
Input 与 SurfaceOutput*
Input 的成员名有约定:Unity 按名字自动填充,例如 uv_纹理属性名、uv2_、viewDir、screenPos、worldPos、worldRefl、worldNormal,以及带 COLOR 语义的顶点色。若在 surf 里修改了切线空间法线 o.Normal,则依赖世界空间法线或反射的字段不应再用未修正的 worldNormal / worldRefl,应改用 WorldNormalVector(IN, o.Normal)、WorldReflectionVector(IN, o.Normal),否则光照与采样方向会与视觉法线不一致。
声明了 vertex: 时,可以在顶点阶段往 Input 的自定义字段写数据,片元阶段在 surf 里读,这是表面着色器里扩展逐顶点数据的标准路径。
三种 SurfaceOutput* 结构由引擎定义,字段集合固定,不能随意加减成员。选型规则:SurfaceOutput 配非 PBR 那套;SurfaceOutputStandard / SurfaceOutputStandardSpecular 配对应工作流里的 Metallic / Specular 参数。
实例:法线贴图、顶点膨胀、finalcolor
法线贴图:在 Input 中声明 float2 uv_BumpMap(名字与 _BumpMap 属性对应),在 surf 里 o.Normal = UnpackNormal(tex2D(_BumpMap, uv))。若光照模型是 Standard,继续在 SurfaceOutputStandard 上设置 Metallic、Smoothness、Emission 等,逻辑与常规 PBR 材质一致,只是表面函数代替了手写片元里的光照拼装。
顶点沿法线膨胀:#pragma surface ... vertex:MyVert,在 MyVert 里 inout appdata_full v,写 v.vertex.xyz += v.normal * _Expansion。用途包括描边壳、体积感夸张;注意对象空间尺度与模型缩放的感受,和纯片元描边是不同取舍。
finalcolor 调色:若把 _Color 乘在 surf 的 Albedo 上,等价于「进光照之前就染色」。有时希望「先按物理光照算完,再整体乘一层颜色」,则把乘法挪到 finalcolor,对 color 做 color *= _Color。两种顺序在数学上不等价,选用哪一种是美术与物理可信度之间的选择。
实例:动态液体(思路摘要)
场景结构:外层透明网格表示容器,内层单独材质画液体。液体 Shader 使用透明队列与特定混合:RenderType 与 Queue 为 Transparent,Blend DstColor SrcColor,ZWrite Off;正文实现选用 StandardSpecular 并配合 noshadow,示例为控制篇幅未展开阴影投射链路。
液面高度:把模型中心用 unity_ObjectToWorld 变到世界空间,与 Input.worldPos 在竖直方向比较,加上 _Height 等参数得到相对高度;再叠加正弦项,用 _Time.y、_WaveFrequency、世界坐标与 _InvWaveLength、_WaveAmplitude 控制波纹相位与幅度。
硬边界:用 step 把「在液面之上 / 之下」变成 0 或 1,再配合 clip 丢弃不应显示的片元;小偏移(如减去 0.001)可减少边界抖动。示例里的 surf 未使用 _Speed 属性;若要做流速,只需在时间项上乘以系数或单独引入速度 uniform 即可。
17.2 面试题精选
基础题
1. ShaderGUI 和 MaterialPropertyDrawer 分别解决什么问题
题目
Unity 里想自定义材质在 Inspector 里的表现,ShaderGUI 与 MaterialPropertyDrawer 各负责哪一层?各通过什么入口参与绘制?
深入解析
- ShaderGUI 面向整张材质面板:继承
ShaderGUI,在OnGUI(MaterialEditor, MaterialProperty[])里决定有哪些控件、是否调用默认ShaderProperty、是否完全自定义。 - MaterialPropertyDrawer 面向单个属性槽:继承
MaterialPropertyDrawer,重写带Rect的OnGUI,只负责该属性在列表里那一行的外观与交互;可通过 Shader 属性前的特性挂接,也可在自定义 ShaderGUI 里手动调用。 - Shader 根级
CustomEditor指向 ShaderGUI;Drawer 挂在具体属性声明前或与 ShaderGUI 逻辑组合使用。
答题示例
ShaderGUI 管整页材质 Inspector,重写
OnGUI,Shader 里用CustomEditor指定类名。MaterialPropertyDrawer 管单个属性怎么画,重写带
Rect的OnGUI,常用特性挂在属性前,或在 ShaderGUI 里对某个MaterialProperty手动调 Drawer。
参考文章
- 1.自定义材质面板-ShaderGUI类
- 2.自定义材质面板-MaterialPropertyDrawer类
2. shader_feature 与 multi_compile 选型差异
题目
#pragma shader_feature 和 #pragma multi_compile 在 Shader 变体生成上有什么本质区别?各适合什么场景?
深入解析
- 二者都声明局部关键字并参与变体生成;区别在于未使用组合是否仍全部编译进资源的典型策略:
shader_feature倾向只为材质实际用到的关键字组合生成变体,利于控制变体数量与包体;multi_compile倾向为声明中的组合提供更完整覆盖,变体更多,编译更慢、体积更大。 - 少开关、可选功能用
shader_feature更常见;系统级、必须所有组合都可切换的管线常用multi_compile。 - 实际行为以当前 Unity 版本与 Stripping 设置为准,但面试常考「变体数量与编译/包体权衡」这一维度。
答题示例
shader_feature一般更省变体,只为你实际用到的关键字组合生成版本,适合功能开关少、想控包体的时候。
multi_compile往往把声明里的组合都编出来,变体多、编译重,适合必须全覆盖的组合。选型就是在编译时间、包体大小和功能覆盖之间做权衡。
参考文章
- 3.自定义材质面板-自带Shader材质属性绘制类-Shader变体和关键字
3. Frame Debugger 里 RT0 和 Details 一般怎么用
题目
用 Frame Debugger 查一帧渲染时,RT0、通道隔离、Details 分别帮你确认什么?
深入解析
RT0表示当前正在查看的渲染目标之一,常对应颜色缓冲;多目标或中间纹理时序号递增。- 单通道或
Levels用于把非「直观颜色」的数据(深度编码、法线、遮罩)在画面上拉开对比度。 Details汇总当前事件的渲染状态:材质、Shader 名、Pass、混合、深度测试、光照路径相关开关等,用来对照「我以为用的 Shader/变体」和「实际提交 GPU 的状态」是否一致。
答题示例
RT0 就是看当前这一步画到哪张目标上,常是第一个颜色缓冲。
只开 R/G/B 或调 Levels,是为了看清深度、法线这类挤在 0~1 里的数据。
Details 里看具体哪个材质、哪个 Pass、混合和深度怎么开,跟 Inspector 里设的是否一致。
参考文章
- 8.帧调试器
4. 表面着色器和直接写顶点片元着色器怎么选
题目
Unity 里 Surface Shader 与手写多 Pass 的顶点/片元着色器各适合什么场合?
深入解析
- Surface Shader:Unity 生成 Pass 并接内置光照与阴影,适合快速做受光材质、少写重复光照代码;缺点是黑盒多、定制渲染路径或极致优化时受限,生成代码体积大。
- 显式 VF:完全控制 Pass、插值与关键字,适合自定义管线、特殊混合、极致性能或与非内置光照模型深度耦合的场景。
- 移动端项目常要在「开发效率」与「变体/指令数可控」之间权衡 Surface 的使用面。
答题示例
表面着色器省事,光照阴影 Unity 帮你拼 Pass,做常规受光材质快。
手写顶点片元适合要完全掌控 Pass、混合、优化和特殊管线的时候。
移动平台要警惕表面着色器展开后的体积和开销。
参考文章
- 9.表面着色器-基本原理和结构
进阶题
1. Toggle 与 Shader 关键字如何对齐
题目
材质上用 [Toggle]_ShowTex("ShowTex", Float) = 1 时,默认会关联什么关键字?在 Shader 里要如何声明才能用 #if defined 走变体分支而不是纯 if?
深入解析
- 默认会为勾选状态启用形如
_SHOWTEX_ON的关键字(与属性名大写加_ON的规则一致,正文以_SHOWTEX_ON为例);也可用[Toggle(自定义名)]指定自定义关键字。 - Pass 内用
#pragma shader_feature _SHOWTEX_ON或multi_compile列出与 Toggle 一致的关键字,片元里用#if defined(_SHOWTEX_ON)与#else分支;这样切换材质上的 Toggle 会切换变体。若只用if(_ShowTex==1)则是运行时分支,不依赖该 pragma 关键字体系。 pragma中的关键字名必须与 Toggle 生成或指定的字符串一致,否则面板切换不会驱动预期变体。
答题示例
默认 Toggle 会用到类似
_SHOWTEX_ON这种关键字,也可以用[Toggle(自定义)]改名。Pass 里用
shader_feature或multi_compile声明这些关键字,代码里用#if defined分支,就和 Inspector 上的勾选联动成变体切换。名字要和 pragma 里完全一致,否则对不上。
参考文章
- 4.自定义材质面板-自带Shader材质属性绘制类-ToggleDrawer
2. 表面着色器里的 addshadow 是干什么的
题目
#pragma surface 的可选参数 addshadow 典型用在什么情况?和单纯依赖 FallBack 有什么差别?
深入解析
- 顶点动画、改模型轮廓或 Alpha Test 等会改变「几何在光空间中的样子」时,默认阴影投射可能不准。
addshadow让 Unity 为表面着色器生成更贴合当前顶点变换的阴影投射路径,避免仅靠 FallBack 的简化 Mesh 与当前片元裁剪不一致。- 仍要与材质上的 Cast Shadows 等设置配合,且会增加生成代码量。
答题示例
顶点动画或透明度裁剪改了实际轮廓时,FallBack 的阴影可能对不上。
addshadow让 Unity 按你当前顶点逻辑去补投射阴影,减少错位。代价是多生成相关 Pass,要权衡。
参考文章
- 10.表面着色器-编译指令
3. vertex 与 finalcolor 在表面着色器流程里扮演什么角色
题目
vertex:MyVert 和 finalcolor:MyFinal 分别修改的是哪一阶段的数据?举一个适合用 finalcolor 的调色需求。
深入解析
vertex在顶点阶段改appdata_full,影响后续插值进表面的几何与部分可写进Input的自定义数据。surf写出表面属性(Albedo、Normal 等),再经光照模型合成颜色。finalcolor在得到光照结果之后对color做最后乘法或叠加,适合「光照后再统一乘一层色调」的需求;正文示例将_Color放到finalcolor即避免在surf的 Albedo 上重复乘颜色逻辑。
答题示例
vertex 管顶点变换和往 Input 里塞数据。
surf 给材质属性,光照算完才到 finalcolor。
要在光照后整体调色、用 finalcolor 乘
_Color这类需求很合适。
参考文章
- 14.表面着色器-实例分析-顶点膨胀
深度题
1. Enum 与 KeywordEnum 在材质面板与 GPU 侧的差异
题目
[Enum(...)] 与 [KeywordEnum(...)] 在 Inspector 上都是下拉,对 Shader 编译与运行时选路有何不同?
深入解析
Enum只把 Float 设成你配置的离散数值,片元里通常用if/else按数值分支;不自动引入新的 shader keyword,变体数量不因此倍增(除非你自己再配合其他关键字)。KeywordEnum为每个选项生成独立关键字(如_属性名_选项名),需配合#pragma shader_feature等声明,用#if defined选路,属于变体级切换;下拉改选项会改材质上的关键字集合,从而切换编译好的变体。- 选型:
Enum适合简单多选一且可接受运行时分支或变体由别机制控制时;KeywordEnum适合要把选项固化进变体、避免每帧动态分支或需要与关键字管线统一的场景。
答题示例
Enum就是改 Float 数值,Shader 里多用手写分支按数值走,不自动造关键字变体。
KeywordEnum每个选项对应一个关键字,要配shader_feature或multi_compile,用宏分支切变体,和关键字系统绑在一起。想要变体级切换、少跑动态分支就用 KeywordEnum;只是面板友好、逻辑简单可用 Enum。
参考文章
- 5.自定义材质面板-自带Shader材质属性绘制类-EnumDrawer和KeywordEnumDrawer
2. 动态液体示例里如何用高度与 clip 做液面
题目
动态液体示例 Shader 如何用世界空间高度与正弦波纹决定显示哪些片元?step 与 clip 各起什么作用?
深入解析
- 将模型中心变换到世界空间,与当前
worldPos做竖直方向差并加_Height缩放,得到相对液面基准的高度关系。 - 叠加
sin(_Time.y * _WaveFrequency + worldPos.x * _InvWaveLength) * _WaveAmplitude模拟液面起伏。 step(0, liquidHeight)把关系变为 0 或 1;再clip(liquidHeight - 0.001):小于等于 0 的片元被丢弃,形成硬边界;小偏移避免边界抖动。
答题示例
用物体中心在世界空间的高度和当前像素比,再加参数调液面,sin 做波纹。
step 把「在液面上还是下」变成 0/1,clip 把不该显示的片元直接扔掉。
减一点点 epsilon 让边界更干净。
参考文章
- 15.表面着色器-实例分析-动态液体-基本原理
- 16.表面着色器-实例分析-动态液体-具体实现
3. exclude_path 对表面着色器有什么意义
题目
表面着色器默认会为多条渲染路径生成代码,exclude_path 系列参数解决什么问题?
深入解析
- Unity 为 Surface Shader 自动生成前向、延迟等路径相关变体,Shader 体积与编译时间上升。
- 若确定材质只出现在某条路径(例如仅前向),可用
exclude_path:deferred等砍掉不需要的路径生成,减小变体与文件大小。 - 选错排除会导致在该路径下材质无法正确渲染,需与项目实际渲染设置一致。
答题示例
表面着色器默认帮多路径都生成 Pass,体积大。
确定只用前向就可以 exclude 延迟那条,减小变体和编译量。
排除错了在那条路径上会渲染不对,要和项目管线一致。
参考文章
- 10.表面着色器-编译指令
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com