Unity UGUI 程序化网格 03:两个三角形组成矩形

四点成面

上一篇的一组三角形索引只能覆盖一个三角区域。本文增加一个顶点和第二组三角形索引,在不重复角点的前提下得到完整矩形。核心组件仍保持“清空、添加顶点、添加索引”的顺序,没有引入尺寸适配或颜色插值。

// Assets/OnPopulateMeshBook/Chapters/Chapter03_Quad/
// Runtime/QuadGraphic.cs · QuadGraphic.OnPopulateMesh
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter03
{
    public sealed class QuadGraphic : Graphic
    {
        protected override void OnPopulateMesh(VertexHelper vh)
        {
            vh.Clear();
            var vertex = UIVertex.simpleVert;
            vertex.color = color;

            vertex.position = new Vector2(-60f, -40f);
            vh.AddVert(vertex);
            vertex.position = new Vector2(-60f, 40f);
            vh.AddVert(vertex);
            vertex.position = new Vector2(60f, 40f);
            vh.AddVert(vertex);
            vertex.position = new Vector2(60f, -40f);
            vh.AddVert(vertex);

            vh.AddTriangle(0, 1, 2);
            vh.AddTriangle(2, 3, 0);
        }
    }
}

本文把矩形固定为四个编号,并用两个三角形共享编号 02。这里新增两个术语:共享顶点表示多个三角形引用同一个顶点记录;绕序表示三角形三个索引沿边界排列的方向。后续圆、圆环和扇形都会复用这两个概念。

沿用上一篇的顶点、索引、三角形与索引范围不变量。AddVert 顺序仍决定编号,AddTriangle 仍只写连接关系。Canvas 拥有刷新,QuadGraphic 拥有当前矩形的生成,测试侧只观察最终 Mesh。

从三角形到矩形的关键变化不是“多画一个点”,而是一个面扩展为相互拼接的两个面。只验证每个三角形单独合法还不够,还要验证二者共享正确边、保持一致绕序并覆盖目标区域。这个组合合同正是后续圆环顶点带的最小原型:复杂表面最终仍由相邻三角形连续拼接而成。

两个三角形

矩形四个角固定为:0=(-60,-40) 左下,1=(-60,40) 左上,2=(60,40) 右上,3=(60,-40) 右下。第一组三角形 0,1,2 覆盖左上半部,第二组 2,3,0 覆盖右下半部;二者共享从 02 的对角线,最终覆盖 120 × 80 的完整区域。

四边形的 Unity 实际结果

四顶点与两组三角形索引

共享顶点与提交关系

固定制品记录四个位置、统一颜色和六个索引。UV 仍保持 UIVertex.simpleVert 的零值;本文不把未使用的 UV 解释成纹理合同,直到第 6 篇才赋予它明确含义。

结果图应当观察到边界完整、内部无缺口,并且对角线没有作为额外线条显示。对角线只是两个三角形的共享边,不是单独绘制的线段。若画面出现半个矩形,优先核对第二组三个索引;若两个面颜色未来在接缝处分离,则应检查共享角点的属性,而不是试图隐藏对角线。

// Artifacts/chapter03.json · 四边形固定网格
{
  "chapter": 3,
  "fixedInput": "indices=0,1,2,2,3,0",
  "components": [
    {
      "component": "QuadGraphic",
      "vertices": [
        { "x": -60.0, "y": -40.0, "z": 0.0 },
        { "x": -60.0, "y":  40.0, "z": 0.0 },
        { "x":  60.0, "y":  40.0, "z": 0.0 },
        { "x":  60.0, "y": -40.0, "z": 0.0 }
      ],
      "colors": [
        { "r": 255, "g": 183, "b": 77, "a": 255 },
        { "r": 255, "g": 183, "b": 77, "a": 255 },
        { "r": 255, "g": 183, "b": 77, "a": 255 },
        { "r": 255, "g": 183, "b": 77, "a": 255 }
      ],
      "indices": [0, 1, 2, 2, 3, 0]
    }
  ]
}

拓扑合同

矩形不是由“四个顶点”自动形成的。渲染几何仍以三角形为基本面,所以四个角必须拆成两组索引。这里选择对角线 0—2,因此第一组引用 0,1,2,第二组引用 2,3,0。编号 02 被引用两次,但顶点数组中各只存一份,这就是共享顶点。

