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

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)

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.