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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions