안동민 개발노트 아이콘

안동민 개발노트

4장 : 월드와 씬 구성

액터 생성과 관리

이전 절에서 레벨 블루프린트와 C++ 스크립트의 역할과 사용처를 비교하며, 언리얼 엔진에서 게임 로직을 구현하는 다양한 방법을 이해했습니다. 어떤 방식으로 로직을 구현하든, 그 로직이 동작하는 대상은 결국 액터(Actor)입니다. 이번 절에서는 게임 월드 내에서 액터를 생성하고, 찾고, 참조하고, 파괴하는 등 액터를 효과적으로 관리하는 방법을 C++ 관점에서 심층적으로 다룰 것입니다. 월드의 규모가 커지고 액터의 수가 많아질수록 효율적인 액터 관리는 게임의 성능과 안정성에 직접적인 영향을 미칩니다.


액터 생성하기 (Spawning Actors)

게임 월드에 액터를 추가하는 방법은 크게 두 가지입니다.

에디터에서 배치 (Manual Placement)

가장 기본적인 방법으로, 언리얼 에디터의 콘텐츠 브라우저에서 원하는 액터 블루프린트(또는 C++ 액터 클래스를 기반으로 만든 블루프린트)를 뷰포트(Viewport)로 드래그 앤 드롭하는 것입니다. 이 경우 액터는 레벨에 영구적으로 배치되며, 레벨이 로드될 때 함께 생성됩니다.

런타임에 동적 생성 (Dynamic Spawning)

게임 플레이 중 특정 이벤트(예: 플레이어가 총을 발사하면 총알이 생성, 적이 죽으면 아이템 생성, 스폰 지점에서 플레이어 캐릭터 생성)에 따라 액터를 동적으로 생성해야 할 때 사용합니다. C++에서는 주로 UWorld::SpawnActor() 함수를 사용합니다.

MyGameModeBase.cpp 또는 AMyCharacter.cpp
#include "Engine/World.h"
#include "Kismet/GameplayStatics.h" // UGameplayStatics::GetPlayerCharacter 등을 위해

// 생성할 액터 클래스 (블루프린트 또는 C++ 클래스)를 UPROPERTY로 노출
// 블루프린트에서 설정할 수 있도록 EditDefaultsOnly 또는 EditAnywhere 사용
UPROPERTY(EditDefaultsOnly, Category = "Spawning")
TSubclassOf<AActor> ActorToSpawnClass; // AActor를 상속받는 모든 클래스 타입 지정

void AMyGameModeBase::SpawnMyActor()
{
    // 월드 포인터는 GetWorld() 함수로 얻을 수 있습니다.
    UWorld* World = GetWorld();
    if (!World)
    {
        UE_LOG(LogTemp, Error, TEXT("World is null. Cannot spawn actor."));
        return;
    }

    // 액터를 생성할 위치와 회전 정보를 정의
    FVector SpawnLocation = FVector(0.0f, 0.0f, 100.0f); // 월드 원점에서 Z축으로 100cm 위
    FRotator SpawnRotation = FRotator::ZeroRotator;     // 회전 없음

    // 액터 생성 파라미터 구조체 설정
    FActorSpawnParameters SpawnParams;
    SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
    // 액터 생성 시 콜리전 처리 방식 (예: 충돌 발생 시 위치 조정 후 생성, 또는 항상 생성 등)
    SpawnParams.Owner = this; // 생성하는 액터의 소유자 설정 (가비지 컬렉션 및 네트워크 관련)
    SpawnParams.Instigator = GetInstigator(); // 액터의 인스티게이터 설정 (예: 데미지를 준 주체)

    // 액터 생성!
    AActor* SpawnedActor = World->SpawnActor<AActor>(ActorToSpawnClass, SpawnLocation, SpawnRotation, SpawnParams);

    if (SpawnedActor)
    {
        UE_LOG(LogTemp, Warning, TEXT("Actor %s spawned successfully at %s"), *SpawnedActor->GetName(), *SpawnLocation.ToString());
    }
}
  • TSubclassOf<AActor>: 이 템플릿 타입은 언리얼 클래스(UClass*)에 대한 참조를 저장하며, 해당 클래스가 특정 부모 클래스(여기서는 AActor)를 상속받아야 함을 강제합니다. 에디터에서 UPROPERTY로 노출하면 드롭다운 목록에서 원하는 블루프린트 클래스를 쉽게 선택할 수 있습니다.
  • FActorSpawnParameters: 액터 생성 시의 다양한 추가 옵션을 설정할 수 있는 구조체입니다. 특히 SpawnCollisionHandlingOverride는 매우 중요하며, 액터가 생성될 위치에 다른 오브젝트가 있을 때 어떻게 처리할지를 결정합니다.
  • World->SpawnActor<T>(): 실제로 액터를 생성하는 함수입니다. T는 생성할 액터의 C++ 타입입니다. (예: <AMyBullet> 또는 <AActor>).

액터 찾기 및 참조하기

월드에 이미 존재하는 액터를 찾거나 다른 액터로부터 참조를 얻는 방법은 여러 가지가 있습니다.

