Skip to content
Copied!
published on 2026-09-01

2. Motion Blur ​

Ray Tracing: The Next Week (v3.2.3): 2 Motion Blur / 2.2 Motion Blur

I introduce a time dimension into the final scene from Book 1 and generate motion blur by making the diffuse spheres bounce. The original v3.2.3 implements MovingSphere as a separate class from Sphere and adds the concept of a shutter interval to the camera. This chapter follows the same approach.

Four new elements are required:

  • Add a time value tm to Ray
  • Add a shutter interval [time0, time1] to Camera and emit rays carrying random times sampled from that interval
  • Add MovingSphere, a sphere whose center moves linearly from time0 to time1
  • Propagate the incident ray's time to the scattered ray in Material::scatter

All of these changes are additive so that the r1XX crates written for Book 1 continue to build unchanged.

Introduction to Spatiotemporal Ray Tracing ​

The shutter of a physical camera remains open for a finite interval. If a subject moves while the shutter is open, the same image pixel records the subject at several different positions. The resulting blur is motion blur.

This effect can be reproduced with ray tracing as follows:

  1. Give each ray a time t
  2. When the camera samples many rays per pixel, assign each ray a random time within the shutter interval
  3. Make each moving object in the scene, represented here by MovingSphere, return its position at the given time
  4. Because the many rays that arrive at the same pixel carry slightly different times, the moving object appears probabilistically blurred

This can be regarded as four-dimensional ray tracing with three spatial dimensions and one time dimension.

Adding Time to Rays ​

Add tm: f64 to Ray.

common/src/ray.rs
rust
pub struct Ray {
    pub orig: Point3,
    pub dir: Vec3,
    pub tm: f64,
}

impl Ray {
    /// Creates a new ray from `origin` and `direction`. The ray's time is `0.0`.
    pub fn new(origin: Point3, direction: Vec3) -> Self {
        Ray { orig: origin, dir: direction, tm: 0.0 }
    }

    /// Creates a new ray from `origin`, `direction`, and the given `time`.
    pub fn with_time(origin: Point3, direction: Vec3, time: f64) -> Self {
        Ray { orig: origin, dir: direction, tm: time }
    }

    pub fn time(&self) -> f64 { self.tm }
    // ...
}

The signature of Ray::new remains unchanged from Book 1. Calls with only two arguments create rays at time 0.0. Code that needs a ray with an explicit time, such as the camera and material scattering implementations, uses Ray::with_time.

Differences Between C++ and Rust ​

The ray class in the original C++ uses one constructor whose time parameter has the default value = 0.0.

cpp
class ray {
  public:
    ray() {}
    ray(const point3& origin, const vec3& direction, double time = 0.0)
      : orig(origin), dir(direction), tm(time)
    {}
    double time() const { return tm; }
    // ...
  public:
    point3 orig;
    vec3 dir;
    double tm;
};

A default argument in C++ allows a call that omits an argument to use the same signature. Rust supports neither default arguments nor function overloading, so the conventional pattern is to name the primary constructor new and provide separately named functions, such as with_time, for variants. The standard library follows the same pattern with pairs such as Vec::new and Vec::with_capacity, or String::new and String::with_capacity.

A Camera That Simulates Motion Blur ​

The camera stores the shutter interval [time0, time1] and assigns each ray a random time within that interval.

common/src/camera.rs
rust
pub struct Camera {
    origin: Point3,
    lower_left_corner: Point3,
    horizontal: Vec3,
    vertical: Vec3,
    u: Vec3,
    v: Vec3,
    lens_radius: f64,
    time0: f64,
    time1: f64,
}

impl Camera {
    /// Book 1-compatible constructor. The shutter interval is fixed at `[0, 0]`.
    pub fn new(/* lookfrom, lookat, vup, vfov, aspect_ratio, aperture, focus_dist */) -> Self {
        Self::new_with_shutter(/* ..., */ 0.0, 0.0)
    }

    /// Creates a camera with the shutter interval `[time0, time1]`.
    pub fn new_with_shutter(
        lookfrom: Point3, lookat: Point3, vup: Vec3,
        vfov: f64, aspect_ratio: f64,
        aperture: f64, focus_dist: f64,
        time0: f64, time1: f64,
    ) -> Self { /* ... */ }

    pub fn get_ray(&self, s: f64, t: f64) -> Ray {
        let rd = self.lens_radius * random_in_unit_disk();
        let offset = self.u * rd.x() + self.v * rd.y();
        let time = if self.time0 == self.time1 {
            self.time0
        } else {
            random_double_range(self.time0, self.time1)
        };
        Ray::with_time(
            self.origin + offset,
            self.lower_left_corner + s * self.horizontal + t * self.vertical
                - self.origin - offset,
            time,
        )
    }
}

Camera::new delegates to new_with_shutter(..., 0.0, 0.0), so every Book 1 demo builds without any code changes. When time0 == time1, the function does not generate a random number and instead uses time0 directly. This prevents Book 1 from consuming additional values from its random sequence and preserves reproducibility across the r1XX crates.

