그래픽스/OpenGL

게임 객체와 컴포넌트의 관계

뽀또치즈맛 2024. 3. 26. 17:26

 

 

 

게임 객체란?

게임 객체는 게임 세계에서 자신을 갱신하거나 그리거나
또는 갱신과 그리기 둘 다 수행하는 모든 오브젝트를 가리킨다.

 

 

 

게임 객체를 표현하는 데는 몇 가지 방법이 있다.

일부 게임에서는 객체 계층 구조를 사용하고, 다른 게임에서는 합성을 사용하며,

또 다른 게임에서는 매우 복잡한 방법을 활용한다. 구현 방법에 상관없이 게임은 이러한 게임 객체를 추적하고

갱신하는 방법이 필요하다.

 

 

게임 객체의 타입

 

일반적인 타입의 게임 객체는 루프의 '게임 세계 갱신' 단계 동안 갱신되며,

'출력 생성' 단계에서는 그려진다.

모든 캐릭터나 생명체 또는 움직일 수 있는 오브젝트는 이 범주에서 벗어나지 않는다.

예를 들어 슈퍼 마리오 브라더스에서 마리오나 적들 그리고 모든 동적인 블록은

게임이 갱신하거나 그리는 게임 오브젝트이다.

 

개발자가 오브젝트를 그리기는 하지만 갱신은 하지 않는 게임 객체를 정적 객체로 부른다.

이 오브젝트는 플레이어에게 보이기는 하지만, 결코 자신을 갱신하지 않는다.

 

레벨의 배경에 있는 빌딩 등을 예로 들 수 있겠다.

대부분의 게임에서 빌딩은 움직이지 않고 플레이어를 공격하지 않지만,

화면에는 보인다.

 

카메라는 자신을 갱신하지만 화면에는 그려지지 않는 게임 객체의 한 예이다.

 

또 다른 예로는 트리거를 들 수 있는데 트리거는 다른 물체와의 교차로 인해 발생하는

이벤트이 원인이 된다.

 

예를 들어 공포 게임에서는 플레이어가 문에 접근할 때 좀비가 나타나길 기대할 수 있다.

이 경우에 레벨 디자이너는 플레이어를 감지하고 좀비를 생성하느 ㄴ액션을 발동하는

트리거 오브젝트를 배치한다.

 

트리거를 구현하는 한 가지 방법은 프레임마다 플레이어와 교차 여부를 확인하기 위해

눈에 보이지 않는 상자를 배치하는 것이다.

 

 

게임 객체 모델

 

게임 객체 모델은 수없이 많으며, 게임 객체를 표현하는 데에는 여러 방법이 존재한다.

일부 게임 객체 모델의 타입과 이러한 접근법 사이의 트레이드 오프에 관한 것은 다음과 같다.

 

 

클래스 계층 구조로서의 게임 객체

게임 객체 모델 접근법 중 하나는 표준 객체지향 크랠스 계층 구조로 게임 객체를 선언하는 것인데

모든 게임 객체가 하나의 기본 클래스를 상속하기 때문에 때때로 모놀리식 클래스로 부르기도 한다.

 

이 객체 모델을 사용하려면 먼저 기본 클래스를 선언한다.

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

 

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

 

비슷하게 Actor의 다른 서브 클래스를 선언할 수 있다.

예를 들어 Actor를 상속한 Ghost 클래스가 있을 수 있고, 각 개별 유령은

Ghost를 상속한 자신만의 클래슬 가질 수 있다.

이러한 게임 객체 클래스 계층 구조의 스타일을 보여준다.

이 접근법의 단점은 모든 게임 객체가 기본 게임 객체의 모든 속성과 기능을 가져야 한다는 데 있다.

예를 들어 모든 액터가 자신을 갱신하거나 그린다고 가정하자.

하지만 이전에 설명한 바 있듯이

눈에 보이지 않는 객체도 있는데 이런 객체에 Draw를 호출하는 것은 시간낭비다.

 

상속에 따른 문제점은 게임의 기능이 많아짐에 따라 보다 명확해진다.

게임상의 여러 액터들이 전부는 아니지만 움직일 필요가 있다고 가정해 보자.

팩맨의 경우 유령과 팩맨은 이동할 필요가 있지만, 알갱이는 그렇지 않다.

한 가지 방법은 이동 코드를 액터 내부에 놓는 것이다.

하지만 모든 서브 클래스가 이 코드를 필요로 하지 않을 것이다.

대안채긍로 액터와 이동이 필요한 서브클래스 사이에 새로운

MovingActor를 계층 구조에 추가하는 것으로 고려할 수 있다.

하지만 이렇게하면 클래스의 계층구조는 복잡해지기 마련이다.

 

또한 하나의 클래스 계층 구조를 가지만 나중에 두 형제 클래스로부터

기능을 공유받을 상황이 생길 때 문제가 발생하게 된다.

예를 들어 Grand Theft Auto란 게임에는

기본 클래스인 Vehicle 클래스가 있다고 가정해보자.

이 클래스로부터 LandVehicle 서브 클래스 (땅을 가로지르는 차량)와

WaterVehicle 서브클래스(보트와 같은 수상 차량)를 생성한다면 괜찮을지도 모르겠다.

하지만 어느날 디자이너가 수륙 양용 차량을 추가하기로 결정했따면 어떻게 하면 좋을까?

두 서브크래스 모두를 상속하는 AmphibiousVehicle이란 새로운 서브클래스가 대안이 될 수 있을 것이다.

그러나 이 경우는 다중 상속을 하게 되므로 2가지 경로를 따라 Vehicle을 상속받는다.

다이아몬드 상속이라 불리는 이러한 유형의 계층 구조는

서브 클래스가 여러 버전의 가상 함수를 상속받을 수 있어서 문제를 초래할 수 있다.

이런 문제로 다이아몬드 계층 구조는 피하는 것이 좋다.

때문에 이러한 대안으로 나온 것이 컴포넌트를 이용하는 것이다.

 

컴포넌트로 구성된 게임 객체

 

모놀리식 계층 구조를 사용하는 대신에 많은 게임은 컴포넌트 기반 게임 객체 모델을 사용한다.

이 모델은 유니티 게임 엔진이 사용하고 있기도 한 방법이다.

이 접근법은 게임 객체 클래스는 존재하지만, 게임 객체의 서브클래스는 없다.

대신에 게임 객체 클래스는 필요에 따라 기능을 구현한 컴포넌트 객체의 컬렉션을 갖고 있다.

 

이러한 각각의 컴포넌트는 그 컴포넌트에 피룡한 특정한 속성과 기능이 있는데,

예를 들어 DrawComponent는 화면에 물체를 그리는 것과 관련된다.

그리고 TransformComponent는 게임 세계에 있는 물체의 위치와 변환을 저장한다.

 

컴포넌트 객체 모델을 구현하는 한 가지 방법은 컴포넌트를 기반으로 하는 클래스 계층도를 가지는 것이다.

이 클래스 계층도는 일반적으로 그 깊이가 매우 얕다.

기본 Component 클래스가 주어졌을 때 GameObject는 단순히 컴포넌트의 컬렉션만 가지면 된다.

 

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

 

GameObject는 오직 컴포넌트를 추가하고 제거하는 함수만을 갖고 있다.

따라서 여러 타입의 컴포넌트가 제대로 동작하려면 해당 컴포넌트를 추적하는 시스템 구축이 필요하다.

예를 들어 모든 DrawComponent는 Render 객체에 등록되므로

Renderer는 프레임을 그릴 시 모든 활성화된 Drawcomponent에 접근할 수 있다.

 

컴포넌트 기반 게임 객체 모델의 한 가지 장점은 특정 기능이 필요한 게임 객체에만

해당 기능을 구현한 컴포넌트를 추가하면 된다는 데 있다.

그리기가 필요한 오브젝트라면  DrawComponent 컴포넌트가 필요하겠지만,

카메라같이 그리기 기능이 필요하지 않는 오브젝트는 DrawComponent가 필요없다.

 

그러나 순수 컴포넌트 시스템은 게임 객체 컴포넌트들 간의 의존성이 명확하지 않다는 단점이 있다.

예를 들어 DrawComponent는 무레즈를 어디에다 그릴지 결정하기 위해 

TransformComponent에 관해 알아야 한다.

이는 DrawComponent가 TransformComponent의 소유자인

GameObject에게 TransformComponent를 소유하고 있는지 질의할 필요가 있음을 의미한다.

구현에 따라 현저한 성능 병목 현상을 초래할 수 있다.

 

 

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

 

게임 객체 모델은 모놀리시기 계층 구조와 컴포넌트 객체 모델을 섞은 하이브리드 형태이다.

이 구조는 언리얼 4 에서 사용한 게임 객체 모델에서 필자가 참고하는 서적의 저자가 영감을 얻은 것으로,

몇 안 되는 가상 함수를 가진 기본 Actor 클래스가 있으며,

액터는 컴포넌트의 벡터를 가진다.

 

코드는 다음과 같다.

class Actor
{
public:
	// 액터의 상태를 추적하는 데 사용된다.
    enum State
    {
    	EActive,
        EPaused,
        EDead
    };
    // 생성자 / 소멸자
    Actor(class Game* game);
    virtual ~Actor();
    // Game에서 호출하는 Update 함수 (가상 함수 아님)
    void Update(float deltaTime);
    // 엑터에 부착된 모든 컴포넌트 업데이트 (가상 함수 아님)
    void UpdateComponents(float deltaTime);
    // 특정 액터에 특화된 업데이트 코드 (오버라이딩 가능)
    virtual void UpdateActor(float deltaTime);
    
    // Getters/setters
    // ...
    
    // 컴포넌트 추가 / 제거
    void AddComponent(class Component* component);
    void RemoveComponent(class Component* component);
    
private:
	// 액터의 상태
    State mState;
    // 변환
    Vector2 mPosition; 	// 액터의 중심점
    float mScale;		// 액터의 배율 (100%의 경우 1.0f)
    float mRotation; 	// 회전 각도 (라디안)
    // 이 액터가 보유한 컴포넌트들
    std::vector<class Component*> mComponents;
    class Game* mGame;

 

(여기서 부터 설명이 좀 어려움)

Actor 클래스는 몇몇 주목할 만한 특징이 있다.

먼저 상태 열거형은 액터의 상태를 표현한다.

예를 들어 액터는 자신이 EActive 상태에 있을 때만 자신을 갱신한다.

EDead 상태는 게임에게 액터를 제거하라고 통지하는 역할을 한다.

다음으로, Update 함수는 먼저 UpdateComponents를 호출한 후 UpdateActor를 호출한다.

UpdateComponents는 모든 컴포넌트를 반복하면서 순서대로 각 컴포넌트를 갱신한다.

UpdateActor의 기본 구현은 비어 있지만 Actor 서브클래스는

UpdateActor 함수를 재정의해서 함수 동작을 변경할 수 있다.

 

또한 Actor 클래스는 추가 액터 생성을 포함한 몇 가지 이유 때문에 Game 클래스에 접근해야 한다.

한 가지 방법은 게임 객체를 싱글턴으로 만드는 것이다.

싱글턴은 단일하고 전역적으로 접근 가능한 클래스 인스턴스다.

 

하지만 싱글턴 패턴은 클래스에 여러 인스턴스가 필요하다고 판단되는 상황이 온다면

문제가 발생하게 된다.

해당 서적에서는 의존성 주입이라는 접근법을 사용한다.

이 접근법에서는 액터 생성자가 Game 클래스의 포인터를 받는다.

이렇게 하면 액터는 다른 액터를 생성하거나 Game 함수에 접근하기 위해 이 포인터를 사용하면 된다.

 

Vector2는 액터의 위치를 나타낸다.

또한 액터는 액터를 더 크게 하거나 더 작게 하는 스케일과 액터의 회전을 지원한다.

회전값의 단위로는 각도가 아니라 라디안 값을 사용한다.

 

컴포넌트 클래스의 선언코드가 아래와 같이 있다고 가정 했을 때,

mUpdateOrder 멤버 변수가 있는데 이 멤버 변수는

여러 컴포넌트 간 갱신 순서를 지정해 주므로 매우 유용하다.

예를들어 플레이어의 1인칭 카메라 컴포넌트는 이동 컴포넌트가 플레이어를 이동시킨 다음에 갱신돼야 한다.

이 순서를 유지하기 위해 Actor의 AddComponent 함수는 새 컴포넌트를 추가할 때마다

컴포넌트 벡터를 정렬한다.

마지막으로 컴포넌트 클래스는 소유자 액터의 포인터를 가진다.

소유자 액터가 필요한 이유는 컴포넌트가 필요하다고 판단되는 변환 데이터 및

여러 정보에 접근하기 위해서다.

 

class Component
{
public:
	// 생성자
    // (업데이트 순서값이 작을수록 컴포넌트는 더 빨리 갱신된다)
    Component(class Actor* owner, int updateOrder = 100);
    // 소멸자
    virtual ~Component();
    // 델타 시간으로 이 컴포넌트를 업데이트
    virtual void Update(float deltaTime);
    int GetUpdateOrder() const {	return mUpdateOrder; }
protected:
	// 소유자 액터
    class Actor* mOwner;
    // 컴포넌트의 업데이트 순서
    int mUpdateOrder;
}

 

현재의 액터와 컴포넌트의 구현에서는 플레이어 입려 장치에 관해 설명하지 않고 있다.

 

이 하이브리드 접근법의 계층 구조 깊이는 순서 컴포넌트 기반 모델보다는 더 크지만,

모놀리식 객체 모델의 깊은 클래스 계층 구조를 피하는 데 효과적이다.

또한 아히브리드 접근법은 컴포넌트 간 통신 오버헤드를 완전히 제거하지는 못하지만,

어느 정도 피할 수는 있다.

왜냐하면 모든 액터는 변환 데이터와 같은 중요 속성을 갖고 있기 때문이다.

 

다른 접근법

게임 객체 모델에는 여러 다양한 접근법이 있다.

일부 객체 모델은 여러 기능을 선언하려면 인터페이스 클래스를 사용하며,

각 게임 객체는 이 인터페이스를 구현한다.

다른 접근 방법으로는 게임 객체로부터 컴포넌트가 완전히 제거된,

컴포넌트 모델을 한 단계 더 확장한 방법이 있다.

이 접근법에서는 숫자 식별자로 컴포넌트를 추적한다.

예를 들어, 이러한 시스템에서는 객체에 체력 속성을 추가하면

그 시점부터 객체는 체력을 회복하더나 데미지를 받게 된다.

 

모든 게임 객체 모델의 접근법에는 각각 장단점이 있다.

하지만 필자가 공부에 참고하고 있는 서적은 하이브리드 접근법을 사용한다.

하이브리드 접근법은 특정 복잡도를 가진 게임에서는 좋은 타협안이 될 수 있고,

상대적으로 잘 동작하기 때문이다.

 

 

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

 

하이브리드 게임 객체 모델을 게임 루프로 통합하는 데는 약간의 코드의 작성이 필요하지만,

그렇게 복잡하지는 않다.

먼저 Actor 포인터 벡터인 두 개의 std::vector를 추가하자.

하나는 활성화된 액터 mActors를 포함하며, 다른 하나는 대기 중인 액터(mPendingActor)를 포함한다.

액터를 반복하는 동안 (즉 mActors를 반복하는 동안) 새 액터를 생성하는 경우를 다루려면

대기 액터들을 위한 벡터가 필요하다.

이 경우 mActors는 반복되고 있으므로 mActor에 요소를 추가하면 안된다.

대신 mPendingActors 벡터에 요소를 추가한 뒤 mActor의 반복이 끝나면

그때 Actor로 이 대기 중인 액터를 이동시킨다.

 

다음으로 Actor 포인터를 인자로 받는 두 함수 AddActor와 RemoveActor를 만든다.

AddActor 함수는 액터를 mPendingActors나 mActors로 추가한다.

어느 벡터에 추가할지는 액터의 갱신 여부 (bool 타입의 mUpdatingActors)에 따라 결정된다.

 

마찬가지로 RemoveActor는 두 벡터에서 액터를 제거한다.

 

모든 액터를 갱신하고자 UpdateGame함수를 변경해야 하고,

델타 시간을 계산한 후에 mActors의 모든 액터를 반복하면서 Update를 호출한다.

그 후 대기 중인 액터 mActors 벡터로 이동한 후 마지막으로 액터가 죽었다면 그 액터를 제거한다.

 

그러나 이러한 구조의 과정으로 액터를 추가하거나 제거하는 것은 코드 복잡도를 약간 증가시킨다.

떄문에 추후 OpenGL로 코드를 제작할 때는 좀 더 효율적으로

자신의 생성자와 소멸자에서 액터 자신을 게임에 자동으로 추가하고 제거할 수 있다.

이렇게 구현한다면 mActors 벡터를 반복하는 코드나 지우는 코드는 주의 깊고 신중하게 작성해야 한다.