Skip to content
Copied!
published on 2026-04-05

10. 誘電体マテリアル

Ray Tracing in One Weekend (v3.2.3): 10 Dielectrics / 1.10 誘電体マテリアル

この章では誘電体マテリアル(水・ガラス・ダイヤモンドなど透明な物体)を実装します。光が誘電体に当たると,一部は反射し,残りは屈折(透過)します。この実装では 1 回の交差判定ごとにランダムにどちらか一方を選択します。

スネルの法則

光が異なる媒質の境界を通過するとき,進行方向が変わります。これを屈折といい,スネルの法則で記述されます。

ηsinθ=ηsinθ

ηη は各媒質の屈折率で,θθ は法線に対する入射角・屈折角です。

媒質屈折率 η
空気≈ 1.0
ガラス1.3–1.7
ダイヤモンド≈ 2.4

屈折レイ R を法線 n^ に対する平行成分と垂直成分に分解すると

R=ηη(R+cosθn^)R=1|R|2n^

Rn^ が単位ベクトルであれば cosθ=(R)n^ なので

R=ηη(R+((R)n^)n^)

自分で確認してみよ(prove this for yourself)ということで,以下に導出過程を示します。

分解式の導出

入射レイの分解

R(単位ベクトル)を n^ 方向とそれに垂直な成分に分解します。

R=(Rn^)n^法線方向+(R(Rn^)n^)接線方向

cosθ=(R)n^ より Rn^=cosθ なので,接線成分は

R接線=R+cosθn^

この成分の大きさは |R接線|=sinθ です(|R|=1 より)。単位接線ベクトルを

t^=R+cosθn^sinθ

と置きます。

R(接線成分)

境界面では波の位相整合条件から,屈折レイの接線成分は入射レイと同じ方向 t^ を向きます。その大きさはスネルの法則から

sinθ=ηηsinθ

なので

R=sinθt^=ηηsinθR+cosθn^sinθ=ηη(R+cosθn^)

sinθ が約分されて消えます。

R(法線成分)

R は単位ベクトルなので |R|2+|R|2=1,したがって

|R|=1|R|2

屈折レイは媒質の内側へ進むので,法線成分は n^ と逆向きです。

R=1|R|2n^

refract 関数

上式を C++ で実装すると以下になります(vec3.h に自由関数として追加)。

cpp
vec3 refract(const vec3& uv, const vec3& n, double etai_over_etat) {
    auto cos_theta = dot(-uv, n);
    vec3 r_out_parallel = etai_over_etat * (uv + cos_theta * n);
    vec3 r_out_perp = -sqrt(1.0 - r_out_parallel.length_squared()) * n;
    return r_out_parallel + r_out_perp;
}

Rust の実装です(vec3.rs に追加)。

rust
/// Computes the refraction vector (Snell's law).
///
/// `uv`: unit incident direction, `n`: unit normal pointing outward from the incident medium, `etai_over_etat`: η/η'.
pub fn refract(uv: Vec3, n: Vec3, etai_over_etat: f64) -> Vec3 {
    let cos_theta = dot(-uv, n).min(1.0);
    let r_out_parallel = etai_over_etat * (uv + cos_theta * n);
    let r_out_perp = -(1.0 - r_out_parallel.length_squared()).sqrt() * n;
    r_out_parallel + r_out_perp
}

C++ の fmin(dot(-uv, n), 1.0) は Rust では dot(-uv, n).min(1.0) と書きます。浮動小数点誤差で cosθ が 1.0 をわずかに超える場合を防ぐためのクランプです。

全反射

密な媒質から疎な媒質へ(例:ガラス→空気)レイが進むとき,スネルの法則の解が存在しない場合があります。

sinθ=ηηsinθ

η/η>1 のとき,sinθ が 1.0 を超えることがあり,この場合は全反射が起きます。sinθ=1cos2θ の恒等式を使うと

cpp
// C++
if (etai_over_etat * sin_theta > 1.0) {
    // Total internal reflection → call reflect() instead of refract()
}

Schlick 近似

現実のガラスは入射角によって反射率が変化します。垂直入射(θ=0)では透過率が高く,斜め入射では徐々に反射率が上がり,θ=90° では全反射に近づきます。

この角度依存の反射率をフレネル式の多項式近似(Schlick 近似)で計算します。

Schlick近似は,フレネル反射を高速に計算するための近似手法です。フレネル反射とは,物体表面での光の反射率が視線方向と表面法線のなす角度によって変化する現象であり,物理的に正確な計算にはフレネル方程式を用いますが,この式には複雑な三角関数や累乗計算が含まれるため,リアルタイムレンダリングでは計算コストが問題となります。Christophe Schlickが1994年に提案したこの近似式は,反射率を視線ベクトルと法線ベクトルの内積の簡単な多項式で近似することにより,物理的妥当性を保ちながら計算量を大幅に削減します。具体的には,垂直入射時の反射率を基準値として,角度依存性を5乗の項で表現する形式が広く用いられており,ゲームエンジンや映像制作ソフトウェアにおける標準的な手法の一つとなっています。

