UGUI源码学习笔记_第一篇续_Canvas篇

20260904-202717_看图王(2)

这篇是自己看UguiCSReference总结的源码阅读笔记,顺便整理出来分享。边啃代码,边写下来。哪里讲的不到位、理解错了,欢迎直接指出来。

CanvasCanvasGroupCanvasRenderer 这三个CS类,是引擎的在C# 层的一层薄薄的壳。

这层壳长什么样、壳背后一帧里到底发生了什么、为什么我改了 text.text 画面却不立刻变——这些搞清楚了,UGUI 的很多行为才说得通。下面按我自己的理解,一层一层说。

一、啊欧~Canvas、CanvasGroup、CanvasRenderer三个类都在C++引擎源码里。

com.unity.ugui@1.0.0/Runtime/UI/Core/ 目录里,你能看到 Graphic.csImage.csText.csSelectable.cs……但就是没有 Canvas.csCanvasGroup.csCanvasRenderer.cs

它们的声明写在 Unity 的源码参考仓库(UnityCsReference)的绑定文件里,头顶都挂着 NativeClass

// UICanvas.bindings.cs
[NativeClass("UI::Canvas", PersistentTypeId = 223),
 NativeHeader("Modules/UI/Canvas.h"),
 NativeHeader("Modules/UI/CanvasManager.h")]
public sealed partial class Canvas : Behaviour { ... }

// CanvasGroup.bindings.cs
[NativeClass("UI::CanvasGroup", PersistentTypeId = 225),
 NativeHeader("Modules/UI/CanvasGroup.h")]
public sealed class CanvasGroup : Behaviour, ICanvasRaycastFilter { ... }

// CanvasRenderer.bindings.cs
[NativeClass("UI::CanvasRenderer", PersistentTypeId = 222),
 NativeHeader("Modules/UI/CanvasRenderer.h")]
public sealed partial class CanvasRenderer : Component { ... }

NativeClass("UI::Canvas") 这行基本等于官方盖了章:真正的渲染、合批、alpha 级联逻辑,全在 C++ 引擎里(Canvas.h 那几个文件)。C# 侧这份 .bindings.cs 只是把原生字段和函数包成属性暴露出来而已。

所以有个铁律,我自己记了很久:你能从公开源码里"看到"的,只有绑定层。合批算法、alpha 怎么乘法,这些都不在 ugui 包里。也正因为如此,网上老有人说"加个 CanvasGroup 会克隆材质、会拆批",从源码层面看就是不对的——这些东西根本不归 C# 管。

二、一帧的心跳:改个属性,画面不是"立刻"变的

这是新手最容易误解、也是源码最容易讲明白的一件事:你改了 text.text,画面不是立刻重画的。

UGUI 是"脏标记 + 帧末统一收口"的套路。你写的那行 text.text = "x",实际上只是调了 Graphic 的 setter,setter 再调对应的 SetXxxDirty(),在队列里登记一个"我脏了":

// 这是 Image/Text 都继承的 Graphic
public virtual Color color {
    get { return m_Color; }
    set { if (SetPropertyUtility.SetColor(ref m_Color, value)) SetVerticesDirty(); }
}

public virtual void SetVerticesDirty()   // 顶点脏:位置/颜色/UV 变了
{
    if (!IsActive()) return;
    m_VertsDirty = true;
    CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);  // 进"图形重建队列"
}

public virtual void SetMaterialDirty()   // 材质脏
{
    if (!IsActive()) return;
    m_MaterialDirty = true;
    CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
}

public virtual void SetLayoutDirty()     // 布局脏:尺寸/锚点/父子关系变了
{
    if (!IsActive()) return;
    LayoutRebuilder.MarkLayoutForRebuild(rectTransform);  // 注意,走的是"布局根",不是注册这个 Graphic 本身
}

这里藏着第一个容易看漏的差异:SetVerticesDirty / SetMaterialDirty 把自己(一个叶子 Graphic)塞进图形重建队列;而 SetLayoutDirtyLayoutRebuilder.MarkLayoutForRebuild,它会顺着父链往上,找到最*的"布局根"(带 ILayoutController 的祖先),把那个根登记进布局重建队列

换句话说:图形脏登记的是"叶子",布局脏登记的是"根"。这也是为什么你改一个叶子 Text,重算的是它所在的整个 VerticalLayoutGroup,而不是单个 text——因为布局的置脏粒度就是整个布局根。弄混这一点,就会想不通"我就改个字的尺寸,怎么整片都在重排"。

真画起来,是在帧末

*时置脏只是打标记,真正的重建,要等引擎在每帧渲染前点燃一个 Canvas.willRenderCanvases 事件。这个事件只有一个订阅者——CanvasUpdateRegistry 的构造里挂的 PerformUpdate

protected CanvasUpdateRegistry()
{
    Canvas.willRenderCanvases += PerformUpdate;   // 唯一的驱动入口
}

willRenderCanvases 谁点燃的?C++ 引擎,在帧末、渲染前。托管层不能"制造"这个心跳,只能等它来。

那"我就是要这一帧立刻拿到布局结果"怎么办?Canvas.ForceUpdateCanvases() 就是干这个的——它手动把两步事件提前触发,等于人为制造一次"早搏",立刻同步跑一遍重建流水线:

