《Exploring in UE4》游戏角色的移动原理(上)

作者: Jerish 游戏开发那些事 2019-09-11
上一篇文章主要给大家科普了游戏中的“角色”是如何动起来的。今天这篇文章会非常细致的给大家分析一下,虚幻引擎是如何处理角色移动的。不过文章的内容可能比较深,需要读者有一定游戏开发经验,建议结合引擎源码去理解。

一.深刻理解移动组件的意义

在大部分游戏中,玩家移动是最最核心的一个基本操作。即使没有一个所谓的“玩家”,也必定有一些你可以控制或AI控制的物体。

UE4提供的GamePlay框架就给开发者提供了一个相当完美的移动解决方案。由于UE采用了组件化的设计思路(即把不同的功能拆分并封装到某个特定的组件里),所以这个移动解决方案的核心功能就都交给了移动组件来完成。移动逻辑会根据游戏的复杂程度有不同的处理,如果是一个简单的俯视视角RTS类型的游戏,可能只提供基本的坐标移动就可以了;而对于第一人称的RPG游戏,玩家可能上天入地,潜水飞行,那需要的移动就要更复杂一些。但是不管是哪一种,UE都基本上帮我们实现了,这也得益于其早期的FPS游戏的开发经验。


而引擎提供的基本移动并不一定能完成我们的目标,我们也不应该因此局限我们的设计。比如轻功的飞檐走壁,魔法飞船的超重力,弹簧鞋,喷气背包飞行控制,这些效果都需要我们自己去进一步的处理移动逻辑,我们可以在其基础上修改,也可以自定义自己的移动模式。不管怎么样,这些操作都需要对移动组件进行细致入微的调整,所以我们就必须要深刻理解移动组件的实现原理。

再者,在一个网络游戏中,我们对移动的处理会更加的复杂。如何让不同客户端的玩家都体验到流畅的移动表现?如何保证角色不会由于一点点的延迟而产生“瞬移”?UE对这方面的处理都值得我们去思考和学习。

移动组件看起来只是一个和移动相关的组件,但其本身涉及到状态机,同步解决方案,物理模块,不同移动状态的细节处理,动画以及与其他组件(Actor)之间的调用关系等相关内容,足够花上一段时间去好好研究。这篇文章会从移动的基本原理,移动状态的细节处理,移动同步的解决方案几个角度尽可能详细的分析其实现原理,然后帮助大家快速理解并更好的使用移动组件。最后,给出几个特殊移动模式的实现思路供大家参考。

二.移动实现的基本原理

2.1移动组件与玩家角色

角色的移动本质上就是合理的改变坐标位置,在UE里面角色移动的本质就是修改某个根组件的坐标位置。图2-1是我们常见的一个Character的组件构成情况,可以看到我们通常将CapsuleComponent(胶囊体)作为自己的根组件,而Character的坐标本质上就是其RootComponent的坐标,Mesh网格等其他组件都会跟随胶囊体而移动。移动组件在初始化的时候会把胶囊体设置为移动基础组件的UpdateComponent,随后的操作都是在计算UpdateComponent的位置。

图2-1一个默认Character的组件构成

当然,我们也并不是一定要设置胶囊体为UpdateComponent,对于DefaultPawn(观察者)会把他的SphereComponent作为UpdateComponent,对于交通工具对象AWheeledVehicle会默认把他的Mesh网格组件作为UpdateComponent。你可以自己定义你的UpdateComponent,但是你的自定义组件必须要继承USceneComponent(换句话说就是组件得有世界坐标信息),这样他才能正常的实现其移动的逻辑。

2.2移动组件继承树

移动组件类并不是只有一个,他通过一个继承树,逐渐扩展了移动组件的能力。从最简单的提供移动功能,到可以正确模拟不同移动状态的移动效果。如图2-2所示

图2-2移动组件继承关系类图

移动组件类一共四个。首先是UMovementComponent,作为移动组件的基类实现了SafeMovementUpdatedComponent()的基本移动接口,可以调用UpdateComponent组件的接口函数来更新其位置。


  1. bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
  2. {
  3.     if (UpdatedComponent)
  4.     {
  5.         const FVector NewDelta = ConstrainDirectionToPlane(Delta);
  6.         return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport);
  7.     }

  8.     return false;
  9. }
  10.   
