bevy_director 0.8.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
//! Cut-based coverage: one take, three cameras.
//!
//!     cargo run -p bevy_director --example multicam
//!
//! Space plays it. The take opens on the gameplay camera, cuts to a
//! named crane, then to a named dolly with its own look, and hands the
//! frame back at the end. Each named camera keeps its own post stack
//! across the cut — the director drives the pose and puts everything
//! back afterward.

use bevy::post_process::effect_stack::Vignette;
use bevy::prelude::*;
use bevy_director::prelude::*;

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, DirectorPlugin))
        .add_systems(Startup, setup)
        .add_systems(Update, (play_on_space, report_cuts))
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::new(60.0, 1.0, 60.0))),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::srgb(0.24, 0.26, 0.3),
            ..Default::default()
        })),
        Transform::from_xyz(0.0, -0.5, 0.0),
    ));
    let block = meshes.add(Cuboid::new(2.0, 6.0, 2.0));
    for i in -3..=3 {
        commands.spawn((
            Mesh3d(block.clone()),
            MeshMaterial3d(materials.add(StandardMaterial {
                base_color: Color::hsl(24.0 + i as f32 * 14.0, 0.45, 0.52),
                ..Default::default()
            })),
            Transform::from_xyz(i as f32 * 5.0, 3.0, -6.0),
        ));
    }
    commands.spawn((
        Name::new("mark"),
        Mesh3d(meshes.add(Sphere::new(0.8))),
        MeshMaterial3d(materials.add(StandardMaterial {
            emissive: LinearRgba::rgb(6.0, 3.0, 1.0),
            ..Default::default()
        })),
        Transform::from_xyz(0.0, 2.0, 4.0),
    ));
    commands.spawn((
        DirectionalLight::default(),
        Transform::from_xyz(18.0, 32.0, 14.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    // The gameplay camera. The take opens and closes here.
    commands.spawn((
        Camera3d::default(),
        Camera {
            clear_color: ClearColorConfig::Custom(Color::srgb(0.05, 0.07, 0.12)),
            ..Default::default()
        },
        Transform::from_xyz(0.0, 4.0, 20.0).looking_at(Vec3::new(0.0, 2.0, 0.0), Vec3::Y),
    ));

    // Two cameras placed in the level for the take to cut to. Shots name
    // them by their bevy Name; the CineCamera marker is what makes them
    // eligible. They start inactive — the cut puts them on air.
    commands.spawn((
        Name::new("crane"),
        CineCamera,
        Camera3d::default(),
        Camera {
            is_active: false,
            clear_color: ClearColorConfig::Custom(Color::srgb(0.05, 0.07, 0.12)),
            ..Default::default()
        },
        Transform::from_xyz(-12.0, 9.0, 8.0).looking_at(Vec3::new(0.0, 2.0, 0.0), Vec3::Y),
    ));
    commands.spawn((
        Name::new("dolly"),
        CineCamera,
        Camera3d::default(),
        Camera {
            is_active: false,
            clear_color: ClearColorConfig::Custom(Color::srgb(0.12, 0.05, 0.05)),
            ..Default::default()
        },
        // This one keeps its own look through the cut: a heavy vignette
        // the director never touches, on top of a different clear color.
        Vignette {
            intensity: 0.85,
            ..Default::default()
        },
        Transform::from_xyz(9.0, 2.0, 7.0).looking_at(Vec3::new(0.0, 2.0, 0.0), Vec3::Y),
    ));

    info!("space plays the take; watch the cuts between gameplay, crane, and dolly");
}

fn play_on_space(
    keys: Res<ButtonInput<KeyCode>>,
    mut assets: ResMut<Assets<SequenceAsset>>,
    mut commands: Commands,
) {
    if !keys.just_pressed(KeyCode::Space) {
        return;
    }
    let look_at_mark = Look::At {
        target: TargetRef::Entity("mark".into()),
        damping: None,
    };
    let take = SequenceAsset {
        name: "coverage".into(),
        shots: vec![
            // Opening: the gameplay camera pushes in, no cut yet.
            Shot::keys(
                0.0,
                3.0,
                vec![
                    Key::at(0.0).pos(Vec3::new(0.0, 4.0, 20.0)),
                    Key::at(3.0).pos(Vec3::new(0.0, 3.5, 13.0)),
                ],
            )
            .look(look_at_mark.clone())
            .blend_in(1.0, EaseFunction::SmoothStep),
            // Cut to the crane, swinging down.
            Shot::keys(
                3.0,
                3.0,
                vec![
                    Key::at(0.0).pos(Vec3::new(-12.0, 9.0, 8.0)),
                    Key::at(3.0).pos(Vec3::new(-6.0, 3.0, 7.0)),
                ],
            )
            .look(look_at_mark.clone())
            .camera("crane"),
            // Cut to the dolly, which brings its own vignette with it.
            Shot::keys(
                6.0,
                3.0,
                vec![
                    Key::at(0.0).pos(Vec3::new(9.0, 2.0, 7.0)),
                    Key::at(3.0).pos(Vec3::new(4.0, 2.2, 5.0)),
                ],
            )
            .look(look_at_mark)
            .camera("dolly"),
        ],
        markers: vec![],
        texts: vec![
            TextBlock::at(0.2, 2.6, "COVERAGE")
                .anchor(TextAnchor::Center)
                .fades(0.4, 0.6),
            TextBlock::at(3.1, 2.8, "crane").anchor(TextAnchor::LowerThird),
            TextBlock::at(6.1, 2.8, "dolly — its own vignette").anchor(TextAnchor::LowerThird),
        ],
        actors: vec![],
        blend_out: None,
    };
    let handle = assets.add(take);
    commands.play_sequence(handle);
}

/// Cuts are reported, so a game can land a stinger on them.
fn report_cuts(mut cuts: MessageReader<SequenceCut>) {
    for cut in cuts.read() {
        info!(
            "cut to shot {} ({:?} -> {:?})",
            cut.shot + 1,
            cut.from,
            cut.to
        );
    }
}