Unity UGUI 程序化网格 06:UV 坐标如何映射一张纹理

纹理落点

前两章分别确定顶点“画在哪里”和“带什么颜色”。本文增加 uv0,回答纹理“从哪里取样”。完成后,读者应能把纹理四角对应到四边形四角,解释 mainTexture 的作用,并区分纹理变化与顶点变化使用的脏标记。

本文输入合同
v0 左下 position -> uv(0,0)
v1 左上 position -> uv(0,1)
v2 右上 position -> uv(1,1)
v3 右下 position -> uv(1,0)
texture = fixed 4x4 checker
indices = 0,1,2, 2,3,0

沿用前两篇的顶点合同:四边形仍按左下、左上、右上、右下写入,位置仍来自 GetPixelAdjustedRect()。颜色仍然写入每个 UIVertex;本文快照使用继承自 Graphic 的组件颜色作为统一色调,不再重复第 5 篇的四角颜色字段。

“位置决定画在哪里,UV 决定取哪里”是本文贯穿始终的判断。两组坐标都写在顶点里,但量纲不同:位置使用 RectTransform 本地单位,UV 使用归一化纹理坐标。把它们都称为“坐标”却不说明空间,会导致最常见的错误——把宽高像素直接写入 UV,或把零到一的 UV 当成本地位置。

UV 场景

零到一 UV 映射固定棋盘纹理的结果

场景对象尺寸为 420×240,Pivot 为中心,四个顶点颜色均为白色。固定纹理是一张 4×4 棋盘,用明显不同的格子帮助核对方向。白色顶点不会给纹理增加额外色偏,因此固定画面主要反映 UV 与纹理的对应关系。

复现时先创建空 UI 对象、挂载 TextureQuadGraphic,设置固定尺寸,再把棋盘纹理拖入 Texture 字段。若尚未提供纹理,组件通过白纹理回退仍会显示统一顶点颜色;这说明几何链存在,但不等于自定义纹理已经提交。赋值后应逐角核对棋盘方向,再用 Mesh UV 表确认,不能只凭“出现花纹”判断映射正确。

Inspector 固定值
RectTransform = 420 x 240
Pivot         = (0.5, 0.5)
Color         = RGBA(255,255,255,255)
Texture       = Checker4x4

corner mapping:
texture bottom-left -> rect bottom-left
texture top-left    -> rect top-left
texture top-right   -> rect top-right
texture bottom-right-> rect bottom-right

位置与 UV 是同一个顶点的两组属性。改变位置会移动几何边界,改变 UV 会保留边界但改变采样位置。把这两件事拆开,是后续生成圆形时仍能保持判断清晰的关键:先证明几何拓扑,再证明附加属性。

采样坐标

UV 是纹理空间中的二维坐标,本文只使用闭区间 [0,1]u=0 表示纹理左边,u=1 表示右边;v=0v=1 分别对应本固定纹理的下边与上边。四个角覆盖完整矩形,因此整个纹理被映射到一个 UI 四边形上。

position                    uv
(-210,-120) left-bottom     (0,0)
(-210, 120) left-top        (0,1)
( 210, 120) right-top       (1,1)
( 210,-120) right-bottom    (1,0)

position rectangle size = 420 x 240 local units
uv rectangle size       = 1 x 1 normalized units

四边形位置与零到一 UV 的逐顶点对应关系

UV 不保存像素坐标。纹理换成不同分辨率后,(1,1) 仍表示右上边界;具体像素过滤由纹理和材质设置决定。本文没有验证过滤模式,因此不讨论棋盘边缘是否锐利,也不把显示结果扩展成纹理采样性能结论。

归一化坐标还允许只取纹理的一部分。例如把右侧两个顶点的 u1 改为 0.5,四边形位置完全不动,却只展示纹理左半区域。相反,若把顶点位置缩到一半而保持 UV 不变,仍会展示整张纹理,只是显示区域变小。两组实验能帮助读者确认哪一层拥有裁剪范围,哪一层拥有 UI 尺寸。

本文固定纹理的上下方向由实际 uv0 表与场景结果共同定义,不把其他图像工具的原点约定带进来。外部资源若看起来上下翻转,应先确认导入后的纹理内容与材质采样约定,再决定是否交换 v=0v=1。不能因为某一张图片方向不同,就改掉系列已经锁定的顶点编号。

纹理组件

组件新增一个序列化 Texture 字段,并覆盖 mainTexture。UGUI 查询 Graphic.mainTexture 来获得渲染所用纹理;若字段为空,返回 s_WhiteTexture,组件仍能显示自己的顶点颜色,而不是把空引用交给渲染边界。

