use bevy::{
input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll},
prelude::*,
window::{CursorGrabMode, CursorOptions},
};
use crate::{
CineCamera, DirectorSet,
eval::{
CameraSnapshot, CompiledSequence, EvalCtx, bake, collect_active_actor_cues,
collect_active_texts,
},
gizmos::{DirectorGizmoGroup, draw_playhead, draw_sequence},
player::{
ActiveActorCues, ActiveTexts, DirectorPhase, DirectorState, HandoffCamera, snapshot_of,
},
sequence::{FovSpec, Key, KeyInterp, Lens, Rig, ScalarKey, ScalarTrack, SequenceAsset, Shot},
};
const DEFAULT_SHOT_SECS: f32 = 5.0;
const REPLACE_WINDOW: f32 = 0.05;
const DELETE_RANGE: f32 = 2.0;
const MOUSE_SENSITIVITY: f32 = 0.0028;
#[derive(Resource)]
pub struct ViewfinderConfig {
pub toggle: Option<KeyCode>,
pub capture: KeyCode,
pub delete: KeyCode,
pub play_pause: KeyCode,
pub scrub_back: KeyCode,
pub scrub_forward: KeyCode,
pub scrub_step: f32,
pub fly_speed: f32,
pub auto_key: bool,
pub save_dir: std::path::PathBuf,
pub capture_ease: EaseFunction,
}
impl Default for ViewfinderConfig {
fn default() -> Self {
Self {
toggle: Some(KeyCode::Backquote),
capture: KeyCode::KeyK,
delete: KeyCode::Delete,
play_pause: KeyCode::KeyP,
scrub_back: KeyCode::BracketLeft,
scrub_forward: KeyCode::BracketRight,
scrub_step: 0.5,
fly_speed: 8.0,
auto_key: true,
save_dir: std::path::PathBuf::from("assets/sequences"),
capture_ease: EaseFunction::SmoothStep,
}
}
}
#[derive(Resource)]
pub struct ViewfinderSession {
pub sequence: SequenceAsset,
pub armed_shot: usize,
pub playhead: f32,
pub playing: bool,
yaw: f32,
pitch: f32,
pub(crate) fov_deg: f32,
speed: f32,
saved_cursor: Option<(bool, CursorGrabMode)>,
pub(crate) compiled: Option<CompiledSequence>,
pub(crate) live_snapshot: CameraSnapshot,
pub(crate) preview_prev_rot: Option<Quat>,
pub(crate) preview_lens: crate::player::LensTouch,
}
impl Default for ViewfinderSession {
fn default() -> Self {
Self {
sequence: SequenceAsset::empty("untitled"),
armed_shot: 0,
playhead: 0.0,
playing: false,
yaw: 0.0,
pitch: 0.0,
fov_deg: 45.0,
speed: 8.0,
saved_cursor: None,
compiled: None,
live_snapshot: CameraSnapshot {
position: Vec3::new(0.0, 2.0, 8.0),
rotation: Quat::IDENTITY,
fov_y: 45f32.to_radians(),
},
preview_prev_rot: None,
preview_lens: crate::player::LensTouch::default(),
}
}
}
impl ViewfinderSession {
pub(crate) fn rebake(&mut self) {
self.compiled = bake(&self.sequence).ok();
}
pub(crate) fn shot_at_playhead(&self) -> usize {
self.sequence
.shots
.iter()
.rposition(|shot| shot.start <= self.playhead)
.unwrap_or(0)
}
pub(crate) fn capture_frame(
&mut self,
pos: Vec3,
rot: Quat,
fov_deg: f32,
ease: EaseFunction,
) -> (usize, Option<usize>) {
let playhead = self.playhead;
if self.sequence.shots.is_empty() {
self.sequence.shots.push(Shot {
start: 0.0,
duration: DEFAULT_SHOT_SECS.max(playhead),
blend_in: None,
rig: Rig::Keys {
keys: Vec::new(),
interp: KeyInterp::Eased,
},
look: Default::default(),
lens: Lens {
fov: FovSpec::VerticalFovDeg(ScalarTrack { keys: Vec::new() }),
..Default::default()
},
shake: None,
camera: None,
});
}
let armed = self.shot_at_playhead();
self.armed_shot = armed;
let shot = &mut self.sequence.shots[armed];
let local = (playhead - shot.start).max(0.0);
if local > shot.duration {
shot.duration = local;
}
let mut camera_key = None;
if let Rig::Keys { keys, .. } = &mut shot.rig {
let key = Key {
time: local,
pos,
rot: Some(rot),
ease,
};
match keys
.iter()
.position(|key| (key.time - local).abs() <= REPLACE_WINDOW)
{
Some(index) => {
keys[index] = key;
camera_key = Some(index);
}
None => {
let at = keys.partition_point(|key| key.time < local);
keys.insert(at, key);
camera_key = Some(at);
}
}
}
if let FovSpec::VerticalFovDeg(track) = &mut shot.lens.fov {
let key = ScalarKey {
time: local,
value: fov_deg,
ease,
};
match track
.keys
.iter()
.position(|key| (key.time - local).abs() <= REPLACE_WINDOW)
{
Some(index) => track.keys[index] = key,
None => {
let at = track.keys.partition_point(|key| key.time < local);
track.keys.insert(at, key);
}
}
}
self.rebake();
(armed, camera_key)
}
pub(crate) fn sync_pilot_from_pose(&mut self, rotation: Quat, fov_y: f32) {
let (yaw, pitch, _) = rotation.to_euler(EulerRot::YXZ);
self.yaw = yaw;
self.pitch = pitch;
self.fov_deg = fov_y.to_degrees();
}
}
pub struct ViewfinderPlugin;
impl Plugin for ViewfinderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ViewfinderConfig>()
.init_resource::<ViewfinderSession>()
.init_gizmo_group::<DirectorGizmoGroup>()
.add_systems(
Update,
(
toggle_viewfinder,
fly_camera,
scrub_and_preview,
capture_key,
delete_key,
save_session,
draw_overlay,
)
.chain(),
)
.add_systems(
PostUpdate,
(update_session_texts, update_session_actor_cues).in_set(DirectorSet::Apply),
);
}
}
fn update_session_texts(
state: Res<DirectorState>,
session: Res<ViewfinderSession>,
mut active: ResMut<ActiveTexts>,
) {
if state.phase != DirectorPhase::Viewfinder {
return;
}
if session.sequence.texts.is_empty() && active.blocks.is_empty() {
return;
}
let blocks = &mut active.blocks;
collect_active_texts(&session.sequence.texts, session.playhead, blocks);
}
fn update_session_actor_cues(
state: Res<DirectorState>,
session: Res<ViewfinderSession>,
mut active: ResMut<ActiveActorCues>,
) {
if state.phase != DirectorPhase::Viewfinder {
return;
}
if session.sequence.actors.is_empty() && active.cues.is_empty() {
return;
}
let cues = &mut active.cues;
collect_active_actor_cues(&session.sequence.actors, session.playhead, cues);
}
fn viewfinder_up(state: &DirectorState) -> bool {
state.phase == DirectorPhase::Viewfinder
}
#[allow(clippy::too_many_arguments)] fn toggle_viewfinder(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
mut state: ResMut<DirectorState>,
mut cameras: ParamSet<(
Query<
(
Entity,
&Camera,
&Transform,
Option<&Projection>,
Has<HandoffCamera>,
),
(With<Camera3d>, Without<CineCamera>),
>,
Query<&mut Camera>,
)>,
cine_query: Query<Entity, With<CineCamera>>,
mut transforms: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
mut cursor: Single<&mut CursorOptions>,
) {
let Some(toggle) = config.toggle else {
return;
};
if !keys.just_pressed(toggle) {
return;
}
if viewfinder_up(&state) {
if let Some((visible, grab)) = session.saved_cursor.take() {
cursor.visible = visible;
cursor.grab_mode = grab;
}
let mut writable = cameras.p1();
crate::player::restore_live(&mut commands, &mut state, &mut writable);
return;
}
if state.phase != DirectorPhase::Idle {
warn!("viewfinder only opens while the director is idle");
return;
}
let live = {
let p0 = cameras.p0();
p0.iter()
.find(|(_, _, _, _, marked)| *marked)
.or_else(|| p0.iter().find(|(_, cam, ..)| cam.is_active))
.map(|(e, _, t, p, _)| (e, snapshot_of(t, p)))
};
let live_snapshot = live.map(|(_, s)| s).unwrap_or(CameraSnapshot {
position: Vec3::new(0.0, 2.0, 8.0),
rotation: Quat::IDENTITY,
fov_y: 45f32.to_radians(),
});
let existing = cine_query.iter().next();
let borrowed = existing.is_none() && live.is_some();
let cine = existing.or(live.map(|(e, _)| e)).unwrap_or_else(|| {
commands
.spawn((
Name::new("cine camera"),
CineCamera,
Camera3d::default(),
Camera {
is_active: true,
..Default::default()
},
Projection::Perspective(PerspectiveProjection {
fov: live_snapshot.fov_y,
..Default::default()
}),
Transform::from_translation(live_snapshot.position)
.with_rotation(live_snapshot.rotation),
))
.id()
});
commands
.entity(cine)
.queue(crate::player::begin_take(if borrowed {
crate::player::TakeRole::Borrowed
} else {
crate::player::TakeRole::Dedicated
}));
if !borrowed && let Ok((mut transform, mut projection)) = transforms.get_mut(cine) {
transform.translation = live_snapshot.position;
transform.rotation = live_snapshot.rotation;
if let Projection::Perspective(p) = &mut *projection {
p.fov = live_snapshot.fov_y;
}
}
let (yaw, pitch, _) = live_snapshot.rotation.to_euler(EulerRot::YXZ);
session.yaw = yaw;
session.pitch = pitch;
session.fov_deg = live_snapshot.fov_y.to_degrees();
session.speed = config.fly_speed;
session.playing = false;
session.live_snapshot = live_snapshot;
session.preview_prev_rot = None;
session.preview_lens = crate::player::LensTouch::default();
session.rebake();
if !borrowed {
let mut writable = cameras.p1();
if let Some((live_entity, _)) = live
&& let Ok(mut cam) = writable.get_mut(live_entity)
{
cam.is_active = false;
}
if let Ok(mut cam) = writable.get_mut(cine) {
cam.is_active = true;
}
}
session.saved_cursor = Some((cursor.visible, cursor.grab_mode));
cursor.visible = false;
cursor.grab_mode = CursorGrabMode::Locked;
state.phase = DirectorPhase::Viewfinder;
state.camera = Some(cine);
state.anchor = Some(cine);
state.live_camera = live.map(|(e, _)| e);
info!(
"viewfinder up: fly WASD/QE, K captures, [ ] scrub, P previews, ctrl+S saves '{}'",
session.sequence.name
);
}
#[allow(clippy::too_many_arguments)] fn fly_camera(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
motion: Res<AccumulatedMouseMotion>,
scroll: Res<AccumulatedMouseScroll>,
time: Res<Time<Real>>,
mut cine: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || session.playing {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.open) {
return;
}
let Some(camera) = state.camera else {
return;
};
let Ok((mut transform, mut projection)) = cine.get_mut(camera) else {
return;
};
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let lens_changed = ctrl && scroll.delta.y != 0.0;
if ctrl {
session.fov_deg = (session.fov_deg - scroll.delta.y * 1.5).clamp(10.0, 120.0);
} else if scroll.delta.y.abs() > 0.0 {
session.speed = (session.speed * 1.1f32.powf(scroll.delta.y)).clamp(0.5, 200.0);
}
session.yaw -= motion.delta.x * MOUSE_SENSITIVITY;
session.pitch = (session.pitch - motion.delta.y * MOUSE_SENSITIVITY).clamp(-1.54, 1.54);
transform.rotation = Quat::from_euler(EulerRot::YXZ, session.yaw, session.pitch, 0.0);
let mut wish = Vec3::ZERO;
let flat_forward = transform.forward().as_vec3();
let right = transform.right().as_vec3();
if keys.pressed(KeyCode::KeyW) {
wish += flat_forward;
}
if keys.pressed(KeyCode::KeyS) {
wish -= flat_forward;
}
if keys.pressed(KeyCode::KeyD) {
wish += right;
}
if keys.pressed(KeyCode::KeyA) {
wish -= right;
}
if keys.pressed(KeyCode::KeyE) {
wish += Vec3::Y;
}
if keys.pressed(KeyCode::KeyQ) {
wish -= Vec3::Y;
}
let sprint = if keys.pressed(KeyCode::ShiftLeft) {
3.0
} else {
1.0
};
transform.translation += wish.normalize_or_zero() * session.speed * sprint * time.delta_secs();
if let Projection::Perspective(p) = &mut *projection {
p.fov = session.fov_deg.to_radians();
}
let camera_changed = motion.delta != Vec2::ZERO || wish != Vec3::ZERO || lens_changed;
if config.auto_key && camera_changed && !session.sequence.shots.is_empty() {
let fov_deg = session.fov_deg;
session.capture_frame(
transform.translation,
transform.rotation,
fov_deg,
config.capture_ease,
);
}
}
#[allow(clippy::too_many_arguments)] fn scrub_and_preview(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
motion: Res<AccumulatedMouseMotion>,
time: Res<Time<Real>>,
names: Query<(&Name, &GlobalTransform)>,
mut cine: Query<(&mut Transform, &mut Projection), With<CineCamera>>,
pre_take: Query<&crate::player::PreTakeState>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) {
return;
}
#[cfg(feature = "editor")]
if editor.as_ref().is_some_and(|editor| editor.typing()) {
return;
}
let step = if keys.pressed(KeyCode::ShiftLeft) {
1.0 / 30.0
} else {
config.scrub_step
};
let mut moved = false;
let mut advancing = false;
if keys.just_pressed(config.scrub_back) {
session.playhead = (session.playhead - step).max(0.0);
moved = true;
}
if keys.just_pressed(config.scrub_forward) {
session.playhead += step;
moved = true;
}
if keys.just_pressed(config.play_pause) {
session.playing = !session.playing;
if session.playing {
session.preview_prev_rot = None;
if session.playhead >= session.sequence.duration() {
session.playhead = 0.0;
}
}
moved = true;
}
if session.playing {
session.playhead += time.delta_secs();
if session.playhead >= session.sequence.duration() {
session.playing = false;
}
#[cfg(feature = "editor")]
let editor_open = editor.is_some_and(|editor| editor.open);
#[cfg(not(feature = "editor"))]
let editor_open = false;
if motion.delta.length_squared() > 4.0 && !editor_open {
session.playing = false;
return;
}
moved = true;
advancing = true;
}
if !moved {
return;
}
let duration = session.sequence.duration();
session.playhead = session.playhead.clamp(0.0, duration.max(0.0));
let Some(camera) = state.camera else {
return;
};
let Ok((mut transform, mut projection)) = cine.get_mut(camera) else {
return;
};
let live = session.live_snapshot;
let resolve = |wanted: &str| {
names
.iter()
.find(|(name, _)| name.as_str() == wanted)
.map(|(_, gt)| gt.translation())
};
let ctx = EvalCtx {
live: &live,
dt: if advancing { time.delta_secs() } else { 0.0 },
prev_rot: if advancing {
session.preview_prev_rot
} else {
None
},
resolve_entity: &resolve,
held: pre_take.get(camera).ok().map(|p| p.held_snapshot()),
};
let Some(compiled) = &session.compiled else {
return;
};
let pose = compiled.pose_at(session.playhead, &ctx);
transform.translation = pose.position;
transform.rotation = pose.rotation;
if let Projection::Perspective(p) = &mut *projection {
p.fov = pose.fov_y;
}
let touch = crate::player::LensTouch::of(&pose);
crate::player::apply_lens_components(
&mut commands,
camera,
&pose,
touch.union(session.preview_lens),
pre_take.get(camera).ok().and_then(|p| p.grading()),
);
session.preview_lens = touch;
session.preview_prev_rot = Some(pose.rotation);
session.sync_pilot_from_pose(pose.rotation, pose.fov_y);
}
fn capture_key(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
cine: Query<&Transform, With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || !keys.just_pressed(config.capture) {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.typing()) {
return;
}
let Some(transform) = state.camera.and_then(|e| cine.get(e).ok()) else {
return;
};
let fov_deg = session.fov_deg;
session.capture_frame(
transform.translation,
transform.rotation,
fov_deg,
config.capture_ease,
);
let armed = session.armed_shot;
let local = (session.playhead - session.sequence.shots[armed].start).max(0.0);
info!(
"key at {:.2}s in shot {} ({} keys)",
local,
armed,
key_count(&session.sequence, armed)
);
}
fn key_count(sequence: &SequenceAsset, shot: usize) -> usize {
match &sequence.shots[shot].rig {
Rig::Keys { keys, .. } => keys.len(),
_ => 0,
}
}
fn delete_key(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
mut session: ResMut<ViewfinderSession>,
cine: Query<&Transform, With<CineCamera>>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || !keys.just_pressed(config.delete) {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.typing()) {
return;
}
let Some(transform) = state.camera.and_then(|e| cine.get(e).ok()) else {
return;
};
let from = transform.translation;
let mut best: Option<(usize, usize, f32)> = None;
for (si, shot) in session.sequence.shots.iter().enumerate() {
let Rig::Keys { keys, .. } = &shot.rig else {
continue;
};
for (ki, key) in keys.iter().enumerate() {
let d = key.pos.distance(from);
if d <= DELETE_RANGE && best.is_none_or(|(_, _, bd)| d < bd) {
best = Some((si, ki, d));
}
}
}
let Some((si, ki, _)) = best else {
info!("no key within {DELETE_RANGE} m to delete");
return;
};
let Rig::Keys { keys, .. } = &mut session.sequence.shots[si].rig else {
return;
};
let removed = keys.remove(ki);
session.rebake();
info!("removed key at {:.2}s from shot {si}", removed.time);
}
fn save_session(
keys: Res<ButtonInput<KeyCode>>,
state: Res<DirectorState>,
config: Res<ViewfinderConfig>,
session: Res<ViewfinderSession>,
#[cfg(feature = "editor")] editor: Option<Res<crate::editor::DirectorsCutState>>,
) {
if !viewfinder_up(&state) || !keys.just_pressed(KeyCode::KeyS) {
return;
}
#[cfg(feature = "editor")]
if editor.is_some_and(|editor| editor.typing()) {
return;
}
if !(keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight)) {
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = config
.save_dir
.join(format!("{}.dir.ron", session.sequence.name));
match crate::loader::save_sequence(&session.sequence, &path) {
Ok(()) => info!("saved {}", path.display()),
Err(err) => error!("could not save sequence: {err}"),
}
}
#[cfg(target_arch = "wasm32")]
warn!("saving sequences is native-only");
}
fn draw_overlay(
state: Res<DirectorState>,
session: Res<ViewfinderSession>,
mut gizmos: Gizmos<DirectorGizmoGroup>,
) {
if !viewfinder_up(&state) {
return;
}
draw_sequence(&mut gizmos, &session.sequence);
if let Some(compiled) = &session.compiled {
let live = session.live_snapshot;
let pose = compiled.pose_at(session.playhead, &EvalCtx::still(&live));
draw_playhead(&mut gizmos, pose.position, pose.rotation);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn two_shot_session() -> ViewfinderSession {
let mut session = ViewfinderSession::default();
session.sequence.shots = vec![
Shot::keys(
0.0,
2.0,
vec![Key::at(0.0).pos(Vec3::ZERO).rot(Quat::IDENTITY)],
),
Shot::keys(
2.0,
2.0,
vec![Key::at(0.0).pos(Vec3::X).rot(Quat::IDENTITY)],
),
];
session
}
#[test]
fn capture_targets_the_shot_under_the_playhead() {
let mut session = two_shot_session();
session.armed_shot = 0;
session.playhead = 3.0;
let (shot, key) =
session.capture_frame(Vec3::splat(2.0), Quat::IDENTITY, 45.0, EaseFunction::Linear);
assert_eq!(shot, 1);
assert_eq!(key, Some(1));
assert_eq!(session.armed_shot, 1);
let Rig::Keys { keys, .. } = &session.sequence.shots[1].rig else {
panic!("expected key rig");
};
assert_eq!(keys.len(), 2);
assert!((keys[1].time - 1.0).abs() < 1e-6);
let Rig::Keys { keys, .. } = &session.sequence.shots[0].rig else {
panic!("expected key rig");
};
assert_eq!(keys.len(), 1);
}
#[test]
fn capture_replaces_within_the_window_and_reports_the_index() {
let mut session = two_shot_session();
session.playhead = 3.0;
let first = session.capture_frame(Vec3::ONE, Quat::IDENTITY, 45.0, EaseFunction::Linear);
let second =
session.capture_frame(Vec3::splat(9.0), Quat::IDENTITY, 45.0, EaseFunction::Linear);
assert_eq!(first, second);
let Rig::Keys { keys, .. } = &session.sequence.shots[1].rig else {
panic!("expected key rig");
};
assert_eq!(keys.len(), 2);
assert_eq!(keys[1].pos, Vec3::splat(9.0));
}
#[test]
fn capture_past_the_last_shot_extends_it() {
let mut session = two_shot_session();
session.playhead = 5.0;
let (shot, _) =
session.capture_frame(Vec3::ZERO, Quat::IDENTITY, 45.0, EaseFunction::Linear);
assert_eq!(shot, 1);
assert!((session.sequence.shots[1].duration - 3.0).abs() < 1e-6);
}
#[test]
fn first_capture_creates_a_shot() {
let mut session = ViewfinderSession::default();
let (shot, key) =
session.capture_frame(Vec3::ZERO, Quat::IDENTITY, 45.0, EaseFunction::Linear);
assert_eq!((shot, key), (0, Some(0)));
assert_eq!(session.sequence.shots.len(), 1);
}
#[test]
fn auto_key_defaults_on() {
assert!(ViewfinderConfig::default().auto_key);
}
#[test]
fn toggle_defaults_to_backquote_and_can_be_turned_off() {
assert_eq!(ViewfinderConfig::default().toggle, Some(KeyCode::Backquote));
let silent = ViewfinderConfig {
toggle: None,
..Default::default()
};
assert_eq!(silent.toggle, None);
}
}