← 개념 · 데모 가이드 | 구현 허브 | 6장. 고급 렌더링 | 물리 기반 렌더링 (PBR)
IMPLEMENTATION · CHAPTER 6

물리 기반 렌더링 (PBR) — 한 줄 한 줄 직접 구현

퐁/블린-퐁 조명은 "예뻐 보이도록" 손으로 계수를 튜닝하는 모델입니다. 광원 세기나 재질을 바꾸면 또 튜닝해야 하고, 서로 다른 조명 환경에서 같은 재질이 다르게 보입니다. PBR(Physically Based Rendering)은 이 문제를 정면으로 해결합니다. 빛이 표면과 상호작용하는 방식을 에너지 보존미세면(microfacet) 이론이라는 물리 법칙으로 기술하기 때문에, 재질을 albedo(기본색) · metallic(금속성) · roughness(거칠기) 세 값으로만 표현해도 어떤 조명에서든 일관되게 사실적입니다. 이 페이지에서는 실시간 표준인 Cook-Torrance BRDF를 HLSL 픽셀 셰이더로 한 줄씩 뜯어보며, 수식이 왜 그렇게 생겼는지 · 각 항이 기하학적으로 무엇을 뜻하는지 · 코드의 어느 부분에 대응하는지를 입문자 눈높이로 끝까지 설명합니다.

0. 왜 PBR인가 — 반사 방정식과 BRDF

코드에 들어가기 전에, 우리가 셰이더에서 결국 무엇을 계산하려는지 한 줄로 정리하겠습니다. 렌더링의 근본 질문은 이것입니다: "이 표면 점에서, 카메라 방향으로 나가는 빛(복사휘도)은 얼마인가?" 이를 수학으로 적은 것이 반사 방정식(reflectance equation)입니다.

Lo(V) = ∫Ω f(L, V) · Li(L) · (N·L) dωL

말로 풀면: 점을 덮은 반구(Ω) 위 모든 입사 방향 L에 대해, 그 방향에서 들어온 빛 LiBRDF f(그 빛이 시선 방향 V로 얼마나 튕겨나가는지의 비율)와 람베르트 코사인 항 N·L(비스듬히 들어온 빛은 단위 면적당 세기가 줄어듦)을 곱해 전부 더한(적분한) 것이 나가는 빛 Lo라는 뜻입니다.

  1. 점광원 근사: 실시간에서는 반구 전체를 적분하지 못하므로, 광원을 몇 개의 으로 보고 적분(∫)을 합(Σ)으로 바꿉니다. 코드의 for 루프가 바로 이 Σ입니다.
  2. BRDF f 를 물리적으로 만든다: 이것이 PBR의 핵심입니다. Cook-Torrance는 f확산(diffuse) 항과 정반사(specular) 항의 합으로 씁니다.
  3. f = kd·(albedo/π) + (D·G·F) / (4·(N·V)·(N·L))

  4. D — 법선 분포(GGX/Trowbridge-Reitz): 표면을 이루는 무수히 작은 거울(미세면) 중, 하필 하프벡터 H 방향으로 정렬되어 시선으로 빛을 정확히 튕겨줄 미세면의 비율. roughness가 작을수록 좁고 강렬한 하이라이트가 됩니다.
  5. G — 기하 감쇠(Smith + Schlick-GGX): 미세면들이 서로를 가려(masking) 빛을 못 받거나, 반사된 빛이 옆 봉우리에 막혀(shadowing) 시선까지 못 오는 손실.
  6. F — 프레넬(Schlick 근사): 표면을 스치듯 비스듬히 볼수록 반사율이 급격히 커지는 현상. 정면에서의 기본 반사율 F0에서 출발해 스침각에서 1(완전 반사)로 향합니다.
  7. 금속-거칠기 워크플로 + 에너지 보존: 비금속은 F0=0.04로 고정, 금속은 albedo가 곧 F0. 그리고 정반사로 튕겨나간 에너지만큼 확산광을 줄여(kd = (1-F)(1-metallic)) 총 에너지가 입사량을 넘지 않게 합니다.
