← 개념 · 데모 가이드 | 구현 허브 | 5장. 그래픽스 API | 디버깅과 검증
IMPLEMENTATION · CHAPTER 5

디버깅과 검증 — 한 줄 한 줄 직접 구현

명시적 API의 대가는 "잘못된 DESC·바인딩도 대개 조용히 무시되고, 몇 프레임 뒤 엉뚱한 곳에서 검은 화면으로만 드러난다"는 점입니다. D3D11에서 삼각형이 안 나올 때 원인은 셰이더 컴파일 실패일 수도, 인풋 레이아웃 불일치일 수도, 렌더 타깃 미바인딩일 수도, 깊이 테스트 설정 오류일 수도 있는데, 화면은 똑같이 새까맣게만 나옵니다. 그래서 D3D11 프로그래밍의 절반은 "조용한 실패를 시끄러운 실패로 바꾸는 장치를 미리 깔아두는 일"입니다. 이 페이지에서는 세 겹의 방어선 — ① 반환값(HRESULT)을 즉시 검사하는 매크로, ② 런타임이 모든 호출을 검증해주는 디버그 레이어 + InfoQueue, ③ 한 프레임을 통째로 뜯어보는 PIX·RenderDoc 프레임 캡처 — 를 코드 한 줄, 인자 하나까지 해부합니다.

0. 왜 디버깅이 "장치를 미리 까는 일"인가

D3D11은 성능을 위해 낙관적으로 동작합니다. 여러분이 CreateBuffer에 말이 안 되는 BindFlags를 넘겨도, 드라이버는 매 호출마다 "이 값이 정말 유효한가?"를 꼼꼼히 검사하며 시간을 낭비하지 않습니다. 유효성 검사는 비싸니까요. 대신 반환값(HRESULT)에 실패 코드를 담아 돌려줄 뿐이고, 그마저 여러분이 확인하지 않으면 그냥 지나갑니다. 결과적으로 잘못된 리소스가 파이프라인에 꽂히고, 몇 개의 드로우 콜을 지나 완전히 다른 위치에서 화면이 검게 나옵니다. "증상이 나타난 곳"과 "원인이 있는 곳"이 멀리 떨어져 있는 것 — 이게 그래픽스 디버깅이 어려운 근본 이유입니다.

해결 전략은 항상 같습니다. 실패를 원인 지점에 최대한 가깝게, 최대한 시끄럽게 만드는 것. 아래 세 방어선은 정확히 그 순서대로 "실패와 원인 사이의 거리"를 좁혀 나갑니다.

  1. 1층 — HRESULT 확인: 함수가 실패를 반환한 바로 그 순간 파일·줄 번호와 함께 잡는다. "어느 호출이" 실패했는지 알아낸다.
  2. 2층 — 디버그 레이어 + InfoQueue: 런타임이 "" 실패했는지 사람이 읽을 수 있는 문장으로 알려주고, 그 호출에서 디버거를 멈춰 콜스택을 보여준다.
  3. 3층 — 프레임 캡처(PIX·RenderDoc): 코드가 성공했는데도 화면이 이상할 때, GPU에 실제로 올라간 리소스·셰이더·픽셀을 눈으로 확인한다.

1층은 "실패한 함수"를, 2층은 "실패한 이유"를, 3층은 "성공했지만 결과가 틀린 이유"를 잡습니다. 세 층은 대체재가 아니라 보완재입니다.

1. 기본 방어선 — HRESULT 확인

D3D11의 거의 모든 생성·매핑 함수(CreateBuffer, CreateTexture2D, CreateInputLayout, Map, D3D11CreateDevice …)는 HRESULT라는 32비트 정수를 반환합니다. 이름은 "Handle to a RESULT"에서 왔지만, 실제로는 성공/실패와 상세 오류 코드를 함께 담은 비트필드입니다. 최상위 비트(bit 31)가 1이면 실패, 0이면 성공을 뜻합니다.

