텍스처 매핑 — 한 줄 한 줄 직접 구현
지금까지 삼각형은 정점 색으로만 칠했습니다. 하지만 나무 상자, 벽돌, 캐릭터 피부처럼
현실감 있는 표면은 색을 정점마다 지정하는 것이 아니라 사진(이미지)을 표면에 붙여서 만듭니다.
이것이 텍스처 매핑입니다. 이 페이지에서는 디스크의 PNG 파일 한 장이
stb_image로 메모리에 올라오고 → ID3D11Texture2D라는 GPU 리소스가 되고 →
셰이더 리소스 뷰(SRV)와 샘플러 상태를 거쳐 → 픽셀 셰이더가 정점의
UV 좌표로 픽셀 하나하나를 뽑아 쓰는 전 과정을, C++ 코드 한 줄과 D3D API 인자 하나까지
입문자 눈높이로 해부합니다.
v' = v*M, HLSL mul(v, M))로
통일합니다. 텍스처 좌표는 V축이 위→아래(좌상단이 (u,v)=(0,0))인 D3D 관례를 따릅니다.
OpenGL은 V가 아래→위라 관례가 반대이니, 다른 자료의 UV를 그대로 옮기면 이미지가 뒤집힐 수 있습니다.
float2 uv 필드가 있어야 하고, 입력 레이아웃에도 TEXCOORD 요소가 등록돼 있어야 합니다(§ 마지막에 정리).
이미지 디코딩은 헤더 온리 라이브러리 stb_image 한 파일로 처리합니다.
먼저: 텍스처 매핑이란 무엇인가 — UV 좌표의 직관
본격적인 코드에 들어가기 전에, 이 페이지 전체를 관통하는 UV 좌표 개념부터 확실히 잡고 갑니다.
UV가 무엇인지 모르면 아래 코드의 input.uv가 왜 픽셀을 결정하는지 이해할 수 없습니다.
이미지는 [0,1] × [0,1] 정사각형에 펼쳐진 지도입니다
실제 이미지 파일은 512×512, 1920×1080처럼 픽셀 단위 크기가 제각각입니다.
하지만 셰이더는 "이미지의 몇 번째 픽셀"이 아니라 비율(0~1)로 이미지를 가리킵니다.
가로 방향을 U, 세로 방향을 V라 부르고, 각각 0에서 1까지의 실수입니다:
(u,v) = (0,0)— 이미지의 좌상단 구석(D3D 관례)(u,v) = (1,0)— 우상단(u,v) = (0,1)— 좌하단(u,v) = (1,1)— 우하단(u,v) = (0.5, 0.5)— 정확히 이미지 한가운데
이렇게 0~1 비율로 표현하면, 이미지가 256×256이든 4096×4096이든
같은 UV가 "같은 위치"를 가리킵니다. 해상도에 독립적인 좌표계인 셈입니다. 실제 픽셀 위치로 바꾸는 것은
GPU 샘플러의 몫입니다. 예를 들어 512×512 이미지에서 u=0.5는 픽셀 x좌표 0.5 × 512 = 256번째
근처를, v=0.25는 y좌표 0.25 × 512 = 128번째 근처를 가리킵니다.
정점마다 UV가 붙고, 삼각형 내부는 보간됩니다
메시의 각 정점에는 위치 (x,y,z)뿐 아니라 "이 정점은 이미지의 어디에 대응하는가"를 나타내는
(u,v)가 함께 저장됩니다. 사각형(삼각형 2개)에 이미지를 통째로 입히려면 네 모서리에
(0,0)·(1,0)·(0,1)·(1,1)을 주면 됩니다. 그러면 삼각형 내부의 UV는 래스터라이저가
세 정점 값을 무게중심 보간(barycentric interpolation, 원근 보정 포함)으로 자동으로 채워
픽셀 셰이더에 넘겨줍니다. 그래서 픽셀 셰이더는 "지금 이 픽셀의 UV"만 받아 이미지에서 색을 뽑으면 됩니다.
비유하자면 UV는 선물 상자에 포장지를 붙일 때 "포장지의 이 점을 상자의 이 모서리에 맞춰라"고 지정하는 핀과 같습니다. 핀(정점 UV) 몇 개만 꽂으면, 그 사이 포장지(이미지)는 자연스럽게 늘어나 붙습니다.
stbi_load로 이미지 픽셀을 CPU 메모리에 읽는다(RGBA 4채널로 강제 요청).D3D11_TEXTURE2D_DESC로 텍스처를 서술하고CreateTexture2D로 GPU 리소스를 만든다.UpdateSubresource로 픽셀을 0번 밉에 올리고,CreateShaderResourceView로 SRV를 만든 뒤GenerateMips로 밉맵 생성.ID3D11SamplerState(래핑·필터링 규칙)를 한 번 만들어 재사용한다.- 슬롯에 SRV/샘플러를 바인딩하고(
PSSetShaderResources/PSSetSamplers) 그린다.
1단계 — 이미지 로드에서 텍스처 생성까지
무엇을 만드나: 파일 경로 하나를 받아, GPU가 셰이더에서 읽을 수 있는
셰이더 리소스 뷰(SRV)를 돌려주는 함수입니다.
왜 필요한가: 픽셀 셰이더는 원본 PNG 파일을 직접 열 수 없습니다. 이미지를 GPU 메모리의
특정 포맷(R8G8B8A8) 텍스처로 올리고, 그 텍스처를 "셰이더에서 읽는 관점"으로 감싼 SRV가 있어야만
Texture2D.Sample()이 동작합니다. 이 함수가 그 준비를 통째로 처리합니다.
// .cpp 하나에서만 구현부 포함
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include <d3d11.h>
#include <wrl/client.h>
using Microsoft::WRL::ComPtr;
// SRV를 반환. 실패 시 nullptr.
ComPtr<ID3D11ShaderResourceView> loadTexture(
ID3D11Device* device, ID3D11DeviceContext* ctx, const char* path) {
// D3D 텍스처 V 원점은 좌상단이라 뒤집을 필요 없음.
// (UV를 뒤집어야 한다면 OBJ 로더에서 v = 1 - v 로 처리 — §3 참고)
int w, h, channels;
// 4채널(RGBA)로 강제 → 포맷 분기 없이 단순화
unsigned char* data = stbi_load(path, &w, &h, &channels, 4);
if (!data) return nullptr; // 로드 실패
// 텍스처 서술: 밉맵 자동 생성을 위해 MipLevels=0 + RENDER_TARGET|SHADER_RESOURCE
D3D11_TEXTURE2D_DESC td{};
td.Width = w;
td.Height = h;
td.MipLevels = 0; // 0 = 전체 밉 체인
td.ArraySize = 1;
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
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;
ComPtr<ID3D11Texture2D> tex;
device->CreateTexture2D(&td, nullptr, &tex); // 초기 데이터 없이 생성
// 0번 밉에 픽셀 업로드 (한 행의 바이트 수 = w * 4)
ctx->UpdateSubresource(tex.Get(), 0, nullptr, data, w * 4, 0);
stbi_image_free(data); // CPU 메모리는 이제 필요 없음
// 셰이더 리소스 뷰(SRV)
ComPtr<ID3D11ShaderResourceView> srv;
device->CreateShaderResourceView(tex.Get(), nullptr, &srv);
ctx->GenerateMips(srv.Get()); // 밉맵 자동 생성
return srv;
}
헤더와 STB_IMAGE_IMPLEMENTATION의 정체
#define STB_IMAGE_IMPLEMENTATION은 이 페이지에서 가장 헷갈리기 쉬운 한 줄입니다.
stb_image.h는 헤더 온리(header-only) 라이브러리입니다. 즉 함수 선언뿐 아니라
구현(함수 본문) 전체도 이 하나의 .h 파일 안에 들어 있습니다. 그런데 헤더는 원래
여러 .cpp에 #include되기 마련이고, 만약 구현 본문이 매번 딸려 들어가면
같은 함수가 여러 번 정의돼 링커 중복 정의 오류(LNK2005)가 납니다.
그래서 stb는 딱 하나의 .cpp에서만 #include 바로 위에
#define STB_IMAGE_IMPLEMENTATION을 쓰라고 요구합니다. 이 매크로가 정의된 번역 단위에서만
함수 본문이 컴파일되고, 나머지 파일은 선언(프로토타입)만 보게 됩니다. 이 프로젝트에서는
texture.cpp가 그 "하나의 파일" 역할을 맡습니다. 다른 곳에서 stbi_load를 쓰고 싶으면
그 파일에서는 #define 없이 #include "stb_image.h"만 하면 됩니다.
.cpp에서 STB_IMAGE_IMPLEMENTATION을 정의하면 링커가
"이미 정의된 심볼(stbi_load 등)"이라며 링크에 실패합니다. 반대로 어느 파일에서도 정의하지 않으면
"정의되지 않은 외부 심볼"로 실패합니다. 정확히 한 번이 규칙입니다.
<wrl/client.h>의 ComPtr은 Direct3D의 COM 객체를 위한
스마트 포인터입니다. D3D의 ID3D11... 인터페이스들은 참조 카운트로 수명을 관리하는데,
ComPtr이 이 AddRef/Release 호출을 자동으로 처리해 줍니다.
ComPtr이 범위를 벗어나 소멸하면 알아서 Release가 불려 메모리 누수가 없습니다.
using Microsoft::WRL::ComPtr;은 매번 긴 네임스페이스를 쓰지 않으려고 짧은 이름을 여는 선언입니다.
시그니처 해부 — 왜 이런 인자들인가
ComPtr<ID3D11ShaderResourceView> loadTexture(ID3D11Device* device, ID3D11DeviceContext* ctx, const char* path)를
조각내 봅니다.
-
반환
ComPtr<ID3D11ShaderResourceView>— 셰이더가 실제로 바인딩하는 것은 텍스처 자체가 아니라 SRV입니다. 그래서 최종 산출물인 SRV를 돌려줍니다.ComPtr로 반환하므로 호출한 쪽이 이 SRV를 변수에 담는 순간 참조 카운트가 올라가고, 함수 안의 지역srv가 사라져도 객체는 살아남습니다. -
ID3D11Device* device— 리소스를 "생성"하는 객체입니다. D3D11에서 텍스처·버퍼·뷰·상태 객체를 만드는 모든Create...함수는 디바이스에 있습니다. 디바이스는 스레드 세이프한 "공장"이라고 보면 됩니다. -
ID3D11DeviceContext* ctx— 실제 GPU 명령을 "실행"하는 객체입니다. 픽셀 업로드(UpdateSubresource)와 밉 생성(GenerateMips)은 디바이스가 아니라 컨텍스트가 처리합니다. 생성은 device, 조작·그리기는 context — 이 이분법이 D3D11의 핵심 구조입니다. -
const char* path— 읽을 파일 경로 문자열입니다.const는 "이 함수가 문자열 내용을 바꾸지 않는다"는 약속이고, 포인터로 받는 이유는 문자열 리터럴("assets/wood.png")을 복사 없이 그대로 넘길 수 있기 때문입니다.
stbi_load 한 줄 완전 해부
unsigned char* data = stbi_load(path, &w, &h, &channels, 4);
— 이미지 파일을 디코딩(PNG·JPG 압축을 풀어)해 생 픽셀 배열을 CPU 메모리에 만들고
그 첫 바이트 주소를 반환합니다. 인자를 하나씩 봅니다.
path— 열 파일 경로. 상대 경로면 실행 파일의 작업 디렉터리 기준이라 주의해야 합니다(아래 함정 참고).-
&w,&h—int w, h;의 주소를 넘깁니다. stb가 디코딩하며 알게 된 이미지의 실제 가로/세로 픽셀 수를 이 변수에 써 넣어 돌려주기 위해 주소를 전달합니다. C에는 여러 값을 동시에 반환하는 문법이 없어, 이렇게 "출력 인자(out-parameter)"로 주소를 넘겨 받는 관용구를 씁니다. 호출 후w,h에는 예컨대 512, 512가 채워집니다. -
&channels— 원본 파일의 채널 수(1=회색, 3=RGB, 4=RGBA)를 알려줍니다. "파일이 원래 몇 채널이었나"는 이 변수로 알 수 있지만, 다음 인자 때문에 실제로 받는 데이터는 항상 4채널입니다. -
마지막 인자
4(desired_channels) — "무조건 4채널(RGBA)로 변환해서 달라"는 요청입니다. 원본이 RGB(3채널)든 회색(1채널)든 stb가 알파를 255로 채우거나 회색을 복제해 4채널로 맞춰 줍니다. 덕분에 우리 코드는DXGI_FORMAT_R8G8B8A8_UNORM하나만 다루면 되고, 포맷 분기(if 3채널이면… if 1채널이면…)가 사라집니다. GPU 텍스처는 4바이트 정렬을 좋아하므로 성능상으로도 유리합니다.
반환 타입 unsigned char*는 "부호 없는 8비트 정수 배열의 첫 주소"입니다. 픽셀은
[R,G,B,A, R,G,B,A, ...] 순서로 행 우선(row-major), 즉 이미지의 맨 윗줄 왼쪽 픽셀부터
오른쪽으로 쭉, 그다음 아랫줄로 이어지는 순서로 빽빽이 늘어서 있습니다. 각 채널은 0~255 정수입니다.
512×512 이미지라면 총 바이트 수는 512 × 512 × 4 = 1,048,576바이트(정확히 1MB)입니다.
if (!data) return nullptr; — stb는 실패하면 널 포인터를 돌려줍니다(파일 없음, 손상된 이미지 등).
!data는 "data가 널이면 참"이므로, 실패 시 곧바로 nullptr을 반환해 이후 코드가 널 포인터를
건드리다 크래시하는 것을 막습니다. 방어적으로 반드시 넣어야 하는 한 줄입니다.
"assets/wood.png"는 현재 작업 디렉터리(CWD) 기준으로 찾습니다.
Visual Studio에서 F5로 실행하면 CWD가 프로젝트 폴더지만, 빌드된 exe를 직접 더블클릭하면 CWD가
exe가 있는 폴더로 바뀌어 파일을 못 찾고 data == nullptr → 검은 텍스처가 될 수 있습니다.
애셋을 출력 폴더로 복사하거나 절대 경로를 쓰세요.
D3D11_TEXTURE2D_DESC — 텍스처 설계도 필드 하나하나
D3D11_TEXTURE2D_DESC td{};에서 뒤의 {}는 C++의 값 초기화(value-initialization)입니다.
구조체의 모든 멤버를 0으로 채운 뒤 시작한다는 뜻이라, 우리가 명시하지 않은 필드는 자동으로 0(또는 그에 해당하는 기본값)이 됩니다.
이걸 빼먹으면 스택의 쓰레기 값이 그대로 남아 CreateTexture2D가 알 수 없는 이유로 실패합니다. D3D DESC 구조체는 항상 {}로 시작하는 습관을 들이세요.
이제 채워 넣는 필드들을 봅니다. 이 구조체는 "GPU에게 어떤 텍스처를 만들지 서술하는 설계도"입니다.
Width = w,Height = h— 텍스처의 가로·세로 픽셀 수. stb가 알려준 실제 이미지 크기를 그대로 넣습니다.-
MipLevels = 0— 0은 "전체 밉 체인을 만들 공간을 확보하라"는 특수 값입니다. 밉맵이란 원본을 절반씩 줄인 축소본들의 사슬입니다(512→256→128→…→1).0을 주면 D3D가log2(max(w,h))+1개의 레벨(512×512면 10개)을 자동으로 계산해 자리를 잡아 줍니다. 나중에GenerateMips가 이 빈 레벨들을 채웁니다.1로 주면 밉 없이 원본 한 장만 생깁니다. ArraySize = 1— 텍스처 배열이 아니라 단일 텍스처라는 뜻. 큐브맵이나 배열 텍스처가 아니면 항상 1입니다.-
Format = DXGI_FORMAT_R8G8B8A8_UNORM— 픽셀 하나가 R,G,B,A 각 8비트(총 32비트), UNORM은 "0~255 정수를 셰이더에서 0.0~1.0 실수로 정규화(unsigned normalized)해서 읽는다"는 뜻입니다. 그래서 셰이더에서 흰색은(1,1,1,1), 절반 회색은(0.5,0.5,0.5,1)로 잡힙니다. stb에게 4채널을 요청한 것과 정확히 짝이 맞습니다. -
SampleDesc.Count = 1— MSAA(멀티샘플 안티에일리어싱) 샘플 수. 일반 텍스처는 1입니다. 2 이상은 렌더 타겟용 멀티샘플 텍스처에나 씁니다.{}덕분에SampleDesc.Quality는 0으로 이미 채워졌습니다. -
Usage = D3D11_USAGE_DEFAULT— GPU가 읽고 쓰는 일반 용도. CPU는 직접 접근하지 않고UpdateSubresource같은 명령으로만 갱신합니다. (CPU가 매 프레임 바꾸는 데이터라면DYNAMIC을 쓰지만 정적 텍스처는DEFAULT가 정석입니다.) -
BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET— 이 텍스처를 어떤 용도로 파이프라인에 바인딩할 수 있는지를 비트 OR로 나열합니다.SHADER_RESOURCE는 "셰이더에서 읽을 수 있다"(우리 목적).RENDER_TARGET은 "그려 넣을 수 있다"인데, 여기서는 순전히GenerateMips가 렌더 타겟 권한을 요구하기 때문에 함께 켭니다(밉을 그리듯 채워 넣음). -
MiscFlags = D3D11_RESOURCE_MISC_GENERATE_MIPS— "이 텍스처는 밉을 자동 생성할 것"이라는 표시. 이 플래그가 없으면GenerateMips가 아무 일도 하지 않습니다. 위의MipLevels=0·RENDER_TARGET과 3종 세트로 함께 있어야 밉맵이 제대로 만들어집니다.
MipLevels = 0, BindFlags에 RENDER_TARGET 포함,
MiscFlags = ...GENERATE_MIPS. 이 중 하나라도 빠지면 GenerateMips가 조용히 실패하거나
(RENDER_TARGET 누락 시) 아예 텍스처 생성이 실패합니다. 밉이 안 생기면 이 3개부터 확인하세요.
CreateTexture2D — 설계도로 실제 GPU 리소스 찍어내기
device->CreateTexture2D(&td, nullptr, &tex); — 설계도(td)대로 GPU에 텍스처 메모리를 할당합니다.
인자를 봅니다.
&td— 방금 채운 설계도 구조체의 주소. D3D API는 큰 구조체를 값이 아니라 주소(포인터)로 받습니다.-
두 번째 인자
nullptr(초기 데이터) — 원래 여기에 초기 픽셀을 담은D3D11_SUBRESOURCE_DATA배열을 넘길 수 있습니다. 하지만 밉을 자동 생성하려면 생성 시점에 데이터를 넣는 대신, 텍스처를 빈 상태로 만든 뒤UpdateSubresource로 0번 밉만 채우고GenerateMips로 나머지를 계산하는 흐름이 표준입니다. 그래서 여기서는nullptr로 비워 둡니다. -
&tex— 만들어진ID3D11Texture2D포인터를 받을ComPtr의 주소.ComPtr은&를 오버로딩해 내부 포인터의 주소를 넘겨주므로, 성공하면tex가 새 텍스처를 가리키게 됩니다(참조 카운트 1).
실무에서는 이 함수가 HRESULT(성공/실패 코드)를 반환하므로 if (FAILED(hr)) ...로 검사해야 합니다.
학습용 예제라 생략했지만, 실제 코드에서는 반드시 확인하세요.
UpdateSubresource — 0번 밉에 픽셀 밀어 넣기
ctx->UpdateSubresource(tex.Get(), 0, nullptr, data, w * 4, 0); — CPU 메모리의 픽셀 배열
(data)을 GPU 텍스처의 0번 밉(원본 해상도)으로 복사합니다. 인자가 6개나 되니 하나씩 봅니다.
tex.Get()— 대상 리소스의 원시 포인터.ComPtr::Get()은 내부 포인터를 참조 카운트 변화 없이 꺼내 줍니다(API에 잠깐 빌려주는 용도).-
두 번째
0(DstSubresource) — 서브리소스 인덱스. 밉 레벨과 배열 슬라이스를 하나의 번호로 표현하는데, 0번은 "배열 0번의 밉 0(원본 해상도)"입니다. 밉맵은GenerateMips가 채우므로 우리는 원본만 올리면 됩니다. - 세 번째
nullptr(pDstBox) — "텍스처의 어느 사각 영역에 쓸지" 지정하는 상자.nullptr은 전체 영역을 뜻합니다. data— 복사할 원본 픽셀의 첫 주소(stb가 준 배열).-
w * 4(SrcRowPitch) — 한 행(row)의 바이트 수입니다. 가로w픽셀 × 픽셀당 4바이트(RGBA)이므로w × 4. GPU는 이 값으로 "다음 줄이 어디서 시작하는지"를 계산합니다. 예:512폭이면 한 행은512 × 4 = 2048바이트. 이 값이 틀리면 이미지가 대각선으로 찢어져 보이는 전형적인 버그가 납니다. - 마지막
0(SrcDepthPitch) — 3D 텍스처에서 한 슬라이스(깊이 한 층)의 바이트 수. 2D 텍스처에는 의미가 없어 0입니다.
stbi_image_free(data); — 픽셀이 이미 GPU로 복사됐으니 CPU 쪽 원본은 더 이상 필요 없습니다.
stbi_load가 할당한 메모리는 free가 아니라 반드시 stb의 stbi_image_free로 해제해야 합니다
(같은 할당자를 쓰기 위함). 이 줄을 빼먹으면 텍스처를 로드할 때마다 CPU 메모리가 새어 나갑니다(메모리 릭).
UpdateSubresource는 내부적으로 CPU→GPU 복사를 일으키므로 초기화 시 한 번 쓰는 게 정석입니다.
매 프레임 텍스처 내용을 바꾸는 상황이라면 D3D11_USAGE_DYNAMIC + Map/Unmap이
훨씬 빠릅니다. 정적 텍스처(나무·벽돌 등)는 지금 방식이 맞습니다.
CreateShaderResourceView와 GenerateMips
device->CreateShaderResourceView(tex.Get(), nullptr, &srv); — 텍스처를 "셰이더가 읽는 관점"으로
감싸는 SRV를 만듭니다. 왜 텍스처를 직접 바인딩하지 않고 뷰를 거치냐면, 같은 메모리를 여러 방식으로 해석할 수 있기 때문입니다
(예: 같은 텍스처를 렌더 타겟 뷰로도, 셰이더 리소스 뷰로도). 뷰는 "이 리소스를 이런 포맷·이런 범위로 읽겠다"는 렌즈입니다.
tex.Get()— 뷰가 가리킬 원본 텍스처.-
두 번째
nullptr(pDesc) — SRV 서술 구조체.nullptr이면 원본 텍스처의 포맷과 전체 밉 범위를 그대로 물려받은 "기본 뷰"를 만듭니다. 우리는 텍스처를 있는 그대로 전부 읽을 것이므로 기본값으로 충분합니다. &srv— 만들어진 SRV를 받을ComPtr의 주소.
ctx->GenerateMips(srv.Get()); — 0번 밉(원본)만 채워진 텍스처를 받아, GPU가 하드웨어 가속으로
1번, 2번, … 마지막 1×1 밉까지 순차로 절반씩 축소해 채웁니다. 축소 방식은 대개 인접 4픽셀 평균과
유사한 필터입니다. 이 함수는 SRV를 인자로 받는데(텍스처가 아니라), SRV가 밉 범위 정보를 담고 있고
내부적으로 렌더 타겟으로 각 밉을 그려 채우기 때문입니다. 그래서 앞서 RENDER_TARGET 바인드 플래그가 필요했습니다.
밉맵이 왜 필요한가 — 기하 직관
화면에서 멀리 있는 물체는 큰 텍스처가 작은 화면 영역에 눌려 들어갑니다. 예를 들어
512×512 텍스처가 화면에서 겨우 8×8 픽셀로 보인다면, 픽셀 하나가 원본의
64×64 = 4096개 텍셀(texel, 텍스처 픽셀)을 대표해야 합니다. 밉맵 없이 원본만 샘플링하면 그 4096개 중
단 하나만 뽑히므로, 카메라가 조금만 움직여도 뽑히는 텍셀이 팍팍 바뀌어
반짝임(에일리어싱)이 생깁니다.
밉맵은 미리 여러 축소본을 만들어 두고, "이 픽셀이 대략 8×8 크기로 보인다"면 그에 가까운
이미 평균 처리된 작은 밉을 골라 뽑습니다. 그러면 반짝임이 사라지고, 작은 텍스처는 캐시에도
잘 들어가 성능까지 좋아집니다. "가까우면 큰 밉, 멀면 작은 밉"을 자동으로 고르는 것이 다음 절의 샘플러 필터입니다.
2단계 — 샘플러 상태 만들기
무엇을 만드나: "이미지에서 색을 어떻게 뽑을지"의 규칙 묶음인 샘플러 상태입니다. 왜 필요한가: UV가 정확히 텍셀 중앙에 떨어지는 일은 거의 없습니다. UV가 [0,1] 밖으로 나가면 어떻게 할지(래핑), 두 텍셀 사이에 걸치면 어떻게 섞을지(필터링), 어느 밉을 고를지 — 이 모든 결정을 샘플러가 담당합니다. 텍스처가 "무엇을"이라면, 샘플러는 "어떻게"입니다.
// 샘플러 상태 (한 번만 생성해 재사용)
ComPtr<ID3D11SamplerState> makeSampler(ID3D11Device* device) {
D3D11_SAMPLER_DESC sd{};
// 필터: 축소 시 밉맵 삼선형, 확대 시 이중선형 → ANISOTROPIC/MIN_MAG_MIP_LINEAR
sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR;
// 래핑: UV가 [0,1] 밖일 때 반복
sd.AddressU = D3D11_TEXTURE_ADDRESS_WRAP;
sd.AddressV = D3D11_TEXTURE_ADDRESS_WRAP;
sd.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
sd.MaxLOD = D3D11_FLOAT32_MAX;
ComPtr<ID3D11SamplerState> samp;
device->CreateSamplerState(&sd, &samp);
return samp;
}
Filter — 텍셀 사이를 어떻게 섞는가
sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; — 이름을 세 조각으로 읽으면 뜻이 그대로 드러납니다.
MIN(축소, minification: 텍스처가 화면보다 클 때), MAG(확대, magnification: 화면보다 작을 때),
MIP(밉 레벨 사이) — 이 세 상황 모두에서 LINEAR(선형 보간)을 쓰라는 뜻입니다.
"삼선형 필터링(trilinear)"이 바로 이것입니다.
-
MIN/MAG LINEAR(이중선형, bilinear) — UV가 텍셀 4개 사이에 걸치면, 그 2×2 텍셀을
가까운 정도에 비례해 섞습니다. 예를 들어 UV가 왼쪽 텍셀에서 오른쪽으로 30% 지점, 위 텍셀에서 아래로 70% 지점이면
네 텍셀을
(1−0.3)(1−0.7), 0.3(1−0.7), (1−0.3)(0.7), 0.3·0.7=0.21, 0.09, 0.49, 0.21의 가중치로 평균합니다(합이 1). 그래서 확대해도 계단 없이 부드럽습니다. 반대로POINT였다면 가장 가까운 텍셀 하나만 뽑아 픽셀 아트처럼 딱딱해집니다. -
MIP LINEAR(삼선형의 세 번째 선형) — 물체가 "밉 3.4개만큼 멀다"처럼 두 밉 레벨 사이에 걸치면,
3번 밉과 4번 밉에서 각각 이중선형으로 뽑은 뒤
0.6 : 0.4로 다시 섞습니다. 이 덕분에 카메라가 다가올 때 밉이 바뀌는 경계선이 툭 튀지 않고 매끄럽게 전환됩니다.
더 나은 품질을 원하면 D3D11_FILTER_ANISOTROPIC을 쓰고 sd.MaxAnisotropy = 16;을 설정합니다.
이방성 필터링은 표면을 비스듬히 볼 때(도로·바닥) 삼선형이 뭉개는 원거리 텍스처를 훨씬 선명하게 유지합니다.
AddressU/V/W — UV가 [0,1] 밖으로 나가면?
WRAP은 UV의 정수 부분을 버리고 소수 부분만 씁니다. 즉 u = 2.3은
0.3으로, u = -0.2는 0.8로 접힙니다. 결과적으로 이미지가 격자처럼
반복 타일링됩니다. 벽돌·타일·잔디처럼 이어 붙여도 자연스러운 텍스처에 딱 맞습니다.
U는 가로, V는 세로, W는 3D 텍스처의 깊이 축이라 2D에서는 사실상 무시되지만
관례상 함께 설정합니다.
다른 주요 모드도 알아 두면 유용합니다:
| 모드 | 동작 | 쓰임 |
|---|---|---|
WRAP | 소수부만 사용 → 반복 | 타일링 가능한 텍스처(벽돌·잔디) |
CLAMP | [0,1] 밖은 가장자리 텍셀로 고정 | UI·스프라이트(가장자리 번짐 방지) |
MIRROR | 한 번 걸러 좌우 반전 반복 | 이음매를 숨기고 싶은 반복 |
BORDER | 밖은 지정한 테두리 색 | 그림자맵·데칼 경계 |
sd.MaxLOD = D3D11_FLOAT32_MAX;는 사용할 밉 레벨의 상한을 무제한으로 둔다는 뜻입니다.
가장 작은 밉까지 전부 쓸 수 있게 해 원거리에서도 밉이 제대로 적용됩니다. {} 초기화 덕분에
MinLOD는 0, MipLODBias는 0으로 이미 설정돼 있습니다.
makeSampler를 호출해 멤버 변수에 보관하고 계속 재사용하세요.
D3D11 디바이스는 내부적으로 동일한 샘플러를 캐싱하지만, 매번 생성/해제하는 것 자체가 낭비입니다.
3단계 — 셰이더에서 UV로 샘플링
무엇을 만드나: 정점의 UV를 픽셀 셰이더로 흘려보내고, 그 UV로 텍스처를 뽑아 출력 색으로 쓰는 셰이더입니다.
왜 필요한가: 앞의 C++ 코드는 텍스처를 GPU에 "준비"만 했을 뿐입니다. 실제로 그 텍스처를 픽셀 색으로
바꾸는 것은 셰이더의 Sample 호출입니다.
// ── 정점 셰이더 (발췌) ──
struct VSIn { float3 pos : POSITION; float2 uv : TEXCOORD; };
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD; };
VSOut VSMain(VSIn input) {
VSOut o;
o.uv = input.uv;
// o.pos = mul(mvp, float4(input.pos, 1.0));
return o;
}
// ── 픽셀 셰이더 ──
Texture2D uTexture : register(t0); // SRV 슬롯 0
SamplerState uSampler : register(s0); // 샘플러 슬롯 0
float4 PSMain(VSOut input) : SV_Target {
return uTexture.Sample(uSampler, input.uv);
}
입출력 구조체와 시맨틱(: POSITION 등)
struct VSIn { float3 pos : POSITION; float2 uv : TEXCOORD; }; — 정점 셰이더가 받는 정점 하나의 모양입니다.
콜론 뒤의 대문자 단어(POSITION, TEXCOORD)는 시맨틱(semantic)으로,
"이 필드가 의미상 무엇인지"를 알려주는 이름표입니다. GPU는 이 이름표로 입력 레이아웃(정점 버퍼의 어느
바이트가 어느 필드인지)과 셰이더 필드를 짝지어 연결합니다. 즉 C++ 쪽에서
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, ... }로 등록한 정점 데이터가 이 uv로 들어옵니다.
float3 pos : POSITION— 모델 공간 정점 위치 (x,y,z).float2 uv : TEXCOORD— 이 정점의 텍스처 좌표 (u,v). 우리가 이 페이지 내내 이야기한 그 UV입니다.
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD; }; — 정점 셰이더가 내보내는 모양입니다.
SV_Position의 SV_ 접두사는 "시스템 값(System Value)"으로, GPU가 특별 취급하는 예약 시맨틱입니다.
SV_Position은 클립 공간 최종 위치를 뜻하고, 래스터라이저가 이 값으로 삼각형을 화면 픽셀로 쪼갭니다.
반면 uv는 SV_가 아닌 일반 시맨틱이라, 래스터라이저가 세 정점의 uv를 픽셀마다 보간해서 픽셀 셰이더로 전달합니다.
정점 셰이더 — UV는 그냥 통과시킨다
o.uv = input.uv; — 정점 셰이더는 UV를 가공하지 않고 그대로 출력으로 넘깁니다.
UV는 모델에 이미 붙어 있는 고정 좌표라 변환이 필요 없기 때문입니다(스크롤 애니메이션 같은 특수 효과가 아니면).
핵심 일은 다음 주석 처리된 줄입니다.
// o.pos = mul(mvp, float4(input.pos, 1.0)); — 3D 위치를 화면(클립) 공간으로 변환하는 줄입니다.
이 시리즈의 규약대로 행벡터를 쓰지만, 실제로 이 프로젝트에서는 상수 버퍼에 올릴 때
행렬을 전치(transpose)해 두고 셰이더에서 mul(matrix, vector) 형태로 곱하는 관례를 씁니다
(변환·행렬 페이지 참고). float4(input.pos, 1.0)은 3D 위치에 w=1을 붙여 4차원 동차 좌표로 만드는 것으로,
평행이동까지 행렬 한 번으로 처리하기 위함입니다. 이 페이지는 텍스처가 주제라 이 줄은 발췌에서 주석으로만 남겼습니다.
픽셀 셰이더 — 리소스 선언과 Sample
Texture2D uTexture : register(t0); — 픽셀 셰이더가 읽을 2D 텍스처를 선언하고,
register(t0)로 t 레지스터 0번에 배치합니다. C++에서
PSSetShaderResources(0, 1, ...)로 0번 슬롯에 바인딩한 SRV가 바로 이 uTexture에 연결됩니다.
번호(0)가 양쪽에서 일치해야 연결됩니다.
SamplerState uSampler : register(s0); — 샘플링 규칙(래핑·필터) 객체를 s 레지스터 0번에 선언합니다.
C++의 PSSetSamplers(0, 1, ...)가 여기에 연결됩니다.
float4 PSMain(VSOut input) : SV_Target — 픽셀 셰이더는 보간된 정점 데이터(input)를 받아
이 픽셀의 최종 색 float4(RGBA)를 반환합니다. 반환값의 시맨틱 SV_Target은
"이 값을 렌더 타겟(즉 화면 백버퍼)에 쓴다"는 시스템 값입니다.
return uTexture.Sample(uSampler, input.uv); — 이 페이지의 심장입니다.
Sample은 "uSampler가 정한 규칙(필터·래핑·밉)으로, uTexture에서
input.uv 위치의 색을 뽑아라"는 명령입니다. 인자를 봅니다:
- 첫 인자
uSampler— 어떻게 뽑을지의 규칙(2단계에서 만든 샘플러). -
둘째 인자
input.uv— 어디서 뽑을지의 좌표. 이 UV는 정점이 아니라 현재 픽셀의 값임에 주목하세요. 래스터라이저가 삼각형 세 정점의 UV를 이 픽셀 위치까지 원근 보정 보간해 이미 채워 놓은 값입니다.
손으로 따라가는 예: 512×512 나무 텍스처, 현재 픽셀의 보간된 UV가 (0.5, 0.5)라면,
샘플러는 텍셀 좌표 (0.5×512, 0.5×512) = (256, 256) 근처의 2×2 텍셀을 이중선형으로 섞어 예컨대
(0.55, 0.40, 0.25, 1.0)(갈색 계열) 같은 float4를 돌려주고, 이 색이 그대로 그 픽셀에 칠해집니다.
이것이 표면에 이미지가 "붙어" 보이는 원리 전부입니다.
Sample은 밉 레벨을 화면상의 UV 변화율(미분)로 자동 계산하므로 반드시 픽셀 셰이더 안,
분기 밖에서 호출해야 합니다. if 블록 안이나 루프 안에서 조건부로 Sample을 부르면
미분 정보가 깨져 밉 선택이 이상해집니다. 조건부 샘플링이 필요하면 SampleLevel(밉을 직접 지정)을 쓰세요.
4단계 — 슬롯 바인딩 후 그리기
무엇을 만드나: 준비한 SRV와 샘플러를 파이프라인 슬롯에 꽂고 메시를 그리는 렌더 루프 코드입니다.
왜 필요한가: 셰이더의 register(t0)·register(s0)는 "0번 슬롯을 읽겠다"는 약속일 뿐,
그 슬롯에 실제 리소스를 넣는 것은 C++의 바인딩 호출입니다.
ComPtr<ID3D11ShaderResourceView> srv = loadTexture(device.Get(), context.Get(), "assets/wood.png");
ComPtr<ID3D11SamplerState> samp = makeSampler(device.Get());
// 렌더 루프 안:
context->VSSetShader(vs.Get(), nullptr, 0);
context->PSSetShader(ps.Get(), nullptr, 0);
// t0 슬롯에 SRV, s0 슬롯에 샘플러 바인딩
context->PSSetShaderResources(0, 1, srv.GetAddressOf());
context->PSSetSamplers(0, 1, samp.GetAddressOf());
mesh.draw(context.Get());
초기화 — 루프 밖에서 딱 한 번
loadTexture(...)와 makeSampler(...) 두 호출은 렌더 루프 밖(초기화 단계)에
있어야 합니다. 텍스처 로드는 디스크 I/O와 GPU 업로드가 걸리는 무거운 작업이라, 매 프레임 다시 하면
프레임레이트가 바닥납니다. 결과인 srv·samp를 멤버 변수로 보관하고 루프에서는 바인딩만 반복합니다.
device.Get()·context.Get()은 ComPtr에서 원시 포인터를 꺼내 함수에 넘기는 것입니다.
셰이더 바인딩
context->VSSetShader(vs.Get(), nullptr, 0); / PSSetShader(ps.Get(), nullptr, 0); —
이번 드로우에 쓸 정점/픽셀 셰이더를 파이프라인에 설정합니다. 두 번째·세 번째 인자
(nullptr, 0)는 클래스 인스턴스 인터페이스(고급 기능)용이라 여기서는 각각 "없음", "0개"입니다.
PSSetShaderResources / PSSetSamplers — 슬롯에 꽂기
context->PSSetShaderResources(0, 1, srv.GetAddressOf()); — 픽셀 셰이더의 t 슬롯에 SRV를 바인딩합니다.
- 첫 인자
0(StartSlot) — 시작 슬롯 번호. 셰이더의register(t0)와 짝. 이 번호가 어긋나면 셰이더가 엉뚱한(또는 빈) 슬롯을 읽어 검은 화면이 됩니다. - 둘째 인자
1(NumViews) — 바인딩할 SRV 개수. 여기서는 텍스처 한 장이라 1. -
셋째 인자
srv.GetAddressOf()— SRV 포인터들의 배열의 첫 주소. API가 "여러 개"를 받을 수 있게 배열 형태를 요구하므로, 하나여도 그 포인터의 주소를 넘깁니다.GetAddressOf()는ComPtr이 내부 포인터의 주소(ID3D11ShaderResourceView**)를 참조 카운트 변화 없이 돌려줍니다.
context->PSSetSamplers(0, 1, samp.GetAddressOf()); — 같은 구조로 s 슬롯 0번에 샘플러를 바인딩합니다.
셰이더의 register(s0)와 짝입니다.
register(t0)(SRV)와 register(s0)(샘플러)는 서로 다른 슬롯 공간입니다.
둘 다 "0번"이지만 t-공간의 0과 s-공간의 0은 완전히 별개라 충돌하지 않습니다.
PSSetShaderResources에 넘긴 번호는 t#에, PSSetSamplers에 넘긴 번호는 s#에
대응합니다. 이 둘을 헷갈리거나 SRV를 아예 바인딩하지 않으면 텍스처가 검은색으로 나옵니다.
mesh.draw(context.Get()); — 마지막으로 실제 드로우 콜입니다. 내부적으로 정점 버퍼·인덱스 버퍼를 바인딩하고
context->DrawIndexed(...) 등을 호출해 삼각형을 화면으로 흘려보냅니다. 이 시점의 파이프라인 상태에는
방금 꽂은 텍스처와 샘플러가 들어 있으므로, 픽셀 셰이더의 Sample이 올바른 이미지를 읽습니다.
참고 — 정점 구조체와 입력 레이아웃(TEXCOORD 등록)
셰이더의 float2 uv : TEXCOORD가 실제 정점 데이터와 연결되려면, C++ 쪽 정점 구조체에 UV 필드가 있고
입력 레이아웃에 TEXCOORD 요소가 등록돼 있어야 합니다. 이 부분이 빠지면 UV가 항상 0으로 들어와
이미지의 좌상단 한 텍셀만 온 표면에 칠해집니다.
struct Vertex {
float px, py, pz; // 위치 → POSITION
float u, v; // 텍스처 좌표 → TEXCOORD
};
// 입력 레이아웃: 정점 버퍼의 바이트 배치를 셰이더 시맨틱에 매핑
D3D11_INPUT_ELEMENT_DESC layout[] = {
// 시맨틱 인덱스 포맷(성분/비트) 슬롯 바이트오프셋 분류 step
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, D3D11_APPEND_ALIGNED_ELEMENT, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
각 요소의 필드를 봅니다:
"POSITION"/"TEXCOORD"— 셰이더VSIn의 시맨틱과 철자·대소문자까지 정확히 일치해야 합니다.- 인덱스
0— 같은 시맨틱을 여러 개 쓸 때(TEXCOORD0, TEXCOORD1…) 구분하는 번호. 하나면 0. -
포맷 — 위치는
R32G32B32_FLOAT(32비트 float 3개 =float3), UV는R32G32_FLOAT(float 2개 =float2). 셰이더 필드 타입과 성분 수가 맞아야 합니다. -
바이트 오프셋 — 위치는 정점 시작에서
0바이트, UV는D3D11_APPEND_ALIGNED_ELEMENT로 "앞 요소 바로 뒤"를 자동 계산(여기서는 12바이트, float 3개 뒤)합니다. 직접12를 써도 되지만 APPEND가 실수를 줄입니다. PER_VERTEX_DATA— 이 데이터가 정점마다 하나씩(인스턴스마다가 아니라)이라는 뜻. 마지막0은 인스턴스 스텝(정점 데이터라 무의미).
DirectXMath / D3D API 대응 정리
이 페이지는 벡터·행렬 수식보다 D3D 리소스 API 호출이 중심이라, 원본의 각 단계가 어떤 API 함수와 1:1로 대응하는지 정리합니다. UV 변환이 필요할 때만 DirectXMath가 개입합니다.
| 단계 / 개념 | 이 페이지의 코드 | 대응 API·타입 |
|---|---|---|
| 이미지 디코딩 | stbi_load(..., 4) | stb_image(외부). 대안: WIC IWICImagingFactory |
| 텍스처 생성 | device->CreateTexture2D | ID3D11Device::CreateTexture2D |
| 픽셀 업로드 | ctx->UpdateSubresource | ID3D11DeviceContext::UpdateSubresource |
| 셰이더 읽기 뷰 | device->CreateShaderResourceView | ID3D11Device::CreateShaderResourceView |
| 밉맵 생성 | ctx->GenerateMips | ID3D11DeviceContext::GenerateMips |
| 샘플러 생성 | device->CreateSamplerState | ID3D11Device::CreateSamplerState |
| SRV 바인딩 | PSSetShaderResources(0,1,...) | 셰이더 register(t0)와 짝 |
| 샘플러 바인딩 | PSSetSamplers(0,1,...) | 셰이더 register(s0)와 짝 |
| UV 스크롤/회전(선택) | — | XMFLOAT2, XMMatrixRotationZ 등으로 UV 변환행렬 |
UV를 애니메이션(스크롤·회전)하고 싶다면, 상수 버퍼에 XMFLOAT4X4 UV 변환행렬을 올리고 정점 셰이더에서
o.uv = mul(float4(input.uv, 0, 1), uvMatrix).xy;처럼 곱합니다. 행렬은 이 시리즈 규약대로
DirectXMath로 만든 뒤 XMMatrixTranspose로 전치해 업로드하고, 셰이더에서 행벡터로 곱하면 됩니다.
(변환·행렬 페이지에서 다룬 규약과 동일합니다.)
Shader 클래스는
4장 GPU · 셰이더에서 완성합니다.
이 장의 인라인 셰이더들을 거기로 옮기면 코드가 훨씬 깔끔해집니다. 밉맵과 필터링을 더 깊이 파고드는
내용은 7장에서 다룹니다.