bevy_camera_controller 0.19.0

Premade camera controllers for Bevy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
//! A camera controller that allows the user to move freely around the scene.
//!
//! Free cameras are helpful for exploring large scenes, level editors and for debugging.
//! They are rarely useful as-is for gameplay,
//! as they allow the user to move freely in all directions,
//! which can be disorienting, and they can clip through objects and terrain.
//!
//! You may have heard of a "fly camera" — a type of free camera designed for fluid "flying" movement and quickly surveying large areas.
//! By contrast, the default settings of this particular free camera are optimized for precise control.
//!
//! To use this controller, add [`FreeCameraPlugin`] to your app,
//! and attach the [`FreeCamera`] component to your camera entity.
//! The required [`FreeCameraState`] component will be added automatically.
//!
//! To configure the settings of this controller, modify the fields of the [`FreeCamera`] component.
// TODO: Discuss switching camera to orthographic mode.

use bevy_app::{App, Plugin, RunFixedMainLoop, RunFixedMainLoopSystems};
use bevy_camera::Camera;
use bevy_ecs::prelude::*;
use bevy_input::keyboard::KeyCode;
use bevy_input::mouse::{
    AccumulatedMouseMotion, AccumulatedMouseScroll, MouseButton, MouseScrollUnit,
};
use bevy_input::touch::Touches;
use bevy_input::ButtonInput;
use bevy_log::info;
use bevy_math::curve::{Interval, SampleAutoCurve};
use bevy_math::Curve;
use bevy_math::{ops::exp, Dir3, EulerRot, Quat, StableInterpolate, Vec2, Vec3};
use bevy_time::{Real, Time};
use bevy_transform::prelude::Transform;
use bevy_window::{CursorGrabMode, CursorOptions, Window};

use core::{f32::consts::*, fmt};

/// A freecam-style camera controller plugin.
///
/// Use the [`FreeCamera`] struct to add and customize the controller for a camera entity.
/// The camera's dynamic state is managed by the [`FreeCameraState`] struct.
pub struct FreeCameraPlugin;

impl Plugin for FreeCameraPlugin {
    fn build(&self, app: &mut App) {
        // This ordering is required so that both fixed update and update systems can see the results correctly
        app.add_systems(
            RunFixedMainLoop,
            (run_freecamera_controller, rotate_freecam_to)
                .chain()
                .in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
        );
    }
}

/// Scales mouse motion into yaw/pitch movement.
///
/// Based on Valorant's default sensitivity, not entirely sure why it is exactly 1.0 / 180.0,
/// but we're guessing it is a misunderstanding between degrees/radians and then sticking with
/// it because it felt nice.
const RADIANS_PER_DOT: f32 = 1.0 / 180.0;

