Three.js comes with many materials built in. All these materials drawn in WebGL utilize shaders. Shaders are small programs that run on the GPU written in GLSL. We can create our own custom materials in Three.js by writing our own shaders and passing them into a ShaderMaterial, which we can then use in our scene.

There are two types of shaders. Vertex shaders, which handle the processing of individual vertices. This influences the shape of the geometry. Fragment shaders, take the rasterized geometry from the vertex shader and applies color to it. Between these two shaders we can influence how objects are drawn both their physical geometry and their color and texture.

GLSL is it's own language and we're going to look at it and the code that make up these two different shader types. It is a high level typed programming language similar to C/C++. To learn more specifically about the syntax, type definitions, and how to use more deeply you can read the documentation and see examples people have written on GLSL Sandbox. Some basic variable types it contains are:

  • float - single-precision floating point number
  • vec2 - a vector containing two floats
  • vec3 - a vector containing three floats
  • vec4 - a vector containing four floats
  • mat2 - a matrix containing two columns and two rows
  • mat3 - a matrix containing three columns and three rows
  • mat4 - a matrix containing four columns and four rows

Three special types of variables that we will focus on for the purpose of this post are attribute, varying, and uniform. These can generally be any of the above values types with some limitations on varyings.

  • attributes - Attributes are per vertex properties. These are only defined in the vertex shader.
  • varying - This is an interface between the vertex and fragment shader. Defining a varying variable in a vertex shader allows you to access that value in the fragment shader.
  • uniforms - Uniforms are variables that will change outside of the shader. They are a way of updating and passing values into your vertex and fragment shaders from your main application.

Three.js has two types of shader materials the one we will focus on is ShaderMaterial which has some helpful built-in uniforms and attributes passed into our shaders. Things like projectionMatrix and modelViewMatrix, see a full list here. The other shader material RawShaderMaterial is basically the same but does not have these built-in variables, which means if you need access to these values you'll need to set up your own uniforms and attributes.

Vertex Shader

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

The vertex shader will always set a variable gl_Position. This bit of code is run on every vertex and applies the value of gl_Position, which is a vec4, to each one. The variables we are using are built-in Three.js variables. modelViewMatrix is camera position and orientation within the scene. position is a vec3 of each vertex position in space. projectionMatrix the projection fo the scene from the camera including field of view. This gets multiplied by the modelViewPosition to create the final value of the vertex from our camera. This is a basic example of a vertex shader in Three.js that will draw our objects as we've defined them relative to our camera.

Fragment Shader

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

The fragment shader will always set a variable gl_FragColor. This bit of code is applied to every pixel on the fragment of our geometry drawn. It takes a type vec4 and this consists of four floats representing the RGBA (Red Green Blue and Alpha) colors. The example above will draw everything red since the red value is set to 1 and the alpha value is set to 1.

ShaderMaterial

Now that we have our shaders it's time to turn them into a material we can use in Three.js. We pass the shaders in as strings to our material. This means we can include them any number of ways. We can put them in our document inside of a script tag, we could have them as a literal string in our javascript or we could load the string from an external file. We then pass these strings in as parameters to our shader material.

var material = new THREE.ShaderMaterial({
	vertexShader: document.getElementById( 'vertexShader' ).textContent,
	fragmentShader: document.getElementById( 'fragmentShader' ).textContent
});

This material can then be used as any other material in Three.js.

Dynamic Values

So we've created a simple ShaderMaterial, but it's pretty static. So let's pass some variables into our shaders and add some math to make things more interesting.

var customUniforms = {
	delta: {value: 0}
};

var material = new THREE.ShaderMaterial({
	uniforms: customUniforms,
	vertexShader: document.getElementById( 'vertexShader' ).textContent,
	fragmentShader: document.getElementById( 'fragmentShader' ).textContent
});


var vertexDisplacement = new Float32Array(geometry.attributes.position.count);

for (var i = 0; i < vertexDisplacement.length; i ++) {
	vertexDisplacement[i] = Math.sin(i);
}

geometry.addAttribute('vertexDisplacement', new THREE.BufferAttribute(vertexDisplacement, 1));

In our uniforms object, we have a delta uniform with a value of zero and we'll update this later. Then we simply pass our uniform object into our ShaderMaterial. In this case it's just a float, but we can also pass in other value types using some types provided in Three.js. For example, if we want a vec2 we can set the value to new THREE.Vector2(). For a listing of all the uniform types available in Three.js see the documentation.

Attributes work a bit differently. Because attributes only effect the vertex shader they are actually applied to geometry. In Three.js they must be applied to BufferGeometry and we create a Float32Array the size of the number of vertices in the geometry. Then for each vertex we can assign a value to our array. Then we add the attribut to the geometry by calling addAttribute which takes the attribute name and the BufferAttribute as parameters. The BufferAttribute takes the array and the number of items per vertex, because we just have a float it is only one, but if it were a vec3 it would be 3.

vertex shader
attribute float vertexDisplacement;
uniform float delta;
varying float vOpacity;
varying vec3 vUv;

void main() 
{
    vUv = position;
    vOpacity = vertexDisplacement;

    vec3 p = position;

    p.x += sin(vertexDisplacement) * 50.0;
    p.y += cos(vertexDisplacement) * 50.0;

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

In our vertex shader we first define the vertexDisplacement attribute we attached to the geometry. We then define the delta uniform. Then we're creating two varyings that we will pass along to the fragment shader. The first is called vOpacity and this will be used to manipulate the opacity of our fragment shader which we set to the same value as our vertexDisplacement attribute. The other is vUv which we set to collect the position of each vertex. After setting both of our varying values we then play around with the position of the vertices some on the x and y axis based on our vertexDisplacement value.

fragment shader
uniform float delta;
varying float vOpacity;
varying vec3 vUv;

void main() {

    float r = 1.0 + cos(vUv.x * delta);
    float g = 0.5 + sin(delta) * 0.5;
    float b = 0.0;
    vec3 rgb = vec3(r, g, b);

	gl_FragColor = vec4(rgb, vOpacity);
}

In our fragment shader we define our delta uniform. Then define the same varyings we created in the vertex shader. We use vOpacity to be the resulting alpha of our gl_FragColor. And we use the delta and vUv values along with some math to adjust the red and green values.

We can continue to update our uniforms and attributes in a loop as shown below.

delta += 0.1;

mesh.material.uniforms.delta.value = 0.5 + Math.sin(delta) * 0.5;

for (var i = 0; i < vertexDisplacement.length; i ++) {
    vertexDisplacement[i] = 0.5 + Math.sin(i + delta) * 0.25;
}

mesh.geometry.attributes.vertexDisplacement.needsUpdate = true;

We simply update the uniform by directly accessing the uniform value we want to update and setting it to a new value. For the attribute we need to iterate through the array and adjust the values. Then we need to call needsUpdate on the attribute in the geometry.

Click here to download the demo.

Note: these examples were created using Three.js v79. Three.js is known to update frequently and sometimes cause breaking changes to the API so it may be worth checking the version you are using.