← 개념 · 데모 가이드 | 구현 허브 | 6장. 고급 렌더링 | 디퍼드 렌더링 (Deferred Rendering)
IMPLEMENTATION · CHAPTER 6

디퍼드 렌더링 (Deferred Rendering) — 한 줄 한 줄 직접 구현

화면에 광원이 수십·수백 개 있는 장면을 생각해 봅시다. 보통(포워드) 렌더링은 메시를 그릴 때마다, 픽셀마다, 모든 광원을 계산합니다. 그런데 그 픽셀이 나중에 다른 물체에 가려져 버려질 픽셀이라면 그 조명 계산은 전부 낭비입니다. 디퍼드(deferred, "미룬") 렌더링은 조명 계산을 뒤로 미뤄 두 단계로 나눕니다: 먼저 (1) 지오메트리 패스에서 화면 각 픽셀의 위치·법선·색을 여러 장의 텍스처(G-buffer)에 한 번에 기록하고, 그다음 (2) 라이팅 패스에서 실제로 보이는 픽셀에 대해서만 그 텍스처들을 읽어 조명을 계산합니다. 이 페이지에서는 G-buffer를 만드는 C++ 코드, 그 G-buffer에 동시에 기록하는 HLSL(MRT) 픽셀 셰이더, 그리고 풀스크린 라이팅 패스 셰이더를 코드 한 줄, 성분 하나까지 해부한 뒤, D3D11 API와 DirectXMath로 하나하나 짚습니다.

규약 이 가이드는 D3D 규약으로 통일합니다: 행벡터 v' = v * M(HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 공간 z ∈ [0,1], 텍스처 V축은 위→아래입니다. 이 페이지의 조명 계산은 모두 뷰 공간(view space)에서 이뤄집니다 — 좌표계가 무엇이든 "카메라를 원점에 둔 공간"이라는 점만 일관되면 조명 공식 자체는 동일합니다. 실무에서는 직접 구현보다 DirectXMath와 검증된 라이브러리를 권장하지만, 여기서는 원리를 이해하려고 손으로 풀어 봅니다.

0. 무엇을, 왜 만드나

포워드 렌더링(forward rendering)은 우리가 지금까지 해 온 평범한 방식입니다. 물체를 하나씩 그리고, 각 물체의 픽셀 셰이더 안에서 "이 픽셀에 닿는 모든 광원"을 반복문으로 돌며 색을 더합니다. 문제는 두 가지입니다.

디퍼드 렌더링의 핵심 아이디어는 "조명 계산에 필요한 재료를 먼저 화면 크기 텍스처에 다 적어 두고, 조명은 화면의 최종적으로 보이는 픽셀에 대해 딱 한 번만 하자"는 것입니다. 그러면 조명 비용이 화면 픽셀 수 × 광원 수고정됩니다 — 물체가 아무리 겹쳐 있어도 화면 픽셀 수는 일정하니까요.

  1. G-buffer를 만든다: 위치·법선·(알베도+스페큘러) 세 장의 렌더 타깃 텍스처를 만들고, 각각 쓰기용 RTV와 읽기용 SRV를 붙입니다.
  2. 지오메트리 패스: 세 렌더 타깃을 OMSetRenderTargets배열로 한꺼번에 묶어 (MRT, Multiple Render Targets) 설정하고, 픽셀 셰이더가 각 픽셀의 속성을 세 타깃에 동시에 기록합니다.
  3. 라이팅 패스: 화면을 덮는 풀스크린 삼각형 하나를 그리면서, G-buffer 세 장을 SRV로 샘플해 픽셀 속성을 복원하고, 광원 루프를 돌며 최종 색을 계산합니다.
G-buffer란? G-buffer(Geometry buffer)는 "기하 정보를 담아 두는 버퍼(텍스처) 묶음"입니다. 화면과 같은 해상도의 텍스처 여러 장에, 픽셀마다 그 자리에 보이는 표면의 위치·법선·색을 저장합니다. 조명 계산에 필요한 재료를 미리 화면에 "인화"해 둔 사진 여러 장이라고 생각하면 됩니다.