public static void ForceUpdateCanvases()
{
    SendPreWillRenderCanvases();
    SendWillRenderCanvases();
}

代价是白多跑一次完整重建。所以它只适合"改完布局立刻要读 rectTransform.rect 做后续计算"这种场景,日常 Update 里反复调就是纯浪费。

其实这段代码还藏着一个容易看漏的点:ForceUpdateCanvases() 触发的是两个事件——Canvas.preWillRenderCanvasesCanvas.willRenderCanvases(上面的 SendPreWillRenderCanvases / SendWillRenderCanvases 各点一个)。*时只盯着 willRenderCanvases,另一个 preWillRenderCanvases 是更早的钩子,本可以拿来"渲染前最后再补一刀",我在别的项目里就靠它挂"帧末统一改 UI"的回调。

还有一扇跟"心跳要不要每次渲染都跑"有关的门:Canvas.batchingInterval(Player 运行时才生效,Editor 无视)。默认 GatedByRendering 只在真正要渲染的那一帧更新合批,配 OnDemandRendering 时"被跳过的帧不重排 UI";想让它每次都跑就切 AlwaysUpdate。这个属性我一开始完全没注意,是读绑定文件时才翻到的,属于"省电省 CPU"的进阶旋钮。

三、PerformUpdate 跑的那几拍:真干活的其实没几拍

PerformUpdate 是最值得看的一段,因为它就是 UGUI 重建的整个骨架。一开始我以为分五个阶段、每拍都在算,结果读源码才发现不是——**驱动层确实按五个 case 跑调度,但队列元素大多只在个别 case 里真正响应,其余是早期留下的扩展槽。**这段反复读了好几遍,下面把顺序捋清楚。

整个 PerformUpdate 的动作顺序是固定的:清场 → 布局三拍 → 布局收尾 → 裁剪 → 图形两拍 → 图形收尾。

先说两个真干活的阶段,它们承担了几乎所有计算量:

Layout 阶段:布局的计算和落位

布局队列里的元素是 LayoutRebuilder,它的 Rebuild 只认 Layout 这一个 case:

// LayoutRebuilder.cs
public void Rebuild(CanvasUpdate executing)
{
    switch (executing)
    {
        case CanvasUpdate.Layout:
            PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputHorizontal());
            PerformLayoutControl(m_ToRebuild,      e => (e as ILayoutController).SetLayoutHorizontal());
            PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputVertical());
            PerformLayoutControl(m_ToRebuild,      e => (e as ILayoutController).SetLayoutVertical());
            break;
    }
}

也就是一次 Layout 调用里就完成了水* + 垂直各两小步:

  • 算水*/垂直输入(自底向上):让每个 ILayoutElement 先报告自己"想要"的尺寸——minWidthpreferredWidthflexibleWidth 这些。Text 量字符串宽度,Image 报精灵尺寸,LayoutGroup 把子节点尺寸求和。

  • 施加水*/垂直接口(自顶向下):让每个 ILayoutController(比如 HorizontalLayoutGroup)把算好的尺寸分配到自己和子节点的 RectTransform

为什么水*在垂直前面?很多布局组是"宽决定高"或反过来,引擎约定先解 X 再解 Y。为什么算输入要自底向上?因为父级的 preferred 尺寸依赖子级报上来的数,得先让叶子报数、父级再汇总。为什么施加接口要自顶向下?因为子节点落在哪、占多大,得先等父级定好尺寸。一问一答,方向正好相反。

控制阶段内部还有个顺序细节:同一条 RectTransform 上如果既有"自控制器"又有"组控制器",先跑自控制器ContentSizeFitterAspectRatioFitter 这类只管自己的),再跑组控制器HorizontalLayoutGroup 这类要分孩子的)。不然组控制器分孩子时会拿自己的旧尺寸来分,就错了。

PreRender 阶段:生成 mesh 和绑材质

图形队列里的元素是 Graphic(Image / Text / MaskableGraphic 它们),它的 Rebuild 只认 PreRender

public virtual void Rebuild(CanvasUpdate update)
{
    if (canvasRenderer == null || canvasRenderer.cull) return;   // 被裁剪掉的直接跳过整段
    switch (update)
    {
        case CanvasUpdate.PreRender:
            if (m_VertsDirty)    { UpdateGeometry();  m_VertsDirty = false; }
            if (m_MaterialDirty) { UpdateMaterial();  m_MaterialDirty = false; }
            break;
    }
}

UpdateGeometry 内部大致三步:先 OnPopulateMesh(子类填顶点,Image 填四边形、Text 填字符网格),再对 IMeshModifier 逐个调 ModifyMesh——这就是 ShadowOutline 这些特效往顶点里追加东西的地方,最后 FillMesh 灌进 canvasRenderer.SetMesh 交还原生。

两点顺序别搞反:先 SetMesh(几何)再 SetMaterial(材质)。另外 Mask 的模板缓冲(stencil)裁剪,就是在这一步的 UpdateMaterial 里插进材质的——它走的是材质路径。

夹在中间没被注意的 Cull 阶段

