Another project
1use core::time::Duration;
2
3use nalgebra::{UnitQuaternion, Vector3};
4use uom::si::angle::radian;
5use uom::si::f64::{Angle, Length};
6use uom::si::length::millimeter;
7
8use bone_types::{Camera3, CubicEasing, Point3, Projection, ProjectionKind, Result, UnitVec3};
9
10const SLERP_EPSILON: f64 = 1.0e-9;
11const NEWTON_ITERATIONS: u32 = 8;
12const BISECTION_ITERATIONS: u32 = 24;
13const SOLVE_EPSILON: f64 = 1.0e-7;
14
15#[derive(Copy, Clone, Debug, PartialEq)]
16pub struct CameraTween {
17 from: Camera3,
18 to: Camera3,
19 duration: Duration,
20 easing: CubicEasing,
21}
22
23impl CameraTween {
24 #[must_use]
25 pub fn eased(from: Camera3, to: Camera3, duration: Duration, easing: CubicEasing) -> Self {
26 Self {
27 from,
28 to,
29 duration,
30 easing,
31 }
32 }
33
34 #[must_use]
35 pub fn immediate(to: Camera3) -> Self {
36 Self {
37 from: to,
38 to,
39 duration: Duration::ZERO,
40 easing: CubicEasing::LINEAR,
41 }
42 }
43
44 #[must_use]
45 pub fn to(self) -> Camera3 {
46 self.to
47 }
48
49 #[must_use]
50 pub fn is_done(self, elapsed: Duration) -> bool {
51 elapsed >= self.duration
52 }
53
54 pub fn sample(self, elapsed: Duration) -> Result<Camera3> {
55 let progress = if self.duration.is_zero() {
56 1.0
57 } else {
58 (elapsed.as_secs_f64() / self.duration.as_secs_f64()).clamp(0.0, 1.0)
59 };
60 interpolate(self.from, self.to, ease(self.easing, progress))
61 }
62}
63
64fn interpolate(from: Camera3, to: Camera3, t: f64) -> Result<Camera3> {
65 let t = t.clamp(0.0, 1.0);
66 if t <= 0.0 {
67 return Ok(from);
68 }
69 if t >= 1.0 {
70 return Ok(to);
71 }
72 let orientation = orientation_of(from)
73 .try_slerp(&orientation_of(to), t, SLERP_EPSILON)
74 .unwrap_or_else(|| orientation_of(to));
75 let forward = orientation * Vector3::z();
76 let up_axis = orientation * Vector3::y();
77 let target = lerp_point(from.target(), to.target(), t);
78 let distance = lerp(eye_distance(from), eye_distance(to), t);
79 let eye = target
80 + UnitVec3::new_unchecked(forward.x, forward.y, forward.z)
81 .into_vec(Length::new::<millimeter>(-distance));
82 let up = UnitVec3::new_unchecked(up_axis.x, up_axis.y, up_axis.z);
83 Camera3::new(
84 eye,
85 target,
86 up,
87 lerp_projection(from.projection(), to.projection(), t)?,
88 )
89}
90
91fn orientation_of(camera: Camera3) -> UnitQuaternion<f64> {
92 let (ex, ey, ez) = camera.eye().coords_mm();
93 let (tx, ty, tz) = camera.target().coords_mm();
94 let (ux, uy, uz) = camera.up().components();
95 let forward = Vector3::new(tx - ex, ty - ey, tz - ez);
96 let up = Vector3::new(ux, uy, uz);
97 UnitQuaternion::face_towards(&forward, &up)
98}
99
100fn eye_distance(camera: Camera3) -> f64 {
101 (camera.target() - camera.eye()).norm_mm()
102}
103
104fn lerp(a: f64, b: f64, t: f64) -> f64 {
105 a + (b - a) * t
106}
107
108fn lerp_point(a: Point3, b: Point3, t: f64) -> Point3 {
109 let (ax, ay, az) = a.coords_mm();
110 let (bx, by, bz) = b.coords_mm();
111 Point3::from_mm(lerp(ax, bx, t), lerp(ay, by, t), lerp(az, bz, t))
112}
113
114fn lerp_mm(a: Length, b: Length, t: f64) -> Length {
115 Length::new::<millimeter>(lerp(a.get::<millimeter>(), b.get::<millimeter>(), t))
116}
117
118fn lerp_projection(a: Projection, b: Projection, t: f64) -> Result<Projection> {
119 match (a.kind(), b.kind()) {
120 (
121 ProjectionKind::Orthographic { half_height: ha },
122 ProjectionKind::Orthographic { half_height: hb },
123 ) => Projection::orthographic(lerp_mm(ha, hb, t)),
124 (
125 ProjectionKind::Perspective {
126 fov: fa,
127 near: na,
128 far: la,
129 },
130 ProjectionKind::Perspective {
131 fov: fb,
132 near: nb,
133 far: lb,
134 },
135 ) => {
136 let fov = Angle::new::<radian>(lerp(fa.get::<radian>(), fb.get::<radian>(), t));
137 Projection::perspective(fov, lerp_mm(na, nb, t), lerp_mm(la, lb, t))
138 }
139 _ => Ok(if t < 0.5 { a } else { b }),
140 }
141}
142
143fn ease(control: CubicEasing, x: f64) -> f64 {
144 let x = x.clamp(0.0, 1.0);
145 if x <= 0.0 || x >= 1.0 {
146 return x;
147 }
148 bezier_axis(
149 control.y1(),
150 control.y2(),
151 solve_for_t(control.x1(), control.x2(), x),
152 )
153}
154
155fn bezier_axis(p1: f64, p2: f64, s: f64) -> f64 {
156 let c = 3.0 * p1;
157 let b = 3.0 * (p2 - p1) - c;
158 let a = 1.0 - c - b;
159 ((a * s + b) * s + c) * s
160}
161
162fn bezier_slope(p1: f64, p2: f64, s: f64) -> f64 {
163 let c = 3.0 * p1;
164 let b = 3.0 * (p2 - p1) - c;
165 let a = 1.0 - c - b;
166 (3.0 * a * s + 2.0 * b) * s + c
167}
168
169fn solve_for_t(x1: f64, x2: f64, x: f64) -> f64 {
170 let newton = (0..NEWTON_ITERATIONS).try_fold(x, |guess, _| {
171 let error = bezier_axis(x1, x2, guess) - x;
172 if error.abs() < SOLVE_EPSILON {
173 return Err(guess);
174 }
175 let slope = bezier_slope(x1, x2, guess);
176 if slope.abs() < SOLVE_EPSILON {
177 return Err(guess);
178 }
179 Ok(guess - error / slope)
180 });
181 match newton {
182 Err(solved) => solved,
183 Ok(guess) if (0.0..=1.0).contains(&guess) && bezier_close(x1, x2, guess, x) => guess,
184 Ok(_) => bisect(x1, x2, x),
185 }
186}
187
188fn bezier_close(x1: f64, x2: f64, s: f64, x: f64) -> bool {
189 (bezier_axis(x1, x2, s) - x).abs() < SOLVE_EPSILON
190}
191
192fn bisect(x1: f64, x2: f64, x: f64) -> f64 {
193 let result = (0..BISECTION_ITERATIONS).try_fold((0.0_f64, 1.0_f64), |(low, high), _| {
194 let mid = f64::midpoint(low, high);
195 let value = bezier_axis(x1, x2, mid);
196 if (value - x).abs() < SOLVE_EPSILON {
197 return Err(mid);
198 }
199 if value < x {
200 Ok((mid, high))
201 } else {
202 Ok((low, mid))
203 }
204 });
205 match result {
206 Err(mid) => mid,
207 Ok((low, high)) => f64::midpoint(low, high),
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use bone_types::Angle;
215 use uom::si::angle::degree;
216
217 fn ortho_camera(eye: Point3, up: UnitVec3) -> Camera3 {
218 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else {
219 panic!("half height is positive");
220 };
221 let Ok(camera) = Camera3::new(eye, Point3::origin(), up, projection) else {
222 panic!("camera is non-degenerate");
223 };
224 camera
225 }
226
227 fn front() -> Camera3 {
228 ortho_camera(Point3::from_mm(0.0, -10.0, 0.0), UnitVec3::z_axis())
229 }
230
231 fn top() -> Camera3 {
232 ortho_camera(Point3::from_mm(0.0, 0.0, 10.0), UnitVec3::y_axis())
233 }
234
235 fn bottom() -> Camera3 {
236 ortho_camera(
237 Point3::from_mm(0.0, 0.0, -10.0),
238 UnitVec3::y_axis().reversed(),
239 )
240 }
241
242 fn close(a: Point3, b: Point3) -> bool {
243 let (ax, ay, az) = a.coords_mm();
244 let (bx, by, bz) = b.coords_mm();
245 (ax - bx).abs() < 1e-9 && (ay - by).abs() < 1e-9 && (az - bz).abs() < 1e-9
246 }
247
248 #[test]
249 fn endpoints_reproduce_the_input_cameras() {
250 let Ok(start) = interpolate(front(), top(), 0.0) else {
251 panic!("t=0 interpolates");
252 };
253 let Ok(end) = interpolate(front(), top(), 1.0) else {
254 panic!("t=1 interpolates");
255 };
256 assert!(
257 close(start.eye(), front().eye()),
258 "t=0 must be the start eye"
259 );
260 assert!(close(end.eye(), top().eye()), "t=1 must be the end eye");
261 assert!(close(start.target(), front().target()));
262 assert!(close(end.target(), top().target()));
263 }
264
265 #[test]
266 fn immediate_tween_is_the_target_at_any_time() {
267 let tween = CameraTween::immediate(top());
268 let Ok(at_zero) = tween.sample(Duration::ZERO) else {
269 panic!("immediate samples at zero");
270 };
271 let Ok(at_later) = tween.sample(Duration::from_secs(5)) else {
272 panic!("immediate samples later");
273 };
274 assert!(close(at_zero.eye(), top().eye()));
275 assert!(close(at_later.eye(), top().eye()));
276 assert!(
277 close(tween.to().eye(), top().eye()),
278 "the endpoint is the target"
279 );
280 assert!(tween.is_done(Duration::ZERO), "a zero-length tween is done");
281 }
282
283 #[test]
284 fn opposite_views_interpolate_without_panic() {
285 let Ok(mid) = interpolate(top(), bottom(), 0.5) else {
286 panic!("a 180-degree swing between top and bottom is well defined");
287 };
288 let span = (mid.eye() - mid.target()).norm_mm();
289 assert!(span > 0.0, "the midpoint camera keeps eye and target apart");
290 }
291
292 #[test]
293 fn midpoint_keeps_the_orbit_radius() {
294 let Ok(mid) = interpolate(front(), top(), 0.5) else {
295 panic!("midpoint interpolates");
296 };
297 let radius = (mid.eye() - mid.target()).norm_mm();
298 assert!(
299 (radius - 10.0).abs() < 1e-9,
300 "equal-radius endpoints keep the radius across the slerp: {radius}",
301 );
302 }
303
304 #[test]
305 fn sample_runs_the_tween_from_start_to_finish() {
306 let tween = CameraTween::eased(
307 front(),
308 top(),
309 Duration::from_millis(180),
310 CubicEasing::STANDARD,
311 );
312 let Ok(start) = tween.sample(Duration::ZERO) else {
313 panic!("samples at start");
314 };
315 let Ok(finish) = tween.sample(Duration::from_millis(180)) else {
316 panic!("samples at finish");
317 };
318 assert!(close(start.eye(), front().eye()));
319 assert!(close(finish.eye(), top().eye()));
320 }
321
322 #[test]
323 fn linear_easing_is_the_identity() {
324 [0.0, 0.25, 0.5, 0.75, 1.0].into_iter().for_each(|x| {
325 assert!(
326 (ease(CubicEasing::LINEAR, x) - x).abs() < SOLVE_EPSILON,
327 "linear ease passes x through within solver precision",
328 );
329 });
330 }
331
332 #[test]
333 fn standard_easing_is_monotonic_and_pinned_at_the_ends() {
334 assert!((ease(CubicEasing::STANDARD, 0.0)).abs() < 1e-9);
335 assert!((ease(CubicEasing::STANDARD, 1.0) - 1.0).abs() < 1e-9);
336 let samples: Vec<f64> = (0..=20)
337 .map(|i| ease(CubicEasing::STANDARD, f64::from(i) / 20.0))
338 .collect();
339 samples.windows(2).for_each(|pair| {
340 assert!(pair[1] >= pair[0] - 1e-9, "easing must not move backward");
341 });
342 }
343
344 #[test]
345 fn perspective_projection_interpolates_in_kind() {
346 let Ok(near_proj) = Projection::perspective(
347 Angle::new::<degree>(40.0),
348 Length::new::<millimeter>(1.0),
349 Length::new::<millimeter>(100.0),
350 ) else {
351 panic!("projection is valid");
352 };
353 let Ok(far_proj) = Projection::perspective(
354 Angle::new::<degree>(60.0),
355 Length::new::<millimeter>(2.0),
356 Length::new::<millimeter>(200.0),
357 ) else {
358 panic!("projection is valid");
359 };
360 let Ok(mid) = lerp_projection(near_proj, far_proj, 0.5) else {
361 panic!("two perspectives interpolate");
362 };
363 let ProjectionKind::Perspective { fov, near, far } = mid.kind() else {
364 panic!("the interpolation stays perspective");
365 };
366 assert!((fov.get::<degree>() - 50.0).abs() < 1e-9);
367 assert!((near.get::<millimeter>() - 1.5).abs() < 1e-9);
368 assert!((far.get::<millimeter>() - 150.0).abs() < 1e-9);
369 }
370}