규약 이 페이지의 모든 벡터·행렬은 이 사이트 공통 규약을 따릅니다: 행벡터 v' = v*M (HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 공간 z 범위 [0,1], 텍스처 V축 위→아래. 실무에서는 검증된 라이브러리(DirectXMath 등)를 쓰는 것을 권장하며, 이 페이지의 직접 구현은 원리를 손으로 따라가 보기 위한 학습용입니다.

1. 셰이더 입력 — 보간값과 상수 버퍼

픽셀 셰이더가 조명을 계산하려면 세 가지가 필요합니다. (1) 지금 이 픽셀이 어디에 있는지(월드 좌표), (2) 표면이 어느 쪽을 향하는지(법선), (3) 재질과 광원이 무엇인지. 앞의 둘은 정점 셰이더가 래스터라이저를 거쳐 보간해 넘겨주고, 셋째는 상수 버퍼로 CPU에서 업로드합니다.

pbr.hlsl — 입력 구조체와 상수 버퍼hlsl
// 정점 셰이더에서 넘어온 보간값
struct PSIn {
    float4 posH     : SV_Position;
    float3 worldPos : WORLDPOS;   // 월드 좌표
    float3 normal   : NORMAL;     // 월드 공간 법선
    float2 uv       : TEXCOORD0;
};

// --- 프레임/재질 상수 버퍼 ---
#define NR_LIGHTS 4
cbuffer PBRConstants : register(b0) {
    float3 camPos;      float _pad0;
    float3 albedo;      float metallic;   // 재질 (텍스처 대신 균일값으로 단순화)
    float  roughness;   float ao;         float2 _pad1;
    float4 lightPositions[NR_LIGHTS];     // 점광원 배열 (.xyz 사용)
    float4 lightColors[NR_LIGHTS];
};

static const float PI = 3.14159265359;

1-1. PSIn 구조체 — 픽셀마다 보간되어 도착하는 값들

struct PSIn은 정점 셰이더의 출력이자 픽셀 셰이더의 입력입니다. 각 멤버 뒤의 : SV_Position, : NORMAL 같은 것을 시맨틱(semantic)이라고 부릅니다. 시맨틱은 "이 데이터가 파이프라인에서 무슨 역할인지"를 GPU에 알려주는 이름표입니다.

직관 보간이 무엇인지 한 장면으로: 삼각형의 세 꼭짓점에만 값(예: 법선)이 정해져 있고, 그 안쪽 수천 개 픽셀의 값은 세 꼭짓점 값을 무게중심 좌표(barycentric)로 섞어 만듭니다. 그래서 곡면처럼 매끄러운 음영이 나옵니다. 이 "섞기"가 길이를 망가뜨리므로 법선은 픽셀 셰이더에서 다시 정규화해야 하는 것입니다.

1-2. cbuffer — 16바이트 정렬과 패딩의 정체

cbuffer PBRConstants : register(b0)는 상수 버퍼입니다. C++ 쪽에서 b0 슬롯에 바인딩한 구조체가 여기로 들어옵니다. 여기서 입문자가 가장 헷갈리는 것이 중간중간 끼어 있는 _pad0, _pad1 같은 패딩 변수입니다.

HLSL 상수 버퍼에는 엄격한 규칙이 하나 있습니다: 어떤 변수도 16바이트(float4 하나 크기) 경계를 걸쳐서는 안 된다는 것입니다. GPU는 상수를 16바이트짜리 레지스터 단위로 읽기 때문입니다. 한 줄씩 배치를 따라가 봅시다(각 칸 = 4바이트 = float 하나):