// Assets/OnPopulateMeshBook/Chapters/Chapter06_UV/
// Runtime/TextureQuadGraphic.cs · TextureQuadGraphic
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter06
{
    public sealed class TextureQuadGraphic : Graphic
    {
        [SerializeField] private Texture texture;

        public override Texture mainTexture =>
            texture == null ? s_WhiteTexture : texture;

        public void SetTexture(Texture value)
        {
            if (texture == value) return;
            texture = value;
            SetMaterialDirty();
        }

        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),
                new Vector2(0f, 0f));
            AddVertex(vh,
                new Vector2(rect.xMin, rect.yMax),
                new Vector2(0f, 1f));
            AddVertex(vh,
                new Vector2(rect.xMax, rect.yMax),
                new Vector2(1f, 1f));
            AddVertex(vh,
                new Vector2(rect.xMax, rect.yMin),
                new Vector2(1f, 0f));
            vh.AddTriangle(0, 1, 2);
            vh.AddTriangle(2, 3, 0);
        }

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

逐步实现先给 AddVertex 增加 Vector2 uv 参数,再把参数写入 vertex.uv0,最后按四个角传入归一化坐标。SetTexture 只在引用实际变化时更新字段,并调用 SetMaterialDirty,因为此处改变的是提交给材质的纹理,四个顶点的位置与 UV 都没有变化。若未来修改 UV,才应调用 SetVerticesDirty

mainTexture 是字段与 UGUI 渲染边界之间的桥梁。仅仅声明 [SerializeField] private Texture texture,只会让对象保存引用;CanvasRenderer 并不会根据字段名自动发现它。覆盖属性后,渲染流程通过统一合同查询当前纹理。空值回退也集中在同一位置,调用方无需在每次生成顶点时判断纹理是否存在。

SetTexture 的相等判断同样是状态合同的一部分。重复写入同一个引用时直接返回,避免把没有发生的数据变化报告为材质变化。这不是一项帧率结论,而是减少无意义状态转换。若调用者每帧传入相同纹理,组件仍保持稳定;若引用真的改变,下一次渲染提交会获取新的 mainTexture

逐步操作时可以把纹理链拆成四个可观察阶段。第一阶段不赋纹理,只确认白色矩形存在;第二阶段赋棋盘纹理,确认 mainTexture 已改变;第三阶段读取 Mesh,确认四项 uv0 完整;第四阶段故意交换上下 UV,确认画面方向随顶点数据变化。每一步只改变一个条件,才能区分“没有几何”“没有纹理”“没有 UV”和“UV 方向错误”。如果一开始就同时换材质、颜色与纹理,任何成功或失败都缺少明确归因。

状态所有权因此很明确:RectTransform 拥有局部边界,TextureQuadGraphic 拥有纹理引用与顶点属性,CanvasRenderer 消费两者生成的提交结果。不要让外部控制器绕过 SetTexture 直接写私有字段,否则材质脏状态不会随数据一起变化。

正常时序是“调用 SetTexture(newTexture) → 引用变化 → 标记材质脏 → UGUI 再次查询 mainTexture → 原有四边形使用新纹理”。UV 没有变化,所以不需要重新定义顶点位置。失败时序是“外部只保存了一张新纹理,却没有更新组件字段或脏标记 → Mesh 数据仍合法 → 画面继续使用旧提交”。把失败归咎于 OnPopulateMesh 并重复重建顶点,不会修复纹理所有权。

UV 快照

自动测试不依赖肉眼识别棋盘,而是读取 Mesh 的 UV 数组,按固定顶点编号逐项断言。只要其中一项上下颠倒,测试就会指出具体角落;仅断言 UV 数量为 4 无法发现方向错误。第二个样本把宽高同时设为零,验证组件不会为无面积矩形生成带 UV 的重合顶点。

// Assets/OnPopulateMeshBook/Chapters/Chapter06_UV/
// Tests/Editor/TextureQuadGraphicTests.cs
using NUnit.Framework;
using OnPopulateMeshBook.Chapter06;
using OnPopulateMeshBook.TestSupport;
using UnityEngine;

public sealed class TextureQuadGraphicTests
{
    [Test]
    public void Populate_MapsWholeUvRectangle()
    {
        var graphic =
            MeshTestSupport.CreateGraphic<TextureQuadGraphic>(
                new Vector2(120f, 80f));
        var mesh = MeshTestSupport.Build(graphic);
        Assert.That(mesh.uv[0],
            Is.EqualTo(new Vector2(0f, 0f)));
        Assert.That(mesh.uv[1],
            Is.EqualTo(new Vector2(0f, 1f)));
        Assert.That(mesh.uv[2],
            Is.EqualTo(new Vector2(1f, 1f)));
        Assert.That(mesh.uv[3],
            Is.EqualTo(new Vector2(1f, 0f)));
        MeshTestSupport.Destroy(graphic, mesh);
    }

    [Test]
    public void Populate_ZeroAreaProducesEmptyMesh()
    {
        var graphic =
            MeshTestSupport.CreateGraphic<TextureQuadGraphic>(
                Vector2.zero);
        var mesh = MeshTestSupport.Build(graphic);
        Assert.That(mesh.vertexCount, Is.Zero);
        MeshTestSupport.Destroy(graphic, mesh);
    }
}

固定产物同时保存位置、UV、颜色和索引。这四组数据来自同一次网格生成,避免用一张正确截图掩盖顶点表已经变化。

