这篇博客来自于Fabrice Piquet,翻译工作已获得作者授权,原文传送门。
我决定分享一下我在当前项目中处理真实第一人称相机的方法。针对真实第一人称视角,目前没有太多相关的文档。因此研究一段时间过后,尤其是当前项目中我花了不少时间去解决一些存在的难题过后,我决定写一篇相关的文章。项目中最终的效果如下:
何为真实第一人称(True First Person,TFP)呢?在某些场景下,它也被称为“身体意识(Body Awareness)”。相对于仅仅是一个悬浮的相机来说,它是一个真实运动的,模拟角色真实身体运动的身体的第一人称视角。拥有这类视角的游戏有:
超世纪战警:暗黑雅典娜
暴力辛迪加
镜之边缘
在一个手臂和身体分离的系统中,角色的两只手和身体是分开的,从而直接将手臂attach到相机上。这样可以在确保手是跟随相机进行运动的同时,还能够对手臂进行操作。身体的剩下的部分通常也是分开的,它们通常也会有自己的动画系统。
这个系统的问题在于在做一个全身动画(例个重力缓冲效果)的时候,它要求这两个独立的动画系统进行严格的同步(这对动画的制作以及在引擎中的逻辑都有着对应的要求)。有时候游戏使用一个只对操纵玩家可见的模型来模拟,全身的模型用于渲染角色的阴影(以及在多人游戏中对于其他玩家显示,在最近的使命召唤系列游戏中,这种方法运用的比较多)。
如果对于优化以及特殊表现有着比较高的要求,那么这种方法是适合的。但是如果游戏追求可信度和沉浸感,那么我并不推荐这种方式。由于这并不是我需要的方法,因此针对于这种方式我并不准备介绍过多。再说了现在互联网上有很多很多相关的教程,这里就不再赘述了。
针对于全身模型,我们不使用隔离的动画系统。相反,我们使用一整个的全身模型来表现角色。对应的相机attach在头部,这也就意味着由你的身体动画来驱动它。我们不直接进行相机的位置或朝向的数值修改,最终,整个类的架构如下:
PlayerControler -> Character -> Mesh -> AnimBlueprint -> Camera
针对PlayerController
,其实没什么好说的,在UE中它总是在Character
或者Pawn
之上。Character有一个表示身体的Mesh
,而这个Mesh
有一个针对全身骨骼进行操作的AnimBlueprint
。最后,我们有一个在Constructor中attach到头上的相机。
那么现在相机已经attach到头上了,我们完成了吗?当然没有。因为相机是由骨骼驱动的,我们需要实现基本的相机操作:向上下左右看。可以通过使用Additive animation
来制作。所谓的Additive animation
是一帧的动画,用于把各个骨骼的offset给apply上去。总体来说,我是用了10个动画,当然你可以使用更多的pose,但是我发现更多的动画就不再必要了。
在我们的项目中,我设置当玩家向左/右看时,整个人的身体也会向左/右转(就像上面的镜之边缘的gif图一样)。此外,还有一个专门为角色idle设置的additional animation
,这层动画在这些动画层级之上。效果如下:
当这些动画被成功导入引擎中后,我们需要设置一些东西。首先起一个好名字,来确保自己日后能够找到它。在我们的项目中,我将其命名为“anim_idle_additive_base”。针对其他的pose动画,我将其进行Additive Setting。具体来讲就是将Additive Anim Type
参数设定为Mesh Space
,并且将Base Pose Type
设定为Selected Animation
。最后,将Base Pose Animation
设定好即可。针对每个Pose重复以上过程即可。
将动画资源准备好后,就可以创建Aim Offset了。Aim Offset指的是允许开发者依据输入的参数,在多个动画中进行平滑Blending操作的东西。针对更多的内容,可以参考官方的文档:Aim Offset。当设定完毕后,效果如下:
我自己的Aim Offset
使用两个参数进行驱动:Pitch和Yaw。这两个数值在代码内进行逻辑更新,细节如下:
我们需要将玩家针对相机的输入转化为驱动Aim Offset
的值,我通过下面三步来进行处理:
PlayerController
里将游戏输入转化为旋转值Character
中将世界空间下的旋转值转化为本地空间Anim Blueprint
当玩家移动鼠标或者手柄摇杆时,我需要将这些值在PlayerController
中接收,并通过重写UpdateRotation()
函数转化为对应的旋转值。
void AExedrePlayerController::UpdateRotation(float DeltaTime)
{
if( !IsCameraInputEnabled() )
return;
float Time = DeltaTime * (1 / GetActorTimeDilation());
FRotator DeltaRot(0,0,0);
DeltaRot.Yaw = GetPlayerCameraInput().X * (ViewYawSpeed * Time);
DeltaRot.Pitch = GetPlayerCameraInput().Y * (ViewPitchSpeed * Time);
DeltaRot.Roll = 0.0f;
RotationInput = DeltaRot;
Super::UpdateRotation(DeltaTime);
}
需要注意的是,UpdateRotation
方法在PlayerController
类中每帧都会调用一次。我考虑了GetActorTimeDilation()
函数,因此当使用slomo
方法时,相机转动的速度不会变动。
我的Character类中有一个PreUpdateCamera()
函数,该函数如下:
void AExedreCharacter::PreUpdateCamera( float DeltaTime )
{
if( !FirstPersonCameraComponent || !EPC || !EMC )
return;
//-------------------------------------------------------
// Compute rotation for Mesh AIM Offset
//-------------------------------------------------------
FRotator ControllerRotation = EPC->GetControlRotation();
FRotator NewRotation = ControllerRotation;
// Get current controller rotation and process it to match the Character
NewRotation.Yaw = CameraProcessYaw( ControllerRotation.Yaw );
NewRotation.Pitch = CameraProcessPitch( ControllerRotation.Pitch + RecoilOffset );
NewRotation.Normalize();
// Clamp new rotation
NewRotation.Pitch = FMath::Clamp( NewRotation.Pitch, -90.0f + CameraTreshold, 90.0f - CameraTreshold);
NewRotation.Yaw = FMath::Clamp( NewRotation.Yaw, -91.0f, 91.0f);
//Update loca variable, will be retrived by AnimBlueprint
CameraLocalRotation = NewRotation;
}
函数CameraProcessYaw()
和CameraProcessPitch()
将Controller
的世界坐标系旋转值转化为本地坐标系下的旋转值。这两个函数如下:
float AExedreCharacter::CameraProcessPitch( float Input )
{
//Recenter value
if( Input > 269.99f )
{
Input -= 270.0f;
Input = 90.0f - Input;
Input *= -1.0f;
}
return Input;
}
float AExedreCharacter::CameraProcessYaw( float Input )
{
//Get direction vector from Controller and Character
FVector Direction1 = GetActorRotation().Vector();
FVector Direction2 = FRotator(0.0f, Input, 0.0f).Vector();
//Compute the Angle difference between the two dirrection
float Angle = FMath::Acos( FVector::DotProduct(Direction1, Direction2) );
Angle = FMath::RadiansToDegrees( Angle );
//Find on which side is the angle difference (left or right)
FRotator Temp = GetActorRotation() - FRotator(0.0f, 90.0f, 0.0f);
FVector Direction3 = Temp.Vector();
float Dot = FVector::DotProduct( Direction3, Direction2 );
//Invert angle to switch side
if( Dot > 0.0f )
{
Angle *= -1;
}
return Angle;
}
(译者按:使用欧拉角真的没问题吗?万象的话该怎么办orz)
最后一步也是最简单的一步,我通过Event Blueprint Update Animation
节点来获取上述的值,并且将其作为Aim Offset
的控制变量:
这个问题有时很多人并不重视,但是这的确是个问题。如果你是按照上面的设置走下来的并且你不是太清楚Tick()
函数在UE中是怎么运作的,你会遇到这个问题:有一帧的延迟。
这一帧的延迟会很蛋疼,而且有可能会造成很糟糕的游戏体验——基本上来讲这一帧的相机总是会基于上一帧的数据。这意味着如果你快速移动鼠标然后突然停止,那么实际上你会在下一帧才停止。无论你的帧率是多少,这个问题都会存在。
解决这个问题的方案需要对Tick
函数有一些理解,在默认状况下,Tick
函数执行顺序如下:
_ _ _ _ _ UpdateTimeAndHandleMaxTickRate (Engine function)
_ _ _ _ _ Tick_PlayerController
_ _ _ _ _ Tick_SkeletalMeshComponent
_ _ _ _ _ Tick_AnimInstance
_ _ _ _ _ Tick_GameMode
_ _ _ _ _ Tick_Character
_ _ _ _ _ Tick_Camera
那么在这里发生了什么事呢?可以看见Character
类的Tick
顺序是在AnimBlueprint
之后的,这意味着在这一帧的AnimBlueprint
更新时,对应的Character
还没更新。
为了解决这个问题,我并没有在Character
的Tick
函数中执行PreUpdateCamera()
方法,我将这个方法的调用放在PlayerController
的Tick
函数中。通过这样的方法,我确保了对应的值是实时最新的。
整体来讲,这个系统已经可以工作了。下一步就是去播放一个可以作用于整个身体的动画。为了做到这一点,我们使用AnimMontage。在这个项目中,我需要让人物在落地后,播放一个重力缓冲的动画。该动画如下:
代码很简单,可能在Blueprint中更简单:
void AExedreCharacter::PlayAnimLanding()
{
if( MeshBody != nullptr )
{
if( EPC != nullptr )
{
EPC->SetMovementInputEnabled( false );
EPC->SetCameraInputEnabled( false );
EPC->ResetFallingTime();
}
//Snap mesh
FRotator TargetRotation = FRotator::ZeroRotator;
if( EPC != nullptr )
{
TargetRotation.Yaw = EPC->GetControlRotation().Yaw;
}
else
{
TargetRotation.Yaw = GetActorRotation().Yaw;
}
SetActorRotation( TargetRotation );
//Start anim
SetPerformingMontage(true);
TotalMontageDuration = MeshBody->AnimScriptInstance->Montage_Play(AnmMtgLandingFall, 1.0f);
LatestMontageDuration = TotalMontageDuration;
//Set Timer to the end of the duration
FTimerHandle TimeHandler;
this->GetWorldTimerManager().SetTimer(TimeHandler, this, &AExedreCharacter::PlayAnimLandingExit, TotalMontageDuration - 0.01f, false);
}
}
这段代码做的事是取消玩家的输入,然后播放Montage。我设定了一个Timer,从而在动画结束的时候重新开启输入。如果你是这么做的,那么你会获得这样的结果:
这并不是我们想要的效果。发生这种情况的原因是Anim slot
先于Anim Offset
节点就被设置了。因此当播放全身动画时,这个aim offset
就直接被加上去了。因此如果玩家看着地面再播放这个动画,那么这个偏移就会变成双份。
那么我们为什么要将Aim offset
放在之后进行计算呢?实际上这只是为了在状态之间进行更顺滑的切换。如果在Aim offset之后再进行montage的播放,那么整个的切换会非常尖锐。
为了解决这个问题,我将Camera Rotation
值进行了一次重置。我在PreUpdateCamera
函数中加入了如下代码:
//-------------------------------------------------------
// Blend Pitch to 0.0 if we are performing a montage (input are disabled)
//-------------------------------------------------------
if( IsPerformingMontage() )
{
//Reset camera rotation to 0 for when the Montage finish
FRotator TargetControl = EPC->GetControlRotation();
TargetControl.Pitch = 0.0f;
float BlenSpeed = 300.0f;
TargetControl = FMath::RInterpConstantTo( EPC->GetControlRotation(), TargetControl, DeltaTime, BlenSpeed);
EPC->SetControlRotation( TargetControl );
}
以上的代码只是在下落过程中,在本地相机的旋转值计算之前,将其Pitch值通过RInterpConstantTo()
函数逐渐设为0.以下是最终效果:
相比来讲好多了。在此之外,可以再做一个在Montage结尾的时候,将其设回最初的Rotation,但是这个在这个项目中并不太重要。
最后一点,当使用全身动画时,需要注意那些针对头部的运动操作。不停点头、快速转身之类的快速动画容易使得玩家感到恶心。因此跑步和走路的动画需要尽可能的稳定。这一点和VR中的眩晕很类似——产生这种眩晕的原因是玩家的感觉和看到的东西并不一致。
在我的项目中,我针对了大部分的重复动画(例如跑步)使用了一个方法——将玩家的角色进行约束,让其总是看着很远处的一个固定点。这样的方法能够使得头部尽量聚焦于一点,从而稳定相机。
在AnimationBP的这一层之后,你可以使用一些额外的处理来进行身体动画的操作。这样做的好处是可以很好的进行状态之间的切换,并且减少眩晕感。
<全文完>