문제는 왜 실패했는지 코드만 봐서는 알기 어렵다는 것입니다. 0x80070057이라는 숫자를 받아도 그게 "E_INVALIDARG(인자가 틀림)"라는 걸 외우고 있어야 하죠. 그래서 실무 전략은 이렇습니다 — "실패했다"는 사실은 매크로로 즉시·자동으로 잡고, "왜 실패했나"라는 상세 이유는 다음 절의 디버그 레이어에 맡긴다. 아래 매크로는 그 1층 방어선입니다.

hr_check.cppcpp
// 호출 직후 HRESULT를 검사하는 매크로 — 실패하면 파일·줄과 함께 즉시 중단
#define HR_CHECK(call) do {                                      \
    HRESULT _hr = (call);                                        \
    if (FAILED(_hr))                                             \
        std::fprintf(stderr, "[D3D] 0x%08lX @ %s:%d -> %s\n",   \
                     (unsigned long)_hr, __FILE__, __LINE__, #call); \
} while (0)

// 사용 예
HR_CHECK(device->CreateBuffer(&vbd, &vinit, &vbo));

1-1. 매크로의 골격 — do { ... } while (0)가 왜 필요한가

#define은 함수가 아니라 텍스트 치환입니다. 컴파일러가 코드를 읽기 전에 전처리기가 HR_CHECK(...)라는 글자를 정의된 본문으로 그대로 갈아끼웁니다. 그래서 매크로 본문을 어떻게 감싸느냐가 중요합니다. 만약 본문을 그냥 { ... } 블록으로만 두면 다음처럼 뒤에 세미콜론을 붙이는 자연스러운 사용에서 문제가 생깁니다.

매크로가 왜 do-while(0)로 감싸이는가cpp
// 만약 매크로가 그냥 { ... } 라면, 이렇게 쓸 때
if (조건)
    HR_CHECK(foo());   // 치환 후: if (조건) { ... };  ← 뒤에 세미콜론이 남는다
else
    bar();             // 그 세미콜론이 if를 "끝내버려" else가 붕 떠서 컴파일 에러

// do { ... } while (0) 로 감싸면
if (조건)
    HR_CHECK(foo());   // 치환 후: if (조건) do { ... } while (0);  ← 세미콜론이 while을 정상 종결
else
    bar();             // else가 if에 제대로 붙는다

do { ... } while (0)"단 한 번만 실행되는 블록"인데, 이 관용구의 목적은 반복이 아닙니다. 목적은 오직 "매크로 본문 전체를 하나의 문(statement)으로 만들고, 사용처에서 붙이는 세미콜론이 자연스럽게 소화되게 하는 것"입니다. while (0)이므로 조건이 거짓이라 절대 반복하지 않고, 최적화 컴파일러는 루프를 통째로 제거합니다 — 런타임 비용은 0입니다.

1-2. 줄별 해부

코드 조각의미
HRESULT _hr = (call);매크로 인자 call(예: device->CreateBuffer(...))을 딱 한 번만 실행하고 그 반환값을 _hr에 저장. 괄호 (call)로 감싸 연산자 우선순위 사고를 막는다.
if (FAILED(_hr))FAILED는 Windows SDK 매크로로, 내부적으로 ((HRESULT)(hr) < 0) — 즉 최상위 비트(부호 비트)가 켜졌는지 검사. 실패면 참.
0x%08lXHRESULT를 8자리 대문자 16진수, 앞을 0으로 채워 출력. llong 크기 지정. 예: 0x80070057.
__FILE__, __LINE__전처리기가 채워주는 현재 소스 파일 경로와 줄 번호. 실패 지점을 정확히 짚어준다.
#call문자열화(stringize) 연산자. 매크로 인자 call을 그 소스 텍스트 그대로 문자열로 바꾼다. 즉 "device->CreateBuffer(&vbd, &vinit, &vbo)"가 로그에 그대로 찍힌다.

정리하면 이 한 줄 HR_CHECK(device->CreateBuffer(&vbd, &vinit, &vbo));이 실패하면 출력창에 이렇게 찍힙니다:

실패 시 출력 예text
[D3D] 0x80070057 @ C:\proj\mesh.cpp:142 -> device->CreateBuffer(&vbd, &vinit, &vbo)

