Unity UGUI 程序化网格 04:RectTransform 如何决定顶点位置

固定坐标失效

前三篇已经确定四边形由左下、左上、右上、右下四个顶点组成,索引固定为 0,1,2, 2,3,0。本文不改变拓扑,只解决顶点坐标由谁决定。完成后,读者应能从宽高与 Pivot 算出四条边界,并让组件尺寸改变后仍填满自身区域。

本文输入合同
size        = (360, 180)
pivot       = (0.2, 0.25)
vertexOrder = 左下、左上、右上、右下
indices     = 0,1,2, 2,3,0
expected    = x[-72,288], y[-45,135]

沿用两个结论:OnPopulateMesh 每次先调用 Clear;顶点坐标采用 RectTransform 的本地二维坐标,X 向右、Y 向上。第 3 篇固定的 X=±60、Y=±40 只适合一个特定尺寸,不能成为 UI 组件自己的尺寸来源。

这一章是从“能画一个矩形”走向“能写一个 UI 组件”的分界。固定坐标把几何尺寸藏在源码里,Inspector 显示的矩形边界却属于另一个对象;两者恰好相同只是偶然。可复用组件必须让唯一的布局事实源驱动顶点,否则策划调整宽高、父布局重新排版或美术修改 Pivot 后,画面会继续停留在脚本作者最初假定的位置。

尺寸与 Pivot

RectTransform 尺寸与 Pivot 驱动的矩形结果

在场景中创建 Canvas 子对象,挂载 RectQuadGraphic,把 Width 设为 360、Height 设为 180、Pivot 设为 (0.2, 0.25)。目标不是让本地原点永远位于矩形中心,而是让矩形的四条边始终等于当前局部矩形的四条边。此时原点位于左边向右 20%、下边向上 25% 的位置,因此左侧还有 72 个单位,右侧还有 288 个单位。

Inspector 固定值
RectTransform.Width  = 360
RectTransform.Height = 180
RectTransform.Pivot  = X 0.2, Y 0.25
Graphic.Color        = RGBA(113, 91, 255, 255)
Raycast Target       = false

这里的状态所有者是 RectTransform:它保存尺寸与 Pivot;RectQuadGraphic 只在生成网格时读取结果。若组件再保存一份 widthheight,布局系统修改 RectTransform 后两份状态就可能分叉。正常时序是“布局写入 RectTransform → UGUI 标记顶点脏 → OnPopulateMesh 读取当前矩形 → CanvasRenderer 接收新网格”;失败时序则是“布局改变 → 组件继续使用旧常量 → 画面尺寸与交互区域不一致”。

复现画面时,从空 Canvas 开始即可:创建空 UI 对象,删除默认 Image,挂上 RectQuadGraphic,再设置 Width、Height 与 Pivot。组件不需要 Update,也不需要自行监听鼠标。改变 RectTransform 后,UGUI 的重建流程会再次请求顶点数据;本文组件只需保证每次请求都从当前矩形完整重建。若为了“立即更新”而在 Update 中不断调用 SetVerticesDirty,只是在重复触发已经存在的布局通知,并没有解决新的状态问题。

局部边界

Pivot 是本地原点在矩形中的归一化位置,不是一个额外位移。设宽高为 W、H,Pivot 为 (px, py),四条边界可以直接复算:

xMin = -px * W
xMax = (1 - px) * W
yMin = -py * H
yMax = (1 - py) * H

W=360, H=180, px=0.2, py=0.25
xMin=-72, xMax=288
yMin=-45, yMax=135

中心 Pivot (0.5,0.5) 才会得到常见的 ±W/2±H/2。左下 Pivot (0,0) 会得到 x=[0,W]、y=[0,H];右上 Pivot (1,1) 则得到 x=[-W,0]、y=[-H,0]。三种情况改变的是原点相对矩形的位置,不改变矩形自身的宽高。

