이번 챕터의 목적은 무기 상태를 HUD로 연결하는 과정에 대한 설명이다.
저번 챕터에서는 WeaponTrace로 충돌 지점을 찾고, FireEffects로 사운드와 이펙트를 재생하며, Montage와 Automatic Fire로 발사 동작을 구성했다.
여기까지가 무기의 기능 자체를 만드는 과정이었다면, 이번 섹션은 그 결과를 플레이어 화면에 어떻게 반응성 있게 보여줄 것인가에 가깝다.

단순히 화면 중앙에 조준점을 띄우는 수준을 넘어서, 현재 장착한 무기와 플레이어의 행동 상태에 따라 UI가 변하는 구조를 만들어보았다.
특히 UI가 무기 종류를 직접 판단하지 않고, Weapon이 자기 표현에 필요한 리소스와 반응 데이터를 제공하는 방향을 잡는 것이 핵심이다.

먼저 탄약 처리부터 정리했다.
FPS에서 탄약은 단순히 int32 Ammo 하나를 화면에 보여주는 문제가 아니다.
발사 입력과 바로 연결되기 때문에, 서버 응답을 기다렸다가 탄약을 줄이면 조작감이 둔해진다. 마우스를 클릭하는 순간 총이 나가고 탄약이 줄어야 플레이어는 즉시 반응했다고 느낀다.
그래서 탄약은 클라이언트에서 먼저 감소시키고, 서버가 나중에 권위 있는 값을 보내 보정하는 방식으로 구성했다.
void AWeapon::Local_Fire(...)
{
FireEffects(...);
if (GetInstigator()->IsLocallyControlled())
{
Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity);
++Sequence;
}
}
여기서 Sequence는 로컬 클라이언트가 예측으로 먼저 처리했지만, 아직 서버 응답을 받지 못한 발사 횟수다.
서버는 실제 권위 있는 탄약값을 계산한다.
void AWeapon::Auth_Fire()
{
Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity);
}
이후 클라이언트가 서버 값을 받으면, 그 사이에 로컬에서 추가로 예측한 발사 횟수를 반영해 다시 맞춘다.
void AWeapon::Rep_Fire(int32 AuthAmmo)
{
Ammo = AuthAmmo;
--Sequence;
Ammo -= Sequence;
}
예를 들어 로컬에서 빠르게 3발을 쐈는데 서버 응답은 첫 발까지만 도착했다고 하자. 서버는 첫 발 기준으로 남은 탄약을 보내지만, 클라이언트는 이미 두 발을 더 예측했으므로 Sequence만큼 다시 빼야 한다.
이 보정이 없으면 HUD 탄약이 순간적으로 되돌아가는 것처럼 보일 수 있다.
FPS라면 다들 하는 꼼수이기도 하다.

탄약을 화면에 보여주려면 HUD가 필요하다.
멀티플레이어에서 HUD는 서버가 복제하는 대상이 아니다. UI는 각 클라이언트의 화면에만 존재하는 로컬 표현이다. 그래서 Overlay는 로컬 PlayerController에서 생성한다.
BP_ShooterPlayerController
BeginPlay
-> Is Local Player Controller
-> Create Widget
-> Add to Viewport
중요한 것은 위젯 생성 노드 자체보다 생성 위치다.
Character는 사망과 리스폰 과정에서 Destroy되고 새로 만들어질 수 있다.
반면 PlayerController는 플레이어의 연결을 대표하고, Pawn이 바뀌어도 유지될 수 있다. 따라서 화면에 붙는 Overlay는 PlayerController가 만들고, 내부적으로 현재 Pawn과 다시 연결되는 흐름을 가져가는 것이 자연스럽다.

