What do you see when you look up into the sky? Clouds? Clouds of all sizes and shapes - clouds that look like elephants and cars!
How could you go about creating such an effect?
You want to create that magical, fluffy soft look - like cotton candy!
Fluffy Cotton Candy Clouds (Animated)
In fact, it's really easy to generate these types of clouds! Yes! You can generate animated life-like cotton candy clouds that can shift into any shape or size you can imagine!
The secret is to combine signed distance functions (sdf) with a bit of random noise on the fragment shader.
Generating clouds is great! But animated clouds is even cooler! How could we make it even cooler? Making the clouds into shapes and spelling out words!
SDF Functions
The journey starts with SDF functions and doing a bit of `ray-tracing` in the fragment shader. Then we'll go step by step adding little featuers to make it more and more cloud-like - to the point that you're able to generated animated volumetric clouds that can be used for cloud-lettering or other effects.
Simple starting point - ray-trace red sphere - not very exciting?
Setup a SDF function for the scene - and for that scene we add a simple sphere. Using a fixed step we determine if a ray hits or doesn't hit the sphere (draw the result as a red circle).
As the ray-sphere intersection just returns a red color to confirm it works - the output looks like a flat 2d circle - but the math is 3d and is a sphere. You'll see later when we start adding in lighting that there is a depth.
fn rayMarch( ro:vec3<f32>, rd:vec3<f32> ) -> vec4<f32> { let MAX_STEPS = 100; let MARCH_SIZE = 0.08; var depth = 0.0; var p = ro + depth * rd;
var res = vec4<f32>(0.0);
for (var i:i32 = 0; i < MAX_STEPS; i++) { var density = sdfScene( p );
// We only draw the density if it's greater than 0 if (density > 0.0) { res += vec4<f32>(1,0,0,1); }
depth += MARCH_SIZE; p = ro + depth * rd; }
return res; }
@fragment fn main(@location(0) coords : vec2<f32>) -> @location(0) vec4<f32> { var uv = (-1.0 + 2.0*coords.xy); // -1 to 1
var ro = vec3<f32>(0, 0, 2); // ray origin that represents camera position var rd = normalize(vec3<f32>(uv, -1)); // ray direction
var fragColor = rayMarch( ro, rd );
// Output to screen return vec4<f32>(fragColor); }
Add a bit of depth (color) - make the sphere look a bit more `fluffy` (use a grayscale cloudy color)
Modify the color calculation information in the
rayMarch(..)
function - so instead of a constant red color we'll mix the the depth with a gray and feather the edges.
Make the intersection color more feathery/cloud like - assuming a we're looking to create a grayish type cloud.
fn rayMarch( ro:vec3<f32>, rd:vec3<f32> ) -> vec4<f32> { let MAX_STEPS = 100; let MARCH_SIZE = 0.08; var depth = 0.0; var p = ro + depth * rd;
var res = vec4<f32>(0.0);
for (var i:i32 = 0; i < MAX_STEPS; i++) { var density = sdfScene( p );
// We only draw the density if it's greater than 0 if (density > 0.0) { // Make the sphere more cloudy/feather-like around the edges var color = vec4(mix(vec3(1.0,1.0,1.0), vec3(0.0, 0.0, 0.0), density), density ); color = vec4<f32>( color.rgb*color.a, color.a ); res += color*(1.0-res.a); }
depth += MARCH_SIZE; p = ro + depth * rd; }
return res; }
Noise (on the shader)
Go into generating random noise on the shader using our own random number generator - for the seed it can either be the texture coordinates (x,y) or the world position (x,y,z).
The random number generator uses the chaotic values of the sin function for high frequencies. This random number function is cheap and cheerful - and works fine for our purposes - but there are lots and lots of different random number generates each with their own properties.
A simple test case is the plot the random pixels - see that the output is consistent - no blocks or rings in the random pixel output. Using the random number output for the red, green and blue (giving a grayscale color).
// red==green==blue => 'gray' scale var fragColor = vec4<f32>( random( uv ), // red random( uv ), // green random( uv), // blue 1.0 ); // alpha // Output to screen return vec4<f32>(fragColor); }
If different random numbers are required for on the same GPU thread - the seed can be scaled and modified to generate multiple unique values.
For example, generating random pixels with color which requires 3 random numbers (red, green and blue color instead of just one value for the grayscale).
var fragColor = vec4<f32>( random( uv ), // red random( uv*2.32 ), // green random( uv*7.21932), // blue 1.0 ); // alpha // Output to screen return vec4<f32>(fragColor); }
Smooth Noise
Random numbers are great! But they're just too noisy and chaotic - we wanat the noise to change `gradually`. Actually very easy using a bit of linear interpolation and number rounding (taking the decimal and fractional part of the seed).
Add the
randomsmooth(..)
function which takes samples from the
random(..)
number function - but interpolates them - as the seed is 2-floating point values - the noise is interpolated in both dimensions (so if it's plotted you see a nice smoother noise).
fn randomsmooth( st:vec2<f32> ) -> f32 { var i = floor( st * 2.0 ); // uv - 0, 1, 2, 3, var f = fract( st * 2.0 ); // uv - 0-1, 0-1, 0-1, 0-1
// Four corners in 2D of a tile var a = random(i); var b = random(i + vec2<f32>(1.0, 0.0)); var c = random(i + vec2<f32>(0.0, 1.0)); var d = random(i + vec2<f32>(1.0, 1.0));
// Ease-in followed by an ease-out (tweening) for f // f = 0.5 * (1.0 - cos( 3.14 * f ) ); // version without cos/sin // f = 3*f*f - 2*f*f*f;
f = 3*f*f - 2*f*f*f;
// bilinear interpolation to combine the values sampled from the // four corners (a,b,c,d), resulting in a smoothly interpolated value.
// Interpolate Along One Axis - interpolate between `a` and `b` using the fractional coordinate `f.x`, // then interpolate between `c` and `d` using the same `f.x`. // This gives two intermediate values, say `e` and `f`.
// Interpolate Along the Other Axis - linearly interpolate between `e` and `f` // using the fractional coordinate `f.y`. // Final interpolation gives a moothly interpolated value across the square
var x1 = mix( a, b, f.x ); var x2 = mix( c, d, f.x );
// red==green==blue => 'gray' scale var fragColor = vec4<f32>( randomsmooth( uv ), // red randomsmooth( uv ), // green randomsmooth( uv), // blue 1.0 ); // alpha // Output to screen return vec4<f32>(fragColor); }
SDF Unions (new shapes)
SDF shapes can be combined very easily to create new shapes.
Because the sdf function describe geometric shapes by indicating the shortest distance from any point in space to the shape's surface.
To create new shapes, SDFs can be combined using operations such as addition and subtraction, where addition blends shapes together and subtraction cuts out one shape from another.
Unions of shapes are formed by taking the minimum value of the SDFs of the individual shapes, effectively merging them into a single continuous form.
Example - combining a cube with sphere:
Add the sphere and cube sdf functions to create a new shape.
fn randomsmooth( st:vec2<f32> ) -> f32 { var i = floor( st * 3.0 ); // uv - 0, 1, 2, 3, var f = fract( st * 3.0 ); // uv - 0-1, 0-1, 0-1, 0-1
// Four corners in 2D of a tile var a = random(i); var b = random(i + vec2<f32>(1.0, 0.0)); var c = random(i + vec2<f32>(0.0, 1.0)); var d = random(i + vec2<f32>(1.0, 1.0));
// Ease-in followed by an ease-out (tweening) for f // f = 0.5 * (1.0 - cos( 3.14 * f ) ); // version without cos/sin // f = 3*f*f - 2*f*f*f;
f = 3*f*f - 2*f*f*f;
// bilinear interpolation to combine the values sampled from the // four corners (a,b,c,d), resulting in a smoothly interpolated value.
// Interpolate Along One Axis - interpolate between `a` and `b` using the fractional coordinate `f.x`, // then interpolate between `c` and `d` using the same `f.x`. // This gives two intermediate values, say `e` and `f`.
// Interpolate Along the Other Axis - linearly interpolate between `e` and `f` // using the fractional coordinate `f.y`. // Final interpolation gives a moothly interpolated value across the square
var x1 = mix( a, b, f.x ); var x2 = mix( c, d, f.x );
fn rayMarch( ro:vec3<f32>, rd:vec3<f32> ) -> vec4<f32> { let MAX_STEPS = 100; let MARCH_SIZE = 0.08; var depth = 0.0; var p = ro + depth * rd;
var res = vec4<f32>(0.0);
for (var i:i32 = 0; i < MAX_STEPS; i++) { var density = sdfScene( p );
// We only draw the density if it's greater than 0 if (density > 0.0) { var color = vec4(mix(vec3(1.0,1.0,1.0), vec3(0.0, 0.0, 0.0), density), density ); color = vec4<f32>( color.rgb*color.a, color.a ); res += color*(1.0-res.a); }
var ro = vec3<f32>(0, 0, 2); // ray origin that represents camera position var rd = normalize(vec3<f32>(uv, -1)); // ray direction
var fragColor = rayMarch( ro, rd );
// Output to screen return vec4<f32>(fragColor); }
You can tweak the smoothnoise function a little to increase the noise frequency, these two lines at the top (isntead of `3` you could use `6` or higher). However, it might add more fidelity to the result - it not the result we're looking for - shifting the noise to be more coarse (less smooth).
fn randomsmooth( st:vec2<f32> ) -> f32 { var i = floor( st * 6.0 ); // Change 3 to 6 var f = fract( st * 6.0 ); // ...
Modifying the fidelity in the 'randomsmooth(..)' function.
Fractal Noise
The solution to our problems is fractals - fractals let us generate noise patterns that resemble noise in nature! There are lots of different types of fractal algorithms - the one that we'll use here is the `brownian` fractal noise algorithm - it works by taking multiple samples of the noise function.
Instead of using the smoothed noise function as it is - instead it will take multiple samples and combine them together (each sample will have a different seed offset). Creating a smoothed noise function but with fractal characteristics (repeating nested components).
Fractal noise combined with a sdf sphere.
First, let's extend the
smoothnoise(..)
so it takes a `vec3` instead of a `vec2` so can produce random noise in the x, y and z.
fn noise( x:vec3<f32> ) -> f32 { var p = floor(x * 1.0); var f = fract(x * 1.0); f = f*f*(3.0-2.0*f);
var uv = (p.xy+vec2(37.0,239.0)*p.z) + f.xy; var texx = randomsmooth( uv ); var texy = randomsmooth( 2.182983*uv );
return mix( texx, texy, f.z ) * 2.0 - 1.0; }
Second, let's make our fractal brownian noise function (
fbm(..)
).
fn fbm( p:vec3<f32> ) -> f32 { var uTime = mytimer * 0.8; var q = p + uTime * vec3(0.2, -0.1, 0.0); var g = noise(q );
var f = 0.0; var scale = 0.25; var factor = 2.02;
for (var i:i32 = 0; i < 6; i++) { f += scale * noise(q ); q *= factor; factor += 0.21; scale *= 0.5; }
return f; }
Animating the cloud can be done by adding a small offset to the seed - for example
mytimer
uniform is passed to the shader - this is incremented each frame. Adding it to the noise creates a scrolling effect - like the cloud is getting blown by the wind.
Adding Light
The clouds starting to look nice - it's fluffy and it's animated - but it lacks a bit of `depth` - which lighting will fix.
Add some lighting to the noise cloud.
Add a constant to define the suns location - which we can use to calculate the direction from the point of intersection to the sun. This is done in two places - in the
fn rayMarch( ro:vec3<f32>, rd:vec3<f32> ) -> vec4<f32> { let MAX_STEPS = 100; let MARCH_SIZE = 0.08; var depth = 0.0; var p = ro + depth * rd;
var res = vec4<f32>(0.0);
for (var i:i32 = 0; i < MAX_STEPS; i++) { var density = sdfScene( p );
// We only draw the density if it's greater than 0 if (density > 0.0) { // Directional lighting - diffuse lighting var sunDirection = normalize(SUN_POSITION); var diffuse = clamp((sdfScene(p) - sdfScene(p + 0.3 * sunDirection)) / 0.3, 0.0, 1.0 ); var lin = vec3(0.60,0.60,0.75) * 1.1 + 0.8 * vec3(1.0,0.6,0.3) * diffuse; var color = vec4(mix(vec3(1.0, 1.0, 1.0), vec3(0.0, 0.0, 0.0), density), density ); color = vec4<f32>( color.rgb*lin, color.a ); color = vec4<f32>( color.rgb*color.a, color.a ); res += color*(1.0-res.a); }
var ro = vec3<f32>(0, 0, 2); // ray origin that represents camera position var rd = normalize(vec3<f32>(uv, -1)); // ray direction
// Sun and Sky var sunDirection = normalize(SUN_POSITION); var sun = clamp(dot(sunDirection, rd), 0.0, 1.0 ); // Base sky color var color = vec3(0.7,0.7,0.90); // Add vertical gradient color -= 0.3 * vec3(0.90,0.75,0.90) * rd.y; // Add sun color to sky color += 0.5 * vec3(1.0,0.5,0.3) * pow(sun, 10.0);
// Output to screen return vec4<f32>(fragColor); }
Shape of Clouds
Up until now, we've just been adding the noise to a sphere - to create a `roundish` cloud shape - however, the real beautiy of the algorithm is we can swap in other sdf functions or build or won. For example, instead of the sphere - lets create a sdf function for the letter A. This can be constructed from simpler SDF functions (box and capsules).
SDF function for the letter 'A' - cloud in this shape instead of a sphere. The left side shows the SDF function with 'no' nois added- the right shows with the noise - to create the cloud-like effect.
... fn sdfCapsule(p: vec3<f32>, a: vec3<f32>, b: vec3<f32>, r: f32) -> f32 { var ab = b - a; var ap = p - a;
var t = dot(ab, ap) / dot(ab, ab); t = clamp(t, 0.0, 1.0);
fn randomsmooth( st:vec2<f32> ) -> f32 { var i = floor( st * 2.0 ); // uv - 0, 1, 2, 3, var f = fract( st * 2.0 ); // uv - 0-1, 0-1, 0-1, 0-1
// Four corners in 2D of a tile var a = random(i); var b = random(i + vec2<f32>(1.0, 0.0)); var c = random(i + vec2<f32>(0.0, 1.0)); var d = random(i + vec2<f32>(1.0, 1.0));
// Ease-in followed by an ease-out (tweening) for f // f = 0.5 * (1.0 - cos( 3.14 * f ) ); // version without cos/sin // f = 3*f*f - 2*f*f*f;
f = 3*f*f - 2*f*f*f;
// bilinear interpolation to combine the values sampled from the // four corners (a,b,c,d), resulting in a smoothly interpolated value.
// Interpolate Along One Axis - interpolate between `a` and `b` using the fractional coordinate `f.x`, // then interpolate between `c` and `d` using the same `f.x`. // This gives two intermediate values, say `e` and `f`.
// Interpolate Along the Other Axis - linearly interpolate between `e` and `f` // using the fractional coordinate `f.y`. // Final interpolation gives a moothly interpolated value across the square
var x1 = mix( a, b, f.x ); var x2 = mix( c, d, f.x );
var y1 = mix( x1, x2, f.y );
return y1; }
fn noise( x:vec3<f32> ) -> f32 { var p = floor(x * 1.0); var f = fract(x * 1.0); f = f*f*(3.0-2.0*f);
var uv = (p.xy+vec2(37.0,239.0)*p.z) + f.xy; var texx = randomsmooth( uv ); var texy = randomsmooth( 2.182983*uv );
return mix( texx, texy, f.z ) * 2.0 - 1.0; }
fn fbm( p:vec3<f32> ) -> f32 { var uTime = mytimer * 0.8; var q = p + uTime * vec3(0.2, -0.1, 0.0); var g = noise(q );
var f = 0.0; var scale = 0.25; var factor = 2.02;
for (var i:i32 = 0; i < 6; i++) { f += scale * noise( q ); q *= factor; factor += 0.21; scale *= 0.5; }
fn rayMarch( ro:vec3<f32>, rd:vec3<f32> ) -> vec4<f32> { let MAX_STEPS = 100; let MARCH_SIZE = 0.08; var depth = 0.0; var p = ro + depth * rd;
var res = vec4<f32>(0.0);
for (var i:i32 = 0; i < MAX_STEPS; i++) { var density = sdfScene( p );
// We only draw the density if it's greater than 0 if (density > 0.0) { // Directional lighting - diffuse lighting var sunDirection = normalize(SUN_POSITION); var diffuse = clamp((sdfScene(p) - sdfScene(p + 0.3 * sunDirection)) / 0.3, 0.0, 1.0 ); var lin = vec3(0.60,0.60,0.75) * 1.1 + 0.8 * vec3(1.0,0.6,0.3) * diffuse; var color = vec4(mix(vec3(1.0, 1.0, 1.0), vec3(0.0, 0.0, 0.0), density), density ); color = vec4<f32>( color.rgb*lin, color.a ); color = vec4<f32>( color.rgb*color.a, color.a ); res += color*(1.0-res.a); }
var ro = vec3<f32>(0, 0, 5); // ray origin that represents camera position var rd = normalize(vec3<f32>(uv, -1)); // ray direction
// Sun and Sky var sunDirection = normalize(SUN_POSITION); var sun = clamp(dot(sunDirection, rd), 0.0, 1.0 ); // Base sky color var color = vec3(0.7,0.7,0.90); // Add vertical gradient color -= 0.3 * vec3(0.90,0.75,0.90) * rd.y; // Add sun color to sky color += 0.5 * vec3(1.0,0.5,0.3) * pow(sun, 10.0);
// Output to screen return vec4<f32>(fragColor); }
This is only the beginning of the journey - you can keep enhancing the lighting and style of the clouds - also optimizing the algorithm so it runs smoother.
Cloud writing - letter 'B'.
Things to Try
• Other noise functions
• Other fractal algorithms
• Cloud colors (not just gray but other mixes)
• Mixing in `lightning` effects so the cloud flashes
• Multiple clouds (e.g., `mod` function)
• Complex SDF functions - e.g., cloud that looks like a car or a human head
• Mouse interaction - add forces to the direction of the movement
• Add the full alphabet of letters (capitals) - spell out words in clouds
• Try other lighting algorithms
The algorithm can also be modified to other noise functions, such as `fire` and `flames` using a different fractal noise function and colors, example shown here:
• WebGPU Fire Effect [LINK]
Visitor:
Copyright (c) 2002-2025 xbdev.net - All rights reserved.
Designated articles, tutorials and software are the property of their respective owners.