%% title = "tairu - an interactive exploration of 2D autotiling techniques" scripts = [ "components/literate-programming.js", "tairu/cardinal-directions.js", "tairu/framework.js", "tairu/tairu.js", "tairu/tilemap-registry.js", "tairu/tilemap.js", "vendor/codejar.js", ] styles = ["tairu.css"] % id = "01HPD4XQPWM8ECT2QM6AT9YRWB" - I remember since my early days doing programming, I've been interested in how games like Terraria handle automatically tiling their terrain. % id = "01HPD4XQPWPDBH6QQAZER7A05G" - in Terraria, you can fully modify the terrain however you want, and the tiles will connect to each other seamlessly. % id = "01HPD4XQPW8HE7681P7H686X4N" - TODO: short videos demoing this here % id = "01HPD4XQPWJBTJ4DWAQE3J87C9" - once upon a time I stumbled upon a technique called...\ **bitwise autotiling** % id = "01HPD4XQPW6VK3FDW5QRCE6HSS" + I learned about it way back when I was just a kid building 2D Minecraft clones using [Construct 2](https://www.construct.net/en/construct-2/manuals/construct-2), and I wanted my terrain to look nice as it does in Terraria % id = "01HPD4XQPWJ1CE9ZVRW98X7HE6" - Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile: % id = "01HPJ8GHDET8ZGNN0AH3FWA8HX" - let's begin with a tilemap. say we have the following grid of tiles: (the examples are interactive, try editing it!) Your browser does not support <canvas>. % id = "01HPJ8GHDEC0Z334M04MTNADV9" - for each tile, we can assign a bitset of cardinal directions like so: Your browser does not support <canvas>. % template = true id = "01HPJ8GHDE9QKQ4QFZK1Z1KQD4" classes.branch = "tileset-cardinal-directions-demo" + now given a tileset, such as the one below that I drew a while ago, we can assign each tile to a set of cardinal directions. I'll indicate where there's a connection between individual tiles with the letters **N**, **E**, **S**, **W**, standing for the cardinal directions **N**orth, **E**ast, **S**outh, and **W**est. % id = "01HPMVT9BM65YD5AXWPT4Z67H5" - (it's frustratingly hard to center individual letters like this in CSS. please forgive me for how crooked these are!) % id = "01HPMVT9BM5V4BP8K80X0C1HJZ" - note that the state of connection for a given cardinal direction can be represented using two values: **connected**, and **not connected**. two values make one bit, so we can pack these four connection states into four bits, and use that as an array index! % classes.branch = "tileset-cardinal-directions-demo" id = "01HPMVT9BM4AXG2Z1D2QBH828G" + for that to work though, we need to rearrange our tilemap somewhat such that we can index into it easily using our integer. assuming we pack our bits as `NWSE` (bit 0 is east, each next bit we go clockwise), therefore the final arrangement is this:
E S ES W EW SW ESW N EN SN ESN WN EWN SWN ESWN
packing that into a single tilesheet, or rather tile *strip*, we get this image: ![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5] % id = "01HPQCCV4RB65D5Q4RANJKGC0D" - **hint:** you can actually just use the original image, but use a lookup table from these indices to (x, y) coordinates. this makes creating the assets a lot easier! (at the expense of some CPU time, though it is totally possible to offload tilemap rendering to the GPU - in that case it barely even matters.) % id = "01HPMVT9BMMEM4HT4ANZ40992P" - in JavaScript, drawing on a `` using bitwise autotiling would look like this: ```javascript for (let y = 0; y < tilemap.height; ++y) { for (let x = 0; x < tilemap.width; ++x) { // Assume `tilemap.at` is a function which returns the type of tile // stored at coordinates (x, y). let tile = tilemap.at(x, y); // We need to treat *some* tile as an empty (fully transparent) tile. // In our case that'll be 0. if (tile != 0) { let tileset = tilesets[tile]; // Now it's time to represent the tile connections as bits. // For each cardinal direction we produce a different bit value, or 0 if there is // no connection: let connectedWithEast = shouldConnect(tile, tilemap.at(x + 1, y)) ? 0b0001 : 0; let connectedWithSouth = shouldConnect(tile, tilemap.at(x, y + 1)) ? 0b0010 : 0; let connectedWithWest = shouldConnect(tile, tilemap.at(x - 1, y)) ? 0b0100 : 0; let connectedWithNorth = shouldConnect(tile, tilemap.at(x, y - 1)) ? 0b1000 : 0; // Then we OR them together into one integer. let tileIndex = connectedWithNorth | connectedWithWest | connectedWithSouth | connectedWithEast; // With that, we can draw the correct tile. // Our strip is a single horizontal line, so we can assume let tilesetTileSize = tileset.height; let tilesetX = tileIndex * tilesetTileSize; let tilesetY = 0; ctx.drawImage( tilesets[tile], tilesetX, tilesetY, tilesetTileSize, tilesetTileSize, x * tileSize, y * tileSize, tileSize, tileSize, ); } } } ``` % template = true id = "01HPMVT9BM9CS9375MX4H9WKW8" - and that gives us this result: Your browser does not support <canvas>. % id = "01HPMVT9BM3WR0BNZFHP2BPZ8A" - but if you play around with it (or have *already* played around with it, and are therefore left with a non-default tilemap) ...something seems awful about it doesn't it? % template = true id = "01HPMVT9BMPA89037VPWPPWX8V" - something's off about the corners. let me give you a fresh example to illustrate what I mean: Your browser does not support <canvas>. % id = "01HPMVT9BM16EF3TV5J1K19JAM" + see that tile in the bottom left corner of the `L` shape? it's missing a corner. the top-right corner, to be exact, which makes it visually disjoint from the tiles to the north and the east. % id = "01HPMVT9BM5VWJSMDNPK2SRNZV" - (I'm totally not trying to say this implementation is an L so far) % id = "01HPMVT9BMWG6QHQ125Z884W8Z" + i'll cut right to the chase here and say it outright - the issue is that we simply don't have enough tiles to represent *corner* cases like this! % id = "01HPMVT9BMQK8N1H68YV3J4CFQ" - see what I did there? % id = "01HPMVT9BMJTG3KD3K5EJ3BC93" - the solution to that is to introduce more tiles to handle these edge cases. % classes.branch = "tileset-four-to-eight-demo" id = "01HPQCCV4R5N97FJ1GS36HZJZ7" - to represent the corners, we'll turn our four cardinal directions... into eight *ordinal* directions: % id = "01HPQCCV4R3GNEWZQFWGWH4Z6R" - you might think that at this point 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???*** % template = true id = "01HPQCCV4RX13VR4DJAP2F9PFA" - ...right! if you experiment with the bit combinations, you'll quickly find out that there is no difference if, relative to a single center tile, we have tiles on the corners: Your browser does not support <canvas>. these should all render the same way, despite technically having some [new neighbors](https://en.wikipedia.org/wiki/Moore_neighborhood). % classes.branch = "tileset-four-to-eight-demo" id = "01HPQCCV4RHZ8A7VMT2KM7T27P" - what we can do about this is to ignore corners whenever zero or one of the tiles at their cardinal directions is connected - for example, in the case of `E | SE | S`: we can completely ignore what happens in the northeast, northwest, and southwest, because the tile's cardinal directions do not fully contain any of these direction pairs. % id = "01HPQCCV4R557T2SN7ES7Z4EJ7" - 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. + we'll start off by defining a bunch of variables to represent our ordinal directions: ```javascript ordinal-directions const E = 0b00000001; const SE = 0b00000010; const S = 0b00000100; const SW = 0b00001000; const W = 0b00010000; const NW = 0b00100000; const N = 0b01000000; const NE = 0b10000000; const ALL = E | SE | S | SW | W | NW | N | NE; ``` as I've already said, we represent each direction using a single bit. - I'm using JavaScript by the way, because it's the native programming language of your web browser. read on to the end of this tangent to see why. - now 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: ```javascript ordinal-directions function isSet(integer, bit) { return (integer & bit) == bit; } ``` - 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. ```javascript ordinal-directions // t is a tile index; variable name is short for brevity 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; } ``` - with that, we can find a set of all unique non-redundant combinations: ```javascript ordinal-directions function ordinalDirections() { let unique = new Set(); for (let i = 0; i <= ALL; ++i) { unique.add(removeRedundancies(i)); } return Array.from(unique).sort((a, b) => a - b); } ``` - 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? [`Array.prototype.sort`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort - and now it's time to _Let It Cook™_: ```javascript ordinal-directions let dirs = ordinalDirections(); console.log(dirs.length); ``` ```output ordinal-directions 47 ``` - forty seven! that's how many unique tiles we actually need. - 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. - phew... the nesting's getting quite unwieldy, let's wrap up this tangent and return back to doing some bitwise autotiling! - 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! - 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. % id = "01HPD4XQPWT9N8X9BD9GKWD78F" - bitwise autotiling is a really cool technique that I've used in plenty of games in the past. % id = "01HPD4XQPW5FQY8M04S6JEBDHQ" - as I mentioned before, [I've known it since my Construct 2 days][branch:01HPD4XQPW6VK3FDW5QRCE6HSS], but when it comes to any released games [Planet Overgamma] would probably be the first to utilize it properly. TODO video of some Planet Overgamma gameplay showing the autotiling in action [Planet Overgamma]: https://liquidev.itch.io/planet-overgamma-classic % id = "01HPJ8GHDEN4XRPT1AJ1BTNTFJ" - this accursed game has been haunting me for years since; there have been many iterations. the autotiling source code of the one in the video can be found [here][autotiling source code]. [autotiling source code]: https://github.com/liquidev/planet-overgamma/blob/classic/jam/map.lua#L209 % id = "01HPD4XQPWPN6HNA6M6EH507C6" + but one day I found a really cool project called [Tilekit](https://rxi.itch.io/tilekit) % id = "01HPD4XQPW11EQTBDQSGXW3S52" + (of course it's really cool, after all [rxi](https://github.com/rxi) made it) % id = "01HPD4XQPWYHS327BV586SB085" - for context rxi is the genius behind the Lua-powered, simple, and modular text editor [lite](https://github.com/rxi/lite) that I was using for quite a while % id = "01HPD4XQPWJ9QAQ5MF2J5JBB8M" - after a while I switched to a fork - [Lite XL](https://github.com/lite-xl/lite-xl), which had better font rendering and more features % id = "01HPD4XQPWB11TZSX5VAAJ6TCD" - I stopped using it because VS Code was just more feature packed and usable; no need to reinvent the wheel, rust-analyzer *just works.* % id = "01HPD4XQPW3G7BXTBBTD05MB8V" - the LSP plugin for Lite XL had some issues around autocompletions not filling in properly :pensive: it's likely a lot better now, but back then I decided this is too much for my nerves. while tinkering with your editor is something really cool, in my experience it's only cool up to a point. % id = "01HPD4XQPWV1BAPA27SNDFR93B" - the cool thing with Tilekit is that it's *more* than just your average bitwise autotiling - of course it *can* do basic autotiling, but it can also do so much more % id = "01HPD4XQPWM1JSAPXVT6NBHKYY" classes.branch_children = "branch-quote" - if I had to describe it, it's basically something of a *shader langauge for tilesets.* this makes it really powerful, as you can do little programs like % id = "01HPD4XQPWE7ZVR0SS67DHTGHQ" - autotile using this base tileset % id = "01HPD4XQPW2BFZYQQ920SYHM9M" - if the tile above is empty AND with a 50% chance % id = "01HPD4XQPWJB7V67TS1M3HFCYE" - then grass % id = "01HPD4XQPWF7K85Z0CEK4WDDBZ" - if the tile above is solid AND with a 10% chance % id = "01HPD4XQPW5J3N6MVT9Z2W00S9" - then vines % id = "01HPD4XQPWGCMCEAR5Z9EETSGP" - if the tile above is vines AND with a 50% chance % id = "01HPD4XQPWP847T0EAM0FJ88T4" - then vines - well... it's even simpler than that in terms of graphical presentation, but we'll get to that. % id = "01HPD4XQPWK58Z63X6962STADR" - I mean, after all - bitwise autotiling is basically a clever solution to an `if` complexity problem, so why not extend that with more logic and rules and stuff to let you build more complex maps? % id = "01HPJ8GHDFRA2SPNHKJYD0SYPP" - of course Tilekit's solution is a lot more simple, streamlined, and user-friendly, but you get the gist.