← 개념 · 데모 가이드 | 구현 허브 | 2장. 2D 그래픽스 | 픽셀과 색상 — 프레임버퍼
IMPLEMENTATION · CHAPTER 2

픽셀과 색상 — 프레임버퍼 — 한 줄 한 줄 직접 구현

모든 렌더링의 최종 출력은 결국 픽셀 한 개입니다. 이 페이지에서는 GPU에 맡기기 전에, 메모리 위에 이미지 한 장을 담는 프레임버퍼를 손으로 직접 만들어 봅니다. 색을 32비트 정수 하나로 패킹/언패킹하는 비트 연산부터, 픽셀을 찍고 지우는 버퍼 조작, 결과를 PPM 파일로 저장해 눈으로 확인하는 것까지, 코드 한 줄·비트 하나·성분 하나를 입문자 눈높이로 낱낱이 해부합니다. 마지막엔 이 소프트웨어 버퍼를 Direct3D 11 텍스처로 올려 화면에 실시간으로 띄우는 방법(개념)까지 이어집니다.

0. 왜 "프레임버퍼"부터 시작하나

화면(모니터)은 결국 아주 촘촘한 격자(grid)입니다. 가로 폭(width)개, 세로 높이(height)개의 작은 사각형 칸이 있고, 그 칸 하나하나를 픽셀(pixel, picture element)이라고 부릅니다. "그림을 그린다"는 것은 결국 "각 칸에 어떤 색을 넣을지 정하는 것"입니다. 3D 렌더링이든, 폰트 렌더링이든, 게임 화면이든, 파이프라인의 맨 끝에서 하는 일은 언제나 똑같습니다 — 어떤 픽셀에 어떤 색을 쓸 것인가.

그 "색을 써 두는 메모리 공간"이 바로 프레임버퍼(framebuffer)입니다. 말 그대로 "한 프레임(화면 한 장)을 담아 두는 버퍼(임시 저장소)"라는 뜻이죠. GPU 안에도 프레임버퍼가 있지만, 우리는 그 마법을 이해하기 위해 일반 RAM 위에 우리만의 프레임버퍼를 직접 만들어 볼 겁니다. 그러면 "픽셀을 찍는다"는 것이 실제로는 배열의 한 칸에 숫자를 대입하는 것임을 두 눈으로 확인할 수 있습니다.

  1. 색을 uint32_t 하나로 패킹/언패킹하는 헬퍼를 만든다(비트 연산의 이해).
  2. width * height 크기의 std::vector를 픽셀 저장소로 잡는다(1차원 배열로 2차원 이미지 표현).
  3. setPixel / clear 로 버퍼를 조작한다(경계 검사와 클리핑).
  4. 결과를 PPM(P6) 파일로 저장해 눈으로 확인한다(가장 단순한 무손실 포맷).
  5. (응용) 이 버퍼를 D3D11 동적 텍스처로 올려 화면에 실시간 표시한다.
규약 이 시리즈 전체에서 이미지/화면 좌표는 좌상단이 원점 (0,0)이고, x는 오른쪽으로, y는 아래로 증가합니다. 이는 Direct3D 11의 텍스처 좌표(UV)와도 방향이 일치합니다(V가 위→아래). 수학 시간에 배운 "좌하단 원점, y는 위로"와 반대이므로, 처음엔 반드시 헷갈립니다. 이 페이지 곳곳에서 이 규약을 다시 짚겠습니다.

1. 색을 숫자 하나로 — RGBA8 패킹/언패킹

무엇을 만드나: 빨강(R)·초록(G)·파랑(B)·투명도(A) 네 값을 32비트 정수 하나(uint32_t)에 욱여넣고, 다시 꺼내는 헬퍼 함수들을 만듭니다. 왜 필요한가: 픽셀 하나를 바이트 4개(구조체)로 다루는 것보다, 정수 4바이트 한 덩어리로 다루면 복사·비교·메모리 접근이 훨씬 빠르고, GPU 텍스처 포맷(DXGI_FORMAT_R8G8B8A8_UNORM)의 바이트 배치와도 정확히 일치시킬 수 있기 때문입니다.

