Unity UGUI 程序化网格 01:Graphic 与 OnPopulateMesh 的最小边界

回调入口

技能范围、冷却遮罩和自定义仪表盘最终都要提交可绘制数据,但第一步不是画形状,而是确认谁在什么时机拥有这份数据。本文只交付一个不会显示图形的 EmptyGraphic:它能被 Canvas 刷新流程调用,每次调用都清空当前 VertexHelper,并记录调用次数。

// Assets/OnPopulateMeshBook/Chapters/Chapter01_EmptyGraphic/
// Runtime/EmptyGraphic.cs · EmptyGraphic.OnPopulateMesh
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter01
{
    public sealed class EmptyGraphic : Graphic
    {
        public int PopulateCount { get; private set; }

        protected override void OnPopulateMesh(VertexHelper vh)
        {
            PopulateCount++;
            vh.Clear();
        }
    }
}

本文先把“回调没有执行”和“回调执行但主动生成空网格”拆成两个可观察状态。调用计数证明入口是否发生,顶点数和索引数证明当前几何是否为空;顶点、索引与三角形的定义留到下一篇展开。

本文只要求掌握两个核心概念:Graphic 是参与 UGUI 刷新流程的可绘制组件基类;OnPopulateMesh(VertexHelper) 是它重建几何时留给子类的生成入口。CanvasCanvasRenderer 本文只作为调用链两端出现:前者组织 UI 刷新,后者接收最终几何。VertexHelper 暂时只看作一次重建所用的临时容器,不提前讨论内部网格结构。

初学时容易把这四者理解成同一件事的不同名字,实际职责并不重叠。Canvas 可以同时管理许多 Graphic;一个 Graphic 只描述自身绘制数据;CanvasRenderer 是 Graphic 所在对象上的提交组件;OnPopulateMesh 则只是一次重建中的回调。看到空白画面时,必须沿这条边界逐层确认,而不是笼统地说“Canvas 没画出来”。只有把失败定位到对象组织、入口调用或几何内容,后续网格排错才有稳定起点。

空白起点

场景中的 Chapter01_EmptyGraphic 位于 Canvas 下,RectTransform 固定为 320 × 220。Hierarchy 和 Inspector 能看到组件,Game 视图中央却保持空白。这不是故障,而是当前合同的正确结果:组件存在、刷新入口已执行、提交的几何为零。

空 Graphic 的 Unity 实际结果

零顶点与零索引拓扑

Canvas 到 OnPopulateMesh 的调用边界

固定制品把“空白”转换成可核对的数据。输入尺寸仍被场景保存,但组件没有产生顶点、UV、颜色或索引。这里不能从空数组推出“RectTransform 没有意义”;它只证明本文实现没有读取尺寸,也没有向本次生成容器写入几何。

固定画面还承担一个重要的基准作用:后续出现三角形和矩形时,Canvas、相机、背景和对象位置均不需要重新解释,变化只来自组件写入的数据。系列为每章保留独立场景,正是为了冻结这一比较条件。如果直接在同一场景上不断覆盖脚本,读者看到异常时无法判断是本文代码、旧参数还是前章对象残留造成的。

// Artifacts/chapter01.json · 固定输入与输出
{
  "chapter": 1,
  "scene": "Assets/OnPopulateMeshBook/Chapters/Chapter01_EmptyGraphic/Scene/Chapter01.unity",
  "fixedInput": "size=(320,220)",
  "components": [
    {
      "component": "EmptyGraphic",
      "vertices": [],
      "uv": [],
      "colors": [],
      "indices": []
    }
  ]
}

调用边界

正常时序是:Canvas 判断某个 Graphic 的顶点数据需要重建,UGUI 准备 VertexHelper,调用组件的 OnPopulateMesh,组件完整写入本次几何,随后 CanvasRenderer 接收结果。EmptyGraphic 只拥有“如何生成当前几何”的规则,不拥有 Canvas 的刷新时机,也不直接调用 CanvasRenderer。这条边界能避免业务代码越过 UGUI 生命周期手工提交半成品。

本文有两个不变量。第一,每次进入 OnPopulateMesh 都先建立一份自洽的当前结果,旧数据不得残留;所以即使结果为空,也明确执行 Clear。第二,空结果必须是合法结果:顶点数与索引数同时为零,不存在指向缺失顶点的索引。后续所有章节都沿用“先清空、再完整生成、最后由 UGUI 提交”的事务形状。

测试辅助代码模拟这一边界:创建具备 RectTransformCanvasRenderer 的对象,通过反射调用受保护入口,把 VertexHelper 填入真实 Mesh。反射只属于测试侧,运行组件仍由 Unity 生命周期调用。

// Assets/OnPopulateMeshBook/Common/TestSupport/
// MeshTestSupport.cs · CreateGraphic / Build
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.TestSupport
{
    public static class MeshTestSupport
    {
        public static T CreateGraphic<T>(Vector2 size)
            where T : Graphic
        {
            var go = new GameObject(
                typeof(T).Name,
                typeof(RectTransform),
                typeof(CanvasRenderer));
            go.GetComponent<RectTransform>().sizeDelta = size;
            return go.AddComponent<T>();
        }

        public static Mesh Build(Graphic graphic)
        {
            using var helper = new VertexHelper();
            var method = graphic.GetType().GetMethod(
                "OnPopulateMesh",
                BindingFlags.Instance | BindingFlags.NonPublic,
                null,
                new[] { typeof(VertexHelper) },
                null);
            method.Invoke(graphic, new object[] { helper });
            var mesh = new Mesh();
            helper.FillMesh(mesh);
            return mesh;
        }
    }
}

失败时序与正常时序的分界也因此清楚:若脚本没有继承 Graphic,它不进入这条生成链;若对象没有 CanvasRenderer,它不具备完整的 UI 渲染组件组合;若组件被禁用,Canvas 不应把它当作当前可绘制对象。反之,PopulateCount 已递增但画面为空,说明入口执行成功,问题应从生成结果检查,而不是继续怀疑脚本是否挂载。

恢复顺序应与责任边界一致。先确认对象处于 Canvas 层级且组件启用,再观察调用计数,最后检查容器内容。若第一步失败,修改几何代码没有意义;若计数不变,应检查刷新与启停;若计数增加而结果不符,才进入 OnPopulateMesh。这种由外到内的定位方式会贯穿系列,并在最终技能指示器中用于区分“组件未显示”“状态未更新”和“网格生成错误”。

最小组件

在 Canvas 下创建空 UI 对象,命名为 Chapter01_EmptyGraphic,把尺寸设为 320 × 220,再挂载 EmptyGraphic。脚本继承 Graphic 后,Unity 会确保对象具有 CanvasRenderer;场景仍显式保留该组件,使 Hierarchy、测试构造与实际运行采用同一个边界。

OnPopulateMesh 的参数 vh 由 UGUI 提供。方法没有返回值,因为结果写入 vh;调用者在方法结束后继续处理它。PopulateCount 是本文的观察点,不参与绘制。把它设为私有写入可以保证只有生成入口递增计数,外部代码不能伪造“已经生成”。

最小回归同时验证三个事实:生成一次、结果没有顶点、结果没有索引。尺寸设置为 120 × 80,与场景制品的 320 × 220 不同,目的是证明当前空实现不依赖尺寸,而不是只对一组场景参数偶然成立。

// Assets/OnPopulateMeshBook/Chapters/Chapter01_EmptyGraphic/
// Tests/Editor/EmptyGraphicTests.cs · Populate_ProducesNoGeometry
using NUnit.Framework;
using OnPopulateMeshBook.Chapter01;
using OnPopulateMeshBook.TestSupport;
using UnityEngine;

public sealed class EmptyGraphicTests
{
    [Test]
    public void Populate_ProducesNoGeometry()
    {
        var graphic = MeshTestSupport.CreateGraphic<EmptyGraphic>(
            new Vector2(120f, 80f));
        var mesh = MeshTestSupport.Build(graphic);

        Assert.That(mesh.vertexCount, Is.Zero);
        Assert.That(mesh.triangles, Is.Empty);
        Assert.That(graphic.PopulateCount, Is.EqualTo(1));

        MeshTestSupport.Destroy(graphic, mesh);
    }
}

这一实现刻意不预建抽象基类、通用缓冲区或参数系统。十章实验工程允许相邻样例保留少量重复,让每章组件可以直接复制并独立运行。只有创建对象、调用入口和读取 Mesh 这类机械操作进入公共测试辅助代码。

另一个有意保留的限制是 PopulateCount 只提供读取属性,没有事件、日志或编辑器面板。它足以证明一次状态转换,再增加观察设施不会改变结论。真正产品代码通常不需要长期保留这个计数;当三角形能以顶点和索引直接验证后,第二章会删除它。这也是系列的演进原则:观察点只服务于当前无法从输出直接确认的事实,不让临时诊断接口变成永久架构。

运行结果

测试使用 Unity 2022.3.62f3 的无界面 EditMode 入口。命令中的 $UNITY_EDITOR 指向对应版本编辑器,$PROJECT_ROOT 代表仓库根目录;实际路径不得写入系列、日志摘录或图片。

export UNITY_EDITOR="<Unity-2022.3.62f3-editor>"
"$UNITY_EDITOR" \
  -batchmode \
  -nographics \
  -projectPath "$PROJECT_ROOT/.tmp/unity-onpopulate-mesh-lab" \
  -runTests \
  -testPlatform EditMode \
  -testResults Artifacts/editmode-results.xml \
  -logFile Artifacts/editmode.log
echo "exit=$?"

整套基础网格回归共执行 19 项,19 项通过、0 项失败、0 项跳过,进程退出码为 0,总耗时 0.0400379 秒。本文测试自身耗时 0.012106 秒。耗时只用于记录这次执行,不据此声称性能改善。

exit=0
testcasecount=19
passed=19
failed=0
skipped=0
duration=0.0400379s

EmptyGraphicTests.Populate_ProducesNoGeometry
result=Passed
duration=0.012106s
vertices=0
indices=0
populateCount=1

这组输出同时证明正常路径与状态变化:调用前 PopulateCount=0,调用后为 1;固定结果是 vertices=0indices=0。它没有证明组件在所有 Canvas 模式、材质组合或平台上都具有相同调用频率,因此本文不承诺“每帧调用一次”。正确合同只是:UGUI 请求重建时,组件生成一份完整的当前结果。

空白排查

最容易误判的错误是删除 vh.Clear(),然后看到当前测试仍然通过。原因是测试每次创建新的 VertexHelper,容器本来就是空的;单次调用无法暴露旧几何残留。这个实验说明“没有立即失败”不等于语句多余。后续章节会向容器写入数据,一旦同一容器带着旧内容进入重建,增量追加就会破坏完整替换不变量。

// 错误示例:本次生成被误写成对旧数据的追加
protected override void OnPopulateMesh(VertexHelper vh)
{
    PopulateCount++;
    // vh.Clear(); // 故意删除
}

// 观察:
// 新 VertexHelper:0 顶点、0 索引,表面上仍通过
// 含旧数据的 VertexHelper:旧几何不会被本文主动移除
// 修复:恢复 vh.Clear(),每次完整生成当前状态

另一个错误是把 PopulateCount++ 放进 Update。这样得到的是帧计数,不是网格生成计数,无法回答 OnPopulateMesh 是否执行。观察点必须靠近被观察的状态转换,否则日志数量与几何重建次数没有因果关系。

当前方案的边界很窄:它适合验证 UGUI 入口与空网格语义,不适合承担可见占位符、射线命中或布局尺寸提示。若产品需要空组件仍参与点击,应单独定义命中合同;若需要编辑器中显示占位轮廓,应建立编辑器工具。这些需求都不应改变“空生成结果为零顶点、零索引”的基础含义。

演进触发条件不是“代码看起来太简单”,而是证据问题发生变化。本文要回答的是入口是否运行以及空输出是否合法;这两项已经由计数和 Mesh 断言关闭。只有当目标变为“提交一个可以覆盖像素的面”时,才需要引入顶点与索引。提前加入占位顶点会让本文无法再区分空网格与可见网格,也会削弱下一篇的最小差异。

下一步

这篇只确认三件事:Graphic 进入 UGUI 重建链,OnPopulateMesh 拥有当前几何生成,空输出以零顶点、零索引表达。业务层直接构造 Mesh 并提交给 CanvasRenderer 会同时接管刷新、生成与提交,破坏现成的生命周期边界。

下一篇沿用 vh.Clear() 和调用者提供的 VertexHelper,只增加三个顶点与一组三角形索引。变化发生在几何内容,Canvas、Graphic 与 CanvasRenderer 的责任不变。