DEV Community

SEN LLC
SEN LLC

Posted on

A WebGL 3D Cube From First Principles — Handwritten 4x4 Matrices and Visible Shaders

A WebGL 3D Cube From First Principles — Handwritten 4x4 Matrices and Visible Shaders

Most WebGL tutorials import gl-matrix. This one doesn't. The 4x4 matrix math is written out: identity, multiply, translate, rotateX/Y/Z, scale, perspective, lookAt, invert. The shaders are visible in a panel. The projection × view × model matrix multiplication is shown live as you drag the cube. If you want to actually understand what WebGL is doing, gl-matrix is a black box. Removing it reveals the whole thing.

WebGL's reputation for being hard comes from the combination of verbose API + implicit math. If you've never written a matrix multiply, rotating a cube feels like magic. Writing the math yourself turns it from magic into arithmetic.

🔗 Live demo: https://sen.ltd/portfolio/cube-3d/
📦 GitHub: https://github.com/sen-ltd/cube-3d

Screenshot

Features:

  • Pure WebGL 1.0 (no libraries)
  • Handwritten 4x4 matrix library (12 functions)
  • Mouse drag to rotate, scroll to zoom
  • Wireframe overlay toggle
  • Live matrix display (projection, view, model)
  • Visible shader source code
  • 3 color modes (solid, gradient, monochrome)
  • Japanese / English UI
  • Zero dependencies, 34 tests

Column-major 4x4 matrices

WebGL expects column-major storage. Matrix[0..3] is the first column, not the first row:

export function identity() {
  return new Float32Array([
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ]);
}
Enter fullscreen mode Exit fullscreen mode

Reading this as rows is wrong. The four columns are: (1,0,0,0), (0,1,0,0), (0,0,1,0), (0,0,0,1). OpenGL conventions stretch back to the 80s and everyone just accepted column-major even though row-major is more natural to read.

Matrix multiplication

export function multiply(a, b) {
  const out = new Float32Array(16);
  for (let i = 0; i < 4; i++) {
    for (let j = 0; j < 4; j++) {
      let sum = 0;
      for (let k = 0; k < 4; k++) {
        // a is column-major: a[col*4 + row] = a[k*4 + i]
        sum += a[k * 4 + i] * b[j * 4 + k];
      }
      out[j * 4 + i] = sum;
    }
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

16 multiplies and 12 additions per entry, 64 entries total — a fixed ~256 operations. Fast enough for every frame. The indexing looks backwards because column-major: a[col * 4 + row] not a[row * 4 + col].

Rotation around an arbitrary axis

Rodrigues's rotation formula expressed as a matrix:

export function rotate(m, rad, x, y, z) {
  const len = Math.sqrt(x*x + y*y + z*z);
  x /= len; y /= len; z /= len;
  const c = Math.cos(rad);
  const s = Math.sin(rad);
  const t = 1 - c;
  const r = new Float32Array([
    t*x*x + c,    t*x*y + s*z, t*x*z - s*y, 0,
    t*x*y - s*z, t*y*y + c,    t*y*z + s*x, 0,
    t*x*z + s*y, t*y*z - s*x, t*z*z + c,    0,
    0,            0,            0,            1,
  ]);
  return multiply(m, r);
}
Enter fullscreen mode Exit fullscreen mode

Every entry is a combination of sines, cosines, and axis components. You can derive it from "rotate around X, then Y, then Z" but the closed form is faster and numerically better.

Perspective projection

export function perspective(fov, aspect, near, far) {
  const f = 1 / Math.tan(fov / 2);
  return new Float32Array([
    f / aspect, 0, 0,                              0,
    0,          f, 0,                              0,
    0,          0, (far + near) / (near - far),    -1,
    0,          0, (2 * far * near) / (near - far), 0,
  ]);
}
Enter fullscreen mode Exit fullscreen mode

This maps a frustum (pyramid cut off at near and far) to the unit cube NDC space that WebGL rasterizes. The non-obvious parts:

  • f / aspect squishes X to account for the canvas aspect ratio
  • The -1 in row 4 column 3 enables perspective divide (x/w, y/w, z/w)
  • The z terms map the frustum depth range to [-1, 1]

Rendering loop

function render(now) {
  const dt = (now - lastTime) / 1000;
  lastTime = now;

  // Update model matrix with rotation
  let model = identity();
  model = rotateY(model, rotationY);
  model = rotateX(model, rotationX);

  // Upload matrices to shader uniforms
  gl.uniformMatrix4fv(uModel, false, model);
  gl.uniformMatrix4fv(uView, false, view);
  gl.uniformMatrix4fv(uProjection, false, projection);

  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);

  requestAnimationFrame(render);
}
Enter fullscreen mode Exit fullscreen mode

Three matrix uniforms, one draw call, 36 indices (6 faces × 2 triangles × 3 vertices). The shader multiplies projection * view * model * position for each vertex.

Series

This is entry #74 in my 100+ public portfolio series.

Top comments (0)