Core Concepts
One render pass, one write
Section titled “One render pass, one write”glyphcss does not build a DOM node per polygon, and it does not pre-bake frames. Every camera or scene change runs a single render pass:
- Walk all mounted meshes in scene order.
- Transform polygon vertices through the camera to 2D projected positions.
- Fill a
cols × rowscharacter grid — depth-testing overlapping polygons and picking a glyph per cell according to the render mode. - Join the grid into one string and assign it to
<pre>.textContent, once.
That last point is the invariant the whole renderer is built around: each render
cycle writes each <pre> exactly once. No cell-by-cell DOM patching, no
matrix3d, no per-polygon elements. A scene with per-mesh detail layers writes
the base <pre> plus one write per detail layer, and nothing else.
Interaction follows the same path rather than a special one: a control mutates a
single camera-state object, the rasterizer reads it, and one string is written.
Dragging can optionally render at reduced resolution via interactiveDownscale
and restore full detail on release — same on-screen size, fewer cells mid-gesture.
Because rasterize is pure — geometry + camera in, string out — the same pass
runs at build time or on a server. That is what compileScene and
GlyphSceneStatic use, and their output is byte-identical to the runtime render
for the same inputs.
The hit layer
Section titled “The hit layer”polycss-style “every polygon is a DOM node” doesn’t fit ASCII rendering: the visible output is a single character grid, not a set of clickable polygons. Instead, glyphcss exposes a sparse hit layer: you opt-in to interactivity by registering hotspots at specific 3D anchors.
import { GlyphMesh, GlyphHotspot } from "@glyphcss/react";import { dodecahedronPolygons } from "@glyphcss/core";
const shape = dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#cc44ff" });
<GlyphMesh polygons={shape}> <GlyphHotspot at={[0, 1.2, 0]} onClick={...}> <span className="badge">Top</span> </GlyphHotspot></GlyphMesh>Each hotspot becomes a real <div> absolutely positioned at its projected cell.
The rasterizer projects Hotspot.at through the same camera the grid was drawn
with and returns a HotspotCell (col, row, depth, visible); the consumer moves
the element with a single inline-style assignment per hotspot — no DOM rebuild,
and no second projection that could disagree with the glyphs. Hotspots:
- Render real DOM children (use them for tooltips, badges, hover affordances).
- Fire normal DOM events (
onClick,onMouseEnter,onFocus). - Get free CSS
:hoverand:focus-visiblestyling. - Inspect in DevTools like any other element.
The camera contract
Section titled “The camera contract”The renderer and the hit layer must use the same camera.project(v, ...) call.
This is enforced by both reading from a shared GlyphCamera handle:
const camera = createGlyphPerspectiveCamera({ rotX: 25, rotY: 0, distance: 3, zoom: 50, stretch: 1.0,});
const ctx = buildRasterizeContext({ camera, grid, polygons, mode: "solid" });
// Renderer — one pass, one stringconst text = rasterize(ctx);
// Hit layer — the SAME camera object, so it cannot disagreeconst cells = projectHotspots(hotspots, camera, grid.cols, grid.rows, grid.cellAspect);Mutating camera.rotY and re-running both calls is all “animation” is here:
there is no baked sequence to keep in sync, and nothing caches a projection that
could go stale.
If you ever find yourself threading a separate “current angle” through projection, stop: one camera-state object feeds both the grid and the hotspots, and that is exactly what keeps them from drifting apart.
Cell measurement (the recurring footgun)
Section titled “Cell measurement (the recurring footgun)”<pre> is display: block, so getBoundingClientRect().width returns the
container width, not the character width. Always measure on a hidden
<span style="display: inline-block"> instead. The probe should have the same
font properties as the <pre> (same font-family, same font-size,
line-height: normal).
The measured cell matters because the projection uses its aspect ratio: with
autoSize, the scene re-measures the host cell and overwrites cellAspect from
it, so geometry stays correctly proportioned when the font or size changes. A
fixed-size scene (explicit cols/rows, no autoSize) has no such correction —
it keeps whatever cellAspect you gave it, which is why compileScene takes the
value explicitly.