ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity NavMesh寻路与LineRenderer路径可视化实战指南

Unity NavMesh寻路与LineRenderer路径可视化实战指南

1. 项目缘起与整体设计思路

Unity 的 Navigation 寻路系统几乎是每个做 3D 或 2D 伪 3D 项目都绕不开的一块。不管是做 RPG 的点击移动、塔防的怪物行进、还是解谜游戏里 NPC 的巡逻路线,只要涉及“让角色自己走到某个点”,NavMesh 就是最省事的那条路。但真正上手之后你会发现一个很现实的问题:角色确实会走,可玩家根本不知道它要往哪走。尤其是策略类或者解谜类项目,玩家需要预判角色的行进路线,这时候光有一个闷头往前冲的角色是不够的,你得把路径“画”出来。

这个项目的核心目标就两件事:第一,用 Unity 自带的 Navigation 系统完成一套可用的寻路逻辑;第二,把计算出来的路径用可视化的方式画成一条指引线,让玩家一眼就能看清从起点到终点的完整走向。听起来简单,但中间涉及的知识点其实不少——NavMesh 的烘焙参数、NavMeshAgent 的移动控制、路径点的获取方式、LineRenderer 的绘制技巧,还有性能上的取舍。我见过不少项目在这块翻车,要么线画得歪歪扭扭,要么路径点采样太密导致卡顿,要么指引线和实际行走轨迹对不上。

先说说为什么选 Unity 自带的 Navigation 而不是自己写 A*。自己写 A* 当然可以,网上一搜一大把,但问题是你要处理网格生成、障碍物剔除、路径平滑、动态避障这一整套东西,工作量远超想象。Unity 的 NavMesh 把这些都封装好了,烘焙一次就能用,Agent 自带避障和路径重算,对于绝大多数中小型项目来说完全够用。而且它的 API 很干净,CalculatePath直接给你一串路径点,拿来画线正合适。

那指引线为什么用 LineRenderer 而不是 GL 或者 UI 画线?GL 属于底层绘制,写起来麻烦且不容易和场景物体交互;UI 画线需要把世界坐标转屏幕坐标,遇到摄像机旋转就会出问题。LineRenderer 是 Unity 专门为“在 3D 空间画线段”设计的组件,设置好材质和宽度就能用,性能也不错,路径点数量在几百以内基本无感。所以这套组合——NavMesh 负责算路,LineRenderer 负责画路——是性价比最高的方案。

这个内容适合谁看?如果你已经会一点 Unity 基础操作,知道什么是 GameObject、Component、Prefab,但还没怎么碰过 Navigation 系统,那这篇就是为你写的。如果你已经用过 NavMesh 但不知道怎么把路径可视化,同样能在这里找到可直接抄的代码。我会把每一步的参数含义、为什么这么设、踩过的坑都讲清楚,保证你跟着做一遍就能跑起来。

2. 核心细节解析与实操要点

2.1 NavMesh 烘焙的关键参数怎么调

Navigation 系统的第一步永远是烘焙 NavMesh。打开 Window > AI > Navigation,在 Bake 面板里你会看到一堆参数,很多人直接点 Bake 就完事了,结果后面 Agent 走起来各种奇怪。这里挑几个最关键的讲。

Agent Radius决定角色能通过的缝隙宽度。默认 0.5,意思是角色被当成一个半径 0.5 的圆柱体。如果你的角色实际半径是 0.3,那就改成 0.3,否则明明能过的门它过不去。Agent Height是角色高度,影响头顶能不能钻过去。Max Slope是最大可行走坡度,默认 45 度,超过这个角度的斜坡会被判定为不可行走。Step Height是台阶高度,角色能直接迈上去的最大高度差,做楼梯的时候这个值特别重要,设小了角色会在楼梯前卡住。

还有一个容易被忽略的是Drop Height和Jump Distance,这两个是给“跳下”和“跳远”用的,普通项目用不到可以不管。烘焙完成后场景里会出现蓝色的可行走区域,如果发现某些该走的地方没变蓝,多半是 Agent Radius 设大了或者 Max Slope 设小了。

注意:每次修改场景里的静态障碍物之后都要重新 Bake,否则 NavMesh 还是旧的,Agent 会撞墙。动态障碍物不要烘焙进去,用 NavMeshObstacle 组件处理。

