하위 클래스 샌드박스 (Subclass Sandbox)

상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의한다.

 

 

동기

다양한 초능력을 구현한다고 하자. 그러면 상위 클래스를 만들고 상속받은 클래스들을 정의하는것이 일반적이며 구현을 마치고 나면 수십개가 넘는 초능력이 만들어지게 될 것이다.

그런데 이런식으로 구현하게 되면 중복되는 코드들이 많아지고 게임 내의 거의 모든 코드가 초능력 클래스와 커플링이 생기게 될 가능성이 높다.

 

구조를 조금 바꿔서 초능력 클래스를 구현하는 동료 프로그래머들과 같이 사용할 원시명령 집합을 만들어서 공통적으로 사용하면 중복 코드를 많이 줄일 수 있다. 원시명령 집합들은 코드중복을 최소화하여 하위 클래스들을 구현하기 위해 상위 클래스에 정의한 일종의 도구이기 때문에 가상함수일 필요도 없고 public으로 공개할 필요도 없다. 그래서 일반적으로 protected 비가상함수로 만들게 된다.

그리고 이 원시명령 집합들을 이용해 행동들을 구현할 메서드가 필요할텐데, 이 메서드는 protected 순수 가상 함수로 하위 클래스에서 재정의할 수 있도록 한다.

 

상위 클래스가 제공하는 기능을 최대한 고수준 형태로 만들어서 코드 중복을 최대한 회피하고 추후 하위 클래스에서 중복 코드가 발생하면 해당 코드를 언제든지 상위 클래스로 옮겨서 재사용 할 수 있게 하면 된다.

 

커플링 문제는 상위 클래스 한곳에 몰아넣었기 때문에 하위 클래스는 상위 클래스하고만 커플링될뿐이고 다른 코드와는 커플링 되지 않는다.

 

나중에 게임 시스템이 변경되면 상위 클래스를 수정하는것은 불가피하겠지만 하위 클래스들은 수정할 필요가 없다.

 

 

패턴

상위 클래스는 추상 샌드박스 메서드와 여러 제공 기능을 정의한다. 제공 기능은 protected로 만들어서 하위 클래스에게만 제공하는 것이라는 걸 분명하게 명시한다. 각 하위 클래스들은 제공받은 기능들을 이용해서 샌드박스 메서드를 구현한다.

 

 

언제 쓸 것인가?

패턴이라고 하기도 뭣할정도로 매우 단순하고 일반적이라서 게임이 아니더라도 많이 사용된다.

클래스에 protected 비가상함수가 있다면 샌드박스 메서드 패턴을 사용하고 있을 가능성이 높다.

 

◾ 클래스 하나에 하위 클래스가 많이 있다.

◾ 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.

◾ 하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶다.

◾ 하위 클래스들 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.

 

위와 같은 경우에 샌드박스 패턴을 사용하면 좋다.

 

 

주의사항

하위 클래스는 상위 클래스를 통해서 게임 코드에 접근하기 때문에 상위 클래스가 하위 클래스들에서 접근해야 하는 모든 시스템과 커플링되고 하위 클래스 역시 상위 클래스와 매우 밀접하게 묶이게 된다.

이런 관계에서는 상위 클래스를 조금만 수정해도 어딘가 깨지기가 쉬워진다. 꺠지기 쉬운 상위 클래스 문제에 빠지게 되는 것이다.

 

그래도 마냥 단점만 있는것은 아닌게 커플링 대부분을 상위 클래스에 떠넘겼기 때문에 하위 클래스들은 다른 코드들과 깔끔하게 디커플링 될 수 있다는 점이다. 동작의 대부분은 하위 클래스에 존재하기 때문에 유지보수가 쉽다.

 

그렇다 하더라도 상위 클래스가 점점 비대해진다면 제공하는 기능의 일부를 별도의 클래스로 분리하여 책임을 나눠갖게 하는 것도 좋다. 이 때 컴포넌트 패턴이 도움이 된다.

 

 

예제

class Superpower {
public:
    virtual ~Superpower() {}
    
protected:
    virtual void activate() = 0;
    void move(double x, double y, double z) { ... }
    void playSound(SoundId sound, double volume) { ... }
    void spawnParticles(ParticleType type, int count) { ... }
};

매우 간단하고 이해하기 쉽다.

샌드박스 메서드는 순수 가상 함수로 만들었기 때문에 어디에 작업을 해야 할 지 분명하게 알 수 있다.

 

class SkyLaunch : public Superpower {
protected:
    virtual void activate() {
        playSound(SOUND_SPROING, 1.0f);
        spawnParticles(PARTICLE_DUST, 10);
        move(0, 0, 20);
    }
};

