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 篇再用独立组合器接入状态。
锥形场景



固定场景使用中心方向 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 因而保持一个纯粹、可单独测试的网格组件。
SetVerticesDirty 与 SetMaterialDirty 也不能混用。这里改变的是顶点位置、顶点数或索引,所以选择前者;纹理引用变化才属于第 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。