숫자(0x80070057 = E_INVALIDARG) + 파일:줄(mesh.cpp:142) + 실패한 호출 원문까지 한 줄에 모두 들어있습니다. "어느 함수가 실패했나?"라는 첫 번째 질문이 즉시 해결됩니다.

함정 매크로 인자는 반드시 딱 한 번만 실행되어야 합니다. 이 매크로가 _hr = (call)로 결과를 변수에 저장한 뒤 그 변수만 검사하는 이유가 여기 있습니다. 만약 매크로 본문에서 call을 두 번 써서 if (FAILED(call)) ... print(call)처럼 만들면, CreateBuffer두 번 호출되어 버퍼가 두 개 생성됩니다(누수!). "인자를 한 번만 평가하라"는 것은 매크로 작성의 철칙입니다.
함정 로그만 찍고 실행을 계속하면, 실패한 vbonullptr인 채로 다음 드로우 콜까지 흘러가 결국 다른 곳에서 크래시합니다. 학습·디버그 빌드에서는 실패 시 assert(false)DebugBreak()즉시 멈추는 편이 원인 추적에 훨씬 유리합니다. (아래 InfoQueue의 SetBreakOnSeverity가 이 역할을 자동으로 해줍니다.)
성능 HR_CHECK 자체는 실패했을 때만 fprintf를 부르므로 성공 경로에서는 사실상 비교 한 번이 전부입니다. 다만 렌더 루프 안쪽(매 프레임 수천 번 호출되는 Map 등)에 무겁게 두는 것은 피하고, 생성·초기화처럼 프레임당 몇 번 안 부르는 경로에 쓰는 것이 이상적입니다.

2. 모던 방어선 — 디버그 레이어 + InfoQueue

1층 매크로는 "어느 호출이 실패했나"를 알려주지만, ""는 0x80070057 같은 숫자로만 말합니다. D3D11은 이보다 훨씬 친절한 수단을 내장하고 있습니다 — 디버그 레이어(Debug Layer)입니다. 디바이스를 D3D11_CREATE_DEVICE_DEBUG 플래그로 만들면, 런타임이 모든 API 호출을 가로채 검증하고 "이 DESC의 BindFlags가 이 Usage와 호환되지 않습니다", "슬롯 0에 nullptr SRV가 바인딩됐습니다" 같은 사람이 읽을 수 있는 문장을 디버거 출력창에 찍어줍니다.

한 걸음 더 나아가 ID3D11InfoQueue 인터페이스를 얻으면, 이 메시지들을 심각도(severity)별로 필터링하고, 특정 심각도가 발생하면 디버거를 그 자리에서 멈추게(break) 할 수 있습니다. 그러면 콜스택을 타고 올라가 정확한 범인 줄을 짚을 수 있죠. 코드는 두 부분으로 나뉩니다 — ① 디바이스 생성 시점에 디버그 플래그를 켜는 함수, ② 생성 후에 InfoQueue를 설정하는 함수.

d3d_debug.cppcpp
#include <d3d11.h>
#include <wrl/client.h>
#include <cstdio>
using Microsoft::WRL::ComPtr;

// 디바이스 생성 "시점"에 디버그 플래그를 켜야 한다
HRESULT createDebugDevice(ComPtr<ID3D11Device>& device,
                          ComPtr<ID3D11DeviceContext>& context) {
    UINT flags = 0;
#if defined(_DEBUG)
    flags |= D3D11_CREATE_DEVICE_DEBUG;   // 릴리스에선 자동으로 빠짐
#endif
    D3D_FEATURE_LEVEL fl = D3D_FEATURE_LEVEL_11_0;
    return D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags,
                             &fl, 1, D3D11_SDK_VERSION,
                             &device, nullptr, &context);
}

