给Character换一个自己的CMC
目录
前言
上一篇把「从输入到角色移动」的链路走了一遍,这篇是接着往下做的东西:写一个自己的 Character,再给它换上一个自己的 CMC(CharacterMovementComponent),用 CMC 子类做一个冲刺。
其中最想记下来的是「父类的 CMC 子对象替换」那一段,也就是为什么换 CMC 的那一行代码,只能写在构造函数的初始化列表里。
引擎版本同样是 5.6.1 源码版,工程依旧是第三人称模板改的。
先写一个自己的 Character
向前声明与 #include
写 Character 之前,先聊一下 C++ 本身的东西,因为 UE 的头文件里到处是向前声明。#include 是预处理阶段的文本复制。预处理器把 Foo.h 的全部内容原地展开到你写 #include “Foo.h” 的那一行,然后编译器才看到代码。所以拿到的是完整类型(complete type),编译器知道这个类多大、有哪些成员、成员在什么偏移。向前声明只是告诉编译器「有这么个名字,它是个 class」,编译器此时拿到的是不完整类型(incomplete type),知道它存在,不知道它多大、里面有什么。那什么情况用向前声明,什么情况用 #include?指针和引用的大小是固定的(64 位平台上是 8 字节),编译器不需要知道它指向什么就能给它分配空间;其他一切都需要真正看到类的定义。
向前声明就够了的情况:
- 声明指针 AMyCharacter*、引用 AMyCharacter&
- 函数参数、返回值的声明
需要 #include 的情况:
- 按值声明成员 AMyCharacter Member
- 继承 class X : public AMyCharacter
- 调用成员 Ptr->Jump()
- sizeof(AMyCharacter)、new AMyCharacter
我自己的 GymCharacter.h 里就是这么写的:
// 向前声明类, 避免在头文件中包含过多的头文件, 以减少编译时间
class USpringArmComponent;
class UCameraComponent;
class UInputAction;
struct FInputActionValue;UE 官方的 C++ 编码规范里有一条:“Forward declarations are preferred to including headers.” 核心意思就是 .h 里尽量向前声明,.cpp 里才 #include。另外官方还有一份 IWYU(Include What You Use)文档,要求每个头文件自给自足,只 include 自己真正需要的。
这对于 UE 这种超大项目很重要,非常影响编译时间。一个头文件一旦改动,所有直接或间接 include 它的 .cpp 都要重新编译。
Character 代码实现
主要参考官方第三人称示例模板的 Character 类代码,实现自己的 C++ Character 类,做到可以 WASD 移动以及鼠标控制相机。
头文件里声明输入动作和对应的处理函数:
// 移动,鼠标旋转输入动作IA的声明
UPROPERTY(EditAnywhere, Category = "Input")
UInputAction* MoveAction;
UPROPERTY(EditAnywhere, Category = "Input")
UInputAction* MouseLookAction;
// 重写 SetupPlayerInputComponent 函数,绑定输入动作到对应的函数
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
/**
* @brief 角色移动,处理character的输入
* @param Value 只读的输入值,包含了移动方向和速度等信息
*/
void Move(const FInputActionValue& Value);
void Look(const FInputActionValue& Value);绑定 EIC 的方法参考官方示例,这里只放相机旋转 Look 的实现:
/**
* @brief 相机旋转
* @param Value 只读的输入值,包含了旋转方向和速度等信息
*/
void AGymCharacter::Look(const FInputActionValue& Value)
{
// 将输入值转换为 FVector2D 类型,表示鼠标或手柄的旋转输入
const FVector2D V = Value.Get<FVector2D>();
// 继承自 ACharacter 的 AddControllerYawInput 和 AddControllerPitchInput 函数
// 用于将X与Y轴的输入值应用到角色的控制器上,实现相机的旋转
AddControllerYawInput(V.X);
AddControllerPitchInput(V.Y);
}C++ 写完之后是蓝图配置:用这个 C++ 类创建蓝图子类,在蓝图里配置好对应的 IA;再在 GameMode 蓝图类中配置 PlayerControllerClass、Default Pawn Class。
PlayerController 与 GameMode
上面配 GameMode 的时候出现了 PlayerController,顺便理一下这两个东西。
目前可以理解为,PlayerController 是玩家本人在 UE 里的代表:我自己是 PlayerController,而我操控的是 Character。
在 UE 中,玩家被分成了两层:
APlayerController ← 玩家。看哪儿、按了什么键、UI 归它
↓ possess
APawn / ACharacter ← 身体。有胶囊、有网格体、会移动理由很简单,玩家只有一个,而玩家可以操控的对象不止一个。
PlayerController 通常用来做什么:
- 相机管理 PlayerCameraManager,相机的配置、震屏、抖动等。PlayerCameraManager 归 PlayerController 管;角色身上挂的 CameraComponent,是被它拿来用的。
- 需要时屏蔽玩家的移动输入,Controller 上有 SetIgnoreMoveInput 可以做这件事。
- 控制台命令的自定义声明等。
GameMode 简单地说,作用就是定义游戏规则,包括用什么 Pawn 类、用什么 PlayerController、玩家出生点、胜利条件、玩家游玩人数等。
在多人游戏的情况下,GameMode 只存在于服务器上(规则由服务器裁定)。PlayerController 则是服务器上有每个玩家的一份,而每个客户端只有自己的那一个。
Demo 项目结构
整个 Demo 的结构大致如下:
关卡 World Settings
└── GameMode Override = BP_GymGameMode
│ (父类 AGymGameMode,C++ 里只写了 DefaultPawnClass 兜底)
├── PlayerControllerClass = BP_ThirdPersonPlayerController
│ └── 注册 IMC_Default + IMC_MouseLook ← 键位在这儿
└── DefaultPawnClass = BP_GymCharacter
└── 挂 IA_Move / IA_MouseLook / IA_Jump / IA_Crouch / IA_Sprint ← 绑定在这儿
└── AGymCharacter (C++):组件 + 输入处理实现GameMode 的 C++ 那一行兜底长这样:
AGymGameMode::AGymGameMode()
{
DefaultPawnClass = AGymCharacter::StaticClass();
}CMC 子类:冲刺
Character 能跑能转视角之后,就该动移动本身了。这一部分用 CMC 子类实现一个按住 Shift 冲刺。
改成员 vs override
之前我做过一版冲刺,是直接修改 CMC 的 MaxWalkSpeed 成员实现的;这次改成靠重写 GetMaxSpeed() 函数来实现。
两种做法有什么不同?改成员是「写状态」,override 是「读时计算」,其余差别全从这里派生:
- 真相有几份 —— 改成员要自己存一份原值,于是有两份;override 不动 MaxWalkSpeed,配置只有一份。
- 要不要回滚 —— 改成员必须覆盖所有「改回来」的路径,漏一条就永久坏;override 没有回滚这回事,每帧重算,坏一帧自愈。
- 会不会和别人踩 —— 改成员是全局可写的一个数,谁最后写谁赢;override 是把「怎么算」收进一个函数,冲刺和蹲伏谁优先由你在里面明确写死。
- 能不能联网 —— 改成员产生的是重放不出来的隐藏状态;override 的输入只有意图 bit,服务器拿到这 1 bit 就能算出同一个值。前提是这个意图 bit 得先接进 CMC 的网络预测(SavedMove),我现在的版本还没接,后面会说。
引擎原版的 GetMaxSpeed() 本身就是「读时计算」,按移动模式返回不同的值:
float UCharacterMovementComponent::GetMaxSpeed() const
{
switch(MovementMode)
{
case MOVE_Walking:
case MOVE_NavWalking:
return IsCrouching() ? MaxWalkSpeedCrouched : MaxWalkSpeed;
case MOVE_Falling:
return MaxWalkSpeed;
case MOVE_Swimming:
return MaxSwimSpeed;
case MOVE_Flying:
return MaxFlySpeed;
case MOVE_Custom:
return MaxCustomMovementSpeed;
case MOVE_None:
default:
return 0.f;
}
}所以如果冲刺是靠改 MaxWalkSpeed,蹲着的时候这个值根本没人读,按这段代码推,蹲着按冲刺就是没反应,而且不会有任何报错。
GymCharacterMovementComponent
先说这个 CMC 子类的职责:不需要重新实现整个角色 3C 系统,只需要在原有 CMC 逻辑上加上冲刺相关的逻辑。
先在编辑器里按照向导,创建继承自 UE 自带 CMC 的 C++ 类,这里将这个 CMC 子类取名为 GymCharacterMovementComponent。

