A powerful technique to use when creating procedural worlds is layering.
You generate the first pass of data with a given algorithm, store it, and forward it to the next processing function, that acts as a separate layer. Each layer only processes data from underlying ones.
You work in passes until you reach the desired result.
We’ll use that to generate a richer world than in the previous lessons, using five layers:
For the trading routes, we need to check neighboring sectors. To do this safely, the algorithm works by shrinking each new layer by one sector compared to the previous one.
Layered generation works a bit like multipass shaders. You run the first pass on your image or mesh, and subsequent passes process the previous pass’s output.
This lesson is long, so it’s split into two parts. In this part, we’ll set up the layered generation and drawing code. In the next part, we’ll generate the world’s planets, moons, and everything you can see on the screenshot above.
Brace yourselves because the class we will write one has a lot of code, although the layering system itself is only a small part of it.
We need a scene that’s similar to the previous lessons, but we need to attach a different script to its root node. Duplicate the BlueNoiseWorldGenerator.tscn
, name it LayeredWorldGenerator.tscn
, open it, and rename its root node accordingly.
In the image, you’ll notice we use a CanvasLayer and a ColorRect node. This allows us to not modify the project’s clear color, but instead have a specific background for this scene.
Remove the LayeredWorldGenerator’s script and attach a new one to it, so we have a new GDScript file to work with.
Open the script and let’s start coding with the generator’s foundations: the layer system.
We start by defining some constants we’ll use later.
## Generates an infinite world using a layered approach, allowing each layer to ## access the previous layers' data. Each layer is smaller than the next by one ## sector to access the neighbors' data. class_name LayeredWorldGenerator extends WorldGenerator # The following constants define the base size of planets, moons, and asteroids # in pixels. We'll use them to calculate their position relative to one another # and to draw them. const PLANET_BASE_SIZE := 96 const MOON_BASE_SIZE := 32 const ASTEROID_BASE_SIZE := 16 # We use this array of coordinates to loop over the eight neighbors around any # sector. const NEIGHBORS := [ Vector2(1, 0), Vector2(1, 1), Vector2(0, 1), Vector2(-1, 1), Vector2(-1, 0), Vector2(-1, 1), Vector2(0, -1), Vector2(1, -1) ]
Like before, we’ll store generated data for sectors inside a dictionary. But this time, instead of storing an array, for each sector, we’ll have a dictionary.
Each layer will be a separate key: “seeds”, “planet”, and so on. The corresponding values will be arrays of dictionaries, or a single dictionary.
We use dictionaries because they are both fast and flexible. You can extend them with more data anytime if you need to, and as you will see in the _draw()
function, they are convenient to check existing keys and available data. They are a great prototyping tool and an efficient data structure.
We define a constant representing the data structure for empty layers.
# This constant holds the template for the data output by our algorithms for # each of the five layers. We will loop over the keys to generate each layer # and duplicate the dictionary to initialize newly generated sectors. const LAYERS = { seeds = [], planet = {}, moons = [], travel_lanes = [], asteroids = [], }
We initialize the layers that can generate multiple elements per sector as arrays. In the case of the planet, you can only have one per sector, so we directly initialize it as an empty dictionary.
Our first layer will place three random points inside a sector. We call them seeds as we will not use them as world entities but rather see if we want to generate a planet or not in that sector, depending on the area they cover.
Unlike before, we want to generate layers one at a time. Instead of processing each sector individually, possibly in parallel, we need the first layer to finish running before starting the second pass. The WorldGenerator.generate()
function that calls _generate_sector()
in a loop does not allow for that.
That’s why in this class, we need to override the parent class’s generate()
method.
## Generates the world with a layered approach. Each layer requires another ## layer before it to already be generated. ## As we add layers, we shrink them by one sector so that sectors on upper ## layers can access data from neighboring sectors. func generate() -> void: # We need to calculate each layer's index to shrink the range of generated # sectors every time we move up one layer. var index := -1 # Our layer names correspond to the keys in our layers dictionary. As # dictionaries are ordered in Godot, we can loop over them to generate # seeds, planets, moons, travel lanes, and asteroids in that order. for layer in LAYERS: index += 1 # The next two full groups calculate the grid of sectors that we process # in a given layer. We use the index to shrink the grid by one cell # every time we move up one layer. for x in range( _current_sector.x - _half_sector_count + index, _current_sector.x + _half_sector_count - index ): for y in range( _current_sector.y - _half_sector_count + index, _current_sector.y + _half_sector_count - index ): # In this class, we decided to make the functions take the # sectors coordinate as their argument because after we wrote # the code, we realized that we needed that Vector2 in every # method. var sector = Vector2(x, y) # For each layer, we call a corresponding function. In Godot # 4.0, we will be able to shorten this code by directly mapping # layers to a function in the form of a dictionary. But in # version 3.2, the match keyword works fine. match layer: "seeds": # We initialize every sector's data before generating # the first layer. if not _sectors.has(sector): _sectors[sector] = LAYERS.duplicate(true) # Here is the layer's processing. It's a function call. # The main difference compared to what we had before is we'll run the # `_generate_seeds_at()` method over all sectors before moving to the # next layer. _generate_seeds_at(sector) "planet": _generate_planets_at(sector) "moons": _generate_moons_at(sector) "travel_lanes": _generate_travel_lanes_at(sector) "asteroids": _generate_asteroids_at(sector) # Once we finished generating everything, we trigger a drawing update. # Calling `CanvasItem.update()` causes the engine to call this node's `_draw()` virtual callback. update()
We have yet to define our five functions. To avoid errors, you can define them and leave them empty for now. That way, the GDScript compiler won’t get in your way.
func _generate_seeds_at(sector: Vector2) -> void: pass func _generate_planets_at(sector: Vector2) -> void: pass func _generate_moons_at(sector: Vector2) -> void: pass func _generate_travel_lanes_at(sector: Vector2) -> void: pass func _generate_asteroids_at(sector: Vector2) -> void: pass
Even though we don’t have any data to draw yet, and we haven’t written the generation, we will add the _draw()
function next. Thanks to the dictionaries and arrays that we are using, you will see that we can easily only draw what is available.
If you know what you are looking to generate from the start and have some sketch of the results you want on paper, you can write that function upfront to visualize your functions’ output as you implement them.
We first define a show_debug
property to toggle drawing of the grid and some data points.
We use it to toggle the GridDrawer node’s visibility at the start of the game.
## Hides or shows the grid and the planetary seeding points. export var show_debug := true setget _set_show_debug onready var _grid_drawer := $GridDrawer # This code is similar to what we had in previous lessons. We generate the world and initialize the GridDrawer. func _ready() -> void: generate() _grid_drawer.setup(sector_size, sector_axis_count) _grid_drawer.visible = show_debug ## Toggles the GridDrawer's visibility and requests a call to the `_draw()` function. ## As you'll see in a second, we'll use the `show_debug` property to toggle the drawing of ## seeding points. func _set_show_debug(value: bool) -> void: show_debug = value if not is_inside_tree(): yield(self, "ready") _grid_drawer.visible = show_debug update()
Here is the _draw()
function.
# This is where we draw the data generated by our algorithm. This function is # available on every node that inherits `CanvasItem`, and it gives us access to a # range of methods to draw geometric shapes. # # The `_draw()` callback only updates when necessary: # # 1. When the node first gets added to the tree. # 2. When we toggle node visibility on and off. # 3. When we call the `update()` function manually. func _draw() -> void: # We loop over all the data stored in all the sectors and draw everything, # even if it is right outside the screen. # You will want to adjust your generation settings, like the number of # sectors you draw per axis, so it doesn't go too far out the screen. for data in _sectors.values(): # Each of the `if` and `for` blocks below draws one layer. # For the first layer, the seeds, we only draw if `show_debug` is on # because they don't contribute to the final world's entities. if show_debug and data.seeds: for point in data.seeds: draw_circle(point, 12, Color(0.5, 0.5, 0.5, 0.5)) # For the rest of the layers, we check if the corresponding data exists # in the `_sectors` dictionary. # # For every layer that is an array, the for loop takes care of that for us. # If the array is empty, the loop will instantly end. # # In the planet's case, we need to check that the dictionary is not empty. # We draw circles for the planet, moons, and asteroids, and a line for # the trading routes. if data.planet: # `draw_circle()` takes a position, radius in pixels, and color as its arguments. # We'll generate the position and scale in the `_generate_planets_at()` function. # To get the radius in pixels, we multiply the scale by our base size constant. draw_circle(data.planet.position, PLANET_BASE_SIZE * data.planet.scale, Color.bisque) for moon in data.moons: draw_circle(moon.position, MOON_BASE_SIZE * moon.scale, Color.aquamarine) for path in data.travel_lanes: var start: Vector2 = path.source var end: Vector2 = path.destination # The arguments for `draw_line()` are the start and end position, the line's color, # and its width in pixels. draw_line(start, end, Color.cornflower, 6.0) for asteroid in data.asteroids: draw_circle(asteroid.position, ASTEROID_BASE_SIZE * asteroid.scale, Color.orangered)
We’ll wrap up this lesson by generating the seeding points, so you can see the drawing code in action.
On the first layer, we generate three random points per sector. Together, we can treat them as a triangle and use its area as a threshold to spawn a planet. You will see on the resulting generation this produces varied positions and sizes for the planets. Even without smoothing or processing the initial result, we get a relatively natural distribution.
The advantage of starting with seeding points is that any layer can reuse them differently. You can also smooth out their position, grow or shrink them, and do whatever you need to fine-tune the generator’s output. There is no hard rule there. As we mentioned before, all that matters is the output and how the final world looks. There’s a lot of experimentation to do and two or three points give you a lot of flexibility in the way you process and interpret them.
If you generate two seeding points instead of three, you can interpret them as a circle’s diameter. You can then use that circle to define a specific area, for example to spawn enemies, loot, and more.
We add two variables to add some margin to our sectors, as in the previous lesson, and implement the _generate_seeds_at()
function. You can replace the empty definition with the following code.
## Percentage to keep the planetary seeding points away from the sector edges. export var sector_margin_proportion := 0.1 ## The pixel value of the margin calculated from the margin percentage onready var _sector_margin := sector_size * sector_margin_proportion ## Generates a triangle inside of the `sector`. The next layer can use it to spawn ## planets. We'll use the triangle's area and epicenter to randomly spawn planets. func _generate_seeds_at(sector: Vector2) -> void: # To update the map generation, we have to generate the layers in order. # To do so conveniently, we'll call the `generate()` method again, triggering a call # to this and other functions even for generated sectors. # Returning early from function calls in already generated sectors keeps the code # simple while prototyping. So if this sector has seeds, it means we already processed it # so we return from the function. if _sectors[sector].seeds: return # We use our `make_seed_for()` function's custom data argument for # the first time. Like before, to keep the generation deterministic, we # create a unique seed for every sector, and now, for every layer too. _rng.seed = make_seed_for(sector.x, sector.y, "seeds") # Like before, we first calculate the sector's bounds, applying some margin to it. var half_size := Vector2(_half_sector_size, _half_sector_size) var margin := Vector2(_sector_margin, _sector_margin) var top_left := sector * sector_size - half_size + margin var bottom_right := sector * sector_size + half_size - margin # We then generate three points using white noise to form a triangle. # The next layer will use these points and the area and epicenter of the # corresponding triangle to spawn a planet. # We create an array of three points and save them in this sector's `seeds` # key. var seeds := [] for _i in range(3): var seed_position := Vector2( _rng.randf_range(top_left.x, bottom_right.x), _rng.randf_range(top_left.y, bottom_right.y) ) seeds.append(seed_position) _sectors[sector].seeds = seeds
You will notice a pattern in each of the functions: if a given sector already has data, we return early.
This is due to the way we wrote the generate()
function, which loops over all the sectors for all layers. This code is not the most optimized, but for one, it should run smoothly during development, and optimizing it would make it more complex and harder to modify, which we do not want at this stage.
If you test the scene now, it should look like this.
Now we have the seeds, we can use them to generate the planets and build other layers from there. We’ll do that in the next lesson.