이번 포스팅에서 다룰 Weapon은 단순히 입력에 그치지 않고, trace, montage, cosmetic, multicast, automatic fire까지 이어지는 발사 파이프라인에 대한 고민을 담게 되었다.

사실 이런 장면을 연출하는건 FPS프로젝트에서 누구나 한번 해봤지만, 어떻게 해야 좋은 설계일까는 좀 더 고민해봐야 하는 주제다.
이번 포스팅은 이러한 발사 파이프라인을 어떻게해야 효율적이고 데이터지향적이게, 그리고 기획 친화적이게 만들 수 있을까하는 고민이 담겨있다.
일단 크게 책임분리부터 하면 다음과 같다.
Character는 입력만 전달하고, CombatComponent는 현재 장착 중인 무기를 기준으로 발사 흐름을 조율한다.
Weapon은 trace와 cosmetic 진입점을 제공하며, 실제 이펙트 구현은 Blueprint에서 처리한다.
이러한 사실을 머리에 인지하고 포스팅을 시작한다.
무기 종류를 분리하는것은 항상 Enum의 역할이었다.
그러나 앞선 포스팅에서 설명한대로 Enum보다는 Tag가 확장측면에서 압도적인 효율을 가지고 있기에, 여기서 이 Tag가 어떻게 다뤄지는지 상세하게 다뤘다.
무기의 종류는 GameplayTag로 표현하고, 무기별로 가지고 있는 데이터는 WeaponData에 모았다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "FPS|WeaponType")
FGameplayTag WeaponType;
무기 자체는 자신이 어떤 타입인지 WeaponType으로 가진다.
그리고 WeaponData는 이 tag를 key로 사용해서 무기별 montage나 animation 데이터를 찾는다.
USTRUCT(BlueprintType)
struct FMontageData
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TObjectPtr<UAnimMontage> EquipMontage = nullptr;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TObjectPtr<UAnimMontage> ReloadMontage = nullptr;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TObjectPtr<UAnimMontage> FireMontage = nullptr;
};
즉 각 무기별로 MontageData라는 Struct와 기존의 Weapon DA를 가지고 있다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "FPS|WeaponData|FirstPerson")
TMap<FGameplayTag, FMontageData> FirstPersonMontages;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "FPS|WeaponData|ThirdPerson")
TMap<FGameplayTag, FMontageData> ThirdPersonMontages;
에디터에서는 DA파일을 연 후 Montage만 수정하게끔 만들면 된다.

발사 시 montage를 찾을 때도 현재 무기의 tag를 사용한다.

