use super::super::framing::{CameraPose, bounding_sphere, fit_distance};
use super::super::registry::ID_BASE;
use super::super::widget;
use crate::ecs::World;
use crate::ecs::asset_id::AssetId;
pub(crate) const FADE: AssetId = AssetId(ID_BASE + 0xD000);
const SHOT_SECS: f32 = 13.0;
const SPIN_SECS: f32 = 26.0;
const FADE_SECS: f32 = 1.1;
const MAX_DT: f32 = 0.1;
const ORBIT_DISTANCE: f32 = 1.15;
const DRIFT_DISTANCE: f32 = 1.2;
const ELEVATION: f32 = 0.24;
const ORBIT_RATE: f32 = 0.045;
const DRIFT_SPAN: f32 = 0.5;
const ORBIT_START: f32 = 0.6;
const DRIFT_AZIMUTH: f32 = 4.1;
const MIN_RADIUS: f32 = 1.0e-3;
const MAX_FRAMED_RADIUS: f32 = 18.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Shot {
Orbit,
Spin,
Drift,
}
impl Shot {
const fn secs(self) -> f32 {
match self {
Shot::Spin => SPIN_SECS,
_ => SHOT_SECS,
}
}
}
const CYCLE: [Shot; 3] = [Shot::Orbit, Shot::Spin, Shot::Drift];
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Framing {
pub center: [f32; 3],
pub radius: f32,
pub distance: f32,
pub home: CameraPose,
}
impl Framing {
pub(crate) fn new(
mn: [f32; 3],
mx: [f32; 3],
fov_y_radians: f32,
aspect: f32,
home: CameraPose,
) -> Option<Framing> {
if !mn.iter().chain(mx.iter()).all(|c| c.is_finite()) {
return None;
}
let (bounds_center, extent) = bounding_sphere(mn, mx);
if extent <= MIN_RADIUS {
return None;
}
let radius = extent.min(MAX_FRAMED_RADIUS);
let distance = fit_distance(radius, fov_y_radians, aspect);
if !distance.is_finite() {
return None;
}
let center = match extent > MAX_FRAMED_RADIUS {
true => ahead_of(&home, distance),
false => bounds_center,
};
Some(Framing {
center,
radius,
distance,
home,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Cinematic {
shot: usize,
elapsed: f32,
}
impl Cinematic {
pub(crate) fn new() -> Self {
Cinematic {
shot: 0,
elapsed: 0.0,
}
}
pub(crate) fn advance(&mut self, dt: f32) {
self.elapsed += dt.clamp(0.0, MAX_DT);
while self.elapsed >= self.shot().secs() {
self.elapsed -= self.shot().secs();
self.shot = (self.shot + 1) % CYCLE.len();
}
}
pub(crate) fn shot(&self) -> Shot {
CYCLE[self.shot]
}
pub(crate) fn fade_alpha(&self) -> f32 {
let (t, secs) = (self.elapsed, self.shot().secs());
let alpha = if t < FADE_SECS {
1.0 - t / FADE_SECS
} else if t > secs - FADE_SECS {
(t - (secs - FADE_SECS)) / FADE_SECS
} else {
0.0
};
alpha.clamp(0.0, 1.0)
}
pub(crate) fn pose(&self, f: &Framing) -> CameraPose {
let t = self.elapsed;
match self.shot() {
Shot::Orbit => ring_pose(f, ORBIT_START + ORBIT_RATE * t, f.distance * ORBIT_DISTANCE),
Shot::Spin => CameraPose {
yaw: f.home.yaw + std::f32::consts::TAU * (t / SPIN_SECS),
..f.home
},
Shot::Drift => {
let s = t / SHOT_SECS - 0.5;
let base = ring_pose(f, DRIFT_AZIMUTH, f.distance * DRIFT_DISTANCE);
let right = right_of(DRIFT_AZIMUTH);
let offset = DRIFT_SPAN * f.radius * s;
let position = [
base.position[0] + right[0] * offset,
base.position[1],
base.position[2] + right[2] * offset,
];
aimed(position, f.center)
}
}
}
}
fn ahead_of(home: &CameraPose, distance: f32) -> [f32; 3] {
let (sin, cos) = home.yaw.sin_cos();
[
home.position[0] - sin * distance,
home.position[1],
home.position[2] - cos * distance,
]
}
fn ring_pose(f: &Framing, azimuth: f32, distance: f32) -> CameraPose {
let horizontal = distance * ELEVATION.cos();
let position = [
f.center[0] + azimuth.sin() * horizontal,
f.center[1] + distance * ELEVATION.sin(),
f.center[2] + azimuth.cos() * horizontal,
];
aimed(position, f.center)
}
fn right_of(azimuth: f32) -> [f32; 3] {
[azimuth.cos(), 0.0, -azimuth.sin()]
}
fn aimed(position: [f32; 3], target: [f32; 3]) -> CameraPose {
let d = [
target[0] - position[0],
target[1] - position[1],
target[2] - position[2],
];
let flat = (d[0] * d[0] + d[2] * d[2]).sqrt();
CameraPose {
position,
yaw: (-d[0]).atan2(-d[2]),
pitch: d[1].atan2(flat),
}
}
pub(crate) fn apply(world: &mut World, vp: [f32; 2], alpha: f32) {
let tint = [0.0, 0.0, 0.0, alpha.clamp(0.0, 1.0)];
widget::place_sprite(world, FADE, [0.0, 0.0, vp[0], vp[1]], tint, alpha > 0.0);
}
pub(crate) fn hide(world: &mut World) {
widget::set_sprite_visible(world, FADE, false);
}
#[cfg(test)]
mod tests {
use super::super::super::framing::forward;
use super::*;
const FOV: f32 = std::f32::consts::FRAC_PI_3;
const ASPECT: f32 = 16.0 / 9.0;
const HOME: CameraPose = CameraPose {
position: [1.0, 1.7, 9.0],
yaw: 0.3,
pitch: -0.05,
};
fn framing() -> Framing {
Framing::new([-4.0, 0.0, -4.0], [4.0, 3.0, 4.0], FOV, ASPECT, HOME)
.expect("a real box frames")
}
fn at(shot: Shot) -> Cinematic {
let mut c = Cinematic::new();
while c.shot() != shot {
c.advance(MAX_DT);
}
c
}
fn range(p: &CameraPose, f: &Framing) -> f32 {
let d = [
p.position[0] - f.center[0],
p.position[1] - f.center[1],
p.position[2] - f.center[2],
];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
fn aim_error(p: &CameraPose, f: &Framing) -> f32 {
let fw = forward(p.yaw, p.pitch);
let d = range(p, f);
(0..3)
.map(|a| (p.position[a] + fw[a] * d - f.center[a]).abs())
.fold(0.0_f32, f32::max)
}
#[test]
fn the_cycle_walks_every_shot_and_wraps() {
let mut c = Cinematic::new();
assert_eq!(c.shot(), Shot::Orbit);
let step = |c: &mut Cinematic| {
let held = c.shot();
while c.shot() == held {
c.advance(MAX_DT);
}
};
step(&mut c);
assert_eq!(c.shot(), Shot::Spin);
step(&mut c);
assert_eq!(c.shot(), Shot::Drift);
step(&mut c);
assert_eq!(c.shot(), Shot::Orbit, "the cycle wraps");
assert!(Shot::Spin.secs() > Shot::Orbit.secs());
}
#[test]
fn a_hitch_cannot_skip_a_shot() {
let mut c = Cinematic::new();
c.advance(3.0 * SHOT_SECS);
assert_eq!(c.shot(), Shot::Orbit);
assert!((c.elapsed - MAX_DT).abs() < 1e-6);
c.advance(-5.0);
assert!((c.elapsed - MAX_DT).abs() < 1e-6);
}
#[test]
fn the_fade_is_black_at_every_boundary_and_clear_between() {
let mut c = Cinematic::new();
assert_eq!(c.fade_alpha(), 1.0, "a cycle opens on black");
while c.elapsed < FADE_SECS * 0.5 {
c.advance(MAX_DT);
}
let alpha = c.fade_alpha();
assert!((0.4..0.6).contains(&alpha), "fading in: {alpha}");
while c.elapsed < SHOT_SECS * 0.5 {
c.advance(MAX_DT);
}
assert_eq!(c.fade_alpha(), 0.0);
let mut closing = 0.0;
while c.shot() == Shot::Orbit {
closing = c.fade_alpha();
c.advance(MAX_DT);
}
assert!(closing > 0.9, "the shot closes on black: {closing}");
assert_eq!(c.shot(), Shot::Spin);
assert!(c.fade_alpha() > 0.9, "and the next opens on it");
}
#[test]
fn a_reset_mid_fade_starts_over_and_clears() {
let mut c = Cinematic::new();
while c.elapsed < SHOT_SECS - FADE_SECS * 0.5 {
c.advance(MAX_DT);
}
assert!(c.fade_alpha() > 0.0, "mid fade-out");
let mut fresh = Cinematic::new();
assert_eq!(fresh.fade_alpha(), 1.0);
assert_eq!(fresh.shot(), Shot::Orbit, "a new preview opens on shot one");
let mut last = 1.0;
while fresh.elapsed < FADE_SECS {
fresh.advance(MAX_DT);
let alpha = fresh.fade_alpha();
assert!(alpha <= last, "the fade only ever clears: {alpha} > {last}");
last = alpha;
}
assert_eq!(fresh.fade_alpha(), 0.0);
}
#[test]
fn framing_needs_real_bounds() {
assert_eq!(Framing::new([1.0; 3], [1.0; 3], FOV, ASPECT, HOME), None);
assert_eq!(
Framing::new([f32::NEG_INFINITY; 3], [1.0; 3], FOV, ASPECT, HOME),
None
);
assert_eq!(
Framing::new([0.0; 3], [f32::NAN; 3], FOV, ASPECT, HOME),
None
);
let street = Framing::new([-200.0, 0.0, -30.0], [200.0, 40.0, 30.0], FOV, ASPECT, HOME)
.expect("a street frames");
assert_eq!(street.radius, MAX_FRAMED_RADIUS);
assert!(
street.distance < 4.0 * MAX_FRAMED_RADIUS,
"and stands a shot's distance out, not the street's: {}",
street.distance
);
assert_eq!(
street.center[1], HOME.position[1],
"the subject is at the camera's own height, never up in the sky"
);
let fw = forward(HOME.yaw, 0.0);
for a in [0, 2] {
let ahead = HOME.position[a] + fw[a] * street.distance;
assert!(
(street.center[a] - ahead).abs() < 1e-3,
"and straight ahead of it"
);
}
let f = framing();
assert!(f.radius < MAX_FRAMED_RADIUS, "a small world frames whole");
assert_eq!(f.center, [0.0, 1.5, 0.0], "on the bounds it fits");
assert!(
f.distance > f.radius,
"the camera stands outside the bounds"
);
assert_eq!(f.home, HOME, "and the world's own camera is carried");
}
#[test]
fn the_moving_shots_stand_outside_the_bounds_and_look_at_them() {
let f = framing();
let mut c = Cinematic::new();
for _ in 0..((Shot::Orbit.secs() + SPIN_SECS + Shot::Drift.secs()) / MAX_DT) as usize {
if c.shot() != Shot::Spin {
let p = c.pose(&f);
assert!(
range(&p, &f) > f.radius,
"{:?} sits inside the bounds",
c.shot()
);
assert!(
p.position[1] > f.center[1],
"{:?} looks down on the world",
c.shot()
);
assert!(aim_error(&p, &f) < 1e-3, "{:?} loses the centre", c.shot());
}
c.advance(MAX_DT);
}
}
#[test]
fn the_orbit_sweeps_one_way_at_a_calm_rate() {
let f = framing();
let mut c = Cinematic::new();
let start = c.pose(&f);
let mut last = start.yaw;
let mut swept = 0.0_f32;
while c.elapsed + MAX_DT < Shot::Orbit.secs() {
c.advance(MAX_DT);
let yaw = c.pose(&f).yaw;
let step = shortest(yaw - last);
assert!(step >= 0.0, "the sweep never reverses: {step}");
swept += step.abs();
last = yaw;
}
assert!(swept > 0.1, "the orbit moves: {swept}");
assert!(swept < std::f32::consts::PI * 0.5, "and slowly: {swept}");
let end = c.pose(&f);
assert!((range(&start, &f) - range(&end, &f)).abs() < 1e-3);
}
#[test]
fn the_spin_turns_once_where_the_worlds_camera_stands() {
let f = framing();
let mut c = at(Shot::Spin);
assert_eq!(c.pose(&f).position, HOME.position, "it opens at home");
let mut swept = 0.0_f32;
let mut last = c.pose(&f).yaw;
while c.shot() == Shot::Spin {
c.advance(MAX_DT);
if c.shot() != Shot::Spin {
break;
}
let p = c.pose(&f);
assert_eq!(p.position, HOME.position, "the camera never leaves home");
assert_eq!(p.pitch, HOME.pitch, "nor changes the tilt it was set at");
let step = shortest(p.yaw - last);
assert!(step >= 0.0, "the turn never reverses: {step}");
swept += step;
last = p.yaw;
}
let turn = std::f32::consts::TAU;
assert!(
(swept - turn).abs() < 0.05,
"a full turn and no more: {swept}"
);
assert!(turn / SPIN_SECS < 0.3, "at a calm rate");
}
#[test]
fn the_drift_trucks_sideways_holding_the_centre() {
let f = framing();
let mut c = at(Shot::Drift);
let opened = c.pose(&f);
while c.elapsed + MAX_DT < Shot::Drift.secs() {
c.advance(MAX_DT);
}
let closed = c.pose(&f);
let travelled = ((closed.position[0] - opened.position[0]).powi(2)
+ (closed.position[2] - opened.position[2]).powi(2))
.sqrt();
let span = DRIFT_SPAN * f.radius;
assert!(
travelled > span * 0.9 && travelled <= span + 1e-3,
"the truck covers its span: {travelled} of {span}"
);
assert!(
(closed.position[1] - opened.position[1]).abs() < 1e-6,
"and stays level"
);
assert!(aim_error(&closed, &f) < 1e-3);
}
fn shortest(delta: f32) -> f32 {
use std::f32::consts::{PI, TAU};
(delta + PI).rem_euclid(TAU) - PI
}
}