Today I Learned |
Unreal Engine 5.7|AnimBlueprint|GAS|AIPerception|Collision|DataTable|C++
오늘은 일반 몬스터(늑대)의 이동·공격·인지를 구현하면서 마주친 ABP 바인딩 함정, 캐릭터 콜리전 설계 제약, 공격 몽타주 데이터화, 시야/소실 로직을 정리했습니다. 표면 증상이 진짜 원인과 다른 경우가 많아 소거법으로 추적한 기록입니다.
자식 ABP(ABP_WolfEnemy)가 부모(ABP_EnemyBase)를 상속하는데, 이동 클립들이 재생되지 않았습니다(Dead만 정상). 스크린샷상 모든 노드가 동일하게 바인딩돼 있어 한참 헤맸습니다.
원인은 노드의 클립 출처였습니다. Sequence Player 노드가 변수에 property binding돼 있으면, 그 노드는 바인딩된 변수의 값(= 자식 ABP의 Class Defaults)을 읽습니다. 이때 Anim Graph Override 패널의 값은 완전히 무시됩니다.
❌ Anim Graph Override 패널에만 클립 지정 → 바인딩된 노드가 무시 → null → 재생 안 됨
✅ Class Defaults의 변수 값에 클립 지정 → 바인딩된 노드가 읽음 → 정상 재생
Dead는 Class Defaults에 클립이 들어가 있어서 정상 재생됐습니다.MoveStart/IdleBreak 등은 Override 패널에만 넣고 Class Defaults는 비어 있어 null이 나왔습니다.배운 것: 바인딩된 노드의 애니가 안 나오면 Override 패널이 아니라 Class Defaults의 변수 값을 확인해야 합니다.
showdebug animation은 BP 변수 값을 안 보여주므로, ABP 디버그-오브젝트 드롭다운이나 Rewind Debugger로 변수 값을 직접 봐야 합니다.
"공격 중 Idle이 보인다"를 추적하며 AnimGraph 출력 경로를 역추적했습니다.
Main SM → cache 'Main'
→ LayeredBoneBlend ( Base = 'Main', BlendPoses_0 = Slot 'UpperBody' )
→ cache 'UpperPose'
→ Slot 'DefaultSlot' ← 출력 직전 최종 풀바디 슬롯
→ Output Pose
DefaultSlot이 출력 직전 최종 노드라, 여기 재생되는 몽타주는 전신을 완전히 덮습니다.UpperBody 슬롯은 LayeredBoneBlend의 상체 레이어라, 여기 재생되면 상체 본만 덮고 하체는 locomotion이 비칩니다.또한 montage 에디터의 슬롯 드롭다운에 UpperBody가 안 보이는 이유는, 슬롯 목록이 Skeleton의 Anim Slot Manager에 등록된 것만 나오기 때문입니다. ABP의 슬롯 노드와 별개로, 스켈레톤에 등록돼 있어야 montage가 그 슬롯을 쓸 수 있습니다.
| 구분 | DefaultSlot | UpperBody 슬롯 |
|---|---|---|
| 위치 | 출력 직전 최종 노드 | LayeredBoneBlend의 상체 레이어 |
| 효과 | 전신 덮어쓰기 | 상체 본만, 하체는 locomotion |
| 용도 | 전신 공격/리액션 | 이동 중 상체 동작 |
배운 것: 몽타주가 전신을 덮으려면 출력 경로의 슬롯에 재생돼야 합니다. 슬롯은 ABP가 아니라 스켈레톤이 소유하므로, 슬롯이 안 보이면 Skeleton Slot Manager를 확인합니다. (이번 늑대 공격은 DefaultSlot이 맞아 결과적으로 슬롯 문제는 아니었습니다.)
4족 보행 늑대의 콜리전을 "앞으로 길쭉하게" 만들려다 막혔습니다. Character의 루트 캡슐은 두 가지가 강제됩니다.
따라서 "앞으로만 길쭉한 캡슐"은 불가능합니다. 4족 보행은 낮고 적당히 넓은 수직 캡슐 + 코·꼬리 오버행 허용으로 처리합니다. 몸통 모양이 꼭 필요하면 메시에 자식 BoxComponent를 붙입니다(루트만 캡슐 강제).
추가로 배운 것: CharacterMovement의 이동/월드 충돌은 루트 캡슐만 사용합니다. 자식 박스는 늑대 자신의 이동에 영향을 주지 못하고, 다른 액터/트레이스가 부딪힐 때만 작동합니다. 그래서 "긴 몸이 벽을 안 뚫게" 같은 월드 블로킹도 결국 캡슐의 몫입니다.
플레이어 무기가 적을 때리는 판정을 코드에서 확인했습니다.
// GA_Attack.cpp
FCollisionObjectQueryParams ObjectQueryParams;
ObjectQueryParams.AddObjectTypesToQuery(ECC_Pawn); // ObjectType = Pawn
World->SweepMultiByObjectType(HitResults, SweepStart, CurrentTraceOrigin,
FQuat::Identity, ObjectQueryParams, FCollisionShape::MakeSphere(TraceRadius), QueryParams);
SweepMultiByObjectType(ECC_Pawn)은 ObjectType이 Pawn인 컴포넌트만 때립니다. 채널 응답이나 PhysicsAsset과 무관합니다. 그래서 늑대 캡슐(ObjectType=Pawn)이 이미 이 스윕에 맞고, 피격은 박스 없이도 작동했습니다. 몸 모양 정밀 판정이 필요하면 메시의 ObjectType을 Pawn으로 + Query를 켜면 PhysicsAsset 바디가 맞습니다.
배운 것: 회피·박스 추가 전에 "무엇을 기준으로 때리는가"를 코드에서 확인해야 했습니다. ObjectType 질의는 채널 응답 설정과 독립이라, 뒤(2-3)의 Camera 채널 수정과도 충돌하지 않습니다.
적이 카메라와 플레이어 사이에 들어오면 스프링암이 당겨졌습니다.
bDoCollisionTest로 Camera 채널 프로브를 쏩니다.해결은 적 콜리전이 Camera 채널만 Ignore하게 하는 것입니다. ObjectType 판정(2-2)과 별개라 피격이 깨지지 않습니다.
// RetrieveEnemyCharacter 생성자 — 모든 적에 일괄 적용
GetCapsuleComponent()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
GetMesh()->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
FistHitbox->SetCollisionResponseToChannel(ECC_Camera, ECR_Ignore);
배운 것: 캡슐만이 아니라 메시도 콜리전이 켜져 있으면 Camera를 막습니다. "모든 적 일괄"이 필요한 옵션은 C++ 생성자가 맞고, BP에서 추가한 컴포넌트(박스 등)는 따로 처리하거나 커스텀 프리셋으로 통일합니다.
공유 GA_EnemyBasicAttack이 몽타주를 하드코딩하던 것을 데이터화했습니다.
// FMonsterPatternRow — 미사용 FName을 몽타주 포인터로 교체
TSoftObjectPtr<UAnimMontage> AttackMontage;
// EnemyCombatComponent::RequestBasicAttack — 이벤트에 실어 전달
EventData.OptionalObject = Row->AttackMontage.LoadSynchronous();
GA는 이미 Event Data → Optional Object → Cast To AnimMontage → PlayMontageAndWait로 배선돼 있어, C++에서 몽타주만 실어 보내면 끝이었습니다.
설계의 핵심은 2계층 분리입니다.
| 계층 | 발동 경로 | 몽타주 | 예 |
|---|---|---|---|
| 일반 공격 | Enemy.Attack → 공유 GA | 패턴 행 AttackMontage (데이터) | 늑대 물기 |
| 특수 패턴 | Enemy.SpecialAttack → 전용 GA | GA가 자체 소유(다중 몽타주/로직) | 와이번 AerialDive |
배운 것: 다중 몽타주·가변 시간 패턴(AerialDive 등)은 "긴 몽타주 + 캔슬"이 아니라 루프 애니 + 이동 AbilityTask(
ApplyRootMotionMoveToActor)로 풀어야 자연스럽습니다. 공유 데이터 필드(AttackMontage)는 일반 공격 전용이고, 특수 패턴을 막지 않습니다.
AttackMontage를 패턴 행에 두면 특수 패턴 행에도 빈 필드가 생긴다는 점이 걸렸습니다. 하지만 FMonsterPatternRow는 이미 bCanBeParried, CounterEventTag, HitboxBoneName 등 일부 패턴만 쓰는 필드 투성이였습니다. 한 종류(공격 패턴)의 속성 합집합을 한 struct에 담고 행마다 필요한 것만 채우는 것이 DataTable의 정상 패턴입니다.
배운 것: "한 점에 몰리니 빼야 하나"를 고민하기 전에, granularity(어느 단위의 속성인가)와 응집(같이 정의돼야 하는가)을 봅니다. 몽타주는 패턴당이고 히트박스와 같은 행에 있어야 하므로 패턴 행이 맞습니다. MonsterData(몬스터당)는 단위가 틀립니다.
데이터를 다 세팅했는데 늑대가 엉뚱하게 동작했습니다. Monolith로 데이터 → 참조를 따라 역추적했습니다.
DT_MonsterPattern → Wolf_Normal_Attack 행 정상 (AttackMontage/Jaw) ✅
DT_MonsterData → 늑대 행 PatternSlots 정상, 단 행 이름이 "NewRow" ⚠️
BP_EnemyWolf (CDO) → MonsterDataRowName = "Zombie_Basic" ❌ 범인
자식 BP_EnemyWolf가 부모 BP_EnemyZombie의 MonsterDataRowName = "Zombie_Basic"을 상속한 채 안 바꿔서, 늑대가 좀비 데이터(좀비 몽타주, hand_r 히트박스)로 초기화되고 있었습니다.
배운 것: 상속 BP는 부모의
EditDefaultsOnly값을 그대로 물려받으니, 오버라이드 여부를 CDO에서 확인해야 합니다. 데이터 기반 시스템의 버그는 "데이터 행 → 참조하는 BP/컴포넌트"를 한 단계씩 따라가며 소거하는 것이 빠릅니다.
"360도 감지"의 원인은 PeripheralVisionAngleDegrees = 140이었습니다. 이 값은 콘의 절반각이라 실제 시야는 280° 콘(뒤 80°만 사각)입니다.
그런데 60으로 줄였더니 근접한 플레이어를 못 봤습니다. UAISenseConfig_Sight는 콘 각도가 하나뿐인 3D 원뿔이라 좌우와 함께 상하도 같이 좁아지기 때문입니다. 늑대는 키가 낮아 근접 시 플레이어로 가는 벡터가 수직으로 가팔라져 좁은 콘 밖으로 나갑니다.
해결은 역할 분리입니다.
// 엔진 콘은 넓게(수직 담당) + evaluator에서 yaw-only 게이트(수평 FOV)
FVector ToTarget = Actor->GetActorLocation() - PawnLocation; ToTarget.Z = 0.f;
FVector Forward = Pawn->GetActorForwardVector(); Forward.Z = 0.f;
ToTarget.Normalize(); Forward.Normalize();
if (FVector::DotProduct(Forward, ToTarget) < FMath::Cos(FMath::DegreesToRadians(HorizontalHalfFOV)))
{
continue; // 수평 시야 밖 → 무시
}
배운 것: 엔진 Sight는 수평/수직 FOV를 분리 설정할 수 없습니다. 콘은 넓게 두어 수직·근접을 보장하고, 수평 FOV는 yaw만 보는 커스텀 게이트로 구현하면 사실상 분리됩니다.
"NavMesh를 벗어나도 계속 감지된다"의 원인은 두 가지가 맞물린 것이었습니다.
TimeSinceLastSeen ≥ TargetLostDelay)은 타깃이 인지 목록에서 빠졌을 때만 누적됩니다.해결은 "NavMesh 밖 타깃은 미인지로 처리"해서 기존 타이머를 재활용하는 것입니다.
if (NearestTarget)
{
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(Pawn->GetWorld());
FNavLocation ProjectedLoc;
const bool bOnNavMesh = NavSys && NavSys->ProjectPointToNavigation(
NearestTarget->GetActorLocation(), ProjectedLoc, FVector(100.f, 100.f, 250.f));
if (!bOnNavMesh) { NearestTarget = nullptr; } // 보여도 NavMesh 밖이면 "안 보임" 처리
}
이렇게 하면 NavMesh 밖에서는 타이머가 누적되어 해제되고, 해제 후엔 재획득도 막힙니다(획득은 NearestTarget 유효 분기에서만 일어나므로).
배운 것: "일정 시간 후 해제" 로직이 안 먹으면, 그 로직의 발동 조건이 실제로 만들어지는지부터 확인합니다. 인지(Perception)와 도달성(NavMesh)은 완전히 다른 시스템입니다.
추격 중 방향 전환이 0.2초쯤 늦었습니다. evaluator가 TargetLocation을 0.2초(TickInterval) throttle 블록 안에서만 갱신하고 있었습니다 → MoveTo 목적지가 그만큼 지연.
// 무거운 perception 쿼리는 throttle 유지, 타깃 위치 갱신만 매 틱으로 분리
APawn* Pawn = Context.GetExternalDataPtr(PawnHandle);
if (Pawn && IsValid(InstanceData.TargetPlayer))
{
InstanceData.TargetLocation = InstanceData.TargetPlayer->GetActorLocation();
InstanceData.DistanceToTarget = FVector::Dist(Pawn->GetActorLocation(), InstanceData.TargetLocation);
}
InstanceData.AccumulatedTime += DeltaTime;
if (InstanceData.AccumulatedTime < TickInterval) { return; } // 이하 무거운 쿼리만 throttle
배운 것: 성능을 위한 throttle 안에 싼 갱신(위치)까지 같이 묶이면 반응성이 죽습니다. 무거운 작업(perception 쿼리)과 싼 작업(위치 refresh)을 분리합니다.
여러 적이 플레이어에 몰리면 서로 밀리는 문제를 두고 두 회피 시스템을 비교했습니다.
| 항목 | RVO (튜닝) | Detour Crowd |
|---|---|---|
| 정체 | CharacterMovement 내장 로컬 속도 회피 | 네비메시 기반 군중 시뮬 |
| 성능(10개체) | 매우 가벼움 | 약간 무거움(절대량은 작음) |
| 한 점 수렴 품질 | 약함(진동·밀림) | 강함(부드러움) |
| 셋업 | 파라미터 몇 개 | C++ 컴포넌트 교체 + 튜닝 |
| 스케일(수십+) | 나빠짐 | 우수 |
배운 것: 10개체 규모에선 성능상 Detour Crowd가 과합니다. 다만 근접 적이 한 점(플레이어)에 몰리는 문제는 회피 시스템만으로 완전 해결이 안 되고 — 공격 위치 분산(포위 슬롯)이 더 근본적입니다. 회피는 "이동 중 분리"만 담당합니다.
UNavigationSystemV1::ProjectPointToNavigation을 쓰자 헤더는 컴파일됐지만 링크 에러(LNK2019)가 났습니다. AIModule은 있어도 NavigationSystem 모듈이 의존성에 없었기 때문입니다.
// Retrieve.Build.cs — PublicDependencyModuleNames
"AIModule",
"NavigationSystem", // UNavigationSystemV1 링크용
배운 것: 컴파일은 되는데 LNK2019(unresolved external)가 나면 거의 모듈 의존성 누락입니다. 심볼이 속한 모듈을
Build.cs에 추가하고 리빌드합니다.
- ABP에서 property-binding된 노드는 Class Defaults의 변수 값을 읽고 Anim Graph Override 패널은 무시합니다.
- Character 루트 캡슐은 수직·원형 고정이고 이동 충돌은 캡슐만 씁니다. 피격은
SweepMultiByObjectType(Pawn)— ObjectType 기준이라 Camera 채널 Ignore와 무관합니다.- 카메라가 당겨지면 SpringArm의 Camera 채널 프로브 + Pawn 프리셋의 Camera Block을 의심하고, 적 콜리전을 Camera=Ignore 처리합니다.
- 공격 몽타주는 패턴 행 데이터(AttackMontage)로, 일반(공유 GA)/특수(전용 GA) 2계층으로 나눕니다. DataTable의 부분 사용 필드는 정상입니다.
- Perception(Sight)은 NavMesh와 무관하고 콘은 단일 3D입니다. 수평 FOV는 커스텀 yaw 게이트로, NavMesh 이탈 해제는 "미인지 처리"로 기존 타이머를 재활용합니다.
- 데이터 기반 버그는 데이터 → 참조 BP/CDO를 따라 소거하고, LNK2019는 모듈 의존성 누락을 먼저 의심합니다.
Retrieve Project — 일반 몬스터(늑대) 구현: ABP·콜리전·공격 데이터화·AI 인지 (2026-06-01)