Ray Tracing in One Weekend: Part 3 Materials
Introduction
I have been reading through this book series on Raytracing which walks the reader through the creation of a Raytracer using C++. In this series of articles, I will be going through this book and implementing the lessons in Rust instead and diving deep into the pieces.
In this article, we will add materials to our spheres, materials will dictate how to color a pixel by bouncing — or scattering — the incoming ray to another surface, which gives this rendering technique its name.
The following link is the commit in my Github repo that matches the code we will go over.
Materials
Adding Materials to our spheres will allow us to give different properties and behaviors to the objects in our scene— we can make metallic spheres, mirror spheres, glass spheres, matte spheres, etc….
Vec3 Functions
To scatter our rays properly, we will need some helper vec3 functions.
pub fn random_in_unit_sphere() -> Vec3 {
loop {
let p = Vec3::random_range(-1.0, 1.0);
if p.length_squared() > 1.0 {
continue;
}
return p;
}
}
pub fn random_unit_vector() -> Vec3 {
unit_vector(random_in_unit_sphere())
}
pub fn reflect(v: Vec3, n: Vec3) -> Vec3 {
v - 2.0 * dot(v, n) * n
}
pub fn refract(uv: Vec3, n: Vec3, refraction_ratio: f64) -> Vec3 {
let cos_theta = f64::min(dot(-uv,n), 1.0);
let r_out_perp = refraction_ratio * (uv + cos_theta * n);
let r_out_parallel = -f64::sqrt(f64::abs(1.0 - r_out_perp.length_squared())) * n;
r_out_perp + r_out_parallel
}
We have a random_unit_vector function which will give our scattering some randomness. Reflection and Refraction are critical parts of how light travels through — and bounces off of — materials and so we have defined functions to compute resulting reflected and refracted rays.
Material Trait
pub trait Material {
fn scatter(&self, r_in: &Ray, rec: &HitRecord, attenuation: &mut Color, scattered: &mut Ray) -> bool;
}
A material will have to be able to scatter a ray, we will provide the caller with the scattered ray off of this material (scattered) and the color we have from this bounce (attenuation).
Lambertian
The first type of material we will create is Lambertian, which reflects incoming light uniformly, we use this material to give assets a matte look.
pub struct Lambertian {
albedo: Color
}
impl Lambertian {
pub fn new(albedo: Color) -> Lambertian {
Lambertian {
albedo
}
}
}
impl Material for Lambertian {
fn scatter(&self, _r_in: &Ray, rec: &HitRecord, attenuation: &mut Color, scattered: &mut Ray) -> bool {
let mut scatter_direction = rec.normal + vec3::random_unit_vector();
if scatter_direction.near_zero() {
scatter_direction = rec.normal;
}
*attenuation = self.albedo;
*scattered = Ray::new(rec.p, scatter_direction);
true
}
}
We have a base color for our Lambertian material and implement scatter so we can bounce incoming rays off of it.
Our scatter direction will be in the direction of our normal plus some random amount in a unit sphere so that our scatter ray points outward from our object. We can then then provide the color of our material and a scattered ray to our caller.
Metal
Next up, we have a metallic material.
impl Metal {
pub fn new(albedo: Color, f: f64) -> Metal {
Metal {
albedo,
fuzz: if f < 1.0 {f} else {1.0}
}
}
}
impl Material for Metal {
fn scatter(&self, r_in: &Ray, rec: &HitRecord, attenuation: &mut Color, scattered: &mut Ray) -> bool {
let reflected = vec3::reflect(vec3::unit_vector(r_in.direction()), rec.normal);
*attenuation = self.albedo;
*scattered = Ray::new(rec.p, reflected + self.fuzz * vec3::random_in_unit_sphere());
vec3::dot(scattered.direction(), rec.normal) > 0.0
}
}
We use the normal and our incoming ray to calculate the reflected ray, which we will use to generate our final scattered ray. We tell the caller that the material should be used to color the pixel if our direction of scatter points in the same direction of our normal — meaning that the light bounces back out into the world and not into the object.
Dielectric
Finally, we have Dielectric materials. These materials split a single ray into two rays — a reflected one and a refracted one. In practice, our raytracer only does one scattered ray, so we will have to pick one.
pub struct Dialetric {
ior: f64
}
impl Dialetric {
pub fn new(ior: f64) -> Dialetric {
Dialetric {
ior
}
}
fn reflectance(cosine: f64, ref_idx: f64) -> f64 {
// Schlcik's approximation for reflectance
let mut r0 = (1.0 - ref_idx) / (1.0 + ref_idx);
r0 = r0 * r0;
r0 + (1.0 - r0) * f64::powf(1.0 - cosine, 5.0)
}
}
impl Material for Dialetric {
fn scatter(&self, r_in: &Ray, rec: &HitRecord, attenuation: &mut Color, scattered: &mut Ray) -> bool {
let refraction_ratio = if rec.front_face {1.0 / self.ior} else {self.ior};
let unit_direction = vec3::unit_vector(r_in.direction());
let cos_theta = f64::min(vec3::dot(-unit_direction, rec.normal), 1.0);
let sin_theta = f64::sqrt(1.0 - cos_theta * cos_theta);
let cannot_refract = refraction_ratio * sin_theta > 1.0;
let direction = if cannot_refract || Self::reflectance(cos_theta, refraction_ratio) > random_double() {
vec3::reflect(unit_direction, rec.normal)
} else {
vec3::refract(unit_direction, rec.normal, refraction_ratio)
};
*attenuation = Color::new(1.0, 1.0, 1.0);
*scattered = Ray::new(rec.p, direction);
true
}
}
When generating our scattered ray, we check if the ray can even refract at all, if not we will return a reflected ray. If the ray can refract, we compare the reflectance with a random double to randomly pick between the reflecting ray or the refracting ray. Dielectric materials are usually transparent and therefore we do not add any color attenuation.
Using Materials
We have done a lot of work setting up our materials, now lets actually attach them to our spheres and use them in our render.
Updating HitRecord
First of all, our HitRecord needs to be updated to capture a hit object’s material.
#[derive(Clone, Default)]
pub struct HitRecord {
pub p: Point3,
pub normal: Vec3,
pub mat: Option<Rc<dyn Material>>,
pub t: f64,
pub front_face: bool
}
Simple enough.
Sphere Updates
Our Sphere Objects will also have a material property added to them.
pub struct Sphere {
center: Point3,
radius: f64,
mat: Rc<dyn Material>
}
impl Sphere {
pub fn new(center: Point3, mat: Rc<dyn Material>, radius: f64) -> Sphere {
Sphere {
center,
mat,
radius
}
}
}
And then — in our hittable implementation for sphere — we simply have to provide the material to the hit record.
impl Hittable for Sphere {
fn hit(&self, ray: &crate::ray::Ray, t_min: f64, t_max: f64, rec: &mut crate::hittable::HitRecord) -> bool {
let oc = ray.origin() - self.center;
let a = ray.direction().length_squared();
let half_b = vec3::dot(oc, ray.direction());
let c = oc.length_squared() - self.radius * self.radius;
let discriminant = half_b * half_b - a * c;
if discriminant < 0.0 {
return false;
}
let sqrt_disc = f64::sqrt(discriminant);
// find nearest root
let mut root = (-half_b - sqrt_disc) / a;
if root <= t_min || root >= t_max {
root = (-half_b + sqrt_disc) / a;
if root <= t_min || root >= t_max {
return false;
}
}
rec.t = root;
rec.p = ray.at(root);
let outward_norm = (rec.p - self.center) / self.radius;
rec.set_face_normal(ray, outward_norm);
rec.mat = Some(self.mat.clone());
true
}
}
Rendering
Now that we can get a material every time we hit something, we can go ahead and update our renderer to be a true raytracer.
fn main() {
const ASPECT_RATIO: f64 = 16.0 / 9.0;
const IMAGE_WIDTH: i32 = 400;
const IMAGE_HEIGHT: i32 = (IMAGE_WIDTH as f64 / ASPECT_RATIO) as i32;
const SAMPLES_PER_PIXEL: i32 = 100;
const MAX_DEPTH: i32 = 50;
let mut world = HittableList::new();
let material_ground = Rc::new(Lambertian::new(Color::new(0.8, 0.8, 0.0)));
let material_center_sphere = Rc::new(Lambertian::new(Color::new(0.1, 0.2, 0.5)));
let material_left_sphere = Rc::new(Dialetric::new(1.5));
let material_right_sphere = Rc::new(Metal::new(Color::new(0.8, 0.6, 0.2), 0.0));
world.add(Box::new(Sphere::new(Point3::new(0.0, -100.5, -1.0), material_ground, 100.0)));
world.add(Box::new(Sphere::new(Point3::new(0.0, 0.0, -1.0), material_center_sphere, 0.5)));
world.add(Box::new(Sphere::new(Point3::new(-1.0, 0.0, -1.0), material_left_sphere, -0.5)));
world.add(Box::new(Sphere::new(Point3::new(1.0, 0.0, -1.0), material_right_sphere, 0.5)));
// Camera
let camera = Camera::new();
// Render
print!("P3\n{} {}\n255\n", IMAGE_WIDTH, IMAGE_HEIGHT);
for j in (0..IMAGE_HEIGHT).rev() {
eprint!("\rScanlines remaining: {}", j);
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);
}
color::write_color(&mut io::stdout(), pixel_color, SAMPLES_PER_PIXEL);
}
}
eprint!("\nDone.\n");
}
We have added a MAX_DEPTH value which tells our renderer the max amount of times a ray can scatter. We will pass the MAX_DEPTH into our ray_color function.
ray_color
fn ray_color(ray: &Ray, world: &dyn Hittable, depth: i32) -> Color {
if depth <= 0 {
return Color::new(0.0, 0.0, 0.0);
}
let mut rec = HitRecord::new();
if world.hit(ray, 0.001, common::INFINITY, &mut rec) {
let mut attenuation = Color::default();
let mut scattered = Ray::default();
if rec.mat.as_ref().unwrap().scatter(ray, &rec, &mut attenuation, &mut scattered) {
return attenuation * ray_color(&scattered, world, depth - 1);
}
return Color::new(0.0, 0.0, 0.0);
}
let unit_direction = vec3::unit_vector(ray.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)
}
Ray Color now recursively calls itself with depth-1 each scatter accumulating color from the materials it hits along its journey. If our rays bounce against objects too much and reach max depth, we will color the pixel black to indicate shadowing. In most cases however, the ray will eventually scatter to the ambient environment.
The result of all our hard work will look something like this.
Conclusion
In this article, we have added three types of materials to our spheres to model different types of materials in the real world. We also have created our first real ray tracer, bouncing the rays we shoot from our camera out into more scattering rays.