하위 클래스는 위와 같이 구현하면 된다. 제공받은 비가상함수로 순수 가상 함수를 구현한다. 끝이다.

샌드박스 메서드를 입맛에 맞게 구현하면 된다.

 

 

디자인 결정

어떤 기능을 제공해야 하는지를 따져봐야 한다. 

잠시 극단적인 사례로 따져보자. 기능을 적게 제공하는 방향의 끝에는 상위 클래스에서 제공하는 기능은 하나도 없이 샌드박스 메서드 하나만 있고 기능을 많이 제공하는 방향의 끝에는 하위 클래스가 필요로 하는 모든 기능을 상위 클래스에서 제공한다.

전자는 하위 클래스 샌드박스 패턴이라고 부를수 있는지조차 의문이 드는 상황이고 후자는 상위 클래스의 커플링이 매우 강력해진다.

여기서 절충안을 찾아야 하는데 일반적인 원칙은 아래와 같다.

 

◾ 상위 클래스가 제공하는 기능을 몇 안되는 하위 클래스에서만 사용하면 별 이득이 없다. 상위 클래스의 복잡도가 증가하는 것에 비해 혜택을 받는 하위 클래스가 적기 때문이다.

◾ 외부 시스템의 상태를 변경하는 함수는 상위 클래스의 제공 기능으로 옮겨주는 것이 좋다.

◾ 제공 기능이 단순히 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 굳이 기능을 제공할 필요가 없다. 이런 경우라면 그냥 하위 클래스에서 외부 메서드를 호출하는게 더 나을수도 있다.

 

 

하위 클래스 샌드박스 패턴의 매우 큰 단점중 하나는 상위 클래스의 메서드 수가 무수히 많이 증가한다는 점이다.

이 단점은 일부 기능을 보조 클래스에 분산시켜서 해당 객체들을 반환하는 방식으로 우회하면 된다. 그러면 상위 클래스의 메서드 개수를 줄일 수 있고 다른 시스템과의 커플링도 낮출 수 있다.

유지보수가 더 쉬워지는 부수효과도 있다.

 

 

상위 클래스가 필요한 객체를 얻는 방법도 생각해볼 여지가 있다.

예를 들어 파티클을 생성시켜주는 객체가 있다. 이 객체를 이용해서 파티클과 관련된 기능을 구현하고 하위 클래스들에게는 메서드만 제공해주면 되기 때문에 하위 클래스들에 대해서는 캡슐화가 될 필요가 있다.

파티클 객체를 얻는 가장 쉬운 방법은 상위 클래스의 생성자 인수로 받는것이다. 문제는 이 객체를 하위 클래스의 생성자에서 받아서 상위 클래스의 생성자에 전달해주어야 하기 때문에 객체가 노출되어 캡슐화의 의미가 없어진다.

파티클 뿐만 아니라 다른 객체들도 받아야 한다면 하위 클래스 생성자에도 계속 추가해 주어야 하기 때문에 유지보수에도 좋지 않다.

 

Superpower* power = new SkyLaunch();
power->init(particles);

대신 초기화 과정을 2단계로 나누면 생성자에게 모든걸 전달하는 번거로움을 피할 수 있다.

다만 init 호출을 까먹지 말아야 하는 문제가 있다. 이 부분은 객체 생성 과정 전체를 하나의 함수로 캡슐화하면 해결할 수 있다.

덧붙여서 생성자를 private으로 만들고 friend 클래스를 잘 활용하면 하나의 인터페이스로만 객체 생성이 가능하도록 강제할 수 있으므로 초기화 단계를 빼먹을 일이 사라진다.

 

class Superpower {
public:
    static void init(ParticleSystem* particles) {
        particles_ = particles;
    }
    // ...
    
private:
    static ParticleSystem* particles_;
};

혹은 아예 정적 객체로 만들어서 사용하는 방법도 있다. 물론 간단한 만큼 발생할 수 있는 사이드 이펙트들을 유의해야한다.

 

class Superpower {
protected:
    void spawnParticles(ParticleType type, int count) {
        ParticleSystem& particles = Locator::getParticles(); // 서비스 중개자를 통해 접근
        particles.spawn(type, count);
    }
    // ...
};

다른 방법으로는 서비스 중개자 패턴의 도움을 받는 것이다. 2단계 초기화든 정적 객체든 공통점은 해당 객체를 소유한다는 점인데, 필요한 객체를 소유하지 않고 서비스 중개자를 통해 원하는 객체를 직접 가져와서 사용하면 된다.

업데이트 메서드 (Update Method)

컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션한다.

 

 

동기

while (true) {
    for (double x = 0; x < 100; ++x) skeleton.setX(x);
    for (double x = 100; x > 0; --x) skeleton.setX(x);
}

