mote-engine
ASCII art for the web, drawn on a canvas. 3D models (glTF 2.0, as exported by Blender), built-in shapes and extruded drawings are drawn by thousands of character particles: they turn and shade like solids, morph into the next scene through smoke, crumble at the edges and ripple where you click. The package also has a code backdrop (source code swept into glyphs and back) and a decode animation for text.
Install
After you buy a licence you get a link to mote-engine-<version>.tgz. The engine has no dependencies, so that archive is the only thing to install.
With npm, from your project folder:
npm install ./mote-engine-<version>.tgz
Then import it by name:
import { createAsciiScene, cssColors } from "mote-engine";
The package is ESM only and ships its TypeScript types. Entry points:
| Import | What |
|---|---|
mote-engine |
everything |
mote-engine/geometry |
meshes, sampling, built-in shapes (no browser needed) |
mote-engine/gltf |
the glTF reader (no browser needed) |
mote-engine/text |
still frames as plain text (no browser needed) |
mote-engine/backdrop |
the code backdrop |
mote-engine/decode |
the decode animation |
If you have no build step, unpack the archive and serve the folder that holds index.js next to the rest of your site. An import map keeps the short name:
<script type="importmap">
{ "imports": { "mote-engine": "/vendor/mote-engine/index.js" } }
</script>
<script type="module" src="/app.js"></script>
Quick start
<canvas id="art" style="width: 100%; height: 480px"></canvas>
import { createAsciiScene, loadGltf, cssColors } from "mote-engine";
const scene = createAsciiScene(document.getElementById("art") as HTMLCanvasElement, {
stages: [loadGltf("/models/logo.glb"), loadGltf("/models/shield.glb")],
colors: cssColors(), // reads --ascii-text and --ascii-accent
});
Each stage holds for five seconds, then the particles melt into smoke and gather into the next one. The canvas is sized by CSS; the drawing follows its size and the pixel ratio. Call scene.destroy() when the canvas goes away.
Scenes, stages and objects
A scene is a list of stages. A stage is one or more objects shown together, and the scene morphs from stage to stage. A stage can be written in three ways:
stages: [
loadGltf("/models/monkey.glb"), // a source: one object, turning
{ source: box(), motion: "sway", transform: { scale: 0.8 } }, // one object
{ // several objects
hold: 8,
items: [
{ id: "left", source: torus(), transform: { position: [-0.9, 0, 0], scale: 0.5 }, motion: "tumble" },
{ id: "right", source: loadGltf("/models/gear.glb"), transform: { position: [0.9, 0, 0], scale: 0.5 } },
],
},
]
The scene's space has y up, x to the right and z towards the viewer, as in glTF. One unit is half the smaller side of the canvas (times zoom, 0.85 by default). Every source is normalised to fit a sphere of radius 1, so an object at scale 1 fills most of the height of a square canvas, whatever its size was in Blender.
Moving between stages:
// createAsciiScene(canvas, { stages, stage: 2 }) starts on stage 2
scene.goTo(2); // morphs to stage 2
scene.show(0); // shows stage 0 at once, no morph
scene.morphStyle = "explode"; // "smoke" (default), "direct", "swirl", "explode"
scene.morph = 2.5; // seconds a morph takes
scene.next();
scene.previous();
scene.autoplay = false;
await scene.setStages([...]); // a new list, resolves when the models have loaded
await scene.setStages([...], 3); // the same, showing stage 3 from the first frame
await scene.ready; // every source of the current list loaded
Moving objects
Every object has a transform (position, rotation, scale) and a motion that runs on top of it.
const gear = scene.item("right")!;
gear.set({ position: [0.5, 0.2, 0], rotation: [0, 45, 0], scale: 0.7 }); // at once
await gear.animate({ position: [0, 0, 0.5], scale: 1 }, 1.5); // eased, in 1.5 s
gear.motion = { spin: { axis: [0, 0, 1], speed: 40 }, tilt: 20 }; // degrees per second
console.log(gear.transform); // { position, rotation (quaternion), scale }
rotationtakes Euler angles in degrees, applied X, then Y, then Z (Blender's XYZ Euler), or a quaternion[x, y, z, w].scaletakes a number or a vector. When an object grows or shrinks a lot, the scene samples it again, so the surface keeps the same density on screen.motionis one of:"turn": all the way round, tilted a little;"sway": left and right, so the front stays readable;"steady": a little sway, for words and logos;"tumble": spins around its own axis, tilted towards you, for rings and knots;"none";- a custom motion:
{ spin: { axis, speed }, sway: { axis, amplitude, period }, tilt }.
For an animation of your own, change transforms in onFrame:
createAsciiScene(canvas, {
stages: [...],
onFrame: (t, scene) => scene.item("planet")?.set({ position: [Math.cos(t) * 0.5, 0, Math.sin(t) * 0.5] }),
});
Models from Blender
The reader takes glTF 2.0: .glb, or .gltf with its .bin next to it or embedded. In Blender use File › Export › glTF 2.0 with:
- Format: glTF Binary (.glb);
- Transform › +Y Up: on (the default);
- Mesh › Apply Modifiers: on, so subdivision, bevels and mirrors are exported as you see them;
- Data › Compression: off. Draco and meshopt compression are not supported and are refused with a clear message.
What is read:
- every mesh of the scene, placed by its object's transform and its parents' (a model built from parented parts keeps its layout);
- smooth or flat shading as set in Blender (vertex normals). Edges where the surface bends sharply are drawn a little brighter, so the shape reads;
- the materials, for colour (see below).
Animations, armatures, shape keys and textures are not read. Points, lines and curves have no surface and are skipped; convert text and curves to meshes first (Object › Convert › Mesh).
Colours
Characters come in two colours by default: the theme's text colour, and the accent for every material whose name contains accent (for example accent, accent glass). With colors: "material" each material keeps its own base colour (converted from Blender's linear colour to sRGB), and accent materials still take the accent:
loadGltf("/models/rocket.glb", { colors: "material" });
loadGltf("/models/logo.glb", { accent: /blue|glow/i }); // which materials are accent
loadGltf("/models/scene.glb", { nodes: ["Gear", "Hub"] }); // only these objects (and their children)
loadGltf takes a URL, a File or Blob (a dropped file), or bytes. gltfSource(bytes) does the same without awaiting, and parseGltf, gltfMesh, gltfNodes and gltfMaterials give the parts on their own.
Size
A model is drawn with points spread over its surface by area, about half a character apart; small models need no more points than big ones. A mesh needs no particular resolution: a cube of 12 triangles and a sculpt of 500,000 both work. Above 80,000 points the spacing gets coarser (sample: { maxPoints }). For a fast first load, keep .glb files small: decimate dense sculpts in Blender (Decimate modifier) before exporting.
Other sources
Built-in shapes, as meshes: box(w, h, d), sphere(), torus(major, minor), torusKnot(p, q), blocks() (a 3 × 3 × 3 cube of blocks), and the mesh builders boxMesh, sphereMesh, torusMesh, tubeMesh(curve).
Flat drawings extruded into solids (browser only):
extrudedPath("m12,1.5 l9.265,4.344 v3.621 s-0.475,9.052,-9.265,13.035 C2.735,18.517,2.735,9.465,2.735,9.465 v-3.621 L12,1.5 Z", { viewBox: [24, 24], depth: 0.17 });
extrudedText("hello", { weight: 800 });
extruded((ctx) => { ctx.fillStyle = "#f00"; ctx.fillRect(0, 0, 10, 10); }, [10, 10]);
What is painted red (#f00) is drawn in the accent on the front face.
Your own meshes: meshSource({ positions, indices, normals?, slots?, palette? }). The figures of the day's occasions, as the 404 page shows them: seasonSources(date, seasons, { religious: false }) (without religious: false, the religious feasts and symbols in RELIGIOUS_SEASONS are included). Effects on any source: crumbling(source, at, blend, side) turns one side into flickering dust that sheds glyphs.
Effects
Effects load into a scene and combine freely: they push the objects' particles, change the glyphs drawn, draw glyphs of their own, shake the picture or react to clicks.
import { createAsciiScene, fire, smoke, heatHaze, explode, holes, rain, lightning } from "mote-engine";
const scene = createAsciiScene(canvas, {
stages: [model, { items: [...], effects: [rain(), lightning()] }], // effects of one stage
effects: [fire({ intensity: 0.8 }), smoke(), heatHaze()], // effects of every stage
click: explode(), // what a click does
});
const wind = scene.addEffect(EFFECTS.wind({ strength: 0.6 }));
wind.set({ angle: 30 }); // change it while it runs
wind.remove();
scene.click = [ "ripple", holes({ heal: 8 }) ];
| Kind | Effects |
|---|---|
| Forces on the objects | wind, vortex, magnet (the pointer pushes or gathers), explode (debris, then it flies back together), holes (a click destroys a piece, the edge burns, it heals), shockwave, gravity (melts and piles on the floor), jelly, blackHole (opens beside the objects, bends them and swallows them slowly, then lets them go) |
| Glyph changes | rainbow, glitch, shake, scanlines, dissolve (burns away and back), hologram, matrix, neon, heatHaze, laser, halo (light around the objects' outline, with turning rays; the outline lit too) |
| Glyphs and forces | tornado: a funnel from the ground up into the sky (or to the clouds' underside, fading into it), narrow at its foot and wrapped in a debris cloud that trails behind it, turning, snaking (bend) and wandering (wander); its wind is a Rankine vortex with inflow at the ground and an updraft in the core, carrying dust up the funnel, flinging heavier debris out and tearing the objects apart where it is faster than they hold (pull) |
| All three | disintegrate: the objects dissolve from one end into a stream of dust and crystal shards that flows away and settles on the ground; with metal, steel faces with a moving sheen |
| Glyphs of their own | fire, smoke, rain (splashes on the objects), snow (piles up on them), sparks, fireworks, starfield (with warp), bubbles, water (with the objects' reflection), lightning (strikes the objects, flashes), confetti, aurora, fog, clouds (style: overcast, cumulus or layers; with rain and lightning a storm: the rain falls from their underside, the bolts strike from there and light them up), meteors, drip, orbit (hidden behind the objects) |
EFFECTS maps every name to its function, for menus, and EFFECT_PARAMS describes every effect's settings (a number with its range and step, an on/off, a choice, a colour, each with its default), so a settings panel can be built from it; effectDefaults(name) gives the defaults as an object. Sizes are in scene units, so an effect looks the same at any canvas size; effects with emitters can start from the objects' outline (source: "objects"), the ground or the pointer.
An effect of your own is an object with any of these hooks (types in fx.ts): frame(f) once per frame, particle(p, f) for each particle of the objects (push it with ax/ay, loosen its spring with hold, hide it with weight, move its point with gx/gy), glyph(g, f) for each glyph drawn (change ch, color, alpha, dx/dy), draw(f) for glyphs of its own (f.put, with f.solid, f.glyphAt and f.bounds to know where the objects are), offset(f) to shake, trigger(x, y) for clicks. f.sky is what the effects share in a frame (the clouds' underside per column, a lightning flash): write it in frame, read it in draw, and the order of the effects does not matter. GlyphParticles, noise2, fbm, hsl and gradient are there to build on.
Options
| Option | Default | |
|---|---|---|
stages |
the stages (required) | |
particles |
16000 | the fewest particles drawing the objects; the scene adds more when objects cover more of the canvas (a big scale) and gives them back when they shrink |
maxParticles |
200000 | the most particles; set it to particles for an exact count |
hold, morph |
5, 3.8 | seconds a stage holds, and a morph takes; a stage can set its own hold |
autoplay |
true | move on by itself |
fontSize, lineHeight, fontFamily |
11, 14, a monospace stack | the character grid |
colors |
grey and blue | { text, accent } or a function; cssColors() reads CSS custom properties |
zoom, origin |
0.85, [0.5, 0.5] | size of a unit, and where the origin sits on the canvas |
camera |
12 | camera distance in units: larger is flatter |
light |
upper left, front | direction towards the light |
ramp |
.,:-=+*#%@ |
characters from dark to bright |
halo |
1 | the glyphs flickering around the objects and flying off them, 0 to 1 |
trails |
true | short trails behind fast particles |
smoke |
"rise" |
how the smoke moves in a morph: "rise" or "sideways" |
morphStyle |
"smoke" |
how a morph looks: "smoke", "direct", "swirl", "explode" |
interactive, pointerTarget |
true, the canvas's parent | clicks and pointer moves drop ripples |
ambientRipples |
true | a ripple now and then on its own |
keepClear |
elements (or boxes) the characters keep clear of, such as a heading over the canvas | |
clearFloor |
0 | how much still shows over what keepClear keeps clear (0 to 1): a little lets the figures and the effects pass faintly under a heading, so the clear area does not read as a solid panel |
ambientFps |
30 | frames per second while nothing moves fast; the display's full rate during morphs and interaction |
reducedMotion |
"static" |
with the system's reduce motion, draw still frames; "ignore" animates anyway |
onFrame |
called every frame with the time in seconds |
scene.particles, scene.maxParticles, scene.fontSize, scene.zoom, scene.morph and scene.morphStyle can be changed while the scene runs. Other methods: scene.ripple(x, y), scene.setColors(...), scene.refreshColors() (after a theme change), scene.zoom = 1.2, scene.resize(), scene.pause(), scene.resume(), and scene.stats(), which gives the grid, the particles in use, the points per stage, the density drawn, the frames drawn in the last second (fps), the time a frame takes (frameMs) and whether the scene is resting at its ambient rate.
The scene pauses while the canvas is off screen or the tab is hidden.
A still frame as text
renderText draws a source as plain text, without a browser:
import { renderText } from "mote-engine/text";
import { gltfSource } from "mote-engine/gltf";
console.log(renderText(gltfSource(bytes), { cols: 100, rows: 45, rotation: [20, -30, 0] }));
Code backdrop
import { createCodeBackdrop } from "mote-engine/backdrop";
const backdrop = createCodeBackdrop(canvas, {
keepClear: () => document.querySelectorAll(".hero > *"), // the code keeps clear of the heading
snippets: [myCode], // C# by default
keywords: ["fn", "let", "mut", "pub"], // the language's keywords
});
Columns of code cycle on their own clocks: readable, swept into glyphs (identifiers first, keywords and punctuation last), scrambled, then decoded into the next snippet. Keywords are drawn in the accent, strings and numbers in colors.string.
Decode animation
import { animateDecode } from "mote-engine/decode";
const run = animateDecode(textarea, obfuscatedTrace, decodedTrace);
await run.done; // or run.finish() to jump to the end
Each line is aligned with its old version by a character diff. What both versions share stays still; the characters that go and the ones that come flicker through glyphs in a wave from the top left. The target is a textarea (anything with value) or a function that receives the text.
Performance
renderer: "auto" | "gpu" | "canvas" (an option, and scene.renderer while it runs) chooses what draws the glyphs; stats().renderer says what draws them now. Characters are drawn by the GPU with WebGL 2, every glyph of a frame in one call; every modern browser has it (Chrome, Edge, Firefox, Safari, on desktop and mobile). Without it, or if the GPU context is lost, the engine draws each glyph on the 2D canvas: the same picture, more CPU. useGpu(false) forces the canvas.
Memory
destroy() (and the function the page effects return) stops the frames, removes every listener and observer, and releases the glyph atlases and the WebGL context at once rather than at the next garbage collection: browsers keep only a few WebGL contexts alive, and an app that mounts and unmounts effects (a theme switched on and off) must not pile them up. Caches are bounded (glyph atlases refill when full, colours are capped), and every effect keeps its state in fixed-size pools.