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行)
相关推荐
HAPPY酷2 小时前
解决 Unreal Engine 编译报错 MSB4018:三个核心排查方向
游戏引擎·虚幻
晴夏。6 小时前
UE原生MovementBase实现分析
游戏引擎·ue·3c
天人合一peng7 小时前
Unity工程发布hololens需安装, MRTK安装
unity·游戏引擎·hololens
weixin_409383128 小时前
godot 调用class方法得用实例 不能用脚本引用
游戏引擎·godot
风酥糖8 小时前
Godot游戏练习01-第32节-国际化
游戏·游戏引擎·godot
魔士于安8 小时前
Unity类似博物馆场景
前端·unity·游戏引擎·贴图·模型
小拉达不是臭老鼠9 小时前
Unity数据持久化_XML
学习·unity
RReality9 小时前
【Unity Shader URP】模板遮罩 / 传送门 实战教程
ui·unity·游戏引擎·图形渲染·材质
HAPPY酷9 小时前
UE5 C++ 避坑指南:暴力移除 Electronic Nodes 插件,回归纯净开发
开发语言·c++·ue5