Total Internal Reflection
One troublesome practical issue with refraction is that there are ray angles for which no solution is possible using Snell's law. When a ray enters a medium of lower index of refraction at a sufficiently glancing angle, it can refract with an angle greater than 90°. If we refer back to Snell's law and the derivation of \( \sin \theta' \):
\[ \sin \theta' = \frac{\eta}{\eta'} \cdot \sin \theta \]
If the ray is inside glass and outside is air (\( \eta = 1.5 \) and \( \eta' = 1.0 \)):
\[ \sin \theta' = \frac{1.5}{1.0} \cdot \sin \theta \]
The value of \( \sin \theta' \) cannot be greater than 1. So, if,
\[ \frac{1.5}{1.0} \cdot \sin \theta > 1.0 \]
the equality between the two sides of the equation is broken, and a solution cannot exist. If a solution does not exist, the glass cannot refract, and therefore must reflect the ray:
use crate::{hittable::HitRecord, prelude::*};
pub trait Material {
fn scatter(&self, _r_in: Ray, _rec: HitRecord) -> Option<(Ray, Color)> {
None
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Lambertian {
albedo: Color,
}
impl Lambertian {
pub fn new(albedo: Color) -> Self {
Self { albedo }
}
}
impl Material for Lambertian {
fn scatter(&self, _r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let mut scatter_direction = rec.normal + random_unit_vector();
// Catch degenerate scatter direction
if scatter_direction.near_zero() {
scatter_direction = rec.normal;
}
let scattered = Ray::new(rec.p, scatter_direction);
let attenuation = self.albedo;
Some((scattered, attenuation))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Metal {
albedo: Color,
fuzz: f64,
}
impl Metal {
pub fn new(albedo: Color, fuzz: f64) -> Self {
let fuzz = if fuzz < 1.0 { fuzz } else { 1.0 };
Self { albedo, fuzz }
}
}
impl Material for Metal {
fn scatter(&self, r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let mut reflected = reflect(r_in.direction(), rec.normal);
reflected = unit_vector(reflected) + (self.fuzz * random_unit_vector());
let scattered = Ray::new(rec.p, reflected);
let attenuation = self.albedo;
(dot(scattered.direction(), rec.normal) > 0.0).then(|| (scattered, attenuation))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Dielectric {
/// Refractive index in vacuum or air, or the ratio of the material's refractive index over
/// the refractive index of the enclosing media
refraction_index: f64,
}
impl Dielectric {
pub fn new(refraction_index: f64) -> Self {
Self { refraction_index }
}
}
impl Material for Dielectric {
fn scatter(&self, r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let attenuation = Color::new(1.0, 1.0, 1.0);
let ri = if rec.front_face {
1.0 / self.refraction_index
} else {
self.refraction_index
};
let unit_direction = unit_vector(r_in.direction());
let direction;
let cos_theta = f64::min(dot(-unit_direction, rec.normal), 1.0);
let sin_theta = f64::sqrt(1.0 - cos_theta * cos_theta);
if ri * sin_theta > 1.0 {
// Must Reflect
direction = reflect(unit_direction, rec.normal);
} else {
// Can Refract
direction = refract(unit_direction, rec.normal, ri);
}
let scattered = Ray::new(rec.p, direction);
Some((scattered, attenuation))
}
}
Listing 74: [material.rs] Determining if the ray can refract
Here all the light is reflected, and because in practice that is usually inside solid objects, it is called total internal reflection. This is why sometimes the water-to-air boundary acts as a perfect mirror when you are submerged — if you're under water looking up, you can see things above the water, but when you are close to the surface and looking sideways, the water surface looks like a mirror.
We can solve for sin_theta
using the trigonometric identities:
\[ \sin \theta = \sqrt{ 1 - \cos^2 \theta } \]
and
\[ \cos \theta = \mathbf{R} \cdot \mathbf{n} \]
use crate::{hittable::HitRecord, prelude::*};
pub trait Material {
fn scatter(&self, _r_in: Ray, _rec: HitRecord) -> Option<(Ray, Color)> {
None
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Lambertian {
albedo: Color,
}
impl Lambertian {
pub fn new(albedo: Color) -> Self {
Self { albedo }
}
}
impl Material for Lambertian {
fn scatter(&self, _r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let mut scatter_direction = rec.normal + random_unit_vector();
// Catch degenerate scatter direction
if scatter_direction.near_zero() {
scatter_direction = rec.normal;
}
let scattered = Ray::new(rec.p, scatter_direction);
let attenuation = self.albedo;
Some((scattered, attenuation))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Metal {
albedo: Color,
fuzz: f64,
}
impl Metal {
pub fn new(albedo: Color, fuzz: f64) -> Self {
let fuzz = if fuzz < 1.0 { fuzz } else { 1.0 };
Self { albedo, fuzz }
}
}
impl Material for Metal {
fn scatter(&self, r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let mut reflected = reflect(r_in.direction(), rec.normal);
reflected = unit_vector(reflected) + (self.fuzz * random_unit_vector());
let scattered = Ray::new(rec.p, reflected);
let attenuation = self.albedo;
(dot(scattered.direction(), rec.normal) > 0.0).then(|| (scattered, attenuation))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Dielectric {
/// Refractive index in vacuum or air, or the ratio of the material's refractive index over
/// the refractive index of the enclosing media
refraction_index: f64,
}
impl Dielectric {
pub fn new(refraction_index: f64) -> Self {
Self { refraction_index }
}
}
impl Material for Dielectric {
fn scatter(&self, r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let attenuation = Color::new(1.0, 1.0, 1.0);
let ri = if rec.front_face {
1.0 / self.refraction_index
} else {
self.refraction_index
};
let unit_direction = unit_vector(r_in.direction());
let direction;
let cos_theta = f64::min(dot(-unit_direction, rec.normal), 1.0);
let sin_theta = f64::sqrt(1.0 - cos_theta * cos_theta);
if ri * sin_theta > 1.0 {
// Must Reflect
direction = reflect(unit_direction, rec.normal);
} else {
// Can Refract
direction = refract(unit_direction, rec.normal, ri);
}
let scattered = Ray::new(rec.p, direction);
Some((scattered, attenuation))
}
}
Listing 75: [material.rs] Determining if the ray can refract
And the dielectric material that always refracts (when possible) is:
diff --git a/src/material.rs b/src/material.rs
index 090a4f6..951f550 100644
--- a/src/material.rs
+++ b/src/material.rs
@@ -1,89 +1,97 @@
use crate::{hittable::HitRecord, prelude::*};
pub trait Material {
fn scatter(&self, _r_in: Ray, _rec: HitRecord) -> Option<(Ray, Color)> {
None
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Lambertian {
albedo: Color,
}
impl Lambertian {
pub fn new(albedo: Color) -> Self {
Self { albedo }
}
}
impl Material for Lambertian {
fn scatter(&self, _r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let mut scatter_direction = rec.normal + random_unit_vector();
// Catch degenerate scatter direction
if scatter_direction.near_zero() {
scatter_direction = rec.normal;
}
let scattered = Ray::new(rec.p, scatter_direction);
let attenuation = self.albedo;
Some((scattered, attenuation))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Metal {
albedo: Color,
fuzz: f64,
}
impl Metal {
pub fn new(albedo: Color, fuzz: f64) -> Self {
let fuzz = if fuzz < 1.0 { fuzz } else { 1.0 };
Self { albedo, fuzz }
}
}
impl Material for Metal {
fn scatter(&self, r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let mut reflected = reflect(r_in.direction(), rec.normal);
reflected = unit_vector(reflected) + (self.fuzz * random_unit_vector());
let scattered = Ray::new(rec.p, reflected);
let attenuation = self.albedo;
(dot(scattered.direction(), rec.normal) > 0.0).then(|| (scattered, attenuation))
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Dielectric {
/// Refractive index in vacuum or air, or the ratio of the material's refractive index over
/// the refractive index of the enclosing media
refraction_index: f64,
}
impl Dielectric {
pub fn new(refraction_index: f64) -> Self {
Self { refraction_index }
}
}
impl Material for Dielectric {
fn scatter(&self, r_in: Ray, rec: HitRecord) -> Option<(Ray, Color)> {
let attenuation = Color::new(1.0, 1.0, 1.0);
let ri = if rec.front_face {
1.0 / self.refraction_index
} else {
self.refraction_index
};
let unit_direction = unit_vector(r_in.direction());
- let refracted = refract(unit_direction, rec.normal, ri);
+ let cos_theta = f64::min(dot(-unit_direction, rec.normal), 1.0);
+ let sin_theta = f64::sqrt(1.0 - cos_theta * cos_theta);
- let scattered = Ray::new(rec.p, refracted);
+ let cannot_refract = ri * sin_theta > 1.0;
+ let direction = if cannot_refract {
+ reflect(unit_direction, rec.normal)
+ } else {
+ refract(unit_direction, rec.normal, ri)
+ };
+
+ let scattered = Ray::new(rec.p, direction);
Some((scattered, attenuation))
}
}
Listing 76: [material.rs] Dielectric material class with reflection
Attenuation is always 1 — the glass surface absorbs nothing.
If we render the prior scene with the new dielectric::scatter()
function, we see … no change. Huh?
Well, it turns out that given a sphere of material with an index of refraction greater than air, there's no incident angle that will yield total internal reflection — neither at the ray-sphere entrance point nor at the ray exit. This is due to the geometry of spheres, as a grazing incoming ray will always be bent to a smaller angle, and then bent back to the original angle on exit.
So how can we illustrate total internal reflection? Well, if the sphere has an index of refraction less than the medium it's in, then we can hit it with shallow grazing angles, getting total external reflection. That should be good enough to observe the effect.
We'll model a world filled with water (index of refraction approximately 1.33), and change the sphere material to air (index of refraction 1.00) — an air bubble! To do this, change the left sphere material's index of refraction to
\[ \frac{\text{index of refraction of air}}{\text{index of refraction of water}} \]
diff --git a/src/main.rs b/src/main.rs
index 3894014..6e42461 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,46 +1,46 @@
use code::{
camera::Camera,
hittable_list::HittableList,
material::{Dielectric, Lambertian, Metal},
prelude::*,
sphere::Sphere,
};
fn main() -> std::io::Result<()> {
let mut world = HittableList::new();
let material_ground = Rc::new(Lambertian::new(Color::new(0.8, 0.8, 0.0)));
let material_center = Rc::new(Lambertian::new(Color::new(0.1, 0.2, 0.5)));
- let material_left = Rc::new(Dielectric::new(1.5));
+ let material_left = Rc::new(Dielectric::new(1.0 / 1.33));
let material_right = Rc::new(Metal::new(Color::new(0.8, 0.6, 0.2), 1.0));
world.add(Rc::new(Sphere::new(
Point3::new(0.0, -100.5, -1.0),
100.0,
material_ground,
)));
world.add(Rc::new(Sphere::new(
Point3::new(0.0, 0.0, -1.2),
0.5,
material_center,
)));
world.add(Rc::new(Sphere::new(
Point3::new(-1.0, 0.0, -1.0),
0.5,
material_left,
)));
world.add(Rc::new(Sphere::new(
Point3::new(1.0, 0.0, -1.0),
0.5,
material_right,
)));
env_logger::init();
Camera::default()
.with_aspect_ratio(16.0 / 9.0)
.with_image_width(400)
.with_samples_per_pixel(100)
.with_max_depth(50)
.render(&world)
}
Listing 77: [main.rs] Left sphere is an air bubble in water
This change yields the following render:

Image 17: Air bubble sometimes refracts, sometimes reflects
Here you can see that more-or-less direct rays refract, while glancing rays reflect.