UE原生第三人称相机源码分析

UE 原生第三人称相机实现解析

一句话概括

UE 原生的第三人称相机 = SpringArmComponent(弹簧臂)+ CameraComponent(相机组件),两者以父子关系挂在角色身上。弹簧臂负责算位置、做防穿墙,相机组件负责渲染画面。


一、整体架构

组件层级

复制代码
BP_ThirdPersonCharacter(角色蓝图)
  └── CapsuleComponent(胶囊体,根组件)
        └── SpringArmComponent(弹簧臂,挂在胶囊体上)
              └── CameraComponent(相机,挂在弹簧臂末端)

弹簧臂和相机通过父子层级挂载在角色身上:角色动 → 弹簧臂跟着动 → 相机跟着动

C++ 构造代码

在角色的构造函数中,这段代码搭建出第三人称相机的完整骨架:

cpp 复制代码
// 1. 创建弹簧臂并挂到根组件
CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
CameraBoom->SetupAttachment(RootComponent);

// 2. 关键配置
CameraBoom->TargetArmLength       = 400.0f;   // 相机距离角色 4 米
CameraBoom->bUsePawnControlRotation = true;   // ★ 让弹簧臂跟随 Controller 旋转

// 3. 把相机挂到弹簧臂末端的 Socket 上
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName);
FollowCamera->bUsePawnControlRotation = false;  // 相机不再二次旋转,完全受弹簧臂驱动

其中 bUsePawnControlRotation = true 是最关键的一行------它让弹簧臂每帧读取 Controller 的旋转,从而实现鼠标控制视角。

系统全局流程

复制代码
玩家鼠标/摇杆输入
    ↓
PlayerController 更新 ControlRotation(视角方向)
    ↓
SpringArmComponent 使用 ControlRotation 计算相机位置
    ↓
CameraComponent 的 Transform 被同步更新
    ↓
PlayerCameraManager::UpdateViewTarget 调用 CameraComponent::GetCameraView
    ↓
得到最终 POV(Location, Rotation, FOV)→ 渲染画面

二、SpringArm 工作原理

SpringArm 是第三人称相机的核心,所有位置计算都在 UpdateDesiredArmLocation 中完成。

2.1 每帧算法总览

复制代码
1. 确定旋转      ------ 读 ControlRotation,或组件自身旋转
2. 旋转延迟      ------ 可选,四元数插值平滑追踪
3. 计算 ArmOrigin ------ 组件世界位置 + TargetOffset
4. 位置延迟      ------ 可选,向量插值
5. 理想相机位置   ------ ArmOrigin + 旋转反方向 × TargetArmLength + SocketOffset
6. 碰撞检测      ------ 球体扫描,碰到就拉近
7. 更新子组件     ------ 把 CameraComponent 的 Transform 刷新

2.2 相机位置计算公式

复制代码
相机位置 = ArmOrigin                           ← 弹簧臂原点
         + 旋转方向 × (-TargetArmLength)        ← 沿视线反方向退后
         + SocketOffset                         ← 局部空间的末端偏移(过肩视角)

图解:

复制代码
        角色/ArmOrigin                        相机(理想位置)
              ●────── TargetArmLength ──────○
                                            ↕ SocketOffset(微调)
                                            ○ 相机(实际位置)

2.3 源码拆解

以下按 7 个步骤拆解 UpdateDesiredArmLocation 的实现。