1. G-buffer 만들기 — 텍스처·RTV·SRV (C++)

먼저 G-buffer로 쓸 텍스처들을 만들어야 합니다. 각 타깃은 세 개의 D3D11 객체가 한 세트입니다: 실제 픽셀 데이터를 담는 텍스처(ID3D11Texture2D), 지오메트리 패스에서 여기에 그려라라고 지정하는 렌더 타깃 뷰(RTV), 그리고 라이팅 패스에서 이 텍스처를 읽어라라고 지정하는 셰이더 리소스 뷰(SRV)입니다. 같은 텍스처를 "쓸 때는 RTV로, 읽을 때는 SRV로" 두 얼굴로 쓰는 것이 핵심입니다.

gbuffer.cppcpp
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 타깃을 이루는 세 객체를 하나로 묶은 것입니다. 각 줄을 봅시다.

왜 뷰가 두 개나 필요할까요? D3D11에서 텍스처는 "데이터 덩어리"일 뿐이고, GPU 파이프라인에 어떤 용도로 연결할지는 뷰(view)가 결정합니다. RTV는 "출력 슬롯에 꽂는 어댑터", SRV는 "입력 슬롯에 꽂는 어댑터"입니다. 같은 텍스처라도 쓸 때와 읽을 때 다른 어댑터가 필요하기 때문에 둘 다 만들어 둡니다. ComPtr은 스마트 포인터로, 참조 카운트를 자동 관리해 Release()를 잊어도 누수가 없게 해 줍니다.

함정 같은 텍스처를 같은 프레임 안에서 RTV로 바인딩한 채 SRV로도 읽으려 하면 D3D11이 자동으로 SRV를 언바인드하고 디버그 레이어가 경고를 냅니다("resource is bound as both input and output"). 디퍼드에서는 먼저 지오메트리 패스에서 RTV로 쓰고, 그 패스가 끝난 뒤 RTV를 풀고 나서 라이팅 패스에서 SRV로 읽기 때문에 충돌이 없습니다. 순서가 곧 규칙입니다.

1-2. makeTarget 람다 — 텍스처 서술자 채우기

auto makeTarget = [&](GTarget& g, DXGI_FORMAT fmt){ ... };람다(익명 함수)입니다. 타깃마다 텍스처+RTV+SRV 세 벌을 만드는 코드가 똑같아서, 반복을 줄이려고 한 함수로 묶었습니다. 각 부분을 봅시다.

D3D11_TEXTURE2D_DESC td = {};는 텍스처를 어떻게 만들지 적는 서술자(descriptor)입니다. = {}모든 필드를 0으로 초기화한 뒤 필요한 것만 채웁니다(빼먹은 필드가 쓰레기 값이 되는 사고를 막는 습관). 각 필드의 의미는 이렇습니다.

필드의미
Width / HeightW / H화면과 같은 해상도. 화면 픽셀 1:1로 정보를 담아야 하므로.
MipLevels1밉맵 없음. G-buffer는 1:1로만 읽으므로 축소본이 필요 없습니다.
ArraySize1텍스처 배열이 아니라 낱장 한 장.
Formatfmt픽셀당 채널 수·비트 폭·정수/부동소수(아래 1-4).
SampleDesc.Count1MSAA 안 씀(=1). 디퍼드는 MSAA가 까다롭습니다(아래 성능 노트).
UsageDEFAULTGPU가 읽고 쓰는 일반 텍스처. CPU 접근 없음 → 가장 빠름.
BindFlagsRENDER_TARGET | SHADER_RESOURCE이 텍스처를 렌더 타깃으로도, 셰이더 리소스로도 쓰겠다는 두 용도 선언.
함정 BindFlagsD3D11_BIND_RENDER_TARGETD3D11_BIND_SHADER_RESOURCE둘 다 OR로 넣지 않으면 디퍼드가 성립하지 않습니다. 하나라도 빠지면 그 용도로 뷰를 만들 때 CreateRenderTargetView/CreateShaderResourceView가 실패(HRESULT 오류)합니다. "쓰기+읽기 둘 다"라는 선언이 곧 G-buffer의 정의입니다.

