rustial-renderer-bevy 1.0.0

Bevy Engine renderer for the rustial 2.5D map engine
//! # Tile entity sync system
//!
//! Synchronises the engine's visible tile set to Bevy mesh entities
//! each frame.
//!
//! ## Entity lifecycle
//!
//! Tile entities are managed with a **diff-based** strategy keyed on
//! [`TileId`]:
//!
//! 1. **Despawn** -- entities whose `TileId` is no longer in the
//!    engine's desired set are scheduled for despawn.
//! 2. **Reposition** -- entities whose `TileId` *is* still desired
//!    have their [`Transform`] updated so that tile quads stay
//!    camera-relative as the user pans (avoids f32 jitter).
//! 3. **Spawn** -- tiles present in the desired set but not yet in
//!    the ECS get a fresh `Mesh`, `StandardMaterial`, and
//!    [`TileEntity`] marker component.
//!
//! ## Coordinate pipeline
//!
//! ```text
//! tile_bounds_world(tile_id)
//!     |
//!     |  -> WorldBounds { min, max }  (f64, Web Mercator metres)
//!     v
//! half-extents = (max - min) / 2     (baked into mesh vertices at spawn)
//!     |
//!     |  -> mesh-local quad: [-half_w..+half_w, -half_h..+half_h]
//!     v
//! centre = (min + max) / 2 - camera_origin   (updated every frame)
//!     |
//!     |  -> Transform.translation  (camera-relative, f32)
//!     v
//! Bevy world-space = mesh-local + translation
//!                   = camera-relative tile centre + half-extent offsets
//! ```
//!
//! ### Why mesh-local half-extents?
//!
//! Tile quad vertices are stored as small values centred on the mesh
//! origin (`[-half_w, -half_h` to `[+half_w, +half_h]`), not as
//! absolute world positions.  The `Transform` translation then
//! positions the quad.  Because the half-extents are zoom-dependent
//! and identical across the tile's lifetime, they are baked once at
//! spawn time.  Only the `Transform.translation` changes each frame
//! to track the camera origin.
//!
//! This differs from the WGPU renderer's `build_tile_mesh`, which
//! computes camera-relative vertex positions each frame.  The Bevy
//! approach avoids re-uploading mesh data and instead relies on!
//! Bevy's transform propagation, which is cheaper.
//!
//! ## Mesh geometry
//!
//! Each tile entity is a flat quad (two triangles) lying in the XY
//! plane at Z=0:
//!
//! ```text
//!   v3 ???? v2        UV origin is top-left (matching raster tile
//!   ? ?     ?         conventions: row 0 is the north edge).
//!   ?   ?   ?
//!   ?     ? ?         Winding: CCW (Bevy default front-face).
//!   v0 ???? v1        Indices: [0,1,2], [0,2,3].
//! ```
//!
//! | Vertex | Position (mesh-local)     | UV      |
//! |--------|---------------------------|---------|
//! | v0     | `(-half_w, -half_h, 0)`   | (0, 1)  |
//! | v1     | `(+half_w, -half_h, 0)`   | (1, 1)  |
//! | v2     | `(+half_w, +half_h, 0)`   | (1, 0)  |
//! | v3     | `(-half_w, +half_h, 0)`   | (0, 0)  |
//!
//! ## Texturing
//!
//! Tile entities are spawned with a flat grey unlit
//! [`StandardMaterial`].  The [`upload_textures`](super::texture_upload)
//! system (running in `PostUpdate`) assigns the actual tile imagery as
//! `base_color_texture` on the same frame, so the grey is never
//! visible in practice.
//!
//! ## Scheduling
//!
//! Registered in [`Update`](bevy::prelude::Update) -- after
//! `PreUpdate` has ticked the engine state, fetched tiles, and synced
//! the camera.

