use crate::components::{DeferredAssetDrop, GridScalarEntity};
use crate::grid_scalar_material::{GridScalarMaterial, GridScalarUniforms};
use crate::plugin::MapStateResource;
use crate::systems::frame_change_detection::{frame_unchanged, FrameChangeDetection};
use crate::systems::visualization_sync_stats::VisualizationSyncStats;
use crate::systems::visualization_visibility::grid_intersects_scene_viewport;
use bevy::asset::RenderAssetUsages;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use rustial_engine as rustial_math;
use rustial_engine::{AltitudeMode, LayerId, VisualizationOverlay};
use std::collections::{HashMap, HashSet};
const PROJECTION_WEB_MERCATOR: f32 = 0.0;
const PROJECTION_EQUIRECTANGULAR: f32 = 1.0;
const RAMP_WIDTH: u32 = 256;
#[derive(Debug, Clone)]
struct GridScalarTextureState {
handle: Handle<Image>,
generation: u64,
value_generation: u64,
}
#[derive(Debug, Clone)]
struct GridScalarRampState {
handle: Handle<Image>,
fingerprint: u64,
}
#[derive(Resource, Default)]
pub struct GridScalarSyncState {
scalar_textures: HashMap<LayerId, GridScalarTextureState>,
ramp_textures: HashMap<LayerId, GridScalarRampState>,
pub stats: VisualizationSyncStats,
}
#[allow(clippy::too_many_arguments)]
pub fn sync_grid_scalars(
mut commands: Commands,
state: Res<MapStateResource>,
mut existing: Query<(
Entity,
&mut GridScalarEntity,
&Mesh3d,
&MeshMaterial3d<GridScalarMaterial>,
)>,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<GridScalarMaterial>>,
mut sync_state: ResMut<GridScalarSyncState>,
mut deferred: ResMut<DeferredAssetDrop>,
detection: Res<FrameChangeDetection>,
) {
if frame_unchanged(&detection, &state.0) {
sync_state.stats.skipped_frames += 1;
return;
}
let frame = state.0.frame_output();
let overlays: Vec<_> = frame
.visualization
.iter()
.filter_map(|overlay| match overlay {
VisualizationOverlay::GridScalar {
layer_id,
grid,
field,
ramp,
} => Some((*layer_id, grid, field, ramp)),
_ => None,
})
.collect();
if overlays.is_empty() {
for (entity, _, _, material_handle) in &mut existing {
deferred.keep_grid_scalar_material(material_handle.0.clone());
commands.entity(entity).despawn();
sync_state.stats.despawned_entities += 1;
}
sync_state.scalar_textures.clear();
sync_state.ramp_textures.clear();
return;
}
let camera_origin = state.0.scene_world_origin();
let projection = state.0.camera().projection();
let visible_overlay_ids: HashSet<LayerId> = overlays
.iter()
.filter_map(|(layer_id, grid, _, _)| {
grid_intersects_scene_viewport(grid, &state.0, camera_origin).then_some(*layer_id)
})
.collect();
let overlay_map: HashMap<LayerId, _> = overlays.iter().map(|entry| (entry.0, *entry)).collect();
let desired_ids: HashSet<LayerId> = overlay_map.keys().copied().collect();
let mut existing_ids = HashSet::new();
for (entity, mut grid_entity, mesh3d, material_handle) in &mut existing {
let Some((layer_id, grid, field, ramp)) = overlay_map.get(&grid_entity.layer_id).copied()
else {
deferred.keep_grid_scalar_material(material_handle.0.clone());
commands.entity(entity).despawn();
continue;
};
if !visible_overlay_ids.contains(&layer_id) {
commands.entity(entity).insert(Visibility::Hidden);
sync_state.stats.hidden_entities += 1;
existing_ids.insert(layer_id);
continue;
}
let scalar_handle =
get_or_create_scalar_texture(&mut images, &mut sync_state, layer_id, field);
let ramp_handle = get_or_create_ramp_texture(&mut images, &mut sync_state, layer_id, ramp);
if let Some(mesh) = meshes.get_mut(&mesh3d.0) {
*mesh = build_grid_scalar_mesh(grid, &state.0, camera_origin);
}
if let Some(material) = materials.get_mut(&material_handle.0) {
material.grid = build_grid_scalar_uniforms(grid, field, camera_origin, projection, 1.0);
material.scalar_texture = scalar_handle;
material.ramp_texture = ramp_handle;
}
grid_entity.generation = field.generation;
grid_entity.value_generation = field.value_generation;
grid_entity.ramp_fingerprint = grid_scalar_ramp_fingerprint(ramp);
commands.entity(entity).insert(Visibility::Visible);
sync_state.stats.updated_entities += 1;
existing_ids.insert(layer_id);
}
for (layer_id, grid, field, ramp) in overlays {
if existing_ids.contains(&layer_id) {
continue;
}
if !visible_overlay_ids.contains(&layer_id) {
continue;
}
let scalar_handle =
get_or_create_scalar_texture(&mut images, &mut sync_state, layer_id, field);
let ramp_handle = get_or_create_ramp_texture(&mut images, &mut sync_state, layer_id, ramp);
let mesh_handle = meshes.add(build_grid_scalar_mesh(grid, &state.0, camera_origin));
let material_handle = materials.add(GridScalarMaterial {
grid: build_grid_scalar_uniforms(grid, field, camera_origin, projection, 1.0),
scalar_texture: scalar_handle,
ramp_texture: ramp_handle,
});
commands.spawn((
Mesh3d(mesh_handle),
MeshMaterial3d(material_handle),
Transform::IDENTITY,
Visibility::Visible,
NoFrustumCulling,
GridScalarEntity {
layer_id,
generation: field.generation,
value_generation: field.value_generation,
ramp_fingerprint: grid_scalar_ramp_fingerprint(ramp),
},
));
sync_state.stats.spawned_entities += 1;
}
sync_state
.scalar_textures
.retain(|layer_id, _| desired_ids.contains(layer_id));
sync_state
.ramp_textures
.retain(|layer_id, _| desired_ids.contains(layer_id));
}
fn build_grid_scalar_mesh(
grid: &rustial_engine::GeoGrid,
state: &rustial_engine::MapState,
scene_origin: glam::DVec3,
) -> Mesh {
let rows = grid.rows.max(1);
let cols = grid.cols.max(1);
let mut positions = Vec::with_capacity((rows + 1) * (cols + 1));
let mut normals = Vec::with_capacity((rows + 1) * (cols + 1));
let mut uvs = Vec::with_capacity((rows + 1) * (cols + 1));
let mut indices = Vec::with_capacity(rows * cols * 6);
for row in 0..=rows {
for col in 0..=cols {
let u = col as f32 / cols as f32;
let v = row as f32 / rows as f32;
let coord = grid_corner_coord(grid, row, col, state);
let projected = state.camera().projection().project(&coord);
positions.push([
(projected.position.x - scene_origin.x) as f32,
(projected.position.y - scene_origin.y) as f32,
(projected.position.z - scene_origin.z + 0.05) as f32,
]);
normals.push([0.0, 0.0, 1.0]);
uvs.push([u, v]);
}
}
for row in 0..rows {
for col in 0..cols {
let tl = (row * (cols + 1) + col) as u32;
let tr = tl + 1;
let bl = ((row + 1) * (cols + 1) + col) as u32;
let br = bl + 1;
indices.extend_from_slice(&[tl, bl, tr, tr, bl, br]);
}
}
let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, Default::default());
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(indices));
mesh
}
fn grid_corner_coord(
grid: &rustial_engine::GeoGrid,
row: usize,
col: usize,
state: &rustial_engine::MapState,
) -> rustial_math::GeoCoord {
let dx = col as f64 * grid.cell_width;
let dy = row as f64 * grid.cell_height;
let (sin_r, cos_r) = grid.rotation.sin_cos();
let rx = dx * cos_r - dy * sin_r;
let ry = dx * sin_r + dy * cos_r;
let coord = offset_geo_coord(&grid.origin, rx, ry);
let altitude = resolve_grid_surface_altitude(grid, &coord, state);
rustial_math::GeoCoord::new(coord.lat, coord.lon, altitude)
}
fn offset_geo_coord(
origin: &rustial_math::GeoCoord,
dx_meters: f64,
dy_meters: f64,
) -> rustial_math::GeoCoord {
const METERS_PER_DEG_LAT: f64 = 111_320.0;
let lat = origin.lat - dy_meters / METERS_PER_DEG_LAT;
let cos_lat = origin.lat.to_radians().cos().max(1e-10);
let lon = origin.lon + dx_meters / (METERS_PER_DEG_LAT * cos_lat);
rustial_math::GeoCoord::new(lat, lon, origin.alt)
}
fn resolve_grid_surface_altitude(
grid: &rustial_engine::GeoGrid,
coord: &rustial_math::GeoCoord,
state: &rustial_engine::MapState,
) -> f64 {
let terrain = state.elevation_at(coord).unwrap_or(0.0);
match grid.altitude_mode {
AltitudeMode::ClampToGround => terrain,
AltitudeMode::RelativeToGround => terrain + grid.origin.alt,
AltitudeMode::Absolute => grid.origin.alt,
}
}
fn get_or_create_scalar_texture(
images: &mut Assets<Image>,
sync_state: &mut GridScalarSyncState,
layer_id: LayerId,
field: &rustial_engine::ScalarField2D,
) -> Handle<Image> {
if let Some(entry) = sync_state.scalar_textures.get_mut(&layer_id) {
if entry.generation == field.generation {
if entry.value_generation != field.value_generation {
if let Some(image) = images.get_mut(&entry.handle) {
*image = make_scalar_image(field);
}
entry.value_generation = field.value_generation;
}
return entry.handle.clone();
}
}
let handle = images.add(make_scalar_image(field));
sync_state.scalar_textures.insert(
layer_id,
GridScalarTextureState {
handle: handle.clone(),
generation: field.generation,
value_generation: field.value_generation,
},
);
handle
}
fn get_or_create_ramp_texture(
images: &mut Assets<Image>,
sync_state: &mut GridScalarSyncState,
layer_id: LayerId,
ramp: &rustial_engine::ColorRamp,
) -> Handle<Image> {
let fingerprint = grid_scalar_ramp_fingerprint(ramp);
if let Some(entry) = sync_state.ramp_textures.get(&layer_id) {
if entry.fingerprint == fingerprint {
return entry.handle.clone();
}
}
let handle = images.add(make_ramp_image(ramp));
sync_state.ramp_textures.insert(
layer_id,
GridScalarRampState {
handle: handle.clone(),
fingerprint,
},
);
handle
}
fn make_scalar_image(field: &rustial_engine::ScalarField2D) -> Image {
let mut bytes = Vec::with_capacity(field.data.len() * std::mem::size_of::<f32>());
for sample in &field.data {
bytes.extend_from_slice(&sample.to_le_bytes());
}
Image::new(
Extent3d {
width: field.cols.max(1) as u32,
height: field.rows.max(1) as u32,
depth_or_array_layers: 1,
},
TextureDimension::D2,
bytes,
TextureFormat::R32Float,
RenderAssetUsages::default(),
)
}
fn make_ramp_image(ramp: &rustial_engine::ColorRamp) -> Image {
Image::new(
Extent3d {
width: RAMP_WIDTH,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
ramp.as_texture_data(RAMP_WIDTH),
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
)
}
fn build_grid_scalar_uniforms(
grid: &rustial_engine::GeoGrid,
field: &rustial_engine::ScalarField2D,
scene_origin: glam::DVec3,
projection: rustial_engine::CameraProjection,
opacity: f32,
) -> GridScalarUniforms {
let projection_kind = match projection {
rustial_engine::CameraProjection::WebMercator => PROJECTION_WEB_MERCATOR,
rustial_engine::CameraProjection::Equirectangular => PROJECTION_EQUIRECTANGULAR,
_ => PROJECTION_WEB_MERCATOR,
};
let base_altitude = match grid.altitude_mode {
AltitudeMode::ClampToGround => 0.0,
AltitudeMode::RelativeToGround => grid.origin.alt as f32,
AltitudeMode::Absolute => grid.origin.alt as f32,
};
GridScalarUniforms {
origin_counts: Vec4::new(
grid.origin.lat as f32,
grid.origin.lon as f32,
grid.rows as f32,
grid.cols as f32,
),
grid_params: Vec4::new(
grid.cell_width as f32,
grid.cell_height as f32,
grid.rotation as f32,
opacity,
),
scene_origin: Vec4::new(
scene_origin.x as f32,
scene_origin.y as f32,
scene_origin.z as f32,
projection_kind,
),
value_params: Vec4::new(
field.min,
field.max,
field.nan_value.unwrap_or(0.0),
if field.nan_value.is_some() { 1.0 } else { 0.0 },
),
base_altitude: Vec4::new(base_altitude, 0.0, 0.0, 0.0),
}
}
fn grid_scalar_ramp_fingerprint(ramp: &rustial_engine::ColorRamp) -> u64 {
let mut h = ramp.stops.len() as u64;
for stop in &ramp.stops {
h = h
.wrapping_mul(31)
.wrapping_add(stop.value.to_bits() as u64)
.wrapping_mul(31)
.wrapping_add(stop.color[0].to_bits() as u64)
.wrapping_mul(31)
.wrapping_add(stop.color[1].to_bits() as u64)
.wrapping_mul(31)
.wrapping_add(stop.color[2].to_bits() as u64)
.wrapping_mul(31)
.wrapping_add(stop.color[3].to_bits() as u64);
}
h
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::RustialBevyPlugin;
use rustial_engine::GeoCoord;
use rustial_engine::MapState;
fn test_app() -> App {
let mut app = App::new();
app.add_plugins(bevy::app::TaskPoolPlugin::default());
app.add_plugins(bevy::time::TimePlugin);
app.init_resource::<Assets<Mesh>>();
app.init_resource::<Assets<StandardMaterial>>();
app.init_resource::<Assets<Image>>();
app.add_plugins(RustialBevyPlugin);
app
}
#[test]
fn grid_scalar_sync_spawns_overlay_entity() {
let mut app = test_app();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = MapState::new();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(GeoCoord::from_lat_lon(51.5, -0.12), 4, 4, 50.0, 50.0),
rustial_engine::ScalarField2D::from_data(4, 4, vec![1.0; 16]),
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
app.update();
let count = {
let world = app.world_mut();
world.query::<&GridScalarEntity>().iter(world).count()
};
assert_eq!(count, 1);
}
#[test]
fn grid_scalar_sync_despawns_when_overlay_removed() {
let mut app = test_app();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(GeoCoord::from_lat_lon(51.5, -0.12), 2, 2, 50.0, 50.0),
rustial_engine::ScalarField2D::from_data(2, 2, vec![1.0; 4]),
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
app.update();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = MapState::new();
}
app.update();
let count = {
let world = app.world_mut();
world.query::<&GridScalarEntity>().iter(world).count()
};
assert_eq!(count, 0);
}
#[test]
fn grid_scalar_sync_skips_far_offscreen_overlay() {
let mut app = test_app();
app.update();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = MapState::new();
state.0.set_viewport(1280, 720);
state.0.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.0.set_camera_distance(1_000.0);
state.0.update_camera(1.0 / 60.0);
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(GeoCoord::from_lat_lon(70.0, 120.0), 4, 4, 50.0, 50.0),
rustial_engine::ScalarField2D::from_data(4, 4, vec![1.0; 16]),
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
let count = {
let world = app.world_mut();
world.query::<&GridScalarEntity>().iter(world).count()
};
assert_eq!(count, 0);
}
#[test]
fn grid_scalar_sync_reports_steady_state_skip_on_unchanged_frame() {
let mut app = test_app();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = MapState::new();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(GeoCoord::from_lat_lon(0.0, 0.0), 4, 4, 50.0, 50.0),
rustial_engine::ScalarField2D::from_data(4, 4, vec![1.0; 16]),
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
app.update();
let before = app.world().resource::<GridScalarSyncState>().stats;
app.update();
let after = app.world().resource::<GridScalarSyncState>().stats;
assert_eq!(after.skipped_frames, before.skipped_frames + 1);
assert_eq!(after.spawned_entities, before.spawned_entities);
assert_eq!(after.updated_entities, before.updated_entities);
assert_eq!(after.hidden_entities, before.hidden_entities);
assert_eq!(after.despawned_entities, before.despawned_entities);
}
#[test]
fn grid_scalar_value_only_update_reuses_entity_and_texture() {
let mut app = test_app();
let origin = GeoCoord::from_lat_lon(0.0, 0.0);
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = MapState::new();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(origin, 2, 2, 50.0, 50.0),
rustial_engine::ScalarField2D::from_data(2, 2, vec![1.0, 2.0, 3.0, 4.0]),
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
app.update();
let (entity_before, layer_id, texture_before, spawned_before) = {
let world = app.world_mut();
let mut query = world.query::<(Entity, &GridScalarEntity)>();
let (entity, marker) = query.single(world).expect("grid scalar entity");
let sync = world.resource::<GridScalarSyncState>();
let tex = sync
.scalar_textures
.get(&marker.layer_id)
.expect("scalar texture")
.handle
.clone();
(entity, marker.layer_id, tex, sync.stats.spawned_entities)
};
{
let mut field =
rustial_engine::ScalarField2D::from_data(2, 2, vec![1.0, 2.0, 3.0, 4.0]);
field.update_values(vec![4.0, 3.0, 2.0, 1.0]);
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(origin, 2, 2, 50.0, 50.0),
field,
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
let (entity_after, texture_after, spawned_after, updated_after) = {
let world = app.world_mut();
let mut query = world.query::<(Entity, &GridScalarEntity)>();
let (entity, marker) = query.single(world).expect("grid scalar entity");
assert_eq!(marker.layer_id, layer_id);
let sync = world.resource::<GridScalarSyncState>();
let tex = sync
.scalar_textures
.get(&marker.layer_id)
.expect("scalar texture")
.handle
.clone();
(
entity,
tex,
sync.stats.spawned_entities,
sync.stats.updated_entities,
)
};
assert_eq!(entity_before, entity_after);
assert_eq!(texture_before, texture_after);
assert_eq!(spawned_before, spawned_after);
assert!(updated_after > 0);
}
#[test]
fn grid_scalar_large_value_only_update_reuses_texture_handle() {
let mut app = test_app();
let origin = GeoCoord::from_lat_lon(0.0, 0.0);
let size = 256usize;
let initial_data = (0..size * size)
.map(|idx| (idx % 256) as f32)
.collect::<Vec<_>>();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = MapState::new();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(origin, size, size, 50.0, 50.0),
rustial_engine::ScalarField2D::from_data(size, size, initial_data.clone()),
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
app.update();
let texture_before = {
let world = app.world();
let sync = world.resource::<GridScalarSyncState>();
sync.scalar_textures
.values()
.next()
.expect("scalar texture")
.handle
.clone()
};
let mut updated_field = rustial_engine::ScalarField2D::from_data(size, size, initial_data);
updated_field.update_values(
(0..size * size)
.map(|idx| 255.0 - (idx % 256) as f32)
.collect(),
);
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_grid_scalar(
"density",
rustial_engine::GeoGrid::new(origin, size, size, 50.0, 50.0),
updated_field,
rustial_engine::ColorRamp::new(vec![
rustial_engine::ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 0.5],
},
rustial_engine::ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 0.8],
},
]),
);
}
app.update();
let (texture_after, spawned_after, updated_after) = {
let world = app.world();
let sync = world.resource::<GridScalarSyncState>();
(
sync.scalar_textures
.values()
.next()
.expect("scalar texture")
.handle
.clone(),
sync.stats.spawned_entities,
sync.stats.updated_entities,
)
};
assert_eq!(texture_before, texture_after);
assert_eq!(spawned_after, 1);
assert!(updated_after >= 1);
}
}