KoreanFoodie's Study

언리얼 엔진 개념 간단 정리 1부 (이득우님 블로그 리뷰) 본문

Game Dev/Unreal C++ : Study

언리얼 엔진 개념 간단 정리 1부 (이득우님 블로그 리뷰)

GoldGiver 2022. 10. 5. 00:26

언리얼 엔진의 개념과 동작 원리 복습

이득우님의 블로그를 보며, 배운 내용을 간단히 정리해보려 한다! 언리얼을 처음 시작한다면, 이득우님이 쓰신 책인 "이득우의 언리얼 C++ 개임 개발의 정석" 을 꼭 읽어보는 것을 추천한다! 이 블로그에도 강좌 내용을 정리해 놓았지만, 실제로 읽어보는 것을 적극 추천한다.

 

1. 언리얼 엔진도 결국 모듈의 집합이다.

게임을 만들면서, 개발자는 새로운 기능을 계속 추가하게 되는데, 이것을 사용자 게임 모듈이라고 한다. 구조는 다음과 같다.

 

 

2. 언리얼 엔진은 크로스 플랫폼(Windows 와 MacOS) 을 지원한다.

Config, Content, Plugins, Source, .uproject 만 있으면 "Generate Visual Studio project files" 등의 명령어로 프로젝트를 재구성할 수 있다. 이때 사용하는 것이 언리얼 빌드 툴(UBT)이다.

MyGameName.Target.cs 파일에 보면(MyGameName 은 말 그대로 그냥 게임 이름의 제목을 나타내는 예시 제목이다), Editor 의 Type 은 Editor 로 되어 있고, Game 은 Type 이 Game 으로 되어 있는데, 이 항목이 빌드의 구성을 결정하는 중요한 설정값이다. 정리하면, 언리얼 엔진 빌드 구조는 아래와 같다.

 

3. 언리얼에서 모듈 추가하기

언리얼에서는 게임 로직을 위한 모듈(보통 주 게임 모듈; Primary Game Module) 이외에도 쉽게 모듈을 추가할 수 있다.

모듈 생성을 위해 필요한 세 가지 요소

자세한 방법은 해당 링크를 참고하자.

 

 

4. 언리얼 오브젝트의 이해

언리얼 오브젝트는 언리얼 엔진의 관리를 받는 특수한 객체이다. 물론 일반 C++ 객체도 사용할 수 있지만, 언리얼 오브젝트를 사용하면 다음과 같은 이점을 얻을 수 있다. 언리얼 오브젝트는 U 로, 일반 C++ 오브젝트는 F 로 시작하는 것이 관례이다.

언리얼 오브젝트의 선언은 언리얼 헤더 툴(Unreal Header Tool) 을 통해 파싱된다.

언리얼 엔진에서 GameInstance 는 앱을 관리하는데 사용된다. 게임을 시작하면 엔진을 초기화하고 가장 먼저 실행되는 오브젝트이며, 프로그램이 종료될 때 가장 마지막에 소멸된다. GameInstance 오브젝트는 초기화될 때 init 함수를 호출하는데, 이를 상속받으면 앱의 초기화 루틴을 만들 수 있다.

 

 

5. 클래스 기본 객체 (Class Default Object)

하나의 언리얼 오브젝트가 만들어지기 위해서는 실제 컴파일 전에 언리얼 헤더 툴에 의해 헤더 파일을 분석하는 과정이 선행되며, 이 과정이 완료되면 intermediate 폴더에 언리얼 오브젝트의 정보를 담은 메타 파일이 생성된다.

컴파일 전에 메타 소스 파일과 헤더 파일을 생성하는 목적은 여러가지가 있지만, 기존의 C++ 문법에서 제공하지 못하는 런타임에서의 빠른 클래스 정보의 검색이 그 이유 중 하나이다. 이 메타 정보는 UClass 라는 특별한 클래스를 통해 보관되며, UClass 에는 클래스 계층 구조 정보와 멤버 변수, 함수에 대한 정보가 모두 기록된다. 런타임에서 특정 클래스를 검색해 멤버 변수 값을 변경하거나 특정 인스턴스의 멤버 함수를 호출하는 것도 가능하다.

이와 유사한 기능을 리플렉션(Reflection) 이라고 한다. C++ 에서는 이런 기능이 없어, 언리얼 엔진이 자체적으로 프레임웍을 만들어 제공한다고 이해하면 된다.

