언어:
페이지 정보
엔진 버전:
언리얼 엔진

UE4 C++ 프로그래밍 입문

언리얼 엔진

image alt text

언리얼 C++ 는 대단합니다!

언리얼 엔진에서 C++ 코드를 작성하는 법을 배워보는 안내서입니다. 걱정마세요, 언리얼 엔진에서의 C++ 프로그래밍은 재밌고, 실제로 시작하기도 어렵지 않습니다! 언리얼 C++ 는 "지원형 C++" 정도로 생각해 주시면 좋겠는데, 모두가 C++ 를 쉽게 사용할 수 있도록 도와주는 기능이 정말 많기 때문입니다.

진행하기 전 정말 중요한 점은, C++ 나 다른 프로그래밍 언어에 이미 익숙하셔야 한다는 점입니다. 이 글은 약간의 C++ 경험이 있는 분들을 대상으로 하지만, C#, Java, JavaScript 를 아시는 경우 여러가지 유사한 부분을 확인하실 수 있을 것입니다.

프로그래밍 경험이 전혀 없는 분들의 경우에도 커버 가능한 부분이 있습니다! 블루프린트 비주얼 스크립트 안내서 를 확인해 보시면 되겠습니다. 블루프린트 스크립트만 사용해서도 충분히 게임을 만들 수 있거든요!

언리얼 엔진에서 "일반적인 고전 C++ 코드" 작성을 하는 것도 가능하지만, 이 안내서를 다 읽어보시고 언리얼 프로그래밍 모델의 기본에 대해 알아두시는 편이 가장 나을 것입니다. 진행하면서 자세히 말씀드리겠습니다.

C++ 및 블루프린트

언리얼 엔진에서 새로운 게임플레이 요소를 만드는 방법으로는 C++ 와 블루프린트 비주얼 스크립트, 두 가지 방법이 제공됩니다. C++ 를 사용해서 프로그래머가 바탕이 되는 게임플레이 시스템을 추가하면, 디자이너는 이를 토대로 레벨이나 게임에 맞는 게임플레이를 제작합니다. 이 때 C++ 프로그래머는 (Microsoft Visual Studio 나 Apple 의 Xcode 등) 자주 쓰는 IDE 에서 작업을 하고, 디자이너는 언리얼 에디터의 블루프린트 에디터에서 작업을 합니다.

게임플레이 API 및 프레임워크 클래스는 두 시스템 모두에서 사용 가능하며, 별개로 사용할 수도 있지만, 서로를 보완해 주는 식으로 사용할 때 진정한 위력이 발휘됩니다. 그런데 이게 무슨 소릴까요? 프로그래머가 C++ 로 게임플레이 기본 구성 요소를 만들고, 디자이너가 그 요소를 가지고 재미난 게임플레이를 만들 때 엔진이 최대한의 위력을 발휘한다는 뜻입니다.

그렇게 알아 두시고, 디자이너가 쓰게 되는 기본 구성 요소를 만드는 C++ 프로그래머의 전형적인 작업방식을 살펴봅시다. 여기서는 나중에 디자이너나 프로그래머가 블루프린트를 통해 확장하는 클래스를 하나 만들겠습니다. 이 클래스에서는, 디자이너가 설정할 수 있는 프로퍼티를 몇 개 만든 다음, 그 프로퍼티에서 새로운 값을 파생시키도록 하겠습니다. 제공해 드리는 툴과 C++ 매크로를 사용하면 전체 프로세스를 매우 쉽게 진행할 수 있습니다.

클래스 마법사

먼저 언리얼 에디터 안의 클래스 마법사를 사용하여 나중에 블루프린트로 확장시킬 기본 C++ 클래스를 만들겠습니다. 아래 그림은 새 액터를 만드는 마법사의 첫 단계에서 새 액터를 만드는 것을 보여줍니다.

image alt text

프로세스의 두 번째 단계는 마법사에게 생성하고자 하는 이름을 알려줍니다. 여기서는 기본 이름을 사용했습니다.

image alt text 클래스 생성 선택 이후에는 마법사가 파일을 생성해 주며 개발 환경 프로그램을 열어 편집을 시작할 수 있습니다. 자동 생성되는 클래스 정의는 이렇습니다. 클래스 마법사 관련 상세 정보는 C++ 클래스 마법사 문서를 참고하세요.

#include "GameFramework/Actor.h"
#include "MyActor.generated.h"

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    // 이 액터의 프로퍼티에 기본값을 설정합니다.
    AMyActor();

    // 매 프레임 호출됩니다.
    virtual void Tick( float DeltaSeconds ) override;

protected:
    // 게임 시작 또는 스폰시 호출됩니다.
    virtual void BeginPlay() override;
};

클래스 마법사는 BeginPlay() 와 Tick() 을 오버로딩하는 클래스를 생성합니다. BeginPlay() 는 액터가 플레이 가능한 상태로 게임에 들어왔음을 알려주는 이벤트입니다. 클래스에 대한 게임플레이 로직을 초기화시키기에 좋은 곳입니다. Tick() 는 지난 번 들여온 이후의 경과된 시간만큼 프레임당 한 번씩 호출됩니다. 여기서 어떠한 반복 로직도 가능합니다. 하지만 그러한 함수 기능이 필요치 않은 경우, 제거하는 편이 퍼포먼스가 약간 향상되므로 좋을 것입니다. 제거한 경우, 생성자에서 틱이 일어나야 한다고 나타낸 줄도 제거해 줘야 합니다. 아래 생성자에 문제의 그 줄이 들어있습니다.

AMyActor::AMyActor()

{

    // 이 액터가 Tick() 을 매 프레임 호출하도록 설정합니다. 필요치 않은 경우 이 옵션을 끄면 퍼포먼스가 향상됩니다.

    PrimaryActorTick.bCanEverTick = true;

}

프로퍼티가 에디터에 보이도록 만들기

클래스를 생성했으니, 디자이너가 언리얼 에디터에서 설정할 수 있는 프로퍼티를 만들어 봅시다. 프로퍼티를 에디터에 노출시키는 작업은 전용 매크로 UPROPERTY() 를 사용하면 매우 쉽습니다. 아래 클래스에서 보이는 것처럼, UPROPERTY(EditAnymore) 매크로를 프로퍼티 선언 앞에 두기만 하면 됩니다.

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()
public:

    UPROPERTY(EditAnywhere)
    int32 TotalDamage;

    ...
};

에디터에서의 값 편집 허용을 위해 필요한 작업은 이게 전부입니다. 편집 방법이나 위치를 추가로 제어할 수 있는 방법이 몇 가지 더 있습니다. UPROPERTY() 매크로에 추가 정보를 전달하면 되는데요. 예를 들어 TotalDamage 프로퍼티가 다른 유사 프로퍼티와 한 섹션에 나타나도록 하려면, 카테고리 분류 기능을 사용하면 됩니다. 그 프로퍼티 선언 방법은 아래와 같습니다.

UPROPERTY(EditAnywhere, Category="Damage")
int32 TotalDamage;

사용자가 이 프로퍼티를 편집할 때 보면, 이제 Damage 제목줄 아래 카테고리 이름을 똑같이 설정한 다른 프로퍼티와 함께 나타납니다. 디자이너에게 자주 쓰이는 세팅을 모아놓기에 좋은 방법입니다.

이제 같은 프로퍼티를 블루프린트로 노출시켜 봅시다.

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
int32 TotalDamage;

