feat: add camera path generator #5

Merged
moldybits merged 1 commits from feat/camera-path-generator into main 2026-05-16 10:43:49 -04:00
2 changed files with 389 additions and 0 deletions
Showing only changes of commit 280413d919 - Show all commits
+1
View File
@@ -4,6 +4,7 @@ pub mod app_state;
pub mod cli;
pub mod colormap;
pub mod import;
pub mod path;
pub mod render;
pub mod scene;
pub mod scene_file;
+388
View File
@@ -0,0 +1,388 @@
//! Deterministic camera animation paths.
//!
//! A [`CameraPath`] is an ordered list of [`CameraKeyframe`]s, each binding a
//! [`Camera`] to a moment in time. Sampling the path at an arbitrary time
//! produces a smoothly interpolated camera, which lets callers fly the scene
//! camera along a designed trajectory.
//!
//! # Interpolation
//!
//! `position` and `target` are interpolated with a **uniform Catmull-Rom
//! spline**. Catmull-Rom is an interpolating cubic spline: it passes exactly
//! through every keyframe and is C1-continuous, so the motion has no visible
//! kinks at keyframes. Each segment between keyframe `i` and `i + 1` is
//! evaluated with a local parameter `u = (t - t_i) / (t_{i+1} - t_i)` in
//! `[0, 1]` — that is, the spline runs over **time-normalized segments**, so
//! keyframes may be spaced unevenly in time.
//!
//! Catmull-Rom needs two neighbouring control points per segment. At the path
//! boundaries the missing outer control point is supplied by **duplicating the
//! endpoint keyframe** (a standard "clamped endpoint" boundary condition).
//!
//! `fov_degrees` is interpolated **linearly** rather than with the spline: a
//! cubic spline can overshoot, and an overshoot on field of view could produce
//! a non-positive or absurdly wide angle. A linear ramp keeps the FOV strictly
//! within the bracketing keyframe values.
//!
//! Sampling is fully deterministic: it performs only `f32` arithmetic with no
//! I/O, randomness, or global state, so the same path and time always yield a
//! bit-identical [`Camera`].
use crate::scene::{Camera, Vec3};
/// A single camera pose pinned to a point in time on a [`CameraPath`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraKeyframe {
/// The time at which the camera takes this pose. Times along a path must
/// be strictly increasing.
pub time: f32,
/// The camera pose at [`CameraKeyframe::time`].
pub camera: Camera,
}
impl CameraKeyframe {
/// Create a keyframe binding `camera` to `time`.
pub const fn new(time: f32, camera: Camera) -> Self {
CameraKeyframe { time, camera }
}
}
/// An error produced while building a [`CameraPath`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathError {
/// `try_new` was called with no keyframes.
Empty,
/// Keyframe times were not strictly increasing. `index` is the position of
/// the offending keyframe — its time is not greater than its predecessor's.
NonMonotonicTime { index: usize },
/// A keyframe time was not a finite number. `index` is its position.
NonFiniteTime { index: usize },
}
impl std::fmt::Display for PathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathError::Empty => write!(f, "camera path must have at least one keyframe"),
PathError::NonMonotonicTime { index } => write!(
f,
"keyframe time at index {index} is not strictly greater than the previous keyframe"
),
PathError::NonFiniteTime { index } => {
write!(f, "keyframe time at index {index} is not a finite number")
}
}
}
}
impl std::error::Error for PathError {}
/// A time-ordered sequence of camera keyframes that can be sampled to obtain a
/// smoothly interpolated [`Camera`].
///
/// Construct one with [`CameraPath::try_new`]; the constructor validates that
/// keyframe times are finite and strictly increasing.
#[derive(Debug, Clone, PartialEq)]
pub struct CameraPath {
keyframes: Vec<CameraKeyframe>,
}
impl CameraPath {
/// Build a path from `keyframes`.
///
/// # Errors
///
/// Returns [`PathError::Empty`] if `keyframes` is empty,
/// [`PathError::NonFiniteTime`] if any keyframe time is NaN or infinite,
/// and [`PathError::NonMonotonicTime`] if the times are not strictly
/// increasing.
pub fn try_new(keyframes: Vec<CameraKeyframe>) -> Result<Self, PathError> {
if keyframes.is_empty() {
return Err(PathError::Empty);
}
for (index, kf) in keyframes.iter().enumerate() {
if !kf.time.is_finite() {
return Err(PathError::NonFiniteTime { index });
}
if index > 0 && kf.time <= keyframes[index - 1].time {
return Err(PathError::NonMonotonicTime { index });
}
}
Ok(CameraPath { keyframes })
}
/// The keyframes of this path, in time order.
pub fn keyframes(&self) -> &[CameraKeyframe] {
&self.keyframes
}
/// The time of the first keyframe.
pub fn start_time(&self) -> f32 {
self.keyframes[0].time
}
/// The time of the last keyframe.
pub fn end_time(&self) -> f32 {
self.keyframes[self.keyframes.len() - 1].time
}
/// Sample the camera at `time`.
///
/// Times outside `[start_time, end_time]` are clamped to the nearest
/// endpoint keyframe. Sampling exactly at a keyframe time returns that
/// keyframe's camera unchanged.
pub fn sample(&self, time: f32) -> Camera {
let kf = &self.keyframes;
// Endpoint clamping: a path with a single keyframe is constant, and any
// time at or outside the boundaries snaps to the boundary keyframe.
if kf.len() == 1 || time <= self.start_time() {
return kf[0].camera;
}
if time >= self.end_time() {
return kf[kf.len() - 1].camera;
}
// Locate the segment [i, i + 1] that brackets `time`.
let mut i = 0;
while i + 1 < kf.len() && kf[i + 1].time <= time {
i += 1;
}
let (t1, t2) = (kf[i].time, kf[i + 1].time);
let u = (time - t1) / (t2 - t1);
// Catmull-Rom control points, duplicating endpoints at the boundaries.
let p0 = kf[i.saturating_sub(1)].camera;
let p1 = kf[i].camera;
let p2 = kf[i + 1].camera;
let p3 = kf[(i + 2).min(kf.len() - 1)].camera;
Camera {
position: catmull_rom_vec3(p0.position, p1.position, p2.position, p3.position, u),
target: catmull_rom_vec3(p0.target, p1.target, p2.target, p3.target, u),
fov_degrees: lerp(p1.fov_degrees, p2.fov_degrees, u),
}
}
}
/// Linear interpolation from `a` to `b` by `u` in `[0, 1]`.
fn lerp(a: f32, b: f32, u: f32) -> f32 {
a + (b - a) * u
}
/// Uniform Catmull-Rom basis evaluated for one scalar component.
///
/// `p1` and `p2` bracket the segment; `p0` and `p3` are the neighbouring
/// control points. `u` is the local segment parameter in `[0, 1]`.
fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, u: f32) -> f32 {
let u2 = u * u;
let u3 = u2 * u;
0.5 * ((2.0 * p1)
+ (-p0 + p2) * u
+ (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * u2
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * u3)
}
/// Apply [`catmull_rom`] component-wise to a [`Vec3`].
fn catmull_rom_vec3(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, u: f32) -> Vec3 {
Vec3::new(
catmull_rom(p0.x, p1.x, p2.x, p3.x, u),
catmull_rom(p0.y, p1.y, p2.y, p3.y, u),
catmull_rom(p0.z, p1.z, p2.z, p3.z, u),
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a keyframe with the given time, position, target and FOV.
fn kf(time: f32, pos: [f32; 3], target: [f32; 3], fov: f32) -> CameraKeyframe {
CameraKeyframe::new(
time,
Camera {
position: Vec3::new(pos[0], pos[1], pos[2]),
target: Vec3::new(target[0], target[1], target[2]),
fov_degrees: fov,
},
)
}
#[test]
fn try_new_rejects_empty_keyframes() {
assert_eq!(CameraPath::try_new(vec![]), Err(PathError::Empty));
}
#[test]
fn try_new_rejects_non_monotonic_time() {
let frames = vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(2.0, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(1.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
];
assert_eq!(
CameraPath::try_new(frames),
Err(PathError::NonMonotonicTime { index: 2 })
);
}
#[test]
fn try_new_rejects_equal_adjacent_times() {
let frames = vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(1.0, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(1.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
];
assert_eq!(
CameraPath::try_new(frames),
Err(PathError::NonMonotonicTime { index: 2 })
);
}
#[test]
fn try_new_rejects_non_finite_time() {
let frames = vec![kf(f32::NAN, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0)];
assert_eq!(
CameraPath::try_new(frames),
Err(PathError::NonFiniteTime { index: 0 })
);
}
#[test]
fn try_new_accepts_strictly_increasing_times() {
let frames = vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(1.5, [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(4.0, [2.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
];
let path = CameraPath::try_new(frames).expect("monotonic times must be accepted");
assert_eq!(path.start_time(), 0.0);
assert_eq!(path.end_time(), 4.0);
assert_eq!(path.keyframes().len(), 3);
}
#[test]
fn sample_returns_endpoints_exactly() {
let first = kf(0.0, [0.0, 10.0, 0.0], [1.0, 0.0, 0.0], 50.0);
let last = kf(3.0, [9.0, 4.0, 2.0], [0.0, 1.0, 0.0], 70.0);
let path = CameraPath::try_new(vec![
first,
kf(1.0, [3.0, 8.0, 1.0], [0.5, 0.5, 0.0], 55.0),
kf(2.0, [6.0, 6.0, 1.5], [0.2, 0.8, 0.0], 62.0),
last,
])
.unwrap();
assert_eq!(path.sample(0.0), first.camera);
assert_eq!(path.sample(3.0), last.camera);
}
#[test]
fn sample_clamps_outside_the_time_range() {
let first = kf(1.0, [0.0, 10.0, 0.0], [1.0, 0.0, 0.0], 50.0);
let last = kf(4.0, [9.0, 4.0, 2.0], [0.0, 1.0, 0.0], 70.0);
let path = CameraPath::try_new(vec![first, last]).unwrap();
assert_eq!(path.sample(-100.0), first.camera);
assert_eq!(path.sample(0.5), first.camera);
assert_eq!(path.sample(4.0), last.camera);
assert_eq!(path.sample(999.0), last.camera);
}
#[test]
fn sample_passes_through_interior_keyframes() {
// Catmull-Rom is interpolating: sampling at an interior keyframe time
// must return that keyframe's camera unchanged.
let interior = kf(2.0, [5.0, 7.0, -3.0], [1.0, 2.0, 3.0], 48.0);
let path = CameraPath::try_new(vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(1.0, [1.0, 9.0, 4.0], [9.0, 0.0, 0.0], 90.0),
interior,
kf(3.0, [8.0, 1.0, 1.0], [0.0, 0.0, 9.0], 30.0),
])
.unwrap();
assert_eq!(path.sample(2.0), interior.camera);
}
#[test]
fn sample_interpolates_collinear_segment_linearly() {
// Equally spaced, collinear control points: on an interior segment the
// Catmull-Rom spline reduces to a straight line, so the midpoint is the
// exact arithmetic mean of the bracketing keyframes.
let path = CameraPath::try_new(vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 10.0),
kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 20.0),
kf(2.0, [20.0, 0.0, 0.0], [0.0, 0.0, 0.0], 30.0),
kf(3.0, [30.0, 0.0, 0.0], [0.0, 0.0, 0.0], 40.0),
])
.unwrap();
let mid = path.sample(1.5);
assert_eq!(mid.position, Vec3::new(15.0, 0.0, 0.0));
}
#[test]
fn sample_interpolates_position_strictly_between_keyframes() {
// On the first segment the duplicated endpoint changes the tangent, so
// the midpoint is not the arithmetic mean — but it must still lie
// strictly between the bracketing keyframe positions.
let path = CameraPath::try_new(vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
kf(2.0, [20.0, 0.0, 0.0], [0.0, 0.0, 0.0], 60.0),
])
.unwrap();
let mid = path.sample(0.5);
assert!(
mid.position.x > 0.0 && mid.position.x < 10.0,
"expected 0 < x < 10, got {}",
mid.position.x
);
}
#[test]
fn sample_interpolates_fov_linearly() {
// FOV is a linear ramp, independent of the spline, so the midpoint of a
// segment is the exact mean regardless of segment position.
let path = CameraPath::try_new(vec![
kf(0.0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 40.0),
kf(1.0, [10.0, 0.0, 0.0], [0.0, 0.0, 0.0], 80.0),
])
.unwrap();
assert_eq!(path.sample(0.25).fov_degrees, 50.0);
assert_eq!(path.sample(0.5).fov_degrees, 60.0);
assert_eq!(path.sample(0.75).fov_degrees, 70.0);
}
#[test]
fn sample_is_deterministic() {
let path = CameraPath::try_new(vec![
kf(0.0, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 60.0),
kf(1.0, [4.0, 9.0, -2.0], [0.0, 1.0, 0.0], 75.0),
kf(2.7, [11.0, 3.0, 5.0], [0.0, 0.0, 1.0], 42.0),
])
.unwrap();
// Repeated sampling at the same time is bit-identical.
let a = path.sample(1.3);
let b = path.sample(1.3);
assert_eq!(a, b);
// And a clone of the path produces the identical sample.
let clone = path.clone();
assert_eq!(clone.sample(1.3), a);
}
#[test]
fn single_keyframe_path_is_constant() {
let only = kf(5.0, [1.0, 2.0, 3.0], [4.0, 5.0, 6.0], 33.0);
let path = CameraPath::try_new(vec![only]).unwrap();
assert_eq!(path.sample(-10.0), only.camera);
assert_eq!(path.sample(5.0), only.camera);
assert_eq!(path.sample(1000.0), only.camera);
}
}