Skip to main content

[Design Pattern] 게임 최적화 : 데이터 지역성, 더티 플래그, 오브젝트 풀, 공간 분할

게임 프로그래밍 패턴 책을 읽고 공부한 노트입니다.




데이터 지역성 #

  • CPU 속도 증가를 RAM이 따라가지 못하자 하드웨어 개발자들은 CPU 캐싱이라는 해결책을 내놓았다.
    • CPU 안에는 캐시라는 작은 메모리가 있는데 이 메모리는 주메모리보다 훨씬 빠르게 CPU에 데이터를 전달할 수 있다.
    • CPU가 RAM에서 데이터를 가져와야 할 경우 RAM은 연속되는 메모리 덩이를 캐시에 복사해 놓는다. 이것을 캐시. 라인(cache line)이라고 한다.
    • 이후에 CPU가 필요한 데이터가 이 캐시 라인에 들어 있다면, RAM보다 훨씬 빠른 캐시로부터 데이터를 가져올 수 있게된다.
    • CPU가 찾는 데이터가 캐시에 있는 경우를 캐시 히트(cache hit)라고 하며, 없는 경우를 캐시 미스(cache miss)라고 한다. 이 캐시 미스를 최대한 줄여야 성능이 향상될 수 있겠다.

  • 패턴의 의미
    • 데이터 지역성을 높일 수록(데이터를 처리하는 순서대로 연속된 메모리에 둘수록) 캐시 히트로 성능을 높일 수 있다.
  • 언제 쓸까?
    • 성능에 문제가 발생했는데 그 이유가 캐시 미스 때문일 때 사용한다.
  • 주의사항
    • 객체지향 언어에서는 인터페이스를 사용해서 객체들을 디커플링한다. 하지만 이 과정에서 포인트를 쓰게 되므로 메모리를 여기 저기 뒤적거려야 되기 때문에 캐시 미스가 발생할 수 있다. 따라서 데이터 지역성을 위해서는 이런 추상화 기법을 조금은 희생해야 한다.

코드 예제 #

  • 아래의 게임 루프를 살펴보자.
// 개체 
class GameEntity
{
private:
  // 컴포넌트를 포인터로 가지고 있다. 
  AIComponent* ai;
  PhysicsComponent* physics;
  RenderComponent* render;

public:
  GameEntity(AIComponent* a, PhysicsComponent* p, RenderComponent* r) : ai(a), physics(p), render(r) {}
  AIComponent* ai() { return ai; }
  PhysicsComponent* physics() { return physics; }
  RenderComponent* render() { return render; }
};

class World
{
private:
  // 개체들을 포인터로 가지고 있다. 
  Entity* entities[MAX_ENTITIES]; 
  int numEntities;
  
public:
  // 게임 루프 
  void gameLoop()
  {
    while (true)
    {
      // AI를 처리한다
      for (int i = 0; i < numEntities; i++)
        entities[i]->ai()->update();
      
      // 물리를 업데이트한다 
      for (int i = 0; i < numEntities; i++)
        entities[i]->physics()->update();
              
      // 화면에 그린다
      for (int i = 0; i < numEntities; i++)
        entities[i]->render()->update();
    }
  }
};

  • CPU 캐시를 알고나니 이 코드는 뭔가 잘못되었다.
    • 개체 목록이 포인터로 되어있어서 배열의 각 개체에 접근할 때마다 포인터를 따라가며 캐시 미스가 난다.
    • 개체 안에 컴포넌트가 포인터로 되어있어서 접근할 때마다 또 캐시 미스가 난다.
    • 이 모든 과정이 반복된다.

  • 각 개체 안에 있는 컴포넌트들을 배열로 관리하면 어떨까?
    • 각 개체는 여전히 자기 컴포넌트 포인터를 들고 있다.
    • 하지만 게임 월드에서 컴포넌트 배열을 가지고 있고, 그 안에는 포인터가 아니라 객체들이 들어가게 하는 것이다.
// 객체들이 들어가는 배열을 마련한다. 
AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];
PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES];
RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES];
// 그러면 이렇게 게임루프에서 해당 컴포넌트 객체에 바로 접근이 가능하다. 
void gameLoop()
{
  while (true)
  {
    for (int i = 0; i < numEntities; i++)
      aiComponents[i]->update();
  
    for (int i = 0; i < numEntities; i++)
      physicsComponents[i]->update();
          
    for (int i = 0; i < numEntities; i++)
      renderComponents[i]->update();
  }
}

  • 이번에는 파티클 시스템을 살펴보자.
