← 개념 · 데모 가이드 | 구현 허브 | 2장. 2D 그래픽스 | 래스터라이제이션 — 선과 삼각형
IMPLEMENTATION · CHAPTER 2

래스터라이제이션 — 선과 삼각형 — 한 줄 한 줄 직접 구현

래스터화(rasterization)란 수학적으로 정의된 도형 — 두 점을 잇는 선분이나 세 점으로 둘러싸인 삼각형 — 을 받아서 "그러면 화면의 어떤 픽셀들을 켜야 하는가?" 라는 질문에 답하는 과정입니다. 도형은 연속적인 실수 좌표 위에 있지만, 화면은 정수 격자로 된 픽셀 뿐입니다. 이 둘 사이의 간극을 메우는 게 래스터라이저입니다. 이 페이지에서는 먼저 정수 덧셈만으로 선을 긋는 브레젠험(Bresenham) 알고리즘을 만들고, 이어서 에지 함수무게중심 좌표(barycentric coordinates)로 삼각형을 채우면서 정점 색까지 부드럽게 보간해 봅니다. 이건 장난감이 아니라, GPU가 매 프레임 수백만 번씩 하는 일의 축소판입니다.

  1. 브레젠험으로 두 점 사이를 잇는 픽셀들을 구한다 — 곱셈·나눗셈·부동소수점 없이 정수 덧셈만으로.
  2. 삼각형의 바운딩 박스를 구하고, 그 안의 픽셀마다 에지 함수로 내부/외부를 판정한다.
  3. 세 에지 값을 면적으로 나눈 무게중심 가중치로 정점 색(또는 UV 등)을 보간한다.
  4. top-left 규칙으로 인접한 두 삼각형이 공유 경계 픽셀을 이중으로 찍거나 빠뜨리는 것을 방지한다.

브레젠험 직선

무엇을 만드나: 정수 좌표 두 점 (x0,y0), (x1,y1)을 받아 그 사이를 잇는 픽셀들을 하나씩 찍는 함수입니다. 왜 필요한가: 화면에 직선을 그리는 것은 모든 2D/3D 렌더링의 가장 기초적인 원시 연산(primitive)입니다. 와이어프레임, 디버그 라인, 그래프 축, 그리드 등 어디에나 쓰입니다. "직선의 방정식 y = m·x + b를 풀어서 각 x마다 y를 구하면 되지 않나?"라고 생각할 수 있는데, 그 방식은 매 픽셀마다 부동소수점 곱셈과 반올림이 필요합니다. 브레젠험은 그 모든 걸 정수 덧셈과 비교로 대체하는 유명한 알고리즘입니다.

직선을 그릴 때 가장 까다로운 건 "기울기"입니다. 완만한 선(가로로 길쭉한 선)은 x를 한 칸 갈 때마다 y가 아주 조금 변하고, 가파른 선(세로로 길쭉한 선)은 반대입니다. 또 좌→우로 그릴 수도, 우→좌로, 위→아래로도 그릴 수 있습니다. 이 여덟 가지 방향(팔분면, octant)을 각각 따로 처리하면 코드가 지저분해집니다. 아래 코드는 부호 변수(sx, sy)와 오차 항(err) 하나로 여덟 방향을 전부 일반화한, 흔히 "일반화된 브레젠험"이라 불리는 형태입니다.

  1. 두 점의 x·y 거리차의 절댓값 dx, dy를 구한다(단, dy는 음수로 저장).
  2. 진행 방향 sx, sy(+1 또는 −1)를 정한다.
  3. 오차 항 err을 초기화하고, 루프에서 현재 픽셀을 찍는다.
  4. 오차 항의 부호를 보고 x를 전진할지, y를 전진할지(또는 둘 다) 결정한다.
  5. 끝점에 도달하면 종료한다.
line.hcpp
#pragma once
#include <cstdlib>   // std::abs
#include "framebuffer.h"

// 정수 브레젠험: 모든 8분면을 일반화한 형태.
// dx, dy의 부호(sx, sy)와 오차 누적(err)만으로 다음 픽셀을 고른다.
inline void drawLine(Framebuffer& fb, int x0, int y0, int x1, int y1, Color c) {
    int dx =  std::abs(x1 - x0);
    int dy = -std::abs(y1 - y0);   // dy를 음수로 두면 err 갱신이 간결해진다
    int sx = (x0 < x1) ? 1 : -1;   // x 진행 방향
    int sy = (y0 < y1) ? 1 : -1;   // y 진행 방향
    int err = dx + dy;             // 오차 항

    while (true) {
        fb.setPixel(x0, y0, c);
        if (x0 == x1 && y0 == y1) break;
        int e2 = 2 * err;
        if (e2 >= dy) { err += dy; x0 += sx; } // x 한 칸 전진
        if (e2 <= dx) { err += dx; y0 += sy; } // y 한 칸 전진
    }
}

