前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Unreal随笔系列1: 移动实现中的数学和物理

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

作者头像
JohnYao
发布2023-03-08 15:18:45
9350
发布2023-03-08 15:18:45
举报
文章被收录于专栏:JohnYao的技术分享

系列说明

23年新挖一个《Unreal随笔系列》的坑。所谓随笔,就是研究过程中的一些想法随时记录;细节可能来不及考证,甚至一些想法可能也不太成熟,有失偏颇;希望读者也可以帮忙指正和讨论。这个系列主要求量,希望每个月给自己布置一些研究小课题,争取今年发满12篇。

引言

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

涉及的数学物理知识大致有如下:

  • 弧度角
  • 向量
  • 三角函数
  • 匀加速运动
  • ...

闲话不多讲,开始本文的正题。

一 Unreal移动的概述

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

  1. 《Exploring in UE4》移动组件详解
  2. 《UE4移动的网络同步》
  3. Unreal Doc: 角色移动组件

建议读者可以自行阅读上面的文章,了解Unreal移动实现的概要。这里不做详细展开,仅罗列下Unreal移动及其同步的主要流程:

  1. 1P客户端收集玩家输入
  2. 1P进行物理移动模拟
  3. 1P将模拟结果, 通过RPC上报DS
  4. DS进行物理移动模拟
  5. DS通过RPC响应客户端移动,或者通过RPC修正客户端错误
  6. DS将1P的位置信息通过属性同步给其他客户端
  7. 客户端响应移动同步信息
    1. 1P处理DS RPC回包, 或者根据根据修正调整自身位置
    2. 其他客户端收到1P的位置属性,做3P移动表现

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

二 玩家输入的收集过程

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

完整的堆栈会更多些层级,但并不影响本文的内容展开。我们首先关注函数调用的顶层栈, APawn::Internal_AddMovementInput,  它的实现比较简单:

代码语言:javascript
复制
void APawn::Internal_AddMovementInput(FVector WorldAccel, bool bForce)
{
    if (bForce || !IsMoveInputIgnored())
    {
        ControlInputVector += WorldAccel;
    }
}

WorldAccel是当帧输入转化为的一个向量,可以理解为力或者加速度的方向。ControlInputVector是Pawn的一个成员变量,记录了未被处理的上次输入。 这两个变量的计算和使用,稍后我们再剖析。这里出现了第一个知识点, 向量。

向量和标量

向量在高中就已经引入, 定义是既有大小又有方向的量. 在几何学里, 它可以表示为一个带箭头的线段。与之对应的是标量,标量是只有大小,没有方向的量。

大学的线性代数引入了代数表示发, "在指定了一个坐标系之后,用一个向量在该坐标系下的坐标来表示该向量"。每个坐标轴对应的数值, 称为分量。

代码语言:javascript
复制
struct FVector 
{
public:
    float X;
    float Y;
    float Z;

Unreal中的FVector可以认为是三维坐标系中的一个向量。

向量加法,力和加速度

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

对于代数表示法, 则是各分向量的和

代码语言:javascript
复制
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(第三人称射击)模板工程默认生成的默认实现.

代码语言:javascript
复制
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。

代码语言:javascript
复制
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再次标准化,最终输入只是提供了移动的方向。

矩阵转换

在进行下一部分前, 我们看下之前的求单位向量的矩阵转换。

代码语言:javascript
复制
const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);

矩阵的定义如下: 一个m*n的矩阵是一个由m行n列元素排列成的矩形阵列。矩阵里的元素可以是数字、符号或数学式。

FRotationMatrix是一个4*4的矩阵,它的初始化流程如下。

代码语言:javascript
复制
    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π弧度。所以:

代码语言:javascript
复制
1弧度=180/π角度
1角度=π/180弧度

DegreesToRadians函数的实现就遵循上面的公式。

