언리얼 카오스 디스트럭션(2) + 카오스 캐시 매니저(Chaos Cache Manager)

민트맛치킨·2025년 11월 6일

Unreal

목록 보기
12/26

이전 내용 블로그

https://velog.io/@hydisk77/%EC%96%B8%EB%A6%AC%EC%96%BC-%EC%B9%B4%EC%98%A4%EC%8A%A4-%EB%94%94%EC%8A%A4%ED%8A%B8%EB%9F%AD%EC%85%98

구현 목표 체크

  1. 메시 존재 : 파괴 전/후에도 해당 메시가 정상적으로 보여야 함.
  2. 콜리전 유지 : 파괴 전/후에도 콜리전 처리 정상 작동
  3. 물리 비활성화 : 일반적인 물리 충격으로는 파괴되지 않음
  4. 트리거 기반 파괴 : 외부 충격 대신 특정 조건(GAS 태그)에서만 파괴
  5. 멀티플레이 리플리케이션 : 리슨 서버 환경에서 모든 디스트럭션 결과가 다른 플레이어와 같아야 함
  6. 최적화 : 실시간 물리 계산 대신 캐시 재생
  7. 캐시 재사용 : 하나의 녹화본을 여러 액터에서 공유
  8. Transform 자유도 : 맵에 배치한 위치/회전/스케일을 변경하여도 녹화한 캐시가 정상 작동
  9. GAS : Gameplay Effect를 통한 태그 기반 트리거
  10. 확장성 : 같은 종류의 액터를 여러 개 배치 가능
  11. 하드코딩 피하기 : 블루프린트에서 설정 가능

기존 방식의 문제점

Geometry Collection의 한계

  • Geometry Collection은 컴포넌트로 제공하지만 블루프린트나 C++ 로직 작성이 불가능함
  • 디스트럭션이 일어날 때 매번 물리 계산을 하기 때문에 게임에 부하를 줄 수 있음
  • 멀티플레이에서 플레이어마다 다른 디스트럭션의 결과가 나타남

Chaos Cache Manager의 분리

  • Geometry Collection와 Chaos Cache Manager를 맵에 별도로 배치하고 블루프린트로 개별적으로 연결해야 함
  • 개별 Geometry Collection Transform 변경 시 캐시 재생 위치 불일치

트리거 로직 분산

  • 개별 블루프린트가 아니면 여러 개의 액터를 개별적으로 관리가 힘듦
  • GAS와 통합하기 어려움

시퀀서

  • 개별 액터 Transform 변경 시 녹화된 Transform과 불일치
  • 액터의 콜리전이 제대로 적용이 되지 않음

프레임 드랍 문제 및 최적화

실시간 피직스 계산 시

  • PIE가 실행되고 2초 후에 디스트럭션하도록 설정함

언리얼 인사이트 (실시간 피직스 계산 시)

  • 인사이트로 확인했을 때 위쪽 Main Graph는 보라색이 Game Frames, 분홍색이 Rendering Frames이다.
  • PIE가 실행되고 나서 2초후에 Game FramesRendering Frames의 ms가 80ms로 급격하게 오르는 것을 볼 수 있다
  • 그 밑에 현재 빨간색 박스로 체크 되어있는 GPU0-Compute0와 GameThread를 시간 순으로 확인할 수 있다

인사이트에서 주로 보는 트랙 정리

GameThread

  • 게임 로직 처리의 메인 스레드
  • Actor Tick, Component Update, 블루프린트 실행
  • Physics Simulation
  • Input 처리, AI 업데이트
  • 게임플레이 관련 모든 연산
  • 싱글 스레드기 때문에 여기서 병목 발생 시 전체 프레임레이트 하락

RHI Thread (Render Hardware Interface Thread)

  • GameThread와 GPU 사이의 중간 다리
  • 렌더링 명령어를 GPU가 이해할 수 있는 형태로 번역
  • DirectX, Vulkan, Metal 등 그래픽 API와 통신
  • GPU에게 실제 렌더링 명령 전달
  • GameThread가 만든 렌더링 명령을 비동기로 처리

