Allow 360° orbit, add Center View button and XYZ reference axes
Camera (apps/game/src/camera.rs): - Replace yaw/pitch with a single Quat rotation. Mouse drag now does yaw-around-world-Y * rotation * pitch-around-local-X, which is gimbal-free and allows true 360° tumbling in any direction. - Remove pitch clamps (ORBIT_MIN_PITCH / ORBIT_MAX_PITCH). - Add ResetOrbitCamera resource + apply_orbit_reset system that snaps the orbit camera back to default on demand. - OrbitCamera::reset() helper. Galaxy scene (apps/game/src/gameplay/galaxy_creation/): - New axes.rs: spawn_axes() draws three emissive cylinders through the origin (X=red, Y=green, Z=blue) as children of the scene root so they rebuild with the rest of the galaxy. - UI adds a 'Center View' button below Regenerate that inserts ResetOrbitCamera; handled by reset_view_button_handler. - Help text now mentions drag/zoom/escape.
This commit is contained in:
@@ -9,24 +9,41 @@ pub struct MainCamera;
|
||||
|
||||
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
|
||||
/// Used for inspection scenes like GalaxyCreation where there is no player to follow.
|
||||
///
|
||||
/// Orientation is stored as a quaternion (`rotation`) to allow true 360° motion
|
||||
/// with no gimbal lock. The base orientation is camera at `target + (0, 0, distance)`
|
||||
/// looking toward `target` with up `+Y`. The quaternion rotates this base around
|
||||
/// `target`. Mouse drag applies incremental rotations:
|
||||
/// - horizontal delta → rotate around world `+Y` (yaw)
|
||||
/// - vertical delta → rotate around the camera's local `+X` (its right vector)
|
||||
///
|
||||
/// This yaw-around-world / pitch-around-local split is the standard free-look
|
||||
/// construction; it never produces a degenerate "up" vector at the poles.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct OrbitCamera {
|
||||
pub target: Vec3,
|
||||
pub distance: f32,
|
||||
/// Yaw around the Y axis (radians). 0 = camera at +Z looking toward origin.
|
||||
pub yaw: f32,
|
||||
/// Pitch above the horizontal plane (radians). 0 = horizontal, π/2 = straight down.
|
||||
pub pitch: f32,
|
||||
pub rotation: Quat,
|
||||
}
|
||||
|
||||
impl OrbitCamera {
|
||||
/// Reset to default orientation (camera at the canonical "starting" view).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrbitCamera {
|
||||
fn default() -> Self {
|
||||
// ~36° above horizontal, slightly rotated, roughly matching the docs
|
||||
// GalaxyScene opening shot. Built as yaw * pitch so the angles compose
|
||||
// the same way the incremental drag rotations do.
|
||||
let yaw = Quat::from_rotation_y(0.15);
|
||||
let pitch = Quat::from_rotation_x(0.625);
|
||||
Self {
|
||||
target: Vec3::ZERO,
|
||||
distance: 420.0,
|
||||
yaw: 0.0,
|
||||
// ~36° above horizontal — roughly matches docs GalaxyScene camera position.
|
||||
pitch: 0.625,
|
||||
rotation: yaw * pitch,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,22 +51,25 @@ impl Default for OrbitCamera {
|
||||
/// Initial camera spawn. The same entity is reused across states; control systems
|
||||
/// decide how it moves depending on which state is active.
|
||||
pub fn spawn_camera(mut commands: Commands) {
|
||||
let orbit = OrbitCamera::default();
|
||||
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
Transform::from_xyz(0.0, 260.0, 360.0).looking_at(Vec3::ZERO, Vec3::Y),
|
||||
Transform::from_translation(orbit.target + offset)
|
||||
.with_rotation(orbit.rotation)
|
||||
.looking_at(orbit.target, Vec3::Y),
|
||||
MainCamera,
|
||||
OrbitCamera::default(),
|
||||
orbit,
|
||||
));
|
||||
}
|
||||
|
||||
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
|
||||
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
|
||||
const ORBIT_MIN_PITCH: f32 = 0.15; // ~9° above horizontal — never fully edge-on
|
||||
const ORBIT_MAX_PITCH: f32 = 1.4; // ~80° — never straight down (gimbal safeguard)
|
||||
const ORBIT_MIN_DISTANCE: f32 = 40.0;
|
||||
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
||||
|
||||
/// Left-drag rotates around the orbit target; scroll wheel zooms.
|
||||
/// No pitch clamping — the camera can tumble a full 360°.
|
||||
///
|
||||
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
|
||||
/// buttons or panels would also rotate the camera.
|
||||
@@ -76,11 +96,15 @@ pub fn orbit_camera_control(
|
||||
|
||||
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
|
||||
for event in mouse_motion.read() {
|
||||
orbit.yaw -= event.delta.x * ORBIT_ROTATE_SENSITIVITY;
|
||||
// Vertical is flipped relative to a "trackball" feel so the content
|
||||
// follows the finger (iPhone-style): drag down → camera rises →
|
||||
// scene appears to shift down with the cursor.
|
||||
orbit.pitch += event.delta.y * ORBIT_ROTATE_SENSITIVITY;
|
||||
// iPhone-style: content follows the finger horizontally.
|
||||
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY);
|
||||
// Vertical: drag down raises the camera so the scene appears to
|
||||
// shift down with the cursor.
|
||||
let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY);
|
||||
// Pre-multiply yaw (world-axis rotation), post-multiply pitch
|
||||
// (local-axis rotation) — this preserves a stable horizon line as
|
||||
// long as `rotation` doesn't already pitch past 90°.
|
||||
orbit.rotation = yaw * orbit.rotation * pitch;
|
||||
}
|
||||
} else {
|
||||
mouse_motion.clear();
|
||||
@@ -91,15 +115,39 @@ pub fn orbit_camera_control(
|
||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
||||
}
|
||||
|
||||
orbit.pitch = orbit.pitch.clamp(ORBIT_MIN_PITCH, ORBIT_MAX_PITCH);
|
||||
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
|
||||
|
||||
let cos_p = orbit.pitch.cos();
|
||||
let pos = orbit.target
|
||||
+ Vec3::new(
|
||||
orbit.distance * cos_p * orbit.yaw.sin(),
|
||||
orbit.distance * orbit.pitch.sin(),
|
||||
orbit.distance * cos_p * orbit.yaw.cos(),
|
||||
);
|
||||
*transform = Transform::from_translation(pos).looking_at(orbit.target, Vec3::Y);
|
||||
// Position = target + rotation * (camera-forward * distance).
|
||||
// In the base orientation the camera sits on +Z looking at the origin, so
|
||||
// the offset is `rotation * +Z * distance`.
|
||||
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
|
||||
let position = orbit.target + offset;
|
||||
*transform = Transform::from_translation(position)
|
||||
.looking_at(orbit.target, Vec3::Y)
|
||||
// Re-apply rotation to recover the roll component that `looking_at`
|
||||
// would otherwise discard (matters when the camera is upside-down).
|
||||
.with_rotation(orbit.rotation);
|
||||
}
|
||||
|
||||
/// Resource: set by UI buttons (e.g. "Center View") to request the orbit camera
|
||||
/// be reset to its default orientation on the next frame. Consumed by
|
||||
/// [`apply_orbit_reset`].
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct ResetOrbitCamera;
|
||||
|
||||
/// If [`ResetOrbitCamera`] is present, snap the orbit camera back to default
|
||||
/// and consume the resource. Lives in `Update` so UI button presses (which
|
||||
/// insert the resource via `Commands`) take effect on the following frame.
|
||||
pub fn apply_orbit_reset(
|
||||
mut commands: Commands,
|
||||
flag: Option<Res<ResetOrbitCamera>>,
|
||||
mut query: Query<&mut OrbitCamera, With<MainCamera>>,
|
||||
) {
|
||||
let Some(_flag) = flag else { return };
|
||||
let Ok(mut orbit) = query.single_mut() else {
|
||||
commands.remove_resource::<ResetOrbitCamera>();
|
||||
return;
|
||||
};
|
||||
orbit.reset();
|
||||
commands.remove_resource::<ResetOrbitCamera>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user