시그니처 해부

inline void drawLine(Framebuffer& fb, int x0, int y0, int x1, int y1, Color c) 를 하나씩 뜯어봅니다.

초기화 다섯 줄

수학적 의미 → 기하 직관 → 코드 매핑 순서로 봅니다.

int dx = std::abs(x1 - x0); — x 방향으로 가야 할 총 거리(항상 0 이상)입니다. std::abs로 절댓값을 취해 방향(왼쪽/오른쪽)은 빼고 "얼마나 멀리"만 남깁니다. 방향은 아래 sx가 따로 담당합니다.

int dy = -std::abs(y1 - y0); — y 방향 거리인데, 앞에 마이너스를 붙여 일부러 음수로 저장합니다. "왜 굳이 음수로?"가 이 알고리즘의 첫 번째 트릭입니다. 원래 브레젠험의 오차 갱신식은 err += dy(빼기)와 err += dx(더하기)처럼 부호가 엇갈립니다. dy를 처음부터 음수로 저장해 두면 두 갱신을 모두 += (더하기)로 통일할 수 있어 코드가 대칭적이고 실수가 줄어듭니다. 기하적으로는 "완만한 선과 가파른 선을 대칭적으로 다루기 위한 좌표 정규화"라고 이해하면 됩니다.

int sx = (x0 < x1) ? 1 : -1; — x가 증가 방향이면 +1, 감소 방향이면 −1. 삼항 연산자 조건 ? A : B는 "조건이 참이면 A, 아니면 B"라는 뜻입니다. 즉 시작점이 끝점보다 왼쪽에 있으면 오른쪽(+1)으로 걸어가고, 아니면 왼쪽(−1)으로 걸어갑니다. sy도 y에 대해 같은 논리입니다. 이 두 부호 변수 덕분에 "좌→우/우→좌/위→아래/아래→위" 어느 방향이든 같은 루프로 처리됩니다.

int err = dx + dy; — 오차 항의 초깃값. dy가 음수였음을 기억하세요. 그래서 이 값은 사실 dx - |dy|와 같습니다. 이 err은 "이상적인 수학적 직선"과 "우리가 실제로 찍은 픽셀 격자" 사이의 어긋난 정도를 정수로 누적해 추적하는 변수입니다. 매 스텝에서 이 값을 보고 "이번엔 x로 갈까, y로 갈까, 둘 다 갈까"를 결정합니다.

루프 본체 — 한 스텝의 결정

fb.setPixel(x0, y0, c); — 현재 위치의 픽셀을 먼저 칠합니다. 시작점을 포함해 끝점까지 빠짐없이 찍기 위해 "이동 전에 먼저 찍고, 그다음 이동"하는 구조입니다.

if (x0 == x1 && y0 == y1) break; — 현재 위치가 끝점과 정확히 같아지면 루프를 빠져나갑니다. 끝점을 찍은 직후에 검사하므로 끝점도 반드시 색칠됩니다.

int e2 = 2 * err; — 오차의 두 배. 브레젠험이 곱셈을 피한다면서 왜 2 *가 있을까요? 원래의 정확한 판정식에는 분수(1/2 같은 항)가 등장하는데, 양변에 2를 곱해 분수를 없앤 정수 버전이기 때문입니다. 게다가 2 * x는 컴파일러가 비트 시프트(x << 1)로 바꿔 사실상 공짜입니다. 이 한 번의 값을 e2에 담아 아래 두 비교에서 재사용합니다.

if (e2 >= dy) { err += dy; x0 += sx; }e2dy(음수) 이상이면 "x를 한 칸 전진해도 여전히 이상적인 직선에 충분히 가깝다"는 뜻입니다. 그래서 x를 sx 방향으로 한 칸 옮기고, 그 대가로 오차에 dy를 더해 균형을 맞춥니다.

if (e2 <= dx) { err += dx; y0 += sy; } — 마찬가지로 e2dx 이하이면 y를 sy 방향으로 한 칸 옮기고 오차에 dx를 더합니다. 두 if둘 다 참일 수도 있다는 점이 핵심입니다 — 정확히 45도 대각선에서는 x와 y가 동시에 전진합니다. 이 대칭적인 두 조건이 완만한 선(x 위주 전진)과 가파른 선(y 위주 전진)을 하나의 코드로 매끄럽게 처리하는 이유입니다.