{
  "fixedInput": "uv=(0,0)..(1,1) checker=4x4",
  "component": "TextureQuadGraphic",
  "vertices": [
    [-210.0, -120.0, 0.0],
    [-210.0, 120.0, 0.0],
    [210.0, 120.0, 0.0],
    [210.0, -120.0, 0.0]
  ],
  "uv": [[0, 0], [0, 1], [1, 1], [1, 0]],
  "colors": [
    [255,255,255,255], [255,255,255,255],
    [255,255,255,255], [255,255,255,255]
  ],
  "indices": [0, 1, 2, 2, 3, 0]
}

纹理引用、mainTexture、材质脏标记与顶点 UV 的数据关系

批处理命令仍然执行完整 EditMode 集合,证明 UV 章节没有破坏前五篇已有合同。

"$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

TextureQuadGraphicTests.Populate_MapsWholeUvRectangle
result=Passed
duration=0.000563s
TextureQuadGraphicTests.Populate_ZeroAreaProducesEmptyMesh
result=Passed
duration=0.000105s
process_exit_code=0

本文证据支持“完整零到一 UV 已写入 Mesh”,也支持“纹理引用变化使用材质脏标记”。它不证明任意自定义材质都会读取 uv0,也不证明不同导入方向的外部纹理必然符合本固定棋盘;更换渲染合同后,必须重新核对实际材质与输入资源。

验证同样分为三层。结果图回答“棋盘是否以预期方向出现在矩形上”;JSON 回答“四个顶点实际携带哪些位置、UV 和颜色”;EditMode 测试回答“后续圆形章节加入循环后,四边形映射是否被意外改坏”。截图如果无法辨认一个角的数值,就回到 JSON;测试若只验证 UV 而画面没有纹理,就回到 mainTexture 与材质边界。

翻转排查

最直观的失败是四个顶点都使用同一个 UV。网格仍有四点六索引,颜色也正常,但每个像素都从纹理同一位置附近取样,画面不再展示完整棋盘。几何测试不会失败,四项 UV 断言会失败三项。

// 失败边界:四角重复同一个纹理坐标
var sameUv = new Vector2(0f, 0f);
AddVertex(vh,
    new Vector2(rect.xMin, rect.yMin), sameUv);
AddVertex(vh,
    new Vector2(rect.xMin, rect.yMax), sameUv);
AddVertex(vh,
    new Vector2(rect.xMax, rect.yMax), sameUv);
AddVertex(vh,
    new Vector2(rect.xMax, rect.yMin), sameUv);
failure input:
expected uv=(0,0)..(1,1)

wrong uv:
v0=(0,0)
v1=(0,0)
v2=(0,0)
v3=(0,0)

still valid:
vertexCount=4
indexCount=6
all indices in range

recovery:
restore per-corner uv values in vertex order

另一个失败路径是声明了 texture 字段却没有覆盖 mainTexture。字段在 Inspector 中看似已经赋值,OnPopulateMesh 也写入了 UV,但渲染边界没有消费这份纹理引用。修复位置应是 mainTexture 合同,不应通过修改 UV 或重复调用 SetVerticesDirty 掩盖问题。纹理引用变化与网格变化是两条不同的脏状态路径。

零面积矩形则在 OnPopulateMesh 内部完成恢复。组件先清除旧数据,再检查当前 Rect;任一维不大于零时直接保留空网格。判断必须位于四次 AddVertex 之前,否则虽然最终画面不可见,Mesh 中仍会留下重复位置和无意义 UV。退化测试读取 vertexCount,把这条边界锁定为数据合同。

上下颠倒是另一类数据错误。若只交换所有顶点的 v=0v=1,Mesh 的顶点数和索引数不会变化,纹理仍覆盖整个矩形,但图案会上下翻转。这说明“UV 范围完整”和“UV 方向正确”是两个断言;当前测试逐角比较四项,而不是只检查最小值为零、最大值为一,正是为了锁定方向。

还要避免用修改顶点位置去“修正”纹理方向。把上下两个位置对调,会同时改变三角形绕序和几何形状,原本单一的采样错误会扩大成拓扑问题。恢复策略应始终在拥有错误的层完成:纹理方向错就修改 UV 对应关系,纹理引用错就修正 mainTexture,局部边界错才回到 RectTransform。按所有权定位,比反复试数值更可靠。

圆形之前

纹理引用属于材质提交数据,SetTexture 不改变 position、color 或 uv0,因此使用 SetMaterialDirty。若改变的是四组 UV,才需要重建顶点数据。两个脏标记分别对应不同所有权,不能为了保险全部调用。

当前方案的边界是一张纹理覆盖一个四边形。九宫格边框、图集旋转、Mask、自定义 Shader 和多纹理材质都需要新的明确合同,不属于本文。演进触发条件不是“以后也许会用”,而是现有零到一矩形映射确实无法表达目标资源。入门阶段保留四组显式 UV,能让每次错误都落回一个确定顶点,而不是藏进尚无需求的通用映射层。

下一篇把四次显式写点改成循环,但不改变顶点属性的意义:先计算本地位置,再填写颜色等属性,最后由索引连接相邻记录。