Unity UGUI 程序化网格 09:方向与夹角生成扇形瞄准

带方向的范围

圆形与圆环没有朝向,无法表达锥形攻击。本文沿用圆周采样和内外顶点带,只截取起始角到终止角之间的部分;新增问题是如何由中心方向与夹角计算起始角、复算实际步数,并让参数变化通过 SetVerticesDirty 触发重建。

// Assets/OnPopulateMeshBook/Chapters/Chapter09_Sector/
// Runtime/SectorGraphic.cs · 完整组件上半部
using UnityEngine;
using UnityEngine.UI;

namespace OnPopulateMeshBook.Chapter09
{
    public sealed class SectorGraphic : Graphic
    {
        [SerializeField, Min(0f)] private float innerRadius;
        [SerializeField, Min(0f)] private float outerRadius = 100f;
        [SerializeField] private float startAngle = -30f;
        [SerializeField, Range(0f, 360f)]
        private float sweepAngle = 60f;
        [SerializeField, Min(3)]
        private int fullCircleSegments = 48;

        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 float StartAngle { get => startAngle;
            set { startAngle = value; SetVerticesDirty(); } }
        public float SweepAngle { get => sweepAngle;
            set { sweepAngle = Mathf.Clamp(value, 0f, 360f);
                SetVerticesDirty(); } }
        public int FullCircleSegments { get => fullCircleSegments;
            set { fullCircleSegments = Mathf.Max(3, value);
                SetVerticesDirty(); } }

        public int StepCount => sweepAngle <= 0f
            ? 0
            : Mathf.Max(1, Mathf.CeilToInt(
                Mathf.Max(3, fullCircleSegments) *
                sweepAngle / 360f));

        protected override void OnPopulateMesh(VertexHelper vh)
        {
            vh.Clear();
            var inner = Mathf.Clamp(innerRadius, 0f, outerRadius);
            var steps = StepCount;
            if (outerRadius <= 0f ||
                inner >= outerRadius || steps == 0) return;

            if (inner <= 0f) PopulateFan(vh, steps);
            else PopulateBand(vh, inner, steps);
        }

本文沿用第 7 篇的单位圆、弧度和分段数,以及第 8 篇的内外半径和成对顶点顺序。实心扇形复用三角扇,内半径大于零时复用环带。当前只新增夹角与 SetVerticesDirty,不读取鼠标,也不拥有技能状态。

从系列路线看,本文的重点不是多写一个几何类,而是把静态几何变成可控显示。前八篇都能在 Inspector 中得到固定画面;扇形开始需要上层持续提供方向。若网格类同时保存输入事件、瞄准阶段和顶点容器,任何一处变化都会牵动全部职责。本文先锁定最小显示接口,第 10 篇再用独立组合器接入状态。

锥形场景

方向九十度、夹角六十度的 Unity 扇形结果

六步圆弧、八个顶点和十八个索引的拓扑

中心方向、起始角、属性与 UGUI 重建的数据关系

固定场景使用中心方向 90 度、夹角 60 度、外半径 140、完整圆分段数 36。SectorGraphic 接收的是第一条边,因此控制侧先算 90-60/2=60 度,终止边为 120 度。结果应以正 Y 轴为中线,两侧各展开 30 度。

在 Canvas 下创建对象并挂载组件,RectTransform 设为至少 280×280。Inner Radius 设 0、Outer Radius 设 140、Start Angle 设 60、Sweep Angle 设 60、Full Circle Segments 设 36。先把夹角设为 0,确认网格为空;再恢复 60,确认两条边围绕正 Y 轴对称。这样分别隔离退化条件和方向换算。

{
  "fixedInput": "direction=90 sweep=60 radius=140 fullSegments=36",
  "derived": {"start":60, "end":120, "steps":6},
  "component": "SectorGraphic",
  "vertexCount": 8,
  "indexCount": 18,
  "selectedVertices": [
    {"index":0, "position":[0.0,0.0,0.0]},
    {"index":1, "position":[69.999992,121.243561,0.0]},
    {"index":4, "position":[-0.000006,140.0,0.0]},
    {"index":7, "position":[-70.000008,121.243553,0.0]}
  ],
  "indices": [0,1,2, 0,2,3, 0,3,4,
              0,4,5, 0,5,6, 0,6,7]
}

第一圆弧点接近 (70,121.244),中点接近 (0,140),末点接近 (-70,121.244),分别对应 60、90、120 度。结果图确认整体朝向,坐标确认角度边界,索引确认六个三角形连续;仅凭“看起来像扇形”无法证明第一条边确实位于 60 度。

固定画面还给出一个容易忽略的判断:扇形尖端位于当前 RectTransform 本地原点,而不是矩形左下角或 Canvas 原点。组件沿用系列 X 向右、Y 向上的局部坐标,父节点如何摆放不改变顶点表。若画面方向正确但整体位置错误,应检查 RectTransform 层级;若尖端位置正确但扇形偏转,才回到角度参数。

角区间

夹角是起始边到终止边按逆时针方向覆盖的宽度。中心方向不是 SectorGraphic 的字段,它属于上层瞄准状态;上层用 start=direction-sweep/2 换算。若把中心方向直接写入 Start Angle,方向 90、夹角 60 会覆盖 90 到 150 度,实际中心偏到 120 度。

fullCircleSegments 描述完整 360 度的离散密度,当前夹角只使用其中一部分。实际步数是 ceil(fullSegments×sweep/360),正夹角下至少为 1。固定输入得到 ceil(36×60/360)=6。向上取整防止很小的正夹角得到零步,但一段仍只是一枚三角形,不代表轮廓已经平滑。

推导项 公式 固定结果
起始角 direction-sweep/2 60 度
终止角 start+sweep 120 度
实际步数 ceil(36×60/360) 6
实心扇形顶点 steps+2 8
实心扇形索引 3×steps 18
环形扇区顶点 2×(steps+1) 14
环形扇区索引 6×steps 36

分段下限有两道防线。公共属性把运行时传入的小于 3 的值归一为 3;StepCount 又对字段取 Max(3,value),防止旧场景或异常序列化值绕过属性后让整圈退化到一两段。当前回归通过公共属性写入 1,验证公开值、步数和网格数量均按 3 处理;它没有伪称单独模拟了旧场景反序列化。

Start Angle 可以为负或超过 360,三角函数会周期性映射到同一方向。方向 0、夹角 90 时保留 -45 比强制改为 315 更便于核对“中心减半夹角”。Sweep Angle 则被限制在 [0,360],因为它表示覆盖宽度:负数与逆时针正方向冲突,超过一圈也没有新增显示区域。

跨越零度不需要额外分支。例如起始角 -30、夹角 60 的两个边界分别是 -30 和 30;采样式按相同比例递增,三角函数自然穿过正 X 轴。若为了避免负数把起始角先改成 330,网格结果相同,却增加一次与本文目标无关的表示转换。只有当角度需要序列化到外部协议时,才由协议层决定是否归一。

实际步数而不是完整圆分段数直接决定本轮数量。相同的 fullCircleSegments=36 下,60 度需要 6 步,120 度需要 12 步,360 度需要 36 步。这让不同夹角保持近似一致的角度采样密度。若所有扇形无论宽度都固定 36 步,小夹角会过度细分,而大夹角与小夹角的每段角度也不再一致。

扇形分支

完整组件的后半部包含两种拓扑。实心分支先写中心点,再写含起止边的 steps+1 个圆弧点;环形分支在每个角度成对写入内外点,并复用第 8 篇每段六索引的公式。两条分支共用 PointOnArc,角度单位只在这里从度转换为弧度。

// SectorGraphic.cs · 完整组件下半部
        private void PopulateFan(VertexHelper vh, int steps)
        {
            AddVertex(vh, Vector2.zero);
            for (var i = 0; i <= steps; i++)
                AddVertex(vh, PointOnArc(outerRadius, i, steps));
            for (var i = 0; i < steps; i++)
                vh.AddTriangle(0, i + 1, i + 2);
        }

        private void PopulateBand(
            VertexHelper vh, float inner, int steps)
        {
            for (var i = 0; i <= steps; i++)
            {
                AddVertex(vh, PointOnArc(inner, i, steps));
                AddVertex(vh, PointOnArc(outerRadius, i, steps));
            }
            for (var i = 0; i < steps; i++)
            {
                var start = i * 2;
                vh.AddTriangle(start, start + 1, start + 3);
                vh.AddTriangle(start + 3, start + 2, start);
            }
        }

