saddle-camera-rts-camera

Reusable RTS pan, zoom, rotate, and terrain-follow camera for Bevy


Reusable RTS, builder, and tactics camera for Bevy.

Target-state camera model with smoothing, terrain probing, bounds, cursor-aware zoom and rotation pivots, and follow-target support. Bevy-only and project-agnostic — no game states, UI deps, physics deps, or project imports.

What It Is For

  • Perspective RTS and city-builder cameras
  • Map editors, scenario editors, and god-game navigation
  • Keyboard pan, edge pan, drag pan, wheel zoom, and yaw rotation
  • Terrain-aware focus height using Bevy mesh ray casts
  • Zoom-anchor and rotation-pivot policies driven by focus or cursor-ground hits
  • Bounded camera travel with hard or soft map limits
  • Camera collision avoidance against terrain to prevent clipping
  • Follow-target views for alerts, spectating, and points of interest
  • Programmatic fly-to commands and bookmark save/recall for RTS workflows

Quick Start

[dependencies]
saddle-camera-rts-camera = { git = "https://github.com/julien-blanchon/saddle-camera-rts-camera", default-features = false }
bevy = "0.18"
[dependencies]
saddle-camera-rts-camera = { git = "https://github.com/julien-blanchon/saddle-camera-rts-camera", default-features = false }
bevy = "0.18"
use bevy::prelude::*;
use saddle_camera_rts_camera::{
    RtsCamera, RtsCameraGround, RtsCameraInputTarget, RtsCameraPlugin, RtsCameraSettings,
};

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, RtsCameraPlugin::default()))
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Name::new("Terrain"),
        Mesh3d(meshes.add(Plane3d::default().mesh().size(64.0, 64.0))),
        MeshMaterial3d(materials.add(Color::srgb(0.22, 0.30, 0.20))),
        RtsCameraGround,
    ));

    commands.spawn((
        Name::new("RTS Camera"),
        RtsCamera::looking_at(Vec3::ZERO, Vec3::new(-18.0, 18.0, 18.0)),
        RtsCameraSettings::default(),
        RtsCameraInputTarget,
    ));
}
use bevy::prelude::*;
use saddle_camera_rts_camera::{
    RtsCamera, RtsCameraGround, RtsCameraInputTarget, RtsCameraPlugin, RtsCameraSettings,
};

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, RtsCameraPlugin::default()))
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Name::new("Terrain"),
        Mesh3d(meshes.add(Plane3d::default().mesh().size(64.0, 64.0))),
        MeshMaterial3d(materials.add(Color::srgb(0.22, 0.30, 0.20))),
        RtsCameraGround,
    ));

    commands.spawn((
        Name::new("RTS Camera"),
        RtsCamera::looking_at(Vec3::ZERO, Vec3::new(-18.0, 18.0, 18.0)),
        RtsCameraSettings::default(),
        RtsCameraInputTarget,
    ));
}

The core runtime is headless by default and is driven through the RtsCamera target state plus the RtsCameraInput inbox. Consumers can mutate those public components directly or feed them from their own input layer.

For the raw Bevy fallback bridge used in the RTS-style examples, keep the default fallback-input feature enabled and add RtsCameraFallbackInputPlugin explicitly:

use bevy::prelude::*;
use saddle_camera_rts_camera::{RtsCameraFallbackInputPlugin, RtsCameraPlugin};

fn app_with_fallback_controls(app: &mut App) {
    app.add_plugins((
        RtsCameraPlugin::default(),
        RtsCameraFallbackInputPlugin::default(),
    ));
}
use bevy::prelude::*;
use saddle_camera_rts_camera::{RtsCameraFallbackInputPlugin, RtsCameraPlugin};

fn app_with_fallback_controls(app: &mut App) {
    app.add_plugins((
        RtsCameraPlugin::default(),
        RtsCameraFallbackInputPlugin::default(),
    ));
}

Then insert RtsCameraFallbackControls on the camera entity you want to drive.

That same input surface also handles one-shot fly-to commands plus bookmark save/recall, so minimaps, alerts, and editor tooling can reuse the same smoothing path as manual pan/zoom/rotate controls.

RtsCameraInput keeps general pan intent separate from edge-pan intent so RtsCameraControlFlags::pan and RtsCameraControlFlags::edge_pan can be toggled independently at runtime.

