UE5.3 第三人称角色移动系统深度源码解析
基于 ThirdPerson5_3 模板 + UE5.3 引擎源码的完整3C系统分析
目录
- 系统架构总览
- 输入系统:从按键到动作
- 移动系统核心:CharacterMovementComponent
- 3.1 构造时的参数配置
- 3.2 TickComponent ------ 移动的心跳
- 3.3 PerformMovement ------ 物理移动的真正执行者
- 3.4 移动状态切换机制
- 3.5 PhysWalking ------ 行走物理实现
- 3.6 CalcVelocity ------ 速度计算核心
- 3.7 DoJump ------ 跳跃实现
- 3.8 bOrientRotationToMovement ------ 面向移动方向
- 相机系统:SpringArm的智慧
- 动画系统的协同工作
- 完整数据流:一帧的生命周期
1. 系统架构总览
1.1 什么是3C系统?
在游戏开发中,3C 代表三个核心子系统:
┌─────────────────────────────────────────────────────────────────────┐
│ 3C System Architecture │
├──────────┬──────────────────┬──────────────────────────────────────┤
│ │ │ │
│ Character │ Control │ Camera │
│ (角色) │ (控制) │ (相机) │
│ │ │ │
│ ACharacter│ EnhancedInput │ USpringArmComponent │
│ Mesh │ ──────────► │ + │
│ Capsule │ Move()/Look() │ UCameraComponent │
│ Movement │ │ │
└──────────┴──────────────────┴──────────────────────────────────────┘
▲ ▲ ▲
│ │ │
└────────────┴──────────────────────┘
│
CharacterMovementComponent
(物理移动与状态管理)
1.2 本项目的组件层级
cpp
// 来自 ThirdPerson5_3Character.cpp 构造函数
AThirdPerson5_3Character::AThirdPerson5_3Character()
{
// 继承自 ACharacter 的组件结构:
RootComponent = CapsuleComponent; // 碰撞体(42×96cm)
// ├── CharacterMovement (移动组件) ← ⭐ 物理引擎核心
// ├── Mesh (骨骼网格) ← 角色外观+动画
// ├── CameraBoom (弹簧臂) ← 相机控制中枢
// │ └── FollowCamera (相机) ← 最终渲染输出
}
1.3 关键类继承关系
UObject → AActor → APawn → ACharacter → AThirdPerson5_3Character
│
UActorComponent
│
UPawnMovementComponent
│
UCharacterMovementComponent ⭐ 核心移动逻辑(13208行!)
2. 输入系统:从按键到动作
输入系统采用的增强输入那套, 此处不过多赘述
2.2 移动输入处理 ------ Move()
核心任务只有一个:把"屏幕上的按键方向"翻译成"世界里的移动方向"。
想象一下:玩家按W键,意思是"向前走"。但"前"是哪里?答案取决于相机朝向 ------ 如果相机朝北,W就是向北走;如果相机朝东,W就是向东走。
下面逐步拆解这四件事:
Step 1: 提取二维输入向量
从 Enhanced Input 系统传来的 FInputActionValue 中提取出二维向量。键盘W对应 (0, 1.0),S是 (0, -1.0),A/D 是左右。摇杆则可以是任意 (-1~1, -1~1) 的组合值。
cpp
void AThirdPerson5_3Character::Move(const FInputActionValue& Value)
{
// 键盘W: (0, 1.0) S: (0, -1.0)
// 键盘A: (-1.0, 0) D: (1.0, 0)
// 摇杆可以是任意 (-1~1, -1~1) 的组合
FVector2D MovementVector = Value.Get<FVector2D>();
Step 2: 获取控制器旋转(只取Yaw)
拿到 Controller 当前的旋转值,但只保留 Yaw(水平偏航角),把 Pitch 和 Roll 都清零。
为什么要忽略Pitch?因为角色移动应该在水平面上进行!如果保留抬头角度,玩家一抬头按W键,角色就会斜向上飞------这不是第三人称游戏想要的行为。
cpp
if (Controller != nullptr)
{
const FRotator Rotation = Controller->GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0); // 只保留水平旋转
Step 3: 计算方向向量
用这个纯水平旋转构造一个旋转矩阵,然后从中提取两个坐标轴:
- X轴 = "前方"(ForwardDirection)
- Y轴 = "右方"(RightDirection)
这两个向量就是"相机前方"和"相机右方"在世界坐标系中的真实方向。
cpp
// 从旋转矩阵提取坐标轴
const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
// X轴在旋转矩阵中代表"前方"
const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
// Y轴代表"右方"
Step 4: 应用移动输入
最后一步,把输入映射到方向上。注意这里有一个容易混淆的点:
- 输入向量的 Y分量 (前后)→ 映射到 ForwardDirection
- 输入向量的 X分量 (左右)→ 映射到 RightDirection
调用 AddMovementInput() 只是提交请求,并没有真正移动角色。真正的移动由后面 CharacterMovementComponent 在每帧物理模拟中处理。
cpp
AddMovementInput(ForwardDirection, MovementVector.Y); // W/S → 前后
AddMovementInput(RightDirection, MovementVector.X); // A/D → 左右
}
}
坐标系转换图解:
场景:控制器朝向Yaw=90°(即相机朝向世界+Y方向),玩家按W键向前走
UE 坐标系约定(左手坐标系):
- X轴正向 = "Forward"(默认前方)
- Y轴正向 = "Right"(默认右方)
- Z轴正向 = "Up"(上方)
- Yaw=0° 时 Forward = 世界+X 方向
- Yaw=90° 时 Forward = 世界+Y 方向(绕Z轴逆时针旋转90°)
输入: MovementVector = (0, 1.0) [只有Y轴,表示"前"]
计算过程:
1. Controller.Yaw = 90° (因为Look()改变了它)
2. YawRotation = FRotator(0, 90, 0)
3. ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X)
= 绕Z轴旋转90°后的X轴 = (cos90°, sin90°, 0) = (0, 1, 0)
= 世界+Y方向
4. AddMovementInput((0,1,0), 1.0) → 角色向世界+Y方向移动
最终效果:按W键向"控制器/相机朝向的方向"移动 ✓
这就是 Camera-Oriented Movement(相机导向移动)
2.4 跳跃的双事件绑定
UE5 EnhancedInput系统的一个巧妙设计:同一个跳跃按键绑定两个不同的回调函数,分别在"按下瞬间"和"松开瞬间"触发。
为什么要这样?因为这样可以实现"短按小跳、长按大跳"的效果 ------ 只要玩家一直按住跳跃键,角色就会持续获得向上的动力;一松开,上升力立刻切断。这就是所谓的"变量跳跃高度"(Variable Jump Height)机制。
绑定两个事件到同一个按键
cpp
// 文件: Source/ThirdPerson5_3/ThirdPerson5_3Character.cpp:81-82
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &ACharacter::Jump);
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACharacter::StopJumping);
Started = 按下瞬间调用 Jump();Completed = 松开瞬间调用 StopJumping()。
Jump(): 只是"记账",不直接跳
Jump() 函数本身非常简洁 ------ 它并没有直接让角色跳起来,而是做了两件"记账"工作:
- 设置标志位
bPressedJump = true,告诉系统"玩家正在按住跳跃键" - 重置按住时间计时器
JumpKeyHoldTime = 0
真正的跳跃物理会在后面由 CharacterMovementComponent::DoJump() 执行。这种"标记-延迟处理"的模式把"发生了什么"和"怎么处理"分开。
cpp
void ACharacter::Jump()
{
bPressedJump = true; // 标记"正在按住跳跃"
JumpKeyHoldTime = 0.0f; // 重置按住时间计时器
}
StopJumping(): 切断上升动力
这是 Jump() 的反操作 ------ 当玩家松开空格键时触发。它把跳跃标志位清零,并重置所有跳跃相关的状态。
cpp
void ACharacter::StopJumping()
{
bPressedJump = false; // 标记"已松开跳跃"
ResetJumpState(); // 重置跳跃状态
}
一旦 bPressedJump 变为 false,CharacterMovementComponent 就不再延长跳跃的上升阶段,角色会更快地开始下落(只受重力影响)。不过要注意,UE默认的 JumpMaxHoldTime = 0,这意味着默认情况下变量跳跃高度并未生效 ------需要手动设置 JumpMaxHoldTime > 0 才能启用"长按跳更高"的效果。
CheckJumpInput(): 每帧监控按住状态
这个函数每帧被 Tick 调用,职责是持续监控跳跃键是否还被按住。如果在按住,就累加时间并继续提供上升动力;一旦松开,动力立刻切断。
cpp
// Character.cpp (简化版逻辑)
void ACharacter::CheckJumpInput(float DeltaTime)
{
if (bPressedJump) // 还在按着?
{
JumpKeyHoldTime += DeltaTime;
// 按住时间 < MaxHoldTime → 继续提供上升力
// 松开后 (bPressedJump=false) → 提前结束上升阶段
// 结果: 短按低跳,长按高跳
}
}
3. 移动系统核心:CharacterMovementComponent
这是整个3C系统最复杂、最重要的部分------13208行代码的移动物理引擎!
3.1 构造时的参数配置
在角色创建的瞬间(构造函数),我们通过这一组参数定义了这个角色的"运动个性"。
这些参数就像汽车的调校 ------ 同样的引擎(CharacterMovementComponent),不同的参数设定会带来截然不同的"驾驶手感"。下面逐个解释每个参数的作用:
跳跃相关
cpp
GetCharacterMovement()->JumpZVelocity = 700.f; // 跳跃初速度 → 最大高度≈2.5米
700 cm/s 的向上初速度。物理上,抛物线最大高度 = V²/(2g),代入后约等于2.5米。
空中控制力
cpp
GetCharacterMovement()->AirControl = 0.35f; // 空中控制力35%
0表示空中完全不能转向(像老式平台跳跃),1表示空中和地面一样灵活。UE引擎的默认值是0.05(几乎无法空中转向),本项目设为0.35,给玩家较多的空中调整能力。
移动速度
cpp
GetCharacterMovement()->MaxWalkSpeed = 500.f; // 最大速度 500cm/s (18km/h)
GetCharacterMovement()->MinAnalogWalkSpeed = 20.f; // 最小模拟速度阈值
500 cm/s ≈ 18 km/h,相当于人类慢跑速度。MinAnalogWalkSpeed 是摇杆输入的死区下限。
制动(减速)参数
cpp
GetCharacterMovement()->BrakingDecelerationWalking = 2000.f; // 地面制动减速度
GetCharacterMovement()->BrakingDecelerationFalling = 1500.0f; // 空中水平制动
这两个值决定松开方向键后多久停下来。2000 的地面制动意味着从最高速到停止只需要约0.25秒------响应很灵敏。
自动朝向与转向速度
cpp
GetCharacterMovement()->bOrientRotationToMovement = true;// 自动面向移动方向
GetCharacterMovement()->RotationRate = FRotator(0, 500, 0); // 转向速度500°/s
开启后角色模型会自动转向当前移动方向。500°/s 的转向率意味着不到0.4秒就能完成180°掉头。
3.2 TickComponent ------ 移动的心跳
CharacterMovementComponent 是一个 Tick Component ,意味着它每帧都会被调用。
如果说CharacterMovementComponent是移动系统的"大脑",那TickComponent()就是这个大脑的"心跳" ------ 每秒被调用60次(在60FPS下),每次调用都驱动一次完整的物理模拟。
下面逐步拆解它的工作流程:
Step 1: 消费累积的移动输入
前面 Move() 函数中调用的 AddMovementInput() 并不会立即移动角色,而是把输入向量"攒"在一个内部缓冲区里。这里通过 ConsumeInputVector() 把积攒的请求取出来,同时清空缓冲区,为下一帧做准备。
cpp
void UCharacterMovementComponent::TickComponent(
float DeltaTime,
ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
// Move()函数调用的AddMovementInput()会累积到这里
// ConsumeInputVector()取出并清空,准备这一帧的处理
FVector InputVector = ConsumeInputVector();
为什么采用"按键累计输入 → CMC Tick 消费"模式?
为了让玩家输入 、物理模拟 、网络同步三者解耦,以稳定频率运行,并支持客户端预测与服务器回滚。
五大核心原因
| # | 原因 | 核心问题 | 解决方式 |
|---|---|---|---|
| 1 | 频率不匹配(核心) | 输入是事件驱动(不定频),物理需要稳定 DeltaTime 积分 | 按键只存"意图",Tick 按 DeltaTime 统一积分 |
| 2 | 多输入合并 | 同一帧多个按键会产生锯齿感 | 累计到 PendingInputVector,Tick 里统一归一化 |
| 3 | 网络同步(CMC 核心约束) | 需要客户端预测 + 服务器权威 + 回滚重放 | 每次 Tick 打包成 FSavedMove,可序列化、可重放 |
| 4 | 系统解耦 | 动画、相机、技能需要读稳定的 Velocity | Tick 有固定执行顺序(TickGroup),保证读写一致 |
| 5 | 移动模式切换 | Walking/Falling/Flying/Custom 状态机需要稳定时序 | 统一在 Tick 里"读输入→算物理→判切换" |
"把离散的输入事件,转换成连续的物理模拟单元"
- 按键是瞬时的、离散的 → 不能直接驱动物理
- Tick 是连续的、稳定的 → 是物理模拟的天然时钟
- 中间用一个累计缓冲区做桥梁 → 同时成为网络同步的"原子包"
Step 3: 调度到移动执行函数
TickComponent 并不直接 调用物理函数,而是通过 ControlledCharacterMove() 进入调度层(处理网络角色类型、输入缩放等),最终进入 PerformMovement()。
cpp
// ControlledCharacterMove() → PerformMovement() → StartNewPhysics() → PhysWalking()
ControlledCharacterMove(InputVector, DeltaTime);
}
完整的
PerformMovement内部7阶段流程,请参见下一小节 [3.3 PerformMovement](#3.3 PerformMovement)。
3.3 PerformMovement ------ 物理移动的真正执行者 ⭐⭐
PerformMovement() 是整个 CMC 中最核心、最复杂 的函数(约300+行),它是所有移动逻辑的"总指挥"。如果说 TickComponent 是心跳,那 PerformMovement 就是心脏泵血的具体过程。
整体流程概览
TickComponent()
│
├── ConsumeInputVector() ← 取出输入
│
└── ControlledCharacterMove() ← ★ 调度层(处理网络角色 + 输入缩放)
│
├── CheckJumpInput() ← 检查跳跃按住状态
├── ScaleInputAcceleration() ← 输入向量→加速度转换
│
└── PerformMovement() ← ★★ 真正的物理执行总入口
│
├─ [阶段A] 初始化与状态保存
├─ [阶段B] 力的累积与应用(含平台跟随前置)
├─ [阶段C] 加速度准备(输入 → 加速度向量)
├─ [阶段D] 根运动(Root Motion)处理
├─ [阶段E] StartNewPhysics() → PhysWalking/Falling... ← 核心物理!
├─ [阶段F] 旋转应用
├─ [阶段G] UpdateBasedMovement(平台跟随后置)
└─ [阶段H] 触发移动回调与边界检查
用大白话说这8个阶段到底在干嘛?
想象你在指挥一个角色"走一步",需要按顺序做完这些事:
- 阶段A(准备):先拍个"快照"记下角色现在在哪、速度多少,万一等下算错了还能回滚;顺便打开"批处理"模式,省性能。
- 阶段B(收外力):看看有没有人往角色身上"施力"------被炸飞?被弹簧弹开?踩了传送带?踩了电梯?全部记到速度里。
- 阶段C(翻译输入):玩家按的 W/A/S/D 还只是"意图方向",这一步把它翻译成"加速度向量"(带大小和方向的物理量)------就是给意图配上"油门深度"。
- 阶段D(仲裁):问一句"这次到底谁说了算?"------如果动画在播根运动(爬墙、翻滚),动画优先;否则才用玩家输入算出来的东西。
- 阶段E(真干活) ⭐:这一步才真正让角色动起来。根据当前状态(走路/空中/游泳)调用不同物理函数,算出位移、做碰撞检测、处理墙面滑动、检测地面......
- 阶段F(转身) :角色走到了新位置,但身体可能还朝着旧方向------这一步让身体朝向跟上移动方向(
bOrientRotationToMovement生效的地方)。 - 阶段G(跟平台):如果脚下的电梯/船/旋转平台这一帧动了,再同步一次把角色"粘"在平台上,不让它被落下。
- 阶段H(广播通知):万事俱备,对外喊一声"我这一帧移动完成啦!",让动画系统、音效系统、触发器等都来根据新状态做反应。
一句话总结: 收集意图 → 加工成物理量 → 真正模拟位移 → 收尾同步 ------ 这是一条从"玩家按键"到"角色真的动了一下"的完整流水线,每一步都有明确分工,不越权也不遗漏。
为什么这么复杂? 因为 CMC 要同时处理:
- 4种以上移动模式(走路/下落/游泳/飞行/自定义)
- 外力、根运动、平台跟随、网络同步、碰撞滑动、台阶上攀......
- 全部在每帧16毫秒内完成,还要支持多人联机的客户端预测
所以它不是"简单地改一下位置",而是一条精心编排的流水线。
下面逐步拆解每一个阶段:
阶段A:初始化与状态保存
函数开始时,首先要做几件"准备工作"------保存当前状态(用于可能的回滚)、创建性能优化作用域。
cpp
void UCharacterMovementComponent::PerformMovement(float DeltaSeconds)
{
// === 前置检查 ===
if (!HasValidData() || DeltaSeconds < MIN_TICK_TIME)
{
return; // 数据无效或时间步长太小,跳过本帧
}
// === 保存当前状态(用于后续回滚或对比)===
FVector OldLocation = UpdatedComponent->GetComponentLocation();
FQuat OldRotation = UpdatedComponent->GetComponentQuat();
FVector OldVelocity = Velocity; // 旧速度
FVector OldBaseLocation = FVector::ZeroVector; // 基底位置(站在平台上时有用)
为什么要保存 OldLocation 和 OldVelocity?因为后续的物理计算可能导致碰撞、穿透等异常,需要用旧状态做回滚或修正。此外,网络同步也需要新旧状态的差值来计算预测误差。
cpp
// === 性能优化:创建作用域更新 ===
// FScopedMovementUpdate 会批量合并多次 MoveComponent 调用
// 避免每移动一次就更新一次场景树,大幅提升性能
FScopedMovementUpdate ScopedMovementUpdate(
UpdatedComponent,
bEnableScopedMovementUpdates ? EScopedUpdate::Deferred : EScopedUpdate::Immediate
);
FScopedMovementUpdate 是一个RAII风格的作用域守卫------在构造时"冻结"场景更新,在析构时统一刷新。因为 PhysWalking 的 while 循环可能调用多次 MoveComponent,有了这个优化,一帧内只需要一次场景刷新而不是N次。
cpp
// === 处理基于其他Actor的移动 ===
// 如果角色站在一个移动的平台(如电梯)上
MaybeUpdateBasedMovement(DeltaSeconds);
// === 清理过期的根运动源 ===
CleanUpInvalidRootMotion();
MaybeUpdateBasedMovement 处理的是"电梯问题"------如果角色站在一个正在移动的物理Actor上,角色的世界坐标需要跟着平台一起变动,这部分位移不是角色自己走出来的,而是"被带着走的"。
CleanUpInvalidRootMotion 清理已过期或无效的程序化根运动源(如已经结束的击退力、拉扯力等)。
阶段B:力的累积与应用
接下来是各种"外力"的处理------推力、冲击力、跳跃发射力等。
cpp
// === 应用累积的外部力 ===
// 包括:AddForce添加的力、ApplyImpulse的冲击力、LaunchCharacter的发射力等
ApplyAccumulatedForces(DeltaSeconds);
// === 更新角色状态(蹲伏、飞行等属性在移动前确认)===
UpdateCharacterStateBeforeMovement(DeltaSeconds);
// === 处理待定的发射力(如跳跃、击飞)===
HandlePendingLaunch();
这三行分别处理三种不同的力:
ApplyAccumulatedForces--- 持续性的外力,比如风扇吹拂、水流推动UpdateCharacterStateBeforeMovement--- 在物理计算前同步角色状态(比如是否蹲下影响碰撞体高度)HandlePendingLaunch--- 突发性的瞬时力,最典型的就是跳跃!
最终效果:这三步产生什么结果?
本质上,阶段B 是一个"速度汇总器"------它不直接让角色位移,而是把本帧所有外部施力请求转换为 Velocity 的变化,为后续阶段E 的物理模拟准备好权威的速度值。
| 函数 | 对 Velocity 的影响 | 典型效果 |
|---|---|---|
ApplyAccumulatedForces |
Velocity += Impulse + Force × dt |
爆炸震开、风扇吹拂、水流推动 |
UpdateCharacterStateBeforeMovement |
不直接改 Velocity,但调整物理参数(胶囊高度、MaxWalkSpeed 等) | 蹲下变矮、起立检测头顶、蹲伏限速 |
HandlePendingLaunch |
Velocity 被覆盖或叠加,并切换到 MOVE_Falling |
弹簧床弹飞、技能击退、跳跃台助跳 |
示例:一帧内三步的累积效果
【阶段B 开始】 Velocity = (500, 0, 0) ← 角色正向前跑
│
├─ ApplyAccumulatedForces() [被爆炸冲击, AddImpulse(0,0,300)]
│ → Velocity = (500, 0, 300)
│
├─ UpdateCharacterStateBeforeMovement() [玩家按C键蹲下]
│ → 胶囊高度 96→48, MaxWalkSpeed 500→300(下阶段才生效)
│
└─ HandlePendingLaunch() [踩到弹簧, LaunchCharacter Z=1000 覆盖]
→ Velocity = (500, 0, 1000), 切换为 MOVE_Falling
【阶段B 结束】 最终 Velocity = (500, 0, 1000),交给后续物理阶段使用
设计思想: 所有速度变更请求都"延迟"到这里统一处理,保证同一帧内的所有修改都发生在物理模拟的同一时点------这对网络同步和客户端预测的一致性至关重要。
阶段C:加速度准备(输入 → 加速度向量)
接下来将输入消费成加速度,通过让输入向量在保留方向的情况下,乘以加速度的值,就可以得到一个有方向的加速度向量。
简单来说,假如MaxAcceleration = 2048
当玩家按满键(输入=1),角色就以全速能力 2048 cm/s² 加速; 当玩家轻推摇杆(输入=0.3),角色就以 30% 能力 =614 cm/s² 加速。
后续在physicWalking中的CalcVelocity,会将加速度转变成速度。
另一方面,正常斜向跑可能会导致速度变大,因为此时向量的大小会是根号2,此时速度大小就会变为1.414倍,所以需要加限制,-如果向量长度 > 1 :按比例缩短 到长度正好等于 1
如果向量长度 ≤ 1 :原样返回,不做任何改变
Acceleration = GetMaxAcceleration() * InputVector.GetClampedToMaxSize(1.0f);
// ↑ ↑ ↑
// 2048 cm/s² 保留方向 最大长度为1
但是如果有根运动,则不会去计算此处的速度
阶段D:根运动(Root Motion)准备与处理 ⭐
这是 PerformMovement 中最复杂的部分------协调"代码驱动的物理移动"和"动画驱动的根运动"之间的优先级关系。
cpp
// === 处理根运动(Root Motion)系统 ===
// 子步骤 D1: 如果有动画根运动,先更新骨骼姿势
if (CurrentRootMotion.HasActiveRootMotion())
{
// 更新动画骨骼的姿态,提取根骨位移
TickCharacterPose(DeltaSeconds);
}
// 子步骤 D2: 准备非动画类的根运动源数据
else if (IsUsingRootMotionSources())
{
// 程序化根运动:MoveToForce、ConstantForce、JumpForce 等
PrepareRootMotion(DeltaSeconds, NewRot);
}
根运动优先级体系(从高到低):
优先级1: 动画根运动 (AnimSequence Root Motion) ← 最高!覆盖一切
优先级2: 覆盖型根运动源 (Override Root Motion Sources)
优先级3: 叠加型根运动源 (Additive Root Motion Sources)
优先级4: 常规物理移动 (CalcVelocity + Physics) ← 最低
cpp
// 子步骤 D3: 应用根运动到速度上(如果有)
if (HasRootMotion())
{
// 动画根运动:直接用动画计算的位移覆盖 Velocity
if (CurrentRootMotion.HasActiveRootMotion())
{
// 从动画曲线提取这一帧的根骨世界空间位移
FVector RootMotionVelocity = CurrentRootMotion.ConsumeRootMotion(DeltaSeconds).GetTranslation();
// ★ 直接覆盖速度!物理算的速度被完全忽略
Velocity = RootMotionVelocity / DeltaSeconds;
}
else if (IsUsingRootMotionSources())
{
// 程序化根运动源:按优先级合并
AccumulateOverrideRootMotionVelocity(DeltaSeconds, Velocity);
}
}
当 Velocity 被根运动覆盖后,后面的 PhysWalking 使用的就是这个被修改过的速度来计算位移。这就是"动画驱动移动"的本质------动画说了算,物理靠边站。
阶段E:StartNewPhysics ------ 分发到具体物理模式 ⭐⭐
这是 PerformMovement 中最关键的一步------把工作委托给当前移动模式对应的物理函数。
cpp
// === 核心分发:根据当前移动模式选择物理函数 ===
// 注意:这里的参数是 DeltaSeconds(原始帧间隔),不是子步骤时间
// 子步骤分割在具体的Phys*函数内部完成
NumJumpApexAttempts = 0; // 重置跳跃顶点检测计数
StartNewPhysics(DeltaSeconds, 0); // ★★★ 这是真正的物理入口!
StartNewPhysics 的内部实现就是我们之前在 TickComponent 里简化展示的那个 switch 语句:
cpp
// StartNewPhysics 内部(简化版):
void UCharacterMovementComponent::StartNewPhysics(float DeltaTime, int32 Iterations)
{
switch (MovementMode)
{
case MOVE_Walking:
PhysWalking(DeltaTime, Iterations); // → 调用 CalcVelocity + MoveAlongFloor + FindFloor
break;
case MOVE_Falling:
PhysFalling(DeltaTime, Iterations); // → 重力 + 空气阻力 + 落地检测
break;
case MOVE_Swimming:
PhysSwimming(DeltaTime, Iterations);
break;
case MOVE_Flying:
PhysFlying(DeltaTime, Iterations);
break;
// ... 其他模式
}
}
所以完整的调用链是这样的:
TickComponent()
└→ ControlledCharacterMove()
└→ PerformMovement()
├→ [A-G 各种预处理]
└→ StartNewPhysics()
└→ PhysWalking() ← 我们在 3.5 节详细分析的这个函数
├→ CalcVelocity() ← 我们在 3.6 节详细分析的这个函数
├→ SafeMoveUpdatedComponent()
└→ FindFloor()
阶段F:旋转应用
物理位移完成后,还需要处理角色的朝向旋转。这涉及多个旋转来源的竞争与合并。
cpp
// === 旋转处理 ===
if (bOrientRotationToMovement && !HasRootMotion())
{
// 常规模式:根据速度方向自动朝向(3.8节详细讲解)
// 在 PhysWalking 内部的最后一步处理
// 或者在这里统一处理,取决于引擎版本
}
// 根运动旋转(如果有的话,优先级高于物理旋转)
if (HasRootMotion())
{
const FRotator RootMotionRotation = ConsumeRootMotionRotation();
if (!RootMotionRotation.IsNearlyZero())
{
// 直接应用根运动带来的旋转(如动画中的转身)
MoveUpdatedComponent(FVector::ZeroVector, RootMotionRotation, nullptr);
}
}
旋转也有优先级:动画根运动旋转 > 程序化根运动旋转 > bOrientRotationToMovement 自动朝向 > 物理旋转 (PhysicsRotation)
阶段G:UpdateBasedMovement ------ 让角色"黏"在移动平台上 ⭐
这是一个非常重要但容易被忽视的函数。它的职责是:处理"角色站在一个移动的物体上"的场景 ------ 让角色跟随电梯、船、旋转平台、公交车等一起移动,而不是像踩空一样被平台甩下。
具体实现可以看这篇文章:
https://iwiki.woa.com/p/4020161604
阶段H:触发移动回调
最后做一些清理工作,并通知其他系统"本轮移动已完成"。
cpp
// === 更新基于物体的移动状态 ===
UpdateBasedMovement(DeltaSeconds);
// === 触发移动完成回调 ===
OnMovementUpdated(Velocity, OldLocation, DeltaSeconds);
// === 最后的边界检查 ===
if (UpdatedComponent)
{
// 确保角色不会超出世界边界
const FVector NewLocation = UpdatedComponent->GetComponentLocation();
FWorldBoundary* WorldBoundary = GetWorld()->GetWorldBoundary();
if (WorldBoundary)
{
WorldBoundary->OnActorMovedInside(UpdatedComponent->GetOwner(), NewLocation);
}
}
} // ← PerformMovement 结束!
OnMovementUpdated 是一个广播(Delegate),其他系统可以监听这个事件来做一些"移动后"的逻辑------比如脚步音效播放、地面材质检测、触发区域进入判定等。
PerformMovement 与网络同步的关系(补充理解)
PerformMovement 在单机和服务器上是直接被调用的 ,但在**自主代理客户端(AutonomousProxy)**上,它被包裹在 ReplicateMoveToServer 中:
【服务器端 / 单机模式】
TickComponent → ControlledCharacterMove → PerformMovement()
【客户端模式(AutonomousProxy)】
TickComponent → ControlledCharacterMove
→ ReplicateMoveToServer()
├── PerformMovement() ← 本地预表现(让玩家立即看到移动)
├── SaveClientMove() ← 保存本次移动数据
└── CallServerMove() ← 发送RPC给服务器校验
这就是UE著名的**客户端预测(Client-Side Prediction)**机制的核心------客户端不等服务器确认,先本地执行 PerformMovement 让玩家立刻看到响应;同时把操作发给服务器,服务端也跑一份同样的 PerformMovement 进行权威校验。如果两端结果不一致,服务器会发送修正。
3.4 移动状态切换机制 ⭐⭐
核心问题: 角色是怎么知道自己是"在地上走"还是在"空中掉"?又是如何在这两种状态之间切换的?
在前面的小节中,我们已经分析了 TickComponent → PerformMovement → StartNewPhysics 的调用链。其中 StartNewPhysics 内部有一个 switch(MovementMode) 来决定调用哪个物理函数(PhysWalking、PhysFalling 等)。但一个关键问题还没有回答:MovementMode 这个变量本身是谁、在哪里、因为什么原因而被改变的?
这就是本节要解答的核心内容------CMC 内置的移动状态机。
整体流程概览
角色的移动生命周期就像一个人的日常状态:
【Walking 行走】──→ 按下跳跃 / 走下悬崖 / 被击飞 ──→ 【Falling 下落】
▲ │
│ 落地检测到有效地面 │
└────────────────────────────────────────────────────┘
每个状态都有自己专属的物理函数来处理:
Walking → PhysWalking() (地面摩擦 + 碰撞滑动 + 地面检测)
Falling → PhysFalling() (重力加速 + 空气阻力 + 落地检测)
Swimming → PhysSwimming() (浮力 + 水阻力)
Flying → PhysFlying() (无重力自由移动)
MovementMode ------ 状态的载体
MovementMode 是一个枚举类型的成员变量,定义了角色当前所处的移动状态:
cpp
// 定义于 Engine/Source/Runtime/Engine/Classes/GameFramework/CharacterMovementComponent.h
UENUM(BlueprintType)
enum EMovementMode
{
MOVE_None UMETA(DisplayName = "None"), // 无移动(禁用状态)
MOVE_Walking UMETA(DisplayName = "Walking"), // ★ 行走/跑步/蹲伏 --- 最常用的模式!
MOVE_NavWalking UMETA(DisplayName = "NavWalking"), // 导航网格行走(AI寻路时使用)
MOVE_Falling UMETA(DisplayName = "Falling"), // ★ 下落/跳跃空中 --- 第二常用的模式!
MOVE_Swimming UMETA(DisplayName = "Swimming"), // 游泳
MOVE_Flying UMETA(DisplayName = "Flying"), // 飞行(不受重力)
MOVE_Custom UMETA(DisplayName = "Custom"), // 自定义模式(攀爬、滑索等扩展用)
};
在我们的第三人称项目中,90% 以上的时间都在 MOVE_Walking 和 MOVE_Falling 之间来回切换。
SetMovementMode() ------ 状态切换的核心函数 ★★★
这是整个状态机的"控制旋钮"。无论是主动切换(如跳跃代码直接调用)还是被动切换(如物理检测结果触发),最终都通过这个函数完成状态的变更。
cpp
/**
* 设置角色的移动模式(状态切换入口函数)
*
* 使用场景:
* - 1) 跳跃时:DoJump() 内部调用 SetMovementMode(MOVE_Falling)
* - 2) 落地时:ProcessLanded() 内部调用 SetMovementMode(MOVE_Walking)
* - 3) 掉落时:PhysWalking 检测到脚底悬空后调用 SetMovementMode(MOVE_Falling)
* - 4) 进入水中:物理检测到水位覆盖角色后调用 SetMovementMode(MOVE_Swimming)
*
* @param NewMovementMode 要切换到的目标模式(如 MOVE_Walking、MOVE_Falling)
* @param NewCustomMode 如果目标是 MOVE_Custom,此处指定自定义子模式编号
*/
void UCharacterMovementComponent::SetMovementMode(EMovementMode NewMovementMode, uint8 NewCustomMode)
{
// === 前置检查:如果目标模式和当前模式相同,通常无需处理 ===
if (NewMovementMode == MovementMode)
{
// 即使模式相同,自定义子模式可能不同,需要更新
if (NewCustomMode == CustomMovementMode)
{
return; // 完全相同,直接返回
}
}
// === 记录旧状态(用于回调通知)===
const EMovementMode PreviousMovementMode = MovementMode;
const uint8 PreviousCustomMode = CustomMovementMode;
// ════════════════════════════════════════
// 第一部分:应用新状态
// ════════════════════════════════════════
MovementMode = NewMovementMode;
CustomMovementMode = NewCustomMode;
// ════════════════════════════════════════
// 第二部分:触发状态变更通知(关键!)
// ════════════════════════════════════════
OnMovementModeChanged(PreviousMovementMode, PreviousCustomMode);
}
核心要点: SetMovementMode 本身只做两件事------更新变量 + 发通知。真正"重头戏"都在 OnMovementModeChanged 这个虚函数里。
OnMovementModeChanged() ------ 状态变更的"善后处理"
当状态发生切换后,需要做大量的清理和初始化工作。比如从 Walking 切到 Falling 时要清空地面信息;从 Falling 切回 Walking 时要重置跳跃计数等。
cpp
/**
* 移动模式变更后的回调处理函数
*
* 由 SetMovementMode() 在更改状态后自动调用。
* 负责执行状态切换所需的"善后工作":
* - 清理旧模式的临时数据
* - 初始化新模式需要的参数
* - 通知其他系统(动画、音效等)
*
* @param PreviousMovementMode 切换前的模式
* @param PreviousCustomMode 切换前的自定义子模式
*/
void UCharacterMovementComponent::OnMovementModeChanged(
EMovementMode PreviousMovementMode,
uint8 PreviousCustomMode)
{
// ════════════════════════════════════════
// 第一部分:离开旧模式的清理工作
// ════════════════════════════════════════
if (PreviousMovementMode == MOVE_Walking)
{
// 离开地面模式 → 清除地面相关信息
CurrentFloor.Clear(); // 清空当前地板数据(FFloorResult结构体)
bCrouchMovedNoGravity = false; // 重置蹲伏无重力标志
}
// ════════════════════════════════════════
// 第二部分:进入新模式的初始化工作
// ════════════════════════════════════════
if (MovementMode == MOVE_Walking)
{
// ===== 进入地面模式 =====
// 必须立刻确认脚下有有效的地面
// 因为我们可能是从 Falling 落地回来的,此时地面信息还是旧的或不存在的
FindFloor(UpdatedComponent->GetComponentLocation(), CurrentFloor, false);
if (!CurrentFloor.IsWalkableFloor())
{
// ★ 关键保护逻辑!
// 如果进入 Walking 模式但脚下没有有效的地面,
// 说明这个切换是不合法的 → 自动回退到 Falling 模式
// 这种情况可能发生在:脚本强制设为 Walking 但角色实际在空中
SetMovementMode(MOVE_Falling);
return; // 回退后提前返回
}
// 重置相关计时器和状态
ResetFallState(); // 清空下落过程中的临时数据
SetPostLandedPhysics(); // 应用落地后的物理参数恢复
}
else if (MovementMode == MOVE_Falling)
{
// ===== 进入下落/空中模式 =====
// 保留当前速度!包括水平和垂直分量
// 如果是跳跃进入 Falling,此时 Velocity.Z 已被 DoJump 设为 JumpZVelocity
// 如果是走下悬崖,Velocity.Z 本身就接近 0
// 所以这里不会清零 Velocity.Z ------ 否则跳跃就会失效!
// 清空地面信息(空中不需要地板数据)
CurrentFloor.Clear();
// 重置跳跃相关的空中状态
ResetFallState();
}
else if (MovementMode == MOVE_Swimming)
{
// ===== 进入游泳模式 =====
// 浮力计算、水阻力设置等...
}
else if (MovementMode == MOVE_Flying)
{
// ===== 进入飞行模式 =====
// 飞行时不应用重力
}
// ════════════════════════════════════════
// 第三部分:广播通知
// ════════════════════════════════════════
// 通知所有监听者(蓝图可以绑定这个事件来做状态切换时的特效/音效等)
OnMovementModeChangedDelegate.Broadcast(PreviousMovementMode, MovementMode);
}
★ 核心设计思想: 注意上面进入
MOVE_Walking时那个 "脚底验证 + 自动回退" 的保护逻辑!这是 CMC 防止状态不一致的关键安全网。如果你在蓝图中强行把一个空中的角色设为 Walking,CMC 会发现脚下没地板然后自动把它弹回 Falling。
状态切换场景全景图
下面列出 CMC 中所有会触发状态切换的场景,按触发方式分为主动切换 和被动切换两类:
┌─────────────────────────────────────────────────────────────────────────┐
│ CMC 移动状态完整切换地图 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ╔═══════════════════════════════════════════════════════════════════╗ │
│ ║ 一、主动切换(代码显式调用 SetMovementMode) ║ │
│ ╠═══════════════════════════════════════════════════════════════════╣ │
│ ║ ║ │
│ ║ 场景1: 玩家按下跳跃键 ║ │
│ ║ ACharacter::Jump() ║ │
│ ║ → bPressedJump = true ║ │
│ ║ CMC::CheckJumpInput() [每帧Tick调用] ║ │
│ ║ → 检测到 bPressedJump && CanJump() ║ │
║ ║ → DoJump() ║ │
│ ║ → Velocity.Z = JumpZVelocity (700 cm/s) ↑ ║ │
│ ║ → SetMovementMode(MOVE_Falling) ← ★ 状态切换! ║ │
│ ║ ║ │
│ ║ 场景2: 代码/Lua脚本强制切换 ║ │
│ ║ GetCharacterMovement()->SetMovementMode(MOVE_Flying); ║ │
│ ║ GetCharacterMovement()->SetMovementMode(MOVE_Swimming); ║ │
│ ║ ║ │
│ ║ 场景3: 进入/退出潜水区(PhysicsVolume 水位体积) ║ │
│ ║ 角色进入 WaterVolume ║ │
│ ║ → SetMovementMode(MOVE_Swimming) ║ │
│ ║ ║ │
│ ╚═══════════════════════════════════════════════════════════════════╝ │
│ │
│ ╔═══════════════════════════════════════════════════════════════════╗ │
│ ║ 二、被动切换(物理检测自动触发) ║ │
│ ╠═══════════════════════════════════════════════════════════════════╣ │
│ ║ ║ │
│ ║ ┌─────────────────────────────────────────────────────────────┐ ║ │
│ ║ │ 场景A: Walking → Falling(从地面掉落到空中) │ ║ │
│ ║ │ │ ║ │
│ ║ │ 触发位置: PhysWalking() 内部的每轮迭代末尾 │ ║ │
│ ║ │ 函数调用链: │ ║ │
│ ║ │ PhysWalking() │ ║ │
│ ║ │ → MoveAlongFloor() [沿地面移动] │ ║ │
│ ║ │ → FindFloor() [地面检测] ★ │ ║ │
│ ║ │ → 检测结果: 无有效地面! │ ║ │
│ ║ │ → CheckFall() 或 StartFalling() │ ║ │
│ ║ │ → SetMovementMode(MOVE_Falling) ← ★ 状态切换! │ ║ │
│ ║ │ │ ║ │
│ ║ │ 具体触发条件: │ ║ │
│ ║ │ 1. 走下悬崖边缘 (WalkOffLedge) │ ║ │
│ ║ │ 2. 地面被销毁(如破碎的地板) │ ║ │
│ ║ │ 3. 站在的物体突然移开(如被推动的平台) │ ║ │
│ ║ │ 4. 走到坡度超限的斜面上(超过 WalkableFloorZ) │ ║ │
│ ║ └─────────────────────────────────────────────────────────────┘ ║ │
│ ║ ║ │
│ ║ ┌─────────────────────────────────────────────────────────────┐ ║ │
│ ║ │ 场景B: Falling → Walking(从空中落地回到地面) ★最常见! │ ║ │
│ ║ │ │ ║ │
│ ║ │ 触发位置: PhysFalling() 内部的碰撞处理中 │ ║ │
│ ║ │ 函数调用链: │ ║ │
│ ║ │ PhysFalling() │ ║ │
│ ║ │ → ApplyGravity() [重力加速: V.Z -= g×dt] │ ║ │
│ ║ │ → CalcVelocity() [空气摩擦 + AirControl] │ ║ │
│ ║ │ → SafeMoveUpdatedComponent() [尝试移动] │ ║ │
│ ║ │ → 发生碰撞! (Hit.bBlockingHit == true) │ ║ │
│ ║ │ → ShouldCheckForValidLandingSpot() [是否算落地?] │ ║ │
│ ║ │ → ProcessLanded() │ ║ │
│ ║ │ → Velocity.Z = 0 [垂直速度归零] │ ║ │
│ ║ │ → SetMovementMode(MOVE_Walking) ← ★ 状态切换! │ ║ │
│ ║ │ → LandedDelegate.Broadcast() [播放落地音效/特效] │ ║ │
│ ║ │ │ ║ │
│ ║ │ 具体触发条件: │ ║ │
│ ║ │ 1. 自然落地(重力拉下来碰到地板) │ ║ │
│ ║ │ 2. 跳跃后落下碰头?不,碰头不算落地 │ ║ │
│ ║ │ 3. 空中碰到斜坡(且斜坡角度可行走) │ ║ │
│ ║ └─────────────────────────────────────────────────────────────┘ ║ │
│ ║ ║ │
│ ╚═══════════════════════════════════════════════════════════════════╝ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
FindFloor() ------ 地面检测的核心算法 ⭐⭐
无论从 Walking 切到 Falling("掉下去"),还是从 Falling 切回 Walking("落回来"),判断依据都是同一个函数:FindFloor()。它是整个状态切换机制的"感官器官"------告诉系统"脚下到底有没有地"。
cpp
/**
* 在指定位置检测地面信息
*
* 这是 CMC 中最常被调用的检测函数之一,每帧 PhysWalking 和 PhysFalling 都会调用它。
* 通过胶囊体扫描(Sweep) + 射线检测(Line Trace)的双重机制来判断角色下方是否有
* 可行走的地面。
*
* @param CapsuleLocation 要进行检测的位置(通常是角色当前位置)
* @param FloorResult [输出] 检测结果,包含地面距离、法线、碰撞体等信息
* @param LineTrace 是否使用射线检测作为 Sweep 失败后的补充方案
*
* 调用场景举例:
* - PhysWalking 每轮迭代末尾: "我移动完了,脚下还有地板吗?"
* - PhysFalling 发生碰撞后: "撞到了东西,这是可落脚的地面吗?"
* - OnMovementModeChanged 进入Walking时: "我要进入地面模式,先确认脚下确实有地"
*/
void UCharacterMovementComponent::FindFloor(
const FVector& CapsuleLocation,
FFindFloorResult& FloorResult,
bool bLineTrace)
{
// ════════════════════════════════════════
// 第一部分:正常地面检测(Step 1)
// ════════════════════════════════════════
// 从 capsule 底部向下发射扫描检测
// 检测范围 = 从脚底往下约 MaxStepHeight + 容差值
ComputeFloorDist(
CapsuleLocation,
FloorResult,
CollisionQueryParams
);
if (FloorResult.IsWalkableFloor())
{
// ★ 检测成功:找到可行走的地面!
// 判断标准:
// 1. 碰撞点距离 < MAX_FLOOR_DIST (通常 2cm,防止浮空)
// 2. 碰撞面法线 Z 分量 > WalkableFloorZ (默认 0.7 ≈ 45°坡度以内)
return;
}
// ════════════════════════════════════════
// 第二部分:栖息检测(Step 2)--- 边缘容错
// ════════════════════════════════════════
// 如果 Step1 失败了(比如站在悬崖极边缘),再做一次更精细的检测
// 这次缩小胶囊体半径,寻找细小的支撑边缘
// 这就是为什么角色站在台阶边缘不会立即掉落,而是有一点点"容错空间"
if (bCheckForPerchResult)
{
ComputePerchResult(
CapsuleLocation,
FloorResult,
CollisionQueryParams
);
}
}
地面判定两个核心条件(必须同时满足才算"有地面"):
| 条件 | 参数名 | 默认值 | 含义 |
|---|---|---|---|
| 距离条件 | MAX_FLOOR_DIST |
2.4 cm | 脚底到碰撞点的垂直距离必须小于此值 |
| 坡度条件 | WalkableFloorZ |
0.7 (≈45.57°) | 碰撞面法线的向上分量必须大于此值 |
地面判定示意图:
[Capsule 胶囊体]
│
│ ← FloorDist (到碰撞面的距离)
▼
───────────── ← 地面/台阶表面
╱ ↑
╱ │ 法线 Normal
╱ │ Normal.Z > 0.7?
╱坡度角度
Normal.Z = cos(坡度角度)
坡度=45° → Normal.Z = 0.707 ✓ 可行走
坡度=60° → Normal.Z = 0.500 ✗ 太陡了!→ 可能滑落或判定为不可行走
完整的状态切换时序图
以玩家的一次 "奔跑 → 走下悬崖 → 空中下落 → 落地" 为例,展示完整的切换时序:
时间轴 →
帧 N-5, N-4, ..., N-1: 正常在地面上奔跑
┌─────────────────────────────────────────────────────────┐
│ MovementMode = MOVE_Walking │
│ StartNewPhysics → PhysWalking() 每帧执行: │
│ CalcVelocity() → 有输入W → 加速到500cm/s │
│ MoveAlongFloor() → 向前移动 │
│ FindFloor() → ✓ 检测到有效地面 │
│ 结果: 保持 MOVE_Walking │
└─────────────────────────────────────────────────────────┘
│
角色走到悬崖边缘!
│
▼
帧 N: 最后一寸地面,即将踏空
┌─────────────────────────────────────────────────────────┐
│ MovementMode = MOVE_Walking │
│ PhysWalking(): │
│ MoveAlongFloor() → 向前移动(走出最后一点地面) │
│ FindFloor() → ✗ 未检测到有效地面! │
│ │
│ ┌─ CheckFall() / StartFailing() 触发 ─┐ │
│ │ 保留当前水平速度 (Velocity.XY 不变) │ │
│ │ SetMovementMode(MOVE_Falling) ← ★ │ │
│ │ OnMovementModeChanged() 执行: │ │
│ │ CurrentFloor.Clear() │ │
│ └──────────────────────────────────────┘ │
│ 结果: 本帧内切换为 MOVE_Falling │
└─────────────────────────────────────────────────────────┘
│
帧 N+1 ~ N+45: 空中下落阶段(假设下落了约45帧=0.75秒)
┌─────────────────────────────────────────────────────────┐
│ MovementMode = MOVE_Falling │
│ StartNewPhysics → PhysFalling() 每帧执行: │
│ ApplyGravity(): V.Z -= 980 × 0.01667 ≈ -16.3 cm/s² │
│ CalcVelocity(): AirControl=0.35 有限转向 │
│ SafeMoveUpdatedComponent(): 向下+向前移动 │
│ FindFloor(): 还没碰到地面 → 保持 FALLING │
│ │
│ 速度变化示例: │
│ 帧 N+1: V.Z = -16 cm/s (刚开始下落) │
│ 帧 N+20: V.Z = -330 cm/s (加速中) │
│ 帧 N+35: V.Z = -577 cm/s (接近终端速度) │
│ 帧 N+45: V.Z = -740 cm/s (碰到地面前一刻) │
└─────────────────────────────────────────────────────────┘
│
脚底碰到地面!
│
▼
帧 N+46: 落地瞬间
┌─────────────────────────────────────────────────────────┐
│ MovementMode = MOVE_Falling (还没变!) │
│ PhysFalling(): │
│ ApplyGravity() │
│ CalcVelocity() │
│ SafeMoveUpdatedComponent() │
│ → 发生碰撞! Hit.bBlockingHit == true │
│ │
│ ┌─ ProcessLanded() 触发 ──────────────────┐ │
│ │ ShouldCheckForValidLandingSpot()? ✓ │ │
│ │ FindFloor()? ✓ 有效地面! │ │
│ │ Velocity.Z = 0 (垂直速度归零) │ │
│ │ SetMovementMode(MOVE_Walking) ← ★ │ │
│ │ OnMovementModeChanged() 执行: │ │
│ │ FindFloor() 再次确认地面 │ │
│ │ ResetFallState() 清理下落数据 │ │
│ │ Landed Delegate 广播! (落地音效等) │ │
│ └────────────────────────────────────────┘ │
│ 结果: 本帧内切回 MOVE_Walking │
└─────────────────────────────────────────────────────────┘
│
帧 N+47 之后: 继续在地面上正常奔跑...
状态切换涉及的辅助函数速查
以下是在状态切换过程中会被调用的关键辅助函数:
| 函数名 | 作用 | 被调用时机 |
|---|---|---|
SetMovementMode() |
状态切换入口,更新 MovementMode 变量 | 所有切换的必经之路 |
OnMovementModeChanged() |
状态变更后的善后/初始化 | 被 SetMovementMode 调用 |
FindFloor() |
检测脚下是否有有效地面 | PhysWalking每帧末尾 / PhysFalling碰撞后 |
ComputeFloorDist() |
计算到地面的精确距离(Sweep+Line) | 被 FindFloor 调用 |
StartFalling() |
开始下落(设置初始下落参数) | Walking→Falling 切换时 |
CheckFall() |
检查是否应该开始下落 | PhysWalking 地面检测失败后 |
ProcessLanded() |
落地处理(归零V.Z+切回Walking) | PhysFalling 检测到落地时 |
ResetFallState() |
重置下落相关的临时状态 | OnMovementModeChanged 进入 Walking/Falling 时 |
ShouldCatchAir() |
判断高速冲出平台是否该"接住空气" | PhysWalking 边缘特殊处理 |
HandleWalkingOffLedge() |
处理走下悬崖的额外逻辑(如播放动画) | 确认要掉落时 |
与其他系统的联动
状态切换从来不是孤立发生的,它会联动多个系统:
SetMovementMode(MOVE_Falling) 被调用
│
├── CharacterMovementComponent 内部
│ ├── MovementMode 变量更新
│ ├── OnMovementModeChanged()
│ │ ├── CurrentFloor.Clear() (清空地面数据)
│ │ └── ResetFallState() (重置下落数据)
│ └── OnMovementModeChangedDelegate.Broadcast()
│
├── 动画系统 (监听 Delegate 或读取 IsFalling 属性)
│ └── AnimBP 状态机: Idle/Run → Jump_Loop (切换动画!)
│
├── 音效系统 (蓝图绑定事件)
│ └── 停止脚步声 → 播放风声/呼啸声
│
└── 物理参数变更
├── 重力开始生效 (Falling模式下)
├── 摩擦力变为空气阻力
└── MaxSpeed 变为 MaxFlySpeed (如果配置了)
交叉引用:
- 状态切换后
StartNewPhysics会根据新的MovementMode分发到不同的Phys*函数,详见 [3.3 PerformMovement 阶段E](#3.3 PerformMovement 阶段E)DoJump()是主动触发 Walking→Falling 切换的典型例子,详见 [3.7 DoJump ------ 跳跃实现](#3.7 DoJump —— 跳跃实现)FindFloor()的结果直接影响PhysWalking的地面维持判断,详见 [3.5 PhysWalking ------ 行走物理实现](#3.5 PhysWalking —— 行走物理实现)PhysFalling中的落地检测是 Falling→Walking 切换的主要来源
3.5 PhysWalking ------ 行走物理实现
如果CalcVelocity()负责"算速度",那PhysWalking()就负责**"把速度变成实际位移,同时处理碰撞"**。它是角色在地面上行走时的完整物理模拟循环。
这个函数之所以用 while 循环而不是单次计算,是因为角色可能在一次移动中碰到墙壁。碰到墙后不能直接卡住,而要沿着墙面滑动。所以一帧内可能迭代多次。下面逐步拆解:
前置准备与主循环入口
cpp
void UCharacterMovementComponent::PhysWalking(float deltaTime, int32 Iterations)
{
if (deltaTime < MIN_TICK_TIME) return; // 时间太短不处理
bJustTeleported = false;
float remainingTime = deltaTime; // 剩余时间
子步骤1: 调用 CalcVelocity 计算当前速度
每轮循环先调用 CalcVelocity(详见 [3.6 CalcVelocity](#3.6 CalcVelocity)),根据是否有输入来加速或减速,得出这一小段时间内的速度值。
cpp
while ((remainingTime >= MIN_TICK_TIME) && (Iterations < MaxSimulationIterations))
{
CalcVelocity(subTime, WalkingFriction, false, BrakingDecelerationWalking);
子步骤2: 速度 → 位移
最简单的物理公式:位移 = 速度 × 时间。这个 Delta 向量就是这轮迭代要让角色移动的距离。
cpp
FVector Delta = Velocity * subTime;
子步骤3: Sweep碰撞检测 + 墙面滑动
这是关键一步!SafeMoveUpdatedComponent 会用一个隐形的胶囊体沿着 Delta 路径做扫描检测(Sweep)。如果路上没有障碍物就直接移动;如果碰到了墙壁:
HandleImpact()触发碰撞事件(播放撞击音效等)SlideAlongSurface()让角色沿着墙面"滑过去"而不是卡住
cpp
if (!SafeMoveUpdatedComponent(Delta, NewQuat, true, Hit))
{
// 碰到东西了!尝试沿墙面滑动
HandleImpact(Hit, subTime, Delta);
SlideAlongSurface(Delta, 1.f - Hit.Time, Hit.Normal, Hit, false);
}
子步骤4: 地面检测(防掉落)
最后做一次地面检测------确认角色脚下还有地板。如果走到悬崖边缘、脚下了空了,就会在下一帧切换到 MOVE_Falling 模式。
cpp
FindFloor(UpdatedComponent->GetPosition(), CurrentFloor, false);
remainingTime -= subTime;
Iterations++;
}
}
行走物理流程图:
┌─────────────────────────────────────────────────────────────┐
│ PhysWalking 单帧执行流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ [开始] │
│ │ │
│ ▼ │
│ CalcVelocity() │
│ ├─ 有输入? → 加速: V += Accel × dt │
│ └─ 无输入? → 制动: V -= Brake × dt │
│ │ │
│ ▼ │
│ Delta = Velocity × dt │
│ │ │
│ ▼ │
│ SafeMoveUpdatedComponent(Delta) ← 碰撞检测(Sweep) │
│ │ │
│ ├─ 无碰撞 → 直接移动到新位置 │
│ │ │
│ └─ 有碰撞! │
│ ├─ HandleImpact() 处理碰撞事件(触发音效等) │
│ └─ SlideAlongSurface() 沿墙面滑动 │
│ │ │
│ ▼ │
│ [角色贴墙滑行,而不是卡住不动] │
│ │ │
│ ▼ │
│ FindFloor() 地面检测 │
│ │ │
│ ├─ 还在地面上 → 保持 MOVE_Walking │
│ └─ 不在地面了 → 切换到 MOVE_Falling │
│ │ │
│ ▼ │
│ [结束] → 等待下一帧 │
│ │
└─────────────────────────────────────────────────────────────┘
3.6 CalcVelocity ------ 速度计算核心 ⭐
这是决定角色加速和减速感觉 的最核心函数,每帧被PhysWalking调用。它做的事情可以概括为两个分支:有输入时加速,无输入时制动(减速)。
下面逐步拆解:
前置:获取参数
cpp
void UCharacterMovementComponent::CalcVelocity(
float DeltaTime,
float Friction,
bool bFluid,
float BrakingDeceleration)
{
const float MaxAccel = GetMaxAcceleration(); // 最大加速度
float MaxSpeed = GetMaxSpeed(); // 最大速度 (500 cm/s)
// 判断是否有输入
const bool bZeroAcceleration = Acceleration.IsZero();
分支一:制动逻辑(无输入或超速时)
当玩家松开方向键、或者速度超过了最大值限制时,进入制动分支。实际的制动计算由独立的 ApplyVelocityBraking() 函数完成,它采用摩擦力衰减 + 线性减速的复合模型:
- 摩擦力衰减 (指数型):
V *= (1 - Friction × dt),让速度按比例递减 - 线性减速 (BrakingDeceleration):
V -= direction × BrakingDecel × dt - 防过冲 :如果速度方向反转了(
Velocity · OldVelocity <= 0),直接归零
cpp
if (bZeroAcceleration || bVelocityOverMaxSpeed)
{
// 实际调用 ApplyVelocityBraking(DeltaTime, Friction, BrakingDeceleration)
// 内部实现(简化版):
// Step 1: 摩擦力衰减 --- V *= FMath::Max(0, 1 - Friction * DeltaTime)
// Step 2: 线性制动 --- V -= normalize(V) * BrakingDeceleration * DeltaTime
// Step 3: 防过冲检查 --- if (V · OldV <= 0) V = Zero
ApplyVelocityBraking(DeltaTime, Friction, BrakingDeceleration);
}
分支二:加速逻辑(有输入时)
当玩家按着方向键时,把输入方向转成加速度叠加到当前速度上。关键两步:
- 先用
GetClampedToMaxSize把加速度限制在MaxAccel以内 - 再用
GetClampedToMaxSize确保最终速度不超过MaxSpeed
cpp
else
{
// 将输入方向映射为加速度
// 限制加速度不超过最大值
if (Acceleration.SizeSquared() > 0.f)
{
Acceleration = Acceleration.GetClampedToMaxSize(MaxAccel);
}
// 应用加速度到速度: V = V + A × dt
Velocity = Velocity + Acceleration * DeltaTime;
// 限制速度不超过最大值
Velocity = Velocity.GetClampedToMaxSize(MaxSpeed);
}
}
速度变化的数学模型:
【加速阶段】(有输入时)
Velocity(t+dt) = Velocity(t) + Acceleration × dt
受限于: |Velocity| ≤ MaxWalkSpeed (500 cm/s)
示例: 从静止到最大速
t=0.00s: Velocity = (0, 0, 0)
t=0.05s: Velocity = (0, 200, 0) ← 假设Accel=4000
t=0.10s: Velocity = (0, 400, 0)
t=0.125s: Velocity = (0, 500, 0) ← 达到最大速度!加速时间仅0.125秒
【制动阶段】(松开输入时,由 ApplyVelocityBraking 处理)
// 摩擦力衰减: Velocity *= Max(0, 1 - Friction * dt) ← 指数型,先快后慢
// 线性制动: Velocity -= normalize(Velocity) × BrakingDeceleration × dt
// 两者叠加作用,实际减速比纯线性更快
示例: 从最大速到停止
t=0.00s: Velocity = (0, 500, 0), 速度=500
t=0.10s: Velocity = (0, 300, 0), 速度=300 (减少200)
t=0.20s: Velocity = (0, 100, 0), 速度=100
t=0.25s: Velocity = (0, 0, 0), 完全停止!制动时间0.25秒
→ 这就是为什么设置 BrakingDecelerationWalking=2000 会给玩家"响应灵敏"的感觉
3.7 DoJump ------ 跳跃实现
这是让角色从地面腾空而起的那一瞬间发生的事情 。整个函数的核心只有一行代码:Velocity.Z = JumpZVelocity------给角色一个向上的初速度,之后重力会慢慢把它拉下来。
下面逐步拆解:
安全检查与前置条件
cpp
bool UCharacterMovementComponent::DoJump(bool bReplayingMoves)
{
if (CharacterOwner && CharacterOwner->CanJump())
{
if (!bConstrainToPlane || FMath::Abs(PlaneConstraintNormal.Z) != 1.f)
{
先确认角色存在、允许跳跃、没有被锁定在水平面上。
核心:设置向上的初速度 ⭐
cpp
if (HasCustomGravity())
{
// 自定义重力方向(太空/特殊重力环境)
Velocity = RotateGravityToWorld(...); // 省略细节
}
else
{
这就是跳跃的全部秘密------一行代码:
cpp
Velocity.Z = FMath::Max(Velocity.Z, JumpZVelocity);
把垂直速度设为700cm/s(我们在构造函数中配置的 JumpZVelocity)。使用 Max() 而不是直接赋值,是为了防止降低已有的向上速度(比如从高处落下过程中又按了跳)。
切换模式并返回
cpp
}
SetMovementMode(MOVE_Falling); // 切换到"下落模式"
return true; // 跳跃成功
}
}
return false; // 跳跃失败
}
切换到 MOVE_Falling 后,后续的物理系统就会按照空中物理来处理这个角色------受重力影响自然下落。
跳跃的物理轨迹:
初始条件:
- Velocity.Z = 700 cm/s (来自DoJump)
- GravityZ = -980 cm/s² (UE默认重力)
抛物线运动方程:
Height(t) = V₀t + ½gt²
= 700t - 490t²
时间点分析:
t=0.000s: Z速度=+700, 高度=0, 状态=[起跳瞬间]
t=0.357s: Z速度=+350, 高度=+187.5cm, 状态=[上升中,一半时间]
t=0.714s: Z速度= +0, 高度=+250cm, 状态=[最高点!] ★
t=1.429s: Z速度=-700, 高度=0, 状态=[落回原高度]
最大跳跃高度 = V₀²/(2g) = 700²/(2×980) = 250cm = 2.5米
空中控制(AirControl=0.35):
- 水平方向保持35%的控制力
- 可以微调落地位置,但不如地面灵活
3.8 bOrientRotationToMovement ------ 面向移动方向
在 [3.1 构造时的参数配置](#3.1 构造时的参数配置) 中,我们已经介绍了
bOrientRotationToMovement = true和RotationRate = 500°/s这两个配置项。这里补充说明其内部工作原理。
在每帧 PerformMovement 的旋转处理阶段([3.3 阶段F](#3.3 阶段F)),系统会执行以下逻辑:
cpp
/*
* 工作原理(概念性描述):
* 1. 计算当前速度的水平方向
* 2. 如果速度足够大(超过某个阈值)
* 3. 计算目标朝向 = Atan2(Velocity.Y, Velocity.X)
* 4. 插值当前朝向到目标朝向,速度限制为RotationRate
*
* 结果:角色总是"面向移动方向",像真实的走路一样
*/
旋转也遵循优先级体系:动画根运动旋转 > 程序化根运动旋转 > bOrientRotationToMovement 自动朝向 > 物理旋转。
4. 相机系统:SpringArm的智慧
有关于相机系统的,具体可以看这篇文章:
https://iwiki.woa.com/p/4020157974
5. 动画系统的协同工作
5.1 动画蓝图的结构(蓝图分析)
由于动画蓝图是以 .uasset 二进制形式存储的,其核心逻辑存在于蓝图中。根据UE5.3第三人称模板的标准配置,动画系统的工作方式如下:
动画蓝图的典型结构:
ABP_ThirdPersonCharacter (Animation Blueprint)
│
├── EventGraph (事件图表)
│ ├── Event Blueprint Post Init (初始化)
│ ├── Event Tick (每帧更新)
│ │ └── 设置 Speed 变量 ← 从CharacterMovement获取
│ └── Event Blueprint Update Animation (动画更新)
│
├── AnimGraph (动画图表)
│ ├── State Machine (状态机)
│ │ ├── Idle/Run State (待机/奔跑状态)
│ │ │ ├── Idle Anim (待机动画)
│ │ │ ├── Run Anim (跑步动画)
│ │ │ └── Rule: Speed > 150 ? Run : Idle
│ │ │
│ │ ├── Jump Start State (起跳状态)
│ │ │ └── Jump_Start_Anim (起跳动画)
│ │ │ └── Rule: IsFalling && WasOnGround?
│ │ │
│ │ ├── Jump Loop State (空中状态)
│ │ │ └── Jump_Loop_Anim (空中循环动画)
│ │ │ └── Rule: IsFalling && !WasOnGround?
│ │ │
│ │ └── Jump End State (落地状态)
│ │ └── Jump_End_Anim (落地动画)
│ │ └── Rule: Is Falling == False && WasFalling
│ │
│ └── Final Pose (最终姿势输出)
│
└── Variables (变量)
├── Speed (float) - 当前移动速度
├── Direction (float) - 移动方向角度
├── IsFalling (bool) - 是否在空中
└── IsCrouching (bool) - 是否蹲下
5.2 C++与动画的数据桥梁
虽然动画逻辑主要在蓝图中,但C++提供了关键的运行时数据:
动画系统需要"知道角色当前的状态"才能播放正确的动画。这些信息全部来自C++端的CharacterMovementComponent。
从 C++ 获取关键属性
cpp
// 1. Velocity - 当前速度向量
FVector CurrentVelocity = GetCharacterMovement()->Velocity;
float Speed = CurrentVelocity.Size2D(); // 水平速度(判断Idle/Run)
Speed = 0 → 播放 Idle 动画;Speed > 150 → 播放 Run 动画。
cpp
// 2. IsFalling - 是否在空中
bool InAir = GetCharacterMovement()->IsFalling();
用来切换跳跃/落地动画状态机。
cpp
// 3. acceleration - 当前加速度(计算转向BlendSpace参数)
FVector CurrentAccel = GetCharacterMovement()->GetCurrentAcceleration();
float Direction = CalculateDirection(CurrentAccel, GetActorRotation());
// 4. MovementMode - 当前移动模式
EMovementMode Mode = GetCharacterMovement()->MovementMode;
// MOVE_Walking, MOVE_Falling, MOVE_Swimming 等
C++是数据的提供者,动画蓝图是数据的消费者,两者通过这些只读属性形成单向数据流。
5.3 动画如何影响移动(Root Motion)
Root Motion 的内部实现原理已在 [3.3 PerformMovement 阶段D](#3.3 PerformMovement 阶段D) 中详细分析。这里从动画系统的角度做补充说明。
除了"代码驱动动画"(物理引擎移动角色,动画配合播放),UE还支持反向的 Root Motion(根骨骼运动) 模式------动画本身记录了每一帧根骨骼的位移,然后反过来驱动角色的物理位置。
这种模式适用于需要精确控制移动轨迹的场景:
- 攀爬时手点和脚点必须精确对齐
- 受击时击退距离必须和动作匹配
- 开门/推箱子等交互动作需要动画和位移严格同步
cpp
/** 启用Root Motion时,动画本身决定移动 */
UPROPERTY(Category="Character", VisibleAnywhere, BlueprintReadOnly)
TObjectPtr<UCharacterMovementComponent> CharacterMotion;
/*
* 当动画包含Root Motion时:
* - AnimSequence记录了根骨骼每帧的世界空间位移
* - CharacterMovementComponent不再用物理计算位置
* - 而是直接应用动画中的位移
*/
5.4 AnimInstance 与 Character 的通信
这里展示的是C++主动向动画系统推送数据的一种常见做法。
C++ 端:暴露变量 + Tick 中更新
cpp
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Movement")
float MovementSpeed; // 动画蓝图可直接读取
void AThirdPerson5_3Character::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
MovementSpeed = GetVelocity().Size2D(); // 每帧更新水平速度
}
动画蓝图端:用 Speed 驱动 BlendSpace
Speed == 0→ Idle_Anim0 < Speed < 150→ Walk_Anim (Blendspace混合)Speed >= 150→ Run_Anim (Blendspace混合)
"C++计算、动画消费"的模式是UE中动画集成的最常用范式。
6. 完整数据流:一帧的生命周期
让我们把所有内容整合起来,看看一个完整的游戏帧中发生了什么:
┌─────────────────────────────────────────────────────────────────────────────┐
│ UE5.3 第三人称角色 - 单帧完整生命周期 │
│ Frame #N at Time T (16.67ms @60FPS) │
└─────────────────────────────────────────────────────────────────────────────┘
═══════════════════════════════════════════════════════════════════════════════
阶段1: 操作系统输入采集 (OS Layer)
═══════════════════════════════════════════════════════════════════════════════
[玩家动作]: 按住W键 + 鼠标微微右移
│
▼
OS报告: Key_W=Pressed, Mouse_Delta_X=+3pixels
═══════════════════════════════════════════════════════════════════════════════
阶段2: Enhanced Input处理 (Input System)
═══════════════════════════════════════════════════════════════════════════════
IMC_Default 映射上下文处理:
│
├── W键持续按下 → MoveAction.Triggered(Value=(0, 1.0))
│ │
│ ▼
│ 回调: AThirdPerson5_3Character::Move((0, 1.0))
│ │
│ ├── Controller.Yaw = 45° (假设当前视角)
│ ├── ForwardDir = (0.707, 0.707, 0) [东北方向]
│ ├── RightDir = (-0.707, 0.707, 0)
│ ├── AddMovementInput(ForwardDir, 1.0) ← 累积!
│ └── AddMovementInput(RightDir, 0)
│
└── 鼠标右移 → LookAction.Triggered(Value=(3.0, 0))
│
▼
回调: AThirdPerson5_3Character::Look((3.0, 0))
│
├── AddControllerYawInput(3.0 * Scale)
└── Controller.Yaw += ~0.5° (现在=45.5°)
═══════════════════════════════════════════════════════════════════════════════
阶段3: CharacterMovementComponent.TickComponent → PerformMovement (Physics Core)
═══════════════════════════════════════════════════════════════════════════════
UCharacterMovementComponent::TickComponent(DT=0.01667s)
│
├── [Step 1] InputVector = ConsumeInputVector()
│ 返回: (0.707, 0.707, 0) [Move累积的结果]
│
├── [Step 2] 检查有效性 ✓
│
└── [Step 3] ControlledCharacterMove() ★ 调度层
│
├── CheckJumpInput() [跳跃按住检测]
├── ScaleInputAcceleration() [输入→加速度转换]
│
▼
PerformMovement(DT=0.01667s) ★★★ 物理执行总入口
│
├─ [A] 保存 OldLocation/OldVelocity + ScopedMovementUpdate
├─ [B] MaybeUpdateBasedMotion(平台跟随) + ApplyAccumulatedForces
├─ [C] 准备Acceleration向量, HasRootMotion? NO → 跳过根运动处理
├─ [D] StartNewPhysics(MOVE_Walking) → PhysWalking()
│ │
│ ├── CalcVelocity() → 加速+限速 → Velocity=(354,354,0)
│ ├── SafeMoveUpdatedComponent(Delta=(5.9,5.9,0))
│ │ → Sweep检测无障碍 ✓ → 新位置更新
│ └── FindFloor() → 还在地面 ✓
│
├─ [F] bOrientRotationToMovement → Yaw插值到45°
└─ [G] OnMovementUpdated 广播
═══════════════════════════════════════════════════════════════════════════════
阶段4: SpringArm.TickComponent (Camera Update)
═══════════════════════════════════════════════════════════════════════════════
USpringArmComponent::TickComponent(DT=0.01667s)
│
└── UpdateDesiredArmLocation(bTrace=true, bLocLag=false, bRotLag=false, DT)
│
├── GetTargetRotation()
│ └── bUsePawnControlRotation=true
│ └── 返回 Controller.Rotation (Pitch=-10, Yaw=45.5, Roll=0)
│
├── ArmOrigin = 角色位置 + Offset
│
├── DesiredLoc = ArmOrigin - Rot.Forward * 400cm
│ 相机在角色后4米处
│
├── Sweep碰撞检测 (角色→相机位置)
│ └── 无碰撞 → 使用DesiredLoc
│
└── UpdateChildTransforms()
└── FollowCamera位置/旋转更新
═══════════════════════════════════════════════════════════════════════════════
阶段5: SkeletalMeshComponent.Update (Animation)
═══════════════════════════════════════════════════════════════════════════════
USkeletalMeshComponent::TickComponent()
│
└── AnimInstance->UpdateAnimation(DT)
│
├── 读取变量:
│ Speed = |Velocity| = 500 cm/s
│ Direction = 相对角度
│ IsFalling = false
│
├── 状态机评估:
│ └── Idle/Run State
│ └── Speed > 150? → Run_Anim (Blendspace: 100%)
│
└── Pose evaluation + Bone transforms
└── Mesh渲染到GPU
═══════════════════════════════════════════════════════════════════════════════
阶段6: 渲染输出 (Render Frame)
═══════════════════════════════════════════════════════════════════════════════
[SceneRenderer]
│
├── FollowCamera 视锥体裁剪
├── Mesh (播放跑动动画的角色模型)
├── Lighting & Shadows
└── Present to Screen (BackBuffer)
▼
【显示器输出】: 玩家看到角色向东北方向跑动,相机平稳跟随在身后4米处
═══════════════════════════════════════════════════════════════════════════════
【帧结束】
等待下一个 16.67ms (60 FPS)
═══════════════════════════════════════════════════════════════════════════════
总结:各模块职责分工
| 模块 | 类名 | 核心职责 | 源码量 |
|---|---|---|---|
| 输入层 | EnhancedInput + AThirdPerson5_3Character |
将原始输入转换为移动意图和视角旋转请求 | ~130行 |
| 移动层 | UCharacterMovementComponent |
物理模拟、速度计算、碰撞响应、状态管理 | 13208行 |
| 相机层 | USpringArmComponent + UCameraComponent |
跟随角色、避障、平滑、旋转传递 | ~310行 |
| 表现层 | USkeletalMeshComponent + AnimBP |
播放动画、视觉反馈 | 蓝图为主 |
关键设计思想
- 关注点分离:每个模块只负责自己的领域,通过清晰的接口通信
- 数据驱动:大量参数可在编辑器调整,无需重编译
- 物理正确性:移动不是简单的位置叠加,而是完整的物理模拟
- 平滑体验:插值、滞后、子步骤等技术消除抖动和突兀感
- 可扩展性:继承基类可自定义任何行为(自定义移动模式等)
扩展阅读:如何自定义
修改移动手感:
调整加速度和制动
cpp
GetCharacterMovement()->MaxAcceleration = 5000.f; // 更大加速度 → 启动更迅猛
GetCharacterMovement()->BrakingDecelerationWalking = 4000.f; // 急停 → 适合竞技游戏
调高跳跃
cpp
GetCharacterMovement()->JumpZVelocity = 900.f; // 跳得更高(原700→900cm/s)
动态修改速度实现冲刺
所有这些参数都可以在运行时动态修改,不需要重启游戏:
cpp
void Sprint() { GetCharacterMovement()->MaxWalkSpeed = 800.f; } // 冲刺: 800cm/s
void StopSprint() { GetCharacterMovement()->MaxWalkSpeed = 500.f; } // 正常: 500cm/s
自定义相机行为:
(1) 鼠标滚轮缩放(Zoom)
动态修改 TargetArmLength 来拉近或拉远相机。用 FMath::Clamp 限制在合理范围内------太近会穿模,太远看不清角色。
cpp
void Zoom(float Value)
{
float NewLength = CameraBoom->TargetArmLength - Value * 50;
CameraBoom->TargetArmLength = FMath::Clamp(NewLength, 200.f, 800.f);
}
(2) 开启相机滞后平滑
默认情况下SpringArm是"刚性跟随"的(角色停相机立刻停)。开启 bEnableCameraLag 后相机会有轻微的延迟跟随效果,CameraLagSpeed 值越小延迟越明显。这种平滑效果可以让快速转向时的画面不那么晃动。
cpp
CameraBoom->bEnableCameraLag = true;
CameraBoom->CameraLagSpeed = 5.f; // 值越小越滞后
本文档基于以下源码文件分析:
- 项目源码:
Source/ThirdPerson5_3/ThirdPerson5_3Character.h/.cpp- UE5.3引擎:
Engine/Source/Runtime/Engine/Classes/GameFramework/Character.h(1041行)- UE5.3引擎:
Engine/Source/Runtime/Engine/Private/Character.cpp(~1700行)- UE5.3引擎:
Engine/Source/Runtime/Engine/Classes/GameFramework/CharacterMovementComponent.h(3222行)- UE5.3引擎:
Engine/Source/Runtime/Engine/Private/Components/CharacterMovementComponent.cpp(13208行)- UE5.3引擎:
Engine/Source/Runtime/Engine/Classes/GameFramework/SpringArmComponent.h(179行)- UE5.3引擎:
Engine/Source/Runtime/Engine/Private/GameFramework/SpringArmComponent.cpp(309行)