BP Weapon에서 등록했었던 TAG를 통해 아래 Montage는 발사시마다 자신의 DA를 찾아 해당 Montage를 실행시킨다.
UAnimMontage* Montage1P =
WeaponData
->FirstPersonMontages
.FindChecked(CurrentWeapon->WeaponType)
.FireMontage;
여기서 의도는 무기별 분기를 코드에 직접 늘어놓지 않는 것이다.
권총이면 권총 montage, 라이플이면 라이플 montage를 if로 고르는 대신, 현재 무기의 tag로 data asset에서 필요한 값을 찾는다.
다만 FindChecked를 사용하기 때문에 WeaponData에 key가 빠져 있으면 즉시 assert가 발생한다.
데이터 누락을 빠르게 드러내는 방식이지만, 실제 프로젝트에서는 Find와 fallback, 혹은 editor validation을 두는 편이 더 안전하다.
발사 입력은 Pressed와 Released 두 흐름으로 나뉜다.
Pressed에서는 trigger 상태를 true로 두고 즉시 한 발을 발사하며, Released에서는 trigger 상태를 false로 돌린다.
void UCombatComponent::Initiate_FireWeapon_Pressed()
{
bTriggerPressed = true;
Local_FireWeapon();
}
void UCombatComponent::Initiate_FireWeapon_Released()
{
bTriggerPressed = false;
}
실제 발사 작업은 Local_FireWeapon에서 진행된다.
이 함수는 현재 무기를 확인하고, 1인칭 fire montage를 재생한 뒤, WeaponTrace로 탄착 정보를 만든다.
이후 Weapon::Local_Fire를 호출해서 로컬 cosmetic을 실행하고, 마지막으로 Server_FireWeapon을 호출해 서버와 다른 클라이언트로 결과를 전달한다.
void UCombatComponent::Local_FireWeapon()
{
if (!IsValid(CurrentWeapon)) return;
ensure(IsValid(WeaponData));
UAnimMontage* Montage1P =
WeaponData
->FirstPersonMontages
.FindChecked(CurrentWeapon->WeaponType)
.FireMontage;
USkeletalMeshComponent* Mesh1P =
IPlayerInterface::Execute_GetMesh1P(GetOwner());
if (IsValid(Montage1P) && IsValid(Mesh1P) && IsValid(Mesh1P->GetAnimInstance()))
{
Mesh1P->GetAnimInstance()->Montage_Play(Montage1P);
}
FHitResult Hit;
CurrentWeapon->WeaponTrace(Hit, TraceLength);
const EPhysicalSurface ImpactSurfaceType =
Hit.PhysMaterial.IsValid(false)
? Hit.PhysMaterial->SurfaceType.GetValue()
: SurfaceType1;
CurrentWeapon->Local_Fire(
Hit.ImpactPoint,
Hit.ImpactNormal,
ImpactSurfaceType,
true
);
Server_FireWeapon(Hit);
}
이 단계에서 눈여겨볼 부분은 로컬 피드백을 먼저 실행한다는 점이다.
발사 버튼을 누른 플레이어는 즉시 1인칭 montage와 muzzle/tracer 효과를 봐야 한다.
서버 왕복 이후에야 피드백이 나오면 조작감이 둔해지기 때문에, 우선 로컬에서 발사 연출을 실행하고 그 결과를 서버 RPC로 넘긴다.
서버는 받은 hit 결과를 multicast로 다시 전파한다.
void UCombatComponent::Server_FireWeapon_Implementation(const FHitResult& Hit)
{
Multicast_FireWeapon(Hit);
}
그리고 multicast에서는 로컬 플레이어가 아닌 다른 머신에서 3인칭 fire montage와 cosmetic을 실행한다.
void UCombatComponent::Multicast_FireWeapon_Implementation(const FHitResult& Hit)
{
APawn* OwningPawn = Cast<APawn>(GetOwner());
if (!IsValid(OwningPawn) || !IsValid(CurrentWeapon)) return;
if (OwningPawn->IsLocallyControlled())
{
return;
}
const EPhysicalSurface ImpactSurfaceType =
Hit.PhysMaterial.IsValid(false)
? Hit.PhysMaterial->SurfaceType.GetValue()
: SurfaceType1;
CurrentWeapon->Local_Fire(
Hit.ImpactPoint,
Hit.ImpactNormal,
ImpactSurfaceType,
false
);
}
여기서 bIsFirstPerson이 false로 넘어가는 점이 중요하다. multicast로 보는 무기는 내 1인칭 무기가 아니라 다른 플레이어의 3인칭 무기이기 때문이다.


