1. 项目概述:从寻路到智能集群的进化
在RTS(即时战略)游戏的开发中,单位寻路是基础,但也是最容易暴露问题的环节。玩家最不想看到的就是自己精心组织的坦克集群,在冲锋时因为一个路障而挤成一团,或者远程单位在撤退时互相卡位,被敌人逐个击破。传统的
NavMeshAgent
组件确实能解决“从A点到B点”的问题,但它默认是为单个角色设计的。当你需要指挥一个由数十甚至上百个单位组成的军团时,简单的寻路逻辑就会立刻捉襟见肘。
这个项目的核心,就是突破
NavMeshAgent
作为“单体寻路器”的思维定式,将其升级为一套服务于“群体智能”的动态系统。我们不仅要让每个单位能找到路,更要让它们像一个训练有素的整体一样移动:保持队形、在高速移动中灵活避让队友和动态障碍、在狭窄通道中自动调整队列。这不仅仅是寻路(Pathfinding),更是导航(Navigation)与群体行为(Flocking Behavior)的结合。对于Unity开发者而言,这意味着我们需要深入
NavMeshAgent
的API底层,结合RTS游戏的特殊需求,设计出一套覆盖从数据层、逻辑层到表现层的完整解决方案。
2. 核心需求与架构设计
2.1 RTS编队移动的核心矛盾解析
在动手写代码之前,我们必须先理清RTS编队移动中几个固有的、相互冲突的需求:
- 整体性与个体性的矛盾 :玩家希望编队作为一个整体移动(保持阵型),但每个单位又必须是独立的物理实体,有自己的碰撞体和导航逻辑。
- 效率与美观的矛盾 :最效率的移动是所有单位直线冲向目标,但这会导致单位重叠、穿模,视觉效果极差。而完美的圆形或方形阵型在复杂地形下又难以维持,且计算开销大。
- 实时性与动态性的矛盾 :障碍物可能是动态的(如被摧毁的建筑、移动的友军单位),寻路系统必须能快速响应这些变化,重新规划路径,而不能有肉眼可见的卡顿。
- 控制与自主的矛盾 :玩家下达的是宏观指令(“移动到此处”、“攻击那个单位”),但微观的避障、绕行、等待队友等行为需要单位自主完成,且这些自主行为不能违背玩家的宏观意图。
基于这些矛盾,我们的系统架构不能是简单的“一个脚本控制所有单位”。它需要分层:
- 指挥官层(Commander) :负责接收玩家指令,解析目标点或目标单位,将宏观指令分解为针对整个编队的移动策略(例如,是散开冲锋还是保持阵型接近)。
- 编队管理层(Formation Manager) :负责生成并维护一个虚拟的阵型模板。这个模板由一系列“槽位”(Slot)位置组成,相对于一个虚拟的“编队中心点”。它不直接移动任何游戏对象。
- 智能体层(Agent Controller) :这是附着在每个单位上的核心脚本。它从编队管理层领取一个“槽位”作为自己的目标位置,但它的终极导航目标是由指挥官层决定的实际世界目标。它的核心职责是: 在前往世界目标的大前提下,尽可能优雅地、无碰撞地移动到自己的阵型槽位附近。
2.2 技术栈选型与工具准备
我们的实现将完全基于Unity原生的AI导航系统,以保证最好的性能和兼容性。
-
核心组件
:
NavMeshAgent。这是我们的“腿”,负责底层寻路计算。我们将通过脚本深度控制它的destination、speed、angularSpeed、avoidancePriority等属性。 -
动态障碍
:
NavMeshObstacle组件。对于需要被避开的动态物体(如可移动的载具、临时路障),为其添加此组件,并设置Carve属性为true,它就能在NavMesh上“挖”出一个临时不可通行区域,迫使NavMeshAgent重新规划路径。 -
导航网格
:通过
Window > AI > Navigation窗口烘焙场景的NavMesh。这是所有寻路的基础。需要特别注意Agent Radius(角色半径)和Step Height(可跨越高度)的设置,它们直接影响编队能否通过狭窄区域。 -
辅助工具
:
-
Physics.OverlapSphere/Physics.SphereCast:用于实现单位之间的感知,避免局部碰撞。 -
Vector3数学运算(如Vector3.MoveTowards,Vector3.RotateTowards,Vector3.ProjectOnPlane):处理阵型偏移、朝向插值等。 -
Coroutine(协程)或Update管理:用于分帧处理大量单位的逻辑更新,避免单帧卡顿。
-
注意 :对于超大规模单位(如数百个),可以考虑使用Unity的DOTS(面向数据的技术栈)和Job System进行性能优化,但这属于进阶内容。本项目将聚焦于基于GameObject的传统方案,它更直观,适用于绝大多数RTS项目。
3. 核心模块实现详解
3.1 动态阵型系统的构建
阵型不是静态的图片,而是一组动态计算的相对位置。我们创建一个
FormationManager
单例类来负责此事。
1. 槽位(Slot)计算算法
当玩家框选单位并下达移动指令时,
FormationManager
会根据所选单位数量、当前阵型类型(如线列、方阵、楔形)计算出一个虚拟的阵型。这个阵型以“编队锚点”(通常是队伍中心或领队单位的位置)为基准。
// 简化的线列阵型生成示例
public List<Vector3> CalculateLineFormation(Vector3 anchorPoint, Vector3 forwardDirection, int unitCount, float spacing)
{
List<Vector3> slots = new List<Vector3>();
Vector3 rightDirection = Vector3.Cross(Vector3.up, forwardDirection).normalized;
int unitsPerSide = unitCount / 2;
bool isOdd = (unitCount % 2) == 1;
for (int i = 0; i < unitCount; i++)
{
// 计算当前单位应该在第几列(从中间向两侧排开)
int sideIndex = (i + 1) / 2;
int sideMultiplier = (i % 2 == 0) ? 1 : -1; // 左右交替
if (isOdd && i == 0)
{
// 奇数单位时,第一个单位在中心
slots.Add(anchorPoint);
continue;
}
// 计算偏移:向右偏移 + 可能的向后偏移(形成多排)
Vector3 offset = rightDirection * (sideIndex * spacing * sideMultiplier);
// 可以加入前后排的偏移计算
// offset += -forwardDirection * (rowIndex * rowSpacing);
slots.Add(anchorPoint + offset);
}
return slots;
}
2. 槽位分配与再平衡 单位与槽位的绑定不是永久的。当单位死亡或编队结构变化时,需要进行动态再分配。一个高效的算法是:每一帧(或每几帧),计算每个单位到每个空闲槽位的距离,使用类似匈牙利算法或简单的“最近邻”贪心算法进行重新匹配,使总体移动成本最低。
3. 编队锚点的移动
锚点本身也需要向玩家的目标点移动。我们可以用一个虚拟的
GameObject
或直接用一个
Vector3
变量作为锚点,并使用一个独立的
NavMeshAgent
(或简单的
Vector3.MoveTowards
)来驱动它。所有单位的槽位位置都是基于这个动态移动的锚点实时计算出来的。
3.2 融合NavMeshAgent的智能体控制器
这是每个单位大脑,
UnitAgentController
脚本需要挂载在每一个可移动单位上。
1. 双目标系统
这是实现动态避障和保持阵型的关键。每个
UnitAgentController
维护两个目标:
-
终极目标(Ultimate Destination)
:由指挥官下达的最终世界坐标。这是
NavMeshAgent.destination的设置值,决定了单位的宏观路径。 -
阵型偏移目标(Formation Offset)
:从
FormationManager获取的、相对于编队锚点的本地坐标。这个目标不是直接设置给NavMeshAgent的。
2. 混合速度与转向控制 我们不能让单位僵硬地走向自己的槽位,否则会在复杂地形中脱离大部队。策略是:
-
NavMeshAgent始终朝向 终极目标 寻路。 -
通过脚本,在每一帧计算一个
混合速度向量
。这个向量是
NavMeshAgent.desiredVelocity(想去终极目标的方向)和朝向 阵型偏移目标 的方向向量的加权和。 -
通过控制
NavMeshAgent.velocity(在某些情况下)或使用Rigidbody.AddForce(如果用了物理)来施加这个混合速度,使单位在沿着大路径前进的同时,有向阵型位置靠拢的趋势。
void UpdateMovement()
{
if (!hasFormationSlot) return;
// 计算当前帧的阵型槽位世界坐标
Vector3 worldSlotPosition = formationAnchor.TransformPoint(mySlotLocalPosition);
// 计算朝向槽位的方向向量(水平方向)
Vector3 toSlotDir = (worldSlotPosition - transform.position);
toSlotDir.y = 0;
if (toSlotDir.magnitude > 0.1f)
{
toSlotDir.Normalize();
}
// 获取NavMeshAgent想去终极目标的方向
Vector3 navDesiredDir = agent.desiredVelocity.normalized;
// 混合两个方向:以寻路方向为主,阵型方向为辅。
// blendFactor可以根据距离槽位的远近动态调整,离得越远,寻路权重越高。
float distanceToSlot = Vector3.Distance(transform.position, worldSlotPosition);
float blendFactor = Mathf.Clamp01(distanceToSlot / maxSlotBlendDistance);
Vector3 finalDirection = Vector3.Slerp(navDesiredDir, toSlotDir, 1 - blendFactor);
// 应用速度(这里是一种简化处理,更复杂的可能需要手动控制agent.velocity)
// 注意:直接修改agent.velocity可能会与内部寻路冲突,需谨慎。
// 更常见的做法是控制agent.speed和角速度,让agent自己处理。
agent.speed = CalculateDynamicSpeed(distanceToSlot, blendFactor);
// 通过设置destination,让agent的寻路方向自然向最终方向靠拢,同时我们通过上面的混合逻辑影响其决策权重(通过障碍物设置实现)
}
3. 动态避障优先级设置
Unity的
NavMeshAgent
自带简单的互相避让功能,通过
avoidancePriority
属性实现。我们可以根据单位在阵型中的位置(如前排优先级低,后排优先级高)或单位类型(重型单位优先级高)来设置不同的优先级,让低优先级单位更主动地避让高优先级单位,模拟出更合理的群体流动。
3.3 动态障碍物与局部避碰的增强
NavMeshObstacle
处理的是导航网格层面的、需要重新寻路的大障碍。但单位之间的贴身挤撞,需要更实时的局部避碰(Local Avoidance)。
1. 感知层的实现
在
UnitAgentController
的
Update
中,使用
Physics.OverlapSphere
或更高效的
Physics.SphereCastNonAlloc
检测周围一定半径内的其他单位。
void CheckLocalAvoidance()
{
int hitCount = Physics.OverlapSphereNonAlloc(transform.position, avoidanceRadius, colliderBuffer, unitLayerMask);
Vector3 separationForce = Vector3.zero;
for (int i = 0; i < hitCount; i++)
{
if (colliderBuffer[i].gameObject == this.gameObject) continue;
Vector3 toOther = transform.position - colliderBuffer[i].transform.position;
float distance = toOther.magnitude;
if (distance < 0.01f) continue;
// 距离越近,排斥力越强(遵循反比规律)
float strength = Mathf.Clamp01(1.0f - (distance / avoidanceRadius));
separationForce += (toOther.normalized / distance) * strength * avoidanceWeight;
}
if (separationForce.magnitude > 0)
{
// 将排斥力转化为一个临时的、局部的速度调整或路径偏移
// 可以将其作为一个额外的“推力”应用到单位的移动上
ApplySeparationForce(separationForce);
}
}
2. 力向量合成
将计算得到的“分离力”与之前计算的“混合方向向量”进行合成。注意,分离力的处理应该是瞬时的、高优先级的,但也不能过于剧烈,否则单位会抖动。通常使用一个平滑的插值(
Vector3.SmoothDamp
)来应用这个力。
3. 处理狭窄通道
在通道口,编队需要从宽阵型自动切换为单列或双列。这可以通过在通道入口处设置一个“触发器”,当
FormationManager
检测到锚点进入该区域时,动态切换阵型类型为“纵队”,并拉大前后单位间距。同时,每个单位的
NavMeshAgent.radius
可以临时微调(但这会影响导航网格,需小心),或者通过增大局部避碰的力度来防止并排卡住。
4. 性能优化与实战调试技巧
当单位数量上去后,每一帧对上百个单位进行物理检测和复杂向量计算将是性能杀手。
4.1 分帧更新与LOD系统
不要所有单位每帧都更新所有逻辑。
- 距离分帧 :将单位按与摄像机的距离分层。距离很远的单位,可以每5-10帧更新一次阵型跟随和局部避碰;中距离单位每2-3帧更新一次;只有近距离单位才每帧更新。
-
逻辑LOD
:对于远离战斗或处于闲置状态的单位,可以完全关闭其局部避碰和精细的阵型跟随,只保留最基本的
NavMeshAgent寻路。 - 使用协程管理 :将非紧急的、可延后的计算(如槽位再平衡)放到协程中分帧执行。
IEnumerator UpdateFormationSlotsCoroutine()
{
while (true)
{
UpdateSlotAssignments(); // 一个开销较大的函数
// 根据单位数量决定等待帧数
yield return new WaitForSeconds(0.1f); // 每0.1秒更新一次,而非每帧
}
}
4.2 导航网格与代理参数的微调
NavMeshAgent
的参数对群体行为影响巨大,需要反复调试。
-
Agent Radius
:这是最重要的参数之一。在烘焙导航网格时使用的
Agent Radius决定了通道的可通过性。 在项目中,这个值应该略大于你单位碰撞体的实际半径 。例如,单位碰撞体半径0.4米,NavMesh Agent Radius可以设为0.45米。这会在路径之间创建一个天然的“缓冲区”,防止单位贴边行走时卡住。对于编队,可以考虑为不同类型的单位设置不同的代理类型(在Navigation窗口的Agents页签),并烘焙对应的NavMesh。 - Avoidance Priority :善用此属性。将重要的、移动缓慢的单位(如英雄、攻城车)设置为高优先级(低数值,如10),将灵活的、数量多的单位(如步兵)设置为低优先级(高数值,如80)。这样步兵群会主动绕开攻城车。
- Speed and Angular Speed :转弯速度(Angular Speed)对于保持队形流畅至关重要。过低的转弯速度会导致单位在拐角处“甩尾”,脱离编队。可以尝试根据单位与其阵型槽位的角度差来动态调整角速度,差值越大,临时赋予的角速度越高。
4.3 可视化调试工具
在开发阶段,绘制调试图形是必不可少的。
-
绘制阵型槽位
:在
OnDrawGizmos中,为每个单位绘制一条从自身位置到其目标槽位位置的线(Gizmos.DrawLine),并用小球(Gizmos.DrawSphere)标记槽位位置。这能一眼看出槽位分配是否合理。 -
绘制感知范围与避碰向量
:绘制单位的
avoidanceRadius球体,并用不同颜色的射线绘制出计算出的分离力方向。这能帮你直观调整避碰参数。 -
绘制NavMeshAgent路径
:通过
NavMeshAgent.path可以获取当前路径的角落点,用Gizmos将其连接起来绘制,方便查看寻路是否异常。
5. 常见问题与解决方案实录
在实际开发中,我遇到了无数坑,以下是几个最具代表性的问题及其解决思路。
问题一:单位在目标点附近“跳舞”或高频抖动。
- 现象 :单位接近目标点或阵型槽位时,不是平滑停下,而是左右快速摇摆。
-
根因
:
NavMeshAgent的stoppingDistance(停止距离)设置过小(默认为0),且NavMeshAgent的路径终点更新过于频繁(每帧都设置destination到精确的槽位点),导致代理在极小范围内不断进行微路径规划。 -
解决方案
:
-
设置一个合理的
stoppingDistance,例如0.5。告诉代理,距离目标0.5米以内就算到达。 -
引入一个“到达阈值”判断。当单位与槽位的距离小于此阈值时,不再强制更新
NavMeshAgent.destination,而是让单位依靠惯性或简单的物理滑行至终点,或者直接停止代理的移动(agent.isStopped = true)。 -
对目标位置进行低通滤波。不要直接将计算出的槽位世界坐标设为目的地,而是使用
Vector3.SmoothDamp对其进行平滑处理,得到一个平滑移动的目标点,再设置给代理。
-
设置一个合理的
问题二:编队通过狭窄门洞时,部分单位被“卡”在门外,不断尝试寻路。
- 现象 :门洞宽度刚好允许2个单位并行,但第三个单位永远找不到路进去。
-
根因
:
NavMesh在门洞处生成的可行走区域边界是固定的。当两个单位并排“占据”了门洞两侧时,它们的Agent Radius在导航网格上形成的“占用”区域可能在中线处重叠,导致导航网格认为中间已无路可走。 -
解决方案
:
- 设计上 :确保门洞的导航网格宽度大于(单位Agent Radius * 2 + 缓冲值)。这是最根本的。
- 逻辑上 :实现一个“交通管制”。当检测到多个单位试图通过同一狭窄通道时,可以临时让编队切换为单列模式,或者为通道两端的单位设置一个简单的信号灯系统(通过状态机),让它们轮流通过。
-
参数上
:临时调高通过狭窄区域单位的
avoidancePriority,让它们更“强硬”,或者临时减小其agent.radius(这是一个危险操作,需确保之后能恢复)。
问题三:动态障碍物(NavMeshObstacle)导致单位长时间停顿或绕远路。
- 现象 :一个可移动的箱子作为障碍物,当其移动后,单位仍然在原地等待,不重新寻路,或者寻路出一个巨大的弧形。
-
根因
:
NavMeshObstacle的Carve操作是异步的,且有一定延迟。代理可能在其路径被雕刻掉之前就已经规划了一条路径,而这条路径可能已经无效。代理的自动重新寻路机制可能不够积极。 -
解决方案
:
-
确保
NavMeshObstacle的Carve Only Stationary未被勾选,并且Move Threshold设置得合理(如0.1),这样障碍物微小移动也会触发重新雕刻。 -
在
UnitAgentController中增加一个“路径阻塞检测”。定期(如每0.5秒)检查NavMeshAgent.pathStatus或NavMeshAgent.remainingDistance是否在合理减少。如果代理长时间未移动且路径状态异常,则手动调用NavMeshAgent.ResetPath()后重新设置destination,强制触发一次全新的寻路计算。 -
对于非常重要的单位,可以在其前方发射一个
NavMesh.Raycast来探测即将行走的路径是否被新出现的障碍物阻挡,实现预判。
-
确保
问题四:大规模编队转向时,队形散乱,后排单位划出“大弧线”。
- 现象 :编队锚点直角转弯时,外侧的单位需要走很长的弧线才能跟上,导致队形拉长、散开。
- 根因 :所有单位共享同一个编队锚点路径。当锚点转弯时,外侧单位的槽位在世界空间中划过的轨迹是一个更大的圆弧。
-
解决方案
:这在一定程度上是符合物理现实的(骑兵队转弯外侧就是要跑得更快)。如果要维持紧密队形,需要更复杂的策略:
- 预测性偏移 :不是让槽位严格跟随锚点,而是让槽位根据锚点的移动方向和速度,提前向转弯的内侧做一个偏移。这需要预测锚点的未来位置。
- 分层指挥 :将大编队拆分成多个小队(Squad),每个小队有自己的子锚点。大编队锚点负责宏观路径,子锚点负责微观的队形保持。这样转弯时,每个小队作为一个整体移动,内部队形更易保持。
- 速度差异化 :在转弯时,动态调整内外侧单位的速度。内侧单位减速,外侧单位加速。这需要根据单位相对于锚点转弯中心的半径来计算一个速度系数。
实现一个健壮的RTS单位编队与动态避障系统,是一个在性能、效果、逻辑复杂度之间反复权衡的过程。没有一劳永逸的银弹,最关键的是建立一套清晰的数据流架构(指挥官->编队管理器->智能体),并针对你的游戏特定需求(是《星际争霸》式的快节奏微操,还是《全面战争》式的大规模阵型)进行针对性的调优。从最基本的
NavMeshAgent
目的地管理做起,逐步叠加阵型、局部避碰、动态障碍响应等层次,并通过大量可视化的调试工具来观察和优化每一层的行为,最终才能让屏幕上的像素点真正呈现出“军团”的质感。



1万+

被折叠的 条评论
为什么被折叠?



