An intro to Procedural Generation using Godot
2024-02-25 by Gabriel C. Lins
From The Elder Scrolls: Arena to Dwarf Fortress to Minecraft, procedural generation is a staple in gaming. Even when it's not part of the main game loop, it's still used for a diversity of effects, whether it's particles, water textures or even determining NPC actions. In this article we'll dive into procedural generation using Godot Engine, but you should be able to attempt the same thing in your preferred environment.
What is procedural generation exactly?
If you're here, you probably already know what procedural means, but just so we're on the same page: procedural means generated algorithmically. It is not strictly necessary that it is generated at run time, although one of its bigger benefits is being able to do so to provide pseudo-random values.
We can use procedural generation for basically anything, and it's up to the developer to determine what should or should not be random. We will not focus on any particular use of this tool, but we'll be using the concept of terrain generation (think Minecraft or No Man's Sky) to illustrate our results.
How does it work?
Everything in a computer is deterministic. While there obviously are things that are effectively random to a given program (such as the current time, or the user's inputs), it's impossible to effectively "roll a die" inside a CPU — it's always about input and output.
Pseudorandom number generators (PRNG) are programs that execute special mathematical functions that are designed to have wildly erratic results. They are widely used in cryptography and also useful in gaming. The idea is that, given a function with multiple parameters, changing one of these parameters by a relatively small amount may result in a completely different output. These parameters, or inputs. can be geographical coordinates, time or any other type of "positioning".
Different functions have different behaviours, and some are designed to provide "smoother" results, creating pockets of similar values at various scales. They all have drawbacks in the form of lower performance, more predictability, or a combination of these when used in higher-dimensional contexts.
Here is a comparison of Perlin noise, a common gradient noise generator, and value noise, which is essentially random noise with an easing function applied to average out the results, courtesy of Wikimedia Commons's contributors:
Perlin noise | Value noise |
---|---|
The parameters to the function in this situation are an unknown random seed and the coordinates of the pixels in the pictures (X and Y). By having a seed (also called a key in cryptography), we can both ensure that it's possible to replicate results and that we'll always get different results for our coordinates as long as there are different seeds.
It's not just about terrain
Terrain generation is cool, but there are many other uses for procedural generation:
Minecraft's biomes
We've talked about Minecraft a lot — it's inevitable, since everything about it is essentially random. What makes it so smart is that although it is random, it uses many blending and easing techniques and different noise functions to merge many layers of randomness and create believable landscapes.
Minecraft's world generation consists of many layers of noise maps, each of which determine something different: temperature, altitude, carving caves and ravines out of the stone, structure spawning and much more. By using multiple noise maps related to environment, the game creates biome borders that somewhat make sense: you don't (usually) see warm deserts bordering icebergs, or jungles suddenly transitioning into tundra.
Roguelikes
Roguelikes and roguelites make use of procedural generation to create environments. It's one of the defining traits of the genre, tracing back to Rogue in 1980. There, randomness is used to generate rooms, items, item effects and enemies, and in modern interpretations of the genre there are even more applications – terrain, waves of enemies, card draws, loot and whatever you can imagine.
Slay the Spire, one of my recent favourites, uses procgen to create the rooms and paths in its overworld map.
This example clearly illustrates the kinds of things you can determine using RNG: encounter types, room connections, the presence of the rooms themselves, etc. Being a card game, there's even more randomness involving card draws, rewards after each room, and many other things.
Textures
Procedural textures are a concept you're probably familiar with, even if you haven't heard about it by name – water, sky and fog textures in basically any game, as well as those old screensavers and audio visualisers have all made use of procedural texturing. It's used in more subtle ways in modern shaders as a way to provide variance as well.
Making a terrain generator
I've made a terrain generator using Godot when I was learning about these things myself, and it's available on itch.io. For this tutorial, we'll make something slightly different, but the principle is the same, and the result should be fairly similar.
Creating our project
We'll be using Godot 4.2.1. You can download the editor on the official website to follow these steps yourself.
First, we'll create a new project. It's very simple so we can create it in Compatibility Mode, especially since I want to include it in this article in the end:
Now, we make a new 2D scene named World, and add a script to it.
The scene itself doesn't need any children just yet, as we'll have to set up our script before we do any graphical work.
The script will contain a few things:
- Two constants that will make configuration easy:
- A
TILE_SIZE
constant, set to32
: the size of our tiles, which you can customise to see more tiles at once; - A
SEA_LEVEL
constant used in world generation (more on that later);
- A
- Two inputs:
- A
FastNoiseLite
instance, which will contain our noise generator; - A
PackedScene
to be instantiated when a tile is generated;
- A
- A
generate_terrain_tile(x, y)
method that spawns aSprite2D
with the correct value for the noise coordinates at thex, y
coordinates.
Here's a stub of what it's going to look like:
extends Node2D
class_name World
const TILE_SIZE = 32
const SEA_LEVEL = 0.2
@export var altitude_noise: FastNoiseLite
@export var tile: PackedScene
func generate_terrain_tile(x: int, y: int):
pass
In our example, generate_terrain_tile
is going to be called by the camera as it moves about,
so that we preemptively load more tiles as the camera pans around.
The logic for the first noise layer is going to be simple: if the value at a certain tile is
above our SEA_LEVEL
threshold, it generates as land; otherwise, it generates as sea. Sea and
land could be different objects in a more complex setup, but for this example, our single tile
scene will have properties that toggle it between these two.
Now, we'll create a basic Tile scene with a tile_type
input, which can be set to SEA
or LAND
.
Start by creating the scene and a child Sprite2D node.
Now, attach a script to Tile. This script should do the following:
- Declare the input
tile_type
which World is going to use; - On ready, change its sprite to match the correct
tile_type
.
Because we're creating a basic app, we won't bother with sprites for now, but you can use whatever
you like. In the tutorial we'll simply use Godot's PlaceholderTexture2D and set the sprite's modulate
property to edit its colour.
extends Node2D
class_name Tile
enum TileType {
SEA,
LAND
}
@export var tile_type: TileType
@onready var sprite := $Sprite2D
func _ready():
sprite.modulate = Color(0, 1, 1, 1) if tile_type == TileType.SEA else Color.WHITE
Because Godot's default placeholder sprite is pink for some reason, we use modulate to shut down the red channel to make the sea look blue, while we can keep the land pink for now. This is enough to be able to tell which is which.
Now, back on the World scene, let's spawn a single Tile to make sure we did it right:
first, we wire both scenes correctly by setting World's tile
input to the scene we made
and also creating a new FastNoiseLite for it:
Back at the script, let's add an instantiation to the _ready
lifecycle hook:
func _ready():
var test_tile = tile.instantiate()
add_child(test_tile)
With this, you should be able to see a quarter of a blue tile at the top-left corner of the
screen when previewing the World scene. Adding a line that changes its tile_type
to Tile.TileType.LAND
should turn it pink on the next preview:
test_tile.tile_type = Tile.TileType.LAND
Important: this line should be added before the
add_child
call. Otherwise,_ready
will get called first on Tile, because Godot processes updates from child to parent; this will make it always blue, becauseTileType.SEA
is the first value in the enum and therefore the default.If you change the default or manually switch the value that's saved with the scene, you will see that instead, however it still wouldn't be controlled by the World scene.
Now that we've verified that it works, we can start spawning tiles with the correct color according to what our noise map tells us.
Getting values for each coordinate
Now we can update our generate_terrain_tile
method to actually do what it's supposed to
by moving the responsibility of spawning tiles to it. We'll also make it so it uses the
x
and y
parameters to position the tiles. To make the code more readable, we'll make
a separate method to get the tile value called altitude_value
.
Lastly, we'll change the assignment of tile.tile_type
to use the noise map we passed to World
:
func _ready():
generate_terrain_tile(0, 0)
func generate_terrain_tile(x: int, y: int):
var tile = tile.instantiate()
tile.tile_type = altitude_value(x, y)
tile.position = Vector2(x, y) * TILE_SIZE
add_child(tile)
func altitude_value(x: int, y: int) -> Tile.TileType:
var value = altitude_noise.get_noise_2d(x, y)
if value >= SEA_LEVEL:
return Tile.TileType.LAND
return Tile.TileType.SEA
This should result on the exact same render, except that the tile should now repect the threshold we provided for determining land versus sea. Of course, it'll make no difference to us until we add more tiles around it, so let's get to that by generating tiles around the center.
Rendering more tiles
Let's create a new constant called RENDER_DISTANCE
and set it to 16, then update _ready
to generate a few more tiles:
const RENDER_DISTANCE = 16.0
# ...
func _ready():
for n in RENDER_DISTANCE:
# We divide by two so that half the tiles
# generate left/above center and half right/below
var x = n - RENDER_DISTANCE / 2.0
for m in RENDER_DISTANCE:
var y = m - RENDER_DISTANCE / 2.0
generate_terrain_tile(x, y)
If you run the scene now, there's a high chance you're only seeing sea tiles. That's because
land is rarer and our resolution is very low. To find more land, we can lower SEA_LEVEL
to
0.0, and that should give you a substantial increase in land space, as the chances of generating
sea or land become exactly 50%.
However, because we are rendering around the [0, 0] point, we are only seeing a quarter of all the tiles. Add a Camera2D node under World to correct that. Now, the node tree and rendered scene should look something like this:
Fine-tuning
You can now play around with the values in World's Altitude Noise property through the editor and see as the land and sea shapes change when you re-render the scene.
For example, if you increase Frequency you should start seeing more granular spots of land due to the values varying more rapidly. If you change Seed, you will get different land shapes with the same configuration. This is what we'll use to make our worlds random when this project is finished. Under Fractal you have ways to tune the fractal generation, which is a complex topic to explain, so I'll let you look that up yourself or learn by experimentation.
Conclusion
After the previous steps, your code should look like this:
# world.gd
extends Node2D
class_name World
const TILE_SIZE = 32
const SEA_LEVEL = 0.0
const RENDER_DISTANCE = 16.0
@export var altitude_noise: FastNoiseLite
@export var tile: PackedScene
func _ready():
for n in RENDER_DISTANCE:
# We divide by two so that half the tiles
# generate left/above center and half right/below
var x = n - RENDER_DISTANCE / 2.0
for m in RENDER_DISTANCE:
var y = m - RENDER_DISTANCE / 2.0
generate_terrain_tile(x, y)
func generate_terrain_tile(x: int, y: int):
var tile = tile.instantiate()
tile.tile_type = altitude_value(x, y)
tile.position = Vector2(x, y) * TILE_SIZE
add_child(tile)
func altitude_value(x: int, y: int) -> Tile.TileType:
var value = altitude_noise.get_noise_2d(x, y)
if value >= SEA_LEVEL:
return Tile.TileType.LAND
return Tile.TileType.SEA
With this, you've got a working project that is capable of generating rough coastlines at a very low resolution, as well as customising the generation of the land by tweaking the noise texture in the editor.
In the next installment of this series, we'll implement a simple moving camera, render more tiles as we move, add extra layers to compose the landscape with more properties and use time to affect our generator functions.