← 개념 · 데모 가이드 | 구현 허브 | 7장. 실전 렌더링 기법 | LOD · 컬링
IMPLEMENTATION · CHAPTER 7

LOD · 컬링 — 한 줄 한 줄 직접 구현

대규모 씬을 실시간으로 그리는 비결은 딱 두 문장으로 요약됩니다. “안 보이는 건 아예 그리지 않는다(컬링)”, 그리고 “멀리 있어 작게 보이는 건 대충 그린다(LOD)”입니다. 이 페이지에서는 뷰-투영 행렬 하나에서 절두체(프러스텀) 6개 평면을 뽑아내는 Gribb–Hartmann 기법, AABB(축 정렬 바운딩 박스)를 평면과 비교해 시야 밖 물체를 버리는 컬링, 카메라 거리로 메시 해상도를 고르는 거리 기반 LOD, 그리고 같은 메시 수천 개를 드로우콜 한 번으로 그리는 인스턴싱까지, C++와 DirectX 11 코드를 성분 하나, 부호 하나까지 풀어서 설명합니다. 코드 로직은 원본 그대로 두되, 그 밑에 깔린 수학·기하 직관을 최대한 자세히 파헤칩니다.

규약 이 시리즈는 행벡터 규약을 씁니다. 즉 벡터를 행렬 왼쪽에 두고 v' = v * M으로 변환하며, HLSL에서는 mul(v, M) 순서입니다. 행렬은 행 우선(row-major)으로 저장하고, 좌표계는 왼손, 클립 공간 z 범위는 [0, 1](D3D 관례), 텍스처 V축은 위→아래입니다. DirectXMath도 행벡터 관례라 뷰-투영 행렬은 view * proj 순서로 곱합니다. 실무에서는 검증된 라이브러리(DirectXMath, DirectXCollision의 BoundingFrustum 등)를 쓰는 것을 권장하며, 여기 코드는 원리를 눈으로 확인하기 위한 학습용입니다.

이 페이지에서 만들 것 (큰 그림)

  1. 프러스텀 평면 추출 — 뷰-투영 행렬의 행들을 더하고 빼서 좌·우·하·상·근·원 6평면을 얻습니다.
  2. AABB vs 프러스텀 테스트 — 물체를 감싼 박스가 6평면 중 하나라도 완전히 바깥이면 컬링합니다.
  3. 거리 기반 LOD 선택 — 카메라와의 거리(제곱)로 고/중/저 폴리곤 메시 중 하나를 고릅니다.
  4. 인스턴싱 — 같은 메시 수천 개를 두 번째 정점 버퍼 + DrawIndexedInstanced로 한 방에 그립니다.

이 네 가지는 서로 물려 돌아갑니다. 먼저 컬링으로 보이는 것만 걸러내고, 그 물체마다 LOD로 얼마나 정밀하게 그릴지 정하고, 같은 것이 많으면 인스턴싱으로 드로우콜을 뭉칩니다. 하나만 써서는 반쪽짜리이고, 세 개를 함께 써야 프레임 레이트가 실제로 살아납니다.

1. 프러스텀 평면 6개 추출

무엇을 만드나: 카메라의 시야 절두체(near·far·좌·우·상·하로 둘러싸인 잘린 피라미드)를 이루는 6개 평면을, 뷰-투영 행렬 하나에서 계산으로 뽑아냅니다. 왜 필요한가: 물체가 “화면에 보이는가”를 판정하려면 시야 경계를 수식으로 들고 있어야 하는데, 그 경계가 바로 이 6평면입니다. 절두체의 꼭짓점 8개를 일일이 구할 필요 없이, 행렬의 행 4개를 조합하는 것만으로 평면 방정식이 튀어나옵니다.

1-1. 평면을 어떻게 표현하나 — ax + by + cz + d = 0

한 평면은 법선 n = (a, b, c)와 스칼라 d로 표현합니다. 평면 위의 점 p = (x, y, z)a·x + b·y + c·z + d = 0을 만족합니다. 이 좌변에 임의의 점을 넣으면 값이 0이면 평면 위, 양수면 법선이 가리키는 쪽, 음수면 반대쪽이라는 부호 있는 거리가 됩니다. 우리는 6평면의 법선이 모두 절두체 안쪽을 향하도록 맞출 것이므로, “좌변 값이 양수 = 안쪽”이 판정 규칙이 됩니다.