컴파일 단계에서 언리얼 오브젝트마다 UClass가 생성된다면, 실행 초기의 런타임 과정에서는 언리얼 오브젝트마다 클래스 정보와 함께 언리얼 오브젝트의 인스턴스가 생성된다. 이 특별한 인스턴스는 언리얼 오브젝트의 기본 세팅을 지정하는데 사용되는데, 이를 클래스 기본 객체 ( Class Default Object ) 줄여서 CDO라고 한다.

다음 토막글은 블로그에서 그대로 발췌한 내용이다.

언리얼 엔진에서 CDO를 만드는 이유는 언리얼 오브젝트를 생성할 때마다 매번 초기화 시키지 않고, 기본 인스턴스를 미리 만들어 놓고 복제하는 방식으로 메커니즘이 구성되어 있기 때문이다. 지금 우리가 실습하는 단순한 언리얼 오브젝트라면 이러한 복제 과정이 불필요할 수도 있지만, 하나의 언리얼 오브젝트가, 예를 들어 복잡한 기능을 수행하는 캐릭터까지 담당할 정도로 기능이 확장되면, 굉장히 큰 덩어리의 객체로 커질 수 있다. 만일 게임 실행 중, 런타임에서 이 캐릭터를 한번에 100명을 스폰시킨다고 가정 해보자. 캐릭터를 하나씩 처음부터 생성하고 초기화시키는 방법보다, 미리 큰 기본 객체 덩어리를 복제한 후에 속성 값만 변경하는 방법이 보다 효과일 것이다!

다시 정리하자면 하나의 언리얼 오브젝트가 초기화 될 때에는 두 개의 인스턴스가 항상 생성된다. 아래는 이를 정리한 도식이다.

언리얼 오브젝트 클래스에서 생성자는 특별한 역할을 가지는데, 언리얼 오브젝트의 생성자는 인스턴스를 초기화해 CDO를 제작하기 위한 목적으로 사용된다. 이 생성자 코드는 초기화에서만 실행되고 실제 게임 플레이에서 생성자 코드는 사용할 일이 없다고 보면 된다(참고로 언리얼 엔진에서 게임 플레이에서 사용할 초기화 함수는 생성자 대신 Init 이나 혹은 BeginPlay 함수를 제공한다).

모듈 내 언리얼 오브젝트의 로딩은 아래 그림과 같은 순서로 진행된다.

 A라는 언리얼 오브젝트가 초기화를 위해 B라는 언리얼 오브젝트를 생성할 때 B는 A의 서브오브젝트(Subobject)라고 한다. B의 외부 참조(Outer)는 A가 된다. 따라서 생성자 코드에서 언리얼 오브젝트의 인스턴스를 생성하고 관리하고자 한다면 언리얼 엔진이 제공하는 API인 CreateDefaultSubobject라는 API를 쓰는 것이 좋다. 

WebConnection = CreateDefaultSubobject<UWebConnection>(TEXT("MyWebConnection"));

CreateDefaultSubobject 함수에서 사용하는 첫 번째 문자열 인자는 서브오브젝트를 관리하기 위한 내부 해시(Hash)값을 생성하는데 사용한다. 따라서 아무 문자열 값을 사용해도 무방하지만, 다른 서브오브젝트를 생성할 때 이전에 사용한 값을 사용하면 안된다.

한 모듈이 다른 모듈을 참조하게 만들고 싶다면, 이득우님 블로그 글을 참고하자!

 

 

6. UClass 와 리플렉션

UClass 정보는 컴파일타임이든 런타임이든 원할 때 아무 때나 가져올 수 있다. 컴파일타임에서는 언리얼 오브젝트 선언에 있는 StaticClass를 사용하고, 런타임에서는 언리얼 오브젝트 인스턴스에서 GetClass( )함수를 사용해 가져올 수 있다(참고로 우리는 StaticClass함수를 선언한 적이 없지만 이 함수는 언리얼 헤더툴에 의해 자동으로 생성됨).

// 생성자에서 UWebConnection 타입의 WebConnection CDO 생성
WebConnection = CreateDefaultSubobject<UWebConnection>(TEXT("MyWebConnection"));

...

UClass* ClassInfo1 = WebConnection->GetClass();
UClass* ClassInfo2 = UWebConnection::StaticClass();

// 위의 두 포인터의 값은 동일한 UClass 객체를 가리킨다
if (ClassInfo1 == ClassInfo2)
{
    AB_LOG(Warning, TEXT("ClassInfo1 is same with ClassInfo2"));
}

