UE5 Motion Matching 1편 :: 모든 캐릭터가 이동 시스템을 공유하는 구조
Unreal Engine 5.7 | Motion Matching | Pose Search | Chooser Table | AnimInstance 아키텍처캐릭터가 셋인데 이동 시스템은 하나여야 한다검사, 방패병, 궁수. 이 세 캐릭터는 무기도 다르고, 전투 스타일도 다르다.
raindrovvv.tistory.com
UE5 Motion Matching 2편 :: Offset Root Bone, Warping, IK, Aim Offset
Motion Matching이 모션을 골랐다. 그 다음은?2026.03.11 - [Dev./UE 언리얼 엔진] - UE5 Motion Matching 1편 :: 모든 캐릭터가 이동 시스템을 공유하는 구조 UE5 Motion Matching 1편 :: 모든 캐릭터가 이동 시스템을 공
raindrovvv.tistory.com
UE5 Motion Matching 3편 :: 전투 애니메이션... 그리고 GAS 연동
지금 프로젝트에서 전투는 어디에서 결정되는가 UE5 Motion Matching 1편 :: 모든 캐릭터가 이동 시스템을 공유하는 구조Unreal Engine 5.7 | Motion Matching | Pose Search | Chooser Table | AnimInstance 아키텍처캐릭터
raindrovvv.tistory.com
1편에서 세 캐릭터가 공유하는 Motion Matching 아키텍처를 설계했고, 2편에서 Offset Root Bone, Warping, IK, Aim Offset을 세팅했고, 3편에서 GAS Ability가 Montage를 트리거하고 Notify가 피드백하는 양방향 순환을 구성했다.
이 글은 실제로 부딪힌 문제들을 발생 순서대로 기록한 Dev Log다.
한 문제를 고치면 다른 문제가 드러나는 연쇄적인 과정이었고,
최종적으로는 "누가 무엇의 책임인가"라는 구조적 질문에 도달했다.