수학적으로 n이 단위벡터(길이 1)일 때만 이 좌변 값이 실제 유클리드 거리와 같아집니다. 그래서 아래 코드 마지막에 각 평면을 정규화하는 단계가 반드시 들어갑니다. 정규화를 안 하면 “안/밖” 판정 자체는 맞지만(부호는 보존되므로), 거리 값의 스케일이 평면마다 제각각이라 “박스가 평면에서 얼마나 떨어졌나” 같은 수치를 쓸 수 없게 됩니다.

frustum.hppcpp
#include <DirectXMath.h>
#include <array>
using namespace DirectX;

struct Plane {
    XMFLOAT3 n;   // 정규화된 법선 (프러스텀 안쪽을 향함)
    float    d;   // 부호 있는 원점 거리
    // 점 p의 부호 있는 거리: 양수면 평면 안쪽
    float distance(const XMFLOAT3& p) const {
        return n.x * p.x + n.y * p.y + n.z * p.z + d;
    }
};

// 뷰-투영 행렬 m = view * projection 에서 6평면 추출 (Gribb–Hartmann 방식)
// DirectXMath는 행벡터 관례라 곱 순서가 GL(proj*view)과 반대이며,
// XMFLOAT4X4는 행우선 저장이므로 m(row, col)로 행을 그대로 꺼낼 수 있다.
std::array<Plane, 6> extractFrustumPlanes(const XMMATRIX& vp) {
    XMFLOAT4X4 m;
    XMStoreFloat4x4(&m, vp);
    std::array<Plane, 6> planes;
    // 행벡터를 r0..r3로 꺼내둔다 (행우선이라 m.m[row][col]).
    XMFLOAT4 r0(m._11, m._12, m._13, m._14);
    XMFLOAT4 r1(m._21, m._22, m._23, m._24);
    XMFLOAT4 r2(m._31, m._32, m._33, m._34);
    XMFLOAT4 r3(m._41, m._42, m._43, m._44);

    auto add = [](XMFLOAT4 a, XMFLOAT4 b, float s) {
        return XMFLOAT4(a.x + s*b.x, a.y + s*b.y, a.z + s*b.z, a.w + s*b.w);
    };
    XMFLOAT4 raw[6] = {
        add(r3, r0,  1.0f),   // 좌
        add(r3, r0, -1.0f),   // 우
        add(r3, r1,  1.0f),   // 하
        add(r3, r1, -1.0f),   // 상
        r2,                   // 근 (near)  — D3D clip z 범위 [0,1]
        add(r3, r2, -1.0f)    // 원 (far)
    };
    for (int i = 0; i < 6; ++i) {
        float len = std::sqrt(raw[i].x*raw[i].x + raw[i].y*raw[i].y + raw[i].z*raw[i].z);
        planes[i].n = XMFLOAT3(raw[i].x/len, raw[i].y/len, raw[i].z/len);
        planes[i].d = raw[i].w / len;        // 정규화: 거리 스케일을 맞춤
    }
    return planes;
}

1-2. struct Planedistance() 뜯어보기

struct Plane은 법선 XMFLOAT3 n과 스칼라 float d 두 필드로 평면 하나를 담습니다. XMFLOAT3는 DirectXMath가 메모리에 저장할 때 쓰는 평범한 float x, y, z; 구조체입니다. 계산 전용 타입인 XMVECTOR(SIMD 레지스터에 올라가는 16바이트 정렬 타입)와 대비되는데, 구조체 필드처럼 저장할 때는 XMFLOAT3, 연산할 때는 XMLoadFloat3XMVECTOR에 올리는 것이 DirectXMath의 표준 작업 흐름입니다.

float distance(const XMFLOAT3& p) const의 시그니처를 한 조각씩 봅시다.

기하 직관(그림글): 법선 n이 “안쪽을 가리키는 화살표”라고 생각하세요. 점 p를 그 화살표 방향으로 얼마나 나아간 곳에 있는지 재는 것이 내적 n·p이고, 거기에 평면을 원점에서 밀어낸 오프셋 d를 더하면 “평면을 기준으로 p가 안쪽으로 몇 미터 들어와 있나(음수면 몇 미터 밖으로 나갔나)”가 됩니다. n이 단위벡터라서 이 값이 곧 수직 거리가 됩니다.

1-3. 행렬을 저장하고 행을 꺼내기 — 행벡터·행우선의 핵심

extractFrustumPlanes(const XMMATRIX& vp)는 뷰-투영 행렬을 받습니다. XMMATRIX는 SIMD 레지스터 4개로 이루어진 계산용 행렬 타입이라, 개별 성분에 직접 접근하기 불편합니다. 그래서 첫 줄에서 XMStoreFloat4x4(&m, vp)저장용 XMFLOAT4X4 m에 내려받습니다. 이제 m._11, m._12처럼 _행열 표기로 각 성분을 읽을 수 있습니다.

