밉맵 · 텍스처 필터링 — 한 줄 한 줄 직접 구현
멀리 있는 벽돌 벽이나 비스듬한 바닥 타일을 보면, 필터링이 없는 렌더러에서는 표면이
지글지글 끓듯이 반짝이거나 무늬가 모아레(moiré)로 어지럽게 일렁입니다.
카메라가 조금만 움직여도 무늬가 춤을 추죠. 이건 그래픽이 “고급”이 아니라 버그처럼 보입니다.
원인은 하나입니다 — 화면의 한 픽셀이 텍스처의 여러 텍셀을 덮는데, 그중 딱 하나만
골라 찍기 때문입니다. 이 페이지에서는 이 축소 앨리어싱(minification aliasing)을
밉맵(mipmap)과 텍스처 필터링으로 어떻게 없애는지, D3D11의 텍스처 서술자
한 필드, GenerateMips 한 호출, SamplerState의 Filter 열거값 하나,
그리고 HLSL 픽셀 셰이더의 샘플링 한 줄까지 성분 단위로 해부합니다.
v' = v*M, HLSL mul(v, M))로
통일합니다. 텍스처 좌표는 V축이 위→아래(좌상단이 (u,v)=(0,0))인 D3D 관례를 따릅니다.
실무에서는 밉 생성·필터링 모두 GPU/드라이버가 최적화된 경로로 처리하니 직접 구현하지 말고
검증된 API 경로(GenerateMips, 하드웨어 샘플러)를 쓰는 것이 정석입니다.
여기서 “직접” 해부하는 이유는 내부에서 무슨 일이 벌어지는지를 손에 잡히게 이해하기 위해서입니다.
ID3D11Texture2D → SRV → 샘플러 → 픽셀 셰이더 UV 샘플링의 큰 줄기는 이미 안다고 보고,
“어떻게 하면 흔들리지 않고 선명하게 샘플링하느냐”에 집중합니다.
먼저: 왜 “한 픽셀 = 여러 텍셀”이 문제인가
코드에 들어가기 전에, 이 페이지 전체를 관통하는 샘플링 이론의 직관을 확실히 잡고 갑니다.
이걸 모르면 아래의 MipLevels, Filter 같은 값들이 왜 존재하는지 절대 이해되지 않습니다.
텍셀(texel)과 픽셀(pixel)은 다른 격자입니다
텍셀은 텍스처 이미지 안의 한 칸(예: 512×512 이미지라면 텍셀이 262,144개)입니다.
픽셀(정확히는 프래그먼트)은 화면에 실제로 칠해지는 한 점입니다. 텍스처 매핑이란 결국
“이 화면 픽셀은 텍스처의 어느 위치(uv)를 가리키나?”를 계산해 그 위치의 색을 가져오는 일입니다.
문제는 두 격자의 밀도(해상도)가 다르다는 데서 시작합니다.
확대(Magnification): 텍셀이 픽셀보다 클 때
카메라가 텍스처에 아주 가까이 붙으면, 텍스처 한 텍셀이 화면에서 여러 픽셀을 차지합니다.
16×16 아이콘을 화면 가득 확대한 경우를 떠올리면 됩니다. 이때는 정보가 모자라서
“계단”처럼 각지거나(포인트 필터) 부드럽게 번지게(선형 필터) 됩니다.
이건 정보 부족 문제라 밉맵으로는 못 고치고 MAG 필터가 담당합니다.
축소(Minification): 텍셀이 픽셀보다 작을 때 — 진짜 골칫거리
반대로 1024×1024 벽돌 텍스처가 화면에서 겨우 40×40 픽셀로 작게 그려진다고 합시다.
그러면 화면 한 픽셀이 텍스처의 대략 (1024/40)² ≈ 655개의 텍셀을 덮습니다.
올바른 색은 그 655개 텍셀의 평균이어야 합니다. 그런데 GPU가 아무 대책 없이 uv 좌표에서
딱 한 텍셀만 뽑으면 어떻게 될까요?
카메라가 아주 조금만 움직여도 “뽑히는 그 한 텍셀”이 바뀝니다. 프레임 1에서는 밝은 벽돌 줄눈, 프레임 2에서는 어두운 벽돌 몸통… 이런 식으로 값이 제멋대로 튀면서 화면이 지글거립니다. 이게 바로 축소 앨리어싱입니다. 신호처리로 말하면 “너무 촘촘한 신호(텍스처)를 너무 성기게 표본(픽셀)했다”는 나이퀴스트(Nyquist) 위반이고, 그 부작용이 화면에 무늬로 접혀(alias) 돌아오는 겁니다.
밉맵 = 반씩 줄인 이미지 피라미드
밉맵은 원본(레벨 0)을 가로·세로 절반으로 줄인 이미지(레벨 1), 그것을 또 절반(레벨 2)…
이렇게 1×1이 될 때까지 미리 만들어 둔 이미지 피라미드입니다. “mip”은 라틴어
multum in parvo(작은 것 안에 많은 것)에서 왔습니다. 레벨을 하나 올릴 때마다 텍셀 수가 1/4로
줄어드는데, 그 한 텍셀은 원본 4개 텍셀의 평균(박스 필터)이라, 축소해서 볼 때 필요한
“평균”을 미리 계산해 둔 셈입니다.
| 레벨 | 해상도(예: 512 원본) | 텍셀 수 | 한 텍셀이 대표하는 원본 영역 |
|---|---|---|---|
| 0 (base) | 512 × 512 | 262,144 | 1 텍셀 |
| 1 | 256 × 256 | 65,536 | 2×2 = 4 텍셀 평균 |
| 2 | 128 × 128 | 16,384 | 4×4 = 16 텍셀 평균 |
| … | … | … | … |
| 9 | 1 × 1 | 1 | 이미지 전체 평균색 |
전체 밉 체인은 원본 대비 메모리를 딱 1/3만 더 씁니다
(1 + 1/4 + 1/16 + … = 4/3인 등비급수). 화질을 위해 지불하는 비용치고는 아주 쌉니다.
피라미드가 준비되면, 런타임에는 화면에서의 축소 정도(LOD)를 계산해 알맞은 레벨을 골라
거기서 한 번만 뽑으면 이미 “평균”이 들어 있으니 지글거림이 사라집니다.
1. 밉맵 텍스처 만들기 — desc·GenerateMips·Sampler 한 줄씩
무엇을 만드나 — GPU가 밉 피라미드를 자동으로 채워 줄 수 있는 텍스처를 생성하고,
베이스 레벨에 픽셀을 올린 뒤, GenerateMips 한 번으로 나머지 레벨을 다 채우고, 그것을
트라이리니어로 읽는 샘플러를 만듭니다. 왜 필요한가 — 밉을 만들 “권한”이
있는 텍스처로 선언(desc)해야 GPU가 축소 이미지를 그려 넣을 수 있고, 샘플러의 Filter가
“어느 레벨을 어떻게 섞어 읽을지”를 결정하기 때문입니다.
- 텍스처 서술자(desc)를 밉 체인 자동 할당 + 렌더타깃 + GENERATE_MIPS로 채운다.
CreateTexture2D로 빈 텍스처를 만들고, 베이스(0번) 레벨에 픽셀을 업로드한다.- SRV를 만들고
GenerateMips(srv)로 1번 이하 레벨을 GPU가 자동 생성한다. SamplerState의Filter를 트라이리니어로 두고 픽셀 셰이더 슬롯에 바인딩한다.
using Microsoft::WRL::ComPtr;
// ── 밉맵을 GPU로 생성하려면 텍스처를 RENDER_TARGET + GENERATE_MIPS로 만든다 ──
D3D11_TEXTURE2D_DESC td = {};
td.Width = w; td.Height = h;
td.MipLevels = 0; // 0 = 전체 밉 체인 자동 할당
td.ArraySize = 1;
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; // 컬러는 sRGB
td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_DEFAULT;
td.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET;
td.MiscFlags = D3D11_RESOURCE_MISC_GENERATE_MIPS; // ← GenerateMips 허용
ComPtr<ID3D11Texture2D> tex;
device->CreateTexture2D(&td, nullptr, &tex); // 초기 데이터는 아래서 채움
// 0번(base) 레벨에 픽셀 업로드 후, 나머지 축소 피라미드는 GPU가 생성
context->UpdateSubresource(tex.Get(), 0, nullptr, pixels, w * 4, 0);
ComPtr<ID3D11ShaderResourceView> srv;
device->CreateShaderResourceView(tex.Get(), nullptr, &srv);
context->GenerateMips(srv.Get()); // ── 밉맵 자동 생성 ──
// ── 샘플러: 축소·확대 필터는 SamplerState의 Filter가 결정 ──
D3D11_SAMPLER_DESC sd = {};
sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; // 트라이리니어(표준)
// 대안 비교:
// D3D11_FILTER_MIN_MAG_MIP_POINT → 가장 싸지만 레벨 전환선이 보임
// D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT→ 바이리니어 + 레벨 하드컷 (구형 게임 룩)
// D3D11_FILTER_MIN_MAG_MIP_LINEAR → 트라이리니어, 표준 선택
// (MAG만 POINT로 바꾸면 픽셀아트 느낌의 또렷한 확대)
sd.AddressU = sd.AddressV = sd.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
sd.MaxLOD = D3D11_FLOAT32_MAX;
ComPtr<ID3D11SamplerState> samp;
device->CreateSamplerState(&sd, &samp);
context->PSSetSamplers(0, 1, samp.GetAddressOf());
1-1. using Microsoft::WRL::ComPtr; — 왜 스마트 포인터인가
ComPtr은 COM 객체용 스마트 포인터입니다. Direct3D의 모든 리소스
(ID3D11Texture2D, ID3D11SamplerState 등)는 내부에 참조 카운트를
가진 COM 객체라, 다 쓰면 Release()로 카운트를 낮춰 줘야 메모리가 회수됩니다.
ComPtr은 이 AddRef()/Release()를 스코프에 따라 자동으로
해 줍니다. using 선언은 매번 Microsoft::WRL::ComPtr이라고 길게 안 쓰려는
이름 줄이기일 뿐, 기능은 없습니다. 입문자는 “이건 delete를 대신 해 주는 포인터”로 이해하면 됩니다.
1-2. D3D11_TEXTURE2D_DESC td = {}; — 서술자를 0으로 초기화
D3D11에서 리소스를 만들 때는 “이런 모양의 텍스처를 원해요”라는 설계도를 구조체로 채워
넘깁니다. 그 설계도가 D3D11_TEXTURE2D_DESC입니다. 뒤의 = {}는 C++의
집합 초기화(aggregate initialization)로, 구조체의 모든 멤버를 0으로 밀어 둡니다.
이게 왜 중요하냐면, D3D11_TEXTURE2D_DESC에는 우리가 명시하지 않는 필드
(예: CPUAccessFlags)가 여럿 있는데, = {}가 없으면 이들이 쓰레기값으로
남아 CreateTexture2D가 알 수 없는 이유로 실패합니다. desc는 항상 = {}로
시작하는 습관을 들이세요. 아래 D3D11_SAMPLER_DESC sd = {};도 같은 이유입니다.
1-3. Width / Height — 베이스 레벨의 픽셀 크기
w, h는 원본 이미지의 가로·세로 픽셀 수입니다. 이건 레벨 0의 크기이며,
밉 체인의 나머지 레벨 크기는 여기서 자동으로 유도됩니다. 정확히는
레벨 k의 크기 = max(1, floor(원본 / 2^k))입니다. 예를 들어 512는
256 → 128 → 64 → 32 → 16 → 8 → 4 → 2 → 1로 총 10개 레벨이 나옵니다
(log2(512) + 1 = 10).
1-4. td.MipLevels = 0; — “밉을 몇 층 만들까?”의 마법값 0
MipLevels는 밉 체인의 레벨 개수입니다. 여기에 0을 넣으면
D3D11이 “가능한 만큼 전부(1×1까지) 할당하라”는 뜻으로 해석합니다. 512×512라면
앞서 계산한 10개 레벨의 공간을 미리 확보해 둡니다. (공간만 잡을 뿐, 1번 이하 레벨의
실제 픽셀은 아직 비어 있습니다 — 채우는 건 GenerateMips의 몫입니다.)
MipLevels = 1로 두면 밉이 없는(레벨 0만 있는) 텍스처가 됩니다. 이러면
GenerateMips를 불러도 채울 레벨 자체가 없어 아무 일도 안 일어나고, 샘플러를
트라이리니어로 해 놔도 여전히 지글거립니다. “밉을 켰는데 안 된다”면 십중팔구 여기입니다 —
0(자동 전체) 또는 정확한 레벨 수를 주세요.
1-5. td.ArraySize = 1; — 텍스처 배열이 아니라 단일 텍스처
D3D11의 Texture2D는 사실 텍스처 배열도 될 수 있습니다(큐브맵 6면, 그림자맵
캐스케이드 등). 여기서는 평범한 한 장이라 1입니다. 밉 레벨과 배열 슬라이스는 서로 다른 축이라,
“서브리소스 인덱스”는 배열슬라이스 * 밉레벨수 + 밉레벨로 계산됩니다. 아래
UpdateSubresource의 두 번째 인자가 바로 이 인덱스입니다.
1-6. td.Format = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; — 왜 _SRGB인가
포맷은 텍셀 한 개의 비트 배치를 정합니다. R8G8B8A8는 R·G·B·A 각각
8비트(0~255)로 한 텍셀이 4바이트라는 뜻이고(아래 w * 4가 여기서 나옵니다),
_UNORM은 그 0~255를 셰이더에서 0.0~1.0 실수로 정규화해 읽는다는 뜻입니다.
핵심은 뒤의 _SRGB입니다. 카메라·이미지 파일의 컬러는 감마 인코딩된 sRGB
공간에 저장돼 있습니다. _SRGB 포맷을 쓰면 GPU가 텍셀을 읽는 순간
선형 공간으로 자동 디코딩해 주고, 렌더타깃이 _SRGB이면 출력 때
다시 감마 인코딩해 줍니다. 조명·블렌딩·밉 다운샘플(평균)은 반드시
선형 공간에서 해야 물리적으로 옳습니다. 감마 공간에서 평균을 내면 색이 어두워집니다.
자세한 배경은 감마 · 선형 색공간 페이지를 보세요.
_SRGB가 아니라 순수 _UNORM으로 만들어야 합니다. 노멀맵을
_SRGB로 읽으면 법선 방향이 감마 곡선으로 왜곡돼 라이팅이 미묘하게 틀어집니다.
“색이면 _SRGB, 데이터면 _UNORM”을 기억하세요.
1-7. td.SampleDesc.Count = 1; — MSAA 아님
SampleDesc.Count는 픽셀당 멀티샘플(MSAA) 개수입니다. 일반 텍스처는
멀티샘플이 아니므로 반드시 1입니다(0을 넣으면 실패). MSAA는 렌더타깃(백버퍼)에 쓰는
개념이지, 읽어 오는 소스 텍스처에는 쓰지 않습니다.
1-8. td.Usage = D3D11_USAGE_DEFAULT; — GPU 전용, 빠른 접근
Usage는 이 리소스를 CPU/GPU가 각각 얼마나 자주, 어떻게 만질지를 GPU 메모리
배치에 힌트로 줍니다. DEFAULT는 GPU가 읽고 쓰는 데 최적이고 CPU는 직접
못 만지는 배치입니다. 밉을 GPU가 그려 넣어야 하니(=GPU 쓰기) DEFAULT가 맞고, 이 경우
베이스 픽셀은 CPU 매핑 대신 UpdateSubresource로 올립니다(아래).
1-9. td.BindFlags = SHADER_RESOURCE | RENDER_TARGET; — 왜 렌더타깃까지?
BindFlags는 “이 텍스처를 파이프라인의 어느 자리에 꽂을 수 있게 할까”를
비트 OR로 지정합니다. |는 비트 OR 연산자로, 두 권한을 동시에 부여한다는 뜻입니다.
D3D11_BIND_SHADER_RESOURCE— 픽셀 셰이더에서 읽을 수 있게(SRV 생성 가능). 텍스처의 본래 목적이죠.D3D11_BIND_RENDER_TARGET— 이 텍스처에 GPU가 그림을 그릴 수 있게. 의외죠? 하지만GenerateMips는 내부적으로 각 밉 레벨에 축소 이미지를 “렌더링”하는 방식이라, 렌더타깃 권한이 없으면 밉을 못 만듭니다.
1-10. td.MiscFlags = D3D11_RESOURCE_MISC_GENERATE_MIPS; — 밉 생성 허가증
이 플래그가 있어야 GenerateMips를 이 텍스처의 SRV에 호출할 수 있습니다. 위의
RENDER_TARGET 바인드 플래그와 짝입니다 — 하나라도 빠지면
GenerateMips가 조용히 실패합니다(디버그 레이어를 켜면 경고를 볼 수 있습니다).
“밉을 GPU로 만들려면 (1) 공간을 잡고(MipLevels=0) (2) 그릴 권한(RENDER_TARGET)과
(3) 허가증(GENERATE_MIPS)이 셋 다 필요하다”가 이 코드의 핵심 계약입니다.
1-11. device->CreateTexture2D(&td, nullptr, &tex); — 껍데기 생성
이제 설계도(desc)를 넘겨 실제 GPU 텍스처를 만듭니다. 인자를 하나씩 보면:
&td— 방금 채운 설계도의 주소. D3D는 값 복사 대신 포인터로 받습니다(&는 “주소 연산자”).nullptr— 초기 픽셀 데이터 자리인데, 지금은 비워 둡니다(= NULL). 베이스 픽셀은 바로 다음 줄UpdateSubresource로 따로 올립니다. 밉 레벨 1 이하는 어차피GenerateMips가 채우니, 여기서 모든 레벨의 데이터를 넘기는 건 불필요하고 번거롭습니다.&tex— 만들어진 텍스처 포인터를 받아 올 출력 인자.ComPtr의 주소를 넘기면 소유권이tex에 담깁니다.
반환값(HRESULT)은 생략했지만, 실무에서는 if (FAILED(hr)) …로 반드시 확인해야
합니다(학습용이라 생략). desc가 조금이라도 모순되면 여기서 실패하니 첫 디버깅 포인트입니다.
1-12. context->UpdateSubresource(tex.Get(), 0, nullptr, pixels, w * 4, 0); — 베이스 픽셀 업로드
CPU 메모리의 픽셀 배열(pixels)을 GPU 텍스처의 0번 서브리소스(=레벨 0)에
복사합니다. 인자를 성분별로 해부합니다:
| 인자 | 값 | 의미 |
|---|---|---|
1 pDstResource | tex.Get() | 복사 대상 리소스. .Get()은 ComPtr에서 원시 포인터를 꺼내는 함수(소유권은 안 넘김). |
2 DstSubresource | 0 | 서브리소스 인덱스. 레벨 0(베이스). 앞서 말한 슬라이스*밉수+밉에서 여기선 0. |
3 pDstBox | nullptr | 부분 영역 지정 박스. nullptr이면 전체를 갱신. |
4 pSrcData | pixels | CPU 쪽 원본 픽셀 배열의 첫 주소. |
5 SrcRowPitch | w * 4 | 한 줄(가로 한 행)의 바이트 수. 텍셀 4바이트 × 가로 w개. |
6 SrcDepthPitch | 0 | 3D 텍스처의 한 슬라이스 바이트 수. 2D라 의미 없어 0. |
다섯 번째 w * 4가 왜 중요하냐면, GPU가 “다음 줄로 넘어갈 때 몇 바이트를 건너뛸지”를
이 값으로 판단하기 때문입니다. 포맷이 R8G8B8A8라 텍셀당 4바이트, 가로가 w개니
한 줄은 정확히 w * 4 바이트입니다. 만약 w * 3(RGB로 착각) 같은 잘못된 피치를 주면
이미지가 사선으로 밀려(shear) 찢어집니다. 이건 초보자가 자주 겪는 대표적 버그입니다.
stb_image로 이미지를 로드할 때 4채널(RGBA)로 강제 로드(stbi_load(..., 4))해야
피치 w * 4와 포맷 R8G8B8A8가 맞아떨어집니다. 3채널로 읽어 놓고 w * 4를
주면 데이터가 어긋납니다. “로드 채널 = 포맷 채널 = 피치 계산”을 항상 일치시키세요.
1-13. CreateShaderResourceView(tex.Get(), nullptr, &srv); — 읽기용 창구(SRV)
텍스처 자체는 “GPU 메모리 덩어리”일 뿐, 셰이더가 직접 만지지 못합니다. 셰이더가 읽으려면
SRV(Shader Resource View)라는 “창구”가 필요합니다. 두 번째 인자 nullptr은
“텍스처가 선언된 그대로, 모든 밉 레벨(0 ~ 최상위)을 다 보이게”라는 기본 뷰를 뜻합니다.
여기서 nullptr을 쓰는 게 중요한 이유가 다음 줄에 있습니다.
1-14. context->GenerateMips(srv.Get()); — 이 한 줄이 피라미드를 채운다
드디어 밉맵 자동 생성입니다. 이 호출은 레벨 0을 원본 삼아 1, 2, 3 …번 레벨을 GPU가 그려 넣게
합니다. GPU는 각 레벨을 그 위 레벨의 2×2 평균(박스 다운샘플)로 만듭니다(하드웨어/드라이버에
따라 더 좋은 필터를 쓰기도 합니다). 그래서 SRV가 모든 레벨을 볼 수 있어야 하고
(그래서 위에서 nullptr 기본 뷰), 텍스처에 RENDER_TARGET + GENERATE_MIPS
권한이 있어야 합니다(그래서 위 desc). 이 세 조건이 맞물려야 이 한 줄이 실제로 동작합니다.
UpdateSubresource로 올린 다음 GenerateMips를
불러야 합니다. 순서가 바뀌면 GPU가 빈 레벨 0을 평균 내 온 피라미드가 새까맣게 됩니다.
또, _SRGB 포맷이라 GPU가 선형 공간에서 평균을 내 주므로 색이 어두워지지 않습니다 —
이래서 데이터 텍스처가 아닌 컬러는 _SRGB가 맞다는 1-6의 이야기가 여기서 열매를 맺습니다.
1-15. 샘플러의 Filter — 세 글자(MIN·MAG·MIP)의 조합
이제 “만든 밉을 어떻게 읽을지”를 정하는 샘플러입니다. D3D11_SAMPLER_DESC sd = {};로
0 초기화 후 sd.Filter에 열거값을 넣는데, 이 열거값 이름을 세 부분으로 쪼개 읽으면
구조가 한눈에 보입니다:
| 부분 | 담당 | POINT | LINEAR |
|---|---|---|---|
MIN | 축소 시 한 레벨 안에서 텍셀 뽑기 | 가장 가까운 1개 | 주변 2×2 가중평균(바이리니어) |
MAG | 확대 시 텍셀 뽑기 | 계단 픽셀아트 | 부드럽게 번짐 |
MIP | 레벨 사이 섞기 | 가까운 1레벨만(전환선 보임) | 두 레벨을 섞음(부드러움) |
따라서 D3D11_FILTER_MIN_MAG_MIP_LINEAR는 셋 다 LINEAR —
한 레벨 안 2×2 바이리니어(4텍셀) + 위아래 두 레벨(각 4텍셀) 사이 선형 보간 = 도합 8텍셀을
섞어 읽는 트라이리니어(trilinear)입니다. 레벨 경계가 매끈해져 “전환선”이 사라집니다.
이게 표준 기본값입니다.
주석의 대안들도 같은 규칙으로 읽힙니다:
MIN_MAG_MIP_POINT— 셋 다 POINT. 제일 싸지만 레벨이 뚝뚝 바뀌는 전환선(mip banding)과 텍셀 계단이 다 보입니다.MIN_MAG_LINEAR_MIP_POINT— 레벨 안은 부드럽게(LINEAR) 읽되 레벨 사이는 안 섞음(POINT). 바이리니어 + 레벨 하드컷이라 예전 게임 특유의 “선명한 경계선” 룩이 납니다.- 주석 마지막 줄대로 MAG만 POINT로 바꾸면(
MIN_MIP_LINEAR_MAG_POINT계열), 축소는 부드럽되 확대는 또렷해 픽셀아트를 크게 키울 때 딱 좋습니다.
1-16. AddressU/V/W = WRAP; — uv가 [0,1]을 벗어나면?
Address*는 uv 좌표가 0~1 범위를 넘어갈 때의 처리 방식입니다. U는 가로,
V는 세로, W는 3D 텍스처의 깊이 축입니다. WRAP은
정수부를 버리고 소수부만 사용해 텍스처를 타일처럼 반복합니다
(uv=1.3 → 0.3, uv=2.7 → 0.7). 바닥·벽 타일링에 딱 맞습니다.
다른 값으로 CLAMP(가장자리 텍셀로 고정), MIRROR(거울 반사 반복),
BORDER(지정한 테두리색) 등이 있습니다.
1-17. sd.MaxLOD = D3D11_FLOAT32_MAX; — 가장 작은 레벨까지 허용
MaxLOD는 샘플링에 사용할 가장 높은(=가장 작은 이미지) 밉 레벨 번호의 상한입니다.
D3D11_FLOAT32_MAX(사실상 무한대)를 주면 1×1 레벨까지 전부 쓸 수 있습니다 —
즉 아주 멀리 있는 물체도 제대로 흐려집니다. 반대로 MinLOD(여기선 0 초기화)는 하한이라,
0이면 원본(가장 선명)까지 다 쓴다는 뜻입니다. 이 상·하한으로 일부러 항상 흐리게
(예: 노이즈 줄이려 MinLOD를 올림) 같은 트릭도 가능합니다.
MaxLOD를 0으로 두면(초기화 잊음 등) 항상 레벨 0만 샘플링해
밉을 애써 만들고도 지글거림이 그대로입니다. “밉을 켰는데 여전히 흔들린다”의 또 다른
단골 원인입니다. 반드시 큰 값(D3D11_FLOAT32_MAX)을 주세요.
1-18. CreateSamplerState & PSSetSamplers(0, 1, samp.GetAddressOf());
CreateSamplerState로 샘플러 객체를 만들고, PSSetSamplers로
픽셀 셰이더의 샘플러 슬롯 0번에 1개 바인딩합니다. 인자는 순서대로
(시작 슬롯 0, 개수 1, 샘플러 포인터 배열)입니다.
GetAddressOf()는 ComPtr 내부 포인터의 주소(즉 ID3D11SamplerState**)를
돌려줍니다 — PSSetSamplers가 “포인터 배열”을 받기 때문입니다. 헷갈리기 쉬운
.Get()(포인터 자체)과 .GetAddressOf()(포인터의 주소)의 차이를 여기서 확실히
구분하세요. 이 슬롯 번호 0은 곧 HLSL의 register(s0)와 짝이 됩니다.
2. HLSL 픽셀 셰이더에서 읽기 — 샘플링 한 줄의 내부
C++ 쪽에서 밉과 샘플러를 준비했으니, 실제로 “읽는” 쪽은 픽셀 셰이더입니다. 원본 예제에는 셰이더가
생략돼 있었지만, 위 PSSetSamplers(0, ...)와 SRV 바인딩이 HLSL의 어떤 선언과 짝이 되는지
한 줄씩 보여 드립니다(로직은 표준 텍스처 샘플링 그대로입니다).
// C++의 SRV(슬롯 0) ↔ t0, 샘플러(슬롯 0) ↔ s0
Texture2D gTex : register(t0);
SamplerState gSamp : register(s0);
struct PSIn {
float4 pos : SV_Position; // 래스터라이저가 채우는 화면 좌표
float2 uv : TEXCOORD0; // 정점에서 보간돼 온 uv
};
float4 PSMain(PSIn input) : SV_Target
{
// 밉 레벨은 하드웨어가 uv의 화면 미분(ddx/ddy)으로 자동 선택한다
float4 color = gTex.Sample(gSamp, input.uv);
return color;
}
2-1. Texture2D gTex : register(t0); — SRV와 t 레지스터의 짝
HLSL에서 텍스처는 Texture2D 타입으로 선언하고, : register(t0)로
텍스처 슬롯 0번(t0)에 묶습니다. C++에서 PSSetShaderResources(0, 1, srv…)로
바인딩한 SRV가 바로 이 t0로 들어옵니다. 접두사 t는 texture(SRV),
s는 sampler, b는 constant buffer, u는 UAV를 뜻하는 관례입니다.
2-2. SamplerState gSamp : register(s0); — 1-18의 PSSetSamplers(0,…)와 짝
바로 앞 C++에서 PSSetSamplers(0, 1, samp…)로 샘플러 슬롯 0에 꽂았으니,
셰이더에서는 register(s0)로 받습니다. 이 gSamp 안에 우리가 정한
트라이리니어·WRAP·MaxLOD 설정이 통째로 담겨 있습니다. 즉 “필터를 어떻게 할지”는 셰이더 코드가
아니라 이 샘플러 객체가 들고 오는 것입니다.
2-3. gTex.Sample(gSamp, input.uv) — 세 재료가 만나는 지점
이 한 줄이 페이지 전체의 클라이맥스입니다. 세 재료가 여기서 만납니다:
gTex— 무엇을 읽나(밉 피라미드가 든 텍스처).gSamp— 어떻게 읽나(트라이리니어, 레벨 섞기, 반복 규칙).input.uv— 어디를 읽나(이 픽셀이 가리키는 텍스처 좌표).
반환값은 float4 RGBA. 포맷이 _SRGB였으므로 이 색은 이미
선형 공간으로 디코딩된 값이라, 이대로 조명 계산에 넣어도 물리적으로 옳습니다.
2-4. 마법의 핵심: 밉 레벨은 ddx/ddy로 자동 선택된다
“어느 밉 레벨을 읽을까?”를 우리가 지정하지 않았는데도 알아서 골라 주는 이유가 여기 있습니다. GPU는
픽셀을 2×2 쿼드(quad) 단위로 처리하면서, 옆·아래 픽셀과 uv 값의 차이
— 즉 화면상 uv의 변화율(편미분) ddx(uv), ddy(uv) —
를 자동으로 계산합니다. 이 미분의 크기가 “화면 한 픽셀이 텍스처를 얼마나 넓게 덮나”를
말해 줍니다.
구체적으로, 텍셀 단위 변화율의 최대 길이를 ρ라 하면 LOD는
λ = log₂(ρ)로 계산됩니다. 화면 픽셀 하나가 텍스처를 4배 넓게 덮으면
(ρ = 4) log₂(4) = 2, 즉 레벨 2 근처를 읽습니다.
소수부(예: 2.3)는 MIP=LINEAR일 때 레벨 2와 3을 0.3 비율로 섞는 데 쓰입니다 —
이게 바로 트라이리니어의 “레벨 사이 보간”입니다.
ddx/ddy는 2×2 쿼드의 이웃 값이 필요하므로, 동적 분기(if) 안에서
Sample을 호출하면 미분이 정의되지 않아 경고가 나거나 잘못된 레벨이 나올 수 있습니다.
분기 안에서 샘플링해야 한다면 미분을 미리 밖에서 계산해
gTex.SampleGrad(gSamp, uv, ddx(uv), ddy(uv))로 넘기거나, SampleLevel로
레벨을 직접 지정하세요.
수치 예제로 정리하면: 1024×1024 텍스처가 화면 40×40에 그려질 때
한 픽셀이 덮는 텍셀 폭 ρ ≈ 1024/40 ≈ 25.6, LOD = log₂(25.6) ≈ 4.68.
트라이리니어는 레벨 4와 5를 0.68 비율로 섞어 읽으니, 655개 텍셀의 “미리 계산된 평균”이
자연스럽게 나와 지글거림이 사라집니다. 첫 절에서 든 “655명 평균” 비유가 정확히 이 계산입니다.
3. 이방성 필터링 (Anisotropic Filtering)
무엇을 만드나 — 비스듬한 바닥처럼 기울어진 표면에서 원거리 선명함을 살리는 이방성 샘플러를 만듭니다. 왜 필요한가 — 트라이리니어까지 왔는데도 왜 또 다른 필터가 필요한지, 그 이유가 “정사각 vs 직사각”에 있습니다.
바닥처럼 카메라에 대해 비스듬한 표면은 한 픽셀이 한 방향으로 길게 늘어난 텍셀 영역을 덮습니다. 트라이리니어는 밉 레벨을 등방(정사각) 축소로만 고르기 때문에, 이런 경사면에서는 가장 짧은 축에 맞춰 레벨을 고르면 긴 축이 앨리어싱되고, 가장 긴 축에 맞추면 짧은 축이 과도하게 흐려집니다. 어느 쪽이든 원거리 바닥이 뿌옇게 뭉개지죠. 이방성 필터링은 늘어난 방향(장축)으로 여러 번 샘플링해 직사각 발자국(footprint)을 제대로 평균 냅니다. 그래서 원거리 도로·타일이 또렷하게 살아납니다. Direct3D 11은 이방성을 코어 기능으로 지원하므로 샘플러 하나만 바꾸면 됩니다.
using Microsoft::WRL::ComPtr;
// D3D11은 이방성 필터링이 표준 기능. 샘플러의 Filter를 ANISOTROPIC로,
// MaxAnisotropy(1~16)를 지정하면 된다. 별도의 확장/버전 체크가 필요 없다.
ComPtr<ID3D11SamplerState> createAnisoSampler(ID3D11Device* device, UINT aniso) {
if (aniso < 1u) aniso = 1u;
if (aniso > 16u) aniso = 16u; // 하드웨어 최대치는 16
D3D11_SAMPLER_DESC sd = {};
sd.Filter = D3D11_FILTER_ANISOTROPIC; // ← 이방성 필터링
sd.MaxAnisotropy = aniso; // 예: 8x
sd.AddressU = sd.AddressV = sd.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
sd.MaxLOD = D3D11_FLOAT32_MAX; // 전체 밉 체인 사용
ComPtr<ID3D11SamplerState> samp;
device->CreateSamplerState(&sd, &samp);
return samp;
}
// 사용: 8x 이방성 샘플러를 슬롯 0에 바인딩
ComPtr<ID3D11SamplerState> aniso = createAnisoSampler(device, 8);
context->PSSetSamplers(0, 1, aniso.GetAddressOf());
3-1. 시그니처 ComPtr<ID3D11SamplerState> createAnisoSampler(ID3D11Device* device, UINT aniso)
반환 타입이 ComPtr<ID3D11SamplerState>인 이유: 함수 안에서 만든 샘플러의
소유권을 호출한 쪽에 넘겨야 하기 때문입니다. 원시 포인터를 반환하면 “누가 Release할지”가
애매해지지만, ComPtr을 반환하면 참조 카운트가 자동으로 관리돼 누수·이중 해제가
없습니다. 인자 ID3D11Device* device는 리소스 생성 주체인 디바이스를 포인터로 받고
(D3D 객체는 항상 포인터로 전달), UINT aniso는 이방성 배수(부호 없는 정수)입니다.
UINT는 음수가 없는 정수라 “배수”처럼 자연수 의미의 값에 적합합니다.
3-2. if (aniso < 1u) aniso = 1u; if (aniso > 16u) aniso = 16u; — 값 clamp
MaxAnisotropy의 유효 범위는 1~16입니다(D3D11 하드웨어 최대치가 16).
호출자가 실수로 0이나 100을 넘겨도 안전하도록 범위 안으로 가둡니다. 접미사
1u/16u의 u는 “이 리터럴은 unsigned”라는 표시로,
UINT aniso와 타입을 맞춰 signed/unsigned 비교 경고를 피합니다. 작은 디테일이지만
컴파일러 경고를 깨끗이 유지하는 습관입니다. (aniso=1이면 사실상 이방성 꺼진 트라이리니어와 동급.)
3-3. sd.Filter = D3D11_FILTER_ANISOTROPIC; — 필터 종류 자체가 바뀜
1절의 트라이리니어(MIN_MAG_MIP_LINEAR)와 달리, 이건 MIN·MAG·MIP를 쪼개는 열거값이 아니라
독립적인 한 종류입니다. 이 값을 주는 순간 GPU는 밉 레벨을 하나 고르는 대신,
화면 픽셀의 직사각 발자국을 장축 방향으로 여러 번(최대 MaxAnisotropy번) 샘플링해
평균 냅니다. 즉 “같은 물체를 몇 번 더 읽어 정확한 평균을 얻느냐”의 상한이 다음 줄입니다.
3-4. sd.MaxAnisotropy = aniso; — 장축 샘플 수의 상한
이 값이 8이면, 가장 기울어진 곳에서 장축으로 최대 8배 더 촘촘히 샘플링합니다.
표면이 거의 정면(등방)일 때는 GPU가 알아서 적게 샘플링하니, 필요한 곳에만 비용을 씁니다.
나머지 필드(AddressU/V/W = WRAP, MaxLOD = 무한대)는 1절과 의미가 같습니다 —
이방성도 밉 체인 위에서 동작하므로 MaxLOD가 밉 전체를 열어 둬야 합니다.
3-5. 사용부 — 1절 샘플러를 이 8x 샘플러로 교체
createAnisoSampler(device, 8)로 8x 샘플러를 만들고, PSSetSamplers(0, 1, …)로
같은 슬롯 0번에 바인딩합니다. 즉 HLSL 셰이더(gTex.Sample(gSamp, uv))는
한 글자도 안 바꿔도 됩니다 — s0에 꽂힌 샘플러 객체만 트라이리니어에서 이방성으로
바뀌었을 뿐이니까요. 이것이 “필터 정책은 셰이더가 아니라 샘플러가 들고 온다”(2-2)는 원칙의 실전 증거입니다.
GenerateMips + 밉을 사용하는 샘플러(예: D3D11_FILTER_ANISOTROPIC)와 함께 쓰세요.
4. 세 필터 비교 — 한눈에
| 필터 | 레벨 안 샘플 | 레벨 간 | 경사면 | 비용 | 추천 용도 |
|---|---|---|---|---|---|
| 포인트 | 1텍셀 | 하드컷 | 나쁨(지글) | 최저 | 픽셀아트 확대(MAG만) |
| 바이리니어 + MIP POINT | 4텍셀 | 하드컷(전환선) | 보통 | 낮음 | 구형/저사양 |
| 트라이리니어 | 4텍셀 | 선형 보간(8텍셀) | 과도한 흐림 | 보통 | 표준 기본값 |
| 이방성 8x/16x | 장축 다중 | 선형 보간 | 선명 | 높음 | 바닥·도로·원경 지형 |
_SRGB 포맷, 렌더타깃도 _SRGB 또는 셰이더 말미 감마 인코딩).
행벡터 규약(v' = v*M, HLSL mul(v, M))·왼손 좌표계·클립 z [0,1]·V축 위→아래는
시리즈 전체와 동일하게 유지됩니다. 실무에서는 밉/필터링을 직접 만들지 말고
GenerateMips와 하드웨어 샘플러를 그대로 쓰는 것이 정석입니다.