Bevy Tilemap Renderer

Motivation

Bevy currently lacks first-party support for tilemap rendering, leaving developers to rely on third-party crates or implement their own systems. This fragmentation leads to inconsistent APIs and duplicate effort. First-party tilemap rendering will enable developers to build tile-based games and 3rd party integrations more easily and efficiently.

Goals

  • Support a wide variety of use cases
    • Rectangular, isometric, hexagonal tile shapes
    • Finite and infinite tilemaps
    • Atlas, array texture, and file-per-tile assets for tilesets
  • Clean separation of tilemap rendering and higher-level APIs
  • Focus on performance
  • Enable existing tilemap crates to swap out their own rendering implementations
  • Build on top of Bevy's high-level rendering as much as possible (Mesh2d/Material2d)

Features

Asset to Tileset Pipeline

  • Provide a first-party asset type for tilesets
  • Load tileset images as array texture (1 layer per tile variant)
  • Define metadata for tile dimensions and layout
  • Support slicing packed texture atlases into the array texture
  • Support combining separate tile images into the array texture

Fast Chunk-Based Rendering

  • Divide the map into fixed-size chunks (e.g. 32x32 tiles)
  • A chunk is a Mesh2d with a custom chunk shader
  • The shader is responsible for rendering tiles based on tile data and cached mesh data

Tile Data

  • Tileset texture index (tile variant)
  • Color
  • Visible
  • Flip X/Y/Diagonal
  • Rotate?

Tile Storage

  • Tile data stored in a single source of truth
  • Support both sparse and dense storage?
  • Modifying tile storage should only update relevant chunks

Infinite/finite Map

  • Allow dynamic growth in all directions (IVec2 tile positions, not UVec2)
  • Support lazy loading of chunks?
  • Allow setting map bounds to constrain and enable whole-map transform anchoring

Proposed API

  • Basic infinite map with sparse storage

    ​​​​let chunk_size = UVec2::splat(32); ​​​​let mut tile_storage = TileStorage::sparse(chunk_size); ​​​​// Sets tiledata at (3200,3200) and marks chunk position 100,100 as dirty. ​​​​tile_storage.set( ​​​​ IVec2::splat(3200), ​​​​ TileData { ​​​​ tileset_index: 0, ​​​​ color: Color::WHITE, ​​​​ visible: true, ​​​​ flip: TileFlip { ​​​​ x: true, ​​​​ y: false, ​​​​ d: false ​​​​ } ​​​​ } ​​​​); ​​​​commands.spawn(( ​​​​ Tilemap::default(), ​​​​ Tileset(assets.load("atlas-tileset.tileset.ron")), ​​​​ tile_storage ​​​​));
  • Basic finite map with dense storage

    ​​​​let map_size = UVec2::new(128, 32); ​​​​let chunk_size = UVec2::splat(32); ​​​​let mut tile_storage = TileStorage::new_dense_fill(map_size, chunk_size, TileData::from_index(0)); ​​​​commands.spawn(( ​​​​ Tilemap { ​​​​ // Override the tile size determined by the tileset ​​​​ tile_display_size: UVec2::splat(32) ​​​​ }, ​​​​ Tileset(assets.load("atlas-tileset.tileset.ron")), ​​​​ tile_storage, ​​​​ Anchor::BOTTOM_CENTER ​​​​));

Out of Scope

  • Higher-level ECS API (Entity-per-tile, Tile/Tilemap relation, traversal, etc.)
  • Advanced features like animated tiles, auto-tiling,