디퍼드 렌더링 (Deferred Rendering) — 한 줄 한 줄 직접 구현
화면에 광원이 수십·수백 개 있는 장면을 생각해 봅시다. 보통(포워드) 렌더링은 메시를 그릴 때마다, 픽셀마다, 모든 광원을 계산합니다. 그런데 그 픽셀이 나중에 다른 물체에 가려져 버려질 픽셀이라면 그 조명 계산은 전부 낭비입니다. 디퍼드(deferred, "미룬") 렌더링은 조명 계산을 뒤로 미뤄 두 단계로 나눕니다: 먼저 (1) 지오메트리 패스에서 화면 각 픽셀의 위치·법선·색을 여러 장의 텍스처(G-buffer)에 한 번에 기록하고, 그다음 (2) 라이팅 패스에서 실제로 보이는 픽셀에 대해서만 그 텍스처들을 읽어 조명을 계산합니다. 이 페이지에서는 G-buffer를 만드는 C++ 코드, 그 G-buffer에 동시에 기록하는 HLSL(MRT) 픽셀 셰이더, 그리고 풀스크린 라이팅 패스 셰이더를 코드 한 줄, 성분 하나까지 해부한 뒤, D3D11 API와 DirectXMath로 하나하나 짚습니다.
v' = v * M(HLSL mul(v, M)),
행 우선 저장, 왼손 좌표계, 클립 공간 z ∈ [0,1], 텍스처 V축은
위→아래입니다. 이 페이지의 조명 계산은 모두 뷰 공간(view space)에서 이뤄집니다 — 좌표계가 무엇이든
"카메라를 원점에 둔 공간"이라는 점만 일관되면 조명 공식 자체는 동일합니다. 실무에서는 직접 구현보다
DirectXMath와 검증된 라이브러리를 권장하지만, 여기서는 원리를 이해하려고 손으로 풀어 봅니다.
0. 무엇을, 왜 만드나
포워드 렌더링(forward rendering)은 우리가 지금까지 해 온 평범한 방식입니다. 물체를 하나씩 그리고, 각 물체의 픽셀 셰이더 안에서 "이 픽셀에 닿는 모든 광원"을 반복문으로 돌며 색을 더합니다. 문제는 두 가지입니다.
- 오버드로우(overdraw) 낭비: 앞뒤로 겹친 물체가 많으면, 뒤에 있는 픽셀도 일단 조명을 다 계산한 뒤 나중에 앞 물체가 덮어써 버립니다. 계산해 놓고 버리는 것이죠. 광원이 많을수록 이 낭비는 눈덩이처럼 커집니다.
-
비용 폭발: 포워드의 조명 비용은 대략
그린 픽셀 수 × 광원 수입니다. "그린 픽셀 수"에는 가려져 버려질 픽셀까지 포함됩니다. 광원 64개짜리 장면이면 픽셀 하나에 64번의 조명 계산이 들어갑니다.
디퍼드 렌더링의 핵심 아이디어는 "조명 계산에 필요한 재료를 먼저 화면 크기 텍스처에 다 적어 두고,
조명은 화면의 최종적으로 보이는 픽셀에 대해 딱 한 번만 하자"는 것입니다. 그러면 조명 비용이
화면 픽셀 수 × 광원 수로 고정됩니다 — 물체가 아무리 겹쳐 있어도 화면 픽셀 수는 일정하니까요.
-
G-buffer를 만든다: 위치·법선·(알베도+스페큘러) 세 장의 렌더 타깃 텍스처를 만들고,
각각 쓰기용
RTV와 읽기용SRV를 붙입니다. -
지오메트리 패스: 세 렌더 타깃을
OMSetRenderTargets에 배열로 한꺼번에 묶어 (MRT, Multiple Render Targets) 설정하고, 픽셀 셰이더가 각 픽셀의 속성을 세 타깃에 동시에 기록합니다. -
라이팅 패스: 화면을 덮는 풀스크린 삼각형 하나를 그리면서, G-buffer 세 장을
SRV로 샘플해 픽셀 속성을 복원하고, 광원 루프를 돌며 최종 색을 계산합니다.
1. G-buffer 만들기 — 텍스처·RTV·SRV (C++)
먼저 G-buffer로 쓸 텍스처들을 만들어야 합니다. 각 타깃은 세 개의 D3D11 객체가 한 세트입니다:
실제 픽셀 데이터를 담는 텍스처(ID3D11Texture2D), 지오메트리 패스에서 여기에 그려라라고
지정하는 렌더 타깃 뷰(RTV), 그리고 라이팅 패스에서 이 텍스처를 읽어라라고 지정하는
셰이더 리소스 뷰(SRV)입니다. 같은 텍스처를 "쓸 때는 RTV로, 읽을 때는 SRV로" 두 얼굴로
쓰는 것이 핵심입니다.
using Microsoft::WRL::ComPtr;
// 각 G-buffer 타깃: 텍스처 + RTV(쓰기) + SRV(라이팅 패스에서 읽기)
struct GTarget {
ComPtr<ID3D11Texture2D> tex;
ComPtr<ID3D11RenderTargetView> rtv;
ComPtr<ID3D11ShaderResourceView> srv;
};
GTarget gPosition, gNormal, gAlbedoSpec;
auto makeTarget = [&](GTarget& g, DXGI_FORMAT fmt){
D3D11_TEXTURE2D_DESC td = {};
td.Width = W; td.Height = H; td.MipLevels = 1; td.ArraySize = 1;
td.Format = fmt; td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_DEFAULT;
td.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
device->CreateTexture2D(&td, nullptr, &g.tex);
device->CreateRenderTargetView(g.tex.Get(), nullptr, &g.rtv);
device->CreateShaderResourceView(g.tex.Get(), nullptr, &g.srv);
};
makeTarget(gPosition, DXGI_FORMAT_R16G16B16A16_FLOAT); // 뷰공간 위치
makeTarget(gNormal, DXGI_FORMAT_R16G16B16A16_FLOAT); // 뷰공간 법선
makeTarget(gAlbedoSpec, DXGI_FORMAT_R8G8B8A8_UNORM); // rgb=albedo, a=spec
// 깊이 스텐실 뷰(gDSV)는 별도로 생성 (지오메트리 패스 깊이 테스트용)
// --- 지오메트리 패스: 이 3개에 동시에 출력 (MRT) ---
ID3D11RenderTargetView* rtvs[3] = {
gPosition.rtv.Get(), gNormal.rtv.Get(), gAlbedoSpec.rtv.Get()
};
float clear[4] = { 0, 0, 0, 0 };
for (auto* r : rtvs) ctx->ClearRenderTargetView(r, clear);
ctx->ClearDepthStencilView(gDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
ctx->OMSetRenderTargets(3, rtvs, gDSV.Get());
renderGeometryPass();
1-1. GTarget 구조체 — 왜 세 얼굴이 필요한가
struct GTarget는 한 장의 G-buffer 타깃을 이루는 세 객체를 하나로 묶은 것입니다. 각 줄을 봅시다.
-
ComPtr<ID3D11Texture2D> tex;— 실제 픽셀 값이 저장되는 GPU 메모리 블록입니다. 화면과 같은W × H크기의 2D 이미지죠. -
ComPtr<ID3D11RenderTargetView> rtv;— "이 텍스처에 그려 넣어라"라고 파이프라인의 출력 병합(OM) 단계에 알려 주는 뷰입니다. 지오메트리 패스가 이걸 통해 텍스처에 씁니다. -
ComPtr<ID3D11ShaderResourceView> srv;— "이 텍스처를 셰이더에서 읽어라(샘플하라)"라고 알려 주는 뷰입니다. 라이팅 패스가 이걸 통해 텍스처를 읽습니다.
왜 뷰가 두 개나 필요할까요? D3D11에서 텍스처는 "데이터 덩어리"일 뿐이고, GPU 파이프라인에 어떤
용도로 연결할지는 뷰(view)가 결정합니다. RTV는 "출력 슬롯에 꽂는 어댑터", SRV는 "입력
슬롯에 꽂는 어댑터"입니다. 같은 텍스처라도 쓸 때와 읽을 때 다른 어댑터가 필요하기 때문에 둘 다 만들어 둡니다.
ComPtr은 스마트 포인터로, 참조 카운트를 자동 관리해 Release()를 잊어도 누수가 없게 해 줍니다.
1-2. makeTarget 람다 — 텍스처 서술자 채우기
auto makeTarget = [&](GTarget& g, DXGI_FORMAT fmt){ ... };는 람다(익명 함수)입니다.
타깃마다 텍스처+RTV+SRV 세 벌을 만드는 코드가 똑같아서, 반복을 줄이려고 한 함수로 묶었습니다. 각 부분을 봅시다.
-
[&]— 캡처 절입니다.&는 "바깥 변수를 참조로 캡처"라는 뜻으로, 람다 안에서device,W,H같은 바깥 변수를 그대로 쓸 수 있게 합니다. 값 복사가 아니라 참조라서 큰 객체를 복사하는 비용이 없습니다. -
GTarget& g— 결과를 채워 넣을 타깃을 참조(&)로 받습니다. 참조로 받아야 람다 안에서 만든tex/rtv/srv가 호출자의gPosition등에 실제로 들어갑니다. 값으로 받으면 복사본만 채워지고 원본은 그대로겠죠. -
DXGI_FORMAT fmt— 타깃마다 픽셀 포맷이 다르므로 인자로 받습니다(아래 1-4에서 상세히).
D3D11_TEXTURE2D_DESC td = {};는 텍스처를 어떻게 만들지 적는 서술자(descriptor)입니다.
= {}로 모든 필드를 0으로 초기화한 뒤 필요한 것만 채웁니다(빼먹은 필드가 쓰레기 값이 되는
사고를 막는 습관). 각 필드의 의미는 이렇습니다.
| 필드 | 값 | 의미 |
|---|---|---|
Width / Height | W / H | 화면과 같은 해상도. 화면 픽셀 1:1로 정보를 담아야 하므로. |
MipLevels | 1 | 밉맵 없음. G-buffer는 1:1로만 읽으므로 축소본이 필요 없습니다. |
ArraySize | 1 | 텍스처 배열이 아니라 낱장 한 장. |
Format | fmt | 픽셀당 채널 수·비트 폭·정수/부동소수(아래 1-4). |
SampleDesc.Count | 1 | MSAA 안 씀(=1). 디퍼드는 MSAA가 까다롭습니다(아래 성능 노트). |
Usage | DEFAULT | GPU가 읽고 쓰는 일반 텍스처. CPU 접근 없음 → 가장 빠름. |
BindFlags | RENDER_TARGET | SHADER_RESOURCE | 이 텍스처를 렌더 타깃으로도, 셰이더 리소스로도 쓰겠다는 두 용도 선언. |
BindFlags에 D3D11_BIND_RENDER_TARGET과 D3D11_BIND_SHADER_RESOURCE를
둘 다 OR로 넣지 않으면 디퍼드가 성립하지 않습니다. 하나라도 빠지면 그 용도로 뷰를 만들 때
CreateRenderTargetView/CreateShaderResourceView가 실패(HRESULT 오류)합니다.
"쓰기+읽기 둘 다"라는 선언이 곧 G-buffer의 정의입니다.
1-3. 생성 3종 세트 — 텍스처·RTV·SRV
서술자를 채웠으면 실제 객체를 만듭니다. 세 줄이 순서대로 텍스처, RTV, SRV를 생성합니다.
-
device->CreateTexture2D(&td, nullptr, &g.tex);—&td는 방금 채운 서술자의 주소, 가운데nullptr은 초기 데이터 없음(빈 텍스처로 만들고 나중에 렌더링으로 채운다),&g.tex는 만들어진 텍스처 포인터를 받을 곳입니다.&가 붙는 이유는 API가 "포인터를 채워 주는" 출력 매개변수라서 포인터의 주소(이중 포인터)를 넘겨야 하기 때문입니다. -
device->CreateRenderTargetView(g.tex.Get(), nullptr, &g.rtv);— 이 텍스처에 대한 쓰기 뷰를 만듭니다.g.tex.Get()은ComPtr에서 원시 포인터를 꺼내는 함수, 가운데nullptr은 "서술자 기본값 사용"(텍스처 포맷을 그대로 씀),&g.rtv는 결과 RTV를 받을 곳입니다. -
device->CreateShaderResourceView(g.tex.Get(), nullptr, &g.srv);— 같은 텍스처에 대한 읽기 뷰입니다. 역시nullptr로 기본 SRV 서술자를 쓰면 전체 밉/전체 배열을 텍스처 포맷 그대로 읽는 뷰가 됩니다.
Create* 호출의 반환값 HRESULT를 반드시 확인해야 합니다
(if (FAILED(hr)) ...). 원본은 학습용이라 생략했지만, 포맷·바인드 플래그가 조합상 불가능하거나
메모리가 부족하면 여기서 조용히 실패하고, 나중에 nullptr 뷰를 바인딩하며 원인 모를 크래시가 납니다.
1-4. 세 타깃의 포맷 선택 — 정밀도와 대역폭의 흥정
makeTarget(gPosition, DXGI_FORMAT_R16G16B16A16_FLOAT); // 뷰공간 위치
makeTarget(gNormal, DXGI_FORMAT_R16G16B16A16_FLOAT); // 뷰공간 법선
makeTarget(gAlbedoSpec, DXGI_FORMAT_R8G8B8A8_UNORM); // rgb=albedo, a=spec
포맷 이름은 R16G16B16A16_FLOAT처럼 읽습니다: R/G/B/A 네 채널, 각 16비트, 부동소수(FLOAT).
왜 타깃마다 다르게 골랐는지가 디퍼드의 실무 감각입니다.
-
위치·법선 →
R16G16B16A16_FLOAT: 위치와 법선은 넓은 범위와 정밀도가 필요합니다. 뷰 공간 위치는 값이 수십~수백까지 커질 수 있고 음수도 나옵니다. 법선은 −1~1 범위이지만 조명에서 각도 오차가 바로 얼룩으로 보이므로 정밀도가 중요합니다. 그래서 부동소수 16비트를 씁니다. (8비트 정수라면 위치가 뭉개져 계단현상이 생깁니다.) -
알베도+스페큘러 →
R8G8B8A8_UNORM: 알베도(표면 색)는 화면에 보이는 색이라 0~1 범위면 충분하고,UNORM(0~255 정수를 0.0~1.0으로 정규화해 읽음)으로 절반 대역폭이면 됩니다. 남는 A 채널에 스페큘러 세기를 끼워 넣어 텍스처 한 장을 아낍니다. 이런 채널 패킹(packing)이 G-buffer 최적화의 핵심 기술입니다.
픽셀당 대역폭을 손으로 세어 볼까요? 위치 8바이트(4채널×2바이트) + 법선 8바이트 + 알베도스펙 4바이트 = 픽셀당 20바이트. 깊이 버퍼(보통 4바이트)까지 합치면 24바이트. Full HD(1920×1080 ≈ 207만 픽셀)면 지오메트리 패스에서 쓰기만 약 50MB, 라이팅 패스에서 읽기 또 그만큼. 이게 디퍼드가 대역폭을 많이 먹는 이유입니다(아래 성능 노트).
z = sqrt(1 - x² - y²)로 복원(옥타헤드럴 인코딩 등)해 채널을 아낄 수 있습니다.
원본은 이해를 위해 위치·법선을 그대로 저장하는 가장 직관적인 형태를 씁니다.
1-5. MRT 설정 — 세 타깃에 동시에 그리기
ID3D11RenderTargetView* rtvs[3] = {
gPosition.rtv.Get(), gNormal.rtv.Get(), gAlbedoSpec.rtv.Get()
};
float clear[4] = { 0, 0, 0, 0 };
for (auto* r : rtvs) ctx->ClearRenderTargetView(r, clear);
ctx->ClearDepthStencilView(gDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
ctx->OMSetRenderTargets(3, rtvs, gDSV.Get());
renderGeometryPass();
이 블록이 MRT(Multiple Render Targets)를 켜는 부분입니다. 지오메트리 패스가 한 번의 드로우로 세 텍스처에 동시에 쓰도록 준비합니다.
-
ID3D11RenderTargetView* rtvs[3] = { ... };— 세 RTV의 원시 포인터를 순서대로 담은 배열입니다. 이 순서(0=position, 1=normal, 2=albedoSpec)가 곧 셰이더의SV_Target0/1/2와 짝을 이룹니다. 순서를 뒤섞으면 위치 자리에 색이 들어가는 등 뒤죽박죽이 됩니다. -
float clear[4] = { 0, 0, 0, 0 };— 지울 색(RGBA)입니다. 전부 0으로 지워 프레임 시작 시 잔상을 없앱니다. -
for (auto* r : rtvs) ctx->ClearRenderTargetView(r, clear);— 범위 기반 for로 세 RTV를 각각 0으로 클리어합니다.ClearRenderTargetView는 타깃 하나씩만 지우므로 반복이 필요합니다. -
ctx->ClearDepthStencilView(gDSV.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);— 깊이 버퍼를 1.0(가장 먼 값)으로 클리어합니다. 왼손 좌표계·클립 z[0,1] 규약에서 0이 카메라 최근접, 1이 최원거리이므로, 처음엔 "무한히 멀다"로 채워 놓고 더 가까운 픽셀이 오면 갱신되게 합니다. 세 번째 인자0은 스텐실 클리어 값입니다. -
ctx->OMSetRenderTargets(3, rtvs, gDSV.Get());— 출력 병합(OM) 단계에 렌더 타깃 3개 + 깊이 버퍼 1개를 한꺼번에 묶습니다. 첫 인자3이 타깃 개수, 둘째가 RTV 배열, 셋째가 깊이 스텐실 뷰입니다. 이 한 줄로 "이제부터 그리는 픽셀은 이 세 텍스처에 동시에 기록되고, 깊이는 gDSV로 테스트한다"가 확정됩니다. -
renderGeometryPass();— 실제로 씬의 모든 메시를 그립니다. 이 안의 드로우 콜들이 위 셋업을 타고 G-buffer를 채웁니다.
OMSetRenderTargets에 넘긴 타깃 개수·순서는 픽셀 셰이더의 SV_Target 번호와
정확히 일치해야 합니다. 또한 세 타깃은 같은 해상도·같은 샘플 수(MSAA)여야 합니다. 하나만 크기가
다르면 D3D가 바인딩을 거부합니다. 깊이 버퍼(gDSV)도 같은 해상도여야 합니다.
CreateTexture2D← GPU 메모리에 이미지 할당 (OpenGL의glTexImage2D에 대응).CreateRenderTargetView← 텍스처를 프레임버퍼 어태치먼트로 (OpenGLglFramebufferTexture2D).OMSetRenderTargets(N, ...)← MRT 활성화 (OpenGLglDrawBuffers).ClearRenderTargetView← 타깃 초기화 (OpenGLglClear(GL_COLOR_BUFFER_BIT)).
2. 지오메트리 패스 픽셀 셰이더 (HLSL)
이제 세 타깃에 무엇을 쓸지 정하는 셰이더입니다. 이 픽셀 셰이더는 색을 화면에 바로 내보내는 대신, 각 픽셀의 뷰 공간 위치·법선·알베도·스페큘러를 G-buffer 세 장에 기록합니다. 조명은 하지 않습니다 — "재료만 적어 두고 요리는 나중에".
Texture2D diffuseTex : register(t0);
SamplerState samp : register(s0);
cbuffer GeoConstants : register(b0) {
float specularStrength;
float3 _pad;
};
struct PSIn {
float4 posH : SV_Position;
float3 fragPos: VIEWPOS; // 뷰 공간 위치
float3 normal : NORMAL; // 뷰 공간 법선
float2 uv : TEXCOORD0;
};
// 3개의 렌더 타깃에 동시에 기록
struct GBufferOut {
float3 position : SV_Target0; // 어태치먼트 0
float3 normal : SV_Target1; // 어태치먼트 1
float4 albedoSpec : SV_Target2; // 어태치먼트 2
};
GBufferOut main(PSIn i) {
GBufferOut o;
o.position = i.fragPos;
o.normal = normalize(i.normal);
o.albedoSpec.rgb = diffuseTex.Sample(samp, i.uv).rgb;
o.albedoSpec.a = specularStrength; // 알파 채널에 스페큘러 세기 저장
return o;
}
2-1. 리소스 바인딩 — register의 의미
-
Texture2D diffuseTex : register(t0);— 재질의 디퓨즈(색) 텍스처를 텍스처 슬롯 t0에 바인딩합니다.t는 텍스처(SRV) 슬롯을 뜻하고 번호는 C++에서PSSetShaderResources(0, ...)로 꽂는 슬롯 번호와 일치해야 합니다. -
SamplerState samp : register(s0);— 텍스처를 어떻게 필터링/래핑할지 정하는 샘플러를 슬롯 s0에 바인딩합니다.s는 샘플러 슬롯입니다. -
cbuffer GeoConstants : register(b0) { ... }— 상수 버퍼를 슬롯 b0에 바인딩합니다.b는 상수 버퍼 슬롯입니다. CPU에서 매 프레임 값을 채워 셰이더에 넘기는 통로입니다.
상수 버퍼 안의 float specularStrength; float3 _pad;에서 _pad는 패딩입니다.
D3D11 상수 버퍼는 16바이트(float 4개) 경계로 정렬되기 때문에, float 하나(4바이트) 뒤에
float3(12바이트)를 채워 16바이트를 딱 맞춰 줍니다. 이걸 빼먹으면 C++ 쪽 구조체와 셰이더의 메모리
배치가 어긋나 엉뚱한 값이 읽힙니다.
float3 뒤에 float은 12+4=16으로 딱 맞아
안전하지만, float2 뒤에 float3는 8+12=20이라 float3가 경계를 넘어 다음 줄로
밀립니다. 이 페이지의 라이팅 Light 구조체도 이 규칙을 노려 정렬돼 있습니다(아래 3-1).
2-2. 입력 PSIn — 정점 셰이더가 넘겨준 것
PSIn은 정점 셰이더가 보간해서 픽셀 셰이더로 넘겨준 값들입니다. 각 멤버 뒤의 콜론은
시맨틱(semantic)으로, 이 값이 파이프라인에서 어떤 역할인지 표시하는 이름표입니다.
-
float4 posH : SV_Position;— SV_ 접두사는 시스템 값이라는 뜻입니다.SV_Position은 래스터라이저가 채워 주는 화면 픽셀 좌표로, 어떤 픽셀을 칠하는지 결정합니다. 이 셰이더에서 직접 읽지는 않지만 파이프라인상 필수 입력입니다. -
float3 fragPos : VIEWPOS;— 이 픽셀에 해당하는 표면점의 뷰 공간 위치입니다.VIEWPOS는 우리가 임의로 붙인 사용자 시맨틱 이름으로, 정점 셰이더 출력과 이름만 맞으면 됩니다. -
float3 normal : NORMAL;— 뷰 공간 법선입니다. 표면이 어느 쪽을 향하는지. -
float2 uv : TEXCOORD0;— 텍스처 좌표입니다. 디퓨즈 텍스처를 이 좌표로 샘플합니다.
왜 전부 뷰 공간인가? 조명 계산을 뷰 공간에서 하면 라이팅 패스에서 "카메라 위치"가 항상 원점(0,0,0)이
되어 시선 벡터 계산이 아주 간단해집니다(아래 3-4). 그래서 지오메트리 패스에서 미리 뷰 공간으로 변환해 둡니다.
행벡터 규약에서는 정점 셰이더가 mul(posWorld, viewMatrix)로 뷰 공간 위치를, 법선은
mul(normal, (float3x3)normalMatrix)로 변환해 이 PSIn에 채워 넘깁니다.
2-3. 출력 GBufferOut — SV_Target이 곧 어태치먼트 번호
이 구조체가 MRT의 심장입니다. 픽셀 셰이더가 여러 개의 값을 반환하고, 각 멤버의
SV_TargetN 시맨틱이 몇 번 렌더 타깃에 갈지를 지정합니다.
float3 position : SV_Target0;→ C++의rtvs[0](=gPosition)로 기록됩니다.float3 normal : SV_Target1;→rtvs[1](=gNormal)로.float4 albedoSpec : SV_Target2;→rtvs[2](=gAlbedoSpec)로.
즉 셰이더의 SV_TargetN 번호 = OMSetRenderTargets에 넘긴 배열 인덱스 N입니다.
이 짝이 어긋나면 위치 텍스처에 색이 들어가는 대참사가 납니다. C++ 배열 순서와 셰이더 시맨틱 번호를 항상 함께 봐야 합니다.
position과 normal은 float3인데 타깃 포맷은 R16G16B16A16(4채널)입니다.
셰이더가 3채널만 쓰면 4번째 채널(A)은 정의되지 않은 값이 됩니다. 라이팅 패스에서 .rgb만 읽으면
문제없지만, 실수로 .a를 읽으면 쓰레기가 나옵니다. albedoSpec만 A 채널을 의도적으로 씁니다.
2-4. main — G-buffer에 실제로 기록
본체는 아주 짧습니다. 조명이 없으니 그냥 재료를 각 타깃에 옮겨 담을 뿐입니다.
-
o.position = i.fragPos;— 뷰 공간 위치를 그대로 기록. 부동소수 타깃이라 값 범위 걱정이 없습니다. -
o.normal = normalize(i.normal);— 법선을 다시 정규화합니다. 정점 셰이더에서 픽셀 셰이더로 오는 동안 보간(interpolation)을 거치면서 법선의 길이가 1에서 살짝 벗어나기 때문입니다. 정규화하지 않으면 조명이 미묘하게 밝거나 어두워집니다.normalize(v)는v / length(v)입니다. -
o.albedoSpec.rgb = diffuseTex.Sample(samp, i.uv).rgb;— 디퓨즈 텍스처를uv좌표에서 샘플해 색(RGB)을 가져옵니다..Sample(sampler, uv)는 float4를 돌려주므로.rgb로 앞 세 성분만 취합니다. -
o.albedoSpec.a = specularStrength;— 남는 알파 채널에 스페큘러 세기를 패킹합니다. 텍스처 한 장을 아끼는 핵심 트릭이죠. 라이팅 패스에서 이.a를 꺼내 스페큘러 강도로 씁니다.
3. 라이팅 패스 픽셀 셰이더 (HLSL)
마지막 단계입니다. 화면을 덮는 풀스크린 삼각형을 그리면서, 각 화면 픽셀마다 G-buffer 세 장을 읽어 원래 표면 속성을 복원하고, 광원 64개를 돌며 조명을 합산합니다. 여기서 블린-퐁(Blinn-Phong) 조명 모델을 씁니다.
Texture2D gPosition : register(t0);
Texture2D gNormal : register(t1);
Texture2D gAlbedoSpec : register(t2);
SamplerState samp : register(s0);
struct Light { float3 Position; float Linear; float3 Color; float Quadratic; };
#define NR_LIGHTS 64
cbuffer LightConstants : register(b0) {
Light lights[NR_LIGHTS];
};
float4 main(float4 posH : SV_Position, float2 uv : TEXCOORD0) : SV_Target {
// G-buffer에서 픽셀 속성 복원
float3 FragPos = gPosition.Sample(samp, uv).rgb;
float3 N = normalize(gNormal.Sample(samp, uv).rgb);
float3 Albedo = gAlbedoSpec.Sample(samp, uv).rgb;
float Spec = gAlbedoSpec.Sample(samp, uv).a;
float3 V = normalize(-FragPos); // 뷰공간에서 카메라는 원점
float3 lighting = Albedo * 0.1; // 앰비언트
for (int i = 0; i < NR_LIGHTS; ++i) {
float3 L = normalize(lights[i].Position - FragPos);
float diff = max(dot(N, L), 0.0);
float3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), 32.0) * Spec;
float d = length(lights[i].Position - FragPos);
float atten = 1.0 / (1.0 + lights[i].Linear * d
+ lights[i].Quadratic * d * d);
lighting += (diff * Albedo + spec) * lights[i].Color * atten;
}
return float4(lighting, 1.0);
}
3-1. G-buffer를 입력으로 — 슬롯 t0/t1/t2
세 Texture2D가 각각 t0/t1/t2에 바인딩됩니다. 이 번호는 지오메트리 패스가 끝난 뒤 C++에서
ctx->PSSetShaderResources(0, 3, srvs)로 세 SRV를 같은 순서로 꽂아야 맞습니다
(srvs[0]=gPosition.srv ...). 쓸 때 RTV 순서, 읽을 때 SRV 순서가 처음부터 끝까지 일관돼야 합니다.
struct Light { float3 Position; float Linear; float3 Color; float Quadratic; };는 정렬 규칙을
노린 배치입니다. float3(12바이트) + float(4바이트) = 정확히 16바이트가 두 번
반복되어, 광원 하나가 딱 32바이트로 예쁘게 정렬됩니다. 그래서 Linear와 Quadratic이
엉뚱하게 float3 사이에 끼어 패딩 역할까지 겸하는 영리한 배치입니다.
#define NR_LIGHTS 64— 광원 개수를 컴파일 타임 상수로 고정. 배열 크기와 루프 상한에 함께 쓰입니다.cbuffer LightConstants : register(b0) { Light lights[NR_LIGHTS]; }— 광원 64개를 한 상수 버퍼로 올립니다. 64×32바이트 = 2048바이트로, D3D11 상수 버퍼 한계(64KB) 안에 넉넉히 들어갑니다.
3-2. 진입점 시그니처 — 풀스크린 픽셀의 좌표
float4 main(float4 posH : SV_Position, float2 uv : TEXCOORD0) : SV_Target를 뜯어봅시다.
-
float4 posH : SV_Position— 현재 칠하는 화면 픽셀의 위치. 풀스크린 삼각형이 화면을 덮으므로 모든 화면 픽셀에 대해 이 함수가 한 번씩 호출됩니다. -
float2 uv : TEXCOORD0— 이 픽셀의 정규화된 화면 좌표 [0,1]입니다. 풀스크린 삼각형의 정점 셰이더가 화면 전체에 걸쳐 0~1로 보간되도록 만들어 줍니다. 이uv로 G-buffer를 샘플하면 화면상 같은 위치의 G-buffer 값을 정확히 읽습니다. -
: SV_Target— 반환float4가 최종 백버퍼(화면)로 나갑니다. 여기서 번호를 안 붙이면SV_Target0과 같습니다.
uv를 만들 때 V가 뒤집히면 G-buffer가 위아래로
거꾸로 샘플돼 화면이 뒤집힙니다. 정점 셰이더에서 uv.y를 규약에 맞게 만들었는지 확인하세요.
3-3. G-buffer에서 속성 복원
float3 FragPos = gPosition.Sample(samp, uv).rgb;
float3 N = normalize(gNormal.Sample(samp, uv).rgb);
float3 Albedo = gAlbedoSpec.Sample(samp, uv).rgb;
float Spec = gAlbedoSpec.Sample(samp, uv).a;
지오메트리 패스에서 기록했던 것을 그대로 되읽는 대칭 구조입니다. 각 줄이 2-4의 각 줄과 정확히 짝을 이룹니다.
FragPos— 이 화면 픽셀에 보이는 표면의 뷰 공간 위치.N— 법선. 텍스처 보간·양자화로 길이가 살짝 틀어질 수 있어 다시 정규화합니다.Albedo— 표면 색(RGB).Spec— 아까 A 채널에 패킹한 스페큘러 세기를 다시 꺼냅니다. 패킹/언패킹의 짝.
핵심 통찰: 라이팅 패스는 원래 3D 씬 메시를 전혀 모릅니다. 오직 화면 크기 텍스처 세 장만 봅니다.
조명 비용이 씬의 삼각형 수와 완전히 무관해지고 오직 화면 픽셀 × 광원에만 비례하는 이유가 바로 이것입니다.
3-4. 시선 벡터와 앰비언트
float3 V = normalize(-FragPos); // 뷰공간에서 카메라는 원점
float3 lighting = Albedo * 0.1; // 앰비언트
수학적 의미: 시선 벡터 V는 "표면점에서 카메라를 향하는 단위 벡터"입니다. 일반적으로는
V = normalize(cameraPos - FragPos)인데, 뷰 공간에서는 카메라가 원점(0,0,0)이므로
cameraPos - FragPos = -FragPos가 됩니다. 그래서 normalize(-FragPos) 한 줄로 끝납니다.
기하 직관: 뷰 공간은 "내 눈을 세상의 원점에 둔" 좌표계입니다. 그러니 표면점 FragPos의
반대 방향(-FragPos)이 곧 "그 점에서 나(카메라)를 바라보는 방향"이 되는 것이죠. 이 단순함이 뷰 공간에서
조명하는 가장 큰 실무 이점입니다.
코드 매핑: lighting = Albedo * 0.1은 앰비언트(ambient) 항입니다.
어떤 광원에도 닿지 않는 그늘진 면도 완전히 새까맣지 않도록, 표면 색의 10%를 바닥값으로 깔아 줍니다. 이후 루프에서
광원 기여가 여기에 더해집니다.
3-5. 광원 루프 — 확산(diffuse)
for (int i = 0; i < NR_LIGHTS; ++i) {
float3 L = normalize(lights[i].Position - FragPos);
float diff = max(dot(N, L), 0.0);
수학: L은 "표면점에서 광원을 향하는 단위 벡터"입니다. lights[i].Position - FragPos가
광원까지의 방향(길이 포함)이고, normalize로 길이를 1로 만듭니다. 확산 강도 diff는
법선 N과 광원 방향 L의 내적입니다.
기하 직관(램버트 코사인 법칙): dot(N, L) = cosθ는 표면과 빛이 이루는 각도입니다.
빛이 표면에 수직으로 내리쬐면(θ=0) cos0 = 1로 가장 밝고, 비스듬히 스치면 어두워지며,
표면 뒤에서 오면(θ>90°) 음수가 됩니다. max(..., 0.0)은 그 음수를 0으로 잘라
"표면 뒤의 빛은 이 면을 밝히지 않는다"를 표현합니다.
손계산 예: 법선 N=(0,0,1), 빛이 정면 위 45°에서 온다면 L=(0, 0.707, 0.707).
그러면 dot(N,L) = 0×0 + 0×0.707 + 1×0.707 = 0.707. 즉 정면(1.0)보다 약 71% 밝기입니다. 빛이 옆(90°)에서
오면 L=(0,1,0) → dot = 0 → 어둡습니다. 램버트 법칙이 코드 한 줄에 그대로 들어 있습니다.
3-6. 스페큘러(specular) — 블린-퐁의 하프 벡터
float3 H = normalize(L + V);
float spec = pow(max(dot(N, H), 0.0), 32.0) * Spec;
수학: H는 하프 벡터(halfway vector)로, 광원 방향 L과 시선 방향
V의 정확히 가운데를 향하는 단위 벡터입니다. L + V를 정규화하면 둘의 중간 방향이 나옵니다.
스페큘러 세기는 dot(N, H)를 32제곱한 값에 표면의 스페큘러 강도 Spec를 곱한 것입니다.
기하 직관: 거울 반사가 가장 강하게 보이는 순간은 "표면 법선이 L과 V의 정확히
가운데를 향할 때"입니다. 즉 N과 H가 나란할 때(dot(N,H)≈1) 하이라이트가 뜹니다.
pow(..., 32)의 지수 32는 광택(shininess)입니다. 지수가 클수록 하이라이트가 작고 날카롭게
(반짝이는 금속), 작을수록 넓고 부드럽게(무광 플라스틱) 퍼집니다.
손계산 예: dot(N,H)=0.9이면 0.9^32 ≈ 0.034로 뚝 떨어지고, dot(N,H)=0.99이면
0.99^32 ≈ 0.725로 확 살아납니다. 지수 승이 거의 정면일 때만 밝게 만드는, 날카로운 하이라이트를
만듭니다. 여기에 * Spec로 표면별 반짝임 정도(아까 A 채널에 패킹한 값)를 곱해 조절합니다.
R = reflect(-L, N)을 구해 dot(R, V)를 썼습니다. 블린-퐁은
반사 벡터 대신 하프 벡터 H로 dot(N, H)를 써서 reflect 계산을 아끼고, 스치는
각도에서 하이라이트가 더 자연스럽게 나옵니다. 실무 표준이 블린-퐁인 이유입니다.
3-7. 거리 감쇠(attenuation)
float d = length(lights[i].Position - FragPos);
float atten = 1.0 / (1.0 + lights[i].Linear * d
+ lights[i].Quadratic * d * d);
수학: d는 광원까지의 실제 거리입니다. 감쇠 atten은
1 / (1 + a·d + b·d²) 꼴의 역이차(inverse-quadratic) 감쇠입니다. Linear가
1차 계수 a, Quadratic이 2차 계수 b입니다.
기하 직관: 현실의 빛은 거리 제곱에 반비례해 약해집니다(점광원이 퍼지는 구의 표면적이 4πd²이므로).
d² 항이 그 물리를 담고, d(1차) 항과 상수 1은 가까울 때 값이 무한대로 튀는 것을 막고
감쇠 곡선을 부드럽게 다듬는 실무용 보정입니다. 분모가 항상 1 이상이라 atten은 0~1 사이에 안전하게 머뭅니다.
손계산 예: a=0.09, b=0.032일 때, 거리 d=1이면 분모 1 + 0.09 + 0.032 = 1.122 →
atten ≈ 0.89(거의 밝음). d=10이면 1 + 0.9 + 3.2 = 5.1 → atten ≈ 0.196(많이 어두움).
d=20이면 1 + 1.8 + 12.8 = 15.6 → atten ≈ 0.064. 거리가 멀수록 d² 항이
지배해 급격히 어두워집니다.
3-8. 최종 합산과 반환
lighting += (diff * Albedo + spec) * lights[i].Color * atten;
}
return float4(lighting, 1.0);
성분별 해부: 한 광원의 기여는 (diff * Albedo + spec) * Color * atten입니다.
-
diff * Albedo— 확산광은 표면 색을 입습니다. 빨간 표면은 확산광도 붉게 반사하죠. -
+ spec— 스페큘러는 표면 색을 곱하지 않습니다. 하이라이트는 광원 색 그대로(흰 빛이면 흰 반짝임)라서 알베도를 안 곱합니다. 이 미묘한 차이가 재질감을 살립니다. -
* lights[i].Color— 광원 자체의 색을 곱합니다(붉은 조명이면 전체가 붉게). -
* atten— 마지막에 거리 감쇠를 곱해 먼 광원의 기여를 줄입니다.
lighting += ...로 모든 광원의 기여를 누적합니다(빛은 더해집니다). 루프가 끝나면
return float4(lighting, 1.0)로 알파 1(불투명)을 붙여 화면에 출력합니다. 이 한 픽셀에 대해 광원 64개를
다 돈 결과가 최종 색입니다.
4. 전체 흐름 한눈에
- 준비(1회): 위치·법선·알베도스펙 세 타깃 생성(텍스처+RTV+SRV), 깊이 버퍼 생성.
- 지오메트리 패스(매 프레임): 세 RTV+깊이를 MRT로 묶고 클리어 → 씬 메시를 그림 → 각 픽셀 속성이 G-buffer에 기록됨.
- 전환: RTV 언바인드 → 세 SRV를 t0/t1/t2에 바인딩 → 백버퍼를 렌더 타깃으로 설정.
- 라이팅 패스(매 프레임): 풀스크린 삼각형 1개 드로우 → 픽셀마다 G-buffer 읽어 광원 64개 합산 → 화면 출력.
- 포워드 보정 패스(선택): 반투명 물체·스카이박스 등 디퍼드로 안 되는 것들을 포워드로 덧그림.
XMMatrixMultiply로 월드·뷰·투영을 곱하고, cbuffer에 올릴 때는 HLSL mul(v, M)과
맞추기 위해 XMMatrixTranspose로 전치해 업로드합니다. 광원 위치도 CPU에서
XMVector3TransformCoord(worldPos, viewMatrix)로 뷰 공간으로 옮겨 넘겨야 셰이더의
뷰 공간 조명과 좌표계가 일치합니다. 이 좌표계 일관성이 디퍼드에서 가장 자주 틀리는 부분입니다.