언리얼엔진/구현·실습

[UE5] 애니메이션 문제처럼 보였던 이동 버그를 무기 충돌에서 찾아내기까지

write76465 2026. 7. 15. 19:59

오늘은 캐릭터의 전투 디테일을 개선하면서 다음 기능을 정리했다.

  • 권총과 라이플 재장전 애니메이션 분리
  • 조준 중 달리기 상태 전환
  • 무기 장착 위치 및 트레이서 이펙트 점검
  • 이동 중 발생하던 캐릭터 위치 보정 현상 해결
  • 애니메이션 노티파이를 이용한 발소리 구현
  • 사망 몽타주 이후 캐릭터 숨김 및 결과 UI 연결
  • 결과 UI 종료 후 기존 GameMode 리스폰 흐름 유지

단순히 기능을 추가하는 작업보다, 서로 다른 시스템이 영향을 주면서 발생한 문제의 원인을 추적하는 과정이 핵심이었다.


1. 아래를 조준하면 캐릭터가 뒤로 가속되는 현상

문제 상황

캐릭터가 아래를 조준한 상태에서 전진 입력을 하면 다음 현상이 발생했다.

  • 전진 키를 눌렀는데 캐릭터가 뒤로 이동
  • 일반적인 이동 속도보다 훨씬 빠르게 밀림
  • 재장전 중 이동하면 원래 위치로 돌아가려는 듯한 보정 발생
  • 클라이언트에서는 이동이 끊기는 현상까지 발생

처음에는 Aim Offset 또는 몽타주의 Root Motion 문제로 판단했다.

실제로 Aim Pitch 연결을 제거하면 증상이 사라졌기 때문에 애니메이션 그래프가 직접적인 원인처럼 보였다.

초기 가설

다음 항목을 차례대로 확인했다.

  1. 발사·재장전 몽타주의 Root Motion
  2. Layered Blend per Bone의 하체 영향 여부
  3. Aim Offset의 Pitch 범위
  4. 이동 방향 계산 코드
  5. 네트워크 Character Movement 보정
  6. 캐릭터 캡슐과 메시의 이동 여부

캐릭터 이동은 컨트롤러의 Yaw만 사용하고 있었다.

void APlayerCharacter::Move(const FInputActionValue& Value)
{
    const FVector2D MovementVector = Value.Get<FVector2D>();

    if (Controller != nullptr)
    {
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0.f, Rotation.Yaw, 0.f);

        const FVector ForwardDirection =
            FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);

        const FVector RightDirection =
            FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);

        AddMovementInput(ForwardDirection, MovementVector.X);
        AddMovementInput(RightDirection, MovementVector.Y);
    }
}

Pitch를 이동 방향에 사용하지 않으므로, 아래를 조준한다고 해서 이동 벡터 자체가 반대로 계산될 가능성은 낮았다.

또한 디버깅 결과, 시각적인 캐릭터 메시뿐 아니라 실제 CapsuleComponent까지 함께 끌려가고 있었다.

이 시점에서 문제를 단순 애니메이션 왜곡이 아닌, 실제 충돌에 의해 Character Movement가 영향을 받는 문제로 좁힐 수 있었다.


2. 실제 원인은 장착된 무기의 Collision이었다

원인 분석

캐릭터가 들고 있는 무기 메시가 충돌 가능한 상태로 유지되고 있었다.

무기는 캐릭터 메시의 소켓에 부착되어 Aim Offset과 몽타주에 따라 움직인다. 캐릭터가 아래를 조준하면 무기 메시 역시 아래쪽 또는 캐릭터 캡슐 내부로 크게 회전한다.

이때 무기 충돌이 다음 대상과 겹칠 수 있다.

  • 캐릭터 자신의 CapsuleComponent
  • 캐릭터 Skeletal Mesh
  • 바닥 또는 벽
  • 주변 월드 오브젝트

Character Movement는 충돌을 해결하기 위해 캡슐 위치를 보정한다. 따라서 겉으로는 Aim Offset이나 몽타주가 캐릭터를 이동시키는 것처럼 보였지만, 실제 흐름은 다음과 같았다.

Aim Pitch 변경
→ 손과 무기 위치 변경
→ 무기 Collision이 캡슐 또는 월드와 겹침
→ 충돌 해소를 위한 캐릭터 위치 보정
→ 캐릭터가 뒤로 밀리거나 순간 가속

