UGUI 性能优化(详细,全,深入)

热烈的少年时代,不问归途,只管向前

这篇文章最妙的地方是,在看这篇文章之前你一定接触过里面的东西,但不知你是否思考过?会有很多颠覆认知的知识!

优化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界面指南

[Unity UI 基础](#Unity UI 基础)

[Canvas 画布](#Canvas 画布)

[子画布 SubCanvas(Nested Canvas)](#子画布 SubCanvas(Nested Canvas))

Graphic 图形基类(C#)

布局组件(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 互相独立:布局只改 RectTransformGraphic 读取 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 的核心能力是自动维护子元素之间的相对关系 (间距、对齐、根据内容撑尺寸、自动换行)。

关键洞察 :当"子元素增减/尺寸变化"发生时,只有两种情况------

  1. 关系规则简单(等间距、固定偏移)→ 你自己算也很容易
  2. 关系规则复杂(自动换行、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 重建的调度中心。 它维护两个列表:

  1. 脏布局组件列表(需要重新计算位置大小)
  2. 脏 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 算好的网格,做三件事:

  1. 按深度排序
  2. 检查重叠、是否同材质/同贴图(能合就合一个批次)
  3. 生成渲染命令发给 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 及以后 ,特征两条:

  1. 从后往前画(画家算法,远的先画、近的后画,后画的盖在先画的上面)
  2. 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(完全不透明),是不是就能享受深度剔除了?"

不行。 原因:

  1. Unity UI 整个渲染管线统一走 Transparent 队列 ------只要属于 Canvas,不论你材质是否透明,一律从后往前画,一律不做深度剔除
  2. 即使单张图 alpha=1,它的边缘(反走样)和合批特性仍是透明管线的一部分
  3. 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 步:

  1. 脏布局组件重建布局(布局计算,修改 RectTransform)
  2. 裁切组件剔除(Mask/RectMask2D,把被遮罩裁掉的元素标记)
  3. 脏 Graphic 重建图形(顶点网格)

布局一定要先算!因为 Graphic 生成顶点依赖 RectTransform 的大小位置,如果布局后改了 Rect,Graphic 才需要重新生成网格。

布局重建(分 3 阶段:PreLayout → Layout → PostLayout)

脏布局列表会按层级深度排序:父物体布局优先计算。

原理:父布局改变大小,会影响子布局。必须先算上层,再算下层。 例如:ContentSizeFitter 父物体,会撑开 VerticalLayoutGroup 子物体;父必须先算,否则子物体计算尺寸是错的。

布局重建只修改 RectTransform 的位置和尺寸,不会生成网格。布局完成之后,如果 Rect 大小变了,对应的 Graphic 就会被标记为脏。

Graphic 图形重建(分 2 阶段:PreRender + LatePreRender)

Graphic 的Rebuild()在预渲染阶段执行两件事:

  1. RectTransform 变了 → 重建顶点网格(重新生成 UI 三角面片、UV、颜色)
  2. 贴图 / 材质变化 → 更新 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:

  1. 修改 Text 文字内容 → Text(Graphic)标记脏
  2. 进入 PerformUpdate:
    • 布局:VerticalLayoutGroup 重新计算,修改 3 个 Text RectTransform 位置
    • 图形:Text 重建文字网格,更新 CanvasRenderer
  3. Canvas 拿到新网格,标记脏,C++ 合并批次,提交 GPU 渲染。

Unity UI 配置文件工具

工具 层级 看什么 平台限制
Unity Profiler 托管 + 原生 Canvas.BuildBatchCanvas.SendWillRenderCanvases、UI 时间线 全平台,Editor 内
Unity Frame Debugger 逐帧渲染 draw call 列表、合批被打断的原因 全平台,Editor 里不进 Play Mode 也能用
Xcode Instruments / VTune 原生方法级 Canvas::UpdateBatchesText_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 表格关键列:

  1. 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
  2. Self Batch Count:当前 Canvas 生成多少 DrawCall 批次
  3. Object:对应 UI 游戏对象

操作流程示例

  1. 选中峰值那一帧(在时间线点击,白色竖线锁定当前帧)
  2. 展开目标 Canvas,逐个看 Batch
  3. Batch Breaking Reason,找到大量打断合批的 UI 元素
  4. 优化(图集、减少 Mask、合并材质、减少嵌套 Canvas)
  5. 保持 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 重建 / 合批开销大。

常见坑

  1. UI 模块统计不全:BuildBatch 不在 UI 曲线,只看 UI 曲线会低估 UI 开销,CPU 模块一定要一起看
  2. UI Profiler 只在编辑器有效,打包设备上这个面板不工作
  3. 布局组件(LayoutGroup)会频繁标记脏,大量增加 SendWillRenderCanvases 耗时
  4. 不是 Batch 越少就一定越好,优先解决点击 / 弹窗瞬间的 CPU 尖峰

一套标准排查流程

  1. 打开 Profiler+UI Details,开始录制
  2. 操作 UI,复现卡顿 / 掉帧,选中峰值帧
  3. CPU 模块:看Canvas.SendWillRenderCanvases、Canvas.BuildBatch耗时
  4. UI Details 批处理查看器:看 Batch 数量、断裂原因,定位 UI 对象
  5. 隐藏 / 禁用怀疑的 UI 元素,继续录制,对比前后耗时、Batch 数量 ,确认是不是这个 UI 导致性能问题
  6. 优化后,再次录制对比指标,验证优化收益
  7. 怀疑真机不一致:打包开启 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 节点都要参与:

  1. Layout 重建遍历 ------ CanvasUpdateRegistry 更新时遍历整个 RectTransform 层级
  2. GraphicRaycaster 遍历 ------ 射线检测沿 Transform 一路爬到根 ,每级组件都要检查是否实现 ICanvasRaycastFilter(前面第 4 章讲过,成本与层级深度线性增长
  3. Transform 层级维护 ------ 父级 dirty 会冒泡影响子级

→ 空节点虽没有 Graphic(不贡献采样),但它仍在 Transform 树上,仍在"遍历路径上",仍增加层级深度 → 拖慢上述操作。

所以旧版经典建议:层级越浅越好,别建纯分组空节点。

★ 现代 Unity(2019.3+,尤其 UI Toolkit 时代之后)

**现实是:纯 RectTransform 空节点的开销已经非常小,很多时候可以忽略。**​ 原因:

  1. Layout / 射线遍历有早期剔除优化 ------ 对"无 Graphic、无 Raycast Filter"的节点跳过更快
  2. RectTransform 本身的变换计算极轻 ------ 现代 CPU 上几十、上百个空节点几乎测不出差异
  3. 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 实时调)

  1. 重排顺序 :把不可合批对象移到可合批对象之前或之后(不插中间)
  2. 调位置:消除隐形重叠空间

**→ 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 上",而不是严格三档分类。

→ 落到预制体上的三种方案(优先级)

  1. 首选 :预制体整体属单一频率 → 挂到对应频率层,内部不拆
  2. 混频时:预制体内部嵌子画布(用 Sub-canvas 隔离)
  3. 替代 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(设置 blocksRaycastsignoreParentGroups
  • 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 = 自动缩放到"刚好放进文本框的最大字号"。

为什么是灾难(原文详细列举)

  1. 为每个测试字号都生成字形 → 图集被各种字号撑爆
  2. 图集溢出 → 踢出其他 Text 用的字符 → 连锁重建
  3. 计算完合适字号后还要再重建至少一次

→ 结论: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 存储:两条独立闭合路径(复合轮廓)

  1. 外轮廓:竖边 + 圆弧,围出 P 整体外形;
  2. 内轮廓:小圆闭合环(挖空区域)。

矢量填充规则(非零环绕 / 奇偶填充):

  • 外轮廓里面、内轮廓外面:填充(黑色 P 实体)
  • 内轮廓包围的那块小圆区域:镂空,背景。

所以 P 有两套闭合围墙:外墙、内墙。内墙围起来的地方是空的。 SDF 计算时:镂空区域属于字形外部(sdf<0)。

3. 字母 S

视觉上:弯曲的带子。 TTF 存储:一条单独的闭合贝塞尔路径,沿着 S 的外边缘绕完整一圈,首尾相接闭合。 S 不是两条分开的内外曲线!

矢量轮廓是一条连续闭合环路,绕着 S 整个实心弯曲带状区域走一圈。 想象拿一根绳子,沿着橡皮泥 S 的外表面完整绕一圈,绳子头尾粘在一起。这根绳子就是 S 的矢量轮廓。绳子围起来橡皮泥区域 = S 实体。

误区:S 有一根内圈线 + 外圈线两条轮廓。 不是!S 是单条闭合路径勾勒出整个弯曲实心面片的外边界。P/O/B 才会有内圈(用来挖洞)。

4. 字母 O

视觉上:空心圆环。 TTF 存储:两条独立闭合路径

  1. 外圈大圆闭合轮廓
  2. 内圈小圆闭合轮廓

填充规则:外圈内部,并且在内圈外部 → 填充(圆环);内圈里面是空的。 两个闭合环,一大一小。相当于:外面一圈围墙,里面还有一圈围墙;两墙之间是实心,内墙里面是空院子。

5. 字母 B

视觉上:左边竖线,上下两个半圆洞。 TTF 存储:三条闭合路径

  1. 外轮廓:整个 B 的外围闭合轮廓;
  2. 上内圈闭合轮廓(上面那个洞);
  3. 下内圈闭合轮廓(下面那个洞)。

填充:外轮廓内部,并且不在两个内圈里面的区域,填充黑色。两个内圈围起来的地方是镂空背景。
上述我们理解了什么叫做当前坐标距离字符轮廓边缘的最短距离

那接下来我们看看是如何做的吧!

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 排版阶段必须读取下面这些数据:

  1. 每个 Glyph ɡlɪf(字符)的信息表
    • 这个字符 Unicode 码(比如A=65S=83
    • 该字形在 SDF 图集上的 UV 坐标(贴图上哪一块区域是这个字)
    • 字形包围盒 BBox
    • 水平偏移、垂直偏移、字宽(advanceWidth):这个字占多宽,下一个字符从哪个位置开始摆放
    • 左右边距(bearing):字符相对于基线原点的偏移
  2. 字体全局度量信息
    • 基线位置、上升高度、下降高度、行高
    • EM 单位缩放系数
  3. 材质 + TMP SDF Shader Shader 负责采样 SDF 贴图、把灰度还原回 SDF 距离值,做阈值判断、抗锯齿、描边。

总结一下完整生成 TMP FontAsset的路线

  1. 读取 TTF:拿到所有字符的矢量贝塞尔轮廓 + 字体度量(基线、字宽等)
  2. 对每一个需要的字符 Glyph:
    • 原始字形轮廓(模具)
    • 逐个像素 P:①求最短距离 ②判断内外打上正负号 → 带符号 SDF 数值 → 编码灰度颜色
    • 生成这个字符单独的 SDF 灰度图
  3. 图集打包:把所有字符的 SDF 小图,拼到一张大纹理(SDF 图集),记录每个字符对应的 UV
  4. 构建 Glyph 数据表:把每个字符的 Unicode、UV、包围盒、字宽、偏移全部存起来
  5. 打包所有资源,生成 Unity 的FontAsset资源(包含 SDF 纹理 + Glyph 数据 + 字体参数)
  6. 运行时:TMP_Text 使用 FontAsset + TMP SDF 材质渲染文字

这里区分一下两个概念哦!

  1. SDF 贴图 :存距离场灰度,GPU 着色器用(负责画出字的轮廓)
  2. Glyph / 字体度量数据 :存每个字符的位置、宽度、偏移,CPU 排版用(负责把字符串每个字符摆到正确位置)

Text 和 TMP 有什么区别?

UGUI 旧 Text(位图字体):预先把字渲染成普通黑白位图,运行时 CPU 拼 Mesh,GPU 直接采样位图颜色;

TMP Text(SDF 字体):预先生成 SDF 距离场图集 + Glyph 排版数据,运行时 CPU 拼 Mesh,GPU 在片元着色器里,靠 SDF 距离值实时重建文字轮廓。

两者CPU 排版、生成 Mesh 的大框架很像,但图集内容、GPU 渲染逻辑完全不一样,这就是根本差异。

先看UGUI 原生 Text(旧 Text)完整流程

  1. 读取 TTF 字体,生成位图图集(普通黑白图集)

    • 对每个字符 Glyph:直接把矢量轮廓光栅化成黑白像素(固定分辨率)
    • 白色 = 文字实体,黑色 = 透明背景;贴图存的是最终像素颜色 / 透明度,不是距离
  2. 图集打包:把各个字符的黑白小图拼到一张纹理,记录每个字符 UV、字宽、基线等 Glyph 信息

  3. 运行时 CPU:解析字符串、排版,生成每个字符 Quad 网格(和 TMP 一样)

  4. GPU 阶段:

    • 顶点着色器:坐标变换,传递 UV
    • 片元着色器:直接采样贴图,拿到 alpha 透明度,直接画

    没有距离计算!贴图里是什么像素,就渲染什么。

缺点:一旦放大,位图像素拉伸,直接锯齿模糊。

再对比TMP Text 完整流程

  1. 读取 TTF 字体,生成SDF 距离场图集(重点区别!)

    • 对每个字符 Glyph:矢量轮廓计算带符号距离场,编码成灰度光晕图
    • 贴图不存文字颜色,存距离编码
  2. 图集打包 + Glyph 数据表(字宽、UV、基线,这部分和旧 Text 结构类似)

  3. 运行时 CPU:解析字符串、排版、生成字符 Quad 网格

    CPU 排版、Mesh 生成逻辑,和旧 Text大体一样,都是每个字符一个 Quad,合并成一个 Mesh

  4. 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(图文混排图标回退)

查找逻辑通俗描述:

  1. 先看当前正在用的字体有没有这个字 有就直接用
  2. 没有:去这个字体自己挂载的 Fallback 列表挨个找,而且递归(Fallback 里面还能再挂 Fallback)
  3. 还找不到:去 TMP 专用 Sprite 图集(图文混排的图标)里面找
  4. 还找不到:去全局 TMP Settings 里面配置的默认 Fallback 列表继续找
  5. 还找不到:全局默认 Sprite、全局默认字体
  6. 全部都找不到 → 画豆腐块(空白方框,代表缺字)

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。 用途:搭建分层兜底策略。 示例:

  1. 第一层:艺术字体(优先使用)

  2. 第二层:中文常用字 FontAsset

  3. 第三层:通用基础拉丁 + 符号 FontAsset

  4. 第四层:全局默认 Fallback(TMP Settings 兜底) 逐级降级,最大程度避免豆腐块。

七、全局默认兜底(TMP Settings 中的 Default Fallback)

属于全局层面的 fallback,是所有 TMP 文本最后的保底防线。 用途:项目内任意 TMP 字体,走完自身 fallback 列表还找不到字符,就进入全局默认 fallback 列表继续查找。

注意:不建议在这里一次性挂载全部语言字体,会造成预加载内存爆炸;全局一般只放一个极简通用字体做最后兜底。

Fallback 不能做的事情

  1. 不能动态读取原始 TTF 矢量,实时生成新 Glyph Fallback 资产本身必须是预生成好的 FontAsset,字符必须提前打包进去。原始 TTF 文件不能直接作为 fallback。
  2. 不能自动合并多个 FontAsset 的图集 每个 FontAsset 有独立 SDF 图集,fallback 只是字形查找逻辑,图集不会合并。
  3. 不能自动统一字体大小、字间距、SDF 粗细 不同 FontAsset 的 SDF Range、Padding、字体度量如果不一致,中英文混排时字大小、描边粗细会不一致,需要人工统一参数。
  4. 不是用来动态切换整段文字字体。

整段文字主动换字体,应该直接修改TMP_Text.font;fallback 是缺字符被动自动切换

怎么制作一个 Fallback 备用字体(比如中文 Fallback 包)?

1. 准备源 TTF

比如 SimHei.ttf(黑体),这个 TTF 矢量文件本身包含全部中文、英文、标点。 但我们不会把全部字符打进这一个 FontAsset,按需挑选。

2. 在 TMP 编辑器创建 Font Asset(Create Font Asset)

打开 TMP 字体生成窗口,核心配置:

  1. 源字体:选中 SimHei.ttf

  2. Character Set(字符集,最关键)

    • 如果你做中文 Fallback 包 :字符集选择「Custom Range」,填写中文常用 Unicode 区间(U+4E00 ~ U+9FFF

    • 只勾选中文,不打包拉丁字母(拉丁字母交给主基础字体去承载)

  3. 设置 SDF 图集分辨率、Padding、SDF 半径(和主字体保持一致,避免描边粗细不一致)

  4. 生成 → 输出中文专用 FontAsset

    • 这个 FontAsset 里面,只有中文汉字,没有英文字母,体积小很多。

    • 这就是我们后面要挂载到主字体上的Fallback 备用资产

同理:日语 Fallback 包,就只勾选日文 Unicode 区间,生成只含日文的 FontAsset。

3. 把这个备用 FontAsset 挂载成 Fallback

两种方式:

  1. 代码动态挂载(项目推荐,前面笔记推荐的本地化方案)

    // 主基础字体(只有英文)
    TMP_FontAsset baseFont;
    // 运行时加载出来的中文备用字体包
    TMP_FontAsset chineseFallbackFont;

    // 把中文包追加进主字体的Fallback列表
    baseFont.fallbackFontAssetTable.Add(chineseFallbackFont);

玩家切语言,就加载对应语言 FontAsset,赋值到 fallback 列表;不用的语言包不加载。

  1. 编辑器静态挂载(不推荐多语言项目,容易内存爆炸) 打开主 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 也拿不到。

多语言本地化方案怎么落地?

  1. 基础 FontAsset:只打包拉丁字母、数字、基础符号(体积很小,常驻内存)

  2. 中文 FontAsset(Fallback):单独生成,只打包中文,单独打成 AB 包

  3. 日文 FontAsset(Fallback):单独生成,只打包日文,单独 AB 包

  4. 游戏启动检测玩家语言:

  • 如果玩家选中文 → 加载中文 AB 包,取出中文 FontAsset,代码添加到基础字体 fallback 列表
  • 如果玩家选日文 → 卸载中文包,加载日文包,赋值 fallback
  • 没选中的语言包,不加载,不占用内存

额外补充:Sprite Fallback(图文混排图标)

Fallback 链里还有 Sprite 资产(TMP Sprite Asset),原理一样: Sprite Asset 是 TMP 专用图集资源,里面放图标,当作文字的 fallback。当文本遇到<sprite>标签的图标编码,就去 Sprite Asset 查找。

这个方案的优点

  1. 基础字体很小,常驻内存,占用低;

  2. 语言包互相独立,玩家只加载当前选中语言,其他语言字体完全不进内存;

  3. 不用预先在 TMP Settings 静态挂上全部语言 FontAsset(静态挂载会强制预加载全部字体,内存爆炸)。

这个方案的坑

  1. 同一个字符在多个 fallback 里面存在时,查找顺序决定用哪个字 fallback 列表从前到后依次查找,找到第一个匹配字符就停止。列表顺序不能乱。

  2. 不同 FontAsset 的 SDF 参数要对齐 基础字体、中文包、日文包:SDF Range、Padding、图集分辨率尽量保持一致。 否则文字粗细、描边效果会不一致,中英文拼接时观感割裂。

  3. 语言切换时,要清理旧 fallback 引用 代码操作顺序: ① 从基础字体 fallback 列表移除旧语言 FontAsset ② 卸载旧语言 AB 包 ③ 加载新语言 AB 包 ④ 将新语言 FontAsset 加入 fallback 列表 如果顺序错,还持有引用,AB 包资源无法真正卸载,内存无法释放。

  4. 生僻字:中文 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,开销很大

优化手段:

  1. 尽量少频繁修改 text,不要每帧更新文字(比如每帧刷新伤害数字)
  2. 经常变化的文字,单独放到独立 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 问题:

  1. 初始化瞬间要创建 1000 个 UI 物体,Instantiate 慢,占内存
  2. 滚动的时候,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 条:

  1. 原来挂在 占位符_2 的 Item0,要挪去 占位符_10
  2. 代码执行: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
    └─ Item7

8 个 Item 的直接父物体永远是 Content,从头到尾不改变。 滚动的时候,只写:

复制代码
item.rectTransform.anchoredPosition = new Vector2(0, yPos);

没有 SetParent,父物体永远是 Content。

底层为什么快:

  • 没有SetParent,不会触发OnTransformParentChanged
  • 不会打脏标记,不会触发不必要布局重建、顶点重建
  • 物体数量依然等于屏幕可见条目数,不会多造对象

完整流程

  1. 自己写自定义 LayoutGroup (不要用原生 VerticalLayoutGroup/HorizontalLayoutGroup)
    • 根据全部数据源,算出 Content 整体总高度,保证滚动条范围正确
  2. 只实例化【屏幕能装下 + 预留 2 个缓冲】的 Item,全部直接放在 Content 下面,父子关系永不改变
  3. 监听 ScrollRect 滚动事件
  4. 滚动每一帧:
    • 判断哪些条目现在该显示在屏幕
    • 取出池内 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)裁剪

  1. 原理 Mask 必须搭配一张 Image(遮罩形状,圆形 / 圆角 / 任意图片)。
  • GPU 先渲染 Mask 的 Image,写入模板缓冲;
  • 渲染子物体的时候,GPU 判断:像素是否落在 Mask 形状内部。 在形状外面的像素,直接丢弃,不绘制。

这个判断是在 GPU 片元阶段做的! CPU 完全不感知,视口外面的子 UI 照样全部参与:布局、顶点 Mesh 生成、排序、Canvas 合并 Batch。 CPU 的开销一点没少,只是画出来的时候 GPU 把画面裁掉。

  1. Mask 优点
  • 支持任意形状裁剪 :圆形、圆角、不规则图片遮罩(比如圆形头像框)。RectMask2D只能矩形裁剪,做不了圆形。
  • 老版本 Unity 就支持。
  1. Mask 巨大缺点(重点)
  • CPU 不会剔除:哪怕子物体跑到遮罩外面,UGUI 依然要计算它的 Mesh、合批,CPU 开销不会减少。
  • 会打断合批,产生额外 DrawCall:模板缓冲会改变 Stencil 值,跨 Mask 的 UI 不能合并。
  • 不能用于 ScrollView 长列表性能优化

你给 ScrollView 加普通 Mask,不会减少滚动重建开销,只是画面被裁掉。

RectMask2D 的作用(矩形遮罩,ScrollView 首选)

只能做轴对齐矩形裁剪,不能圆形。

  1. 原理CPU 阶段就判断 UI 元素的 Rect 范围:
  • 如果 Item 完全在矩形视口外面 → 直接把它排除,不参与 Mesh 生成、不参与排序、不参与 Canvas 重建
  • 只有和视口相交的 UI,才会进入后面的合批和渲染流程。

这就是为什么长列表 ScrollView 推荐它:直接减少 CPU 重建、合批工作量。 同时 GPU 也会裁剪掉矩形外像素。

  1. 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 组件(HorizontalLayoutGroupVerticalLayoutGroupGridLayoutGroup每次被标脏 → 必须重新计算所有子元素的尺寸和位置

→ 元素越多、层级越深 → 重排越贵。 ​ 而且 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 SpaceScreen 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
相关推荐
大黄说说1 小时前
告别卡顿:Android UI 性能优化,布局层级过度绘制实战排查
开发语言
千谦阙听1 小时前
【 C++篇】:模板初阶——泛型编程、函数模板与类模板
开发语言·c++·学习·visual studio
SEO_juper1 小时前
2026年用Python检测网站薄内容与重复页:用向量相似度找出“搜索引擎眼中一样“的页面(附完整代码)
开发语言·前端·seo·独立站·谷歌优化
老王爱玩车1 小时前
第6讲:数组和函数实践-----控制台扫雷
c语言·开发语言·学习
m0_734571761 小时前
深入理解C++ 多态<三>(Polymorphism)模板方法模式
开发语言·c++
君顾11 小时前
24小时自助健身房系统开发实战:从0到1完整技术架构指南
java·开发语言·健身房
江屿风2 小时前
【Linux系统】【Linux 进程程序替换机制解析及自定义 Shell 核心逻辑实现 】流食般投喂
linux·运维·服务器·开发语言·笔记
SEO_juper2 小时前
2026年用Python检查多语言站点hreflang:自动揪出“回链缺失/标错语言“等致命错误(附完整代码)
开发语言·前端·seo·独立站·谷歌优化
aramae10 小时前
模拟实现strcmp()(C语言)
c语言·开发语言·后端