이 글에서 다루는 문제들은 다음과 같다.
- 특정 월드 방향에서만 이동 애니메이션이 틀어지는 현상
- 가만히 서 있는 캐릭터가 천천히 회전하는 Idle 드리프트
- 방향을 급격히 바꾸면 머리가 반대쪽을 보는 Aim Offset 버그
- Strafe 전환 후 고개가 카메라를 따라가지 않는 문제
- 이전 프레임 속도가 항상 현재 프레임과 같은 타이밍 버그
- 저속 이동 시작 시 Pivot 애니메이션이 재생되지 않는 문제
- ABP에서 ChooserInputObject에 접근할 때 스레드 세이프 에러
- 매 프레임 60개 이상의 콘솔 변수를 조회하는 성능 문제
- Foot Placement와 Chooser 입력 스키마 누락 경고
- AnimInstance가 캐릭터의 회전 정책을 직접 건드리는 구조적 역전
문제 1: 특정 월드 방향에서만 이동 애니메이션이 틀어진다
증상
캐릭터가 월드 +X 방향을 볼 때는 정상이다.
카메라를 +Y나 -X 방향으로 돌리는 순간, Motion Matching이 엉뚱한 클립을 선택하거나 하체 모션이 비정상적으로 나온다...
원인 분석
먼저 Chooser Table의 분기 조건을 확인했다.
- 현재 프로젝트에서는
CHT_Ares의Walk/Run분기보다IsStarting,IsPivoting,ShouldSpinTransition같은 상태 값이 더 직접적인 기준으로 작동하는 쪽에 가까웠다. 적어도 이번 문제는 Chooser가 방향별로 잘못된 DB를 고르는 쪽보다는, 입력 경로 쪽을 먼저 의심하는 편이 맞았다. - 다음으로 trajectory(Motion Matching이 캐릭터의 미래 경로를 예측하는 데 쓰는 데이터) 생성 경로를 봤다.
ABP_Seeker의GetDesiredFacing함수는 trajectory 샘플의Facing을 그대로 반환한다. 추적 과정에서 확인한 것은, trajectory facing이 사실상GetViewRotation()계열 입력과 강하게 연결되어 있었고 이 값이 카메라 회전과도 함께 영향을 주고받는 구조였다는 점이었다.
여기서 문제가 겹친다. SpringArm(카메라 암)도 bUsePawnControlRotation = true일 때 같은 GetViewRotation()을 카메라 회전에 사용한다. trajectory와 카메라가 동일한 함수를 공유한다.
설상가상으로, 당시 BP_SeekerCharacter 블루프린트의 기본값이 C++ 생성자에서 기대한 회전 정책과 어긋나 있었고 실제 적용값도 그 영향을 받고 있었다.
| 설정 | C++ 생성자 의도 | BP 기본값 (실제 적용) |
|---|---|---|
UseControllerRotationYaw |
true |
false |
OrientRotationToMovement |
false |
false |
UseControllerRotationYaw가 false이면서 OrientRotationToMovement도 false인 상태는, 캐릭터의 회전을 아무도 제어하지 않는 상태다.
trajectory 노드가 이 상태에서 GetViewRotation()을 읽으면 이동 방향과 facing이 일관되지 않게 된다.
해결 과정
처음에는 GetViewRotation()을 오버라이드해서 trajectory에 올바른 방향을 넘기는 방법을 시도했다. 하지만 SpringArm이 같은 함수를 읽으므로, 카메라 회전이 이상하게 회귀하는 부작용이 발생했다.
결국 이동 정책 자체를 명확하게 고정하는 방향을 선택했다. 전투 중심 3인칭 게임이므로, Seeker 기본 이동을 항상 Strafe(카메라 방향을 정면으로 유지하는 이동 방식)로 전환했다.
// AGS_SeekerCharacter 생성자
bUseControllerRotationYaw = true;
GetCharacterMovement()->bOrientRotationToMovement = false;
GetCharacterMovement()->bUseControllerDesiredRotation = false;
블루프린트 기본값이 생성자 설정을 덮어쓸 수 있는 구성이었기 때문에, 현재 구현에서는 BeginPlay()에서도 같은 값을 한 번 더 보장했다.
void AGS_SeekerCharacter::BeginPlay()
{
Super::BeginPlay();
bUseControllerRotationYaw = true;
GetCharacterMovement()->bOrientRotationToMovement = false;
GetCharacterMovement()->bUseControllerDesiredRotation = false;
}
💡 교훈: C++ 생성자에서 설정한 값을 블루프린트의 Class Defaults가 덮어쓰는 것은 UE에서 흔한 함정이다. 회전 정책처럼 조작감 전체를 좌우하는 값은 BeginPlay()에서 한 번 더 보장하는 습관이 필요하다. trajectory와 카메라가 같은 함수를 공유할 때는, 함수를 오버라이드하기보다 입력 자체를 올바르게 만드는 것이 더 안전하다.
문제 2: 가만히 서 있는데 캐릭터가 천천히 회전한다
증상
아무 입력도 없이 캐릭터를 Idle 상태로 두면, 매 프레임 미세하게 회전해서 시간이 지나면 완전히 다른 방향을 바라본다.
원인 분석

