← 개념 · 데모 가이드 | 구현 허브 | 2장. 2D 그래픽스 | 스프라이트 · 텍스처 샘플링
IMPLEMENTATION · CHAPTER 2

스프라이트 · 텍스처 샘플링 — 한 줄 한 줄 직접 구현

스프라이트(sprite)는 화면에 붙이는 작은 이미지 조각입니다. 캐릭터, 아이콘, 글자 하나까지 전부 스프라이트로 볼 수 있죠. 이 페이지에서는 (1) 이미지 조각을 대상 화면에 알파 블렌드로 붙이는 블릿(blit), (2) 텍스처를 UV 좌표로 읽어오는 샘플링을 다룹니다. 샘플링에는 가장 가까운 텍셀 하나만 쓰는 nearest와, 이웃 네 개를 거리로 섞는 bilinear 두 방식이 있는데, 확대·축소할 때 이 선택이 화질을 결정합니다. 마지막으로 이렇게 CPU로 그린 프레임버퍼를 실제로 Direct3D 11 화면에 띄우는 코드까지, 함수 시그니처와 각 줄, 수식의 각 성분을 하나하나 뜯어보고 D3D11 API에 1:1로 대응시켜 봅니다.

규약 이 가이드는 D3D 규약으로 통일합니다: 행벡터 v' = v * M(HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 z 범위 [0,1], 텍스처 V축은 위→아래(좌상단이 원점). 이 페이지의 샘플링 계산 자체는 좌표계와 무관하지만, UV의 V 방향은 이 규약(위→아래)을 따릅니다. 아래에서 다시 짚습니다.

0. 무엇을, 왜 만드나

앞 절들에서 우리는 Framebuffer(픽셀 배열)와 blendPixel(알파 합성)을 만들었습니다. 스프라이트는 그 위에 얹는 “이미지를 이미지 위에 붙이는” 아주 실용적인 연산입니다. 게임 화면 하나는 사실 배경 스프라이트 위에 캐릭터 스프라이트, 그 위에 UI 스프라이트를 차곡차곡 블릿해서 완성됩니다.

핵심 질문은 하나입니다: “UV가 텍셀과 텍셀 사이의 애매한 위치를 가리키면 어떤 색을 돌려줄까?” 가장 가까운 텍셀 하나를 고르면 nearest, 주변 네 텍셀을 거리로 섞으면 bilinear입니다. 이 페이지의 절반은 바로 이 “애매한 위치를 색으로 바꾸는 규칙”을 성분 단위로 해부하는 데 씁니다.

  1. 소스 이미지의 서브 사각형을 알파 블렌드로 대상 위치에 붙인다 (blitSprite).
  2. UV [0,1]로 텍스처를 nearest 샘플링한다 (sampleNearest).
  3. 이웃 4개를 섞는 bilinear 샘플링으로 부드럽게 만든다 (sampleBilinear).
  4. CPU 프레임버퍼를 D3D11 동적 텍스처로 올려 화면에 띄운다 (업로드 + 풀스크린 삼각형 + HLSL).

1. 스프라이트 블릿 — 이미지를 이미지 위에 붙이기

무엇을 만드나: 소스 이미지 src(sx, sy)에서 시작하는 sw×sh 크기 사각형을, 대상 이미지 dst(dx, dy) 위치에 알파 블렌드하며 복사합니다.
왜 필요한가: 스프라이트 시트(여러 그림을 한 장에 모아둔 이미지)에서 “걷는 3번째 프레임”만 잘라서 화면 특정 위치에 붙이는 것이 게임 렌더링의 기본기입니다. 서브 사각형 좌표 (sx, sy, sw, sh)가 바로 “스프라이트 시트에서 어느 칸을 쓸지”를 정합니다.

  1. 소스 영역을 y(세로) 바깥, x(가로) 안쪽 이중 루프로 훑는다.
  2. 소스에서 픽셀 하나 c를 읽는다.
  3. 그 픽셀을 대상의 대응 위치에 blendPixel알파를 반영해 찍는다.
  4. 경계(화면 밖 / 소스 밖)는 blendPixel·inBounds가 알아서 잘라내므로 여기서는 신경 쓰지 않는다.
sprite.h — blitSpritecpp
#pragma once
#include "framebuffer.h"
#include "blend.h"

// src의 (sx,sy)에서 sw×sh 크기 영역을, dst의 (dx,dy) 위치에 알파 블렌드.
// 화면/소스 경계는 blendPixel/inBounds가 알아서 잘라낸다.
inline void blitSprite(Framebuffer& dst, const Framebuffer& src,
                       int dx, int dy, int sx, int sy, int sw, int sh) {
    for (int y = 0; y < sh; ++y)
        for (int x = 0; x < sw; ++x) {
            Color c = src.getPixel(sx + x, sy + y);
            blendPixel(dst, dx + x, dy + y, c);   // 알파 반영
        }
}

시그니처 해부 — 각 인자와 한정자가 왜 그 모양인가

inline void blitSprite(Framebuffer& dst, const Framebuffer& src, int dx, int dy, int sx, int sy, int sw, int sh) — 이 한 줄에도 결정이 여럿 담겨 있습니다.

루프 해부 — 왜 y가 바깥, x가 안쪽인가

for (int y = 0; y < sh; ++y) for (int x = 0; x < sw; ++x)세로(y)가 바깥, 가로(x)가 안쪽인 순서에는 이유가 있습니다. 프레임버퍼의 픽셀은 메모리에 행 우선(row-major), 즉 (0,0),(1,0),(2,0)… 한 줄을 쭉 채운 뒤 다음 줄로 넘어가는 순서로 저장됩니다. 안쪽 루프가 x를 1씩 늘리면 메모리에서 바로 옆 칸을 읽고 쓰게 되어 캐시 적중률이 높아집니다. 반대로 x를 바깥에 두면 매 반복마다 한 행씩(폭×4바이트) 건너뛰어 캐시가 계속 어긋납니다.

루프 변수 x, y스프라이트 로컬 좌표입니다. 즉 “잘라온 사각형 안에서의 상대 위치”로 (0,0)부터 (sw-1, sh-1)까지 돕니다. 이걸 두 개의 절대 좌표로 옮겨야 합니다 — 소스에서는 (sx + x, sy + y), 대상에서는 (dx + x, dy + y). 같은 로컬 오프셋 (x, y)를 서로 다른 시작점에 더한다는 것이 블릿의 전부입니다.

기하 직관 종이 두 장을 떠올리세요. 소스 종이의 (sx, sy)에 압정을 꽂고, 대상 종이의 (dx, dy)에도 압정을 꽂습니다. 이제 두 압정을 겹쳐 포갠 채로, 소스에서 오른쪽·아래로 (x, y)만큼 간 점의 색을 떠서 대상의 같은 (x, y) 자리에 찍습니다. 압정 위치(시작점)만 다르고, 그 뒤로는 완전히 똑같은 격자를 함께 훑는 것이 블릿입니다.

본문 두 줄 해부

Color c = src.getPixel(sx + x, sy + y); — 소스의 절대 좌표에서 픽셀 하나를 읽어 값(사본)으로 받습니다. ColorR,G,B,A 네 바이트짜리 작은 구조체라 값 복사가 저렴합니다.

blendPixel(dst, dx + x, dy + y, c); — 그 색을 대상의 절대 좌표에 알파 블렌드로 찍습니다. 여기가 “그냥 덮어쓰기”가 아니라 알파 반영인 게 핵심입니다. c.a가 128(반투명)이면 대상의 원래 색과 절반씩 섞이고, c.a가 0이면 아무 변화가 없습니다(투명). 덕분에 배경 위에 테두리가 부드러운 스프라이트를 자연스럽게 얹을 수 있습니다. 알파 합성 공식(over 연산자)의 상세는 알파 블렌딩 절에서 이미 다뤘습니다.

함정 경계 검사를 여기서 직접 하지 않는다는 점이 중요합니다. dx+x가 화면 밖으로 나가거나 sx+x가 소스 밖으로 나가도, 그 방어는 getPixel/blendPixel 내부의 inBounds 검사에 맡깁니다. 만약 그 안전장치가 없는 getPixel을 쓰면 배열 범위 밖 접근(UB, 크래시)이 됩니다. “경계는 아래 계층이 책임진다”는 계약을 반드시 지켜야 합니다.
성능 스프라이트가 화면 밖으로 대부분 나가 있어도 이 루프는 sw×sh번을 전부 돌면서 매번 경계에서 버려집니다(그리고 아무것도 안 그림). 스프라이트가 아주 많다면, 루프에 들어가기 전에 화면과 겹치는 사각형만 미리 클리핑x, y 범위를 좁히는 편이 훨씬 빠릅니다. GPU에서는 이 “겹치는 부분만 처리”를 래스터라이저가 자동으로 해 줍니다.

D3D11 대응 — 블릿은 하드웨어가 대신 한다

실무 D3D11에서 “한 텍스처의 일부를 다른 텍스처에 복사”는 우리가 루프를 도는 대신 API 한 번으로 끝납니다. 다만 대응 방식이 두 갈래입니다.

우리 코드D3D11 대응설명
불투명 복사 (알파 무시) CopySubresourceRegion 소스의 특정 박스를 대상의 특정 좌표로 GPU가 바로 복사. 블렌딩은 안 함.
blitSprite (알파 블렌드) 텍스처를 쿼드로 그리기 + D3D11_BLEND_DESC 스프라이트를 사각형(2 삼각형)으로 그리고, 블렌드 스테이트로 over 합성. 실전 스프라이트 렌더링 방식.