R(θ)R0+(1R0)(1cosθ)5R0=(ηηη+η)2

C++ の実装です。

cpp
double schlick(double cosine, double ref_idx) {
    auto r0 = (1 - ref_idx) / (1 + ref_idx);
    r0 = r0 * r0;
    return r0 + (1 - r0) * pow((1 - cosine), 5);
}

Rust の実装です(material.rs 内のプライベート関数)。

rust
fn schlick(cosine: f64, ref_idx: f64) -> f64 {
    let r0 = ((1.0 - ref_idx) / (1.0 + ref_idx)).powi(2);
    r0 + (1.0 - r0) * (1.0 - cosine).powi(5)
}

C++ の pow(x, 5) は Rust では x.powi(5) と書きます。powi は整数乗の専用メソッドで,powf(浮動小数点乗)よりも効率的です。

Dielectric マテリアル

全反射チェックと Schlick 近似を組み合わせた完全な誘電体マテリアルを実装します。

C++ の実装です。

cpp
class dielectric : public material {
public:
    dielectric(double ri) : ref_idx(ri) {}

    virtual bool scatter(
        const ray& r_in, const hit_record& rec,
        color& attenuation, ray& scattered
    ) const {
        attenuation = color(1.0, 1.0, 1.0);
        double etai_over_etat = rec.front_face ? (1.0 / ref_idx) : ref_idx;

        vec3 unit_direction = unit_vector(r_in.direction());
        double cos_theta = fmin(dot(-unit_direction, rec.normal), 1.0);
        double sin_theta = sqrt(1.0 - cos_theta * cos_theta);

        if (etai_over_etat * sin_theta > 1.0) {
            // Total internal reflection
            vec3 reflected = reflect(unit_direction, rec.normal);
            scattered = ray(rec.p, reflected);
            return true;
        }
        double reflect_prob = schlick(cos_theta, etai_over_etat);
        if (drand48() < reflect_prob) {
            vec3 reflected = reflect(unit_direction, rec.normal);
            scattered = ray(rec.p, reflected);
            return true;
        }
        vec3 refracted = refract(unit_direction, rec.normal, etai_over_etat);
        scattered = ray(rec.p, refracted);
        return true;
    }

    double ref_idx;
};

Rust の実装です。

rust
pub struct Dielectric {
    /// Index of refraction (air ≈ 1.0, glass ≈ 1.3–1.7, diamond ≈ 2.4).
    pub ref_idx: f64,
}

impl Dielectric {
    pub fn new(ref_idx: f64) -> Self {
        Dielectric { ref_idx }
    }
}

impl Material for Dielectric {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        // Dielectrics do not absorb light (attenuation = white).
        let attenuation = Color::new(1.0, 1.0, 1.0);
        // Choose η/η' based on whether the ray enters from the front or back face.
        let etai_over_etat = if rec.front_face {
            1.0 / self.ref_idx
        } else {
            self.ref_idx
        };

        let unit_direction = unit_vector(r_in.direction());
        let cos_theta = dot(-unit_direction, rec.normal).min(1.0);
        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();

        let direction = if etai_over_etat * sin_theta > 1.0 {
            // Total internal reflection: Snell's law has no solution, so reflect only.
            reflect(unit_direction, rec.normal)
        } else {
            // Schlick approximation: stochastically choose reflection or refraction.
            let reflect_prob = schlick(cos_theta, etai_over_etat);
            if random_double() < reflect_prob {
                reflect(unit_direction, rec.normal)
            } else {
                refract(unit_direction, rec.normal, etai_over_etat)
            }
        };

        Some((attenuation, Ray::new(rec.p, direction)))
    }
}

scatter の戻り値は常に Some(...) です。誘電体は光を吸収しないため,常に散乱が起きます(反射か屈折かの違いはあります)。attenuation が常に白 (1, 1, 1) なのは,ガラスが色を付けないためです。