네트워크 환경에서는 서버가 계산한 위치와 클라이언트가 예측한 위치가 달라지기 때문에 이 문제가 끊김과 되감기처럼 나타났다.

해결

장착 무기는 전투 판정을 담당하는 물리 객체가 아니므로 메시 충돌과 물리 시뮬레이션을 비활성화했다.

MeshComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
MeshComponent->SetCollisionResponseToAllChannels(ECR_Ignore);
MeshComponent->SetGenerateOverlapEvents(false);
MeshComponent->SetSimulatePhysics(false);

이를 ApplyWeaponData()처럼 무기 메시가 확정되는 시점에 적용했다.

void AWeaponBase::ApplyWeaponData()
{
    if (WeaponDataRow.IsNull())
    {
        return;
    }

    const FWeaponData* WeaponData =
        WeaponDataRow.GetRow<FWeaponData>(TEXT("Weapon"));

    if (WeaponData == nullptr)
    {
        return;
    }

    CurrentWeaponData = *WeaponData;

    if (CurrentWeaponData.WeaponMesh)
    {
        MeshComponent->SetStaticMesh(CurrentWeaponData.WeaponMesh);
    }

    MeshComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    MeshComponent->SetCollisionResponseToAllChannels(ECR_Ignore);
    MeshComponent->SetGenerateOverlapEvents(false);
    MeshComponent->SetSimulatePhysics(false);

    MeshComponent->SetRelativeLocation(
        CurrentWeaponData.AttachLocationOffset);

    MeshComponent->SetRelativeRotation(
        CurrentWeaponData.AttachRotationOffset);

    MeshComponent->SetRelativeScale3D(
        CurrentWeaponData.AttachScale);

    CurrentMagazineAmmo = CurrentWeaponData.MagazineSize;
    CurrentReserveAmmo = CurrentWeaponData.MaxAmmo;
}

결과

이 변경으로 다음 문제가 동시에 해결됐다.

  • 아래를 조준할 때 뒤로 빠르게 이동하던 문제
  • 재장전 중 이동하면 원래 위치로 끌려가던 문제
  • 클라이언트 이동이 끊겨 보이던 문제
  • 무기가 주변 지형과 충돌하면서 위치가 흔들리던 문제

핵심 회고

증상이 애니메이션 입력에 따라 발생한다고 해서 원인도 반드시 애니메이션인 것은 아니다.

Aim Offset은 직접 캐릭터를 이동시키지 않았지만, 무기 충돌 상태를 변화시켜 간접적으로 Character Movement에 영향을 줬다. 원인을 찾을 때는 “어떤 기능을 실행했을 때 발생했는가”와 “어떤 시스템이 실제 위치를 변경했는가”를 분리해서 봐야 했다.


3. Skeletal Mesh와 Static Mesh 변경으로 발생한 장착 오차

문제 상황

에디터의 WeaponSocket에 배치한 무기와 실제 게임에서 생성된 무기의 위치가 서로 달랐다.

소켓 위치를 수정해도 런타임 결과가 예상대로 바뀌지 않았고, 무기의 피벗과 충돌도 기존과 다른 형태로 동작했다.

Weaponsocket 프리뷰

원인

기존 무기 구조는 Skeletal Mesh 기준이었지만, 병합 과정에서 무기가 Static Mesh 기반으로 변경되어 있었다.

두 메시 타입은 같은 무기 외형을 사용하더라도 다음 정보가 다를 수 있다.

  • 에셋 원점과 피벗
  • 기본 회전축
  • 임포트 Transform
  • Collision 설정
  • 소켓과 머즐 위치를 표현하는 방식

따라서 기존 Skeletal Mesh를 기준으로 조정한 소켓과 오프셋이 Static Mesh에 그대로 적용될 것이라고 가정하면 안 된다.

개선 방향

장착 Transform의 책임을 명확히 분리했다.

  • WeaponSocket: 캐릭터 손을 기준으로 한 공통 장착 기준점
  • 데이터 테이블의 Offset: 무기별 피벗 차이 보정
  • Muzzle 위치: 실제 무기 메시 또는 전용 SceneComponent 기준
  • Collision: 장착 직후 강제로 비활성화
CurrentWeapon->AttachToComponent(
    GetMesh(),
    FAttachmentTransformRules::SnapToTargetNotIncludingScale,
    TEXT("WeaponSocket")
);

그 이후 각 무기 데이터의 상대 Transform을 적용한다.

MeshComponent->SetRelativeLocation(
    CurrentWeaponData.AttachLocationOffset);