2편에서 다룬 Offset Root Bone(루트 본과 캡슐을 분리해서 발 미끄러짐을 방지하는 UE5 실험 기능)이 원인이었다.
Motion Matching이 선택한 Idle 클립에는 미세한 root rotation이 포함될 수 있다.
제자리 서기 모션에서 무게 중심 이동으로 발생하는 미세한 회전이다.
Idle에서 Rotation Mode가 Release(루트를 캡슐 방향으로 되돌리는 모드)이면, 매 프레임 "클립이 회전시키려는 힘"과 "캡슐로 되돌리려는 힘"이 충돌하면서 드리프트가 발생했다.
콘솔에서 a.AnimNode.OffsetRootBone.Debug 1을 입력하면 root offset 상태를 시각적으로 볼 수 있다. 디버그 표시에서 초록 원(실제 root bone 위치)이 매 프레임 미세하게 회전하는 것이 확인됐다.
해결 과정: 세 번의 시도
1차 시도 — Release: 드리프트 발생. 위에서 설명한 원인.
2차 시도 — Interpolate: 드리프트는 해결됐지만, Turn-In-Place(제자리 회전) 재생 시 캡슐 회전과 루트 본 회전이 동기화되지 않았다. 캡슐은 돌았는데 발은 제자리.
3차 시도(최종) — Accumulate + root motion 회전 제거: Accumulate(루트가 캡슐 회전을 추종하는 모드)로 전환하고, Idle에서 root motion의 회전 성분만 제거하는 이중 안전장치를 적용했다.
EOffsetRootBoneMode UGS_SeekerAnimInstance::Get_OffsetRootRotationMode() const
{
return (MovementState == EMovementState::Moving)
? EOffsetRootBoneMode::Interpolate
: EOffsetRootBoneMode::Accumulate;
}
void UGS_SeekerAnimInstance::NativePostEvaluateAnimation()
{
Super::NativePostEvaluateAnimation();
if (MovementState == EMovementState::Idle)
{
FRootMotionMovementParams& RootMotion =
GetProxyOnGameThread<FAnimInstanceProxy>().GetExtractedRootMotion();
if (RootMotion.bHasRootMotion)
{
FTransform T = RootMotion.GetRootMotionTransform();
T.SetRotation(FQuat::Identity);
RootMotion.Set(T);
}
}
}
| 상태 | Translation Mode | Rotation Mode | Halflife |
|---|---|---|---|
| Moving | Interpolate | Interpolate | 0.40s |
| Idle | Release | Accumulate | 0.15s |
⚠️ UE 5.7에서 GetProxyOnAnyThread()는 Deprecated다. NativePostEvaluateAnimation()은 게임 스레드에서 호출되므로 GetProxyOnGameThread()를 사용한다.
💡 교훈: Offset Root Bone의 Translation과 Rotation 모드 조합은 상태별로 달라야 한다. "Idle 클립에도 미세한 회전이 있을 수 있다"는 사실을 항상 염두에 두어야 한다. 한 곳에서만 고치면 다른 곳이 깨지므로, 모드 전환 + 포스트 프로세싱의 이중 안전장치가 필요하다.
문제 3: 방향을 급격히 바꾸면 머리가 반대쪽을 바라본다
증상
캐릭터가 180도 회전한 직후, Aim Offset(상체와 머리를 조준 방향으로 회전시키는 보정)의 yaw 값이 이전 프레임 값을 유지한다. 몸은 돌았는데 머리가 아직 뒤를 보고 있다.
원인 분석
UpdateCompatibilityValues()에서 AO 값은 매 프레임 보간으로 부드럽게 갱신된다.
일반적인 상황에서는 자연스럽지만, 캐릭터가 한 프레임 만에 90도 이상 회전하는 급격한 상황에서는 보간이 따라잡지 못한다.
해결
급격한 회전을 감지했을 때 AO 값을 즉시 리셋하는 로직을 추가했다. 두 가지 조건을 OR로 결합한다.
const float RotationDelta = FMath::Abs(
FRotator::NormalizeAxis(TargetRotation.Yaw - PreviousRotation.Yaw));
const float RotationSpeed = DeltaSeconds > KINDA_SMALL_NUMBER
? RotationDelta / DeltaSeconds : 0.f;
constexpr float RapidRotationThreshold = 360.f; // 초당 360도 이상
constexpr float LargeRotationDelta = 90.f; // 프레임 간 90도 이상
if (RotationSpeed > RapidRotationThreshold || RotationDelta > LargeRotationDelta)
{
AOValue = FVector2D::ZeroVector;
}
높은 프레임 레이트에서는 프레임 간 delta가 작아도 속도가 높을 수 있고,
프레임 드랍 시에는 속도 계산이 부정확해도 절대 delta가 크다. 두 조건이 서로의 사각지대를 보완한다.
💡 교훈: 보간 기반 시스템에서는 "연속적인 변화"만 가정하면 안 된다. 불연속적인 상태 전환(급격한 회전, 순간이동, 리스폰)이 발생했을 때 보간 버퍼를 리셋하는 경로를 반드시 마련해야 한다.
문제 4: Strafe 전환 후 고개가 카메라를 따라가지 않는다
증상
문제 1에서 항상 Strafe로 전환한 뒤, locomotion은 원하는 대로 동작한다.
그런데 정지 상태에서 마우스를 움직여도 캐릭터의 머리가 카메라를 따라가지 않는다.
원인 분석
문제 1에서 bUseControllerRotationYaw = true를 항상 고정했다.
이 상태에서는 정지 중에도 ActorRotation.Yaw가 ControlRotation.Yaw를 즉시 따라간다.
AO yaw는 "조준 방향(BaseAimRotation)과 캐릭터 방향(ActorRotation)의 차이"로 계산되는데,
두 값이 항상 같으니 차이가 0이다.
AO_Yaw = BaseAimRotation.Yaw - ActorRotation.Yaw
= 135° - 135° = 0° ← 고개 안 돌아감
해결 과정: 구조적 선택
Idle에서만 bUseControllerRotationYaw를 false로 돌리면 해결된다.
하지만 누가 이 값을 제어해야 하는가가 문제다.
처음에는 UGS_SeekerAnimInstance::NativeUpdateAnimation()에서 직접 토글했다.
동작은 했지만, AnimInstance가 캐릭터의 이동/회전 정책을 변경하는 것은 책임 역전이다.
AnimInstance는 상태를 관찰해서 애니메이션에 반영하는 것이 본래 역할이다.
최종적으로 UGS_SeekerAnimStateComponent::TickComponent()로 이관했다.
이 컴포넌트는 이미 Gait, MontageSlot, Aim/Draw, Combo, TurnInPlace 상태를 소유하고 있으므로, 회전 정책도 여기에 두는 것이 자연스럽다.
void UGS_SeekerAnimStateComponent::UpdateRotationPolicy()
{
const bool bShouldUseControllerYaw = bIsAiming || bIsDrawing
|| Speed > 10.0f
|| !Accel.IsNearlyZero();
Character->bUseControllerRotationYaw = bShouldUseControllerYaw;
}
| 상태 | bUseControllerRotationYaw | 효과 |
|---|---|---|
| Idle (입력 없음) | false |
AO yaw 살아남, 고개가 카메라 추종 |
| 이동 중 | true |
Strafe 유지 |
| Aim/Draw 중 | true |
조준 시 몸이 카메라와 일치 |
| 가속 입력 중 (아직 느림) | true |
이동 시작 직전부터 Strafe 진입 |
원격 프록시에서의 안정성도 고려했다. CurrentAcceleration은 원격 클라이언트에서 신뢰하기 어려우므로, UpdateRotationPolicy()에서 가속도 기반 판단은 로컬/서버에서만 신뢰한다.
원격 프록시가 불완전한 가속도 값 때문에 회전 정책이 흔들리는 것을 방지한다.
💡 교훈: "누가 이 값을 바꿔야 하는가"는 기능 구현보다 중요한 질문이다. AnimInstance가 캐릭터의 이동 정책을 변경하면 디버깅이 극도로 어려워진다. 상태 변경의 책임은 상태를 소유한 컴포넌트에 두고, AnimInstance는 읽기만 하는 원칙을 지켜야 유지보수가 가능하다.
문제 5: IsPivoting()이 항상 false를 반환한다
증상
IsPivoting()(달리다가 급하게 방향을 바꿀 때 이를 감지하는 함수)이 항상 false를 반환한다.
Pivot 애니메이션이 절대 재생되지 않는다.
원인 분석
IsPivoting()은 이전 프레임 속도(VelocityLastFrame)와 현재 속도(Velocity)의 방향 차이를 비교한다.
두 값이 항상 같았다.
실행 순서를 따라가면 원인이 명확해진다.
UGS_SeekerAnimInstance::NativeUpdateAnimation()
│
├── Super::NativeUpdateAnimation()
│ └── UGS_CharacterAnimInstance::UpdateEssentialValues()
│ ├── Velocity = MovementComponent->Velocity
│ └── VelocityLastFrame = Velocity ← 여기서 덮어씀!
│
└── 이후 IsPivoting() 호출
└── VelocityLastFrame == Velocity ← 항상 같음!
파생 클래스의 NativeUpdateAnimation()이 Super::를 먼저 호출하므로, IsPivoting()이 불리는 시점에는 이미 VelocityLastFrame이 현재 값으로 덮어써진 상태다.
해결
더블 버퍼링을 도입했다. CurrentFrameVelocity라는 private 변수를 중간 버퍼로 추가하고, 갱신 순서를 "이전 값 보존 → 현재 값 기록"으로 강제했다.
void UGS_CharacterAnimInstance::UpdateEssentialValues(float DeltaSeconds)
{
const FVector Velocity = MovementComponent->Velocity;
VelocityLastFrame = CurrentFrameVelocity; // 이전 값 보존
CurrentFrameVelocity = Velocity; // 현재 값 기록
// ...
}
💡 교훈: 상속 구조에서 Super:: 호출과 파생 클래스 로직의 실행 순서는 “이전 프레임 vs 현재 프레임” 비교 로직을 쉽게 깨뜨린다. 이전 프레임 값이 필요하면 갱신 함수의 첫 줄에서 보존하고 마지막 줄에서 현재 값을 기록하는 더블 버퍼 패턴을 습관화해야 한다. 파생 클래스에서 직접 속도를 캐싱하지 말고, 반드시 기반 클래스의 VelocityLastFrame을 사용해야 한다.
문제 6: 저속 이동 시작 시 Pivot이 감지되지 않는다
증상
정지 상태에서 이동을 시작할 때(저속 25cm/s 미만), 방향 전환 Pivot 애니메이션이 재생되지 않는다. 고속 이동 중 방향 전환에서는 정상 작동한다.
원인 분석
IsPivoting() 함수의 속도 임계값이 10.0f로 설정되어 있었다. 정지→이동 전이 구간에서 속도가 10을 넘지 않는 프레임에서는 IsPivoting()이 아예 평가를 건너뛰고 있었다.
해결
저속 구간(25cm/s 미만)에서는 현재 속도 대신 FutureVelocity(Motion Matching 예측 속도)만으로 방향 전환을 감지하도록 분기를 추가했다.
bool UGS_ChooserInputObj::IsPivoting()
{
if (Speed2D < 25.0f)
{
const float FutureSpeed = FutureVelocity.Size2D();
if (FutureSpeed < 10.0f) return false;
const FRotator CurrentRot = Velocity.SizeSquared2D() > 1.0f
? Velocity.Rotation()
: CharacterTransform.GetRotation().Rotator();
const FRotator FutureRot = FutureVelocity.Rotation();
FRotator DeltaRot = UKismetMathLibrary::NormalizedDeltaRotator(FutureRot, CurrentRot);
return FMath::Abs(DeltaRot.Yaw) > GetPivotThreshold();
}
// ... 기존 고속 이동 로직
}
현재 속도가 거의 0이면 속도 벡터의 방향이 의미가 없으므로, 대신 캐릭터의 현재 facing(CharacterTransform 기준)과 미래 속도 방향을 비교한다.
💡 교훈: 이동 판정 함수에서 "속도가 낮으면 판정을 건너뛴다"는 최적화는, 정지→이동 전이 구간에서 중요한 애니메이션 전환을 놓치게 만들 수 있다. 저속 구간에서는 현재 속도 대신 미래 예측 속도를 사용하는 별도 경로가 필요하다.
문제 7: Update_Trajectory에서 스레드 세이프 에러
증상
Get ChooserInputObject 오브젝트 레퍼런스에 접근하는 것은 스레드 세이프 방식이 아닙니다
ABP의 Update_Trajectory (OnUpdate) 이벤트에서 ChooserInputObject에 직접 접근했을 때, 이 에러가 발생했다.
원인 분석
UE의 애니메이션 시스템에서 NativeUpdateAnimation()은 게임 스레드에서 실행되지만, OnUpdate (AnimGraph의 업데이트)는 워커 스레드에서 실행될 수 있다. ChooserInputObject는 UObject 포인터이므로, 워커 스레드에서 접근하면 스레드 안전성 위반이다.
해결
두 가지 원칙을 적용했다.
NativeUpdateAnimation() = 게임 스레드. UObject 포인터 접근이 안전하다. ChooserInputObject에 대한 모든 읽기/쓰기는 여기서만 수행한다.
OnUpdate = 워커 스레드. UObject 포인터 접근이 금지된다. 필요한 값이 있으면, NativeUpdateAnimation()에서 ABP의 로컬 변수에 미리 복사해두고, OnUpdate에서는 복사본만 읽는다.



