Skip to content

Color Encoding

Colored output is normally emitted as <span style="color:…"> runs — one span per color run, often thousands per frame. Span count, not cell count, is what gates frame rate for a busy colored scene: the browser’s parse-HTML, style and paint work over a large <pre> costs more than the render pass that produced it. colorTolerance attacks that by merging runs.

colorEncoding: "atlas" removes them entirely. Instead of describing color in markup, it encodes each (glyph, color) pair as a single character against a checked-in COLR/CPAL color font, so the whole render is one plain text node with no elements inside it at all. Measured on the original spike: 2.7–9.4× FPS and 20,948 DOM nodes → 9 at equal visual output — and rasterization got cheaper, not dearer, despite there being more distinct glyphs to draw.

The atlas font declares every glyph once per palette slot, in the Basic Multilingual Plane’s Private Use Area. One code point therefore names both things a cell needs:

codePoint = puaStart + paletteSlot × glyphCount + glyphIndex

The load-bearing detail is paletteSlot: a code point encodes a color’s position in the palette, never its value. Nothing in the text says #4488ff. That lives in a @font-palette-values block whose override-colors maps slot → color, so redefining what slot 7 resolves to recolors the entire render (~0.4 ms, measured) without re-encoding a single cell. Blank cells stay a literal space, and carry no color at all.

Everything else about the render is unchanged — same geometry pass, same depth test, same one-write-per-<pre> rule. Only the final encode step differs.

Set colorEncoding to "atlas". The @font-face, the per-scene @font-palette-values block, and the <pre>’s font-family / font-palette are all wired for you.

import { GlyphCamera, GlyphScene, GlyphMesh } from "@glyphcss/react";
export function Demo() {
return (
<GlyphCamera rotX={62} rotY={30}>
<GlyphScene mode="solid" autoSize colorEncoding="atlas">
<GlyphMesh geometry="icosahedron" color="#4488ff" />
</GlyphScene>
</GlyphCamera>
);
}

The default stays "spans", and a scene that never asks for the atlas is byte-identical to one written before the option existed — it injects no CSS, allocates no palette, and never downloads the font.

A palette has far fewer slots than a shaded render has colors, so something has to reduce one to the other. atlasPalette is optional, and omitting it is the normal case: the scene then derives its own palette by median-cut quantization, pooling the colors of the frames it has actually rendered and refreshing when both a time gate (250 ms) and a drift gate pass. Cells whose color has no exact slot encode to the nearest one by redmean distance, so a render with hundreds of Lambert-shaded colors is reduced to fit rather than refused.

That reduction is measured, not assumed (bench/color-font-atlas-quantize.md). A Lambert ramp is close to one-dimensional in color space and quantizes better than the colorTolerance: 32 merge the site already ships; a photograph sampled onto a quad is the genuinely hard case, where a small fraction of cells land visibly off. If your scene is a photo, measure before switching.

Supply an array only to pin the palette — a fixed brand ramp, or a reproducible build-time bake. It must be ordered #rrggbb strings, at least one and at most the atlas’s slot count; positions are what the encoding references, so reordering it changes every code point.

const brand = ["#0b0e14", "#1d2433", "#3d5a80", "#98c1d9", "#e0fbfc"];
<GlyphScene mode="solid" colorEncoding="atlas" atlasPalette={brand} />

The Private Use Area is 6,400 code points, and every glyph in the atlas costs one of them per palette slot. Glyph coverage and color resolution therefore trade directly against each other, and glyphcss ships two atlases at two points on that curve:

AtlasGlyphsPalette slotsCovers
GLYPH_FONT_ATLAS (default)21230Printable ASCII, the 24 Greek capitals, the solid ramp of every named glyphPalette, the default / ascii wireframe line tiers, ink mode’s 10 oriented glyphs, and the 11 wireframeJunctions box-drawing glyphs
GLYPH_FONT_ATLAS_ASCII9468Printable ASCII, and nothing else

6400 ÷ 212 = 30 against 6400 ÷ 94 = 68. A scene that only ever draws ASCII — a detail, dense, default, ascii or hex solid ramp, say — never touches the universal set’s Greek/braille/box-drawing axis, and 2.3× the slots is where quantization error actually goes down. A scene using ink mode, wireframeJunctions, or a non-ASCII solid ramp needs the universal atlas.

