Daggerfall Unity is an open source recreation of Daggerfall in the Unity engine, adding quality of life features and mod support.

Report RSS Improved Terrain Tiling

Implements a fragment shader for tiling from atlas, with corrections for texture bleeding and mipmap sample errors.

Posted by on

To date, I have used a brute-force approach when tiling textures in cities and terrains. Each texture tile is mapped to a quad of vertices where each corner describes a UV coordinate allowing its texture to be drawn normally, rotated, flipped, or both rotated and flipped. This saves a great deal of texture space as only one version of texture is required to achieve four variations.

While it saves on texture memory (great for a 1996 game), this system has some inherent problems, not least of which is the number of vertices being used. For example, a full 8x8 block terrain has a total of 128x128 quad tiles with 4 vertices each. That's a total of 65536 vertices just to handle the ground plane of a big city like Daggerfall or Wayrest. This is larger than Unity's maximum mesh size, so everything needs to be split up into chunks. As cities are completely flat, wouldn't it be great if we could just render all these tiles onto a single quad, using only 4 vertices for an entire city?

Happily, this is not only possible, we can solve a few other problems with tiled textures in the process. All through the magic of custom shaders.

The first step is to change how the ground atlas is stored. Rather than just reference a single texture and modify using UVs, we're going to pre-transform each texture in all 4 possible configurations (normal, rotated, flipped, rotated+flipped). This removes the need for UV changes and saves time in the fragment shader, and allows for some extra packing to happen as you'll see shortly. The new tile set atlas looks like below (note that Unity texture [0,0] is bottom-left).

Atlas1

In order to reference these tiles in the fragment shader, we also create a tile map texture. This texture packs an index into the red channel that is basically a number 0-255 corresponding to a tile in the atlas above. This texture must be point-sampled to ensure no corruption of indices occurs. The tile map for Daggerfall city is below.

Tilemap

Now the shader has an atlas of tiles and indices referencing which tile to use. The following bit of code will read index from tile map then render out correct texture tile from the atlas.

int index = tex2D(_TilemapTex, i.texcoord0).x * _MaxIndex;
int xpos = index % _TilesetDim;
int ypos = index / _TilesetDim;
float2 uv = float2(xpos, ypos) / _TilesetDim;
float xoffset = frac(i.texcoord0.x * _TilemapDim) / _TilesetDim;
float yoffset = frac(i.texcoord0.y * _TilemapDim) / _TilesetDim;
uv += float2(xoffset, yoffset);
return tex2D(_MainTex, uv);

The result is a fully tiled city quad using just 4 vertices.

TilemapInAction

This works great, and it's fast. If we were just using point filtering with no mipmaps, our job would be done already. Unfortunately linear filtering adds one set of problems and mipmaps add another. The first problem is texture colours bleeding in from adjacent tiles due to how linear filtering works. Image below demonstrates problem, note the dirt texture being sample at edge of water.

Bleeding

There are a few of ways to fix this, but one of the most robust is to manually wrap textures around to their opposite side in the atlas. This means that when the linear filter samples outside the tile area, texture wrapping will be simulated. The new atlas shows this concept with each tile wrapped 50% on all sides. Note: This solution works for tiling textures, but not for non-tiling textures where texture clamping needs to be simulated instead. The atlas generator is yet to support both wrapped and clamped tiles.

Atlas2

With a new atlas format, our fragment shader needs updating to sample interior part of each texture.

// Get offset to tile in atlas
int index = tex2D(_TilemapTex, i.texcoord0).x * _MaxIndex;
int xpos = index % _TilesetDim;
int ypos = index / _TilesetDim;
float2 uv = float2(xpos, ypos) / _TilesetDim;

// Offset to fragment position inside tile
float xoffset = frac(i.texcoord0.x * _TilemapDim) / _GutterSize;
float yoffset = frac(i.texcoord0.y * _TilemapDim) / _GutterSize;
uv += float2(xoffset, yoffset) + _GutterSize / _AtlasSize;

// Return fragment
return tex2D(_MainTex, uv);

These additions fix linear sampling to all but remove bleeding for tiling textures. Compare this image to the earlier version.

BleedingFixed

With texture bleeding taken care of, the next challenge is dealing with mipmaps. The graphics card has no understanding of our atlas, so tile wrapping makes it sample from the wrong mip level, which creates a whole new problem with cracks as shown below (you might need to open image to full size to see the cracking discussed).

MipMapCracks

Fixing this problem requires manually calculating the mip level in fragment shader. Fortunately there's a lot of references online for dealing with this. Rather than just naively return the texture sample using tex2d, we need to use tex2dlod instead. Below is change to final line of code.

// Manually set mip level and return fragment
float mipLevel = GetMipLevel(i.texcoord0, _AtlasSize);
return tex2Dlod(_MainTex, float4(uv.xy, 0, mipLevel));

And the GetMipLevel() function.

float GetMipLevel(float2 iUV, float2 iTextureSize)
{
float2 dx = ddx(iUV * iTextureSize.x);
float2 dy = ddy(iUV * iTextureSize.y);
float d = max(dot(dx, dx), dot(dy,dy));
return 0.5 * log2(d);
}

With this new code, our mip cracking problem has vanished.

MipMapCracksFixed

And there we have it. A super-fast method of tile mapping from an atlas which defeats both texture bleeding and mip sampling problems, and only needs a single quad mesh.

There's still more work to do, mainly around atlas generation. I need to handle wrapped tiles and clamped tiles differently, and manually pack mip levels to improve overall quality. I will get back to this in the new year with my performance review. On the subject of performance, these concepts can be expanded to reduce draw calls for the terrain system and city environments. This is just the first spoke in a big wheel of optimizations.

This last screenshot is of the material inside Unity editor, which shows how everything hangs together.

ShaderInEditor

References
I could not have pulled this off without the help of several people much smarter than myself. All the links below were used in formulating this solution.

Connor Hollis - Fast Tilemap Shader
connorhollis.com/fast-tilemap-shader/

IceFall Games - Terrain Engine
mtnphil.wordpress.com/2011/09/22/terrain-engine/

nVidia - Improve Batching Using Texture Atlasing
developer.nvidia.com/sites/default/files/akamai/tools/files/Texture_Atlas_Whitepaper.pdf

0 FPS - Texture atlases wrapping and mip-mapping
0fps.net/2013/07/09/texture-atlases-wrapping-and-mip-mapping/

Post a comment
Sign in or join with:

Only registered members can share their thoughts. So come on! Join the community today (totally free - or sign in with your social account on the right) and join in the conversation.

Follow Report Profile
Icon
Elder Scrolls II: Daggerfall
Creator
Interkarma
Contact
Send Message
Homepage
Dfworkshop.net
Release date
Mod watch
Follow
News
Browse
News
Report
Report
Share
Related Games