← 개념 · 데모 가이드 | 구현 허브 | 6장. 고급 렌더링 | 패스 트레이싱 (CPU)
IMPLEMENTATION · CHAPTER 6

패스 트레이싱 (CPU) — 한 줄 한 줄 직접 구현

레이 트레이싱은 직접광과 완벽한 반사만 다뤘습니다. 패스 트레이싱(path tracing)은 한 걸음 더 나아가 렌더링 방정식몬테카를로(Monte Carlo)로 풉니다 — 표면에 광선이 닿을 때마다 무작위 방향으로 튕겨 빛이 걸어온 경로(path)를 확률적으로 샘플링하고, 픽셀 하나마다 그런 경로를 수백 개 평균냅니다. 그러면 부드러운 그림자, 간접광, 색번짐(color bleeding)이 특별한 코드 없이 공짜로 나옵니다. 대가는 노이즈이고, 샘플을 늘려야만 사라집니다. 이 페이지는 그 원리 — 무작위성이 어떻게 물리적으로 올바른 빛으로 수렴하는지 — 를 코드 한 줄, 확률 하나, 부호 하나까지 그림글로 풀어냅니다. 이번에도 순수 CPU 계산이라 D3D11 없이 .ppm 파일 하나만 뱉으면 됩니다.

규약 이 시리즈 전체 규약을 유지합니다: 왼손 좌표계, 행벡터 v' = v*M, HLSL은 mul(v, M), 행 우선 저장, 클립 z 범위 [0,1], 텍스처 V축 위→아래. 다만 이 CPU 패스 트레이서는 행렬 파이프라인을 쓰지 않고 카메라에서 광선을 직접 쏘므로, 좌표 규약은 "카메라가 −z를 본다"는 점에서만 드러납니다. 맨 아래에서 이 코드를 GPU(D3D11 컴퓨트 셰이더 / DXR)로 옮길 때 규약이 어떻게 다시 등장하는지 짚습니다.
전제 레이 트레이싱 페이지에서 광선-구 교차, 법선, 그림자 판정을 이미 봤다고 가정합니다. 패스 트레이서는 그 교차 코드를 거의 그대로 재사용하고, "닿은 뒤 무엇을 하는가"만 완전히 바꿉니다. 레이 트레이서는 "빛을 향해 한 번 더 광선을 쏴 직접광을 계산"했지만, 패스 트레이서는 "무작위 방향으로 튕겨 재귀로 계속 따라간다"입니다. 이 한 가지 차이가 전역 조명 전체를 만들어냅니다.

0) 큰 그림 — 렌더링 방정식과 몬테카를로

코드를 보기 전에, 패스 트레이서가 무엇을 근사하는지부터 잡고 갑니다. 1986년 Kajiya가 정리한 렌더링 방정식은 "어떤 표면의 한 점에서 카메라 쪽으로 나가는 빛"을 이렇게 씁니다:

Lo(p, ωo) = Le(p, ωo) + ∫Ω fr(p, ωi, ωo) · Li(p, ωi) · cosθi · dωi

한 문장씩 풀면 — 한 점 p에서 방향 ωo(카메라 쪽)로 나가는 빛 Lo는, 그 표면이 스스로 내는 빛 Le(광원이면 존재, 벽이면 0)에, 반구 전체(Ω)에서 들어오는 모든 빛을 모은 것을 더한 값입니다. 적분 안의 세 항은:

문제는 이 적분을 해석적으로 풀 수 없다는 것입니다(내부에 재귀가 있고, 씬 모양에 따라 형태가 제각각). 그래서 몬테카를로 적분을 씁니다: 적분 ∫ g(x) dx를 무작위 샘플 N개로 (1/N) Σ g(xk) / p(xk)로 근사하는 방법입니다. 여기서 p(x)는 샘플을 뽑은 확률밀도(PDF)이고, 이 /p 나눗셈이 "자주 뽑은 방향은 덜 세게 세는" 보정입니다. 표본이 많아질수록 참값에 수렴하며, 오차는 1/√N으로 줄어듭니다 — 이게 노이즈의 정체입니다.

