I shipped my personal site with a 3D architecture topology in the hero: three decks of systems, packets running between nodes, faults you can trigger. No Three.js. No WebGL. Just Canvas 2D and about 20 lines of my own perspective projection.
Then I connected with a developer who does WebGL for a living, and I asked the obvious question: would you have used Three.js here?
He actually opened my code. His answer was "no, Canvas 2D is a perfectly reasonable choice for this". And then, almost as a side note: switch your animation to delta-time, so the pacing stays the same across refresh rates.
I hadn't thought about it at all. Everything was per frame. On a 120 Hz screen my "calm, cinematic" scene ran exactly twice as fast.
Why not Three.js
The scene is a fixed set of boxes. Nodes are extruded rectangles, walls are quads, packets are dots moving along lines. No meshes from files, no materials, no lights, no shadows.
For that, Three.js is a lot of library to ship to a portfolio's first screen. What I actually needed was this:
function project3D(wx, wy, wz) {
const dx = wx - camFocusX, dy = wy - camFocusY, dz = wz - camFocusZ;
const rx = dx * Math.cos(camYaw) - dz * Math.sin(camYaw);
const rz = dx * Math.sin(camYaw) + dz * Math.cos(camYaw);
const ry = dy * Math.cos(camPitch) - rz * Math.sin(camPitch);
const depth = -(dy * Math.sin(camPitch) + rz * Math.cos(camPitch));
const persp = (1100 / Math.max(340, 1100 + depth)) * camZoom;
return { x: width * 0.5 + rx * persp, y: height * 0.54 - ry * persp, depth, scale: persp };
}
Rotate by yaw, rotate by pitch, divide by distance. The Math.max(340, …) stops things from exploding when they get too close to the camera.
No z-buffer either. It's the painter's algorithm: project everything, sort walls and nodes by depth, draw back to front. With a few dozen objects, sorting every frame costs nothing.
The engine ended up at about 1,500 lines, and most of that is the stuff that makes it a site and not a demo: hover and focus, keyboard navigation, a guided story mode, fault simulation, reduced motion.
The bug: 0.12 is not a speed
Here's what my camera easing looked like:
camYaw += (targetYaw - camYaw) * 0.12;
Every frame, close 12% of the remaining distance. Feels great at 60 Hz.
But "per frame" means the speed is whatever the monitor decides. At 120 Hz you close 12% twice as often, so the camera arrives twice as fast. Same with everything else: packets moved += 0.015 per frame, faults progressed += 0.0042 per frame, flashes decayed *= 0.9 per frame. On a 120 Hz screen the whole scene was on espresso.
The lazy fix is "multiply by dt". For linear motion that's right. For easing it's wrong: 0.12 * 2 is not what two frames of 12% easing do.
Two frames at 12% leave 0.88 × 0.88 = 0.7744 of the distance, so they close 22.56%, not 24%. The correct form is:
// Animation constants were tuned at 60 fps. `frames` is how many 60 fps frames actually elapsed.
const frames = Math.min((ts - lastFrameTs) / (1000 / 60), 4);
const ease = (rate, frames) => 1 - Math.pow(1 - rate, frames);
camYaw += (targetYaw - camYaw) * ease(0.12, frames); // exponential easing
flash *= Math.pow(0.9, frames); // exponential decay
packet.t += packet.speed * frames; // linear motion: just scale
Three rules:
- Linear motion: multiply by elapsed time.
-
Exponential easing:
1 - (1 - rate)^frames. -
Decay:
factor^frames.
And cap frames, here at 4. Otherwise a tab that comes back from the background gets one giant step and everything teleports.
I kept the constants in "60 fps frames" on purpose. The motion was tuned by eye at 60 Hz, so the numbers mean "how it felt then", and the formula keeps that feeling on any screen.
The boring parts that matter more
A hero animation is the first thing on the page, so it's the first thing that can make the page bad.
- It's not in the first load. The engine is a lazy chunk. Moving it out of the eager bundle took the page's eager JS from 20.9 KB to 2.8 KB.
-
It stops when you can't see it. An
IntersectionObserveranddocument.hiddenstop the loop offscreen or in a background tab, andlastFrameTsresets, so the first frame back isn't a giant step. -
It stops when nothing moves. With
prefers-reduced-motion, the loop runs only until every eased value is within 0.001 of its target, then leaves the last frame on the canvas until you touch something.
A perf gate in CI runs Lighthouse on mobile and desktop on every PR, so the hero can't quietly get heavier.
What the agent did, and what it didn't
I built most of the engine with a coding agent over two evenings. The logic was the easy part for it: projection, sorting, hit-testing, keyboard handling, tests.
What it couldn't do was look at the screen and say "this feels nervous" or "this camera arrives too eagerly". Every constant in that easing was me squinting at a canvas. The agent also didn't catch the 120 Hz bug, and neither did I, because we were both looking at 60 Hz. It took a human who reads WebGL code for a living to see it in one pass.
That's the part I keep relearning at work: an agent is great at making the code correct. Whether it's right, how it feels, what it's for, still needs someone who's looking at the actual thing.
So: was skipping Three.js the right call? For a fixed scene of boxes, I still think yes. Ask me again when I want lighting.
Go spin it: rossev.dev. The engine is in src/scripts/iso-engine.ts in the repo.
Top comments (0)