1-3. 생성 3종 세트 — 텍스처·RTV·SRV

서술자를 채웠으면 실제 객체를 만듭니다. 세 줄이 순서대로 텍스처, RTV, 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). 왜 타깃마다 다르게 골랐는지가 디퍼드의 실무 감각입니다.

픽셀당 대역폭을 손으로 세어 볼까요? 위치 8바이트(4채널×2바이트) + 법선 8바이트 + 알베도스펙 4바이트 = 픽셀당 20바이트. 깊이 버퍼(보통 4바이트)까지 합치면 24바이트. Full HD(1920×1080 ≈ 207만 픽셀)면 지오메트리 패스에서 쓰기만 약 50MB, 라이팅 패스에서 읽기 또 그만큼. 이게 디퍼드가 대역폭을 많이 먹는 이유입니다(아래 성능 노트).

성능 G-buffer 채널을 줄이는 것이 디퍼드 최적화의 절반입니다. 실무 팁: 위치를 통째로 저장하지 말고 깊이만 저장한 뒤 라이팅 패스에서 위치를 역투영으로 재구성하면 위치 타깃(8바이트)을 통째로 없앨 수 있습니다. 법선도 x,y 두 성분만 저장하고 z는 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)를 켜는 부분입니다. 지오메트리 패스가 한 번의 드로우로 세 텍스처에 동시에 쓰도록 준비합니다.

함정 OMSetRenderTargets에 넘긴 타깃 개수·순서는 픽셀 셰이더의 SV_Target 번호와 정확히 일치해야 합니다. 또한 세 타깃은 같은 해상도·같은 샘플 수(MSAA)여야 합니다. 하나만 크기가 다르면 D3D가 바인딩을 거부합니다. 깊이 버퍼(gDSV)도 같은 해상도여야 합니다.
D3D11 API 대응

2. 지오메트리 패스 픽셀 셰이더 (HLSL)

이제 세 타깃에 무엇을 쓸지 정하는 셰이더입니다. 이 픽셀 셰이더는 색을 화면에 바로 내보내는 대신, 각 픽셀의 뷰 공간 위치·법선·알베도·스페큘러를 G-buffer 세 장에 기록합니다. 조명은 하지 않습니다 — "재료만 적어 두고 요리는 나중에".

geometry.hlsl (픽셀 셰이더)hlsl
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의 의미

상수 버퍼 안의 float specularStrength; float3 _pad;에서 _pad패딩입니다. D3D11 상수 버퍼는 16바이트(float 4개) 경계로 정렬되기 때문에, float 하나(4바이트) 뒤에 float3(12바이트)를 채워 16바이트를 딱 맞춰 줍니다. 이걸 빼먹으면 C++ 쪽 구조체와 셰이더의 메모리 배치가 어긋나 엉뚱한 값이 읽힙니다.

함정 상수 버퍼 정렬 규칙은 디퍼드가 아니라도 D3D11 초심자가 가장 많이 데는 곳입니다. 핵심 규칙: 하나의 멤버가 16바이트 경계를 넘어가면 안 됩니다. 예컨대 float3 뒤에 float은 12+4=16으로 딱 맞아 안전하지만, float2 뒤에 float3는 8+12=20이라 float3가 경계를 넘어 다음 줄로 밀립니다. 이 페이지의 라이팅 Light 구조체도 이 규칙을 노려 정렬돼 있습니다(아래 3-1).

2-2. 입력 PSIn — 정점 셰이더가 넘겨준 것

PSIn은 정점 셰이더가 보간해서 픽셀 셰이더로 넘겨준 값들입니다. 각 멤버 뒤의 콜론은 시맨틱(semantic)으로, 이 값이 파이프라인에서 어떤 역할인지 표시하는 이름표입니다.

왜 전부 뷰 공간인가? 조명 계산을 뷰 공간에서 하면 라이팅 패스에서 "카메라 위치"가 항상 원점(0,0,0)이 되어 시선 벡터 계산이 아주 간단해집니다(아래 3-4). 그래서 지오메트리 패스에서 미리 뷰 공간으로 변환해 둡니다. 행벡터 규약에서는 정점 셰이더가 mul(posWorld, viewMatrix)로 뷰 공간 위치를, 법선은 mul(normal, (float3x3)normalMatrix)로 변환해 이 PSIn에 채워 넘깁니다.

