감마 · 선형 색공간 — 한 줄 한 줄 직접 구현
화면에 색이 이상하게 나오는 버그의 절반은 감마(gamma) 때문입니다. 두 색을 반씩 섞었는데 가운데가 칙칙하게 어두워지거나, 조명을 켰더니 그림자 경계가 지저분하게 끊기거나, 안티앨리어싱한 글자 가장자리가 지글거린다면 거의 다 감마 처리를 빠뜨린 것입니다. 이 페이지는 왜 조명 계산은 반드시 선형(linear) 색공간에서 해야 하는지를 수식과 손계산으로 먼저 이해하고, 그다음 하드웨어에 맡기는 방법(권장)과 셰이더에서 직접 하는 방법 두 가지를 Direct3D 11 코드로 한 줄씩 뜯어봅니다.
0. 왜 감마가 필요한가 — 30초 요약
모니터, 카메라, 이미지 파일(PNG/JPG의 디퓨즈 텍스처 등)은 색을 sRGB라는 곡선으로
다룹니다. sRGB는 대략 감마 2.2 곡선입니다. 즉 우리가 파일에서 읽는 픽셀 값 0.5는
"밝기의 절반"이 아닙니다. 실제 빛의 양(선형 밝기)으로는 대략
0.52.2 ≈ 0.218, 즉 물리적 밝기의 약 22%에 해당합니다.
이게 왜 문제냐면, 빛은 선형으로 더해지고 곱해지기 때문입니다. 두 전구를 켜면 밝기가 정확히 2배가 되고, 표면이 빛의 절반을 반사하면 도달한 빛의 정확히 절반이 눈에 들어옵니다. 그런데 sRGB로 저장된 값을 그대로 더하거나 곱하면, 곡선 위에서 계산하는 꼴이라 물리적으로 틀린 결과가 나옵니다. 그래서 파이프라인은 항상 이 3단계를 지킵니다.
- 읽을 때 디코드: sRGB 텍스처를 선형으로 변환해 읽는다. (
clin = csrgb2.2) - 선형에서 조명: 모든 라이팅·블렌딩·필터링을 선형 공간에서 수행한다.
- 출력할 때 인코드: 최종 색을 다시 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 리졸브도 올바르게
선형에서 수행되므로 품질이 더 좋습니다.
- 텍스처 자체는
..._UNORM(비트만 담는 그릇)으로 만든다. - 그 위에 씌우는 SRV의 Format만
..._UNORM_SRGB로 지정 → 읽을 때 디코드. - 백버퍼도
..._UNORM으로 만들고, RTV의 Format만..._UNORM_SRGB로 → 쓸 때 인코드. - 그 사이의 모든 셰이더 계산은 감마를 신경 쓰지 않고 순수 선형으로 한다.
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)
한 줄을 뜯어보겠습니다.
-
반환 타입
ComPtr<ID3D11ShaderResourceView>.ComPtr은Microsoft::WRL의 스마트 포인터로, COM 객체의 참조 카운트를 자동으로 관리합니다. 함수가 만든 SRV를 반환할 때 참조 카운트를 올려서 넘겨주고, 호출한 쪽에서ComPtr이 소멸하면 자동으로Release()가 불립니다. 직접ID3D11ShaderResourceView*를 반환하면Release를 깜빡해 누수가 나기 쉬운데,ComPtr은 그 실수를 원천 차단합니다.< >는 템플릿 인자로, "이 스마트 포인터가 감싸는 COM 인터페이스가 무엇인지"를 지정합니다. -
ID3D11Device* device. D3D11의 디바이스는 GPU 리소스를 생성하는 팩토리입니다. 텍스처·버퍼·뷰 같은 자원은 전부device->Create...로 만듭니다. (그리는 명령은ID3D11DeviceContext가 담당하는데, 그건 뒤의UpdateSubresource등에서 나옵니다.) 여기선 원시 포인터*로 받습니다 — 이 함수는 디바이스를 소유하지 않고 잠깐 빌려 쓸 뿐이라 참조 카운트를 건드리지 않습니다. -
const void* pixels. 픽셀 원본 데이터의 시작 주소입니다. 타입을void*로 둔 이유는 "그냥 바이트 덩어리"이기 때문입니다 — RGBA 8비트든 무엇이든 이 함수는 바이트로만 취급해 GPU에 복사합니다.const는 "이 함수가 원본을 읽기만 하고 고치지 않는다"는 약속이라, 호출하는 쪽이 안심하고 넘길 수 있습니다. -
int w, int h. 텍스처의 가로·세로 픽셀 수입니다. 뒤에서SysMemPitch = w * 4(한 줄의 바이트 수)를 계산하는 데 쓰입니다. 4는 R,G,B,A 각 1바이트를 뜻합니다.
1-2. 텍스처 서술자(TEXTURE2D_DESC) 한 줄씩
D3D11_TEXTURE2D_DESC td = {};의 = {}는 집합 값 초기화(aggregate
initialization)입니다. 구조체의 모든 멤버를 0으로 밀어버립니다. C++에서 초기화하지 않은
구조체 멤버에는 쓰레기 값이 들어가는데, = {} 한 번으로 전부 0이 되므로, 우리가 명시적으로
채우지 않은 필드는 안전한 기본값(0)을 갖게 됩니다.
td.Width / td.Height: 텍스처 크기(픽셀).td.MipLevels = 1: 밉 체인 없이 원본 한 장만. (밉맵은 다음 페이지에서 다룹니다.)td.ArraySize = 1: 텍스처 배열이 아니라 낱장 한 개.-
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM— 여기가 핵심입니다. 텍스처 자체는 그냥UNORM(sRGB 아님)으로 만듭니다. 텍스처는 "8비트 정수 네 개를 담는 그릇"일 뿐, 그 비트를 어떻게 해석할지는 뷰가 정합니다. 그릇과 해석을 분리하는 게 D3D의 설계입니다. (UNORM= unsigned normalized: 0~255 정수를 0.0~1.0 실수로 읽어줌.) td.SampleDesc.Count = 1: MSAA 없음(1 샘플).td.Usage = D3D11_USAGE_DEFAULT: GPU가 읽고 쓰는 일반 용도. 초기 데이터로 한 번 채우고 그대로 쓰는 텍스처에 적합합니다.td.BindFlags = D3D11_BIND_SHADER_RESOURCE: 이 텍스처를 셰이더에서 샘플링(읽기)하겠다는 선언.
1-3. 초기 데이터 업로드와 텍스처 생성
D3D11_SUBRESOURCE_DATA sd는 텍스처를 만들면서 곧바로 채워 넣을 초기 픽셀을 알려주는
구조체입니다.
sd.pSysMem = pixels: CPU 메모리에 있는 픽셀 원본의 주소.-
sd.SysMemPitch = w * 4: 피치(pitch)는 한 행(row)의 바이트 수입니다. RGBA8이면 픽셀 하나가 4바이트이므로가로픽셀수 × 4. GPU가 다음 행의 시작 위치를 알려면 이 값이 필요합니다. 만약 여기에w만 넣는 흔한 실수를 하면 이미지가 대각선으로 찢어집니다. -
device->CreateTexture2D(&td, &sd, &tex): 서술자(&td)와 초기 데이터(&sd)를 넘겨 텍스처를 만들고, 결과 포인터를&tex에 받습니다.&는 "이 변수의 주소"를 뜻하는 주소 연산자입니다. C++ API가 값을 채워서 돌려줘야 하니 주소를 넘깁니다.
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가 도착합니다. 즉 "화면에서 보이던 중간 회색"이 아니라 "그
회색이 실제로 반사하는 빛의 양"으로 변환되어 들어옵니다. 조명 계산이 물리적으로 옳은 숫자 위에서
시작되는 것입니다.
(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 인코드 공식)로 변환해서 저장합니다.
rv.Format = ..._UNORM_SRGB: 쓸 때 선형→sRGB 인코드를 켜는 유일한 줄.rv.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D: 이 뷰가 2D 텍스처를 가리킨다는 선언.device->CreateRenderTargetView(backBuffer.Get(), &rv, &rtv): 백버퍼 위에 sRGB RTV를 만든다.backBuffer.Get()은ComPtr이 감싼 원시 포인터를 꺼내는 호출입니다.
..._UNORM(또는 그 _TYPELESS)로 만들고, sRGB 인코드는
RTV에서 켜는 것이 표준입니다. 스왑체인 버퍼를 직접 ..._SRGB로 만들면
일부 플립 모드에서 생성이 거부될 수 있습니다.
1-6. Direct3D API 대응 정리
| 단계 | 무엇을 | 어디서 켜나 |
|---|---|---|
| 디코드(읽기) | sRGB→선형 | SRV.Format = ..._UNORM_SRGB |
| 조명 | 선형에서 add/mul | 셰이더(감마 코드 없음) |
| 인코드(쓰기) | 선형→sRGB | RTV.Format = ..._UNORM_SRGB |
| 데이터 텍스처 | 변환 안 함 | SRV.Format = ..._UNORM |
2. 방법 B — 셰이더에서 수동으로
sRGB 포맷 뷰를 쓸 수 없는 상황도 있습니다. 예를 들어 여러 후처리 패스가 부동소수점(R16G16B16A16_FLOAT)
중간 버퍼를 거치는 체인에서는 sRGB 인코드/디코드를 어느 지점에서 할지를 우리가 직접 통제해야 할
때가 있습니다. 이럴 때는 픽셀 셰이더 안에서 pow()로 감마를 직접 처리합니다. 이 방식은 하드웨어
방식보다 부정확(단순 2.2 근사)하고 필터링·MSAA가 선형에서 안 일어난다는 단점이 있지만, 원리를 이해하는
데는 가장 명확합니다.
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. 리소스 바인딩 선언 해부
-
Texture2D uAlbedo : register(t0);— HLSL에서 2D 텍스처 리소스를 선언합니다.register(t0)의t는 텍스처(SRV) 슬롯을 뜻하고0은 0번 슬롯입니다. C++ 쪽에서PSSetShaderResources(0, 1, &srv)로 0번에 바인딩한 SRV가 바로 이uAlbedo에 연결됩니다. 슬롯 번호가 C++와 HLSL 사이의 약속입니다. -
SamplerState uSamp : register(s0);— 샘플러 상태입니다.s는 샘플러 슬롯. 샘플러는 "어떻게 읽을지"(선형/포인트 필터, 래핑 방식 등)를 담습니다. 텍스처(무엇을 읽나)와 샘플러(어떻게 읽나)가 분리되어 있는 것이 D3D의 특징입니다. -
static const float GAMMA = 2.2;— 셰이더 안에서만 쓰는 상수입니다.const는 값을 못 바꾼다는 뜻,static은 이 값이 컴파일 시점에 셰이더 안에 박히는 상수(외부 cbuffer가 아님)라는 뜻입니다. 상수 버퍼로 뺄 필요가 없는 고정 값이라 이렇게 씁니다.
2-2. 입력 구조체 PSIn
PSIn은 정점 셰이더가 픽셀 셰이더로 넘겨주는 값들의 묶음입니다. 각 멤버 뒤의
: 이름은 시맨틱(semantic)으로, GPU가 어느 값을 어디로 연결할지 정하는
꼬리표입니다.
-
float4 pos : SV_Position;—SV_는 시스템 값(System Value) 접두사입니다.SV_Position은 래스터라이저가 채워주는 화면 픽셀 좌표로, 우리가 직접 안 써도 항상 존재합니다. 클립 공간을 거쳐 온 값이라 z가 [0,1] 범위(D3D 규약)입니다. -
float2 uv : TEXCOORD0;— 텍스처 좌표입니다.TEXCOORD0은 사용자 정의 시맨틱이고, 정점 셰이더가 출력한 UV가 픽셀마다 보간되어 들어옵니다. D3D 규약상 V(=y)는 위→아래로 증가합니다(텍스처 맨 위가 v=0).
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);
uAlbedo.Sample(uSamp, i.uv):uSamp규칙으로uv위치의 텍셀을 읽어float4(RGBA)를 돌려줍니다..rgb: 스위즐(swizzle)로 RGB 세 채널만 뽑아float3로 만듭니다. 알파는 색이 아니라 감마 대상이 아니므로 뺍니다.-
pow(x, GAMMA): HLSL의pow는 성분별로 동작합니다. 즉float3의 R,G,B 각각에x2.2를 적용합니다. 이것이 sRGB→선형 디코드의 2.2 근사입니다.
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. 가장 흔한 버그 — 이중 보정
..._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. 자가 진단 체크리스트
- 컬러 텍스처 SRV는
..._UNORM_SRGB인가? 데이터 텍스처는..._UNORM인가? - 백버퍼 RTV는
..._UNORM_SRGB한 곳에서만 인코드하는가? - SRV가 sRGB면 셰이더에
pow(.., 2.2)디코드가 없어야 한다. - RTV가 sRGB면 셰이더에
pow(.., 1/2.2)인코드가 없어야 한다. - 중간 버퍼(포스트프로세스)는 정밀도 손실을 막기 위해 보통
_FLOAT로 선형을 유지하고, 최종 출력에서 한 번만 인코드한다.
..._SRGB 포맷에 맡기세요 — 더 정확하고, 더 빠르고, 실수할 여지가 적습니다.