어떤 몬스터를 좌우로 움직이는 코드를 간단하게 작성하면 위와 같다. 문제는 무한루프이기 때문에 시각적으로 볼 수 없다.

 

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// 게임 메인 루프
while (true) {
    if (patrollingLeft) {
        --x;
        if (x == 0) patrollingLeft = false;
    }
    else {
        ++x;
        if (x == 100) patrollingLeft = true;
    }
    skeleton.setX(x);
    // 유저 입력 처리, 렌더링 ...
}

게임 루프 구조로 바꾸면 무한루프에 빠지지 않는다.

그런데 다른 몬스터나 오브젝트가 추가되는 경우 유지보수가 점점 어려워진다.

 

해결책은 의외로 간단하다. 모든 객체들이 자신의 동작을 캡슐화 하면 된다.

이를 위해 추상 메서드인 update를 정의하여 추상 계층을 하나 더해서 객체 컬렉션을 관리한다.

게임 루프는 매 프레임마다 객체 컬렉션을 순회하며 update를 호출하면 끝이다.

 

 

패턴

게임 월드는 객체 컬렉션을 관리하고 각 객체는 1프레임 단위의 동작을 하기 위한 업데이트 메서드를 구현한다.

그리고 매 프레임마다 컬렉션에 들어있는 모든 객체를 업데이트한다.

 

 

언제 쓸 것인가?

게임 루프 패턴 다음으로 매우 중요한 패턴이다.

단순한 애니메이션조차 없는 정적인 게임(보드게임 등)이 아닌 이상에야 움직이는 개체가 많은 게임에서는 업데이트 메서드 패턴이 어떻게든 사용된다.

 

◾ 동시에 동작해야 하는 객체나 시스템이 게임에 많다.

◾ 각 객체의 동작은 다른 객체와 거의 독립적이다.

◾ 객체는 시간의 흐름에 따라 시뮬레이션 되어야 한다.

 

위와 같은 경우라면 업데이트 메서드를 사용할 수 있다.

 

 

주의사항

구현 자체가 단순해서 딱히 조심할건 없지만 알아둬야 할 것들은 있다.

 

매 업데이트마다 기존의 상태를 기반으로 행동하기 때문에 현재 상태를 저장해야 하기 때문에 상태 패턴과 사용하면 좋을 수 있다.

 

또한 객체들은 매 프레임마다 업데이트 되지만 동시에 이루어지지 않는다. 내부적으로는 컬렉션에 저장된 순서대로 업데이트가 이루어진다.

순차적으로 업데이트되면 로직 작업이 편하지만 병렬로 업데이트되면 꼬일 가능성이 생긴다.

 

그리고 업데이트를 실행하는 도중에 컬렉션을 수정하는 것은 조심해야한다.

객체가 새로 생성되는 경우라면 목록 뒤에 추가하면 그만이지만 삭제하는 경우는 큰 문제가 발생할 여지가 있다.

 

삭제로 인해 건너뛰어졌다

객체를 삭제해야 하는 경우라면 인덱스를 업데이트 하거나 해당 객체에 죽었다는 표시를 남겨서 순회도중 해당 객체를 만나면 업데이트를 건너뛰고 순회가 끝나면 다시 순회를 돌며 해당 객체들을 제거하는 방법을 사용하는 것이 좋다.

 

 

예제

class Entity {
// 세부내용 생략
public:
    virtual void update() = 0;
}


class World {
public:
    World() : numEntities_(0) {}
    void gameLoop() {
        // ...
        while (true) {
            for (int i = 0; i < numEntities_; ++i)
                entities_[i]->update();
        }
        // ...
    }

private:
    Entity* entities_[MAX_ENTITIES];
    int numEntities_;
};

업데이트가 필요한 클래스들은 Entity를 상속받아서 각자의 행동을 구현하면 된다.

 

그런데 위의 방식은 고정 시간 간격을 쓰는 경우의 구현이다.

앞서 게임 루프에서 언급한것처럼 가변 시간 간격을 사용하는 게임도 있기 때문에 매개변수로 가변 시간 간격을 받아서 처리하면 어렵지 않게 대응할 수 있다.

 

추상 클래스를 이용한 업데이트 메서드 패턴은 상속의 깊이가 한 단계 증가하는 문제가 있지만 상황에 맞게 사용하면 된다.

물론 차후에 다룰 컴포넌트 패턴을 사용하는 것이 더 좋다.

 

 

디자인 결정

중요한건 update 메서드를 어느 클래스에 두느냐이다.

 

◾ 추상 클래스 상속

예제처럼 추상 클래스를 상속하는 것이 가장 간단하지만 요즘은 지양하는 방법이다. 차후 코드 재사용시 단일 상속으로 인한 문제가 발생하면 해결할 방법이 없다.

 