16바이트 레지스터.x.y.z.w
레지스터 0camPos (float3)_pad0
레지스터 1albedo (float3)metallic
레지스터 2roughnessao_pad1 (float2)
레지스터 3~lightPositions[0] (float4)

camPosfloat3라 12바이트를 쓰고 4바이트가 남습니다. 만약 바로 뒤에 albedo(또 float3)를 붙이면 albedo의 일부가 레지스터 경계를 넘어가 버립니다. 그래서 _pad0로 남은 4바이트를 채워 albedo새 레지스터에서 시작하게 만든 것입니다. metallic은 마침 albedo 뒤 4바이트에 딱 맞아 같은 레지스터에 얹혔습니다. 이렇게 "float3 + float1"을 짝지어 배치하는 것이 상수 버퍼 설계의 기본기입니다.

함정 C++ 구조체와 HLSL cbuffer의 메모리 레이아웃이 한 바이트라도 어긋나면 값이 통째로 밀려서 엉뚱하게 읽힙니다(예: roughness 자리에 ao 값이 들어옴). C++ 쪽에서도 동일하게 패딩을 넣거나 alignas(16)·명시적 float pad;로 맞춰야 합니다. 배열(lightPositions)은 HLSL에서 각 원소가 무조건 16바이트 정렬되므로, 점광원 위치를 float3가 아니라 float4로 둔 것도 이 규칙을 피하려는 설계입니다.

static const float PI = 3.14159265359;는 셰이더 전역 상수입니다. static const이므로 상수 버퍼가 아니라 셰이더 코드에 박제된 값이라 업로드가 필요 없습니다. 뒤에서 램버트 확산 albedo/π와 GGX 분모에 등장합니다.

2. D — 법선 분포 함수 (GGX / Trowbridge-Reitz)

무엇을 만드나: 표면을 이루는 무수한 미세거울 중, 하필 하프벡터 H 방향을 향하고 있어 광원 L의 빛을 정확히 시선 V로 튕겨줄 수 있는 미세면의 밀도(비율)를 돌려줍니다. 이 값이 하이라이트의 모양과 집중도를 결정합니다.

왜 하프벡터인가: 하나의 매끈한 거울이 L의 빛을 V로 정확히 보내려면, 그 거울의 법선이 반드시 L과 V의 정중앙, 즉 H = normalize(L + V)를 향해야 합니다(반사의 법칙). 그러니 "H를 향한 미세면이 얼마나 많은가"가 곧 "그 픽셀이 얼마나 밝게 반짝일 수 있는가"입니다.

pbr.hlsl — 법선 분포 Dhlsl
// D: GGX 법선 분포 함수. H 방향에 미세면이 얼마나 몰려있나
float distributionGGX(float3 N, float3 H, float rough) {
    float a  = rough * rough;          // roughness 제곱이 더 지각적으로 선형
    float a2 = a * a;
    float NdotH  = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;
    return a2 / max(denom, 1e-7);      // 0 나눗셈 방지
}

2-1. 시그니처 해부

float distributionGGX(float3 N, float3 H, float rough) — 반환 타입이 float 하나인 것에 주목하세요. D는 방향에 따른 스칼라 밀도라서 색이 아니라 수 하나입니다. 인자는 모두 값 전달(HLSL은 벡터/스칼라를 레지스터로 넘기므로 C++처럼 참조를 쓸 필요가 없습니다). N은 표면 법선, H는 하프벡터, rough[0,1] 거칠기입니다. 셋 다 호출부에서 이미 정규화된 단위벡터를 넘긴다고 가정합니다.

2-2. 한 줄씩: roughness를 제곱하는 이유

float a = rough * rough; — GGX 수식이 요구하는 거칠기 파라미터 α는 사용자가 조절하는 roughness를 그대로가 아니라 제곱해서 씁니다. 왜냐하면 슬라이더의 0→1 변화가 눈에 보이는 하이라이트 크기와 더 자연스럽게(지각적으로 선형에 가깝게) 대응하기 때문입니다. roughness를 그대로 α로 쓰면 슬라이더 앞쪽에서 하이라이트가 급변해 조절이 까다롭습니다. 이 관례는 디즈니 원칙 BRDF 이후 사실상 업계 표준이 되었습니다.