复制代码

通过上图可以看到UpdateComponent的类型是UScenceComponent,UScenceComponent类型的组件提供了基本的位置信息——ComponentToWorld,同时也提供了改变自身以及其子组件的位置的接口InternalSetWorldLocationAndRotation()。而UPrimitiveComponent类直接继承于UScenceComponent,增加了渲染以及物理方面的信息。我们常见的Mesh组件以及胶囊体都是继承自UPrimitiveComponent,因为想要实现一个真实的移动效果,我们时刻都可能与物理世界的某一个Actor接触着,而且移动的同时还需要渲染出我们移动的动画来表现给玩家看。

下一个组件是UNavMovementComponent,该组件更多的是提供给AI寻路的能力,同时包括基本的移动状态,比如是否能游泳,是否能飞行等。

UPawnMovementComponent组件开始变得可以和玩家交互了,前面都是基本的移动接口,不手动调用根本无法实现玩家操作。UPawnMovementComponent提供了AddInputVector(),可以实现接收玩家的输入并根据输入值修改所控制Pawn的位置。要注意的是,在UE中,Pawn是一个可控制的游戏角色(也可以是被AI控制),他的移动必须与特定的组件UPawnMovementComponent配合才行,所以这也是名字的由来吧。一般的操作流程是,玩家通过InputComponent组件绑定一个按键操作,然后在按键响应时调用Pawn的AddMovementInput接口,进而调用移动组件的AddInputVector(),调用结束后会通过ConsumeMovementInputVector()接口消耗掉该次操作的输入数值,完成一次移动操作。

最后到了UCharacterMovementComponent这个移动组件的重头,该组件可以说是Epic做了多年游戏的经验集成,里面非常精确的处理了各种常见的移动状态细节,实现了比较流畅的同步解决方案。各种位置校正,平滑处理才达到了目前的移动效果,而且我们不需要自己写代码就会使用这个完成度的相当高的移动组件,可以说确实很适合做第一,第三人称的RPG游戏了。

其实还有一个比较常用的移动组件,UProjectileMovementComponent,一般用来模拟弓箭,子弹等抛射物的运动状态。不过,这篇文章不会将重点放在这里。

2.3移动组件相关类关系简析

前面主要针对移动组件本身进行了分析,这里更全面的概括一下移动的整个框架。(参考图2-3)

图2-3移动框架相关类图

在一个普通的三维空间里,最简单的移动就是直接修改角色的坐标。所以,我们的角色只要有一个包含坐标信息的组件,就可以通过基本的移动组件完成移动。但是随着游戏世界的复杂程度加深,我们在游戏里面添加了可行走的地面,可以探索的海洋。我们发现移动就变得复杂起来,玩家的脚下有地面才能行走,那就需要不停的检测地面碰撞信息(FFindFloorResult,FBasedMovementInfo);玩家想进入水中游泳,那就需要检测到水的体积(GetPhysicsVolume(),Overlap事件,同样需要物理);水中的速度与效果与陆地上差别很大,那就把两个状态分开写(PhysSwimming,PhysWalking);移动的时候动画动作得匹配上啊,那就在更新位置的时候,更新动画(TickCharacterPose);移动的时候碰到障碍物怎么办,被其他玩家推怎么处理(MoveAlongFloor里有相关处理);游戏内容太少,想增加一些可以自己寻路的NPC,又需要设置导航网格(涉及到FNavAgentProperties);一个玩家太无聊,那就让大家一起联机玩(模拟移动同步FRepMovement,客户端移动修正ClientUpdatePositionAfterServerUpdate)。

这么一看,做一个优秀移动组件还真不简单。但是不管怎么样,UE基本上都帮你实现了。通过上面的描述,你现在也大体上了解了移动组件在各个方面的处理,不过遇到具体的问题也许还是无从下手,所以咱们继续往下分析。

三.各个移动状态的细节处理

