0%

UE4学习笔记—仿CS后坐力效果实现

初学UE4,笔者做了个练枪训练场的demo(Github地址)。 本文将介绍该项目中武器后坐力效果的实现,主要模仿硬核向FPS游戏CSGO。

真男人的AK,怎么能够没有后座

后坐力的构成

在连续的射击中,后坐力主要有以下几个方面构成:

  • 准心的偏移
  • 准心的方向性扩散
  • 准心的随机扩散
  • 镜头抖动

其中准心的扩散指的就是实际射击的点和准心的位置不一致,方向性扩散是指每次连续射击中,扩散弹道随射击时间成一条固定轨迹,俗称的压枪也就是根据这条轨迹来压的。 而随机扩散指的就是在方向性扩散基础上,加上随机的抖动值,使得更加真实。

除此之外,在连点射情况下,还需要考虑下面两个问题:

  • 后坐力的保持和衰减
  • 射击后准心回弹

武器类实现大纲

首先,我们需要实现基本的武器类功能,下面就是笔者实现的武器类的核心函数。包含开火、命中判定、开火效果等逻辑。

FPSHeroWeapon.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void AFPSHeroWeapon::Fire()
{
IsFiring = true;
if(Mode == FireMode::Auto && ShootIntervalSecond > 0)
GetWorld()->GetTimerManager().SetTimer(FireTimer, this, &AFPSHeroWeapon::SingleFire, ShootIntervalSecond, true, ShootIntervalSecond);
SingleFire();
}

void AFPSHeroWeapon::EndFire()
{
IsFiring = false;
GetWorld()->GetTimerManager().ClearTimer(FireTimer);
}

void AFPSHeroWeapon::SingleFire()
{
if (Owner) {
// 得到摄像机的位置和朝向
FVector eyeLoc;
FRotator eyeRot;
Owner->GetActorEyesViewPoint(eyeLoc, eyeRot);
FVector eyeDir = eyeRot.Vector();

// 得到光线追踪目标点
FVector traceEnd = eyeLoc + eyeDir * 10000;
FHitResult Hit;
FCollisionQueryParams para;
// 忽略角色和枪模型
para.AddIgnoredActor(Owner);
para.AddIgnoredActor(this);
// 使用复杂碰撞来求交
para.bTraceComplex = true;
para.bReturnPhysicalMaterial = true;
if (GetWorld()->LineTraceSingleByChannel(Hit, eyeLoc, traceEnd, TRACECHANNEL_WEAPON, para)) {
// 处理命中效果
dealHit(Hit);
}
PlayFireEffect();
}
}

void AFPSHeroWeapon::PlayFireEffect()
{
//枪口特效
if (MussleEffect) {
UGameplayStatics::SpawnEmitterAttached(MussleEffect, MeshComp, MussleName);
}
//音效
if(FireSound)
UGameplayStatics::PlaySoundAtLocation(this, FireSound, GetActorLocation());
}

后坐力系统的实现

后坐力基类

首先,为了减少耦合,笔者单独为后坐力系统创建一个类型。根据前文所述后坐力的构成,该类型所容纳的函数接口也就不言而喻。

FPSHeroRecoilBase.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
UCLASS(BlueprintType, Blueprintable, Abstract, EditInlineNew, Category = "Weapon")
class FPSHERO_API UFPSHeroRecoilBase : public UObject
{
GENERATED_BODY()
public:
UFPSHeroRecoilBase();

// 获取随着射击过程的准心偏移量
UFUNCTION(BlueprintImplementableEvent, Category = "Recoil")
void GetCameraMovement(float AmmoIndex, float& pitch, float& yaw);

// 获取随着射击过程的方向性准心扩散
UFUNCTION(BlueprintImplementableEvent, Category = "Recoil")
//Spread value equal to 1 means 45°spread
void GetDirectionalSpread(float AmmoIndex, float& SpreadUp, float& SpreadRight);

// 获取随着射击过程的随机准心扩散
UFUNCTION(BlueprintImplementableEvent, Category = "Recoil")
//Spread value equal to 1 means 45°spread
void GetRandomSpread(float AmmoIndex, float& scale);

// 获取射击结束后的镜头随时间回弹量
UFUNCTION(BlueprintImplementableEvent, Category = "Recoil")
//From 0 to 1 with TimeSinceStop. Totally restored when return 1.
void GetCameraRestoreRatio(float TimeSinceStop, float& RestoreRatio);

// 触发镜头抖动
UFUNCTION(BlueprintImplementableEvent, Category = "Recoil")
void ApplyCameraShake(float AmmoIndex, APlayerController* controller);

// 后坐力恢复时间
UPROPERTY(EditAnywhere, Category = "Recoil")
float RecoilRestoreTime = 0.5;
};

此处为了方便起见,笔者假设后坐力恢复与时间是线性关系的,因此只设了一个后坐力恢复时间的参数。如果需要更加深入地控制,则需要想镜头回弹一样,返回后坐力恢复程度与时间的一个关系,更进一步的话,甚至需要单独设置准心偏移,准心扩散等随时间恢复的接口。

后坐力逻辑

为了实现后坐力,我们需要在武器类中添加下面几个变量。

FPSHeroWeapon.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FPSHERO_API AFPSHeroWeapon : public AActor
{
...
public:
// 后坐力类实例
UPROPERTY(EditDefaultsOnly, Instanced, Category = "Recoil")
UFPSHeroRecoilBase* RecoilInstance;
protected:
// 统计之前射出的子弹数,用于调节后坐力,停止开火后逐渐衰减
float CurrentFiredAmmo = 0;
// 统计停火后“休息”时间
float SecondsSinceStopFire = 0;
// 记录停止开火时的累计射击子弹数
float FiredAmmoWhenStop = 0;
// 统计镜头偏移量
float PitchOffset = 0, YawOffset = 0;
// 记录停止开火时的镜头偏移
float PitchOffsetWhenStop = 0, YawOffsetWhenStop = 0;
}