class Partical
{
public:
  void update();
  bool isActive();
};

class ParticalSystem
{
private:
  static const int MAX_PARTICLES = 100000;
  int numParticles;
  Particle particles[MAX_PARTICLES];
  
public:
  ParticalSystem() : numParticles(0) {}
  
  void update()
  {
    for (int i = 0; i < numParicles; i++)
      // 플래그를 검사해서 활성화된 파티클만 업데이트한다. 
      if (particles[i].isActive()) 
        particles[i].update();
    
    // 만약 활성화 파티클이 적으면 어떻게 될까?
    // 계속 건너뛰는 횟수가 많아질 것이며 그만큼 캐시 미스가 발생할 것이다. 
  }
};

  • 따라서 활성여부를 플래그로 검사하는 게 아니라 활성화된 파티클만 맨 앞으로 모아두면 될 것이다.
    • 그러면 더이상 아무것도 건너뛸 필요가 없으며,
    • 업데이트 코드에서 플래그를 검사할 필요도 없다.
  • 매번 정렬할 필요 없이 다음과 같이 활성/비활성 시에 위치를 바꿔치기하면 된다.
    • 복사 비용(swap())이 포인터를 할당하는 것에 비해 무겁다고 느낄 수 있겠지만, 포인터 추적 비용까지 놓고 보면 직감이 틀릴 수도 있다.
// 파티클 활성화하기
void ParticleSystem::activateParticle(int idx)
{
  // 비활성 상태여야한다. 
  assert(idx >= numActive);
  
  // 비활성 파티클의 맨 앞에 있는 것과 바꿔치기한다. 
  swap(particles[idx], particles[numActive]);
  
  numActive++;
}

// 파티클 비활성화하기 
void ParticleSystem::deactivateParticle(int idx)
{
  // 활성 상태여야한다. 
  assert(idx < numActive);
  
  numActive--;
  
  // 활성 파티클의 맨 뒤에 있는 것과 바꿔치기한다. 
  swap(particles[idx], particles[numActive])
}

  • 마지막으로 빈번한 코드와 한산한 코드 나누기 기법을 예제를 통해 살펴보자.
class AIComponent
{
private:
  Animation* animation;
  double energy;
  Vector goalPos;
  
  // 죽었을 때 아이템을 떨구기 위한 데이터들
  LootType drop;
  int minDrops;
  int maxDrops;
  double chanceOfDrop;

public:
  void update();
  // 매 프레임 마다 애니메이션, 에너지 값, 목표 지점을 확인하고 변경한다. 
  // 또한 죽었을 때 아이템을 떨어뜨리는 데이터들도 확인하고 변경한다. 
};

  • 여기서 죽었을 때 관련한 데이터들은 죽었을 때 딱 한번만 사용되는 것이다.
  • 이런 데이터들이 많아져서 컴포넌트 크기가 늘어나면, 캐시 라인에 들어갈 컴포넌트 개수가 줄어들게 되고, 결국 캐시 미스가 발생할 것이다.
  • 해결책은 빈번한 코드와 한산한 코드를 나누는 것(hot/cold splitting) 이다.
    • 데이터를 두 개로 분리해서 한곳에는 매 프레임마다 필요로 하는 빈번한 데이터와 상태를 두고,
    • 다른 곳에는 자주 사용하지 않는 한산한 데이터를 두는 것이다.
// 한산한 데이터들
class LootDrop
{
friend class AIComponet;
  LootType drop;
  int minDrops;
  int maxDrops;
  double chanceOfDrop;
};

class AIComponent
{
private:
  // 빈번한 데이터들
  Animation* animation;
  double energy;
  Vector goalPos;
  
  // 한산한 데이터들은 포인터로 가리키게 한다. 
  // AIComponent의 전체 크기가 줄어든다. 
  LootDrop* loot;
};