这一节我们把焦点集中在组件UCharacterMovementComponent上,来详细的分析一下他是如何处理各种移动状态下的玩家角色的。首先肯定是从Tick开始,每帧都要进行状态的检测与处理,状态通过一个移动模式MovementMode来区分,在合适的时候修改为正确的移动模式。移动模式默认有6种,基本常用的模式有行走、游泳、下落、飞行四种,有一种给AI代理提供的行走模式,最后还有一个自定义移动模式。

图3-1单机模式下的移动处理流程

3.1 Walking

行走模式可以说是所有移动模式的基础,也是各个移动模式里面最为复杂的一个。为了模拟出出真实世界的移动效果,玩家的脚下必须要有一个可以支撑不会掉落的物理对象,就好像地面一样。在移动组件里面,这个地面通过成员变量FFindFloorResult CurrentFloor来记录。在游戏一开始的时候,移动组件就会根据配置设置默认的MovementMode,如果是Walking,就会通过FindFloor操作来找到当前的地面,CurrentFloor的初始化堆栈如下图3-2(Character Restart()的会覆盖Pawn的Restart()):

图3-2

下面先分析一下FindFloor的流程,FindFloor本质上就是通过胶囊体的Sweep检测来找到脚下的地面,所以地面必须要有物理数据,而且通道类型要设置与玩家的Pawn有Block响应。这里还有一些小的细节,比如我们在寻找地面的时候,只考虑脚下位置附近的,而忽略掉腰部附近的物体;Sweep用的是胶囊体而不是射线检测,方便处理斜面移动,计算可站立半径等(参考图3-3,HitResult里面的Normal与ImpactNormal在胶囊体Sweep检测时不一定相同)。另外,目前Character的移动是基于胶囊体实现的,所以一个不带胶囊体组件的Actor是无法正常使用UCharacterMovementComponent的。

图3-3

找到地面玩家就可以站立住么?不一定。这又涉及到一个新的概念PerchRadiusThreshold,我称他为可栖息范围半径,也就是可站立半径。默认这个值为0,移动组件会忽略这个可站立半径的相关计算,一旦这个值大于0.15,就会做进一步的判断看看当前的地面空间是否足够让玩家站立在上面。

前面的准备工作完成了,现在正式进入Walking的位移计算,这一段代码都是在PhysWalking里面计算的。为了表现的更为平滑流畅,UE4把一个Tick的移动分成了N段处理(每段的时间不能超过MaxSimulationTimeStep)。在处理每段时,首先把当前的位置信息,地面信息记录下来。在TickComponent的时候根据玩家的按键时长,计算出当前的加速度。随后在CalcVelocity()根据加速度计算速度,同时还会考虑地面摩擦,是否在水中等情况。
  1. <p>
  2. </p><p>//apply input to acceleration</p><p>
  3. </p><p>Acceleration=ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));</p>
复制代码

算出速度之后,调用函数MoveAlongFloor()改变当前对象的坐标位置。在真正调用移动接口SafeMoveUpdatedComponent()前还会简单处理一种特殊的情况——玩家沿着斜面行走。正常在walking状态下,玩家只会前后左右移动,不会有Z方向的移动速度。如果遇到斜坡怎么办?如果这个斜坡可以行走,就会调用ComputeGroundMovementDelta()函数去根据当前的水平速度计算出一个新的平行与斜面的速度,这样可以简单模拟一个沿着斜面行走的效果,而且一般来说上坡的时候玩家的水平速度应该减小,通过设置bMaintainHorizontalGroundVelocity为false可以自动处理这种情况。

现在看起来我们已经可以比较完美的模拟一个移动的流程了,不过仔细想一下还有一种情况没考虑到。那就是遇到障碍的情况怎么处理?根据我们平时游戏经验,遇到障碍肯定是移动失败,还可能沿着墙面滑动一点。UE里面确实也就是这么处理的,在角色移动的过程中(SafeMoveUpdatedComponent),会有一个碰撞检测流程。由于UPrimitiveComponent组件才拥有物理数据,所以这个操作是在函数UPrimitiveComponent::MoveComponentImpl里面处理的。下面的代码会检测移动过程中是否遇到了障碍,如果遇到了障碍会把HitResult返回。

  1. tics::MoveComponentName, Actor);
  2. FCollisionResponseParams ResponseParam;
  3. InitSweepCollisionParams(Params, ResponseParam);
  4. bool const bHadBlockingHit = MyWorld->ComponentSweepMulti(Hits, this, TraceStart, TraceEnd, InitialRotationQuat, Params);
