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>
  

View the demo.

Try it on CodePen.

That's it for the basic WebGL tutorials. Hopefully this has taught you enough to get started with your own experiments.

Table of Contents:
  1. WebGL Concepts
  2. WebGL Triangle
  3. WebGL Square
  4. WebGL Gradient
  5. WebGL Animation