Overlay가 준비되면 Reticle UI를 C++ 위젯 클래스로 확장한다.
UCLASS()
class FPS_API UShooterReticle : public UUserWidget
{
GENERATED_BODY()
public:
virtual void NativeOnInitialized() override;
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UImage> Image_Reticle;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UImage> Image_AmmoCounter;
};
이 작업은 UI 배치를 C++로 옮기려는 목적이 아니다. 배치는 Widget Blueprint에서 처리하는 편이 훨씬 편하다. C++이 담당하는 것은 현재 Pawn, CombatComponent, Weapon 복제 타이밍, Dynamic Material 연결 같은 상태 기반 로직이다.
BindWidget을 사용하면 Widget Blueprint 안에 같은 이름으로 배치한 Image_Reticle, Image_AmmoCounter가 C++ 멤버와 연결된다. 덕분에 화면 구성은 UMG에서 조정하고, 게임 상태와 연결되는 부분은 C++에서 관리할 수 있다.

최종 Overlay UI는 위의 Reticle을 구성요소로 가지고 있다.

후에 있을 HP나 쉴드등이 추가되면 최종적인 Character의 UI가 된다.
Overlay는 PlayerController에 붙어 있지만, Pawn은 바뀔 수 있다.
리스폰이 일어나면 기존 Character는 Destroy되고 새 Character가 Possess된다. 이때 Overlay 자체는 그대로 남아 있을 수 있으므로, UI가 바라보는 Pawn과 CombatComponent를 다시 연결해야 한다.
이를 위해 ShooterReticle은 OnPossessedPawnChanged에 바인딩한다.
GetOwningPlayer()->OnPossessedPawnChanged.AddDynamic(
this,
&ThisClass::OnPossessedPawnChanged
);
Pawn이 바뀌면 기존 Pawn의 delegate에서는 빠지고, 새 Pawn의 CombatComponent delegate에 다시 들어간다.
Old Pawn
-> OnReticleChanged 해제
-> OnAmmoCounterChanged 해제
-> OnRoundFired 해제
-> OnTargetingPlayerStatusChanged 해제
New Pawn
-> OnReticleChanged 등록
-> OnAmmoCounterChanged 등록
-> OnRoundFired 등록
-> OnTargetingPlayerStatusChanged 등록
이 구조는 UI가 이전 Pawn의 이벤트를 계속 듣는 문제를 막는다. 동시에 새 Pawn이 준비되면 같은 Reticle 위젯이 새 CombatComponent의 이벤트를 받아 다시 동작하게 만든다.
Pawn이 바뀌었다고 해서 CurrentWeapon까지 바로 준비되는 것은 아니다.
서버에서는 Character를 만들고 Weapon을 Spawn한 뒤 CurrentWeapon에 넣을 수 있다. 하지만 클라이언트에서는 Pawn 복제, CombatComponent 복제, Weapon Actor 참조 복제가 항상 같은 프레임에 끝난다고 보장할 수 없다.
클라이언트 입장에서는 다음 순서가 가능하다.
Pawn 복제 완료
HUD가 Pawn에 연결됨
CombatComponent는 존재
CurrentWeapon은 아직 nullptr
잠시 후 CurrentWeapon 복제 도착
따라서 UI가 생성 시점에 한 번만 Weapon을 찾고 끝내면 실패할 수 있다.
이 문제를 해결하기 위해 CombatComponent::OnRep_CurrentWeapon()에서 무기가 복제되었음을 알려준다.
void UCombatComponent::OnRep_CurrentWeapon(AWeapon* LastWeapon)
{
CurrentWeapon->AttachToOwningPawn();
IPlayerInterface::Execute_WeaponReplicated(GetOwner());
InitializeWeaponWidgets();
}
Character는 최초 무기 복제 완료 시점에 delegate를 broadcast한다.
void AAShooterCharacter::WeaponReplicated_Implementation()
{
if (!bWeaponFirstReplicated)
{
bWeaponFirstReplicated = true;
OnWeaponFirstReplicated.Broadcast(Combat->GetCurrentWeapon());
}
}
이 방식의 목적은 UI가 Tick에서 계속 CurrentWeapon을 찾아다니지 않게 만드는 것이다. 무기가 준비되는 정확한 시점은 복제 이벤트를 아는 CombatComponent가 알려주고, UI는 그 시점에 필요한 Material과 상태를 받아 초기화한다.
이번 섹션에서 가장 중요한 설계 방향은 UI 리소스의 소유권이다.
무기마다 다른 Reticle과 Ammo Counter를 보여주고 싶을 때, 가장 단순한 방법은 UI에서 무기 종류를 직접 분기하는 것이다.
ShooterReticle
if CurrentWeapon == Rifle
Rifle Reticle 적용
else if CurrentWeapon == Shotgun
Shotgun Reticle 적용
하지만 이 방식은 무기가 늘어날수록 UI가 무기 종류를 너무 많이 알게 된다.
UI는 화면에 표시하는 책임만 가져야 하는데, 어떤 무기가 어떤 Reticle을 써야 하는지까지 알게 되면 무기 데이터와 UI 로직이 엉키기 쉽다.
그래서 무기별 UI 리소스는 Weapon이 소유하게 한다.
UPROPERTY(EditDefaultsOnly, Category = "FPS|Weapon")
TObjectPtr<UMaterialInterface> ReticleMaterial;
UPROPERTY(EditDefaultsOnly, Category = "FPS|Weapon")
TObjectPtr<UMaterialInterface> AmmoCounterMaterial;
이렇게 하면 Rifle, Shotgun, Pistol이 각자 자기 Reticle Material과 Ammo Counter Material을 가진다.
Rifle
ReticleMaterial = MI_RifleReticle
AmmoCounterMaterial = MI_RifleAmmoCounter
Shotgun
ReticleMaterial = MI_ShotgunReticle
AmmoCounterMaterial = MI_ShotgunAmmoCounter
ShooterReticle은 무기 타입을 몰라도 된다. 현재 Weapon이 제공하는 Material을 받아 UMG Image에 붙이면 된다.
Weapon
"내 HUD 리소스는 이거야."
CombatComponent
"현재 장착 무기의 HUD 리소스가 바뀌었어."
ShooterReticle
"받은 Material을 화면에 적용할게."