RenderThread

  • 렌더링 준비 작업을 전담하는 스레드
  • GameThread가 만든 씬 데이터를 분석
  • 실제 GPU 렌더링 명령 생성
  • Culling, Sorting, Batching 등의 최적화 작업
  • GameThread와 병렬로 실행

GPU0-Compute0 (GPU 컴퓨트 스레드)

  • GPU의 병렬 연산 작업을 보여주는 트랙
  • Rendering이 아닌 계산(Compute) 작업
  • Lumen, Nanite, Particle 시뮬레이션, Post-Process 등
  • GPU에서 실행되는 Compute Shader 작업
  • UpdateAllPrimitiveSceneInfos 같은 대량 데이터 처리

다시 본론으로 돌아와서 GameThread를 자세히 보면 빨간색 박스 부분에 지연이 생기는 부분을 볼 수 있다

좀 더 가까이서 보면 GameThread에서 부모인 FEngineLoop::Tick이 있고 자식들이 전부 특정 작업에 대해서 기다리며 병목이 생기는 것을 볼 수 있다.

FEngineLoop::Tick을 관찰하고 Excl을 내림차순하면 WaitForTasks가 제일 높게 나오는데 Incl하고 Excl에 대해서 정리하자면

  • Incl (Inclusive) - 포괄 시간 : 해당 함수의 실행 시간 + 그 안에서 호출된 모든 하위 함수들의 시간까지 포함한 전체 시간
  • Excl (Exclusive) - 전용 시간 : 해당 함수 자체만의 실행 시간 (하위 함수 호출 시간 제외)

WaitForTasks가 GameThread에서 한 Tick에 72ms를 먹고 있다
다만 GameThread가 원인이 아니라 다른 곳에서 생기는 병목을 기다리는 것이기 때문에 다른 트랙을 살펴봐야 한다


GameThread가 아닌 트랙에서 살펴보다 보면 GPU0-Compute0라는 트랙에서
RenderGraphExecute (71.3 ms) UpdateAllPrimitiveSceneInfos라는 것이 오래 걸리는 것을 확인할 수 있다.

카오스 디스트럭션이 일어날 때 프랙처로 정의한 클러스터대로 개별 물리 오브젝트가 Scene에 등록이 된다.
그래서 새로운 Primitive가 추가되기 때문에 UpdateAllPrimitiveSceneInfos에서

1. FPrimitiveSceneInfo 생성
2. Bounding Box 계산
3. Culling 정보 등록
4. Shadow Casting 설정
5. LOD 정보 설정

을 각 조각마다 수행하기 때문에 오래 걸리게 된다.
그리고 이후에도 각 조각에 대해서 Physics를 계산하기 때문에 성능 부하를 준다.

그래서 다시 인사이트를 보면 20ms를 유지하다가 카오스 디스트럭션이 처음 일어날 때 80ms가 되고 이후 40ms를 유지하는 것을 볼 수 있다.

캐시 사용 시

  • PIE가 실행되고 2초 후에 디스트럭션하도록 설정함

언리얼 인사이트 (캐시 사용 시)

캐시를 사용하게 되면 녹화 당시 데이터가 저장되어 있기 때문에 Transform만 업데이트 하면 된다.

캐시 실행이 되고 나서 ms가 조금 올라갔지만 20ms정도로 캐시를 사용하면 성능 부하를 막을 수 있다.


해결 방법

ChaosCacheManager 코드

ChaosCacheManager를 상속받아 ASC와 Geometry Collection을 내장한 독립 액터 생성

// DestructibleCacheActor.h
#pragma once
#include "CoreMinimal.h"
#include "Chaos/CacheManagerActor.h"
#include "AbilitySystemInterface.h"
#include "GameplayTagContainer.h"
#include "DestructibleCacheActor.generated.h"

class UAbilitySystemComponent;
class UGeometryCollectionComponent;

UCLASS()
class PUZZLECHAOS_API ADestructibleCacheActor : public AChaosCacheManager, public IAbilitySystemInterface
{
    GENERATED_BODY()
    
public:
    ADestructibleCacheActor();

