From 3f187cf7526ab375f33a2f43c85f4d1d0fa3f8e2 Mon Sep 17 00:00:00 2001 From: Conner Petzold <96224+ConnerPetzold@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:55:10 -0400 Subject: [PATCH] Add TilemapChunk rendering (#18866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective An attempt to start building a base for first-party tilemaps (#13782). The objective is to create a very simple tilemap chunk rendering plugin that can be used as a building block for 3rd-party tilemap crates, and eventually a first-party tilemap implementation. ## Solution - Introduces two user-facing components, `TilemapChunk` and `TilemapChunkIndices`, and a new material `TilemapChunkMaterial`. - `TilemapChunk` holds the chunk and tile sizes, and the tileset image - The tileset image is expected to be a layered image for use with `texture_2d_array`, with the assumption that atlases or multiple images would go through an asset loader/processor. Not sure if that should be part of this PR or not.. - `TilemapChunkIndices` holds a 1d representation of all of the tile's Option index into the tileset image. - Indices are fixed to the size of tiles in a chunk (though maybe this should just be an assertion instead?) - Indices are cloned and sent to the shader through a u32 texture. ## Testing - Initial testing done with the `tilemap_chunk` example, though I need to include some way to update indices as part of it. - Tested wasm with webgl2 and webgpu - I'm thinking it would probably be good to do some basic perf testing. --- ## Showcase ```rust let chunk_size = UVec2::splat(64); let tile_size = UVec2::splat(16); let indices: Vec> = (0..chunk_size.x * chunk_size.y) .map(|_| rng.gen_range(0..5)) .map(|i| if i == 0 { None } else { Some(i - 1) }) .collect(); commands.spawn(( TilemapChunk { chunk_size, tile_size, tileset, }, TilemapChunkIndices(indices), )); ``` ![Screenshot 2025-04-17 at 11 54 56 PM](https://github.com/user-attachments/assets/850a53c1-16fc-405d-aad2-8ef5a0060fea) --- Cargo.toml | 11 + crates/bevy_sprite/src/lib.rs | 9 +- crates/bevy_sprite/src/tilemap_chunk/mod.rs | 263 ++++++++++++++++++ .../tilemap_chunk/tilemap_chunk_material.rs | 70 +++++ .../tilemap_chunk/tilemap_chunk_material.wgsl | 58 ++++ examples/2d/tilemap_chunk.rs | 70 +++++ examples/README.md | 1 + .../release-notes/tilemap-chunk-rendering.md | 25 ++ 8 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 crates/bevy_sprite/src/tilemap_chunk/mod.rs create mode 100644 crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs create mode 100644 crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl create mode 100644 examples/2d/tilemap_chunk.rs create mode 100644 release-content/release-notes/tilemap-chunk-rendering.md diff --git a/Cargo.toml b/Cargo.toml index e6c75611ba..b082f166c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -845,6 +845,17 @@ description = "Generates a texture atlas (sprite sheet) from individual sprites" category = "2D Rendering" wasm = false +[[example]] +name = "tilemap_chunk" +path = "examples/2d/tilemap_chunk.rs" +doc-scrape-examples = true + +[package.metadata.example.tilemap_chunk] +name = "Tilemap Chunk" +description = "Renders a tilemap chunk" +category = "2D Rendering" +wasm = true + [[example]] name = "transparency_2d" path = "examples/2d/transparency_2d.rs" diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index 882ec5857c..3e15499dd0 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -16,6 +16,7 @@ mod picking_backend; mod render; mod sprite; mod texture_slice; +mod tilemap_chunk; /// The sprite prelude. /// @@ -40,6 +41,7 @@ pub use picking_backend::*; pub use render::*; pub use sprite::*; pub use texture_slice::*; +pub use tilemap_chunk::*; use bevy_app::prelude::*; use bevy_asset::{embedded_asset, AssetEventSystems, Assets}; @@ -87,7 +89,12 @@ impl Plugin for SpritePlugin { .register_type::() .register_type::() .register_type::() - .add_plugins((Mesh2dRenderPlugin, ColorMaterialPlugin)) + .add_plugins(( + Mesh2dRenderPlugin, + ColorMaterialPlugin, + TilemapChunkPlugin, + TilemapChunkMaterialPlugin, + )) .add_systems( PostUpdate, ( diff --git a/crates/bevy_sprite/src/tilemap_chunk/mod.rs b/crates/bevy_sprite/src/tilemap_chunk/mod.rs new file mode 100644 index 0000000000..6ca4b7f77a --- /dev/null +++ b/crates/bevy_sprite/src/tilemap_chunk/mod.rs @@ -0,0 +1,263 @@ +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::Add, + observer::On, + query::Changed, + resource::Resource, + system::{Commands, Query, ResMut}, +}; +use bevy_image::{Image, ImageSampler}; +use bevy_math::{FloatOrd, UVec2, Vec2, Vec3}; +use bevy_platform::collections::HashMap; +use bevy_render::{ + mesh::{Indices, Mesh, Mesh2d, PrimitiveTopology}, + render_resource::{ + Extent3d, 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_observer(on_add_tilemap_chunk) + .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(Mesh2d, MeshMaterial2d, TilemapChunkIndices, Anchor)] +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, Default, Deref, DerefMut)] +pub struct TilemapChunkIndices(pub Vec>); + +fn on_add_tilemap_chunk( + trigger: On, + tilemap_chunk_query: Query<(&TilemapChunk, &TilemapChunkIndices, &Anchor)>, + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + mut images: ResMut>, + mut tilemap_chunk_mesh_cache: ResMut, +) { + let chunk_entity = trigger.target(); + let Ok(( + TilemapChunk { + chunk_size, + tile_display_size, + tileset, + alpha_mode, + }, + indices, + anchor, + )) = tilemap_chunk_query.get(chunk_entity) + else { + warn!("Tilemap chunk {} not found", 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 {}", + chunk_entity, + chunk_size, + indices.len(), + expected_indices_length + ); + return; + } + + let indices_image = make_chunk_image(chunk_size, &indices.0); + + let display_size = (chunk_size * 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 mesh = tilemap_chunk_mesh_cache + .entry(mesh_key) + .or_insert_with(|| meshes.add(make_chunk_mesh(chunk_size, &display_size, anchor))); + + commands.entity(chunk_entity).insert(( + Mesh2d(mesh.clone()), + MeshMaterial2d(materials.add(TilemapChunkMaterial { + tileset: tileset.clone(), + indices: images.add(indices_image), + alpha_mode: *alpha_mode, + })), + )); +} + +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; + } + + 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(), + ), + texture_descriptor: TextureDescriptor { + size: Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + 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, + } +} + +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 new file mode 100644 index 0000000000..c8879a58f1 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.rs @@ -0,0 +1,70 @@ +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. +#[derive(Default)] +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 new file mode 100644 index 0000000000..7424995e22 --- /dev/null +++ b/crates/bevy_sprite/src/tilemap_chunk/tilemap_chunk_material.wgsl @@ -0,0 +1,58 @@ +#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_chunk.rs b/examples/2d/tilemap_chunk.rs new file mode 100644 index 0000000000..35c2694ff5 --- /dev/null +++ b/examples/2d/tilemap_chunk.rs @@ -0,0 +1,70 @@ +//! Shows a tilemap chunk rendered with a single draw call. + +use bevy::{ + prelude::*, + sprite::{TilemapChunk, TilemapChunkIndices}, +}; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +fn main() { + App::new() + .add_plugins((DefaultPlugins.set(ImagePlugin::default_nearest()),)) + .add_systems(Startup, setup) + .add_systems(Update, (update_tileset_image, update_tilemap)) + .run(); +} + +#[derive(Component, Deref, DerefMut)] +struct UpdateTimer(Timer); + +fn setup(mut commands: Commands, assets: Res) { + let mut rng = ChaCha8Rng::seed_from_u64(42); + let chunk_size = UVec2::splat(64); + let tile_display_size = UVec2::splat(8); + let indices: Vec> = (0..chunk_size.element_product()) + .map(|_| rng.gen_range(0..5)) + .map(|i| if i == 0 { None } else { Some(i - 1) }) + .collect(); + + commands.spawn(( + TilemapChunk { + chunk_size, + tile_display_size, + tileset: assets.load("textures/array_texture.png"), + ..default() + }, + TilemapChunkIndices(indices), + UpdateTimer(Timer::from_seconds(0.1, TimerMode::Repeating)), + )); + + commands.spawn(Camera2d); +} + +fn update_tileset_image( + chunk_query: Single<&TilemapChunk>, + mut events: EventReader>, + mut images: ResMut>, +) { + let chunk = *chunk_query; + for event in events.read() { + if event.is_loaded_with_dependencies(chunk.tileset.id()) { + let image = images.get_mut(&chunk.tileset).unwrap(); + image.reinterpret_stacked_2d_as_array(4); + } + } +} + +fn update_tilemap(time: Res