Let’s add rivers to our world map.
We’ll generate them as an ImageTexture
on the CPU.
Then, we’ll pass the data to the shader to apply post-processing to them. By doing so, we’ll get more natural turns and flow.
First, we are going to generate straight lines like in the image below.
As you can see, the rivers can have branches.
Here, we’re not concerned with drawing the rivers from their geographical source.
In the lesson, you’ll see we talk about start and end points. It’s not about geography; instead, we’re always talking about where the lines start and end.
We’ll draw series of connected lines, all 3-pixels wide. We’ll control random parameters like length, direction, and the number of branches with a RandomNumberGenerator
.
Also, we’ll draw the rivers from their mouths to the sources, as it’s easier to draw a single line from which we branch out than drawing branches first and making them connect.
What matters to us with procedural drawing is not that we try to simulate the real world. Instead, we want to get an appealing and believable result.
After applying post-processing steps in the shader, we will get a result like the one below.
Let’s now dive into the river generator’s code.
First, just like in our previous lesson, create a new script called RiverGenerator.gd
and replace the contents with:
class_name RiverGenerator const OFFSETS := [ Vector2.ZERO, Vector2.RIGHT, Vector2.RIGHT + Vector2.UP, Vector2.UP, Vector2.LEFT + Vector2.UP, Vector2.LEFT, Vector2.LEFT + Vector2.DOWN, Vector2.DOWN, Vector2.RIGHT + Vector2.DOWN ] const RIVERS_MAX_BRANCHES := 3 const BRANCH_LENGTH := Vector2(0.3, 0.65) const BRANCH_ANGLE := Vector2(20, 45)
Like Utils.gd
, this script is a library class that we register with class_name RiverGenerator
, giving us access to its members anywhere in the code.
At the top, we define a few useful constants:
OFFSETS
is a list of coordinates that make a 3px x 3px rectangle. For each point on the river path, we will draw a 3 x 3
rectangle. This isn’t the most efficient way of doing it, but it’s one of the simplest ways to implement it.RIVERS_MAX_BRANCHES
is the maximum allowed number of branching paths from the main river path.BRANCH_LENGTH
and BRANCH_ANGLE
are ranges for respectively the length of a river branch and its angle compared to the main river body.Our first function includes a loop that generates all rivers with their branches. It is the only public method of the class, the entry point for the generator:
# Returns rivers generated from a noise-based height map as an image texture. static func generate_rivers( rng: RandomNumberGenerator, texture: Texture, rivers_count: int, rivers_level: Vector2 ) -> ImageTexture: var rivers := _generate_rivers(rng, texture, rivers_count, rivers_level) return _generate_rivers_texture(rng, rivers, texture.get_width(), texture.get_height())
Its input parameters are:
rng
: the RandomNumberGenerator
that will be provided from the main script.texture
: the Height Map texture.rivers_count
: the maximum number of rivers.rivers_level
: the threshold values below which the river starts (rivers_level.x
) and above where it ends (rivers_level.y
).This function calls upon _generate_rivers()
and _generate_rivers_texture()
. The first function generates the river information, an array data structure that holds the river’s start and end points. The latter function uses this information to generate the actual ImageTexture
.
Next we look at _generate_rivers()
:
# Generate all rivers in the map as an array of pixel positions. static func _generate_rivers( rng: RandomNumberGenerator, texture: Texture, rivers_count: int, rivers_level: Vector2 ) -> Array:
We first define available_start_positions
and available_end_positions
. They’re arrays that hold 2D vectors with possible positions for our rivers’ start and end.
var out := [] var available_start_positions := [] var available_end_positions := []
We construct them in the following for loop, based on the input texture
, which is the Height Map noise texture in this case. Instead of using the OpenSimplexNoise
property of the texture, we use the pixel data to make this generator more generic. Our function isn’t limited to NoiseTexture
input data, but any texture supported by Godot. We define this parameter with the more generic Texture
hint instead of NoiseTexture
.
For available_start_positions
we check if the noise value is less than rivers_level.x
and likewise for available_end_positions
, we check if noise
is greater than rivers_level.y
. This is the simple way we pick start and end positions for the river data, based on the input texture and the threshold interval rivers_level
.
var image := texture.get_data() image.lock() for x in range(texture.get_width()): for y in range(texture.get_height()): var noise := image.get_pixel(x, y).r if noise < rivers_level.x: available_start_positions.push_back(Vector2(x, y)) elif rivers_level.y < noise: available_end_positions.push_back(Vector2(x, y)) image.unlock()
We’re using Image.lock()
and Image.unlock()
when accessing image data. Locking is currently required for both read and write operations in Godot 3.2. There’s an open issue on this topic because locking and unlocking image data should be, in theory, required only for write operations.
Finally, in the last for loop, we generate the individual river parts by calling _generate_river()
. That function uses the available start and end positions as well as the RandomNumberGenerator
.
for _i in range(rivers_count): var river := _generate_river(rng, available_start_positions, available_end_positions) if river.empty(): break out += river return out
Here is the full code for _generate_rivers()
:
# Generate all rivers in the map as an array of pixel positions. static func _generate_rivers( rng: RandomNumberGenerator, texture: Texture, rivers_count: int, rivers_level: Vector2 ) -> Array: var out := [] var available_start_positions := [] var available_end_positions := [] var image := texture.get_data() image.lock() for x in range(texture.get_width()): for y in range(texture.get_height()): var noise := image.get_pixel(x, y).r if noise < rivers_level.x: available_start_positions.push_back(Vector2(x, y)) elif rivers_level.y < noise: available_end_positions.push_back(Vector2(x, y)) image.unlock() for _i in range(rivers_count): var river := _generate_river(rng, available_start_positions, available_end_positions) if river.empty(): break out += river return out
Up next, we have _generate_river()
, our function to generate individual river segments.
static func _generate_river( rng: RandomNumberGenerator, available_start_positions: Array, available_end_positions: Array ) -> Array:
We first check for available starting positions, and if we have none, we return with an empty array.
This is why we had a condition in the last for
loop of _generate_rivers()
. We mutate available_start_positions
inside of _generate_river()
by removing positions as we use them. Doing so ensures sure that we don’t pick the same start position twice.
var out := [] if available_start_positions.empty(): return out
We pick a random start position by generating a random index with RandomNumberGenerator
. We store this value in the start
variable and remove it from available_start_positions
.
var r := rng.randi_range(0, available_start_positions.size() - 1) var start: Vector2 = available_start_positions[r] available_start_positions.remove(r)
We then try to find the end
position for the river by finding the minimum distance to all vectors from available_end_positions
. Unlike before, we don’t remove these positions as they are reusable.
var end := Vector2.ZERO var min_distance := INF for position in available_end_positions: var distance: float = (position - start).length() if min_distance > distance: min_distance = distance end = position
Once we found end
, we append the [start, end]
array to out
. Before returning it from the function, we append additional data from _generate_river_branches()
, the one we’re going to write next.
out.push_back([start, end]) out += _generate_river_branches(rng, start, end) return out
Here is the complete code for _generate_river()
:
static func _generate_river( rng: RandomNumberGenerator, available_start_positions: Array, available_end_positions: Array ) -> Array: var out := [] if available_start_positions.empty(): return out var r := rng.randi_range(0, available_start_positions.size() - 1) var start: Vector2 = available_start_positions[r] available_start_positions.remove(r) var end := Vector2.ZERO var min_distance := INF for position in available_end_positions: var distance: float = (position - start).length() if min_distance > distance: min_distance = distance end = position out.push_back([start, end]) out += _generate_river_branches(rng, start, end) return out
Our rivers can branch out. This is what _generate_river_branches()
controls.
Before generating the branches, we store the vector representing the main path of the river as river_vector
.
static func _generate_river_branches(rng: RandomNumberGenerator, start: Vector2, end: Vector2) -> Array: var out := [] var river_vector := end - start
We then loop over a random number of branches between 0
and RIVERS_MAX_BRANCHES
. If the value generated by the RandomNumberGenerator
is 0
, no branches are created.
In the loop, we generate a random angle and store it in branch_angle
. Next, by a toss of a coin (50% chance), we mirror this value: branch_angle *= -1
so we don’t always get river branches on the same side.
for _j in range(rng.randi_range(0, RIVERS_MAX_BRANCHES)): var branch_angle := deg2rad(rng.randf_range(BRANCH_ANGLE.x, BRANCH_ANGLE.y)) if rng.randf() < 0.5: branch_angle *= -1
Still in the loop, we generate a randomized branch_length
using RandomNumberGenerator
. For the starting position, we pick a place on the main river path using Vector2.linear_interpolate()
and rng.randf()
. As for the branch’s end position, we calculate it using all the above variables.
var branch_length := rng.randf_range(BRANCH_LENGTH.x, BRANCH_LENGTH.y) var branch_start := start.linear_interpolate(end, rng.randf()) var branch_end := (branch_start + branch_length * river_vector.rotated(branch_angle)) out.push_back([branch_start, branch_end])
And we finally return the out
value, an array of branch start and ends:
return out
Here is the complete function:
static func _generate_river_branches(rng: RandomNumberGenerator, start: Vector2, end: Vector2) -> Array: var out := [] var river_vector := end - start for _j in range(rng.randi_range(0, RIVERS_MAX_BRANCHES)): var branch_angle := deg2rad(rng.randf_range(BRANCH_ANGLE.x, BRANCH_ANGLE.y)) if rng.randf() < 0.5: branch_angle *= -1 var branch_length := rng.randf_range(BRANCH_LENGTH.x, BRANCH_LENGTH.y) var branch_start := start.linear_interpolate(end, rng.randf()) var branch_end := (branch_start + branch_length * river_vector.rotated(branch_angle)) out.push_back([branch_start, branch_end]) return out
Our last step is to generate the ImageTexture
, which we do in _generate_rivers_texture()
:
# Converts generated rivers array as an image texture. static func _generate_rivers_texture( rng: RandomNumberGenerator, rivers: Array, width: float, height: float ) -> ImageTexture:
We start by creating an empty image
with the same size as the input texture. We make sure it’s Image.FORMAT_RF
so we store information only on the red channel, as floats.
var image := Image.new() image.create(width, height, true, Image.FORMAT_RF)
In the for loop, we store the length of the river in distance
and create a step
value based on it so we can traverse the path step-by-step. At this point, branches are stored in the same way as rivers are so we apply the same method.
image.lock() for river in rivers: var distance: float = (river[1] - river[0]).length() var step := 1 / distance var t := 0.0
An inner while loop traverses the river path. For each incremental step, we assign Color(1, 0, 0, 0)
to that pixel position and all pixels around it in a 3 x 3
square.
for river in rivers: # ... while t <= 1: for offset in OFFSETS: var position: Vector2 = river[0].linear_interpolate(river[1], t) + offset if position.x < 0 or width <= position.x or position.y < 0 or height <= position.y: continue image.set_pixelv(position, Color(1, 0, 0, 0)) t += step image.unlock()
After the loop terminates, we make sure to generate mipmaps with Image.generate_mipmaps()
. This is required to use textureLod()
in the shader, which samples data from a lower quality, blurred image. We are going to use this function to accumulate moisture around rivers.
image.generate_mipmaps()
We finally create a new ImageTexture
using the Image
data we constructed and return the result.
var out := ImageTexture.new() out.create_from_image(image) return out
Here is the complete function:
# Converts generated rivers array as an image texture. static func _generate_rivers_texture( rng: RandomNumberGenerator, rivers: Array, width: float, height: float ) -> ImageTexture: var image := Image.new() image.create(width, height, true, Image.FORMAT_RF) image.lock() for river in rivers: var distance: float = (river[1] - river[0]).length() var step := 1 / distance var t := 0.0 while t <= 1: for offset in OFFSETS: var position: Vector2 = river[0].linear_interpolate(river[1], t) + offset if position.x < 0 or width <= position.x or position.y < 0 or height <= position.y: continue image.set_pixelv(position, Color(1, 0, 0, 0)) t += step image.unlock() image.generate_mipmaps() var out := ImageTexture.new() out.create_from_image(image) return out
This concludes the lesson on generating rivers. In the next part, we’ll put everything together on the CPU side.
The following code listing shows the full RiverGenerator.gd code in one place.
class_name RiverGenerator const OFFSETS := [ Vector2.ZERO, Vector2.RIGHT, Vector2.RIGHT + Vector2.UP, Vector2.UP, Vector2.LEFT + Vector2.UP, Vector2.LEFT, Vector2.LEFT + Vector2.DOWN, Vector2.DOWN, Vector2.RIGHT + Vector2.DOWN ] const RIVERS_MAX_BRANCHES := 3 const BRANCH_LENGTH := Vector2(0.3, 0.65) const BRANCH_ANGLE := Vector2(20, 45) # Returns rivers generated from a noise-based height map as an image texture. static func generate_rivers( rng: RandomNumberGenerator, texture: Texture, rivers_count: int, rivers_level: Vector2 ) -> ImageTexture: var rivers := _generate_rivers(rng, texture, rivers_count, rivers_level) return _generate_rivers_texture(rng, rivers, texture.get_width(), texture.get_height()) # Generate all rivers in the map as an array of pixel positions. static func _generate_rivers( rng: RandomNumberGenerator, texture: Texture, rivers_count: int, rivers_level: Vector2 ) -> Array: var out := [] var available_start_positions := [] var available_end_positions := [] var image := texture.get_data() image.lock() for x in range(texture.get_width()): for y in range(texture.get_height()): var noise := image.get_pixel(x, y).r if noise < rivers_level.x: available_start_positions.push_back(Vector2(x, y)) elif rivers_level.y < noise: available_end_positions.push_back(Vector2(x, y)) image.unlock() for _i in range(rivers_count): var river := _generate_river(rng, available_start_positions, available_end_positions) if river.empty(): break out += river return out static func _generate_river( rng: RandomNumberGenerator, available_start_positions: Array, available_end_positions: Array ) -> Array: var out := [] if available_start_positions.empty(): return out var r := rng.randi_range(0, available_start_positions.size() - 1) var start: Vector2 = available_start_positions[r] available_start_positions.remove(r) var end := Vector2.ZERO var min_distance := INF for position in available_end_positions: var distance: float = (position - start).length() if min_distance > distance: min_distance = distance end = position out.push_back([start, end]) out += _generate_river_branches(rng, start, end) return out static func _generate_river_branches(rng: RandomNumberGenerator, start: Vector2, end: Vector2) -> Array: var out := [] var river_vector := end - start for _j in range(rng.randi_range(0, RIVERS_MAX_BRANCHES)): var branch_angle := deg2rad(rng.randf_range(BRANCH_ANGLE.x, BRANCH_ANGLE.y)) if rng.randf() < 0.5: branch_angle *= -1 var branch_length := rng.randf_range(BRANCH_LENGTH.x, BRANCH_LENGTH.y) var branch_start := start.linear_interpolate(end, rng.randf()) var branch_end := (branch_start + branch_length * river_vector.rotated(branch_angle)) out.push_back([branch_start, branch_end]) return out # Converts generated rivers array as an image texture. static func _generate_rivers_texture( rng: RandomNumberGenerator, rivers: Array, width: float, height: float ) -> ImageTexture: var image := Image.new() image.create(width, height, true, Image.FORMAT_RF) image.lock() for river in rivers: var distance: float = (river[1] - river[0]).length() var step := 1 / distance var t := 0.0 while t <= 1: for offset in OFFSETS: var position: Vector2 = river[0].linear_interpolate(river[1], t) + offset if position.x < 0 or width <= position.x or position.y < 0 or height <= position.y: continue image.set_pixelv(position, Color(1, 0, 0, 0)) t += step image.unlock() image.generate_mipmaps() var out := ImageTexture.new() out.create_from_image(image) return out