깊이 버퍼 (Z-Buffer) — 한 줄 한 줄 직접 구현
3D 장면을 그릴 때 가장 먼저 부딪히는 문제는 "무엇이 무엇을 가리는가"입니다. 카메라에서 가까운
면은 먼 면을 덮어야 하는데, 우리가 삼각형을 그리는 순서는 그 앞뒤 관계와 아무 상관이 없습니다.
이 문제를 그리기 순서와 무관하게 픽셀 단위로 자동 해결하는 장치가 깊이 버퍼(Z-Buffer)입니다.
OpenGL에서는 glEnable(GL_DEPTH_TEST) 한 줄이면 끝났지만, Direct3D 11은 깊이 텍스처 →
깊이 스텐실 뷰(DSV) → 깊이 스텐실 상태를 하나하나 명시적으로 만들어 파이프라인에 꽂아야 합니다.
이 페이지에서는 그 세 덩어리를 구조체 필드 하나, API 인자 하나까지 뜯어보고, 왜 깊이 값이
비선형인지, z-파이팅이 왜 생기고 어떻게 없애는지를 손계산 수치까지 곁들여 풀어냅니다.
[0,1](D3D 기본),
행벡터 규약(v' = v * M, HLSL mul(v, M)), 행 우선 저장을 따릅니다. OpenGL은 클립 z가
[-1,1]이라 깊이 정밀도 분포가 다르니, 다른 자료의 near/far 수치를 그대로 옮기지 마세요.
device(ID3D11Device),
context(ID3D11DeviceContext), 스왑체인의 백버퍼로 만든 rtv
(ID3D11RenderTargetView), 그리고 swapChain이 있다고 가정합니다. 여기서는 그 위에
깊이를 얹는 부분만 다룹니다.
왜 깊이 버퍼가 필요한가 — 문제부터
삼각형 세 개가 카메라 앞에 겹쳐 있다고 합시다. 화가처럼 먼 것부터 그리고 가까운 것을 그 위에 덧칠하면 (이걸 "화가 알고리즘"이라 부릅니다) 될 것 같지만, 실제로는 잘 안 됩니다. 삼각형들이 서로 관통하거나, A가 B를 가리면서 동시에 B가 C를 가리고 C가 다시 A를 가리는 순환 겹침이 생기면 "먼 것부터"라는 전역 순서 자체가 존재하지 않기 때문입니다. 삼각형 단위 정렬로는 근본적으로 풀 수 없습니다.
깊이 버퍼의 발상은 정렬을 삼각형 단위가 아니라 픽셀 단위로 내려버리는 것입니다. 화면의 픽셀 하나하나마다 "지금까지 이 픽셀에 찍힌 것 중 가장 가까운 깊이"를 숫자로 저장해 둡니다. 새 프래그먼트(픽셀 후보)가 도착하면, 그 프래그먼트의 깊이와 저장된 깊이를 비교해서 더 가까울 때만 색을 쓰고 깊이도 갱신합니다. 이러면 삼각형을 어떤 순서로 그려도 최종 결과가 같아집니다. 순서 의존성이 사라지는 것이 이 기법의 핵심 가치입니다.
0.0(가장 가까움,
near 평면)에서 1.0(가장 멈, far 평면) 사이입니다. 매 프레임 시작 때 이 이미지를 전부
1.0(무한히 멀다)으로 지우고 시작합니다.
- 깊이를 담을
ID3D11Texture2D를DXGI_FORMAT_D24_UNORM_S8_UINT포맷으로 만든다. - 그 텍스처를 파이프라인이 깊이 대상으로 볼 수 있게
ID3D11DepthStencilView(DSV)를 만든다. ID3D11DepthStencilState에서DepthEnable=TRUE,DepthFunc=LESS로 "더 가까운 것만 통과" 규칙을 정한다.- 렌더 루프에서 DSV를 색 타깃과 함께
OMSetRenderTargets로 바인딩하고, 상태를OMSetDepthStencilState로 건다. - 매 프레임
ClearDepthStencilView로 깊이를1.0으로 초기화한 뒤 그린다.
전체 코드 — 깊이 버퍼 3종 세트 만들기
먼저 원본 전체를 봅니다. 크게 ①깊이 텍스처, ②DSV, ③깊이 스텐실 상태 세 덩어리를 만들고, 렌더 루프에서 그것들을 바인딩·초기화하는 흐름입니다. 아래에서 이 코드를 필드·인자 단위로 잘게 해부하겠습니다.
// 1) 깊이 텍스처 (백버퍼와 같은 크기)
D3D11_TEXTURE2D_DESC dtDesc{};
dtDesc.Width = 1280;
dtDesc.Height = 720;
dtDesc.MipLevels = 1;
dtDesc.ArraySize = 1;
dtDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT; // 24비트 깊이 + 8비트 스텐실
dtDesc.SampleDesc.Count = 1;
dtDesc.Usage = D3D11_USAGE_DEFAULT;
dtDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
ComPtr<ID3D11Texture2D> depthTex;
device->CreateTexture2D(&dtDesc, nullptr, &depthTex);
// 2) 깊이 스텐실 뷰(DSV)
ComPtr<ID3D11DepthStencilView> dsv;
device->CreateDepthStencilView(depthTex.Get(), nullptr, &dsv);
// 3) 깊이 스텐실 상태: 더 작은(가까운) 깊이만 통과
D3D11_DEPTH_STENCIL_DESC dsDesc{};
dsDesc.DepthEnable = TRUE;
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
dsDesc.DepthFunc = D3D11_COMPARISON_LESS; // GL_LESS 대응
ComPtr<ID3D11DepthStencilState> dsState;
device->CreateDepthStencilState(&dsDesc, &dsState);
// 렌더 루프 안:
context->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get()); // 색+깊이 함께 바인딩
context->OMSetDepthStencilState(dsState.Get(), 0);
// 색 + 깊이 버퍼를 모두 지워야 한다.
// 깊이를 안 지우면 이전 프레임 깊이가 남아 물체가 사라진다.
const float clearColor[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
context->ClearRenderTargetView(rtv.Get(), clearColor);
context->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
// ... 그리기 ...
swapChain->Present(1, 0);
① 깊이 텍스처 만들기 — D3D11_TEXTURE2D_DESC 필드 해부
무엇을 만드나: GPU 메모리에 화면과 같은 크기의 2D 배열을 하나 잡습니다. 이 배열의 각 칸에는 색이 아니라 깊이 값이 들어갑니다. 왜 필요한가: 깊이 테스트가 픽셀마다 "예전 깊이"를 읽고 "새 깊이"와 비교하려면, 그 예전 깊이를 어딘가에 저장해 둬야 합니다. 그 저장소가 바로 이 텍스처입니다. 색 버퍼(백버퍼)와 별개의 메모리라는 점이 중요합니다.
D3D11_TEXTURE2D_DESC dtDesc{};
dtDesc.Width = 1280;
dtDesc.Height = 720;
dtDesc.MipLevels = 1;
dtDesc.ArraySize = 1;
dtDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT; // 24비트 깊이 + 8비트 스텐실
dtDesc.SampleDesc.Count = 1;
dtDesc.Usage = D3D11_USAGE_DEFAULT;
dtDesc.BindFlags = D3D11_BIND_DEPTH_STENCIL;
ComPtr<ID3D11Texture2D> depthTex;
device->CreateTexture2D(&dtDesc, nullptr, &depthTex);
D3D11_TEXTURE2D_DESC dtDesc{}; — 서술자를 0으로 초기화
D3D11_TEXTURE2D_DESC는 "어떤 텍스처를 만들지"를 적는 설명서(서술자) 구조체입니다.
D3D11은 리소스를 만들 때 인자를 잔뜩 나열하는 대신, 이런 Desc 구조체를 채워서 넘기는 방식을 씁니다.
필드가 많아서 실수로 빼먹기 쉬운데, 끝의 {}가 그걸 막아줍니다.
{}는 C++의 집합 초기화(aggregate/value initialization)입니다. 구조체의 모든
멤버를 0(포인터는 nullptr, 정수는 0)으로 채웁니다. 이렇게 해두면
아래에서 우리가 명시적으로 건드리지 않은 필드(예: CPUAccessFlags,
MiscFlags)가 쓰레기 값이 아니라 확실히 0이 됩니다. Desc 구조체는 반드시 {}로
초기화하는 습관을 들이세요 — 0이 대부분의 필드에서 "기본/사용 안 함"을 뜻하기 때문에 안전합니다.
Width / Height — 백버퍼와 정확히 같아야 한다
깊이 버퍼는 색 버퍼와 픽셀 대 픽셀로 짝을 이룹니다. 색 버퍼의 (x, y) 픽셀을 그릴 때 깊이 버퍼의 같은 (x, y) 칸을 읽고 씁니다. 따라서 두 버퍼의 가로·세로가 정확히 일치해야 합니다. 여기서는 백버퍼가 1280×720이라고 가정하고 그대로 적었습니다.
ResizeBuffers로 다시 만들 때 이 깊이
텍스처와 DSV도 반드시 함께 다시 만들어야 합니다. 옛 크기의 깊이 버퍼를 새 크기 백버퍼와 같이
바인딩하려 하면 D3D가 바인딩을 거부하거나(디버그 레이어 에러) 화면이 깨집니다. 실전에서는 크기를 상수
1280/720으로 박지 말고 실제 백버퍼 크기 변수를 쓰세요.
MipLevels = 1 / ArraySize = 1
MipLevels는 밉맵(축소 사본) 단계 수입니다. 깊이 버퍼는 렌더 대상이지 샘플링해서 확대·축소할
텍스처가 아니므로 밉맵이 필요 없습니다. 1은 "원본 한 장만"이라는 뜻입니다.
ArraySize는 텍스처 배열의 장수인데, 큐브맵이나 배열 렌더링이 아니라면 1입니다.
둘 다 "가장 단순한 한 장짜리"를 뜻합니다.
Format = DXGI_FORMAT_D24_UNORM_S8_UINT — 깊이 24비트 + 스텐실 8비트
이 필드가 깊이 버퍼의 정밀도를 결정합니다. 이름을 쪼개보면 뜻이 그대로 보입니다.
D24_UNORM— 깊이(Depth)를 24비트로,UNORM(unsigned normalized) 방식으로 저장합니다. 즉[0, 1]실수 범위를 24비트 정수0 ~ 224-1(= 0 ~ 16,777,215)로 균등 매핑합니다. 깊이0.5는 정수 약 8,388,607로 저장되는 식입니다.S8_UINT— 스텐실(Stencil)을 8비트 부호 없는 정수로 저장합니다. 스텐실은 깊이와 별개로 "마스킹" 용도로 쓰는데(외곽선, 거울, 포털 등), 이 페이지에서는 쓰지 않습니다. 하지만 D24는 32비트 한 칸을 채우고 남는 8비트를 스텐실이 자연스럽게 차지하므로, 이 포맷이 가장 메모리 효율적이고 하드웨어 지원도 넓어 사실상 표준입니다.
DXGI_FORMAT_D32_FLOAT(부동소수점 32비트 깊이)가 있습니다. 부동소수점은
특히 리버스드-Z(뒤에서 설명)와 만나면 near·far 전 구간에서 정밀도가 크게 좋아집니다. 대신
스텐실이 필요하면 D32_FLOAT_S8X24_UINT가 되고 픽셀당 8바이트로 메모리가 두 배가 됩니다.
스텐실을 안 쓰는 학습용이라면 D24_UNORM_S8_UINT로 충분합니다.
SampleDesc.Count = 1 — MSAA 없음
SampleDesc는 멀티샘플 안티에일리어싱(MSAA) 설정입니다. Count = 1은 픽셀당 1샘플, 즉
MSAA를 쓰지 않는다는 뜻입니다. 여기서 결정적으로 중요한 규칙은, 깊이 버퍼의
SampleDesc는 색 버퍼의 SampleDesc와 반드시 같아야 한다는 것입니다.
백버퍼를 4×MSAA로 만들었다면 깊이 텍스처도 Count = 4여야 합니다. 안 맞으면 바인딩이 실패합니다.
Usage = D3D11_USAGE_DEFAULT / BindFlags = D3D11_BIND_DEPTH_STENCIL
Usage는 이 리소스를 어떻게 접근할지에 대한 큰 분류입니다. D3D11_USAGE_DEFAULT는
"GPU가 읽고 쓰는, CPU는 직접 손대지 않는 리소스"입니다. 깊이 버퍼는 매 프레임 GPU가 수백만 번 읽고 쓰는
대상이므로 이게 맞습니다.
BindFlags는 이 리소스를 파이프라인의 어느 슬롯에 꽂을 수 있는지를 나타내는 비트 플래그입니다.
D3D11_BIND_DEPTH_STENCIL은 "이 텍스처는 깊이·스텐실 대상으로 쓸 수 있다"는 허가증입니다. 이
플래그가 없으면 다음 단계에서 이 텍스처로 DSV를 만들려 할 때 실패합니다. 만약 나중에 이 깊이 버퍼를
셰이더에서 읽고 싶다면(예: 소프트 파티클, SSAO) | D3D11_BIND_SHADER_RESOURCE를 OR로
추가하고 포맷을 typeless 계열로 바꿔야 하지만, 지금은 순수 깊이 대상이므로 이 한 플래그면 됩니다.
ComPtr<ID3D11Texture2D> depthTex; — 스마트 포인터로 수명 관리
ComPtr(Microsoft::WRL::ComPtr)는 COM 객체용 스마트 포인터입니다.
D3D11의 모든 인터페이스(ID3D11...)는 COM 객체라 참조 카운트로 수명을 관리하는데,
ComPtr이 AddRef/Release를 자동으로 호출해 줍니다. 변수가 스코프를
벗어나면 알아서 Release가 불려 메모리 누수를 막아줍니다. 각괄호 <ID3D11Texture2D>는
"이 스마트 포인터가 가리킬 타입"을 지정하는 템플릿 인자입니다.
device->CreateTexture2D(&dtDesc, nullptr, &depthTex);
실제로 GPU 메모리를 할당하는 호출입니다. 인자를 하나씩 봅시다.
&dtDesc— 방금 채운 서술자의 주소를 넘깁니다.&는 "주소 연산자"로, 구조체 자체가 아니라 그 구조체가 있는 메모리 위치를 넘긴다는 뜻입니다. API는 이 포인터를 따라가 서술자 내용을 읽습니다.nullptr— 초기 데이터 포인터입니다. 정적 텍스처라면 여기에 픽셀 데이터를 넘기지만, 깊이 버퍼는 매 프레임Clear로 새로 채우므로 초기값이 필요 없어nullptr입니다.&depthTex— 만들어진 텍스처 포인터를 받아올 출력 인자입니다.ComPtr에&를 쓰면 내부적으로 빈 포인터의 주소를 넘겨 결과를 받아옵니다.
CreateTexture2D의 반환값(HRESULT)을 반드시 확인하세요.
if (FAILED(hr)) { ... } 또는 ThrowIfFailed(hr) 같은 헬퍼로 감싸는 게 표준입니다.
지면상 여기서는 생략했지만, 실패를 무시하면 다음 줄에서 nullptr을 역참조해 크래시가 납니다.
② 깊이 스텐실 뷰(DSV) 만들기
무엇을 만드나: 방금 만든 텍스처를 파이프라인이 "깊이 대상"으로 인식할 수 있게 감싸는
뷰(view)를 만듭니다. 왜 필요한가: D3D11에서 리소스(메모리 덩어리)와 뷰
(그 메모리를 어떻게 해석할지)는 분리되어 있습니다. 같은 텍스처라도 "렌더 대상으로 볼 때(RTV)", "셰이더에서
읽을 때(SRV)", "깊이 대상으로 볼 때(DSV)" 해석이 다릅니다. OMSetRenderTargets에 넘길 수 있는 것은
텍스처가 아니라 뷰이므로, 깊이 텍스처를 쓰려면 DSV로 감싸야 합니다.
ComPtr<ID3D11DepthStencilView> dsv;
device->CreateDepthStencilView(depthTex.Get(), nullptr, &dsv);
인자 해부
depthTex.Get()— DSV로 감쌀 원본 리소스입니다.ComPtr::Get()은 스마트 포인터가 관리하는 날 포인터(rawID3D11Texture2D*)를 참조 카운트를 건드리지 않고 꺼내줍니다. API는 이 포인터로 어떤 텍스처를 감쌀지 압니다.nullptr(두 번째 인자,D3D11_DEPTH_STENCIL_VIEW_DESC*) — 뷰 서술자입니다.nullptr을 주면 "원본 텍스처의 포맷과 밉 레벨 0을 그대로 써서 기본 뷰를 만들라"는 뜻입니다. 텍스처가 이미D24_UNORM_S8_UINT로 구체적인 포맷이라 기본 뷰로 충분합니다. (텍스처를 typeless로 만들었다면 여기서 구체 포맷을 지정하는 서술자가 필요합니다.)&dsv— 만들어진 DSV를 받아올 출력 인자입니다.
③ 깊이 스텐실 상태 — 비교 규칙 정하기
무엇을 만드나: "새 프래그먼트를 통과시킬지 말지"를 결정하는 규칙을 담은 상태 객체입니다. 왜 필요한가: 깊이 버퍼가 있다고 자동으로 비교가 되는 게 아닙니다. 파이프라인의 출력 병합기(Output Merger, OM) 단계에게 "깊이 테스트를 켜라, 새 깊이가 저장된 깊이보다 작을 때만 통과시켜라, 통과하면 깊이를 갱신하라"고 명시적으로 지시해야 합니다. 그 지시서가 이 상태 객체입니다.
D3D11_DEPTH_STENCIL_DESC dsDesc{};
dsDesc.DepthEnable = TRUE;
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
dsDesc.DepthFunc = D3D11_COMPARISON_LESS; // GL_LESS 대응
ComPtr<ID3D11DepthStencilState> dsState;
device->CreateDepthStencilState(&dsDesc, &dsState);
DepthEnable = TRUE — 깊이 테스트를 켠다
이 필드가 FALSE면 깊이 버퍼가 있어도 비교를 아예 하지 않습니다. 모든 프래그먼트가
무조건 통과하고, 나중에 그린 것이 앞서 그린 것을 덮습니다(화가 알고리즘으로 퇴화). TRUE로 켜야
비로소 "가까운 것이 이긴다"가 작동합니다. 참고로 DepthEnable = FALSE는 UI/오버레이처럼 항상 위에
그려야 하는 요소에서 일부러 쓰기도 합니다.
DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL — 통과하면 깊이도 갱신
깊이 테스트(읽고 비교)와 깊이 쓰기(갱신)는 별개의 스위치입니다. 이 필드가
_ALL이면 "테스트를 통과한 프래그먼트는 자기 깊이를 버퍼에 기록한다"는 뜻입니다.
이래야 그다음에 오는 더 먼 프래그먼트가 "아, 여긴 이미 더 가까운 게 있네" 하고 걸러집니다.
반대로 D3D11_DEPTH_WRITE_MASK_ZERO는 "테스트는 하되 깊이는 안 쓴다"입니다. 이건
반투명(알파 블렌딩) 물체를 그릴 때 핵심입니다. 반투명 물체는 뒤가 비쳐야 하므로 깊이를 써서
뒤 물체를 가려버리면 안 되기 때문입니다. 그래서 실전 렌더러는 보통 불투명 패스(WRITE_ALL)를
먼저 그리고, 반투명 패스(WRITE_ZERO)를 나중에 그립니다.
DepthFunc = D3D11_COMPARISON_LESS — 비교 연산자
수학적 의미: 이 필드는 "새 깊이 vs 저장된 깊이"를 어떤 부등호로 비교할지 정합니다.
LESS는 조건 새깊이 < 저장된깊이일 때만 통과입니다. D3D에서 깊이는
0(near, 가까움)에서 1(far, 멈)로 커지므로, "값이 더 작다 = 더 가깝다"이고, 따라서
LESS가 곧 "더 가까운 것만 통과"가 됩니다.
기하 직관: 픽셀 하나를 위에서 내려다본다고 상상해 보세요. 그 픽셀 방향으로 광선을 쏘면 여러
삼각형과 만납니다. 각 교점의 깊이(0~1)를 비교해 가장 작은(가장 가까운) 하나만 화면에 남기는
것이 은면 제거입니다. LESS는 새로 도착한 교점이 지금까지의 최소보다 더 작으면 갱신, 아니면 버리는
"달리기 최소값 갱신"과 정확히 같습니다.
코드 매핑 / 다른 자료와의 대응: 주석의 GL_LESS 대응이 가리키듯, OpenGL의
glDepthFunc(GL_LESS)와 같은 규칙입니다. 다른 비교자도 있습니다:
| DepthFunc | 통과 조건 | 쓰임 |
|---|---|---|
LESS | 새 < 저장 | 표준 은면 제거(가까운 것 우선) |
LESS_EQUAL | 새 ≤ 저장 | 같은 깊이 다시 그리기(스카이박스, 데칼) |
GREATER | 새 > 저장 | 리버스드-Z(near=1, far=0로 뒤집을 때) |
ALWAYS | 항상 통과 | 깊이 무시하고 덮어쓰기(오버레이) |
LESS_EQUAL을
쓰면, 이미 그려진 물체가 있는 픽셀은 "1.0 ≤ 1.0"이 아니라 "물체깊이 < 1.0"이라 하늘이 물체 뒤로 밀려
보이지 않고, 빈 픽셀만 하늘로 채워집니다. 이렇게 하면 하늘 픽셀을 낭비 없이 딱 필요한 곳에만 그릴 수 있습니다.
device->CreateDepthStencilState(&dsDesc, &dsState);
서술자를 넘겨 상태 객체를 만듭니다. 상태 객체는 불변(immutable)이라, 한 번 만들면 내용을
바꿀 수 없습니다. 그래서 실전에서는 초기화 때 필요한 상태들(불투명용 LESS+WRITE_ALL, 반투명용
LESS+WRITE_ZERO 등)을 미리 몇 개 만들어 두고 렌더 루프에서
OMSetDepthStencilState로 갈아 끼우기만 합니다. 이것이 상태를 매번 새로 만드는 것보다 훨씬 빠릅니다.
④ 렌더 루프 — 바인딩과 초기화
지금까지 만든 세 재료(DSV, 상태 객체)를 매 프레임 파이프라인에 연결하고, 버퍼를 깨끗이 지운 뒤 그립니다.
context->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get()); // 색+깊이 함께 바인딩
context->OMSetDepthStencilState(dsState.Get(), 0);
const float clearColor[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
context->ClearRenderTargetView(rtv.Get(), clearColor);
context->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
// ... 그리기 ...
swapChain->Present(1, 0);
OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get());
출력 병합기(OM)에 "이번 그리기의 결과를 어디에 쓸지"를 알려줍니다. 인자를 하나씩 봅시다.
1— 색 렌더 타깃(RTV)의 개수입니다. 여기서는 백버퍼 하나뿐이라 1입니다. (지연 렌더링처럼 G-버퍼를 쓰면 여러 개가 됩니다.)rtv.GetAddressOf()— RTV 포인터 배열의 주소입니다. API는 "RTV 포인터의 배열"을 기대하는데,GetAddressOf()는ComPtr내부 포인터 변수의 주소(ID3D11RenderTargetView**)를 돌려주므로 길이 1짜리 배열처럼 쓸 수 있습니다. (Get()은*하나,GetAddressOf()는**라는 차이를 기억하세요.)dsv.Get()— 깊이 스텐실 뷰입니다. 바로 이 인자로 깊이 버퍼가 파이프라인에 연결됩니다. 여기에nullptr을 주면 깊이 대상이 없어 깊이 테스트가 아예 동작하지 않습니다.
OMSetDepthStencilState(dsState.Get(), 0);
앞서 만든 비교 규칙(LESS, 쓰기 켬)을 OM 단계에 겁니다. 두 번째 인자 0은 스텐실
참조값(StencilRef)으로, 스텐실을 쓸 때만 의미가 있습니다. 스텐실 미사용이라 0이면
됩니다.
ClearRenderTargetView / ClearDepthStencilView — 매 프레임 초기화
ClearRenderTargetView(rtv.Get(), clearColor)는 색 버퍼 전체를 배경색으로 칠합니다.
clearColor는 RGBA 4개 float 배열입니다. 여기서 진짜 중요한 것은 그다음 줄입니다.
ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0)의 인자를 봅시다:
dsv.Get()— 지울 깊이 뷰.D3D11_CLEAR_DEPTH— "깊이만 지운다"는 플래그입니다. 스텐실도 지우려면| D3D11_CLEAR_STENCIL을 OR로 붙입니다.1.0f— 깊이를 채울 값. 1.0 = far = "무한히 멀다"입니다. 이래야 첫 프래그먼트(어떤 깊이든 1.0보다 작거나 같음)가 항상 통과해 화면에 나타납니다. 만약 여기를0.0으로 지우면, 저장된 깊이가 이미 최솟값(가장 가까움)이라 그 무엇도LESS를 통과하지 못해 화면이 통째로 비어 버립니다.0— 스텐실을 채울 값(미사용이라 0).
ClearDepthStencilView를 안 부르면, 지난
프레임의 깊이 값이 그대로 남습니다. 카메라가 움직여 물체가 앞으로 와도 "지난 프레임의 더 가까운 깊이"에 막혀
LESS를 통과하지 못하고, 화면이 프리징된 것처럼 보이거나 물체가 아예 안 그려집니다.
색과 깊이는 매 프레임 항상 함께 지우세요. 또한 DSV를 OMSetRenderTargets에
바인딩하지 않으면 깊이 테스트 자체가 동작하지 않는다는 점도 다시 강조합니다.
깊이 값은 왜 비선형인가 — 수식과 손계산
"깊이가 [0,1]이면 near에서 far까지 균등하게 퍼져 있겠지"라고 생각하기 쉽지만, 전혀
아닙니다. 원근 투영을 거치면 깊이 값은 near 근처에 극단적으로 몰리고 far 쪽은 듬성듬성해집니다. 이
비선형성이 z-파이팅의 근본 원인이므로 원리를 정확히 짚고 갑니다.
원근 투영 행렬은 정점의 뷰 공간 깊이 z를 클립 공간에서 z_clip과
w_clip 두 성분으로 내보내고, 래스터라이저가 원근 나눗셈 z_ndc = z_clip / w_clip을
합니다. D3D의 왼손·[0,1] 규약에서 이 결과는 다음과 같습니다(n=near, f=far):
z_ndc = f/(f-n) * (1 - n/z)
= (f/(f-n)) - (f*n) / ((f-n) * z)
핵심은 분모에 뷰 깊이 z가 역수(1/z) 형태로 들어간다는 것입니다. 함수가
1/z꼴이라 z가 작을 때(가까울 때) 값이 급격히 변하고, z가 클 때(멀 때)는
거의 변하지 않습니다. 실제 숫자로 확인해 봅시다. n = 0.1, f = 100으로 두면
f/(f-n) = 100/99.9 ≈ 1.001, f*n/(f-n) = 10/99.9 ≈ 0.1001입니다.
| 뷰 깊이 z (미터) | 저장 깊이 z_ndc | 구간 설명 |
|---|---|---|
| 0.1 (near) | 0.000 | 시작점 |
| 0.2 | 0.500 | 겨우 10cm 왔는데 벌써 절반! |
| 0.5 | 0.800 | |
| 1.0 | 0.900 | |
| 10.0 | 0.990 | |
| 100.0 (far) | 1.000 | 끝점 |
보이시나요? near에서 far까지 절반 지점(z_ndc = 0.5)이 실제로는 겨우 0.2m입니다. 전체 거리의
99.8%(0.2m ~ 100m)가 깊이 값 [0.5, 1.0]이라는 좁은 절반 안에 우겨넣어집니다. 즉 먼
물체일수록 깊이 값이 촘촘히 붙어 24비트 정수로 저장할 때 반올림이 겹치기 쉽습니다. 이게 z-파이팅
(두 면이 번갈아 이겨 번쩍이는 현상)의 원인입니다.
z = 0.2를 공식에 직접 넣어봅시다. 1.001 - 0.1001/0.2 = 1.001 - 0.5005 =
0.5005 ≈ 0.5. 표의 값과 일치합니다. z = 1.0이면 1.001 - 0.1001/1.0 = 0.9009 ≈
0.9. 이렇게 1/z 항이 지배해 가까운 곳에 정밀도가 쏠린다는 걸 숫자로 확인할 수 있습니다.
z-파이팅과 그 해법 — 리버스드-Z까지
near를 0.001처럼 너무 작게 잡으면 먼 물체 두 면이 번쩍이는 z-파이팅이 생깁니다.
해법은 세 가지입니다.
(1) near를 감당 가능한 한 크게(예 0.1~1.0),
far는 필요 최소로 잡아 near/far 비율을 줄인다. 정밀도는 near에 가장 민감하므로 near를 키우는 게
가장 효과가 큽니다.
(2) 의도적으로 겹치는 표면(데칼, 지면 위 글자)은 래스터라이저 상태의 DepthBias /
SlopeScaledDepthBias로 깊이를 살짝 앞뒤로 밀어 충돌을 피한다.
(3) 정밀도가 더 필요하면 리버스드-Z 기법을 쓴다. D3D의 클립 z는 [0,1]이라
리버스드-Z와 특히 잘 맞습니다.
리버스드-Z가 왜 효과적인가
앞의 표에서 문제는 "far 쪽 정밀도 부족"과 "부동소수점의 정밀도 낭비"가 겹치는 것이었습니다. 부동소수점(float)은
0 근처에서 표현 가능한 값이 촘촘하고 1 근처에서 듬성듬성합니다. 그런데 위 원근 깊이는
거꾸로 near에서 값이 0에 몰리고 far에서 1에 몰립니다. 즉 "정밀도가 촘촘한 float 영역(0 근처)"과
"깊이 값이 몰리는 영역(near)"이 겹쳐, far 쪽은 float 정밀도도 낮고 깊이 분포도 성긴 이중고를
겪습니다.
리버스드-Z는 near를 1.0, far를 0.0으로 뒤집습니다. 그러면
1/z의 비선형 몰림과 float의 0 근처 촘촘함이 서로 상쇄되어, near부터
far까지 정밀도가 거의 균일해집니다. 실제로 D32_FLOAT + 리버스드-Z 조합은 깊이 정밀도 문제를
사실상 없애버립니다. D3D는 클립 z가 원래 [0,1]이라 near/far만 뒤집으면 되지만, OpenGL은
[-1,1]이라 별도 확장(glClipControl)이 필요합니다 — 이 지점이 D3D가 유리한 부분입니다.
리버스드-Z를 쓰려면 세 가지를 함께 바꿔야 합니다:
- 투영 행렬의 near/far를 뒤바꿔 near=1, far=0이 되도록 만든다(전용 리버스드-Z 투영 함수 사용).
ClearDepthStencilView의 클리어 값을1.0f가 아니라0.0f로 바꾼다(이제 far가 0).DepthFunc를LESS가 아니라GREATER(또는GREATER_EQUAL)로 바꾼다("값이 클수록 가깝다"로 뒤집혔으므로).
D24_UNORM(정수 깊이)에는
리버스드-Z의 이점이 거의 없습니다 — 부동소수점(D32_FLOAT)의 비균등 분포와 짝을 이뤄야 효과가
나오기 때문입니다.
DirectXMath / D3D API 대응 정리
이 페이지의 깊이 관련 요소들이 실무 코드/라이브러리와 어떻게 대응하는지 1:1로 정리합니다. 실전에서는 near/far 수치와 투영 행렬은 DirectXMath의 검증된 함수를 쓰고, 깊이 상태·뷰는 위처럼 초기화 때 한 번 만들어 재사용하는 것이 표준입니다(직접 손으로 행렬을 짜는 건 학습용으로만).
| 이 페이지 / 개념 | DirectXMath · D3D API | 대응 설명 |
|---|---|---|
| 원근 투영(왼손, z∈[0,1]) | XMMatrixPerspectiveFovLH(fov, aspect, n, f) | 위의 z_ndc 공식과 정확히 같은 [0,1] 깊이 매핑을 생성. n, f 인자가 깊이 정밀도를 좌우. |
| 리버스드-Z 투영 | XMMatrixPerspectiveFovLH(fov, aspect, f, n) (n·f 스왑) | near/far 인자를 뒤바꿔 near=1, far=0 매핑. 클리어 0.0 + GREATER와 함께 써야 함. |
| 깊이 테스트 켜기 | OMSetDepthStencilState(state, ref) | OpenGL glEnable(GL_DEPTH_TEST) 대응(상태 객체 방식). |
비교 규칙 LESS | D3D11_COMPARISON_LESS | OpenGL glDepthFunc(GL_LESS) 대응. |
| 깊이 쓰기 on/off | DepthWriteMask = _ALL/_ZERO | OpenGL glDepthMask(GL_TRUE/GL_FALSE) 대응. 반투명 패스에서 _ZERO. |
| 깊이 초기화 | ClearDepthStencilView(dsv, DEPTH, 1.0f, 0) | OpenGL glClear(GL_DEPTH_BUFFER_BIT) + glClearDepth(1.0) 대응. |
| 깊이 바이어스 | 래스터라이저 상태 DepthBias / SlopeScaledDepthBias | OpenGL glPolygonOffset 대응. 데칼·섀도우에서 z-파이팅/피터패닝 완화. |
XMMatrixTranspose로
전치(transpose)해야 HLSL의 mul(v, M)과 맞습니다. 투영 행렬도 예외가 아니니, 깊이 값이 이상하게
나온다면 전치를 빠뜨렸는지부터 확인하세요.