CODEONWORT

게임 엔진에서 게임 객체 갱신하기 본문

Season 1/Misc

게임 엔진에서 게임 객체 갱신하기

codeonwort 2010. 11. 28. 17:54


아 이미 번역본이 있었네 -_-
http://www.kocca.kr//knowledge/trend/abroad/1289262_1232.html

-------------------------------------------------------------------------------------------------------------

http://www.gamasutra.com/view/feature/4199/book_excerpt_game_engine_.php

[가마수트라는 Naughty Dog의 프로그래머 Jason Gregory가 쓴 Game Engine Architecture에서 발췌한 내용을 제공합니다. 이 책에는 게임 엔진을 개발할 때 고려할 방대한 자료가 들어있습니다. 이 14장 발췌록에서는 엔진이 어떻게 객체를 처리하는지를 다룹니다. 자세한 정보를 알려면 책의 공식 사이트를 방문하세요.]

실시간으로 게임 객체를 갱신한다는 것

가장 간단한 게임 엔진부터 가장 복잡한 게임 엔진까지 모든 게임 엔진은 매 시간 모든 게임 객체의 내부 상태를 갱신할 수단이 필요하다. 게임 객체의 상태는 객체의 모든 속성(C++에선 데이터 멤버라고 함)의 값으로 정의할 수 있다. 예컨데 퐁에서 공의 상태는 화면에서의 (x, y) 위치와 속도(속력과 운동 방향)으로 기술된다. 게임은 역동적이고 시간 기반 시뮬레이션이기 때문에, 게임 객체의 상태는 특정한 순간의 구성을 기술한다. 달리 말하자면 게임 객체의 시간 인식은 연속적이라기보단 이산적이다. (하지만 나중에 보겠지만 게임 객체의 상태를 연속적으로 변하되 엔진에 의해 이산적으로 추출된다고 보는 게 유용한데, 몇 가지 일반적인 곤란함을 피하는 데 도움이 되기 때문이다.)

앞으로의 논의에서, 객체 i의 임의 시각 t에서의 상태를 기호 Si(t)로 표기할 것이다. 여기서의 벡터 표기는 엄밀하게 말해 수학적으로 올바르지는 않지만 게임 객체의 상태는 다양한 자료형의 갖가지 정보를 포함하는 다종류로 이뤄진 n차원 벡터처럼 행동한다는 것을 상기시킨다. 여기서 용어 "상태"는 유한 상태 기계에서의 상태와 다르다는 걸 말해야겠다. 게임 객체는 아마 한 개나 그 이상의 유한 상태 기계라는 관점에서 가장 잘 구현되겠지만, 이 경우 각 FSM의 현재 상태 기술은 게임 객체의 전체 상태 벡터 S(t)의 단지 일부일 뿐이다.

대부분의 저수준 엔진 서브시스템(렌더링, 애니메이션, 충돌, 물리, 음향 따위)은 주기적인 갱신이 필요하고 게임 객체 시스템도 예외가 아니다. 7장에서 봤듯이 갱신은 보통 게임 루프라는 한 마스터 루프를 통해 행해진다(각기 다른 쓰레드에서 돌아가는 여러 게임 루프를 통해서 갱신될 수도 있다). 사실상 모든 게임 엔진은 메인 게임 루프 안에서 게임 객체 상태를 갱신한다. 그러니까 엔진은 게임 객체 모형을 계속 서비스를 제공해야 하는 엔진 하위시스템으로서 다룬다는 말이다.

따라서 게임 객체 갱신을, 이전 시각 Si(t - Δt)에서의 상태를 알고 있을 때, 현재 시각 Si(t)에서의 객체 상태를 결정하는 과정이라고 생각할 수 있다. 모든 객체 상태를 갱신하고 나면 현재 시각 t는 새로운 이전 시각 (t - Δt)이 되고, 이 과정은 게임이 실행되는 한 반복된다. 엔진은 대개 하나 이상의 클럭을 보유한다. 한 클럭은 실제 시각을 정확히 추적하고 다른 클럭들은 실제 시각과 일치하기도 하고 않기도 하다. 클럭은 엔진에게 절대 시각 t와 게임 루프의 각 반복 사이의 시간 변화 Δt를 제공한다. 보통, 게임 객체 상태의 갱신을 주도하는 클럭은 실제 시간 흐름과 갈라진다. 이로써 게임 객체의 행동을 잠깐 멈추거나, 늦추거나, 재촉하거나, 아예 거꾸로 가게 할 수 있다. 이런 특성들은 게임의 디버깅과 개발을 위해서도 가치있다.

1장에서 언급했듯이 게임 객체 갱신 시스템은 컴퓨터 과학에서 동적, 실시간, 대행자(agent) 기반 컴퓨터 시뮬레이션이라고 알려진 것의 한 사례다. 게임 객체 갱신 시스템은 또한 이산 사건 시뮬레이션(이벤트에 대한 세부 사항은 14.7절에서 보라)의 일부 관점을 내비친다. 이것들은 컴퓨터 과학에서 잘 연구된 분야고 인터랙티브 산업의 영역 밖에서도 많은 응용 사례가 있다. 나중에 보겠지만 게임 객체 상태를 매 시간 동적이고, 상호작용하고, 가상적인 환경 속에서 갱신하는 것은 올바르게 만들기가 놀라우리만큼 어려울 수 있다. 그리고 이 분야의 연구자들은 게임 엔진 설계에서 무언가 하나 둘 쯤은 배울 수 있을 것이다!

