4. Rays, a Camera, and Background

Rays, a Camera, and Background

The Ray Struct

The one thing that all ray tracers have is a ray struct and a computation of what color is seen along a ray. Let’s think of a ray as a function P(t)=A+tb\mathbf{P}(t) = \mathbf{A} + t \mathbf{b}. Here P\mathbf{P} is a 3D position along a line in 3D. A\mathbf{A} is the ray origin and b\mathbf{b} is the ray direction. The ray parameter tt is a real number (f64 in the code). Plug in a different tt and P(t)\mathbf{P}(t) moves the point along the ray. Add in negative tt values and you can go anywhere on the 3D line. For positive tt, you get only the parts in front of A\mathbf{A}, and this is what is often called a half-line or ray.

Linear interpolation
Linear interpolation

The function P(t)\mathbf{P}(t) in more verbose code form we call ray::at(t):

ray.rs | The Ray struct
use crate::vec3::{Point3, Vec3};
 
#[derive(Default)]
pub struct Ray {
    orig: Point3,
    dir: Vec3,
}
 
impl Ray {
    pub fn new(origin: Point3, direction: Vec3) -> Ray {
        Ray {
            orig: origin,
            dir: direction,
        }
    }
 
    pub fn origin(&self) -> Point3 {
        self.orig
    }
 
    pub fn direction(&self) -> Vec3 {
        self.dir
    }
 
    pub fn at(&self, t: f64) -> Point3 {
        self.orig + t * self.dir
    }
}

Sending Rays Into the Scene

Now we are ready to turn the corner and make a ray tracer. At the core, the ray tracer sends rays through pixels and computes the color seen in the direction of those rays. The involved steps are (1) calculate the ray from the eye to the pixel, (2) determine which objects the ray intersects, and (3) compute a color for that intersection point.

When first developing a ray tracer, we can do a simple camera for getting the code up and running. We also can make a simple ray_color() function that returns the color of the background (e.g., a simple gradient).

It’s common to get into trouble using square images for debugging because transposing xx and yy is a common operation, so we’ll use a non-square image. For now we’ll use a 16:9 aspect ratio, since that’s so common.

In addition to setting up the pixel dimensions for the rendered image, we also need to set up a virtual viewport through which to pass our scene rays. For the standard square pixel spacing, the viewport’s aspect ratio should be the same as our rendered image. We’ll just pick a viewport two units in height. We’ll also set the distance between the projection plane and the projection point to be one unit. This is referred to as the “focal length”, not to be confused with “focus distance”, which we’ll present later.

We’ll put the “eye” (or camera center if you think of a camera) at (0,0,0)(0,0,0). We will have the y-axis go up, and the x-axis to the right. In order to respect the convention of a right handed coordinate system, into the screen is the negative z-axis. We will traverse the screen from the upper left hand corner, and use two offset vectors along the screen sides to move the ray endpoint across the screen. Note that we do not make the ray direction a unit length vector because not doing that makes for simpler and slightly faster code.

Camera geometry
Camera geometry

Below in code, the ray r goes to approximately the pixel centers (We won’t worry about exactness for now because we’ll add antialiasing later):

main.rs | Rendering a blue-to-white gradient
mod color;
mod ray;
mod vec3;
 
use std::io;
 
use color::Color;
use ray::Ray;
use vec3::{Point3, Vec3};
 
fn ray_color(r: &Ray) -> Color {
    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;
 
    // Camera
 
    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);
 
    // 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 u = i as f64 / (IMAGE_WIDTH - 1) as f64;
            let v = j as f64 / (IMAGE_HEIGHT - 1) as f64;
            let r = Ray::new(
                origin,
                lower_left_corner + u * horizontal + v * vertical - origin,
            );
            let pixel_color = ray_color(&r);
            color::write_color(&mut io::stdout(), pixel_color);
        }
    }
 
    eprint!("\nDone.\n");
}

The ray_color() function linearly blends white and blue depending on the height of the yy coordinate after scaling the ray direction to unit length (so 1.0<y<1.0-1.0 < y < 1.0). Because we’re looking at the yy height after normalizing the vector, you’ll notice a horizontal gradient to the color in addition to the vertical gradient.

We then did a standard graphics trick of scaling that to 0.0t1.00.0 ≤ t ≤ 1.0. When t=1.0t = 1.0 we want blue. When t=0.0t = 0.0 we want white. In between, we want a blend. This forms a “linear blend”, or “linear interpolation”, or “lerp” for short, between two things. A lerp is always of the form:

blendedValue=(1t)startValue+tendValue,\text{blendedValue} = (1-t)\cdot\text{startValue} + t\cdot\text{endValue},

with tt going from zero to one. In our case this produces:

A blue-to-white gradient depending on ray Y coordinate
A blue-to-white gradient depending on ray Y coordinate