raw Software

A Simple Quaternion Game Engine

Robert Eisele

A practical derivation of a compact 3D engine in which pure quaternions represent points and vectors, unit quaternions control every pose and orbit, and an interactive Canvas stage renders the result.

Quaternion.js Read the story

I wanted to find out how little machinery is needed to turn quaternion algebra into a working 3D renderer. The result above is deliberately small: a scene graph, a camera, orbiting entities, flat-shaded polygon meshes, near-plane clipping, and a 2D Canvas renderer. There is no WebGL and no transformation matrix in the spatial pipeline.

"Quaternion-only" needs one qualification. Colors, mesh indices, time, and scalar parameters are still ordinary data. What it means here is that every 3D point, direction, translation, angular update, object pose, camera pose, and orbit offset is represented or transformed with quaternions. A position is a pure quaternion, while an orientation is a unit quaternion.

One Algebra for the Whole Scene

Write a quaternion as a scalar-vector pair

\[ \mathbf q=(q_0,\mathbf q_v)=(q_0,q_1,q_2,q_3). \]

The Hamilton product of \(\mathbf a=(a_0,\mathbf a_v)\) and \(\mathbf b=(b_0,\mathbf b_v)\) is

\[ \mathbf a\otimes\mathbf b =\left( a_0b_0-\mathbf a_v\cdot\mathbf b_v, a_0\mathbf b_v+b_0\mathbf a_v+\mathbf a_v\times\mathbf b_v \right). \]

This one operation already contains the dot product and cross product. If \(\mathbf A=(0,\mathbf a)\) and \(\mathbf B=(0,\mathbf b)\) are pure, then

\[ \mathbf A\otimes\mathbf B=(-\mathbf a\cdot\mathbf b,\mathbf a\times\mathbf b). \]

The symmetric part therefore contains the dot product and the antisymmetric part contains the cross product:

\[ \mathbf a\cdot\mathbf b =-\frac{1}{2}\operatorname{Re}(\mathbf A\otimes\mathbf B+\mathbf B\otimes\mathbf A), \qquad (0,\mathbf a\times\mathbf b) =\frac{1}{2}(\mathbf A\otimes\mathbf B-\mathbf B\otimes\mathbf A). \]

The implementation evaluates these component formulas directly because that avoids temporary objects, but no second geometric algebra is being introduced.

Conjugate, norm, and inverse

The conjugate changes the sign of the vector part:

\[ \overline{\mathbf q}=(q_0,-\mathbf q_v). \]

Multiplication by the conjugate gives a non-negative scalar,

\[ \mathbf q\otimes\overline{\mathbf q} =\overline{\mathbf q}\otimes\mathbf q =(\lVert\mathbf q\rVert^2,\mathbf 0), \]

so the general inverse is

\[ \boxed{\mathbf q^{-1}=\frac{\overline{\mathbf q}}{\lVert\mathbf q\rVert^2}}. \]

Only for a unit quaternion does this simplify to \(\mathbf q^{-1}=\overline{\mathbf q}\). Dividing the conjugate by \(\lVert\mathbf q\rVert\) instead of its square merely normalizes the quaternion; it does not compute the inverse.

Rotating a Pure Quaternion

A rotation by angle \(\theta\) around a unit axis \(\hat{\mathbf n}\) is represented by

\[ \mathbf q=\left(\cos\frac{\theta}{2},\hat{\mathbf n}\sin\frac{\theta}{2}\right), \qquad \lVert\mathbf q\rVert=1. \]

Embed a vector \(\mathbf p\in\mathbb R^3\) as the pure quaternion \(\mathbf P=(0,\mathbf p)\). Its rotated form is the quaternion sandwich product