먼저 알아야 할 개념: 채널당 8비트(=1바이트)입니다. 8비트로 표현할 수 있는 정수는 0~255(총 256단계)죠. 그래서 이 방식을 RGBA8 또는 RGBA8888이라고 부릅니다. 네 채널 × 8비트 = 32비트 = 4바이트 = uint32_t 하나에 딱 맞습니다.

color.hcpp
#pragma once
#include <cstdint>
#include <algorithm>

// 32비트 안에 R,G,B,A를 담는다. 메모리 배치는 리틀엔디언 기준
// 0xAABBGGRR 형태(바이트 순서: R,G,B,A)로 저장해 두면
// uint8_t 4바이트로 재해석했을 때 R이 먼저 온다.
struct Color {
    uint8_t r = 0, g = 0, b = 0, a = 255;
};

// float[0,1] -> 8비트로 클램프 변환
inline uint8_t toByte(float v) {
    v = std::clamp(v, 0.0f, 1.0f);
    return static_cast<uint8_t>(v * 255.0f + 0.5f); // 반올림
}

// Color -> uint32_t 패킹 (R이 최하위 바이트)
inline uint32_t pack(Color c) {
    return  (uint32_t)c.r
         | ((uint32_t)c.g << 8)
         | ((uint32_t)c.b << 16)
         | ((uint32_t)c.a << 24);
}

// uint32_t -> Color 언패킹
inline Color unpack(uint32_t p) {
    Color c;
    c.r = (uint8_t)( p        & 0xFF);
    c.g = (uint8_t)((p >> 8)  & 0xFF);
    c.b = (uint8_t)((p >> 16) & 0xFF);
    c.a = (uint8_t)((p >> 24) & 0xFF);
    return c;
}

inline Color rgb(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 255) {
    return Color{ r, g, b, a };
}

1-1. struct Color — 색 하나의 논리적 표현

struct Color { uint8_t r = 0, g = 0, b = 0, a = 255; }; 는 색 하나를 사람이 이해하기 쉬운 네 필드로 표현한 구조체입니다. 한 줄씩 뜯어봅니다.

용어 알파(A)는 "이 색이 배경을 얼마나 가리는가"를 뜻하는 불투명도입니다. 255면 완전 불투명(뒤가 안 보임), 0이면 완전 투명(색이 없는 것과 같음), 128이면 반투명(뒤가 절반 비침)입니다. 알파를 실제로 섞는 방법(알파 블렌딩)은 이 장의 별도 섹션에서 다룹니다.

1-2. toByte — 실수 [0,1]을 정수 [0,255]로

계산은 보통 실수(float) [0,1] 범위에서 합니다(예: 0.5 = 절반 밝기). 하지만 저장은 정수 [0,255]여야 하죠. 그 변환을 안전하게 해 주는 것이 toByte입니다.

코드 조각의미
float v입력은 0.0~1.0을 기대(밝기 비율). 값으로 받으므로 원본이 바뀌지 않음.
std::clamp(v, 0.0f, 1.0f)범위를 강제로 [0,1]로 자름. 1.2 → 1.0, -0.3 → 0.0. 넘치는 값이 정수 변환에서 폭주(오버플로)하는 걸 막음.
v * 255.0f[0,1]을 [0,255] 크기로 늘림. 0.5 → 127.5.
+ 0.5f반올림. C++의 정수 캐스팅은 소수점 아래를 버림(truncate)하므로, 0.5를 더하고 버리면 반올림과 같아짐. 127.5 → 128.0 → 128.
static_cast<uint8_t>(...)실수를 8비트 정수로 변환(소수점 버림). 위에서 [0,255]로 이미 잘랐으니 안전.

손계산 예: 밝기 0.5를 넣으면 0.5 × 255 = 127.5, 반올림 위해 +0.5 = 128.0, 캐스팅하면 128이 됩니다. 만약 +0.5가 없었다면 127.5를 버려서 127이 나옵니다 — 한 단계 어두워지죠. 이 사소한 +0.5f 하나가 반복 변환에서 색이 조금씩 어두워지는 걸 막아 줍니다.

함정 clamp를 빼먹으면 큰일 납니다. 예컨대 밝기 1.5를 그냥 캐스팅하면 1.5 × 255 = 382.5uint8_t는 256 이상을 담을 수 없어 382 mod 256 = 126이 되어, 밝아야 할 색이 갑자기 중간 회색으로 뒤집힙니다(오버플로 랩어라운드). 조명 계산에서 흔히 1을 넘는 값이 나오므로 clamp는 필수입니다.

1-3. pack — 네 바이트를 정수 하나로 (비트 시프트 & OR)

이 함수가 이 섹션의 핵심입니다. R·G·B·A 네 개의 uint8_t를 겹치지 않게 uint32_t의 서로 다른 자리에 끼워 넣습니다. 32비트를 8비트씩 네 칸으로 나눠 생각하면 됩니다:

[비트 31..24] = A · [23..16] = B · [15..8] = G · [7..0] = R

줄 단위로 봅니다. c.r은 최하위 8비트(자리 이동 없음), 나머지는 왼쪽 시프트(<<)로 자기 자리로 밀어 올립니다.

손계산 예: rgb(255, 128, 0)(주황, 알파 기본 255)을 패킹해 봅니다.

채널값(10진)값(16진)시프트기여분(16진)
R2550xFF<< 00x000000FF
G1280x80<< 80x00008000
B00x00<< 160x00000000
A2550xFF<< 240xFF000000

네 기여분을 OR로 합치면 0xFF008000이 나옵니다. 이 값을 바이트 순서(리틀엔디언, 낮은 주소부터)로 메모리에 펼치면 R, G, B, A = FF, 80, 00, FF 순서가 되어, 아래에서 설명할 GPU 포맷과 정확히 맞아떨어집니다.

1-4. unpack — 정수 하나를 네 바이트로 (시프트 & 마스크)

pack의 역연산입니다. 정수에서 원하는 8비트만 오른쪽으로 끌어내리고(>>), 마스크(& 0xFF)로 그 8비트만 골라냅니다.

검산: 방금 만든 0xFF008000을 언패킹하면 &0xFF = 0x00? — 잠깐, R은 최하위이므로 0xFF008000 & 0xFF = 0x00… 이 아니라 실제 정수는 0xFF008000이 아니라 R=FF였으므로 0xFF0080FF여야 맞습니다. 다시: 위 표에서 R 기여분은 0x000000FF였습니다. 최종값은 0xFF000000 | 0x00008000 | 0x000000FF = 0xFF0080FF. 이제 언패킹하면 R = 0xFF, G = 0x80, B = 0x00, A = 0xFF로 원래대로 완벽히 복원됩니다. (앞 예시의 0xFF008000은 오타로, 정확한 값은 0xFF0080FF입니다.)

함정 엔디언(endianness) 혼동에 주의하세요. 위 배치는 x86/x64 같은 리틀엔디언 시스템에서 "정수의 최하위 바이트가 메모리의 낮은 주소"에 놓이는 성질을 이용합니다. 그래서 uint32_t 배열을 uint8_t*로 재해석하면 메모리에 R, G, B, A 순으로 보입니다. 만약 코드에서 이 정수 하나만 계속 pack/unpack으로 다룬다면 엔디언을 신경 쓸 필요가 없지만, 파일에 정수 그대로 덤프하거나 다른 아키텍처와 교환할 때는 바이트 순서가 달라질 수 있으니, 아래 PPM 저장처럼 채널을 명시적으로 한 바이트씩 써 주는 방식이 이식성 면에서 안전합니다.

1-5. rgb() — 간편 생성 헬퍼

Color{ r, g, b, a } 처럼 중괄호로 매번 만드는 대신, 함수처럼 rgb(255, 80, 80)로 부를 수 있게 해 주는 얇은 래퍼입니다. a = 255기본 인자(default argument)라 알파를 생략하면 자동으로 불투명해집니다. Color{ r, g, b, a }집합 초기화(aggregate initialization)로, 멤버 순서대로 값을 그대로 채워 넣는 문법입니다.

