Unity UGUI 程序化网格 07:圆形技能范围的三角扇

从矩形到圆

前六篇已经锁定顶点记录、索引、局部坐标、颜色与 UV。本文第一次用循环改变网格规模,交付技能范围项目的第一个可运行画面:以对象原点为中心、半径可配置的圆形落点范围。核心组件必须在半径为零时返回空网格,并让分段数始终不少于三。

// Assets/OnPopulateMeshBook/Chapters/Chapter07_Circle/
// Runtime/CircleGraphic.cs · CircleGraphic
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter07
{
    public sealed class CircleGraphic : Graphic
    {
        [SerializeField, Min(0f)] private float radius = 90f;
        [SerializeField, Min(3)] private int segments = 24;

        public float Radius
        {
            get => radius;
            set { radius = Mathf.Max(0f, value); SetVerticesDirty(); }
        }

        public int Segments
        {
            get => segments;
            set { segments = Mathf.Max(3, value); SetVerticesDirty(); }
        }

        protected override void OnPopulateMesh(VertexHelper vh)
        {
            vh.Clear();
            if (radius <= 0f) return;
            var segmentCount = Mathf.Max(3, segments);

            AddVertex(vh, Vector2.zero);
            for (var i = 0; i < segmentCount; i++)
            {
                var radians = i * Mathf.PI * 2f / segmentCount;
                AddVertex(vh, new Vector2(
                    Mathf.Cos(radians),
                    Mathf.Sin(radians)) * radius);
            }

            for (var i = 0; i < segmentCount; i++)
                vh.AddTriangle(0, i + 1,
                    (i + 1) % segmentCount + 1);
        }

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

数量可以直接由分段数复算,最后一个三角形则必须连接回第一个圆周点。本文要区分“分段较少形成多边形”和“网格没有闭合”,并沿用系列坐标合同:RectTransform 本地二维坐标,X 向右、Y 向上;角度以正 X 轴为零度、逆时针为正。顶点生成仍由 CircleGraphic 拥有,Canvas 只决定何时重建。

本文也是实战项目的第一轮纵向切片:建立技能指示器的“有效范围面”。为了让职责保持可见,玩法半径暂时直接以 UI 本地单位输入,组件不读取角色属性、不处理相机投影,也不判断点击是否命中。只要给定半径、分段数和颜色,它就产生确定 Mesh;同一固定输入可以在场景、JSON 与测试中重复得到相同数量和编号。

搭建前先复制一个空 Canvas 场景,不从第 6 篇对象上直接替换脚本。保留独立 Chapter07 场景可以同时打开纹理矩形与圆形范围,对照“显式四点”如何演进成“循环写点”,也能防止后续参数覆盖前章证据。创建对象后先将锚点与 Pivot 保持中心,再挂载组件;本文以本地原点作为圆心,若 Pivot 偏离中心,圆仍围绕原点生成,却不会位于 RectTransform 可见区域的几何中心。

范围面场景

场景固定输入为 radius=120segments=8。结果严格说是正八边形,而不是数学上的连续圆;它由八个三角形围绕中心点组成。作为圆形技能范围原型,这个低分段结果故意保留清晰边角,便于读者直接核对每个角区间。

圆形技能范围的 Unity 实际结果

中心点与八个圆周点的三角扇拓扑

半径与分段数到 VertexHelper 的数据关系

在 Canvas 下创建空 UI 对象,挂载 CircleGraphic,把 RectTransform 设为至少 240 × 240,再将半径设为 120、分段数设为 8。组件颜色采用固定的半透明绿色。RectTransform 只提供对象的局部坐标系,本文半径由组件字段拥有;对象尺寸小于直径时,网格仍会按半径生成,本文没有加入自动裁剪。

可见验收按三个阶段进行。先把分段数设为 3,画面应是三角形,但 Mesh 包含一个中心点和三个圆周点;再设为 8,边界变成可数的八条边;最后恢复默认较高分段,轮廓更接近圆。三种画面使用同一个算法,变化只来自 N。若分段变化后画面完全不动,先确认属性是否通过 setter 写入并标记顶点脏,再检查循环;若只缺少最后一块,则直接检查闭合索引。

Inspector 中 Radius 与 RectTransform Width 是两份不同状态。前者决定顶点离原点的距离,后者只影响对象的布局矩形。把 Width 从 240 改为 400 不会自动把半径从 120 改为 200,这是当前合同的明确边界。需要“始终填满 RectTransform”的圆形时,应另行定义半径来源;技能范围项目则在第 10 篇由游戏半径乘显示比例得到像素半径。

固定结果为 9 个顶点和 24 个索引。编号 0 是中心;编号 1 到 8 沿圆周逆时针排列。第一个圆周点位于 (120,0),第二个位于约 (84.8528,84.8528),最后一个位于约 (84.8528,-84.8528)。浮点三角函数会产生接近零但不等于零的小残差,它们不是额外顶点,也不改变数量合同。

{
  "fixedInput": "radius=120 segments=8",
  "component": "CircleGraphic",
  "vertexCount": 9,
  "indexCount": 24,
  "vertices": [
    [0.0, 0.0, 0.0],
    [120.0, 0.0, 0.0],
    [84.8528137, 84.8528137, 0.0],
    [-0.0000052, 120.0, 0.0],
    [-84.8528137, 84.8528137, 0.0],
    [-120.0, -0.0000105, 0.0],
    [-84.8527985, -84.8528290, 0.0],
    [0.0000014, -120.0, 0.0],
    [84.8528442, -84.8527832, 0.0]
  ],
  "indices": [
    0,1,2, 0,2,3, 0,3,4, 0,4,5,
    0,5,6, 0,6,7, 0,7,8, 0,8,1
  ]
}

单位圆

本文新增“单位圆”:以原点为圆心、半径为 1 的圆。给定角度 θ,圆周方向为 (cos θ, sin θ);再乘目标半径,就得到局部位置。Unity 的 Mathf.SinMathf.Cos 接收弧度,因此完整一圈是 ,不是 360

分段数 N 把完整一圈分成 N 个角区间,第 i 个圆周点角度为 i×2π/N。当 N=8 时,步长是 π/4,也就是展示用的 45 度。循环只生成 i=0..7 八个圆周点,不重复终点;最后一个三角形通过取模把“下一个点”折回编号 1。

可以在纸上先完成编号,再进入代码。把中心写成 0,正 X 轴圆周点写成 1,随后逆时针标到 8;每个三角形都以 0 开始,另外两个编号沿圆周相邻。于是前七组自然是 [0,1,2][0,7,8],最后一组只能是 [0,8,1]。取模不是为了炫技,而是把这个最后一组规则写成与其他循环相同的表达。

半径只缩放位置,不改变拓扑。将 120 改为 60 后,每个圆周位置的 X、Y 都减半,中心仍为零,顶点数与索引数保持 9/24。将分段数从 8 改为 16 则会改变拓扑,数量变为 17/48。分清“只改顶点属性”和“改变顶点、索引数量”,能帮助第 10 篇判断哪些状态会触发什么样的网格变化。

数量由代码直接推导。一个中心点加 N 个圆周点,顶点数为 N+1;每个角区间一个三角形,每个三角形三个索引,索引数为 3N。固定输入代入 N=8 得到 924,和 JSON 一致。

N = 8
angleStep = 2π / 8 = π/4
vertexCount = 1 + N = 9
triangleCount = N = 8
indexCount = 3N = 24

last triangle:
i = 7
current = i + 1 = 8
next = (i + 1) % N + 1 = 1
indices = [0,8,1]

本文没有用第 6 篇的 UV,因为目标是纯色范围面;但顶点记录与颜色合同仍然复用。若后续需要径向纹理,必须另行定义中心与圆周 UV,不能从当前全零 UV 假装得到正确采样。

圆形生成

正常时序是:属性所有者设置半径或分段数,属性先归一化,再调用 SetVerticesDirty;UGUI 请求重建,组件清空容器;若半径有效,先写中心点,再写 N 个圆周点,最后写 N 组三角形。任何一轮都提交完整快照,不在旧圆上追加新分段。

这里仍遵守第 1 篇的事务不变量。假设上一轮是八段、下一轮改为三段,正确结果必须直接变为 4 个顶点和 9 个索引;若忘记 Clear,旧八段数据与新三段数据会共存,数量无法再由当前参数复算。完整替换比“找出变化的几段再修改”更适合当前入门规模,也让每次测试只依赖当前输入。

半径 setter 将负数归一为零;分段 setter 将小于三的输入归一为三。这两个 setter 拥有参数状态和脏标记,OnPopulateMesh 只消费已经归一的字段。半径等于零时,方法在 Clear 后返回空网格,避免提交所有点重合在原点的零面积三角形。

序列化数据可能绕过运行时 setter,例如旧场景或手工修改的序列化字段仍可能保存小于三的分段数。因此生成入口再次计算 segmentCount=Mathf.Max(3,segments),并让角度循环、索引循环与取模统一使用这个局部值。setter 是正常写入防线,生成入口是反序列化边界防线;两者保护不同入口,不是重复的无效检查。

测试同时覆盖正常样本与退化样本。正常样本使用 radius=100segments=8,只验证结构数量、有限数值和索引范围;场景 JSON使用半径 120,证明相同拓扑可承载不同尺寸。失败样本把半径设为零,结果必须没有顶点。

// Assets/OnPopulateMeshBook/Chapters/Chapter07_Circle/
// Tests/Editor/CircleGraphicTests.cs
[Test]
public void Populate_EightSegmentsHaveFixedCounts()
{
    var graphic = MeshTestSupport.CreateGraphic<CircleGraphic>(
        new Vector2(220f, 220f));
    graphic.Radius = 100f;
    graphic.Segments = 8;
    var mesh = MeshTestSupport.Build(graphic);

    Assert.That(mesh.vertexCount, Is.EqualTo(9));
    Assert.That(mesh.triangles, Has.Length.EqualTo(24));
    MeshTestSupport.AssertFinite(mesh);
    MeshTestSupport.AssertIndicesInRange(mesh);
    MeshTestSupport.Destroy(graphic, mesh);
}

[Test]
public void Populate_ZeroRadiusProducesEmptyMesh()
{
    var graphic = MeshTestSupport.CreateGraphic<CircleGraphic>(
        new Vector2(220f, 220f));
    graphic.Radius = 0f;
    var mesh = MeshTestSupport.Build(graphic);

    Assert.That(mesh.vertexCount, Is.Zero);
    MeshTestSupport.Destroy(graphic, mesh);
}

[Test]
public void Populate_SegmentCountClampsToThree()
{
    var graphic = MeshTestSupport.CreateGraphic<CircleGraphic>(
        new Vector2(220f, 220f));
    graphic.Radius = 100f;
    graphic.Segments = 1;
    var mesh = MeshTestSupport.Build(graphic);

    Assert.That(graphic.Segments, Is.EqualTo(3));
    Assert.That(mesh.vertexCount, Is.EqualTo(4));
    Assert.That(mesh.triangles, Has.Length.EqualTo(9));
    MeshTestSupport.Destroy(graphic, mesh);
}

这里的取舍是以分段近似圆,而不是引入曲线图元或 Shader。分段数越大,轮廓采样越密,但本文没有 Profiler 与批量样本,不能把“更圆”写成“更高性能”或“足够平滑”。当前容量只由公式记录:生成时间与顶点、索引数量都随 N 线性增长。

闭环验证

EditMode 命令执行系列当前 19 项基础网格测试,确保圆形循环没有破坏前六篇固定组件及其退化输入、序列化边界合同。命令使用环境变量表示编辑器、工程和制品目录。

"$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 "process_exit_code=$?"

当前 XML 记录总计 19 项全部通过,失败 0、跳过 0,总耗时 0.0400379 秒。本文正常用例耗时 0.000671 秒,分段归一用例耗时 0.000167 秒,零半径用例耗时 0.000103 秒;这些值只描述本次运行。

process_exit_code=0
test-run result=Passed
total=19 passed=19 failed=0 skipped=0
duration=0.0400379s

Populate_EightSegmentsHaveFixedCounts
result=Passed duration=0.000671s
vertexCount=9 indexCount=24

Populate_SegmentCountClampsToThree
result=Passed duration=0.000167s
segments=3 vertexCount=4 indexCount=9

Populate_ZeroRadiusProducesEmptyMesh
result=Passed duration=0.000103s
vertexCount=0

证据支持的结论是:八分段正常输入产生有限且索引合法的 9/24 网格,零半径产生空网格。它不证明任意巨大分段数都适合实时更新,也没有验证半径超出 RectTransform 时的裁剪行为。

半径退化

第一个错误把角度值 i*360/N 直接传给 Sin/Cos。API 会把这些数字解释成弧度,圆周点不再按 45 度均匀排列。修复是使用 计算弧度,或者明确乘 Mathf.Deg2Rad,不能混用两套单位。

// 错误:degrees 被当作 radians
var degrees = i * 360f / segments;
var wrong = new Vector2(
    Mathf.Cos(degrees),
    Mathf.Sin(degrees)) * radius;

// 正确:直接以弧度计算
var radians = i * Mathf.PI * 2f / segments;
var correct = new Vector2(
    Mathf.Cos(radians),
    Mathf.Sin(radians)) * radius;

第二个错误把索引写成 0,i+1,i+2,却没有处理最后一段。当 i=7 时会引用编号 9,而顶点范围只有 0..8。把循环提前结束到 6 虽能避免越界,却会留下开口;正确恢复是让最后一项通过取模回到编号 1。

第三个错误是把中心点放进圆周循环,导致每次角度都追加一份 (0,0)。如果索引仍假定只有一个中心,新增记录不会被引用;如果索引随之改写,则每个扇片拥有不同中心顶点,颜色或其他属性一旦不一致就会沿径向产生接缝。当前技能范围只有一个中心属性,保存一份中心并共享它最清晰。

边界观察还包括极小正半径。setter 只把负数归零,所以 0.0001 会生成结构合法但肉眼几乎不可见的圆;现有合同没有把它判成错误。是否需要最小可见半径属于产品显示规则,应由上层配置或新增明确阈值,而不是由几何组件随意猜测。当前失败样本只锁定精确零半径。

看似简单的替代方案是显式重复 360 度终点,再继续沿用三角扇索引。它可以工作,却为本文增加一个与起点重合的顶点,而闭合只需要索引复用编号 1。下一篇圆环为了成对生成内外边界,会采用显式重复首尾样本;两章选择不同,是由各自最清晰的索引合同决定,不强求统一模板。

环形需求

半径为零时返回空网格,而不是生成 N 个重合在原点的零面积三角形。分段数只决定采样密度与数量:一个中心加 N 个圆周点得到 N+1 个顶点,N 个扇片得到 3N 个索引。

下一篇沿相同角度顺序同时生成内外两个顶点,把实心圆扩展为环形范围。有限数值检查、正 X 轴起步和逆时针编号继续复用。