use bevy::{
math::cubic_splines::{CubicBSpline, CubicCardinalSpline, CubicCurve, CubicGenerator},
prelude::*,
};
use thiserror::Error;
use crate::{
curve::{ArcLengthLut, eased_fraction, eased_segment},
sequence::{
ActorCue, ActorTrack, Blend, DofMode, FocusTrack, FovSpec, Key, KeyInterp, Look, Marker,
RailKind, Rig, ScalarTrack, SequenceAsset, Shake, TargetRef, TextBlock,
},
shake::shake_offset,
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraSnapshot {
pub position: Vec3,
pub rotation: Quat,
pub fov_y: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DofParams {
pub focal_distance: f32,
pub aperture_f_stops: f32,
pub sensor_height: f32,
pub bokeh: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraPose {
pub position: Vec3,
pub rotation: Quat,
pub fov_y: f32,
pub dof: Option<DofParams>,
pub exposure_ev100: Option<f32>,
}
impl CameraPose {
fn from_snapshot(s: &CameraSnapshot) -> Self {
Self {
position: s.position,
rotation: s.rotation,
fov_y: s.fov_y,
dof: None,
exposure_ev100: None,
}
}
}
pub struct EvalCtx<'a> {
pub live: &'a CameraSnapshot,
pub dt: f32,
pub prev_rot: Option<Quat>,
pub resolve_entity: &'a dyn Fn(&str) -> Option<Vec3>,
}
fn no_entities(_: &str) -> Option<Vec3> {
None
}
impl<'a> EvalCtx<'a> {
pub fn still(live: &'a CameraSnapshot) -> Self {
Self {
live,
dt: 0.0,
prev_rot: None,
resolve_entity: &no_entities,
}
}
}
fn resolve_target(target: &TargetRef, ctx: &EvalCtx) -> Option<Vec3> {
match target {
TargetRef::Point(p) => Some(*p),
TargetRef::Entity(name) => (ctx.resolve_entity)(name),
}
}
pub(crate) fn mix(from: &CameraPose, to: &CameraPose, w: f32) -> CameraPose {
let dof = match (from.dof, to.dof) {
(Some(a), Some(b)) => Some(DofParams {
focal_distance: a.focal_distance + (b.focal_distance - a.focal_distance) * w,
aperture_f_stops: a.aperture_f_stops + (b.aperture_f_stops - a.aperture_f_stops) * w,
sensor_height: a.sensor_height + (b.sensor_height - a.sensor_height) * w,
bokeh: if w < 0.5 { a.bokeh } else { b.bokeh },
}),
(a, b) => {
if w < 1.0 {
a.or(b)
} else {
b.or(a)
}
}
};
let exposure_ev100 = match (from.exposure_ev100, to.exposure_ev100) {
(Some(a), Some(b)) => Some(a + (b - a) * w),
(a, b) => {
if w < 1.0 {
a.or(b)
} else {
b.or(a)
}
}
};
CameraPose {
position: from.position.lerp(to.position, w),
rotation: from.rotation.slerp(to.rotation, w),
fov_y: from.fov_y + (to.fov_y - from.fov_y) * w,
dof,
exposure_ev100,
}
}
#[derive(Error, Debug, Clone, PartialEq)]
pub enum BakeError {
#[error("a sequence needs at least one shot")]
Empty,
#[error("shots must be sorted by start time (shot {0} starts before its predecessor)")]
ShotsUnsorted(usize),
#[error("shot {0} has no keys")]
NoKeys(usize),
#[error("shot {0} keys must be sorted by time")]
KeysUnsorted(usize),
#[error("shot {0} key time outside 0..=duration")]
KeyOutOfRange(usize),
#[error("shot {0} uses Look::Free but a key is missing its rotation")]
MissingRotation(usize),
#[error("shot {0} rail needs at least {1} points for its spline kind")]
RailTooShort(usize, usize),
#[error("shot {0} rail cannot use Look::Free: rails carry no rotations")]
RailNeedsLook(usize),
#[error("shot {0} orbit radius track has no keys")]
EmptyRadiusTrack(usize),
#[error("shot {0} fov track has no keys")]
EmptyFovTrack(usize),
#[error("shot {0} spline failed to build")]
SplineFailed(usize),
}
const LUT_DENSITY: usize = 64;
const VELOCITY_STEP: f32 = 1.0 / 60.0;
enum PosSampler {
Keys {
keys: Vec<Key>,
},
KeysSpline {
keys: Vec<Key>,
curve: CubicCurve<Vec3>,
},
Rail {
curve: CubicCurve<Vec3>,
lut: Option<ArcLengthLut>,
progress: ScalarTrack,
},
Orbit {
center: TargetRef,
radius: ScalarTrack,
yaw_deg: ScalarTrack,
pitch_deg: ScalarTrack,
},
}
enum FovSampler {
Degrees(ScalarTrack),
FocalMm { track: ScalarTrack, sensor_mm: f32 },
}
impl FovSampler {
fn radians_at(&self, local: f32) -> f32 {
match self {
FovSampler::Degrees(track) => track.sample(local).to_radians(),
FovSampler::FocalMm { track, sensor_mm } => {
let focal = track.sample(local).max(1.0);
2.0 * (sensor_mm / (2.0 * focal)).atan()
}
}
}
}
struct CompiledShot {
start: f32,
duration: f32,
blend_in: Option<Blend>,
pos: PosSampler,
look: Look,
fov: FovSampler,
focus: Option<FocusTrack>,
aperture: Option<ScalarTrack>,
exposure: Option<ScalarTrack>,
bokeh: bool,
sensor_height: f32,
shake: Option<Shake>,
}
pub struct CompiledSequence {
duration: f32,
shots: Vec<CompiledShot>,
markers: Vec<Marker>,
texts: Vec<TextBlock>,
actors: Vec<ActorTrack>,
blend_out: Option<Blend>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ActiveText {
pub index: usize,
pub alpha: f32,
pub block: TextBlock,
}
pub(crate) fn collect_active_texts(texts: &[TextBlock], t: f32, out: &mut Vec<ActiveText>) {
out.clear();
for (index, block) in texts.iter().enumerate() {
if block.active_at(t) {
out.push(ActiveText {
index,
alpha: block.alpha_at(t),
block: block.clone(),
});
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ActiveActorCue {
pub track: usize,
pub cue_index: usize,
pub target: TargetRef,
pub weight: f32,
pub local_time: f32,
pub cue: ActorCue,
}
pub(crate) fn collect_active_actor_cues(
tracks: &[ActorTrack],
t: f32,
out: &mut Vec<ActiveActorCue>,
) {
out.clear();
for (track_index, track) in tracks.iter().enumerate() {
for (cue_index, cue) in track.cues.iter().enumerate() {
if cue.active_at(t) {
out.push(ActiveActorCue {
track: track_index,
cue_index,
target: track.target.clone(),
weight: cue.alpha_at(t),
local_time: t - cue.start,
cue: cue.clone(),
});
}
}
}
}
pub fn bake(asset: &SequenceAsset) -> Result<CompiledSequence, BakeError> {
if asset.shots.is_empty() {
return Err(BakeError::Empty);
}
let mut shots = Vec::with_capacity(asset.shots.len());
let mut prev_start = f32::NEG_INFINITY;
for (i, shot) in asset.shots.iter().enumerate() {
if shot.start < prev_start {
return Err(BakeError::ShotsUnsorted(i));
}
prev_start = shot.start;
let pos = match &shot.rig {
Rig::Keys { keys, interp } => {
if keys.is_empty() {
return Err(BakeError::NoKeys(i));
}
if keys.windows(2).any(|p| p[1].time < p[0].time) {
return Err(BakeError::KeysUnsorted(i));
}
if keys
.iter()
.any(|k| k.time < 0.0 || k.time > shot.duration + 1e-4)
{
return Err(BakeError::KeyOutOfRange(i));
}
if matches!(shot.look, Look::Free) && keys.iter().any(|k| k.rot.is_none()) {
return Err(BakeError::MissingRotation(i));
}
let mut keys = keys.clone();
for key in &mut keys {
if let Some(rot) = &mut key.rot {
*rot = rot.normalize();
}
}
match interp {
KeyInterp::Eased => PosSampler::Keys { keys },
KeyInterp::CatmullRom => {
if keys.len() < 2 {
return Err(BakeError::RailTooShort(i, 2));
}
let points: Vec<Vec3> = keys.iter().map(|k| k.pos).collect();
let curve = CubicCardinalSpline::new_catmull_rom(points)
.to_curve()
.map_err(|_| BakeError::SplineFailed(i))?;
PosSampler::KeysSpline { keys, curve }
}
}
}
Rig::Rail {
points,
kind,
constant_speed,
progress,
} => {
if matches!(shot.look, Look::Free) {
return Err(BakeError::RailNeedsLook(i));
}
let needed = match kind {
RailKind::CatmullRom => 2,
RailKind::BSpline => 4,
};
if points.len() < needed {
return Err(BakeError::RailTooShort(i, needed));
}
let curve = match kind {
RailKind::CatmullRom => CubicCardinalSpline::new_catmull_rom(points.clone())
.to_curve()
.map_err(|_| BakeError::SplineFailed(i))?,
RailKind::BSpline => CubicBSpline::new(points.clone())
.to_curve()
.map_err(|_| BakeError::SplineFailed(i))?,
};
let lut = constant_speed.then(|| {
ArcLengthLut::new(&curve, LUT_DENSITY * curve.segments().len().max(1))
});
PosSampler::Rail {
curve,
lut,
progress: progress.clone(),
}
}
Rig::Orbit {
center,
radius,
yaw_deg,
pitch_deg,
} => {
if radius.keys.is_empty() {
return Err(BakeError::EmptyRadiusTrack(i));
}
PosSampler::Orbit {
center: center.clone(),
radius: radius.clone(),
yaw_deg: yaw_deg.clone(),
pitch_deg: pitch_deg.clone(),
}
}
};
let fov = match &shot.lens.fov {
FovSpec::VerticalFovDeg(track) => {
if track.keys.is_empty() {
return Err(BakeError::EmptyFovTrack(i));
}
FovSampler::Degrees(track.clone())
}
FovSpec::FocalLengthMm { track, filmback } => {
if track.keys.is_empty() {
return Err(BakeError::EmptyFovTrack(i));
}
FovSampler::FocalMm {
track: track.clone(),
sensor_mm: filmback.sensor_height_mm(),
}
}
};
let sensor_height = match &shot.lens.fov {
FovSpec::FocalLengthMm { filmback, .. } => filmback.sensor_height_mm() / 1000.0,
_ => 0.018_66,
};
shots.push(CompiledShot {
start: shot.start,
duration: shot.duration,
blend_in: shot.blend_in,
pos,
look: shot.look.clone(),
fov,
focus: shot.lens.focus.clone(),
aperture: shot.lens.aperture_f_stops.clone(),
exposure: shot.lens.exposure_ev100.clone(),
bokeh: shot.lens.dof_mode == DofMode::Bokeh,
sensor_height,
shake: shot.shake,
});
}
let mut markers = asset.markers.clone();
markers.sort_by(|a, b| a.time.total_cmp(&b.time));
let texts = asset
.texts
.iter()
.map(|block| TextBlock {
duration: block.duration.max(0.0),
fade_in: block.fade_in.max(0.0),
fade_out: block.fade_out.max(0.0),
..block.clone()
})
.collect();
let actors = asset
.actors
.iter()
.map(|track| ActorTrack {
target: track.target.clone(),
cues: track
.cues
.iter()
.map(|cue| ActorCue {
duration: cue.duration.max(0.0),
fade_in: cue.fade_in.max(0.0),
fade_out: cue.fade_out.max(0.0),
speed: if cue.speed > 0.0 { cue.speed } else { 1.0 },
..cue.clone()
})
.collect(),
})
.collect();
Ok(CompiledSequence {
duration: asset.duration(),
shots,
markers,
texts,
actors,
blend_out: asset.blend_out,
})
}
impl PosSampler {
fn position(&self, local: f32, duration: f32, ctx: &EvalCtx) -> Vec3 {
match self {
PosSampler::Keys { keys } => sample_keys_position(keys, local),
PosSampler::KeysSpline { keys, curve } => {
let n = keys.len();
if n == 1 || local <= keys[0].time {
return curve.position(0.0);
}
if local >= keys[n - 1].time {
return curve.position(curve.segments().len() as f32);
}
let seg_index = keys
.windows(2)
.position(|p| local <= p[1].time)
.unwrap_or(n - 2);
let w = eased_fraction(&keys[seg_index], &keys[seg_index + 1], local);
let segments = curve.segments().len() as f32;
let t = ((seg_index as f32 + w) / (n - 1) as f32) * segments;
curve.position(t)
}
PosSampler::Rail {
curve,
lut,
progress,
} => {
let fraction = if progress.keys.is_empty() {
(local / duration.max(f32::EPSILON)).clamp(0.0, 1.0)
} else {
progress.sample(local).clamp(0.0, 1.0)
};
let t = match lut {
Some(lut) => lut.t_at_fraction(fraction),
None => fraction * curve.segments().len() as f32,
};
curve.position(t)
}
PosSampler::Orbit {
center,
radius,
yaw_deg,
pitch_deg,
} => {
let center = resolve_target(center, ctx).unwrap_or(Vec3::ZERO);
let radius = radius.sample(local).max(0.0);
let yaw = yaw_deg.sample(local).to_radians();
let pitch = pitch_deg.sample(local).to_radians().clamp(-1.55, 1.55);
let dir = Vec3::new(
pitch.cos() * yaw.sin(),
pitch.sin(),
pitch.cos() * yaw.cos(),
);
center + dir * radius
}
}
}
fn key_rotation(&self, local: f32) -> Option<Quat> {
let keys = match self {
PosSampler::Keys { keys } | PosSampler::KeysSpline { keys, .. } => keys,
_ => return None,
};
let n = keys.len();
if n == 1 || local <= keys[0].time {
return keys[0].rot;
}
if local >= keys[n - 1].time {
return keys[n - 1].rot;
}
let seg = keys.windows(2).find(|p| local <= p[1].time)?;
match (seg[0].rot, seg[1].rot) {
(Some(a), Some(b)) => Some(a.slerp(b, eased_fraction(&seg[0], &seg[1], local))),
_ => None,
}
}
}
fn sample_keys_position(keys: &[Key], local: f32) -> Vec3 {
let last = &keys[keys.len() - 1];
if local <= keys[0].time || keys.len() == 1 {
keys[0].pos
} else if local >= last.time {
last.pos
} else {
let seg = keys
.windows(2)
.find(|p| local <= p[1].time)
.expect("local is within the keyed range");
eased_segment(&seg[0], &seg[1], local).0
}
}
fn look_toward(position: Vec3, target: Vec3, fallback: Quat) -> Quat {
if position.distance_squared(target) < 1e-8 {
return fallback;
}
Transform::from_translation(position)
.looking_at(target, Vec3::Y)
.rotation
}
impl CompiledSequence {
pub fn duration(&self) -> f32 {
self.duration
}
pub fn blend_out(&self) -> Option<Blend> {
self.blend_out
}
pub fn texts(&self) -> &[TextBlock] {
&self.texts
}
pub fn active_texts_into(&self, t: f32, out: &mut Vec<ActiveText>) {
collect_active_texts(&self.texts, t, out);
}
pub fn actors(&self) -> &[ActorTrack] {
&self.actors
}
pub fn active_actor_cues_into(&self, t: f32, out: &mut Vec<ActiveActorCue>) {
collect_active_actor_cues(&self.actors, t, out);
}
fn shot_index(&self, t: f32) -> usize {
self.shots.iter().rposition(|s| s.start <= t).unwrap_or(0)
}
fn sample_shot(&self, index: usize, t: f32, ctx: &EvalCtx) -> CameraPose {
let shot = &self.shots[index];
let local = (t - shot.start).clamp(0.0, shot.duration);
let mut position = shot.pos.position(local, shot.duration, ctx);
let fallback = ctx.prev_rot.unwrap_or(ctx.live.rotation);
let mut rotation = match &shot.look {
Look::Free => match &shot.pos {
PosSampler::Keys { .. } | PosSampler::KeysSpline { .. } => {
shot.pos.key_rotation(local).unwrap_or(fallback)
}
PosSampler::Rail { .. } => fallback,
PosSampler::Orbit { center, .. } => {
let center = resolve_target(center, ctx).unwrap_or(Vec3::ZERO);
look_toward(position, center, fallback)
}
},
Look::At { target, damping } => {
let aimed = resolve_target(target, ctx)
.map(|point| look_toward(position, point, fallback))
.unwrap_or(fallback);
match (damping, ctx.prev_rot) {
(Some(decay), Some(mut prev)) if ctx.dt > 0.0 => {
prev.smooth_nudge(&aimed, *decay, ctx.dt);
prev
}
_ => aimed,
}
}
Look::Velocity => {
let ahead = shot.pos.position(
(local + VELOCITY_STEP).min(shot.duration),
shot.duration,
ctx,
);
let behind =
shot.pos
.position((local - VELOCITY_STEP).max(0.0), shot.duration, ctx);
let dir = ahead - behind;
if dir.length_squared() < 1e-10 {
fallback
} else {
look_toward(position, position + dir, fallback)
}
}
};
if let Some(shake) = &shot.shake {
let (nudge, wobble) = shake_offset(shake, local);
position += rotation * nudge;
rotation *= wobble;
}
let dof = shot.focus.as_ref().map(|focus| {
let focal_distance = match focus {
FocusTrack::Distance(track) => track.sample(local),
FocusTrack::Target { target, offset } => resolve_target(target, ctx)
.map(|p| p.distance(position) + offset)
.unwrap_or(10.0),
}
.max(0.05);
DofParams {
focal_distance,
aperture_f_stops: shot
.aperture
.as_ref()
.map(|t| t.sample(local))
.unwrap_or(1.0)
.max(0.1),
sensor_height: shot.sensor_height,
bokeh: shot.bokeh,
}
});
CameraPose {
position,
rotation,
fov_y: shot.fov.radians_at(local),
dof,
exposure_ev100: shot.exposure.as_ref().map(|t| t.sample(local)),
}
}
pub fn pose_at(&self, t: f32, ctx: &EvalCtx) -> CameraPose {
let index = self.shot_index(t);
let shot = &self.shots[index];
let pose = self.sample_shot(index, t, ctx);
let Some(blend) = shot.blend_in else {
return pose;
};
let local = t - shot.start;
if local >= blend.secs || blend.secs <= 0.0 {
return pose;
}
let source = if index == 0 {
CameraPose::from_snapshot(ctx.live)
} else {
self.sample_shot(index - 1, t, ctx)
};
let w = blend
.ease
.sample_clamped((local / blend.secs).clamp(0.0, 1.0));
mix(&source, &pose, w)
}
pub fn markers_between(&self, prev: f32, cur: f32) -> impl Iterator<Item = &Marker> {
self.markers
.iter()
.filter(move |m| prev < m.time && m.time <= cur)
}
pub fn markers_between_backward(&self, cur: f32, prev: f32) -> impl Iterator<Item = &Marker> {
self.markers
.iter()
.filter(move |m| cur <= m.time && m.time < prev)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sequence::{Lens, Shot};
fn key(time: f32, x: f32) -> Key {
Key {
time,
pos: Vec3::new(x, 0.0, 0.0),
rot: Some(Quat::IDENTITY),
ease: EaseFunction::Linear,
}
}
fn shot(start: f32, duration: f32, keys: Vec<Key>, blend_in: Option<Blend>) -> Shot {
Shot {
start,
duration,
blend_in,
rig: Rig::Keys {
keys,
interp: KeyInterp::Eased,
},
look: Look::Free,
lens: Lens::default(),
shake: None,
}
}
fn live() -> CameraSnapshot {
CameraSnapshot {
position: Vec3::new(0.0, 100.0, 0.0),
rotation: Quat::IDENTITY,
fov_y: 1.0,
}
}
fn one_shot_sequence() -> SequenceAsset {
SequenceAsset {
name: "t".into(),
shots: vec![shot(0.0, 4.0, vec![key(0.0, 0.0), key(4.0, 8.0)], None)],
markers: vec![
Marker {
time: 0.0,
name: "zero".into(),
},
Marker {
time: 2.0,
name: "mid".into(),
},
],
texts: vec![],
actors: vec![],
blend_out: None,
}
}
#[test]
fn eval_holds_single_key() {
let seq = SequenceAsset::single_shot("t", shot(0.0, 2.0, vec![key(0.0, 3.0)], None));
let baked = bake(&seq).unwrap();
let live = live();
for t in [0.0, 1.0, 2.0, 99.0] {
assert_eq!(baked.pose_at(t, &EvalCtx::still(&live)).position.x, 3.0);
}
}
#[test]
fn linear_keys_interpolate_and_clamp() {
let baked = bake(&one_shot_sequence()).unwrap();
let live = live();
let ctx = EvalCtx::still(&live);
assert_eq!(baked.pose_at(2.0, &ctx).position.x, 4.0);
assert_eq!(baked.pose_at(-1.0, &ctx).position.x, 0.0);
assert_eq!(baked.pose_at(10.0, &ctx).position.x, 8.0);
assert!((baked.pose_at(0.0, &ctx).fov_y - 45f32.to_radians()).abs() < 1e-6);
}
#[test]
fn blend_window_mixes_prev_clamped() {
let seq = SequenceAsset {
name: "t".into(),
shots: vec![
shot(0.0, 2.0, vec![key(0.0, 0.0), key(2.0, 2.0)], None),
shot(
2.0,
2.0,
vec![key(0.0, 10.0)],
Some(Blend {
secs: 1.0,
ease: EaseFunction::Linear,
}),
),
],
markers: vec![],
texts: vec![],
actors: vec![],
blend_out: None,
};
let baked = bake(&seq).unwrap();
let live = live();
let ctx = EvalCtx::still(&live);
assert_eq!(baked.pose_at(2.0, &ctx).position.x, 2.0);
assert_eq!(baked.pose_at(2.5, &ctx).position.x, 6.0);
assert_eq!(baked.pose_at(3.0, &ctx).position.x, 10.0);
}
#[test]
fn first_shot_blends_from_live_snapshot() {
let seq = SequenceAsset::single_shot(
"t",
shot(
0.0,
2.0,
vec![key(0.0, 0.0)],
Some(Blend {
secs: 1.0,
ease: EaseFunction::Linear,
}),
),
);
let baked = bake(&seq).unwrap();
let live = live();
let ctx = EvalCtx::still(&live);
assert_eq!(
baked.pose_at(0.0, &ctx).position,
Vec3::new(0.0, 100.0, 0.0)
);
assert_eq!(baked.pose_at(0.5, &ctx).position.y, 50.0);
assert_eq!(baked.pose_at(1.0, &ctx).position.y, 0.0);
}
#[test]
fn marker_fires_on_forward_crossing_only_once() {
let baked = bake(&one_shot_sequence()).unwrap();
let hits: Vec<_> = baked.markers_between(1.9, 2.1).map(|m| &m.name).collect();
assert_eq!(hits, ["mid"]);
assert_eq!(baked.markers_between(2.0, 2.1).count(), 0);
assert_eq!(baked.markers_between(-f32::EPSILON, 0.5).count(), 1);
}
#[test]
fn bake_rejects_bad_sequences() {
let mut empty = one_shot_sequence();
empty.shots.clear();
assert!(matches!(bake(&empty), Err(BakeError::Empty)));
let mut unsorted = one_shot_sequence();
unsorted
.shots
.push(shot(0.0, 1.0, vec![key(0.0, 0.0)], None));
unsorted.shots[1].start = -1.0;
assert!(matches!(bake(&unsorted), Err(BakeError::ShotsUnsorted(1))));
let mut no_rot = one_shot_sequence();
no_rot.shots[0].rig = Rig::Keys {
keys: vec![Key {
rot: None,
..key(0.0, 0.0)
}],
interp: KeyInterp::Eased,
};
assert!(matches!(bake(&no_rot), Err(BakeError::MissingRotation(0))));
let mut short_rail = one_shot_sequence();
short_rail.shots[0].rig = Rig::Rail {
points: vec![Vec3::ZERO],
kind: RailKind::CatmullRom,
constant_speed: true,
progress: ScalarTrack::default(),
};
short_rail.shots[0].look = Look::Velocity;
assert!(matches!(
bake(&short_rail),
Err(BakeError::RailTooShort(0, 2))
));
let mut free_rail = one_shot_sequence();
free_rail.shots[0].rig = Rig::Rail {
points: vec![Vec3::ZERO, Vec3::X, Vec3::Y],
kind: RailKind::CatmullRom,
constant_speed: false,
progress: ScalarTrack::default(),
};
assert!(matches!(bake(&free_rail), Err(BakeError::RailNeedsLook(0))));
}
#[test]
fn focal_35mm_super35_gives_29_86_deg_vertical() {
let mut seq = one_shot_sequence();
seq.shots[0].lens.fov = FovSpec::FocalLengthMm {
track: ScalarTrack::constant(35.0),
filmback: crate::sequence::Filmback::Super35,
};
let baked = bake(&seq).unwrap();
let live = live();
let fov = baked
.pose_at(0.0, &EvalCtx::still(&live))
.fov_y
.to_degrees();
assert!((fov - 29.86).abs() < 0.05, "fov was {fov}");
}
#[test]
fn rail_constant_speed_covers_equal_distance_per_tick() {
let seq = SequenceAsset::single_shot(
"rail",
Shot {
start: 0.0,
duration: 4.0,
blend_in: None,
rig: Rig::Rail {
points: vec![
Vec3::ZERO,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(9.0, 0.0, 0.0),
Vec3::new(10.0, 0.0, 0.0),
],
kind: RailKind::CatmullRom,
constant_speed: true,
progress: ScalarTrack::default(),
},
look: Look::Velocity,
lens: Lens::default(),
shake: None,
},
);
let baked = bake(&seq).unwrap();
let live = live();
let ctx = EvalCtx::still(&live);
let points: Vec<Vec3> = (0..=8)
.map(|i| baked.pose_at(i as f32 * 0.5, &ctx).position)
.collect();
let gaps: Vec<f32> = points.windows(2).map(|p| p[0].distance(p[1])).collect();
let mean = gaps.iter().sum::<f32>() / gaps.len() as f32;
for gap in &gaps {
assert!(
(gap - mean).abs() / mean < 0.05,
"gaps {gaps:?} not constant"
);
}
}
#[test]
fn velocity_look_faces_rail_tangent() {
let seq = SequenceAsset::single_shot(
"rail",
Shot {
start: 0.0,
duration: 2.0,
blend_in: None,
rig: Rig::Rail {
points: vec![Vec3::ZERO, Vec3::new(0.0, 0.0, -10.0)],
kind: RailKind::CatmullRom,
constant_speed: false,
progress: ScalarTrack::default(),
},
look: Look::Velocity,
lens: Lens::default(),
shake: None,
},
);
let baked = bake(&seq).unwrap();
let live = live();
let pose = baked.pose_at(1.0, &EvalCtx::still(&live));
let forward = pose.rotation * Vec3::NEG_Z;
assert!(forward.z < -0.99, "forward was {forward}");
}
#[test]
fn orbit_positions_follow_spherical_convention() {
let seq = SequenceAsset::single_shot(
"orbit",
Shot {
start: 0.0,
duration: 2.0,
blend_in: None,
rig: Rig::Orbit {
center: TargetRef::Point(Vec3::new(1.0, 2.0, 3.0)),
radius: ScalarTrack::constant(5.0),
yaw_deg: ScalarTrack::constant(0.0),
pitch_deg: ScalarTrack::constant(0.0),
},
look: Look::Free,
lens: Lens::default(),
shake: None,
},
);
let baked = bake(&seq).unwrap();
let live = live();
let pose = baked.pose_at(0.0, &EvalCtx::still(&live));
assert!((pose.position - Vec3::new(1.0, 2.0, 8.0)).length() < 1e-4);
let forward = pose.rotation * Vec3::NEG_Z;
assert!(forward.z < -0.99, "forward was {forward}");
}
#[test]
fn look_at_damping_converges_deterministically() {
let seq = SequenceAsset::single_shot(
"aim",
Shot {
start: 0.0,
duration: 10.0,
blend_in: None,
rig: Rig::Keys {
keys: vec![Key::at(0.0).pos(Vec3::ZERO)],
interp: KeyInterp::Eased,
},
look: Look::At {
target: TargetRef::Point(Vec3::new(0.0, 0.0, -5.0)),
damping: Some(8.0),
},
lens: Lens::default(),
shake: None,
},
);
let baked = bake(&seq).unwrap();
let live = live();
let aimed = Transform::from_translation(Vec3::ZERO)
.looking_at(Vec3::new(0.0, 0.0, -5.0), Vec3::Y)
.rotation;
let mut prev = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
for _ in 0..60 {
let ctx = EvalCtx {
live: &live,
dt: 1.0 / 60.0,
prev_rot: Some(prev),
resolve_entity: &|_| None,
};
prev = baked.pose_at(1.0, &ctx).rotation;
}
assert!(prev.angle_between(aimed) < 0.01);
}
#[test]
fn entity_focus_pulls_distance_from_resolver() {
let seq = SequenceAsset::single_shot(
"focus",
Shot {
start: 0.0,
duration: 2.0,
blend_in: None,
rig: Rig::Keys {
keys: vec![Key::at(0.0).pos(Vec3::ZERO).rot(Quat::IDENTITY)],
interp: KeyInterp::Eased,
},
look: Look::Free,
lens: Lens {
focus: Some(FocusTrack::Target {
target: TargetRef::Entity("statue".into()),
offset: 0.5,
}),
aperture_f_stops: Some(ScalarTrack::constant(2.8)),
..Default::default()
},
shake: None,
},
);
let baked = bake(&seq).unwrap();
let live = live();
let resolve = |name: &str| (name == "statue").then_some(Vec3::new(0.0, 0.0, -4.0));
let ctx = EvalCtx {
live: &live,
dt: 0.0,
prev_rot: None,
resolve_entity: &resolve,
};
let dof = baked.pose_at(0.0, &ctx).dof.unwrap();
assert!((dof.focal_distance - 4.5).abs() < 1e-4);
assert!((dof.aperture_f_stops - 2.8).abs() < 1e-5);
}
#[test]
fn bake_clamps_malformed_texts_instead_of_failing() {
let mut seq = one_shot_sequence();
seq.texts = vec![TextBlock {
fade_in: -1.0,
fade_out: -2.0,
..TextBlock::at(0.0, -3.0, "broken")
}];
let baked = bake(&seq).expect("texts must never fail the bake");
let block = &baked.texts()[0];
assert_eq!(block.duration, 0.0);
assert_eq!(block.fade_in, 0.0);
assert_eq!(block.fade_out, 0.0);
}
#[test]
fn active_texts_report_overlaps_with_stable_indices() {
let mut seq = one_shot_sequence();
seq.texts = vec![
TextBlock::at(2.0, 2.0, "late").fades(1.0, 0.0),
TextBlock::at(0.0, 4.0, "early"),
];
let baked = bake(&seq).unwrap();
let mut out = Vec::new();
baked.active_texts_into(1.0, &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].index, 1);
assert_eq!(out[0].alpha, 1.0);
baked.active_texts_into(2.5, &mut out);
assert_eq!(out.len(), 2);
assert_eq!(out[0].index, 0);
assert!((out[0].alpha - 0.5).abs() < 1e-6);
assert_eq!(out[1].index, 1);
baked.active_texts_into(4.0, &mut out);
assert_eq!(out.len(), 0);
}
#[test]
fn texts_leave_marker_queries_untouched() {
let mut seq = one_shot_sequence();
seq.texts = vec![TextBlock::at(0.0, 4.0, "over everything")];
let baked = bake(&seq).unwrap();
let hit: Vec<_> = baked
.markers_between(-f32::EPSILON, 4.0)
.map(|m| m.name.clone())
.collect();
assert_eq!(hit, ["zero", "mid"]);
}
#[test]
fn active_actor_cues_report_overlaps_with_stable_indices() {
use crate::sequence::{ActorCue, ActorTrack};
let mut seq = one_shot_sequence();
seq.actors = vec![
ActorTrack::entity("player")
.cue(ActorCue::at(2.0, 2.0, "wake").fades(1.0, 0.0))
.cue(ActorCue::at(0.0, 4.0, "sleep")),
ActorTrack::entity("door").cue(ActorCue::at(2.5, 1.0, "open")),
];
let baked = bake(&seq).unwrap();
let mut out = Vec::new();
baked.active_actor_cues_into(1.0, &mut out);
assert_eq!(out.len(), 1);
assert_eq!((out[0].track, out[0].cue_index), (0, 1));
assert_eq!(out[0].cue.anim, "sleep");
assert_eq!(out[0].weight, 1.0);
assert_eq!(out[0].local_time, 1.0);
baked.active_actor_cues_into(2.5, &mut out);
assert_eq!(out.len(), 3);
assert_eq!((out[0].track, out[0].cue_index), (0, 0));
assert!((out[0].weight - 0.5).abs() < 1e-6);
assert_eq!((out[1].track, out[1].cue_index), (0, 1));
assert_eq!((out[2].track, out[2].cue_index), (1, 0));
assert!(matches!(&out[2].target, TargetRef::Entity(n) if n == "door"));
baked.active_actor_cues_into(4.0, &mut out);
assert_eq!(out.len(), 0);
}
#[test]
fn bake_normalizes_malformed_actor_cues_instead_of_failing() {
use crate::sequence::{ActorCue, ActorTrack};
let mut seq = one_shot_sequence();
seq.actors = vec![ActorTrack::entity("player").cue(ActorCue {
fade_in: -1.0,
fade_out: -2.0,
speed: -3.0,
..ActorCue::at(0.0, -3.0, "broken")
})];
let baked = bake(&seq).expect("actors must never fail the bake");
let cue = &baked.actors()[0].cues[0];
assert_eq!(cue.duration, 0.0);
assert_eq!(cue.fade_in, 0.0);
assert_eq!(cue.fade_out, 0.0);
assert_eq!(cue.speed, 1.0);
}
#[test]
fn actor_cues_leave_marker_queries_untouched() {
use crate::sequence::{ActorCue, ActorTrack};
let mut seq = one_shot_sequence();
seq.actors = vec![ActorTrack::entity("player").cue(ActorCue::at(0.0, 4.0, "sleep"))];
let baked = bake(&seq).unwrap();
let hit: Vec<_> = baked
.markers_between(-f32::EPSILON, 4.0)
.map(|m| m.name.clone())
.collect();
assert_eq!(hit, ["zero", "mid"]);
}
}