간단한 C++ 코드로 이미지 출력부터 카메라, 재질, 반사, 굴절, 심도 흐림까지 구현하며 주말 동안 경로 추적기 기반 레이 트레이서를 만드는 실습서입니다.
하루 만에 배우는 레이 트레이싱
버전 4.0.2, 2025-04-25
저작권 2018-2024 Peter Shirley. 모든 권리 보유.
나는 지난 여러 해 동안 많은 그래픽스 수업을 가르쳐 왔다. 그중 상당수는 레이 트레이싱으로 진행했는데, API 없이도 모든 코드를 직접 작성해야 하지만 여전히 멋진 이미지를 만들 수 있기 때문이다. 나는 내 강의 노트를 가능한 한 빨리 멋진 프로그램을 만들 수 있도록 하는 실습 가이드로 바꾸기로 했다. 이것은 완전한 기능을 갖춘 레이 트레이서는 아니지만, 레이 트레이싱을 영화에서 핵심 기법으로 만든 간접 조명을 포함하고 있다. 이 단계를 따라가면, 여러분이 만든 레이 트레이서의 구조는 나중에 더 확장된 레이 트레이서로 발전시키기에 좋은 형태가 될 것이며, 더 깊이 파고들고 싶어졌을 때에도 도움이 된다.
누군가 “ray tracing”이라고 말할 때 그것은 여러 가지를 뜻할 수 있다. 내가 여기서 설명할 것은 기술적으로는 path tracer이며, 꽤 범용적인 형태다. 코드는 매우 단순할 것이지만(컴퓨터가 일을 하게 두자!) 여러분이 만들 수 있는 이미지에는 매우 만족하게 될 것이라고 생각한다.
나는 내가 실제로 작업하는 순서대로 레이 트레이서를 작성하는 과정을, 몇 가지 디버깅 팁과 함께 안내할 것이다. 끝날 무렵이면 훌륭한 이미지를 생성하는 레이 트레이서를 갖게 될 것이다. 주말 하나면 충분히 해낼 수 있어야 한다. 더 오래 걸리더라도 걱정하지 말자. 나는 기본 언어로 C++를 사용하지만, 반드시 그럴 필요는 없다. 다만 C++를 추천하는데, 빠르고 이식성이 좋으며 대부분의 상업용 영화 및 비디오 게임 렌더러가 C++로 작성되기 때문이다. 참고로 나는 C++의 대부분의 “modern features”는 피하지만, 상속과 연산자 오버로딩은 레이 트레이서에서 너무 유용해서 포기할 수 없다.
나는 코드를 온라인으로 제공하지 않지만, 코드는 실제로 동작하는 코드이며
vec3class의 몇몇 자명한 연산자를 제외하면 전부 보여준다. 나는 배움을 위해 코드를 직접 타이핑하는 것을 강하게 믿지만, 코드가 제공되면 나도 그 코드를 사용한다. 그래서 코드가 제공되지 않을 때만 내가 말한 바를 실천하는 셈이다. 그러니 묻지 말자!
그 마지막 부분은, 내가 얼마나 크게 생각을 바꿨는지 보여 주는 데 재미가 있어서 그대로 남겨 두었다. 여러 독자가 코드 비교를 하면서 미묘한 오류를 해결할 수 있었기 때문이다. 그러니 코드는 직접 입력해 보길 권하지만, 각 책의 완성된 소스는 GitHub의 RayTracing 프로젝트에서 찾을 수 있다.
이 책들에 포함된 구현 코드에 대한 메모 — 우리의 철학은 다음 목표를 우선한다:
따라서 이 코드는 기본이 되는 구현을 제공하며, 독자가 즐길 수 있도록 수많은 개선 여지를 남겨 둔다. 코드를 최적화하고 modernize하는 방법은 끝없이 많지만, 우리는 단순한 해법을 우선한다.
우리는 벡터(예: dot product와 vector addition)에 대해 약간은 익숙하다고 가정한다. 모른다면 잠깐 복습하자. 그런 복습이 필요하거나 처음부터 배우고 싶다면 Morgan McGuire의 온라인 Graphics Codex, Steve Marschner와 Peter Shirley의 Fundamentals of Computer Graphics, 또는 J.D. Foley와 Andy Van Dam의 _Computer Graphics: Principles and Practice_를 참고하라.
이 프로젝트, GitHub 저장소, 디렉터리 구조, 빌드 및 실행, 수정과 기여를 만드는 방법 또는 참조하는 방법에 대한 정보는 project README 파일을 보라.
프로젝트와 관련된 추가 자료는 our Further Reading wiki page에서 볼 수 있다.
이 책들은 브라우저에서 직접 인쇄해도 잘 나오도록 서식이 잡혀 있다. 또한 각 책의 PDF도 각 릴리스의 “Assets” 섹션에 포함되어 있다.
우리와 소통하고 싶다면 언제든지 아래 이메일로 연락해도 좋다:
마지막으로, 구현 중 문제가 생기거나 일반적인 질문이 있거나, 자신의 아이디어나 작업을 공유하고 싶다면 GitHub 프로젝트의 the GitHub Discussions forum을 보라.
이 프로젝트에 손을 보태 준 모든 분께 감사드린다. 이들은 이 책 끝의 acknowledgments 섹션에서 확인할 수 있다.
이제 시작해 보자!
렌더러를 시작할 때마다 이미지를 볼 수 있는 방법이 필요하다. 가장 직접적인 방법은 파일로 쓰는 것이다. 문제는 형식이 너무 많다는 점이다. 그중 많은 형식은 복잡하다. 나는 항상 일반 텍스트 ppm 파일로 시작한다. Wikipedia의 설명을 보자:
Figure 1: PPM 예시
이런 출력을 만드는 C++ 코드를 작성해 보자:
#include <iostream>
int main() {
// Image
int image_width = 256;
int image_height = 256;
// Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
for (int i = 0; i < image_width; i++) {
auto r = double(i) / (image_width-1);
auto g = double(j) / (image_height-1);
auto b = 0.0;
int ir = int(255.999 * r);
int ig = int(255.999 * g);
int ib = int(255.999 * b);
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
}
Listing 1:[main.cc] 첫 번째 이미지 만들기
이 코드에서 주목할 점이 몇 가지 있다:
파일이 표준 출력 스트림에 기록되므로, 이를 이미지 파일로 리디렉션해야 한다. 보통 명령줄에서 > 리디렉션 연산자를 사용해 처리한다.
Windows에서는 CMake로 이 명령을 실행해 debug build를 얻을 수 있다:
cmake -B build
cmake --build build
그다음 새로 빌드한 프로그램을 이렇게 실행한다:
build\Debug\inOneWeekend.exe > image.ppm
나중에는 속도를 위해 최적화된 빌드를 사용하는 편이 좋다. 그 경우에는 이렇게 빌드한다:
cmake --build build --config release
그리고 최적화된 프로그램은 이렇게 실행한다:
build\Release\inOneWeekend.exe > image.ppm
위 예시는 포함된 소스의 CMakeLists.txt 파일과 같은 방식으로 CMake를 사용해 빌드한다고 가정한다. 자신이 가장 편한 빌드 환경(그리고 언어)을 사용하면 된다.
Mac 또는 Linux에서는 release build 기준으로 프로그램을 이렇게 실행한다:
build/inOneWeekend > image.ppm
전체 빌드 및 실행 방법은 project README에서 확인할 수 있다.
출력 파일을 열어 보면(내 Mac에서는 ToyViewer를 사용했지만, 여러분이 즐겨 쓰는 이미지 뷰어를 사용하고, 지원하지 않는다면 “ppm viewer”를 검색해 보라) 다음 결과가 나온다:
Image 1: 첫 번째 PPM 이미지
만세! 이것이 그래픽스의 “hello world”다. 이미지가 이렇게 보이지 않는다면, 출력 파일을 텍스트 편집기로 열어 어떤 내용인지 확인해 보라. 대략 다음과 같이 시작해야 한다:
P3
256 256
255
0 0 0
1 0 0
2 0 0
3 0 0
4 0 0
5 0 0
6 0 0
7 0 0
8 0 0
9 0 0
10 0 0
11 0 0
12 0 0
...
Listing 2: 첫 번째 이미지 출력
PPM 파일이 이렇게 보이지 않는다면 서식 출력 코드를 다시 확인하라. 이렇게 보이는데도 렌더링되지 않는다면 줄 끝 문자 차이 같은 것이 이미지 뷰어를 혼란스럽게 만들고 있을 수 있다. 이를 디버깅하는 데 도움이 되도록 GitHub 프로젝트의 images 디렉터리에는 test.ppm 파일이 들어 있다. 이것은 뷰어가 PPM 형식을 처리할 수 있는지 확인하고, 여러분이 생성한 PPM 파일과 비교하는 데 도움이 된다.
일부 독자는 Windows에서 자신이 생성한 파일을 보는 데 문제가 있다고 보고했다. 이 경우 문제는 종종 PPM이 UTF-16으로 기록된다는 점이며, 대개 PowerShell에서 발생한다. 이런 문제가 생기면 Discussion 1114를 참고하라.
모든 것이 올바르게 표시된다면 시스템과 IDE 문제는 거의 끝난 셈이다 — 이 시리즈의 나머지 부분에서도 렌더링된 이미지를 생성하는 데 동일한 단순한 메커니즘을 계속 사용한다.
다른 이미지 형식을 만들고 싶다면, 나는 GitHub의 https://github.com/nothings/stb에 있는 헤더 전용 이미지 라이브러리 stb_image.h를 좋아한다.
계속하기 전에 출력에 진행 상태 표시를 추가해 보자. 긴 렌더링의 진행 상황을 추적하고, 무한 루프나 다른 문제로 실행이 멈췄을 가능성을 알아차리는 데 유용하다.
우리 프로그램은 이미지를 표준 출력 스트림(std::cout)에 출력하므로 그대로 두고, 대신 로그 출력 스트림(std::clog)에 써 보자:
for (int j = 0; j < image_height; ++j) { std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush; for (int i = 0; i < image_width; i++) {
auto r = double(i) / (image_width-1);
auto g = double(j) / (image_height-1);
auto b = 0.0;
int ir = int(255.999 * r);
int ig = int(255.999 * g);
int ib = int(255.999 * b);
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
std::clog << "\rDone. \n";
Listing 3:[main.cc] 진행 상태 보고가 있는 메인 렌더 루프
이제 실행하면 남은 scanline 수가 계속 갱신되는 것을 볼 수 있다. 이 단계에서는 너무 빨라서 보이지 않기를 바란다! 걱정하지 말자 — 앞으로 레이 트레이서를 확장하면서 천천히 갱신되는 진행 줄을 충분히 보게 될 것이다.
거의 모든 그래픽스 프로그램은 기하 벡터와 색을 저장하는 어떤 클래스(들)를 갖고 있다. 많은 시스템에서는 이 벡터가 4D이다(기하용으로는 3D 위치에 homogeneous coordinate를 더하고, 색용으로는 RGB에 alpha transparency 성분을 더한다). 하지만 우리 목적에는 세 좌표면 충분하다. 우리는 색, 위치, 방향, 오프셋 등 무엇이든 같은 클래스 vec3를 사용할 것이다. 어떤 사람들은 이것이 어리석은 일을 막지 못한다고 싫어한다. 예를 들어 색에서 위치를 빼는 식이다. 그 말도 일리가 있지만, 우리는 명백히 잘못된 것이 아니라면 항상 “코드가 적은 쪽”을 택할 것이다. 그럼에도 vec3에 대해 point3와 color라는 두 개의 별칭을 선언한다. 이 두 타입은 vec3의 별칭일 뿐이므로 point3를 기대하는 함수에 color를 넘겨도 경고가 나오지 않고, point3에 color를 더하는 것도 막지 못하지만, 코드를 조금 더 읽기 쉽고 이해하기 쉽게 만들어 준다.
새 vec3.h 헤더 파일의 위쪽 절반에 vec3 클래스를 정의하고, 아래쪽 절반에는 유용한 벡터 유틸리티 함수 집합을 정의한다:
#ifndef VEC3_H
#define VEC3_H
#include <cmath>
#include <iostream>
class vec3 {
public:
double e[3];
vec3() : e{0,0,0} {}
vec3(double e0, double e1, double e2) : e{e0, e1, e2} {}
double x() const { return e[0]; }
double y() const { return e[1]; }
double z() const { return e[2]; }
vec3 operator-() const { return vec3(-e[0], -e[1], -e[2]); }
double operator[](int i) const { return e[i]; }
double& operator[](int i) { return e[i]; }
vec3& operator+=(const vec3& v) {
e[0] += v.e[0];
e[1] += v.e[1];
e[2] += v.e[2];
return *this;
}
vec3& operator*=(double t) {
e[0] *= t;
e[1] *= t;
e[2] *= t;
return *this;
}
vec3& operator/=(double t) {
return *this *= 1/t;
}
double length() const {
return std::sqrt(length_squared());
}
double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}
};
// point3 is just an alias for vec3, but useful for geometric clarity in the code.
using point3 = vec3;
// Vector Utility Functions
inline std::ostream& operator<<(std::ostream& out, const vec3& v) {
return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
}
inline vec3 operator+(const vec3& u, const vec3& v) {
return vec3(u.e[0] + v.e[0], u.e[1] + v.e[1], u.e[2] + v.e[2]);
}
inline vec3 operator-(const vec3& u, const vec3& v) {
return vec3(u.e[0] - v.e[0], u.e[1] - v.e[1], u.e[2] - v.e[2]);
}
inline vec3 operator*(const vec3& u, const vec3& v) {
return vec3(u.e[0] * v.e[0], u.e[1] * v.e[1], u.e[2] * v.e[2]);
}
inline vec3 operator*(double t, const vec3& v) {
return vec3(t*v.e[0], t*v.e[1], t*v.e[2]);
}
inline vec3 operator*(const vec3& v, double t) {
return t * v;
}
inline vec3 operator/(const vec3& v, double t) {
return (1/t) * v;
}
inline double dot(const vec3& u, const vec3& v) {
return u.e[0] * v.e[0]
+ u.e[1] * v.e[1]
+ u.e[2] * v.e[2];
}
inline vec3 cross(const vec3& u, const vec3& v) {
return vec3(u.e[1] * v.e[2] - u.e[2] * v.e[1],
u.e[2] * v.e[0] - u.e[0] * v.e[2],
u.e[0] * v.e[1] - u.e[1] * v.e[0]);
}
inline vec3 unit_vector(const vec3& v) {
return v / v.length();
}
#endif
Listing 4:[vec3.h] vec3 정의와 보조 함수
여기서는 double을 사용하지만, 어떤 레이 트레이서는 float를 사용한다. double은 더 높은 정밀도와 더 넓은 범위를 제공하지만, float보다 크기가 두 배다. 제한된 메모리 환경(예: hardware shader)에서 프로그래밍한다면 이 크기 증가는 중요할 수 있다. 어느 쪽이든 괜찮다 — 자신의 취향을 따르자.
새 vec3 클래스를 사용해 새 color.h 헤더 파일을 만들고, 단일 픽셀의 색을 표준 출력 스트림에 써 주는 유틸리티 함수를 정의하자.
#ifndef COLOR_H
#define COLOR_H
#include "vec3.h"
#include <iostream>
using color = vec3;
void write_color(std::ostream& out, const color& pixel_color) {
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();
// Translate the [0,1] component values to the byte range [0,255].
int rbyte = int(255.999 * r);
int gbyte = int(255.999 * g);
int bbyte = int(255.999 * b);
// Write out the pixel color components.
out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}
#endif
Listing 5:[color.h] 색상 유틸리티 함수
이제 메인을 둘 다 사용하도록 바꿀 수 있다:
#include "color.h"
#include "vec3.h"
#include <iostream>
int main() {
// Image
int image_width = 256;
int image_height = 256;
// Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) { auto pixel_color = color(double(i)/(image_width-1), double(j)/(image_height-1), 0);
write_color(std::cout, pixel_color); }
}
std::clog << "\rDone. \n";
}
Listing 6:[main.cc] 첫 번째 PPM 이미지의 최종 코드
그러면 이전과 완전히 같은 그림을 얻게 된다.
...