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

2. モーションブラー

Ray Tracing: The Next Week (v3.2.3): 2 Motion Blur / 2.2 モーションブラー

第 1 編の最終シーンに時間軸を導入し,拡散球を跳ねさせてモーションブラーを生成します。原典 v3.2.3 では MovingSphere クラスを Sphere とは別に新設し,カメラにシャッター時間の概念を加える方針で実装しています。本稿もこれに従います。

新規の道具立ては次の 4 つです。

  • Ray に時刻 tm を持たせる
  • Camera にシャッター区間 [time0, time1] を持たせ,区間内の乱数時刻を載せたレイを発射する
  • MovingSphere:中心が time0 から time1 の間で線形に動く球
  • Material::scatter入射レイの時刻を散乱レイへ伝播させる

第 1 編で書いた r1XX クレートがそのままビルドできるように,これらはすべて加算的拡張として実装します。

時空間レイトレーシング入門

実時間カメラのシャッターは一定時間開きっぱなしです。シャッターが開いている間に被写体が動けば,フィルム上の 1 ピクセルには複数の異なる位置に居る被写体が同時に写り込み,これがモーションブラーになります。

レイトレーシングでこれを再現する筋立てはこうです。

  1. レイが時刻 t という属性を持つ
  2. カメラはピクセルあたり多数のレイをサンプリングする際,各レイにシャッター区間内のランダムな時刻を割り当てる
  3. シーン中の動く物体(MovingSphere)は与えられた時刻を見て当該時刻の自身の位置を返す
  4. 同一ピクセルに着弾する多数のレイが少しずつ異なる時刻を持つので,動く物体は確率的にぼけて写る

「空間 3 次元 + 時間 1 次元の 4 次元レイトレーシング」と捉えられます。

レイに時刻を持たせる

Raytm: f64 を加えます。

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 }
    // ...
}

Ray::new のシグネチャは第 1 編から変えていません。引数を 2 つしか取らない呼び出しは時刻 0.0 のレイを作ります。時刻つきレイが必要な場面(カメラ・マテリアルの散乱)は Ray::with_time を使います。

C++ と Rust の違い

C++ 原典の ray クラスでは time にデフォルト引数 = 0.0 を付けた 1 つのコンストラクタで対応します。

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;
};

C++ のデフォルト引数は「引数を省略した呼び出し」を 1 つのシグネチャで受け付ける仕組みです。Rust にはデフォルト引数も関数オーバーロードもないため,慣例として「主たるコンストラクタは new,バリアント別にメソッドを生やす」という命名で別名関数(with_time)を用意します。標準ライブラリでも Vec::new / Vec::with_capacityString::new / String::with_capacity のように同じパターンが使われています。

モーションブラーをシミュレートするカメラ

カメラはシャッター区間 [time0, time1] を保持し,各レイに区間内の乱数時刻を割り当てます。

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 {
    /// 第 1 編互換コンストラクタ。シャッター区間は `[0, 0]` 固定。
    pub fn new(/* lookfrom, lookat, vup, vfov, aspect_ratio, aperture, focus_dist */) -> Self {
        Self::new_with_shutter(/* ..., */ 0.0, 0.0)
    }

    /// シャッター区間 `[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::newnew_with_shutter(..., 0.0, 0.0) に委譲するので,第 1 編のすべてのデモはコード変更なしでビルドできます。time0 == time1 の場合は乱数を呼ばず,time0 をそのまま使います。これは第 1 編で乱数列を消費しないことを意味し,r1XX クレートの再現性が保たれます。

運動する球

中心が time0 時点で center0time1 時点で center1 にあり,その間を線形に補間する球を新たに導入します。

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)
    }
}

Hittable::hit の本体は Sphere とほぼ同じですが,中心としてレイの時刻における self.center(r.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)
    }
}

C++ と Rust の違い

C++ 原典では sphere から多くのコードがコピペされる形になりますが,第 4 章の AABB(軸並行境界ボックス)計算で両者の bounding_box が異なるロジックを必要とするため,このタイミングで別クラスとして分離するのが理にかなっています。

Rust では Sphereenum SphereKind { Static(Point3), Moving { ... } } のように一本化することも可能ですが,hit の中で match するコストが毎回かかります。原典に沿って素直に別構造体としました。

レイの時刻を散乱レイへ伝播

新たに散乱したレイは入射レイと同じ時刻を持つべきです(時刻はピクセルごとに決まる「シャッターを切った瞬間」のものなので,散乱で時間が進むことはない)。Lambertian / Metal / Dielectricscatter を一斉に修正します。

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))
    }
}

Metal::scatterDielectric::scatter も同じ要領で,散乱レイを Ray::new ではなく Ray::with_time(.., r_in.time()) で組み立てるように変更します(実装は省略,common/src/material.rs を参照)。

第 1 編のシーンではすべてのレイの時刻が 0.0 なので,r_in.time() を伝播してもレイは依然として全部 0.0 のままです。挙動は変わりません。

最終シーンの組み立て

r201-motion-blur クレートで,第 1 編の random_scene を改造して拡散球だけを MovingSphere に置き換えます。金属球とガラス球は静止のままです。

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(...) */
}

center2center から y 方向に [0,0.5) ランダムに上に持ち上げた位置です。time0 = 0.0, time1 = 1.0 の間に直線運動するので,シャッターを開けたタイミングのフレームでは球が下から上の任意の位置に確率的に存在しているように見え,ぼけが発生します。

カメラはシャッター区間 [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,
);

メインループは第 1 編 13 章とまったく同じで,違いは Camera::new_with_shutter を使うことと拡散球が 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
}

まとめ

ページ冒頭の WASM デモでは,下から上に「跳ねる」拡散球がぼやけて写り,動かない金属・ガラス球はくっきり写るのが観察できます。サンプル数 30 はモーションブラーがはっきり見えるぎりぎりの値で,乱数の偏りによりピクセル単位ではノイズが目立ちますが,跳ねの確率分布は十分に表現されています。

次章以降では,多数のオブジェクトに対するヒット判定を高速化するための AABB と BVH(境界ボリューム階層)を実装します。