Skip to content

GPU-accelerated tilemap layer rendering #1401

@obiot

Description

@obiot

Summary

Render an entire TMXLayer as a single screen-aligned quad using a GPU shader, instead of drawing each tile individually. The shader samples from a tile index texture (which tile goes where) and a tileset texture (the actual tile graphics) to render the entire layer in one draw call.

Current State

melonJS renders tilemaps tile-by-tile:

  • `TMXLayer.draw()` iterates over visible tiles
  • Each tile is a `drawImage()` call through the quad batcher
  • Multi-texture batching helps (up to 16 textures per flush) but the CPU-side loop and vertex pushing is the bottleneck
  • A 100x100 visible tile area = 10,000 `addQuad()` calls per frame

Proposed Architecture

Data textures

  1. Tile index texture — a `DataTexture` (or `UNSIGNED_SHORT` texture) where each pixel encodes the tile GID at that map position. Updated only when tiles change (rare). Size = map width × map height.

  2. Tileset texture — the existing tileset spritesheet, already loaded as a GL texture.

Shader

The fragment shader:

  • Receives the camera's visible area as uniforms (scroll position, viewport size)
  • For each screen pixel, computes which tile and which pixel within that tile
  • Looks up the tile GID from the index texture
  • Samples the correct tile from the tileset texture
  • Handles tile flipping flags (horizontal, vertical, diagonal) encoded in the GID
uniform sampler2D uTileIndex;    // tile GID map
uniform sampler2D uTileset;      // tileset spritesheet
uniform vec2 uMapSize;           // map dimensions in tiles
uniform vec2 uTileSize;          // tile size in pixels
uniform vec2 uTilesetSize;       // tileset texture size in pixels
uniform vec2 uTilesetColumns;    // tiles per row in tileset
uniform vec2 uScroll;            // camera scroll position

vec4 apply(vec4 color, vec2 uv) {
    // compute which tile this pixel is in
    vec2 pixelPos = uv * uViewportSize + uScroll;
    vec2 tileCoord = floor(pixelPos / uTileSize);
    vec2 tileUV = fract(pixelPos / uTileSize);

    // look up tile GID from index texture
    float gid = texture2D(uTileIndex, tileCoord / uMapSize).r * 255.0;
    if (gid == 0.0) discard; // empty tile

    // compute tileset UV from GID
    float col = mod(gid - 1.0, uTilesetColumns);
    float row = floor((gid - 1.0) / uTilesetColumns);
    vec2 tileOrigin = vec2(col, row) * uTileSize / uTilesetSize;
    vec2 tileTexel = tileOrigin + tileUV * uTileSize / uTilesetSize;

    return texture2D(uTileset, tileTexel);
}

Integration

  • New `TMXGPULayer` class extending or wrapping `TMXLayer`
  • Builds the tile index texture on layer load and when tiles change (`setTile()`)
  • Renders as a single quad via the existing batcher with a custom shader
  • Falls back to the standard tile-by-tile renderer for Canvas mode
  • Needs to handle: multiple tilesets per layer, tile flipping, animated tiles, tile opacity

Challenges

  • Multiple tilesets: a single layer can reference tiles from multiple tilesets. Options: merge into one mega-texture, use texture arrays (WebGL2), or split into one quad per tileset.
  • Animated tiles: GIDs change over time. Either update the index texture per frame (cheap if few animated tiles) or encode animation data in the shader.
  • Tile flipping: Tiled encodes flip flags in the upper bits of the GID. The shader needs to handle UV flipping.
  • GID encoding: with 16-bit textures (`UNSIGNED_SHORT`), supports up to 65535 unique tiles. For larger tilesets, use `RGBA` encoding (4 bytes per tile).
  • Isometric/hexagonal: initial implementation targets orthogonal maps only.

Performance expectations

  • CPU: near-zero per-frame cost — no tile iteration, no vertex pushing, no per-tile draw calls
  • GPU: single quad, single draw call, single shader. The shader does per-pixel work but GPUs are built for this.
  • Memory: one extra texture (tile index map). For a 256x256 map with 16-bit GIDs = 128KB.

API Sketch

// automatic — TMXLayer uses GPU rendering when available
const layer = map.getLayer("Background");
layer.gpuRendering = true; // opt-in per layer

// or globally via application settings
new Application(800, 600, {
    gpuTilemap: true, // enable for all layers
});

References

  • `TMXLayer.draw()`: `src/level/tiled/TMXLayer.js`
  • `QuadBatcher`: `src/video/webgl/batchers/quad_batcher.js`
  • `ShaderEffect`: `src/video/webgl/shadereffect.js`
  • WebGL2 texture formats: `gl.R16UI`, `gl.RGBA8` for tile index data

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions