主题
3D 单位移动控制器与 Locomotion
本文档说明 SCE 3D 场景归一化、GameGraph 物理角色 motor、上层 UnitMovementController、以及客户端 locomotion 状态机如何串成一个可用闭环。
创建 3D 项目/场景、选择 SpaceModel、生成 3D 体素/导航/碰撞产物的编辑器流程,见 3D 场景创建、SpaceModel 与场景产物。
当前状态判断
这套能力已经可以作为 3D 单位控制器的第一版公开入口使用,适合角色、怪物、玩家主控单位在 3D 场景中做寻路、物理角色移动、跳跃、空中追加跳跃和移动动画状态机。
需要同时说明边界:
| 能力 | 状态 | 说明 |
|---|---|---|
| 3D 场景空间模型 | 可用 | GameDataScene.SpaceModel 区分 Surface2D 与 Volumetric3D。物理角色和多层 3D 导航应使用 Volumetric3D。 |
| 3D 场景产物 | 可用 | 修改 3D 地形、静态阻挡或导航内容后,需要重新生成 3D 场景产物。 |
| 3D 寻路移动 | 可用 | VolumetricNavigation 保留单位寻路语义,不使用物理 capsule motor。 |
| 物理角色移动 | 可用 | PhysicalCharacter 使用 KinematicCharacterMotor 胶囊角色,由 node 驱动 Unit transform。 |
| 跳跃/空中跳 | 可用 | TryJumpUnit、SetUnitExtraAirJumpCount、JumpLaunchSpeed、ExtraAirJumps 已有高层入口。 |
| Locomotion 动画 | 可用 | GameDataUnitLocomotionAnimationProfile 驱动 idle / move / jump / rise / fall / land 状态机。 |
| 角色推动动态刚体 | 无内建配置 | KCC 当前没有角色质量或自动推力语义;框架可能在后续评估基于接触的有限冲量,但尚未确定 API、行为或交付时间。 |
Rigidbody 单位模式 | 保留项 | UnitMovementMode.Rigidbody 还不是公开可用的单位控制器模式。 |
Scripted 单位模式 | 保留项 | 需要显式脚本运动 owner,目前不要当作 SDK 推荐入口。 |
架构链路
text
GameDataScene.SpaceModel
-> SCE Scene Load / 3D nav / physics scene products
-> GameDataUnit.MovementController
-> UnitMovementController
-> UnitNodeBinding(role: PhysicalCharacter, mode: NodeDriven)
-> KinematicCharacterMotor
-> UnitMovementSignal
-> GameDataUnitLocomotionAnimationProfile
-> 客户端模型 locomotion 状态机核心原则是:玩法仍以 Unit 为主,物理角色只接管连续位移。生命值、技能、AI、目标过滤、视野、玩家主控等仍走 Unit 系统;当进入 PhysicalCharacter 模式时,底层由绑定的 GameGraph node 和 KinematicCharacterMotor 写回 Unit 坐标。
3D 场景前置条件
本节只列出 Unit 控制器需要的场景前置条件。编辑器里的完整创建与生成流程见 3D 场景创建、SpaceModel 与场景产物。
SpaceModel
GameDataScene.SpaceModel 决定场景移动语义:
| SpaceModel | 语义 | 适用场景 |
|---|---|---|
Surface2D | 2.5D 单层可走面 | 传统俯视角、地形有高度但同一 XY 只有一层可走面。 |
Volumetric3D | 真 3D 空间模型 | 多层地表、桥洞、高低平台、物理角色、3D 导航。 |
物理角色模式只应在 Volumetric3D 场景中启用。若在 Surface2D 场景调用 ActivateUnitPhysicalCharacterMovement,控制器会拒绝切换并记录失败原因。
3D 场景产物
3D 场景依赖导航、体素、静态碰撞等生成产物。修改以下内容后,需要在编辑器中重新生成 3D 场景产物:
- 3D 地形高度或尺寸
- 静态阻挡、可走面、碰撞角色
- Nav3D 相关内容
- 会影响物理角色落地、台阶或坡度判断的静态场景内容
调试时可以打开 DebugOverlayMode.Nav3DMesh、Nav3DVoxels、PhysicsWorld 或 MotorProbe 检查导航网格、体素、物理世界和 character motor。需要逐帧比较着地、速度与碰撞命中时,通过 Runtime MCP 用 debug.overlay 订阅 MotorProbe,再用 motor.snapshot 读取服务端物理帧序列;完整参数与状态语义见Runtime MCP 调试指南。
推荐数据配置
优先通过 GameDataUnit.MovementController 声明单位移动控制器。GameSparkCore 已提供通用 3D 控制器预设:
csharp
// gamesparkcore.ScopeData 通常通过 csproj 全局别名为 GSC。
_ = new GameDataUnit(MyLinks.Hero)
{
UpdateFlags = new UpdateFlags
{
Walkable = true,
Turnable = true,
AllowMover = true,
},
MovementController = GSC.GameDataUnitMovementController.Generic3DMovementController,
Properties = new UnitPropertyInitialData
{
{ GSC.GameDataUnitProperty.MoveSpeed, 300 },
{ GSC.GameDataUnitProperty.JumpLaunchSpeed, 650 },
{ GSC.GameDataUnitProperty.ExtraAirJumps, 1 },
},
};如果需要自定义胶囊尺寸、坡度、台阶或碰撞掩码,可以创建自己的 GameDataUnitMovementController:
csharp
public static readonly GameLink<GameDataUnitMovementController, GameDataUnitMovementController> HeroMovement =
new("HeroMovement"u8);
_ = new GameDataUnitMovementController(HeroMovement)
{
InitialMode = UnitMovementMode.VolumetricNavigation,
ApplyInitialMode = true,
ExtraAirJumpsProperty = GSC.GameDataUnitProperty.ExtraAirJumps,
PhysicalCharacter = new UnitPhysicalCharacterMovementSettings
{
EnsureUnitNodeBinding = true,
EnsureStaticMeshColliders = true,
EnsureTerrainCollider = false,
UseCharacterCollisionRoles = false,
Radius = 40f,
Height = 180f,
StepHeight = 45f,
MaxSlopeDegrees = 45f,
CollisionLayer = 1,
CollisionMask = uint.MaxValue,
},
};
_ = new GameDataUnit(MyLinks.Hero)
{
MovementController = HeroMovement,
UpdateFlags = new UpdateFlags { Walkable = true, Turnable = true, AllowMover = true },
};InitialMode 推荐先用 VolumetricNavigation,在需要跳跃或物理角色手感时由服务器切换到 PhysicalCharacter。如果项目明确从出生开始就是物理角色,也可以设为 PhysicalCharacter,但场景必须是 Volumetric3D 且 3D 场景产物完整。
运行时控制
移动模式切换、跳跃、空中跳次数修改都必须由服务端权威执行。客户端可以读状态、发请求、做 UI,但不要直接切换模式。
csharp
#if SERVER
using TriggerEncapsulation;
var unit = player.MainUnit;
if (unit == null)
{
return;
}
// 切到物理角色模式。若 GameDataUnit.MovementController 已配置,会复用其中的物理角色参数。
var activated = unit.ActivateUnitPhysicalCharacterMovement();
if (!activated)
{
var controller = unit.GetUnitMovementControllerInfo();
Game.Logger.LogWarning("切换物理角色失败: {Reason}", controller?.LastTransitionFailureReason);
}
// 请求跳跃。launchSpeed 为空时使用 JumpLaunchSpeed 属性或默认值。
var jumped = unit.TryJumpUnit();
if (!jumped)
{
var controller = unit.GetUnitMovementControllerInfo();
Game.Logger.LogWarning("跳跃失败: {Reason}", controller?.LastJumpFailureReason);
}
// 允许 1 次额外空中跳跃。
unit.SetUnitExtraAirJumpCount(1);
// 切回当前场景适用的寻路模式:Surface2D 场景回 SurfaceNavigation,Volumetric3D 场景回 VolumetricNavigation。
unit.ReturnUnitToSceneNavigation();
#endif常用查询:
csharp
using TriggerEncapsulation;
var mode = unit.GetUnitMovementMode();
var controller = unit.GetUnitMovementControllerInfo();
var signal = unit.GetUnitMovementSignal();
var isPhysical = mode == UnitMovementMode.PhysicalCharacter;
var canJump = unit.CanUnitJump();
var grounded = unit.IsUnitMovementGrounded();
var remainingAirJumps = unit.GetUnitRemainingAirJumps();输入与移动意图
常规玩家控制仍推荐使用 GameSystemUI 的默认移动控件和主控单位绑定。移动控件记录的是单位移动意图,服务端根据当前 movement mode 选择后端:
SurfaceNavigation:走 2.5D 寻路/Walkable。VolumetricNavigation:走 3D 导航移动。PhysicalCharacter:Host 会把 vector move 路由给KinematicCharacterMotor,由 motor 处理重力、着地、台阶和坡度。
如果只是想读取输入控制相机、载具或纯 SceneGraph 节点,不要绑定 MainUnit,改用 MoveControlBase.OnMoveInput。详见 GameSystemUI 移动控件。
底层物理角色 motor
PhysicalCharacter 模式底层使用 KinematicCharacterMotor:
| 参数/方法 | 说明 |
|---|---|
Radius / Height | 胶囊半径和总高度。 |
StepHeight | 可自动跨上的最大台阶高度。 |
MaxSlopeDegrees | 可站立/行走的最大坡度。 |
Gravity | 可选自定义重力;为空时使用场景物理世界重力。 |
CollisionLayer / CollisionMask | 胶囊碰撞层和碰撞掩码。 |
Move(Vector3 velocity) | 提交期望速度。Unit 控制器会自动调用,一般不用业务手写。 |
Jump(float launchSpeed) | 给 motor 垂直跳跃初速。推荐通过 TryJumpUnit 调用。 |
Warp(Vector3 position) | 物理角色瞬移。业务通常优先使用 Unit 的传送语义。 |
直接使用 unit.UsePhysicalCharacterMovement(...) 属于较低层 GameGraph/Node API。普通单位玩法优先用 GameDataUnit.MovementController 和 unit.ActivateUnitPhysicalCharacterMovement(...),这样 Unit movement signal、空中跳、客户端状态同步和 locomotion 才会一起生效。
UseCharacterCollisionRoles 当前默认关闭。需要按静态碰撞角色区分 ground/blocker 时再打开,并确保场景的碰撞角色产物已生成且经过项目验证。
KinematicCharacterMotor 不等于 Kinematic RigidBody
KinematicCharacterMotor 名称中的 Kinematic 表示“由控制器按移动意图驱动的运动学角色”,不表示角色绑定 Node 上存在 RigidBody.Kinematic = true。它底层使用 KCC ghost object 和胶囊形状完成 sweep、接触、台阶、坡度、重力与着地处理:
- 它不是参与刚体动力学求解的
RigidBody,因此没有可配置的真实Mass。 - 它可以被静态或动态碰撞体阻挡,但当前不会自动按角色质量向动态刚体施加冲量。
- 不要在
PhysicalCharacter的绑定 Node 上再添加 KinematicRigidBody和第二套碰撞形状。这样会造成重复接触、自碰撞或双重位移所有权。 UnitMovementMode.Rigidbody是另一种尚未公开实现的移动模式,不能把它与当前PhysicalCharacter/ KCC 混为一谈。
框架今后可能评估由 UnitMovementController 在 KCC contact / sweep 命中动态刚体后施加有限冲量,但这只是后续方向,不代表确定的公开 API、参数语义或发布时间。项目当前不应依赖该能力做玩法排期。
临时 PushProxy Node
项目现在确实需要“角色推箱子”时,可以自行实现一个 PushProxy Node。PushProxy 只是建议的项目侧命名,不是 SDK 内置类型:
text
PhysicalCharacter Binding Node
├── KinematicCharacterMotor # KCC,负责角色运动和场景阻挡
└── PushProxy Node # 独立的 server-local 子节点
├── Kinematic RigidBody
└── Box / Capsule CollisionShape- 在
PhysicalCharacterBinding Node 下创建独立的 server-localPushProxy子节点。不要把 KinematicRigidBody/CollisionShape直接添加到 Binding Node 本身,也不要尝试复用 motor 内部的胶囊形状。 - 给 proxy 添加小型盒或胶囊
CollisionShape与 KinematicRigidBody。它会继承 Binding Node 的角色 transform;项目可通过 proxy 的 local transform 调整推动区域和朝向。 - 使用独立 collision layer / mask,让 proxy 只接触项目指定的可推动动态刚体,并排除角色 KCC、静态场景、trigger 和其他不应推动的对象。
- proxy 不需要复制给客户端;被推动物体仍应使用 replicated Node + server-local
RigidBody,由客户端在同一 replicated Node 下挂 local 表现。 - 角色离开物理模式、死亡、传送、切场景或销毁时,同步停用、warp 或销毁 proxy,避免留下不可见碰撞体。
Kinematic proxy 对动态物体表现得接近“无限质量推动者”,并不等价于角色具有有限质量。proxy 体积过大、单帧位移过远、与静态场景发生接触或同时挤压多个刚体时,可能产生穿透、抖动或过强推力。应限制 proxy 尺寸和每帧位移,并针对轻重物体、墙角挤压、台阶、跳跃、传送和不同服务器帧率做专项回归。需要严格控制最大推力时,项目应改为查询目标 RigidBody 后调用 ApplyImpulse(...) / ApplyImpulseAtPosition(...),自行限制冲量,而不是依赖 Kinematic proxy 的接触求解。
Locomotion 动画状态机
GameDataUnitLocomotionAnimationProfile 用来定义客户端模型的移动动画状态机。它不是服务端物理的一部分,而是消费 UnitMovementSignal 与客户端位置采样,驱动模型播放:
IdleMoveJumpStartAirJumpStartAirRiseAirFallLand
示例:
csharp
public static readonly GameLink<GameDataUnitLocomotionAnimationProfile, GameDataUnitLocomotionAnimationProfile> HeroLocomotion =
new("HeroLocomotion"u8);
_ = new GameDataUnitLocomotionAnimationProfile(HeroLocomotion)
{
Idle = Clip("anim/human/idle_01.ani", looping: true),
Move = Clip("anim/human/run_01.ani", looping: true),
JumpStart = Clip("anim/human/jump_start.ani", looping: false),
AirJumpStart = Clip("anim/human/air_jump.ani", looping: false),
AirRise = Clip("anim/human/air_rise.ani", looping: true),
AirFall = Clip("anim/human/air_fall.ani", looping: true),
Land = Clip("anim/human/land.ani", looping: false),
EnterMoveSpeed = 20f,
ExitMoveSpeed = 5f,
FallSpeedThreshold = -50f,
MinAirTimeForLand = TimeSpan.FromMilliseconds(120),
LandSpeedThreshold = -120f,
InterruptLandOnMove = true,
MinLandTimeForMoveInterrupt = TimeSpan.FromMilliseconds(80),
LandMoveInterruptSpeed = 20f,
MoveSpeedScaleMode = UnitLocomotionAnimationSpeedScaleMode.HorizontalSpeed,
MovementAnimationSpeedRatio = 0.003f,
};
static UnitLocomotionAnimationClip Clip(string animation, bool looping) => new()
{
Animation = animation,
LogicLayer = AnimationLogicLayer.Rollback,
Priority = 2,
BodyPart = AnimationBodyPart.FullBody,
IsLooping = looping,
BlendIn = TimeSpan.FromMilliseconds(120),
Speed = 1f,
};把 profile 挂到模型数据上:
csharp
_ = new GameDataModel(MyLinks.HeroModel)
{
UnitLocomotionAnimationProfile = HeroLocomotion,
MovementAnimationSpeedRatio = 0.003f,
};也可以在客户端手动应用:
csharp
#if CLIENT
unit.UseUnitLocomotionAnimation(HeroLocomotion);
#endif动画优先级有效范围是 0 到 31。locomotion 的 idle / move / jump 通常使用较低优先级,例如 2;普攻、技能、受击等短动作使用更高优先级覆盖。
测试模板参考
map_templates/unit_movement_3d_template 中的 PhysicalCharacterMotorTest.cs 是当前 3D 单位控制器联调模板,覆盖:
- 物理角色 / 3D 寻路模式切换
Space跳跃- 额外空中跳跃次数调整
- 移动速度调整
- 服务端状态快照回传
- 向下 raycast 地面探测
Nav3DMesh、Nav3DVoxels、PhysicsWorld、MotorProbe调试 overlay- 项目侧 patch
Controlled3DHeroSample的 locomotion profile
该模板当前仍是内部/测试入口,不应在文档里当作普通公开模板推荐给最终用户。写项目代码时可以参考其中的 API 用法,但正式数据仍应放在项目自己的 GameData 初始化或数据编辑器源数据里。
常见问题
切换物理角色失败
先检查:
- 当前场景是否为
SceneSpaceModel.Volumetric3D - 是否重新生成过 3D 场景产物
GameDataUnit.UpdateFlags.Walkable是否启用- 是否在服务端调用
unit.GetUnitMovementControllerInfo()?.LastTransitionFailureReason
能跳但落不到地面
先打开 DebugOverlayMode.PhysicsWorld 和 MotorProbe。如果没有静态碰撞或地形碰撞,角色会一直下落。EnsureStaticMeshColliders 默认应保持 true;如果依赖地形 collider,再打开 EnsureTerrainCollider 并验证场景资产。
偶发问题不要只看某一帧画面。用 Runtime MCP 连续读取 motor.snapshot,对照 grounded、selected_ground.normal、velocity、forward_sweep 和 penetration_contact。如果只在替换碰撞根的物理帧中断,优先检查碰撞提交顺序;稳定静态盒也持续失败时,再进入 KCC 算法排查。
客户端动画没有进入跳跃/落地
检查:
- 模型是否配置
UnitLocomotionAnimationProfile - profile 的动画路径是否存在
- 客户端 Unit 是否能读到
GetUnitMovementSignal() - 是否使用了过高优先级的手动动画长期覆盖 locomotion
JumpSequence、GroundedChangeSequence是否随跳跃和落地变化
物理角色移动时普通寻路行为不一致
这是预期差异。PhysicalCharacter 是 node-driven 物理角色,能处理重力、台阶、坡度和跳跃;VolumetricNavigation 是 3D 导航移动,不会使用胶囊 motor。需要跳跃和平台手感时使用物理角色;需要自动找路和 AI 路径时使用 3D 导航。
API 速查
| API | 运行端 | 用途 |
|---|---|---|
GameDataUnit.MovementController | 双端数据 | 为单位声明移动控制器配置。 |
GameDataUnitMovementController | 双端数据 | 配置初始模式、额外空中跳属性和物理角色参数。 |
UnitMovementMode | 双端 | SurfaceNavigation、VolumetricNavigation、PhysicalCharacter 等模式枚举。 |
unit.ActivateUnitPhysicalCharacterMovement(...) | 服务端 | 切换到物理角色模式。 |
unit.ReturnUnitToSceneNavigation() | 服务端 | 返回当前场景适用的寻路模式。 |
unit.TryJumpUnit(...) | 服务端 | 请求物理角色跳跃。 |
unit.SetUnitExtraAirJumpCount(...) | 服务端 | 设置额外空中跳跃次数。 |
unit.GetUnitMovementSignal() | 双端 | 读取移动模式、速度、跳跃序列、着地序列等状态。 |
unit.GetPhysicalCharacterMovement() | 双端 | 读取底层物理角色 handle。 |
GameDataUnitLocomotionAnimationProfile | 客户端表现数据 | 配置模型 locomotion 状态机。 |
unit.UseUnitLocomotionAnimation(...) | 客户端 | 手动应用或刷新 locomotion profile。 |