首先,我们修改每一次开火的逻辑,实现后坐力累加和子弹扩散。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void AFPSHeroWeapon::SingleFire()
{
CurrentFiredAmmo++;
if (Owner) {
// 得到摄像机的位置和朝向
FVector eyeLoc;
FRotator eyeRot;
Owner->GetActorEyesViewPoint(eyeLoc, eyeRot);
FVector eyeDir = eyeRot.Vector();

/* 后坐力扩散 */
// 方向性扩散
// 首先得到镜头的Up、Right方向在世界坐标系下的方向向量,再乘上对应方向的扩散系数
eyeDir.Normalize();
FVector UpDir(0, 0, 1);
FVector RightDir = FVector::CrossProduct(eyeDir, UpDir);
RightDir.Normalize();
float SpreadUp=0, SpreadRight=0;
if (RecoilInstance) {
RecoilInstance->GetDirectionalSpread(CurrentFiredAmmo, SpreadUp, SpreadRight);
}
eyeDir += UpDir * SpreadUp + RightDir * SpreadRight;
eyeDir.Normalize();
// 随机扩散
// 根据扩散系数,在圆锥上取随机向量
float SpreadScale = 0;
if (RecoilInstance) {
RecoilInstance->GetRandomSpread(CurrentFiredAmmo, SpreadScale);
}
eyeDir = UKismetMathLibrary::RandomUnitVectorInConeInRadians(eyeDir, SpreadScale);

// 得到光线追踪目标点
FVector traceEnd = eyeLoc + eyeDir * 10000;
// 处理求交
...
PlayFireEffect();
}
}

在PlayFireEffect中,我们施加准心偏移和镜头抖动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void AFPSHeroWeapon::PlayFireEffect()
{
//枪口特效
...
//音效
...
if (RecoilInstance) {
// 镜头抖动
RecoilInstance->ApplyCameraShake(CurrentFiredAmmo, Controller);
// 准心偏移
float RecoilPitch, RecoilYaw;
RecoilInstance->GetCameraMovement(CurrentFiredAmmo, RecoilPitch, RecoilYaw);
ApplyNewRecoilCameraOffset(RecoilPitch, RecoilYaw);
}
}

void AFPSHeroWeapon::ApplyNewRecoilCameraOffset(float Pitch, float Yaw)
{
if (!Owner)
return;
APlayerController* controller = Cast<APlayerController>(Owner->GetController());
if (controller) {
// 取反使得正值为镜头向上
controller->AddPitchInput(-(Pitch - PitchOffset));
// 取反使得正值为镜头向右
controller->AddYawInput(-(Yaw - YawOffset));
PitchOffset = Pitch;
YawOffset = Yaw;
}
}

在EndFire中,我们记录准心偏移和后坐力的当前,以便于之后进行恢复

1
2
3
4
5
6
7
8
9
void AFPSHeroWeapon::EndFire()
{
IsFiring = false;
SecondsSinceStopFire = 0;
FiredAmmoWhenStop = CurrentFiredAmmo;
PitchOffsetWhenStop = PitchOffset;
YawOffsetWhenStop = YawOffset;
GetWorld()->GetTimerManager().ClearTimer(FireTimer);
}

最后,在每一帧中,不在开火的情况下,我们处理后坐力恢复和准心恢复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
AFPSHeroWeapon::AFPSHeroWeapon()
{
...
// 记得要打开Actor的Tick开关
this->PrimaryActorTick.bCanEverTick = true;
}

void AFPSHeroWeapon::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if(!IsFiring){
// 停止射击后,计算后坐力恢复和准心恢复
SecondsSinceStopFire += DeltaSeconds;
// 后坐力恢复
if (CurrentFiredAmmo > 0 && RecoilInstance) {
CurrentFiredAmmo = FGenericPlatformMath::Max(0.0f,
FGenericPlatformMath::CeilToFloat((RecoilInstance->RecoilRestoreTime - SecondsSinceStopFire)
/ RecoilInstance->RecoilRestoreTime * FiredAmmoWhenStop));
}
// 准心恢复
float RestoreRatio = 1;
if (RecoilInstance)
RecoilInstance->GetCameraRestoreRatio(SecondsSinceStopFire, RestoreRatio);
float TargetPicth = PitchOffsetWhenStop * (1 - RestoreRatio);
float TargetYaw = YawOffsetWhenStop * (1 - RestoreRatio);
ApplyNewRecoilCameraOffset(TargetPicth, TargetYaw);
}
}

后坐力蓝图类

之前我们创建的后坐力基类只是抽象类,需要蓝图中进一步完善。笔者在此处创建了一个BP_DefaultRecoil类,在该类中,每一个后坐力分量随射击子弹数的变化关系采用UE4曲线资产进行控制。

以镜头偏移为例:

首先分配变量,类型为Curve Float,并且设为公开。


重载GetCameraMovement方法,查询曲线值进行返回。

对于镜头抖动,在蓝图类中设置CameraShake类,重载ApplyCameraShake方法。


应用后坐力蓝图类

经过上面的工作,我们已经能在武器的蓝图子类中设置后坐力类了,由于将后坐力类对象设为了Instanced,我们能在武器类中进行后坐力类参数的调节。

最后,要实现猛男AK的强力手感,一番调参是必不可少的。此处仅供参考。

Pitch

Yaw

UpSpread

RightSpread

Sprad

CameraRestore

DefaultCameraShake

展示

连续射击

猛男压枪

短点射