Implementing the world map shader

It’s now time to use all our world data and to finish implementing the shader. Last time we left off is with this fragment() implementation:

void fragment() {
    float height = texture(height_map, UV).r;
    COLOR = texture(color_map, vec2(height, 0.0));
}

We need to manipulate heat_map, moisture_map and height_map and come up with a formula for calculating biome types for a flat color look.

By the end of this lesson, you will have like the one below.

Adding heat map

Without any processing, heat_map is just a simple NoiseTexture. We want something realistic, where most of the heat is around the equator and decreasing towards the poles gradually.

Adjust your fragment() function like so:

const float PI = 3.14159265358979323846;

void fragment() {
    float height = texture(height_map, UV).r;

    float heat = texture(heat_map, UV).r;
    heat *= pow(sin(PI * UV.y), 2.0);

    // NOTE the use of `heat`
    COLOR = texture(color_map, vec2(heat, 0));
}

We are extracting the heat_map red channel, just like with height_map. We then multiply these noise values with pow(sin(PI * UV.y), 2.0). By doing so we gradually decrease the heat values towards the poles, with maximum at the equator. See the following graph, where:

This produces the following result:

I’m using the gradient version to exemplify the transition towards the poles, where we have dark blue for low values. We can clearly see the influences of the noise.

Adding moisture and rivers

For the next part, we’re going to need an UNCERTAINTY constant. Define it at the top of the shader:

const float UNCERTAINTY = 0.01;

It is a small value for error margins. We use it to account for minor errors caused by floating-point arithmetic.

We continue expanding fragment() by introducing moisture and rivers. We need to adjust both moisture_map and height_map to account for rivers_map texture.