For always-on tools and examples, RtsCameraPlugin::always_on(Update) is the convenience constructor.

Public API

TypePurpose
RtsCameraPluginRegisters the runtime with injectable activate, deactivate, and update schedules
RtsCameraFallbackInputPluginOptional raw Bevy keyboard/mouse bridge that writes into RtsCameraInput
RtsCameraSystemsPublic ordering hooks: ReadInput, ResolveTarget, FollowGround, ApplyBounds, AdvanceRuntime, ResolveCollision, SyncTransform, Debug
RtsCameraMain controller component containing desired focus, yaw, distance, and snap requests
RtsCameraRuntimeSmoothed runtime state: focus, yaw, pitch, distance, ground height, last ground hit, last cursor anchor
RtsCameraBookmark / RtsCameraBookmarksCaptured camera views for RTS-style bookmark save/recall
RtsCameraSettingsTop-level tuning surface for distance, pitch, motion, bounds, terrain follow, collision, anchors, and control flags
RtsCameraBounds / RtsCameraBoundsModeHard or soft travel limits for focus XZ
RtsCameraGroundMarker for meshes that participate in terrain-follow and cursor-ground ray casts
RtsCameraFollowOptional follow-target seam with offset and snap behavior
RtsCameraInputPublic intent inbox for pan, zoom, rotate, drag, and cursor position
RtsCameraInputTargetOpt-in marker for which camera should consume shared pointer input
RtsCameraFallbackControlsExplicit raw-input fallback path for examples, prototypes, or tool scenes
RtsCameraDebugEnables focus, anchor, ground-hit, and bounds gizmos
MessagesRtsCameraBookmarkStored, RtsCameraBookmarkRecalled, RtsCameraFlyToApplied

Input Model

  • Production path: Use your own input adapter and write into RtsCameraInput.
  • Headless/programmatic path: Skip input adapters entirely and drive RtsCamera / RtsCameraInput from gameplay, tools, automation, or server-side systems.
  • Optional fallback: RtsCameraFallbackControls plus RtsCameraFallbackInputPlugin provide a raw Bevy-input bridge for examples, prototypes, and lightweight tools.
  • bevy_enhanced_input: The examples show a BEI integration that keeps the runtime crate itself Bevy-only.

The fallback-input cargo feature is enabled by default for convenience, but the core plugin no longer installs any fallback reader on its own. Projects that want the smallest possible runtime surface can disable default features and depend on the headless core only.

Only cameras tagged with RtsCameraInputTarget participate in shared pointer input. If several active cameras carry the marker, the highest Camera.order wins.

State Model

The crate deliberately separates:

  • Desired state in RtsCamera
  • Smoothed resolved state in RtsCameraRuntime
  • Final rendered transform written in PostUpdate

That split keeps smoothing, cursor-anchor adjustments, follow offsets, and programmatic teleports on the same code path.

Examples

Run from the examples/ directory:

cd examples
cargo run -p saddle-camera-rts-camera-example-basic
cd examples
cargo run -p saddle-camera-rts-camera-example-basic
ExamplePurpose
basicExplicit RTS raw-input mapping adapter on flat terrain
bookmarksBattlefield command posts with RTS-style bookmark recall and overwrite
terrain_followUneven ground with focus-height gizmos
cursor_zoomFocus zoom by default, Alt for cursor-preserving zoom
boundsSoft bounds behavior with visible clamp loop
follow_targetMoving follow target with manual offset adjustment
headlessNo-input mode driven only by scripted RtsCameraInput fly-to commands
enhanced_inputbevy_enhanced_input bridge writing into RtsCameraInput

Every example now ships with a live saddle-pane control surface so distance limits, cursor zoom policy, pan speeds, and debug gizmos can be tuned at runtime.

Known Limitations

  • The runtime currently targets perspective cameras only.
  • Terrain follow uses Bevy mesh ray casts against RtsCameraGround meshes. If a project already owns a richer terrain query, it can bypass the built-in probing by writing the desired focus height into RtsCamera.
  • There is intentionally no built-in UI hover gate. Consumers should disable RtsCameraInputTarget or stop writing RtsCameraInput when UI should own the pointer.

Docs

License

MIT-0 — use freely, no attribution required.