Skip to main content

bevy_camera_controller/
pan_camera.rs

1//! A controller for 2D cameras that supports panning, zooming, and rotation.
2//!
3//! To use this controller, add [`PanCameraPlugin`] to your app,
4//! and insert a [`PanCamera`] component into your camera entity.
5//!
6//! To configure the settings of this controller, modify the fields of the [`PanCamera`] component.
7
8use bevy_app::{App, Plugin, RunFixedMainLoop, RunFixedMainLoopSystems};
9use bevy_camera::{Camera, RenderTarget};
10use bevy_ecs::prelude::*;
11use bevy_input::keyboard::KeyCode;
12use bevy_input::mouse::{AccumulatedMouseScroll, MouseScrollPixelsPerLine};
13use bevy_input::ButtonInput;
14use bevy_math::{Vec2, Vec3};
15use bevy_picking::{
16    events::{PointerDrag, PointerDragEnd, PointerDragStart},
17    pointer::PointerButton,
18};
19use bevy_time::{Real, Time};
20use bevy_transform::components::GlobalTransform;
21use bevy_transform::prelude::Transform;
22use bevy_window::{PrimaryWindow, Window, WindowRef};
23use core::{f32::consts::*, fmt};
24
25/// A plugin that enables 2D camera panning and zooming controls.
26///
27/// Add this plugin to your [`App`] to enable [`PanCamera`] behavior
28/// on any camera entity that has the [`PanCamera`] component.
29pub struct PanCameraPlugin;
30
31impl Plugin for PanCameraPlugin {
32    fn build(&self, app: &mut App) {
33        app.add_systems(
34            RunFixedMainLoop,
35            (
36                run_pancamera_controller.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
37                run_pan_to_cursor_on_zoom.after(run_pancamera_controller),
38            ),
39        )
40        .add_message::<ZoomEvent>()
41        .add_observer(add_window_observer)
42        .add_observer(remove_window_observer);
43    }
44}
45
46/// Configuration and state for a 2D panning camera controller.
47///
48/// Add this component to a [`Camera`] entity to enable keyboard and mouse controls
49/// for panning, zooming, and optional rotation. Requires the [`PanCameraPlugin`].
50#[derive(impl bevy_ecs::component::Component for PanCamera where
    Self: ::core::marker::Send + ::core::marker::Sync + 'static {
    const STORAGE_TYPE: bevy_ecs::component::StorageType =
        bevy_ecs::component::StorageType::Table;
    type Mutability = bevy_ecs::component::Mutable;
    fn register_required_components(_requiree:
            bevy_ecs::component::ComponentId,
        required_components:
            &mut bevy_ecs::component::RequiredComponentsRegistrator) {}
    fn clone_behavior() -> bevy_ecs::component::ComponentCloneBehavior {
        use bevy_ecs::component::{
            DefaultCloneBehaviorBase, DefaultCloneBehaviorViaClone,
        };
        (&&&bevy_ecs::component::DefaultCloneBehaviorSpecialization::<Self>::default()).default_clone_behavior()
    }
    fn relationship_accessor()
        ->
            ::core::option::Option<bevy_ecs::relationship::ComponentRelationshipAccessor<Self>> {
        ::core::option::Option::None
    }
}Component, #[automatically_derived]impl ::core::clone::Clone for PanCamera {
    #[inline]
    fn clone(&self) -> Self {
        PanCamera {
            enabled: ::core::clone::Clone::clone(&self.enabled),
            zoom_factor: ::core::clone::Clone::clone(&self.zoom_factor),
            min_zoom: ::core::clone::Clone::clone(&self.min_zoom),
            max_zoom: ::core::clone::Clone::clone(&self.max_zoom),
            zoom_speed: ::core::clone::Clone::clone(&self.zoom_speed),
            zoom_target: ::core::clone::Clone::clone(&self.zoom_target),
            key_zoom_in: ::core::clone::Clone::clone(&self.key_zoom_in),
            key_zoom_out: ::core::clone::Clone::clone(&self.key_zoom_out),
            pan_speed: ::core::clone::Clone::clone(&self.pan_speed),
            key_up: ::core::clone::Clone::clone(&self.key_up),
            key_down: ::core::clone::Clone::clone(&self.key_down),
            key_left: ::core::clone::Clone::clone(&self.key_left),
            key_right: ::core::clone::Clone::clone(&self.key_right),
            rotation_speed: ::core::clone::Clone::clone(&self.rotation_speed),
            key_rotate_ccw: ::core::clone::Clone::clone(&self.key_rotate_ccw),
            key_rotate_cw: ::core::clone::Clone::clone(&self.key_rotate_cw),
            mouse_pan_settings: ::core::clone::Clone::clone(&self.mouse_pan_settings),
        }
    }
}Clone)]
51pub struct PanCamera {
52    /// Enables this [`PanCamera`] when `true`.
53    pub enabled: bool,
54    /// Current zoom level (factor applied to camera scale).
55    pub zoom_factor: f32,
56    /// Minimum allowed zoom level.
57    pub min_zoom: f32,
58    /// Maximum allowed zoom level.
59    pub max_zoom: f32,
60    /// Translation speed for panning movement.
61    pub zoom_speed: f32,
62    /// The focal point for zooming
63    pub zoom_target: ZoomTarget,
64    /// [`KeyCode`] to zoom in.
65    pub key_zoom_in: Option<KeyCode>,
66    /// [`KeyCode`] to zoom out.
67    pub key_zoom_out: Option<KeyCode>,
68    /// This [`PanCamera`]'s translation speed.
69    pub pan_speed: f32,
70    /// [`KeyCode`] for upward translation.
71    pub key_up: Option<KeyCode>,
72    /// [`KeyCode`] for downward translation.
73    pub key_down: Option<KeyCode>,
74    /// [`KeyCode`] for leftward translation.
75    pub key_left: Option<KeyCode>,
76    /// [`KeyCode`] for rightward translation.
77    pub key_right: Option<KeyCode>,
78    /// Rotation speed multiplier (in radians per second).
79    pub rotation_speed: f32,
80    /// [`KeyCode`] for counter-clockwise rotation.
81    pub key_rotate_ccw: Option<KeyCode>,
82    /// [`KeyCode`] for clockwise rotation.
83    pub key_rotate_cw: Option<KeyCode>,
84    /// Mouse pan settings.
85    pub mouse_pan_settings: MousePanSettings,
86}
87
88/// Settings for mouse panning for the [`PanCamera`] controller.
89#[derive(#[automatically_derived]impl ::core::clone::Clone for MousePanSettings {
    #[inline]
    fn clone(&self) -> Self {
        MousePanSettings {
            enabled: ::core::clone::Clone::clone(&self.enabled),
            button: ::core::clone::Clone::clone(&self.button),
        }
    }
}Clone)]
90pub struct MousePanSettings {
91    /// Whether the mouse panning is enabled.
92    pub enabled: bool,
93    /// The mouse button to use for panning.
94    pub button: PointerButton,
95}
96
97#[derive(#[automatically_derived]impl ::core::clone::Clone for ZoomTarget {
    #[inline]
    fn clone(&self) -> Self {
        match self {
            Self::Center => ZoomTarget::Center,
            Self::Cursor => ZoomTarget::Cursor,
        }
    }
}Clone, #[automatically_derived]impl ::core::cmp::Eq for ZoomTarget { }Eq, #[automatically_derived]impl ::core::marker::StructuralPartialEq for
    ZoomTarget {
}
#[automatically_derived]impl ::core::cmp::PartialEq for ZoomTarget {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq)]
98/// Target focal point for zooming using the [`PanCamera`] controller
99pub enum ZoomTarget {
100    /// Zoom to / from the center of the window
101    Center,
102    /// Zoom to / from the position of the cursor if the cursor is over the window
103    Cursor,
104}
105
106/// Message for communicating how much the camera's scale has changed this frame
107#[derive(impl bevy_ecs::message::Message for ZoomEvent where
    Self: ::core::marker::Send + ::core::marker::Sync + 'static {}Message)]
