← 개념 · 데모 가이드 | 구현 허브 | 4장. GPU · 셰이더 | 셰이더 만들기 (재사용 헬퍼)
IMPLEMENTATION · CHAPTER 4

셰이더 만들기 (재사용 헬퍼) — 한 줄 한 줄 직접 구현

GPU에게 "이렇게 그려라"라고 시키려면, 우리가 쓴 HLSL 소스 코드(사람이 읽는 텍스트)를 GPU가 실제로 실행할 수 있는 셰이더 객체로 바꿔야 합니다. 이 변환은 두 단계입니다 — 먼저 D3DCompile로 소스를 바이트코드로 컴파일하고, 그 바이트코드로 CreateVertexShader/CreatePixelShader를 불러 스테이지별 객체를 만듭니다. 이 과정은 셰이더를 만들 때마다 똑같이 반복되고, 컴파일 에러 로그를 놓치면 디버깅이 지옥이 되므로, 이 페이지에서는 이 반복을 작은 재사용 헬퍼(compileShader 함수, Shader 클래스, 상수 버퍼 헬퍼 3종)로 묶어 두는 코드를 한 줄, 인자 하나, API 호출 하나까지 해부합니다.

규약 이 가이드는 D3D 규약으로 통일합니다: 행벡터 v' = v * M(HLSL에서는 정점을 왼쪽에 두는 mul(v, M)), 행 우선(row-major) 저장, 왼손 좌표계, 클립 공간 z 범위 [0, 1], 텍스처 V축은 위→아래. 이 페이지의 헬퍼 자체는 행렬 규약과 직접 관련이 없지만, 여기서 만든 상수 버퍼에 행렬을 실어 보낼 때는 CPU에서 미리 transpose해서 올리는 것이 이 시리즈의 관례입니다(그 이유는 아래 상수 버퍼 절에서 다시 짚습니다). 실무에서는 DirectXMath 같은 검증된 라이브러리를 쓰는 것을 권장하며, 여기서 직접 만드는 헬퍼는 내부 동작을 이해하기 위한 학습용 입니다.

0. 무엇을, 왜 만드나 — 전체 그림

셰이더 하나를 화면에 쓰기까지의 길은 언제나 똑같습니다. 텍스트 → 바이트코드 → 객체 → 바인딩. 이 네 걸음을 그림으로 그려 보면 이렇습니다.

  1. 컴파일: D3DCompile이 HLSL 텍스트를 읽어, GPU에 가까운 중간 표현인 바이트코드(ID3DBlob)로 바꿉니다. 실패하면 사람이 읽을 에러 메시지가 별도의 에러 blob에 담깁니다 — 이걸 꼭 찍어야 원인을 압니다.
  2. 객체 생성: 컴파일된 바이트코드로 device->CreateVertexShader / CreatePixelShader를 불러 스테이지별 셰이더 객체 (ID3D11VertexShader, ID3D11PixelShader)를 만듭니다.
  3. blob 보관: 버텍스 셰이더의 바이트코드(blob)는 나중에 입력 레이아웃(ID3D11InputLayout) 생성에도 재사용되므로, 버리지 말고 멤버로 들고 있습니다.
  4. 상수 전달: 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 — 준비물부터

본격적인 코드에 들어가기 전에, 파일 맨 위의 #includeusing 줄이 각각 무엇을 끌어오는지 먼저 짚겠습니다. 이 줄들이 없으면 아래의 ComPtr, D3DCompile, XMFLOAT4X4 같은 이름을 컴파일러가 알아보지 못합니다.

src/Shader.h (앞부분)cpp
#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;

줄별 해부

함정 ComPtr참조 카운트를 하나 잡고 있습니다. 여기에 ID3D11Device* 같은 날포인터가 필요할 때는 ptr.Get()으로 꺼내 쓰되, 그 날포인터에 대고 Release()를 직접 부르면 안 됩니다(ComPtr이 이미 그 책임을 지고 있어 이중 해제가 됩니다). 소유권을 넘기려면 ptr.Detach(), 받으려면 &ptr(내부적으로 기존 것을 놓고 새 것을 받음)을 씁니다.

2. compileShader — 텍스트를 바이트코드로

무엇을 만드나: HLSL 소스 문자열 하나를 받아, GPU에 가까운 바이트코드 (ID3DBlob)로 컴파일하고, 실패하면 에러 로그를 찍은 뒤 nullptr을 돌려주는 작은 함수입니다.
왜 필요한가: 셰이더는 여러 개(버텍스·픽셀·컴퓨트…) 만들게 되는데, 매번 "컴파일하고 → 실패했는지 확인하고 → 에러 blob을 찍고" 하는 세 줄을 손으로 반복하면 실수하기 쉽습니다. 특히 에러 로그 출력을 빼먹으면 컴파일이 왜 실패했는지 전혀 알 수 없어 디버깅이 막힙니다. 그래서 이걸 한 함수로 못 박아 둡니다.

src/Shader.h — compileShadercpp
// 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) — 한 조각씩 뜯어보겠습니다.

컴파일 플래그 — flags

UINT flags = D3DCOMPILE_ENABLE_STRICTNESS;
D3DCOMPILE_ENABLE_STRICTNESS는 컴파일러를 엄격 모드로 켭니다. 오래된/모호한 문법을 거부하고 경고를 더 촘촘히 내주어, 겉으로만 돌아가는 위험한 코드를 미리 걸러 줍니다. UINT는 부호 없는 32비트 정수로, 이런 플래그들은 비트 플래그라서 |(OR)로 켜서 쌓습니다.

디버그 빌드에서만 켜는 플래그cpp
#if defined(_DEBUG)
    flags |= D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif

#if defined(_DEBUG)전처리기 조건입니다. _DEBUG 매크로는 보통 디버그 구성으로 빌드할 때만 정의되므로, 이 두 줄은 디버그 빌드에서만 실제 코드가 됩니다.

성능 이 두 플래그는 릴리스 빌드에는 절대 남기면 안 됩니다. 디버그 정보는 바이트코드를 부풀리고, 최적화 생략은 셰이더를 훨씬 느리게 만듭니다. 그래서 #if defined(_DEBUG)로 감싸 릴리스에서는 자동으로 빠지게 해 둔 것입니다. 릴리스에서는 D3DCOMPILE_OPTIMIZATION_LEVEL3을 추가로 켜기도 합니다.

D3DCompile 호출 — 인자 11개 하나씩

D3DCompile 호출cpp
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. pSrcDatasrcHLSL 소스 텍스트의 시작 포인터
2. SrcDataSizestd::strlen(src)소스의 바이트 길이(널 문자 제외)
3. pSourceNamenullptr에러 메시지에 표시할 파일 이름(없어도 됨)
4. pDefinesnullptr셰이더용 #define 매크로 목록(없음)
5. pIncludenullptr#include 처리기(없음)
6. pEntrypointentry진입점 함수 이름
7. pTargettarget셰이더 프로파일(예: "vs_5_0")
8. Flags1flags위에서 만든 컴파일 옵션
9. Flags20이펙트(fx) 관련 플래그(안 씀)
10. ppCode&code성공 시 바이트코드 blob을 받을 출력
11. ppErrorMsgs&errors실패/경고 시 에러 로그 blob을 받을 출력

특히 눈여겨볼 점은 10번과 11번이 별개라는 것입니다. 바이트코드와 에러 메시지가 서로 다른 출력 채널로 나옵니다. 그래서 컴파일 성공 여부는 code가 아니라 반환값 hr로 판단하고, "왜 실패했는가"는 errors blob에서 읽어야 합니다.

&code&ComPtr의 주소를 넘기는 것인데, ComPtroperator&내부 포인터를 비운 뒤 그 슬롯의 주소를 주도록 오버로딩돼 있어, D3D가 새 객체를 안전하게 채워 넣을 수 있습니다. 그래서 날포인터를 다룰 때처럼 직접 AddRef를 신경 쓸 필요가 없습니다.

결과 판정 — HRESULTFAILED

실패 처리cpp
if (FAILED(hr)) {
    if (errors)
        std::fprintf(stderr, "[셰이더 컴파일 실패]\n%s\n",
                     static_cast<const char*>(errors->GetBufferPointer()));
    return nullptr;
}
return code;
함정 컴파일이 성공해도 errors blob에 경고가 담길 수 있습니다. 위 코드는 실패할 때만 로그를 찍지만, 실전에서는 성공 시에도 errors가 비어 있지 않으면 경고를 출력하도록 확장하는 것이 좋습니다. 경고 하나가 나중에 큰 버그의 단서인 경우가 많습니다.
OpenGL 대응 이 함수 전체는 OpenGL의 glCreateShader + glShaderSource + glCompileShader + glGetShaderInfoLog를 한데 묶은 것에 해당합니다. 특히 실패 로그를 읽는 부분(GetBufferPointer)이 OpenGL의 glGetShaderInfoLog와 정확히 같은 목적입니다 — 사람이 읽을 컴파일 에러를 얻는 통로입니다.

3. Shader 클래스 — VS·PS를 한 덩어리로

무엇을 만드나: 버텍스 셰이더 소스와 픽셀 셰이더 소스를 받아, 각각 컴파일·생성해서 들고 있다가, use() 한 번으로 파이프라인에 바인딩해 주는 얇은 래퍼 클래스입니다.
왜 필요한가: 그리기 한 번에는 보통 VS와 PS가 으로 필요합니다. 두 객체를 따로 관리하고 매번 각각 바인딩하는 대신, 하나로 묶어 두면 호출부가 shader.use(ctx) 한 줄로 깔끔해집니다. 이것이 OpenGL의 glUseProgram(program)에 대응합니다(단, 앞서 말했듯 D3D에는 링크 단계가 없습니다).

src/Shader.h — Shader 클래스cpp
// 얇은 래퍼: 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 이 소멸 시 자동 해제하므로 별도 소멸자가 필요 없다.
};

멤버 변수 세 개

참고 멤버를 public으로 둔 것은 학습·편의를 위해서입니다. vsBlob을 외부에서 꺼내 입력 레이아웃을 만들어야 하기 때문이기도 합니다. 실무에서는 접근을 캡슐화하고 getVSBlob() 같은 접근자를 두는 경우가 많습니다.

생성자 — 시그니처와 기본 인자

Shader(ID3D11Device* dev, const char* vsSrc, const char* psSrc, const char* vsEntry = "VSMain", const char* psEntry = "PSMain")

생성자 본문 — 컴파일 → 검사 → 생성

생성자 본문cpp
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가 이미 에러 로그를 찍어 둔 상태이므로 원인은 콘솔에서 확인할 수 있습니다.

셰이더 객체 생성cpp
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의 인자를 봅시다.

CreatePixelShader도 인자 구조가 완전히 같습니다(픽셀 blob으로 픽셀 셰이더를 생성). 두 호출 뒤의 assert(SUCCEEDED(hr))개발 중 전제 조건 검사입니다. 여기까지 바이트코드가 멀쩡히 컴파일됐다면 객체 생성이 실패할 이유는 거의 없으므로(예: GPU 메모리 부족 같은 드문 경우), 실패하면 디버그 빌드에서 즉시 멈춰 문제를 드러냅니다. assert는 릴리스 빌드(NDEBUG)에서는 보통 사라지므로, 릴리스에서 반드시 처리해야 하는 오류라면 assert 대신 실제 에러 처리를 넣어야 합니다.

함정 assert(SUCCEEDED(hr))릴리스에서 통째로 없어지는 것이 함정입니다. 만약 hr을 얻는 함수 호출을 assert(SUCCEEDED(호출()))처럼 assert 안에 직접 넣으면, 릴리스에서는 그 호출 자체가 실행되지 않아 버그가 됩니다. 위 코드처럼 호출은 밖에서 하고 assert에는 결과 값만 넣는 습관이 안전합니다.

use() — 파이프라인에 바인딩

use()cpp
void use(ID3D11DeviceContext* ctx) const {
    ctx->VSSetShader(vs.Get(), nullptr, 0);
    ctx->PSSetShader(ps.Get(), nullptr, 0);
}