모든 고수준 게임 엔진 시스템이 그렇듯이, 엔진마다 약간씩(가끔은 근본부터) 다른 접근법을 사용한다. 하지만 대부분의 게임 팀은 예전에 그랬듯이 통상적인 문제 덩어리에 직면하며, 사실상 모든 게임 엔진에서 한 가지 설계 양식이 나타나고 또 나타난다. 이 절에서는 이런 일반적인 문제들과 일반적인 해결책들을 살핀다. 여기서 기술한 것들에 대해 아주 다른 해결책을 채택하는 게임 엔진들이 있고, 어떤 게임을 설계할 땐 여기서 다룰 수 없는 정말 특별한 문제들을 마주치기도 한다는 것을 명심하기 바란다.

14.6.1. 간단한 접근법 (안 먹힘)
게임 객체들의 상태를 갱신하는 가장 간단한 방법은 매 번 이 모음을 훑으며 Update() 같은 가상 함수를 호출하는 것이다. 이건 주 게임 루프의 각 반복마다(즉, 프레임마다) 흔히 행해지는 일이다. 상태를 진행하는 데 필요한 작업들을 수행하기 위해서 게임 객체 클래스는 저마다 알맞게 구현한 Update() 함수를 제공한다. 이전 프레임부터의 경과 시간은 갱신 함수에 넘어가고 객체들은 적절한 경과 시간을 받는다. 그런 Update() 함수의 시그너쳐를 가장 간단하게 표현하면 이렇다.

virtual void Update(float dt);

앞으로의 논의를 위해 우리 엔진이 단일 객체 계층(monolithic object hierarchy)이라는, 각 게임 객체가 한 클래스의 한 인스턴스에 의해 표현되는 방법을 사용한다고 가정하자. 하지만 여기서의 발상을 사실 어떤 객체 중심 설계로든 확장할 수 있다. 예를 들면 구성요소 기반 객체 모형을 갱신하기 위해 각 게임 객체를 구성하는 모든 구성 요소의 Update()를 호출할 수도 있고, "중추(hub)" 객체의 Update()를 호출하여 이 객체가 자신과 연관된 구성 요소들을 갱신하도록 할 수도 있다. 이 구상을 속성 중심 설계까지 확장할 수 있는데, 프레임마다 각 속성 인스턴스의 Update() 함수를 호출하면 된다.

사람들이 악마는 세부 사항 속에 있다고 말하니, 여기서 자세한 세부 사항 두 개를 살펴보자. 첫째, 모든 게임 객체의 모음을 어떻게 보관할 것인가? 둘째, Update() 함수는 어떤 종류의 일들을 해야 하나?

14.6.1.1. 활성화된 게임 객체 모음 보관하기
주로 이름이 GameWorld나 GameObjectManager인 싱글턴 관리자 클래스가 활성 게임 객체들의 모음을 보관한다. 게임 객체 모음은 보통 동적이여야 하는데, 게임 객체들은 게임을 플레이하면서 생성되고 파괴되기 때문이다. 따라서 게임 객체들에 대한 포인터, 똑똑한 포인터, 핸들을 담는 연결 목록(linked list)이 간단하고 효율적인 접근법이다. (어떤 게임 엔진들은 게임 객체들의 동적인 생성과 소멸을 수용하지 않는다. 그런 엔진들은 연결 목록 대신 게임 객체 포인터, 똑똑한 포인터, 핸들을 담는 크기 고정 배열을 사용한다.) 밑에서 보겠지만 대부분의 엔진은 게임 객체들의 흔적을 유지하기 위해 간단하고 평면적인 연결 목록 대신 더 복잡한 자료 구조를 사용한다. 하지만 당장은 간결함을 위해 자료 구조를 연결 목록이라고 치자.

14.6.1.2 Update() 함수의 책임
게임 객체의 Update() 함수는 주로 Si(t - Δt)가 주어진 때에 객체의 상태 Si(t)를 결정할 책임이 있다. 이렇게 하는 것으로는 객체에 강체 동역학 시뮬레이션을 적용하기, 미리 만들어놓은 애니메이션 추출하기, 막 발생한 사건에 반응하기 따위가 있다.

대부분의 게임 객체는 하나 이상의 엔진 서브시스템과 상호작용한다. 게임 객체는 움직여야 하고, 그려져야 하고, 입자 효과를 발생시키고, 음향을 재생하고, 다른 객체들 그리고 정적 지형에 충돌해야 한다. 각 시스템은 역시 매 시각, 보통 한 프레임에 몇 번씩 갱신해야 하는 내부 상태를 가진다. 그저 이 모든 서브시스템을 게임 객체의 Update() 함수 안에서 갱신하는 것이 합리적이고 직관적으로 보일 것이다. 예를 들어, 다음 Tank 객체의 가상의 갱신 함수를 보자.

