xfy 3749ee5691 fix(camera): eliminate gimbal-lock flip by decoupling right from forward
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.
2026-07-14 16:36:36 +08:00

62 lines
3.6 KiB
Rust

use bevy::prelude::*;
use bevy_egui::egui;
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
) {
if let Ok(ctx) = contexts.ctx_mut() {
egui::Window::new("Controls")
.collapsible(true)
.default_pos([16.0, 16.0])
.show(ctx, |ui| {
egui::CollapsingHeader::new("Camera")
.default_open(true)
.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, (-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")
.default_open(true)
.show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.spin, 0.0..=1.0).text("Spin (χ)"));
ui.label(format!("ISCO (disk inner): {:.3}", crate::physics::kerr_isco(params.spin)));
ui.label(format!("Horizon r+: {:.3}", crate::physics::kerr_horizon(params.spin)));
});
egui::CollapsingHeader::new("Accretion Disk")
.default_open(true)
.show(ui, |ui| {
// disk_inner removed — now spin-derived (see Black Hole section).
ui.add(egui::Slider::new(&mut params.disk_outer, 6.0..=40.0).text("Outer radius"));
ui.add(egui::Slider::new(&mut params.disk_tilt, 0.0..=std::f32::consts::PI).text("Tilt"));
ui.add(egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).text("Brightness"));
ui.add(egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).text("Rotation speed"));
});
egui::CollapsingHeader::new("Doppler").show(ui, |ui| {
ui.checkbox(&mut params.doppler_enabled, "Enabled");
ui.add_enabled(params.doppler_enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength"));
});
egui::CollapsingHeader::new("Renderer").show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.steps, 50..=600).text("Steps"));
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Render scale"));
});
egui::CollapsingHeader::new("Background").show(ui, |ui| {
ui.add(egui::Slider::new(&mut params.star_intensity, 0.0..=3.0).text("Star intensity"));
ui.add(egui::Slider::new(&mut params.skybox_intensity, 0.0..=3.0).text("Skybox intensity"));
});
egui::CollapsingHeader::new("Grid").show(ui, |ui| {
ui.checkbox(&mut params.grid_enabled, "Enabled");
ui.add_enabled(params.grid_enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
});
});
// egui captures pointer when the cursor is over a window or being interacted with.
wants.0 = ctx.egui_wants_pointer_input();
} else {
wants.0 = false;
}
}