角色移动随笔

前言

角色移动相关的林林总总

QA

  • Q: 角色的Z方向是速度是哪里处理的?
  • A: MaintainHorizontalGroundVelocity, 而且不止调用了一次; Z速度比较复杂, 地面移动的Z速度不能通过重写这个函数来找回
  • Q:有没有处理撞墙(Hit)事件的逻辑
  • A:相比于监听Capsule的Hit回调事件, 移动逻辑里有一部分相关代码也是类似情形, 大致在这里

PerformMovement

角色移动的核心就是这里, 从这里展开

1.清理过期数据

image-20220428150346256

这个阶段判断是否处于不能移动或者物理状态, 如果是那么清理

如果条件允许同时处于RootMotion状态, 那么Tick一次Pose然后清理RootMotionParams

如果有激活的RootMotionSource(下简称RMS), 那么清理RMS数据

清理Force

2.处理RMS的LastPreAdditiveVelocity

1
2
const FVector Adjustment = (Velocity - LastUpdateVelocity);
CurrentRootMotion.LastPreAdditiveVelocity += Adjustment;

根据当前速度与上一帧的Velocity差, 补偿到LastPreAdditiveVelocity

3.MaybeUpdateBasedMovement

这里主要处理了如果角色站在了动态地面(即Moveable的模型)上的变化, 不过角色的速度不会得加动态物体的速度

4.清理过期RMS

image-20220428150414316

过期的RMS即被标记为MarkedForRemoval或者Finished的RMS, 在这一步清理掉

5.应用力

1
ApplyAccumulatedForces(DeltaSeconds);

我们对角色调用的AddImpulse等接口添加的力在这一步被换算成Velocity

如果有Z速度, Mode可能会被设置成Falling

6.UpdateCharacterStateBeforeMovement

这里刷新一次Crouch/Uncrouch状态

7.TryToLeaveNavWalking

如果是MOVE_NavWalking, 这里尝试离开这个状态

8.HandlePendingLaunch

处理LaunchCharacter产生的推力, 把推力换算成Velocity, 这里一定会把Mode改成Falling

9.清理所有力

1
ClearAccumulatedForces();

清理第5和第8步的力

10.再次处理RMS的LastPreAdditiveVelocity

1
2
const FVector Adjustment = (Velocity - OldVelocity);
CurrentRootMotion.LastPreAdditiveVelocity += Adjustment;

因为上面几步与第2步之间有力的作用, 所以这里通过前后Velocity的变化, 把这个插值记录到LastPreAdditiveVelocity

11.预处理RootMotion

image-20220425150948103

这里预处理了两种RootMotion, 包括动画RootMotion(下简称ARM)和RMS

如果是动画RootMotion, 那么这里会TickCharacterPose(DeltaSeconds);, 这个会产生RootMotion数据RootMotionParams,然后保存客户端的ClientRootMotionParams

如果是RMS, 这里会执行CurrentRootMotion.PrepareRootMotion(), 这个函数就是通过各种不同的RMS算法把速度记录到RootMotionParams

12.处理RootMotion

同11步, 同样处理了两者

12.1 ARM

image-20220425151154463

TickPose产生的RootMotionParams转换成世界空间, , 然后转化成Velocity

UE5插件MotionWarping监听的就是ConvertLocalRootMotionToWorld()当中的事件

我们知道, 如果保持正常的移动Mode, ARM的Z速度经常是没有的, 但是这里的Velocity还是有Z速度的, Z速度会在后面被处理掉

12.1.1 CalcAnimRootMotionVelocity

image-20220426104001900

把RootMotionParams中的偏移转换成速度

12.1.2 ConstrainAnimRootMotionVelocity

image-20220426104048929

如果处于Falling 那么速度用的是本身的速度, RootMotion中的Z速度会被替换

12.2 RMS

image-20220425151702742

这一步就是把把RMS中的数据换算成Velocity

13.清理Jump输入