float a2 = a * a; — 수식에 α²가 반복 등장하므로 미리 계산해 둡니다. 결국 a2 = roughness⁴입니다.

2-3. 수식 → 기하 직관 → 코드 매핑

GGX 법선 분포의 수학적 정의는 다음과 같습니다:

D(H) = α² / ( π · ( (N·H)²·(α²−1) + 1 )² )

기하 직관(그림글): N과 H가 완전히 같은 방향이면(정면에서 하이라이트 정중앙을 볼 때) N·H = 1이고, 분모의 괄호가 (1·(α²−1)+1)=α²이 되어 D = α²/(π·α⁴) = 1/(π·α²)최대가 됩니다. α(거칠기)가 작을수록 이 최댓값이 커지죠 — 즉 매끄러운 표면은 하이라이트가 아주 좁고 뾰족하며 강렬합니다. 반대로 N과 H가 조금만 어긋나도(N·H가 1에서 떨어짐) 분모가 급격히 커져 D가 뚝 떨어집니다. 이 "급격히 떨어지는 정도"가 하이라이트의 가장자리 롤오프이며, GGX가 다른 분포보다 사랑받는 이유가 바로 이 긴 꼬리(가장자리에서 완전히 0이 되지 않고 은은히 남는) 특성 때문입니다.

코드 매핑:

2-4. 손계산 예제

roughness = 0.5라 해 봅시다. 그러면 a = 0.25, a2 = 0.0625입니다. 하이라이트 정중앙 (N·H = 1)에서:

이번엔 roughness = 0.1(매끈)로 같은 정중앙을 계산하면 a2 = 0.0001, 분모 ≈ π·0.0001² ≈ 3.14e-8, D ≈ 0.0001 / 3.14e-8 ≈ 3183. 거칠기만 0.5→0.1로 낮췄는데 정점 밝기가 약 5에서 3000 이상으로 폭증했습니다. 이것이 매끈한 금속의 눈부신 점 하이라이트가 나오는 수치적 이유입니다.

함정 max(denom, 1e-7)의 작은 엡실론은 0으로 나누기를 막습니다. α가 아주 작고 N·H가 특정 값일 때 분모가 0에 가까워질 수 있는데, 이때 나눗셈이 inf/NaN을 내면 그 픽셀이 검게 죽거나 화면 전체 톤매핑을 오염시킬 수 있습니다. 엡실론은 "물리적으로는 무한대지만 화면에는 아주 밝은 유한값으로" 안전하게 클램프하는 장치입니다.

3. G — 기하 감쇠 함수 (Smith + Schlick-GGX)

무엇을 만드나: 미세면들이 서로를 가려 발생하는 빛 손실 비율을 [0,1]로 돌려줍니다. 거친 표면일수록 봉우리와 골이 험해서, 빛이 골에 못 들어가거나(그림자, shadowing) 튕겨나온 빛이 옆 봉우리에 막혀 시선까지 못 오는(가림, masking) 일이 잦습니다. G는 이 손실을 보정해 표면이 실제보다 밝아지지 않게 합니다.

pbr.hlsl — 기하 감쇠 Ghlsl
// Schlick-GGX: 한 방향에 대한 기하 감쇠
float geometrySchlickGGX(float NdotV, float rough) {
    float r = rough + 1.0;
    float k = (r * r) / 8.0;           // 직접광용 k
    return NdotV / (NdotV * (1.0 - k) + k);
}

// Smith: 시선(V)과 광원(L) 양쪽의 가림을 곱한다
float geometrySmith(float3 N, float3 V, float3 L, float rough) {
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    return geometrySchlickGGX(NdotV, rough) * geometrySchlickGGX(NdotL, rough);
}