布局算完了、尺寸定了、但 mesh 还没生成,正好先定裁剪区。这一步不走队列,而是 ClipperRegistry.Cull() 遍历场景里所有 IClipper(也就是 RectMask2D),它把"自身矩形"和"所有祖先 mask 的矩形"做交集,得到最终可见窗口,再下发给每个 MaskableGraphicCanvasRenderer。整块完全在裁剪区外面的,直接被标记 cull=true——后面的 PreRender 阶段跳过它,连 mesh 都不生成,这是省大钱的地方。

一个特别容易混的点,我自己栽过:RectMask2D 在这一个 Cull 阶段定矩形,而 Mask(模板裁剪)不走这条路,它在 PreRender 阶段通过修改材质插 stencil。一句话记法:软裁剪(RectMask2D)在 Cull 定矩形,硬模板裁剪(Mask)在 PreRender 定材质。都在 CanvasRenderer 上作用,但不在同一拍。

这张"裁剪矩形"落到 CanvasRenderer 上,走的其实是 EnableRectClipping(rect) 这条原生调用(绑定文件里 hasRectClipping 就是它的开关,DisableRectClipping 关掉)。要软边也是在这:clippingSoftness 设一个软边像素数,会被塞进 UI shader 的 _MaskSoftnessX / _MaskSoftnessY 做线性淡出。这也是为什么列表边缘有时能明显看到"羽化/软边",根子就在这俩值,嫌硬就调大。

其余几拍为什么是"空转"

  • Prelayout(i=0)和 PostLayout(i=2):驱动层确实会分别调 Rebuild(Prelayout)Rebuild(PostLayout),但 LayoutRebuilder 的 switch 里没有这两个 case,对标准布局就是空转。这是很老版本的"布局前/布局后"钩子遗留。网上有些说法"Prelayout 算水*、Layout 算垂直、PostLayout 收尾"——那是更老版本的语义,ugui 1.0.0 已经全并进 Layout 一次跑完了。

  • LatePreRender(i=4):基类 Graphic 里也是空转,但被 InputField 真正用上了——它在 LatePreRender 重建光标(caret)和选区的高亮 mesh。为什么用这个"更晚的槽"?因为光标要画在文本几何已经定好布局之后,文本在 PreRender 重建,输入框就得到 LatePreRender 这个更晚的节拍里,才能拿到最终文本布局来定位光标。这正好说明了"为什么要留这个扩展槽"。

为什么要按这个顺序,为什么是两套队列

我一开始以为把布局和图形塞进同一个循环排一下就行,读完源码才知道不行:

  • 为什么要分布局 / 图形两个队列、严格先后:图形重建(生成 mesh)依赖布局算出的最终 RectTransform 尺寸和位置。必须 100% 跑完布局,才能跑图形,否则几何会按上一帧的旧尺寸生成。这是 m_LayoutRebuildQueuem_GraphicRebuildQueue 两个队列、两段 for 循环的物理保证。

  • 为什么布局队列要按父级深度排序SortLayoutList 按每个元素的 ParentCount(父链长度)升序排,保证父级先重建、子级后重建,这样子物体才能用父级最新的尺寸。深度排序是够用且高效的——它只保证"祖先先于后代",不保证兄弟间的顺序,但兄弟本来就互不影响,布局的依赖靠 ILayoutController 的回调链在各自 Rebuild 内部消化,不是靠这个排序解决。

  • 为什么 CleanInvalidItems 放最前面:队列里可能残留已经被 Destroy 的对象。它逆序遍历,把 IsDestroyed 的剔掉(先调 LayoutComplete/GraphicUpdateComplete 让它退场、再移除),避免对"已死的对象"调 Rebuild 抛空引用。逆序遍历是为了 RemoveAt 时不打乱未处理下标。

  • 为什么每个 Rebuild 都套 try/catch:一个元素的 OnPopulateMesh 写崩了,不该连累同帧其它所有 UI 一起不重建,LogException 后继续下一个。

  • 两个"重入锁"标志m_PerformingLayoutUpdatem_PerformingGraphicUpdate 用来防"重建过程中又置脏再入队"。这里有个有意思的不对称,我读源码时才注意到:布局阶段中途再登记布局元素,那段报错代码被注释掉了(因为缩放 Game View 会误触,成了一个历史 bug 的取舍);而图形阶段中途再登记图形元素,会直接 LogError 并拒绝。含义就是:布局可以被外部事件打断,图形重建必须原子。所以你在 Graphic 的重建回调里想"顺手再 SetVerticesDirty",十有八九会看到 "inside a graphic rebuild loop" 的报错——那不是 bug,是防自己破坏队列遍历。

以上几个"为什么",比五个阶段的名字本身更值钱。因为面试和实际调优,问的都是思路,不是名词。

四、CanvasRenderer:每个 Graphic 挂在身上、和原生打交道的牌子

前面反复提到 canvasRenderer.SetMesh(SetMaterial),这玩意儿到底是什么?

它就是每个 Graphic 跟原生渲染之间唯一的句柄Graphic.cs 里这么拿它:

public CanvasRenderer canvasRenderer
{
    get
    {
        if (ReferenceEquals(m_CanvasRenderer, null)) {
            m_CanvasRenderer = GetComponent<CanvasRenderer>();
            if (ReferenceEquals(m_CanvasRenderer, null))
                m_CanvasRenderer = gameObject.AddComponent<CanvasRenderer>(); // 没有就自动补
        }
        return m_CanvasRenderer;
    }
}

注意它会在没有的情况下自动加一个。你场景里的每个 UI 元素,基本都挂着一块 CanvasRenderer。Graphics 自己不持"最终 mesh 的去处",它把活都交给这个代理:

protected virtual void UpdateGeometry()
{
    OnPopulateMesh(s_VertexHelper);      // 子类填顶点
    canvasRenderer.SetMesh(workerMesh);  // 交还原生
}
protected virtual void UpdateMaterial()
{
    canvasRenderer.materialCount = 1;
    canvasRenderer.SetMaterial(materialForRendering, 0);
    canvasRenderer.SetTexture(mainTexture);
}

下面这些成员,*时就藏在 CanvasRenderer 上,用到才知道疼:

  • GetInheritedAlpha():返回"所有父 CanvasGroup 的 alpha 乘完"的结果。最终显示 alpha = UIVertex.alpha × CanvasRenderer.color.a × inheritedAlpha,三层相乘。第前一篇说的"加 CanvasGroup 不会克隆材质",底气就在这里——alpha 是乘进去的,不是 new 一个材质。

  • hasMoved / absoluteDepth / relativeDepthhasMoved 是合批/裁剪能否跳过的快路径;absoluteDepth(相对根画布)和 relativeDepth(相对父画布)是 Graphic.depth 的来源,画布内谁压谁就看它。跨画布排序看 Canvas.sortingOrder/sortingLayerID,画布内看 absoluteDepth——两个维度,调 z-order 前先分清楚你在改哪层。

  • cull / cullTransparentMesh:剔除开关。后者指"所有顶点 alpha 接* 0 时连几何都别提交",省合批。

  • 渲染栈 pop(Mask 用的那套)hasPopInstructionpopMaterialCountSetPopMaterial 是一套"先按正常材质画完子树,画完再 pop 回去用 pop 材质重新画一遍"的机制。真正的 Mask 模板裁剪走的就是这条路——它比"单纯往材质里插 stencil"更像一个带出栈的重绘栈。这几个成员*时用不上,但看懂它能解释"为什么 Mask 附*会*白多出一次 DrawCall",不是玄学。

  • SplitUIVertexStreams / CreateUIVertexStream:如果你不靠 VertexHelper,想从裸顶点数组自己造一个 MeshSetMesh,就靠这对函数把 UIVertex 拆成位置/颜色/UV/法线/切线/索引各数组喂给 mesh。老的那套 SetVertices 已标记 [Obsolete],别用了。

  • SetMesh 的隐性要求:你塞给它的那个 mesh 必须勾了 read/write,运行时才允许持续改顶点;会动的 UI mesh 往往在这里栽第一脚。

  • SetAlphaTexture / SetSecondaryTextureSetAlphaTexture 不是装饰,是安卓 ETC1 压缩路径里真在用的——ETC1 压不了带透明通道的图,Unity 会把一个 RGBA 精灵拆成"RGB 图 + 独立 Alpha 图"两张,渲染时由 UI/DefaultETC1 shader 经 _AlphaTex 合回去。SetSecondaryTexture 是更通用的"绑额外贴图给自定义 shader"的机制,内置 Image 只用了 _AlphaTex 一条,但协议是开放的——注意每个次级贴图都要配一个 shader 属性名SetSecondaryTexture(index, name, texture) / GetSecondaryTextureName),不是随便塞一张就生效。

五、层级渲染合并:CanvasRenderer 收齐之后,原生怎么"合并 + 排序"成 DrawCall

前面几章把"改属性 → 置脏 → 心跳 → CanvasRenderer"讲完了。但有个问题一直悬着:几百个 Graphic 各自往 canvasRenderer.SetMesh 里塞了 mesh,然后呢? 它们是怎么被并成很少几个 DrawCall、又是按什么顺序画到屏幕上的?

这一章单独聊这个——它不在 ugui 包里,全是 C++ 引擎(Canvas.h)干的事,但理解它的规则,决定你 UI 的性能边界:哪些能省成一个 DrawCall、哪些必然拆开、谁盖在谁身上。 我按"总流程 → 排序规则 → 合并规则 → 六种真实情景"往下讲,全是画图。

5.1 总流程:重建完交给原生之后发生了什么

flowchart TD subgraph CSharp["重建阶段(C# 侧,前面几章)"] A1[置脏] --> A2[心跳 willRenderCanvases] --> A3[PerformUpdate] A3 --> A4[Graphic.Rebuild] --> A5[canvasRenderer.SetMesh / SetMaterial / SetColor 写入数据] end subgraph Native["原生渲染阶段(C++ Canvas.h,C# 看不到)"] N1[① 收集:场景中所有 可见且未 cull 的 CanvasRenderer] --> N2[② 排序:算出绘制顺序,谁画在上面] N2 --> N3[③ 合批:相邻且条件相同 → 并成一个 DrawCall] N3 --> N4[④ 提交:把每个 DrawCall 交给 GPU 绘制] end A5 --> N1 N4 --> OUT[屏幕上的这一帧]

