ugui源码学习笔记_第一篇_Canvas篇

做 UGUI 性能排查时,我会先把两件事分开:Graphic / Layout 在 C# 层的重建,以及 Canvas 在原生渲染侧的批次重建。它们都可能出现在 Profiler 里,但处理单位和成本来源不是一回事。
Canvas 是原生批处理边界
Canvas 是 UI 渲染层级的重要边界。归属于不同 Canvas 的 CanvasRenderer 不会被同一个 Canvas 的批构建过程合并;嵌套子 Canvas 也维护自己的几何与批次。因此,把高频变化区域放进独立子 Canvas,通常可以隔离父 Canvas 的原生 rebatch 成本,但也会引入额外 Canvas 和批次边界,不能无限拆。
这里需要明确源码边界:Canvas 的完整渲染与合批实现在 Unity 原生模块中,不在 com.unity.ugui@1.0.0 的 C# Runtime 源码里。Package 能确认的是 Graphic 会把自己登记到以 Canvas 为键的 GraphicRegistry;不能把 Canvas 的私有职责或原生合批算法写成可见的 C# 实现。
结论:跨独立 Canvas 不会进入同一个 Canvas 的批次;同一 Canvas 内也不保证只产生一个批次。材质、纹理、绘制顺序、深度、重叠、裁剪和 Mask 等条件都会影响最终结果,实际以目标 Unity 版本的 Frame Debugger 或 UI Profiler 为准。
两层“重建”不能混为一谈
C# 层由 CanvasUpdateRegistry 保存两个 ICanvasElement 队列:
// CanvasUpdateRegistry.cs,逐字节选
private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>();
private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>();
protected CanvasUpdateRegistry()
{
Canvas.willRenderCanvases += PerformUpdate;
}
Graphic.SetVerticesDirty() 只把当前 Graphic 登记到图形队列,并不让 CanvasUpdateRegistry 遍历该 Canvas 下的全部 Graphic:
// Graphic.cs,逐字节选
public virtual void SetVerticesDirty()
{
if (!IsActive())
return;
m_VertsDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
}
PerformUpdate() 的顺序是:先执行 Prelayout、Layout、PostLayout,再调用 ClipperRegistry.instance.Cull(),最后执行 PreRender、LatePreRender。到了 Graphic.Rebuild(CanvasUpdate.PreRender),也只有已置脏的顶点或材质会分别调用 UpdateGeometry()、UpdateMaterial()。
这是 ICanvasElement rebuild。另一方面,几何、材质或变换变化还可能让所属 Canvas 的原生批数据失效,随后由原生侧重新分析可绘制元素,这是 Canvas rebatch。前者按登记元素处理,后者以 Canvas 为隔离边界;“一个 Image 变化等于 C# 重新生成整个 Canvas 下所有网格”并不成立。
willRenderCanvases 的准确时序
置脏通常不会在属性赋值点立即生成布局或网格,而是在后续 Canvas 渲染前的更新阶段处理。但不能固定说成“下一帧清算”:如果变更发生在本帧相关更新阶段之前,它可以在本帧渲染前生效;如果发生在该阶段之后,才会延到后续帧。
Canvas.ForceUpdateCanvases() 可以同步触发 Canvas 更新流程,让待处理的布局和内容生成在调用后可用。它可能把原本集中在渲染前的工作提前到当前调用点,但不等于无条件重新生成所有 Canvas 下的全部 Graphic。需要立即读取布局结果时可以使用,常规 Update 中不应反复调用。
Canvas 渲染流程:从置脏到提交 DrawCall
把前面几节拼起来,一个 UI 元素从"属性变了"到"真正画到屏幕上",链路是这样的:
- 属性变化 →
Graphic.SetVerticesDirty()/SetMaterialDirty(),把当前 Graphic 登记进CanvasUpdateRegistry的队列(前面两节讲的就是这段)。 Canvas.willRenderCanvases触发 →CanvasUpdateRegistry.PerformUpdate跑 Layout + Graphic rebuild。Graphic.Rebuild走到PreRender,调用UpdateGeometry()/UpdateMaterial(),把网格和材质推给本 Graphic 持有的那个CanvasRenderer:
// Graphic.cs, UpdateGeometry / UpdateMaterial(逐字节选)
canvasRenderer.SetMesh(workerMesh);
canvasRenderer.SetMaterial(materialForRendering, 0);
- 这一步之后,每个 Graphic 的可见数据都落在自己的
CanvasRenderer里了。Canvas 把这个 root Canvas 下所有CanvasRenderer收拢,按材质、纹理、排序等条件做原生合批(rebatch),生成 DrawCall 交给 GPU。
renderMode 决定"有没有相机参与":ScreenSpaceOverlay 不依赖相机,UI 独立画在最上层;ScreenSpaceCamera / WorldSpace 走 worldCamera,并进入该相机的渲染排序体系(用 Canvas 上的 sortingLayerID / sortingOrder / overrideSorting)。
铁律:第 3 步的
SetMesh/SetMaterial是 C# 侧能观察到的边界;第 4 步的合批算法在原生层,Package 里看不到。所以"改一个 Image 的材质就立刻合批"这类判断,必须落到CanvasRenderer的实际状态上,不能凭想象。嵌套子 Canvas 在这一步也是独立单元,不会把自己并入父 Canvas 的批次。
三种相机模式(renderMode)差在哪
Canvas 的 renderMode 不是"画在哪"这么简单,它直接决定相机要不要参与、UI 进不进 Sorting Layer 体系、事件射线从哪发。三种模式各有各的坑。
| 维度 | ScreenSpaceOverlay | ScreenSpaceCamera | WorldSpace |
|---|---|---|---|
| 相机 | 不需要 | 绑定 worldCamera |
绑 worldCamera 发事件,但任意相机都能拍到 |
| 渲染位置 | 屏幕最上层,场景渲染之后 | 相机前方 planeDistance 处的平面 |
世界里的真实 Transform |
| Sorting Layer | 不参与,忽略 CanvasRenderer.sortingOrder |
参与,用 Canvas 的 sortingLayerID / sortingOrder |
参与,按相机和深度排 |
| 与 3D / 特效穿插 | 做不到,永远盖在最前 | 可以(同相机 + sorting layer) | 天然就在世界里 |
| 事件射线 | 屏幕坐标,不经相机 | 从 worldCamera 发 |
从 worldCamera 发 |
| 典型用途 | HUD、全屏弹窗 | UI 与场景物体混合 | 名字板、VR / AR、世界内 UI |
逐条说坑:
- Overlay 永远在最前。它脱离相机渲染流程,场景渲染完之后独立画在最上层,谁也挡不住——除非把 Canvas 切成 Camera / World。想让 3D 模型或粒子出现在 UI 后面?Overlay 做不到,必须切模式。
- Camera 模式要配好
worldCamera和planeDistance。planeDistance是 UI 平面离相机的距离,太近会穿进近裁剪面,太远可能被别的物体挡。它和相机 depth 一起决定多个 Canvas 的先后:先比相机 depth,再比 Canvas 的sortingLayerID/sortingOrder。 - WorldSpace 的"大小"是 world unit。
RectTransform的尺寸是世界单位,视觉大小随相机距离变;CanvasScaler的scaleFactor在 WorldSpace 下不适用(下一篇会细讲)。多相机下每个相机都能渲染它,排序由各自相机决定,别指望一个sortingOrder通吃。 overrideSorting给嵌套子 Canvas 用:开启后子 Canvas 可以脱离父 Canvas 继承的 sorting,自己设sortingLayerID/sortingOrder。多 Canvas 层级里要精调顺序时靠它。
Canvas.sortingOrder 在 Overlay 下只管"这个 Canvas 比那个 Canvas 先画";在 Camera / World 下它和 sortingLayerID 一起进相机的排序体系——具体哪些属性被忽略,CanvasRenderer.sortingOrder 在 Overlay 下是摆设 一节已经拆过,这里不重复。
CanvasGroup:三条路径分别看
CanvasGroup 属于引擎原生类型,Package 中没有完整的 CanvasGroup.cs 实现。可以确认的公开属性是 alpha、interactable、blocksRaycasts 和 ignoreParentGroups;包内文档明确说明,它们影响组件所在 GameObject 及其子对象,而不只是“所有子孙但不含自己”。
alpha:乘法继承
包内测试通过 CanvasRenderer.GetInheritedAlpha() 验证了嵌套 CanvasGroup 的有效 alpha 是乘法结果。例如父组为 0.25、子组为 0.5,子节点继承值为 0.125。CanvasGroup alpha 不需要给每个 Graphic 克隆一份材质,因此不要把它和 Mask 通过 StencilMaterial 生成修改材质的机制混为一谈。
不过,“没有克隆材质”只能说明不会因独立材质实例这一原因拆批,不能进一步保证任何层级和版本下都绝不形成批次边界。批次仍应实测。
ignoreParentGroups=true 会截断更高层 CanvasGroup 对该分支的影响。包内测试同样验证:子组忽略父组后,其继承 alpha 只保留子组自己的值。
blocksRaycasts:Graphic 射线过滤
blocksRaycasts 通过 ICanvasRaycastFilter 参与 UGUI 的 Graphic raycast。Graphic.Raycast() 从命中的目标 Graphic 开始沿父层级向上检查过滤器;遇到启用且 ignoreParentGroups=true 的 CanvasGroup 后,不再应用更高层 CanvasGroup。
它只影响 GraphicRaycaster 这条路径,不影响 Physics.Raycast。设为 false 后,相关 Graphic 不能通过该组的射线过滤,EventSystem 才可能命中后方 Graphic。模态背景若要接收“点击空白关闭”,背景 Graphic 本身通常仍要可命中,不能机械地把整个弹窗组设为不阻挡射线。
interactable:Selectable 的独立路径
interactable 不靠 ICanvasRaycastFilter。Selectable.ParentGroupAllowsInteraction() 会从当前对象向父层收集 CanvasGroup:遇到启用且 interactable=false 的组就返回不可交互;遇到 ignoreParentGroups=true 则停止考虑更高层组。最终 Selectable.IsInteractable() 同时检查组件自己的 m_Interactable 和父组计算结果。
因此,父 CanvasGroup 的 interactable=false 会禁用 Button、Slider、Toggle 等依赖 Selectable.IsInteractable() 的标准控件,但不等于关闭所有自定义 IPointerClickHandler 或 IDragHandler。需要阻止整片区域的事件时,还要结合 blocksRaycasts、背景 Graphic 和具体事件逻辑设计。
Canvas 和 CanvasGroup 到底差在哪
一句话:Canvas 负责"怎么画、何时画、按什么顺序画、合批边界在哪";CanvasGroup 只负责"这片 UI 透不透明、能不能点、挡不挡射线",它不渲染、不合批、不参与任何排序;CanvasRenderer 是挂在每个 Graphic 身上的原生渲染代理,真正持有该元素的 mesh + material,是 Canvas 合批时的"输入单元"。三者不是一个层级的角色,别放一起比。
| 维度 | Canvas | CanvasGroup | CanvasRenderer |
|---|---|---|---|
| 本质 | 原生渲染 / 合批边界 | 逻辑级联控制组件 | 单个 Graphic 的原生渲染代理 |
| 渲染 | 收集子级、合批、出 DrawCall | 不参与 | 持有一个 Graphic 的 mesh + material,供给 Canvas 合批 |
| 排序 | sortingLayerID / sortingOrder / overrideSorting |
无关 | 也有 sortingOrder / sortingLayerID(Overlay 下被忽略,见下节) |
| alpha | 无此概念 | 乘法继承到自身 + 子树 | GetInheritedAlpha() 拿到的是 CanvasGroup 链乘完的结果 |
| 交互 | 无 | interactable / blocksRaycasts |
无(射线过滤在 Graphic / CanvasGroup 层) |
| 合批边界 | 是 | 默认不是 | 不是;它正是被 Canvas 合批的"输入单元" |
把三者放在一张关系图里看:一个 Canvas(root 或嵌套)下面挂了一堆 Graphic,每个 Graphic 自带一个 CanvasRenderer;Canvas 把这些 CanvasRenderer 收拢合批、出 DrawCall;CanvasGroup 不拥有任何 renderer,它只是沿层级往下把 alpha / interactable / blocksRaycasts 级联,级联结果最终落到每个 CanvasRenderer 上——比如 CanvasRenderer.GetInheritedAlpha() 拿到的就是 CanvasGroup 链乘完的 alpha(前面 alpha 一节验证过 0.25 × 0.5 = 0.125)。所以 CanvasRenderer 既不是"小一号的 Canvas",也不是"CanvasGroup 的渲染版":它是单个元素的渲染句柄,排序属性(sortingOrder / sortingLayerID)也在它身上,但 Overlay 模式下同样被忽略(见下一节)。
别把三者混着用:想让某片 UI 灰掉、置灰、不挡点击 → 上 CanvasGroup;想让某片 UI 独立合批或改变渲染顺序 → 上 Canvas(或切 Screen Space - Camera 后用 sorting 属性);想直接操作某个元素的网格 / 材质 / 继承 alpha,那是碰 CanvasRenderer,别去动 Canvas 或 CanvasGroup。
一个容易踩的点:给一个 Canvas 挂 CanvasGroup,CanvasGroup 的 alpha 会乘进这个 Canvas 下所有 Graphic 的继承 alpha(前面 alpha 一节讲过乘法继承),但它不会让这个 Canvas 变成"子画框"或消失——Canvas 依旧是该层级的渲染边界,CanvasGroup 只是给边界里的东西统一加了一层透明 / 开关。嵌套子 Canvas 落在 CanvasGroup 树下也一样:子 Canvas 仍是独立批次,只是它收到的继承 alpha 被父组乘过。
排序、变化与拆 Canvas
Canvas 间排序使用公开的 sortingOrder 等属性;不要把不可见的私有字段名当作 Package 源码。绘制顺序会影响哪些元素能保持相邻,因此排序与合批不是完全无关的两套问题。
RectTransform 尺寸变化时,Graphic.OnRectTransformDimensionsChange() 会让相关 Graphic 顶点变脏,并在非布局重建阶段标记布局;不能据此推导位置、旋转、缩放和尺寸变化都会重新生成全部 Graphic 网格。不过高频变换仍可能触发所属 Canvas 的原生 rebatch。我的处理原则是:先用 Profiler 找到频繁变化的 Canvas,再把确实高频的动画、滚动或计时区域拆到子 Canvas,静态区域保留在父 Canvas,最后用 DrawCall 和 CPU 数据验证收益。
CanvasRenderer.sortingOrder 在 Overlay 下是摆设
CanvasRenderer.sortingOrder 文档描述是 "Sorting order within a canvas"。但它在 Overlay 模式下根本不生效,这是 UGUI 排序里最常被坑的一处。
铁律:Canvas 处于 Screen Space - Overlay 时,
CanvasRenderer.sortingOrder被忽略。Overlay Canvas 完全脱离相机渲染流程、独立画在最上层;同一 overlay Canvas 内部的先后就是 Hierarchy 里从下往上的顺序(越靠下越后画、越在顶)。跨 Canvas 的先后,overlay 模式只看Canvas.sortingOrder(Canvas 组件上那个),不看任何CanvasRenderer.sortingOrder。
CanvasRenderer.sortingOrder 真正起作用的是 Screen Space - Camera 和 World Space 模式:这时 UI 进入相机的排序体系,单个 CanvasRenderer 的 sortingOrder 才能在同 sorting layer 内调先后。
更隐蔽的一点:即便在 Camera / World 模式下,不同 CanvasRenderer 要能靠 sortingOrder 比先后,得在同一个 sortingLayerID 里;你只改 sortingOrder 不改 layer,跨 layer 还是比不了。默认 CanvasRenderer 继承所属 Canvas 的 sorting layer。
工程上的坑长这样:你想在 overlay 弹窗里让某个特效 UI 盖住兄弟节点,去调 CanvasRenderer.sortingOrder 一点反应没有。三个出路:
- 直接调 GameObject 在 Hierarchy 里的位置(最简单,但大项目层级乱了难维护);
- 把要置顶的那片拆成独立 Canvas,调
Canvas.sortingOrder(注意这会新建合批边界,别无脑拆); - 把 Canvas 切到 Screen Space - Camera / World Space,再用
CanvasRenderer.sortingOrder(要配好worldCamera和 sorting layer,否则和 3D / 特效的穿插会更乱)。
另外 Canvas.overrideSorting(Canvas 属性)是给嵌套子 Canvas 用的:开启后子 Canvas 可以脱离父 Canvas 继承的 sorting,自己设 sortingLayerID / sortingOrder。这和 CanvasRenderer.sortingOrder 不是一回事,别搞混。具体排序行为以你项目使用的 Unity 版本为准,跨版本细节可能微调。
与君共勉~

浙公网安备 33010602011771号