← 개념 · 데모 가이드 | 구현 허브 | 7장. 실전 렌더링 기법 | 감마 · 선형 색공간
IMPLEMENTATION · CHAPTER 7

감마 · 선형 색공간 — 한 줄 한 줄 직접 구현

화면에 색이 이상하게 나오는 버그의 절반은 감마(gamma) 때문입니다. 두 색을 반씩 섞었는데 가운데가 칙칙하게 어두워지거나, 조명을 켰더니 그림자 경계가 지저분하게 끊기거나, 안티앨리어싱한 글자 가장자리가 지글거린다면 거의 다 감마 처리를 빠뜨린 것입니다. 이 페이지는 왜 조명 계산은 반드시 선형(linear) 색공간에서 해야 하는지를 수식과 손계산으로 먼저 이해하고, 그다음 하드웨어에 맡기는 방법(권장)셰이더에서 직접 하는 방법 두 가지를 Direct3D 11 코드로 한 줄씩 뜯어봅니다.

규약 D3D 규약으로 통일합니다: 왼손 좌표계, 클립 공간 z는 [0,1], 텍스처 V축은 위→아래. 색 파이프라인은 sRGB로 저장된 텍스처를 선형으로 디코드 → 선형에서 조명 → 다시 sRGB로 인코드하는 순서를 항상 지킵니다. 실무에서는 하드웨어 sRGB 포맷에 맡기는 것을 권장하고, 셰이더 수동 방식은 그 뒤에서 무슨 일이 일어나는지 이해하기 위한 학습용으로 봅니다.

0. 왜 감마가 필요한가 — 30초 요약

모니터, 카메라, 이미지 파일(PNG/JPG의 디퓨즈 텍스처 등)은 색을 sRGB라는 곡선으로 다룹니다. sRGB는 대략 감마 2.2 곡선입니다. 즉 우리가 파일에서 읽는 픽셀 값 0.5는 "밝기의 절반"이 아닙니다. 실제 빛의 양(선형 밝기)으로는 대략 0.52.2 ≈ 0.218, 즉 물리적 밝기의 약 22%에 해당합니다.

이게 왜 문제냐면, 빛은 선형으로 더해지고 곱해지기 때문입니다. 두 전구를 켜면 밝기가 정확히 2배가 되고, 표면이 빛의 절반을 반사하면 도달한 빛의 정확히 절반이 눈에 들어옵니다. 그런데 sRGB로 저장된 값을 그대로 더하거나 곱하면, 곡선 위에서 계산하는 꼴이라 물리적으로 틀린 결과가 나옵니다. 그래서 파이프라인은 항상 이 3단계를 지킵니다.

  1. 읽을 때 디코드: sRGB 텍스처를 선형으로 변환해 읽는다. (clin = csrgb2.2)
  2. 선형에서 조명: 모든 라이팅·블렌딩·필터링을 선형 공간에서 수행한다.
  3. 출력할 때 인코드: 최종 색을 다시 sRGB로 변환해 화면에 쓴다. (csrgb = clin1/2.2)

수학적 의미 → 기하 직관 → 코드 매핑

수학적 의미. 감마 인코딩은 거듭제곱 함수입니다. 인코드는 y = x1/γ (γ≈2.2이므로 지수는 약 0.4545), 디코드는 그 역함수인 x = yγ 입니다. 두 함수는 서로 역함수라서, 디코드했다가 그대로 인코드하면 원래 값으로 정확히 돌아옵니다: (x2.2)1/2.2 = x. 이 성질이 뒤에서 다룰 "이중 보정 금지"의 근거입니다.

기하 직관(그림글). γ=2.2 인코드 곡선을 x축(선형 밝기) 대 y축(저장 값)으로 그리면, 원점에서 가파르게 솟았다가 완만해지는 위로 볼록한 곡선입니다. 이 곡선은 어두운 영역에 더 많은 저장 비트를 할당합니다. 사람 눈은 어두운 부분의 밝기 차이에 훨씬 민감하기 때문에, sRGB는 사실 "사람 눈에 효율적인 8비트 저장 방식"입니다. 즉 sRGB는 버그가 아니라 압축 포맷이고, 우리는 계산 직전에만 이 압축을 풀었다가 저장 직전에 다시 압축하는 것입니다.

