UGUI原理(一)

源码位置:Library\PackageCache\com.unity.ugui@bb329a87fcdc\Runtime\UGUI\UI\Core

CanvasUpdateRegistry.cs

CanvasUpdateRegistry

把UI 重建分成 5 道工序按顺序走。前 3 道管布局,后 2 道管图形。

public enum CanvasUpdate

{

Prelayout = 0, // 布局前
Layout = 1, // 正式布局
PostLayout = 2, // 布局后

PreRender = 3, // 渲染前
LatePreRender = 4, // 渲染前的最后一道

MaxUpdateValue= 5 //它自己不是工序,只是拿来当循环上界的("一直干到 5 为止")。

}
谁想进我的队列,谁就必须实现这 5 个方法。

public interface ICanvasElement

{

void Rebuild(CanvasUpdate executing); // 干活:按当前工序重建

Transform transform { get; } // 你是哪个物体上的

void LayoutComplete(); // 通知:布局做完了
void GraphicUpdateComplete(); // 通知:图形做完了

bool IsDestroyed(); // 你还活着吗

}
private static CanvasUpdateRegistry s_Instance; // 唯一的那个实例

private bool m_PerformingLayoutUpdate; // 正在做布局?

private bool m_PerformingGraphicUpdate; // 正在做图形?

private string\[\] m_CanvasUpdateProfilerStrings = ...; // Profiler 里显示的名字

private const string m_CullingUpdateProfilerString = "ClipperRegistry.Cull";

private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<...>();
private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<...>();

IndexedSet<T> 就是带索引的去重集合(Set) ,和普通 HashSet<T> 核心一样:同一个元素只能存一份,重复 Add 不会新增多条记录

  • HashSet<T>:去重 ;不支持下标索引访问 ,不能 set[0]、set[1];只能枚举。
  • IndexedSet<T>(UGUI 自己实现的,不是 .NET 原生类):
    1. 底层一般同时维护一个 List<T> + 一个 HashSet<T>
    2. 去重(HashSet 负责判重)
    3. 支持 [index] 按索引取元素
    4. 可以按顺序遍历、随机访问,同时保证不重复
    5. 内部是 UGUI 为布局、裁剪队列量身定做的容器

这就是"一帧改 100 次只重建 1 次"的源码依据------队列去重。

Canvas.willRenderCanvases += PerformUpdate;

"Unity,你每次要渲染 UI 之前,记得叫我一声 ,我要跑 PerformUpdate() 开工。"

+= 是 C# 的事件订阅语法------不是赋值,是"把我的方法挂到你的广播列表上"。

UI 的所有重建,时机是统一的 :不是"改一次就重建一次",而是"渲染前统一处理一次"。这就是延迟 + 批量的设计0。
一个 C# 的经典坑

想判断的是:这个 UI 元素(ICanvasElement)还活着、没被 Destroy,可以安全更新

我们分两个 "空",这是最关键的:

  1. C# 托管引用空(普通的 null) 变量压根没指向任何东西,element == null。这个是 C# 原生的空。
  2. Unity 特有的 "假空" 你调用 Destroy(graph) 之后:
  • C# 这边 element 这个引用不是 null(还有一个 C# 壳子)
  • 但是底层 C++ 的 UI 对象已经删掉了,一碰就报错 MissingReference

因为 Unity 的对象(GameObject/Component)被销毁后,C# 对象引用不是真的 null ,而是一个"壳"------直接 == null 判断会失效。

Unity 自己重载了 == 运算符来处理这个。但问题是:

当你的变量类型是 ICanvasElement(接口)时,C# 不会去走那个重载,只会做普通的引用判空------判断就不准了。

所以必须先强转回 Object 类型,再用 != null,这样才能走到 Unity 的重载逻辑。
(开工前先扔垃圾)

为什么 i--(倒着遍历)?

因为要一边遍历一边删。

正着删会出问题:你删了第 3 个,后面的元素全往前挪一位,你的下标就错位了,会漏掉元素。

倒着删就没这问题------后面被删的元素不影响前面还没处理的下标。这是个通用编程技巧。
整个类的心脏

private void PerformUpdate()
{

// ────── 第①步:布局开始 ──────

//告诉 Profiler"布局这段开始计时了"
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);

//扔垃圾
CleanInvalidItems();

m_PerformingLayoutUpdate = true;

