3D Without Canvas or WebGL
Format: field noteEvidence: closed
What this is: a small experiment showing how 3D rotation and projection map to browser rendering using only DOM elements, TypeScript, and CSS transforms. What this is not: a general-purpose 3D engine or a recommended production rendering pipeline.
Why DOM Instead of Canvas?
In this experiment, the cube is not drawn on a canvas. Each vertex is a div element, positioned using CSS transforms.
This is a deliberate constraint: no canvas, no WebGL, no engine; just enough code to make the math visible from matrix multiplication to motion on screen.
The Problem
A cube has eight vertices. Each vertex is a point in space: .
To rotate the cube, we need new coordinates for every vertex—coordinates that place each point in its rotated position.
Rotation matrices perform that transformation. In this demo, the math is done directly in TypeScript and the result is pushed into CSS transforms.
One Axis at a Time
Rotation around an axis leaves that axis unchanged. Rotating around moves points in the -plane. Rotating around moves points in the -plane.
Each axis has its own matrix. In the plane perpendicular to that axis the picture is a 2D rotation: counterclockwise when points up (the math convention), clockwise when points down (the screen).
Around X
The first row is . This preserves :
const rotationX = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[1, 0, 0],
[0, Math.cos(angle), -Math.sin(angle)],
[0, Math.sin(angle), Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
Around Y
The middle row is . No rotation around the -axis leaves unchanged while rotating points in the -plane:
const rotationY = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), 0, Math.sin(angle)],
[0, 1, 0],
[-Math.sin(angle), 0, Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
Around Z
The last row is . This preserves :
const rotationZ = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), -Math.sin(angle), 0],
[Math.sin(angle), Math.cos(angle), 0],
[0, 0, 1]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
Applying a Rotation
To rotate a vertex, multiply the matrix by its coordinates:
The result is the rotated position. Do that for every vertex and the whole shape turns. The origin stays put.
In this demo, that math is done in plain TypeScript:
const matrixMultiplyVertex = (matrix: number[][], vertex: Vertex): Vertex => {
const result: number[][] = []
for (let i = 0; i < matrix.length; i++) {
let sum = 0
sum += matrix[i][0] * vertex.x
sum += matrix[i][1] * vertex.y
sum += matrix[i][2] * vertex.z
result[i] = [sum]
}
return { x: result[0][0], y: result[1][0], z: result[2][0] }
}
Combining Rotations
The cube above uses all three matrices in sequence: , then , then .
This demo uses column vectors and (matrix on the left). Composition is right-to-left: apply , then , then . Rotations are active (they move the cube; the axes stay put) and right-handed: a positive angle is counterclockwise when you look along the axis toward the origin. Screens flip , so the same looks clockwise on the page. The view step later is not a rotation — it is a projective scale.
Order matters. Rotating first around then around is not the same as then . Matrix multiplication is not commutative. Walk it one turn at a time:
A single rotation about an arbitrary axis is the same idea in one step: the part of along is fixed; the rest spins in the plane of .
export const transformPoints = (
vertexArray: Vertex[],
angleX: number,
angleY: number,
angleZ: number,
scale: number,
eyeDistance: number,
) => {
let rotatedPoints = rotationX(vertexArray, angleX)
rotatedPoints = rotationY(rotatedPoints, angleY)
rotatedPoints = rotationZ(rotatedPoints, angleZ)
const scaledPoints = scaleXYZ(rotatedPoints, scale)
return projectPoints(scaledPoints, eyeDistance)
}
The goal isn’t to build a general transform stack; it’s to keep the full pipeline visible: matrix multiplication in TypeScript, projection, and DOM updates in the browser.
From 3D to Screen
After rotation the cube is still in 3D. The screen is 2D.
A ray from the eye through the screen hits the point. Where it crosses the screen is what you draw.
The gap from the eye to the screen is eye distance (the camera's when the plane sits at ). The two triangles — eye to the screen hit, and eye to the point — are the same shape, so height on screen is a shrink of height in space:
Depth here is . Farther away means a larger depth, so a smaller mark. Same construction as .
The demo stores as eyeDistance and folds the shrink into one perspective scale . Same , two depths: the far tree lands closer to the middle, and smaller.
const projectPoints = (vertexArray: Vertex[], eyeDistance: number): Vertex[] => {
const result: Vertex[] = []
vertexArray.forEach(vertex => {
const perspectiveScale = 1 / (eyeDistance - vertex.z)
const projectionMatrix = [
[perspectiveScale, 0, 0],
[0, perspectiveScale, 0],
[0, 0, 1]
]
result.push(matrixMultiplyVertex(projectionMatrix, vertex))
})
return result
}
This simplified projection works for a small demo, though in a more robust renderer you’d guard against points crossing the projection plane.
Each frame, eight vertices become eight screen positions. Those positions render as the dots you see above.
Again: no canvas, no 3D engine—just math → DOM updates.
The Render Loop
React manages the vertices as state. When mouse movement triggers a recalculation, the component re-renders with new coordinates:
{vertices.map((vertex, index) => (
<div
key={index}
className={styles.vertex}
style={{
// Template literal: backticks allow embedded expressions ${...}
// to inject computed coordinates directly into the CSS string
transform: `translate3d(
${vertex.x + center.x}px,
${vertex.y + center.y}px,
${vertex.z}px
)`
}}
/>
))}
The browser handles the actual positioning. We compute the math; CSS does the rendering.
DOM, GPU, and Seeing the Math
Using translate3d lets the browser composite vertices on the GPU. For eight tiny divs, that’s plenty: the GPU moves a few layers while the CPU focuses on the math.
This demo isn’t about “beating” WebGL. It’s about keeping the entire 3D pipeline visible, rotation & projection, so you can read the math directly in TypeScript and see the result on screen.
Here's the full chain:
- Take a 3D point
- Apply rotation matrices
- Project to 2D
- Push the result into a CSS transform
The complete code
The functions from the article, in one place, in the order the cube runs them. This is lib/matrixTransformations.ts plus the vertex divs.
interface Vertex {
x: number
y: number
z: number
}
const rotationX = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[1, 0, 0],
[0, Math.cos(angle), -Math.sin(angle)],
[0, Math.sin(angle), Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
const rotationY = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), 0, Math.sin(angle)],
[0, 1, 0],
[-Math.sin(angle), 0, Math.cos(angle)]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
const rotationZ = (vertexArray: Vertex[], angle: number): Vertex[] => {
const rotationMatrix = [
[Math.cos(angle), -Math.sin(angle), 0],
[Math.sin(angle), Math.cos(angle), 0],
[0, 0, 1]
]
return transformPointsWithMatrix(vertexArray, rotationMatrix)
}
const transformPointsWithMatrix = (vertexArray: Vertex[], matrix: number[][]): Vertex[] => {
return vertexArray.map(vertex => matrixMultiplyVertex(matrix, vertex))
}
const matrixMultiplyVertex = (matrix: number[][], vertex: Vertex): Vertex => {
const result: number[][] = []
for (let i = 0; i < matrix.length; i++) {
let sum = 0
sum += matrix[i][0] * vertex.x
sum += matrix[i][1] * vertex.y
sum += matrix[i][2] * vertex.z
result[i] = [sum]
}
return { x: result[0][0], y: result[1][0], z: result[2][0] }
}
const scaleXYZ = (vertexArray: Vertex[], scale: number): Vertex[] => {
const scaleMatrix = [
[scale, 0, 0],
[0, scale, 0],
[0, 0, 1]
]
return transformPointsWithMatrix(vertexArray, scaleMatrix)
}
const projectPoints = (vertexArray: Vertex[], eyeDistance: number): Vertex[] => {
const result: Vertex[] = []
vertexArray.forEach(vertex => {
const perspectiveScale = 1 / (eyeDistance - vertex.z)
const projectionMatrix = [
[perspectiveScale, 0, 0],
[0, perspectiveScale, 0],
[0, 0, 1]
]
result.push(matrixMultiplyVertex(projectionMatrix, vertex))
})
return result
}
export const transformPoints = (
vertexArray: Vertex[],
angleX: number,
angleY: number,
angleZ: number,
scale: number,
eyeDistance: number,
) => {
let rotatedPoints = rotationX(vertexArray, angleX)
rotatedPoints = rotationY(rotatedPoints, angleY)
rotatedPoints = rotationZ(rotatedPoints, angleZ)
const scaledPoints = scaleXYZ(rotatedPoints, scale)
return projectPoints(scaledPoints, eyeDistance)
}
{vertices.map((vertex, index) => (
<div
key={index}
className={styles.vertex}
style={{
transform: `translate3d(
${vertex.x + center.x}px,
${vertex.y + center.y}px,
${vertex.z}px
)`
}}
/>
))}