태그(Tags)를 이용한 찾기

액터에 고유한 태그(Tag)를 부여하여 월드 내에서 해당 태그를 가진 액터들을 찾을 수 있습니다.

AMyGameModeBase.cpp
// AMyGameModeBase.cpp
#include "Kismet/GameplayStatics.h"

void AMyGameModeBase::FindActorsByTag()
{
    UWorld* World = GetWorld();
    if (!World) return;

    // 특정 태그를 가진 모든 액터 찾기
    TArray<AActor*> FoundActors;
    UGameplayStatics::GetAllActorsWithTag(World, TEXT("TargetEnemy"), FoundActors);

    for (AActor* Actor : FoundActors)
    {
        UE_LOG(LogTemp, Warning, TEXT("Found actor with tag 'TargetEnemy': %s"), *Actor->GetName());
        // 찾은 액터에 대한 추가 로직 수행
    }

    // 특정 태그를 가진 액터 중 가장 첫 번째 액터 찾기
    AActor* FirstFoundActor = UGameplayStatics::GetActorWithTag(World, TEXT("PlayerStart"));
    if (FirstFoundActor)
    {
        UE_LOG(LogTemp, Warning, TEXT("First PlayerStart actor: %s"), *FirstFoundActor->GetName());
    }
}
  • AActor::Tags: 액터 클래스의 Tags 배열에 태그를 추가할 수 있습니다. 에디터의 디테일 패널에서도 설정 가능합니다.
  • UGameplayStatics::GetAllActorsWithTag(): 주어진 태그를 가진 모든 액터를 TArray에 담아 반환합니다.
  • UGameplayStatics::GetActorWithTag(): 주어진 태그를 가진 첫 번째 액터를 반환합니다.

클래스(Class)를 이용한 찾기

특정 클래스 타입의 모든 액터를 찾을 때 사용합니다.

AMyGameModeBase.cpp
#include "Kismet/GameplayStatics.h"
#include "MyTargetActor.h" // 찾고자 하는 액터 클래스의 헤더 포함

void AMyGameModeBase::FindActorsByClass()
{
    UWorld* World = GetWorld();
    if (!World) return;

    // 특정 클래스 타입의 모든 액터 찾기
    TArray<AActor*> FoundActors;
    UGameplayStatics::GetAllActorsOfClass(World, AMyTargetActor::StaticClass(), FoundActors);

    for (AActor* Actor : FoundActors)
    {
        AMyTargetActor* TargetActor = Cast<AMyTargetActor>(Actor);
        if (TargetActor)
        {
            UE_LOG(LogTemp, Warning, TEXT("Found MyTargetActor: %s"), *TargetActor->GetName());
        }
    }

    // 특정 클래스 타입의 첫 번째 액터 찾기
    AMyTargetActor* FirstTargetActor = Cast<AMyTargetActor>(UGameplayStatics::GetActorOfClass(World, AMyTargetActor::StaticClass()));
    if (FirstTargetActor)
    {
        UE_LOG(LogTemp, Warning, TEXT("First MyTargetActor: %s"), *FirstTargetActor->GetName());
    }
}
  • AMyTargetActor::StaticClass(): UClass* 타입으로 해당 C++ 클래스에 대한 메타데이터를 반환합니다.
  • UGameplayStatics::GetAllActorsOfClass(): 주어진 클래스 타입의 모든 액터를 찾습니다.
  • UGameplayStatics::GetActorOfClass(): 주어진 클래스 타입의 첫 번째 액터를 찾습니다.

직접 참조 (UPROPERTY)

가장 권장되는 방법 중 하나는 직접적인 참조를 UPROPERTY로 저장하는 것입니다. 이는 디자이너가 에디터에서 수동으로 액터를 할당하거나, 스폰 시 참조를 직접 넘겨주는 방식으로 사용됩니다.

AMyLever.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyLever.generated.h"

class AMyDoor; // MyDoor 액터 클래스 미리 선언

UCLASS()
class MYPROJECT_API AMyLever : public AActor
{
    GENERATED_BODY()

public:
    AMyLever();

protected:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction")
    AMyDoor* ConnectedDoor; // 이 레버가 제어할 문 액터에 대한 직접 참조

    // 레버가 당겨졌을 때 호출될 함수 (예시)
    void OnLeverPulled();
};

이 방법은 런타임에 액터를 검색할 필요 없이 이미 연결된 참조를 사용하므로 성능상 이점이 있습니다.


액터 수명 주기 관리와 파괴

액터 생성과 관리에서 리플렉션 경계, 객체 수명, 엔진 호출을 정리한 것입니다.

액터는 생성되면 월드 내에서 수명 주기를 가집니다. 특정 시점에 액터를 월드에서 제거해야 할 때가 오는데, 이를 액터 파괴(Destruction)라고 합니다.

액터의 수명 주기 함수 (Recall)