// 按层级深浅排序,父节点少的排前面

m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);

// ────── 第②步:跑布局的三道工序 ──────

// Prelayout = 0, Layout = 1, PostLayout = 2,

for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
{
UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStringsi);

for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
{
var rebuild = m_LayoutRebuildQueuej;
try
{
if (ObjectValidForUpdate(rebuild))
rebuild.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, rebuild.transform);
}
}
UnityEngine.Profiling.Profiler.EndSample();
}

双层循环,顺序是"工序在外、包裹在内"

工序 0(Prelayout):包裹1 → 包裹2 → 包裹3 → ...
工序 1(Layout) :包裹1 → 包裹2 → 包裹3 → ...
工序 2(PostLayout):包裹1 → 包裹2 → 包裹3 → ...

注意 :不是"包裹1走完三道工序再处理包裹2",而是所有包裹一起走完第一道,再一起走第二道。

为什么?因为工序之间有依赖------必须先让所有元素都"收集完需求",才能开始"分配空间"。

包裹 = 一批 UI 元素打包成的一个分组单元。

// ────── 第③步:布局收尾 ──────

for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
m_LayoutRebuildQueuei.LayoutComplete();

m_LayoutRebuildQueue.Clear();
m_PerformingLayoutUpdate = false;
UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout);

**逐个发"布局做完了"的通知,然后清空队列。**​ 因为队列是去重的,处理完就得清干净,不然下一帧会重复处理。

// ────── 第④步:裁剪(夹在中间!)──────
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Render);

// now layout is complete do culling...
UnityEngine.Profiling.Profiler.BeginSample(m_CullingUpdateProfilerString);
ClipperRegistry.instance.Cull();
UnityEngine.Profiling.Profiler.EndSample();

位置很关键 :夹在 m_PerformingLayoutUpdate = false 和 m_PerformingGraphicUpdate = true 之间。

为什么必须在这儿?

  • 布局刚算完 → 拿到最新的位置和大小 → 才能准确判断谁在视口外
  • 赶在顶点生成之前剔除 → 否则顶点白算

注意:它虽然在布局之后执行,但被 BeginSample(Render) 包着 → Profiler 里 Cull 的耗时算在 Render 曲线里。

m_PerformingGraphicUpdate = true;

// ────── 第⑤步:跑图形的两道工序 ──────

for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)
{
UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStringsi);
for (var k = 0; k < m_GraphicRebuildQueue.Count; k++)
{
try
{
var element = m_GraphicRebuildQueuek;
if (ObjectValidForUpdate(element))
element.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, m_GraphicRebuildQueuek.transform);
}
}
UnityEngine.Profiling.Profiler.EndSample();
}

// ────── 第⑥步:图形收尾 ──────

for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
m_GraphicRebuildQueuei.GraphicUpdateComplete();

m_GraphicRebuildQueue.Clear();
m_PerformingGraphicUpdate = false;
UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Render);
}

发完工通知 → 清空 → 摘牌 → Profiler 计时结束。

代表什么

**父布局先算,子布局后算。**​ 因为父的尺寸决定子能分到多少空间,必须先算上层。

隐藏开销 :ParentCount 是 O(深度) 的循环,而排序要比较 O(n log n) 次 → 层级越深、元素越多,光排序就不便宜。这又是一个"层级要浅"的论据。

队列是 IndexedSet(去重) → 一帧改 100 次只重建 1 次 → 批量刷新优于逐帧刷新

顺序是:布局三档 → 裁剪 → 图形两档 → 裁剪夹在中间是依赖关系逼出来的(要用最新矩形判断、要在顶点生成前剔除)

布局排序、图形不排序 → 布局有依赖(父决定子),图形无依赖(位置已定)

Graphic.cs

问题 答案 源码出处
① 改 color 走哪条脏路径? 顶点脏 ​ SetVerticesDirty() color setter
② 顶点脏/材质脏是两条队列吗? 同一条队列 ,靠两个布尔位区分;布局脏走 LayoutRebuilder​ 三个 SetXxxDirty()
③ Rebuild 只在 PreRender 档干活? 是,switch 只有 case PreRender Rebuild()
④ 被剔除的元素还生成顶点吗? 不生成 ,cull 检查直接 return,脏标记欠着等 OnCullingChanged 补做 Rebuild() + OnCullingChanged()
⑤ 描边/阴影怎么叠上去? IMeshModifier 链在同一份顶点数据上加三角形,不是多画一层 DoMeshGeneration()
⑥ OnEnable 会全量标脏吗? 会,SetAllDirty() OnEnable()