주의 fb.setPixel 내부에서 화면 밖 좌표를 걸러주지 않으면(clip), 선의 일부가 버퍼 밖으로 나갈 때 배열 범위를 벗어나는 접근이 일어나 크래시하거나 메모리를 오염시킵니다. 실무에서는 루프 전에 선분을 화면 사각형으로 클리핑(예: Cohen–Sutherland 알고리즘)하거나, 최소한 setPixel 안에서 경계를 검사(inBounds)해 조용히 무시하도록 만드세요.
성능 브레젠험이 빠른 이유는 루프 안에 곱셈·나눗셈·부동소수점이 전혀 없기 때문입니다. 정수 덧셈과 비교만으로 각 스텝에서 픽셀 하나를 확정하므로, 아주 오래된 하드웨어나 마이크로컨트롤러에서도 잘 돕니다. 유일한 곱셈 2 * err조차 시프트로 처리됩니다. 브레젠험이 1962년(!)에 나온 알고리즘인데도 여전히 임베디드 그래픽스에서 쓰이는 이유입니다.

손으로 따라가 보기

drawLine(fb, 0, 0, 4, 2, c)를 손으로 추적해 봅시다. 완만한 선(가로가 더 긴 선)입니다.

찍힌 픽셀은 (0,0) (1,1) (2,1) (3,2) (4,2). x 5칸을 가는 동안 y는 2번만 올라가는, 딱 기울기 2/4의 계단 모양입니다. 곱셈·나눗셈 없이 정수 덧셈만으로 이상적인 직선에 가장 가까운 픽셀들을 골라낸 것을 확인할 수 있습니다.

DirectX 11에서는? — 이 코드의 실무적 위치

현대 GPU 파이프라인에서 "선 래스터화"는 하드웨어의 고정 기능 래스터라이저가 담당합니다. D3D11에서는 프리미티브 토폴로지를 D3D11_PRIMITIVE_TOPOLOGY_LINELIST 혹은 ..._LINESTRIP으로 설정하고 (ID3D11DeviceContext::IASetPrimitiveTopology) 정점 버퍼를 넘긴 뒤 Draw를 호출하면, 위와 같은 픽셀 선택을 GPU가 대신 해 줍니다. 즉 실무에서는 이 브레젠험 루프를 직접 짜지 않습니다. 하지만 GPU 내부에서 벌어지는 일이 개념적으로 바로 이것이며, 소프트웨어 렌더러·CPU 디버그 오버레이· 폰트 힌팅 등에서는 지금도 이 코드를 직접 씁니다. 정수 좌표를 다루는 감각은 그대로 이어집니다.

에지 함수 · 무게중심 삼각형

무엇을 만드나: 세 정점으로 정의된 삼각형을 받아, 그 내부에 들어가는 픽셀들을 칠하되 각 픽셀의 색을 세 정점 색의 가중 평균으로 부드럽게 섞는 함수입니다. 왜 필요한가: 삼각형은 3D 그래픽스의 근본 단위입니다. 모든 3D 모델은 결국 삼각형 무더기로 쪼개지고, GPU는 화면에 삼각형을 채우는 일만 미친 듯이 빠르게 반복합니다. 그 "채우기"의 심장이 바로 에지 함수와 무게중심 좌표입니다.

핵심 아이디어부터 그림으로 잡아 봅시다. 점 P가 두 정점 A→B가 만드는 선분의 어느 쪽에 있는지는 2D 외적(cross product) 부호로 알 수 있습니다: E(P) = (B−A) × (P−A). 이 값이 양수면 P는 선분의 한쪽(예: 왼쪽), 음수면 반대쪽, 정확히 0이면 선분 위에 있습니다. 삼각형은 세 개의 에지로 둘러싸여 있으니, 세 에지 함수가 모두 같은 부호이면 P는 세 선분의 같은 쪽 — 즉 삼각형 내부에 있습니다. 게다가 이 세 에지 값을 삼각형 전체 면적으로 나누면 그대로 무게중심 좌표가 되어, 정점 속성(색·UV·법선 등)을 매끄럽게 보간할 수 있습니다.

triangle.hcpp
#pragma once
#include <algorithm>
#include "framebuffer.h"

struct V2 { float x, y; };

struct Vertex {
    V2    pos;      // 화면 좌표(픽셀)
    Color color;    // 정점 색 (보간 대상)
};

// 2D 외적: (b-a) × (c-a). 부호로 c가 선분 ab의 어느 쪽인지 판정.
inline float edge(const V2& a, const V2& b, const V2& c) {
    return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}

자료형 두 개: V2 와 Vertex

struct V2 { float x, y; }; — 2차원 점/벡터를 담는 가장 단순한 구조체입니다. float을 쓴 이유는 픽셀 중심(x + 0.5)처럼 소수점 좌표를 다뤄야 하기 때문입니다. (앞의 선 그리기는 정수 격자만 다뤘지만, 삼각형은 픽셀 중심과 면적 계산에서 실수가 필요합니다.)

struct Vertex { V2 pos; Color color; }; — 정점 하나는 "위치 + 색"으로 이루어집니다. 이게 바로 GPU의 정점 버퍼에 들어가는 데이터의 축소판입니다. 실무의 정점은 여기에 UV 좌표, 법선, 접선 등이 더 붙지만, 구조는 똑같이 "위치 + 보간할 속성들"입니다. 주석 // 보간 대상이 가리키듯, color는 픽셀마다 무게중심 가중치로 섞일 대상입니다.

edge() — 2D 외적, 삼각형의 심장

시그니처 inline float edge(const V2& a, const V2& b, const V2& c): 세 점을 받아 실수 하나를 돌려줍니다. 세 인자 모두 const V2&입니다 — &(참조)는 구조체 복사를 피하고, const는 "이 함수는 인자를 읽기만 하고 절대 바꾸지 않는다"는 약속입니다. 이 약속 덕분에 호출하는 쪽이 안심할 수 있고, 컴파일러도 최적화 여지가 생깁니다.

수학적 의미: 2D 외적은 두 벡터 u = (ux, uy), v = (vx, vy)에 대해 스칼라 ux·vy − uy·vx로 정의됩니다. 여기서 u = B−A, v = C−A이므로 edge = (b.x−a.x)·(c.y−a.y) − (b.y−a.y)·(c.x−a.x)가 됩니다. 이 값은 두 벡터가 만드는 평행사변형의 부호 있는 넓이이고, 절반이 삼각형 ABC의 부호 있는 넓이입니다.

기하 직관: 벡터 B−A를 기준선으로 놓고, C가 그 선의 왼쪽에 있으면 부호 있는 넓이가 양수, 오른쪽이면 음수입니다(왼손 좌표계·화면 좌표 기준에서 부호 방향은 규약에 달렸지만, 핵심은 "한쪽은 +, 반대쪽은 −, 선 위는 0"이라는 것). 즉 edge 부호 하나로 "점이 선분의 어느 쪽인가"를 즉시 알 수 있습니다. 이게 픽셀이 삼각형 안인지 밖인지 판정하는 기준이 됩니다.

성분별 계산: 두 곱셈항을 뜯어보면 — 첫째 항 (b.x−a.x)·(c.y−a.y)는 "AB의 x성분 × AC의 y성분", 둘째 항 (b.y−a.y)·(c.x−a.x)는 "AB의 y성분 × AC의 x성분", 그리고 이 둘을 뺍니다. 3D 외적 u×v의 z성분 공식 ux·vy − uy·vx와 정확히 같습니다. 즉 2D 외적은 사실 "z=0 평면에 놓인 두 벡터의 3D 외적의 z성분"입니다.

주의 항의 순서와 부호가 결과 부호를 결정합니다. (b.x−a.x)·(c.y−a.y) − (b.y−a.y)·(c.x−a.x)에서 빼는 순서를 뒤집으면 부호가 통째로 반대가 되어, "내부"와 "외부"가 뒤바뀝니다. 아래 삼각형 채우기 코드는 이 부호 규약에 맞춰 winding(감김 순서)을 정규화하므로, edge의 식을 함부로 바꾸면 전체가 어긋납니다.

작은 수치 예제: A=(0,0), B=(4,0), C=(0,3)이라면 edge(A,B,C) = (4−0)·(3−0) − (0−0)·(0−0) = 12 − 0 = 12. 이 삼각형의 실제 넓이는 밑변 4 × 높이 3 ÷ 2 = 6이고, edge가 준 12는 그 두 배(평행사변형 넓이)입니다. 즉 |edge| / 2 = 넓이임을 손으로 확인할 수 있습니다. 부호가 +12로 양수인 것은 이 정점 순서가 (이 좌표계에서) 우리가 "정방향(내부 판정 기준)"으로 택한 감김이라는 뜻입니다.

top-left 규칙 — 공유 경계의 픽셀을 딱 한 번만

왜 필요한가: 화면을 삼각형들로 촘촘히 덮을 때(예: 사각형 = 삼각형 2개), 두 삼각형이 맞닿는 경계선 위의 픽셀은 양쪽 다 자기 것이라고 주장할 수 있습니다. 아무 규칙 없이 "에지 값이 0이면 포함"으로 두면 그 픽셀을 두 번 칠하게 됩니다. 반투명 블렌딩에서는 경계가 두 번 겹쳐 더 진해지고, 반대로 "0이면 제외"로 두면 경계 픽셀이 어느 삼각형에도 안 뽑혀 틈(crack)이 생깁니다. top-left 규칙은 "공유 경계 픽셀은 항상 정확히 한 삼각형에만 속하게" 만드는 업계 표준(D3D/OpenGL도 채택) 관례입니다.

triangle.h (계속)cpp
// top-left 규칙: 에지가 "위쪽" 혹은 "왼쪽" 경계면 그 위의 픽셀을 포함(true).
// 인접 삼각형이 공유 에지의 픽셀을 이중으로 찍거나 빠뜨리는 걸 막는다.
inline bool isTopLeft(const V2& a, const V2& b) {
    bool top  = (a.y == b.y) && (b.x < a.x); // 수평이고 왼쪽으로 진행 = 위쪽 에지
    bool left = (b.y > a.y);                 // 아래로 내려가는 에지 = 왼쪽 에지
    return top || left;
}

bool top = (a.y == b.y) && (b.x < a.x); — 에지 A→B수평(두 y가 같음)이면서 진행 방향이 왼쪽(b.x < a.x)이면 이것은 삼각형의 "위쪽 변"입니다. 화면 좌표는 y가 아래로 갈수록 커지고(텍스처 V 위→아래 규약과 같은 방향), CCW로 감긴 삼각형에서 위쪽 수평 변은 오른쪽에서 왼쪽으로 진행합니다. 그래서 "수평 + 왼쪽 진행 = 위쪽 에지"라는 판정이 나옵니다.

bool left = (b.y > a.y); — 에지가 아래로 내려가면(끝점 y가 시작점 y보다 큼) 그것은 삼각형의 "왼쪽 변"입니다. CCW 감김에서 삼각형의 좌측 윤곽선은 위에서 아래로 흐르기 때문입니다.

return top || left; — 위쪽 변이거나 왼쪽 변이면 true. 이 true는 나중에 "이 에지 위(에지 값이 정확히 0)에 놓인 픽셀을 포함시킬지"의 스위치로 쓰입니다. 위/왼쪽 경계면 포함, 아래/오른쪽 경계면 제외 — 그러면 맞닿은 이웃 삼각형에서는 그 경계가 반대로(아래/오른쪽으로) 보여 제외되므로, 경계 픽셀은 정확히 한 번만 칠해집니다.

주의 부동소수점 a.y == b.y 정확 비교가 여기선 의도된 것입니다. 정점 좌표를 미리 정수 격자나 고정소수점으로 스냅해 두면 이 등호 비교가 안정적으로 동작합니다. 좌표가 미세하게 어긋난 float라면 수평 판정이 어긋날 수 있으니, 실무 래스터라이저는 서브픽셀 고정소수점(예: 8비트 소수부)으로 정점을 양자화합니다.

drawTriangle() — 전체 흐름

  1. 세 정점으로 삼각형의 부호 있는 면적을 구하고, winding을 CCW로 정규화한다.
  2. 세 정점을 감싸는 바운딩 박스를 구해 검사 범위를 좁힌다(+ 화면 밖 클리핑).
  3. 세 에지의 top-left 여부를 미리 계산해 둔다.
  4. 박스 안 모든 픽셀 중심에서 세 에지 함수를 평가해 내부 판정.
  5. 내부 픽셀은 무게중심 가중치로 색을 보간해 칠한다.