2-3. 출력 GBufferOutSV_Target이 곧 어태치먼트 번호

이 구조체가 MRT의 심장입니다. 픽셀 셰이더가 여러 개의 값을 반환하고, 각 멤버의 SV_TargetN 시맨틱이 몇 번 렌더 타깃에 갈지를 지정합니다.

셰이더의 SV_TargetN 번호 = OMSetRenderTargets에 넘긴 배열 인덱스 N입니다. 이 짝이 어긋나면 위치 텍스처에 색이 들어가는 대참사가 납니다. C++ 배열 순서와 셰이더 시맨틱 번호를 항상 함께 봐야 합니다.

함정 positionnormalfloat3인데 타깃 포맷은 R16G16B16A16(4채널)입니다. 셰이더가 3채널만 쓰면 4번째 채널(A)은 정의되지 않은 값이 됩니다. 라이팅 패스에서 .rgb만 읽으면 문제없지만, 실수로 .a를 읽으면 쓰레기가 나옵니다. albedoSpec만 A 채널을 의도적으로 씁니다.

2-4. main — G-buffer에 실제로 기록

본체는 아주 짧습니다. 조명이 없으니 그냥 재료를 각 타깃에 옮겨 담을 뿐입니다.

성능 지오메트리 패스 픽셀 셰이더는 조명 루프가 전혀 없어 아주 가볍습니다. 무거운 부분은 나중에 라이팅 패스로 미뤄졌고, 그것도 보이는 픽셀에 대해 한 번씩만 실행됩니다. 이 "무거운 계산을 화면 픽셀 수로 고정"하는 것이 디퍼드의 성능 이점 그 자체입니다.

3. 라이팅 패스 픽셀 셰이더 (HLSL)

마지막 단계입니다. 화면을 덮는 풀스크린 삼각형을 그리면서, 각 화면 픽셀마다 G-buffer 세 장을 읽어 원래 표면 속성을 복원하고, 광원 64개를 돌며 조명을 합산합니다. 여기서 블린-퐁(Blinn-Phong) 조명 모델을 씁니다.

lighting_pass.hlsl (픽셀 셰이더)hlsl
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바이트로 예쁘게 정렬됩니다. 그래서 LinearQuadratic이 엉뚱하게 float3 사이에 끼어 패딩 역할까지 겸하는 영리한 배치입니다.

3-2. 진입점 시그니처 — 풀스크린 픽셀의 좌표

float4 main(float4 posH : SV_Position, float2 uv : TEXCOORD0) : SV_Target를 뜯어봅시다.

함정 텍스처 V축은 위→아래 규약입니다. 풀스크린 삼각형의 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의 각 줄과 정확히 짝을 이룹니다.

핵심 통찰: 라이팅 패스는 원래 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를 곱한 것입니다.

기하 직관: 거울 반사가 가장 강하게 보이는 순간은 "표면 법선이 LV의 정확히 가운데를 향할 때"입니다. 즉 NH가 나란할 때(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 채널에 패킹한 값)를 곱해 조절합니다.

왜 하프 벡터? 원조 퐁(Phong) 모델은 반사 벡터 R = reflect(-L, N)을 구해 dot(R, V)를 썼습니다. 블린-퐁은 반사 벡터 대신 하프 벡터 Hdot(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는 광원까지의 실제 거리입니다. 감쇠 atten1 / (1 + a·d + b·d²) 꼴의 역이차(inverse-quadratic) 감쇠입니다. Linear가 1차 계수 a, Quadratic이 2차 계수 b입니다.

기하 직관: 현실의 빛은 거리 제곱에 반비례해 약해집니다(점광원이 퍼지는 구의 표면적이 4πd²이므로). 항이 그 물리를 담고, d(1차) 항과 상수 1은 가까울 때 값이 무한대로 튀는 것을 막고 감쇠 곡선을 부드럽게 다듬는 실무용 보정입니다. 분모가 항상 1 이상이라 atten은 0~1 사이에 안전하게 머뭅니다.

손계산 예: a=0.09, b=0.032일 때, 거리 d=1이면 분모 1 + 0.09 + 0.032 = 1.122atten ≈ 0.89(거의 밝음). d=10이면 1 + 0.9 + 3.2 = 5.1atten ≈ 0.196(많이 어두움). d=20이면 1 + 1.8 + 12.8 = 15.6atten ≈ 0.064. 거리가 멀수록 항이 지배해 급격히 어두워집니다.

3-8. 최종 합산과 반환

    lighting += (diff * Albedo + spec) * lights[i].Color * atten;
}
return float4(lighting, 1.0);