Neither atlas lists the space character, and neither needs to: a blank cell is written as a literal U+0020, carrying no color and costing no slot.

Both are plain manifest objects re-exported from glyphcss, @glyphcss/react and @glyphcss/vue. fontAtlas is fixed at scene creation — the scene’s <pre> elements, its font-readiness probe and its palette CSS are all pinned to one family for its lifetime, so setOptions is inert for it and a later prop change is deliberately not forwarded. Remount the scene to switch atlases.

import { GlyphScene, GLYPH_FONT_ATLAS_ASCII } from "@glyphcss/react";
{/* read at mount only — remount to change it */}
<GlyphScene mode="solid" colorEncoding="atlas" fontAtlas={GLYPH_FONT_ATLAS_ASCII} />

The atlas either encodes the entire render or none of it. A mixed encoding would still need spans for whatever fell outside the atlas, which defeats the point — so this is always a whole-scene decision, never a per-cell one, and the <pre>’s font stack follows the encoding that frame actually produced rather than the option you set. A fallback frame is never left painting in the atlas face.

Transient — it re-enables itself:

  • The font hasn’t arrived yet. The ~49 KB WOFF2 is a lazily imported chunk, so a scene created with colorEncoding: "atlas" renders spans for its first frame or two and re-renders itself once the face is decoded. This is why a "spans" consumer never pays for the payload at all.
  • A non-blank cell with no usable #rrggbb color. Nothing to assign a slot to. Not a configuration problem, so it doesn’t stick.

Sticky for the session or the scene:

  • The engine can’t do it. glyphcss requires CSS.supports("font-palette", "--x") — the custom-ident form specifically, since font-palette: normal | light | dark would leave every cell in the font’s baked hue ramp — and then rasterizes one atlas code point to a canvas and requires a chromatic pixel, because an engine that decodes the face but ignores COLR would otherwise paint a blank grid. Either check failing warns once and pins that document to spans.
  • A glyph outside the chosen atlas. Once any output of a scene falls back for a glyph reason, that scene stays on spans until a setOptions call touches colorEncoding, atlasPalette, mode or charMode. The latch exists for content glyphcss cannot see in advance — an animated effect layer’s data-driven ramp, or a transformCells hook — whose realized glyph set varies frame to frame and would otherwise make the encoding flicker. charMode: "braille" lands here too: its 256 dot patterns are outside both atlases by design.

Permanent no-ops — cases where a span-per-cell representation is structurally required, and the option is simply ignored:

SettingWhy
charMode: "halfblock" / "quadrant"Two colors per cell; the atlas encodes one
solidWeightRamp actively selecting a weightCOLR/CPAL carries color, not font-weight
glyphOutput: "semantic"Semantic output ignores every presentation option
useColors: falseNo color axis to encode

One wrinkle worth knowing in wireframe mode: each cell draws a random glyph from its line-weight tier, so gating on the frame’s realized glyphs would make eligibility flip frame to frame. The decision is made instead from the palette’s potential glyph set, derived from configuration alone. Under the universal atlas that means the blocks, stars, arrows, braille, runes and math palettes render spans deterministically in wireframe — every one of them has a line-tier glyph outside the atlas — while their solid ramps encode fine.

Find-in-page does not work under "atlas". The text in the DOM is Private Use Area code points, not the glyphs on screen, so the browser’s own search has nothing to match. This is an accepted trade, not something worked around.

Copy and paste do work, but only through a decode step, and the decoder has to know which atlas produced the text: both variants start at U+E000 with different glyph counts, so the same code point means different things in each. The font-family glyphcss pins on each <pre> is that key — per element, so a selection spanning two scenes on different atlases still decodes correctly.

import { decodeGlyphAtlasText, glyphAtlasForFamily, GLYPH_FONT_ATLAS } from "glyphcss";
function readAscii(pre: HTMLPreElement): string {
const atlas = glyphAtlasForFamily(pre.style.fontFamily) ?? GLYPH_FONT_ATLAS;
return decodeGlyphAtlasText(pre.textContent ?? "", atlas);
}

