레이 트레이싱 (CPU) — 한 줄 한 줄 직접 구현
래스터화가 "삼각형을 화면에 투영"하는 순방향 파이프라인이라면, 레이 트레이싱은 방향을 뒤집습니다. 픽셀마다 광선(ray)을 세계 속으로 쏘아 무엇과 먼저 부딪히는지 찾고, 그 지점의 색을 계산합니다. 이 한 가지 뒤집기 덕분에 그림자·반사·굴절이 억지 트릭 없이 물리적으로 자연스럽게 흘러나옵니다. 이 페이지에서는 외부 라이브러리 없는 자립 실행 C++ 레이 트레이서를, 벡터 구조체의 연산자 오버로딩부터 2차 방정식 교차·램버트 셰이딩·그림자 광선·재귀 반사·감마 보정까지 성분 하나, 코드 한 줄 단위로 해부합니다. 순수 CPU 계산이라 D3D11 없이 그대로 돌아가고, 마지막에 결과 버퍼를 화면에 올리는 D3D11 연결법도 짚습니다.
v' = v * M(HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계,
클립 공간 z 범위 [0,1], 텍스처 V축 위→아래. 다만 이 CPU 레이 트레이서는 행렬 변환을 거의 쓰지 않고
월드 좌표에서 직접 광선을 다루므로, 좌표계 규약이 눈에 띄게 드러나는 곳은 카메라 광선 생성 부분뿐입니다.
실무에서는 벡터·행렬 연산을 DirectXMath(검증된 SIMD 라이브러리)로 처리하길 권합니다. 여기서 벡터를 직접
구현하는 이유는 오로지 내부 동작을 눈으로 보기 위한 학습 목적입니다.
전체 흐름 한눈에 보기
코드를 뜯어보기 전에, 이 레이 트레이서가 하나의 픽셀 색을 어떻게 만들어내는지 큰 그림을 먼저 그려두겠습니다.
아래 다섯 단계가 main()의 이중 루프 안에서 픽셀마다 반복됩니다.
- 광선 정의 — 카메라 원점에서 각 픽셀을 향하는 광선(원점 + 정규화된 방향)을 만든다.
- 레이-구 교차 — 광선과 각 구의 교차를 2차 방정식으로 풀어 가장 가까운 충돌을 찾는다.
- 셰이딩 — 충돌점에서 램버트(확산) 조명을 계산하고, 광원으로 그림자 광선을 쏘아 가림을 판정한다.
- 재귀 반사 — 재질이 거울성이면 반사 방향으로 광선을 다시 쏘아(최대 N회 바운스) 색을 섞는다.
- 출력 — 계산된 선형 색에 감마 보정을 적용해 0~255 정수로 만들고 PPM 이미지로 저장한다.
핵심 통찰은 "보이는 것을 거꾸로 추적한다"는 데 있습니다. 현실에서 빛은 광원에서 출발해 물체에 부딪히고 튕겨 우리 눈에 들어오지만, 그 무수한 광자 중 카메라에 도달하는 것은 극소수입니다. 그래서 레이 트레이싱은 카메라(눈)에서 거꾸로 광선을 쏩니다. 어차피 우리가 알고 싶은 건 "이 픽셀에 어떤 빛이 도달했는가"뿐이니, 그 픽셀에 도달할 자격이 있는 경로만 역추적하는 것이 압도적으로 효율적입니다. 이 방식을 backward ray tracing 또는 eye tracing이라 부릅니다.
// cl /O2 /EHsc raytracer.cpp (또는 g++ -O2 raytracer.cpp -o rt), 실행 > out.ppm
#include <cmath>
#include <cstdio>
#include <vector>
#include <limits>
#include <algorithm>
// --- 최소 3D 벡터 (외부 수학 라이브러리 의존 없음) ---
struct Vec3 {
float x, y, z;
Vec3(float a=0, float b=0, float c=0):x(a),y(b),z(c){}
Vec3 operator+(const Vec3& o) const { return {x+o.x, y+o.y, z+o.z}; }
Vec3 operator-(const Vec3& o) const { return {x-o.x, y-o.y, z-o.z}; }
Vec3 operator*(float s) const { return {x*s, y*s, z*s}; }
Vec3 operator*(const Vec3& o) const { return {x*o.x, y*o.y, z*o.z}; }
};
float dot(const Vec3& a, const Vec3& b){ return a.x*b.x + a.y*b.y + a.z*b.z; }
float len(const Vec3& a){ return std::sqrt(dot(a,a)); }
Vec3 normalize(const Vec3& a){ float l = len(a); return a * (1.0f/l); }
Vec3 reflect(const Vec3& d, const Vec3& n){ return d - n * (2.0f * dot(d,n)); }
struct Ray { Vec3 o, d; }; // 원점, 정규화된 방향
struct Sphere {
Vec3 center;
float radius;
Vec3 albedo;
float reflectivity; // 0=난반사, 1=완전 거울
};
// 레이-구 교차: |o + t d - c|^2 = r^2 를 t에 대해 푼다
bool hitSphere(const Sphere& s, const Ray& r, float& t) {
Vec3 oc = r.o - s.center;
float b = 2.0f * dot(oc, r.d);
float c = dot(oc, oc) - s.radius * s.radius;
float disc = b*b - 4.0f*c; // a=1 (d가 단위벡터)
if (disc < 0.0f) return false;
float sq = std::sqrt(disc);
float t0 = (-b - sq) * 0.5f;
float t1 = (-b + sq) * 0.5f;
t = (t0 > 1e-3f) ? t0 : t1; // 앞쪽 교차 우선
return t > 1e-3f;
}
std::vector<Sphere> scene = {
{{ 0.0f, -0.5f, -4.0f}, 1.0f, {0.9f, 0.3f, 0.3f}, 0.3f}, // 빨강, 약한 반사
{{ 2.2f, 0.0f, -6.0f}, 1.2f, {0.3f, 0.6f, 0.9f}, 0.8f}, // 파랑, 거울성
{{-2.2f, 0.0f, -5.0f}, 1.0f, {0.3f, 0.9f, 0.4f}, 0.0f}, // 초록, 무광
{{ 0.0f,-101.0f,-5.0f}, 100.0f,{0.7f, 0.7f, 0.7f}, 0.1f} // 바닥(큰 구)
};
Vec3 lightPos(5.0f, 8.0f, 0.0f);
// 씬에서 가장 가까운 교차 구를 찾는다
int closest(const Ray& r, float& tHit) {
int idx = -1; tHit = std::numeric_limits<float>::max();
for (int i = 0; i < (int)scene.size(); ++i) {
float t;
if (hitSphere(scene[i], r, t) && t < tHit) { tHit = t; idx = i; }
}
return idx;
}
// 재귀적 광선 추적
Vec3 trace(const Ray& r, int depth) {
if (depth <= 0) return {0,0,0};
float t; int i = closest(r, t);
if (i < 0) return {0.5f, 0.7f, 1.0f}; // 하늘색 배경
const Sphere& s = scene[i];
Vec3 p = r.o + r.d * t; // 교차점
Vec3 n = normalize(p - s.center); // 표면 법선
Vec3 toL = normalize(lightPos - p);
// 그림자 광선: 교차점 → 광원 사이에 막히는 물체가 있나
Ray shadow{ p + n * 1e-3f, toL };
float st; bool inShadow = (closest(shadow, st) >= 0);
float diff = inShadow ? 0.0f : std::max(0.0f, dot(n, toL));
Vec3 local = s.albedo * (0.1f + 0.9f * diff); // 앰비언트 + 램버트
// 거울 반사 재귀
if (s.reflectivity > 0.0f) {
Ray refl{ p + n * 1e-3f, normalize(reflect(r.d, n)) };
Vec3 rc = trace(refl, depth - 1);
local = local * (1.0f - s.reflectivity) + rc * s.reflectivity;
}
return local;
}
int main() {
const int W = 640, H = 480, MAX_DEPTH = 5;
const float aspect = (float)W / H;
std::printf("P3\n%d %d\n255\n", W, H); // PPM 헤더
Vec3 cam(0, 0, 0);
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
// 픽셀 → NDC → 카메라 광선 (fov 90도 가정, 초점거리 1)
float px = (2.0f * (x + 0.5f) / W - 1.0f) * aspect;
float py = 1.0f - 2.0f * (y + 0.5f) / H;
Ray r{ cam, normalize(Vec3(px, py, -1.0f)) };
Vec3 col = trace(r, MAX_DEPTH);
auto to255 = [](float v){
return (int)(std::clamp(std::pow(v, 1.0f/2.2f), 0.0f, 1.0f) * 255);
};
std::printf("%d %d %d ", to255(col.x), to255(col.y), to255(col.z));
}
}
return 0;
}
위가 전체 코드입니다. 지금부터 이 코드를 여섯 덩어리로 나눠, 각 덩어리마다 코드 조각을 다시 보여준 뒤 시그니처·각 줄·수식·성분을 하나하나 뜯어보겠습니다. 순서는 (1) 벡터 구조체, (2) 벡터 유틸 함수, (3) 광선·구·재질 정의, (4) 레이-구 교차, (5) 씬과 최근접 탐색, (6) 재귀 추적, (7) 카메라 광선 생성과 출력입니다.
1. Vec3 — 최소 3D 벡터 구조체
무엇을 만드나: 3차원 벡터 하나를 담는 값 타입입니다. 점(위치), 방향, 색(RGB)을 모두 이 하나로 표현합니다. 왜 필요한가: 레이 트레이싱의 거의 모든 계산 — 광선 위치, 방향, 법선, 색 — 이 3성분 실수 묶음이기 때문입니다. 외부 라이브러리 없이 내부 동작을 투명하게 보여주려고 직접 만들었습니다.
struct Vec3 {
float x, y, z;
Vec3(float a=0, float b=0, float c=0):x(a),y(b),z(c){}
Vec3 operator+(const Vec3& o) const { return {x+o.x, y+o.y, z+o.z}; }
Vec3 operator-(const Vec3& o) const { return {x-o.x, y-o.y, z-o.z}; }
Vec3 operator*(float s) const { return {x*s, y*s, z*s}; }
Vec3 operator*(const Vec3& o) const { return {x*o.x, y*o.y, z*o.z}; }
};
필드와 생성자
float x, y, z; — 세 개의 32비트 실수를 순서대로 나란히 저장합니다. C++에서 이렇게 선언하면 메모리에
x, y, z가 연속으로 12바이트를 차지합니다. 이 연속성 덕분에
나중에 Vec3 배열을 통째로 GPU 텍스처나 버퍼에 memcpy할 수 있습니다.
Vec3(float a=0, float b=0, float c=0):x(a),y(b),z(c){} — 생성자입니다. 하나씩 뜯어보면:
float a=0, float b=0, float c=0— 세 인자에 기본값 0을 줬습니다. 그래서Vec3()는(0,0,0),Vec3(5)는(5,0,0),Vec3(1,2,3)은(1,2,3)이 됩니다. 기본값이 있으니 하나의 생성자로 인자 0~3개를 모두 받습니다.:x(a),y(b),z(c)— 멤버 초기화 리스트(member initializer list)입니다. 함수 본문{}에 들어가기 전에 멤버를 곧바로 초기화합니다.{ x=a; y=b; z=c; }처럼 본문에서 대입하는 것보다 빠르고 안전합니다 — 특히const나 참조 멤버는 초기화 리스트로만 세팅할 수 있습니다.{}— 본문이 비어 있습니다. 초기화가 모두 리스트에서 끝났으니 할 일이 없습니다.
연산자 오버로딩이 무엇이고 왜 하나
연산자 오버로딩(operator overloading)은 +, -, * 같은 기호를
사용자 정의 타입에 대해 동작하도록 정의하는 기능입니다. 이게 없다면 두 벡터를 더할 때
add(a, b)처럼 함수를 써야 하고, a + b*t - c 같은 물리 수식이
sub(add(a, mul(b,t)), c)처럼 읽기 힘든 형태가 됩니다. 오버로딩을 하면 종이에 쓴 수식과
코드가 거의 똑같이 생겨서, 오류가 줄고 의도가 드러납니다.
+ 와 - : 성분별 덧셈·뺄셈
Vec3 operator+(const Vec3& o) const { return {x+o.x, y+o.y, z+o.z}; }
시그니처 해부:
Vec3(반환 타입) — 덧셈 결과는 새 벡터이므로Vec3를 값으로 돌려줍니다.const Vec3& o(인자) — 오른쪽 피연산자입니다. 참조(&)로 받는 이유는 복사 비용(12바이트)을 피하기 위해서고,const를 붙인 이유는 이 함수가o를 절대 바꾸지 않겠다는 약속을 컴파일러에게 하기 위해서입니다. 이 약속 덕분에 임시 객체(예:a + (b*t)의b*t)도 인자로 넘길 수 있습니다.- 끝의
const(멤버 함수 한정자) — 왼쪽 피연산자인*this(즉a + b에서a)도 바꾸지 않겠다는 뜻입니다. 그래서const Vec3끼리도 자유롭게 더할 수 있습니다. return {x+o.x, y+o.y, z+o.z};— 중괄호{...}는 집합 초기화(aggregate/brace init)로, 반환 타입Vec3의 생성자에 세 값을 넘겨 새 벡터를 만듭니다.x는 왼쪽 벡터(자기 자신)의 성분,o.x는 오른쪽 벡터의 성분입니다.
수학적 의미: 벡터 덧셈은 성분별로 더합니다. (1,2,3) + (4,0,0) = (5,2,3).
기하 직관: 벡터를 "이동 화살표"라 생각하면, a + b는 a만큼 걸어간 뒤 이어서 b만큼 더 걷는 것입니다.
뺄셈 b - a는 "a에서 b로 향하는 화살표"입니다 — 이 성질은 뒤에서
lightPos - p(교차점에서 광원으로 향하는 방향)를 만들 때 그대로 쓰입니다.
* 오버로딩이 두 개인 이유
같은 * 기호를 두 가지로 정의했습니다. 인자 타입이 다르므로 컴파일러가 자동으로 구별합니다(오버로드 해소).
operator*(float s)— 스칼라 곱(scaling). 벡터를s배 늘이거나 줄입니다.(1,2,3) * 2 = (2,4,6). 광선을 따라t만큼 나아간 점r.d * t가 대표적입니다.operator*(const Vec3& o)— 성분별 곱(Hadamard product). 수학의 내적·외적과는 전혀 다른, "같은 자리 성분끼리 곱하기"입니다.(0.9,0.3,0.3) * (0.5,0.7,1.0) = (0.45,0.21,0.3). 이건 색 필터링에 씁니다 — 빨간 물체의 albedo에 하늘색 빛을 곱하면, 빨간 채널만 살아남아 붉게 보입니다.
*는 내적(dot)도 외적(cross)도 아닙니다. 초보자가 dot(a,b)를 써야 할 곳에
a*b를 쓰면 컴파일은 되지만 결과가 완전히 틀립니다(스칼라가 아니라 벡터가 나옴). 내적은 반드시 별도 함수
dot()로 씁니다.
Vec3 대신 XMVECTOR(SIMD 128비트 레지스터, 4×float)와 저장용 XMFLOAT3를 씁니다.
대응 관계는 이렇습니다 — a + b → XMVectorAdd(a, b), a - b → XMVectorSubtract(a, b),
v * s → XMVectorScale(v, s), 성분별 곱 a * b → XMVectorMultiply(a, b).
XMFLOAT3(메모리)와 XMVECTOR(연산용) 사이는 XMLoadFloat3 / XMStoreFloat3로 오갑니다.
SIMD라 4성분을 한 명령에 처리해 훨씬 빠릅니다.
2. 벡터 유틸 함수 — dot · len · normalize · reflect
float dot(const Vec3& a, const Vec3& b){ return a.x*b.x + a.y*b.y + a.z*b.z; }
float len(const Vec3& a){ return std::sqrt(dot(a,a)); }
Vec3 normalize(const Vec3& a){ float l = len(a); return a * (1.0f/l); }
Vec3 reflect(const Vec3& d, const Vec3& n){ return d - n * (2.0f * dot(d,n)); }
dot — 내적 (모든 조명 계산의 심장)
float dot(const Vec3& a, const Vec3& b){ return a.x*b.x + a.y*b.y + a.z*b.z; } —
두 벡터의 대응 성분을 곱해 모두 더합니다. 반환은 벡터가 아니라 스칼라(float) 하나입니다.
수학적 의미 → 기하 직관 → 코드 매핑 (3단):
- 수학:
a·b = |a||b|cos θ. 두 벡터가 단위 벡터라면|a|=|b|=1이므로 내적은 곧 두 방향 사이 각의 코사인입니다. - 기하 직관: 내적은 "한 벡터가 다른 벡터 방향으로 얼마나 정렬돼 있나"를 잽니다. 같은 방향이면
+1, 수직이면0, 반대 방향이면-1. 그래서 표면 법선n과 광원 방향toL의 내적은 "빛이 이 표면을 얼마나 정면으로 때리나"를 뜻하고, 이게 램버트 조명의 밝기가 됩니다. - 코드 매핑:
a.x*b.x + a.y*b.y + a.z*b.z가 성분곱의 합, 즉 정의 그대로입니다.
손계산 예제: (1,2,3)·(4,0,0) = 1·4 + 2·0 + 3·0 = 4. 또 (0,1,0)·(0,1,0) = 1(자기 자신과의 내적은 길이의 제곱).
len — 길이(크기)
float len(const Vec3& a){ return std::sqrt(dot(a,a)); } —
피타고라스 정리의 3차원 확장입니다. dot(a,a) = x²+y²+z²가 길이의 제곱이고,
std::sqrt로 제곱근을 취하면 실제 길이가 됩니다. |a| = √(x²+y²+z²).
손계산: len((3,4,0)) = √(9+16+0) = √25 = 5.
normalize — 방향만 남기고 길이를 1로
Vec3 normalize(const Vec3& a){ float l = len(a); return a * (1.0f/l); } —
벡터를 자기 길이로 나눠 길이 1의 단위 벡터로 만듭니다. 방향은 그대로 두고 크기만 1로 통일합니다.
왜 필요한가: 광선 방향, 법선, 광원 방향을 모두 단위 벡터로 맞춰두면, 앞서 본 dot = cos θ가
성립해 조명 계산이 깔끔해집니다. 또 교차 계산에서 방향이 단위 벡터라는 가정(a=1) 덕분에 2차 방정식이 크게 단순해집니다.
손계산: normalize((3,4,0)) = (3,4,0) * (1/5) = (0.6, 0.8, 0). 이 결과의 길이는 √(0.36+0.64) = 1입니다.
len(a)가 0(영벡터)이면 1.0f/l이 0으로 나누기가 되어 inf/NaN이 번집니다.
학습 코드라 생략했지만, 실무에서는 if (l > 1e-8f) 가드를 두거나 XMVector3Normalize(0 처리 내장)를 씁니다.
reflect — 거울 반사 방향 (반사의 핵심 공식)
Vec3 reflect(const Vec3& d, const Vec3& n){ return d - n * (2.0f * dot(d,n)); } —
입사 방향 d가 법선 n인 표면에 부딪혔을 때, 거울처럼 튕겨 나가는 방향을 계산합니다.
수학 → 기하 직관 → 코드 (3단):
- 수학:
r = d - 2(d·n)n. 여기서d·n은 입사 방향 중 법선 축에 놓인 성분의 크기이고,(d·n)n은 그 성분을 벡터로 되살린 것입니다. - 기하 직관: 입사 벡터를 "법선에 나란한 성분"과 "표면에 나란한 성분"으로 분해한다고 상상해 보세요.
반사는 법선 성분의 부호만 뒤집고 표면 성분은 그대로 둡니다(입사각 = 반사각). 원래 벡터에서
법선 성분을 두 번 빼면(
-2(d·n)n) 정확히 그 부호가 뒤집힙니다. - 코드 매핑:
dot(d,n)이d·n,n * (2.0f*dot(d,n))이2(d·n)n,d - ...가 그 뺄셈입니다.
손계산: 바닥(법선 n=(0,1,0))에 아래로 비스듬히 들어오는 광선 d=(1,-1,0)을 생각합시다.
d·n = 1·0 + (-1)·1 + 0·0 = -1. 따라서 r = (1,-1,0) - (0,1,0)·(2·-1) = (1,-1,0) - (0,-2,0) = (1,1,0).
아래로 오던 광선이 위로 튕겨 나갔습니다 — 수평 성분 x=1은 그대로, 수직 성분만 -1 → +1로 뒤집혔습니다. 정확합니다.
dot → XMVector3Dot(결과가 4성분에 브로드캐스트된 XMVECTOR), len → XMVector3Length,
normalize → XMVector3Normalize, reflect → XMVector3Reflect(d, n).
특히 XMVector3Reflect는 위의 d - 2(d·n)n 공식을 SIMD로 그대로 구현한 것이라 부호·정의가 이 코드와 동일합니다.
3. Ray · Sphere — 광선과 구, 재질의 정의
struct Ray { Vec3 o, d; }; // 원점, 정규화된 방향
struct Sphere {
Vec3 center;
float radius;
Vec3 albedo;
float reflectivity; // 0=난반사, 1=완전 거울
};
Ray — 반직선
Ray는 원점 o(origin)와 방향 d(direction) 두 벡터로 이뤄집니다.
수학적으로 광선 위의 점은 매개변수 t ≥ 0에 대해 P(t) = o + t·d로 표현됩니다.
t는 "원점에서 얼마나 나아갔나"이고, d가 단위 벡터라면 t가 곧 실제 거리(미터 같은 물리 단위)가 됩니다.
이 반직선-방정식이 앞으로 나올 모든 교차·셰이딩의 뼈대입니다.
Sphere — 구와 재질을 한 덩어리로
구는 기하(center, radius)와 재질(albedo, reflectivity)을 함께 담습니다.
Vec3 center— 구의 중심 위치(월드 좌표).float radius— 반지름. 나중에 바닥을 반지름 100짜리 거대한 구로 근사하는 트릭에 쓰입니다.Vec3 albedo— 반사율 색. "이 표면이 각 색 채널의 빛을 얼마나 되돌려 보내나"를0~1로 나타냅니다.(0.9,0.3,0.3)이면 빨강은 90% 되돌리고 초록·파랑은 30%만 되돌려서 붉게 보입니다. 물리적으로는 확산 반사의 세기입니다.float reflectivity— 거울성.0이면 순수 난반사(무광),1이면 완전 거울. 중간값이면 로컬 색과 반사 색을 그 비율로 섞습니다(뒤의trace에서 사용).
4. hitSphere — 레이-구 교차 (2차 방정식 풀기)
무엇을 만드나: 광선이 특정 구를 뚫고 지나가는지, 지나간다면 가장 먼저 어느 거리 t에서 닿는지 구합니다.
왜 필요한가: 이게 레이 트레이서에서 가장 자주 호출되는 함수입니다(픽셀 × 구 × 바운스마다). 여기 로직이 정확하고 빨라야 합니다.
// 레이-구 교차: |o + t d - c|^2 = r^2 를 t에 대해 푼다
bool hitSphere(const Sphere& s, const Ray& r, float& t) {
Vec3 oc = r.o - s.center;
float b = 2.0f * dot(oc, r.d);
float c = dot(oc, oc) - s.radius * s.radius;
float disc = b*b - 4.0f*c; // a=1 (d가 단위벡터)
if (disc < 0.0f) return false;
float sq = std::sqrt(disc);
float t0 = (-b - sq) * 0.5f;
float t1 = (-b + sq) * 0.5f;
t = (t0 > 1e-3f) ? t0 : t1; // 앞쪽 교차 우선
return t > 1e-3f;
}
수학 유도 — 왜 2차 방정식이 나오나
구는 "중심 c에서 거리가 정확히 r인 점들의 집합"입니다. 즉 어떤 점 P가 구 위에 있으려면
|P - c|² = r². 광선 위의 점 P = o + t·d를 대입하면:
|o + t·d - c|² = r²
여기서 oc = o - c(구 중심에서 광선 원점으로 향하는 벡터)로 두면 |oc + t·d|² = r².
좌변을 내적으로 전개합니다 — |v|² = v·v이므로:
(d·d)t² + 2(oc·d)t + (oc·oc - r²) = 0
이것은 t에 대한 표준 2차 방정식 a t² + b t + c = 0입니다. 계수는:
a = d·d— 그런데d를 단위 벡터로 정규화해 두었으니d·d = 1. 그래서 코드에a가 아예 없습니다. (주석// a=1)b = 2(oc·d)→ 코드의float b = 2.0f * dot(oc, r.d);c = oc·oc - r²→ 코드의float c = dot(oc, oc) - s.radius * s.radius;
줄 단위 해부
bool hitSphere(const Sphere& s, const Ray& r, float& t)— 반환은 "맞았나(bool)".s,r은const 참조(안 바꿈, 복사 안 함). 세 번째float& t는 비-const 참조 = 출력 인자(out parameter)입니다. 함수가 계산한 교차 거리를 이 인자를 통해 바깥으로 돌려줍니다. 반환값 하나(bool)와 출력 인자 하나(t)를 함께 쓰는 흔한 C++ 관용구입니다.Vec3 oc = r.o - s.center;— 구 중심에서 광선 원점으로의 벡터(o - c). 위 유도의oc입니다.float disc = b*b - 4.0f*c;— 판별식b² - 4ac인데a=1이라b² - 4c. 판별식의 부호가 교차 개수를 결정합니다:<0안 맞음(허근),=0접함(중근),>0두 점에서 관통.if (disc < 0.0f) return false;— 판별식이 음수면 광선이 구를 완전히 빗나간 것이므로 즉시 실패. 이 조기 반환이sqrt(느린 연산)를 피하게 해줘 성능에도 좋습니다.float sq = std::sqrt(disc);—√판별식. 근의 공식의√(b²-4ac)부분.float t0 = (-b - sq) * 0.5f;/float t1 = (-b + sq) * 0.5f;— 근의 공식t = (-b ± √disc) / (2a)에서a=1이라2a=2, 즉×0.5.t0가 더 작은 근(가까운 교차점, 광선이 구에 들어가는 지점),t1이 더 큰 근(나가는 지점)입니다.t = (t0 > 1e-3f) ? t0 : t1;— 가장 가까운 유효 교차를 선택합니다.t0이 아주 작은 양수(> 1e-3)면 그걸 씁니다(정상적으로 앞에서 부딪힘). 만약t0이 음수/영에 가까우면 광선 원점이 이미 구 안에 있다는 뜻이라 나가는 지점t1을 씁니다.return t > 1e-3f;— 최종 선택한t도 앞쪽(양수, 엡실론 초과)일 때만 참을 반환. 둘 다 뒤(음수)면 구가 광선 뒤편에 있다는 뜻이라 거짓.
1e-3f(0.001)라는 작은 엡실론으로 t를 걸러내는 이유는 self-intersection 방지입니다.
반사·그림자 광선의 출발점은 표면 바로 그 위인데, 부동소수점 오차로 t가 아주 작은 양수로 계산되면
광선이 자기 표면과 즉시 재교차합니다. 엡실론보다 가까운 교차는 무시해 이를 막습니다.
b = 2(oc·d)로 두어 근의 공식에 ×0.5가 붙습니다. 실무 최적화에서는 half_b = oc·d로 두면
판별식이 half_b² - c, 근이 -half_b ± √disc가 되어 곱셈이 몇 개 줄어듭니다(다음 페이지 패스 트레이서의 hit가
바로 이 최적화 형태입니다). 결과는 동일하고 상수만 정리한 것입니다.
5. scene · closest — 씬 데이터와 최근접 탐색
std::vector<Sphere> scene = {
{{ 0.0f, -0.5f, -4.0f}, 1.0f, {0.9f, 0.3f, 0.3f}, 0.3f}, // 빨강, 약한 반사
{{ 2.2f, 0.0f, -6.0f}, 1.2f, {0.3f, 0.6f, 0.9f}, 0.8f}, // 파랑, 거울성
{{-2.2f, 0.0f, -5.0f}, 1.0f, {0.3f, 0.9f, 0.4f}, 0.0f}, // 초록, 무광
{{ 0.0f,-101.0f,-5.0f}, 100.0f,{0.7f, 0.7f, 0.7f}, 0.1f} // 바닥(큰 구)
};
Vec3 lightPos(5.0f, 8.0f, 0.0f);
// 씬에서 가장 가까운 교차 구를 찾는다
int closest(const Ray& r, float& tHit) {
int idx = -1; tHit = std::numeric_limits<float>::max();
for (int i = 0; i < (int)scene.size(); ++i) {
float t;
if (hitSphere(scene[i], r, t) && t < tHit) { tHit = t; idx = i; }
}
return idx;
}
중첩 중괄호로 읽는 씬 정의
std::vector<Sphere>는 Sphere의 가변 배열입니다. 각 원소는 Sphere의 필드 순서
(center, radius, albedo, reflectivity)대로 중괄호로 초기화됩니다.
예를 들어 첫 줄 {{ 0.0f, -0.5f, -4.0f}, 1.0f, {0.9f, 0.3f, 0.3f}, 0.3f}는:
{0.0f, -0.5f, -4.0f}→center. z가-4인 이유는 왼손 좌표계에서 카메라가 -z를 바라보도록 광선을 쏘기 때문입니다(뒤의 카메라 절 참조). 화면 앞쪽이 음의 z입니다.1.0f→radius.{0.9f, 0.3f, 0.3f}→albedo(붉은색).0.3f→reflectivity(약한 반사).
네 번째 구 {{0, -101, -5}, 100, ...}는 바닥 트릭입니다. 중심을 한참 아래(y=-101)에 두고
반지름을 100으로 키우면, 카메라 앞에서는 표면이 거의 평평하게 보여 바닥 평면 역할을 합니다. 평면 교차 코드를 따로 짤 필요 없이
구 교차 하나로 바닥까지 처리하는 영리한 방법입니다. lightPos(5,8,0)은 오른쪽 위 앞쪽의 점광원입니다.
closest — 선형 순회로 가장 가까운 교차 찾기
int idx = -1;— 결과로 반환할 "맞은 구의 인덱스".-1은 "아무것도 안 맞음"을 뜻하는 관례입니다.tHit = std::numeric_limits<float>::max();— 지금까지 찾은 최소 거리를 가능한 가장 큰 값으로 초기화합니다. 이래야 첫 번째 교차가 무조건 이보다 작아 갱신됩니다(전형적인 최솟값 탐색 패턴).tHit도 출력 인자라 최종 거리를 바깥으로 돌려줍니다.for (... i < (int)scene.size(); ++i)— 모든 구를 하나씩 검사.scene.size()는 부호 없는size_t라(int)로 캐스팅해i(int)와 부호 불일치 경고를 피합니다.if (hitSphere(scene[i], r, t) && t < tHit)— 이 구에 맞았고(hitSphere가 참), 그 거리t가 지금까지 최솟값보다 가까울 때만 갱신.&&는 단축 평가라hitSphere가 거짓이면t는 보지 않습니다.{ tHit = t; idx = i; }— 더 가까운 교차를 찾았으니 최소 거리와 인덱스를 갱신.return idx;— 가장 가까이 맞은 구의 인덱스(없으면 -1).
closest는 매 광선마다 모든 구를 전부 검사합니다(O(구 개수)). 구가 4개면 문제없지만 수백만 개
삼각형이면 치명적입니다. 실무 레이 트레이서는 BVH(Bounding Volume Hierarchy) 같은 공간 가속 구조로
검사 횟수를 O(log N)으로 줄입니다. 하드웨어 RT(DXR)는 이 BVH 순회를 GPU의 RT 코어가 전담합니다.
6. trace — 재귀적 광선 추적 (셰이딩·그림자·반사)
무엇을 만드나: 광선 하나가 최종적으로 카메라에 가져오는 색을 계산합니다. 셰이딩, 그림자 판정, 거울 반사 재귀를 모두 여기서 처리합니다. 왜 필요한가: 이 함수가 레이 트레이서의 "두뇌"입니다. 자기 자신을 재귀 호출해 빛의 여러 바운스를 자연스럽게 표현합니다.
Vec3 trace(const Ray& r, int depth) {
if (depth <= 0) return {0,0,0};
float t; int i = closest(r, t);
if (i < 0) return {0.5f, 0.7f, 1.0f}; // 하늘색 배경
const Sphere& s = scene[i];
Vec3 p = r.o + r.d * t; // 교차점
Vec3 n = normalize(p - s.center); // 표면 법선
Vec3 toL = normalize(lightPos - p);
// 그림자 광선: 교차점 → 광원 사이에 막히는 물체가 있나
Ray shadow{ p + n * 1e-3f, toL };
float st; bool inShadow = (closest(shadow, st) >= 0);
float diff = inShadow ? 0.0f : std::max(0.0f, dot(n, toL));
Vec3 local = s.albedo * (0.1f + 0.9f * diff); // 앰비언트 + 램버트
// 거울 반사 재귀
if (s.reflectivity > 0.0f) {
Ray refl{ p + n * 1e-3f, normalize(reflect(r.d, n)) };
Vec3 rc = trace(refl, depth - 1);
local = local * (1.0f - s.reflectivity) + rc * s.reflectivity;
}
return local;
}
재귀 종료 조건과 배경
if (depth <= 0) return {0,0,0};— 재귀의 바닥(base case). 반사가MAX_DEPTH번 튕기면 더는 추적하지 않고 검정을 반환합니다. 이게 없으면 두 거울이 마주 볼 때 무한 재귀로 스택이 터집니다.float t; int i = closest(r, t);— 이 광선이 가장 먼저 맞는 구를 찾습니다.if (i < 0) return {0.5f, 0.7f, 1.0f};— 아무것도 안 맞으면 광선이 허공으로 날아간 것이므로 하늘색 배경을 반환합니다. 현실의 하늘/환경광 역할입니다.
교차점·법선·광원 방향 구하기
const Sphere& s = scene[i];— 맞은 구를const 참조로 별칭. 복사 없이s.albedo처럼 짧게 씁니다.Vec3 p = r.o + r.d * t;— 반직선 방정식P(t) = o + t·d그대로. 교차점의 월드 좌표입니다.Vec3 n = normalize(p - s.center);— 구의 법선은 중심에서 표면점으로 향하는 방향입니다. 구는 항상 그렇습니다(중심에서 바깥으로 뻗은 반지름이 곧 법선).normalize로 길이를 1로 맞춥니다.Vec3 toL = normalize(lightPos - p);— 앞서 배운 "b - a는 a에서 b로 향하는 화살표" 성질로, 교차점p에서 광원lightPos로 향하는 단위 방향입니다.
그림자 광선 — 빛이 막혔나
Ray shadow{ p + n * 1e-3f, toL };— 교차점에서 광원 쪽으로 쏘는 그림자 광선입니다. 원점을p가 아니라p + n * 1e-3f로, 즉 법선 방향으로 살짝 띄운 것이 핵심입니다. 표면 바로 위에서 쏴야 자기 표면과 즉시 교차(shadow acne)하는 걸 막습니다.float st; bool inShadow = (closest(shadow, st) >= 0);— 그림자 광선이 무엇이든 맞으면(>= 0) 그 사이에 물체가 있다는 뜻이므로 그림자 안(in shadow)입니다.
lightPos까지의 거리 st < |lightPos - p|인
교차만 그림자로 세야 합니다. 또한 이 방식은 딱딱한(hard) 그림자만 만듭니다 — 부드러운 그림자는 다음 페이지의 면적광 샘플링이 필요합니다.
램버트(확산) 셰이딩
float diff = inShadow ? 0.0f : std::max(0.0f, dot(n, toL));— 램버트 조명의 핵심. 그림자면 확산광 0. 아니면dot(n, toL)= 법선과 광원 방향 사이 코사인 = "빛이 표면을 얼마나 정면으로 때리나".std::max(0.0f, ...)는 표면 뒷면(빛을 등진 면)의 음수 내적을 0으로 잘라 음의 밝기를 막습니다.
기하 직관: 손전등을 벽에 정면(수직)으로 비추면 밝고(cos 0° = 1), 비스듬히 비추면 같은 빛이 넓은
면적에 퍼져 어두워집니다(cos θ만큼). 이 코사인 감쇠가 램버트 법칙입니다.
Vec3 local = s.albedo * (0.1f + 0.9f * diff);— 최종 로컬 색.0.1은 앰비언트(환경광) 항으로, 그림자 속에서도 완전 검정이 되지 않게 하는 상수 채움입니다.0.9 * diff가 실제 확산 성분. 이(0.1 + 0.9·diff)계수를albedo에 성분별로 곱해(색 필터링) 물체 고유색이 반영된 밝기를 얻습니다.
손계산: 빨간 구(albedo=(0.9,0.3,0.3))에서 빛이 정면(diff=1)이고 그림자 밖이면
계수는 0.1 + 0.9·1 = 1.0, 로컬 색은 (0.9,0.3,0.3). 그림자 속(diff=0)이면 계수 0.1,
색은 (0.09,0.03,0.03)로 어둡지만 완전 검정은 아닙니다.
거울 반사 재귀 — 빛의 두 번째 바운스
if (s.reflectivity > 0.0f)— 반사 성분이 있는 재질만 아래를 실행(무광 구는 건너뜀 = 최적화).Ray refl{ p + n * 1e-3f, normalize(reflect(r.d, n)) };— 반사 광선. 원점은 역시 표면에서1e-3띄우고, 방향은reflect(r.d, n)(입사 방향을 법선 기준으로 튕김) 후 다시normalize. 부동소수점 오차로 길이가 1에서 미세하게 벗어날 수 있어 안전하게 재정규화합니다.Vec3 rc = trace(refl, depth - 1);— 재귀 호출. 반사 광선이 가져오는 색을 얻습니다.depth - 1로 깊이를 줄여 무한 재귀를 막습니다(base case로 수렴).local = local * (1.0f - s.reflectivity) + rc * s.reflectivity;— 선형 보간(lerp).reflectivity=0.8이면 로컬 색 20% + 반사 색 80%. 거울성이 높을수록 반사가 지배합니다.
XMVector3Reflect로 대체할 수 있습니다. 이 재귀 구조 자체를 GPU로 옮기면 하드웨어 RT(DXR)의
closest-hit 셰이더에서 TraceRay()를 다시 호출하는 형태가 됩니다 — CPU 재귀 trace(refl, depth-1)가
GPU의 재귀적 TraceRay에 1:1로 대응합니다. 그림자 광선은 any-hit / miss 셰이더 조합으로 처리합니다.
7. main — 카메라 광선 생성과 이미지 출력
int main() {
const int W = 640, H = 480, MAX_DEPTH = 5;
const float aspect = (float)W / H;
std::printf("P3\n%d %d\n255\n", W, H); // PPM 헤더
Vec3 cam(0, 0, 0);
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
// 픽셀 → NDC → 카메라 광선 (fov 90도 가정, 초점거리 1)
float px = (2.0f * (x + 0.5f) / W - 1.0f) * aspect;
float py = 1.0f - 2.0f * (y + 0.5f) / H;
Ray r{ cam, normalize(Vec3(px, py, -1.0f)) };
Vec3 col = trace(r, MAX_DEPTH);
auto to255 = [](float v){
return (int)(std::clamp(std::pow(v, 1.0f/2.2f), 0.0f, 1.0f) * 255);
};
std::printf("%d %d %d ", to255(col.x), to255(col.y), to255(col.z));
}
}
return 0;
}
해상도·PPM 헤더
const int W = 640, H = 480, MAX_DEPTH = 5;— 이미지 크기와 최대 반사 깊이.const float aspect = (float)W / H;— 종횡비640/480 = 1.333. 가로가 세로보다 넓으니 x 방향 시야를 이 비율만큼 넓혀야 원이 타원으로 찌그러지지 않습니다.std::printf("P3\n%d %d\n255\n", W, H);— PPM(Portable PixMap) 헤더.P3는 아스키 RGB 형식, 이어서 너비·높이,255는 채널 최대값입니다. 이후 픽셀들이 텍스트 숫자로 이어집니다. 표준출력으로 찍으므로 실행 시> out.ppm으로 리다이렉트해 파일로 저장합니다.
픽셀 → NDC → 카메라 광선 (좌표계 규약이 드러나는 곳)
이 세 줄이 이 페이지에서 좌표계 규약이 가장 뚜렷하게 나타나는 부분입니다. 하나씩 봅니다.
float px = (2.0f * (x + 0.5f) / W - 1.0f) * aspect;— 픽셀 x좌표를 NDC(정규화 장치 좌표) [-1, 1]로 변환.x + 0.5f— 픽셀의 중심을 겨냥(픽셀 왼쪽 모서리x가 아니라)./ W→[0, 1]범위.* 2 - 1→[-1, 1]범위로 재매핑. 왼쪽 -1, 오른쪽 +1.* aspect— 종횡비 보정으로 가로 시야를 넓힘.
float py = 1.0f - 2.0f * (y + 0.5f) / H;— y좌표 변환인데 부호가 뒤집혀 있습니다. 이미지의 y=0 행은 맨 위인데(텍스처 V가 위→아래 규약), NDC/월드의 y는 위가 +입니다. 그래서1 - 2·(y/H)로 위(y=0)를+1, 아래(y=H)를-1로 뒤집습니다. 이 Y-플립을 빠뜨리면 이미지가 상하로 뒤집힌 채 렌더됩니다.Ray r{ cam, normalize(Vec3(px, py, -1.0f)) };— 원점은 카메라(0,0,0), 방향은(px, py, -1)을 정규화. z가-1인 것이 왼손 좌표계에서 카메라가 -z 방향(화면 속)을 바라본다는 규약을 드러냅니다.z=-1(초점거리 1)에 놓인 가상 스크린 위의 한 점(px, py, -1)을 향하는 광선이라, 자연히 시야각(fov)이 약 90도가 됩니다(x가 -1~1이므로 반각 45도).
z=-1 부호는 좌표계 규약(왼손·V 위→아래)에 종속적입니다. 이 두 부호 중 하나만 실수로 바꾸면 그림이 위아래로
뒤집히거나 안팎이 뒤바뀌어 아무것도 안 보입니다. "규약을 정하고 일관되게 지키는 것"이 좌표계 버그를 피하는 유일한 방법입니다.
감마 보정과 8비트 출력
auto to255 = [](float v){ ... };— 람다(lambda). 이름 없는 즉석 함수로, 선형 색값 하나를 화면용 0~255 정수로 바꿉니다.[]는 캡처 목록(여기선 바깥 변수를 안 씀),(float v)는 인자입니다.std::pow(v, 1.0f/2.2f)— 감마 보정. 우리가 계산한 색은 물리적으로 선형(linear)인데, 모니터와 sRGB 표준은 대략2.2거듭제곱의 비선형 응답을 씁니다. 그래서 출력 전에1/2.2 ≈ 0.4545제곱을 걸어 선형 → sRGB로 변환합니다. 이걸 빠뜨리면 중간톤이 실제보다 어둡게 보입니다.std::clamp(..., 0.0f, 1.0f)— 반사로 색이 1을 넘거나 0 밑으로 떨어질 수 있어[0,1]로 자릅니다.* 255후(int)— 0~255 정수로.std::printf("%d %d %d ", ...)로 세 채널을 공백 구분해 출력합니다.
손계산: 선형 v = 0.5는 0.5^0.4545 ≈ 0.73 → ×255 ≈ 186. 감마를 안 걸면 0.5×255 ≈ 128로
훨씬 어둡습니다. 이 차이가 감마 보정의 효과입니다.
원본 콜아웃 — 학습 시 꼭 기억할 점
n * 1e-3f만큼 살짝 띄우지 않으면, 부동소수점 오차로 자기 자신과 즉시 교차해
검은 얼룩(shadow acne)이 생깁니다. 이 epsilon 오프셋은 이 코드 곳곳(그림자·반사·교차 필터)에 반복해 나오는 필수 장치입니다.
D3D11_USAGE_DYNAMIC +
CPU_ACCESS_WRITE 텍스처에 Map / memcpy / Unmap으로 올리고, 그 SRV를
풀스크린 삼각형으로 그리면 됩니다(2절의 톤매핑 패스와 동일한 구조). CPU 계산 자체는 그대로 두고 표시만 D3D11로 붙이는 방식입니다.
이때 색을 Vec3(float) 대신 uint8_t[4](RGBA8)로 패킹해 DXGI_FORMAT_R8G8B8A8_UNORM 텍스처에 맞춰야 합니다.
정리
레이 트레이싱의 전부는 결국 "광선을 정의하고, 무엇과 먼저 만나는지 풀고, 그 지점의 빛을 계산하고, 필요하면 광선을 또 쏜다"는 한 문장으로 압축됩니다. 이 페이지에서 본 벡터 연산·2차 방정식 교차·램버트 셰이딩·그림자/반사 광선·감마 보정은 오프라인 렌더러부터 하드웨어 RT(DXR)까지 이름과 규모만 다를 뿐 똑같이 재등장하는 기본기입니다. 다음 페이지에서는 이 재귀 추적을 확률적으로 일반화해, 직접광뿐 아니라 간접광·부드러운 그림자·색번짐까지 물리적으로 계산하는 패스 트레이싱으로 나아갑니다.