Game Programming in C++ - Day 4

이응민·2024년 7월 17일
0

Game Programming in C++

목록 보기
4/21

Day4 게임 객체

이전에 퐁 게임을 구현할 때는 별도의 클래스를 사용하지 않고 Game 클래스에서 멤버 변수를 사용해서 공과 벽, 패들을 구현했다. 그러나 게임의 크기가 커지면 좋은 다른 방법을 찾아야한다. 게임 객체(game object)는 게임 세계에서 자신을 갱신하거나 그리거나 또는 갱신과 그리기를 둘 다 수행하는 모든 오브젝트를 가리킨다. 게임 객체를 표현하는 데는 몇 가지 방법이 있다. 일부 게임에서는 객체 계층 구조를 사용하고, 다른 게임에서는 합성(composition)을 사용하며, 또 다른 게임에서는 매우 복잡한 방법을 활용한다.

게임 객체의 타입

일반적인 타입의 게임 객체는 루프의 '게임 세계 갱신' 단계동안 갱신되며, '출력 생성' 단계에서는 그래진다. 모든 캐릭터를 비롯한 움직이는 오브젝트는 이 범주에서 벗어나지 않는다. 그려지지만 갱신은 하지 않는 객체를 정적 객체(static object)로 부르기도 한다. 카메라와 트리거(trigger)는 그러지지 않는 게임 객체의 예이다.

게임 객체 모델

게임 객체 모델(game object model)은 수없이 많으며, 게임 객체를 표현하는 데에는 여러 방법이 존재한다. 먼저 클래스 계층 구조로서의 게임 객체에 대해서 알아보자. 게임 객체 모델 접근법 중 하나는 표준 객체지향 클래스 계층 구조로 게임 객체를 선언하는 것인데 모든 게임 객체가 하나의 기본 클래스를 상속하기 때문에 때때로 모놀리식 클래스(monolithic class)로 부르기도 한다. 이 객체 모델을 사용하려면 먼저 기본 클래스를 선언한다.

class Actor {
public:
	// 액터를 갱신하기 위해 프레임마다 호출
	virtual void Update(float deltaTime);
    // 액터를 그리기 위해 프레임마다 호출
    virtual void Draw();
};

그러면 기본 클래스를 상속한 다양한 캐릭터는 각자의 서브클래스를 가질 것이다.

class PacMan : public Actor {
public:
	void Update(float deltaTime) override;
    void Draw() override;
};

이 접근법의 단점은 모든 게임 객체가 기본 게임 객체의 모든 속성과 기능을 가져야 한다는 데 있다. 예를 들어 눈에 보이지 않는 객체도 있는데 이런 객체에 Draw를 호출하는 것은 시간 낭비다. 상속의 다른 문제점은 게임의 기능이 많아지면 더 명확해진다. 게임상의 여러 액터들이 전부는 아니지만 움직일 필요가 있다고 가정했을 때를 팩맨의 경우 팩맨과 유령은 이동할 필요가 있지만 알갱이는 그렇지 않다. 한 가지 방법은 이동 코드를 액터 내부에 놓는 것이다. 하지만 모든 서브클래스가 이 코드를 필요로 하지 않기 때문에 액터와 움직이는 서브클래스 사이에 MovingActor라는 클래스를 추가하는 것을 고려할 수 있다. 하지만 이렇게 하면 계층 구조가 복잡해진다. 또한 하나의 클래스 계층 구조를 가지면 나중에 두 형제 클래스로부터 기능을 공유받을 상황이 생길 때 문제가 발생한다. 다이아몬드 상속(diamond inheritance)라고 구조가 생길 수 있는데 이런 구조는 서브클래스가 여러 버전의 가상 함수를 상속받을 수 있어서 문제를 초래할 수 있다. 이런 이유로 다이아몬드 계층 구조를 피하는 것이 좋다. 그래서 모놀리식 계층 구조를 사용하는 대신에 많은 게임은 컴포넌트 기반의 게임 객체 모델을 사용한다. 이 접근법에는 게임 객체 클래스는 존재하지만, 게임 객체의 서브클래스는 없다. 대신에 게임 객체 클래스는 필요에 따라 기능을 구현한 컴포넌트 객체의 컬렉션을 갖고 있다.

유령 Pinky는 위 그림의 왼쪽에서 보이듯 Ghost의 서브 클래스이며 Actor의 서브클래스이다. 그러나 컴포넌트 기반 모델에서는 오른쪽처럼 Pinky는 4개의 컴포넌트를 소유한 GameObject이다. 컴포넌트 기반의 클래스 계층도는 깊이가 매우 얕다. 기본 Component 클래스가 주어졌을 때 GameObject는 단순히 컴포넌트의 컬렉션만 가지만 된다.

class GameObject {
public:
	void AddComponent(Component* comp);
    void RemoveComponent(Component* comp);
private:
	std::unordered_set<Component*> mComponents;
};

GameObject는 오직 컴포넌트는 추가하고 제거하는 함수만 가지고 있다. 따라서 여러 타입의 컴포넌트가 제대로 동작하려면 해당 컴포넌트를 추적하는 시스템 구축이 필요하다. 예를 들어 모든 DrawComponent는 Renderer 객체에 등록되므로 Renderer는 프레임을 그릴 시 모든 활성화된 Drawcomponent에 접근할 수 있다. 컴포넌트 기반 게임 객체 모델의 한 가지 장점은 특정 기능이 필요한 게임 객체에만 해당 기능을 구현한 컴포넌트를 추가하면 된다는 데 있다. 그리기가 필요한 오브젝트라면 DrawComponent 컴포넌트가 필요하겠지만, 카메라같이 그리기 기능이 필요하지 않은 오브젝트는 DrawComponent가 필요없다. 그러나 순수 컴포넌트 시스템은 게임 객체 컴포넌트들 간의 의존성이 명확하지 않다는 단점이 있다. 예를 들어 DrawComponent가 Transform Component의 소유자인 GameObject에게 TransformComponent를 소유하고 있는지 질의할 필요가 있음을 의미한가 구현에 따라 이 질의는 현저한 성능 병목 현상을 초래할 수 있다.

컨포넌트와 계층 구조로 구성된 게임 객체

프로젝트들에서 게임 객체 모델은 모놀리식 계층 구조와 컴포넌트 객체 모델을 섞은 하이브리드 형태이다. 몇 안 되는 가상 함수를 가진 기본 Actor 클래스가 있으며, 액터는 컴포넌트의 벡터를 갖는다.

Actor.h

Actor.cpp

Actor 클래스에서는 상태 열거형을 통해 액터의 상태를 표현한다.

enum state {
	EActive,
    EPaused,
    EDead
};

액터는 자신이 EActive 상태에 있을 때만 자신을 갱신하고 EDead 상태는 게임에게 액터를 제거하라고 통지하는 역할을 한다. 다음으로 Update 함수는 먼저 UpdateComponents를 호출한 후 UpdateActor를 호출한다. UpdateComponents는 모든 컴포넌트를 반복하면서 순서대로 각 컴포넌트를 갱신한다. UpdateActor의 기본 구현은 비어 있지만 Actor 서브클래스는 UpdateActor 함수를 재정의해서 함수 동작을 변경할 수 있다. 또한 Actor 클래스는 추가 액터 생성을 포함한 몇 가지 이유때문에 Game 클래스에 접근해야한다. 한 가지 방법은 게임 객체를 싱글턴(singleton)으로 만드는 것이다. 싱글턴은 단일하고 전역적으로 접근 가능한 클래스 인스턴스다. 하지만 싱클턴 패턴은 클래스에 여러 인스턴스가 필요하다고 판단되는 상황이 온다면 문제가 발생한다. 이 책에서는 의존석 주입(dependency injection)이라는 접근법을 사용한다. 이 접근법에서 액터 생성자가 Game 클래스의 포인터를 받는다. 이렇게 하면 액터는 다른 액터를 생성하거나 Game 함수에 접근하기 위해 이 포인터를 사용하면 된다. 그리고 Actor 클래스는 액터의 위치와 액터의 크기를 조절하는 스케일 멤버 변수, 액터를 회전시키는 회전값 멤버 변수(라디안 값)를 갖고 있다.

Component.h

Component.cpp

Component 클래스에서 mUpdateOrder 멤버 변수가 있는데 이 멤버 변수는 여러 컴포넌트 간 갱신 순서를 지정해 주므로 매우 유용하다. 예를 들어 플레이어의 카메라 컴포넌트는 이동 컴포넌트가 플레이어를 이동시킨 다음에 갱신되어야한다. 이러한 순서를 유지하기 위해 Actor의 AddComponent 함수는 새 컴포넌트를 추가할 때마다 컴포넌트 벡터를 정렬한다. 마지막으로 컴포넌트 클래스는 소유자 액터의 포인터를 가진다. 소유자 액터의 포인터가 필요한 이유는 컴포넌트가 필요하다고 판단된는 변환 데이터 및 여러 정보에 접근하기 위해서다.
게임 객체 모델에는 여러 다양한 접근법이 있다. 일부 객체 모델은 여러 기능을 선언하려면 인터페이스 클래스를 사용하며, 각 게임의 객체는 이 인터페이스를 구현한다. 다른 접근법으로는 게임 객체로 부터 컴포넌트가 완전히 제거된, 컴포넌트 모델을 한 단계 더 확장한 방법이 있다. 이 접근법에서는 숫자 식별자로 컨포넌트를 추적하는 컴포넌트 데이터베이스를 사용한다. 또 다른 접근 법에서는 객체를 속성으로 정의한다. 예를 들어, 이러한 시스템에서는 객체에 체력 속성을 추가하면 그 시점부터 객체는 체력을 회복하거나 데미지를 받게 된다.

게임 객체를 게임 루프에 통합하기

하이브리드 게임 객체 모델을 게임 루프로 통합하는 데는 약간의 코드 작성이 필요하다. 먼저 Actor 포인터 벡터인 두개의 std::vector를 추가한다. 하나는 활성화된 액터(mActors)를 포함하며, 다른 하나는 대기 중인 액터(mPendingActors)를 포함한다. 액터를 반복하는 동안(mActors) 새 액터를 생성하는 경우를 다루려면 대기 액터들을 위한 벡터가 필요하다. 이 경우에 mActors에 요소를 추가하는 대신 mPendingActors에 요소를 추가한뒤 mActors의 반복이 끝나면 그때 mActor로 이 대기중인 액터를 이동시킨다. 다음으로 Actor 포인터를 인자로 받는 두함수 AddActor와 RemoveActor를 만든다. AddActor 함수는 액터를 mPendingActors나 mActors로 추가한다. 어느 벡터에 추가할지는 액터의 갱신 여부(bool mUpdatingActor)에 따라 결정된다.

Game 클래스에 새로 추가된 멤버 변수와 함수

Game::AddActor 함수와 Game::RemoveActor 함수


Game::AddActor 함수에서는 mActors 벡터나 mPendingActor 벡터에 액터를 추가하는 함수이다. 또 Game::RemoveActor 함수는 두 벡터에서 액터를 제거한다.

Game::UpdateGame 액터 갱신

액터를 갱신하기 위해 UpdateGame 함수를 변경해야한다. 델타 시간을 계산하고 mActors의 모든 액터를 반복하면서 Actor::Update를 호출한다. 그 후에 대기 중인 액터를 mActors 벡터로 이동시킨다. 마지막으로 액터가 죽었다면 그 액터를 제거한다.

0개의 댓글