MeshComponent->SetRelativeRotation(
    CurrentWeaponData.AttachRotationOffset);

MeshComponent->SetRelativeScale3D(
    CurrentWeaponData.AttachScale);

이 구조를 사용하면 공통 소켓을 유지하면서도 권총과 라이플의 피벗 차이를 데이터로 보정할 수 있다.


4. 조준 중에는 걷고, 조준 해제 후 다시 달리기

요구사항

달리기 키를 계속 누르고 있는 상태에서 조준했을 때:

  • 조준 중에는 걷기 속도로 전환
  • 달리기 입력 상태는 유지
  • 조준을 해제하면 다시 자동으로 달리기

단순히 조준 시작 시 StopSprint()를 호출하면 달리기 입력 상태까지 제거되어, 조준을 해제한 뒤 달리기 키를 다시 눌러야 한다.

따라서 물리적인 입력 상태와 현재 적용 중인 이동 상태를 분리해야 한다.

bSprintInputHeld
    달리기 키를 현재 누르고 있는가?

bIsAiming
    현재 조준 상태인가?

실제 달리기
    bSprintInputHeld && !bIsAiming

조준 시작 시에는 달리기 요청을 지우지 않고 속도만 걷기로 바꾼다.

조준 종료 시 달리기 키가 여전히 눌려 있다면 다시 달리기 속도를 적용한다.

이 방식은 입력 의도와 캐릭터 상태를 분리하기 때문에 재장전, 피격, 스태미나 부족 등 새로운 상태가 추가돼도 확장하기 쉽다.


5. 애니메이션 노티파이를 이용한 발소리 처리

설계 목표

권총과 라이플뿐 아니라 전후좌우 애니메이션이 각각 존재했다. 각 애니메이션마다 별도 발소리 로직을 작성하면 중복이 급격하게 늘어난다.

대신 애니메이션에는 “발이 바닥에 닿은 시점”만 기록하고, 실제 사운드 재생은 AnimBP에서 공통 처리했다.

애니메이션 노티파이

각 이동 애니메이션에서 기존 L/R 발 접지 타이밍에 다음 Skeleton Notify를 추가했다.

  • Footstep_L
  • Footstep_R
발 접지 타이밍에 맞춘 노티파이

AnimBP에서는 각 노티파이 이벤트를 받아 발 소켓 위치를 구했다.

AnimNotify_Footstep_L
→ Get Owning Component
→ Get Socket Location("foot_l")
→ Spawn Sound at Location
AnimNotify_Footstep_R
→ Get Owning Component
→ Get Socket Location("foot_r")
→ Spawn Sound at Location

 

발 위치에서 재생하는 이유

캐릭터 Actor Location에서 사운드를 재생하면 양발의 위치 차이가 사라진다. 발 소켓 위치를 사용하면 다음 기능으로 확장하기 쉽다.

  • 왼발과 오른발의 공간감 표현
  • 지면 Physical Material 감지
  • 바닥 재질별 사운드 변경
  • 발자국 데칼 및 먼지 이펙트 생성
  • 네트워크 원격 플레이어의 3D 발소리 처리

BlendSpace에서는 여러 애니메이션이 동시에 평가될 때 중복 노티파이가 발생할 수 있으므로 Notify Trigger Mode도 함께 확인해야 한다. 대표 애니메이션 하나의 노티파이만 사용하려면 Highest Weighted Animation이 적합하다.


6. 사망 몽타주와 결과 UI, 리스폰 흐름 정리

기존 흐름 분석

사망 상태는 PlayerState가 관리하고 있었다.

서버에서 피해 처리
→ 체력 0 확인
→ PlayerState의 bIsDead 변경
→ OnRep_bIsDead
→ OnPlayerbIsDeadChanged 델리게이트 Broadcast
→ Character의 OnbIsDeadChanged
→ Die()
→ 사망 몽타주 재생
→ OnDeathMontageEnded()

여기에는 두 종류의 델리게이트가 사용된다.

  • OnPlayerbIsDeadChanged: PlayerState 사망 상태 변경을 전달하는 Dynamic Multicast Delegate
  • FOnMontageEnded: 사망 몽타주 종료 시점을 전달하는 몽타주 델리게이트

결과 UI는 로컬 플레이어 화면에만 생성해야 하므로 기존 IsLocallyControlled() 조건을 유지했다.