还要区分 Pivot 与 Anchor。Anchor 决定 RectTransform 怎样从父矩形求出自己的布局结果,Pivot 决定本地原点落在自身矩形的哪里。OnPopulateMesh 不参与父子布局求解,它只读取布局完成后的局部 Rect。因此本文公式以最终 W、H、px、py 为输入,而不是尝试从 Anchor Min、Anchor Max 和父尺寸反推一遍。把布局求解复制进 Graphic,不但增加代码,还会在 LayoutGroup 或拉伸锚点参与时得到另一套答案。

四个顶点与两组三角形索引的局部坐标拓扑

GetPixelAdjustedRect() 返回供当前 Graphic 使用的局部矩形。组件应读取它的 xMin/xMax/yMin/yMax,而不是根据 sizeDelta 自行猜测最终尺寸;锚点、布局和像素调整已经属于 RectTransform 与 Canvas 的边界。本文只消费结果,不复制布局算法。

局部坐标也不能与屏幕坐标混用。JSON 中的 (-72,-45) 不是屏幕左下角的像素位置,而是当前 UI 对象本地原点到左下顶点的向量。父对象的缩放、旋转和位置会在后续变换阶段作用于整个四边形;本文顶点无需预先乘父变换。若把屏幕坐标直接写进 UIVertex.position,对象移动时就会再被父变换一次,产生重复偏移。

组件实现

下面是本文可以直接复制的完整组件。与第 3 篇相比,顶点顺序和索引完全不变,唯一实质变化是把固定坐标替换为 GetPixelAdjustedRect() 的四条边界。

// Assets/OnPopulateMeshBook/Chapters/Chapter04_RectTransform/
// Runtime/RectQuadGraphic.cs · RectQuadGraphic
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter04
{
    public class RectQuadGraphic : Graphic
    {
        protected override void OnPopulateMesh(VertexHelper vh)
        {
            vh.Clear();
            var rect = GetPixelAdjustedRect();
            if (rect.width <= 0f || rect.height <= 0f) return;
            AddVertex(vh, new Vector2(rect.xMin, rect.yMin));
            AddVertex(vh, new Vector2(rect.xMin, rect.yMax));
            AddVertex(vh, new Vector2(rect.xMax, rect.yMax));
            AddVertex(vh, new Vector2(rect.xMax, rect.yMin));
            vh.AddTriangle(0, 1, 2);
            vh.AddTriangle(2, 3, 0);
        }

        private void AddVertex(VertexHelper vh, Vector2 position)
        {
            var vertex = UIVertex.simpleVert;
            vertex.color = color;
            vertex.position = position;
            vh.AddVert(vertex);
        }
    }
}

逐步实现时,先保留第 3 篇的四个 AddVertex 与两个 AddTriangle,确认顶点编号没有变化;再在 Clear 后读取一次 rect;最后只替换四个位置。不要在每个顶点前重复调用 GetPixelAdjustedRect(),因为四个顶点必须来自同一次几何快照。该规则不是性能技巧,而是完整提交不变量:同一轮生成不能混入两组边界。

在 Unity 编辑器中验证每一步时,可以先使用中心 Pivot,确认改造没有改变第 3 篇的画面;再切到左下 Pivot,确认本地原点落在左下角;最后使用固定 (0.2,0.25),对照坐标表。这样的顺序分别隔离“拓扑被改坏”“Pivot 没有生效”和“公式计算错误”。一次同时修改尺寸、Pivot、颜色和索引,出现缺角后将无法判断是哪一层破坏了合同。

组件有两个不可破坏的不变量。其一,vertices[0] 永远代表左下而不是“最靠近原点的点”;Pivot 改变后,左下点未必是负坐标。其二,两组三角形只能引用本轮已经写入的四个顶点。只要这两项成立,后续顶点色和 UV 都能沿用相同编号。

