Unity UGUI 程序化网格 08:内外半径生成环形技能范围

圆环拓扑

圆形范围只能表达“中心到外边界全部有效”。当技能要求目标离角色至少一段距离时,需要保留外边界,同时挖空中心。本文沿用第 7 篇的角度顺序,在每个采样方向上生成一个内点和一个外点,再把相邻两组连接成四边形带。

// Assets/OnPopulateMeshBook/Chapters/Chapter08_Ring/
// Runtime/RingGraphic.cs · 字段、属性与生成入口
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter08
{
    public sealed class RingGraphic : Graphic
    {
        [SerializeField, Min(0f)] private float innerRadius = 70f;
        [SerializeField, Min(0f)] private float outerRadius = 90f;
        [SerializeField, Min(3)] private int segments = 24;

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

        public float OuterRadius
        {
            get => outerRadius;
            set { outerRadius = 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();
            var inner = Mathf.Clamp(innerRadius, 0f, outerRadius);
            if (outerRadius <= 0f || inner >= outerRadius) return;
            var segmentCount = Mathf.Max(3, segments);

零内半径不能继续沿用环带的成对中心点。下面的分支只保存一个中心点,并复用第 7 篇的取模闭合;正内半径才进入后续成对采样。

            if (inner <= 0f)
            {
                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)) * outerRadius);
                }
                for (var i = 0; i < segmentCount; i++)
                    vh.AddTriangle(0, i + 1,
                        (i + 1) % segmentCount + 1);
                return;
            }

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

正内半径走另一条拓扑:每个角度依次写入内点和外点。下面继续同一个 OnPopulateMesh,两段代码按顺序组成实验工程中的完整实现。

            for (var i = 0; i < segmentCount; i++)
            {
                var start = i * 2;
                vh.AddTriangle(start, start + 1, start + 3);
                vh.AddTriangle(start + 3, start + 2, start);
            }
        }

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

本文用一段环带的四个顶点与六个索引建立重复规则,并解释正内半径为何显式重复首尾样本、零内半径为何切换为单中心三角扇。单位圆、弧度和分段数继续沿用上一篇定义;顶点顺序仍按正 X 轴起步、逆时针推进。

这一章把技能范围实战从“最大距离”推进到“有效距离区间”。例如外半径表示技能能到达的最远边界,内半径表示角色附近不能选中的区域。RingGraphic 只把这个区间画出来,不负责决定技能是否允许施放;玩法系统拥有规则,显示组件拥有网格。把二者分开后,同一个环形组件还可以表达警戒区、拾取距离或冷却边界,而测试仍只依赖半径和分段数。

实验搭建从第 7 篇场景的参数开始,但保留独立对象与脚本。创建一个中心 Pivot 的 UI 对象,挂载 RingGraphic,先设置内半径 0、外半径 125,确认组件生成单中心三角扇;再把内半径设为 70,确认拓扑切换为稳定环带。系列固定画面使用正内半径,零内半径则作为受测试的合法边界保留。

内外半径

场景固定输入为 inner=70outer=125segments=8。可见区域只覆盖半径 70 到 125 的带状空间,可用作近身禁区、最小施法距离或落点限制的 UI 表达。这里的“有效”只是显示语义,组件不负责判断游戏目标是否真的落在环内。

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

九组内外顶点与八段四边形带

内外半径到环带网格的数据关系

在 Canvas 下创建对象并挂载 RingGraphic,RectTransform 至少设为 250 × 250。Inspector 中把内半径设为 70、外半径设为 125、分段数设为 8。与上一篇不同,循环范围为 i=0..8,因此第 8 组方向重新回到完整一圈的起点;它与第 0 组位置近似相同,却拥有独立编号。

肉眼验收先检查中心空洞是否完整,再检查外轮廓,最后检查正 X 轴方向的首尾接缝。若空洞半径明显不对,读取偶数编号内点;若外边界不对,读取奇数编号外点;若只有接缝破裂,核对最后一组 16/17 与起点 0/1。成对编号让每类失败都有固定观察位置,不需要遍历所有三角形猜测。