void fragment() {
    // ...
    float moisture = texture(moisture_map, UV).r;
    // UV offset to warp our rivers, based on our moisture
    float uv_perturbation = height * moisture;
    float river = texture(rivers_map, UV + uv_perturbation).r;
    // Using a blurred version of the river texture allows us to accumulate moisture
    // around river beds.
    float river_blurred = textureLod(rivers_map, UV + uv_perturbation, 3.0).r;

    // ...
    moisture = max(moisture, step(UNCERTAINTY, river_blurred));
    float rivers_level = get_array_at(color_map_offsets, 1) + UNCERTAINTY;
    height = mix(height, rivers_level, river);

    // NOTE the use of `moisture`
    COLOR = texture(color_map, vec2(moisture, 0.0));

Before getting to the core of it, let’s look at and implement get_array_at(). Define it above fragment():

// Gets value at index from 1D array passed in as data texture from GDScript.
float get_array_at(sampler2D array, int index) {
    return texelFetch(array, ivec2(index, 0), 0).r;
}

We can define functions in shaders just like in GDScript. We create this utility function to get values from color_map_offsets, which corresponds to the color_map.gradient.offsets array we passed in as texture data.

We use get_array_at() to get the value from the texture at a given index with the help of the built-in texelFetch(). This function returns the color of the pixel at a given index. Since we constructed our texture precisely with 1-pixel height, we can ignore the Y axis. So given our sampler2D array data, we get the pixel at ivec2(index, 0), with lod (level of detail) 0. Finally we select the red component of the texture. Putting it all together we get our return value, texelFetch(array, ivec2(index, 0), 0).r.

With that out of the way, apart from getting the moisture_map data, we also define uv_perturbation which we use to perturb, as the name implies, the UV vector when getting the rivers_map data: texture(rivers_map, UV + uv_perturbation).r. This is the driving factor that generates those bendy rivers even though our input texture is made of straight lines.

To visualize it, use the river value instead of moisture in the COLOR assignment:

COLOR = texture(color_map, vec2(river, 0.0));

We encourage you to play with the uv_perturbation formula. Here, we recycle height and moisture. Since UV is in the [0, 1] interval, we need a tiny value to get a good result.

Also, the perturbation as defined above is a positive number which means we’ll always shift the rivers in a certain direction. It would be much better if uv_perturbation would fall within a symmetric interval around zero like [-x, x]. Furthermore, we need to adjust heat and moisture to span the entire [0, 1] interval by normalizing the values like I mentioned in previous lessons. We’ll come back later to fix these issues.

Here is a visualization of the moisture image:

Notice the pure white areas. Those are areas where river_blurred is greater than UNCERTAINTY. The river_blurred value is the blurred version or the rivers_map, generated for us by Godot with the help of mipmaps.

If you recall, we generated them when creating the rivers texture with Image.generate_mipmaps(). In the shader, we use textureLod() instead of texture() to access a mipmap. Here, we pass an lod value of 3.0, determined by trial-and-error. We then calculate the moisture as:

moisture = max(moisture, step(UNCERTAINTY, river_blurred));

By getting the maximum between the pre-processed moisture and river_blurred. We use step() to make sure that moisture around the rivers is set to 1.0 to keep it simple.

We to go over have one last calculation:

float rivers_level = get_array_at(color_map_offsets, 1) + UNCERTAINTY;
height = mix(height, rivers_level, river);

To make the rivers appear on the map, we need to carve them into height_map. This is what the above code does. We use mix() to assign rivers_level to height wherever there is a river. The mix() function interpolates values, in our case from height to rivers_level. Since river only contains values of 0 or 1, mix() either returns height (unchanged) or rivers_level. We take river_level from the gradient offsets. The one that determines the lowest height_map value a river can take before going into the deep water range. Note the use of UNCERTAINTY once again. We use it to shift the value to be above the deep water value range because of floating-point arithmetic errors.

Determining the biome

Now we have all of our ingredients to calculate the biome type. Here is our biome legend table:

At the top of the shader, let’s add some constants:

const float COLDEST = 0.025;
const float COLDER = 0.075;
const float COLD = 0.3;
const float HOT = 0.55;

const float DRYER = 0.15;
const float DRY = 0.35;
const float WET = 0.65;
const float WETTER = 0.85;

const int ICE = 0;
const int TUNDRA = 1;
const int GRASSLAND = 2;
const int WOODLAND = 3;
const int BOREAL_FOREST = 4;
const int DESERT = 5;
const int SEASONAL_FOREST = 6;
const int TEMPERATE_RAINFOREST = 7;
const int SAVANNA = 8;
const int TROPICAL_RAINFOREST = 9;

The constants from COLDEST to HOT, DRYER to WETTER, and ICE to TROPICAL_RAINFOREST are from our biomes table. They are threshold that separate different types of climates.

The values from ICE to TROPICAL_RAINFOREST work like an enum. Each variable has a different number associated with it. They correspond to the color_map_offsets array that determine the position of colors in the Color Map texture. To map values in our shader to biomes, we need to define a new function, get_biome():

// Gets the offset value for a given biome type (`ICE` through `TROPICAL_RAINFOREST`).
// This is used to assign a color to the biome using the discretized GradientTexture.
float get_biome(int index) {
    return get_array_at(color_map_offsets, color_map_offsets_n - index - 1) - UNCERTAINTY;
}

Add the following code at the end of your fragment() function:

// ...
// Default `biome` to `height` before calculating the type with the legend table.
float biome = height;

// Define the height value above which landmass starts.
float land_level = get_array_at(color_map_offsets, 2) - UNCERTAINTY;

if (height > land_level) {
    int type = -1;
    // `ice_level` is the 12th (2nd to last) color stop.
    float ice_level = get_array_at(color_map_offsets, 11) + UNCERTAINTY;
    // The rest of the if statements are based on the biome legend table except for
    // this first test where we assign `ICE` to noise values that satisfy
    // `height > ice_level` as well.
    //
    // We follow the same pattern each time:
    // 1. Test heat values (columns in the legend)
    // 2. Test moisture values (rows in the legend)
    if (heat < COLDEST || height > ice_level) {
        type = ICE;
    } else if (heat < COLDER) {
        type = TUNDRA;
    } else if (heat < COLD) {
        if (moisture < DRYER) {
            type = GRASSLAND;
        } else if (moisture < DRY) {
            type = WOODLAND;
        } else {
            type = BOREAL_FOREST;
        }
    } else if (heat < HOT) {
        if (moisture < DRYER) {
            type = DESERT;
        } else if (moisture < WET) {
            type = WOODLAND;
        } else if (moisture < WETTER) {
            type = SEASONAL_FOREST;
        } else {
            type = TEMPERATE_RAINFOREST;
        }
    } else {
        if (moisture < DRYER) {
            type = DESERT;
        } else if (moisture < WET) {
            type = SAVANNA;
        } else {
            type = TROPICAL_RAINFOREST;
        }
    }
    // At this step we just need to get the appropriate value level for the given biome `type`.
    biome = get_biome(type);
}

// NOTE the use of `biome`
COLOR = texture(color_map, vec2(biome, 0));

We don’t need all to define constants for all columns and rows from the biomes legend table because some are identical, such as the last two columns: HOTTER and HOTTEST. Everything greater than HOT will have the same biome assignment which means we can skip over HOTTER and HOTTEST. We also don’t need WETTEST, because it’s enough for us to check for moisture values above WETTER. We determined the values assigned to the COLDEST to HOT and DRYER to WETTER constants through experimentation.

By running the project now, we get the following image.

Which is almost what we want, but if you look closely we have some issues with the rivers. We need to fix the uv_perturbation values and to normalize heat and moisture.

Finalizing the shader

Let’s finalize the shader with some final touches. We have to define these remaining utility functions:

// Normalizes a value using the min to max range for that value.
float normalized(float x, vec2 minmax) {
    return (x - minmax.x) / (minmax.y - minmax.x);
}

// Remaps a normalized value to the interval [-span, span].
float normalized_remap(float x, float span) {
    return (2.0 * x - 1.0) * span;
}

We use them to map noise values to the intervals [0, 1] and [-span, span], respectively. The OpenSimplexNoise get_image() and get_seamless_image() functions produce values within a range smaller than [0, 1]. To get the most of our generator, we stretch this interval to the [0, 1] interval by normalizing the values using the formula from normalized().

Conversely, for any normalized value we can use normalized_remap() to remap to the symmetric interval [-span, span]. Let’s also introduce one last constant, used to define uv_perturbation:

const float SPAN = 0.1;

We use it for easier customization and testing of the [-span, span] interval returned by normalized_remap().

Finally, replace your respective definitions from fragment() with:

float heat = normalized(texture(heat_map, UV).r, heat_map_minmax);
float moisture = normalized(texture(moisture_map, UV).r, moisture_map_minmax);
float uv_perturbation = normalized_remap(height * moisture, SPAN);

By running the project now we get much nicer rivers:

This concludes our long lesson on the shader.

References

The following code listing provides the full shader code for reference.

shader_type canvas_item;


const float UNCERTAINTY = 1e-2;
const float PI = 3.14159265358979323846;
const float SPAN = 1e-1;

const float COLDEST = 0.025;
const float COLDER = 0.075;
const float COLD = 0.3;
const float HOT = 0.55;

const float DRYER = 0.15;
const float DRY = 0.35;
const float WET = 0.65;
const float WETTER = 0.85;

const int ICE = 0;
const int TUNDRA = 1;
const int GRASSLAND = 2;
const int WOODLAND = 3;
const int BOREAL_FOREST = 4;
const int DESERT = 5;
const int SEASONAL_FOREST = 6;
const int TEMPERATE_RAINFOREST = 7;
const int SAVANNA = 8;
const int TROPICAL_RAINFOREST = 9;


// A step-wise color map for assigning flat colors to the water/land masses and biomes.
uniform sampler2D color_map : hint_black;
// We pass in the `color_map.gradient.offsets` array as a `sampler2D` as we need the positions of
// the gradient offsets to determine water and land masses.
uniform sampler2D color_map_offsets : hint_black;
// The following three variables are noise values for height, heat and moisture.
uniform sampler2D height_map : hint_black;
uniform sampler2D heat_map : hint_black;
uniform sampler2D moisture_map : hint_black;
// GDScript generated texture with linear rivers for post-processing in the shader.
uniform sampler2D rivers_map : hint_black;

// This is the value of `color_map.gradient.offsets.size()`. We need it to get values
// at the given indices.
uniform int color_map_offsets_n = 0;
// We calculate the next two variables on the CPU and we use them to normalize the noise values.
// This way we get the most out of the `[0, 1]` range.
uniform vec2 heat_map_minmax = vec2(0.0, 1.0);
uniform vec2 moisture_map_minmax = vec2(0.0, 1.0);


// Gets value at index from 1D array passed in as data texture from GDScript.
float get_array_at(sampler2D array, int index) {
    return texelFetch(array, ivec2(index, 0), 0).r;
}


// Gets the offset value for a given biome type (`ICE` through `TROPICAL_RAINFOREST`).
// This is used to assign a color to the biome using the discretized GradientTexture.
float get_biome(int index) {
    return get_array_at(color_map_offsets, color_map_offsets_n - index - 1) - UNCERTAINTY;
}

// Normalizes a value knowing the min/max range for that value.
float normalized(float x, vec2 minmax) {
    return (x - minmax.x) / (minmax.y - minmax.x);
}

// Remaps a normalized value to the interval [-span, span].
float normalized_remap(float x, float span) {
    return (2.0 * x - 1.0) * span;
}


void fragment() {
    float height = texture(height_map, UV).r;
    float heat = normalized(texture(heat_map, UV).r, heat_map_minmax);
    float moisture = normalized(texture(moisture_map, UV).r, moisture_map_minmax);
    float uv_perturbation = normalized_remap(height * moisture, SPAN);
    float river = texture(rivers_map, UV + uv_perturbation).r;
    float river_blurred = textureLod(rivers_map, UV + uv_perturbation, 3.0).r;

    heat *= pow(sin(PI * UV.y), 2.0);
    moisture = max(moisture, step(UNCERTAINTY, river_blurred));
    float rivers_level = get_array_at(color_map_offsets, 1) + UNCERTAINTY;
    height = mix(height, rivers_level, river);

    // Default `biome` to `height` before calculating the type with the legend table.
    float biome = height;
    // Define the height value above which landmass starts.
    float land_level = get_array_at(color_map_offsets, 2) - UNCERTAINTY;
    if (height > land_level) {
        int type = -1;
        // `ice_level` is the 12th (2nd to last) color stop.
        float ice_level = get_array_at(color_map_offsets, 11) + UNCERTAINTY;
        // The rest of the if statements are based on the biome legend table except for
        // this first test where we assign `ICE` to noise values that satisfy
        // `height > ice_level` as well.
        //
        // We follow the same pattern each time:
        // 1. Test heat values (columns in the legend)
        // 2. Test moisture values (rows in the legend)
        if (heat < COLDEST || height > ice_level) {
            type = ICE;
        } else if (heat < COLDER) {
            type = TUNDRA;
        } else if (heat < COLD) {
            if (moisture < DRYER) {
                type = GRASSLAND;
            } else if (moisture < DRY) {
                type = WOODLAND;
            } else {
                type = BOREAL_FOREST;
            }
        } else if (heat < HOT) {
            if (moisture < DRYER) {
                type = DESERT;
            } else if (moisture < WET) {
                type = WOODLAND;
            } else if (moisture < WETTER) {
                type = SEASONAL_FOREST;
            } else {
                type = TEMPERATE_RAINFOREST;
            }
        } else {
            if (moisture < DRYER) {
                type = DESERT;
            } else if (moisture < WET) {
                type = SAVANNA;
            } else {
                type = TROPICAL_RAINFOREST;
            }
        }
        // At this step we just need to get the appropriate value level for the given biome `type`.
        biome = get_biome(type);
    }

    // NOTE the use of `biome`
    COLOR = texture(color_map, vec2(biome, 0));
}