Ray Tracing in One Weekend: Part 9 Volumes
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 be adding the ability to create objects with volumes. Up until this point, we just scatter rays when they hit object surfaces, volumes like fog and mist require us to add in sub surface scattering — meaning the ray may scatter randomly at any point within the object — or allow the ray to just go all the way through the object. The below image from the original book is a great visual of the process.
The way we will simulate this is to probabilistically hit the object as we travel through it. This means the ray could bounce at any point or go all the way through.
The following link is the commit in my Github repo that matches the code we will go over.
Isotropic Material
Our volumes will have an Isotropic material — in graphics — this means that the material will look the same no matter what direction we look at it (unlike materials like metal which might have some shine based on where you look at it from).
pub struct Isotropic {
albedo: Box<dyn Texture>
}
impl Isotropic {
pub fn new(albedo: Box<dyn Texture>) -> Self {
Isotropic {
albedo
}
}
pub fn from_color(albedo_color: Color) -> Self {
let albedo = SolidColor::new(albedo_color);
Isotropic {
albedo: Box::new(albedo)
}
}
}
impl Material for Isotropic {
fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<ScatterRecord> {
let scattered = Ray::new(rec.p, random_unit_vector(), r_in.time());
let attenuation = self.albedo.get_color(rec.u, rec.v, &rec.p);
Some(ScatterRecord {
attenuation,
scattered
})
}
}
Our Isotopic material has an albedo texture, and will scatter the ray in a random direction no matter what the in ray looks like.
Constant Medium Volume
Our Medium will have a density (which will impact how probable it is that a light ray will scatter) a phase_function for the material of the medium and a boundary to detect hits and form the shape of our volume.
pub struct ConstantMedium {
boundary: Arc<dyn Hittable>,
neg_inv_density: f64,
phase_function: Arc<dyn Material>
}
impl ConstantMedium {
pub fn new(boundary: Arc<dyn Hittable>, density: f64, phase_function: Arc<dyn Material>) -> Self {
ConstantMedium {
boundary,
neg_inv_density: -1.0 / density,
phase_function
}
}
pub fn from_color(boundary: Arc<dyn Hittable>, density: f64, albedo: Color) -> Self {
let phase_function = Arc::new(Isotropic::from_color(albedo));
ConstantMedium {
boundary,
neg_inv_density: -1.0 / density,
phase_function
}
}
}
To render the volume, we will first shoot a ray to find the volume, then we will shoot a ray within the volume and see how far we go though to reach the other side and get out of the volume.
We pick a random hit distance based on the density of the material — the more dense, the shorter the hit distance. If our randomly picked hit distance is greater than the length of the ray we shot from within the value, we act as if we did not even hit the volume and let our ray go to the other side of the volume.
If our hit distance is less, then we determine the t to record for how far along the ray went before it scattered within the medium, and return a hit record.
impl Hittable for ConstantMedium {
fn hit(&self, ray: &crate::ray::Ray, t_min: f64, t_max: f64) -> Option<crate::hittable::HitRecord> {
let hit_rec_one = self.boundary.hit(ray, -f64::INFINITY, f64::INFINITY);
if hit_rec_one.is_none() {
return None;
}
let mut hit_rec_one = hit_rec_one.unwrap();
let hit_rec_two = self.boundary.hit(ray, hit_rec_one.t + 0.0001, f64::INFINITY);
if hit_rec_two.is_none() {
return None;
}
let mut hit_rec_two = hit_rec_two.unwrap();
if hit_rec_one.t < t_min {
hit_rec_one.t = t_min;
}
if hit_rec_two.t > t_max {
hit_rec_two.t = t_max;
}
if hit_rec_one.t >= hit_rec_two.t {
return None;
}
if hit_rec_one.t < 0.0 {
hit_rec_one.t = 0.0;
}
let ray_length = ray.direction().length();
let distance_inside_boundary = (hit_rec_two.t - hit_rec_one.t) * ray_length;
let hit_distance = self.neg_inv_density * f64::ln(random_double());
if hit_distance > distance_inside_boundary {
return None;
}
let t = hit_rec_one.t + hit_distance / ray_length;
Some(HitRecord {
p: ray.at(t),
normal: Vec3::new(1.0, 0.0, 0.0), //arbitrary
mat: self.phase_function.clone(),
t: t,
u: 0.0,
v: 0.0,
front_face: true, //arbitrary
})
}
}
Rendering
Now, we can render some fog like objects.
NOTE: we now use Arc instead of Box for our hittable object pointers to make them easier to share (you could leave them as Box smart pointers if you wish).
fn cornell_smoke() {
let mut world = HittableList::new();
let red = Lambertian::from_color(Color::new(0.65, 0.05, 0.05));
let white = Arc::new(Lambertian::from_color(Color::new(0.73, 0.73, 0.73)));
let green = Lambertian::from_color(Color::new(0.12, 0.45, 0.15));
let light = DiffuseLight::from_color(Color::new(15.0, 15.0, 15.0));
world.add(Arc::new(Quad::new(Point3::new(555.0,0.0,0.0), Vec3::new(0.0, 555.0, 0.0), Vec3::new(0.0, 0.0, 555.0), Arc::new(green))));
world.add(Arc::new(Quad::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 555.0, 0.0), Vec3::new(0.0, 0.0, 555.0), Arc::new(red))));
world.add(Arc::new(Quad::new(Point3::new(113.0, 554.0, 127.0), Vec3::new(330.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 305.0), Arc::new(light))));
world.add(Arc::new(Quad::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(555.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 555.0), white.clone())));
world.add(Arc::new(Quad::new(Point3::new(555.0, 555.0, 555.0), Vec3::new(-555.0, 0.0, 0.0), Vec3::new(0.0, 0.0, -555.0), white.clone())));
world.add(Arc::new(Quad::new(Point3::new(0.0, 0.0, 555.0), Vec3::new(555.0, 0.0, 0.0), Vec3::new(0.0, 555.0, 0.0), white.clone())));
let box1 = Arc::new(Quad::get_box(Point3::new(0.0, 0.0, 0.0), Point3::new(165.0, 330.0, 165.0), white.clone()));
let box1 = Arc::new(RotateY::new(box1, 15.0));
let box1 = Arc::new(Translate::new(box1, Vec3::new(265.0, 0.0, 295.0)));
let box2 = Arc::new(Quad::get_box(Point3::new(0.0, 0.0, 0.0), Point3::new(165.0, 165.0, 165.0), white.clone()));
let box2 = Arc::new(RotateY::new(box2, -18.0));
let box2 = Arc::new(Translate::new(box2, Vec3::new(130.0, 0.0, 65.0)));
world.add(Arc::new(ConstantMedium::from_color(box1.clone(), 0.01, Color::new(0.0, 0.0, 0.0))));
world.add(Arc::new(ConstantMedium::from_color(box2.clone(), 0.01, Color::new(1.0, 1.0, 1.0))));
let eye = Point3::new(278.0, 278.0, -800.0);
let lookat = Point3::new(278.0, 278.0, 0.0);
let up = Point3::new(0.0, 1.0, 0.0);
let dist_to_focus = (eye - lookat).length();
let aperture = 0.0;
let camera = Camera::new(600, 600, 200, MAX_DEPTH, eye, lookat, up, 40.0, 1.0, aperture, dist_to_focus, Color::new(0.0, 0.0, 0.0));
camera.render(&world);
}
This will give us the following render
Conclusion
In this article we added the ability to create volumes with suburface scattering. This allows us to add some pretty cool things to our render like fog and smoke which are kind-of see through.