디자인 결정에 고려할 사항들 #

  • 다형성은 어떻게 할 것인가?
    • 사용하지 않는다
    • 종류별로 다른 배열에 넣는다
    • 하나의 컬렉션에 포인터를 모아놓는다
  • 게임 개체는 어떻게 정의할 것인가?
    • 게임 개체 클래스가 자기 컴포넌트를 포인터로 들고 있을 때
    • 게임 개체 클래스가 컴포넌트를 ID로 들고 있을 때
    • 게임 개체가 단순히 ID일 때



더티 플래그 #

  • 게임에서 해적선이 바다에 떠 있는데 돚대에는 망대가 달려있고, 그 망대 위에는 해적이 서있다. 그리고 그 해적의 어깨위에는 앵무새가 앉아 있다.
  • 그 모든 객체들이 마음대로 움직인다고 생각해보자. 각각의 로컬 트랜스폼이 변했다.
    • 이 객체들을 월드에 그리려면 로컬 트랜스폼을 월드 트랜스폼으로 계산해야 한다.
    • 상위 객체가 움직이면 하위 객체들의 월드 트랜스폼값도 재귀적으로 전부 재계산해야 한다.
움직인 객체 월드 트랜스폼 계산
배 로컬 트랜스폼 이동 배 월드 트랜스폼 재계산
망대 월드 트랜스폼 재계산
해적 월드 트랜스폼 재계산
앵무새 월드 트랜스폼 재계산
망대 로컬 트랜스폼 이동 망대 월드 트랜스폼 재계산
해적 월드 트랜스폼 재계산
앵무새 월드 트랜스폼 재계산
해적 로컬 트랜스폼 이동 해적 월드 트랜스폼 재계산
앵무새 월드 트랜스폼 재계산
앵무새 로컬 트랜스폼 이동 앵무새 월드 트랜스폼 재계산

  • 객체는 4개가 움직였지만 계산은 10번 해야한다.
    • 특히 앵무새를 그리기 위해서 월드 트랜스폼을 4번이나 계산했다.
  • 이런 계산을 렌더링마다 해줘서 월드 트랜스폼을 계산한다면 CPU 자원을 낭비하게 되겠다.

  • 월드 트랜스폼 계산을 렌더링 직전에 한 번만 계산하면 어떨까?
    • 객체에 플래그(flag)를 추가해서 로컬 트랜스폼이 바뀌면 플래그를 켠다.
    • 플래그가 켜졌다는 것은 월드 트랜스폼이 맞지 않음(dirty)을 나타낸다고 볼 수 있다.
    • 그리고 월드 트랜스폼 값이 필요할 때 그 플래그를 검사해서, 켜져 있으면 계산한 뒤 플래그를 끄는 것이다.
움직인 객체 월드 트랜스폼 계산
배 로컬 트랜스폼 이동
망대 로컬 트랜스폼 이동
해적 로컬 트랜스폼 이동
앵무새 로컬 트랜스폼 이동
(렌더링 전) 배 월드 트랜스폼 재계산
망대 월드 트랜스폼 재계산
해적 월드 트랜스폼 재계산
앵무새 월드 트랜스폼 재계산

  • 더티 플래그
    • 계속해서 변경되는 기본 값(primary data) 과, 그 기본 값에 비싼 작업을 거쳐야 얻을 수 있는 파생 값(derived data) 이 있다.
    • 기본 값이 변경되면 더티 플래그를 켠다.
    • 그리고 파생 값이 필요할 때 더티 플래그를 검사한다.
      • 켜져 있다면 계산한 뒤 더티 플래그를 끈다.
      • 꺼져 있다면 캐시해놓은 파생 값을 쓴다.
  • 언제 쓸까?
    • 기본 값이 파생 값보다 자주 변경되는데, 기본 값에서 파생 값을 계산하는 게 오래걸릴 때 사용한다.
  • 주의사항
    • 계산을 너무 오래 지연하면 나중에 계산할 때 화면 멈춤 현상이 생길 수도 있다. 또한 계산 전에 작업이 전부 날아갈 위험성도 있다.
    • 기본 값이 변경되었을 때 플래그 켜는 것을 한 곳에서라도 놓치면 버그가 생길 수 있다.
    • 이전 파생 값을 메모리에 저장해둬야 한다.

코드 예제 #

class Transform
{
public:
  // 아무런 이동, 회전, 크기 변화가 없는 단위행렬로 나타낸 기본 트랜스폼을 반환한다. 
  static Transform origin();
  
