saddle-camera-rts-camera keeps the controller state on the camera entity and splits runtime work into visible phases:
ReadInputResolveTargetFollowGroundApplyBoundsAdvanceRuntimeResolveCollisionSyncTransformDebug
The phases are exposed as public SystemSets so a consumer can order other gameplay, tool, or presentation systems around them.
Data Model
The runtime uses three layers of state instead of mutating Transform directly from input:
RtsCameraDesired state. This is the public control seam.target_focustarget_yawtarget_distancesnap
RtsCameraRuntimeSmoothed resolved state used to render the camera.focusyawpitchdistanceground_heightlast_ground_hitlast_cursor_anchor
TransformFinal camera pose derived from the runtime state inPostUpdate
That separation keeps programmatic jumps, follow-target motion, smoothing, bounds, and cursor-anchor compensation on one consistent path.
RtsCameraBookmarks lives on the same entity and stores optional saved views. Bookmark recall writes back into RtsCamera, so saved views travel through the same bounds, smoothing, and transform-sync pipeline as manual control.
Input Flow
The runtime accepts input through RtsCameraInput.
Production path
External systems write:
panedge_panzoomrotaterotate_drag_deltadrag_pan_activedrag_rotate_activecursor_positionzoom_to_cursorfly_to_focusfly_to_yawfly_to_distancefly_to_snapset_bookmark_slotrecall_bookmark_slotrecall_bookmark_snap
The examples and crate-local lab show a bevy_enhanced_input bridge that evaluates its context in Update, then writes this component after EnhancedInputSystems::Apply and before RtsCameraSystems::ResolveTarget.
Fallback path
If the fallback-input feature is enabled and the app adds RtsCameraFallbackInputPlugin, cameras carrying RtsCameraFallbackControls can opt into a raw Bevy keyboard/mouse bridge that writes the same RtsCameraInput component. The fallback adapter lives outside the core plugin so the runtime stays headless by default and does not hard-depend on any specific physical input source.
Only cameras tagged with RtsCameraInputTarget are candidates for shared pointer input. The active camera with the highest Camera.order wins.
System Flow
ReadInput
- consumer-owned input adapters
- optional
fallback::apply_fallback_controls
The core plugin does not install any input reader by default. This set exists as a public seam for consumer-owned input adapters, and the optional RtsCameraFallbackInputPlugin uses it for the crate's raw RTS-style fallback mapping.
ResolveTarget
initialize_added_camerasapply_programmatic_commandssync_follow_targetsapply_camera_input
initialize_added_cameras seeds runtime state from the authored target state so new cameras do not jump on their first frame.
apply_programmatic_commands resolves one-shot fly-to requests and bookmark save/recall before follow and manual input are processed.
sync_follow_targets updates target_focus from the tracked entity plus RtsCameraFollow.offset.
apply_camera_input is the core intent resolver:
- converts general pan and edge-pan intent into world-space movement relative to current yaw
- scales pan speed across the zoom range
- resolves cursor-ground anchors from
viewport_to_worldplusMeshRayCast - supports drag pan by preserving a cursor-ground world anchor
- supports cursor-preserving rotation pivots
- supports cursor-preserving zoom anchors
- updates follow offsets instead of fighting follow mode when follow is enabled
Terrain-Follow Pipeline
Terrain probing is intentionally generic:
- a downward
MeshRayCastis fired from above the smoothed runtime focus position - only entities marked
RtsCameraGroundparticipate - hit height is converted into desired focus height by adding
ground.clearance ground.keep_last_height_on_missdetermines whether the last valid height is retained when no ground mesh is hit
The runtime stores the resolved height in RtsCameraRuntime.ground_height and smooths vertical motion separately from horizontal focus motion. That avoids the common jitter where raycast height and lateral smoothing fight each other.
The debug set exposes both:
last_ground_hitlast_cursor_anchor
which makes BRP and screenshot-based debugging much easier when tuning terrain-follow behavior.
Bounds Pipeline
Bounds operate on focus XZ, not camera eye position.
HardClamptarget_focusdirectly into the allowed rectangle.SoftCompress pan deltas near the edge before the final clamp pass.
ApplyBounds still performs a final hard clamp even in soft mode so programmatic teleports, follow-target motion, or cursor-anchor compensation cannot push focus outside the legal rectangle.
When follow mode is active, clamp corrections are also applied to RtsCameraFollow.offset so the next sync_follow_targets pass does not immediately reintroduce the out-of-bounds position.
Smoothing Model
The crate uses frame-rate-independent exponential decay.
- horizontal focus smoothing:
motion.focus_decay - vertical terrain smoothing:
motion.ground_decay - yaw smoothing:
motion.yaw_decay - zoom smoothing:
motion.distance_decay
0.0 means snap immediately, not freeze. That behavior matters for consumers that want per-channel smoothing disabled without rewriting the runtime.
Yaw smoothing uses shortest-angle interpolation so values wrap cleanly across -PI..PI.
Pitch Model
Pitch is derived from zoom distance, not integrated separately.
- near distance:
pitch.near_angle - far distance:
pitch.far_angle
The resolved distance value determines pitch every frame, which keeps zoom and pitch coupling stable and predictable.
Collision Avoidance
After the runtime state is advanced, ResolveCollision prevents the camera eye from clipping through ground meshes.
- A backward ray is cast from the smoothed focus along the camera's look-back direction
- Only entities marked
RtsCameraGroundparticipate - If terrain is closer than
runtime.distance + collision.clearance, the runtime distance is clamped tohit_distance - clearance, floored atcollision.min_distance - The collision system only ever reduces runtime distance — it never increases it, so the target-state smoothing path remains authoritative for zoom-out
This is the same "spring arm" pattern used by Unreal Engine's camera boom. It runs after AdvanceRuntime so it sees the fully smoothed state, and before SyncTransform so the corrected distance is what reaches the final Transform.
Final Transform
SyncTransform runs in PostUpdate before transform propagation and writes the final Transform from:
- smoothed focus
- smoothed yaw
- derived pitch
- smoothed distance
Keeping this step in PostUpdate means consumer systems can order world simulation before camera resolve in Update, while anything that depends on the final rendered pose can read it after transform propagation in the same frame.
Why The Public Sets Exist
ReadInputLets consumers install their own input bridge, automation writer, or the optional fallback input plugin before target resolution.ResolveTargetLets gameplay systems update follow targets, bookmarks, alerts, or tool focus points before smoothing.FollowGroundLets projects insert custom terrain or ground-height authoring before or after the default probe.ApplyBoundsLets projects enforce additional map or scenario limits.AdvanceRuntimeLets downstream systems read the smoothed runtime state before render sync.ResolveCollisionLets projects replace or augment camera-terrain collision avoidance.SyncTransformLets presentation systems order around the final camera pose.DebugLets projects disable or order gizmo output cleanly.