즉 우리의 이중 루프 + blendPixel은 GPU에서는 “텍스처 입힌 사각형을 블렌드 켜고 한 장 그리는 것”과 같습니다. 소스 서브 사각형 (sx, sy, sw, sh)는 그 사각형의 UV 범위로, (dx, dy)는 화면상 위치로 넘어갑니다. 바로 그 “UV로 텍스처를 읽는” 부분이 다음 절의 주제입니다.

2. nearest 샘플링 — 가장 가까운 텍셀 하나

무엇을 만드나: UV 좌표 (u, v) ∈ [0,1]²를 받아 텍스처에서 가장 가까운 텍셀 하나의 색을 돌려줍니다.
왜 필요한가: 스프라이트를 확대/회전/변형해 그릴 때, 화면 픽셀 하나가 텍스처의 정확한 정수 좌표에 떨어지는 일은 드뭅니다. 대개 (3.7, 12.2)처럼 텍셀 사이 애매한 곳을 가리키죠. 이때 “어떤 색?”을 정하는 가장 단순한 규칙이 nearest입니다.

sampler.h — sampleNearestcpp
#pragma once
#include <algorithm>
#include "framebuffer.h"

// UV [0,1] -> 가장 가까운 텍셀. u,v가 범위를 벗어나면 clamp.
inline Color sampleNearest(const Framebuffer& tex, float u, float v) {
    u = std::clamp(u, 0.0f, 1.0f);
    v = std::clamp(v, 0.0f, 1.0f);
    int x = std::min((int)(u * tex.width()),  tex.width()  - 1);
    int y = std::min((int)(v * tex.height()), tex.height() - 1);
    return tex.getPixel(x, y);
}

1단계 — clamp: UV를 [0,1] 안으로 가둔다

u = std::clamp(u, 0.0f, 1.0f);std::clamp(값, 최소, 최대)는 값이 최소보다 작으면 최소를, 최대보다 크면 최대를, 그 사이면 그대로 돌려줍니다. UV가 1.21.0으로, -0.3이면 0.0으로 눌립니다.

수학적 의미: clamp(u,0,1) = max(0, min(1, u)). 기하 직관: 텍스처 밖을 가리키는 좌표를 가장자리 텍셀에 “들러붙게” 만드는 것 — 이것이 바로 CLAMP 주소 지정 모드입니다. UV가 텍스처를 넘어가면 테두리 색이 죽 늘어나 보입니다. (다른 모드로는 넘친 만큼 반복하는 WRAP/REPEAT, 거울처럼 뒤집는 MIRROR가 있습니다.) 코드 매핑: 두 줄의 clamp가 u축·v축 각각의 CLAMP 처리입니다.

2단계 — UV를 텍셀 인덱스로 환산

int x = std::min((int)(u * tex.width()), tex.width() - 1); — 이 한 줄을 조각조각 봅니다.

y도 완전히 대칭입니다: v축은 위→아래(D3D 규약)이므로 v=0이 맨 윗줄, v=1이 맨 아랫줄입니다. OpenGL이라면 여기서 v1-v로 뒤집어야 했을 자리입니다.

손계산 예제 폭·높이 256×256 텍스처에서 sampleNearest(tex, 0.5, 0.25)를 계산해 봅시다.
· u=0.5 → 0.5×256 = 128.0 → (int)128 → min(128, 255) = 128
· v=0.25 → 0.25×256 = 64.0 → (int)64 → min(64, 255) = 64
결과: tex.getPixel(128, 64). 즉 가로 정중앙, 세로 1/4 지점의 텍셀 하나를 그대로 돌려줍니다. 섞음(보간) 없이 딱 하나입니다.
성능 nearest는 픽셀당 텍스처 읽기 1회뿐이라 가장 빠릅니다. GPU에서는 D3D11_SAMPLER_DESC.Filter = D3D11_FILTER_MIN_MAG_MIP_POINT 한 줄이면 됩니다 (POINT = point sampling = nearest). 픽셀아트를 확대할 땐 흐려지면 안 되므로 nearest가 정답입니다.

3. bilinear 샘플링 — 이웃 4텍셀을 섞기

무엇을 만드나: UV 주변의 텍셀 4개를 UV의 소수부(거리)로 가중 평균해 부드러운 색을 만듭니다.
왜 필요한가: 사진처럼 색이 연속으로 변하는 이미지를 확대하면, nearest는 큼직한 계단(블록)이 보입니다. bilinear는 경계를 매끄럽게 이어 붙여 자연스럽게 확대합니다.

먼저 두 값을 비율 t로 섞는 선형 보간(lerp) 보조 함수부터 봅니다.

sampler.h — lerpcpp
// 채널별 선형 보간 보조
inline float lerp(float a, float b, float t) { return a + (b - a) * t; }

수학: lerp(a,b,t) = a + (b − a)·t = (1−t)·a + t·b. 직관: t=0이면 완전히 a, t=1이면 완전히 b, t=0.5면 정확히 중간값. 즉 a에서 b일정한 속도로 걸어가는 함수입니다. a + (b-a)*t 형태로 쓴 건 곱셈 1회·덧셈 2회로 수치 오차와 연산량이 작기 때문입니다 ((1-t)*a + t*b는 곱셈이 2회).

sampler.h — sampleBilinearcpp
// UV [0,1] -> 이웃 4텍셀의 이중선형 보간.
inline Color sampleBilinear(const Framebuffer& tex, float u, float v) {
    u = std::clamp(u, 0.0f, 1.0f);
    v = std::clamp(v, 0.0f, 1.0f);
    // 텍셀 중심(-0.5)을 기준으로 실수 좌표를 잡는다
    float fx = u * tex.width()  - 0.5f;
    float fy = v * tex.height() - 0.5f;
    int x0 = (int)std::floor(fx), y0 = (int)std::floor(fy);
    float tx = fx - x0, ty = fy - y0;   // 소수부 = 보간 가중치

    auto at = [&](int x, int y) {
        x = std::clamp(x, 0, tex.width()  - 1);
        y = std::clamp(y, 0, tex.height() - 1);
        return tex.getPixel(x, y);
    };
    Color c00 = at(x0, y0),     c10 = at(x0 + 1, y0);
    Color c01 = at(x0, y0 + 1), c11 = at(x0 + 1, y0 + 1);

    Color out;
    out.r = toByte((lerp(lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty)) / 255.0f);
    out.g = toByte((lerp(lerp(c00.g, c10.g, tx), lerp(c01.g, c11.g, tx), ty)) / 255.0f);
    out.b = toByte((lerp(lerp(c00.b, c10.b, tx), lerp(c01.b, c11.b, tx), ty)) / 255.0f);
    out.a = toByte((lerp(lerp(c00.a, c10.a, tx), lerp(c01.a, c11.a, tx), ty)) / 255.0f);
    return out;
}

1단계 — 왜 -0.5를 빼는가 (텍셀 중심 규약)

float fx = u * tex.width() - 0.5f; — 이 -0.5가 bilinear의 가장 중요하고 가장 자주 빠뜨리는 한 조각입니다.

수학/규약: 텍셀 i중심은 픽셀 좌표 i + 0.5에 있습니다. 즉 0번 텍셀은 [0,1) 구간을 차지하고 그 중심은 0.5입니다. 우리가 원하는 건 “연속 좌표를 텍셀 중심 기준으로 보는 것”이므로, u*width(텍셀 모서리 기준)에서 0.5를 빼서 중심 기준으로 옮깁니다.

기하 직관: 텍셀들을 “점”이라고 보면, 그 점들은 0.5, 1.5, 2.5 …에 박혀 있습니다. 임의의 연속 좌표 p가 어느 두 점 사이에 있는지, 그리고 왼쪽 점에서 얼마나 떨어졌는지를 알려면 좌표계를 0.5만큼 왼쪽으로 밀어(fx = p - 0.5) 점들이 0, 1, 2 … 정수에 오도록 맞추는 게 편합니다. 코드 매핑: fx, fy가 바로 그 “중심 기준 연속 좌표”입니다.

함정 -0.5를 빠뜨리면 이미지가 반 픽셀 어긋나고, 확대할 때 가장자리가 미묘하게 밀립니다. 특히 텍스처 아틀라스(여러 그림을 한 장에)에서는 옆 칸의 색이 새어 들어오는 블리딩으로 이어지기도 합니다. “텍셀의 중심이 정수 인덱스에 오도록 좌표를 잡는다”가 핵심 규약입니다.

2단계 — 좌하단 텍셀 x0,y0와 소수부 tx,ty

int x0 = (int)std::floor(fx), y0 = (int)std::floor(fy);std::floor내림(음수도 더 작은 쪽으로: floor(-0.3) = -1)입니다. 여기서 (int) 절단이 아니라 floor를 쓰는 이유가 있습니다: fx가 음수일 수 있는데 (u=0이면 fx=-0.5), (int)(-0.5)0이지만 floor(-0.5)-1입니다. 보간의 “왼쪽 텍셀”은 항상 더 작은 정수여야 하므로 floor가 맞습니다.

float tx = fx - x0, ty = fy - y0;소수부입니다. fx = x0 + tx이고 0 ≤ tx < 1. 이 tx가 바로 “왼쪽 텍셀(x0)에서 오른쪽 텍셀(x0+1) 쪽으로 얼마나 갔나”를 나타내는 가로 보간 가중치입니다. ty는 세로 가중치.

3단계 — 이웃 4텍셀 읽기 (경계는 람다가 clamp)

auto at = [&](int x, int y){ … };람다(lambda), 즉 그 자리에서 만드는 작은 익명 함수입니다. 대괄호 [&]캡처 방식으로, “바깥의 tex 같은 변수를 참조로 끌어다 쓴다”는 뜻입니다(값 복사가 아니라 원본 참조 → 큰 tex 복사 안 함). 이 at은 좌표를 [0, width-1]×[0, height-1]로 clamp한 뒤 픽셀을 읽어주므로, x0+1이 텍스처 오른쪽 끝을 넘어가도 안전하게 가장자리 텍셀을 돌려줍니다.