/// Stores the settings for the [`FreeCamera`] controller.
///
/// This component defines static configuration for camera controls,
/// including movement speed, sensitivity, and input bindings.
///
/// From the controller’s perspective, this data is treated as immutable,
/// but it may be modified externally (e.g., by a settings UI) at runtime.
///
/// Add this component to a [`Camera`] entity to enable `FreeCamera` controls.
/// The associated dynamic state is automatically handled by [`FreeCameraState`],
/// which is added to the entity as a required component.
///
/// To activate the controller, add the [`FreeCameraPlugin`] to your [`App`].
#[derive(Component, Clone)]
#[require(FreeCameraState)]
pub struct FreeCamera {
    /// Multiplier for pitch and yaw rotation speed.
    pub sensitivity: f32,
    /// [`KeyCode`] for forward translation.
    pub key_forward: KeyCode,
    /// [`KeyCode`] for backward translation.
    pub key_back: KeyCode,
    /// [`KeyCode`] for left translation.
    pub key_left: KeyCode,
    /// [`KeyCode`] for right translation.
    pub key_right: KeyCode,
    /// [`KeyCode`] for up translation.
    pub key_up: KeyCode,
    /// [`KeyCode`] for down translation.
    pub key_down: KeyCode,
    /// [`KeyCode`] to use [`run_speed`](FreeCamera::run_speed) instead of
    /// [`walk_speed`](FreeCamera::walk_speed) for translation.
    pub key_run: KeyCode,
    /// [`MouseButton`] for grabbing the mouse focus.
    pub mouse_key_cursor_grab: MouseButton,
    /// [`KeyCode`] for grabbing the keyboard focus.
    pub keyboard_key_toggle_cursor_grab: KeyCode,
    /// Modifier [`KeyCode`] for making pressed axis alignment buttons go in opposite direction
    pub key_snap_reverse: KeyCode,
    /// [`KeyCode`] for snapping camera to top/bottom (+Y/-Y).
    pub axis_top: KeyCode,
    /// [`KeyCode`] for snapping camera to right/left (+X/-X).
    pub axis_right: KeyCode,
    /// [`KeyCode`] for snapping camera to front/back (-Z/+Z).
    pub axis_front: KeyCode,
    /// Base multiplier for unmodified translation speed.
    pub walk_speed: f32,
    /// Base multiplier for running translation speed.
    pub run_speed: f32,
    /// Multiplier for how much the mouse scroll wheel affects [`walk_speed`](FreeCamera::walk_speed)
    /// and [`run_speed`](FreeCamera::run_speed).
    ///
    /// Mouse scroll affects speed exponentially. This is to ensure that scrolling the same
    /// amount always has the same effect on speed, regardless of how the scroll amount
    /// is reported by the hardware (i.e. as one big event vs many smaller events). This
    /// also allows the free camera to navigate very large scenes easier.
    ///
    /// For every unit of scroll, the speed of the camera is multiplied by a factor of
    /// `e^(scroll_factor)`.
    ///
    /// A reasonable value to start with is a `scroll_factor` between 0.04879016 (~ln(1.05))
    /// and 0.0953102 (~ln(1.1)). They represent an increase by a factor between 1.05 and 1.1 per
    /// positive unit scroll and a reduction between ~0.952 (~e^-0.04879016) and ~0.909
    /// (~e^-0.0953102) times its value per negative unit scroll
    ///
    /// A `scroll_factor` closer to 0.0 means that speed will be less sensitive to scroll.
    /// A `scroll_factor` equal to 0.0 means that speed is unaffected by scroll
    /// (it will be multiplied by a factor of 1.0 per positive and negative unit scroll).
    pub scroll_factor: f32,
    /// Friction factor used to exponentially decay [`velocity`](FreeCameraState::velocity) over time.
    pub friction: f32,
    /// Speed of camera rotation to snapped axis in radians/second
    pub rotation_speed: f32,
    /// Whether the vertical inputs translate the camera in world or local space axes.
    pub vertical_movement_axis: VerticalMovementAxis,
}

impl Default for FreeCamera {
    fn default() -> Self {
        Self {
            sensitivity: 0.2,
            key_forward: KeyCode::KeyW,
            key_back: KeyCode::KeyS,
            key_left: KeyCode::KeyA,
            key_right: KeyCode::KeyD,
            key_up: KeyCode::KeyE,
            key_down: KeyCode::KeyQ,
            key_run: KeyCode::ShiftLeft,
            mouse_key_cursor_grab: MouseButton::Right,
            keyboard_key_toggle_cursor_grab: KeyCode::KeyM,
            key_snap_reverse: KeyCode::ControlLeft,
            axis_top: KeyCode::Numpad7,
            axis_right: KeyCode::Numpad3,
            axis_front: KeyCode::Numpad1,
            walk_speed: 5.0,
            run_speed: 15.0,
            // Approximation of ln(1.05)
            scroll_factor: 0.04879016,
            friction: 40.0,
            rotation_speed: PI / 16.0 * 60.0,
            vertical_movement_axis: VerticalMovementAxis::default(),
        }
    }
}

impl fmt::Display for FreeCamera {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "
Freecamera Controls:
    Mouse\t- Move camera orientation
    Scroll\t- Adjust movement speed
    {:?}\t- Hold to grab cursor
    {:?}\t- Toggle cursor grab
    {:?} & {:?}\t- Fly forward & backwards
    {:?} & {:?}\t- Fly sideways left & right
    {:?} & {:?}\t- Fly up & down
    {:?}\t- Fly faster while held
    [{:?} + ]{:?}\t- Snap to Up (+Y)/Down (-Y)
    [{:?} + ]{:?}\t- Snap to Right (+X)/Left (-X)
    [{:?} + ]{:?}\t- Snap to Front (-Z)/Back (+Z)",
            self.mouse_key_cursor_grab,
            self.keyboard_key_toggle_cursor_grab,
            self.key_forward,
            self.key_back,
            self.key_left,
            self.key_right,
            self.key_up,
            self.key_down,
            self.key_run,
            self.key_snap_reverse,
            self.axis_top,
            self.key_snap_reverse,
            self.axis_right,
            self.key_snap_reverse,
            self.axis_front,
        )
    }
}

/// Whether the vertical inputs translate the camera in world or local space axes.
#[derive(Debug, Default, Clone, Copy)]
pub enum VerticalMovementAxis {
    /// Vertical movement is aligned to the world.
    ///
    /// This is the default behavior in Bevy, Unreal and Blender.
    #[default]
    World,
    /// Vertical movement follows the camera's rotation.
    ///
    /// This is the default behavior in Unity and Godot.
    Local,
}

/// Tracks the runtime state of a [`FreeCamera`] controller.
///
/// This component holds dynamic data that changes during camera operation,
/// such as pitch, yaw, velocity, and whether the controller is currently enabled.
///
/// It is automatically added to any entity that has a [`FreeCamera`] component,
/// and is updated by the [`FreeCameraPlugin`] systems in response to user input.
#[derive(Component)]
pub struct FreeCameraState {
    /// Enables [`FreeCamera`] controls when `true`.
    pub enabled: bool,
    /// Internal flag indicating if this controller has been initialized by the [`FreeCameraPlugin`].
    initialized: bool,
    /// This [`FreeCamera`]'s pitch rotation.
    pub pitch: f32,
    /// This [`FreeCamera`]'s yaw rotation.
    pub yaw: f32,
    /// Multiplier applied to movement speed.
    pub speed_multiplier: f32,
    /// This [`FreeCamera`]'s translation velocity.
    pub velocity: Vec3,
    /// Dictates camera movement during camera snap at speed, specified in [`FreeCamera`] by [`FreeCamera::rotation_speed`] field.
    /// Consist of counter of seconds from pressing curve snap hotkeys and curve that used to interpolate between old and new rotation
    pub rotation_curve: Option<(f32, SampleAutoCurve<Quat>)>,
}

impl Default for FreeCameraState {
    fn default() -> Self {
        Self {
            enabled: true,
            initialized: false,
            pitch: 0.0,
            yaw: 0.0,
            speed_multiplier: 1.0,
            velocity: Vec3::ZERO,
            rotation_curve: None,
        }
    }
}