// 디바이스 생성 "후"에 InfoQueue로 심각도별 브레이크·필터를 건다
void installInfoQueue(ID3D11Device* device) {
    ComPtr<ID3D11InfoQueue> iq;
    if (FAILED(device->QueryInterface(IID_PPV_ARGS(&iq)))) return; // 디버그 레이어 없음

    // 오류/경고가 나면 디버거에서 즉시 멈추게 — 콜스택으로 범인을 짚는다
    iq->SetBreakOnSeverity(D3D11_MESSAGE_SEVERITY_ERROR,   TRUE);
    iq->SetBreakOnSeverity(D3D11_MESSAGE_SEVERITY_CORRUPTION, TRUE);
    iq->SetBreakOnSeverity(D3D11_MESSAGE_SEVERITY_WARNING, TRUE);

    // 무해한 알림성 메시지는 걸러 소음을 줄인다
    D3D11_MESSAGE_SEVERITY deny[] = { D3D11_MESSAGE_SEVERITY_INFO };
    D3D11_INFO_QUEUE_FILTER filter{};
    filter.DenyList.NumSeverities = 1;
    filter.DenyList.pSeverityList = deny;
    iq->AddStorageFilterEntries(&filter);
}

2-1. 인클루드와 using — 무대를 세우는 줄들

맨 위 네 줄은 이 코드가 무엇에 의존하는지 선언합니다. 하나씩 볼게요.

2-2. createDebugDevice — 디버그 플래그는 "생성 시점"에만 켤 수 있다

가장 중요한 사실부터 짚습니다. 디버그 레이어는 디바이스를 만드는 그 순간에만 활성화할 수 있습니다. 이미 만든 디바이스에 나중에 "디버그 모드 켜기"를 할 수는 없습니다. 그래서 이 함수가 D3D11CreateDevice 호출을 감싸고 있는 것이죠.

시그니처 해부

시그니처cpp
HRESULT createDebugDevice(ComPtr<ID3D11Device>& device,
                          ComPtr<ID3D11DeviceContext>& context)

만약 & 없이 값으로 받았다면(ComPtr<ID3D11Device> device), 함수 안에서 채운 값은 함수 지역 복사본에만 담기고 함수가 끝나는 순간 사라져 호출자는 여전히 빈 device를 갖게 됩니다. 그래서 출력 파라미터에는 참조 &가 필수입니다.

본문 한 줄씩

의미
UINT flags = 0;디바이스 생성 옵션 비트필드. 기본은 아무 플래그 없음(0).
#if defined(_DEBUG)전처리 조건부 컴파일. 디버그 빌드에서만 정의되는 _DEBUG 매크로가 있을 때만 다음 줄을 포함시킨다.
flags |= D3D11_CREATE_DEVICE_DEBUG;|=비트 OR 대입 — 기존 flags에 디버그 비트를 추가로 켠다. 다른 플래그를 지우지 않고 합쳐진다.
#endif조건부 블록 종료. 즉 릴리스 빌드에선 이 세 줄이 통째로 코드에서 사라진다 — 검증 비용이 0이 된다.
D3D_FEATURE_LEVEL fl = ..._11_0;요구할 기능 레벨(하드웨어 능력 등급). 여기선 D3D11.0을 요구.
return D3D11CreateDevice(...);실제 생성. 아래에서 인자를 하나씩 본다.

D3D11CreateDevice의 11개 인자 — 하나씩