triangle.h (계속)cpp
inline void drawTriangle(Framebuffer& fb, Vertex v0, Vertex v1, Vertex v2) {
    // CCW(반시계) 정점 순서를 가정. 시계면 면적이 음수가 되므로 뒤집어 준다.
    float area = edge(v0.pos, v1.pos, v2.pos);
    if (area == 0.0f) return;                 // 퇴화 삼각형
    if (area < 0.0f) { std::swap(v1, v2); area = -area; }

    // 바운딩 박스(화면 클리핑 포함)
    int minX = std::max(0, (int)std::floor(std::min({v0.pos.x, v1.pos.x, v2.pos.x})));
    int minY = std::max(0, (int)std::floor(std::min({v0.pos.y, v1.pos.y, v2.pos.y})));
    int maxX = std::min(fb.width()  - 1, (int)std::ceil(std::max({v0.pos.x, v1.pos.x, v2.pos.x})));
    int maxY = std::min(fb.height() - 1, (int)std::ceil(std::max({v0.pos.y, v1.pos.y, v2.pos.y})));

    // 각 에지의 top-left 여부: 이 에지 위(=값 0)의 픽셀을 포함할지 결정
    bool tl0 = isTopLeft(v1.pos, v2.pos);
    bool tl1 = isTopLeft(v2.pos, v0.pos);
    bool tl2 = isTopLeft(v0.pos, v1.pos);

    for (int y = minY; y <= maxY; ++y) {
        for (int x = minX; x <= maxX; ++x) {
            V2 p{ x + 0.5f, y + 0.5f };        // 픽셀 중심에서 판정
            float w0 = edge(v1.pos, v2.pos, p); // v0에 대응
            float w1 = edge(v2.pos, v0.pos, p); // v1에 대응
            float w2 = edge(v0.pos, v1.pos, p); // v2에 대응

            // 경계(값==0)는 top-left면 포함, 아니면 제외
            bool in0 = (w0 > 0) || (w0 == 0 && tl0);
            bool in1 = (w1 > 0) || (w1 == 0 && tl1);
            bool in2 = (w2 > 0) || (w2 == 0 && tl2);
            if (!(in0 && in1 && in2)) continue;

            // 무게중심 가중치로 정점 색 보간
            float l0 = w0 / area, l1 = w1 / area, l2 = w2 / area;
            Color c;
            c.r = toByte((l0 * v0.color.r + l1 * v1.color.r + l2 * v2.color.r) / 255.0f);
            c.g = toByte((l0 * v0.color.g + l1 * v1.color.g + l2 * v2.color.g) / 255.0f);
            c.b = toByte((l0 * v0.color.b + l1 * v1.color.b + l2 * v2.color.b) / 255.0f);
            c.a = 255;
            fb.setPixel(x, y, c);
        }
    }
}

① 면적과 winding 정규화

float area = edge(v0.pos, v1.pos, v2.pos); — 세 정점으로 부호 있는 (평행사변형) 면적을 구합니다. 이 하나의 값이 두 가지 일을 합니다: (1) 부호로 감김 방향을 알려주고, (2) 크기로 무게중심 좌표의 분모가 됩니다. 함수 인자로 Vertex v0, v1, v2값으로(참조 아님) 받은 것에 주목하세요 — 아래에서 std::swap(v1, v2)지역 복사본을 바꿔야 하기 때문입니다. 참조로 받았다면 호출한 쪽의 원본 정점 배열이 바뀌어 버립니다.

if (area == 0.0f) return; — 면적이 0이면 세 점이 한 직선 위에 있는 퇴화(degenerate) 삼각형입니다. 넓이가 없으니 칠할 픽셀도 없고, 무엇보다 아래에서 area로 나누므로 0으로 나누기(무한대/NaN)를 막기 위해 여기서 즉시 빠져나갑니다.

if (area < 0.0f) { std::swap(v1, v2); area = -area; } — 면적이 음수라는 건 정점이 시계(CW) 방향으로 감겼다는 뜻입니다. 이 코드는 CCW를 가정하고 부호 규약을 세웠으므로, CW로 들어온 삼각형은 v1v2를 맞바꿔 CCW로 뒤집고 면적도 양수로 만듭니다. std::swap은 두 값을 교환하는 표준 함수입니다. 이렇게 정규화해 두면 이후 "에지 값이 양수 = 내부"라는 판정이 항상 성립합니다.

② 바운딩 박스와 클리핑

삼각형은 화면의 작은 일부만 차지하는데 화면 전체 픽셀을 검사하면 낭비입니다. 그래서 삼각형을 감싸는 가장 작은 사각형(바운딩 박스)만 훑습니다.

int minX = std::max(0, (int)std::floor(std::min({v0.pos.x, v1.pos.x, v2.pos.x}))); — 안쪽부터 읽습니다. std::min({a,b,c})는 중괄호 리스트(std::initializer_list)로 준 세 x 좌표 중 최솟값입니다. std::floor내림해서 그 픽셀을 놓치지 않게 하고, (int)로 정수 변환한 뒤, std::max(0, ...)로 0 미만(화면 왼쪽 밖)을 0으로 잘라냅니다 — 이게 화면 왼쪽 경계 클리핑입니다.

maxX는 대칭적으로 std::max(최댓값)에 std::ceil(올림)을 쓰고, std::min(fb.width() - 1, ...)로 화면 오른쪽 끝(width-1)을 넘지 않게 자릅니다. minY/maxY도 y에 대해 똑같습니다. 내림/올림을 반대로 쓰는 이유는 "삼각형이 걸치는 픽셀을 한 개도 빠뜨리지 않도록 바깥으로 넉넉히" 잡기 위해서입니다.