Moving Spheres ​

I now introduce a sphere whose center is at center0 at time0, at center1 at time1, and linearly interpolated between those times.

common/src/moving_sphere.rs
rust
pub struct MovingSphere {
    pub center0: Point3,
    pub center1: Point3,
    pub time0: f64,
    pub time1: f64,
    pub radius: f64,
    pub material: Arc<dyn Material>,
}

impl MovingSphere {
    /// Returns the sphere center at the given `time`, by linear interpolation.
    pub fn center(&self, time: f64) -> Point3 {
        self.center0
            + ((time - self.time0) / (self.time1 - self.time0))
                * (self.center1 - self.center0)
    }
}

The body of Hittable::hit is nearly identical to the implementation for Sphere. The key difference is that it uses self.center(r.time()), the sphere's center at the ray's time.

common/src/moving_sphere.rs
rust
impl Hittable for MovingSphere {
    fn hit(&self, r: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
        let center_now = self.center(r.time());
        let oc = r.origin() - center_now;
        let a = r.direction().length_squared();
        let half_b = dot(oc, r.direction());
        let c = oc.length_squared() - self.radius * self.radius;
        let discriminant = half_b * half_b - a * c;

        if discriminant < 0.0 { return None; }

        let sqrtd = discriminant.sqrt();
        let mut root = (-half_b - sqrtd) / a;
        if root < t_min || root > t_max {
            root = (-half_b + sqrtd) / a;
            if root < t_min || root > t_max { return None; }
        }

        let p = r.at(root);
        let outward_normal = (p - center_now) / self.radius;
        let mut rec = HitRecord {
            t: root, p, normal: outward_normal,
            front_face: false,
            mat: Some(self.material.clone()),
        };
        rec.set_face_normal(r, outward_normal);
        Some(rec)
    }
}

Differences Between C++ and Rust ​

The original C++ duplicates much of the code from sphere. However, the two types require different bounding_box logic for the axis-aligned bounding box calculations in Chapter 4, so separating them into distinct classes at this point is reasonable.

Rust could instead combine them into a single type such as enum SphereKind { Static(Point3), Moving { ... } }, but every call to hit would then incur the cost of a match. I follow the original and use a separate structure.

Propagating Ray Time Through Scattering ​

A newly scattered ray should carry the same time as its incident ray. Time represents the instant at which the shutter samples a pixel, so scattering does not advance it. I update the scatter implementations for Lambertian, Metal, and Dielectric together.

common/src/material.rs
rust
impl Material for Lambertian {
    fn scatter(&self, r_in: &Ray, rec: &HitRecord) -> Option<(Color, Ray)> {
        let mut scatter_direction = rec.normal + random_unit_vector();
        if scatter_direction.near_zero() {
            scatter_direction = rec.normal;
        }
        // Propagate the incident ray's time so motion-blur scenes stay coherent.
        let scattered = Ray::with_time(rec.p, scatter_direction, r_in.time());
        Some((self.albedo, scattered))
    }
}

The Metal::scatter and Dielectric::scatter implementations are changed in the same way: each scattered ray is built with Ray::with_time(.., r_in.time()) instead of Ray::new. See common/src/material.rs for the complete implementations.

Every ray in a Book 1 scene has time 0.0, so propagating r_in.time() still leaves all of those rays at 0.0. Their behavior remains unchanged.

Assembling the Final Scene ​

In the r201-motion-blur crate, I modify the random_scene from Book 1 by replacing only its diffuse spheres with MovingSphere. The metal and glass spheres remain stationary.

r201-motion-blur/src/lib.rs
rust
if choose_mat < 0.8 {
    // Diffuse: bouncing sphere.
    let albedo = Color::new(
        random_double() * random_double(),
        random_double() * random_double(),
        random_double() * random_double(),
    );
    let center2 = center
        + Point3::new(0.0, random_double_range(0.0, 0.5), 0.0);
    world.add(Box::new(MovingSphere::new(
        center, center2, 0.0, 1.0, 0.2,
        Arc::new(Lambertian::new(albedo)),
    )));
} else if choose_mat < 0.95 {
    // Metal: stationary.
    /* Sphere::with_material(...) */
} else {
    // Glass: stationary.
    /* Sphere::with_material(...) */
}

center2 is a position raised from center by a random distance in [0,0.5) along the y direction. The sphere moves linearly between time0 = 0.0 and time1 = 1.0. It therefore appears at a probabilistically selected height from bottom to top in each sampled shutter instant, producing the blur.

Construct the camera with the shutter interval [0.0, 1.0].

r201-motion-blur/src/lib.rs
rust
let camera = Camera::new_with_shutter(
    lookfrom, lookat, vup,
    20.0, aspect_ratio,
    aperture, dist_to_focus,
    0.0, 1.0,
);

The main loop is exactly the same as in Chapter 13 of Book 1. The only differences are the use of Camera::new_with_shutter and the replacement of the diffuse spheres with MovingSphere.

