Skip to content

Compiling to Static

glyphcss’s renderer is pure: rasterize(scene) → string takes geometry + camera + grid and returns the <pre> text — no DOM, no WebGL. So a scene can be rendered at build time, on a server, or in a worker, not just in the browser. Because the render is text, it inlines straight into HTML with zero runtime.

There are two flavors:

  • Static — a frozen <pre> of ASCII. No JavaScript ships at all.
  • Interactive — a self-contained snippet that ships only the control you declared (orbit / zoom / pan / fpv) plus a decimated mesh.

Everything is built on one pure function, compileScene, which reproduces createGlyphScene’s exact render (same defaults) without a DOM:

import { compileScene } from "glyphcss";
const { html, inner, cols, rows } = compileScene({
polygons, // a Polygon[]
cols: 80, rows: 24, // library defaults
// camera, mode, glyphPalette, useColors, lights… (same options as createGlyphScene)
});
// html === '<pre class="glyph-output">…ascii…</pre>'

The output is byte-identical to the runtime render for the same inputs.

The @glyphcss/compile package ships a Vite plugin that compiles a mesh import to its baked <pre> at build time:

vite.config.ts
import { glyphcssCompile } from "@glyphcss/compile/vite";
export default { plugins: [glyphcssCompile()] };
import dog from "./dog.glb?glyph&autoCenter=1&rotX=60&rotY=45&cols=80&rows=30";
document.querySelector("#app")!.innerHTML = dog; // the <pre> string — no runtime

Query params map to the options below. Works in any Vite pipeline — Astro, vanilla Vite, or Vite-React (import the string and inject it).

Astro is built on Vite, so the plugin works once you register it:

astro.config.mjs
import { defineConfig } from "astro/config";
import { glyphcssCompile } from "@glyphcss/compile/vite";
export default defineConfig({ vite: { plugins: [glyphcssCompile()] } });
---
import dog from "../models/dog.glb?glyph&autoCenter=1&rotX=60";
---
<Fragment set:html={dog} />

But the most idiomatic Astro path needs no plugin at all — Astro frontmatter runs in Node at build, so compileFile works there directly:

---
import { compileFile } from "@glyphcss/compile";
import { fileURLToPath } from "node:url";
const { html } = await compileFile(
fileURLToPath(new URL("../models/dog.glb", import.meta.url)),
{ autoCenter: true, rotX: 60, rotY: 45 },
);
---
<Fragment set:html={html} />

Both run at build and emit the <pre> into the page HTML with zero JS — the Astro equivalent of GlyphSceneStatic. A dedicated Astro integration isn’t needed; it would only add sugar (auto-registering the plugin and a typed <GlyphScene src="…" /> component).

The universal escape hatch — works in any pipeline (Hugo, Eleventy, CI, a Makefile):

Install it (npm i -g @glyphcss/compile → the glyphcss command, or npx @glyphcss/compile …), then:

Terminal window
glyphcss cube --auto-center # a primitive shape → color ASCII
glyphcss dog.glb --auto-center # a mesh file → ANSI color in the terminal
glyphcss --polygons-json '[{"vertices":[[0,0,0],[2,0,0],[1,2,0]],"color":"#f00"}]'
glyphcss dog.glb -f full -o dog.html # full HTML document

Input is a mesh file (.obj/.glb/.gltf/.vox/.stl), a primitive shape name (cube, sphere, icosahedron, torus, cone, … — 44 shapes), or custom polygons (--polygons FILE.json / --polygons-json '…').

Output -f, --format picks the encoding: ansi (truecolor terminal), text (plain), html (a <pre>), or full (HTML doc). The default depends on the destination — terminal → ansi, -o file → html, piped → text. With no --cols/--rows it auto-fits the grid + zoom to the content, cropped tight (give just one and the other adapts to show the whole model).

glyphcss ships a skill so Claude Code, Cursor, Codex, and other AI coding agents can drive this CLI — ask “render a cube in the terminal” and the agent runs glyphcss cube --auto-center. See Coding agents for setup.

import { compileFile, loadMeshFromFile } from "@glyphcss/compile";
const { html, cols, rows } = await compileFile("dog.glb", { autoCenter: true });

