KoreanFoodie's Study

이득우의 언리얼 C++ 2 : 액터의 설계 본문

Game Dev/Unreal C++ : Tutorial

이득우의 언리얼 C++ 2 : 액터의 설계

GoldGiver 2022. 2. 12. 21:13

이득우님의 "이득우의 언리얼 C++ 게임 개발의 정석" 책을 따라가며 실습한 내용을 정리한 포스팅입니다. 실습에 필요한 자료들은 이 링크에서, 제가 작업한 예제 소스 완성본은 여기에서 찾아보실 수 있습니다. (저는 언리얼 4.27.2 버전 기준으로 작업하였습니다)

월드의 개념

월드는 다음과 같은 기초 요소를 제공한다.

  • 공간(Space) : 가상세계를 구성하는 3차원의 영역 (Transform) 이라는 구조체 제공. 단위는 cm
  • 시간(Time) : 가상 공간에서 흐르는 시간
  • 물리(Physics) : 월드 공간에 배치된 물체에 작용하는 물리적인 환경(중력 등). 물체가 월드로부터 물리적인 영향을 받으려면 콜리전(Collision) 정보가 있어야 한다.
  • 렌더링(Rendering) : 엔진이 제공하는 시각적인 기능(빛, 머티리얼...)

 

 

액터와 컴포넌트

액터는 월드에 배치될 수 있는 오브젝트를 뜻한다. 액터는 다양한 경우에 맞게 컴포넌트(Component)로 규격화되어 있는데, 주요 컴포넌트는 다음과 같다.

  • 스태틱메시 컴포넌트(StaticMesh Component) : 애니메이션이 없는 모델링 에셋. 시각적인 기능과 물리적인 기능 제공.
  • 스켈레탈메시 컴포넌트(SkeletalMesh Component) : 애니메이션 정보가 있는 모델링 에셋. 시각적인 기능, 애니메이션, 캐릭터의 물리 기능을 제공하는 모듈이다.
  • 콜리전 컴포넌트(Collision Component) : 구/박스/캡슐로 지정한 영역에 물리적인 기능을 설정하기 위해 제공하는 모듈이다. 시각적인 기능은 없다.
  • 카메라 컴포넌트(Camera Component) : 현재 상황을 플레이어의 모니터 화면에 출력한다.
  • 오디오 컴포넌트(Audio Component) : 소리를 발생시킨다.
  • 파티클 시스템 컴포넌트(Particle System Component) : 이펙트를 발생시킨다.
  • 라이트 컴포넌트(Light Component) : 물체에 광원 효과를 부여한다.
  • 무브먼트 컴포넌트(Movement Component) : 물체에 특정한 움직임을 부여한다.

 

액터는 여러 컴포넌트를 가질 수 있으며, 그 중에서 대표하는 하나의 컴포넌트를 반드시 지정해야 한다. 이를 루트 컴포넌트(Root Component) 라고 한다.

예를 들어, 위의 Fountain 인스턴스의 Root Component 는 Body 이다.

 

 

액터 설계 : 분수대 액터 만들기

언리얼 실행 환경이 우리가 선언한 객체를 자동으로 관리하게 만들기 위해 코드에서 UPROPERTY 라는 매크로를 사용해 객체를 지정해 줄 수 있다. 이제 해당 변수들은 언리얼이 알아서 메모리를 관리해주게 된다.

다만 모든 객체가 UPROPERTY 매크로를 이용할 수는 없고, 언리얼 오브젝트라는, 언리얼 실행 환경에 의해 관리되는 C++ 객체만 사용 가능하다. 어떤 C++ 클래스가 언리얼 오브젝트 클래스가 되려면 다음과 같은 규칙이 필요하다.

  • 클래스 선언 매크로 : 맨 윗줄에 UCLASS 매크로 선언, 클래스 내부에 GENERATED_BODY 매크로 선언
  • 클래스 이름 접두사 : A는 액터 클래스에, U는 액터가 아닌 클래스에 사용
  • generated.h 헤더 파일 : 언리얼 오브젝트 선언의 마지막 #include 구문에 이 헤더파일을 선언
  • 외부 모듈에의 공개 여부 : '모듈명_API' 라는 키워드를 클래스 선언 앞에 추가하여 다른 모듈에서 해당 객체에 접근할 수 있도록 한다.

 

위의 내용들이 잘 적용되었는지는 다음 Fountain.h 헤더 파일에서 확인해볼 수 있다.

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "EngineMinimal.h"
#include "GameFramework/Actor.h"
#include "Fountain.generated.h"

UCLASS()
class CPP_PORTFOLIO_API AFountain : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AFountain();

protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;

public:	
	// Called every frame
	virtual void Tick(float DeltaTime) override;

	UPROPERTY(VisibleAnywhere)
	UStaticMeshComponent* Body;

	UPROPERTY(VisibleAnywhere)
	UStaticMeshComponent* Water;

	UPROPERTY(VisibleAnywhere)
	UPointLightComponent* Light;
	
	UPROPERTY(VisibleAnywhere)
	UParticleSystemComponent* Splash;

	UPROPERTY(EditAnywhere, Category=ID)
	int32 ID;

};

본격적으로 C++ 코드를 이용해 분수대 액터를 만든다고 하자. 먼저, 분수대 액터의 헤더파일에 컴포넌트들을 추가해야 한다(Tick 함수 아래에 다음 코드를 추가한다). 위의 코드는 분수대 Body, 물인 Water, 광원인 Light, 파티클인 Splash, 마지막으로 ID 를 부여하고 있다.

 

 

액터와 에디터 연동 : UPROPERTY

이때, UPROPERTY(VisibleAnywhere) 을 넣게 되면, 언리얼 에디터 내에서 '속성값'을 편집 가능할 수 있게 된다. 하지만 해당 객체를 다른 객체로(예를 들면, StaticMeshComponent를 다른 컴포넌트로) 변경할 수는 없다. 즉, 해당 속성의 데이터를 변경할 수는 없다.

언리얼 오브젝트의 속성 값은 객체를 관리하는 객체 유형과 값을 관리하는 값 유형으로 나뉜다. ID 의 경우, 객체 유형이 아닌 값 유형이다. 따라서 EditAnywhere 을 해야 에디터에서 실제 값을 변경할 수 있다. 언리얼 엔진이 제공하는 대표적인 값 유형은 다음과 같다.

  • 바이트 : uint8
  • 정수 : int32
  • 실수 : float
  • 문자열 : FString, FName
  • 구조체 : FVector, FRotator, FTransform

ID 속성에는 0의 기본값이 할당된다. 또한, 'Category=분류명' 규칙으로 지정한 분류에서 속성 값을 관리할 수 있다.

 

 

액터 기능의 확장

위의 분수대 모양처럼, 기둥이 위로 삐져나오기 위해서는 Z 축 값을 조정해야 하지만, 코드를 통해 이를 자동으로 조정할 수 있다.

// Fill out your copyright notice in the Description page of Project Settings.


#include "Fountain.h"

// Sets default values
AFountain::AFountain()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;
	
	Body = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BODY"));
	Water = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("WATER"));
	Light = CreateDefaultSubobject<UPointLightComponent>(TEXT("Light"));
	Splash = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("Splash"));

	RootComponent = Body;
	Water->SetupAttachment(Body);
	Light->SetupAttachment(Body);
	Splash->SetupAttachment(Body);

	Water->SetRelativeLocation(FVector(0.0f, 0.0f, 135.0f));
	Light->SetRelativeLocation(FVector(0.0f, 0.0f, 195.0f));
	Splash->SetRelativeLocation(FVector(0.0f, 0.0f, 195.0f));

}

// Called when the game starts or when spawned
void AFountain::BeginPlay()
{
	Super::BeginPlay();
	
}

// Called every frame
void AFountain::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

}

Body, Water, Light, Splash 는 각각 CreateDefaultSubobject 를 이용해 객체를 생성했고, RootComponent 를 Body 로 잡아준 다음, SetupAttachment 메소드를 이용해 Water, Light, Splash 객체를 Body 객체에 종속시켰다.

그 후, SetRelativeLocation을 이용해 FVector 구조체 안에 상대적인 Transform 값을 설정했다.

 

 

액터의 지정

사실 CPP 코드로 만든 오브젝트는 실제 액터로 역할을 다하기 위해서 에셋을 넣어주어야 한다.

하지만 모든 액터에 대해 프로그래머가 일일히 에셋을 지정해 주는 것은 상당한 낭비이다. C++ 코드에 사용할 에셋 경로를 넣어 줌으로써, C++ 클래스를 월드에 배치할 때 에셋이 자동으로 들어가도록 만들 수 있다.

// Fill out your copyright notice in the Description page of Project Settings.


#include "Fountain.h"

// Sets default values
AFountain::AFountain()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;
	
	Body = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BODY"));
	Water = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("WATER"));
	Light = CreateDefaultSubobject<UPointLightComponent>(TEXT("Light"));
	Splash = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("Splash"));

	RootComponent = Body;
	Water->SetupAttachment(Body);
	Light->SetupAttachment(Body);
	Splash->SetupAttachment(Body);

	Water->SetRelativeLocation(FVector(0.0f, 0.0f, 135.0f));
	Light->SetRelativeLocation(FVector(0.0f, 0.0f, 195.0f));
	Splash->SetRelativeLocation(FVector(0.0f, 0.0f, 195.0f));

	static ConstructorHelpers::FObjectFinder<UStaticMesh>
		SM_BODY(TEXT("/Game/InfinityBladeGrassLands/Environments/Plains/Env_Plains_Ruins/StaticMesh/SM_Plains_Castle_Fountain_01.SM_Plains_Castle_Fountain_01"));

	if (SM_BODY.Succeeded()) {
		Body->SetStaticMesh(SM_BODY.Object);
	}
	
	static ConstructorHelpers::FObjectFinder<UStaticMesh>
		SM_WATER(TEXT("/Game/InfinityBladeGrassLands/Environments/Plains/Env_Plains_Ruins/StaticMesh/SM_Plains_Castle_Fountain_02.SM_Plains_Castle_Fountain_02"));

	if (SM_WATER.Succeeded()) {
		Water->SetStaticMesh(SM_WATER.Object);
	}

	static ConstructorHelpers::FObjectFinder<UParticleSystem>
		PS_SPLASH(TEXT("/Game/InfinityBladeGrassLands/Effects/FX_Ambient/Water/P_Water_Fountain_Splash_Base_01.P_Water_Fountain_Splash_Base_01"));

	if (PS_SPLASH.Succeeded()) {
		Splash->SetTemplate(PS_SPLASH.Object);
	}
}

// Called when the game starts or when spawned
void AFountain::BeginPlay()
{
	Super::BeginPlay();
	
}

// Called every frame
void AFountain::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

}

 

먼저, 사용할 에셋의 레퍼런스를 복사(상대 경로 복사)한 후, ConstructorHelpers::FObjectFinder<TYPE> 을 이용해 해당 에셋을 찾는다.

그 후, 에셋을 제대로 찾았으면 ( SM_BODY.Succeeded() ) 해당 객체에 맞게 에셋을 맞춰준다( Body->SetStaticMesh(SM_BODY.Object) ).

게임 실행 도중에 에셋 경로가 변경될 일은 없으므로, static 키워드를 붙여 처음 한 번만 초기화를 해 주었다.

 

Comments