The tile editor changed the data, but not the drawing
The new tileset editor stores level geometry in a flat, cache-friendly uint16_t grid. Each cell is a 16-bit ID from a palette. Empty cells are zero. Terrain is RLE-compressed on disk. It's a tiny, perfect data structure for 2D grid editing — and for the 211×1 level we recently imported, it expands into 204 ground cubes and 2 objects.
Those 204 cubes are currently produced by TilesetConfig::expand_to_spawns() as DemoSpawn entries. The level system turns each one into an entity with a transform, a mesh, and a material. Then the renderer draws each one. The cost is not in the grid; the grid is almost free. The cost is in the entity creation, the transform updates, and the per-tile draw calls.
The insight: the grid is already an instance buffer
Modern GPU tilemap renderers (the kind you see in high-end 2D games and sparse tilemap research) do not create one entity per tile. They upload the flat grid as a buffer and draw the entire visible tilemap in one instanced draw call. The vertex shader takes the tile index, computes the world position from grid coordinates and tile size, and looks up the texture from a bindless array. The fragment shader samples the tile.
Our tileset data is already arranged exactly this way. TerrainGrid.cells is a row-major std::vector<uint16_t>. The TilePalette maps those IDs to tile names. The TilesetTileDef maps names to archetypes, and the archetypes already carry mesh and material overrides. The only missing piece is a render pass that reads the grid directly instead of flattening it into entities first.
"The uint16_t array is not just a level editing format. It is the same buffer a GPU tilemap renderer would use. The grid is the instance buffer."
What we already have: VFX instancing
The VFX render pass already does the heavy lifting for particle instancing. It allocates a persistently mapped SSBO, fills it with ParticleInstanceData structs each frame, and dispatches up to 65,536 particles in three draw calls using vkCmdDraw and vkCmdDrawIndirect. The texture lookup is handled through the existing bindless texture array, which RenderSystem already exposes.
// Allocate buffer for max particle capacity (65K * 64 bytes = 4 MiB) constexpr size_t k_max_particles = 65536; constexpr size_t k_stride = sizeof(ParticleInstanceData); const VkDeviceSize buffer_size = k_max_particles * k_stride; // ... // CPU draw path: vkCmdDraw with instance counts vkCmdDraw_(cmd, 6, opaque_count, 0, instance_offset);
The billboard quad is 6 vertices. The opaque_count is the instance count. Per-frame, the VFX system copies instance data into a mapped buffer and draws all opaque particles in one call. Particle data is just position, color, scale, rotation, and texture index. Tile data is even simpler: grid position, tile ID, texture index. The tile mesh might be a cube rather than a billboard, but the instancing pipeline is the same.
Proposed architecture: TileRenderPass
A new TileRenderPass would read the tileset's flat array directly and render all terrain tiles in one instanced call. The data flow looks like this:
TilesetConfig is already available. The pass receives the TerrainGrid, the TilePalette, the tile definitions, and the origin.TileInstanceData struct for every non-empty cell. The struct contains world position (col * tile_width + origin, row * tile_height + origin, 0), the tile ID, and the bindless texture index. Empty cells are skipped entirely.gl_InstanceIndex to fetch the per-tile data and computes the model matrix.vkCmdDraw(cmd, cube_vertex_count, tile_count, 0, 0) call. For the current 204-tile level, that's 204 instances of a shared cube mesh. The same call handles 10,000 tiles with no additional CPU cost.struct TileInstanceData {
glm::vec3 position; // world origin of this tile
uint16_t tile_id; // index into tile palette
uint16_t tex_index; // bindless texture array slot
uint32_t pad; // 16-byte alignment
}; // 16 bytes per tile — 204 tiles = 3.2 KiB The palette's uint16_t IDs map directly to bindless texture array indices. The tile definitions already carry the mesh and material paths. If every tile in a level uses the same mesh, we bind one vertex buffer and draw instances. If a level uses multiple tile meshes, we bucket instances by mesh and emit one draw per bucket — still far fewer than 204 individual draw calls.
Why this matters for the project
The side-scroller prototype is intentionally small, but the tile format is designed to scale. A single level with 211 columns is just the beginning. The MAX_TILESET_GRID_DIMENSION is 4096 and MAX_TILESET_CELLS is 16 million. At 16 million cells, the grid is still 32 MB of uint16_t data — small enough to stream, huge enough to represent a world. The renderer should not be the bottleneck that converts that compact data into a heavy entity graph.
The existing infrastructure
CatGame's render graph already has the pieces needed. The RenderSystem exposes get_bindless_texture_array(), which handles bindless texture indexing. The VFX pass demonstrates SSBO-based instancing, persistent mapped buffers, and both CPU and GPU indirect draw paths. The tileset types are already defined in engine/systems/level/tileset/tileset_types.hpp and the load/save pipeline flows through ResourceSystem and FileSystem.
The modularization plan for the render system (RENDER_SYSTEM_FINAL_CLEANUP.md) and the Vulkan device extraction plan (VULKAN_DEVICE_EXTRACTION.md) both point toward cleaner, smaller render passes. Adding a TileRenderPass as a new focused pass fits that direction. It would be a single-purpose pass rather than more logic in the existing monolith.
Open questions and risks
3D cubes, not 2D billboards. The VFX pass uses billboards. Tile terrain is usually 3D cubes. The instanced shader needs a cube vertex buffer and the instance data must contain orientation/scale for tiles that are not axis-aligned or uniformly sized.
Physics representation. Entity-based tiles can carry colliders. GPU-instanced tiles cannot. The physics world needs a separate, lighter representation derived from the same grid — possibly a simple per-column collision height map.
Render graph integration. The forward/deferred pipeline and shadow pass need to know about tile geometry. The pass must write depth, support shadows, and participate in culling if the camera sees only a portion of the grid.
Mesh variety. If a palette uses 10 different tile meshes, the single-draw-call promise becomes 10 draw calls. That is still excellent, but the bucketing logic needs to be fast and cache-aware.
First experiment
The next step is a minimal prototype: profile the current level to measure draw calls and entity creation time for the 204 ground cubes, then implement a TileRenderPass that renders a test grid of a single cube mesh using vkCmdDraw(cmd, 36, tile_count, 0, 0) with a simple vertex shader. If the test grid can render 204 cubes in one call and correctly map tile IDs to bindless texture indices, the architecture is proven. The hard problems — physics, shadows, and mesh variety — can be layered on top.
"The goal is not just fewer draw calls. It is a renderer that respects the tile editor's data: a flat grid, a small palette, and a world that can be drawn at the scale of the grid itself."
This is the logical next step for the tileset subsystem. The editor created the right data. The engine is now positioned to render it in the right way.
