스프라이트 · 텍스처 샘플링 — 한 줄 한 줄 직접 구현
스프라이트(sprite)는 화면에 붙이는 작은 이미지 조각입니다. 캐릭터, 아이콘, 글자 하나까지 전부 스프라이트로 볼 수 있죠. 이 페이지에서는 (1) 이미지 조각을 대상 화면에 알파 블렌드로 붙이는 블릿(blit), (2) 텍스처를 UV 좌표로 읽어오는 샘플링을 다룹니다. 샘플링에는 가장 가까운 텍셀 하나만 쓰는 nearest와, 이웃 네 개를 거리로 섞는 bilinear 두 방식이 있는데, 확대·축소할 때 이 선택이 화질을 결정합니다. 마지막으로 이렇게 CPU로 그린 프레임버퍼를 실제로 Direct3D 11 화면에 띄우는 코드까지, 함수 시그니처와 각 줄, 수식의 각 성분을 하나하나 뜯어보고 D3D11 API에 1:1로 대응시켜 봅니다.
v' = v * M(HLSL mul(v, M)),
행 우선 저장, 왼손 좌표계, 클립 z 범위 [0,1],
텍스처 V축은 위→아래(좌상단이 원점). 이 페이지의 샘플링 계산 자체는 좌표계와 무관하지만,
UV의 V 방향은 이 규약(위→아래)을 따릅니다. 아래에서 다시 짚습니다.
0. 무엇을, 왜 만드나
앞 절들에서 우리는 Framebuffer(픽셀 배열)와 blendPixel(알파 합성)을 만들었습니다.
스프라이트는 그 위에 얹는 “이미지를 이미지 위에 붙이는” 아주 실용적인 연산입니다.
게임 화면 하나는 사실 배경 스프라이트 위에 캐릭터 스프라이트, 그 위에 UI 스프라이트를 차곡차곡
블릿해서 완성됩니다.
- 블릿(blit) = Block Image Transfer. 소스 이미지의 사각형 영역을 대상의 특정 위치로 복사(합성)하는 것.
- 텍스처(texture) = 샘플링해서 읽는 대상 이미지. 여기서는 우리
Framebuffer가 곧 텍스처입니다. - UV 좌표 = 텍스처를
[0,1]×[0,1]로 정규화한 좌표. 픽셀 개수에 무관하게 “왼쪽 위 = (0,0), 오른쪽 아래 = (1,1)”로 위치를 가리킵니다. - 텍셀(texel) = texture element. 텍스처를 이루는 낱개 픽셀. “화면 픽셀(pixel)”과 구분하려고 부르는 이름입니다.
핵심 질문은 하나입니다: “UV가 텍셀과 텍셀 사이의 애매한 위치를 가리키면 어떤 색을 돌려줄까?” 가장 가까운 텍셀 하나를 고르면 nearest, 주변 네 텍셀을 거리로 섞으면 bilinear입니다. 이 페이지의 절반은 바로 이 “애매한 위치를 색으로 바꾸는 규칙”을 성분 단위로 해부하는 데 씁니다.
- 소스 이미지의 서브 사각형을 알파 블렌드로 대상 위치에 붙인다 (
blitSprite). - UV
[0,1]로 텍스처를 nearest 샘플링한다 (sampleNearest). - 이웃 4개를 섞는 bilinear 샘플링으로 부드럽게 만든다 (
sampleBilinear). - CPU 프레임버퍼를 D3D11 동적 텍스처로 올려 화면에 띄운다 (업로드 + 풀스크린 삼각형 + HLSL).
1. 스프라이트 블릿 — 이미지를 이미지 위에 붙이기
무엇을 만드나: 소스 이미지 src의 (sx, sy)에서 시작하는 sw×sh 크기
사각형을, 대상 이미지 dst의 (dx, dy) 위치에 알파 블렌드하며 복사합니다.
왜 필요한가: 스프라이트 시트(여러 그림을 한 장에 모아둔 이미지)에서 “걷는 3번째 프레임”만
잘라서 화면 특정 위치에 붙이는 것이 게임 렌더링의 기본기입니다. 서브 사각형 좌표
(sx, sy, sw, sh)가 바로 “스프라이트 시트에서 어느 칸을 쓸지”를 정합니다.
- 소스 영역을
y(세로) 바깥,x(가로) 안쪽 이중 루프로 훑는다. - 소스에서 픽셀 하나
c를 읽는다. - 그 픽셀을 대상의 대응 위치에
blendPixel로 알파를 반영해 찍는다. - 경계(화면 밖 / 소스 밖)는
blendPixel·inBounds가 알아서 잘라내므로 여기서는 신경 쓰지 않는다.
#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)
— 이 한 줄에도 결정이 여럿 담겨 있습니다.
-
inline: 이 함수는 헤더(.h)에 정의가 통째로 들어 있습니다. 헤더를 여러.cpp에서#include하면 같은 함수 정의가 여러 번 나타나 중복 정의(ODR 위반)로 링크 에러가 납니다.inline은 “같은 정의가 여러 번 보여도 하나로 합쳐서 봐줘”라고 링커에게 허락하는 표시입니다. (성능상 “함수 호출을 없앤다”는 뜻은 부차적입니다.) -
void: 결과를dst에 직접 그려 넣기 때문에 따로 반환할 값이 없습니다. 부작용(side effect)으로dst를 바꾸는 함수라는 뜻입니다. -
Framebuffer& dst: 비-const 참조(&)입니다. 참조인 이유는 두 가지 — (1) 프레임버퍼는 픽셀 배열을 통째로 가진 큰 객체라 값 복사하면 비쌉니다. 참조면 복사가 없습니다. (2) 우리가 원본을 고쳐 써야 하므로 사본이 아니라 진짜 대상이 넘어와야 합니다. 그래서const를 안 붙였습니다. -
const Framebuffer& src: 이쪽은 const 참조입니다. 소스는 읽기만 하고 절대 바꾸지 않겠다는 약속(const)이자, 큰 객체의 복사를 피하는 참조(&)입니다. 이렇게 두면 실수로 소스를 건드리는 코드가 컴파일 단계에서 막힙니다. -
int dx, dy: 대상(destination) 안에서 스프라이트의 왼쪽 위 모서리가 놓일 위치. -
int sx, sy: 소스(source) 안에서 잘라올 영역의 왼쪽 위 모서리. -
int sw, sh: 잘라올 영역의 가로·세로 크기(source width/height). 픽셀 단위입니다.
루프 해부 — 왜 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); — 소스의 절대 좌표에서 픽셀 하나를 읽어 값(사본)으로
받습니다. Color는 R,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입니다.
#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.2면 1.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); — 이 한 줄을 조각조각 봅니다.
-
u * tex.width():[0,1]범위의 UV를[0, width]픽셀 좌표로 스케일 업합니다. 폭이 256이면u=0.5 → 128.0. -
(int)( … ): C++의float→int캐스트는 소수부를 버리는(0쪽으로) 절단입니다.127.9 → 127,128.0 → 128. 이 절단 덕분에 “한 텍셀이 차지하는 구간 안의 어떤 UV든 같은 정수 인덱스로 떨어져” nearest 특유의 계단(블록) 모양이 생깁니다. -
std::min( …, tex.width() - 1): 왜 필요할까요?u가 정확히1.0이면u*width = width가 되어 인덱스가width(범위 밖!)가 됩니다. 유효한 마지막 인덱스는width-1이므로,min으로 눌러 배열 범위 초과를 막습니다. 이것은 clamp(2단계)와 별개의 두 번째 안전장치입니다.
y도 완전히 대칭입니다: v축은 위→아래(D3D 규약)이므로 v=0이
맨 윗줄, v=1이 맨 아랫줄입니다. OpenGL이라면 여기서 v를 1-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 지점의 텍셀 하나를
그대로 돌려줍니다. 섞음(보간) 없이 딱 하나입니다.
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) 보조 함수부터 봅니다.
// 채널별 선형 보간 보조
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회).
// 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비트]입니다:
c00 = at(x0, y0)— 좌상단 (u쪽 0, v쪽 0)c10 = at(x0+1, y0)— 우상단 (u쪽 1, v쪽 0)c01 = at(x0, y0+1)— 좌하단 (u쪽 0, v쪽 1)c11 = at(x0+1, y0+1)— 우하단 (u쪽 1, v쪽 1)
이름의 첫 숫자는 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)선형” 보간의 전부입니다. 안에서 밖으로 읽습니다.
-
lerp(c00.r, c10.r, tx)— 윗줄의 좌(c00)·우(c10)를 가로 가중치tx로 섞음 → 윗줄에서 UV의 x위치에 해당하는 색top. -
lerp(c01.r, c11.r, tx)— 아랫줄의 좌·우를 같은tx로 섞음 → 아랫줄의 x위치 색bottom. -
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·ty는 c11의 가중치인데, 이는 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을 냅니다.
Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR 한 줄이면 공짜에 가깝게 됩니다.
소프트웨어(CPU) 렌더러에서만 이 비용을 직접 부담합니다.
| nearest | bilinear | |
|---|---|---|
| 픽셀당 텍스처 읽기 | 1회 | 4회 |
| 확대 결과 | 계단(블록), 선명 | 부드러움, 흐림 |
| 적합 대상 | 픽셀아트·UI | 사진·연속 톤 |
| D3D11 Filter | MIN_MAG_MIP_POINT | MIN_MAG_MIP_LINEAR |
4. 소프트웨어 버퍼를 D3D11 화면에 띄우기
지금까지는 결과를 파일로만 확인했습니다. 실시간으로 보려면 CPU로 그린 Framebuffer를
매 프레임 Direct3D 11 텍스처로 올리고, 화면 전체를 덮는 삼각형에 그 텍스처를
입혀 그리면 됩니다. 큰 흐름은 이렇습니다.
- 최초 1회: 매 프레임 갱신할 동적 텍스처와 그것을 셰이더에서 읽을 SRV, 그리고 샘플러를 만든다.
- 매 프레임: 소프트웨어로 한 장 다 그린 뒤,
Map으로 텍스처 메모리를 잠그고 픽셀을 행 단위로 복사한다. - 출력: 정점 버퍼 없이
SV_VertexID3개로 풀스크린 삼각형을 그리고, 픽셀 셰이더가 우리 텍스처를 샘플링해 뿌린다.
디바이스/스왑체인 생성과 셰이더 파이프라인 전반은 3장(3D 그래픽스)과 4장(셰이더)에서 자세히 다룹니다. 여기서는 “CPU 픽셀을 화면에 올리는” 부분에 집중합니다.
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 = 기본/무플래그).
Width / Height: 텍스처 크기를 우리 프레임버퍼와 똑같이 맞춥니다. 다르면 복사 시 어긋납니다.-
MipLevels = 1: 밉맵(축소용 계단식 축소본)을 안 씁니다. 화면 덮기 용도라 축소가 없어 1레벨이면 충분합니다. ArraySize = 1: 텍스처 배열이 아니라 낱장 한 장.-
Format = DXGI_FORMAT_R8G8B8A8_UNORM: 채널당 8비트, R,G,B,A 순서, UNORM(0~255를 셰이더에서 0.0~1.0으로 자동 정규화). 우리Color의 바이트 순서와 정확히 일치해야 색이 안 뒤집힙니다. (엉뚱한B8G8R8A8을 쓰면 R↔B가 뒤바뀝니다.) SampleDesc.Count = 1: MSAA 없음(샘플 1개). 텍스처로 읽을 거라 멀티샘플이면 안 됩니다.-
Usage = D3D11_USAGE_DYNAMIC: CPU가 자주 덮어쓰고 GPU가 읽는 용도. 매 프레임 픽셀을 올릴 것이므로 이 용도가 딱 맞습니다. BindFlags = D3D11_BIND_SHADER_RESOURCE: 셰이더에서 읽기(SRV)용으로 묶겠다는 표시.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE: CPU가 쓰기 접근을 하겠다(=Map으로 잠글 수 있게). DYNAMIC과 짝을 이룹니다.
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가 여기서는 필드 하나로 압축됩니다.
-
Filter = D3D11_FILTER_MIN_MAG_MIP_POINT→ nearest.MIN_MAG_MIP_LINEAR로 바꾸면 bilinear(밉맵까지 켜면 trilinear). 이름의 MIN/MAG/MIP은 각각 축소·확대·밉 사이 필터를 뜻합니다. -
AddressU = AddressV = AddressW = D3D11_TEXTURE_ADDRESS_CLAMP→ 우리std::clamp와 정확히 같은 CLAMP 주소 지정. UV가[0,1]을 넘으면 가장자리에 들러붙습니다. (WRAP이면 반복,MIRROR면 거울 반사.) W축은 3D 텍스처용이지만 함께 지정해 둡니다.
즉 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하면 안 되고,
한 행씩 옮겨야 합니다.
dstBase + y * ms.RowPitch: 대상에서y번째 행의 시작(패딩 포함 간격).srcPx + y * fb.width(): 소스에서y번째 행의 시작.srcPx는uint32_t*(픽셀=4바이트 1칸)라 ×width면 픽셀 단위로 정확히 한 행 넘어갑니다.fb.width() * 4: 실제로 복사할 바이트 수(픽셀당 4바이트). 패딩은 복사하지 않습니다.
context->Unmap(tex.Get(), 0); — 잠금을 풀어 GPU가 다시 텍스처를 쓸 수 있게 합니다.
Map과 Unmap은 반드시 짝을 맞춰야 합니다.
v를 1-v로 매핑해야 했습니다.)
또한 Map이 돌려주는 RowPitch는 width×4보다 클 수 있으니, 버퍼 전체를
한 번에 memcpy하지 말고 반드시 행 단위로 복사하세요.
D3D11_MAP_WRITE_DISCARD를 쓰는 이유가 바로 이 업로드가 이전 프레임의 GPU 작업과 겹치지
않게(더블 버퍼링) 해서 파이프라인 스톨을 피하는 것입니다. 순수 GPU 렌더러라면 이런
업로드 자체가 없으니, 소프트웨어 렌더러 특유의 비용임을 기억하세요.
5. 풀스크린 삼각형과 HLSL 셰이더
무엇을 만드나: 정점 버퍼(정점 데이터)를 하나도 넘기지 않고, 정점 셰이더가
SV_VertexID(0,1,2) 만으로 화면을 덮는 큰 삼각형을 계산으로 만들어냅니다.
왜 이렇게까지: 화면 전체를 덮는 데는 사실 삼각형 하나면 충분합니다.
사각형(삼각형 2개)보다 정점이 적고, 두 삼각형 경계선에서 생기는 중복 픽셀 처리도 없어 더 효율적입니다.
정점 버퍼가 없으니 준비 코드도 사라집니다.
// ── 정점 셰이더: 정점 버퍼 없이 화면을 덮는 삼각형 생성 ──
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)를 만드는 정수 비트 연산입니다. 하나씩 대입해 봅니다.
| id | id<<1 (×2) | (id<<1)&2 → u | id&2 → v | UV |
|---|---|---|---|---|
| 0 | 0 (000) | 0 | 0 | (0, 0) |
| 1 | 2 (010) | 2 | 0 | (2, 0) |
| 2 | 4 (100) | 0 | 2 | (0, 2) |
<< 1은 왼쪽 시프트 1비트 = ×2.& 2는 2의 자리 비트만 남기는 마스크(0b10). 결과는 항상 0 또는 2입니다.id=1:1<<1 = 2,2&2 = 2 → u=2;1&2 = 0 → v=0.id=2:2<<1 = 4 = 0b100,4&2 = 0 → u=0;2&2 = 2 → v=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)로 옮깁니다.
-
x:
uv.x*2-1—u=0 → x=-1(화면 왼쪽),u=1 → x=+1(오른쪽). 단순[0,1]→[-1,1]선형 변환입니다. -
y(부호 뒤집기):
1 - uv.y*2—v=0 → y=+1(화면 위),v=1 → y=-1(아래). UV의 V는 아래로 갈수록 커지는데 클립 공간 Y는 위로 갈수록 커지므로, 부호를 뒤집어 텍스처 위쪽이 화면 위쪽에 오게 맞춥니다. 이 한 줄이 “좌상단 원점 텍스처”와 “Y-up 클립 공간”을 이어 줍니다. -
z = 0.0: 클립 z 범위가 [0,1](D3D 규약)이므로
0은 가장 앞 (near)입니다. 풀스크린 삼각형은 깊이가 의미 없으니 맨 앞에 둡니다. (OpenGL은[-1,1]이라 값이 달랐습니다.) -
w = 1.0: 원근 나눗셈에 쓰이는 성분. 2D라 원근이 없으므로
1이면pos.xyz가 그대로 NDC가 됩니다.
(-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
하드웨어가 대신 수행하는 것과 정확히 같습니다 — 어느 방식일지는 samp의
Filter가 결정합니다. 결과 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 |
sampleNearest | D3D11_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” 뒤에서 정확히 무슨 계산이 일어나는지를 아는 것과 모르는 것은, 화질 버그를 만났을 때 하늘과 땅 차이입니다.