image-20220425152534520

Jump是可以有HoldTime的, 这里就是处理这个

14.StartNewPhysics *

这里内容非常庞大, 也是移动输入的核心场所, 分条展开

14.1 PhysWalking *

正常的地面移动都在这里处理

14.1.1 RestorePreAdditiveRootMotionVelocity

如果RMS有有叠加的, 那么这一步会把第2和10步处理的LastPreAdditiveVelocity设置成Velocity

14.1.2 MaintainHorizontalGroundVelocity

image-20220425155829483

两种方式, 要么单纯把Z速度变成0但是总速度大小会变(其实后面还会补上来), 要么总速度大小不变的情况下把Z速度去掉

其实这一步本质上区别不大

14.1.3 CalcVelocity *

这个函数非常重要, 核心的速度计算都在这里.

进入这里有几个条件, 需要不是ARM或者RMS状态

14.1.3.1 计算RequestedMove

image-20220425160159695

先计算Requested速度, 这个是一个比较特殊的速度, 是作为额外一个叠加速度存在的, 通过下面2个UCharacterMovementComponent的接口设置(目前未暴露给蓝图)

1
2
virtual void RequestDirectMove(const FVector& MoveVelocity, bool bForceMaxSpeed) override;
virtual void RequestPathMove(const FVector& MoveInput) override;

image-20220425160850994

上图中有个需要注意的开关bRequestedMoveUseAcceleration, 这个在蓝图中是可编辑的

如果true那么之类会计算出来一个加速度, 等后续的时候加到速度上去, 否则就直接会把当前速度替换成RequestedVelocity

