Home [게임 프로그래밍 패턴] Decoupling Patterns: Component
Post
Cancel

[게임 프로그래밍 패턴] Decoupling Patterns: Component

Decoupling Patterns

  • 어려운것: 요구사항 변경을 대비해 코드 고치기 쉽게 만드는 일
    • 디커플링이 많은 도움을 줌.

  • 컴포넌트 패턴: 한 개체에 들어 있는 코드들을 기능별로 서로 디커플링
  • 이벤트 큐 패턴: 서로 통신을 주고받는 두 객체를 코드뿐만 아니라 시간 측면에서도 디커플링한다.
  • 서비스 중개자 패턴: 코드가 실제로 그 기능을 제공하는 코드와 결합하지 않고도 특정 기능에 접근할 수 있게

Component

  • 한 개체가 여러 분야를 서로 커플링 없이 다룰 수 있게

동기

  • 플랫포머를 만든다고 가정.
    • 입력, 행동, 물리, 충돌, 애니메이션, 렌더링, 사운드 등… 여러 분야의 코드를 작성해야함.
    • 이때 분야가 다른 코드끼리는 최대한 서로 모르는게 좋음.
  • 클래스가 크다 == 기능보다 버그가 더 빨리 늘어남

문제: 고르디우스의 매듭

  • 더 큰 문제는 커플링이다.

    • 알아야할 코드량이 많아짐
    • 아래 코드를 손보려면 물리, 그래픽, 사운드 전부 알아야함.

      멀티스레드 환경에서 더 심각, 보통 분야별로 스레드 분배한다.

    분야별로 스레드를 나누면, 분야별로 디커플링해야 교착상태 같은 골치 아픈 동시성 버그를 피할 수 있다.

    서로 다른 스레드에서 실행되어야 하는 메서드를 한 클래스 안에 모아두는 것은 동시성 버그를 조장하는 짓이다.

1
2
3
4
if (collidingWithFloor() && (getRenderState() != INVISIBLE))
{
  playSound(HIT_FLOOR);
}
  • 커플링 + 코드 길이 => 서로 악영향

해결방법: 매듭 끊기

  • 클래스 코드 크기문제: 클래스를 여러 작은 클래스
    • 한 덩어리를 분야에 따라 나누면 됨
    • 해당하는 분야의 여러 인스턴스를 소유하는 하나의 클래스로 만드는것.

열린구조

  • 컴포넌트 클래스들은 디커플링되어 있다.
    • 여러 사람이 작업하기 편함
  • 컴포넌트끼리 상호작용 필요할 수도 있다.
    • 모든 코드를 한곳에 섞어놓지 않았기 때문에 서로 통신이 필요한 컴포넌트만으로 결합을 재현할 수 있다.

다시 합치기

  • 이런 컴포넌트는 재사용가능하다.

요즘 소프트웨어 설계: 상속 대신 조합(composition)이 대세,두 클래스에서 같은 코드를 공유하고 싶다면, 상속이 아닌 같은 클래스의 인스턴스를 가지게 한다.

  • 플랫포머에서 주인공 외에 다른 객체들을 생각해보자
    • 데커레이션(decorations): 덤불, 먼지 같이 볼 수 있으나 상호작용 불가능한 객체
    • 프랍(props): 상자, 바위, 나무같이 볼 수 있으면서 상호작용도 할 수 있는 객체
    • 존(zone): 데커레이션과 반대, 보이지 않지만 상호작용 가능
      • 주인공이 특정 영역에 들어올 때 컷신을 틀고 싶다면 존을 사용할 수 있다.
  • 컴포넌트를 쓰지 않는다면 이들 클래스는 아래와 같이 상속할 것이다.

  • GameObject: 위치나 방향 등 기본 데이터
  • Zone: GameObject 상속, 충돌 검사 기능
  • Decoration: GameObject 상속, 렌더링 기능 추가
  • Prop: 충돌 기능을 위해 Zone 상속, 하지만 렌더링 기능을 추가하기 위해 Decoration 상속하려는 순간 '죽음의 다이아몬드'라고 불리는 다중 상속 문제 피할 수 없음.

  • 깔끔하게 재사용할 수 없음.

컴포넌트 생성

  • 상속은 필요없음.
  • GameObject 클래스, PhysicsComponent, GraphicsComponent 클래스만 있으면됨.
    • 데커레이션: 그래픽
    • 존: 물리
    • 프랍: 물리 + 그래픽
  • 코드 중복 x, 다중상속 x, 적은 클래스

  • 컴포넌트 == 객체를 위한 플러그 앤 플레이
    • 개체 소캣에 재사용 가능한 여러 컴포넌트 객체를 넣어 기능이 풍부하게 만든다.