public virtual Color color { get { return m_Color; }

set

{

// SetColor 内部:先对比新值和旧值,不一样才赋值,返回true

if (SetPropertyUtility.SetColor(ref m_Color, value))

SetVerticesDirty(); }

}

set 里调用了 SetVerticesDirty() ------ 改颜色走的是"顶点脏"这条路,不是材质脏。

改颜色 = 顶点脏 = 要重新生成网格(重算顶点色)= 必然 SetMesh = 原生层必然重新拼批次。

Color.Lerp 做呼吸渐变(问题来了)

呼吸灯渐变很贵的根源:每帧都命中脏标记,触发网格重建

两种改色的两种方案 本质差异

修改 Graphic.color(顶点色,会 SetVerticesDirty)

Color lerpCol = Color.Lerp(c1, c2, t);

image.color = lerpCol;

material.SetColor(Uniform,不碰顶点、不标记 VerticesDirty)

需要一个简单自写 UI Shader,增加一个 _Tint 变量,不用顶点色,用 Uniform 控制整体颜色

方案 1 Graphic.color 方案 2 mat.SetColor
数据落点 顶点色(vertex color,写进 mesh 顶点) Shader Uniform(每次 drawcall 传一个 vec4)
是否 dirty 顶点 是 → SetVerticesDirty() → UpdateGeometry → OnPopulateMesh + canvasRenderer.SetMesh() 否
是否重算 Canvas batch 是(触发 Canvas.BuildBatch) 否
是否打断合批 否(仍用 defaultGrap hicMaterial,靠顶点色区分,可合批) 是(一旦材质实例不共享,就拆成独立 drawcall)
每帧 CPU 开销 重建该 Graphic 的 mesh + BuildBatch 一次 SetColor,几乎为 0

"Uniform 比顶点色便宜",但代价从 CPU 重建转移到了 DrawCall
注意:

直接 image.material.SetColor(...) 会污染全场景

Graphic.material 默认返回的是共享材质 (defaultGraphicMaterial 或图集材质)。直接 SetColor 会让所有用到它的 UI 一起变色,改到磁盘 material asset 上还会落盘污染。必须先实例化一份:

而方案 1 反而不打断合批(材质还是默认的,颜色靠顶点色区分)。所以:

  • 1 个呼吸灯 :方案 1 的真实开销只是重建 4 个顶点的 quad,通常 < 0.05ms ,大概率根本不值得优化------先开 Profiler 看 Canvas.BuildBatch 再决定。
  • N 个同步呼吸灯(同色) :方案 2 完胜------共享同一份材质实例 ,一次 SetColor 全变,仍是 1 个 drawcall,零重建。这才是方案 2 最香的场景。
  • N 个异步呼吸灯(各闪各的):方案 2 会变成 N 个 drawcall,这时候该走下面的方案 3。

更彻底的做法:把呼吸搬进 Shader,CPU 每帧零开销

既然走 uniforms 了,就别每帧从 C# 推数据了------让 GPU 自己算:

复制代码
// 关键 frag 片段(注意保留原 UI shader 的 stencil/clipping 代码)
_BreathSpeed ("Speed", Float) = 2.0
_BreathMin   ("Min",   Float) = 0.4
_BreathMax   ("Max",   Float) = 1.0

float k = 0.5 + 0.5 * sin(_Time.y * _BreathSpeed + IN.phase);
half4 c = tex2D(_MainTex, IN.texcoord) * IN.color;
c.rgb *= lerp(_BreathMin, _BreathMax, k);

相位怎么做成 per-instance 又不打断合批? ​ 把每个元素的随机 ID 预先写进 uv2(或复用顶点色的某个通道),shader 里 IN.uv2.x * 6.283 当相位偏移。这样所有呼吸灯共享同一份材质实例,可合批、零 CPU 每帧开销、还各自闪各自的。

三个脏标记

怎么看出来的

  • 顶点脏和材质脏,调的是同一个方法 :RegisterCanvasElementForGraphicRebuild
  • 布局脏完全不同 :转交给 LayoutRebuilder.MarkLayoutForRebuild,根本不进那两条队列本体