인자의미
pAdapternullptr어떤 GPU를 쓸지. nullptr이면 기본 어댑터(주 GPU) 자동 선택.
DriverTypeD3D_DRIVER_TYPE_HARDWARE실제 GPU 하드웨어 드라이버 사용(가장 빠름). WARP는 소프트웨어 대체.
Softwarenullptr소프트웨어 래스터라이저 DLL. HARDWARE일 땐 반드시 nullptr.
Flagsflags여기가 핵심. 위에서 조립한 D3D11_CREATE_DEVICE_DEBUG 비트가 이 자리로 전달돼 디버그 레이어가 켜진다.
pFeatureLevels&fl요구 기능 레벨 배열의 시작 주소.
FeatureLevels1그 배열의 원소 개수(여기선 1개).
SDKVersionD3D11_SDK_VERSIONSDK 버전 상수. 항상 이 매크로를 그대로 넘긴다.
ppDevice&device생성된 디바이스를 받을 곳. ComPtr&를 쓰면 내부 포인터의 주소가 넘어가 out 파라미터로 채워진다.
pFeatureLevelnullptr실제로 얻은 기능 레벨을 돌려받을 곳. 관심 없으면 nullptr.
ppImmediateContext&context즉시 컨텍스트를 받을 곳.
함정 D3D11_CREATE_DEVICE_DEBUG로 생성이 실패하면(반환 DXGI_ERROR_SDK_COMPONENT_MISSING 등), 그건 대개 "그래픽스 도구(Graphics Tools)" 선택적 기능이 Windows에 설치되지 않은 것입니다. 설정 → 앱 → 선택적 기능에서 "그래픽스 도구"를 추가하면 됩니다. 릴리스 배포 PC에는 이게 없으므로, #if defined(_DEBUG)로 감싸 디버그 빌드에서만 켜는 이 패턴이 안전합니다.

2-3. installInfoQueue — "왜"를 문장으로, 그리고 브레이크

디버그 레이어를 켠 것만으로도 오류 메시지가 출력창에 찍히기 시작합니다. installInfoQueue는 여기에 두 가지를 더합니다 — ① 특정 심각도에서 디버거를 멈추게 하고, ② 시끄러운 정보성 메시지를 걸러 신호 대 잡음비를 높입니다.

QueryInterfaceIID_PPV_ARGS — COM에서 "다른 얼굴" 꺼내기

InfoQueue 획득cpp
ComPtr<ID3D11InfoQueue> iq;
if (FAILED(device->QueryInterface(IID_PPV_ARGS(&iq)))) return; // 디버그 레이어 없음

COM 객체 하나는 여러 인터페이스(얼굴)를 가질 수 있습니다. 우리가 가진 ID3D11Device는 "리소스를 만드는 얼굴"이지만, 같은 객체가 디버그 레이어가 켜져 있을 때는 ID3D11InfoQueue라는 "진단 메시지 큐 얼굴"도 갖습니다. QueryInterface"이 객체에게 저 얼굴이 있으면 그 포인터를 달라"고 요청하는 함수입니다.

SetBreakOnSeverity — 실패한 그 자리에서 멈추기

심각도별 브레이크cpp
iq->SetBreakOnSeverity(D3D11_MESSAGE_SEVERITY_ERROR,      TRUE);
iq->SetBreakOnSeverity(D3D11_MESSAGE_SEVERITY_CORRUPTION, TRUE);
iq->SetBreakOnSeverity(D3D11_MESSAGE_SEVERITY_WARNING,    TRUE);

각 호출은 "이 심각도의 메시지가 발생하면 DebugBreak()처럼 디버거를 그 순간 멈춰라"라고 지시합니다. 심각도는 위에서 아래로 갈수록 덜 치명적입니다.

심각도의미
CORRUPTION메모리·상태가 이미 망가짐. 가장 치명적.이미 해제된 리소스를 바인딩.
ERRORAPI 사용 규칙 위반. 이 호출은 무시되거나 실패.호환 안 되는 BindFlags.
WARNING동작은 하지만 의도와 다를 가능성.SRV가 렌더 타깃과 동시에 바인딩.
INFO / MESSAGE단순 알림. 대개 무해.리소스 생성됨 같은 로그.

기하 직관(디버깅 직관): 로그를 나중에 스크롤하며 "몇 번째 메시지가 진짜 원인이었지?"를 찾는 대신, 브레이크를 걸어두면 프로그램이 범행 현장에서 얼어붙습니다. 디버거의 콜스택(Call Stack) 창을 보면 "이 오류를 유발한 함수 → 그 함수를 부른 함수 → …" 하고 여러분의 코드까지 화살표가 그려집니다. 그 화살표 끝이 범인 줄입니다.

함정 WARNING까지 브레이크를 거는 것은 개발 초기엔 유용하지만, 일부 무해한 경고(예: 드라이버별 관용 경고)에서도 멈춰 짜증날 수 있습니다. 그럴 땐 WARNINGFALSE로 두고 ERROR·CORRUPTIONTRUE로 남기세요. "일단 다 켜고, 무해하다고 확인된 것만 하나씩 끈다"가 안전한 순서입니다.