액터는 다음과 같은 주요 수명 주기 함수들을 가집니다. 이 함수들을 오버라이드하여 액터의 특정 단계에서 원하는 로직을 구현합니다.

  • AActor::PostInitializeComponents(): 액터의 모든 컴포넌트가 초기화된 후 호출됩니다.
  • AActor::BeginPlay(): 액터가 월드에 스폰되거나 레벨이 시작될 때 단 한 번 호출됩니다. 게임플레이 로직의 주요 초기화 지점입니다.
  • AActor::Tick(float DeltaTime): 게임 루프의 매 프레임마다 호출됩니다. 시간의 흐름에 따라 지속적으로 업데이트되어야 하는 로직(움직임, 애니메이션 업데이트 등)에 사용됩니다. PrimaryActorTick.bCanEverTickfalse로 설정하여 비활성화할 수 있습니다.
  • AActor::EndPlay(const EEndPlayReason::Type EndPlayReason): 액터가 월드에서 제거되거나 게임이 종료될 때 호출됩니다. 리소스 해제, 연결 끊기 등의 정리 작업에 사용됩니다.

액터 파괴하기 (Destroying Actors)

액터를 월드에서 제거하려면 AActor::Destroy() 함수를 호출합니다.

AMyBullet.cpp (총알이 목표물에 맞았을 때 자신을 파괴하는 예시)
void AMyBullet::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
    // ... 데미지 처리 로직 ...

    // 자신 (총알 액터)을 파괴
    Destroy();
}

// AMyItem.cpp (플레이어가 아이템을 주웠을 때 아이템을 파괴하는 예시)

void AMyItem::OnCollected()
{
    // ... 아이템 획득 로직 ...

    // 자신 (아이템 액터)을 파괴
    Destroy();
}
  • Destroy(): 이 함수를 호출하면 액터는 더 이상 렌더링되지 않고, 틱 업데이트를 받지 않으며, 콜리전도 비활성화됩니다. 실제 메모리 해제는 언리얼 엔진의 가비지 컬렉터가 다음 사이클에서 수행합니다. EndPlay() 함수는 Destroy() 호출 시 자동으로 호출됩니다.
  • 주의사항: 액터가 파괴되면 해당 액터에 대한 유효하지 않은 포인터가 남을 수 있습니다. 유효하지 않은 포인터에 접근하면 크래시가 발생할 수 있으므로, 항상 IsValid()nullptr 체크를 통해 포인터의 유효성을 확인해야 합니다.
// 안전한 포인터 사용 예시
if (MyActorPointer && MyActorPointer->IsValidLowLevel()) // 또는 MyActorPointer.IsValid() (TWeakObjectPtr, TSharedPtr 사용 시)
{
    // MyActorPointer가 유효할 때만 작업 수행
    MyActorPointer->DoSomething();
}

생성 방식, 참조 방법, 파괴 시점, 최적화 판단은 따로 떨어진 주제가 아니라 실제 게임 루프 안에서 계속 이어지는 선택입니다. 아래 다이어그램은 액터를 배치하거나 스폰한 뒤, 어떻게 찾고 보관하며, 언제 제거하거나 재사용할지 결정하는 흐름을 한 번에 연결해 보여줍니다.


액터 관리의 최적화

월드에 수백, 수천 개의 액터가 존재할 수 있는 대규모 게임에서는 액터의 생성, 찾기, 파괴, 그리고 지속적인 업데이트(Tick)에 대한 최적화가 필수적입니다.

  • 불필요한 Tick 비활성화: 액터가 매 프레임 업데이트될 필요가 없다면 PrimaryActorTick.bCanEverTick = false;로 설정하여 성능을 절약합니다. 필요한 순간에만 SetActorTickEnabled(true);로 활성화할 수 있습니다.
  • 액터 풀링(Actor Pooling): 총알이나 파티클 효과처럼 자주 생성되고 파괴되는 액터의 경우, 생성/파괴 비용이 높으므로 액터 풀링 기법을 사용하여 미리 액터를 생성해두고 재활용하는 것이 효율적입니다.
  • 오브젝트 계층 구조 활용: USceneComponent를 통해 액터 내의 컴포넌트들을 계층적으로 구성하거나, 액터 자체를 다른 액터에 부착(AttachToActor 또는 AttachToComponent)하여 월드 아웃라이너를 정리하고 변환 관리를 용이하게 할 수 있습니다.
  • 오브젝트 리스너/이벤트 사용: 특정 액터를 지속적으로 Tick에서 찾거나 모든 액터를 순회하는 대신, DelegateInterface를 사용하여 액터 간의 통신을 이벤트 기반으로 처리하면 불필요한 연산을 줄일 수 있습니다.

아래 다이어그램은 액터 생성, 참조, Tick 비용, 파괴 시점을 한 번에 점검하는 관리 루프입니다.


액터 관리는 생성, 조회, 참조 유지, 파괴 시점을 명확히 나누어 다뤄야 합니다. 참조 수명과 스폰 비용을 함께 관리하면 월드 복잡도와 성능 문제를 줄일 수 있습니다.

마지막으로 액터를 스폰하기 전후에 확인해야 할 입력값, 초기화, 수명 관리 흐름을 정리해 보세요.

액터 생성과 관리는 호출 경계, 소유권, 성능 영향, 재측정 기준으로 점검합니다.