virtual void Tank::Update(float dt)
{

 // 탱크 자체의 상태를 갱신한다.
 MoveTank(dt);

 DeflectTurret(dt);
 FireIfNecessary();

 // 이제 이 탱크를 대신하여 저수준 엔진 서브시스템을 갱신한다.
 // (좋은 생각이 아니다... 아래를 보라!)
 m_pAnimationComponent->Update(dt);

 m_pCollisionComponent->Update(dt);

 m_pPhysicsComponent->Update(dt);

 m_pAudioComponent->Update(dt);
 m_pRenderingComponent->draw();
}

우리의 Update() 함수가 이렇게 생겨먹은 상태에서, 밑처럼 게임 객체들을 갱신하여 게임 루프를 거의 완전히 처리할 수 있다.

while (true)
{

 PollJoypad();

 float dt = g_gameClock.CalculateDeltaTime();

 for (each gameObject)
 {

  // 이 가상의 Update() 함수는 모든 엔진 서브시스템을 갱신한다!
  gameObject.Update(dt);
 }

 g_renderingEngine.SwapBuffers();
}

위에서 본 객체 갱신에 대한 간단한 접근법이 얼마나 매력적이든, 이 방법은 상용 게임 엔진에서는 성공할 수 없다. 다음 절에서는 이 간단한 접근법의 문제점을 몇 가지 본 다음 각 문제점을 해결하는 일반적인 방법을 살펴본다.

14.6.2. 수행능력 제약과 일괄 갱신
대부분의 저수준 엔진 시스템에는 극도로 엄중한 수행능력 제약이 있다. 이것들은 막대한 자료를 가지고 작업하며 프레임마다 가능한 빨리 수많은 계산을 해야 한다. 결국 대부분의 엔진 시스템은 일괄 갱신에서 이익을 얻는다. 예컨데 많은 애니메이션을 한 번에 갱신하는 것이, 각 객체의 애니메이션을 충돌 검사, 물리 시뮬레이션, 렌더링 같은 무관한 작업들과 같이 처리하는 것보다 훨씬 이득이다.

대부분의 상용 게임 엔진에서, 각 엔진 서브시스템은 각 객체의 Update() 함수 안에서가 아니라 주 게임 루프 안에서 직접적으로든 간접적으로든 갱신된다. 게임 객체는 특정 서브시스템의 서비스가 필요하면, 그 서브시스템에게 서브시스템에 한정된 특정한 상태 정보를 요청한다. 예를 들어 삼각형 메시를 통해 렌더링될 게임 객체는 렌더링 서브시스템에게 메시 인스턴스를 할당하길 요구한다. (메시 인스턴스는 삼각형 메시의 단일 인스턴스를 표현한다. 월드 공간에서 그 인스턴스의 표시 여부를 따지지 않고 위치, 방향, 크기를 저장한다. 또한 인스턴스별 재질 데이터에 더해 관련이 있는 기타 인스턴스별 정보도 저장한다.) 렌더링 엔진은 메시 인스턴스 모음을 내부에 간직한다. 렌더링 엔진은 실행시간 수행능력을 극대화하기 위해 메시 인스턴스들을 적합해 보이는 방식으로 관리한다. 게임 객체는 메시 인스턴스 객체의 속성을 변경하여 메시가 어떻게 그려질 지를 제어하지만, 메시 인스턴스 그리기를 직접 제어하지 않는다. 대신 모든 게임 객체가 자신을 갱신할 기회를 가진 뒤에 렌더링 엔진은 보여야 하는 모든 메시 인스턴스를 효율적인 일괄 갱신 한 번으로 그린다.

일괄 갱신 후 특정 게임 객체(가령 가상의 탱크 객체)의 Update() 함수는 이렇게 보인다.

virtual void Tank::Update(float dt)
{
 // 탱크 자체의 상태를 갱신한다.
 MoveTank(dt);

 DeflectTurret(dt);

 FireIfNecessary();

 // 여러 엔진 서브시스템 구성요소들의 속성을 조절하지만,
 // 여기서 갱신하지는 않는다.
 if (justExploded)
 {
  m_pAnimationComponent->PlayAnimation("explode");
 }
 if (isVisible)
 {
  m_pCollisionComponent->Activate();
  m_pRenderingComponent->Show();
 }
 else
 {
  m_pCollisionComponent->Deactivate();
  m_pRenderingComponent->Hide();
 }
 // 기타.
}

게임 루프는 이렇다.

while (true)
{
 PollJoypad();

 float dt = g_gameClock.CalculateDeltaTime();

 for (each gameObject)
 {
  gameObject.Update(dt);
 }

 g_animationEngine.Update(dt);

 g_physicsEngine.Simulate(dt);

 g_collisionEngine.DetectAndResolveCollisions(dt);

 g_audioEngine.Update(dt);

 g_renderingEngine.RenderFrameAndSwapBuffers();
}

일괄 갱신은 여기에 국한되지 않고 여러 수행능력 이익을 가져온다.

- 최대의 캐시 일관성. 일괄 갱신을 하면 엔진 서브시스템이 최대의 캐시 일관성을 이루는데, 각 객체의 자료가 내부에 보관되며 RAM에서 인접한 단일 영역에 배열되기 때문이다.
- 최소한의 계산 중복. 객체마다 계산을 다시 수행하지 않고 전반적인 계산을 한 번 하여 많은 게임 객체에 재사용할 수 있다.
- 자원 재할당 감소. 엔진 서브시스템은 갱신 중에 메모리나 그 밖의 자원을 자주 할당하고 관리한다. 특정 하위시스템의 갱신이 다른 하위시스템들의 갱신 사이에 껴있다면, 이 자원들은 게임 객체마다 해제되었다 재할당되어야 한다. 하지만 갱신이 일괄적이면 한 프레임마다 자원을 할당하여 모든 객체에 재사용할 수 있다.
- 효율적인 파이프라이닝(pipelining). 많은 엔진 하위시스템이 게임 세계의 각 객체에 사실상 동일한 계산을 수행한다. 갱신이 일괄적이면 새로운 최적화가 가능해지며 특화된 하드웨어 자원이 영향을 받을 수 있다. 예를 들어 플레이스테이션 3은 수많은 고속 마이크로프로세서, 즉 SPU를 제공하고, 각 SPU는 고유의 고속 메모리 영역을 가진다. 애니메이션을 일괄 처리할 때 한 캐릭터의 자세를 계산하는 동시에 그 자료를 다음 캐릭터를 위해 SPU 메모리 속으로 DMA(Direct Memory Access)할 수 있다.

수행능력 이익이 일괄 갱신 방법을 선호할 유일한 이유는 아니다. 어떤 엔진 서브시스템은 객체마다 갱신할 경우 아예 작동할 수가 없다. 예를 들어 다수의 움직이는 강체들 사이의 충돌을 해결하려면 각 객체를 따로 고려해서는 만족스러운 해결책이 나오지 않는다. 이런 객체들 사이의 간섭은 반복법을 쓰거나 선형계를 풀어 한꺼번에 처리해야 한다.

 
14.6.3. 객체와 서브시스템의 상호의존
 
수행능력은 차치하고서라도, 간단한 객체별 갱신법은 게임 객체들이 서로를 의존할 때 무너지고 만다. 예컨데 사람 캐릭터가 팔에 고양이를 안고 있다면? 고양이의 뼈대 자세를 계산하려면 사람의 자세를 먼저 계산해야 한다. 이는 객체들의 갱신 순서가 게임의 알맞은 작동에 중요하다는 뜻이다.

엔진 서브시스템들이 서로 의존하면 또다른 문제가 떠오른다. 예를 들면 랙돌(rag-doll) 물리 시뮬레이션은 애니메이션 엔진과 함께 갱신되어야 한다. 보통 애니메이션 시스템은 도중에 로컬 공간상 뼈대 자세를 만들어 낸다. 이런 관절 변형은 월드 공간으로 변환되며 뼈대를 물리 시스템으로 근사하는, 연관된 강체 시스템에 적용된다. 물리 시스템이 먼저 강체를 시뮬레이션하고, 관절의 최종 위치는 뼈대의 대응하는 관절에 다시 적용된다. 마지막으로 애니메이션 시스템은 최종 월드 자세와 skinning matrix palette를 계산한다. 그러니 다시 한 번 말하지만, 애니메이션 시스템과 물리 시스템의 갱신은 올바른 결과를 내기 위해 특정 순서대로 실행되어야 한다. 이런 서브시스템간 의존은 게임 엔진 설계에서 흔하다.
* skinning matrix palette는 뭔지 모르겠다...

14.6.3.1. 단계적 갱신
서브시스템간 의존을 해결하기 위해 주 게임 루프에서 엔진 서브시스템들의 갱신 순서를 명시적으로 코딩할 수 있다. 예를 들어 애니메이션 시스템과 봉제 인형 물리 사이의 상호작용을 다루기 위해 이렇게 적을 수 있다.

while (true) // 주 게임 루프
{
 // ...

 g_animationEngine.CalculateIntermediatePoses(dt);

 g_ragdollSystem.ApplySkeletonsToRagDolls();

 g_physicsEngine.Simulate(dt); // runs ragdolls too

 g_collisionEngine.DetectAndResolveCollisions(dt);

 g_ragdollSystem.ApplyRagDollsToSkeletons();

 g_animationEngine.FinalizePoseAndMatrixPalette();

 // ...
}

게임 루프에서 제 시간에 게임 객체들의 상태를 갱신하는 것에 신중을 기해야 한다. 대개 이것은 매 프레임마다 게임 객체들의 Update() 함수를 호출하는 것만큼 간단하지 않다. 게임 객체들은 엔진 서브시스템들이 수행하는 계산의 중간 결과에 의존한다. 예를 들어 게임 객체는 애니메이션 시스템이 애니메이션을 갱신하기에 앞서 애니메이션을 재생하도록 요구할 수도 있다. 하지만 바로 이 게임 객체가 애니메이션 시스템이 만든 중간 자세를, 랙돌 물리 시스템이 사용하거나 최종 자세와 matrix palette이 생성되기 전에 단계적으로 조절하길 원할 수도 있다. 이는 애니메이션이 중간 자세를 계산하기 전과 후, 두 번 그 객체를 갱신해야 함을 뜻한다.

많은 게임 엔진이 한 프레임에 게임 객체를 여러 번 갱신하는 것을 허용한다. 예를 들어 엔진은 게임 객체를 애니메이션 혼합 전에, 애니메이션 혼합 후 최종 자세 생성 전에, 최종 자세 생성 후에 이렇게 세 번 갱신할 수도 있다. 각 게임 객체 클래스에 "갈고리(hoook)"로서 작동하는 세 가상 함수를 주어 이렇게 할 수 있다. 이런 시스템에서 게임 루프는 이렇다.