  // 객체의 월드 트랜스폼을 계산한다. 
  // 상위 노드를 따라서 로컬 트랜스폼 값을 전부 결합해서 계산한다. 
  Transform combine(Transform& other);
};

class GraphNode
{
private:
  Transform local;
  Mesh* mesh;
  GraphNode* children[MAX_CHILDREN];
  int numChildren;

public:
  GraphNode(Mesh* m) : mesh(m), local(Transform::origin()) {}
};

  • 먼저, 매번 월드 트랜스폼을 계산하는 코드를 보자.
// 렌더링한다. 
void GraphNode::render(Transform parentWorld)
{
  // 월드 트랜스폼을 계산한다. 
  Transform world = local.combine(parentWorld);
  if (mesh) renderMesh(mesh, world);
  
  // 모든 하위 객체들을 재귀적으로 호출한다. 
  for (int i = 0; i < numChildren; i++)
    children[i]->render(world);
}

  • 이제 더티 플래그 패턴을 활용해보자.
class GraphNode
{
private:
  Transform world; // 월드 트랜스폼을 캐시한다.
  bool dirty;      // 더티 플래그 
  
  Transform local;
  Mesh* mesh;
  GraphNode* children[MAX_CHILDREN];
  int numChildren;

public:
  GraphNode(Mesh* m) : mesh(m), local(Transform::origin()), dirty(true) {}
  
  // 로컬 트랜스폼을 변화시킨다.
  void setTransform(Transform l)
  {
    local = l;
    dirty = true; // 더티 플래그가 켜진다. 
  }
  
  // 렌더링한다. 
  void render(Transform parentWorld, bool d)
  {
    dirty |= d;
    if (d)
    {
      // 더티 플래그가 켜졌을 때만 월드 트랜스폼을 계산한다. 
      world = local.combine(parentWorld);
      dirty = false;
    } 
    
    if (mesh) renderMesh(mesh, world);
  
    // 모든 하위 객체들을 재귀적으로 호출한다.
    // 만약 상위 객체 중에 하나라도 d == true 였다면
    // 그 하위 객체들은 모두 업데이트된다. 
    for (int i = 0; i < numChildren; i++)
      children[i]->render(world, d);
  }
};

디자인 결정에 고려할 사항들 #

  • 언제 계산하고 더티 플래그를 끌 것인가?
    • 결과가 필요할 때
    • 미리 정해놓은 지점해야 할 때
    • 백그라운드로 처리할 때
  • 더티 플래그는 값 변화를 얼마나 세밀하게 관리해야 하는가?
    • 더 세밀하게 관리한다
    • 더 듬성듬성하게 관리한다



오브젝트 풀 #

  • 플레이어가 마법봉을 한 번 휘두르는 것만으로도 수백 개의 파티클이 생성될 수 있다.
    • 파티클은 굉장히 빠르게 만들 수 있어야한다.
    • 또한 메모리 단편화가 생기면 안 된다.
    • 그렇다면 파티클 시스템은 어떻게 메모리를 관리해야 할까?
  • 오브젝트 풀
    • 재사용할 수 있는 객체들을 모아놓은 오브젝트 풀 클래스를 정의한다.
    • 풀은 초기화될 때 객체들을 미리 생성해 놓는다.
    • 새로운 객체가 필요할 때 풀에 요청해서 객체를 얻을 수 있다.
  • 언제 쓸까?
    • 객체를 빈번하게 생성/삭제해야 한다.
    • 객체들의 크기가 비슷하다.
    • 객체를 힙에 생성하기가 느리거나 메모리 단편화가 우려된다.
    • 데이터베이스 연결이나 네트워크 연결같이 접근 비용이 비싸면서 재사용 가능한 자원을 객체가 캡슐화하고 있다.
  • 주의사항
    • 오브젝트 풀에서 사용되지 않는 객체는 메모리 낭비와 다를 바 없다.
    • 한 번에 사용 가능한 객체 개수가 정해져 있다.
    • 객체를 위한 메모리 크기는 고정되어 있다.
    • 재사용되는 객체는 저절로 초기화되지 않는다.
    • 사용 중이지 않은 객체도 메모리에 남아 있다.

코드 예제 #

