Procedural 2D Terrain - Part 3: Texturing

Continuing from part 2 of the blog series, it was time to start texturing the terrain.

The Technique
At this point the terrain was rendered  by reading an index value stored in an integer texture and using it to choose a colour to be displayed. I modified the shader, based on this article, to look up the corresponding fragment in a tile set, and use the result to colour the current tile. Temporarily I used a texture which looked like this to see which indices were being rendered (warning crude programmer art ahead!):


While this went mostly without trouble I did discover that on some AMD cards there were occasional rounding errors, which led to the wrong tile being rendered. I fixed this by adding a small offset to coordinate values before rounding (which can be seen as the variable 'epsilon' in the shader source).

Smooth Transitions
The reason the above tile set has 120 tiles is because of what I had planned for the next part; now that I knew any tile could be rendered from within a set simply by using its index I wanted to create smooth transitions between the different textures in the set, rather than the harsh square edges of the tiles. I'd come across this article on using the marching squares algorithm to process the edges of transitions, so I implented it within the Chunk class's tile creation function. First the noise values are generated via FastNoise as before - but then the final tile indices are calculated using the marching squares algorithm. Using the above tile set again, I could check the correct tiles were being displayed. As you can see there was a minor problem:


Tile number 60 would not render...

After some amount of head scratching, and a lot of probing with renderdoc (thanks Aster!) I discovered that once again this was a rounding error - a position value which should have been 0 was in fact 1, causing tile 60 to render the last column of fragments from tile 74. A quick fix was to enable wrapping on the tile set texture so that the incorrect coordinate wrapped back around to tile 60 - but of course the proper fix was to correct the rounding error with the epsilon value once again. With a hastily scribbled tile set created in Pyxel Edit (I love this program) testing the terrain now displayed pleasing terrain transitions.




Texturing Biomes
At the end of the last post I wrote about how I included generating biome data. While the output is still far from refined the terrain was, by this point, generating biome IDs which were stored in the second byte of the array value, and therefore accessible to the shader. Using the ID my plan was to use a GL_TEXTURE_2D_ARRAY containing a tile set for each biome, and use the biome ID in the shader to select the correct texture when rendering. The index in the first byte of the lookup value would then select the correct tile from within the tile set.

More programmer art was needed to test this - I wanted a tile set (based on the above template) for each biome, but each tileset also needed to be different enough that it was visibly outstanding when rendered. The biome IDs are based on Whittaker's biome graph so using <searchengine> image search I found some reference images of real-world biomes and, coupled with Paletton, created a set of palettes which represented each biome.

Palettes for Cold Desert, Shrubland, Boreal Forest, Savanna Forest and Seasonal Forest
Eventually I'll use these palettes to make a series of complete tile sets, but for now they are enough to make coloured templates. Implementing the rendering didn't go entirely smoothly, however, as although it is possible to modify the OpenGL properties of SFML textures, it is not possible to use any format other than GL_TEXTURE_2D. This was a shame and, although I could have reimplemented the entire rendering setup in OpenGL, meant that I had to reconsider my options. Eventually I settled on building a texture atlas at run time, combining all of the tile sets in to a single texture. The biome ID was still valid, but in the shader was now used to calculate a position within the atlas at which the tile set started, rather than act as an index into a texture array. To combine the textures I took advantage of the sf::Image class, as not only could I validate that all the textures were the correct size, I could also compensate for load failures by replacing the images with a solid colour. The final atlas texture would still be valid if images on the disk weren't - although would not render correctly of course.

void TerrainComponent::loadTerrainTexture()
{
    m_terrainTexture.create(width * texturesPerSide, height * texturesPerSide);
    for (auto i = 0u; i < biomeCount; ++i)
    {
        sf::Image img;
        if (!img.loadFromFile("assets/images/tiles/" + std::to_string(i) + ".png"))
        {
            img.create(width, height, sf::Color::Magenta);
        }
        if (img.getSize().x != width || img.getSize().y != height)
        {
            img.create(width, height, sf::Color::Magenta);
            xy::Logger::log("Image " + std::to_string(i) + " was not correct size.", xy::Logger::Type::Warning);
        }

        auto xPos = i % texturesPerSide;
        auto yPos = i / texturesPerSide;
        m_terrainTexture.update(img, xPos * width, yPos * height);
    }

    m_terrainShader.setUniform("u_tileTexture", m_terrainTexture);
}


and the fragment shader:

void main()
{
    uint value = texture(u_lookupTexture, v_texCoord).r; 
    float index = float(value & 0xFFu); 
    vec2 tilesetCount = u_tilesetCount * biomeCount;
    vec2 position = vec2(mod(index, u_tilesetCount.x), floor((index / u_tilesetCount.x) + epsilon)) / tilesetCount;

    float biomeID = float((value & 0xf00u) >> 8u); 
    vec2 biomePosition = vec2(mod(biomeID, biomeCount.x), floor(biomeID / biomeCount.x)) / biomeCount; 

    vec2 texelSize = vec2(1.0) / textureSize(u_lookupTexture, 0);
    vec2 offset = mod(v_texCoord, texelSize); vec2 ratio = offset / texelSize; offset = ratio * (1.0 / u_tileSize); 
    offset *= u_tileSize / tilesetCount; 

    colour = texture(u_tileTexture, biomePosition + position + offset); 


Things are (rather colourfully) now coming together.


After I've finished creating more detailed tile sets it will be time to tackle terrain details, such as foliage, as well as further refining the noise generation technique. For now though the full code can be found in the repository.

Part 1
Part 2
Part 4

Popular Posts