Unreal随笔系列1: 移动实现中的数学和物理
系列说明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移动表现
本文着重捋下流程前两步的实现细节,对实现中涉及的数学物理公式着重介绍。首先看下收集玩家输入的实现。
二 玩家输入的收集过程
下图展示了收集过程的代码调用层级:
http://pic3.zhimg.com/v2-9781fc96755ffd136588af82b443150a_r.jpg
完整的堆栈会更多些层级,但并不影响本文的内容展开。我们首先关注函数调用的顶层栈, 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 = CP * CY;
M = CP * SY;
M = SP;
M = 0.f;
M = SR * SP * CY - CR * SY;
M = SR * SP * SY + CR * CY;
M = - SR * CP;
M = 0.f;
M = -( CR * SP * CY + SR * SY );
M = CY * SR - CR * SP * SY;
M = CR * CP;
M = 0.f;
M = Origin.X;
M = Origin.Y;
M = Origin.Z;
M = 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&#39;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, M, M);也就是
M = CP * CY;
M = CP * SY;
M = SP;
M = 0.f;结合下图和三角函数,假定向量长度是1的话, z轴坐标就是sin(Pitch),有就是M。依次可以推导,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角色移动,在客户端模拟阶段的一些实现过程。并在代码阅读过程中,对其中涉及的数学,物理知识尝试拆解其应用的原理。
相比光照,渲染,底层物理引擎使用的更复杂的计算公式,这里的内容只能说是非常浅显。本文的目的,也是希望可以激发初学者研究引擎的兴趣,发现引擎实现中的更多科学宝藏。
页:
[1]