class Particle
{
private:
  int framesLeft;
  double x, y;
  double xVel, yVel;

public:
  Particle() : framesLeft(0) {}
  
  void init(double _x, double _y, double _xVel, double _yVel, int lifetime)
  {
    x = _x;
    y = _y;
    xVel = _xVel;
    yVel = _yVel;
    framesLeft = lifetime;  
  }

  void animate()
  {
    if (inUse() == false) return;
    
    framesLeft--;
    x += xVel;
    y += yVel;
  }
  
  bool inUse() const { return framesLeft > 0; }
};

class ParticlePool
{
private:
  static const int POOL_SIZE = 100;
  Particle particles[POOL_SIZE];
  
public:
  void create(double _x, double _y, double _xVel, double _yVel, int lifetime)
  {
    for (int i = 0; i < POOL_SIZE; i++)
    {
      // 아직 사용 중이지 않은 객체를 찾아서 초기화한다. 
      if (particles[i].inUse() == false)
      {
        particles[i].init(_x, _y, _xVel, _yVel, lifetime);
        return;
      }
    }
  }
  
  void animate()
  {
    for (int i = 0; i < POOL_SIZE; i++)
      particles[i].animate();
  }
};

  • 아직 사용 중이지 않은 객체를 찾느라고 시간을 낭비하고 싶지 않다면?
  • 빈칸 리스트(free list) 기법을 적용해보자.
    • 파티클 객체 안에 사용 가능한 다음 파티클을 가리키는 포인터를 둔다.
    • 이 포인터를 이용하면 풀에서 사용 가능한 파티클이 묶여 있는 연결 리스트를 만들 수 있다.
    • 이렇게 하면 풀에다가 추가적으로 연결리스트를 만들어서 관리하지 않아도 된다.
class Particle
{
private:
  int framesLeft;
  
  union 
  {
    // 살아 있는 동안의 상태들
    struct
    {
      double x, y;
      double xVel, yVel;
    } live;
    
    // 이 파티클 다음에 사용 가능한 파티클 객체를 가리킨다. 
    Particle* next;
  } state;

public:
  Particle* getNext() const { return state.next; }
  void setNext(Particle* next) { state.next = next; }
  
  bool animate()
  {
    if (inUse() == false) return false;
        
    framesLeft--;
    x += xVel;
    y += yVel;
    
    // 파티클이 죽으면 true를 반환해서
    // 빈칸 리스트에 추가할 수 있도록 한다. 
    return framesLeft == 0;
  }
};

class ParticlePool
{
private:
  Particle* firstAvailable;

public:
  ParticlePool()
  {
    // 처음 파티클부터 사용 가능하다. 
    firstAvailable = &particles[0];
    
    // 모든 파티클은 그 다음 파티클을 가리킨다. 
    for (int i = 0; i < POOL_SIZE - 1; i++)
      particles[i].setNext(&particles[i + 1]);
    
    // 마지막 파티클에서 리스트를 종료한다. 
    particles[POOL_SIZE - 1].setNext(NULL);
  }
  
  void create(double _x, double _y, double _xVel, double _yVel, int lifetime)
  {
    assert(firstAvailable != NULL);
    
    // firstAvailable이 얻은 파티클(newParticle)의 다음 객체를 가리키도록 해서 
    // 얻은 파티클을 빈칸 리스트에서 제외한다. 
    Particle* newParticle = firstAvailable;
    firstAvailable = newParticle->getNext();
    newParticle.init(_x, _y, _xVel, _yVel, lifetime);
  }
  
  void animate()
  {
    for (int i = 0; i < POOL_SIZE; i++)
    {
      // 파티클이 죽으면 
      if (particles[i].animate() == false) continue;
      
      // firstAvailable이 죽은 파티클을 가리키도록 해서
      // 죽은 파티클을 빈칸 리스트에 추가한다. 
      particles[i].setNext(firstAvailable);
      firstAvailable = &particles[i];
    }    
  }
};

