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

这篇是自己看UguiCSReference总结的源码阅读笔记,顺便整理出来分享。边啃代码,边写下来。哪里讲的不到位、理解错了,欢迎直接指出来。
Canvas、CanvasGroup、CanvasRenderer 这三个CS类,是引擎的在C# 层的一层薄薄的壳。
这层壳长什么样、壳背后一帧里到底发生了什么、为什么我改了 text.text 画面却不立刻变——这些搞清楚了,UGUI 的很多行为才说得通。下面按我自己的理解,一层一层说。
一、啊欧~Canvas、CanvasGroup、CanvasRenderer三个类都在C++引擎源码里。
com.unity.ugui@1.0.0/Runtime/UI/Core/ 目录里,你能看到 Graphic.cs、Image.cs、Text.cs、Selectable.cs……但就是没有 Canvas.cs、CanvasGroup.cs、CanvasRenderer.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)塞进图形重建队列;而 SetLayoutDirty 走 LayoutRebuilder.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.preWillRenderCanvases 和 Canvas.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先报告自己"想要"的尺寸——minWidth、preferredWidth、flexibleWidth这些。Text量字符串宽度,Image报精灵尺寸,LayoutGroup把子节点尺寸求和。 -
施加水*/垂直接口(自顶向下):让每个
ILayoutController(比如HorizontalLayoutGroup)把算好的尺寸分配到自己和子节点的RectTransform。
为什么水*在垂直前面?很多布局组是"宽决定高"或反过来,引擎约定先解 X 再解 Y。为什么算输入要自底向上?因为父级的 preferred 尺寸依赖子级报上来的数,得先让叶子报数、父级再汇总。为什么施加接口要自顶向下?因为子节点落在哪、占多大,得先等父级定好尺寸。一问一答,方向正好相反。
控制阶段内部还有个顺序细节:同一条 RectTransform 上如果既有"自控制器"又有"组控制器",先跑自控制器(ContentSizeFitter、AspectRatioFitter 这类只管自己的),再跑组控制器(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——这就是 Shadow、Outline 这些特效往顶点里追加东西的地方,最后 FillMesh 灌进 canvasRenderer.SetMesh 交还原生。
两点顺序别搞反:先 SetMesh(几何)再 SetMaterial(材质)。另外 Mask 的模板缓冲(stencil)裁剪,就是在这一步的 UpdateMaterial 里插进材质的——它走的是材质路径。
夹在中间没被注意的 Cull 阶段
布局算完了、尺寸定了、但 mesh 还没生成,正好先定裁剪区。这一步不走队列,而是 ClipperRegistry.Cull() 遍历场景里所有 IClipper(也就是 RectMask2D),它把"自身矩形"和"所有祖先 mask 的矩形"做交集,得到最终可见窗口,再下发给每个 MaskableGraphic 的 CanvasRenderer。整块完全在裁剪区外面的,直接被标记 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_LayoutRebuildQueue和m_GraphicRebuildQueue两个队列、两段 for 循环的物理保证。 -
为什么布局队列要按父级深度排序:
SortLayoutList按每个元素的ParentCount(父链长度)升序排,保证父级先重建、子级后重建,这样子物体才能用父级最新的尺寸。深度排序是够用且高效的——它只保证"祖先先于后代",不保证兄弟间的顺序,但兄弟本来就互不影响,布局的依赖靠ILayoutController的回调链在各自Rebuild内部消化,不是靠这个排序解决。 -
为什么
CleanInvalidItems放最前面:队列里可能残留已经被Destroy的对象。它逆序遍历,把IsDestroyed的剔掉(先调LayoutComplete/GraphicUpdateComplete让它退场、再移除),避免对"已死的对象"调Rebuild抛空引用。逆序遍历是为了RemoveAt时不打乱未处理下标。 -
为什么每个
Rebuild都套 try/catch:一个元素的OnPopulateMesh写崩了,不该连累同帧其它所有 UI 一起不重建,LogException后继续下一个。 -
两个"重入锁"标志:
m_PerformingLayoutUpdate和m_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/relativeDepth:hasMoved是合批/裁剪能否跳过的快路径;absoluteDepth(相对根画布)和relativeDepth(相对父画布)是Graphic.depth的来源,画布内谁压谁就看它。跨画布排序看Canvas.sortingOrder/sortingLayerID,画布内看absoluteDepth——两个维度,调 z-order 前先分清楚你在改哪层。 -
cull/cullTransparentMesh:剔除开关。后者指"所有顶点 alpha 接* 0 时连几何都别提交",省合批。 -
渲染栈 pop(
Mask用的那套):hasPopInstruction、popMaterialCount、SetPopMaterial是一套"先按正常材质画完子树,画完再 pop 回去用 pop 材质重新画一遍"的机制。真正的Mask模板裁剪走的就是这条路——它比"单纯往材质里插 stencil"更像一个带出栈的重绘栈。这几个成员*时用不上,但看懂它能解释"为什么 Mask 附*会*白多出一次 DrawCall",不是玄学。 -
SplitUIVertexStreams/CreateUIVertexStream:如果你不靠VertexHelper,想从裸顶点数组自己造一个Mesh再SetMesh,就靠这对函数把UIVertex拆成位置/颜色/UV/法线/切线/索引各数组喂给 mesh。老的那套SetVertices已标记[Obsolete],别用了。 -
SetMesh的隐性要求:你塞给它的那个 mesh 必须勾了 read/write,运行时才允许持续改顶点;会动的 UI mesh 往往在这里栽第一脚。 -
SetAlphaTexture/SetSecondaryTexture:SetAlphaTexture不是装饰,是安卓 ETC1 压缩路径里真在用的——ETC1 压不了带透明通道的图,Unity 会把一个 RGBA 精灵拆成"RGB 图 + 独立 Alpha 图"两张,渲染时由UI/DefaultETC1shader 经_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 总流程:重建完交给原生之后发生了什么
关键点先立住:"排序"和"合批"是两件独立的原生步骤,排序决定谁盖谁,合批决定能不能省。 顺序排错了,就算条件再相同也合不了;条件总不同,排得再对也只能一个个画。后面两个小节分别讲。
5.2 排序规则:到底谁画在上面
排"谁在上面"不是一句话,得分维度——跨画布看一套,画布内看另一套。我画了一张判定流程:
几点要单独记牢,都是实战容易翻车的:
-
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。下面这张流程把"能不能并"的每一个门槛列全:
一句话记法:相邻 + 同材质 + 同贴图 + 同裁剪区 + 同画布 + 中间无夹层,才能并。 任何一条断掉,就多一次 DrawCall。
5.4 六种真实情景:到底拆不拆、画成什么样
这一节是重点,我把最常见的六种情况一张张画出来。图例:[ ] 表示一次 DrawCall 提交,括号里是它一次性画到的元素。
情景 A:同画布、同图、相邻 → 合并成一个 DrawCall(最理想)
情景 B:中间夹一张不同的图 → 拆成 3 个 DrawCall
情景 C:层级穿插 / 夹层 → 同图也拆、还可能错序
情景 D:RectMask2D / Mask 边界 → 强行断批
情景 E:跨 Canvas → 天然新批次边界
情景 F:跨 Canvas 的上下关系 → sortingOrder 决定谁压谁
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.h,C# 里根本找不到那段乘积代码。你翻遍 ugui 包也翻不到。Graphic 拿到的只是 CanvasRenderer.GetInheritedAlpha() 算好的结果。
关键语义是嵌套连乘,绑定类注释里写得很清楚:最终 alpha = 所有嵌套 CanvasGroup 的 alpha 连乘 × 元素自身 alpha。而且 ignoreParentGroups 的语义容易理解错——它只是"从自己往上、碰到第一个 ignoreParentGroups 就停"地截断链,并不能把你从"自己还挂在 alpha=0 的父组下面"这个事实里救出来。
alpha=0 是最大的坑:假如父 Canvas 上有 CanvasGroup.alpha = 0,整个父画布下什么都不渲染,子节点怎么设 ignoreParentGroups 都白搭。要救出某个子节点,官方给的路子就两条:要么把父 alpha 设成一个很小的非零值,要么给那个子节点单独加一个 Canvas 组件,让它脱离父组的乘法管辖。
另外 interactable、blocksRaycasts、alpha 三条路径在原生层是汇合的,C# 只暴露了旋钮:interactable 走 Selectable.ParentGroupAllowsInteraction 沿父链收集,blocksRaycasts 走 Graphic.Raycast 的 ICanvasRaycastFilter,alpha 走 GetInheritedAlpha。看源码的最大收获之一,就是确认了"这三条路是分开的、而且都在引擎那边"。
七、几个容易翻车的细节,踩过才记得住
这一节攒的是我读过绑定文件、又踩过坑才明白的杂货,按主题归了一下。
自定义 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 事件(贴图重导入等让数据失效时触发),运行时不会冒出来,别在运行时依赖它。 -
stagePriority、normalizedSortingGridSize属于进阶调优项,常规项目碰不到,别乱改。 -
Canvas.targetDisplay管 Overlay 画布显示到哪块屏,最多支持 8 块副屏;它和Camera.targetDisplay(相机出哪块屏)是一个 display 体系,多屏工程里用来把不同 UI 分到不同显示器。 -
Canvas.renderingDisplaySize是"实际渲染像素尺寸"的快捷读取:Overlay = 屏幕分辨率、Camera = 相机覆盖的屏幕区、World = 那个 RectTransform 的宽高。想按"真正会画多大"算东西时别自己猜,直接读它。 -
Canvas.useReflectionProbes只有 World 模式才用得上——让 Canvas 参与反射探针,把合适的反射 cubemap 绑给 shader。常规 UI 别开,世界内 UI 想要反射效果时才碰。 -
rootCanvas/isRootCanvas:isRootCanvas标记根画布;rootCanvas从当前画布逐层往父级找最*的那个根,找不到就返回自己。cachedSortingLayerValue是sortingLayerID的缓存换算值,跨画布频繁比排序层时用它更稳。 -
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 看真实的合批、裁剪、排序来反推行为。
与君共勉🍜~


浙公网安备 33010602011771号