while (true) // 주 게임 루프
{
 // ...

 for (each gameObject)
 {
  gameObject.PreAnimUpdate(dt);
 }

 g_animationEngine.CalculateIntermediatePoses(dt);

 for (each gameObject)
 {
  gameObject.PostAnimUpdate(dt);
 }

 g_ragdollSystem.ApplySkeletonsToRagDolls();

 g_physicsEngine.Simulate(dt); // 랙돌 시뮬레이션도 실행한다

 g_collisionEngine.DetectAndResolveCollisions(dt);

 g_ragdollSystem.ApplyRagDollsToSkeletons();

 g_animationEngine.FinalizePoseAndMatrixPalette();

 for (each gameObject)
 {

  gameObject.FinalUpdate(dt);

 }

 // ...
}

게임 객체에 우리가 적합하다고 보는 여러 갱신 단계를 집어넣는다. 하지만 모든 게임 객체를 훑으며 가상 함수를 호출하는 것은 비용이 비싸기 때문에 주의해야 한다. 또한, 모든 게임 객체가 여러 갱신 단계가 필요한 것도 아니다. 특정 단계가 필요하지 않은 객체를 지나가는 것은 순수히 CPU 대역폭 낭비다. 반복 비용을 최소화할 한 방법은 갱신할 게임 객체들의 연결 목록을 각 단계마다 두는 것이다. 특정 객체가 한 갱신 단계에 포함되길 원한다면 상응하는 연결 목록에 자신을 추가한다. 이러면 특정 갱신 단계에 관심 없는 객체들을 방문하는 것을 피한다.

14.6.3.2. 버킷 갱신
객체간 의존이라는 상황에서, 위에 기술한 단계별 갱신 기법은 조금 수정해야 한다. 객체간 의존은 갱신 순서를 다스리는 규칙을 혼란스럽게 만들기 때문이다.

예를 들어, 객체 B가 객체 A에 의해 유지되고 있다고 치자. 더 나아가, A를 월드 자세와 matrix palette 계산까지 포함하여 완전히 갱신한 뒤에만 B를 갱신할 수 있다고 치자. 이는 애니메이션 시스템이 최대 효율을 얻기 위해 모든 게임 객체의 애니메이션을 일괄적으로 갱신해야 하는 것과 충돌한다.

객체간 의존은 의존 트리들의 숲이라고 볼 수 있다. 부모 없는 게임 객체들(다른 객체와의 의존이 없음)은 숲의 뿌리들을 나타낸다. 이 뿌리 객체들 중 하나에 직접 의존하는 객체는 1층에 사는 자식이다. 1층 자식에 의존하는 객체는 2층 자식이란 식이다. 그림 14.14에 이게 묘사되어 있다.


그림 14.14. 객체간 갱신 순서 의존은 의존 트리들의 숲으로 볼 수 있다.

갱신 순서 충돌이라는 문제의 해결책 중 하나는 객체들을 별개의 집단들로 모으는 것이다. 여기서는 딱히 더 나은 이름이 생각나지 않아 버킷(bucket)이라고 부르겠다. 버킷1은 숲의 모든 뿌리 객체들로 구성되고, 버킷2는 모든 1층 자식 객체로 구성되고, 버킷3은 모든 2층 자식으로 구성되는 식이다. 각 버킷에서, 게임 객체들과 엔진 시스템들을 완전히 갱신한다. 버킷이 더 없을 때까지 이 전체 과정을 반복한다.

이론적으로 의존 숲에 서식하는 트리들의 깊이는 한계가 없다. 하지만 실제로는 대개 꽤 얕다. 예를 들어 무기를 든 상태고, 움직이는 플랫폼이나 탈 것을 타고 있거나 안 타고 있는 캐릭터들을 생각해보자. 이것을 구현하려면 의존 숲에 단지 3층만 필요하고, 그러므로 버킷은 세 개다. 플랫폼/탈 것들에 한 개, 캐릭터들에 한 개, 캐릭터들의 손에 쥐어져 있는 무기들에 한 개. 많은 게임 엔진이 의존 숲의 깊이를 명시적으로 한정하여 버킷을 일정 개수만 사용한다(버킷 접근법을 사용한다고 가정하면. 물론 게임 루프를 설계하는 다른 방법도 많다).

버킷을 쓰는 단계별 일괄 갱신 루프는 이럴 것이다.

void UpdateBucket(Bucket bucket)
{
 // ...

 for (each gameObject in bucket)
 {
  gameObject.PreAnimUpdate(dt);
 }

 g_animationEngine.CalculateIntermediatePoses(bucket, dt);

 for (each gameObject in bucket)
 {
  gameObject.PostAnimUpdate(dt);
 }

 g_ragdollSystem.ApplySkeletonsToRagDolls(bucket);
 g_physicsEngine.Simulate(bucket, dt);
 // 랙돌 시뮬레이션도 수행한다
 g_collisionEngine.DetectAndResolveCollisions(bucket, dt);

 g_ragdollSystem.ApplyRagDollsToSkeletons(bucket);
 g_animationEngine.FinalizePoseAndMatrixPalette(bucket);

 for (each gameObject in bucket)
 {
  gameObject.FinalUpdate(dt);
 }
 // ...
}
void RunGameLoop()
{
 while (true)
 {
  // ...

  UpdateBucket(g_bucketVehiclesAndPlatforms);

  UpdateBucket(g_bucketCharacters);

  UpdateBucket(g_bucketAttachedObjects);

  // ...

  g_renderingEngine.RenderSceneAndSwapBuffers();
 }
}

