Skip to content
Elements · Clouds & smoke (2D)Intermediate

Sky clouds

Soft cumulus drifting across a blue gradient, from three fBm calls and one threshold.

UVTimeSurface
Open in Playground
  1. 01GoalElements
  2. 02TechniqueClouds & smoke (2D)
  3. 03ExecutionSky clouds
Full-screen quad

Parameters

3.0
0.50
0.15
1.50
0.08
#3873d1
#add1f2
#ffffff
#99a6bd

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. mixMixmixmixmix
    UV
    Time

    01 Sky gradient

  2. fbm()Texture2Dfbm()fbm()fbm()

    02 Warp field

  3. fbm()Texture2Dfbm()fbm()fbm()

    03 Warped fBm

  4. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    04 Threshold

  5. mixMixmixmixmix

    05 Shade the cores

  6. mixMixmixmixmix

    06 Clouds over the sky

How it works

The card is the four-part recipe of its technique, with each part as small as it can be: a value noise, an fBm built on it, a warp, a threshold and three mixes.

The noise underneath

valueNoise() puts a random value on each corner of a unit grid and blends the four corners around the pixel with a smooth fade (the bilinear blend of the value-noise fundamental). The random values come from hashCell(), an integer hash: it mixes the bits of the cell’s coordinates, so the same cell gives the same value on every GPU. The common one-line fract(sin(…)) hash depends on how precisely a GPU computes the sine of a large number, and the coordinates here keep growing with time, so after a while it would start to show stripes.

fbm() adds five octaves of that noise, each twice the frequency and half the strength of the one before. Between octaves the coordinate is also turned by about 37 degrees. Value noise has its features aligned to the grid, and without the turn the grid of each octave would line up with the others and show as faint horizontal and vertical rows. The result sits around 0.5, rarely below 0.2 or above 0.8.

Step by step

  1. Sky gradient. mix(horizon, zenith, sqrt(uv.y)) blends the two sky colors by height. The square root moves the blend down, so most of the frame is the deeper blue overhead and the pale band stays near the horizon, as in a real sky.
  2. Warp field. Two fBm calls at unrelated offsets give a smooth random 2D vector at every pixel, shown as red and green. The coordinate was first squashed vertically by 1.8, since clouds seen from below look wider than tall.
  3. Warped fBm. The third fBm reads the coordinate pushed by the warp field times Warp, plus the wind. This is the cloud density. The warp field drifts slower than the wind and in another direction, so the shapes fold and change as they move instead of sliding across the screen like a picture. With Warp at 0 you see exactly that picture sliding.
  4. Threshold. smoothstep(threshold, threshold + softness, density) turns density into coverage, with threshold = 1 - coverage. Below it is clear sky, above it is solid cloud, and Edge softness is how wide the transition is: small for crisp cumulus, large for haze.
  5. Shade the cores. A second, wider smoothstep of the same density says how thick the cloud is. Thick cores get mixed toward Cloud shadow, thin edges stay Sunlit cloud, which is what a cloud lit from above looks like from the ground.
  6. Clouds over the sky. One last mix lays the shaded cloud over the gradient by the coverage.

Cost

Each noise sample is four hashes and three mixes, about 90 operations; an octave adds the turn and the sum, so about 95. Three fBm calls of five octaves are fifteen octaves, about 1,425 operations, and the gradient, threshold and mixes add about 15 more. No texture reads, one pass. That is fine for a background on a desktop GPU, and heavy for a full screen on a phone. The cheap fixes, in order: drop the warp field to three octaves (it only needs to be smooth), render the sky at half resolution, or bake the density into a tiling texture and keep only the threshold and shading live.

Precision

The wind adds time * speed to the coordinate. After a few hours that number is large enough that the fractional part fract() returns loses precision and the clouds turn blocky. In a long-running game, wrap the time you send (for example modulo 1000 seconds) and accept a jump once in a while, or move the clouds with a uniform offset that the game wraps itself.

Make it in GLSL

The copied shader is a complete GLSL ES 3.00 fragment shader: the helpers, effect(), the uniforms and a main() that fills Inputs and writes the color encoded to sRGB.

  1. Draw a full-screen triangle or quad and pass its UV, 0 to 1 from the bottom-left, as v_uv. The shader also declares v_position, v_normal and v_view; the sky doesn’t read them, so any value works.
  2. Every frame, set u_time to the seconds since the page started (performance.now() / 1000) and u_resolution to the canvas size.
  3. Set the params once, or when a control changes: the floats as they are, the four colors converted from sRGB to linear (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 per channel).

