Unity UGUI 程序化网格 02:VertexHelper 绘制第一个三角形
可见三角形
上一篇已经证明 OnPopulateMesh 会被调用,但空容器不会形成画面。本文保持相同生命周期,只增加三个位置和一组三角形连接关系,得到第一个可见 UI 图形。核心组件只有一次清空、三次 AddVert 和一次 AddTriangle。
// Assets/OnPopulateMeshBook/Chapters/Chapter02_Triangle/
// Runtime/TriangleGraphic.cs · TriangleGraphic.OnPopulateMesh
using UnityEngine;
using UnityEngine.UI;
namespace OnPopulateMeshBook.Chapter02
{
public sealed class TriangleGraphic : Graphic
{
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
var vertex = UIVertex.simpleVert;
vertex.color = color;
vertex.position = new Vector2(-50f, -40f);
vh.AddVert(vertex);
vertex.position = new Vector2(0f, 50f);
vh.AddVert(vertex);
vertex.position = new Vector2(50f, -40f);
vh.AddVert(vertex);
vh.AddTriangle(0, 1, 2);
}
}
}
本文用三个位置和一组三角形索引建立最小可见网格。顶点是网格中的一个数据记录,当前只关心其位置与颜色;索引是指向顶点编号的整数;三角形由三个索引组成。它们不是三套互相独立的数据:位置决定三个角在哪里,索引决定这些角怎样连接。
沿用上一篇的两个不变量:每次 OnPopulateMesh 先 Clear,并在一次调用内完整写入当前几何。Canvas 仍拥有刷新时机,TriangleGraphic 只拥有本次三角形的生成规则,CanvasRenderer 仍在方法返回后接收结果。
上一篇的 PopulateCount 在这里被删除,因为可见三角形和最终 Mesh 已经提供更强的结果证据。调用计数只能说明方法进入过,不能说明数据正确;三顶点、三索引和固定位置同时成立,才足以描述本文输出。观察手段随问题升级而替换,但 Canvas、Graphic 与提交边界保持不变。
三点场景
章节场景中的 RectTransform 为 320 × 220,三角形三个点固定为左下 (-50,-40)、顶部 (0,50)、右下 (50,-40)。坐标位于组件自己的二维平面,X 向右、Y 向上;“局部坐标”术语会在第 4 篇结合 RectTransform 正式定义,本文只按给定数值摆放三个点。自动测试另用 120 × 100 的容器证明固定顶点不依赖场景尺寸。



AddVert 每调用一次,就把当前 UIVertex 的副本追加到容器。本文按加入顺序获得编号 0、1、2。AddTriangle(0,1,2) 不会创建新顶点,它只写入三个整数,声明现有编号怎样组成一个面。固定制品因此必须同时出现三条顶点记录和三个索引,少任何一侧都不能证明画面正确。
目标图中的编号不是 Unity 自动显示的界面元素,而是阅读网格数据的标注。运行时真正保存的是按顺序排列的顶点记录与索引数组。把编号先写在草图上,再照着草图实现,比不断调换 AddTriangle 参数更可靠;当形状扩展到几十个三角形时,靠试错已经无法维护。第二章就建立这一习惯,是为了让后续循环生成仍能回到明确编号。
// Artifacts/chapter02.json · 固定输入到网格输出
{
"fixedInput": "vertices=(-50,-40),(0,50),(50,-40)",
"components": [
{
"component": "TriangleGraphic",
"vertices": [
{ "x": -50.0, "y": -40.0, "z": 0.0 },
{ "x": 0.0, "y": 50.0, "z": 0.0 },
{ "x": 50.0, "y": -40.0, "z": 0.0 }
],
"uv": [
{ "x": 0.0, "y": 0.0 },
{ "x": 0.0, "y": 0.0 },
{ "x": 0.0, "y": 0.0 }
],
"colors": [
{ "r": 72, "g": 202, "b": 142, "a": 255 },
{ "r": 72, "g": 202, "b": 142, "a": 255 },
{ "r": 72, "g": 202, "b": 142, "a": 255 }
],
"indices": [0, 1, 2]
}
]
}
顶点与索引
UIVertex.simpleVert 提供一份完整的初始顶点记录。代码先把组件的 color 赋给它,然后只改变 position 并重复加入。由于 AddVert 保存的是当时数据,后续修改局部变量不会回头移动已加入的顶点。三个记录最终共享颜色,却拥有不同位置。
这里的“共享颜色”只是三个记录恰好复制了相同值,不是共享同一可变对象。把局部变量的 position 改成顶部坐标后,编号 0 仍保留左下位置;否则三个编号最后都会指向右下角,测试中的顶部断言也无法通过。理解值被逐次写入容器,可以避免把 UIVertex 当作场景中的 Transform 引用。
本文的不变量从“空结果合法”扩展为三项。第一,所有索引必须满足 0 <= index < vertexCount;三个顶点只能引用 0 到 2。第二,构成可见三角形的三个点不能全部落在同一直线上,否则几何面积为零。第三,位置与索引必须在同一次生成中共同提交,不能先提交三个点、等待下一帧再补连接关系。
正常时序为:清空容器,准备顶点模板,按编号顺序添加三个顶点,最后添加索引 0,1,2,返回给 UGUI。失败时序有三类:只有顶点没有索引时,数据存在但没有任何面;索引引用 3 时,它超出当前三个顶点的范围;三个点共线时,索引合法却只能形成零面积几何。前两类是结构错误,第三类是几何退化,检查方式不同。
为避免把测试辅助工具误当成运行时架构,本文只复用它的两项机械职责:主动调用受保护入口,以及验证索引范围。实际 Canvas 不依赖反射,也不会调用 AssertIndicesInRange。
// Assets/OnPopulateMeshBook/Common/TestSupport/
// MeshTestSupport.cs · AssertIndicesInRange
public static void AssertIndicesInRange(Mesh mesh)
{
foreach (var index in mesh.triangles)
{
if (index < 0 || index >= mesh.vertexCount)
{
throw new System.InvalidOperationException(
$"index {index} outside vertex count {mesh.vertexCount}");
}
}
}
三角形实现
从上一篇复制独立组件目录,将类名与命名空间改为 TriangleGraphic。删除调用计数,因为可见几何和自动测试已经成为更直接的观察点。保留 vh.Clear(),创建一个 UIVertex.simpleVert,设置颜色后依次写入三个位置。最后才调用 AddTriangle,这样索引只会指向已经加入的顶点。
在 Canvas 下创建对象,尺寸设为 320 × 220,挂载 TriangleGraphic,颜色设为制品中的 RGBA (72,202,142,255)。如果 Game 视图只看到背景,依次检查组件是否启用、颜色透明度是否为零、是否存在三次 AddVert,再检查 AddTriangle。这种顺序先确认调用链,再确认数据,不靠反复交换数字碰运气。
回归测试固定验证数量、完整索引序列、顶部位置和索引范围。只断言顶部点并不意味着其他坐标不重要;完整三点已由章节 JSON 保存,测试则选择一个方向明确的代表点,避免在入门章节堆叠重复断言。
// Assets/OnPopulateMeshBook/Chapters/Chapter02_Triangle/
// Tests/Editor/TriangleGraphicTests.cs
using NUnit.Framework;
using OnPopulateMeshBook.Chapter02;
using OnPopulateMeshBook.TestSupport;
using UnityEngine;
public sealed class TriangleGraphicTests
{
[Test]
public void Populate_ProducesThreeVerticesAndOneTriangle()
{
var graphic = MeshTestSupport.CreateGraphic<TriangleGraphic>(
new Vector2(120f, 100f));
var mesh = MeshTestSupport.Build(graphic);
Assert.That(mesh.vertexCount, Is.EqualTo(3));
Assert.That(mesh.triangles, Is.EqualTo(
new[] { 0, 1, 2 }));
Assert.That(mesh.vertices[1], Is.EqualTo(
new Vector3(0f, 50f, 0f)));
MeshTestSupport.AssertIndicesInRange(mesh);
MeshTestSupport.Destroy(graphic, mesh);
}
}
当前实现故意使用固定位置,不读取 RectTransform 实际边界。这样做的取舍是:本文能把注意力集中在“顶点记录 + 索引连接”,代价是改变 UI 尺寸时三角形不会自动伸缩。这个限制不是遗漏,第 4 篇才会由 RectTransform 提供边界。现在提前抽象尺寸算法会同时引入两个新问题,破坏循序渐进。
颜色也采用单值而非三角渐变。组件的 color 属于 Graphic 已有属性,把它复制到三个顶点就能得到稳定纯色;透明度若为零,几何数据仍然存在,却不会覆盖可见像素。因此排查画面时要把“Mesh 结构正确”和“颜色可见”分开。第 5 篇会正式研究每个顶点不同颜色,本文只要求三个记录的颜色一致且不透明。
创建对象的操作顺序同样可以验证。先在 Canvas 下建立空对象并设置尺寸,再挂载脚本,最后设置颜色;每完成一步都保留一个可观察状态。脚本挂载前没有自定义生成入口,挂载后 Mesh 应变为三顶点三索引,颜色修改只改变顶点颜色而不改变坐标和索引。把结构变化与表现变化分阶段观察,能避免一次改动多个参数后无法定位原因。若复制本文样例到另一 Canvas,验收仍以固定 Mesh 数据为准,而不是以屏幕像素大小猜测坐标是否正确。
数量与坐标
使用与第一章相同的 EditMode 命令运行整个基础网格测试集。统一运行而不是只筛选本文,能够证明新增三角形没有破坏已经交付的空网格样例。命令保持项目路径和编辑器位置脱敏。
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=$?"
进程退出码为 0;19 项测试全部通过,总耗时 0.0400379 秒。本文用例耗时 0.002151 秒。固定输入产生 3 个顶点、3 个索引和 1 个三角形,顶部点为 (0,50,0),所有索引均落在 [0,2]。
exit=0
testcasecount=19
passed=19
failed=0
skipped=0
duration=0.0400379s
TriangleGraphicTests.Populate_ProducesThreeVerticesAndOneTriangle
result=Passed
duration=0.002151s
vertexCount=3
indexCount=3
triangles=[0,1,2]
topVertex=(0,50,0)
数量关系可以复算:一次 AddVert 追加一个顶点,调用三次得到 vertexCount=3;一次 AddTriangle 追加三个索引,得到 indexCount=3;每组三个索引表示一个三角形,因此三角形数为 3/3=1。本文没有进行批量生成或 Profiler 采样,不对耗时和内存效率作推论。
消失原因
第一个错误只保留三个 AddVert,删除 AddTriangle。结果会得到 vertexCount=3、indexCount=0:顶点是数据记录,不是自动连线的点。恢复 vh.AddTriangle(0,1,2) 后才形成可绘制面。
// 错误示例一:只有点,没有面
vertex.position = new Vector2(-50f, -40f);
vh.AddVert(vertex);
vertex.position = new Vector2(0f, 50f);
vh.AddVert(vertex);
vertex.position = new Vector2(50f, -40f);
vh.AddVert(vertex);
// vh.AddTriangle(0, 1, 2); // 故意删除
// 结果:vertexCount=3, indexCount=0
// 修复:恢复一组三角形索引
第二个错误把三个位置改为 (-50,0)、(0,0)、(50,0)。索引仍在范围内,但三个点共线,三角形面积为零。可用二维叉积形式检查两条边:(b-a).x*(c-a).y - (b-a).y*(c-a).x;结果为零时没有可覆盖像素的面积。公式只用于识别退化,不在本文扩展向量课程。
var a = new Vector2(-50f, 0f);
var b = new Vector2(0f, 0f);
var c = new Vector2(50f, 0f);
var twiceArea =
(b.x - a.x) * (c.y - a.y) -
(b.y - a.y) * (c.x - a.x);
Assert.That(twiceArea, Is.Zero);
// 索引合法,但几何退化。
// 修复:至少让一个点离开同一直线。
看似合理的替代方案是为三个角各创建一个 UI Image,再靠布局拼成三角形。它绕过了本文要建立的网格数据合同,也不能表达任意三角面;当后续圆形需要几十个面时,子对象数量会替代顶点数量成为新的状态负担。当前单组件方案更贴近最终技能范围的生成边界。
本文的恢复策略也保持最小。索引越界时,不应该通过增加无意义顶点让错误编号“合法”,而应回到草图修正连接;三个点共线时,不应该重复提交相同三角形,而应修正位置输入;颜色透明时,只调整颜色,不改拓扑。每种失败都在拥有该状态的层内修复,避免一个补丁掩盖另一个合同的错误。
四边形伏笔
三个位置不会自动形成面;可见三角形需要顶点记录与索引关系同时成立。索引写入时,被引用的编号必须已经存在,三个位置还必须形成非零面积。移动顶部只改变编号 1 的位置,不需要改动 [0,1,2]。
下一篇在这套编号规则上增加第四个顶点,并让两组三角形共享角点。索引范围不变量不变,新的问题变成两个合法三角形如何共同覆盖一个矩形。