use bevy::{
camera::Exposure,
core_pipeline::prepass::MotionVectorPrepass,
post_process::{
dof::{DepthOfField, DepthOfFieldMode},
effect_stack::{ChromaticAberration, LensDistortion, Vignette},
motion_blur::MotionBlur,
},
prelude::*,
render::view::ColorGrading,
};
use crate::{
eval::{
ActiveActorCue, ActiveText, CameraPose, CameraSnapshot, CompiledSequence, EvalCtx, bake,
mix,
},
letterbox::LetterboxSettings,
sequence::{Blend, SequenceAsset},
};
#[derive(Component, Default)]
pub struct CineCamera;
#[derive(Component)]
pub struct HandoffCamera;
#[derive(Component)]
pub struct SequencePlayer {
pub sequence: Handle<SequenceAsset>,
pub playhead: f32,
pub rate: f32,
pub playback: Playback,
pub clock: ClockSource,
pub loop_mode: LoopMode,
}
impl SequencePlayer {
pub fn new(sequence: Handle<SequenceAsset>) -> Self {
Self {
sequence,
playhead: 0.0,
rate: 1.0,
playback: Playback::Playing,
clock: ClockSource::default(),
loop_mode: LoopMode::default(),
}
}
pub fn seek(&mut self, t: f32) {
self.playhead = t.max(0.0);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Playback {
Stopped,
#[default]
Playing,
Paused,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClockSource {
#[default]
Virtual,
Real,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LoopMode {
#[default]
Once,
Loop,
PingPong,
}
#[derive(Resource, Default)]
pub struct DirectorState {
pub phase: DirectorPhase,
pub camera: Option<Entity>,
pub anchor: Option<Entity>,
pub live_camera: Option<Entity>,
}
impl DirectorState {
pub fn is_borrowing(&self) -> bool {
self.anchor.is_some() && self.anchor == self.live_camera
}
pub fn take_anchor(&self) -> Option<Entity> {
self.anchor.or(self.camera)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DirectorPhase {
#[default]
Idle,
Shooting,
Handback,
Viewfinder,
}
#[derive(Resource, Default, Debug, Clone)]
pub struct ActiveTexts {
pub blocks: Vec<ActiveText>,
}
#[derive(Resource, Default, Debug, Clone)]
pub struct ActiveActorCues {
pub cues: Vec<ActiveActorCue>,
}
pub fn director_idle(state: Res<DirectorState>) -> bool {
state.phase == DirectorPhase::Idle
}
pub fn director_active(state: Res<DirectorState>) -> bool {
state.phase != DirectorPhase::Idle
}
pub fn gameplay_camera_free(state: Res<DirectorState>) -> bool {
matches!(state.phase, DirectorPhase::Idle | DirectorPhase::Handback)
}
#[derive(Message, Debug, Clone)]
pub struct SequenceStarted {
pub camera: Entity,
pub sequence: AssetId<SequenceAsset>,
}
#[derive(Message, Debug, Clone)]
pub struct SequenceCut {
pub from: Entity,
pub to: Entity,
pub shot: usize,
}
#[derive(Message, Debug, Clone)]
pub struct MarkerReached {
pub camera: Entity,
pub name: String,
pub time: f32,
}
#[derive(Message, Debug, Clone)]
pub struct SequenceFinished {
pub camera: Entity,
pub reason: FinishReason,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FinishReason {
Completed,
Skipped,
Stopped,
}
#[derive(Message)]
pub(crate) enum DirectorRequest {
Play {
handle: Handle<SequenceAsset>,
options: PlayOptions,
},
Skip,
Stop,
}
#[derive(Clone, Debug)]
pub struct PlayOptions {
pub rate: f32,
pub clock: ClockSource,
pub loop_mode: LoopMode,
pub blend_out: Option<Blend>,
pub letterbox: Option<f32>,
}
impl Default for PlayOptions {
fn default() -> Self {
Self {
rate: 1.0,
clock: ClockSource::default(),
loop_mode: LoopMode::default(),
blend_out: None,
letterbox: None,
}
}
}
pub trait DirectorCommands {
fn play_sequence(&mut self, handle: Handle<SequenceAsset>);
fn play_sequence_with(&mut self, handle: Handle<SequenceAsset>, options: PlayOptions);
fn skip_sequence(&mut self);
fn stop_sequence(&mut self);
}
impl DirectorCommands for Commands<'_, '_> {
fn play_sequence(&mut self, handle: Handle<SequenceAsset>) {
self.play_sequence_with(handle, PlayOptions::default());
}
fn play_sequence_with(&mut self, handle: Handle<SequenceAsset>, options: PlayOptions) {
self.queue(move |world: &mut World| {
world.write_message(DirectorRequest::Play { handle, options });
});
}
fn skip_sequence(&mut self) {
self.queue(|world: &mut World| {
world.write_message(DirectorRequest::Skip);
});
}
fn stop_sequence(&mut self) {
self.queue(|world: &mut World| {
world.write_message(DirectorRequest::Stop);
});
}
}
#[derive(Component)]
pub(crate) struct Baked {
pub(crate) compiled: CompiledSequence,
pub(crate) live: CameraSnapshot,
pub(crate) marker_cursor: f32,
pub(crate) blend_out: Option<Blend>,
pub(crate) last_pose: Option<CameraPose>,
pub(crate) lens_active: LensTouch,
pub(crate) held_rot: Option<Quat>,
pub(crate) warned_cameras: Vec<String>,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub(crate) struct LensTouch {
dof: bool,
exposure: bool,
vignette: bool,
distortion: bool,
aberration: bool,
motion_blur: bool,
grading: bool,
}
impl LensTouch {
pub(crate) fn of(pose: &CameraPose) -> Self {
Self {
dof: pose.dof.is_some(),
exposure: pose.exposure_ev100.is_some(),
vignette: pose.vignette.is_some(),
distortion: pose.distortion.is_some(),
aberration: pose.aberration.is_some(),
motion_blur: pose.motion_blur.is_some(),
grading: pose.grading.is_some(),
}
}
pub(crate) fn union(self, other: Self) -> Self {
Self {
dof: self.dof || other.dof,
exposure: self.exposure || other.exposure,
vignette: self.vignette || other.vignette,
distortion: self.distortion || other.distortion,
aberration: self.aberration || other.aberration,
motion_blur: self.motion_blur || other.motion_blur,
grading: self.grading || other.grading,
}
}
}
#[derive(Component)]
pub(crate) struct PendingPlay {
handle: Handle<SequenceAsset>,
options: PlayOptions,
}
#[derive(Component)]
pub(crate) struct HandbackBlend {
from: CameraPose,
elapsed: f32,
blend: Blend,
clock: ClockSource,
}
#[derive(Component)]
pub(crate) struct PreTakeState {
transform: Transform,
projection: Projection,
viewport: Option<bevy::camera::Viewport>,
dof: Option<DepthOfField>,
exposure: Option<Exposure>,
vignette: Option<Vignette>,
distortion: Option<LensDistortion>,
aberration: Option<ChromaticAberration>,
motion_blur: Option<MotionBlur>,
had_motion_vector_prepass: bool,
grading: Option<ColorGrading>,
role: TakeRole,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TakeRole {
Borrowed,
Dedicated,
CutTarget,
}
impl PreTakeState {
#[cfg(feature = "viewfinder")]
pub(crate) fn grading(&self) -> Option<&ColorGrading> {
self.grading.as_ref()
}
#[cfg(feature = "viewfinder")]
pub(crate) fn held_snapshot(&self) -> CameraSnapshot {
snapshot_of(&self.transform, Some(&self.projection))
}
}
pub(crate) fn begin_take(role: TakeRole) -> impl EntityCommand {
move |mut entity: EntityWorldMut| {
let borrowed = role == TakeRole::Borrowed;
let saved = PreTakeState {
transform: entity.get::<Transform>().copied().unwrap_or_default(),
projection: entity.get::<Projection>().cloned().unwrap_or_default(),
viewport: entity.get::<Camera>().and_then(|c| c.viewport.clone()),
dof: entity.get::<DepthOfField>().cloned(),
exposure: entity.get::<Exposure>().cloned(),
vignette: entity.get::<Vignette>().cloned(),
distortion: entity.get::<LensDistortion>().cloned(),
aberration: entity.get::<ChromaticAberration>().cloned(),
motion_blur: entity.get::<MotionBlur>().cloned(),
had_motion_vector_prepass: entity.contains::<MotionVectorPrepass>(),
grading: entity.get::<ColorGrading>().cloned(),
role,
};
if borrowed && entity.contains::<ChildOf>() {
warn!(
"the gameplay camera is parented; directed poses are world space and will \
fight its parent. Spawn a CineCamera entity for this game."
);
}
entity.insert(saved);
if borrowed {
entity.insert(CineCamera);
}
}
}
fn end_take(mut entity: EntityWorldMut) {
entity.remove::<(
SequencePlayer,
Baked,
HandbackBlend,
PendingPlay,
SkipRequested,
LetterboxSettings,
)>();
let Some(saved) = entity.take::<PreTakeState>() else {
entity.remove::<(
DepthOfField,
Exposure,
Vignette,
LensDistortion,
ChromaticAberration,
MotionBlur,
ColorGrading,
)>();
return;
};
if let Some(mut camera) = entity.get_mut::<Camera>() {
camera.viewport = saved.viewport;
}
if let Some(mut projection) = entity.get_mut::<Projection>() {
*projection = saved.projection;
}
restore_component(&mut entity, saved.dof);
restore_component(&mut entity, saved.exposure);
restore_component(&mut entity, saved.vignette);
restore_component(&mut entity, saved.distortion);
restore_component(&mut entity, saved.aberration);
restore_component(&mut entity, saved.motion_blur);
restore_component(&mut entity, saved.grading);
if !saved.had_motion_vector_prepass {
entity.remove::<MotionVectorPrepass>();
}
if saved.role != TakeRole::Dedicated {
entity.insert(saved.transform);
}
if saved.role == TakeRole::Borrowed {
entity.remove::<CineCamera>();
}
}
fn restore_component<C: Component>(entity: &mut EntityWorldMut, saved: Option<C>) {
match saved {
Some(component) => {
entity.insert(component);
}
None => {
entity.remove::<C>();
}
}
}
pub(crate) fn snapshot_of(
transform: &Transform,
projection: Option<&Projection>,
) -> CameraSnapshot {
let fov_y = match projection {
Some(Projection::Perspective(p)) => p.fov,
_ => 45f32.to_radians(),
};
CameraSnapshot {
position: transform.translation,
rotation: transform.rotation,
fov_y,
}
}
fn resolve_live_camera(
cameras: &Query<(Entity, &Camera, Has<HandoffCamera>), (With<Camera3d>, Without<CineCamera>)>,
) -> Option<Entity> {
if let Some((e, ..)) = cameras.iter().find(|(_, _, marked)| *marked) {
return Some(e);
}
let mut actives = cameras.iter().filter(|(_, cam, _)| cam.is_active);
let first = actives.next().map(|(e, ..)| e);
if actives.next().is_some() {
warn!("several active cameras and no HandoffCamera marker; picking one arbitrarily");
}
first
}
fn spawn_fallback_cine_camera(commands: &mut Commands, active: bool) -> Entity {
commands
.spawn((
Name::new("cine camera"),
CineCamera,
Camera3d::default(),
Camera {
is_active: active,
..Default::default()
},
Projection::default(),
Transform::default(),
))
.id()
}
#[allow(clippy::too_many_arguments)] pub(crate) fn handle_requests(
mut commands: Commands,
mut requests: MessageReader<DirectorRequest>,
mut state: ResMut<DirectorState>,
assets: Res<Assets<SequenceAsset>>,
mut cameras: ParamSet<(
Query<(Entity, &Camera, Has<HandoffCamera>), (With<Camera3d>, Without<CineCamera>)>,
Query<&mut Camera>,
)>,
cine_cameras: Query<(Entity, Option<&Name>), With<CineCamera>>,
pending: Query<(Entity, &PendingPlay)>,
mut players: Query<(&mut SequencePlayer, Option<&Baked>)>,
transforms: Query<(&Transform, Option<&Projection>)>,
mut started: MessageWriter<SequenceStarted>,
mut finished: MessageWriter<SequenceFinished>,
) {
let retry: Option<(Handle<SequenceAsset>, PlayOptions, Entity)> = pending
.iter()
.next()
.filter(|(_, p)| assets.contains(&p.handle))
.map(|(e, p)| (p.handle.clone(), p.options.clone(), e));
let mut plays: Vec<(Handle<SequenceAsset>, PlayOptions)> = Vec::new();
if let Some((handle, options, entity)) = retry {
commands.entity(entity).remove::<PendingPlay>();
plays.push((handle, options));
}
for request in requests.read() {
match request {
DirectorRequest::Play { handle, options } => {
plays.push((handle.clone(), options.clone()));
}
DirectorRequest::Skip => {
if state.phase == DirectorPhase::Shooting
&& let Some(camera) = state.take_anchor()
&& let Ok((mut player, baked)) = players.get_mut(camera)
&& let Some(baked) = baked
{
player.playhead = baked.compiled.duration();
player.playback = Playback::Playing;
player.loop_mode = LoopMode::Once;
finished.write(SequenceFinished {
camera,
reason: FinishReason::Skipped,
});
commands.entity(camera).insert(SkipRequested);
}
}
DirectorRequest::Stop => {
if state.phase == DirectorPhase::Shooting || state.phase == DirectorPhase::Handback
{
if let Some(camera) = state.take_anchor() {
finished.write(SequenceFinished {
camera,
reason: FinishReason::Stopped,
});
}
let mut writable = cameras.p1();
restore_live(&mut commands, &mut state, &mut writable);
}
}
}
}
for (handle, options) in plays {
if state.phase != DirectorPhase::Idle {
warn!(
"play_sequence ignored: the director is already {:?}",
state.phase
);
continue;
}
let live_entity = resolve_live_camera(&cameras.p0());
let Some(asset) = assets.get(&handle) else {
let holder = cine_cameras
.iter()
.next()
.map(|(e, _)| e)
.or(live_entity)
.unwrap_or_else(|| spawn_fallback_cine_camera(&mut commands, false));
commands.entity(holder).insert(PendingPlay {
handle: handle.clone(),
options,
});
continue;
};
let compiled = match bake(asset) {
Ok(compiled) => compiled,
Err(err) => {
error!("sequence '{}' failed to bake: {err}", asset.name);
continue;
}
};
let existing = cine_cameras
.iter()
.find(|(_, name)| name.is_none_or(|name| !compiled.cuts_to(name.as_str())))
.map(|(e, _)| e);
let borrowed = existing.is_none() && live_entity.is_some();
let cine = existing
.or(live_entity)
.unwrap_or_else(|| spawn_fallback_cine_camera(&mut commands, true));
let live = live_entity
.and_then(|e| transforms.get(e).ok())
.map(|(t, p)| snapshot_of(t, p))
.unwrap_or(CameraSnapshot {
position: Vec3::ZERO,
rotation: Quat::IDENTITY,
fov_y: 45f32.to_radians(),
});
let blend_out = options.blend_out.or(compiled.blend_out());
let mut cine_commands = commands.entity(cine);
cine_commands.queue(begin_take(if borrowed {
TakeRole::Borrowed
} else {
TakeRole::Dedicated
}));
cine_commands.insert((
SequencePlayer {
sequence: handle.clone(),
playhead: 0.0,
rate: options.rate,
playback: Playback::Playing,
clock: options.clock,
loop_mode: options.loop_mode,
},
Baked {
compiled,
live,
marker_cursor: -f32::EPSILON,
blend_out,
last_pose: None,
lens_active: LensTouch::default(),
held_rot: None,
warned_cameras: Vec::new(),
},
));
if let Some(aspect) = options.letterbox {
cine_commands.insert(LetterboxSettings { aspect });
}
if !borrowed {
let mut writable = cameras.p1();
if let Some(live_entity) = live_entity
&& 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;
}
}
state.phase = DirectorPhase::Shooting;
state.camera = Some(cine);
state.anchor = Some(cine);
state.live_camera = live_entity;
started.write(SequenceStarted {
camera: cine,
sequence: handle.id(),
});
}
}
#[derive(Component)]
pub(crate) struct SkipRequested;
pub(crate) fn rebake_on_asset_change(
mut events: MessageReader<AssetEvent<SequenceAsset>>,
assets: Res<Assets<SequenceAsset>>,
mut players: Query<(&mut SequencePlayer, &mut Baked)>,
) {
for event in events.read() {
let AssetEvent::Modified { id } = event else {
continue;
};
for (mut player, mut baked) in &mut players {
if player.sequence.id() != *id {
continue;
}
let Some(asset) = assets.get(*id) else {
continue;
};
match bake(asset) {
Ok(compiled) => {
player.playhead = player.playhead.min(compiled.duration());
baked.compiled = compiled;
info!("sequence '{}' rebaked from disk", asset.name);
}
Err(err) => error!("sequence '{}' failed to rebake: {err}", asset.name),
}
}
}
}
pub(crate) fn guard_orphans(
mut commands: Commands,
mut state: ResMut<DirectorState>,
players: Query<&SequencePlayer>,
mut cameras: Query<&mut Camera>,
mut finished: MessageWriter<SequenceFinished>,
) {
if state.phase == DirectorPhase::Idle {
return;
}
let camera_gone = if state.phase == DirectorPhase::Viewfinder {
state.camera.is_none_or(|e| cameras.get(e).is_err())
} else {
state.take_anchor().is_none_or(|e| players.get(e).is_err())
};
if !camera_gone {
return;
}
if let Some(camera) = state.take_anchor() {
finished.write(SequenceFinished {
camera,
reason: FinishReason::Stopped,
});
}
restore_live(&mut commands, &mut state, &mut cameras);
}
pub(crate) fn restore_live(
commands: &mut Commands,
state: &mut DirectorState,
cameras: &mut Query<&mut Camera>,
) {
if let Some(live) = state.live_camera {
if let Ok(mut cam) = cameras.get_mut(live) {
cam.is_active = true;
}
for cine in [state.camera, state.anchor].into_iter().flatten() {
if cine != live
&& let Ok(mut cam) = cameras.get_mut(cine)
{
cam.is_active = false;
}
}
}
let anchor = state.take_anchor();
commands.queue(move |world: &mut World| {
let mut captured: Vec<Entity> = world
.query_filtered::<Entity, With<PreTakeState>>()
.iter(world)
.collect();
if let Some(anchor) = anchor
&& !captured.contains(&anchor)
{
captured.push(anchor);
}
for camera in captured {
if let Ok(entity) = world.get_entity_mut(camera) {
end_take(entity);
}
}
});
state.phase = DirectorPhase::Idle;
state.camera = None;
state.anchor = None;
state.live_camera = None;
}
pub(crate) fn tick_players(
mut commands: Commands,
mut state: ResMut<DirectorState>,
virtual_time: Res<Time<Virtual>>,
real_time: Res<Time<Real>>,
mut players: Query<(Entity, &mut SequencePlayer, &mut Baked, Has<SkipRequested>)>,
mut cameras: Query<&mut Camera>,
mut markers: MessageWriter<MarkerReached>,
mut finished: MessageWriter<SequenceFinished>,
) {
if state.phase != DirectorPhase::Shooting {
return;
}
let Some(camera) = state.take_anchor() else {
return;
};
let Ok((entity, mut player, mut baked, skipping)) = players.get_mut(camera) else {
return;
};
if player.playback != Playback::Playing {
return;
}
let cut_away = state.camera != Some(camera);
let dt = match player.clock {
ClockSource::Virtual => virtual_time.delta_secs(),
ClockSource::Real => real_time.delta_secs(),
} * player.rate;
player.playhead += dt;
let duration = baked.compiled.duration();
let cursor = baked.marker_cursor;
if !skipping {
let fire = |m: &crate::sequence::Marker, markers: &mut MessageWriter<MarkerReached>| {
markers.write(MarkerReached {
camera: entity,
name: m.name.clone(),
time: m.time,
});
};
if dt >= 0.0 {
for marker in baked.compiled.markers_between(cursor, player.playhead) {
fire(marker, &mut markers);
}
} else {
for marker in baked
.compiled
.markers_between_backward(player.playhead, cursor)
{
fire(marker, &mut markers);
}
}
}
baked.marker_cursor = player.playhead;
let over = dt >= 0.0 && player.playhead >= duration;
let under = dt < 0.0 && player.playhead <= 0.0;
if !over && !under {
return;
}
match player.loop_mode {
LoopMode::Loop => {
let wrapped = if over {
let wrapped = (player.playhead - duration).max(0.0);
if !skipping {
for marker in baked.compiled.markers_between(-f32::EPSILON, wrapped) {
markers.write(MarkerReached {
camera: entity,
name: marker.name.clone(),
time: marker.time,
});
}
}
wrapped
} else {
(player.playhead + duration).clamp(0.0, duration)
};
player.playhead = wrapped;
baked.marker_cursor = wrapped;
}
LoopMode::PingPong => {
player.rate = -player.rate;
player.playhead = if over {
(2.0 * duration - player.playhead).clamp(0.0, duration)
} else {
(-player.playhead).clamp(0.0, duration)
};
baked.marker_cursor = player.playhead;
}
LoopMode::Once => {
player.playhead = if over { duration } else { 0.0 };
player.playback = Playback::Stopped;
if !skipping {
finished.write(SequenceFinished {
camera: entity,
reason: FinishReason::Completed,
});
}
match baked.blend_out {
Some(blend) if blend.secs > 0.0 && !cut_away => {
let from = baked.last_pose.unwrap_or_else(|| {
baked
.compiled
.pose_at(player.playhead, &EvalCtx::still(&baked.live))
});
commands.entity(entity).insert(HandbackBlend {
from,
elapsed: 0.0,
blend,
clock: player.clock,
});
state.phase = DirectorPhase::Handback;
}
_ => restore_live(&mut commands, &mut state, &mut cameras),
}
}
}
}
#[allow(clippy::too_many_arguments)] pub(crate) fn execute_cuts(
mut commands: Commands,
mut state: ResMut<DirectorState>,
mut players: Query<(&SequencePlayer, &mut Baked)>,
named: Query<(Entity, &Name), With<CineCamera>>,
mut cameras: Query<&mut Camera>,
letterbox: Query<&LetterboxSettings>,
captured: Query<(), With<PreTakeState>>,
mut cuts: MessageWriter<SequenceCut>,
) {
if state.phase != DirectorPhase::Shooting {
return;
}
let (Some(anchor), Some(current)) = (state.take_anchor(), state.camera) else {
return;
};
let Ok((player, mut baked)) = players.get_mut(anchor) else {
return;
};
let playhead = player.playhead;
let wanted = baked.compiled.camera_at(playhead).map(str::to_owned);
let mut target = match &wanted {
None => anchor,
Some(name) => match named.iter().find(|(_, n)| n.as_str() == name.as_str()) {
Some((entity, _)) => entity,
None => {
if !baked.warned_cameras.iter().any(|seen| seen == name) {
baked.warned_cameras.push(name.clone());
warn!(
"shot at {playhead:.2}s cuts to CineCamera '{name}', which no entity \
carries; staying on the take's camera"
);
}
anchor
}
},
};
if target != anchor && cameras.get(target).is_err() {
target = anchor;
}
if target == current {
return;
}
if !captured.contains(target) {
commands
.entity(target)
.queue(begin_take(TakeRole::CutTarget));
}
if let Ok(mut cam) = cameras.get_mut(current) {
cam.is_active = false;
}
if let Ok(mut cam) = cameras.get_mut(target) {
cam.is_active = true;
}
if let Ok(settings) = letterbox.get(current) {
commands.entity(current).remove::<LetterboxSettings>();
commands.entity(target).insert(*settings);
}
baked.last_pose = None;
baked.held_rot = None;
cuts.write(SequenceCut {
from: current,
to: target,
shot: baked.compiled.shot_index(playhead),
});
state.camera = Some(target);
}
fn soften_mount(
baked: &mut Baked,
pose: &mut CameraPose,
playhead: f32,
dt: f32,
mount: Option<Quat>,
) {
let (Some(decay), Some(mount)) = (baked.compiled.held_damping_at(playhead), mount) else {
baked.held_rot = None;
return;
};
let target = mount * pose.rotation;
let mut smoothed = baked.held_rot.unwrap_or(target);
if dt > 0.0 {
smoothed.smooth_nudge(&target, decay, dt);
} else {
smoothed = target;
}
baked.held_rot = Some(smoothed);
pose.rotation = mount.inverse() * smoothed;
}
#[allow(clippy::too_many_arguments)] pub(crate) fn apply_pose(
mut commands: Commands,
mut state: ResMut<DirectorState>,
virtual_time: Res<Time<Virtual>>,
real_time: Res<Time<Real>>,
mut driven: Query<(&mut Transform, &mut Projection, Option<&PreTakeState>), With<CineCamera>>,
mut playback: Query<(
Option<(&SequencePlayer, &mut Baked)>,
Option<&mut HandbackBlend>,
)>,
live: Query<(&Transform, Option<&Projection>), Without<CineCamera>>,
names: Query<(&Name, &GlobalTransform)>,
parents: Query<&ChildOf>,
globals: Query<&GlobalTransform>,
mut cameras: Query<&mut Camera>,
) {
let Some(camera) = state.camera else {
return;
};
let Some(anchor) = state.take_anchor() else {
return;
};
let (borrowed, pre_take_pose, base_grading) = match driven.get(camera) {
Ok((_, _, pre_take)) => (
pre_take.is_some_and(|p| p.role == TakeRole::Borrowed),
pre_take.map(|p| snapshot_of(&p.transform, Some(&p.projection))),
pre_take.and_then(|p| p.grading.clone()),
),
Err(_) => return,
};
let Ok((playing, handback)) = playback.get_mut(anchor) else {
return;
};
let resolve = |wanted: &str| {
names
.iter()
.find(|(name, _)| name.as_str() == wanted)
.map(|(_, gt)| gt.translation())
};
let (pose, lens_flags) = match state.phase {
DirectorPhase::Shooting => {
let Some((player, mut baked)) = playing else {
return;
};
let dt = match player.clock {
ClockSource::Virtual => virtual_time.delta_secs(),
ClockSource::Real => real_time.delta_secs(),
};
let ctx = EvalCtx {
live: &baked.live,
dt,
prev_rot: baked.last_pose.map(|p| p.rotation),
resolve_entity: &resolve,
held: pre_take_pose,
};
let mut pose = baked.compiled.pose_at(player.playhead, &ctx);
baked.last_pose = Some(pose);
let mount = parents
.get(camera)
.ok()
.and_then(|child_of| globals.get(child_of.parent()).ok())
.map(|global| global.rotation());
soften_mount(&mut baked, &mut pose, player.playhead, dt, mount);
let touch = LensTouch::of(&pose);
let flags = Some(touch.union(baked.lens_active));
baked.lens_active = touch;
(pose, flags)
}
DirectorPhase::Handback => {
let Some(mut handback) = handback else {
return;
};
handback.elapsed += match handback.clock {
ClockSource::Virtual => virtual_time.delta_secs(),
ClockSource::Real => real_time.delta_secs(),
};
let target = if borrowed {
pre_take_pose
} else {
state
.live_camera
.and_then(|e| live.get(e).ok())
.map(|(t, p)| snapshot_of(t, p))
}
.unwrap_or(CameraSnapshot {
position: handback.from.position,
rotation: handback.from.rotation,
fov_y: handback.from.fov_y,
});
let target = CameraPose::from_snapshot(&target);
let w = handback
.blend
.ease
.sample_clamped((handback.elapsed / handback.blend.secs).clamp(0.0, 1.0));
let pose = mix(&handback.from, &target, w);
if handback.elapsed >= handback.blend.secs {
restore_live(&mut commands, &mut state, &mut cameras);
}
(pose, None)
}
_ => return,
};
let Ok((mut transform, mut projection, _)) = driven.get_mut(camera) else {
return;
};
transform.translation = pose.position;
transform.rotation = pose.rotation;
if let Projection::Perspective(perspective) = &mut *projection {
perspective.fov = pose.fov_y;
}
if let Some(touch) = lens_flags {
apply_lens_components(&mut commands, camera, &pose, touch, base_grading.as_ref());
}
}
pub(crate) fn apply_lens_components(
commands: &mut Commands,
camera: Entity,
pose: &CameraPose,
touch: LensTouch,
base_grading: Option<&ColorGrading>,
) {
if touch.dof {
match pose.dof {
Some(d) => {
commands.entity(camera).insert(DepthOfField {
mode: if d.bokeh {
DepthOfFieldMode::Bokeh
} else {
DepthOfFieldMode::Gaussian
},
focal_distance: d.focal_distance,
aperture_f_stops: d.aperture_f_stops,
sensor_height: d.sensor_height,
..Default::default()
});
}
None => {
commands.entity(camera).remove::<DepthOfField>();
}
}
}
if touch.exposure {
match pose.exposure_ev100 {
Some(ev100) => {
commands.entity(camera).insert(Exposure { ev100 });
}
None => {
commands.entity(camera).remove::<Exposure>();
}
}
}
if touch.vignette {
match pose.vignette {
Some(intensity) => {
commands.entity(camera).insert(Vignette {
intensity,
..Default::default()
});
}
None => {
commands.entity(camera).remove::<Vignette>();
}
}
}
if touch.distortion {
match pose.distortion {
Some(intensity) => {
commands.entity(camera).insert(LensDistortion {
intensity,
..Default::default()
});
}
None => {
commands.entity(camera).remove::<LensDistortion>();
}
}
}
if touch.aberration {
match pose.aberration {
Some(intensity) => {
commands.entity(camera).insert(ChromaticAberration {
intensity,
..Default::default()
});
}
None => {
commands.entity(camera).remove::<ChromaticAberration>();
}
}
}
if touch.motion_blur {
match pose.motion_blur {
Some(spec) => {
commands.entity(camera).insert(MotionBlur {
shutter_angle: spec.shutter_angle,
samples: spec.samples,
});
}
None => {
commands.entity(camera).remove::<MotionBlur>();
}
}
}
if touch.grading {
match pose.grading {
Some(grade) => {
let mut grading = base_grading.cloned().unwrap_or_default();
grading.global.exposure += grade.exposure;
grading.global.temperature += grade.temperature;
grading.global.tint += grade.tint;
grading.global.post_saturation *= grade.saturation;
commands.entity(camera).insert(grading);
}
None => match base_grading {
Some(saved) => {
commands.entity(camera).insert(saved.clone());
}
None => {
commands.entity(camera).remove::<ColorGrading>();
}
},
}
}
}
pub(crate) fn update_active_texts(
state: Res<DirectorState>,
players: Query<(&SequencePlayer, &Baked)>,
mut active: ResMut<ActiveTexts>,
) {
match state.phase {
DirectorPhase::Shooting => {
let playing = state
.take_anchor()
.and_then(|camera| players.get(camera).ok());
match playing {
Some((player, baked)) => {
if baked.compiled.texts().is_empty() && active.blocks.is_empty() {
return;
}
let blocks = &mut active.blocks;
baked.compiled.active_texts_into(player.playhead, blocks);
}
None => {
if !active.blocks.is_empty() {
active.blocks.clear();
}
}
}
}
DirectorPhase::Viewfinder => {}
_ => {
if !active.blocks.is_empty() {
active.blocks.clear();
}
}
}
}
pub(crate) fn update_active_actor_cues(
state: Res<DirectorState>,
players: Query<(&SequencePlayer, &Baked)>,
mut active: ResMut<ActiveActorCues>,
) {
match state.phase {
DirectorPhase::Shooting => {
let playing = state
.take_anchor()
.and_then(|camera| players.get(camera).ok());
match playing {
Some((player, baked)) => {
if baked.compiled.actors().is_empty() && active.cues.is_empty() {
return;
}
let cues = &mut active.cues;
baked.compiled.active_actor_cues_into(player.playhead, cues);
}
None => {
if !active.cues.is_empty() {
active.cues.clear();
}
}
}
}
DirectorPhase::Viewfinder => {}
_ => {
if !active.cues.is_empty() {
active.cues.clear();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DirectorPlugin;
use crate::sequence::*;
use bevy::app::App;
use bevy::asset::AssetPlugin;
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
fn test_sequence(blend_out: Option<Blend>) -> SequenceAsset {
SequenceAsset {
name: "test".into(),
shots: vec![Shot {
start: 0.0,
duration: 0.1,
blend_in: None,
rig: Rig::Keys {
keys: vec![Key {
time: 0.0,
pos: Vec3::new(5.0, 0.0, 0.0),
rot: Some(Quat::IDENTITY),
ease: EaseFunction::Linear,
}],
interp: KeyInterp::Eased,
},
look: Look::Free,
lens: Lens::default(),
shake: None,
camera: None,
}],
markers: vec![Marker {
time: 0.05,
name: "beat".into(),
}],
texts: vec![],
actors: vec![],
blend_out,
}
}
fn test_app() -> (App, Entity, Handle<SequenceAsset>) {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default(), DirectorPlugin));
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(
16,
)));
let live = app
.world_mut()
.spawn((
Camera3d::default(),
Camera::default(),
Projection::default(),
Transform::from_xyz(0.0, 9.0, 0.0),
))
.id();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(test_sequence(None));
(app, live, handle)
}
fn test_app_dedicated() -> (App, Entity, Entity, Handle<SequenceAsset>) {
let (mut app, live, handle) = test_app();
let cine = app
.world_mut()
.spawn((
CineCamera,
Camera3d::default(),
Camera {
is_active: false,
..Default::default()
},
Projection::default(),
Transform::default(),
))
.id();
(app, live, cine, handle)
}
fn phase(app: &App) -> DirectorPhase {
app.world().resource::<DirectorState>().phase
}
fn is_active(app: &mut App, e: Entity) -> bool {
app.world().get::<Camera>(e).unwrap().is_active
}
#[test]
fn play_swaps_is_active_and_finishes() {
let (mut app, live, cine, handle) = test_app_dedicated();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
assert!(!is_active(&mut app, live));
assert_eq!(app.world().resource::<DirectorState>().camera, Some(cine));
assert!(is_active(&mut app, cine));
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
assert!(!is_active(&mut app, cine));
let t = app.world().get::<Transform>(cine).unwrap();
assert_eq!(t.translation.x, 5.0);
}
#[test]
fn borrow_drives_the_gameplay_camera_and_puts_it_back() {
let (mut app, live, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let state = app.world().resource::<DirectorState>();
assert!(state.is_borrowing());
assert_eq!(state.camera, Some(live));
assert_eq!(
app.world_mut()
.query::<&Camera3d>()
.iter(app.world())
.count(),
1
);
assert!(is_active(&mut app, live));
assert_eq!(
app.world().get::<Transform>(live).unwrap().translation.x,
5.0
);
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
assert_eq!(
app.world().get::<Transform>(live).unwrap().translation,
Vec3::new(0.0, 9.0, 0.0)
);
assert!(app.world().get::<CineCamera>(live).is_none());
assert!(app.world().get::<PreTakeState>(live).is_none());
}
#[test]
fn borrowed_handback_lands_on_the_pose_it_took_over() {
let (mut app, live, _) = test_app();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(test_sequence(Some(Blend {
secs: 0.05,
ease: EaseFunction::Linear,
})));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..40 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert_eq!(
app.world().get::<Transform>(live).unwrap().translation,
Vec3::new(0.0, 9.0, 0.0)
);
}
#[test]
fn borrow_restores_the_viewport_and_lens_the_game_owned() {
let (mut app, live, handle) = test_app();
let viewport = bevy::camera::Viewport {
physical_position: UVec2::new(4, 8),
physical_size: UVec2::new(320, 240),
..Default::default()
};
app.world_mut().entity_mut(live).insert((
Camera {
viewport: Some(viewport.clone()),
..Default::default()
},
Exposure { ev100: 7.0 },
));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions {
letterbox: Some(2.39),
..Default::default()
},
});
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
let camera = app.world().get::<Camera>(live).unwrap();
assert_eq!(
camera.viewport.as_ref().map(|v| v.physical_size),
Some(viewport.physical_size)
);
assert_eq!(
app.world().get::<Exposure>(live).map(|e| e.ev100),
Some(7.0)
);
}
#[test]
fn adopted_cine_camera_keeps_its_own_lens() {
let (mut app, _, cine, handle) = test_app_dedicated();
app.world_mut()
.entity_mut(cine)
.insert(Exposure { ev100: 3.5 });
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert_eq!(
app.world().get::<Exposure>(cine).map(|e| e.ev100),
Some(3.5)
);
}
#[test]
fn borrowed_camera_despawning_mid_take_just_goes_idle() {
let (mut app, live, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
app.world_mut().entity_mut(live).despawn();
app.update();
assert_eq!(phase(&app), DirectorPhase::Idle);
assert_eq!(app.world().resource::<DirectorState>().camera, None);
}
#[cfg(feature = "titles")]
#[test]
fn director_plugin_installs_the_text_overlay() {
let (app, ..) = test_app();
assert!(app.is_plugin_added::<crate::titles::TitlesPlugin>());
}
fn filmic_sequence() -> SequenceAsset {
let mut sequence = test_sequence(None);
let lens = &mut sequence.shots[0].lens;
lens.vignette = Some(ScalarTrack::constant(0.6));
lens.distortion = Some(ScalarTrack::constant(0.15));
lens.aberration = Some(ScalarTrack::constant(0.04));
lens.motion_blur = Some(crate::sequence::MotionBlurSpec {
shutter_angle: 0.5,
samples: 2,
});
sequence
}
#[test]
fn filmic_components_ride_the_take_and_leave_with_it() {
let (mut app, live, _) = test_app();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(filmic_sequence());
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(
app.world().get::<Vignette>(live).map(|v| v.intensity),
Some(0.6)
);
assert_eq!(
app.world().get::<LensDistortion>(live).map(|d| d.intensity),
Some(0.15)
);
assert_eq!(
app.world()
.get::<ChromaticAberration>(live)
.map(|a| a.intensity),
Some(0.04)
);
assert_eq!(
app.world().get::<MotionBlur>(live).map(|m| m.samples),
Some(2)
);
assert!(app.world().get::<MotionVectorPrepass>(live).is_some());
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().get::<Vignette>(live).is_none());
assert!(app.world().get::<LensDistortion>(live).is_none());
assert!(app.world().get::<ChromaticAberration>(live).is_none());
assert!(app.world().get::<MotionBlur>(live).is_none());
assert!(app.world().get::<MotionVectorPrepass>(live).is_none());
}
#[test]
fn motion_blur_leaves_the_games_own_prepass_alone() {
let (mut app, live, _) = test_app();
app.world_mut().entity_mut(live).insert(MotionVectorPrepass);
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(filmic_sequence());
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().get::<MotionVectorPrepass>(live).is_some());
}
#[test]
fn grading_composes_over_the_games_grade_and_hands_it_back() {
let (mut app, live, _) = test_app();
let mut base = ColorGrading::default();
base.global.exposure = 1.0;
base.global.post_saturation = 0.8;
app.world_mut().entity_mut(live).insert(base);
let mut sequence = test_sequence(None);
sequence.shots[0].lens.grading = Some(crate::sequence::GradeTrack {
exposure: Some(ScalarTrack::constant(2.0)),
saturation: Some(ScalarTrack::constant(0.5)),
..Default::default()
});
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let live_grade = app.world().get::<ColorGrading>(live).unwrap();
assert_eq!(live_grade.global.exposure, 3.0);
assert!((live_grade.global.post_saturation - 0.4).abs() < 1e-6);
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
let restored = app.world().get::<ColorGrading>(live).unwrap();
assert_eq!(restored.global.exposure, 1.0);
assert_eq!(restored.global.post_saturation, 0.8);
}
fn cut_sequence(name: &str) -> SequenceAsset {
let mut sequence = test_sequence(None);
sequence.shots[0].duration = 0.05;
let mut second = sequence.shots[0].clone();
second.start = 0.05;
second.camera = Some(name.to_string());
if let Rig::Keys { keys, .. } = &mut second.rig {
keys[0].pos = Vec3::new(-7.0, 0.0, 0.0);
}
sequence.shots.push(second);
sequence.markers.clear();
sequence
}
fn spawn_named_cine(app: &mut App, name: &str) -> Entity {
app.world_mut()
.spawn((
Name::new(name.to_string()),
CineCamera,
Camera3d::default(),
Camera {
is_active: false,
..Default::default()
},
Projection::default(),
Transform::from_xyz(0.0, 1.0, 0.0),
))
.id()
}
#[test]
fn a_cut_moves_the_frame_the_letterbox_and_reports_itself() {
let (mut app, live, _) = test_app();
let crane = spawn_named_cine(&mut app, "crane");
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(cut_sequence("crane"));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions {
letterbox: Some(2.39),
..Default::default()
},
});
app.update();
assert_eq!(app.world().resource::<DirectorState>().camera, Some(live));
for _ in 0..4 {
app.update();
}
let state = app.world().resource::<DirectorState>();
assert_eq!(state.camera, Some(crane));
assert_eq!(state.anchor, Some(live));
assert!(is_active(&mut app, crane));
assert!(!is_active(&mut app, live));
assert!(app.world().get::<LetterboxSettings>(crane).is_some());
assert!(app.world().get::<LetterboxSettings>(live).is_none());
assert!(app.world().get::<SequencePlayer>(live).is_some());
assert_eq!(
app.world().get::<Transform>(crane).unwrap().translation.x,
-7.0
);
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
assert!(!is_active(&mut app, crane));
assert!(app.world().get::<PreTakeState>(live).is_none());
assert!(app.world().get::<PreTakeState>(crane).is_none());
assert!(app.world().get::<CineCamera>(live).is_none());
assert_eq!(
app.world().get::<Transform>(crane).unwrap().translation,
Vec3::new(0.0, 1.0, 0.0)
);
assert_eq!(
app.world().get::<Transform>(live).unwrap().translation,
Vec3::new(0.0, 9.0, 0.0)
);
}
#[test]
fn a_cut_to_a_camera_nobody_has_stays_on_the_take() {
let (mut app, live, _) = test_app();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(cut_sequence("nobody"));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..5 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Shooting);
assert_eq!(app.world().resource::<DirectorState>().camera, Some(live));
assert!(is_active(&mut app, live));
}
#[test]
fn losing_a_cut_target_falls_back_instead_of_ending_the_take() {
let (mut app, live, _) = test_app();
let crane = spawn_named_cine(&mut app, "crane");
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(cut_sequence("crane"));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..5 {
app.update();
}
assert_eq!(app.world().resource::<DirectorState>().camera, Some(crane));
app.world_mut().entity_mut(crane).despawn();
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
assert_eq!(app.world().resource::<DirectorState>().camera, Some(live));
assert!(is_active(&mut app, live));
}
#[test]
fn a_damped_mount_lags_its_parents_lean_and_settles() {
let (mut app, _live, _) = test_app();
app.add_plugins(bevy::transform::TransformPlugin);
let bike = app.world_mut().spawn(Transform::default()).id();
let mirror = app
.world_mut()
.spawn((
Name::new("mirror"),
CineCamera,
Camera3d::default(),
Camera {
is_active: false,
..Default::default()
},
Projection::default(),
Transform::default(),
ChildOf(bike),
))
.id();
let mut sequence = test_sequence(None);
sequence.shots[0].duration = 0.05;
let mut held = Shot::held(0.05, 2.0).mount_damping(6.0);
held.camera = Some("mirror".into());
sequence.shots.push(held);
sequence.markers.clear();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..6 {
app.update();
}
assert_eq!(app.world().resource::<DirectorState>().camera, Some(mirror));
let lean = Quat::from_rotation_z(0.6);
app.world_mut().get_mut::<Transform>(bike).unwrap().rotation = lean;
app.update();
app.update();
let local = app.world().get::<Transform>(mirror).unwrap().rotation;
assert!(
local.angle_between(Quat::IDENTITY) > 0.05,
"the mount never softened: {local:?}"
);
assert!(
(lean * local).angle_between(lean) < 0.6,
"the camera outran its own mount"
);
for _ in 0..80 {
app.update();
}
let settled = app.world().get::<Transform>(mirror).unwrap().rotation;
assert!(
settled.angle_between(Quat::IDENTITY) < 0.02,
"the mount never settled: {settled:?}"
);
}
#[test]
fn a_cut_to_a_parented_held_camera_rides_its_mount() {
let (mut app, _live, _) = test_app();
let bike = app
.world_mut()
.spawn(Transform::from_xyz(0.0, 0.0, 0.0))
.id();
let mount = Vec3::new(0.4, 1.0, 0.1);
let mirror = app
.world_mut()
.spawn((
Name::new("mirror"),
CineCamera,
Camera3d::default(),
Camera {
is_active: false,
..Default::default()
},
Projection::Perspective(PerspectiveProjection {
fov: 1.2,
..Default::default()
}),
Transform::from_translation(mount),
ChildOf(bike),
))
.id();
let mut sequence = test_sequence(None);
sequence.shots[0].duration = 0.05;
let mut second = Shot::held(0.05, 0.2);
second.camera = Some("mirror".into());
sequence.shots.push(second);
sequence.markers.clear();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..5 {
app.update();
app.world_mut()
.get_mut::<Transform>(bike)
.unwrap()
.translation
.x += 1.0;
}
assert_eq!(app.world().resource::<DirectorState>().camera, Some(mirror));
assert_eq!(
app.world().get::<Transform>(mirror).unwrap().translation,
mount
);
let Projection::Perspective(p) = app.world().get::<Projection>(mirror).unwrap() else {
panic!("perspective expected");
};
assert_eq!(p.fov, 1.2);
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().get::<PreTakeState>(mirror).is_none());
assert_eq!(
app.world().get::<Transform>(mirror).unwrap().translation,
mount
);
}
#[test]
fn a_take_that_ends_cut_away_skips_the_handback() {
let (mut app, _, _) = test_app();
spawn_named_cine(&mut app, "crane");
let mut sequence = cut_sequence("crane");
sequence.blend_out = Some(Blend {
secs: 0.2,
ease: EaseFunction::Linear,
});
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
let mut saw_handback = false;
for _ in 0..40 {
app.update();
if phase(&app) == DirectorPhase::Handback {
saw_handback = true;
}
}
assert!(!saw_handback);
assert_eq!(phase(&app), DirectorPhase::Idle);
}
#[test]
fn blend_out_routes_through_handback() {
let (mut app, live, _) = test_app();
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(test_sequence(Some(Blend {
secs: 0.05,
ease: EaseFunction::Linear,
})));
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let mut saw_handback = false;
for _ in 0..40 {
app.update();
if phase(&app) == DirectorPhase::Handback {
saw_handback = true;
}
}
assert!(saw_handback);
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
}
#[test]
fn orphan_guard_restores_live_camera() {
let (mut app, live, cine, handle) = test_app_dedicated();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
app.world_mut().entity_mut(cine).despawn();
app.update();
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(is_active(&mut app, live));
}
#[test]
fn pause_of_virtual_clock_freezes_playhead() {
let (mut app, _, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
app.world_mut().resource_mut::<Time<Virtual>>().pause();
for _ in 0..10 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Shooting);
let cine = app.world().resource::<DirectorState>().camera.unwrap();
let head = app.world().get::<SequencePlayer>(cine).unwrap().playhead;
assert!(head <= 0.017, "playhead crept to {head}");
}
#[derive(Resource, Default)]
struct MarkerHits(usize);
fn count_markers(mut hits: ResMut<MarkerHits>, mut reader: MessageReader<MarkerReached>) {
hits.0 += reader.read().count();
}
#[test]
fn marker_fires_exactly_once() {
let (mut app, _, handle) = test_app();
app.init_resource::<MarkerHits>();
app.add_systems(Update, count_markers);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
for _ in 0..30 {
app.update();
}
assert_eq!(app.world().resource::<MarkerHits>().0, 1);
}
#[test]
fn active_texts_fill_while_shooting_and_clear_after() {
let (mut app, _, _) = test_app();
let mut sequence = test_sequence(None);
sequence.texts = vec![TextBlock::at(0.0, 0.1, "caption")];
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
let active = app.world().resource::<ActiveTexts>();
assert_eq!(active.blocks.len(), 1);
assert_eq!(active.blocks[0].block.text, "caption");
assert_eq!(active.blocks[0].alpha, 1.0);
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().resource::<ActiveTexts>().blocks.is_empty());
}
#[test]
fn active_actor_cues_fill_while_shooting_and_clear_after() {
let (mut app, _, _) = test_app();
let mut sequence = test_sequence(None);
sequence.actors =
vec![ActorTrack::entity("player").cue(ActorCue::at(0.0, 0.1, "cutscene.sleeping"))];
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert_eq!(phase(&app), DirectorPhase::Shooting);
let active = app.world().resource::<ActiveActorCues>();
assert_eq!(active.cues.len(), 1);
assert_eq!(active.cues[0].cue.anim, "cutscene.sleeping");
assert!(matches!(
&active.cues[0].target,
crate::sequence::TargetRef::Entity(name) if name == "player"
));
let first_local = active.cues[0].local_time;
app.update();
let advanced = app.world().resource::<ActiveActorCues>().cues[0].local_time;
assert!(advanced > first_local, "local_time never advanced");
for _ in 0..20 {
app.update();
}
assert_eq!(phase(&app), DirectorPhase::Idle);
assert!(app.world().resource::<ActiveActorCues>().cues.is_empty());
}
#[test]
fn skip_clears_actor_cues_immediately() {
let (mut app, _, _) = test_app();
let mut sequence = test_sequence(None);
sequence.shots[0].duration = 10.0;
sequence.actors = vec![ActorTrack::entity("player").cue(ActorCue::at(0.0, 10.0, "sleep"))];
let handle = app
.world_mut()
.resource_mut::<Assets<SequenceAsset>>()
.add(sequence);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
assert!(!app.world().resource::<ActiveActorCues>().cues.is_empty());
app.world_mut().write_message(DirectorRequest::Skip);
app.update();
assert!(app.world().resource::<ActiveActorCues>().cues.is_empty());
}
#[test]
fn pingpong_bounces_between_the_ends() {
let (mut app, _, handle) = test_app();
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions {
loop_mode: LoopMode::PingPong,
..Default::default()
},
});
app.update();
let cine = app.world().resource::<DirectorState>().camera.unwrap();
let mut flipped = false;
for _ in 0..40 {
app.update();
let player = app.world().get::<SequencePlayer>(cine).unwrap();
assert!((-0.001..=0.101).contains(&player.playhead));
flipped |= player.rate < 0.0;
}
assert!(flipped, "the rate never reflected");
assert_eq!(phase(&app), DirectorPhase::Shooting);
}
#[test]
fn backward_markers_fire_on_the_mirror_rule() {
let (mut app, _, handle) = test_app();
app.init_resource::<MarkerHits>();
app.add_systems(Update, count_markers);
app.world_mut().write_message(DirectorRequest::Play {
handle,
options: PlayOptions::default(),
});
app.update();
let cine = app.world().resource::<DirectorState>().camera.unwrap();
{
let mut entity = app.world_mut().entity_mut(cine);
let duration = entity.get::<Baked>().unwrap().compiled.duration();
entity.get_mut::<Baked>().unwrap().marker_cursor = duration;
let mut player = entity.get_mut::<SequencePlayer>().unwrap();
player.playhead = duration;
player.rate = -1.0;
}
for _ in 0..30 {
app.update();
}
assert_eq!(app.world().resource::<MarkerHits>().0, 1);
assert_eq!(phase(&app), DirectorPhase::Idle);
}
}