보시다시피 프로퍼티를 읽고 쓸 수 있도록 만들기 위한 블루프린트 전용 파라미터가 있습니다. 프로퍼티가 블루프린트에서 const (상수) 취급하도록 하고자 하는 경우 사용할 수 있는 옵션도 BlueprintReadOnly 라고 따로 있습니다. 엔진에 프로퍼티를 노출시키는 방법을 제어할 수 있는 옵션은 몇 가지 더 있습니다. 자세한 정보는 프로퍼티 지정자 문서를 참고하세요.

다음 섹션으로 넘어가기 전, 이 샘플 클래스에 프로퍼티를 몇 가지 추가해 봅시다. 이 액터가 내는 총 대미지 양 조절을 위한 프로퍼티는 이미 있습니다. 하지만 이를 토대로 시간에 걸쳐 서서히 대미지를 입히도록 만들어 봅시다. 아래 코드는 디자이너 설정가능 프로퍼티를 하나, 디자이너에게 보이긴 하지만 변경은 불가능한 프로퍼티를 하나 추가합니다.

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()
public:

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    int32 TotalDamage;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Damage")
    float DamageTimeInSeconds;

    UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Transient, Category="Damage")
    float DamagePerSecond;

    ...
};

DamageTimeInSeconds 는 디자이너 변경가능 프로퍼티입니다. DamagePerSecond 프로퍼티는 디자이너의 세팅을 사용하여 계산된 값입니다 (다음 섹션 참고). VisibleAnywhere 플래그는 프로퍼티가 보이기는 하되 언리얼 에디터에서 편집은 불가능하도록 마킹합니다. Transient (휘발성) 플래그는 디스크에(서) 저장되거나 로드되지 않고, 지속되지 않는 파생 값이라는 뜻입니다. 아래 그림은 클래스 디폴트 일부인 프로퍼티를 나타냅니다.

image alt text

생성자에서 기본값 설정

생성자에서 프로퍼티의 기본값을 설정하는 것은 전형적인 C++ 클래스와 동일합니다. 아래는 생성자에서 기본값을 설정하는 예제 둘로, 함수 기능은 동일합니다.

AMyActor::AMyActor()
{
    TotalDamage = 200;
    DamageTimeInSeconds = 1.f;
}

AMyActor::AMyActor() :
    TotalDamage(200),
    DamageTimeInSeconds(1.f)
{
}

생성자에 기본값을 추가한 이후 같은 프로퍼티를 본 모습입니다.

image alt text

인스턴스별 디자이너 설정 프로퍼티를 지원하기 위해, 주어진 오브젝트에 대한 인스턴스 데이터에서 값을 로드하기도 합니다. 이 데이터는 생성자 이후에 적용됩니다. PostInitProperties() 콜 체인에 걸어주는 것으로 디자이너 설정 값을 기반으로 기본값을 만들 수 있습니다. 다음은 그러한 프로세스에 대한 예제로서, TotalDamage 와 DamageTimeInSeconds 를 디자이너 지정 값으로 한 것입니다. 디자이너가 지정한 것이긴 하지만, 위 예제에서처럼 거기에 대해 적당한 기본값을 지정해 줄 수는 있습니다.

프로퍼티에 기본값을 지정해 주지 않으면, 엔진에서 자동으로 0 또는 포인터의 경우 널 포인터로 설정합니다.

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
}

위의 PostInitProperties() 코드를 추가한 이후 같은 프로퍼티를 다시 확인한 모습입니다.

image alt text

핫 리로드

다른 프로젝트에서만 C++ 프로그래밍을 경험하셨다면 놀라실만한 언리얼의 멋진 기능입니다. 에디터를 닫지 않고도 C++ 변경내용을 컴파일 할 수 있습니다! 그 방법은 두 가지입니다:

  1. 에디터를 열어둔 채로, 평소처럼 Visual Studio 나 Xcode 에서 빌드합니다. 에디터가 새로 컴파일된 DLL 을 감지하여 변경내용을 즉시 리로드합니다!

    image alt text

    참고로 디버거가 붙어있는 경우, Visual Studio 에서 Build 를 할 수 있도록 하려면 먼저 떼어줘야 합니다.

  2. 아니면 그냥 에디터 메인 툴바의 컴파일 버튼을 클릭합니다!

    image alt text

이 기능의 덕은 튜토리얼을 진행하면서 앞으로 느껴보실 수 있습니다.

블루프린트를 통한 C++ 클래스 확장

지금까지 C++ 클래스 마법사로 간단한 게임플레이 클래스를 만들고 디자이너가 설정할 수 있는 프로퍼티를 추가해 봤습니다. 이제 어떻게 하면 디자이너가 이렇게 미약한 시작에서 창대한 고유 클래스를 만들 수 있을지 그 방법을 살펴봅시다.

먼저 AMyActor 클래스에서 블루프린트 클래스를 새로 만들어 주겠습니다. 아래 그림에서 보면 선택된 베이스 클래스의 이름이 AMyActor 가 아닌 MyActor 로 나타납니다. 이는 의도된 것으로, 디자이너에게 툴 내부적으로 쓰이는 이름으로 보이지 않도록 하여 보다 친근감을 주기 위한 것입니다.

image alt text

선택하고 나면, 기본 이름의 블루프린트 클래스가 새로 생성됩니다. 이 경우 아래 콘텐츠 브라우저 스냅샷에서 보시듯이 CustomActor1 이라고 이름을 설정했습니다.

image alt text

디자이너가 걸칠 수 있도록 맞춤 제작한 클래스 1 호입니다. 먼저 바꿔줄 것은, 대미지 프로퍼티의 기본값입니다. 이 경우 디자이너가 TotalDamage 를 300 으로, 그만큼의 대미지를 입히는 데 걸리는 시간을 2 초로 설정했습니다. 그러면 프로퍼티는 이렇게 보입니다.

image alt text

잠깐만요... 계산된 값이 기대한 대로 나오지 않는데요. 150 이 되어야 하겠지만, 여전히 기본값 200 으로 표시됩니다. 그 이유는, 로딩 프로세스에서 프로퍼티가 초기화된 이후의 초당 대미지 값만 계산하고 있기 때문입니다. 언리얼 에디터의 런타임 변경사항은 반영되지 않습니다. 에디터에서 타깃 오브젝트의 값이 변경되면 알려주므로 이 문제에는 간단한 해법이 있습니다. 아래 코드는 에디터에서 변해가는 파생된 값의 계산에 필요한 후크 추가를 나타냅니다.

void AMyActor::PostInitProperties()
{
    Super::PostInitProperties();

    CalculateValues();
}

void AMyActor::CalculateValues()
{
    DamagePerSecond = TotalDamage / DamageTimeInSeconds;
}

#if WITH_EDITOR
void AMyActor::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    CalculateValues();

    Super::PostEditChangeProperty(PropertyChangedEvent);
}
#endif

한 가지 참고할 것은 PostEditChangeProperty() 메서드가 에디터 전용 #ifdef 안에 있다는 것입니다. 이는 게임에 필요한 코드로만 게임을 만들 수 있도록 하여, 실행파일 크기를 불필요하게 늘릴 수 있는 여타 코드를 제거하는 것입니다. 그 코드를 컴파일해 넣고 나면, 아래 그림에서 보시듯이 DamagePerSecond 값이 예상대로 나옵니다.

image alt text

C++ 및 블루프린트 경계를 넘는 함수 호출

