포스트

UE 커스텀 DamageType과 동기·비동기 Trace

StaticClass()로 넘기고 CDO에서 값을 읽는 이유

'맞았다'는 이벤트 하나로는 화상·관통·폭발을 구분할 수 없다. UDamageType을 식별자로 써서 TakeDamage를 분기하고, 적 여럿이 매 틱 시야 판정을 돌릴 때 프레임이 튀는 이유까지.

UE 커스텀 DamageType과 동기·비동기 Trace

“맞았다”는 이벤트 하나로는 화상과 관통과 폭발을 구분할 수 없다. 언리얼은 그 구분을 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 액터에서 두 방식을 모두 실습.

동기 TraceUKismetSystemLibrary::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
    );
}

비동기 TraceUWorld::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는 “지금 이 프레임에 결과가 필요한가”로 갈린다 — 즉시 반응이 필요한 발사는 동기, 적 여럿이 매 틱 도는 시야 판정은 비동기여야 물리 쿼리가 메인 스레드에 직렬로 쌓이지 않는다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.