if (IsLocallyControlled())
{
    AMyPlayerController* PlayerControllerES =
        Cast<AMyPlayerController>(GetController());

    if (IsValid(PlayerControllerES))
    {
        PlayerControllerES->Client_OpenResultUI();
    }
}

캐릭터를 즉시 Destroy하면 안 되는 이유

처음에는 사망 몽타주 종료 후 캐릭터를 즉시 제거하려고 했다.

하지만 기존 GameMode 리스폰 코드는 결과 UI 입력 이후 현재 Pawn을 가져와 제거하고 새 Pawn을 생성하는 구조였다.

APawn* OldPawn = TargetController->GetPawn();

if (!IsValid(OldPawn))
{
    return;
}

OldPawn->Destroy();

RestartPlayerAtTransform(
    TargetController,
    MovePoint
);

사망 몽타주 종료 시 캐릭터를 먼저 Destroy()하면, 나중에 GameMode가 GetPawn()을 호출했을 때 유효한 Pawn을 얻지 못해 리스폰 함수가 중단될 수 있다.

따라서 캐릭터는 제거하지 않고 시각적으로만 숨겼다.

void APlayerCharacter::OnDeathMontageEnded(
    UAnimMontage* Montage,
    bool bInterrupted)
{
    if (USkeletalMeshComponent* MeshComp = GetMesh())
    {
        MeshComp->SetVisibility(false, true);
    }

    if (HasAuthority() && IsValid(CurrentWeapon))
    {
        CurrentWeapon->Destroy();
        CurrentWeapon = nullptr;
    }

    if (IsLocallyControlled())
    {
        AMyPlayerController* PlayerControllerES =
            Cast<AMyPlayerController>(GetController());

        if (IsValid(PlayerControllerES))
        {
            PlayerControllerES->Client_OpenResultUI();
        }
    }
}

무기만 서버에서 제거하는 이유

무기는 별도의 복제 Actor이므로 서버에서 제거해야 한다.

if (HasAuthority() && IsValid(CurrentWeapon))
{
    CurrentWeapon->Destroy();
    CurrentWeapon = nullptr;
}

서버에서 Destroy()하면 제거 상태가 클라이언트에 복제되므로, 별도로 숨긴 뒤 제거하는 이중 처리는 필수적이지 않다.

캐릭터는 GameMode의 리스폰 로직이 사용할 수 있도록 유지하고, 무기는 남아 있을 이유가 없으므로 서버에서 제거한다.

최종 사망·리스폰 흐름

사망 상태 복제
→ 사망 몽타주 재생
→ 몽타주 종료
→ 캐릭터 메시 숨김
→ 서버에서 장착 무기 제거
→ 로컬 플레이어에게 결과 UI 표시
→ 결과 UI 입력
→ GameMode가 기존 Pawn 제거
→ RestartPlayerAtTransform으로 새 Pawn 생성
→ 새 Pawn은 기본 Visibility로 정상 표시

마무리

오늘 작업에서 가장 큰 교훈은 애니메이션, 충돌, 네트워크 이동 보정이 서로 독립된 문제가 아니라는 점이었다.

특히 아래를 조준할 때 발생한 비정상 이동은 겉으로 보면 Aim Offset 또는 Root Motion 문제처럼 보였다. 하지만 캡슐까지 실제로 움직이는지 확인하고, 입력 벡터와 네트워크 이동 코드를 배제한 뒤 무기 충돌까지 추적하면서 실제 원인을 찾을 수 있었다.

또한 사망 처리에서는 단순히 “사라지게 만들기”보다 기존 리스폰 시스템이 어떤 Pawn 생명주기를 전제로 하는지 먼저 파악해야 했다. 즉시 제거 대신 가시성만 변경함으로써 기존 GameMode 구조를 깨지 않고 요구사항을 만족시켰다.

오늘 작업을 통해 다음 기준을 정리했다.

  • 장착 무기는 기본적으로 Collision을 사용하지 않는다.
  • 메시 타입이 바뀌면 피벗, 충돌, 소켓 기준을 다시 검증한다.
  • 입력 유지 여부와 실제 이동 상태를 분리한다.
  • 애니메이션은 이벤트 시점만 제공하고 기능은 공통 로직에서 처리한다.
  • 로컬 UI와 서버 Actor 수명 관리를 명확히 분리한다.
  • 기존 리스폰 흐름을 분석하기 전에는 Pawn을 조기에 제거하지 않는다.

단순한 기능 구현을 넘어, 여러 시스템 사이의 책임과 실행 순서를 정리한 작업이었다.