关键点先立住:"排序"和"合批"是两件独立的原生步骤,排序决定谁盖谁,合批决定能不能省。 顺序排错了,就算条件再相同也合不了;条件总不同,排得再对也只能一个个画。后面两个小节分别讲。

5.2 排序规则:到底谁画在上面

排"谁在上面"不是一句话,得分维度——跨画布看一套,画布内看另一套。我画了一张判定流程:

flowchart TD START[判断两个 CanvasRenderer A、B 谁画在上面] --> Q{是否在同一个 Canvas 里?} Q -->|"否,跨画布"| L1[① 比 Canvas.sortingLayerID,排序层大者上] L1 --> L2[② 同一层再比 Canvas.sortingOrder,画布顺序大者上] L2 --> L3[③ 仍相同:比层级遍历顺序,Hierarchy 中更靠后的元素上] Q -->|"是,同一画布"| M{Canvas 的渲染模式?} M -->|"Overlay"| M1[看 Hierarchy 深度优先遍历顺序:后遍历到的 = 更靠下的 = 子节点 = 在上面] M -->|"ScreenSpaceCamera / WorldSpace"| M2[看 absoluteDepth(相对根画布深度)+ 实际变换深度]

几点要单独记牢,都是实战容易翻车的:

  • Overlay 下"顺序 = Hierarchy 从下往上":你调整 RectTransform 的兄弟顺序(SetAsFirstSibling / SetAsLastSibling)能改变覆盖关系,根子就是它。子物体天然盖在父物体上,后面的兄弟盖在前面的兄弟上

  • CanvasRenderer.sortingOrder 在 Overlay 下是摆设(§ 上版提过)——Overlay 不走这套数值,"谁上谁下"只看 Hierarchy 遍历顺序。它只在 ScreenSpaceCamera / WorldSpace 且有 worldCamera 时才参与排序,而且要在同一 sortingLayerID 内讨论。

  • 跨 Canvas 时,sortingLayerID > sortingOrder > 层级顺序,这是"让整个界面层级压住游戏 UI"的手法:把功能面板放一个 sortingOrder 更大的独立 Canvas。

  • 画布内画布:你给某个子物体单独套了个 Canvas(overrideSorting),它会变成独立的排序单元,用 sortingOrder 自己压,不再完全跟父的 Hierarchy 顺序(§七 嵌套 Canvas 那条)。

  • Canvas.renderOrder 只在 Overlay 下"算得准":文档原话是,只有 Screen Space - Overlay 的 Canvas 是按序正确输出的;Screen Space - Camera / World 的 Canvas 实际是按到相机的距离排序的。所以别指望 renderOrder 在贴相机的画布上替你压顺序。

  • Canvas.sortingOrder 底层是 16 位有符号整数:范围只有 -32768 ~ 32767。我一开始以为是 int,设了个大的想"永远置顶",结果发现越界会被截断。真要置顶到保底,与其把 sortingOrder 顶到天花板上限,不如单独分一个更高的 sorting layer,来得稳。

5.3 合并规则:什么条件才能并成一个 DrawCall

排序定好之后,原生从前往后扫,把相邻每一项都满足的元素归成一组,组内共用一次 DrawCall。下面这张流程把"能不能并"的每一个门槛列全:

flowchart TD R[已排好序的相邻元素 R1、R2] --> C1{R1、R2 在绘制顺序里紧挨着?} C1 -- 否 --> SPLIT[拆成两次提交,DrawCall+1,从 R2 起重新组] C1 -- 是 --> C2{材质实例一致?shader + 参数完全相同} C2 -- 否 --> SPLIT C2 -- 是 --> C3{主贴图 MainTex 是同一张纹理?} C3 -- 否 --> SPLIT C3 -- 是 --> C4{裁剪矩形一致?同 RectMask2D / 同段 stencil 区间} C4 -- 否 --> SPLIT C4 -- 是 --> C5{在同一个 Canvas、同一个排序层?} C5 -- 否 --> SPLIT C5 -- 是 --> C6{中间没有深度更大的第三方夹层?} C6 -- 否 --> SPLIT C6 -- 是 --> MERGE[合并成一次 DrawCall,DrawCall+0]

一句话记法相邻 + 同材质 + 同贴图 + 同裁剪区 + 同画布 + 中间无夹层,才能并。 任何一条断掉,就多一次 DrawCall。

5.4 六种真实情景:到底拆不拆、画成什么样

这一节是重点,我把最常见的六种情况一张张画出来。图例:[ ] 表示一次 DrawCall 提交,括号里是它一次性画到的元素。

情景 A:同画布、同图、相邻 → 合并成一个 DrawCall(最理想)

flowchart TD subgraph HIER["Hierarchy:同一父节点 P,兄弟相邻、不重叠"] A["Image A · 贴图 bg"] --> B["Image B · 贴图 bg"] B --> C["Image C · 贴图 bg"] end HIER --> DRAWORDER["绘制顺序:A → B → C"] DRAWORDER --> DC0{"B、C 是否紧挨、同图、同材质、同区?"} DC0 -->|"是,全部满足"| DC1["1 次 DrawCall = [ bg A · bg B · bg C ]"] DC0 -->|"否,任一条件断掉"| DCX["则 DrawCall ≥ 2(详见情景 B/C/D)"] style DC1 fill:#e6ffe6,stroke:#2e7d32