3-1. geometrySchlickGGX — 한 방향의 가림

이 함수는 한 방향(시선이든 광원이든)에 대한 가림 계수 하나를 계산합니다. 인자 NdotV는 그 방향과 법선의 코사인, rough는 거칠기입니다.

기하 직관: NdotV가 1(정면에서 봄)이면 반환값은 1/(1·(1−k)+k) = 1/1 = 1, 즉 가림이 전혀 없습니다. 정면에서는 골 속까지 다 보이니까요. 반대로 NdotV가 0에 가까우면 (거의 스치듯 봄) 반환값이 0/(0+k) = 0으로 떨어져 거의 다 가려집니다 — 스침각에서는 봉우리 뒤가 안 보이니 당연합니다. k가 클수록(거칠수록) 이 감쇠가 더 이른 각도에서 시작됩니다.

3-2. geometrySmith — 왜 두 번 곱하나

빛이 눈에 도달하려면 두 관문을 통과해야 합니다. (1) 광원 L에서 미세면까지 들어오는 길에 가림이 없어야 하고(shadowing), (2) 미세면에서 시선 V까지 나가는 길에도 가림이 없어야 합니다 (masking). Smith 근사는 이 두 확률이 독립이라고 가정하고 합니다:

G = G1(N·V) · G1(N·L)

직관 G가 [0,1]인 이유: 각 G₁이 확률처럼 0~1이고, 두 확률의 곱도 0~1이기 때문입니다. G=1은 "아무것도 안 가려짐", G=0은 "완전히 가려짐"을 뜻합니다. 스페큘러가 스침각에서 너무 밝아지는 것을 막는 게 G의 실질적 역할입니다(D만 있으면 가장자리가 과하게 타버립니다).

4. F — 프레넬 (Schlick 근사)

무엇을 만드나: 표면을 바라보는 각도에 따라 정반사율이 얼마인지를 RGB 3채널로 돌려줍니다. 잔잔한 호수를 정면 위에서 보면 바닥이 비치지만(반사 약함), 멀리 수평선 쪽으로 스치듯 보면 거울처럼 하늘이 반사되는(반사 강함) 현상 — 그것이 프레넬 효과입니다.

pbr.hlsl — 프레넬 Fhlsl
// F: 프레넬 Schlick 근사. 스치는 각도일수록 반사율 상승
float3 fresnelSchlick(float cosTheta, float3 F0) {
    return F0 + (1.0 - F0) * pow(saturate(1.0 - cosTheta), 5.0);
}

4-1. 시그니처 — 왜 반환이 float3인가

반환 타입이 float3인 것이 핵심입니다. 금속은 파장(색)마다 반사율이 다릅니다 — 금이 노랗게, 구리가 붉게 반짝이는 이유죠. 그래서 반사율은 스칼라 하나가 아니라 RGB 세 값(F0float3)이며, 결과 F도 float3입니다. 인자 cosTheta는 보통 H·V (하프벡터와 시선의 코사인), F0정면에서의 기본 반사율입니다.

4-2. 수식 → 직관 → 코드

F(θ) = F0 + (1 − F0)·(1 − cosθ)⁵

기하 직관: 정면(cosθ = 1)이면 (1−1)⁵ = 0이라 F = F0 — 기본 반사율만 나옵니다. 스침각(cosθ → 0)이면 (1−0)⁵ = 1이라 F = F0 + (1−F0) = 1모든 재질이 스침각에서는 완전 거울이 됩니다. 5제곱은 이 상승이 오직 각도가 극단적으로 스칠 때만 급격히 일어나도록 만드는 Schlick의 경험적 근사입니다(정밀한 프레넬 방정식을 값싸게 흉내낸 것).