Rifle과 Pistol을 다른 UI를 써서 구별할 수 있게 된다.

Pistol의 경우 다른 UI가 들어가게 된다.
런타임에 계속해서 바뀌는 Dynamic UI를 구현하기 위해서는 Dynmaic MI가 사실상 필수적이다.
이 챕터에서 우리는 두개의 Dynamic MI를 사용하게 되는데 첫 번째가 Reticle이고 두번째가 AmmoCounter이다.
각각 적을 감지하거나 반동을 줄때 얼마나 벌어지는지, 색이 어떻게 바뀌는지를 구현할 것이다.
AmmoCounter은 이름에서 볼 수 있듯이 실시간으로 탄약의 UI를 이쁘게 보여주는 방법을 Instance 수치를 동적으로 조절하여 보여줄 것이다.
때문에 해당에셋들은 다음과 같은 인자들을 가지고 있다.


이 값을 원본 Material에 직접 적용하면 공유 중인 다른 UI나 다른 무기에도 영향을 줄 수 있다.
그래서 런타임 전용 복사본인 UMaterialInstanceDynamic을 만든다.
UMaterialInstanceDynamic* AWeapon::GetReticleDynamicMaterialInstance()
{
if (!IsValid(DynMatInst_Reticle))
{
DynMatInst_Reticle = UMaterialInstanceDynamic::Create(
ReticleMaterial,
this
);
}
return DynMatInst_Reticle;
}
이 함수는 처음 호출될 때만 Dynamic Material Instance를 만들고, 이후에는 저장해둔 인스턴스를 반환한다.
Ammo Counter도 같은 방식으로 만든다.
UMaterialInstanceDynamic* AWeapon::GetAmmoCounterDynamicMaterialInstance()
{
if (!IsValid(DynMatInst_AmmoCounter))
{
DynMatInst_AmmoCounter = UMaterialInstanceDynamic::Create(
AmmoCounterMaterial,
this
);
}
return DynMatInst_AmmoCounter;
}
여기서 중요한 점은 Dynamic Material Instance의 생성 위치다.
UI가 매번 새 인스턴스를 만드는 것이 아니라 Weapon이 자기 Material Instance를 생성하고 보관한다.
무기별 HUD 리소스와 런타임 파라미터의 소유권을 Weapon에 두기 위함이다.
이렇게 하면 나중에 무기 교체가 들어와도 흐름이 유지된다.
UMaterialInstanceDynamic* AWeapon::GetReticleDynamicMaterialInstance()
{
if (!IsValid(DynMatInst_Reticle))
{
DynMatInst_Reticle = UMaterialInstanceDynamic::Create(ReticleMaterial, this);
}
return DynMatInst_Reticle;
}
Weapon이 Dynamic Material Instance를 만들었다면, UI는 이를 UMG Image에 적용해야 한다.
UMG의 Image는 Slate Brush를 통해 표시 리소스를 가진다.
그래서 Dynamic Material Instance를 FSlateBrush의 ResourceObject로 넣고, Image에 Brush를 설정한다.
void UShooterReticle::OnReticleChanged(
UMaterialInstanceDynamic* ReticleDynMatInst,
const FReticleParams& ReticleParams,
bool bCurrentlyTargetingPlayer
)
{
CurrentReticleParams = ReticleParams;
CurrentReticle_DynMatInst = ReticleDynMatInst;
FSlateBrush Brush;
Brush.SetResourceObject(ReticleDynMatInst);
if (IsValid(Image_Reticle))
{
Image_Reticle->SetBrush(Brush);
}
OnTargetingPlayerStatusChanged(bCurrentlyTargetingPlayer);
}
Ammo Counter도 같은 방식이다.
FSlateBrush Brush;
Brush.SetResourceObject(AmmoCounterDynMatInst);
if (IsValid(Image_AmmoCounter))
{
Image_AmmoCounter->SetBrush(Brush);
}
이 시점부터 UMG Image는 단순 Texture가 아니라 Dynamic Material을 화면에 표시하는 역할을 한다. 이후 SetScalarParameterValue, SetVectorParameterValue로 값을 바꾸면 Image에 표시되는 결과도 같이 바뀐다.
예를 들어 Ammo Counter는 현재 탄약과 최대 탄약이 바뀌어야 한다.
위에서 언급했던 AmmoCounter의 인자 값을 다시 한번 살펴보자.
명칭과 더불어 값을 직접 움직이면서 해당 값이 직접적으로 어떻게 관여하는지 쉽게 이해할 수 있다.

