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

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

래스터화가 "삼각형을 화면에 투영"하는 순방향 파이프라인이라면, 레이 트레이싱은 방향을 뒤집습니다. 픽셀마다 광선(ray)을 세계 속으로 쏘아 무엇과 먼저 부딪히는지 찾고, 그 지점의 색을 계산합니다. 이 한 가지 뒤집기 덕분에 그림자·반사·굴절이 억지 트릭 없이 물리적으로 자연스럽게 흘러나옵니다. 이 페이지에서는 외부 라이브러리 없는 자립 실행 C++ 레이 트레이서를, 벡터 구조체의 연산자 오버로딩부터 2차 방정식 교차·램버트 셰이딩·그림자 광선·재귀 반사·감마 보정까지 성분 하나, 코드 한 줄 단위로 해부합니다. 순수 CPU 계산이라 D3D11 없이 그대로 돌아가고, 마지막에 결과 버퍼를 화면에 올리는 D3D11 연결법도 짚습니다.

규약 이 시리즈 전체 규약: 행벡터 v' = v * M(HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 공간 z 범위 [0,1], 텍스처 V축 위→아래. 다만 이 CPU 레이 트레이서는 행렬 변환을 거의 쓰지 않고 월드 좌표에서 직접 광선을 다루므로, 좌표계 규약이 눈에 띄게 드러나는 곳은 카메라 광선 생성 부분뿐입니다. 실무에서는 벡터·행렬 연산을 DirectXMath(검증된 SIMD 라이브러리)로 처리하길 권합니다. 여기서 벡터를 직접 구현하는 이유는 오로지 내부 동작을 눈으로 보기 위한 학습 목적입니다.

전체 흐름 한눈에 보기

코드를 뜯어보기 전에, 이 레이 트레이서가 하나의 픽셀 색을 어떻게 만들어내는지 큰 그림을 먼저 그려두겠습니다. 아래 다섯 단계가 main()의 이중 루프 안에서 픽셀마다 반복됩니다.

  1. 광선 정의 — 카메라 원점에서 각 픽셀을 향하는 광선(원점 + 정규화된 방향)을 만든다.
  2. 레이-구 교차 — 광선과 각 구의 교차를 2차 방정식으로 풀어 가장 가까운 충돌을 찾는다.
  3. 셰이딩 — 충돌점에서 램버트(확산) 조명을 계산하고, 광원으로 그림자 광선을 쏘아 가림을 판정한다.
  4. 재귀 반사 — 재질이 거울성이면 반사 방향으로 광선을 다시 쏘아(최대 N회 바운스) 색을 섞는다.
  5. 출력 — 계산된 선형 색에 감마 보정을 적용해 0~255 정수로 만들고 PPM 이미지로 저장한다.

핵심 통찰은 "보이는 것을 거꾸로 추적한다"는 데 있습니다. 현실에서 빛은 광원에서 출발해 물체에 부딪히고 튕겨 우리 눈에 들어오지만, 그 무수한 광자 중 카메라에 도달하는 것은 극소수입니다. 그래서 레이 트레이싱은 카메라(눈)에서 거꾸로 광선을 쏩니다. 어차피 우리가 알고 싶은 건 "이 픽셀에 어떤 빛이 도달했는가"뿐이니, 그 픽셀에 도달할 자격이 있는 경로만 역추적하는 것이 압도적으로 효율적입니다. 이 방식을 backward ray tracing 또는 eye tracing이라 부릅니다.

raytracer.cpp — 전체cpp
// 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성분 실수 묶음이기 때문입니다. 외부 라이브러리 없이 내부 동작을 투명하게 보여주려고 직접 만들었습니다.

Vec3 정의cpp
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){} — 생성자입니다. 하나씩 뜯어보면:

연산자 오버로딩이 무엇이고 왜 하나

연산자 오버로딩(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}; }

시그니처 해부:

수학적 의미: 벡터 덧셈은 성분별로 더합니다. (1,2,3) + (4,0,0) = (5,2,3). 기하 직관: 벡터를 "이동 화살표"라 생각하면, a + b는 a만큼 걸어간 뒤 이어서 b만큼 더 걷는 것입니다. 뺄셈 b - a"a에서 b로 향하는 화살표"입니다 — 이 성질은 뒤에서 lightPos - p(교차점에서 광원으로 향하는 방향)를 만들 때 그대로 쓰입니다.

* 오버로딩이 두 개인 이유

같은 * 기호를 두 가지로 정의했습니다. 인자 타입이 다르므로 컴파일러가 자동으로 구별합니다(오버로드 해소).

함정 벡터끼리의 *내적(dot)도 외적(cross)도 아닙니다. 초보자가 dot(a,b)를 써야 할 곳에 a*b를 쓰면 컴파일은 되지만 결과가 완전히 틀립니다(스칼라가 아니라 벡터가 나옴). 내적은 반드시 별도 함수 dot()로 씁니다.
DirectXMath 대응 실무에서는 이 Vec3 대신 XMVECTOR(SIMD 128비트 레지스터, 4×float)와 저장용 XMFLOAT3를 씁니다. 대응 관계는 이렇습니다 — a + bXMVectorAdd(a, b), a - bXMVectorSubtract(a, b), v * sXMVectorScale(v, s), 성분별 곱 a * bXMVectorMultiply(a, b). XMFLOAT3(메모리)와 XMVECTOR(연산용) 사이는 XMLoadFloat3 / XMStoreFloat3로 오갑니다. SIMD라 4성분을 한 명령에 처리해 훨씬 빠릅니다.

2. 벡터 유틸 함수 — dot · len · normalize · reflect

