Using an equilateral triangle-shaped tilemap to draw 2D dual hexagon grids

I'll explain in this post why I think this is revolutionary πŸ˜„

Introduction

Ok, so there is a lot of information on the internet about how to create a 2D hexagon grid system. For example: https://www.redblobgames.com/grids/hexagons/.

However, there is a significant absence of information about how to efficiently map textures to these hexagon grids. By efficient, I mean two things:

  1. Efficient for the artist to create the textures
  2. Efficient for the computer to draw the textures

Efficiency for artists

Figure 1: each possible hex combination excl. flipped/rotated variants Each hexagon can have 6 neighbours. In traditional hex grid layouts, you need to draw 43 tiles (or 10 when you want to mirror/rotate them) to ensure each possible combination is supported. If you add extra kinds of terrain, this can get tedious very quickly.

There is another problem with this system. Essentially, you need to choose: nice rounded convex corners, or nice rounded concave corners. You can't have both.

Enter the dual grid variant of the 2D hexagon tilemap. There are a handful of places where people are talking about it. For example:

However, nobody seems to talk about how to map textures on these triangular grids.

Figure 2: each possible triangle combination excl. flipped/rotated variants Oskar came up with the idea as far as I know. He talked about it here. Using this method, you don't have to choose between nice rounded convex and concave corners AND you can get away with drawing 16 tiles (4 if you use flipping/rotating).

In the figures below you can see some examples of tilemaps and some terrains with those tilemaps applied. Figure 3: example_map

The following terrain-map is used in the examples shown in this post:

Water and beach Grass and dirt Triangles exposed

You can download some templates here:  hex_tilemap_template_placeholder

Efficiency for computers (technical outlines)

Figure 4: wasted CPU cycles Now it gets a bit technical. To make rendering efficient, I had to create triangle-shaped textures in memory. Otherwise, we would get triangle-shaped tiles in a square texture. Because these triangles are equilateral, we can say that the computer spends about half of the time processing pixels that get discarded before they are drawn to the screen. This is a huge waste when done thousands of times per frame.

Loading in the tilemap

Figure 4: loading_tilemap When the program loads, all tilemaps are loaded into memory. In case the tilemap contains triangle-shaped tiles, a few modifications are made in-memory.

The hex tiles on screen are based on a uniform grid with a fixed width and height per tile. However, each tile consists of two triangles, and these triangles are slightly smaller than the full tile area. Specifically, each triangle has a height of tile_height \* 0.875 and a width of tile_width \* 0.75. These dimensions ensure that the two triangles fit neatly within the visual bounds of a hex tile without overlapping their neighbors, while still preserving the appearance of equilateral triangles once warped back during rendering.

This is how the process goes:

  1. First, the tilesheet is loaded into memory using an HTML5 Canvas. Easy.
  2. Next, an array is created where the pixel information in the aforementioned canvas is loaded. However, the image will be warped in memory in a way that the triangles become right angles, instead of equilateral. Only the x-axis is manipulated based on the y-axis. This is the formula used:
    var x_offset = Math.floor(Math.tan(0.5235987756) * (y -     (Math.floor(y / tile_height)) * tile_height)) * (((Math.floor(y / tile_height) % 2 == 0) * 2) - 1) + (((tile_width / 2) - 1) * (Math.floor(y / tile_height) % 2 != 0));
  3. Next, a 4D array is created per tile in the tileset: tile ID, variant ID, y-axis, x-axis. The variant is not used as of now. Later you can use this to create variations or animation frames for instance.
  4. Each tile is loaded in, and each 'line' of pixels has a different length depending on the y-axis and tile_id:
    var x_offset = (Math.floor(Math.tan(0.5235987756) * (y - (Math.floor(y / tile_height)) * tile_height)) * (((Math.floor(y / tile_height) % 2 == 0) * 2) - 1) + (Math.floor((tile_width / 2)) * (Math.floor(y / tile_height) % 2 != 0))) + ((1 * (tile_y) % 2 == 0) - 0.5);
    var x_length = Math.abs((tile_width * ((tile_x) % 2 != 0)) - x_offset * 2);
  5. The tiles are assigned to an ID. This tile-ID is used by determining the tile based on the neighbourhood when rendering the level map. The order has a certain logic to it, as explained in the next paragraph.
    var tile_order = [8, 2, 12, 1, 15, 5, 11, 6, 0, 10, 4, 9, 7, 13, 3, 14];
    for(var i = 0; i < 16; i++)
    {
     tiles[tile_order[i]] = tmp_tiles[i];
    }

Drawing tiles on screen

When it is time to draw the triangles on screen, part of the process above is reversed to warp the right angles back to equilateral triangles:

  1. The hex-tiles are based on a 2D grid (see Figure 3). It is, however, rendered somewhat diagonal on the screen, which is why the three examples in the last paragraph are askew. I won't go into why and how in this post, as Redblob Games did a terrific job explaining how to do this. The only thing I will say is that the neighbours checked by my code are north-east, east, and south (north is ignored as I will explain shortly).

  2. Figure 5. checking neighbours Next, the tile-ID is determined based on the neighbouring tiles. As mentioned before, the hex-tiles north-east, east, and south from the current hex-tile are queried. Also, the hex-tile itself is queried. Right now there are two states: green (1) and red (0).

  3. Then two triangles are picked to draw on screen. One triangle is upright and the other faces down.

    • The upright triangle is calculated like this:
      var tile_id_up = (1 * current_tile) + (2 * east_tile) + (4 * north_east_tile);
    • The one facing down is calculated like this; note that 8 is always added to the total:
      var tile_id_down = (1 * current_tile) + (2 * east_tile) + (4 * south_tile) + 8;
    • Note that the above calculations can be done using bitwise operators too. For example:
      var tile_id = current_tile << 0 | east_tile << 1 | ... etc.
  4. Figure 6. drawing triangles Then the two triangles are drawn on screen to the right of the hex-tile. The triangle upright is shown in blue and the downfacing triangle is shown in pink. Remember, the tiles are right-angled triangles in memory, not equilateral. This means the pixel data must be positioned carefully so the final result appears visually correct once warped back into equilateral triangles on screen.

    To do this, each row of pixels in the triangle texture may be a different width. In order to center these rows horizontally during drawing, we calculate an offset per row. This ensures that each line of pixels aligns properly with the triangle shape it's meant to form. The formula used is:

    var x_offset = Math.floor((tile_width - tiles[i][0][y].length) / 2);

    Figure 7. warping back Essentially, the max tile-width is subtracted by the width of the current row of pixels divided by two to calculate the offset.

This process is repeated for every hex-tile on screen.

Final thoughts

The dual-grid triangle system solves a lot of headaches for both artists and developers. It reduces the number of tiles needed, preserves both convex and concave corner aesthetics, and optimizes performance by minimizing overdraw. While it may seem a bit more complex at first glance, the payoff in flexibility and efficiency is huge, especially for procedural map generation or handcrafted tilemaps at scale.

In a future post, I may dive into some concrete performance measurements to compare traditional hex tiles versus this dual-grid approach. I’m also planning to share a small interactive demo, so you can see the technique in action and test how it performs in your own projects.

If you have feedback, please fill in the form below :-)