View animations turn smoothly to the very end: the camera blends heading (the shorter way) and elevation instead of slerping orientations (whose roll-free heading spun near straight up or down, so moves to Top or Bottom snapped in their last frames); test samples every view-cube move at 60 fps for jumps
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
3f44110b59
commit
c450619b0a
1 file changed
+70
-24
+70
-24
@@ -327,8 +327,8 @@ impl Camera {
|
||||
let (r, u, b) = self.basis();
|
||||
Quat::from_basis(r, u, b)
|
||||
}
|
||||
/// Blend between two cameras at `s` in [0, 1]: orientation by quaternion slerp, target,
|
||||
/// distance and field of view linearly. The ends are returned exactly.
|
||||
/// Blend between two cameras at `s` in [0, 1]: heading (the shorter way), elevation,
|
||||
/// target, distance and field of view linearly. The ends are returned exactly.
|
||||
pub fn interpolate(a: &Camera, b: &Camera, s: f64) -> Camera {
|
||||
if !(s > 0.0) {
|
||||
return *a;
|
||||
@@ -336,19 +336,13 @@ impl Camera {
|
||||
if s >= 1.0 {
|
||||
return *b;
|
||||
}
|
||||
let q = a.orientation().slerp(b.orientation(), s);
|
||||
let (r, _, back) = q.basis();
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let pitch = back.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim);
|
||||
// The camera has no roll: follow the slerped view direction, and near straight up/down
|
||||
// (where the direction has no heading) the slerped right vector.
|
||||
let yaw = if back.x.hypot(back.y) > 1e-3 {
|
||||
(-back.x).atan2(-back.y)
|
||||
} else if r.x.hypot(r.y) > 1e-9 {
|
||||
(-r.y).atan2(r.x)
|
||||
} else {
|
||||
a.yaw + (b.yaw - a.yaw) * s
|
||||
};
|
||||
// The camera has no roll, so it turns in heading and elevation: both change smoothly
|
||||
// to the very end (the heading the shorter way round). A slerp of orientations would
|
||||
// need a roll-free heading at every step, and near straight up or down that heading
|
||||
// swings fast, so a move to Top spun in its last frames.
|
||||
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let yaw = a.yaw + dy * s;
|
||||
let pitch = a.pitch + (b.pitch - a.pitch) * s;
|
||||
Camera {
|
||||
target: a.target + (b.target - a.target) * s,
|
||||
yaw,
|
||||
@@ -404,17 +398,11 @@ mod tests {
|
||||
assert_eq!(anim.sample(10.0).0, a);
|
||||
assert_eq!(anim.sample(10.0 + CameraAnim::DURATION), (b, true));
|
||||
assert_eq!(anim.sample(99.0), (b, true));
|
||||
// Midpoint: a valid orientation half way between (equal angles to both ends).
|
||||
// Midpoint: half way in heading, elevation, target and distance.
|
||||
let (m, done) = anim.sample(10.0 + CameraAnim::DURATION / 2.0);
|
||||
assert!(!done && m.is_valid());
|
||||
let q = m.orientation();
|
||||
assert!((q.len() - 1.0).abs() < 1e-12);
|
||||
let (qa, qb) = (a.orientation(), b.orientation());
|
||||
let qm = qa.slerp(qb, 0.5);
|
||||
assert!((qm.dot(qa).abs() - qm.dot(qb).abs()).abs() < 1e-12);
|
||||
// The camera keeps no roll but follows the slerped view direction.
|
||||
let (_, _, back) = qm.basis();
|
||||
assert!(m.back().dist(back) < 1e-9, "{:?} vs {back:?}", m.back());
|
||||
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
assert!((m.yaw - (a.yaw + dy / 2.0)).abs() < 1e-12 && (m.pitch - (a.pitch + b.pitch) / 2.0).abs() < 1e-12);
|
||||
assert!(m.target.dist(Vec3::new(-2.0, 1.0, 6.5)) < 1e-9 && (m.distance - 100.0).abs() < 1e-9);
|
||||
// Quaternion round trip of a basis.
|
||||
let (r, u, bk) = a.basis();
|
||||
@@ -456,4 +444,62 @@ mod tests {
|
||||
c.fit(&Aabb3::EMPTY);
|
||||
assert!(c.is_valid());
|
||||
}
|
||||
|
||||
/// A view change sampled every frame (60 fps) moves smoothly to the very end: no frame
|
||||
/// turns the view, moves the target or changes the distance by much more than the average
|
||||
/// step, and the last frame lands exactly on the target camera.
|
||||
#[test]
|
||||
fn view_animations_have_no_jumps() {
|
||||
let base = Camera { target: Vec3::new(5.0, -3.0, 2.0), distance: 150.0, ..Default::default() };
|
||||
let views = [
|
||||
StandardView::Iso,
|
||||
StandardView::Top,
|
||||
StandardView::Front,
|
||||
StandardView::Right,
|
||||
StandardView::Bottom,
|
||||
StandardView::Back,
|
||||
StandardView::Left,
|
||||
];
|
||||
let mut cams: Vec<Camera> = views
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut c = base;
|
||||
c.set_view(*v);
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
// Corners and edges of the view cube, and a fit (new target and distance).
|
||||
cams.push(base.looking_from(Vec3::new(1.0, -1.0, 1.0)));
|
||||
cams.push(base.looking_from(Vec3::new(-1.0, 0.0, 1.0)));
|
||||
cams.push(base.looking_from(Vec3::new(0.0, 0.0, 1.0)));
|
||||
cams.push(Camera { target: Vec3::new(-40.0, 10.0, 0.0), distance: 60.0, ..base });
|
||||
for a in &cams {
|
||||
for b in &cams {
|
||||
let anim = CameraAnim::new(*a, *b, 0.0);
|
||||
let frames = 30;
|
||||
let mut prev = anim.sample(0.0).0;
|
||||
let (mut steps, mut done_at) = (Vec::new(), None);
|
||||
for f in 1..=frames + 2 {
|
||||
let (c, done) = anim.sample(f as f64 / 60.0);
|
||||
let turn = c.back().dist(prev.back()) + c.basis().0.dist(prev.basis().0);
|
||||
let moved =
|
||||
c.target.dist(prev.target) / a.distance.max(b.distance) + (c.distance - prev.distance).abs() / a.distance.max(b.distance);
|
||||
steps.push((f, turn, moved));
|
||||
prev = c;
|
||||
if done && done_at.is_none() {
|
||||
done_at = Some(f);
|
||||
assert_eq!(c, *b);
|
||||
}
|
||||
}
|
||||
assert!(done_at.is_some_and(|f| f >= frames), "ended early at {done_at:?}");
|
||||
let total_turn: f64 = steps.iter().map(|s| s.1).sum();
|
||||
let total_move: f64 = steps.iter().map(|s| s.2).sum();
|
||||
for (f, turn, moved) in &steps {
|
||||
// The eased curve's fastest frame is 1.5x the average: allow 3x.
|
||||
assert!(*turn <= 3.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
|
||||
assert!(*moved <= 3.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user