rustial-renderer-bevy 0.0.1

Bevy Engine renderer for the rustial 2.5D map engine
// ---------------------------------------------------------------------------
//! # Model instance sync system
//!
//! Synchronises 3D model instance data from the engine's
//! [`MapState::model_instances`](rustial_engine::MapState) to Bevy
//! mesh entities each frame.
//!
//! ## Entity lifecycle
//!
//! Model entities are managed with a **rebuild-on-change** strategy:
//!
//! 1. If the instance list is empty, all existing [`ModelEntity`]
//!    entities are despawned.
//! 2. If the instance count has changed, all old entities are
//!    despawned and new ones are spawned from scratch.
//! 3. If the count is unchanged, the system updates the `Transform`
//!    of existing entities in place (camera-relative repositioning)
//!    without re-creating meshes or materials.
//!
//! This is simple and correct.  A future improvement could diff
//! instance data to avoid re-uploading unchanged meshes.
//!
//! ## Coordinate pipeline
//!
//! ```text
//! GeoCoord (lat/lon/alt)
//!     |
//!     |  WebMercator::project()
//!     v
//! WorldCoord (x, y, z) -- metres, absolute
//!     |
//!     |  subtract camera_origin
//!     v
//! camera-relative (f32) -- small values, no jitter
//! ```
//!
//! ## Transform composition
//!
//! The model transform is built as **translate * rotateZ * rotateX *
//! rotateY * scale**, applied right-to-left:
//!
//! 1. **Scale** -- uniform `instance.scale`.
//! 2. **Roll** -- rotation around the local Y axis (`instance.roll`).
//! 3. **Pitch** -- rotation around the local X axis (`instance.pitch`).
//! 4. **Heading** -- rotation around the world Z (up) axis
//!    (`instance.heading`, 0 = north, clockwise).
//! 5. **Translation** -- camera-relative position from the coordinate
//!    pipeline above.
//!
//! ## Altitude modes
//!
//! Altitude is resolved by
//! [`ModelInstance::resolve_altitude`](rustial_engine::ModelInstance::resolve_altitude)
//! using the terrain elevation at the instance's position.
//! See [`AltitudeMode`](rustial_engine::AltitudeMode) for details.
//!
//! ## Scheduling
//!
//! Registered in [`Update`](bevy::prelude::Update) -- after
//! `PreUpdate` has ticked the engine state and synced the camera.
// ---------------------------------------------------------------------------

use crate::components::ModelEntity;
use crate::plugin::MapStateResource;
use crate::systems::frame_change_detection::{frame_unchanged, FrameChangeDetection};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use rustial_engine::{ModelInstance, ModelMesh};
use std::collections::HashMap;

// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------

/// Tracks whether the model instance set and camera origin have changed
/// since the last sync, so steady-state frames can skip the per-entity
/// `Transform` rewrite entirely.
#[derive(Resource, Default)]
pub struct ModelSyncState {
    /// Rolling fingerprint of the model instance list and camera origin.
    fingerprint: u64,
    /// Instance count from the previous sync.
    instance_count: usize,
    /// Quantised camera origin from the previous sync.
    origin_key: [i64; 3],
}

impl ModelSyncState {
    fn quantise_origin(origin: glam::DVec3) -> [i64; 3] {
        [
            (origin.x * 100.0) as i64,
            (origin.y * 100.0) as i64,
            (origin.z * 100.0) as i64,
        ]
    }