WeaponTrace는 이번 파트에서 발사 판정의 기준점을 만드는 함수다.
총구 위치가 아니라 플레이어의 view point, 즉 카메라 기준으로 trace를 수행한다.
PC->GetActorEyesViewPoint(EyesWorldLocation, EyesWorldRotation);
const FVector EyesWorldDirection =
UKismetMathLibrary::GetForwardVector(EyesWorldRotation);
const FVector Start = EyesWorldLocation;
const FVector End = Start + EyesWorldDirection * TraceLength;
FPS에서 실제 조준 기준은 화면 중앙이다.
총구 위치에서 trace를 시작하면 1인칭 카메라와 무기 mesh의 위치 차이 때문에 조준점과 탄착점이 어긋날 수 있다.
그래서 플레이어 시점 방향으로 trace를 쏜다.
trace 방식은 line trace가 아니라 sphere sweep이다.
const bool bHit = GetWorld()->SweepSingleByChannel(
OutHit,
Start,
End,
FQuat::Identity,
FPSTraceChannels::ECC_Weapon,
FCollisionShape::MakeSphere(TraceRadius),
QueryParams,
ResponseParams
);
TraceRadius만큼 두께가 있는 구체를 앞으로 쓸어가며 검사하기 때문에, 완전히 얇은 선 하나보다 약간의 허용 범위를 가진다.
또한 physical material을 반환하도록 설정한다.
QueryParams.bReturnPhysicalMaterial = true;
이 값은 뒤에서 impact effect를 표면 타입에 따라 다르게 재생하기 위해 사용된다.

코드에 bHit검사, 그러니까 대상이 안맞았을때를 처리하지 않으면 이렇게 대상을 찾지 못해 Origin Point나 이상한 궤적으로 Trace 궤적이 생길 수 있다.
전 프로젝트때도 이런 일이 똑같이 있었는데, 때문인지 보자마자 원인을 파악할 수 있었다.
따라서bHit이 false일 때 ImpactPoint를 보정하는건 반드시 필요하다.
if (!bHit)
{
OutHit.ImpactPoint = End;
}

trace가 아무것도 맞추지 못하면 OutHit.ImpactPoint는 의미 있는 충돌 지점으로 채워지지 않는다.
그런데 이후 tracer나 weapon fire cosmetic은 이 값을 목표 지점으로 사용한다.
그래서 허공에 쏜 경우에는 trace 끝점을 impact point처럼 채워준다.
즉 Hit가 성공하게 되면
-> ImpactPoint = 실제 맞은 위치
Hit가 실패하게 되면
-> ImpactPoint = 조준 방향의 trace 끝점
앞선 내용도 그렇고, 멀티에서는 너무나 당연한 이야기이며 보일러플레이트가 곳곳에 있지만, 이 Cosmetic을 단순 Fire에 넣지 않고 이 또한 데이터 뭉치로써 관리하는 방법에 집중해서 봤으면 한다.
발사 판정과 cosmetic을 분리하기 위해 Weapon::Local_Fire와 FireEffects를 만든다.
void AWeapon::Local_Fire(
const FVector& ImpactPoint,
const FVector& ImpactNormal,
TEnumAsByte<EPhysicalSurface> ImpactSurfaceType,
bool bIsFirstPerson
)
{
FireEffects(
ImpactPoint,
ImpactNormal,
ImpactSurfaceType,
bIsFirstPerson
);
}
FireEffects는 C++에서 구현하지 않고 Blueprint에서 구현하는 이벤트다.
UFUNCTION(BlueprintImplementableEvent)
void FireEffects(
const FVector& ImpactPoint,
const FVector& ImpactNormal,
EPhysicalSurface ImpactSurfaceType,
bool bIsFirstPerson
);
즉 CombatComponent는 trace 결과를 Weapon에 넘기고, Weapon은 이 정보를 Blueprint 이벤트로 전달한다.
C++은 발사 시점과 필요한 데이터를 정리하고, 실제 muzzle flash, tracer, shell eject, impact, decal은 Blueprint에서 조립한다.
cosmetic을 C++에 모조리 넣고 Weapon에서 두는 방법도 있지만, BP로 빼는게 오히려 앞서 얘기했던 친화적인 방법이다.
무엇보다 발사 이펙트는 어떤 논리라기보다는 presentation에 가까운 영역이다. 즉 수학적으로 이리저리 싸매는 도구가 아닌, 이런 효과를 연출하기 위한 표현이라는 것이다.
무기별 muzzle flash, tracer, shell eject mesh, decal, sound는 코드보다 에셋과 에디터 조정의 영향을 많이 받는다.
C++에서 모든 이펙트를 직접 생성하도록 만들면 무기별 variation을 만들 때마다 코드가 무거워지고, 에디터에서 빠르게 조정하기도 어렵다.
반대로 Blueprint가 trace나 네트워크 흐름까지 알 필요는 없다. ImpactPoint, ImpactNormal, ImpactSurfaceType, bIsFirstPerson처럼 C++에서 계산한 값만 받아 실제 연출을 구성하면 된다.
C++
-> 발사 시점
-> trace 결과
-> surface type
-> 1인칭 / 3인칭 여부
Blueprint
-> muzzle flash
-> shell eject
-> tracer
-> impact
-> decal
-> sound
또한 dedicated server에서는 cosmetic을 실행할 필요가 없다. 서버는 화면을 렌더링하지 않으므로, BP의 FireEffects 시작부에서 dedicated server 여부를 확인하고 시각 효과를 건너뛰는 구조를 둔다.

