From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
Today we are going to create an animated cloud using a custom shader material, extending the built-in Sprite material of Three.js.
We’ll assume that you are familiar with React (including Hooks), Three.js and React-Three-Fiber. If not, you might find this article that I wrote as a beginner’s intro to the library helpful as a quick start.
The technique that we’ll explain was used in two recent projects made at Low:
We won’t cover the other elements, like the background, and we also won’t create the most performant cloud because it would get a bit too complex and the purpose of this article is to get you familiar with the technique while keeping it simple.
Note that the use of React it’s not obligatory here but I started using React-Three-Fiber for all my demos and projects, so I’ve opted to use it here, too.
We will cover two main points in this article:
- How to extend a SpriteMaterial
- How to write the shader of the cloud
Extending the sprite material
Since the goal is not to create a volumetric cloud (a real 3D cloud), I decided to extend a Three.js SpriteMaterial. We can instead leverage the fact that using a Sprite, the cloud will always be facing the camera, independently of the camera position or orientation. So if you move the cloud or move the camera you’ll always see it and it helps to fake the missing of 3D volume (check out the debug mode to get the idea).
Note: If you head to the demo and add
/?debug=true
to the URL it will enable the Orbit Controls which will give you some visual insight into why I decided to use the Sprite material.
There are multiple ways to extend a built-in material of Three.js, and you can find a good explanation in this article by Dusan Bosnjak.
import {ShaderMaterial, UniformsUtils, ShaderLib} from 'three'
import fragment from '~shaders/cloud.frag'
import vertex from '~shaders/cloud.vert'
/**
* We are going to take the uniforms of the Sprite material
* and we'll merge with our uniforms
*/
const myUniforms = useMemo(() => ({
.......
}), [])
const material = useMemo(() => {
const mat = new ShaderMaterial({
uniforms: {...UniformsUtils.clone(ShaderLib.sprite.uniforms), ...myUniforms},
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
})
return mat
}, [])
We need to compose our vertex shader, adding the necessary #include
code snippets. If you are interested in how materials are built in Three.js you can have a look at the source code.
uniform float rotation;
uniform vec2 center;
#include <common>
#include <uv_pars_vertex>
#include <fog_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
varying vec2 vUv;
void main() {
vUv = uv;
vec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );
vec2 scale;
scale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );
scale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );
vec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;
vec2 rotatedPosition;
rotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;
rotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;
mvPosition.xy += rotatedPosition;
gl_Position = projectionMatrix * mvPosition;
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
#include <fog_vertex>
}
In this way we created a custom Sprite
material. We can achieve the same effect in other ways for sure, but I decided to extend a built-in material because it could be useful in the future to add a custom logic. It’s now time to dig into the fragment
.
Cloud’s fragment
To create the cloud we need two assets. One is the rough starting shape of the cloud, the other one is the starting point of the texture/pattern.
Keep in mind that both of the textures can be created directly in the shader but it will take some GPU calculations. That is fine, but if you can avoid it it’s a good practice to optimise the shader, too.
Using some images instead of creating them with code could save you some computational power.
Sliding textures
First of all let’s create two sliding textures, using the texture image above (uTxtCloudNoise
), that we will use later to handle the alpha channel of the output. These are sliding textures that helps us to create a “fake” noise effect by concatenate, adding and multiply them.
vec4 txtNoise1 = texture2D(uTxtCloudNoise, vec2(vUv.x + uTime * 0.0001, vUv.y - uTime * 0.00014));
vec4 txtNoise2 = texture2D(uTxtCloudNoise, vec2(vUv.x - uTime * 0.00002, vUv.y + uTime * 0.000017 + 0.2));
Noise
We now need some GLSL noise: the Simpex noise and the Fractional Brownian motion (FBM) that allows us to morph the shape and create the vaporous border effect.
Let’s first create the Simplex and FBM noise to distort our UV.
We will use the FBM to achieve the effect for the border of the cloud, to make it like smoke, and we will use the Simplex to do the shape morphing of the cloud.
The distorted UV, now called newUv, will be used during the declaration of the txtShape:
#pragma glslify: fbm3d = require('glsl-fractal-brownian-noise/3d')
#pragma glslify: snoise3 = require(glsl-noise/simplex/3d)
// FBM
float noiseBig = fbm3d(vec3(vUv, uTime), 4)+ 1.0 * 0.5;
newUv += noiseBig * uDisplStrenght1;
//SIMPLEX
float noiseSmall = snoise3(vec3(newUv, uTime)) + 1.0 * 0.5;
newUv += noiseSmall * uDisplStrenght2;
......
vec4 txtShape = texture2D(uTxtShape, newUv);
And this is how the noise looks like:
Mask & Alpha
To create the mask for the cloud we will use the shape texture (uTxtShape) we saw at the beginning and the result of the sliding textures we mentioned earlier.
The following output is the result of the masking only. The border and the shape effect is fine but the internal pattern/color is not:
Now we calculate the alpha used on the sliding textures from before. We’ll use the levels
function, that was taken from here, which is more or less like the Photoshop levels function.
Concatenating the distorted shape (uTxtShape) and the red channel of the sliding textures will give us the external shape and even the internal “cloud pattern” to create a more real look and feel:
vec4 txtShape = texture2D(uTxtShape, newUv);
float alpha = levels((txtNoise1 + txtNoise2) * 0.6, 0.2, 0.4, 0.7).r;
alpha *= txtShape.r;
gl_FragColor = vec4(vec3(0.95,0.95,0.95), alpha);
Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.
Concatenating everything
It’s time now to wrap everything up to display the final output:
void main() {
vec2 newUv = vUv;
// Sliding textures
vec4 txtNoise1 = texture2D(uTxtCloudNoise, vec2(vUv.x + uTime * 0.0001, vUv.y - uTime * 0.00014)); // noise txt
vec4 txtNoise2 = texture2D(uTxtCloudNoise, vec2(vUv.x - uTime * 0.00002, vUv.y + uTime * 0.000017 + 0.2)); // noise txt
// Calculate the FBM and distort the UV
float noiseBig = fbm3d(vec3(vUv * uFac1, uTime * uTimeFactor1), 4)+ 1.0 * 0.5;
newUv += noiseBig * uDisplStrenght1;
// Calculate the Simplex and distort the UV
float noiseSmall = snoise3(vec3(newUv * uFac2, uTime * uTimeFactor2)) + 1.0 * 0.5;
newUv += noiseSmall * uDisplStrenght2;
// Create the shape (mask)
vec4 txtShape = texture2D(uTxtShape, newUv);
// Alpha
float alpha = levels((txtNoise1 + txtNoise2) * 0.6, 0.2, 0.4, 0.7).r;
alpha *= txtShape.r;
gl_FragColor = vec4(vec3(0.95,0.95,0.95), alpha);
}
Final thoughts
Keep in mind that this is not the most performant way to create a cloud, but it’s a simple one. Using noise functions is expensive, but for the sake of this tutorial it should suffice.
If you have any thoughts, improvements or doubts, please feel free to write to me in Twitter, I’ll be happy to help.
Nice work!