리플렉션을 이렇게 이해하면 될 것 같다. 예를 들어, A 클래스 타입의 객체 a 가 있다. C++ 에서는 a 의 멤버 값을 바꾸어 주려면 a 의 주소나 포인터를 알고 있어야 했다. 그런데 함수의 인자로 주어지거나, 특정 타입(예를 들어 클래스가 A 인 녀석)만 골라내서 값을 변경하거나 함수를 호출하려면, 타입 체킹이 컴파일 타임에 이루어져야 했다.

언리얼에서는 리플렉션을 사용하기 때문에, 컴파일 타임에서 만들어낸 클래스 메타 정보를 런타임에서 활용하게 된다. 예를 들어 객체의 타입 검색과 멤버 변수값 수정 및 함수 호출이 가능해진 것이다.

 

 

7. 언리얼 오브젝트의 계층 구조

한 언리얼 오브젝트(A)의 생성자 내에서 다른 언리얼 오브젝트(B)를 생성하면(DefaultSubobject), B 는 A 의 SubObject 가 된다. 아래 그림은 A 를 ABGameInstance, B 를 WebConnection 에 대응시킨 그림이다.

그런데, WebConnection 객체를 생성자에서 CreateDefaultSubobject 를 통해 생성했다고 가정하고, WebConnectionNew 객체를 Init 함수에서 런타임에 NewObject 로 생성했다고 가정하자.

// 이 문장은 생성자에서...
WebConnection = CreateDefaultSubobject<UWebConnection>(TEXT("MyWebConnection"));

// 이 문장은 Init 함수(런타임) 에서...
WebConnectionNew = NewObject<UWebConnection>(this);

그럼 언리얼 오브젝트들은 다음과 같은 관계를 맺게 된다.

아래 문단들은 토막글은 이득우님 블로그 글에서 그대로 발췌한 것이다.

그러면 기존의 WebConnection과 새로운 WebConnectionNew와의 차이는 무엇일까?

사실 사용에 있어서 두 객체간의 차이는 없다고 보면 된다. 다만 생성방식에서만 차이가 있는데, 전자인 WebConnection은 ABGameInstance의 인스턴스가 생성되면 자동으로 따라 생성되는 반면, 후자인 WebConnectionNew는 게임 플레이 런타임에서 우리가 직접 생성해줘야 한다.

전자의 방식을 스태틱(Static)하게 생성한다고 표현하겠다. 이는 기획상으로 변경될 여지가 거의 없는 오브젝트 묶음을 한번에 빠르게 생성할 때 유용하다. 

후자의 방식을 다이나믹(Dynamic)하게 생성한다고 표현하겠다. 후자 방식은 느리지만 세팅에 따라 다양한 오브젝트를 생성할 수 있어서 유연하게 상황에 대처할 수 있다는 장점이 있다.

언리얼에서 게임 컨텐츠 구조는 ABGameInstance와 WebConnection간의 관계와 동일하게 부모-자식 관계가 확장된 구조를 따른다.

언리얼에서 모든 게임 컨텐츠의 기초은 월드에서부터 시작된다. 월드에는 최소 하나의 레벨이 지정되는데, 이를 퍼시스턴트 레벨(Persistent Level)이라고 한다. 이 퍼시스턴트 레벨에는 추가로 월드의 초기 설정 값들을 지정한 월드세팅 언리얼 오브젝트가 있다. 기본 생성된 월드에는 레벨을 실시간으로 추가할 수 있는데, 월드에 무관하게 추가/삭제가 가능하도록 설계된 레벨을 스트리밍 레벨(Streaming Level)이라고 한다. 이러한 레벨은 월드에서 독립적으로 행동하는 단위 오브젝트인 액터(Actor)의 묶음으로 구성되어 있다. 이 액터들은 다시 컴포넌트들로 구성되어 있다. 지금까지 설명한 월드의 계층 구성은 아래와 같이 정리할 수 있다.

이렇게 생성된 계층 구조는 설계에 따라 기본서브오브젝트 방식으로 관리할지, 런타임에 실시간으로 생성해 관리할지 방식이 나뉘어진다.

FPS 게임을 만든다고 했을 때, 월드 세팅은 게임의 룰(데스매치, 깃발 뺏기 등)에 해당하므로, 스태틱 방식으로 만들면 월드세팅마다 레벨이 하나씩 만들어져, 중복된 맵으로 인해 용량의 낭비가 심할 것이다. 그래서 월드에서 월드세팅과 게임모드는 다이나믹 방식으로 설계되어 있다.

반면 액터는 스태틱하게 컴포넌트와 함께 생성하고 소멸하는 것이 효과적이다. 아래는 이를 정리한 도식이다.

 

월드내 오브젝트 생성 방식의 구분 ( 파란색이 다이나믹 방식, 붉은색이 스태틱 방식 )

Comments