성분별 해부: 한 광원의 기여는 (diff * Albedo + spec) * Color * atten입니다.

lighting += ...모든 광원의 기여를 누적합니다(빛은 더해집니다). 루프가 끝나면 return float4(lighting, 1.0)로 알파 1(불투명)을 붙여 화면에 출력합니다. 이 한 픽셀에 대해 광원 64개를 다 돈 결과가 최종 색입니다.

성능 이 루프는 화면의 모든 픽셀에 대해 64번 도므로, 광원 대부분이 그 픽셀에서 멀어 기여가 0에 가까워도 비용은 똑같이 듭니다. 실무 최적화는 라이트 볼륨(light volume)이나 타일드/클러스터드 디퍼드로, 각 픽셀이 실제로 영향받는 광원만 돌게 합니다. 원본은 이해를 위해 모든 광원을 무조건 도는 가장 단순한 형태입니다.

4. 전체 흐름 한눈에

  1. 준비(1회): 위치·법선·알베도스펙 세 타깃 생성(텍스처+RTV+SRV), 깊이 버퍼 생성.
  2. 지오메트리 패스(매 프레임): 세 RTV+깊이를 MRT로 묶고 클리어 → 씬 메시를 그림 → 각 픽셀 속성이 G-buffer에 기록됨.
  3. 전환: RTV 언바인드 → 세 SRV를 t0/t1/t2에 바인딩 → 백버퍼를 렌더 타깃으로 설정.
  4. 라이팅 패스(매 프레임): 풀스크린 삼각형 1개 드로우 → 픽셀마다 G-buffer 읽어 광원 64개 합산 → 화면 출력.
  5. 포워드 보정 패스(선택): 반투명 물체·스카이박스 등 디퍼드로 안 되는 것들을 포워드로 덧그림.
성능 대역폭 트레이드오프: 디퍼드는 픽셀당 여러 개의 넓은 G-buffer를 쓰고 읽어 메모리 대역폭을 많이 씁니다. 그 대신 오버드로우 셰이딩이 사라져 광원이 많을 때 압승입니다. 단점은 (1) MSAA가 어렵고(에지 안티앨리어싱을 G-buffer 단계에서 하기 까다로워 보통 FXAA/TAA 같은 후처리 AA로 대체), (2) 반투명 처리가 안 돼(반투명은 여러 겹의 색을 섞어야 하는데 G-buffer는 픽셀당 표면 하나만 담으므로, 포워드로 별도 패스 필요), (3) 재질 다양성이 G-buffer 크기에 제한됩니다(재질 파라미터가 늘면 타깃이 늘고 대역폭도 늡니다).
DirectXMath 대응 이 페이지의 셰이더는 좌표를 만들어 오는 정점 셰이더에 의존합니다. C++/DirectXMath 쪽에서는 행벡터 규약으로 XMMatrixMultiply로 월드·뷰·투영을 곱하고, cbuffer에 올릴 때는 HLSL mul(v, M)과 맞추기 위해 XMMatrixTranspose로 전치해 업로드합니다. 광원 위치도 CPU에서 XMVector3TransformCoord(worldPos, viewMatrix)뷰 공간으로 옮겨 넘겨야 셰이더의 뷰 공간 조명과 좌표계가 일치합니다. 이 좌표계 일관성이 디퍼드에서 가장 자주 틀리는 부분입니다.