代码语言:javascript
复制
{
        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中。

代码语言:javascript
复制
FMath::SinCos(&SP, &CP, (T)FMath::DegreesToRadians(Rot.Pitch));

正弦就是将弧度角对应的直角三角形中, 该角的对边长度除以斜边长度。 余弦就是将弧度角对应的直角三角形中, 该角的邻边长度除以斜边长度。

4. 反平方根

对于开平方我的印象是很很清楚的,X^(1/2)。

乍看到反平方根时,有点回忆不起其含义。反平方根,就是平方根的倒数,X^(-1/2)。

矩阵转换(续)

有了如上的背景知识,我们继续看下这个矩阵的GetUnitAxis(X)的实现。

代码语言:javascript
复制
template<typename T>
inline TVector<T> TMatrix<T>::GetUnitAxis(EAxis::Type InAxis) const
{
    return GetScaledAxis(InAxis).GetSafeNormal();
}

这里的GetSafeNormal就是标准化的过程。简单的讲,向量长度就是xx+y*y+z*z(使用勾股定理)开平方。 除以向量长度,就相当于乘以它的反平方根。为什么直接使用反平方根,可能是这样整体的计算量更小些?

代码语言:javascript
复制
{
    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)函数调用则返回第一个行向量。

代码语言:javascript
复制
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]);

也就是

代码语言:javascript
复制
    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。所以理论上每帧都是先执行输入收集,再执行移动的物理模拟。

整体调用堆栈如下:

代码语言:javascript
复制
UCharacterMovementComponent::TickComponent
    UPawnMovementComponent::ConsumeInputVector()
    ControlledCharacterMove
        ReplicateMoveToServer
            PerformMovement
                StartNewPhysics(DeltaSeconds, 0);
                    PhysWalking
                        CalcVelocity
                        MoveAlongFloor
                            MoveUpdatedComponent

输入消耗

在玩家开始真正的物理模拟前, 会获取之前缓存在ControlInputVector的输入数据.

这个函数的实现也比较简单, 就是获取并将缓存变量置0。

代码语言:javascript
复制
{
    LastControlInputVector = ControlInputVector;
    ControlInputVector = FVector::ZeroVector;
    return LastControlInputVector;
}

加速度计算

然后将获取到输入向量传入到ControlledCharacterMove函数, 及后续ScaleInputAcceleration函数。 计算移动的物理模拟过程中的加速度。

ScaleInputAcceleration的实现也比较简单, 如果输入的向量长度大于1, 则标准化为单位向量(前面一节已经提过); 否则则采用原始值。

代码语言:javascript
复制
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也是固定的。所以可以看出,原生实现的物理模拟是将角色移动当作匀加速运动来看。

匀加速运动

匀加速运动应该是初中物理知识,就是以固定的加速度进行直线运动。其中涉及的公式,也简单罗列下:

代码语言:javascript
复制
加速度 a=(v-v0)/t
瞬时速度公式 v=v0+at;
位移公式 x=v0t+½at²;
平均速度 v=x/t=(v0+v)/2
导出公式 v²-v0²=2ax

在随后的PhysWalking中,会将tick对应的delta time分解为更小的时间段

代码语言:javascript
复制
FMath::Min(MaxSimulationTimeStep, RemainingTime * 0.5f);

每个时间段,最多模拟MaxSimulationTimeStep秒。最多分解MaxSimulationIterations个时间段。

这里似乎有点微积分的意思。

在这个循环中,每次都会计算瞬时速度CalcVelocity(最终速度会受最大速度的限制),并计算当次的位移。并利用底层的物理引擎,判断是否产生了碰撞或者overlap,并修正最终位置。

四 结语

以上就是Unreal角色移动,在客户端模拟阶段的一些实现过程。并在代码阅读过程中,对其中涉及的数学,物理知识尝试拆解其应用的原理。

相比光照,渲染,底层物理引擎使用的更复杂的计算公式,这里的内容只能说是非常浅显。本文的目的,也是希望可以激发初学者研究引擎的兴趣,发现引擎实现中的更多科学宝藏。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-01-30,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 一 Unreal移动的概述
  • 二 玩家输入的收集过程
    • 向量和标量
      • 向量加法,力和加速度
        • WorldAccel的计算
          • 向量的标量乘法
            • 矩阵转换
              • 数学&背景知识
                • 1. 弧度&角度
                  • 2. Rotator
                    • 3. 三角函数
                      • 4. 反平方根
                        • 矩阵转换(续)
                          • 小结
                          • 三 1P角色移动的物理模拟过程
                            • 输入消耗
                              • 加速度计算
                                • 匀加速运动
                                • 四 结语
                                领券
                                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档