代表什么

顶点脏和材质脏是"一条队列 + 两个布尔位" ,靠 m_VertsDirty / m_MaterialDirty 区分"找到它之后干什么"。

布局脏走的是另一条路 (LayoutRebuilder)

两个关键细节

① canvasRenderer.cull 检查在最开头

被矩形遮罩剔除的元素,连顶点都不生成,直接 return。

这就是 RectMask2D "省 CPU"的源码实锤------不只是"不画",是根本不生成网格。

② switch 里只有 case CanvasUpdate.PreRender

③ 两个布尔位用完就置 false

这就是为什么"队列去重"能生效------标记是"待办事项",处理完就划掉。

OnCullingChanged

是 UGUI CanvasRenderer 剔除状态变化的回调,

没有 OnCullingChanged 会发生什么问题?

举滚动列表例子:

  1. 一个 Graphic(Image/Text)在列表里滑出屏幕 → cull=true
  2. 代码修改它的文字 / 颜色,设置 m_VertsDirty=true
  3. UGUI 走 Rebuild 流程,发现 cull,直接 return,不刷新网格 ✘ 脏标记还留在 true,但是重建动作被跳过了
  4. 之后这个 Item 再滑回屏幕,cull=false → UGUI 默认不会主动发现:"哎,你还有脏标记没处理!" → 表现 bug:UI 显示是旧的,修改不生效,要等下一次别的触发才刷新

这就是漏洞:剔除期间产生的脏数据,丢失了重建时机。

复制代码
public virtual void OnCullingChanged()
{
    // 如果现在【不再被剔除】,并且还有顶点脏标记 或者 材质脏标记
    if (!canvasRenderer.cull && (m_VertsDirty || m_MaterialDirty))
    {
        // 注释:被剔除的时候,我们很可能跳过了Rebuild调用
        // 把自己重新注册到Canvas的Graphic重建队列,下一帧去执行Rebuild
        CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
    }
}

触发时机:canvasRenderer.cull 状态切换的时候(true ↔ false)

  • 从可见 → 被剔除(cull 变成 true):if 条件不满足,啥也不干
  • 从被剔除 → 重新可见(cull 变成 false):进入 if 判断
    • 只要身上还挂着脏标记:立刻排队重建

为什么说这个设计优雅?(重点,滚动列表核心收益)

  1. 滑出去的时候:0 开销 Item 不在视口,就算你疯狂改它的数据,UGUI 不会跑昂贵的顶点重建、不会提交网格,节省 CPU。 如果没有剔除机制,列表哪怕几十上百个滑出去的 Item,每一帧都在 Rebuild 顶点,直接卡。
  2. 滑回来的时候:自动补更新,业务层无感 业务代码不用自己写逻辑:监听滚动、判断这个 Item 是不是刚滑进来、要不要手动 MarkDirty+Rebuild。 UGUI 底层回调自动兜底脏数据。 业务只管正常改 Text/Image,不管它在不在屏幕里。

DoMeshGeneration

DoMeshGeneration() 是 Graphic 真正生成 UI 网格的核心函数 : 先让图形本体(Image/Text)往 VertexHelper 写入基础顶点,再遍历身上所有 IMeshModifier(Shadow、Outline、自定义网格修改器)追加 / 改动顶点,最后把顶点数据打包进共享临时 Mesh,交给 CanvasRenderer 送到底层渲染。

阴影、描边不是额外一个 GameObject、不是额外一个 DrawCall,是在同一份顶点流上追加三角形。

