角色移动随笔
前言
角色移动相关的林林总总
QA
- Q: 角色移动来自哪里
- A:
- 按键输入
- 物理力(Force)
- Launch
- 动画RootMotion
- RootMotionSource
- RequestVelocity
- Gravity
- Q: 角色的Z方向是速度是哪里处理的?
- A: MaintainHorizontalGroundVelocity, 而且不止调用了一次; Z速度比较复杂, 地面移动的Z速度不能通过重写这个函数来找回
- Q:刹车是怎么处理的
- A:刹车/制动
- Q:怎么判断角色处于Falling
- A:空中判断
- Q:怎么处理角色处于边缘的情况
- A:14.1.9 边缘检查
- Q:速度和加速度是哪里 计算的
- A:14.1.3 CalcVelocity
- Q:有没有处理撞墙(Hit)事件的逻辑
- A:相比于监听Capsule的Hit回调事件, 移动逻辑里有一部分相关代码也是类似情形, 大致在这里
PerformMovement
角色移动的核心就是这里, 从这里展开
1.清理过期数据
这个阶段判断是否处于不能移动或者物理状态, 如果是那么清理
如果条件允许同时处于RootMotion状态, 那么Tick一次Pose然后清理RootMotionParams
如果有激活的RootMotionSource(下简称RMS), 那么清理RMS数据
清理Force
2.处理RMS的LastPreAdditiveVelocity
1 | const FVector Adjustment = (Velocity - LastUpdateVelocity); |
根据当前速度与上一帧的Velocity差, 补偿到LastPreAdditiveVelocity
3.MaybeUpdateBasedMovement
这里主要处理了如果角色站在了动态地面(即Moveable的模型)上的变化, 不过角色的速度不会得加动态物体的速度
4.清理过期RMS
过期的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 | const FVector Adjustment = (Velocity - OldVelocity); |
因为上面几步与第2步之间有力的作用, 所以这里通过前后Velocity的变化, 把这个插值记录到LastPreAdditiveVelocity
中
11.预处理RootMotion
这里预处理了两种RootMotion, 包括动画RootMotion(下简称ARM)和RMS
如果是动画RootMotion, 那么这里会TickCharacterPose(DeltaSeconds);
, 这个会产生RootMotion数据RootMotionParams
,然后保存客户端的ClientRootMotionParams
如果是RMS, 这里会执行CurrentRootMotion.PrepareRootMotion()
, 这个函数就是通过各种不同的RMS算法把速度记录到RootMotionParams
12.处理RootMotion
同11步, 同样处理了两者
12.1 ARM
TickPose产生的RootMotionParams转换成世界空间, , 然后转化成Velocity
UE5插件
MotionWarping
监听的就是ConvertLocalRootMotionToWorld()当中的事件
我们知道, 如果保持正常的移动Mode, ARM的Z速度经常是没有的, 但是这里的Velocity还是有Z速度的, Z速度会在后面被处理掉
12.1.1 CalcAnimRootMotionVelocity
把RootMotionParams中的偏移转换成速度
12.1.2 ConstrainAnimRootMotionVelocity
如果处于Falling 那么速度用的是本身的速度, RootMotion中的Z速度会被替换
12.2 RMS
这一步就是把把RMS中的数据换算成Velocity
13.清理Jump输入
Jump是可以有HoldTime的, 这里就是处理这个
14.StartNewPhysics *
这里内容非常庞大, 也是移动输入的核心场所, 分条展开
14.1 PhysWalking *
正常的地面移动都在这里处理
14.1.1 RestorePreAdditiveRootMotionVelocity
如果RMS有有叠加的, 那么这一步会把第2和10步处理的LastPreAdditiveVelocity设置成Velocity
14.1.2 MaintainHorizontalGroundVelocity
两种方式, 要么单纯把Z速度变成0但是总速度大小会变(其实后面还会补上来), 要么总速度大小不变的情况下把Z速度去掉
其实这一步本质上区别不大
14.1.3 CalcVelocity *
这个函数非常重要, 核心的速度计算都在这里.
进入这里有几个条件, 需要不是ARM或者RMS状态
14.1.3.1 计算RequestedMove
先计算Requested速度, 这个是一个比较特殊的速度, 是作为额外一个叠加速度存在的, 通过下面2个UCharacterMovementComponent的接口设置(目前未暴露给蓝图)
1 | virtual void RequestDirectMove(const FVector& MoveVelocity, bool bForceMaxSpeed) override; |
上图中有个需要注意的开关bRequestedMoveUseAcceleration, 这个在蓝图中是可编辑的
如果true那么之类会计算出来一个加速度, 等后续的时候加到速度上去, 否则就直接会把当前速度替换成RequestedVelocity
另外的细节可以看[RequestVelocity](#Request Velocity)
14.1.3.2 处理加速度
如果bForceMaxAccel为true, 那么在这里会把加速度大小强制设置成最大的
14.1.3.3 制动流程
这里会有几个判断, 制动流程的进入条件是(bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax
翻译一下是 没有加速度同时没有Requested速度, 或者是当前速度大于了最大速度. 后者比较容易被忽略
制动流程可以看↑制动流程
然后如果速度还是大于最大速度, 这里有个保护, 这里把速度大小设置成最大速度
14.1.3.4 阻力对速度的影响
阻力参数Friction
(需要区别BrakingFriction)对由加速度造成的速度变化是有影响的, 阻力越大, 加速和切换方向会慢
14.1.3.5 流体对加速的影响
然后是处理流体速度的影响
14.1.3.6 输入对速度的加速
如果有按键输入, 那么这里通过加速度对速度进行加速
14.1.3.7 处理Requested速度
然后是处理前面产生的Request速度, 如果bRequestedMoveUseAcceleration
为true, 那么这里会对速度进行一个增量, 如果为false, 那么这里可以忽略
14.1.3.8 处理RVO
涉及到RVO的内容比较多, 这里先不展开
14.1.4 ApplyRootMotionToVelocity
这个函数的字面意思跟实际用途略有差别, 这里主要处理的是RootMotion(包含ARM和RMS)+Falling状态下的DecayingFormerBaseVelocity的处理
DecayingFormerBaseVelocity会在站在动态地面上时才会有变换的速度值
14.1.5 处理如果是Fallinig
这一步如果是Falling了, 那么重新执行StartNewPhysics()
14.1.6 MoveAlongFloor *
在地面上的移动处理, 这个函数也比较庞大, 返回一个脚步数据FStepDownResult
, 这个数据包含了我们经常用的CurrentFloor
14.1.6.1 ComputeGroundMovementDelta
这里处理的是斜坡上的速度, 传入的Delta是水平速度, 经过Dot计算得到了Z的速度, 这里也有bMaintainHorizontalGroundVelocity
的判断, 但是返回值都是带Z速度的(目的是什么?)
14.1.6.2 SafeMoveUpdatedComponent
这个函数在很多地方都会调用, CharacterMovementComponent没有重写任何东西, 核心内容在UPrimitiveComponent::MoveComponentImpl()
中
里面一大堆的数据初始化, 最核心的就是下面这里
之后就是调用物理接口了
所以就是利用前面算出来的delta速度, 对capsule进行一个短距离的Multi的Sweap操作,获取了Hit数据, 如果有必要就移动
如果bStartPenetrating(出生点跟其他物体有重叠/穿透), 那么会需要解决穿透问题, 这个就是角色如果出生在模型内然后被挤出模型的操作, 如果要特化这个操作可以重写ResolvePenetrationImpl()
14.1.6.3 处理穿透
如果出生的时候与其他物体有穿透, 那么其实前面的SafeMoveUpdatedComponent()
已经处理了这个问题, 一般走不进这里, 感觉这个是做了一层保护, 因为前面的SafeMoveUpdatedComponent
是可以重写的
14.1.6.4 处理Hit事件
撞墙和撞台阶都会走到这里, 需要判定是否能上台阶
bMaintainHorizontalGroundVelocity在这里又发挥了奇怪的作用, 在判断是否可以StepUp的过程中有查询地面数据, 该数据包含在FStepDownResult
中
14.1.7 再次处理Falling/Swimming
同14.1.5一样的处理, 经过上一步的处理以后如果进入了这两个状态, 那么就切换
14.1.8 记录或者查询Floor
如果有step查询, 那么这里就已经有了floor数据, 否则就要通过FindFloor查询floor数据
14.1.8.1 FindFloor
这里通过了一个Sweap操作获取地面数据, 主要计算在ComputeFloorDist()
中, 大部分内容可以看是否在空中判断
14.1.9 边缘检查
移动组件有这么两个参数, 如果false, 那么角色不能从边缘通过走路掉下去(可以通过Jump)
这里就限制了角色下落
如果可以下落, 那么接下来就是适配角色的高度, 因为角色的胶囊体底部是圆形的, 所以默认的就是保持胶囊体底部跟地面是接触的, 在边缘会出现这样的情况
如果要自定义这个效果, 可以重写ShouldCatchAir()
(这个默认直接false)和HandleWalkingOffLedge()
做处理, 也可以重写AdjustFloorHeight()
进行补充
14.1.9.1 Swimming检查
可能到这一步从边缘掉进水里, 就进入Swimming逻辑
14.1.9.2 进入Falling
正常情况, 到这一步就进入了Falling, 然后直接return
14.1.10 最后处理地面速度
到这里如果在地面上, 那么通过这时候的组件的位置与PhysWalking
刚开始的位置偏差计算得到最终的Velocity
如果撞墙了, 那么这里的速度只有墙面切向方向的速度, 而且大小没有变化
比如Velocity(100,20,0)撞到了切线是Y方向的墙面, 这里的速度是Velocity(0,20,0)
15.UpdateCharacterStateAfterMovement
参考第6步, 也是更新了Crouch状态
16.PhysicsRotation
这里有几个前提
bAllowPhysicsRotationDuringAnimRootMotion
(默认关闭)或处于非RootMotion- 需要开启bOrientRotationToMovement, 同时关闭bUseControllerDesiredRotation
其实就是小白人默认的移动模式就可以走到这里, 角色永远会面朝速度方向, 你转动镜头的时候角色通过一定速度转向速度方向
17.处理RootMotion的旋转
前面第12步处理了ARM和RMS的Velocity, 这一步单独处理两者的旋转
发出疑问, 所以RMS是可以有旋转的, 虽然引擎自带的API和案例没有进行这个计算
18.OnMovementUpdated
虚函数, 无实现, 到这一步需要处理的事情可以重写这个函数实现
19.CallMovementUpdateDelegate
虽然命名是Call***Delegate, 但是这里刷新了组件的速度
20.根据动态地面保存位置
如果站在动态地面上, 那么这一步会更新对于动态地面的相对位置和旋转信息
21. UpdateComponentVelocity()
所以, 为什么这里又刷新一次, 前面CallMovementUpdateDelegate
中已经刷新了一次
22.处理若干网络数据
23.保存旧数据
把当前最终的数据保存为Last版本
AddMovementInput
这个接口是玩家移动输入的入口
通过一个方向和一个缩放值计算得到是一个加速度方向, 我们看下面分析
Tick中每一帧消耗掉上一次的输入信息并赋值给InputVector
, 看下图, 主要内容就在这里面
然后Tick流程继续, 在下面判定并执行了ControlledCharacterMove(InputVector, DeltaTime);
这个函数把InputVector
转变成加速度Acceleration
(带长度的), 然后就进入大名鼎鼎的PerformMovement()
, 一路走到
PhysWalking()
中的CalcVelocity()
函数中, 正常情况下会判定是否是制动流程, 如果不是就进入速度与摩擦力的计算
上图可以看到关键计算代码, 跟参数Friction
和Delta
值有很大关系, 如果最终得到的值大于1, 那么速度瞬间改变, 否则就是做插值过渡, 那么就意味着GroundFriction
参数越大, 移动速度改变越快
接下来会计算流体的速度影响, 这里跳过
然后再根据加速度和delta计算速度
后面的是RequestedAcceleration
和RVO的相关的计算
制动/减速
角色在两种情况下会执行制动流程
- 没有加速度,即没有移动输入
- 当前速度大于最大速度
减速相关的主要的配置属性如下
- bUseSeparateBrakingFriction: 如果为true, 那么单独设置阻力, 否则就使用GroundFriction或者其他外部的阻力
- BrakingFrictionFactor: 阻力系数
- BrakingFriction: 阻力, 计算的时候一般就是
BrakingFrictionFactor * BrakingFriction
- BrakingSubStepTime: 计算阻力的时候分段模拟的时间, 会clamp在 1/75与1/20之间,数值小能更平滑, 一般也不用设置
- BrakingDecelerationWalking: 移动制动力, 还有类似的有falling,flying等等
角色移动的减速流程就在下面ApplyVelocityBraking
代码当中
1 | void UCharacterMovementComponent::ApplyVelocityBraking(float DeltaTime, float Friction, float BrakingDeceleration) |
概括一下减速流程分2部分, 一个是阻力, 一个是制动力;
阻力是一个变速减速运动, 制动力是匀减速运动
所以如果把阻力设为0, 把制动力设置为当前速度一致, 那么得到的结果就是1秒内(会有大约一帧的误差)减速到0
Request Velocity
我们直接通过设置角色或者移动组件的速度是不符合UE设计的, 因为他有自己的一套计算流程, 比如下面这样
事实上UE提供了一个接口, 让我们可以手动的设置速度(叠加速度)
1 | virtual void RequestDirectMove(const FVector& MoveVelocity, bool bForceMaxSpeed) override; |
在计算整体速度的时候会用到这个速度
上面图中可以看到, 在计算完普通的速度以后, 在最后叠加上了这个RequestVelocity
, 同时也保证了总速度大小保持一致
是否在空中的判定
角色是否在空中的判定主要来自当前地面数据CurrentFloor
, 通过CurrentFloor.IsWalkableFloor()
来判定
满足条件下就开始执行 StartFalling()
那就需要看CurrentFloor
是如何判断是否在地面的
从角色移动组件的UCharacterMovementComponent::ComputeFloorDist()
开始, 里面有下图这一段
可以理解, 角色胶囊体会进行基于形状的向下的sweap检测, 检测距离跟宽高差有关系, 意味着越细长这个检测距离越长, 距离范围是 20 + [0, 半高/10)
一般情况下这样就够了, 但是有一些特殊情况还需要再进行sweap检测, 比如初始就有重叠等情况
还有就是遇到超过能走路的斜坡这种, 虽然block了但是不是walkable, 那就还需要进行一次射线检测来判断是否是站在地面上
胶囊体与地面的距离
如上图所示, 我们从角色往下打射线检测到的地面的点 与我们角色脚底位置的点实际上是有偏差的, 这个偏差可以从移动组件里面获取到,就是下图这个东西
这个距离怎么来的呢?
实际上就是角色在计算地面数据的sweap过程中产生的, 大概理解为sweap前后的距离差,具体代码在UCharacterMovementComponent::ComputeFloorDist()
中
大致如下
1 | { |
这个SweepResult就是我们要的Dist
orm