언리얼엔진/구현·실습

Hitscan 판정은 즉시, 총알은 보이게: UE5 Niagara 기반 네트워크 트레이서 구현기

write76465 2026. 7. 14. 00:31

1. 문제 정의

기존 무기 시스템에는 이미 다음 기능이 구현돼 있었다.

  • 카메라 기준 라인트레이스
  • 총구 기준 보정 트레이스
  • 서버 권한 데미지 처리
  • 연사 및 재장전
  • 데이터테이블 기반 무기 설정

하지만 발사 판정만 존재하고 시각적인 탄환 이동이 없어, 총을 쏘면 대상이 즉시 피격되는 느낌만 났다.

단순 Projectile Actor를 추가하면 구현은 직관적이지만, 기존 Hitscan 판정 구조를 다시 설계해야 하고 네트워크에서 Projectile 위치까지 복제해야 한다. 따라서 판정과 연출을 분리했다.

서버: Hitscan으로 즉시 판정 및 데미지 처리
클라이언트: Niagara로 총알이 이동하는 시각적 트레이서 재생

이 구조의 핵심은 Niagara가 실제 판정을 담당하지 않는다는 점이다. 시각적 탄환이 아직 날아가는 중이어도 서버 판정은 이미 끝났으며, 트레이서는 그 결과를 시각적으로 재현한다.

 

이미 팀원이 짜놓은 라인트레이싱 코드이다.

 


2. 데이터테이블 기반 이펙트 확장

무기별로 다른 트레이서 이펙트를 사용할 수 있도록 FWeaponData에 Niagara System 참조를 추가했다.

UPROPERTY(
    EditAnywhere,
    BlueprintReadWrite,
    Category = "Effects"
)
TObjectPtr<UNiagaraSystem> TracerEffect = nullptr;

이렇게 하면 무기 클래스에 AK47 전용 이펙트를 하드코딩하지 않고 데이터테이블에서 무기별 효과를 교체할 수 있다.

AK47 → NS_BulletTracer
Pistol → NS_PistolTracer
Sniper → NS_SniperTracer

무기의 데미지, 사거리, 연사 속도뿐 아니라 표현 리소스까지 데이터로 관리하는 구조가 된다.

 

무기 데이터테이블에 추가된 모습

 


3. 판정 좌표만 전송하고 이펙트는 각 클라이언트가 생성한다

트레이서를 네트워크로 동기화할 때 Niagara Component 자체를 복제하지 않았다. 서버가 확정한 시작점과 끝점만 전달하고, 각 클라이언트가 동일한 Niagara System을 로컬에서 생성한다.

UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayFireEffects(
    FVector_NetQuantize MuzzleLocation,
    FVector_NetQuantize TracerEndLocation
);

여기서 Unreliable을 선택한 이유는 총구 화염과 트레이서가 일시적인 표현이기 때문이다. 한 번의 이펙트 패킷이 유실되더라도 게임 상태가 깨지지 않으며, 연사 중 Reliable RPC가 누적되는 문제를 피할 수 있다.

또한 FVector_NetQuantize로 좌표를 전달해 일반 FVector보다 네트워크 전송 비용을 줄였다.

if (HasAuthority())
{
    MulticastPlayFireEffects(
        MuzzleLocation,
        TracerEndLocation
    );
}

서버는 충돌 결과를 확정하고 좌표만 Multicast한다. 데미지 처리와 이펙트 재생의 책임이 명확하게 분리된다.


4. 첫 프레임 오염을 막기 위한 Niagara 활성화 순서

Niagara System을 생성하자마자 자동 실행하면, User Parameter를 넣기 전에 기본값 (0, 0, 0)으로 첫 프레임이 계산될 수 있다.

이를 막기 위해 bAutoActivate를 false로 설정했다.

UNiagaraComponent* TracerComponent =
    UNiagaraFunctionLibrary::SpawnSystemAtLocation(
        GetWorld(),
        CurrentWeaponData.TracerEffect,
        FVector(MuzzleLocation),
        FRotator::ZeroRotator,
        FVector::OneVector,
        true,                   // Auto Destroy
        false,                  // Auto Activate
        ENCPoolMethod::None,
        true
    );

그다음 좌표를 넣고 수동 활성화한다.

if (TracerComponent)
{
    TracerComponent->SetVariablePosition(
        TEXT("User.BeamStart"),
        FVector(MuzzleLocation)
    );

    TracerComponent->SetVariablePosition(
        TEXT("User.BeamEnd"),
        FVector(TracerEndLocation)
    );

    TracerComponent->Activate(true);
}

실행 순서는 다음과 같다.

Niagara 생성
→ User Parameter 설정
→ Activate
→ 첫 Simulation Tick

이 순서를 지키면 원점에서 빔이 번쩍이거나 잘못된 방향으로 한 프레임 표시되는 현상을 방지할 수 있다.

BeamStart와 BeamEnd는 단순 Vector가 아니라 Niagara의 Position 타입으로 생성했기 때문에 C++에서도 SetVariableVec3()가 아닌 SetVariablePosition()을 사용했다.