复制代码

在接收到SafeMoveUpdatedComponent()返回的HitResult后,会在下面的代码里面处理碰撞障碍的情况。

如果Hit.Normal在Z方向上有值而且还可以行走,那说明这是一个可以移动上去的斜面,随后让玩家沿着斜面移动

判断当前的碰撞体是否可以踩上去,如果可以的话就试着踩上去,如果过程中发现没有踩上去,也会调用SlideAlongSurface()沿着碰撞滑动。

  1. // UCharacterMovementComponent::PhysWalking
  2. else if (Hit.IsValidBlockingHit())
  3. {
  4.     // We impacted something (most likely another ramp, but possibly a barrier).
  5.     float PercentTimeApplied = Hit.Time;
  6.     if ((Hit.Time > 0.f) && (Hit.Normal.Z > KINDA_SMALL_NUMBER) && IsWalkable(Hit))
  7.     {
  8.         // Another walkable ramp.
  9.         const float InitialPercentRemaining = 1.f - PercentTimeApplied;
  10.         RampVector = ComputeGroundMovementDelta(Delta * InitialPercentRemaining, Hit, false);
  11.         LastMoveTimeSlice = InitialPercentRemaining * LastMoveTimeSlice;
  12.         SafeMoveUpdatedComponent(RampVector, UpdatedComponent->GetComponentQuat(), true, Hit);
  13.         const float SecondHitPercent = Hit.Time * InitialPercentRemaining;
  14.         PercentTimeApplied = FMath::Clamp(PercentTimeApplied + SecondHitPercent, 0.f, 1.f);
  15.     }

  16.     if (Hit.IsValidBlockingHit())
  17.     {
  18.         if (CanStepUp(Hit) || (CharacterOwner->GetMovementBase() != NULL && CharacterOwner->GetMovementBase()->GetOwner() == Hit.GetActor()))
  19.         {
  20.             // hit a barrier, try to step up
  21.             const FVector GravDir(0.f, 0.f, -1.f);
  22.             if (!StepUp(GravDir, Delta * (1.f - PercentTimeApplied), Hit, OutStepDownResult))
  23.             {
  24.                 UE_LOG(LogCharacterMovement, Verbose, TEXT("- StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString());
  25.                 HandleImpact(Hit, LastMoveTimeSlice, RampVector);
  26.                 SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true);
  27.             }
  28.             else
  29.             {
  30.                 // Don't recalculate velocity based on this height adjustment, if considering vertical adjustments.
  31.                 UE_LOG(LogCharacterMovement, Verbose, TEXT("+ StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString());
  32.                 bJustTeleported |= !bMaintainHorizontalGroundVelocity;
  33.             }
  34.         }
  35.         else if ( Hit.Component.IsValid() && !Hit.Component.Get()->CanCharacterStepUp(CharacterOwner) )
  36.         {
  37.             HandleImpact(Hit, LastMoveTimeSlice, RampVector);
  38.             SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true);
  39.         }
  40.     }
  41. }
复制代码

基本上的移动处理就完成了,移动后还会立刻判断玩家是否进入水中,或者进入Falling状态,如果是的话立刻切换到新的状态。

由于玩家在一帧里面可能会从Walking,Swiming,Falling的等状态不断的切换,所以在每次执行移动前都会有一个iteration记录当前帧的移动次数,如果超过限制就会取消本次的移动模拟行为。

3.2 Falling

Falling状态也算是处理Walking以外最常见的状态,只要玩家在空中(无论是跳起还是下落),玩家都会处于Falling状态。与Walking相似,为了表现的更为平滑流畅,Falling的计算也把一个Tick的移动分成了N段处理(每段的时间不能超过MaxSimulationTimeStep)。在处理每段时,首先计算玩家通过输入控制的水平速度,因为玩家在空中也可以受到玩家控制的影响。随后,获取重力计算速度。重力的获取有点意思,你会发现他是通过Volume体积获取的,


  1. float UMovementComponent::GetGravityZ() const
  2. {
  3.     return GetPhysicsVolume()->GetGravityZ();
  4. }
  5. APhysicsVolume* UMovementComponent::GetPhysicsVolume() const
  6. {
  7.     if (UpdatedComponent)
  8.     {
  9.         return UpdatedComponent->GetPhysicsVolume();
  10.     }
  11.     return GetWorld()->GetDefaultPhysicsVolume();
  12. }
复制代码


Volume里面会取WorldSetting里面的GlobalGravityZ,这里给我们一个提示,我们可以通过修改代码实现不同Volume的重力不同,实现自定义的玩法。注意,即使我们没有处在任何一个体积里面,他也会给我们的UpdateComponent绑定一个默认的DefaultVolume。那为什么要有一个DefaultVolume?因为在很多逻辑处理上都需要获取DefaultVolume以及里面的相关的数据。比如,DefaultVolume有一个TerminalLimit,在通过重力计算下降速度的时候不可以超过这个设置的速度,我们可以通过修改该值来改变速度的限制。默认情况下,DefaultVolume里面的很多属性都是通过ProjectSetting里面的Physics相关配置来初始化的。参考图3-4,

图3-4

通过获取到的Gravity计算出当前新的FallSpeed(NewFallVelocity里面计算,计算规则很简单,就是单纯的用当前速度-Gravity*deltaTime)。随后再根据当前以及上一帧的速度计算出位移并进行移动,公式如下


  1. FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick;
  2. SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);
复制代码

前面我们计算完速度并移动玩家后,也一样要考虑到移动碰撞问题。

第一种情况就是正常落地,如果玩家计算后发现碰撞到一个可以站立的地形,那直接调用ProcessLanded进行落地操作(这个判断主要是根据碰撞点的高度来的,可以筛选掉墙面)。

第二种情况就是跳的过程中遇到一个平台,然后检测玩家的坐标与当前碰撞点是否在一个可接受的范围(IsWithinEdgeTolerance),是的话就执行FindFloor重新检测一遍地面,检测到的话就执行落地流程。

第三种情况是就是墙面等一些不可踩上去的,下落过程如果碰到障碍,首先会执行HandleImpact给碰到的对象一个力。随后调用ComputeSlideVector计算一下滑动的位移,由于碰撞到障碍后,玩家的速度会有变化,这时候重新计算一下速度,再次调整玩家的位置与方向。如果玩家这时候有水平方向上的位移,还会通过LimitAirControl来限制玩家的速度,毕竟玩家在空中是无法自由控制角色的。对第三种情况做进一步的延伸,可能会出现碰撞调整后又碰到了另一个墙面,这里Falling的处理可以让玩家在两个墙面找到一个合适的位置。但是仍然不能解决玩家被夹在两个斜面但是却无法落地的情况(或者在Waling和Falling中不断切换)。如果有时间,我们后面可以尝试解决这个问题,解决思路可以从FindFloor下的ComputeFloorDist函数入手,目的就是让这个情况下玩家可以找到一个可行走的地面。

图3-5夹在缝隙导致不停的切换状态

3.2.1 Jump

提到Falling,不得不提跳跃这一基本操作。下面大致描述了跳跃响应的基本流程,

1.绑定触发响应事件

  1. void APrimalCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
  2. {
  3.     // Set up gameplay key bindings
  4.     check(PlayerInputComponent);
  5.     PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
  6.     PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);
  7. }
  8. void ACharacter::Jump()
  9. {
  10.     bPressedJump = true;
  11.     JumpKeyHoldTime = 0.0f;
  12. }

  13. void ACharacter::StopJumping()
  14. {
  15.     bPressedJump = false;
  16.     ResetJumpState();
  17. }