2.2 NavMeshAgent 的移动控制要点

Agent 挂上之后,最简单的用法就是agent.SetDestination(target.position),然后它自己就过去了。但实际项目里你往往需要更精细的控制。比如agent.speed控制移动速度,agent.angularSpeed控制转向速度,agent.acceleration控制加速度。这几个值配合不好,角色会像喝醉一样左右摇摆。

agent.stoppingDistance是停止距离,设成 0.5 的话角色会在距离目标 0.5 米的地方停下,适合做“走到 NPC 面前对话”的场景。agent.autoBraking打开后会自动减速,关闭则匀速冲到终点。还有一个agent.isStopped,设为 true 可以立刻暂停移动,做剧情演出的时候很有用。

真正和画线相关的是agent.path.corners这个属性,它是一个 Vector3 数组,存的是当前路径的所有拐点。但注意,这个属性只有在 Agent 有激活路径时才有值,而且它是只读的。如果你想在设置目标之前就预判路径,得用NavMesh.CalculatePath手动算。

2.3 LineRenderer 画线的参数配置

LineRenderer 的核心参数有这几个:Position Count是点的数量,Width是线宽,Material是材质,Color是颜色。画指引线的时候,我一般会创建一个空物体挂 LineRenderer,然后动态设置 positionCount 和每个点的位置。

材质方面,默认的 Sprites/Default 就能用,但如果你想要发光效果,可以用 Particles/Standard Unlit 配合自发光颜色。线宽建议设成 0.05 到 0.1 之间,太粗会挡住角色,太细看不清。还有一个Use World Space选项,画世界空间的线一定要勾上,否则线会跟着物体旋转。

提示:LineRenderer 默认使用世界坐标,设置useWorldSpace = true之后,SetPosition传入的就是世界坐标,不用做任何转换。

2.4 路径点采样的密度控制

agent.path.corners返回的是拐点,不是密集的路径点。如果你直接拿拐点画线,画出来的是折线,转角处会很生硬。想要平滑的曲线,就得自己插值。但插值太密又会增加顶点数,影响性能。我的经验是每两个拐点之间插 5 到 10 个点,具体看拐点之间的距离,距离越远插得越多。

这里有个计算公式可以参考:插值点数 = Mathf.CeilToInt(两点距离 / 0.2f),意思是每 0.2 米插一个点。这样画出来的线既平滑又不会太吃性能。如果路径特别长,比如超过 100 米,可以适当放宽到每 0.5 米一个点。

3. 完整实操流程与核心代码实现

3.1 场景搭建与 NavMesh 烘焙

先建一个简单的测试场景。放一个 Plane 当地面,缩放设成 (5, 1, 5),这样地面够大。然后放几个 Cube 当障碍物,随便摆几个位置。再放一个 Capsule 当玩家角色,给它挂上 NavMeshAgent 组件。最后放一个 Sphere 当目标点,给它换个显眼的颜色。

选中地面和所有障碍物,在 Inspector 右上角勾上 Static,然后打开 Navigation 面板点 Bake。烘焙完成后地面会变成蓝色,障碍物周围会有镂空。如果蓝色区域不对,回去调 Agent Radius 重新烘。

3.2 基础寻路脚本

新建一个脚本叫NavigationController,挂到玩家角色上。核心逻辑就是检测鼠标点击,把点击位置转成世界坐标,然后设置给 Agent。

using UnityEngine; using UnityEngine.AI; public class NavigationController : MonoBehaviour { private NavMeshAgent agent; private LineRenderer lineRenderer; private Vector3[] pathCorners; void Start() { agent = GetComponent<NavMeshAgent>(); lineRenderer = GetComponent<LineRenderer>(); lineRenderer.useWorldSpace = true; lineRenderer.startWidth = 0.08f; lineRenderer.endWidth = 0.08f; lineRenderer.positionCount = 0; } void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { agent.SetDestination(hit.point); DrawPath(hit.point); } } } void DrawPath(Vector3 target) { NavMeshPath path = new NavMeshPath(); if (NavMesh.CalculatePath(transform.position, target, NavMesh.AllAreas, path)) { pathCorners = path.corners; DrawLineWithCorners(pathCorners); } } void DrawLineWithCorners(Vector3[] corners) { if (corners.Length < 2) { lineRenderer.positionCount = 0; return; } System.Collections.Generic.List<Vector3> points = new System.Collections.Generic.List<Vector3>(); for (int i = 0; i < corners.Length - 1; i++) { Vector3 start = corners[i]; Vector3 end = corners[i + 1]; float distance = Vector3.Distance(start, end); int segments = Mathf.CeilToInt(distance / 0.2f); for (int j = 0; j <= segments; j++) { float t = (float)j / segments; points.Add(Vector3.Lerp(start, end, t)); } } lineRenderer.positionCount = points.Count; lineRenderer.SetPositions(points.ToArray()); } }