情景 B:中间夹一张不同的图 → 拆成 3 个 DrawCall

flowchart LR subgraph HIER["Hierarchy:中间那个 Image 用另一张贴图"] A["Image A · 贴图 bg"] --> B["Image B · 贴图 icon"] B --> C2["Image C · 贴图 bg"] end HIER --> ORDER["绘制顺序:A → B → C"] ORDER --> DCB{B 贴图与 A 不同?} DCB -->|"是(本情景:B 是 icon)"| S1["1. [ bg A ]"] DCB -->|"否,B 反与 A 同图"| B_MERGE["则 A、B 可并入同批"] S1 --> DCB2{"C 同 bg,但和 A 相邻吗?"} DCB2 -->|"否,中间被 B 隔开(本情景)"| S2["2. [ icon B ]"] DCB2 -->|"是,能接回 A"| C_MERGE["则 A、C 可并;此处被 B 隔开,走'否'"] S2 --> S3["3. [ bg C ]"] style S1 fill:#fff0f0,stroke:#c62828 style S2 fill:#fff0f0,stroke:#c62828 style S3 fill:#fff0f0,stroke:#c62828

情景 C:层级穿插 / 夹层 → 同图也拆、还可能错序

flowchart TD subgraph HIER["Hierarchy:面板上两个按钮,中间叠一张同图弹窗"] P["面板 P · 贴图 panel"] P --> B1["按钮1 · 贴图 focus"] P --> B2["按钮2 · 贴图 focus"] end W["弹窗 W · 贴图 panel(独立,renderOrder 更大)"] HIER --> ORDER["实际绘制顺序:按钮1 → 弹窗 W → 按钮2(夹层插入)"] ORDER --> DCB1{W 贴图是 panel,与 focus 不同?} DCB1 -->|"是(本情景)"| S1["1. [ btn1 · focus ]"] DCB1 -->|"否,W 反与按钮同图"| W_MERGE["则可能并入;W 是 panel,走'是'"] S1 --> S2["2. [ W · panel ]"] S2 --> DCB2{按钮2 与按钮1 相邻吗?中间夹了 W} DCB2 -->|"否(本情景:夹了 W)"| S3["3. [ btn2 · focus ]"] DCB2 -->|"是,紧邻"| BTN_MERGE["则按钮1、按钮2 可并;此处夹层打断,走'否'"] style S1 fill:#fff0f0,stroke:#c62828 style S2 fill:#fff0f0,stroke:#c62828 style S3 fill:#fff0f0,stroke:#c62828

情景 D:RectMask2D / Mask 边界 → 强行断批

flowchart TD subgraph HIER["场景:一个列表套了 RectMask2D,列表外同一张 item 贴图再摆一张"] L["List 根 · 挂 RectMask2D<br/>裁剪矩形 = 列表可见区域"] L --> D1["滚动项1 · 贴图 item<br/>裁剪索引 = 列表段"] L --> D2["滚动项2 · 贴图 item<br/>裁剪索引 = 列表段"] D_OUT["列表外 item · 贴图 item<br/>裁剪矩形 = 无(不在列表裁剪区)"] end D1 --> DCHK1{"D1、D2 相邻 + 同贴图<br/>+ 同裁剪矩形,能满足?"} D2 --> DCHK1 DCHK1 -->|"是(本情景)"| DDC1["批次1:[滚动项1 · 滚动项2] · item<br/>✓ 共享同一裁剪区,mesh 里的裁剪索引一致,可并"] DCHK1 -->|"否,任一断掉"| DDC1X["则列表内也各自拆批"] D_OUT --> DCHK2{"列表外 item 的裁剪矩形<br/>与列表那段一致吗?"} DCHK2 -->|"否,区间不同(本情景)"| DDC2["批次2:[列表外 item] · item<br/>✕ 裁剪索引不同 → 顶点状态无法共享,同图也拆"] DCHK2 -->|"是,同区"| DDC2X["则可并入批次1"] DDC1 --> DSUM["最终:2 次 DrawCall<br/>列表内 1 次 + 列表外 1 次"] DDC2 --> DSUM DSUM --> DNOTE["为什么'同图'在这里也没用:<br/>合批不只比贴图,还比每个顶点里携带的裁剪状态。<br/>RectMask2D / Mask 都会把'第几段裁剪 / stencil 区间'写进 mesh,<br/>裁剪矩形一变,即使贴图完全相同,也叫两个批次。<br/>→ 它是会改变'合批段'的硬边界,不是可有可无"]

情景 E:跨 Canvas → 天然新批次边界

