feat(pan-cam): add scaffolding for 2D pan camera controller (#21520)

# Objective

Implements scaffolding for a 2D pan camera controller.

Fixes #21468 


## Solution

- Introduced a `PanCam` component with settings for panning, zooming,
and rotation via keyboard input, following the design of the existing
`FreeCam`.
- Added a `PanCamPlugin` to register the controller system.
- Implemented keyboard-based panning and rotation.


## TODOs

- Movement is currently world-axis aligned. 
  - TODO: Consider movement relative to camera rotation.
- Zooming support is scaffolded with config fields but not yet
implemented.


## Testing

Unfortunately, I was unable to fully test this implementation due to
issues running graphical output with GPU acceleration under WSL.
As a result, zoom behavior and rotation effects remain TODOs, and the
whole code could not be fully verified.

Once I resolve the GPU passthrough issues, I plan to complete and test
the remaining features (with a more meaningful example).

---

I'm happy to hear any suggestions or feedback in the meantime!

---------

Co-authored-by: syszery <syszery@users.noreply.github.com>
Co-authored-by: Janis <130913856+janis-bhm@users.noreply.github.com>
This commit is contained in:
syszery
2025-10-16 23:32:24 +02:00
committed by GitHub
parent 4b27d4662e
commit 81d9c88950
9 changed files with 316 additions and 2 deletions
+15
View File
@@ -386,6 +386,9 @@ bevy_camera_controller = ["bevy_internal/bevy_camera_controller"]
# Enables the free cam from bevy_camera_controller
free_cam = ["bevy_internal/free_cam"]
# Enables the pan cam from bevy_camera_controller
pan_cam = ["bevy_internal/pan_cam"]
# Enable the Bevy Remote Protocol
bevy_remote = ["bevy_internal/bevy_remote"]
@@ -4831,3 +4834,15 @@ name = "Render Depth to Texture"
description = "Demonstrates how to use depth-only cameras"
category = "Shaders"
wasm = true
[[example]]
name = "pan_cam_controller"
path = "examples/camera/pan_cam_controller.rs"
doc-scrape-examples = true
required-features = ["pan_cam"]
[package.metadata.example.pan_cam_controller]
name = "Pan Cam"
description = "Example Pan-Cam Styled Camera Controller for 2D scenes"
category = "Camera"
wasm = true
+1
View File
@@ -30,6 +30,7 @@ libm = ["bevy_math/libm"]
# Camera controllers
free_cam = []
pan_cam = []
[lints]
workspace = true
+3
View File
@@ -40,3 +40,6 @@
#[cfg(feature = "free_cam")]
pub mod free_cam;
#[cfg(feature = "pan_cam")]
pub mod pan_cam;
@@ -0,0 +1,237 @@
//! A camera controller for 2D scenes that supports panning and zooming.
//!
//! To use this controller, add [`PanCamPlugin`] to your app,
//! and insert a [`PanCam`] component into your camera entity.
//!
//! To configure the settings of this controller, modify the fields of the [`PanCam`] component.
use bevy_app::{App, Plugin, RunFixedMainLoop, RunFixedMainLoopSystems};
use bevy_camera::Camera;
use bevy_ecs::prelude::*;
use bevy_input::keyboard::KeyCode;
use bevy_input::mouse::{AccumulatedMouseScroll, MouseScrollUnit};
use bevy_input::ButtonInput;
use bevy_math::{Vec2, Vec3};
use bevy_time::{Real, Time};
use bevy_transform::prelude::Transform;
use core::{f32::consts::*, fmt};
/// A pancam-style camera controller plugin.
///
/// Use [`PanCam`] to add a pancam controller to a camera entity,
/// and change its values to customize the controls and change its behavior.
pub struct PanCamPlugin;
impl Plugin for PanCamPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
RunFixedMainLoop,
run_pancam_controller.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop),
);
}
}
/// Pancam controller settings and state.
///
/// Add this component to a [`Camera`] entity and add [`PanCamPlugin`]
/// to your [`App`] to enable pancam controls.
#[derive(Component)]
pub struct PanCam {
/// Enables this [`PanCam`] when `true`.
pub enable: bool,
/// Current zoom level (factor applied to camera scale).
pub zoom_factor: f32,
/// Minimum allowed zoom level.
pub min_zoom: f32,
/// Maximum allowed zoom level.
pub max_zoom: f32,
/// This [`PanCam`]'s zoom sensitivity.
pub zoom_speed: f32,
/// [`KeyCode`] to zoom in.
pub key_zoom_in: Option<KeyCode>,
/// [`KeyCode`] to zoom out.
pub key_zoom_out: Option<KeyCode>,
/// This [`PanCam`]'s translation speed.
pub pan_speed: f32,
/// [`KeyCode`] for upward translation.
pub key_up: Option<KeyCode>,
/// [`KeyCode`] for downward translation.
pub key_down: Option<KeyCode>,
/// [`KeyCode`] for leftward translation.
pub key_left: Option<KeyCode>,
/// [`KeyCode`] for rightward translation.
pub key_right: Option<KeyCode>,
/// Rotation speed multiplier (in radians per second).
pub rotation_speed: f32,
/// [`KeyCode`] for counter-clockwise rotation.
pub key_rotate_ccw: Option<KeyCode>,
/// [`KeyCode`] for clockwise rotation.
pub key_rotate_cw: Option<KeyCode>,
}
/// Provides the default values for the `PanCam` controller.
///
/// The default settings are:
/// - Zoom factor: 1.0
/// - Min zoom: 0.1
/// - Max zoom: 5.0
/// - Zoom speed: 0.1
/// - Zoom in/out key: +/-
/// - Pan speed: 500.0
/// - Move up/down: W/S
/// - Move left/right: A/D
/// - Rotation speed: PI (radiradians per second)
/// - Rotation ccw/cw: Q/E
impl Default for PanCam {
/// Provides the default values for the `PanCam` controller.
///
/// Users can override these values by manually creating a `PanCam` instance
/// or modifying the default instance.
fn default() -> Self {
Self {
enable: true,
zoom_factor: 1.0,
min_zoom: 0.1,
max_zoom: 5.0,
zoom_speed: 0.1,
key_zoom_in: Some(KeyCode::Equal),
key_zoom_out: Some(KeyCode::Minus),
pan_speed: 500.0,
key_up: Some(KeyCode::KeyW),
key_down: Some(KeyCode::KeyS),
key_left: Some(KeyCode::KeyA),
key_right: Some(KeyCode::KeyD),
rotation_speed: PI,
key_rotate_ccw: Some(KeyCode::KeyQ),
key_rotate_cw: Some(KeyCode::KeyE),
}
}
}
impl PanCam {
fn key_to_string(key: &Option<KeyCode>) -> String {
key.map_or("None".to_string(), |k| format!("{:?}", k))
}
}
impl fmt::Display for PanCam {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"
PanCam Controls:
Move Up / Down - {} / {}
Move Left / Right - {} / {}
Rotate CCW / CW - {} / {}
Zoom - Mouse Scroll + {} / {}
",
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),
)
}
}
/// This system is typically added via the [`PanCamPlugin`].
///
/// Reads inputs and then moves the camera entity according
/// to the settings given in [`PanCam`].
///
/// **Note**: The zoom applied in this controller is linear. The zoom factor is directly adjusted
/// based on the input (either from the mouse scroll or keyboard).
fn run_pancam_controller(
time: Res<Time<Real>>,
key_input: Res<ButtonInput<KeyCode>>,
accumulated_mouse_scroll: Res<AccumulatedMouseScroll>,
mut query: Query<(&mut Transform, &mut PanCam), With<Camera>>,
) {
let dt = time.delta_secs();
let Ok((mut transform, mut controller)) = query.single_mut() else {
return;
};
if !controller.enable {
return;
}
// === Movement
let mut movement = Vec2::ZERO;
if let Some(key) = controller.key_left {
if key_input.pressed(key) {
movement.x -= 1.0;
}
}
if let Some(key) = controller.key_right {
if key_input.pressed(key) {
movement.x += 1.0;
}
}
if let Some(key) = controller.key_down {
if key_input.pressed(key) {
movement.y -= 1.0;
}
}
if let Some(key) = controller.key_up {
if key_input.pressed(key) {
movement.y += 1.0;
}
}
if movement != Vec2::ZERO {
let right = transform.right();
let up = transform.up();
let delta = (right * movement.x + up * movement.y).normalize() * controller.pan_speed * dt;
transform.translation.x += delta.x;
transform.translation.y += delta.y;
}
// === Rotation
if let Some(key) = controller.key_rotate_ccw {
if key_input.pressed(key) {
transform.rotate_z(controller.rotation_speed * dt);
}
}
if let Some(key) = controller.key_rotate_cw {
if key_input.pressed(key) {
transform.rotate_z(-controller.rotation_speed * dt);
}
}
// === Zoom
let mut zoom_amount = 0.0;
// (with keys)
if let Some(key) = controller.key_zoom_in {
if key_input.pressed(key) {
zoom_amount -= controller.zoom_speed;
}
}
if let Some(key) = controller.key_zoom_out {
if key_input.pressed(key) {
zoom_amount += controller.zoom_speed;
}
}
// (with mouse wheel)
let mouse_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
}
};
zoom_amount += mouse_scroll * controller.zoom_speed;
controller.zoom_factor =
(controller.zoom_factor - zoom_amount).clamp(controller.min_zoom, controller.max_zoom);
transform.scale = Vec3::splat(controller.zoom_factor);
}
+1
View File
@@ -310,6 +310,7 @@ bevy_dev_tools = ["dep:bevy_dev_tools"]
# Provides a collection of prebuilt camera controllers
bevy_camera_controller = ["dep:bevy_camera_controller"]
free_cam = ["bevy_camera_controller/free_cam"]
pan_cam = ["bevy_camera_controller/pan_cam"]
# Enable support for the Bevy Remote Protocol
bevy_remote = ["dep:bevy_remote", "serialize"]
+1
View File
@@ -144,6 +144,7 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio
|morph_animation|Enables bevy_mesh and bevy_animation morph weight support|
|mp3|MP3 audio format support|
|multi_threaded|Enables multithreaded parallelism in the engine. Disabling it forces all engine tasks to run on a single thread.|
|pan_cam|Enables the pan cam from bevy_camera_controller|
|pbr_anisotropy_texture|Enable support for anisotropy texture in the `StandardMaterial`, at the risk of blowing past the global, per-shader texture limit on older/lower-end GPUs|
|pbr_clustered_decals|Enable support for Clustered Decals|
|pbr_light_textures|Enable support for Light Textures|
+1
View File
@@ -286,6 +286,7 @@ Example | Description
[Custom Projection](../examples/camera/custom_projection.rs) | Shows how to create custom camera projections.
[First person view model](../examples/camera/first_person_view_model.rs) | A first-person camera that uses a world model and a view model with different field of views (FOV)
[Free cam camera controller](../examples/camera/free_cam_controller.rs) | Shows the default free cam camera controller.
[Pan Cam](../examples/camera/pan_cam_controller.rs) | Example Pan-Cam Styled Camera Controller for 2D scenes
[Projection Zoom](../examples/camera/projection_zoom.rs) | Shows how to zoom orthographic and perspective projection cameras.
[Screen Shake](../examples/camera/2d_screen_shake.rs) | A simple 2D screen shake effect
+42
View File
@@ -0,0 +1,42 @@
//! Example for `PanCam`, demonstrating basic camera controls such as panning and zooming.
//!
//! This example shows how to use the `PanCam` controller on a 2D camera in Bevy. The camera
//! can be panned with keyboard inputs (arrow keys or WASD) and zoomed in/out using the mouse
//! wheel or the +/- keys. The camera starts with the default `PanCam` settings, which can
//! be customized.
//!
//! Controls:
//! - Arrow keys (or WASD) to pan the camera.
//! - Mouse scroll wheel or +/- to zoom in/out.
use bevy::camera_controller::pan_cam::{PanCam, PanCamPlugin};
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(PanCamPlugin) // Adds the PanCam plugin to enable camera panning and zooming controls.
.add_systems(Startup, (setup, spawn_text).chain())
.run();
}
fn spawn_text(mut commands: Commands, camera: Query<&PanCam>) {
commands.spawn((
Node {
position_type: PositionType::Absolute,
top: px(-16),
left: px(12),
..default()
},
children![Text::new(format!("{}", camera.single().unwrap()))],
));
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// Spawn a 2D Camera with default PanCam settings
commands.spawn((Camera2d, PanCam::default()));
commands.spawn(Sprite::from_image(
asset_server.load("branding/bevy_bird_dark.png"),
));
}
@@ -1,7 +1,7 @@
---
title: First-party camera controllers
authors: ["@alice-i-cecile"]
pull_requests: [20215, 21450]
authors: ["@alice-i-cecile", "@syszery"]
pull_requests: [20215, 21450, 21520]
---
To understand a scene, you must look at it through the lens of a camera: explore it, and interact with it.
@@ -34,6 +34,19 @@ To configure the settings (speed, behavior, keybindings) or enable / disable the
We've done our best to select good defaults, but the details of your scene (especially the scale!) will make a big
difference to what feels right.
### `PanCam`
The `PanCam` controller is a simple and effective tool designed for 2D games or any project where you need
to pan the camera and zoom in/out with ease. It allows you to move the camera using the WASD keys and zoom
in and out with the mouse wheel or +/- keys.
By adding the `PanCamPlugin` and attaching the `PanCam` component to your camera entity, you can quickly add
this controller to your project.
To configure the camera's zoom levels, speed, or keybindings, simply modify the `PanCam` component. The default
settings should work well for most use cases, but you can adjust them based on your specific needs, especially
for large-scale or high-resolution 2D scenes.
### Using `bevy_camera_controller` in your own projects
The provided camera controllers are designed to be functional, pleasant debug and dev tools: