The robot playground has no WebGL, no three.js, no matrix library and no shaders. It
draws every frame with ctx.fill() on a 2D canvas — which is how real-time 3D worked
before GPUs were something you could assume. The whole renderer is about 120 lines. Writing it that
way makes the pipeline legible in a way a shader never is, and it makes the classic failure modes
visible in the output instead of buried in a validation layer. This post is all of it: the vector
kernel, the projection, the culling test and why its sign convention is the bug everyone writes, the
shading model, the painter's algorithm and exactly when it is wrong, and the nested-frame scheme that
turns five different robot bodies into the same twenty lines of code.
Four steps, in order
Every frame does the same four things to a list of flat-shaded quads: project each 3D point to a 2D screen position, cull the faces pointing away from the camera, shade each surviving face a single colour from its normal, then sort them far to near and fill. Roughly two hundred quads go in, about half survive the cull, and each stage is a job a GPU would do in dedicated silicon — here each is four lines.
The vector kernel: six functions, no library
Everything below stands on this. Points are three-element arrays and rotations are arrays of three
basis vectors — no matrix class, no w component, no homogeneous coordinates, because
without a GPU pipeline to feed there is nothing that requires them:
function ad(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
function sb(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function sc(a, k) { return [a[0]*k, a[1]*k, a[2]*k]; }
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function crs(a, b) { return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
function nrm(a) { var l = Math.hypot(a[0],a[1],a[2]) || 1; return [a[0]/l, a[1]/l, a[2]/l]; }
A rotation is applied as a linear combination of its basis vectors, and composed by rotating one basis by the other. That is what a matrix multiply is; writing it this way just declines the notation:
function apply(m, v) { return ad(ad(sc(m[0], v[0]), sc(m[1], v[1])), sc(m[2], v[2])); }
function mmul(a, b) { return [apply(a, b[0]), apply(a, b[1]), apply(a, b[2])]; }
function rotY(t) { var c = Math.cos(t), s = Math.sin(t);
return [[c, 0, -s], [0, 1, 0], [s, 0, c]]; }
Project
Rotate the point into camera space, then divide by depth. Perspective really is just division — a thing twice as far away is half the size — and the whole projection-matrix formalism exists to make that composable, not to make it different:
function project(p) {
var v = sb(p, cam.eye);
var x = dot(v, cam.right), y = dot(v, cam.up), z = dot(v, cam.fwd);
if (z <= 0.05) return null; // behind or on the near plane
var k = FOCAL / z; // ← the entire perspective transform
return { x: W / 2 + x * k, y: H / 2 - y * k, z: z };
}
Two details do real work. The y is subtracted because canvas coordinates
grow downward and world coordinates grow up — forget that once and the robot walks upside down. And
the z <= 0.05 guard is a one-line near-plane clip: without it, a vertex behind the eye
divides by a negative depth and lands, mirrored, on the far side of the screen. That artefact — a
face suddenly stretching across the whole canvas as you rotate — is the single most recognisable
symptom of a hand-rolled projection with no near clip.
null for a
behind-camera vertex discards the entire face rather than clipping the polygon against the near
plane. Correct clipping — Sutherland–Hodgman against one plane — would split the quad and keep the
visible part. For a scene of small boxes viewed from outside, whole-face rejection is invisible; for
a first-person camera inside the geometry it would not be.Cull
A face pointing away from the camera is on the far side of a closed body and cannot be seen, so it is discarded before any further work:
var n = nrm(crs(sb(b, a), sb(c, a))); // face normal, from winding order
var cen = sc(ad(ad(a, b), ad(c, d)), 0.25); // face centroid
if (dot(n, sb(cam.eye, cen)) <= 0) continue; // normal faces away → skip
Note the test is against eye − centroid, not against the view direction. Using the
camera's forward vector instead is the standard shortcut and it is subtly wrong off-axis: for a face
at the edge of a wide field of view, the vector to the eye and the camera's forward direction differ
by tens of degrees, and faces flicker in and out along the screen border. One extra subtraction buys
correctness at every field of view.
The normal comes from the winding order via a cross product, which means the sign of the cull is determined by how you enumerate the corners of a box. Enumerate one face counter-clockwise where its neighbours are clockwise and that single face vanishes. This is the bug everybody writes, and it is pleasantly easy to find: the robot has a hole in it.
For a closed convex body the test removes about half the geometry for one dot product per face. It is also what makes the painter's algorithm survive the next step, because the faces most likely to produce ordering artefacts — the back ones — are already gone.
Shade
One colour per face, from the angle between its normal and a fixed light direction:
function shade(col, n) {
var d = 0.42 + 0.62 * Math.max(0, dot(n, LIGHT)); // ambient + Lambert
return "rgb(" + Math.min(255, col[0]*d|0) + "," +
Math.min(255, col[1]*d|0) + "," +
Math.min(255, col[2]*d|0) + ")";
}
That is Lambert's cosine law with an ambient floor. The 0.42 is what a surface facing
away from the light still receives; the 0.62 scales the diffuse response, and the two
sum to 1.04 so a fully-lit face just saturates rather than clipping hard. The
Math.max(0, …) is not optional — a negative dot product would produce negative colour
components, which |0 then turns into an unpredictable string.
There is no interpolation across the face, so every quad is a single flat colour. That is the whole reason the sandbox looks the way it does. Smooth shading here would need per-pixel work a 2D canvas cannot do, so the aesthetic and the constraint are the same decision, and the result reads as deliberately faceted rather than as broken.
Sort
With no depth buffer, correctness is entirely a question of paint order: compute each face's mean depth, sort descending, fill in that order. Later paint covers earlier paint.
draw.sort(function (a, b) { return b.d - a.d; }); // far → near
draw.forEach(function (f) {
ctx.beginPath();
ctx.moveTo(f.p[0].x, f.p[0].y);
for (var i = 1; i < f.p.length; i++) ctx.lineTo(f.p[i].x, f.p[i].y);
ctx.closePath();
ctx.fillStyle = f.col; ctx.fill();
});
Note also that ctx.fill() antialiases the edge of every polygon, which means adjacent
coplanar quads show a hairline seam where the two antialiased edges each cover half a pixel. Real
software rasterisers avoid this with a fill rule that owns each edge exactly once. Here the fix is to
not have adjacent coplanar quads, which the box primitive naturally avoids.
Bodies are nested frames
Each robot is built from boxes in nested coordinate frames: a thigh is positioned relative to the hip, a shin relative to the knee. A frame is a rotation plus a translation, and a child frame is one function call:
function child(parent, off, rotM) {
return { m: rotM ? mmul(parent.m, rotM) : parent.m.map(r => r.slice()),
t: ad(parent.t, apply(parent.m, off)) }; // offset in parent's space
}
function pt(fr, local) { return ad(apply(fr.m, local), fr.t); }
That is a scene graph expressed as function calls rather than as a data structure, and it is why a whole limb is five lines:
var hip = child(torso, [side * 0.22, -0.42, 0]);
var thigh = child(hip, [0, -0.02, 0], rotX(swing)); // one angle…
var knee = child(thigh, [0, -0.46, 0]);
var shin = child(knee, [0, -0.02, 0], rotX(bend)); // …and everything
box(thigh, 0.11, 0.24, 0.12, COL.limb); // below inherits
box(shin, 0.09, 0.24, 0.10, COL.limb);
Animating the leg means changing swing. The knee, the shin and every vertex of both
boxes follow because they were defined relative to it — no per-frame bookkeeping, no transform
propagation pass, no dirty flags.
Gaits are trigonometry, not keyframes
Legs swing on sine waves offset in phase; a quadruped's diagonal pairs share a phase; the drone tilts into its acceleration; the rover's wheels rotate at a rate proportional to ground speed.
state.phase += speed * dt * 6.5; // phase advances with motion
var swing = Math.sin(state.phase) * 0.55; // humanoid: legs in antiphase
// quadruped: diagonal pairs, offset by π
var lift = Math.max(0, Math.sin(state.phase + L.off)) * 0.3;
// drone: tilt toward acceleration, so it leans into a turn
var tilt = clamp(accel * 0.28, -0.5, 0.5);
Tying phase to distance travelled rather than to wall-clock time is the detail that makes
it read as walking rather than as marching: the feet stop when the robot stops, and a slow walk has
slow legs. Drive the phase from performance.now() and the robot moon-walks whenever its
speed changes.
This is a handful of trigonometric functions rather than an animation system, and it is why swapping the body — humanoid, quadruped, rover, arm, drone — changes the feel so completely while the controller barely changes at all. The morphology does the work. That is the point the sandbox exists to make.
Using it yourself
The renderer is about sixty lines of the file and has no dependency on anything else in it. To
lift it into your own page you need project, shade, the vector kernel, and
a draw loop of this shape:
var faces = [];
function quad(a, b, c, d, color) { faces.push({ p: [a, b, c, d], col: color }); }
function frame() {
faces.length = 0;
buildScene(); // push quads in world coordinates
var draw = [];
faces.forEach(function (f) {
var n = nrm(crs(sb(f.p[1], f.p[0]), sb(f.p[2], f.p[0])));
var cen = sc(f.p.reduce(ad), 1 / f.p.length);
if (dot(n, sb(cam.eye, cen)) <= 0) return; // cull
var ps = f.p.map(project);
if (ps.some(function (p) { return !p; })) return; // near-plane reject
draw.push({ p: ps, col: shade(f.col, n), d: dot(sb(cen, cam.eye), cam.fwd) });
});
draw.sort(function (a, b) { return b.d - a.d; }); // painter's order
draw.forEach(fill);
requestAnimationFrame(frame);
}
Sensible things to point it at: a robot arm's reachable workspace, a physics toy, a voxel scene, data visualisation in 3D, or any diagram where you want depth cueing and refuse to ship a 600 KB engine to get it. The budget is the constraint — see below — so favour scenes made of tens of primitives, not thousands.
Why bother, when WebGL exists
Three reasons, in ascending order of honesty.
It has zero dependencies and works everywhere a canvas does, including the contexts where WebGL is blocked, unavailable, or silently software-emulated. It is small enough to read in one sitting — the whole pipeline is on one screen, whereas the equivalent WebGL version buries half of it in GLSL and the other half in buffer setup, and neither half can be inspected in a debugger. And the failure modes are legible: get the cull backwards and the robot turns inside out; get the sort backwards and it paints itself hidden; drop the near clip and a face swings across the screen. Those are bugs you learn from, because they are visible in the output rather than in a validation layer.
The cost is a hard ceiling on geometry. Per-face JavaScript work plus a Path2D fill
per quad puts the practical budget around here:
| faces per frame | result |
|---|---|
| ~100–300 | a comfortable 60fps, which is where this scene sits |
| ~1 000 | 30–60fps, sort and fill both starting to show |
| ~5 000+ | use WebGL; you are now writing a rasteriser badly |
That constraint shaped the visual design more than any aesthetic decision did — and being explicit about which of your design choices are actually constraints wearing a costume is a habit worth keeping.
Related: the other end of the rendering spectrum, where the geometry is millions of ellipsoids and the sort is the entire problem — how the Gaussian splatting viewer works.