여기서 사용하는 cosmetic actor는 크게 세 가지다.

셋다 Lyra에서 만들어져 있는 Asset이며, 이들이 요구로하는 데이터들을 BP에 담아 건네주기만 하면 되는 형태이다.
Weapon Fire는 총구 주변에서 발생하는 발사 순간의 연출이다.
muzzle flash
shell eject
tracer
이 actor는 FireEffects에서 spawn하고, 이후에는 매번 새로 만들지 않고 재사용한다. 강의에서는 WeaponFire 변수가 유효하지 않을 때만 spawn하고, 이미 존재하면 필요한 값만 다시 세팅한 뒤 Fire 이벤트를 호출한다.
여기서 bIsFirstPerson이 중요하다. 로컬 플레이어가 자기 무기를 쏘는 경우에는 Mesh1P의 muzzle socket을 기준으로 이펙트를 재생하고, 다른 플레이어가 쏘는 장면을 볼 때는 Mesh3P의 muzzle socket을 기준으로 재생해야 한다.
bIsFirstPerson = true
-> Mesh1P
bIsFirstPerson = false
-> Mesh3P

이런식으로 같은 Mesh더라도, 1P와 3P가 실행해야할 환경이 다르기때문에 캐시하는것은 기본이다.
Impact는 총알이 표면에 맞는 순간 재생되는 이펙트다.
벽에 맞으면 먼지나 파편이 튀고, 금속에 맞으면 spark가 나는 식으로 표면 타입에 따라 다른 연출을 만들 수 있다.
이때 필요한 값은 ImpactPoint, ImpactNormal, ImpactSurfaceType이다.
ImpactPoint
-> 이펙트가 생성될 위치
ImpactNormal
-> 표면 방향
ImpactSurfaceType
-> 맞은 표면의 종류
WeaponTrace에서 physical material을 가져오도록 한 이유가 여기에 있다.

Material을 까보면 위와같이 Parent Material이 있고, Physics Material이 있다.
바닥의 Material도 이와같이 Concrete타입으로 바꾼다.

이들을 통해 Surface가 현재 어떤 Physics Material로 되어있는지 확인하여 어떤 Niagara를 줄건지 미리 계산이 가능하다.
이런식으로 그 캐릭터가 쏜 대상의 Surface Physical이 Concrete인지 Glass인지 아니면 Character인지를 확인한다.

Project Setting에는 해당과 같이 Engine-Physics란에 이러한 Surface타입을 정의하는 Array가 있다.

준비된 총 4개의 NS를 각각에 넣으면 된다.

두개의 Impacts 차이가 보이는가?
바닥을 쏘면 Concrete Physical의 NS가. Character의 경우 탄피가 튕겨나가는 Impact가 생기게 된다.

