Add TilemapChunk rendering (#18866)

# 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<u32> 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<Option<u32>> = (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)
This commit is contained in:
Conner Petzold 2025-06-23 19:55:10 -04:00 committed by GitHub
parent d3ad66f033
commit 3f187cf752
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 506 additions and 1 deletions

View File

@ -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"

View File

@ -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::<TextureSlicer>()
.register_type::<Anchor>()
.register_type::<Mesh2d>()
.add_plugins((Mesh2dRenderPlugin, ColorMaterialPlugin))
.add_plugins((
Mesh2dRenderPlugin,
ColorMaterialPlugin,
TilemapChunkPlugin,
TilemapChunkMaterialPlugin,
))
.add_systems(
PostUpdate,
(

View File

@ -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::<TilemapChunkMeshCache>()
.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<TilemapChunkMeshCacheKey, Handle<Mesh>>);
/// 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<TilemapChunkMaterial>, 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<Image>,
/// 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<Option<u16>>);
fn on_add_tilemap_chunk(
trigger: On<Add, TilemapChunk>,
tilemap_chunk_query: Query<(&TilemapChunk, &TilemapChunkIndices, &Anchor)>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<TilemapChunkMaterial>>,
mut images: ResMut<Assets<Image>>,
mut tilemap_chunk_mesh_cache: ResMut<TilemapChunkMeshCache>,
) {
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<TilemapChunkMaterial>,
),
Changed<TilemapChunkIndices>,
>,
mut materials: ResMut<Assets<TilemapChunkMaterial>>,
mut images: ResMut<Assets<Image>>,
) {
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<u16>]) -> 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
}

View File

@ -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::<TilemapChunkMaterial>::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<Image>,
#[texture(2, sample_type = "u_int")]
pub indices: Handle<Image>,
}
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<Self>,
) -> 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(())
}
}

View File

@ -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<f32>,
@location(1) uv: vec2<f32>,
};
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) tile_index: u32,
}
@group(2) @binding(0) var tileset: texture_2d_array<f32>;
@group(2) @binding(1) var tileset_sampler: sampler;
@group(2) @binding(2) var tile_indices: texture_2d<u32>;
@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<f32>(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<f32> {
let chunk_size = textureDimensions(tile_indices, 0);
let tile_xy = vec2<u32>(
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;
}

View File

@ -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<AssetServer>) {
let mut rng = ChaCha8Rng::seed_from_u64(42);
let chunk_size = UVec2::splat(64);
let tile_display_size = UVec2::splat(8);
let indices: Vec<Option<u16>> = (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<AssetEvent<Image>>,
mut images: ResMut<Assets<Image>>,
) {
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<Time>, mut query: Query<(&mut TilemapChunkIndices, &mut UpdateTimer)>) {
for (mut indices, mut timer) in query.iter_mut() {
timer.tick(time.delta());
if timer.just_finished() {
let mut rng = ChaCha8Rng::from_entropy();
for _ in 0..50 {
let index = rng.gen_range(0..indices.len());
indices[index] = Some(rng.gen_range(0..5));
}
}
}
}

View File

@ -129,6 +129,7 @@ Example | Description
[Sprite Tile](../examples/2d/sprite_tile.rs) | Renders a sprite tiled in a grid
[Text 2D](../examples/2d/text2d.rs) | Generates text in 2D
[Texture Atlas](../examples/2d/texture_atlas.rs) | Generates a texture atlas (sprite sheet) from individual sprites
[Tilemap Chunk](../examples/2d/tilemap_chunk.rs) | Renders a tilemap chunk
[Transparency in 2D](../examples/2d/transparency_2d.rs) | Demonstrates transparency in 2d
## 3D Rendering

View File

@ -0,0 +1,25 @@
---
title: Tilemap Chunk Rendering
authors: ["@ConnerPetzold", "@grind086", "@IceSentry"]
pull_requests: [18866]
---
A performant way to render tilemap chunks has been added as the first building block to Bevy's tilemap support. You can render a chunk by supplying a tileset texture to the `TilemapChunk` component and the indices into that tileset for each tile to `TilemapChunkIndices`.
```rust
let chunk_size = UVec2::splat(64);
let tile_size = UVec2::splat(16);
let indices: Vec<Option<u32>> = (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),
));
```