The rivers generator

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.

Generating all the rivers in a loop

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

Generating main river segments

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

Generating river branches

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

Converting the river’s data to a texture

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.

References

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