tairu - an interactive exploration of 2D autotiling techniques

  • I remember since my early days doing programming, I’ve been interested in how games like Terraria handle automatically tiling their terrain.

    2024-02-11
    • in Terraria, you can fully modify the terrain however you want, and the tiles will connect to each other seamlessly.

      2024-02-11
      • TODO: short videos demoing this here

        2024-02-11
  • once upon a time I stumbled upon a technique called…

    2024-02-11
  • bitwise autotiling

    2024-02-19
    • I learned about it way back when I was just a kid building 2D Minecraft clones using Construct 2, and I wanted my terrain to look nice as it does in Terraria

      2024-02-11
      • Construct 2 was one of my first programming experiences and the first game engine I truly actually liked smile

        2024-02-11
    • so to help us learn, I made a little tile editor so that we can experiment with rendering tiles! have a look:

      import { Tilemap } from "tairu/tilemap.js"; import { TileEditor } from "tairu/editor.js"; export const tilemapSquare = Tilemap.parse(" x", [ " ", " xxx ", " xxx ", " xxx ", " ", ]); new TileEditor({ tilemap: tilemapSquare, tileSize: 40, });
      2024-02-19
      • Tilemap is a class wrapping a flat Uint8Array with a width and a height, so that we can index it using (x, y) coordinates.

        console.log(tilemapSquare.at(0, 0)); console.log(tilemapSquare.at(3, 1));
        0
        1
        
        2024-02-19
        • at has a setAt counterpart which sets tiles instead of getting them.

          2024-02-19
      • TileEditor provides a graphical editor for a Tilemap based on a <canvas>.

        2024-02-19
        • this editor is Certified Battery Efficient™, so it won’t redraw unless it needs to!
          we’ll need to keep this in mind for later when we try to draw images, which may not be loaded during the initial draw.

          2024-02-19
    • to kick this off, let’s set off a goal. I would like the tiles in our little renderer to connect together, like this:

      red rectangle with a black outline, made out of 3x3 tiles

      2024-02-19
    • let’s break this down into smaller steps. drawing a border around the rectangle will involve:

      2024-02-19
      • determining on which tiles to draw it,

        2024-02-19
      • determining where in these tiles to draw it,

        2024-02-19
      • and actually drawing it!

        2024-02-19
    • so let’s zoom in a bit and look at the tiles one by one. in particular, let’s focus on these two tiles:

      the same red rectangle, now with a focus on the northern tile at its center

      2024-02-19
    • notice how the two highlighted tiles are different. therefore, we can infer we should probably connect together any tiles that are the same.

      2024-02-19
    • knowing that, we can extract the logic to a function:

      export function shouldConnect(a, b) { return a == b; }
      2024-02-19
    • now, also note that the border around this particular tile is only drawn on its northern edge - therefore we can infer that borders should only be drawn on edges for whom shouldConnect(thisTile, adjacentTile) is false (not true!). a tile generally has four edges - east, south, west, north - so we need to perform this check for all of them, and draw our border accordingly.

      2024-02-19
      • you might be wondering why I’m using this particular order for cardinal directions - why not [north, south, east, west]? or [north, east, south, west]?

        2024-02-19
        • the reason comes from math - [cos(0) sin(0)] is a vector pointing rightwards, not upwards! and I chose clockwise order, because that’s how the vector rotates as we increase the angle, in a coordinate space where +Y points downward - such as the <canvas> coordinate space.

          2024-02-19
        • this choice yields some nice orderliness in the code that handles fetching tiles for connections - first you check +X, then +Y, then -X, and then -Y - which my pedantic mind really appreciates ahyes
          as X is first alphabetically, so checking Y first would feel wrong.

          2024-02-19
    • to do that, I’m gonna override the tile editor’s drawTilemap function - as this is where the actual tilemap rendering happens!

      import { TileEditor } from "tairu/editor.js"; export class TileEditorWithBorders extends TileEditor { constructor({ borderWidth, ...options }) { super(options); this.borderWidth = borderWidth; this.colorScheme.borderColor = "#000000"; } drawTilemap() { // Let the base class render out the infill, we'll just handle the borders. super.drawTilemap(); this.ctx.fillStyle = this.colorScheme.borderColor; for (let y = 0; y < this.tilemap.height; ++y) { for (let x = 0; x < this.tilemap.width; ++x) { let tile = this.tilemap.at(x, y); // We only want to draw non-empty tiles, so skip tile 0. if (tile == 0) { continue; } // Check which of this tile's neighbors should _not_ connect to it. let disjointWithEast = !shouldConnect(tile, this.tilemap.at(x + 1, y)); let disjointWithSouth = !shouldConnect(tile, this.tilemap.at(x, y + 1)); let disjointWithWest = !shouldConnect(tile, this.tilemap.at(x - 1, y)); let disjointWithNorth = !shouldConnect(tile, this.tilemap.at(x, y - 1)); let { borderWidth, tileSize } = this; let tx = x * tileSize; let ty = y * tileSize; // For each disjoint neighbor, we want to draw a border between us and them. if (disjointWithEast) { this.ctx.fillRect(tx + tileSize - borderWidth, ty, borderWidth, tileSize); } if (disjointWithSouth) { this.ctx.fillRect(tx, ty + tileSize - borderWidth, tileSize, borderWidth); } if (disjointWithWest) { this.ctx.fillRect(tx, ty, borderWidth, tileSize); } if (disjointWithNorth) { this.ctx.fillRect(tx, ty, tileSize, borderWidth); } } } } }

      and here’s the result:

      new TileEditorWithBorders({ tilemap: tilemapSquare, tileSize: 40, borderWidth: 4, });
      2024-02-19
    • this looks pretty perfect - maybe sans corners, which I’ll conveniently skip for now, because most games don’t actually render graphics in a vectorial way like this! instead, the more common way is to use a tileset - a big texture with a bunch of sprites to use for rendering each tile.

      2024-02-19
      • not only does this have the advantage of allowing for richer graphics, but it is also a lot easier to modify by artists, because you no longer need knowledge of graphics APIs to draw tiles.

        2024-02-19
    • for example, here’s a tileset I drew for the 3rd iteration of my game [Planet Overgamma] - though tweaked a bit because I had never used it before writing this post hueh

      heavy metal sheet tileset from Planet Overgamma, made out of 16 tiles. it looks like heavy embossed sheets of metal, resembling steel in its heavyness

      2024-02-19
    • we can split this tileset up into 16 individual tiles, each one 8 × 8 pixels; people choose various resolutions, I chose a fairly low one to hide my lack of artistic skill.

      2024-02-19
    • the keen eyed among you have probably noticed that this is very similar to the case we had before with drawing procedural borders - except that instead of determining which borders to draw based on a tile’s neighbors, this time we’ll determine which whole tile to draw based on its neighbors!

      E S E S W S W S E S N E S W N S W N S N E N E W N W N N E E W W

      2024-02-19
    • previously we represented which single border to draw with a single boolean. now we will represent which single tile to draw with four booleans, because each tile can connect to four different directions.

      2024-02-19
      • four booleans like this can easily be packed into a single integer using some bitwise operations, hence we get bitwise autotiling - autotiling using bitwise operations!

        2024-02-19
    • now the clever part of bitwise autotiling is that we can use this packed integer as an array index - therefore selecting which tile to draw can be determined using just a single lookup table! neat, huh?

      2024-02-19
      • but because I’m lazy, and CPU time is valuable, instead of using an array I’ll just rearrange the tileset texture a bit to be able to slice it in place using this index.

        2024-02-19
      • say we arrange our bits like this:

        export const E = 0b0001; export const S = 0b0010; export const W = 0b0100; export const N = 0b1000;
        2024-02-19
      • that means we’ll need to arrange our tiles like so, where the leftmost tile is at index 0 (0b0000) and the rightmost tile is at index 15 (0b1111):

        E S E S W W E W S W E S N E N S N E S N W N E W N S W N E S W N

        2024-02-19
      • packing that into a single tileset, or rather this time, a tile strip, we get this image:

        horizontal tile strip of 16 8x8 pixel metal tiles

        2024-02-19
    • now it’s time to actually implement it as code! I’ll start by defining a tile index function as a general way of looking up tiles in a tileset.

      2024-02-19
      • I want to make the tile renderer a bit more general, so being able to attach a different tile lookup function to each tileset sounds like a great feature.

        2024-02-19
        • just imagine some game where glass connects to metal, but metal doesn’t connect to glass - I bet that would look pretty great!

          2024-02-19
    • …but anyways, here’s the basic bitwise magic function:

      export function tileIndexInBitwiseTileset(tilemap, x, y) { let tile = tilemap.at(x, y); let tileIndex = 0; tileIndex |= shouldConnect(tile, tilemap.at(x + 1, y)) ? E : 0; tileIndex |= shouldConnect(tile, tilemap.at(x, y + 1)) ? S : 0; tileIndex |= shouldConnect(tile, tilemap.at(x - 1, y)) ? W : 0; tileIndex |= shouldConnect(tile, tilemap.at(x, y - 1)) ? N : 0; return tileIndex; }
      2024-02-19
    • we’ll define our tilesets by their texture, tile size, and a tile indexing function. so let’s create an object that will hold our tileset data:

      // You'll probably want to host the assets on your own website rather than // hotlinking to others. It helps longevity! let tilesetImage = new Image(); tilesetImage.src = "https://riki.house/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+width640.png?v=b3-15038f09"; export const heavyMetalTileset = { image: tilesetImage, tileSize: 8, tileIndex: tileIndexInBitwiseTileset, };
      2024-02-19
    • with all that, we should now be able to write a tile renderer which can handle textures! so let’s try it:

      import { TileEditor } from "tairu/editor.js"; export class TilesetTileEditor extends TileEditor { constructor({ tilesets, ...options }) { super(options); this.tilesets = tilesets; // The image may not be loaded once the editor is first drawn, so we need to request a // redraw for each image that gets loaded in. for (let tileset of this.tilesets) { tileset.image.addEventListener("load", () => this.draw()); } } drawTilemap() { // We're dealing with pixel tiles so we want our images to be pixelated, // not interpolated. this.ctx.imageSmoothingEnabled = false; for (let y = 0; y < this.tilemap.height; ++y) { for (let x = 0; x < this.tilemap.width; ++x) { let tile = this.tilemap.at(x, y); if (tile == 0) { continue; } // Subtract one from the tile because tile 0 is always empty. // Having to specify a null entry at array index 0 would be pretty annoying. let tileset = this.tilesets[tile - 1]; if (tileset != null) { let { tileSize } = this; let tileIndex = tileset.tileIndex(this.tilemap, x, y); this.ctx.drawImage( tileset.image, tileIndex * tileset.tileSize, 0, tileset.tileSize, tileset.tileSize, x * tileSize, y * tileSize, tileSize, tileSize, ); } } } } }
      2024-02-19
    • drum roll please…

      new TilesetTileEditor({ tilemap: tilemapSquare, tileSize: 40, tilesets: [heavyMetalTileset], });
      2024-02-19
    • it works! buuuut if you play around with it you’ll quickly start noticing some problems:

      import { Tilemap } from "tairu/tilemap.js"; export const tilemapEdgeCase = Tilemap.parse(" x", [ " ", " xxx ", " x x ", " xxx ", " ", ]); new TilesetTileEditor({ tilemap: tilemapEdgeCase, tileSize: 40, tilesets: [heavyMetalTileset], });
      2024-02-19
    • where did our nice seamless connections go!?

      2024-02-19
  • thing is, it was never good in the first place

    2024-02-19
    • I’ll be blunt: we don’t have enough tiles to represent corners! like in this case:

      import { Tilemap } from "tairu/tilemap.js"; new TilesetTileEditor({ tilemap: Tilemap.parse(" x", [ " ", " xx ", " x ", " ", ]), tileSize: 40, tilesets: [heavyMetalTileset], });
      2024-02-19
    • have a closer look at the top-left tile:

      the above example, showing an L shape rotated 180°, with the top left corner highlighted

      2024-02-19
      • it should kind of ”bend” to fit in with the tiles to the north and the south, but it doesn’t kamien

        2024-02-19
    • so what if we made the tile look like this instead:

      mockup showing that previous L-shape but with a real corner

      2024-02-19
      • that sure as heck looks a lot nicer! but there’s a problem: that tile, let’s zoom in on it…

        that bent tile, and just it alone

        2024-02-19
        • enhance!

          • E
          • S
          2024-02-19
        • huh. interesting. it connects to the east and the south. so what about this tile -

          • E
          • S
          2024-02-19
          • because it also connects to the east and the south thinking

            2024-02-19
          • seems like we’ll need something to disambiguate the two cases - and what better thing to disambiguate with than more bits!

            2024-02-19
    • to represent the corners, we’ll turn our four cardinal directions…

      • E
      • S
      • W
      • N

      into eight ordinal directions:

      • E
      • SE
      • S
      • SW
      • W
      • NW
      • N
      • NE
      2024-02-15
    • at this point with the four extra corners we’ll need 8 bits to represent our tiles, and that would make…

      256 tiles!?

      nobody in their right mind would actually draw 256 separate tiles, right? RIGHT???

      2024-02-19
      • …right! let’s stick with the 16 tile version for a moment. if we arrange the tiles in a diagnonal cross like this, notice how the tile in the center would have the bits SE | SW | NW | NE set, which upon first glance would suggest us needing a different tile - but it looks correct!

        import { Tilemap } from "tairu/tilemap.js"; new TilesetTileEditor({ tilemap: Tilemap.parse(" x", [ " ", " x x ", " x ", " x x ", " ", ]), tileSize: 40, tilesets: [heavyMetalTileset], });
        2024-02-19
      • therefore there must be some bit combinations that are redundant to others. let’s find them!

        2024-02-19
      • let’s pick one corner first, then generalize to all the other ones. I pick southeast!

        • E
        • SE
        • S
        2024-02-19
        • in this case, if we remove the tile to the southeast, we get that bent tile from before:

          • E
          • S
          2024-02-19
        • what we can learn from this is that for E | S, ES affects the result!

          2024-02-19
        • but if we add any other corner, nothing changes. heck, let’s add all of them:

          • E
          • SE
          • S
          • SW
          • NW
          • NE
          2024-02-19
          • this combination is definitely redundant!

            2024-02-19
        • so it seems like for any two cardinal directions such as E and S, the ordinal direction that’s a combination of the two - in this case ES - only matters if both the cardinal direction bits are set!

          2024-02-19
      • we can verify this logic with a bit of code; with a bit of luck, we should be able to narrow down our tileset into something a lot more manageable.

        2024-02-15
        • we’ll start off by redefining our bits to be ordinal directions instead. I still want to keep the nice orderliness that comes with arranging the bits clockwise starting from east, so if we want that we can’t just extend the indices with an extra four bits at the top.

          export const E = 0b0000_0001; export const SE = 0b0000_0010; export const S = 0b0000_0100; export const SW = 0b0000_1000; export const W = 0b0001_0000; export const NW = 0b0010_0000; export const N = 0b0100_0000; export const NE = 0b1000_0000;
          2024-02-16
        • I don’t know about you, but I find the usual C-style way of checking whether a bit is set extremely hard to read, so let’s take care of that:

          export function isSet(integer, bit) { return (integer & bit) == bit; }
          2024-02-16
        • now we can write a function that will remove the aforementioned redundancies. the logic is quite simple - for southeast, we only allow it to be set if both south and east are also set, and so on and so forth.

          // t is an existing tile index; variable name is short for brevity export function removeRedundancies(t) { if (isSet(t, SE) && (!isSet(t, S) || !isSet(t, E))) { t &= ~SE; } if (isSet(t, SW) && (!isSet(t, S) || !isSet(t, W))) { t &= ~SW; } if (isSet(t, NW) && (!isSet(t, N) || !isSet(t, W))) { t &= ~NW; } if (isSet(t, NE) && (!isSet(t, N) || !isSet(t, E))) { t &= ~NE; } return t; }
          2024-02-16
        • with that, we can find a set of all unique non-redundant combinations:

          export function ordinalDirections() { let unique = new Set(); for (let i = 0; i <= 0b1111_1111; ++i) { unique.add(removeRedundancies(i)); } return Array.from(unique).sort((a, b) => a - b); }
          2024-02-16
          • by the way, I find it quite funny how JavaScript’s Array.prototype.sort defaults to ASCII ordering for all types. even numbers! ain’t that silly?

            2024-02-16
        • and with all the ingredients in the pot, we now Let It Cook™:

          let dirs = ordinalDirections(); console.log(dirs.length);
          47
          
          2024-02-16
          • forty seven! that’s how many unique tiles we actually need.

            2024-02-16
          • you may find pixel art tutorials saying you need forty eight and not forty seven, but that is not quite correct - the forty eighth tile is actually just the empty tile! saying it’s part of the tileset is quite misleading IMO.

            2024-02-16
          • phew… the nesting’s getting quite unwieldy, let’s wrap up this tangent and return back to doing some bitwise autotiling!

            2024-02-16
    • so in reality we actually only need 47 tiles and not 256 - that’s a whole lot less, that’s 81.640625% less tiles we have to draw!

      2024-02-16
      • and it’s even possible to autogenerate most of them given just a few smaller 4x4 pieces - but for now, let’s not go down that path.
        maybe another time.

        2024-02-16
    • so we only need to draw 47 tiles, but to actually display them in a game we still need to pack them into an image.

      2024-02-17
      • we could use a similar approach to the 16 tile version, but that would leave us with lots of wasted space!

        2024-02-17
        • think that with this redundancy elimination approach most of the tiles will never even be looked up by the renderer, because the bit combinations will be collapsed into a more canonical form before the lookup.

          2024-02-17
      • so instead of wasting space, we can compress the tiles into a compact strip, and use a lookup table from sparse tile indices to dense tile positions within the strip.

        2024-02-19
    • I don’t want to write the lookup table by hand, so let’s generate it!

      2024-02-17
      • we’ll start by obtaining our ordinal directions array again:

        export let xToConnectionBitSet = ordinalDirections();
        2024-02-17
      • then we’ll turn that array upside down… in other words, invert the index-value relationship, so that we can look up which X position in the tile strip to use for a specific connection combination.

        remember that our array has only 256 values, so it should be pretty cheap to represent using a [Uint8Array]:

        export let connectionBitSetToX = new Uint8Array(256); for (let i = 0; i < xToConnectionBitSet.length; ++i) { connectionBitSetToX[xToConnectionBitSet[i]] = i; }
        2024-02-17
      • and there we go! we now have a mapping from our bitset to positions within the tile strip. try to play around with the code example to see which bitsets correspond to which position!

        console.log(connectionBitSetToX[E | SE | S]);
        4
        
        2024-02-17
      • for my own (and your) convenience, here’s a complete list of all the possible combinations in order.

        2024-02-17
        • function toString(bitset) { if (bitset == 0) return "0"; let directions = []; if (isSet(bitset, E)) directions.push("E"); if (isSet(bitset, SE)) directions.push("SE"); if (isSet(bitset, S)) directions.push("S"); if (isSet(bitset, SW)) directions.push("SW"); if (isSet(bitset, W)) directions.push("W"); if (isSet(bitset, NW)) directions.push("NW"); if (isSet(bitset, N)) directions.push("N"); if (isSet(bitset, NE)) directions.push("NE"); return directions.join(" | "); } for (let x in xToConnectionBitSet) { console.log(`${x} => ${toString(xToConnectionBitSet[x])}`); }
          0 => 0
          1 => E
          2 => S
          3 => E | S
          4 => E | SE | S
          5 => W
          6 => E | W
          7 => S | W
          8 => E | S | W
          9 => E | SE | S | W
          10 => S | SW | W
          11 => E | S | SW | W
          12 => E | SE | S | SW | W
          13 => N
          14 => E | N
          15 => S | N
          16 => E | S | N
          17 => E | SE | S | N
          18 => W | N
          19 => E | W | N
          20 => S | W | N
          21 => E | S | W | N
          22 => E | SE | S | W | N
          23 => S | SW | W | N
          24 => E | S | SW | W | N
          25 => E | SE | S | SW | W | N
          26 => W | NW | N
          27 => E | W | NW | N
          28 => S | W | NW | N
          29 => E | S | W | NW | N
          30 => E | SE | S | W | NW | N
          31 => S | SW | W | NW | N
          32 => E | S | SW | W | NW | N
          33 => E | SE | S | SW | W | NW | N
          34 => E | N | NE
          35 => E | S | N | NE
          36 => E | SE | S | N | NE
          37 => E | W | N | NE
          38 => E | S | W | N | NE
          39 => E | SE | S | W | N | NE
          40 => E | S | SW | W | N | NE
          41 => E | SE | S | SW | W | N | NE
          42 => E | W | NW | N | NE
          43 => E | S | W | NW | N | NE
          44 => E | SE | S | W | NW | N | NE
          45 => E | S | SW | W | NW | N | NE
          46 => E | SE | S | SW | W | NW | N | NE
          
          2024-02-17
    • with the lookup table generated, we are now able to prepare a tile strip like before - except now it’s even more tedious work arranging the pieces together ralsei_dead

      anyways I spent like 20 minutes doing that by hand, and now we have a neat tile strip just like before, except way longer:

      horizontal tile strip of 47 8x8 pixel metal tiles

      2024-02-17
    • now let’s hook it up to our tileset renderer!

      2024-02-17
    • since we already prepared the bulk of the framework before, it should be as simple as writing a new tileIndex function:

      export function tileIndexInBitwiseTileset47(tilemap, x, y) { let tile = tilemap.at(x, y); let tileBitset = 0; tileBitset |= shouldConnect(tile, tilemap.at(x + 1, y)) ? E : 0; tileBitset |= shouldConnect(tile, tilemap.at(x + 1, y + 1)) ? SE : 0; tileBitset |= shouldConnect(tile, tilemap.at(x, y + 1)) ? S : 0; tileBitset |= shouldConnect(tile, tilemap.at(x - 1, y + 1)) ? SW : 0; tileBitset |= shouldConnect(tile, tilemap.at(x - 1, y)) ? W : 0; tileBitset |= shouldConnect(tile, tilemap.at(x - 1, y - 1)) ? NW : 0; tileBitset |= shouldConnect(tile, tilemap.at(x, y - 1)) ? N : 0; tileBitset |= shouldConnect(tile, tilemap.at(x + 1, y - 1)) ? NE : 0; return connectionBitSetToX[removeRedundancies(tileBitset)]; }
      2024-02-19
    • now we can write a new tileset descriptor that uses this indexing function and the larger tile strip:

      // Once again, use your own link here! let tilesetImage = new Image(); tilesetImage.src = "https://riki.house/static/pic/01HPW47SHMSVAH7C0JR9HWXWCM-tilemap-heavy-metal-bitwise-48+pixel+width752.png?v=b3-93fbb34d"; export const heavyMetalTileset47 = { image: tilesetImage, tileSize: 8, tileIndex: tileIndexInBitwiseTileset47, };
      2024-02-19
    • and Drum Roll 2: Return of the Snare please…

      import { Tilemap } from "tairu/tilemap.js"; new TilesetTileEditor({ tilemap: Tilemap.parse(" x", [ " x ", " x x ", " xxx ", " xx ", " x ", ]), tileSize: 40, tilesets: [heavyMetalTileset47], });
      2024-02-19
    • it works perfectly!

      2024-02-19
    • but honestly, this is a bit boring if we’re gonna build a game with procedural worlds.

      2024-02-19
      • heck, it’s even boring for a level designer to have to lay out all the tiles manually - introducing variations and what not, such that the world doesn’t look too bland… there has to be a better way!

        2024-02-19
        • and a better way… there is! but I’ll get to that once my nap is over.

          2024-02-19
  • for now, have a big editor to play around with. it’s a lot of fun arranging the tiles in various shapes!

    import { Tilemap } from "tairu/tilemap.js"; new TilesetTileEditor({ tilemap: new Tilemap(25, 16), tileSize: 40, tilesets: [heavyMetalTileset47], });

    nap <!–

    2024-02-19