코드 매핑. 이 "풀고-계산하고-다시 압축" 과정을 GPU 하드웨어에 자동으로 시키는 게 방법 A(..._SRGB 포맷 뷰), 셰이더에서 pow()로 직접 하는 게 방법 B입니다. 아래에서 둘 다 구현합니다.

함정 선형 공간에서 섞어야 하는 이유 — 손계산. 완전한 빨강 (1,0,0)과 완전한 초록 (0,1,0)을 반반 섞는다고 합시다. 선형에서 섞으면 (0.5, 0.5, 0) → 인코드하면 (0.51/2.2, 0.51/2.2, 0) ≈ (0.73, 0.73, 0) 으로 밝고 자연스러운 올리브색이 됩니다. 그런데 sRGB 값을 그대로 섞으면 그냥 (0.5, 0.5, 0)이 화면에 나가고, 이건 선형으로 치면 (0.22, 0.22, 0) — 즉 훨씬 어두운 색입니다. 감마를 빠뜨리면 그라디언트·블렌딩·안티앨리어싱 가장자리가 전부 이렇게 칙칙해집니다.

1. 방법 A — 하드웨어에 맡기기 (권장)

가장 좋은 방법은 셰이더에서 감마 코드를 한 줄도 쓰지 않는 것입니다. 디코드는 텍스처의 셰이더 리소스 뷰(SRV)를 DXGI_FORMAT_R8G8B8A8_UNORM_SRGB 포맷으로 만들면 GPU가 샘플링할 때 자동으로 선형화합니다. 인코드는 렌더 타깃(백버퍼)의 RTV를 ..._SRGB 포맷으로 만들면 픽셀을 쓸 때 자동으로 sRGB로 변환합니다. 게다가 이 하드웨어 변환은 정확한 sRGB 수식(단순 pow(x, 2.2)보다 정밀한 조각별 함수)을 쓰고, 텍스처 필터링·MSAA 리졸브도 올바르게 선형에서 수행되므로 품질이 더 좋습니다.

  1. 텍스처 자체는 ..._UNORM(비트만 담는 그릇)으로 만든다.
  2. 그 위에 씌우는 SRV의 Format만 ..._UNORM_SRGB로 지정 → 읽을 때 디코드.
  3. 백버퍼도 ..._UNORM으로 만들고, RTV의 Format만 ..._UNORM_SRGB로 → 쓸 때 인코드.
  4. 그 사이의 모든 셰이더 계산은 감마를 신경 쓰지 않고 순수 선형으로 한다.
gamma_hw.cppcpp
using Microsoft::WRL::ComPtr;

// ── (1) 디코드: 컬러/디퓨즈 텍스처는 sRGB 포맷 SRV로 읽기 ──
//     주의: 노멀맵·러프니스·메탈릭 등 "데이터" 텍스처는 ..._UNORM(선형)으로!
ComPtr<ID3D11ShaderResourceView> loadColorTexture(
        ID3D11Device* device, const void* pixels, int w, int h) {
    D3D11_TEXTURE2D_DESC td = {};
    td.Width = w; td.Height = h; td.MipLevels = 1; td.ArraySize = 1;
    td.Format = DXGI_FORMAT_R8G8B8A8_UNORM;    // 저장은 UNORM
    td.SampleDesc.Count = 1;
    td.Usage = D3D11_USAGE_DEFAULT;
    td.BindFlags = D3D11_BIND_SHADER_RESOURCE;

    D3D11_SUBRESOURCE_DATA sd = {};
    sd.pSysMem = pixels; sd.SysMemPitch = w * 4;
    ComPtr<ID3D11Texture2D> tex;
    device->CreateTexture2D(&td, &sd, &tex);

    // SRV를 sRGB 포맷으로 → 하드웨어가 읽을 때 선형으로 디코드
    D3D11_SHADER_RESOURCE_VIEW_DESC sv = {};
    sv.Format = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;   // ← 여기서 sRGB 지정
    sv.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
    sv.Texture2D.MipLevels = 1;
    ComPtr<ID3D11ShaderResourceView> srv;
    device->CreateShaderResourceView(tex.Get(), &sv, &srv);
    return srv;
}