디자인 결정에 고려할 사항들 #

  • 풀이 객체와 커플링되는가?
    • 객체가 풀과 커플링된다
      • 객체를 풀을 통해서만 생성할 수 있도록 강제할 수 있다.
      • 객체 안에 사용 중 플래그나 함수를 추가해서 간단히 구현할 수 있다.
    • 객체가 풀과 커플링되지 않는다
      • 어떤 객체라도 풀에 넣을 수 있다.
      • 플래그 상태를 객체 외부에서 관리해야 한다.
  • 재사용되는 객체를 초기화할 때 어떤 점을 주의해야 하는가?
    • 객체를 풀 안에서 초기화한다
      • 풀은 객체를 완전히 캡슐화할 수 있으며, 풀 클래스는 객체가 초기화되는 방법과 결합된다.
    • 객체를 풀 밖에서 초기화한다
      • 풀의 인터페이스는 단순해지지만, 객체 생성이 실패할 때의 처리를 외부에서 챙겨야한다.



공간 분할 #

  • 게임에서는 드넓은 공간 안에 다양한 위치 값을 가진 객체들이 존재한다. 예를 들어, 실시간 전략 게임을 만드는데 전장에 수백이 넘는 군인들이 뒤엉켜 싸우고 있다. 이때 전사들 주변에 적들이 어디에 있는지 알아야 한다면?
    • 모든 객체의 거리값을 다 검사한다면 각 객체의 제곱만큼 검사해야한다.
    • 어떻게 효과적으로 검색할 수 있을까?

  • 공간 분할
    • 공간을 자료구조화하여서 같은 위치, 주변에 있는 객체를 빠르게 찾을 수 있게 한다.
    • 객체의 위치가 바뀌면 공간 자료구조도 업데이트해서 계속해서 객체를 찾을 수 있도록 한다.
  • 언제 쓸까?
    • 살아움직이는 게임 객체뿐만 아니라 지형을 저장하는 데 흔히 사용한다.
    • 위치 값을 빈번히 따져보는 과정이 성능에 영향을 줄 때 사용한다.
  • 주의사항
    • 객체가 많을 수록 의미가 있다.
    • 객체의 위치 변경이 잦다면 그때마다 자료구조를 재정리해야하기 때문에 이 패턴을 쓰는 의미가 있는지 먼저 확인할 필요가 있다.
    • 공간 분할 자료구조를 위한 추가적인 메모리가 필요하다.

코드 예제 #

  • 여러 가지 구현 방법이 있으나 여기서는 간단한 고정 격자(fixed grid) 방법을 알아보자.
  • 전체 맵을 정사각형 모양의 고정 크기 격자로 나눈 후, 그 안에 유닛을 연결리스트로 저장한다.
// 격자에 존재하는 객체이다. 
class Unit
{
  friend class Grid;

private:
  double x, y;
  
  // 자신이 속한 격자를 가리킨다. 
  Grid* grid; 
  
  // 이전 유닛과 다음 유닛을 가리킨다. (이중 연결 리스트)
  Unit* prev; 
  Unit* next;

public:
  Unit(Grid* g, double _x, double _y) : grid(g), x(_x), y(_y), prev(NULL), next(NULL)
  {
    // 새로 만든 유닛을 적당한 격자 칸에 넣는다. 
    grid->add(this);
  }
  
  // 유닛이 움직이면 추가 작업이 필요하다. 
  void move(double _x, double _y)
  {
    grid->move(this, _x, _y);
  } 
};

class Grid
{
private:
  // 격자의 모든 칸은 유닛의 포인터로 되어 있다. 
  Unit* cells[NUM_CELLS][NUM_CELLS];

public:
  static const int NUM_CELLS = 10;
  static const int CELL_SIZE = 20;

  Grid()
  { 
    // 격자를 모두 지운다. 
    for (int x = 0; x < NUM_CELLS; x++)
      for (int y = 0; y < NUM_CELLS; y++)
        cells[x][y] = NULL;
  }
  
  void add(Unit* unit)
  {
    // 어느 칸에 들어갈지 결정한다. 
    int cellX = (int)(unit->x / Grid::CELL_SIZE);
    int cellY = (int)(unit->y / Grid::CELL_SIZE);
    
    // 칸에 들어 있는 리스트의 맨 앞에 추가한다. 
    unit->prev = NULL;
    unit->next = cell[cellX][cellY];
    if (unit->next != NULL)
      unit->next->prev = unit;
  }
  
  // 전투를 처리한다. 
  void handleMelee()
  {
    for (int x = 0; x < NUM_CELLS; x++)
      for (int y = 0; y < NUM_CELLS; y++)
        handleCell(cells[x][y]);
  }
  
