客户端寻路、NavMesh 资源与移动表现模块 private
客户端寻路、NavMesh 资源与移动表现模块
本篇只讲 Unity 客户端,并在章节中区分 Logic(目标、策略、规则)和 Rendering(NavMeshAgent、Rigidbody、远端表现)。完整跨端时序见 22 联机同步,Battle 校验见 21 服务端寻路权威与移动同步模块。
1. 先给结论
这套工程没有自研 A*。它把 Unity NavMesh 当作客户端的几何查询和局部运动执行器,把游戏逻辑层的 MoveData 当作移动意图:
flowchart LR
A[Editor 场景\nNavMeshPrefabInstance / NavMeshLink] --> B[MapBaker\n写入 map-*.bytes]
B --> C[MapProcessor\n创建 NavMesh MapEntity]
C --> D[MapNavAssetsSystem]
D --> E[MapNavObjectFactory\n加载 NavMesh Prefab / Portal]
E --> F[NavMeshAgent]
G[输入 / AI / 技能] --> H[MoveData]
H --> I[NavMoveStrategy]
I --> F
I --> J[SyncDesMoveController]
J --> K[StateSyncSystem\n路径长度 / Duration]
K --> L[CS_MoveBegin]
关键理解:
MapBaker不负责运行时寻路,它只把导航 Prefab 的 GUID、变换和NavMeshLink参数写入地图二进制。MapNavObjectFactory在客户端实例化导航对象;导航对象加载失败时,Agent 虽然可能存在,但isOnNavMesh会一直为false。NavMoveStrategy不直接改Transform.position。它让 Agent 计算nextPosition/desiredVelocity,再把位移转换成 Rigidbody 速度;旋转由策略自己平滑处理。NavMoveSyncHelper在发送前调用NavMesh.CalculatePath,再按当前path.corners累加MoveDistance并计算Duration。发送端没有检查PathComplete,所以它是“按当前返回拐点估算”,不是严格保证完整可达的路径长度。- 角色、怪物、传送和随机出生点都必须使用当前 Agent 的
areaMask。随意使用NavMesh.AllAreas会让英雄走到敌人专用区域,或把点采到另一层楼。
2. 源码入口与职责
| 层 | 实际入口 | 职责 |
|---|---|---|
| 几何查询 | Assets/Code/Game/OLogic/Common/NavMeshTool.cs | SamplePosition、Raycast、CalculatePath、路径长度 |
| 烘焙组件 | Assets/Plugins/RuntimeNavMeshBuilding/Extend/Scripts/NavMeshPrefabInstance.cs | 持有 NavMeshData,运行时 Add/Remove,可选跟随 Transform |
| 烘焙 Inspector | Assets/Plugins/RuntimeNavMeshBuilding/NavMeshComponents/Editor/NavMeshPrefabInstanceEditor.cs | 收集局部几何、Bake/Clear、把数据写入 Prefab sub-asset |
| 地图解析 | Assets/Code/Game/OServices/View/Map/MapProcessor.cs | 把 NavMeshElement/NavPortalElement 转成 MapEntity |
| 导航加载 | Assets/Code/Game/OGame/Map/Systems/MapNavAssetsSystem.cs | 监听 MapEntity,调用工厂加载导航对象 |
| 导航实例化 | Assets/Code/Game/OServices/View/Map/Navigation/MapNavObjectFactory.cs | 按 GUID 加载 NavMesh Prefab;按导出字段恢复 NavMeshLink |
| 移动执行 | Assets/Code/Game/OLogic/Actor/Move/NavMoveStrategy.cs | Agent 生命周期、目标设置、OffMeshLink、Rigidbody 同步 |
| 角色特化 | Assets/Code/Game/OLogic/Actor/Move/CharacterNavMoveStrategy.cs | 玩家移动期间关闭避障,结束后恢复优先级并 Warp |
| 控制器 | Assets/Code/Game/Server/Move/SyncDesMoveController.cs | 选择策略、处理目标变更、发布本地网络消息 |
| 网络状态 | Assets/Code/Game/Temp/StateSyncSystem.cs | 组包 CS_MoveBegin、接收远端移动、Dead Reckoning |
| 压缩协议 | Assets/Code/Game/Temp/NetworkDataOptimizer/IMoveDataOptimizer.cs | t_MoveData 的整数化、增量压缩、参数校验 |
3. 导航数据怎样从场景进入客户端
3.1 真正的烘焙入口:NavMeshPrefabInstance Inspector
本项目的主链路不是在一个完整场景里点 Unity 全局 Navigation Bake。地图由多个模板和局部资源拼装,如果只保存场景级 NavMeshData,模板复用、资源按需加载和运行时卸载都会很难处理。因此工程给导航模板挂 NavMeshPrefabInstance,在自定义 Inspector 中提供 Bake 和 Clear。
[CustomEditor(typeof(NavMeshPrefabInstance))]
class NavMeshPrefabInstanceEditor : Editor
{
// Inspector 最终调用 OnBake / OnClear
if (GUILayout.Button("Bake")) OnBake();
if (GUILayout.Button("Clear")) OnClear();
}
Bake 的真实处理顺序是:
flowchart TD
A[选中带 NavMeshPrefabInstance 的模板或实例] --> B[临时启用 CharacterMoveBlock 下的 ModifierVolume]
B --> C[临时启用 BridgeNavmeshComponents 对象]
C --> D[收集 NavMeshModifier]
D --> E[NavMeshBuilder.CollectSources\nRenderMeshes + LayerMask]
E --> F[追加 NavMeshModifierVolume]
F --> G[GetSettingsByID + minRegionArea]
G --> H[BuildNavMeshData]
H --> I{普通 Prefab?}
I -->|是| J[NavMeshData 写成原 Prefab 的 sub-asset]
I -->|实例或特殊目录| K[生成 navmesh_*.prefab\nNavMeshData 写为 sub-asset]
J --> L[恢复临时对象状态并 SaveAssets]
K --> L
关键参数不能只凭默认值:
| Inspector 字段 | 对烘焙的实际影响 |
|---|---|
LayerMask | CollectSources 只收集这些 Layer 上的 RenderMesh;漏 Layer 会直接形成导航空洞 |
IgnoreModifier | 为 false 时读取子节点 NavMeshModifier,支持覆盖 Area 或从构建中排除 |
IgnoreModifierVolume | 为 false 时追加子节点 NavMeshModifierVolume;代码没有再按 LayerMask 过滤 Volume |
m_MinRegionArea | 写入 NavMeshBuildSettings.minRegionArea,用于剔除小型孤岛;组件默认值为 10000,调整前必须用目标模板复测 |
m_SettingId | 传给 NavMesh.GetSettingsByID;随后代码又固定 agentTypeID = 0,不能想当然地认为它支持任意 AgentType |
followTransform | 运行时对象移动或旋转后重新挂载 NavMeshData;静态地图通常应关闭 |
烘焙前脚本会临时启用所有子级 NavMeshModifierVolume,注释明确点名 CharacterMoveBlock;也会启用 BridgeNavmeshComponents 所在对象,使阻挡体和桥面几何参与构建。烘焙结束或失败后再关闭它们。这也是“编辑器里看着关闭的阻挡或桥组件为什么仍影响烘焙”的原因。
3.2 NavMeshData 保存在哪里
普通 Prefab 直接通过 AssetDatabase.AddObjectToAsset(navMeshData, prefab) 把导航数据写成原 Prefab 的 sub-asset。实例模式以及以下特殊目录使用独立导航 Prefab:
Assets/Maps/Resources/Maps/Templates/
Assets/Maps/_Bridge/
Assets/Maps/_NavmeshTemplates/
编辑器脚本中的生成目录常量是:
Assets/Maps/Resources/Maps/Props/NavMesh/navmesh_<模板名>.prefab
但当前仓库实际保存目录已经迁移为:
Assets/Maps/Resources_moved/Maps/Props/NavMesh/navmesh_*.prefab
这两者必须区分:前者是脚本里的历史路径,后者是当前检出的资源位置。若重新执行旧 Inspector 烘焙,要先确认资源迁移或构建流程会把产物放到正确目录,否则可能重新生成一套 Resources 目录,造成 GUID 索引和发布资源不一致。实际 navmesh_*.prefab 是二进制序列化资产,内部同时包含 NavMeshPrefabInstance 和名为 Navmesh 的 NavMeshData sub-asset。
在正确连接到本项目 Unity MCP 后,对当前 Assets/Maps/EditorScenes/1-0-44.unity 做只读检查,能看到真实的两级结构:
| 场景对象 | m_NavMesh | followTransform | LayerMask | 结论 |
|---|---|---|---|---|
World01_Level00_Template01_A | null | true | All Layers | 地图模板/烘焙入口,本身不持有最终数据 |
子对象 navmesh_World01_Level00_Template01_A | Assets/Maps/Resources_moved/Maps/Props/NavMesh/navmesh_World01_Level00_Template01_A.prefab | true | 268435968 | 真正注册到 Unity NavMesh 的数据实例 |
268435968 = (1 << 9) | (1 << 28),项目 Layer 9 是 Floor,Layer 28 是 CharacterMoveBlock。这说明最终烘焙显式收集地面和角色移动阻挡,而不是所有场景 Mesh。