把分段数暂时改为 3,可以看到三段环带实际由三个四边形组成,每个四边形又拆为两组三角形;恢复 8 后边界变细。改变内半径只移动偶数编号,改变外半径只移动奇数编号,数量保持不变。改变分段数则同时改变顶点和索引数量,这一差异可直接从 JSON 与 Mesh 验证。

固定制品产生 18 个顶点和 48 个索引。每个采样方向成对写入“内、外”,编号 0/1 是起点内外边界,16/17 是闭合终点内外边界。两对位置距离都小于测试容差,说明环带首尾接上。

{
  "fixedInput": "inner=70 outer=125 segments=8",
  "component": "RingGraphic",
  "vertexCount": 18,
  "indexCount": 48,
  "samples": [
    { "i": 0, "inner": [70.0,0.0], "outer": [125.0,0.0] },
    { "i": 1, "inner": [49.497475,49.497475],
              "outer": [88.388344,88.388344] },
    { "i": 2, "inner": [-0.000003,70.0],
              "outer": [-0.000005,125.0] },
    { "i": 8, "inner": [70.0,0.000012],
              "outer": [125.0,0.000022] }
  ],
  "firstIndices": [0,1,3, 3,2,0],
  "lastIndices": [14,15,17, 17,16,14]
}

环带拓扑

本文新增内半径与外半径。二者都是从对象本地原点到边界的距离,量纲相同。正厚度环带满足 0 < inner < outerinner=0 是实心圆边界,必须改用单中心三角扇。若仍按成对顶点生成,N+1 个内点会全部重合在原点,每段的两个索引指向同一位置,形成零面积三角形。入口分支把这一退化输入转换为第 7 篇已经证明的合法扇形。

每个角度写入两个顶点,所以第 i 组的起始编号为 2i:内点 start、外点 start+1;下一组内点 start+2、外点 start+3。一个带段由两组三角形组成,固定索引为 [start,start+1,start+3][start+3,start+2,start]。这正是第 3 篇四边形拓扑沿圆周重复。

以第一段为例,顶点 0/1 是 0 度内外点,2/3 是 45 度内外点,索引为 [0,1,3][3,2,0]。共享边是 0 到 3 的对角线,两个面保持同一绕序。第二段只把 start 增加 2,公式变成 [2,3,5][5,4,2]。这说明环带不是全新拓扑,而是第 3 篇矩形合同按角度序列重复。

内外半径的差值是环厚,但当前组件没有单独保存 thickness。保存 innerRadiusouterRadius 可以直接表达有效距离区间,也让反转关系被明确检测。若同时保存三者,就会出现 outer-inner 与 thickness 不一致的第三状态;当前需求没有理由引入这个额外所有者。

正内半径时,顶点数量为 2(N+1),因为 N 个角区间需要 N+1 组边界样本;索引数量为 6N,因为每段两个三角形、每个三角形三个索引。代入 N=8 得到 18/48。零内半径时复用三角扇合同,数量变为 N+1 个顶点和 3N 个索引,即 9/24;两种拓扑都不含零面积面。

N = 8
sampleGroups = N + 1 = 9
verticesPerGroup = 2
vertexCount = 2(N+1) = 18

quads = N = 8
trianglesPerQuad = 2
indexCount = N * 2 * 3 = 48

group i:
inner=i*2, outer=i*2+1
nextInner=i*2+2, nextOuter=i*2+3

第 7 篇使用取模复用第一个圆周点,本文改为显式重复首尾样本。原因不是 API 限制,而是成对顶点带用连续编号能让每段索引保持完全相同的公式,最后一段无需特殊分支。代价是多两个位置重合的顶点;当前系列优先选择索引清晰且可复算的实现。

两种生成分支