/// Updates the camera's position and orientation based on user input.
///
/// - [`FreeCamera`] contains static configuration such as key bindings, movement speed, and sensitivity.
/// - [`FreeCameraState`] stores the dynamic runtime state, including pitch, yaw, velocity, and enable flags.
///
/// This system is typically added via the [`FreeCameraPlugin`].
///
/// Axis snapping takes priority over mouse movement.
pub fn run_freecamera_controller(
    time: Res<Time<Real>>,
    mut windows: Query<(&Window, &mut CursorOptions)>,
    accumulated_mouse_motion: Res<AccumulatedMouseMotion>,
    accumulated_mouse_scroll: Res<AccumulatedMouseScroll>,
    touch_input: Res<Touches>,
    mouse_button_input: Res<ButtonInput<MouseButton>>,
    key_input: Res<ButtonInput<KeyCode>>,
    mut toggle_cursor_grab: Local<bool>,
    mut mouse_cursor_grab: Local<bool>,
    mut query: Query<(&mut Transform, &mut FreeCameraState, &FreeCamera), With<Camera>>,
) {
    let dt = time.delta_secs();

    let Ok((mut transform, mut state, config)) = query.single_mut() else {
        return;
    };

    if !state.initialized {
        let (yaw, pitch, _roll) = transform.rotation.to_euler(EulerRot::YXZ);
        state.yaw = yaw;
        state.pitch = pitch;
        state.initialized = true;
        info!("{}", *config);
    }

    if !state.enabled {
        // don't keep the cursor grabbed if the camera controller was disabled.
        if *toggle_cursor_grab || *mouse_cursor_grab {
            *toggle_cursor_grab = false;
            *mouse_cursor_grab = false;

            for (_, mut cursor_options) in &mut windows {
                cursor_options.grab_mode = CursorGrabMode::None;
                cursor_options.visible = true;
            }
        }
        return;
    }

    let scroll = match accumulated_mouse_scroll.unit {
        MouseScrollUnit::Line => accumulated_mouse_scroll.delta.y,
        MouseScrollUnit::Pixel => {
            accumulated_mouse_scroll.delta.y / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
        }
    };
    // By using exponentiation we ensure that this scales up and down smoothly
    // regardless of the amount of scrolling processed per frame
    state.speed_multiplier *= exp(config.scroll_factor * scroll);
    // Clamp the speed multiplier for safety.
    state.speed_multiplier = state.speed_multiplier.clamp(f32::EPSILON, f32::MAX);

    // Handle key input
    let mut axis_input = Vec3::ZERO;
    if key_input.pressed(config.key_forward) {
        axis_input.z += 1.0;
    }
    if key_input.pressed(config.key_back) {
        axis_input.z -= 1.0;
    }
    if key_input.pressed(config.key_right) {
        axis_input.x += 1.0;
    }
    if key_input.pressed(config.key_left) {
        axis_input.x -= 1.0;
    }
    if key_input.pressed(config.key_up) {
        axis_input.y += 1.0;
    }
    if key_input.pressed(config.key_down) {
        axis_input.y -= 1.0;
    }

    let mut cursor_grab_change = false;
    if key_input.just_pressed(config.keyboard_key_toggle_cursor_grab) {
        *toggle_cursor_grab = !*toggle_cursor_grab;
        cursor_grab_change = true;
    }
    if mouse_button_input.just_pressed(config.mouse_key_cursor_grab) {
        *mouse_cursor_grab = true;
        cursor_grab_change = true;
    }
    if mouse_button_input.just_released(config.mouse_key_cursor_grab) {
        *mouse_cursor_grab = false;
        cursor_grab_change = true;
    }
    let cursor_grab = *mouse_cursor_grab || *toggle_cursor_grab;

    // Update velocity
    if axis_input != Vec3::ZERO {
        let max_speed = if key_input.pressed(config.key_run) {
            config.run_speed * state.speed_multiplier
        } else {
            config.walk_speed * state.speed_multiplier
        };
        state.velocity = axis_input.normalize() * max_speed;
    } else {
        let friction = config.friction.clamp(0.0, f32::MAX);
        state.velocity.smooth_nudge(&Vec3::ZERO, friction, dt);
        if state.velocity.length_squared() < 1e-6 {
            state.velocity = Vec3::ZERO;
        }
    }

    // Apply movement update
    if state.velocity != Vec3::ZERO {
        let forward = *transform.forward();
        let right = *transform.right();
        let up = match config.vertical_movement_axis {
            VerticalMovementAxis::World => Vec3::Y,
            VerticalMovementAxis::Local => *transform.up(),
        };
        transform.translation += state.velocity.x * dt * right
            + state.velocity.y * dt * up
            + state.velocity.z * dt * forward;
    }

    // Handle cursor grab
    if cursor_grab_change {
        if cursor_grab {
            for (window, mut cursor_options) in &mut windows {
                if !window.focused {
                    continue;
                }

                cursor_options.grab_mode = CursorGrabMode::Locked;
                cursor_options.visible = false;
            }
        } else {
            for (_, mut cursor_options) in &mut windows {
                cursor_options.grab_mode = CursorGrabMode::None;
                cursor_options.visible = true;
            }
        }
    }

    // Handle mouse input
    if accumulated_mouse_motion.delta != Vec2::ZERO && cursor_grab {
        // Apply look update
        state.pitch = (state.pitch
            - accumulated_mouse_motion.delta.y * RADIANS_PER_DOT * config.sensitivity)
            .clamp(-PI / 2., PI / 2.);
        state.yaw -= accumulated_mouse_motion.delta.x * RADIANS_PER_DOT * config.sensitivity;
        transform.rotation = Quat::from_euler(EulerRot::ZYX, 0.0, state.yaw, state.pitch);
    }

    // Handle touch input
    for touch in touch_input.iter() {
        if touch.delta() != Vec2::ZERO {
            state.pitch = (state.pitch - touch.delta().y * RADIANS_PER_DOT * config.sensitivity)
                .clamp(-PI / 2., PI / 2.);
            state.yaw -= touch.delta().x * RADIANS_PER_DOT * config.sensitivity;
            transform.rotation = Quat::from_euler(EulerRot::ZYX, 0.0, state.yaw, state.pitch);
        }
    }
    // Axis snapping
    let mod_key_pressed = key_input.pressed(config.key_snap_reverse);
    let mut rotate_to = None;
    if key_input.just_pressed(config.axis_front) {
        if mod_key_pressed {
            rotate_to = Some((Dir3::Z, Dir3::Y));
        } else {
            rotate_to = Some((Dir3::NEG_Z, Dir3::Y));
        }
    }
    if key_input.just_pressed(config.axis_right) {
        if mod_key_pressed {
            rotate_to = Some((Dir3::NEG_X, Dir3::Y));
        } else {
            rotate_to = Some((Dir3::X, Dir3::Y));
        }
    }
    if key_input.just_pressed(config.axis_top) {
        if mod_key_pressed {
            rotate_to = Some((Dir3::NEG_Y, Dir3::NEG_Z));
        } else {
            rotate_to = Some((Dir3::Y, Dir3::Z));
        }
    }
    if let Some((dir, up)) = rotate_to {
        let start = transform.rotation;
        let target = Transform::default().looking_to(dir, up).rotation; // I don't understand why Quat::look_to_rh produce different result.
        let angle = target.angle_between(start);
        let rotation_time = angle / config.rotation_speed;

        if let Ok(interval) = Interval::new(0.0, rotation_time) {
            let curve = SampleAutoCurve::new(interval, [start, target])
                .expect("Interval should be in bounds as start and end are finite numbers");
            state.rotation_curve = Some((0.0, curve));
        }
    }
}

/// Smoothly changes orientation([`Transform`]) of [`FreeCamera`] camera according to target orientation in [`FreeCameraState`].
///
/// - [`FreeCamera`] contains static configuration such as key bindings and rotation speed.
/// - [`FreeCameraState`] stores the dynamic runtime state, including direction for camera rotation and enable flags.
///
/// This system is typically added via the [`FreeCameraPlugin`].
pub fn rotate_freecam_to(
    mut query: Query<(&mut Transform, &mut FreeCameraState), With<Camera>>,
    time: Res<Time<Real>>,
) {
    let Ok((mut transform, mut state)) = query.single_mut() else {
        return;
    };
    if !state.enabled {
        return;
    }
    let Some((progress, curve)) = state.rotation_curve.as_mut() else {
        return;
    };
    *progress += time.delta_secs();
    transform.rotation = curve.sample_clamped(*progress);
    if !curve.domain().contains(*progress) {
        state.rotation_curve = None;
    }
    let (yaw, pitch, _roll) = transform.rotation.to_euler(EulerRot::YXZ);
    state.pitch = pitch;
    state.yaw = yaw;
}