108pub struct ZoomEvent(f32);
109
110/// Provides the default values for the `PanCamera` controller.
111///
112/// The default settings are:
113/// - Zoom factor: 1.0
114/// - Min zoom: 0.1
115/// - Max zoom: 5.0
116/// - Zoom speed: 0.1
117/// - Zoom target: center
118/// - Zoom in/out key: +/-
119/// - Pan speed: 500.0
120/// - Move up/down: W/S
121/// - Move left/right: A/D
122/// - Rotation speed: PI (radians per second)
123/// - Rotation ccw/cw: Q/E
124impl Default for PanCamera {
125    /// Provides the default values for the `PanCamera` controller.
126    ///
127    /// Users can override these values by manually creating a `PanCamera` instance
128    /// or modifying the default instance.
129    fn default() -> Self {
130        Self {
131            enabled: true,
132            zoom_factor: 1.0,
133            min_zoom: 0.1,
134            max_zoom: 5.0,
135            zoom_speed: 0.1,
136            zoom_target: ZoomTarget::Center,
137            key_zoom_in: Some(KeyCode::Equal),
138            key_zoom_out: Some(KeyCode::Minus),
139            pan_speed: 500.0,
140            key_up: Some(KeyCode::KeyW),
141            key_down: Some(KeyCode::KeyS),
142            key_left: Some(KeyCode::KeyA),
143            key_right: Some(KeyCode::KeyD),
144            rotation_speed: PI,
145            key_rotate_ccw: Some(KeyCode::KeyQ),
146            key_rotate_cw: Some(KeyCode::KeyE),
147            mouse_pan_settings: MousePanSettings {
148                enabled: true,
149                button: PointerButton::Primary,
150            },
151        }
152    }
153}
154
155impl PanCamera {
156    fn key_to_string(key: &Option<KeyCode>) -> String {
157        key.map_or("None".to_string(), |k| ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0:?}", k))
    })format!("{:?}", k))