◾ 컴포넌트 클래스

컴포넌트는 알아서 자기 자신을 업데이트 하기 때문에 고민할 필요가 없다.

 

◾ 위임 클래스

상태 패턴처럼 일부 동작을 다른 객체에 위임하고 포워딩만 해주어서 유연성을 얻는다.

상태가 업데이트 되어야 하는 클래스에는 여전히 update가 존재하지만 오버라이딩 한것이 아니라 일반 멤버 함수이다.

 

 

또한 휴면 상태에 들어간 객체의 처리도 생각해 보아야 한다.

여러 이유로 일시적으로 업데이트를 할 필요가 없는 객체들까지 순회하면서 업데이트를 호출하는 것은 자원의 낭비이다.

이를 해결하는 방법으로는 크게 두가지가 있다.

 

단일 컬렉션으로 모두 관리하는 경우에는 플래그 검사를 하더라도 전체 컬렉션을 순회하기 때문에 시간이 낭비되고, 활성 객체만 관리하는 컬렉션을 추가하여 관리하면 시간의 낭비는 없지만 메모리를 추가로 소모해야 할 뿐더러 두 컬렉션의 동기화를 항상 유지해야 한다.

게임 루프 (Game Loop)

게임 시간 진행을 유저 입력, 프로세서 속도와 디커플링한다.

 

 

동기

게임 루프 패턴은 이름에서부터 알 수 있듯이 게임이 아닌 분야에서는 그다지 쓰이지 않는다. 사실상 게임을 위해 존재하는 패턴이다. 구현 내용이 다를 수 있지만 거의 모든 게임에서 사용한다.

 

while (true) {
    char* command = readCommand();
    handleCommand(command);
}

대화형 프로그램을 만들다 보면 자연스럽게 위와 같은 형태가 만들어지게 된다.

그런데 게임은 다른 프로그램과는 달리 유저의 입력이 없어도 계속 돌아간다. 루프에서 유저의 입력을 처리하지만 유저의 입력을 마냥 기다리고 있지 않다는 점에서 차이가 있다. 이것이 게임 루프의 첫 번째 핵심이다.

 

while (true) {
    processInput();
    update();
    render();
}

게임 루프의 기본적인 코드는 위와 크게 달라지지 않는다.

 

 

루프가 유저의 입력을 기다리지 않고 계속 돌아간다면 루프가 도는데 시간이 얼마나 걸리는지를 따져봐야 한다.

게임 입장에서는 루프를 도는것이 시간이 흐르는 것과 동일하다. 물론 그동안 현실세계의 시간도 흘러간다.

이를 측정한것이 초당 프레임 수(FPS)이다.

게임 루프가 빠르게 돌면 FPS가 높아져서 부드럽고 빠른 게임 화면을 볼 수 있지만 루프가 느리면 끊기는 화면을 보게된다.

 

별다른 제한을 두지 않으면 루프는 무조건 빠르게 돈다. 그런데 플랫폼마다 성능에 차이가 있기 때문에 루프의 실행 횟수가 일정하지 않은 문제가 발생한다. 플랫폼에 따라 게임의 경험이 달라지는 것이다.

 

어떤 하드웨어에서도 일정한 속도로 실행될 수 있도록 하는 것이 게임 루프의 두 번째 핵심이다.

 

 

패턴

게임 루프는 게임 내내 실행된다. 루프마다 멈춤 없이 유저 입력 처리, 게임 상태 업데이트, 게임 화면 렌더링을 수행하고 시간의 흐름에 따라 게임플레이 속도를 조절한다.

 

 

언제 쓸 것인가?

게임 루프 패턴은 다른 패턴과 다르게 "언제 쓸 것인가" 를 고민할 필요 없이 "무조건" 사용된다.

설령 내가 작성하지 않더라도 어딘가에는 게임 루프가 반드시 존재한다.

 

 

주의사항

게임 루프는 전체 코드 중에서도 가장 핵심이다. 그래서 최적화를 매우 깐깐하게 해야한다.

또한 플랫폼에 따라 해당 플랫폼의 이벤트 루프를 게임 루프로 사용해야 하는 경우도 있다.

 

 

예제

1) 최대한 빨리 달리기

while (true) {
    processInput();
    update();
    render();
}

처음 봤던 코드이다. 이 코드는 실행 속도를 제어할 수 없기 때문에 실행 환경에 따라 실행 속도가 다르다.

 

2) 한숨 돌리기

while (true) {
    double start = getCurrentTime();
    processInput();
    update();
    render();
    
    sleep(start + MS_PER_FRAME - getCurrentTime());
}