직관 렌더링 방정식은 "빛이 카메라에 오기까지 씬 안을 몇 번이든 튕기며 걸어온 모든 경로의 합"입니다. 패스 트레이서는 그 무한한 경로들 중 무작위로 하나씩 뽑아 걷고, 픽셀당 수백 번 반복해 평균냅니다. 한 경로는 노이즈투성이지만, 평균은 참값으로 모입니다 — 여론조사와 똑같은 원리입니다(무작위 표본의 평균이 전체 여론에 수렴).
  1. 카메라에서 픽셀을 향해 광선을 쏜다(픽셀 안에서도 무작위 지터 → 안티앨리어싱 겸함).
  2. 표면에 닿으면 그 점에서 코사인 가중 반구 샘플링으로 다음 방향을 무작위로 뽑는다(램버트 BRDF와 상쇄되어 식이 극단적으로 단순해짐).
  3. 광원에 부딪히면 그 방출값을 경로의 기여로 반환하고, 아니면 재귀로 계속 튕긴다(러시안 룰렛으로 종료).
  4. 픽셀당 SPP(samples per pixel)만큼 반복해 평균 → Reinhard 톤매핑 + 감마 → PPM 출력.
pathtracer.cppcpp
// cl /O2 /EHsc pathtracer.cpp   (또는 g++ -O2 pathtracer.cpp -o pt), 실행 > cornell.ppm
#include <cmath>
#include <cstdio>
#include <vector>
#include <random>
#include <limits>
#include <algorithm>

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;}
Vec3 normalize(const Vec3&a){float l=std::sqrt(dot(a,a));return a*(1.0f/l);}
Vec3 cross(const Vec3&a,const Vec3&b){
    return {a.y*b.z-a.z*b.y, a.z*b.x-a.x*b.z, a.x*b.y-a.y*b.x};
}

헤더와 Vec3 — 왜 이 여섯 줄로 충분한가

맨 위 주석은 컴파일·실행 방법입니다. cl /O2 /EHsc는 MSVC(/O2 최적화, /EHsc C++ 예외 모델), g++ -O2는 GCC/Clang입니다. 실행 결과를 > cornell.ppm로 리다이렉트하는 이유는, 이 프로그램이 이미지를 화면에 띄우는 게 아니라 표준 출력(stdout)에 PPM 텍스트를 뱉기 때문입니다. PPM은 가장 단순한 이미지 포맷이라 D3D도 창(window)도 필요 없이 순수 계산만으로 그림을 만들 수 있습니다.

#include 목록을 하나씩 보면: <cmath>std::sqrt·std::cos·std::pow, <cstdio>std::printf, <vector>는 씬을 담는 동적 배열, <random>는 고품질 난수 생성기 std::mt19937, <limits>는 "가장 큰 float"(교차 판정 초기값), <algorithm>std::max·std::clamp를 위한 것입니다.

struct Vec3 — 3D 벡터 한 개

float x,y,z;는 좌표 세 성분입니다. 바로 아래 생성자 Vec3(float a=0,float b=0,float c=0):x(a),y(b),z(c){}를 뜯어보면:

다음은 연산자 오버로딩입니다. a + b, a - b 같은 벡터 산수를 수학처럼 쓰기 위해 +·-·*의 의미를 Vec3에 대해 새로 정의합니다. 하나씩 보면:

함정 *스칼라 곱성분별 곱 두 가지로 오버로드되어 있습니다. v * 2.0f는 크기 조절, v * w는 색 필터로 전혀 다른 의미입니다. 실수로 색끼리 스칼라 곱을 기대하면 결과가 이상해집니다. 그리고 여기 어디에도 벡터 *가 내적/외적을 뜻하지 않습니다 — 내적은 dot(), 외적은 cross() 자유 함수로 따로 뒀습니다(수학의 a·b, a×b* 하나에 다 욱여넣으면 혼란).

dot · normalize · cross — 세 개의 자유 함수

float dot(const Vec3&a,const Vec3&b){return a.x*b.x+a.y*b.y+a.z*b.z;}내적. 수학적으로 a·b = |a||b|cosθ라서 "두 벡터가 얼마나 같은 방향인가"를 하나의 숫자로 줍니다. 여기서는 광선-구 교차의 판별식, 법선-시선 방향 판정, 그리고 dot(a,a) = |a|²(자기 자신과의 내적 = 길이 제곱)로 씁니다.

Vec3 normalize(const Vec3&a){float l=std::sqrt(dot(a,a));return a*(1.0f/l);}정규화. dot(a,a) = x²+y²+z²가 길이의 제곱이고, std::sqrt로 실제 길이 l = |a|를 얻은 뒤 a * (1/l)로 각 성분을 길이로 나눕니다. 결과는 방향은 그대로, 길이만 1단위 벡터입니다. 방향만 필요한 곳(광선 방향, 법선)은 반드시 길이 1이어야 cosθ = dot 같은 식이 성립합니다.