사실 상황은 이보다 조금 복잡하다. 예를 들어 물리 엔진 같은 몇 엔진 서브시스템은 버킷 개념을 지원하지 않는데, 써드 파티 SDK여서이거나 버킷 방법으로는 사실상 갱신할 수 없기 때문일 것이다. 하지만 이 버킷 갱신은 Naughty Dog에서 우리가 Uncharted: Drake's Fortune을 구현하기 위해 사용한 것이고 차기작인 Uncharted 2: Among Thieves에서 또 사용할 것이다. 그러니 이건 실용적이고 효율적임이 검증된 방법이다.

14.6.3.3. 객체 상태 비일관성과 한 프레임 뒤쳐짐
게임 객체 갱신으로 돌아가 이번에는 각 객체의 내적 시간 인지라는 관점에서 생각해보자. 14.6절에서 게임 객체 i의 시각 t에서의 상태는 상태 벡터 Si(t)로 표기할 수 있다고 하였다. 게임 객체를 갱신할 때 우리는 객체의 이전 상태 벡터 Si(t1)을 새로운 현재 상태 벡터 Si(t2)로 바꾼다(t2 = t1 + Δt).

이론적으로 모든 게임 객체의 상태는 시각 t1에서 t2로 즉시 병렬으로 갱신된다. 그림 14.15를 보시라. 하지만 사실 우리는 객체들을 하나씩만 갱신할 수 있다. 게임 객체마다 갱신 함수 같은 것을 호출해야 한다. 이 갱신 루프가 반절 쯤 진행되었을 때 프로그램을 중단한다면 게임 객체들의 반절은 상태가 Si(t2)로 갱신된 반면 나머지 반절은 이전 상태 Si(t1)로 남아있을 것이다. 즉 갱신 루프 안에서 두 게임 객체에게 현재 시각을 묻는다면 같을 수도 그렇지 않을 수도 있다! 게다가, 우리가 갱신 루프의 정확히 어디에 끼어들었는지에 따라 모든 객체들이 일부만 갱신된 상태일 수도 있다. 예를 들어 애니메이션 자세 혼합은 수행되었지만 물리와 충돌 해결은 아직 적용되지 않았을 수도 있다. 따라서 다음과 같은 규칙이 나온다.

"모든 게임 객체의 상태는 갱신 루프의 전과 후에는 일관되지만 갱신하는 동안에는 일관되지 않다."

그림 14.16에 묘사되어 있다.


그림 14.15. 이론적으로 모든 게임 객체들의 상태는 게임 루프의 각 반복 중에 즉시 병렬로 갱신된다. 


그림 14.16. 사실 게임 객체들의 상태는 하나씩 갱신된다. 이는 갱신 루프의 어느 순간에 어떤 객체들은 현재 시각이 t2라고 생각하지만 다른 것들은 여전히 t1이라고 생각함을 뜻한다. 몇 객체는 일부만 갱신되어 이것들의 내부 상태는 일관되지 않을 것이다. 실은 그런 객체의 상태는 t1과 t2 사이의 어느 지점에 놓여 있다.

갱신 루프 중 게임 객체 상태의 비일관성은 혼란과 버그의 주요 원천이다. 게임 업계의 전문가들에게도 그렇다. 갱신 루프 동안 게임 객체가 다른 게임 객체에게 상태 정보를 요청할 때(둘 사이에 의존이 있음을 뜻함) 문제가 주로 고개를 치켜든다. 예를 들어 객체 B가 시각 t에 자신의 속도를 결정하기 위해 객체 A의 속도를 살펴본다면 프로그래머는 자신이 객체 A의 이전 상태 SA(t1)을 원하는지 새 상태 SA(t2)를 원하는지에 명확해야 한다. 새 상태가 필요하지만 객체 A가 아직 갱신되지 않았다면 갱신 순서 문제가 있는 것이고 "한 프레임 뒤쳐짐(one frame off lag)"이라고 알려진 버그를 무더기로 일으킬 수 있다. 이런 종류의 버그에서 한 객체의 상태는 동료들의 상태보다 한 프레임 뒤쳐져서 게임 객체들간 어우러짐이 부족한 채로 화면에 나타난다.

14.6.3.4. 객체 상태 저장(Object State Caching)
위에 썼듯이 이 문제의 한 해결책은 게임 객체들을 여러 버킷에 담는 것이다(14.6.3.2절). 간단한 버킷 갱신 방법의 문제점 하나는 이 방법이 게임 객체들이 서로에게 상태 정보를 요청하는 방법에 제약을 건다는 것이다. 객체 A가 객체 B의 이전 상태 벡터 SB(t1)을 원한다면 객체 B는 아직 갱신되지 않은 버킷에 담겨있어야 한다. 객체 A는 자기가 들어있는 버킷에 있는 객체의 상태 벡터를 요구하면 안 되는데, 위에 적었듯이 그 상태 벡터는 일부만 갱신되었을 것이기 때문이다.