이미 구현된 MI는 Round_Current와 MAX라는 값에 따라 Ammo의 현재 Count를 그대로 반영한다.
Rounds_Current
Rounds_Max
이 두개의 값을 통해 탄약 상태를 어떻게 조절할 것인지 고민하면 다음과 같은 코드가 나온다.
namespace Ammo
{
const FName Rounds_Current = FName("Rounds_Current");
const FName Rounds_Max = FName("Rounds_Max");
}
여기서 namespace는 파라미터 이름 상수를 묶기 위한 용도다.
Rounds_Current, Rounds_Max는 무기마다 달라지는 데이터가 아니라 Material이 공통으로 사용하는 이름이다. 인스턴스화할 필요가 없고, 에디터에 노출할 필요도 없다.
반면 실제 값은 발사 이벤트마다 바뀐다.
void UShooterReticle::OnRoundFired(int32 RoundsCurrent, int32 RoundsMax)
{
if (CurrentAmmoCounter_DynMatInst.IsValid())
{
CurrentAmmoCounter_DynMatInst->SetScalarParameterValue(
Ammo::Rounds_Current,
RoundsCurrent
);
CurrentAmmoCounter_DynMatInst->SetScalarParameterValue(
Ammo::Rounds_Max,
RoundsMax
);
}
}
이 구조의 장점은 UI가 텍스트 블록으로 탄약을 직접 그리지 않아도 된다는 점이다.
Ammo Counter Material이 탄약값을 받아 그래픽적으로 표현할 수 있으므로, 원형 게이지, 세그먼트 바, 디지털 카운터처럼 다양한 형태로 확장할 수 있다.
Reticle은 발사할 때 순간적으로 변하고, 시간이 지나면서 원래 상태로 돌아온다. 이 반응은 모든 무기가 같을 필요가 없다.
앞서 AmmoCounter처럼 다시 한번 인자값을 대충 살펴보면 다음과 같다.

