找回密码
 立即注册
查看: 169|回复: 0

Unreal随笔系列1: 移动实现中的数学和物理

[复制链接]
发表于 2023-3-11 06:01 | 显示全部楼层 |阅读模式
系列说明
23年新挖一个《Unreal随笔系列》的坑。所谓随笔,就是研究过程中的一些想法随时记录;细节可能来不及考证,甚至一些想法可能也不太成熟,有失偏颇;希望读者也可以帮忙指正和讨论。这个系列主要求量,希望每个月给自己布置一些研究小课题,争取今年发满12篇。
引言

近期打算再次对Unreal的移动实现做进一步的研究。在研究过程中,发现Unreal应用了很多数学和物理的公式;虽然公式本身并不复杂,大部分是初高中所学,但每回忆起公式的含义,并搞清楚其应用的原理,就好像淘金人发现遗失的一粒金沙,感觉欣喜万分。
涉及的数学物理知识大致有如下:  

  • 弧度角
  • 向量
  • 三角函数
  • 匀加速运动
  • ...
闲话不多讲,开始本文的正题。
一 Unreal移动的概述

关于Unreal移动及其同步,知乎有两篇文章浏览量较高,当然还有官方文档的角色移动组件章节可以参考。

  • 《Exploring in UE4》移动组件详解
  • 《UE4移动的网络同步》
  • Unreal Doc: 角色移动组件
建议读者可以自行阅读上面的文章,了解Unreal移动实现的概要。这里不做详细展开,仅罗列下Unreal移动及其同步的主要流程:

  • 1P客户端收集玩家输入
  • 1P进行物理移动模拟
  • 1P将模拟结果, 通过RPC上报DS
  • DS进行物理移动模拟
  • DS通过RPC响应客户端移动,或者通过RPC修正客户端错误
  • DS将1P的位置信息通过属性同步给其他客户端
  • 客户端响应移动同步信息

    • 1P处理DS RPC回包, 或者根据根据修正调整自身位置
    • 其他客户端收到1P的位置属性,做3P移动表现

本文着重捋下流程前两步的实现细节,对实现中涉及的数学物理公式着重介绍。首先看下收集玩家输入的实现。
二 玩家输入的收集过程

下图展示了收集过程的代码调用层级:


完整的堆栈会更多些层级,但并不影响本文的内容展开。我们首先关注函数调用的顶层栈, APawn::Internal_AddMovementInput,  它的实现比较简单:
void APawn::Internal_AddMovementInput(FVector WorldAccel, bool bForce)
{
    if (bForce || !IsMoveInputIgnored())
    {
        ControlInputVector += WorldAccel;
    }
}
WorldAccel是当帧输入转化为的一个向量,可以理解为力或者加速度的方向。ControlInputVector是Pawn的一个成员变量,记录了未被处理的上次输入。 这两个变量的计算和使用,稍后我们再剖析。这里出现了第一个知识点, 向量。
向量和标量

向量在高中就已经引入, 定义是既有大小又有方向的量. 在几何学里, 它可以表示为一个带箭头的线段。与之对应的是标量,标量是只有大小,没有方向的量。
大学的线性代数引入了代数表示发, "在指定了一个坐标系之后,用一个向量在该坐标系下的坐标来表示该向量"。每个坐标轴对应的数值, 称为分量。
struct FVector
{
public:
    float X;
    float Y;
    float Z;
Unreal中的FVector可以认为是三维坐标系中的一个向量。
向量加法,力和加速度

向量加法可以使用几何的方法, 使用平行四边形法求解.


对于代数表示法, 则是各分向量的和
FORCEINLINE FVector FVector::operator+(const FVector& V) const
{
    return FVector(X + V.X, Y + V.Y, Z + V.Z);
}
向量加法的实际物理意义可以理解为合力。
物理中的加速度和力, 可以用向量表示。 这里对向量进行加法, 也就是未被消耗的ControlInputVector对应的力和WorldAccel对应的力, 二者产生了一个合力.
一般来讲, ControlInputVector在单帧就会消耗掉, 并置为零向量. 所以一般情况下, 上面的函数会返回WorldAccel。
WorldAccel的计算

形成WorldAccel是在绑定的输入处理函数内.  我们看下Unreal TPS(第三人称射击)模板工程默认生成的默认实现.
void ATShooterCharacter::MoveForward(float Value)
{
    if ((Controller != nullptr) && (Value != 0.0f))
    {
        // find out which way is forward
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0, Rotation.Yaw, 0);

        // get forward vector
        const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
        AddMovementInput(Direction, Value);
    }
}
这里的Direction变量,通过计算他的过程和函数名字可以知道, 它是一个单位向量(长度为1的向量),它的方向就是控制器指向的方向。由于角色只是在xy平面移动,所以这里只取了Yaw的分量。不同的游戏类型,可能会有不同的实现。
这里比较复杂的一步是使用了矩阵进行Rotator到Vector的转换。这里为了保证这一小节讲述的完整性,我们将这个矩阵转换放到后面的小节单独展开。
Value的形成也比较繁复,但不是本文的重点, 这里可以先简单的理解为输入映射配置中填入的数值。


