WebGL Animation
Changes over Time
This is a follow-up to WebGL Tutorial WebGL Gradient. We'll start from what we built in the previous tutorial. This time we're going to draw changes over time to create animation.
View the demo or jump ahead and edit the code.
Drawing Repeatedly
To see changes over time, we need to run our WebGL program repeatedly to draw many times a second. We can use
JavaScript's
requestAnimationFrame
function to draw as many frames per second (FPS) as our browser and CPU will allow, typically up to 60 FPS.
The gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
call runs our WebGL
program once and draws the results on the canvas. Let's put that in a draw()
function and have it call itself via requestAnimationFrame
. Then call it once to start the animation loop.
function draw() {
gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
requestAnimationFrame(draw);
}
draw();
What time is it?
Repeatedly drawing is pointless if the shaders draw the same thing every time. To change what they draw, they need to
know what time it is and change their behavior based on the current time. The GPU can't access the system clock
directly, so we need to access it in JavaScript and pass it to our WebGL program. We will use a uniform variable,
similar to canvasSize
. Unlike canvasSize
, our time
variable will only be a single floating point number, which we
define with gl.uniform1f(...)
:
const timeUniform = gl.getUniformLocation(program, "time");
gl.uniform1f(timeUniform, performance.now() / 1000);
The 1f
means a 1D float value, which is a floating point number (not a 1D vector containing a float).
performance.now()
returns the time since the page loaded, in milliseconds. We
convert it to seconds with the / 1000
because I prefer to work with seconds.
We want to continuously update the time, so we'll set the value of timeUniform
inside our
draw()
loop:
const timeUniform = gl.getUniformLocation(program, "time");
function draw() {
gl.uniform1f(timeUniform, performance.now() / 1000);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
requestAnimationFrame(draw);
}
draw();
A rule of thumb is to keep the draw()
loop as lightweight and fast as possible.
Only do what you absolutely need to in there and let the GPU do the heavy lifting.
Functions of Time
We have everything we need to make a shader that animates. We define our time
uniform in the shader, like we did for
canvasSize
, except this is a float
. Let's adjust the gradient fragment shader from the previous tutorial so it only
draws the gradient when the x-coordinate is less than a threshold that varies over time:
uniform vec2 canvasSize;
uniform float time;
out vec4 fragColor;
void main() {
vec2 coord = gl_FragCoord.xy/canvasSize.xy;
if (coord.x < sin(time)) {
fragColor = vec4(coord.x, coord.y, 1.-coord.x, 1);
} else {
fragColor = vec4(1,1,1,1); // white
}
}
This animates the width of the gradient in a loop. sin()
is the
sine function, which, along with cos()
(cosine), is great for creating looping
animations. These functions oscillate between -1 and 1, but our normalized coord
goes from 0 to 1. The negative
sin()
values result in the gradient being invisible for half of the loop. We can shift the range from [-1, 1] to [0,
1] with some arithmetic:
if (coord.x < (sin(time) + 1.)/2.) { // oscillate between 0 and 1
Now the gradient is visible at all times (except the moment when (sin(time)+1.)/2.
is
0). Shifting, stretching, and otherwise transforming ranges of numbers like this happens a lot in graphics programming.
Stretching the Gradient
At this point you know how to animate shaders. The rest of this tutorial will make the animation more complex by working through several practical issues.
Let's store the sin()
expression in a variable and use it to animate in the y
direction too:
float threshold = (sin(time) + 1.)/2.; // oscillate between 0 and 1
if (coord.x < threshold && coord.y < threshold) {
This looks like a growing/shrinking square window into a stationary gradient. All you see is blue when the square is small:
What if we want the gradient to stretch with the square? Instead of changing the threshold for where we draw the gradient, we can change the coordinate system.
// float threshold = (sin(time) + 1.)/2.;
float scale = (sin(time) + 1.)/2.;
coord = vec2(coord.x/scale, coord.y/scale);
if (coord.x > 0. && coord.x < 1. &&
coord.y > 0. && coord.y < 1.) {
I used coord = vec2(coord.x/scale, coord.y/scale);
for clarity. You can perform
arithmetic operations directly between a vector and a float and it will apply the operation to each element in the
vector. It's preferable to take advantage of that and write this code as the equivalent
coord = coord/scale;
. We can further simplify with coord /= scale;
.
Our coordinate system is in the range [0,1], and scale
oscillates between 1 and 0, so the resulting coordinate system
animates between a range of [0,1] and [0,infinity]. You might have noticed there's a division by 0 at infinity. The GPU
program doesn't crash or raise an error. It acts like 1/0
in JavaScript, which is the floating point value for
Infinity
.
The updated if
condition checks that x and y are between 0 to 1. When the coordinate system has the range [0,1], the
gradient fills the canvas. As the coordinate system's bounds go towards infinity, we're effectively zooming out on our
gradient, which still only goes from 0 to 1, until eventually the gradient is so small it briefly disappears as we start
zooming back in.
Because we've transformed the entire coordinate system, and the gradient is computed from those coordinates via
vec4(coord.x, coord.y, 1.-coord.x, 1);
, the gradient stretches with our square:
Centering the Animation
Lastly, let's center the animation. This requires additional adjustments to the coordinate system. Inverse to how we
adjusted sin()
to go from -1 to 1, we can change [0,1] to [-1,1] by multiplying by 2 and subtracting 1:
vec2 coord = gl_FragCoord.xy/canvasSize.xy; // [0,1]
coord = 2.*coord - 1.; // [-1,1]
This makes the coordinate system go to -1 in all directions with the origin (0,0) in the center. This is a very common coordinate system and you may find yourself using it a lot.
Our square is only in the upper right quadrant because its corner is at the center (0,0), so update the if
condition
to make it go to the edges of the coordinate system:
if (coord.x > -1. && coord.x < 1. &&
coord.y > -1. && coord.y < 1.) {
We can simplify this by taking the absolute value of the coordinates:
if (abs(coord.x) < 1. && abs(coord.y) < 1.) {
But wait! The gradient is messed up. Everything except the upper right quadrant is wrong. The entire lower left quadrant is blue:
We're using the coordinates to compute our RGB color values via vec4(coord.x, coord.y,
1.-coord.x, 1);
but we made the coordinates go from -1 to 1 and RGB values go from 0 to 1. So we have to convert
back to the original 0 to 1 range with coord = (coord + 1.)/2.;
Our coordinate system could go to infinity if we divide by 0 (when scale
is 0), which would be a problem for the
(coord + 1.)/2.
calculation. But we are only going through that code path when
abs(coord.x) < 1. && abs(coord.y) < 1.
, so the code works in all cases.
Putting it all together:
void main() {
vec2 coord = gl_FragCoord.xy/canvasSize.xy; // [0,1]
coord = 2.*coord - 1.; // [-1,1]
float scale = (sin(time) + 1.)/2.; // from 1 to 0
coord /= scale; // from [-1,1] to [-infinity,infinity]
if (abs(coord.x) < 1. && abs(coord.y) < 1.) {
coord = (coord + 1.)/2.; // [0,1]
fragColor = vec4(coord.x, coord.y, 1.-coord.x, 1);
} else {
fragColor = vec4(1,1,1,1);
}
}
This pattern of translating (moving) the coordinates, scaling/rotating, and then translating back to their original position is very common in graphics programming.
Result
Here's the full HTML page with code. Changes to the previous tutorial are highlighted.
<!doctype html>
<html>
<body>
<canvas id="canvas" width="500" height="500"></canvas>
<script id="vertex" type="x-shader/x-vertex">
#version 300 es
in vec4 vertexPosition;
void main() {
gl_Position = vertexPosition;
}
</script>
<script id="fragment" type="x-shader/x-fragment">
#version 300 es
precision highp float;
uniform vec2 canvasSize;
uniform float time;
out vec4 fragColor;
void main() {
vec2 coord = gl_FragCoord.xy/canvasSize.xy;
coord = 2.*coord - 1.;
float scale = (sin(time) + 1.)/2.;
coord /= scale;
if (abs(coord.x) < 1. && abs(coord.y) < 1.) {
coord = (coord + 1.)/2.;
fragColor = vec4(coord.x, coord.y, 1.-coord.x, 1);
} else {
fragColor = vec4(1,1,1,1);
}
}
</script>
<script>
const canvas = document.getElementById("canvas");
const vertexCode = document.getElementById("vertex").textContent;
const fragmentCode = document.getElementById("fragment").textContent;
const gl = canvas.getContext("webgl2");
if (!gl) throw "WebGL2 not supported";
function createShader(shaderType, sourceCode) {
const shader = gl.createShader(shaderType);
gl.shaderSource(shader, sourceCode.trim());
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw gl.getShaderInfoLog(shader);
}
return shader;
}
const program = gl.createProgram();
gl.attachShader(program, createShader(gl.VERTEX_SHADER, vertexCode));
gl.attachShader(program, createShader(gl.FRAGMENT_SHADER, fragmentCode));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw gl.getProgramInfoLog(program);
}
gl.useProgram(program);
const vertices = [
[-1, -1],
[1, -1],
[-1, 1],
[1, 1],
];
const vertexData = new Float32Array(vertices.flat());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);
const vertexPosition = gl.getAttribLocation(program, "vertexPosition");
gl.enableVertexAttribArray(vertexPosition);
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
const canvasSizeUniform = gl.getUniformLocation(program, 'canvasSize');
gl.uniform2f(canvasSizeUniform, canvas.width, canvas.height);
const timeUniform = gl.getUniformLocation(program, 'time');
function draw() {
gl.uniform1f(timeUniform, performance.now() / 1000);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, vertices.length);
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>
That's it for the basic WebGL tutorials. Hopefully this has taught you enough to get started with your own experiments.