feat: add camera path generator #5
@@ -4,6 +4,7 @@ pub mod app_state;
|
|||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod colormap;
|
pub mod colormap;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod path;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod scene_file;
|
pub mod scene_file;
|
||||||
|
|||||||
+388
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user