singularity-rs/src/camera.rs
xfy ef631ab848 feat(camera): default to yaw -1.065 / pitch -0.335 framing
Set the OrbitCamera default to the framing the UI screenshot shows:
yaw -1.065, pitch -0.335, distance 30, fov 1.0 (the latter two were
already the defaults).

The old default (yaw 0, pitch +0.7) was a 3/4 view from above the disk
plane. The new pitch sits the eye just below the disk plane, so the
gravitationally lensed far side of the disk arcs over the top of the
shadow — the iconic Interstellar framing. yaw -1.065 (~-61°) picks a
viewing azimuth that keeps the lensed arc and photon ring balanced in
frame.
2026-07-15 15:09:53 +08:00

104 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use bevy::input::mouse::MouseMotion;
use bevy::input::mouse::MouseWheel;
use bevy::prelude::*;
/// Orbit camera state. The black hole is at the origin (Rs=1 units).
/// `distance` is the camera radius; `yaw`/`pitch` orient it.
#[derive(Resource, Clone, Copy)]
pub struct OrbitCamera {
pub yaw: f32,
pub pitch: f32,
pub distance: f32,
/// Vertical field of view in radians.
pub fov: f32,
}
impl Default for OrbitCamera {
fn default() -> Self {
Self {
yaw: -1.065,
// ~0.335 rad (19°) below the disk plane. The lensed far side of the
// disk then arcs over the top of the shadow — the iconic framing. The
// basis() has no gimbal pole, so any pitch is safe; this is just a nice
// default angle, not a pole-avoidance choice.
pitch: -0.335,
distance: 30.0,
fov: 1.0, // radians
}
}
}
/// Set by the UI system each frame; the orbit controller ignores input when true.
#[derive(Resource, Default)]
pub struct WantsPointer(pub bool);
impl OrbitCamera {
/// Compute the camera eye position and an orthonormal basis (forward/right/up)
/// 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();
let cy = self.yaw.cos();
let sy = self.yaw.sin();
// Eye position on a sphere around the origin.
let eye = Vec3::new(
self.distance * cp * sy,
self.distance * sp,
self.distance * cp * cy,
);
// Forward points from eye toward the origin.
let forward = (-eye).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)
}
}
pub fn orbit_controller(
wants: Res<WantsPointer>,
mut camera: ResMut<OrbitCamera>,
mouse: Res<ButtonInput<MouseButton>>,
mut motion: MessageReader<MouseMotion>,
mut wheel: MessageReader<MouseWheel>,
) {
if wants.0 {
// egui is using the pointer: drain events so they don't pile up, and ignore.
motion.clear();
wheel.clear();
return;
}
if mouse.pressed(MouseButton::Left) {
for ev in motion.read() {
// 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;
// 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() {
// Scroll up zooms IN: wint's +y (scroll up) must shrink distance, hence
// the negation. The old sign gave natural/inverted scrolling.
camera.distance = (camera.distance / (1.0 + ev.y * 0.1)).clamp(2.6, 500.0);
// 2.6 ≈ bcrit; don't let the camera pass through the shadow.
}
}