패스 트레이싱 (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)에,
반구 전체(Ω)에서 들어오는 모든 빛을 모은 것을 더한 값입니다. 적분 안의 세 항은:
fr— BRDF. "방향ωi로 들어온 빛이ωo로 얼마나 반사되나"를 나타내는 표면 재질 함수. 무광(램버트) 표면이면fr = albedo/π(상수).Li(p, ωi)— 방향ωi에서 들어오는 빛. 이게 또 다른 표면의Lo라서 재귀가 됩니다(빛이 여러 번 튕김 = 간접광 = GI).cosθi— 입사각 코사인. 빛이 비스듬히 오면 단위 면적당 에너지가 줄어드는 램버트 코사인 법칙.
문제는 이 적분을 해석적으로 풀 수 없다는 것입니다(내부에 재귀가 있고, 씬 모양에 따라 형태가 제각각).
그래서 몬테카를로 적분을 씁니다: 적분 ∫ g(x) dx를 무작위 샘플 N개로
(1/N) Σ g(xk) / p(xk)로 근사하는 방법입니다. 여기서 p(x)는 샘플을 뽑은
확률밀도(PDF)이고, 이 /p 나눗셈이 "자주 뽑은 방향은 덜 세게 세는" 보정입니다.
표본이 많아질수록 참값에 수렴하며, 오차는 1/√N으로 줄어듭니다 — 이게 노이즈의 정체입니다.
- 카메라에서 픽셀을 향해 광선을 쏜다(픽셀 안에서도 무작위 지터 → 안티앨리어싱 겸함).
- 표면에 닿으면 그 점에서 코사인 가중 반구 샘플링으로 다음 방향을 무작위로 뽑는다(램버트 BRDF와 상쇄되어 식이 극단적으로 단순해짐).
- 광원에 부딪히면 그 방출값을 경로의 기여로 반환하고, 아니면 재귀로 계속 튕긴다(러시안 룰렛으로 종료).
- 픽셀당 SPP(samples per pixel)만큼 반복해 평균 → Reinhard 톤매핑 + 감마 → PPM 출력.
// 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){}를 뜯어보면:
float a=0,float b=0,float c=0— 기본 인자입니다.Vec3()처럼 아무것도 안 주면(0,0,0),Vec3(1,2,3)처럼 세 개를 주면 그대로. 인자를 하나만 줘도 컴파일은 되지만 의도가 애매하니 보통 0개 아니면 3개로 씁니다.:x(a),y(b),z(c){}— 멤버 초기화 리스트입니다. 생성자 본문{}안에서x=a;로 대입하는 것보다 이 방식이 미세하게 빠르고 안전합니다(멤버를 처음부터 그 값으로 "짓기" vs 기본값으로 지은 뒤 "덮어쓰기"). 본문{}가 비어 있는 이유가 이것 — 할 일이 리스트에서 끝났습니다.
다음은 연산자 오버로딩입니다. a + b, a - b 같은 벡터 산수를 수학처럼 쓰기 위해
+·-·*의 의미를 Vec3에 대해 새로 정의합니다. 하나씩 보면:
Vec3 operator+(const Vec3&o)const{return{x+o.x,y+o.y,z+o.z};}— 성분별 덧셈. 인자const Vec3&o는 const 참조:&로 복사 없이 원본을 가리키고(성능),const로 "안 바꾼다"를 약속합니다. 함수 뒤const는 "이 연산이 자기 자신도 안 바꾼다"는 뜻이라const Vec3에도 이 연산을 쓸 수 있습니다. 반환의{x+o.x, ...}는 중괄호 집합 초기화로 임시Vec3를 만들어 돌려줍니다(생성자가 세 인자를 받으니 자동 매칭).operator-— 성분별 뺄셈. 두 점의 차B - A는 "A에서 B로 가는 방향 벡터"라서 광선 방향·법선 계산의 기본입니다.Vec3 operator*(float s)const— 스칼라 곱. 벡터 길이를s배 늘립니다 (방향 유지, 크기만 조절).d * t로 "광선을t만큼 전진" 같은 데 씁니다.Vec3 operator*(const Vec3&o)const— 성분별 곱(Hadamard product).(x*o.x, y*o.y, z*o.z). 이건 수학적 벡터곱(내적·외적)이 아니라 색 필터링용입니다. 빨간 빛(1,0,0)이 초록 표면(0,1,0)에 닿으면 반사색은(0,0,0)— 빛의 각 채널을 표면 반사율(albedo)로 걸러냅니다. 패스 트레이서에서albedo * incoming이 바로 이 연산입니다.
*가 스칼라 곱과 성분별 곱 두 가지로 오버로드되어 있습니다. 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 성분
a.y*b.z - a.z*b.y— y,z 성분만 등장(x는 빠짐). - y 성분
a.z*b.x - a.x*b.z— z,x 성분만 등장(y는 빠짐, 순서가 z→x임에 주의). - z 성분
a.x*b.y - a.y*b.x— x,y 성분만 등장(z는 빠짐).
각 성분이 "자기를 뺀 나머지 두 축을 순환(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차 방정식 하나로 풀려 코드가 가장 짧기 때문입니다. 삼각형 메쉬 대신 아주 큰 구로 벽을 흉내내는 게 이 예제의 핵심 트릭입니다.
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);
Ray와 Sphere — 광선과 표면의 최소 표현
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; }; — 구는 네 필드로:
c— 중심(center) 좌표.r— 반지름(radius).albedo— 반사율/표면색. RGB 각 채널이 0~1이고, "들어온 빛의 몇 %를 되튕기나"입니다.0.75면 75% 반사(나머지 25%는 흡수). 물리적으로 1을 넘으면 안 됩니다(빛을 없던 것에서 만들어냄 = 에너지 위반).emission— 방출색. 0이 아니면 이 구는 스스로 빛나는 광원입니다. 렌더링 방정식의Le가 바로 이 값. 광원은 albedo가{0,0,0}이어도 됩니다(빛을 내기만 하고 반사는 안 해도 무방).
큰 구로 벽 만들기 — 1e5f 트릭
벽은 평평한데 왜 구로? 반지름이 아주 크면(1e5 = 10만) 구 표면의 일부는 거의 평면처럼 보이기
때문입니다. 지구가 둥글어도 발밑은 평평하게 느껴지는 것과 같습니다. 왼벽을 예로 성분을 읽어보면:
{{-1e5f-2, 0, -5}, 1e5f, {0.75f,0.25f,0.25f}, {0,0,0}}
- 중심 x =
-1e5 - 2, 반지름 =1e5. 그러면 구의 오른쪽 끝(중심 + 반지름 방향)은x = (-1e5-2) + 1e5 = -2입니다. 즉 이 거대한 구의 표면이 x=−2 근처에서 세로 벽을 이룹니다. 씬 물체들이 있는 중앙(x≈0)에서 보면 완벽한 수직 벽으로 보입니다. - albedo
{0.75, 0.25, 0.25}— 빨강이 강하고 초록·파랑이 약함 → 빨간 벽. 이 빨강이 옆 흰 구에 반사되어 붉게 물드는 게 color bleeding이고, 패스 트레이싱이 자동으로 계산하는 대표 효과입니다. - emission
{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) 광선-구 교차 — hit과 closest
무엇을 만드나: 광선이 구를 뚫는 가장 가까운 지점의 거리 t를 구하는 함수(hit),
그리고 씬의 모든 구 중 가장 먼저 맞는 구를 찾는 함수(closest). 왜 필요한가:
"광선이 무엇에, 어디서 닿나"를 모르면 그 지점에서 빛 계산을 시작할 수 없습니다. 모든 렌더러의 심장입니다.
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이 됩니다. 코드가 이걸 그대로 옮깁니다:
Vec3 oc = r.o - s.c;—oc = o − c, 구 중심에서 광선 출발점으로의 벡터.float b = dot(oc, r.d);—b = oc·d. 표준 근의 공식의 계수는2b지만, 여기서는 2를 미리 약분했습니다(아래 판별식·근 공식에서 2가 사라짐 → 더 짧고 빠름).float c = dot(oc,oc) - s.r*s.r;—c = |oc|² − r². 상수항.float disc = b*b - c;— 판별식. 원래 근의 공식 판별식은b² − 4ac지만a=1이고 2를 약분한 형태라disc = b² − c로 단순해집니다. 이 부호가 교차 여부를 결정합니다.if (disc < 0) return false;— 판별식이 음수면 실근이 없음 = 광선이 구를 비껴감. 기하 직관: 광선을 무한 직선으로 봐도 구를 스치지조차 않는다는 뜻입니다.
판별식이 0 이상이면 근이 두 개(구의 앞면 진입·뒷면 진출):
float sq = std::sqrt(disc);— 근호 값. 근의 공식t = −b ± √disc의±부분입니다.t = -b - sq; if (t > 1e-3f) return true;— 작은 근(가까운 교차 = 진입점)을 먼저 시도.−b−sq가−b+sq보다 항상 작으니 이게 앞면입니다.1e-3f보다 크면 채택.t = -b + sq; return t > 1e-3f;— 앞면이 너무 가깝거나 뒤에 있으면(즉 광선 출발점이 구 안쪽에 있으면) 큰 근(진출점)을 시도. 이것도 안 되면false.
1e-3f는 자기 교차(self-intersection)를 막는 엡실론입니다. 광선이 방금 튕겨나온 바로 그 표면을
"거리 0에서 또 맞았다"고 오판하면(부동소수 오차로 t가 0에 아주 가깝게 나옴) 표면이 검은 얼룩(shadow
acne)으로 뒤덮입니다. t > 1e-3f 조건으로 "출발점 바로 앞의 미세 거리"를 무시해 이를 방지합니다.
반대로 이 값이 너무 크면 얇은 물체를 통과해버립니다.
closest — 가장 가까운 구 찾기
광선은 여러 구를 동시에 관통할 수 있으니, 그중 카메라에 가장 가까운(t가 최소인) 것만 보입니다(그 뒤는 가려짐).
int idx=-1;— 맞은 구의 인덱스.-1은 "아무것도 안 맞음"의 표식(허공).tHit=std::numeric_limits<float>::max();— 지금까지 찾은 최소 거리. 처음엔 float 최댓값으로 둬서 어떤 실제 교차든 반드시 이보다 작게 만듭니다(비교 초기값의 정석).for (int i=0;i<(int)scene.size();++i)— 모든 구를 순회.(int)캐스트는size()가 부호 없는 정수(size_t)라 부호 있는i와 비교할 때 경고가 뜨는 걸 막습니다.if (hit(scene[i],r,t) && t<tHit){tHit=t; idx=i;}— 단축 평가: 먼저 맞았는지 확인하고(hit), 맞았을 때만t를 읽어 지금 최솟값보다 가까운지 비교. 더 가까우면 갱신.hit이false면&&오른쪽은 아예 실행 안 됩니다(t가 미초기화라도 안전).return idx;— 가장 가까운 구의 인덱스(없으면 −1). 거리는tHit(참조 인자)로 함께 전달됩니다.
closest는 모든 구를 매번 다 검사하는 O(N) 무차별 방식입니다. 구 8개니 괜찮지만, 수백만
삼각형이면 재앙입니다. 실무 렌더러는 BVH(Bounding Volume Hierarchy)로 공간을 트리로 나눠 O(log N)에
가까이 줄입니다. 하드웨어 RT(DXR)는 이 BVH 순회를 RT 코어가 전용 회로로 처리해 수십 배 빠릅니다.
3) 코사인 가중 반구 샘플링 — cosineHemisphere
무엇을 만드나: 표면 법선 n 주변의 반구(위쪽 절반)에서 무작위 방향 하나를 뽑되,
법선에 가까운 방향일수록 더 자주 뽑는 함수입니다. 왜 이렇게 편향되게 뽑나: 램버트 표면은
법선 정면에서 오는 빛(cosθ가 큰 방향)이 훨씬 중요하기 때문입니다. 그 중요도에 맞춰 뽑으면(중요도 샘플링)
노이즈가 극적으로 줄고, 게다가 BRDF와 PDF가 상쇄되어 계산식이 albedo 하나로 붕괴합니다.
- [0,1) 난수 두 개로 반구 위의 한 점을 극좌표로 정한다.
- 이 방향은 아직 "법선이 z축인 로컬 좌표계" 기준이다.
- 법선
n을 z축으로 삼는 직교 기저(t, b, n)를 세운다. - 로컬 방향을 그 기저로 회전시켜 월드 방향으로 바꾼다.
// 법선 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단계 — 난수 두 개로 로컬 방향 정하기
float u1 = U(rng), u2 = U(rng);— 독립적인 [0,1) 난수 두 개. 하나는 "법선에서 얼마나 기우나(극각)", 다른 하나는 "그 원판 위 어느 방위인가(방위각)"를 정합니다.float r = std::sqrt(u1);— 로컬 원판 위 반지름. 여기sqrt가 코사인 가중의 핵심입니다. 만약r = u1로 그냥 뒀다면 방향이 반구에 균등하게 퍼지지만,r = √u1로 하면 바깥쪽(법선에서 멀리 기운 방향)이 더 자주 나오도록 제대로 왜곡되어, 최종적으로 방향의 확률밀도가cosθ/π가 됩니다.float theta = 6.2831853f * u2;— 방위각θ ∈ [0, 2π).6.2831853은2π입니다. 원판 한 바퀴를 균등하게 돕니다.
그다음 로컬 좌표를 조립합니다:
Vec3 local(r*cos(theta), r*sin(theta), sqrt(1-u1));— x·y는 반지름r의 원판 위 좌표(cos·sin으로 원을 그림), z는√(1−u1). 왜 z가√(1−u1)인가: 로컬에서 z축이 법선이고, 벡터가 단위 길이가 되려면x²+y²+z² = r²+z² = u1 + (1−u1) = 1이 딱 맞아떨어지도록 설계된 것입니다(r² = u1이니까). 즉 이 점은 항상 단위 반구 위에 놓입니다.
√u1로 뽑은 반지름은 원판 위 점들이
바깥 테두리(법선에서 많이 기운 곳)보다 중앙(법선 정면)에 더 촘촘히 모이게 만듭니다. 이걸 반구 표면으로
올리면(z 좌표 부여) 결과적으로 "법선 정면 방향이 가장 자주 뽑히는" 분포가 됩니다 — 정확히 램버트 표면이 원하는 그것.
2단계 — 법선을 z축으로 하는 직교 기저 세우기
지금 local은 "법선이 z축이라고 가정한 로컬 좌표"입니다. 실제 표면의 법선 n은 아무 방향이나 가리키니,
이 로컬 방향을 월드 공간으로 회전시켜야 합니다. 그러려면 n과 그에 수직인 두 축(t, b)이
필요합니다 — 이 셋이 직교 좌표계(orthonormal basis)를 이룹니다.
Vec3 a = (fabs(n.x) > 0.1f) ? Vec3(0,1,0) : Vec3(1,0,0);— 보조 벡터를 고릅니다. 외적으로 수직축을 만들려면n과 평행하지 않은 벡터가 하나 필요한데, 아무거나 고르면n과 우연히 평행해져 외적이 0벡터가 될 수 있습니다. 그래서 "n의 x성분이 크면(=x축과 가까우면) 대신 y축을, 아니면 x축을" 골라 항상n과 충분히 어긋나게 합니다. 이게 그 삼항 연산자의 뜻입니다.Vec3 t = normalize(cross(a, n));—a와n의 외적은 둘 다에 수직인 벡터. 정규화해 첫 번째 접선축t(tangent)로 씁니다.Vec3 b = cross(n, t);—n과t의 외적은 세 번째 축b(bitangent).n·t가 이미 단위이고 서로 수직이라 결과도 자동으로 단위 길이 → 여기선 정규화 생략 가능(그래서 안 함).
왜 임계값이 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)에 실어 더합니다:
t*local.x— 로컬 x만큼 접선축 방향으로.b*local.y— 로컬 y만큼 종접선축 방향으로.n*local.z— 로컬 z만큼 법선 방향으로(로컬 z가 컸으니 결과는 대체로 법선 쪽을 향함).
세 벡터의 합이 곧 월드 공간에서의 최종 방향입니다. 마지막 normalize는 부동소수 오차로 길이가 살짝
틀어진 걸 다시 1로 맞추는 안전장치입니다(이론상 이미 단위이지만 실용상 넣어둠).
1/(2π)로 달라져, 아래
radiance의 "albedo만 남는 상쇄"가 성립하지 않습니다. 그때는 albedo * incoming * cosθ * (2π/π) = albedo *
incoming * 2cosθ처럼 cosθ와 상수를 직접 곱해줘야 하고, 이걸 빠뜨리면 결과가 어둡거나 밝게
틀어집니다. 샘플링 방식과 곱셈 식은 반드시 짝을 맞춰야 물리적으로 옳습니다.
4) 경로 추적 재귀 — radiance
무엇을 만드나: 광선 하나를 씬에 쏴서, 그 경로가 카메라로 가져오는 빛(복사휘도, radiance)을 계산하는
재귀 함수입니다. 왜 재귀인가: 렌더링 방정식의 Li(들어오는 빛)가 또 다른
표면의 Lo(나가는 빛)이기 때문 — 빛이 튕길 때마다 radiance가 자기 자신을 다시 부릅니다.
// 경로 추적: 반환값은 이 경로가 카메라로 가져오는 복사휘도
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);
}
시그니처와 종료 조건
Vec3 radiance(Ray r, int depth)— 광선r과 남은 튕김 횟수depth를 받습니다.r이 값 전달(참조 아님)인 이유: 재귀 안에서 새bounce광선으로 자유롭게 다뤄야 하고 크기가 작아 복사 비용이 무의미하기 때문. 반환은 RGB 복사휘도(Vec3).if (depth <= 0) return {0,0,0};— 재귀 바닥(base case). 정해진 최대 튕김에 도달하면 더 안 따라가고 검정(빛 없음) 반환. 무한 재귀 방지의 1차 안전선입니다.float t; int i = closest(r, t);— 가장 가까운 구를 찾습니다.if (i < 0) return {0,0,0};— 아무것도 안 맞으면(광선이 열린 방향으로 빠져나감) 허공 = 검정. 코넬 박스는 벽으로 둘러싸여 실제로는 거의 일어나지 않지만, 안전을 위해 처리합니다.
교차점과 법선 구하기
Vec3 p = r.o + r.d * t;— 광선 공식P(t) = o + d·t로 실제 교차점p를 계산.Vec3 n = normalize(p - s.c);— 구의 법선은 중심에서 표면점으로의 방향입니다.p − c가 딱 그 벡터이고(길이는 반지름), 정규화해 단위 법선으로.if (dot(n, r.d) > 0) n = n * -1.0f;— 법선을 시선 쪽으로 뒤집기. 광선 방향r.d와 법선n의 내적이 양수면 둘이 같은 쪽을 본다는 뜻 = 광선이 표면 뒷면에서 들어왔다는 것. 그럴 땐n을 반대로 뒤집어 항상 광선을 마주보게 만듭니다. 큰 구로 만든 벽 안쪽에서 특히 중요합니다 (방 안에서 보면 벽의 안쪽 면을 보는 것이라 법선이 안쪽을 향해야 함).
광원에 닿으면 종료
if (s.emission.x + s.emission.y + s.emission.z > 0.0f) return s.emission;— 방출값 세 채널 합이 0보다 크면 이 구는 광원입니다. 그 방출색을 그대로 반환하고 경로를 끝냅니다. 이것이 렌더링 방정식의Le이자, 경로가 마침내 "빛의 근원"에 도달한 순간입니다. 이 값이 재귀를 거슬러 올라가며 각 표면의 albedo로 걸러지면서 카메라까지 전달됩니다.
흰색 × 빨강 = 붉은 기운이 되어 color bleeding으로 나타납니다.
러시안 룰렛 — 편향 없이 재귀 끝내기
depth 한계만으로 끝내면 "8번째 튕김에서 강제로 잘린 빛"이 통째로 사라져 결과가 어두워집니다(편향, bias).
러시안 룰렛은 이를 피하면서 재귀를 확률적으로 끝내는 우아한 기법입니다:
float pSurvive = std::max({s.albedo.x, s.albedo.y, s.albedo.z});— 생존 확률을 albedo의 최대 채널로 잡습니다.std::max({...})는 초기화 리스트 버전으로 여러 값 중 최댓값을 한 번에 구합니다. 밝은(반사율 높은) 표면일수록pSurvive가 커서 오래 살아남고, 어두운 표면은 금방 끝납니다 — 기여가 큰 경로에 계산을 집중하는 셈입니다.if (depth < 4) { if (U(rng) > pSurvive) return {0,0,0}; }— 깊이 4 미만부터만 룰렛 발동. 난수가 생존확률을 넘으면 "총알에 맞아" 경로를 즉시 종료(검정 반환). 처음 몇 바운스는 무조건 살려(품질 보장), 그 뒤부터 확률적으로 정리합니다.else pSurvive = 1.0f;— 깊이가 아직 4 이상이면 룰렛을 건너뛰고 생존확률을 1로. 이러면 아래1/pSurvive나눗셈이 그냥 1이 되어 보정이 사라집니다(초기 바운스는 편향 보정 불필요).
1/pSurvive로 증폭하는 것입니다(마지막 줄에 등장).
확률 p로 살아남은 경로에 1/p를 곱하면, 평균적으로는 원래 값이 그대로 유지됩니다
(일부는 0으로 죽고, 산 것은 더 세게). 이 증폭을 빠뜨리면 이미지가 어두워집니다. "죽이되 산 것은 보상한다"가
편향을 없애는 열쇠입니다.
다음 바운스와 상쇄의 마법
Vec3 newDir = cosineHemisphere(n);— 코사인 가중으로 다음 방향을 뽑습니다.Ray bounce{ p + n * 1e-3f, newDir };— 새 광선. 출발점을p가 아니라p + n * 1e-3f로 법선 방향으로 살짝 띄웁니다. 표면에 정확히 붙어서 출발하면 부동소수 오차로 "자기 자신을 즉시 재교차"해 shadow acne가 생기니, 법선쪽으로 미세하게 밀어 이를 방지합니다(hit의1e-3f엡실론과 같은 목적, 상호 보완).Vec3 incoming = radiance(bounce, depth - 1);— 재귀 호출. 이 새 광선이 다시 씬을 튕겨 가져오는 빛을 구합니다.depth - 1로 남은 횟수를 하나 줄입니다. 여기가 렌더링 방정식의Li를 실제로 계산하는 지점입니다.return s.albedo * incoming * (1.0f / pSurvive);— 최종 기여. 왜 이렇게 짧은가: 원래 곱해야 할 항은BRDF(albedo/π) × cosθ × (1/PDF)인데, 코사인 가중 PDF가cosθ/π라1/PDF = π/cosθ. 셋을 곱하면(albedo/π) × cosθ × (π/cosθ) = albedo—π도cosθ도 완벽히 상쇄되고albedo하나만 남습니다. 여기에 색 필터(* incoming)와 러시안 룰렛 보정(* 1/pSurvive)만 곱하면 끝입니다.
손계산: 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로 곱해지며 카메라로 전달됩니다.
여러 번 튕길수록 곱셈이 쌓여 어두워지는 게 자연스러운 감쇠입니다.
albedo만 남지 않으니, 반드시 BRDF × cosθ / PDF를
일반식대로 곱해야 합니다. "왜 albedo만 곱하지?"를 이해하지 못한 채 다른 재질에 그대로 복붙하면 결과가 틀어집니다.
5) 메인 루프 — 샘플 평균과 톤매핑
무엇을 만드나: 모든 픽셀을 돌며 카메라 광선을 만들고, 픽셀마다 radiance를 SPP번 호출해
평균낸 뒤, 톤매핑·감마 보정을 거쳐 PPM으로 출력합니다.
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 헤더
const int W=400, H=400, SPP=256, MAX_DEPTH=8;— 이미지 400×400, 픽셀당 256샘플, 최대 8바운스. SPP가 품질↔시간의 조절 손잡이입니다. 256이면 꽤 깨끗하지만 400×400×256×(평균 바운스)회의 광선을 쏘니 싱글스레드로 수십 초~수 분 걸립니다.std::printf("P3\n%d %d\n255\n", W, H);— PPM 헤더.P3는 "ASCII 텍스트 컬러" 매직 넘버, 다음 줄에 폭·높이, 마지막255는 채널 최댓값. 이 세 줄 뒤로 픽셀 RGB 숫자들이 이어집니다.Vec3 cam(0, 0, 0);— 카메라를 원점에 둡니다. 씬이 −z 방향(z=−4~−8)에 있으니 카메라는 −z를 바라봅니다(왼손 좌표계 규약과 일치).
카메라 광선 만들기 — 픽셀 → 방향
float px = (2.0f*(x + U(rng))/W - 1.0f);— 픽셀 x좌표(0~W)를 NDC 유사 좌표 [−1,+1]로 변환.x + U(rng)의U(rng)가 픽셀 내부 무작위 지터라, 매 샘플마다 픽셀 안 조금씩 다른 곳을 조준합니다 → 안티앨리어싱이 공짜로 됩니다(경계 계단이 여러 샘플 평균으로 부드러워짐).float py = 1.0f - 2.0f*(y + U(rng))/H;— y좌표 변환. 여기서1.0f - ...로 부호를 뒤집는 이유: 이미지 좌표는 y가 위→아래로 증가(텍스처 V축 규약과 동일)하지만, 3D 공간은 y가 아래→위라 방향을 맞춰야 하기 때문입니다. 이 반전을 빠뜨리면 이미지가 상하로 뒤집혀 나옵니다.Ray r{ cam, normalize(Vec3(px, py, -1.0f)) };— 원점에서(px, py, −1)방향으로 광선.z=−1이 화면 평면까지의 거리로, 이 값이 시야각(FOV)을 암묵적으로 정합니다(작으면 광각, 크면 망원).normalize로 방향을 단위화해야hit의d·d=1가정이 성립합니다.acc = acc + radiance(r, MAX_DEPTH);— 이 샘플의 복사휘도를 누적기acc에 더합니다.
평균과 톤매핑
acc = acc * (1.0f / SPP);— SPP개 샘플의 평균. 이게 몬테카를로 추정값 — 샘플이 많을수록 참값에 수렴하고 노이즈가1/√SPP로 줄어듭니다.auto tm = [](float v){ ... };— 람다(lambda). 이름 없는 인라인 함수로, 세 채널 각각에 같은 톤매핑을 적용하려고 만들었습니다.[]는 캡처 없음(외부 변수 안 씀),float v는 입력 밝기.v = v / (1.0f + v);— Reinhard 톤매핑. 광원 방출 12 같은 1을 넘는 HDR 값을 부드럽게 [0,1)로 압축합니다.v=0→0,v=1→0.5,v→∞ ⇒ →1. 단순clamp로 자르면 밝은 부분이 하얗게 뭉개지지만(디테일 손실), 이 곡선은 밝은 곳의 계조를 살립니다.std::pow(std::clamp(v,0,1), 1/2.2f)— 감마 보정. 모니터는 sRGB 곡선(≈감마 2.2)을 쓰므로, 선형으로 계산한 색을1/2.2제곱해 미리 보정해야 화면에서 올바른 밝기로 보입니다.clamp는 Reinhard 뒤에도 혹시 모를 범위 이탈을 막는 안전장치.* 255+(int)— [0,1] 실수를 0~255 정수로. PPM은 정수 RGB를 요구합니다.std::printf("%d %d %d ", ...)— 세 정수를 공백 구분해 출력. 픽셀 순서(y 바깥·x 안쪽 루프)가 PPM의 좌→우, 위→아래 순서와 일치합니다.
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)"을 변수로 들고 다니며 반복합니다:
// 컴퓨트 셰이더: 스레드 하나가 픽셀 하나를 담당
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 * ...로 바깥에서 안으로 색을 곱했습니다.
루프 버전은 방향을 뒤집어 안에서 바깥으로, 한 바운스씩 진행하며 곱해 나갑니다:
float3 throughput = float3(1,1,1);— throughput(경로 투과율). "이 경로를 따라온 빛이 지금까지 표면들의 albedo로 얼마나 걸러졌나"를 누적합니다. 처음엔 1(아직 아무것도 안 걸러짐).float3 L = float3(0,0,0);— 최종 누적 빛. 광원에 닿을 때만 채워집니다.throughput *= s.albedo;— 표면을 튕길 때마다 그 albedo를 throughput에 곱해 쌓습니다. CPU 재귀에서s.albedo * incoming이 되돌아오며 곱해지던 것을, 진행 방향으로 미리 곱해두는 것.L += throughput * s.emission;— 광원에 닿는 순간, 지금까지 곱해진 throughput × 방출값을 기여로 확정합니다. 빨간 벽(albedo 0.75,0.25,0.25)을 거쳐 왔다면 throughput에 그 빨강이 배어 있어, 광원 흰빛이 붉게 물들어 더해집니다 — color bleeding이 여기서 그대로 재현됩니다.throughput /= pSurvive;— 러시안 룰렛 증폭. CPU의* (1/pSurvive)와 동일하지만, throughput에 직접 나눠 담습니다.break— 재귀의return을 대신하는 루프 탈출. 광원 도달·허공·룰렛 탈락 시 경로 종료.
-n(단항 마이너스)이 되지만 C++ Vec3엔 operator- 단항이 없어 CPU 코드는
n * -1.0f로 썼습니다 — 같은 뜻입니다. 또 HLSL float3는 a*b가 자동 성분별 곱이라
CPU의 성분별 operator*를 따로 만들 필요가 없습니다. 반대로 행렬 변환이 필요하면 규약대로
mul(v, M)(행벡터)를 쓰고, cbuffer 업로드 시 DirectXMath 행렬을 transpose해야 합니다
(이 패스 트레이서는 행렬을 안 써서 해당 없음).
break하면 일부가 놀게 됩니다. 그래서 실무 GPU 패스 트레이서는 웨이브프론트
방식(바운스 단계별로 광선을 재정렬해 비슷한 것끼리 묶음)이나 DXR의 하드웨어 스케줄링에 의존합니다.
최고 성능은 결국 DXR + RT 코어 + 시공간 디노이저 조합입니다.