Rifle은 작게 벌어졌다가 빠르게 돌아오고, Shotgun은 크게 벌어졌다가 조금 느리게 돌아올 수 있다.
이 차이를 Reticle Material을 매번 새로 만드는 방식으로도 처리할 수 있지만, 반응값을 데이터로 빼두면 더 유연하다.
그래서 FReticleParams를 둔다.
USTRUCT(BlueprintType)
struct FReticleParams
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite, EditAnywhere)
float ShapeCutFactor_RoundFired = 0.f;
UPROPERTY(BlueprintReadWrite, EditAnywhere)
float ScaleFactor_RoundFired = 0.f;
UPROPERTY(BlueprintReadWrite, EditAnywhere)
float ScaleFactor_Targeting = 0.f;
UPROPERTY(BlueprintReadWrite, EditAnywhere)
float ScaleFactor_NotTargeting = 0.f;
UPROPERTY(BlueprintReadWrite, EditAnywhere)
float RoundFiredInterpSpeed = 20.f;
UPROPERTY(BlueprintReadWrite, EditAnywhere)
float TargetingPlayerInterpSpeed = 10.f;
};
namespace Ammo와 다르게 FReticleParams가 struct인 이유는 이 값들이 무기마다 달라지는 데이터이기 때문이다. Weapon BP에서 편집 가능해야 하고, 각 Weapon이 자기 값을 들고 있어야 한다.
UPROPERTY(EditDefaultsOnly, Category = "FPS|Reticle")
FReticleParams ReticleParams;
발사하면 Reticle 값이 순간적으로 튄다.
BaseCornerScaleFactor_RoundFired +=
CurrentReticleParams.ScaleFactor_RoundFired;
BaseShapeCutFactor_RoundFired +=
CurrentReticleParams.ShapeCutFactor_RoundFired;
그리고 Tick에서 다시 원래 값으로 보간한다.
BaseCornerScaleFactor_RoundFired = FMath::FInterpTo(
BaseCornerScaleFactor_RoundFired,
0.f,
InDeltaTime,
CurrentReticleParams.RoundFiredInterpSpeed
);
최종 값은 Material Parameter로 전달된다.
CurrentReticle_DynMatInst->SetScalarParameterValue(
Reticle::RoundedCornerScale,
BaseCornerScaleFactor
);
CurrentReticle_DynMatInst->SetScalarParameterValue(
Reticle::ShapeCutThickness,
BaseShapeCutFactor
);
이 방식은 Reticle Material을 하나의 렌더링 장치로 두고, 무기별 차이는 데이터로 제어하는 방법이다.
같은 Reticle을 사용하였고, 인자만 바꾼 Rifle과 Shotgun의 예시를 보자.
아래 둘은 확연히 다른 모습을 취하고 있다.