The UV is stretched with the canvas, so on a wide canvas the clouds get wider too. That suits a sky; if you want them the same shape at every aspect ratio, multiply i.uv.x by i.resolution.x / i.resolution.y before the noise.

To put the clouds over something else instead of the gradient, return vec4(cloud, cover) from effect(), change the last line of main() to fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), frag_out.a);, and draw it with gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA): cover is already a soft alpha.

Make it in Godot

In VisualShader there is no noise node; the usual build samples a NoiseTexture2D (FastNoiseLite, fractal type FBM, seamless on) with a Texture2D node, and FastNoiseLite's own domain warp stands in for the warp field. That version is a texture read, so it is cheaper, but it repeats.

The export is a canvas_item shader, for anything drawn in 2D: a ColorRect, a TextureRect, a Sprite2D or a Panel.

  1. Add a ColorRect that fills the background (Layout → Full Rect) and move it to the top of the scene tree so it draws first.
  2. In its Material slot, create a New ShaderMaterial, then a New Shader inside it, and replace its code with the copied shader.
  3. The params appear under Shader Parameters. The colors use source_color, so the picker shows the same sRGB values as the site.

The shader flips Godot’s UV, which starts at the top-left, so the zenith color is at the top of the rect as on the site. TIME drives the wind; to pause or scrub the clouds from a script, replace i.time = TIME; in fragment() with a uniform you set.

For a 3D sky, the same effect body works in a shader_type sky; shader with EYEDIR mapped to a 2D coordinate (EYEDIR.xz / EYEDIR.y for a flat cloud layer overhead), but that needs a template of its own and isn’t the copied code.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