The pattern

  • 여러 분야를 다루는 하나의 개체
    • 분야별로 격리
    • 각각의 코드를 별도의 컴포넌트 클래스에 둔다.
    • 개체 클래스는 단순히 이들 컴포넌트들의 컨테이너 역할만 한다.

컴포넌트 다양한 의미가 있음: 기업용 소프트웨어에서는 웹을 통해서 통신하도록 서비스를 디커플링하는 것을 컴포넌트 디자인 패턴이라고 부름(컴포넌트 기반 개발), 게임에서는 이와 개념이 다르지만 컴포넌트라는 용어가 대중적임

언제 사용?

  • 다음 조건 중 하나라도 만족하면 유용하게 사용가능.
    • 한 클래스에서 여러 분야 건드리고 있어, 서로 디커플링하고 싶을 때
    • 클래스가 거대해져 작업하기 어려울 때
    • 여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶을 경우(단, 상속으로는 딱 원하는 부분만 골라서 재사용할 수가 없다.)

주의사항

  • 클래스 한곳에 모아놨을 때보다 더 복잡해질 가능성이 높음.
    • 컴포넌트끼리 통신하기도 어렵고, 메모리 제어하는것도 복잡해짐.
  • 코드베이스 규모가 크면, 디커플링과 컴포넌트를 통한 재사용에서 이득이 더 클 수 있음
  • 하지만 오버엔지니어링은 지양해야함.
  • 무엇이든지 하려면 한 단계를 거쳐야 할 경우가 많다.

    • 포인터를 타고 내려가다보면 성능이 떨어질 가능성이 있음.

      반대로, 성능이나 캐시 일관성을 향상해줄 때도 많음.

    컴포넌트는 CPU에서 필요로 하는 순서대로 데이터를 조직하는 데이터 지역성 패턴을 사용하기 쉽게한다.

예제 코드

통짜 클래스
  • 모든 기능이 들어 있는 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Bjorn
{
public:
  Bjorn()
  : velocity_(0),
    x_(0), y_(0)
  {}

  void update(World& world, Graphics& graphics);

private:
  static const int WALK_ACCELERATION = 1;

  int velocity_;
  int x_, y_;

  Volume volume_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};
  • Bjorn 의 update는 매 프레임마다 호출된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void Bjorn::update(World& world, Graphics& graphics)
{
  // Apply user input to hero's velocity.
  switch (Controller::getJoystickDirection())
  {
    case DIR_LEFT:
      velocity_ -= WALK_ACCELERATION;
      break;

    case DIR_RIGHT:
      velocity_ += WALK_ACCELERATION;
      break;
  }

  // Modify position by velocity.
  x_ += velocity_;
  world.resolveCollision(volume_, x_, y_, velocity_);

  // Draw the appropriate sprite.
  Sprite* sprite = &spriteStand_;
  if (velocity_ < 0)
  {
    sprite = &spriteWalkLeft_;
  }
  else if (velocity_ > 0)
  {
    sprite = &spriteWalkRight_;
  }

  graphics.draw(*sprite, x_, y_);
}
  • 업데이트 코드는 입력에 따라 주인공을 가속하며, 물리엔진을 통해 다음 위치를 구한다. 그리고 마지막으로 주인공을 그린다.
입력 나누기
  • 먼저 분야 하나를 정해서 관련 코드를 Bjorn에서 별도의 컴포넌트 클래스로 옮긴다.
  • 입력을 분리하면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class InputComponent
{
public:
  void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;

      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }

private:
  static const int WALK_ACCELERATION = 1;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Bjorn
{
public:
  int velocity;
  int x, y;

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);

    // Modify position by velocity.
    x += velocity;
    world.resolveCollision(volume_, x, y, velocity);

    // Draw the appropriate sprite.
    Sprite* sprite = &spriteStand_;
    if (velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }

    graphics.draw(*sprite, x, y);
  }

private:
  InputComponent input_;

  Volume volume_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};
  • Bjorn 클래스에 InputComponent 객체가 추가되었다.
나머지도 나누기
  • 남아 있는 물리 코드와 그래픽스 코드도 똑같이 나눌 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class PhysicsComponent
{
public:
  void update(Bjorn& bjorn, World& world)
  {
    bjorn.x += bjorn.velocity;
    world.resolveCollision(volume_,
        bjorn.x, bjorn.y, bjorn.velocity);
  }

private:
  Volume volume_;
};
  • 데이터(volume_)도 같이 옮겼긴것 주의.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class GraphicsComponent
{
public:
  void update(Bjorn& bjorn, Graphics& graphics)
  {
    Sprite* sprite = &spriteStand_;
    if (bjorn.velocity < 0)
    {
      sprite = &spriteWalkLeft_;
    }
    else if (bjorn.velocity > 0)
    {
      sprite = &spriteWalkRight_;
    }

    graphics.draw(*sprite, bjorn.x, bjorn.y);
  }

private:
  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
};
  • 이제 주인공 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Bjorn
{
public:
  int velocity;
  int x, y;

  void update(World& world, Graphics& graphics)
  {
    input_.update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }

private:
  InputComponent input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};
  • 분리된 Bjorn 의 두 가지 역할

    • 자신을 정의하는 컴포넌트 집합을 관리하는 역할
    • 컴포넌트들이 공유하는 상태를 들고 있는 역할(위치와 속도)
  • 위치 속도를 남긴 두 가지 이유

    • 이 상태들은 전 분야에서 사용됨
    • 컴포넌트들이 서로 커플링되지 않고도 쉽게 통신할 수 있음.

Robo-Bjorn

  • 동작 코드를 컴포넌트로 옮겼지만, 아직 추상화하지 않음.
  • 추상 상위 클래스로 인풋 컴포넌트를 만들고, 입력처리 컴포넌트를 인터페이스 뒤로 숨겨보자.
1
2
3
4
5
6
class InputComponent
{
public:
  virtual ~InputComponent() {}
  virtual void update(Bjorn& bjorn) = 0;
};
  • 사용자 입력을 처리하던 코드는 InputComponent 인터페이스를 구현하는 클래스로 끌어내린다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class PlayerInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    switch (Controller::getJoystickDirection())
    {
      case DIR_LEFT:
        bjorn.velocity -= WALK_ACCELERATION;
        break;

      case DIR_RIGHT:
        bjorn.velocity += WALK_ACCELERATION;
        break;
    }
  }

private:
  static const int WALK_ACCELERATION = 1;
};
  • Bjorn 클래스는 인터페이스의 포인터를 들고 있게 바꾼다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Bjorn
{
public:
  int velocity;
  int x, y;

  Bjorn(InputComponent* input)
  : input_(input)
  {}

  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_.update(*this, world);
    graphics_.update(*this, graphics);
  }

private:
  InputComponent* input_;
  PhysicsComponent physics_;
  GraphicsComponent graphics_;
};
  • 이제 Bjorn 객체를 생성할 때, Bjorn이 사용할 입력 컴포넌트를 다음과 같이 전달할 수 있다.
1
Bjorn* bjorn = new Bjorn(new PlayerInputComponent());
  • 어떤 클래스라도 InputComponent 추상 인터페이스만 구현하면 입력 컴포넌트가 될 수 있다.

  • update()를 가상 메서드로 변경하여 속도는 느려짐.

    • 대신 ‘데모 모드’ 지원 가능해짐
    • 자동으로 게임을 플레이하는 모드 만들기 가능.
1
2
3
4
5
6
7
8
class DemoInputComponent : public InputComponent
{
public:
  virtual void update(Bjorn& bjorn)
  {
    // AI to automatically control Bjorn...
  }
};
  • 위 컴포넌트를 연결하면, 인공지능 플레이어를 만들 수 있다.
1
Bjorn* bjorn = new Bjorn(new DemoInputComponent());

No Bjørn at all?

  • 이제 Bjorn 클래스는 컴포넌트 묶음일 뿐이다.

  • 그래픽과 물리를 인터페이스와 구현부로 나눠보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
class PhysicsComponent
{
public:
  virtual ~PhysicsComponent() {}
  virtual void update(GameObject& obj, World& world) = 0;
};

class GraphicsComponent
{
public:
  virtual ~GraphicsComponent() {}
  virtual void update(GameObject& obj, Graphics& graphics) = 0;
};
  • Bjorn 클래스의 이름을 바꾸고 인터페이스를 사용하게 해야함.

일부 컴포넌트 시스템은 여기서 한발 더 나아간다. 게임 개체는 컴포넌트 포인터를 들고 있는 클래스가 아니라 그냥 ID 숫자로만 표현된다.

개별 컬렉션에 들어 있는 컴포넌트들은 ID를 통해 어느 개체인지 안다.

이러한 개체 컴포넌트 시스템은 극단적으로 컴포넌트 간의 커플링을 막는다. 개체는 컴포넌트가 추가되는지를 알 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class GameObject
{
public:
  int velocity;
  int x, y;

  GameObject(InputComponent* input,
             PhysicsComponent* physics,
             GraphicsComponent* graphics)
  : input_(input),
    physics_(physics),
    graphics_(graphics)
  {}

  void update(World& world, Graphics& graphics)
  {
    input_->update(*this);
    physics_->update(*this, world);
    graphics_->update(*this, graphics);
  }

private:
  InputComponent* input_;
  PhysicsComponent* physics_;
  GraphicsComponent* graphics_;
};
  • 인터페이스들을 구현해보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BjornPhysicsComponent : public PhysicsComponent
{
public:
  virtual void update(GameObject& obj, World& world)
  {
    // Physics code...
  }
};

class BjornGraphicsComponent : public GraphicsComponent
{
public:
  virtual void update(GameObject& obj, Graphics& graphics)
  {
    // Graphics code...
  }
};
  • 이제 이전의 Bjron을 다음과 같이 똑같이 만들 수 있다.
1
2
3
4
5
6
GameObject* createBjorn()
{
  return new GameObject(new PlayerInputComponent(),
                        new BjornPhysicsComponent(),
                        new BjornGraphicsComponent());
}

이 함수는 GOF의 팩토리 메서드 패턴임.

  • 이제 컴포넌트를 조합하여 여러 객체를 만들 수 있음

디자인 결정

  • 어떤 컴포넌트 집합이 필요한가?

객체는 컴포넌트를 어떻게 얻는가?

  • 누가 컴포넌트를 하나로 합치는지?

객체가 필요한 컴포넌트를 알아서 생성할 때

  • 객체는 항상 필요한 컴포넌트를 가지게 된다.
  • 객체를 변경하기가 어렵다.

외부 코드에서 컴포넌트를 제공할 때

  • 객체가 유연해진다.
  • 객체를 구체 컴포넌트 타입(the concrete component types)으로부터 디커플링할 수 있다.
    • 밖에서 전달 == 인터페이스를 상속받은 컴포넌트 객체일 가능성이 높음
    • 객체는 컴포넌트의 인터페이스만 알고 있음, 어떤 클래스인지 구체적으로 모름, 구조를 캡슐화하기 더 좋다.

컴포넌트들끼리는 어떻게 통신?

  • 아래의 여러 방식을 동시에 지원할 수 있다.
    • 보통 그렇게함.

컨테이너 객체의 상태를 변경하는 방식

  • 컴포넌트들은 서로 디커플링 상태를 유지함.
    • input 에서 속도 변경, physics에서 그 값을 사용하면 서로 몰라도됨.
  • 컴포넌트들이 공유하는 정보를 컨테이너 객체에 전부 넣어야한다.
    • 모든 컴포넌트들이 접근할 수 있는 컨테이너 객체로 올려야한다.
    • 컴포넌트 조합에 따라 컨테이너 객체의 상태를 전혀 사용하지 않을 수 있다. => 보이지 않는(그래픽이 없는) 객체에는 컨테이너 객체에 들어 있는 렌더링 관련데이터는 그저 메모리 낭비
  • 컴포넌트끼리 암시적으로 통시하다 보니, 컴포넌트 실행 순서에 의존하게됨.
    • 분리해도 통짜 클래스의 순서 그대로 유지해, 실행순서가 바뀌지 않도록해야함.
    • 그렇지 않으면 미묘한 버그가 생길 수있음.
    • ex. 그래픽 부터 업데이트하면 이전 프레임을 그리게 됨.

      이런 이유 때문에, 하스켈 같이 변경 가능한 상태가 전혀 없는 순수 함수형 언어를 연구하고 있음.