复制代码

2.一旦按键响应立刻设置bPressedJump为true。TickComponent的帧循环调用ACharacter::CheckJumpInput来立刻检测到是否执行跳跃操作

执行CanJump()函数,处理蓝图里面的相关限制逻辑。如果蓝图里面不重写该函数,就会默认执行ACharacter::CanJumpInternal _ Implementation()。这里面是控制玩家能否跳跃的依据,比如蹲伏状态不能跳跃,游泳状态不能跳跃。另外,有一个JumpMaxHoldTime表示玩家按键超过这个值后不会触发跳跃。JumpMaxCount表示玩家可以执行跳跃的段数。(比如二段跳)

执行CharacterMovement-&gt;DoJump(bClientUpdating)函数,执行跳跃操作,进入Falling,设置跳跃速度为JumpZVelocity,这个值不能小于0。

判断const bool bDidJump=canJump&&CharacterMovement&&DoJump;是否为真。做一些其他的相关操作。

做Jump计数以及相关事件分发

3.在一次PerformMovement结束后,就会执行ClearJumpInput,设置设置bPressedJump为false。但是不会清除JumpCurrentCount这样可以继续处理多段跳。

4.玩家松开按键p也会设置bPressedJump为false,清空相关状态。如果玩家仍在空中,那也不会清除JumpCurrentCount。一旦bPressedJump为false,就不会处理任何跳跃操作了。