若不用共享顶点,也可以为每个三角形各写三个记录,总计六个顶点。对一个纯色矩形,两种写法都可能得到相同画面;四顶点方案更直接地表达“两个面共用同一角点”。后续为四个角分配颜色和 UV 时,共享记录也能保证接缝两侧引用同一份角点属性。这里不建立通用去重器,因为当前编号在草图中已经确定,额外算法只会遮蔽教学目标。

共享并不意味着任何位置相同的点都必须合并。顶点记录除了位置,还会承载颜色与 UV;两个面若需要在同一位置保存不同属性,就必须保留两份记录。本文四个角的属性完全一致,因此共享成立。判断依据是完整顶点属性合同,而不仅是二维坐标,这个边界会在渐变与纹理章节再次使用。

统一绕序是第二个不变量。本文两个三角形都按同一方向沿边界排列。用二维有向面积检查,0→1→22→3→0 得到相同符号,说明两个面朝向一致。系列不依赖某个平台的反面剔除结果来定义正确性;统一绕序本身就是拓扑合同,能让后续新增面遵循同一规则。

// 由固定顶点复算两组三角形的有向面积
static float SignedTwiceArea(
    Vector2 a, Vector2 b, Vector2 c)
{
    return (b.x - a.x) * (c.y - a.y)
         - (b.y - a.y) * (c.x - a.x);
}

var v0 = new Vector2(-60f, -40f);
var v1 = new Vector2(-60f, 40f);
var v2 = new Vector2(60f, 40f);
var v3 = new Vector2(60f, -40f);

Assert.That(SignedTwiceArea(v0, v1, v2), Is.EqualTo(-9600f));
Assert.That(SignedTwiceArea(v2, v3, v0), Is.EqualTo(-9600f));

两个三角形的有向二倍面积都为 -9600,符号一致且绝对值非零。总实际面积为 (9600+9600)/2=9600,也等于矩形宽高 120×80=9600。这项复算只证明固定输入完整覆盖矩形,没有推导任意多边形三角剖分。

矩形组件

TriangleGraphic 出发,把原来的三个位置替换为按“左下、左上、右上、右下”排列的四个角。第四次 AddVert 之后编号 3 才存在,再追加两组三角形。代码每次先清空,因此同一组件多次重建时,输出数量仍应保持 4 个顶点与 6 个索引,而不是随调用次数增长。

在 Canvas 下创建对象,尺寸设为 120 × 80,挂载 QuadGraphic,颜色设为制品中的 RGBA (255,183,77,255)。当前四个坐标恰好覆盖这个尺寸,是为了让画面容易核对;组件并没有读取 RectTransform。将对象尺寸改成 200 × 100 后,几何仍保持 120 × 80,这项限制由下一篇修复。

测试验证四个顶点、完整六索引序列以及所有索引范围。完整序列比只断言 triangles.Length==6 更重要:错误的六个整数同样满足数量,却可能覆盖错误区域。

// Assets/OnPopulateMeshBook/Chapters/Chapter03_Quad/
// Tests/Editor/QuadGraphicTests.cs
using NUnit.Framework;
using OnPopulateMeshBook.Chapter03;
using OnPopulateMeshBook.TestSupport;
using UnityEngine;

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

        Assert.That(mesh.vertexCount, Is.EqualTo(4));
        Assert.That(mesh.triangles, Is.EqualTo(
            new[] { 0, 1, 2, 2, 3, 0 }));
        MeshTestSupport.AssertIndicesInRange(mesh);

        MeshTestSupport.Destroy(graphic, mesh);
    }
}

这条调用链的状态所有者没有变化:QuadGraphic 负责把当前四角和连接关系写入临时容器,VertexHelper 只承载本次结果,CanvasRenderer 接收完整几何。不能让第二个三角形由另一个组件追加到同一容器,否则两个组件共同拥有一份网格,启停与重建顺序都会改变结果。

将两个面留在同一组件还有直接的失败恢复价值:任何一步发生异常,本次几何都可以整体放弃并在下一次重建重新生成。如果把四个角与两组三角形拆给不同对象,局部成功可能留下半个矩形,恢复还要协调多个组件的脏标记。当前规模下,一个组件完整拥有一个矩形是最小且可验证的事务边界。

