[게임 프로그래밍 패턴] Chapter18 더티 플래그

Jangmanbo·2023년 12월 12일
0

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


게임에서 월드에 들어있는 모든 객체는 장면 그래프로 저장되며 모든 객체에는 변환값(transform)이 들어있다.
이때 장면 그래프는 거의 계층형이다. 따라서 어떤 객체에 붙어있는 하위 객체의 변환값은 절대 위치가 상대 위치로 저장된다.

그러나 하위 객체를 그리기 위해서는 객체의 지역 변환값(local transform)이 아니라 월드 변환값(world transform)을 알아야 한다.

지역 변환과 월드 변환

배 > 망대 > 해적 > 앵무새
이러한 계층 구조라면

앵무새 월드 변환 = 배 지역 변환(=배 월드 변환) * 망대 지역 변환 * 해적 지역 변환 * 앵무새 지역 변환

그러나 이렇게 모든 객체를 매 프레임마다 월드 변환 계산하면 성능에 크게 영향을 준다.
전혀 움직이지 않는데 매번 월드변환을 재계산하는 것은 낭비다.

월드 변환 값 캐싱

변환값을 캐시하자 -> 모든 객체의 지역 변환값과 파생 월드 변환 값을 저장

객체가 전혀 움직이지 않음 > 캐시에놓은 변환값 사용
객체가 움직임 > 월드 변환 값 업데이트. 그러나 상위 객체가 움직이면 하위 객체들의 월드 변환값도 재귀적으로 전부 재계산 필요

만약 배, 망대, 해적, 앵무새가 모두 움직이고 있다면 앵무새 위치 계산을 위해서는
총 10번의 계산이 필요하며 이 중 6번의 계산 결과는 버려진다. (월드 변환 계산은 4번이나 했다.)

재계산 미루기

지역 변환값과 월드 변환값 업데이트를 분리하자.
필요한 지역 변환값부터 한 번에 전부 변경한 뒤에 렌더링 직전에 월드 변환 값을 한 번만 재계산하면 된다.

  1. 장면 그래프의 객체에 플래그를 추가하여 지역 변환 값이 바뀌면 플래그를 켠다.
  2. 플래그가 켜져있으면 월드 변환을 계산하고 플래그를 끈다.

여기서 사용하는 플래그가 더티 플래그이다.

더티 플래그 사용 시 장점

  • 월드 변환값이 더이상 맞지 않음을 의미
  • 상위 노드를 따라가면서 여러 번 지역 변환을 곱하던 것을 객체당 한 번의 재계산으로 합침
  • 움직이지 않는 객체는 변환 계산을 하지 않음
  • 렌더링 전에 제거될 객체에 대해 월드 변환 계산을 하지 않아도 됨


더티 플래그 패턴

기본값: 계속해서 변경됨
파생값: 기본값에 비싼 작업을 거쳐야 얻을 수 있음

더티 플래그

  • 파생값이 참조하는 기본값의 변경 여부를 추적
  • 따라서 기본값이 변경되면 켜짐
  • 파생값이 필요할 때 플래그가 켜져있으면 다시 계산하고 플래그를 끔
  • 플래그가 꺼져 있으면 캐시했던 파생값 그대로 사용

언제 쓸 것인가?

더티 플래그 패턴은 계산(기본값으로부터 파생값을 얻는게 오래 걸림)이나 동기화(파생값이 다를 기기에 있어 가져오는 비용이 큼)에 사용한다.

주의사항

너무 오래 지연하려면 비용이 든다

앞선 예제에서의 월드 좌표 계산은 한 프레임 안에서도 금방 할 수 있어 문제가 없다.
그러나 오랜 시간이 걸리는 작업은 지연해놨다가 결과를 보고 싶어할 때가 되어서야 처리를 시작한다면 결과를 보는 것이 늦어질 것이다.

뭔가 잘못됐을 때 작업이 전부 날아갈 수 있다는 단점도 있다.
예를 들어 텍스트 편집기에서 기본값은 메모리에 열려있는 문서, 파생값은 디스크에 있는 파일이다.
그래서 텍스트 편집기는 갑자기 꺼지는 것에 대응하기 위해 자동 저장 기능을 제공한다.

상태가 변할 때마다 플래그를 켜야 한다

캐시 무효화

  • 원본 데이터가 변경될 때 캐시값이 더이상 맞지 않음을 제때 알려주는 작업
  • 여기서는 기본값이 바뀌었을 때 더티 플래그를 켜주는 일

혹시라도 놓쳐 무효화된 파생값을 사용하지 않도록 기본값을 변경하는 코드를 인터페이스로 캡슐화하는 방법도 있다.

이전 파생값을 메모리에 저장해둬야 한다.

더티 플래그 패턴은 속도를 위해 메모리를 희생한다.
따라서 메모리보다 시간이 남는다면 굳이 더티 플래그 패턴을 사용하지 않는 것이 좋다.

더티 플래그 패턴 예제

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

origin: 아무런 이동, 회전 크기 변화가 없는 단위행렬로 나타낸 기본 변환
combine: 상위 노드를 따라서 지역 변환값을 전부 결합해 객체의 월드 변환값을 만들어 반환

더티 플래그 패턴 적용 X

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()); // 장면 그래프를 그리기 위해 루트 노드부터 시작

parentWorld: 상위 노드의 월드 변환값
world: 노드의 월드 변환값

모든 노드에 대해서 local_.combine(parentWorld)을 호출하고 있어 비효율적이다.

더티 플래그 패턴 적용 O

class GraphNode {
public:
    GraphNode(Mesh* mesh) : mesh_(mesh), local(Transform::origin()), dirty_(true) {}
    void setTransform(Transform local) {
        local_ = local;
        dirty_ = true;	// 더티 플래그 On
    }
    // ...
    
private:
    Transform world_;	// 이전에 계산한 월드 변환값
    bool dirty_;	// 더티 플래그 (초기값은 true)
    // ...
};

자신의 더티 플래그가 켜질 때 하위 노드들의 더티 플래그 값을 켜지 않는다. (켜려면 재귀 호출을 해야한다..)
렌더링할 때 켜주면 된다.

void GraphNode::render(Transform parentWorld, bool dirty) {
    dirty |= dirty_; // 상위 노드 중 어느 한 노드라도 켜져있으면 On
    
    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);
}

노드의 지역 변환값을 몇 번의 대입만으로 바꿀 수 있고,
렌더링할 때에만 변경된 노드에 대해 최소한으로 월드 변환 계산을 하도록 만들었다.

0개의 댓글