XMFLOAT4X4행 우선으로 저장됩니다. 즉 메모리에서 _11 _12 _13 _14가 먼저 나오고(0번 행), 그 다음 _21 _22 _23 _24(1번 행)가 이어집니다. 그래서 아래 네 줄은 각각 행렬의 한 행 전체XMFLOAT4로 뽑는 작업입니다.

r0 = ( m._11, m._12, m._13, m._14 )   ← 0번 행
r1 = ( m._21, m._22, m._23, m._24 )   ← 1번 행
r2 = ( m._31, m._32, m._33, m._34 )   ← 2번 행
r3 = ( m._41, m._42, m._43, m._44 )   ← 3번 행 (w 성분들이 모인 "동차" 행)

왜 하필 “행”을 뽑을까요? Gribb–Hartmann 유도의 핵심이 여기 있습니다. 행벡터 규약에서 어떤 점 p = (x, y, z, 1)을 클립 공간으로 보내는 계산은 clip = p * VP입니다. 이때 클립 좌표의 각 성분은 clip.x = p · (VP의 0번 열), clip.w = p · (VP의 3번 열) …식으로 나옵니다. 그런데 XMFLOAT4X4가 행 우선이고 우리가 r0..r3로 뽑은 것은 “행”이므로, 실제로 유도에서 필요한 “열”과 맞추려면 성분 인덱스를 잘 대응시켜야 합니다. 다행히 이 구현은 그 대응까지 반영해 “r3 ± r0” 같은 조합으로 정리되어 있으니, 우리는 결과 공식을 그대로 쓰면 됩니다. 중요한 것은 DirectXMath(행벡터·행우선)에서는 이 조합이 맞다는 사실이고, GL 계열(열벡터·proj*view)에서 베낀 공식을 그대로 쓰면 부호나 축이 틀어진다는 점입니다.

함정 다른 자료의 프러스텀 추출 코드를 그대로 붙여 넣었는데 “근평면에서 뭔가 이상하게 잘린다”면 십중팔구 클립 z 범위 불일치입니다. OpenGL은 클립 z가 [-1, 1]이라 근평면을 r3 + r2로 뽑지만, D3D는 [0, 1]이라 근평면이 그냥 r2입니다(위 코드의 다섯 번째 항목). 이 한 줄 차이가 near 컬링을 통째로 망가뜨리니 API에 맞는 공식을 써야 합니다.

1-4. add 람다와 6평면 조합

auto add = [](XMFLOAT4 a, XMFLOAT4 b, float s) { ... };람다(익명 함수)입니다. []는 캡처 목록으로, 바깥 변수를 전혀 끌어오지 않는다는 뜻입니다(순수 계산이라 필요 없음). 이 람다는 성분별로 a + s·b를 계산해 돌려줍니다. 즉 s = +1이면 a + b, s = -1이면 a - b가 되어, “행을 더하거나 빼는” 한 가지 도구로 좌·우·하·상·원 평면을 모두 만들 수 있습니다.

이제 raw[6] 배열이 6평면을 만들어 냅니다. 각 항이 어떤 경계선을 뜻하는지 봅시다.

인덱스조합평면직관
0r3 + r0좌(Left)클립 x ≥ -w 경계
1r3 - r0우(Right)클립 x ≤ +w 경계
2r3 + r1하(Bottom)클립 y ≥ -w 경계
3r3 - r1상(Top)클립 y ≤ +w 경계
4r2근(Near)클립 z ≥ 0 경계 (D3D)
5r3 - r2원(Far)클립 z ≤ +w 경계

왜 이런 조합이 평면이 되나: 클립 공간에서 “화면 안”의 조건은 -w ≤ x ≤ w, -w ≤ y ≤ w, 0 ≤ z ≤ w입니다. 예를 들어 좌 경계 x ≥ -wx + w ≥ 0으로 옮겨 적을 수 있습니다. 그런데 clip.x = p·(0열), clip.w = p·(3열)이므로 x + w = p·(0열 + 3열)입니다. 즉 “0열 + 3열”이라는 벡터가 바로 월드 공간에서의 좌평면 법선+d가 됩니다. 위 코드가 행 인덱스로 정리해 r3 + r0으로 쓴 것이 그것입니다. 부등호 ≥ 0이 “안쪽이 양수”라는 우리의 규칙과 정확히 맞아떨어집니다.

1-5. 정규화 — 왜 마지막에 나눠 주나