Step 1:获取目标旋转(GetTargetRotation

这个函数是连接 Controller 与 SpringArm 的桥梁

cpp 复制代码
FRotator USpringArmComponent::GetTargetRotation() const
{
    FRotator DesiredRot = GetComponentRotation();  // 默认使用组件自身旋转

    // ★ 关键分支:是否跟随 Pawn 视角?
    if (bUsePawnControlRotation)
    {
        if (APawn* OwningPawn = Cast<APawn>(GetOwner()))
        {
            const FRotator PawnViewRotation = OwningPawn->GetViewRotation();
            if (DesiredRot != PawnViewRotation)
            {
                DesiredRot = PawnViewRotation;     // 用 Controller 的旋转
            }
        }
    }

    // 选择性轴继承:可以只继承 Yaw 不继承 Pitch 等
    if (!bInheritPitch) DesiredRot.Pitch = LocalRelativeRotation.Pitch;
    if (!bInheritYaw)   DesiredRot.Yaw   = LocalRelativeRotation.Yaw;
    if (!bInheritRoll)  DesiredRot.Roll  = LocalRelativeRotation.Roll;

    return DesiredRot;
}

简而言之,这个函数决定了"相机到底看向哪里"。

Step 2:旋转延迟(可选)

四元数插值平滑地追踪目标旋转,避免鼠标抖动造成相机抖动,也避免欧拉角插值的万向锁:

cpp 复制代码
if (bDoRotationLag)
{
    DesiredRot = FRotator(FMath::QInterpTo(
        FQuat(PreviousDesiredRot),
        FQuat(DesiredRot),
        DeltaTime,
        CameraRotationLagSpeed   // 插值速度,越大追得越紧
    ));
}

子步进(低帧率保护) :当 DeltaTime 过大时,把一帧的插值拆成多个小步,避免低帧率下的视觉跳跃:

cpp 复制代码
if (bUseCameraLagSubstepping && DeltaTime > CameraLagMaxTimeStep)
{
    float RemainingTime = DeltaTime;
    while (RemainingTime > KINDA_SMALL_NUMBER)
    {
        float LerpAmount = FMath::Min(CameraLagMaxTimeStep, RemainingTime);
        DesiredRot = FRotator(FMath::QInterpTo(..., LerpAmount, CameraRotationLagSpeed));
        RemainingTime -= LerpAmount;
    }
}
Step 3~4:计算臂原点 + 位置延迟
cpp 复制代码
FVector ArmOrigin  = GetComponentLocation() + TargetOffset;
FVector DesiredLoc = ArmOrigin;

if (bDoLocationLag)
{
    DesiredLoc = FMath::VInterpTo(
        PreviousDesiredLoc,
        DesiredLoc,
        DeltaTime,
        CameraLagSpeed
    );
}

注意 :位置延迟只有一个速度值,X/Y/Z 三个方向共用同一个速度,无法分别控制。这是许多自定义相机系统选择另起炉灶的主要原因之一。

Step 5:⭐ 沿视线反方向退后(相机为什么在角色后面)
cpp 复制代码
DesiredLoc -= DesiredRot.Vector() * TargetArmLength;

示意:

复制代码
    [Camera] ← DesiredLoc(最终相机位置)
       ↑
       │ TargetArmLength(400 cm)
       │
    [角色位置] ← ArmOrigin
Step 6:⭐⭐⭐ 碰撞检测------防穿墙

ArmOriginDesiredLoc 做一次球体扫描,遇到障碍就把相机拉到碰撞点:

复制代码
  ArmOrigin ●══════════════════════● DesiredLoc
                    ↑
              ProbeSize 半径的球沿这条线滑过去

没碰到:使用理想位置
碰到了:使用碰撞点,避免"穿墙看场景"
cpp 复制代码
if (bDoTrace && (TargetArmLength != 0.0f))
{
    FHitResult Result;
    GetWorld()->SweepSingleByChannel(
        Result,
        ArmOrigin,                                // 起点
        DesiredLoc,                               // 终点
        FQuat::Identity,
        ProbeChannel,                             // 默认 ECC_Camera
        FCollisionShape::MakeSphere(ProbeSize),   // 探测球,默认半径 12 cm
        QueryParams
    );

    if (Result.bBlockingHit)
    {
        // 碰到了:用 BlendLocations 平滑过渡到碰撞位置
        ResultLoc = BlendLocations(DesiredLoc, Result.Location, true, DeltaTime);
    }
    else
    {
        ResultLoc = DesiredLoc;
    }
}
Step 7:更新 Socket 变换 + 刷新子组件
cpp 复制代码
FTransform WorldCamTM(DesiredRot, ResultLoc);
FTransform RelCamTM = WorldCamTM.GetRelativeTransform(GetComponentTransform());

RelativeSocketLocation = RelCamTM.GetLocation();
RelativeSocketRotation = RelCamTM.GetRotation();

UpdateChildTransforms();   // FollowCamera 自动跟着更新

2.4 TickComponent ------ 每帧调度

SpringArm 的 Tick 只做一件事:调用上面那个复杂的 UpdateDesiredArmLocation

cpp 复制代码
void USpringArmComponent::TickComponent(
    float DeltaTime,
    ELevelTick TickType,
    FActorComponentTickFunction* ThisTickFunction)
{
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

    UpdateDesiredArmLocation(
        bDoCollisionTest,           // 是否碰撞检测
        bEnableCameraLag,           // 是否位置滞后
        bEnableCameraRotationLag,   // 是否旋转滞后
        DeltaTime
    );
}

注意 PrimaryComponentTick.TickGroup = TG_PostPhysics------相机更新在物理计算之后执行,这样相机才能平滑地跟随已经完成物理模拟的角色位置。


三、核心参数详解

以下参数都可以在蓝图属性面板中调节。表格中默认值来自引擎构造函数,括号中是本项目改动后的值。

3.1 距离与偏移

参数 类型 默认值 作用
TargetArmLength float 300(项目 400) 弹簧臂长度,相机到角色的理想距离
TargetOffset FVector (0,0,0) 弹簧臂原点(起点)的世界空间偏移,通常向上偏移看肩膀
SocketOffset FVector (0,0,0) 相机(末端)的局部空间偏移,通常向右偏做过肩视角

3.2 旋转继承开关

参数 默认值 作用
bUsePawnControlRotation false(项目改为 true) 是否读取 Pawn 视角作为目标旋转
bInheritPitch true 是否继承俯仰角
bInheritYaw true 是否继承偏航角
bInheritRoll true 是否继承翻滚角

3.3 延迟平滑

参数 默认值 作用
bEnableCameraLag false 是否启用位置延迟
CameraLagSpeed 10.0 位置延迟速度(越大追得越快)
CameraLagMaxDistance 0 最大延迟距离,防止相机完全跟不上
bEnableCameraRotationLag false 是否启用旋转延迟
CameraRotationLagSpeed 10.0 旋转延迟速度
bUseCameraLagSubstepping true 低帧率时的子步进保护
CameraLagMaxTimeStep 1/60 子步进的单步最大时长

3.4 碰撞检测

参数 默认值 作用
bDoCollisionTest true 碰撞检测总开关
ProbeSize 12.0 碰撞检测球体半径(固定值)
ProbeChannel ECC_Camera 碰撞检测通道

四、PlayerCameraManager 的角色

PlayerCameraManager 是 UE 相机系统的总调度,每帧做如下工作:

复制代码
1. 初始化 POV 默认值(FOV、投影模式等)

2. 根据 ViewTarget 类型分发:
   ├── CameraActor              → 直接用它的 CameraComponent
   ├── 有 CameraComponent 的 Actor → 调 GetCameraView()
   └── 无 CameraComponent        → 用内置简单实现(或 CalcCamera)

3. 应用 CameraModifier(震屏、后处理等)

4. 同步 CameraManager 的位置/旋转到最终 POV

5. 更新镜头特效

可介入点

若要自定义相机,以下几个位置最常被使用:

函数 作用 典型使用者
UpdateViewTarget 相机更新主入口,重写可完全接管 UpdateViewTargetInternal
BlueprintUpdateCamera 蓝图可重写的相机更新 简单项目常用
CameraComponent::GetCameraView 自定义相机组件逻辑 Lyra 项目

五、快速搭建第三人称相机

步骤 1:创建组件层级

在角色蓝图中:

复制代码
添加 SpringArm   → 挂到 CapsuleComponent 下
添加 Camera      → 挂到 SpringArm 下

步骤 2:配置 SpringArm

复制代码
Target Arm Length           = 300        ← 相机距离
Socket Offset               = (0, 65, 0) ← 右偏 65,过肩视角
Target Offset               = (0, 0, 40) ← 上偏 40,看肩膀
Use Pawn Control Rotation   = ✓          ← 跟随鼠标旋转
Do Collision Test           = ✓          ← 防穿墙
Probe Size                  = 12         ← 碰撞球半径
Enable Camera Lag           = ✓          ← 位置延迟
Camera Lag Speed            = 10         ← 延迟速度

步骤 3:配置输入

在 PlayerController 或角色蓝图中:

cpp 复制代码
AddControllerYawInput(MouseX);     // 鼠标横向 → 偏航
AddControllerPitchInput(MouseY);   // 鼠标纵向 → 俯仰

步骤 4:运行效果

复制代码
鼠标转动 → ControlRotation 变化
  → SpringArm 跟着转 → Camera 绕角色旋转
角色移动 → SpringArm 原点跟着动
  → Camera Lag 平滑跟随 → 有"跟拍感"
靠近墙壁 → 碰撞检测触发
  → Camera 被拉近角色 → 不穿墙

六、一帧内的完整数据流

复制代码
                    ┌─────────────────────────────────────────┐
                    │         一帧内的相机更新流程              │
                    └─────────────────────────────────────────┘
                                     │
                                     ▼
                    [鼠标/摇杆输入] LookAxisVector
                                     │
                                     ▼
              AddControllerYaw/PitchInput(ControlRotation 改变)
                                     │
                                     ▼
                    SpringArm.TickComponent() 开始
                                     │
                    ┌────────────────┼────────────────┐
                    ▼                ▼                ▼
            GetTargetRotation   ArmOrigin 计算    位置滞后插值
            (读 Controller 旋转) (角色位置+Offset)  (VInterpTo)
                    │                │                │
                    └────────────────┼────────────────┘
                                     ▼
                DesiredLoc = Origin - Rot.Forward × 400cm
                                     │
                                     ▼
                    Sweep 碰撞检测(球形扫描)
                                     │
                    ┌────────────────┼────────────────┐
                    ▼                                 ▼
            [无碰撞]                              [有碰撞]
            使用 DesiredLoc                       使用碰撞点
                    │                                 │
                    └────────────────┬────────────────┘
                                     ▼
                            更新 Socket 位置
                                     │
                                     ▼
                        FollowCamera 自动跟随
                                     │
                                     ▼
                            【屏幕画面输出】

七、附录:与 Lyra 方案对比

UE 原生 SpringArm 胜在简单、开箱即用;当需求复杂化时,常见的两种扩展思路如下。

7.1 Lyra 的 CameraMode Stack

Epic 官方 Lyra 项目没有用 SpringArm,而是采用数据驱动的相机模式栈

复制代码
ULyraCameraComponent(自定义 CameraComponent)
  └── CameraModeStack(模式栈)
        ├── CameraMode_ThirdPerson(正常跟随)  ← 栈底
        ├── CameraMode_ADS(开镜瞄准)          ← 栈顶
        └── ... 通过 BlendWeight 混合过渡
  • 优点:不同状态是独立 CameraMode 类,互不影响;Push 新模式自动混合;完全数据驱动。
  • 缺点:实现复杂度高;碰撞检测等需要自己写。

八、总结

UE 原生第三人称相机的设计思路非常直觉:

  1. 弹簧臂挂在角色身上,像一根有弹性的自拍杆;
  2. 鼠标控制弹簧臂旋转,相机绕角色转;
  3. 碰撞检测防穿墙,碰到墙就把相机拉近;
  4. 延迟平滑让相机不会死板地贴着角色跑。

这套方案足够简单、开箱即用,适合绝大多数第三人称游戏的原型开发。当需求变复杂(三轴独立延迟、动画曲线驱动、半透管理等),才需要像 Lyra 或 NRC 那样自己扩展。

相关推荐
小熊Coding17 小时前
Python 龙与魔法回合制2D游戏
python·游戏·pygame
Lanren的编程日记1 天前
Flutter 鸿蒙应用游戏化元素实战:积分等级+成就解锁+排行榜,全方位提升用户粘性
flutter·游戏·华为·harmonyos
superior tigre2 天前
45 跳跃游戏2
算法·leetcode·游戏
CyL_Cly2 天前
杀戮尖塔2mod:二次元猎宝
windows·游戏
cxr8282 天前
从细胞到蜂群:基于康威生命游戏原理的多智能体编排
游戏
小辉同志2 天前
45. 跳跃游戏 II
c++·leetcode·游戏·贪心算法
开开心心就好2 天前
避免借电脑尴尬的故障模拟工具
科技·游戏·visualstudio·edge·pdf·电脑·powerpoint
郑寿昌2 天前
UE5中FBX材质丢失终极修复指南
ue5·材质
深念Y2 天前
王者荣耀与英雄联盟数值设计对比:穿透、乘算与加算、增伤乘算更厉害,减伤加算更厉害
数学·算法·游戏·建模·游戏策划·moba·数值