Reticle은 발사뿐 아니라 현재 무엇을 겨누고 있는지에도 반응한다.
이를 위해 CombatComponent는 로컬 플레이어만 Tick에서 카메라 방향으로 LineTrace를 수행한다.
APawn* OwningPawn = Cast<APawn>(GetOwner());
if (!IsValid(OwningPawn) || !OwningPawn->IsLocallyControlled()) return;
서버나 원격 클라이언트의 UI를 위한 판정이 아니므로, 로컬 컨트롤 여부를 먼저 확인한다.
PC->GetActorEyesViewPoint(EyesWorldLocation, EyesWorldRotation);
const FVector EyesWorldDirection =
UKismetMathLibrary::GetForwardVector(EyesWorldRotation);
const FVector Start = EyesWorldLocation;
const FVector End = Start + EyesWorldDirection * TraceLength;
이후 맞은 Actor가 PlayerInterface를 구현했는지 확인한다.
bHitPlayer = IsValid(Hit.GetActor()) &&
Hit.GetActor()->Implements<UPlayerInterface>();
상태가 이전 프레임과 달라졌을 때만 UI에 알린다.
if (bHitPlayer != bHitPlayerLastFrame)
{
OnTargetingPlayerStatusChanged.Broadcast(bHitPlayer);
}
매 프레임 UI를 무조건 갱신하는 것이 아니라, targeting 상태가 바뀌는 순간에만 delegate를 쏘는 방식이다.

플레이어를 겨누는 상태는 두 가지 방식으로 Reticle에 반영된다.
첫 번째는 색상이다.
namespace Reticle
{
const FName Inner_RGBA = FName("Inner_RGBA");
}
플레이어를 겨누면 빨간색, 아니면 흰색으로 바꾼다.
void UShooterReticle::OnTargetingPlayerStatusChanged(bool bTargeting)
{
bTargetingPlayer = bTargeting;
if (CurrentReticle_DynMatInst.IsValid())
{
const FLinearColor ReticleColor =
bTargetingPlayer ? FLinearColor::Red : FLinearColor::White;
CurrentReticle_DynMatInst->SetVectorParameterValue(
Reticle::Inner_RGBA,
ReticleColor
);
}
}
두 번째는 크기 반응이다.
앞서 언급했듯이, ScaleFactor만 만져도 다음과 같은 효과가 일어난다.

그럼 이를 Factor와 InterSpeed, RoundFired등 다양한 인자를 조합해 최대한 자연스럽게 만드는것도 어렵지 않다.
FReticleParams에는 targeting 상태일 때와 아닐 때의 scale factor가 들어간다.
float ScaleFactor_Targeting = 0.f;
float ScaleFactor_NotTargeting = 0.f;
float TargetingPlayerInterpSpeed = 10.f;
Tick에서는 현재 targeting 상태에 따라 목표값을 고르고, 그 값으로 부드럽게 보간한다.
BaseCornerScaleFactor_TargetingPlayer = FMath::FInterpTo(
BaseCornerScaleFactor_TargetingPlayer,
bTargetingPlayer
? CurrentReticleParams.ScaleFactor_Targeting
: CurrentReticleParams.ScaleFactor_NotTargeting,
InDeltaTime,
CurrentReticleParams.TargetingPlayerInterpSpeed
);
최종 Reticle 값은 여러 요인의 합으로 만든다.
BaseCornerScaleFactor
= RoundFired Factor
+ TargetingPlayer Factor
+ 기본값
이 구조는 이후 다른 요인을 추가하기에도 좋다. 조준 상태, 이동 속도, 공중 여부, 피격 상태 같은 값을 각각 factor로 계산한 뒤 최종 Material Parameter에 합산할 수 있다.


이번 섹션에서 만든 구조는 다음과 같이 나뉜다.
PlayerController
- 로컬 HUD 생성
ShooterReticle
- 현재 Pawn의 CombatComponent에 바인딩
- Dynamic Material을 UMG Image에 적용
- Ammo / Reticle / Targeting 파라미터 갱신
CombatComponent
- CurrentWeapon 관리
- 발사 이벤트 발생
- Weapon UI 리소스 전달
- Targeting Player 상태 감지
Weapon
- Ammo 상태 소유
- ReticleMaterial 소유
- AmmoCounterMaterial 소유
- ReticleParams 소유
결과적으로 얻은 것은 단순한 HUD가 아니다. 현재 무기가 어떤 UI 리소스를 사용할지 결정하고, CombatComponent가 상태 변화를 알리며, ShooterReticle이 그 결과를 Dynamic Material Parameter로 반영하는 흐름이다.