https://zhuanlan.zhihu.com/p/685714685
https://news.16p.com/842025.html
角色移动是一个复杂的计算过程,为了让游戏操作更加顺畅,往往需要非常多的细节处理,这些特殊的移动处理逻辑叫做 collide and slide
正如 Phys-x 物理引擎的 SDK 文档中所说,如果使用物理引擎来控制角色移动存在很多问题
tunneling effect
tunneling effect 当角色移动过快的时候,可能会穿过墙壁,因此需要限制角色最大速度tunneling,当角色在角落向前推时,会出现角色抖动的情况,因为物理引擎不断将其前后移动到略有不同的位置restitution 的存在,会产生回弹效果由于以上这些问题的存在,角色移动不能直接使用 物理引擎 来控制
虚幻引擎将碰撞检测的信息封装成 FHitResult,虽然下面这些理论知识与虚幻引擎无关,为了方便参数解释,还是提前说明
| FHitResult 的属性 | 解释 |
|---|---|
| bBlockingHit | 是否发生碰撞 |
| Time | 碰撞后实际移动距离除以检测移动距离 |
| Distance | 碰撞后实际移动距离 |
| Location | 碰撞后最终位置 |
| ImpactPoint | 碰撞接触点 |
| Normal | 碰撞切面法向量 |
| ImpactNormal | 碰撞切面法向量(非胶囊体和球体检测与Normal不同) |
| TraceStart | 检测开始位置 |
| TraceEnd | 检测结束位置 |
| bStartPenetrating | 是否在检测开始就有渗透情况 |
| PenetrationDepth | 渗透深度 |
Sweep 检测
Sweep扫过、掠过、大范围伸展
以当前坐标为起点,以当前 速度 和 Delta 计算下一帧的理论坐标为终点,进行检测
如果中间检测到碰撞,则无法移动到终点坐标,从碰撞点计算角色下一帧真实坐标
TraceStart 表示 检测起点,值为当前坐标TraceEnd 表示 检测终点,值为理论下一帧坐标ImpactPoint 表示 碰撞接触点Location 表示 下一帧的实际坐标Distance 表示 下一帧的实际坐标与当前坐标之间的距离Time 表示 $\frac{Distance}{TraceEnd-TraceStart}$,是一个 0~1 之间的值InitialOverlaps 检测
也就是在开始位置就检测到了重叠
这个时候 bStartPenetrating 值为 true,表示检测到了 InitialOverlaps
Penetrating 表示重叠深度
Penetrating: 渗透、贯穿、穿过
| 可以移动的坡度 | 不可移动的坡度 |
|---|---|
![]() |
![]() |
通过 速度、加速都、摩擦力、BrakingDeceleration 等,计算出最后的速度向量 Velocity * DeltaTime
根据地面坡度调整移动向量方向,例如可移动坡度的红色箭头
如果碰撞检测返回 Hit 的结果是 Block,表示检测到斜坡比较陡,那么可以将剩下的移动向量改为沿着面2移动
引擎通过 Hit.Normal 获取 面2 的法线方向,通过坡度检测,若夹角小于 WalkableFloorAngle 则判定为缓坡,可以行走;若夹角大于 WalkableFloorAngle 判定为陡坡,不可行走
随后第一次响应 沿面移动 SlideVector = OriginalDelta - (OriginalDelta • Hit.Normal) * Hit.Normal,之后再次调用 SafeMoveUpdatedComponent
此时再次射线检测,如果返回的 Hit 的结果还是 Blokc,表示面2 非常陡,可能需要 StepUp 上楼逻辑
上楼逻辑分成 3 次移动构成
MaxStepHeight 高度不过很多情况会导致 StepUp 失败
比如:移动过程中检测到穿透 Penetration,最终无法落到一个合适的落脚点
如果 StepUp 失败,需要调用 SlideAlongSurface 贴着面走
大概就是下面这个情况,会触发 SlideAlongSurface,红色向量是加速度方向
在计算 MoveAlongFloor 之后,由于 StepUp 失败,尝试 SlideAlongSurface
得到橙色向量为实际速度方向
| 沿着 面3 向右移动 | 沿着 面3 向上移动 |
|---|---|
![]() |
![]() |
当我们通过 SlideAlongSurface 移动的时候,前面又出现一堵墙,此时需要计算 墙2 和 墙3 之间的角度,如果夹角大于 90°,那么可以沿着面3的方向继续移动
通过 FindFloor 可以计算得到脚下的地面信息,并包含在 FFindFloorResult 结构体中
| FFindFloorResult 属性 | 作用 |
|---|---|
| bBlockHit | 是否跟地面有碰撞 |
| bWalkableFloor | 可以行走的地面 |
| bLineTrace | 是否是通过line trace检测出来的结果 |
| FloorDist | Sweep查询到地面的距离 |
| LineDist | LineTrace查询到地面的距离 |
| HitResult | 跟地面的FHitResult |
一般情况下,只需要一次垂直向下 Sweep 检测就可以计算出 FloorDist
不过还会出现一种情况,那就是角色有一部分在地面里面
此时检测的 bStartPenetration 是 true
需要缩小叫能提,重新向下 Sweep 计算出来的 FloorDist - ShrinkHeight 就是原胶囊体跟地面的距离
所有缩小的胶囊体 Sweep 让然出现了穿透的情况,需要修改使用 Line Trace 并且从胶囊体中心向下检测胶囊体的半高,如何检测到了 Hit,则可以计算陷入地面以下的高度
无论是 Sweep 还是 LineTrace,单次调整的高度 MaxPenetrationAdjust 最大只能是胶囊体的半径,如果陷入地面的深度大于高度,无法一次调整到地面上,需要多帧处理
角色移动组件分层设计,各自负责不同的职责
| 组件 | 作用 |
|---|---|
| UMovementComponent | 基础移动 |
| UNavMovementComponent | 导航集成 |
| UPawnMovementComponent | 输入响应 |
| UCharacterMovementComponent | 角色物理 |
MovementComponent 为基类,提供基本的更新坐标逻辑。通常用于 可推动的物理道具
UpdateComponent),通常是根组件,比如 ACharacter 的根胶囊体组件UpdatedPrimitive),用于物理交互,如果 UpdateComponent 能转换成 UPrimitiveComponent 会直接使用 UpdateComponentMoveComponentFlags,用于控制更新行为的精细开关Velocity 存储实时移动速度向量,是驱动组件运动的核心数据PlaneConstraintNormal 定义移动约束平面的法线方向((0,1,1) 限制 Y 轴移动)PlaneConstraintOrigin 定义移动约束平面的原点坐标,用于计算组件与平面的空间关系UpdatedComponent = IsValid(NewUpdatedComponent) ? NewUpdatedComponent : NULL;
UpdatedPrimitive = Cast<UPrimitiveComponent>(UpdatedComponent);
NavMovementComponent 提供了AI寻路用的一些接口。通常用于 AI 控制的非人形物体
NavAgentProps 定义导航代理的物理特性和移动能力(如:半径、高度、最大速度、加速度等),用于路径计算和碰撞检测FixedPathBrakingDistance 定义减速到停止的距离,由 bUseFixedBrakingDistanceForPaths 控制是否启用PathFollowingComp 处理路径跟随逻辑,通常是 UPathFollowingComponentPawnMovementComponent 定义了接受输入的接口。其Owner必须为APawn子类。通常用于自定义载具等
UMovementComponent::MoveUpdateComponent 是真正执行物体移动逻辑的函数接口,并且该函数不是虚函数,子类无法重写
FORCEINLINE_DEBUGGABLE bool UMovementComponent::MoveUpdatedComponent(const FVector& Delta, const FRotator& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
{
return MoveUpdatedComponentImpl(Delta, NewRotation.Quaternion(), bSweep, OutHit, Teleport);
}
bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
{
if (UpdatedComponent)
{
const FVector NewDelta = ConstrainDirectionToPlane(Delta);
return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport);
}
return false;
}
对的,该函数直接调用 UpdatedComponent 的 MoveComponent,让组件自己更新自己
不过 USceneComponent::MoveComponentImpl 是虚函数,可以被子类重写,所以说根据设置的 UpdatedComponent 的不同,最后执行的移动逻辑也不相同
对于 USceneComponent::MoveComponentImpl 的实现逻辑是比较简单的
Mobility 必须是 MovableConditionalUpdateComponentToWorld 确保组件的 Transform 已经更新Delta.IsZero(),零位移表示不用移动,也就无需后续计算InternalSetWorldLocationAndRotationUpdateOverlaps,主要是递归更新 AttachedChild,并且更新自己的 PhysicsVolume 信息InternalSetWorldLocationAndRotation 逻辑相对简单
RelativeLocation 和 RelativeRotation 的值bCanEverAffectNavigation 更新导航网格的信息前面说过 MoveComponentImpl 是一个虚函数
在 UMovementComponent 中的 UpdatedComponent 通常被设置为对象的根组件,而 ACharacter 的根组件是 UCapsuleComponent
UCapsuleComponent -> UShapeComponent -> UPrimitiveComponent
所以对于 ACharacter 来说,更新坐标执行的是 UPrimitiveComponent::MoveComponentImpl
接下来逐步对代码 MoveComponentImpl 进行解释
Sweep 所需要的数据// Set up
const FVector TraceStart = GetComponentLocation(); // 当前坐标
const FVector TraceEnd = TraceStart + Delta; // 理论终点坐标
float DeltaSizeSq = (TraceEnd - TraceStart).SizeSquared(); // 移动距离的平方
const FQuat InitialRotationQuat = GetComponentTransform().GetRotation(); // 当前角度
注意这里 DeltaSizeSq 使用的是 SizeSquared,也就是长度的平方
节省了 sqrt 计算的性能,因为与 MinMovementDistSq 比较大小,不需要精确值,大概那个范围就行
DeltaSizeSq 设置为 0// ComponentSweepMulti does nothing if moving < KINDA_SMALL_NUMBER in distance, so it's important to not try to sweep distances smaller than that.
const float MinMovementDistSq = (bSweep ? FMath::Square(4.f* UE_KINDA_SMALL_NUMBER) : 0.f);
if (DeltaSizeSq <= MinMovementDistSq)
{
// Skip if no vector or rotation.
if (NewRotationQuat.Equals(InitialRotationQuat, SCENECOMPONENT_QUAT_TOLERANCE))
{
// copy to optional output param
if (OutHit)
{
OutHit->Init(TraceStart, TraceEnd);
}
return true;
}
DeltaSizeSq = 0.f;
}
NewRotationQuat新角度与InitialRotationQuat当前角度
MyWorld->ComponentSweepMulti 进行 Sweep 检测// static void PullBackHit(FHitResult& Hit, const FVector& Start, const FVector& End, const float Dist)
// {
// const float DesiredTimeBack = FMath::Clamp(0.1f, 0.1f/Dist, 1.f/Dist) + 0.001f;
// Hit.Time = FMath::Clamp( Hit.Time - DesiredTimeBack, 0.f, 1.f );
// }
TArray<FHitResult> Hits;
bool const bHadBlockingHit = MyWorld->ComponentSweepMulti(Hits, this, TraceStart, TraceEnd, InitialRotationQuat, Params);
if (Hits.Num() > 0)
{
const float DeltaSize = FMath::Sqrt(DeltaSizeSq);
for(int32 HitIdx=0; HitIdx<Hits.Num(); HitIdx++)
{
PullBackHit(Hits[HitIdx], TraceStart, TraceEnd, DeltaSize);
}
}
PullBackHit函数实现以注释的形式在上面代码块中
还记得 DeltaSizeSq 是距离的平方吗?得到真正的距离 DeltaSize
由于物理引擎返回的碰撞点可能非常接近物体表面,由于浮点计算误差实际碰撞点可能略微嵌入碰撞体内部,可能会导致后续处理出现卡在表面或者抖动问题
使用 PullBackHit 将碰撞点拉回一点
FMath::Clamp(0.1f, 0.1f/Dist, 1.f/Dist) + 0.001f 保证最少有 0.001 的拉回,并且拉回的长度与距离反比
根据 阻挡碰撞 和 重叠事件 进行不同处理
这里主要做了三个判断
bStartPenetrating,则表示一开始角色就与其他物体重叠了,遍历得到 方向 与 碰撞面法线 进行 点乘值最小的面bStartPenetrating,则保存第一个 bBlockingHit 的碰撞信息Overlap,则记录其信息如果先出现了 bStartPenetrating,表示角色与其他物体重合,需要首先解决重叠问题,其他的都不重要
如果先出现了普通的 bBlockingHit,表示角色没有与其他物体重合,那么其他的 bStartPenetrating 理论上就不会出现,也不重要
比阻挡碰撞点远的 Overlap 全部忽略,因为不会移动到那些 Overlap 的点
普通的 bBlockingHit |
出现 bStartPenetrating |
|---|---|
![]() |
![]() |
别忘了前面解释过的
collide and slide
如果 bBlockingHit == false,则没有碰撞,移动的终点就是 TraceEnd 点
如果 bBlockingHit == true,则出现了碰撞,根据根据碰撞点的信息更新 NewLocation
如果 NewLocation 与 当前坐标 的距离极小,那么 NewLocation 就等于 当前坐标
bIncludesOverlapsAtEnd = AreSymmetricRotations(InitialRotationQuat, NewRotationQuat, GetComponentScale());
主要服务于碰撞检测的优化处理,对于胶囊体来说,绕 Z 轴旋转不影响碰撞
bMoved = InternalSetWorldLocationAndRotation(NewLocation, NewRotationQuat, bSkipPhysicsMove, Teleport);
如果出现了移动 即 bMoved == true,则需要更新 Overlap 的信息
根据 IsDeferringMovementUpdates 判断是否需要延迟更新,如果延迟更新则将数据存储在 FScopedMovementUpdate 中,否则立刻调用 UpdateOverlaps
如果出现了 bBlockingHit,并且需要发送碰撞事件,同样根据是否需要延迟更新来决定立刻发送事件,还是交给 FScopedMovementUpdate 来延迟发送
if (IsDeferringMovementUpdates())
{
FScopedMovementUpdate* ScopedUpdate = GetCurrentScopedMovement();
ScopedUpdate->AppendBlockingHitAfterMove(BlockingHit);
}
else
{
DispatchBlockingHit(*Actor, BlockingHit);
}