일관성을 향상시키는 한 방법은 각 게임 객체가 갱신 동안, 새 상태 벡터 Si(t2)를 계산하고 있을 때 이전 상태 벡터 Si(t1)을 덮어쓰지 않고 저장(cache)해 두도록 하는 것이다. 이렇게 하면 첫째, 갱신 순서를 고려할 필요 없이 이전 상태 벡터를 안전하게 요청할 수 있다. 둘째, 완전히 일관된 상태 벡터 (즉 Si(t1))에 항상, 심지어 새 상태 벡터를 갱신할 때도 접근 가능함을 보장한다

상태 저장의 또다른 이점은 두 시각 사이 임의 순간에 객체의 상태를 근사하기 위해 이전 상태와 다음 상태 사이를 선형 보간할 수 있다는 것이다. Havok 물리 엔진은 오로지 이를 위해 시뮬레이션에서 모든 강체의 이전 상태와 현재 상태를 보관한다.

상태 저장의 단점은 메모리를 두 배 쓴다는 것이다. 또한 문제점을 반절 해결할 뿐인데, 시각 t1에서 이전 상태는 완전히 일정하지만 시각 t2에서 새 상태는 잠재적 비일관성으로 고난을 겪기 때문이다. 그렇지만 이 기법은 신중하게 적용하면 유용하다.

14.6.3.5. 시간 기록(Time-Stamping)
게임 객체 상태의 일관성을 향상시키는 쉽고 싸게 먹히는 한 방법은 상태를 시간 기록하는 것이다. 그러면 게임 객체의 상태 벡터가 이전 시각과 현재 시각에서의 설정과 맞는지 어쩐지 알아보는 건 하찮은 일이다. 갱신 루프 동안 다른 게임 객체의 상태를 요청하는 어떤 코드든 시간 기록을 확인하여 적합한 상태 정보를 얻었음을 확신할 수 있다.

시간 기록은 버킷의 갱신 중 상태 비일관성을 해결하지 않는다. 하지만 우린 지금 무슨 버킷을 갱신하고 있는지를 나타내기 위해 전역 변수나 정적 변수를 설정할 수 있다. 짐작컨대 모든 게임 객체는 자신이 어느 버킷에 있는지 "안다". 그러면 지금 갱신하는 버킷과 비교하여 요청받은 게임 객체의 버킷을 검사하고 비일관 상태 질의를 막기 위해 이것들이 같지 않다고 단정할 수 있다.
* time stamp : 어느 시점에 데이터가 존재했다는 사실을 증명하기 위하여 특정 위치에 표시하는 시각. 공통적으로 참고하는 시각에 대해 시간의 기점을 표시하는 시간 변위 매개 변수 [네이버 사전]

14.6.4. 병렬 처리를 위한 설계
7.6절에서 요즘 게임용 하드웨어에서 표준이 된 병렬 처리 자원의 이점을 게임 엔진이 얻을 수 있는 많은 접근법을 소개했다. 그러면 병렬이 게임 객체 상태가 갱신되는 방식에 어떻게 영향을 끼치는가?

14.6.4.1. 게임 객체 모형 자체의 병렬화
게임 객체 모형은 몇 가지 이유로 인해 병렬화하기 어렵기로 악명이 높다. 게임 객체는 다른 게임 객체들과 수많은 엔진 서브시스템이 생성하는 자료에 대단히 의존하는 경향이 있다. 게임 객체들은 서로 의사소통하는데 때로는 갱신 루프 동안 여러 번 그러며, 의사소통 양식은 예측할 수 없고 플레이어의 입력과 게임 세계에서 일어나는 사건에 대단히 민감해질 수 있다. 이는 게임 객체 갱신을 여러 쓰레드에서 처리하는 것을 어렵게 하는데, 예컨데 객체간 통신을 지원하는 데 필요한 쓰레드 동기화의 양이 수행능력 관점에서 볼 때 엄두를 못 낼 정도이기 때문이다. 그리고 다른 게임 객체의 상태 벡터를 직접 살펴보면, 갱신을 위해 플레이스테이션 3의 SPU 같은 보조 프로세서의 독립 메모리로 게임 객체를 DMA하는 것이 불가능하다.