함정 a의 길이가 0이면 1/l이 무한대(0으로 나누기)라 결과가 NaN이 됩니다. 이 학습용 코드는 광선·법선이 절대 길이 0이 안 되는 상황만 쓰므로 방어를 생략했지만, 실무에선 if (l > 1e-8f) 가드를 넣습니다. 한 번 NaN이 섞이면 이후 모든 덧셈·평균이 NaN으로 전염되어 픽셀이 통째로 검게(또는 이상하게) 나옵니다.

cross외적 — 두 벡터 모두에 수직인 새 벡터입니다. 성분 하나씩 어디서 왔는지 봅시다. 외적의 정의 a×b = (aybz−azby, azbx−axbz, axby−aybx)를 코드가 그대로 옮깁니다:

각 성분이 "자기를 뺀 나머지 두 축을 순환(x→y→z→x)"으로 조합한다는 규칙이 보입니다. 여기서는 코사인 반구 샘플링에서 법선 주변에 직교 기저(coordinate frame)를 세울 때 딱 한 번 씁니다 — 뒤에서 자세히.

손계산: a=(1,0,0), b=(0,1,0)이면 a×b = (0·0−0·1, 0·0−1·0, 1·1−0·0) = (0,0,1). x축과 y축의 외적이 z축 — 오른손이면 +z, 왼손 규약이면 방향 해석만 손이 바뀝니다(성분 계산 공식은 동일).

1) 씬 정의 — 구로 만든 코넬 박스

무엇을 만드나: 렌더링할 3D 장면입니다. 왜 구만 쓰나: 광선-구 교차가 2차 방정식 하나로 풀려 코드가 가장 짧기 때문입니다. 삼각형 메쉬 대신 아주 큰 구로 벽을 흉내내는 게 이 예제의 핵심 트릭입니다.

pathtracer.cpp — 씬cpp
struct Ray { Vec3 o, d; };
// emission이 0이 아니면 광원
struct Sphere { Vec3 c; float r; Vec3 albedo; Vec3 emission; };

// 코넬 박스: 큰 구들로 벽을 만들고, 위에 밝은 구 하나를 광원으로
std::vector<Sphere> scene = {
    {{-1e5f-2, 0, -5}, 1e5f, {0.75f,0.25f,0.25f}, {0,0,0}}, // 왼벽 빨강
    {{ 1e5f+2, 0, -5}, 1e5f, {0.25f,0.35f,0.75f}, {0,0,0}}, // 오른벽 파랑
    {{0, 0, -1e5f-8}, 1e5f, {0.75f,0.75f,0.75f}, {0,0,0}},  // 뒷벽 흰색
    {{0, -1e5f-2, -5}, 1e5f, {0.75f,0.75f,0.75f}, {0,0,0}}, // 바닥
    {{0,  1e5f+3, -5}, 1e5f, {0.75f,0.75f,0.75f}, {0,0,0}}, // 천장
    {{-0.9f,-1.3f,-5.5f}, 0.7f, {0.9f,0.9f,0.9f}, {0,0,0}}, // 물체 구 1
    {{ 0.9f,-1.3f,-4.2f}, 0.7f, {0.9f,0.9f,0.9f}, {0,0,0}}, // 물체 구 2
    {{0, 2.6f, -5}, 0.6f, {0,0,0}, {12,12,12}}              // 천장 광원
};

std::mt19937 rng(1234);
std::uniform_real_distribution<float> U(0.0f, 1.0f);

RaySphere — 광선과 표면의 최소 표현

struct Ray { Vec3 o, d; }; — 광선은 출발점 o(origin)방향 d(direction) 두 벡터로 표현합니다. 광선 위의 점은 매개변수 t에 대해 P(t) = o + d·t입니다(t는 출발점에서의 거리, d가 단위 벡터라면 실제 거리와 같음).

struct Sphere { Vec3 c; float r; Vec3 albedo; Vec3 emission; }; — 구는 네 필드로:

큰 구로 벽 만들기 — 1e5f 트릭

벽은 평평한데 왜 구로? 반지름이 아주 크면(1e5 = 10만) 구 표면의 일부는 거의 평면처럼 보이기 때문입니다. 지구가 둥글어도 발밑은 평평하게 느껴지는 것과 같습니다. 왼벽을 예로 성분을 읽어보면:

{{-1e5f-2, 0, -5}, 1e5f, {0.75f,0.25f,0.25f}, {0,0,0}}