직관 F0 예시 값: 물 0.02, 플라스틱/피부 0.04, 다이아몬드 0.17, 금속은 0.5~1.0(색을 띔). 다음 절에서 비금속은 0.04로 고정하고 금속은 albedo를 F0로 쓰는 이유가 여기 있습니다.

5. main — 조각들을 Cook-Torrance로 조립

이제 D·G·F를 한데 모아 실제 픽셀 색을 계산합니다. 큰 흐름은 이렇습니다.

  1. 이 픽셀의 법선 N과 시선 V를 정규화한다.
  2. 재질로부터 기본 반사율 F0를 정한다(금속/비금속 구분).
  3. 광원마다 반복: L·H·거리감쇠를 구하고, D·G·F로 스페큘러를, 에너지 보존으로 확산을 계산해 누적.
  4. 단순 앰비언트를 더하고, HDR→LDR 톤매핑 + 감마 후 출력.
pbr.hlsl — 픽셀 셰이더 본체hlsl
float4 main(PSIn i) : SV_Target {
    float3 N = normalize(i.normal);
    float3 V = normalize(camPos - i.worldPos);

    // 비금속 기본 반사율 0.04, 금속이면 albedo 자체가 F0
    float3 F0 = lerp((float3)0.04, albedo, metallic);

    float3 Lo = (float3)0.0;            // 나가는 복사휘도 누적
    for (int idx = 0; idx < NR_LIGHTS; ++idx) {
        float3 L = normalize(lightPositions[idx].xyz - i.worldPos);
        float3 H = normalize(V + L);
        float dist = length(lightPositions[idx].xyz - i.worldPos);
        float atten = 1.0 / (dist * dist);          // 물리적 역제곱 감쇠
        float3 radiance = lightColors[idx].xyz * atten;

        // Cook-Torrance 스페큘러 항
        float  D = distributionGGX(N, H, roughness);
        float  G = geometrySmith(N, V, L, roughness);
        float3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

        float3 numerator = D * G * F;
        float denom = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 1e-4;
        float3 specular = numerator / denom;

        // 에너지 보존: 반사한 만큼 확산은 줄이고, 금속은 확산이 없다
        float3 kd = ((float3)1.0 - F) * (1.0 - metallic);

        float NdotL = max(dot(N, L), 0.0);
        Lo += (kd * albedo / PI + specular) * radiance * NdotL;
    }

    // 아주 단순한 앰비언트 (뒤에서 IBL로 대체하는 것이 정석)
    float3 ambient = (float3)0.03 * albedo * ao;
    float3 color = ambient + Lo;

    // HDR → LDR: 다음 절의 톤매핑으로 넘기려면 이 두 줄은 빼고 float4(color,1)만 출력
    color = color / (color + (float3)1.0);          // Reinhard
    color = pow(color, (float3)(1.0 / 2.2));         // 감마
    return float4(color, 1.0);
}

5-1. N과 V 준비

5-2. F0 — 금속/비금속을 lerp로 통합

float3 F0 = lerp((float3)0.04, albedo, metallic);

lerp(a, b, t)는 선형 보간 a·(1−t) + b·t입니다. 여기서:

5-3. 광원 루프 — 반사 방정식의 Σ

Lo는 이 픽셀에서 카메라로 나가는 빛(복사휘도)을 누적하는 변수로, 0에서 시작합니다. for (int idx = 0; idx < NR_LIGHTS; ++idx)가 각 광원의 기여를 더합니다. 루프 안:

5-4. Cook-Torrance 스페큘러 조립

5-5. 에너지 보존과 확산 — 가장 중요한 한 줄

float3 kd = ((float3)1.0 - F) * (1.0 - metallic);

이 한 줄이 PBR을 "물리적"으로 만드는 핵심입니다. 두 인수를 봅시다.

마지막 누적:

함정 확산에 /PI가 붙는 이유를 빼먹기 쉽습니다. 람베르트 확산 BRDF는 albedo/π입니다 — 반구 전체로 균일하게 재방출되는 에너지를 정규화하는 상수 π입니다. 이걸 생략하면 확산이 약 3.14배 과하게 밝아져 정반사와의 균형이 깨집니다. 정반사 항에는 이미 분모에 4·(N·V)·(N·L) 정규화가 들어 있으므로 별도 π가 없습니다.

5-6. 앰비언트, 톤매핑, 감마

주의 이 셰이더는 톤매핑·감마를 셰이더 에서 처리하지만, 실무에서는 이 두 줄을 빼고 HDR 그대로 float4(color, 1)를 float 렌더 타깃에 출력한 뒤, 별도 후처리 패스에서 노출·블룸과 함께 톤매핑하는 것이 정석입니다. 자세한 흐름은 다음 페이지(HDR·톤 매핑)에서 다룹니다.
함정 셰이더 내부의 albedo는 반드시 선형 공간이어야 합니다. sRGB 텍스처를 그대로 쓰면 조명이 두 번 감마 처리되어 어두워집니다. DXGI_FORMAT_R8G8B8A8_UNORM_SRGB 포맷으로 SRV를 만들어 샘플 시 자동 선형화하거나, 샘플 직후 pow(c, 2.2)로 직접 선형화하세요.
성능 실제 재질감은 IBL(Image-Based Lighting)에서 나옵니다. HDR 환경맵을 미리 필터링해 irradiance 큐브맵(확산)과 prefiltered 큐브맵 + BRDF LUT(스페큘러)을 만들어 앰비언트를 대체하면 추가 조명 없이도 사실적인 결과가 나옵니다. 또한 위 루프의 D·G·F 계산은 픽셀·광원당 반복되므로, 광원이 많다면 디퍼드 렌더링으로 셰이딩 비용을 화면 픽셀 수로 고정하는 것이 유리합니다.

6. C++ · DirectXMath 대응 — 상수 버퍼 업로드

셰이더가 아무리 완벽해도 C++에서 상수 버퍼를 정확한 레이아웃으로 채워 넘기지 못하면 화면은 엉망이 됩니다. HLSL cbuffer와 바이트 단위로 일치하는 C++ 구조체를 만들고, 매 프레임 업로드하는 코드를 보겠습니다.

pbr_upload.cppcpp
#include <DirectXMath.h>
using namespace DirectX;
using Microsoft::WRL::ComPtr;

static const int NR_LIGHTS = 4;

// HLSL cbuffer PBRConstants와 바이트 레이아웃이 1:1로 일치해야 한다.
// 각 float3 뒤에 float 패딩을 넣어 16바이트 정렬을 셰이더와 똑같이 맞춘다.
struct PBRConstantsCPU {
    XMFLOAT3 camPos;    float _pad0;
    XMFLOAT3 albedo;    float metallic;
    float    roughness; float ao;  XMFLOAT2 _pad1;
    XMFLOAT4 lightPositions[NR_LIGHTS];   // .xyz만 사용, w는 패딩
    XMFLOAT4 lightColors[NR_LIGHTS];
};
static_assert(sizeof(PBRConstantsCPU) % 16 == 0, "cbuffer는 16바이트 배수여야 함");

// --- 상수 버퍼 생성 (한 번만) ---
ComPtr<ID3D11Buffer> pbrCB;
D3D11_BUFFER_DESC bd = {};
bd.ByteWidth      = sizeof(PBRConstantsCPU);   // 16 배수
bd.Usage          = D3D11_USAGE_DYNAMIC;       // CPU가 매 프레임 갱신
bd.BindFlags      = D3D11_BIND_CONSTANT_BUFFER;
bd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;    // Map으로 쓰기
device->CreateBuffer(&bd, nullptr, &pbrCB);

// --- 매 프레임 값 채워 업로드 ---
PBRConstantsCPU c = {};
c.camPos    = { camX, camY, camZ };
c.albedo    = { 1.0f, 0.765f, 0.336f };  // 금색 (이미 선형 공간 값)
c.metallic  = 1.0f;
c.roughness = 0.2f;
c.ao        = 1.0f;
for (int k = 0; k < NR_LIGHTS; ++k) {
    c.lightPositions[k] = { lx[k], ly[k], lz[k], 1.0f };
    c.lightColors[k]    = { 300.0f, 300.0f, 300.0f, 1.0f }; // HDR 세기
}

D3D11_MAPPED_SUBRESOURCE ms;
ctx->Map(pbrCB.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &ms);
memcpy(ms.pData, &c, sizeof(c));
ctx->Unmap(pbrCB.Get(), 0);

// b0 슬롯에 바인딩 (HLSL의 register(b0)과 일치)
ctx->PSSetConstantBuffers(0, 1, pbrCB.GetAddressOf());

6-1. 구조체 레이아웃 1:1 매핑

이 C++ 구조체는 1절의 HLSL cbuffer와 필드 순서·패딩까지 완전히 동일합니다. HLSL의 float3는 C++의 XMFLOAT3(float 3개, 12바이트), float4XMFLOAT4 (16바이트)에 대응합니다. _pad0·_pad1이 셰이더와 같은 자리에 있어야 값이 밀리지 않습니다. static_assert(sizeof(...) % 16 == 0, ...)는 이 구조체가 16의 배수인지 컴파일 타임에 검사해, 레이아웃 실수를 빌드 단계에서 잡아줍니다.

함정 XMFLOAT3XMVECTOR를 혼동하지 마세요. XMVECTOR는 SIMD 정렬(16바이트) 레지스터 타입이라 구조체 멤버로 두면 안 됩니다(정렬 요구 때문에 예상치 못한 패딩·오정렬 발생). 저장·업로드 용은 항상 XMFLOAT2/3/4를 쓰고, 계산할 때만 XMLoadFloat3XMVECTOR로 올렸다가 XMStoreFloat3로 다시 내리는 것이 DirectXMath의 정석 패턴입니다.

6-2. HLSL 내적/정규화의 DirectXMath 대응

이 예제는 조명 계산을 셰이더에서 하므로 C++ 수학이 많이 필요 없지만, 만약 CPU에서 같은 계산을 검증하고 싶다면 HLSL 내장 함수는 다음과 같이 1:1 대응됩니다.

HLSL (셰이더)DirectXMath (C++)의미
dot(N, H)XMVector3Dot(N, H)두 벡터의 내적(코사인)
normalize(v)XMVector3Normalize(v)단위벡터로
length(v)XMVector3Length(v)벡터 길이(거리)
V + LXMVectorAdd(V, L)성분별 덧셈(하프벡터 전 단계)
lerp(a, b, t)XMVectorLerp(a, b, t)선형 보간(F0 계산)
pow(x, 5)XMVectorPow(x, {5,5,5,5})성분별 거듭제곱(프레넬)
saturate(x)XMVectorSaturate(x)[0,1] 클램프

XMFLOAT3 f = {...}를 계산에 쓰려면 먼저 XMVECTOR v = XMLoadFloat3(&f);로 올린 뒤 위 함수들을 적용하고, 결과를 XMStoreFloat3(&f, v);로 내립니다. 이 load/store 왕복이 SIMD의 비용이자 규칙입니다.

성능 상수 버퍼 업로드에는 Map(D3D11_MAP_WRITE_DISCARD)를 썼습니다. WRITE_DISCARD는 "이전 내용은 버리고 새 메모리를 달라"는 뜻이라, GPU가 아직 옛 버퍼를 쓰는 중이라도 드라이버가 새 영역을 내주어 CPU가 멈추지(stall) 않습니다. 매 프레임 바뀌는 상수 버퍼의 표준 갱신 방식입니다. 반대로 거의 안 바뀌는 데이터는 UpdateSubresource가 더 단순합니다.