필터 — 무해한 소음 걸러내기

정보성 메시지 억제cpp
D3D11_MESSAGE_SEVERITY deny[] = { D3D11_MESSAGE_SEVERITY_INFO };
D3D11_INFO_QUEUE_FILTER filter{};
filter.DenyList.NumSeverities = 1;
filter.DenyList.pSeverityList = deny;
iq->AddStorageFilterEntries(&filter);
의미
D3D11_MESSAGE_SEVERITY deny[] = { ...INFO };차단할 심각도 목록. 여기선 INFO(정보성) 하나만.
D3D11_INFO_QUEUE_FILTER filter{};필터 구조체를 {}로 값 초기화 — 모든 필드가 0/nullptr로 깨끗이 시작. 쓰레기값으로 인한 오동작 방지.
filter.DenyList.NumSeverities = 1;거부 목록에 심각도가 1개 있다고 알림. 배열 길이와 반드시 일치해야 함.
filter.DenyList.pSeverityList = deny;그 심각도 배열의 시작 주소를 연결.
iq->AddStorageFilterEntries(&filter);이 필터를 저장 필터(storage filter)에 추가 — 이후 INFO 메시지는 큐에 쌓이지도 않는다.

D3D11_INFO_QUEUE_FILTERAllowList(허용)와 DenyList(거부) 두 하위 구조체를 갖습니다. 여기서는 거부 목록만 채워 "INFO 심각도는 버려라"라고 지시합니다. 그러면 진짜 봐야 할 ERROR·WARNING만 남아 로그가 읽을 만해집니다.