这段代码的逻辑很直白:鼠标点击发射射线,命中地面后设置目标并计算路径,然后把路径拐点之间插值成密集点,最后交给 LineRenderer 绘制。NavMesh.CalculatePath的第三个参数NavMesh.AllAreas表示在所有可行走区域上计算,如果你有不同代价的区域,可以传特定的 area mask。

3.3 让指引线更美观的几个技巧

上面画出来的线是纯色的,看起来比较朴素。想要好看一点,可以给 LineRenderer 加个渐变材质,或者用colorGradient属性设置起点和终点的颜色。比如起点用青色,终点用黄色,视觉上就有方向感。

还有一个技巧是让线稍微抬高一点,避免和地面重叠产生 Z-fighting。在设置每个点的时候给 y 坐标加 0.05 就行。另外,如果路径很长,可以在线上加一些流动的箭头纹理,用材质偏移做动画,效果会好很多。

// 在 DrawLineWithCorners 里给每个点加高度偏移 points.Add(Vector3.Lerp(start, end, t) + Vector3.up * 0.05f);

3.4 实时更新路径线

上面的代码只在点击时画一次线,如果角色走到一半你改了目标,线不会更新。想要实时更新,可以在 Update 里检测 Agent 的剩余距离,或者监听agent.path的变化。更简单的做法是每帧都重新计算路径并重绘,但这样性能开销会大一些。

折中方案是只在目标变化时重绘,或者每隔几帧重绘一次。我一般会在 Agent 的SetDestination之后立刻重绘,然后在 Agent 到达终点时清空线。如果项目里有动态障碍物,还需要在障碍物移动后重新计算路径。

void Update() { if (agent.hasPath && agent.remainingDistance < 0.1f) { lineRenderer.positionCount = 0; } }

4. 常见问题与排查技巧实录

4.1 路径线画不出来或者画歪了

最常见的原因是NavMesh.CalculatePath返回了 false,说明起点或终点不在 NavMesh 上。检查一下角色和目标点的位置,确保它们都在蓝色区域内。如果目标点在障碍物内部,也会算不出路径。还有一种情况是lineRenderer.positionCount设了但没调SetPositions,或者点的数量对不上。

画歪了通常是坐标系搞混了。CalculatePath返回的是世界坐标,LineRenderer如果没勾useWorldSpace,就会把世界坐标当本地坐标用,线自然就歪了。另外如果 LineRenderer 挂在角色身上,角色移动时线会跟着动,这时候要么把 LineRenderer 独立出来,要么每帧重新设置位置。

4.2 Agent 走到一半卡住或者绕远路

卡住的原因可能是 NavMesh 烘焙时 Agent Radius 设得比实际大,导致某些窄道被判定为不可通行。也可能是stoppingDistance设得太大,角色在距离目标还有一段距离时就停了。绕远路一般是 NavMesh 的连通性问题,检查一下有没有断开的区域,或者障碍物把路完全堵死了。

还有一个隐蔽的坑是agent.autoTraverseOffMeshLink,如果你用了 OffMeshLink 做跳跃或传送,这个选项关掉的话 Agent 会在链接处停下。默认是开的,但如果你手动改过就要注意。

4.3 性能问题:路径点太多导致卡顿

路径点数量直接决定 LineRenderer 的顶点数。如果场景很大,路径拐点很多,插值后又翻了好几倍,顶点数可能上千。虽然 LineRenderer 性能不错,但上千顶点每帧更新还是会掉帧。解决办法是降低插值密度,或者只在路径变化时更新,不要每帧都重算。

另一个优化点是限制路径的最大长度。如果目标点特别远,可以分段计算,只画玩家附近的一段。或者用 LOD 思路,距离远的时候用低密度采样,距离近的时候用高密度。