复制代码
private void DoMeshGeneration()
{
    // ① 有效矩形:让子类填充基础顶点到 VertexHelper
    if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
        OnPopulateMesh(s_VertexHelper);
    else
        s_VertexHelper.Clear(); // 宽高<=0,清空顶点,不渲染
    
    // 拿一个池化List,收集身上所有实现 IMeshModifier 的组件
    var components = ListPool<Component>.Get();
    GetComponents(typeof(IMeshModifier), components);

    // ② 遍历所有Modifier,修改VertexHelper里的顶点
    for (var i = 0; i < components.Count; i++)
        ((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper);

    ListPool<Component>.Release(components); // 归还列表,消灭GC
    
    // ③ 把 VertexHelper 的顶点、索引 拷进 workerMesh
    s_VertexHelper.FillMesh(workerMesh);
    // ④ 交给CanvasRenderer,提交给Unity原生渲染层
    canvasRenderer.SetMesh(workerMesh);
}

代表什么(这就是"描边/阴影怎么来的")

描边、阴影不是多画一层,而是在同一个网格上"加顶点" ------它们实现了 IMeshModifier 接口,在基础图形生成完后继续往 VertexHelper 里塞三角形。

VertexHelper 是什么

一句话:UGUI 专门用来攒 UI 顶点、三角索引的「轻量临时容器」,是 OnPopulateMesh 和 IMeshModifier 之间的标准传参对象。它不是 Mesh,只是 C# 侧的顶点数据缓冲区,设计目标就是方便拼 UI 面片。

  1. OnPopulateMesh :子类重写。
    • Image:生成一个矩形 4 个顶点、2 个三角;
    • Text:给每个文字生成 4 顶点,一堆三角; 只负责【本体图形】。
  2. IMeshModifier(重点:Shadow / Outline) 它拿到同一个 VertexHelper,不替换原有顶点,追加新的顶点 + 三角 :
    • Shadow:把原有顶点整体偏移一段距离,复制一份塞进去;
    • Outline:上下左右偏移多份顶点; 关键点:全部在同一个 VertexHelper 里,最后产出同一个 Mesh → 同一个 CanvasRenderer,同一个 DrawCall(前提:材质、贴图合批条件满足) 不是多加一个 Image、不是两层 UI 叠加,是网格本身就自带阴影三角。
  3. FillMesh:把 VertexHelper 内部的数组(verts、uvs、colors、indices)复制到 Mesh 对象里。
  4. SetMesh:通知 CanvasRenderer:"这是我这次要渲染的网格"。Unity C++ 侧会把这份数据拷贝走。

注意:Modifier 是追加顶点,不是新建一层,所以顶点数量会暴涨(Outline 顶点很多,容易爆顶点上限)

走到这里 C# 侧任务结束;之后再出问题,就是原生合批、Shader、Canvas、Mask、剔除的问题,C# 脚本层改不动了

这条链路直接决定:Shadow/Outline 为什么不额外增加 DC

  • 全部顶点走同一个 VertexHelper → 产出单个 Mesh → 交给同一个 CanvasRenderer
  • 因此无论叠了多少层 Shadow/Outline,CanvasRenderer 数量始终是 1、材质和贴图也没变,DC 不变
  • 注意:它们是无条件 不增加 DC,与"是否满足合批条件"无关。合批条件影响的是它还能不能和别的 UI 合并

它们真正增加的是两样东西:顶点数 (Outline ×5)和 overdraw(同一片像素填 5 遍,移动端 tile-based GPU 上这个才是瓶颈,往往比顶点数更早出问题)

合批到底怎么判 (Canvas 把多个 CanvasRenderer 的 mesh 合并成一个大 mesh,即 Canvas.BuildBatch):

  • 同 Canvas,且同 sorting layer / order,不被 ParticleSystem 隔断
  • 同 material 实例(不是"同 shader")
  • 同 texture,或同一图集
  • 同裁剪状态(Mask 的 stencil / RectMask2D 的 clip rect)
  • depth 不交叉(层级顺序不能打断)

DC 飙升的主因几乎全在 材质/图集不同、Mask、depth 交错​ 这三条里,不在顶点数里

UGUI 的 Shadow/Outline 原理是什么?

四步链路,点明:不是额外 GameObject,IMeshModifier 追加顶点到同一个 VertexHelper。

它们是 IMeshModifier,在 OnPopulateMesh 之后把偏移后的顶点副本追加进同一个 VertexHelper 。Shadow 叠 1 份 → ×2;Outline 继承 Shadow,四方向各叠一份 + 原始 → ×5。代价是顶点数和 overdraw,不是 DC。

  • OnPopulateMesh:图形本体生产
  • IMeshModifier:顶点后处理(形变、复制追加)
  • FillMesh:数据容器转换(VertexHelper → Mesh)
  • SetMesh:提交,移交 C++ 渲染管线

IMeshModifier 执行时机?能不能改材质?多个 Modifier 顺序影响吗?

OnPopulateMesh 之后、FillMesh 之前;只能修改顶点数据;执行顺序是 GetComponents 顺序,顺序会影响叠加效果。

UpdateMaterial() + materialForRendering

拿到这个 Graphic 身上所有 IMaterialModifier,串行加工材质 :原始材质丢进去,返回加工后的新材质,一路传递下去,最后交给 UpdateMaterial → canvasRenderer.SetMaterial。

所有被 Mask 裁剪的 UI(Image、Text)都继承 MaskableGraphic,MaskableGraphic : IMaterialModifier,它重写了 GetModifiedMaterial

它会 new Material 复制一份,这就是材质变体。

不是改原来的材质,是克隆新实例,给它加上模板缓冲相关参数。

为什么生成新 Material 实例 = 打断合批

UGUI 合批的硬性条件之一:Material 对象引用必须完全一样。

  • 两个 Image,共用同一个 Material 引用 → 可以合批
  • A 走 GetModifiedMaterial,克隆出 MaterialA_Stencil;B 克隆出 MaterialB_Stencil 哪怕它们 shader、贴图、颜色全都一样,对象引用是两个不同实例 → 直接断合批,DC 上涨

对比前面 IMeshModifier(Shadow/Outline): Shadow 只是往 VertexHelper 追加顶点,不新建 Material 对象,材质引用不变,所以满足条件时可以继续合批。 这就是两者巨大差别。

对比 RectMask2D,反过来帮你理解

RectMask2D 不走 IMaterialModifier、不克隆材质! 它是在 CPU 阶段做矩形裁剪,不碰材质对象,所以不会产生材质变体、不会因为这个原因打断合批
protected override void OnEnable()

{

base.OnEnable();

CacheCanvas();

GraphicRegistry.RegisterGraphicForCanvas(canvas, this);

if (s_WhiteTexture == null)

s_WhiteTexture = Texture2D.whiteTexture;

SetAllDirty(); // ← 全量标脏!

}

protected override void OnDisable()

{

GraphicRegistry.DisableGraphicForCanvas(canvas, this);

CanvasUpdateRegistry.DisableCanvasElementForRebuild(this); // ← 注意是 Disable 不是 UnRegister

if (canvasRenderer != null)

canvasRenderer.Clear();

LayoutRebuilder.MarkLayoutForRebuild(rectTransform);

base.OnDisable();

}

protected override void OnDestroy()

{

GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);

CanvasUpdateRegistry.UnRegisterCanvasElementForRebuild(this); // ← 这才是彻底移除

if (m_CachedMesh) Destroy(m_CachedMesh);

...

}