마지막 for 루프가 6평면 각각을 정규화합니다. len은 법선 부분 (x, y, z)의 길이 √(x² + y² + z²)입니다. w 성분(d)은 길이 계산에서 빼는 점에 주목하세요. 평면의 “방향”은 오직 법선이 정하고, d는 그 방향을 따라 얼마나 밀렸는지의 오프셋일 뿐이라, 법선의 길이로 x, y, z, w모두 나눠 줘야 스케일이 일관됩니다.

len = √(a² + b² + c²)          // 법선 길이 (d 제외)
n   = (a/len, b/len, c/len)    // 단위 법선
d   = w / len                  // 같은 스케일로 오프셋도 나눔

정규화 후에는 distance(p)진짜 수직 거리(미터 단위)를 돌려줍니다. 안/밖만 볼 거면 정규화를 건너뛰어도 부호는 맞지만, 뒤에서 “박스가 평면에서 얼마나 밖으로 나갔나”를 수치로 쓰거나 크로스페이드 임계에 거리를 쓰려면 정규화가 필수입니다.

성능 프러스텀 추출은 카메라가 움직인 프레임에 딱 한 번만 하면 됩니다(6평면 = 24개 float). 이걸 물체마다 다시 계산하는 실수를 하지 마세요. 한 번 뽑아 두고 그 프레임의 모든 컬링 테스트에서 재사용합니다. 실무에서는 DirectXCollision의 BoundingFrustum::CreateFromMatrix가 같은 일을 해 주며, SIMD로 최적화되어 있습니다.
DirectXMath 대응 이 절의 수작업을 라이브러리로 바꾸면 이렇게 됩니다.
· XMStoreFloat4x4XMMATRIX를 저장용 XMFLOAT4X4로 내림(위와 동일).
· 전체 흐름 → BoundingFrustum bf; BoundingFrustum::CreateFromMatrix(bf, projMatrix); 로 투영 행렬에서 절두체를 만들고, 뷰 역행렬로 bf.Transform(...)해 월드로 옮긴 뒤 bf.Contains(box)로 판정합니다. 우리가 손으로 뽑은 6평면은 그 BoundingFrustum이 내부에 들고 있는 것과 같은 정보입니다.

2. AABB vs 프러스텀 테스트

무엇을 만드나: 물체를 감싼 축 정렬 바운딩 박스(AABB)가 절두체 안에 있는지(또는 걸치는지) 판정하는 함수입니다. 왜 필요한가: 삼각형 하나하나를 절두체와 비교하면 너무 비싸니, 물체를 단순한 박스로 근사해 “이 박스가 아예 시야 밖이면 통째로 스킵”하는 것이 컬링의 실전 형태입니다.

2-1. positive vertex — 박스에서 “가장 안쪽에 가까운 꼭짓점”

박스는 꼭짓점이 8개입니다. 8개를 전부 평면과 비교할 수도 있지만, 사실 평면 하나에 대해서는 단 한 꼭짓점만 보면 충분합니다. 그 한 꼭짓점을 positive vertex(p-vertex)라 부르는데, “평면 법선 방향으로 박스에서 가장 멀리(=가장 안쪽으로) 나아간 꼭짓점”입니다.

기하 직관(그림글): 평면의 법선이 오른쪽·위·앞을 향한다고 합시다. 그러면 박스에서 그 방향으로 가장 튀어나온 모서리는 (max.x, max.y, max.z)입니다. 만약 이 “가장 유리한” 꼭짓점조차 평면 바깥에 있다면, 나머지 7개는 더 바깥이므로 박스 전체가 바깥이라고 확신할 수 있습니다. 반대로 법선의 어떤 축 성분이 음수라면, 그 축에서는 min 값이 더 “안쪽”으로 가는 꼭짓점이 됩니다. 그래서 축마다 법선 부호를 보고 maxmin이냐를 고릅니다.

frustum_cull.cppcpp
struct AABB { XMFLOAT3 min, max; };

// 프러스텀 안(또는 걸침)에 있으면 true → 그려야 함
bool aabbInFrustum(const std::array<Plane, 6>& planes, const AABB& box) {
    for (const Plane& pl : planes) {
        // 법선 방향으로 가장 "안쪽에 가까운" 꼭짓점 = positive vertex
        XMFLOAT3 pv;
        pv.x = (pl.n.x >= 0.0f) ? box.max.x : box.min.x;
        pv.y = (pl.n.y >= 0.0f) ? box.max.y : box.min.y;
        pv.z = (pl.n.z >= 0.0f) ? box.max.z : box.min.z;

        // 가장 유리한 꼭짓점조차 평면 바깥이면 박스 전체가 바깥 → 컬링
        if (pl.distance(pv) < 0.0f)
            return false;
    }
    return true;   // 어떤 평면에도 완전히 밀려나지 않음 → 보임
}