네 이웃의 이름 규약은 c[행][열]이 아니라 c[y비트][x비트]입니다:

이름의 첫 숫자는 x방향, 둘째 숫자는 y방향을 뜻합니다(c[x][y]). V가 위→아래 규약이므로 y0+1은 화면상 아래쪽 텍셀입니다.

4단계 — 이중 선형 보간의 수식 (핵심)

out.r = toByte( lerp( lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty ) / 255.0f ); — 이 한 줄이 “이중(bi)선형” 보간의 전부입니다. 안에서 밖으로 읽습니다.

  1. lerp(c00.r, c10.r, tx)윗줄의 좌(c00)·우(c10)를 가로 가중치 tx로 섞음 → 윗줄에서 UV의 x위치에 해당하는 색 top.
  2. lerp(c01.r, c11.r, tx)아랫줄의 좌·우를 같은 tx로 섞음 → 아랫줄의 x위치 색 bottom.
  3. lerp(top, bottom, ty) — 이제 위에서 나온 두 값을 세로 가중치 ty로 섞음 → 최종 색. 가로로 두 번, 세로로 한 번 = 총 3번의 lerp, 그래서 “이중 선형”입니다.

수학적 형태(전개): 결과는 (1−tx)(1−ty)·c00 + tx(1−ty)·c10 + (1−tx)ty·c01 + tx·ty·c11입니다. 네 텍셀의 가중치 합은 항상 1이고, 각 가중치는 “대각선 반대편 텍셀이 차지하는 사각형 넓이”입니다. tx·tyc11의 가중치인데, 이는 UV점 기준 대각선 반대쪽인 c00이 만드는 작은 사각형의 넓이와 같습니다 — 가까울수록 크게, 멀수록 작게 섞이는 직관과 정확히 맞습니다.

5단계 — /255, toByte: 정수↔실수를 오가는 이유

c00.r 등은 0~255바이트 정수입니다. lerp는 이 정수들을 float로 섞습니다(정수 채널을 그냥 float로 계산). 그런데 마지막에 / 255.0f로 나눠 [0,1] 실수로 바꾼 뒤 toByte에 넘깁니다. 이는 이 프로젝트의 toByte가 “[0,1] 실수를 받아 0~255 바이트로 clamp·반올림”하는 규약이기 때문입니다. 즉 섞은값(0~255) → /255(0~1) → toByte(0~255)로 한 바퀴 돌아 범위를 안전하게 맞추고 반올림하는 것입니다.

네 채널(r,g,b,a)을 각각 독립적으로 보간합니다. 색뿐 아니라 알파도 보간되므로, 반투명 가장자리가 부드럽게 이어집니다.

손계산 예제 tx=0.25, ty=0.5이고 네 텍셀의 R값이 c00.r=0, c10.r=100, c01.r=200, c11.r=40이라고 합시다.
· top = lerp(0, 100, 0.25) = 0 + (100-0)·0.25 = 25
· bottom = lerp(200, 40, 0.25) = 200 + (40-200)·0.25 = 200 - 40 = 160
· final = lerp(25, 160, 0.5) = 25 + (160-25)·0.5 = 25 + 67.5 = 92.5
92.5/255 ≈ 0.3627 → toByte → 93 정도. nearest였다면 그냥 c00.r=0 하나만 나왔을 텐데, bilinear는 네 값을 위치에 맞게 섞어 중간값 93을 냅니다.
성능 bilinear는 픽셀당 텍스처 읽기 4회 + lerp 3회(×채널 4)라 nearest보다 확연히 무겁습니다. 하지만 GPU에는 텍스처 유닛에 하드웨어 bilinear가 내장돼 있어 Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR 한 줄이면 공짜에 가깝게 됩니다. 소프트웨어(CPU) 렌더러에서만 이 비용을 직접 부담합니다.
함정 확대 시 무조건 bilinear가 낫다고 오해하기 쉽습니다. 픽셀아트는 nearest가 정답입니다 — bilinear로 확대하면 선명한 도트가 흐릿하게 뭉개집니다. 반대로 사진·연속 톤은 nearest면 계단이 도드라집니다. “대상 이미지 성격에 맞춰 고른다”가 원칙입니다.
nearestbilinear
픽셀당 텍스처 읽기1회4회
확대 결과계단(블록), 선명부드러움, 흐림
적합 대상픽셀아트·UI사진·연속 톤
D3D11 FilterMIN_MAG_MIP_POINTMIN_MAG_MIP_LINEAR

4. 소프트웨어 버퍼를 D3D11 화면에 띄우기

지금까지는 결과를 파일로만 확인했습니다. 실시간으로 보려면 CPU로 그린 Framebuffer를 매 프레임 Direct3D 11 텍스처로 올리고, 화면 전체를 덮는 삼각형에 그 텍스처를 입혀 그리면 됩니다. 큰 흐름은 이렇습니다.

  1. 최초 1회: 매 프레임 갱신할 동적 텍스처와 그것을 셰이더에서 읽을 SRV, 그리고 샘플러를 만든다.
  2. 매 프레임: 소프트웨어로 한 장 다 그린 뒤, Map으로 텍스처 메모리를 잠그고 픽셀을 행 단위로 복사한다.
  3. 출력: 정점 버퍼 없이 SV_VertexID 3개로 풀스크린 삼각형을 그리고, 픽셀 셰이더가 우리 텍스처를 샘플링해 뿌린다.

디바이스/스왑체인 생성과 셰이더 파이프라인 전반은 3장(3D 그래픽스)4장(셰이더)에서 자세히 다룹니다. 여기서는 “CPU 픽셀을 화면에 올리는” 부분에 집중합니다.

upload.cpp (개념 발췌)cpp
using Microsoft::WRL::ComPtr; // <wrl/client.h>

// ── 최초 1회: 매 프레임 갱신할 동적 텍스처 + SRV ──
D3D11_TEXTURE2D_DESC td = {};
td.Width  = fb.width();
td.Height = fb.height();
td.MipLevels = 1;
td.ArraySize = 1;
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM;   // Color(R,G,B,A) 바이트 순서와 일치
td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_DYNAMIC;           // CPU가 매 프레임 덮어쓸 수 있게
td.BindFlags = D3D11_BIND_SHADER_RESOURCE;
td.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;

ComPtr<ID3D11Texture2D> tex;
device->CreateTexture2D(&td, nullptr, &tex);

ComPtr<ID3D11ShaderResourceView> srv;
device->CreateShaderResourceView(tex.Get(), nullptr, &srv);

// 최근접(픽셀아트) 샘플러 — D3D11_FILTER_MIN_MAG_MIP_LINEAR로 바꾸면 bilinear
D3D11_SAMPLER_DESC sd = {};
sd.Filter   = D3D11_FILTER_MIN_MAG_MIP_POINT;
sd.AddressU = sd.AddressV = sd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP;
ComPtr<ID3D11SamplerState> samp;
device->CreateSamplerState(&sd, &samp);

// ── 매 프레임: 소프트웨어로 그린 뒤 Map으로 픽셀을 복사해 올린다 ──
D3D11_MAPPED_SUBRESOURCE ms;
context->Map(tex.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &ms);
// GPU는 행마다 패딩(RowPitch)이 있을 수 있으므로 한 행씩 복사한다.
const uint32_t* srcPx = fb.pixels().data();
auto* dstBase = static_cast<uint8_t*>(ms.pData);
for (int y = 0; y < fb.height(); ++y) {
    std::memcpy(dstBase + static_cast<size_t>(y) * ms.RowPitch,
                srcPx  + static_cast<size_t>(y) * fb.width(),
                static_cast<size_t>(fb.width()) * 4); // 4바이트/픽셀
}
context->Unmap(tex.Get(), 0);

// 풀스크린 삼각형으로 출력: 정점 버퍼 없이 SV_VertexID 3개만 그린다
context->IASetInputLayout(nullptr);
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
context->VSSetShader(fullscreenVS.Get(), nullptr, 0);
context->PSSetShader(blitPS.Get(),       nullptr, 0);
context->PSSetShaderResources(0, 1, srv.GetAddressOf());
context->PSSetSamplers(0, 1, samp.GetAddressOf());
context->Draw(3, 0);   // 화면을 덮는 삼각형 1개 (3~4장 참고)

텍스처 서술자(D3D11_TEXTURE2D_DESC) 필드별 해부

D3D11_TEXTURE2D_DESC td = {};= {}집합 초기화로 구조체의 모든 필드를 0으로 채웁니다. D3D 서술자 구조체는 필드가 많아서, 0으로 밀어두고 필요한 것만 덮어 쓰는 게 안전한 관례입니다(0 = 기본/무플래그).

ComPtr, 그리고 &td / tex.Get()

ComPtr<ID3D11Texture2D> tex;스마트 포인터입니다. D3D 객체는 참조 카운트(COM)로 수명을 관리하는데, ComPtr이 소멸할 때 자동으로 Release()를 불러줘 메모리 누수를 막습니다. 직접 Release를 호출할 필요가 없습니다.

device->CreateTexture2D(&td, nullptr, &tex); — 인자를 하나씩 보면: &td는 “이런 스펙으로 만들라”는 서술자 주소, 둘째 nullptr은 “초기 픽셀 데이터 없음”(어차피 매 프레임 덮어씀), &tex는 만들어진 객체 포인터를 받아올 출력 위치입니다. ComPtr&를 쓰면 내부 포인터 슬롯을 넘겨 결과를 받습니다.