OnEnable 最后一行 SetAllDirty() ------ 重新激活 = 布局脏 + 材质脏 + 顶点脏 + 射线脏,全来一遍。

SetActive(true) 会全量重建的源码实锤。​ 这就解释了为什么排行榜/统计界面"打开时激活整个面板"会卡一帧------面板里几百个 Text 全部 SetAllDirty。

解法就是:禁用 Canvas 组件而不是 SetActive(false)。

Disable vs UnRegister 的精准对应

生命周期 调用 含义
OnDisable DisableCanvasElementForRebuild 留在队列里但跳过(临时隐藏,对象池语义)
OnDestroy UnRegisterCanvasElementForRebuild 彻底移出(真销毁)
复制代码
protected override void OnRectTransformDimensionsChange()
{
    if (gameObject.activeInHierarchy)
    {
        if (CanvasUpdateRegistry.IsRebuildingLayout())
            SetVerticesDirty();
        else
        {
            SetVerticesDirty();
            SetLayoutDirty();
        }
    }
}
  1. OnRectTransformDimensionsChange() 是 RectTransform 的回调:只有 RectTransform 的尺寸相关属性变化才会触发;anchoredPosition、rotation、scale 变化不会触发这个回调(这是 Unity 引擎底层 RectTransform 绑定的事件,不是这个函数内部逻辑)
  2. SetLayoutDirty():标记布局脏标记 → 触发 Layout 重建(Horizontal/Vertical LayoutGroup、ContentSizeFitter 这类布局组件走这个)
  3. SetVerticesDirty():标记顶点脏标记 → Graphic(Image/Text)重新生成网格顶点、UV、颜色,也就是渲染数据重建
  4. CanvasUpdateRegistry.IsRebuildingLayout():查询当前是不是正处在 Layout 重建阶段(CanvasUpdate 生命周期的 Layout 阶段)