벡터 유틸cpp
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단):

손계산 예제: (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/l0으로 나누기가 되어 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단):

손계산: 바닥(법선 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로 뒤집혔습니다. 정확합니다.

DirectXMath 대응 dotXMVector3Dot(결과가 4성분에 브로드캐스트된 XMVECTOR), lenXMVector3Length, normalizeXMVector3Normalize, reflectXMVector3Reflect(d, n). 특히 XMVector3Reflect는 위의 d - 2(d·n)n 공식을 SIMD로 그대로 구현한 것이라 부호·정의가 이 코드와 동일합니다.

3. Ray · Sphere — 광선과 구, 재질의 정의

기본 타입cpp
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)을 함께 담습니다.

4. hitSphere — 레이-구 교차 (2차 방정식 풀기)

무엇을 만드나: 광선이 특정 구를 뚫고 지나가는지, 지나간다면 가장 먼저 어느 거리 t에서 닿는지 구합니다. 왜 필요한가: 이게 레이 트레이서에서 가장 자주 호출되는 함수입니다(픽셀 × 구 × 바운스마다). 여기 로직이 정확하고 빨라야 합니다.

레이-구 교차cpp
// 레이-구 교차: |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입니다. 계수는:

줄 단위 해부

함정 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 — 씬 데이터와 최근접 탐색

씬과 최근접 구 탐색cpp
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, -101, -5}, 100, ...}바닥 트릭입니다. 중심을 한참 아래(y=-101)에 두고 반지름을 100으로 키우면, 카메라 앞에서는 표면이 거의 평평하게 보여 바닥 평면 역할을 합니다. 평면 교차 코드를 따로 짤 필요 없이 구 교차 하나로 바닥까지 처리하는 영리한 방법입니다. lightPos(5,8,0)은 오른쪽 위 앞쪽의 점광원입니다.

closest — 선형 순회로 가장 가까운 교차 찾기

성능closest는 매 광선마다 모든 구를 전부 검사합니다(O(구 개수)). 구가 4개면 문제없지만 수백만 개 삼각형이면 치명적입니다. 실무 레이 트레이서는 BVH(Bounding Volume Hierarchy) 같은 공간 가속 구조로 검사 횟수를 O(log N)으로 줄입니다. 하드웨어 RT(DXR)는 이 BVH 순회를 GPU의 RT 코어가 전담합니다.

6. trace — 재귀적 광선 추적 (셰이딩·그림자·반사)

무엇을 만드나: 광선 하나가 최종적으로 카메라에 가져오는 색을 계산합니다. 셰이딩, 그림자 판정, 거울 반사 재귀를 모두 여기서 처리합니다. 왜 필요한가: 이 함수가 레이 트레이서의 "두뇌"입니다. 자기 자신을 재귀 호출해 빛의 여러 바운스를 자연스럽게 표현합니다.

재귀 추적cpp
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;
}

재귀 종료 조건과 배경

교차점·법선·광원 방향 구하기

그림자 광선 — 빛이 막혔나

함정 이 그림자 광선은 광원까지의 거리를 검사하지 않아, 광원 너머에 있는 물체도 가림으로 오판할 수 있습니다(여기선 광원이 씬 밖 먼 곳이라 문제가 안 됩니다). 정확히 하려면 lightPos까지의 거리 st < |lightPos - p|인 교차만 그림자로 세야 합니다. 또한 이 방식은 딱딱한(hard) 그림자만 만듭니다 — 부드러운 그림자는 다음 페이지의 면적광 샘플링이 필요합니다.

램버트(확산) 셰이딩

기하 직관: 손전등을 벽에 정면(수직)으로 비추면 밝고(cos 0° = 1), 비스듬히 비추면 같은 빛이 넓은 면적에 퍼져 어두워집니다(cos θ만큼). 이 코사인 감쇠가 램버트 법칙입니다.

손계산: 빨간 구(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)로 어둡지만 완전 검정은 아닙니다.

거울 반사 재귀 — 빛의 두 번째 바운스

DirectXMath / D3D 대응 반사 방향 계산은 그대로 XMVector3Reflect로 대체할 수 있습니다. 이 재귀 구조 자체를 GPU로 옮기면 하드웨어 RT(DXR)의 closest-hit 셰이더에서 TraceRay()를 다시 호출하는 형태가 됩니다 — CPU 재귀 trace(refl, depth-1)가 GPU의 재귀적 TraceRay에 1:1로 대응합니다. 그림자 광선은 any-hit / miss 셰이더 조합으로 처리합니다.

7. main — 카메라 광선 생성과 이미지 출력

메인 루프cpp
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 헤더

픽셀 → NDC → 카메라 광선 (좌표계 규약이 드러나는 곳)

이 세 줄이 이 페이지에서 좌표계 규약이 가장 뚜렷하게 나타나는 부분입니다. 하나씩 봅니다.

함정 Y-플립과 z=-1 부호는 좌표계 규약(왼손·V 위→아래)에 종속적입니다. 이 두 부호 중 하나만 실수로 바꾸면 그림이 위아래로 뒤집히거나 안팎이 뒤바뀌어 아무것도 안 보입니다. "규약을 정하고 일관되게 지키는 것"이 좌표계 버그를 피하는 유일한 방법입니다.

감마 보정과 8비트 출력

손계산: 선형 v = 0.50.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)까지 이름과 규모만 다를 뿐 똑같이 재등장하는 기본기입니다. 다음 페이지에서는 이 재귀 추적을 확률적으로 일반화해, 직접광뿐 아니라 간접광·부드러운 그림자·색번짐까지 물리적으로 계산하는 패스 트레이싱으로 나아갑니다.