flowchart TD subgraph HIER["场景:根画布底下又挂了一个子 Canvas"] CR["Canvas 根<br/>一个批次段"] CR --> E1["普通 UI 若干<br/>跟随根的批次"] CR --> SUB["子 Canvas<br/>overrideSorting → 独立排序 + 独立合批"] SUB --> E2["子 Canvas 内的 UI 若干<br/>自成一段"] end E1 --> SEG1["批次段1:根画布里的这些 UI<br/>原生按同一 Canvas 一起收集合并"] E2 --> SEG2["批次段2:子 Canvas 里的这些 UI<br/>独立收集,不与外面混批"] SEG1 --> ESUM["最终:至少 2 次 DrawCall(一个画布一段)"] SEG2 --> ESUM ESUM --> ENOTE["代价与收益:<br/>✕ 至少多一道批次边界 → DrawCall +1<br/>✓ 子 Canvas 是独立排序单元,能用 sortingOrder 压层<br/>✓ 父画布静止时子画布几何不变,可单独不重排即省 CPU<br/>→ 拆不拆,看'局部动的范围'和'边界开销'谁更大,用 Profiler 定位再定"]

情景 F:跨 Canvas 的上下关系 → sortingOrder 决定谁压谁

flowchart TD subgraph HIER["场景:两个独立画布(Overlay)叠在一起"] CA["Canvas A · 游戏底层<br/>sortingLayerID = Default(0)<br/>sortingOrder = 0"] CB["Canvas B · 功能弹窗<br/>sortingLayerID = Default(0)<br/>sortingOrder = 100"] end CA --> FSEQ1["跨画布先比层① 再比序②:<br/>① sortingLayerID 都是 Default,*<br/>② sortingOrder:B(100) &gt; A(0)<br/>∴ B 整段排在 A 之后画"] FSEQ1 --> FSEQ2["绘制顺序:<br/>先 Canvas A 整段 [A 的 UI]<br/>再 Canvas B 整段 [B 的 UI] ← 盖在上面"] FSEQ2 --> FSUM["结果:A、B 各自合批互不干扰<br/>且 B 必盖在 A 上(整片压另一片)"] FSUM --> FNOTE["应用技巧:<br/>想让一整片界面永远压住另一片,只调 Canvas.sortingOrder 就够,<br/>不必去动内部每个元素的 Hierarchy 顺序。<br/>想彻底分层、更稳,用更高的 sortingLayerID。/<br/>(对比 §5.2:这正是'排序层 &gt; 排序顺序 &gt; 层级顺序'的实例)"]

5.5 怎么用 Frame Debugger 逐条验

真实项目里"到底拆了几个 DrawCall、谁压谁",别靠猜,用 Window → Analysis → Frame Debugger 直接看:点进去能展开到每次 Canvas.Render 提交,能精确看到每次 DrawCall 用的材质、贴图、裁剪矩形和它含几个顶点。对照上面六种情景,你很快能定位到"多出来的那个 DrawCall 是哪条规则断的"——是贴图不同、夹层插入、还是裁剪/画布边界。改 UI 前的性能基线,一定从这里抄。

六、CanvasGroup:只暴露了四个旋钮

CanvasGroup.bindings.cs 极短,就四个属性,全是 NativeProperty

public sealed class CanvasGroup : Behaviour, ICanvasRaycastFilter
{
    public extern float alpha { get; set; }
    public extern bool interactable { get; set; }
    public extern bool blocksRaycasts { get; set; }
    public extern bool ignoreParentGroups { get; set; }

    public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
        => blocksRaycasts;   // 射线过滤就一行,直接看 blocksRaycasts
}

注意:alpha 的乘法继承发生在原生层 CanvasGroup.hC# 里根本找不到那段乘积代码。你翻遍 ugui 包也翻不到。Graphic 拿到的只是 CanvasRenderer.GetInheritedAlpha() 算好的结果。

关键语义是嵌套连乘,绑定类注释里写得很清楚:最终 alpha = 所有嵌套 CanvasGroup 的 alpha 连乘 × 元素自身 alpha。而且 ignoreParentGroups 的语义容易理解错——它只是"从自己往上、碰到第一个 ignoreParentGroups 就停"地截断链,并不能把你从"自己还挂在 alpha=0 的父组下面"这个事实里救出来。

alpha=0 是最大的坑:假如父 Canvas 上有 CanvasGroup.alpha = 0,整个父画布下什么都不渲染,子节点怎么设 ignoreParentGroups 都白搭。要救出某个子节点,官方给的路子就两条:要么把父 alpha 设成一个很小的非零值,要么给那个子节点单独加一个 Canvas 组件,让它脱离父组的乘法管辖。

另外 interactableblocksRaycastsalpha 三条路径在原生层是汇合的,C# 只暴露了旋钮:interactableSelectable.ParentGroupAllowsInteraction 沿父链收集,blocksRaycastsGraphic.RaycastICanvasRaycastFilteralphaGetInheritedAlpha。看源码的最大收获之一,就是确认了"这三条路是分开的、而且都在引擎那边"。

七、几个容易翻车的细节,踩过才记得住

这一节攒的是我读过绑定文件、又踩过坑才明白的杂货,按主题归了一下。

自定义 shader 想用 UV1~3,为什么读出来是黑的?

Canvas 生成 mesh 时,默认只带 Position / Color / UV0(Camera / World 画布另加 Normal / Tangent)。你勾了 additionalShaderChannels 里的 TexCoord1,UV1 才会被写进顶点,否则那列永远是 0:

canvas.additionalShaderChannels |= AdditionalCanvasShaderChannels.TexCoord1;

