From 3749ee56914c30fd96ff65f0b75d68add975ec1f Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 14 Jul 2026 16:35:53 +0800 Subject: [PATCH] fix(camera): eliminate gimbal-lock flip by decoupling right from forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit basis() derived `right` from `forward × world_up`, which degenerated to a zero vector when forward aligned with world-Y (pitch = ±π/2) — the cross product vanished, normalization produced garbage, and crossing the pole flipped the image 180°. Right now depends on yaw alone: Vec3::new(cos yaw, 0, -sin yaw). It is a unit vector in the y=0 plane for any yaw, so it never goes to zero regardless of pitch. In the original upper hemisphere (cp > 0) this is exactly the old value after normalization, so existing views are unchanged; it additionally stays perfectly horizontal for all pitch, fixing a subtle roll the cross-product form introduced below the equator. With the singularity gone, pitch is unclamped from [0.05, π-0.05] to [-π+0.05, π-0.05]: the camera can now cross both poles and reach below the disk plane without flipping. The ±0.05 margin only avoids the exact origin-axis points where forward = ∓Y and yaw becomes meaningless. yaw is also wrapped to (-π, π] on each drag so it cannot drift out of the UI slider range (which would snap the view when the slider is later touched); cos/sin periodicity makes the wrap visually lossless. The UI pitch slider range is widened to match. Stale "gimbal pole" comments that described the old restriction are corrected. basis() still returns (eye, forward, right, up), so the uniform bridge in plugin.rs and both shaders are untouched. --- src/camera.rs | 30 ++++++++++++++++++++++-------- src/ui.rs | 2 +- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/camera.rs b/src/camera.rs index f53ddf0..34cf86a 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -17,10 +17,9 @@ impl Default for OrbitCamera { fn default() -> Self { Self { yaw: 0.0, - // Keep away from the π/2 gimbal pole: near the pole the world-Y (yaw) - // axis points almost straight out of the screen, so horizontal drag - // degenerates into an in-plane roll instead of a clean turn around - // the hole. ~0.7 rad (40°) is a 3/4 view with a comfortable yaw axis. + // ~0.7 rad (40°) is a comfortable 3/4 view of the disk. The basis() + // no longer has a gimbal pole, so any pitch is safe; this is just a + // nice default angle, not a pole-avoidance choice. pitch: 0.7, distance: 30.0, fov: 1.0, // radians @@ -37,6 +36,13 @@ impl OrbitCamera { /// in Bevy's right-handed Y-up coordinate system. The black hole sits at origin. /// Disk plane is the xz-plane (y=0); the disk tilt is applied in the shader /// via the params, so the camera basis here is in world space. + /// + /// `right` is derived from `yaw` alone (always a unit vector in the y=0 plane), + /// NOT from `forward × world_up`. The old cross-product form degenerated to a + /// zero vector when forward aligned with world-Y (pitch = ±π/2), flipping the + /// image. Decoupling right from forward removes that singularity, so pitch can + /// cross both poles with no roll/flip. This also keeps right exactly horizontal + /// for all pitch, eliminating a subtle roll the old form introduced. pub fn basis(&self) -> (Vec3, Vec3, Vec3, Vec3) { let cp = self.pitch.cos(); let sp = self.pitch.sin(); @@ -50,8 +56,8 @@ impl OrbitCamera { ); // Forward points from eye toward the origin. let forward = (-eye).normalize(); - let world_up = Vec3::Y; - let right = forward.cross(world_up).normalize(); + // Right depends on yaw only: a unit vector in the y=0 plane, never zero. + let right = Vec3::new(cy, 0.0, -sy); let up = right.cross(forward).normalize(); (eye, forward, right, up) } @@ -75,8 +81,16 @@ pub fn orbit_controller( // View-follows-cursor: dragging right rotates the view right, // dragging down tilts it down (inverts the old grab-the-scene mapping). camera.yaw += ev.delta.x * 0.005; - // Clamp pitch to avoid flipping. - camera.pitch = (camera.pitch - ev.delta.y * 0.005).clamp(0.05, std::f32::consts::PI - 0.05); + // Keep yaw in (-π, π] so it never drifts out of the UI slider's range + // (which would make touching the slider snap the view). cos/sin are + // periodic, so the wrap is visually lossless. + camera.yaw = (camera.yaw + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) + - std::f32::consts::PI; + // Full pitch range: the basis() has no gimbal pole now (right is yaw-only), + // so crossing ±π/2 no longer flips. The ±0.05 margin keeps eye off the + // exact origin-axis singularity where forward = ∓Y and yaw is undefined. + camera.pitch = + (camera.pitch - ev.delta.y * 0.005).clamp(-std::f32::consts::PI + 0.05, std::f32::consts::PI - 0.05); } } for ev in wheel.read() { diff --git a/src/ui.rs b/src/ui.rs index 08d1153..4695d8f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,7 +17,7 @@ pub fn ui_system( .show(ui, |ui| { ui.add(egui::Slider::new(&mut camera.distance, 3.0..=200.0).text("Distance")); ui.add(egui::Slider::new(&mut camera.yaw, -std::f32::consts::PI..=std::f32::consts::PI).text("Yaw")); - ui.add(egui::Slider::new(&mut camera.pitch, 0.05..=3.09).text("Pitch")); + ui.add(egui::Slider::new(&mut camera.pitch, (-std::f32::consts::PI + 0.05)..=(std::f32::consts::PI - 0.05)).text("Pitch")); ui.add(egui::Slider::new(&mut camera.fov, 0.3..=2.0).text("FOV")); }); egui::CollapsingHeader::new("Black Hole")