A complete 3D graphics pipeline written in plain JavaScript, running on the CPU. No WebGL, no Three.js, no graphics API of any kind. The canvas is only somewhere to blit a finished framebuffer.
Its C++ counterpart, rasterizer_engine, does the same pipeline with SDL2 and has its own browser build.
Toggle backface culling, the barycentric wireframe overlay, and a live depth-buffer visualization while a torus rotates, with framerate, CPU time per frame and triangle count on screen.
Every stage a GPU would do in hardware, done here in a loop:
- Model transform. Rotation about Z and X composed with a translation into a single world matrix, so each vertex is transformed once rather than three times.
- Backface culling. The surface normal comes from the cross product of two triangle edges; if it points away from the camera ray, the triangle is dropped before it costs anything. Toggle it off to watch the triangle count roughly double.
- Flat shading. The dot product of the surface normal with the light direction gives one intensity per triangle, clamped so nothing goes fully black.
- Projection. A 4x4 perspective matrix built from FOV, aspect ratio and the
near/far planes. Vertices are carried as
(x, y, z, w)throughout. - Perspective divide. Dividing by
wis the step that makes distance shrink things. - Viewport transform. NDC in
[-1, 1]scaled into the 400x300 framebuffer. - Rasterization. For each triangle, a screen-space bounding box, then three
barycentric weights per pixel. Dividing the weights by the signed area makes the
coverage test (
all weights >= 0) correct for either winding order, so no separate clockwise/counter-clockwise case is needed. - Depth test. Per pixel, against a
Float32Arrayz-buffer. - Blit. The
Uint8ClampedArraycolour buffer goes to the canvas in oneputImageDatacall.
The depth value stored per pixel is 1/w, not z.
Post-divide z does not interpolate linearly in screen space: halfway across a
triangle on screen is not halfway along it in the world, so interpolating z across a
face produces a depth that is subtly wrong everywhere except the vertices. 1/w does
interpolate linearly in screen space, which is why it is captured before the perspective
divide and interpolated instead. Larger means closer, so the buffer clears to 0 for
infinitely far.
Getting this wrong is what the write-up on this
project is about: Vec3.add/sub/mul/div were
resetting w to the constructor default, so every vertex reported w = 1 after
projection and the depth buffer silently degenerated into no depth test at all. Turn on
Visualize Depth Buffer to check it: a working z-buffer shows a smooth gradient
across the surface, not a flat plate.
git clone https://github.com/apollo-2006/cpu_rasterizer.git
cd cpu_rasterizer
npm install
npm run devOther scripts: npm run build, npm run preview, npm run lint, and npm run bench
(see below). GitHub Actions lints, runs a short benchmark, builds, and publishes the site to
Pages on every push to main.
bench.mjs loads the engine half of App.jsx straight from the source file and calls
renderFrame(), the same function the demo's animation loop calls, so it times the real
pipeline rather than a copy. Node v26, Ryzen 9 5900XT, 576 triangle torus, 3000 frames
after a 300 frame JIT warm-up:
| 400x300 | mean | p99 | frames/s |
|---|---|---|---|
| default (culling on, 314 tris) | 0.43 ms | 0.88 ms | ~2,300 |
| culling off (576 tris) | 0.68 ms | 0.99 ms | ~1,470 |
| wireframe overlay | 0.35 ms | 0.56 ms | ~2,870 |
| depth buffer view | 0.56 ms | 0.80 ms | ~1,800 |
Scaling the framebuffer up, default settings: 0.91 ms at 800x600, 1.96 ms at 1280x960, 2.70 ms at 1920x1080. Even at 1080p that is about a sixth of a 60 Hz frame budget.
Culling earns its place: it halves the triangles and cuts frame time by 37%. The wireframe view is the fastest mode because it writes far fewer pixels, and the depth view costs an extra full pass over the buffer to normalize and grey it.
These numbers leave out putImageData and the browser's compositor. The demo shows the
browser-side equivalent live as CPU per frame, measured from the first matrix to
putImageData inclusive. node bench.mjs [frames] [width] [height] to reproduce.
src/App.jsx everything: Vec3, Mat4x4, torus generation, rasterizer, renderFrame, React shell
src/main.jsx React entry point
src/index.css Tailwind import
bench.mjs times renderFrame from App.jsx outside the browser
App.jsx is deliberately one file, in pipeline order: math, then rasterizer, then one
frame of the pipeline as renderFrame(), then the React loop that calls it.
- 400x300 internal resolution, upscaled with
image-rendering: pixelated. That is a choice of look, not a ceiling: the pipeline itself runs 1080p in about 3 ms (see Performance). - Flat shading only. One colour per triangle; no Gouraud or Phong interpolation, no textures, no UVs.
- No near-plane clipping. Geometry crossing the camera plane is not clipped, so it will smear rather than being cut.
- One hardcoded light and a fixed camera. Neither is movable.
- One procedural mesh. A torus generated at startup; no model loading.
MIT. See LICENSE.
Abir Deol