Shaders: The GPU Programs Behind WebGL
Three.js and other WebGL libraries do a great job of hiding complexity, but there will come a time when you need a specific effect or want to understand what the GPU is actually doing. At that point, you'll encounter shaders. They run entirely on the graphics card, freeing the CPU for other tasks, and they are split into two types: vertex shaders and fragment shaders.
Vertex Shaders: Positioning Geometry
A 3D shape is composed of vertices. The vertex shader processes each vertex one at a time, and its primary job is to set gl_Position, a 4D float vector that represents the vertex’s final position on the 2D screen. This involves projecting a 3D point onto a 2D viewport. Using Three.js simplifies this step, as it provides handy built-in uniforms to handle the projection math for you.
Fragment Shaders: Coloring Pixels
Once vertices are placed, something has to determine what color each resulting pixel on the screen should be. This is the fragment shader’s role. Its only required task is to set or discard the gl_FragColor variable, another 4D float vector that defines the final color of the pixel.
A fragment is essentially the per-pixel data needed to draw a shape. Consider a triangle: every pixel within that triangle gets drawn based on interpolated data from its three vertices. If one vertex is red and another is blue, the colors will smoothly blend from red, through purple, to blue across the triangle's surface.
Shader Variables: Uniforms, Attributes, Varyings
Shaders use three distinct types of variables, each with a specific scope:
- Uniforms: Values that remain constant for the entire frame. They are available to both the vertex and fragment shaders. A light’s position is a common example.
- Attributes: Values that are tied to individual vertices, with a one-to-one relationship between each vertex. Attributes are only accessible in the vertex shader, such as a unique color per vertex.
- Varyings: Variables declared in the vertex shader intended to share data with the fragment shader. You achieve this by declaring a varying with the same name and type in both shaders. A vertex's normal, used for lighting calculations, is a typical use case.
Hello, World
Here are the simplest shaders you can create. The vertex shader uses a couple of uniforms injected by Three.js: the 4D matrices Model-View and Projection. These are responsible for projecting the 3D vertex positions to 2D screen coordinates. Without Three.js, you would need to create and manage these uniforms yourself.
/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
/**
* Set the color to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}
Using a MeshShaderMaterial
To apply these shaders to an object in Three.js, you simply assign them to a material and attach it to your mesh. The library handles the rest, compiling and running your shaders on the GPU.
/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader: $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});
You can also pass uniforms and attributes to the material. Uniforms are single values, the same for the whole frame. Attributes, being per-vertex, are arrays. The number of values in each attribute array must correspond exactly to the number of vertices in the mesh.
Simulating a Light
Now we can move beyond flat coloring. Rather than using a full lighting model, we can fake a directional light. By declaring a varying in the vertex shader, we can pass the vertex normal to the fragment shader.
// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;
void main() {
// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
In the fragment shader, we use the dot product of that normal with a vector representing light coming from above and to the right of the object. Dot products measure how similar two vectors are: for normalized vectors, pointing in the same direction yields 1, opposite directions yield -1. Clamping the result to 0 gives you a shaded sphere where surfaces facing the light appear bright and others fade to black.
// same name and type as VS
varying vec3 vNormal;
void main() {
// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
// ensure it's normalized
light = normalize(light);
// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));
// feed into our frag color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
Displacing Vertices with Attributes
The next step is manipulating vertex positions. By attaching a random number to each vertex, we can push them outward along their normals to create an irregular, spiky sphere. This begins with declaring the attribute in the vertex shader.
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
normal *
vec3(displacement);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
The object won't change immediately because the attribute isn't defined yet in the material, acting as a placeholder. You can't reuse the original attribute variable directly, as attributes are read-only, so the code assigns the updated position to a new vec3 variable.
With the shader updated, you must populate the attribute in the material with the necessary per-vertex data.
var attributes = {
displacement: {
type: 'f', // a float
value: [] // an empty array
}
};
// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}
This creates a distorted sphere, with all the geometric changes occurring on the GPU.
Bringing It to Life
To animate the displacement, you need a uniform to control the amplitude frame-by-frame and an animation loop. A sine or cosine function is perfect for creating an oscillating effect.
First, add the uniform to the vertex shader so it can scale the displacement.
uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
normal *
vec3(displacement *
amplitude);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
Then, update the material to include that uniform, setting its initial value to 0. Without updating it later, there will be no visible change.
// add a uniform for the amplitude
var uniforms = {
amplitude: {
type: 'f', // a float
value: 0
}
};
// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms: uniforms,
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
Finally, the JavaScript animation loop updates the uniform's value every frame using requestAnimationFrame, creating a smooth, pulsating effect.
var frame = 0;
function update() {
// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;
renderer.render(scene, camera);
// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);
This combination of a vertex shader that displaces positions, a fragment shader that computes simple lighting, and shared varying and uniform variables working together on the GPU gives you practical insight into the core concepts behind any shader pipeline.