\[ \boxed{\mathbf P'=\mathbf q\otimes\mathbf P\otimes\overline{\mathbf q}}. \]

The scalar part of \(\mathbf P'\) remains zero. Length is preserved because quaternion norms multiply:

\[ \lVert\mathbf P'\rVert =\lVert\mathbf q\rVert\lVert\mathbf P\rVert\lVert\overline{\mathbf q}\rVert =\lVert\mathbf P\rVert. \]

This is why an orbit update can rotate an offset indefinitely without changing its radius. It is also why the engine periodically normalizes accumulated orientation quaternions: floating-point roundoff must not slowly introduce a scale factor.

Object, World, and Camera Frames

Frame notation removes the most common source of camera bugs. Let \(B\) be an object's local body frame, \(W\) the world frame, and \(C\) the camera frame. The unit quaternion \({}^{W}\mathbf q_B\) maps body-frame directions into world coordinates. The pure quaternion \({}^{W}\mathbf T_B\) is the object's world position.

A local mesh vertex \({}^{B}\mathbf P\) reaches world space through

\[ {}^{W}\mathbf P ={}^{W}\mathbf q_B\otimes{}^{B}\mathbf P\otimes\overline{{}^{W}\mathbf q_B} +{}^{W}\mathbf T_B. \]

The camera pose is described in the same direction: \({}^{W}\mathbf q_C\) maps camera coordinates into world coordinates, and \({}^{W}\mathbf T_C\) is the camera position. Rendering needs the opposite map. Translation is undone first, followed by the inverse camera rotation:

\[ {}^{C}\mathbf P =\overline{{}^{W}\mathbf q_C} \otimes\left({}^{W}\mathbf P-{}^{W}\mathbf T_C\right) \otimes{}^{W}\mathbf q_C. \]

Substituting the object transform gives the complete vertex pipeline before projection:

\[ \boxed{ {}^{C}\mathbf P =\overline{{}^{W}\mathbf q_C}\otimes \left( {}^{W}\mathbf q_B\otimes{}^{B}\mathbf P\otimes\overline{{}^{W}\mathbf q_B} +{}^{W}\mathbf T_B-{}^{W}\mathbf T_C \right) \otimes{}^{W}\mathbf q_C }. \]

A camera orientation is not a view direction. It rotates an entire basis. The camera's world-space forward, right, and up directions are obtained by rotating the three pure basis quaternions:

\[ {}^{W}\mathbf F_C ={}^{W}\mathbf q_C\otimes(0,0,0,1)\otimes\overline{{}^{W}\mathbf q_C}, \]

with analogous expressions for \((0,1,0,0)\) and \((0,0,1,0)\). This gives camera-relative panning without a matrix.

Incremental Motion and Orbits

Suppose an entity rotates about a world-space unit axis \(\hat{\boldsymbol\omega}\) with angular speed \(\omega\). During a frame of duration \(\Delta t\), the incremental rotation is

\[ \delta\mathbf q =\left( \cos\frac{\omega\Delta t}{2}, \hat{\boldsymbol\omega}\sin\frac{\omega\Delta t}{2} \right). \]

Because the axis is resolved in the world frame, the increment multiplies on the left:

\[ {}^{W}\mathbf q_B(t+\Delta t) =\delta\mathbf q\otimes{}^{W}\mathbf q_B(t). \]

An orbit is the same update applied to a pure offset \({}^{W}\mathbf R\):

\[ {}^{W}\mathbf R(t+\Delta t) =\delta\mathbf q\otimes{}^{W}\mathbf R(t)\otimes\overline{\delta\mathbf q}, \qquad {}^{W}\mathbf T_B={}^{W}\mathbf T_O+{}^{W}\mathbf R. \]

The center \({}^{W}\mathbf T_O\) may itself come from another orbit path. That is enough to produce the small moon following the reddish cube in the stage. No special epicycle class is needed; paths simply compose.

Perspective Projection

The renderer uses a right-handed camera frame looking along positive \(z_C\). Let the canvas have width \(w\), height \(h\), and vertical field of view \(\theta_y\). Similar triangles give the focal length in pixels:

\[ f=\frac{h/2}{\tan(\theta_y/2)}. \]

For a visible camera-space point \({}^{C}\mathbf P=(0,x_C,y_C,z_C)\) with \(z_C>0\), the screen position is

\[ \boxed{ s_x=\frac{w}{2}+f\frac{x_C}{z_C}, \qquad s_y=\frac{h}{2}-f\frac{y_C}{z_C} }. \]

The minus sign in \(s_y\) converts mathematical upward-positive coordinates to Canvas's downward-positive pixel coordinates. JavaScript trigonometric functions expect radians, so a field of view stored in degrees must be multiplied by \(\pi/180\) before evaluating the tangent. The projection factor is the cotangent \(1/\tan(\theta_y/2)\), not the tangent itself.

The Near Plane Must Be Clipped

Projection divides by \(z_C\). A point on or behind the camera plane cannot simply be projected, and rejecting an entire line or face when one vertex is behind it causes visible popping. Place a near plane at \(z_C=n>0\).

For an edge from pure quaternion \(\mathbf A\) to \(\mathbf B\), parameterize the segment by

\[ \mathbf P(t)=\mathbf A+t(\mathbf B-\mathbf A),\qquad 0\leq t\leq1. \]

At the near plane, \(P_z(t)=n\). Solving for \(t\) gives

\[ \boxed{t=\frac{n-A_z}{B_z-A_z}}. \]

A line segment has three cases: keep both endpoints when both are inside, reject both when both are outside, and replace the outside endpoint with \(\mathbf P(t)\) when the segment crosses the plane.

Polygons use the Sutherland-Hodgman procedure. Walk around the polygon once and compare every directed edge with the plane:

The result remains ordered and may contain one extra vertex. Clipping each polygon edge independently and appending both returned endpoints does not preserve that invariant and tends to duplicate vertices.

function clipPolygonToNear(points, near) {
  const output = []

  for (let i = 0; i < points.length; i += 1) {
    const current = points[i]
    const previous = points[(i + points.length - 1) % points.length]
    const currentInside = current.z >= near
    const previousInside = previous.z >= near

    if (currentInside !== previousInside)
      output.push(intersectNearPlane(previous, current, near))
    if (currentInside) output.push(current)
  }

  return output
}

Normals and Backface Culling

For the first three camera-space vertices of a face, define the edge quaternions

\[ \mathbf U=\mathbf P_1-\mathbf P_0, \qquad \mathbf V=\mathbf P_2-\mathbf P_0. \]

The outward normal is the normalized vector part of their Hamilton product:

\[ \mathbf N =\left(0,\frac{\mathbf u\times\mathbf v}{\lVert\mathbf u\times\mathbf v\rVert}\right). \]

Let \(\mathbf M\) be the face centroid in camera space. The vector from the face toward the camera is \(-\mathbf M\). A consistently wound face is visible when

\[ \mathbf n\cdot(-\mathbf m)>0 \quad\Longleftrightarrow\quad \mathbf n\cdot\mathbf m<0. \]

That one test removes faces pointing away from the viewer before clipping and projection.

Flat Lighting Without a Gradient

A fixed light direction in camera space is another normalized pure quaternion \(\mathbf L=(0,\boldsymbol\ell)\). Simple Lambertian diffuse lighting uses

\[ I=I_a+I_d\max(0,\mathbf n\cdot\boldsymbol\ell), \]

where \(I_a\) is ambient light and \(I_d\) is the diffuse contribution. The stage multiplies each face's base RGB color by \(I\). Every face receives one solid color; no CSS or Canvas gradient is involved.

The normal must be compared with a direction, not with the four components of the camera orientation quaternion. An orientation encodes a rotation operator, while a light or view direction is a pure quaternion. They are different geometric objects even though both happen to contain four numbers.

Painter's Algorithm on Canvas 2D

Canvas 2D has no depth buffer. The engine computes the average camera-space depth of each clipped face, sorts faces from far to near, and paints them in that order. This is the painter's algorithm.

It is adequate for separate convex meshes such as the cubes and octahedron above, but it is not a general visibility solution. Intersecting polygons, cyclic overlap, and some large concave faces cannot always be ordered by one average depth. A production renderer would triangulate meshes and use WebGL or WebGPU with a depth buffer. The small Canvas renderer is valuable because every stage of the transformation remains visible in ordinary JavaScript.

A Minimal Engine Architecture

The implementation separates deterministic geometry from browser code:

The per-frame order matters:

update(deltaTime)
  rotate entity orientations with deltaQuaternion
  rotate every orbit offset with the sandwich product
  update entity positions from their paths

render()
  transform vertices from object to world to camera
  reject backfaces
  clip faces against z = near
  project camera coordinates to canvas pixels
  sort faces from far to near
  shade and draw

requestAnimationFrame synchronizes drawing with the browser. The elapsed frame time is clamped before an update so returning to a backgrounded tab cannot advance every orbit by several seconds in one step.

HiDPI and responsive rendering

The canvas has two sizes. CSS controls its layout size; the backing bitmap is multiplied by the device pixel ratio. The context transform maps drawing commands back to CSS pixels:

pixelRatio = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = cssWidth * pixelRatio
canvas.height = cssHeight * pixelRatio
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0)

A ResizeObserver updates this backing store when the stage changes size. Capping the ratio at two keeps the pixel count reasonable on very dense screens.

Orbiting and panning the camera

The camera's orientation maps camera directions into world directions. A world-space yaw increment therefore multiplies on the left, while a camera-local pitch increment multiplies on the right:

\[ {}^{W}\mathbf q_C' =\delta\mathbf q_{\mathrm{yaw}} \otimes{}^{W}\mathbf q_C \otimes\delta\mathbf q_{\mathrm{pitch}}. \]

The camera position is rebuilt from a target and a rotated backward offset:

\[ {}^{W}\mathbf T_C ={}^{W}\mathbf T_{\mathrm{target}} +{}^{W}\mathbf q_C\otimes(0,0,0,-d)\otimes\overline{{}^{W}\mathbf q_C}. \]

Pointer capture keeps dragging active outside the canvas. Shift-drag translates the target along the rotated right and up basis vectors, while the wheel changes \(d\). The related quaternion trackball interaction maps pointer positions onto a virtual sphere instead; both approaches accumulate unit-quaternion rotations without Euler angles.

What the Small Prototype Needed to Get Right

A compact experiment often hides errors because its default camera starts at the identity and all rotations are normalized. The following details are not optional once the code becomes an engine:

Where to Take It Next

The next meaningful additions would be homogeneous frustum clipping against all six planes, mesh triangulation, a depth buffer, and collision primitives. At that point Canvas 2D stops being the sensible rendering backend, but the quaternion scene model does not need to change. Object and camera poses can move directly into WebGL, WebGPU, or a physics engine.

For application code, a mature implementation such as Quaternion.js keeps the arithmetic core tested and reusable. The engine adds only scene-specific helpers for embedding pure quaternions and evaluating vector operations.

References