Heightmap-based procedural world map

intermediate godot tutorial

By: Răzvan C. Rădulescu

This tutorial goes over a simple world map generator using NoiseTexture, a modified GradientTexture, and shaders.

Objectives:

  • Understanding NoiseTexture with OpenSimplexNoise to create height maps for use in shaders.
  • Modifying GradientTexture for our needs to use it as a discrete color map in shaders.
  • Using basic shaders with input from GDScript.

WorldMap presentation

Preparing the scene structure

Let’s prepare the scene. Create a Control Node at the root and name it WorldMap. Add a TextureRect node as its child and name it HeightMap. We will use it to display our world map’s texture.

Select the two nodes, and in the toolbar, click Layout -> Full Rect to make both nodes span the entire viewport.

SceneTree Init

Select the HeightMap node and in the Inspector:

  1. In the Texture slot, create a new NoiseTexture resource and assign an OpenSimplexNoise resource to the Noise property.
  2. Set the Stretch Mode to Scale, so the texture scales with the node.

HeightMap setup

Save the scene as WorldMap.tscn.

Preparing the color-map

Add a placeholder script to the HeightMap node with the following content. We’re exporting a variable that should contain a gradient texture. We are going to use it to recolor our noise texture.

extends TextureRect


export var colormap: GradientTexture

Save the script and head back to the 2D workspace (F2). In the Inspector, assign a GradientTexture to the Colormap property. Add a Gradient resource to it and add a few color stops to it.

HeightMap colormap

Add a new ShaderMaterial to the Material property. Click the newly created material to open the shader editor and use the following shader:

shader_type canvas_item;

// Our gradient texture.
uniform sampler2D colormap : hint_black;

void fragment() {
	// Sample the node's texture and extract the red channel from the image.
	float noise = texture(TEXTURE, UV).r;
	// Convert the noise value to a horizontal position to sample the gradient texture.
	vec2 uv_noise = vec2(noise, 0);
	// Replace greyscale values from the input texture by a color from the gradient texture.
	COLOR = texture(colormap, uv_noise);
}

Copy the Colormap‘s GradientTexture resource and paste it into the Colormap under Material -> Shader Param -> Colormap.

HeightMap colormap copy/paste

You should see an immediate update in the main Viewport.

Fixing the NoiseTexture value range

OpenSimplexNoise generates random values between 0.0 and 1.0. But it does not guarantee that the minimum and maximum values are 0.0 and 1.0, respectively. For our world map and height maps in general, we want to work with a predictable value range. To ensure we always have a range of values from 0.0 to 1.0, we are going to pass the minimum and maximum noise values to the shader. Update the shader as follows:

shader_type canvas_item;

uniform sampler2D colormap : hint_black;
// Stores the minimum and maximum values generated by the noise texture.
uniform vec2 noise_minmax = vec2(0.0, 1.0);

void fragment() {
	// Using `noise_minmax`, we normalize our `noise` variable's range.
	float noise = (texture(TEXTURE, UV).r - noise_minmax.x) / (noise_minmax.y - noise_minmax.x);
	vec2 uv_noise = vec2(noise, 0);
	COLOR = texture(colormap, uv_noise);
}

Update the HeightMap script to set the noise_minmax uniform of the shader from GDScript:

extends TextureRect


# The maximum 8-bit value of a color channel held into 32-bit images.
# Named after the `Image.FORMAT_L8` format we use below.
const L8_MAX := 255

export var colormap: GradientTexture


func _ready() -> void:
	# The open simplex noise algorithm takes a while to generate the noise data so
	# we have to wait for it to finish updating before using it in any calculations.
	yield(texture, "changed")
	var heightmap_minmax := _get_heightmap_minmax(texture.get_data())
	# Use the material's `set_shader_param` method to assign values to a shader's uniforms.
	material.set_shader_param("noise_minmax", heightmap_minmax)


# Returns the lowest and highest value of the heightmap, converted to be in the [0.0, 1.0] range.
func _get_heightmap_minmax(image: Image) -> Vector2:
	# We convert the image to have a single channel of integer values that go from `0` to `255`.
	image.convert(Image.FORMAT_L8)
	# Gets the lowest and biggest values in the image and divides it to have values between 0 and 1.
	return _get_minmax(image.get_data()) / L8_MAX


# Utility function that returns the minimum and maximum value of an array as a `Vector2`
func _get_minmax(array: Array) -> Vector2:
	var out := Vector2(INF, -INF)
	for value in array:
		out.x = min(out.x, value)
		out.y = max(out.y, value)
	return out

Run the project now to see the difference between the visual representation in the editor viewport and the image normalized at runtime.

HeightMap colormap normalized

Getting a toon shaded look

To get toon shading, we need our gradient texture to have sharp transitions between colors. For our example world map, we want a given blue to appear where noise < 0.2, green where 0.2 <= noise < 0.4, etc. To do so, we can generate a discrete version of the gradient stored in Colormap. That is to say, a version of the texture with sharp color transitions instead of smooth color interpolation.

Add the following function to the end of your HeightMap script:

# Generates a discrete texture from a gradient texture and returns it as a new `ImageTexture`.
func _discrete(gt: GradientTexture) -> ImageTexture:
	var out := ImageTexture.new()
	var image := Image.new()

	# We create an image texture as wide as the input gradient, but with a height of 1 pixel.
	image.create(gt.width, 1, false, Image.FORMAT_RGBA8)
	var point_count := gt.gradient.get_point_count()

	# To fill the new image's pixels, we must first lock it.
	image.lock()
	# For each color stop on the gradient, we fill the image with that color, up to the next color
	# stop.
	for index in (point_count - 1) if point_count > 1 else point_count:
		var offset1: float = gt.gradient.offsets[index]
		var offset2: float = gt.gradient.offsets[index + 1] if point_count > 1 else 1
		var color: Color = gt.gradient.colors[index]
		# This is where we fill the pixels in the image.
		for x in range(gt.width * offset1, gt.width * offset2):
			image.set_pixel(x, 0, color)
	image.unlock()

	out.create_from_image(image, 0)
	return out

Finally add the following line to the end of the script’s _ready() function:

material.set_shader_param("colormap", _discrete(colormap))

If you run the project now you’ll get a toon shading like the following:

HeightMap final toon shading

We use the information stored in the gradient resource to create a discrete color map by expanding the color of each offset towards the right. This means that the last offset isn’t taken into account. In the above image, there is no white color generated at the end of the cel-shaded ImageTexture.

It’s easier to visually explain how we generate the toon shading using the _discrete() function. For this reason, we included a demo scene, GradientDiscrete.tscn, in our open-source demo.

GradientDiscrete showcase

You can find the demo on Github, in our Godot mini-tuts demos. It’s in the godot/pcg/world-map/ directory.

Become a better game developer

Join our weekly newsletter and get our latest game creation tutorials, tips, and open-source tools right in your inbox.

🔒 No spam. Unsubscribe anytime.

Made by

Răzvan C. Rădulescu

GameDev. enthusiast, passionate about technology, sciences and philosophy. I believe we should freely share our knowledge.