UE 커스텀 DamageType과 동기·비동기 Trace
StaticClass()로 넘기고 CDO에서 값을 읽는 이유
'맞았다'는 이벤트 하나로는 화상·관통·폭발을 구분할 수 없다. UDamageType을 식별자로 써서 TakeDamage를 분기하고, 적 여럿이 매 틱 시야 판정을 돌릴 때 프레임이 튀는 이유까지.
“맞았다”는 이벤트 하나로는 화상과 관통과 폭발을 구분할 수 없다. 언리얼은 그 구분을 DamageType 클래스로 처리하는데, 인스턴스를 만들지 않고 StaticClass()만 넘기는 방식이라 처음 보면 낯설다. 이 글에서는 데미지 타입을 커스터마이징해 피격 처리를 분기하는 구현과, 그 데미지를 발생시키는 Trace를 동기와 비동기 두 방식으로 나눠 쓰는 기준을 이야기하려 한다. 적 열 마리가 매 틱 시야 판정을 돌릴 때 프레임이 튀는 이유가 뒤쪽의 핵심이다.
DamageType · Trace 시스템
다루는 주제는 데미지 타입 커스터마이징과 Trace의 동기/비동기 차이 둘이다. 둘 다 이후 과제인 샷건 반동과 비동기 AI 탐지의 기반이 되는 기술이다.
TakeDamage 오버라이드 + 커스텀 DamageType
AActor에는 기본 TakeDamage 가상함수가 있고, 데미지 원인 타입을 구별하려면 UDamageType을 상속한 클래스를 만들어 구분자로 쓴다.
UFireDamageType — 화상 속성을 추가 데이터로 보유:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
UCLASS()
class NBC_MASTER_API UFireDamageType : public UDamageType
{
GENERATED_BODY()
public:
UFireDamageType();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
float BurnDuration; // 화상 지속시간
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
float ArmorPenetration; // 방어구 관통률
};
UFireDamageType::UFireDamageType()
{
BurnDuration = 5.f;
ArmorPenetration = 0.2f;
}
UMyTestDamageType — 물리 임펄스 기반 데미지:
1
2
3
4
5
6
UMyTestDamageType::UMyTestDamageType()
{
DamageImpulse = 5000.f; // 히트 시 물리 임펄스 크기
bScaleMomentumByMass = true; // 질량에 따라 임펄스 스케일
bCausedByWorld = true; // 월드 원인(낙하·환경) 데미지 표시
}
ANBC_MasterCharacter::TakeDamage 오버라이드 — DamageEvent.DamageTypeClass로 타입 분기:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
float ANBC_MasterCharacter::TakeDamage(
float DamageAmount, FDamageEvent const& DamageEvent,
AController* EventInstigator, AActor* DamageCauser)
{
float ActualDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
// DamageTypeClass는 UClass* — GetDefaultObject로 CDO 꺼냄
const UFireDamageType* FireDamage =
DamageEvent.DamageTypeClass->GetDefaultObject<UFireDamageType>();
if (FireDamage)
{
ActualDamage *= (1.f + FireDamage->ArmorPenetration);
// TODO: 화상 이펙트, 사운드 재생
UE_LOG(LogTemp, Warning, TEXT("ByWorld Damage Received"));
}
if (EventInstigator)
{
UE_LOG(LogTemp, Warning, TEXT("Im Enemy!"));
}
return ActualDamage;
}
핵심 포인트:
DamageTypeClass->GetDefaultObject<T>()— CDO(Class Default Object, 클래스의 기본값 원본 객체)를 캐스팅해 타입의 기본값 파라미터에 접근 (1강에서 배운 CDO 패턴 활용)GetDefaultObject<UFireDamageType>()결과가 nullptr이 아니면 “이 데미지는 화상 계열”로 판정- 여러 DamageType을 chain해 조건별 효과 분기 가능 (화상·관통·폭발 등)
동기 Trace vs 비동기 Trace
ATraceTest 액터에서 두 방식을 모두 실습.
동기 Trace — UKismetSystemLibrary::LineTraceMulti (호출 프레임 내 결과 반환):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void ATraceTest::StartSingleTarce()
{
TArray<FHitResult> HitResult;
TArray<AActor*> ActorsToIgnore;
ActorsToIgnore.Add(this);
UKismetSystemLibrary::LineTraceMulti(
GetWorld(),
GetActorLocation(),
GetActorForwardVector() * 1000.f + GetActorLocation(),
UEngineTypes::ConvertToTraceType(ECC_Visibility),
false, // bTraceComplex
ActorsToIgnore,
EDrawDebugTrace::ForOneFrame,
HitResult,
true,
FLinearColor::Red,
FLinearColor::Green
);
}
비동기 Trace — UWorld::AsyncLineTraceByChannel (콜백으로 나중에 결과 수신):
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
void ATraceTest::StartAsyncTrace()
{
// 1. 델리게이트에 콜백 바인딩
FTraceDelegate TraceDelegate;
TraceDelegate.BindUObject(this, &ATraceTest::OnAsyncTraceCompleted);
// 2. 응답 파라미터 — 어떤 채널에 Block할지
FCollisionResponseParams ResponseParams;
ResponseParams.CollisionResponse.WorldDynamic = ECR_Block;
// 3. 쿼리 파라미터 — 자기 자신 무시, 복잡 충돌 체크 끔
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
QueryParams.bTraceComplex = false;
// 4. 비동기 요청 제출 (반환 즉시, 결과는 다음 프레임 이후 콜백)
GetWorld()->AsyncLineTraceByChannel(
EAsyncTraceType::Multi,
GetActorLocation(),
GetActorForwardVector() * 1000.f + GetActorLocation(),
ECC_Visibility,
QueryParams,
ResponseParams,
&TraceDelegate
);
}
void ATraceTest::OnAsyncTraceCompleted(const FTraceHandle& Handle, FTraceDatum& Data)
{
for (const FHitResult& Hit : Data.OutHits)
{
AActor* HitActor = Hit.GetActor();
GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Green,
FString::Printf(TEXT("Multi Hit Actor : %s"), *HitActor->GetName()));
DrawDebugSphere(GetWorld(), Hit.ImpactPoint, 20.f, 12, FColor::Green, false, 2.f);
UGameplayStatics::ApplyPointDamage(
HitActor, 50.f,
GetActorForwardVector(),
Hit,
GetInstigatorController(),
this,
UMyTestDamageType::StaticClass()
);
}
}
동기 vs 비동기 비교:
| 항목 | 동기 Trace (LineTraceMulti) | 비동기 Trace (AsyncLineTraceByChannel) |
|---|---|---|
| 결과 시점 | 호출 즉시 반환 | 다음 프레임(이상) 뒤 콜백 |
| 호출 스레드 | 메인 스레드 블로킹 | 백그라운드 처리 |
| 적합한 상황 | 1회성 판정, 총알 히트스캔 | 지속 스캔, AI 시야 판정, 많은 수 동시 |
| 결과 전달 | 반환값 TArray<FHitResult> | FTraceDelegate 콜백 → FTraceDatum.OutHits |
| 프레임 부하 | Trace 수 × 복잡도 | 분산됨, 프레임 시간 보장 |
| 주의사항 | 매 프레임 호출 시 부하 누적 | 결과가 “과거” 상태일 수 있음 (1~2 프레임 지연) |
왜 AI에는 비동기 Trace가 적합한가: 적이 10마리 있고 각자 플레이어를 찾는 Trace를 매 틱 돌리면, 동기 방식은 메인 스레드에서 10번의 물리 쿼리가 직렬 실행돼 프레임이 튄다. 비동기는 병렬 분산되고 결과를 콜백으로만 처리하므로 프레임 시간이 안정적이다. 대신 “지금 바로 반응”이 아니라 “1~2 프레임 뒤 반응”이라는 걸 AI 로직이 감안해야 함.
ApplyPointDamage 체인
비동기 Trace가 히트한 액터에 데미지를 전달하는 흐름:
1
2
3
4
5
6
7
8
9
AsyncLineTrace (결과 수신)
↓ FHitResult
ApplyPointDamage(HitActor, 50.f, ..., UMyTestDamageType::StaticClass())
↓
HitActor::TakeDamage(DamageEvent)
↓
DamageEvent.DamageTypeClass->GetDefaultObject<UMyTestDamageType>()
↓ 타입 확인 후 분기
화상 처리 / 폭발 처리 / 일반 처리
왜 DamageType을 StaticClass()로 넘기나:
- 데미지 타입은 타입 식별자 + 기본 파라미터 묶음 역할
- 인스턴스를 만들 필요가 없으므로 CDO(StaticClass가 반환하는 UClass*)만 전달
- 받는 쪽은
GetDefaultObject<T>()로 CDO의 기본값을 읽음 → 1강 CDO 패턴의 실전 활용
핵심 요약 — 데미지 타입은 인스턴스가 아니라 타입 식별자 + 기본 파라미터 묶음이라,
StaticClass()로 넘기고 받는 쪽에서GetDefaultObject<T>()로 CDO의 기본값을 읽는다. Trace는 “지금 이 프레임에 결과가 필요한가”로 갈린다 — 즉시 반응이 필요한 발사는 동기, 적 여럿이 매 틱 도는 시야 판정은 비동기여야 물리 쿼리가 메인 스레드에 직렬로 쌓이지 않는다.