성능 pack·unpack·toByte·rgb에 붙은 inline은 "이 함수 호출을 호출 자리에 펼쳐 넣어도 좋다"는 힌트입니다. 픽셀 루프 안에서 수백만 번 불리는 초경량 함수이므로, 함수 호출 비용(스택 세팅 등)을 없애는 게 중요합니다. 헤더에 정의를 두면서 다중 정의 링크 오류를 피하는 역할도 겸합니다.

2. 프레임버퍼 클래스 — 메모리 위의 이미지 한 장

무엇을 만드나: 폭×높이 개의 패킹된 픽셀을 담는 Framebuffer 클래스입니다. 내부는 1차원 배열(std::vector<uint32_t>) 하나지만, index(x,y) 변환으로 마치 2차원 격자처럼 다룹니다. 왜 필요한가: 픽셀을 안전하게(경계 밖 무시) 읽고 쓰고 지우는 공통 창구를 두면, 이후의 선·삼각형·필터·블렌딩 코드가 모두 이 몇 개 메서드 위에서 깔끔하게 돌아갑니다.

framebuffer.hcpp
#pragma once
#include <vector>
#include <cstdint>
#include <cstdio>
#include "color.h"

// 소프트웨어 프레임버퍼: 메모리 위의 이미지 한 장.
// 원점(0,0)은 좌상단, x는 오른쪽, y는 아래로 증가시킨다.
// (수학 좌표계는 좌하단이 원점이지만, 이미지/화면은 좌상단이 관례다.)
class Framebuffer {
public:
    Framebuffer(int w, int h)
        : w_(w), h_(h), px_(static_cast<size_t>(w) * h, 0) {}

    int width()  const { return w_; }
    int height() const { return h_; }

    bool inBounds(int x, int y) const {
        return x >= 0 && x < w_ && y >= 0 && y < h_;
    }

    // 인덱스 = y * width + x  (행 우선/row-major)
    size_t index(int x, int y) const {
        return static_cast<size_t>(y) * w_ + x;
    }

    void setPixel(int x, int y, Color c) {
        if (!inBounds(x, y)) return;       // 화면 밖은 무시(클리핑)
        px_[index(x, y)] = pack(c);
    }

    Color getPixel(int x, int y) const {
        if (!inBounds(x, y)) return Color{ 0, 0, 0, 0 };
        return unpack(px_[index(x, y)]);
    }

    // 버퍼 전체를 한 색으로 채운다
    void clear(Color c) {
        uint32_t p = pack(c);
        std::fill(px_.begin(), px_.end(), p);
    }

    const std::vector<uint32_t>& pixels() const { return px_; }
    std::vector<uint32_t>&       pixels()       { return px_; }

    // ── PPM(P6) 바이너리로 저장: 가장 단순한 무손실 포맷 ──
    // 헤더는 아스키("P6 w h 255\n"), 이후 RGB 3바이트씩 나열.
    // (알파는 PPM이 지원하지 않으므로 버린다.)
    bool savePPM(const char* path) const {
        FILE* f = std::fopen(path, "wb");
        if (!f) return false;
        std::fprintf(f, "P6\n%d %d\n255\n", w_, h_);
        std::vector<uint8_t> row(static_cast<size_t>(w_) * 3);
        for (int y = 0; y < h_; ++y) {
            for (int x = 0; x < w_; ++x) {
                Color c = unpack(px_[index(x, y)]);
                row[x * 3 + 0] = c.r;
                row[x * 3 + 1] = c.g;
                row[x * 3 + 2] = c.b;
            }
            std::fwrite(row.data(), 1, row.size(), f);
        }
        std::fclose(f);
        return true;
    }

private:
    int w_, h_;
    std::vector<uint32_t> px_; // RGBA8 패킹된 픽셀 (길이 = w*h)
};

2-1. 멤버 변수와 생성자 — 저장소 확보

먼저 맨 아래 private: 블록의 두 멤버를 봅니다.

