[게임 프로그래밍 패턴] Chapter19 오브젝트 풀

Jangmanbo·2023년 12월 12일
0

객체를 매번 할당/해제하지 않고 고정 크기 풀에 들어있는 객체를 재사용하여 메모리 사용 성능을 개선


게임에서 스킬을 사용할 때마다 보이는 이펙트를 위해 파티클이 필요하다.
이때 파티클을 굉장히 빠르게 만들어야 하면서, 파티클을 생성/제거하는 과정에서 메모리 단편화가 생겨서는 안된다.
콘솔, 모바일 게임은 PC에 비해 메모리가 부족하고, 효율 좋은 메모리 관리자를 거의 쓸 수 없어 메모리 단편화에 치명적이다.

객체 풀(=오브젝트 풀) 패턴을 사용하면 메모리 관리자는 처음에 한 번 메모리를 크게 잡아놓고 게임이 실행되는 동안 계속 들고 있을 수 있다.
사용자는 마음껏 객체를 할당, 해제할 수 있다.


오브젝트 풀 패턴

준비

  • 재사용 가능한 객체를 모아놓은 오브젝트 풀 클래스 정의
  • 이때 객체에는 자신이 '사용 중'인지 여부를 알 수 있는 방법 제공
  • 초기화될 때 사용할 객체들을 미리 생성하고, '사용 안 함' 상태로 초기화

사용

  1. 새로운 객체가 필요하면 풀에 요청
  2. 풀은 사용 가능한 객체를 찾아 '사용 중'으로 초기화 후 리턴
  3. 객체를 더 이상 사용하지 않는다면 '사용 안 함' 상태로 변경

언제 쓸 것인가?

  • 객체를 빈전하게 생성/삭제해야 한다.
  • 객체들의 크기가 비슷하다.
  • 객체를 힙에 생성하기가 느리거나 메모리 단편화가 우려된다.
  • DB 연결이나 네트워크 연결 같이 접근 비용이 비싸면서 재사용 가능한 자원을 객체가 캡슐화하고 있다.

주로 게임 개체나 시각적 효과같이 눈으로 볼 수 있는 것이나 사운드 등에 사용된다.


주의사항

객체 풀에서 사용되지 않는 객체는 메모리 낭비와 다를 바 없다

풀이 너무 작으면 크래시가 날 것이고, 풀이 너무 크면 메모리 낭비다.

한 번에 사용 가능한 객체 개수가 정해져 있다

객체 풀의 모든 개게가 사용 중이어서 재사용할 객체를 반환받지 못할 때를 대비해야 한다.

  1. 이런 일이 아예 생기지 않도록 하기
    • 가장 흔한 방법. 적, 아이템 등의 중요한 객체 풀에서 주로 사용한다.
  2. 객체를 생성하지 않는다.
    • 특히 파티클 시스템의 경우 모든 파티클 객체를 사용 중이라는 것은 이미 번쩍이는 이펙트가 화면을 뒤덮고 있는 이야기이다.
  3. 기존 객체를 강제로 제거한다.
    • 모든 사운드 객체가 사용 중인데 마법봉 스킬 사운드가 나지 않는다면 거슬릴 것이다. 가장 소리가 작은 사운드 객체를 새로운 사운드로 교체하는 것이 낫다.
  4. 풀의 크기를 늘린다.
    • 이 방식을 택했다면 추가로 늘린 메모리를 더 이상 쓰지 않을 때는 다시 크기를 원래대로 줄일 건지, 그대로 둘지도 결정해야 한다.

객체를 위한 메모리 크기가 고정되어 있다

배열 한 칸의 크기는 가장 큰 객체가 기준이므로, 객체 크기의 편차가 크면 낭비되는 메모리가 많아진다.
따라서 객체 크기 별로 풀을 나누는 게 좋다.

재사용되는 객체는 저절로 초기화되지 않는다

오브젝트 풀은 메모리 고나리자를 통하지 않고 객체를 재사용한다.
때문에 새로운 객체라고 할당받았어도 이전 객체의 상태가 남아있을 수 있다.