// ── (2) 인코드: 백버퍼 RTV를 sRGB 포맷으로 만들면 쓸 때 자동 변환 ──
//     (스왑체인 백버퍼는 ..._UNORM으로 만들고, RTV만 ..._UNORM_SRGB로 뷰잉)
D3D11_RENDER_TARGET_VIEW_DESC rv = {};
rv.Format = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;   // 선형→sRGB 인코드
rv.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D;
device->CreateRenderTargetView(backBuffer.Get(), &rv, &rtv);
// 이제 픽셀 셰이더는 전부 선형 색으로 계산하면 된다. 감마 코드가 필요 없다.

1-1. 함수 시그니처 해부

ComPtr<ID3D11ShaderResourceView> loadColorTexture(ID3D11Device* device, const void* pixels, int w, int h) 한 줄을 뜯어보겠습니다.

1-2. 텍스처 서술자(TEXTURE2D_DESC) 한 줄씩

D3D11_TEXTURE2D_DESC td = {};= {}집합 값 초기화(aggregate initialization)입니다. 구조체의 모든 멤버를 0으로 밀어버립니다. C++에서 초기화하지 않은 구조체 멤버에는 쓰레기 값이 들어가는데, = {} 한 번으로 전부 0이 되므로, 우리가 명시적으로 채우지 않은 필드는 안전한 기본값(0)을 갖게 됩니다.

1-3. 초기 데이터 업로드와 텍스처 생성

D3D11_SUBRESOURCE_DATA sd는 텍스처를 만들면서 곧바로 채워 넣을 초기 픽셀을 알려주는 구조체입니다.

1-4. 디코드의 핵심 — SRV Format을 sRGB로

이제 디코드를 켜는 딱 한 줄입니다. sv.Format = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB;

수학적 의미. 이 포맷의 SRV로 샘플링하면 GPU가 저장된 각 채널 값 s(0~1)에 대해 sRGB→선형 역함수를 적용해서 셰이더에 넘겨줍니다. 실제 sRGB 표준 공식은 아주 어두운 값에서 선형 구간을, 나머지에서 거듭제곱 구간을 쓰는 조각별 함수입니다: s ≤ 0.04045이면 s/12.92, 아니면 ((s+0.055)/1.055)2.4. 이건 pow(s, 2.2)보다 정확합니다.

기하 직관(그림글). 텍스처에서 회색 벽돌의 저장 값이 0.5라면, 이 SRV를 통과하는 순간 셰이더에는 약 0.214가 도착합니다. 즉 "화면에서 보이던 중간 회색"이 아니라 "그 회색이 실제로 반사하는 빛의 양"으로 변환되어 들어옵니다. 조명 계산이 물리적으로 옳은 숫자 위에서 시작되는 것입니다.

함정 컬러 텍스처만 sRGB, 데이터 텍스처는 절대 sRGB 금지. 노멀맵·러프니스·메탈릭·높이맵 같은 텍스처는 색이 아니라 숫자 데이터입니다. 예컨대 노멀맵의 (0.5, 0.5, 1.0)은 "위를 향하는 법선 벡터"를 인코딩한 값이지 색이 아닙니다. 여기에 sRGB 디코드를 적용하면 법선 방향이 망가져 라이팅이 전부 틀어집니다. 데이터 텍스처는 ..._UNORM(선형)으로 그대로 읽으세요.

1-5. 인코드의 핵심 — RTV Format을 sRGB로