5. 단순 Beam이 레이저처럼 보였던 이유

초기 구현에서는 User.BeamStart와 User.BeamEnd를 Beam Emitter Setup에 직접 연결했다.

Beam Start = User.BeamStart
Beam End   = User.BeamEnd

결과는 총구부터 충돌 지점까지 한 번에 생성되는 긴 직선이었다. 판정 표현에는 문제가 없지만, 총알이 이동하는 것이 아니라 레이저가 순간적으로 나타나는 것처럼 보였다.

해결 방법은 빔의 시작점과 끝점을 시간에 따라 함께 이동시키는 것이었다.

Head = Lerp(Muzzle, HitPoint, t)

Tail = Lerp(
    Muzzle,
    HitPoint,
    Clamp(t - TrailLength, 0, 1)
)

여기서 t는 Emitter.NormalizedLoopAge다.

t = 현재 이미터 시간 / Loop Duration

따라서 t는 이미터 시작 시 0, 종료 시 1이 된다.

빔 앞부분은 현재 시간으로 이동시키고, 뒷부분은 조금 늦은 시간으로 이동시킨다. 그 차이만큼 짧은 빔 조각이 만들어진다.

 


6. Scratch Dynamic Input으로 위치 보간 구현

동일한 보간 로직을 재사용하기 위해 세 개의 입력을 갖는 Scratch Dynamic Input을 구성했다.

StartPosition : Position
EndPosition   : Position
Alpha         : Float

내부 계산은 단순하다.

Lerp(
    A = StartPosition,
    B = EndPosition,
    Alpha
)

 

Beam End에는 현재 진행도를 그대로 전달했다.

Alpha = Emitter.NormalizedLoopAge

Beam Start에는 지연값을 적용했다.

Alpha = Clamp(
    Emitter.NormalizedLoopAge - 0.08,
    0,
    1
)

최종 구조는 다음과 같다.

Beam End Alpha
= NormalizedLoopAge

Beam Start Alpha
= Clamp(NormalizedLoopAge - 0.08, 0, 1)

0.08은 시간이 아니라 전체 경로에 대한 비율이다. 값을 키우면 빔이 길어지고, 줄이면 짧아진다.


트러블슈팅

1. User Parameter가 Scratch Pad에서 검색되지 않았다

증상

Scratch Dynamic Input 내부의 Map Get에서 다음 파라미터를 검색했지만 나타나지 않았다.

User.BeamStart
User.BeamEnd
Emitter.NormalizedLoopAge

잘못된 접근

완성되지 않은 Scratch Dynamic Input에서 Active Overview를 열어 시스템 파라미터를 직접 가져오려고 했다.

그 결과 Niagara Editor 내부에서 다음 Ensure가 발생했다.

Encountered '0' parameter map history
when collecting parameters for system parameter panel view mode

 

원인 분석

Scratch Dynamic Input은 당시 아직 출력까지 연결되지 않은 독립 스크립트였다. 그런데 Active Overview는 완성된 System 또는 Emitter의 Parameter Map History를 요구한다.

로그와 재현 조건을 통해 다음 조합이 Ensure의 직접적인 트리거임을 확인했다.

미완성 Dynamic Input
+ System Parameter Panel View
+ 유효한 Parameter Map History 없음

Visual Studio에 표시된 NiagaraEditor.pdb 포함 안 됨은 원인이 아니었다. 엔진 내부 Ensure 위치의 소스를 표시할 디버그 심볼이 없다는 부가 메시지였다.

해결

Scratch 내부에서 System User Parameter를 직접 찾지 않고, Scratch 자체가 필요한 값을 입력으로 받도록 변경했다.

Scratch Input
├ StartPosition
├ EndPosition
└ Alpha

바깥 Beam Emitter Setup에서 실제 값을 연결했다.

StartPosition → User.BeamStart
EndPosition   → User.BeamEnd
Alpha         → Emitter.NormalizedLoopAge

이렇게 구성하면 Scratch Script가 특정 Niagara System에 강하게 의존하지 않으며 다른 Beam에도 재사용할 수 있다.


2. 입력을 만들었는데 바깥 Stack에 표시되지 않았다

증상

Scratch 그래프에는 StartPosition, EndPosition, Alpha 노드가 존재했지만 Beam Emitter Setup에서는 입력칸이 나타나지 않았다.

Expose Input도 활성화된 상태였다.

원인

입력을 일반 Parameter 노드로 만들었기 때문에 그래프에는 존재했지만 실제 Module Input으로 등록되지 않았다.

Edit Input Hierarchy를 확인했을 때 다음 항목만 표시됐다.

INPUT Default

즉, 새로 만든 세 값은 Stack이 인식하는 입력 계층에 포함되지 않은 상태였다.

해결

일반 Input Parameter 생성 메뉴가 아니라 Parameters 패널의 Module Inputs에서 다시 생성했다.

INPUT StartPosition
INPUT EndPosition
INPUT Alpha

그 후 Map Get으로 값을 꺼내 Lerp에 연결했다.