같은 방식으로 오른벽(x=+2, 파랑), 뒷벽(z=−8, 흰색), 바닥(y=−2), 천장(y=+3)이 큰 구로 만들어집니다. 부호를 잘 보면 중심이 벽 바깥쪽으로 1e5만큼 밀려 있고, 그만큼의 반지름이 안쪽 표면을 방 경계에 정확히 맞춥니다.

가운데 두 개 {-0.9,-1.3,-5.5}·{0.9,-1.3,-4.2}는 반지름 0.7의 실제 구 물체(밝은 회색 albedo 0.9)이고, 맨 끝 {{0, 2.6f, -5}, 0.6f, {0,0,0}, {12,12,12}}가 천장에 매달린 광원입니다 — albedo는 0, emission이 {12,12,12}1을 크게 초과합니다. albedo와 달리 방출값은 실제 밝기(radiance)라 1보다 훨씬 커도 됩니다(태양은 albedo가 아니라 방출이 어마어마한 것). 이 12라는 값이 이후 톤매핑으로 눌려 [0,1]에 담깁니다.

함정 1e5너무 크면(예 1e10) float 정밀도가 부족해 교차 판정에서 표면이 지글거리거나(z-fighting류) 광선이 벽을 뚫습니다. 1e5 정도가 "충분히 평평하되 정밀도는 안전한" 절충값입니다. 반대로 너무 작으면 벽의 곡률이 눈에 띄어 방이 볼록 거울처럼 보입니다.

난수 생성기 — mt19937과 균등분포

std::mt19937 rng(1234);메르센 트위스터 난수 엔진입니다. 이름의 19937은 주기가 219937−1이라는 뜻으로, 패턴이 반복될 걱정이 없는 고품질 난수입니다. 인자 1234시드(seed) — 같은 시드면 매번 똑같은 난수 열이 나옵니다. 디버깅과 재현성을 위해 고정했습니다(같은 이미지가 늘 나옴).

std::uniform_real_distribution<float> U(0.0f, 1.0f); — 엔진의 원시 비트를 [0,1) 구간의 균등한 실수로 변환하는 분포입니다. U(rng)를 호출할 때마다 0~1 사이 무작위 float가 하나 나오고, 이것이 이 프로그램의 모든 무작위성의 원천입니다(픽셀 지터, 반구 샘플 방향, 러시안 룰렛 판정).

성능 mt19937은 품질은 좋지만 상태가 커서(약 2.5KB) 느린 편입니다. 멀티스레드로 병렬화하면 스레드마다 별도 rng를 둬야 합니다(하나를 공유하면 락 경합 + 재현성 붕괴). GPU로 옮기면 이 무거운 엔진 대신 픽셀 좌표+프레임 번호를 해시하는 가벼운 난수(PCG, xxhash 등)를 씁니다 — 셰이더 안에서 상태를 들고 다닐 수 없기 때문입니다.

2) 광선-구 교차 — hitclosest

무엇을 만드나: 광선이 구를 뚫는 가장 가까운 지점의 거리 t를 구하는 함수(hit), 그리고 씬의 모든 구 중 가장 먼저 맞는 구를 찾는 함수(closest). 왜 필요한가: "광선이 무엇에, 어디서 닿나"를 모르면 그 지점에서 빛 계산을 시작할 수 없습니다. 모든 렌더러의 심장입니다.

pathtracer.cpp — 교차cpp
bool hit(const Sphere&s, const Ray&r, float&t){
    Vec3 oc = r.o - s.c;
    float b = dot(oc, r.d);
    float c = dot(oc,oc) - s.r*s.r;
    float disc = b*b - c;
    if (disc < 0) return false;
    float sq = std::sqrt(disc);
    t = -b - sq; if (t > 1e-3f) return true;
    t = -b + sq; return t > 1e-3f;
}
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 (hit(scene[i],r,t) && t<tHit){tHit=t; idx=i;}
    }
    return idx;
}

hit — 광선과 구의 2차 방정식

수학적 유도: 구는 "중심 c에서 거리가 정확히 r인 점들"입니다: |P − c|² = r². 광선 위의 점 P = o + d·t를 대입하면 |o + d·t − c|² = r². oc = o − c로 두고 전개하면(내적의 분배):

|oc + d·t|² = (d·d)t² + 2(oc·d)t + (oc·oc) = r²

광선 방향 d단위 벡터d·d = 1입니다. 그래서 t에 대한 2차 방정식 t² + 2(oc·d)t + (oc·oc − r²) = 0이 됩니다. 코드가 이걸 그대로 옮깁니다:

판별식이 0 이상이면 근이 두 개(구의 앞면 진입·뒷면 진출):

함정 1e-3f자기 교차(self-intersection)를 막는 엡실론입니다. 광선이 방금 튕겨나온 바로 그 표면을 "거리 0에서 또 맞았다"고 오판하면(부동소수 오차로 t가 0에 아주 가깝게 나옴) 표면이 검은 얼룩(shadow acne)으로 뒤덮입니다. t > 1e-3f 조건으로 "출발점 바로 앞의 미세 거리"를 무시해 이를 방지합니다. 반대로 이 값이 너무 크면 얇은 물체를 통과해버립니다.

closest — 가장 가까운 구 찾기

광선은 여러 구를 동시에 관통할 수 있으니, 그중 카메라에 가장 가까운(t가 최소인) 것만 보입니다(그 뒤는 가려짐).

성능closest모든 구를 매번 다 검사하는 O(N) 무차별 방식입니다. 구 8개니 괜찮지만, 수백만 삼각형이면 재앙입니다. 실무 렌더러는 BVH(Bounding Volume Hierarchy)로 공간을 트리로 나눠 O(log N)에 가까이 줄입니다. 하드웨어 RT(DXR)는 이 BVH 순회를 RT 코어가 전용 회로로 처리해 수십 배 빠릅니다.

3) 코사인 가중 반구 샘플링 — cosineHemisphere

무엇을 만드나: 표면 법선 n 주변의 반구(위쪽 절반)에서 무작위 방향 하나를 뽑되, 법선에 가까운 방향일수록 더 자주 뽑는 함수입니다. 왜 이렇게 편향되게 뽑나: 램버트 표면은 법선 정면에서 오는 빛(cosθ가 큰 방향)이 훨씬 중요하기 때문입니다. 그 중요도에 맞춰 뽑으면(중요도 샘플링) 노이즈가 극적으로 줄고, 게다가 BRDF와 PDF가 상쇄되어 계산식이 albedo 하나로 붕괴합니다.

  1. [0,1) 난수 두 개로 반구 위의 한 점을 극좌표로 정한다.
  2. 이 방향은 아직 "법선이 z축인 로컬 좌표계" 기준이다.
  3. 법선 n을 z축으로 삼는 직교 기저(t, b, n)를 세운다.
  4. 로컬 방향을 그 기저로 회전시켜 월드 방향으로 바꾼다.
pathtracer.cpp — 반구 샘플cpp
// 법선 n 주변 코사인 가중 반구 샘플
Vec3 cosineHemisphere(const Vec3& n){
    float u1 = U(rng), u2 = U(rng);
    float r = std::sqrt(u1);
    float theta = 6.2831853f * u2;
    // n을 z축으로 하는 직교 기저 구성
    Vec3 a = (std::fabs(n.x) > 0.1f) ? Vec3(0,1,0) : Vec3(1,0,0);
    Vec3 t = normalize(cross(a, n));
    Vec3 b = cross(n, t);
    Vec3 local(r*std::cos(theta), r*std::sin(theta), std::sqrt(1.0f-u1));
    return normalize(t*local.x + b*local.y + n*local.z);
}

1단계 — 난수 두 개로 로컬 방향 정하기

그다음 로컬 좌표를 조립합니다:

직관 그림으로: 법선을 곧게 위(z)로 세운 반구를 상상하세요. √u1로 뽑은 반지름은 원판 위 점들이 바깥 테두리(법선에서 많이 기운 곳)보다 중앙(법선 정면)에 더 촘촘히 모이게 만듭니다. 이걸 반구 표면으로 올리면(z 좌표 부여) 결과적으로 "법선 정면 방향이 가장 자주 뽑히는" 분포가 됩니다 — 정확히 램버트 표면이 원하는 그것.

2단계 — 법선을 z축으로 하는 직교 기저 세우기

지금 local은 "법선이 z축이라고 가정한 로컬 좌표"입니다. 실제 표면의 법선 n은 아무 방향이나 가리키니, 이 로컬 방향을 월드 공간으로 회전시켜야 합니다. 그러려면 n과 그에 수직인 두 축(t, b)이 필요합니다 — 이 셋이 직교 좌표계(orthonormal basis)를 이룹니다.

왜 임계값이 0.1인가: n.x가 정확히 0에 가까울 때만 x축과 평행 위험이 있으니, "x성분이 0.1보다 작을 때만 x축을 보조로 써도 안전"하다는 넉넉한 기준입니다. 0.1은 수치 안정성을 위한 경험값입니다.