생성자를 봅니다:

Framebuffer(int w, int h)
    : w_(w), h_(h), px_(static_cast<size_t>(w) * h, 0) {}
함정 곱셈 전에 캐스팅해야 의미가 있습니다. static_cast<size_t>(w * h)처럼 곱한 뒤 캐스팅하면 이미 int로 계산돼 오버플로가 난 다음이라 소용이 없습니다. 반드시 static_cast<size_t>(w) * h처럼 피연산자 하나를 먼저 넓혀야 합니다.

2-2. width() / height() — const 접근자

단순히 w_, h_를 돌려주는 게터(getter)입니다. 함수 뒤의 const가 핵심입니다: 이 함수는 객체의 상태를 절대 바꾸지 않겠다는 약속입니다. 덕분에 const Framebuffer&로 전달된(읽기 전용) 버퍼에서도 크기를 물어볼 수 있습니다. 만약 const가 없으면, 상수 참조로 받은 필터 함수 안에서 src.width()조차 못 부릅니다.

2-3. inBounds — 화면 안인지 검사

return x >= 0 && x < w_ && y >= 0 && y < h_; 네 조건을 모두(&&) 만족해야 참입니다. 좌표가 [0, w_)·[0, h_) 반열린 구간 안에 있어야 유효합니다. 상한이 < w_(미만)인 이유는, 인덱스가 0부터 시작하므로 폭이 320이면 유효한 x는 0~319이기 때문입니다. x == 320은 배열 밖이라 반드시 걸러야 합니다.

이 한 줄이 바로 클리핑(clipping)의 가장 원초적인 형태입니다. 화면 밖 좌표를 조용히 무시함으로써, 선을 화면 경계 너머로 그리거나 스프라이트가 화면 밖으로 삐져나가도 배열 범위를 벗어나는 크래시가 나지 않습니다.

2-4. index — 2D 좌표 → 1D 오프셋 (행 우선)

1차원 배열로 2차원 이미지를 표현하는 핵심 공식입니다:

index = y × width + x

수학적 의미: 픽셀들을 "한 행(row)씩 차례로 이어 붙인다"는 뜻입니다. 0번째 행의 픽셀들이 인덱스 0 ~ (width-1)에, 1번째 행이 width ~ (2·width-1)에… 이렇게 이어집니다. 이 배치를 행 우선(row-major)이라 부릅니다.

기하 직관(그림글): 320×240 이미지를 상상해 보세요. 좌상단부터 오른쪽으로 320칸 채우고 나면, 줄바꿈해서 그 아래 줄로 내려가 다시 왼쪽부터 320칸을 채웁니다 — 타자기가 종이에 글을 찍는 순서와 똑같습니다. "몇 번째 줄(y)인가 × 한 줄 길이(width)"로 그 줄의 시작 위치를 구한 뒤, "그 줄에서 몇 칸째(x)"를 더하면 전체에서의 위치가 나옵니다.

손계산 예: width=320에서 (x=10, y=5)의 인덱스는 5 × 320 + 10 = 1610입니다. (x=0, y=1)1 × 320 + 0 = 320으로, 정확히 두 번째 줄의 첫 픽셀이 맞습니다.

여기서도 static_cast<size_t>(y) * w_ + xy를 먼저 넓혀 곱합니다. 큰 이미지에서 y * w_int 범위를 넘길 수 있기 때문입니다.

2-5. setPixel / getPixel — 픽셀 하나 쓰고 읽기

setPixel은 "경계 검사 → 인덱스 계산 → 패킹해서 대입" 3단계입니다.

getPixel은 반대로 "경계 검사 → 읽어서 언패킹해 반환"입니다. 화면 밖을 읽으려 하면 Color{ 0, 0, 0, 0 }(검정·완전 투명)을 돌려줍니다. 알파를 0으로 주는 게 중요한데, 나중에 이 값이 블렌딩에 쓰이면 "밖은 아무것도 없음"으로 자연스럽게 처리되기 때문입니다.

2-6. clear — 버퍼 전체를 한 색으로

