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

 

유니티 게임 오브젝트에 여러 컴포넌트를 붙였던 경험을 떠올려 쉽게 이해할 수 있었다.

 

멀티스레드 환경에서는 게임 코드를 분야별로 스레드의 방법을 사용한다. AI, 사운드, 렌더링을 각자 스레드에서 실행시킨다. 분야별로 디커플링 해야 교착상태 같은 동시성 버그를 피할 수 있다.

 

서로 통신이 필요한 컴포넌트가 있다면 필요한 컴포넌트 끼리의 결합을 사용할 수 있다.

 

14.3 패턴

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

 

14.4 언제 쓸 것인가?

게임 개체를 정의하는 핵심 클래스(GameObject)에서 가장 많이 사용되지만, 다음 조건 중 하나라도 만족한다면 다른 분야에서도 유용하게 쓸 수 있다.

 

1. 한 클래스에서 여러 분야를 건드리고 있어서 이들을 서로 디커플링하고 싶다.

2. 클래스가 거대해져서 작업하기가 어렵다.

3. 여러 다른 기능을 공유하는 다양한 객체를 정의하고 싶다. 단, 상속으로는 딱 원하는 부분만 골라서 재사용할 수가 없다.

 

14.5 주의사항

컴포넌트 패턴을 적용하면 클래스 하나에 코드를 모아놨을 때보다 더 복잡해질 가능성이 높다.

컴포넌트끼리 통신하기 어렵다.

각 컴포넌트의 객체를 생성하고 초기화를 잘 해줘야 한다.

대상 개체의 데이터에 접근하거나 행동을 수행하기 위해서 개체의 컴포넌트 객체를 거쳐야 하기 때문에 성능이 민감한 내부 루프 코드에서 이런 식으로 포인터를 따라가다 보면 성능이 떨어질 수 있다.

 

14.6 예제 코드

class GameObject {
public:
    int velocity;
    int x, y; // 컴포넌트간 통신을 위한 멤버 변수
    
    GameObject(InputComponent* input,
    		PhysicsComponent* physics,
                GraphicsComponent* grpahics): 
                input_(input),
                pyhics_(physics),
                graphics_(graphics) {
                
    }
    
    void update(Wolrd& world, Graphics& graphics) {
    	input_->update(*this);
        physics_->update(*this, world);
        graphics_->update(*this, graphics);
    }
    
private:
    InputComponent* input_;
    PhysicsComponent* physics_;
    GraphicsComponent* graphics_;
};
class PhysicsComponent {
	public:
    	virtual ~PhysicsComponent() {}
        virtual void update(GameObject& obj, World& world) = 0;
};

class BjronPhysicsComponent : public PhysicsComponent {
public:
    virtual void update(GameObject& obj, World& world) {
        
	}
};

 

14.7 디자인 결정

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

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

ㄴ 객체는 항상 필요한 컴포넌트를 가지게 된다.

ㄴ 객체를 변경하기가 어렵다. (컴포넌트 패턴의 강점 중 하나는 컴포넌트 재조합만으로 새로운 종류의 객체를 만들 수 있다는 점인데 어떤 컴포넌트를 사용할지를 하드코딩해놓으면, 이런 유연성을 잃게 된다.)

 

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

ㄴ 객체가 훨씬 유연해진다. 컴포넌트 구현 클래스만 변경해도 다르게 동작하는 컴포넌트를 객체에 제공할 수 있다.

ㄴ 객체를 구체 컴포넌트 자료형 (예제의 BjronPhysicsComponent)으로부터 디커플링 할 수 있다. 

 

컴포넌트들끼리는 어떻게 통신할 것인가? (중복 가능)

1. 컨테이너 객체의 상태를 변경(공유)하는 방식 (멤버 변수를 공유 정보로써 사용할 수 있다.)

ㄴ 컴포넌트들이 공유하는 정보를 컨테이너 객체에 전부 넣어야 한다.

ㄴ 컴포넌트끼리 암시적으로 통신하다 보니 컴포넌트 실행 순서에 의존하게 된다.

 

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

ㄴ 컴포넌트 구체 클래스의 생성자로 참조할 컴포넌트 객체를 인수로 전달한다.

ㄴ 참조하는 컴포넌트들 끼리 강하게 결합된다.

 

3. 메세지를 전달하는 방식

ㄴ 컴포넌트가 컨테이너에 메세지를 보내면, 컨테이너는 자기에게 있는 모든 컴포넌트에 이를 전파한다.

ㄴ 메시징은 호출하고 나서 신경 안 써도 되는 사소한 통신에 쓰기 좋다. 예를 들어 물리 컴포넌트에서 객체가 무엇인가와 충돌했다고 전파하면 오디오 컴포넌트가 이를 받아서 소리를 내는 식이다.

ㄴ 상태 공유 방식에서처럼 상위 컨테이너 객체를 통해서 통신하기 때문에, 컴포넌트들은 메세지 값과 커플링되지만 하위 컴포넌트들끼리는 디커플링 상태를 유지한다. 

class Component {
public:
    virtual ~Component() {}
    virtual void receive(int message) = 0;
};

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];
};

 

클래스 하나를 인스턴스 별로 다른 객체형으로 표현할 수 있게 만들어, 새로운 '클래스들'을 유연하게 만들 수 있게 한다.

 

Zombie - Monster, Dragon - Monster는 is-a 관게이다. 이런 관계를 전통적인 OOP에서는 클래스 상속으로 구현한다.

몬스터 종족의 수가 많아지면 코드를 추가, 수정하는데 비용이 많이 들게 된다.

 

Monster 클래스에서 종족 정보를 담는 Breed 클래스를 참조하게 만들면 해결할 수 있다.

Breed 객체는 설정 파일(json)에서 읽은 데이터로 생성한다.

 

타입 객체 패턴은 코드 수정 없이 새로운 타입을 정의할 수 있다는 게 장점이다. 코드에서 클래스 상속으로 만들던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮긴다.

 

13.3 패턴

타입 객체 클래스(Breed)와 타입 사용 객체 클래스(Monster)를 정의한다.

인스턴스 별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고,

개념적으로 같은 타입끼리 공유하는 데이터나 동작은  타입 객체에 저장한다.

 

13.4 언제 쓸 것인가?

나중에 어떤 타입(새로운 몬스터 종족)이 필요할지 알 수 없을 때

컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶을 경우

 

13.5 주의사항

타입 객체 패턴를 직접 관리해야 한다

상속 방식을 사용했을 때는 종족 클래스를 정의하는 데이터가 컴파일될 때 포함되는데,

타입 객체 패턴을 사용하면 종족 객체를 직접 만들고 초기화시켜 사용 객체 클래스(Monster)에 넘겨줘야 한다.

 

타입별로 동작을 표현하기가 더 어렵다

상속 방식에서는 메서드를 오버라이드해서 코드로 값을 계산하거나 다른 코드를 호출하는 등 마음대로 할 수 있다.

 

타입 객체 패턴에서는 종족 객체 변수에 데이터를 저장하는 방법으로 타입을 설정하기 때문에 동작을 정의하기는 어렵다.

우회적인 방법으로 바이트코드 패턴(11장)에서 하던 것처럼 동작 코드를 정의해놓은 뒤에 타입 객체 데이터에서 이 중 하나를 선택하는 것이다.

 

13.6 예제 코드

class Breed {
	public:
    	Monster* newMonster() {
        	// Monster에서 Breed가 friend로 선언되었기 때문에 생성자가 private이어도 가능하다.
        	// 팩토리 메서드 패턴의 생성자
            return new Monster(*this); 
        }
    
    	Breed(int health, const char* attack)
        : health_(health), attack_(attack) {}
        
        int getHealth() { return health_; }
        const char* getAttack() { return attack_; }
        
    private:
    	int health_;
        const char* attack_;
};

class Monster {
	friend class Breed;

	public:
        const char* getAttack() { return breed_.getAttack(); }
        
    private:
    	Monster(Breed& breed)
        : health_(breed.getHealth()), breed_(breed) {}
        
    	int health_;
        Breed& breed_;      
};
// 몬스터를 생성하는 코드
Monster* monster = someBreed.newMonster();

 

Breed 클래스에 생성자 함수 newMonster를 정의하면 Monster 클래스에 초기화 제어권을 넘겨주기 전에 메모리 풀이나 커스텀 힙에서 메모리를 가져올 수 있다. (위 예제의 return new Monster(*this)가 아닌 다른 방법으로 메모리를 얻을 수 있다는 뜻이다.)  몬스터를 생성할 수 있는 유일한 곳인 Breed 클래스 안에 이런 로직을 둠으로써, 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라 생성되도록 강제할 수 있다.

 

타입 객체 클래스 간의 상속(프로그래밍 언어의 상속이 아님)으로 데이터 공유하기

Breed 클래스의 멤버로 Breed 객체(부모 Breed)를 추가한다. // Breed* parent_;

하위 객체는 데이터를 상위 객체로부터 받을지, 자기 값으로 오버라이드 할지 제어할 수 있어야 한다.

 

1. 속성 값을 요청받을 때마다 동적으로 위임하는 방식으로 구현

ㄴ 종족 속성 값이 런타임에 바뀔 때 사용 

 

2. 객체 생성 시점에 위임 (copy - down 위임)

ㄴ 종족 속성 값이 바뀌지 않을때 사용

 

13.7 디자인 결정

타입 객체 시스템의 사용자는 프로그래머가 아닌 경우가 많아서 이해하기 쉽게 만들어야 한다.

 

타입 객체를 숨길 것이가? 노출할 것인가?

숨기는 경우

ㄴ 타입 사용 객체는 타입 객체로부터 동작을 선택적으로 오버라이드할 수 있다.

ㄴ 타입 객체 메서드를 전부 포워딩해야 한다.

 

노출하는 경우

ㄴ 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있다.

ㄴ 타입 객체가 공개 API의 일부가 된다.

타입 사용 객체를 어떻게 생성할 것인가?

타입 사용 객체를 생성한 뒤에 타입 객체를 넘겨주는 경우 (Monster* monster = new Monster(someBreed) )

ㄴ 외부 코드에서 메모리 할당을 제어할 수 있다.

 

타입 객체의 '생성자' 함수를 호출하는 경우 (위 예제의 방법, Monster* monster = someBreed.newMonster() )

ㄴ 타입 객체에서 메모리 할당을 제어한다.

타입을 바꿀 수 있는가?

타입을 바꿀 수 없다면 

ㄴ 디버깅 하기 쉽다.

 

타입을 바꿀 수 있다면

ㄴ 객체 생성 횟수가 줄어든다. (바꿀 수 없다면 객체를 지우고 다시 만드는 방식으로 구현해야 하기 때문에)

 

상속을 어떻게 지원할 것인가?

상속 없음, 단일 상속, 다중 상속 중 선택할 수 있다.

상속이 많을수록 데이터 중복을 피할 수 있지만, 이해하기 복잡하고 런타임 낭비가 일어날 수 있다.

상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의한다.

 

12.2 동기

Superpower라는 상위 클래스를 만든 후에 초능력별로 이를 상속받는 클래스를 정의한다고 가정

초능력 클래스가 수십 개가 넘으면 초능력 클래스를 길게 하드코딩 하는 것보다 데이터 기반으로 구현하는게 낫다.

타입 객체(13장), 바이트코드(11장), 인터프리터 같은 패턴을 사용할 수 있다.

 

ㄴ 중복 코드가 많아진다.

ㄴ 거의 모든 게임 코드가 초능력 클래스와 커플링된다.

ㄴ 외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다.

ㄴ 모든 초능력 클래스가 지켜야 할 규칙을 정의하기 어렵다.

class Superpower {
	public:
    	virtual ~Superpower() {}
        
	protected:
    	virtual void activate() = 0; // 추상 샌드박스 메서드
        void move(double x, double y, double z) {
         // 코드..
        }
        void playSound(SoundId sound, double volume) {
         // 코드..
        }
        
        double getHeroX() { }
        double getHeroY() { }
        double getHeroZ() { }
    }
}   

class  SkyLaunch : public Superpower {
	protected:
    	virtual void activate() { // 샌드박스 메서드를 오버라이딩한다. 
        // 하늘로 튀어 오른다.
        // 상위 클래스의 메서드들을 조합하여 기능을 구현한다.
        playSound(SOUND_SPROING, 1.0f); 
        spawnParticles(PARTICLE_DUST, 10);
        move(0, 0, 20);
   	}
};

12.5 주의사항

상위 클래스에 코드가 계속 쌓이는 경향이 있다.

많은 하위 클래스들이 상위 클래스를 통해서 게임 코드에 접근하므로 상위 클래스가 하위 클래스에서 접근하려는 모든 시스템(사운드, 이펙트)과 커플링 된다.

그렇기 때문에 상위 클래스를 조금만 바꿔도 문제가 생기는 하위 클래스가 생길 수 있다. (깨지기 쉬운 상위 클래스 문제)

 

12.7 디자인 결정

어떤 기능을 제공해야 하나?

외부 기능을 전부 상위 클래스에서 제공받으면 상위 클래스가 지나치게 복잡해질 수 있기 때문에 하위 클래스에서 처리할 수 있으면 그렇게 하는 것이 낫기 때문에 상위 클래스가 제공할 기능들을 선택해야 한다.

 

상위 클래스가 제공해야 할 기능

1. 모든 하위 클래스가 영향을 받는 기능

2. 외부 시스템의 상태를 변경하는 함수 (set 함수?)

ㄴ 이러한 함수는 외부 시스템과 강하게 커플링되기 때문에 상위 클래스로 옮겨주어 하위 클래스의 결합도를 낮춰야 한다.

 

메서드를 직접 제공할 것인가? 이를 담고 있는 객체를 통해서 제공할 것인가?

개별 기능들을 전부 메서드화 하여 상위 클래스에 넣으면 메서드가 너무 많아질 수 있기 때문에 역할이 비슷한 기능들을 하나의 클래스에 묶고 그 객체를 반환하는 메서드를 제공하는 방법을 사용할 수 있다.

 

상위 클래스는 필요한 객체를 어떻게 얻는가?

1. 상위 클래스의 생성자 인수로 받기

ㄴ 하위 클래스 생성자도 인수로 받아야 하는 문제가 있다.

 

2. 2단계 초기화

ㄴ 상위 클래스 내부에 init 함수를 두어 필요한 객체를 init 함수의 인자로 받는다.

1단계 : 객체 생성

2단계 : 객체->init(필요한 객체들);

 

3, 정적 객체로 만들기

필요한 객체가 예를 들어 파티클 시스템(싱글턴)이라면 상위 클래스에 정적 객체를 만들고 초기화 했을 때 모든 인스턴스에서 파티클 시스템에 접근할 수 있고, 인스턴스별로 파티클 시스템 객체를 저장할 필요가 없어 메모리 사용량을 줄일 수 있다.

class Superpower {
	public:
    	static void init(ParticleSystem* particles) {
        	particles_ = particles;
        }
       	
        // 샌드박스 메서드와 그 외 다른 기능들...
    
    private:
    	static ParticleSystem* particles_;
};

 

4. 서비스 중개자를 이용하기

상위 클래스가 필요로 하는 객체를 직접 가져와 스스로 초기화 하는 방법 (서비스 중개자 패턴, 16장)

class Superpower {
	protected:
    	void spawnParticles(ParticleType type, int count) {
        	particleSystem& particles = Locator::getParticles();
            particles.spawn(type, count);
        }
    }
    // 샌드박스 메서드와 그 외 다른 기능들
};

가상 머신 명령어를 인코딩한 데이터로 행동을 표현할 수 있는 유연함을 제공한다.

 

큰 규모의 프로젝트는 빌드에 시간이 오래 걸릴 수 있기 때문에, 자주 고칠 수 있어야 하는 코드는 데이터 파일로 빼서 쉽게 수정할 수 있도록 해야 한다.

 

기계어의 성능과 인터프리터 패턴의 안정성 사이에서 절충해야 한다.

 

가상 기계어(바이트 코드)와 가상 머신(가상 기계어를 실행하는 에뮬레이터 = 인터프리터)을 사용

 

11.3 패턴

명령어 집합은 실행할 수 있는 저수준 작업들을 정의한다.

명령어는 일련의 바이트로 인코딩된다.

가상 머신은 중간 값들을 스택에 저장해가면서 명령어를 하나씩 실행한다.

 

11.4 언제 쓸 것인가?

1. 게임 구현에 사용된 언어가 너무 저수준이라 만드는 데 손이 많이 가거나 오류가 생기기 쉬운 경우 고수준으로 표현하기 위해 사용한다.

2. 컴파일 시간이나 다른 빌드 환경 때문에 반복 개발하기가 너무 오래 걸리는 경우

3. 정의하려는 행동이 보안에 취약한 경우

 

바이트코드는 네이티브 코드보다는 느리므로 성능이 민감한 곳에는 적합하지 않다.

enum Instruction {
  INST_SET_HEALTH = 0x00,
  INST_SET_WISDOM = 0x01,
  INST_PLAY_AGILITY = 0x02,
  INST_PLAY_SOUND = 0x03,
  INST_SPAWN_PARTICLES = 0x04
};

class VM {
public: 
  void interpret(char bytecode[], int size) {
    for (int i = 0; i < size; i++) {
      char instruction = bytecode[i];
      
      switch (instruction) {
  		case INST_SET_HEALTH:
    	  setHealth(0, 100);
    	  break;
        case INST_SET_WISDOM:
          setWisdom(0, 100);
          break;
  
        case INST_SET_AGILITY:
          setAgility(0, 100);
          break;
  ...
      }
    }
  }
}

대부분의 바이트코드 VM은 1바이트로 명령어를 표현한다. (256개의 명령어)

 

스택 머신

class VM {
  public:
    void interpret(char bytecode[], int size) {
    
    .. 위 예제와 동일 ..
    
    switch (instruction) {
    
      case INST_SET_HEALTH:
        int amount = pop();
        int wizard = pop();
        setHealth(wizard, amount);
        break;
    
    ....
    
      case INST_LITERAL: 
        int value = bytecode[++i];
        push(value);
        break;
    }
    
    
  }
  
  private:
    static const int MAX_STACK = 128;
    int stackSize_;
    int stack_[MAX_STACK];
  
    void push(int value){
      assert(stackSize_ < MAX_STACK);
      stack_[stackSize_++] = value;
    }
    
    int pop(){
      assert(stackSize_ > 0);
      return stack_[--stackSize_];
    }

명령어가 매개변수를 받을 때는 스택을 이용해서 전달할 수 있다.

리터럴 명령어는 바이트 코드에서 바이트 배열에서 다음 인덱스의 바이트 값을 읽는다.

위에서 구현한 숫자 리터럴의 경우 바이트 값을 그대로 int 변수에 저장한다.

 

GET_HEALTH 명령어와 ADD 명령어같은 명령어도 추가할 수 있다.

 

ADD 명령어는 스택에서 두 값을 pop하여 결과를 push하는 방식으로 구현할 수 있다.

 

고수준 행동을 바이트 코드로 만들어 주는 툴이 필요하다

GUI 툴, 텍스트 기반 언어 컴파일러를 사용할 수 있다.

 

11.7 디자인 결정

두가지 종류의 바이트 코드 VM

1. 스택 기반 VM

ㄴ 명령어가 짧다.

ㄴ 코드 생성이 간단하다.

ㄴ 하나의 행동을 수행하는데 필요한 명령어 개수가 많다.

 

2. 레지스터 기반 VM

ㄴ 명령어가 길다.

ㄴ 명령어의 개수는 줄어든다.

 

어떤 명령어를 만들어야 하는가?

1. 외부 원시명령

ㄴ 게임 코드에 접근하고 유저가 볼 수 있는 일들을 처리한다. (위 예제에서는 INST_SET_HEALTH 와 같은 것들)

 

2. 내부 원시명령

ㄴ 리터럴, 연산, 비교, PUSH, POP 같은 명령어들

 

3. 흐름 제어

ㄴ 반복문을 구현하기 위해 jump가 필요하다. (바이트 코드의 실행 위치를 옮기는 명령)

 

4. 추상화

ㄴ 고수준 언어에서의 함수 역할을 하기 위해 call(호출) 과 return(반환) 명령이 필요하다.물론 jump도 필요하다.

 

값을 어떻게 표현할 것인가?

1. 단일 자료형

ㄴ 위의 예제처럼 하나의 자료형을 사용하는 방법

 

2. 태그 붙은 변수

ㄴ 8비트를 사용한다고 가정하면 앞의 2비트는 자료형(int인지 double인지 string인지)를 구분하는데 사용, 나머지 6비트는 데이터로 사용(공용체)

ㄴ 런타임에서 값의 자료형을 확인할 수 있다는 장점이 있다.

ㄴ 타입을 검사하느라 클럭을 낭비한다.

enum ValueType {
  TYPE_INT,
  TYPE_DOUBLE,
  TYPE_STRING
};

struct Value {
  ValueType type;
  union {
    int intValue;
    double doubleValue;
    char* stringValue;
  };
};

 

3. 태그가 붙지 않은 공용체

ㄴ 공용체를 사용하되 자료형 태그는 따로 없어서 사용자가 알아서 해석해야 한다.

ㄴ 안전하지 않다.

 

4. 인터페이스

ㄴ Value 인터페이스를 만들고 IntValue FloatValue 등의 구현 클래스가 상속받게 하는 방법이다.

ㄴ 자료형마다 클래스를 만들어주는게 번거롭고 비효율적(다형성은 포인터를 통해서 동작하기 때문에)이다.

 

결론 : 되도록이면 자료형은 하나만 쓰거나 안되면 태그 붙은 변수를 쓰자.

바이트코드는 어떻게 만들 것인가?

1. 텍스트 기반 언어를 정의할 경우

ㄴ 문법을 정의해야 한다.

ㄴ 파서를 구현해야 한다.

ㄴ 문법 오류를 처리해야 한다.

 

2. UI가 있는 저작 툴을 만들 경우

ㄴ UI를 구현해야 한다.

ㄴ 오류가 적다.

ㄴ 이식성이 낮다. (UI 프레임워크가 OS에 종속적이기 때문에)

컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘 전체를 시뮬레이션한다.

 

모든 개체가 자신의 동작을 캡슐화(update 함수로 만들기)해야 게임 루프에 개체를 쉽게 추가, 삭제할 수 있다.

큰 update 안에 각 객체마다의 update가 있다.

 

10.4 업데이트 메서드는 언제 쓰는 것이 좋은가?

1. 동시에 동작해야 하는 객체나 시스템이 게임에 많은 경우

 

2. 각 객체의 동작은 다른 객체와 독립적이어야 한다.

 

3. 객체는 시간의 흐름에 따라 시뮬레이션되어야 한다.

 

10.5 주의사항

1. 코드를 한 프레임 단위로 끊어서 실행해야 하기 떄문에, 코드가 복잡해질 수 있다.

 

2. 다음 프레임에서 다시 시작할 수 있도록 현재 상태를 저장해야 한다. (상태 패턴(7장) 을 사용하면 좋다.)

 

3. 모든 객체는 매 프레임마다 시뮬레이션되지만 진짜로 동시에 되는 건 아니다.

ㄴ 객체 업데이트 순서를 신경 써야 한다. 한 위치에 하나의 객체만 존재할 수 있다고 가정할 때, 서로 다른 두 객체 A와 B가 같은 위치로 움직이려 한다면 업데이트 순서에 따라 결과가 다를 것이다. 

순차적으로 업데이트를 진행하지 않고 병렬적으로 객체를 업데이트 하고 싶다면 (이중 버퍼 패턴(8장) 같은 게 필요하다.)

 

4. 업데이트 도중에 객체 목록을 바꾸는 건 조심해야 한다.

ㄴ 업데이트 도중 객체를 추가하거나 제거할 때 순차적으로 update하다 보면 아직 렌더링 되지 않은 객체의 update문이 실행될 수도 있고, 업데이트하려는 객체 이전에 있는 객체를 삭제할 경우, 객체를 하나 건너뛸 수 있다.

 

ㄴ 객체 추가 문제는 업데이트 가능 객체 개수를 프레임 시작시에 미리 저장하여 해결할 수 있고, 객체 삭제 문제는 순회 변수를 조정하거나 객체에 '죽었음' 표시를 하고, 전체 루프가 끝날 때 객체 컬렉션에서 제거하여 해결할 수 있다.

 

10.7 디자인 결정

업데이트 메서드를 어느 클래스에 둘 것인가?

1. 개체 클래스

개체마다 update() 함수를 정의 ( 

 

2. 컴포넌트 클래스

컴포넌트 패턴은 한 개체의 일부를 개체의 다른 부분들과 디커플링 한다.

컴포넌트는 알아서 자기 자신을 업데이트 한다.

 

3. 위임 클래스

개체의 update() 내부에서 다른 클래스의 update() 를 호출 

여러 개체가 동작을 공유할 수 있게 해준다.

 

휴면 객체 처리

update가 필요 없는 객체가 생기는 경우 순회 객체 대상에서 제거 해야 한다.

살아 있는 객체만 컬렉션에 모아두면 된다.

 

1. 비활성 객체가 포함된 컬렉션 하나만 사용할 경우

ㄴ 활성 상태인지 확인하여 아니면 넘어간다. (CPU 클럭 낭비 & 데이터 캐시 낭비)

 

2. 활성 객체만 모여 있는 컬렉션을 하나 더 둘 경우

ㄴ 두 번째 컬렉션을 위해 메모리를 추가로 사용해야 한다.

ㄴ 컬렉션 두 개의 동기화를 유지해야 한다. (객체가 생성되거나 소멸되는 경우 두 컬렉션을 모두 변경해야 한다.)

ㄴ 비활성 객체가 많을수록 컬렉션을 따로 두는 게 좋다.

 

게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링 해야 한다.

게임은 유저 입력이 없어도 계속 돌아간다. (루프가 존재한다.)

게임 루프의 코드는 프로그램 실행 시간의 대부분을 차지하기 때문에 최적화를 고려하여 깐깐하게 만들어야 한다.

d3dApp.cpp의 Run()에 존재하는 루프문

게임 루프의 핵심 업무는 어떤 하드웨어에서라도 일정한 속도로 실행될 수 있도록 하는 것이다.

좋은 하드웨어에서의 환경과 좋지 않은 환경에서의 while문 내부 코드 소요 시간은 차이가 있을 수 있기 때문에 프레임을

60FPS로 고정하면 (좋지 않은 환경에서도 60FPS는 보장 된다는 가정 하에) 게임이 느려지는 문제는 회피할 수 있다.

 

Sleep(100); 코드는 사실 sleep(while문 시작 시점 시간 - while문 끝 시점 시간 + 100) 으로 써야 정확하다.

 

가변 시간 간격, 유동 시간 간격

위 예제에서 mTimer.Tick() 함수는 현재 시각을 기록하고 mTimer 객체는 현재 시각과 이전 시각을 갖고 있다.

Update와 Draw 함수에 mTimer 객체를 넘겨줌으로써 시간 간격 차이를 이용해 게임 월드 상태를 진행할 수 있다.

 

예를 들면 총알의 위치를 속도 * 시간 간격으로 계산 할 수 있다.

 

가변 시간 간격 방식을 사용하면 게임이 비결정적이게 된다.

네트워크 게임에서 위의 총알 위치를 계산하는 방법을 예시로 들자면

1초에 총알을 위치를 50번 계산하는 PC에서와 5번 계산하는 PC에서의 총알의 위치는 다를 수 있다.

ㄴ 기존 위치 + 변위 값을 통해 위치를 계산하게 될텐데 변위값은 float이기 때문에 숫자를 누적할수록 반올림 오차가 커질 수 있기 때문이다.

 

물리 계산은 고정 시간 간격을 사용하되 렌더링되는 간격은 유연하게 하는 방법

렌더링되는 간격을 60FPS로 고정시킨다면 프로세서의 낭비이다. (더 자주 렌더링 할 수 있는데도 막는 것이므로)

 

double previous = getCurrentTime();
double lag = 0.0;
while (true) {
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;
  processInput();
  
  while (lag >= MS_PER_UPDATE) {
  	update();
    lag -= MS_PER_UPDATE;
  }
  render(lag / MS_PER_UPDATE);
}

render 함수의 인자는 위치 보간에 쓰인다.

update()와 render()가 동일한 시각에 호출된다는 보장이 없기 때문에 물체의 움직임이 튀어 보일 수 있다.

 

렌더러가 게임 객체들과 각각의 속도를 안다고 가정할 때, 아마도 정점 셰이더에서 이 인자를 이용해 위치 보간을 하지 않을까 생각한다. (확실하지 않음..) 실제로 물리 계산에 필요한 정보를 수정하는 것은 의미가 없기 때문이다. 

 

60fps 고정 시간 간격 환경에서의 함수 호출 빈도(16ms = 1/60초 라고 가정)

9.7 디자인 결정

1. 게임 루프를 직접 관리하는가, 플랫폼이 관리하는가?

ㄴ 플랫폼 이벤트 루프 사용

ㄴ 게임 엔진 루프 사용

ㄴ 직접 만든 루프 사용

 

2. 전력 소모 문제

ㄴ 프레임율 제한

ㄴ 프레임율 제한 해제

 

3. 게임 플레이 속도는 어떻게 제어할 것인가?

ㄴ 동기화 없는 고정 시간 간격 방식 - 게임 속도가 하드웨어와 게임 복잡도에 영향을 받는다.

ㄴ 동기화하는 고정 시간 간격 방식 - 고정 시간 간격으로 게임을 실행하되, 루프 마지막에 지연(sleep)이나 동기화 지점을 넣어서 게임이 너무 빨리 실행되는 것을 막는다.

ㄴ 가변 시간 간격 방식

ㄴ 업데이트는 고정 시간 간격으로, 렌더링은 가변 시간 간격으로

게임의 렌더링 시스템에서는 프레임 버퍼를 최소 두 개 사용한다. 프레임 버퍼에 대한 읽기와 쓰기를 동시에 수행할 수 있도록 하기 위함이다.

 

전체 장면을 한 번에 화면에 띄워주기 위해 한 프레임 버퍼가 화면에 띄워질 동안 다른 프레임 버퍼는 렌더링 코드에 의해 값이 쓰여진다. 값이 쓰여진 프레임 버퍼는 다시 GPU가 읽어 화면에 출력해 준다. 이 과정이 반복된다.

 

8.3 패턴

버퍼 클래스는 현재 버퍼와 다음 버퍼, 이렇게 두 개의 버퍼를 갖는다.

정보를 읽을 때는 현재 버퍼에 접근, 정보를 쓸 때는 다음 버퍼에 접근, 변경이 끝나면 다음 버퍼와 현재 버퍼를 교체한다.

 

DirectX에서는 렌더링을 위한 이중 버퍼를 지원한다. IDXGISwapChain 를 버퍼 클래스로 사용한다. GetBuffer로 버퍼 객체를 얻을 수 있다.

객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴으로, 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보인다.

 

7.2 유한 상태 기계의 일종인 플로 차트를 그리자

유니티의 애니메이터 컨트롤러는 상태 머신을 사용하여 애니메이션 클립 전환을 관리한다.

FSM의 특징

1. 가질 수 있는 상태가 한정된다. (점프, 엎드리기, 내려찍기 등)

2. 한 번에 한 가지 상태만 가질 수 있다. (점프와 동시에 엎드리기는 안된다.)

3. 입력이나 이벤트가 기계에 전달된다. (버튼 누르기와 버튼 떼기와 같은 입력)

4. 각 상태에는 입력에 따라 다음 상태로 바뀌는 전이(조건)가 있다. (현재 상태에 전이 조건이 없으면 어느 입력이 들어와도 무시한다.)

상태 패턴은 FSM을 구현하는 방법 중 하나이다.

 

7.3 상태 패턴을 구현 하는 방법 - 열거형과 다중 선택문

enum State{

  STATE_JUMPING,

  STATE_DUCKING,

}

입력이나 이벤트를 받는 부분에서는 상태에 따라 분기한 뒤에 입력에 따라 분기한다.

 

열거형만으로 기능을 충분히 구현할 수 있는 경우는 굳이 인터페이스를 쓰지 않아도 된다.

상태를 바꾸는데 기록해야 될 것들이 많고 복잡해지면 열거형 보다는 상태 별로 클래스를 정의하여 구현하는 방법이 있다.

7.4 인터페이스를 사용하여 구현하는 상태 패턴

1. 상태 인터페이스를 정의한다.

STATE_XXXXX를 사용하는 모든 코드 ( 상태에 의존하는 코드들, 예제에서는 handleInput()과 update() )들을 인터페이스의 가상 메서드로 만든다.

class HeroineState { // 상태 인터페이스
public:
  virtual ~HeroineState() {} 
  virtual void handleInput(Heroine& heroine, Input, input) {}
  virtual void update(Heroine& heroine) {}
};

2. 상태별 클래스를 만든다. (상태 인터페이스를 구현)

class JumpingState : public HeroineState {
public:
  JumpingState() : {}
  
  virtual void handleInput(Heroine& heroine, Input input){
    if(input == PRESS_DOWN) {
      heroine.setGraphics(IMAGE_DIVE);
      // 내려찍기 상태로 전환
  }
  
  virtual void update(Heroine& heroine){
    // jumpTime_을 update에서 감소시키고, jumpTime_이 0 보다 작아지면
    // heroine.state_에 StandingState를 대입하여 상태 전이를 구현할 수 있을 것이다.
  }
  
  private:
    int jumpTime_; // 점프 상태에서만 의미 있는 변수 (체공 시간?)
};

3. 대상(캐릭터)에 상태 객체 포인터를 추가한다.

class Heroine{
public:
  virtual void handleInput(Input input){
    state_->handleInput(*this, input);
  }
  
  virtual void update() { state_->update(*this); }
  
private:
  HeroineState* state_; // 상태 객체 포인터
};

ㄴ 상태를 바꾸려면 이 포인터에 상태별 클래스 객체를 할당하면 된다.

 

7.5 상태별 클래스 객체를 유지하는 두 가지 방법

1. 정적 객체

일반적으로 상위 상태 클래스에 정적 상태별 클래스 객체를 만들어 둔다.

 

2. 상태 객체 만들기

캐릭터 마다 점프력이 달라 체공시간을 다르게 두어야 할 수도 있기 때문에 정적 객체만으로 부족한 경우 사용하는 방법이다. 이 경우에는 상태를 전이할 때마다 상태 객체를 만들어야 한다.

이 방법은 매번 상태 객체를 할당하기 위해 new 키워드를 사용하여 메모리와 CPU를 사용해야 한다.

상태를 동적으로 할당하면서 메모리 단편화가 생길 수 있다.

void Heroine::handleInput(Input input) {
// 상태가 바뀌는 경우(상태를 바꾸는 입력이 들어온 경우) NULL이 아닌 상태 객체를 반환한다.
  HeroineState *state = state_->handleInput(*this, input); 
  if (state != NULL) {
    delete state_; // 기존 상태를 해제하고 새로운 상태를 가리키도록 한다.
    state_ = state;
  }
}


HeroineState* JumpingState::handleInput(Heroine& heroine, Input input) {
  if(input == PRESS_DOWN) {
    return new DivingState();
  }
  return NULL;
}

 

7.6 상태의 입장과 퇴장 함수

현재 상태에서 다음 상태로의 전이가 일어날 때 (state를 대입할 때) 다음 상태의 설정을 초기화 하는 것은 어떻게 보면 현재 상태에서 다음 상태를 설정하는 것이다.

 

상태 객체에 입장 함수 enter를 정의하여 설정을 초기화하는 코드를 넣고,  전이가 일어난 이후 enter를 호출하도록 하면

상태 객체는 캡슐화 되었다고 할 수 있다.

 

7.7 단점

인공지능같이 복잡한 곳에 적용하기 힘들다.

상태 전이 구조를 바꾸기 어렵다.

 

7.8 병행 상태 기계

두 종류의 상태를 유지해야 하는 경우, 두 상태 기계가 서로 전혀 연관이 없다면 이 방법을 사용할 수 있다.

class Heroine {
private:
  HeroineState* state_;
  HeroineState* equipment_;
};

7.9 계층형 상태 기계

서로 다른 여러 가지 상태에서 동일한 입력으로 하나의 상태로 전이되는 경우 단순하게 상태 기계를 구현한다면 상태 전이 코드를 상태마다 중복해 넣어야 한다.

 

계층형 상태 기계

상태를 상위층과 하위층으로 나누고, 하위 상태는 상위 상태를 상속받는 개념이다. 클래스 상속으로 구현할 수 있다.

 

7.10 푸시다운 오토마타

FSM에는 이력 개념(현재 상태는 알 수 있지만 직전 상태가 무엇인지를 따로 저장하지 않음)이 없기 때문에 이전 상태로 쉽게 돌아갈 수 없다.

예를 들면 서있는 상태나 엎드려있는 상태에서 총을 쏘면 FiringState로 전이하고 다시 원래의 상태로 돌아가야 한다고 하면 이전에 엎드려있는 상태였다면 엎드려있는 상태로, 서 있는 상태라면 서 있는 상태로 되돌아가야 한다.

 

푸시다운 오토마타에서는 상태를 스택으로 관리한다.

class Heroine {
public 
  void pushState(HeroineState* state) {}; // s에 새로운 상태를 넣는다.
  void popState() {}; // s에서 최상위 상태(현재 상태)를 빼는 함수를 정의해야 한다.
  						// 수행하면 이전 상태가 현재 상태가 된다.

private:
  // HeroineState* state_;
  // HeroineState* equipment_;
  
  std::stack<HeroineState*> s;
};

+ Recent posts