지금까지는 블루프린트에 프로퍼티를 노출시키는 법을 보여드렸는데, 엔진에 더욱 깊숙히 뛰어들기 전에 소개해 드렸으면 하는 주제가 마지막 하나 있습니다. 게임플레이 시스템 생성 도중에는, 디자이너가 C++ 프로그래머에 의해 생성된 함수를 호출할 수도, 게임플레이 프로그래머가 블루프린트나 C++ 코드로 구현된 함수를 호출할 수도 있어야 합니다. 먼저 CalculateValues() 함수를 블루프린트에서 호출가능하도록 만들어 봅시다. 함수를 블루프린트에 노출시키는 것은, 프로퍼티 노출만큼이나 간단합니다. 함수 선언 전 매크로 하나만 배치해 주면 됩니다! 무엇이 필요한지는 아래 코드 스니펫으로 알 수 있습니다.

UFUNCTION(BlueprintCallable, Category="Damage")
void CalculateValues();

UFUNCTION() 매크로가 리플렉션 시스템으로의 C++ 함수 노출을 처리합니다. BlueprintCallable 옵션이 블루프린트에 노출시키면, 사용자는 블루프린트 그래프 안에서 함수를 호출할 수 있습니다. 아래 이미지는 카테고리가 컨텍스트 메뉴에 주는 영향을 보여줍니다.

image alt text

보시듯이 Damage 카테고리에서 함수를 선택할 수 있습니다. 아래 블루프린트 코드는 TotalDamage 값의 변화 뒤에 종속 데이터 재계산을 위한 호출이 오고 있습니다.

image alt text

여기서는 앞서 종속 프로퍼티 계산을 위해 추가했던 바로 그 함수를 사용합니다. 엔진 많은 부분이 UFUNCTION() 매크로를 통해 블루프린트에 노출되어 있어, C++ 코드를 작성하지 않고도 게임을 만들 수 있는 것입니다. 기반이 되는 게임플레이 시스템과 퍼포먼스가 중요한 코드는 C++ 를 사용하고, 그 C++ 기본 구성요소를 토대로 복합적인 작동방식을 만들거나 입맛대로 수정하는 데는 블루프린트를 사용하는 것이 최적의 접근법입니다.

디자이너가 C++ 코드를 호출할 수 있게 되었으니, C++/블루프린트 경계를 넘나드는 비법을 한 가지 더 알아봅시다. 이 접근법은 C++ 코드에서 블루프린트에 정의된 함수를 호출할 수 있도록 해 주는 것입니다. 종종 디자이너가 알맞게 반응할 수 있도록 디자이너에게 이벤트를 알리는 접근법을 사용합니다. 거기에는 액터를 숨기거나 보이게 만드는 등의 시각적인 영향이나 이펙트 스폰 작업이 수반되게 마련인데요. 블루프린트로 구현된 함수를 나타내 주는 코드 스니펫은 아래와 같습니다.

UFUNCTION(BlueprintImplementableEvent, Category="Damage")
void CalledFromCpp();

이 함수는 다른 C++ 함수처럼 호출됩니다. 내부적으로 언리얼 엔진은 블루프린트 가상머신으로 호출해 들어가는 방법을 이해하는 베이스 C++ 함수 구현을 생성합니다. 이를 흔히 Thunk, 썽크라고 합니다. 해당 블루프린트가 이 메서드에 대한 함수 바디를 제공해 주지 않는 경우, 그 함수는 본래 동작이 없는 C++ 함수인 것처럼 작동합니다: 아무것도 하지 않는 것입니다. 블루프린트가 메서드를 덮어쓸 수 있도록 하면서도 C++ 기본 구현을 제공하려면 어떻게 해야 할까요? UFUNCTION() 매크로에 그에 대한 옵션도 있습니다. 그것을 이뤄내기 위해 헤더를 어떻게 변경해야 하는지 나타내는 코드 스니펫은 아래와 같습니다.

UFUNCTION(BlueprintNativeEvent, Category="Damage")
void CalledFromCpp();

이 버전은 여전히 블루프린트 가상 머신으로 호출해 들어가는 썽크 메서드를 생성합니다. 그러면 기본 구현은 어떻게 제공할까요? 툴에서는 <함수명>_Implementation() 같이 보이는 함수 선언을 새로 생성하는 툴도 제공해 줍니다. 이 버전의 함수를 제공해 주지 않으면 프로젝트가 링크하는 데 실패할 것입니다. 위 선언에 대한 구현 코드는 이렇습니다.

void AMyActor::CalledFromCpp_Implementation()
{
    // 여기서 어떤 작업을 해라
}

이제 이 버전의 함수가 호출되면 해당 블루프린트는 메서드를 덮어쓰지 않습니다. 한 가지 참고할 것은, 이전 버전의 빌드 툴에서는 _Implementation() 선언이 자동 생성되었었습니다. 4.8 버전 이상에서는 헤더에 명시적으로 추가해 줘야 합니다.

일반적인 게임플레이 프로그래머 작업방식과 디자이너와 함께 게임플레이 기능을 만들어 나가는 방법을 안내해 드렸으니, 여러분 자신의 여정을 선택할 시간입니다. 이 문서를 계속 읽어보면서 엔진에서 C++ 를 어떻게 사용하는지 더 알아보거나, 런처에 포함된 샘플 중 하나에 바로 뛰어들어 실전 경험을 쌓아 보실 수도 있겠습니다.

심화 학습

이 험난한 여정을 함께 하기로 하셨군요. 멋집니다! 다음 논의할 주제는 게임플레이 클래스 계층구조가 어때 보이는지에 대해서입니다. 여기서는, 기본 구성 요소부터 시작해서 어떻게 서로 연관되는지에 대해 이야기해 보도록 하겠습니다. 이를 통해 언리얼 엔진에서 상속(inheritance)이나 구성(composition)을 통해 커스텀 게임플레이 요소를 만드는 법을 살펴보겠습니다.

게임플레이 클래스: Object, Actor, Component

대다수의 게임플레이 클래스에 대해 파생해 올 수 있는 클래스 유형은 크게 네 가지입니다. UObject, AActor, UActorComponent, UStruct 입니다. 이들 각각의 구성 요소에 대해서는 다음 섹션에 자세히 설명드립니다. 물론 이 클래스 이외에서 파생된 유형을 만들 수는 있지만, 엔진에 내장된 기능이 포함되지는 않을 것입니다. UObject 계층구조 밖에서 생성되는 클래스의 전형적인 예는, 써드 파티 라이브러리를 통합하거나, OS 전용 기능에 대한 래핑 등입니다.

언리얼 오브젝트 (UObject)

언리얼 엔진의 기본 구성 요소는 UObject 라 합니다. 이 클래스는 UClass 와 함께하여 엔진의 가장 중요한 근간이 되는 서비스를 다수 제공합니다:

  • 프로퍼티와 메서드의 리플렉션(반영)

  • 프로퍼티의 시리얼라이제이션

  • 가비지 컬렉션

  • 이름으로 UObject 찾기

  • 프로퍼티에 환경설정 가능 값

  • 프로퍼티와 메서드에 네트워킹 지원

UObject 에서 파생되는 각 클래스에는 그에 대한 단독 개체 UClass 가 생성되어 클래스 인스턴스에 대한 모든 메타 데이터가 포함됩니다. UClass 와 UObject 의 차이점이라면, UClass 는 UObject 의 인스턴스가 어떤 모양인지, 어떤 프로퍼티가 시리얼라이제이션, 네트워크 대상인지 등을 설명하는 것으로 보면 됩니다. 대부분의 게임플레이 개발 상황에서는 UObject 를 직접 파생하기 보다는, AActor 나 UActorComponent 를 파생하게 됩니다. 게임플레이 코드를 작성하기 위해 세부적인 UClass/UObject 작동 방식을 알아야 할 필요는 없지만, 이런 시스템이 존재한다는 것은 알아두면 좋습니다.

AActor