    fn compute_fingerprint(instances: &[ModelInstance], origin: glam::DVec3) -> u64 {
        let mut fp: u64 = instances.len() as u64;
        let ok = Self::quantise_origin(origin);
        fp = fp
            .wrapping_mul(31)
            .wrapping_add(ok[0] as u64)
            .wrapping_mul(31)
            .wrapping_add(ok[1] as u64)
            .wrapping_mul(31)
            .wrapping_add(ok[2] as u64);
        for instance in instances {
            fp = fp
                .wrapping_mul(31)
                .wrapping_add(instance.position.lat.to_bits())
                .wrapping_mul(31)
                .wrapping_add(instance.position.lon.to_bits())
                .wrapping_mul(31)
                .wrapping_add(instance.scale.to_bits())
                .wrapping_mul(31)
                .wrapping_add(instance.heading.to_bits());
        }
        fp
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct ModelMeshKey {
    pos_len: usize,
    idx_len: usize,
    fingerprint: u64,
}

impl ModelMeshKey {
    fn from_mesh(mesh: &ModelMesh) -> Self {
        let mut fingerprint: u64 = mesh.positions.len() as u64;
        if let Some(first) = mesh.positions.first() {
            fingerprint = fingerprint
                .wrapping_mul(31)
                .wrapping_add(first[0].to_bits() as u64)
                .wrapping_mul(31)
                .wrapping_add(first[1].to_bits() as u64)
                .wrapping_mul(31)
                .wrapping_add(first[2].to_bits() as u64);
        }
        if let Some(first) = mesh.normals.first() {
            fingerprint = fingerprint
                .wrapping_mul(31)
                .wrapping_add(first[0].to_bits() as u64)
                .wrapping_mul(31)
                .wrapping_add(first[1].to_bits() as u64)
                .wrapping_mul(31)
                .wrapping_add(first[2].to_bits() as u64);
        }
        if let Some(first) = mesh.uvs.first() {
            fingerprint = fingerprint
                .wrapping_mul(31)
                .wrapping_add(first[0].to_bits() as u64)
                .wrapping_mul(31)
                .wrapping_add(first[1].to_bits() as u64);
        }
        if let Some(&first_idx) = mesh.indices.first() {
            fingerprint = fingerprint.wrapping_mul(31).wrapping_add(first_idx as u64);
        }
        Self {
            pos_len: mesh.positions.len(),
            idx_len: mesh.indices.len(),
            fingerprint,
        }
    }
}

/// Retained Bevy mesh/material cache for model instance rendering.
///
/// This lets structural `sync_models` rebuilds reuse already-uploaded
/// `Mesh` and `StandardMaterial` assets instead of recreating them on
/// every entity respawn.
#[derive(Resource, Default)]
pub struct CachedModelAssets {
    meshes: HashMap<ModelMeshKey, Handle<Mesh>>,
    material: Option<Handle<StandardMaterial>>,
}

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

/// Synchronise engine model instances to Bevy mesh entities.
///
/// See the [module-level documentation](self) for the full entity
/// lifecycle, coordinate pipeline, and transform composition.
#[allow(clippy::too_many_arguments)]
pub fn sync_models(
    mut commands: Commands,
    state: Res<MapStateResource>,
    existing: Query<(Entity, &ModelEntity)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut cache: ResMut<CachedModelAssets>,
    mut sync_state: ResMut<ModelSyncState>,
    detection: Res<FrameChangeDetection>,
) {
    // Whole-frame skip: if nothing in the engine changed, model sync
    // has no work to do.
    if frame_unchanged(&detection, &state.0) {
        return;
    }

    let camera_origin = state.0.scene_world_origin();
    let projection = state.0.camera().projection();
    let model_data = &state.0.model_instances();

    // -----------------------------------------------------------------
    // Case 1: no model data -- despawn everything and return early.
    // -----------------------------------------------------------------
    if model_data.is_empty() {
        for (entity, _) in existing.iter() {
            commands.entity(entity).despawn();
        }
        sync_state.fingerprint = 0;
        sync_state.instance_count = 0;
        return;
    }

    let existing_count = existing.iter().count();
    let new_fp = ModelSyncState::compute_fingerprint(model_data, camera_origin);

    // -----------------------------------------------------------------
    // Case 2: counts match -- update transforms in place (cheap path).
    //
    // When the instance list has not structurally changed, we only
    // need to reposition entities because the camera may have moved.
    // Meshes and materials stay as-is.
    //
    // If neither the camera origin nor the instance data has changed
    // since the last sync (same fingerprint), skip entirely.
    // -----------------------------------------------------------------
    if existing_count == model_data.len() && existing_count == sync_state.instance_count {
        if new_fp == sync_state.fingerprint {
            return;
        }

        for (entity, marker) in existing.iter() {
            let Some(instance) = model_data.get(marker.instance_index) else {
                continue;
            };

            let terrain_elev = state.0.elevation_at(&instance.position);
            let altitude = instance.resolve_altitude(terrain_elev);

            let world_pos = projection.project(&instance.position);
            let rel_x = (world_pos.position.x - camera_origin.x) as f32;
            let rel_y = (world_pos.position.y - camera_origin.y) as f32;
            let rel_z = (altitude - camera_origin.z) as f32;

            let transform = build_model_transform(instance, rel_x, rel_y, rel_z);
            commands.entity(entity).insert(transform);
        }

        sync_state.fingerprint = new_fp;
        sync_state.origin_key = ModelSyncState::quantise_origin(camera_origin);
        return;
    }

    // -----------------------------------------------------------------
    // Case 3: structural change -- full despawn + respawn.
    // -----------------------------------------------------------------
    for (entity, _) in existing.iter() {
        commands.entity(entity).despawn();
    }

    let shared_material = cache
        .material
        .get_or_insert_with(|| {
            materials.add(StandardMaterial {
                base_color: Color::linear_rgb(0.7, 0.7, 0.7),
                ..Default::default()
            })
        })
        .clone();

    for (idx, instance) in model_data.iter().enumerate() {
        // Skip degenerate instances with no geometry.
        if instance.mesh.positions.is_empty() || instance.mesh.indices.is_empty() {
            continue;
        }

        // -- Position -----------------------------------------------------
        let terrain_elev = state.0.elevation_at(&instance.position);
        let altitude = instance.resolve_altitude(terrain_elev);

        let world_pos = projection.project(&instance.position);
        let rel_x = (world_pos.position.x - camera_origin.x) as f32;
        let rel_y = (world_pos.position.y - camera_origin.y) as f32;
        let rel_z = (altitude - camera_origin.z) as f32;

        // -- Transform ----------------------------------------------------
        let transform = build_model_transform(instance, rel_x, rel_y, rel_z);
        let mesh_handle = cached_model_mesh_handle(&mut meshes, &mut cache, &instance.mesh);

        // -- Spawn --------------------------------------------------------
        commands.spawn((
            Mesh3d(mesh_handle),
            MeshMaterial3d(shared_material.clone()),
            transform,
            Visibility::default(),
            ModelEntity {
                instance_index: idx,
                layer_name: format!("model_{idx}"),
            },
        ));
    }

    sync_state.fingerprint = new_fp;
    sync_state.instance_count = model_data.len();
    sync_state.origin_key = ModelSyncState::quantise_origin(camera_origin);
}

fn cached_model_mesh_handle(
    meshes: &mut Assets<Mesh>,
    cache: &mut CachedModelAssets,
    mesh: &ModelMesh,
) -> Handle<Mesh> {
    let key = ModelMeshKey::from_mesh(mesh);
    if let Some(handle) = cache.meshes.get(&key) {
        return handle.clone();
    }

    let mut bevy_mesh = Mesh::new(PrimitiveTopology::TriangleList, Default::default());
    bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, mesh.positions.clone());
    bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, mesh.normals.clone());
    bevy_mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, mesh.uvs.clone());
    bevy_mesh.insert_indices(Indices::U32(mesh.indices.clone()));

    let handle = meshes.add(bevy_mesh);
    cache.meshes.insert(key, handle.clone());
    handle
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Build the Bevy [`Transform`] for a model instance.
///
/// Composition order (applied right-to-left):
/// **translate * rotateZ(heading) * rotateX(pitch) * rotateY(roll) * scale**
fn build_model_transform(
    instance: &ModelInstance,
    rel_x: f32,
    rel_y: f32,
    rel_z: f32,
) -> Transform {
    let scale = instance.scale as f32;
    let heading = instance.heading as f32;
    let pitch = instance.pitch as f32;
    let roll = instance.roll as f32;

    // Build rotation: heading (Z) * pitch (X) * roll (Y).
    let rotation =
        Quat::from_rotation_z(heading) * Quat::from_rotation_x(pitch) * Quat::from_rotation_y(roll);

    Transform {
        translation: Vec3::new(rel_x, rel_y, rel_z),
        rotation,
        scale: Vec3::splat(scale),
    }
}