需要写的代码有这些。新增:
- 一个冲刺的 bool 意图
- 一个冲刺判定函数
- 冲刺状态下的加速度和最大速度
重写:
- GetMaxSpeed,增加冲刺状态下返回的速度
- GetMaxAcceleration,同上,返回加速度
头文件:
UCLASS()
class MYDEMO_API UGymCharacterMovementComponent : public UCharacterMovementComponent
{
GENERATED_BODY()
public:
// 判断玩家是否处于冲刺状态
bool IsSprinting() const;
// 玩家想不想冲刺的flag
UPROPERTY(BlueprintReadOnly, Category = "Sprint")
bool bWantsToSprint = false;
// 重写GetMaxSpeed函数,冲刺状态时返回冲刺速度
virtual float GetMaxSpeed() const override;
// 重写GetMaxAcceleration函数,冲刺状态时返回冲刺加速度
virtual float GetMaxAcceleration() const override;
protected:
// 玩家冲刺的速度和加速度,bp里面可以调整,分类叫做Sprint
UPROPERTY(EditAnywhere, Category = "Sprint")
float SprintSpeed = 1000.f;
// 取名对照引擎的命名规范,速度是Speed,加速度是Acceleration
UPROPERTY(EditAnywhere, Category = "Sprint")
float SprintAcceleration = 4096.f;
};CPP:
bool UGymCharacterMovementComponent::IsSprinting() const
{
// 只有在平地行走时,bWantsToSprint为true,才允许冲刺,蹲下、空中、爬墙等状态下不允许冲刺
return bWantsToSprint && MovementMode == MOVE_Walking && !IsCrouching();
}
float UGymCharacterMovementComponent::GetMaxSpeed() const
{
// 如果玩家想要冲刺,就返回冲刺速度,否则返回默认的最大速度
if(IsSprinting()) return SprintSpeed;
return Super::GetMaxSpeed();
}
float UGymCharacterMovementComponent::GetMaxAcceleration() const
{
// 玩家处于冲刺状态时才返回冲刺加速度,冲刺状态的判定与GetMaxSpeed方法一致
if (IsSprinting()) return SprintAcceleration;
return Super::GetMaxAcceleration();
}两个 override 都是在引擎原版外面套一层:冲刺时返回自己的值,其余情况交还给 Super::。引擎原版的 GetMaxAcceleration() 只有一行 return MaxAcceleration;。
SprintAcceleration 为什么是 4096?直线加速时,CalcVelocity 里修正方向的那一项不改变速度大小,所以从静止加速到上限的时间就是「速度差 ÷ 加速度」。引擎默认走路是 600 / 2048 ≈ 0.29 秒;冲刺如果不改加速度,就是 1000 / 2048 ≈ 0.49 秒,起步会显得拖沓。按同样的比例放大是 3413,我取了 4096,也就是 0.24 秒,我的手感偏好起步更快一点。
Character 侧按键接入
CMC 那边准备好了,Character 这边要做的就是把按键接上。需要写的代码有这些,新增:
- 冲刺的 IA
- 冲刺开始/结束的方法
- 在 SetupPlayerInputComponent 里绑定 Started / Completed,分别对应冲刺开始/结束的方法
头文件:
UPROPERTY(EditAnywhere, Category = "Input")
UInputAction* SprintAction;
void StartSprint();
void StopSprint();CPP,绑定:
// Sprint按下触发,松开停止触发
EIC->BindAction(SprintAction, ETriggerEvent::Started, this, &AGymCharacter::StartSprint);
EIC->BindAction(SprintAction, ETriggerEvent::Completed, this, &AGymCharacter::StopSprint);绑到的两个函数,各自只做一件事,就是改意图:
void AGymCharacter::StartSprint()
{
UGymCharacterMovementComponent* GymCMC = Cast<UGymCharacterMovementComponent>(GetCharacterMovement());
if (GymCMC) GymCMC->bWantsToSprint = true;
}
void AGymCharacter::StopSprint()
{
UGymCharacterMovementComponent* GymCMC = Cast<UGymCharacterMovementComponent>(GetCharacterMovement());
if (GymCMC) GymCMC->bWantsToSprint = false;
}这里要 Cast,是因为 GetCharacterMovement() 返回的是基类 UCharacterMovementComponent* 指针。
职责梳理
这次可以明确看到两者的职责:
Character
│
├── 接收玩家输入
│
└── 告诉 CMC:
"玩家想干什么"
↓
↓
CMC
│
├── 判断当前状态
├── 决定是否允许行为
├── 计算移动参数
│
└── 执行实际移动实测
PIE 里打开 p.VisualizeMovement 1,角色头顶会显示移动调试信息,其中 Velocity 那一行带着当前的 Max(最大速度)。
- 平地按住 Shift:Max 从 600 跳到 1000。
- 蹲着按 Shift:不冲刺,IsSprinting() 里的 !IsCrouching() 生效。
- 冲刺中按 Ctrl 蹲下:掉到 300(引擎默认蹲伏速度是 MaxWalkSpeed * 0.5);松开 Ctrl,自动恢复到 1000,不需要重按 Shift。这就是前面说的「每帧重算,坏一帧自愈」。
- 按住 Shift 起跳:Max 显示 600,但水平速度保持在 1000。
最后一条一开始我以为会被削速:离地后 IsSprinting() 因为 MOVE_Walking 不成立而变成 false,GetMaxSpeed() 回落到 600,速度应该被拉回去才对。实测不是这样,翻了下源码,空中有两处相关的逻辑:
- 超速确实会进 CalcVelocity 的制动分支,但空中传进去的摩擦是 FallingLateralFriction,制动减速度是 BrakingDecelerationFalling,引擎默认都是 0(CharacterMovementComponent.cpp:724、:729)。ApplyVelocityBraking 开头就判断了摩擦和制动都是 0 的话直接 return,什么都不做。
- 已经超速时,CalcVelocity 夹速度的目标是「当前速度」而不是 MaxSpeed(:3841),它只阻止继续加速,不会往下拉。
const float NewMaxInputSpeed = IsExceedingMaxSpeed(MaxInputSpeed) ? Velocity.Size() : MaxInputSpeed;所以空中超速是没有纠正机制的,冲起来的速度会被带进空中。
落地之后:如果还按着 Shift,Walking 下 IsSprinting() 又成立,上限回到 1000;如果空中已经松开,落地上限是 600,速度 1000,超速就会走地面的制动参数(BrakingDecelerationWalking 默认等于 MaxAcceleration 即 2048、GroundFriction 8、BrakingFrictionFactor 2),把速度压回 600。还按着方向键的话,:3818 那段会保证制动不会把速度压到 600 以下。
MOVE_Walking 这个门槛,只决定「空中能不能重新加速到 1000」,不影响「已经冲起来的速度带进空中」。
父类的 CMC 子对象替换
上面一直默认 GetCharacterMovement() 拿到的就是 GymCMC,但 CMC 是 ACharacter 自己创建的,怎么让它创建的是我的子类?这是这篇最想记的部分。
C++ 构造函数的两个阶段
C++ 构造函数可以粗略理解成两个阶段:初始化阶段 + 构造函数体执行阶段。这属于 C++ 的对象构造机制,并非 UE C++ 独有。
比如有这样一段代码:
AMyCharacter::AMyCharacter()
: Super()
, SomeMember(123)
{
SomeMember = 456;
}可以理解为:
第一阶段:初始化阶段
↓
构造父类
构造成员变量
↓
第二阶段:执行构造函数体
↓
执行 { } 里的代码那么初始化列表就是冒号后面的这一整段,它负责在进入构造函数体之前,调用父类构造函数和初始化成员变量:
Super()
SomeMember(123)如果派生类构造函数的初始化列表没有显式指定父类,那么父类会被默认初始化,这意味着会调用父类的无参构造函数;若父类没有无参构造函数,则会编译报错。
为什么需要这种机制?先从一个对象是怎么被创建的说起。假设我们有一个类 AGymCharacter,并且创建对应的对象:
class MYDEMO_API AGymCharacter : public ACharacter {}
AGymCharacter* Character = ...那么此时实际的创建顺序是:
创建 AGymCharacter
↓
构造 UObject
↓
构造 AActor
↓
构造 APawn
↓
构造 ACharacter 此时会创建UE默认的CMC
↓
构造 AGymCharacter
↓
完成也就是说,父类永远先构造(析构则相反,子类先析构),每一层父类在执行自己的函数体之前,又必须先把自己的父类初始化完成。
ACharacter 的构造函数里,创建 CMC 的就是这一行:
CharacterMovement = CreateDefaultSubobject<UCharacterMovementComponent>(ACharacter::CharacterMovementComponentName);所以当进入 AGymCharacter 的构造函数体时,ACharacter 已经构造完成,而 CMC 也已经在 ACharacter 的构造过程中创建完成,此时再想把 UE 的 CMC 换成自己的 GymCMC,已经太晚了。
Subobject 子对象
「CMC 是 ACharacter 的默认子对象」,这句话怎么理解?注意这里说的是默认子对象(Default Subobject),与普通的「子类」概念不同。
父类子类之间的关系是「继承」,比如说:ACharacter is-a APawn。而子对象的概念是「拥有」关系,比如说:ACharacter has-a CharacterMovementComponent。
以 ACharacter 举例子,可以把 ACharacter 想成一个「大对象」,它里面拥有、管理着一些属于自己的小对象,这些小对象就是它的 Subobject(子对象):
ACharacter
├── CapsuleComponent
├── Mesh
├── CharacterMovement
└── ...理解了子对象的概念,默认子对象也好理解了:ACharacter 需要拥有 CharacterMovement 类型的子对象,而构造 ACharacter 的时候,默认创建的那个 CharacterMovement 类型的子对象就是 CMC,所以 CMC 就是 ACharacter 的默认子对象。
所以当 AGymCharacter 构建的时候,它会直接继承 ACharacter 所拥有的 CMC。更准确的描述是:AGymCharacter 继承了 ACharacter,因此 ACharacter 那部分对象状态也属于这个 AGymCharacter 对象,其中包括由 ACharacter 创建和拥有的 CMC。
FObjectInitializer
因此我们需要一个机制,在 AGymCharacter 的构造过程中,提前告诉父类 ACharacter:等会儿创建 CMC 默认子对象的时候,要用 GymCMC 这个类。
注意,并不是将已经创建好的 CMC 替换掉,而是在 ACharacter 创建它的默认子对象 CMC 之前,就告诉它这次用另一个类来创建。
FObjectInitializer 可以把它理解成:UE 在「创建一个 UObject 对象」的过程中,随身带着的一份「构造配置单」,它会告诉 UE 这次创建对象的时候,各种默认子对象该怎么创建。
具体做法是,在构造函数的声明里加上 FObjectInitializer 类型的参数,换掉构造函数的签名;然后在该构造函数的初始化列表里,就可以调用指定的父类构造函数了。这样,在构造 AGymCharacter 的时候,能够拿到 FObjectInitializer 这张「预约单」,再通过初始化列表把它传给 ACharacter。
就能达到 ACharacter 创建子对象的时候,换成 GymCMC 这个类的效果!
// 头文件声明
AGymCharacter(const FObjectInitializer& ObjectInitializer);
// CPP定义
AGymCharacter::AGymCharacter(
const FObjectInitializer& ObjectInitializer
)
: Super(
ObjectInitializer.SetDefaultSubobjectClass<UGymCharacterMovementComponent>(
ACharacter::CharacterMovementComponentName
)
)
{
// ACharacter 会在自己的构造阶段创建 CMC,所以我要在 ACharacter 构造之前,通过 FObjectInitializer
// 提前预约:"这个 CMC 用我的子类来创建。"
// ......各种构造函数体代码
}ACharacter::CharacterMovementComponentName 是 ACharacter 里的一个静态 FName(值是 “CharMoveComp”)。ACharacter 创建 CMC 时用的也是这个名字,所以它能靠名字找到这次登记。
为什么只能在初始化列表里?
上面说的「已经太晚」,在源码里有更具体的答案。
SetDefaultSubobjectClass 做的事很简单,就是往配置单里登记一条:
/**
* Sets the class to use for a subobject defined in a base class, the class must be a subclass of the class used by the base class.
*/
const FObjectInitializer& SetDefaultSubobjectClass(FName SubobjectName, const UClass* Class) const
{
AssertIfSubobjectSetupIsNotAllowed(SubobjectName);
SubobjectOverrides.Add(SubobjectName, Class);
return *this;
}注释里也写了,换上去的类必须是父类原本那个类的子类,GymCMC 继承自 CMC,所以没问题。它返回的是配置单自己,所以「登记」和「传给 Super」可以写成同一个表达式。
关键在第一行那个检查。最底层的 UObject 构造函数一执行,就会把登记关掉(UObjectGlobals.cpp:3966):
const_cast<FObjectInitializer&>(ObjectInitializer).FinalizeSubobjectClassInitialization();关掉之后再调 SetDefaultSubobjectClass,AssertIfSubobjectSetupIsNotAllowed 会直接 Fatal(UObjectGlobals.cpp:4810),报错信息写得很直白:
Subobject class setup is only allowed in base class constructor call (in the initialization list)而 Super(…) 括号里的那个表达式,是在调用父类构造函数之前就求值的,那时连 UObject 都还没开始构造,登记还开着。所以它是唯一来得及的地方。到了 AGymCharacter 的函数体里再调,就不只是「太晚了没效果」,而是直接崩溃。
整体逻辑梳理
C++继承
↓
父类必须先构造
↓
初始化列表可以指定父类怎么构造
↓
ACharacter 构造过程中创建 Default Subobject
↓
其中有 CharacterMovementComponent
↓
我想让它使用 GymCMC
↓
不能等 ACharacter 构造完再换
↓
必须提前准备 FObjectInitializer
↓
SetDefaultSubobjectClass()
↓
通过 : Super(...) 传给 ACharacter
↓
ACharacter 创建 CMC 时
使用 GymCMC换完之后,在 BP_GymCharacter 的组件树里点 CharacterMovement,就能看到它的类型已经变成了 GymCharacterMovementComponent。

