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:⭐⭐⭐ 碰撞检测------防穿墙
从 ArmOrigin 到 DesiredLoc 做一次球体扫描,遇到障碍就把相机拉到碰撞点:
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 原生第三人称相机的设计思路非常直觉:
- 弹簧臂挂在角色身上,像一根有弹性的自拍杆;
- 鼠标控制弹簧臂旋转,相机绕角色转;
- 碰撞检测防穿墙,碰到墙就把相机拉近;
- 延迟平滑让相机不会死板地贴着角色跑。
这套方案足够简单、开箱即用,适合绝大多数第三人称游戏的原型开发。当需求变复杂(三轴独立延迟、动画曲线驱动、半透管理等),才需要像 Lyra 或 NRC 那样自己扩展。