2D→3D
How to build 2D→3D
A flat donut tumbles as if solid — swelling once on the same wave — then lies flat again. When you want that solid-looking move while keeping a flat, graphic look — the kind that composites as 2D art — this is the motion-graphics way to get it: tilt the rim's shape, drop it onto the screen, and take the convex hull of the points as the outline, and a flat ring reads as solid. We follow the build with code.
- Published
- June 14, 2026
- Topics
- 2D→3D · Projection · Convex Hull · SVG
Spin a disc and make its outline by projection
- You place one thick disc — tilt its axis and tumble it a half-turn over, then back
- As it tumbles, the same wave swells the disc once and shrinks it back
- You don't draw the width or the bore by hand — spin the rim, drop it on screen, and trace its outer edge
// fold the frame number into a position inside the loop
const wrap = (frame, span) => ((frame % span) + span) % span;
// the there-and-back wave: rise to the peak, fall back, then a long rest
const riseSpan = peakFrame - riseStart;
const fallSpan = fallEnd - peakFrame;
const restLo = fallEnd;
const restHi = riseStart + periodFrames;
const phaseAt = (local) => {
if (local >= restLo && local <= restHi) return null;
if (local <= peakFrame) {
const unwrapped = local >= restHi ? local - periodFrames : local;
return { p: (unwrapped - riseStart) / riseSpan, rising: true };
}
return { p: (local - peakFrame) / fallSpan, rising: false };
};
const scaleAt = (local) => {
const ph = phaseAt(local);
if (!ph) return restScale;
const amp = 1 - restScale;
if (ph.rising) return restScale + amp * cubicBezier(...scaleRise)(ph.p);
return restScale + amp * (1 - cubicBezier(...scaleFall)(ph.p));
};
const thetaAt = (local) => {
const ph = phaseAt(local);
if (!ph) return restTheta;
if (ph.rising) return 180 * cubicBezier(...thetaRise)(ph.p);
return 180 * (1 - cubicBezier(...thetaFall)(ph.p));
};scaleAt and thetaAt share one humped wave. phaseAt returns whether you're on the way out or back, and how far along (a value from 0 to 1); scaleAt runs the size from restScale up to 1 and back, and thetaAt swings the angle a half-turn and back. When the wave ends a long rest begins and the disc lies still. The curves like scaleRise are the same set as CSS cubic-bezier(), and drop straight into a ready-made library like bezier-easing.
// spin a point about the tilted axis and drop the depth onto the screen
const projectAboutTiltedAxis = (x, y, z, axisCos, axisSin, th) => {
const c = Math.cos(th);
const s = Math.sin(th);
const t = 1 - c;
const px = (t * axisCos * axisCos + c) * x + t * axisCos * axisSin * y + s * axisSin * z;
const py = t * axisCos * axisSin * x + (t * axisSin * axisSin + c) * y + -s * axisCos * z;
return [px, py];
};
const axisTilt = axisTiltDeg * Math.PI / 180;
// frame number → the disc's outline
const discAt = (frame) => {
const local = wrap(frame, periodFrames);
const scale = scaleAt(local);
const thetaDeg = thetaAt(local);
const th = thetaDeg * Math.PI / 180;
const r = peakOuterR * scale;
const onFall = local >= fallStart && local <= fallAxisEnd;
const axisCos = Math.cos(axisTilt);
const axisSin = Math.sin(axisTilt) * (onFall ? -1 : 1);
// project both rims into a point cloud
const rim = [thicknessRatio * r, -thicknessRatio * r].flatMap((fz) =>
Array.from({ length: rimCount }, (_, i) => {
const a = 2 * Math.PI * i / rimCount;
const [x, y] = projectAboutTiltedAxis(r * Math.cos(a), r * Math.sin(a), fz, axisCos, axisSin, th);
return [x + pivot[0], y + pivot[1]];
}),
);
// the outline is the convex hull of that cloud
const outline = convexHull(rim);
const edgeOnness = Math.abs(Math.sin(th));
return { outline, scale, thetaDeg, edgeOnness };
};This is what 2D→3D really is. projectAboutTiltedAxis spins a rim point about the tilted axis and drops the depth to land it on screen — Rodrigues' rotation and an orthographic projection (depth dropped straight down) fused into one formula, built from just sine and cosine. Split the rim into rimCount points, project both rims, and take the convex hull (convexHull) of that cloud — that hull is the outline, and the solid-looking width falls right out of it. convexHull is a standard convex-hull algorithm; use your own stack's.
The bore works the same way: project the inner rim and take its bounding box as an ellipse — when the disc turns edge-on the bore closes and vanishes. The solid shading is two ellipses clipped to the outline: a dark side-wall opposite the face normal (the direction square to the disc's face), a lit face on the normal side, both deepening as the disc turns edge-on and fading as it lies flat. The outline itself never depends on this shading.
To put it on screen, draw one outline path, append the bore ellipse while it's open, and punch it out with the even-odd rule. Rather than hard-coding a colour, it takes the page's own ink. Drive it with requestAnimationFrame: multiply elapsed time by your frame count to get a frame number, fold it with periodFrames, and hand it to discAt — you get the demo's loop. To play, change the axis tilt, the thickness, the bore ratio, or the easing; rimCount sets how finely the rim is sampled, so raising it smooths the outline.