따라서 풀에서 새로운 객체를 초기화할 때 객체를 완전히 초기화해야 한다.

사용 중이지 않은 객체도 메모리에 남아있다

오브젝트 풀은 객체가 사용 중이 아니어도 메모리를 해제하지 않는데,
이때 객체가 다른 객체를 참조하고 있다면 GC에서 그 객체를 회수할 수 없다.

사용하지 않는 객체는 다른 객체를 참조하는 부분을 모두 정리해야 한다.

오브젝트 풀 패턴 예제

오브젝트: Particle

// 한 쪽 방향으로 이동하고 사라질 파티클
class Particle {
public:
    Particle() : frameLeft_(0) {}	// '사용 안 함'으로 초기화
    void init(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
    bool inUse() const { return frameLeft_ > 0; }
    
private:
    int frameLeft_;
    double x_, y_;
    double xVel_, yVel_;
};

void Particle::init(double x, double y, double xVel, double yVel, int lifetime) {
    x_ = x;
    y_ = y;
    xVel_ = xVel;
    yVel_ = yVel;
    frameLeft_ = lifetime;
}

// 매 프레임마다 호출됨 (아마도 업데이트 메서드)
void Particle::animate() {
    if (!inUse()) return;
    
    frameLeft_--;
    x_ += xVel_;
    y_ += yVel_;
}

오브젝트 풀: ParticlePool

class ParticlePool {
public:
    void create(double x, double y, double xVel, double yVel, int lifetime);
    void animate();
    
private:
	// 이런 정적 배열 말고도, 동적 배열의 크기를 지정하거나 템플릿 매개변수로 크기를 조절하는 방법도 있음
    static const int POOL_SIZE = 100;
    Particle particles_[POOL_SIZE];
};

// 매 프레임마다 Particle::animate 호출
void ParticlePool::animate() {
    for (int i = 0; i < POOL_SIZE; ++i) {
        particles_[i].animate();
    }
}

// 새로운 파티클 생성
void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) {
    for (int i = 0; i < POOL_SIZE; ++i) {
    	// 사용 가능한 파티클 찾아 초기화
        if (!particles_[i].inUse()) {
            particles_[i].init(x, y, xVel, yVel, lifetime);
            return;
        }
    }
}

이렇게 오브젝트 풀 패턴을 이용한 파티클 시스템을 완성했다.
그러나 여기에는 파티클을 생성할 때마다 전체 컬렉션을 순회하는 단점이 있다.

빈칸 리스트

사용 가능한 객체의 리스트가 있으면 좋겠지만, 이는 추가적인 메모리를 필요로 한다.
메모리를 희생하지 않고도 해결하기 위해 사용하지 않는 파티클 객체의 일부를 활용해보자.

class Particle {
public:
    Particle* getNext() const { return state_.next; }
    void setNext(Particle* next) {
        state_.next = next;
    }
    
private:
    int frameLeft_;
    
    union {
    	// 사용 중일 때의 상태
        struct {
            double x, y;
            double xVel, yVel;
        } live;
        
        // 사용 중이 아닐 때의 상태
        Particle* next;
    } state_;
};

사용하지 않는 파티클은 frameLeft_ 외의 데이터는 의미가 없다. frameLeft_ 외의 모든 멤버 변수를 live 구조체 안으로 옮겼다.
next는 이 파티클 다음에 사용 가능한 파티클 객체 포인터다. 이를 통해 사용 가능한 객체의 연결 리스트, 즉 빈칸 리스트를 만들었다.

class ParticlePool {
    // ...
private:
    Particle* firstAvailable_; // 연결 리스트의 head
};

ParticlePool::ParticlePool() {
    firstAvailable_ = &particles_[0];
    
    for (int i = 0; i < POOL_SIZE - 1; ++i)
        particles_[i].setNext(&particles_[i+1]);
    
    particles_[POOL_SIZE-1].setNext(nullptr);
}

void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) {
	// 사용 가능한 객체가 없는지 확인
    assert(firstAvailable_ != nullptr);
    
    Particle* newParticle = firstAvailable_;
    firstAvailable_ = newParticle->getNext();
    newParticle->init(x, y, xVel, yVel, lifetime);
}

