An algorithm for generating smooth 2D multi-layer tilemaps from only three source textures per layer.

TL;DR - This:
Becomes this:
A larger map using the same technique:

A common practice when creating a game is to create a 2D grid of "locations" or "location types". In a roguelike, it might be the walls, the floor, the doors, etc. Or in an open world game it could be the biomes or different layers of altitude.

A common problem faced by developers is creating sprites that transition smoothly between these tiles, given the large amount of permutations possible.

Often times, developers will create tile atlases, like this:

Each terrain layer (lava, grass, snow, etc) has 13 sprites. And allows you to create a nice looking environment like this:

However, a astute observer may notice a problem here. There is a limitation to only having one border type.

As a developer on Unity's forums put it:

The only problem is that a place that could reasonably have 2 outside corners ends up having one corner or the other. From what I can tell, this is a failing of the LPC pack, not the rulesets. Two solutions to that is A) Avoid double corners. B) create one or two alts for each double corner scenario (corners separate or corners together)

As an indie developer of many years, this problem has always bothered me, and I have wrestled with it many times in various projects.

However, I think I may now have a very nice solution. As with any solution to hard problems, some tradeoffs need to be made. But I think my trades have been worth the effort, and as such I want to share it because I think it may be a unique approach that I have not seen before.

My system uses only 3 base textures, however, the algorithm would work well with multiple variations of these 3 to spice up the output. This is a quality improvement in the final product, while also being a HEAVY reduction in asset cost which is a GREAT trade to make.

By asset cost, I mean the time required to produce a new set of textures for a new layer or variation. With LPC assets, you need to create 13 new assets per variation, while I only need 3. This multiplies your development efficiency by heavily reducing iteration costs (when something doesn't turn out right, you dont need to fix much).

So what do I give up to make this possible? Just a little...automated pre-processing. :)

So enough blabbering, how does it work, and what are the three textures?

In my current game, a single layer's tilemap looks like this:

The three square textures

public enum TileAtom {
 OneCorner, TwoCorner, ThreeCorner
}

These three squares are 256x256, and they would be used to create tiles that are 512x512 pixels. So that's the first key, these base "atom" textures, create the "molecule" result textures that are 2x the width/height.

The way to think about it, is to count the number of black/white corners in each texture. The left most texture has 1 white corner, the middle 2, the left 3. The missing texture, is just a single color, so we leave it out.

So what "molecule" textures do we need to create, and how do we do it?

public enum TileMolecule { Full, Corner, Edge, Tunnel, TwoSides, Penninsula, Island }
  • Full - All 4 atoms grass.
  • Corner - 3 corners grass, top right sand.
  • Edge - bottom edge grass, top edge sand
  • Tunnel - Top and Bottom edge sand, left and right edge Grass
  • TwoSides - top and right edge sand, left and bottom edge grass.
  • Penninsula - Bottom edge grass. Left, top, and right edge Sand.
  • Island - All 4 neighbors sand, while I am a grass tile.

All 7 built:

First, lets ignore the corners, and only worry about the 4 main neighbors. Up, Right, Down, Left.

Next, lets ignore most of those neighbors, and only worry about the top neighbor.

Say we are on grass, and above us is sand. So we need to create a tile that has sand along the top edge, and grass along the bottom edge.

So we

A) Fill the texture with our single color (remember, we left out a 4th texture that is a single color, in this case its grass, so green).

B) We now have a green square that is 512x512. Next, we need the middle 'atom' texture, the one with two white corners along the top, and we place two of them on our final square. One in the upper left, and one in the upper right.

Ta-da! We now have a 512x512 grass -> sand texture

Now consider the 'Corner' molecule. We need 2 edge atoms, one rotated CW 90, and a corner atom.

However, there is a small problem here. Our bottom right texture (sand facing upward) needs to be rotated clockwise so that the sand is facing to the right instead.

With a simple library of matrix rotations and mirroring, we are able to do this pretty easily.

public enum TileTransforms {
 None, ClockWise90, CounterClockWise90, Rotate180, FlipHorizontal, FlipVertical, FlipBoth
}

Finally we flip it vertically

And alas, this is really the trade we're making. We're automating our tilemap generation by generating the tiles we need instead of baking them beforehand.

Using this method, you can see how we can easily do a similar matrix transformation to generate a texture with sand facing downwards utilizing the 2nd atom texture, but flipped vertically.

Another problem we now face is figuring out which neighbors are what, and what textures we need to paint.

So before I do any of the above painting, I do an analysis pass of my grid. For each tile in the grid, I create a TileAnalysis struct that looks like this:

public struct TileAnalysis {
 public int heightIndex;
 public int neighborLowerCount;
 public int cornerLowerCount;
 public bool topLess;
 public bool leftLess;
 public bool rightLess;
 public bool botLess;
 public bool c1Less;
 public bool c2Less;
 public bool c3Less;
 public bool c4Less;
 public bool c1Less2;
 public bool c2Less2;
 public bool c3Less2;
 public bool c4Less2;
}

This analysis tells me which neighbors are less than my current location. I do not care about which neighbors are higher, since those tiles will be responsible for blending with me. So I only need to worry about blending downward.

The analysis also tells me which neighbors are 2 less. This allows the algorithm to handle situations where, lets say a grass meets directly with a water tile, or a forest overrides the beach.

As you can see, the forest tile simply painted the grass corner texture where appropriate, since sand is 2 levels lower than forest (forest -> grass -> sand).

How did this work?

We're a Forest tile, with a grass tile above us and to the right. However, there is also a sand tile up and to the right.

 if(tData.tileAnalysis.c2Less && ((!tData.tileAnalysis.topLess && !tData.tileAnalysis.rightLess) || (tData.tileAnalysis.c2Less2))) {
  if(tData.tileAnalysis.c2Less2 && tData.heightIndex > 0){
   cornerTexture = textureDict[tData.heightIndex - 1][(int)TileBase.Corner][TileTransforms.None];
   t = new Task(ColorCornerTextureRoutine());
  } else {
   cornerTexture = textureDict[tData.heightIndex][(int)TileBase.Corner][TileTransforms.None];
   t = new Task(ColorCornerTextureRoutine());
  }
 }

So, in short, if the corner is less2 (forest -> sand as opposed to less1 forest -> grass), first we paint our forest corner.

Then we take the corner of our heightindex - 1 (the grass corner), and use it in this corner of our final texture with the appropriate rotation (in this case TileTransform.None since the corner points to the up and right by default).

But, now since we used a grass corner with our forest sprites, we would have a color mismatch between this corner atom and the other 3 atoms in the texture. To fix, we simply delete the grass base color in the corner texture, and then apply it over our forest corner.

To summarize, we trade pre-processing and memory storage for a tiny asset production cost and high quality output.

I'll probably improve this in the future to no longer use 'less1' and 'less2' but instead have arbitrary levels. Instead of coloring the base atoms lower levels in with color, they should instead be transparent, which would allow them to be painted over any lower terrain. Since we know the base colors of each level, we could do this in code if we prefer.

Comments

Popular posts from this blog

Stitching tiles to neighbor tiles at different altitudes.