Show/Hide Mobile Menu

Shaders in Three.js

02.01.2022

Shaders are programmes that run on the GPU. The reason they're called shaders is that originally they just handled the shading of 3D objects but have since expanded beyond that. They require a different mindset than conventional programming because the programmes are run in parallel for each vertex or pixel. WebGL and OpenGL use a language called GLSL which stands for OpenGL Shader Language and is similar to C. The easiest way to add shaders in Three.js is to use ShaderMaterial. There is also RawShaderMaterial which doesn't include some Three.js GLSL code. In WebGL you have vertex and fragment shaders. In vertex shaders you can manipulate the vertices of the geometry and in fragment shaders you can manipulate the pixels of the rendered triangles. In real-time graphics everything is reduced down to triangles. The vertex shader returns a 2D position, so it projects from 3D to 2D using a projection matrix. A basic vertex shader will look like this:

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

The function has to be called main and you have to set the built-in variable gl_Position. projectionMatrix, modelViewMatrix and position are supplied by Three.js. You read matrix multiplication from right to left. modelViewMatrix is the view matrix multiplied by the model matrix. The view matrix is the inverse transformation of the camera - moving the camera is the same as moving the model in the opposite direction. The model matrix is the transformation done on the model. The projectionMatrix projects from 3D to 2D.

A basic fragment shader will look like this:

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

Again the function needs to be called main and it needs to set a built-in variable gl_FragColor. In this instance I'm setting every pixel to red. To create a ShaderMaterial you specify the vertex and fragment shaders as strings. If you exclude either Three.js will use a default shader.

const vertexShader = /*glsl*/`
void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragmentShader = /*glsl*/`
void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;

const material = new ShaderMaterial({
  fragmentShader: fragmentShader,
  vertexShader: vertexShader
});

In the above code the /*glsl*/ comment before the string adds syntax highlighting in VS Code if you install Comment tagged templates and Shader language support plugins. Template literals i.e. backticks are used so you can have multiple lines in the string. You could also include the GLSL in script tags in the HTML file:

<script id="fragment-shader" type="x-shader/x-fragment">
void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
</script>

And access the DOM element in JavaScript:

const material = new ShaderMaterial({
  fragmentShader: document.getElementById('fragment-shader').textContent
});

Or you could keep the shaders in separate files and import them when using a bundler like Webpack or Parcel. For Webpack you will need to install raw-loader and for Parcel you will need to install @parcel/transformer-glsl plugin.

Information is passed from the vertex shader to the fragment shader and in the fragment shader the values are interpolated depending on where the pixel is located relative to the vertices. For example if you assign a color to each vertex and pass it down to the fragment shader, the pixel colors will be interpolated from the vertex colors.

Shaders have a few different type of variables:

  • Uniforms: These are the same across all GPU threads e.g. current time. You set these in the ShaderMaterial.
  • Varyings: These vary on each GPU thread. You use these to pass values from the vertex shader to the fragment shader e.g. UVs (texture mapping coordinates). You set these inside the vertex shader.
  • Attributes: This is the data loaded into vertex shaders e.g. the position of the vertices. Three.js usually handles these for you unless you want to add custom attributes or want to specify the vertices manually. You set these on the geometry.

Uniforms

This is a way for you to pass data from JavaScript to the shader.

this.material = new ShaderMaterial({
  uniforms: {
    uTime: { value: 0 }
  },
  fragmentShader: fragmentShader
});

And then in the render function you can update the value:

render() {
  this.material.uniforms.uTime.value++;
  this.renderer.render(this.scene, this.camera);
}

Inside either the vertex or fragment shader you would access it like this:

uniform float uTime

void main() {
  gl_FragColor = vec4(vec3(abs(sin(uTime))), 1.0);
}

Varyings

These are useful for passing values from the vertex shader to the fragment shader. Below I'm setting a varying to a Three.js built-in variable:

varying vec2 vUv;

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

And I access it in the fragment shader:

varying vec2 vUv;
uniform sampler2D uImage;

void main() {
  gl_FragColor = texture2D(uImage, vUv);
}

To set a texture in JavaScript:

const image = new Image();
image.onload = function() {
  const texture = new Texture(image);
  const material = new ShaderMaterial({
    uniforms: {
      uTexture: { value: texture }
    },
    fragmentShader
  });
}
image.src = '/images/some_image.jpg';

I prefix uniforms with u and varyings with v though it's not required.

Attributes

When setting an attribute, Three.js expects a flat typed array. Typed arrays were added to JavaScript because of WebGL. BufferGeometry provides position, normal and uv attributes by default. If you want to use vertex colors you will need to set an attribute called color and set vertexColors to true in the material's options.

const geometry = new BufferGeometry();
const displacement = new Float32Array([0, 0.5, 1]);
geometry.setAttribute('displacement', new BufferAttribute(displacement, 1));

It is possible to add a standard JavaScript array and have Three.js convert it to a typed array. You might want to do this if you are constructing the array programmatically:

const vertices = [];
for (let i = 0; i < 3; i++) {
  vertices.push(Math.cos(i), Math.sin(i), 0);
}
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));

To access the attribute in the shader:

attribute float displacement;

void main() {
  vec3 newPosition = position + normal * vec3(displacement);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}

GLSL Modules

Node modules are very useful when writing JavaScript. There isn't an official module system for GLSL but there is an unofficial one called Glslify. First you need to install the Glslify Node module along with the GLSL module:

npm install glslify glsl-noise

You wrap the shader in a glsl function and import the GLSL module inside the shader:

import glsl from 'glslify';

const fragmentShader = glsl(`
  #pragma glslify: noise = require('glsl-noise/simplex/3d');

  varying vec3 vPosition;

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

Webpack

There is a Glslify loader for webpack so you can use Glslify in external shader files.

npm install raw-loader glslify-loader

And the Webpack config looks like this:

module: {
  rules: [
    {
      test: /\.(glsl|vs|fs|vert|frag)$/,
      exclude: /node_modules/,
      use: ['raw-loader', 'glslify-loader']
    }
  ]
}

Parcel

Parcel supports Glslify via the @parcel/transformer-glsl plugin.

Example

Below is an example of a sphere that has a vertex and fragment shader. The vertex shader is displacing the vertices using a noise function and the fragment shader is mixing red, green and blue again using a noise function. By default the vertices in a geometry are disconnected. If you move a vertex it will only move it for the face it's attached to so the faces become disconnected. To keep the faces connected you have to convert the vertices to indexed vertices i.e. the same vertex is referred to by index rather than duplicating it for each face. One advantage of having your shader variables as uniforms is that you can attach a GUI and tweak them. You can view the source code for the example here.

Where to go next?

This should get you started with shaders in Three.js. Programming shaders is different to sequential programming. Because everything is running in parallel you tend to have to use maths to calculate things rather than using if/then. The Book of Shaders is a great interactive introduction to the topic.