ASCII polygon-mesh renderer for the DOM — projects 3D meshes into a monospace character grid in a single <pre>. No WebGL, no canvas, no per-polygon DOM.
Loads OBJ, glTF, GLB, and MagicaVoxel .vox files. Supports wireframe, solid, and voxel render modes with swappable glyph palettes.
Forked from polycss — the mesh math, parsers (OBJ / glTF / GLB / VOX), scene composition tree, camera math, and input controls carried over intact. The paint backend is rewritten: instead of emitting one CSS-transformed DOM leaf per polygon, the rasteriser walks all polygons, fills a
cols × rowscharacter grid, and writes a single string to<pre>.textContentper render.
# Vanilla / custom elements
npm install glyphcss
# React
npm install @glyphcss/react
# Vue 3
npm install @glyphcss/vue
# Reusable surface/scene effects (optional)
npm install @glyphcss/effectsYou can also load glyphcss directly from a CDN. Here is a minimal custom-element scene:
<script type="module" src="https://esm.sh/glyphcss/elements"></script>
<glyph-camera rot-x="65" rot-y="45">
<glyph-scene>
<glyph-orbit-controls></glyph-orbit-controls>
<glyph-mesh src="/cottage.glb"></glyph-mesh>
</glyph-scene>
</glyph-camera>React and Vue expose the same component model. <GlyphCamera> owns the viewpoint, <GlyphScene> owns the rasteriser options and lighting, and <GlyphMesh> loads or receives polygon data.
import {
GlyphCamera,
GlyphScene,
GlyphOrbitControls,
GlyphMesh,
} from "@glyphcss/react";
export default function App() {
return (
<GlyphCamera rotX={65} rotY={45}>
<GlyphScene mode="solid" glyphPalette="default">
<GlyphOrbitControls drag wheel />
<GlyphMesh src="/gallery/obj/cottage.obj" />
</GlyphScene>
</GlyphCamera>
);
}<template>
<GlyphCamera :rot-x="65" :rot-y="45">
<GlyphScene mode="solid" glyph-palette="default">
<GlyphOrbitControls drag wheel />
<GlyphMesh src="/gallery/obj/cottage.obj" />
</GlyphScene>
</GlyphCamera>
</template>
<script setup lang="ts">
import {
GlyphCamera,
GlyphScene,
GlyphOrbitControls,
GlyphMesh,
} from "@glyphcss/vue";
</script>Each render pass fills a cols × rows character grid and writes the result as a single string assignment to <pre>.textContent (or innerHTML when color spans are enabled).
| Mode | How cells are filled |
|---|---|
wireframe |
Polygon edges rasterised as ASCII rules; glyph weight scales with edge prominence |
solid |
Filled polygons; glyph picked from the palette's solid ramp by Lambert-shaded intensity |
voxel |
Cube-aligned geometry; face normals drive glyph selection |
The glyphPalette option selects a named character set used for both wireframe tiers and solid shading ramps. Built-in palettes:
default, ascii, dots, lines, blocks, stars, arrows, braille, runes, math, binary, hex
<GlyphScene mode="wireframe" glyphPalette="braille">
<GlyphMesh src="/model.glb" />
</GlyphScene>GlyphCamera is the ergonomic default — it resolves to GlyphOrthographicCamera. Use GlyphPerspectiveCamera for perspective depth.
| Prop | Type | Default | Description |
|---|---|---|---|
rotX |
number |
65 |
Tilt angle in degrees |
rotY |
number |
45 |
Spin angle in degrees |
zoom |
number |
0.65 |
Absolute scale: zoom=1 → one world unit = 50 px (BASE_TILE). Not a viewport fraction. |
center |
[number, number] |
[0.5, 0.5] |
Projection center in normalized grid coordinates |
GlyphPerspectiveCamera adds:
| Prop | Type | Default | Description |
|---|---|---|---|
distance |
number |
6 |
Perspective distance in world units. Larger = flatter. |
stretch |
number |
1.0 |
Extra horizontal scale on top of cellAspect |
rotX=65, rotY=45 is the classic isometric-ish viewpoint. Rotation values are in degrees throughout (XYZ Euler) — there are no radians in the public API.
Must be placed inside a camera component.
| Prop | Type | Default | Description |
|---|---|---|---|
mode |
"wireframe" | "solid" | "voxel" |
"solid" |
Render mode |
glyphPalette |
string |
"default" |
Named glyph character set |
useColors |
boolean |
true |
Emit <span> color elements inside the <pre> |
cols |
number |
80 |
Character grid width |
rows |
number |
24 |
Character grid height |
cellAspect |
number |
2.0 |
Cell height / width ratio |
directionalLight |
GlyphDirectionalLight |
{ direction: [-0.5,-0.7,-0.5], intensity: 1 } |
Key light |
ambientLight |
GlyphAmbientLight |
{ intensity: 0.4 } |
Fill light |
smoothShading |
boolean |
false |
Gouraud shading — interpolates Lambert intensity across vertices. Off by default (faceted ASCII look is intentional). |
creaseAngle |
number |
60 |
Degrees — edges sharper than this stay flat even with smoothShading on |
autoSize |
boolean |
false |
Auto-measure the host element and adapt cols/rows to fill it via ResizeObserver |
shadow |
GlyphShadowOptions |
undefined |
Enable shadow mapping (see Shadows section) |
| Prop | Type | Default | Description |
|---|---|---|---|
polygons |
Polygon[] |
— | Pre-parsed geometry. Takes precedence over src and geometry. |
src |
string |
— | URL of an OBJ, glTF, GLB, or VOX file (custom elements and <glyph-mesh> only; use loadMesh imperatively in React/Vue) |
geometry |
GlyphGeometryName |
— | Built-in geometry shortcut (e.g. "sphere", "cube") |
size |
number |
1 |
Uniform size passed to resolveGeometry |
color |
string |
— | Fill color passed to resolveGeometry |
position |
Vec3 |
— | World-space translation |
rotation |
Vec3 |
— | XYZ Euler rotation in degrees |
scale |
number | Vec3 |
— | Uniform or per-axis scale |
castShadow |
boolean |
false |
This mesh casts shadows onto receiveShadow surfaces |
receiveShadow |
boolean |
false |
This mesh receives (displays) shadows |
A mesh that is both castShadow and receiveShadow self-shadows.
Effects are ordered appearance programs mounted over the retained glyph frame.
Parameter-only animation does not re-project the mesh, and the stable params
object can be targeted directly by Anime.js or updated by a custom clock.
import { GlyphEffectLayer } from "@glyphcss/react";
import { GlyphEffects } from "@glyphcss/effects";
<GlyphEffectLayer
effect={GlyphEffects.matrixRain}
blend="replace"
params={{ glyphs: "HOLA", speedMin: 5, speedMax: 12 }}
/>Vanilla scenes use the same definition and handle:
const rain = scene.addEffectLayer({
effect: GlyphEffects.matrixRain,
blend: "replace",
});
function tick(now: number) {
rain.params.time = now / 1000;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);The initial catalog also includes flow text, scan, wipe, scramble, glitch,
noise dissolve, and ripple. Matrix rain, flow text, and scan use authored UVs
when available in auto mapping; surface forces world-position and
face-normal mapping on solid models:
world -Z is projected into each face so sloped polygons flow downhill and
differently oriented faces derive different directions. A horizontal face has
no downhill tangent, so it receives a stable pseudo-random in-plane direction.
Generated Matrix rain fits each visible coplanar face's world-derived surface
basis to projected glyph-cell space and evaluates both the sparse trail mask and
the word in that same orthogonal face-local field. The glyph phase comes from
distance behind the moving head, so HOLA… travels in the strand's direction and
at its speed despite face shear or foreshortening, without skipping, repeating in
blocks, or changing phase when a periodic strand wraps.
Every active trail glyph has full coverage; lane density and trail length create
the gaps without coverage dithering severing the words.
Projected scene coordinates remain the fallback outside solid mode. Matrix rain
can preserve original surface colors or tint every strand with one configurable
color while retaining the model's lighting and shape. See
ANIMATIONS.md for the implemented boundary and the richer
graph/sampling design.
Convenience ground plane — a horizontal planePolygons registered as a mesh.
| Prop | Type | Default | Description |
|---|---|---|---|
size |
number |
5 |
Half-extent in world units |
color |
string |
"#444444" |
Fill color |
position |
Vec3 |
[0, -0.5, 0] |
World-space position |
castShadow |
boolean |
false |
|
receiveShadow |
boolean |
true |
Ground planes are the primary shadow receivers |
| Component | Behaviour |
|---|---|
GlyphOrbitControls |
Drag orbit, shift-drag pan, wheel zoom, optional auto-rotate |
GlyphMapControls |
Pan-first map-style input |
GlyphFirstPersonControls |
Keyboard and pointer-look navigation |
Props accepted by all controls: drag, wheel, touch.
GlyphHotspot is a 3D anchor that produces an absolutely-positioned DOM overlay tracking a world-space point. The rasteriser projects the at coordinate to a grid cell; glyphcss positions a <div> over that cell. Children are portalled inside that div.
<GlyphHotspot id="label-a" at={[0, 2, 0]}>
<span className="label">Summit</span>
</GlyphHotspot>| Prop | Type | Description |
|---|---|---|
id |
string |
Stable identifier |
at |
Vec3 |
World-space anchor |
size |
[number, number] |
Hitbox size in character cells. Default [1, 1]. |
Each polygon describes one renderable face:
import type { Polygon } from "@glyphcss/core";
const polygons: Polygon[] = [
{
vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]],
color: "#f97316",
},
{
vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]],
color: "#3b82f6",
},
];Polygons can also carry UV coordinates and TextureTriangle data for texture-mapped meshes (loaded via OBJ/MTL or glTF).
Pass polygons directly to a GlyphScene (imperative API) or a GlyphMesh component:
<GlyphCamera rotX={65} rotY={45}>
<GlyphScene>
<GlyphMesh polygons={polygons} />
</GlyphScene>
</GlyphCamera>Shadows are opt-in. Enable them by setting shadow on <GlyphScene>, then flag individual meshes with castShadow and/or receiveShadow. glyphcss uses a shadow-map technique — renders depth from the light direction, then compares per cell — rather than an analytic projection.
<GlyphScene
shadow={{ color: "#000000", opacity: 0.35, lift: 0.05, maxExtend: 2000 }}
>
<GlyphMesh src="/tree.glb" castShadow />
<GlyphGround receiveShadow />
</GlyphScene>| Option | Type | Default | Description |
|---|---|---|---|
shadow.color |
string |
"#000000" |
Shadow tint hex color |
shadow.opacity |
number |
0.25 |
Darkness 0–1 toward color |
shadow.lift |
number |
0.05 |
Depth bias — prevents self-shadow acne on flat lit surfaces |
shadow.maxExtend |
number |
2000 |
Half-extent of the light-space projection volume |
Use loadMesh() from @glyphcss/core to parse supported formats imperatively:
import {
createGlyphScene,
createGlyphOrthographicCamera,
loadMesh,
} from "glyphcss";
const host = document.getElementById("scene")!;
const camera = createGlyphOrthographicCamera({ rotX: 65, rotY: 45 });
const scene = createGlyphScene(host, { camera });
const mesh = await loadMesh("/gallery/obj/cottage.obj");
scene.add(mesh.polygons);In custom element HTML, set the src attribute directly — <glyph-mesh> fetches and parses the file automatically:
<glyph-mesh src="/model.glb"></glyph-mesh>
<glyph-mesh src="/model.obj"></glyph-mesh>
<glyph-mesh src="/model.vox"></glyph-mesh>In React and Vue, use loadMesh (from @glyphcss/react or @glyphcss/vue) and pass the parsed polygons to <GlyphMesh>:
const { polygons } = await loadMesh("/model.glb");
<GlyphMesh polygons={polygons} />Supported formats:
- OBJ + MTL, including
map_Kdtextures and UV coordinates - glTF / GLB, including embedded images and
TEXCOORD_0 - MagicaVoxel
.vox, with face-culling and default or custom palettes
glyphcss renders through a single <pre> element. The performance envelope is shaped by two things: the number of polygons walked per render and the size of the character grid written to the DOM.
Rendering is change-driven, not a fixed loop: a render is scheduled only when the camera or scene state actually changes, and multiple changes in the same tick are coalesced into a single pass. A static, un-interacted scene performs zero redraws. (Continuous animation — auto-rotate, inertia — does write every frame, but each frame is a genuinely different image.)
On every render pass:
- All mounted meshes are walked in scene order.
- Polygon vertices are transformed through the camera matrix to 2D projected positions.
- A
cols × rowscharacter grid is filled: polygons are depth-tested, each cell picks a glyph from the active palette. - All cells are joined and written to
<pre>.textContent(or.innerHTMLfor color mode) exactly once.
There are no per-polygon DOM elements and no CSS matrix3d. Hotspot overlays update via a single el.style.left/top assignment per hotspot per render — not a DOM rebuild.
autoSize uses a ResizeObserver to re-fit the grid whenever the host element resizes, keeping the character density constant regardless of viewport size.
| Package | npm name | Description |
|---|---|---|
@glyphcss/core |
@glyphcss/core |
Pure math: Vec3, Polygon, scene, camera, mesh ops, parsers. Zero browser globals. |
glyphcss |
glyphcss |
ASCII rasteriser + vanilla custom elements + imperative createGlyphScene API. |
@glyphcss/react |
@glyphcss/react |
React components, hooks, and controls. |
@glyphcss/vue |
@glyphcss/vue |
Vue 3 mirror of the React package. |
@glyphcss/effects |
@glyphcss/effects |
Reusable spatial effect definitions; framework-agnostic and clock-free. |
@glyphcss/fonts |
@glyphcss/fonts |
Font/text to extruded polygon-mesh generation. |
@glyphcss/compile |
@glyphcss/compile |
Static compiler, CLI, Vite plugin, and Node API. |
MIT.