미세하지만 다른 차이가 게임의 디테일을 풍성하게 해준다.
전 프로젝트에서 했던, 밟는 환경에 따라 이펙트가 달라지는 효과도 이 Physics Material를 통해 해결했었다. 다만 그때는 코드로 쑤셔넣었다.
Decal은 총알이 맞은 자리에 남는 탄흔이다.
Impact가 순간적으로 재생되는 particle이라면, Decal은 표면에 일정 시간 남는 흔적에 가깝다.
Decal 역시 ImpactPoint와 ImpactNormal이 필요하다.
위치만 있으면 탄흔을 어디에 만들지는 알 수 있지만, 표면의 방향을 모르면 벽에 자연스럽게 붙일 수 없다.
ImpactPoint는 탄흔의 위치고, ImpactNormal은 탄흔이 튕길 방향이다.


마지막으로 automatic fire를 timer로 구현한다.
무기에는 발사 타입과 발사 간격이 추가된다.
UENUM(BlueprintType)
enum class EFireType : uint8
{
Auto UMETA(DisplayName = "Automatic"),
SemiAuto UMETA(DisplayName = "SemiAutomatic")
};
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "FPS|FireType")
EFireType FireType = EFireType::SemiAuto;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "FPS|FireType")
float FireTime = 0.1f;
발사 버튼을 누르면 한 발을 쏘고, 현재 무기의 FireTime만큼 timer를 건다.
GetWorld()->GetTimerManager().SetTimer(
FireTimer,
this,
&ThisClass::FireTimerFinished,
CurrentWeapon->FireTime
);
timer가 끝나면 아직 trigger가 눌려 있는지, 그리고 현재 무기가 Auto 타입인지 확인한다.
void UCombatComponent::FireTimerFinished()
{
if (!IsValid(CurrentWeapon)) return;
if (bTriggerPressed && CurrentWeapon->FireType == EFireType::Auto)
{
Local_FireWeapon();
}
}
조건을 만족하면 다시 Local_FireWeapon을 호출한다. 이 함수 안에서 다시 timer가 설정되므로, 버튼을 누르고 있는 동안 FireTime 간격으로 발사가 반복된다.
SemiAuto
-> Press마다 한 발
Auto
-> Press 유지 동안 FireTime 간격으로 반복
여기서 입력 지속 여부는 CombatComponent가 가지고, 발사 타입과 발사 간격은 Weapon이 가진다.
플레이어가 지금 trigger를 누르고 있는지는 전투 입력 상태이고, 얼마나 빠르게 쏠 수 있는지는 무기 고유 속성이기 때문이다.

발사 타입과 발사 간격은 Weapon별로 다르게 설정한다.
이번 Weapon 파트의 결과물은 다음처럼 정리할 수 있다.
WeaponData
-> 무기별 montage data 제공
Character
-> fire input 전달
CombatComponent
-> 현재 무기 기준 발사 흐름 조율
-> 1P montage 재생
-> trace 호출
-> server RPC / multicast
-> automatic fire timer
Weapon
-> WeaponTrace 수행
-> Local_Fire / FireEffects 진입점 제공
-> fire type, fire time, trace radius 보유
Weapon Blueprint
-> FireEffects 구현
-> Weapon Fire / Impact / Decal cosmetic 처리
핵심은 발사 기능을 하나의 큰 함수로 완성하는 것이 아니라, 발사 과정에서 필요한 값과 실행 위치를 나누는 데 있다.
CombatComponent는 발사 흐름을 조율하고, Weapon은 무기 단위의 trace와 cosmetic entry point를 제공하며, 실제 시각 효과는 Blueprint에서 구현한다.
이 구조 위에 이후 섹션에서 ammo, reload, damage가 얹히게 된다.
따라서 이번 파트는 화려한 무기 시스템의 완성이라기보다, 이후 기능들이 붙을 수 있는 발사 파이프라인의 기준점을 만든 구간에 가깝다.