10. Dielectrics
Ray Tracing in One Weekend (v3.2.3): 10 Dielectrics
This chapter implements dielectric materials (transparent objects such as water, glass, and diamonds). When light hits a dielectric, some of it reflects and the rest refracts (passes through). This implementation randomly picks one or the other for each intersection.
Snell's Law
When light crosses a boundary between two different media, its direction changes. This is called refraction and is described by Snell's law:
| Medium | Index of refraction η |
|---|---|
| Air | ≈ 1.0 |
| Glass | 1.3–1.7 |
| Diamond | ≈ 2.4 |
Decomposing the refracted ray
When
The original author notes "prove this for yourself," so let's do it myself. The derivation process is shown below.
Derivation of the decomposition
Decomposing the incident ray
Decompose
Since
Its magnitude is
At the boundary, phase matching requires the refracted ray's tangential component to point in the same direction
So:
The
Since
The refracted ray travels into the medium, so the normal component points opposite to
The refract Function
The C++ implementation (free function added to vec3.h):
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;
}The Rust implementation (added to vec3.rs):
/// 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++'s fmin(dot(-uv, n), 1.0) is written as dot(-uv, n).min(1.0) in Rust. This clamp guards against floating-point errors that may push
Total Internal Reflection
When a ray travels from a denser medium into a less dense one (e.g., glass → air), Snell's law may have no solution.
When
// C++
if (etai_over_etat * sin_theta > 1.0) {
// Total internal reflection → call reflect() instead of refract()
}Schlick Approximation
Real glass has a reflectance that varies with the angle of incidence. At normal incidence (
We compute this angular dependence using the polynomial approximation of the Fresnel equations, known as the Schlick approximation:
The C++ implementation:
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);
}The Rust implementation (private function inside material.rs):
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++'s pow(x, 5) is written as x.powi(5) in Rust. powi is a dedicated integer-power method, more efficient than powf (floating-point power).
The Dielectric Material
We implement the complete dielectric material combining the total-internal-reflection check and the Schlick approximation.
The C++ implementation:
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) {
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;
};The Rust implementation:
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 always returns Some(...). Dielectrics do not absorb light, so scattering always occurs (whether as reflection or refraction). The attenuation is always white (1, 1, 1) because glass does not tint the color.
rec.front_face is the field added to HitRecord in chapter 1.6 — it indicates whether the ray hit the front (outside) face of the sphere.
- Entering from the outside (
front_face = true): air (η=1) to glass (η'=1.5), soetai_over_etat = 1/1.5 - Exiting from the inside (
front_face = false): glass (η=1.5) to air (η'=1), soetai_over_etat = 1.5
Expression-Oriented Branching in Rust
The C++ version of scatter uses three separate if blocks, each assigning scattered and calling return true. The Rust version computes direction as a single if expression and then calls Ray::new once at the end. This is a consequence of Rust's expression-oriented design.
// 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 (Schlick)
} else {
refract(unit_direction, rec.normal, etai_over_etat) // Refraction
};The Hollow Sphere Trick
Using a negative radius reverses the surface normals, leaving the geometry intact. Placing two concentric spheres (outer radius +0.5, inner radius −0.45) creates a hollow glass sphere (an air bubble inside glass).
// 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)));The Rust implementation:
// 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,
)));Note the type annotation let glass: Arc<dyn Material>. Without it, glass would be inferred as Arc<Dielectric>, and Arc::clone(&glass) would return Arc<Dielectric>. Since Sphere::with_material requires Arc<dyn Material>, the types would not match. Declaring glass as Arc<dyn Material> from the start makes Arc::clone return Arc<dyn Material> as well.
Differences Between C++ and Rust
| Feature | C++ | Rust |
|---|---|---|
| Refraction vector | refract(uv, n, eta) free function | refract(uv, n, eta) free function |
| Floating-point clamp | fmin(x, 1.0) | x.min(1.0) |
| Integer power | pow(x, 5) | x.powi(5) |
| Random number | drand48() | random_double() |
| Shared pointer type | shared_ptr<dielectric> is implicit | Arc<dyn Material> type annotation may be needed |
| Expression-oriented branching | if is a statement; each branch assigns + return | if is an expression; let x = if ... { ... } |
Adding to the common Crate
common/src/vec3.rs (addition)
/// 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 (additions)
/// 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)))
}
}Implementing the r110-dielectric Crate
Set up r110-dielectric/Cargo.toml:
[package]
name = "r110-dielectric"
version = "0.1.0"
edition = "2024"
[dependencies]
common = { workspace = true }r110-dielectric/src/lib.rs
use std::sync::Arc;
use common::{
Camera, Color, Dielectric, Hittable, HittableList, Lambertian, Metal, Point3, Sphere, Vec3,
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(
Point3::new(0.0, 0.0, 0.0),
Point3::new(0.0, 0.0, -1.0),
Vec3::new(0.0, 1.0, 0.0),
90.0,
aspect_ratio,
0.0,
1.0,
);
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
}Implementing the r110-dielectric-hollow Crate (Hollow Glass Sphere)
r110-dielectric-hollow is nearly identical to r110-dielectric, with the left sphere replaced by a hollow glass sphere.
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 Export
Add to raytracing-demos/src/lib.rs:
// 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()
}