조명과 그림자 (퐁 셰이딩) — 한 줄 한 줄 직접 구현
3D 물체가 "입체로 보이는" 이유는 표면마다 밝기가 다르기 때문입니다. 그 밝기를 계산하는 가장 고전적인 방법이 퐁(Phong) 조명 모델입니다. 이 페이지는 퐁의 세 성분 — ambient(주변광), diffuse(난반사), specular(정반사) — 을 HLSL 픽셀 셰이더에서 한 줄씩 만들고, 각 줄의 벡터 연산이 기하학적으로 무엇을 뜻하는지를 화살표 그림글로 풀어냅니다. 또한 초보자가 가장 많이 틀리는 법선 변환(법선 행렬)과 cbuffer 16바이트 패킹을 성분 하나까지 짚고, C++/DirectXMath로 상수 버퍼를 채워 GPU에 올리는 과정을 대응시킵니다.
v' = v * M(HLSL mul(v, M)),
행 우선 저장, 왼손 좌표계, 클립 공간 z 범위 [0, 1], 텍스처 V축 위→아래.
DirectXMath도 행벡터라서 cbuffer로 올리기 전에 전치(transpose)가 필요합니다(뒤에서 설명).
조명 계산 자체는 좌표계 손잡이(handedness)와 무관한 순수 벡터 연산이지만, 법선의 방향과
앞면/뒷면 판정은 규약에 따라 달라지니 다른 자료의 부호를 그대로 옮기지 마세요.
- 정점 셰이더에서 월드 공간 위치와 법선 행렬로 변환한 법선을 픽셀 셰이더로 넘긴다.
- 픽셀 셰이더에서 ambient + diffuse + specular를 합산해 최종 색을 만든다.
- C++에서 광원 위치/색, 카메라 위치, 물체 색을 상수 버퍼로 채워 셰이더에 바인딩한다.
먼저: 퐁 모델의 세 성분, 한 문장씩 직관
코드를 보기 전에, 퐁이 왜 이 세 가지를 더하는지 그림으로 잡고 갑니다. 현실의 빛은 무한히 튕기고 흩어지지만, 실시간 렌더링은 그걸 그대로 계산할 수 없어서 세 개의 근사로 나눕니다.
ambient(주변광) — "빛이 아예 안 닿는 곳도 새까맣진 않다"
현실에서는 빛이 벽·바닥·천장에 튕겨 그늘진 곳도 어느 정도 밝습니다. 이 튕김을 일일이 계산하는 대신, 모든 픽셀에 최소 밝기를 일정하게 더하는 아주 거친 근사가 ambient입니다. 방향도 각도도 없이 그냥 상수를 더할 뿐이라, 물체 전체가 살짝 들뜬 바탕색을 갖게 됩니다. 없으면 빛을 등진 면이 완전히 새까맣게(RGB 0,0,0) 나와 형체를 알 수 없습니다.
diffuse(난반사) — "표면이 빛을 정면으로 받을수록 밝다"
거친(매트한) 표면은 들어온 빛을 사방으로 고르게 흩뿌립니다. 그래서 보는 방향과 무관하게, 오직
표면이 빛을 얼마나 정면으로 받는가만으로 밝기가 정해집니다. 이 "정면으로 받는 정도"가 바로
표면 법선 N과 빛 방향 L의 내적 dot(N, L)입니다. 정면(N과 L이 같은
방향)이면 1, 90°로 스치면 0, 등지면 음수(→ 0으로 잘라냄)가 됩니다. 이게 물체의 "명암"을 만드는 핵심입니다.
specular(정반사) — "매끈한 표면의 반짝이는 하이라이트"
금속·플라스틱·물처럼 매끈한 표면은 빛을 거울처럼 한 방향으로 튕겨냅니다. 그래서 내 눈이 그 반사 방향에 정확히 놓였을 때만 강한 하이라이트가 보입니다. diffuse와 달리 specular는 보는 방향(V)에 의존합니다. 반사가 얼마나 눈 쪽을 향하는지를 각도로 재고, 거기에 큰 지수(광택, shininess)를 걸어 "정확히 맞을 때만 확 밝아지는" 좁고 날카로운 점을 만듭니다.
최종 색은 (ambient + diffuse + specular) * objectColor입니다. 세 밝기 성분을 더한 뒤 물체 고유색을
곱하는 구조인데, 이 곱셈이 왜 이렇게 되는지도 뒤에서 성분별로 뜯어봅니다.
1) HLSL 셰이더 — 퐁 라이팅 전체
무엇을 만드나: GPU에서 픽셀마다 색을 계산하는 정점/픽셀 셰이더 한 쌍입니다. 정점 셰이더는 조명 계산에 필요한 재료(월드 위치·법선)를 준비해 넘기고, 픽셀 셰이더가 실제 퐁 공식을 돌립니다. 왜 필요한가: 조명은 픽셀 단위로 부드럽게 변해야 자연스럽기 때문에, 정점에서 미리 계산해 보간(補間)하기보다 픽셀마다 다시 계산하는 것이 훨씬 매끈합니다(이를 픽셀 단위 라이팅이라 부릅니다).
// ── 상수 버퍼 (b0: 변환, b1: 조명) ──
cbuffer Transforms : register(b0)
{
float4x4 mvp; // model*view*proj (transpose 해 넘김)
float4x4 model; // 월드 변환
float4x4 normalMatrix; // = transpose(inverse(model))의 3x3 (float4x4로 패딩)
};
cbuffer Lighting : register(b1)
{
float3 lightPos; float _pad0;
float3 lightColor; float _pad1;
float3 viewPos; float _pad2; // 카메라 위치
float3 objectColor; float _pad3;
};
// ── 정점 셰이더 (VSMain) ──
struct VSIn { float3 pos : POSITION; float3 normal : NORMAL; };
struct VSOut {
float4 pos : SV_Position;
float3 fragPos : POSITION0; // 월드 공간 위치
float3 normal : NORMAL0; // 월드 공간 법선
};
VSOut VSMain(VSIn input)
{
VSOut o;
float4 world = mul(model, float4(input.pos, 1.0));
o.fragPos = world.xyz;
// 법선은 normalMatrix의 3x3 부분으로 변환
o.normal = normalize(mul((float3x3)normalMatrix, input.normal));
o.pos = mul(mvp, float4(input.pos, 1.0));
return o;
}
// ── 픽셀 셰이더 (PSMain) ──
float4 PSMain(VSOut input) : SV_Target
{
// 1) ambient — 항상 존재하는 최소 밝기
float3 ambient = 0.12 * lightColor;
// 2) diffuse — 표면과 빛 방향의 각도
float3 N = normalize(input.normal);
float3 L = normalize(lightPos - input.fragPos);
float diff = max(dot(N, L), 0.0);
float3 diffuse = diff * lightColor;
// 3) specular — 하프웨이 벡터 방식(블린-퐁)
float3 V = normalize(viewPos - input.fragPos);
float3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), 64.0); // 64 = 광택
float3 specular = 0.5 * spec * lightColor;
// (전통 퐁이라면 reflect 사용:)
// float3 R = reflect(-L, N);
// float spec = pow(max(dot(V, R), 0.0), 32.0);
float3 result = (ambient + diffuse + specular) * objectColor;
return float4(result, 1.0);
}
상수 버퍼 b0·b1 — 셰이더가 매 프레임 받는 상수들
cbuffer(constant buffer, 상수 버퍼)는 CPU가 매 프레임 계산해 GPU에 통째로 올려주는 작은
상수 묶음입니다. 셰이더는 이 값들을 읽기만 합니다. register(b0)·register(b1)의
b는 buffer의 b, 숫자는 슬롯 번호입니다. 나중에 C++에서
PSSetConstantBuffers(1, ...)로 올린 버퍼가 셰이더의 register(b1)과 짝이 됩니다 —
이 번호가 어긋나면 셰이더가 엉뚱한 값을 읽습니다.
float4x4 mvp— model·view·proj를 하나로 합친 변환 행렬. 정점을 최종 클립 공간으로 보냅니다.float4x4 model— 정점을 로컬 공간에서 월드 공간으로 옮기는 변환. 조명은 월드 공간에서 계산하므로 필요합니다.float4x4 normalMatrix— 법선 전용 변환 행렬. 실제로는 3×3이지만 cbuffer 정렬을 위해 4×4로 패딩해 넣습니다(뒤에서 상세히).
조명 버퍼 b1은 float3 네 개(광원 위치·광원 색·카메라 위치·물체 색)와, 그 뒤마다 붙은
float _pad0..3로 이뤄집니다. 이 _pad는 사람이 읽으라고 쓴 값이 아니라, 메모리
레이아웃을 맞추기 위한 빈칸입니다. 왜 이 빈칸이 필요한지는 아래 함정 callout에서 성분 단위로 설명합니다.
cbuffer는 16바이트(float 4개, 즉 float4 하나) 경계로 값을 배치합니다. 규칙은
"하나의 원소가 16바이트 경계를 넘어 걸치면 안 된다"입니다. float3는 12바이트라,
float3 두 개를 연속으로 두면 두 번째가 12~24바이트에 걸쳐 경계(16)를 넘어버립니다. 그러면
컴파일러가 두 번째 float3를 다음 16바이트 칸으로 밀어내고, 그 사이 4바이트가 보이지 않는
패딩으로 생깁니다. CPU 쪽 구조체는 그런 규칙이 없으니, 명시적 float _pad로 그 4바이트를
직접 채워 넣어야 CPU·GPU 레이아웃이 정확히 일치합니다. 안 맞추면 lightColor 자리에
lightPos의 일부가 섞여 들어오는 등 조명이 미묘하게(혹은 완전히) 깨집니다.
정점 셰이더 VSMain — 조명 재료를 준비해 넘기기
VSIn은 정점 버퍼에서 들어오는 입력입니다. float3 pos : POSITION과
float3 normal : NORMAL — 뒤의 : POSITION, : NORMAL은
시맨틱(semantic)이라 부르는 이름표로, "이 필드가 정점 레이아웃의 어느 슬롯과 연결되는가"를
GPU에 알려줍니다. C++의 D3D11_INPUT_ELEMENT_DESC에서 지정한 "POSITION",
"NORMAL" 문자열과 철자까지 정확히 일치해야 연결됩니다.
VSOut은 픽셀 셰이더로 넘어갈 출력입니다. float4 pos : SV_Position의
SV_는 System Value의 약자로, 이 필드가 래스터라이저가 화면 좌표로 쓸
최종 위치라는 특별한 의미를 갖습니다(반드시 있어야 합니다). 나머지 fragPos,
normal은 우리가 조명 계산에 쓰려고 얹어 보내는 값이고, : POSITION0,
: NORMAL0처럼 뒤에 번호를 붙여 서로 구분합니다.
이제 함수 본문을 한 줄씩 봅니다.
float4 world = mul(model, float4(input.pos, 1.0));
수학: 정점 위치를 로컬 공간에서 월드 공간으로 옮깁니다. float4(input.pos, 1.0)는
3차원 위치 (x, y, z)에 w=1을 붙여 4차원 동차좌표로 만드는 것입니다.
w=1을 붙이는 이유는, 행렬의 네 번째 행(또는 열)에 든 평행이동 성분이 이 w와 곱해져 위치에
더해지기 때문입니다. 만약 w=0이면 평행이동이 무시되어 "방향 벡터"처럼 취급됩니다(법선을 옮길 때 이 성질을 씁니다).
기하 직관: 로컬 원점에 놓인 모형을, 월드의 어느 위치에·어느 방향으로·어느 크기로 놓을지
model 행렬이 정해줍니다. 이 곱셈 한 번으로 정점이 "무대 위 제자리"로 이동합니다.
코드 매핑: mul(model, v)의 인자 순서에 주의하세요. 이 시리즈의 규약은 행벡터
v' = v * M이고, C++에서 model을 cbuffer에 올릴 때 이미 전치해서
넣습니다. 전치된 행렬을 mul(M, v) 형태로 곱하면 결과가 행벡터 규약과 동일해집니다. 즉
"C++에서 전치 + HLSL에서 mul(행렬, 벡터)" 조합이 한 세트입니다(뒤 C++ 코드에서 다시 확인).
o.fragPos = world.xyz;
방금 구한 월드 위치의 xyz만 떼어 픽셀 셰이더로 넘깁니다. 픽셀 셰이더는 이 위치에서
"빛까지의 방향(L)"과 "카메라까지의 방향(V)"을 계산해야 하므로, 월드 공간
위치가 꼭 필요합니다. 클립 공간 위치(o.pos)로는 방향을 제대로 못 구합니다 — 원근 나눗셈으로
비선형 왜곡이 들어가기 때문입니다.
o.normal = normalize(mul((float3x3)normalMatrix, input.normal));
수학: 법선도 월드 공간으로 옮겨야 하는데, 위치처럼 model로 곱하면 안 됩니다.
법선은 "표면에 수직인 방향"이라는 성질이 핵심인데, 물체를 비균등하게(축마다 다르게) 스케일하면
그 수직 관계가 깨집니다. 예를 들어 x축으로만 2배 늘리면, 45° 기울어졌던 표면 법선이 더 이상 표면에 수직이
아니게 틀어집니다.
기하 직관: 종이에 수직으로 꽂힌 압정을 상상하세요. 종이를 가로로 쭉 늘이면 종이 면은 눕는데,
법선을 같은 방식으로 늘이면 압정이 종이를 향해 기울어져 더는 수직이 아닙니다. 올바른 새 법선은 "늘어난
종이에 다시 수직"이 되도록 반대로 보정해야 합니다. 그 보정이 바로 역행렬의 전치
transpose(inverse(model))입니다.
코드 매핑: (float3x3)normalMatrix는 4×4로 패딩해 넣은 법선 행렬에서
왼쪽 위 3×3만 꺼내는 캐스팅입니다. 법선은 방향이므로 평행이동(4번째 행/열)이 필요 없어 3×3이면
충분합니다. normalize(...)로 마지막에 길이를 1로 되돌리는 이유는, 변환 과정에서 법선의 길이가
1이 아니게 될 수 있는데 조명 공식의 내적은 단위 벡터를 전제하기 때문입니다.
model로 그냥 곱하면, 물체를 비균등하게 스케일했을 때 법선이 표면에 수직이 아니게 틀어져
조명이 이상해집니다(늘어난 방향으로 밝기가 쏠려 보임). 반드시
transpose(inverse(model))을 쓰세요. 균등 스케일(모든 축 같은
배율)이나 회전·이동만 하는 경우엔 model의 3×3 부분으로도 결과가 같지만, 언제
비균등 스케일이 섞일지 모르니 습관적으로 법선 행렬을 쓰는 게 안전합니다.
o.pos = mul(mvp, float4(input.pos, 1.0));
화면에 그리기 위한 최종 클립 공간 위치입니다. mvp는 model·view·proj를 미리 곱해 하나로 합친
행렬이라, 정점을 한 번에 클립 공간까지 보냅니다. 이 값이 SV_Position으로 나가면 GPU가
자동으로 원근 나눗셈·뷰포트 변환을 거쳐 화면 픽셀 위치로 바꿉니다. 조명 계산에는 o.pos가 아니라
o.fragPos(월드 위치)를 쓴다는 점을 다시 강조합니다.
픽셀 셰이더 PSMain — 퐁 공식을 픽셀마다 계산
: SV_Target은 이 함수의 반환값 float4가 렌더 타깃(화면 버퍼)에 쓸 최종
색이라는 시맨틱입니다. 반환은 (R, G, B, A) 네 값이고, 각 채널은 보통 0~1 범위입니다
(1을 넘으면 화면에서는 흰색으로 포화됩니다). 이제 세 성분을 차례로 만듭니다.
1) ambient — float3 ambient = 0.12 * lightColor;
수학·직관: 광원 색에 작은 상수 0.12를 곱한 것이 전부입니다. 방향도 각도도 없이,
모든 픽셀에 동일하게 더해질 "바닥 밝기"입니다. 0.12는 12%라는 뜻으로, 빛을 등진 면조차 광원 색의
12% 밝기는 유지하게 합니다. 이 값을 0으로 하면 그늘이 완전히 새까매지고, 너무 키우면 명암 대비가 사라져
물체가 납작해 보입니다.
성분 계산 예: lightColor = (1, 1, 1)(흰빛)이면
ambient = (0.12, 0.12, 0.12)가 됩니다. 즉 RGB 세 채널 각각에 0.12씩 들어갑니다.
2) diffuse — 표면과 빛의 각도
float3 N = normalize(input.normal);
float3 L = normalize(lightPos - input.fragPos);
float diff = max(dot(N, L), 0.0);
float3 diffuse = diff * lightColor;
float3 N = normalize(input.normal); — 정점 셰이더에서 넘어온 법선을 다시
정규화합니다. "정점 셰이더에서 이미 normalize 했는데 왜 또?"라고 물을 수 있는데, 정점→픽셀로 넘어오며 값이
보간(선형 평균)되기 때문입니다. 길이 1인 두 벡터의 평균은 길이가 1보다 짧아지므로, 픽셀
셰이더에서 한 번 더 정규화해야 정확합니다.
float3 L = normalize(lightPos - input.fragPos); — 표면 점에서 광원을 향하는
단위 방향 벡터입니다. "광원 위치 − 표면 위치"는 표면에서 광원으로 향하는 화살표이고,
normalize로 길이를 1로 맞춥니다. 순서에 주의: lightPos - fragPos여야
표면→광원 방향입니다. 뒤집으면 광원→표면이 되어 조명이 반대로 뒤집힙니다.
float diff = max(dot(N, L), 0.0); — 이 시리즈의 심장입니다.
dot(N, L)는 표면 법선과 빛 방향이 얼마나 같은 방향인가를 −1~1 사이 숫자로 냅니다.
수학적으로 N·L = |N||L|cosθ = cosθ(둘 다 단위 벡터이므로)이고, θ는 법선과 빛 방향의
사잇각입니다. 빛을 정면으로 받으면(θ=0°) 1, 90°로 스치면 0, 등지면(θ>90°) 음수입니다.
max(..., 0.0)는 그 음수를 0으로 잘라냅니다 — 빛을 등진 뒷면은 "빛이 안 든다"는 뜻이지
"음의 빛"이 있을 순 없으니까요.
N = (0, 1, 0)(위를 향한 바닥), 빛 방향 L = (0, 1, 0)(정수리에서 내리쬠)이면
dot(N, L) = 0·0 + 1·1 + 0·0 = 1 → 최대 밝기. 만약 빛이 45° 기울어
L = (0.707, 0.707, 0)이면 dot = 1·0.707 = 0.707 → 약 71% 밝기. 빛이 옆에서
수평으로 오면 L = (1, 0, 0) → dot = 0 → 완전히 어두움. 각도가 벌어질수록 밝기가
cos 곡선을 따라 부드럽게 떨어지는 게 그대로 보입니다.
float3 diffuse = diff * lightColor; — 각도로 구한 밝기 계수(diff,
스칼라)를 광원 색에 곱합니다. 스칼라 하나를 float3에 곱하면 세 채널 모두에 같은
비율로 곱해집니다. 붉은 빛(lightColor = (1, 0, 0))에 diff = 0.5면
diffuse = (0.5, 0, 0)가 됩니다.
3) specular — 하프웨이 벡터(블린-퐁)
float3 V = normalize(viewPos - input.fragPos);
float3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), 64.0); // 64 = 광택
float3 specular = 0.5 * spec * lightColor;
float3 V = normalize(viewPos - input.fragPos); — 표면 점에서 카메라를
향하는 단위 방향입니다. specular는 "반사가 눈 쪽을 향하는가"를 따지므로 시선 방향 V가
필요합니다. diffuse는 이 V가 없었다는 점(보는 방향과 무관)과 대비됩니다.
float3 H = normalize(L + V); — 하프웨이 벡터입니다. 빛 방향
L과 시선 방향 V를 더한 뒤 정규화하면, 두 벡터의 정확히 가운데를 가리키는
방향이 나옵니다. 기하 직관: 만약 표면 법선 N이 이 가운데 방향 H와 정확히 일치하면,
표면은 빛을 시선 쪽으로 완벽히 반사하는 각도입니다 — 즉 가장 강한 하이라이트가 생깁니다.
float spec = pow(max(dot(N, H), 0.0), 64.0); — dot(N, H)는 법선이
하프웨이 방향과 얼마나 일치하는지(0~1)이고, 여기에 pow(..., 64)로 64제곱을 겁니다.
이 큰 지수가 곡선을 극단적으로 뾰족하게 만듭니다: 예컨대 0.99^64 ≈ 0.53, 0.9^64 ≈ 0.0012,
0.8^64 ≈ 0.00000006. 즉 거의 완벽히 정렬됐을 때만 값이 살아남고 조금만 벗어나도
순식간에 0으로 꺼집니다. 그래서 좁고 날카로운 하이라이트 점이 만들어집니다. 지수를 키우면 하이라이트가
작고 날카로워지고(더 매끈한 재질), 줄이면 넓고 흐릿해집니다(더 거친 재질).
float3 specular = 0.5 * spec * lightColor; — 하이라이트 세기(spec)에
강도 0.5를 곱하고 광원 색을 입힙니다. 0.5는 하이라이트의 최대 밝기를 절반으로 눌러
과하게 하얗게 타지 않게 조절하는 값입니다.
reflect 버전은 전통 퐁입니다: 빛의 반사 벡터
R = reflect(-L, N)를 실제로 구하고 dot(V, R)로 시선과의 각도를 잽니다. 결과는
비슷하지만 반사 벡터를 계산하는 만큼 조금 더 무겁고, 시선과 빛이 표면 반대편으로 갈 때 하이라이트가 부자연스럽게
끊기는 경우가 있습니다. 블린-퐁(하프웨이)이 더 싸고 더 부드러워서 실무 기본값입니다.
같은 광택이라도 지수 스케일이 달라(퐁 32 ↔ 블린 64 정도) 위 코드가 두 버전에서 다른 숫자를 쓰는 이유입니다.
최종 합성 — float3 result = (ambient + diffuse + specular) * objectColor;
수학: 세 밝기 성분을 더한 뒤, 물체 고유색을 곱합니다.
더하기는 "여러 빛 기여가 누적된다"는 뜻이고, 곱하기는 "물체가 각 색 채널을 얼마나 반사하는가"라는 뜻입니다.
예를 들어 objectColor = (0.85, 0.55, 0.25)(주황 계열)라면, 흰빛이 와도 파란 채널(0.25)은 많이
흡수되어 어두워지고 빨강(0.85)은 잘 반사되어 물체가 주황으로 보입니다.
성분 계산 예: 어떤 픽셀에서 ambient=(0.12,0.12,0.12),
diffuse=(0.7,0.7,0.7), specular=(0.3,0.3,0.3)라면 합은
(1.12, 1.12, 1.12). 여기에 objectColor=(0.85,0.55,0.25)를 채널별로 곱하면
(0.95, 0.62, 0.28) — 밝고 살짝 하얗게 뜬 주황이 됩니다(하이라이트 부분).
return float4(result, 1.0); — RGB 세 채널에 알파(A) 1.0(완전 불투명)을 붙여
최종 색으로 내보냅니다.
normalize(제곱근 포함)·pow 같은 연산의 개수가 프레임 비용에 직접 영향을 줍니다.
광원이 여러 개면 이 계산이 광원 수만큼 배로 늘어나므로, 실무에서는 화면에 영향 주는 광원만 추리거나
(컬링), 조명을 미리 텍스처에 굽거나(라이트맵), 디퍼드 셰이딩으로 픽셀당 한 번만 계산하는 식으로 최적화합니다.
2) C++ — 조명 상수 버퍼 설정
무엇을 만드나: 셰이더의 cbuffer에 대응하는 C++ 구조체를 정의하고, 값을 채워
GPU에 올린 뒤 셰이더 슬롯에 바인딩합니다. 왜 필요한가: 셰이더의 lightPos,
objectColor 같은 값은 CPU가 매 프레임 여기서 채워 넣어야 셰이더가 읽을 수 있습니다.
이 코드가 곧 "셰이더에 데이터를 공급하는 파이프"입니다.
// 상수 버퍼 구조체 (HLSL cbuffer와 레이아웃 일치, 16바이트 정렬)
struct CBLighting {
XMFLOAT3 lightPos; float _pad0;
XMFLOAT3 lightColor; float _pad1;
XMFLOAT3 viewPos; float _pad2;
XMFLOAT3 objectColor; float _pad3;
};
// 법선 행렬: 비균등 스케일에서도 법선을 올바르게 변환
XMMATRIX normalMat = XMMatrixTranspose(XMMatrixInverse(nullptr, model));
// 변환 상수 버퍼 채우기 (mvp, model, normalMatrix 모두 transpose 해 넘김)
CBTransforms cbT;
XMStoreFloat4x4(&cbT.mvp, XMMatrixTranspose(model * view * proj));
XMStoreFloat4x4(&cbT.model, XMMatrixTranspose(model));
XMStoreFloat4x4(&cbT.normalMatrix, normalMat); // 위에서 이미 transpose 됨
// context->UpdateSubresource(cbTransforms.Get(), 0, nullptr, &cbT, 0, 0);
// 조명 상수 버퍼 채우기
CBLighting cbL{};
cbL.lightPos = { 1.2f, 1.5f, 2.0f };
cbL.lightColor = { 1.0f, 1.0f, 1.0f };
XMStoreFloat3(&cbL.viewPos, XMLoadFloat3(&gCam.position));
cbL.objectColor = { 0.85f, 0.55f, 0.25f };
// context->UpdateSubresource(cbLighting.Get(), 0, nullptr, &cbL, 0, 0);
// 셰이더 스테이지에 바인딩
context->VSSetConstantBuffers(0, 1, cbTransforms.GetAddressOf());
context->PSSetConstantBuffers(0, 1, cbTransforms.GetAddressOf());
context->PSSetConstantBuffers(1, 1, cbLighting.GetAddressOf());
CPU 구조체 CBLighting — HLSL과 바이트 단위로 일치시키기
XMFLOAT3는 { float x, y, z; }일 뿐인 12바이트 구조체입니다(HLSL의 float3에
대응). 각 XMFLOAT3 뒤의 float _pad가 앞서 설명한 16바이트 정렬용 4바이트를
채웁니다. 그래서 이 구조체는 (12+4) × 4 = 64바이트이고, 셰이더의 cbuffer Lighting과
바이트 하나까지 동일한 배치가 됩니다. 필드 순서·타입·패딩 중 하나라도 어긋나면 값이 밀려
엉뚱한 자리로 들어갑니다.
_pad를 빼먹고 XMFLOAT3 세 개를 붙여 쓰면 CPU 구조체는 12·12·12로 촘촘히 놓이는데
GPU는 0·16·32로 띄워 읽습니다. 그 결과 lightColor 자리에 lightPos의 뒷부분과
lightColor의 앞부분이 섞여 들어가, 빛 색이 이상해지거나 물체가 안 보이는 식으로 조용히 깨집니다.
컴파일 에러가 안 나는 종류의 버그라 특히 잡기 어렵습니다.
법선 행렬 — XMMatrixTranspose(XMMatrixInverse(nullptr, model))
셰이더에서 필요한 법선 행렬 transpose(inverse(model))을 CPU에서 미리 계산합니다. 안에서 바깥으로 읽으면:
XMMatrixInverse(nullptr, model)—model의 역행렬을 구합니다. 첫 인자nullptr은 행렬식(determinant)을 따로 받지 않겠다는 뜻입니다(안 쓰니 버림).XMMatrixTranspose(...)— 그 역행렬을 전치합니다. 이 둘의 조합이 바로 "비균등 스케일에서도 법선을 표면에 수직으로 유지"시키는 보정 행렬입니다.
cbT.mvp·cbT.model에
쓰는 전치는 DirectXMath(행벡터)를 HLSL로 올릴 때의 규약 전치로, 목적이 다릅니다.
normalMat은 이미 수학적 전치가 끝났으니 cbuffer에 넣을 때 다시 전치하지 않습니다
(코드에서 cbT.normalMatrix만 XMMatrixTranspose로 감싸지 않은 이유). 여기서
한 번 더 전치하면 보정이 원위치로 돌아가 법선이 다시 틀어집니다.
변환 버퍼 채우기 — XMStoreFloat4x4와 규약 전치
XMStoreFloat4x4(&dst, mat)는 SIMD 연산용 XMMATRIX를, 메모리에 저장 가능한
XMFLOAT4X4(cbuffer에 올릴 형태)로 내려 씁니다. 반대로 계산할 때 불러오는 함수는
XMLoadFloat4x4입니다("Load=SIMD로 올리기, Store=메모리로 내리기"로 기억하면 편합니다).
XMMatrixTranspose(model * view * proj)에서 전치를 하는 이유가 규약의 핵심입니다. DirectXMath는
행벡터(v' = v * M) 규약이라 CPU에서는 model * view * proj 순서로 곱합니다. 그런데
HLSL 셰이더 코드는 mul(mvp, v)처럼 행렬을 앞에 두고 곱하도록 짜여 있어서, cbuffer로
올리기 전에 한 번 전치해두면 셰이더 안에서의 곱이 CPU 규약과 정확히 같은 결과를 냅니다.
model도 같은 이유로 전치합니다.
조명 값 채우기
cbL.lightPos = { 1.2f, 1.5f, 2.0f };처럼 중괄호 초기화로 XMFLOAT3의 x·y·z를 한 번에
채웁니다. lightColor = {1,1,1}은 흰빛, objectColor = {0.85, 0.55, 0.25}는 주황 계열
물체 색입니다.
XMStoreFloat3(&cbL.viewPos, XMLoadFloat3(&gCam.position));는 카메라 위치를 뷰 위치로 복사하는
코드입니다. XMLoadFloat3로 카메라 위치(XMFLOAT3)를 SIMD 레지스터(XMVECTOR)로
올렸다가 XMStoreFloat3로 다시 내려 cbL.viewPos에 씁니다. 여기서는 단순 복사라 직접
대입해도 되지만, 중간에 벡터 연산을 넣기 좋게 Load/Store 관용구를 쓴 형태입니다. 이
viewPos가 셰이더에서 시선 방향 V를 만드는 데 쓰입니다.
바인딩 — VSSetConstantBuffers / PSSetConstantBuffers
채운 버퍼를 셰이더 슬롯에 연결합니다. 함수 인자는 (시작슬롯, 개수, 버퍼배열)입니다.
VSSetConstantBuffers(0, 1, cbTransforms...)— 정점 셰이더의 슬롯 0(=register(b0))에 변환 버퍼를 연결. 정점 셰이더가mvp·model·normalMatrix를 씁니다.PSSetConstantBuffers(0, 1, cbTransforms...)— 픽셀 셰이더의 슬롯 0에도 같은 변환 버퍼를 연결. (이 예제 픽셀 셰이더는 변환 버퍼를 직접 안 쓰지만, 슬롯을 맞춰 두면 확장 시 편합니다.)PSSetConstantBuffers(1, 1, cbLighting...)— 픽셀 셰이더의 슬롯 1(=register(b1))에 조명 버퍼를 연결. 이 번호가 셰이더의register(b1)과 일치해야 조명 값이 제대로 들어갑니다.
GetAddressOf()는 ComPtr가 감싼 원시 포인터의 주소(ID3D11Buffer**)를
돌려줍니다. D3D API가 "버퍼 포인터들의 배열"을 요구하기 때문에, 하나만 넘길 때도 그 하나의 주소를 배열처럼 넘깁니다.
UpdateSubresource는 매 프레임 자주 바뀌는 작은 버퍼에는 무난하지만, 아주 빈번한 갱신에는
D3D11_USAGE_DYNAMIC 버퍼 + Map/Unmap(WRITE_DISCARD)이 더
효율적일 수 있습니다. 또 변하지 않는 값(예: objectColor)까지 매 프레임 다시 올릴 필요는 없으니,
자주 바뀌는 값과 고정 값을 별도 cbuffer로 쪼개면 업로드 트래픽을 줄일 수 있습니다.
DirectXMath / D3D API 대응 한눈에
| 개념 / 원본 표현 | DirectXMath · D3D11 대응 | 역할 |
|---|---|---|
transpose(inverse(model)) (법선 행렬) | XMMatrixTranspose(XMMatrixInverse(nullptr, model)) | 비균등 스케일에서 법선 유지 |
model * view * proj 전치 | XMMatrixTranspose(model * view * proj) | 행벡터 규약 → HLSL mul로 올리기 |
float3 → XMFLOAT4X4 저장 | XMStoreFloat4x4(&dst, mat) | SIMD 행렬을 cbuffer 형태로 내려쓰기 |
| 카메라 위치 로드 | XMLoadFloat3(&gCam.position) | XMFLOAT3 → XMVECTOR |
float3 dot(N, L) | HLSL dot (CPU라면 XMVector3Dot) | 표면·빛 각도(diffuse) |
normalize(v) | HLSL normalize (CPU라면 XMVector3Normalize) | 단위 벡터화 |
reflect(-L, N) (전통 퐁) | HLSL reflect (CPU라면 XMVector3Reflect) | 반사 벡터(specular) |
cbuffer : register(bN) | VSSetConstantBuffers / PSSetConstantBuffers(N, ...) | 상수 버퍼 슬롯 바인딩 |
| 버퍼 데이터 갱신 | ID3D11DeviceContext::UpdateSubresource | CPU→GPU 값 업로드 |