use crate::components::{HillshadeEntity, TerrainEntity, TileEntity};
use crate::hillshade_material::HillshadeMaterial;
use crate::painter::{PainterPass, PainterPlanResource};
use crate::plugin::MapStateResource;
use crate::tile_fog_material::TileFogMaterial;
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use rustial_engine::{DecodedImage, TileData, TileId, TilePixelRect};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum UploadedTextureKey {
Exact(TileId),
Fallback { target: TileId, actual: TileId },
}
#[derive(Resource, Default)]
pub struct UploadedTileTextures {
handles: HashMap<UploadedTextureKey, Handle<Image>>,
}
#[derive(Resource, Default)]
pub struct UploadedHillshadeTextures {
handles: HashMap<TileId, Handle<Image>>,
}
const RGBA_CHANNELS: u32 = 4;
const MAX_RETAINED_EXACT_TEXTURES: usize = 512;
const MAX_MIP_UPLOADS_PER_FRAME: usize = 16;
#[allow(clippy::type_complexity)]
pub fn upload_textures(
state: Res<MapStateResource>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<TileFogMaterial>>,
mut texture_cache: ResMut<UploadedTileTextures>,
mut queries: ParamSet<(
Query<(
&mut TileEntity,
&MeshMaterial3d<TileFogMaterial>,
&mut Visibility,
)>,
Query<(
&mut TerrainEntity,
&MeshMaterial3d<TileFogMaterial>,
&mut Visibility,
)>,
)>,
) {
let visible = state.0.visible_tiles();
if visible.is_empty() {
texture_cache
.handles
.retain(|key, _| matches!(key, UploadedTextureKey::Exact(_)));
return;
}
let mut loaded_by_actual: HashMap<TileId, &rustial_engine::DecodedImage> =
HashMap::with_capacity(visible.len());
let mut by_target: HashMap<TileId, (TileId, &rustial_engine::DecodedImage)> =
HashMap::with_capacity(visible.len());
let mut needed_keys: HashSet<UploadedTextureKey> = HashSet::with_capacity(visible.len() * 2);
let mut fade_by_target: HashMap<TileId, f32> = HashMap::with_capacity(visible.len());
for vt in visible.iter() {
fade_by_target
.entry(vt.target)
.and_modify(|o| *o = o.max(vt.fade_opacity))
.or_insert(vt.fade_opacity);
if let Some(TileData::Raster(ref img)) = vt.data {
loaded_by_actual.entry(vt.actual).or_insert(img);
by_target.entry(vt.target).or_insert((vt.actual, img));
if vt.target == vt.actual {
by_target.insert(vt.target, (vt.actual, img));
needed_keys.insert(UploadedTextureKey::Exact(vt.target));
} else {
needed_keys.insert(UploadedTextureKey::Fallback {
target: vt.target,
actual: vt.actual,
});
}
}
}
if by_target.is_empty() {
texture_cache
.handles
.retain(|key, _| matches!(key, UploadedTextureKey::Exact(_)));
return;
}
let mut mip_uploads_this_frame: usize = 0;
for (mut tile, material_handle, mut visibility) in queries.p0().iter_mut() {
let Some((actual_id, img)) = find_best_texture(tile.tile_id, &by_target, &loaded_by_actual)
else {
continue;
};
let Some(material) = materials.get(&material_handle.0) else {
continue;
};
let already_textured = material.flags.has_texture > 0.5;
let needs_crop = actual_id != tile.tile_id;
let is_exact = !needs_crop;
if tile.has_exact_texture && already_textured {
continue;
}
if already_textured && !is_exact {
continue;
}
let texture_key = if needs_crop {
UploadedTextureKey::Fallback {
target: tile.tile_id,
actual: actual_id,
}
} else {
UploadedTextureKey::Exact(tile.tile_id)
};
needed_keys.insert(texture_key);
if is_exact && !texture_cache.handles.contains_key(&texture_key) {
if mip_uploads_this_frame >= MAX_MIP_UPLOADS_PER_FRAME {
continue;
}
mip_uploads_this_frame += 1;
}
let image_handle = match get_or_create_uploaded_image(
&mut images,
&mut texture_cache,
texture_key,
img,
tile.tile_id,
actual_id,
) {
Some(handle) => handle,
None => continue,
};
if let Some(material) = materials.get_mut(&material_handle.0) {
material.tile_texture = Some(image_handle);
material.flags.has_texture = 1.0;
material.flags.fade_opacity = fade_by_target.get(&tile.tile_id).copied().unwrap_or(1.0);
*visibility = Visibility::Inherited;
tile.has_exact_texture = is_exact;
}
}
for (mut terrain, material_handle, mut visibility) in queries.p1().iter_mut() {
let tile_id = terrain.tile_id;
let Some((actual_id, img)) = find_best_texture(tile_id, &by_target, &loaded_by_actual)
else {
continue;
};
let Some(material) = materials.get(&material_handle.0) else {
continue;
};
let already_textured = material.flags.has_texture > 0.5;
let needs_crop = actual_id != tile_id;
let is_exact = !needs_crop;
if terrain.has_exact_texture {
continue;
}
if already_textured && !is_exact {
continue;
}
let texture_key = if needs_crop {
UploadedTextureKey::Fallback {
target: tile_id,
actual: actual_id,
}
} else {
UploadedTextureKey::Exact(tile_id)
};
needed_keys.insert(texture_key);
if is_exact && !texture_cache.handles.contains_key(&texture_key) {
if mip_uploads_this_frame >= MAX_MIP_UPLOADS_PER_FRAME {
continue;
}
mip_uploads_this_frame += 1;
}
let image_handle = match get_or_create_uploaded_image(
&mut images,
&mut texture_cache,
texture_key,
img,
tile_id,
actual_id,
) {
Some(handle) => handle,
None => continue,
};
if let Some(material) = materials.get_mut(&material_handle.0) {
material.tile_texture = Some(image_handle);
material.flags.has_texture = 1.0;
*visibility = Visibility::Inherited;
terrain.has_exact_texture = is_exact;
}
}
texture_cache.handles.retain(|key, _| match key {
UploadedTextureKey::Exact(_) => true,
UploadedTextureKey::Fallback { .. } => needed_keys.contains(key),
});
if texture_cache.handles.len() > MAX_RETAINED_EXACT_TEXTURES {
let excess = texture_cache.handles.len() - MAX_RETAINED_EXACT_TEXTURES;
let mut removed = 0usize;
texture_cache.handles.retain(|key, _| {
if removed >= excess {
return true;
}
if needed_keys.contains(key) {
return true;
}
removed += 1;
false
});
}
}
fn get_or_create_uploaded_image(
images: &mut Assets<Image>,
texture_cache: &mut UploadedTileTextures,
texture_key: UploadedTextureKey,
img: &DecodedImage,
target_tile: TileId,
actual_tile: TileId,
) -> Option<Handle<Image>> {
if let Some(handle) = texture_cache.handles.get(&texture_key) {
return Some(handle.clone());
}
let expected_len = (img.width as usize)
.saturating_mul(img.height as usize)
.saturating_mul(RGBA_CHANNELS as usize);
if img.width == 0 || img.height == 0 || img.data.len() != expected_len {
log::warn!(
"texture_upload: skipping tile {:?} -- invalid image \
({}x{}, {} bytes, expected {})",
target_tile,
img.width,
img.height,
img.data.len(),
expected_len,
);
return None;
}
let (final_data, final_width, final_height) = if actual_tile != target_tile {
match crop_parent_texture(img, target_tile, actual_tile) {
Some(cropped) => cropped,
None => {
log::trace!(
"texture_upload: skipping tile {:?} -- ancestor {:?} too distant to crop",
target_tile,
actual_tile,
);
return None;
}
}
} else {
(img.data.to_vec(), img.width, img.height)
};
let is_fallback = actual_tile != target_tile;
let image = if is_fallback {
build_single_level_bevy_image(final_width, final_height, final_data)
} else {
match build_mipped_bevy_image(final_width, final_height, final_data) {
Ok(image) => image,
Err(err) => {
log::warn!(
"texture_upload: failed to build mip chain for tile {:?} from {:?}: {}",
target_tile,
actual_tile,
err,
);
return None;
}
}
};
let handle = images.add(image);
texture_cache.handles.insert(texture_key, handle.clone());
Some(handle)
}
fn build_mipped_bevy_image(
width: u32,
height: u32,
data: Vec<u8>,
) -> Result<Image, rustial_engine::TileError> {
let decoded = DecodedImage {
width,
height,
data: Arc::new(data),
};
let mip_chain = decoded.build_mip_chain_rgba8()?;
let mut image = Image::new_uninit(
Extent3d {
width,
height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
);
image.texture_descriptor.mip_level_count = mip_chain.level_count();
image.data = Some(mip_chain.into_bytes());
image.sampler = bevy::image::ImageSampler::Descriptor(bevy::image::ImageSamplerDescriptor {
address_mode_u: bevy::image::ImageAddressMode::ClampToEdge,
address_mode_v: bevy::image::ImageAddressMode::ClampToEdge,
mag_filter: bevy::image::ImageFilterMode::Linear,
min_filter: bevy::image::ImageFilterMode::Linear,
mipmap_filter: bevy::image::ImageFilterMode::Linear,
anisotropy_clamp: 16,
..Default::default()
});
Ok(image)
}
fn build_single_level_bevy_image(width: u32, height: u32, data: Vec<u8>) -> Image {
let mut image = Image::new_uninit(
Extent3d {
width,
height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
);
image.data = Some(data);
image.sampler = bevy::image::ImageSampler::Descriptor(bevy::image::ImageSamplerDescriptor {
address_mode_u: bevy::image::ImageAddressMode::ClampToEdge,
address_mode_v: bevy::image::ImageAddressMode::ClampToEdge,
mag_filter: bevy::image::ImageFilterMode::Linear,
min_filter: bevy::image::ImageFilterMode::Linear,
..Default::default()
});
image
}
pub fn upload_hillshade_textures(
state: Res<MapStateResource>,
plan: Res<PainterPlanResource>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<HillshadeMaterial>>,
mut texture_cache: ResMut<UploadedHillshadeTextures>,
mut query: Query<(
&HillshadeEntity,
&MeshMaterial3d<HillshadeMaterial>,
&mut Visibility,
)>,
) {
let rasters = state.0.hillshade_rasters();
if rasters.is_empty() || !plan.contains(PainterPass::HillshadeOverlay) {
texture_cache.handles.clear();
for (_, _, mut visibility) in &mut query {
*visibility = Visibility::Hidden;
}
return;
}
let by_tile: HashMap<TileId, &rustial_engine::PreparedHillshadeRaster> =
rasters.iter().map(|r| (r.tile, r)).collect();
for (hillshade, material_handle, mut visibility) in &mut query {
let Some(raster) = by_tile.get(&hillshade.tile_id) else {
continue;
};
let image_handle = if let Some(handle) = texture_cache.handles.get(&hillshade.tile_id) {
handle.clone()
} else {
let image = match build_mipped_bevy_image(
raster.image.width,
raster.image.height,
raster.image.data.to_vec(),
) {
Ok(image) => image,
Err(err) => {
log::warn!(
"upload_hillshade_textures: failed to build mip chain for tile {:?}: {}",
hillshade.tile_id,
err,
);
continue;
}
};
let handle = images.add(image);
texture_cache
.handles
.insert(hillshade.tile_id, handle.clone());
handle
};
if let Some(material) = materials.get_mut(&material_handle.0) {
material.hillshade_texture = image_handle;
*visibility = Visibility::Inherited;
}
}
texture_cache
.handles
.retain(|tile_id, _| by_tile.contains_key(tile_id));
}
const MAX_FALLBACK_DEPTH: u8 = 8;
fn find_best_texture<'a>(
tile_id: TileId,
by_target: &'a HashMap<TileId, (TileId, &'a rustial_engine::DecodedImage)>,
loaded_by_actual: &'a HashMap<TileId, &'a rustial_engine::DecodedImage>,
) -> Option<(TileId, &'a rustial_engine::DecodedImage)> {
if let Some(&(actual, img)) = by_target.get(&tile_id) {
return Some((actual, img));
}
let mut current = tile_id;
let mut depth = 0u8;
while depth < MAX_FALLBACK_DEPTH {
let Some(parent) = current.parent() else {
break;
};
if let Some(&img) = loaded_by_actual.get(&parent) {
return Some((parent, img));
}
current = parent;
depth += 1;
}
None
}
fn crop_parent_texture(
parent_img: &rustial_engine::DecodedImage,
child: TileId,
parent: TileId,
) -> Option<(Vec<u8>, u32, u32)> {
let crop = TilePixelRect::from_tiles(&child, &parent, parent_img.width, parent_img.height)?;
if crop.width == parent_img.width
&& crop.height == parent_img.height
&& crop.x == 0
&& crop.y == 0
{
return Some((
parent_img.data.to_vec(),
parent_img.width,
parent_img.height,
));
}
let channels = RGBA_CHANNELS as usize;
let src_stride = parent_img.width as usize * channels;
let dst_stride = crop.width as usize * channels;
let mut out = vec![0u8; crop.height as usize * dst_stride];
for row in 0..crop.height as usize {
let src_row = (crop.y as usize + row) * src_stride + crop.x as usize * channels;
let dst_row = row * dst_stride;
out[dst_row..dst_row + dst_stride]
.copy_from_slice(&parent_img.data[src_row..src_row + dst_stride]);
}
Some((out, crop.width, crop.height))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{HillshadeEntity, TerrainEntity, TileEntity};
use crate::hillshade_material::HillshadeMaterial;
use crate::plugin::MapStateResource;
use crate::tile_fog_material::TileFogMaterial;
use glam::DVec3;
use rustial_engine::{MapState, VisibleTile};
fn test_app() -> App {
let mut app = App::new();
app.init_resource::<Assets<Image>>();
app.init_resource::<Assets<TileFogMaterial>>();
app.init_resource::<Assets<HillshadeMaterial>>();
app.init_resource::<UploadedTileTextures>();
app.init_resource::<UploadedHillshadeTextures>();
app.init_resource::<crate::painter::PainterPlanResource>();
app.insert_resource(MapStateResource(MapState::new()));
app.add_systems(PreUpdate, crate::painter::update_painter_plan);
app.add_systems(Update, upload_textures);
app.add_systems(Update, upload_hillshade_textures);
app
}
#[test]
fn reuses_uploaded_exact_tile_texture_across_entity_rebuilds() {
let mut app = test_app();
let tile_id = TileId {
zoom: 3,
x: 4,
y: 2,
};
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_visible_tiles(vec![VisibleTile {
target: tile_id,
actual: tile_id,
data: Some(TileData::Raster(rustial_engine::DecodedImage {
width: 1,
height: 1,
data: vec![255, 0, 0, 255].into(),
})),
fade_opacity: 1.0,
}]);
}
let first_material = {
let mut materials = app.world_mut().resource_mut::<Assets<TileFogMaterial>>();
materials.add(TileFogMaterial::default())
};
app.world_mut().spawn((
TileEntity {
tile_id,
spawn_origin: DVec3::ZERO,
projection: rustial_engine::CameraProjection::default(),
has_exact_texture: false,
},
MeshMaterial3d(first_material.clone()),
Visibility::Hidden,
));
app.update();
let first_texture = {
let world = app.world_mut();
let materials = world.resource::<Assets<TileFogMaterial>>();
materials
.get(&first_material)
.and_then(|material| material.tile_texture.clone())
.expect("first upload should assign a texture")
};
let first_entity = {
let world = app.world_mut();
let mut query = world.query::<(Entity, &TileEntity)>();
query
.iter(world)
.find(|(_, tile)| tile.tile_id == tile_id)
.map(|(entity, _)| entity)
.expect("tile entity should exist")
};
app.world_mut().entity_mut(first_entity).despawn();
let second_material = {
let mut materials = app.world_mut().resource_mut::<Assets<TileFogMaterial>>();
materials.add(TileFogMaterial::default())
};
app.world_mut().spawn((
TileEntity {
tile_id,
spawn_origin: DVec3::ZERO,
projection: rustial_engine::CameraProjection::default(),
has_exact_texture: false,
},
MeshMaterial3d(second_material.clone()),
Visibility::Hidden,
));
app.update();
let second_texture = {
let world = app.world_mut();
let materials = world.resource::<Assets<TileFogMaterial>>();
materials
.get(&second_material)
.and_then(|material| material.tile_texture.clone())
.expect("rebuilt entity should reuse a texture")
};
assert_eq!(first_texture, second_texture);
}
#[test]
fn terrain_uses_loaded_ancestor_texture_when_exact_tile_is_missing() {
let mut app = test_app();
let parent = TileId {
zoom: 3,
x: 4,
y: 2,
};
let child = TileId {
zoom: 4,
x: 8,
y: 4,
};
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_visible_tiles(vec![VisibleTile {
target: parent,
actual: parent,
data: Some(TileData::Raster(rustial_engine::DecodedImage {
width: 2,
height: 2,
data: vec![
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255,
]
.into(),
})),
fade_opacity: 1.0,
}]);
}
let material = {
let mut materials = app.world_mut().resource_mut::<Assets<TileFogMaterial>>();
materials.add(TileFogMaterial::default())
};
app.world_mut().spawn((
TerrainEntity {
tile_id: child,
spawn_origin: DVec3::ZERO,
projection: rustial_engine::CameraProjection::default(),
gpu_displaced: false,
mesh_generation: 0,
has_exact_texture: false,
},
MeshMaterial3d(material.clone()),
Visibility::Hidden,
));
app.update();
let world = app.world_mut();
let materials = world.resource::<Assets<TileFogMaterial>>();
let material = materials
.get(&material)
.expect("terrain material should exist");
assert!(
material.tile_texture.is_some(),
"terrain should receive ancestor imagery"
);
assert!(
material.flags.has_texture > 0.5,
"terrain should be marked textured"
);
}
#[test]
fn hillshade_overlay_receives_prepared_texture() {
let mut app = test_app();
let tile_id = {
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_terrain(rustial_engine::TerrainManager::new(
rustial_engine::TerrainConfig {
enabled: true,
source: Box::new(rustial_engine::FlatElevationSource::new(2, 2)),
..rustial_engine::TerrainConfig::default()
},
16,
));
state
.0
.push_layer(Box::new(rustial_engine::HillshadeLayer::new("hillshade")));
state.0.update();
state.0.hillshade_rasters()[0].tile
};
let material = {
let mut materials = app.world_mut().resource_mut::<Assets<HillshadeMaterial>>();
materials.add(HillshadeMaterial::default())
};
app.world_mut().spawn((
HillshadeEntity {
tile_id,
spawn_origin: DVec3::ZERO,
projection: rustial_engine::CameraProjection::default(),
gpu_displaced: false,
mesh_generation: 0,
},
MeshMaterial3d(material.clone()),
Visibility::Hidden,
));
app.update();
let world = app.world_mut();
let materials = world.resource::<Assets<HillshadeMaterial>>();
let material = materials
.get(&material)
.expect("hillshade material should exist");
assert!(material.hillshade_texture != Handle::default());
}
}