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 등)를
쓰는 것을 권장하며, 여기 코드는 원리를 눈으로 확인하기 위한 학습용입니다.
이 페이지에서 만들 것 (큰 그림)
- 프러스텀 평면 추출 — 뷰-투영 행렬의 행들을 더하고 빼서 좌·우·하·상·근·원 6평면을 얻습니다.
- AABB vs 프러스텀 테스트 — 물체를 감싼 박스가 6평면 중 하나라도 완전히 바깥이면 컬링합니다.
- 거리 기반 LOD 선택 — 카메라와의 거리(제곱)로 고/중/저 폴리곤 메시 중 하나를 고릅니다.
- 인스턴싱 — 같은 메시 수천 개를 두 번째 정점 버퍼 +
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)일 때만 이 좌변 값이 실제 유클리드 거리와 같아집니다.
그래서 아래 코드 마지막에 각 평면을 정규화하는 단계가 반드시 들어갑니다.
정규화를 안 하면 “안/밖” 판정 자체는 맞지만(부호는 보존되므로), 거리 값의 스케일이 평면마다 제각각이라
“박스가 평면에서 얼마나 떨어졌나” 같은 수치를 쓸 수 없게 됩니다.
#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 Plane와 distance() 뜯어보기
struct Plane은 법선 XMFLOAT3 n과 스칼라 float d 두 필드로
평면 하나를 담습니다. XMFLOAT3는 DirectXMath가 메모리에 저장할 때 쓰는
평범한 float x, y, z; 구조체입니다. 계산 전용 타입인 XMVECTOR(SIMD 레지스터에
올라가는 16바이트 정렬 타입)와 대비되는데, 구조체 필드처럼 저장할 때는 XMFLOAT3,
연산할 때는 XMLoadFloat3로 XMVECTOR에 올리는 것이 DirectXMath의 표준
작업 흐름입니다.
float distance(const XMFLOAT3& p) const의 시그니처를 한 조각씩 봅시다.
const XMFLOAT3& p— 인자를 참조(&)로 받습니다. 값 복사(XMFLOAT3 p)를 하면 12바이트를 매번 복사하지만, 참조는 주소만 넘겨 복사가 없습니다. 앞의const는 “이 함수가p를 절대 수정하지 않겠다”는 약속이라, 호출부가 안심하고 임시 객체나 상수도 넘길 수 있게 해 줍니다.- 맨 뒤의
const— “이 멤버 함수는Plane자신(this)도 건드리지 않는다”는 뜻입니다. 그래서const Plane&로 받은 평면에도 호출할 수 있습니다. 뒤에 나올aabbInFrustum이 평면을const로 순회하며 이 함수를 부르므로, 이const가 없으면 컴파일이 안 됩니다. - 본문
n.x*p.x + n.y*p.y + n.z*p.z + d— 이것이 바로 내적n·p에d를 더한 것, 즉 평면 방정식 좌변ax + by + cz + d입니다.
기하 직관(그림글): 법선 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)에서
베낀 공식을 그대로 쓰면 부호나 축이 틀어진다는 점입니다.
[-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평면을 만들어 냅니다. 각 항이 어떤 경계선을 뜻하는지 봅시다.
| 인덱스 | 조합 | 평면 | 직관 |
|---|---|---|---|
| 0 | r3 + r0 | 좌(Left) | 클립 x ≥ -w 경계 |
| 1 | r3 - r0 | 우(Right) | 클립 x ≤ +w 경계 |
| 2 | r3 + r1 | 하(Bottom) | 클립 y ≥ -w 경계 |
| 3 | r3 - r1 | 상(Top) | 클립 y ≤ +w 경계 |
| 4 | r2 | 근(Near) | 클립 z ≥ 0 경계 (D3D) |
| 5 | r3 - r2 | 원(Far) | 클립 z ≤ +w 경계 |
왜 이런 조합이 평면이 되나: 클립 공간에서 “화면 안”의 조건은
-w ≤ x ≤ w, -w ≤ y ≤ w, 0 ≤ z ≤ w입니다.
예를 들어 좌 경계 x ≥ -w는 x + 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)가 진짜 수직 거리(미터 단위)를 돌려줍니다.
안/밖만 볼 거면 정규화를 건너뛰어도 부호는 맞지만, 뒤에서 “박스가 평면에서 얼마나 밖으로 나갔나”를
수치로 쓰거나 크로스페이드 임계에 거리를 쓰려면 정규화가 필수입니다.
BoundingFrustum::CreateFromMatrix가 같은 일을
해 주며, SIMD로 최적화되어 있습니다.
·
XMStoreFloat4x4 → XMMATRIX를 저장용 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 값이 더 “안쪽”으로
가는 꼭짓점이 됩니다. 그래서 축마다 법선 부호를 보고 max냐 min이냐를 고릅니다.
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. 코드 한 줄씩 매핑
struct AABB { XMFLOAT3 min, max; };— 박스를 두 점으로 표현합니다.min은 세 축 모두 최솟값을 모은 꼭짓점,max는 최댓값을 모은 꼭짓점입니다. 이 두 점만 있으면 나머지 6개 꼭짓점은 두 점의 성분을 섞어(min.x, max.y, min.z…) 복원할 수 있으므로, 박스를 저장하는 가장 조밀한 방법입니다.const std::array<Plane, 6>& planes— 1절에서 뽑은 6평면을 참조로 받습니다. 배열이 24개 float라 복사가 아깝기도 하고, 이 함수가 평면을 바꾸지 않으니const를 붙였습니다. 그래서distance()도const멤버여야 했던 것입니다.for (const Plane& pl : planes)— 6평면을 하나씩 순회합니다. 한 평면이라도 박스를 완전히 밀어내면 즉시false로 나갑니다(조기 탈출).pv.x = (pl.n.x >= 0.0f) ? box.max.x : box.min.x;— 삼항 연산자로 “법선의 x가 0 이상이면max.x, 아니면min.x”를 고릅니다. y, z도 동일합니다. 이 세 줄이 축마다 독립적으로 최적의 꼭짓점을 조립해pv(positive vertex)를 만듭니다.if (pl.distance(pv) < 0.0f) return false;— p-vertex의 부호 있는 거리가 음수면 “가장 유리한 꼭짓점마저 바깥”이므로 박스 전체가 바깥 → 컬링(그리지 않음).- 루프를 다 돌고도 살아남으면
return true;— 어떤 평면에도 완전히 밀려나지 않았으니 절두체 안에 있거나 경계를 걸치는 것이라 그려야 합니다.
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이 되어 이 평면은 통과하고,
다음 평면 검사로 넘어갑니다.
true가 나올 수 있습니다(false positive).
이는 안전한 방향의 오차입니다 — 보이는 걸 실수로 안 그리는 것(false negative, 물체가
깜빡 사라짐)이 훨씬 치명적이니까요. 더 정밀하게 하려면 구(sphere) 1차 컬링 → AABB 2차 컬링처럼
단계를 두거나, 박스가 6평면과 완전히 안쪽인지(negative vertex까지) 검사해 “교차/포함”을
구분합니다.
distance 안의 곱셈·덧셈을
XMVector3Dot + XMPlaneDotCoord로 벡터화하면 이득이 큽니다. 더 나아가
SoA(구조체 배열이 아닌 배열 구조체)로 박스들을 정렬해 SIMD로 4~8개를 한꺼번에 테스트하는
것이 상용 엔진의 방식입니다. 학습 단계에서는 위 코드가 읽기 쉬워 좋습니다.
frustum.Contains(BoundingBox{center, extents}) 한 줄이 됩니다.
반환값 ContainmentType은 DISJOINT(완전 밖 → 컬링),
INTERSECTS(걸침 → 그림), CONTAINS(완전 안 → 그림) 세 가지라,
우리 코드의 false가 DISJOINT, true가 나머지 둘에 대응합니다.
plane.distance(p)는 XMPlaneDotCoord(plane, XMVectorSetW(p, 1))과 같습니다.
3. 거리 기반 LOD 선택
무엇을 만드나: 카메라와 물체 중심 사이의 거리를 재서, 고/중/저 폴리곤 메시 중 하나를 고릅니다. 왜 필요한가: 멀리 있는 물체는 화면에서 몇 픽셀 안 되므로 고폴리곤을 그려 봐야 낭비입니다. 거리에 따라 해상도를 낮추면 시각적 손실 없이 정점 처리량을 크게 아낍니다.
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 성분 해부
XMLoadFloat3(&model.center),XMLoadFloat3(&camPos)— 저장용XMFLOAT3를 계산용XMVECTOR(SIMD)로 로드합니다. DirectXMath의 벡터 연산 함수들은 전부XMVECTOR를 받으므로, 계산 전 이 로드가 필요합니다.XMVectorSubtract(center, camPos)— 두XMVECTOR를 성분별로 빼서 카메라→물체 방향 벡터d = (cx-px, cy-py, cz-pz)를 만듭니다. 부호는 상관없습니다(길이만 쓸 것이므로).XMVector3LengthSq(d)—d의 길이 제곱dx² + dy² + dz²를 계산해, 그 값을 네 성분(x,y,z,w) 모두에 복제한XMVECTOR로 돌려줍니다. 이름의3은 “xyz 세 성분만 쓴다(w 무시)”는 뜻입니다.XMVectorGetX(...)— 그XMVECTOR의 x 성분 하나를 평범한float로 꺼냅니다. 네 성분이 다 같은 값이니 x를 꺼내면 됩니다. 이렇게 스칼라로 내려야if (d2 < near2)같은 일반 비교가 가능합니다.
손계산 예: 카메라 (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가 물리는 지점
사용 함수는 세 조각을 한 흐름으로 엮습니다.
AABB box = boundsOf(m);— 물체의 바운딩 박스를 얻습니다(월드 공간 기준).if (!aabbInFrustum(frustum, box)) continue;— 시야 밖이면 즉시 스킵. LOD 선택도, 드로우콜도 아예 하지 않습니다. 컬링이 가장 먼저 오는 이유는 “안 보이는 것에는 LOD 계산조차 아깝다”는 것입니다.draw(pickLOD(m, camPos));— 컬링을 통과한 것만 거리로 LOD를 골라 그립니다.
4. 인스턴싱 — 같은 메시 대량 렌더
무엇을 만드나: 풀·바위·군중처럼 같은 메시를 수천 개 그릴 때, 인스턴스마다 다른 변환 행렬만 바꿔 가며 드로우콜 한 번으로 전부 그립니다. 왜 필요한가: 물체 하나당 드로우콜을 부르면 CPU→GPU 통신 오버헤드가 수천 배로 늘어 CPU가 병목이 됩니다. 인스턴싱은 “메시는 한 벌, 위치만 다르게” 방식으로 이 오버헤드를 없앱니다.
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으로 채웁니다(안 쓰는 필드가 쓰레기값이 되는 것을 막음). 필드를 봅시다.
bd.ByteWidth = count * sizeof(XMFLOAT4X4);— 버퍼 전체 크기. 행렬 하나가 64바이트니 5000개면 320,000바이트입니다.bd.Usage = D3D11_USAGE_DEFAULT;— GPU가 읽는, CPU가 자주 안 바꾸는 버퍼. 매 프레임 내용이 바뀌면 뒤의 성능 콜아웃대로DYNAMIC을 씁니다.bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;— 이 버퍼를 정점 버퍼로 쓰겠다는 선언. 인스턴스 데이터도 결국 입력 어셈블러가 “정점 스트림”처럼 읽으므로 정점 버퍼입니다.sd.pSysMem = models.data();— 초기 내용으로 CPU 쪽 배열을 가리킵니다.CreateBuffer가 이 메모리를 GPU로 복사합니다.ComPtr<ID3D11Buffer> instanceVB;—ComPtr는 COM 객체의 참조 카운트를 자동 관리하는 스마트 포인터라, 범위를 벗어나면Release()를 알아서 불러 누수를 막습니다.
4-2. 입력 레이아웃 — 두 슬롯, 두 스텝 방식
입력 레이아웃은 GPU에게 “정점 버퍼 바이트를 어떻게 셰이더 입력으로 쪼갤지” 알려 주는 설명서입니다.
각 D3D11_INPUT_ELEMENT_DESC의 필드는 순서대로
시맨틱 이름, 시맨틱 인덱스, 포맷, 입력 슬롯, 바이트 오프셋, 스텝 종류, 스텝 레이트입니다.
앞의 세 요소(POSITION, NORMAL, TEXCOORD)는
슬롯 0(메시 정점 버퍼)에서 오고, D3D11_INPUT_PER_VERTEX_DATA이며
스텝 레이트가 0입니다. 즉 정점마다 다음 데이터로 전진합니다. 오프셋
0 / 12 / 24는 Vertex 구조체 안에서 위치(12바이트=float3), 법선(12바이트),
UV(8바이트=float2)가 차례로 놓인 바이트 위치입니다.
뒤의 네 요소가 인스턴싱의 핵심입니다. 4×4 행렬은 셰이더 입력으로 한 번에 받을 수 없어
float4 4개로 쪼개, 같은 시맨틱 이름 "INSTMAT"에
인덱스 0, 1, 2, 3을 붙입니다. 공통점을 봅시다.
- 입력 슬롯이 1 — 방금 만든
instanceVB에서 읽습니다. - 오프셋
0 / 16 / 32 / 48— 행렬 한 개(64바이트) 안에서 각 행(float4=16바이트)의 시작 바이트입니다. 즉 행렬의 0행, 1행, 2행, 3행을 순서대로 집습니다. D3D11_INPUT_PER_INSTANCE_DATA+ 스텝 레이트 1 — “인스턴스마다 다음 행렬로 전진”. 즉 한 인스턴스를 그리는 동안 이 행렬은 고정이고, 다음 인스턴스로 넘어갈 때 버퍼에서 1칸(=1개 행렬) 나아갑니다. 이것이 “정점당”과 “인스턴스당”을 가르는 단 하나의 스위치입니다.
CreateInputLayout은 이 배열과 정점 셰이더 바이트코드(vsBlob)를 대조해,
셰이더 입력 시그니처와 레이아웃이 맞는지 검증하며 레이아웃 객체를 만듭니다. _countof(layout)은
배열 원소 개수(여기선 7)를 컴파일 타임에 세는 매크로입니다.
mul(pos, world) 순서입니다.
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 셰이더 해부
cbuffer PerFrame : register(b0)— 프레임당 한 번 올리는 상수 버퍼로 뷰-투영 행렬을 담습니다.row_major는 “이 행렬은 행 우선으로 저장돼 있다”고 HLSL에 알려, C++ 쪽XMFLOAT4X4와 메모리 배치가 맞도록 합니다(이걸 빼면 HLSL 기본 열 우선과 어긋나 transpose가 필요해집니다).float4 world0 : INSTMAT0; ...— 입력 레이아웃에서 쪼갠 네 조각을 시맨틱INSTMAT0~3으로 그대로 받습니다. C++ 레이아웃의 시맨틱 이름·인덱스와 정확히 일치해야 합니다.float4x4 world = float4x4(i.world0, ...);— 네 행을 다시 4×4 행렬로 조립합니다. 각float4가 행렬의 한 행이 되어, C++에서 저장한 행 우선 행렬과 같은 형태로 복원됩니다.mul(float4(i.pos, 1.0), world)— 위치에w = 1을 붙여 동차 좌표로 만든 뒤 월드 행렬을 곱합니다. 행벡터 규약이라 벡터가mul의 왼쪽입니다 (v * M). 순서를mul(world, v)로 바꾸면 전치된 변환이 적용돼 물체가 엉뚱하게 찌그러집니다.mul(worldPos, gViewProj)— 이어서 뷰-투영을 곱해 클립 공간SV_Position으로 보냅니다. 역시 벡터가 왼쪽입니다.
4-4. 렌더 — 두 버퍼 바인딩과 DrawIndexedInstanced
ID3D11Buffer* vbs[2] = { meshVB.Get(), instanceVB.Get() };— 슬롯 0에 메시 정점 버퍼, 슬롯 1에 인스턴스 행렬 버퍼를 놓습니다..Get()은ComPtr에서 원시 포인터를 꺼내되 참조 카운트는 건드리지 않습니다.UINT strides[2] = { sizeof(Vertex), sizeof(XMFLOAT4X4) };— 각 슬롯에서 “한 원소가 몇 바이트인지”. 슬롯 0은 정점 하나 크기, 슬롯 1은 행렬 하나(64바이트)입니다. GPU가 이 값만큼씩 건너뛰며 읽습니다.UINT offsets[2] = { 0, 0 };— 각 버퍼에서 읽기 시작 바이트(둘 다 처음부터).IASetVertexBuffers(0, 2, vbs, strides, offsets)— 슬롯 0부터 2개 버퍼를 한 번에 바인딩합니다.IASetIndexBuffer(meshIB.Get(), DXGI_FORMAT_R32_UINT, 0)— 인덱스 버퍼(32비트 정수)를 바인딩. 인스턴스마다 같은 인덱스 목록을 재사용하므로 인덱스 버퍼는 하나면 됩니다.DrawIndexedInstanced(indexCount, count, 0, 0, 0)— 이 한 줄이 전부입니다. 인자는 인스턴스당 인덱스 개수, 인스턴스 개수, 시작 인덱스, 베이스 정점, 시작 인스턴스입니다. GPU가 내부적으로 인덱스 목록을count번 돌면서, 매 회 인스턴스 슬롯의 다음 행렬을 집어 그립니다 → 드로우콜은 1번, 그려지는 물체는 5000개.
D3D11_USAGE_DYNAMIC + Map/Unmap으로 만들고, CPU에서 프러스텀 컬링해
보이는 인스턴스만 버퍼에 채워 넣으세요. 이렇게 하면 “컬링 → 컴팩션 → 인스턴싱”이 한 파이프라인이
되어, 5000개 중 화면에 보이는 800개만 그리면서도 드로우콜은 여전히 1번입니다.DYNAMIC 버퍼를 매 프레임 Map(D3D11_MAP_WRITE_DISCARD)로 갱신하지 않고
Map(WRITE_NO_OVERWRITE)나 UpdateSubresource를 잘못 쓰면, GPU가 아직 이전
프레임을 그리는 중인 버퍼를 덮어써 화면이 깜빡이거나 스톨이 걸립니다. 매 프레임 통째로 다시 채운다면
WRITE_DISCARD가 정석입니다(드라이버가 새 백업 메모리를 줘 동기화 스톨을 피함).
정리 — 세 기법이 하나의 파이프라인으로
지금까지 만든 조각을 프레임 루프에 놓으면 이런 순서가 됩니다.
- 카메라가 움직인 프레임에
extractFrustumPlanes(view * proj)로 6평면을 한 번 뽑는다. - 물체(또는 인스턴스)마다
aabbInFrustum으로 안 보이는 것을 버린다. - 살아남은 것에
pickLOD로 거리에 맞는 해상도를 고른다. - 같은 메시가 많으면 보이는 인스턴스만 모아
DrawIndexedInstanced로 드로우콜을 뭉친다.
각 단계는 “일을 줄이는 방향”으로만 작동하며, 서로의 이득을 곱셈으로 키워 줍니다. 컬링이 개수를 줄이고, LOD가 개당 비용을 줄이고, 인스턴싱이 호출 횟수를 줄입니다. 실무에서는 여기에 오클루전 컬링(다른 물체 뒤에 가려진 것 제거), 임포스터(아주 먼 물체를 빌보드 텍스처로 치환), GPU 컬링(컴퓨트 셰이더로 절두체 판정) 등이 더해집니다. 그 기반 개념이 바로 이 페이지에서 손으로 짜 본 4가지입니다.