객체를 효과적으로 찾기 위해 객체 위치에 따라 구성되는 자료구조에 저장한다.
수백 명의 군인이 뒤엉켜 싸우는 게임이 있다.
전사는 칼을 휘두르기 전에 적이 어디에 있는지를 알아야 한다.
그러나 이렇게 무식하게 이중 중첩 루프로 구현한다면, 유닛 수가 많아질수록 검사 횟수가 매우 늘어나게 된다.
(공간 복잡도: O(n^2))
void handleMelee(Unit* units[], int numUnits) {
for (int a = 0; a < numUnits - 1; ++a) {
for (int b = a + 1; b < numUnits; ++b) {
if (units[a]->position() == units[b]->position()) {
handleAttack(units[a], units[b]);
}
}
}
}
1차원 공간이라면 위치에 따라 정렬했을 때 전체 배열을 다 훑지 않아도 이진 검색 등으로 주변 유닛을 찾을 수 있다.
이를 2차원 이상의 공간에 적용한 것이 공간 분할 패턴이다.
살아있는 객체 말고 정적인 프랍이나 지형을 저장하는 데에도 흔히 쓰인다.
위치 값이 있는 객체가 많고, 위치에 따라 객체를 찾는 질의가 성능에 영향을 줄 정도로 많을 때 공간 분할 패턴을 사용한다.
공간 분할 패턴에서는 객체를 위치에 따라 정리하기 때문에, 객체의 위치 변경 처리가 어렵다.
그런데 공간 분할 패턴은 객체가 많을 수록 의미있는 패턴이다. 즉 객체가 많이 없다면 굳이 사용할 이유가 없다.
또한 공간 분할 자료구조에 추가적인 메모리가 필요하므로, CPU가 메모리보다 부족한 경우에는 오히려 손해이다.
가장 간단한 공간 분할 형식인 고정 격자 방법을 소개한다.
정사각형 모양의 고정 크기 격자들이 있는 모눈종이 모양의 전장이 있다.
유닛을 격자 칸 안에 넣으며, 칸마다 유닛 리스트가 있다.
전투를 처리할 때 같은 칸에 있는 유닛만 고려한다.
class Unit {
// 유닛이 이동할 때 Grid에서 데이터 수정하므로 friend class
friend class Grid;
public:
Unit(Grid* grid, double x, double y) : grid_(grid), x_(x), y_(y) {}
void move(double x, double y);
private:
double x_, y_; // 2차원 위치값
Grid* grid_; // 자신이 속해 있는 격자
// 연결 리스트로 유닛 관리 가능
Unit* prev_;
Unit* next_;
};
class Grid {
public:
// 격자 초기화
Grid() {
for (int x = 0; x < NUM_CELLS; ++x) {
for (int y = 0; y < NUM_CELLS; ++y) {
cells_[x][y] = nullptr;
}
}
}
static const int NUM_CELLS = 10;
static const int CELL_SIZE = 20;
private:
// 모든 칸이 유닛의 포인터
Unit* cells_[NUM_CELLS][NUM_CELLS];
};
칸->유닛<->유닛<->유닛
격자 칸은 해당 칸에 있는 유닛 중 첫 번째 유닛을 가리킨다.
이렇게 연결리스트로 유닛을 관리한다.
// Unit 생성자에서 알맞은 격자 칸에 유닛 추가
Unit::Unit(Grid* grid, double x, double y)
: grid_(grid), x_(x), y_(y), prev_(nullptr), next_(nullptr) {
grid_->add(this);
}
void Grid::add(Unit* unit) {
// 어느 칸에 들어갈 지 결정
int cellX = (int)(unit->x_ / Grid::CELL_SIZE);
int cellY = (int)(unit->y_ / Grid::CELL_SIZE);
// 해당 격자 칸의 유닛 리스트의 첫 번째 유닛으로 추가
unit->prev_ = nullptr;
unit->next_ = cells_[cellX][cellY];
cells_[cellX][cellY] = unit;
if (unit->next_ != nullptr)
unit->next_->prev_ = unit;
}
void Grid::handleMelee() {
// 모든 격자 칸을 순회하며 handleCell 호출
for (int x = ; x < NUM_CELLS; ++x) {
for (int y = 0; y < NUM_CELLS; ++y) {
handleCell(cells_[x][y]);
}
}
}
거대한 전장을 각각 고립된 전투 공간들(격자 칸)로 분리했다.
void Grid::handleCell(Unit* unit) {
while (unit != nullptr) {
Unit* other = unit->next_;
while (other != nullptr) {
if (unit->x_ == other->x_ && unit->y_ == other->y_) {
handleAttack(unit, other);
}
other = other->next_;
}
unit = unit->next_;
}
}
각 칸에서 전투를 처리하는 방법이다.
모든 유닛을 순회하면서 처리하므로, 제일 처음에 만들었던 코드와 다를 게 없다.
유일한 차이점은 전장에 있는 모든 유닛을 확인하지 않는다는 것이다. 이것이 최적화의 핵심!
유닛이 이동할 때마다 추가 작업이 필요하다.
칸을 넘어가면 해당 칸에서 유닛을 제거하고 새로운 칸에 추가해야 한다.
void Unit::move(double x, double y) {
grid_->move(this, x, y);
}
void Grid::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_ != nullptr) {
unit->prev_->next_ = unit->next_;
}
if (unit->next_ != nullptr) {
unit->next_->prev_ = unit->prev_;
}
if (cells_[oldCellX][oldCellY] == unit) {
cells_[oldCellX][oldCellY] = unit->next_;
}
// 이동할 칸에 유닛 추가
add(unit);
}
매 프레임마다 많은 유닛들을 연결 리스트에서 넣었다 빼기 때문에,
추가 삭제가 빠른 이중 연결 리스트를 사용하는 것이다.
사실적인 게임에서는 공격 볌위도 고려해야 한다.
같은 격자칸에 위치하지 않아도 공격 범위에 있을 수 있으므로, 조금 더 넓게 검사할 수 있어야 한다.
void Grid::handleUnit(Unit* unit, Unit* other) {
while (other != nullptr) {
if (distance(unit, other) < ATTACK_DISTANCE) {
handleAttack(unit, other);
}
other = other->next_;
}
}
void Grid::handleCell(int x, int y) {
Unit* unit = cells_[x][y];
while (unit != nullptr) {
// 전처럼 해당 칸에 들어있는 유닛 처리
handleUnit(unit, unit->next_);
// 인접한 칸에 있는 유닛 처리
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_;
}
}
handleCell이 handleUnit을 사용하게 바꿨다
기존의 handleCell은 해당 격차 칸 내에서만 검사했지만 주변 격자 칸의 유닛도 처리할 수 있게 되었다.
여기서 주의할 점은 인접한 8칸 중 4칸만 검사하고 있는 것이다.
예제는 단순 충돌검사이기 때문에 두 유닛이 인접한지만 검사하면 되기 때문에 중복해서 충돌 검사를 하지 않도록 반만 검사한다.
단, 일반적인 공격은 비대칭이기 때문에 8칸을 모두 검사하는 것이 맞다. (A가 B를 공격, B가 A를 공격하는 것은 다르기 때문)
앞선 예제들은 공간을 여러 개의 균등한 칸으로 나누었다.
계층적 공간 분할은 공간을 먼저 몇 개의 영역으로 나눈다.
객체가 많은 영역은 다시 분할하여 모든 영역에 있는 유닛 개수가 특정 개수 이하로 떨어질 때까지 재귀적으로 반복한다.
예제 코드와 달리 적응형 분할 방식은 유닛 개수와 위치에 따라 분할 크기를 조절한다.
쿼드트리
- 고정 분할과 적응형 분할의 장점을 가진다.
- 전체 공간을 한 영역으로 시작해 한 공간의 객체 개수가 일정 개수 이상이면 1/4로 분할한다.
- 각 사각형의 객체 개수가 일정 수 이하가 될 때까지 재귀적으로 반복한다.
컬렉션이 두 개가 되면서 생기는 메모리 비용과 복잡도를 피할 수 있다. (컬렉션이 두 개면 동기화가 필요하다.)
전체 객체를 더 빠르게 순회할 수 있다.
객체마다 계속해서 처리할 작업이 있는데 공간 분할 자료구조에만 저장하면 모든 칸을 전부 뒤져야 한다.
객체를 별도의 컬렉션에도 저장하면 순회 과정이 훨씬 빠르다. > 두 개의 자료구조를 각자 필요에 맞게 최적화할 수 있다.