From 83238290a8df3c490c5ba8f406c1705d3e15bdbd Mon Sep 17 00:00:00 2001 From: Conner Petzold Date: Tue, 1 Jul 2025 14:22:09 -0400 Subject: [PATCH] Tile entities; TilemapLayer; TileStorage --- Cargo.toml | 11 +- assets/textures/tileset_array_texture.png | Bin 0 -> 664 bytes crates/bevy_sprite/src/lib.rs | 11 +- crates/bevy_sprite/src/tilemap/chunk.rs | 283 ++++++++++++++++++ crates/bevy_sprite/src/tilemap/mod.rs | 70 +++++ crates/bevy_sprite/src/tilemap/storage.rs | 76 +++++ crates/bevy_sprite/src/tilemap/tile.rs | 227 ++++++++++++++ .../src/tilemap/tilemap_chunk.wgsl | 58 ++++ crates/bevy_sprite/src/tilemap_chunk/mod.rs | 267 ----------------- .../tilemap_chunk/tilemap_chunk_material.rs | 69 ----- .../tilemap_chunk/tilemap_chunk_material.wgsl | 58 ---- examples/2d/tilemap.rs | 108 +++++++ examples/2d/tilemap_chunk.rs | 81 ----- 13 files changed, 831 insertions(+), 488 deletions(-) create mode 100644 assets/textures/tileset_array_texture.png create mode 100644 crates/bevy_sprite/src/tilemap/chunk.rs create mode 100644 crates/bevy_sprite/src/tilemap/mod.rs create mode 100644 crates/bevy_sprite/src/tilemap/storage.rs create mode 100644 crates/bevy_sprite/src/tilemap/tile.rs create mode 100644 crates/bevy_sprite/src/tilemap/tilemap_chunk.wgsl delete mode 100644 crates/bevy_sprite/src/tilemap_chunk/mod.rs delete mode 100644 crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs delete mode 100644 crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl create mode 100644 examples/2d/tilemap.rs delete mode 100644 examples/2d/tilemap_chunk.rs diff --git a/Cargo.toml b/Cargo.toml index f047040bdc9f7..a404b17abee83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -872,13 +872,14 @@ category = "2D Rendering" wasm = false [[example]] -name = "tilemap_chunk" -path = "examples/2d/tilemap_chunk.rs" +name = "tilemap" +path = "examples/2d/tilemap.rs" doc-scrape-examples = true +required-features = ["bevy_dev_tools"] -[package.metadata.example.tilemap_chunk] -name = "Tilemap Chunk" -description = "Renders a tilemap chunk" +[package.metadata.example.tilemap] +name = "Tilemap" +description = "Renders a tilemap" category = "2D Rendering" wasm = true diff --git a/assets/textures/tileset_array_texture.png b/assets/textures/tileset_array_texture.png new file mode 100644 index 0000000000000000000000000000000000000000..86a427764394b119d1c757da2acbfb842e94a522 GIT binary patch literal 664 zcmV;J0%!e+P)EX>4Tx04R}tkv&MmKpe$i(`rR34t5Z6$WWauh!t_vDionYs1;guFuC*#ni!H4 z7e~Rh;NZt%)xpJCR|i)?5c~jfb8}L3krMxx6k5c1aNLh~_a1le0HIN3niU!cG~G5c zsic_8uZZDSbR&c)5)fr(8MBgEj=A{Svtpa#g^{ zF^>&skX=9cAN=mtDkdhpq(~CzdU2eO5g@z^H0zG@ee5{R6Cn5uT)|5Tqat9cEGGtSBr65hASOnhB=$rDuz%9_b>h;#z$LRx*rLNL9z`-Ff zTB7VVpLh3k_V(|YR)0T+(Q>bfaI$Ow007WQL_t(oh3%HH4Z|Q5L=TGrJO(4Ap~@^w z!!|HL3NPUeGKN!qguxaj4HS3P7v|nS^eq7}TB@~S+qOe$Yi+fEd14l04yzKBAFZ{j zTnizj+Iz1~3Ceg^-Ew11OSe_wnb5I_I{0Pq73ms(K+vYOKX0000() .register_type::() .register_type::() - .add_plugins(( - Mesh2dRenderPlugin, - ColorMaterialPlugin, - TilemapChunkPlugin, - TilemapChunkMaterialPlugin, - )) + .add_plugins((Mesh2dRenderPlugin, ColorMaterialPlugin, TilemapPlugin)) .add_systems( PostUpdate, ( diff --git a/crates/bevy_sprite/src/tilemap/chunk.rs b/crates/bevy_sprite/src/tilemap/chunk.rs new file mode 100644 index 0000000000000..b313d3ec309ae --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/chunk.rs @@ -0,0 +1,283 @@ +use bevy_app::{App, Plugin, Update}; +use bevy_asset::{ + embedded_asset, embedded_path, Asset, AssetPath, Assets, Handle, RenderAssetUsages, +}; +use bevy_color::ColorToPacked; +use bevy_derive::{Deref, DerefMut}; +use bevy_ecs::{ + component::Component, + entity::Entity, + lifecycle::HookContext, + query::With, + reflect::ReflectComponent, + resource::Resource, + system::{Commands, Query, ResMut}, + world::DeferredWorld, +}; +use bevy_image::{Image, ImageSampler, ToExtents}; +use bevy_math::UVec2; +use bevy_platform::collections::{HashMap, HashSet}; +use bevy_reflect::{Reflect, TypePath}; +use bevy_render::{ + mesh::Mesh, + render_resource::{ + AsBindGroup, ShaderRef, TextureDataOrder, TextureDescriptor, TextureDimension, + TextureFormat, TextureUsages, + }, + view::ViewVisibility, +}; +use bytemuck::{Pod, Zeroable}; +use tracing::warn; + +use crate::{ + AlphaMode2d, Material2d, Material2dPlugin, MeshMaterial2d, TileColor, TileIndex, TileStorage, + TileVisible, TilemapLayer, Tileset, +}; + +/// Plugin that adds support for tilemap chunk materials. +pub struct TilemapChunkPlugin; + +impl Plugin for TilemapChunkPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "tilemap_chunk.wgsl"); + + app.add_plugins(Material2dPlugin::::default()) + .init_resource::() + .add_systems(Update, update_visible_tilemap_chunks); + } +} + +/// A resource storing the meshes for each tilemap chunk size. +#[derive(Resource, Default, Deref, DerefMut)] +pub struct TilemapChunkMeshCache(HashMap>); + +/// A component representing a chunk of a tilemap. +/// Each chunk is a rectangular section of tiles that is rendered as a single mesh. +#[derive(Component, Clone, Debug)] +#[component( + immutable, + on_insert = on_tilemap_chunk_insert, + on_replace = on_tilemap_chunk_insert, + on_remove = on_tilemap_chunk_remove, +)] +#[require(MeshMaterial2d)] +pub struct TilemapChunk { + pub tilemap_layer: Entity, + pub location: UVec2, +} + +#[derive(Component, Default, Reflect)] +#[reflect(Component)] +pub struct TilemapChunkDirty; + +fn on_tilemap_chunk_insert(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { + let Some(tilemap_chunk) = world.get::(entity) else { + return; + }; + + let location = tilemap_chunk.location; + + let Some(mut tilemap_layer) = world.get_mut::(tilemap_chunk.tilemap_layer) else { + return; + }; + + tilemap_layer.chunks.insert(location, entity); +} + +fn on_tilemap_chunk_remove(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { + let Some(tilemap_chunk) = world.get::(entity) else { + return; + }; + + let location = tilemap_chunk.location; + + let Some(mut tilemap_layer) = world.get_mut::(tilemap_chunk.tilemap_layer) else { + return; + }; + + tilemap_layer.chunks.remove(&location); +} + +fn update_visible_tilemap_chunks( + tilemap_layer_query: Query<(Entity, &TilemapLayer, &TileStorage, &Tileset)>, + tile_query: Query<(&TileIndex, Option<&TileVisible>, Option<&TileColor>)>, + mut chunk_query: Query< + ( + Entity, + &TilemapChunk, + &mut MeshMaterial2d, + &ViewVisibility, + ), + With, + >, + mut chunk_materials: ResMut>, + mut images: ResMut>, + mut commands: Commands, +) { + let mut chunk_entities_to_undirty = HashSet::new(); + for (chunk_entity, chunk, mut chunk_material, visibility) in &mut chunk_query { + if !visibility.get() { + continue; + } + + let Ok((tilemap_layer_entity, tilemap_layer, tile_storage, tileset)) = + tilemap_layer_query.get(chunk.tilemap_layer) + else { + warn!("TilemapChunk: TilemapLayer not found"); + continue; + }; + + #[cfg(target_arch = "wasm32")] + if let Some(tileset_image) = images.get(&tileset.image) { + let layer_count = tileset_image.texture_descriptor.array_layer_count(); + if layer_count % 6 == 0 { + commands.entity(tilemap_layer_entity).remove::(); + + if layer_count == 6 { + error!( + "WebGL2: Tileset image has 6 layers which WebGL2 will interpret as a Cube texture. Ensure the layer count is not a multiple of 6. The Tileset component has been removed." + ); + } else { + error!( + "WebGL2: Tileset image has {} layers. This is a multiple of 6, which WebGL2 will interpret as a CubeArray texture. Ensure the layer count is not a multiple of 6. The Tileset component has been removed.", + layer_count + ); + } + } + }; + + chunk_entities_to_undirty.insert(chunk_entity); + + let chunk_size = tilemap_layer.chunk_size; + let chunk_tiles = tile_storage.iter_chunk_tiles(chunk.location, chunk_size); + + let packed_tiles: Vec = chunk_tiles + .map(|tile_opt| { + tile_opt + .map(|tile_entity| { + let Ok((tile_index, tile_visible, tile_color)) = + tile_query.get(tile_entity) + else { + return PackedTileData::empty(); + }; + + PackedTileData::new( + tile_index.clone(), + tile_visible.cloned().unwrap_or_default(), + tile_color.cloned().unwrap_or_default(), + ) + }) + .unwrap_or_else(PackedTileData::empty) + }) + .collect(); + + if let Some(material) = chunk_materials.get_mut(chunk_material.id()) { + let Some(chunk_image) = images.get_mut(&material.tile_data) else { + warn!( + "TilemapChunkMaterial tile data image not found for tilemap chunk {} in tilemap layer {}", + chunk_entity, tilemap_layer_entity + ); + return; + }; + let Some(data) = chunk_image.data.as_mut() else { + warn!( + "TilemapChunkMaterial tile data image data not found for tilemap chunk {} in tilemap layer {}", + chunk_entity, tilemap_layer_entity + ); + return; + }; + data.clear(); + data.extend_from_slice(bytemuck::cast_slice(&packed_tiles)); + } else { + let tile_data_image = make_chunk_tile_data_image(&chunk_size, &packed_tiles); + + let material = chunk_materials.add(TilemapChunkMaterial { + alpha_mode: tilemap_layer.alpha_mode, + tileset: tileset.image.clone(), + tile_data: images.add(tile_data_image), + }); + + *chunk_material = MeshMaterial2d(material); + } + } + + for chunk_entity in chunk_entities_to_undirty { + commands.entity(chunk_entity).remove::(); + } +} + +/// Material used for rendering tilemap chunks. +/// +/// This material is used internally by the tilemap system to render chunks of tiles +/// efficiently using a single draw call per chunk. +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +pub struct TilemapChunkMaterial { + pub alpha_mode: AlphaMode2d, + + #[texture(0, dimension = "2d_array")] + #[sampler(1)] + pub tileset: Handle, + + #[texture(2, sample_type = "u_int")] + pub tile_data: Handle, +} + +impl Material2d for TilemapChunkMaterial { + fn fragment_shader() -> ShaderRef { + ShaderRef::Path( + AssetPath::from_path_buf(embedded_path!("tilemap_chunk.wgsl")).with_source("embedded"), + ) + } + + fn alpha_mode(&self) -> AlphaMode2d { + self.alpha_mode + } +} + +/// Packed per-tile data for use in the `Rgba16Uint` tile data texture in `TilemapChunkMaterial`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +pub struct PackedTileData { + index: u16, // red channel + color: [u8; 4], // green and blue channels + flags: u16, // alpha channel +} + +impl PackedTileData { + fn new(index: TileIndex, visible: TileVisible, color: TileColor) -> Self { + Self { + index: index.0, + color: color.0.to_srgba().to_u8_array(), + flags: visible.0 as u16, + } + } + + fn empty() -> Self { + Self { + index: u16::MAX, + color: [0, 0, 0, 0], + flags: 0, + } + } +} + +fn make_chunk_tile_data_image(size: &UVec2, data: &[PackedTileData]) -> Image { + Image { + data: Some(bytemuck::cast_slice(data).to_vec()), + data_order: TextureDataOrder::default(), + texture_descriptor: TextureDescriptor { + size: size.to_extents(), + dimension: TextureDimension::D2, + format: TextureFormat::Rgba16Uint, + label: None, + mip_level_count: 1, + sample_count: 1, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, + view_formats: &[], + }, + sampler: ImageSampler::nearest(), + texture_view_descriptor: None, + asset_usage: RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, + copy_on_resize: false, + } +} diff --git a/crates/bevy_sprite/src/tilemap/mod.rs b/crates/bevy_sprite/src/tilemap/mod.rs new file mode 100644 index 0000000000000..256b6f03858b3 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/mod.rs @@ -0,0 +1,70 @@ +use crate::{AlphaMode2d, Anchor}; +use bevy_app::{App, Plugin}; +use bevy_asset::Handle; +use bevy_ecs::{component::Component, entity::Entity, name::Name, reflect::ReflectComponent}; +use bevy_image::Image; +use bevy_math::UVec2; +use bevy_platform::collections::HashMap; +use bevy_reflect::Reflect; +use bevy_render::view::Visibility; +use bevy_transform::components::Transform; + +mod chunk; +mod storage; +mod tile; + +pub use chunk::*; +pub use storage::*; +pub use tile::*; + +/// Plugin that handles the initialization and updating of tilemap chunks. +/// Adds systems for processing newly added tilemap chunks and updating their indices. +pub struct TilemapPlugin; + +impl Plugin for TilemapPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(TilemapChunkPlugin).add_plugins(TilePlugin); + } +} + +/// A component representing a tileset image containing all tile textures. +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component, Clone, Debug)] +pub struct Tileset { + pub image: Handle, + pub tile_size: UVec2, +} + +#[derive(Component, Clone, Debug, Reflect)] +#[reflect(Component, Clone, Debug)] +#[require( + TileStorage, + Tiles, + Tileset, + Name::new("TilemapLayer"), + Transform, + Visibility, + Anchor +)] +pub struct TilemapLayer { + pub chunks: HashMap, + pub chunk_size: UVec2, + pub tile_display_size: Option, + pub alpha_mode: AlphaMode2d, +} + +impl Default for TilemapLayer { + fn default() -> Self { + Self { + chunks: HashMap::new(), + chunk_size: UVec2::splat(32), + tile_display_size: None, + alpha_mode: AlphaMode2d::Blend, + } + } +} + +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component, Clone, Debug)] +#[relationship_target(relationship = TileOf)] +pub struct Tiles(Vec); diff --git a/crates/bevy_sprite/src/tilemap/storage.rs b/crates/bevy_sprite/src/tilemap/storage.rs new file mode 100644 index 0000000000000..ef2dcf675b158 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/storage.rs @@ -0,0 +1,76 @@ +use bevy_ecs::{component::Component, entity::Entity, reflect::ReflectComponent}; +use bevy_math::{URect, UVec2}; +use bevy_reflect::Reflect; + +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component)] +pub struct TileStorage { + tiles: Vec>, + size: UVec2, +} + +impl TileStorage { + pub fn new(size: UVec2) -> Self { + Self { + tiles: vec![None; size.element_product() as usize], + size, + } + } + + fn index(&self, tile_position: UVec2) -> usize { + (tile_position.y * self.size.x + tile_position.x) as usize + } + + pub fn get(&self, tile_position: UVec2) -> Option { + let index = self.index(tile_position); + self.tiles.get(index).cloned().flatten() + } + + pub fn set(&mut self, tile_position: UVec2, maybe_tile_entity: Option) { + let index = self.index(tile_position); + let Some(tile) = self.tiles.get_mut(index) else { + return; + }; + *tile = maybe_tile_entity; + } + + pub fn remove(&mut self, tile_position: UVec2) { + self.set(tile_position, None); + } + + pub fn iter(&self) -> impl Iterator> { + self.tiles.iter().cloned() + } + + pub fn iter_sub_rect(&self, rect: URect) -> impl Iterator> { + let URect { min, max } = rect; + + (min.y..max.y).flat_map(move |y| { + (min.x..max.x).map(move |x| { + if x >= self.size.x || y >= self.size.y { + return None; + } + + let index = (y * self.size.x + x) as usize; + self.tiles.get(index).cloned().flatten() + }) + }) + } + + pub fn iter_chunk_tiles( + &self, + chunk_position: UVec2, + chunk_size: UVec2, + ) -> impl Iterator> { + let chunk_rect = URect::from_corners( + chunk_position * chunk_size, + (chunk_position + UVec2::splat(1)) * chunk_size, + ); + + self.iter_sub_rect(chunk_rect) + } + + pub fn size(&self) -> UVec2 { + self.size + } +} diff --git a/crates/bevy_sprite/src/tilemap/tile.rs b/crates/bevy_sprite/src/tilemap/tile.rs new file mode 100644 index 0000000000000..64b70d0de5f91 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/tile.rs @@ -0,0 +1,227 @@ +use bevy_app::{App, Plugin, PreUpdate}; +use bevy_asset::Assets; +use bevy_color::Color; +use bevy_derive::{Deref, DerefMut}; +use bevy_ecs::{ + component::Component, entity::Entity, hierarchy::ChildOf, lifecycle::{HookContext, Insert, Replace}, name::Name, observer::On, query::{Changed, Or}, reflect::ReflectComponent, schedule::IntoScheduleConfigs, system::{Commands, Query, ResMut}, world::DeferredWorld +}; +use bevy_math::{primitives::Rectangle, UVec2}; +use bevy_reflect::Reflect; +use bevy_render::mesh::{Mesh, Mesh2d}; +use bevy_transform::components::Transform; +use tracing::warn; + +use crate::{ + Anchor, TileStorage, TilemapChunk, TilemapChunkDirty, TilemapChunkMeshCache, TilemapLayer, + Tiles, Tileset, +}; + +pub struct TilePlugin; + +impl Plugin for TilePlugin { + fn build(&self, app: &mut App) { + app.register_type::() + .add_observer(on_insert_tile_of) + .add_observer(on_replace_tile_of) + .add_systems(PreUpdate, (update_tiles, update_previous_positions).chain()); + } +} + +#[derive(Component, Clone, Debug, Deref, DerefMut, Reflect)] +#[reflect(Component, Clone, Debug)] +#[relationship(relationship_target = Tiles)] +pub struct TileOf(pub Entity); + +/// Index that corresponds to the position in the tileset array texture. +#[derive(Component, Clone, Debug, Default, Reflect)] +#[reflect(Component, Clone, Debug)] +pub struct TileIndex(pub u16); + +/// Grid position of the tile in the tilemap, in tile coordinates. +#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect, PartialEq, Eq, Hash)] +#[reflect(Component, Clone, Debug, PartialEq, Hash)] +#[component( + on_add = on_add_tile_position +)] +pub struct TilePosition(pub UVec2); + +/// The previous grid position of the tile in the tilemap, in tile coordinates. +#[derive(Component, Clone, Copy, Debug, Default, Deref, DerefMut, Reflect, PartialEq, Eq, Hash)] +#[reflect(Component, Clone, Debug, PartialEq, Hash)] +pub struct PreviousTilePosition(pub UVec2); + +/// The tint color of a tile. +#[derive(Component, Reflect, Default, Clone, Copy, Debug)] +#[reflect(Component, Clone, Debug)] +pub struct TileColor(pub Color); + +impl From for TileColor { + fn from(color: Color) -> Self { + TileColor(color) + } +} + +/// Hides or shows a tile based on the boolean. Default: True +#[derive(Component, Reflect, Clone, Copy, Debug, Hash, PartialEq, Eq)] +#[reflect(Component, Clone, Debug, Hash, PartialEq)] +pub struct TileVisible(pub bool); + +impl Default for TileVisible { + fn default() -> Self { + Self(true) + } +} + +fn on_insert_tile_of( + trigger: On, + tile_query: Query<(&TileOf, &TilePosition)>, + mut tilemap_layer_query: Query<(&mut TilemapLayer, &mut TileStorage, &Tileset, &Anchor)>, + mut tilemap_chunk_mesh_cache: ResMut, + mut meshes: ResMut>, + mut commands: Commands, +) { + let tile_entity = trigger.target(); + let Ok((TileOf(tilemap_layer_entity), tile_position)) = tile_query.get(tile_entity) else { + warn!("TileOf: Tile not found"); + return; + }; + + let Ok((mut tilemap_layer, mut tile_storage, tileset, anchor)) = + tilemap_layer_query.get_mut(*tilemap_layer_entity) + else { + warn!("TileOf: Tilemap not found"); + return; + }; + + tile_storage.set(**tile_position, Some(tile_entity)); + + let chunk_position = tile_position.0 / tilemap_layer.chunk_size; + let tile_size = tilemap_layer.tile_display_size.unwrap_or(tileset.tile_size); + let layer_size = (tile_storage.size() * tile_size).as_vec2(); + let mesh_size = tilemap_layer.chunk_size * tile_size; + + let chunk_entity = tilemap_layer + .chunks + .entry(chunk_position) + .or_insert_with(|| { + let anchor_offset = -((layer_size / 2.0) + (anchor.as_vec() * layer_size)); + let chunk_world_position = (anchor_offset + + ((chunk_position * mesh_size) + (mesh_size / 2)).as_vec2()) + .extend(0.0); + + let mesh = tilemap_chunk_mesh_cache + .entry(mesh_size) + .or_insert_with(|| meshes.add(Rectangle::from_size(mesh_size.as_vec2()))); + + let chunk_entity = commands + .spawn(( + Name::new(format!( + "TilemapChunk {},{}", + chunk_position.x, chunk_position.y + )), + TilemapChunk { + tilemap_layer: *tilemap_layer_entity, + location: chunk_position, + }, + Transform::from_translation(chunk_world_position), + Mesh2d(mesh.clone()), + ChildOf(*tilemap_layer_entity), + )) + .id(); + + chunk_entity + }); + + commands.entity(tile_entity).insert(( + Name::new(format!("Tile {},{}", tile_position.x, tile_position.y)), + ChildOf(*chunk_entity), + )); +} + +fn on_replace_tile_of( + trigger: On, + tile_query: Query<(&TileOf, &TilePosition)>, + mut tile_storage_query: Query<&mut TileStorage>, +) { + let tile_entity = trigger.target(); + let Ok((TileOf(tilemap_entity), tile_position)) = tile_query.get(tile_entity) else { + return; + }; + + let Ok(mut tile_storage) = tile_storage_query.get_mut(*tilemap_entity) else { + return; + }; + + tile_storage.remove(**tile_position); +} + +fn update_tiles( + tile_query: Query< + (Entity, &TileOf, &TilePosition, &PreviousTilePosition), + Or<( + Changed, + Changed, + Changed, + Changed, + )>, + >, + mut tilemap_layer_query: Query<(&TilemapLayer, &mut TileStorage)>, + mut commands: Commands, +) { + for (tile_entity, tile_of, tile_pos, prev_tile_pos) in tile_query { + let Ok((tilemap_layer, mut tile_storage)) = tilemap_layer_query.get_mut(**tile_of) else { + warn!("Couldn't find tilemap layer {}", tile_of.0); + continue; + }; + + if tile_pos.0 != prev_tile_pos.0 { + tile_storage.remove(prev_tile_pos.0); + tile_storage.set(tile_pos.0, Some(tile_entity)); + } + + let old_chunk_position = prev_tile_pos.0 / tilemap_layer.chunk_size; + let chunk_position = tile_pos.0 / tilemap_layer.chunk_size; + + // This fails if the chunk hasn't been lazily created yet by the on_insert_tile_of trigger. + let Some(&chunk_entity) = tilemap_layer.chunks.get(&chunk_position) else { + warn!( + "Couldn't find chunk {} in tilemap layer {}", + chunk_position, tile_of.0 + ); + continue; + }; + + commands.entity(chunk_entity).insert(TilemapChunkDirty); + + if old_chunk_position != chunk_position { + commands.entity(tile_entity).insert(ChildOf(chunk_entity)); + + if let Some(&old_chunk_entity) = tilemap_layer.chunks.get(&old_chunk_position) { + commands.entity(old_chunk_entity).insert(TilemapChunkDirty); + } else { + warn!( + "Couldn't find old chunk {} in tilemap layer {}", + old_chunk_position, tile_of.0 + ); + }; + } + } +} + +fn on_add_tile_position(mut world: DeferredWorld, context: HookContext) { + let Some(tile_position) = world.get::(context.entity).cloned() else { + return; + }; + world.commands().entity(context.entity).insert(PreviousTilePosition(*tile_position)); +} + + +fn update_previous_positions( + mut tile_query: Query<(&TilePosition, &mut PreviousTilePosition)>, +) { + for (tile_pos, mut prev_pos) in &mut tile_query { + // Update the previous position to the current position + // This will be the "previous" position for the next frame + prev_pos.0 = tile_pos.0; + } +} diff --git a/crates/bevy_sprite/src/tilemap/tilemap_chunk.wgsl b/crates/bevy_sprite/src/tilemap/tilemap_chunk.wgsl new file mode 100644 index 0000000000000..8c0c311ccbdef --- /dev/null +++ b/crates/bevy_sprite/src/tilemap/tilemap_chunk.wgsl @@ -0,0 +1,58 @@ +#import bevy_sprite::{ + mesh2d_functions as mesh_functions, + mesh2d_view_bindings::view, + mesh2d_vertex_output::VertexOutput, +} + +@group(2) @binding(0) var tileset: texture_2d_array; +@group(2) @binding(1) var tileset_sampler: sampler; +@group(2) @binding(2) var tile_data: texture_2d; + +struct TileData { + tileset_index: u32, + color: vec4, + visible: bool, +} + +fn get_tile_data(coord: vec2) -> TileData { + let data = textureLoad(tile_data, coord, 0); + + let tileset_index = data.r; + + let color_r = f32(data.g & 0xFFu) / 255.0; + let color_g = f32((data.g >> 8u) & 0xFFu) / 255.0; + let color_b = f32(data.b & 0xFFu) / 255.0; + let color_a = f32((data.b >> 8u) & 0xFFu) / 255.0; + + let color = vec4(color_r, color_g, color_b, color_a); + + let visible = data.a != 0u; + + return TileData(tileset_index, color, visible); +} + +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + let chunk_size = textureDimensions(tile_data, 0); + + // Calculate tile coordinate with inverted Y + let tile_uv = vec2(in.uv.x, 1.0 - in.uv.y) * vec2(chunk_size); + let tile_coord = clamp(vec2(floor(tile_uv)), vec2(0), chunk_size - 1); + + let tile = get_tile_data(tile_coord); + + if (tile.tileset_index == 0xffffu || !tile.visible) { + discard; + } + + // Use original UV for texture sampling (no inversion) + let local_uv = fract(in.uv * vec2(chunk_size)); + let tex_color = textureSample(tileset, tileset_sampler, local_uv, tile.tileset_index); + let final_color = tex_color * tile.color; + + if (final_color.a < 0.001) { + discard; + } + + return final_color; +} \ No newline at end of file diff --git a/crates/bevy_sprite/src/tilemap_chunk/mod.rs b/crates/bevy_sprite/src/tilemap_chunk/mod.rs deleted file mode 100644 index 174816154bc6e..0000000000000 --- a/crates/bevy_sprite/src/tilemap_chunk/mod.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crate::{AlphaMode2d, Anchor, MeshMaterial2d}; -use bevy_app::{App, Plugin, Update}; -use bevy_asset::{Assets, Handle, RenderAssetUsages}; -use bevy_derive::{Deref, DerefMut}; -use bevy_ecs::{ - component::Component, - entity::Entity, - lifecycle::HookContext, - query::Changed, - resource::Resource, - system::{Query, ResMut}, - world::DeferredWorld, -}; -use bevy_image::{Image, ImageSampler, ToExtents}; -use bevy_math::{FloatOrd, UVec2, Vec2, Vec3}; -use bevy_platform::collections::HashMap; -use bevy_render::{ - mesh::{Indices, Mesh, Mesh2d, PrimitiveTopology}, - render_resource::{ - TextureDataOrder, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, - }, -}; -use tracing::warn; - -mod tilemap_chunk_material; - -pub use tilemap_chunk_material::*; - -/// Plugin that handles the initialization and updating of tilemap chunks. -/// Adds systems for processing newly added tilemap chunks and updating their indices. -pub struct TilemapChunkPlugin; - -impl Plugin for TilemapChunkPlugin { - fn build(&self, app: &mut App) { - app.init_resource::() - .add_systems(Update, update_tilemap_chunk_indices); - } -} - -type TilemapChunkMeshCacheKey = (UVec2, FloatOrd, FloatOrd, FloatOrd, FloatOrd); - -/// A resource storing the meshes for each tilemap chunk size. -#[derive(Resource, Default, Deref, DerefMut)] -pub struct TilemapChunkMeshCache(HashMap>); - -/// A component representing a chunk of a tilemap. -/// Each chunk is a rectangular section of tiles that is rendered as a single mesh. -#[derive(Component, Clone, Debug, Default)] -#[require(Anchor)] -#[component(immutable, on_insert = on_insert_tilemap_chunk)] -pub struct TilemapChunk { - /// The size of the chunk in tiles - pub chunk_size: UVec2, - /// The size to use for each tile, not to be confused with the size of a tile in the tileset image. - /// The size of the tile in the tileset image is determined by the tileset image's dimensions. - pub tile_display_size: UVec2, - /// Handle to the tileset image containing all tile textures - pub tileset: Handle, - /// The alpha mode to use for the tilemap chunk - pub alpha_mode: AlphaMode2d, -} - -/// Component storing the indices of tiles within a chunk. -/// Each index corresponds to a specific tile in the tileset. -#[derive(Component, Clone, Debug, Deref, DerefMut)] -pub struct TilemapChunkIndices(pub Vec>); - -fn on_insert_tilemap_chunk(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) { - let Some(tilemap_chunk) = world.get::(entity) else { - warn!("TilemapChunk not found for tilemap chunk {}", entity); - return; - }; - - let chunk_size = tilemap_chunk.chunk_size; - let alpha_mode = tilemap_chunk.alpha_mode; - let tileset = tilemap_chunk.tileset.clone(); - - let Some(indices) = world.get::(entity) else { - warn!("TilemapChunkIndices not found for tilemap chunk {}", entity); - return; - }; - - let Some(&anchor) = world.get::(entity) else { - warn!("Anchor not found for tilemap chunk {}", entity); - return; - }; - - let expected_indices_length = chunk_size.element_product() as usize; - if indices.len() != expected_indices_length { - warn!( - "Invalid indices length for tilemap chunk {} of size {}. Expected {}, got {}", - entity, - chunk_size, - indices.len(), - expected_indices_length - ); - return; - } - - let indices_image = make_chunk_image(&chunk_size, &indices.0); - - let display_size = (chunk_size * tilemap_chunk.tile_display_size).as_vec2(); - - let mesh_key: TilemapChunkMeshCacheKey = ( - chunk_size, - FloatOrd(display_size.x), - FloatOrd(display_size.y), - FloatOrd(anchor.as_vec().x), - FloatOrd(anchor.as_vec().y), - ); - - let tilemap_chunk_mesh_cache = world.resource::(); - let mesh = if let Some(mesh) = tilemap_chunk_mesh_cache.get(&mesh_key) { - mesh.clone() - } else { - let mut meshes = world.resource_mut::>(); - meshes.add(make_chunk_mesh(&chunk_size, &display_size, &anchor)) - }; - - let mut images = world.resource_mut::>(); - let indices = images.add(indices_image); - - let mut materials = world.resource_mut::>(); - let material = materials.add(TilemapChunkMaterial { - tileset, - indices, - alpha_mode, - }); - - world - .commands() - .entity(entity) - .insert((Mesh2d(mesh), MeshMaterial2d(material))); -} - -fn update_tilemap_chunk_indices( - query: Query< - ( - Entity, - &TilemapChunk, - &TilemapChunkIndices, - &MeshMaterial2d, - ), - Changed, - >, - mut materials: ResMut>, - mut images: ResMut>, -) { - for (chunk_entity, TilemapChunk { chunk_size, .. }, indices, material) in query { - let expected_indices_length = chunk_size.element_product() as usize; - if indices.len() != expected_indices_length { - warn!( - "Invalid TilemapChunkIndices length for tilemap chunk {} of size {}. Expected {}, got {}", - chunk_entity, - chunk_size, - indices.len(), - expected_indices_length - ); - continue; - } - - // Getting the material mutably to trigger change detection - let Some(material) = materials.get_mut(material.id()) else { - warn!( - "TilemapChunkMaterial not found for tilemap chunk {}", - chunk_entity - ); - continue; - }; - let Some(indices_image) = images.get_mut(&material.indices) else { - warn!( - "TilemapChunkMaterial indices image not found for tilemap chunk {}", - chunk_entity - ); - continue; - }; - let Some(data) = indices_image.data.as_mut() else { - warn!( - "TilemapChunkMaterial indices image data not found for tilemap chunk {}", - chunk_entity - ); - continue; - }; - data.clear(); - data.extend( - indices - .iter() - .copied() - .flat_map(|i| u16::to_ne_bytes(i.unwrap_or(u16::MAX))), - ); - } -} - -fn make_chunk_image(size: &UVec2, indices: &[Option]) -> Image { - Image { - data: Some( - indices - .iter() - .copied() - .flat_map(|i| u16::to_ne_bytes(i.unwrap_or(u16::MAX))) - .collect(), - ), - data_order: TextureDataOrder::default(), - texture_descriptor: TextureDescriptor { - size: size.to_extents(), - dimension: TextureDimension::D2, - format: TextureFormat::R16Uint, - label: None, - mip_level_count: 1, - sample_count: 1, - usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, - view_formats: &[], - }, - sampler: ImageSampler::nearest(), - texture_view_descriptor: None, - asset_usage: RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, - copy_on_resize: false, - } -} - -fn make_chunk_mesh(size: &UVec2, display_size: &Vec2, anchor: &Anchor) -> Mesh { - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, - ); - - let offset = display_size * (Vec2::splat(-0.5) - anchor.as_vec()); - - let num_quads = size.element_product() as usize; - let quad_size = display_size / size.as_vec2(); - - let mut positions = Vec::with_capacity(4 * num_quads); - let mut uvs = Vec::with_capacity(4 * num_quads); - let mut indices = Vec::with_capacity(6 * num_quads); - - for y in 0..size.y { - for x in 0..size.x { - let i = positions.len() as u32; - - let p0 = offset + quad_size * UVec2::new(x, y).as_vec2(); - let p1 = p0 + quad_size; - - positions.extend([ - Vec3::new(p0.x, p0.y, 0.0), - Vec3::new(p1.x, p0.y, 0.0), - Vec3::new(p0.x, p1.y, 0.0), - Vec3::new(p1.x, p1.y, 0.0), - ]); - - uvs.extend([ - Vec2::new(0.0, 1.0), - Vec2::new(1.0, 1.0), - Vec2::new(0.0, 0.0), - Vec2::new(1.0, 0.0), - ]); - - indices.extend([i, i + 2, i + 1]); - indices.extend([i + 3, i + 1, i + 2]); - } - } - - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); - mesh.insert_indices(Indices::U32(indices)); - - mesh -} diff --git a/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs deleted file mode 100644 index 71af0244c8709..0000000000000 --- a/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::{AlphaMode2d, Material2d, Material2dKey, Material2dPlugin}; -use bevy_app::{App, Plugin}; -use bevy_asset::{embedded_asset, embedded_path, Asset, AssetPath, Handle}; -use bevy_image::Image; -use bevy_reflect::prelude::*; -use bevy_render::{ - mesh::{Mesh, MeshVertexBufferLayoutRef}, - render_resource::*, -}; - -/// Plugin that adds support for tilemap chunk materials. -pub struct TilemapChunkMaterialPlugin; - -impl Plugin for TilemapChunkMaterialPlugin { - fn build(&self, app: &mut App) { - embedded_asset!(app, "tilemap_chunk_material.wgsl"); - - app.add_plugins(Material2dPlugin::::default()); - } -} - -/// Material used for rendering tilemap chunks. -/// -/// This material is used internally by the tilemap system to render chunks of tiles -/// efficiently using a single draw call per chunk. -#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] -pub struct TilemapChunkMaterial { - pub alpha_mode: AlphaMode2d, - - #[texture(0, dimension = "2d_array")] - #[sampler(1)] - pub tileset: Handle, - - #[texture(2, sample_type = "u_int")] - pub indices: Handle, -} - -impl Material2d for TilemapChunkMaterial { - fn fragment_shader() -> ShaderRef { - ShaderRef::Path( - AssetPath::from_path_buf(embedded_path!("tilemap_chunk_material.wgsl")) - .with_source("embedded"), - ) - } - - fn vertex_shader() -> ShaderRef { - ShaderRef::Path( - AssetPath::from_path_buf(embedded_path!("tilemap_chunk_material.wgsl")) - .with_source("embedded"), - ) - } - - fn alpha_mode(&self) -> AlphaMode2d { - self.alpha_mode - } - - fn specialize( - descriptor: &mut RenderPipelineDescriptor, - layout: &MeshVertexBufferLayoutRef, - _key: Material2dKey, - ) -> Result<(), SpecializedMeshPipelineError> { - let vertex_layout = layout.0.get_layout(&[ - Mesh::ATTRIBUTE_POSITION.at_shader_location(0), - Mesh::ATTRIBUTE_UV_0.at_shader_location(1), - ])?; - descriptor.vertex.buffers = vec![vertex_layout]; - Ok(()) - } -} diff --git a/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl deleted file mode 100644 index 7424995e22954..0000000000000 --- a/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl +++ /dev/null @@ -1,58 +0,0 @@ -#import bevy_sprite::{ - mesh2d_functions as mesh_functions, - mesh2d_view_bindings::view, -} - -struct Vertex { - @builtin(instance_index) instance_index: u32, - @builtin(vertex_index) vertex_index: u32, - @location(0) position: vec3, - @location(1) uv: vec2, -}; - -struct VertexOutput { - @builtin(position) position: vec4, - @location(0) uv: vec2, - @location(1) tile_index: u32, -} - -@group(2) @binding(0) var tileset: texture_2d_array; -@group(2) @binding(1) var tileset_sampler: sampler; -@group(2) @binding(2) var tile_indices: texture_2d; - -@vertex -fn vertex(vertex: Vertex) -> VertexOutput { - var out: VertexOutput; - - let world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); - let world_position = mesh_functions::mesh2d_position_local_to_world( - world_from_local, - vec4(vertex.position, 1.0) - ); - - out.position = mesh_functions::mesh2d_position_world_to_clip(world_position); - out.uv = vertex.uv; - out.tile_index = vertex.vertex_index / 4u; - - return out; -} - -@fragment -fn fragment(in: VertexOutput) -> @location(0) vec4 { - let chunk_size = textureDimensions(tile_indices, 0); - let tile_xy = vec2( - in.tile_index % chunk_size.x, - in.tile_index / chunk_size.x - ); - let tile_id = textureLoad(tile_indices, tile_xy, 0).r; - - if tile_id == 0xffffu { - discard; - } - - let color = textureSample(tileset, tileset_sampler, in.uv, tile_id); - if color.a < 0.001 { - discard; - } - return color; -} \ No newline at end of file diff --git a/examples/2d/tilemap.rs b/examples/2d/tilemap.rs new file mode 100644 index 0000000000000..a0d0e4a070b95 --- /dev/null +++ b/examples/2d/tilemap.rs @@ -0,0 +1,108 @@ +//! Shows a tilemap layer where each tile is an entity. + +use bevy::{ + color::palettes::tailwind, + dev_tools::fps_overlay::FpsOverlayPlugin, + prelude::*, + sprite::{TileIndex, TileOf, TilePosition, TileStorage, TilemapChunk, TilemapLayer, Tileset}, +}; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +const NUM_TILES: u16 = 6; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest())) + .add_plugins(FpsOverlayPlugin::default()) + .add_systems(Startup, setup) + .add_systems(Update, (update_tileset_image, update_tilemap)) + .add_observer(on_tilemap_chunk_inserted) + .run(); +} + +#[derive(Component, Deref, DerefMut)] +struct UpdateTimer(Timer); + +#[derive(Resource, Deref, DerefMut)] +struct SeededRng(ChaCha8Rng); + +fn setup(mut commands: Commands, assets: Res) { + // We're seeding the PRNG here to make this example deterministic for testing purposes. + // This isn't strictly required in practical use unless you need your app to be deterministic. + let mut rng = ChaCha8Rng::seed_from_u64(42); + + let map_size = UVec2::splat(10); + + commands + .spawn(( + TilemapLayer { + // chunk_size: UVec2::splat(2), + ..default() + }, + TileStorage::new(map_size), + Tileset { + image: assets.load("textures/tileset_array_texture.png"), + tile_size: UVec2::splat(8), + }, + UpdateTimer(Timer::from_seconds(0.5, TimerMode::Repeating)), + )) + .with_related_entities::(|t| { + t.spawn(( + TilePosition(uvec2(0, 0)), + TileIndex(0), + // TileIndex(rng.gen_range(0..NUM_TILES)), + )); + // for x in 0..map_size.x { + // for y in 0..map_size.y { + // t.spawn(( + // Visibility::default(), + // Transform::from_xyz(x as f32, y as f32, 0.0), + // TilePosition(uvec2(x, y)), + // TileIndex(rng.gen_range(0..NUM_TILES)), + // )); + // } + // } + }); + + commands.spawn(Camera2d); + + commands.insert_resource(SeededRng(rng)); +} + +fn update_tileset_image( + tileset_query: Single<&Tileset>, + mut events: EventReader>, + mut images: ResMut>, +) { + let tileset = *tileset_query; + for event in events.read() { + if event.is_loaded_with_dependencies(tileset.image.id()) { + let image = images.get_mut(&tileset.image).unwrap(); + image.reinterpret_stacked_2d_as_array(NUM_TILES as u32); + } + } +} + +fn update_tilemap( + time: Res