实际搭建时还应按固定编号检查 Inspector 结果:先确认四个角围成轴对齐边界,再确认两组三角形共同覆盖内部,最后才调整组件颜色。若先用透明色或与背景相同的颜色测试,即使拓扑正确也难以肉眼确认。测试中的完整索引序列负责结构验收,结果图负责覆盖区域验收,两类证据互相补充;任何一项单独通过,都不足以证明“矩形组件已经正确完成”。这也是系列固定保留 JSON、测试与结果图三种证据的原因。

索引验证

仍运行整套 EditMode 回归,确认第一章空网格、第二章三角形和本文矩形能同时保留。命令输出写入工程内的 Artifacts,不把真实编辑器安装位置或工作目录复制到正文。

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.000358 秒。固定输入产生 vertexCount=4indexCount=6,索引恰为 [0,1,2,2,3,0]

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

QuadGraphicTests.Populate_ProducesSharedVertexQuad
result=Passed
duration=0.000358s
vertexCount=4
indexCount=6
triangleCount=2
indices=[0,1,2,2,3,0]

数量可从实现直接复算:四次 AddVert 产生 4 个顶点;两次 AddTriangle 各写三个索引,共 6 个索引;6/3=2 个三角形。共享顶点不意味着索引去重,编号 02 仍需要在第二组三角形中再次出现。当前数据规模固定,没有循环或批量样本,因此本文不宣称四顶点方案带来可测性能收益。

缺角与交叉

第一个错误把第二组三角形写成 0,1,3。索引都在范围内,数量也仍为六,但两个三角形共享了边 0—1,右侧区域没有按预期覆盖。它说明“索引合法”只能排除越界,不能证明拓扑正确;还必须核对编号草图或完整固定序列。

vh.AddTriangle(0, 1, 2);
vh.AddTriangle(0, 1, 3); // 错误:重复使用左边,不沿对角线 0—2

// 数量仍为:
// vertexCount=4
// indexCount=6
// 但固定合同 [0,1,2,2,3,0] 已被破坏
// 修复:
vh.AddTriangle(2, 3, 0);

第二个错误是删除 vh.Clear()。若每次重建在带有旧数据的同一容器上继续追加,第二次加入的新顶点编号会从 4 开始,但代码仍写固定索引 0,1,2,2,3,0。结果不是“第二个新矩形”,而是再次引用旧四角,同时留下未被引用的新顶点;重复调用会不断扩大不一致数据。最小修复不是手工计算偏移量,而是恢复“每次完整替换”的所有权合同。

// 失败时序示意:容器已有上一轮 4 顶点、6 索引
// 省略 vh.Clear()
// 再 AddVert 四次 -> 顶点总数 8,新编号为 4..7
// 仍写 0,1,2,2,3,0 -> 再次引用旧编号

Assert.That(existingVertexCount, Is.EqualTo(4));
var appendedVertexCount = existingVertexCount + 4;
Assert.That(appendedVertexCount, Is.EqualTo(8));

// 修复:生成入口第一句恢复 vh.Clear()

本文方案的适用边界是轴对齐、固定坐标的纯色矩形。它不能随 Pivot 或尺寸变化,也没有四角颜色与纹理语义。演进触发条件十分具体:当 Inspector 改变 RectTransform 后,矩形仍停留在旧 ±60/±40 边界,就进入第 4 篇的尺寸适配;当需要渐变和纹理时,分别进入第 5、6 篇。当前阶段不使用额外参数预建这些能力。

另一个看似简便的替代方案是把一个三角形放大到覆盖矩形,再依靠裁剪去掉多余部分。它把明确的四边形拓扑变成对外部裁剪状态的依赖,无法直接从顶点与索引复算覆盖区域,也为后续 UV 和颜色增加不必要边界。两个三角形已经是 UGUI 网格内表达矩形的最小清晰方案。

尺寸限制

四个共享顶点直接对应四个物理角点,两组三角形通过 [0,1,2,2,3,0] 复用对角线端点。六个独立顶点也能画纯色矩形,但会把后续颜色与 UV 的接缝一致性变成额外状态;当前没有断开属性的需求。

下一篇保持角点顺序、索引和绕序不变,只把固定 ±60/±40 替换为 RectTransform 的实际局部边界。这样可以单独验证尺寸与 Pivot,而不把拓扑变化混进坐标问题。