  void handleCell(Unit* unit)
  {
    // 포인터로 연결 리스트를 순회하면서 전투를 처리한다. 
    // 결과적으로 해당 칸에 있는 유닛들만 검사하게 된다. 
    while(unit != NULL)
    {
      Unit* other = unit->next;
      
      while(other != NULL)
        // 완전히 똑같은 위치마나 고려한다. 
        if (unit->x == other->x && unit->y == other->y) 
          handleAttack(unit, other);
    
      other = other->next;
    }
    
    unit = unit->next;
  }
  
  void move(Unit* unit, double x, double y)
  {
    // 유닛이 어느 칸에 있었는지 확인한다. 
    int oldCellX = (int)(unit->x / Grid::CELL_SIZE);
    int oldCellY = (int)(unit->y / Grid::CELL_SIZE);
    
    // 유닛이 어느 칸으로 가야하는지 확인한다. 
    int cellX = (int)(x / Grid::CELL_SIZE);
    int cellY = (int)(y / Grid::CELL_SIZE);
    
    unit->x = x;
    unit->y = y;
    
    // 칸이 바뀌지 않았다면 더 할 일이 없다. 
    if (oldCellX == cellX && oldCellY == cellY) return;
    
    // 이전 칸에 들어 있는 리스트에서 유닛을 제거한다. 
    if (unit->prev != NULL) 
      unit->prev->next = unit->next;
    
    if (unit->next != NULL)
      unit->next->prev = unit->prev;
    
    // 유닛이 칸에 들어 있는 리스트의 머리였다면, 머리를 바꿔준다. 
    if (cells[oldCellX][oldCellY] == unit)
      cells[oldCellX][oldCellY] = unit->next;
    
    // 새로 들어갈 칸에 추가한다. 
    add(unit);
  }
};

  • 지금까지는 정확히 같은 위치에 있는 유닛끼리만 상호작용하도록 하였다.
  • 하지만 보통 게임에서는 범위(distance) 를 고려해야한다.
    • 이럴 때는 위치가 똑같지 않아도 다음과 같이 조금 더 넓게 검사하도록 바꾸면 된다.
    • 범위는 여러 개의 칸에 겹쳐 있을 수도 있으므로, 주변 칸에 있는 유닛도 같이 비교해야할 것이다.
void Grid::handleCell(int x, int y)
{
  Unit* unit = cells[x][y];
  
  while(unit != NULL)
  {
    // 이 칸에 들어 있는 유닛들을 처리한다. 
    handleUnit(unit, unit->next);
    
    // 이 칸과 인접한 주변의 8칸 중에서
    // 좌측 4칸에 있는 유닛도 처리한다. 
    if (x > 0)                      handleUnit(unit, cells[x - 1][y]);
    if (y > 0)                      handleUnit(unit, cells[x][y - 1]);
    if (x > 0 && y > 0)             handleUnit(unit, cells[x - 1][y - 1]);
    if (x > 0 && y < NUM_CELLS - 1) handleUnit(unit, cells[x - 1][y + 1]);
    
    unit = unit->next;
  }
}

void Grid::handleUnit(Unit* unit, Unit* other)
{
  while(other != NULL)
  {
    // 범위를 고려한다. 
    if (distance(unit, other) < ATTACK_DISTANCE)
     handleAttack(unit, other);
    
    other = other->next;
  }
}

디자인 결정에 고려할 사항들 #

  • 공간을 계층적으로 나눌 것인가, 균등하게 나눌 것인가?
  • 객체 개수에 따라 분할 횟수가 달라지는가?
    • 객체 개수에 성관없이 분할한다
    • 객체 개수에 따라 영역이 다르게 분할된다
      • 이진 공간 분할(BSP)나 k-d 트리의 경우 양쪽에 같은 수의 객체가 들어 있도록 월드를 반씩 재귀적으로 쪼갠다.
    • 영역 분할은 고정되어 있으나, 계층은 객체 개수에 따라 달라진다
      • 쿼드트리는 객체 개수가 일정 수 이하가 될 때까지 $\frac{1}{4}$로 공간을 재귀적으로 나눈다.
  • 객체를 공간 분할 자료구조에만 저장하는가?