意图与联网
服务器的概念
前面「能不能联网」那条提到了意图 bit,这里展开一下。目前可以先把服务器理解成一个「裁判」。
比方说,现在有两个东西:
bWantsToSprint = false
SprintSpeed = 1000bWantsToSprint 是一个意图 bit。引擎里同类的意图,比如蹲伏的 bWantsToCrouch,走的是 CMC 的那套 SavedMove / CompressedFlags 体系。
在联网游戏里,通常传意图比传结果更合适,原因如下。
原因 1:服务器也需要进行一次计算。假设本地客户端直接告诉服务器「我当前正在冲刺,速度 1000」,服务器没法验证这是否合法,因为这是结果,而服务器不知道客户端发生了什么才导致了这个结果;但如果客户端告诉服务器「我按了 Shift,想要冲刺」,服务器就可以根据规则计算当前能不能冲刺,可以的话确切的冲刺速度是多少。
原因 2:客户端需要进行一次预测。假设现在网络延迟 100ms,按 Shift 后要等服务器的模拟结果返回才冲刺,实际游玩体验就是按了 Shift 后等了 100ms 屏幕里的角色才动起来,会非常卡手。所以 UE 的 CMC 会做一种预测:「我先猜服务器大概也会同意我这么做」,先让客户端的角色立刻冲刺,然后再根据服务器的权威模拟结果进行对比,确认是否需要回滚或者纠正(网络同步 / 预测纠正)。
原因 3:可以被服务器重新模拟。并且省带宽,意图通常可以用少量 bit 表示,适合放入 SavedMove 的压缩标志中,在网络传输的时候占用的资源更小。
串起来就是:客户端 Character 收到输入后,先修改客户端 CMC 的意图;这个意图随后会被记录到 SavedMove 并发送给服务器,服务器收到后,再在服务器自己的 CMC 中恢复这个意图。
通常,客户端会立刻执行模拟,立刻冲刺;而服务器要经过短暂的网络延迟后才能收到这个意图,然后根据同样的输入以及游戏状态重新计算一遍。此时我们有两轮模拟结果,客户端上的是预测结果,服务器上的是权威结果。客户端预测不断向前模拟,服务器收到输入后进行权威模拟,客户端再根据服务器结果进行校正:如果两个结果相同,说明客户端预测没有问题,一般不会进行额外处理;若结果不同,客户端就可能修正自己的状态。很多情况都会导致两边结果不一致,比如网络延迟、客户端预测不对、两边状态有细微差异等。
CMC 网络预测(暂搁置)
这一部分接触到了意图标志和联网的一些概念,如果想让 bWantsToSprint 联网,还缺少一些东西。这一块涉及 CMC 网络预测,和当前的学习路线有偏差,暂时搁置,留下一些关键词,后续需要的时候再去捣鼓。
关键词:UE CMC 网络预测(Client Prediction / Server Authority / SavedMove / CompressedFlags)
概念:CMC 联网时需要保存每一帧的输入意图,让客户端预测、服务器权威模拟以及客户端纠正 / 重放能够使用同一帧的输入数据;以后实现自定义移动能力联网时,需要通过 SavedMove 和 CompressedFlags 扩展。
UE CMC 网络预测
├── Client Prediction(客户端预测)
├── Server Authority(服务器权威)
├── Server Reconciliation(服务器纠正)
├── Client Replay / Move Replay(客户端重放)
│
├── SavedMove / FSavedMove_Character
│ └── 保存每一帧的输入意图
│
├── CompressedFlags
│ └── 将输入意图压缩进移动数据
│
├── UpdateFromCompressedFlags
│ └── 服务器还原输入意图
│
├── FNetworkPredictionData_Client_Character
│ └── 客户端网络预测数据
│
└── Replication
└── 同步最终状态 / 结果从 QA 视角看 CMC 的网络测试
学到这一块的时候,我找 GPT 吐槽过一次:如果让我从 QA 的角度去测 CMC 的网络预测,会有多麻烦?
以前做联机功能测试,很多时候关注的是「状态有没有同步」。比如 A 玩家放了个技能,B 玩家有没有看到;A 玩家走到某个位置,B 玩家看到的位置对不对;断线重连之后状态有没有恢复。这类测试也会遇到网络问题,但思路相对直接:客户端发生了什么 → 服务器有没有收到 → 其他客户端有没有正确看到。
CMC 麻烦在预测。客户端先动,服务器拿同样的输入重新模拟一遍,再和客户端报上来的位置对比:差得不多就认,差太多就发纠正回去,客户端被拉回来以后,再把还没被确认的 Move 重放一遍。
这样一来,QA 要测的东西就开始变复杂了。而且「完全一致」本身就不是标准。服务器允许客户端和自己有一点误差,默认阈值是 MAXPOSITIONERRORSQUARED = 3.0(GameNetworkManager.cpp:27),也就是位置差在大约 1.7 厘米以内,服务器都不纠正。所以 QA 没法简单地说「有一点位置差就是 Bug」。
测试维度也跟着变多:延迟、丢包、网络抖动、客户端低帧率,再叠上高速移动。引擎在非 Shipping 包里提供了 NetEmulation.PktLag、NetEmulation.PktLoss 这类命令来构造网络条件,p.NetShowCorrections 1 可以把发生纠正的位置画出来。不过这些我只是在源码里找到了,还没真正跑过。
对高速移动的游戏,或者延迟极其敏感的 FPS 游戏来说,这个问题会更明显。普通走路的时候,客户端和服务器差了一点位置,玩家可能都注意不到;但如果角色正在高速滑铲、跳跃、滑墙,甚至正在做一个需要精确碰撞检测的攀越动作,这一点偏差就可能被放大成:
客户端:我撞到墙了,应该开始滑墙。
服务器:你没撞到。
客户端:???
服务器:给你修回来。玩家看到的,可能就是角色突然被拉回去、动作被打断,或者明显卡了一下。
而且,很多网络问题不是稳定复现的。「100ms 延迟下一定出问题」其实还好测,真正麻烦的是「80~120ms 延迟 + 偶发丢包 + 帧率波动 + 玩家高速移动」,然后偶尔出现一次奇怪的瞬移或者回弹。
我目前身处的这个项目组虽然也有联机,但是通常只测试双方状态有没有同步就可以了,这种情况,只能提一个P3 BUG(23333)。
像是这种问题,靠「输入 → 预期输出」那种逐条执行用例的点功能测法就很难发现问题了,思路会变成「构造一组网络条件 → 确认游戏表现是不是还满足一组标准」:预测合理、纠正合理、没有明显的穿模 / 瞬移 / 状态错乱。
拿我自己的冲刺举个例子。bWantsToSprint 没接进 SavedMove,服务器根本不知道我按了 Shift,按源码推,联机时客户端跑 1000、服务器只认 600,位置差超过阈值就会被拉回去(没实际联机跑过)。以前作为 QA,我大概只会记一条「联机按住冲刺,角色一卡一卡地被拉回去」;现在就可以接着往下问:是输入没进 SavedMove,还是两边 MovementMode 不一致,还是碰撞结果不同?
这和单纯知道「这里有个网络 Bug」,已经是不太一样的视角了。当然这一块我也只是稍微开始有了一点自己的思考,几乎没有实践。
一些小疑虑
写 Character 那会儿留下的几个问题,到现在还没完全想明白:
- BP_ThirdPersonPlayerController 的作用。现在知道 IMC 是在它身上注册的,但它别的还做了什么,我没细看。
- 部分实现细节。
- 为何一定要用蓝图子类配置资产?虽然我知道在蓝图里配置会方便一点,用来装 C++ 里赋不了值的资产引用。
- 还有就是上面暂搁置的网络预测:bWantsToSprint 要真正联网,得接进 SavedMove 和 CompressedFlags,这一套我还没动手写过。