bevy_director 0.6.0-dev

Unreal-Sequencer-inspired cinematic camera and sequence system for Bevy
Documentation

bevy_director

Camera director, shots, sequences: an Unreal-Sequencer-inspired cinematic layer for Bevy 0.19. Sequences are RON assets made of shots, shots are camera rigs (eased keys, constant-speed dolly rails, orbits) with a film-literate lens (focal length against a filmback, focus pulls, aperture, exposure), and playback hands off from your gameplay camera and glides back onto it when the take ends.

Quickstart

use bevy::prelude::*;
use bevy_director::prelude::*;

app.add_plugins(DirectorPlugin);

// Let the director borrow the frame: gate your own camera-driving
// system so it yields during a take and runs during the glide home.
app.add_systems(Update, my_camera_rig.run_if(gameplay_camera_free));

// Anywhere, later:
commands.play_sequence(asset_server.load("sequences/intro.dir.ron"));

The director finds your active Camera3d, snapshots it, and drives that same camera in place for the take — your clear color, tonemapping, bloom, skybox, and the rest of the post stack never leave the frame, because no camera swap happens. At the end it glides back to the pose it took over and hands everything back: transform, projection, viewport, and any depth-of-field or exposure the camera already had.

Spawn your own CineCamera entity when you want a dedicated cinematic camera instead — a separate post stack, or a gameplay camera that is parented to a rig (directed poses are world space and would fight the parent). The director then adopts it and swaps is_active across the handoff, blending home toward wherever your live camera has got to, sampled every frame. Control can return to the player while the camera is still gliding.

Listen for SequenceStarted, MarkerReached { name, .. } (cues, stingers; on-screen text has its own track now), and SequenceFinished { reason } (story flags belong here, never on markers). commands.skip_sequence() jumps to the end and takes the normal blend home; stop_sequence() cuts hard.

Director's Cut (feature editor, opt-in)

Director's Cut is the docked, mouse-driven authoring surface. It uses the same ViewfinderSession, evaluator, and .dir.ron assets as the runtime, so there is no conversion or editor-only project format.

use bevy::prelude::*;
use bevy_director::prelude::*;

App::new()
    .add_plugins((DefaultPlugins, DirectorsCutPlugin))
    .run();

Run the included stage with:

cargo run --example directors_cut --features editor

Press Backquote to possess the camera; Director's Cut opens automatically. Press Tab to switch between the editor cursor and camera piloting.

Action Control
Move a shot Drag its body
Resize a shot Drag its bright right edge
Retime a transform or lens key Drag the diamond
Scrub Click or drag the timeline
Edit camera values Select a key; use the inspector for position, rotation, FOV/focal value, and time
Capture the current camera Key button (the key lands in the shot under the playhead)
Auto-key Auto button — while piloting (Tab), moving the camera keys the shot under the playhead automatically
Add a camera cut Shot button
Add a text block at the playhead Text button
Move / resize a text block Drag its body / its bright right edge
Edit a text block Select it; the inspector has start, duration, fades, size, and anchor; click the content to type, Enter confirms
Preview Play button or Space
Freeze or resume the game world Freeze button (pauses Time<Virtual>)
Undo / redo Ctrl+Z / Ctrl+Y
Open a sequence Open button or Ctrl+O lists the folder's .dir.ron files; click one to load it; Escape closes
Save Ctrl+S
Rename the sequence file Click the pencil filename; Enter confirms and Escape cancels
Zoom timeline zoom-out / zoom-in buttons in the toolbar

New/Open/Save use assets/sequences/<sequence name>.dir.ron by default. The Open panel lists that folder; loading a file also renames the working take after it, so a following Ctrl+S writes back to the same file. The filename field accepts either a plain name or the full .dir.ron filename; the extension is added exactly once. Set DirectorsCutConfig::sequence_path when a fixed path is preferable; the Open panel then lists that file's folder instead. The editor feature is not enabled by default, keeping UI and picking out of shipping builds unless requested.

Integrating with a live game

The editor runs inside your game, over the live scene. That is the point: you frame shots against real lighting, real actors, real physics. It also means the game still sees the same keyboard and mouse while you direct. Anything you do not gate will fight the editor: the character walks away while you fly the camera on WASD, or a click-to-grab-cursor handler locks the pointer mid-drag and the timeline scrub sticks.

The crate tells you when to stand down. DirectorState.phase says who owns the frame, and three run conditions wrap the common cases:

Condition True when Gate with it
gameplay_camera_free Idle or Handback your camera-driving system
director_idle Idle only gameplay input, cursor grabbing, click-to-act handlers
director_active anything but Idle editor-only helpers

The prerequisites, in order of pain avoided:

  1. Yield the camera. Run your camera rig under gameplay_camera_free, as in the quickstart. That condition stays true through Handback so your rig is somewhere real to glide to — which is what a dedicated CineCamera wants. When the director is borrowing your camera there is nothing to chase (it glides back to the pose it took over), so gate the rig on director_idle instead and it will not fight the blend.

  2. Yield the keyboard and mouse. While the director is not Idle, stop reading gameplay input. Per-system run_if(director_idle) works; if your game has a state machine, one bridge system is cleaner because every state-gated system and input context follows automatically:

    fn yield_to_director(
        director: Res<DirectorState>,
        state: Res<State<GameState>>,
        mut next: ResMut<NextState<GameState>>,
    ) {
        match (director.phase, state.get()) {
            (DirectorPhase::Viewfinder, GameState::Playing) => {
                next.set(GameState::Directing)
            }
            (DirectorPhase::Idle, GameState::Directing) => {
                next.set(GameState::Playing)
            }
            _ => {}
        }
    }
    
  3. Leave the cursor alone. The viewfinder manages the cursor itself: locked while piloting, free while the editor cursor is up, and your previous grab state is restored on eject. A gameplay system that re-locks the cursor on click breaks timeline dragging; gate it like the rest of the input.

  4. Drive gameplay off the default clock. The Freeze button pauses Time<Virtual>, so actors hold still while you frame a shot. Systems that read Time<Real> ignore the freeze. The editor itself runs on real time, so flying, scrubbing, and previewing keep working in a frozen world; ejecting resumes the clock if the editor paused it. A physics engine with its own clock (avian's Time<Physics>, for example) needs a small mirror system that pauses it whenever Time<Virtual> is paused.

Key bindings that overlap are harmless once input is gated: the editor's pilot/editor toggle defaults to Tab, and if your game also binds Tab, the two never see it at the same time. Rebind via DirectorsCutConfig::toggle if you want a different key.

DirectorsCutState is a public resource; open tells you whether the dock is up if you want to gate anything the run conditions do not cover.

Development vs shipping

Enabling a Cargo feature only makes its code available. A tool becomes active—and starts reading its shortcuts—only when its plugin is added to the app.

Setup What runs
DirectorPlugin Sequence playback only. No authoring shortcuts or UI.
(DirectorPlugin, ViewfinderPlugin) Playback plus the keyboard/mouse capture mode. Backquote opens the viewfinder.
DirectorsCutPlugin The complete authoring setup: runtime, viewfinder, timeline UI, Bevy 0.19's infinite grid, and editor shortcuts. It installs the other required plugins automatically.

For a game that authors cinematics during development but ships only the RON player, expose the editor through a game-specific feature:

[features]
director_editor = ["bevy_director/editor"]

[dependencies]
# default-features = false drops the viewfinder key and the gizmos too;
# keep "titles" so the shipped build still draws the sequence's text.
bevy_director = { version = "0.5", default-features = false, features = [
    "titles",
] }

Select the plugin at compile time:

#[cfg(feature = "director_editor")]
app.add_plugins(DirectorsCutPlugin);

#[cfg(not(feature = "director_editor"))]
app.add_plugins(DirectorPlugin);

Use the authoring build to create and save the asset:

cargo run --features director_editor

Then run or release the normal game without that feature:

cargo run
cargo build --release

The shipping build loads and plays the saved asset normally:

commands.play_sequence(asset_server.load("sequences/intro.dir.ron"));

It contains no Director's Cut UI or editor shortcuts because DirectorsCutPlugin is not installed. This also makes Director's Cut usable as a temporary authoring tool whose only shipped output is the .dir.ron sequence.

The editor's infinite grid appears only while the viewfinder is active. Disable it when the game already supplies an authoring grid:

app.insert_resource(DirectorsCutConfig {
    show_grid: false,
    ..default()
});

The spawned entity is marked with DirectorsCutGrid; you can despawn it and add your own InfiniteGrid and InfiniteGridSettings when you need custom colors, spacing, or fade distance.

Viewfinder (feature viewfinder, on by default)

The director's chair, no widgets: press Backquote in your running game.

Key Action
Backquote Toggle the viewfinder (possesses the camera at the current view). Rebind with ViewfinderConfig::toggle, or set it to None so no key opens it
WASD + mouse Fly and look
Q / E Sink / rise
Wheel Fly speed (Ctrl+Wheel: fov)
K Capture the framing as a key at the playhead (into the shot under it)
(auto-key) On by default: flying or Ctrl+wheel writes the key at the playhead without pressing K; set ViewfinderConfig::auto_key = false to opt out
[ / ] Scrub (Shift: single frame)
P Preview the working take
Delete Remove the nearest key
Ctrl+S Write assets/sequences/<name>.dir.ron (native only)

Gizmos draw every shot's path, keys, aim rays, and the playhead ghost while the viewfinder is up.

.dir.ron format

SequenceAsset(
    name: "intro",
    shots: [
        Shot(
            start: 0.0, duration: 8.0,
            rig: Rail(
                points: [(-2.0, 2.0, -6.0), (0.0, 2.2, 0.0), (2.0, 2.5, 6.0)],
                constant_speed: true,
                progress: ScalarTrack(keys: [
                    ScalarKey(time: 0.0, value: 0.0, ease: SmoothStep),
                    ScalarKey(time: 8.0, value: 1.0),
                ]),
            ),
            look: At(target: Entity("gate"), damping: Some(8.0)),
            lens: Lens(
                fov: FocalLengthMm(track: ScalarTrack(keys: [ScalarKey(time: 0.0, value: 35.0)])),
                focus: Some(Target(target: Entity("gate"))),
                aperture_f_stops: Some(ScalarTrack(keys: [ScalarKey(time: 0.0, value: 2.0)])),
                // Filmic extras, all optional and all put back after.
                vignette: Some(ScalarTrack(keys: [ScalarKey(time: 0.0, value: 0.4)])),
                aberration: Some(ScalarTrack(keys: [ScalarKey(time: 0.0, value: 0.02)])),
                motion_blur: Some(MotionBlurSpec(shutter_angle: 0.5, samples: 2)),
                grading: Some(GradeTrack(
                    temperature: Some(ScalarTrack(keys: [ScalarKey(time: 0.0, value: 0.2)])),
                )),
            ),
        ),
        Shot(
            // Cut to a CineCamera the level already placed.
            start: 8.0, duration: 4.0,
            camera: Some("crane"),
            rig: Keys(keys: [Key(time: 0.0, pos: (6.0, 3.0, 2.0))]),
            look: At(target: Entity("gate")),
        ),
    ],
    markers: [Marker(time: 1.0, name: "stinger_one")],
    texts: [
        TextBlock(
            start: 1.0, duration: 3.0,
            text: "THE GATE",
            anchor: Center,
            fade_in: 0.5, fade_out: 0.5,
        ),
    ],
    actors: [
        ActorTrack(
            target: Entity("hero"),
            cues: [
                ActorCue(start: 0.0, duration: 6.0, anim: "sleeping", fade_out: 0.4),
                ActorCue(start: 6.0, duration: 3.0, anim: "wake_up", fade_in: 0.4),
            ],
        ),
    ],
    blend_out: Some(Blend(secs: 1.5, ease: SmoothStep)),
)

Rules worth knowing: shot starts are absolute and sorted; a shot with no blend_in cuts hard; during a blend the previous shot is sampled clamped (extend it into the overlap for a moving crossfade); markers fire only while playing across them (skips and seeks never retro-fire); text blocks are the opposite — level-triggered, so whatever the playhead sits inside is visible at the fade-resolved alpha, and they may overlap each other and span camera cuts (their window is half-open, and anything past the last shot's end never shows because playback stops there); actor cues follow the same level-triggered rule as text blocks; constant_speed rails cover equal distance in equal time via an arc-length table, the piece Bevy itself does not ship.

Text overlays

Text blocks carry a duration, fades, an anchor (Center for title cards, LowerThird for subtitles, TopCenter for location stamps), and a style (size, color, shadow, optional background box). The core crate always parses and evaluates them: every frame, the ActiveTexts resource holds the blocks visible right now with their fade alphas, which is the whole contract for rendering captions with your own UI (localization included: treat text as a key and look it up when you draw).

The titles feature — on by default — adds the built-in renderer, TitlesPlugin: bevy_ui text on the directed camera with the fades applied for you. DirectorPlugin installs it, so text a sequence carries renders in the game with no further wiring and no editor; DirectorsCutPlugin inherits the same renderer, so the TEXT lane previews exactly what ships. Because the overlay targets the directed camera, layout happens inside its viewport, so anchors respect the letterbox crop with no extra work. It enables Bevy's default_font so text renders out of the box; set TitlesConfig::font to ship your own face (if you assemble Bevy features by hand and text is invisible, the missing default font is why).

To render captions yourself instead, take the crate with default-features = false and read ActiveTexts — the evaluation still runs. Markers remain the right tool for cues and stingers; captions belong to text blocks.

Actor tracks

A sequence can drive more than the camera. An actors: track aims named animation cues at world entities — the classic opening where the camera pushes in on a sleeping character, she plays a wake-up animation, and control returns to the player once the take hands back.

The crate stays out of your animation setup entirely. It never touches an AnimationPlayer; instead, every frame it publishes the ActiveActorCues resource — the cues the playhead is inside right now, each with its target name, blend weight, cue-local time, and a stable (track, cue_index) identity. Your game reads it and does the playing:

fn drive_cutscene_actors(
    cues: Res<bevy_director::ActiveActorCues>,
    names: Query<(Entity, &Name)>,
    mut commands: Commands,
    // ... your animation graph / clip lookup ...
) {
    for cue in &cues.cues {
        let bevy_director::TargetRef::Entity(target) = &cue.target else { continue };
        // Find the entity named `target`, look up `cue.cue.anim` in your
        // own clip set, and play it. Derive "just started" / "just ended"
        // by tracking which (cue.track, cue.cue_index) pairs you saw last
        // frame — the resource is level-triggered, so there are no edges
        // to miss and seeks/skips/loops need no special handling.
    }
}

Because it is level-triggered, a skip or a scrub simply changes which cues are reported; when a take ends (or is skipped or stopped) the resource empties, which is your signal to return the actor to gameplay control. TargetRef::Point is reported as authored but never names a performer — match on Entity. Fade in/out shape cue.weight for games that blend by weight; cue.cue.speed is a playback-rate hint. Author cues by hand in the .dir.ron file or on the Director's Cut ACTORS lane (drag to move/resize, inspector rows for timing and speed, inline edits for the anim name and target); the viewfinder previews them live as you scrub, so a game binding the cues sees the character perform in editor.

Multi-camera cuts

Cut-based coverage, the way a cutscene in a Resident Evil-shaped game is built: place the cameras in the level, then cut between them.

commands.spawn((
    Name::new("crane"),
    CineCamera,
    Camera3d::default(),
    Camera { is_active: false, ..default() },
    // ...this camera's own bloom, grade, clear color: kept across the cut
));

A shot naming that camera (camera: Some("crane"), or .camera("crane") in code) takes the frame for its span. The take's playback stays on its anchor camera — the one it borrowed or adopted at the start — so DirectorState::camera is whichever camera is on air right now and anchor is where the playhead lives. Titles and the letterbox follow the cut, and every camera a take visited is put back the way it was found when the take ends.

Cuts are hard. A blend_in authored across a camera change would be mixing poses meant for two different cameras, so it is ignored and said so at bake time; likewise a take that ends while cut away hands back without a glide. Cross-camera dissolves need render-target compositing and are not here yet.

Listen for SequenceCut { from, to, shot } to land a stinger or a rumble on the cut. Naming a camera no entity carries holds the current one rather than cutting to black. cargo run --example multicam runs the whole thing.

Glossary for Unreal users

Unreal Sequencer bevy_director
Level Sequence asset SequenceAsset (.dir.ron)
Sequencer (the editor) Director's Cut (editor feature)
Camera Cuts track the shot list, with shot.camera naming the cut
CineCameraActor a CineCamera entity, found by its Name
Ease In/Out on a section Blend (blend_in / blend_out)
Camera Rail rig Rig::Rail
Crane / orbit rig Rig::Orbit
Transform keyframes Rig::Keys
Look-at Tracking Look::At { target, damping }
Filmback Filmback presets (Super35 default)
Focal Length FovSpec::FocalLengthMm
Focus Settings FocusTrack -> bevy DepthOfField
Event track Marker -> MarkerReached
Text / subtitle track TextBlock (texts:), rendered by the titles feature
Skeletal / animation track ActorTrack (actors:) -> ActiveActorCues, played by your game
Play Rate SequencePlayer::rate (negative plays backward)
Possess / Eject the handoff and DirectorPhase::Handback
Pilot camera viewfinder fly mode
Camera Shake Shake (seeded, deterministic)

Features

  • viewfinder (default): the in-game capture mode. Implies gizmos.
  • gizmos (default): path and key overlay drawing.
  • titles (default): the built-in text overlay renderer (TitlesPlugin), installed by DirectorPlugin itself so a sequence's text shows up in a plain game build. Pulls bevy_ui/bevy_text and the default font; turn it off with default-features = false if you draw captions from ActiveTexts yourself. The text data model and ActiveTexts evaluation are in the core crate regardless.
  • editor (opt-in): Director's Cut dock, camera inspector, timeline, undo/redo, and the Open panel. Implies viewfinder and titles.

With everything off, the crate is the data model, the evaluator, the RON loader, and the playback state machine: enough to ship cutscenes.

Compatibility

bevy bevy_director
0.19 0.1–0.4

License

Dual-licensed under MIT or Apache-2.0, at your option.