AActor (액터)는 게임플레이 경험의 일부로 쓸 오브젝트입니다. AActor 는 디자이너가 레벨에 배치하거나, 게임플레이 시스템을 통해 런타임에 생성하는 것도 가능합니다. 레벨에 배치할 수 있는 오브젝트는 전부 이 클래스에서 확장됩니다. 그 예제는 AStaticMeshActor, ACameraActor, APointLight 액터 등입니다. AActor 는 UObject 에서 파생되므로, 기존 섹션에 나열된 표준 기능 혜택을 전부 누릴 수 있습니다. AActor 는 게임플레이 코드(C++ 또는 블루프린트)를 통해 명시적으로 소멸시킬 수도 있고, 소유중인 레벨이 메모리에서 언로드될 때 표준 가비지 컬렉션 메커니즘을 통해 소멸시킬 수도 있습니다. AActor 는 게임 오브젝트가 하이레벨에서 작동되는 방식을 담당합니다. AActor 는 네트워크 상황에서 리플리케이트되는 바탕 유형이기도 합니다. 네트워크 리플리케이션 도중, AActor 는 네트워크 지원을 필요로 하는 AActor 에 소유된 UActorComponent 에 대해서도 정보를 배포할 수 있습니다.

AActor 에는 별도의 작동방식이 있는데 (상속을 통한 전문화), UActorComponent 계층구조에 대한 컨테이너 역할을 하기도 합니다 (구성을 통한 전문화). 이는 AActor 의 RootComponent 멤버를 통해 이루어지는데, 여기에는 하나의 UActorComponent 가 들어있으며, 차례대로 여기에 다른 여러 컴포넌트가 담깁니다. AActor 를 레벨에 배치하기 전, AActor 는 반드시 최소 하나의 USceneComponent 가 들어있어야 하며, 여기에는 그 AActor 에 대한 이동, 회전, 스케일이 들어있습니다.

AActor 에는 AActor 수명 도중 호출할 수 있는 이벤트 시리즈가 있습니다. 아래 목록은 단순화시킨 이벤트 세트로 그 수명을 나타낸 것입니다.

  • BeginPlay - 오브젝트가 게임플레이에 처음 등장했을 때 호출됩니다.

  • Tick - 지속적으로 이루어지는 작업을 하기 위해 프레임 당 한 번 호출됩니다.

  • EndPlay - 오브젝트가 게임플레이 공간을 떠날 때 호출됩니다.

AActor 관련 보다 자세한 논의는 액터 문서를 참고하세요.

런타임 수명

방금 전 AActor 의 수명 일부분에 대해 논했습니다. 레벨에 배치된 액터의 경우, 그 수명을 이해하는 것이 꽤나 상상하기 쉽습니다. 액터가 등장하기 시작하면 로드하고, 결국 레벨이 언로드되면 액터가 소멸됩니다. 그러면 런타임 생성 및 소멸 프로세스는 어떻게 될까요? 언리얼 엔진에서는 런타임 스폰시 AActor 생성을 호출합니다. 액터 스폰은 게임에서 일반 오브젝트 생성시보다 약간 더 복잡합니다. 그 이유라면 AActor 는 다양한 런타임 시스템으로 등록시켜줘야 그 요구사항을 전부 충족시킬 수 있기 때문입니다. 액터의 초기 위치와 회전을 설정해 줘야 합니다. 피직스에서 알아야 할 수가 있습니다. 액터에게 틱 요청을 담당하는 매니저도 알아야 합니다. 그와 같은 식입니다. 그때문에 액터의 스폰을 전담하는 메서드를 만들었는데, 바로 UWorld::SpawnActor() 입니다. 그 액터 스폰에 성공하면 BeginPlay() 메서드가 호출되고, 다음 프레임에 Tick() 이 잇따릅니다.

액터가 수명 이상으로 살아남게 되면, Destroy() 를 호출하여 소멸시킬 수 있습니다. 그 프로세스 도중 EndPlay() 가 호출되는데, 여기서 소멸 관련 커스텀 로직을 짤 수 있습니다. 액터가 얼마나 오래 존재하는가를 제어하는 또 한가지 옵션은, Lifespan 멤버를 사용하는 것입니다. 오브젝트의 생성자 또는 런타임에 다른 코드 안에 기간을 설정할 수 있습니다. 그 기간이 만료되면, 액터는 자동으로 스스로에 대해 Destroy() 를 호출합니다.

액터 스폰 관련 보다 자세한 내용은 액터 스폰하기 문서를 참고하세요.

UActorComponent

UActorComponent (액터 컴포넌트)에는 별도의 작동방식이 내장되어 있으며, 보통 비주얼 메시, 파티클 이펙트, 카메라 시점, 피직스 상호작용 등 AActor 의 여러 유형에 공유되는 기능을 담당합니다. AActor 는 보통 게임의 전반적인 규칙에 관계된 일반적인 수준의 목표가 주어지는 반면, UActorComponent 는 보통 그 일반적인 수준의 목표를 보조하는 개별 작업을 수행합니다. 컴포넌트는 다른 컴포넌트에 붙일 수도 있고, 액터의 루트 컴포넌트가 될 수도 있습니다. 컴포넌트는 오직 하나의 부모 컴포넌트 또는 액터에만 붙일 수 있으나, 자신에게는 다수의 자손 컴포넌트를 붙일 수도 있습니다. 컴포넌트 트리 구조를 그려봅시다. 자손 컴포넌트는 부모 컴포넌트를 기준으로 한 위치, 회전, 스케일 값을 갖습니다.

액터와 컴포넌트를 사용하는 방법은 여러가지 있는 반면, 액터-컴포넌트 관계를 빗대어 생각해 볼 수 있는 한 가지 방법은, 액터는 "이게 뭐지?" 라는 질문에 대한 답이, 컴포넌트는 "이게 뭘로 만들어졌지?" 라는 질문에 대한 답이 될 수 있다는 것입니다.

  • RootComponent - AActor 의 컴포넌트 트리 내 최상위 컴포넌트를 담는 AActor 멤버입니다.

  • Ticking - 소유한 AActor 의 Tick() 일부분으로 틱되는 컴포넌트입니다.

일인칭 캐릭터 분석

위의 몇몇 섹션은 이론적인 부분만 다뤘지 실제로 보여드린 부분은 적습니다. AActor 와 그 UActorComponent 사이의 관계를 눈으로 보여드리기 위해, First Person (일인칭) 템플릿을 기반으로 새 프로젝트를 만들었을 때 생성되는 블루프린트를 파 보도록 합시다. 아래 그림은 FirstPersonCharacter 액터의 Component 트리입니다. RootComponentCapsuleComponent 입니다. CapsuleComponent 에는 ArrowComponent, MeshComponent, FirstPersonCameraComponent 가 붙어있습니다. 가장 말단에 붙어있는 컴포넌트는 Mesh1P 컴포넌트로, 부모는 FirstPersonCameraComponent, 즉 일인칭 카메라 기준 일인칭 메시라는 뜻입니다.

image alt text

시각적으로 이 컴포넌트 트리는 아래 그림과 같은데, Mesh 컴포넌트를 제외한 모든 컴포넌트가 3D 공간에 보입니다.

image alt text

이 컴포넌트 트리는 하나의 액터 클래스에 붙어있습니다. 이 예제에서 볼 수 있듯이, 상속과 구성 양쪽을 통해 복잡한 게임플레이 오브젝트를 만들 수 있습니다. 기존 AActor 또는 UActorComponent 를 커스터마이징할 때는 상속을, 여러가지 다양한 AActor 유형에 함수 기능을 공유시킬 때는 구성을 사용합니다.

UStruct