[게임 스레드: NativeUpdateAnimation()]
ChooserInputObject->Speed2D → ABP 로컬 변수 CachedSpeed2D에 복사
ChooserInputObject->Gait → ABP 로컬 변수 CachedGait에 복사
[워커 스레드: OnUpdate]
CachedSpeed2D 읽기 (UObject 접근 없음) → 안전
💡 교훈: AnimGraph의 OnUpdate에서 UObject 포인터에 접근하면 안 된다. 게임 스레드에서 필요한 값을 primitive 타입이나 구조체로 복사해두고, 워커 스레드에서는 복사본만 사용하는 것이 UE 애니메이션 시스템의 기본 원칙이다.
문제 8: 매 프레임 60개 이상의 콘솔 변수를 조회하는 성능 문제
증상
프로파일링 결과, Update_CVarDrivenVariables()가 매 프레임 약 0.3~0.5ms를 소비하고 있었다.
60fps 기준 전체 프레임 예산의 약 2~3%다.
원인 분석
이 함수가 Motion Matching 품질, Offset Root Bone 반경, 디버그 표시 등을 제어하는...
CVar(Console Variable — 런타임에 콘솔 명령으로 조정할 수 있는 엔진 변수)를 읽을 때마다, IConsoleManager::Get().FindConsoleVariable(TEXT("DDCvar.MMDatabaseLOD"))같은 조회를 60회 이상 반복하고 있었다. 이 프로젝트에서는 그 반복 호출 비용이 누적되면서, 프로파일링에서 무시하기 어려운 구간으로 드러났다.
해결 과정: 두 번의 시도
1차 시도 — 초기화 시에만 호출: CVar 포인터를 멤버 변수로 캐싱하고, NativeInitializeAnimation()에서 한 번만 조회하고, Update_CVarDrivenVariables()도 초기화 시에만 호출했다. 성능은 해결됐지만, 에디터 콘솔에서 CVar를 변경했을 때 값이 반영되지 않는 문제가 발생했다.
2차 시도(최종) — 조회는 한 번만, 읽기는 매 프레임: 포인터 캐싱은 유지하되, Update_CVarDrivenVariables()는 NativeUpdateAnimation() 끝에서 매 프레임 호출하도록 복원했다. 캐싱된 포인터에서 GetInt()/GetFloat()만 호출하므로 성능 영향은 무시할 수 있다.
// 헤더 — private 섹션
IConsoleVariable* CVarMMDatabaseLOD = nullptr;
// ... 총 27개 CVar 포인터
void UGS_SeekerAnimInstance::CacheCVars()
{
CVarMMDatabaseLOD = IConsoleManager::Get().FindConsoleVariable(
TEXT("DDCvar.MMDatabaseLOD"));
// ...
}
| 항목 | Before | After |
|---|---|---|
FindConsoleVariable() 호출 |
매 프레임 60+회 | 초기화 시 27회 |
| 매 프레임 비용 | 0.3 ~ 0.5ms | 무시 가능 |
| 런타임 CVar 변경 | 즉시 반영 | 즉시 반영 |
💡 교훈: FindConsoleVariable()은 단발로는 가볍지만, 대량 반복 시 누적 비용이 발생한다. CVar 포인터는 엔진 생존 기간 동안 유효하므로, 초기화 시 캐싱하고 이후에는 포인터만 참조하는 것이 정석이다.
문제 9: Foot Placement와 Chooser 입력 스키마 누락