正常时序是属性写入、归一化、标记顶点脏、UGUI 重建、清空容器、检查半径关系、生成 N+1 组内外点,再生成 N 段四边形。参数所有者仍是 RingGraphic,游戏控制器不直接操作 VertexHelper。

若外半径从 125 改为 100,而内半径仍为 70,组件会在下一轮完整重建,所有奇数编号向中心移动,偶数编号保持半径 70。若外半径继续减到 60,本次局部 inner 被夹到 60,随后关系检查返回空网格。失败不会留下上一轮 70 到 100 的环,因为方法第一步已经 Clear;空结果准确表达当前输入无有效宽度。

恢复这类失败应由数据所有者修正半径,再次通过属性下发。显示组件不交换参数、不保留旧图,也不在控制台替玩法层选择默认值。这样 Inspector 错配、网络快照错误或测试故障都会表现为同一确定结果:零顶点,而不是不同入口得到不同“自动修正”。

Mathf.Clamp(innerRadius,0,outerRadius) 只产生本次使用的局部变量,不回写序列化字段。若内半径超过外半径,局部值会等于外半径,随即命中 inner>=outer 并返回空网格。这条失败语义比偷偷交换两个半径更清楚:输入关系错误时不改变用户意图,也不生成与配置相反的范围。

与圆形组件相同,运行时 setter 不是唯一输入边界。场景反序列化可能直接恢复旧的 segments 字段,因而 OnPopulateMesh 再计算至少为三的 segmentCount,并让顶点循环与索引循环统一使用它。若一处使用原字段、另一处使用归一值,顶点数量和索引访问范围就会分叉;本地变量把本轮完整快照锁在同一分段合同内。

测试正常样本使用 inner=60outer=100、8 段,验证数量、首尾闭合与索引范围;失败样本使用反转半径 100/60,结果必须为空;边界样本使用零内半径,验证只产生一个中心点、有限顶点和合法索引。

// Assets/OnPopulateMeshBook/Chapters/Chapter08_Ring/
// Tests/Editor/RingGraphicTests.cs
[Test]
public void Populate_EightSegmentsHaveClosedBandCounts()
{
    var graphic = MeshTestSupport.CreateGraphic<RingGraphic>(
        new Vector2(220f, 220f));
    graphic.InnerRadius = 60f;
    graphic.OuterRadius = 100f;
    graphic.Segments = 8;
    var mesh = MeshTestSupport.Build(graphic);

    Assert.That(mesh.vertexCount, Is.EqualTo(18));
    Assert.That(mesh.triangles, Has.Length.EqualTo(48));
    Assert.That(Vector3.Distance(
        mesh.vertices[0], mesh.vertices[16]), Is.LessThan(0.001f));
    Assert.That(Vector3.Distance(
        mesh.vertices[1], mesh.vertices[17]), Is.LessThan(0.001f));
    MeshTestSupport.AssertIndicesInRange(mesh);
    MeshTestSupport.Destroy(graphic, mesh);
}

[Test]
public void Populate_InvertedRadiiProduceEmptyMesh()
{
    var graphic = MeshTestSupport.CreateGraphic<RingGraphic>(
        new Vector2(220f, 220f));
    graphic.InnerRadius = 100f;
    graphic.OuterRadius = 60f;
    var mesh = MeshTestSupport.Build(graphic);

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

[Test]
public void Populate_ZeroInnerRadiusUsesTriangleFan()
{
    var graphic = MeshTestSupport.CreateGraphic<RingGraphic>(
        new Vector2(220f, 220f));
    graphic.InnerRadius = 0f;
    graphic.OuterRadius = 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);
}

当前方案只负责局部显示。它没有把游戏单位换算成像素,也没有判断目标点是否位于有效带内;这些所有权要到第 10 篇才组合。现在把命中检测塞进 Graphic,会让显示组件同时拥有玩法规则与网格生成。

数量验证

仍使用全量 EditMode 命令,保证新增环带没有改坏圆形和前六篇样例。