TIP SetBreakOnSeverity를 켜면 오류를 일으킨 바로 그 호출에서 디버거가 멈춥니다. 콜스택으로 정확한 범인 줄을 짚을 수 있죠. 단 디버그 레이어는 검증 비용이 붙습니다 — 모든 API 호출을 가로채 검사하므로 프레임 시간이 눈에 띄게 늘어날 수 있습니다. 그래서 릴리스 빌드에서는 반드시 D3D11_CREATE_DEVICE_DEBUG를 빼야 합니다(위 #if defined(_DEBUG) 패턴이 이를 자동화합니다).
주의 호출 순서를 지키세요. createDebugDevice(디버그 플래그로 생성) → installInfoQueue(InfoQueue 설정) 순입니다. 디버그 플래그 없이 만든 디바이스에서는 QueryInterface(IID_PPV_ARGS(&iq))가 실패해 InfoQueue를 못 얻습니다. 즉 디버그 레이어가 켜져 있어야 InfoQueue라는 "얼굴"이 생깁니다.

3. 최종 방어선 — 프레임 캡처: PIX · RenderDoc

1층·2층은 API를 잘못 썼을 때를 잡습니다. 하지만 모든 호출이 성공했는데도(즉 HRESULTS_OK, 디버그 레이어도 조용한데도) 화면이 이상한 경우가 있습니다 — 텍스처가 엉뚱하게 나오거나, UV가 위아래로 뒤집혀 있거나, 깊이가 엉켜 뒷면이 앞을 가리는 등. 이건 "코드는 문법적으로 옳은데 데이터·논리가 틀린" 부류라, 코드만 봐서는 안 잡힙니다. 이럴 때 쓰는 것이 프레임 캡처 도구입니다.

사용법은 개념적으로 같습니다. 도구로 실행 파일을 띄우고, 문제가 보이는 순간 한 프레임을 캡처합니다. 그러면 그 프레임의 모든 드로우 콜, 각 드로우에 바인딩된 리소스(버퍼·텍스처·상태), 파이프라인 각 단계(IA→VS→RS→PS→OM)의 입출력, 셰이더 소스와 그 실행 결과를 클릭 한 번으로 뜯어볼 수 있습니다. "지금 GPU에 실제로 뭐가 올라가 있나?"에 대한 최종 진실입니다.

예를 들어 UV가 뒤집혀 보인다면, 캡처에서 해당 드로우의 텍스처 미리보기픽셀 셰이더 입력 UV를 직접 확인해 "샘플링 좌표 V가 0~1이 아니라 1~0으로 들어오는구나 → 정점 데이터의 V를 뒤집어야겠다"처럼 눈으로 원인을 특정할 수 있습니다. (우리 규약은 텍스처 V가 위→아래로 증가합니다.)

3-1. 캡처를 읽기 쉽게 — 디버그 마커와 리소스 이름

캡처를 열면 드로우 콜이 Draw #0, Draw #1, …처럼 번호로만 나열됩니다. 프로젝트가 커지면 어느 게 그림자 패스이고 어느 게 UI인지 알 수 없죠. 그래서 코드에 미리 라벨과 이름을 심어두면 캡처 화면이 "GBuffer Pass", "Shadow Pass" 같은 사람이 읽는 트리로 정리됩니다. 두 가지 도구가 있습니다.

드로우 묶음에 라벨 — ID3DUserDefinedAnnotation

debug_markers.cppcpp
#include <d3d11_1.h>   // ID3DUserDefinedAnnotation
#include <wrl/client.h>
using Microsoft::WRL::ComPtr;

// 컨텍스트에서 "주석(annotation)" 얼굴을 얻는다 (1회만)
ComPtr<ID3DUserDefinedAnnotation> anno;
context->QueryInterface(IID_PPV_ARGS(&anno));

// 드로우 묶음을 라벨로 감싼다
anno->BeginEvent(L"GBuffer Pass");   // ← 캡처 트리에 이 이름으로 그룹이 생김
    context->DrawIndexed(idxCount, 0, 0);
    context->DrawIndexed(idxCount2, 0, 0);
anno->EndEvent();                    // 그룹 닫기 (Begin/End 짝을 반드시 맞출 것)
요소의미
<d3d11_1.h>ID3DUserDefinedAnnotation은 D3D11.1에서 추가돼 이 헤더에 있습니다.
QueryInterface(IID_PPV_ARGS(&anno))2절과 같은 패턴 — 컨텍스트의 "주석 얼굴"을 얻습니다.
BeginEvent(L"...")여기부터의 드로우들을 하나의 그룹으로 묶기 시작. 인자는 와이드 문자열(L"...")이라 L 접두사가 필수입니다.
EndEvent()그룹 닫기. BeginEvent와 개수가 정확히 짝지어져야 캡처 트리가 올바르게 중첩됩니다.

기하/도구 직관: Begin/EndEvent는 캡처 화면에서 폴더처럼 접히는 트리 노드를 만듭니다. 중첩도 됩니다 — "Frame → Shadow Pass → Cascade 0" 식으로 감싸면 수백 개의 드로우 콜이 의미 단위로 접혀, 원하는 패스만 펼쳐 볼 수 있습니다.

객체에 이름 — SetPrivateData + WKPDID_D3DDebugObjectName

naming.cppcpp
// 리소스에 사람이 읽을 이름을 붙여 캡처에서 식별하기 쉽게 만든다
const char name[] = "AlbedoTexture";
tex->SetPrivateData(WKPDID_D3DDebugObjectName,
                    (UINT)(sizeof(name) - 1),  // 널 종료 문자 제외한 길이
                    name);
인자의미
WKPDID_D3DDebugObjectName"이 private data는 디버그용 이름이다"라고 알려주는 특수 GUID. 도구가 이 키로 저장된 문자열을 이름으로 표시합니다.
sizeof(name) - 1바이트 길이. "AlbedoTexture"는 널 종료(\0)를 포함해 14바이트라, -1널 문자를 빼 13바이트를 넘깁니다.
name실제 이름 데이터의 포인터.

SetPrivateDataID3D11DeviceChild(버퍼·텍스처·셰이더 등 거의 모든 D3D11 객체의 공통 조상)에 있어, 어떤 리소스에든 이름을 붙일 수 있습니다. 이름을 붙여두면 디버그 레이어의 오류 메시지에도 "AlbedoTexture에서 문제 발생"처럼 이름이 함께 찍혀 2절의 진단까지 훨씬 명료해집니다.

실행 중인 GPU 이름 확인 — IDXGIAdapter::GetDesc

gpu_name.cppcpp
#include <dxgi.h>
// device → DXGI 디바이스 → 어댑터로 거슬러 올라가 GPU 설명을 읽는다
ComPtr<IDXGIDevice> dxgiDev;
device->QueryInterface(IID_PPV_ARGS(&dxgiDev));

ComPtr<IDXGIAdapter> adapter;
dxgiDev->GetAdapter(&adapter);

DXGI_ADAPTER_DESC desc{};
adapter->GetDesc(&desc);
// desc.Description 은 와이드 문자열: 예) L"NVIDIA GeForce RTX 4070"

실행 중인 하드웨어를 로그에 남겨두면, "특정 GPU에서만 재현되는 버그"를 추적할 때 결정적 단서가 됩니다. 경로는 ID3D11DeviceIDXGIDeviceIDXGIAdapter 순으로 거슬러 올라가며(모두 QueryInterface/GetAdapter), 마지막 GetDesc로 채운 DXGI_ADAPTER_DESC.Description이 바로 "NVIDIA GeForce RTX 4070" 같은 사람이 읽는 GPU 이름(와이드 문자열)입니다.

주의 캡처를 편하게 보려면 디버그 마커·리소스 이름을 남겨두는 습관이 큰 차이를 만듭니다. BeginEvent/EndEvent로 드로우 묶음에 라벨을, SetPrivateData(WKPDID_D3DDebugObjectName)로 객체에 이름을 붙이면 캡처 화면이 "GBuffer Pass" 같은 라벨로 정리돼 탐색이 훨씬 빨라집니다. 다만 이 마커들도 약간의 오버헤드가 있으니, 성능이 극도로 중요한 릴리스 경로에서는 매크로로 감싸 디버그 빌드에서만 켜는 것을 고려하세요.
성능 프레임 캡처는 드로우 콜 수·리소스 크기에 비례해 무거워집니다. 문제 상황을 가능한 한 작은 씬으로 재현한 뒤 캡처하면 분석이 빨라지고, 캡처 파일 크기도 관리 가능해집니다. "버그를 최소 재현 케이스로 좁힌 뒤 캡처"가 정석입니다.

4. 정리 — 세 방어선을 언제 쓰나

증상먼저 볼 방어선이유
생성 함수가 실패(nullptr 리소스)1층 HR_CHECK어느 호출이 실패했는지 파일·줄로 즉시 특정.
실패 이유를 모르겠다2층 디버그 레이어"왜"를 사람이 읽는 문장으로 설명 + 브레이크로 콜스택 확인.
모든 호출은 성공인데 화면이 이상3층 프레임 캡처GPU에 올라간 실제 데이터·픽셀을 눈으로 검증.

순서는 항상 싼 것 → 비싼 것입니다. 반환값 검사(1층)는 공짜에 가깝고, 디버그 레이어(2층)는 개발 빌드 한정으로 켜고, 프레임 캡처(3층)는 앞의 둘로 안 잡히는 시각적 버그에만 꺼내 씁니다. 이 세 겹을 미리 깔아두는 것이, "몇 프레임 뒤 엉뚱한 검은 화면"이라는 명시적 API의 대가를 감당 가능한 수준으로 낮추는 실무의 핵심입니다.

실무 권장 직접 구현한 HR_CHECK 매크로는 학습·이해용으로 훌륭하지만, 실무에서는 DirectXTK의 ThrowIfFailed나 검증된 헬퍼를 쓰는 편이 안전합니다. 마찬가지로 GPU 수학은 직접 짜기보다 DirectXMath를, 캡처는 검증된 PIX·RenderDoc을 쓰는 것이 정석입니다. 직접 구현은 "내부가 어떻게 도는지"를 아는 데 의미가 있고, 프로덕션 코드는 검증된 라이브러리 위에 세우는 것이 원칙입니다.