得到了Value和移动方向后, 后续执行AddMovementInput函数. 这里会进行向量的标量乘法WorldDirection * ScaleValue。
void APawn::AddMovementInput(FVector WorldDirection, float ScaleValue, bool bForce)
{
    UPawnMovementComponent* MovementComponent = GetMovementComponent();
    if (MovementComponent)
    {
        MovementComponent->AddInputVector(WorldDirection * ScaleValue, bForce);
    }
    else
    {
        Internal_AddMovementInput(WorldDirection * ScaleValue, bForce);
    }
}
向量的标量乘法

向量的标量乘法,就是用标量乘以各个向量的分量。举个物理中的例子,力的大小这个标量乘以力方向的单位向量,得到力的向量。
虽然这里做了向量的标量乘法,似乎输入可以决定力的大小。 但在后续玩家移动的引擎原生实现中,会对这个ControlInputVector再次标准化,最终输入只是提供了移动的方向。
矩阵转换

在进行下一部分前, 我们看下之前的求单位向量的矩阵转换。
const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
矩阵的定义如下: 一个m*n的矩阵是一个由m行n列元素排列成的矩形阵列。矩阵里的元素可以是数字、符号或数学式。
FRotationMatrix是一个4*4的矩阵,它的初始化流程如下。
    T SP, SY, SR;
    T CP, CY, CR;
    FMath::SinCos(&SP, &CP, (T)FMath::DegreesToRadians(Rot.Pitch));
    FMath::SinCos(&SY, &CY, (T)FMath::DegreesToRadians(Rot.Yaw));
    FMath::SinCos(&SR, &CR, (T)FMath::DegreesToRadians(Rot.Roll));

    M[0][0] = CP * CY;
    M[0][1] = CP * SY;
    M[0][2] = SP;
    M[0][3] = 0.f;

    M[1][0] = SR * SP * CY - CR * SY;
    M[1][1] = SR * SP * SY + CR * CY;
    M[1][2] = - SR * CP;
    M[1][3] = 0.f;

    M[2][0] = -( CR * SP * CY + SR * SY );
    M[2][1] = CY * SR - CR * SP * SY;
    M[2][2] = CR * CP;
    M[2][3] = 0.f;

    M[3][0] = Origin.X;
    M[3][1] = Origin.Y;
    M[3][2] = Origin.Z;
    M[3][3] = 1.f;在填充矩阵值的时候, 用到了三角函数。三角函数中变量使用的弧度。而在Rotator中,使用的是角度,所以这里要将角度转化为弧度。 在研究这个矩阵的使用时,我们回忆下如下数学知识和背景知识。
数学&背景知识

1. 弧度&角度

弧度的定义, 弧长等于半径的弧, 对应的圆心角为1弧度。
一弧度对应多少度呢?根据周长公式,360角度对应的弧长是2π弧度。所以:
1弧度=180/π角度
1角度=π/180弧度DegreesToRadians函数的实现就遵循上面的公式。
{
        return DegVal * (PI / 180.f);
    }2. Rotator

Rotator向量的各分量含义如下,你可以想象人形actor的初始状态是, 正面朝向x轴方向,肩膀和y轴平行。
1. pitch的含义是向前跌倒,那它对相应的分量就是角色在y轴的旋转。
2. yaw的含义是偏航,那它对相应的分量就是角色在z轴旋转。
3. roll的含义是摇晃,那它对相应的分量就是x轴旋转,左右摇晃。


3. 三角函数

FMath::SinCos函数是在一个函数中,将这个角的正弦余弦求出来,保存在SP,CP中。
FMath::SinCos(&SP, &CP, (T)FMath::DegreesToRadians(Rot.Pitch));
正弦就是将弧度角对应的直角三角形中, 该角的对边长度除以斜边长度。 余弦就是将弧度角对应的直角三角形中, 该角的邻边长度除以斜边长度。
4. 反平方根

对于开平方我的印象是很很清楚的,X^(1/2)。
乍看到反平方根时,有点回忆不起其含义。反平方根,就是平方根的倒数,X^(-1/2)。
矩阵转换(续)

有了如上的背景知识,我们继续看下这个矩阵的GetUnitAxis(X)的实现。
template<typename T>
inline TVector<T> TMatrix<T>::GetUnitAxis(EAxis::Type InAxis) const
{
    return GetScaledAxis(InAxis).GetSafeNormal();
}这里的GetSafeNormal就是标准化的过程。简单的讲,向量长度就是xx+y*y+z*z(使用勾股定理)开平方。 除以向量长度,就相当于乘以它的反平方根。为什么直接使用反平方根,可能是这样整体的计算量更小些?
{
    const T SquareSum = X*X + Y*Y + Z*Z;

    // Not sure if it's safe to add tolerance in there. Might introduce too many errors
    if(SquareSum == 1.f)
    {
        return *this;
    }      
    else if(SquareSum < Tolerance)
    {
        return ResultIfZero;
    }
    const T Scale = (T)FMath::InvSqrt(SquareSum);
    return TVector<T>(X*Scale, Y*Scale, Z*Scale);
}GetUnitAxis(EAxis::X)函数调用则返回第一个行向量。
template<typename T>
inline TVector<T> TMatrix<T>::GetScaledAxis(EAxis::Type InAxis) const
{
    switch (InAxis)
    {
    case EAxis::X:
        return TVector<T>(M[0][0], M[0][1], M[0][2]);也就是
    M[0][0] = CP * CY;
    M[0][1] = CP * SY;
    M[0][2] = SP;
    M[0][3] = 0.f;结合下图和三角函数,假定向量长度是1的话, z轴坐标就是sin(Pitch),有就是M[0][2]。依次可以推导,x,y坐标。


小结

以上,通过一系列的函数调用及其背后的数学转换,我们最终得到了输入代表的“力”。下面我们继续探究1P角色移动的物理模拟过程,及其中间涉及的物理知识。
三 1P角色移动的物理模拟过程

玩家的物理模拟是在CharacterMovementComponent的TickComponent中实现的。第一步的输入收集是在PlayerController中Tick中实现的。
PlayerController的TickGroup是TG_PrePhysics, 而MovmentComponent的TickGroup是TG_PostPhysics。所以理论上每帧都是先执行输入收集,再执行移动的物理模拟。
整体调用堆栈如下:
UCharacterMovementComponent::TickComponent
    UPawnMovementComponent::ConsumeInputVector()
    ControlledCharacterMove
        ReplicateMoveToServer
            PerformMovement
                StartNewPhysics(DeltaSeconds, 0);
                    PhysWalking
                        CalcVelocity
                        MoveAlongFloor
                            MoveUpdatedComponent输入消耗

在玩家开始真正的物理模拟前, 会获取之前缓存在ControlInputVector的输入数据.
这个函数的实现也比较简单, 就是获取并将缓存变量置0。
{
    LastControlInputVector = ControlInputVector;
    ControlInputVector = FVector::ZeroVector;
    return LastControlInputVector;
}
加速度计算

然后将获取到输入向量传入到ControlledCharacterMove函数, 及后续ScaleInputAcceleration函数。 计算移动的物理模拟过程中的加速度。
ScaleInputAcceleration的实现也比较简单,  如果输入的向量长度大于1, 则标准化为单位向量(前面一节已经提过); 否则则采用原始值。
FVector UCharacterMovementComponent::ScaleInputAcceleration(const FVector& InputAcceleration) const
{
    return GetMaxAcceleration() * InputAcceleration.GetClampedToMaxSize(1.0f);
}

FORCEINLINE FVector FVector::GetClampedToMaxSize(float MaxSize) const
{
    if (MaxSize < KINDA_SMALL_NUMBER)
    {
        return FVector::ZeroVector;
    }

    const float VSq = SizeSquared();
    if (VSq > FMath::Square(MaxSize))
    {
        const float Scale = MaxSize * FMath::InvSqrt(VSq);
        return FVector(X*Scale, Y*Scale, Z*Scale);
由于每帧的输入的InputAcceleration都是固定的,在原生实现中,GetMaxAcceleration也是固定的。所以可以看出,原生实现的物理模拟是将角色移动当作匀加速运动来看。
匀加速运动

匀加速运动应该是初中物理知识,就是以固定的加速度进行直线运动。其中涉及的公式,也简单罗列下:
加速度 a=(v-v0)/t
瞬时速度公式 v=v0+at;
位移公式 x=v0t+at;
平均速度 v=x/t=(v0+v)/2
导出公式 v-v0=2ax在随后的PhysWalking中,会将tick对应的delta time分解为更小的时间段
FMath::Min(MaxSimulationTimeStep, RemainingTime * 0.5f);
每个时间段,最多模拟MaxSimulationTimeStep秒。最多分解MaxSimulationIterations个时间段。
这里似乎有点微积分的意思。
在这个循环中,每次都会计算瞬时速度CalcVelocity(最终速度会受最大速度的限制),并计算当次的位移。并利用底层的物理引擎,判断是否产生了碰撞或者overlap,并修正最终位置。
四 结语

以上就是Unreal角色移动,在客户端模拟阶段的一些实现过程。并在代码阅读过程中,对其中涉及的数学,物理知识尝试拆解其应用的原理。
相比光照,渲染,底层物理引擎使用的更复杂的计算公式,这里的内容只能说是非常浅显。本文的目的,也是希望可以激发初学者研究引擎的兴趣,发现引擎实现中的更多科学宝藏。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-7 10:16 , Processed in 0.091863 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表