이 문제를 통해 “그래프에 입력 노드가 존재한다”와 “외부 Stack에서 호출 가능한 Module Input으로 등록됐다”는 서로 다른 상태임을 확인했다.


3. Loop Duration, Lifetime, Trail Length를 하나의 값처럼 조정하면 안 됐다

트레이서를 튜닝하면서 세 값의 역할을 분리해야 했다.

파라미터역할
Loop Duration 총구에서 목표까지 이동하는 시간
Particle Lifetime 파티클이 살아 있는 시간
Alpha 지연값 빔 조각의 길이
Beam Width 트레이서 굵기

초기에는 Lifetime이 Loop Duration보다 길어 목표 지점에 도착한 빔이 잠시 남는 현상이 발생했다.

최종적으로 다음과 같이 기준을 잡았다.

Loop Duration = 0.06~0.08
Lifetime      = Loop Duration와 같거나 약간 길게
Trail Offset  = 0.06~0.10

또한 연사 간격보다 Loop Duration이 길면 여러 트레이서가 동시에 존재한다.

FireRate = 0.1초
Loop Duration = 1.0초

→ 최대 10개의 트레이서가 겹쳐 보일 수 있음

따라서 AK47처럼 연사 간격이 0.1초라면 트레이서 이동 시간은 0.08초 이하로 두는 것이 자연스러웠다.


4. API 버전 차이와 활성화 순서 문제

UE 버전에 따라 SpawnSystemAtLocation() 시그니처가 달라 기존 예제를 그대로 사용할 수 없었다.

현재 프로젝트에서 요구하는 전체 인자를 확인하고 다음 순서로 호출했다.

SpawnSystemAtLocation(
    WorldContext,
    System,
    Location,
    Rotation,
    Scale,
    bAutoDestroy,
    bAutoActivate,
    PoolingMethod,
    bPreCullCheck
);

단순히 컴파일 에러를 맞추는 데서 끝내지 않고 bAutoActivate = false를 의도적으로 사용했다. 파라미터를 넣기 전에 실행되는 첫 프레임을 제거하기 위해서다.

이는 API 오버로드 문제를 해결하면서 Niagara 초기화 순서까지 함께 정리한 사례였다.


7. 현재 발사 흐름

최종 발사 흐름은 다음과 같다.

클라이언트 발사 입력
→ ServerFire RPC
→ 서버 FireTrace
→ 카메라 기준 조준 지점 계산
→ 총구 기준 2차 트레이스
→ 서버 데미지 적용
→ MuzzleLocation, TracerEndLocation Multicast
→ 각 클라이언트가 Niagara 생성
→ User Parameter 설정
→ Niagara 활성화
→ 짧은 Beam Segment가 목표까지 이동

이 구조에서는 다음 책임이 분리된다.

  • 서버: 명중 판정과 데미지
  • 데이터테이블: 무기 수치와 표현 리소스
  • Niagara: 총알 이동 연출
  • Multicast RPC: 연출에 필요한 최소 좌표 전달

8. 추가 검증 예정

상하 조준 시 트레이서 방향이 어색하게 보이는 현상이 남아 있다.

코드상 트레이서는 다음 데이터테이블 소켓에서 시작한다.

MeshComponent->GetSocketLocation(
    CurrentWeaponData.MuzzleSocketName
);

다만 현재 코드에는 카메라 트레이스와 총구 트레이스를 2초간 표시하는 DrawDebugLine()이 남아 있다.

DrawDebugLine(..., FColor::Red, false, 2.f);
DrawDebugLine(..., FColor::Yellow, false, 2.f);

위아래로 조준하면 카메라와 총구의 시차 때문에 두 디버그 선이 서로 다른 각도를 형성한다. 연사 시 이 선들이 2초간 누적돼 실제 Niagara 방향 문제처럼 보일 가능성이 있다.

다음 작업에서는 디버그 렌더링을 제거한 뒤 아래 항목을 분리해서 검증할 예정이다.

  • 총구 소켓 위치
  • 카메라–총구 시차
  • Niagara Local Space 설정
  • Absolute Beam Start/End
  • 실제 트레이서와 디버그 라인의 시각적 구분

마무리

이번 구현에서 핵심은 “총알을 보이게 만들었다”가 아니다.

기존 Hitscan 구조를 유지하면서 판정과 표현을 분리하고, 네트워크에는 결과 좌표만 전달하며, Niagara에서는 정규화된 시간으로 빔의 앞뒤를 독립적으로 보간했다.

특히 Scratch Dynamic Input 문제를 해결하는 과정에서 다음 차이를 확인할 수 있었다.

일반 Parameter ≠ Module Input
그래프에 존재함 ≠ 외부 Stack에서 호출 가능함
Vector ≠ Niagara Position
Emitter Duration ≠ Particle Lifetime
실제 트레이서 ≠ 디버그 시각화

결과적으로 무기 로직을 대대적으로 수정하지 않고 데이터 기반·네트워크 친화적인 트레이서 시스템을 추가할 수 있었다.