	// 지오메트리 컬렉션 컴포넌트
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Destruction")
    UGeometryCollectionComponent* GeoComp;

	// ASC
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Abilities")
    UAbilitySystemComponent* AbilitySystemComponent;

	// 부여받은 태그와 비교하는 TriggerTag
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Destruction")
    FGameplayTag TriggerTag;

    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;

    UFUNCTION(BlueprintCallable, Category="Destruction")
    void TriggerDestruction();

protected:
    virtual void BeginPlay() override;
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

private:
    UFUNCTION(Server, Reliable)
    void ServerTriggerDestruction();

    UPROPERTY(ReplicatedUsing=OnRep_IsDestroyed)
    bool bIsDestroyed;

    UFUNCTION()
    void OnRep_IsDestroyed();

    void OnTagChanged(const FGameplayTag Tag, int32 NewCount);
    void ExecuteDestruction();
};

// DestructibleCacheActor.cpp
#include "DestructibleCacheActor.h"
#include "GeometryCollection/GeometryCollectionComponent.h"
#include "AbilitySystemComponent.h"
#include "Net/UnrealNetwork.h"

ADestructibleCacheActor::ADestructibleCacheActor()
{
    SetReplicates(true);
    
    // 지오메트리 컬렉션 생성 (부서질 메시)
    GeoComp = CreateDefaultSubobject<UGeometryCollectionComponent>(TEXT("GeoComp"));
    RootComponent = GeoComp;
    GeoComp->SetSimulatePhysics(false); // Trigger 전까지 물리 비활성
    GeoComp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);

    // GAS 통합을 위한 ASC 생성
    AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
    AbilitySystemComponent->SetIsReplicated(true);
    AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Minimal);

    bIsDestroyed = false;
    TriggerTag = FGameplayTag::RequestGameplayTag(FName("Effect.Destruction.Triggered"));
}

void ADestructibleCacheActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(ADestructibleCacheActor, bIsDestroyed);
}

UAbilitySystemComponent* ADestructibleCacheActor::GetAbilitySystemComponent() const
{
    return AbilitySystemComponent;
}

void ADestructibleCacheActor::BeginPlay()
{
    Super::BeginPlay();

    // 서버에서만 초기화
    if (HasAuthority())
    {
        // 카오스 캐시 매니저가 GeoComp 관찰 시작
        FindOrAddObservedComponent(GeoComp, NAME_None, false);

        if (AbilitySystemComponent)
        {
            // ASC 초기화
            AbilitySystemComponent->InitAbilityActorInfo(this, this);

            // TriggerTag 변경 감지 등록
            if (TriggerTag.IsValid())
            {
                AbilitySystemComponent->RegisterGameplayTagEvent(
                    TriggerTag,
                    EGameplayTagEventType::NewOrRemoved
                ).AddUObject(this, &ADestructibleCacheActor::OnTagChanged);
            }
        }
    }
}

// TriggerTag가 추가/제거될 때 호출
void ADestructibleCacheActor::OnTagChanged(const FGameplayTag Tag, int32 NewCount)
{
    // 서버에서만 실행, 추가된 태그가 지정한 태그와 같고 아직 파괴되지 않았으면
    if (HasAuthority() && Tag.MatchesTagExact(TriggerTag) && NewCount > 0 && !bIsDestroyed)
    {
        bIsDestroyed = true;
        ExecuteDestruction();
    }
}

// 블루프린트나 외부에서 직접 호출 가능
void ADestructibleCacheActor::TriggerDestruction()
{
    if (bIsDestroyed)
    {
        return;
    }

    if (HasAuthority())
    {
        bIsDestroyed = true;
        ExecuteDestruction();
    }
    else
    {
        // 클라이언트면 서버에 요청
        ServerTriggerDestruction();
    }
}

void ADestructibleCacheActor::ServerTriggerDestruction_Implementation()
{
    TriggerDestruction();
}

// bIsDestroyed 리플리케이션 콜백
void ADestructibleCacheActor::OnRep_IsDestroyed()
{
    if (bIsDestroyed)
    {
        ExecuteDestruction();
    }
}