这道开关是原生 mesh 生成路径读的,托管侧 Graphic / Image 根本不碰它——我 grep 过,只有 GetDefaultCanvasMaterial 命中。所以"我的 UV 怎么是黑的",大概率是这没勾。另外补充一句:Normal / Tangent 这两个通道基本是给 Camera / World 画布用的(要受光才需要报销),Overlay 画布原则上不能有光照,开了也吃不到光。

线性空间下深色渐变有台阶感 / 发灰?

vertexColorAlwaysGammaSpace 就是治这个的。顶点色如果在 CPU 端从 gamma 转成 linear 再存进 mesh(8-bit 通道插值),深色段位精度损失大,会断层。把它设 true,就让它保持 gamma 编码,把换算推迟到 UI shader 里用全精度做。注意两个硬条件:内置 UI shader 自带 gamma→linear,不用管;但自定义 UI shader 必须自己补这步转换,否则颜色会偏。另外这只是颜色精度调优,不是性能项,常规项目不用碰。

嵌套 Canvas 有什么用?和"别乱拆 Canvas"矛盾吗?

不矛盾,是一枚硬币的两面。子 Canvas 是独立合批/排序单元:父画布静止、只有局部在动时,把动的部分塞进子 Canvas,父画布光栅里几何不变就不重排,只有子画布重排,省 CPU;代价是子画布多一道 DrawCall 边界。overrideSorting 让子画布不再跟父的绘制顺序,自己靠 sortingOrder 压上压下沉。权衡点是"动的范围 vs 边界开销",用 Profiler 定位再拆,别无脑拆。

为什么会看到 UI.Layout / UI.Render 两个采样段?

UISystemProfilerApi 是绑定文件里藏着的静态类,原生 Canvas 在重建布局时 BeginSample(Layout)…EndSample(Layout),提交渲染时再打 Render 那一段。所以 UI.Layout 是布局阶段的耗时,UI.Render 是图形重建(含你的 OnPopulateMesh)加裁剪的耗时。你的 OnPopulateMesh 写重了,会直接体现在 UI.Render 里。想给某个子系统单独计时,可以 AddMarker("MyUIWidget", this)

还有几个"知道存在即可"的角料

  • onRequestRebuild 是 Editor-only 事件(贴图重导入等让数据失效时触发),运行时不会冒出来,别在运行时依赖它。

  • stagePrioritynormalizedSortingGridSize 属于进阶调优项,常规项目碰不到,别乱改。

  • Canvas.targetDisplay 管 Overlay 画布显示到哪块屏,最多支持 8 块副屏;它和 Camera.targetDisplay(相机出哪块屏)是一个 display 体系,多屏工程里用来把不同 UI 分到不同显示器。

  • Canvas.renderingDisplaySize 是"实际渲染像素尺寸"的快捷读取:Overlay = 屏幕分辨率、Camera = 相机覆盖的屏幕区、World = 那个 RectTransform 的宽高。想按"真正会画多大"算东西时别自己猜,直接读它。

  • Canvas.useReflectionProbes 只有 World 模式才用得上——让 Canvas 参与反射探针,把合适的反射 cubemap 绑给 shader。常规 UI 别开,世界内 UI 想要反射效果时才碰。

  • rootCanvas / isRootCanvasisRootCanvas 标记根画布;rootCanvas 从当前画布逐层往父级找最*的那个根,找不到就返回自己。cachedSortingLayerValuesortingLayerID 的缓存换算值,跨画布频繁比排序层时用它更稳。

  • UGUI 的默认材质是引擎统一给的 UI/Default(或 ETC1 变体),不是每个 Graphic 各 new 一个——所以你*白加个 Image 不会无端拆批,除非你改了 material 属性或挂了不同 shader。顺带一句:Canvas.GetDefaultCanvasTextMaterial() 已经标记 [Obsolete],文字和普通 UI 现在共用同一份 UI/Default,不用担心单独给 Text 开了另一份默认材质。

八、总结

一句话收口三者的分工:Canvas 管"画不画、何时画、谁先画、批次边界";CanvasGroup 管"这片透不透、点不点、挡不挡";CanvasRenderer 是每个 Graphic 挂在身上的原生渲染代理,是数据和原生之间的唯一句柄。 三者不在一层,调 alpha 时数据真正流动的方向是 CanvasGroup → CanvasRenderer → Canvas

至于能挖多深:公开的 Unity 源码参考仓库(UnityCsReference)只到 C# 绑定层,这篇用到的就是它。再往深,Canvas.h 那几个 C++ 文件是非公开的(我翻了份网上 2017 年的引擎4.3 drop,里面也没有 UGUI 的 C++ 实现,只有 legacy IMGUI 的 GUIClip 那一套裁剪栈)。真想在合批/裁剪上再抠一层,我只能从Frame Debugger + RenderDoc 看真实的合批、裁剪、排序来反推行为。
与君共勉🍜~

20260904-202717_看图王(1)

posted @ 2026-09-04 20:30  昂流  阅读(8)  评论(0)    收藏  举报
//替换成自己路径的js文件 hhttp(s)://static.tctip.com/tctip-1.0.4.min.js