3단계 — 로컬을 월드로 회전(기저 변환)

return normalize(t*local.x + b*local.y + n*local.z); — 이 한 줄이 기저 변환입니다. 로컬 성분 (local.x, local.y, local.z)를 각각 월드 축 (t, b, n)에 실어 더합니다:

세 벡터의 합이 곧 월드 공간에서의 최종 방향입니다. 마지막 normalize는 부동소수 오차로 길이가 살짝 틀어진 걸 다시 1로 맞추는 안전장치입니다(이론상 이미 단위이지만 실용상 넣어둠).

함정 코사인 가중이 아니라 균등(uniform) 반구 샘플링을 쓰면 PDF가 1/(2π)로 달라져, 아래 radiance의 "albedo만 남는 상쇄"가 성립하지 않습니다. 그때는 albedo * incoming * cosθ * (2π/π) = albedo * incoming * 2cosθ처럼 cosθ와 상수를 직접 곱해줘야 하고, 이걸 빠뜨리면 결과가 어둡거나 밝게 틀어집니다. 샘플링 방식과 곱셈 식은 반드시 짝을 맞춰야 물리적으로 옳습니다.

4) 경로 추적 재귀 — radiance

무엇을 만드나: 광선 하나를 씬에 쏴서, 그 경로가 카메라로 가져오는 빛(복사휘도, radiance)을 계산하는 재귀 함수입니다. 왜 재귀인가: 렌더링 방정식의 Li(들어오는 빛)가 또 다른 표면의 Lo(나가는 빛)이기 때문 — 빛이 튕길 때마다 radiance가 자기 자신을 다시 부릅니다.

pathtracer.cpp — radiancecpp
// 경로 추적: 반환값은 이 경로가 카메라로 가져오는 복사휘도
Vec3 radiance(Ray r, int depth){
    if (depth <= 0) return {0,0,0};
    float t; int i = closest(r, t);
    if (i < 0) return {0,0,0};                  // 허공 = 검정

    const Sphere& s = scene[i];
    Vec3 p = r.o + r.d * t;
    Vec3 n = normalize(p - s.c);
    if (dot(n, r.d) > 0) n = n * -1.0f;          // 법선을 시선 쪽으로

    // 광원에 닿으면 방출값 반환 (경로 종료)
    if (s.emission.x + s.emission.y + s.emission.z > 0.0f)
        return s.emission;

    // 러시안 룰렛으로 무한 재귀 방지 (밝은 표면일수록 오래 살린다)
    float pSurvive = std::max({s.albedo.x, s.albedo.y, s.albedo.z});
    if (depth < 4) {
        if (U(rng) > pSurvive) return {0,0,0};
    } else pSurvive = 1.0f;

    // 코사인 샘플링에서는 (albedo/π)·cos·(π/cos) = albedo 만 남는다
    Vec3 newDir = cosineHemisphere(n);
    Ray  bounce{ p + n * 1e-3f, newDir };
    Vec3 incoming = radiance(bounce, depth - 1);
    return s.albedo * incoming * (1.0f / pSurvive);
}

시그니처와 종료 조건

교차점과 법선 구하기

광원에 닿으면 종료

직관 패스 트레이서는 빛을 카메라에서 거꾸로 추적합니다(실제 빛은 광원→표면→눈이지만, 눈에 도달하는 빛만 중요하니 반대로 걷는 게 효율적). 그래서 "광원에 닿으면 성공"이고, 그 전까지 튕긴 표면들의 색이 곱해져 최종 색이 됩니다. 빨간 벽을 거쳐 흰 구에 온 빛은 흰색 × 빨강 = 붉은 기운이 되어 color bleeding으로 나타납니다.

러시안 룰렛 — 편향 없이 재귀 끝내기

depth 한계만으로 끝내면 "8번째 튕김에서 강제로 잘린 빛"이 통째로 사라져 결과가 어두워집니다(편향, bias). 러시안 룰렛은 이를 피하면서 재귀를 확률적으로 끝내는 우아한 기법입니다:

주의 러시안 룰렛의 핵심은 살아남은 경로를 1/pSurvive로 증폭하는 것입니다(마지막 줄에 등장). 확률 p로 살아남은 경로에 1/p를 곱하면, 평균적으로는 원래 값이 그대로 유지됩니다 (일부는 0으로 죽고, 산 것은 더 세게). 이 증폭을 빠뜨리면 이미지가 어두워집니다. "죽이되 산 것은 보상한다"가 편향을 없애는 열쇠입니다.

