C++レイトレーサー自作シリーズ
| Part1 レイ/ベクトル | Part2 交差判定 | Part3 反射/屈折 | Part4 マテリアル | Part5 BVH | Part6 パストレ |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | ✅ | 👈 Now |
はじめに
ここまでで、かなり綺麗な画像が作れるようになった。
でも、間接光(他の物体からの反射光)がまだ足りない。
今回はパストレーシングを実装して、よりリアルな画像を目指そう。
レンダリング方程式
すべてのレンダリング技術の基礎となる方程式。
$$L_o(\mathbf{x}, \omega_o) = L_e(\mathbf{x}, \omega_o) + \int_\Omega f_r(\mathbf{x}, \omega_i, \omega_o) L_i(\mathbf{x}, \omega_i) (\omega_i \cdot \mathbf{n}) d\omega_i$$
Lo = 出力される光(求めたい値)
Le = 自己発光
fr = BRDF(双方向反射率分布関数)
Li = 入射光
n = 法線
Ω = 半球
この積分をモンテカルロ法で近似するのがパストレーシング。
モンテカルロ積分
基本原理
積分をランダムサンプリングで近似する。
$$\int f(x) dx \approx \frac{1}{N} \sum_{i=1}^{N} \frac{f(x_i)}{p(x_i)}$$
N = サンプル数
xi = ランダムサンプル
p(xi) = xiのサンプリング確率密度
なぜこれが使えるの?
大数の法則:サンプル数を増やすと真の値に収束する。
N = 10 → ノイズだらけ
N = 100 → なんとなく見える
N = 1000 → 結構綺麗
N = 10000 → ほぼノイズなし
発光マテリアル
まず、光源を実装しよう。
class DiffuseLight : public Material {
public:
std::shared_ptr<Texture> emit;
DiffuseLight(std::shared_ptr<Texture> a) : emit(a) {}
DiffuseLight(const Color& c) : emit(std::make_shared<SolidColor>(c)) {}
bool scatter(const Ray& r_in, const HitRecord& rec,
Color& attenuation, Ray& scattered) const override {
return false; // 発光するだけで散乱しない
}
Color emitted(double u, double v, const Point3& p) const override {
return emit->value(u, v, p);
}
};
Materialクラスの修正
class Material {
public:
virtual ~Material() = default;
virtual bool scatter(
const Ray& r_in,
const HitRecord& rec,
Color& attenuation,
Ray& scattered
) const = 0;
virtual Color emitted(double u, double v, const Point3& p) const {
return Color(0, 0, 0); // デフォルトは発光しない
}
};
レイカラーの修正
発光を考慮する。
Color ray_color(const Ray& r, const Hittable& world, int depth) {
if (depth <= 0) {
return Color(0, 0, 0);
}
HitRecord rec;
// 何にも当たらなければ背景色(または環境マップ)
if (!world.hit(r, 0.001, infinity, rec)) {
return Color(0, 0, 0); // 黒(光源なし)
}
Ray scattered;
Color attenuation;
Color emitted = rec.mat_ptr->emitted(rec.u, rec.v, rec.p);
// 散乱しなければ発光のみ
if (!rec.mat_ptr->scatter(r, rec, attenuation, scattered)) {
return emitted;
}
// 発光 + 反射光
return emitted + attenuation * ray_color(scattered, world, depth - 1);
}
コーネルボックス
パストレーシングのテストに使われる定番シーン。
HittableList cornell_box() {
HittableList objects;
auto red = std::make_shared<Lambertian>(Color(0.65, 0.05, 0.05));
auto white = std::make_shared<Lambertian>(Color(0.73, 0.73, 0.73));
auto green = std::make_shared<Lambertian>(Color(0.12, 0.45, 0.15));
auto light = std::make_shared<DiffuseLight>(Color(15, 15, 15));
// 壁
objects.add(std::make_shared<Quad>(Point3(555,0,0), Vec3(0,555,0), Vec3(0,0,555), green)); // 右(緑)
objects.add(std::make_shared<Quad>(Point3(0,0,0), Vec3(0,555,0), Vec3(0,0,555), red)); // 左(赤)
objects.add(std::make_shared<Quad>(Point3(0,0,0), Vec3(555,0,0), Vec3(0,0,555), white)); // 床
objects.add(std::make_shared<Quad>(Point3(555,555,555), Vec3(-555,0,0), Vec3(0,0,-555), white)); // 天井
objects.add(std::make_shared<Quad>(Point3(0,0,555), Vec3(555,0,0), Vec3(0,555,0), white)); // 奥
// 光源
objects.add(std::make_shared<Quad>(Point3(213,554,227), Vec3(130,0,0), Vec3(0,0,105), light));
// ボックス
std::shared_ptr<Hittable> box1 = make_box(Point3(0,0,0), Point3(165,330,165), white);
box1 = std::make_shared<RotateY>(box1, 15);
box1 = std::make_shared<Translate>(box1, Vec3(265,0,295));
objects.add(box1);
std::shared_ptr<Hittable> box2 = make_box(Point3(0,0,0), Point3(165,165,165), white);
box2 = std::make_shared<RotateY>(box2, -18);
box2 = std::make_shared<Translate>(box2, Vec3(130,0,65));
objects.add(box2);
return objects;
}
四角形(Quad)クラス
class Quad : public Hittable {
public:
Point3 Q; // 頂点
Vec3 u, v; // 2辺のベクトル
std::shared_ptr<Material> mat;
Vec3 normal;
double D;
Vec3 w;
Quad(const Point3& Q, const Vec3& u, const Vec3& v,
std::shared_ptr<Material> m)
: Q(Q), u(u), v(v), mat(m)
{
auto n = cross(u, v);
normal = normalize(n);
D = dot(normal, Q);
w = n / dot(n, n);
}
bool hit(const Ray& r, double t_min, double t_max,
HitRecord& rec) const override {
auto denom = dot(normal, r.direction);
// 平行チェック
if (std::fabs(denom) < 1e-8) {
return false;
}
auto t = (D - dot(normal, r.origin)) / denom;
if (t < t_min || t > t_max) {
return false;
}
// 四角形の内部かチェック
auto intersection = r.at(t);
Vec3 planar = intersection - Q;
auto alpha = dot(w, cross(planar, v));
auto beta = dot(w, cross(u, planar));
if (alpha < 0 || alpha > 1 || beta < 0 || beta > 1) {
return false;
}
rec.t = t;
rec.p = intersection;
rec.mat_ptr = mat;
rec.set_face_normal(r, normal);
rec.u = alpha;
rec.v = beta;
return true;
}
AABB bounding_box() const override {
return AABB(Q, Q + u + v).pad();
}
};
重点サンプリング(Importance Sampling)
ランダムにサンプリングするより、光源に向かってサンプリングしたほうが効率的。
┌──────────────────────────────────────────────────────────────┐
│ 重点サンプリング │
│ │
│ 一様サンプリング 重点サンプリング │
│ ↗ ↗ ↑ ↖ ↖ ↗↗↗ │
│ ╲ ● ╱ ╲ ● ╱ │
│ ↓ ↓ │
│ │
│ ほとんどが無駄 光源に集中 → 効率的 │
└──────────────────────────────────────────────────────────────┘
PDF(確率密度関数)
class PDF {
public:
virtual ~PDF() = default;
virtual double value(const Vec3& direction) const = 0;
virtual Vec3 generate() const = 0;
};
// コサイン重み付きPDF(Lambertian用)
class CosinePDF : public PDF {
public:
ONB uvw;
CosinePDF(const Vec3& w) { uvw.build_from_w(w); }
double value(const Vec3& direction) const override {
auto cosine = dot(normalize(direction), uvw.w);
return (cosine <= 0) ? 0 : cosine / pi;
}
Vec3 generate() const override {
return uvw.local(random_cosine_direction());
}
};
// 光源に向かうPDF
class HittablePDF : public PDF {
public:
Point3 origin;
std::shared_ptr<Hittable> ptr;
HittablePDF(std::shared_ptr<Hittable> p, const Point3& origin)
: ptr(p), origin(origin) {}
double value(const Vec3& direction) const override {
return ptr->pdf_value(origin, direction);
}
Vec3 generate() const override {
return ptr->random(origin);
}
};
混合PDF
複数のPDFを組み合わせる。
class MixturePDF : public PDF {
public:
std::shared_ptr<PDF> p[2];
MixturePDF(std::shared_ptr<PDF> p0, std::shared_ptr<PDF> p1) {
p[0] = p0;
p[1] = p1;
}
double value(const Vec3& direction) const override {
return 0.5 * p[0]->value(direction) + 0.5 * p[1]->value(direction);
}
Vec3 generate() const override {
if (random_double() < 0.5) {
return p[0]->generate();
} else {
return p[1]->generate();
}
}
};
レイカラーの改良版
PDFを使った実装。
Color ray_color(const Ray& r, const Hittable& world,
const std::shared_ptr<Hittable>& lights, int depth) {
if (depth <= 0) {
return Color(0, 0, 0);
}
HitRecord rec;
if (!world.hit(r, 0.001, infinity, rec)) {
return Color(0, 0, 0);
}
ScatterRecord srec;
Color emitted = rec.mat_ptr->emitted(r, rec, rec.u, rec.v, rec.p);
if (!rec.mat_ptr->scatter(r, rec, srec)) {
return emitted;
}
if (srec.skip_pdf) {
// 鏡面反射などはPDFを使わない
return srec.attenuation * ray_color(srec.skip_pdf_ray, world, lights, depth - 1);
}
// 混合PDF:光源 + コサイン重み
auto light_ptr = std::make_shared<HittablePDF>(lights, rec.p);
MixturePDF mixed_pdf(light_ptr, srec.pdf_ptr);
Ray scattered = Ray(rec.p, mixed_pdf.generate());
auto pdf_val = mixed_pdf.value(scattered.direction);
double scattering_pdf = rec.mat_ptr->scattering_pdf(r, rec, scattered);
Color sample_color = ray_color(scattered, world, lights, depth - 1);
return emitted + srec.attenuation * scattering_pdf * sample_color / pdf_val;
}
ロシアンルーレット
深い再帰を確率的に打ち切る方法。
Color ray_color(const Ray& r, const Hittable& world, int depth) {
HitRecord rec;
if (!world.hit(r, 0.001, infinity, rec)) {
return Color(0, 0, 0);
}
// ロシアンルーレット
double continue_probability = 0.8; // 80%の確率で続行
if (depth > 3 && random_double() > continue_probability) {
return rec.mat_ptr->emitted(rec.u, rec.v, rec.p);
}
// ... 通常の処理
// 打ち切り確率で補正
return result / continue_probability;
}
これで無限再帰でも正しい期待値が得られる。
NEE(Next Event Estimation)
各ヒットポイントで直接光を明示的にサンプリング。
Color direct_light(const Point3& p, const Vec3& normal,
const std::shared_ptr<Hittable>& lights) {
Color direct(0, 0, 0);
// 光源上のランダムな点をサンプル
Vec3 to_light = lights->random(p) - p;
double distance_squared = to_light.length_squared();
to_light = normalize(to_light);
// 遮蔽チェック
Ray shadow_ray(p, to_light);
HitRecord shadow_rec;
if (world.hit(shadow_ray, 0.001, std::sqrt(distance_squared) - 0.001, shadow_rec)) {
return Color(0, 0, 0); // 遮られている
}
// 光源からの寄与を計算
double cosine = std::fmax(0, dot(normal, to_light));
double pdf = lights->pdf_value(p, to_light);
Color light_emission = lights->material->emitted(...);
return light_emission * cosine / (pdf * distance_squared);
}
MIS(Multiple Importance Sampling)
複数のサンプリング戦略を最適に組み合わせる方法。
double balance_heuristic(double pdf_a, double pdf_b) {
return pdf_a / (pdf_a + pdf_b);
}
double power_heuristic(double pdf_a, double pdf_b, double beta = 2.0) {
double a = std::pow(pdf_a, beta);
double b = std::pow(pdf_b, beta);
return a / (a + b);
}
並列化
レイトレーシングはembarrassingly parallel。各ピクセルを独立に計算できる。
#include <omp.h>
int main() {
std::vector<Color> framebuffer(image_width * image_height);
#pragma omp parallel for schedule(dynamic, 1)
for (int j = image_height - 1; j >= 0; --j) {
for (int i = 0; i < image_width; ++i) {
Color pixel_color(0, 0, 0);
for (int s = 0; s < samples_per_pixel; ++s) {
auto u = (i + random_double()) / (image_width - 1);
auto v = (j + random_double()) / (image_height - 1);
Ray r = camera.get_ray(u, v);
pixel_color += ray_color(r, world, lights, max_depth);
}
framebuffer[j * image_width + i] = pixel_color / samples_per_pixel;
}
// 進捗表示(スレッドセーフに)
#pragma omp critical
{
std::cerr << "\rScanlines remaining: " << j << " " << std::flush;
}
}
// ファイル出力
// ...
}
最終結果
1000サンプル/ピクセルでレンダリングした結果:
- 直接光のみ:硬い影、暗い
- パストレーシング:柔らかい影、自然な色の滲み
- NEE + MIS:ノイズが少ない、効率的
まとめ
| トピック | ポイント |
|---|---|
| レンダリング方程式 | 全ての光の伝搬を記述 |
| モンテカルロ積分 | ランダムサンプリングで近似 |
| 重点サンプリング | 光源に向かってサンプル |
| ロシアンルーレット | 確率的な再帰の打ち切り |
| NEE | 直接光を明示的にサンプル |
| MIS | 複数戦略の最適な組み合わせ |
このシリーズで、レイトレーシングの基礎から応用まで一通り学んだ。
もっと学びたい人へのおすすめ:
- Ray Tracing in One Weekend - 今回の実装のベース
- PBRT - プロダクションレベルのレンダラー解説
- Scratchapixel - CG全般の解説
ぜひ自分でも実装して、3DCGの世界を楽しんでね!
この記事が役に立ったら、いいね・ストックしてもらえると嬉しいです!