2-2. 코드 한 줄씩 매핑

2-3. 손계산으로 확인해 보기

좌평면 하나가 정규화 후 n = (1, 0, 0), d = 5라고 합시다. 즉 평면 방정식은 x + 5 = 0 → 평면은 x = -5에 있고, 법선이 +x(안쪽)를 향합니다. 이제 박스가 min = (-9, 0, 0), max = (-7, 2, 2)라고 하면 (즉 박스 전체가 x = -7 ~ -9에 있어 평면보다 왼쪽에 있음):

n.x = 1 ≥ 0  → pv.x = max.x = -7
n.y = 0 ≥ 0  → pv.y = max.y =  2
n.z = 0 ≥ 0  → pv.z = max.z =  2
pv = (-7, 2, 2)

distance(pv) = n·pv + d
             = (1)(-7) + (0)(2) + (0)(2) + 5
             = -7 + 5 = -2   ( < 0 )  → return false → 컬링!

가장 안쪽 꼭짓점(x = -7)마저 평면(x = -5) 바깥에 2만큼 나가 있으니 박스 전체가 시야 왼쪽 밖이라는 결론이 맞습니다. 만약 박스를 오른쪽으로 밀어 max.x = -3이 되면 distance = -3 + 5 = +2 ≥ 0이 되어 이 평면은 통과하고, 다음 평면 검사로 넘어갑니다.

함정 이 테스트는 보수적(conservative)입니다. 즉 “확실히 밖인 것”만 걸러내고, 절두체 모서리 근처의 큰 박스는 실제로 안 보여도 true가 나올 수 있습니다(false positive). 이는 안전한 방향의 오차입니다 — 보이는 걸 실수로 안 그리는 것(false negative, 물체가 깜빡 사라짐)이 훨씬 치명적이니까요. 더 정밀하게 하려면 구(sphere) 1차 컬링 → AABB 2차 컬링처럼 단계를 두거나, 박스가 6평면과 완전히 안쪽인지(negative vertex까지) 검사해 “교차/포함”을 구분합니다.
성능 수천 개 물체를 매 프레임 도는 뜨거운 루프이므로, distance 안의 곱셈·덧셈을 XMVector3Dot + XMPlaneDotCoord로 벡터화하면 이득이 큽니다. 더 나아가 SoA(구조체 배열이 아닌 배열 구조체)로 박스들을 정렬해 SIMD로 4~8개를 한꺼번에 테스트하는 것이 상용 엔진의 방식입니다. 학습 단계에서는 위 코드가 읽기 쉬워 좋습니다.
DirectXMath 대응 라이브러리로 바꾸면 이 함수 전체가 frustum.Contains(BoundingBox{center, extents}) 한 줄이 됩니다. 반환값 ContainmentTypeDISJOINT(완전 밖 → 컬링), INTERSECTS(걸침 → 그림), CONTAINS(완전 안 → 그림) 세 가지라, 우리 코드의 falseDISJOINT, true가 나머지 둘에 대응합니다. plane.distance(p)XMPlaneDotCoord(plane, XMVectorSetW(p, 1))과 같습니다.

3. 거리 기반 LOD 선택

무엇을 만드나: 카메라와 물체 중심 사이의 거리를 재서, 고/중/저 폴리곤 메시 중 하나를 고릅니다. 왜 필요한가: 멀리 있는 물체는 화면에서 몇 픽셀 안 되므로 고폴리곤을 그려 봐야 낭비입니다. 거리에 따라 해상도를 낮추면 시각적 손실 없이 정점 처리량을 크게 아낍니다.

lod_pick.cppcpp
struct Model {
    Mesh lod0, lod1, lod2;   // 고 → 중 → 저 폴리곤
    XMFLOAT3 center;
};

Mesh& pickLOD(Model& model, const XMFLOAT3& camPos) {
    // sqrt를 피하려 거리 제곱으로 비교 (임계값도 제곱해 둠)
    XMVECTOR d = XMVectorSubtract(XMLoadFloat3(&model.center),
                                  XMLoadFloat3(&camPos));
    float d2 = XMVectorGetX(XMVector3LengthSq(d));
    const float near2 = 15.0f * 15.0f;   // 15m 이내: 최고 디테일
    const float mid2  = 50.0f * 50.0f;   // 50m 이내: 중간
    if (d2 < near2) return model.lod0;
    if (d2 < mid2)  return model.lod1;
    return model.lod2;                    // 그 밖: 최저 (또는 임포스터)
}