다음 바운스와 상쇄의 마법

손계산: albedo (0.9,0.9,0.9) 표면이 흰빛 (1,1,1)을 받고 생존확률 1이면 기여는 (0.9,0.9,0.9) × (1,1,1) × 1 = (0.9,0.9,0.9). 이 값이 다시 상위 표면의 albedo로 곱해지며 카메라로 전달됩니다. 여러 번 튕길수록 곱셈이 쌓여 어두워지는 게 자연스러운 감쇠입니다.

함정 이 상쇄는 램버트(완전 무광) + 코사인 가중 샘플링이라는 특정 조합에서만 성립합니다. 금속·유리 같은 다른 BRDF나 다른 샘플링 방식을 쓰면 albedo만 남지 않으니, 반드시 BRDF × cosθ / PDF를 일반식대로 곱해야 합니다. "왜 albedo만 곱하지?"를 이해하지 못한 채 다른 재질에 그대로 복붙하면 결과가 틀어집니다.

5) 메인 루프 — 샘플 평균과 톤매핑

무엇을 만드나: 모든 픽셀을 돌며 카메라 광선을 만들고, 픽셀마다 radiance를 SPP번 호출해 평균낸 뒤, 톤매핑·감마 보정을 거쳐 PPM으로 출력합니다.

pathtracer.cpp — maincpp
int main(){
    const int W=400, H=400, SPP=256, MAX_DEPTH=8;
    std::printf("P3\n%d %d\n255\n", W, H);
    Vec3 cam(0, 0, 0);

    for (int y=0; y<H; ++y){
        for (int x=0; x<W; ++x){
            Vec3 acc(0,0,0);
            for (int s=0; s<SPP; ++s){
                // 픽셀 내 지터링으로 안티앨리어싱까지 겸함
                float px = (2.0f*(x + U(rng))/W - 1.0f);
                float py = 1.0f - 2.0f*(y + U(rng))/H;
                Ray r{ cam, normalize(Vec3(px, py, -1.0f)) };
                acc = acc + radiance(r, MAX_DEPTH);
            }
            acc = acc * (1.0f / SPP);            // 평균
            // Reinhard 톤매핑 + 감마
            auto tm = [](float v){
                v = v / (1.0f + v);
                return (int)(std::pow(std::clamp(v,0.0f,1.0f), 1.0f/2.2f) * 255);
            };
            std::printf("%d %d %d ", tm(acc.x), tm(acc.y), tm(acc.z));
        }
    }
    return 0;
}

설정과 PPM 헤더

카메라 광선 만들기 — 픽셀 → 방향

평균과 톤매핑

성능 노이즈의 표준편차는 1/√N로 줄어듭니다 — 노이즈를 절반으로 낮추려면 샘플을 4배 써야 합니다. 그래서 실무는 (1) 중요도 샘플링(광원 방향을 직접 겨냥해 어두운 씬에서도 빨리 수렴), (2) 다중 중요도 샘플링(MIS)(BRDF 샘플과 광원 샘플의 장점을 가중 결합), (3) 마지막에 디노이저(Intel OIDN, NVIDIA OptiX)로 적은 SPP를 AI로 보정 — 이 세 가지를 조합합니다.
함정 이 구조는 완벽히 병렬입니다(픽셀·샘플이 서로 독립). CPU 멀티스레드나 GPU로 옮기면 선형 가속됩니다. 다만 난수를 조심해야 합니다 — 위에서 말했듯 스레드/픽셀마다 독립적인 rng 상태가 필요하고, 하나를 공유하면 락 경합으로 오히려 느려지거나 샘플 상관(correlation)으로 이상한 무늬가 낍니다.

6) GPU로 옮기기 — DirectXMath / D3D11 대응

이 CPU 코드의 각 요소가 GPU(HLSL 컴퓨트 셰이더)에서 무엇에 대응하는지 1:1로 짚습니다. 원리는 같고 표현 도구만 바뀝니다.

