Implementing a Camera and View Matrix in WebGL

This article explains how to create a Camera class in WebGL, compute view and rotation matrices using lookAt, apply them to render a cube from arbitrary positions, and discusses why parts of the cube may be clipped when the camera is placed at [0.5,0.5,0.5].

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Implementing a Camera and View Matrix in WebGL

In the previous article we covered matrix‑based 3D transformations, but the WebGL code was hard to visualize. This article introduces the concept of a camera, showing how moving a virtual camera around a scene determines what is finally rendered on the screen.

We first create a Camera instance, set its position, and make it look at the origin:

const camera = new Camera();

camera.position.x = 0.5
camera.position.y = 0.5
camera.position.z = 0.5 // set camera position

camera.lookAt([0, 0, 0]) // look at the origin

const matLoc = gl.getUniformLocation(program, 'uMat')
gl.uniformMatrix4fv(matLoc, false, camera.viewMatrix)

The camera.viewMatrix is the view matrix generated from the camera’s position and target; applying this matrix to objects yields the camera’s view.

To align the camera with the OpenGL convention (camera at the origin looking down the –Z axis), we perform two steps: translate the camera to the origin and rotate it to face –Z. The translation matrix is built by negating the camera position, and the rotation matrix is derived from the camera’s own axes.

We compute the camera axes as follows:

Z = normalize(cameraPosition - targetPosition)

Using an up vector (usually [0, 1, 0]) we obtain the X axis via cross product, then the Y axis via another cross product:

X = normalize(cross(up, Z))
Y = cross(Z, X)

These axes form the rotation matrix. Because rotation matrices are orthogonal, their inverse is simply the transpose.

The final view matrix is the product of the rotation matrix R and the translation matrix T (i.e., viewMatrix = R * T).

class Mat4 {
  static identity(out = []) {
    return Object.assign(out, [
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, 1
    ])
  }

  static lookAt(eye, target, up, out = []) {
    const eyeX = eye[0], eyeY = eye[1], eyeZ = eye[2]
    const upX = up[0], upY = up[1], upZ = up[2]
    const targetX = target[0], targetY = target[1], targetZ = target[2]
    let x0, x1, x2, y0, y1, y2, z0, z1, z2, len

    // Z axis (camera direction)
    z0 = eyeX - targetX
    z1 = eyeY - targetY
    z2 = eyeZ - targetZ
    len = z0*z0 + z1*z1 + z2*z2
    if (len > 0) {
      len = 1 / Math.sqrt(len)
      z0 *= len; z1 *= len; z2 *= len
    } else {
      return Mat4.identity(out)
    }

    // X axis = up × Z
    x0 = upY * z2 - upZ * z1
    x1 = upZ * z0 - upX * z2
    x2 = upX * z1 - upY * z0
    len = x0*x0 + x1*x1 + x2*x2
    if (len > 0) {
      len = 1 / Math.sqrt(len)
      x0 *= len; x1 *= len; x2 *= len
    } else {
      return Mat4.identity(out)
    }

    // Y axis = Z × X
    y0 = z1 * x2 - z2 * x1
    y1 = z2 * x0 - z0 * x2
    y2 = z0 * x1 - z1 * x0

    // Column‑major order for OpenGL
    out[0] = x0; out[1] = y0; out[2] = z0; out[3] = 0
    out[4] = x1; out[5] = y1; out[6] = z1; out[7] = 0
    out[8] = x2; out[9] = y2; out[10] = z2; out[11] = 0
    out[12] = -(x0*eyeX + x1*eyeY + x2*eyeZ)
    out[13] = -(y0*eyeX + y1*eyeY + y2*eyeZ)
    out[14] = -(z0*eyeX + z1*eyeY + z2*eyeZ)
    out[15] = 1
    return out
  }
}

class Vec3 extends Array {
  constructor(x = 0, y = x, z = x) { super(x, y, z) }
  get x() { return this[0] }
  get y() { return this[1] }
  get z() { return this[2] }
  set x(v) { this[0] = v }
  set y(v) { this[1] = v }
  set z(v) { this[2] = v }
}

class Camera {
  constructor() {
    this.position = new Vec3()
    this.up = new Vec3(0, 1, 0)
    this.viewMatrix = Mat4.identity()
  }
  lookAt(target) {
    Mat4.lookAt(this.position, target, this.up, this.viewMatrix)
  }
}

Using the Camera class, we render a unit cube centered at the origin. The cube vertices are generated by createBox(), and the rendering pipeline is set up with shaders, attribute buffers, and depth testing:

const gl = createGl()

const program = createProgramFromSource(gl, `
attribute vec4 aPos;
uniform mat4 uMat;

void main() {
  gl_Position = uMat * aPos;
}
`, `
precision highp float;

void main() {
  gl_FragColor = vec4(gl_FragCoord.zzz, 1);
}
`)

const box = createBox()
const indexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, box.index.value, gl.STATIC_DRAW)

const [posLoc] = createAttrBuffer(gl, program, 'aPos', box.position.value)
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(posLoc)

const camera = new Camera()
camera.position.x = camera.position.y = camera.position.z = 0.5
camera.lookAt([0, 0, 0])
const matLoc = gl.getUniformLocation(program, 'uMat')
gl.uniformMatrix4fv(matLoc, false, camera.viewMatrix)

gl.enable(gl.DEPTH_TEST)
gl.enable(gl.CULL_FACE)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.drawElements(gl.TRIANGLES, box.index.value.length, gl.UNSIGNED_SHORT, 0)

function createShader(gl, type, source) {
  const shader = gl.createShader(type)
  gl.shaderSource(shader, source)
  gl.compileShader(shader)
  return shader
}

function createProgramFromSource(gl, vertex, fragment) {
  const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertex)
  const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragment)
  const program = gl.createProgram()
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  gl.useProgram(program)
  return program
}

function createAttrBuffer(gl, program, attr, data) {
  const location = gl.getAttribLocation(program, attr)
  const buffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
  return [location, buffer]
}

When the camera is placed at [0.5, 0.5, 0.5], part of the cube disappears because the cube is translated in the opposite direction by 0.5 units, moving some vertices to the edge of the clip space (‑1 to 1). Vertices outside this range are clipped, which explains the missing corners.

The article concludes by noting that the next tutorial will cover extending the view frustum, handling clipping, and implementing perspective projection.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaScriptWebGLCamera3DGraphicsViewMatrix
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.