compileFile loads the mesh from disk (reusing the library’s loadMesh — format dispatch, sibling .mtl, optimization) and hands it to compileScene.

React and Vue both export GlyphSceneStatic, which calls compileScene at render time and outputs the <pre>:

import { GlyphSceneStatic } from "@glyphcss/react"; // or @glyphcss/vue
<GlyphSceneStatic polygons={polygons} cols={80} rows={24} autoCenter rotX={60} rotY={45} />

Where it renders decides whether it’s static. Because compileScene runs at render time, you only get a zero-runtime static <pre> when the component is rendered at build or on the server:

  • SSG / SSR — an Astro island without a client: directive, a Next.js Server Component (or getStaticProps + renderToString), vite-ssg, Gatsby, Remix, renderToStaticMarkup. The <pre> is baked into the HTML and, if not hydrated, no glyphcss JS ships.
  • Plain client-side React (a default CRA / Vite npm run build) renders in the browser — compileScene executes client-side and glyphcss is in the bundle. You still get the <pre> with no interactive machinery, but it isn’t “compiled to static.” For that, use the Vite plugin instead, which bakes the string at build in any Vite app.

Two caveats for true static output: the polygons must be available where it renders (load them at build with loadMeshFromFile and pass them in — loading via loadMesh in the browser reintroduces a runtime), and the island must not be hydrated. For an interactive scene, use GlyphScene.

Defaults are the library defaults (createGlyphScene). A loaded mesh is not recentered or auto-fit unless you ask — pass autoCenter + a camera to frame it.

OptionQuery / CLI flagDefault
Camera anglerotX rotY / --rot-x --rot-y65 / 45
Zoomzoom / --zoom0.65
Projectionprojection=orthographic / --orthoperspective
Gridcols rows cellAspect / --cols80 / 24 / 2.0
Render modemode / --modesolid
Palettepalette / --palettedefault
Colorscolors=0 / --no-colorson
Recenter meshautoCenter=1 / --auto-centeroff
Mesh optimizemeshResolution / --mesh-resolutionlossy
Smooth shadingsmoothShading creaseAngle / --smoothoff / 60
Back facesdoubleSided / --double-sidedculled
Supersamplesupersample / --supersample1
Semantic outputglyphOutput / --glyph-output (with sceneManifest + dictionary)visible

charMode, hiddenLines, solidWeightRamp, and colorTolerance are pure functions of geometry + camera, so a static bake reproduces them exactly. colorTolerance is the odd one out mechanically — it operates on the already-rasterized cell grid as a post-hoc span merge, while the other three change rasterization itself — but all four are equally reproducible at build time. compileScene and GlyphSceneStatic (React + Vue) accept all four. @glyphcss/compile’s CLI, Vite plugin and compileFile do not accept them yet; call compileScene directly if you need them in those pipelines. buildGlyphFramesExport, buildGlyphInteractiveExport, and buildGlyphFieldSynthStaticExport (@glyphcss/effects) accept none of the four either — a colorTolerance value set through the /synth page’s UI (it persists to the URL) is silently dropped by that page’s CodePen export.

wireframeJunctions and per-mesh density/transparent are runtime-only in every path — the static bake takes a flat polygon list and cannot represent per-mesh detail layers.

Textures: per-cell texture sampling needs browser image decoding, so the static compile renders from material / vertex colors.

The static path produces a frozen frame. To ship interaction, declare which interactions you want and buildGlyphInteractiveExport (pure, browser-safe) produces a self-contained snippet — glyphcss from a CDN + the mesh inlined:

import { buildGlyphInteractiveExport, glyphCodepenPrefill } from "glyphcss";
const { html, pen, polygonCount } = buildGlyphInteractiveExport(polygons, {
interactions: ["orbit", "zoom"],
autoCenter: true,
});
// pen → { html, css, js } ready for a CodePen prefill (glyphCodepenPrefill)

An optional effect: { id, params, blend?, timeScale? } mounts a live stock @glyphcss/effects layer in the snippet: it adds a second CDN import, resolves the effect by id at runtime, and — when timeScale > 0 — appends a small requestAnimationFrame loop driving params.time. Only stock effects work here; a custom defineGlyphEffect cannot cross the CDN boundary. glyphcss itself never imports @glyphcss/effects, so that dependency only points one way.