UStruct (구조체)를 사용하기 위해서는, 특정 클래스를 확장할 필요 없이, 그냥 구조체에 USTRUCT() 마킹을 해 주면 빌드 툴이 알아서 바탕 작업을 해줍니다. UObject 와는 달리, UStruct 는 가비지 컬렉션 대상이 아닙니다. 그에 대한 다이내믹 인스턴스를 생성한 경우, 그 수명을 직접 관리해 줘야 합니다. UStruct 는 언리얼 에디터 내 편집, 블루프린트 조작, 시리얼라이제이션, 네트워크 등 UObject 리플렉션이 지원되는 일반적인 이전 데이터 유형들을 모아놓는 용도로 쓰입니다.

게임플레이 클래스 생성시 사용되는 기본적인 계층구조에 대해 이야기 나눴으니, 다시 한 번 갈림길에 설 차례가 되었습니다. 이 문서 에서 게임플레이 클래스에 대한 상세 정보를 확인할 수도, 런처에서 받을 수 있는 샘플에서 추가 정보를 확인할 수도 있고, 계속해서 게임 제작을 위한 C++ 기능을 더욱 깊이 파내려가 볼 수도 있습니다.

한층 더 심화 학습

좋습니다, 지적인 호기심이 대단하시군요. 엔진 작동 방식을 보다 깊이 파내려가 보도록 합시다.

언리얼 리플렉션 시스템

블로그 게시물: 언리얼 프로퍼티 시스템 (리플렉션)

게임플레이 클래스는 특수한 마크업을 활용하므로, 자세히 들어가기 전 언리얼 프로퍼티 시스템의 기초를 약간 살펴보도록 합시다. UE4 는 별도의 리플렉션 구현을 통해 가비지 컬렉션, 시리얼라이제이션, 네트워크 리플리케이션, 블루프린트/C++ 통신과 같은 동적인 기능을 활용합니다. 이러한 기능들은 선택적으로 넣을 수 있는 것들이라, 자신의 유형에 올바른 마크업을 추가해 주지 않으면 언리얼에서는 무시하고 그에 대한 리플렉션 데이터를 생성하지 않는다는 뜻입니다. 기본 마크업에 대한 간단 개요는 다음과 같습니다:

  • UCLASS() - 언리얼에게 클래스의 리플렉션 데이터를 생성하라 할 때 씁니다. UObject 파생 클래스여야 합니다.

  • USTRUCT() - 언리얼에게 구조체의 리플렉션 데이터를 생성하라 할 때 씁니다.

  • GENERATED_BODY() - UE4 에서는 이 부분을 해당 유형에 대해 생성되는 전체 필수 표준(boilerplate) 코드로 대체합니다.

  • UPROPERTY() - UCLASS 또는 USTRUCT 의 멤버 변수를 UPROPERTY 로 사용할 수 있도록 해줍니다. UPROPERTY 에는 여러가지 용도가 있습니다. 변수가 리플리케이트, 시리얼라이즈 되도록 하거나, 블루프린트에서의 접근할 수 있도록 할 수도 있습니다. UObject 로의 레퍼런스가 몇 개나 되는지 가비지 컬렉터가 추적하는 데도 사용됩니다.

  • UFUNCTION() - UCLASS 또는 USTRUCT 의 클래스 메서드를 UFUNCTION 으로 사용할 수 있도록 해줍니다. UFUNCTION 은 블루프린트에서 클래스 메서드를 호출할 수 있도록, 다른 것 보다도 RPC 로 사용할 수 있도록 해줍니다.

UCLASS 선언 예제는 다음과 같습니다:

#include "MyObject.generated.h"

UCLASS(Blueprintable)
class UMyObject : public UObject
{
    GENERATED_BODY()

public:
    MyUObject();

    UPROPERTY(BlueprintReadOnly, EditAnywhere, Category="Example")
    float ExampleProperty;

    UFUNCTION(BlueprintCallable, Category="Example")
    void ExampleFunction();
};

먼저 "MyClass.generated.h" 를 include 한 것이 보일 것입니다. 언리얼에서는 모든 리플렉션 데이터를 생성하여 이 파일 안에 넣습니다. 이 파일 include 는 유형을 선언하는 헤더 파일에서 마지막 include 로 넣어줘야 합니다.

이 예제의 UCLASS, UPROPERTY, UFUNCTION 마크업에는 부가 지정자를 포함합니다. 필수는 아니지만, 데모 목적 상 몇 가지 흔한 지정자를 추가했습니다. 지정자는 특정 작동방식이나 프로퍼티를 활성화 또는 변경할 수 있습니다.

  • Blueprintable - 이 클래스는 블루프린트로 확장시킬 수 있습니다.

  • BlueprintReadOnly - 이 프로퍼티는 블루프린트에서 읽을 수는 있지만, 쓰기는 불가능합니다.

  • EditAnywhere - 이 프로퍼티는 아키타입과 인스턴스의 프로퍼티 창에서 편집할 수 있습니다.

  • Category - 이 프로퍼티가 에디터의 디테일 뷰에서 어느 섹션 아래 나타나도록 할지를 정의합니다. 정리용입니다.

  • BlueprintCallable - 이 함수는 블루프린트에서 호출할 수 있습니다.

지정자는 여기 전부 나열할 수 없을 만큼 많으니, 다음 링크에서 참고해 보실 수 있습니다:

UCLASS 지정자 목록

UPROPERTY 지정자 목록

UFUNCTION 지정자 목록

USTRUCT 지정자 목록

오브젝트/액터 이터레이터

오브젝트 이터레이터(반복처리기)는 특정 UObject 유형과 그 서브클래스의 모든 인스턴스를 대상으로 반복처리할 때 매우 유용하게 사용되는 툴입니다.

// 현재 UObject 인스턴스를 전부 찾습니다.
for (TObjectIterator<UObject> It; It; ++It)
{
    UObject* CurrentObject = *It;
    UE_LOG(LogTemp, Log, TEXT("Found UObject named: %s"), *CurrentObject->GetName());
}

이터레이터에 보다 구체적인 유형을 제공해 주는 것으로 검색 범위를 제한시킬 수 있습니다. UObject 에서 파생된 UMyClass 라는 클래스가 있다 칩시다. 그 클래스(와 거기서 파생된 것들)의 모든 인스턴스는 다음과 같이 찾을 수 있습니다:

for (TObjectIterator<UMyClass> It; It; ++It)
{
    // ...
}

PIE (에디터에서 플레이) 모드에서 오브젝트 이터레이터를 사용하면 예상치 못한 결과가 날 수 있습니다. 에디터가 로드되어 있고, 오브젝트 이터레이터는 게임 월드 인스턴스에 대해 생성된 모든 UObject 에 추가로 에디터에서만 사용되는 것들도 반환할 것이기 때문입니다.

액터 이터레이터는 오브젝트 이터레이터와 매우 비슷한 방식으로 작동하지만, AActor 에서 파생되는 액터에 대해서만 작동합니다. 액터 이터레이터에는 위와 같은 문제가 없으며, 현재 게임 월드 인스턴스에서 사용되는 오브젝트만 반환합니다.

액터 이터레이터를 생성할 때는, UWorld 인스턴스로의 포인터를 주어야 합니다. APlayerController 처럼 다수의 UObject 클래스는 보조를 위해 GetWorld 메서드를 제공합니다. 확실치 않은 경우 UObject 의 ImplementsGetWorld 에서 GetWorld 메서드를 구현하는지 확인해 보면 됩니다.

APlayerController* MyPC = GetMyPlayerControllerFromSomewhere();
UWorld* World = MyPC->GetWorld();

