← 개념 · 데모 가이드 | 구현 허브 | 7장. 실전 렌더링 기법 | 투명과 렌더링 순서
IMPLEMENTATION · CHAPTER 7

투명과 렌더링 순서 — 한 줄 한 줄 직접 구현

유리·물·연기·불꽃처럼 뒤가 비치는 물체는 알파 블렌딩으로 그립니다. 그런데 블렌딩은 지금 화면(렌더 타깃)에 이미 그려져 있는 색과 새 색을 섞는 연산이라, 어떤 순서로 그리느냐에 따라 결과가 완전히 달라집니다. 이 페이지에서는 ① 알파 오버 블렌드 스테이트를 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%가 되어 색이 밝아지거나 어두워지지 않습니다.

blend_setup.cppcpp
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.a1 빼기 소스 알파D3D11_BLEND_INV_SRC_ALPHA
둘을 합치는 방법 = 더하기덧셈D3D11_BLEND_OP_ADD

INV_SRC_ALPHAINV는 "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, 오프스크린 합성) 알파가 어긋나지 않게 해줍니다.

주의 화면(백버퍼)에 곧장 그릴 때는 목적지 알파가 최종 출력에 거의 영향을 주지 않아 RGB만 신경 써도 대개 괜찮습니다. 하지만 오프스크린 텍스처에 그린 뒤 나중에 합성하는 파이프라인(포스트프로세스, UI 레이어)에서는 알파 공식을 틀리면 가장자리가 검게 뜨거나 이중 블렌딩이 됩니다. 위 설정(ONE / INV_SRC_ALPHA)이 그런 경우까지 안전한 표준값입니다.

RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;

이 마스크는 "블렌딩 결과 중 어떤 채널을 실제로 렌더 타깃에 쓸지"를 고르는 비트 플래그입니다. ENABLE_ALLR|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를 뜻합니다. 블렌딩과 깊이/스텐실이 여기서 처리됩니다. 세 인자를 하나씩 봅시다.

  1. alphaBlend.Get() — 방금 만든 스테이트 객체의 원시 포인터. ComPtr는 참조 카운트를 건드리지 않고 내부 포인터만 꺼내주는 Get()을 제공합니다. nullptr을 넣으면 "블렌딩 없음(불투명 덮어쓰기)"으로 되돌아갑니다.
  2. nullptr블렌드 팩터(float[4])입니다. D3D11_BLEND_BLEND_FACTOR 계수를 쓸 때만 필요하고, 우리는 SRC_ALPHA/INV_SRC_ALPHA만 쓰므로 필요 없어 nullptr입니다.
  3. 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를 얹을 때:

현실에서 "빛이 물체를 통과하는" 물리적으로 올바른 순서는 가장 먼 것부터 가까운 것으로 (back-to-front)입니다. 배경 → 먼 유리 → 가까운 유리 순으로 얹어야, 각 유리가 "자기 뒤에 이미 완성된 색" 위에 자기 색을 올바른 비율로 덧칠하게 됩니다. 그래서 반투명은 카메라에서 먼 것부터 정렬해서 그려야 합니다.

3. 2패스 렌더링 파이프라인

앞의 두 제약을 동시에 만족시키는 표준 전략이 2패스 + 정렬입니다. 큰 그림은 이렇습니다.

  1. 불투명 패스: 깊이 쓰기 ON, 블렌딩 OFF. 순서는 상관없이 다 그립니다 (깊이 테스트가 알아서 가림을 처리하고, 오히려 앞→뒤로 그리면 early-Z로 더 빠릅니다).
  2. 반투명 물체만 골라 카메라 거리로 정렬(먼 것 먼저)합니다.
  3. 반투명 패스: 깊이 테스트는 유지하되 깊이 쓰기는 끈 (DepthWriteMask = ZERO) 깊이-스텐실 스테이트로, 블렌딩 ON으로 정렬된 순서대로 그립니다.

핵심은 불투명 패스가 깊이 버퍼를 먼저 채워둔다는 점입니다. 그러면 반투명 패스는 그 깊이를 읽어서(테스트) 불투명 물체 뒤에 있는 유리 픽셀은 자동으로 폐기하되, 쓰지는 않아서(no-write) 반투명끼리는 서로를 지우지 않습니다.

two_pass_transparency.cppcpp
#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; ... };

한 개의 그릴 대상을 표현하는 최소 구조체입니다. positionXMFLOAT3 — float 3개(x,y,z)를 담는 저장용 타입입니다. DirectXMath에는 두 종류의 벡터 타입이 있는데, XMFLOAT3는 메모리에 그대로 두는 "저장 타입"이고, 실제 연산은 SIMD 레지스터에 올린 XMVECTOR로 합니다 (아래 XMLoadFloat3에서 이 변환이 나옵니다). transparent는 이 물체를 불투명 패스에서 그릴지 반투명 패스에서 그릴지 가르는 플래그입니다.

void renderScene(std::vector<Renderable>& objects, XMFLOAT3 camPos)

시그니처 해부:

3-2. 패스 1 — 불투명

패스 1 발췌cpp
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. 반투명 골라내기와 정렬

정렬 발췌cpp
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을 복사 없이 그대로 씁니다. 내부 계산을 한 줄씩 봅시다.

  1. XMLoadFloat3(&o->position) — 물체 위치를 XMVECTOR로 로드.
  2. XMVectorSubtract(위치, cam)차 벡터 d = position − cam. 카메라에서 물체로 향하는 방향과 거리를 담습니다.
  3. XMVector3LengthSq(d)길이의 제곱 d·d = dₓ² + dᵧ² + d_z². 결과는 4채널에 같은 값이 복제된 XMVECTOR입니다.
  4. 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); }는 "ab보다 앞에 와야 하면 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 — 반투명

패스 2 발췌cpp
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 한 줄뿐입니다.

depth_states.cppcpp
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가 올바른 선택입니다. 두 스테이트는 DepthWriteMaskALLZERO로 다릅니다.

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 벡터 로드XMFLOAT3XMVECTOR
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패스 파이프라인은 그 세 축을 가장 단순하고 정확하게 묶는 표준 레시피입니다.