본문 바로가기

TIL

World Grid 렌더링 최적화: 두 가지 접근 방식

기존 방식: 모든 데이터를 하나의 벡터에 삽입하여 렌더링

과거에는 World Grid, AABB, Cone, OBB 등 렌더링에 필요한 모든 정점 데이터를 미리 계산한 후,
하나의 벡터(예: worldVector)에 모두 저장하고,
D3D11_PRIMITIVE_TOPOLOGY_LINELIST 상태에서 단일 드로우콜(context->Draw)로 한 번에 렌더링하는 방법을 사용했다.

이 방식의 장점은 데이터가 미리 준비되어 있어 렌더링 시 바로 사용할 수 있다는 점이다.
그러나 AABB나 Cone이 회전하거나 위치 변경이 발생할 때마다, 전체 버퍼를 다시 업데이트해야 한다.
동적 변환이 자주 발생하면 매 프레임마다 CPU가 매번 정점 데이터를 재계산하면서 불필요한 연산 비용이 발생하게 되고, 이를 최적화할 수 있는 여지가 있다.

인스턴싱 방식: DrawInstanced 활용

두 번째 방식은 context->DrawInstanced 함수를 사용하여 공통된 라인 데이터(예를 들어, 라인 객체의 경우 버텍스 2개)와 인스턴스 데이터를 분리해 전달하는 방식이다.

예를 들어, 각 인스턴스는 자신이 어떤 프리미티브에 해당하는지와 변환 정보(위치, 회전 등)를 GPU에서 계산할 수 있도록 Constant Buffer로 따로 보내고,
공통된 정점 데이터는 한 번만 준비하여 재사용한다.

UINT vertexCountPerInstance = 2; 
UINT instanceCount = gridParam.numGridLines + 3 + (boundingBoxCount * 12) + (coneCount * (2 * coneSegmentCount)) + (12 * obbCount); 
Graphics->DeviceContext->DrawInstanced(vertexCountPerInstance, instanceCount, 0, 0);
 

이 방식의 장점은 다음과 같다.

  • 드로우콜 오버헤드 감소:
    모든 프리미티브를 하나의 드로우콜로 처리할 수 있어, CPU와 GPU 간 호출 비용이 줄어든다.
  • 데이터 업데이트 최소화:
    공통 정점 데이터는 한 번만 준비하고, 각 인스턴스별 변환 정보만 별도로 업데이트하므로,
    동적 변환(예: 회전, 위치 변경)이 발생할 때 전체 버퍼를 다시 갱신할 필요가 없다.
  • 유지보수 및 확장성 향상:
    프리미티브별로 필요한 변환 정보만 따로 관리되므로, 코드의 복잡성이 낮아지고
    다양한 프리미티브가 추가되어도 관리하기 쉽다.

HLSL 코드 예시

아래는 World Grid의 위치를 계산하는 HLSL 코드 예시이다.
이 코드는 각 인스턴스의 vertexID와 instanceID를 활용하여 그리드의 시작점과 끝점을 계산한다.

float3 ComputeGridPosition(uint instanceID, uint vertexID)
{
    int halfCount = GridCount / 2;
    float centerOffset = halfCount * 0.5; // grid 중심이 원점에 오도록

    float3 startPos;
    float3 endPos;
    
    if (instanceID < halfCount)
    {
        // 수직선: X 좌표 변화, Y는 -centerOffset ~ +centerOffset
        float x = GridOrigin.x + (instanceID - centerOffset) * GridSpacing;
        if (abs(x - GridOrigin.x) < 0.001)
        {
            // 두 점 모두 화면 밖 위치로 설정
            startPos = float3(0, 0, 0);
            endPos = float3(0, (GridOrigin.y - centerOffset * GridSpacing), 0);
        }
        else
        {
            startPos = float3(x, GridOrigin.y - centerOffset * GridSpacing, GridOrigin.z);
            endPos = float3(x, GridOrigin.y + centerOffset * GridSpacing, GridOrigin.z);
        }
    }
    else
    {
        // 수평선: Y 좌표 변화, X는 -centerOffset ~ +centerOffset
        int idx = instanceID - halfCount;
        float y = GridOrigin.y + (idx - centerOffset) * GridSpacing;
        if (abs(y - GridOrigin.y) < 0.001)
        {
            // 두 점 모두 화면 밖 위치로 설정
            startPos = float3(0, 0, 0);
            endPos = float3(-(GridOrigin.x + centerOffset * GridSpacing), 0, 0);
        }
        else
        {
            startPos = float3(GridOrigin.x - centerOffset * GridSpacing, y, GridOrigin.z);
            endPos = float3(GridOrigin.x + centerOffset * GridSpacing, y, GridOrigin.z);
        }

    }
    return (vertexID == 0) ? startPos : endPos;
}

 

결론

첫 번째 방식은 모든 프리미티브의 정점 데이터를 하나의 벡터에 미리 계산해 넣어 단일 드로우콜로 렌더링하지만,
동적 변환(회전, 위치 변경)이 발생할 때마다 전체 버퍼를 업데이트해야 하는 단점이 있다.

반면, DrawInstanced 방식을 사용하면 공통 정점 데이터는 한 번만 준비하고,
프리미티브별 변환 정보만 인스턴스 데이터로 관리하여 업데이트할 수 있으므로
드로우콜 오버헤드와 CPU 부담이 줄어들고, 유지보수와 확장성 측면에서 유리하다.

'TIL' 카테고리의 다른 글

UE Actor 라이프사이클  (0) 2025.03.21
매크로와 RTTI  (0) 2025.03.20
std::vector erase, std::remove 관련  (0) 2025.03.18
FName  (0) 2025.03.18
Today I Learned  (0) 2025.02.24