Skip to content

Core Concepts

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:

  1. Walk all mounted meshes in scene order.
  2. Transform polygon vertices through the camera to 2D projected positions.
  3. Fill a cols × rows character grid — depth-testing overlapping polygons and picking a glyph per cell according to the render mode.
  4. 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.

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 :hover and :focus-visible styling.
  • Inspect in DevTools like any other element.

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 string
const text = rasterize(ctx);
// Hit layer — the SAME camera object, so it cannot disagree
const 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.

<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.