CPU 코드 (이 페이지)DirectXMath / HLSL / D3D11 대응메모
struct Vec3 + 연산자 DirectXMath XMVECTOR / HLSL float3 HLSL은 a+b, a*b가 기본 성분별 연산이라 오버로딩 불필요.
dot(a,b) XMVector3Dot / HLSL dot(a,b) SIMD 한 명령. GPU에선 내장 함수.
cross(a,b) XMVector3Cross / HLSL cross(a,b) 성분 공식 동일. 왼손 좌표계 방향 해석만 규약대로.
normalize(a) XMVector3Normalize / HLSL normalize(a) HLSL rsqrt 기반이라 빠름.
std::vector<Sphere> scene StructuredBuffer<Sphere> (SRV) CPU에서 구 배열을 만들어 CreateBuffer로 GPU 업로드.
mt19937 + U(rng) 픽셀좌표+프레임 해시(PCG/xxhash) 또는 블루노이즈 텍스처 셰이더는 무거운 상태를 못 들고 다녀 경량 난수로 대체.
픽셀 이중 루프 (for y,x) SV_DispatchThreadID + Dispatch(W/8, H/8, 1) 스레드 하나가 픽셀 하나. 이중 루프가 사라지고 병렬화됨.
std::printf PPM 출력 RWTexture2D<float4> UAV에 output[id.xy] = ... 결과를 텍스처에 쓰고 화면에 복사하거나 파일로 저장.
재귀 radiance HLSL은 재귀 불가 → 반복(loop)으로 변환 (경로 throughput 누적) 또는 DXR(하드웨어 RT)의 TraceRay + 셰이더 테이블.

HLSL 컴퓨트 셰이더로 옮긴 radiance의 핵심은 재귀를 루프로 푸는 것입니다. GPU 셰이더는 함수 재귀를 지원하지 않으므로, "지금까지 곱해진 색(throughput)"을 변수로 들고 다니며 반복합니다:

pathtracer.hlsl — 재귀를 루프로hlsl
// 컴퓨트 셰이더: 스레드 하나가 픽셀 하나를 담당
StructuredBuffer<Sphere>   gScene  : register(t0);
RWTexture2D<float4>        gOutput : register(u0);

float3 Radiance(Ray r, uint seed)
{
    float3 throughput = float3(1, 1, 1);  // 지금까지 곱해진 albedo들
    float3 L          = float3(0, 0, 0);  // 누적된 빛

    for (int depth = 0; depth < MAX_DEPTH; ++depth)
    {
        float t; int i = Closest(r, t);
        if (i < 0) break;                  // 허공 = 종료

        Sphere s = gScene[i];
        float3 p = r.o + r.d * t;
        float3 n = normalize(p - s.c);
        if (dot(n, r.d) > 0) n = -n;       // 법선을 시선 쪽으로

        if (s.emission.x + s.emission.y + s.emission.z > 0)
        {
            L += throughput * s.emission;  // 광원 도달: 기여 확정 후 종료
            break;
        }

        // 러시안 룰렛
        float pSurvive = max(s.albedo.x, max(s.albedo.y, s.albedo.z));
        if (depth >= 4)
        {
            if (Rand(seed) > pSurvive) break;
            throughput /= pSurvive;        // 살아남은 경로 증폭
        }

        // 코사인 샘플링 → albedo만 곱해 throughput 갱신
        throughput *= s.albedo;
        r.o = p + n * 1e-3f;
        r.d = CosineHemisphere(n, seed);
    }
    return L;
}

재귀 → 루프 변환의 원리

CPU 버전은 재귀가 되돌아오며 return s.albedo * incoming * ...바깥에서 안으로 색을 곱했습니다. 루프 버전은 방향을 뒤집어 안에서 바깥으로, 한 바운스씩 진행하며 곱해 나갑니다:

주의 HLSL은 -n(단항 마이너스)이 되지만 C++ Vec3operator- 단항이 없어 CPU 코드는 n * -1.0f로 썼습니다 — 같은 뜻입니다. 또 HLSL float3a*b가 자동 성분별 곱이라 CPU의 성분별 operator*를 따로 만들 필요가 없습니다. 반대로 행렬 변환이 필요하면 규약대로 mul(v, M)(행벡터)를 쓰고, cbuffer 업로드 시 DirectXMath 행렬을 transpose해야 합니다 (이 패스 트레이서는 행렬을 안 써서 해당 없음).
성능 GPU로 옮기면 픽셀·샘플 병렬화로 수십~수백 배 빨라지지만, 발산(divergence)에 주의해야 합니다. 한 워프(warp) 안 스레드들이 서로 다른 깊이에서 break하면 일부가 놀게 됩니다. 그래서 실무 GPU 패스 트레이서는 웨이브프론트 방식(바운스 단계별로 광선을 재정렬해 비슷한 것끼리 묶음)이나 DXR의 하드웨어 스케줄링에 의존합니다. 최고 성능은 결국 DXR + RT 코어 + 시공간 디노이저 조합입니다.