래스터라이제이션 — 선과 삼각형 — 한 줄 한 줄 직접 구현
래스터화(rasterization)란 수학적으로 정의된 도형 — 두 점을 잇는 선분이나 세 점으로 둘러싸인 삼각형 — 을 받아서 "그러면 화면의 어떤 픽셀들을 켜야 하는가?" 라는 질문에 답하는 과정입니다. 도형은 연속적인 실수 좌표 위에 있지만, 화면은 정수 격자로 된 픽셀 뿐입니다. 이 둘 사이의 간극을 메우는 게 래스터라이저입니다. 이 페이지에서는 먼저 정수 덧셈만으로 선을 긋는 브레젠험(Bresenham) 알고리즘을 만들고, 이어서 에지 함수와 무게중심 좌표(barycentric coordinates)로 삼각형을 채우면서 정점 색까지 부드럽게 보간해 봅니다. 이건 장난감이 아니라, GPU가 매 프레임 수백만 번씩 하는 일의 축소판입니다.
- 브레젠험으로 두 점 사이를 잇는 픽셀들을 구한다 — 곱셈·나눗셈·부동소수점 없이 정수 덧셈만으로.
- 삼각형의 바운딩 박스를 구하고, 그 안의 픽셀마다 에지 함수로 내부/외부를 판정한다.
- 세 에지 값을 면적으로 나눈 무게중심 가중치로 정점 색(또는 UV 등)을 보간한다.
- top-left 규칙으로 인접한 두 삼각형이 공유 경계 픽셀을 이중으로 찍거나 빠뜨리는 것을 방지한다.
브레젠험 직선
무엇을 만드나: 정수 좌표 두 점 (x0,y0), (x1,y1)을 받아
그 사이를 잇는 픽셀들을 하나씩 찍는 함수입니다.
왜 필요한가: 화면에 직선을 그리는 것은 모든 2D/3D 렌더링의 가장 기초적인 원시 연산(primitive)입니다.
와이어프레임, 디버그 라인, 그래프 축, 그리드 등 어디에나 쓰입니다. "직선의 방정식 y = m·x + b를
풀어서 각 x마다 y를 구하면 되지 않나?"라고 생각할 수 있는데, 그 방식은 매 픽셀마다 부동소수점 곱셈과
반올림이 필요합니다. 브레젠험은 그 모든 걸 정수 덧셈과 비교로 대체하는 유명한 알고리즘입니다.
직선을 그릴 때 가장 까다로운 건 "기울기"입니다. 완만한 선(가로로 길쭉한 선)은 x를 한 칸 갈 때마다 y가 아주 조금 변하고, 가파른 선(세로로 길쭉한 선)은 반대입니다. 또 좌→우로 그릴 수도, 우→좌로, 위→아래로도 그릴 수 있습니다. 이 여덟 가지 방향(팔분면, octant)을 각각 따로 처리하면 코드가 지저분해집니다. 아래 코드는 부호 변수(sx, sy)와 오차 항(err) 하나로 여덟 방향을 전부 일반화한, 흔히 "일반화된 브레젠험"이라 불리는 형태입니다.
- 두 점의 x·y 거리차의 절댓값
dx,dy를 구한다(단, dy는 음수로 저장). - 진행 방향
sx,sy(+1 또는 −1)를 정한다. - 오차 항
err을 초기화하고, 루프에서 현재 픽셀을 찍는다. - 오차 항의 부호를 보고 x를 전진할지, y를 전진할지(또는 둘 다) 결정한다.
- 끝점에 도달하면 종료한다.
#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) 를 하나씩 뜯어봅니다.
inline: 이 함수는 헤더 파일(line.h)에 정의를 통째로 넣었습니다. 여러.cpp가 이 헤더를#include하면 함수 정의가 여러 번 생기는데, 링커가 이걸 "중복 정의"로 보고 오류를 냅니다.inline은 "이 함수는 여러 번 정의돼도 하나로 취급하라"고 링커에게 알려 그 오류(ODR 위반)를 막습니다. 성능상 "함수 호출을 펼쳐라"라는 힌트이기도 하지만, 헤더 온리 라이브러리에서는 링크 규칙 쪽 의미가 더 큽니다.void: 반환값이 없습니다. 이 함수는 값을 계산해 돌려주는 게 아니라, 인자로 받은 프레임버퍼를 직접 수정하는 게 목적(부수 효과, side effect)이기 때문입니다.Framebuffer& fb: 앰퍼샌드&는 참조(reference)를 뜻합니다. 값으로 받았다면(Framebuffer fb) 폭×높이 개의 픽셀 배열이 통째로 복사되어 느리고, 게다가 복사본을 수정하니 원본 화면에는 아무 변화가 없습니다. 참조로 받으면 "원본 그 자체"를 가리키므로 복사 없이 원본을 고칩니다. 여기서는 원본을 바꿔야 하므로const를 붙이지 않았습니다.int x0, int y0, int x1, int y1: 시작점과 끝점의 픽셀 좌표입니다. 픽셀은 정수 격자 위에 있으므로int가 자연스럽고, 이게 바로 브레젠험이 정수 연산만 쓸 수 있는 전제이기도 합니다.Color c: 선의 색.Color는 작은 구조체(RGBA 4바이트 정도)라 값 복사 비용이 미미하므로 참조 대신 값으로 받아도 괜찮습니다.
초기화 다섯 줄
수학적 의미 → 기하 직관 → 코드 매핑 순서로 봅니다.
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; } — e2가 dy(음수) 이상이면
"x를 한 칸 전진해도 여전히 이상적인 직선에 충분히 가깝다"는 뜻입니다. 그래서 x를 sx 방향으로 한 칸
옮기고, 그 대가로 오차에 dy를 더해 균형을 맞춥니다.
if (e2 <= dx) { err += dx; y0 += sy; } — 마찬가지로 e2가 dx 이하이면
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)를 손으로 추적해 봅시다. 완만한 선(가로가 더 긴 선)입니다.
- 초기화:
dx = |4-0| = 4,dy = -|2-0| = -2,sx = +1,sy = +1,err = 4 + (-2) = 2. - 스텝1 —
(0,0)찍음. 끝점 아님.e2 = 4.4 >= -2참 →err = 2-2 = 0,x0 = 1.4 <= 4참 →err = 0+4 = 4,y0 = 1. - 스텝2 —
(1,1)찍음.e2 = 8.8 >= -2참 →err = 4-2 = 2,x0 = 2.8 <= 4거짓 → y 그대로. - 스텝3 —
(2,1)찍음.e2 = 4.4 >= -2참 →err = 2-2 = 0,x0 = 3.4 <= 4참 →err = 4,y0 = 2. - 스텝4 —
(3,2)찍음.e2 = 8.8 >= -2참 →x0 = 4.8 <= 4거짓. - 스텝5 —
(4,2)찍음. 이제x0==x1 && y0==y1→ 종료.
찍힌 픽셀은 (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·법선 등)을 매끄럽게 보간할 수 있습니다.
#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도 채택) 관례입니다.
// 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() — 전체 흐름
- 세 정점으로 삼각형의 부호 있는 면적을 구하고, winding을 CCW로 정규화한다.
- 세 정점을 감싸는 바운딩 박스를 구해 검사 범위를 좁힌다(+ 화면 밖 클리핑).
- 세 에지의 top-left 여부를 미리 계산해 둔다.
- 박스 안 모든 픽셀 중심에서 세 에지 함수를 평가해 내부 판정.
- 내부 픽셀은 무게중심 가중치로 색을 보간해 칠한다.
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로 들어온 삼각형은 v1과
v2를 맞바꿔 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 경계인지를 루프 밖에서
딱 한 번 계산합니다. 삼각형 하나에 대해 변은 세 개뿐인데 픽셀은 수천 개일 수 있으니, 픽셀 루프 안에서
매번 다시 계산하지 않도록 미리 빼둔 것입니다. 인덱스 대응에 주의하세요: tl0은 v0의
맞은편 변인 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는 대응 정점의 맞은편 변에서 나옵니다: w0은 v0의
맞은편 변(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장에서 다룹니다.)
area < 0일 때 정점을 스왑해 항상 CCW로 정규화합니다. 실전에서는 이 면적 부호를 역이용해
백페이스 컬링(뒤를 향한 삼각형은 아예 버리기)을 하기도 합니다. D3D11에서는 이걸
래스터라이저 상태(D3D11_RASTERIZER_DESC::FrontCounterClockwise와 CullMode)로 설정하며,
이 코드의 부호 판정이 바로 그 하드웨어 동작의 소프트웨어 버전입니다.
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_DESC의 CullMode·FrontCounterClockwise |
| 바운딩 박스 + 픽셀 중심 | 래스터라이저가 서브픽셀 정밀도로 자동 수행(멀티샘플 위치 포함) |
Vertex{pos,color} | 정점 버퍼 + D3D11_INPUT_ELEMENT_DESC 입력 레이아웃 |
참고로 우리 코드는 화면 좌표(픽셀)를 직접 다뤘지만, 실무에서는 정점이 로컬 공간에서 출발해
월드→뷰→투영 행렬을 거쳐 클립 공간으로 갑니다. 이때 곱셈은 행벡터 규약 v' = v * M
(HLSL mul(v, M)), 행 우선 저장을 씁니다. DirectXMath의 행렬은 행벡터 기준이므로 cbuffer로 올리기
전에 XMMatrixTranspose로 전치하는 관례가 흔합니다(HLSL의 열 우선 기본과 맞추기 위해). 왼손 좌표계,
클립 z 범위 [0,1], 텍스처 V 위→아래 규약도 그대로 이어집니다. 이 변환 파이프라인 뒤에
래스터라이저가 붙어, 지금 우리가 손으로 짠 바로 이 내부 판정·보간을 하드웨어로 수행합니다.