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.
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:
sin(x)
is red and does the job, butpow(sin(x), 2.0)
, the blue line gives us a steeper decrease towards the poles.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.
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.
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
.
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.
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)); }