의도
컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션한다. 쉽게 말하자면 게임에 존재하는 객체들의 동작이나 상태를 한 프레임씩 업데이트 하는 것이다.
동기
던전을 지키는 해골 전사가 있고 이 전사가 문 주위를 순찰한다고 해보자. 해골 전사를 왔다갔다하는 코드를 가장 간단하게 만든다면 다음과 같을 것이다.
while(true)
{
// 오른쪽으로 간다.
for(double x = 0; x < 100; x++)
skeleton.setX(x);
// 왼쪽으로 간다.
for(double x = 100; x > 0; x--)
skeleton.setX(x);
}
하지만 이 코드는 무한루프가 있어서 해골 전사가 순찰도는 걸 플레이어는 볼 수 없다는 문제가 있다. 우리가 원하는 것은 해골이 한 프레임에 한 걸음씩 걸어가는 것이다.
이 루프를 제거하고 외부 게임 루프를 통해서 반복하도록 고쳐야한다. 이러면 해골 전사가 한 프레임에 한 걸음씩 움직이며 그동안에도 끊이지 않고 유저 입력에 반응하고 렌더링 할 수 있다.
Skeleton skeleton;
bool goLeft = false;
double x = 0.0;
// 게임 메인 루프
while(true)
{
if(goLeft)
{
x--;
if(x <= 0) goLeft = false;
}
else
{
x++;
if(x >= 100) goLeft = true;
}
skeleton.setX(x);
// 유저 입력을 처리하고 게임을 렌더링한다...
}
이전에는 for 루프 두개만 써서 좌우로 순찰했고 이동 방향은 어느 루프가 실행중인지 보고 암시적으로 알 수 있었다. 하지만 이제는 매 프레임마다 외부 게임 루프로 나갔다가 직전 프레임의 해골 전사의 위치에서 다시 시작하기 때문에, goLeft
변수를 써서 방향을 명시적으로 기록해야 한다. 게임 루프에 대해서는 게임 루프 패턴을 참고하자.
어쨌거나 동작은 하니 계속 진행해보자. 해골 전사 한 마리는 너무 쉬우니 번개를 쏘는 석상을 두 개 추가해보자. 지금까지 한 것처럼 한다면 코드는 다음과 같이 될 것이다.
// 해골 전사용 변수들...
Statue leftStatue;
Statue rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;
while(true)
{
// 해골 전사용 코드...
if(++leftStatueFrames == 90)
{
leftStatueFrames = 0;
leftStatue.shootLightning();
}
if(++rightStatueFrames == 90)
{
rightStatueFrames = 0;
rightStatue.shootLightning();
}
// 유저 입력을 처리하고 게임을 렌더링한다...
}
점점 코드가 길어지고 유지보수하기 어려워진다. 메인 루프에는 각자 다르게 처리할 게임 객체용 변수와 실행 코드가 가득 들어있다. 이들 모두를 한 번에 실행하려다 보니 코드를 한데 뭉쳐놔야 한다.
해결책은 모든 객체가 자신의 동작을 캡슐화하는 것이다. 이러면 게임 루프를 어지럽히지 않고도 쉽게 객체를 추가, 삭제할 수 있다. 이를 위해 추상 메서드인 update()
를 정의해 추상 계층을 더한다. 게임 루프는 업데이트가 가능하다는 것만 알뿐 정확한 자료형은 모르는 채로 객체 컬렉션을 관리한다.
게임 루프는 매 프레임마다 객체 컬렉션을 순회하면서 update()
를 호출한다. 이때 각 객체는 한 프레임만큼 동작을 진행한다. 덕분에 모든 게임 객체가 한 프레임 내에 모두 동작한다.
패턴
게임 월드는 객체 컬렉션을 관리한다. 각 객체는 한 프레임 단위의 동작을 시뮬레이션하기 위한 업데이트 메서드를 구현한다. 매 프레임마다 게임은 컬렉션에 들어 있는 모든 객체를 업데이트한다.
Unity에서는 MonoBehaviour
를 상속받은 객체의 void Update()
가 매 프레임마다 호출된다. Unreal Engine에서는 Actor
를 상속받은 객체의 Tick()
이 매 프레임마다 호출된다. 즉, 상용 게임 엔진에서는 이미 업데이트 메서드 패턴을 사용하고 있고 엔진을 사용하는 개발자는 단지 각 객체가 하는 일을 업데이트 메서드 안에 작성하기만 하면 된다.
언제 쓸것인가?
업데이트 메서드는 이럴 때 쓸 수 있다.
- 동시에 동작해야 하는 객체나 시스템이 게임에 많다.
- 각 객체의 동작은 다른 객체와 거의 독립적이다.
- 객체는 시간의 흐름에 따라 시뮬레이션되어야 한다.
주의 사항
코드를 한 프레임 단위로 끊어서 실행하는 게 더 복잡하다
앞의 예제 코드 중에서 마지막 코드가 조금 더 복잡하다. 마지막 코드는 게임 루프가 매 프레임마다 호출하도록 제어권을 넘긴다. 유저 입력, 렌더링 등을 게임 루프가 처리하려면 거의 언제나 마지막 코드처럼 만들어야 하기 때문에, 사실상 나머지 예제 코드는 현실성이 없다. 그래도 이런 식으로 동작 코드를 프레임마다 조금씩 실행되도록 쪼개어 넣으려면 코드가 복잡해져서 구현 비용이 더 든다는 것만 알아두자.
거의라고?
거의라고 한것은 도중에 반환하지 않는 직관적인 코드를 유지하면서도, 동시에 게임 루프 안에서 여러 객체를 실행할 수 있는 일석이조의 방법이 있기 때문이다. 동시에 여러 개의 실행 '흐름'을 돌릴 수 있는 시스템만 있으면 된다. 객체용 코드가 반환 대신 중간에 잠시 멈췄다가 다시 실행할 수만 있다면 코드를 훨씬 직관적으로 만들 수 있다. 스레드는 이런 용도로 쓰기엔 너무 무겁다. 하지만 언어에서 제너레이터(generator), 코루틴(coroutine), 파이버(fiber)같은 가벼운 동시성 구조물을 지원한다면 이를 활용할 수 있다.
다음 프레임에서 다시 시작할 수 있도록 현재 상태를 저장해야 한다
예제에서 해골 전사의 이동 방향을 변수에 따로 저장해줘야 했다. 코드가 반환하고 나면 이전 실행 위치를 알 수 없기 때문에 다음 프레임에서도 돌아갈 수 있도록 현재 프레임에서의 정보를 따로 저장해야 한다.
이럴때 상태 패턴이 좋을 수 있다. 상태 기계가 게임에서 많이 쓰이는 이유 중 하나는 이전에 중단한 곳으로 되돌아 갈때 필요한 상태를 상태 기계가 저장하기 때문이다.
모든 객체는 매 프레임마다 시뮬레이션되지만 동시에 되는건 아니다
게임 루프는 컬렉션을 돌면서 모든 객체를 업데이트한다. update
함수가 업데이트 중인 다른 객체에도 접근할 수 있다보니 객체 업데이트 순서가 중요하다.
객체 목록에서 A가 B보다 앞에 있다면, A는 B의 이전 프레임 상태를 본다. B 차례가 왔을 때 A는 이미 업데이트했기 때문에, B는 A의 현재 프레임 상태를 보게된다. 플레이어에게는 모두가 동시에 움직이는 것처럼 보일지 몰라도 내부에서는 순서대로 업데이트된다. 다만 한 프레임 안에 전체 객체를 도는 것뿐이다.
(어떤 이유에서건 이렇게 순차적으로 동작하지 않게 하려면 이중 버퍼 패턴 같은 게 필요하다. 이 패턴은 A, B 둘 다 이전 프레임의 상태를 보도록 하기 때문에 A, B의 순서가 더 이상 문제가 되지 않게 된다.)
순차적으로 업데이트하면 게임 로직을 작업하기가 편하다. 객체를 병렬로 업데이트하다보면 꼬일 수 있다. 예를 들어 체스에서 흑과 백이 동시에 이동할 수 있다고 해보자. 둘 다 동시에 같은 위치로 말을 이동하려 든다면 어떻게 해야 할까? 순차 업데이트에서는 이런 문제를 피할 수 있다. 게임 월드가 유효한 상태를 유지하면서 업데이트할 때마다 점진적으로 바꿔나가면 상태가 꼬이지 않아 중재할 게 없다. 또한 여러 이동을 직렬화해서 네트워크로 전송할 수 있으므로 온라인 게임 만들기에도 좋다.
업데이트 도중에 객체 목록을 바꾸는 건 조심해야 한다
이 패턴에서는 많은 게임 동작이 update()
안에 들어가게 된다. 그 중에는 업데이트 가능한 객체를 게임에서 추가, 삭제하는 코드가 포함된다.
예를 들어 해골 전사를 죽이면 아이템이 떨어진다고 해보자. 객체가 새로 생기면 보통은 객체 목록 뒤에 추가하면 된다. 계속 객체 목록을 순회하다 보면 결국에는 새로 만든 아이템 객체까지 도달해 그것까지 업데이트하게 될 것이다. 하지만 이렇게 하면 새로 생성된 객체가 스폰된 걸 플레이어가 볼 틈도 없이 해당 프레임에서 작동하게 된다. 이게 싫다면 업데이트 루프를 시작하기 전에 목록에 있는 객체 개수를 미리 저장해놓고 그만큼만 업데이트하면 된다.
// 현재 프레임의 객체 개수를 미리 저장
int numObjectsThisFrame = numObjects;
// 저장된 개수만큼만 순회하며 업데이트한다.
for(int i = 0; i < numObjectsThisFrame; i++)
{
objects[i]->update();
}
순회 도중에 객체를 삭제하는건 더 어렵다. 해골 전사를 죽였다면 객체 목록에서 빼야 한다. 순회 도중 객체를 삭제할 경우 의도치 않게 객체 하나를 건너뛸 수 있다. 이를 고려해서 여러 가지 방법이 있다.
- 객체를 삭제할 때 순회 변수 i를 업데이트한다.
- 목록을 다 순회할 때 까지 삭제를 늦춘다.
- 객체에 죽었음을 표시하되 그대로 둬서 업데이트 도중에 죽은 객체를 만나면 그냥 넘어간다. 전체 목록을 다 돌고 나면 다시 목록을 돌면서 죽은 객체를 삭제한다.
예제 코드
실제 게임 코드였다면 그래픽스나 물리 처리를 위한 다른 코드가 더 있겠지만, 지금 우리에게 중요한 부분은 추상 메서드인 update
이기 때문에 설명에 꼭 필요한 코드만 넣는다. 해골 전사와 석상을 표현할 Entity
클래스를 만들어보자. 코드는 다음과 같다.
class Entity
{
public:
Entity() : x_(0), y_(0) {}
virtual ~Entity() {}
virtual void update() = 0;
double x() const { return x_; }
double y() const { return y_; }
void setX(double x) { x_ = x; }
void setY(double y) { y_ = y; }
private:
double x_;
double y_;
};
게임은 Entity
컬렉션을 관리한다. 예제에서는 게임 월드를 대표하는 World
클래스에 이를 맡긴다. 코드는 다음과 같다.
class World
{
public:
World() : numEntities_(0) {}
void gameLoop();
private:
Entity* entities_[MAX_ENTITIES];
int numEntities_;
};
이제 모든게 준비되었다. 매 프레임마다 Entity
를 업데이트하면 업데이트 메서드 구현이 끝난다. 매 프레임마다 업데이트 메서드를 호출하는 함수는 게임 루프 패턴을 사용한다.
void World::gameLoop()
{
while(true)
{
// 유저 입력 처리 코드...
// 각 Entity를 업데이트 한다.
for(int i = 0; i < numEntities_; i++)
{
entities_[i]->update();
}
// 물리, 렌더링 코드...
}
}
Entity를 상속받는다고?!
객체별로 다른 동작을 정의하기 위해 Entity
클래스를 상속한다는 얘기를 보고 문제를 느낄수도 있을 것이다. 과거, 객체 지향 언어가 대두되면서 바벨탑처럼 쌓아 올린 클래스 상속 구조는 너무나 높고 거대해서 해를 가릴 정도였다. 시간이 지나면서 이러한 거대한 상속 구조가 형편없다는 것을 알게 되었고 이를 쪼개지 않고서는 유지보수가 불가능했다. 이런 깨달음이 전반적으로 퍼져나갈 때쯤 등장한 해결책이 컴포넌트 패턴이다.
컴포넌트 패턴을 사용하면 update
함수는 Entity
가 아닌 Entity
의 컴포넌트(Component)
에 있게 된다. 이러면 상속 구조를 복잡하게 만들지 않고 대신 필요한 컴포넌트만 골라 넣으면 된다. 예를 들면 순찰하는 Entity
일 경우 순찰하는 컴포넌트를 넣고, 순찰 및 추적과 공격을 하는 Entity
일 경우 순찰, 추적, 공격 컴포넌트를 넣는 식이다.
하지만 무조건 상속을 쓰지 않는것은 무조건 상속하는 것만큼이나 나쁘다. 필요하면 적당히 쓰자.
Entity를 상속받은 객체 정의
다시 돌아와서, 순찰을 도는 해골 전사와 번개를 쏘는 석상을 정의해보자.
class Skeleton : public Entity
{
public:
Skeleton() : goLeft_(false) {}
virtual void update()
{
if(goLeft_)
{
setX(x() - 1);
if(x() <= 0) goLeft = false;
}
else
{
setX(x() + 1);
if(x() >= 100) goLeft = true;
}
}
private:
bool goLeft_;
};
앞에서 본 게임 루프에 있던 코드를 update
메서드에 그대로 가져왔다. goLeft
도 지역변수에서 클래스의 멤버 변수로 바뀌었다. 석상도 정의해보자.
class Statue : public Entity
{
public:
Statue(int delay) : frames_(0), delay_(delay) {}
virtual void update()
{
if(++frames_ == delay_)
{
shootLightning();
// 타이머 초기화
frames_ = 0;
}
}
private:
int frames_;
int delay_;
void shootLightning()
{
// 번개를 쏜다...
}
};
이 클래스도 게임 루프에 있던 코드 대부분을 가져와 이름만 일부 바꿨다. 석상마다 프레임 카운터와 발사 주기를 지역 변수로 따로 관리하던 것을 클래스의 멤버 변수로 옮겼다. 이때문에 석상 인스턴스가 타이머를 각자 관리할 수 있어 석상을 원하는 만큼 많이 만들 수 있다. 객체가 자신이 필요한 모든 걸 직접 들고있기 때문에 게임 월드에 새로운 Entity
를 추가하기가 훨씬 쉬워진다.
시간 전달
여기까지가 핵심이지만 좀 더 다듬어보자. 지금까지는 update()
를 부를 때마다 게임 월드 상태가 동일한 고정 단위 시간만큼 진행된다고 가정하고 있었다. 하지만 가변 시간 간격을 쓰는 게임에서는 게임 루프를 돌 때마다 이전 프레임에서 작업 진행과 렌더링에 걸린 시간에 따라 시간 간격을 크게 혹은 짧게 시뮬레이션한다. 이는 게임 루프 패턴에서 자세히 다룬다. 즉, 매번 update
함수는 얼마나 많은 시간이 지났는지를 알아야 하기 때문에 지난 시간을 인수로 받는다. 해골 전사는 가변 시간 간격을 아래와 같이 처리한다.
void Skeleton::update(double deltaTime)
{
if(goLeft_)
{
x -= deltaTime;
if(x <= 0)
{
goLeft = false;
x = -x;
}
}
else
{
x += deltaTime;
if(x >= 100)
{
goLeft = true;
x = 100 - (x - 100);
}
}
}
해골 전사의 이동 거리는 지난 시간에 따라 늘어난다. 시간 간격이 크면 해골 전사가 순찰 범위를 벗어날 수 있고 가변 시간 간격을 처리해야 하므로 코드가 더 복잡해졌음을 알 수 있다.
디자인 결정
업데이트 메서드를 어느 클래스에 둘 것인가?
Entity 클래스
이미 Entity
클래스가 있다면 다른 클래스를 추가하지 않아도 된다는 점에서 가장 간단하다. 하지만 Entity
종류가 많다면 새로운 동작을 만들때마다 클래스를 상속받아야 하기 때문에 코드가 망가지기 쉽고 작업하기 어렵다. 언젠가 단일 상속 구조로 코드를 매끄럽게 재사용할 수 없는 순간이 올 텐데, 이러면 방법이 없다.
컴포넌트 클래스
이미 컴포넌트 패턴을 쓰고 있다면 더 생각할 것이 없다. 업데이트 메서드 패턴이 게임 객체를 게임 월드에 있는 다른 객체와 디커플링하는 것과 마찬가지로, 컴포넌트 패턴은 한 객체의 일부를 객체의 다른 부분들과 디커플링한다. 컴포넌트는 알아서 자기 자신을 업데이트 할 것이고, 렌더링, 물리, AI는 스스로 알아서 돌아갈 것이다.
위임 클래스
클래스의 동작 일부를 다른 객체에 위임하는 것과 관련된 패턴이 더 있다. 상태 패턴은 상태가 위임하는 객체를 바꿈으로써 객체의 동작을 변경할 수 있게 해준다. 타입 객체 패턴은 같은 종류의 여러 객체가 동작을 공유할 수 있게 해준다. 이들 패턴 중 하나를 쓰고 있다면 위임 클래스에 두는 것이 자연스럽다. 여전히 update()
는 Entity
클래스에 있지만 가상함수가 아니며 다음과 같이 위임 객체에 포워딩만 한다.
void Entity::update()
{
// 상태 객체에 포워딩한다.
state_->update();
}
새로운 동작을 정의하고 싶다면 위임 객체를 바꾸면 된다. 컴포넌트와 마찬가지로, 완전히 새로운 상속 클래스를 정의하지 않아도 동작을 바꿀 수 있는 유연성을 얻을 수 있다.
휴면 객체 처리
여러 이유로 일시적으로 업데이트가 필요 없는 객체가 생길 수 있다. 사용 불능 상태이거나 화면 밖에 있거나 아직 잠금 상태일 수도 있다. 이런 객체가 많으면 매 프레임마다 쓸데없이 객체를 더 순회하면서 CPU 클럭을 낭비하게 된다.
비활성 객체가 포함된 컬렉션 하나만 사용할 경우
- 시간을 낭비한다. 비활성 객체의 경우 활성 상태인지 나타내는 플래그만 검사하거나 아무것도 하지 않는 메서드를 호출하기 때문이다.
활성 객체만 모여 있는 컬렉션을 하나 더 둘 경우
- 두 번째 컬렉션을 위해 메모리를 추가로 사용해야 한다. 전체 객체를 대상으로 작업해야 할 수도 있기 때문에 모든 객체를 모아 놓은 마스터 컬렉션도 있기 마련이다. 이때 활성 객체 컬렉션은 엄밀히 말하자면 중복 데이터이다. 메모리보다 속도가 중요하다면 그 정도는 받아들일 만할 수도 있다. 절충하자면 컬렉션을 두 개 두되, 나머지 하나에는 전체 객체가 아닌 비활성 객체만 모아놓는 방법도 있다.
- 컬렉션 두 개의 동기화를 유지해야 한다. 객체가 생성되거나 완전히 소멸되면 마스터 컬렉션과 활성 객체 컬렉션 둘다 변경해야 한다.
보통 얼마나 많은 객체가 비활성 상태로 남아 있는가에 따라 방법을 결정하면 된다. 비활성 객체가 많을수록 컬렉션을 따로 두는게 좋다.
참고 문헌
한빛 미디어, 게임 프로그래밍 패턴