07 January 2018

WebGL Renderer for Tiled Maps

TL;DR: I made a WebGL renderer for Tiled Editor maps called gl-tiled.

Introduction

I was looking for a good way to render tile maps created with the Tiled Editor and couldn’t find a WebGL renderer that really fit my needs so I decided to write one. I call the resulting library gl-tiled.

I got inspired by an article from Brandon Jones. In this article he describes a technique in which the map is rendered using two textures. One is the tileset, the other is a texture representing the map itself. Each pixel encodes the x/y coords of the tile from the tileset to draw. In my library I extend this technique to support more features of Tiled, as well as automatically generating the lookup texture.

Anatomy of a Tiled Map

In this post I’m going to focus on high-level concepts rather than listing each field on the format. If you are interested in that you can take a look at the XML format reference or my Typescript definition that represents the JSON output.

A Tiled map is basically a container for 2 types of objects: layers and tilesets. Each layer then has a subtype which is one of: imagelayer, tilelayer, or objectlayer.

Objectlayer is not yet supported, and won’t be talked about here. That leaves us with 3 main objects to worry about handling properly: a tileset, an imagelayer, and a tilelayer.

Rendering an imagelayer

An imagelayer is really just an image, so drawing it is pretty straightforward. Here is the fragment shader that is used to draw the image:

precision mediump float;

varying vec2 vTextureCoord;

uniform sampler2D uSampler;
uniform float uAlpha;
uniform vec4 uTransparentColor;

void main()
{
    vec4 color = texture2D(uSampler, vTextureCoord);

    if (uTransparentColor.a == 1.0 && uTransparentColor.rgb == color.rgb)
        discard;

    gl_FragColor = vec4(color.rgb, color.a * uAlpha);
}

As you can see, basically all we do is pull the color from the image and draw it. There is one extra step since Tiled lets you choose a color to be treated as transparent, so we check if this texel is that color and ignore it if it is.

Rendering a tilelayer

Rendering the tilelayer is really the meat of the work the library does. The process involves generating a map texture which represents all the metadata of the layer, then uploading that and all the tileset images to the GPU for rendering. I use all 4 channels of the texture to encode information about the layer, here is what each channel holds:

  • Red: The X coord of the tile to draw
  • Green: The Y coord of the tile to draw
  • Blue: The index of which tileset image to use
  • Alpha: Flags related to rendering this tile

For this map, this generated data texture looks like this:

DW tilelayer

This map texture can be combined with this tileset to create the final map you see here.

A simplified and annotated version of the shader that combines these images looks something like this:

precision mediump float;

varying vec2 vPixelCoord;
varying vec2 vTextureCoord;

// This is the generated map data texture
uniform sampler2D uLayer;

// These are the tileset images the map uses
uniform sampler2D uTilesets[NUM_TILESET_IMAGES];

uniform vec2 uTilesetTileSize[NUM_TILESET_IMAGES];
uniform vec2 uTilesetTileOffset[NUM_TILESET_IMAGES];
uniform vec2 uInverseTilesetTextureSize[NUM_TILESET_IMAGES];
uniform float uAlpha;

// Here I removed some simple getter functions for brevity.
// They are getTilesetTileOffset, getTilesetTileOffset, and getColor
// All of them take an int index and perform a lookup in the array of uniforms above.

void main()
{
    // Read the metadata of the tile we are operating on here
    vec4 tile = texture2D(uLayer, vTextureCoord);

    // index of the tileset image to use
    int imgIndex = int(floor(tile.z * 255.0));

    // the size of a tile in this layer
    vec2 tileSize = getTilesetTileSize(imgIndex);

    // Tiled supports spacing and margin in a tileset, these values are loaded here
    vec2 tileOffset = getTilesetTileOffset(imgIndex);

    // To get the x/y coord of the tile to draw we denormalize the value
    // we pulled from the generated layer texture
    vec2 tileCoord = floor(tile.xy * 255.0);

    // tileOffset.x is 'spacing', tileOffset.y is 'margin'
    tileCoord.x = (tileCoord.x * tileSize.x) + (tileCoord.x * tileOffset.x) + tileOffset.y;
    tileCoord.y = (tileCoord.y * tileSize.y) + (tileCoord.y * tileOffset.x) + tileOffset.y;

    // Now that we have the tile coord, we need to find which specific texel in
    // the tile we are at.
    vec2 offsetInTile = mod(vPixelCoord, tileSize);

    // finally load the color from the tileset image using the index of the
    // image and the calculated offset
    vec4 color = getColor(imgIndex, tileCoord + offsetInTile);

    gl_FragColor = vec4(color.rgb, color.a * uAlpha);
}

That is pretty much it! Thanks again to Brandon Jones for sharing the technique that inspired this library.