Camera / Pan Orbit Camera

Back to examples View in GitHub

Showcases how a pan-orbit-zoom camera can be used to inspect a CAD model.

Features:

  • Smoothly between perspective and orthographic projection (P)
  • Toggle between free and constrained orbit (should the camera always be "facing up"?) (C)
  • Snap to 6 axis-aligned views (1-6)
  • Explode the model, separating its parts for inspection (E)
  • Toggling passing thought surface on minimal zool (Z)
mod pan_orbit_camera_custom_input_plugin;

use std::time::Duration;

use bevy::camera::Hdr;
use bevy::camera_controller::pan_orbit_camera::{
    extensions::{dolly_zoom::DollyZoomTrigger, look_to::LookToTrigger},
    prelude::*,
};
use bevy::math::DVec3;
use bevy::{
    anti_alias::smaa::Smaa, camera::primitives::Aabb, core_pipeline::tonemapping::Tonemapping,
    pbr::ScreenSpaceAmbientOcclusion, platform::time::Instant, post_process::bloom::Bloom,
    prelude::*, window::RequestRedraw,
};

use crate::pan_orbit_camera_custom_input_plugin::CustomInputPlugin;
fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            MeshPickingPlugin,
            DefaultPanOrbitCameraPlugins,
            // Currently PanOrbitCamera needs an external plugin to handle input mapping.
            CustomInputPlugin,
        ))
        // The camera controller works with reactive rendering:
        // .insert_resource(bevy::winit::WinitSettings::desktop_app())
        .insert_resource(GlobalAmbientLight::NONE)
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                toggle_projection,
                toggle_constraint,
                explode,
                switch_direction,
                toggle_zoom,
            )
                .chain(),
        )
        .run();
}

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Original envmaps:
    // let diffuse_map = asset_server.load(get_web_asset_url("bevy_editor_cam_environment_maps/diffuse_rgb9e5_zstd.ktx2"));
    // let specular_map = asset_server.load(get_web_asset_url("bevy_editor_cam_environment_maps/specular_rgb9e5_zstd.ktx2"));
    // let intensity = 1000.0;

    let diffuse_map = asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2");
    let specular_map = asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2");
    let intensity = 5000.0;

    let scene = asset_server.load(get_web_asset_url("PlaneEngine/scene.gltf#Scene0"));

    commands.spawn((
        WorldAssetRoot(scene),
        Transform::from_scale(Vec3::splat(2.0)),
    ));

    let cam_trans = Transform::from_xyz(2.0, 2.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y);
    let camera = commands
        .spawn((
            Camera3d::default(),
            Hdr,
            Camera::default(),
            cam_trans,
            Tonemapping::AcesFitted,
            Bloom::default(),
            EnvironmentMapLight {
                intensity,
                diffuse_map: diffuse_map.clone(),
                specular_map: specular_map.clone(),
                ..Default::default()
            },
            PanOrbitCamera {
                orbit_constraint: OrbitConstraint::Free,
                last_anchor_depth: -cam_trans.translation.length() as f64,
                orthographic: projections::OrthographicSettings {
                    scale_to_near_clip: 1_000_f32, // Needed for SSAO to work in ortho
                    ..Default::default()
                },
                ..Default::default()
            },
            ScreenSpaceAmbientOcclusion::default(),
            Smaa::default(),
            Msaa::Off,
        ))
        .id();

    setup_ui(commands, camera);
}

fn toggle_projection(
    keys: Res<ButtonInput<KeyCode>>,
    mut dolly: MessageWriter<DollyZoomTrigger>,
    cam: Query<Entity, With<PanOrbitCamera>>,
    mut toggled: Local<bool>,
) {
    if keys.just_pressed(KeyCode::KeyP) {
        *toggled = !*toggled;
        let target_projection = if *toggled {
            Projection::Orthographic(OrthographicProjection::default_3d())
        } else {
            Projection::Perspective(PerspectiveProjection::default())
        };
        dolly.write(DollyZoomTrigger {
            target_projection,
            camera: cam.single().unwrap(),
        });
    }
}

#[derive(Component)]
struct ZoomStatusIndicator;

fn zoom_status(zoom_through: bool) -> String {
    format!("Zoom Through: {zoom_through}")
}

fn toggle_zoom(
    keys: Res<ButtonInput<KeyCode>>,
    mut cam: Query<&mut PanOrbitCamera>,
    mut text: Query<&mut Text, With<ZoomStatusIndicator>>,
) {
    if keys.just_pressed(KeyCode::KeyZ) {
        let mut editor = cam.single_mut().unwrap();
        editor.zoom_limits.zoom_through_objects = !editor.zoom_limits.zoom_through_objects;
        text.single_mut().unwrap().0 = zoom_status(editor.zoom_limits.zoom_through_objects);
    }
}

fn toggle_constraint(
    keys: Res<ButtonInput<KeyCode>>,
    mut cam: Query<(Entity, &Transform, &mut PanOrbitCamera)>,
    mut look_to: MessageWriter<LookToTrigger>,
) {
    if keys.just_pressed(KeyCode::KeyC) {
        let (entity, transform, mut editor) = cam.single_mut().unwrap();
        match editor.orbit_constraint {
            OrbitConstraint::Fixed { .. } => editor.orbit_constraint = OrbitConstraint::Free,
            OrbitConstraint::Free => {
                editor.orbit_constraint = OrbitConstraint::Fixed {
                    up: DVec3::Y,
                    can_pass_tdc: false,
                };

                look_to.write(LookToTrigger::auto_snap_up_direction(
                    transform.forward().as_dvec3(),
                    entity,
                    &transform.rotation.as_dquat(),
                    editor.as_ref(),
                ));
            }
        };
    }
}

fn switch_direction(
    keys: Res<ButtonInput<KeyCode>>,
    mut look_to: MessageWriter<LookToTrigger>,
    cam: Query<(Entity, &Transform, &PanOrbitCamera)>,
) {
    let (camera, transform, editor) = cam.single().unwrap();
    if keys.just_pressed(KeyCode::Digit1) {
        look_to.write(LookToTrigger::auto_snap_up_direction(
            DVec3::X,
            camera,
            &transform.rotation.as_dquat(),
            editor,
        ));
    }
    if keys.just_pressed(KeyCode::Digit2) {
        look_to.write(LookToTrigger::auto_snap_up_direction(
            DVec3::Z,
            camera,
            &transform.rotation.as_dquat(),
            editor,
        ));
    }
    if keys.just_pressed(KeyCode::Digit3) {
        look_to.write(LookToTrigger::auto_snap_up_direction(
            DVec3::NEG_X,
            camera,
            &transform.rotation.as_dquat(),
            editor,
        ));
    }
    if keys.just_pressed(KeyCode::Digit4) {
        look_to.write(LookToTrigger::auto_snap_up_direction(
            DVec3::NEG_Z,
            camera,
            &transform.rotation.as_dquat(),
            editor,
        ));
    }
    if keys.just_pressed(KeyCode::Digit5) {
        look_to.write(LookToTrigger::auto_snap_up_direction(
            DVec3::Y,
            camera,
            &transform.rotation.as_dquat(),
            editor,
        ));
    }
    if keys.just_pressed(KeyCode::Digit6) {
        look_to.write(LookToTrigger::auto_snap_up_direction(
            DVec3::NEG_Y,
            camera,
            &transform.rotation.as_dquat(),
            editor,
        ));
    }
}

fn setup_ui(mut commands: Commands, camera: Entity) {
    let text = "\
Left Mouse  - Pan
Right Mouse - Orbit
Scroll      - Zoom
Z           - Toggle passing thought surface on minimal zoom
P           - Toggle projection
C           - Toggle orbit constraint
E           - Toggle explode
1-6         - Switch direction";
    commands.spawn((
        children![
            Text::new(text),
            (Text::new(zoom_status(false)), ZoomStatusIndicator),
        ],
        TextFont {
            font_size: FontSize::Px(20.0),
            ..default()
        },
        Node {
            margin: UiRect::all(Val::Px(20.0)),
            flex_direction: FlexDirection::Column,
            ..Default::default()
        },
        Pickable::IGNORE,
        UiTargetCamera(camera),
    ));
}

#[derive(Component)]
struct StartPos(f32);

fn explode(
    mut commands: Commands,
    keys: Res<ButtonInput<KeyCode>>,
    mut toggle: Local<Option<(bool, Instant, f32)>>,
    mut explode_amount: Local<f32>,
    mut redraw: MessageWriter<RequestRedraw>,
    mut parts: Query<(Entity, &mut Transform, &Aabb, Option<&StartPos>), With<Mesh3d>>,
    mut matls: ResMut<Assets<StandardMaterial>>,
) {
    let animation = Duration::from_millis(2000);
    if keys.just_pressed(KeyCode::KeyE) {
        let new = if let Some((last, ..)) = *toggle {
            !last
        } else {
            true
        };
        *toggle = Some((new, Instant::now(), *explode_amount));
    }
    if let Some((toggled, start, start_amount)) = *toggle {
        let goal_amount = toggled as usize as f32;
        let t = (start.elapsed().as_secs_f32() / animation.as_secs_f32()).clamp(0.0, 1.0);
        let progress = CubicSegment::new_bezier_easing((0.25, 0.1), (0.25, 1.0)).ease(t);
        *explode_amount = start_amount + (goal_amount - start_amount) * progress;
        for (part, mut transform, aabb, start) in &mut parts {
            let start = if let Some(start) = start {
                start.0
            } else {
                let start = aabb.max().y;
                commands.entity(part).insert(StartPos(start));
                start
            };
            transform.translation.y = *explode_amount * (start) * 2.0;
        }
        if t < 1.0 {
            redraw.write(RequestRedraw);
        }
    }
    for (_, matl) in matls.iter_mut() {
        matl.perceptual_roughness = matl.perceptual_roughness.clamp(0.3, 1.0);
    }
}

/// Returns the GitHub download URL for the given asset.
///
/// The files are expected to be in the [repository].
///
/// [repository]: https://github.com/bevyengine/bevy_asset_files
fn get_web_asset_url(name: &str) -> String {
    format!(
        "https://raw.githubusercontent.com/bevyengine/bevy_asset_files/refs/heads/main/{}",
        name
    )
}