代表什么

① 这个方法只在"尺寸变化"时触发,改位置不触发。 ​ 这就是"只改 anchoredPosition 不重建"的源码实锤。

② 尺寸一变 → 顶点脏 + 布局脏一起来。

换父节点

protected override void OnBeforeTransformParentChanged()

{

GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);

LayoutRebuilder.MarkLayoutForRebuild(rectTransform); // 换之前:标布局

}

protected override void OnTransformParentChanged()

{

base.OnTransformParentChanged();

m_Canvas = null; // 缓存的 Canvas 作废,要重找

if (!IsActive()) return;

CacheCanvas();

GraphicRegistry.RegisterGraphicForCanvas(canvas, this);

SetAllDirty(); // 换之后:全脏!

}

换父:AllDirty + 注销 / 注册 Graphic + 向上遍历查 Canvas + 旧布局重建,开销高一大截。

什么场景下会发生 "换父节点"?(UI 池化最常见)

ScrollView 长列表 错误复用方案

ScrollRect + Content(带 VerticalLayoutGroup) 有人写对象池思路:

  • 池子里拿出来 Item → item.transform.SetParent(content)
  • 滑出视口了 → item.transform.SetParent(poolRoot)(挪去池父节点) 👉 每一次进出视口 = 一次 SetParent → 触发 Graphic 的 OnTransformParentChanged → SetAllDirty() + 注销注册 Graphic + 找 Canvas。 列表一次滑动几十条 Item 反复 SetParent,每一条都全脏、布局重建、注册表来回插拔 → CPU 暴涨、掉帧、卡顿。

这就是你那句结论来源:用 SetParent 做列表复用是大坑

正确对比方案(基于 anchoredPosition 的池化,就是 Unity 推荐的 Virtualized List / LoopScroll): Item永远不换父,一直挂在 Content 下面 。 滑出屏幕:只是把 anchoredPosition 挪到视口外面隐藏; 需要复用:直接改 anchoredPosition 放到新位置,刷新数据。

前面源码已经看到:改 anchoredPosition 不会触发 OnRectTransformDimensionsChange,不会自动打 Layout/Vertex 脏,没有 SetAllDirty、没有注册表注销注册,开销极低。

graphic.color和CanvasRenderer.color这两个颜色有什么区别?

项目 graphic.color(m_Color) canvasRenderer.color
存储位置 C# Graphic 成员 m_Color,最终写入 Mesh 顶点 COLOR CanvasRenderer 对象里的一个 4 维颜色,不在 Mesh 里
修改入口 image.color = xxx setter graphic.canvasRenderer.SetColor(col) / CrossFadeColor
是否触发 Dirty 赋值不同就 SetVerticesDirty(),触发 OnPopulateMesh、重建网格 完全不触发任何 Dirty,Mesh 不再重建
生命周期时机 布局 / 顶点重建阶段(CanvasUpdate PreRender)填进 Mesh Mesh 已经生成好,绘制提交前引擎叠加相乘
合批影响 不打断合批;顶点色是 Mesh 数据,同材质可以合批 不打断合批!它是渲染时乘数,不改变材质、不改变 Mesh
叠加特性 是顶点原始颜色,贴图先乘它 在顶点色结果之上,再整体乘一遍(乘法叠加)
持久化 Mesh 存在就跟着存在,Mesh 不变它就不变 临时渲染状态;不序列化,Inspector 看不到
代表用法 静态底色、文本基础颜色 Button Tween 渐变、CrossFadeColor 呼吸、临时高亮闪烁
  1. 想改基础底色、固定不变 → 用 graphic.color(写进顶点)
  2. 想做流畅渐变、hover 闪烁、呼吸,不想重建 Mesh → 控制 canvasRenderer.color(CrossFadeColor / 自己每帧 canvasRenderer.SetColor)
  3. 两者可以叠加;叠加规则永远是相乘
  4. 两者都不会打断合批,这是它俩共同优点,和 material.SetColor 不一样

举一个叠加例子,直观感受乘法

复制代码
graphic.color = Color.red;          // 顶点色:(1,0,0,1)
graphic.canvasRenderer.SetColor(0.5f,0.5f,0.5f,1);

贴图是白色 (1,1,1,1) final = (1,1,1) * (1,0,0) * (0.5,0.5,0.5) = (0.5, 0, 0) → 暗红,不是灰色!