sky-clouds.fragGLSL
#version 300 es// Clouds & smoke (2D) → Sky clouds, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_scale; // Scale: 3.0 (1.0 to 8.0)uniform float p_coverage; // Coverage: 0.5 (0.0 to 1.0)uniform float p_softness; // Edge softness: 0.15 (0.01 to 0.5)uniform float p_warp; // Warp: 1.5 (0.0 to 4.0)uniform float p_speed; // Wind speed: 0.08 (0.0 to 0.5)uniform vec3 p_zenith; // Sky overhead: vec3(0.22, 0.45, 0.82), sRGB; upload it decoded to linearuniform vec3 p_horizon; // Sky at the horizon: vec3(0.68, 0.82, 0.95), sRGB; upload it decoded to linearuniform vec3 p_cloudLit; // Sunlit cloud: vec3(1.0, 1.0, 1.0), sRGB; upload it decoded to linearuniform vec3 p_cloudShade; // Cloud shadow: vec3(0.6, 0.65, 0.74), sRGB; upload it decoded to linearin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// Integer bit mixing instead of the one-line sine hash: it gives the same value on every GPU, and the drifting// coordinates can grow for hours without the noise turning into stripes.uint scramble(uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}float hashCell(ivec2 cell) {	return float(scramble(uint(cell.x) ^ scramble(uint(cell.y)))) / 4294967295.0;}float valueNoise(vec2 p) {	ivec2 cell = ivec2(floor(p));	vec2 f = fract(p);	vec2 u = f * f * (3.0 - 2.0 * f);	float a = hashCell(cell);	float b = hashCell(cell + ivec2(1, 0));	float c = hashCell(cell + ivec2(0, 1));	float d = hashCell(cell + ivec2(1, 1));	return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}// Each octave is turned by about 37 degrees, so the value noise's grid lines don't line up across octaves and show// as rows in the sky.float fbm(vec2 p) {	const mat2 turn = mat2(vec2(0.8, 0.6), vec2(-0.6, 0.8));	float sum = 0.0;	float amplitude = 0.5;	for (int k = 0; k < 5; k++) {		sum += amplitude * valueNoise(p);		p = turn * p * 2.0 + vec2(17.3, 9.1);		amplitude *= 0.5;	}	return sum;}vec4 effect(Inputs i) {	vec3 sky = mix(p_horizon, p_zenith, sqrt(i.uv.y));	// Clouds seen from below look wider than they are tall, so the noise is squashed vertically	vec2 p = vec2(i.uv.x, i.uv.y * 1.8) * p_scale;	float drift = i.time * p_speed;	// The warp field moves slower than the wind, so the clouds churn as they travel instead of sliding like a picture	vec2 warpField = vec2(fbm(p + vec2(0.0, drift * 0.3)), fbm(p + vec2(5.2, 1.3) - drift * 0.2));	float density = fbm(p + p_warp * warpField + vec2(drift, 0.0));	float threshold = 1.0 - p_coverage;	float cover = smoothstep(threshold, threshold + p_softness, density);	// Thick cores block the sun and thin edges let it through, so shade grows with density above the threshold	float thickness = smoothstep(threshold, threshold + 0.35, density);	vec3 cloud = mix(p_cloudLit, p_cloudShade, thickness * 0.8);	vec3 color = mix(sky, cloud, cover);	return vec4(color, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
sky-clouds.gdshaderGodot shader
// Clouds & smoke (2D) → Sky clouds, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float scale : hint_range(1.0, 8.0, 0.1) = 3.0;uniform float coverage : hint_range(0.0, 1.0, 0.01) = 0.5;uniform float softness : hint_range(0.01, 0.5, 0.01) = 0.15;uniform float warp : hint_range(0.0, 4.0, 0.05) = 1.5;uniform float speed : hint_range(0.0, 0.5, 0.01) = 0.08;uniform vec3 zenith : source_color = vec3(0.22, 0.45, 0.82);uniform vec3 horizon : source_color = vec3(0.68, 0.82, 0.95);uniform vec3 cloudLit : source_color = vec3(1.0, 1.0, 1.0);uniform vec3 cloudShade : source_color = vec3(0.6, 0.65, 0.74);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) {	return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// Integer bit mixing instead of the one-line sine hash: it gives the same value on every GPU, and the drifting// coordinates can grow for hours without the noise turning into stripes.uint scramble(uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}float hashCell(ivec2 cell) {	return float(scramble(uint(cell.x) ^ scramble(uint(cell.y)))) / 4294967295.0;}float valueNoise(vec2 p) {	ivec2 cell = ivec2(floor(p));	vec2 f = fract(p);	vec2 u = f * f * (3.0 - 2.0 * f);	float a = hashCell(cell);	float b = hashCell(cell + ivec2(1, 0));	float c = hashCell(cell + ivec2(0, 1));	float d = hashCell(cell + ivec2(1, 1));	return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);}// Each octave is turned by about 37 degrees, so the value noise's grid lines don't line up across octaves and show// as rows in the sky.float fbm(vec2 p) {	const mat2 turn = mat2(vec2(0.8, 0.6), vec2(-0.6, 0.8));	float sum = 0.0;	float amplitude = 0.5;	for (int k = 0; k < 5; k++) {		sum += amplitude * valueNoise(p);		p = turn * p * 2.0 + vec2(17.3, 9.1);		amplitude *= 0.5;	}	return sum;}vec4 effect(Inputs i) {	vec3 sky = mix(u_toLinear(horizon), u_toLinear(zenith), sqrt(i.uv.y));	// Clouds seen from below look wider than they are tall, so the noise is squashed vertically	vec2 p = vec2(i.uv.x, i.uv.y * 1.8) * scale;	float drift = i.time * speed;	// The warp field moves slower than the wind, so the clouds churn as they travel instead of sliding like a picture	vec2 warpField = vec2(fbm(p + vec2(0.0, drift * 0.3)), fbm(p + vec2(5.2, 1.3) - drift * 0.2));	float density = fbm(p + warp * warpField + vec2(drift, 0.0));	float threshold = 1.0 - coverage;	float cover = smoothstep(threshold, threshold + softness, density);	// Thick cores block the sun and thin edges let it through, so shade grows with density above the threshold	float thickness = smoothstep(threshold, threshold + 0.35, density);	vec3 cloud = mix(u_toLinear(cloudLit), u_toLinear(cloudShade), thickness * 0.8);	vec3 color = mix(sky, cloud, cover);	return vec4(color, 1.0);}void fragment() {	Inputs i;	// UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = 1.0 / SCREEN_PIXEL_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading