three.js tutorials

Rendering snow with shaders

In this tutorial I'm going to show you how to render a snow simulation with three.js and WebGL shaders like the following:

If you can't see a heavy snow shower in the above frame and get a black frame or a bold red error message, then either your browser does not support WebGL or there's something wrong with your configuration. Please go to the Get WebGL page and follow their advice.

At the end of the tutorial you will know how to build a particle system with 10,000 (ten thousand!) particles simulating snow, all running in your GPU, which means the CPU will be almost totally free for your JavaScript code. Nice, isn't it?

We'll start by building a simple THREE.ParticleSystem and updating its particles' position with JavaScript, and then will change to using shaders in order to get the best performance.

SOURCE CODE: The source code for each step is in github.

So keep reading if you'd like to learn how to do this!

Step 1: a plain THREE.ParticleSystem

For the first step we'll be aiming at a very simple system of white particles. And to keep things even simpler, there's no user interaction: the camera moves continuously around the particles, which don't move downwards yet.

SOURCE CODE: HTML | JS

Let's go through the most interesting bits of the code:


var numParticles = 100,
    width = 100,
    height = 100,
    depth = 100,
    systemGeometry = new THREE.Geometry(),
    systemMaterial = new THREE.ParticleBasicMaterial({ color: 0xFFFFFF });

So far we've defined a series of numeric variables (numParticles, width, height and depth) that we'll use later--it's a good practice to store these values in variables instead of hardcoding them in the code. It makes them more visible and it's easier to play with the values if they all are located in the same place.

We've also created the system's geometry (systemGeometry) and material (systemMaterial) objects. The geometry will store the position of each particle--which is equivalent to say that the geometry object stores the position of each snow flake. And evidently we also need to define how do the particles look, which is what systemMaterial is for: describing the appearance of the flakes. In this case we're just telling three.js to render them as pure white --which is 0xFFFFFF in hexadecimal terms.

In the following lines we specify the positions of the particles. This is done by adding one vertex per particle to the geometry object. Notice how the vertex is created, and then stored on the vertices property, using push.


for( var i = 0; i < numParticles; i++ ) {
    var vertex = new THREE.Vector3(
        rand( width ),
        rand( height ),
        rand( depth )
    );

    systemGeometry.vertices.push( vertex );
}

We are creating a vertex and then giving it random width, height and depth values by means of the rand() function. This is not a native JavaScript or Three.js function, but one defined by us, which simply returns random values in the (-value/2, value/2) range if you call it with rand(value). This is way shorter than typing something akin to (value * (Math.random() - 0.5)) each time!

At this point we've got two objects describing the particle system properties: systemGeometry and systemMaterial, but no system yet. We'll create it now:


particleSystem = new THREE.ParticleSystem( systemGeometry, systemMaterial );

And we still have to add it to the scene, so that three.js paints it next time the scene is rendered:


scene.add( particleSystem );

In the next step we will animate the particles so that they move downwards.

Step 2: a naive approach to animating the particle system

Now that we've got this particle system, the next thing we'd like to do is make them move downwards, right? After all, that's what makes snow flakes enticing: they fall gracefully, at peace with themselves. Mathematically, that means that their positions' y value decreases on each animation frame, until they reach zero and then they are no longer animated, because they are in the floor.

There's an easy way to do this: since the position of each snow flake is stored in the systemGeometry.vertices array, we can iterate through it on each frame, updating the y values. Then we let Three.js know that the geometry has been modified, and we'll get a particle system with particles moving downwards as below:

SOURCE CODE: HTML | JS

In this step, we have started using an instance of THREE.Clock. It provides nice utility functions for keeping track of how much time has passed, both since we first start keeping track of time (getElapsed()), and since the last time we asked how much time has passed (getDelta()).

Creating an instance is pretty easy:


clock = new THREE.Clock();

And then we can use it in the animate function:


var delta = clock.getDelta(),
    t = clock.getElapsedTime() * 0.5;

There's a catch here: if you have to use both getDelta and getElapsedTime, you must call getDelta before calling getElapsedTime. Otherwise, getDelta will return 0, because getElapsedTime internally calls getDelta! It happened to me, so be careful when using the Clock--it might betray you! ;-)

We also introduced the updateParticleSystem function which is called on every frame to update the positions of the particles:


function updateParticleSystem( elapsed ) {

    var geometry = particleSystem.geometry,
        vertices = geometry.vertices,
    numVertices = vertices.length,
    speedY = 10 * elapsed;

    for(var i = 0; i < numVertices; i++) {
    var v = vertices[i];

    if( v.y > 0 ) {
            v.y -= speedY * Math.random();
    } else {
        v.y = particleSystemHeight;
    }
    }

    geometry.verticesNeedUpdate = true;

}

See how we're decreasing the particles' position.y values and repositioning them on top of the system (at y == particleSystemHeight) when they reach the bottom?

But none of that is effective unless the last line is present:


geometry.verticesNeedUpdate = true;

Without it, three.js won't send the new positions to the GPU, and you'll be pulling your hair trying to find out what is wrong with your code and why nothing changes in screen :-)

Building and animating a particle system this way is pretty much the only way that you can follow if using the CanvasRenderer, but it's not the best practice when using the WebGLRenderer. In the next step we'll start writing our own snow shader which will be faster and more efficient.

Step 3: starting with WebGL shaders

The problem with the approach we've followed until now is that it only works with very small amounts of particles, because we're doing wrong possibly everything that can be done wrong: we're updating positions in the CPU with a sequential loop, and then asking Three.js to update the geometry, which means that it needs to tell the GPU to forget everything it knew about the existing particle system's geometry, and send the new values to the GPU.

If you know a little bit about GPUs, I bet all your alarm bells were ringing frantically while reading the last paragraph: GPUs are so good at making calculations in parallel, and we're doing them sequentially in the CPU! And GPUs are so bad at receiving and munching data from the CPU, it's such a bottleneck! And we're doing it per frame! What a waste!

I guess you understand now why I say that the way we've been following is not the right one! So let's fix it: let's use shaders! We'll write the shader, use it for the particle system, and then leave the GPU alone, which frees the CPU from the JavaScript calculations we were doing per frame, and thus the interface won't appear jerky, even with thousands of particles.

A shader is actually composed of two parts: the vertex shader and the fragment shader. Roughly speaking, we could say that they mimic the separation between geometry and material: we'll modify the position of the geometry vertices in the vertex shader, and we'll deal with their appearance in the fragment shader.

We'll start by writing a very basic shader: it will simply render the particles in white. For now just try to follow along; if you don't understand something, it's OK--I'll go into more details progressively.

The vertex shader will just calculate and assign the position of each vertex to the gl_Position variable, without further changes:


void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}

Vertex shaders are called for each vertex, and amongst other things they can do, they also must set the gl_Position, so that WebGL uses such position to draw the vertices later on. (NOTE: since this is a somewhat basic tutorial, I'm not going to explain the matrix math magic behind the code above, as it would require a tutorial on its own!).

On the other hand, the fragment shader is called for each pixel to be drawn, and can do many things, but must always assign a value to the gl_FragColor variable. We'll tell it to assign white for the time being:


void main() {
    gl_FragColor = vec4( 1.0, 1.0, 1.0, 1.0 );
}

Notice how white is (1.0, 1.0, 1.0, 1.0) in RGBA terms, just like 0xFFFFFF in hexadecimal terms.

The most substantial change is in the way we define systemMaterial. Instead of using an instance of THREE.ParticleBasicMaterial, as we had been doing until now, we use an instance of THREE.ShaderMaterial, and refer to the shaders we want to use:


systemMaterial = new THREE.ShaderMaterial({
    vertexShader: document.getElementById( 'step03_vs' ).textContent,
    fragmentShader: document.getElementById( 'step03_fs' ).textContent
});

Here's the result of our efforts so far:

SOURCE CODE: HTML | JS

You'll also notice that the flakes don't move downwards any more. This is completely intentional, as the updating function has been removed because this will be done in the shader.

We've liberated the CPU from most of the work it was doing so far. Now we need to improve the shader, so we'll start by learning about uniforms and how to send values from JavaScript to WebGL via uniforms.

Step 4: setting parameters with uniforms

With our knowledge so far, if we wanted to change the snow colour while the animation is running, we would have to create a new instance of ShaderMaterial and assign it a different fragment shader which would specify such new colour. But that's a bit overkill for simply changing a colour on the fly!

Fortunately, there's a more efficient way of doing this: uniforms.

You can think of uniforms as a set of variables that can be defined and written from your JavaScript code, and can then be read from the shader code.

The uniforms are defined when you create the material. For example, let's create a material with a uniform, color:


systemMaterial = new THREE.ShaderMaterial({
    uniforms: {
        color:  { type: 'c', value: new THREE.Color( 0xFFFFFF ) }
    },
    vertexShader: document.getElementById( 'step03_vs' ).textContent,
    fragmentShader: document.getElementById( 'step04_fs' ).textContent
})

That's right! Uniforms are defined with a JavaScript Object. Each key is the name of the uniform, and the value is another object that specifies the type of the uniform, and the uniform value. There are several types of uniforms that can be used with Three.js, but for now you just need to know that type 'c' is a colour uniform, and internally gets converted into a vec3 variable (an array with three floats) when Three.js speaks to WebGL. You need to specify the types because the shaders need to be compiled, and thus knowing the type of the uniform is required at compilation time.

Here's the result of introducing uniforms in the fragment shader:

SOURCE CODE: HTML | JS

Nothing looks different, apart from the controls on top, right? Well--try hovering over the value and then changing the colour! The particles will change their colour immediately, because the fragment shader is now aware of the existence of the color uniform:


uniform vec3 color;

void main() {
    gl_FragColor = vec4( color, 1.0 );
}

The uniform is acknowledged in the first line:

glsl uniform vec3 color;

This needs to be done outside main() itself, i.e. in what WebGL calls the global scope.

You might be wondering how do we link the changes in the color picker to changes in the uniform value. This is done with the onParametersUpdate callback: when the controls' values change, onParametersUpdate will update the uniform. Three.js automatically sends these values to the GPU next time that the scene is rendered.


onParametersUpdate = function( v ) {
    systemMaterial.uniforms.color.value.set( parameters.color );
}

controls = new dat.GUI();
controls
    .addColor( parameters, 'color' )
    .onChange( onParametersUpdate );

In the next step we will finally make the snow flakes move downwards!

Step 5: animating the snow flakes with the shader

Very good, I hear you saying, you've turned to using shaders and all that, but the flakes still don't move!.

Let's tackle that.

Since we want the particles to move, we need to change the vertices' position, and we must do that in the vertex shader, which needs to know about a couple of values in order to make its calculations:

  • the height of the particle system
  • the elapsed time
Both values are floating point values, or floats as they are generally referred to, so this is how the materials uniforms section looks like now:

javascript uniforms: { color: { type: 'c', value: new THREE.Color( parameters.color ) }, height: { type: 'f', value: particleSystemHeight }, elapsedTime: { type: 'f', value: 0 } }

See how the type is an f for these new uniforms? f for float. Makes sense, right?

The elapsedTime has to be updated in animate:

javascript particleSystem.material.uniforms.elapsedTime.value = elapsedTime * 10;

We'll use these values in the vertex shader this time:

glsl uniform float height; uniform float elapsedTime; void main() { vec3 pos = position; pos.y = mod(pos.y - elapsedTime, height); gl_Position = projectionMatrix * modelViewMatrix * vec4( pos, 1.0 ); }

The mod function is the key here. It will ensure that the particles y position is always in the (0, height) range, which is more or less functionally equivalent to the if statement we used when we updated the positions in JavaScript:

javascript if( v.y > 0 ) { v.y -= speedY * Math.random(); } else { v.y = particleSystemHeight; }

You can check for yourself that we're using the uniform value here by experimenting with the height slider in the controls.

SOURCE CODE: HTML | JS In the next step we'll make the movements a bit nicer and smoother.

Step 6: making the flakes more snowy

It's nice to see the particles move downwards, but they don't really behave like snow flakes because real snow flakes move slightly horizontally too. We need to add some variation to the x and z axis.

Simulating the real movements of flakes, taking into account wind speed, direction, flake weight, friction, rotational speed and etc. is a bit overkill for our purposes, so let's cheat a bit and use sin and cos for this: with proper tweaking, these functions can generate the sort of smooth, peaceful movements we're after.

The new vertex shader:


uniform float radiusX;
uniform float radiusZ;
uniform float height;
uniform float elapsedTime;

void main() {
    vec3 pos = position;
    pos.x += cos((elapsedTime + position.z) * 0.25) * radiusX;
    pos.y = mod(pos.y - elapsedTime, height);
    pos.z += sin((elapsedTime + position.x) * 0.25) * radiusZ;

    gl_Position = projectionMatrix * modelViewMatrix * vec4( pos, 1.0 );
}

The new uniforms radiusX and radiusZ can be modified with new sliders in the controls. This will allow us to play with the values and get the ones that look best for our concrete case.

SOURCE CODE: HTML | JS

You might have noticed another change: the camera doesn't move around now, so we can better appreciate the effect of the horizontal variation.

In the next step we'll make these mini-snow flakes bigger, and will also add user interaction so we can place ourselves at the center of the snow storm if we fancy such a thing!

Step 7: controlling flakes' size and adding user interaction

Certainly these flakes are as minuscule as they can get, because currently they are only one pixel big! Let's not only make them bigger, but also make them smaller the further away they are, which will provide a nice feeling of perspective.

Apart from gl_Position, the vertex shader can also assign other variables such as gl_PointSize, which is exactly what we need to make the flakes larger!

And to cheat a bit more, let's use the same method that is used in the default shader for particle systems in Three.js:


vec4 mvPosition = modelViewMatrix * vec4( pos, 1.0 );

gl_PointSize = size * ( scale / length( mvPosition.xyz ) );

gl_Position = projectionMatrix * mvPosition;

We're assigning the gl_PointSize variable a value that depends on the distance of such vertex to the viewer. Such distance is calculated with the length function, and then we also use the new size and scale uniforms to control the final size of each flake.

If you want to have a look at the source in Three.js, src/renderers/WebGLShaders.js is what you're after.

Here are the new (and larger) snow flakes:

SOURCE CODE: HTML | JS

Also, you can now move the camera around using the mouse and the mouse wheel. See how flakes change their size depending on where you're placed!

In the next step we'll learn how to make the flakes somewhat transparent so they look subtler.

Step 8: making the flakes transparent

We're getting closer to adding the final touch --i.e., textures!-- but before we do that, we need to add support for opacity in the shader. As it is now, setting the opacity and transparent properties in systemMaterial won't have any effect at all, because we're currently specifying an alpha value of 1.0 in the fragment shader, which to all effects causes things to be fully opaque:

glsl gl_FragColor = vec4( color, 1.0 );

The solution is really immediate and I bet you guessed it already: we need to add another uniform for the opacity! Then we'll use it in place of the current 1.0 value. The new fragment shader looks like this:


uniform vec3 color;
uniform float opacity;

void main() {
    gl_FragColor = vec4( color, opacity );
}

We also need to tell Three.js that we want to render the snow with transparency and additive blending--otherwise nothing will look different. So we'll add these two properties to systemMaterial:


blending: THREE.AdditiveBlending,
transparent: true

Look, transparent snow flakes!

SOURCE CODE: HTML | JS

I know, I know, not too exciting... yet! In the next step we will finally use a texture.

Step 9: adding textures

Because square snow flakes aren't what one would describe as believable, let's add some textures to soften things a little bit!

We're going to cheat again and use one of the textures that come with the Three.js's particle sprites example. This snow flake is drawn by none other than René Descartes!

Loading the texture is really simple with THREE.ImageUtils:


texture = THREE.ImageUtils.loadTexture( 'snowflake1.png' );

As before, the way to let the shader know about this texture is by means of another uniform, this type with type t:


texture: { type: 't', value: texture }

And here's the fragment shader using the texture:


uniform vec3 color;
uniform float opacity;
uniform sampler2D texture;

void main() {
    vec4 texColor = texture2D( texture, gl_PointCoord );
    gl_FragColor = texColor * vec4( color, opacity );
}

Using the special gl_PointCoord variable that WebGL sets for us, we pick the corresponding color value in the texture (texColor) and multiply it with the color and opacity that we defined in previous steps. This allows us to not only adjust the transparency level, but also tint the flakes with any colour!

We also need to disable the depth test in the systemMaterial. Otherwise, we'll see black squares surrounding the snow flakes:


depthTest: false

And finally, we have also increased the particles' default size and scale, so that the texture can be appreciated!

SOURCE CODE: HTML | JS

In the next (and final step) we will not add any new feature, but will simply adjust some parameters and increase the number of particles (from 100 to 10000) so that the effect can be fully appreciated!

Step 10: going further

Now that everything is done in the GPU, we can afford to push the number of flakes way higher. Say: 1000? 10000? No problem! Here they are:

SOURCE CODE: HTML | JS

There are a couple more of parameter tweaks. For example, the camera is closer to the particle system, so that the snow fills the entire screen and we don't see the particles 'pop out' of the top. There are also two new uniforms for altering the vertical and horizontal speed movements of the flakes: speedH and speedV, with their respective controls. Other than that, it's still the same shader code that we used in the previous step, and that demonstrates why parameterising your code instead of hardcoding all values pays off later, as it gives you more room to play and tweak :-)

Credits and further reading

I must give full credit to OutsideOfSociety, whose Stopping by woods on a snowy evening experiment contained a rad snow shader which opened me the eyes and instantly showed me how to build snow shaders with WebGL, and I ended up replacing the JavaScript code I was using in my experiment with a shader too! Be sure to look at the source code as there are many interesting tricks and details that I haven't included here for the sake of simplicity.

If you're interested in learning more about shaders, Paul Lewis wrote a nice introduction that covers way more than I wrote about too.

Looking at the source of three.js's WebGLRenderer and WebGLShaders is also a good way to see how things are done, although it's definitely not for an extreme beginner! :-)