Unity动态寻路实战:NavMesh障碍物与运行时烘焙混合方案
2026/7/21 7:05:21 网站建设 项目流程

1. 项目概述:为什么Unity AI Navigation是动态场景的基石

在Unity里做游戏,尤其是涉及到角色移动的,寻路是个绕不开的话题。十年前我们可能还在手动写A*算法,或者用NavMesh烘焙一个静态地图就万事大吉。但现在,玩家和项目的要求都高了,场景里不再是静态的摆设,箱子可以被推开,桥梁会断裂,敌人会动态搭建掩体——这就是动态寻路要解决的问题。Unity内置的AI Navigation系统,特别是它的NavMesh组件,早已不是简单的“烘焙完就完事”的工具,它提供了一套从静态烘焙到动态更新的完整解决方案。这个项目,就是带你从零开始,亲手搭建一个能应对这些变化的动态寻路场景。无论你是想做一个RTS游戏里单位绕过被摧毁的建筑,还是一个解谜游戏里实时改变的通路,亦或是AI敌人对玩家搭建的临时路障做出反应,这里面的核心逻辑都是一样的。

很多人对Unity NavMesh的印象还停留在“Window -> AI -> Navigation”烘焙一下,然后给角色挂个NavMeshAgent组件调用SetDestination就结束了。这确实能解决80%的静态场景寻路需求。但剩下的20%,恰恰是提升游戏真实感和策略深度的关键。动态寻路的核心,在于让NavMesh这个“地图”活起来,能够响应游戏运行时的变化。这不仅仅是性能优化,更是游戏逻辑设计的一部分。通过这个实战,你会掌握如何让AI“看见”并适应一个不断变化的世界,这是构建高级游戏AI的第一步。

2. 核心思路与方案选型:动态更新 vs. 局部重建

面对动态场景,我们主要有两种技术思路,选择哪一种取决于你的场景变化是“局部小改动”还是“全局大变动”。

2.1 方案一:NavMesh障碍物(NavMeshObstacle)

这是处理动态寻路最直接、最常用的方案。它的原理很简单:在场景中移动的、会阻挡AI的物体上,挂载一个NavMeshObstacle组件。这个组件有两种工作模式:

  • Carve模式:这是最强大的功能。当障碍物启用时,它会在运行时从已有的NavMesh中“雕刻”掉自己所占的区域,相当于实时在寻路地图上挖个洞。AI在计算路径时会自动避开这个区域。当障碍物移动时,这个“洞”也会跟着移动。这完美解决了移动的箱子、开关的门、被驾驶的车辆这类需求。
  • 障碍物模式:更像是一个虚拟的碰撞体,AI的NavMeshAgent在寻路时会将其视为不可穿越的边界,但不会真正修改底层的NavMesh数据。开销更小,适用于大量但简单的阻挡物。

注意NavMeshObstacle的Carve功能虽然方便,但频繁移动或大量使用会对性能产生持续开销,因为它需要动态更新NavMesh数据。通常建议对关键的运动物体使用,对于静止后就不再移动的物体,可以考虑方案二。

2.2 方案二:运行时NavMesh烘焙(NavMeshSurface)

当场景发生结构性改变,比如一栋建筑被炸毁、一座桥被搭建起来,这时NavMeshObstacle就不够用了,因为我们需要的是永久性地改变可行走区域。这就需要用到Unity的NavMeshSurface组件(来自AI Navigation包)和运行时烘焙API。

其工作流程是:

  1. 在场景中创建一个空物体,添加NavMeshSurface组件,它定义了烘焙的参数(如代理大小、可行走层)。
  2. 游戏运行时,当发生重大地形变化(如建筑摧毁),你通过代码移除或添加对应的场景几何体。
  3. 调用NavMeshSurface.BuildNavMesh()或它的异步方法,在运行时重新烘焙整个或局部NavMesh。

这个方案的优点是能处理任意复杂的、永久性的地形变化。缺点是运行时烘焙是重量级操作,即使只烘焙一小块区域(通过NavMeshBuilder.BuildNavMeshData配合NavMeshBuildSettingsNavMeshBuildSource可以做到局部更新),其耗时也比NavMeshObstacle的雕刻要高得多,不能每帧调用。

