← 개념 · 데모 가이드 | 구현 허브 | 3장. 3D 그래픽스 | 텍스처 매핑
IMPLEMENTATION · CHAPTER 3

텍스처 매핑 — 한 줄 한 줄 직접 구현

지금까지 삼각형은 정점 색으로만 칠했습니다. 하지만 나무 상자, 벽돌, 캐릭터 피부처럼 현실감 있는 표면은 색을 정점마다 지정하는 것이 아니라 사진(이미지)을 표면에 붙여서 만듭니다. 이것이 텍스처 매핑입니다. 이 페이지에서는 디스크의 PNG 파일 한 장이 stb_image로 메모리에 올라오고 → ID3D11Texture2D라는 GPU 리소스가 되고 → 셰이더 리소스 뷰(SRV)샘플러 상태를 거쳐 → 픽셀 셰이더가 정점의 UV 좌표로 픽셀 하나하나를 뽑아 쓰는 전 과정을, C++ 코드 한 줄과 D3D API 인자 하나까지 입문자 눈높이로 해부합니다.

규약 이 시리즈는 Direct3D 11 · 왼손 좌표계 · 클립 z [0,1] · 행벡터(v' = v*M, HLSL mul(v, M))로 통일합니다. 텍스처 좌표는 V축이 위→아래(좌상단이 (u,v)=(0,0))인 D3D 관례를 따릅니다. OpenGL은 V가 아래→위라 관례가 반대이니, 다른 자료의 UV를 그대로 옮기면 이미지가 뒤집힐 수 있습니다.
전제 이 페이지는 3장의 메시(정점 버퍼)와 셰이더 파이프라인이 이미 돌아간다는 가정 위에서 텍스처 리소스를 추가하는 부분만 다룹니다. 정점 구조체에는 위치 외에 float2 uv 필드가 있어야 하고, 입력 레이아웃에도 TEXCOORD 요소가 등록돼 있어야 합니다(§ 마지막에 정리). 이미지 디코딩은 헤더 온리 라이브러리 stb_image 한 파일로 처리합니다.

먼저: 텍스처 매핑이란 무엇인가 — UV 좌표의 직관

본격적인 코드에 들어가기 전에, 이 페이지 전체를 관통하는 UV 좌표 개념부터 확실히 잡고 갑니다. UV가 무엇인지 모르면 아래 코드의 input.uv가 왜 픽셀을 결정하는지 이해할 수 없습니다.

이미지는 [0,1] × [0,1] 정사각형에 펼쳐진 지도입니다

실제 이미지 파일은 512×512, 1920×1080처럼 픽셀 단위 크기가 제각각입니다. 하지만 셰이더는 "이미지의 몇 번째 픽셀"이 아니라 비율(0~1)로 이미지를 가리킵니다. 가로 방향을 U, 세로 방향을 V라 부르고, 각각 0에서 1까지의 실수입니다:

이렇게 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) 몇 개만 꽂으면, 그 사이 포장지(이미지)는 자연스럽게 늘어나 붙습니다.

  1. stbi_load로 이미지 픽셀을 CPU 메모리에 읽는다(RGBA 4채널로 강제 요청).
  2. D3D11_TEXTURE2D_DESC로 텍스처를 서술하고 CreateTexture2D로 GPU 리소스를 만든다.
  3. UpdateSubresource로 픽셀을 0번 밉에 올리고, CreateShaderResourceView로 SRV를 만든 뒤 GenerateMips로 밉맵 생성.
  4. ID3D11SamplerState(래핑·필터링 규칙)를 한 번 만들어 재사용한다.
  5. 슬롯에 SRV/샘플러를 바인딩하고(PSSetShaderResources / PSSetSamplers) 그린다.

1단계 — 이미지 로드에서 텍스처 생성까지

무엇을 만드나: 파일 경로 하나를 받아, GPU가 셰이더에서 읽을 수 있는 셰이더 리소스 뷰(SRV)를 돌려주는 함수입니다. 왜 필요한가: 픽셀 셰이더는 원본 PNG 파일을 직접 열 수 없습니다. 이미지를 GPU 메모리의 특정 포맷(R8G8B8A8) 텍스처로 올리고, 그 텍스처를 "셰이더에서 읽는 관점"으로 감싼 SRV가 있어야만 Texture2D.Sample()이 동작합니다. 이 함수가 그 준비를 통째로 처리합니다.

src/texture.cpp — stb_image 로딩 → D3D11 텍스처cpp
// .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)를 조각내 봅니다.

stbi_load 한 줄 완전 해부

unsigned char* data = stbi_load(path, &w, &h, &channels, 4); — 이미지 파일을 디코딩(PNG·JPG 압축을 풀어)해 생 픽셀 배열을 CPU 메모리에 만들고 그 첫 바이트 주소를 반환합니다. 인자를 하나씩 봅니다.

반환 타입 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에게 어떤 텍스처를 만들지 서술하는 설계도"입니다.

함정 자동 밉맵 3종 세트: MipLevels = 0, BindFlagsRENDER_TARGET 포함, MiscFlags = ...GENERATE_MIPS. 이 중 하나라도 빠지면 GenerateMips가 조용히 실패하거나 (RENDER_TARGET 누락 시) 아예 텍스처 생성이 실패합니다. 밉이 안 생기면 이 3개부터 확인하세요.

CreateTexture2D — 설계도로 실제 GPU 리소스 찍어내기

device->CreateTexture2D(&td, nullptr, &tex); — 설계도(td)대로 GPU에 텍스처 메모리를 할당합니다. 인자를 봅니다.

실무에서는 이 함수가 HRESULT(성공/실패 코드)를 반환하므로 if (FAILED(hr)) ...로 검사해야 합니다. 학습용 예제라 생략했지만, 실제 코드에서는 반드시 확인하세요.

