Moving geometry in WebGL

Before diving into 3D, it’s worth spending more time with 2D transformations. These concepts—translation, rotation, and scaling—are the building blocks for everything that follows. Not only are they essential, but they also introduce you to thinking about geometry in terms of shader operations rather than constant CPU-side updates.

Translation is just changing coordinates

Translation simply means moving geometry. In the examples from the WebGL fundamentals series, you could translate a rectangle by changing the values passed to a function like setRectangle. But this approach breaks down quickly with complex shapes.

Consider a shape like an 'F' built from six triangles. Writing specific setter functions for every point is impractical. For anything with hundreds or thousands of vertices, the JavaScript update cost balloons, and the code becomes unmanageable.

A better path is to keep the geometry uploaded once and let the shader handle the movement. Instead of updating every point in JavaScript, you update a single uniform. The updated vertex shader would look like this:

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;

void main() {
   // Add in the translation.
   vec2 position = a_position + u_translation;

   // convert the rectangle from pixels to 0.0 to 1.0
   vec2 zeroToOne = position / u_resolution;
   ...

With this approach, you only need to set the geometry once—before the render loop—and then update u_translation per frame. Even if the geometry contains tens of thousands of points, the main loop remains small and consistent.

Rotating with the unit circle

Rotation introduces what’s called a unit circle—a circle with a radius of 1.0. If you recall basic arithmetic, multiplying by 1 doesn’t change a value. A unit circle is a “rotating 1.” You take a point on this circle and multiply your geometry’s coordinates by its X and Y values, and the magic of rotation happens.

Here is the updated shader using this idea:

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;

void main() {
  // Rotate the position
  vec2 rotatedPosition = vec2(
     a_position.x * u_rotation.y + a_position.y * u_rotation.x,
     a_position.y * u_rotation.y - a_position.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;

And the JavaScript passes those two uniform values in before drawing:

  ...
  var rotationLocation = gl.getUniformLocation(program, "u_rotation");
  ...
  var rotation = [0, 1];
  ..
  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Set the rotation.
    gl.uniform2fv(rotationLocation, rotation);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

Why this works is visual: imagine a rectangle and you want to rotate it. A corner at (3.0, 9.0). Pick a point on the unit circle 30 degrees clockwise from 12 o’clock—its coordinates are 0.50 and 0.87. Multiply those against the original height of the rectangle, and the new Y position becomes 9.0 * 0.87, while the X remains controlled by 9.0 * 0.50. As you sweep the angle clockwise, the X grows and the Y shrinks, and the shape naturally rotates.

These unit-circle points have names: sine and cosine. For an angle, you compute them:

function printSineAndCosineForAnAngle(angleInDegrees) {
  var angleInRadians = angleInDegrees * Math.PI / 180;
  var s = Math.sin(angleInRadians);
  var c = Math.cos(angleInRadians);
  console.log("s = " + s + " c = " + c);
}

Set the two uniform values to those, and your geometry will rotate:

  ...
  var angleInRadians = angleInDegrees * Math.PI / 180;
  rotation[0] = Math.sin(angleInRadians);
  rotation[1] = Math.cos(angleInRadians);

Why radians instead of degrees?

Radians make rotation math easier. A full turn is radians, a half turn is π, and 90 degrees is π/2. So use Math.PI * 0.5 for 90 degrees, Math.PI * 0.25 for 45, and so on. Stick with radians internally; save degrees for the UI layer.

Scaling is multiplying

Scaling is as straightforward as translation, but multiplication instead of addition. The vertex shader multiplies its position by a uniform u_scale:

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Scale the positon
  vec2 scaledPosition = a_position * u_scale;

  // Rotate the position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y +
        scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y -
        scaledPosition.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;

And JavaScript sets that uniform before drawing:

  ...
  var scaleLocation = gl.getUniformLocation(program, "u_scale");
  ...
  var scale = [1, 1];
  ...
  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Set the rotation.
    gl.uniform2fv(rotationLocation, rotation);

    // Set the scale.
    gl.uniform2fv(scaleLocation, scale);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

Keep in mind that scaling by a negative value flips the geometry. That’s a useful side effect once you need mirroring.

Together, translation, rotation, and scaling cover most 2D groundwork. The next step is to combine them into a single operation using matrices, which dramatically simplifies and generalizes the code.

Combining Transformations with Matrices

We've covered translation, rotation, and scale separately. Each required its own shader changes, and the order in which they were applied mattered. A scale followed by a rotation and a translation produces a different result than a translation followed by a rotation and a scale. Changing that order required writing yet another shader.

Matrix math removes that constraint. For 2D, a 3x3 matrix — a grid of nine numbers — can represent any combination of these transformations. The math multiplies a position's coordinates down each column of the matrix and sums the results. Since positions have only x and y, we add a third value of 1 to make the multiplication work.

1.0 2.0 3.0
4.0 5.0 6.0
7.0 8.0 9.0

A translation matrix, for example, takes the translation amounts tx and ty. After simplifying the algebra — dropping terms that multiply by zero and keeping those that multiply by one — the result is identical to the manual translation code we used earlier.

1.00.00.0
0.01.00.0
txty1.0

The same approach works for rotation. Building a matrix from the sine and cosine of the rotation angle yields exactly the rotation equations we had before. Scaling follows similarly, with a matrix holding the factors sx and sy on the diagonal.

c-s0.0
sc0.0
0.00.01.0
sx0.00.0
0.0sy0.0
0.00.01.0

The Real Payoff: Matrix Multiplication

The practical gain comes from multiplying matrices together. Instead of applying several transformations in the shader, you multiply their matrices into a single one and apply it once. If we have a function matrixMultiply that combines two matrices, and helper functions that build translation, rotation, and scale matrices, the shader gets dramatically simpler.

The old vertex shader with explicit transform steps becomes:

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  vec2 position = (u_matrix * vec3(a_position, 1)).xy;
  ...

In JavaScript, we build the combined matrix and pass it in as a uniform:

  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Compute the matrices
    var translationMatrix =
       makeTranslation(translation[0], translation[1]);
    var rotationMatrix = makeRotation(angleInRadians);
    var scaleMatrix = makeScale(scale[0], scale[1]);

    // Multiply the matrices.
    var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);

    // Set the matrix.
    gl.uniformMatrix3fv(matrixLocation, false, matrix);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

Changing the order of operations is now a matter of changing the multiplication order in JavaScript — no shader rewrite needed.

    ...
    // Multiply the matrices.
    var matrix = matrixMultiply(translationMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, scaleMatrix);
    ...

This becomes essential for hierarchical animation, like an arm attached to a body, a moon orbiting a planet, or branches on a tree. In a simple example, we can draw an 'F' multiple times, each instance using the accumulated matrix from the previous one. Doing this requires an identity matrix — the matrix equivalent of 1, which leaves anything multiplied by it unchanged.

  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Compute the matrices
    var translationMatrix = makeTranslation(translation[0], translation[1]);
    var rotationMatrix = makeRotation(angleInRadians);
    var scaleMatrix = makeScale(scale[0], scale[1]);

    // Starting Matrix.
    var matrix = makeIdentity();

    for (var i = 0; i < 5; ++i) {
      // Multiply the matrices.
      matrix = matrixMultiply(matrix, scaleMatrix);
      matrix = matrixMultiply(matrix, rotationMatrix);
      matrix = matrixMultiply(matrix, translationMatrix);

      // Set the matrix.
      gl.uniformMatrix3fv(matrixLocation, false, matrix);

      // Draw the geometry.
      gl.drawArrays(gl.TRIANGLES, 0, 18);
    }
  }

Another useful technique: since the math rotates around the origin, and our 'F' had its top-left corner at the origin, we can move the origin before applying other transforms. This lets us rotate or scale from any chosen point — the mechanism behind movable rotation points in tools like Photoshop or Flash.

    // make a matrix that will move the origin of the 'F' to
    // its center.
    var moveOriginMatrix = makeTranslation(-50, -75);
    ...

    // Multiply the matrices.
    var matrix = matrixMultiply(moveOriginMatrix, scaleMatrix);
    matrix = matrixMultiply(matrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);

Folding Pixel-to-Clipspace Conversion into a Matrix

In the earlier WebGL fundamentals article, the shader contained code to convert pixels to clip space in several steps: scaling by 1.0/resolution, scaling by 2.0, translating by -1.0,-1.0, and negating Y. All of that can be expressed as a single projection matrix.

function make2DProjection(width, height) {
  // Note: This matrix flips the Y axis so that 0 is at the top.
  return [
    2 / width, 0, 0,
    0, -2 / height, 0,
    -1, 1, 1
  ];
}

With this, the entire vertex shader reduces to a single matrix multiplication:

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

And in JavaScript, we multiply the object's matrix by the projection matrix:

  // Draw the scene.
  function drawScene() {
    ...
    // Compute the matrices
    var projectionMatrix =
       make2DProjection(canvas.width, canvas.height);
    ...

    // Multiply the matrices.
    var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);
    matrix = matrixMultiply(matrix, projectionMatrix);
    ...
  }

The code that set the resolution directly is gone. What started as a six- or seven-step shader is now one step, all thanks to matrix math. The same principles extend directly to 3D, where the only difference is the size of the matrices involved.