rec.front_face は 1.6 章で HitRecord に追加したフィールドで,レイが球の表側(外側)から当たったかどうかを示します。

  • 外から当たる(front_face = true):空気(η=1)からガラス(η'=1.5)なので etai_over_etat = 1/1.5
  • 内から当たる(front_face = false):ガラス(η=1.5)から空気(η'=1)なので etai_over_etat = 1.5

Rust でのパターンマッチング構造

C++ 版の scatterif 分岐でそれぞれ scattered を設定して return true を繰り返していますが,Rust 版では directionif 式で求めて最後にまとめて Ray::new を呼びます。これは Rust の式指向(expression-oriented)な設計によるものです。

cpp
// C++: if is a statement
if (etai_over_etat * sin_theta > 1.0) {
	scattered = ray(rec.p, reflected);
	return true;
}
if (drand48() < reflect_prob) {
	scattered = ray(rec.p, reflected);
	return true;
}
scattered = ray(rec.p, refracted);
return true;
rust
// Rust: if is an expression and can return a value
let direction = if etai_over_etat * sin_theta > 1.0 {
    reflect(unit_direction, rec.normal)        // Total internal reflection
} else if random_double() < reflect_prob {
    reflect(unit_direction, rec.normal)        // Probabilistic reflection via Schlick
} else {
    refract(unit_direction, rec.normal, etai_over_etat)  // Refraction
};

中空ガラス球のトリック

球の半径を負にすると,ジオメトリはそのままで法線が反転します。これを利用して,同心の 2 球(外球 radius=+0.5,内球 radius=−0.45)を配置すると中空ガラス球(空気の泡)を表現できます。

cpp
// C++
world.add(make_shared<sphere>(point3(-1,0,-1),  0.5,  make_shared<dielectric>(1.5)));
world.add(make_shared<sphere>(point3(-1,0,-1), -0.45, make_shared<dielectric>(1.5)));

Rust の実装です。

rust
// Adding a type annotation as `Arc<dyn Material>` resolves the type for Arc::clone()
let glass: Arc<dyn Material> = Arc::new(Dielectric::new(1.5));
world.add(Box::new(Sphere::with_material(
    Point3::new(-1.0, 0.0, -1.0), 0.5,
    Arc::clone(&glass),
)));
world.add(Box::new(Sphere::with_material(
    Point3::new(-1.0, 0.0, -1.0), -0.45,
    glass,
)));

let glass: Arc<dyn Material> の型注釈に注意してください。型注釈がないと glassArc<Dielectric> に推論され,Arc::clone(&glass) の戻り値が Arc<Dielectric> になります。Sphere::with_materialArc<dyn Material> を要求するので,そのままでは型が合いません。最初から Arc<dyn Material> として宣言することで,Arc::cloneArc<dyn Material> を返すようになります。

C++ と Rust の比較

機能C++Rust
屈折ベクトルrefract(uv, n, eta) 自由関数refract(uv, n, eta) 自由関数
浮動小数点クランプfmin(x, 1.0)x.min(1.0)
整数乗pow(x, 5)x.powi(5)
乱数drand48()random_double()
共有ポインタの型shared_ptr<dielectric> として透過的に扱えるArc<dyn Material> と明示的に型注釈が必要な場合がある
式指向分岐if が文なので各分岐で変数代入+returnif が式なので let x = if ... { ... }

common クレートへの追加

common/src/vec3.rs(追加分)

common/src/vec3.rs
rust
/// Computes the refraction vector (Snell's law).
///
/// `uv`: unit incident direction, `n`: unit normal pointing outward from the incident medium, `etai_over_etat`: η/η'.
pub fn refract(uv: Vec3, n: Vec3, etai_over_etat: f64) -> Vec3 {
    let cos_theta = dot(-uv, n).min(1.0);
    let r_out_parallel = etai_over_etat * (uv + cos_theta * n);
    let r_out_perp = -(1.0 - r_out_parallel.length_squared()).sqrt() * n;
    r_out_parallel + r_out_perp
}

common/src/material.rs(追加分)

common/src/material.rs
rust
/// Reflectance computed via the Schlick approximation (angle-dependent reflection probability).
fn schlick(cosine: f64, ref_idx: f64) -> f64 {
    let r0 = ((1.0 - ref_idx) / (1.0 + ref_idx)).powi(2);
    r0 + (1.0 - r0) * (1.0 - cosine).powi(5)
}

/// Dielectric (refractive) material (glass, water, diamond, etc.).
pub struct Dielectric {
    /// Index of refraction (air ≈ 1.0, glass ≈ 1.3–1.7, diamond ≈ 2.4).
    pub ref_idx: f64,
}

impl Dielectric {
    /// Creates a `Dielectric` material with the given index of refraction.
    pub fn new(ref_idx: f64) -> Self {
        Dielectric { ref_idx }
    }
}

impl Material for Dielectric {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let attenuation = Color::new(1.0, 1.0, 1.0);
        let etai_over_etat = if rec.front_face {
            1.0 / self.ref_idx
        } else {
            self.ref_idx
        };
        let unit_direction = unit_vector(r_in.direction());
        let cos_theta = dot(-unit_direction, rec.normal).min(1.0);
        let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
        let direction = if etai_over_etat * sin_theta > 1.0 {
            reflect(unit_direction, rec.normal)
        } else {
            let reflect_prob = schlick(cos_theta, etai_over_etat);
            if random_double() < reflect_prob {
                reflect(unit_direction, rec.normal)
            } else {
                refract(unit_direction, rec.normal, etai_over_etat)
            }
        };
        Some((attenuation, Ray::new(rec.p, direction)))
    }
}

r110-dielectric クレートの実装

r110-dielectric/Cargo.toml を用意します。

r110-dielectric/Cargo.toml
toml
[package]
name = "r110-dielectric"
version = "0.1.0"
edition = "2024"

[dependencies]
common = { workspace = true }

r110-dielectric/src/lib.rs の完全な実装です。

r110-dielectric/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, Dielectric, Hittable, HittableList, Lambertian, Metal, Point3, Sphere,
    unit_vector, write_color_gamma, Ray, random_double,
};