// 사용: 컬링 통과한 것만 LOD 골라 그린다
void drawModels(std::vector<Model>& models,
                const std::array<Plane,6>& frustum,
                const XMFLOAT3& camPos) {
    for (Model& m : models) {
        AABB box = boundsOf(m);
        if (!aabbInFrustum(frustum, box)) continue;   // 시야 밖 스킵
        draw(pickLOD(m, camPos));
    }
}

3-1. 거리 “제곱”으로 비교하는 이유

pickLOD는 참조 반환 타입 Mesh&를 씁니다. 값으로 돌려주면 무거운 메시를 통째로 복사하니, 세 LOD 중 골라진 것을 복사 없이 그 자리(원본)로 넘기려는 것입니다. 인자 Model& model도 참조라 복사가 없고, const XMFLOAT3& camPos는 “읽기만” 하는 카메라 위치입니다.

핵심 아이디어는 제곱근을 피하는 것입니다. 두 점 사이 실제 거리는 √(dx² + dy² + dz²)인데, sqrt는 상대적으로 비싼 연산입니다. 하지만 우리는 “거리가 15보다 작냐?”만 알면 되지 정확한 거리 값은 필요 없습니다. 거리가 양수인 한 거리 < 15거리² < 15²와 완전히 동치이므로, 양변을 제곱해 비교하면 sqrt를 통째로 없앨 수 있습니다. 그래서 임계값도 미리 15*15, 50*50으로 제곱해 둡니다.

3-2. DirectXMath 성분 해부

손계산 예: 카메라 (0,0,0), 물체 중심 (12, 0, 9)이면 d = (12, 0, 9), d2 = 144 + 0 + 81 = 225. near2 = 225이므로 225 < 225는 거짓 → lod0을 건너뛰고, mid2 = 2500에 대해 225 < 2500 참 → lod1(중간)이 선택됩니다. 실제 거리는 √225 = 15m로 정확히 경계인데, <(미만) 비교라 경계값은 다음 단계로 넘어감을 볼 수 있습니다.

3-3. drawModels — 컬링과 LOD가 물리는 지점

사용 함수는 세 조각을 한 흐름으로 엮습니다.

  1. AABB box = boundsOf(m); — 물체의 바운딩 박스를 얻습니다(월드 공간 기준).
  2. if (!aabbInFrustum(frustum, box)) continue; — 시야 밖이면 즉시 스킵. LOD 선택도, 드로우콜도 아예 하지 않습니다. 컬링이 가장 먼저 오는 이유는 “안 보이는 것에는 LOD 계산조차 아깝다”는 것입니다.
  3. draw(pickLOD(m, camPos)); — 컬링을 통과한 것만 거리로 LOD를 골라 그립니다.
함정 경계 임계값(15m, 50m)에서 두 LOD가 딱 바뀌는 순간, 실루엣이 눈에 띄게 튀는 “LOD 팝핑”이 생깁니다. 완화법은 (1) 임계에 히스테리시스를 넣어 들어갈 때와 나올 때 기준을 다르게 하거나(예: 15m에서 lod1로 내려가고, 13m에서야 lod0로 올라옴 → 경계에서 왔다갔다 깜빡임 방지), (2) 두 LOD를 짧은 구간에서 크로스페이드(알파 블렌드)하거나, (3) 스크린도어 디더링으로 픽셀 단위로 섞는 것입니다.
성능 거리 임계는 “미터”보다 화면 투영 크기(스크린 스페이스 에러)로 잡는 게 더 정확합니다. 같은 15m라도 시야각(FOV)이 좁거나 해상도가 높으면 물체가 화면에서 더 크게 보이기 때문입니다. 상용 엔진은 물체의 바운딩 반경과 카메라 파라미터로 “이 물체가 화면에서 몇 픽셀인가”를 추정해 LOD를 고릅니다. 학습 단계에서는 거리 제곱 비교로 충분합니다.

4. 인스턴싱 — 같은 메시 대량 렌더

무엇을 만드나: 풀·바위·군중처럼 같은 메시를 수천 개 그릴 때, 인스턴스마다 다른 변환 행렬만 바꿔 가며 드로우콜 한 번으로 전부 그립니다. 왜 필요한가: 물체 하나당 드로우콜을 부르면 CPU→GPU 통신 오버헤드가 수천 배로 늘어 CPU가 병목이 됩니다. 인스턴싱은 “메시는 한 벌, 위치만 다르게” 방식으로 이 오버헤드를 없앱니다.