두 줄이 끝나면 파이프라인의 정점 처리·픽셀 처리 단계가 이 셰이더로 세팅됩니다. 이후 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 — 버퍼 만들기

src/Shader.h — createConstantBuffercpp
// 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)처럼 부르면 컴파일러가 TTransforms로 채운 전용 함수를 자동 생성합니다. 덕분에 구조체마다 똑같은 함수를 복붙할 필요가 없습니다.

16바이트 정렬 — 왜 static_assert인가

static_assert(sizeof(T) % 16 == 0, "...")컴파일 시점에 조건을 검사합니다. 조건이 거짓이면 빌드 자체가 실패하며 뒤의 메시지를 보여 줍니다. 왜 하필 16의 배수일까요?

수학·규칙 GPU의 상수 버퍼는 데이터를 16바이트(= float 4개 = 하나의 레지스터) 단위로 다룹니다. 그래서 버퍼 전체 크기가 16의 배수여야 하고, 더 나아가 하나의 멤버가 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으로 만들어, 쓰레기 값이 들어가는 실수를 막습니다. 그 뒤 필요한 필드만 채웁니다.

필드의미
ByteWidthsizeof(T)버퍼 크기. 위 정렬 규칙 때문에 항상 16의 배수
UsageD3D11_USAGE_DYNAMICCPU가 자주(매 프레임) 쓰는 버퍼임을 알림
BindFlagsD3D11_BIND_CONSTANT_BUFFER이 버퍼를 상수 버퍼로 쓴다고 선언
CPUAccessFlagsD3D11_CPU_ACCESS_WRITECPU가 쓰기 가능해야 함(Map으로 갱신하니까)

D3D11_USAGE_DYNAMICD3D11_CPU_ACCESS_WRITE으로 갑니다. "매 프레임 CPU에서 갱신할 것"이라는 의도를 드라이버에 알려, GPU가 이 버퍼를 CPU가 쓰기 좋은 위치에 배치하도록 합니다. 만약 값이 거의 안 바뀌는 버퍼라면 D3D11_USAGE_DEFAULT로 만들고 UpdateSubresource로 갱신하는 편이 더 나을 수 있습니다.

CreateBuffer 호출

dev->CreateBuffer(&desc, nullptr, &cb)의 인자는 세 개입니다.

성공하면 assert(SUCCEEDED(hr))를 통과하고 return cb;로 버퍼를 돌려줍니다. 이 버퍼는 보통 프로그램 시작 때 한 번만 만들고, 매 프레임 내용만 갈아 끼웁니다.

4-2. updateConstantBuffer — 내용 갱신

src/Shader.h — updateConstantBuffercpp
// 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);
}

시그니처

Map → memcpy → Unmap

이 세 줄이 "CPU 데이터를 GPU 버퍼로 실어 나르는" 표준 절차입니다.

  1. D3D11_MAPPED_SUBRESOURCE m{};Map이 알려 줄 쓸 수 있는 주소를 받을 그릇을 0으로 초기화해 준비합니다.
  2. ctx->Map(cb, 0, D3D11_MAP_WRITE_DISCARD, 0, &m) — GPU 버퍼를 잠시 CPU 주소 공간에 매핑해, m.pData에 쓸 수 있는 포인터를 받습니다. 인자는 순서대로 버퍼, 서브리소스 인덱스(0), 매핑 방식, 플래그(0), 출력 &m 입니다.
  3. std::memcpy(m.pData, &data, sizeof(T)) — CPU 구조체의 바이트를 통째로 복사해 넣습니다. m.pData가 목적지, &data가 원본, sizeof(T)가 복사 크기입니다.
  4. ctx->Unmap(cb, 0) — 매핑을 끝냅니다. 이 순간 GPU가 갱신된 버퍼를 다시 쓸 수 있게 됩니다. MapUnmap은 반드시 이어야 합니다.
성능 D3D11_MAP_WRITE_DISCARD가 성능의 핵심입니다. "이전 내용은 버려도 된다(discard), 통째로 새로 쓸 것"이라고 드라이버에 알려 주는 신호입니다. 그러면 드라이버는 GPU가 아직 지난 프레임 버퍼를 읽는 중이라도 새 메모리 영역을 즉시 내주어(리네이밍), CPU가 GPU를 기다리며 멈추는 일 (스톨)을 피합니다. 반대로 이전 값을 유지한 채 일부만 고치고 싶다면 D3D11_MAP_WRITE_NO_OVERWRITE 등 다른 방식을 써야 하며, 매 프레임 전체 갱신에는 WRITE_DISCARD가 정석입니다.
함정 memcpy는 C++ 구조체의 바이트를 있는 그대로 복사할 뿐, 행렬을 알아서 transpose해 주지 않습니다. 이 시리즈 규약(행벡터 · 행 우선)에서는 셰이더에 넣기 전에 CPU에서 행렬을 미리 transposedata에 담아 두어야 합니다. 즉 updateConstantBuffer에 넘기는 구조체 안의 행렬은 이미 전치된 상태여야 화면이 올바르게 나옵니다.

4-3. 셰이더 쪽 바인딩 — register(b0)*SSetConstantBuffers

C++에서 만든 버퍼를 셰이더가 쓰려면, 셰이더 쪽에서 cbuffer레지스터 슬롯에 선언하고, C++에서 VSSetConstantBuffers/PSSetConstantBuffers로 그 슬롯에 버퍼를 꽂아야 합니다. HLSL 선언은 이렇게 생겼습니다.

셰이더 쪽 cbuffer 선언hlsl
cbuffer Transforms : register(b0) {
    float4x4 uModel;   // CPU에서 이미 transpose 해서 올린 값
    float4x4 uView;
    float4x4 uProj;
};

register(b0)b버퍼(constant buffer) 레지스터를, 00번 슬롯을 뜻합니다. C++에서 ctx->VSSetConstantBuffers(0, 1, cb.GetAddressOf()) 처럼 부르면, 0번 슬롯에 버퍼 1개를 꽂아라가 되어 셰이더의 b0과 정확히 연결됩니다. 이름 (Transforms)이 아니라 슬롯 번호로 맺어진다는 점이 OpenGL과의 큰 차이입니다.

성능 cbuffer가 이름이 아니라 레지스터 슬롯(register(b0))으로 바인딩되므로, OpenGL의 glGetUniformLocation 같은 매 프레임 문자열 조회가 필요 없습니다. 또한 자주 바뀌는 값과 거의 안 바뀌는 값을 서로 다른 cbuffer로 분리(예: per-frame을 b0, per-object를 b1)하면 Map 횟수와 대역폭을 줄일 수 있습니다 — 프레임당 한 번만 바뀌는 카메라 행렬을 오브젝트마다 다시 올리는 낭비를 없애는 것입니다.

5. 전체를 하나의 흐름으로 — 사용 예와 API 대응

지금까지의 헬퍼를 실제로 쓰는 모습은 이렇습니다. 초기화 때 한 번 만들고, 매 프레임 갱신·바인딩만 반복합니다.

사용 예cpp
// ── 초기화 (한 번) ──
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 / CreatePixelShaderglCreateShader (+ 링크는 없음)
Shader::use()VSSetShader / PSSetShaderglUseProgram
createConstantBuffer<T>()CreateBuffer (D3D11_BIND_CONSTANT_BUFFER)glGenBuffers + glBufferData (UBO)
updateConstantBuffer()Map / memcpy / UnmapglBufferSubData / glUniform*
(호출부) VSSetConstantBuffersVSSetConstantBuffersglBindBufferBase (UBO 슬롯)
정리 핵심은 세 가지입니다. ① 셰이더는 텍스트 → 바이트코드 → 객체 순으로 만들어지고, 실패 로그는 에러 blob에서 읽는다. ② VS 바이트코드(blob)는 입력 레이아웃에 재사용하므로 보관한다. ③ 상수는 16바이트 정렬된 cbuffer에 담아 Map/Unmap으로 올리고, 행렬은 규약에 맞게 CPU에서 전치한다. 이 헬퍼들은 학습용이며, 실무에서는 DirectXMath와 검증된 래퍼를 함께 쓰는 것을 권장합니다.