158    }
159}
160
161impl fmt::Display for PanCamera {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        f.write_fmt(format_args!("\nPanCamera Controls:\n  Move Up / Down    - {0} / {1}\n  Move Left / Right - {2} / {3}\n  Rotate CCW / CW   - {4} / {5}\n  Zoom              - Mouse Scroll + {6} / {7}\n",
        Self::key_to_string(&self.key_up),
        Self::key_to_string(&self.key_down),
        Self::key_to_string(&self.key_left),
        Self::key_to_string(&self.key_right),
        Self::key_to_string(&self.key_rotate_ccw),
        Self::key_to_string(&self.key_rotate_cw),
        Self::key_to_string(&self.key_zoom_in),
        Self::key_to_string(&self.key_zoom_out)))write!(
164            f,
165            "
166PanCamera Controls:
167  Move Up / Down    - {} / {}
168  Move Left / Right - {} / {}
169  Rotate CCW / CW   - {} / {}
170  Zoom              - Mouse Scroll + {} / {}
171",
172            Self::key_to_string(&self.key_up),
173            Self::key_to_string(&self.key_down),
174            Self::key_to_string(&self.key_left),
175            Self::key_to_string(&self.key_right),
176            Self::key_to_string(&self.key_rotate_ccw),
177            Self::key_to_string(&self.key_rotate_cw),
178            Self::key_to_string(&self.key_zoom_in),
179            Self::key_to_string(&self.key_zoom_out),
180        )
181    }
182}
183
184/// This system is typically added via the [`PanCameraPlugin`].
185///
186/// Reads [`ZoomEvent`] messages from the [`run_pancamera_controller`] system and pans the
187/// camera so that the focus of the zoom is the cursor. Assumes that if a message is
188/// received, then the [`PanCamera`] [`ZoomTarget`] is set to Cursor.
189///
190/// **Note**: Should be scheduled to run after the [`run_pancamera_controller`] system
191fn run_pan_to_cursor_on_zoom(
192    window: Single<&Window>,
193    query: Single<(&Camera, &GlobalTransform, &mut Transform), With<PanCamera>>,
194    mut reader: MessageReader<ZoomEvent>,
195) {
196    let (camera, global, mut transform) = query.into_inner();
197
198    for ZoomEvent(delta_zoom) in reader.read() {
199        // If a ZoomEvent message is received, assume that ZoomTarget is Cursor.
200        // Translate the camera so that the pixel at the world coordinates of the cursor
201        // maintains the same viewport coordinates.
202        if let Some(cursor_pos) = window.cursor_position()
203            && let Ok(world_pos) = camera.viewport_to_world_2d(global, cursor_pos)
204        {
205            let cursor_vec = world_pos - transform.translation.truncate();
206            let delta_cursor_vec = (1. - delta_zoom) * cursor_vec;
207            transform.translation += delta_cursor_vec.extend(0.);
208        }
209    }
210}
211
212/// This system is typically added via the [`PanCameraPlugin`].
213///
214/// Reads inputs and then moves the camera entity according
215/// to the settings given in [`PanCamera`].
216///
217/// **Note**: The zoom applied in this controller is linear. The zoom factor is directly adjusted
218/// based on the input (either from the mouse scroll or keyboard).
219fn run_pancamera_controller(
220    time: Res<Time<Real>>,
221    key_input: Res<ButtonInput<KeyCode>>,
222    accumulated_mouse_scroll: Res<AccumulatedMouseScroll>,
223    mouse_scroll_conversion: Res<MouseScrollPixelsPerLine>,
224    mut query: Query<(&mut Transform, &mut PanCamera), With<Camera>>,
225    mut writer: MessageWriter<ZoomEvent>,
226) {
227    let dt = time.delta_secs();
228
229    let Ok((mut transform, mut controller)) = query.single_mut() else {
230        return;
231    };
232
233    if !controller.enabled {
234        return;
235    }
236
237    // === Movement
238    let mut movement = Vec2::ZERO;
239    if let Some(key) = controller.key_left {
240        if key_input.pressed(key) {
241            movement.x -= 1.0;
242        }
243    }
244    if let Some(key) = controller.key_right {
245        if key_input.pressed(key) {
246            movement.x += 1.0;
247        }
248    }
249    if let Some(key) = controller.key_down {
250        if key_input.pressed(key) {
251            movement.y -= 1.0;
252        }
253    }
254    if let Some(key) = controller.key_up {
255        if key_input.pressed(key) {
256            movement.y += 1.0;
257        }
258    }
259
260    if movement != Vec2::ZERO {
261        let right = transform.right();
262        let up = transform.up();
263
264        let delta = (right * movement.x + up * movement.y).normalize() * controller.pan_speed * dt;
265
266        transform.translation.x += delta.x;
267        transform.translation.y += delta.y;
268    }
269
270    // === Rotation
271    if let Some(key) = controller.key_rotate_ccw {
272        if key_input.pressed(key) {
273            transform.rotate_z(controller.rotation_speed * dt);
274        }
275    }
276    if let Some(key) = controller.key_rotate_cw {
277        if key_input.pressed(key) {
278            transform.rotate_z(-controller.rotation_speed * dt);
279        }
280    }
281
282    // === Zoom
283    let mut zoom_amount = 0.0;
284
285    // (with keys)
286    if let Some(key) = controller.key_zoom_in {
287        if key_input.pressed(key) {
288            zoom_amount -= controller.zoom_speed;
289        }
290    }
291    if let Some(key) = controller.key_zoom_out {
292        if key_input.pressed(key) {
293            zoom_amount += controller.zoom_speed;
294        }
295    }
296
297    // (with mouse wheel)
298    let mouse_scroll = accumulated_mouse_scroll
299        .to_lines(&mouse_scroll_conversion)
300        .delta
301        .y;
302    zoom_amount += mouse_scroll * controller.zoom_speed;
303
304    let prev_zoom = controller.zoom_factor;
305
306    controller.zoom_factor =
307        (controller.zoom_factor - zoom_amount).clamp(controller.min_zoom, controller.max_zoom);
308
309    // If Zoom target is Cursor, send a message with the change in zoom so that the camera
310    // can also be panned.
311    if controller.zoom_target == ZoomTarget::Cursor {
312        writer.write(ZoomEvent(controller.zoom_factor / prev_zoom));
313    }
314
315    transform.scale = Vec3::splat(controller.zoom_factor);
316}
317
318/// A component attached to window entities that holds the id of an
319/// active `handle_mouse_pan` observer. It is None if there is no
320/// such observer.
321#[derive(impl bevy_ecs::component::Component for HandleMousePanObserver where
    Self: ::core::marker::Send + ::core::marker::Sync + 'static {
    const STORAGE_TYPE: bevy_ecs::component::StorageType =
        bevy_ecs::component::StorageType::Table;
    type Mutability = bevy_ecs::component::Mutable;
    fn register_required_components(_requiree:
            bevy_ecs::component::ComponentId,
        required_components:
            &mut bevy_ecs::component::RequiredComponentsRegistrator) {}
    fn clone_behavior() -> bevy_ecs::component::ComponentCloneBehavior {
        use bevy_ecs::component::{
            DefaultCloneBehaviorBase, DefaultCloneBehaviorViaClone,
        };
        (&&&bevy_ecs::component::DefaultCloneBehaviorSpecialization::<Self>::default()).default_clone_behavior()
    }
    fn relationship_accessor()
        ->
            ::core::option::Option<bevy_ecs::relationship::ComponentRelationshipAccessor<Self>> {
        ::core::option::Option::None
    }
}Component)]
322struct HandleMousePanObserver(Option<Entity>);
323
324fn add_window_observer(
325    drag_start: On<PointerDragStart>,
326    mut commands: Commands,
327    render_targets: Query<&RenderTarget, With<PanCamera>>,
328    primary_window: Single<Entity, With<PrimaryWindow>>,
329) {
330    for render_target in render_targets.iter() {
331        if let RenderTarget::Window(window) = render_target {
332            let entity = match window {
333                WindowRef::Primary => primary_window.entity(),
334                WindowRef::Entity(entity) => *entity,
335            };
336            if entity == drag_start.entity {
337                let observer_id = commands
338                    .spawn(Observer::new(handle_mouse_pan).with_entity(entity))
339                    .id();
340                commands
341                    .entity(entity)
342                    .insert(HandleMousePanObserver(Some(observer_id)));
343            }
344        }
345    }
346}
347
348fn remove_window_observer(
349    drag_end: On<PointerDragEnd>,
350    mut commands: Commands,
351    render_targets: Query<&RenderTarget, With<PanCamera>>,
352    mut handle_mouse_pan_observer: Query<&mut HandleMousePanObserver>,
353    primary_window: Single<Entity, With<PrimaryWindow>>,
354) {
355    for render_target in render_targets.iter() {
356        if let RenderTarget::Window(window) = render_target {
357            let entity = match window {
358                WindowRef::Primary => primary_window.entity(),
359                WindowRef::Entity(entity) => *entity,
360            };
361            if entity == drag_end.entity
362                && let Ok(mut observer) = handle_mouse_pan_observer.get_mut(entity)
363                && let Some(observer_entity) = observer.0.take()
364            {
365                commands.entity(observer_entity).despawn();
366            }
367        }
368    }
369}
370
371fn handle_mouse_pan(
372    drag: On<PointerDrag>,
373    mut pan_cameras: Query<(&Camera, &GlobalTransform, &mut Transform, &PanCamera)>,
374) {
375    for (camera, global_transform, mut transform, pan_camera_controller) in pan_cameras.iter_mut() {
376        if !pan_camera_controller.enabled
377            || !pan_camera_controller.mouse_pan_settings.enabled
378            || drag.button != pan_camera_controller.mouse_pan_settings.button
379        {
380            return;
381        }
382
383        let Ok(camera_screen_position) =
384            camera.world_to_viewport(global_transform, transform.translation)
385        else {
386            continue;
387        };
388
389        let offset_camera_screen_position = camera_screen_position + drag.delta * -1.; // inverted feels more natural
390
391        let Ok(new_camera_position) =
392            camera.viewport_to_world_2d(global_transform, offset_camera_screen_position)
393        else {
394            continue;
395        };
396
397        transform.translation = new_camera_position.extend(transform.translation.z);
398    }
399}