7. Antialiasing

Antialiasing

When a real camera takes a picture, there are usually no jaggies along edges because the edge pixels are a blend of some foreground and some background. We can get the same effect by averaging a bunch of samples inside each pixel. We abstract the camera class a bit so we can make a cooler camera later.

Some Random Number Utilities

One thing we need is a random number generator that returns real random numbers. We need a function that returns a canonical random number which by convention returns a random real in the range 0≤r<10 ≤ r < 1. The “less than” before the 1 is important as we will sometimes take advantage of that.

🦀

Rust does not have a built-in random number generator in the standard library. However, we can add an external library using Cargo. We can run below in the terminal:

cargo add rand

Cargo will install the rand crate and update the dependency in Cargo.toml.

We add the following code snippet:

common.rs | random_double() functions
use rand::Rng;
 
// Constants
 
pub use std::f64::consts::PI;
pub use std::f64::INFINITY;
 
// Utility functions
 
pub fn degrees_to_radians(degrees: f64) -> f64 {
    degrees * PI / 180.0
}
 
pub fn random_double() -> f64 {
    // Return a random real in [0.0, 1.0)
    rand::thread_rng().gen()
}
 
pub fn random_double_range(min: f64, max: f64) -> f64 {
    // Return a random real in [min, max)
    min + (max - min) * random_double()
}
🦀

Note that the gen() (opens in a new tab) function has a generic type in the return position. This means that the caller gets to choose which type gen() will return. In this case, the compiler infers that we want f64 from the definition of the enclosing random_double() function.

Generating Pixels with Multiple Samples

For a given pixel we have several samples within that pixel and send rays through each of the samples. The colors of these rays are then averaged:

Pixel samples
Pixel samples

Now’s a good time to create a Camera struct to manage our virtual camera and the related tasks of scene sampling. The following struct implements a simple camera using the axis-aligned camera from before:

camera.rs | The Camera struct
use crate::ray::Ray;
use crate::vec3::{Point3, Vec3};
 
pub struct Camera {
    origin: Point3,
    lower_left_corner: Point3,
    horizontal: Vec3,
    vertical: Vec3,
}
 
impl Camera {
    pub fn new() -> Camera {
        let aspect_ratio = 16.0 / 9.0;
        let viewport_height = 2.0;
        let viewport_width = aspect_ratio * viewport_height;
        let focal_length = 1.0;
 
        let origin = Point3::new(0.0, 0.0, 0.0);
        let horizontal = Vec3::new(viewport_width, 0.0, 0.0);
        let vertical = Vec3::new(0.0, viewport_height, 0.0);
        let lower_left_corner =
            origin - horizontal / 2.0 - vertical / 2.0 - Vec3::new(0.0, 0.0, focal_length);
 
        Camera {
            origin,
            lower_left_corner,
            horizontal,
            vertical,
        }
    }
 
    pub fn get_ray(&self, u: f64, v: f64) -> Ray {
        Ray::new(
            self.origin,
            self.lower_left_corner + u * self.horizontal + v * self.vertical - self.origin,
        )
    }
}

To handle the multi-sampled color computation, we’ll update the write_color() function. Rather than adding in a fractional contribution each time we accumulate more light to the color, just add the full color each iteration, and then perform a single divide at the end (by the number of samples) when writing out the color. In addition, we’ll add a handy utility function to the common module: clamp(x, min, max), which clamps the value x to the range [min, max]:

common.rs | The clamp() utility function
use rand::Rng;
 
// Constants
 
pub use std::f64::consts::PI;
pub use std::f64::INFINITY;
 
// Utility functions
 
pub fn degrees_to_radians(degrees: f64) -> f64 {
    degrees * PI / 180.0
}
 
pub fn random_double() -> f64 {
    // Return a random real in [0.0, 1.0)
    rand::thread_rng().gen()
}
 
pub fn random_double_range(min: f64, max: f64) -> f64 {
    // Return a random real in [min, max)
    min + (max - min) * random_double()
}
 
pub fn clamp(x: f64, min: f64, max: f64) -> f64 {
    if x < min {
        return min;
    }
    if x > max {
        return max;
    }
    x
}
color.rs | The multi-sample write_color() function
use std::io::Write;
 
use crate::common;
use crate::vec3::Vec3;
 
// Type alias
pub type Color = Vec3;
 
pub fn write_color(out: &mut impl Write, pixel_color: Color, samples_per_pixel: i32) {
    let mut r = pixel_color.x();
    let mut g = pixel_color.y();
    let mut b = pixel_color.z();
 
    // Divide the color by the number of samples
    let scale = 1.0 / samples_per_pixel as f64;
    r *= scale;
    g *= scale;
    b *= scale;
 
    // Write the translated [0, 255] value of each color component
    writeln!(
        out,
        "{} {} {}",
        (256.0 * common::clamp(r, 0.0, 0.999)) as i32,
        (256.0 * common::clamp(g, 0.0, 0.999)) as i32,
        (256.0 * common::clamp(b, 0.0, 0.999)) as i32,
    )
    .expect("writing color");
}

Main is also changed:

main.rs | Rendering with multi-sampled pixels
mod camera;
mod color;
mod common;
mod hittable;
mod hittable_list;
mod ray;
mod sphere;
mod vec3;
 
use std::io;
 
use camera::Camera;
use color::Color;
use hittable::{HitRecord, Hittable};
use hittable_list::HittableList;
use ray::Ray;
use sphere::Sphere;
use vec3::Point3;
 
fn ray_color(r: &Ray, world: &dyn Hittable) -> Color {
    let mut rec = HitRecord::new();
    if world.hit(r, 0.0, common::INFINITY, &mut rec) {
        return 0.5 * (rec.normal + Color::new(1.0, 1.0, 1.0));
    }
 
    let unit_direction = vec3::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)
}
 
fn main() {
    // Image
 
    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;
 
    // World
 
    let mut world = HittableList::new();
    world.add(Box::new(Sphere::new(Point3::new(0.0, 0.0, -1.0), 0.5)));
    world.add(Box::new(Sphere::new(Point3::new(0.0, -100.5, -1.0), 100.0)));
 
    // Camera
 
    let cam = 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 + common::random_double()) / (IMAGE_WIDTH - 1) as f64;
                let v = (j as f64 + common::random_double()) / (IMAGE_HEIGHT - 1) as f64;
                let r = cam.get_ray(u, v);
                pixel_color += ray_color(&r, &world);
            }
            color::write_color(&mut io::stdout(), pixel_color, SAMPLES_PER_PIXEL);
        }
    }
 
    eprint!("\nDone.\n");
}

Zooming into the image that is produced, we can see the difference in edge pixels:

Before and after antialiasing
Before and after antialiasing
🦀

By default, Cargo builds and runs the program in debug mode, which may be slow. You can try using cargo run --release to build and run in release mode. It will be much faster.