③ 세 에지의 top-left 사전 계산

bool tl0 = isTopLeft(v1.pos, v2.pos); 등 세 줄은 각 변이 top-left 경계인지를 루프 밖에서 딱 한 번 계산합니다. 삼각형 하나에 대해 변은 세 개뿐인데 픽셀은 수천 개일 수 있으니, 픽셀 루프 안에서 매번 다시 계산하지 않도록 미리 빼둔 것입니다. 인덱스 대응에 주의하세요: tl0v0맞은편 변인 v1→v2에 대응합니다. 아래 w0도 같은 변에서 나오므로 짝이 맞습니다.

④ 픽셀 루프와 내부 판정

V2 p{ x + 0.5f, y + 0.5f }; — 픽셀 (x,y)중심에서 판정합니다. 픽셀은 점이 아니라 1×1 크기의 작은 사각형이고, 그 대표점은 좌상단 모서리가 아니라 중심(+0.5, +0.5)입니다. 중심 규약을 지켜야 이웃 삼각형과의 경계 판정이 일관되고, 이후 텍스처 샘플링·안티에일리어싱과도 규약이 맞습니다.

float w0 = edge(v1.pos, v2.pos, p);, w1 = edge(v2.pos, v0.pos, p), w2 = edge(v0.pos, v1.pos, p) — 세 변 각각에 대해 "픽셀 중심이 그 변의 어느 쪽인가"를 재는 에지 값입니다. 각 w는 대응 정점의 맞은편 변에서 나옵니다: w0v0의 맞은편 변(v1v2)에서 나오므로 v0의 가중치가 됩니다(맞은편 변에서 멀수록 그 정점에 가깝다는 직관과 일치).

bool in0 = (w0 > 0) || (w0 == 0 && tl0); — 에지 값이 양수면 확실히 그 변의 내부 쪽, 정확히 0이면 변 위에 있는데 이때는 top-left(tl0)일 때만 포함합니다. 앞서 만든 top-left 규칙이 바로 여기서 작동합니다. 세 판정 in0, in1, in2모두 참일 때만 삼각형 내부입니다: if (!(in0 && in1 && in2)) continue; — 하나라도 밖이면 이 픽셀은 건너뜁니다.

⑤ 무게중심 보간

수학적 의미: l0 = w0/area, l1 = w1/area, l2 = w2/area는 무게중심 좌표(barycentric coordinates)입니다. 세 값은 항상 l0 + l1 + l2 = 1을 만족하고, 삼각형 내부에서는 모두 0~1 사이입니다. 어떤 정점의 코너에 정확히 있으면 그 정점의 가중치가 1, 나머지는 0이 됩니다.

기하 직관: l0은 "픽셀이 v0에 얼마나 가까운가"의 척도입니다. 정확히는 "픽셀 P와 v1, v2가 만드는 부분 삼각형의 넓이 ÷ 전체 삼각형 넓이"입니다. P가 v0에 붙어 있으면 그 부분 삼각형이 전체와 거의 같아 l0 ≈ 1, P가 반대쪽 변(v1v2) 위에 있으면 부분 삼각형 넓이가 0이라 l0 = 0. 세 부분 삼각형 넓이의 합은 항상 전체 넓이이므로 세 가중치의 합이 1이 됩니다.

코드 매핑: 색 보간은 채널별 가중 평균입니다 — l0 * v0.color.r + l1 * v1.color.r + l2 * v2.color.r은 세 정점 빨강값을 무게중심 비율로 섞은 값입니다. toByte(... / 255.0f)은 0~255 범위의 결과를 0~1로 정규화한 뒤 다시 0~255 바이트로 클램프·변환하는 헬퍼입니다(범위를 벗어난 값이 오버플로하지 않도록 안전하게 자릅니다). c.a = 255;로 결과는 불투명하게 둡니다.

손계산 예제: 픽셀이 세 정점의 정확한 무게중심(centroid)에 있다면 l0 = l1 = l2 = 1/3입니다. 세 정점 색이 각각 빨강(255,0,0)·초록(0,255,0)·파랑(0,0,255)이면 보간된 색의 R = (1/3·255 + 1/3·0 + 1/3·0) = 85, 마찬가지로 G=85, B=85 → 회색 (85,85,85)이 됩니다. 무지개 삼각형의 정중앙이 왜 탁한 회색인지 이 계산이 설명해 줍니다.

