셰이더 만들기 (재사용 헬퍼) — 한 줄 한 줄 직접 구현
GPU에게 "이렇게 그려라"라고 시키려면, 우리가 쓴 HLSL 소스 코드(사람이 읽는 텍스트)를
GPU가 실제로 실행할 수 있는 셰이더 객체로 바꿔야 합니다. 이 변환은 두 단계입니다 —
먼저 D3DCompile로 소스를 바이트코드로 컴파일하고, 그 바이트코드로
CreateVertexShader/CreatePixelShader를 불러 스테이지별 객체를 만듭니다.
이 과정은 셰이더를 만들 때마다 똑같이 반복되고, 컴파일 에러 로그를 놓치면 디버깅이 지옥이 되므로,
이 페이지에서는 이 반복을 작은 재사용 헬퍼(compileShader 함수, Shader 클래스,
상수 버퍼 헬퍼 3종)로 묶어 두는 코드를 한 줄, 인자 하나, API 호출 하나까지 해부합니다.
v' = v * M(HLSL에서는 정점을 왼쪽에 두는
mul(v, M)), 행 우선(row-major) 저장, 왼손 좌표계, 클립 공간 z 범위
[0, 1], 텍스처 V축은 위→아래. 이 페이지의 헬퍼 자체는 행렬 규약과 직접 관련이
없지만, 여기서 만든 상수 버퍼에 행렬을 실어 보낼 때는 CPU에서 미리 transpose해서
올리는 것이 이 시리즈의 관례입니다(그 이유는 아래 상수 버퍼 절에서 다시 짚습니다). 실무에서는 DirectXMath 같은
검증된 라이브러리를 쓰는 것을 권장하며, 여기서 직접 만드는 헬퍼는 내부 동작을 이해하기 위한 학습용
입니다.
0. 무엇을, 왜 만드나 — 전체 그림
셰이더 하나를 화면에 쓰기까지의 길은 언제나 똑같습니다. 텍스트 → 바이트코드 → 객체 → 바인딩. 이 네 걸음을 그림으로 그려 보면 이렇습니다.
- 컴파일:
D3DCompile이 HLSL 텍스트를 읽어, GPU에 가까운 중간 표현인 바이트코드(ID3DBlob)로 바꿉니다. 실패하면 사람이 읽을 에러 메시지가 별도의 에러 blob에 담깁니다 — 이걸 꼭 찍어야 원인을 압니다. - 객체 생성: 컴파일된 바이트코드로
device->CreateVertexShader/CreatePixelShader를 불러 스테이지별 셰이더 객체 (ID3D11VertexShader,ID3D11PixelShader)를 만듭니다. - blob 보관: 버텍스 셰이더의 바이트코드(blob)는 나중에
입력 레이아웃(
ID3D11InputLayout) 생성에도 재사용되므로, 버리지 말고 멤버로 들고 있습니다. - 상수 전달: OpenGL의 유니폼(uniform) 대신, D3D11은 상수 버퍼(cbuffer)를 씁니다.
C++ 구조체를
Map/Unmap으로 통째로 갱신해VSSetConstantBuffers/PSSetConstantBuffers로 GPU에 넘깁니다.
한 가지 OpenGL 경험자가 헷갈리는 점: D3D11에는 OpenGL의 "프로그램 링크(link)" 단계가
없습니다. OpenGL은 여러 셰이더를 하나의 프로그램 객체로 링크해 스테이지 간 변수 이름을 맞추지만, D3D11은
스테이지 사이의 연결을 시맨틱(semantic)(예: SV_Position, TEXCOORD0)과
입력 레이아웃으로 처리합니다. 그래서 우리의 Shader 클래스도 "링크"에 해당하는 코드가
전혀 없고, VS와 PS를 그냥 각각 만들어 들고 있기만 하면 됩니다.
1. 헤더와 using — 준비물부터
본격적인 코드에 들어가기 전에, 파일 맨 위의 #include와 using 줄이 각각 무엇을 끌어오는지
먼저 짚겠습니다. 이 줄들이 없으면 아래의 ComPtr, D3DCompile, XMFLOAT4X4 같은
이름을 컴파일러가 알아보지 못합니다.
#pragma once
#include <d3d11.h>
#include <d3dcompiler.h> // D3DCompile
#include <wrl/client.h> // Microsoft::WRL::ComPtr
#include <DirectXMath.h>
#include <string>
#include <cstdio>
#include <cassert>
using Microsoft::WRL::ComPtr;
using namespace DirectX;
줄별 해부
#pragma once— 이 헤더가 여러.cpp에서#include돼도 딱 한 번만 실제로 펼쳐지도록 하는 지시자입니다. 없으면 같은 클래스 정의가 두 번 들어가서 "재정의(redefinition)" 에러가 납니다. 옛날식#ifndef ... #define ... #endif가드와 같은 역할이지만 한 줄로 끝납니다.<d3d11.h>— Direct3D 11의 핵심 인터페이스들이 여기 있습니다.ID3D11Device(자원을 만드는 공장),ID3D11DeviceContext(명령을 내리는 리모컨),ID3D11VertexShader,ID3D11Buffer등 우리가 쓰는 거의 모든 D3D 타입이 이 헤더에서 옵니다.<d3dcompiler.h>— 런타임 셰이더 컴파일 함수D3DCompile이 여기 선언돼 있습니다. 이 함수를 쓰려면 링커에d3dcompiler.lib도 연결해야 합니다(예:#pragma comment(lib, "d3dcompiler.lib")또는 프로젝트 설정).<wrl/client.h>—Microsoft::WRL::ComPtr, 즉 COM 객체용 스마트 포인터가 들어 있습니다. D3D 객체들은 COM 규약을 따르는 참조 카운팅 객체라서, 직접Release()를 호출하는 대신ComPtr이 소멸될 때 자동으로 해제되게 하면 실수를 크게 줄일 수 있습니다.<DirectXMath.h>—XMFLOAT4X4(4×4 행렬 저장용 구조체),XMMATRIX(SIMD 연산용 행렬) 등 수학 타입을 제공합니다. 헬퍼 자체는 수학을 거의 안 쓰지만, 상수 버퍼에 담을 데이터 구조체가 보통 이 타입들을 쓰므로 미리 포함합니다.<string>,<cstdio>,<cassert>— 각각std::strlen류 문자열 유틸,std::fprintf(에러 로그 출력),assert(개발 중 전제 조건 검사)를 위해 필요합니다.using Microsoft::WRL::ComPtr;— 앞으로Microsoft::WRL::ComPtr이라고 길게 쓰지 않고 그냥ComPtr이라고 쓸 수 있게 해 줍니다. 한 이름만 끌어오는 얌전한 형태입니다.using namespace DirectX;—DirectX::네임스페이스 전체를 열어XMFLOAT4X4등을 접두사 없이 쓰게 합니다. 헤더 파일에서using namespace를 여는 것은 일반적으로 권장되지 않지만(이 헤더를 포함하는 모든 곳이 영향을 받으므로), 학습용 예제라 편의를 위해 열었습니다.
ComPtr은 참조 카운트를 하나 잡고 있습니다. 여기에 ID3D11Device* 같은
날포인터가 필요할 때는 ptr.Get()으로 꺼내 쓰되, 그 날포인터에 대고
Release()를 직접 부르면 안 됩니다(ComPtr이 이미 그 책임을 지고 있어 이중 해제가 됩니다).
소유권을 넘기려면 ptr.Detach(), 받으려면 &ptr(내부적으로 기존 것을 놓고 새 것을 받음)을
씁니다.
2. compileShader — 텍스트를 바이트코드로
무엇을 만드나: HLSL 소스 문자열 하나를 받아, GPU에 가까운 바이트코드
(ID3DBlob)로 컴파일하고, 실패하면 에러 로그를 찍은 뒤 nullptr을 돌려주는
작은 함수입니다.
왜 필요한가: 셰이더는 여러 개(버텍스·픽셀·컴퓨트…) 만들게 되는데, 매번
"컴파일하고 → 실패했는지 확인하고 → 에러 blob을 찍고" 하는 세 줄을 손으로 반복하면 실수하기 쉽습니다. 특히
에러 로그 출력을 빼먹으면 컴파일이 왜 실패했는지 전혀 알 수 없어 디버깅이 막힙니다. 그래서 이걸
한 함수로 못 박아 둡니다.
// HLSL 소스 하나를 바이트코드로 컴파일하고 에러 로그를 검사한다.
// entry: 진입점 함수 이름(예: "VSMain"), target: 프로파일(예: "vs_5_0" / "ps_5_0" / "cs_5_0")
inline ComPtr<ID3DBlob> compileShader(const char* src, const char* entry, const char* target) {
UINT flags = D3DCOMPILE_ENABLE_STRICTNESS;
#if defined(_DEBUG)
flags |= D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif
ComPtr<ID3DBlob> code, errors;
HRESULT hr = D3DCompile(src, std::strlen(src), nullptr, nullptr, nullptr,
entry, target, flags, 0, &code, &errors);
if (FAILED(hr)) {
// OpenGL의 glGetShaderInfoLog 에 해당: 에러 blob에 사람이 읽을 로그가 담긴다.
if (errors)
std::fprintf(stderr, "[셰이더 컴파일 실패]\n%s\n",
static_cast<const char*>(errors->GetBufferPointer()));
return nullptr;
}
return code;
}
시그니처 해부
inline ComPtr<ID3DBlob> compileShader(const char* src, const char* entry, const char* target)
— 한 조각씩 뜯어보겠습니다.
inline— 이 함수 정의가 헤더(.h) 안에 있기 때문에 필요합니다. 헤더는 여러.cpp에 펼쳐지는데,inline이 없으면 같은 함수가 여러 번 정의됐다며 링커 에러(ODR 위반)가 납니다.inline은 "여러 번역 단위에 같은 정의가 있어도 괜찮다"는 허가장 역할을 합니다(성능상 "인라인 전개"와는 별개의 의미입니다).- 반환 타입
ComPtr<ID3DBlob>— 컴파일 결과인 바이트코드 blob을 담아 돌려줍니다.ID3DBlob은 D3D가 쓰는 범용 메모리 덩어리 컨테이너로, 안에 든 것은 바이트코드일 수도 에러 문자열일 수도 있습니다.ComPtr로 감쌌으므로 호출한 쪽에서 따로 해제할 필요가 없습니다. 실패 시엔nullptr(빈ComPtr)이 나옵니다. const char* src— HLSL 소스 텍스트를 가리키는 포인터입니다.const가 붙은 이유는 이 함수가 소스 내용을 읽기만 하고 고치지 않기 때문입니다. 파일에서 읽었든 코드에 하드코딩했든 널 종료 C 문자열이면 됩니다.const char* entry— 진입점 함수 이름입니다. 하나의.hlsl파일에 여러 함수가 있어도, 실제로 셰이더 스테이지가 되는 것은 이 이름의 함수뿐입니다(예:"VSMain").const char* target— 셰이더 프로파일(모델 버전) 문자열입니다."vs_5_0"은 셰이더 모델 5.0의 버텍스 셰이더,"ps_5_0"은 픽셀,"cs_5_0"은 컴퓨트 셰이더를 뜻합니다. 이 문자열이 "어떤 스테이지로 어떤 기능 수준까지 컴파일할지"를 결정합니다.
컴파일 플래그 — flags
UINT flags = D3DCOMPILE_ENABLE_STRICTNESS;
D3DCOMPILE_ENABLE_STRICTNESS는 컴파일러를 엄격 모드로 켭니다. 오래된/모호한 문법을
거부하고 경고를 더 촘촘히 내주어, 겉으로만 돌아가는 위험한 코드를 미리 걸러 줍니다. UINT는 부호 없는
32비트 정수로, 이런 플래그들은 비트 플래그라서 |(OR)로 켜서 쌓습니다.
#if defined(_DEBUG)
flags |= D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif
#if defined(_DEBUG)는 전처리기 조건입니다. _DEBUG 매크로는 보통
디버그 구성으로 빌드할 때만 정의되므로, 이 두 줄은 디버그 빌드에서만 실제 코드가 됩니다.
D3DCOMPILE_DEBUG— 바이트코드에 디버그 정보(원본 소스 줄 번호, 변수 이름 등)를 심어, PIX나 그래픽스 디버거로 셰이더를 한 줄씩 들여다볼 수 있게 합니다.D3DCOMPILE_SKIP_OPTIMIZATION— 최적화를 끕니다. 최적화가 변수를 지우거나 순서를 바꾸면 디버깅 때 값이 안 보이거나 이상해 보이므로, 개발 중에는 끄는 편이 관찰하기 좋습니다.
#if defined(_DEBUG)로 감싸 릴리스에서는 자동으로
빠지게 해 둔 것입니다. 릴리스에서는 D3DCOMPILE_OPTIMIZATION_LEVEL3을 추가로 켜기도 합니다.
D3DCompile 호출 — 인자 11개 하나씩
ComPtr<ID3DBlob> code, errors;
HRESULT hr = D3DCompile(src, std::strlen(src), nullptr, nullptr, nullptr,
entry, target, flags, 0, &code, &errors);
먼저 ComPtr<ID3DBlob> code, errors;로 결과를 받을 빈 blob 두 개를 준비합니다.
하나는 성공 시 바이트코드가, 다른 하나는 실패(또는 경고) 시 에러 로그가 담깁니다. D3DCompile은 이
두 변수의 주소를 마지막 인자로 받아 안을 채워 줍니다(출력 매개변수). 인자를 순서대로 봅시다.
| 인자 | 값 | 의미 |
|---|---|---|
1. pSrcData | src | HLSL 소스 텍스트의 시작 포인터 |
2. SrcDataSize | std::strlen(src) | 소스의 바이트 길이(널 문자 제외) |
3. pSourceName | nullptr | 에러 메시지에 표시할 파일 이름(없어도 됨) |
4. pDefines | nullptr | 셰이더용 #define 매크로 목록(없음) |
5. pInclude | nullptr | #include 처리기(없음) |
6. pEntrypoint | entry | 진입점 함수 이름 |
7. pTarget | target | 셰이더 프로파일(예: "vs_5_0") |
8. Flags1 | flags | 위에서 만든 컴파일 옵션 |
9. Flags2 | 0 | 이펙트(fx) 관련 플래그(안 씀) |
10. ppCode | &code | 성공 시 바이트코드 blob을 받을 출력 |
11. ppErrorMsgs | &errors | 실패/경고 시 에러 로그 blob을 받을 출력 |
특히 눈여겨볼 점은 10번과 11번이 별개라는 것입니다. 바이트코드와 에러 메시지가 서로 다른
출력 채널로 나옵니다. 그래서 컴파일 성공 여부는 code가 아니라 반환값 hr로
판단하고, "왜 실패했는가"는 errors blob에서 읽어야 합니다.
&code의 &는 ComPtr의 주소를 넘기는 것인데, ComPtr은
operator&가 내부 포인터를 비운 뒤 그 슬롯의 주소를 주도록 오버로딩돼 있어,
D3D가 새 객체를 안전하게 채워 넣을 수 있습니다. 그래서 날포인터를 다룰 때처럼 직접 AddRef를 신경 쓸
필요가 없습니다.
결과 판정 — HRESULT와 FAILED
if (FAILED(hr)) {
if (errors)
std::fprintf(stderr, "[셰이더 컴파일 실패]\n%s\n",
static_cast<const char*>(errors->GetBufferPointer()));
return nullptr;
}
return code;
HRESULT hr— 거의 모든 COM/D3D 함수가 돌려주는 결과 코드입니다. 단순 성공/실패 불리언이 아니라, 성공 계열과 실패 계열을 비트로 구분하는 32비트 값입니다.FAILED(hr)— 그HRESULT가 실패 계열인지 검사하는 매크로입니다. (짝으로SUCCEEDED(hr)가 있습니다.)hr < 0인지 보는 것과 사실상 같습니다.if (errors)— 에러 blob이 실제로 채워졌을 때만 로그를 찍습니다. 실패했는데도errors가 비어 있을 수 있어(예: 파일을 못 찾는 등 컴파일 이전 단계 실패) 방어적으로 확인합니다.errors->GetBufferPointer()— 에러 blob 안의 바이트 시작 주소를 반환합니다. 내용이 사람이 읽는 문자열이므로static_cast<const char*>로 형변환해%s로 출력합니다.std::fprintf(stderr, ...)— 표준 에러 스트림으로 출력합니다. 일반 로그(stdout)와 섞이지 않아 문제 추적이 쉽습니다.- 실패하면
return nullptr;로 빈 blob을 돌려주고, 성공하면return code;로 바이트코드를 돌려줍니다. 호출한 쪽은 결과가 널인지만 보면 성공 여부를 알 수 있습니다.
errors blob에 경고가 담길 수 있습니다. 위 코드는 실패할
때만 로그를 찍지만, 실전에서는 성공 시에도 errors가 비어 있지 않으면 경고를 출력하도록 확장하는 것이
좋습니다. 경고 하나가 나중에 큰 버그의 단서인 경우가 많습니다.
glCreateShader + glShaderSource +
glCompileShader + glGetShaderInfoLog를 한데 묶은 것에 해당합니다. 특히 실패 로그를
읽는 부분(GetBufferPointer)이 OpenGL의 glGetShaderInfoLog와 정확히 같은 목적입니다 —
사람이 읽을 컴파일 에러를 얻는 통로입니다.
3. Shader 클래스 — VS·PS를 한 덩어리로
무엇을 만드나: 버텍스 셰이더 소스와 픽셀 셰이더 소스를 받아, 각각 컴파일·생성해서 들고 있다가,
use() 한 번으로 파이프라인에 바인딩해 주는 얇은 래퍼 클래스입니다.
왜 필요한가: 그리기 한 번에는 보통 VS와 PS가 짝으로 필요합니다. 두 객체를 따로
관리하고 매번 각각 바인딩하는 대신, 하나로 묶어 두면 호출부가 shader.use(ctx) 한 줄로 깔끔해집니다.
이것이 OpenGL의 glUseProgram(program)에 대응합니다(단, 앞서 말했듯 D3D에는 링크 단계가 없습니다).
// 얇은 래퍼: VS/PS 컴파일·생성 + 바인딩을 편하게. (OpenGL의 "프로그램 링크" 단계는 없다)
class Shader {
public:
ComPtr<ID3D11VertexShader> vs;
ComPtr<ID3D11PixelShader> ps;
ComPtr<ID3DBlob> vsBlob; // 입력 레이아웃 생성에 재사용하므로 보관
// 버텍스/픽셀 소스로 바로 만드는 생성자
Shader(ID3D11Device* dev, const char* vsSrc, const char* psSrc,
const char* vsEntry = "VSMain", const char* psEntry = "PSMain") {
vsBlob = compileShader(vsSrc, vsEntry, "vs_5_0");
ComPtr<ID3DBlob> psBlob = compileShader(psSrc, psEntry, "ps_5_0");
if (!vsBlob || !psBlob) return;
HRESULT hr;
hr = dev->CreateVertexShader(vsBlob->GetBufferPointer(),
vsBlob->GetBufferSize(), nullptr, &vs);
assert(SUCCEEDED(hr));
hr = dev->CreatePixelShader(psBlob->GetBufferPointer(),
psBlob->GetBufferSize(), nullptr, &ps);
assert(SUCCEEDED(hr));
}
// 이 셰이더를 파이프라인에 바인딩 (glUseProgram 에 해당)
void use(ID3D11DeviceContext* ctx) const {
ctx->VSSetShader(vs.Get(), nullptr, 0);
ctx->PSSetShader(ps.Get(), nullptr, 0);
}
// ComPtr 이 소멸 시 자동 해제하므로 별도 소멸자가 필요 없다.
};
멤버 변수 세 개
ComPtr<ID3D11VertexShader> vs;— 완성된 버텍스 셰이더 객체. 정점마다 실행되는 프로그램입니다.ComPtr<ID3D11PixelShader> ps;— 완성된 픽셀 셰이더 객체. 래스터화된 픽셀(정확히는 프래그먼트)마다 실행됩니다.ComPtr<ID3DBlob> vsBlob;— 버텍스 셰이더의 바이트코드 blob을 그대로 보관합니다. 왜 픽셀 blob은 안 들고 버텍스 blob만 들까요? 입력 레이아웃(ID3D11InputLayout)을 만들 때 버텍스 셰이더 바이트코드가 필요하기 때문입니다. D3D는 입력 레이아웃이 셰이더의 입력 시그니처와 맞는지 blob으로 검증합니다. 그래서 이 blob은 셰이더 생성 후에도 버리지 않고 남겨 둡니다.
public으로 둔 것은 학습·편의를 위해서입니다. vsBlob을 외부에서 꺼내 입력
레이아웃을 만들어야 하기 때문이기도 합니다. 실무에서는 접근을 캡슐화하고 getVSBlob() 같은 접근자를
두는 경우가 많습니다.
생성자 — 시그니처와 기본 인자
Shader(ID3D11Device* dev, const char* vsSrc, const char* psSrc, const char* vsEntry = "VSMain",
const char* psEntry = "PSMain")
ID3D11Device* dev— 셰이더 객체를 만들어 주는 장치입니다. D3D에서 자원 생성은 항상 Device가, 명령 실행은 DeviceContext가 담당하는 역할 분담을 기억하세요. 생성이 목적이므로 여기선 Device를 받습니다.const char* vsSrc,const char* psSrc— 각각 버텍스·픽셀 셰이더의 HLSL 소스 텍스트입니다.const char* vsEntry = "VSMain",const char* psEntry = "PSMain"— 기본 인자입니다. 진입점 이름을 안 주면 각각"VSMain","PSMain"이 쓰입니다. 우리 셰이더 파일들이 이 관례를 따르므로 대부분의 호출에서 이 두 인자를 생략할 수 있어 호출부가 간결해집니다.
생성자 본문 — 컴파일 → 검사 → 생성
vsBlob = compileShader(vsSrc, vsEntry, "vs_5_0");
ComPtr<ID3DBlob> psBlob = compileShader(psSrc, psEntry, "ps_5_0");
if (!vsBlob || !psBlob) return;
먼저 앞서 만든 compileShader로 두 소스를 각각 컴파일합니다. 버텍스는 프로파일
"vs_5_0", 픽셀은 "ps_5_0"로 고정돼 있습니다. vsBlob은 멤버라서 결과가
그대로 보관되고, psBlob은 생성자 안에서만 쓸 지역 변수라 입력 레이아웃에 필요 없는
픽셀 blob은 함수가 끝나면 자동 해제됩니다.
if (!vsBlob || !psBlob) return; — 둘 중 하나라도 컴파일에 실패(널)하면 더 진행하지
않고 생성자를 끝냅니다. 이때 vs·ps 멤버는 여전히 빈 ComPtr로
남습니다. 즉 "실패한 Shader"는 나중에 use()해도 널 셰이더가 바인딩되어 아무것도
안 그려집니다. 이때 위의 compileShader가 이미 에러 로그를 찍어 둔 상태이므로 원인은 콘솔에서 확인할
수 있습니다.
HRESULT hr;
hr = dev->CreateVertexShader(vsBlob->GetBufferPointer(),
vsBlob->GetBufferSize(), nullptr, &vs);
assert(SUCCEEDED(hr));
hr = dev->CreatePixelShader(psBlob->GetBufferPointer(),
psBlob->GetBufferSize(), nullptr, &ps);
assert(SUCCEEDED(hr));
이제 바이트코드로 실제 셰이더 객체를 만듭니다. CreateVertexShader의 인자를 봅시다.
vsBlob->GetBufferPointer()— 바이트코드의 시작 주소.vsBlob->GetBufferSize()— 바이트코드의 길이(바이트). 이 둘이 짝이 되어 "여기부터 이만큼이 바이트코드다"라고 알려 줍니다.nullptr— 클래스 링키지(class linkage) 인자로, 동적 셰이더 링킹이라는 고급 기능에 씁니다. 안 쓰므로 널입니다.&vs— 완성된 셰이더 객체를 받을 출력. 여기서도ComPtr의operator&가 안전하게 새 객체를 담아 줍니다.
CreatePixelShader도 인자 구조가 완전히 같습니다(픽셀 blob으로 픽셀 셰이더를 생성). 두 호출 뒤의
assert(SUCCEEDED(hr))는 개발 중 전제 조건 검사입니다. 여기까지 바이트코드가
멀쩡히 컴파일됐다면 객체 생성이 실패할 이유는 거의 없으므로(예: GPU 메모리 부족 같은 드문 경우), 실패하면
디버그 빌드에서 즉시 멈춰 문제를 드러냅니다. assert는 릴리스 빌드(NDEBUG)에서는 보통
사라지므로, 릴리스에서 반드시 처리해야 하는 오류라면 assert 대신 실제 에러 처리를
넣어야 합니다.
assert(SUCCEEDED(hr))는 릴리스에서 통째로 없어지는 것이 함정입니다. 만약
hr을 얻는 함수 호출을 assert(SUCCEEDED(호출()))처럼 assert 안에 직접 넣으면,
릴리스에서는 그 호출 자체가 실행되지 않아 버그가 됩니다. 위 코드처럼 호출은 밖에서 하고
assert에는 결과 값만 넣는 습관이 안전합니다.
use() — 파이프라인에 바인딩
void use(ID3D11DeviceContext* ctx) const {
ctx->VSSetShader(vs.Get(), nullptr, 0);
ctx->PSSetShader(ps.Get(), nullptr, 0);
}
ID3D11DeviceContext* ctx— 이번엔 DeviceContext를 받습니다. "바인딩"은 자원 생성이 아니라 명령이므로 Context의 몫입니다.- 메서드 끝의
const— 이 함수가Shader의 멤버를 바꾸지 않는다는 약속입니다. 실제로vs·ps를 읽어 넘길 뿐 고치지 않습니다. 덕분에const Shader&로도use()를 호출할 수 있습니다. VSSetShader(vs.Get(), nullptr, 0)— 버텍스 셰이더 슬롯에 우리 VS를 꽂습니다.vs.Get()은ComPtr에서 날포인터를 꺼내는 방법입니다(참조 카운트는 건드리지 않습니다). 나머지 두 인자nullptr,0은 위와 같은 동적 링킹용 인터페이스 배열/개수로, 안 쓰므로 각각 널·0입니다.PSSetShader(ps.Get(), nullptr, 0)— 픽셀 셰이더 슬롯에 우리 PS를 꽂습니다. 구조 동일.
두 줄이 끝나면 파이프라인의 정점 처리·픽셀 처리 단계가 이 셰이더로 세팅됩니다. 이후 Draw/DrawIndexed
를 부르면 이 셰이더들이 실행됩니다. 이것이 OpenGL glUseProgram과 개념적으로 같지만, D3D는 스테이지를
따로따로 지정한다는 차이가 있습니다.
소멸자가 없는 이유
클래스 끝의 주석 "ComPtr 이 소멸 시 자동 해제하므로 별도 소멸자가 필요 없다"가 핵심입니다.
Shader 객체가 스코프를 벗어나면, 멤버인 vs·ps·vsBlob
ComPtr들이 자동으로 소멸되면서 각자 잡고 있던 COM 참조를 알아서 Release()
합니다. 이것이 RAII입니다 — "자원의 수명 = 객체의 수명". 그래서 우리가 손으로 해제 코드를 쓸 필요가 없고,
예외가 나거나 중간에 return해도 누수가 나지 않습니다.
4. 상수 버퍼(cbuffer) 헬퍼 — 유니폼의 대체재
무엇을 만드나: C++ 구조체 타입 하나를 정해, 그 크기에 맞는 GPU 상수 버퍼를
만드는 헬퍼(createConstantBuffer)와, 매 프레임 그 버퍼를 CPU 데이터로
갱신하는 헬퍼(updateConstantBuffer)입니다.
왜 필요한가: 셰이더에 "이 프레임의 변환 행렬은 이거야" 같은 상수를 넘겨야 하는데, D3D11은
OpenGL의 glUniform* 대신 상수 버퍼라는 하나의 메모리 블록으로 상수를 통째로
올립니다. 이 블록을 만들고 채우는 정형 코드를 템플릿 헬퍼로 못 박습니다.
4-1. createConstantBuffer — 버퍼 만들기
// C++ 구조체(예: struct Transforms { XMFLOAT4X4 model, view, proj; })를
// GPU에 올릴 때 사용. cbuffer 는 16바이트 배수로 정렬돼야 한다.
template <typename T>
inline ComPtr<ID3D11Buffer> createConstantBuffer(ID3D11Device* dev) {
static_assert(sizeof(T) % 16 == 0, "cbuffer 는 16바이트 배수로 정렬돼야 한다");
D3D11_BUFFER_DESC desc{};
desc.ByteWidth = sizeof(T);
desc.Usage = D3D11_USAGE_DYNAMIC; // 매 프레임 CPU에서 갱신
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
ComPtr<ID3D11Buffer> cb;
HRESULT hr = dev->CreateBuffer(&desc, nullptr, &cb);
assert(SUCCEEDED(hr));
return cb;
}
template <typename T>는 이 함수를 어떤 구조체 타입에도 쓸 수 있게 하는
템플릿 선언입니다. 예를 들어 createConstantBuffer<Transforms>(dev)처럼 부르면
컴파일러가 T를 Transforms로 채운 전용 함수를 자동 생성합니다. 덕분에 구조체마다 똑같은
함수를 복붙할 필요가 없습니다.
16바이트 정렬 — 왜 static_assert인가
static_assert(sizeof(T) % 16 == 0, "...")는 컴파일 시점에 조건을 검사합니다.
조건이 거짓이면 빌드 자체가 실패하며 뒤의 메시지를 보여 줍니다. 왜 하필 16의 배수일까요?
float3(12바이트) 뒤에 float(4바이트)를 두면 딱 16바이트로 채워져 괜찮지만,
float2(8바이트) 뒤에 float3를 두면 float3가 경계를 넘어가서
4바이트 패딩이 끼어 들어가고, C++ 구조체 레이아웃과 셰이더 쪽 레이아웃이 어긋납니다.
static_assert는 전체 크기 조건만 잡아 주므로, 멤버 배치는 손으로 신경 써야 합니다.
구체적인 손계산 예를 봅시다. struct Transforms { XMFLOAT4X4 model, view, proj; };는
4×4 float 행렬 세 개입니다. 하나가 4 × 4 × 4바이트 = 64바이트이고, 세 개면
64 × 3 = 192바이트. 192 % 16 == 0이므로 통과합니다. 반대로
struct Bad { XMFLOAT3 a; float b; XMFLOAT3 c; };라면 12 + 4 + 12 = 28바이트인데,
컴파일러 정렬로 실제 크기가 32바이트가 될 수도 있어 32 % 16 != 0으로 걸립니다 —
이때 뒤에 float pad;를 추가해 48바이트로 맞추는 식으로 손봐야 합니다.
D3D11_BUFFER_DESC 채우기
D3D11_BUFFER_DESC desc{};의 {}는 구조체 전체를 0으로 초기화하는
집합 초기화입니다. 안 쓰는 필드(예: MiscFlags, StructureByteStride)를 자동으로 0으로
만들어, 쓰레기 값이 들어가는 실수를 막습니다. 그 뒤 필요한 필드만 채웁니다.
| 필드 | 값 | 의미 |
|---|---|---|
ByteWidth | sizeof(T) | 버퍼 크기. 위 정렬 규칙 때문에 항상 16의 배수 |
Usage | D3D11_USAGE_DYNAMIC | CPU가 자주(매 프레임) 쓰는 버퍼임을 알림 |
BindFlags | D3D11_BIND_CONSTANT_BUFFER | 이 버퍼를 상수 버퍼로 쓴다고 선언 |
CPUAccessFlags | D3D11_CPU_ACCESS_WRITE | CPU가 쓰기 가능해야 함(Map으로 갱신하니까) |
D3D11_USAGE_DYNAMIC과 D3D11_CPU_ACCESS_WRITE는 짝으로 갑니다.
"매 프레임 CPU에서 갱신할 것"이라는 의도를 드라이버에 알려, GPU가 이 버퍼를 CPU가 쓰기 좋은 위치에 배치하도록
합니다. 만약 값이 거의 안 바뀌는 버퍼라면 D3D11_USAGE_DEFAULT로 만들고
UpdateSubresource로 갱신하는 편이 더 나을 수 있습니다.
CreateBuffer 호출
dev->CreateBuffer(&desc, nullptr, &cb)의 인자는 세 개입니다.
&desc— 방금 채운 설계도의 주소.nullptr— 초기 데이터. 지금은 빈 버퍼로 만들고 나중에updateConstantBuffer로 채울 것이므로 널입니다. 처음부터 값을 넣고 싶으면D3D11_SUBRESOURCE_DATA를 넘길 수 있습니다.&cb— 완성된 버퍼 객체를 받을 출력.
성공하면 assert(SUCCEEDED(hr))를 통과하고 return cb;로 버퍼를 돌려줍니다. 이 버퍼는
보통 프로그램 시작 때 한 번만 만들고, 매 프레임 내용만 갈아 끼웁니다.
4-2. updateConstantBuffer — 내용 갱신
// Map/Unmap 으로 상수 버퍼 내용을 통째로 갱신한다 (glUniform* 대체).
template <typename T>
inline void updateConstantBuffer(ID3D11DeviceContext* ctx, ID3D11Buffer* cb, const T& data) {
D3D11_MAPPED_SUBRESOURCE m{};
ctx->Map(cb, 0, D3D11_MAP_WRITE_DISCARD, 0, &m);
std::memcpy(m.pData, &data, sizeof(T));
ctx->Unmap(cb, 0);
}
시그니처
ID3D11DeviceContext* ctx— 이번에도 "명령"이므로 Context입니다.ID3D11Buffer* cb— 갱신할 상수 버퍼(위에서 만든 것).const T& data— 올릴 데이터. 참조(&)로 받는 이유는 구조체를 통째로 값 복사하지 않기 위해서고(행렬 세 개 = 192바이트를 불필요하게 복사할 필요 없음),const인 이유는 이 함수가 데이터를 읽어서 GPU로 보내기만 하고 고치지 않기 때문입니다.
Map → memcpy → Unmap
이 세 줄이 "CPU 데이터를 GPU 버퍼로 실어 나르는" 표준 절차입니다.
D3D11_MAPPED_SUBRESOURCE m{};—Map이 알려 줄 쓸 수 있는 주소를 받을 그릇을 0으로 초기화해 준비합니다.ctx->Map(cb, 0, D3D11_MAP_WRITE_DISCARD, 0, &m)— GPU 버퍼를 잠시 CPU 주소 공간에 매핑해,m.pData에 쓸 수 있는 포인터를 받습니다. 인자는 순서대로 버퍼, 서브리소스 인덱스(0), 매핑 방식, 플래그(0), 출력&m입니다.std::memcpy(m.pData, &data, sizeof(T))— CPU 구조체의 바이트를 통째로 복사해 넣습니다.m.pData가 목적지,&data가 원본,sizeof(T)가 복사 크기입니다.ctx->Unmap(cb, 0)— 매핑을 끝냅니다. 이 순간 GPU가 갱신된 버퍼를 다시 쓸 수 있게 됩니다.Map과Unmap은 반드시 짝이어야 합니다.
D3D11_MAP_WRITE_DISCARD가 성능의 핵심입니다. "이전 내용은 버려도 된다(discard),
통째로 새로 쓸 것"이라고 드라이버에 알려 주는 신호입니다. 그러면 드라이버는 GPU가 아직 지난 프레임 버퍼를
읽는 중이라도 새 메모리 영역을 즉시 내주어(리네이밍), CPU가 GPU를 기다리며 멈추는 일
(스톨)을 피합니다. 반대로 이전 값을 유지한 채 일부만 고치고 싶다면 D3D11_MAP_WRITE_NO_OVERWRITE
등 다른 방식을 써야 하며, 매 프레임 전체 갱신에는 WRITE_DISCARD가 정석입니다.
memcpy는 C++ 구조체의 바이트를 있는 그대로 복사할 뿐, 행렬을 알아서 transpose해 주지
않습니다. 이 시리즈 규약(행벡터 · 행 우선)에서는 셰이더에 넣기 전에 CPU에서
행렬을 미리 transpose해 data에 담아 두어야 합니다. 즉 updateConstantBuffer에
넘기는 구조체 안의 행렬은 이미 전치된 상태여야 화면이 올바르게 나옵니다.
4-3. 셰이더 쪽 바인딩 — register(b0)와 *SSetConstantBuffers
C++에서 만든 버퍼를 셰이더가 쓰려면, 셰이더 쪽에서 cbuffer를 레지스터 슬롯에
선언하고, C++에서 VSSetConstantBuffers/PSSetConstantBuffers로 그 슬롯에 버퍼를 꽂아야
합니다. HLSL 선언은 이렇게 생겼습니다.
cbuffer Transforms : register(b0) {
float4x4 uModel; // CPU에서 이미 transpose 해서 올린 값
float4x4 uView;
float4x4 uProj;
};
register(b0)의 b는 버퍼(constant buffer) 레지스터를, 0은
0번 슬롯을 뜻합니다. C++에서 ctx->VSSetConstantBuffers(0, 1, cb.GetAddressOf())
처럼 부르면, 0번 슬롯에 버퍼 1개를 꽂아라가 되어 셰이더의 b0과 정확히 연결됩니다. 이름
(Transforms)이 아니라 슬롯 번호로 맺어진다는 점이 OpenGL과의 큰 차이입니다.
register(b0))으로 바인딩되므로, OpenGL의
glGetUniformLocation 같은 매 프레임 문자열 조회가 필요 없습니다. 또한 자주 바뀌는
값과 거의 안 바뀌는 값을 서로 다른 cbuffer로 분리(예: per-frame을 b0,
per-object를 b1)하면 Map 횟수와 대역폭을 줄일 수 있습니다 — 프레임당 한 번만 바뀌는
카메라 행렬을 오브젝트마다 다시 올리는 낭비를 없애는 것입니다.
5. 전체를 하나의 흐름으로 — 사용 예와 API 대응
지금까지의 헬퍼를 실제로 쓰는 모습은 이렇습니다. 초기화 때 한 번 만들고, 매 프레임 갱신·바인딩만 반복합니다.
// ── 초기화 (한 번) ──
struct Transforms { XMFLOAT4X4 model, view, proj; }; // 192바이트 (16의 배수)
Shader shader(dev, vsSource, psSource); // 컴파일 + 생성
auto cb = createConstantBuffer<Transforms>(dev); // 상수 버퍼 생성
// ── 매 프레임 ──
Transforms t;
// 규약: 행벡터 v'=v*M 이므로 cbuffer 로 보내기 전에 CPU에서 transpose 한다.
XMStoreFloat4x4(&t.model, XMMatrixTranspose(modelMat));
XMStoreFloat4x4(&t.view, XMMatrixTranspose(viewMat));
XMStoreFloat4x4(&t.proj, XMMatrixTranspose(projMat));
updateConstantBuffer(ctx, cb.Get(), t); // Map/memcpy/Unmap
ctx->VSSetConstantBuffers(0, 1, cb.GetAddressOf()); // b0 슬롯에 꽂기
shader.use(ctx); // VS/PS 바인딩
ctx->DrawIndexed(indexCount, 0, 0); // 그리기
XMMatrixTranspose가 규약 통일의 핵심 지점입니다. DirectXMath의 XMMATRIX로 계산한
행렬을 그대로 올리면 셰이더의 mul(matrix, vector) 관례와 어긋나므로, 전치해서
XMFLOAT4X4로 저장(XMStoreFloat4x4)한 뒤 버퍼에 실어 보냅니다. 이렇게 CPU에서 미리
전치해 두면 셰이더에서는 mul(uModel, worldPos)처럼 자연스럽게 쓸 수 있습니다.
API·함수 1:1 대응표
| 이 페이지의 헬퍼 | 내부 D3D11 호출 | OpenGL 대응 |
|---|---|---|
compileShader() | D3DCompile + 에러 blob 확인 | glCompileShader + glGetShaderInfoLog |
Shader 생성자 | CreateVertexShader / CreatePixelShader | glCreateShader (+ 링크는 없음) |
Shader::use() | VSSetShader / PSSetShader | glUseProgram |
createConstantBuffer<T>() | CreateBuffer (D3D11_BIND_CONSTANT_BUFFER) | glGenBuffers + glBufferData (UBO) |
updateConstantBuffer() | Map / memcpy / Unmap | glBufferSubData / glUniform* |
(호출부) VSSetConstantBuffers | VSSetConstantBuffers | glBindBufferBase (UBO 슬롯) |
Map/Unmap으로 올리고,
행렬은 규약에 맞게 CPU에서 전치한다. 이 헬퍼들은 학습용이며, 실무에서는 DirectXMath와 검증된
래퍼를 함께 쓰는 것을 권장합니다.