instancing.cppcpp
using namespace DirectX;
using Microsoft::WRL::ComPtr;

// 인스턴스별 model 행렬 배열을 준비한다.
std::vector<XMFLOAT4X4> models = buildInstanceMatrices();  // 예: 5000개
const UINT count = (UINT)models.size();

// ── 인스턴스 행렬용 정점 버퍼 (슬롯 1) ──
D3D11_BUFFER_DESC bd = {};
bd.ByteWidth = count * sizeof(XMFLOAT4X4);
bd.Usage = D3D11_USAGE_DEFAULT;
bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
D3D11_SUBRESOURCE_DATA sd = {};
sd.pSysMem = models.data();
ComPtr<ID3D11Buffer> instanceVB;
device->CreateBuffer(&bd, &sd, &instanceVB);

// ── 입력 레이아웃: 슬롯 0 = 메시(정점당), 슬롯 1 = 행렬(인스턴스당) ──
// float4x4 = float4 4개 → 시맨틱 INSTMAT 0~3, StepRate=1 로 "인스턴스마다" 전진
D3D11_INPUT_ELEMENT_DESC layout[] = {
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,  D3D11_INPUT_PER_VERTEX_DATA,   0 },
    { "NORMAL",   0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA,   0 },
    { "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT,    0, 24, D3D11_INPUT_PER_VERTEX_DATA,   0 },
    { "INSTMAT",  0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0,  D3D11_INPUT_PER_INSTANCE_DATA, 1 },
    { "INSTMAT",  1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 16, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
    { "INSTMAT",  2, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 32, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
    { "INSTMAT",  3, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 48, D3D11_INPUT_PER_INSTANCE_DATA, 1 },
};
ComPtr<ID3D11InputLayout> inputLayout;
device->CreateInputLayout(layout, _countof(layout),
                          vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(),
                          &inputLayout);

// ── 렌더: 두 정점 버퍼를 슬롯 0/1에 바인딩, 드로우콜 한 번으로 count개 ──
ID3D11Buffer* vbs[2] = { meshVB.Get(), instanceVB.Get() };
UINT strides[2] = { (UINT)sizeof(Vertex), (UINT)sizeof(XMFLOAT4X4) };
UINT offsets[2] = { 0, 0 };
context->IASetInputLayout(inputLayout.Get());
context->IASetVertexBuffers(0, 2, vbs, strides, offsets);
context->IASetIndexBuffer(meshIB.Get(), DXGI_FORMAT_R32_UINT, 0);
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
context->DrawIndexedInstanced(indexCount, count, 0, 0, 0);

4-1. 인스턴스 데이터 준비와 버퍼 생성

std::vector<XMFLOAT4X4> models는 인스턴스마다의 월드 변환 행렬을 담은 배열입니다. XMFLOAT4X4(저장용 4×4 행렬)를 쓰는 이유는 GPU 버퍼에 그대로 memcpy할 수 있는 평탄한 64바이트 레이아웃이기 때문입니다. const UINT count는 인스턴스 개수인데, D3D API가 부호 없는 32비트 정수 UINT를 요구하므로 size()를 캐스팅합니다.

D3D11_BUFFER_DESC bd = {};= {}집합 초기화로 구조체 전체를 0으로 채웁니다(안 쓰는 필드가 쓰레기값이 되는 것을 막음). 필드를 봅시다.

4-2. 입력 레이아웃 — 두 슬롯, 두 스텝 방식

입력 레이아웃은 GPU에게 “정점 버퍼 바이트를 어떻게 셰이더 입력으로 쪼갤지” 알려 주는 설명서입니다. 각 D3D11_INPUT_ELEMENT_DESC의 필드는 순서대로 시맨틱 이름, 시맨틱 인덱스, 포맷, 입력 슬롯, 바이트 오프셋, 스텝 종류, 스텝 레이트입니다.

앞의 세 요소(POSITION, NORMAL, TEXCOORD)는 슬롯 0(메시 정점 버퍼)에서 오고, D3D11_INPUT_PER_VERTEX_DATA이며 스텝 레이트가 0입니다. 즉 정점마다 다음 데이터로 전진합니다. 오프셋 0 / 12 / 24Vertex 구조체 안에서 위치(12바이트=float3), 법선(12바이트), UV(8바이트=float2)가 차례로 놓인 바이트 위치입니다.

뒤의 네 요소가 인스턴싱의 핵심입니다. 4×4 행렬은 셰이더 입력으로 한 번에 받을 수 없어 float4 4개로 쪼개, 같은 시맨틱 이름 "INSTMAT"인덱스 0, 1, 2, 3을 붙입니다. 공통점을 봅시다.

CreateInputLayout은 이 배열과 정점 셰이더 바이트코드(vsBlob)를 대조해, 셰이더 입력 시그니처와 레이아웃이 맞는지 검증하며 레이아웃 객체를 만듭니다. _countof(layout)은 배열 원소 개수(여기선 7)를 컴파일 타임에 세는 매크로입니다.

HLSL 쪽 대응 정점 셰이더에서는 이 네 조각을 받아 행렬로 다시 조립해 씁니다. 행벡터 규약이므로 mul(pos, world) 순서입니다.
instanced_vs.hlslhlsl
cbuffer PerFrame : register(b0) {
    row_major float4x4 gViewProj;   // 뷰*투영 (행벡터 규약)
};

struct VSIn {
    float3 pos      : POSITION;
    float3 normal   : NORMAL;
    float2 uv       : TEXCOORD;
    // 인스턴스별 world 행렬: float4 4개로 쪼개 들어옴
    float4 world0   : INSTMAT0;
    float4 world1   : INSTMAT1;
    float4 world2   : INSTMAT2;
    float4 world3   : INSTMAT3;
};

struct VSOut {
    float4 posH : SV_Position;
    float2 uv   : TEXCOORD0;
};

VSOut main(VSIn i) {
    VSOut o;
    // 4개의 행을 다시 4x4 행렬로 조립
    float4x4 world = float4x4(i.world0, i.world1, i.world2, i.world3);
    // 행벡터 규약: v' = v * M  →  mul(v, M)
    float4 worldPos = mul(float4(i.pos, 1.0), world);
    o.posH = mul(worldPos, gViewProj);
    o.uv   = i.uv;
    return o;
}

4-3. HLSL 셰이더 해부

4-4. 렌더 — 두 버퍼 바인딩과 DrawIndexedInstanced

성능 인스턴싱의 이득은 드로우콜(CPU→GPU 오버헤드) 절감이지 GPU 정점 처리량 자체를 줄이진 않습니다. 그래서 프러스텀 컬링·LOD와 함께 써야 진짜로 빨라집니다. 인스턴스 데이터가 매 프레임 바뀌면 D3D11_USAGE_DYNAMIC + Map/Unmap으로 만들고, CPU에서 프러스텀 컬링해 보이는 인스턴스만 버퍼에 채워 넣으세요. 이렇게 하면 “컬링 → 컴팩션 → 인스턴싱”이 한 파이프라인이 되어, 5000개 중 화면에 보이는 800개만 그리면서도 드로우콜은 여전히 1번입니다.
함정 DYNAMIC 버퍼를 매 프레임 Map(D3D11_MAP_WRITE_DISCARD)로 갱신하지 않고 Map(WRITE_NO_OVERWRITE)UpdateSubresource를 잘못 쓰면, GPU가 아직 이전 프레임을 그리는 중인 버퍼를 덮어써 화면이 깜빡이거나 스톨이 걸립니다. 매 프레임 통째로 다시 채운다면 WRITE_DISCARD가 정석입니다(드라이버가 새 백업 메모리를 줘 동기화 스톨을 피함).

정리 — 세 기법이 하나의 파이프라인으로

지금까지 만든 조각을 프레임 루프에 놓으면 이런 순서가 됩니다.

  1. 카메라가 움직인 프레임에 extractFrustumPlanes(view * proj)로 6평면을 한 번 뽑는다.
  2. 물체(또는 인스턴스)마다 aabbInFrustum으로 안 보이는 것을 버린다.
  3. 살아남은 것에 pickLOD거리에 맞는 해상도를 고른다.
  4. 같은 메시가 많으면 보이는 인스턴스만 모아 DrawIndexedInstanced드로우콜을 뭉친다.

각 단계는 “일을 줄이는 방향”으로만 작동하며, 서로의 이득을 곱셈으로 키워 줍니다. 컬링이 개수를 줄이고, LOD가 개당 비용을 줄이고, 인스턴싱이 호출 횟수를 줄입니다. 실무에서는 여기에 오클루전 컬링(다른 물체 뒤에 가려진 것 제거), 임포스터(아주 먼 물체를 빌보드 텍스처로 치환), GPU 컬링(컴퓨트 셰이더로 절두체 판정) 등이 더해집니다. 그 기반 개념이 바로 이 페이지에서 손으로 짜 본 4가지입니다.