tex.Get()ComPtr이 감싼 원시(raw) 포인터를 꺼내는 것입니다 (소유권은 넘기지 않고 “빌려주기”). CreateShaderResourceView(tex.Get(), nullptr, &srv)는 이 텍스처를 셰이더에서 읽을 수 있는 뷰(SRV)로 만들어 srv에 받습니다. 셰이더는 텍스처를 직접 만지지 않고 항상 이 “뷰”를 통해 접근합니다.

샘플러 서술자 — nearest/bilinear를 API 한 줄로

앞 절에서 손으로 짠 nearest/bilinear가 여기서는 필드 하나로 압축됩니다.

2·3절에서 CPU로 직접 구현한 로직 전체가, GPU에서는 이 D3D11_SAMPLER_DESC 몇 줄로 하드웨어에 위임됩니다. 우리가 손으로 짜 본 이유는 “안에서 무슨 일이 일어나는지”를 이해하기 위함입니다.

매 프레임 업로드 — Map / 행 단위 memcpy / Unmap

context->Map(tex.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &ms); — GPU 텍스처 메모리를 잠가 CPU가 쓸 수 있는 포인터(ms.pData)를 얻습니다. 인자를 보면: 첫 tex.Get()은 대상, 둘째 0은 서브리소스 인덱스(밉레벨 0), 셋째 D3D11_MAP_WRITE_DISCARD는 “이전 내용은 버리고 통째로 새로 쓴다”는 힌트로, GPU가 이전 프레임 사용을 기다리지 않게 해 스톨(stall)을 피하는 핵심입니다. 넷째 0은 플래그 없음, &ms는 결과(포인터 + RowPitch)를 받을 구조체입니다.

ms.RowPitch가 이 절의 함정입니다. GPU는 한 행을 정렬(예: 256바이트 배수)하려고 행 끝에 패딩을 붙일 수 있어, RowPitch가 우리 width×4보다 클 수 있습니다. 그래서 버퍼 전체를 한 번에 memcpy하면 안 되고, 한 행씩 옮겨야 합니다.

context->Unmap(tex.Get(), 0); — 잠금을 풀어 GPU가 다시 텍스처를 쓸 수 있게 합니다. MapUnmap은 반드시 짝을 맞춰야 합니다.

주의 D3D11의 텍스처 UV는 좌상단이 원점이라, 좌상단 원점 프레임버퍼가 방향까지 그대로 맞습니다. (OpenGL이라면 좌하단 원점이라 상하가 뒤집혀 v1-v로 매핑해야 했습니다.) 또한 Map이 돌려주는 RowPitchwidth×4보다 클 수 있으니, 버퍼 전체를 한 번에 memcpy하지 말고 반드시 행 단위로 복사하세요.
성능 매 프레임 CPU→GPU로 텍스처 전체를 올리는 것은 대역폭이 큰 작업입니다. D3D11_MAP_WRITE_DISCARD를 쓰는 이유가 바로 이 업로드가 이전 프레임의 GPU 작업과 겹치지 않게(더블 버퍼링) 해서 파이프라인 스톨을 피하는 것입니다. 순수 GPU 렌더러라면 이런 업로드 자체가 없으니, 소프트웨어 렌더러 특유의 비용임을 기억하세요.

5. 풀스크린 삼각형과 HLSL 셰이더

무엇을 만드나: 정점 버퍼(정점 데이터)를 하나도 넘기지 않고, 정점 셰이더가 SV_VertexID(0,1,2) 만으로 화면을 덮는 큰 삼각형을 계산으로 만들어냅니다.
왜 이렇게까지: 화면 전체를 덮는 데는 사실 삼각형 하나면 충분합니다. 사각형(삼각형 2개)보다 정점이 적고, 두 삼각형 경계선에서 생기는 중복 픽셀 처리도 없어 더 효율적입니다. 정점 버퍼가 없으니 준비 코드도 사라집니다.

blit.hlsl (정점 · 픽셀 셰이더)HLSL
// ── 정점 셰이더: 정점 버퍼 없이 화면을 덮는 삼각형 생성 ──
struct VSOut {
    float4 pos : SV_Position;
    float2 uv  : TEXCOORD0;
};

// id = 0,1,2 세 정점으로 클립 공간 [-1,3]의 큰 삼각형을 만든다.
// 잘려서 화면([-1,1])을 정확히 덮고, UV는 좌상단(0,0)~우하단(1,1).
VSOut VSMain(uint id : SV_VertexID) {
    VSOut o;
    float2 uv = float2((id << 1) & 2, id & 2); // (0,0),(2,0),(0,2)
    o.uv  = uv;
    o.pos = float4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
    return o;
}

// ── 픽셀 셰이더: 프레임버퍼 텍스처를 그대로 샘플링 ──
Texture2D    srcTex : register(t0);
SamplerState samp   : register(s0);

float4 PSMain(VSOut i) : SV_Target {
    return srcTex.Sample(samp, i.uv);
}

출력 구조체 VSOut과 시맨틱