3.3 运行时 Add、跟随和 Remove
加载导航 Prefab 后,Unity 会启用其中的 NavMeshPrefabInstance:
void OnEnable()
{
AddInstance();
if (m_Instance.valid && m_FollowTransform)
AddTracking();
}
void AddInstance()
{
if (m_NavMesh != null)
m_Instance = NavMesh.AddNavMeshData(
m_NavMesh, transform.position, transform.rotation);
}
void OnDisable()
{
m_Instance.Remove();
RemoveTracking();
}
因此 Prefab 被实例化并不等于“存在一张全局导航图”,而是每个局部对象把自己的 NavMeshData 注册到 Unity 的全局查询系统。禁用或销毁对象时必须执行 Remove(),否则会留下不可见的旧导航面。
followTransform 打开后,实例会注册到 NavMesh.onPreUpdate。每次导航更新前检查位置或旋转;只要发生变化,就先 Remove 再 Add。它适合移动平台或动态拼块,但会产生逐帧遍历以及重挂导航数据的成本,静态模板不应无理由开启。
关卡卸载最终由 ResourceDestroyer 调用:
_navObjectFactory.DestroyAll();
MapNavObjectFactory.DestroyAll() 会删除 __NavMesh__ 的全部子对象,由组件生命周期触发 OnDisable -> NavMeshDataInstance.Remove(),完成导航资源卸载。
3.4 编辑器对象不是运行时对象
地图作者在 Assets/Maps/EditorScenes 放置导航 Prefab 和 NavMeshLink。MapBaker 遍历 MapRoot,遇到带 NavMeshPrefabInstance 的对象就写入:
if (obj.GetComponent<NavMeshPrefabInstance>())
return new NavMeshElement { Id = guid };
if (obj.GetComponent<NavMeshLink>())
return CreateNavPortalElement(obj.GetComponent<NavMeshLink>());
真正写入 .bytes 前,地图必须有可用三角化结果:
NavMeshTriangulation triangulation = NavMesh.CalculateTriangulation();
if (triangulation.vertices.Length == 0)
throw new Exception("NavMesh.CalculateTriangulation failed!");
这一步只证明当前编辑器场景有烘焙结果,并不会把所有三角形直接序列化进地图。运行时仍通过 GUID 找到导航 Prefab/资源。
3.5 MapProcessor 先建实体,不直接 Instantiate
客户端 MapProcessor 的两个处理器只保存位置、旋转、缩放和导航数据:
void ProcessNavMesh(IMapElement e, float4x4 matrix,
List<MapEntity> entities, List<MapEntity> templateProps)
{
var navMesh = (NavMeshElement)e;
var entity = _context.CreateEntity();
var (position, rotation, scale) = matrix.DecomposeTRS();
entity.AddPosition(position);
entity.AddRotation(rotation);
entity.AddScale(scale);
entity.AddNavMesh(navMesh.Id);
entities.Add(entity);
}
NavPortalElement 则保存 AgentTypeId、起止点、宽度、代价、双向标志和 area 数组。这样地图逻辑与 View 层解耦,资源加载只由 MapNavAssetsSystem 响应实体创建。
3.6 MapNavAssetsSystem 和工厂的真实链路
MapNavAssetsSystem 有两个 ReactiveSystem:
LoadNavMesh收集Position + Rotation + Scale + NavMesh,把 GUID 交给INavMeshObjectFactory。LoadNavPortal收集Position + Rotation + Scale + NavPortal,把完整NavPortalElement交给INavPortalObjectFactory。
MapNavObjectFactory 先通过 Database.CfgAssetsPath.GetAssetPath(id) 查资源路径,再用 AMF.AssetManager 加载场景资源并挂到 __NavMesh__ 根节点:
var propsPath = Database.CfgAssetsPath.GetAssetPath(id);
var prefab = AMF.AssetManager.Instance.GetAsset<GameObject>(
propsPath, AMF.ASSETTYPE.SCENE);
var instance = GameObject.Instantiate(prefab, position, rotation, _root);
instance.transform.localScale = scale;
Portal 不保存一个不可移植的 Unity Component,而是运行时加载 Portal Prefab,然后恢复导出字段:
link.agentTypeID = portal.AgentTypeId;
link.startPoint = portal.StartPoint;
link.endPoint = portal.EndPoint;
link.width = portal.Width;
link.costModifier = portal.CostModifier;
link.autoUpdate = portal.AutoUpdatePosition;
link.bidirectional = portal.Bidirectional;
link.area = area;
因此 CfgAssetsPath 缺失时,最先看到的不是“寻路算法报错”,而是导航 Prefab 查不到、__NavMesh__ 下没有对象、Agent 不在 NavMesh。
上图是正确 QMGame 工程中选中 navmesh_World01_Level00_Template01_A 后的 Scene View;蓝色区域是该局部 Prefab 实际携带的导航面,不是整张地图的一次性全局 Bake。
4. NavMeshTool:所有合法点判断的公共入口
4.1 SamplePosition 不是路径可达性
public static bool IsOnNavMesh(NavMeshAgent agent,
Vector3 pos, float height = 0.3f)
{
NavMeshHit hit;
return agent != null &&
NavMesh.SamplePosition(pos, out hit, height, agent.areaMask);
}
它回答的是“附近有没有同 area 的 NavMesh 面”,不是“从当前位置能不能走到”。所以目标点应至少按下面顺序判断:
SamplePosition(target, searchHeight, agent.areaMask)。- 需要完整可达性时调用
NavMesh.CalculatePath。 - 需要直线视线时调用
NavMesh.Raycast。
4.2 路径状态和拐点长度
public static bool IsWalkablePathComplete(
Vector3 source, Vector3 target, int areaMask)
{
var path = new NavMeshPath();
NavMesh.CalculatePath(source, target, areaMask, path);
return path.status == NavMeshPathStatus.PathComplete;
}
GetPathLength 将起点、path.corners 和目标点拼接后逐段累加。这个长度比 Vector3.Distance 更接近 Agent 实际要走的距离,尤其是墙角、桥、门和 NavMeshLink 场景。
4.3 边缘阻挡与落点高度
IsIntersectNavMeshEdge用NavMesh.Raycast检查路径是否撞到边缘,并把命中点加上agent.baseOffset。GetFarthestPoint用于取得前方还能走到的最远 NavMesh 点。GetGroundHeight用SamplePosition取地面高度,再加baseOffset,避免把逻辑位置写在地表以下。
不要把 SamplePosition 的搜索半径设置得过大。多层地图上,半径过大会采到楼上/楼下的面,结果看起来是“目标合法”,实际上路径跨层。
5. NavMoveStrategy 的逐帧行为
5.1 初始化和开始移动
初始化时策略明确关闭 Agent 自己的旋转控制:
_agent.updateRotation = false;
_agent.updatePosition = true;
Begin 的顺序很重要:
- 清空完成标记。
agent.Warp(targetTransform.position),把 Agent 内部位置与逻辑对象对齐。- 启用 Agent,解除 Rigidbody 的运动冻结。
- 调用
OnDestinationChange设置目标。
如果对象刚从对象池取出、刚被传送或刚切换地图,缺少 Warp 很容易出现 Transform、Agent、Rigidbody 三套坐标不一致。
5.2 设置目标
目标已经在 stoppingDistance 内时,不调用 SetDestination,而是把位移标记为完成,进入朝向收尾。否则必须先校验目标:
bool result = NavMeshTool.IsOnNavMesh(
_agent, moveData.TargetPosition);
if (result && !moveData.IsDead &&
_agent.isActiveAndEnabled)
_agent.SetDestination(moveData.TargetPosition);
_isTranslateFinish = !result;
这里的设计意味着:目标非法时不会强行把 Transform 拉到目标点,而是记录 warning 并结束这次移动。上层应决定重试、换目标还是回退出生点。
5.3 普通 NavMesh 与 OffMeshLink
普通面上,Rigidbody 速度跟随 agent.nextPosition:
_rigidbody.velocity =
(_agent.nextPosition - _targetTransform.position)
/ Time.fixedDeltaTime;
facingDir = _agent.desiredVelocity;
进入 OffMeshLink 后,策略不依赖 Agent 默认插值,而是手动向 endPos + baseOffset 移动,到达后调用 CompleteOffMeshLink()。这样跳台、桥和门洞可以继续使用项目自己的 Rigidbody/动画表现。
5.4 完成条件与冻结
当 !pathPending && remainingDistance < stoppingDistance 时:
- 停止 Agent。
- Rigidbody 速度、角速度归零。
- Rigidbody 设置为
isKinematic = true、FreezeAll。 - 位置完成,并按
FaceDirection继续转向(若有需要)。
不要在其他系统里直接写 Transform。正确的强制结束入口是 ForceFinish;它会 Warp 到 Agent 目标并根据最后一个路径拐点恢复朝向。
6. 玩家、怪物和特殊移动的差异
6.1 玩家 CharacterNavMoveStrategy
角色开始移动时暂时设置:
_agent.obstacleAvoidanceType = ObstacleAvoidanceType.NoObstacleAvoidance;
_agent.avoidancePriority = GlobalVariable.LOWEST_AVOIDANCE_PRIORITY;
结束时恢复原值并 Warp 到当前 Transform。这样玩家由输入/网络状态决定轨迹,避免多个 Agent 的避障结果让玩家与服务器时间线分叉。
6.2 怪物 AI
怪物的追击、逃跑、传送和技能落点大量调用 RandomTool.RandomPointOnNavMesh、RandomPointInAngelOnNavMesh 或 NavMesh.Raycast。写新 AI 时不要复制一个裸 Random.insideUnitCircle 点;必须保留:
int areaMask = actor.NavMeshAgent.areaMask;
if (!RandomTool.RandomPointOnNavMesh(center,
minRange, maxRange, areaMask, ref result))
return;
RandomTool 的采样常量是:
private static readonly int SAMPLE_POSITION_REPEAT_TIME = 10;
private static readonly float SAMPLE_POSITION_MAX_DISTANCE = 1.0f;
也就是最多随机 10 次,每次只接受候选点 1 米内能采到的 NavMesh。全部失败后返回 false,不会自动退回中心点;调用者必须处理失败,否则保留旧的 result 很容易造成怪物看似“随机失败后瞬移到上一次位置”。
当前 ReachableRandomPointOnNavMesh 还有一个需要特别留意的实现问题:
if (NavMesh.SamplePosition(randomPoint, out hit, 1.0f, areaMask))
{
// 当前代码检查 targetPosition,不是刚采样出的 hit.position
if (NavMeshTool.IsWalkablePathComplete(
sourcePosition, targetPosition, areaMask))
{
result = hit.position;
return true;
}
}
函数名表达的是“返回从 source 可达的随机点”,但实际验证的是 sourcePosition -> targetPosition。因此 targetPosition 可达并不能证明 hit.position 可达,尤其在墙角、孤岛和多层地图上。正确语义应检查 sourcePosition -> hit.position;在修代码前,调用方不能把该函数的 true 当作采样点一定 PathComplete。
6.3 传送/禁用 Agent 的对象
许多 Boss、Ghost 和特殊技能会临时 NavMeshAgent.enabled = false。重新启用后要检查:
- 当前 Transform 是否仍在 NavMesh。
- 是否需要先
Warp到采样点。 - 是否要恢复速度、
stoppingDistance、避障参数。 - 是否应清掉旧路径再设置新目标。
6.4 MoveController 的策略矩阵
MoveController.Init 实际注册了十一种策略,不是所有位移都会经过 NavMesh。排查“为什么角色穿墙”时必须先看当前 MoveStrategyType:
| 策略 | 核心用途 | 是否依赖 NavMesh | 是否有专用同步 Helper |
|---|---|---|---|
NavMove | 普通怪物按目标点寻路 | 是 | 是 |
CharacterNavMove | 角色版导航移动,调整避障并在结束时 Warp | 是 | 是,继承 NavMove Helper |
DirectionMove | 按方向/距离推进,常用于技能位移 | 不负责算路径,另做阻挡检查 | 是 |
CharacterDirectionMove | 玩家输入方向移动 | 使用 Agent/碰撞做角色特化 | 是 |
Rotation | 原地旋转 | 否 | 是,检测参数代表角度 |
SmoothDampRotation | 平滑旋转表现 | 否 | 未注册到 SyncDesMoveController 的 Helper 表 |
FreeMoveTo | 直接向目标插值移动 | 不做 NavMesh 路径规划 | 未注册 |
OrbitMove | 围绕目标做轨道移动 | 否 | 是 |
SpeedMove | 按速度曲线或阶段速度移动 | 不做 NavMesh 路径规划 | 未注册 |
ScaleMove | 缩放型表现移动 | 否 | 未注册 |
IgnoreObstacleDirecteMove | 继承 DirectionMove,但明确忽略障碍 | 否 | 没有独立 Helper |
这里最重要的边界是:MoveController 能在本地执行的策略多于 SyncDesMoveController 能进行 Dead Reckoning 的策略。新增策略时,必须同时确认本地执行器、MoveDataFactory、协议压缩、同步 Helper 和服务端策略白名单,而不是只往枚举里加一项。
7. MoveData、路径长度与客户端发包
SyncDesMoveController 为每种策略注册一个 IMoveSyncHelper。NavMove 的 ProcessMoveDataBeforeSend 会重新计算 Unity 路径:
NavMesh.CalculatePath(target.transform.position,
moveData.TargetPosition,
target.NavMeshAgent.areaMask, path);
moveData.MoveDistance = NavMeshTool.GetPathLength(
target.transform.position, moveData.TargetPosition, path);
moveData.Duration = moveData.MoveDistance / moveData.MoveSpeed;
这里有一个发送端和接收端不对称的边界:
ProcessMoveDataBeforeSend只判断path != null。由于字段本身始终是已创建的NavMeshPath,这个判断不能证明路径完整;Partial/Invalid 时仍可能按现有corners计算长度。OptimizeReceivedNetworkData才明确要求path.status == NavMeshPathStatus.PathComplete,完整路径成立时才用本地路径长度反推速度。
所以发送前的 MoveDistance/Duration 应理解为“当前路径结果的长度估算”。如果业务必须保证目标可达,应在发包前显式检查 PathComplete,失败时换目标或取消移动,不能依赖这段计算顺带完成校验。
随后 StateSyncSystem:
- 将
MoveData整数化为t_MoveData。 - 写入
SrcPos、TargetPos、moveSpeed、duration、faceDirection等字段。 - 将策略、同步参数、状态和 ActorType 打进
ParamEx。 - 按
FROM_BEGIN_*或FROM_UPDATE做增量压缩。 - 发送
emPacket_Battling_CS.MoveBegin。
目标变化时,DynamicHandleNewDestination 允许控制器在移动中重新调用 OnDestinationChange,并用 FROM_UPDATE 发一条新 MoveBegin。追击 AI 如果频繁改目标,应控制更新频率,否则会产生大量重算和网络包。
8. 远端移动与 Dead Reckoning
客户端收到 SC_MoveBegin 后,StateSyncSystem.RecordRemoteMoveData 会把包转成本地 MoveData。DeadReckoningCheck 的规则是:
FROM_BEGIN_NO_WAITTING/FROM_BEGIN_WAITTING_STATE:清掉该实体旧模拟缓存,允许新移动。FROM_UPDATE:按策略设置检查开关;若同一实体已有模拟项,则拒绝重复的旧更新。- 非法或死亡实体不应用移动。
NavMoveSyncHelper 以 TargetDirection * MoveSpeed * deltaTime 模拟位置,并同时检查:
- 距离偏差是否超过
detectRange。 - 模拟时间是否超过
detectDuration。 - 是否已经到达完成状态。
这只是客户端表现层的预测/失步检测;真正的服务器边界和失败回包见下一篇。
8.1 当前 StateSyncSettings 实际值
配置文件是 Assets/Resources_moved/GConfig/StateSyncSettings.asset。当前检出值如下:
| 配置 | 当前值 | 含义 |
|---|---|---|
EnableDistanceCheck | true | 启用普通帧率距离失步检查 |
DeadReckoningValidDistance | 1 m | 模拟点与实体距离超过 1 米进入距离失步 |
DeadReckoningValidAngle | 15° | Rotation Helper 使用的角度阈值 |
EnableDurationCheck | true | 启用持续时间检查 |
DeadReckoningValidDuration | 0.15 s | 普通帧率持续时间阈值 |
EnableLowFrameDeadReckoning | true | 低帧率采用另一套阈值 |
TargetLowFrame | 20 FPS | 低于目标帧率时切换 |
| 低帧距离/角度/时长 | 0.3 m / 10° / 0.1 s | 低帧配置更敏感 |
NeedForceTeleport | true | 允许远端实体强制纠偏 |
ForceTeleportDistance | 5 m | 收到的源点与本地逻辑位置相差至少 5 米时纠偏 |
ForceNavMoveOnRemote | true | 把远端 CharacterDirectionMove 改成 CharacterNavMove 表现 |
EnablePacketCache | true | 启用远端包缓存 |
CachedBufferLength | 0 ms | 当前没有额外固定缓冲 |
MaxTimeOffset | 100 ms | 双端时间最大偏差配置 |
PublishInFixedUpdate | true | 在 FixedUpdate 节奏发布 |
EnableBeHitProtect | true | 启用受击位置保护 |
BeHitProtectDistance | 3 m | 与最后英雄位置差超过 3 米进入保护 |
不要只改 ScriptableObject 的默认字段值;运行时读的是这个 asset 的序列化值。调整阈值后应同时验证低帧设备、跨 Link、受击击退和重连后的第一包。
8.2 远端纠偏的实际顺序
远端 MoveBegin 应用时不是简单“拿目标点开始走”,而是:
- 解压
t_MoveData。 - 若策略不是 Rotation,且包内
SrcPos与本地逻辑位置距离大于等于 5 米,先对齐到SrcPos。 - 调用策略 Helper 的
OptimizeReceivedNetworkData;NavMove 只有本地重算得到完整路径时才按收到的 Duration 反推速度。 - 若策略是
CharacterNavMove,且实体当前不在 NavMesh 的 0.1 米采样范围内,再次对齐到SrcPos。 - 启用受击保护时,以最后英雄位置和 3 米阈值决定是否进入保护。
- FromBegin 会结束旧策略、切换策略并 Begin;FromUpdate 只更新移动速度和目标点。
这套纠偏只作用于非本地、仍存活的实体。它解决的是远端表现发散,不会替服务端验证目标点合法性。
8.3 PacketCache 为什么会让远端加速
EnemyPacketCache 根据包队列跨度或位置偏移计算时间差,再选择“额外增加”的速度倍率:
| 时间差 | 额外倍率 | 最终速度约为 |
|---|---|---|
> 200 ms | +300% | 4.0x |
> 100 ms | +200% | 3.0x |
> 50 ms | +50% | 1.5x |
<= 50 ms | +0% | 1.0x |
代码最终执行 moveData.MoveSpeed *= (1 + accelerationRatio),所以配置里的 3 表示额外加 300%,不是最终 3 倍。看到远端角色短暂冲刺时,应先查看 PacketCache 的 TimeOffset/AccelerationRatio,不要直接归因于 NavMeshAgent。
9. 地图作者可执行的导航 SOP
- 打开
Assets/Maps/EditorScenes/<scene>.unity,确认 MapRoot、导航 Prefab 和 Link 都在正确模板下。 - 选中模板上的
NavMeshPrefabInstance,核对 LayerMask、Modifier/Volume、m_MinRegionArea和m_SettingId,再点击 Inspector 的Bake;不要把场景全局 Bake 当成本项目最终产物。 - 对 Portal 父对象运行
GameObject/地图/检查Portal。PortalSetter会把父 Link 设为 HeroWalkable(area 3),子 Link 设为 EnemyWalkable(area 4),并强制双向、自动更新。 - 运行地图导出,确认
Maps/Data/map-*.bytes、导航 Prefab GUID 和AssetsMapPath.bytes同批生成。 - 进入客户端后查看
__NavMesh__层级是否有实例,检查 Agent 的isOnNavMesh。 - 分别测试普通面、跨 Link、不可达目标、上下层目标和传送后的首次移动。
- 再测试网络包中的
SrcPos/TargetPos/Duration,同时记录CalculatePath.status;只有PathComplete才能把拐点长度当作完整路径长度。
10. 排错矩阵
| 现象 | 优先检查 | 常见原因 |
|---|---|---|
| Agent 不在 NavMesh | __NavMesh__、CfgAssetsPath、isOnNavMesh | GUID 索引缺失、Prefab 未加载、地图版本不一致 |
| 目标点合法但走不到 | CalculatePath.status、areaMask、Link 两端 | 只做了 SamplePosition,没有做完整路径检查 |
| 角色瞬移/抖动 | Warp、nextPosition、Rigidbody isKinematic | 直接改 Transform 或 Agent/逻辑坐标脱节 |
| 跨桥不动 | NavMeshLink 参数、AgentTypeId、area | Link 两端不重合、area 不在 Agent mask、方向错误 |
| 玩家和远端位置逐渐分叉 | Duration、路径版本、Dead Reckoning 参数 | 一端按折线路程,另一端按直线距离;或模板/地图不是同批导出 |
| 怪物出生在墙里 | RandomPointOnNavMesh、半径和 areaMask | 随机点未采样 NavMesh,或采样半径过大跨层 |
| “可达随机点”偶尔仍不可达 | ReachableRandomPointOnNavMesh 的校验目标 | 当前实现检查原 targetPosition,不是采样后的 hit.position |
| 切换关卡后仍有旧导航面 | ResourceDestroyer、__NavMesh__ | 导航根节点没有 Destroy,组件未触发 OnDisable/Remove |
| 远端角色短暂冲刺 | PacketCache TimeOffset 和 AccelerationRatio | 客户端为追平积压包主动提高移动速度 |
11. 修改建议
- 新增 Agent 类型时,先同步 Unity Navigation 设置、Portal 的
AgentTypeId、area 编号和客户端 Prefab,不要只改一个枚举。 - 调整
NavMeshAgent.radius/height/stoppingDistance时,同时复测SamplePosition、近战攻击范围、掉落点和路径完成条件。 - 如果要让服务端真正验证路径,不能只在 Battle 增加一个距离判断;需要服务端可用的同版本导航数据和确定性的路径/区域校验,见 21 服务端寻路权威与移动同步模块 的改造章节。