客户端寻路、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]

关键理解:

  1. MapBaker 不负责运行时寻路,它只把导航 Prefab 的 GUID、变换和 NavMeshLink 参数写入地图二进制。
  2. MapNavObjectFactory 在客户端实例化导航对象;导航对象加载失败时,Agent 虽然可能存在,但 isOnNavMesh 会一直为 false。
  3. NavMoveStrategy 不直接改 Transform.position。它让 Agent 计算 nextPosition/desiredVelocity,再把位移转换成 Rigidbody 速度;旋转由策略自己平滑处理。
  4. NavMoveSyncHelper 在发送前调用 NavMesh.CalculatePath,再按当前 path.corners 累加 MoveDistance 并计算 Duration。发送端没有检查 PathComplete,所以它是“按当前返回拐点估算”,不是严格保证完整可达的路径长度。
  5. 角色、怪物、传送和随机出生点都必须使用当前 Agent 的 areaMask。随意使用 NavMesh.AllAreas 会让英雄走到敌人专用区域,或把点采到另一层楼。

2. 源码入口与职责

层实际入口职责
几何查询Assets/Code/Game/OLogic/Common/NavMeshTool.csSamplePosition、Raycast、CalculatePath、路径长度
烘焙组件Assets/Plugins/RuntimeNavMeshBuilding/Extend/Scripts/NavMeshPrefabInstance.cs持有 NavMeshData,运行时 Add/Remove,可选跟随 Transform
烘焙 InspectorAssets/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.csAgent 生命周期、目标设置、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.cst_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 字段对烘焙的实际影响
LayerMaskCollectSources 只收集这些 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_NavMeshfollowTransformLayerMask结论
World01_Level00_Template01_AnulltrueAll Layers地图模板/烘焙入口,本身不持有最终数据
子对象 navmesh_World01_Level00_Template01_AAssets/Maps/Resources_moved/Maps/Props/NavMesh/navmesh_World01_Level00_Template01_A.prefabtrue268435968真正注册到 Unity NavMesh 的数据实例

268435968 = (1 << 9) | (1 << 28),项目 Layer 9 是 Floor,Layer 28 是 CharacterMoveBlock。这说明最终烘焙显式收集地面和角色移动阻挡,而不是所有场景 Mesh。

1-0-44 模板中的局部 NavMesh 烘焙面近景

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 面”,不是“从当前位置能不能走到”。所以目标点应至少按下面顺序判断:

  1. SamplePosition(target, searchHeight, agent.areaMask)。
  2. 需要完整可达性时调用 NavMesh.CalculatePath。
  3. 需要直线视线时调用 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 的顺序很重要:

  1. 清空完成标记。
  2. agent.Warp(targetTransform.position),把 Agent 内部位置与逻辑对象对齐。
  3. 启用 Agent,解除 Rigidbody 的运动冻结。
  4. 调用 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 并结束这次移动。上层应决定重试、换目标还是回退出生点。

普通面上,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。重新启用后要检查:

  1. 当前 Transform 是否仍在 NavMesh。
  2. 是否需要先 Warp 到采样点。
  3. 是否要恢复速度、stoppingDistance、避障参数。
  4. 是否应清掉旧路径再设置新目标。

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:

  1. 将 MoveData 整数化为 t_MoveData。
  2. 写入 SrcPos、TargetPos、moveSpeed、duration、faceDirection 等字段。
  3. 将策略、同步参数、状态和 ActorType 打进 ParamEx。
  4. 按 FROM_BEGIN_* 或 FROM_UPDATE 做增量压缩。
  5. 发送 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。当前检出值如下:

配置当前值含义
EnableDistanceChecktrue启用普通帧率距离失步检查
DeadReckoningValidDistance1 m模拟点与实体距离超过 1 米进入距离失步
DeadReckoningValidAngle15°Rotation Helper 使用的角度阈值
EnableDurationChecktrue启用持续时间检查
DeadReckoningValidDuration0.15 s普通帧率持续时间阈值
EnableLowFrameDeadReckoningtrue低帧率采用另一套阈值
TargetLowFrame20 FPS低于目标帧率时切换
低帧距离/角度/时长0.3 m / 10° / 0.1 s低帧配置更敏感
NeedForceTeleporttrue允许远端实体强制纠偏
ForceTeleportDistance5 m收到的源点与本地逻辑位置相差至少 5 米时纠偏
ForceNavMoveOnRemotetrue把远端 CharacterDirectionMove 改成 CharacterNavMove 表现
EnablePacketCachetrue启用远端包缓存
CachedBufferLength0 ms当前没有额外固定缓冲
MaxTimeOffset100 ms双端时间最大偏差配置
PublishInFixedUpdatetrue在 FixedUpdate 节奏发布
EnableBeHitProtecttrue启用受击位置保护
BeHitProtectDistance3 m与最后英雄位置差超过 3 米进入保护

不要只改 ScriptableObject 的默认字段值;运行时读的是这个 asset 的序列化值。调整阈值后应同时验证低帧设备、跨 Link、受击击退和重连后的第一包。

8.2 远端纠偏的实际顺序

远端 MoveBegin 应用时不是简单“拿目标点开始走”,而是:

  1. 解压 t_MoveData。
  2. 若策略不是 Rotation,且包内 SrcPos 与本地逻辑位置距离大于等于 5 米,先对齐到 SrcPos。
  3. 调用策略 Helper 的 OptimizeReceivedNetworkData;NavMove 只有本地重算得到完整路径时才按收到的 Duration 反推速度。
  4. 若策略是 CharacterNavMove,且实体当前不在 NavMesh 的 0.1 米采样范围内,再次对齐到 SrcPos。
  5. 启用受击保护时,以最后英雄位置和 3 米阈值决定是否进入保护。
  6. 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

  1. 打开 Assets/Maps/EditorScenes/<scene>.unity,确认 MapRoot、导航 Prefab 和 Link 都在正确模板下。
  2. 选中模板上的 NavMeshPrefabInstance,核对 LayerMask、Modifier/Volume、m_MinRegionArea 和 m_SettingId,再点击 Inspector 的 Bake;不要把场景全局 Bake 当成本项目最终产物。
  3. 对 Portal 父对象运行 GameObject/地图/检查Portal。PortalSetter 会把父 Link 设为 HeroWalkable(area 3),子 Link 设为 EnemyWalkable(area 4),并强制双向、自动更新。
  4. 运行地图导出,确认 Maps/Data/map-*.bytes、导航 Prefab GUID 和 AssetsMapPath.bytes 同批生成。
  5. 进入客户端后查看 __NavMesh__ 层级是否有实例,检查 Agent 的 isOnNavMesh。
  6. 分别测试普通面、跨 Link、不可达目标、上下层目标和传送后的首次移动。
  7. 再测试网络包中的 SrcPos/TargetPos/Duration,同时记录 CalculatePath.status;只有 PathComplete 才能把拐点长度当作完整路径长度。

10. 排错矩阵

现象优先检查常见原因
Agent 不在 NavMesh__NavMesh__、CfgAssetsPath、isOnNavMeshGUID 索引缺失、Prefab 未加载、地图版本不一致
目标点合法但走不到CalculatePath.status、areaMask、Link 两端只做了 SamplePosition,没有做完整路径检查
角色瞬移/抖动Warp、nextPosition、Rigidbody isKinematic直接改 Transform 或 Agent/逻辑坐标脱节
跨桥不动NavMeshLink 参数、AgentTypeId、areaLink 两端不重合、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 服务端寻路权威与移动同步模块 的改造章节。

上一篇:地图导出模块 · 下一篇:服务端寻路权威与移动同步