물리 기반 렌더링 (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에 대해, 그 방향에서 들어온 빛
Li에 BRDF f(그 빛이 시선 방향 V로 얼마나 튕겨나가는지의 비율)와
람베르트 코사인 항 N·L(비스듬히 들어온 빛은 단위 면적당 세기가 줄어듦)을 곱해
전부 더한(적분한) 것이 나가는 빛 Lo라는 뜻입니다.
- 점광원 근사: 실시간에서는 반구 전체를 적분하지 못하므로, 광원을 몇 개의 점으로 보고
적분(∫)을 합(Σ)으로 바꿉니다. 코드의
for루프가 바로 이 Σ입니다. - BRDF
f를 물리적으로 만든다: 이것이 PBR의 핵심입니다. Cook-Torrance는f를 확산(diffuse) 항과 정반사(specular) 항의 합으로 씁니다. -
f = kd·(albedo/π) + (D·G·F) / (4·(N·V)·(N·L)) - D — 법선 분포(GGX/Trowbridge-Reitz): 표면을 이루는 무수히 작은 거울(미세면) 중, 하필 하프벡터 H 방향으로 정렬되어 시선으로 빛을 정확히 튕겨줄 미세면의 비율. roughness가 작을수록 좁고 강렬한 하이라이트가 됩니다.
- G — 기하 감쇠(Smith + Schlick-GGX): 미세면들이 서로를 가려(masking) 빛을 못 받거나, 반사된 빛이 옆 봉우리에 막혀(shadowing) 시선까지 못 오는 손실.
- F — 프레넬(Schlick 근사): 표면을 스치듯 비스듬히 볼수록 반사율이 급격히 커지는 현상.
정면에서의 기본 반사율
F0에서 출발해 스침각에서 1(완전 반사)로 향합니다. - 금속-거칠기 워크플로 + 에너지 보존: 비금속은
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에서 업로드합니다.
// 정점 셰이더에서 넘어온 보간값
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에 알려주는 이름표입니다.
float4 posH : SV_Position— SV_는 "system value"의 약자로, GPU가 특별하게 다루는 시맨틱입니다.SV_Position은 클립→스크린 변환이 끝난 화면 픽셀 좌표라서, 래스터라이저가 어느 픽셀을 칠할지 결정하는 데 씁니다. 픽셀 셰이더 안에서는 보통 직접 쓰지 않습니다.float3 worldPos : WORLDPOS— 이 픽셀에 대응하는 표면 점의 월드 좌표입니다. 광원과의 거리·방향을 재려면 반드시 광원과 같은 좌표계에 있어야 하므로 월드 공간으로 넘깁니다.WORLDPOS는 사용자 정의 시맨틱이라 이름은 자유이며, 정점 셰이더 출력과 철자만 맞으면 됩니다.float3 normal : NORMAL— 월드 공간 법선. 표면이 향하는 방향입니다. 뒤에서normalize하는 이유는, 세 정점의 법선을 픽셀 위치에 맞춰 선형 보간하면 길이가 1이 아니게 되기 때문입니다(단위벡터 두 개를 섞으면 대개 짧아집니다).float2 uv : TEXCOORD0— 텍스처 좌표. 이 예제는 재질을 균일값으로 단순화해 실제로 쓰진 않지만, 실무에서 albedo/normal/roughness 맵을 샘플할 때 필요합니다.
1-2. cbuffer — 16바이트 정렬과 패딩의 정체
cbuffer PBRConstants : register(b0)는 상수 버퍼입니다. C++ 쪽에서 b0 슬롯에 바인딩한
구조체가 여기로 들어옵니다. 여기서 입문자가 가장 헷갈리는 것이 중간중간 끼어 있는 _pad0,
_pad1 같은 패딩 변수입니다.
HLSL 상수 버퍼에는 엄격한 규칙이 하나 있습니다: 어떤 변수도 16바이트(float4 하나 크기)
경계를 걸쳐서는 안 된다는 것입니다. GPU는 상수를 16바이트짜리 레지스터 단위로 읽기 때문입니다.
한 줄씩 배치를 따라가 봅시다(각 칸 = 4바이트 = float 하나):
| 16바이트 레지스터 | .x | .y | .z | .w |
|---|---|---|---|---|
| 레지스터 0 | camPos (float3) | _pad0 | ||
| 레지스터 1 | albedo (float3) | metallic | ||
| 레지스터 2 | roughness | ao | _pad1 (float2) | |
| 레지스터 3~ | lightPositions[0] (float4) | |||
camPos는 float3라 12바이트를 쓰고 4바이트가 남습니다. 만약 바로 뒤에
albedo(또 float3)를 붙이면 albedo의 일부가 레지스터 경계를 넘어가 버립니다. 그래서
_pad0로 남은 4바이트를 채워 albedo가 새 레지스터에서 시작하게 만든 것입니다.
metallic은 마침 albedo 뒤 4바이트에 딱 맞아 같은 레지스터에 얹혔습니다. 이렇게
"float3 + float1"을 짝지어 배치하는 것이 상수 버퍼 설계의 기본기입니다.
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를 향한
미세면이 얼마나 많은가"가 곧 "그 픽셀이 얼마나 밝게 반짝일 수 있는가"입니다.
// 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이 되지 않고 은은히 남는)
특성 때문입니다.
코드 매핑:
NdotH = max(dot(N, H), 0.0)— 수식의N·H. 두 단위벡터의 내적은 사이각의 코사인입니다.max(...,0)로 음수(뒷면 방향)를 잘라 물리적으로 말이 되게 합니다.NdotH2 = NdotH * NdotH— 수식의(N·H)².denom = (NdotH2 * (a2 - 1.0) + 1.0)— 분모 안쪽 괄호(N·H)²(α²−1)+1그대로입니다.denom = PI * denom * denom— 그 괄호를 제곱하고 π를 곱해 완전한 분모π·(...)²를 만듭니다.return a2 / max(denom, 1e-7)— 분자 α²를 분모로 나눕니다.
2-4. 손계산 예제
roughness = 0.5라 해 봅시다. 그러면 a = 0.25, a2 = 0.0625입니다. 하이라이트 정중앙
(N·H = 1)에서:
- 괄호 =
1·(0.0625−1)+1 = 0.0625 - 분모 =
π · 0.0625² = 3.14159 · 0.003906 ≈ 0.012272 - D =
0.0625 / 0.012272 ≈ 5.093
이번엔 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는 이 손실을 보정해 표면이 실제보다
밝아지지 않게 합니다.
// 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는 거칠기입니다.
float r = rough + 1.0;— 다음 줄에서 쓸 중간값입니다.float k = (r * r) / 8.0;— Schlick-GGX가 요구하는 재매핑 계수 k입니다. 직접광(점광원/방향광)에서는k = (roughness+1)²/8을 씁니다. (참고: IBL 환경광에서는k = roughness²/2로 다르게 씁니다. 상황에 맞는 k를 쓰지 않으면 거친 표면이 부자연스럽게 어두워지거나 밝아집니다.)return NdotV / (NdotV * (1.0 - k) + k);— Schlick-GGX 본체입니다.
기하 직관: 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)
NdotV = max(dot(N, V), 0.0)— 시선 쪽 코사인(가림/masking 담당).NdotL = max(dot(N, L), 0.0)— 광원 쪽 코사인(그림자/shadowing 담당).- 둘 각각에
geometrySchlickGGX를 적용해 곱합니다.
[0,1]인 이유: 각 G₁이 확률처럼 0~1이고, 두 확률의 곱도 0~1이기 때문입니다. G=1은 "아무것도
안 가려짐", G=0은 "완전히 가려짐"을 뜻합니다. 스페큘러가 스침각에서 너무 밝아지는 것을 막는 게 G의
실질적 역할입니다(D만 있으면 가장자리가 과하게 타버립니다).
4. F — 프레넬 (Schlick 근사)
무엇을 만드나: 표면을 바라보는 각도에 따라 정반사율이 얼마인지를 RGB 3채널로 돌려줍니다. 잔잔한 호수를 정면 위에서 보면 바닥이 비치지만(반사 약함), 멀리 수평선 쪽으로 스치듯 보면 거울처럼 하늘이 반사되는(반사 강함) 현상 — 그것이 프레넬 효과입니다.
// 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 세 값(F0가
float3)이며, 결과 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의 경험적 근사입니다(정밀한 프레넬 방정식을 값싸게 흉내낸 것).
saturate(1.0 - cosTheta)—1−cosθ를[0,1]로 클램프합니다.saturate는 GPU에서 사실상 공짜(하드웨어 지원)라clamp(x,0,1)보다 선호됩니다. 음수가 들어와pow에서 NaN이 나는 것을 막는 안전장치이기도 합니다.pow(..., 5.0)— 5제곱.F0 + (1.0 - F0) * ...—F0와 1 사이를 위 5제곱 값으로 보간합니다. 결과적으로 F0(정면)에서 1(스침각)로 부드럽게 올라갑니다.
F0 예시 값: 물 0.02, 플라스틱/피부 0.04, 다이아몬드 0.17, 금속은 0.5~1.0(색을 띔). 다음
절에서 비금속은 0.04로 고정하고 금속은 albedo를 F0로 쓰는 이유가 여기 있습니다.
5. main — 조각들을 Cook-Torrance로 조립
이제 D·G·F를 한데 모아 실제 픽셀 색을 계산합니다. 큰 흐름은 이렇습니다.
- 이 픽셀의 법선 N과 시선 V를 정규화한다.
- 재질로부터 기본 반사율 F0를 정한다(금속/비금속 구분).
- 광원마다 반복: L·H·거리감쇠를 구하고, D·G·F로 스페큘러를, 에너지 보존으로 확산을 계산해 누적.
- 단순 앰비언트를 더하고, HDR→LDR 톤매핑 + 감마 후 출력.
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 준비
float3 N = normalize(i.normal);— 앞서 말한 대로 보간으로 길이가 망가진 법선을 단위벡터로 되돌립니다. 이후 모든 내적(N·L,N·V등)이 정확한 코사인이 되려면 필수입니다.float3 V = normalize(camPos - i.worldPos);— 시선 벡터는 표면에서 카메라로 향합니다.camPos - worldPos순서(끝점 − 시작점)에 주의하세요. 방향만 필요하므로 정규화합니다.
5-2. F0 — 금속/비금속을 lerp로 통합
float3 F0 = lerp((float3)0.04, albedo, metallic);
lerp(a, b, t)는 선형 보간 a·(1−t) + b·t입니다. 여기서:
metallic = 0(완전 비금속) →F0 = 0.04(회색빛 약한 반사). 비금속의 정반사는 색이 없어(흰색) 세 채널 모두 0.04입니다.metallic = 1(완전 금속) →F0 = albedo. 금속은 확산이 없고 정반사가 곧 그 금속의 색이므로, albedo를 그대로 반사색(F0)으로 씁니다.- 중간값은 두 상태를 섞습니다(반쯤 녹슨 금속 등을 표현).
(float3)0.04는 스칼라 0.04를 세 채널로 복제하는 HLSL 캐스트 문법입니다.
5-3. 광원 루프 — 반사 방정식의 Σ
Lo는 이 픽셀에서 카메라로 나가는 빛(복사휘도)을 누적하는 변수로, 0에서
시작합니다. for (int idx = 0; idx < NR_LIGHTS; ++idx)가 각 광원의 기여를 더합니다. 루프 안:
L = normalize(lightPositions[idx].xyz - i.worldPos)— 표면에서 광원으로 향하는 단위벡터. V와 마찬가지로 "끝 − 시작"입니다.H = normalize(V + L)— 하프벡터. V와 L의 정중앙 방향(2절에서 설명). D와 F가 이걸 씁니다.dist = length(lightPositions[idx].xyz - i.worldPos)— 광원까지의 실제 거리(월드 단위).atten = 1.0 / (dist * dist)— 역제곱 감쇠. 점광원의 빛은 거리 제곱에 반비례해 약해집니다(구면으로 퍼지므로). 물리적으로 정확한 형태라 PBR에서는 이걸 씁니다.radiance = lightColors[idx].xyz * atten— 이 픽셀에 실제로 도달한 광원의 세기(색 × 감쇠). 반사 방정식의Li에 해당합니다.
5-4. Cook-Torrance 스페큘러 조립
D = distributionGGX(N, H, roughness)·G = geometrySmith(N, V, L, roughness)·F = fresnelSchlick(max(dot(H, V), 0.0), F0)— 앞서 만든 세 함수 호출. F의 각도로H·V를 쓰는 것에 주목(스페큘러는 하프벡터 기준으로 반사가 일어나므로).numerator = D * G * F— 분자D·G·F. D·G는 스칼라, F는 float3라 결과가 float3.denom = 4.0 * max(dot(N,V),0.0) * max(dot(N,L),0.0) + 1e-4— Cook-Torrance 정규화 분모4·(N·V)·(N·L). 끝의+1e-4는N·V나N·L이 0이 되어 0으로 나누는 것을 막는 엡실론입니다.specular = numerator / denom— 최종 정반사 항.
5-5. 에너지 보존과 확산 — 가장 중요한 한 줄
float3 kd = ((float3)1.0 - F) * (1.0 - metallic);
이 한 줄이 PBR을 "물리적"으로 만드는 핵심입니다. 두 인수를 봅시다.
(1.0 - F)— 에너지 보존. F는 정반사로 튕겨나간 빛의 비율입니다. 그러면 나머지(1−F)만이 표면 속으로 들어가 확산될 수 있습니다. 정반사가 강한 곳(스침각)일수록 확산은 자동으로 줄어들어, 반사+확산 합이 입사량을 절대 넘지 않습니다. 이것이 퐁 모델과의 결정적 차이 — 퐁은 이 둘을 따로 튜닝해 쉽게 에너지 위반이 일어납니다.(1.0 - metallic)— 금속은 확산이 없다. 금속은 들어온 빛을 표면 속에서 흡수해 버려 확산 반사가 거의 없습니다. metallic=1이면 이 인수가 0이라 확산 항 전체가 사라집니다.
마지막 누적:
NdotL = max(dot(N, L), 0.0)— 람베르트 코사인 항. 빛이 비스듬히 들어올수록 단위 면적당 받는 에너지가 줄어드는 물리 현상. 뒷면(음수)은 잘라냅니다.Lo += (kd * albedo / PI + specular) * radiance * NdotL— 반사 방정식 그 자체입니다. 괄호 안이 BRDFf(확산kd·albedo/π+ 정반사specular), 거기에 도달광radiance와 코사인NdotL을 곱해 이 광원의 기여를 누적합니다.
/PI가 붙는 이유를 빼먹기 쉽습니다. 람베르트 확산 BRDF는 albedo/π입니다 —
반구 전체로 균일하게 재방출되는 에너지를 정규화하는 상수 π입니다. 이걸 생략하면 확산이 약 3.14배
과하게 밝아져 정반사와의 균형이 깨집니다. 정반사 항에는 이미 분모에 4·(N·V)·(N·L) 정규화가
들어 있으므로 별도 π가 없습니다.
5-6. 앰비언트, 톤매핑, 감마
ambient = (float3)0.03 * albedo * ao— 아주 조잡한 환경광입니다. 직접광이 안 닿는 부분이 완전 검게 되지 않도록 albedo의 3%를 깔아줍니다.ao는 ambient occlusion(구석이 더 어두워지는 정도). 정석은 IBL로 대체하는 것입니다(아래 perf callout 참고).color = ambient + Lo— 환경광 + 직접광 누적.color = color / (color + (float3)1.0)— Reinhard 톤매핑. PBR 결과는 1.0을 훌쩍 넘는 HDR이라 그대로 화면에 못 냅니다. 이 식은[0,∞)를[0,1)로 부드럽게 압축합니다 (큰 값일수록 1에 수렴).color = pow(color, (float3)(1.0/2.2))— 감마 보정. 지금까지 모든 계산은 선형 공간에서 했으므로, sRGB 디스플레이용으로1/2.2제곱해 인코딩합니다.return float4(color, 1.0)— 알파 1(불투명)로 출력.
float4(color, 1)를 float 렌더 타깃에 출력한 뒤, 별도 후처리 패스에서 노출·블룸과
함께 톤매핑하는 것이 정석입니다. 자세한 흐름은 다음 페이지(HDR·톤 매핑)에서 다룹니다.
albedo는 반드시 선형 공간이어야 합니다. sRGB 텍스처를 그대로
쓰면 조명이 두 번 감마 처리되어 어두워집니다. DXGI_FORMAT_R8G8B8A8_UNORM_SRGB 포맷으로 SRV를
만들어 샘플 시 자동 선형화하거나, 샘플 직후 pow(c, 2.2)로 직접 선형화하세요.
6. C++ · DirectXMath 대응 — 상수 버퍼 업로드
셰이더가 아무리 완벽해도 C++에서 상수 버퍼를 정확한 레이아웃으로 채워 넘기지 못하면 화면은 엉망이 됩니다. HLSL cbuffer와 바이트 단위로 일치하는 C++ 구조체를 만들고, 매 프레임 업로드하는 코드를 보겠습니다.
#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바이트), float4는 XMFLOAT4
(16바이트)에 대응합니다. _pad0·_pad1이 셰이더와 같은 자리에 있어야 값이 밀리지
않습니다. static_assert(sizeof(...) % 16 == 0, ...)는 이 구조체가 16의 배수인지
컴파일 타임에 검사해, 레이아웃 실수를 빌드 단계에서 잡아줍니다.
XMFLOAT3와 XMVECTOR를 혼동하지 마세요. XMVECTOR는 SIMD 정렬(16바이트) 레지스터
타입이라 구조체 멤버로 두면 안 됩니다(정렬 요구 때문에 예상치 못한 패딩·오정렬 발생). 저장·업로드
용은 항상 XMFLOAT2/3/4를 쓰고, 계산할 때만 XMLoadFloat3로 XMVECTOR로
올렸다가 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 + L | XMVectorAdd(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가 더 단순합니다.