float4 pos : SV_Position; — 콜론 뒤 SV_Position시스템 값 시맨틱으로, “이 float4클립 공간 정점 위치다”라고 파이프라인에 알립니다. 래스터라이저가 이 값으로 삼각형을 화면 픽셀로 쪼갭니다. SV_가 붙은 시맨틱은 GPU가 특별 취급합니다.

float2 uv : TEXCOORD0; — 이건 사용자 정의 시맨틱으로, 정점 셰이더가 계산한 UV를 픽셀 셰이더로 넘겨주는 통로입니다. 래스터라이저는 삼각형 내부 각 픽셀에 대해 세 정점의 UV를 보간해 넣어줍니다. 그래서 픽셀 셰이더에는 “그 픽셀에 해당하는 UV”가 들어옵니다.

UV 생성 비트 트릭 — (id << 1) & 2, id & 2

float2 uv = float2((id << 1) & 2, id & 2); — 정점 id 3개로 UV (0,0), (2,0), (0,2)를 만드는 정수 비트 연산입니다. 하나씩 대입해 봅니다.

idid<<1 (×2)(id<<1)&2 → uid&2 → vUV
00 (000)00(0, 0)
12 (010)20(2, 0)
24 (100)02(0, 2)

왜 UV가 [0,2]까지 가나? 삼각형을 화면([-1,1])보다 일부러 크게 만들어 화면을 확실히 덮고, 넘치는 부분은 클리핑으로 잘라내기 때문입니다. 화면을 딱 덮는 지점의 UV는 자연히 [0,1] 안에 들어옵니다.

UV → 클립 공간 위치 매핑

o.pos = float4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0); — UV [0,1](텍스처 공간, V는 위→아래)을 클립 공간 [-1,1](Y는 위→아래가 아니라 위가 +1)로 옮깁니다.

기하 직관 세 정점의 클립 위치를 실제로 찍어 보면 (-1,+1)(좌상), (+3,+1)(우상 밖), (-1,-3)(좌하 밖)입니다. 이 거대한 직각삼각형이 화면 사각형 [-1,1]²를 완전히 덮고, 화면을 벗어난 두 꼭짓점은 GPU가 잘라냅니다. 화면에 남는 부분의 UV는 정확히 [0,1]²가 되어 텍스처가 화면에 딱 맞게 깔립니다.

픽셀 셰이더 — Sample 한 줄

Texture2D srcTex : register(t0);는 우리 텍스처(SRV)를 t0 슬롯에, SamplerState samp : register(s0);는 샘플러를 s0 슬롯에 묶겠다는 선언입니다. C++ 쪽 PSSetShaderResources(0, …)·PSSetSamplers(0, …)슬롯 0과 정확히 짝을 이룹니다.

return srcTex.Sample(samp, i.uv); — 픽셀에 보간돼 들어온 i.uv로 텍스처를 샘플링합니다. Sample(samp, uv)2·3절에서 손으로 짠 nearest/bilinear를 GPU 하드웨어가 대신 수행하는 것과 정확히 같습니다 — 어느 방식일지는 sampFilter가 결정합니다. 결과 float4(RGBA, 각 0~1)를 SV_Target으로 돌려주면 그 값이 렌더 타겟(화면)에 기록됩니다.

함정 register(t0)/register(s0)의 번호와 C++의 PSSetShaderResources/ PSSetSamplers 슬롯 번호가 어긋나면 셰이더가 엉뚱한(또는 비어 있는) 리소스를 읽어 화면이 까맣게 나옵니다. 컴파일 에러가 아니라 런타임에 조용히 검게 나오는 유형이라 디버깅이 까다롭습니다. 슬롯 번호를 늘 짝지어 확인하세요.

정리 — CPU 구현과 D3D11 API의 1:1 지도

이 페이지에서 손으로 짠 모든 조각은 실무 D3D11 파이프라인의 어딘가에 정확히 대응합니다.

우리가 손으로 짠 것D3D11 대응
blitSprite (알파 블렌드 복사)텍스처 쿼드 그리기 + D3D11_BLEND_DESC
UV clamp (범위 가두기)D3D11_TEXTURE_ADDRESS_CLAMP
sampleNearestD3D11_FILTER_MIN_MAG_MIP_POINT
sampleBilinear (4텍셀 + 3 lerp)D3D11_FILTER_MIN_MAG_MIP_LINEAR
프레임버퍼 픽셀 배열ID3D11Texture2D (DYNAMIC) + SRV
행 단위 픽셀 복사Map/Unmap + RowPitch
화면 덮기(수동)풀스크린 삼각형 + Draw(3, 0)
샘플링 호출HLSL Texture2D.Sample(samp, uv)

직접 구현은 원리를 이해하기 위한 학습용입니다. 실무에서는 GPU 하드웨어 샘플러와 검증된 파이프라인을 쓰는 것이 성능·정확성 모두에서 정답입니다. 하지만 그 “한 줄 API” 뒤에서 정확히 무슨 계산이 일어나는지를 아는 것과 모르는 것은, 화질 버그를 만났을 때 하늘과 땅 차이입니다.