이 무게중심 보간이 바로 GPU가 정점 셰이더 출력을 픽셀(프래그먼트) 셰이더 입력으로 넘길 때 하는 일입니다. HLSL에서 정점 셰이더가 내보낸 float4 color : COLOR 같은 값이 삼각형 내부의 각 픽셀에서 자동으로 이렇게 보간되어 픽셀 셰이더로 들어옵니다. 세 정점을 빨강·초록·파랑으로 주면 그 유명한 "무지개 삼각형"이 나옵니다. (단, 3D에서는 원근 때문에 화면 공간 선형 보간이 왜곡되므로 원근 보정 보간이 추가로 필요합니다 — 3장에서 다룹니다.)

함정 정점 감김 순서(winding)를 신경 쓰지 않으면 면적 부호가 뒤집혀 삼각형이 통째로 사라집니다. 위 코드는 area < 0일 때 정점을 스왑해 항상 CCW로 정규화합니다. 실전에서는 이 면적 부호를 역이용해 백페이스 컬링(뒤를 향한 삼각형은 아예 버리기)을 하기도 합니다. D3D11에서는 이걸 래스터라이저 상태(D3D11_RASTERIZER_DESC::FrontCounterClockwiseCullMode)로 설정하며, 이 코드의 부호 판정이 바로 그 하드웨어 동작의 소프트웨어 버전입니다.
성능 에지 함수는 x가 1 증가하면 상수만큼 증가하는 선형 함수입니다(구체적으로 w0는 x가 +1될 때마다 (v2.y − v1.y)만큼, y가 +1될 때마다 −(v2.x − v1.x)만큼 변합니다). 그래서 실제 래스터라이저는 매 픽셀 edge()를 다시 부르지 않고, 각 행 시작에서 한 번만 계산한 뒤 안쪽 x 루프에서는 증분(w += dEdge_dx)만 더합니다. 곱셈이 사라져 몇 배 빨라지고, GPU는 이걸 타일 단위로 병렬화하기까지 합니다.

DirectXMath / D3D11 대응 1:1

이 소프트웨어 삼각형 코드의 각 부분이 실무 GPU 파이프라인에서 어디에 대응하는지 짚어 봅니다.

이 코드의 개념DirectX 11 / DirectXMath 대응
edge() 2D 외적XMVector2Cross(2D 외적의 스칼라 z성분을 반환)
내부 판정(픽셀 루프)하드웨어 래스터라이저(고정 기능) — 직접 코드 없음
l0,l1,l2 무게중심 보간정점→픽셀 셰이더 사이 자동 보간(HLSL 세맨틱), 필요 시 noperspective/기본은 원근 보정
winding 정규화 / 백페이스D3D11_RASTERIZER_DESCCullMode·FrontCounterClockwise
바운딩 박스 + 픽셀 중심래스터라이저가 서브픽셀 정밀도로 자동 수행(멀티샘플 위치 포함)
Vertex{pos,color}정점 버퍼 + D3D11_INPUT_ELEMENT_DESC 입력 레이아웃

참고로 우리 코드는 화면 좌표(픽셀)를 직접 다뤘지만, 실무에서는 정점이 로컬 공간에서 출발해 월드→뷰→투영 행렬을 거쳐 클립 공간으로 갑니다. 이때 곱셈은 행벡터 규약 v' = v * M (HLSL mul(v, M)), 행 우선 저장을 씁니다. DirectXMath의 행렬은 행벡터 기준이므로 cbuffer로 올리기 전에 XMMatrixTranspose로 전치하는 관례가 흔합니다(HLSL의 열 우선 기본과 맞추기 위해). 왼손 좌표계, 클립 z 범위 [0,1], 텍스처 V 위→아래 규약도 그대로 이어집니다. 이 변환 파이프라인 뒤에 래스터라이저가 붙어, 지금 우리가 손으로 짠 바로 이 내부 판정·보간을 하드웨어로 수행합니다.

주의 학습용으로 삼각형·선을 직접 래스터화해 보는 것은 개념 이해에 최고지만, 실무 렌더링은 GPU 하드웨어 래스터라이저와 검증된 수학 라이브러리(DirectXMath 등)를 쓰는 것이 정답입니다. 직접 구현은 서브픽셀 정밀도, 클리핑, 멀티샘플 안티에일리어싱, 원근 보정 보간 등 무수한 경계 상황을 다 처리해야 하며 이는 이 페이지 범위를 훨씬 넘습니다. 여기서 얻어야 할 것은 "GPU가 그 안에서 무엇을 하는가"에 대한 정확한 직관입니다.