r201-motion-blur/src/lib.rs
rust
use std::sync::Arc;
use common::{
    Camera, Color, Dielectric, Hittable, HittableList, Lambertian, Metal, MovingSphere, Point3,
    Ray, Sphere, random_double, random_double_range, unit_vector, write_color_gamma,
};

fn ray_color(r: &Ray, world: &dyn Hittable, depth: i32) -> Color {
    if depth <= 0 {
        return Color::new(0.0, 0.0, 0.0);
    }
    if let Some(rec) = world.hit(r, 0.001, f64::INFINITY) {
        if let Some(mat) = &rec.mat {
            if let Some((attenuation, scattered)) = mat.scatter(r, &rec) {
                return attenuation * ray_color(&scattered, world, depth - 1);
            }
        }
        return Color::new(0.0, 0.0, 0.0);
    }
    let unit_direction = 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 random_scene() -> HittableList {
    let mut world = HittableList::new();

    world.add(Box::new(Sphere::with_material(
        Point3::new(0.0, -1000.0, 0.0),
        1000.0,
        Arc::new(Lambertian::new(Color::new(0.5, 0.5, 0.5))),
    )));

    for a in -11..11 {
        for b in -5..5 {
            let choose_mat = random_double();
            let center = Point3::new(
                a as f64 + 0.9 * random_double(),
                0.2,
                b as f64 + 0.9 * random_double(),
            );

            let avoid_1 = Point3::new(4.0, 0.2, 0.0);
            let avoid_2 = Point3::new(0.0, 1.0, 0.0);
            let avoid_3 = Point3::new(-4.0, 0.2, 0.0);

            if (center - avoid_1).length() > 0.9
                && (center - avoid_2).length() > 0.9
                && (center - avoid_3).length() > 0.9
            {
                if choose_mat < 0.8 {
                    let albedo = Color::new(
                        random_double() * random_double(),
                        random_double() * random_double(),
                        random_double() * random_double(),
                    );
                    let center2 = center
                        + Point3::new(0.0, random_double_range(0.0, 0.5), 0.0);
                    world.add(Box::new(MovingSphere::new(
                        center, center2, 0.0, 1.0, 0.2,
                        Arc::new(Lambertian::new(albedo)),
                    )));
                } else if choose_mat < 0.95 {
                    let albedo = Color::new(
                        random_double_range(0.5, 1.0),
                        random_double_range(0.5, 1.0),
                        random_double_range(0.5, 1.0),
                    );
                    let fuzz = random_double_range(0.0, 0.5);
                    world.add(Box::new(Sphere::with_material(
                        center, 0.2, Arc::new(Metal::new(albedo, fuzz)),
                    )));
                } else {
                    world.add(Box::new(Sphere::with_material(
                        center, 0.2, Arc::new(Dielectric::new(1.5)),
                    )));
                }
            }
        }
    }

    world.add(Box::new(Sphere::with_material(
        Point3::new(-4.0, 1.0, 0.0), 1.0,
        Arc::new(Lambertian::new(Color::new(0.4, 0.2, 0.1))),
    )));
    world.add(Box::new(Sphere::with_material(
        Point3::new(0.0, 1.0, 0.0), 1.0,
        Arc::new(Dielectric::new(1.5)),
    )));
    world.add(Box::new(Sphere::with_material(
        Point3::new(4.0, 1.0, 0.0), 1.0,
        Arc::new(Metal::new(Color::new(0.7, 0.6, 0.5), 0.0)),
    )));

    world
}

pub fn render_image() -> String {
    let aspect_ratio = 16.0_f64 / 9.0;
    let image_width = 300_i32;
    let image_height = (image_width as f64 / aspect_ratio) as i32;
    let samples_per_pixel = 30_i32;
    let max_depth = 50_i32;

    let world = random_scene();

    let lookfrom = Point3::new(13.0, 2.0, 3.0);
    let lookat = Point3::new(0.0, 0.0, 0.0);
    let vup = Point3::new(0.0, 1.0, 0.0);
    let dist_to_focus = 10.0_f64;
    let aperture = 0.1_f64;

    let camera = Camera::new_with_shutter(
        lookfrom, lookat, vup,
        20.0, aspect_ratio,
        aperture, dist_to_focus,
        0.0, 1.0,
    );

    let mut output = String::new();
    output.push_str("P3\n");
    output.push_str(&format!("{} {}\n", image_width, image_height));
    output.push_str("255\n");

    for j in (0..image_height).rev() {
        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);
            }
            output.push_str(&format!(
                "{}\n",
                write_color_gamma(pixel_color, samples_per_pixel)
            ));
        }
    }

    output
}

Summary ​

In the WASM demo at the top of the page, the diffuse spheres that bounce upward from below appear blurred, while the stationary metal and glass spheres remain sharp. Thirty samples per pixel is just enough to make the motion blur clearly visible. Random variation still produces noticeable pixel-level noise, but the probability distribution of the bouncing motion is represented sufficiently well.

The following chapters implement AABBs and a BVH to accelerate hit testing across large numbers of objects.