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:
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.