// 오브젝트 이터레이터처럼, 구체적인 클래스를 제공하여 해당 클래스 또는 그 
// 파생 클래스의 오브젝트만 구할 수 있습니다.
for (TActorIterator<AEnemy> It(World); It; ++It)
{
    // ...
}

AActor 는 UObject 에서 파생되므로, TObjectIterator 를 사용하여 AActor 인스턴스를 찾을 수도 있습니다. 단 PIE 에서는 조심하세요!

메모리 관리 및 가비지 컬렉션

여기서는 UE4 의 기본적인 메모리 관리 및 가비지 컬렉션 시스템에 대해 다뤄보겠습니다. (영문)

Wiki: Garbage Collection & Dynamic Memory Allocation

UObject 및 가비지 컬렉션

UE4 에서는 가비지 컬렉션 시스템 구현을 위해 리플렉션 시스템을 사용합니다. 가비지 컬렉션 덕에 UObject 삭제를 수동 관리할 필요 없이, 그냥 그에 대한 유효 레퍼런스만 유지해 주면 됩니다. UObject 파생 클래스여야 가비지 컬렉션이 활성화됩니다. 우리가 사용할 단순한 예제 클래스는 다음과 같습니다:

UCLASS()
class MyGCType : public UObject
{
    GENERATED_BODY()
};

가비지 컬렉터에는, 루트 세트라 불리는 이런 개념이 있습니다. 이 루트 세트는 기본적으로 컬렉터가 하는 오브젝트 중 절대 가비지 컬렉션 대상이 되지 않는 오브젝트 목록입니다. 루트 세트의 한 오브젝트에서 문제의 오브젝트로 레퍼런스 경로가 있는 한, 그 오브젝트는 가비지 컬렉션 대상이 되지 않습니다. 어느 한 오브젝트에 대해 루트 세트로의 경로가 존재하지 않는 경우, 그를 unreachable (도달불가능)이라 하며, 다음 번 가비지 컬렉터 실행시 컬렉팅(삭제)됩니다. 엔진에서는 일정 간격마다 가비지 컬렉터를 실행합니다.

"레퍼런스"로 치는 것은 무엇인가요? UPROPERTY 에 저장된 UObject 포인터를 말합니다. 단순한 예제로 시작해 봅시다.

void CreateDoomedObject()
{
    MyGCType* DoomedObject = NewObject<MyGCType>();
}

위 함수를 호출할 때, 새로운 UObject 를 생성하지만 그에 대한 포인터는 어떤 UPROPERTY 에도 저장하지 않으며, 루트 세트에 들어있지도 않습니다. 결국 가비지 컬렉터는 이 오브젝트가 도달불가능한 것으로 감지하고, 소멸시킵니다.

액터 및 가비지 컬렉션

액터는 보통 가비지 컬렉팅되지 않습니다. 스폰 후에는 반드시 거기서 Destroy() 를 수동 호출해야 합니다. 즉시 삭제되는 것은 아니고, 다음 가비지 컬렉션 단계에서 지워질 것입니다.

UObject 프로퍼티를 가진 액터가 있는, 보다 일반적인 경우는 다음과 같습니다.

UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()

public:
    UPROPERTY()
    MyGCType* SafeObject;

    MyGCType* DoomedObject;

    AMyActor(const FObjectInitializer& ObjectInitializer)
        : Super(ObjectInitializer)
    {
        SafeObject = NewObject<MyGCType>();
        DoomedObject = NewObject<MyGCType>();
    }
};

void SpawnMyActor(UWorld* World, FVector Location, FRotator Rotation)
{
    World->SpawnActor<AMyActor>(Location, Rotation);
}

위 함수를 호출하면, 월드에 액터를 스폰합니다. 액터의 생성자는 오브젝트를 둘 생성합니다. 하나는 UPROPERTY 에 할당되고, 다른 하나는 기본(bare) 포인터에 할당됩니다. 액터는 자동으로 루트 세트의 일부가 되므로, SafeObject 는 루트 세트 오브젝트에서 도달 가능하니 가비지 컬렉팅되지 않습니다. 하지만 DoomedObject 는 제대로 살지 못합니다. UPROPERTY 마킹을 하지 않았으니, 컬렉터는 레퍼런싱되었다고 알지 못하므로 결국 소멸될 것입니다.

UObject 가 가비지 컬렉팅될 때, 그에 대한 모든 UPROPERTY 레퍼런스는 널 포인터로 설정해 줍니다. 그래야 오브젝트가 가비지 컬렉팅 되었는지 여부를 안전하게 검사할 수 있게 됩니다.

if (MyActor->SafeObject != nullptr)
{
    // Use SafeObject
}

이 부분이 중요한데, 앞서 말씀드렸듯이 Destroy() 를 호출한 액터는 다음 번 가비지 컬렉터가 실행되기 전까지는 제거되지 않기 때문입니다. UObject 가 삭제 대기중인지는 IsPendingKill() 메서드를 사용해서 검사할 수 있습니다. 그 메서드가 true 를 반환하는 경우, 오브젝트가 죽을 테니 사용하지 말아야겠다 생각하면 됩니다.

UStructs

UStructs (구조체)는 앞서 말씀드렸듯이 UObject 의 경량 버전으로 의도된 것입니다. 그렇기에 UStruct 는 가비지 컬렉팅 불가능합니다. UStruct 의 다이내믹 인스턴스를 반드시 사용해야만 한다면, 나중에 다룰 스마트 포인터를 사용하는 것이 좋습니다.

UObject 이외의 레퍼런스

일반적인 UObject 이외의 것도 오브젝트로의 레퍼런스를 추가하고 가비지 컬렉션을 막을 수 있습니다. 그러기 위해서, 오브젝트는 반드시 FGCObject 에서 파생되어 그 AddReferencedObjects 클래스를 오버라이드해야 합니다.

class FMyNormalClass : public FGCObject
{
public:
    UObject* SafeObject;

    FMyNormalClass(UObject* Object)
        : SafeObject(Object)
    {
    }

    void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(SafeObject);
    }
};

꼭 필요해서 가비지 컬렉팅되지 않도록 하려는 오브젝트에는 FReferenceCollector 를 사용하여 강 레퍼런스를 수동 추가해 줍니다. 그 오브젝트가 삭제되어 소멸자가 실행될 때, 그 오브젝트는 추가한 모든 레퍼런스를 자동으로 지웁니다.

클래스 이름 접두사

언리얼 엔진에서는 빌드 프로세스 도중 코드를 생성하는 툴을 제공합니다. 이러한 툴에는 클래스 이름에 기대하는 작명 규칙이 몇 가지 있는데, 이름이 그 기대와 일치하지 않을 경우 경고나 오류가 나게 됩니다. 툴에서 기대하는 클래스 접두사에 대한 상세 설명은 아래 목록과 같습니다.

  • Actor (액터) 파생 클래스 접두사는 A 입니다. 예: AController.

  • Object (오브젝트) 파생 클래스 접두사는 U 입니다. 예: UComponent.

  • Enum (열거형) 접두사는 E 입니다. 예: EFortificationType.

  • Interface (인터페이스) 클래스 접두사는 보통 I 입니다. 예: IAbilitySystemInterface.

  • Template (템플릿) 클래스 접두사는 T 입니다. 예: TArray.

  • SWidget (슬레이트 UI) 파생 클래스 접두사는 S 입니다. 예: SButton.

  • 그 외의 접두사는 letter F 입니다. 예: FVector.

숫자 유형

short, int, long 와 같은 기본적인 유형도 플랫폼마다 크기가 각기 다를 수 있기에, UE4 에서는 대안으로 사용해야 하는 유형을 다음과 같이 제공하고 있습니다:

  • int8/uint8 : 8 비트 부호 있는/없는 정수형

  • int16/uint16 : 16 비트 부호 있는/없는 정수형

  • int32/uint32 : 32 비트 부호 있는/없는 정수형

  • int64/uint64 : 64 비트 부호 있는/없는 정수형

부동 소수점 역시 표준 float (32 비트) 및 double (64 비트) 유형으로 지원됩니다.

언리얼 엔진에서는 TNumericLimits<t> 템플릿으로 어떤 값 유형이 저장할 수 있는 최소 최대 값 범위를 알아낼 수 있습니다. 자세한 벙보는 API reference 를 참고하시기 바랍니다.

문자열

UE4 에는 string (문자열) 작업 관련해서 필요에 따라 쓸 수 있는 여러가지 클래스가 제공됩니다.

스트링 처리

FString

FString 가변(mutable) 문자열로, std::string 과 유사합니다. FString 에는 스트링 작업을 수월하게 하기 위한 대규모의 메서드 모음이 들어있습니다. 새로운 FString 생성을 위해서는, TEXT() 매크로를 사용합니다:

FString MyStr = TEXT("Hello, Unreal 4!").

Full Topic: FString API

FText

FText 는 FString 과 유사하나, 현지화 텍스트 용입니다. 새로운 FText 생성을 위해서는, NSLOCTEXT 매크로를 사용합니다. 이 매크로는 네임스페이스, 키, 기본 언어 값을 받습니다:

FText MyText = NSLOCTEXT("Game UI", "Health Warning Message", "Low Health!")

LOCTEXT 매크로를 사용하면, 네임스페이스를 파일 당 하나만 정의해도 됩니다. 파일 끝에서는 반드시 정의를 해제해 줘야 합니다.

// In GameUI.cpp
#define LOCTEXT_NAMESPACE "Game UI"

//...
FText MyText = LOCTEXT("Health Warning Message", "Low Health!")
//...

#undef LOCTEXT_NAMESPACE
// End of file

Full Topic: FText API

FName

FName 은 자주 반복되는 문자열을 식별자로 저장하여 그에 대한 비교를 할 때 메모리와 CPU 시간을 절약하는 데 쓰입니다. 이를 레퍼런싱하는 모든 오브젝트에 전체 문자열을 매번 반복 저장하기 보다, FName 은 보다 작은 저장 용량을 차지하는 Index 를 사용하여 주어진 문자열에 매핑시킵니다. 그러면 문자열 내용을 한 번만 저장하여, 여러 오브젝트에 사용할 때의 메모리를 절약합니다. NameA.IndexNameB.Index 와 같은지 검사해 보면 그 문자열 안의 글자를 하나하나 검사할 필요 없이 두 문자열을 빠르게 비교할 수 있습니다.

Full Topic: FName API

TCHAR

TCHAR 는 플랫폼마다 다를 수 있는 캐릭터 세트와 무관하게 캐릭터를 저장하기 위한 방편으로 사용됩니다. 내부적으로, UE4 문자열은 TCHAR 배열을 사용하여 UTF-16 인코딩으로 데이터를 저장합니다. TCHAR 를 반환하는 레퍼런스 해제 오버로드 연산자를 사용하여 원(raw) 데이터에 접근할 수 있습니다.

캐릭터 인코딩

FString::Printf 처럼 이것이 꼭 필요한 함수가 있는데, %s 스트링 포맷 지정자가 FString 대신 TCHAR 를 기대하는 경우입니다.

FString Str1 = TEXT("World");
int32 Val1 = 123;
FString Str2 = FString::Printf(TEXT("Hello, %s! You have %i points."), *Str1, Val1);

FChar 유형에는 개별 TCHAR 작업을 위한 스태틱 유틸리티 함수 세트가 제공됩니다.

TCHAR Upper('A');
TCHAR Lower = FChar::ToLower(Upper); // 'a'

FChar 유형은 TChar<TCHAR> 로 정의됩니다 (API 에 나열된 대로입니다).

Full Topic: TChar API

컨테이너

Container (컨테이너)는 그 주요 함수가 데이터 모음을 저장하는 데 쓰이는 클래스입니다. 이 클래스 중 가장 일반적인 것은 TArray, TMap, TSet 입니다. 이들 각각은 크기가 동적으로 변하며, 원하는 만큼 확장됩니다.

Full Topic: Containers API

TArray

세 가지 컨테이너 중 언리얼 엔진 4 에서 가장 많이 쓰이는 컨테이너는 TArray (배열)인데, std::vector 와 매우 유사한 방식으로 작동하나, 훨씬 많은 함수 기능이 제공됩니다. 일반적인 것 몇 가지는 다음과 같습니다:

TArray<AActor*> ActorArray = GetActorArrayFromSomewhere();

// ActorArray 에 현재 (AActor) 요소가 몇 개나 저장되어 있는지 알려줍니다.
int32 ArraySize = ActorArray.Num();

// TArray 는 0 부터 시작합니다 (첫 번째 요소의 인덱스는 0 이 됩니다).
int32 Index = 0;
// 주어진 인덱스의 요소 값 구하기를 시도합니다.
TArray* FirstActor = ActorArray[Index];

// 배열 끝에 새 요소를 추가합니다.
AActor* NewActor = GetNewActor();
ActorArray.Add(NewActor);

// 이미 배열 끝이 아닐 경우에만 배열 끝에 요소를 추가합니다.
ActorArray.AddUnique(NewActor); // Won't change the array because NewActor was already added

// 배열에서 NewActor 인스턴스를 전부 제거합니다.
ActorArray.Remove(NewActor);

// 지정된 인덱스의 요소를 제거합니다.
// 그 윗 번호 인덱스의 요소는 하나씩 내려 빈 공간을 채웁니다.
ActorArray.RemoveAt(Index);

// RemoveAt 의 보다 효율적인 버전으로, 요소의 순서는 유지하지 않습니다.
ActorArray.RemoveAtSwap(Index);

// 배열의 모든 요소를 제거합니다.
ActorArray.Empty();

TArray 는 그 요소를 가비지 컬렉션 시킬 때 부가적인 혜택이 있습니다. 여기에는 TArray 가 UPROPERTY 마킹되어 있고, UObject 파생 포인터를 저장한다 가정합니다.

UCLASS()
class UMyClass : UObject
{
    GENERATED_BODY();

    // ...

    UPROPERTY()
    TArray<AActor*> GarbageCollectedArray;
};

가비지 컬렉션에 대해서는 나중에 심도있게 다루도록 하겠습니다.

TArray: 언리얼 엔진의 배열

Full Topic: TArray API

TMap

TMap (맵)은 키-값의 짝으로 된 모음으로, std::map 과 비슷합니다. TMap 에는 키를 가지고 요소를 검색, 추가, 제거하기 위한 빠른 메서드가 있습니다. 키에는 GetTypeHash 함수가 정의되어 있기만 하다면, 어떤 유형이든 사용할 수 있는데, 그에 대해서는 나중에 자세히 다루겠습니다.

바둑판식 보드 게임을 만들려는데, 어느 칸에 어떤 조각이 있는지 저장하고 질의할 필요가 있다 가정합시다. 그 작업은 TMap 으로 쉽게 할 수 있습니다. 보드 크기가 작고 크기가 항상 같다면, 분명 더욱 효율적인 방법이 있을테지만, 예제삼아 그냥 가 보도록 합시다!

enum class EPieceType
{
    King,
    Queen,
    Rook,
    Bishop,
    Knight,
    Pawn
};