        private Vector2 PointOnArc(
            float radius, int index, int steps)
        {
            var degrees =
                startAngle + sweepAngle * index / steps;
            var radians = degrees * Mathf.Deg2Rad;
            return new Vector2(
                Mathf.Cos(radians), Mathf.Sin(radians)) * radius;
        }

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

正常时序是:控制者产生中心方向,换算起始角,写入属性;属性归一并标记顶点脏;Canvas 在重建阶段提供 VertexHelper;组件清空旧数据,根据当前内半径选择分支,再提交一份完整网格。任何一轮都不在旧扇形上追加新点。

半径非正、内半径不小于外半径、或步数为零时,方法在 Clear 后返回空网格。零夹角必须先得到 StepCount=0,不能为了“至少一段”强制生成起止点重合的零面积三角形。内半径为零走三角扇,避免环带分支在中心重复生成多组重合点。

两条生成分支共享的不是一套抽象框架,而是圆弧采样函数和参数合同。实心扇形第 i 段使用中心点、弧点 i、弧点 i+1;环形扇区第 i 段使用两组内外点,形成两个三角形。分支明确写出各自索引,读者可以从顶点编号复算;若强行合并成一个高度通用循环,中心点退化和成对顶点顺序反而更难观察。

当前颜色仍使用继承自 Graphic 的统一顶点色,UV 保持默认值。本文不把第 5 篇四角渐变或第 6 篇纹理映射强行套到圆弧,因为圆形 UV 需要新的中心与边界定义。系列只复用已经成立的顶点记录概念,不把“前章出现过”误解为“后章必须同时启用所有属性”。

何时重建

SetVerticesDirty 只通知 Canvas 顶点数据需要重建,并不直接调用 OnPopulateMesh。半径、起始角、夹角和分段数都会改变位置或索引,所以 setter 写入归一值后统一标脏。Graphic 不读取输入设备,输入层也不接触 VertexHelper;同一组属性因而可以被 Inspector、固定测试、鼠标或摇杆复用。

失败时序有两种。直接绕开属性写私有字段,可能让数据已变而 Canvas 仍显示旧几何;每帧重复写入完全相同属性,则会产生无意义的重建请求。当前组件负责正确重建,不在每个 setter 保存幂等快照;第 10 篇会在三个 Graphic 的共同上游比较完整显示状态,只在状态实际改变时下发。

重建发生时,组件读取的是当时全部字段,而不是某个 setter 的单项增量。控制者若依次写半径、夹角和起始角,Canvas 最终应消费一组一致参数;但业务层仍应把它们视为一次显示快照,避免不同系统分别拥有同一参数。第 10 篇把幂等与批量下发放在组合器入口,SectorGraphic 因而保持一个纯粹、可单独测试的网格组件。

SetVerticesDirtySetMaterialDirty 也不能混用。这里改变的是顶点位置、顶点数或索引,所以选择前者;纹理引用变化才属于第 6 篇的材质路径。错误地只标记材质,可能重新提交材质却继续使用旧扇形;每次两个标记都调用虽然看似保险,却模糊了真正变化的数据边界。

// Assets/OnPopulateMeshBook/Chapters/Chapter09_Sector/
// Tests/Editor/SectorGraphicTests.cs
[Test]
public void Populate_SixtyDegreeFanUsesSixSteps()
{
    var graphic = MeshTestSupport.CreateGraphic<SectorGraphic>(
        new Vector2(220f, 220f));
    graphic.InnerRadius = 0f;
    graphic.OuterRadius = 100f;
    graphic.FullCircleSegments = 36;
    graphic.StartAngle = 60f;
    graphic.SweepAngle = 60f;
    var mesh = MeshTestSupport.Build(graphic);
    Assert.That(graphic.StepCount, Is.EqualTo(6));
    Assert.That(mesh.vertexCount, Is.EqualTo(8));
    Assert.That(mesh.triangles, Has.Length.EqualTo(18));
    MeshTestSupport.AssertFinite(mesh);
    MeshTestSupport.AssertIndicesInRange(mesh);
    MeshTestSupport.Destroy(graphic, mesh);
}

[Test]
public void Populate_ZeroSweepProducesEmptyMesh()
{
    var graphic = MeshTestSupport.CreateGraphic<SectorGraphic>(
        new Vector2(220f, 220f));
    graphic.SweepAngle = 0f;
    var mesh = MeshTestSupport.Build(graphic);
    Assert.That(mesh.vertexCount, Is.Zero);
    MeshTestSupport.Destroy(graphic, mesh);
}

[Test]
public void Populate_FullCircleSegmentsClampToThree()
{
    var graphic = MeshTestSupport.CreateGraphic<SectorGraphic>(
        new Vector2(220f, 220f));
    graphic.OuterRadius = 100f;
    graphic.FullCircleSegments = 1;
    graphic.SweepAngle = 360f;
    var mesh = MeshTestSupport.Build(graphic);
    Assert.That(graphic.FullCircleSegments, Is.EqualTo(3));
    Assert.That(graphic.StepCount, Is.EqualTo(3));
    Assert.That(mesh.vertexCount, Is.EqualTo(5));
    Assert.That(mesh.triangles, Has.Length.EqualTo(9));
    MeshTestSupport.Destroy(graphic, mesh);
}

三项测试分别锁定正常角区间、零夹角退化和分段下限。正常样本还检查有限坐标与索引范围;退化样本检查空网格;非法分段样本检查三段整圈产生 5 个顶点和 9 个索引。

测试与场景故意使用不同外半径:测试半径 100 便于结构断言,固定场景半径 140 便于观察。顶点数量只由步数和分支决定,因此二者都应得到 8/18。这一区分防止读者把某个坐标值误认为拓扑常量,也证明算法不是只对截图中的单一尺寸成立。

网格快照

全量 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"
echo "process_exit_code=$?"
process_exit_code=0
test-run result=Passed
total=19 passed=19 failed=0 skipped=0
duration=0.0400379s

Populate_FullCircleSegmentsClampToThree
result=Passed duration=0.000730s
segments=3 stepCount=3 vertexCount=5 indexCount=9
Populate_SixtyDegreeFanUsesSixSteps
result=Passed duration=0.000206s
stepCount=6 vertexCount=8 indexCount=18
Populate_ZeroSweepProducesEmptyMesh
result=Passed duration=0.000116s
stepCount=0 vertexCount=0

证据证明固定 60 度输入按公式选择六步,输出有限且索引合法;零夹角输出空网格;小于三的公开分段输入被归一。本文没有连续拖动的 Profiler 样本,也没有设备矩阵,不能从单次测试耗时推导推荐分段上限。当前只报告生成数量随实际步数线性增长。

总计 19 项覆盖的是系列 EditMode 网格合同,不包含第 10 篇的指针生命周期。三项本文用例的微秒级耗时只用于识别这次执行结果,不能横向比较组件快慢。若未来需要提高动态瞄准的刷新频率,应先记录真实状态变化次数、实际分段数和 Canvas 重建范围,再决定是否修改组件,而不是从单次 NUnit 耗时提前优化。

动态失败

第一个错误把中心方向直接赋给 Start Angle。方向 90、夹角 60 时,区间变成 90 到 150 度,画面中心偏到 120 度。修复属于状态到显示参数的换算层:起始角减去半夹角,SectorGraphic 不猜测调用者传入的是中心还是边界。

第二个错误在 PointOnArc 中忘记乘 Mathf.Deg2Rad。数值 60 会被解释成 60 弧度,第一边界点不再接近 (70,121.244)。第三个错误让零夹角仍强制一步,起止点重合却留下三枚索引。第四个错误只依赖 Inspector 的 [Min(3)],运行时仍可能写入 1;当前属性和步数公式共同守住下限。

错误 可观察数据 正确恢复
中心方向直接作为起始角 中心偏移半个夹角 direction-sweep/2
度数直接传给 Sin/Cos 固定边界坐标不匹配 Deg2Rad
零夹角仍生成一步 重合点与零面积三角形 StepCount=0
分段数允许 1 整圈只有一枚三角形 归一为至少 3

看似省事的替代方案是让 Graphic 直接读取鼠标并自行计算方向。它会让固定属性不再决定唯一输出,测试无法只凭状态复现,摇杆或回放也必须改动网格类。当前单向边界虽然多一次属性下发,却保留输入替换和独立验证能力。

失败边界的观察顺序应从数据开始。先读取 StepCount,判断是否选出了正确步数;再读取首末圆弧坐标,判断角度与弧度是否换算正确;最后检查索引范围和零夹角顶点数。直接盯着画面调整角度,可能用另一个错误数值偶然抵消单位错误,留下无法复算的实现。

组合入口

输入属于控制状态,网格生成属于显示组件。把鼠标读取放进 OnPopulateMesh 会让相同参数产生不同输出,也会破坏 Inspector、测试与其他输入源的复用。SectorGraphic 因此只接收半径、起始角、夹角和分段数。

第 10 篇接收可动态更新的 SectorGraphic、中心方向到起始角的换算式、零夹角空网格合同和实际步数公式,再与圆环、方向线组合成完整技能范围 Prefab。