把这段实现放回 UGUI 调用链看,RectQuadGraphic 并不主动把 Mesh 推给 CanvasRenderer。UGUI 在需要重建时提供一个 VertexHelper,组件向其中写入本轮完整数据,框架随后完成提交。于是 OnPopulateMesh 的职责边界非常窄:读取当前组件状态、清空旧记录、写入一致快照。它不应修改 RectTransform,也不应在生成中触发布局刷新;若一边读取矩形一边改尺寸,下一轮重建条件会与本轮几何互相纠缠。

读者可以用三次 Inspector 操作检查这一边界。先只改 Width,矩形的 X 跨度应变化,Y 边界保持;再只改 Height,只有 Y 跨度变化;最后只改 Pivot,宽高差值保持为原值,但四个坐标相对原点整体移动。若改 Pivot 后宽度也变化,说明代码没有直接使用同一 Rect 的最小值和最大值,或场景中还有布局组件在同时改尺寸,需要先隔离布局输入。

尺寸验证

测试辅助函数建立一个只含 RectTransformCanvasRenderer 和目标 Graphic 的对象,通过真实 OnPopulateMesh 生成 Mesh。测试使用更容易核对的 size=(120,80)、pivot=(0,0):左下顶点必须落在本地原点,右上顶点必须为 (120,80,0)

// Assets/OnPopulateMeshBook/Chapters/Chapter04_RectTransform/
// Tests/Editor/RectQuadGraphicTests.cs
using NUnit.Framework;
using OnPopulateMeshBook.Chapter04;
using OnPopulateMeshBook.TestSupport;
using UnityEngine;

public sealed class RectQuadGraphicTests
{
    [Test]
    public void Populate_UsesRectTransformPivot()
    {
        var graphic = MeshTestSupport.CreateGraphic<RectQuadGraphic>(
            new Vector2(120f, 80f),
            Vector2.zero);
        var mesh = MeshTestSupport.Build(graphic);
        Assert.That(mesh.vertices[0], Is.EqualTo(Vector3.zero));
        Assert.That(mesh.vertices[2],
            Is.EqualTo(new Vector3(120f, 80f, 0f)));
        MeshTestSupport.Destroy(graphic, mesh);
    }

    [Test]
    public void Populate_ZeroWidthProducesEmptyMesh()
    {
        var graphic = MeshTestSupport.CreateGraphic<RectQuadGraphic>(
            new Vector2(0f, 80f));
        var mesh = MeshTestSupport.Build(graphic);
        Assert.That(mesh.vertexCount, Is.Zero);
        Assert.That(mesh.triangles, Is.Empty);
        MeshTestSupport.Destroy(graphic, mesh);
    }
}

固定场景产物提供了另一组输入。它没有只验证“能看到矩形”,而是记录全部顶点与索引,使结果可以脱离截图复算。

{
  "fixedInput": "size=(360,180) pivot=(0.2,0.25)",
  "component": "RectQuadGraphic",
  "vertices": [
    [-72.0, -45.0, 0.0],
    [-72.0, 135.0, 0.0],
    [288.0, 135.0, 0.0],
    [288.0, -45.0, 0.0]
  ],
  "indices": [0, 1, 2, 2, 3, 0],
  "vertexCount": 4,
  "indexCount": 6
}

RectTransform 到 VertexHelper 再到 CanvasRenderer 的数据关系

同一轮 EditMode 批处理执行系列基础网格测试。下面的命令使用语义化环境变量,避免把编辑器与项目的本机位置写入系列;最终数量以紧随其后的真实输出为准。

"$UNITY_EDITOR" \
  -batchmode -nographics -quit \
  -projectPath "$PROJECT_ROOT/.tmp/unity-onpopulate-mesh-lab" \
  -runTests -testPlatform EditMode \
  -testResults "$ARTIFACTS/editmode-results.xml" \
  -logFile "$ARTIFACTS/editmode.log"
test-run result=Passed
total=19 passed=19 failed=0 skipped=0
duration=0.0400379s