decodeGlyphAtlasText passes non-atlas characters through untouched, so it is safe to run unconditionally over any <pre> — it is a no-op on ordinary "spans" output. Colors are dropped; recovering them means resolving each cell’s slot through the scene’s own @font-palette-values block.

compileScene and GlyphSceneStatic accept all three options — they are pure functions of geometry and camera, so a build-time bake reproduces the runtime render exactly. Two differences matter:

  1. atlasPalette is required there. The pooled quantizer belongs to a live scene; with no palette to encode against, a compiled "atlas" render quietly degrades to spans.
  2. They inject no CSS. Being DOM-less, they have no document to inject into, so the embedder supplies the @font-face and @font-palette-values itself — with the same atlas passed to all three calls, or the output resolves its code points against the wrong glyph modulus.
import {
compileScene,
loadGlyphAtlasFontFaceCss,
buildGlyphAtlasFontPaletteValuesCss,
GLYPH_FONT_ATLAS_ASCII as atlas,
} from "glyphcss";
const palette = ["#0b0e14", "#3d5a80", "#98c1d9", "#e0fbfc"];
const { html } = compileScene({
polygons,
camera,
mode: "solid",
cols: 120,
rows: 40,
colorEncoding: "atlas",
atlasPalette: palette,
fontAtlas: atlas,
});
const css = [
await loadGlyphAtlasFontFaceCss(atlas),
buildGlyphAtlasFontPaletteValuesCss("--brand", palette, atlas),
`.glyph-output{font-family:"${atlas.family}",monospace;font-palette:--brand}`,
].join("\n");

loadGlyphAtlasFontFaceCss inlines the WOFF2 as a data: URI, so the result is self-contained with no extra font request. Unlike the runtime path it rejects when the payload is unavailable: an export that shipped a @font-face with an empty src would look correct in review and render tofu in the browser.

The frame-roll, interactive/CodePen and static-effect exporters do not carry these options yet.

OptionTypeDefaultEffect
colorEncoding"spans" | "atlas""spans""atlas" encodes (glyph, color) as PUA code points against the color font — one text node, zero <span>s. "spans" is the byte-identical default.
atlasPalettereadonly string[]Pin the ordered #rrggbb palette slots encode against. Omitted, the scene derives and pools one itself. Max = the atlas’s slot count.
fontAtlasGlyphFontAtlasGLYPH_FONT_ATLASWhich atlas to encode against. Fixed at scene creation.

Supporting exports from glyphcss:

ExportPurpose
GLYPH_FONT_ATLAS / GLYPH_FONT_ATLAS_ASCIIThe two shipped atlas manifests (also re-exported from @glyphcss/react and @glyphcss/vue)
glyphAtlasForFamily(fontFamily)Which atlas a <pre>’s font stack names — the decoder’s key
decodeGlyphAtlasText(text, atlas?)PUA text → plain glyphs; passes anything else through
decodeGlyphAtlasCodePoint(cp, atlas?)One code point → { glyph, paletteSlot }
glyphAtlasCodePoint(glyph, slot, atlas?)The forward mapping
loadGlyphAtlasFontFaceCss(atlas?)@font-face text with the WOFF2 inlined; rejects if unavailable
buildGlyphAtlasFontPaletteValuesCss(name, colors, atlas?)@font-palette-values text for a slot → color mapping
glyphAtlasFontLoadState(atlas?)"idle" | "loading" | "ready" | "failed" for the lazy payload
  • Browser-only for the automatic wiring. createGlyphScene (and therefore React, Vue and <glyph-scene>) injects and manages the CSS; the static path does not — see above.
  • The @font-face is document-global; the palette block is per scene. Ten atlas scenes on one page share one font injection and one payload download, each with its own @font-palette-values ident. scene.destroy() removes the scene’s own block.
  • Composes with colorTolerance, but there is nothing to gain. Merging runs only matters for markup the atlas has already eliminated.
  • It does not reduce cell count. Everything in Performance about grid cells still applies — the atlas removes the DOM cost of color, not the cost of shading a big grid.