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:
- inside to inside: emit the current vertex,
- inside to outside: emit the intersection,
- outside to inside: emit the intersection and the current vertex,
- outside to outside: emit nothing.
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:
- Quaternion.js provides Hamilton products, conjugation, inversion, normalization, and rotation.
- Entity combines a mesh with a pure position, a unit orientation, scale, and face colors.
- OrbitPath rotates a pure offset around a pure center or another path.
- Camera stores a world pose and derives orbit, pan, and zoom updates.
- CanvasRenderer transforms, culls, clips, projects, sorts, shades, and draws.
- QuaternionGame owns input, frame timing, scene updates, and responsive canvas sizing.
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:
- The general inverse divides by \(\lVert\mathbf q\rVert^2\), not \(\lVert\mathbf q\rVert\).
- A four-dimensional quaternion dot product contains \(w_1w_2+x_1x_2+y_1y_2+z_1z_2\) exactly once each; a 3D geometric dot product uses only the three vector components.
- Field-of-view values stored in degrees must be converted to radians, and perspective uses the cotangent factor.
- The camera pose and world-to-camera transform must have an explicit direction; guessing where to place an inverse works only for the identity pose.
- Lighting compares a face normal with a light direction, never with an orientation quaternion.
- Near-plane clipping must produce one ordered polygon rather than a bag of independently clipped edges.
- Frame-rate-independent motion uses \(\Delta t\), and animation uses
requestAnimationFramerather than two unrelated interval timers.
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
- [Hamilton1844]W. R. Hamilton (1844), On Quaternions; or on a New System of Imaginaries in Algebra, Philosophical Magazine.
- [Shoemake1985]K. Shoemake (1985), Animating Rotation with Quaternion Curves, Proceedings of SIGGRAPH '85.
- [SutherlandHodgman1974]I. E. Sutherland and G. W. Hodgman (1974), Reentrant Polygon Clipping, Communications of the ACM 17(1), 32-42.