Before moving on, let’s use the idea from our previous lesson and visualize the Height Map by assigning Color Map to it in the shader. Open up the Shader assigned to Viewer in the bottom editor panel and add the following fragment()
function at the end:
void fragment() { float height = texture(height_map, UV).r; COLOR = texture(color_map, vec2(height, 0.0)); }
If you remember from the previous lesson, this is how we ended our experiment with GradientTexture. Here, we just replace height
for noise
and height_map
for TEXTURE
. Doing so will allow us to see what we’re doing in GDScript partially. Partially because we’re only visualizing Height Map, but we have more than one texture.
Add a script to WorldMap, our scene’s main node. Replace its contents with:
extends Control var _rng := RandomNumberGenerator.new() onready var viewer: ColorRect = $Viewer func _ready() -> void: _rng.randomize() generate()
We want a RandomNumberGenerator
, stored in the _rng
variable here, to drive the seeds of our noise textures and randomness of the rivers. We will implement the rivers in another lesson. In the _ready()
function, we make sure to randomize _rng
. Otherwise, we’d get the same map every time we run the scene. We don’t want this to happen unless we’re debugging our code.
In generate()
, we need to get references to the textures we assigned under Inspector > Material > Shader > Shader Param. We do this using get_shader_param()
which is a ShaderMaterial
method:
func generate() -> void: var color_map: GradientTexture = viewer.material.get_shader_param("color_map") var height_map: Texture = viewer.material.get_shader_param("height_map") var heat_map: NoiseTexture = viewer.material.get_shader_param("heat_map") var moisture_map: NoiseTexture = viewer.material.get_shader_param("moisture_map") height_map.noise.seed = _rng.randi() heat_map.noise.seed = _rng.randi() moisture_map.noise.seed = _rng.randi()
We also assign random numbers to the noise generators’ seed
parameters. Doing so will produce a different world map each time we run the scene.
At this point, we have to manipulate some of our textures. Let’s start by improving height_map
. We’ll make it slightly more realistic using a technique called domain warping.
Domain warping is taking our noise and offsetting its values using another noise generator. Doing so produces much more varied shapes than working with a raw simplex noise texture.
Add a new function named domain_warp()
. It should take a NoiseTexture
, a strength
, and a size
as input parameters and return an ImageTexture
, a processed version of the input noise texture:
func domain_warp(nt: NoiseTexture, strength: float, size: float) -> ImageTexture:
In the function, the first thing we do is to create the out
variable, which is our return value. Next we make sure that strength
and size
can’t be less than 0.
var out := ImageTexture.new() strength = max(0, strength) size = max(0, size)
We then construct a data
array with noise values such that the values aren’t given by OpenSimplexNoise.get_noise_2d()
. Instead we construct it with OpenSimplexNoise.get_noise_3d()
having Z
set to strength * nt.noise.get_noise_2d(size * x, size * y)
. So we’re sampling values from a 3D space by recycling the values from the 2D space.
var data := [] var minmax := Vector2(INF, -INF) for x in range(nt.width): for y in range(nt.height): var value := strength * nt.noise.get_noise_2d(size * x, size * y) value = nt.noise.get_noise_3d(x, y, value) minmax.x = min(minmax.x, value) minmax.y = max(minmax.y, value) data.push_back(value)
While generating these values in the loop, we’re also storing minimum and maximum values to construct a normalized noise range later.
We then use the StreamPeerBuffer
object to save these floating-point values inside the bytes
variable. This object is usually used for transmitting data over the network. We use it here for constructing textures from arrays because it gives us fine-grained control over what data types we use. You can learn more about StreamPeerBuffer
and its parent, StreamPeer
in the official docs.
When adding values to bytes
we use range_lerp()
with the minimum and maximum values previously stored in the loop. This takes the value d
which we know is in the interval [minmax.x, minmax.y]
and maps it to the interval [0, 1]
, thus normalizing the value.
var bytes = StreamPeerBuffer.new() for d in data: bytes.put_float(range_lerp(d, minmax.x, minmax.y, 0, 1))
We then build our return value, an image with a single red channel of floats: Image.FORMAT_RF
in the code below. We need to do so because OpenSimplexNoise.get_noise_*()
functions gives us values in the [-1, 1]
interval. We also make sure that there are no flags set upon creation so that there will be no filtering or interpolation done on the image by default.
var image := Image.new() image.create_from_data(nt.width, nt.height, false, Image.FORMAT_RF, bytes.data_array) out.create_from_image(image, 0) return out
Here is the complete function’s code:
func domain_warp(nt: NoiseTexture, strength: float, size: float) -> ImageTexture: var out := ImageTexture.new() strength = max(0, strength) size = max(0, size) var data := [] var minmax := Vector2(INF, -INF) for x in range(nt.width): for y in range(nt.height): var value := strength * nt.noise.get_noise_2d(size * x, size * y) value = nt.noise.get_noise_3d(x, y, value) minmax.x = min(minmax.x, value) minmax.y = max(minmax.y, value) data.push_back(value) var bytes = StreamPeerBuffer.new() for d in data: bytes.put_float(range_lerp(d, minmax.x, minmax.y, 0, 1)) var image := Image.new() image.create_from_data(nt.width, nt.height, false, Image.FORMAT_RF, bytes.data_array) out.create_from_image(image, 0) return out
At this point, we can go back to generate()
and call domain_warp()
like below, then assign the height map to the shader:
func generate() -> void: #... height_map = domain_warp(height_map, 10, 0.15) viewer.material.set_shader_param("height_map", height_map)
Values assigned to strength
and size
parameters of domain_warp()
were determined by trial and error.
The next function we implement is discrete()
which converts a GradientTexture to a discrete version as ImageTexture:
func discrete(gt: GradientTexture) -> ImageTexture: var out := ImageTexture.new() var image := Image.new() image.create(gt.width, 1, false, Image.FORMAT_RGBA8) var point_count := gt.gradient.get_point_count() image.lock() 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] 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
Given the input GradientTexture
parameter, we convert it to a discrete ImageTexture
of the same width and 1-pixel height. In the following Image the textures have been stretched on the vertical axis so we can see better what’s going on.
Notice how the colors respect the offset locations from the GradientTexture
. Dark/light blue and cream colors are occupying most of the texture space. Furthermore, see how we use the last offset position to determine the span of the previous color from the GradientTexture
. The actual last color (white) isn’t used.
In the for loop, we take a pair of adjacent offsets, starting from 0, and we fill in the respective range with the color of the first stop in the pair. That’s why we have to skip the last color in the original GradientTexture
.
Like before, in domain_warp()
, we make sure that image filtering is off, otherwise, we’d get a slight gradual transition between colors.
At this point we can append the following line at the end of generate()
:
viewer.material.set_shader_param("color_map", discrete(color_map))
In Godot 3.2, there’s no direct way to pass an array to a shader. We have to convert it to an image where each pixel stores a floating-point value. Let’s create a new function that converts an array to an image texture. I’ve named it to_sampler2D()
:
func to_sampler2D(array: PoolRealArray) -> ImageTexture: # Convert the array's values to a stream of floating-point values var bytes := StreamPeerBuffer.new() for x in array: bytes.put_float(x) # Create an Image with only a red channel, without flags, from the bytes stream var image := Image.new() image.create_from_data(array.size(), 1, false, Image.FORMAT_RF, bytes.data_array) # Convert the image to an ImageTexture so we can assign it to a shader's texture uniform var out := ImageTexture.new() out.create_from_image(image, 0) return out
We can now finish generate()
by adding the following lines at the end of the function:
func generate() -> void: # ... viewer.material.set_shader_param("color_map_offsets", to_sampler2D(color_map.gradient.offsets)) viewer.material.set_shader_param("color_map_offsets_n", color_map.gradient.offsets.size())
Note that we pass color_map.gradient.offsets.size()
to the shader because it’s simpler than calculating the length of the texture in shader.
By running the project, we get something similar to the following Image. It isn’t impressive yet, but we’ll get there!
In the next part, we’ll go over the Utils.gd
library, which defines a few utility functions, mainly to normalize the noise values.
See the entire code for this lesson in the following listing.
extends Control var _rng := RandomNumberGenerator.new() onready var viewer: ColorRect = $Viewer func _ready() -> void: _rng.randomize() generate() # Generates a new world map and feeds textures into the viewer's shader. func generate() -> void: var color_map: GradientTexture = viewer.material.get_shader_param("color_map") var height_map: Texture = viewer.material.get_shader_param("height_map") var heat_map: NoiseTexture = viewer.material.get_shader_param("heat_map") var moisture_map: NoiseTexture = viewer.material.get_shader_param("moisture_map") height_map.noise.seed = _rng.randi() heat_map.noise.seed = _rng.randi() moisture_map.noise.seed = _rng.randi() height_map = domain_warp(height_map, 10, 0.15) viewer.material.set_shader_param("height_map", height_map) viewer.material.set_shader_param("color_map", discrete(color_map)) viewer.material.set_shader_param("color_map_offsets", to_sampler2D(color_map.gradient.offsets)) viewer.material.set_shader_param("color_map_offsets_n", color_map.gradient.offsets.size()) # Uses the 2D noise value as z-axis in the 3D noise function to generate a more realistic # height map. func domain_warp(nt: NoiseTexture, strength: float, size: float) -> ImageTexture: var out := ImageTexture.new() strength = max(0, strength) size = max(0, size) var data := [] var minmax := Vector2(INF, -INF) for x in range(nt.width): for y in range(nt.height): var value := strength * nt.noise.get_noise_2d(size * x, size * y) value = nt.noise.get_noise_3d(x, y, value) minmax.x = min(minmax.x, value) minmax.y = max(minmax.y, value) data.push_back(value) var bytes = StreamPeerBuffer.new() for d in data: bytes.put_float(range_lerp(d, minmax.x, minmax.y, 0, 1)) var image := Image.new() image.create_from_data(nt.width, nt.height, false, Image.FORMAT_RF, bytes.data_array) out.create_from_image(image, 0) return out # Converts a smooth gradient to a discrete texture, that is to say, # a texture with hard color transitions. func discrete(gt: GradientTexture) -> ImageTexture: var out := ImageTexture.new() var image := Image.new() image.create(gt.width, 1, false, Image.FORMAT_RGBA8) var point_count := gt.gradient.get_point_count() image.lock() 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] 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 # Converts an array of floating point values to an ImageTexture, to sample with a shader. func to_sampler2D(array: PoolRealArray) -> ImageTexture: var bytes := StreamPeerBuffer.new() for x in array: bytes.put_float(x) var image := Image.new() image.create_from_data(array.size(), 1, false, Image.FORMAT_RF, bytes.data_array) var out := ImageTexture.new() out.create_from_image(image, 0) return out