热烈的少年时代,不问归途,只管向前
这篇文章最妙的地方是,在看这篇文章之前你一定接触过里面的东西,但不知你是否思考过?会有很多颠覆认知的知识!
优化Unity界面指南
优化任何Unity界面都是如何平衡绘制调用与批量处理成本
- 你把元素合批 → draw call 降了,但合批几何体的重建可能变贵
- 你拆画布隔离重建 → batching cost 降了,但跨画布永远不合批,draw call 又上去了
先 profile,再动手
在尝试优化 Unity UI 系统之前,**首要任务是找出观察到的性能问题的确切原因。**Unity UI 用户常遇到的常见问题有四类:
| 病因 | 瓶颈在哪 | 典型场景 |
|---|---|---|
| ① GPU 片段着色器过载(fill-rate 过度使用) | GPU | 半透明重叠、全屏面板 |
| ② CPU 重建 Canvas batch 耗时过高 | CPU | 单个 Canvas 元素太多 |
| ③ Canvas batch **重建次数过多(过度 dirty)** | CPU | 每帧都有元素在变 |
| ④ 生成顶点 CPU 耗时过高(通常来自 Text) | CPU | 大量动态文本 |
理论上可能死于"draw call 太多",但实践中"draw call 过多"的项目,几乎都是先被 fill-rate 卡住的 。所以优先怀疑 ① 和 ②/③,Text 单独归为 ④。
这个分类就是你的"查表顺序"------遇到 UI 卡顿,先拿这四个框去套 Profiler,而不是瞎猜!!!(重要)
目录
这篇文章最妙的地方是,在看这篇文章之前你一定接触过里面的东西,但不知你是否思考过?会有很多颠覆认知的知识!
[Unity UI 基础](#Unity UI 基础)
[Canvas 画布](#Canvas 画布)
[子画布 SubCanvas(Nested Canvas)](#子画布 SubCanvas(Nested Canvas))
布局组件(LayoutGroup、ContentSizeFitter、LayoutElement)
[Layout Group 解决的到底是什么问题?](#Layout Group 解决的到底是什么问题?)
[什么时候 Layout Group "值得用"?](#什么时候 Layout Group "值得用"?)
CanvasUpdateRegistry(画布更新注册表,重点!)
[渲染细节:UI 全部在透明队列渲染](#渲染细节:UI 全部在透明队列渲染)
[为什么UI 全部在透明队列渲染?](#为什么UI 全部在透明队列渲染?)
[批处理过程(Canvas 原生 C++ 层,BuildBatch)](#批处理过程(Canvas 原生 C++ 层,BuildBatch))
重建过程 PerformUpdate(CanvasUpdateRegistry,C#)
[完整一帧 UI 生命周期](#完整一帧 UI 生命周期)
[Unity UI 配置文件工具](#Unity UI 配置文件工具)
[Unity Profiler](#Unity Profiler)
[Unity Frame Debugger 帧调试器](#Unity Frame Debugger 帧调试器)
[填充率、画布与输入Fill-rate, Canvases and input](#填充率、画布与输入Fill-rate, Canvases and input)
[① 修复填充率问题](#① 修复填充率问题)
[手段 1:消除隐形 UI(零成本,首选)](#手段 1:消除隐形 UI(零成本,首选))
[手段 2:简化 UI 结构](#手段 2:简化 UI 结构)
[手段 3:禁用隐形相机输出( 极高频、极有效)](#手段 3:禁用隐形相机输出( 极高频、极有效))
[手段 4:多数遮挡相机 → Render Texture 冒充](#手段 4:多数遮挡相机 → Render Texture 冒充)
[手段 5:基于合成的界面](#手段 5:基于合成的界面)
[② UI Canvas 重建(CPU 重建逻辑)](#② UI Canvas 重建(CPU 重建逻辑))
[③ 分割画布(Splitting Canvases)全章核心](#③ 分割画布(Splitting Canvases)全章核心)
[④ 输入与射线投射](#④ 输入与射线投射)
[阶段 A:3步测试(全过才入候选池)](#阶段 A:3步测试(全过才入候选池))
[阶段 B:命中列表的 3 步过滤 + 排序](#阶段 B:命中列表的 3 步过滤 + 排序)
[五个控件块(Text / TMP / ScrollView / Image / Mask)](#五个控件块(Text / TMP / ScrollView / Image / Mask))
[① UI Text:三个子问题](#① UI Text:三个子问题)
文字从"一个字符串"到"屏幕上能看见的像素",到底经历了哪几步?
[子问题 1:文本网格重建(Mesh Rebuild)](#子问题 1:文本网格重建(Mesh Rebuild))
[子问题 2:★ 动态字体与字体图集(Dynamic Font Atlas)](#子问题 2:★ 动态字体与字体图集(Dynamic Font Atlas))
[子问题 3:专用字形渲染器(自定义数字)](#子问题 3:专用字形渲染器(自定义数字))
[子问题 4:Best Fit 的灾难 ★](#子问题 4:Best Fit 的灾难 ★)
[② TextMesh Pro(TMP)](#② TextMesh Pro(TMP))
文字从"一个字符串"到"屏幕上能看见的像素",到底经历了哪几步?
[Text 和 TMP 有什么区别?](#Text 和 TMP 有什么区别?)
[核心优势:无 "字号撑爆图集",描边无 Overdraw](#核心优势:无 "字号撑爆图集",描边无 Overdraw)
[TMP fallback 回退字体链](#TMP fallback 回退字体链)
[TMP 网格重建(UI 性能)](#TMP 网格重建(UI 性能))
[World Space 世界空间文本的选择](#World Space 世界空间文本的选择)
[③ ScrollView:第二大性能杀手 ★](#③ ScrollView:第二大性能杀手 ★)
[④ Image / ⑤ Mask](#④ Image / ⑤ Mask)
[Mask(旧模板遮罩) vs RectMask2D(UGUI 专用 2D 矩形裁剪)](#Mask(旧模板遮罩) vs RectMask2D(UGUI 专用 2D 矩形裁剪))
[① 基于 RectTransform 的布局(替代 Layout 组件)](#① 基于 RectTransform 的布局(替代 Layout 组件))
[② 禁用画布(Disabling Canvases)★ 核心技巧](#② 禁用画布(Disabling Canvases)★ 核心技巧)
[③ 分配活动摄像机(worldCamera)](#③ 分配活动摄像机(worldCamera))
[④ UI 源代码自定义(改 DLL)★ 最后手段](#④ UI 源代码自定义(改 DLL)★ 最后手段)
Unity UI 基础
Canvas 画布
画布是 Unity 原生 C++ 组件,作用:收集 UI 几何体,合并成渲染批次 (batch),生成渲染命令发给图形管线。当 UI 的几何体发生变化 → Canvas 标记为脏(Dirty) → 下一帧重新做批处理。
- 几何体:就是 UI 的面片网格(Image 是一个四边形,Text 是一堆文字三角面)
- CanvasRenderer(画布渲染器) :每个 Graphic 组件自带 CanvasRenderer ,它是 C# UI 层和原生 Canvas 之间的桥梁。Graphic 算出顶点网格,交给 CanvasRenderer,再提交给 Canvas。
简单记:Graphic 负责生成网格数据 ,CanvasRenderer 负责提交网格 ,Canvas 负责合并网格渲染 。
优化推论 :一个元素变了 → 整个 Canvas 重建
若静态动态混在同一 Canvas → 动态元素每变一次,静态元素跟着一起重建(白算、白合批)。
→ 所以切割标准 = 刷新频率 :静态单独一个 Canvas,动态单独一个(或一组)Canvas。
子画布 SubCanvas(Nested Canvas)
子画布是 GameObject 上额外挂一个 Canvas 组件。

核心作用:隔离脏标记
- 子画布内部 UI 变脏,只会触发子画布自己重新批处理,不会让父 Canvas 重建批次;
- 父 Canvas 变化,默认也不会影响子画布;
例外:父节点 RectTransform 缩放 / 大小改变,带动子画布整体尺寸变化,这时子画布也会脏。
性能用途:把频繁变动的 UI(弹窗、血条)放到单独子画布,避免整个大 Canvas 整批重绘。
优化推论 :不要在频繁缩放/移动的父级下嵌套子画布指望它完全隔离 ;做弹窗动画时,如果靠缩放 Canvas 根节点做弹出,嵌套的子画布照样会跟着重建。用位置平移而不是缩放来做动画更划算。-
Graphic 图形基类(C#)
怎么读?ˈɡræfɪk
Graphic 是所有可绘制 UI 组件的基类(Image、RawImage、Text、Outline 都继承它)。 MaskableGraphic 是 Graphic 的子类,实现了遮罩接口IMaskable,支持 Mask/RectMask2D 裁切。
Graphic 职责:根据当前 RectTransform、颜色、贴图,计算 UI 顶点(三角面、UV、颜色),生成网格交给 CanvasRenderer。 布局组件和 Graphic 互相独立:布局只改 RectTransform;Graphic 读取 RectTransform,生成顶点。
The major subclasses are Image and Text.
就这俩。**Unity UI 世界里 99% 的可绘制对象都是 Image 或 Text。** 理解这点后,"优化 UI"就约等于"管好 Image 和 Text"。
布局组件(LayoutGroup、ContentSizeFitter、LayoutElement)
布局组件不负责渲染、不产生网格 ,只修改自身 RectTransform 的size/anchoredPosition。 例子:VerticalLayoutGroup 自动排列子物体,本质只是循环修改子物体 RectTransform。
特点:完全独立于 Graphic,就算物体没有 Image/Text,只要有 RectTransform + 布局组件,照样会计算布局。
优化推论 :Layout 组件(HorizontalLayoutGroup 等)跟"有没有图片文字"无关,它只算矩形。所以哪怕你界面上是空的,只要挂了 Layout 且它 dirty,照样走重建流程。这也是"能用 Anchor 就别用 Layout Group"的原因------Layout 是纯开销。
Layout Group 解决的到底是什么问题?
Layout Group 的核心能力是自动维护子元素之间的相对关系 (间距、对齐、根据内容撑尺寸、自动换行)。
关键洞察 :当"子元素增减/尺寸变化"发生时,只有两种情况------
- 关系规则简单(等间距、固定偏移)→ 你自己算也很容易
- 关系规则复杂(自动换行、content size fitter、嵌套约束)→ Layout Group 的价值才真正体现
所以"子元素会增减"本身不构成用 Layout 的理由。 真正构成理由的是:"排布规则复杂到你自己维护会很痛苦"。
不用 Layout 的三种实战做法
做法 1:代码手动排(最常见,性能最优)
子元素会增减,但排列规则是确定的------比如"一行 N 个格子,等间距横向排"。
[SerializeField] float spacing = 10; [SerializeField] float itemWidth = 100; // 只在 items 变化时才调用,不是每帧 void Rebuild(IReadOnlyList<Item> items) { float x = 0; for (int i = 0; i < items.Count; i++) { var rt = pool.Get(i); // 从对象池取 rt.anchorMin = rt.anchorMax = new Vector2(0, 1); // 左上锚 rt.pivot = new Vector2(0, 1); rt.anchoredPosition = new Vector2(x, 0); rt.sizeDelta = new Vector2(itemWidth, rt.sizeDelta.y); x += itemWidth + spacing; } pool.ReleaseExtra(items.Count); // 回收多余的 contentRT.sizeDelta = new Vector2(x - spacing, contentHeight); }这跟你用
HorizontalLayoutGroup+ContentSizeFitter达到的效果一模一样,但:
- 零 Layout 重建开销(不进 Rebuild 队列)
- 你精确控制重算时机(只有数据真变了才调一次)
- 对象池配合位置移动,避免 reparent 脏重建------ScrollView 优化的核心思想
适用 :固定网格、横向/纵向列表、确定间距的排列。绝大多数 UI 其实都落在这里。
做法 2:锚点 + 结构约定(规则极简时)
如果每个子元素位置能用锚点表达 (比如"左半区放 A、右半区放 B,A 内部再分几块"),直接锚点定死,增减时只改对应锚点的 anchoredPosition。比做法 1 还轻。
做法 3:自研简易 Layout(规则固定但重复多)
如果项目里同一套排列规则被很多面板复用 (比如所有弹窗的"标题-内容-按钮"三栏结构),写一个继承
LayoutGroup的自定义组件或干脆一个普通 MonoBehaviour:
// 只算一次,数据变化时手动调 Refresh() public class VerticalStack : MonoBehaviour { public float spacing; public void Refresh() { /* 遍历子节点算 anchoredPosition */ } }好处 :保留了"自动排布"的便利,但重算时机在你手里,不像 Layout Group 那样被标脏就自动全量算。
什么时候 Layout Group "值得用"?
就是规则复杂到你不想维护时:
场景 用 Layout? 理由 等间距横排/网格 代码算 规则太简单,自己算更快更可控 聊天消息(高度由文字撑开) 可用 每个 item 高度不同,还要自动往下挤,自己维护麻烦 背包自动换行 + 动态增减 可用 换行逻辑复杂 弹窗"内容撑高、背景自适应" 可用 需要 ContentSizeFitter 嵌套的相对布局(父依赖子尺寸) 可用 冒泡逻辑自己写易错 但即便是这些场景,"用 Layout"也不等于"无脑挂组件"------仍要优化:
- 层级浅:避免嵌套 Layout(嵌套 = 每个 dirty 都冒泡全树)
- 混用 :布局框架用 Layout,内部固定部分用锚点,Layout 只管"必须自动排"的那一层
- 局部化 :把会变化的 Layout 放到独立的子画布上,避免牵连静态部分重建(第 4 章准则)
CanvasUpdateRegistry(画布更新注册表,重点!)
这是一个内部静态 C# 类,编辑器面板看不到,是整个 UI 重建的调度中心。 它维护两个列表:
- 脏布局组件列表(需要重新计算位置大小)
- 脏 Graphic 图形组件列表(需要重新计算顶点网格)
触发时机:每帧 Canvas.willRenderCanvases 事件 这个事件发生在渲染 Canvas 之前 ,Unity 会触发这个回调,调用CanvasUpdateRegistry.PerformUpdate(),开始执行重建流程。
很多人以为"UI 重建"是一件事,其实它是两个阶段
PerformUpdate(C#)负责"算出每个 UI 元素长什么样、顶点在哪";
BuildBatch(C++)负责"把所有元素的顶点拼成大网格、决定怎么交给 GPU 画"。
用一个类比彻底讲清
想象你在手工做一本书(这本书 = 一帧画面里的 UI):
阶段 1:PerformUpdate = "写作 + 排版"(C#,作者在大脑里算)
每个 UI 元素(Graphic、Layout)就是书里的一个章节。
- Layout 组件 (章节作者):决定"这一章占几页、放哪"→ 算出位置和尺寸
- Graphic 组件 (文字/图片):决定"这页上每个字的笔画、每个图的顶点"→ 算出顶点数据
CanvasUpdateRegistry就是主编 :手里有两张待办清单------
- 清单 A:脏的 Layout(位置尺寸要重算的章节)
- 清单 B:脏的 Graphic(顶点要重算的章节)
每帧,
willRenderCanvases事件一响,主编调PerformUpdate():按清单逐条处理,算出所有"该变的东西"的新数据。关键点 :此阶段只算数据,不动印刷机,不拼书。
阶段 2:BuildBatch = "印刷拼版"(C++,印刷厂机器干)
现在所有章节的最终文字和图都定稿了 (顶点数据就绪),但书还没成型------需要把零散的页面拼成大印张,决定"哪些页能印在同一张纸上(合批)"。
Canvas(原生 C++)拿着所有 CanvasRenderer 算好的网格,做三件事:
- 按深度排序
- 检查重叠、是否同材质/同贴图(能合就合一个批次)
- 生成渲染命令发给 GPU
关键点 :此阶段是 C++ 干的,多线程,C# 这边 看不到细节。
- 两阶段顺序固定:先 PerformUpdate(算数据),再 BuildBatch(拼批次)
- 谁管? :PerformUpdate 归
CanvasUpdateRegistry(C# 开源);BuildBatch 归Canvas本身(C++ 闭源) - 数据怎么传过去? :PerformUpdate 把顶点写进
CanvasRenderer,BuildBatch 再去读这些 CanvasRenderer → CanvasRenderer 就是两阶段的"交接区"
为什么"区分两个阶段"对你优化有用
因为只有分清了,你才知道瓶颈在哪一阶段、该动哪个开关:
| 你看到的症状 | 瓶颈在哪个阶段 | 对应优化 |
|---|---|---|
| SendWillRenderCanvases 耗时高、Text 热 | 阶段 1(C# 重建) | 减 Layout、关 Best Fit、Text 池化、隔离动态 |
| BuildBatch 耗时高、元素巨多 | 阶段 2(C++ 合批) | 拆画布(减小每次合批规模)、减 CanvasRenderer 数量 |
| BuildBatch 每帧都跑 | 有元素每帧在变 | 找出那个元素挪到独立子画布(局部化 dirty) |
| Draw call 多但 BuildBatch 不慢 | 合批被打断(不是慢) | 查 Batch Breaking Reason、排 Hierarchy 顺序 |
阶段 1 PerformUpdate(C#,开源可看): Layout 算位置尺寸 + Graphic 算顶点 → 数据写进 CanvasRenderer
阶段 2 BuildBatch(C++,闭源只看耗时): 读所有 CanvasRenderer → 排序、合批、生成渲染命令
渲染细节:UI 全部在透明队列渲染
Canvas 的所有几何体,渲染队列是 Transparent(透明队列),从后往前绘制,开启 Alpha 混合。
为什么UI 全部在透明队列渲染?
为什么 Unity 不干脆让 UI 走不透明队列、享受深度剔除、省掉 overdraw?答案不是"Unity 没优化",而是 "UI 的物理特性决定了它只能走透明队列"
什么叫"透明队列"?
渲染队列是 Unity 按数字 分的,"Transparent"队列 = 3000 及以后 ,特征两条:
- 从后往前画(画家算法,远的先画、近的后画,后画的盖在先画的上面)
- Alpha 混合开启
而"Geometry"/"Opaque"队列 = 不透明,从前往后画 + 深度测试开启,被挡的像素直接丢弃。
你要问的本质是:为什么 UI 不能走后者?
核心原因:UI 天生需要"半透明",而半透明在数学上不允许深度剔除
原因 1:UI 的 alpha 通道是核心功能,不是例外
UI 里大量元素本身就是半透明的:弹窗背景 80% 不透明度、渐变、阴影、描边、光晕、圆角边缘的反走样像素......
半透明像素的可见颜色 = 源颜色 × alpha + 背景颜色 × (1-alpha)。
这个公式意味着:最终颜色取决于"后面是什么"------必须先画出后面的颜色,再按 alpha 混上来。
→ 如果被挡的像素走"深度剔除"直接跳过,那半透明混合就错了。 一张 50% 透明的图标,你跳过它后面的像素不画,混合结果就变成"直接显示图标"而不是"图标叠在背景上"------画面直接出错。
所以:只要存在任何 alpha < 1 的像素,就必须从后往前逐层画,无法剔除。 UI 几乎不可能全部是 alpha=1,所以 UI 只能整体进透明队列。
原因 2:UI 有"层叠顺序"语义,不透明队列表达不了
不透明队列用的是深度缓冲(Z-buffer)------谁能显示取决于"谁离相机近"。
但 UI 的遮挡关系不是由 3D 深度决定的,是由"层级顺序"决定的:
- Hierarchy 里的上下顺序
- Canvas 的 Sorting Order
- 兄弟节点的先后
一个按钮"盖在"背景上,不是因为按钮 Z 值更近,而是因为按钮在 Hierarchy 更下面。 这是显式排序,不是几何深度。
透明队列的"从后往前 + 画家算法"恰好完美匹配 UI 的层级语义;不透明队列的 Z-buffer 反而表达不了"我想让这个层无论几何位置如何都盖在上面"。
原因 3:UI 元素普遍是"薄四边形",几乎没有深度可言
UI 几何体几乎全是贴在屏幕上的矩形(quad),都在同一个近平面上。
→ 它们之间基本没有有意义的"谁离相机更近"的差异 ,Z 值要么相同要么靠手动调。用 Z-buffer 做遮挡剔除在这里既不适用也不可靠。
所以透明队列对 UI 不是"退而求其次",而是"天然匹配"。
一个反直觉的点:即便"完全不透明",UI 仍走透明队列
你可能会想:"那我把 UI 图片都设成 alpha=1(完全不透明),是不是就能享受深度剔除了?"
不行。 原因:
- Unity UI 整个渲染管线统一走 Transparent 队列 ------只要属于 Canvas,不论你材质是否透明,一律从后往前画,一律不做深度剔除 。
- 即使单张图 alpha=1,它的边缘(反走样)和合批特性仍是透明管线的一部分 。
- UI 用的是 UI/Default shader,队列固定 Transparent。
→ 所以"完全挡住后面几十张图"的那个场景,即使挡板的图是 100% 不透明,GPU 照样会把被挡的几十张全部采样一遍。
这也是 UI 和 3D 物体的根本差异 :3D 里不透明墙挡住的东西能被 Z-cull 掉;UI 里即使一张"实心"图盖住一切,下面的图依然被完整渲染。
那代价怎么扛?------ 这就是"fill-rate 优化"存在的全部理由
既然设计上必须走透明队列 (无法改变),那 overdraw 就只能靠内容侧来压。
优化手段 为什么能压 overdraw(透明队列视角) 关掉被全屏 UI 挡住的 World Camera 3D 场景的像素在透明队列里不会被 UI 剔除,白画 → 直接关掉源头 **禁用看不到的 UI 元素(SetActive,不是 alpha=0)** alpha=0 照样在透明队列里画,必须真正移除 合并装饰层为专用精灵 3 层重叠 = 每像素采样 3 次 → 烘成 1 张 = 采样 1 次 用 RectMask2D 裁剪 ScrollView 视口外元素不再进可绘制列表,少一层就少一遍采样 简化 UI 结构、减空节点 每个节点都是潜在的一层采样 低端设备用精简 UI shader 减少每个采样点的 fragment 计算量(治"每次采样多贵",不是"采样几次") 这些建议的共同本质 :既然无法减少"每层的采样次数"(透明队列的规矩改不了),那就 减少"有多少层重叠"和减少"被画的像素总数"。
批处理过程(Canvas 原生 C++ 层,BuildBatch)
Canvas 收集它下面所有 CanvasRenderer(排除子画布内部的,子画布自己单独批),合并网格,生成渲染指令。 只有 Canvas 标记为 Dirty 时,才会重新执行批处理;缓存批次可以复用。
合并批次的条件:
- 同一个 Canvas 内;
- 材质相同(贴图、Shader、材质参数一致);
- 渲染顺序连续,没有打断;
批处理是多线程运行 的。 PC 多核 CPU 感受不明显;移动端 SoC 核心少,UI 大量元素变更时,这个合并网格的开销会很明显。
注意:批处理 ≠ 重建 Rebuild
- Rebuild 重建:C# 层,算布局、算顶点(Image/Text 生成网格);
- BuildBatch 批处理:C++ 层,收集已经算好的网格,合并成渲染块。
顺序:先 C# Rebuild 算出顶点 → CanvasRenderer 提交网格 → Canvas 判断脏 → C++ BuildBatch 合并网格。
重建过程 PerformUpdate(CanvasUpdateRegistry,C#)
每帧willRenderCanvases触发 PerformUpdate(),固定 3 步:
- 脏布局组件重建布局(布局计算,修改 RectTransform)
- 裁切组件剔除(Mask/RectMask2D,把被遮罩裁掉的元素标记)
- 脏 Graphic 重建图形(顶点网格)
布局一定要先算!因为 Graphic 生成顶点依赖 RectTransform 的大小位置,如果布局后改了 Rect,Graphic 才需要重新生成网格。
布局重建(分 3 阶段:PreLayout → Layout → PostLayout)
脏布局列表会按层级深度排序:父物体布局优先计算。
原理:父布局改变大小,会影响子布局。必须先算上层,再算下层。 例如:ContentSizeFitter 父物体,会撑开 VerticalLayoutGroup 子物体;父必须先算,否则子物体计算尺寸是错的。
布局重建只修改 RectTransform 的位置和尺寸,不会生成网格。布局完成之后,如果 Rect 大小变了,对应的 Graphic 就会被标记为脏。
Graphic 图形重建(分 2 阶段:PreRender + LatePreRender)
Graphic 的Rebuild()在预渲染阶段执行两件事:
- RectTransform 变了 → 重建顶点网格(重新生成 UI 三角面片、UV、颜色)
- 贴图 / 材质变化 → 更新 CanvasRenderer 使用的材质
- 图形重建不需要排序。布局阶段已经把所有 RectTransform 计算完毕,Graphic 只需要读取当前 RectTransform,生成顶点,互相之间没有依赖关系。
举个例子:Image 改颜色,会标记 Graphic 脏,触发 Rebuild,重新生成顶点颜色;Image 贴图更换,会更新 CanvasRenderer 材质,打断批。
完整一帧 UI 生命周期
1.代码 / 动画修改 UI(改 Text 文本、Image Sprite、布局尺寸)
2.修改会把对应的布局组件或者 Graphic 标记为脏,加入 CanvasUpdateRegistry 脏列表
3.渲染 Canvas 前,触发willRenderCanvases事件,调用PerformUpdate()
Step1:排序脏布局,从上到下执行布局重建,更新所有 RectTransform
Step2:Mask 裁切剔除
Step3:脏 Graphic 执行 Rebuild,根据最新 Rect 生成顶点网格,交给 CanvasRenderer
4.所有 CanvasRenderer 提交网格完成,回到原生 C++ Canvas
5.Canvas 检测到有网格变更,标记 Canvas 脏,执行 BuildBatch 多线程合并网格批次
6.提交渲染命令到 GPU,透明队列从后往前绘制 UI 面片
高频误区澄清
× 改 UI 就一定会重建 Batch
√只改 UI 颜色,没有改变顶点数量、材质,Graphic 重建顶点颜色;但如果网格拓扑不变,Canvas 不一定需要重批。
× 子画布可以解决所有 UI 性能问题
√ 子画布隔离 Batch,但子画布本身也有开销;大量子画布反而增加多个批处理开销。
适合:高频变化区域独立拆分。
× Layout 组件开销在渲染
√ Layout 开销是 C# 每帧递归计算 RectTransform,大量嵌套 Layout 会造成布局风暴,CPU 卡顿,和渲染 Batch 无关。
× Graphic 重建和 Canvas 批处理是同一个东西
√重建:C# 算顶点和位置;批处理:C++ 合并网格。两个独立阶段。
简单例子理解整套流程
一个 VerticalLayoutGroup 下面放 3 个 Text:
- 修改 Text 文字内容 → Text(Graphic)标记脏
- 进入 PerformUpdate:
- 布局:VerticalLayoutGroup 重新计算,修改 3 个 Text RectTransform 位置
- 图形:Text 重建文字网格,更新 CanvasRenderer
- Canvas 拿到新网格,标记脏,C++ 合并批次,提交 GPU 渲染。
Unity UI 配置文件工具
| 工具 | 层级 | 看什么 | 平台限制 |
|---|---|---|---|
| Unity Profiler | 托管 + 原生 | Canvas.BuildBatch、Canvas.SendWillRenderCanvases、UI 时间线 |
全平台,Editor 内 |
| Unity Frame Debugger | 逐帧渲染 | draw call 列表、合批被打断的原因 | 全平台,Editor 里不进 Play Mode 也能用 |
| Xcode Instruments / VTune | 原生方法级 | Canvas::UpdateBatches、Text_OnPopulateMesh 等 C++ 符号 |
iOS / Intel 专属 |
| Xcode Frame Debugger / GPA | GPU 管线级 | Tiler / Renderer 占用、overdraw | iOS / Intel 专属 |
Unity Profiler
如果没有了解过这个工具的,可以先看
unity性能分析器---Profiler window reference(1)-CSDN博客
unity性能分析器---Profiler window reference(2)-CSDN博客
这两篇文章
如何使用?
重点前置:UI Profiler(UI/UI Details 模块)仅编辑器 Play 模式可用,打包后的设备上不能采集这个批处理查看器数据
1. 打开窗口,添加 UI 模块
菜单:Window → Analysis → Profiler
只保留 这几个其余全部关掉:

2. 看懂 UI 模块 时间线 选中 UI (Canvas) 模块,上方两个时间图表:
1.选中UI (Canvas)模块**:布局 Layout 耗时 + 渲染 Render 耗时**
- Layout:UGUI 布局重建(Vertical/Horizontal LayoutGroup、ContentSizeFitter、脏标记 RectTransform)
- Render:Canvas 合批、顶点计算
- 注意:同样存在分类不全,部分 UI 逻辑不会统计在这里

2. 选中 UI Details (Canvas) 模块**:Batches 批次、顶点数、事件标记 Marker**
专门看UI 渲染资源量 + 用户交互触发点
- 竖线标记:按钮点击、滑动条拖拽等 UI 交互事件,用来定位:点击 UI 瞬间出现的 CPU 峰值
- 观察:操作 UI 时,Batch 数量、顶点数是否异常暴涨
Batches(批次) 含义:当前帧 UGUI 合批后的 DrawCall 总数 (1 Batch = 1 次绘制调用)
Vertices(顶点数) 含义:当前帧 UI 渲染用到的顶点总数量
Markers(事件标记,竖线 + 文字标签,就是截图里Button.onClick)最有价值
- 含义:UGUI 交互事件标记 。当你点击按钮、拖动 Slider、Toggle 勾选,Unity 自动在时间线上画一条竖线 + 事件名称标签(button.onClick、slider.valueChanged)Unity
- 核心用途:
把用户操作和 CPU 尖峰精准对齐
比如:你点按钮一瞬间,时间线出现 Marker 竖线,同时 CPU 曲线出现尖峰 → 证明这次点击操作直接触发了 UI 重建卡顿 。 操作:点击 Marker 竖线,选中这一帧,切到底部 Batch Viewer,查看这一帧哪些 Batch 断裂、哪些 UI 脏了重建。
3. 最核心:底部"Batch Viewer 批处理查看器"
它是UI Details模块选中之后,Profiler 窗口 底部的详情面板**,叫它 Batch Viewer 批处理查看器Unity**

左侧树状列表:所有 Canvas → 展开每个 Canvas,列出它生成的全部 Batch 表格关键列:
- Batch Breaking Reason(批次断裂原因,最重要) 告诉为什么当前 Batch 不能和上一个合并,常见:
- Different Texture:贴图不一样 → 用 SpriteAtlas 精灵图集解决
- Different Material Instance:材质实例不同
- Rect Clipping / Mask 裁剪:Mask/RectMask2D 强制打断合批
- Not Coplanar With Canvas:RectTransform 有旋转 / 偏移,不和 Canvas 共面
- CanvasInjectionIndex:CanvasGroup、嵌套 Canvas 强制新开 Batch
- Self Batch Count:当前 Canvas 生成多少 DrawCall 批次
- Object:对应 UI 游戏对象
操作流程示例
- 选中峰值那一帧(在时间线点击,白色竖线锁定当前帧)
- 展开目标 Canvas,逐个看 Batch
- 看
Batch Breaking Reason,找到大量打断合批的 UI 元素 - 优化(图集、减少 Mask、合并材质、减少嵌套 Canvas)
- 保持 Profiler 录制,反复开关 / 隐藏这个 UI 面板,对比前后 Batch 数量、Canvas.BuildBatch 耗时,验证优化效果(就是文档开头说的对比分析法)
4. 定位 UI 重建卡顿(CPU 模块,看 Canvas 两个函数)
切回 CPU Usage 模块,搜索这两个函数:
Canvas.SendWillRenderCanvases:UGUI 脏组件重建(CanvasUpdateRegistry 布局重建,C# 层),频繁触发 = UI 频繁脏标记、反复重建→ C# 布局脏重建问题(LayoutGroup、频繁改 transform)

Canvas.BuildBatch:原生 C++ 层合批计算,耗时高 = 顶点多、合批压力大→ 合批、顶点数量问题(贴图混乱、Mask 多、大量 UI 顶点)

排查思路: 点击按钮 / 打开弹窗 → 看这两个函数是否出现尖峰。 尖峰大 = UI 重建 / 合批开销大。
常见坑
- UI 模块统计不全:BuildBatch 不在 UI 曲线,只看 UI 曲线会低估 UI 开销,CPU 模块一定要一起看
- UI Profiler 只在编辑器有效,打包设备上这个面板不工作
- 布局组件(LayoutGroup)会频繁标记脏,大量增加 SendWillRenderCanvases 耗时
- 不是 Batch 越少就一定越好,优先解决点击 / 弹窗瞬间的 CPU 尖峰
一套标准排查流程
- 打开 Profiler+UI Details,开始录制
- 操作 UI,复现卡顿 / 掉帧,选中峰值帧
- CPU 模块:看Canvas.SendWillRenderCanvases、Canvas.BuildBatch耗时
- UI Details 批处理查看器:看 Batch 数量、断裂原因,定位 UI 对象
- 隐藏 / 禁用怀疑的 UI 元素,继续录制,对比前后耗时、Batch 数量 ,确认是不是这个 UI 导致性能问题
- 优化后,再次录制对比指标,验证优化收益
- 怀疑真机不一致:打包开启 Development Build,连接 Profiler,再用 Frame Debugger 核对真机 DrawCall
Unity Frame Debugger 帧调试器
UI‑Details(Batch Viewer)只在编辑器 Play 模式,只告诉你为什么合批失败;Frame Debugger(帧调试器)可以编辑器 Edit/Play 模式, 还支持连接真机看真实渲染 DrawCall**,看每一帧实际执行的绘制顺序、真实 DrawCall,专门排查**层级重叠导致合批断裂 这个设计坑
后续我会一篇文章详细讲解一下Unity Frame Debugger 帧调试器。在这里偷个懒
优化Unity界面------Unity 学习可以看官方文档,另外两个工具
接下来我们就是把前面的原理变成"动手改什么"啦
填充率、画布与输入Fill-rate, Canvases and input
欢迎来到UGUI 两大性能瓶颈:GPU 侧 填充率透支 (Overdraw) + CPU 侧 Canvas 脏标记重建,再加 UI 输入射线投射 CPU 开销。

① 修复填充率问题
有两种措施可以采取,以减轻GPU片段流水线的压力:
① 减少片段着色器的复杂性;
② 减少需要采样的像素数量。
而 UI shader 是标准化的,所以实际问题几乎永远是 ② ------ 即 overdraw(过度绘制)
**为什么?**
UI 全在 Transparent 队列,从后往前画,不做深度剔除 → 每多一层重叠,每个像素就多采样一次。fill-rate = 每帧要采样的像素总数,超了 GPU 就卡
两个元凶:
- 大量重叠的 UI 元素
- 多个 UI 元素占据屏幕较大区域(全屏半透明层最致命)
→ 所以接下来5 个手段,本质都在干一件事:减少"有多少层要采样"
手段 1:消除隐形 UI(零成本,首选)
最省 redesign 的方法:直接禁用玩家看不到的元素。 最常见:打开不透明背景的全屏界面 时,它下面的 UI 元素全部可以禁用。
核心警告:
不要用把 alpha 设为 0 来"隐藏"UI ------ 元素仍会发送给 GPU,仍占渲染时间!
为什么?
因为 SetAlpha(0) 只是把颜色透明度设为 0,这个 UI 元素依然在 Transparent 队列里,依然参与从后往前绘制,GPU 依然要为它采样每个像素 (虽然最终贡献是透明,但采样已经发生了 )。在透明队列里,"看不见"≠"不画"。
→ 真隐藏必须二选一:
SetActive(false)整个 GameObject- 禁用 Canvas 组件(保留 VBO)
VBO 是什么(先补这个前置知识)
VBO = Vertex Buffer Object(顶点缓冲对象) ,是GPU 显存里的一块内存,存着"这个 UI 元素长什么样"的原始数据:
每个顶点包含:位置、颜色、UV(贴图坐标)、法线......
│
▼
这些顶点按三角形组合 → 就是 UI 元素的网格(Mesh)
阶段 1
PerformUpdate(C#) :Layout + Graphic 算出来顶点数据 → 写进CanvasRenderer阶段 2
BuildBatch(C++) :读所有 CanvasRenderer → 排序、合批 → 生成渲染命令那个"顶点数据"最终就是要上传到 GPU 的 VBO。 而 "合批"的结果(哪些顶点组成一个批次、用什么材质)也是 GPU 要用的东西。
"保留 VBO" = 顶点缓冲对象还好好地躺在 GPU 显存里,合批结果也还在内存里。
三种方式对比:
方式 GameObject 组件 **VBO(显存顶点)** 合批结果 下次显示要重建? OnEnable/Disable **① SetActive(false)**销毁 禁用 丢弃 丢弃 全量重建(慢!) 触发 ② 禁用 Canvas 组件 保留 保留 保留 保留 秒开(快!) 不触发 **③ SetAlpha(0)**(且每帧照画) 无
额外提示 :如果某 UI 元素根本不需要 Graphic 组件 ,直接移除 Graphic ,射线检测照样工作 ------ 省一个 Graphic 就少一个可绘制对象。
既然只有一个 Canvas,那禁用它 = 整个界面没了,这和 SetActive 关根节点有什么区别?
答:区别不在"关哪个",而在"用什么 API 关"。 这是两个完全不同的问题:
- 关的是"同一个东西"(界面整体)
- 但"怎么关"决定了"VBO 丢不丢" ← 这才是方式禁用 Canvas 组件(保留 VBO)的价值

那禁用 Canvas 和 SetActive 到底怎么选?
| 情况 | 用哪个 | 理由 |
|---|---|---|
| 频繁开关、内容多、卡顿明显 | 禁用 Canvas 组件 | VBO 保留,秒开 |
| 界面内多个面板要独立显隐 | 各加 Sub-canvas + 禁用 | 各自 VBO 隔离,互不影响 |
| 偶尔开关、内容少 | SetActive | 简单,省显存(VBO 释放) |
| 隐藏期间要让脚本也停 | SetActive | OnEnable/Disable 自然触发,生命周期干净 |
| 已按频率拆好画布 | 各 Canvas 用方式 | 配套隔离,重开零开销 |
手段 2:简化 UI 结构
尽量减少 UI 对象数量。尽量烘焙(bake)。比如:
- 不要用混合版 GameObject 只为改个色调 → 用材质属性改
- 不要创建纯当文件夹用的空 GameObject****旧版 Unity(2017-)新版本另说,往后看
为什么? 不要用混合版 GameObject 只为改个色调
"混合版 GameObject"是什么?
就是一个纯做颜色叠加用的额外节点:
原:Image(底图)
↓ 为了"压暗一点",有人加了一层
改:Image(底图)
→ Image(半透明黑色遮罩,Blend 叠加)→ 只是为了改色调
多这一层 = 透明队列里多一次采样 = overdraw。 因为 UI 走 Transparent 队列,从后往前画,每个半透明层都要采样一次,叠 3 层就是 3 次采样。
正确的做法:
用材质属性改
// 直接改 Image 的颜色,不增加任何节点 image.color = new Color(0.8f, 0.8f, 1f, 1f); // 偏冷一点的色调color 是在顶点数据里直接修改的,同一个 Image、同一个 Mesh、同一个 Draw call,零额外采样
或者用 MaterialPropertyBlock(不改材质实例也能调属性),同样不产生新图层
为什么?"不要空文件夹节点"到底为什么(★ 新版unity已变化)
空文件夹节点是什么
就是只有 RectTransform、纯粹用来分组/组织层级、没有任何 Graphic 的空 GameObject:
HealthBar(空,仅作分组) ├─ Background(Image) ├─ Fill(Image) └─ Label(Text)旧版 Unity(约 2017 及更早)的经典理由
旧版 UI 系统里,每个 RectTransform 节点都要参与:
- Layout 重建遍历 ------
CanvasUpdateRegistry更新时遍历整个 RectTransform 层级- GraphicRaycaster 遍历 ------ 射线检测沿 Transform 一路爬到根 ,每级组件都要检查是否实现
ICanvasRaycastFilter(前面第 4 章讲过,成本与层级深度线性增长)- Transform 层级维护 ------ 父级 dirty 会冒泡影响子级
→ 空节点虽没有 Graphic(不贡献采样),但它仍在 Transform 树上,仍在"遍历路径上",仍增加层级深度 → 拖慢上述操作。
所以旧版经典建议:层级越浅越好,别建纯分组空节点。
★ 现代 Unity(2019.3+,尤其 UI Toolkit 时代之后)
**现实是:纯 RectTransform 空节点的开销已经非常小,很多时候可以忽略。** 原因:
- Layout / 射线遍历有早期剔除优化 ------ 对"无 Graphic、无 Raycast Filter"的节点跳过更快
RectTransform本身的变换计算极轻 ------ 现代 CPU 上几十、上百个空节点几乎测不出差异- Unity 官方自己现在都用空节点分组 ------ RectMask2D、各种复合控件的默认结构都带空根节点
那是不是这条建议就废了?不是,但要重新理解------关键在"空节点下面挂了什么"。
★真正的开销在"层级深度",不在"空节点本身"
这条是核心,很多人搞反了:
空节点本身几乎免费;"深层嵌套"才是问题------而且代价来自嵌套链上的 Graphic / Layout / Raycast Filter,不是空节点。
浅:Canvas → Panel → Item (Graphic) 深:Canvas → Folder1 → Folder2 → Folder3 → Item (Graphic)深度影响的是:
- GraphicRaycaster :命中测试要从叶子一路向上遍历到根 (前面第 4 章:
overrideSorting能截断就是这个原因)→ 每深一层,每次射线多一层检查- Layout 重建 :Layout Group 要按 Hierarchy 深度排序 + 冒泡 → 深层嵌套在 dirty 时更贵
- Transform 冒泡:父级变化影响子级
→ 所以精确表述应该是:
不要为了"组织整洁"制造深层嵌套的分组链。每一层 Folder 都在射线/Layout 的遍历路径上,深度线性变贵。
空节点只是"让层级变深的常见原因",它本身不是原罪。
手段 3:禁用隐形相机输出( 极高频、极有效)
打开不透明背景的全屏界面 时,世界空间相机仍在渲染背后标准 3D 场景 ------ 渲染器不知道 UI 会遮挡整个世界。
→ 解法:直接 disable 被遮挡的 world-space 相机。
关键限定):
如果 Canvas 设为
Screen Space -- Overlay,无论场景有多少相机,它都会绘制。
所以"关相机"这招对 Overlay 模式的 Canvas 无效 ------ Overlay 是独立渲染路径,不走相机。只对 Screen Space -- Camera 和 World Space 有效。
若 UI 没完全 遮住 3D 场景 → 把可见部分渲染到 Render Texture 缓存一次,关掉实时相机,用这张静态图当背景。代价:看不到 3D 动画了,但多数情况可接受。
手段 4:多数遮挡相机 → Render Texture 冒充
"全屏"UI 其实只露出世界一小块 时 → 只把可见部分捕获进 Render Texture → 关掉真实世界相机 → 用缓存纹理当"冒名顶替版 3D 世界"。
这是手段 3 的精细版 ------ 完全遮挡就直接关相机;部分遮挡就缓存可见部分 。核心思想:能用一张静态图替代实时渲染,就省掉每帧的 3D 像素开销。
手段 5:基于合成的界面
原本需要层层叠叠 才能呈现的静态装饰元素,美术出图时就合并成一张,而不是拆成 N 张小图运行时再叠。
② UI Canvas 重建(CPU 重建逻辑)
两个性能病因
| 病因 | 触发条件 | 本质 |
|---|---|---|
| ① 合批本身贵 | 单个 Canvas 元素太多 | 一次重建的计算量太大 |
| ② 重建过频 | Canvas 频繁 dirty | 重建发生的次数太多 |
两者经常同时出现、互相放大
Canvas 要"排序 + 分析重叠 + 检查共享材质/纹理" → 才能把元素合并成批次。
关键:这些操作不是"看一眼就过"------它们需要 两两比较、排序、判断重叠**。**
只要给定 Canvas 上"任何一个"可绘制元素变了,Canvas 就必须重跑整个合批构建过程------重新分析"每一个"元素,不管它变没变。→ 也就是说:重建的粒度是"整个 Canvas",不是"那个变化的元素"。
哪怕只是改了一个 Text 的一个字符、或者移动了一个小图标 1 个像素------只要这个元素属于某个 Canvas,Unity 就会把这个 Canvas 上"所有元素"的合批从头重建一遍。如果这个 Canvas 有 500 个元素,那 499 个没变的元素也要跟着重新排序、重新分析。
子物体顺序(中间层打断合批)
批次构建从上到下遍历 Hierarchy ,收集同材质、同纹理、无中间层 的对象。"中间层" = 一个材质不同 、包围盒与两个本可合批对象重叠 、且插在两者之间 的对象 → 强制打断合批。
两种解法(都可在 Editor 里开着 Frame Debugger 实时调):
- 重排顺序 :把不可合批对象移到可合批对象之前或之后(不插中间)
- 调位置:消除隐形重叠空间
**→ Hierarchy 里拖一下顺序 = 性能优化操作。** 纯手工活,Profiler 定位 + Frame Debugger 验证。
③ 分割画布(Splitting Canvases)全章核心
先讲两种 Canvas 怎么选
| 类型 | 适用场景 |
|---|---|
| **兄弟画布(Sibling Canvas)** | 需独立控制深度 (永远最上/最下),如教程箭头 |
| **子画布(Sub-canvas)** | 其余几乎所有情况 ------ 继承父 Canvas 显示设置,省心 |
核心权衡(原文原话,务必记住)
把 UI 细分成很多 Sub-canvas 看似最佳实践,但请记住:Canvas 系统也不会跨 Canvas 合并批次。 高性能 UI 设计需要在最小化重建成本和最小化浪费的 draw call之间取得平衡。
这就是贯穿全指南的核心矛盾(开头就提的):
- 拆画布 → 重建局部化(CPU 好)但 跨画布不合批 → draw call 上升(GPU 压力)
- 不拆 → 合批好(draw call 低)但重建全量(CPU 压力)
→ 没有"越多 Canvas 越好",只有"平衡点"。
一般指导准则("怎么拆"的答案)
准则 1:能同时变化的元素放同一 Canvas
例:进度条 + 倒计时器 读同一份数据、同时刷新 → 同一 Canvas。(避免被拆到两个画布导致两次重建)
准则 2:分成至少两层
Canvas 1(静态层):背景、标签…… 首次显示时合批一次,之后不再重建
Canvas 2(动态层):频繁变化的元素 → 只重建脏部分
准则 3:动态层大了再细分
动态层 → 持续变化组(进度条、计时器、所有动画)
→ 偶尔变化组
官方自己的诚实补充:
This is actually rather difficult in practice, especially when encapsulating UI controls into prefabs.
→ 这就是之前纠结的"预制体里静态动态混在一起怎么办"。官方实际建议倾向 :很多项目改为"把较贵的控件单独拆到 Sub-canvas 上",而不是严格三档分类。
→ 落到预制体上的三种方案(优先级):
- 首选 :预制体整体属单一频率 → 挂到对应频率层,内部不拆
- 混频时:预制体内部嵌子画布(用 Sub-canvas 隔离)
- 替代 2:把预制体切得更小,让每个预制体粒度 = 一个频率组
Unity 5.2 优化(历史背景,理解即可)
5.2 重写合批代码,多线程(多核设备) ,减少拆成"几十个子画布"的必要性 。很多移动 UI 现在只需 2-3 个 Canvas。
→ 现代 Unity(2018+)结论 :不要过度拆分。 过去"能拆就拆"的经验过时了。先 profile,按需拆到 2-5 个为宜。
④ 输入与射线投射
移动端鼠标输入错误(5.3 历史坑,新版已修复)
Unity 5.4 之前 :每个带 GraphicRaycaster 的活跃 Canvas ,每帧 都跑一次射线检测鼠标位置 ------ 即使没触摸输入、即使 iOS/Android 没鼠标。
代价 :浪费 CPU,曾被发现占 CPU 帧时间 5% 以上。
→ 5.4 已修复:无鼠标设备不再查鼠标位置、不再做无用射线投射。
若用 5.4 之前版本 :自己写 Input Manager,注释掉 ProcessMouseEvent。
射线投射实现细节
GraphicRaycaster 做的事,本质是一个筛选器:
从"所有 UI 元素"里,一层层筛,最后只剩"既被点中、又能响应事件"的那一个。
阶段 A:3步测试(全过才入候选池)
对每一个 Raycast Target = true 的 Graphic,挨个做测试。 注意:这是"逐个元素测试",不是"先排序"。
测试 1:激活/启用/已绘制
对象活跃(active)、enabled、且有几何体(有网格可画)。
→ 被禁用、被隐藏、alpha=0 但 仍有几何体**的元素......注意:**alpha=0 若仍有几何体,这一步可能通过**(遮挡判断在后面),所以前面才强调"隐藏 UI 用 SetActive 而不是 alpha=0"。
测试 2:RectTransform 内含点 ★最直观
输入点(点击位置)在 RectTransform 的矩形范围内。
就是"你点的是不是落在这个 UI 元素的方框里"。
→ 这是最基础的"点中检测"。注意:它用的是 RectTransform 的 矩形**,不是像素(即使文字周围大部分透明,只要点在矩形里就算"点中"------这也是文字容易"误触"相邻精灵的原因)。**
测试 3:射线过滤(Raycast Filter)★ 最绕的
先解释 ICanvasRaycastFilter: 一个接口 ,实现它的组件可以拦截/过滤射线。
Unity 内置实现了它的组件(前面见过):
CanvasGroup(设置blocksRaycasts、ignoreParentGroups)Image(配合Raycast Target)Mask/RectMask2D(裁剪区域外的不命中)
GraphicRaycaster 会沿着 Transform 层级往上爬 ,只要路径上任何一个父级 有 Raycast Filter 且它说"不允许" → 这个 Graphic 被剔除。
→ 即使文字本身 Raycast Target 开着,但 父级 CanvasGroup 说"别让我下面响应" → 文字被过滤掉,不进候选池。
★ 这也解释了前面"层级深度与射线投射"的性能坑:因为要"一路爬到根"检查 Filter,层级越深、每级组件越多 → 越慢(与深度线性增长)。
阶段 B:命中列表的 3 步过滤 + 排序
通过阶段 A 的所有 Graphic 组成"命中列表"------但点击点落下时, 重叠的多个元素可能都被点中**(按钮、按钮上的文字、背景都叠在一起、矩形都包含这个点)。**
→ 阶段 B 就是"这么多候选,谁响应?"
步骤1:按深度排序(Depth)★
命中列表按 depth 从大到小排序,取最前面的
步骤 2:过滤反向目标(Back-facing / 反向)
过滤掉"背面朝向相机"的元素。
主要针对 World Space Canvas 里的 UI: 一个 UI 面板如果翻到背面(背面朝向相机),正常不该被点中。
→ 移除那些"在相机后方渲染、背面朝向"的元素。 选项 Ignore Reversed Graphics(Inspector 里那个勾)就是控制这步。
步骤 3:移除相机后方(屏幕不可见)的元素
移除"渲染在相机后方、屏幕上根本看不到"的元素。
即:即使几何上被点中、depth 也够,但 它在 3D 空间里位于相机后面(屏幕不可见) → 没有视觉意义 → 剔除。
→ 保证"点中的东西是用户能看到的东西"。
★ 物理遮挡剔除(blockingObjects)
若 GraphicRaycaster 的
blockingObjects设了 2D/3D → 被物理遮挡的物体也从命中列表剔除。
这是"命中列表"阶段的 再加一道筛**。** 场景:World Space UI(UI 贴在 3D 世界里)。**
射线投射优化技巧
核心原则 :Raycast Target 列表越小、层级越浅 → 每次射线测试越快。
技巧 1:只在必须接收事件的组件上勾 Raycast Target
默认很多 Image/Text 都勾着 → 大部分用不到,逐个关掉,尤其列表里成百上千的项。
技巧 2:
只在根节点(Button)放一个 Raycast Target
把子级所有 Graphic 的 Raycast Target 关掉
→ 把一个按钮的 N 次射线测试压成 1 次。
GraphicRaycaster 组件介绍
它牵出了 射线检测(Raycaster)体系 的完整结构
GraphicRaycaster 它的职责把屏幕上的点击/触摸点 ,转换成"这个 Canvas 里哪个 UI 元素被点中了
它属于更大的"Raycaster 体系"
Unity 其实有三种 Raycaster,
Raycaster 挂在哪 检测什么 典型用途 GraphicRaycaster Canvas **UI(Graphic:Image/Text/RawImage)** uGUI 按钮、UI 点击 ★ Physics Raycaster Camera 3D 物体(Collider) 点 3D 世界物体 Physics 2D Raycaster Camera 2D 物体(Collider2D) 点 2D 精灵 你的点击是怎么被处理的:
鼠标点击 / 手指触摸(屏幕坐标) │ ▼ EventSystem(全局,一个场景一个) │ ▼ 遍历所有启用的 Raycaster ├─ GraphicRaycaster → 检测 UI(Canvas 下的 Graphic) ├─ Physics Raycaster → 检测 3D └─ Physics 2D Raycaster → 检测 2D │ ▼ 按深度排序 → 返回最前面的命中对象 │ ▼ 触发按钮的 OnClick / IPointerDownHandler 等事件GraphicRaycaster 的关键配置
选中 Canvas 上的 GraphicRaycaster,你能看到:
字段 作用 Ignore Reversed Graphics 忽略"背面朝向相机"的 UI(背面的不命中) Blocking Objects 是否让场景里的 3D/2D 物体挡住 UI 射线(None / Two D / Three D / All) Blocking Mask 配合上一条,指定哪些 Layer 的物体能挡 ★ "Blocking Objects" 的作用(容易忽略):
比如你的 UI 是 World Space 的(贴在 3D 世界里),前面有个 3D 箱子 → 设
Blocking Objects = Three D+ 箱子在对应 Layer → 点箱子时 UI 不会被命中,射线被箱子挡住。
注意这里需要解释一下:用的是深度(Depth),不是层级顺序
排序(按深度)和层级顺序(Hierarchy)是两件不同的事,用在两个不同阶段:
- 层级顺序(Hierarchy) → 决定同一 Canvas 内 UI 元素的"前后遮挡关系"(谁盖在谁上面)
- 按深度排序(Depth) → 发生在射线检测之后 ,用来在多个命中对象里挑"最前面的那个"
这是混淆的核心------"深度"最终 基于**层级顺序,但不等于层级顺序本身。** 规则分两种:
情况 A:同一 Canvas 内
GraphicRaycaster 会给每个 Graphic 计算一个排序深度,基本规则是:
Hierarchy 里越靠后(越靠下、越盖在上面)的对象 → depth 值越大 → 越"靠前"
所以在这个意义上,"层级靠后"和"depth 更大"是 一致的**------这让人误以为"按 depth 排序 = 按层级排序"。**
情况 B:跨 Canvas / 有 Sorting Layer
当有多个 Canvas 时,depth 的计算加入了"Canvas 的 Sorting Layer + Order in Layer":
最终 depth = 综合了: ├─ Canvas 的 Sorting Layer(层级更高 → 更前) ├─ Canvas 的 Order in Layer(值更大 → 更前) └─ 同一 Canvas 内的 Hierarchy 顺序→ 这时"depth"就 不等于单纯"层级顺序"了------它是"层级 + Canvas 排序设置"的综合结果。
例:
Canvas A(Sorting Layer = "UI",Order = 0) └─ ButtonA(Hierarchy 靠后 → depth 大) Canvas B(Sorting Layer = "UI",Order = 1)← Order 更大,整体更前 └─ ButtonB(Hierarchy 靠前 → depth 小)即使 ButtonB 在 Hierarchy 靠前,但因为它所在 Canvas 的 Order 更大 → ButtonB 整体在 ButtonA 前面 → 点击时 ButtonB 的 depth 更大 → ButtonB 命中。
→ 这证明"按深度排序"≠"按层级排序":Canvas 排序设置能覆盖层级顺序。
| 你遇到的问题 | 对应文本 | 具体手段 |
|---|---|---|
| 全屏 UI 时 GPU 满 | ① 消除隐形 UI | SetActive 禁用下层 UI(别用 alpha=0) |
| 半透明层重叠多 | ① 简化结构 | 用材质属性改色调,别叠 GameObject |
| 全屏 UI 背后 3D 还在渲染 | ① 禁用相机 | 关掉被遮挡的 World/Camera 相机(Overlay 无效) |
| 部分遮挡的 3D | ① Render Texture | 缓存可见部分,冒充 3D 世界 |
| UI 装饰层多、overdraw 高 | ① 合成 UI | 合并为专用精灵(局部合并到子元素背景) |
| 低端机(iPhone 4 级) | Shader | 用 UI/Fast-Default 精简 Shader |
| BuildBatch 耗时高 | ② 重建 | 拆画布减元素数 |
| 每帧都重建 | ② 重建 | 隔离动态元素到子画布 |
| 合批被打断 | ② 子物体顺序 | 排 Hierarchy 顺序 + 消除隐形重叠 |
| 卡顿、元素多 | ③ 分割画布 | 静态/动态分离(2-5 个 Canvas 为宜) |
| 同时更新的元素 | ③ 准则 | 同频元素共置一画布 |
| 移动端 CPU 莫名占 5%+ | ④ 鼠标错误 | 升级 5.4+ |
| 点击/悬停卡 | ④ 射线优化 | 关 Raycast Target + 复合控件用单一 Target |
| UI 层级很深 | ④ 层级深度 | 减层级 + 用 Sub-canvas 的 overrideSorting |
五个控件块(Text / TMP / ScrollView / Image / Mask)

给一下内容定个调!所有控件的性能问题,归根到底都是前面原理的"具体表现"------重建、合批、overdraw、射线。看完就会发现新概念很少,全是旧原理的新面孔。
① UI Text:三个子问题
补充知识:
文字从"一个字符串"到"屏幕上能看见的像素",到底经历了哪几步?
你在屏幕上看到的每一个字符,本质上就是"一张小图片(字形),贴在一个矩形面片上"------这个矩形面片就是"两个三角形拼成的四边形(Quad)
从"字是怎么画出来的"讲起
第 1 层:字 = 一张小图片(字形 Glyph)
一个字符(比如字母 "A")最终要显示成像素,得先有"它的样子"。
这个"样子"有两种存在方式:
矢量(轮廓) **位图(像素图)** ← 最终显示用这个 是什么 数学曲线(TrueType 轮廓) 一个矩形小图,里面是 "A" 的像素 能直接显示吗 ❌不能 能 → 关键:无论矢量还是位图,最终要显示,都必须变成"像素图(位图)"。 这个过程叫光栅化(Rasterize)。
光栅化(Rasterize) = 把"字符的形状描述(矢量轮廓)" → 转换成"图集上的像素(位图)"。
Unity 把常用字符的这些"小图片"打包进一张大图 ------ 就是前面反复提到的"字体图集(Font Atlas)":
→ 每个字符 "A"、"你"、"好" 都是这张大图里的一块小区域。
第 2 层:要把这张小图贴到屏幕上 → 需要一个"面片"
现在你有 "A" 的小图片了,怎么让它出现在屏幕上某个位置?
3D/2D 渲染的根本规则:所有东西都是"三角形"画的。 一张平面图片 → 用**两个三角形拼成的矩形(四边形 Quad)**来承载:
→ 这就是"四边形":就是一个矩形面片,上面贴着 "A" 的图。
第 3 层:一串文字 = 一排四边形排在一起
字符串 "Hello":
每个字一个四边形,排成一行 → 就是文字
每个字符的四边形(4 个顶点),每个顶点存 3 样东西:
数据 作用 例子(字符 "A") **位置(Position)** 这个字放哪、多大 左下角 (0,0),宽高 (10,16) UV 用图集哪一块 图集上 "A" 那块区域的坐标 **颜色(Color)** 顶点色(支持渐变/富文本) 白色(默认) → 改文字为什么重建网格:因为"字形/UV/位置"全都变了,而这三者 存在每个顶点上**,所以顶点要全部重新生成。**
子问题 1:文本网格重建(Mesh Rebuild)
问题 :改 Text 的 text 内容 → 必须重新光栅化整个字符串 → 重建网格(每个字符一个四边形)。
为什么"整个"而不是"只改那个字符"?
原因 1:排版是"整体的"(换行会传染)
原来:"Hello Wo" ↓ 加了个 "r" 变成:"Hello Wor" ↑ 整个词 "World" 可能触发换行 → 后面所有行都重排**→ 改中间一个字,可能导致后面所有字的排布都变(换行、对齐)。** 所以"局部改"不现实,必须整体重排。
原因 2:网格是"一块连续数据",局部改比全重建更慢
Unity 把整段 Text 做成 一个连续的网格(顶点数组连续排列)**。** 要"插入一个字符"意味着:
- 后面所有顶点整体往后挪(数组插入,O(n))
- 不如直接全部重新生成(对现代 CPU 来说"整体重建"比"数组中间插入"更快)
→ 所以"全量重建"反而是 工程上最快的做法。
★ 最反直觉的坑:
即使不改文本,只要 Text 组件或其任意父 GameObject 被 SetActive(false) 再 SetActive(true) → 也会重建网格!
**为什么?** 因为 SetActive 走的是"重新启用"路径,Unity 保守地认为需要重建。
→ 这对"排行榜/统计界面"是灾难 :这类界面通常做法是"打开时 SetActive 整个面板",而面板里几百个 Text → 一次性全部重建 → 卡一帧。
解法("Disabling Canvases"):
- 别用 SetActive 开关面板 → 用"禁用 Canvas 组件"(保留 VBO,免重建)
- 或把 Text 所在部分单独放 Sub-canvas,隔离重建
子问题 2:★ 动态字体与字体图集(Dynamic Font Atlas)
补充知识:什么是"静态字体"和"动态字体"?
在 Unity 里建一个字体,导入时有个关键设置
Character Set(字符集):
类型 设置 图集怎么来 **静态字体(Static)** Character Set = Static导入时就把"指定的字符"一次性光栅化进图集 ,运行时不再加字符 **动态字体(Dynamic)** Character Set = Dynamic图集初始很小(甚至空) ,运行时遇到新字符才现加
| 两个 Text 都显示 "A" | 图集里的字形数 | 为什么 |
|---|---|---|
| 同字号 | 1 | 同一组合,共用一份 |
| 16pt / 24pt | 2 | 字号不同 = 不同组合 |
| 加粗 / 不加粗 | 2 | 样式不同 = 不同组合 |
"字符 + 字号 + 样式"三者定一个字形,任何一个变了就多一份
例:一个用 "Arial"、一个用 "Arial Bold" → 即使视觉同族,Unity 当成 两个独立 Font**,各建一张图集**→ 内存翻倍。
→ 这是"机制"里最容易被忽视、又最烧内存的一条。
动态字体的扩容机制?
新字符到来
│
├─ 图集还有空 → 直接加 + 重新上传 GPU(小开销)
│
└─ 图集满了
│
▼
阶段 1:同尺寸重建,只保留"当前活跃 Text 正在用的"字符
│
├─ 放得下 → 重建完成
│
└─ 仍放不下
│
▼
阶段 2:图集短边 ×2(512² → 512×1024)→ 重建 + 重新光栅化
关键性质:图集"只增不减"(只会变大、不会缩回)。
三大坑
| 坑 | 根源 | 表现 |
|---|---|---|
| ① 重建按每个 Text 单独触发 | 每个 Text 遇新字符就触发扩容流程 | 大量 Text 逐个赋值 → 反复重建 → 卡 |
| ② 图集只增不减 | 扩容只往大走 | 内存随时间持续增长 |
| ③ 重建会踢出非活跃字符 | 阶段 1 "只保留活跃 Text 正在用的" | 之前预热过的字符,重建后被剔除 → 再显示变豆腐块 |
★ 坑 ③ 最阴间:
即使你启动时用
RequestCharactersInTexture预热了全图集,一旦某个 Text 改内容触发重建(阶段 1)→ 没被"当前活跃 Text"引用的字符就被踢掉。
→ 所以预热不是"一次就万事大吉",还得保活。
解法:四条应对
现在四条解法就很好理解了------每条都是 针对上面某个坑的反制**:**
| 解法 | 针对的坑 | 怎么做的 |
|---|---|---|
| ① 能用静态就别用动态 + 预配置字符集 | 根治:避开整个机制 | 固定字符集(ASCII/有限字符 + 有限字号)→ 图集导入时一次建好,运行时零重建 |
| ② 必须动态时:启动时批量预热 | 坑 ①(每个 Text 单独重建) | 收集所有唯一字符,一次注入,图集只重建一次,而非"每遇新字重建一次" |
③ 订阅 Font.textureRebuilt 保活 |
坑 ③(踢出非活跃字符) | 重建回调里重新请求保留需要的字符,防止被剔除 |
| ④ 避免大量字体变体、合并 Font 对象 | 针对 ★(同族多张图集) | 用同一 Font + fontStyle 开关(加粗/斜体),别建多个 Bold/Italic Font 资产 |
→ 你看:四条解法 = ① 躲开机制 + ②③④ 分别堵三个坑。逻辑严丝合缝。
解法 ② 如下代码
HashSet<char> chars = new HashSet<char>();
foreach (var text in allTexts)
foreach (var c in text.text) chars.Add(c);
font.RequestCharactersInTexture(new string(chars.ToArray()));
**它解决的正是"坑 ①:重建按每个 Text 单独触发"。** 原理:
不用预热时(灾难):
Text1 赋值 → 遇新字 → 图集重建 #1
Text2 赋值 → 遇新字 → 图集重建 #2
Text3 赋值 → 遇新字 → 图集重建 #3
...
Text100 赋值 → 重建 #100 ← 卡爆
用了预热后:
启动时:收集全部唯一字符 → RequestCharactersInTexture 一次注入
→ 图集重建 #1(就这一次)
│
运行时:Text1..Text100 赋值 → 字符全在图集里 → ★ 不再触发重建
→ "一次预热,而不是每遇新字重建一次"------这句话就是解法 ② 的全部价值。
但别忘解法 ③: 预热只保证"启动时图集完整",后续若有其他 Text 触发了扩容(阶段 1 重建),仍可能把你预热的字符踢掉 → 所以配合订阅 textureRebuilt 重新请求保活,才闭环。
① 静态字体(根本不用动态)
│ (最佳:从根上消除问题)
▼
② 动态 + 批量预热(减少重建次数)
│ (次优:把 N 次重建压成 1 次)
▼
③ textureRebuilt 保活(防被踢出)
│ (补漏:保证预热字符不丢)
▼
④ 合并 Font 对象(减图集数量)
(优化内存:别让同族开多张图集)
"既然动态这么坑,为什么 Unity 还留着它?"
使用功能上:动态和静态到底差在哪(用户视角)
先说结论:在"能不能显示字"这件事上,两者完全一样------玩家根本看不出区别。 差别全在**"你怎么准备字符"和"运行时遇到陌生字符怎么办"**。
场景对比
场景 1:你准备了一个"你好"按钮
静态字体(Character = Static / Custom,字符范围填了"你好"):
编辑期:图集里烤好了 "你" "好" 两个字 运行期:显示 "你好" → 直接查图集 → 正常动态字体(Character = Dynamic):
编辑期:图集几乎是空的 运行期:遇到 "你" → 图集里没有 → 现烤进去 → 显示 遇到 "好" → 图集里没有 → 现烤进去 → 显示→ 这一轮,两者结果一模一样,你完全感知不到区别。
场景 2:运行时突然冒出个"™"符号(静态没预烘焙)
静态字体:
显示 "™" → 图集里没有 → ★ 显示成空白/豆腐块(缺字)动态字体:
显示 "™" → 图集里没有 → 现烤进去 → 正常显示→ 这是唯一的、但致命的功能差异:
静态字体遇到"没预烘焙的字符" = 直接缺字(豆腐块)
动态字体遇到"没见过的字符" = 当场补烤,照常显示
静态字体的"代价"是:你必须在编辑期就把"所有可能用到的字符"列全,漏一个就缺字。
★ 那为什么还要有动态字体?(它的存在理由)
你现在的理解可能是:"动态这么坑,全用静态不就行了?" ------ 不行,因为"列全所有字符"这件事,在很多场景下根本做不到或不现实。
理由 1:★ 你根本无法预知"会出现什么字符"
这是动态字体最核心的存在理由。
典型例子:
- 玩家聊天框 :玩家能打任何字,你能预知他会打"™"还是日文"こ"还是 emoji "😀"吗?不能。
- 玩家起名框:同上。
- 联网游戏的外语玩家名
- 运行期从服务器下载的文本内容
- 用户粘贴的任意文本
→ 这些场景"字符集是开放/未知的"。 静态字体要求你"编辑期列全",但你列不全(你总不能把全 Unicode 十几万字符都预烘焙吧?那图集会巨大到爆内存)。
所以:开放输入 / 未知文本 = 必须用动态。
理由 2:全 Unicode 静态 = 内存遭不住
假设你要支持中文(CJK,约 2-3 万字常用 + 几万全部)。
静态字体 :导入时把所有字符烤进图集 → 一张巨大的图集常驻内存。
动态字体 :运行时只用"当前界面实际出现的字" → 图集只含活跃字符,内存相对小。
→ 对"字符集巨大但单次只显示其中一小部分"的场景,动态反而更省内存。(虽然它有"只增不减"的问题,但至少起点小。)
理由 3:开发迭代快,不用反复配字符集
静态字体要你手动维护字符范围。 项目里加了个新按钮用了个新符号 → 得回去把字符范围补上,否则缺字。漏配 = 运行时豆腐块(bug)。
**动态字体:不用管,遇到自动加。** 开发期更省心。
→ 对快速原型、开发阶段、或文本频繁变动的项目,动态更"鲁棒"。
子问题 3:专用字形渲染器(自定义数字)
适用 :字符集已知且位置固定(如分数显示 = 0-9)。
做法 :把每个数字做成 Sprite,整数拆位 → 显示对应数字精灵。
优势:
- 无文本网格重建(改数字只是换 Sprite)
- 无字体图集开销
- 可做成零分配、计算/动画极快
→ 这是"绕过 Text 系统"的极端优化,适合 HUD 上的数字、计时器。
子问题 4:Best Fit 的灾难 ★
Best Fit = 自动缩放到"刚好放进文本框的最大字号"。
为什么是灾难(原文详细列举):
- 为每个测试字号都生成字形 → 图集被各种字号撑爆
- 图集溢出 → 踢出其他 Text 用的字符 → 连锁重建
- 计算完合适字号后还要再重建至少一次
→ 结论:Best Fit 绝不要用。 原文:"最佳匹配设置绝不应使用"。
TMP 的 Best Fit 稍好 (用二分查找,无动态字体),但仍建议:先在编辑器测出最优字号 → 关掉 Auto Size → 手动设固定值。
② TextMesh Pro(TMP)
我们也和讲解Text的时候,同样先回答一个问题
文字从"一个字符串"到"屏幕上能看见的像素",到底经历了哪几步?
大家应该都知道这句话:TMP用的是SDF原理(有向距离场)
那么什么是SDF原理呢?
SDF 贴图不存字符的颜色 / 透明度 ,每个像素存储的是:当前坐标距离字符轮廓边缘的最短距离,带符号:
- 像素在字形内部:距离值 > 0
- 像素正好在字形轮廓线上:距离值 = 0
- 像素在字形外部:距离值 < 0
一张 SDF 纹理,本质是一张「距离灰度图」:灰度代表距离轮廓远近。
没事,不着忙我们一点点解释!当前坐标距离字符轮廓边缘的最短距离,这句话什么意思?我们举例子解释
我们举几个例子来解释!下面逐个拆解:I、P、S、O、B
1. 字母 I(大写 I)
视觉上:细细一条竖线。 TTF 存储:一个狭长矩形的闭合轮廓(4 条贝塞尔线段首尾相连,形成闭合回路)。
- 闭合轮廓内部:填充,就是我们看到 I 的黑色实体。
- 轮廓线本身:边界,SDF=0。
- 轮廓外面:背景。
想象:一根长方体橡皮,从正面看就是大写 I;橡皮的四条外边缘,就是字体矢量轮廓。
你可以把 I 看成一个很扁很高的长方形。长方形是闭合路径。
2. 字母 P
视觉上:左边一竖,右上角一个半圆环。 TTF 存储:两条独立闭合路径(复合轮廓)
- 外轮廓:竖边 + 圆弧,围出 P 整体外形;
- 内轮廓:小圆闭合环(挖空区域)。
矢量填充规则(非零环绕 / 奇偶填充):
- 外轮廓里面、内轮廓外面:填充(黑色 P 实体)
- 内轮廓包围的那块小圆区域:镂空,背景。
所以 P 有两套闭合围墙:外墙、内墙。内墙围起来的地方是空的。 SDF 计算时:镂空区域属于字形外部(sdf<0)。
3. 字母 S
视觉上:弯曲的带子。 TTF 存储:一条单独的闭合贝塞尔路径,沿着 S 的外边缘绕完整一圈,首尾相接闭合。 S 不是两条分开的内外曲线!
矢量轮廓是一条连续闭合环路,绕着 S 整个实心弯曲带状区域走一圈。 想象拿一根绳子,沿着橡皮泥 S 的外表面完整绕一圈,绳子头尾粘在一起。这根绳子就是 S 的矢量轮廓。绳子围起来橡皮泥区域 = S 实体。
误区:S 有一根内圈线 + 外圈线两条轮廓。 不是!S 是单条闭合路径勾勒出整个弯曲实心面片的外边界。P/O/B 才会有内圈(用来挖洞)。
4. 字母 O
视觉上:空心圆环。 TTF 存储:两条独立闭合路径
- 外圈大圆闭合轮廓
- 内圈小圆闭合轮廓
填充规则:外圈内部,并且在内圈外部 → 填充(圆环);内圈里面是空的。 两个闭合环,一大一小。相当于:外面一圈围墙,里面还有一圈围墙;两墙之间是实心,内墙里面是空院子。
5. 字母 B
视觉上:左边竖线,上下两个半圆洞。 TTF 存储:三条闭合路径
- 外轮廓:整个 B 的外围闭合轮廓;
- 上内圈闭合轮廓(上面那个洞);
- 下内圈闭合轮廓(下面那个洞)。
填充:外轮廓内部,并且不在两个内圈里面的区域,填充黑色。两个内圈围起来的地方是镂空背景。
上述我们理解了什么叫做当前坐标距离字符轮廓边缘的最短距离那接下来我们看看是如何做的吧!
TTF 的贝塞尔轮廓是怎么采样计算,生成 SDF 贴图里每个像素的距离值?
目标:SDF 贴图里每一个像素(采样点) ,算出它到整套字形贝塞尔轮廓的最短带符号距离,存入灰度。
1.拿到 TTF 的 Glyph:多条贝塞尔闭合路径(直线 / 二次贝塞尔曲线)
2.建立一张 SDF 纹理画布(比如 256×256),画布上每个像素中心就是一个查询采样点 P
3.对点 P:遍历该字形全部轮廓线段(贝塞尔曲线段),算出 P 到每一段曲线的最近距离
举例子:P 点离模具 A 小段刀口 3 毫米,离 B 小段刀口 7 毫米,离 C 小段刀口 5 毫米。
取所有距离里最小的那一个,得到「最短无符号距离」
4.判断点 P 在轮廓内部还是外部,给距离加上正负号(内部 +,外部−)
5.把这个带符号距离压缩到 0~1 灰度范围,写入 SDF 贴图该像素
举个例子解释一下:
从字体文件读出这个字母的饼干模具。模具边框是一小段一小段拼出来:有的是直线,有的是平滑弯弧线。比如字母 S 的模具就是一圈弯弯的闭合刀口。
拿一张白纸,把饼干模具放在白纸中间,周围留出一圈空白边距(padding,用来后面做描边发光)。 白纸分成密密麻麻小格子,每一格就是贴图的一个像素。 每个小格子正中心,标记一个点叫 P。我们要给每一个 P 算一个数字。
随便挑一个格子中心点 P。 把模具的每一小段刀口挨个拿出来看:算一算 P 点距离 这一小段刀口 最短有多远。 注意:不是到模具的角点!是到这条弯 / 直刀口线上最近那一点。
上面一组数字:3、7、5。最小是 3 毫米。 这个 P 点,距离模具刀口最近距离就是 3 毫米。 现在只有长度,还不知道它在模具里面,还是模具外面 。3 毫米只是纯长度,不分内外,叫无符号距离。
现在判断这个 P 点:是站在饼干模具圆圈里面 ,还是站在模具外面空白地方。
如果 P 在模具内部(饼干面团那一侧) → 数字写成 +3
如果 P 在模具外面空白地上 → 数字写成 −3
白纸像素只能存颜色(0 黑~1 白),不能直接写 + 3、‑3 这种数字。 我们事先定一个最大范围:比如最多向外向内算 8 毫米。
+8 → 纯白色 1.0
0(刚好踩在刀口上)→ 中灰色 0.5
−8 → 纯黑色 0.0
把 + 3 换算成对应的灰色,填进这个像素格子。
这一个像素就处理完了。
然后把纸上每一个小格子中心点 P 全部重复一遍上面整套操作 。整张白纸填满灰色,这就是 SDF 图集纹理。
打开 SDF 贴图看到效果:字母是中间亮,越靠近边缘越灰,外面慢慢变黑的光晕长条。
重点:这张贴图没有存储 "这里要画黑色文字",每个像素存的只是离模具刀口多远,在内还是在外编码成的灰色。 真正黑色文字,是 GPU 着色器拿到这张灰度图,现场做判断
那我们来总结一下这个顺序:
原始字形轮廓(模具) → 逐个像素 P:①求最短距离 ②判断内外、打上正负号 → 得到带符号 SDF 数值 → 最后才编码成灰度颜色
输出:单个字形的 SDF 灰度图
如果批量处理多个字符(A,B,S,I,O 等),把它们打包拼到一张大图集纹理上,得到SDF 图集纹理。
事情进展到这一步得到的只是一张 SDF 图集纹理。还不是完整可用的 TMP 字体资源(FontAsset)
只有这张图集,Unity TMP 依然不知道怎么渲染文字。
还缺什么?FontAsset = SDF 图集纹理 + Glyph 数据表 + 字体度量信息
TMP 渲染文字,除了 SDF 贴图,CPU 排版阶段必须读取下面这些数据:
- 每个 Glyph ɡlɪf(字符)的信息表
- 这个字符 Unicode 码(比如
A=65,S=83)- 该字形在 SDF 图集上的 UV 坐标(贴图上哪一块区域是这个字)
- 字形包围盒 BBox
- 水平偏移、垂直偏移、字宽(advanceWidth):这个字占多宽,下一个字符从哪个位置开始摆放
- 左右边距(bearing):字符相对于基线原点的偏移
- 字体全局度量信息
- 基线位置、上升高度、下降高度、行高
- EM 单位缩放系数
- 材质 + TMP SDF Shader Shader 负责采样 SDF 贴图、把灰度还原回 SDF 距离值,做阈值判断、抗锯齿、描边。
总结一下完整生成 TMP FontAsset的路线
- 读取 TTF:拿到所有字符的矢量贝塞尔轮廓 + 字体度量(基线、字宽等)
- 对每一个需要的字符 Glyph:
- 原始字形轮廓(模具)
- 逐个像素 P:①求最短距离 ②判断内外打上正负号 → 带符号 SDF 数值 → 编码灰度颜色
- 生成这个字符单独的 SDF 灰度图
- 图集打包:把所有字符的 SDF 小图,拼到一张大纹理(SDF 图集),记录每个字符对应的 UV
- 构建 Glyph 数据表:把每个字符的 Unicode、UV、包围盒、字宽、偏移全部存起来
- 打包所有资源,生成 Unity 的
FontAsset资源(包含 SDF 纹理 + Glyph 数据 + 字体参数)- 运行时:TMP_Text 使用 FontAsset + TMP SDF 材质渲染文字
这里区分一下两个概念哦!
- SDF 贴图 :存距离场灰度,GPU 着色器用(负责画出字的轮廓)
- Glyph / 字体度量数据 :存每个字符的位置、宽度、偏移,CPU 排版用(负责把字符串每个字符摆到正确位置)
Text 和 TMP 有什么区别?
UGUI 旧 Text(位图字体):预先把字渲染成普通黑白位图,运行时 CPU 拼 Mesh,GPU 直接采样位图颜色;
TMP Text(SDF 字体):预先生成 SDF 距离场图集 + Glyph 排版数据,运行时 CPU 拼 Mesh,GPU 在片元着色器里,靠 SDF 距离值实时重建文字轮廓。
两者CPU 排版、生成 Mesh 的大框架很像,但图集内容、GPU 渲染逻辑完全不一样,这就是根本差异。
先看UGUI 原生 Text(旧 Text)完整流程
读取 TTF 字体,生成位图图集(普通黑白图集)
- 对每个字符 Glyph:直接把矢量轮廓光栅化成黑白像素(固定分辨率)
- 白色 = 文字实体,黑色 = 透明背景;贴图存的是最终像素颜色 / 透明度,不是距离
图集打包:把各个字符的黑白小图拼到一张纹理,记录每个字符 UV、字宽、基线等 Glyph 信息
运行时 CPU:解析字符串、排版,生成每个字符 Quad 网格(和 TMP 一样)
GPU 阶段:
- 顶点着色器:坐标变换,传递 UV
- 片元着色器:直接采样贴图,拿到 alpha 透明度,直接画
没有距离计算!贴图里是什么像素,就渲染什么。
缺点:一旦放大,位图像素拉伸,直接锯齿模糊。
再对比TMP Text 完整流程
读取 TTF 字体,生成SDF 距离场图集(重点区别!)
- 对每个字符 Glyph:矢量轮廓计算带符号距离场,编码成灰度光晕图
- 贴图不存文字颜色,存距离编码
图集打包 + Glyph 数据表(字宽、UV、基线,这部分和旧 Text 结构类似)
运行时 CPU:解析字符串、排版、生成字符 Quad 网格
CPU 排版、Mesh 生成逻辑,和旧 Text大体一样,都是每个字符一个 Quad,合并成一个 Mesh
GPU 阶段(巨大差异)
- 顶点着色器:坐标变换,传递 UV
- 片元着色器:采样灰度 → 解码还原 SDF 距离值 → 用阈值判断字形内外 + 平滑抗锯齿,还能实时算描边 / 发光
文字形状不是贴图自带,是 GPU现场算出来的。放大不会糊,只要 SDF 图集分辨率足够。
两个容易混淆的相同点
CPU 部分几乎一样 旧 Text 和 TMP:都是解析文本、计算字符位置、生成由 Quad 组成的动态 Mesh,都需要 Glyph 表存字宽、UV、基线。
也就是说:排版逻辑,两者思路同源。差别不在 CPU,在贴图内容 + GPU 片元怎么画图。
两者都需要把多个字符打包到一张图集,减少 DrawCall。
一个形象的比喻
- 旧 UGUI Text:提前打印好一堆文字贴纸,贴纸上画好字。运行时把贴纸剪下来贴到屏幕上。放大贴纸,贴纸的像素就糊了。
- TMP :提前准备一张距离地图 (SDF 灰度图)。地图上没有画字,只记录每个点离字边缘多远。GPU 拿到地图,现场根据距离规则,当场画出字的轮廓。放大的时候,GPU 重新计算边缘,边缘依然平滑。
正文开始:
开始了解Unity TMP的工程级性能
前置前提:
TextMeshProUGUI:UGUI 画布里面的 TMP 文字,属于 UI Canvas 系统TextMeshPro(不带 UGUI 后缀):3D 世界空间文字,不属于 UGUI Canvas
核心优势:无 "字号撑爆图集",描边无 Overdraw
无 "字号撑爆图集" 问题(字号靠 shader 实时缩放,不存多份)→ 直接消灭了内置 Text 的动态图集噩梦
描边、阴影、发光等效果全靠改材质属性(一次采样),不走叠加层 → 消灭 overdraw → 这正好对应前面 "手段 5:合并精灵" 的 shader 维度。
原生 UGUI Text 的噩梦
原生 UGUI 动态字体: 图集里的每个字符,是固定像素大小的位图。 如果你同一个字,一会儿 12 号、一会儿 60 号。动态图集机制会把两种尺寸的同一个字,都生成、塞进图集。字号越多,图集疯狂膨胀,很容易图集塞满、反复重建,卡顿、爆显存。
本质:字号变化 → 需要生成新的位图。
TMP 为什么没有这个问题: TMP 图集存的是SDF 距离场,不是最终文字图片 。 字符只需要生成 1 次 SDF 灰度图 。放大缩小完全在 Shader 里数学计算,图集里不需要存大号版本的字。
同一个字符,图集里只存一份 SDF,不管你在代码里设字号 10 还是 200,直接 shader 缩放。不会因为字号变大,在图集新增字符。
Overdraw(重复绘制)
原生 Text 做描边:一般是画 2 层文字,底层粗一点黑色描边,上层原色文字。同一个区域画 2 遍,就是 Overdraw。层数越多,越耗 GPU。
TMP: 只1 次贴图采样 ,靠 SDF 距离值,在同一个片元着色器里面,同时判断:文字本体、描边、发光。 只渲染一层,不需要叠加多层,没有额外 Overdraw。
"shader 维度合并精灵":常规思路是多加一层 Mesh / 多画一层实现特效;TMP 直接在片元代码里用距离数据算出所有效果,不用增加绘制层数。
TMP fallback 回退字体链
什么是 Fallback?
一个FontAsset(TMP 字体)里面,只包含你预先打包的那一批字符 。 比如你的基础字体只打包了英文字母,当渲染中文 "你",这个字在当前 FontAsset 找不到 → 去 Fallback 备用字体里面找 。 Fallback 就是备用字体资产。
Fallback 分两类:FontAsset(文字字形回退) 、SpriteAsset(图文混排图标回退)
查找逻辑通俗描述:
- 先看当前正在用的字体有没有这个字 有就直接用
- 没有:去这个字体自己挂载的 Fallback 列表挨个找,而且递归(Fallback 里面还能再挂 Fallback)
- 还找不到:去 TMP 专用 Sprite 图集(图文混排的图标)里面找
- 还找不到:去全局 TMP Settings 里面配置的默认 Fallback 列表继续找
- 还找不到:全局默认 Sprite、全局默认字体
- 全部都找不到 → 画豆腐块(空白方框,代表缺字)
TMP 没有 "运行时实时读取 TTF 矢量文件生成字符" 的能力(TMP 无动态字体)。 所有字符必须预先存在某个 FontAsset 里面,找不到就只能回退找备用字体。
(区分:TMP动态图集 Dynamic Atlas ,是动态把已有 Glyph 打包进图集;不是动态读取 TTF 矢量生成新字形!不要混淆哦)
TMP Fallback(Fallback FontAsset + SpriteAsset)的用途
一、多语言本地化(最核心、大型项目最常用)
作用
把不同语言的字符拆分到独立 FontAsset,配合 AB 包按需加载、按需卸载,控制内存,避免一次性加载全部语种字体。 例子:
基础 FontAsset:常驻,拉丁字母、数字、基础标点
中文 FontAsset、日文 FontAsset、韩文 FontAsset 各自独立打包
启动 / 切语言:只加载当前语言包,动态加入 fallback 列表;不用的语种直接卸载。
特点:不同语种字符集几乎不重叠,拆分收益巨大。
二、艺术字体兜底降级(标题 / UI 艺术字场景)
作用
艺术字体(标题字体、装饰字体)通常只做少量字符(只有常用汉字 / 字母),缺少大量生僻字、符号。
主 FontAsset:艺术字体(只包含少量设计过的字形,好看)
Fallback:挂一个常规黑体 / 思源黑体作为兜底 逻辑:
有艺术字形 → 用艺术字体渲染;
艺术字体没有的生僻字、标点 → 自动切到普通黑体,防止豆腐块。
典型场景:游戏主标题、对话框名字用艺术字,正文生僻字自动降级普通字体。
三、补充少量缺失符号、特殊字符
作用
主字体大部分字符齐全,但缺少少量特殊符号(★、▶、✦、特殊箭头、几何符号、特殊货币符号)。 单独做一个极小 FontAsset,只打包这十几个符号,挂成 fallback。 好处: 不用重新生成一份巨大的主字体图集,只单独做一个很小的补充字体,专门放特殊符号。
四、多字体混排(同一段文本,多种字体风格)
注意:TMP 富文本
<font="xxx">可以直接指定字体;但也可以结合 fallback 实现备选。 例子: 一段文字里,大部分是普通黑体;部分公式、希腊字母需要另一套字体。 主字体找不到希腊字母,自动 fallback 到希腊字母专用 FontAsset。区分:
<font>是主动切换;fallback 是找不到字符被动自动切换。五、图文混排:SpriteAsset 作为 Fallback(图标、表情)
SpriteAsset 是 TMP 专用图标图集,可以放进 Fallback 链条。
作用
文本里
<sprite="xxx">标签,沿着 fallback 链查找 Sprite 资源,把图标嵌入文字流,像字符一样排版。 例子:聊天框表情、道具小图标、技能图标,直接插在文字中间,自动对齐文字基线。
主 FontAsset
→Fallback 列表加入 SpriteAsset(表情图集) 文本
获得<sprite=gold>100金币,自动渲染金币图标。SpriteAsset 也支持递归 Fallback,一个 SpriteAsset 找不到图标,继续找它自己的 fallback。
六、字体回退链多层递归,搭建多级兜底体系
Fallback 可以递归:A 的 fallback 是 B,B 的 fallback 是 C。 查找顺序:A → B → C。 用途:搭建分层兜底策略。 示例:
第一层:艺术字体(优先使用)
第二层:中文常用字 FontAsset
第三层:通用基础拉丁 + 符号 FontAsset
第四层:全局默认 Fallback(TMP Settings 兜底) 逐级降级,最大程度避免豆腐块。
七、全局默认兜底(TMP Settings 中的 Default Fallback)
属于全局层面的 fallback,是所有 TMP 文本最后的保底防线。 用途:项目内任意 TMP 字体,走完自身 fallback 列表还找不到字符,就进入全局默认 fallback 列表继续查找。
注意:不建议在这里一次性挂载全部语言字体,会造成预加载内存爆炸;全局一般只放一个极简通用字体做最后兜底。
Fallback 不能做的事情
- 不能动态读取原始 TTF 矢量,实时生成新 Glyph Fallback 资产本身必须是预生成好的 FontAsset,字符必须提前打包进去。原始 TTF 文件不能直接作为 fallback。
- 不能自动合并多个 FontAsset 的图集 每个 FontAsset 有独立 SDF 图集,fallback 只是字形查找逻辑,图集不会合并。
- 不能自动统一字体大小、字间距、SDF 粗细 不同 FontAsset 的 SDF Range、Padding、字体度量如果不一致,中英文混排时字大小、描边粗细会不一致,需要人工统一参数。
- 不是用来动态切换整段文字字体。
整段文字主动换字体,应该直接修改TMP_Text.font;fallback 是缺字符被动自动切换。
怎么制作一个 Fallback 备用字体(比如中文 Fallback 包)?
1. 准备源 TTF
比如
SimHei.ttf(黑体),这个 TTF 矢量文件本身包含全部中文、英文、标点。 但我们不会把全部字符打进这一个 FontAsset,按需挑选。2. 在 TMP 编辑器创建 Font Asset(Create Font Asset)
打开 TMP 字体生成窗口,核心配置:
源字体:选中
SimHei.ttfCharacter Set(字符集,最关键)
如果你做中文 Fallback 包 :字符集选择「Custom Range」,填写中文常用 Unicode 区间(
U+4E00 ~ U+9FFF)只勾选中文,不打包拉丁字母(拉丁字母交给主基础字体去承载)
设置 SDF 图集分辨率、Padding、SDF 半径(和主字体保持一致,避免描边粗细不一致)
生成 → 输出中文专用 FontAsset
这个 FontAsset 里面,只有中文汉字,没有英文字母,体积小很多。
这就是我们后面要挂载到主字体上的Fallback 备用资产
同理:日语 Fallback 包,就只勾选日文 Unicode 区间,生成只含日文的 FontAsset。
3. 把这个备用 FontAsset 挂载成 Fallback
两种方式:
代码动态挂载(项目推荐,前面笔记推荐的本地化方案)
// 主基础字体(只有英文)
TMP_FontAsset baseFont;
// 运行时加载出来的中文备用字体包
TMP_FontAsset chineseFallbackFont;// 把中文包追加进主字体的Fallback列表
baseFont.fallbackFontAssetTable.Add(chineseFallbackFont);玩家切语言,就加载对应语言 FontAsset,赋值到 fallback 列表;不用的语言包不加载。
- 编辑器静态挂载(不推荐多语言项目,容易内存爆炸) 打开主 FontAsset 资源,在 Inspector 面板找到
Fallback Font Assets,直接把中文 FontAsset 拖进去。缺点:静态挂载,Unity 会启动就加载这个 Fallback 字体进内存,不管用不用。
关键细节:Fallback 本身还可以继续挂 Fallback(递归)
中文 Fallback 这个 FontAsset,它自己的 Inspector 面板也可以继续添加 fallback。
- 例:中文 Fallback 找不到某个生僻汉字 → 继续去中文 Fallback 自己的 fallback 列表查找。 这就是文档说的递归查找。
两个高频误区
误区 1:Fallback 是 TTF 原文件?
不是。 Fallback 必须是TMP 的 FontAsset 资源 ,不是原始 ttf。TMP 运行时不能直接读取 TTF 矢量文件实时生成字形。
TMP 只能读取已经预计算好 SDF+Glyph 表的 FontAsset。
误区 2:Fallback 会自动从 TTF 里临时生成字符?
不会。 Fallback 的 FontAsset 里面只有生成时勾选打包的那些字符。哪怕原始 TTF 里面有这个字,但生成 FontAsset 的时候没勾选,这个字照样找不到,依然出豆腐块。
举例子:SimHei.ttf 有全部汉字,但你生成中文 Fallback 时,只勾选了常用 3500 汉字。生僻字不在这个 FontAsset 里,就算 TTF 有,TMP 也拿不到。
多语言本地化方案怎么落地?
基础 FontAsset:只打包拉丁字母、数字、基础符号(体积很小,常驻内存)
中文 FontAsset(Fallback):单独生成,只打包中文,单独打成 AB 包
日文 FontAsset(Fallback):单独生成,只打包日文,单独 AB 包
游戏启动检测玩家语言:
- 如果玩家选中文 → 加载中文 AB 包,取出中文 FontAsset,代码添加到基础字体 fallback 列表
- 如果玩家选日文 → 卸载中文包,加载日文包,赋值 fallback
- 没选中的语言包,不加载,不占用内存
额外补充:Sprite Fallback(图文混排图标)
Fallback 链里还有 Sprite 资产(TMP Sprite Asset),原理一样: Sprite Asset 是 TMP 专用图集资源,里面放图标,当作文字的 fallback。当文本遇到
<sprite>标签的图标编码,就去 Sprite Asset 查找。这个方案的优点
基础字体很小,常驻内存,占用低;
语言包互相独立,玩家只加载当前选中语言,其他语言字体完全不进内存;
不用预先在 TMP Settings 静态挂上全部语言 FontAsset(静态挂载会强制预加载全部字体,内存爆炸)。
这个方案的坑
同一个字符在多个 fallback 里面存在时,查找顺序决定用哪个字 fallback 列表从前到后依次查找,找到第一个匹配字符就停止。列表顺序不能乱。
不同 FontAsset 的 SDF 参数要对齐 基础字体、中文包、日文包:SDF Range、Padding、图集分辨率尽量保持一致。 否则文字粗细、描边效果会不一致,中英文拼接时观感割裂。
语言切换时,要清理旧 fallback 引用 代码操作顺序: ① 从基础字体 fallback 列表移除旧语言 FontAsset ② 卸载旧语言 AB 包 ③ 加载新语言 AB 包 ④ 将新语言 FontAsset 加入 fallback 列表 如果顺序错,还持有引用,AB 包资源无法真正卸载,内存无法释放。
生僻字:中文 Fallback 生成的时候,如果没打包这个汉字,哪怕原始 TTF 有,依然会显示豆腐块。Fallback 只认打包进 FontAsset 里的字符。
多语言分包加载,是利用 Fallback 底层能力做的内存优化方案,是 Fallback 最经典的工程用途之一
TMP 网格重建(UI 性能)
TMP 改 text 同样触发 Canvas.SendWillRendererCanvases + BuildBatch(它仍是 UI 控件)。 优化同 Text: 最小化 text 修改 频繁变文本的 TMP 放独立 Sub-canvas(隔离重建)
不管是原生 Text,还是 TMP_Text(UGUI 版本),都属于 UGUI Canvas 系统。
UGUI 原理:Canvas 里面任意一个 UI 元素改动(改文字、颜色),Unity 会重建整个 Canvas 里面所有 UI 的合并 Mesh(BuildBatch)。
举例子: 你的 Canvas 里面有:按钮、图片、10 个 TMP 文字。 只要其中 1 个 TMP 改了文字 → 整个 Canvas 全部 UI 重新合并 Mesh,开销很大。
优化手段:
- 尽量少频繁修改 text,不要每帧更新文字(比如每帧刷新伤害数字)
- 经常变化的文字,单独放到独立 Sub-Canvas(子画布)
SubCanvas 会隔离 Batch 重建:子画布里面文字改动,只重建这个子画布内部内容,不会影响外面主 Canvas。
World Space 世界空间文本的选择
★ World Space 特例:世界空间的文本 → 用普通 TextMeshPro(3D 组件),
别用 TextMeshProUGUI → 避免 Canvas 系统开销。
两个组件区分:
1.TextMeshProUGUI:是 UI 组件,依赖 Canvas。
放在世界空间使用时,它依然挂在 Canvas 下面,会被 UGUI 的 Batch、Canvas 重建机制拖累,有额外开销。
2.TextMeshPro(不带 UGUI 后缀) :纯 3D 文本组件,不属于 UGUI Canvas 系统。
直接在 3D 世界渲染,不走 UGUI 的画布合并逻辑,没有 Canvas 重建开销。
③ ScrollView:第二大性能杀手 ★
列表条目很多的时候,原生 ScrollView 直接全部实例化会巨卡
方案 A:全部实例化(把 1000 个 Item 全部生成出来塞到 Content 下面)
写代码最简单,直接循环 Instantiate 全部 Item 问题:
- 初始化瞬间要创建 1000 个 UI 物体,Instantiate 慢,占内存
- 滚动的时候,UGUI 布局、合批、Mesh 重建工作量和物体数量成正比,物体越多越卡。
适合:只有几条、十几条的短列表;几百上千条绝对不能这么干。
于是大家想到:对象池复用(只生成屏幕看得见的那几个 Item,滚动反复复用)
手机屏幕只能看见 8 条,那我只创建 8 个 Item,不要 1000 个。 数据一共 1000 条,滚动的时候,把旧 Item 拿过来,刷新显示新的数据,位置挪到对应地方。
这里有两种复用思路,坑完全不一样。
方案 A:占位符 + Reparent 复用(很多新手网上抄的旧方案)
做法
Content 下面预先放一堆看不见的占位符(LayoutElement,只占位置,没有实际 UI) ,用来撑开 Content 高度、让滚动条正常。 真正显示内容的 Item 只有 屏幕可见数量个(比如 8 个),放在对象池。 滚动的时候:把池子里的 Item,SetParent 挂载到对应占位符下面。
ScrollRect └── Content ├─ 占位符_0(GameObject,带LayoutElement,看不见,没有图片文字) ├─ 占位符_1(GameObject,带LayoutElement) ├─ 占位符_2(GameObject,带LayoutElement) ├─ 占位符_3(GameObject,带LayoutElement) ├─ ...... 一直到占位符_999(一共1000个占位符!)这 1000 个占位符全部在 Content 下面。 VerticalLayoutGroup 会自动排布这 1000 个占位符,把 Content 撑到 1000 条的总高度,滚动条就正常了。
但是!占位符只是空壳,没有 UI 内容。真正能显示文字图片的 Item 只有 8 个。 对象池里面存这 8 个 Item:
对象池(默认不在Content下面,或者临时放) ├─ Item0 ├─ Item1 ├─ Item2 ├─ Item3 ├─ Item4 ├─ Item5 ├─ Item6 └─ Item7滚动发生时,发生了什么(重点!)
假设现在屏幕显示的是第 2~9 条数据:
- Item0 → SetParent (占位符_2)
- Item1 → SetParent (占位符_3)
- Item2 → SetParent (占位符_4) ......
手指往上滑一点点,列表整体上移,现在屏幕要显示第 3~10 条:
- 原来挂在 占位符_2 的 Item0,要挪去 占位符_10
- 代码执行:
item0.transform.SetParent(占位符_10)这一步就是更换父物体!Item 的爸爸从【占位符 2】变成【占位符 10】
每滚动一小段,Item 就要从旧占位符解绑,挂载到新的占位符。 每一次挂载,就是一次 SetParent。
你可能疑惑: 不都是在 Content 下面吗?占位符全都属于 Content 的子物体。 👉父物体是占位符,不是 Content! 父物体是直接包裹它的那一级,不是最顶层 Content。 Item 的父从占位符 2 → 占位符 10,父物体变了,所以触发 OnTransformParentChanged。
举个极简代码示例(这个方案的核心代码)
// 滚动更新,当前条目索引 startIndex for(int i = 0; i < visibleItemCount; i++) { int dataIndex = startIndex + i; var item = GetItemFromPool(); Transform placeholder = placeholderList[dataIndex]; // ========= 这里就是罪魁祸首 SetParent ========= item.transform.SetParent(placeholder); item.UpdateData(data[dataIndex]); }
item.transform.SetParent(placeholder);把 item 放到 placeholder(占位符)下面。
逻辑上看起来很完美: Content 高度是 1000 条总高度;真实 UI 永远只有 8 个,物体很少。
致命大坑:Reparent(改变父物体)会触发 UI 脏标记、强制重建
Unity UGUI 底层规则:
只要执行
transform.SetParent()换父物体,或者修改物体在父物体下的兄弟排序 → 触发OnTransformParentChanged→ Unity 给这个物体 + 所有子物体打上 "脏标记"SetAllDirty()→ 强制重新布局、重新计算顶点、重建 Canvas Batch
放到滚动场景: 手指每滑动一丢丢,就要把 Item 从 A 占位符拿出来,挂载到 B 占位符下面。 每一次滑动都在不停 SetParent,不停触发完整重建 → 疯狂卡顿,达不到优化效果。
一个缓解办法,但有代价
给每一个 Item 根物体挂上独立 Canvas 。 作用:脏重建只发生在 Item 自己这个 Canvas 内部,不会污染整个大 Canvas。 代价:不同 Canvas 之间不能合批,DrawCall 暴涨,GPU 压力上去了,按下葫芦浮起瓢。
所以这种Reparent对象池属于看上去很美,实际有硬伤,不推荐做长列表。
方案 B:【基于位置的池化,行业标准答案】重点!
核心思想:永远不要 SetParent、不要改父子、不改兄弟顺序,只改 anchoredPosition 位置
Item 父物体从头到尾固定不变,就在 Content 下面,从来不变。 屏幕外面的 Item还存在,只是把它移动到视口外面很远的地方 ; 滚动时:只修改 Item 的
anchoredPosition坐标,把它挪到屏幕对应位置,刷新上面文字图片数据。
ScrollRect └── Content ├─ Item0 ├─ Item1 ├─ Item2 ├─ Item3 ├─ Item4 ├─ Item5 ├─ Item6 └─ Item78 个 Item 的直接父物体永远是 Content,从头到尾不改变。 滚动的时候,只写:
item.rectTransform.anchoredPosition = new Vector2(0, yPos);没有 SetParent,父物体永远是 Content。
底层为什么快:
- 没有SetParent,不会触发OnTransformParentChanged
- 不会打脏标记,不会触发不必要布局重建、顶点重建
- 物体数量依然等于屏幕可见条目数,不会多造对象
完整流程
- 自己写自定义 LayoutGroup (不要用原生 VerticalLayoutGroup/HorizontalLayoutGroup)
- 根据全部数据源,算出 Content 整体总高度,保证滚动条范围正确
- 只实例化【屏幕能装下 + 预留 2 个缓冲】的 Item,全部直接放在 Content 下面,父子关系永不改变
- 监听 ScrollRect 滚动事件
- 滚动每一帧:
- 判断哪些条目现在该显示在屏幕
- 取出池内 Item,只修改 anchoredPosition 坐标,挪到对应位置
- 调用
item.UpdateData(数据)更新文字图片 - 超出屏幕的 Item,直接移动到视口以外(负坐标或者很远位置),不销毁、不改父物体
前提条件:每条 Item 高度固定。如果 Item 高度动态不固定,逻辑会复杂很多。
配套通用优化:RectMask2D
不管你用哪一种滚动方案,视口上面一定要加**RectMask2D。 作用:视口外面的 UI 直接排除,不参与几何体计算、排序、重建,大幅减少 CPU 工作量。**
注意:不要只用 Mask,Mask 是模板测试,CPU 侧不会过滤数据;RectMask2D 才会裁剪掉视口外 UI 计算。
④ Image / ⑤ Mask
Image:
- Simple 模式最快(一个四边形)
- Tiled/Sliced 顶点更多
- SetAlpha(0) 仍渲染(前面强调过)→ 隐藏用 SetActive
Mask:
- Mask 组件会打断合批 (每个 Mask 创建一个"材质变体"用于遮罩)→ 合批杀手
- 能用
RectMask2D就用它(不打断合批,且裁剪更省) - 多层嵌套 Mask = 性能灾难
→ 串回前面"子物体顺序 / 中间层打断合批"。
Mask(旧模板遮罩) vs RectMask2D(UGUI 专用 2D 矩形裁剪)
Mask 是GPU 层面的模板测试,CPU 照样要处理全部 UI;RectMask2D 是CPU 提前剔除视口外元素,GPU 再裁剪。
Mask 的作用(Mask 组件,带 Image)
Mask 组件 = 模板缓冲(Stencil Buffer)裁剪
- 原理 Mask 必须搭配一张 Image(遮罩形状,圆形 / 圆角 / 任意图片)。
- GPU 先渲染 Mask 的 Image,写入模板缓冲;
- 渲染子物体的时候,GPU 判断:像素是否落在 Mask 形状内部。 在形状外面的像素,直接丢弃,不绘制。
这个判断是在 GPU 片元阶段做的! CPU 完全不感知,视口外面的子 UI 照样全部参与:布局、顶点 Mesh 生成、排序、Canvas 合并 Batch。 CPU 的开销一点没少,只是画出来的时候 GPU 把画面裁掉。
- Mask 优点
- 支持任意形状裁剪 :圆形、圆角、不规则图片遮罩(比如圆形头像框)。RectMask2D只能矩形裁剪,做不了圆形。
- 老版本 Unity 就支持。
- Mask 巨大缺点(重点)
- CPU 不会剔除:哪怕子物体跑到遮罩外面,UGUI 依然要计算它的 Mesh、合批,CPU 开销不会减少。
- 会打断合批,产生额外 DrawCall:模板缓冲会改变 Stencil 值,跨 Mask 的 UI 不能合并。
- 不能用于 ScrollView 长列表性能优化。
你给 ScrollView 加普通 Mask,不会减少滚动重建开销,只是画面被裁掉。
RectMask2D 的作用(矩形遮罩,ScrollView 首选)
只能做轴对齐矩形裁剪,不能圆形。
- 原理 在CPU 阶段就判断 UI 元素的 Rect 范围:
- 如果 Item 完全在矩形视口外面 → 直接把它排除,不参与 Mesh 生成、不参与排序、不参与 Canvas 重建。
- 只有和视口相交的 UI,才会进入后面的合批和渲染流程。
这就是为什么长列表 ScrollView 推荐它:直接减少 CPU 重建、合批工作量。 同时 GPU 也会裁剪掉矩形外像素。
- RectMask2D 缺点
- 只能矩形,不能圆形、不规则形状。
- 不支持模板缓冲,不能做异形遮罩
| 控件问题 | 对应前文的原理 |
|---|---|
| Text 网格重建 | **Canvas 重建(脏标记 + 全量)** |
| SetActive 触发重建 | 禁用 Canvas 保留 VBO |
| 动态字体图集撑爆 | 合批 / 纹理空间 + 重建频率 |
| Best Fit 灾难 | 每次重建 + 图集扩容 |
| TMP SDF | shader 降采样成本(手段 5 Shader 维度) |
| ScrollView 池 + reparent 脏 | 重建隔离 + 脏传播 |
| RectMask2D | 减少重建元素数 / overdraw |
| Mask 打断合批 | 中间层 / 材质变体 |
| 专用数字精灵 | 合并精灵降 overdraw |
| 症状 | 控件 | 解法 |
|---|---|---|
| 打开排行榜卡一帧 | Text 重建 | 禁用 Canvas / 池化 Text / 预热图集 |
| 字体图集撑爆内存 | 动态字体 | 静态字体 + 批量预热 + 合并 Font 对象 |
| 多语言内存爆炸 | TMP fallback | 按需加载语言包,动态设 fallback |
| 数字频繁刷新 | Text | **专用数字精灵(零重建)** |
| Best Fit 卡顿 | Text/TMP | 禁用,手动设固定字号 |
| 滚动卡 | ScrollView | 方案 B:基于位置池化 + RectMask2D |
| 滚动 item 复杂 | ScrollView | 每 item 独立 Canvas(权衡 draw call) |
| Mask 导致合批打断 | Mask | 改用 RectMask2D |
其他界面优化技巧
首先我要声明:这些技巧结构上"不干净"、难维护、可能有副作用 ,是权衡后的取舍
① 基于 RectTransform 的布局(替代 Layout 组件)
问题:Layout 组件为什么贵
回忆前面"重建"那部分: Layout 组件(HorizontalLayoutGroup、VerticalLayoutGroup、GridLayoutGroup)每次被标脏 → 必须重新计算所有子元素的尺寸和位置。
→ 元素越多、层级越深 → 重排越贵。 而且 Layout 是 C# 脚本驱动,每帧重建有 GC 和调用开销。
解法:用 RectTransform 锚点"自动布局"
核心思想:利用 RectTransform 的 锚点(Anchors)**让 Transform 系统(C++ 底层)自动算位置和大小,**完全绕开 Layout 系统。
例子(两列布局):
父 RectTransform(全屏)
├─ 左列 ── 锚点 X:(0, 0.5) Y:(0, 1) ← 贴父级左边半区
└─ 右列 ── 锚点 X:(0.5, 1) Y:(0, 1) ← 贴父级右边半区
锚点的含义: 子级的位置和大小随父级的比例自动变化。
X:(0, 0.5)= 左边缘贴父级 0%,右边缘贴父级 50% → 自动占左半Y:(0, 1)= 上下边缘贴父级 0% 和 100% → 高度撑满
**→ 两个 RectTransform 就实现"两列布局",**无需任何 Layout 组件、无需 C# 计算。
为什么更快
| Layout 组件 | RectTransform 锚点 | |
|---|---|---|
| 驱动方 | C# 脚本 | **Transform 系统(C++ 本地代码)** |
| 脏时 | 重算所有子级尺寸/位置 | 自动,零脚本开销 |
| 重建 | 参与 Canvas 重建 | 不参与 Layout 重建 |
| 性能 | 较慢 | 更快 |
→ "由 Transform 系统本身以本地代码驱动" = 更快。
★ 适用边界(重要,否则会踩坑)
"如果元素数量相对较少且固定、布局结构相对简单......"
适合:
- 简单两列、三列、固定格子
- 元素不动态增减
不适合:
- 动态列表(数量变化)
- 复杂响应式布局(不同分辨率要重新排列)
- 内容驱动的尺寸(文字长短不一)
**→ 这些复杂场景用 Layout 组件更省心,代价是性能。** 这是权衡。
进阶:自定义 MonoBehaviour 布局
可以写 MonoBehaviour 来设置 RectTransform 布局。
适用: 需要程序化布局 (如按数据动态排布)但又想避免 Layout 组件的重建开销。
代价: "相对复杂的任务,超出本指南范围" → 维护成本高 ,只在极高频/极复杂的布局才值得。
② 禁用画布(Disabling Canvases)★ 核心技巧
这一块你前面几轮其实已经铺垫很多了------现在看原文的完整表述。
问题:SetActive 开关 UI 的代价
常见做法:
panel.SetActive(false); // 隐藏
panel.SetActive(true); // 显示
代价:
"这会导致 Canvas 丢弃其 VBO 数据。重新启用需要 Canvas(及所有子画布)运行重建和重批处理。"
→ 回忆前面:"Text 网格重建 + Canvas 重建 + BuildBatch" ------ SetActive 复活 = 全部重来一遍 = 卡。
解法:禁用 Canvas 组件(保留 VBO)
做法:
把要显隐的 UI 放到自己的 Canvas / Sub-canvas 上
↓
隐藏时:禁用该 Canvas 组件(不是 GameObject)
↓
显示时:启用 Canvas 组件
效果(★ 三个关键好处):
| SetActive(false) | 禁用 Canvas 组件 | |
|---|---|---|
| VBO/网格 | 丢弃 | **保留(常驻内存)** |
| 原始批处理 | 丢弃,需重建 | 保留 |
| OnEnable/OnDisable | 触发 | 不触发 |
| 渲染 | 停止 | 停止 |
→ "网格不会被绘制,但常驻内存,原始批处理被保留" = 下次显示 零重建、秒开**。**
★ 代价(很重要)
禁用 Canvas 组件不是免费的,有三个副作用:
副作用 1:OnEnable/OnDisable 不调用
→ 你依赖这些回调的逻辑(初始化、事件注册) 不会执行**。**
副作用 2:★ MonoBehaviour 仍会收到生命周期回调
"这不会禁用隐藏界面内的任何 MonoBehaviours,因此它们仍会收到 Update 等回调。"
→ 也就是说:面板"看不见了",但里面的脚本 还在每帧跑 Update、消耗 CPU**!**这是最大的坑。**
解法:回调管理器(Callback Manager)模式
被禁用 UI 上的 MonoBehaviour
不直接实现生命周期回调(Update 等)
改为从"回调管理器"接收回调
↑
UI 根 GameObject 上一个 Manager
├─ 界面显示时被通知 → 向子级传播回调
└─ 界面隐藏时 → 停止传播(子级不跑 Update)
**→ 手动控制"哪些脚本在隐藏时继续跑"。** 代价:架构复杂度上升。
SetActive(false) → 彻底关(含脚本),但丢 VBO → 重开卡 禁用 Canvas 组件 → 保留 VBO(秒开),但脚本仍跑 → 要用回调管理器
→ 选哪个取决于:你更怕"重开卡"还是"隐藏时脚本空转"。
③ 分配活动摄像机(worldCamera)
问题:World Space / Camera Space Canvas 的性能坑
当 Canvas 是 World Space 或 Screen Space - Camera 模式时,它需要知道"用哪个 Camera 渲染/处理射线"。
如果你 没设 worldCamera:
// Unity 内部会做:
Camera.main → 实际是 GameObject.FindWithTag("MainCamera")
→ GameObject.FindWithTag 是 已知较慢的操作。
代价: 每个 World/Camera 模式的 Canvas,每帧至少查询一次 MainCamera → Canvas 越多、每帧开销越大。
解法:设计时/初始化时显式赋值
代码:
// 设计时拖拽,或初始化时赋值(只一次)
canvas.worldCamera = mainCamera; // Screen Space - Camera
// 或
canvas.worldCamera = uiCamera; // World Space
→ "强烈建议所有 World/Camera 画布在设计时或初始化时分配摄像机属性。"
★ 例外:Overlay 不需要
"Override Canvas 不会出现这个问题。"
(即 Screen Space - Overlay 模式,它不用 Camera,走顶层渲染 → 无需赋值、无 Find 开销。)
| Canvas 模式 | 需设 worldCamera? | 说明 |
|---|---|---|
| Screen Space - Overlay | 不用 | 无此问题 |
| Screen Space - Camera | 必须设 | 否则每帧 FindWithTag |
| World Space | 必须设 | 否则每帧 FindWithTag |
④ UI 源代码自定义(改 DLL)★ 最后手段
场景:什么时候需要改源码
"如果你遇到通过修改 C# UI 源代码来增加 CPU 周期的情况......"
翻译: 当你性能瓶颈就在 UI 框架本身 ,而扩展/继承都解决不了(因为要改的是框架内部逻辑)→ 才考虑改源码。
做法:重新编译 UI DLL
Unity UI 源码(Bitbucket 开源)
↓
修改 C# 源码(优化那处 CPU 热点)
↓
重新编译 → 生成新的 UI DLL
↓
覆盖 Unity 自带的 DLL
↓
获取对应你 Unity 版本的源码(重要!)
★三大代价
| 缺点 | 说明 |
|---|---|
| ① DLL 分发 | 必须让所有开发者和构建机器都用你的 DLL(CI/CD 要改) |
| ② 合并维护 | 每次升级 Unity 都要把你的改动合并进新版本源码 → 长期负担 |
| ③ 优先尝试扩展 | 确保你不能"继承/扩展现有类"或"自己写组件"解决 → 才改源码 |
→ 这是"为几 % 性能换架构复杂度"的权衡,慎用。
判断流程
遇到 UI 性能瓶颈
│
├─ 能用现有 API/继承扩展解决? → 用扩展(别改源码)
│
├─ 能自己写组件替代? → 自己写
│
└─ 都不行,瓶颈在框架内部? → 才考虑改 DLL
