UE5第三人称模板实现及相关引擎源码分析

UE5.3 第三人称角色移动系统深度源码解析

基于 ThirdPerson5_3 模板 + UE5.3 引擎源码的完整3C系统分析


目录

  1. 系统架构总览
  2. 输入系统:从按键到动作
  3. 移动系统核心:CharacterMovementComponent
    • 3.1 构造时的参数配置
    • 3.2 TickComponent ------ 移动的心跳
    • 3.3 PerformMovement ------ 物理移动的真正执行者
    • 3.4 移动状态切换机制
    • 3.5 PhysWalking ------ 行走物理实现
    • 3.6 CalcVelocity ------ 速度计算核心
    • 3.7 DoJump ------ 跳跃实现
    • 3.8 bOrientRotationToMovement ------ 面向移动方向
  4. 相机系统:SpringArm的智慧
  5. 动画系统的协同工作
  6. 完整数据流:一帧的生命周期

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() 函数本身非常简洁 ------ 它并没有直接让角色跳起来,而是做了两件"记账"工作:

  1. 设置标志位 bPressedJump = true,告诉系统"玩家正在按住跳跃键"
  2. 重置按住时间计时器 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个阶段到底在干嘛?

想象你在指挥一个角色"走一步",需要按顺序做完这些事:

  1. 阶段A(准备):先拍个"快照"记下角色现在在哪、速度多少,万一等下算错了还能回滚;顺便打开"批处理"模式,省性能。
  2. 阶段B(收外力):看看有没有人往角色身上"施力"------被炸飞?被弹簧弹开?踩了传送带?踩了电梯?全部记到速度里。
  3. 阶段C(翻译输入):玩家按的 W/A/S/D 还只是"意图方向",这一步把它翻译成"加速度向量"(带大小和方向的物理量)------就是给意图配上"油门深度"。
  4. 阶段D(仲裁):问一句"这次到底谁说了算?"------如果动画在播根运动(爬墙、翻滚),动画优先;否则才用玩家输入算出来的东西。
  5. 阶段E(真干活) ⭐:这一步才真正让角色动起来。根据当前状态(走路/空中/游泳)调用不同物理函数,算出位移、做碰撞检测、处理墙面滑动、检测地面......
  6. 阶段F(转身) :角色走到了新位置,但身体可能还朝着旧方向------这一步让身体朝向跟上移动方向(bOrientRotationToMovement 生效的地方)。
  7. 阶段G(跟平台):如果脚下的电梯/船/旋转平台这一帧动了,再同步一次把角色"粘"在平台上,不让它被落下。
  8. 阶段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;  // 基底位置(站在平台上时有用)

为什么要保存 OldLocationOldVelocity?因为后续的物理计算可能导致碰撞、穿透等异常,需要用旧状态做回滚或修正。此外,网络同步也需要新旧状态的差值来计算预测误差。

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();

这三行分别处理三种不同的力:

  1. ApplyAccumulatedForces --- 持续性的外力,比如风扇吹拂、水流推动
  2. UpdateCharacterStateBeforeMovement --- 在物理计算前同步角色状态(比如是否蹲下影响碰撞体高度)
  3. 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) 来决定调用哪个物理函数(PhysWalkingPhysFalling 等)。但一个关键问题还没有回答: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_WalkingMOVE_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() 函数完成,它采用摩擦力衰减 + 线性减速的复合模型:

  1. 摩擦力衰减 (指数型):V *= (1 - Friction × dt),让速度按比例递减
  2. 线性减速 (BrakingDeceleration):V -= direction × BrakingDecel × dt
  3. 防过冲 :如果速度方向反转了(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);
    }

分支二:加速逻辑(有输入时)

当玩家按着方向键时,把输入方向转成加速度叠加到当前速度上。关键两步:

  1. 先用 GetClampedToMaxSize 把加速度限制在 MaxAccel 以内
  2. 再用 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 = trueRotationRate = 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_Anim
  • 0 < 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 播放动画、视觉反馈 蓝图为主

关键设计思想

  1. 关注点分离:每个模块只负责自己的领域,通过清晰的接口通信
  2. 数据驱动:大量参数可在编辑器调整,无需重编译
  3. 物理正确性:移动不是简单的位置叠加,而是完整的物理模拟
  4. 平滑体验:插值、滞后、子步骤等技术消除抖动和突兀感
  5. 可扩展性:继承基类可自定义任何行为(自定义移动模式等)

扩展阅读:如何自定义

修改移动手感:

调整加速度和制动

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行)
相关推荐
weixin_4242946712 小时前
Unity的测试Edit Mode和Play Mode,有什么区别?
unity
Python私教16 小时前
我用 AI 做出了第一个 Godot 贪吃蛇
人工智能·游戏引擎·godot
玖玥拾16 小时前
Unity 3D 笔记(八)ScrollRect 滚动视图、NavMesh 自动寻路系统
笔记·3d·unity
远离UE416 小时前
UE5 UV邻接 Niagara潜水模拟 学习笔记
ue5·uv
AA陈超1 天前
005 T03 — 角色基类与移动系统 详细功能设计文档
ue5
AA陈超1 天前
004 T02 - 俯视角摄像机系统 设计文档
网络·c++·ue5·虚幻引擎
淡海水2 天前
06-04-YooAsset源码-Unity加密解密服务
前端·unity·性能优化·c#·游戏引擎·yooasset
AA陈超2 天前
006 T03 — 蓝图操作指南
c++·游戏·架构·ue5·github·虚幻引擎