이번 강좌에서는 드디어 언리얼 엔진의 게임 컨텐츠 핵심이라 할 수 있는 액터(Actor)를 제작해보겠습니다.
언리얼 엔진 메뉴얼에서는 액터를"레벨에 배치하거나 스폰시킬 수 있는 기본 객체"라고 설명합니다.
저 개인적으로는 이를 좀 더 보강해서 월드에 존재하면서, 정해진 기능에 따라 자신의 역할을 수행하는 기본 객체라고 액터를 정의하고 싶습니다.
트랜스폼
월드에서 존재한다는 이야기는 3차원 가상 공간에서 항상 자신의 위치, 방향과 크기가 지정되어 있다는 뜻입니다. 일반적으로 많은 3D 소프트웨어에서는 이를 구현하기위해 위치,회전,스케일 정보를 묶은트랜스폼(Transform)이라는 정보를 사용합니다.
우리가 사용하는 언리얼 엔진도 모든 액터에 트랜스폼 정보가 들어있습니다. 월드 아웃라이너에서 어떤 액터를 선택하더라도 에디터의 디테일 뷰에 항상 트랜스폼 정보가 가장 먼저 지정되어 있음을 확인할 수 있습니다.
액터의 제작
단순하게 이야기하자면 액터는 월드 트랜스폼이 있는 언리얼 오브젝트라고 할 수 있겠습니다.
C++로 액터를 만들어봅시다. Actor를 상속받은 클래스를 하나 제작하고 이름을 Weapon이라고 만들겠습니다. 지금까지 하던 방식대로 [파일 > 새로운클래스 생성] 메뉴를 선택하고 부모클래스를 액터(Actor)로 지정합시다.
자동 생성된 코드를 살펴보면, 아래와 같이 클래스 이름이 UWeapon이 아닌 AWeapon이 되는 것을 확인할 수 있습니다.
※언리얼 오브젝트의 접두사는 U, A 그리고 S가 있습니다. - 액터 기반이라면 A를 - 액터 기반이 아니라면 모두 U - UI를 담당하는 슬레이트만 S를 사용하면 됩니다.
언리얼 엔진에서 액터를 상속받는 클래스는 모두 A라는 접두사를 사용하도록 설계되어 있습니다.
게임을 프로그래밍 프레임웍의 관점에서 확장해 컨텐츠 제작의 관점으로 본다면, 게임 컨텐츠는 월드에 존재하는 물체간의 상호작용이라고 할 수 있습니다.
액터는 월드에 존재하는 물체의 기본 단위이기 때문에, 게임 컨텐츠의 설계는 액터에서부터 시작한다고 할 수 있습니다. 그래서 언리얼 엔진이 액터 클래스의 정의에 U접두사를 사용하지 않고, A접두사를 사용하는 이유는, 컨텐츠 제작에 기여하는 오브젝트들을 묶어서 관리하기가 용이하기 때문입니다.
액터가 가지는 또 다른 특징은 제작자가 액터에 부여한 기능입니다. 언리얼 엔진은 액터에 기능을 부여할 수 있도록, 엔진의 기능을 잘 모듈화하여 컴포넌트란 이름으로 포장을 해서 제공을 합니다. 제작자는 액터를 설계할 때 언리얼 엔진이 제공하는 컴포넌트를 골라 부착해, 액터의 기능을 지정할 수 있습니다. ( 물론 제작자가 컴포넌트를 직접 제작하는 것도 가능합니다. )
블루프린트 액터 제작
우리가 제작할 무기 액터는 우선 무기의 시각적 요소를 보여줘야하는 기능이 필요합니다. 이를 위해서 언리얼 엔진이 제공하는 스켈레탈메시 컴포넌트를 사용하면 됩니다. 이해를 돕기 위해 먼저 블루프린트로 무기를 제작하고, 동일하게 C++로 만들겠습니다.
무기를 시각적으로 표현하기 위해 무료 애셋을 다운로드 받읍시다. 언리얼 마켓플레이스에서 Infinity라고 검색한 후 아래의 Infinity Blade Weapons 애셋을 현재 프로젝트로 추가로 임포트 시키겠습니다. 콘텐츠 임포트가 완료되면 여러분 프로젝트의 콘텐츠 브라우저에 InfinityBladeWeapons라는 폴더가 생성됩니다.
이제 생성한 BP_Weapon 블루프린트를 더블클릭합시다. 좌측 상단에 있는 컴포넌트 추가 메뉴를 누른 후, 스켈레탈 메시(Skeletal Mesh) 컴포넌트를 선택해 추가하고, 컴포넌트의 이름을 Weapon으로 변경합시다. 그리고 생성된 스켈레탈 메시 컴포넌트를 최상단에 있는 DefaultSceneRoot 컴포넌트에 드래그하여 루트 컴포넌트(Root Component)로 설정합시다.
이제 스켈레탈 메시 컴포넌트를 선택하고 오른쪽의 디테일 윈도우의 Mesh 섹션에서 Skeletal Mesh 애셋을 SK_Blade_Blackknight로 지정합시다. ( 사실 다른 무기 애셋을 선택해도 강좌 진행에는 지장이 없습니다. ) 완료되면 컴파일을 눌러 완료합시다.
최종 블루프린트 무기 액터의 설정은 아래와 같습니다.
블루프린트 작업을 정리하면, 우리는 무기 역할을 수행할 액터에서 무기를 비주얼적으로 보여주기 위해, 스켈레탈 메시 컴포넌트를 사용하였습니다.
( 애니메이션이 없는 칼임에도 스태틱 메시 컴포넌트를 사용하지 않고 스켈레탈 메시 컴포넌트를 사용한 이유는 활과 같은 다른 무기에 애니메이션이 있을 수 있기 때문입니다. )
그리고 스켈레탈 메시 컴포넌트를 액터의 움직임이나 충돌 기능을 대표하도록 루트 컴포넌트로 설정했습니다.
완성된 BP_Weapon 블루프린트를 레벨에 드래그하면 액터 인스턴스가 현재 레벨에 생성됩니다. 배치된 액터는 플레이 버튼을 누르지 않아도 이미 액터에 컴포넌트 인스턴스가 생성되어 있음을 확인할 수 있습니다.
이처럼 액터를 제작할 때에는, 생성 시점에 관련 컴포넌트들도 함께 생성되는 방식으로 동작합니다. 블루프린트도 앞서서 설명한 C++의 CDO메카니즘과 유사하게 동작한다고 이해하시면 되겠습니다.
이렇게 액터를 정의한 블루프린트의 설계도를 다른 말로는 아키타입(Archetype)이라고 합니다.
C++ 액터 제작
(위에 만든 AWepon을 계속 이용하여 제작함)
블루프린트 제작이 완료되었으니, BP_Weapon 블루프린트와 동일하게 C++ 클래스인 AWeapon를 만들어봅시다.
먼저 C++ 코드에서 스켈레탈메시 컴포넌트를 선언해봅시다. 스켈레탈 메시 컴포넌트를 추가하려면 이 컴포넌트의 클래스 이름을 알아야 할텐데요, 아래와 같이 블루프린트에서 컴포넌트를 우클릭해 헤더를 열면 쉽게 파악할 수 있습니다.
컴포넌트는 액터와 달리 월드에서 자립할 수 없기 때문에 U접두사를 사용하며, 소스 파일을 연 후, 비주얼 스튜디오에서 U로 시작하는 클래스를 찾으면 쉽게 확인할 수 있습니다.
이제 프로젝트에서 AWeapon 클래스에 아래와 같이 변수를 하나 추가합시다
//AWeapon.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Weapon.generated.h"
UCLASS()
class ABC_API AWeapon : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AWeapon();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
//추가됨
UPROPERTY()
class USkeletalMeshComponent* Weapon;
};
헤더 선언이 완료되면 이제 구현부에 가서 생성자 코드에
CreateDefaultSubobject API를 사용해 스켈레탈 메시 컴포넌트를 생성해줍시다.
NewObject가 아닌 CreateDefaultSubobject를 사용하는 이유에 대해서는 이전 강에서 설명을 드렸습니다.
모든 액터는 컴포넌트를 항상 기본 탑재하게 되므로 게임플레이 런타임에서 컴포넌트를 생성하기보다는 (동적생성) CDO 생성시에 포함시키는 방식이 유리하다고 설명드렸습니다. (정적생성)
먼저 ConstructorHelpers 클래스는 말 그대로 생성자(Constructor)에서만 사용이 가능합니다.
즉 CDO 제작에만 사용된다는 이야기지요. 만일 게임플레이 런타임에서 애셋을 로딩하기 위해서는 ConstructorHelper가 아닌 StaticLoadObject와 같은 다른 API를 사용하여야 합니다.
이렇게 애셋을 로딩 시점을 1. 엔진 초기화 런타임과 2. 게임플레이 런타임으로 구분하는 이유는, 안전을 위해서입니다.
일반적으로 컨텐츠에서 사용하는 애셋들은 엔진 초기화 시점에서 우리가 사용할 애셋이 확실히 존재하는지 검증하고 다음 단계를 진행하는 것이 안전합니다.
만일 런타임에서 애셋을 로딩하게 된다면 예기치 않은 문제들이 발생할 수 있습니다. 예를 들어 우리가 제작한 무기를 게임 플레이 런타임에서 로딩한다면, 캐릭터가 등장하고 열심히 탐험하고 몬스터 공격을 위해 칼을 빼들 때야 비로서 로딩을 시작하겠지요. 그런데 이 때 칼 애셋이 없는 경우에는 잘못해서 콘텐츠가 크래시가 일어날 수 있습니다.
불러들이는 애셋의 경로는 특별한 이유가 없다면 로딩된 이후에변경될 일이 없을테니, 사용할 애셋들은 ConstructorHelpers 클래스를 사용해 애셋의 유무를 미리 확인하고로딩하는 것이 좋습니다. (게임플레이런타임에서 애셋을 로딩하는 기능은 이후 강좌에서 다룰 예정입니다.)
ConstructorHelpers 클래스를 사용해 제작한 변수에는 static 키워드를 앞에 붙였습니다.
이 구문은스태틱 키워드를사용하지 않아도 동작에는무방하지만, 이렇게 스태틱 변수로선언한 이유는 리소스는 공유해서 사용하는 자원이므로
모든 언리얼 오브젝트인스턴스마다 애셋 정보를 로딩할필요가 없기 때문입니다.
그래서 ConstructorHelpers 로변수를 선언할 때에는 관련 인스턴스들이공유해서 쓰도록 로컬 스태틱 변수로 선언하는 것이 일반적입니다.
아래는 이렇게 완성된 코드를 컴파일하고 생성된 C++ Weapon 클래스를 레벨에 끌어다 배치한 결과입니다.
먼저 ConstructorHelpers 클래스는말 그대로 생성자(Constructor)에서만 사용이 가능합니다.
즉 CDO 제작에만 사용된다는 이야기지요. 만일 게임플레이런타임에서애셋을 로딩하기 위해서는 ConstructorHelper가 아닌 StaticLoadObject와 같은 다른 API를 사용하여야 합니다.
이렇게 애셋을 로딩 시점을 1. 엔진 초기화 런타임과 2. 게임플레이 런타임으로 구분하는 이유는,안전을 위해서입니다.
일반적으로 컨텐츠에서 사용하는 애셋들은 엔진 초기화 시점에서 우리가 사용할 애셋이 확실히 존재하는지 검증하고 다음 단계를 진행하는 것이 안전합니다.
만일런타임에서 애셋을 로딩하게 된다면 예기치 않은 문제들이 발생할 수 있습니다. 예를 들어 우리가 제작한 무기를 게임 플레이 런타임에서 로딩한다면, 캐릭터가 등장하고 열심히 탐험하고 몬스터 공격을 위해 칼을 빼들 때야 비로서 로딩을 시작하겠지요. 그런데 이 때 칼 애셋이 없는 경우에는 잘못해서 콘텐츠가 크래시가 일어날 수 있습니다.
불러들이는 애셋의 경로는 특별한 이유가 없다면 로딩된 이후에변경될 일이 없을테니, 사용할 애셋들은 ConstructorHelpers 클래스를 사용해 애셋의 유무를 미리 확인하고로딩하는 것이 좋습니다.
그러면 기존의 WebConnection과 새로운 WebConnectionNew와의 차이는 무엇일까요?
사실 사용에 있어서 두 객체간의 차이는 없다고 보면 됩니다.
다만 생성방식에서만 차이가 있는데,
전자인 WebConnection은 ABGameInstance의 인스턴스가 생성되면 자동으로 따라 생성되는 반면, 후자인 WebConnectionNew는 게임 플레이 런타임에서 우리가 직접 생성해줘야 합니다.
전자(WebConnection)의 방식을 스태틱(Static)하게 생성한다고 표현하겠습니다. 이는 기획상으로 변경될 여지가 거의 없는 오브젝트 묶음을 한번에 빠르게 생성할 때 유용합니다.
후자(WebConnectionNew)의 방식을 다이나믹(Dynamic)하게 생성한다고 표현하겠습니다. 후자 방식은 느리지만 세팅에 따라 다양한 오브젝트를 생성할 수 있어서, 유연하게 상황에 대처할 수 있다는 장점이 있습니다.
Level
퍼시스턴트 레벨 : 월드에는최소 하나의 지정되는 레벨 스트리밍 레벨 : 월드에 무관하게 추가/삭제가 가능하도록 설계된 레벨
언리얼에서 게임 컨텐츠 구조는 ABGameInstance와 WebConnection간의 관계와 동일하게 부모-자식 관계가 확장된 구조를 따릅니다.
언리얼에서 모든 게임 컨텐츠의 기초은 월드에서부터 시작됩니다.
월드에는 최소 하나의 레벨이 지정되는데, 이를 퍼시스턴트 레벨(Persistent Level)이라고 합니다. 이 퍼시스턴트 레벨에는 추가로 월드의 초기 설정 값들을 지정한 월드세팅 언리얼 오브젝트가 있습니다.
기본 생성된 월드에는 레벨을 실시간으로 추가할 수 있는데, 월드에 무관하게 추가/삭제가 가능하도록 설계된 레벨을 스트리밍 레벨(Streaming Level)이라고 합니다.
이러한 레벨은 월드에서 독립적으로 행동하는 단위 오브젝트인 액터(Actor)의 묶음으로 구성되어 있습니다.
이 액터들은 다시 컴포넌트들로 구성되어 있지요.
지금까지 설명드린 월드의 계층 구성은 아래와 같이 정리할 수 있습니다.
이렇게 생성된 계층 구조는 설계에 따라 기본서브오브젝트 방식으로 관리할지, 런타임에서 실시간으로 생성해 관리할지 방식이 나뉘어지게 됩니다.
설명을 위해 월드 내에서 게임의 룰을 관장하는 액터인 게임모드를 가지고 예시를 들어보겠습니다. FPS 게임을 제작할 때 레벨 디자이너가 레벨을 공들여서 제작했습니다.
이렇게 제작된 레벨에 플레이어들이 접속해 플레이를 할 때에는 데스매치(DeathMatch)룰을 적용할지, 깃발 뺏기(Capture the Flag) 룰을 적용할지를 방장이 정하도록 유연하게 적용할 수 있게 만드는 것이 좋은 디자인일 겁니다. (레벨마다 변경이 될수 있으므로, 다이나믹인게 좋다?)
이 상황에서 월드세팅 오브젝트가 퍼시스턴트레벨 오브젝트에 스태틱 방식인 기본서브오브젝트로 포함되어 버리면, 월드세팅마다 레벨이 하나씩 만들어져야 합니다. 이러면 중복된 맵으로 인해 용량이 낭비되겠지요. 그래서 월드에서 월드세팅과 게임 모드는 후자인 다이나믹 방식으로 로딩되게 설계되어 있습니다.
반면에 액터는 설계자가 지정한 컴포넌트의 조합에 따라 월드에서 스스로 동작하도록 설계되어 있습니다. 그래서 액터는 스태틱하게 컴포넌트와 함께 생성하고 다 같이 소멸하는 것이 효과적입니다.
그래서 액터와 컴포넌트는 전자인 스태틱하게 로딩하도록 설계되어 있습니다.
아래는 이를 정리한 도식입니다.
FRangedWorld
이제 월드에 있는 언리얼 오브젝트들을 살펴봅시다.
월드에 속한 액터를 빠르게 검색하는 방법은 두 가지입니다.
하나는 For Ranged Loop 구조에 맞게 추가된 FRangedWorld를 사용하는 방법이 있습니다.
이 방식은 모든 액터를 간편하게 검색할 수 있다는 장점이 있습니다.
액터 내 컴포넌트는 모두 기본서브오브젝트로 등록되어 있으니 쉽게 검색이 가능합니다. ("Runtime/Engine/Public/EngineUtils.h" 추가해야함)