"$UNITY_EDITOR" \
  -batchmode -nographics \
  -projectPath "$PROJECT_ROOT/.tmp/unity-onpopulate-mesh-lab" \
  -runTests -testPlatform EditMode \
  -testResults "$ARTIFACTS/editmode-results.xml" \
  -logFile "$ARTIFACTS/editmode.log"

当前结果为 19/19 通过,总耗时 0.0400379 秒。环带正常用例耗时 0.002224 秒,反转半径用例耗时 0.000107 秒,零内半径用例耗时 0.000119 秒,分段归一用例耗时 0.000132 秒。固定场景与正内半径测试均保持八段 18/48;零内半径测试按三角扇得到 9/24

process_exit_code=0
total=19 passed=19 failed=0 skipped=0
duration=0.0400379s

Populate_EightSegmentsHaveClosedBandCounts
result=Passed duration=0.002224s
vertexCount=18 indexCount=48
innerClosureDistance<0.001
outerClosureDistance<0.001

Populate_InvertedRadiiProduceEmptyMesh
result=Passed duration=0.000107s vertexCount=0

Populate_ZeroInnerRadiusUsesTriangleFan
result=Passed duration=0.000119s
vertexCount=9 indexCount=24 finite=true indicesInRange=true

Populate_SegmentCountClampsToThree
result=Passed duration=0.000132s

这里不比较圆与圆环的耗时,因为单次微小样本不足以形成性能结论。能确认的是数量公式、闭合距离、索引范围和失败语义。

非法半径

第一个错误把循环写成 i<segments,只生成八组而非九组。若索引仍按连续组连接,最后一段需要访问不存在的编号 16、17;若把索引循环也减少一段,则网格留下首尾缺口。恢复方式是保留 i<=segments 的闭合样本。

// 错误:缺少闭合的第 N 组
for (var i = 0; i < segments; i++)
{
    var radians = i * Mathf.PI * 2f / segments;
    var direction = new Vector2(
        Mathf.Cos(radians), Mathf.Sin(radians));
    AddVertex(vh, direction * inner);
    AddVertex(vh, direction * outerRadius);
}

// N=8 只产生 16 顶点,最后一段公式需要编号 16、17。
// 修复:顶点循环使用 i <= segments。

第二个错误交换同组内外编号,却保留旧索引。所有索引可能仍在范围内,但三角形在带宽方向交叉,绕序与拓扑合同被破坏。修复应恢复每组固定的“内、外”顺序,不能依靠改变颜色隐藏交叉。

第三个错误是只重复外圈终点,不重复内圈终点。顶点总数变为奇数,最后一段的“内、外、下一内、下一外”分组被破坏,固定 start+3 会指向错误属性。环带闭合必须以完整采样组为单位,任何角度都同时产生内外两点;不能分别管理两条圆周的循环边界。

极薄环也是合法但有边界的输入。例如内半径 99.999、外半径 100 会产生正厚度网格,测试当前不会把它视为空。它在实际分辨率下可能难以辨认,但“最小可见厚度”属于 UI 设计与像素比例,不属于纯几何关系。第 10 篇边界线会明确以 3 个像素计算厚度,而本文保留通用半径合同。

看似友好的替代方案是发现内半径大于外半径时自动交换二者。它会把配置错误转成另一种看似正常的范围,调用者无法知道数据关系已被改变。当前空网格与失败测试更适合系列和生产诊断:上层可以明确修正半径,而不是接受静默猜测。

扇形缺口

环带以 N+1 组内外样本换取统一索引公式。起点角度为 0,终点通过三角函数计算 ,位置可能留下极小浮点残差;拓扑依靠独立编号与索引闭合,测试使用距离容差验证连续。

下一篇把完整 360 度限制为起止角区间,只生成面向输入方向的扇形。RingGraphic 的成对顶点顺序、每段六索引和无正厚度时输出空网格的合同保持不变。