출력 쪽도 대칭입니다. 백버퍼 텍스처는 ..._UNORM으로 만들고, 거기에 씌우는 RTV의 Format만 DXGI_FORMAT_R8G8B8A8_UNORM_SRGB로 지정합니다. 그러면 픽셀 셰이더가 반환한 선형clin을 GPU가 clin1/2.2 (정확히는 sRGB 인코드 공식)로 변환해서 저장합니다.

주의 스왑체인 포맷 규칙. DXGI 플립 모델 스왑체인에서는 스왑체인 자체를 ..._UNORM(또는 그 _TYPELESS)로 만들고, sRGB 인코드는 RTV에서 켜는 것이 표준입니다. 스왑체인 버퍼를 직접 ..._SRGB로 만들면 일부 플립 모드에서 생성이 거부될 수 있습니다.

1-6. Direct3D API 대응 정리

단계무엇을어디서 켜나
디코드(읽기)sRGB→선형SRV.Format = ..._UNORM_SRGB
조명선형에서 add/mul셰이더(감마 코드 없음)
인코드(쓰기)선형→sRGBRTV.Format = ..._UNORM_SRGB
데이터 텍스처변환 안 함SRV.Format = ..._UNORM

2. 방법 B — 셰이더에서 수동으로

sRGB 포맷 뷰를 쓸 수 없는 상황도 있습니다. 예를 들어 여러 후처리 패스가 부동소수점(R16G16B16A16_FLOAT) 중간 버퍼를 거치는 체인에서는 sRGB 인코드/디코드를 어느 지점에서 할지를 우리가 직접 통제해야 할 때가 있습니다. 이럴 때는 픽셀 셰이더 안에서 pow()로 감마를 직접 처리합니다. 이 방식은 하드웨어 방식보다 부정확(단순 2.2 근사)하고 필터링·MSAA가 선형에서 안 일어난다는 단점이 있지만, 원리를 이해하는 데는 가장 명확합니다.

gamma_manual.hlsl (Pixel Shader)HLSL
Texture2D    uAlbedo : register(t0);   // ..._UNORM(선형 포맷)로 올린 sRGB 이미지
SamplerState uSamp   : register(s0);

static const float GAMMA = 2.2;

struct PSIn {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;
};

float4 main(PSIn i) : SV_Target {
    // (1) 디코드: sRGB → 선형 (하드웨어 sRGB 뷰를 안 썼을 때만)
    float3 albedo = pow(uAlbedo.Sample(uSamp, i.uv).rgb, GAMMA);

    // (2) 선형 공간에서 조명 계산
    float3 color = doLighting(albedo);   // 모든 add/mul은 선형에서

    // (3) 인코드: 선형 → sRGB (화면 출력 직전 한 번만)
    color = pow(color, 1.0 / GAMMA);
    return float4(color, 1.0);
}

2-1. 리소스 바인딩 선언 해부

2-2. 입력 구조체 PSIn

PSIn은 정점 셰이더가 픽셀 셰이더로 넘겨주는 값들의 묶음입니다. 각 멤버 뒤의 : 이름시맨틱(semantic)으로, GPU가 어느 값을 어디로 연결할지 정하는 꼬리표입니다.

2-3. main 함수 — 세 줄의 파이프라인

float4 main(PSIn i) : SV_Target에서 반환 시맨틱 SV_Target은 "이 값을 렌더 타깃 0번에 쓴다"는 뜻입니다. 이제 몸통 세 줄이 곧 §0에서 설명한 3단계입니다.

(1) 디코드: pow(albedo, GAMMA)

float3 albedo = pow(uAlbedo.Sample(uSamp, i.uv).rgb, GAMMA);

주의 pow 디코드는 SRV가 ..._UNORM(선형 포맷)일 때만 씁니다. 만약 SRV를 ..._UNORM_SRGB로 만들었다면 하드웨어가 이미 디코드했으므로, 이 줄을 쓰면 안 됩니다(§3의 이중 보정 참고).

(2) 조명: doLighting(albedo)

이 단계에서 하는 모든 덧셈·곱셈(디퓨즈 N·L, 앰비언트 더하기, 여러 광원 합산, 감쇠 곱하기 등)은 반드시 선형 값albedo 위에서 이루어져야 물리적으로 옳습니다. §0의 손계산에서 봤듯이, 여기서 sRGB 값을 섞으면 결과가 어두워집니다. doLighting은 라이팅 페이지에서 구현한 함수라고 보면 됩니다 — 핵심은 "여기 들어오는 색도, 여기서 나오는 색도 전부 선형"이라는 점입니다.

(3) 인코드: pow(color, 1.0 / GAMMA)

color = pow(color, 1.0 / GAMMA); — 화면에 내보내기 직전 딱 한 번 선형→sRGB로 인코드합니다. 1.0 / GAMMA는 약 0.4545이고, 디코드에서 쓴 2.2의 역수입니다. 지수를 곱하면 2.2 × 0.4545 = 1.0이 되어, 아무 조명 없이 디코드→인코드만 하면 원래 값으로 정확히 돌아온다는 걸 확인할 수 있습니다.

마지막 return float4(color, 1.0);은 인코드된 RGB에 알파 1.0(완전 불투명)을 붙여 float4로 반환합니다.

성능 pow는 공짜가 아닙니다. pow는 내부적으로 exp·log로 풀리는 비싼 연산이라, 픽셀마다 3채널 두 번(디코드+인코드) 호출하면 화면 전체에서 무시 못 할 비용이 됩니다. 그래서 실무에서는 이 수동 방식보다 하드웨어 sRGB 포맷(방법 A)이 성능·품질 모두 낫습니다. 하드웨어 변환은 텍스처 샘플러/ROP에 내장된 전용 회로라 사실상 공짜입니다.

3. 가장 흔한 버그 — 이중 보정

함정 하드웨어 방식과 수동 방식을 절대 섞지 마세요. RTV를 ..._UNORM_SRGB로 만든 상태에서 셰이더에서도 pow(color, 1/2.2)를 하면 감마가 두 번 적용됩니다. 그러면 화면이 지나치게 밝고 뿌옇게(워시아웃) 됩니다. 반대로 ..._UNORM_SRGB SRV를 쓰면서 셰이더에서 또 pow(.., 2.2)로 디코드하면 디코드가 두 번 걸려 색이 과도하게 어두워집니다.

수치로 확인. 선형 밝기 0.5를 인코드하면 0.50.4545 ≈ 0.73입니다. 여기에 실수로 한 번 더 인코드하면 0.730.4545 ≈ 0.865 — 원래보다 훨씬 밝아졌죠. 반대로 sRGB 값 0.5를 두 번 디코드하면 0.52.2 ≈ 0.218, 다시 0.2182.2 ≈ 0.037로 거의 검은색이 됩니다. 두 경우 모두 "감마 처리는 파이프라인 전체에서 디코드 1번, 인코드 1번"이라는 규칙을 어긴 결과입니다.

3-1. 자가 진단 체크리스트

  1. 컬러 텍스처 SRV는 ..._UNORM_SRGB인가? 데이터 텍스처는 ..._UNORM인가?
  2. 백버퍼 RTV는 ..._UNORM_SRGB 한 곳에서만 인코드하는가?
  3. SRV가 sRGB면 셰이더에 pow(.., 2.2) 디코드가 없어야 한다.
  4. RTV가 sRGB면 셰이더에 pow(.., 1/2.2) 인코드가 없어야 한다.
  5. 중간 버퍼(포스트프로세스)는 정밀도 손실을 막기 위해 보통 _FLOAT로 선형을 유지하고, 최종 출력에서 한 번만 인코드한다.
정리 감마는 어렵지 않습니다. "저장은 sRGB, 계산은 선형" 한 문장과, 디코드/인코드는 파이프라인 전체에서 각각 딱 한 번이라는 규칙만 지키면 됩니다. 가능하면 셰이더에서 손대지 말고 하드웨어 ..._SRGB 포맷에 맡기세요 — 더 정확하고, 더 빠르고, 실수할 여지가 적습니다.