Type sphere
001
Inspired by a 1970 Pirelli poster, "Industria mondiale": a circle packed with rows of the word PIRELLI, huge and heavy down the dark left side and dwindling to tiny marks towards the right, so the type alone reads as a lit ball.
The shading is analytic. For any point on the disc, shadeSphere builds the
surface normal of a sphere, takes its dot product with the light direction
(Lambert), adds an ambient floor and bends the result with contrast to get a
darkness in [0,1]. There is no image anywhere. Rows then go down at a constant
rowPitch, each one a chord of the circle, and every row is laid out word by
word from its left limb to its right. A word's scale is a straight line from
minScale in full light to maxScale in full shadow, read at the word's own
centre: size it where it starts, step to where its centre would land, size it
again. minScale is a floor, so the lit side still has tiny words rather than
gaps. The last word on each row is clipped to its row's edge, which is how the
poster's rows run off the edge too.
The shape dial swaps the ball. The sphere keeps its closed form; the cube, the
tetrahedron (triangle, a vertex towards you so it reads as a triangle of three
faces) and the donut are signed distance fields, and each word's darkness comes
from an orthographic ray cast straight back from that point, marched to the
surface, with the normal taken from the field's gradient and the same Lambert,
ambient and contrast after it. Each is turned so it shows more than one
face, and scaled so the sphere that holds each one is the ball's size, so
radius still sizes them all and none can leave the canvas however it is turned. They are flat shaded, so a face is one size of word and the faces step
rather than ramp, with ragged edges where they meet. thickness is the donut's
tube radius over its major radius.
With no circle to take chords of, each row's centre line is scanned for the runs where the ray lands on the shape, which are the row's spans. A cube or tetrahedron row has one, a donut row through the hole has two, and the words are laid out within each span exactly as before, the cycle carrying on across the hole. A span is clipped to a rectangle one row tall. Spans depend on the shape, its turn and its size but not the light, so they're worked out once and reused while the light moves; a frame then costs only the words.
The rotation folder turns the cube, tetrahedron and donut (the sphere has
nothing to turn) on top of the tilt each starts with, about the screen's axes:
x tips it, y swings it round the vertical, z rolls it. They are offsets,
so every shape is at zero when it opens, rather than absolute angles, which
would mean a different number for each shape's good pose. spin swings the
shape round the vertical at spinSpeed degrees a second, with a small nod about
the horizontal so it never repeats a pose, and works with either light. A spin
changes the silhouette every frame, so the spans are rescanned each time: in
steps of a few pixels, with each edge then bisected, which keeps it to a few
milliseconds.
The text dial is split on whitespace and cycled along each row, so a single
word reproduces the poster, and several make something closer to a paragraph.
Each row starts one word further on than the one above it. A running count
down the whole sphere would reshuffle every row below whenever the light moved
and a word dropped out of one above, so each row works from its own number
instead. Words are measured once at a fixed size and cached, then drawn by
scaling the canvas transform rather than changing the font, which keeps
thousands of words a frame cheap. It is Canvas 2D throughout.
The light orbits the shape slowly by itself, starting from the right like the
poster's. The angle dial is where it starts, and orbitSpeed is degrees per
second, so the ball waxes and wanes like a moon, including a stretch when the
light is behind it and almost every word swells. Nothing is random: a frame
depends only on the dials and how far the orbit has gone. The animation starts
paused if the system asks for reduced motion, and the same static frame is what
you get when you press pause.
Set light.source to pointer and the light follows the mouse instead: its x
and y are the pointer's offset from the middle of the shape in radii, and its
distance in front of the screen is the light's height dial, so a low one rakes
the light across the surface and a high one lights it nearly head on. When the
pointer leaves, the light holds where it was. It works under reduced motion,
since you're the one moving it, and it redraws on pointer moves rather than
needing the loop, which only runs for a spin. angle, orbitSpeed and
elevation are for the orbit and are ignored. The pause button greys out when
neither the light nor a spin is moving by itself, and pauses both together.
Source
Type sphere
A lit solid drawn in rows of words on a Canvas 2D. Each word is scaled by how dark the solid is where it lands: big in shadow, tiny in the light.
Files
index.tsx: the React shell. It holds the dials, pointer input, the frame loop for the orbiting light and spin, and the drawing.shading.ts: the solids. A sphere in closed form, and a cube, tetrahedron and torus as signed distance fields, ray cast along -z. Lambert shading with ambient and contrast gives a darkness in [0,1]. Pure maths, no DOM.layout.ts: the row layout. It finds where each row crosses the silhouette, then places words left to right within each span, sizing each one by the darkness at its centre. Pure maths, no DOM; text measuring is passed in.
Running it
It needs React and dialkit for the controls. Render the default export
anywhere; it draws its own cream paper panel.
index.tsx
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import { useDialKit } from "dialkit";
import { layoutWords, rowsFor } from "./layout.ts";
import type { Row, Span } from "./layout.ts";
import { covers, lightDirection, makeSolid, pointerLightDirection, SHAPES, shadeSolid } from "./shading.ts";
import type { ShapeKind, Solid } from "./shading.ts";
// Type sphere: after a 1970 Pirelli poster, a ball made of one word set in
// rows. Each word is sized by how dark the ball is where it lands, so it swells
// into the shadow and shrinks to a speck in the light. The ball can also be a
// cube, a tetrahedron or a donut.
//
// light direction -> shadeSolid(point) -> darkness in [0,1]
// rowsFor(silhouette) -> spans per row
// layoutWords(rows, words, darkness) -> [{ text, x, y, scale, span }]
// Canvas 2D clips each row to its spans and fills the words
//
// The light orbits the shape slowly on its own, or follows the pointer.
// Everything is analytic, so a frame depends only on the dials and where the
// light is; nothing is random.
//
// This file is the React shell: dials, canvas sizing, pointer and the frame
// loop. shading.ts is the Lambert maths and ray casting and layout.ts the row
// layout (both pure).
//
// Colours are literals rather than the playground's CSS variables so the
// sketch survives being lifted out of this repo. The canvas is the cream paper
// panel, sitting inside the dark stage.
const FONT_FAMILY = '"Helvetica Neue", Helvetica, Arial, sans-serif';
const FONT_WEIGHT = 900;
const MEASURE_SIZE = 100; // words are measured, and drawn, at this font size
const CAP_CENTRE = 0.36; // the middle of a capital, in em above the baseline
const FILL = 1.15; // font size per px of row pitch: capitals nearly fill a row
const HEIGHT_MIN = 240;
// Silhouette rows are scanned this many px at a time, with each edge then
// bisected to a fraction of a pixel. Spinning rescans every frame, so it is as
// coarse as it can be without missing anything a row would show.
const SCAN_STEP = 4;
// While spinning, the shape also nods about the horizontal this far, at 0.6 of
// the spin rate, so it never passes through the same face-on poses.
const WOBBLE = 8;
const buttonStyle: CSSProperties = {
padding: "6px 12px",
borderRadius: 8,
border: "1px solid #333",
background: "#1e2027",
color: "inherit",
cursor: "pointer",
};
// Word widths at MEASURE_SIZE, per font and word. Measuring is the slow part of
// a frame, and the same few words come round every frame.
const widths = new Map<string, number>();
function measureWord(ctx: CanvasRenderingContext2D, font: string, word: string): number {
const key = `${font}\n${word}`;
let w = widths.get(key);
if (w === undefined) {
w = ctx.measureText(word).width;
widths.set(key, w);
}
return w;
}
// What is moving by itself. The light orbits unless it is following the pointer,
// and a sphere has nothing to spin: it looks the same from every side.
function motionOf(p: { shape: string; light: { source: string; orbitSpeed: number }; rotation: { spin: boolean; spinSpeed: number } }) {
return {
orbit: p.light.source === "orbit" && p.light.orbitSpeed !== 0,
spin: p.rotation.spin && p.rotation.spinSpeed !== 0 && p.shape !== "sphere",
};
}
export default function TypeSphere() {
const p = useDialKit("Type sphere", {
text: { type: "text", default: "Lorem ipsum dolor sit amet consectetur adipiscing elit" },
uppercase: true,
rowPitch: [14, 6, 40, 1],
minScale: [0.17, 0.03, 1, 0.01],
maxScale: [1, 0.3, 1.5, 0.01],
wordGap: [0.3, 0, 1.5, 0.05],
shape: { type: "select", options: [...SHAPES], default: "sphere" },
thickness: [0.38, 0.1, 0.8, 0.01], // donut only: tube radius over major radius
radius: [0.94, 0.3, 1, 0.01],
height: [620, HEIGHT_MIN, 900, 10],
// Turns on top of each shape's own tilt (the sphere ignores them), in
// degrees about the screen's axes. Spin swings it round the vertical.
rotation: {
x: [0, -180, 180, 1],
y: [0, -180, 180, 1],
z: [0, -180, 180, 1],
spin: false,
spinSpeed: [30, -180, 180, 1],
},
light: {
// orbit turns the light round by itself (angle, orbitSpeed and elevation
// are for it); pointer aims it at the mouse, `height` units in front of
// the screen. Only orbit moves on its own.
source: { type: "select", options: ["orbit", "pointer"], default: "orbit" },
height: [1, 0.2, 4, 0.05],
elevation: [20, -80, 80, 1],
angle: [45, 0, 360, 1],
orbitSpeed: [10, -60, 60, 1],
ambient: [0.04, 0, 0.8, 0.01],
contrast: [1, 0.3, 3, 0.05],
},
paper: { type: "color", default: "#ebe6da" },
ink: { type: "color", default: "#0c0c0c" },
});
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const phaseRef = useRef(0); // degrees the light has orbited since the start
const spinRef = useRef(0); // degrees the shape has spun since the start
// Where the pointer last was, in radius units from the centre, y up. It is
// held when the pointer leaves, and starts at the upper right.
const pointerRef = useRef({ x: 0.8, y: 0.5 });
const pointerRaf = useRef(0);
// The silhouette changes with the shape, its turn and its size, not the
// light, so its rows are worked out once and reused while only the light
// moves. A spin changes the turn every frame, so it rescans every frame.
const silhouette = useRef<{ key: string; solid: Solid; rows: Row[] } | null>(null);
const loop = useRef<{ raf: number; last: number | null }>({ raf: 0, last: null });
const [width, setWidth] = useState(0);
const [playing, setPlaying] = useState(
() => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
const height = Math.max(HEIGHT_MIN, Math.round(p.height));
const orbiting = p.light.source === "orbit";
const motion = motionOf(p);
const canPlay = motion.orbit || motion.spin;
const animating = playing && canPlay;
// Always-current snapshot for the rAF loop, which must not re-subscribe on
// every dial tick.
const state = useRef({ p, width, height, animating });
state.current = { p, width, height, animating };
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setWidth(Math.floor(entry.contentRect.width)));
ro.observe(el);
setWidth(Math.floor(el.getBoundingClientRect().width));
return () => ro.disconnect();
}, []);
// Reduced motion can change while the page is open: follow it, but only to
// stop, never to start something the reader paused.
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => mq.matches && setPlaying(false);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const draw = () => {
const { p, width: w, height: h } = state.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx || w <= 0) return;
// Back the canvas with device pixels, but draw in CSS pixels.
const dpr = window.devicePixelRatio || 1;
const bw = Math.round(w * dpr);
const bh = Math.round(h * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = p.paper;
ctx.fillRect(0, 0, w, h);
const cx = w / 2;
const cy = h / 2;
const radius = (Math.min(w, h) / 2) * p.radius;
const light = p.light.source === "orbit"
? lightDirection(p.light.angle + phaseRef.current, p.light.elevation)
: pointerLightDirection(pointerRef.current.x, pointerRef.current.y, p.light.height);
const shape: ShapeKind = (SHAPES as readonly string[]).includes(p.shape)
? (p.shape as ShapeKind)
: "sphere";
// Every shape fits the unit circle however it is turned, so `radius` sizes
// it, and its silhouette is read in radius units.
const spin = p.rotation.spin ? spinRef.current : 0;
const turn = {
x: p.rotation.x + (p.rotation.spin ? WOBBLE * Math.sin((spin * 0.6 * Math.PI) / 180) : 0),
y: p.rotation.y + spin,
z: p.rotation.z,
};
const key = [shape, p.thickness, turn.x, turn.y, turn.z, cx, cy, radius, p.rowPitch].join("|");
let sil = silhouette.current;
if (!sil || sil.key !== key) {
const solid = makeSolid(shape, p.thickness, turn);
const rows = rowsFor(
{
cx,
cy,
radius,
rowPitch: p.rowPitch,
inside: (x, y) => covers(solid, (x - cx) / radius, (y - cy) / radius),
},
SCAN_STEP,
);
sil = silhouette.current = { key, solid, rows };
}
const { solid, rows } = sil;
const darkness = (x: number, y: number) =>
shadeSolid(solid, (x - cx) / radius, (y - cy) / radius, light, p.light.ambient, p.light.contrast)
.darkness;
const font = `${FONT_WEIGHT} ${MEASURE_SIZE}px ${FONT_FAMILY}`;
ctx.font = font;
ctx.textBaseline = "alphabetic";
ctx.textAlign = "left";
const unit = p.rowPitch * FILL; // font size at scale 1
const k = unit / MEASURE_SIZE; // px per measured px at scale 1
const words = p.text.split(/\s+/).filter(Boolean);
const shown = p.uppercase ? words.map((s) => s.toUpperCase()) : words;
const placed = layoutWords({
cx,
cy,
radius,
rowPitch: p.rowPitch,
rows,
words: shown,
measure: (word) => measureWord(ctx, font, word) * k,
darkness,
minScale: p.minScale,
maxScale: Math.max(p.minScale, p.maxScale),
wordGap: p.wordGap * unit,
});
// Clip each row to its span, one row tall: the last word of a span runs off
// the edge, and the edges come out ragged, as on the poster. Words arrive
// grouped by span, so the clip changes only when the span does.
ctx.fillStyle = p.ink;
let clipped: Span | null = null;
for (const word of placed) {
if (word.span !== clipped) {
if (clipped) ctx.restore();
clipped = word.span;
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.beginPath();
ctx.rect(clipped.x0, word.y - p.rowPitch / 2, clipped.x1 - clipped.x0, p.rowPitch);
ctx.clip();
}
// Set the font once and scale the transform per word: re-parsing a font
// string for thousands of words a frame costs far more.
const s = k * word.scale;
ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * word.x, dpr * word.y);
ctx.fillText(word.text, 0, CAP_CENTRE * MEASURE_SIZE);
}
if (clipped) ctx.restore();
};
const drawRef = useRef(draw);
drawRef.current = draw;
// The orbit and the spin advance only while animating, so pausing freezes
// them and the first frame is always the starting pose.
const tick = (now: number) => {
const l = loop.current;
const dt = l.last === null ? 0 : Math.min(0.1, (now - l.last) / 1000);
l.last = now;
const { p, animating } = state.current;
if (animating) {
const m = motionOf(p);
if (m.orbit) phaseRef.current += p.light.orbitSpeed * dt;
if (m.spin) spinRef.current += p.rotation.spinSpeed * dt;
}
drawRef.current();
if (animating) {
l.raf = requestAnimationFrame((t) => tickRef.current(t));
} else {
l.raf = 0;
l.last = null;
}
};
const tickRef = useRef(tick);
tickRef.current = tick;
useEffect(() => {
if (animating && !loop.current.raf) {
loop.current.raf = requestAnimationFrame((t) => tickRef.current(t));
}
}, [animating]);
useEffect(
() => () => {
cancelAnimationFrame(loop.current.raf);
cancelAnimationFrame(pointerRaf.current);
},
[],
);
// Pointer light: aim at the pointer, in radius units from the shape's centre,
// and redraw once per frame however fast the pointer reports. It works under
// reduced motion, since the reader is the one moving it, and it holds its
// last aim when the pointer leaves.
const aimLight = (e: PointerEvent<HTMLCanvasElement>) => {
const { p, width: w, height: h } = state.current;
if (p.light.source !== "pointer" || w <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const radius = (Math.min(w, h) / 2) * p.radius;
pointerRef.current = {
x: (e.clientX - rect.left - w / 2) / radius,
y: -(e.clientY - rect.top - h / 2) / radius,
};
if (!pointerRaf.current && !loop.current.raf) {
pointerRaf.current = requestAnimationFrame(() => {
pointerRaf.current = 0;
drawRef.current();
});
}
};
// When the loop is idle (paused, reduced motion), repaint on every render
// instead: a dial change or a resize.
useEffect(() => {
if (!loop.current.raf) drawRef.current();
});
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16, alignItems: "stretch" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button
onClick={() => setPlaying((v) => !v)}
disabled={!canPlay}
title={canPlay ? undefined : "Nothing is moving by itself"}
style={{
...buttonStyle,
opacity: canPlay ? 1 : 0.5,
cursor: canPlay ? "pointer" : "default",
}}
>
{animating ? "pause" : "play"}
</button>
</div>
<div ref={containerRef} style={{ width: "100%", height }}>
<canvas
ref={canvasRef}
role="img"
aria-label={`A ${p.shape === "triangle" ? "tetrahedron" : p.shape} made of rows of words, large in the shadow and small in the light`}
onPointerMove={aimLight}
onPointerDown={aimLight}
style={{
display: "block",
width,
height,
borderRadius: 4,
cursor: orbiting ? "default" : "crosshair",
}}
/>
</div>
</div>
);
}
layout.ts
// Row layout for the type sphere: pure maths, no canvas and no DOM, so it can be
// tested under node:test. The shell hands in a text-measuring function and a
// darkness function; this file decides where every word goes and how big.
//
// Units are CSS pixels from the canvas's top-left. A word's `scale` multiplies
// its natural size: at scale 1 it is as big as a row allows, at scale 0.1 it is
// a tenth of that, still centred on its row's line.
//
// The shape is whatever `inside` says it is. Each row's centre line is scanned
// for the runs of x where the shape is, which are the row's spans: a donut's
// row can have two. With no `inside`, the shape is a circle of `radius`.
export interface Span {
x0: number;
x1: number;
}
export interface Row {
y: number; // centre line of the row
spans: Span[]; // left to right, never overlapping
}
export interface PlacedWord {
text: string;
x: number; // left edge
y: number; // vertical centre of the row it sits on
scale: number;
width: number; // measured width at this scale, without the gap after it
span: Span; // the run it was set in; the caller clips to it
}
export interface LayoutInput {
cx: number; // centre of the shape's bounding circle
cy: number;
radius: number; // radius of the bounding circle: the shape fits inside it
inside?: (x: number, y: number) => boolean; // the silhouette; default a circle
rows?: readonly Row[]; // from rowsFor, if the caller cached them
rowPitch: number; // distance between row centres; constant down the sphere
words: readonly string[]; // cycled in order
measure: (word: string) => number; // width at scale 1
darkness: (x: number, y: number) => number; // 0..1 at a canvas point
minScale: number; // scale of a word in full light
maxScale: number; // scale of a word in full shadow
wordGap: number; // space after a word at scale 1; shrinks with the word
}
// Scale is linear in darkness. The floor keeps the lit side from vanishing:
// the poster's lightest words are tiny, but they are still words.
export function scaleFor(darkness: number, minScale: number, maxScale: number): number {
const d = Math.min(1, Math.max(0, darkness));
return minScale + (maxScale - minScale) * d;
}
// The runs of x in [x0, x1] where `inside` holds along the line at y. It walks at
// `step` and, wherever the answer flips between two steps, bisects the pair to
// pin the edge, so a ragged edge is accurate to a fraction of a pixel. Anything
// narrower than a step can slip through, which at a pixel or two is under a
// letter's stroke.
const BISECT_STEPS = 6;
export function findSpans(
inside: (x: number, y: number) => boolean,
y: number,
x0: number,
x1: number,
step = 1,
): Span[] {
const spans: Span[] = [];
if (!(x1 > x0) || !(step > 0)) return spans;
const edge = (out: number, inn: number) => {
for (let i = 0; i < BISECT_STEPS; i++) {
const mid = (out + inn) / 2;
if (inside(mid, y)) inn = mid;
else out = mid;
}
return (out + inn) / 2;
};
let was = inside(x0, y);
let start = x0;
let prev = x0;
for (let x = x0 + step; ; x += step) {
if (x > x1) x = x1;
const now = inside(x, y);
if (now && !was) start = edge(prev, x);
else if (!now && was) spans.push({ x0: start, x1: edge(x, prev) });
was = now;
prev = x;
if (x >= x1) break;
}
if (was && x1 > start) spans.push({ x0: start, x1 });
return spans;
}
// The rows that fit the shape: whole rows of the pitch into the bounding
// circle's diameter, centred on it, each with its spans. A circle (no `inside`)
// gets its exact chords; any other shape is scanned, and only within the
// bounding circle's chord, since nothing is outside it.
export function rowsFor(
input: Pick<LayoutInput, "cx" | "cy" | "radius" | "rowPitch" | "inside">,
step = 1,
): Row[] {
const { cx, cy, radius, rowPitch, inside } = input;
if (rowPitch <= 0 || radius <= 0) return [];
const count = Math.floor((2 * radius) / rowPitch);
const top = cy - (count * rowPitch) / 2 + rowPitch / 2;
const rows: Row[] = [];
for (let r = 0; r < count; r++) {
const y = top + r * rowPitch;
const dy = y - cy;
const half = Math.sqrt(Math.max(0, radius * radius - dy * dy));
const spans = inside
? findSpans(inside, y, cx - half, cx + half, step)
: half > 0
? [{ x0: cx - half, x1: cx + half }]
: [];
rows.push({ y, spans });
}
return rows;
}
// The cursor always moves on by at least this much, so a measure that returns
// something absurdly small can't keep a row from ending.
const MIN_ADVANCE = 0.5;
const SETTLE_STEPS = 3;
// Rows are packed top to bottom at a constant pitch and centred on the shape,
// each one cut into spans by the silhouette. Row r starts with word r % n, then
// cycles on, carrying over from one span to the next. Every span starts at its
// left edge and runs to its right, and a word that begins inside the span is
// placed even if it overhangs the edge, because the caller clips to the span
// when it draws. That reads closest to the poster, whose words march off the
// edge of the disc rather than stopping short of it.
//
// Restarting the cycle on every row, rather than carrying one count down the
// whole shape, is deliberate: the light moves, so words per row change every
// frame, and a single running count would reshuffle every row below.
//
// A word is sized by the darkness where it lands. Its width depends on its
// scale and its scale on where it lands, so the size is settled by a few
// fixed-point steps: size it at the cursor, move to where its centre would be,
// size it again. Each step is averaged with the last, which stops a word
// sitting on a hard edge of light from flipping between big and small.
export function layoutWords(input: LayoutInput): PlacedWord[] {
const { measure, darkness, minScale, maxScale, wordGap } = input;
const words = input.words.filter((w) => measure(w) > 0);
if (words.length === 0) return [];
const rows = input.rows ?? rowsFor(input);
const placed: PlacedWord[] = [];
rows.forEach((row, r) => {
const y = row.y;
let i = r;
for (const span of row.spans) {
let cursor = span.x0;
while (cursor < span.x1) {
const text = words[i % words.length];
const natural = measure(text);
let scale = scaleFor(darkness(cursor, y), minScale, maxScale);
for (let step = 0; step < SETTLE_STEPS; step++) {
const centre = cursor + (natural * scale) / 2;
scale = (scale + scaleFor(darkness(centre, y), minScale, maxScale)) / 2;
}
const width = natural * scale;
placed.push({ text, x: cursor, y, scale, width, span });
cursor += Math.max(MIN_ADVANCE, width + wordGap * scale);
i++;
}
}
});
return placed;
}
shading.ts
// Analytic Lambert shading for a sphere: pure maths, no canvas and no DOM, so
// it can be tested under node:test. The sketch asks it one question, "how dark
// is the sphere at this point?", and sizes a word by the answer.
//
// Coordinates are the unit disc: (0, 0) is the sphere's centre, the limb is at
// radius 1, x runs right and y runs DOWN the screen, like the canvas.
export type Vec3 = readonly [x: number, y: number, z: number];
export interface Shade {
darkness: number; // 0 = fully lit, 1 = fully in shadow
inside: boolean; // false when the point is off the disc
}
// A unit vector pointing at the light. +x is screen right, +y is up, +z is out
// of the screen towards the viewer. Azimuth is the angle round the vertical
// axis: 0 puts the light behind the viewer, 90 on the right, 180 behind the
// sphere. Elevation lifts it above (positive) or below (negative) the equator.
export function lightDirection(azimuthDeg: number, elevationDeg: number): Vec3 {
const az = (azimuthDeg * Math.PI) / 180;
const el = (elevationDeg * Math.PI) / 180;
return [Math.sin(az) * Math.cos(el), Math.sin(el), Math.cos(az) * Math.cos(el)];
}
// A unit vector pointing at a light that sits over the point (x, y) in the
// screen plane, `height` in front of it. x and y are in radius units from the
// shape's centre with y UP, like the light itself; height is in the same units,
// so a small one rakes the light across the surface and a large one lights it
// from nearly head on, however far out the pointer is.
export function pointerLightDirection(x: number, y: number, height: number): Vec3 {
const len = Math.hypot(x, y, height) || 1;
return [x / len, y / len, height / len];
}
// Darkness of the sphere at (x, y) on the unit disc.
//
// normal = (x, -y, sqrt(1 - x^2 - y^2)) the surface normal there
// lambert = max(0, normal . light)
// lit = ambient + (1 - ambient) * lambert ambient is the floor of light
// darkness = (1 - lit) ^ contrast contrast bends the ramp
//
// Contrast is a gamma on the darkness: above 1 it pulls the midtones towards
// the light, so the shadow shrinks to a tighter, harder crescent; below 1 it
// pushes them towards the dark, so more of the sphere swells. A point off the disc
// is reported as outside, but its darkness is still that of the nearest limb
// point, so a caller that samples a word's centre just past the edge gets a
// sensible number instead of a hole.
export function shadeSphere(
x: number,
y: number,
light: Vec3,
ambient: number,
contrast: number,
): Shade {
const r2 = x * x + y * y;
const inside = r2 <= 1;
const k = inside ? 1 : 1 / Math.sqrt(r2);
const nx = x * k;
const ny = y * k;
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
const lambert = Math.max(0, nx * light[0] - ny * light[1] + nz * light[2]);
const lit = ambient + (1 - ambient) * lambert;
const darkness = Math.pow(Math.min(1, Math.max(0, 1 - lit)), contrast);
return { darkness, inside };
}
// ---------------------------------------------------------------------------
// Other solids
//
// The sphere has a closed form, so it keeps it. A cube, a tetrahedron and a
// torus don't, so they are signed distance fields (SDFs) and each sample is a
// ray cast: an orthographic ray starting in front of the screen at (x, y) and
// running straight back along -z, advanced by the distance to the nearest
// surface until it touches one (sphere tracing). The normal is the SDF's
// gradient there; Lambert, ambient and contrast are exactly as above.
//
// Every solid is scaled so the sphere that holds it has radius 1, which is what
// the radius dial sizes. That makes the fit independent of how the solid is
// turned: however it is rotated, or spun, its silhouette stays inside the unit
// circle, and so inside the canvas, and never changes size. Each starts turned
// a little so it shows more than one face; the rotation dials turn it further.
// Turning is done by moving the ray and the light into the solid's own frame,
// so the SDFs stay axis-aligned and simple.
export type ShapeKind = "sphere" | "cube" | "triangle" | "donut";
export const SHAPES: readonly ShapeKind[] = ["sphere", "cube", "triangle", "donut"];
type Mat3 = readonly [
readonly [number, number, number],
readonly [number, number, number],
readonly [number, number, number],
];
export interface Solid {
kind: ShapeKind;
// World to local: local = frame * world.
frame: Mat3;
// Distance to the surface (a lower bound is enough) at a local point.
sdf: (x: number, y: number, z: number) => number;
}
const rad = (deg: number) => (deg * Math.PI) / 180;
function rotX(deg: number): Mat3 {
const c = Math.cos(rad(deg));
const s = Math.sin(rad(deg));
return [[1, 0, 0], [0, c, -s], [0, s, c]];
}
function rotY(deg: number): Mat3 {
const c = Math.cos(rad(deg));
const s = Math.sin(rad(deg));
return [[c, 0, s], [0, 1, 0], [-s, 0, c]];
}
function rotZ(deg: number): Mat3 {
const c = Math.cos(rad(deg));
const s = Math.sin(rad(deg));
return [[c, -s, 0], [s, c, 0], [0, 0, 1]];
}
function mul(a: Mat3, b: Mat3): Mat3 {
const out = [0, 1, 2].map((i) => [0, 1, 2].map((j) => a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]));
return out as unknown as Mat3;
}
function transpose(m: Mat3): Mat3 {
return [[m[0][0], m[1][0], m[2][0]], [m[0][1], m[1][1], m[2][1]], [m[0][2], m[1][2], m[2][2]]];
}
// Each solid's own tilt is world = base * local. A cube at 35 degrees about x
// and 45 about y shows its top and two sides.
const CUBE_TURN = mul(rotX(35), rotY(45));
// A tetrahedron with a vertex towards the viewer reads as a triangle with
// three faces meeting at the middle. Tipped a little so those faces differ.
const TETRA_TURN = mul(rotX(-12), rotY(-10));
// A torus with its axis (local y) tipped 60 degrees from the screen plane, so
// the hole is open and the near side of the ring is on top.
const DONUT_TURN = rotX(60);
// Regular tetrahedron of circumradius 1 with a vertex on +z. Each face is
// opposite a vertex, so its outward normal is minus that vertex's direction,
// and its distance from the centre is the inradius, 1/3.
const BASE_R = Math.sqrt(8) / 3;
const TETRA_VERTS: Vec3[] = [
[0, 0, 1],
...[90, 210, 330].map((a): Vec3 => [BASE_R * Math.cos(rad(a)), BASE_R * Math.sin(rad(a)), -1 / 3]),
];
const TETRA_NORMALS = TETRA_VERTS.map(([x, y, z]): Vec3 => [-x, -y, -z]);
const TETRA_INRADIUS = 1 / 3;
const IDENTITY: Mat3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
// A turn on top of a solid's own tilt, in degrees about the screen's axes: x
// tips its top towards you, y swings it round the vertical, z rolls it. They are
// applied in that order, so y spins the solid about the screen's vertical
// whatever x has done to it.
export interface Turn {
x: number;
y: number;
z: number;
}
export const NO_TURN: Turn = { x: 0, y: 0, z: 0 };
// `thickness` is the donut's tube radius as a fraction of its major radius.
// The sphere ignores the turn, being the same from every side.
export function makeSolid(kind: ShapeKind, thickness = 0.38, turn: Turn = NO_TURN): Solid {
if (kind === "sphere") {
return { kind, frame: IDENTITY, sdf: (x, y, z) => Math.hypot(x, y, z) - 1 };
}
const base = kind === "cube" ? CUBE_TURN : kind === "triangle" ? TETRA_TURN : DONUT_TURN;
const frame = transpose(mul(mul(rotZ(turn.z), mul(rotY(turn.y), rotX(turn.x))), base));
if (kind === "cube") {
const h = 1 / Math.sqrt(3); // half the edge: the corners are at radius 1
return {
kind,
frame,
sdf: (x, y, z) => {
const qx = Math.abs(x) - h;
const qy = Math.abs(y) - h;
const qz = Math.abs(z) - h;
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qy, qz), 0);
},
};
}
if (kind === "triangle") {
return {
kind,
frame,
// The largest signed distance to any face plane: exact inside, and a
// lower bound outside, which sphere tracing tolerates.
sdf: (x, y, z) => {
let d = -Infinity;
for (const n of TETRA_NORMALS) d = Math.max(d, x * n[0] + y * n[1] + z * n[2] - TETRA_INRADIUS);
return d;
},
};
}
const tube = Math.min(0.9, Math.max(0.05, thickness));
const major = 1 / (1 + tube); // major + minor = 1
const minor = major * tube;
return { kind, frame, sdf: (x, y, z) => Math.hypot(Math.hypot(x, z) - major, y) - minor };
}
// Rays that end within HIT of a surface count as touching it. MAX_STEPS bounds
// the work on a ray that grazes a silhouette without ever quite landing.
const HIT = 0.0015;
const MAX_STEPS = 48;
const NORMAL_H = 0.001;
interface Trace {
hit: boolean;
// Local point where the ray landed, or on a miss, where it came closest.
p: Vec3;
}
// Cast one ray at (x, y), in radius units (y down), through a solid.
export function trace(solid: Solid, x: number, y: number): Trace {
const { frame: m, sdf } = solid;
const wy = -y; // screen y runs down, world y runs up
// Start one radius in front of the screen plane, outside the solid.
const ox = m[0][0] * x + m[0][1] * wy + m[0][2];
const oy = m[1][0] * x + m[1][1] * wy + m[1][2];
const oz = m[2][0] * x + m[2][1] * wy + m[2][2];
const dx = -m[0][2];
const dy = -m[1][2];
const dz = -m[2][2];
// The ray crosses the unit sphere that holds every solid between these two
// distances from where it starts, so march only that stretch.
const half = Math.sqrt(Math.max(0, 1 - x * x - y * y));
const tMax = 1 + half;
let t = 1 - half;
let nearest = Infinity;
let nearT = 0;
for (let i = 0; i < MAX_STEPS; i++) {
const d = sdf(ox + dx * t, oy + dy * t, oz + dz * t);
if (d < HIT) return { hit: true, p: [ox + dx * t, oy + dy * t, oz + dz * t] };
if (d < nearest) {
nearest = d;
nearT = t;
}
t += d;
if (t > tMax) break;
}
return { hit: false, p: [ox + dx * nearT, oy + dy * nearT, oz + dz * nearT] };
}
// The SDF's gradient at a point, from four samples at the corners of a
// tetrahedron, normalised.
function gradient(sdf: Solid["sdf"], p: Vec3): Vec3 {
const h = NORMAL_H;
const a = sdf(p[0] + h, p[1] - h, p[2] - h);
const b = sdf(p[0] - h, p[1] - h, p[2] + h);
const c = sdf(p[0] - h, p[1] + h, p[2] - h);
const d = sdf(p[0] + h, p[1] + h, p[2] + h);
const nx = a - b - c + d;
const ny = -a - b + c + d;
const nz = -a + b - c + d;
const len = Math.hypot(nx, ny, nz) || 1;
return [nx / len, ny / len, nz / len];
}
// Darkness of a solid at (x, y) in radius units, same ramp as shadeSphere. The
// sphere takes its closed form, which is exact and far cheaper than a march;
// everything else goes through shadeMarched.
export function shadeSolid(
solid: Solid,
x: number,
y: number,
light: Vec3,
ambient: number,
contrast: number,
): Shade {
if (solid.kind === "sphere") return shadeSphere(x, y, light, ambient, contrast);
return shadeMarched(solid, x, y, light, ambient, contrast);
}
// The ray-cast version of shadeSolid, for any solid; a test holds it to
// shadeSphere on the sphere.
//
// `inside` is whether the ray lands on the solid, so a donut's hole is outside.
// A point outside is still shaded, by the normal where its ray passed closest,
// which for a sphere is the limb's, so a word whose centre hangs past an edge
// gets a sensible number rather than a hole. Beyond the unit circle nothing can
// be hit, so the ray is cast from the nearest point on the circle instead.
export function shadeMarched(
solid: Solid,
x: number,
y: number,
light: Vec3,
ambient: number,
contrast: number,
): Shade {
const r = Math.hypot(x, y);
const beyond = r > 1;
const k = beyond ? 1 / r : 1;
const { hit, p } = trace(solid, x * k, y * k);
const n = gradient(solid.sdf, p);
const m = solid.frame;
// The light in the solid's frame; Lambert only needs the two to agree.
const lx = m[0][0] * light[0] + m[0][1] * light[1] + m[0][2] * light[2];
const ly = m[1][0] * light[0] + m[1][1] * light[1] + m[1][2] * light[2];
const lz = m[2][0] * light[0] + m[2][1] * light[1] + m[2][2] * light[2];
const lambert = Math.max(0, n[0] * lx + n[1] * ly + n[2] * lz);
const lit = ambient + (1 - ambient) * lambert;
const darkness = Math.pow(Math.min(1, Math.max(0, 1 - lit)), contrast);
return { darkness, inside: hit && !beyond };
}
// Whether the ray at (x, y) lands on the solid: the silhouette, without the
// cost of a normal.
export function covers(solid: Solid, x: number, y: number): boolean {
if (x * x + y * y > 1) return false;
if (solid.kind === "sphere") return true;
return trace(solid, x, y).hit;
}