초기화할 때 빈칸 리스트를 만들고, create로 얻은 파티클은 빈칸 리스트에서 제외한다.

bool Particle::animate() {
    if (!inUse()) return false;
    
    frameLeft_--;
    x_ += xVel_;
    y_ += yVel_;
    
    return frameLeft_ == 0;
}

void ParticlePool::animate() {
    for (int i = 0; i < POOL_SIZE; ++i) {
        if (particles_[i].animate()) {
            particles_[i].setNext(firstAvailable_);
            firstAvailable_ = &particles_[i];
        }
    }
}

파티클이 죽었을 때 다시 빈칸 리스트에 추가해야 하므로 animate에서 죽으면 true를 리턴한다.
죽은 파티클은 빈칸 리스트의 head가 된다.



디자인 결정

풀이 객체와 커플링되는가?

객체가 자신이 풀에 있는 지를 알게 할 것인지를 결정해야 한다.
(아무 객체나 담는 풀 클래스의 경우에는 이렇게 하기 힘들다.)

커플링 O

  • 간단함: 파티클 시스템 예제처럼 '사용 중' 플래그나 플래그 역할을 하는 함수를 추가하면 된다.
  • 객체가 풀을 통해서만 생성할 수 있도록 강제 가능: 풀 클래스를 객체 클래스의 firend로 두고 객체 생성자를 private에 둔다.
  • '사용 중' 플래그가 꼭 필요하지 않을 수 있음: 파티클 시스템 예제에서도 따로 '사용 중' 플래그가 없고 inUse 메서드로 제공했다. (추가 메모리 할당이 필요없다!)

커플링 X

  • 어떤 객체라도 풀에 넣기 가능: 일반적이고 재사용 가능한 풀 클래스 구현 가능
  • '사용 중' 상태를 객체 외부에서 관리 필요
template <class TObject>
clas GenericPool {
private:
	static const int POOL_SIZE = 100;
    
    TObject pool_[POOL_SIZE];
    bool inUse[POOL_SIZE];	// TObject 외부에서 관리
};

재사용되는 객체를 초기화할 때 어떤 점을 주의해야 하는가?

객체를 풀 안에서 초기화

  • 풀이 객체를 완전히 캡슐화: 밖에서 객체를 아예 참조할 수 없어, 예상치 못한 재사용을 막을 수 있다.
  • 풀 클래스와 객체 초기화가 결합
    초기화 메서드를 여러 개 지원하는 객체가 있을 수 있다.
    풀에서 초기화를 관리하려면, 객체의 모든 초기화 메서드를 풀에서 지원하고 객체에 포워딩해야 한다.
class Particle {
	void init(double x, double y);
    void init(double x, double y, double angle);
    void init(double x, double y, double xVel, double yVel);
};

class ParticlePool {
public:
	void create(double x, double y) {
    	// Particle 클래스로 포워딩
    }
    
    void create(double x, double y, double angle) {
    	// Particle 클래스로 포워딩
    }
    
    void create(double x, double y, double xVel, double yVel) {
    	// Particle 클래스로 포워딩
    }
};

객체를 풀 밖에서 초기화

  • 풀의 인터페이스가 단순함
    풀은 객체 초기화 함수를 전부 제공하지 않아도 된다. 새로운 객체에 대한 레퍼런스만 리턴하면 된다.
class Particle {
	void init(double x, double y);
    void init(double x, double y, double angle);
    void init(double x, double y, double xVel, double yVel);
};

class ParticlePool {
public:
	Particle* create() {
    	// 사용 가능한 파티클에 대한 레퍼런스 리턴
    }
    
private:
	Particle pool_[100];
};

// 예제
ParticlePool pool;

pool.create()->init(1, 2);
pool.create()->init(1, 2, 0.3);
pool.create()->init(1, 2, 3.3, 4.4);
  • 외부 코드에서 객체 생성이 실패할 때 처리 필요
    craete가 null을 반환할 수 있으므로 검사가 필요하다.
Particle* particle = pool.create();
if (particle != NULL) {
	particle->init(1, 2);
}

0개의 댓글