use crate::components::DeferredAssetDrop;
use crate::components::TileEntity;
use crate::plugin::MapStateResource;
use crate::systems::frame_change_detection::{frame_unchanged, FrameChangeDetection};
use crate::systems::terrain_sync::TerrainHeightPlaceholderTexture;
use crate::tile_fog_material::TileFogMaterial;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use glam::DVec3;
use rustial_engine::{CameraProjection, TileId};
use std::collections::{HashMap, HashSet};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct CachedTileKey {
    tile_id: TileId,
    projection: CachedProjectionKey,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum CachedProjectionKey {
    WebMercator,
    Equirectangular,
}

impl CachedProjectionKey {
    fn from_projection(projection: CameraProjection) -> Option<Self> {
        match projection {
            CameraProjection::WebMercator => Some(Self::WebMercator),
            CameraProjection::Equirectangular => Some(Self::Equirectangular),
            _ => None,
        }
    }
}

/// Retained flat-tile mesh cache for projection-stable Bevy tile quads.
#[derive(Resource, Default)]
pub struct CachedTileAssets {
    meshes: HashMap<CachedTileKey, Handle<Mesh>>,
}

/// Synchronise the engine's visible tile set to Bevy mesh entities.
///
/// See the [module-level documentation](self) for the full entity
/// lifecycle, coordinate pipeline, and mesh geometry.
pub fn sync_tiles(
    mut commands: Commands,
    state: Res<MapStateResource>,
    mut existing: Query<(Entity, &TileEntity, &mut Transform, &MeshMaterial3d<TileFogMaterial>, &Mesh3d)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<TileFogMaterial>>,
    mut deferred: ResMut<DeferredAssetDrop>,
    height_placeholder: Option<Res<TerrainHeightPlaceholderTexture>>,
    mut cache: ResMut<CachedTileAssets>,
    detection: Res<FrameChangeDetection>,
) {
    // Whole-frame skip: if nothing in the engine changed, tile sync
    // has no work to do (the diff-based strategy would produce the
    // same entity set and same transforms).
    if frame_unchanged(&detection, &state.0) {
        return;
    }

    if state.0.terrain().enabled() {
        for (entity, _, _, mat_handle, mesh_handle) in existing.iter() {
            deferred.keep_mesh(mesh_handle.0.clone());
            deferred.keep_material(mat_handle.0.clone());
            commands.entity(entity).despawn();
        }
        return;
    }

    let camera_origin = state.0.scene_world_origin();
    let projection = state.0.camera().projection();
    let desired_set: HashSet<TileId> = state.0.visible_tiles().iter().map(|vt| vt.target).collect();

    for (entity, tile, _, mat_handle, mesh_handle) in existing.iter() {
        if !desired_set.contains(&tile.tile_id) || tile.projection != projection {
            deferred.keep_mesh(mesh_handle.0.clone());
            deferred.keep_material(mat_handle.0.clone());
            commands.entity(entity).despawn();
            log::trace!("tile_sync: despawned tile {:?}", tile.tile_id);
        }
    }

    let mut existing_ids: HashSet<TileId> = HashSet::with_capacity(desired_set.len());
    for (_, tile, mut transform, _, _) in existing.iter_mut() {
        if tile.projection != projection {
            continue;
        }

        existing_ids.insert(tile.tile_id);

        transform.translation = tile_translation(tile.tile_id, projection, camera_origin);
    }

    let Some(height_placeholder) = height_placeholder else {
        return;
    };

    let Some(cached_projection) = CachedProjectionKey::from_projection(projection) else {
        return;
    };

    for tile_id in &desired_set {
        if existing_ids.contains(tile_id) {
            continue;
        }

        let mesh_handle = get_or_create_tile_mesh_handle(
            &mut meshes,
            &mut cache,
            *tile_id,
            projection,
            cached_projection,
        );

        let material_handle = materials.add(TileFogMaterial {
            height_texture: height_placeholder.0.clone(),
            ..TileFogMaterial::default()
        });

        commands.spawn((
            Mesh3d(mesh_handle),
            MeshMaterial3d(material_handle),
            Transform::from_translation(tile_translation(*tile_id, projection, camera_origin)),
            Visibility::Hidden,
            NoFrustumCulling,
            TileEntity {
                tile_id: *tile_id,
                spawn_origin: camera_origin,
                projection,
                has_exact_texture: false,
            },
        ));

        log::trace!("tile_sync: spawned tile {:?}", tile_id);
    }
}

fn get_or_create_tile_mesh_handle(
    meshes: &mut Assets<Mesh>,
    cache: &mut CachedTileAssets,
    tile_id: TileId,
    projection: CameraProjection,
    cached_projection: CachedProjectionKey,
) -> Handle<Mesh> {
    let key = CachedTileKey {
        tile_id,
        projection: cached_projection,
    };

    if let Some(handle) = cache.meshes.get(&key) {
        return handle.clone();
    }

    let (positions, uvs) = centered_projected_tile_quad(tile_id, projection);

    let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, Default::default());
    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
    mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 4]);
    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
    mesh.insert_indices(Indices::U32(vec![0, 1, 2, 0, 2, 3]));

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

fn centered_projected_tile_quad(
    tile_id: TileId,
    projection: CameraProjection,
) -> (Vec<[f32; 3]>, Vec<[f32; 2]>) {
    let southwest = DVec3::from_array(projection.project_tile_corner(&tile_id, 0.0, 1.0));
    let southeast = DVec3::from_array(projection.project_tile_corner(&tile_id, 1.0, 1.0));
    let northeast = DVec3::from_array(projection.project_tile_corner(&tile_id, 1.0, 0.0));
    let northwest = DVec3::from_array(projection.project_tile_corner(&tile_id, 0.0, 0.0));
    let center = DVec3::from_array(projection.project_tile_center(&tile_id));

    (
        vec![
            relative_vertex(southwest, center),
            relative_vertex(southeast, center),
            relative_vertex(northeast, center),
            relative_vertex(northwest, center),
        ],
        vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]],
    )
}

fn relative_vertex(position: DVec3, center: DVec3) -> [f32; 3] {
    let relative = position - center;
    [relative.x as f32, relative.y as f32, relative.z as f32]
}

fn tile_translation(tile_id: TileId, projection: CameraProjection, origin: DVec3) -> Vec3 {
    let center = DVec3::from_array(projection.project_tile_center(&tile_id)) - origin;
    Vec3::new(center.x as f32, center.y as f32, center.z as f32)
}