5.如果玩家在空中按下跳跃键,他也会进入ACharacter::CheckJumpInput,如果JumpCurrentCount小于JumpMaxCount,玩家就可以继续执行跳跃操作了。

图3-6

3.3 Swiming

各个状态的差异本质有三个点:

速度的不同

受重力影响的程度

惯性大小

游泳状态表现上来看是一个有移动惯性(松手后不会立刻停止),受重力影响小(在水中会慢慢下落或者不动),移动速度比平时慢(表现水有阻力)的状态。而玩家是否在水中的默认检测逻辑也比较简单,就是判断当前的updateComponent所在的Volume是否是WaterVolume。(在编辑器里面拉一个PhysicsVolume,修改属性WaterVolume即可)

CharacterMovement组件里面有浮力大小配置Buoyancy,根据玩家潜入水中的程度(ImmersionDepth返回0-1)可计算最终的浮力。随后,开始要计算速度了,这时候我们需要获取Volume里面的摩擦力Friction,然后传入CalcVelocity里面,这体现出玩家在水中移动变慢的效果。随后在Z方向通过计算浮力大小该计算该方向的速度,随着玩家潜水的程度,你会发现玩家在Z方向的速度越来越小,一旦全身都浸入了水中,在Z轴方向的重力速度就会被完全忽略。


  1. // UCharacterMovementComponent::PhysSwimming
  2. const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction * Depth;
  3. CalcVelocity(deltaTime, Friction, true, BrakingDecelerationSwimming);
  4. Velocity.Z += GetGravityZ() * deltaTime * (1.f - NetBuoyancy);

  5. // UCharacterMovementComponent::CalcVelocity Apply fluid friction
  6. if (bFluid)
  7. {
  8.     Velocity
复制代码

图3-7角色在水体积中飘浮

速度计算后,玩家就可以移动了。这里UE单独写了一个接口Swim来执行移动操作,同时他考虑到如果移动后玩家离开了水体积而且超出水面过大,他机会强制把玩家调整到水面位置,表现会更好一些。

接下来还要什么,那大家可能也猜出来了,就是处理移动中检测到碰撞障碍的情况。基本上和之前的逻辑差不多,如果可以踩上去(StepUp())就调整玩家位置踩上去,如果踩不上去就给障碍一个力,然后顺着障碍表面滑动一段距离(HandleImpact,SlideAlongSurface)。

那水中移动的惯性表现是怎么处理的呢?其实并不是水中做了什么特殊处理,而是计算速度时有两个传入的参数与Walking不同。一个是Friction表示摩擦力,另一个是BrakingDeceleration表示刹车的反向速度。

在加速度为0的时候(表示玩家的输入已经被清空),水中的传入的摩擦力要远比地面摩擦里小(0.15:8),而刹车速度为0(Walking为2048),所以ApplyVelocityBraking在处理的时候在Walking表现的好像立刻刹车一样,而在Swim和fly等情况下就好像有移动惯性一样。


  1. // Only apply braking if there is no acceleration, or we are over our max speed and need to slow down to it.
  2. if ((bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax)
  3. {
  4.     const FVector OldVelocity = Velocity;

  5.     const float ActualBrakingFriction = (bUseSeparateBrakingFriction ? BrakingFriction : Friction);
  6.     ApplyVelocityBraking(DeltaTime, ActualBrakingFriction, BrakingDeceleration);

  7.     //Don't allow braking to lower us below max speed if we started above it.
  8.     if (bVelocityOverMax && Velocity.SizeSquared() < FMath::Square(MaxSpeed) && FVector::DotProduct(Acceleration, OldVelocity) > 0.0f)
  9.     {
  10.         Velocity = OldVelocity.GetSafeNormal() * MaxSpeed;
  11.     }
  12. }
复制代码


3.4 Flying

终于讲到了最后一个移动状态了,如果你想调试该状态的话,在角色的移动组件里面修改DefaultLandMovementMode为Flying即可。

Flying和其他状态套路差不多,而且相对更简单一些,首先根据前面输入计算Acceleration,然后根据摩擦力开始计算当前的速度。速度计算后调用SafeMoveUpdatedComponent进行移动。如果碰到障碍,就先看能不能踩上去,不能的话处理碰撞,沿着障碍表面滑动。


  1. //UCharacterMovementComponent::PhysFlying
  2. //RootMotion Relative
  3. RestorePreAdditiveRootMotionVelocity();

  4. if( !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity() )
  5. {
  6.     if( bCheatFlying && Acceleration.IsZero() )
  7.     {
  8.         Velocity = FVector::ZeroVector;
  9.     }
  10.     const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction;
  11.     CalcVelocity(deltaTime, Friction, true, BrakingDecelerationFlying);
  12. }
  13. //RootMotion Relative
  14. ApplyRootMotionToVelocity(deltaTime);
复制代码

有一个关于Flying状态的现象大家可能会产生疑问,当我设置默认移动方式为Flying的时候,玩家可以在松开键盘后进行滑行一段距离(有惯性)。但是使用GM命令的时候,为什么就像Walking状态一样,松开按键后立刻停止?

其实时代码对cheat Flying做了特殊处理,玩家松开按键后,加速度变为0,这时候强制设置玩家速度为0。所以使用GM的表现与实际上的不太一样。

3.5 FScopedMovementUpdate延迟更新

FScopedMovementUpdate并不是一种状态,而是一种优化移动方案。因为大家在查看引擎代码时,可能会看到在执行移动前会有下面这样的代码:

  1. // Scoped updates can improve performance of multiple MoveComponent calls.
  2. {
  3.     FScopedMovementUpdate ScopedMovementUpdate(UpdatedComponent, bEnableScopedMovementUpdates ? EScopedUpdate::DeferredUpdates : EScopedUpdate::ImmediateUpdates);

  4.     MaybeUpdateBasedMovement(DeltaSeconds);

  5.     //......其他逻辑处理,这里不给出具体代码

  6.     // Clear jump input now, to allow movement events to trigger it for next update.
  7.     CharacterOwner->ClearJumpInput();
  8.     // change position
  9.     StartNewPhysics(DeltaSeconds, 0);

  10.     //......其他逻辑处理,这里不给出具体代码

  11.     OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);
  12. } // End scoped movement update
复制代码

为什么要把移动的代码放到这个大括号里面,FScopedMovementUpdate又是什么东西?仔细回想一下我们前面具体的移动处理逻辑,在一个帧里面,我们由于移动的不合法,碰到障碍等可能会多次重置或者修改我们的移动。如果只是简单修改胶囊体的位置,其实没什么,不过实际上我们还要同时修改子组件的位置,更新物理体积,更新物理位置等等,而计算过程中的那些移动数据其实是没有用的,我们只需要最后的那个移动数据。

因此使用FScopedMovementUpdate可以在其作用域范围内,先锁定不更新物理等对象的移动,等这次移动真正的完成后再去更新。(等到FScopedMovementUpdate析构的时候再处理)用到FScopedMovementUpdate的地方有很多,基本上涉及到移动回滚的逻辑都会有。

待续......

作者: Jerish  
来源:游戏开发那些事
原地址:https://mp.weixin.qq.com/s/_Z_anP-55k6lKXYxHds1Vw

最新评论
暂无评论
参与评论