UpdateSubresource — 0번 밉에 픽셀 밀어 넣기

ctx->UpdateSubresource(tex.Get(), 0, nullptr, data, w * 4, 0); — CPU 메모리의 픽셀 배열 (data)을 GPU 텍스처의 0번 밉(원본 해상도)으로 복사합니다. 인자가 6개나 되니 하나씩 봅니다.

stbi_image_free(data); — 픽셀이 이미 GPU로 복사됐으니 CPU 쪽 원본은 더 이상 필요 없습니다. stbi_load가 할당한 메모리는 free가 아니라 반드시 stb의 stbi_image_free로 해제해야 합니다 (같은 할당자를 쓰기 위함). 이 줄을 빼먹으면 텍스처를 로드할 때마다 CPU 메모리가 새어 나갑니다(메모리 릭).

성능 UpdateSubresource는 내부적으로 CPU→GPU 복사를 일으키므로 초기화 시 한 번 쓰는 게 정석입니다. 매 프레임 텍스처 내용을 바꾸는 상황이라면 D3D11_USAGE_DYNAMIC + Map/Unmap이 훨씬 빠릅니다. 정적 텍스처(나무·벽돌 등)는 지금 방식이 맞습니다.

CreateShaderResourceViewGenerateMips

device->CreateShaderResourceView(tex.Get(), nullptr, &srv); — 텍스처를 "셰이더가 읽는 관점"으로 감싸는 SRV를 만듭니다. 왜 텍스처를 직접 바인딩하지 않고 뷰를 거치냐면, 같은 메모리를 여러 방식으로 해석할 수 있기 때문입니다 (예: 같은 텍스처를 렌더 타겟 뷰로도, 셰이더 리소스 뷰로도). 뷰는 "이 리소스를 이런 포맷·이런 범위로 읽겠다"는 렌즈입니다.

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] 밖으로 나가면 어떻게 할지(래핑), 두 텍셀 사이에 걸치면 어떻게 섞을지(필터링), 어느 밉을 고를지 — 이 모든 결정을 샘플러가 담당합니다. 텍스처가 "무엇을"이라면, 샘플러는 "어떻게"입니다.

src/texture.cpp — 샘플러 상태cpp
// 샘플러 상태 (한 번만 생성해 재사용)
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)"이 바로 이것입니다.

더 나은 품질을 원하면 D3D11_FILTER_ANISOTROPIC을 쓰고 sd.MaxAnisotropy = 16;을 설정합니다. 이방성 필터링은 표면을 비스듬히 볼 때(도로·바닥) 삼선형이 뭉개는 원거리 텍스처를 훨씬 선명하게 유지합니다.

AddressU/V/W — UV가 [0,1] 밖으로 나가면?

WRAP은 UV의 정수 부분을 버리고 소수 부분만 씁니다. 즉 u = 2.30.3으로, u = -0.20.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 호출입니다.

셰이더 — UV 샘플링HLSL
// ── 정점 셰이더 (발췌) ──
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로 들어옵니다.

struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD; }; — 정점 셰이더가 내보내는 모양입니다. SV_PositionSV_ 접두사는 "시스템 값(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 위치의 색을 뽑아라"는 명령입니다. 인자를 봅니다:

손으로 따라가는 예: 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++의 바인딩 호출입니다.

src/main.cpp — 슬롯 바인딩 후 그리기cpp
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를 바인딩합니다.

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으로 들어와 이미지의 좌상단 한 텍셀만 온 표면에 칠해집니다.

src/mesh.h — 정점 구조체와 입력 레이아웃cpp
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 },
};

각 요소의 필드를 봅니다:

DirectXMath / D3D API 대응 정리

이 페이지는 벡터·행렬 수식보다 D3D 리소스 API 호출이 중심이라, 원본의 각 단계가 어떤 API 함수와 1:1로 대응하는지 정리합니다. UV 변환이 필요할 때만 DirectXMath가 개입합니다.

단계 / 개념이 페이지의 코드대응 API·타입
이미지 디코딩stbi_load(..., 4)stb_image(외부). 대안: WIC IWICImagingFactory
텍스처 생성device->CreateTexture2DID3D11Device::CreateTexture2D
픽셀 업로드ctx->UpdateSubresourceID3D11DeviceContext::UpdateSubresource
셰이더 읽기 뷰device->CreateShaderResourceViewID3D11Device::CreateShaderResourceView
밉맵 생성ctx->GenerateMipsID3D11DeviceContext::GenerateMips
샘플러 생성device->CreateSamplerStateID3D11Device::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로 전치해 업로드하고, 셰이더에서 행벡터로 곱하면 됩니다. (변환·행렬 페이지에서 다룬 규약과 동일합니다.)

정리 핵심 흐름을 한 문장으로: 파일 → (stb) CPU 픽셀 → (CreateTexture2D + UpdateSubresource) GPU 텍스처 → (CreateShaderResourceView + GenerateMips) SRV·밉 → (CreateSamplerState) 규칙 → (PSSetShaderResources/PSSetSamplers) 슬롯 바인딩 → (Sample) 픽셀 색. "무엇을(텍스처) + 어떻게(샘플러) + 어디서(UV)"가 만나 표면에 이미지가 붙습니다.
다음 셰이더 컴파일과 상수 버퍼를 캡슐화한 재사용 가능한 Shader 클래스는 4장 GPU · 셰이더에서 완성합니다. 이 장의 인라인 셰이더들을 거기로 옮기면 코드가 훨씬 깔끔해집니다. 밉맵과 필터링을 더 깊이 파고드는 내용은 7장에서 다룹니다.