4.4 常见问题速查表

问题现象可能原因解决方法
线画不出来起点或终点不在 NavMesh 上检查位置,确保在蓝色区域内
线歪了LineRenderer 没勾 useWorldSpace勾上 useWorldSpace
线跟着角色动LineRenderer 挂在角色身上独立出来或每帧重设位置
Agent 卡住Agent Radius 太大或 stoppingDistance 太大调小参数重新烘焙
绕远路NavMesh 连通性问题检查障碍物和烘焙区域
卡顿路径点太多降低插值密度或减少更新频率
线闪烁Z-fighting给点加 y 轴偏移
路径不更新只在点击时计算在目标变化时重算

提示:排查问题时可以先把 LineRenderer 的材质换成亮红色,线宽调大,这样一眼就能看出线到底画没画出来。

4.5 几个我踩过的坑

第一个坑是NavMesh.CalculatePath的返回值。它返回的是 bool,但即使返回 true,path.corners 也可能只有一个点(起点和终点重合的情况)。这时候画线会报错,所以一定要判断 corners.Length >= 2。

第二个坑是 Agent 的path属性和CalculatePath算出来的路径可能不一样。Agent 的 path 是它当前实际走的路径,会受避障影响;CalculatePath是理论路径,不考虑动态障碍。如果你想要指引线和实际行走完全一致,得用agent.path.corners而不是自己算。

第三个坑是 LineRenderer 的positionCount设置后必须紧接着设置位置,否则会有一帧显示错误。我一般会先算好所有点存到数组里,再一次性设置 positionCount 和 SetPositions。

第四个坑是移动端上Input.GetMouseButtonDown不生效,得换成Input.touchCount判断。这个不算 Navigation 的问题,但做跨平台项目时很容易忘。

5. 进阶扩展与个人经验分享

5.1 用贝塞尔曲线让指引线更顺滑

线性插值画出来的线在拐点处还是会有折角。想要完全平滑,可以用贝塞尔曲线。具体做法是把每三个拐点作为一组,中间点作为控制点,生成二次贝塞尔曲线。这样画出来的线在拐弯处是圆弧过渡,视觉效果提升明显。

Vector3 QuadraticBezier(Vector3 p0, Vector3 p1, Vector3 p2, float t) { float u = 1 - t; return u * u * p0 + 2 * u * t * p1 + t * t * p2; }

不过贝塞尔曲线会增加计算量,路径点少的时候用效果很好,路径点多了反而没必要。

5.2 给指引线加上动态效果

静态的线看久了会腻,加一点动态效果会更有游戏感。最简单的做法是让线的颜色随时间渐变,或者让材质纹理滚动。用lineRenderer.material.mainTextureOffset每帧累加一个值,就能做出流动的效果。还可以在终点加一个脉冲光圈,提示玩家目标位置。

5.3 多角色路径线的管理

如果场景里有多个角色同时寻路,每个角色都画一条线会显得很乱。我的做法是只画当前选中角色的线,或者用不同颜色区分。还可以给线加一个淡出效果,角色走过之后线逐渐消失,这样既能看到路径又不会太杂乱。

管理多条线的时候,建议用一个对象池来复用 LineRenderer,避免频繁创建销毁。每个 LineRenderer 用完之后重置 positionCount 为 0,放回池子里下次再用。

5.4 个人体会

这套方案我在好几个项目里都用过,从小的解谜 Demo 到中等规模的策略游戏,稳定性没问题。最关键的一点是不要把 Navigation 和画线耦合得太紧,画线只是一个表现层的东西,寻路逻辑该独立就独立。这样以后想换画线方式(比如换成贴花或者粒子),不用动寻路代码。

另外,NavMesh 烘焙是个细活,场景改一次就要重烘一次,建议在项目早期就把场景布局定下来,后期频繁改地形会很痛苦。如果地形是程序化生成的,那就得用 NavMeshBuilder 在运行时动态烘焙,那是另一套东西了,复杂度会高不少。

最后分享一个小技巧:在 Scene 视图里用NavMesh.CalculatePath配合 Gizmos 画线,可以在不运行游戏的情况下预览路径。写个 Editor 脚本,在 OnDrawGizmos 里算路径并画出来,调场景的时候特别方便。这个技巧帮我省了很多反复运行的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进