bevy_director 0.5.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
Documentation
//! Cinematic bars, done honestly: the cine camera renders into a real
//! viewport crop, and a black 2d camera underneath guarantees the bar
//! pixels are defined instead of stale swapchain memory.

use bevy::{camera::Viewport, prelude::*, window::PrimaryWindow};

/// Put this on the cine camera (or pass PlayOptions::letterbox) to crop
/// it to an aspect ratio, e.g. 2.39 for scope.
#[derive(Component, Clone, Copy, Debug, Reflect)]
pub struct LetterboxSettings {
    pub aspect: f32,
}

/// The bars: a black-clearing 2d camera far below everything.
#[derive(Component)]
pub(crate) struct LetterboxBars;

pub(crate) fn sync_letterbox(
    mut commands: Commands,
    windows: Query<&Window, With<PrimaryWindow>>,
    mut boxed: Query<(&LetterboxSettings, &mut Camera, &mut Projection)>,
    bars: Query<Entity, With<LetterboxBars>>,
) {
    if boxed.is_empty() {
        for entity in &bars {
            commands.entity(entity).despawn();
        }
        return;
    }
    let Ok(window) = windows.single() else {
        return;
    };
    let win = UVec2::new(window.physical_width(), window.physical_height());
    if win.x == 0 || win.y == 0 {
        return;
    }
    if bars.is_empty() {
        commands.spawn((
            Name::new("letterbox bars"),
            LetterboxBars,
            Camera2d,
            Camera {
                order: -100,
                clear_color: ClearColorConfig::Custom(Color::BLACK),
                ..Default::default()
            },
        ));
    }
    for (settings, mut camera, mut projection) in &mut boxed {
        let (position, size) = viewport_rect(win, settings.aspect);
        camera.viewport = Some(Viewport {
            physical_position: position,
            physical_size: size,
            ..Default::default()
        });
        if let Projection::Perspective(p) = &mut *projection {
            p.aspect_ratio = settings.aspect;
        }
    }
}

/// The centered crop of `win` at `aspect`: bars when the window is
/// taller than the target, pillars when it is wider.
pub(crate) fn viewport_rect(win: UVec2, aspect: f32) -> (UVec2, UVec2) {
    let aspect = aspect.max(0.01);
    let win_aspect = win.x as f32 / win.y as f32;
    if win_aspect > aspect {
        let width = ((win.y as f32 * aspect).round() as u32).clamp(1, win.x);
        (UVec2::new((win.x - width) / 2, 0), UVec2::new(width, win.y))
    } else {
        let height = ((win.x as f32 / aspect).round() as u32).clamp(1, win.y);
        (
            UVec2::new(0, (win.y - height) / 2),
            UVec2::new(win.x, height),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn letterbox_rect_math_both_orientations() {
        // 16:9 window, scope target: bars top and bottom.
        let (pos, size) = viewport_rect(UVec2::new(1920, 1080), 2.39);
        assert_eq!(size.x, 1920);
        assert_eq!(size.y, 803);
        assert_eq!(pos, UVec2::new(0, 138));

        // Tall window, wide target: still bars, thicker.
        let (pos, size) = viewport_rect(UVec2::new(1080, 1920), 2.39);
        assert_eq!(size, UVec2::new(1080, 452));
        assert_eq!(pos.x, 0);

        // Ultrawide window, narrower target: pillars.
        let (pos, size) = viewport_rect(UVec2::new(3440, 1440), 1.78);
        assert_eq!(size.y, 1440);
        assert_eq!(size.x, 2563);
        assert!(pos.x > 0 && pos.y == 0);

        // Crop never exceeds the window.
        let (_, size) = viewport_rect(UVec2::new(100, 100), 2.39);
        assert!(size.x <= 100 && size.y <= 100);
    }
}