컴포넌트가 서로 참조하는 방식

  • 서로 통신해야 하는 컴포넌트들이 컨테이너 객체를 통하지 않고 직접 참조하게 만드는것

  • 점프 기능 => 그래픽스-점프스프라이트
  • 점프 여부 == 물리엔진에게 쿼리
  • 그래픽스 컴포넌트가 물리 컴포넌트를 알고 있으면 쉽게 해결 가능
  • 그래픽스 컴포넌트를 생성할 때 적절한 Phyics 컴포넌트를 인수에 레퍼런스로 제공해야함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class BjornGraphicsComponent
{
public:
  BjornGraphicsComponent(BjornPhysicsComponent* physics)
  : physics_(physics)
  {}

  void Update(GameObject& obj, Graphics& graphics)
  {
    Sprite* sprite;
    if (!physics_->isOnGround())
    {
      sprite = &spriteJump_;
    }
    else
    {
      // Existing graphics code...
    }

    graphics.draw(*sprite, obj.x, obj.y);
  }

private:
  BjornPhysicsComponent* physics_;

  Sprite spriteStand_;
  Sprite spriteWalkLeft_;
  Sprite spriteWalkRight_;
  Sprite spriteJump_;
};
  • 간단하고 빠름: 한 객체가 다른 객체 메서드를 직접 호출, 컴포넌트는 참조하는 컴포넌트의 메서드를 제한없이 호출 가능
  • 두 컴포넌트가 강하게 결합

메시지를 전달하는 방식

  • 가장 복잡한 대안
  • 컨테이너 객체에 간단한 메시징 시스템을 만든 뒤에, 각 컴포넌트들이 서로에 정보를 뿌리게 할 수 있다.

  • 일단 컴포넌트에 메시지를 받는 receive메서드를 추가해야함.
    • 지금은 int로 메세지 구현, 제대로 구현하려면 데이터를 추가적으로 보내야함.
1
2
3
4
5
6
class Component
{
public:
  virtual ~Component() {}
  virtual void receive(int message) = 0;
};
  • 컨테이너 객체에는 메시지를 보내는 메서드를 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ContainerObject
{
public:
  void send(int message)
  {
    for (int i = 0; i < MAX_COMPONENTS; i++)
    {
      if (components_[i] != NULL)
      {
        components_[i]->receive(message);
      }
    }
  }

private:
  static const int MAX_COMPONENTS = 10;
  Component* components_[MAX_COMPONENTS];
};
  • 컴포넌트가 컨테이너에 접근할 수 있는 경우, 컴포넌트가 컨테이너에게 메시지를 보낼 수 있다.
    • 그러면 컨테이너는 포함된 모든 구성 요소에 메시지를 다시 브로드 캐스트한다.
    • 여기서 처음 메시지 보낸 컴포넌트도 포함되니, 피드백 루프를 조심해야한다.(이벤트 큐 패턴 참고)
  • 결과는 다음과 같다. - 하위 컴포넌트들은 디커플링된다.: 상태 공유 방식에서처럼 상위 컨테이너 객체를 통해서 통신하기 때문에, 컴포넌트들은 메시지 값과 커플링될 뿐, 컴포넌트끼리는 디커플링 상태 유지 - 컨테이너 객체는 단순하다: 무작정 메시지만 전달하면 끝.

    GOF의 중재자(mediator)패턴: 둘 이상의 객체가 중간 객체를 통해서 메시지를 간접적으로 전달해 통신하는 방법

결론

  • 세 방식 중에 정답은 없음
    • 조금씩 셋다 쓰게됨
  • 상태 공유 방식: 위치나 크기같은 기본적인 정보를 공유하기 좋음
  • 서로 가까운 분야: 입력과 AI, 물리와 충돌 => 쌍을 직접 알게 하는게 좋을 수 있음.
  • 메시지: 호출하고 나서 신경 안써도 되는 ‘사소한’통신에 좋음
    • 물리 컴포넌트- 충돌 => 오디오 컴포넌트- 소리

관련 자료

  • 유니티 프레임 워크의 핵심 클래스인 GameObject는 전적으로 컴포넌트 방식에 맞춰 설계되었다.

  • 오픈소스 엔진인 Delta3D는 actor에 컴포넌트 패턴 사용
  • 마소 XNA 게임 프레임워크에는 Game이라는 핵심 클래스가 있음.
    • 여기에 GameComponent에서 이 패턴 사용.
    • 컴포넌트 패턴을 메인 게임 객체 수준에서 적용함.
  • GoF의 전략 패턴과 비슷함
    • 두 패턴 모두 객체의 동작 일부를 별도의 하위 객체에 위임.
    • 전략객체: 상태가 없는 경우가 대부분.(데이터가 없음, 동작만 정의)
    • 컴포넌트: 객체를 표현하는 상태 소유, 정체성 정의됨

출처

component

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Behavioral Patterns: Type Object

[게임 프로그래밍 패턴] Decoupling Patterns: Event Queue