rustial-renderer-bevy 1.0.0

Bevy Engine renderer for the rustial 2.5D map engine
//! System that synchronises the engine camera to the Bevy camera entity.
//!
//! Bridges the engine's [`Camera`](rustial_engine::Camera) state to the
//! Bevy ECS every frame so the 3D scene matches the map view.
//!
//! ## What is synchronised
//!
//! | Engine field | Bevy component | Notes |
//! |-------------|----------------|-------|
//! | `eye_offset()` | `Transform` translation + `looking_at` | Camera-relative origin (see below) |
//! | `pitch` / `yaw` | `Transform` orientation (via up-vector) | Smooth blend avoids gimbal flip |
//! | `mode` | `Projection` (Perspective / Orthographic) | Near/far planes scaled by distance |
//! | `fov_y` | `PerspectiveProjection::fov` | Only in perspective mode |
//! | `distance` | `OrthographicProjection` half-height | Only in orthographic mode |
//!
//! ## Camera-relative origin
//!
//! Web Mercator world coordinates (meters east/north of the origin) can
//! exceed millions of meters.  Storing these directly in Bevy's f32
//! `Transform` would cause severe jitter.  Instead, the engine uses a
//! **camera-relative** model:
//!
//! - The Bevy camera is placed at `eye_offset()` (the orbital offset
//!   from the target) and looks at `Vec3::ZERO`.
//! - All tile, terrain, vector, and model entities are positioned
//!   relative to `camera.target_world()`, not in absolute world space.
//! - This keeps all f32 values small (within a few km of the origin),
//!   eliminating jitter.
//!
//! ## Up-vector derivation
//!
//! The up-hint is computed from the orbital geometry in two regimes,
//! smoothly blended over a small pitch range:
//!
//! **Pitched** (`pitch > 0.15 rad`): the camera's "screen-right"
//! vector is the orbit-sphere tangent in the yaw direction:
//! `right = (cos(yaw), -sin(yaw), 0)`.  The up-hint is then
//! `right x look` (normalised), which is always perpendicular to the!
//! look direction and always points "above the horizon".
//!
//! **Top-down** (`pitch <= 0.15 rad`): the look direction is nearly
//! `-Z`, making the horizontal right vector degenerate.  Instead the
//! up-hint is `(sin(yaw), cos(yaw), 0)` so the yaw bearing controls
//! which map direction appears at the top of the screen.
//!
//! Key properties:
//!
//! - No north/south flip at any yaw (including yaw = PI).
//! - No gimbal-lock at any pitch.
//! - Smooth transition across the blend boundary.
//!
//! This mirrors the engine's
//! [`Camera::view_matrix`](rustial_engine::Camera::view_matrix) logic
//! exactly so the Bevy frustum matches the engine's CPU-side matrices.
//!
//! ## Projection sync
//!
//! Near and far clip planes are derived from `camera.distance` to
//! maximise depth-buffer precision:
//!
//! | Mode | Near | Far |
//! |------|------|-----|
//! | Perspective | `distance * 0.001` | `distance * 10 * pitch_factor` |
//! | Orthographic | `-distance * 100` | `+distance * 100` |
//!
//! `pitch_factor = min(1/cos(pitch), 100)` extends the far plane when
//! the camera pitches toward the horizon, preventing terrain and tiles
//! at the view's edge from being clipped.
//!
//! ## Scheduling
//!
//! Registered in [`PreUpdate`](bevy::prelude::PreUpdate) **after**
//! `update_map_state` so the camera reads the freshly ticked engine
//! state (post-animator, post-input).

use crate::components::MapCamera;
use crate::plugin::MapStateResource;
use bevy::prelude::*;
use rustial_engine::CameraMode;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Minimum near-plane distance to prevent depth-buffer collapse when
/// `cam.distance()` is extremely small.
const MIN_NEAR_PLANE: f32 = 0.01;

// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------

/// Sync the engine's [`Camera`](rustial_engine::Camera) to the Bevy
/// [`Camera3d`] + [`Transform`] + [`Projection`] each frame.
///
/// Operates on every entity carrying the [`MapCamera`] marker.  In
/// practice there is exactly one, spawned by `setup_from_config`.
///
/// See the [module-level documentation](self) for the full description
/// of what is synchronised, the camera-relative origin pattern, and
/// the up-vector derivation.
pub fn sync_camera(
    state: Res<MapStateResource>,
    mut query: Query<(&mut Transform, &mut Projection), With<MapCamera>>,
) {
    let cam = state.0.camera();

    let eye = cam.eye_offset();

    for (mut transform, mut projection) in query.iter_mut() {
        // ------------------------------------------------------------------
        // Transform: position the camera at the orbital eye offset,
        // looking toward the origin (camera-relative target).
        // ------------------------------------------------------------------
        let eye_f32 = Vec3::new(eye.x as f32, eye.y as f32, eye.z as f32);

        let up = cam.view_up_vector();
        let up = Vec3::new(up.x as f32, up.y as f32, up.z as f32).normalize_or_zero();
        let up = if up.length_squared() < 0.5 { Vec3::Z } else { up };

        *transform = Transform::from_translation(eye_f32).looking_at(Vec3::ZERO, up);

        // ------------------------------------------------------------------
        // Projection
        // ------------------------------------------------------------------
        match cam.mode() {
            CameraMode::Perspective => {
                // Near plane: close enough to see objects at the target,
                // far enough to preserve depth precision.
                let near = ((cam.distance() * 0.001) as f32).max(MIN_NEAR_PLANE);

                // Far plane: extended when pitched toward the horizon so
                // the visible ground area (which grows as 1/cos(pitch))
                // is not clipped.
                let pitch_far_scale = if cam.pitch() > 0.01 {
                    (1.0 / cam.pitch().cos().abs().max(0.05)).min(100.0)
                } else {
                    1.0
                };
                let far = (cam.distance() * 10.0 * pitch_far_scale) as f32;

                // near_clip_plane must be consistent with `near` so that
                // Bevy's oblique-clip-plane adjustment is **not** applied.
                // The default near_clip_plane assumes near=0.1; if we set
                // a different near distance, the check
                //   near_clip_plane == vec4(0,0,-1,-near)
                // fails and the projection matrix gets distorted, breaking
                // the exact correspondence between engine mpp and rendered
                // pixels.
                *projection = Projection::Perspective(PerspectiveProjection {
                    fov: cam.fov_y() as f32,
                    aspect_ratio: cam.viewport_width() as f32
                        / cam.viewport_height().max(1) as f32,
                    near,
                    far,
                    near_clip_plane: Vec4::new(0.0, 0.0, -1.0, -near),
                });
            }
            CameraMode::Orthographic => {
                // Orthographic half-height equals camera distance, so
                // zoom works identically to perspective (increase
                // distance = see more ground).
                let half_h = cam.distance() as f32;

                // The wide near/far range (+/-distance*100) accommodates
                // terrain elevation and ensures geometry above and below
                // the ground plane is visible.
                let near = -(cam.distance() * 100.0) as f32;
                let far = (cam.distance() * 100.0) as f32;

                *projection = Projection::Orthographic(OrthographicProjection {
                    near,
                    far,
                    scaling_mode: bevy::camera::ScalingMode::FixedVertical {
                        viewport_height: half_h * 2.0,
                    },
                    ..OrthographicProjection::default_3d()
                });
            }
        }
    }
}