2.3 我们的选型与混合策略

对于一个综合性的动态寻路场景,我们几乎总是采用混合策略,这也是工业级项目的常见做法:

  • 高频、小范围的动态阻挡:使用NavMeshObstacle(Carve模式)。例如:玩家角色、可推动的箱子、开关的门。
  • 低频、大范围的地形改变:使用运行时局部NavMesh烘焙。例如:可摧毁的墙壁、可搭建的楼梯。
  • 静态背景:在编辑器中预先烘焙好,运行时不变。

这样既能保证对动态事件的实时响应,又能将性能开销控制在合理范围内。本次实战将同时实现这两种机制,让你体会其中的差异和适用场景。

3. 基础环境搭建与静态烘焙

在玩动态之前,必须把静态的基础打牢。我们首先创建一个标准的3D项目,并确保AI Navigation包已导入(Unity 2019.4及以上版本通常已内置,较新版本在Package Manager中搜索“AI Navigation”进行安装或更新)。

3.1 场景搭建与导航静态层设置

创建一个简单的测试场景:一个Plane作为地面,上面放置几个Cube作为静态的墙壁和房屋。关键步骤在于设置导航区域(NavMesh Area)。在Navigation窗口(Window -> AI -> Navigation)的“Areas”标签页,你会看到默认的“Walkable”区域。我们可以增加新的区域,例如:

  • Jump(索引1):用于标记可以跳过的矮墙。
  • Not Walkable(索引2):明确不可行走的区域,但有时我们更倾向于用物体本身不参与烘焙来实现。

更实用的做法是,为不同地形设置不同成本。例如,将“泥泞”地形的成本设为3,意味着AI会优先选择成本为1的普通道路,即使泥泞路更近。这通过在场景中物体的材质或Renderer组件上设置“Navigation Area”属性来实现。

3.2 导航网格烘焙参数详解

切换到Navigation窗口的“Bake”标签页,这里的参数决定了最终生成的NavMesh质量:

  • Agent Radius:代理半径。想象AI是一个圆柱体,这是它的身体半径。值太小,AI会贴墙走;值太大,狭窄通道可能无法生成可行走区域。通常设置为角色碰撞体半径的80%-90%。
  • Agent Height:代理高度。AI能通过的最低门框高度。
  • Max Slope:最大爬坡角度。超过这个角度的斜坡将被视为不可行走。
  • Step Height:可跨越台阶高度。低于此值的台阶,AI会直接走上去而不是寻找绕路。
  • Drop Height:最大下落高度。AI可以安全跳下的最大高度,与Jump功能关联。
  • Jump Distance:最大跳跃距离。水平跳跃的最大距离。

设置好参数后(例如,Radius=0.5, Height=2.0, Max Slope=45, Step Height=0.3),点击“Bake”按钮。烘焙完成后,场景视图会显示蓝色的NavMesh网格。这就是AI眼中的“地图”。

3.3 创建导航代理(NavMeshAgent)

创建一个胶囊体(Capsule)作为我们的AI角色。为其添加NavMeshAgent组件。核心参数包括:

  • Speed:移动速度。
  • Angular Speed:转向速度。
  • Acceleration:加速度。
  • Stopping Distance:到达目标点多远时停止。
  • Auto Braking:接近目标时是否自动减速。
  • Obstacle Avoidance:避障质量。设置优先级(Priority)可以让一些AI主动为其他AI让路。

写一个简单的测试脚本,让AI点击地面移动:

using UnityEngine; using UnityEngine.AI; public class ClickToMove : MonoBehaviour { private NavMeshAgent agent; private Camera mainCamera; void Start() { agent = GetComponent<NavMeshAgent>(); mainCamera = Camera.main; } void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit)) { // 关键:确保点中的位置在NavMesh上 if (NavMesh.SamplePosition(hit.point, out NavMeshHit navHit, 1.0f, NavMesh.AllAreas)) { agent.SetDestination(navHit.position); } } } } }

至此,一个基础的静态寻路场景就完成了。AI已经可以在预烘焙的地图上自由移动。

4. 动态寻路核心实现:NavMesh障碍物实战

现在,让我们引入动态元素。首先实现可移动的障碍物。

4.1 创建可移动的障碍物方块

创建一个Cube,命名为“DynamicObstacle”。为其添加以下组件:

  1. Rigidbody:使其受物理引擎影响,可以被推动或受力移动。勾选“Is Kinematic”如果你希望用脚本而非物理力来控制它的移动。
  2. NavMeshObstacle:这是核心。设置参数:
    • Shape:选择Box(与Cube匹配)。
    • Carve务必勾选。这是启用动态雕刻的关键。
    • Move Threshold:移动阈值。障碍物移动距离超过此值才会触发NavMesh更新。适当提高(如0.1)可以减少不必要的频繁更新。
    • Time To Stationary:静止时间。障碍物停止移动后,等待多久才将其视为静止并(可选)停止雕刻。默认即可。
    • Carve Only Stationary:仅当静止时雕刻。如果希望障碍物移动时也产生阻挡,不要勾选此项

4.2 编写障碍物移动脚本

为了让障碍物动起来,我们写一个简单的脚本,让它能在水平面上被鼠标拖动。

using UnityEngine; public class DraggableObstacle : MonoBehaviour { private Vector3 screenPoint; private Vector3 offset; private NavMeshObstacle obstacle; private bool isDragging = false; void Start() { obstacle = GetComponent<NavMeshObstacle>(); if (obstacle == null) { Debug.LogError("NavMeshObstacle component missing!"); } } void OnMouseDown() { screenPoint = Camera.main.WorldToScreenPoint(transform.position); offset = transform.position - Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, screenPoint.z)); isDragging = true; // 开始拖动时,确保障碍物是启用的,并且正在雕刻 if (obstacle != null) { obstacle.enabled = true; obstacle.carving = true; } } void OnMouseDrag() { if (!isDragging) return; Vector3 curScreenPoint = new Vector3(Input.mousePosition.x, Input.mousePosition.y, screenPoint.z); Vector3 curPosition = Camera.main.ScreenToWorldPoint(curScreenPoint) + offset; curPosition.y = transform.position.y; // 保持Y轴不变,在平面上拖动 transform.position = curPosition; } void OnMouseUp() { isDragging = false; // 松开鼠标后,障碍物仍然启用并雕刻,除非你希望它停止后变成非阻挡状态。 // 如果需要,可以在这里设置一个计时器,一段时间后设置 obstacle.carving = false; } }

将脚本挂载到“DynamicObstacle”上。运行游戏,点击并拖动这个方块。你会发现,当方块移动时,蓝色的NavMesh网格会实时地“避开”它。让你的AI角色尝试走向方块另一侧的目标点,它会自动绕开移动中的方块。这就是动态雕刻的魅力。

4.3 障碍物参数调优与性能考量

  • Carve Only Stationary:这是一个重要的性能开关。如果勾选,障碍物只在静止时才会雕刻NavMesh,移动过程中NavMesh是完整的,AI可能会计算出一条穿过移动中障碍物的路径,直到障碍物停下。这适合像“最终停下的路障”这类物体。对于持续移动的障碍(如巡逻的敌人),则不能勾选。
  • Move Threshold:如果你的障碍物因为物理抖动而轻微移动,可能会触发频繁的NavMesh更新。适当增加此值(例如从0.1到0.3)可以有效降低性能开销。
  • 开销管理:场景中同时活动的NavMeshObstacle(尤其是开启雕刻的)数量不宜过多(建议不超过几十个)。对于大量的小型动态阻挡物(如一群密集的、移动缓慢的虫子),可以考虑使用Obstacle Avoidance的“障碍物模式”而非雕刻模式,或者使用更简化的碰撞处理逻辑。

5. 高级动态:运行时NavMesh烘焙与局部更新

接下来,处理更剧烈的场景变化——可摧毁的墙壁。我们创建一个Cube作为墙壁,并计划在运行时销毁它,然后更新NavMesh,让AI能通过新出现的缺口。

5.1 设置可摧毁墙壁与NavMeshSurface

首先,需要导入或确保已安装“AI Navigation”包(包含NavMeshSurface)。在场景中创建一个Cube作为墙壁,确保其静态标记(Static flag)中不包含“Navigation Static”。因为我们不希望在初始烘焙时把它算进去,而是由NavMeshSurface动态管理。

然后,创建一个空游戏对象,命名为“RuntimeNavMesh”,为其添加NavMeshSurface组件。在组件中:

  • Agent Type:选择你使用的代理类型(默认Humanoid)。
  • Collect Objects:选择“Volume”或“All Children”。这里我们选“Volume”。
  • Size:设置一个包围盒,覆盖你希望运行时烘焙的动态物体区域。比如,把包围盒设置得足够大,能包含那面可摧毁的墙及其周围区域。
  • Layer Mask:指定哪些层的物体会被收集用于烘焙。确保你的墙壁所在的层被包含。

初始状态下,先不烘焙。我们的静态地面已经由Window->Navigation烘焙好了。NavMeshSurface将负责管理动态部分的网格。

5.2 实现墙壁摧毁与局部烘焙

为墙壁写一个简单的脚本,当被点击时“摧毁”它(实际上是禁用渲染和碰撞),并触发NavMesh更新。

using UnityEngine; using UnityEngine.AI; // 需要引入AI命名空间 public class DestructibleWall : MonoBehaviour { private NavMeshSurface navMeshSurface; private bool isDestroyed = false; void Start() { // 假设NavMeshSurface对象在场景中只有一个,或者通过其他方式引用 navMeshSurface = FindObjectOfType<NavMeshSurface>(); if (navMeshSurface == null) { Debug.LogError("No NavMeshSurface found in the scene!"); } } void OnMouseDown() { if (isDestroyed) return; // 1. “摧毁”墙壁:禁用渲染器和碰撞体 GetComponent<Renderer>().enabled = false; GetComponent<Collider>().enabled = false; isDestroyed = true; // 2. 触发NavMesh更新 if (navMeshSurface != null) { // 方法A:同步重建整个Surface(简单但可能卡顿) // navMeshSurface.BuildNavMesh(); // 方法B(推荐):异步重建,避免帧率卡顿 StartCoroutine(RebuildNavMeshAsync()); } } private System.Collections.IEnumerator RebuildNavMeshAsync() { // 使用NavMeshBuilder的异步接口进行更精细的控制 // 这里我们简单调用Surface的异步方法(如果版本支持) // 对于较新版本,可以直接: // navMeshSurface.UpdateNavMesh(navMeshSurface.navMeshData); // 但更通用的做法是: AsyncOperation operation = navMeshSurface.UpdateNavMeshAsync(navMeshSurface.navMeshData); while (!operation.isDone) { yield return null; } Debug.Log("NavMesh updated after wall destruction."); // 或者,如果你需要完全重新收集几何体并烘焙: // NavMeshData newData = navMeshSurface.BuildNavMeshData(); // if (newData != null) // { // NavMesh.RemoveAllNavMeshData(); // 小心!这会移除所有NavMesh // NavMesh.AddNavMeshData(newData); // } } }

重要提示:直接调用navMeshSurface.BuildNavMesh()会在主线程执行,如果动态物体很多,会造成明显的卡顿。在生产环境中,务必使用异步更新。上述代码中的UpdateNavMeshAsync是一个简化示例,实际可能需要根据Unity版本和具体需求,使用NavMeshBuilder.BuildNavMeshDataAsync配合NavMeshBuildSources来收集需要更新的局部区域数据,实现真正的增量更新,这是性能最优解。

5.3 结合使用:动态障碍物与可导航区域更新

现在,你的场景中同时存在:

  1. 一个由NavMeshObstacle控制的、可拖动的动态方块。
  2. 一面可摧毁的墙壁,摧毁后通过NavMeshSurface更新导航网格。

运行游戏,先让AI去一个被墙壁挡住的目标点,它无法到达。点击摧毁墙壁,等待NavMesh更新完成(可以在UpdateNavMeshAsync的协程里加个调试日志或进度提示)。更新后,再次命令AI去同一个目标点,它会从墙壁的缺口处通过。

同时,你仍然可以拖动那个动态方块。AI在寻找路径时,会同时考虑:

  • 静态烘焙的NavMesh(地面)。
  • 运行时由NavMeshSurface更新的NavMesh(墙壁缺口区域)。
  • NavMeshObstacle实时雕刻出的空洞(移动的方块)。

这三者共同构成了一个完整的、动态的寻路世界。

6. 路径搜索优化与高级代理控制

有了动态的地图,AI的寻路逻辑也需要更精细的控制。

6.1 分层成本与区域掩码(Area Mask)

之前提到过为不同区域设置不同的成本。现在我们来使用它。假设我们创建了一个“Mud”(泥泞)区域,成本为3。在场景中创建一个Plane,为其材质分配“Mud”区域。 在AI的NavMeshAgent组件上,有一个Area Mask属性。它决定了AI可以行走在哪些区域。默认是“Everything”。你可以取消勾选“Mud”,那么AI将完全不会踏上泥泞地。或者保持勾选,但AI在寻路时会计算成本,如果泥泞地是捷径,它可能会选择,但通常会优先选择成本低的道路。 在代码中,你可以动态改变Area Mask:

agent.areaMask = (1 << NavMesh.GetAreaFromName("Walkable")) | (1 << NavMesh.GetAreaFromName("Jump")); // 这行代码设置代理只能走在Walkable和Jump区域。

6.2 动态目标与路径状态查询

在动态场景中,目标点本身也可能是移动的(如追击玩家)。简单的每帧SetDestination会造成大量路径重计算。一个优化方案是设置一个合理的路径更新频率

public class FollowMovingTarget : MonoBehaviour { public Transform target; private NavMeshAgent agent; private float pathUpdateInterval = 0.5f; // 每0.5秒更新一次路径 private float timer = 0f; void Start() { agent = GetComponent<NavMeshAgent>(); } void Update() { timer += Time.deltaTime; if (timer >= pathUpdateInterval) { timer = 0; if (target != null && agent.isOnNavMesh) { agent.SetDestination(target.position); } } // 实时检查路径状态 if (agent.hasPath) { // 检查路径是否被阻塞(例如,一个动态障碍物突然出现在路径上) if (agent.isPathStale || agent.pathStatus == NavMeshPathStatus.PathPartial || agent.pathStatus == NavMeshPathStatus.PathInvalid) { Debug.Log("Path is invalid or stale, recalculating..."); // 可以立即触发一次重新寻路,或者等待下一次定时更新 agent.ResetPath(); agent.SetDestination(target.position); } // 计算剩余距离,用于判断是否到达 if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance) { if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f) { // 真正到达目的地 OnDestinationReached(); } } } } void OnDestinationReached() { // 处理到达目标后的逻辑 } }

6.3 避障与优先级管理

在多个AI同时寻路时,避免它们挤在一起。NavMeshAgentObstacle Avoidance属性是关键。

  • Quality:避障质量。设置为“High Quality”会进行更精确的预测和规避,但计算量更大。对于大量AI,使用“Low Quality”或“No Obstacle Avoidance”,然后通过其他逻辑(如分离力)处理碰撞可能是更好的选择。
  • Priority:优先级(0-99)。数值越低,优先级越高。高优先级的AI会迫使低优先级的AI让路。你可以为不同类型的AI(如英雄单位 vs 小兵)设置不同的优先级。

7. 常见问题、性能优化与调试技巧

在实际开发中,你会遇到各种奇怪的问题。这里记录一些典型坑点和解决方案。

7.1 NavMesh常见问题排查表

问题现象可能原因排查步骤与解决方案
AI卡在角落或物体边缘Agent Radius太大,或NavMesh在边缘处生成不准确。1. 减小NavMeshAgent的Radius。 2. 检查烘焙设置中的“Agent Radius”是否与代理设置一致。 3. 在编辑器Navigation窗口的“Object”标签页,检查卡住位置的物体,确保其“Navigation Area”设置正确,且参与了烘焙(如果是静态物体)。
SetDestination返回false,AI不移动目标点不在NavMesh上。1. 使用NavMesh.SamplePosition对目标点进行采样。 2. 检查目标点所在的区域是否在Agent的areaMask内。 3. 确保NavMesh已正确烘焙(运行时烘焙是否完成)。
动态障碍物无效,AI直接穿过去NavMeshObstacle的Carve未启用,或障碍物Layer未被NavMesh识别。1. 确认NavMeshObstacle组件勾选了“Carve”。 2. 检查障碍物是否在NavMeshObstacle的“Carve Only Stationary”状态下移动。 3. 确保障碍物的Layer在Navigation窗口的“Layers”标签页中,对应的“Default Area”不是“Not Walkable”(或者你期望的区域)。
运行时烘焙后,AI仍然不走新路NavMesh数据没有成功添加或更新。1. 确认NavMeshSurface.BuildNavMesh()或更新方法被成功调用且未报错。 2. 在Scene视图的“Navigation”显示模式下,查看新的NavMesh网格是否生成(蓝色区域)。 3. 检查新烘焙的区域是否与原有NavMesh连接。断开的部分需要手动处理连接或确保烘焙范围重叠。
性能下降,尤其在动态障碍物多时NavMeshObstacle的Carve更新开销大,或运行时烘焙太频繁。1. 为NavMeshObstacle增加“Move Threshold”。 2. 评估是否所有障碍物都需要Carve,能否用简单的碰撞体+避障逻辑替代。 3. 将运行时烘焙操作放在协程中异步进行,并限制频率(如每秒最多一次)。 4. 使用NavMeshBuilder.BuildNavMeshDataAsync进行局部增量更新,而非全量重建。

7.2 性能优化实战心得

  • 分帧处理:如果你有上百个需要更新目的地的AI,不要在同一帧调用所有SetDestination。可以分散到多帧完成。
  • LOD for AI:对于远离玩家或不在屏幕内的AI,可以降低其寻路频率,甚至暂停寻路,只做简单的预设移动。
  • 简化碰撞形状NavMeshObstacle和用于烘焙的Mesh尽量使用简单的碰撞体(Box, Sphere, Capsule),而不是复杂的Mesh Collider。Unity的NavMesh系统在处理简单几何体时效率高得多。
  • 合理使用代理半径:不要盲目使用过大的半径。较小的半径能生成更密集的NavMesh,让AI有更多路径选择,但也增加了寻路图的复杂度。需要根据游戏类型和AI大小权衡。

7.3 调试与可视化技巧

  • Scene视图导航叠加层:在Scene视图左上角,点击“Gizmos”下拉菜单,可以调整NavMesh、NavMeshAgent路径等的显示颜色和透明度。这是最直观的调试工具。
  • 绘制路径线:在AI的脚本中,使用Debug.DrawLineGizmos.DrawLineOnDrawGizmos中绘制agent.path.corners,可以清晰看到AI计算的路径。
  • 检查IsOnNavMesh:在让AI做任何移动操作前,用agent.isOnNavMesh检查它是否在有效导航网格上。如果不在,可能需要使用NavMesh.SamplePosition将其“放置”到最近的NavMesh点上(Warp功能有时不可靠)。
  • 使用NavMeshQuery进行高级查询:对于需要预先判断某点是否可达、或计算两点间路径成本的高级需求,可以研究NavMeshQuery类,它提供了比NavMeshAgent更底层的接口。

动态寻路系统的构建,是一个从静态到动态,从基础到高级,不断权衡性能与效果的过程。它没有唯一的正确答案,只有最适合你项目需求的组合方案。从NavMeshObstacle的实时雕刻,到NavMeshSurface的运行时烘焙,再到代理自身的避障与路径优化,每一层都为你的游戏世界注入更多活力与可能性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询