In this tutorial, we’re going to create animated procedural clouds that cast shadows over your game level. This shader is intended for top-down 2D games.
The shader multiplies the sampled screen color with a black-and-white image, here a processed noise, and a tint color.
You are going to learn to:
Let’s get started.
The setup on this scene is minimal. We need some background sprites as we’ll overlay the cloud shadows over it. We also need a Sprite at the bottom of the node tree to represent our cloud layer.
For the background, you can find an adventure game mock-up in the Godot project: topdown-adventure.svg
.
Here is my scene’s setup:
Assign a new noise texture to the Texture property with an Open Simplex Noise resource.
The most important change to make at this point is to set the texture to be Seamless so we can repeat it in our shader. I invite you to tweak the noise settings later on, once we have some of our shader’s code in place.
Here are the parameters I used for reference.
We need to create another noise texture to warp the first one, but we first need to create the shader and define a uniform to that end.
Create a new ShaderMaterial with a Shader resource.
It should be of type canvas_item
. Add a uniform sampler2D
in which we will store our second noise.
shader_type canvas_item; uniform sampler2D noise_texture2 :hint_black; void fragment() { }
In the Inspector, under Shader Param, create a new noise texture with a noise generator different from the first one. We are going to use this other texture to warp the sampling of our first noise.
Here are my settings.
We can now start to animate our noises.
We want to use two layers of noise moving in different directions and at different speeds. When mixing them, doing so will simulate the edges of the clouds evolving.
The first noise is provided by our texture variable and the second one by the noise_texture2
uniform. For each of them, we want three parameters: a scroll direction, a timescale, and a tile factor to control the scale of the clouds. Create two groups of three uniforms like so:
uniform vec2 scroll_direction1 = vec2(0.7, -0.7); uniform float time_scale1 :hint_range(0.001, 0.25) = 0.012; uniform float tile_factor1 :hint_range(0.1, 3.0) = 0.6; uniform vec2 scroll_direction2 = vec2(0.75, 0.25); uniform float time_scale2 :hint_range(0.001, 0.25) = 0.005; uniform float tile_factor2 :hint_range(0.1, 3.0) = 0.3;
The scroll direction should be a unit vector that represents a direction to move towards in UV space.
Let’s use the TIME
variable and the uniforms we just defined to animate the second noise. To do so, multiply the UV
by tile_factor2
and add the product of scroll_direction2 * TIME * time_scale2
. That expression controls the texture’s panning.
Sample the noise texture using these UV coordinates.
void fragment() { vec2 noise2_uv = UV * tile_factor2 + scroll_direction2 * TIME * time_scale2; float noise2 = texture(noise_texture2, noise2_uv).r;
I’m only extracting a floating-point value from the noise because all three color channels contain the same data.
Do the same for the UV coordinates of our first noise texture, using the second set of uniforms.
vec2 noise1_uv = UV * tile_factor1 + scroll_direction1 * TIME * time_scale1;
To warp our noise texture, we need to offset it using our second noise generator. To do so, add the noise2_uv
to the noise1_uv
when sampling noise1
:
float noise1 = texture(TEXTURE, noise1_uv + noise2 * 0.02).r;
Use a small multiplier to control the warp effect’s strength. The offset caused by noise2
is in UV space, so even a little value can cause significant changes.
If the offset is too big, the noise patterns will turn into lava or thin stretched curves, like the rings of a tree’s bark.
To visualize the result, convert your noise value into an RGB color:
COLOR.rgb = vec3(noise1);
At this point, your shader’s code should look like so:
shader_type canvas_item; uniform sampler2D noise_texture2 :hint_black; uniform vec2 scroll_direction1 = vec2(0.7, -0.7); uniform float time_scale1 :hint_range(0.001, 0.25) = 0.012; uniform float tile_factor1 :hint_range(0.1, 3.0) = 0.6; uniform vec2 scroll_direction2 = vec2(0.75, 0.25); uniform float time_scale2 :hint_range(0.001, 0.25) = 0.005; uniform float tile_factor2 :hint_range(0.1, 3.0) = 0.3; void fragment() { vec2 noise2_uv = UV * tile_factor2 + scroll_direction2 * TIME * time_scale2; float noise2 = texture(noise_texture2, noise2_uv).r; vec2 noise1_uv = UV * tile_factor1 + scroll_direction1 * TIME * time_scale1; float noise1 = texture(TEXTURE, noise1_uv + noise2 * 0.02).r; }
To turn our smooth noise into clouds, we need to increase its value contrast.
You can do so using the smoothstep()
function, which smoothly interpolates between two values.
float clouds = smoothstep(0.45, 0.55, noise1);
The first two arguments define the range in which we want to interpolate the third argument. With this function, if noise1
is lower than 0.45
, we get 0.0
. If it’s greater than 0.55
, we get 1.0
. And if noise1
is within the [0.45, 0.55]
range, the function outputs values between 0.0
and 1.0
.
At this stage, you want to play with your two noise generators’ parameters and with the smoothstep function’s values. You should see cloud-like shapes appear.
While the smoothstep()
function works, we are going to use gradient mapping to have real-time control over our noise’s contrast.
Create a new uniform, sampler2D
.
uniform sampler2D gradient_texture :hint_black;
In the Inspector, assign a new GradientTexture to it with a Gradient resource. You want to move the black, and the white color stops close to one another to produce an effect similar to the smoothstep()
function.
Back in the shader, use the gradient mapping technique you learned in the previous lesson to remap the noise to the value range defined by the gradient.
float clouds = texture(gradient_texture, vec2(noise1, 0.0)).r;
Output your clouds
values to the screen and move the color stops in the Inspector to see how the gradient turns your noise into clouds.
We now have two steps left:
Create a new uniform to represent a tint that we’ll apply to the clouds mask.
uniform vec4 tint :hint_color = vec4(0.0);
To turn our shader’s output into a shade, we can use the blend_mul
render mode. Right after the shader_type
, add the following line:
render_mode blend_mul;
Doing so makes the output of our shader use the multiply blending mode. This allows us to add shadows over the game world without having to sample the screen texture.
We need to output dark colors where our clouds are, and white where we do not want any shadows. We can do so using the mix()
function:
COLOR.rgb = mix(vec3(1.0), tint.rgb, clouds * tint.a);
The line above uses our clouds
to interpolate between white and our tint
color. We also use the alpha channel of our tint
to control the opacity of the shadows.
Here is the final code:
shader_type canvas_item; render_mode blend_mul; uniform vec4 tint :hint_color = vec4(0.0); uniform sampler2D noise_texture2 :hint_black; uniform sampler2D gradient_texture :hint_black; uniform vec2 scroll_direction1 = vec2(0.7, -0.7); uniform float time_scale1 :hint_range(0.001, 0.25) = 0.012; uniform float tile_factor1 :hint_range(0.1, 3.0) = 0.6; uniform vec2 scroll_direction2 = vec2(0.75, 0.25); uniform float time_scale2 :hint_range(0.001, 0.25) = 0.005; uniform float tile_factor2 :hint_range(0.1, 3.0) = 0.3; void fragment() { vec2 noise2_uv = UV * tile_factor2 + scroll_direction2 * TIME * time_scale2; float noise2 = texture(noise_texture2, noise2_uv).r; vec2 noise1_uv = UV * tile_factor1 + scroll_direction1 * TIME * time_scale1; float noise1 = texture(TEXTURE, noise1_uv + noise2 * 0.02).r; float clouds = texture(gradient_texture, vec2(noise1, 0.0)).r; COLOR.rgb = mix(vec3(1.0), tint.rgb, clouds * tint.a); }