좀비 라그돌 본 폴백과 히트스톱 충돌 응답
시간을 늦춰도 캡슐 충돌은 정상 속도로 평가된다
말단 본을 맞히면 라그돌이 안 흔들리고, 히트스톱을 걸어도 뒤 좀비가 밀고 들어왔다. Mixamo와 UE5의 본 이름 표준이 다르다는 문제와, 충돌 응답을 일시 무시하는 한 세트 패턴.
히트리액트와 히트스톱을 붙인 뒤 실제로 쏴 보니 세 가지가 남아 있었다. 말단 본을 맞히면 라그돌이 거의 안 흔들리고, 특정 본을 맞히면 좀비가 이상하게 꺾이고, 히트스톱을 걸어도 좀비가 뒤로 밀려 나갔다. 이 글에서는 그 후속 세 건을 이야기하려 한다 — 셋 다 원인이 다르고, 특히 마지막 건은 시간을 늦춰도 충돌 응답은 정상 속도로 평가된다는 엔진 동작을 몰라서 생긴 문제였다.
트러블슈팅 1 — 말단 본을 맞히면 라그돌이 안 흔들린다
Private/Combat/WeaponComponent.cpp::ApplyHitDamage
버그 증상 — 캐릭터가 눕는다
히트리액트 통합 직후 플레이 테스트에서 발견된 후속 버그. 좀비 머리·팔·다리를 쏘면 의도대로 본별 흔들림이 잘 잡히는데, 가끔 좀비가 통째로 옆으로 눕는 현상이 보였다. 발사 위치를 옮기면서 재현해보니 사격 라인이 좀비의 가슴·골반 부근에 떨어졌을 때가 트리거.
원인 — 루트·척추 본에 BlendWeight 걸린 결과
Hit.BoneName이 Hips(Mixamo 루트) 또는 Spine·Spine1·Spine2로 떨어지면, HitReactComponent::PlayHitReact가 SetAllBodiesBelowSimulatePhysics(BoneName, true) + SetAllBodiesBelowPhysicsBlendWeight(BoneName, ...)를 호출.
문제는 이 본들이 전체 스켈레톤의 루트 또는 루트 근방이라는 점. SetAllBodiesBelow는 인자 본을 포함한 그 아래 전체에 BlendWeight를 거니까, Hips에 걸면 전신, Spine에 걸면 상체 전체 + 양팔 + 머리가 시뮬레이션에 들어간다. PhysicsAsset의 중력·관성이 작용해 좀비가 균형을 잃고 그대로 쓰러짐.
앞서 작성한 _End 폴백(말단 본을 부모로 거슬러)은 본 효과가 너무 작은 쪽을 잡았지만, 반대쪽 — 본 효과가 너무 큰 루트/척추 쪽 폴백은 빠져 있었다는 게 핵심.
수정 — Unsafe set + 자식 본 6단계 탐색
include 추가(헤더 변경 없이 cpp만):
1
2
3
4
5
#include "GameFramework/Character.h"
#include "Components/SkeletalMeshComponent.h"
#include "Components/CapsuleComponent.h"
#include "Engine/SkinnedAsset.h"
#include "ReferenceSkeleton.h"
기존 _End 폴백 루프 뒤에 추가:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 루트성 본은 BlendWeight 시 캐릭터가 통째로 쓰러짐 → 자식 본으로 내려감
static const TSet<FName> UnsafeRootBones = {
// Mixamo 표준
TEXT("Hips"), TEXT("Spine"), TEXT("Spine1"), TEXT("Spine2"),
// UE5 표준
TEXT("pelvis"), TEXT("spine_01"), TEXT("spine_02"), TEXT("spine_03"),
TEXT("root")
};
if (UnsafeRootBones.Contains(ResolvedBone))
{
if (USkeletalMeshComponent* SkelMesh = HitActor->FindComponentByClass<USkeletalMeshComponent>())
{
if (const USkinnedAsset* Asset = SkelMesh->GetSkinnedAsset())
{
const FReferenceSkeleton& RefSkel = Asset->GetRefSkeleton();
FName Current = ResolvedBone;
// 6단계까지 자식 본 중 ImpactPoint 가장 가까운 본 탐색
for (int32 Depth = 0; Depth < 6; ++Depth)
{
FName ClosestChild = NAME_None;
float ClosestDistSq = TNumericLimits<float>::Max();
const int32 ParentIdx = RefSkel.FindBoneIndex(Current);
if (ParentIdx == INDEX_NONE) break;
// 직속 자식만 순회
const int32 NumBones = RefSkel.GetNum();
for (int32 BoneIdx = ParentIdx + 1; BoneIdx < NumBones; ++BoneIdx)
{
if (RefSkel.GetParentIndex(BoneIdx) != ParentIdx) continue;
const FName ChildName = RefSkel.GetBoneName(BoneIdx);
const FVector ChildLoc = SkelMesh->GetBoneLocation(ChildName);
const float DistSq = FVector::DistSquared(ChildLoc, Hit.ImpactPoint);
if (DistSq < ClosestDistSq)
{
ClosestDistSq = DistSq;
ClosestChild = ChildName;
}
}
if (ClosestChild.IsNone()) break;
Current = ClosestChild;
// 더 이상 unsafe가 아니면 정지
if (!UnsafeRootBones.Contains(Current)) break;
}
ResolvedBone = Current;
}
}
}
핵심 포인트 ——
FReferenceSkeleton로 자식 본 순회 —SkelMesh->GetBoneName/Location만으로는 부모-자식 관계를 모름.FReferenceSkeleton::GetParentIndex(BoneIdx)로 트리 구조를 알 수 있다. 직속 자식은 인덱스가 부모보다 큰 본 중GetParentIndex == ParentIdx인 것- ImpactPoint 가장 가까운 자식 선택 — 가슴 정중앙을 쏜 경우 Spine → Spine1 → Spine2 순으로 내려가고, 옆구리를 쏜 경우 Spine → LeftShoulder/RightShoulder로 분기. 사격 방향과 일치하는 본이 자연스럽게 잡힘
- 6단계 제한 — Mixamo 좀비 기준 Hips → Spine → Spine1 → Spine2 → LeftShoulder → LeftArm 정도까지가 의미 있는 깊이. 무한 가드 + 손가락 본까지 내려가는 과한 폴백 차단
- 이미 unsafe가 아니면 즉시 정지 — Spine → Spine1까지 갔는데 Spine1이 unsafe면 계속, 안전한 본이면 거기서 끝
효과 — 플레이 테스트에서 좀비가 눕는 현상이 더는 재현되지 않는 것을 확인. 가슴 사격은 Spine → Spine1 → Spine2 또는 LeftShoulder/RightShoulder로 안전하게 분기.
Mixamo 좀비 본 트리 정리
디버깅 중에 좀비1·좀비2 스켈레톤을 들여다보면서 정리한 본 트리:
1
2
3
4
5
6
7
Hips ← 루트
├── LeftUpLeg → LeftLeg → LeftFoot → LeftToeBase → LeftToe_End
├── RightUpLeg → RightLeg → RightFoot → RightToeBase → RightToe_End
└── Spine → Spine1 → Spine2
├── LeftShoulder → LeftArm → LeftForeArm → LeftHand → (손가락)
├── RightShoulder → RightArm → RightForeArm → RightHand → (손가락)
└── Neck → Head → HeadTop_End (좀비2: + LeftEye, RightEye)
좀비1·좀비2 모두 동일한 본 구성. 좀비2만 LeftEye/RightEye가 추가됐고, RightHandMiddle 본은 누락. Mixamo 표준 본 이름은 UE5 표준(pelvis·spine_01·upperarm_l 등)과 완전히 다르다 — 이게 ExcludedBones와 Unsafe set을 양쪽 표준으로 다 잡아야 하는 이유.
트러블슈팅 2 — 본 이름 표준이 둘이라 안전장치가 새어 나간다
Private/Combat/HitReactComponent.cpp 생성자
기존 설계의 함정 — Mixamo엔 pelvis가 없다
통합 시점의 ExcludedBones는 이렇게 잡혀 있었다:
1
2
// 기존
ExcludedBones = { "pelvis" };
설계 의도는 명확 — pelvis(골반)는 캐릭터 무게중심이라 BlendWeight 걸면 전신 흔들림이라 제외하자. 그런데 실제 좀비 스켈레톤은 Mixamo여서 pelvis라는 이름의 본이 존재하지 않는다. 즉 ExcludedBones가 들어 있긴 했지만 매칭되는 본이 0개라서 무력화 상태였다.
이게 1번 항목의 좀비 라그돌 버그가 발생한 두 번째 경로 — Hips/Spine을 막을 안전장치가 사실상 없었다.
수정 — Mixamo + UE5 9개 본 양쪽 등록
1
2
3
4
5
6
7
8
// 변경
ExcludedBones = {
// Mixamo 표준
TEXT("Hips"), TEXT("Spine"), TEXT("Spine1"), TEXT("Spine2"),
// UE5 표준
TEXT("pelvis"), TEXT("spine_01"), TEXT("spine_02"), TEXT("spine_03"),
TEXT("root")
};
설계 원칙 ——
- 양쪽 표준 모두 등록 — 좀비는 Mixamo, 플레이어는 UE5 표준일 가능성이 있어 한 컴포넌트로 양쪽을 다 잡아야 함. 같은 본이 두 이름 동시 존재하는 일은 없으니 9개 모두 등록해도 안전
- 1번의 Unsafe set과 일치 — 같은 9개 본을 ExcludedBones와 UnsafeRootBones에 동시 등록. 두 가지 동작이 가능 ——
- ExcludedBones 매치 →
PlayHitReact가 진입 즉시 SKIP (안전) - Unsafe set 매치 → 자식 본 6단계 폴백으로 안전한 본 찾기 (보정)
- ExcludedBones 매치 →
- 마지막 방어선이 ExcludedBones — 폴백이 실패해 끝까지 unsafe면 ExcludedBones가 그 본을 통째로 거른다. 좀비가 절대 통째로 눕지 않게 함
ExcludedBones와 Unsafe set은 역할이 다르다. ExcludedBones는 “이 본은 HitReact 안 함”(흔들림 0), Unsafe set은 “이 본은 자식으로 내려가서 HitReact”(흔들림 살리되 안전한 자식 본으로). 둘이 같은 9개 본에 걸려 있으면 폴백이 먼저 시도되고, 끝까지 안전한 본을 못 찾았을 때만 ExcludedBones가 SKIP시키는 흐름.
트러블슈팅 3 — 히트스톱 중에도 좀비가 뒤로 밀린다
Private/Combat/WeaponComponent.cpp::ApplyHitStop
증상 — 뒤의 좀비가 멈춘 좀비를 밀어낸다
히트스톱 자체는 앞선 통합에서 붙였고, 단일 좀비를 쏘면 0.1배 슬로우가 적용되는 게 확인됨. 그런데 여러 좀비가 무리지어 다가오는 상황에서 앞쪽 좀비를 쏘면, 뒤의 좀비들이 Pawn 채널 캡슐 충돌로 멈춘 좀비를 밀어내면서 슬로우 모션이 시각적으로 흐트러지는 현상.
즉 CustomTimeDilation = 0.1로 시간 자체는 슬로우인데, 물리적인 캡슐 충돌은 정상 속도로 계속 일어나는 비대칭이 문제.
원인 — CustomTimeDilation은 캡슐 충돌 응답을 막지 않는다
CustomTimeDilation은 액터의 Tick·이동·애니메이션 시간을 스케일하는 것이지, 콜리전 응답 채널 자체를 비활성화하지 않는다. 즉 멈춘 좀비의 캡슐은 여전히 ECC_Pawn에 Block 응답이라, 뒤의 좀비가 다가와 캡슐이 맞닿으면 물리 엔진은 그대로 Pawn-Pawn 푸시를 계산해 멈춘 좀비를 옆으로 밀어낸다.
이건 CustomTimeDilation 자체의 한계 — 시간 축은 슬로우지만 공간 축의 충돌 응답은 매 프레임 정상으로 평가되는 것.
수정 — 캡슐 ECC_Pawn 응답을 Ignore로 일시 변경 + 복귀 시 원복
ApplyHitStop에서 CustomTimeDilation 설정 직후, 캡슐의 ECC_Pawn 응답을 Ignore로 일시 변경:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void UWeaponComponent::ApplyHitStop(AActor* HitActor, AActor* Instigator)
{
if (!HitActor) return;
// 1) 시간 슬로우 (기존)
HitActor->CustomTimeDilation = HitStopTimeScale;
if (APawn* HitPawn = Cast<APawn>(HitActor))
{
if (AController* HitController = HitPawn->GetController())
{
HitController->CustomTimeDilation = HitStopTimeScale;
}
}
if (Instigator) Instigator->CustomTimeDilation = HitStopTimeScale;
// 2) 캡슐 ECC_Pawn 응답을 일시 Ignore (신규)
TWeakObjectPtr<UCapsuleComponent> WeakCapsule;
ECollisionResponse RestoreResp = ECR_Block;
if (ACharacter* HitChar = Cast<ACharacter>(HitActor))
{
if (UCapsuleComponent* Capsule = HitChar->GetCapsuleComponent())
{
WeakCapsule = Capsule;
RestoreResp = Capsule->GetCollisionResponseToChannel(ECC_Pawn);
Capsule->SetCollisionResponseToChannel(ECC_Pawn, ECR_Ignore);
}
}
// 3) Duration 후 복귀 — 시간 + 캡슐 응답 동시 원복
FTimerHandle Handle;
TWeakObjectPtr<AActor> WeakHit = HitActor;
TWeakObjectPtr<AActor> WeakInst = Instigator;
GetWorld()->GetTimerManager().SetTimer(Handle,
[WeakHit, WeakInst, WeakCapsule, RestoreResp]()
{
// 시간 복귀
if (WeakHit.IsValid())
{
WeakHit->CustomTimeDilation = 1.f;
if (APawn* P = Cast<APawn>(WeakHit.Get()))
{
if (AController* C = P->GetController()) C->CustomTimeDilation = 1.f;
}
}
if (WeakInst.IsValid()) WeakInst->CustomTimeDilation = 1.f;
// 캡슐 응답 복귀 — 원래 응답으로 정확히 복원 (Block이었으면 Block, Overlap이었으면 Overlap)
if (WeakCapsule.IsValid())
{
WeakCapsule->SetCollisionResponseToChannel(ECC_Pawn, RestoreResp);
}
}, HitStopDuration, false);
}
핵심 설계 ——
- 원래 응답을 캡처 + 복원 —
RestoreResp로 캡슐의 원래ECC_Pawn응답(보통 Block, 경우에 따라 Overlap)을 미리 저장해두고, 복귀 람다에서 정확히 그 값으로 되돌린다. 무조건 Block으로 원복하면 원래 Overlap이었던 좀비의 설정이 깨짐 - WeakObjectPtr 캡처 — 람다 실행 시점(0.15초 뒤)에 액터가 이미 파괴됐을 수 있음.
TWeakObjectPtr로 캡처하면IsValid()체크로 안전. 일반 raw 포인터 캡처는 dangling 위험 - 시간과 공간 동시 원복 —
CustomTimeDilation복귀와 캡슐 응답 복귀가 같은 람다 안에서 일어나야 함. 둘 중 하나만 늦으면 또 비대칭
효과 — 멈춘 좀비를 뒤에서 다른 좀비가 밀어내는 push 차단. 슬로우 모션 동안 멈춘 좀비는 그 자리에 멈춰 있고 뒤의 좀비도 캡슐을 통과하지 않고 자기 자리에서 멈춤(슬로우 적용 받음).
남은 문제 — 히트스톱 체감이 여전히 약하다
직접 플레이해 본 체감 — 히트스톱이 저번에도 그렇고 제대로 적용 안 된 것 같다.
HitStopTimeScale = 0.1, HitStopDuration = 0.15로 헤더 디폴트 값은 정상 수치. 코드 진단 필요.
의심 1 — BP 인스턴스 오버라이드
PlayerCharacter BP 또는 Weapon 관련 BP에서 디폴트 값을 덮어썼을 가능성. BP 디테일 패널 → Weapon | Hit Stop 카테고리에서 실제 인스턴스 값을 확인해야 함.
확인 방법 — BP를 열어 디테일 패널의 Weapon | Hit Stop 카테고리에서 HitStopTimeScale·HitStopDuration 값을 본다. 노란색 화살표(reset to default)가 보이면 오버라이드된 상태.
의심 2 — FireInterval 충돌 (가장 의심)
현재 ApplyHitStop은 매 발사마다 지역 FTimerHandle 로 SetTimer를 호출. 연사 시 ——
1
2
3
4
시간 t=0.00s — 좀비A 피격 → CustomTimeDilation 0.1 + 타이머 SetTimer(0.15s 뒤 1.0 복귀)
시간 t=0.10s — 좀비A 다시 피격 → CustomTimeDilation 0.1 + 새 타이머 SetTimer
시간 t=0.15s — 첫 번째 타이머 만료 → CustomTimeDilation 1.0 복귀 (← 여기서 슬로우 풀림)
시간 t=0.25s — 두 번째 타이머 만료 → CustomTimeDilation 1.0 (이미 1.0이라 효과 없음)
즉 첫 번째 복귀 람다가 새로 정지된 상태를 1.0으로 덮어버리는 레이스. 결과적으로 두 번째 슬로우가 0.05초만 지속되고 끝.
패치 — 액터별 타이머 맵으로 추적
1
2
3
4
// WeaponComponent.h — 멤버 추가
private:
// ApplyHitStop 추적 — 같은 액터 연속 피격 시 이전 복귀 타이머 취소
TMap<TWeakObjectPtr<AActor>, FTimerHandle> HitStopTimers;
1
2
3
4
5
6
7
8
9
10
11
12
13
// ApplyHitStop 진입부에 추가
if (FTimerHandle* Existing = HitStopTimers.Find(HitActor))
{
World->GetTimerManager().ClearTimer(*Existing);
}
// SetTimer 호출을 지역 변수 → 멤버 맵으로
FTimerHandle NewTimer;
World->GetTimerManager().SetTimer(NewTimer, /* lambda */, HitStopDuration, false);
HitStopTimers.Add(HitActor, NewTimer);
// 복귀 람다 마지막에 맵에서 제거
HitStopTimers.Remove(WeakHit);
이렇게 두면 같은 액터에 연속 피격이 들어와도 이전 복귀 타이머가 즉시 취소되어 새 슬로우 기간이 온전히 0.15초 보장. 가장 유력한 원인이라 다음 작업에서 가장 먼저 패치할 항목으로 남겼다.
의심 3 — Anim BP가 World Delta 직접 참조
AnimGraph의 Event Update Animation 노드는 인자로 Delta Time을 받는데, 이 값이 자동으로 CustomTimeDilation 스케일을 반영. 그런데 노드 안에서 명시적으로 Get World Delta Seconds 노드를 호출하면 이건 액터 무관 글로벌 delta라 CustomTimeDilation 무시.
확인 — 좀비 AnimBP를 열어 Event Update Animation 본체에 Get World Delta Seconds 노드가 있는지 검색. 있으면 Event Update Animation의 입력 Delta Time으로 교체.
진단 로그 (선택)
ApplyHitStop 진입부에 한 줄:
1
2
UE_LOG(LogTemp, Warning, TEXT("[HitStop] %s scale=%.2f dur=%.2fs"),
*HitActor->GetName(), HitStopTimeScale, HitStopDuration);
PIE(Play In Editor — 에디터에서 바로 실행해 보는 플레이 모드)에서 사격 후 로그가 찍히는지 확인 — 안 찍히면 ApplyHitStop 자체가 호출 안 됨(다른 경로 문제), 찍히는데 스케일이 다르면 BP 오버라이드(의심 1), 찍히는데 효과 약하면 의심 2 또는 3.
핵심 요약 —
CustomTimeDilation은 액터의 시간만 느리게 할 뿐 캡슐 충돌 응답은 정상 속도로 평가된다 — 그래서 히트스톱에는 충돌 응답 일시 Ignore와 원래 응답 복원이 한 세트로 가야 한다. 그리고 Mixamo와 UE5는 본 이름 표준이 완전히 달라서, ExcludedBones 같은 안전장치는 양쪽 표준을 모두 등록해야 실제로 동작한다.