如果你反过来:

复制代码
graphic.color = Color.white;
graphic.canvasRenderer.SetColor(Color.red);

结果就是红色,等价于把 canvasRenderer.color 当主色调用,这也是我们做 CrossFade 呼吸的推荐写法:把 graphic.color 固定白色,只动 canvasRenderer.color。

Raycast 爬父链 + overrideSorting 截断

protected bool Raycast(Vector2 sp, Camera eventCamera, bool ignoreMasks)

{

...

while (t != null)

{

...

t.GetComponents(components);

for (var i = 0; i < components.Count; i++)

{

var canvas = component as Canvas;

if (canvas != null && canvas.overrideSorting)

continueTraversal = false; // ← 遇到覆盖排序的画布就停止向上

var filter = component as ICanvasRaycastFilter; // Image / Mask / RectMask2D / CanvasGroup

if (filter == null) continue;

raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);

...

}

t = continueTraversal ? t.parent : null;

isParent = true;

}

}

射线检测是 while (t != null) 一路向上爬父链 ,每级都要 GetComponents 检查有没有 ICanvasRaycastFilter。

→ 层级越深,每次点击要爬的层数越多 ​ ------ 这就是"层级深度影响射线检测性能"的源码实锤。

而且overrideSorting 会把 continueTraversal 置 false,直接截断向上遍历​ ------ 这就是子画布勾选覆盖排序后能省射线开销的原因。

另外 :raycastTarget 的 setter 会 GraphicRegistry.Unregister/RegisterRaycastGraphicForCanvas → 关掉 Raycast Target 是把元素从射线候选池里摘出去,这才是"关 Raycast Target 能优化"的真正机制(不只是跳过判断,是压根不进列表)。

Animator 是脏元素制造机

复制代码
protected override void OnDidApplyAnimationProperties()
{
    SetAllDirty();     // ← 动画属性一变就全脏
}

UI 上挂 Animator,每帧只要动画属性有变化就 SetAllDirty(),全量重建。

官方文档点名批评过这个行为 → UI 动效优先用代码 Tween(DOTween),别用 Animator。

结论 出处
一帧改 N 次只重建 1 次 CanvasUpdateRegistry 队列用 IndexedSet + AddUnique
布局三档 / 图形两档,Cull 夹中间 PerformUpdate 两个循环边界
改颜色 = 顶点脏 = 必然重建​ Graphic.color setter → SetVerticesDirty()
被剔除元素连顶点都不生成​ Rebuild() 开头 canvasRenderer.cull
换父节点 = 全脏​ OnTransformParentChanged → SetAllDirty()
重新激活 = 全脏​ OnEnable → SetAllDirty()
Mask 打断合批的出处​ materialForRendering 过 IMaterialModifier 链
描边/阴影是加顶点不是多画一层​ DoMeshGeneration 的 IMeshModifier 链
射线检测沿父链向上爬,overrideSorting 截断​ Raycast() 的 while (t != null)
CrossFadeColor 不重建​ 回调是 canvasRenderer.SetColor
相关推荐
速易达网络1 小时前
Three.js 动画入门教学:从场景搭建到流畅动效
开发语言·javascript·ecmascript
福大大架构师每日一题1 小时前
excelize v2.11.1发布:Go 1.26.0起步,16项安全修复与嵌入对象能力全面升级
开发语言·安全·golang
Sarvartha1 小时前
集合概述 & Collection 接口
开发语言
lhbweilai2 小时前
QT项目(串口助手)
开发语言·qt
eggwyw2 小时前
C++相交链表和反转链表详解
开发语言·c++
Wang's Blog2 小时前
Java 项目实战:交友项目 - 保存访客记录
java·开发语言·交友
暴走约伯2 小时前
【VFX工具链源码拆解 04】拯救丢失的绑定:Timeline 一键克隆与层级相对路径重映射算法
unity·c#·游戏引擎·硬件工程
Raas1002 小时前
AI网关如何接入?MAI Gateway部署实操教程
java·开发语言·人工智能·gateway·ai网关·mai gateway·企业级产品
小小龙学IT2 小时前
Go 语言 pprof 性能剖析与调优深度解析:把“卡在哪“变成一张可执行的数据图
开发语言·后端·golang