RectQuadGraphicTests.Populate_UsesRectTransformPivot
result=Passed
duration=0.000501s
RectQuadGraphicTests.Populate_ZeroWidthProducesEmptyMesh
result=Passed
duration=0.000167s
process_exit_code=0

这里没有性能结论。证据只支持三个判断:四个顶点与六个索引保持不变;Pivot 为 (0,0) 时边界测试通过;固定 Pivot 为 (0.2,0.25) 时 JSON 坐标满足公式。

验证层次也要分清。Game 视图适合确认矩形是否填满自己的可见区域;JSON 适合核对固定输入的四个精确位置;EditMode 测试适合在后续章节修改代码时阻止 Pivot 规则回退。三类证据互相补充,不能互相替代:一张截图难以区分 287.5288,单元测试也不会告诉读者最终画面在 Canvas 中是否摆放合理。

退化矩形

故意把实现改成 ±width/2±height/2,代码仍可编译,中心 Pivot 下也看似正确,但左下 Pivot 测试会立即失败。这类错误危险之处在于它不是越界索引,而是合法网格出现在错误的局部位置。

// 失败边界:只对 Pivot=(0.5,0.5) 成立
var rect = GetPixelAdjustedRect();
var halfWidth = rect.width * 0.5f;
var halfHeight = rect.height * 0.5f;

AddVertex(vh, new Vector2(-halfWidth, -halfHeight));
AddVertex(vh, new Vector2(-halfWidth, halfHeight));
AddVertex(vh, new Vector2(halfWidth, halfHeight));
AddVertex(vh, new Vector2(halfWidth, -halfHeight));
failure input:
size=(120,80) pivot=(0,0)

wrong vertices[0]=(-60,-40,0)
expected vertices[0]=(0,0,0)
wrong vertices[2]=(60,40,0)
expected vertices[2]=(120,80,0)

recovery:
read rect.xMin/xMax/yMin/yMax directly
keep vertex order and indices unchanged

修复不能靠给四个点再加一段 Pivot 偏移,因为那是在重新实现 Rect 已经提供的边界,并扩大出错面。最小且稳定的做法就是把 rect 当作本轮几何事实源。若将来需要留白,应该把“内缩后的矩形”明确建模为新的输入,而不是暗中抵消 Pivot。

零尺寸现在由生成边界直接处理:Clear 后读取矩形,只要宽或高不大于零就返回,不添加任何顶点与索引。Populate_ZeroWidthProducesEmptyMesh 使用 (0,80) 验证顶点数为零且三角形数组为空。这样布局把某一维压到零时,组件提交的是确定的空网格,而不是四个重合点与两枚零面积三角形。

另一个观察点是负坐标本身不是错误。中心 Pivot 下左边和下边通常为负,表示它们位于本地原点的左侧与下侧;只要四条边满足 xMax-xMin=WyMax-yMin=H,矩形仍然正确。真正的失败是边界差值与 Rect 不一致、顶点编号变换,或索引引用越界。把“出现负数”当作异常并强行取绝对值,会让中心 Pivot 的四个点折叠到同一象限。

颜色准备

RectTransform 已经拥有布局后的尺寸与 Pivot,RectQuadGraphic 再保存一份宽高只会制造第二个状态所有者。稳定合同是每轮读取一个局部矩形,按左下、左上、右上、右下写入四点,再提交固定索引。

工程上的取舍是保留四次显式 AddVertex,没有抽象成通用矩形生成器。十几行重复让初学者能够把位置、编号和后续颜色逐项对照;直到确实出现多个生产组件共享且容易漂移的几何合同,才值得提取公共函数。当前规模的演进触发条件不是“看见重复”,而是两个以上组件需要共同修复同一条坐标规则。

下一篇只为这四个顶点分别填写颜色,不修改坐标来源、编号或索引。位置和表现属性继续附着在同一顶点记录上。