fn ray_color(r: &Ray, world: &dyn Hittable, depth: i32) -> Color {
    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }
    if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) {
        if let Some(mat) = &rec.mat {
            if let Some((attenuation, scattered)) = mat.scatter(r, &rec) {
                return attenuation * ray_color(&scattered, world, depth - 1);
            }
        }
        return Color::new(0.0, 0.0, 0.0);
    }
    let unit_direction = unit_vector(r.direction());
    let t = 0.5 * (unit_direction.y() + 1.0);
    (1.0 - t) * Color::new(1.0, 1.0, 1.0) + t * Color::new(0.5, 0.7, 1.0)
}

pub fn render_image() -> String {
    let aspect_ratio = 16.0_f64 / 9.0;
    let image_width = 384_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;
    let samples_per_pixel = 50_i32;
    let max_depth = 50_i32;

    let mut world = HittableList::new();
    // Center: Lambertian (bluish diffuse sphere)
    world.add(Box::new(Sphere::with_material(
        Point3::new(0.0, 0.0, -1.0), 0.5,
        Arc::new(Lambertian::new(Color::new(0.1, 0.2, 0.5))),
    )));
    // Ground: Lambertian (yellow-green large sphere)
    world.add(Box::new(Sphere::with_material(
        Point3::new(0.0, -100.5, -1.0), 100.0,
        Arc::new(Lambertian::new(Color::new(0.8, 0.8, 0.0))),
    )));
    // Right: Metal (gold, fuzz=0)
    world.add(Box::new(Sphere::with_material(
        Point3::new(1.0, 0.0, -1.0), 0.5,
        Arc::new(Metal::new(Color::new(0.8, 0.6, 0.2), 0.0)),
    )));
    // Left: dielectric (glass sphere, ref_idx=1.5)
    world.add(Box::new(Sphere::with_material(
        Point3::new(-1.0, 0.0, -1.0), 0.5,
        Arc::new(Dielectric::new(1.5)),
    )));

    let camera = Camera::new();

    let mut output = String::new();
    output.push_str("P3\n");
    output.push_str(&format!("{} {}\n", image_width, image_height));
    output.push_str("255\n");

    for j in (0..image_height).rev() {
        for i in 0..image_width {
            let mut pixel_color = Color::new(0.0, 0.0, 0.0);
            for _ in 0..samples_per_pixel {
                let u = (i as f64 + random_double()) / (image_width - 1) as f64;
                let v = (j as f64 + random_double()) / (image_height - 1) as f64;
                let r = camera.get_ray(u, v);
                pixel_color += ray_color(&r, &world, max_depth);
            }
            output.push_str(&format!("{}\n", write_color_gamma(pixel_color, samples_per_pixel)));
        }
    }

    output
}

r110-dielectric-hollow クレートの実装(中空ガラス球)

r110-dielectric-hollowr110-dielectric とほぼ同じで,左球を中空ガラス球に置き換えます。

rust
use common::Material;

// Left: hollow glass sphere (outer radius=+0.5, inner radius=-0.45)
let glass: Arc<dyn Material> = Arc::new(Dielectric::new(1.5));
world.add(Box::new(Sphere::with_material(
    Point3::new(-1.0, 0.0, -1.0), 0.5,
    Arc::clone(&glass),
)));
world.add(Box::new(Sphere::with_material(
    Point3::new(-1.0, 0.0, -1.0), -0.45,
    glass,
)));

WASM エクスポート

raytracing-demos/src/lib.rs に追記します。

raytracing-demos/src/lib.rs
rust
// Chapter 1.10: Dielectric Materials
#[wasm_bindgen]
pub fn render_dielectric() -> String {
    r110_dielectric::render_image()
}

#[wasm_bindgen]
pub fn render_dielectric_hollow() -> String {
    r110_dielectric_hollow::render_image()
}