struct FPiece
{
    int32 PlayerId;
    EPieceType Type;
    FIntPoint Position;

    FPiece(int32 InPlayerId, EPieceType InType, FIntVector InPosition) :
        PlayerId(InPlayerId),
        Type(InType),
        Position(InPosition)
    {
    }
};

class FBoard
{
private:

    // TMap 을 사용하여 각 조각을 위치로 레퍼런싱할 수 있습니다.
    TMap<FIntPoint, FPiece> Data;

public:
    bool HasPieceAtPosition(FIntPoint Position)
    {
        return Data.Contains(Position);
    }
    FPiece GetPieceAtPosition(FIntPoint Position)
    {
        return Data[Position];
    }

    void AddNewPiece(int32 PlayerId, EPieceType Type, FIntPoint Position)
    {
        FPiece NewPiece(PlayerId, Type, Position);
        Data.Add(Position, NewPiece);
    }

    void MovePiece(FIntPoint OldPosition, FIntPoint NewPosition)
    {
        FPiece Piece = Data[OldPosition];
        Piece.Position = NewPosition;
        Data.Remove(OldPosition);
        Data.Add(NewPosition, Piece);
    }

    void RemovePieceAtPosition(FIntPoint Position)
    {
        Data.Remove(Position);
    }

    void ClearBoard()
    {
        Data.Empty();
    }
};

TMap

Full Topic: TMap API

TSet

TSet (세트)는 고유한 값의 집합을 저장하며, std::set 와 유사합니다. AddUniqueContains 메서드를 통해 TArray 역시 세트로 사용할 수 있습니다. 하지만 TSet 의 해당 작업에 대한 구현은 더욱 빨라서, TArray 처럼 UPROPERTY 로 사용하기는 불가능할 비용으로 처리할 수 있습니다. TSet 는 요소 인덱스를 매기는 방식도 TArray 와 다릅니다.

TSet<AActor*> ActorSet = GetActorSetFromSomewhere();

int32 Size = ActorSet.Num();

// 세트에 요소를 들어있지 않은 경우 추가합니다.
AActor* NewActor = GetNewActor();
ActorSet.Add(NewActor);

// 세트에 요소가 이미 들어있는지 검사합니다.
if (ActorSet.Contains(NewActor))
{
    // ...
}

// 세트에서 요소를 제거합니다.
ActorSet.Remove(NewActor);

// 세트에서 모든 요소를 제거합니다.
ActorSet.Empty();

// TSet 요소를 포함하는 TArray 를 생성합니다.
TArray<AActor*> ActorArrayFromSet = ActorSet.Array();

Full Topic: TSet API

기억하실 것은, 현재 UPROPERTY 마킹이 가능한 유일한 컨테이너 클래스는 TArray 입니다. 즉 다른 컨테이너 클래스는 리플리케이션이나 저장, 그 요소의 가비지 컬렉션이 가능하지 않다는 뜻입니다.

컨테이너 이터레이터

이터레이터를 사용하면 컨테이너의 각 요소를 대상으로 반복 처리할 수 있습니다. TSet 를 사용할 때의 이터레이터 문법이 어때 보이는지, 예제는 다음과 같습니다.

void RemoveDeadEnemies(TSet<AEnemy*>& EnemySet)
{
    // 세트 첫 위치부터 시작하여 세트 끝까지를 대상으로 반복처리합니다.
    for (auto EnemyIterator = EnemySet.CreateIterator(); EnemyIterator; ++EnemyIterator)
    {
        // * 연산자가 현재 요소를 구해옵니다.
        AEnemy* Enemy = *EnemyIterator;
        if (Enemy.Health == 0)
        {
            // TSet 와 TMap 에는 RemoveCurrent 가 지원됩니다.
            EnemyIterator.RemoveCurrent();
        }
    }
}

이터레이터와 사용할 수 있도록 지원되는 다른 연산자는 다음과 같습니다:

// 이터레이터를 한 요소 뒤로 이동시킵니다.
--EnemyIterator;

// 이터레이터를 일정 오프셋만큼 이동시킵니다. 오프셋은 정수입니다.
EnemyIterator += Offset;
EnemyIterator -= Offset;

// 현재 요소의 인덱스를 구합니다.
int32 Index = EnemyIterator.GetIndex();

// 이터레이터를 첫 요소로 리셋시킵니다.
EnemyIterator.Reset();

For-each 루프

이터레이터는 다 좋은데 각 요소에 대해 한 번만 반복처리하고자 할 경우에는 약간 번거로울 수 있습니다. 각 컨테이너 클래스에는 각기 다른 스타일로 요소에 대한 반복 처리를 할 수 있도록 하는 문법 역시도 지원됩니다. TArray 및 TSet 는 각 요소를 반환하는 반면, TMap 은 키-값 짝을 반환합니다.

// TArray
TArray<AActor*> ActorArray = GetArrayFromSomewhere();
for (AActor* OneActor : ActorArray)
{
    // ...
}

// TSet - TArray 와 같습니다.
TSet<AActor*> ActorSet = GetSetFromSomewhere();
for (AActor* UniqueActor : ActorSet)
{
    // ...
}

// TMap - 이터레이터가 키-값 짝을 반환합니다.
TMap<FName, AActor*> NameToActorMap = GetMapFromSomewhere();
for (auto& KVP : NameToActorMap)
{
    FName Name = KVP.Key;
    AActor* Actor = KVP.Value;

    // ...
}

auto 키워드는 자동으로 포인터/레퍼런스를 지정해 주지 않는다는 점, 직접 추가해 줘야 한다는 점 기억해 주세요.

TSet/TMap 에 별도 유형 사용 (해시 함수)

TSet 와 TMap 내부적으로는 hash (해시 함수)를 사용해야 합니다. 별도의 클래스를 만들었는데 그것을 TSet 에서 또는 TMap 에의 키로 사용하고자 하는 경우, 별도의 해시 함수를 먼저 만들어 줘야 합니다. 이러한 유형에 일반적으로 넣으려는 대부분의 UE4 유형은 이미 별도의 해시 함수를 정의하고 있습니다.

해시 함수는 해당 유형으로의 const 포인터/레퍼런스를 받아 uint64 를 반환합니다. 이 반환 값을 일컬어 오브젝트에 대한 hash code (해시 코드)라 하며, 해당 오브젝트에 거의(pseudo) 고유한 수치일 것입니다. 동등한 두 오브젝트는 항상 같은 해시 코드를 반환할 것입니다.

class FMyClass
{
    uint32 ExampleProperty1;
    uint32 ExampleProperty2;

    // 해시 함수입니다.
    friend uint32 GetTypeHash(const FMyClass& MyClass)
    {
        // HashCombine 은 두 해시 값을 합치는 유틸리티 함수입니다.
        uint32 HashCode = HashCombine(MyClass.ExampleProperty1, MyClass.ExampleProperty2);
        return HashCode;
    }

    // 데모 목적상, 두 오브젝트가 같으므로 
    // 항상 같은 해시 코드를 반환할 것입니다.
    bool operator==(const FMyClass& LHS, const FMyClass& RHS)
    {
        return LHS.ExampleProperty1 == RHS.ExampleProperty1
            && LHS.ExampleProperty2 == RHS.ExampleProperty2;
    }
};

이제 TSet<FMyClass> 와 TMap<FMyClass, ...> 는 적절한 해시 함수를 사용하여 키를 해싱할 것입니다. 포인터를 키로 사용하는 경우 (예: TSet<FMyClass*>) uint32 GetTypeHash(const FMyClass* MyClass) 구현도 해줘야 합니다.

블로그 게시물: 알아두면 좋을 언리얼 엔진 4 라이브러리