증상 1: Foot Placement 프로퍼티 바인딩 경고
ABP_Seeker 컴파일 시 Foot Placement 관련 프로퍼티 바인딩 경고가 발생한다.
원인
ABP_Seeker의 Foot Placement 노드가 Get_FootPlacementPlantSettings와 Get_FootPlacementInterpolationSettings 함수를 기대하는데, UGS_SeekerAnimInstance에 해당 getter가 선언되어 있지 않았다.
해결
두 getter 함수를 UGS_SeekerAnimInstance에 추가해서 경고를 제거했다.
증상 2: Chooser 입력 스키마 누락
CHT_Ares가 기대하는 입력 필드와 GS_ChooserInputObj C++ 클래스의 필드가 일치하지 않았다.
원인
현재 Chooser 설정이 기대하는 MovementMode, TrajectoryCollision, JustTraversed 입력과 GS_ChooserInputObj의 UPROPERTY 구성이 어긋나 있었다.
해결
GS_ChooserInputObj에 세 필드를 추가해서 스키마를 일치시켰다.
💡 교훈: Chooser Table이 C++ UObject의 프로퍼티를 참조하므로, Chooser 에디터에서 컬럼을 추가하면 C++ 쪽에도 대응하는 UPROPERTY가 있어야 한다. ABP 에디터에서 함수 바인딩을 설정하면 C++ 쪽에 해당 함수가 있어야 한다. 양쪽이 항상 일치하는지 컴파일 경고로 확인해야 한다.
문제 10: AnimInstance가 캐릭터를 제어하는 구조적 역전
증상
이 문제는 버그가 아니라 코드 리뷰에서 발견된 설계 문제다. 문제 4를 해결하는 과정에서 UGS_SeekerAnimInstance::NativeUpdateAnimation()이 Character->bUseControllerRotationYaw를 직접 변경하고 있었다.
왜 이것이 문제인가
정상적인 데이터 흐름은 이렇다.
캐릭터 상태 ──→ AnimInstance (관찰) ──→ 애니메이션 출력
역방향 의존성이 있으면 이렇게 된다.
AnimInstance ──→ 캐릭터 상태 변경 ──→ 다음 프레임 AnimInstance가 읽음
이 역방향이 있으면 디버깅이 매우 어려워진다. "이 캐릭터의 회전 모드가 왜 바뀌었지?"를 추적할 때, AnimInstance가 범인일 거라고는 잘 생각하지 못한다.
해결
회전 정책 결정 책임을 UGS_SeekerAnimStateComponent::TickComponent()로 이동했다.
[이전 구조]
캐릭터 상태 ──→ AnimInstance ──→ 애니메이션 출력
└──→ Character.bUseControllerRotationYaw 변경 (역방향!)
[개선 구조]
캐릭터 상태 ──→ AnimStateComponent ──→ bUseControllerRotationYaw 변경 (정방향)
──→ AnimInstance ──→ 애니메이션 출력 (읽기만)
AnimInstance에서 제거한 코드는 단 몇 줄이지만, 이 변경으로 얻는 것은 크다. 디버깅에서 "회전 정책은 AnimStateComponent만 본다"는 규칙이 생긴다. AnimInstance가 게임 스레드 전용 프로퍼티를 건드리지 않으므로 스레드 안전성도 개선된다.
💡 교훈: 동작하는 코드와 올바른 구조의 코드는 다르다. AnimInstance는 "관찰자"로 유지하고, 상태 변경은 상태를 소유한 컴포넌트가 수행하는 원칙을 지키면, 프로젝트가 커져도 디버깅 가능한 시스템을 유지할 수 있다.
이번 정리 이후 확인한 결과
| 문제 | Before | After |
|---|---|---|
| 방향별 이동 틀어짐 | 특정 월드 방향에서 MM 클립 오선택 | 모든 방향에서 정상 |
| Idle 회전 드리프트 | 매 프레임 미세 회전 누적 | 완전 해소 |
| 급격한 회전 후 AO | 머리가 반대 방향 | 즉시 리셋 후 보간 복귀 |
| Strafe Idle에서 AO | yaw 0 고정 (고개 안 돌아감) | 카메라 자유 추종 |
| IsPivoting 항상 false | Pivot 애니메이션 미재생 | 정상 감지 |
| 저속 Pivot 미감지 | 정지→이동 전이에서 Pivot 누락 | 저속에서도 정상 감지 |
| 스레드 세이프 에러 | UObject 접근 위반 | 게임 스레드에서만 접근 |
| CVar 매 프레임 조회 | 무시 가능 | |
| Foot/Chooser 스키마 | 컴파일 경고 | 경고 제거 |
| 회전 정책 책임 | AnimInstance에서 직접 변경 | AnimStateComponent로 이관 |
디버깅 원칙 정리
이 트러블슈팅 과정에서 반복적으로 적용한 원칙을 정리한다.
- 특정 방향에서만 깨지면 trajectory/facing 입력 경로를 먼저 의심한다. 이번 케이스에서는
CHT_Ares의 Walk/Run 분기를 파고드는 것보다 trajectory 생성 경로 쪽을 먼저 보는 편이 맞았다. - 카메라와 trajectory가 같은 함수를 보면, 함수를 오버라이드하지 말고 입력을 고친다.
GetViewRotation()을 오버라이드하면SpringArm까지 영향을 받는다. 카메라가 정상인지 먼저 확인한 뒤, locomotion 정책을 고정하는 순서가 맞다. - Strafe에서 AO yaw가 죽으면, AO 계산식보다
bUseControllerRotationYaw를 먼저 확인한다. 이 플래그가 idle에서도 켜져 있으면 ActorYaw와 CameraYaw가 동기화되어 delta가 0이 된다. - C++ 생성자 값을 BP Class Defaults가 덮어쓸 수 있다. 회전 정책처럼 중요한 값은
BeginPlay()에서 한 번 더 적용한다. Super::호출 후 이전 프레임 값이 살아 있는지 반드시 확인한다. 더블 버퍼 패턴을 사용하고, 파생 클래스에서 직접 속도를 캐싱하지 않는다.OnUpdate(워커 스레드)에서 UObject에 접근하지 않는다. 필요한 값은NativeUpdateAnimation()(게임 스레드)에서 primitive로 복사한다.- AnimInstance는 캐릭터의 이동/회전 정책을 변경하지 않는다. 회전 정책은
AnimStateComponent의 책임이다. a.AnimNode.OffsetRootBone.Debug 1로 root offset 상태를 시각적으로 확인한다. 파란색은 컴포넌트 기준, 초록색은 실제 root bone 결과, 빨간색은 허용 한계다.
이 글에서는 시스템 구축 과정에서 실제로 부딪힌 10가지 문제와 해결 과정을 기록했다.
한 문제를 고치면 다른 문제가 드러나는 연쇄적인 과정이었고,
최종적으로는 “AnimInstance는 관찰자, 상태 변경은 컴포넌트” 라는 구조적 원칙에 도달했다.
5편 — 이식 가이드 편에서는 이 과정... 즉 GASP 모션매칭 시스템을 다른 프로젝트에 재현하는 구체적인 절차를 다룬다.
Skeleton 호환성 확인 3단계, 전략 A(ABP 공유)와 전략 B(캐릭터별 ABP) 선택 기준, Chooser Table 세팅 등을 정리한다.
'Dev. > UE 언리얼 엔진' 카테고리의 다른 글
| AI와 함께 Unreal Engine 플러그인을 만드는 워크플로우 — 삽질을 시스템으로 바꾸는 법 (0) | 2026.03.19 |
|---|---|
| UE5 Motion Matching 5편 :: 다른 프로젝트에 이 시스템을 옮기는 방법 (0) | 2026.03.15 |
| UE5 Motion Matching 3편 :: 전투 애니메이션... 그리고 GAS 연동 (0) | 2026.03.14 |
| UE5 Motion Matching 2편 :: Offset Root Bone, Warping, IK, Aim Offset (1) | 2026.03.13 |
| UE5 Motion Matching 1편 :: 모든 캐릭터가 이동 시스템을 공유하는 구조 (2) | 2026.03.12 |