한 프레임이 빨리 끝나도 sleep에 의해 일정 시간 대기하게 되므로 실행 속도에 상한선이 생기지만 그보다 느려지는것은 막지 못한다.

 

3) 한 번은 짧게, 한 번은 길게

게임 루프의 문제는 결국 두가지이다.

 

1. 업데이트할 때마다 정해진 만큼 게임 시간이 진행되고,

2. 업데이트하는 데에는 현실 세계의 시간이 어느 정도 걸린다.

 

여기서 2번이 1번보다 오래 걸리면 게임이 느려지게 된다.

게임의 시간을 16ms 진행시키는데 현실 시간의 16ms보다 오래 걸리면 따라갈수가 없다.

그러면 한 번에 게임 시간을 16ms 이상 진행시킨다면 적어도 업데이트 횟수는 따라잡을 수 있다.

 

한 번에 게임 시간을 더 많이 진행시키려면 프레임을 고정적으로 16ms씩 처리하는것이 아니라 프레임 이후 실제 경과시간에 따라 간격을 가변적으로 잡으면 된다.

 

double lastTime = getCurrentTime();
while (true) {
    double current = getCurrentTime();
    double elapsed = current - lastTime;
    
    processInput();
    update(elapsed);
    render();
    
    lastTime = current;
}

매 프레임마다 직전 루프 이후 실제 경과 시간을 계산하고 그 값을 update에 넘겨주면 해당 시간만큼 게임 월드의 상태를 진행시킨다.

 

문제는 가변 시간 간격에 따른 연산 오차이다.

가변 시간 간격을 사용하면 어떤 오브젝트가 이동할 때, 업데이트 횟수에 상관없이 실제 시간동안 같은 거리를 이동하게 된다.

그런데 게임에서는 보통 부동 소수점을 쓰기 때문에 반올림 오차가 발생하기가 매우 쉽고 업데이트 횟수가 많을수록 오차가 더 크게 누적된다.

 

실행 환경에 관계 없이 실행 속도를 동일하게 제어하려고 적용한 가변 시간 간격이 연산 오차로 인해 서로 다른 결과를 발생시킬 수 있는 새로운 문제에 봉착하게 된다.

물리 엔진은 보통 실제 물리 법칙의 근사치를 취하게 되는데, 근사치가 튀는걸 막기 위해 감쇠를 적용하게 된다. 이 감쇠는 시간의 간격에 맞춰서 세심하게 조정해야 하지만 가변 시간 간격을 적용한다면 감쇠 값이 계속 바뀌어서 물리가 불안정해지게 되는 결과를 낳는다.

간단히 말해서 불안정성이 생긴다는 것이다.

 

4) 따라잡기

가변 시간 간격에 영향을 받지 않는 부분 중 하나는 렌더링이다.

모션블러같은 경우를 제외하고는 렌더링은 가변 시간을 고려하지 않고 고려할 필요도 없다.

그래서 물리나 AI 등은 고정 시간 간격을 적용하고 렌더링 같은것은 가변 시간을 적용하여 조금 더 유연하게 만든다.

 

double previous = getCurrentTime();
double lag = 0.0;

while (true) {
    double current = getCurrentTime();
    double elapsed = current - previous;
    previous = current;
    lag += elapsed;
    processInput();
    
    while (lag >= MS_PER_UPDATE) { // 시각적 프레임 레이트가 아님
        update();
        lag -= MS_PER_UPDATE;
    }
    render();
}

주의할 점은 고정 시간 간격을 너무 짧게 잡지 않는것이다. 적어도 update를 실행하는 데 걸리는 시간보다는 간격이 커야한다.

 

5) 중간에 끼는 경우

그런데 만약 렌더링이 두 업데이트 사이에 끼게 된다면 움직임이 튀어보일 수 있다.

다만 이제는 가변 시간 간격을 알기때문에 렌더링 함수의 인수로 (가변 시간 간격 / 업데이트 시간 간격) 을 넘겨주어 값을 보간하여 렌더링한다.

보간의 결과가 틀릴수도 있지만 별로 눈에 띄지도 않고 보간을 하지 않아서 움직임이 튀는 것보다 훨씬 낫다.

 

 

디자인 결정

대부분의 경우 게임 루프를 만들일은 거의 없다.

프레임워크부터 만들어 나간다면 모를까 웹 브라우저는 루프를 따로 만들지 못하도록 막혀있고 엔진을 사용한다면 엔진이 제공하는 게임 루프를 사용할 가능성이 매우 높다.

무엇을 사용하던간에 장단이 존재하지만 게임루프는 어쨌든 반드시 사용한다는 점이 핵심이다.

 