매 프레임 시작마다 배경색으로 화면을 지우는 함수입니다.

2-7. pixels() — 내부 배열 노출 (const 오버로드 한 쌍)

같은 이름의 함수가 두 개 있습니다. 이것을 const 오버로딩이라 합니다.

둘 다 참조(&)를 반환하는 게 포인트입니다. 값으로 반환하면 수백만 픽셀 전체가 복사되어 끔찍하게 느립니다. 참조는 원본을 그대로 가리키므로 복사가 0입니다.

2-8. savePPM — 결과를 눈으로 보기

아직 화면 출력을 안 만들었으니, 결과를 파일로 저장해 이미지 뷰어로 확인합니다. PPM(Portable Pixmap) P6 포맷은 "아스키 헤더 + 날것의 RGB 바이트"라 코드가 아주 짧습니다.

  1. std::fopen(path, "wb")로 파일을 바이너리 쓰기("wb") 모드로 엽니다. Windows에서 "w"(텍스트 모드)로 열면 \n\r\n으로 변환돼 픽셀 바이트가 오염되므로 반드시 "wb"여야 합니다. 열기 실패 시 false 반환.
  2. std::fprintf(f, "P6\n%d %d\n255\n", w_, h_);로 헤더를 씁니다. P6=바이너리 컬러 매직넘버, 그다음 폭·높이, 255=채널 최댓값. 각 항목은 공백/개행으로 구분합니다.
  3. 임시 행 버퍼 rowwidth×3 크기로 잡습니다(픽셀당 RGB 3바이트, 알파 제외).
  4. 각 픽셀을 언패킹해 row[x*3+0]=R, +1=G, +2=B로 채운 뒤, 한 줄씩 std::fwrite로 파일에 씁니다.
  5. std::fclose(f)로 닫고 true 반환.

row[x * 3 + 0]× 3은 "픽셀 하나가 3바이트를 차지"하기 때문입니다. x번째 픽셀의 R은 x*3, G는 x*3+1, B는 x*3+2에 놓입니다 — 이것도 작은 규모의 인덱스 계산이죠. 한 줄을 통째로 모아 fwrite 한 번으로 쓰는 이유는, 픽셀마다 파일 쓰기를 부르면 시스템 콜 오버헤드가 커지기 때문입니다(작은 배치화).

주의 PPM은 학습·디버깅용으로 최고지만 압축이 없어 파일이 큽니다(320×240이면 약 225KB). PNG로 저장하려면 헤더 온리 라이브러리 stb_image_write.hstbi_write_png("out.png", w, h, 4, data, w*4)를 쓰면 됩니다 (여기서 datapixels().data(), 채널 수 4, 마지막 w*4는 한 행의 바이트 수). PNG는 알파도 저장됩니다.

3. 사용 예 — 만들고, 지우고, 찍고, 저장하기

지금까지 만든 도구를 조립해 봅니다. 딱 네 줄이면 첫 이미지가 나옵니다.

main.cpp (발췌)cpp
Framebuffer fb(320, 240);
fb.clear(rgb(20, 22, 30));            // 어두운 배경
fb.setPixel(10, 10, rgb(255, 80, 80)); // 좌상단 근처에 빨간 점
fb.savePPM("out.ppm");                 // 결과 저장
  1. Framebuffer fb(320, 240); — 폭 320·높이 240짜리 버퍼를 스택에 만듭니다. 내부적으로 76,800개(=320×240)의 uint32_t가 0으로 초기화됩니다(약 300KB).
  2. fb.clear(rgb(20, 22, 30)); — 전체를 어두운 남색 배경으로 칠합니다. R·G·B가 모두 낮은 값이라 거의 검정에 가깝습니다.
  3. fb.setPixel(10, 10, rgb(255, 80, 80));좌상단에서 오른쪽으로 10, 아래로 10 떨어진 자리에 밝은 빨간 점 하나. (수학 좌표라면 좌하단 기준이겠지만, 여기선 좌상단 기준이라 화면 왼쪽 위 근처입니다.)
  4. fb.savePPM("out.ppm"); — 결과를 파일로 저장. 이미지 뷰어로 열면 어두운 배경에 빨간 점 하나가 보입니다.