그렇긴 하지만, 게임 객체 갱신은 이론적으로는 병렬로 행해질 수 있다. 현실적으로 만들려면 게임 객체가 다른 게임 객체의 상태 벡터를 직접 살피지 않음을 보장하기 위해 전체 객체 모형을 주의깊게 설계해야 한다. 모든 객체간 통신은 메시지 교환을 통해 행해야 하고, 게임 객체간 메시지 교환을 위한 효율적인 시스템이 필요할 것이다. 객체들이 완전히 다른 메모리 영역에 있거나 물리적으로 동떨어진 서로 다른 CPU 코어에 있어도 말이다. Ericsson의 Erlang(http://www.erlang.org) 같은 분산 프로그래밍 언어를 사용하여 게임 객체 모형을 코딩하는 것에 대한 연구가 행해지기도 했다. 이런 언어는 병렬 처리와 메시지 교환과 쓰레드간 핸들 문맥 전환(handle context switching)이 C나 C++ 같은 언어보다 효율적이고 빠르게 수행되도록 내장 지원을 제공하고, 프로그래밍 문법은 프로그래머들이 절대로 "규칙을 어기지 않도록" 도와주어 공존, 분산, 다수 대행자(agent) 설계가 적합하고 효율적으로 기능하게 해준다.

14.6.4.2. 병렬 엔진 서브시스템들과의 접촉(Interfacing with Concurrent Engine Subsystems)
정교한 병렬, 분산 객체 모형이 이론으로는 실현 가능하고 극히 흥미로운 연구 분야지만 대부분의 게임 팀이 지금은 이걸 사용하지 않는다. 대신 그들은 객체 모형을 단일 쓰레드에 놓고 구식 게임 루프를 사용하여 객체들을 갱신한다. 그들은 차라리 게임 객체들이 의존하는 여러 저수준 엔진 서브시스템을 병렬화하는 데 초점을 맞춘다. 이는 팀에게 가장 큰 "본전은 뽑는 가치"를 선사하는데, 저수준 엔진 서브시스템은 게임 객체 모형보다 퍼포먼스가 중요하기 때문이다. 이는 저수준 서브시스템이 프레임마다 방대한 자료를 처리하는 반면 게임 객체 모형이 사용하는 CPU 출력이 조금은 적기 때문이다. 이것은 80-20 법칙의 예시다.

물론 단일 쓰레드식 게임 객체 모형을 사용한다고 게임 프로그래머들은 병렬 프로그래밍 문제를 전혀 의식하지 못한다는 뜻은 아니다. 객체 모델은 함께 실행되는 엔진 서브시스템과 상호작용해야 한다. 이 패러다임 변화로 게임 프로그래머들이 병렬 처리 시대 전에는 잘 먹혔을 프로그래밍 패러다임을 피하고 새 것을 받아들이게 되었다.

게임 프로그래머가 시작해야 하는 가장 중요한 변화는 비동기적으로 생각하는 것이라고 생각한다. 7.6.5절에서 서술했듯이 게임 객체가 시간이 오래 걸리는 작업을 요구할 때 blocking 함수를 호출하는 것은 피해야 한다. blocking 함수는 호출하는 쓰레드의 context에서 직접 작동하여 그 작업이 완료되기 전까지 쓰레드를 block하는 함수다. 대신 가능한 한, non-blocking 함수를 호출하여 크거나 비싼 작업을 요구해야 한다. non-blocking 함수는 실행해야 하는 요구사항을 다른 쓰레드, 코어, 프로세서로 보내고 호출하는 함수에 대한 제어를 즉시 돌려주는 함수다. 주 게임 루프는 다른 게임 객체를 갱신하는 것과 같은 무관한 작업을 계속하는 반면 원래 객체는 요청의 결과를 기다린다. 원래 객체는 요청의 결과를 같은 프레임에서 나중에 받거나 또는 다음 프레임에서 받아 이용할 수 있다.

일괄은 게임 프로그래머들이 생각해야 하는 또다른 변화다. 14.6.2절에서 언급했듯이 비슷한 작업들을 모아 한번에 처리하는 것이 따로따로 하는 것보다 효율이 좋다. 이것은 게임 객체 상태 갱신에도 적용된다. 예를 들어 게임 객체가 여러 목적을 위해 충돌 시스템에 광선을 100개 발사해야 한다면 광선 발사 요청을 쌓아놓고 한 번에 실행하는 게 최선책이다. 이미 있는 게임 엔진을 병렬을 위해 개선한다면 여러 요청을 따로하지 않고 모아놓도록 코드를 다시 써야 한다.

 
동기적이고 비일괄적인 코드를 비동기적이고 일관되게 바꾸는 것의 한 가지 까다로운 측면은, 게임 루프 중 언제 요청을 시작하고 기다렸다 결과를 이용할지 결정하는 것이다. 다음 질문을 스스로에게 던져보면 도움이 될 것이다.

 
- 이 요청을 얼마나 빨리 개시할 수 있는가? 빨리 요청할 수록 실제로 결과가 필요하기 전에 끝마칠 가능성이 높다 -- 그리고 비동기 요청이 완료되길 기다리는 동안 주 쓰레드가 절대 놀지 않음을 보장하여 CPU 활용이 극대화된다. 그러니 임의의 요청을 처리하기에 충분한 정보가 모이는 가장 빠른 프레임을 결정하고 그 시점에 개시해야 한다.
- 이 요청의 결과가 나올 때까지 얼마나 기다릴 수 있는가? 갱신 루프의 후반부까지 기다렸다가 요청의 나머지 반절을 처리하는 것은 받아들일 만하다. 한 프레임 늦어지는 건 넘어가고, 최근 프레임의 결과를 이용해 현재 프레임에서의 객체 상태를 갱신할 수도 있다. (AI 같은 하위시스템은 몇 초마다 갱신되기 때문에 더 오래 기다릴 수도 있다) 많은 상황에서, 어떤 요청의 결과를 사용하는 코드는 조금만 생각하면 코드 리팩토링이나 중간 데이터의 캐싱을 통해 그 프레임의 후반부까지 실행을 지연할 수 있다

 
* 티바이트의 지적으로 양동이를 버킷으로 수정한다. 글쓴이도 이 의미를 연상하면서 쓴 것 같다.
 
버킷 : 직접 액세스 기억 장치에서 사용하는 기억 단위
 

1 Comments
댓글쓰기 폼