전력 소모에 관해서 잠시 얘기하자면 PC 게임은 성능도 중요하지만 품질 역시 중요하기 때문에 시간이 남으면 FPS나 그래픽 품질을 더 높이는데에 시간을 사용하는 경우가 많아서 전력은 언제나 최대치를 사용하게 된다.

반대로 모바일 게임은 품질보다 성능이 중요하기 때문에 FPS를 제한한다.

 

마지막으로 앞서 언급한 예제들을 정리한다.

 

◾ 동기화 없는 고정 시간 간격 방식

루프를 제어하지 않고 최대한 빠르게 실행한다. 구현이 간단하지만 실행 환경마다 실행 속도가 다르다.

 

◾ 동기화하는 고정 시간 간격 방식

루프의 끝에 지연이나 동기화를 추가하여 너무 빠르게 실행되는 것을 막는다. 전력의 효율이 높지만 게임이 너무 느려질 수도 있다.

 

◾ 가변 시간 간격 방식

환경에 관계 없이 맞춰서 플레이가 가능하다. 하지만 오차로 인해 게임을 불안정하고 비결정적으로 만들어버린다.

 

◾ 업데이트는 고정 시간 간격, 렌더링은 가변 시간 간격

대체로 가장 좋지만 구현이 복잡하다. 특히 업데이트 시간 간격을 잘 조정해야 한다.

이중 버퍼 (Double Buffer)

 

여러 순차 작업의 결과를 한 번에 보여준다.

 

연산 결과를 순차적으로 출력하지 않고 렌더링같이 한번에 출력해야 하는 경우 사용된다.

코드가 프레임 버퍼에 값을 쓰는 동안에 비디오 디스플레이가 프레임 버퍼의 값을 읽어버리면 이미지의 일부만 출력되는 테어링이 발생하게 된다.

 

프레임 버퍼를 두개 준비하여 입력과 출력을 서로 번갈아가며 반복하면 테어링이 발생하지 않게된다.

 

 

패턴

버퍼 클래스가 현재 버퍼와 다음 버퍼, 총 2개의 버퍼를 가진다.

버퍼를 읽을때는 항상 현재 버퍼를 읽고 버퍼에 쓸때는 항상 다음 버퍼에 쓴다. 이를 계속 반복한다.

 

 

언제 쓸 것인가?

  • 순차적으로 변경해야 하는 상태가 있다.
  • 이 상태는 변경 도중에도 접근 가능해야 한다.
  • 외부 코드에서는 작업 중인 상태에 접근할 수 없어야 한다.
  • 상태에 값을 쓰는 도중에도 기다리지 않고 바로 접근할 수 있어야 한다.

 

 

주의사항

버퍼의 교체 연산에는 짧더라도 시간이 걸리기때문에 교체 연산은 반드시 원자적이어야 한다.

대부분은 포인터만 교환하기 때문에 빠르지만 혹시라도 버퍼에 값을 쓰는 것보다 교환이 더 오래걸리면 이중 버퍼는 아무런 도움이 안된다.

 

또한 버퍼를 두개 사용하는 만큼 메모리를 더 사용하므로 메모리가 부족한 기기에서는 사용이 부담스러울 수 있다.

 

 

예제

class Framebuffer {
public:
    Framebuffer() { clear(); }
    void clear() {
        for (int i = 0; i < WIDTH * HEIGHT; ++i) {
            pixels_[i] = WHITE;
        }
    }
    void draw(int x, int y) {
        pixels_[(WIDTH * y) + x] = BLACK;
    }
    const char* getPixels() { return pixels_; }
    
private:
    static const int WIDTH = 160;
    static const int HEIGHT = 120;
    
    char pixels_[WIDTH * HEIGHT];
};


class Scene {
public:
    Scene() : current_(&buffers_[0]), next_(&buffers_[1]) {}
    void draw() {
        next_->clear();
        next_->draw(1, 1);
        // ...
        next_->draw(4, 3);
        swap();
    }
    Framebuffer& getBuffer() { return *current_; }
    
private:
    void swap() {
        // 버퍼 포인터 교체
        Framebuffer* temp = current_;
        current_ = next_;
        next_ = temp;
    }
    
    Framebuffer buffers_[2];
    Framebuffer* current_;
    Framebuffer* next_;
};

 

그래픽스 외에 물리나 인공지능같이 객체가 서로 상호작용할 때 이중 버퍼를 사용하면 도움이 될 수 있다.

어떤 상태를 변경하는 코드가 일괄적으로 변경하려는 상태를 읽을 때 말이다.

 

 

디자인 결정

버퍼 교체 연산은 원자적으로 이루어져야 한다. 그에 따라서 버퍼 교체 연산 작업중에는 읽거나 쓰는 작업이 불가능해진다.

 

 

