asciify

Asciify

Converts images, videos, and 3D renders into rasterized ASCII art.

npm version deno module npm downloads license

Spiral shader demo · Bouncing ball demo · Image upload demo

Asciify draws character sprites onto a canvas rather than emitting text nodes, so a full frame costs one WebGL draw call: around 0.3 ms at 4K, with no per-character work on the CPU. Where WebGL2 is missing it falls back to a Canvas2D rasterizer automatically, and a separate terminal renderer rasterizes to ANSI escapes for Node. Zero dependencies; the browser renderers are about 8 kB gzipped and the terminal renderer about 3 kB.

Anything that reaches a canvas can be asciified: images, video frames, WebGL scenes, or raw RGBA buffers. Character sets, fonts, contrast, and color are all configurable. The API is documented inline; every export carries TSDoc, so your editor is the reference.

Installation

yarn add @sister.software/asciify
# or
npm install --save @sister.software/asciify

Or with Deno:

import { createAsciify } from "https://deno.land/x/asciify/index.ts"

Usage

createAsciify attaches a renderer to your output canvas, preferring WebGL and falling back to Canvas2D on its own:

import { createAsciify } from "@sister.software/asciify"

const canvas = document.createElement("canvas")
const asciify = createAsciify(canvas)

asciify.setSize(window.innerWidth, window.innerHeight)
await asciify.rasterizeImage(myImageElement)

For 3D content, size the source so one scene pixel becomes one character. Passing the renderer as the third argument to setSize keeps the two in sync:

const renderer = new THREE.WebGLRenderer({
	powerPreference: "high-performance",
	precision: "lowp",
})

asciify.setSize(window.innerWidth, window.innerHeight, renderer)

renderer.render(scene, camera)
asciify.rasterizeWebGLRenderer(renderer)

Choosing a renderer

createAsciify handles this for you, but each renderer is exported if you want it directly.

Renderer When
AsciifyWebGL The default. One draw call per frame; cost is independent of how many characters you’re drawing.
Asciify2D Automatic fallback when WebGL2 is missing. Also the one to pick if you need a 2D context on the output canvas yourself, since a WebGL context claims the canvas exclusively.
AsciifyPass You already have a WebGL2 context and want asciify to render inside it, sampling a texture you already hold. Skips the per-frame source upload.

Pass a preference if you need to force one:

createAsciify(canvas, { renderer: "2d" }) // "auto" (default) | "webgl" | "2d"

All three produce byte-identical output; the test suite pixel-diffs them against each other on every run. Timings below use a 1×1 readPixels to drain the GPU, since requestAnimationFrame is vsync-bound and flattens every measurement to the frame interval:

Output Grid Asciify2D AsciifyWebGL AsciifyPass
3840 × 2160 160×90 22.0 ms 0.35 ms 0.31 ms
1920 × 1080 240×135 45.7 ms 0.31 ms 0.15 ms
1280 × 720 160×90 21.5 ms 0.31 ms 0.16 ms

Renderer choice is about the output canvas, never the source: there is no input type for which Asciify2D is faster.

Three.js post-processing

If you’re already running an EffectComposer, asciify can be the last pass in the chain. AsciifyComposerPass duck-types Three’s Pass protocol, so nothing from Three is imported and the package stays dependency-free:

import { AsciifyComposerPass } from "@sister.software/asciify"

const composer = new EffectComposer(renderer)
composer.addPass(new RenderPass(scene, camera))

const asciiPass = new AsciifyComposerPass(renderer, { fontSize: 12 })
asciiPass.renderToScreen = true
composer.addPass(asciiPass)

// Asciify wants one source pixel per character, so the composer's buffers
// are sized to the character grid rather than to the canvas.
asciiPass.syncComposerSize(composer)

In the terminal

@sister.software/asciify/tui is a separate entry point that rasterizes to ANSI escapes instead of a canvas. It ships DOM-free type declarations, so Node projects can use it without a dom lib.

import { AsciifyTerminal } from "@sister.software/asciify/tui"

const asciify = new AsciifyTerminal(process.stdout)

// The source renders at one pixel per braille dot: 2 wide and 4 tall per cell.
const frame = new Uint8ClampedArray(asciify.sourceWidth * asciify.sourceHeight * 4)

// ...fill the buffer with RGBA pixels, then:
asciify.rasterize(frame)

What you get:

The output target is structural ({ columns, rows, write }), so process.stdout, an xterm.js terminal, or a test sink all work. Anything that produces RGBA can feed it, including readPixels from a WebGL scene; rasterize(buffer, true) handles the row flip.

Demos

The browser demos are live at asciify.sister.software: a spiral fragment shader, a bouncing 3D ball, and an image uploader. Each takes ?renderer=webgl or ?renderer=2d to force a backend.

The terminal demos live in demo/tui and run from a clone (yarn compile first):

node demo/tui/spiral.mjs   # the spiral shader, ported to CPU JavaScript
node demo/tui/ball.mjs     # Amiga Boing homage; a showcase for the damage diff
node demo/tui/3d.mjs       # the bouncing ball scene as a CPU raytrace, with keyboard orbit
node demo/tui/3d-gpu.mjs   # the same scene rendered by Three.js in headless Chromium

Alternatives

License

Asciify is licensed under the MIT License. If you build something with it, let us know at @SisterSoftware.