// 실제 파괴 실행 (캐시 재생)
void ADestructibleCacheActor::ExecuteDestruction()
{
    if (!GeoComp)
    {
        return;
    }
    // 카오스 캐시 매니저의 Trigger 모드 실행
    TriggerComponent(GeoComp);
}
  • 단일 액터 : 하나의 액터에 모든 기능 통합
  • Transform 독립성 : 각 인스턴스가 자신의 위치에서 캐시 재생
  • 블루프린트 설정 : 원하는 Cache Collection을 BP에서 지정 가능
  • 확장성 : 100개 배치해도 각각 독립적으로 동작
  • GAS 통합 : Gameplay Effect로 태그 기반 트리거

Build.cs 모듈 추가

PublicDependencyModuleNames.AddRange(new string[] 
{
    "GameplayTags",           	// Gameplay Tag 시스템
    "GameplayAbilities",      	// GAS
    "ChaosCaching",           	// Chaos Cache Manager
    "GeometryCollectionEngine" 	// Geometry Collection
});

블루프린트 설정

  • 지오메트리 컬렉션 컴포넌트에서 원하는 컬렉션 지정
  • 마스터 필드로 디스트럭션 준비 및 카오스 캐시 매니저로 녹화 진행
  • 녹화할 때는 캐시 모드를 녹화로, 시작 모드는 Timed로 지정
  • 녹화를 진행한 후 매니저 설정
  • Trigger Tag 설정 : Effect.Destruction.Triggered
  • 캐시 컬렉션 설정 : 원하는 캐시 컬렉션 설정, 플레이 모드, Triggered 모드
  • 관찰된 컴포넌트 : 캐시 컬렉션의 원하는 캐시 이름

Gameplay Effect

  • 경과시간 : Infinite (부여한 태그가 지속되도록)
  • 타깃 액터에 태그 부여 : Effect.Destruction.Triggered

언리얼 5.6 기본 슈터 캐릭터 + 투사체 블루프린트 설정

투사체 블루프린트

  • Owner인 BP_ShooterCharacter의 서버 RPC를 호출

슈터 캐릭터 블루프린트

  • 이벤트 서버에서 실행
  • ASC Valid 검사 후 ApplyGameplayEffectToTarget으로 GE_DestructionTrigger 이펙트 부여

네트워크 프로세스

서버가 총을 쏠 때

1. Server: Projectile Hit
2. Server: Character::ServerNotifyHit 직접 실행 (이미 서버)
3. Server: GE_DestructionTrigger 적용
4. Server: ASC에 Destruction.Trigger 태그 추가
5. Server: OnTagChanged 감지
6. Server: bIsDestroyed = true
7. Server: ExecuteDestruction() → TriggerComponent()
8. 리플리케이트 → All Clients
9. Clients: OnRep_IsDestroyed
10. Clients: ExecuteDestruction() → 캐시 재생

클라이언트가 총을 쏠 때

1. Client: Projectile Hit (로컬)
2. Client: Character::ServerNotifyHit (RPC 호출)
3. → Server로 전달
4. Server: ServerNotifyHit_Implementation 실행
5. Server: GE_DestructionTrigger 적용
6. Server: ASC에 Destruction.Trigger 태그 추가
7. Server: OnTagChanged 감지
8. Server: bIsDestroyed = true
9. Server: ExecuteDestruction() → TriggerComponent()
10. 리플리케이트 → All Clients
11. Clients: OnRep_IsDestroyed
12. Clients: ExecuteDestruction() → 캐시 재생

서버가 총을 쐈을 때

클라이언트가 총을 쐈을 때

결론

  • 실시간으로 물리 계산을 하는 것이 아닌 녹화한 캐시가 제대로 실행됨
  • 녹화 했을 당시 Transform과 다른 Transform이여도 캐시가 잘 재생됨
  • 각 파편들의 콜리전이 제대로 적용됨
  • 서버와 클라이언트 간 리플리케이트도 잘 적용됨
  • Effect로 처리했기 때문에 GAS를 사용하는 퍼즐 로직이나 캐릭터 스킬, 몬스터 스킬에도 쉽게 활용할 수 있음

0개의 댓글