버퍼에 남아있는 데이터는 직전 프레임이 아닌 2프레임 직전의 데이터라는것을 이해해야 한다.

일반적으로는 버퍼를 정리하기 때문에 문제가 되지 않지만 모션블러같이 버퍼의 기존 데이터를 섞어서 사용하는 경우가 존재한다.

 

또한 버퍼가 하나의 큰 덩어리(그래픽스)인지, 혹은 객체의 컬렉션 안에 분산(인공지능)되어 있는지에 따라 버퍼링의 성능이 달라진다.

전자의 경우 서로 바꾸기만 하면 되기 때문에 속도가 매우 빠르다. 하지만 후자의 경우 전체 컬렉션을 순회하며 알려주어야 하기 때문에 훨씬 더 느리다. 다만 단순한 경우라면 순회하며 교환하는 대신, 정적 함수의 플래그 변수를 통해 버퍼의 인덱스만 수정하면 최적화가 가능하다.

 

class Actor {
public:
    static void init() { current_ = 0; }
    static void swap() { current_ = next(); }
    
    void slap() { slapped_[next()] = true; }
    bool wasSlapped() { return slapped_[current_]; }
    
private:
    static int current_;
    static int next() { return 1 - current_; }
    
    bool slapped_[2];
};

 

이런 식으로 말이다.

std::string 객체들을 담는 컨테이너에 삽입 함수를 이용해서 새 요소들을 추가한다고 하면 당연히 논리적으로 삽입 함수에 넘겨주는 인수의 타입은 std::string이어야 할 것이다.

그렇기는 한데 항상 참은 아닐 수도 있다.

 

std::vector<std::string> vs;
vs.push_back("xyzzy"); // 문자열 리터럴

문자열 리터럴은 std::string 객체가 아니라서 컨테이너의 타입과 다르다. 그런데 에러가 발생하지 않고 동작하는 이유가 무엇일까?

 

template<class T, class Allocator = allocator<T>>
class vector {
public:
    ...
    void push_back(const T& x);
    void push_back(T&& x);
    ...
};

push_back은 왼값과 오른값에 대해 오버로딩 되어있다.

문자열 리터럴이 인수로 전달되면 std::string != const char[] 이기 때문에 타입 불일치를 해소하기 위해 컴파일러는 문자열 리터럴로부터 임시 std::string 객체를 생성하고 push_back에 전달하는 코드를 산출한다.

 

vs.push_back("xyzzy");
vs.push_back(std::string("xyzzy"));

위의 코드를 마치 아래 코드처럼 취급하게 된다.

문제없이 작동하지만 성능을 고려한다면 그다지 좋지는 않다. 왜냐면 std::string의 생성자가 두번 호출되기 때문이다.

 

문자열 리터럴이 인수로 전달될 때, 임시 std::string 객체가 생성되면서 생성자가 호출된다.

임시 객체가 push_back의 오른값 매개변수 버전으로 전달되고 x의 참조에 묶이게 되는데, 이 때 이동 생성자에 의해 x의 복사본이 생성되며 생성자가 호출된다.

이후 push_back의 호출이 끝나는 즉시 임시 객체도 사라지며 소멸자가 호출된다.

총 2번의 생성과 한번의 소멸이 이루어진다.

 

만약 문자열 리터럴을 통해 임시 객체를 생성하지 않고 직접 전달할 수 있으면 임시 객체의 생성과 파괴 비용이 발생하지 않을 것이다.

 

 

이를 위해서는 push_back이 아닌 emplace_back을 호출하면 된다.

emplace_back은 전달된 인수를 이용해서 직접 객체를 생성하고 그 과정에 임시 객체가 관여하지 않게 된다.

완벽 전달을 이용하기 때문에 완벽 전달이 실패하는 경우들을 제외하고는 항상 효율적으로 동작한다.

 

push_back, push_front를 지원하는 표준 컨테이너는 emplace_back, emplace_front 역시 같이 지원한다.

그 외 삽입 함수들 역시 대응되는 생성 삽입 함수인 emplace를 제공한다.

 

생성 삽입 함수들은 삽입 함수들이 하는 모든 일을 할 수 있으며 이론적으로 적어도 삽입 함수들보다 덜 효율적으로 수행하는 경우는 없다.

 

 

항상 그런것은 아니지만 그렇지 않은 경우들을 특정짓는것은 쉽지 않고 대신 생성 삽입이 바람직할 가능성이 큰 상황들을 식별할 수 있는 발견법은 있다.

 

◾ 추가할 값이 컨테이너에 대입되는 것이 아니라 컨테이너 안에서 생성된다.

◾ 추가할 인수 타입들이 컨테이너가 담는 타입과 다르다.

