더티 플래그 (Dirty Flag)

불필요한 작업을 피하기 위해 실제로 필요할 때까지 그 일을 미룬다.

쉽게 말해서 지연 평가(Lazy Evaluation)의 일종이다.

 

 

동기

월드에 존재하는 오브젝트들을 렌더링 하려면 장면 그래프라는 거대한 자료구조에 저장해야 한다. 렌더링 코드는 이 장면 그래프를 이용해서 어떤 것들을 렌더링 해야하는지를 결정하기 때문이다.

매우 간단하게 만든다면 객체 리스트 하나만 존재하면 되지만 장면 그래프는 거의 계층형으로 이루어져 있기 때문에 적합한 자료구조가 아니다.

 

계층형이라 함은 쉽게 말해서 트랜스폼의 부모 자식 관계이다. 부모의 트랜스폼이 변하면 자식의 트랜스폼도 바뀐다.

그리고 렌더링을 하려면 로컬 트랜스폼이 아닌 월드 트랜스폼을 알아야만 렌더링 할 수 있다.

로컬 트랜스폼을 월드 트랜스폼으로 변환하는 것은 어렵지 않다. 문제는 이 연산을 모든 객체에 대해서 매 프레임 수행하면 성능 하락은 피할 수 없다.

특히 지형같은 경우는 전혀 움직이지 않는데 이것까지 매번 월드 트랜스폼으로 변환하는 연산을 수행하는 것은 자원의 낭비다.

 

 

객체가 움직이지 않는 경우를 대비하기 위해 월드 트랜스폼 값을 캐싱하면 최적화가 좀 더 이루어진다.

 

부모 트랜스폼이 변화하면 자식 트랜스폼도 같이 바뀐다

하지만 최상위 부모의 트랜스폼이 변화하면 계층을 따라 내려오면서 재귀적으로 연산이 이루어지는데, 그림으로 보이다시피 중복된 연산이 많이 발생한다. 객체는 고작 4개가 움직였을 뿐이지만 중복된 연산으로 인해 10회의 연산량이 발생한다.

캐싱으로는 이 문제를 해결할 수 없다.

 

대신 로컬 트랜스폼과 월드 트랜스폼 연산을 분리시키고 연산을 뒤로 미룸으로써 연산량을 줄이는 방법을 선택한다.

이를 위해서 장면 그래프에 들어가는 객체에 플래그를 추가한다.

로컬 트랜스폼의 값이 바뀌면 플래그를 켜고 객체의 월드 트랜스폼 값이 필요할 때에는 플래그를 검사한다. 플래그가 켜져있으면 월드 트랜스폼을 계산한 뒤에 플래그를 끈다.

 

이제 와서 얘기하는 것이지만 패턴 이름이 더티 플래그인 이유는 "더 이상 맞지 않음" 을 나타내는 플래그를 더티라고 부르기 때문에 더티 플래그이다.

 

월드 트랜스폼으로 변환하는 연산을 렌더링 할 때로 미뤄서 연산량을 4회로 줄였다.

움직이지 않는 객체는 계산을 하지 않고 렌더링 전에 제거될 객체는 월드 트랜스폼 변환 연산을 하지 않아도 된다.

 

 

패턴

계속해서 변경되는 기본 값이 있다. 파생 값은 기본 값에 비싼 작업을 거쳐야 얻을 수 있다. 더티 플래그는 파생 값이 참조하는 기본 값의 변경 여부를 추적한다. 즉, 더티 플래그는 기본 값이 변경되면 켜진다. 파생 값을 써야 할 떄 더티 플래그가 켜져 있다면 다시 계산한 뒤에 플래그를 끈다. 플래그가 꺼져 있다면 이전에 캐시해놓은 파생 값을 그대로 사용한다.

 

 

언제 쓸 것인가?

마찬가지로 코드가 복잡해지는 것을 감수할 정도로 성능 문제가 심할 때에만 적용해야 한다.

계산과 동기화라는 두 종류의 작업에 사용되고 두 작업 모두 기본 값으로부터 파생 값을 얻는 게 오래 걸리거나 비용이 크다는 문제가 있다.

 

파생 값이 사용되는 횟수보다 기본 값이 더 자주 변경되어야 하고 점진적으로 업데이트 하기가 어려운 경우에 적용하기 좋고 그게 아니라면 별로 도움이 되지 않는다.

 

 

주의사항

계산을 오래 지연시키려면 비용이 든다. GC를 정리하는것도 일종의 지연인데 GC 정리를 너무 빨리하는것도 성능의 하락이 생기지만 너무 늦게 정리하는것도 비용이 크게 발생된다.

그리고 지연 도중에 뭔가 잘못되었을 경우 작업이 전부 날아갈 수도 있다는 문제도 있다.

 

상태가 변할 때마다 플래그를 일일이 켜줘야 하는 번거로움도 있다. 한군데라도 놓치면 무효화된 파생 값을 사용해서 잡기 어려운 버그가 발생할 수도 있다.

 

또한 더티 플래그가 꺼져 있으면 캐싱된 값을 그대로 사용해야 하기 때문에 이전 파생값을 캐싱해둬야 한다.

 

다른 최적화 방법들과 마찬가지로 더티 플래그 패턴 역시 속도를 위해 메모리를 희생한다.

 

 

예제

class Transform {
public:
    static Transform origin();
    Transform combine(Transform& other);
};

combine은 상위 노드를 따라서 로컬 트랜스폼을 전부 결합하여 월드 트랜스폼으로 변환한 값을 반환한다.

origin은 아무 변화가 없는 단위행렬로 나타낸 기본 트랜스폼을 반환한다. 

 

class GraphNode {
public:
    GraphNode(Mesh* mesh) : mesh_(mesh), local(Transform::origin()) {}
    
private:
    Transform local_;
    Mesh* mesh_;
    GraphNode* children_[MAX_CHILDREN];
    int numChildren_;
};

더티 플래그 패턴을 적용하기 전의 클래스 구조이다.

 

GraphNode* graph_ = new GraphNode(nullptr); // 하위 노드를 루트에 추가

void renderMesh(Mesh* mesh, Transform transform); // 메시 렌더링

 

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);
}

/* == */

graph_->render(Tranform::origin()); // 루트 노드부터 순회하며 렌더링

더티 플래그를 사용하지 않고 단순히 장면 그래프를 루트 노드부터 순회하며 렌더링한다.

 

 

class GraphNode {
public:
    GraphNode(Mesh* mesh) : mesh_(mesh), local(Transform::origin()), dirty_(true) {}
    void setTransform(Transform local) {
        local_ = local;
        dirty_ = true;
    }
    // ...
    
private:
    Transform world_;
    bool dirty_;
    // ...
};

로컬 트랜스폼이 변하면 더티 플래그를 켜준다.

 

void GraphNode::render(Transform parentWorld, bool dirty) {
    dirty |= dirty_; // 상위 노드의 플래그 중 하나라도 켜져있으면 켜진다
    
    if (dirty) [
        world_ = local_.combine(parentWorld);
        dirty_ = false;
    }
    
    if (mesh_) renderMesh(mesh_, world_);
    
    for (int i = 0; i < numChildren_; i++)
        children_[i]->render(world_, dirty);
}

재귀구조 대신 더티 플래그를 매개변수로 사용해서 연산 비용을 줄인다.

더티 플래그가 켜진 경우에만 월드 트랜스폼을 계산하고 더티 플래그를 꺼준다.

 

 

디자인 결정

더티 플래그를 끄는 시점도 상황에 따라서 결정할 수 있다.

 

◾ 결과가 필요할 때 : 결과 값이 필요 없다면 아예 계산하지 않을 수 있지만 계산 시간이 오래 걸린다면 프리징이 생길 수 있다.

 

◾ 미리 정해놓은 지점에서 할 때 : 지연 작업 처리가 플레이 경험에 영향을 미치지 않지만, 처리 시점을 제어할 수 없다.

 

◾ 백그라운드로 처리할 때 : 타이머 핸들로 정해진 시간마다 변경사항을 처리한다. 작업의 처리 빈도를 조절할 수 있지만 필요 없는 작업을 더 많이 할 수 있고 비동기 작업을 지원해야 한다.

+ Recent posts