최적화 패턴 : 더티 플래그

더러운 깃발

불필요한 작업을 피하려면 정말 필요할 때까지 미루십시오.

간단히 말해서 일종의 게으른 리뷰입니다.

동기 부여

세계에서 개체를 렌더링하려면 장면 그래프라는 거대한 데이터 구조에 개체를 저장해야 합니다.

이는 렌더링 코드가 이 장면 그래프를 사용하여 무엇을 렌더링할지 결정하기 때문입니다.

매우 간단하게 만들면 객체 목록만 있으면 되지만 장면 그래프는 거의 계층적이므로 적합한 데이터 구조가 아닙니다.

계층적이란 간단히 말해서 변환의 부모-자식 관계입니다.

상위 요소의 변형이 변경되면 하위 요소의 변형도 변경됩니다.

그리고 렌더링하려면 로컬 변환이 아닌 월드 변환을 알아야 합니다.

로컬 변환을 월드 변환으로 변환하는 것은 어렵지 않습니다.

문제는 모든 프레임에서 모든 오브젝트에 대해 이 작업을 수행하면 성능 저하가 불가피하다는 점입니다.

특히 전혀 움직이지 않는 지형의 경우 매번 세계변동으로 바꾸는 계산을 하기에는 자원낭비가 된다.

오브젝트가 움직이지 않는 경우 월드 변환 값을 캐싱하여 또 다른 최적화를 달성합니다.


상위 변환이 변경되면 하위 변환도 변경됩니다.

그러나 최상위 부모의 변환이 변경되면 계층 구조를 따라 내려가면서 재귀적으로 연산을 수행하게 되는데, 그림과 같이 중복 연산이 많이 발생한다.

객체는 4개만 이동하지만 중복 작업으로 인해 10개의 작업이 생성됩니다.

캐싱은 이 문제를 해결할 수 없습니다.

대신 로컬 변환과 월드 변환 작업을 분리하고 계산을 연기하여 계산 오버헤드를 줄이는 방법을 선택합니다.

이를 위해 장면 그래프에 들어가는 개체에 플래그를 추가합니다.

로컬 변환 값이 변경되면 플래그를 활성화하고 오브젝트의 월드 변환 값이 필요할 때 플래그를 확인합니다.

플래그가 켜져 있으면 월드 변환을 계산한 후 플래그를 끕니다.

그러고 보니 패턴 이름이 Dirty Flag인 이유는 무엇입니까? “이젠 어울리지 않아” 플래그 표시 더러운더러운 깃발입니다.


세계 변환으로 변환하는 계산이 렌더링 시점으로 옮겨져 계산 수고가 4배로 줄었습니다.

움직이지 않는 오브젝트는 계산할 필요가 없으며 렌더링 전에 제거된 오브젝트는 월드 변환 변환 계산이 필요하지 않습니다.

무늬

지속적으로 변경되는 기본값이 있습니다.

파생 값은 기본 값에 대한 비용이 많이 드는 작업으로 얻을 수 있습니다.

더티 플래그는 파생된 값이 참조하는 기본 값이 변경되었는지 여부를 추적합니다.

즉, 기본값이 변경되면 더티 플래그가 켜집니다.

파생 값을 써야 할 때 더티 플래그가 켜져 있으면 다시 계산한 후 끄십시오. 플래그가 꺼져 있으면 이전에 캐시된 파생 값을 그대로 사용합니다.

언제 사용

마찬가지로 성능 문제가 코드의 복잡성을 보증할 만큼 충분히 심각한 경우에만 적용해야 합니다.

계산과 동기화의 두 가지 연산에 사용되며 둘 다 기본 값에서 파생 값을 얻는 것이 지루하거나 비용이 많이 드는 문제가 있습니다.

이는 파생된 값보다 기준 값을 더 자주 변경해야 하고 점진적으로 업데이트하기 어려운 경우에 유용하며, 그렇지 않으면 별로 도움이 되지 않습니다.

주의

계산이 오래 지연되면 비용이 발생합니다.

GC scavenging도 지연의 일종으로, GC scavenging을 너무 일찍 하면 성능이 저하되고 너무 늦게 하면 비용이 많이 든다.

또한 지연 중에 문제가 발생하면 모든 작업을 잃게 되는 문제가 있습니다.

상태가 변경되면 플래그를 개별적으로 켜야 하는 번거로움도 있습니다.

하나가 누락되면 유효하지 않은 파생 값으로 인해 잡기 어려운 오류가 발생할 수 있습니다.

더티 플래그가 꺼져 있어도 캐시된 값은 그대로 사용해야 합니다.

이전 파생을 캐시해야 합니다.

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

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

결합은 상위 노드를 따라 모든 로컬 변환을 결합하고 월드 변환으로 변환된 값을 반환합니다.

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

재귀 구조 대신 더티 플래그를 매개변수로 사용하여 계산 오버헤드를 줄입니다.

더티 플래그가 설정된 경우에만 월드 변환이 계산되고 더티 플래그가 꺼집니다.

디자인 결정

더티 플래그를 끄는 타이밍도 상황에 따라 결정할 수 있습니다.

결과가 필요한 경우 : 결과 값이 필요하지 않은 경우 전혀 계산되지 않을 수 있지만 계산 시간이 오래 걸리면 정지될 수 있습니다.

정해진 시점에서 할 때 : 작업 처리 지연은 게임 경험에 영향을 미치지 않지만 처리되는 시기를 제어할 수 없습니다.

백그라운드에서 처리할 때 : 타이머 핸들을 사용하여 설정된 간격으로 변경 사항을 처리합니다.

작업의 처리 빈도를 조정할 수 있지만 필요하지 않은 작업을 더 많이 수행할 수 있으며 비동기 작업을 지원해야 합니다.