함정 원점 위치를 헷갈리기 쉽습니다. 이미지/화면은 좌상단이 (0,0)이고 y가 아래로 증가합니다. Direct3D 11의 텍스처 좌표(UV) 역시 좌상단이 원점이라 이 버퍼와 방향이 일치합니다(OpenGL은 좌하단이 원점이라 뒤집힘). D3D로 올리면 대개 그대로 맞지만, 수학 좌표계(좌하단 원점)와 섞어 쓸 때는 UV의 v를 1-v로 다뤄야 할 수 있습니다.
성능 setPixel은 매번 경계 검사를 합니다. 안쪽 루프에서 이미 범위가 보장된다면 px_[index(x,y)] = p로 직접 쓰는 편이 훨씬 빠릅니다. 또 픽셀을 32비트 정수 하나로 다루면 바이트 4개보다 캐시/복사 효율이 좋습니다.

4. 화면에 실시간으로 — D3D11 동적 텍스처로 올리기 (개념)

파일 저장은 학습엔 좋지만, 게임처럼 매 프레임 화면에 뿌리려면 이 소프트웨어 버퍼를 Direct3D 11 텍스처로 업로드해야 합니다. 개념만 짚고, 실제 디바이스/스왑체인 생성과 셰이더 파이프라인 전체는 3~4장에서 다룹니다. 여기서 중요한 건 우리 프레임버퍼의 픽셀 배치가 D3D 텍스처와 어떻게 1:1로 맞아떨어지는가입니다.

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장 참고)

4-1. 텍스처 서술자(D3D11_TEXTURE2D_DESC) 필드 하나씩

D3D11은 리소스를 만들 때 "이런 리소스를 원한다"를 서술자(descriptor) 구조체에 채워 건넵니다. D3D11_TEXTURE2D_DESC td = {};= {}모든 필드를 0으로 초기화하는 집합 초기화입니다(안 채운 필드가 쓰레기 값이 되는 걸 방지).

필드의미
Width / Heightfb.width()/height()텍스처 크기를 우리 프레임버퍼와 똑같이 맞춤.
MipLevels1밉맵 없이 원본 한 장만(매 프레임 갱신하는 화면 버퍼엔 밉맵 불필요).
ArraySize1텍스처 배열이 아니라 한 장.
FormatDXGI_FORMAT_R8G8B8A8_UNORM핵심. 메모리 바이트가 R,G,B,A 순, 각 8비트 UNORM(0~255를 셰이더에서 0.0~1.0으로 해석). 우리 pack의 바이트 배치와 정확히 일치.
SampleDesc.Count1멀티샘플 안티에일리어싱 없음(픽셀당 샘플 1개).
UsageD3D11_USAGE_DYNAMICCPU가 매 프레임 덮어쓸 수 있는 용도. GPU 읽기 + CPU 쓰기에 최적.
BindFlagsD3D11_BIND_SHADER_RESOURCE셰이더에서 텍스처로 읽을 수 있게 바인딩 허용.
CPUAccessFlagsD3D11_CPU_ACCESS_WRITECPU가 Map으로 쓰기 접근 허용(DYNAMIC과 짝).
규약 DXGI_FORMAT_R8G8B8A8_UNORM은 메모리에서 R,G,B,A 바이트 순서를 기대합니다. 우리 pack이 R을 최하위 바이트에 두었으므로(리틀엔디언에서 낮은 주소=R), 이 포맷과 바이트 하나까지 일치합니다. 만약 셰이더에서 색이 파랑/빨강이 뒤바뀌어 보인다면(BGRA), 포맷을 DXGI_FORMAT_B8G8R8A8_UNORM으로 바꾸거나 pack에서 R/B 자리를 바꿔야 합니다.

4-2. 리소스 생성과 ComPtr

4-3. 샘플러(D3D11_SAMPLER_DESC) — 픽셀을 어떻게 고를까

샘플러는 "텍스처에서 색을 뽑을 때의 규칙"입니다.

4-4. Map으로 픽셀 복사 — RowPitch가 핵심

매 프레임 CPU에서 그린 픽셀을 GPU 텍스처로 옮기는 부분입니다.

그래서 버퍼 전체를 한 번에 memcpy하면 안 되고, 반드시 한 행씩 복사합니다:

context->Unmap(tex.Get(), 0);로 잠금을 풀면 GPU가 갱신된 텍스처를 쓸 수 있게 됩니다.

함정 RowPitch를 무시하고 memcpy(ms.pData, srcPx, width*height*4) 한 방으로 복사하면, 패딩이 있는 GPU에선 이미지가 대각선으로 밀려 찢어지거나(skew) 색이 어긋나 보입니다. "행 단위 복사"는 D3D/Vulkan 텍스처 업로드의 철칙입니다.

4-5. 풀스크린 삼각형 출력 (정점 버퍼 없이)

마지막 블록은 "정점 버퍼 하나 없이" 화면을 덮는 삼각형을 그려, 그 위에 우리 텍스처를 입히는 부분입니다.

정점 버퍼 없이 SV_VertexID 3개만으로 클립 공간의 큰 삼각형을 만들어 화면 전체를 덮습니다. 픽셀 셰이더는 이 삼각형의 UV로 우리 텍스처를 샘플링해 그대로 출력합니다.

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);
}

4-6. 정점 셰이더 해부 — 한 줄로 화면을 덮는 트릭

struct VSOut는 정점 셰이더가 픽셀 셰이더로 넘길 데이터입니다.

VSMain의 핵심 두 줄:

float2 uv = float2((id << 1) & 2, id & 2);

이 비트 트릭으로 id 0·1·2에서 UV (0,0)·(2,0)·(0,2)를 만들어 냅니다. 표로 검증합니다:

idid<<1(id<<1)&2 = uv.xid&2 = uv.y
0000
1220
240 (4&2=0)2

즉 UV 좌표는 (0,0), (2,0), (0,2) 세 점 — [0,1] 사각형의 두 배 크기인 큰 직각삼각형입니다. 이 삼각형은 화면보다 커서, 화면 영역([0,1]에 해당하는 부분)을 남김없이 덮고 나머지는 잘려 나갑니다. 두 개의 삼각형(사각형)보다 하나의 큰 삼각형이 GPU에 살짝 더 효율적이라 흔히 쓰는 관용구입니다.

o.pos = float4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);

UV [0,2]를 클립 공간 [-1,3]으로 매핑합니다. 성분별로:

4-7. 픽셀 셰이더 해부 — 텍스처 그대로 출력

주의 Direct3D 11의 텍스처 UV는 좌상단이 원점이라 좌상단 원점 프레임버퍼가 방향까지 그대로 맞습니다 (OpenGL이라면 좌하단 원점이라 상하가 뒤집혀 v를 1-v로 매핑해야 했습니다). 또 Map이 돌려주는 RowPitchwidth×4보다 클 수 있으니, 버퍼 전체를 한 번에 memcpy하지 말고 반드시 행 단위로 복사하세요.

5. 정리 — 여기서 얻은 것

이 페이지에서 우리는 렌더링의 가장 밑바닥을 손으로 만졌습니다.

다음 페이지에서는 이 프레임버퍼 위에 선과 삼각형을 실제로 그립니다 — 브레젠험 직선과 에지 함수·무게중심 좌표로 삼각형을 채우면서, GPU 래스터라이저가 내부에서 하는 일을 그대로 재현합니다.

성능 실무에서 CPU 소프트웨어 렌더링은 대부분 GPU로 대체되지만, 이 프레임버퍼 개념은 포스트프로세싱 스테이징, 디버그 오버레이, 절차적 텍스처 생성, 폰트 아틀라스 굽기 등에서 여전히 쓰입니다. 무엇보다, GPU가 "마법처럼" 하는 일이 결국 거대한 병렬 setPixel임을 이해하면 셰이더가 훨씬 덜 무섭게 느껴집니다.