◾ 컨테이너가 기존 값과의 중복 때문에 새 값을 거부할 우려가 별로 없다.

 

위의 세 가지 조건을 모두 만족하는 경우 거의 항상 생성 삽입이 삽입보다 성능이 좋다.

 

 

추가로 생성 삽입 함수를 사용할 때 고려하면 좋은 사항들도 알아보자.

 

자원 관리

자원 관리와 관련된 경우 emplace의 호출은 오히려 독이될수도 있다.

 

std::shared_ptr를 생성할 때, 커스텀 삭제자를 지정하는 경우에는 make_shared를 통해서 생성할 수 없다. 이 때는 std::shared_ptr의 생성자를 직접 호출하게 되는데, 이를 push_back을 통해 삽입하게 되면 임시객체로 인수를 전달하는것이 보통의 경우일 것이다.

임시 객체의 생성과 삭제 비용을 피하기 위해 emplace를 사용하는 것인데, 이런 경우에는 임시 객체의 생성 비용이 매우 가치가 있다.

 

만약 임시 std::shared_ptr 객체를 담을 복사본을 할당하는 도중 메모리 부족 예외가 발생한다면 예외가 push_back 밖으로 전파되며 임시 std::shared_ptr 객체가 파괴된다. 그와 함께 임시 std::shared_ptr 객체를 만들 때 동적 할당된 자원 역시 임시 std::shared_ptr 객체의 소멸자가 호출되며 같이 해제된다.

 

그러나 emplace_back을 호출하면 동적할당으로 만들어진 원시 포인터가 완벽 전달되고 할당이 실패하여 메모리 부족 예외가 발생하면 예외가 emplace_back 바깥으로 전파되며 원시 포인터가 사라지게 되어 메모리 누수가 발생한다.

 

 

자원 관리 객체들을 담은 컨테이너의 삽입 함수를 호출하는 경우, 일반적으로 삽입 함수의 매개변수 타입들은 자원의 획득과 그 자원을 관리하는 객체의 생성 사이에 아무것도 끼어들지 않음을 보장한다.

그에 반해 생성 삽입 함수는 완벽 전달에 의해 컨테이너의 메모리 안에서 생성할 수 있는 시점까지 지연된다.

그 사이에 예외가 발생하면 위와 같은 문제가 발생하는 것이다.

 

std::shared_ptr<Widget> spw(new Widget, killWidget);

ptrs.push_back(std::move(spw)); // 삽입
ptrs.emplace_back(std::move(spw)); // 생성 삽입

이번 예제에 한해서는 new 연산자를 통해 생성된 임시 객체를 직접 push_back이나 emplace_back에 전달하기보다 별도로 생성하여 오른값으로 전달해 주는것이 더 바람직하다.

 

explicit 생성자들과의 상호작용

C++11에 추가된 정규표현식(regex)은 nullptr을 받을 수 없다.

 

std::regex r = nullptr;
regexes.push_back(nullptr);

이것들은 컴파일이 안되는데,

 

std::vector<std::regex> regexes;

regexes.emplace_back(nulltpr);

이 코드는 컴파일이 된다.

 

std::regex는 암시적 변환에 의한 복사 연산이 일어나면 비용이 커질 우려가 있기 때문에 생성자가 explicit로 선언되어있다.

explicit 생성자는 복사 초기화를 사용할 수 없지만 직접 초기화는 사용할 수 있는 특징이 있다.

대입이나 push_back은 암시적 변환이 일어나며 복사 초기화를 시도하여 컴파일 에러가 발생하지만, emplace_back은 std::regex 객체로 변환될 어떤 객체가 아니라 std::regex의 생성자에 전달될 인수에 불과하기 때문에 컴파일이 가능한 것이다.

 

근데 컴파일만 가능할 뿐이지 미정의 동작이 일어날것은 뻔하다.

좀 더 일반화하자면 생성 삽입 함수는 직접 초기화를 사용하여 explicit 생성자를 지원하고 삽입 함수는 복사 생성자를 사용하여 explicit 생성자를 지원하지 않는다.

 

◾ 이론적으로, 생성 삽입 함수들은 종종 해당 삽입 버전보다 더 효율적이어야 하며, 덜 효율적인 경우는 절대로 없어야 한다.

◾ 실질적으로, 만일 (1)추가하는 값이 컨테이너로 대입되는 것이 아니라 생성되고, (2)인수 타입들이 컨테이너가 담는 타입과 다르고, (3)그 값이 중복된 값이어도 컨테이너가 거부하지 않는다면, 생성 삽입 함수가 삽입 함수보다 빠를 가능성이 아주 크다.

◾ 생성 삽입 함수는 삽입 함수라면 거부당했을 타입 변환들을 수행할 수 있다.

+ Recent posts