另外的细节可以看[RequestVelocity](#Request Velocity)

14.1.3.2 处理加速度

如果bForceMaxAccel为true, 那么在这里会把加速度大小强制设置成最大的

14.1.3.3 制动流程

这里会有几个判断, 制动流程的进入条件是(bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax

翻译一下是 没有加速度同时没有Requested速度, 或者是当前速度大于了最大速度. 后者比较容易被忽略

制动流程可以看↑制动流程

然后如果速度还是大于最大速度, 这里有个保护, 这里把速度大小设置成最大速度

14.1.3.4 阻力对速度的影响

image-20220425210056186

阻力参数Friction(需要区别BrakingFriction)对由加速度造成的速度变化是有影响的, 阻力越大, 加速和切换方向会慢

14.1.3.5 流体对加速的影响

image-20220426102131633

然后是处理流体速度的影响

14.1.3.6 输入对速度的加速

image-20220426102953748

如果有按键输入, 那么这里通过加速度对速度进行加速

14.1.3.7 处理Requested速度

image-20220426103052807

然后是处理前面产生的Request速度, 如果bRequestedMoveUseAcceleration为true, 那么这里会对速度进行一个增量, 如果为false, 那么这里可以忽略

14.1.3.8 处理RVO

涉及到RVO的内容比较多, 这里先不展开

14.1.4 ApplyRootMotionToVelocity

这个函数的字面意思跟实际用途略有差别, 这里主要处理的是RootMotion(包含ARM和RMS)+Falling状态下的DecayingFormerBaseVelocity的处理

DecayingFormerBaseVelocity会在站在动态地面上时才会有变换的速度值

14.1.5 处理如果是Fallinig

image-20220426105204619

这一步如果是Falling了, 那么重新执行StartNewPhysics()

14.1.6 MoveAlongFloor *

在地面上的移动处理, 这个函数也比较庞大, 返回一个脚步数据FStepDownResult, 这个数据包含了我们经常用的CurrentFloor

14.1.6.1 ComputeGroundMovementDelta

image-20220426114218973

这里处理的是斜坡上的速度, 传入的Delta是水平速度, 经过Dot计算得到了Z的速度, 这里也有bMaintainHorizontalGroundVelocity的判断, 但是返回值都是带Z速度的(目的是什么?)

14.1.6.2 SafeMoveUpdatedComponent

这个函数在很多地方都会调用, CharacterMovementComponent没有重写任何东西, 核心内容在UPrimitiveComponent::MoveComponentImpl()

里面一大堆的数据初始化, 最核心的就是下面这里

image-20220426143535492

之后就是调用物理接口了

所以就是利用前面算出来的delta速度, 对capsule进行一个短距离的Multi的Sweap操作,获取了Hit数据, 如果有必要就移动

如果bStartPenetrating(出生点跟其他物体有重叠/穿透), 那么会需要解决穿透问题, 这个就是角色如果出生在模型内然后被挤出模型的操作, 如果要特化这个操作可以重写ResolvePenetrationImpl()

14.1.6.3 处理穿透

image-20220426151944767

如果出生的时候与其他物体有穿透, 那么其实前面的SafeMoveUpdatedComponent()已经处理了这个问题, 一般走不进这里, 感觉这个是做了一层保护, 因为前面的SafeMoveUpdatedComponent是可以重写的

14.1.6.4 处理Hit事件

image-20220426161939931

撞墙和撞台阶都会走到这里, 需要判定是否能上台阶

bMaintainHorizontalGroundVelocity在这里又发挥了奇怪的作用, 在判断是否可以StepUp的过程中有查询地面数据, 该数据包含在FStepDownResult

14.1.7 再次处理Falling/Swimming

同14.1.5一样的处理, 经过上一步的处理以后如果进入了这两个状态, 那么就切换

14.1.8 记录或者查询Floor

image-20220426163301687

如果有step查询, 那么这里就已经有了floor数据, 否则就要通过FindFloor查询floor数据

14.1.8.1 FindFloor

这里通过了一个Sweap操作获取地面数据, 主要计算在ComputeFloorDist()中, 大部分内容可以看是否在空中判断

14.1.9 边缘检查

image-20220426164227611

移动组件有这么两个参数, 如果false, 那么角色不能从边缘通过走路掉下去(可以通过Jump)

image-20220426164321195

这里就限制了角色下落

如果可以下落, 那么接下来就是适配角色的高度, 因为角色的胶囊体底部是圆形的, 所以默认的就是保持胶囊体底部跟地面是接触的, 在边缘会出现这样的情况

image-20220426164600831

如果要自定义这个效果, 可以重写ShouldCatchAir()(这个默认直接false)和HandleWalkingOffLedge()做处理, 也可以重写AdjustFloorHeight()进行补充

14.1.9.1 Swimming检查

image-20220426165041282

可能到这一步从边缘掉进水里, 就进入Swimming逻辑

14.1.9.2 进入Falling

正常情况, 到这一步就进入了Falling, 然后直接return

14.1.10 最后处理地面速度

image-20220426165449693

到这里如果在地面上, 那么通过这时候的组件的位置与PhysWalking刚开始的位置偏差计算得到最终的Velocity

如果撞墙了, 那么这里的速度只有墙面切向方向的速度, 而且大小没有变化

比如Velocity(100,20,0)撞到了切线是Y方向的墙面, 这里的速度是Velocity(0,20,0)

15.UpdateCharacterStateAfterMovement

参考第6步, 也是更新了Crouch状态

16.PhysicsRotation

这里有几个前提

  1. bAllowPhysicsRotationDuringAnimRootMotion(默认关闭)或处于非RootMotion
  2. 需要开启bOrientRotationToMovement, 同时关闭bUseControllerDesiredRotation

其实就是小白人默认的移动模式就可以走到这里, 角色永远会面朝速度方向, 你转动镜头的时候角色通过一定速度转向速度方向

17.处理RootMotion的旋转

image-20220425153635384

前面第12步处理了ARM和RMS的Velocity, 这一步单独处理两者的旋转

发出疑问, 所以RMS是可以有旋转的, 虽然引擎自带的API和案例没有进行这个计算

18.OnMovementUpdated

虚函数, 无实现, 到这一步需要处理的事情可以重写这个函数实现

19.CallMovementUpdateDelegate

image-20220425153810696

虽然命名是Call***Delegate, 但是这里刷新了组件的速度

20.根据动态地面保存位置

image-20220425154100712

如果站在动态地面上, 那么这一步会更新对于动态地面的相对位置和旋转信息

21. UpdateComponentVelocity()

image-20220425154413751

所以, 为什么这里又刷新一次, 前面CallMovementUpdateDelegate中已经刷新了一次

22.处理若干网络数据

image-20220425154514338

23.保存旧数据

image-20220425154547787

把当前最终的数据保存为Last版本

AddMovementInput

image-20220228164034051

这个接口是玩家移动输入的入口

通过一个方向和一个缩放值计算得到是一个加速度方向, 我们看下面分析

image-20220228152835493

Tick中每一帧消耗掉上一次的输入信息并赋值给InputVector, 看下图, 主要内容就在这里面

image-20220228152955556

然后Tick流程继续, 在下面判定并执行了ControlledCharacterMove(InputVector, DeltaTime);

image-20220228153110566

这个函数把InputVector转变成加速度Acceleration(带长度的), 然后就进入大名鼎鼎的PerformMovement(), 一路走到

PhysWalking()中的CalcVelocity()函数中, 正常情况下会判定是否是制动流程, 如果不是就进入速度与摩擦力的计算

image-20220228155341035

上图可以看到关键计算代码, 跟参数FrictionDelta值有很大关系, 如果最终得到的值大于1, 那么速度瞬间改变, 否则就是做插值过渡, 那么就意味着GroundFriction参数越大, 移动速度改变越快

接下来会计算流体的速度影响, 这里跳过

然后再根据加速度和delta计算速度

image-20220228160245335

后面的是RequestedAcceleration和RVO的相关的计算

制动/减速

角色在两种情况下会执行制动流程

  1. 没有加速度,即没有移动输入
  2. 当前速度大于最大速度

减速相关的主要的配置属性如下

image-20220214154836007

  • bUseSeparateBrakingFriction: 如果为true, 那么单独设置阻力, 否则就使用GroundFriction或者其他外部的阻力
  • BrakingFrictionFactor: 阻力系数
  • BrakingFriction: 阻力, 计算的时候一般就是 BrakingFrictionFactor * BrakingFriction
  • BrakingSubStepTime: 计算阻力的时候分段模拟的时间, 会clamp在 1/75与1/20之间,数值小能更平滑, 一般也不用设置
  • BrakingDecelerationWalking: 移动制动力, 还有类似的有falling,flying等等

角色移动的减速流程就在下面ApplyVelocityBraking代码当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void UCharacterMovementComponent::ApplyVelocityBraking(float DeltaTime, float Friction, float BrakingDeceleration)
{
if (Velocity.IsZero() || !HasValidData() || HasAnimRootMotion() || DeltaTime < MIN_TICK_TIME)
{
return;
}
//缩放系数
const float FrictionFactor = FMath::Max(0.f, BrakingFrictionFactor);
//阻力 = 阻力 * 系数
Friction = FMath::Max(0.f, Friction * FrictionFactor);
//制动力
BrakingDeceleration = FMath::Max(0.f, BrakingDeceleration);
const bool bZeroFriction = (Friction == 0.f);
const bool bZeroBraking = (BrakingDeceleration == 0.f);

if (bZeroFriction && bZeroBraking)
{
return;
}

const FVector OldVel = Velocity;

// 细分制动以在较低的帧率下获得合理一致的结果
//(对于丢包的情况很重要)
float RemainingTime = DeltaTime;
const float MaxTimeStep = FMath::Clamp(BrakingSubStepTime, 1.0f / 75.0f, 1.0f / 20.0f);

// 减速到刹车停止
const FVector RevAccel = (bZeroBraking ? FVector::ZeroVector : (-BrakingDeceleration * Velocity.GetSafeNormal()));
while( RemainingTime >= MIN_TICK_TIME )
{
// 零摩擦使用恒定的减速,所以不需要迭代。
const float dt = ((RemainingTime > MaxTimeStep && !bZeroFriction) ? FMath::Min(MaxTimeStep, RemainingTime * 0.5f) : RemainingTime);
RemainingTime -= dt;

// 应用制动力和摩擦力
Velocity = Velocity + ((-Friction) * Velocity + RevAccel) * dt ;

// 保证不会反向
if ((Velocity | OldVel) <= 0.f)
{
Velocity = FVector::ZeroVector;
return;
}
}

// 如果接近零,或如果低于最小阈值和制动,速度也会设置为0
//BRAKE_TO_STOP_VELOCITY默认值为10.0
const float VSizeSq = Velocity.SizeSquared();
if (VSizeSq <= KINDA_SMALL_NUMBER || (!bZeroBraking && VSizeSq <= FMath::Square(BRAKE_TO_STOP_VELOCITY)))
{
Velocity = FVector::ZeroVector;
}
}

概括一下减速流程分2部分, 一个是阻力, 一个是制动力;

阻力是一个变速减速运动, 制动力是匀减速运动

所以如果把阻力设为0, 把制动力设置为当前速度一致, 那么得到的结果就是1秒内(会有大约一帧的误差)减速到0

Request Velocity

我们直接通过设置角色或者移动组件的速度是不符合UE设计的, 因为他有自己的一套计算流程, 比如下面这样

image-20220228163002143

事实上UE提供了一个接口, 让我们可以手动的设置速度(叠加速度)

1
virtual void RequestDirectMove(const FVector& MoveVelocity, bool bForceMaxSpeed) override;

在计算整体速度的时候会用到这个速度

image-20220228163214104

image-20220228163235918

上面图中可以看到, 在计算完普通的速度以后, 在最后叠加上了这个RequestVelocity, 同时也保证了总速度大小保持一致

是否在空中的判定

角色是否在空中的判定主要来自当前地面数据CurrentFloor, 通过CurrentFloor.IsWalkableFloor()来判定

满足条件下就开始执行 StartFalling()

那就需要看CurrentFloor是如何判断是否在地面的

从角色移动组件的UCharacterMovementComponent::ComputeFloorDist()开始, 里面有下图这一段

image-20220310165554207

可以理解, 角色胶囊体会进行基于形状的向下的sweap检测, 检测距离跟宽高差有关系, 意味着越细长这个检测距离越长, 距离范围是 20 + [0, 半高/10)

image-20220310171952535

一般情况下这样就够了, 但是有一些特殊情况还需要再进行sweap检测, 比如初始就有重叠等情况

还有就是遇到超过能走路的斜坡这种, 虽然block了但是不是walkable, 那就还需要进行一次射线检测来判断是否是站在地面上

胶囊体与地面的距离

image-20220401161819905

如上图所示, 我们从角色往下打射线检测到的地面的点 与我们角色脚底位置的点实际上是有偏差的, 这个偏差可以从移动组件里面获取到,就是下图这个东西

image-20220401161933973

这个距离怎么来的呢?

实际上就是角色在计算地面数据的sweap过程中产生的, 大概理解为sweap前后的距离差,具体代码在UCharacterMovementComponent::ComputeFloorDist()

大致如下

1
2
3
4
5
6
7
{
float ShrinkHeight = (PawnHalfHeight - PawnRadius) * (1.f - ShrinkScale);
float TraceDist = SweepDistance + ShrinkHeight;
//.................
const float MaxPenetrationAdjust = FMath::Max(MAX_FLOOR_DIST, PawnRadius);
const float SweepResult = FMath::Max(-MaxPenetrationAdjust, Hit.Time * TraceDist - ShrinkHeight);
}

这个SweepResult就是我们要的Dist

orm