The capability manifest (interactions) drives two things:

  1. Runtime — only the declared control is imported, so the snippet tree-shakes. An orbit-only export ships less than an fpv one.
  2. MeshdecimatePolygons simplifies the geometry to a budget tied to the interaction (coarse for orbit; finer when zoom / fpv let the camera approach). The ASCII grid is coarse, so sub-cell geometry is invisible anyway.
interactionsShips
[]static scene, no control
["orbit"]orbit control, coarse mesh
["orbit","zoom"]+ wheel zoom, finer mesh
["pan","zoom"]map controls
["fpv"]first-person controls, finest mesh

The CLI exposes the same via --interactions orbit,zoom.

When emitting a fully static <pre>, encodeStaticGlyphHtml(inner, mode) chooses how colors and whitespace are encoded — the best choice is model-dependent:

ModeWhat it does
classes (default)dedupes colors into .cN{…} rules + class spans, trims trailing spaces — smallest for typical dense, many-color renders
gridCSS-grid-places each run by column/row — no literal spaces in the markup; wins for sparse renders with big gaps
inlineone style="color:…" per run — simplest

For orbit-class motion you can do better than shipping a mesh + runtime: pre-bake a turntable of frames and cycle them with pure CSS — no mesh, no glyphcss, no JS at all.

import { buildGlyphFramesExport } from "glyphcss";
const { html, pen } = buildGlyphFramesExport(polygons, {
frameCount: 36, // 10° steps over 360°
autoCenter: true, rotX: 65,
cols: 120, rows: 48, // fixed grid; frames stack into one <pre>
});
// pen.css holds a `@keyframes … steps(36)` loop; pen.js is empty

Each frame is a faithful compileScene render (colors baked per face for textured meshes). The gallery’s CodePen export exposes this as Static → Rotate.

Trade-offs: discrete angles (smoothness ∝ frameCount), fixed resolution, and payload = frameCount × frame. It only covers orbit (1 axis) — pan / fpv have too many camera states to bake, so those still use the interactive mesh + runtime path.

For a scene whose camera and mesh are fixed and where only a field-synth texture animates, buildGlyphFieldSynthStaticExport bakes the static base grid plus each covered cell’s resolved domain coordinate once, then ships a tiny hand-written field-synth evaluator that recomputes the pattern every frame — zero imports, zero CDN, no glyphcss at runtime.

import { buildGlyphFieldSynthStaticExport } from "@glyphcss/effects";
const { html, pen } = buildGlyphFieldSynthStaticExport(polygons, {
params, // the live field-synth patch
blend, // the layer's REAL blend, not the definition's defaultBlend
loopSeconds: 8,
cols: 120,
rows: 40,
});

Payload is fixed regardless of loop length and the motion is continuously smooth — unlike a frame roll, whose payload grows with frameCount. Two bakes shrink it further for the common flat-surface case: when every covered cell’s domain coordinate fits an affine function of (col, row), the per-cell coordinate table (most of the payload) collapses to six fitted scalars; and when the effect covers every cell with blend: "replace" at opacity 1, the base grid is skipped too. A curved or partially covered surface keeps the full table.

Field synth only: another effect id would need its own exported coordinate resolver plus a hand-written port of its per-cell math, since there is no way to ship an arbitrary GlyphEffectProgram.evaluate() without shipping the effect runtime with it.

This exporter ports field synth’s 2D machinery — layers, duty, phase, per-voice angleN/originUN/originVN, subcellRes: "2x4"/"ink", and the SDF fields and step wave (gyroid/menger/sierpinski, at real-renderer exact parity) — but rejects the volumetric branch and carve/xray explicitly (space: "object", render: "carve"/"xray", or an active voice using linearZ/a nonzero originWN): a per-cell-per-frame march is a different export design this affine-fit/coordinate-table bake can’t fake. It also rejects a program option (program-as-data) up front, before the flat-param merge/bake — an unbounded field has no schema this bake can serialize. The originWN reject is waived for an active SDF voice, since that family reads originW even in the 2D branch. Check isGlyphFieldSynthStaticExportSupported(params) before calling; a volumetric or carve/xray patch (with no program) exports through the interactive export instead, which ships and evaluates the live effect runtime rather than a baked approximation.