Daniel Arcé

Senior Front-End Developer & Design TechnologistProduction interfaces, accessibility, prototyping

3D Without Canvas or WebGL

Format: field noteEvidence: closed

Move your mouse over the cube to rotate it. Each of its eight vertices is a div; TypeScript computes the rotated coordinates and CSS transforms position the dots.

The DOM rendering is a deliberate constraint that keeps the calculation visible, from matrix multiplication to motion on screen.

Rotate eight points

A cube has eight vertices. Each vertex is a point in space: (x,y,z)(x, y, z).

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 zz moves points in the xyxy-plane. Rotating around xx moves points in the yzyz-plane.

Angles below are in radians: a quarter turn is π/2\pi/2. Coordinates are multiplied as column vectors, and these are active rotations: points move while the axes stay fixed. The right-hand convention determines each sign, as in Lynch and Park’s Modern Robotics rotation-matrix explanation.

Each axis has its own matrix. In the plane perpendicular to that axis the picture is a 2D rotation: counterclockwise when yy points up (the math convention), clockwise when yy points down (the screen).

θ measured left from +x. This is the usual math picture, with y pointing up.
θ measured right from +x. Screens number y downward, so the same matrix looks clockwise.

Around X

The first row is [1,0,0][1, 0, 0]. This preserves xx:

Rx(θ)=[1000cos⁡θ−sin⁡θ0sin⁡θcos⁡θ]R_x(\theta) = \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta \\ 0 & \sin\theta & \cos\theta \end{bmatrix}

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 [0,1,0][0, 1, 0]. A rotation around the yy-axis leaves yy unchanged while rotating points in the xzxz-plane:

Ry(θ)=[cos⁡θ0sin⁡θ010−sin⁡θ0cos⁡θ]R_y(\theta) = \begin{bmatrix} \cos\theta & 0 & \sin\theta \\ 0 & 1 & 0 \\ -\sin\theta & 0 & \cos\theta \end{bmatrix}

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 [0,0,1][0, 0, 1]. This preserves zz:

Rz(θ)=[cos⁡θ−sin⁡θ0sin⁡θcos⁡θ0001]R_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix}

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:

[x′y′z′]=R⋅[xyz]\begin{bmatrix} x' \\ y' \\ z' \end{bmatrix} = R \cdot \begin{bmatrix} x \\ y \\ z \end{bmatrix}

For example, Rz(π/2)(1,0,0)T=(0,1,0)TR_z(\pi/2)(1,0,0)^T = (0,1,0)^T: positive xx turns to positive yy. Rotation preserves length; only the later scaling and projection change the on-screen size.

The result (x′,y′,z′)(x', y', z') is the rotated position. Do that for every vertex and the whole shape turns. The origin stays put.

Apply R to every corner. Same L, turned by θ. Ghost is before, coral is after.

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: RxR_x, then RyR_y, then RzR_z.

This demo uses column vectors and v′=Mvv' = Mv (matrix on the left). Composition is right-to-left: apply RxR_x, then RyR_y, then RzR_z. 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 yy, so the same RzR_z looks clockwise on the page. The view step later is not a rotation — it is a projective scale.

Order matters. Rotating first around zz then around xx is not the same as xx then zz. Matrix multiplication is not commutative. Walk it one turn at a time:

1 · Start. The L sits on the xy plane.
2 · Apply Rz. It turns around z and stays on the floor.
3 · Then Rx. That pose tips around x.
4 · Other order: Rx first, then Rz. Same two turns, different pose.
5 · The two finals. Same turns, opposite order — not the same pose.

A single rotation about an arbitrary axis u^\hat{u} is the same idea in one step: the part of v⃗\vec{v} along u^\hat{u} is fixed; the rest spins in the plane of θ\theta.

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)
}

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 dd (the camera's zz when the plane sits at z=0z = 0). 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:

height on screen=height in space×ddepth\text{height on screen} = \text{height in space} \times \frac{d}{\text{depth}}

Depth here is d−zd - z. Farther away means a larger depth, so a smaller mark. In coordinates measured from the eye, the same horizontal construction is Bx=Ax⋅d/AzB_x = A_x \cdot d / A_z: AzA_z then denotes eye-relative depth, rather than the demo's world-space zz.

Height on screen is a shrink of height in space. Same colors as the braces.

The demo stores dd as eyeDistance and uses a normalized perspective scale s=1/(d−z)s = 1/(d - z). This omits the fixed numerator dd from the screen-plane formula above, giving a different overall scale while retaining the inverse-depth relationship. At the same eye distance, farther points land closer to the centre.

The next plate keeps the physical screen-plane construction: its scale is d/(d−z)d/(d - z). The long brace runs from the eye to the near object's depth, not from the screen to the object. Dividing the drawn screen coordinates by dd gives the demo's normalized coordinates.

Physical screen-plane scale = d/(d − z). Solid rays = near, dashed = far. Farther away → smaller on the screen, closer to the middle. Divide screen coordinates by d to get the demo’s normalized projection.
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
}

For d=4d=4, a point with x=2x=2 and z=0z=0 projects to x=0.5x=0.5; at z=2z=2 it projects to x=1x=1. The denominator is zero at the eye plane z=dz=d, not at the screen plane z=0z=0. A robust renderer must clip points before they reach or cross the eye plane; this demo does not implement that clipping.

Each frame, eight vertices become eight screen positions. Those positions render as the dots you see above.

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.

The browser decides how to composite the transformed elements. This experiment makes the coordinate calculation inspectable; it supplies no GPU-performance measurement or production-rendering comparison.

The complete code

This reference puts the transformation steps together so they can be read without moving between sections. It is a simplified equivalent of lib/matrixTransformations.ts, whose multiplication routine handles matrix dimensions explicitly. Despite its name, scaleXYZ scales only xx and yy; zz is left unchanged. The final JSX fragment belongs inside the React component, where vertices, styles, and center are supplied. The fragment and functions are not a standalone application.

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
            )`
        }}
    />
))}