Word sphere
002
Inspired by the 1970 Pirelli "Industria Mondiale" poster: a sphere built from rows of the PIRELLI logotype, black ink on cream paper. The words are big and heavy on the shadowed left, shrink toward the lit right so that side reads lighter, and the sphere's left edge follows the circle. The poster cuts its sphere off with a flat vertical edge on the right; this sketch doesn't, and shows the whole ball.
The sphere is analytic: a ray-sphere hit in the fragment shader, with a normal,
Lambert diffuse and ambient, and optional Blinn-Phong specular and rim. Rows of
fixed height are laid across it, and each cell shades the sphere once, at its
centre, so a cell is one value. That value becomes ink (the poster inks the
shadow, so invert is on), shaped by gamma and contrast, and ink decides
the mark. The whole pipeline runs in one WebGL2 fragment shader: each pixel
finds its cell and draws from the mark atlas, so the cost doesn't grow with the
number of words.
scale is the poster. The whole mark is drawn smaller as the ink falls, from
full size down to minScale, anchored at its left-centre, and cells with less
ink than cutoff draw nothing. ramp is for text only: one character per
cell, picked from the text by how much ink each character has. The sketch
measures every character's coverage on a canvas and sorts them lightest first,
so .:-=+*#%@ works as typed and so does any other string. An image mark has no
characters to rank, so ramp falls back to scale for it.
The poster's words sit end to end: a word that shrinks pulls the next one in, so
the lit side stays dense with small words. A pixel can't find a word by
division when its position depends on every width before it, so with pack on
(the default) the rows are laid out per row in JS, a few thousand shades, and
uploaded as a texture the shader binary-searches. Turn pack off for a fixed
grid, where every cell is a full-size word wide and small words sit in its
left corner with gaps, and stagger offsets alternate rows. alignLeft starts
each row on the circle's edge, as the poster does, instead of the bounding box.
The whole sphere stays on the canvas. A cell belongs to the sphere when its
left edge is inside the circle, so marks overhang the right-hand rim by up to a
word; radius is a share of the shorter side, pulled in just enough to leave
room for that overhang, and it only bites on a narrow stage.
The light is the pointer. Hover the canvas and the light moves to the point of
the sphere under it, then eases back to the azimuth and elevation dials when
the pointer leaves. spin turns the light around the sphere in rad/s while
playing; it starts at 0, so the poster holds still, and the animation starts
paused if the system asks for reduced motion (hovering still moves the light).
The mark is text (typed as is, in a heavy sans) or an image: drop one on the
canvas or use "Choose image". An image with real transparency is its own shape,
so its alpha is the mark. An opaque one has none, so its darkness is: dark
pixels become ink and light ones paper. Either way the mark is cropped to its
ink, shrunk to 512 px, read into memory and sampled with mipmaps, so marks
drawn small are averaged rather than aliased. Nothing is saved. ink and
paper are the two colours; turn paperOn off to leave the canvas transparent
and let the ink sit on the dark stage, where you will want a lighter ink.
Source
Word sphere
A lit sphere drawn as rows of words, by one WebGL2 fragment shader. Each cell holds a mark (a string of text, or a dropped-in image) and the sphere's shading decides how big it is, or which character it is.
Files
index.tsx: the React shell. It holds the dials and buttons, image loading, the pointer-driven light and the frame loop.renderer.ts: the WebGL2 side. It holds the GLSL, context loss handling, drawing-buffer sizing, the atlas and layout textures, and uniform packing.createSphereRenderer()is its only entry point.sphere.ts: the sphere, the light, the grid and the packed row layout. Pure maths, no GL or DOM; the shader mirrors it.sphereRadiusPx()keeps the whole sphere, overhanging marks included, on the canvas.atlas.ts: the mark atlas type and the pure parts of building one (ramp ordering, cropping, packing, image masks). No GL or DOM.rasterize.ts: the 2D-canvas half of the atlas. It draws glyphs and reads image pixels, then hands the bytes toatlas.ts.
Running it
It needs React, dialkit for the controls and a browser with WebGL2. Render the
default export on a dark background; the canvas paints its own paper. The
Helvetica Neue / Arial Black font stack is literal, so a machine without
either falls back to Arial.
atlas.ts
// The mark atlas: the one picture the shader draws every cell from, as a mask
// (one byte per texel, 255 where ink goes). A mark is a string of text, a ramp
// of characters, or a dropped-in image.
//
// Everything here is pure (no DOM, no GL), so it is tested under node:test.
// rasterize.ts is the other half: it draws glyphs and reads image pixels on a 2D
// canvas, then hands the bytes to these functions to order, crop and pack.
export interface Atlas {
mask: Uint8Array; // width * height bytes, row-major from the top-left
width: number;
height: number;
tiles: number; // 1 for a single mark; n for a ramp laid out as n tiles in a row
aspect: number; // width / height of one mark (of one tile, for a ramp)
chars: string; // a ramp's characters, lightest first; "" for a single mark
}
// The ramp is limited so its row of tiles fits a texture on any WebGL2 device.
export const MAX_RAMP = 48;
export const MAX_TEXT_CHARS = 256; // input past this is ignored: it's a mark, not a document
// --- pure: characters ------------------------------------------------------
// The distinct characters of `text` in first-seen order, whitespace folded to a
// single space so a tab or newline can't mint its own ramp step. Code points,
// not UTF-16 units, so an emoji stays whole.
export function uniqueChars(text: string, max = MAX_TEXT_CHARS): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of Array.from(text).slice(0, max)) {
const ch = /\s/.test(raw) ? " " : raw;
if (seen.has(ch)) continue;
seen.add(ch);
out.push(ch);
}
return out;
}
// Characters sorted by ascending ink coverage. Ties keep their original order,
// so the ramp for a given string is the same on every run.
export function orderByCoverage(chars: string[], coverage: number[]): string[] {
return chars
.map((ch, i) => ({ ch, i, c: coverage[i] ?? 0 }))
.sort((a, b) => a.c - b.c || a.i - b.i)
.map((e) => e.ch);
}
// At most `max` items, evenly spread and always keeping the first and last, so
// a long ramp thins out without losing either end of its tone range.
export function subsampleEvenly<T>(items: T[], max: number): T[] {
if (items.length <= max) return items;
if (max <= 1) return items.slice(0, Math.max(0, max));
return Array.from({ length: max }, (_, i) => items[Math.round((i * (items.length - 1)) / (max - 1))]);
}
// --- pure: masks -----------------------------------------------------------
// Mean ink of a mask in [0,1]: the share of its texels that are covered.
export function coverageOf(mask: Uint8Array): number {
if (mask.length === 0) return 0;
let sum = 0;
for (let i = 0; i < mask.length; i++) sum += mask[i];
return sum / (255 * mask.length);
}
export type MaskMode = "alpha" | "luminance";
const lum = (r: number, g: number, b: number) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
// An image becomes a mask one of two ways. A picture with real transparency (a
// logo or cut-out) is its own shape, so its alpha is the mask. An opaque one
// has no shape of its own, so its darkness is: dark pixels are ink and light
// ones are paper, the way a photo or scan reads. "Real transparency" is more
// than 2% of pixels not fully opaque, so a stray edge pixel doesn't count.
export function chooseMaskMode(rgba: Uint8ClampedArray): MaskMode {
const n = Math.floor(rgba.length / 4);
if (n === 0) return "luminance";
let see = 0;
for (let i = 0; i < n; i++) if (rgba[i * 4 + 3] < 250) see++;
return see / n > 0.02 ? "alpha" : "luminance";
}
// RGBA bytes to a mask. Luminance mode composites on white first, so a
// half-transparent pixel counts as lighter, then inverts.
export function maskFromRgba(rgba: Uint8ClampedArray, mode: MaskMode): Uint8Array {
const n = Math.floor(rgba.length / 4);
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) {
const a = rgba[i * 4 + 3] / 255;
if (mode === "alpha") {
out[i] = rgba[i * 4 + 3];
} else {
const l = lum(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]) * a + (1 - a);
out[i] = Math.round((1 - l) * 255);
}
}
return out;
}
export interface Box {
x: number;
y: number;
w: number;
h: number;
}
// The smallest box holding every texel above `threshold`, or null for a blank
// mask. Cropping to it makes a mark's aspect ratio the ink's, not the canvas's.
export function inkBounds(mask: Uint8Array, width: number, height: number, threshold = 8): Box | null {
let x0 = width;
let y0 = height;
let x1 = -1;
let y1 = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (mask[y * width + x] > threshold) {
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
return x1 < 0 ? null : { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
}
// Copy `box` out of a mask, with a margin of empty texels all round. The margin
// is a mark's built-in gap to its neighbours and keeps mip levels from
// smearing ink into a neighbouring tile.
export function cropMask(
mask: Uint8Array,
width: number,
box: Box,
padX = 0,
padY = 0,
): { mask: Uint8Array; width: number; height: number } {
const w = box.w + 2 * padX;
const h = box.h + 2 * padY;
const out = new Uint8Array(w * h);
for (let y = 0; y < box.h; y++) {
const from = (box.y + y) * width + box.x;
out.set(mask.subarray(from, from + box.w), (y + padY) * w + padX);
}
return { mask: out, width: w, height: h };
}
// Lay equal-size tiles out left to right in one mask.
export function packTiles(tiles: Uint8Array[], tileW: number, tileH: number): Uint8Array {
const width = tileW * tiles.length;
const out = new Uint8Array(width * tileH);
tiles.forEach((tile, i) => {
for (let y = 0; y < tileH; y++) {
out.set(tile.subarray(y * tileW, (y + 1) * tileW), y * width + i * tileW);
}
});
return out;
}
// Shrink (never grow) a size so its longer side is at most `max`, keeping the
// aspect ratio.
export function fitSize(w: number, h: number, max: number): { w: number; h: number } {
const k = Math.min(1, max / Math.max(w, h, 1));
return { w: Math.max(1, Math.round(w * k)), h: Math.max(1, Math.round(h * k)) };
}
export function median(values: number[]): number {
if (values.length === 0) return 0;
const s = [...values].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
index.tsx
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, DragEvent, PointerEvent } from "react";
import { useDialKitController } from "dialkit";
import { buildImageAtlas, buildRampAtlas, buildTextAtlas } from "./rasterize.ts";
import type { Atlas } from "./atlas.ts";
import { createSphereRenderer } from "./renderer.ts";
import type { SphereRenderer } from "./renderer.ts";
import { easeLight, lightFromAngles, lightSettled, pointerToLight, sphereRadiusPx } from "./sphere.ts";
import type { Vec3 } from "./sphere.ts";
// Word sphere: an analytically lit sphere, rasterized into rows of words. Each
// cell holds one mark (a string of text or a dropped-in image) whose size
// follows the shading, after the 1970 Pirelli "Industria Mondiale" poster.
//
// sphere + light -> shade(cell centre) -> ink = 1 - shade -> mark size / glyph
// render (scale | ramp) -> one mark scaled by ink, or one
// character of a ramp picked by ink
//
// The pointer is the light: hover the canvas and the light moves to the point of
// the sphere under it, then eases back to the dialled direction on leave.
//
// This file is the React shell: dials, buttons, image loading, the light and
// the frame loop. atlas.ts builds the mark bitmap and sphere.ts holds the maths
// (both testable without a browser); renderer.ts owns WebGL and the shader.
//
// Colours are literals rather than the playground's CSS variables so the
// sketch survives being lifted out of this repo. They assume the dark stage.
const STAGE_HEIGHT_MIN = 240;
const LIGHT_EASE = 7; // 1/s: how fast the light chases the pointer, or home
const buttonStyle: CSSProperties = {
padding: "6px 12px",
borderRadius: 8,
border: "1px solid #333",
background: "#1e2027",
color: "inherit",
cursor: "pointer",
};
export default function WordSphere() {
// The controller (not plain useDialKit) so loading an image can flip the
// `mark` select from code.
const { values: p, setValue } = useDialKitController("Word sphere", {
mark: { type: "select", options: ["text", "image"], default: "text" },
text: { type: "text", default: "MLDUKE", placeholder: "A word, or characters for ramp" },
// scale draws the whole mark smaller where it's lit; ramp draws one
// character per cell, picked from the text by how much ink it has.
render: { type: "select", options: ["scale", "ramp"], default: "scale" },
ink: { type: "color", default: "#141414" },
paper: { type: "color", default: "#ece8de" },
paperOn: true,
sphere: {
radius: [0.46, 0.15, 0.5, 0.01], // of min(width, height); shrinks to keep marks on the canvas
azimuth: [45, -180, 180, 1], // 0 at the viewer, 90 from the right
elevation: [12, -90, 90, 1],
spin: [0, -3, 3, 0.05], // rad/s the light circles; 0 holds still
},
light: {
ambient: [0.08, 0, 1, 0.01],
diffuse: [0.95, 0, 2, 0.01],
specular: [0, 0, 1, 0.01],
shininess: [32, 1, 128, 1],
rim: [0, 0, 1, 0.01],
},
grid: {
rowHeight: [13, 4, 40, 1],
fill: [0.8, 0.2, 1, 0.01], // mark height as a share of the row
cellWidth: [1, 0.5, 2, 0.01], // 1 packs marks edge to edge
stagger: [0, 0, 1, 0.01], // fixed grid only: odd rows shift by this much of a cell
pack: true, // words end to end, each as wide as it is drawn, like the poster
alignLeft: true, // rows start on the circle's edge, as on the poster
height: [620, STAGE_HEIGHT_MIN, 900, 10],
},
tone: {
invert: true, // ink where it's dark
gamma: [1, 0.2, 3, 0.01],
contrast: [1, 0.2, 3, 0.05],
minScale: [0.3, 0.05, 1, 0.01],
cutoff: [0, 0, 1, 0.01], // cells with less ink than this draw nothing
},
});
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const rendererRef = useRef<SphereRenderer | null>(null);
const timeRef = useRef(0);
const dragDepth = useRef(0);
const loadToken = useRef(0);
const pointer = useRef<{ x: number; y: number } | null>(null); // canvas px; null when away
const lightRef = useRef<Vec3>(lightFromAngles(p.sphere.azimuth, p.sphere.elevation));
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 [imageAtlas, setImageAtlas] = useState<Atlas | null>(null);
const [imageName, setImageName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [glError, setGlError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const height = Math.max(STAGE_HEIGHT_MIN, Math.round(p.grid.height));
const isImage = p.mark === "image";
const canPlay = p.sphere.spin !== 0;
const animating = playing && canPlay;
// The mark atlas: an image's, once one is loaded, or the text built the way
// the render mode needs it. An image has no characters to rank, so `ramp`
// falls back to `scale` for it without any special case.
const textAtlas = useMemo(() => {
if (isImage) return null;
try {
return p.render === "ramp" ? buildRampAtlas(p.text) : buildTextAtlas(p.text);
} catch {
return null;
}
}, [isImage, p.render, p.text]);
const atlas = isImage ? imageAtlas : textAtlas;
// Always-current snapshot for the rAF loop, which must not re-subscribe on
// every dial tick.
const aspect = atlas?.aspect ?? 1;
const state = useRef({ p, width, height, animating, aspect });
state.current = { p, width, height, animating, aspect };
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();
}, []);
// Renderer: created once, and it rebuilds itself if the browser takes the GL
// context away and gives it back. Declared before the draw effects so the
// first paint has a program.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const renderer = createSphereRenderer(canvas, {
onError: setGlError,
onRestore: () => drawRef.current(),
});
rendererRef.current = renderer;
return () => {
loadToken.current += 1; // a decode still in flight is now stale
renderer?.destroy();
rendererRef.current = null;
};
}, []);
useEffect(() => {
rendererRef.current?.setAtlas(atlas);
}, [atlas]);
// Where the light wants to be: under the pointer while it hovers, otherwise
// the dialled direction, turned by `spin` over the play clock.
const targetLight = (): Vec3 => {
const { p, width: w, height: h, aspect } = state.current;
const ptr = pointer.current;
if (ptr) {
const { rowHeight, fill } = p.grid;
return pointerToLight(ptr.x, ptr.y, w / 2, h / 2, sphereRadiusPx(p.sphere.radius, w, h, Math.max(2, rowHeight), fill, aspect));
}
const turn = (p.sphere.spin * timeRef.current * 180) / Math.PI;
return lightFromAngles(p.sphere.azimuth + turn, p.sphere.elevation);
};
const draw = () => {
const { p, width: w, height: h } = state.current;
rendererRef.current?.render({ ...p, width: w, height: h }, lightRef.current);
};
const drawRef = useRef(draw);
drawRef.current = draw;
// One frame loop for two clocks. Time advances only while animating, so
// pausing freezes the spin and the first frame is always t = 0. The light
// eases toward its target whenever it hasn't arrived, paused or not, since the
// pointer sent it. The loop stops itself once neither needs it.
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;
if (state.current.animating) timeRef.current += dt;
const target = targetLight();
lightRef.current = easeLight(lightRef.current, target, dt, LIGHT_EASE);
const easing = !lightSettled(lightRef.current, target);
if (!easing) lightRef.current = target;
drawRef.current();
if (state.current.animating || easing) {
l.raf = requestAnimationFrame((t) => tickRef.current(t));
} else {
l.raf = 0;
l.last = null;
}
};
const tickRef = useRef(tick);
tickRef.current = tick;
const startLoop = () => {
if (!loop.current.raf) loop.current.raf = requestAnimationFrame((t) => tickRef.current(t));
};
useEffect(() => {
if (animating) startLoop();
}, [animating]);
useEffect(() => () => cancelAnimationFrame(loop.current.raf), []);
// When the loop is idle, repaint on every render (dial change, resize, new
// mark) instead. A dial change moves the light at once; only the pointer's
// coming and going is eased.
useEffect(() => {
if (loop.current.raf) return;
if (!pointer.current) lightRef.current = targetLight();
drawRef.current();
});
// Hover is the light: the pointer's place on the canvas picks the direction.
const onPointerMove = (e: PointerEvent) => {
const box = e.currentTarget.getBoundingClientRect();
pointer.current = { x: e.clientX - box.left, y: e.clientY - box.top };
startLoop();
};
const onPointerLeave = () => {
pointer.current = null;
startLoop();
};
// Latest load wins: one that resolves after a newer load started (or after
// unmount) is dropped, and its bitmap closed. Held in memory only: decoded,
// reduced to a mask and uploaded to the GPU, never stored.
async function loadImage(file: Blob, name: string) {
const token = ++loadToken.current;
try {
if (!file.type.startsWith("image/")) {
setError("That file isn't an image");
return;
}
const bitmap = await createImageBitmap(file);
const made = token === loadToken.current ? buildImageAtlas(bitmap) : null;
bitmap.close();
if (token !== loadToken.current) return;
if (!made) {
setError("That image has no marks to draw");
return;
}
setImageAtlas(made);
setImageName(name);
setError(null);
setValue("mark", "image");
} catch {
if (token === loadToken.current) setError("Couldn't read that image");
}
}
const loadFile = (file: File | undefined) => {
if (file) void loadImage(file, file.name);
};
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
};
const onDragEnter = (e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
dragDepth.current += 1;
setDragging(true);
};
const onDragLeave = () => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
dragDepth.current = 0;
setDragging(false);
loadFile(e.dataTransfer.files[0]);
};
const showHint = isImage && !imageAtlas;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16, alignItems: "stretch" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<button
onClick={() => setPlaying((v) => !v)}
disabled={!canPlay}
title={canPlay ? undefined : "Spin is 0, so there is nothing to play"}
style={{ ...buttonStyle, opacity: canPlay ? 1 : 0.5, cursor: canPlay ? "pointer" : "default" }}
>
{animating ? "pause" : "play"}
</button>
<button onClick={() => fileRef.current?.click()} style={buttonStyle}>
Choose image
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
loadFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
{((isImage && imageName) || error) && (
<span style={{ fontSize: 12, color: error ? "#e07a7a" : "#8a909e" }}>{error ?? imageName}</span>
)}
</div>
<div
ref={containerRef}
onDragOver={onDragOver}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
style={{
position: "relative",
width: "100%",
height,
borderRadius: 8,
outline: dragging ? "2px dashed #3d7cf2" : "2px dashed transparent",
outlineOffset: 4,
}}
>
{glError ? (
<div style={{ fontSize: 13, color: "#e07a7a" }}>{glError}</div>
) : (
<canvas
ref={canvasRef}
role="img"
aria-label={
isImage ? "A sphere built from rows of an image" : `A sphere built from rows of the text ${p.text}`
}
style={{ display: "block", width, height, borderRadius: 8 }}
/>
)}
{showHint && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
<span
style={{
padding: "10px 16px",
borderRadius: 8,
background: "#16181dE6",
border: "1px solid #333",
fontSize: 13,
color: "#c4c8d2",
}}
>
Drop an image or choose a file
</span>
</div>
)}
</div>
</div>
);
}
rasterize.ts
import {
chooseMaskMode,
coverageOf,
cropMask,
fitSize,
inkBounds,
MAX_RAMP,
MAX_TEXT_CHARS,
maskFromRgba,
median,
orderByCoverage,
packTiles,
subsampleEvenly,
uniqueChars,
} from "./atlas.ts";
import type { Atlas } from "./atlas.ts";
// The DOM half of the atlas: draws a string's glyphs, or reads an image's
// pixels, on a 2D canvas and turns them into an Atlas with the pure functions
// in atlas.ts. Nothing here runs at import.
const MAX_IMAGE_PX = 512; // marks are small; a bigger source only costs memory
const MAX_ATLAS_PX = 2048; // widest a text mark's bitmap may get
// Heavy and wide, with literal fallbacks so the atlas looks alike wherever the
// first choice is missing. Sized per use.
const FONT_STACK = `"Helvetica Neue", "Arial Black", Arial, sans-serif`;
const FONT_WEIGHT = 900;
function scratch(width: number, height: number): CanvasRenderingContext2D {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("A 2D canvas isn't available");
return ctx;
}
function alphaOf(ctx: CanvasRenderingContext2D): Uint8Array {
const { width, height } = ctx.canvas;
return maskFromRgba(ctx.getImageData(0, 0, width, height).data, "alpha");
}
const fontOf = (px: number) => `${FONT_WEIGHT} ${px}px ${FONT_STACK}`;
// The whole string as one mark, cropped to its ink. Null for blank text.
export function buildTextAtlas(text: string): Atlas | null {
const str = Array.from(text).slice(0, MAX_TEXT_CHARS).join("").replace(/\s+/g, " ").trim();
if (!str) return null;
// Rasterize big and let the mipmaps shrink it. A very long string is drawn
// smaller instead, to stay inside the texture limit.
const probe = scratch(1, 1);
const BASE_PX = 160;
probe.font = fontOf(BASE_PX);
const perPx = probe.measureText(str).width / BASE_PX; // string width in ems
const px = Math.max(24, Math.min(BASE_PX, Math.floor(MAX_ATLAS_PX / (perPx + 0.6))));
const margin = Math.ceil(px * 0.3);
probe.font = fontOf(px);
const width = Math.ceil(probe.measureText(str).width) + 2 * margin;
const height = Math.ceil(px * 1.7);
const ctx = scratch(width, height);
ctx.font = fontOf(px);
ctx.fillStyle = "#fff";
ctx.textBaseline = "alphabetic";
ctx.fillText(str, margin, Math.round(px * 1.2));
const mask = alphaOf(ctx);
const box = inkBounds(mask, width, height);
if (!box) return null;
// A side margin of 14% of the height each side, so words an em or so apart
// stay readable as separate words when they pack end to end.
const cropped = cropMask(mask, width, box, Math.round(box.h * 0.14), 1);
return {
mask: cropped.mask,
width: cropped.width,
height: cropped.height,
tiles: 1,
aspect: cropped.width / cropped.height,
chars: "",
};
}
const TILE_H = 64; // a ramp tile's height, texels
const TILE_CAP = 54; // its capital height; the rest is margin
const TILE_PAD_X = 6;
// One tile per distinct character of `text`, ordered by measured coverage so
// the first is the lightest. Every tile shares a size and baseline, with glyphs
// centred and squeezed to fit, so the cells of a ramp line up like type.
export function buildRampAtlas(text: string): Atlas | null {
const chars = uniqueChars(text);
if (chars.length === 0) return null;
const probe = scratch(1, 1);
probe.font = fontOf(100);
const capRatio = probe.measureText("H").actualBoundingBoxAscent / 100 || 0.72;
const px = TILE_CAP / capRatio;
probe.font = fontOf(px);
const ink = chars.map((ch) => {
const m = probe.measureText(ch);
return m.actualBoundingBoxLeft + m.actualBoundingBoxRight;
});
const typical = median(ink.filter((w) => w > 0));
const tileW = Math.min(120, Math.max(24, Math.round(typical) + 2 * TILE_PAD_X));
const ctx = scratch(tileW, TILE_H);
const tiles = chars.map((ch, i) => {
ctx.clearRect(0, 0, tileW, TILE_H);
ctx.font = fontOf(px);
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
const sx = ink[i] > 0 ? Math.min(1, (tileW - 2 * TILE_PAD_X) / ink[i]) : 1;
ctx.save();
ctx.translate(tileW / 2, (TILE_H - TILE_CAP) / 2 + TILE_CAP);
ctx.scale(sx, 1);
ctx.fillText(ch, 0, 0);
ctx.restore();
return alphaOf(ctx);
});
const order = orderByCoverage(chars, tiles.map(coverageOf));
const kept = subsampleEvenly(order, MAX_RAMP);
const byChar = new Map(chars.map((ch, i) => [ch, tiles[i]]));
const packed = packTiles(
kept.map((ch) => byChar.get(ch)!),
tileW,
TILE_H,
);
return {
mask: packed,
width: tileW * kept.length,
height: TILE_H,
tiles: kept.length,
aspect: tileW / TILE_H,
chars: kept.join(""),
};
}
// A decoded image as a mark: shrunk to MAX_IMAGE_PX, turned into a mask by
// chooseMaskMode() and cropped to its ink. Null when it has none to draw (a
// blank page, an all-transparent PNG).
export function buildImageAtlas(bitmap: ImageBitmap): Atlas | null {
const { w, h } = fitSize(bitmap.width, bitmap.height, MAX_IMAGE_PX);
const ctx = scratch(w, h);
ctx.drawImage(bitmap, 0, 0, w, h);
const rgba = ctx.getImageData(0, 0, w, h).data;
const mask = maskFromRgba(rgba, chooseMaskMode(rgba));
const box = inkBounds(mask, w, h);
if (!box) return null;
// A side margin of 10% of the height keeps neighbours from fusing when the
// marks pack end to end.
const cropped = cropMask(mask, w, box, Math.round(box.h * 0.1), 1);
return {
mask: cropped.mask,
width: cropped.width,
height: cropped.height,
tiles: 1,
aspect: cropped.width / cropped.height,
chars: "",
};
}
renderer.ts
import type { Atlas } from "./atlas.ts";
import { cellWidthPx, MAX_ROW_CELLS, packedHalfRows, packedLayout, sphereRadiusPx } from "./sphere.ts";
import type { Vec3 } from "./sphere.ts";
// The WebGL2 side of the word sphere: GLSL, compile and link, context loss,
// drawing-buffer sizing, the mark atlas texture and uniform packing. The sketch
// sees only createSphereRenderer(); nothing here touches React.
//
// The whole pipeline lives in one fragment shader over a full-screen triangle:
// each pixel finds its row and cell, shades the sphere at that cell's centre
// (one value per cell), turns the shade into ink and draws the mark. Cost
// depends on pixel count, not mark count. JS only works out the cell width and
// the sphere's radius, and uploads uniforms; sphere.ts holds the same maths in JS.
//
// Cells sit on a fixed grid by default; with `pack` they sit end to end instead,
// which a pixel can't find by division. sphere.ts lays those rows out (cheap:
// cells, not pixels) and this file uploads the result as a texture for the
// shader to search.
// What one frame needs, in CSS pixels. The sketch's dial values fit this shape;
// the light is separate because it eases rather than being a dial.
export interface SphereParams {
width: number;
height: number;
ink: string;
paper: string;
paperOn: boolean;
sphere: { radius: number }; // fraction of min(width, height), pulled in to fit the marks
light: { ambient: number; diffuse: number; specular: number; shininess: number; rim: number };
grid: { rowHeight: number; fill: number; cellWidth: number; stagger: number; alignLeft: boolean; pack: boolean };
tone: { invert: boolean; gamma: number; contrast: number; minScale: number; cutoff: number };
}
export interface SphereRenderer {
// Draw one frame, lit from the unit vector `light` (x right, y up, z at the
// viewer).
render(params: SphereParams, light: Vec3): void;
// Hand over the mark atlas, or null for none. The renderer keeps it: it
// uploads on the next render and again after a context restore. An atlas
// with `chars` is a ramp (one character per cell, chosen by ink); any other
// is one mark per cell, scaled by ink.
setAtlas(atlas: Atlas | null): void;
destroy(): void;
}
export interface SphereRendererHooks {
// Something stopped the sphere drawing; the message is ready to show.
onError(message: string): void;
// The context came back and is rebuilt; draw again.
onRestore(): void;
}
// --- shaders ---------------------------------------------------------------
// Coordinates are CSS pixels from the canvas's top-left, y down, like sphere.ts.
// Rows are centred on the sphere; a pixel's cell is the one under it on its row.
const VERT = `#version 300 es
void main() {
// One oversized triangle covers the viewport; no buffers needed.
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
precision highp int;
uniform float uDpr; // drawing-buffer px per CSS px
uniform float uBufH; // drawing-buffer height, to flip gl_FragCoord
uniform vec2 uCentre; // sphere centre, CSS px
uniform float uRadius; // sphere radius, CSS px
uniform vec3 uLight; // unit vector toward the light
uniform vec4 uShade; // ambient, diffuse, specular, shininess
uniform float uRim;
uniform vec4 uTone; // invert, gamma, contrast, cutoff
uniform float uMinScale;
uniform vec4 uRow; // row height, cell width, stagger, align left
uniform vec4 uMark; // aspect, tiles, ramp, mark height at full size
uniform vec3 uInk;
uniform vec3 uPaper;
uniform int uPaperOn;
uniform sampler2D uAtlas;
uniform int uHasAtlas;
uniform vec3 uPack; // packed layout on, half rows, cell width multiplier
uniform sampler2D uLayout; // packed: (anchor, pitch) per cell, one row per texture row
const int MAX_ROW_CELLS = ${MAX_ROW_CELLS};
out vec4 outColor;
// Lambert + ambient, optional Blinn-Phong and rim. Mirrors shade() in sphere.ts.
float shade(vec3 n) {
float diff = max(dot(n, uLight), 0.0);
vec3 hv = uLight + vec3(0.0, 0.0, 1.0);
float hl = length(hv);
vec3 h = hl < 1e-6 ? vec3(0.0, 0.0, 1.0) : hv / hl;
float spec = diff > 0.0 ? pow(max(dot(n, h), 0.0), max(1.0, uShade.w)) : 0.0;
float rim = pow(1.0 - n.z, 3.0);
return clamp(uShade.x + uShade.y * diff + uShade.z * spec + uRim * rim, 0.0, 1.0);
}
void main() {
vec2 px = vec2(gl_FragCoord.x, uBufH - gl_FragCoord.y) / uDpr;
// Premultiplied output: paper fills the canvas, or nothing does and the stage
// shows through.
vec4 bg = uPaperOn == 1 ? vec4(uPaper, 1.0) : vec4(0.0);
outColor = bg;
if (uHasAtlas == 0) return;
// Row: evenly spaced, centred on the sphere. Mirrors cellAt() in sphere.ts.
float rowH = uRow.x;
float cellW = uRow.y;
float row = floor((px.y - uCentre.y) / rowH + 0.5);
float yc = uCentre.y + row * rowH;
float dy = yc - uCentre.y;
if (abs(dy) > uRadius) return;
float ax;
float cx; // where this cell's shade is taken
if (uPack.x > 0.5) {
// Packed: the row's cells were laid end to end in JS. Find the last one
// that starts at or before this pixel by binary search. Mirrors
// packedCellAt() in sphere.ts.
int half_ = int(uPack.y);
int r = int(row) + half_;
if (r < 0 || r > 2 * half_) return;
if (texelFetch(uLayout, ivec2(0, r), 0).r > px.x) return;
int lo = 0;
int hi = MAX_ROW_CELLS - 1;
for (int i = 0; i < 9; i++) {
if (lo >= hi) break;
int mid = (lo + hi + 1) >> 1;
if (texelFetch(uLayout, ivec2(mid, r), 0).r <= px.x) lo = mid; else hi = mid - 1;
}
vec2 cell = texelFetch(uLayout, ivec2(lo, r), 0).rg;
if (cell.x >= 1e8 || px.x >= cell.x + cell.y) return;
ax = cell.x;
cx = ax + cell.y / (2.0 * uPack.z); // the pitch is the drawn mark's width times the multiplier
} else {
// Fixed grid: cells start on the sphere's left edge at this row (the
// poster's ragged circle) or on the bounding edge; odd rows shift by the
// stagger. Mirrors cellAt() in sphere.ts.
bool align = uRow.w > 0.5;
float edge = uCentre.x - (align ? sqrt(uRadius * uRadius - dy * dy) : uRadius);
float off = mod(row, 2.0) * uRow.z * cellW;
float col = floor((px.x - edge - off) / cellW);
if (align && col < 0.0) return;
ax = edge + off + col * cellW;
cx = ax + 0.5 * cellW;
}
if (length(vec2(ax, yc) - uCentre) > uRadius + 0.75) return;
// One shade per cell, taken at its centre and held to the disc so a cell that
// pokes past the rim still has a normal.
vec2 s = vec2(cx, yc) - uCentre;
float r = length(s);
if (r > 0.995 * uRadius) s *= 0.995 * uRadius / r;
vec3 n = vec3(s.x, -s.y, 0.0) / uRadius;
n.z = sqrt(max(0.0, 1.0 - dot(n.xy, n.xy)));
float ink = uTone.x > 0.5 ? 1.0 - shade(n) : shade(n);
ink = clamp((ink - 0.5) * uTone.z + 0.5, 0.0, 1.0);
ink = pow(ink, max(0.01, uTone.y));
if (ink < uTone.w) return;
// The mark: a ramp draws full size and lets ink choose the character; a
// single mark is scaled by ink, anchored at its left-centre.
bool ramp = uMark.z > 0.5;
float tiles = uMark.y;
float mh = uMark.w * (ramp ? 1.0 : mix(uMinScale, 1.0, ink));
float mw = mh * uMark.x;
vec2 uv = (px - vec2(ax, yc - 0.5 * mh)) / vec2(mw, mh);
if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) return;
// uv jumps at every cell edge, so the screen derivatives would blow up there
// and shimmer. The mark is a plain scale of the atlas, so its footprint is
// known: uv change per device pixel.
vec2 grad = 1.0 / (vec2(mw, mh) * uDpr);
if (ramp) {
float idx = min(tiles - 1.0, floor(ink * tiles));
uv.x = (idx + uv.x) / tiles;
grad.x /= tiles;
}
float a = textureGrad(uAtlas, uv, vec2(grad.x, 0.0), vec2(0.0, grad.y)).r;
outColor = uPaperOn == 1 ? vec4(mix(uPaper, uInk, a), 1.0) : vec4(uInk * a, a);
}`;
// --- uniforms --------------------------------------------------------------
// Every uniform the shader declares, by name. Locations are typed from this
// list, so `u.uDpi` is a compile error rather than a silent no-op; the reverse
// (a name here that the shader lacks) is caught when the program links.
const UNIFORM_NAMES = [
"uDpr", "uBufH", "uCentre", "uRadius", "uLight", "uShade", "uRim", "uTone", "uMinScale",
"uRow", "uMark", "uInk", "uPaper", "uPaperOn", "uAtlas", "uHasAtlas", "uPack", "uLayout",
] as const;
type UniformName = (typeof UNIFORM_NAMES)[number];
type Uniforms = Record<UniformName, WebGLUniformLocation | null>;
// --- GL plumbing -----------------------------------------------------------
interface Program {
program: WebGLProgram;
uniforms: Uniforms;
}
function compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader | string {
const shader = gl.createShader(type);
if (!shader) return "Couldn't create a shader";
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader) ?? "";
console.error(`[word-sphere] ${type === gl.VERTEX_SHADER ? "vertex" : "fragment"} shader failed:\n${log}`);
gl.deleteShader(shader);
return "The sphere shader failed to compile (see the console)";
}
return shader;
}
// Compile and link once per context; uniform locations are looked up here too.
function createProgram(gl: WebGL2RenderingContext): Program | string {
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
if (typeof vs === "string") return vs;
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
if (typeof fs === "string") {
gl.deleteShader(vs);
return fs;
}
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(`[word-sphere] program failed to link:\n${gl.getProgramInfoLog(program) ?? ""}`);
gl.deleteProgram(program);
return "The sphere shader failed to link (see the console)";
}
const active = new Set<string>();
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
for (let i = 0; i < count; i++) {
const info = gl.getActiveUniform(program, i);
if (info) active.add(info.name.replace(/\[0\]$/, ""));
}
const uniforms = {} as Uniforms;
for (const name of UNIFORM_NAMES) {
if (!active.has(name)) console.warn(`[word-sphere] uniform ${name} is not in the shader`);
uniforms[name] = gl.getUniformLocation(program, name);
}
gl.useProgram(program);
gl.disable(gl.BLEND);
return { program, uniforms };
}
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace("#", "");
if (h.length === 3 || h.length === 4) {
h = h
.split("")
.map((c) => c + c)
.join("");
}
const n = parseInt(h.slice(0, 6), 16);
if (Number.isNaN(n)) return [0.5, 0.5, 0.5];
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
// --- renderer --------------------------------------------------------------
// Returns null when it can't start; `hooks.onError` has then been called with
// the reason (WebGL2 missing, or the shader failed to build).
export function createSphereRenderer(canvas: HTMLCanvasElement, hooks: SphereRendererHooks): SphereRenderer | null {
const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: false });
if (!gl) {
hooks.onError("WebGL2 isn't available in this browser, so the sphere can't render.");
return null;
}
let prog: Program | null = null; // null while the context is lost or the shader failed
let texture: WebGLTexture | null = null;
let textureVersion = -1; // which setAtlas the texture holds; -1 = none
let atlas: Atlas | null = null;
let atlasVersion = 0;
let layoutTexture: WebGLTexture | null = null;
let layoutBuffer: Float32Array | undefined; // reused between frames
// Built once per context; the textures are rebuilt lazily.
const init = () => {
texture = null;
textureVersion = -1;
layoutTexture = null;
const made = createProgram(gl);
if (typeof made === "string") {
prog = null;
hooks.onError(made);
} else {
prog = made;
}
};
const onLost = (e: Event) => {
e.preventDefault(); // opt in to restoration
prog = null;
};
const onRestored = () => {
init();
hooks.onRestore();
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
init();
if (!prog) {
// init() has reported why; don't leave listeners behind on a dead renderer.
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
return null;
}
// Upload a newly set atlas once as a single-channel texture; it then lives on
// the GPU. Mipmapped, so a mark drawn small is averaged rather than aliased.
const uploadAtlas = () => {
if (!atlas || textureVersion === atlasVersion) return;
if (!texture) texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); // rows of bytes are not 4-aligned
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, atlas.width, atlas.height, 0, gl.RED, gl.UNSIGNED_BYTE, atlas.mask);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
textureVersion = atlasVersion;
};
// The packed layout as an RG32F texture on unit 1, a row of cells per texture
// row, read with texelFetch so it needs no filtering. Re-sent every frame:
// the light can move any of it.
const uploadLayout = (layout: { data: Float32Array; rows: number }) => {
gl.activeTexture(gl.TEXTURE1);
if (!layoutTexture) {
layoutTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, layoutTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
gl.bindTexture(gl.TEXTURE_2D, layoutTexture);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, MAX_ROW_CELLS, layout.rows, 0, gl.RG, gl.FLOAT, layout.data);
};
return {
render(p, light) {
if (!prog || p.width <= 0 || gl.isContextLost()) return;
const u = prog.uniforms;
const { width: w, height: h } = p;
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;
}
gl.viewport(0, 0, bw, bh);
uploadAtlas();
const ready = !!atlas && textureVersion === atlasVersion;
const cx = w / 2;
const cy = h / 2;
const rowHeight = Math.max(2, p.grid.rowHeight);
const fill = p.grid.fill;
const aspect = atlas?.aspect ?? 1;
const radius = sphereRadiusPx(p.sphere.radius, w, h, rowHeight, fill, aspect);
const ink = hexToRgb(p.ink);
const paper = hexToRgb(p.paper);
gl.uniform1f(u.uDpr, dpr);
gl.uniform1f(u.uBufH, bh);
gl.uniform2f(u.uCentre, cx, cy);
gl.uniform1f(u.uRadius, radius);
gl.uniform3f(u.uLight, light[0], light[1], light[2]);
gl.uniform4f(u.uShade, p.light.ambient, p.light.diffuse, p.light.specular, p.light.shininess);
gl.uniform1f(u.uRim, p.light.rim);
gl.uniform4f(u.uTone, p.tone.invert ? 1 : 0, p.tone.gamma, p.tone.contrast, p.tone.cutoff);
gl.uniform1f(u.uMinScale, p.tone.minScale);
gl.uniform4f(
u.uRow,
rowHeight,
cellWidthPx(rowHeight, fill, aspect, p.grid.cellWidth),
p.grid.stagger,
p.grid.alignLeft ? 1 : 0,
);
gl.uniform4f(u.uMark, aspect, atlas?.tiles ?? 1, atlas && atlas.chars.length > 0 ? 1 : 0, rowHeight * fill);
gl.uniform3f(u.uInk, ink[0], ink[1], ink[2]);
gl.uniform3f(u.uPaper, paper[0], paper[1], paper[2]);
gl.uniform1i(u.uPaperOn, p.paperOn ? 1 : 0);
gl.uniform1i(u.uAtlas, 0);
gl.uniform1i(u.uHasAtlas, ready ? 1 : 0);
// Packed layout applies to a single mark that scales; a ramp's characters
// are all one size, so a fixed grid already packs them.
const ramp = !!atlas && atlas.chars.length > 0;
const packed = ready && p.grid.pack && !ramp;
if (packed) {
const layout = packedLayout(
{
cx,
cy,
radius,
rowHeight,
fullWidth: rowHeight * fill * aspect,
cellWidth: p.grid.cellWidth,
minScale: p.tone.minScale,
alignLeft: p.grid.alignLeft,
},
light,
p.light,
p.tone,
layoutBuffer,
);
layoutBuffer = layout.data;
uploadLayout(layout);
}
gl.uniform3f(u.uPack, packed ? 1 : 0, packedHalfRows(radius, rowHeight), Math.max(0.01, p.grid.cellWidth));
gl.uniform1i(u.uLayout, 1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
setAtlas(next) {
if (next === atlas) return;
atlas = next;
atlasVersion += 1;
},
destroy() {
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
if (prog) gl.deleteProgram(prog.program);
gl.deleteTexture(texture);
gl.deleteTexture(layoutTexture);
prog = null;
texture = null;
layoutTexture = null;
atlas = null;
},
};
}
sphere.ts
// The sphere, the light and the cell grid: pure maths, no GL and no DOM, so it
// can be tested under node:test. shade() and cellAt() mirror the shader line
// for line; keep the two in step. The renderer and the frame loop both use this
// file, and the shader is handed the results of cellWidthPx().
//
// Units are CSS pixels from the canvas's top-left (y down). Light and normal
// vectors are in sphere space: x right, y up, z toward the viewer.
export type Vec3 = [number, number, number];
const DEG = Math.PI / 180;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
export function normalize(v: Vec3, fallback: Vec3 = [0, 0, 1]): Vec3 {
const len = Math.hypot(v[0], v[1], v[2]);
return len < 1e-6 ? [...fallback] : [v[0] / len, v[1] / len, v[2] / len];
}
// --- light -----------------------------------------------------------------
// Azimuth turns the light around the vertical axis (0 is straight at the
// viewer, 90 is from the right, 180 from behind); elevation lifts it. Degrees.
export function lightFromAngles(azimuth: number, elevation: number): Vec3 {
const az = azimuth * DEG;
const el = elevation * DEG;
return [Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az)];
}
// Hovering at (x, y) puts the light over the point of the sphere under the
// pointer, so the lit patch follows it. Beyond the sphere's rim the light slides
// to the horizon in that direction: it never goes behind the sphere.
export function pointerToLight(x: number, y: number, cx: number, cy: number, radius: number): Vec3 {
let nx = (x - cx) / radius;
let ny = -(y - cy) / radius;
const r = Math.hypot(nx, ny);
if (r > 1) {
nx /= r;
ny /= r;
}
return [nx, ny, r > 1 ? 0 : Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny))];
}
// Move `cur` toward `target` by a frame's share of an exponential ease, and
// keep it a unit vector. Frame-rate independent: `rate` is 1/s. Exact opposites
// would average to nothing, so they snap to the target instead.
export function easeLight(cur: Vec3, target: Vec3, dt: number, rate: number): Vec3 {
const k = 1 - Math.exp(-Math.max(0, rate) * Math.max(0, dt));
return normalize(
[cur[0] + (target[0] - cur[0]) * k, cur[1] + (target[1] - cur[1]) * k, cur[2] + (target[2] - cur[2]) * k],
target,
);
}
// Close enough that easing is done and the frame loop can sleep.
export function lightSettled(a: Vec3, b: Vec3): boolean {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] > 1 - 1e-6;
}
// --- shading ---------------------------------------------------------------
export interface Shading {
ambient: number;
diffuse: number;
specular: number;
shininess: number;
rim: number;
}
// The surface normal at canvas point (x, y) of an orthographic sphere. A point
// outside the disc is pulled onto its edge, so a cell whose centre pokes past
// the rim still has a normal (the grazing one).
export function sphereNormal(x: number, y: number, cx: number, cy: number, radius: number): Vec3 {
let nx = (x - cx) / radius;
let ny = -(y - cy) / radius;
const r = Math.hypot(nx, ny);
const max = 0.995;
if (r > max) {
nx *= max / r;
ny *= max / r;
}
return [nx, ny, Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny))];
}
// Lambert + ambient, plus optional Blinn-Phong and rim, clamped to [0,1]. The
// viewer looks down -z, so the half vector is the light plus (0,0,1).
export function shade(n: Vec3, light: Vec3, s: Shading): number {
const diff = Math.max(0, n[0] * light[0] + n[1] * light[1] + n[2] * light[2]);
const h = normalize([light[0], light[1], light[2] + 1]);
const nh = Math.max(0, n[0] * h[0] + n[1] * h[1] + n[2] * h[2]);
const spec = diff > 0 ? Math.pow(nh, Math.max(1, s.shininess)) : 0;
const rim = Math.pow(1 - n[2], 3);
return clamp01(s.ambient + s.diffuse * diff + s.specular * spec + s.rim * rim);
}
export interface Tone {
invert: boolean;
gamma: number;
contrast: number;
}
// Light to ink: the poster inks the shadow, so `invert` (on by default) makes
// dark cells heavy. Contrast pivots on mid grey; gamma above 1 thins the middle.
export function inkOf(light: number, t: Tone): number {
const base = t.invert ? 1 - light : light;
const contrasted = clamp01((base - 0.5) * t.contrast + 0.5);
return Math.pow(contrasted, Math.max(0.01, t.gamma));
}
// --- marks -----------------------------------------------------------------
// How big `scale` mode draws a mark, as a fraction of its full size.
export function markScale(ink: number, minScale: number): number {
return minScale + (1 - minScale) * clamp01(ink);
}
// `ramp` mode: which of `n` characters (sorted lightest to heaviest) a cell
// shows. Ink 1 lands on the last one rather than past it.
export function rampIndex(ink: number, n: number): number {
if (n <= 1) return 0;
return Math.min(n - 1, Math.floor(clamp01(ink) * n));
}
// A cell is as wide as the mark at full size, times the cellWidth dial, so at 1
// marks pack edge to edge. The mark's height at full size is fill * rowHeight.
export function cellWidthPx(rowHeight: number, fill: number, aspect: number, cellWidth: number): number {
return Math.max(1, rowHeight * fill * aspect * cellWidth);
}
// The sphere's radius in px: the dial's share of the shorter side, pulled in just
// enough that everything drawn stays inside the canvas. Marks hang off the right
// rim (a cell starts inside the sphere, and its mark runs on from there), and
// the top and bottom rows' marks are half a mark tall, so the radius leaves room
// for a full-size mark at both. At the default dials that only bites on a narrow
// stage; the dial otherwise wins.
export function sphereRadiusPx(
fraction: number,
width: number,
height: number,
rowHeight: number,
fill: number,
aspect: number,
): number {
const markH = rowHeight * fill;
const room = Math.min(width / 2 - markH * aspect - 1, height / 2 - markH / 2 - 1);
return Math.max(1, Math.min(fraction * Math.min(width, height), room));
}
// --- grid ------------------------------------------------------------------
export interface GridInput {
cx: number; // sphere centre
cy: number;
radius: number;
rowHeight: number;
cellWidth: number; // px, from cellWidthPx()
stagger: number; // fraction of a cell that odd rows shift right
alignLeft: boolean; // rows start on the sphere's left edge, like the poster
}
export interface Cell {
row: number; // 0 is the row centred on the sphere's centre; negative is above
col: number;
anchorX: number; // the cell's left edge: where its mark is anchored
centreY: number; // the row's centre line
centreX: number; // where the shading is sampled
inside: boolean; // a mark is drawn here: its anchor is in the sphere
}
// A cell is inside when its left-centre anchor is in the sphere, so a mark may
// overhang the right-hand rim. One pixel of slack so the first cell of an aligned row, whose anchor is on the rim
// by construction, isn't lost to rounding.
const RIM_SLACK = 0.75;
// The cell under canvas point (x, y). Rows are centred on the sphere, evenly
// spaced, so the layout is symmetric about the horizontal midline. Within a row
// cells start at the sphere's left edge on that row (or the sphere's bounding
// edge when not aligned); odd rows are shifted by `stagger` of a cell.
export function cellAt(x: number, y: number, g: GridInput): Cell {
const row = Math.floor((y - g.cy) / g.rowHeight + 0.5);
const centreY = g.cy + row * g.rowHeight;
const dy = centreY - g.cy;
const none: Cell = { row, col: 0, anchorX: 0, centreY, centreX: 0, inside: false };
if (Math.abs(dy) > g.radius) return none;
const edge = g.cx - (g.alignLeft ? Math.sqrt(g.radius * g.radius - dy * dy) : g.radius);
const off = (((row % 2) + 2) % 2) * g.stagger * g.cellWidth;
const col = Math.floor((x - edge - off) / g.cellWidth);
const anchorX = edge + off + col * g.cellWidth;
const cell: Cell = { row, col, anchorX, centreY, centreX: anchorX + g.cellWidth / 2, inside: false };
if (g.alignLeft && col < 0) return cell;
cell.inside = Math.hypot(anchorX - g.cx, dy) <= g.radius + RIM_SLACK;
return cell;
}
// A cell's ink: the shading at its centre, turned into ink. This is the whole
// per-cell pipeline the shader runs, so tests can pin it down.
export function cellInk(cell: Cell, g: GridInput, light: Vec3, s: Shading, t: Tone): number {
const n = sphereNormal(cell.centreX, cell.centreY, g.cx, g.cy, g.radius);
return inkOf(shade(n, light, s), t);
}
// --- packed rows -----------------------------------------------------------
// The poster doesn't keep its words on a fixed grid: each row is laid end to
// end from the circle's edge, so a word that shrinks pulls the next one in and
// the lit side stays dense with small words instead of going sparse. Where one
// word starts depends on the widths of all those before it, so a pixel can't
// find it by division. The layout is worked out here per row (a few thousand
// shades, nothing next to the pixel count) and handed to the shader as a
// texture it binary-searches.
export const MAX_ROW_CELLS = 256; // cells kept per row; a row that needs more is cut short
export const NO_CELL = 1e9; // the anchor of an empty slot: past every pixel
export interface PackedInput {
cx: number;
cy: number;
radius: number;
rowHeight: number;
fullWidth: number; // a mark's width at full size, px
cellWidth: number; // the dial: pitch as a multiple of the drawn mark's width
minScale: number;
alignLeft: boolean;
}
// Rows run from -halfRows to +halfRows about the centre row, and a row's slot in
// the layout is row + halfRows.
export function packedHalfRows(radius: number, rowHeight: number): number {
return Math.floor(radius / rowHeight);
}
// The ink at a canvas point: shade, then tone. What a cell's mark size and
// character come from.
export function inkAt(x: number, y: number, g: { cx: number; cy: number; radius: number }, light: Vec3, s: Shading, t: Tone): number {
return inkOf(shade(sphereNormal(x, y, g.cx, g.cy, g.radius), light, s), t);
}
// The mark a cell holds, drawn at the scale its own centre's ink asks for, is
// that wide; and where the centre is depends on the width. A few rounds of
// "measure the width, move the centre" settle it to well under a pixel, since
// the width changes slowly across a cell.
function settledWidth(x: number, yc: number, g: PackedInput, light: Vec3, s: Shading, t: Tone): number {
let w = g.fullWidth;
for (let i = 0; i < 4; i++) w = g.fullWidth * markScale(inkAt(x + w / 2, yc, g, light, s, t), g.minScale);
return w;
}
// One row's cells as (anchor, pitch) pairs, written into `out` from `offset`.
// Cells run from the circle's edge (or the bounding edge) to the far rim, keeping those that start inside the sphere. The rest of the row's slots are NO_CELL. Returns the cells written.
export function packedRow(
row: number,
g: PackedInput,
light: Vec3,
s: Shading,
t: Tone,
out: Float32Array,
offset: number,
): number {
const yc = g.cy + row * g.rowHeight;
const dy = yc - g.cy;
let n = 0;
if (Math.abs(dy) <= g.radius) {
const edge = g.cx - (g.alignLeft ? Math.sqrt(g.radius * g.radius - dy * dy) : g.radius);
let x = edge;
// On the bounding edge, a row near the top or bottom starts outside the
// circle: those cells take up room but aren't kept. The step bound only
// guards against a pathologically thin mark.
for (let step = 0; step < MAX_ROW_CELLS * 4 && n < MAX_ROW_CELLS && x <= g.cx + g.radius; step++) {
const w = settledWidth(x, yc, g, light, s, t);
const pitch = Math.max(0.5, w * g.cellWidth);
if (Math.hypot(x - g.cx, dy) <= g.radius + RIM_SLACK) {
out[offset + n * 2] = x;
out[offset + n * 2 + 1] = pitch;
n++;
}
x += pitch;
}
}
for (let i = n; i < MAX_ROW_CELLS; i++) {
out[offset + i * 2] = NO_CELL;
out[offset + i * 2 + 1] = 1;
}
return n;
}
// Every row of the sphere: rows * MAX_ROW_CELLS * 2 floats, row-major from the
// top row. Reuses `reuse` when it's big enough.
export function packedLayout(
g: PackedInput,
light: Vec3,
s: Shading,
t: Tone,
reuse?: Float32Array,
): { data: Float32Array; rows: number } {
const half = packedHalfRows(g.radius, g.rowHeight);
const rows = 2 * half + 1;
const size = rows * MAX_ROW_CELLS * 2;
const data = reuse && reuse.length >= size ? reuse : new Float32Array(size);
for (let r = 0; r < rows; r++) packedRow(r - half, g, light, s, t, data, r * MAX_ROW_CELLS * 2);
return { data, rows };
}
// The cell under canvas point (x, y) in a packed layout, or null in a gap, off
// the rows or before the first cell. Mirrors the shader's binary search. The
// returned cell is `inside` by construction, since the layout only holds cells
// that start in the sphere.
export function packedCellAt(
x: number,
y: number,
g: PackedInput,
layout: { data: Float32Array; rows: number },
): { row: number; col: number; anchorX: number; pitch: number } | null {
const half = packedHalfRows(g.radius, g.rowHeight);
const row = Math.floor((y - g.cy) / g.rowHeight + 0.5);
if (row < -half || row > half) return null;
const base = (row + half) * MAX_ROW_CELLS * 2;
let lo = 0;
let hi = MAX_ROW_CELLS - 1;
if (layout.data[base] > x) return null;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (layout.data[base + mid * 2] <= x) lo = mid;
else hi = mid - 1;
}
const anchorX = layout.data[base + lo * 2];
const pitch = layout.data[base + lo * 2 + 1];
if (anchorX >= NO_CELL || x >= anchorX + pitch) return null;
return { row, col: lo, anchorX, pitch };
}