투명과 렌더링 순서 — 한 줄 한 줄 직접 구현
유리·물·연기·불꽃처럼 뒤가 비치는 물체는 알파 블렌딩으로 그립니다. 그런데 블렌딩은 지금 화면(렌더 타깃)에 이미 그려져 있는 색과 새 색을 섞는 연산이라, 어떤 순서로 그리느냐에 따라 결과가 완전히 달라집니다. 이 페이지에서는 ① 알파 오버 블렌드 스테이트를 D3D11 API로 만드는 법, ② 왜 정렬이 필요한지, ③ 불투명/반투명을 나눠 그리는 2패스 + back-to-front 정렬 파이프라인을 코드 한 줄, 플래그 하나까지 해부합니다. 마지막으로 이 접근의 한계와 OIT까지 짚습니다.
1. 알파 오버 블렌딩이란 무엇인가
블렌딩(blending)은 픽셀 셰이더가 뱉은 소스 색(source, 새로 그리는 색)과, 렌더 타깃에 이미 저장되어 있던 목적지 색(destination, 배경/뒤 물체)을 정해진 공식으로 합치는 고정 기능 단계입니다. 우리가 원하는 표준 유리 효과는 알파 오버(alpha over) 혹은 소스 알파 블렌딩이라 부르며, 수식은 이렇습니다.
결과.rgb = 소스.rgb × 소스.a + 목적지.rgb × (1 − 소스.a)
수학적 의미: 이것은 두 색의 가중 평균(선형 보간, lerp)입니다.
소스 알파 a가 1이면 결과는 100% 소스(완전 불투명), 0이면 100% 목적지(완전 투명),
0.5면 정확히 반반이 섞입니다. 즉 a는 "이 표면이 빛을 얼마나 막느냐(불투명도)"입니다.
기하 직관: 창문 유리를 상상해 보세요. 유리 자체 색이 옅은 파랑(소스)이고,
유리 너머에 빨간 벽(목적지)이 있다고 합시다. 유리의 불투명도가 0.3이라면
최종적으로 보이는 색은 "파랑을 30%, 벽의 빨강을 70%" 섞은 색입니다. 이 "70%"가 바로
1 − a = 1 − 0.3 = 0.7 항입니다. 그래서 목적지에는 반드시
(1 − 소스알파)가 곱해져야 두 기여의 합이 항상 100%가 되어 색이 밝아지거나 어두워지지 않습니다.
using Microsoft::WRL::ComPtr;
// 결과 = src.rgb * src.a + dst.rgb * (1 - src.a) → 표준 알파 오버
D3D11_BLEND_DESC bd = {};
bd.RenderTarget[0].BlendEnable = TRUE;
bd.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;
bd.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
bd.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
bd.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE;
bd.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA;
bd.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;
bd.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
ComPtr<ID3D11BlendState> alphaBlend;
device->CreateBlendState(&bd, &alphaBlend);
// 활성화: OMSetBlendState (nullptr blend factor, 0xFFFFFFFF sample mask)
context->OMSetBlendState(alphaBlend.Get(), nullptr, 0xFFFFFFFF);
1-1. 블렌드 공식이 실제로 계산되는 방법
D3D11의 하드웨어 블렌더는 우리가 임의의 수식을 넣는 게 아니라, 딱 이 형태로 고정되어 있습니다.
결과 = (소스 × SrcBlend) BlendOp (목적지 × DestBlend)
즉 우리가 정하는 것은 세 가지 다이얼뿐입니다.
SrcBlend(소스에 곱할 계수), DestBlend(목적지에 곱할 계수),
BlendOp(둘을 합치는 연산: 더하기/빼기 등). 우리 목표 수식
src.rgb·src.a + dst.rgb·(1−src.a)과 이 틀을 나란히 놓고 대응시켜 봅시다.
| 수식의 항 | 필요한 계수 | D3D11 열거값 |
|---|---|---|
소스에 곱하는 값 = src.a | 소스 알파 | D3D11_BLEND_SRC_ALPHA |
목적지에 곱하는 값 = 1 − src.a | 1 빼기 소스 알파 | D3D11_BLEND_INV_SRC_ALPHA |
| 둘을 합치는 방법 = 더하기 | 덧셈 | D3D11_BLEND_OP_ADD |
INV_SRC_ALPHA의 INV는 "inverse(반전)"이 아니라 정확히는
1에서 뺀 값(one-minus)을 뜻합니다. 즉 1.0 − src.a입니다.
이름 때문에 역수(1/a)로 착각하기 쉬운데 그렇지 않습니다.
1-2. 코드 한 줄씩 해부
using Microsoft::WRL::ComPtr;
ComPtr는 Windows의 COM 객체(예: ID3D11BlendState)를 자동으로
참조 카운트 관리해 주는 스마트 포인터입니다. D3D11 인터페이스는 전부 COM이라
Release()를 직접 부르지 않고 ComPtr가 스코프를 벗어날 때
자동 해제하도록 맡깁니다. using은 매번 Microsoft::WRL::ComPtr로
길게 쓰지 않으려는 이름 축약일 뿐입니다.
D3D11_BLEND_DESC bd = {};
블렌드 스테이트를 만들려면 먼저 그 설계도(description) 구조체를 채워야 합니다.
= {}는 C++의 집합 값-초기화(aggregate value-initialization)로,
구조체의 모든 멤버를 0/FALSE로 밀어 버립니다. 이게 중요한 이유는
D3D11_BLEND_DESC 안에는 우리가 명시하지 않는 필드
(AlphaToCoverageEnable, IndependentBlendEnable,
그리고 RenderTarget[1..7] 등 총 8개 렌더타깃 슬롯)가 있기 때문입니다.
= {}가 없으면 이 필드들이 쓰레기 값을 담아
예측 불가능한 블렌딩(혹은 CreateBlendState 실패)이 생깁니다.
D3D11_BLEND_DESC bd;처럼 = {} 없이 선언하면 스택의 쓰레기 값이 그대로 남습니다.
특히 AlphaToCoverageEnable이 우연히 TRUE가 되면 MSAA 커버리지가 알파로
바뀌어 반투명이 지글거리는 도트 패턴으로 보일 수 있습니다. D3D 디스크립터는 항상
= {}로 0-초기화하고 필요한 것만 덮어쓰는 습관을 들이세요.
bd.RenderTarget[0].BlendEnable = TRUE;
RenderTarget[0]은 0번 렌더 타깃(보통 백버퍼)의 블렌딩 설정입니다.
D3D11은 최대 8개(MRT, Multiple Render Targets) 슬롯을 독립적으로 블렌딩할 수 있지만,
화면에 그리는 일반 렌더링은 0번만 씁니다. BlendEnable = TRUE가 없으면
아래에 뭘 채우든 블렌딩이 일어나지 않고 소스 색이 목적지를 그냥 덮어씁니다(불투명).
RGB 채널 3형제 — SrcBlend / DestBlend / BlendOp
이 세 줄이 앞에서 본 다이얼의 RGB 버전입니다. 표에서 매핑한 그대로,
SrcBlend = SRC_ALPHA(소스에 α 곱), DestBlend = INV_SRC_ALPHA(목적지에 1−α 곱),
BlendOp = OP_ADD(둘을 더함). 이 세 개가 합쳐져 정확히
결과.rgb = src.rgb·α + dst.rgb·(1−α)를 만듭니다.
알파 채널 3형제 — SrcBlendAlpha / DestBlendAlpha / BlendOpAlpha
D3D11은 RGB 채널과 A(알파) 채널의 블렌드 공식을 따로 지정할 수 있습니다.
...Alpha가 붙은 세 필드가 결과 픽셀의 알파값을 어떻게 계산할지 정합니다.
여기서는 SrcBlendAlpha = ONE, DestBlendAlpha = INV_SRC_ALPHA이므로
결과 알파는 결과.a = src.a·1 + dst.a·(1−src.a)가 됩니다.
이것은 알파 채널에 대한 올바른 누적(over 연산의 알파 공식)으로,
나중에 이 렌더 타깃을 다시 다른 것 위에 합성할 때(예: UI, 오프스크린 합성) 알파가 어긋나지 않게 해줍니다.
RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
이 마스크는 "블렌딩 결과 중 어떤 채널을 실제로 렌더 타깃에 쓸지"를 고르는
비트 플래그입니다. ENABLE_ALL은 R|G|B|A 네 채널 전부 기록합니다.
특정 채널만 쓰고 싶을 때(예: 알파만 갱신) 여기서 마스킹합니다. 대부분의 반투명 렌더링은
전부 쓰면 되므로 ALL이 기본값입니다.
device->CreateBlendState(&bd, &alphaBlend);
채워둔 설계도(bd)를 GPU가 이해하는 불변 스테이트 객체로 컴파일합니다.
시그니처: 첫 인자는 디스크립터 주소(&bd, 읽기 전용),
둘째 인자는 결과를 받을 포인터의 주소(&alphaBlend, 즉
ID3D11BlendState**). ComPtr의 & 연산자는
내부 포인터의 주소를 안전하게 넘겨줍니다.
CreateBlendState는 무거운 호출이라 프레임마다 만들면 심각한 병목이 됩니다.
보통 alphaBlend, additiveBlend(불꽃·글로우용), noBlend(nullptr) 등을
시작할 때 미리 만들어 두고 매 프레임 OMSetBlendState로 교체만 합니다.
context->OMSetBlendState(alphaBlend.Get(), nullptr, 0xFFFFFFFF);
OM은 파이프라인의 마지막 단계인 Output Merger를 뜻합니다. 블렌딩과 깊이/스텐실이 여기서 처리됩니다. 세 인자를 하나씩 봅시다.
alphaBlend.Get()— 방금 만든 스테이트 객체의 원시 포인터.ComPtr는 참조 카운트를 건드리지 않고 내부 포인터만 꺼내주는Get()을 제공합니다.nullptr을 넣으면 "블렌딩 없음(불투명 덮어쓰기)"으로 되돌아갑니다.nullptr— 블렌드 팩터(float[4])입니다.D3D11_BLEND_BLEND_FACTOR계수를 쓸 때만 필요하고, 우리는SRC_ALPHA/INV_SRC_ALPHA만 쓰므로 필요 없어nullptr입니다.0xFFFFFFFF— 샘플 마스크입니다. MSAA에서 어떤 샘플을 쓸지 고르는 비트마스크로,0xFFFFFFFF는 "모든 샘플 사용"이라 사실상 항상 이 값을 씁니다.
2. 왜 정렬이 필요한가
이제 블렌드 스테이트는 준비됐습니다. 하지만 반투명을 그냥 켜고 아무 순서로 그리면 결과가 틀립니다. 이유는 두 가지 하드웨어 동작이 얽히기 때문입니다: 깊이 테스트와 블렌딩의 순서 의존성.
2-1. 깊이 테스트가 반투명을 지워버린다
깊이 테스트(Z-test)는 "이 픽셀의 깊이가 이미 저장된 깊이보다 더 멀면 버린다"는 규칙입니다. 불투명 물체에서는 이게 축복입니다 — 가려진 픽셀을 자동으로 폐기해 주니까요. 그런데 불투명 물체가 깊이를 쓰면(write), 그 뒤에 있는 반투명 물체는 깊이 테스트에서 "더 멀다"고 걸려 아예 그려지지 않습니다. 유리 뒤 물체가 보여야 하는데 깊이 버퍼가 유리를 이미 불투명처럼 막아버리는 거죠.
기하 직관: 카메라 → [빨강 벽(먼 불투명)] → [파랑 유리(가까운 반투명)] 순으로 있다고 합시다. 만약 유리를 먼저 그리면서 깊이까지 써버리면, 나중에 그리는 벽이 "유리보다 멀다"고 판정되어 통째로 사라집니다. 유리 너머로 보여야 할 벽이 없어지는 겁니다. 그래서 반투명은 깊이를 쓰면 안 됩니다(테스트는 해야 하지만).
2-2. 블렌딩은 순서에 의존한다
알파 오버 C = C_src·α + C_dst·(1−α)는 교환법칙이 성립하지 않습니다.
두 반투명 A, B를 A→B 순으로 섞은 결과와 B→A 순으로 섞은 결과가 다릅니다.
수식으로 확인해 봅시다. 배경 C0 위에 알파 0.5짜리 빨강 R과 파랑 B를 얹을 때:
- 빨강 먼저 → 파랑: 먼저
C0·0.5 + R·0.5, 그 위에 파랑을 얹으면(그 결과)·0.5 + B·0.5. 최종에서 파랑 비중이 크고 빨강은 절반으로 눌립니다. - 파랑 먼저 → 빨강: 반대로 빨강 비중이 커집니다.
현실에서 "빛이 물체를 통과하는" 물리적으로 올바른 순서는 가장 먼 것부터 가까운 것으로 (back-to-front)입니다. 배경 → 먼 유리 → 가까운 유리 순으로 얹어야, 각 유리가 "자기 뒤에 이미 완성된 색" 위에 자기 색을 올바른 비율로 덧칠하게 됩니다. 그래서 반투명은 카메라에서 먼 것부터 정렬해서 그려야 합니다.
3. 2패스 렌더링 파이프라인
앞의 두 제약을 동시에 만족시키는 표준 전략이 2패스 + 정렬입니다. 큰 그림은 이렇습니다.
- 불투명 패스: 깊이 쓰기 ON, 블렌딩 OFF. 순서는 상관없이 다 그립니다 (깊이 테스트가 알아서 가림을 처리하고, 오히려 앞→뒤로 그리면 early-Z로 더 빠릅니다).
- 반투명 물체만 골라 카메라 거리로 정렬(먼 것 먼저)합니다.
- 반투명 패스: 깊이 테스트는 유지하되 깊이 쓰기는 끈
(
DepthWriteMask = ZERO) 깊이-스텐실 스테이트로, 블렌딩 ON으로 정렬된 순서대로 그립니다.
핵심은 불투명 패스가 깊이 버퍼를 먼저 채워둔다는 점입니다. 그러면 반투명 패스는 그 깊이를 읽어서(테스트) 불투명 물체 뒤에 있는 유리 픽셀은 자동으로 폐기하되, 쓰지는 않아서(no-write) 반투명끼리는 서로를 지우지 않습니다.
#include <algorithm>
#include <vector>
#include <DirectXMath.h>
using namespace DirectX;
using Microsoft::WRL::ComPtr;
struct Renderable {
XMFLOAT3 position;
bool transparent;
// ... 메시 핸들, 머티리얼 등
};
// 미리 만들어 둔 스테이트들:
// dssWrite : DepthEnable=TRUE, DepthWriteMask=ALL (불투명)
// dssNoWrite: DepthEnable=TRUE, DepthWriteMask=ZERO (반투명, 테스트만)
// alphaBlend: 위의 blend_setup.cpp 에서 만든 알파 오버 블렌드 스테이트
void renderScene(std::vector<Renderable>& objects, XMFLOAT3 camPos) {
// ── 패스 1: 불투명 (깊이 쓰기 ON, 블렌딩 OFF) ──
context->OMSetDepthStencilState(dssWrite.Get(), 0);
context->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF); // 블렌딩 OFF
for (auto& o : objects)
if (!o.transparent) drawObject(o);
// ── 반투명만 골라 카메라 거리 제곱으로 정렬 (먼 것 → 가까운 것) ──
std::vector<Renderable*> blended;
for (auto& o : objects)
if (o.transparent) blended.push_back(&o);
XMVECTOR cam = XMLoadFloat3(&camPos);
auto dist2 = [&](const Renderable* o) {
XMVECTOR d = XMVectorSubtract(XMLoadFloat3(&o->position), cam);
return XMVectorGetX(XMVector3LengthSq(d));
};
std::sort(blended.begin(), blended.end(),
[&](const Renderable* a, const Renderable* b) {
return dist2(a) > dist2(b); // 거리 큰 것이 앞에 오도록 (먼저 그림)
});
// ── 패스 2: 반투명 (깊이 쓰기 OFF, 블렌딩 ON) ──
context->OMSetBlendState(alphaBlend.Get(), nullptr, 0xFFFFFFFF);
context->OMSetDepthStencilState(dssNoWrite.Get(), 0); // 깊이 쓰기만 끔
for (Renderable* o : blended)
drawObject(*o);
context->OMSetDepthStencilState(dssWrite.Get(), 0); // 다음 프레임 위해 원상복구
context->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);
}
3-1. 데이터 구조와 시그니처
struct Renderable { XMFLOAT3 position; bool transparent; ... };
한 개의 그릴 대상을 표현하는 최소 구조체입니다. position은
XMFLOAT3 — float 3개(x,y,z)를 담는 저장용 타입입니다.
DirectXMath에는 두 종류의 벡터 타입이 있는데, XMFLOAT3는 메모리에 그대로 두는
"저장 타입"이고, 실제 연산은 SIMD 레지스터에 올린 XMVECTOR로 합니다
(아래 XMLoadFloat3에서 이 변환이 나옵니다). transparent는 이 물체를
불투명 패스에서 그릴지 반투명 패스에서 그릴지 가르는 플래그입니다.
void renderScene(std::vector<Renderable>& objects, XMFLOAT3 camPos)
시그니처 해부:
std::vector<Renderable>& objects— 씬의 모든 물체 목록을 참조(&)로 받습니다. 참조로 받는 이유는 벡터 전체를 복사하지 않기 위해서입니다(수백~수천 개일 수 있음).const를 안 붙인 건 아래에서o.position의 주소(&o.position)를 정렬용 포인터로 담기 때문에, 비-const 참조가 필요하기 때문입니다.XMFLOAT3 camPos— 카메라 월드 좌표입니다. float 3개짜리 작은 값이라 값 복사로 받아도 부담이 없습니다.- 반환
void— 즉시 컨텍스트에 드로우 명령을 쏘므로 돌려줄 값이 없습니다.
3-2. 패스 1 — 불투명
context->OMSetDepthStencilState(dssWrite.Get(), 0);
context->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF); // 블렌딩 OFF
for (auto& o : objects)
if (!o.transparent) drawObject(o);
OMSetDepthStencilState(dssWrite.Get(), 0) — 깊이 쓰기까지 ON인
스테이트(DepthEnable=TRUE, DepthWriteMask=ALL)를 겁니다. 둘째 인자 0은
스텐실 레퍼런스 값으로, 스텐실을 안 쓰면 아무 값이나 무방합니다.
OMSetBlendState(nullptr, ...) — 첫 인자 nullptr이
"블렌딩 없음"을 의미합니다. 불투명은 섞을 필요 없이 그냥 덮어쓰기가 맞습니다.
for (auto& o : objects) if (!o.transparent) drawObject(o); —
auto& o는 각 원소를 복사 없이 참조로 순회하는 범위 기반 for입니다.
!o.transparent 즉 불투명만 그립니다. 여기서는 정렬을 전혀 안 합니다 —
깊이 테스트가 가림을 알아서 처리하기 때문입니다.
3-3. 반투명 골라내기와 정렬
std::vector<Renderable*> blended;
for (auto& o : objects)
if (o.transparent) blended.push_back(&o);
XMVECTOR cam = XMLoadFloat3(&camPos);
auto dist2 = [&](const Renderable* o) {
XMVECTOR d = XMVectorSubtract(XMLoadFloat3(&o->position), cam);
return XMVectorGetX(XMVector3LengthSq(d));
};
std::sort(blended.begin(), blended.end(),
[&](const Renderable* a, const Renderable* b) {
return dist2(a) > dist2(b); // 거리 큰 것이 앞에 오도록 (먼저 그림)
});
포인터 벡터로 모으기
std::vector<Renderable*> blended; — 반투명 물체들을 포인터로 모읍니다.
구조체 전체를 복사하지 않고 주소만 담아 정렬 비용을 줄입니다. blended.push_back(&o)에서
&o가 원본 물체의 주소입니다(그래서 objects가 비-const 참조여야 했습니다).
XMVECTOR cam = XMLoadFloat3(&camPos);
저장 타입 XMFLOAT3를 SIMD 연산 타입 XMVECTOR로 로드합니다.
XMLoadFloat3는 인자로 XMFLOAT3*(주소)를 받으므로 &camPos를 넘깁니다.
카메라 위치는 루프에서 매번 같으니 루프 밖에서 한 번만 로드해 두는 게 효율적입니다.
dist2 람다 — 카메라까지 거리의 제곱
auto dist2 = [&](const Renderable* o) { ... };는 람다(익명 함수)입니다.
[&]는 참조 캡처로, 바깥의 cam을 복사 없이 그대로 씁니다.
내부 계산을 한 줄씩 봅시다.
XMLoadFloat3(&o->position)— 물체 위치를XMVECTOR로 로드.XMVectorSubtract(위치, cam)— 차 벡터d = position − cam. 카메라에서 물체로 향하는 방향과 거리를 담습니다.XMVector3LengthSq(d)— 길이의 제곱d·d = dₓ² + dᵧ² + d_z². 결과는 4채널에 같은 값이 복제된XMVECTOR입니다.XMVectorGetX(...)— 그 중 x 성분(스칼라)만 꺼냅니다.
a > b ⟺ a² > b²(둘 다 음수 아님)이므로 굳이 sqrt를 부를 필요가 없습니다.
sqrt는 비싼 연산이라, 수천 개 물체를 O(n log n)번 비교하는 정렬에서
제곱근을 빼면 눈에 띄게 빨라집니다. 거리 비교엔 항상 LengthSq를 기억하세요.
std::sort(..., 비교람다)
std::sort는 [first, last) 구간을 정렬합니다. 셋째 인자인 비교 함수
[&](const Renderable* a, const Renderable* b){ return dist2(a) > dist2(b); }는
"a가 b보다 앞에 와야 하면 true"를 반환합니다.
여기서는 dist2(a) > dist2(b) — 즉 거리가 더 큰 것(먼 것)이 앞에 오도록
내림차순으로 정렬합니다. 그래서 배열 앞쪽=먼 물체, 뒤쪽=가까운 물체가 되고,
앞에서부터 그리면 자연히 back-to-front가 됩니다.
손계산 예제: 카메라가 원점 (0,0,0)이고 두 유리가
A=(0,0,3), B=(0,0,5)라 합시다.
dist2(A)=0+0+9=9, dist2(B)=25. 25 > 9이므로 B가 A보다 앞에 정렬됩니다.
그리는 순서는 B(먼 것) → A(가까운 것). 카메라 기준 뒤에 있는 B가 먼저 그려지니 정확히 back-to-front입니다.
3-4. 패스 2 — 반투명
context->OMSetBlendState(alphaBlend.Get(), nullptr, 0xFFFFFFFF);
context->OMSetDepthStencilState(dssNoWrite.Get(), 0); // 깊이 쓰기만 끔
for (Renderable* o : blended)
drawObject(*o);
context->OMSetDepthStencilState(dssWrite.Get(), 0); // 다음 프레임 위해 원상복구
context->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);
OMSetBlendState(alphaBlend.Get(), ...) — 1절에서 만든 알파 오버
스테이트를 켭니다. 이제부터 그려지는 픽셀은 목적지와 섞입니다.
OMSetDepthStencilState(dssNoWrite.Get(), 0) — 이 스테이트의 핵심은
DepthEnable=TRUE(테스트는 함) + DepthWriteMask=ZERO(쓰지는 않음)입니다.
덕분에 불투명 물체 뒤의 유리 픽셀은 테스트로 폐기되지만, 통과한 유리는 깊이를 쓰지 않아
다음 유리를 가리지 않습니다. 정렬된 순서대로 겹겹이 올바르게 섞입니다.
for (Renderable* o : blended) drawObject(*o); — 포인터 벡터를
순회하며 역참조(*o)해서 그립니다. 배열이 이미 먼 것 → 가까운 것으로 정렬돼 있으니
이 루프가 곧 back-to-front 드로우입니다.
마지막 두 줄 원상복구가 매우 중요합니다. dssWrite(쓰기 ON)와
nullptr(블렌딩 OFF)로 되돌려, 다음 프레임의 불투명 패스가 정상 상태에서 시작하게 합니다.
DepthWriteMask = ZERO로 쓰기만 끄고 DepthEnable은
반드시 TRUE로 켜둬야 합니다. 테스트까지 꺼버리면 불투명 물체 뒤에 있어야 할 유리가
앞에 그려집니다. 그리고 루프가 끝나면 반드시 쓰기 ON 스테이트로 되돌리세요 —
안 그러면 다음 프레임의 불투명 물체가 깊이를 못 써서 깊이 버퍼가 망가집니다.
3-5. 깊이-스텐실 스테이트는 어떻게 만드나 (참고)
본문 코드는 dssWrite/dssNoWrite가 "미리 만들어져 있다"고 전제했습니다.
실제로는 블렌드 스테이트처럼 초기화 때 한 번 만들어 둡니다. 두 스테이트의 차이는
DepthWriteMask 한 줄뿐입니다.
ComPtr<ID3D11DepthStencilState> dssWrite, dssNoWrite;
D3D11_DEPTH_STENCIL_DESC dd = {};
dd.DepthEnable = TRUE; // 깊이 테스트 ON
dd.DepthFunc = D3D11_COMPARISON_LESS; // 더 가까우면 통과 (z 작을수록 가까움)
dd.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL; // 불투명: 깊이 쓰기 ON
device->CreateDepthStencilState(&dd, &dssWrite);
dd.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO; // 반투명: 테스트만, 쓰기 OFF
device->CreateDepthStencilState(&dd, &dssNoWrite);
DepthFunc = D3D11_COMPARISON_LESS는 "새 깊이가 저장된 깊이보다 작으면(더 가까우면)
통과"를 뜻합니다. D3D의 클립 z가 [0,1]이고 0이 근평면·1이 원평면이므로
"z가 작다 = 카메라에 가깝다"가 되어 LESS가 올바른 선택입니다.
두 스테이트는 DepthWriteMask만 ALL↔ZERO로 다릅니다.
4. 한계와 OIT
이 문제를 순서에 무관하게 푸는 기법이 OIT(Order-Independent Transparency)입니다. 실무에서 가장 흔한 Weighted Blended OIT는 각 반투명 픽셀의 색을 깊이 기반 가중치로 곱해 누적 버퍼에 더하고, 별도 버퍼에 커버리지(가중치 합)를 모은 뒤, 마지막에 색 합 ÷ 가중치 합으로 나눠 근사합니다. 정렬이 전혀 필요 없어 대량의 파티클·연기·식생에 특히 유용합니다(대신 물리적으로 정확하진 않고 근사입니다).
| 기법 | 정렬 필요? | 정확도 | 적합한 곳 |
|---|---|---|---|
| 2패스 + 정렬 (이 페이지) | 필요 (오브젝트 단위) | 오브젝트 간 정확, 내부 교차 부정확 | 유리창·소수의 큰 반투명 물체 |
| Weighted Blended OIT | 불필요 | 근사 (겹침 많으면 흐릿) | 대량 파티클·연기·식생 |
| Depth Peeling | 불필요 | 정확 (레이어 수만큼) | 정확도 우선·오프라인/여유 있는 씬 |
실무 팁: 대부분의 게임은 "큰 반투명(유리·물)은 2패스+정렬, 자잘한 반투명(파티클)은 additive 블렌딩 또는
Weighted OIT"로 혼합합니다. additive(SrcBlend=ONE, DestBlend=ONE)는
덧셈이라 순서 무관하고 불꽃·글로우처럼 밝아지기만 하는 효과에 자연스럽습니다.
5. DirectXMath / D3D11 API 대응 정리
| 이 페이지 코드 | 역할 | 대응 개념/수식 |
|---|---|---|
XMLoadFloat3(&v) | 저장 타입 → SIMD 벡터 로드 | XMFLOAT3 → XMVECTOR |
XMVectorSubtract(a,b) | 벡터 차 | d = a − b |
XMVector3LengthSq(d) | 길이 제곱 | dₓ²+dᵧ²+d_z² |
XMVectorGetX(v) | x 성분 스칼라 추출 | 4채널 중 하나 꺼내기 |
CreateBlendState | 알파 오버 스테이트 생성 | src·α + dst·(1−α) |
OMSetBlendState | 블렌딩 켜기/끄기 | Output Merger 단계 |
OMSetDepthStencilState | 깊이 테스트/쓰기 전환 | write ALL ↔ ZERO |
정리하면, 투명은 "블렌드 공식(무엇을 섞나) + 깊이 쓰기 제어(무엇을 남기나) + 정렬(어떤 순서로 섞나)" 세 축의 협업입니다. 세 가지 중 하나라도 어긋나면 유리가 검게 뜨거나, 뒤 물체가 사라지거나, 겹친 색이 이상해집니다. 이 페이지의 2패스 파이프라인은 그 세 축을 가장 단순하고 정확하게 묶는 표준 레시피입니다.