You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Document the renderers and how to choose between them
The README's usage example still constructed `Asciify` directly, which is
now an interface. It leads with `createAsciify` instead, and gains a
table covering when each renderer applies plus an EffectComposer example.
CLAUDE.md records the measurements behind these decisions, and the
methodology needed to reproduce them. requestAnimationFrame timings are
vsync-bound and flatten everything to ~8ms; `gl.finish()` does not
reliably drain Chrome's GPU process and reported a 4K fragment pass at
0.027ms, which would be 300 Gpixel/s. A 1x1 readPixels is a genuine sync.
It also records a benchmark mistake worth not repeating: letting the
compared paths render at different surface sizes produced a confident,
entirely backwards conclusion about whether zero-copy was worth building.
Copy file name to clipboardExpand all lines: CLAUDE.md
+72-4Lines changed: 72 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -14,11 +14,29 @@ yarn check-types # typecheck only
14
14
yarn clean # tsc -b --clean
15
15
yarn lint # oxlint, then oxfmt --check
16
16
yarn lint:fix # oxlint --fix, then oxfmt
17
+
yarn test# vitest run, in a real headless Chromium
18
+
yarn test:watch # same, in watch mode
17
19
yarn demo # http-server on :8081 — serves the repo root so demos can import /out/index.js
18
20
yarn release # compile + release-it
19
21
```
20
22
21
-
There is no test suite and no test runner. Verification is visual: `yarn compile`, then `yarn demo` and open a demo page.
23
+
### Tests
24
+
25
+
`test/` runs under **Vitest browser mode** with the Playwright provider — a real headless Chromium, because everything worth testing here depends on Canvas2D and WebGL2, neither of which jsdom provides. Tests import the TypeScript sources directly through Vite, so no build step is needed first.
26
+
27
+
-`test/parity.test.ts` — the important one. Renders identical input through `Asciify2D` and `AsciifyWebGL` and asserts **zero differing pixels**, across canvas sizes that do and don't divide evenly into cells, pinned animation ticks, and each option that changes geometry. Three real bugs have been caught here.
28
+
-`test/pass-parity.test.ts` — the same treatment for `AsciifyPass`, which reaches the pixels by a completely different route (sampling a framebuffer texture in the host's context), plus a check that it never resizes the host's canvas.
29
+
-`test/orientation.test.ts` — asymmetric four-quadrant fixtures that fail on a horizontal mirror, a vertical mirror, or an off-by-one row.
30
+
-`test/glyph-resources.test.ts` — unit coverage for `LookupTable`, `LuminanceCharacterMap`, `TextureCache` dedup, and `GlyphAtlas` slotting.
31
+
-`test/create-asciify.test.ts` — renderer selection, including a mocked WebGL2-less platform to prove the fallback fires rather than rendering nothing.
32
+
-`test/composer-pass.test.ts` — **runs against a real `EffectComposer`.**`AsciifyComposerPass` agrees with Three by duck-typing rather than by importing it, so nothing else would notice Three changing its `Pass` protocol or how it attaches render targets. This suite exists to fail when that happens. It also pixel-compares the composer path against hand-driving `AsciifyPass` over the same target, which isolates the adapter from colour-management differences.
33
+
34
+
Two things to know when writing tests here:
35
+
36
+
-**Test canvases need an explicit CSS size and must be attached to the document.**`setSize` derives the backing store from `getBoundingClientRect()`, so an unsized canvas feeds its own layout size back into itself and collapses to nothing. Use `createStage()`. A CSS border also skews it, since the rect includes it.
37
+
-**Read output back in the same task as the draw.** Neither renderer preserves its drawing buffer, and a WebGL output canvas has no 2D context of its own — `readCanvas()` routes through a scratch 2D canvas.
38
+
39
+
The demos remain the visual check: `yarn compile`, then `yarn demo`. Each demo takes `?renderer=webgl` to switch backends.
22
40
23
41
Yarn 4 (`packageManager: yarn@4.18.0`) with `nodeLinker: node-modules`. Node >= 24.
24
42
@@ -41,20 +59,70 @@ The sources still carry full TSDoc — `@category Main | Helper | Configuration
41
59
42
60
## Architecture
43
61
44
-
Entry point `index.ts` re-exports `Asciify.ts`, `options/index.ts`, and `utils/index.ts`.
62
+
`createAsciify()` is the entry point callers should use: it prefers `AsciifyWebGL`, falls back to `Asciify2D` when WebGL2 is unavailable _or_ when construction fails despite the probe passing (context limits, blocklisted drivers), and takes a `renderer: "auto" | "webgl" | "2d"` preference. `isWebGL2Available()` probes on a throwaway canvas, because asking a canvas for a WebGL context commits it permanently.
63
+
64
+
`Asciify` (`Asciify.ts`) is an **interface**, not a class. Three backends implement it, all extending `AsciifyBase`:
65
+
66
+
-**`Asciify2D`** — Canvas2D. Works anywhere a 2D context does, including `OffscreenCanvas`. Far and away the slowest; it exists for environments without WebGL2, not for throughput.
67
+
-**`AsciifyWebGL`** — owns a canvas and its own WebGL2 context, one draw call per frame. Throws if WebGL2 is unavailable, and taking the context claims the output canvas (no 2D context alongside it). The default choice.
68
+
-**`AsciifyPass`** — borrows a host's WebGL2 context and samples a texture the host already holds, so nothing crosses a context boundary. Roughly 2x `AsciifyWebGL` at 1080p, converging at 4K. Constructed from a context rather than a canvas, and driven by `rasterizeTexture()`. `AsciifyComposerPass` wraps it for Three.js `EffectComposer`.
69
+
70
+
**There is no source type for which `Asciify2D` is faster.** The rasterizer cost is backend-specific and the source preparation is shared, so WebGL wins for images, buffers, and 3D alike. Renderer choice is about the _output_ canvas — whether you need a 2D context on it — not about what you're feeding in. Don't add source-sniffing heuristics; there is nothing for them to decide.
71
+
72
+
The two GL backends share `utils/GlyphProgram.ts` — the shader, the atlas and luminance textures, and the single `drawArrays`. **Keep it that way**: the GLSL living in one place is a large part of why the backends stay pixel-identical.
73
+
74
+
`AsciifyPass` is the one renderer that does not own its surface, which changes two things. `setSize()`**ignores its arguments** and derives the grid from `gl.drawingBufferWidth/Height` — it must never resize a canvas the host owns, which is what the `_surfaceWidth`/`_surfaceHeight` hooks on `AsciifyBase` exist for. And applying `pixelRatio` to the drawing buffer becomes the host's job (`renderer.setPixelRatio` in Three.js); the option still scales glyphs, so the two must agree.
75
+
76
+
Callers sharing a context need to know the pass touches the bound program, vertex array, texture units 0–2, active texture unit, and viewport. It leaves blend, depth, and framebuffer bindings alone and draws into whatever framebuffer is bound. Three.js caches GL state, so it needs `renderer.resetState()` afterwards.
77
+
78
+
Three is a devDependency and the composer suite imports it. That is the only place Three appears outside the demos — library code stays free of it.
79
+
80
+
`AsciifyComposerPass` duck-types Three's `Pass` protocol (`enabled`/`needsSwap`/`clear`/`renderToScreen`/`setSize`/`render`) without importing Three. It recovers the read buffer's `WebGLTexture` via `getFramebufferTexture()` — bind the target, then ask the framebuffer what's attached — rather than the usual `renderer.properties.get(texture).__webglTexture`, which is a private field with no compatibility promise. Multisampled targets attach a renderbuffer instead of a texture and are rejected with a message saying so. The composer's buffers must be sized to the character grid, not the canvas, which is what `syncComposerSize()` is for.
81
+
82
+
`AsciifyBase` owns everything backend-agnostic: option normalization, the grid arithmetic, the scratch canvas, and the two convenience entry points. Subclasses implement `rasterize`, `clearCanvas`, and two hooks — `_onOptionsChanged` (build glyph resources) and `_onResize` (resize surfaces).
83
+
84
+
**Subclasses must call `setOptions()` at the end of their own constructor**, never from `AsciifyBase`'s. Class fields initialize after `super()` returns, so anything the base constructor built would be clobbered by the subclass's own field declarations.
85
+
86
+
All three backends are verified **byte-identical** — same pinned input, zero differing pixels. Keep it that way: a change to one is a change to all of them. Two things that broke parity and are easy to break again:
87
+
88
+
-**The colour layer must be upscaled onto the grid (`columnCount * cellSize`), not the canvas.** The canvas is rarely an exact multiple of the cell size; stretching to the full width shears colour against glyphs, by a whole cell at the far edge.
89
+
-**Reconstruct exact bytes before quantizing luminance in GLSL** (`floor(rgb * 255.0 + 0.5)`). A normalized texel multiplied straight back by 255 lands under the integer often enough to shift luminance by one, which picks a different _character_, not a slightly different shade.
90
+
91
+
Entry point `index.ts` re-exports all of the above plus `options/index.ts` and `utils/index.ts`.
45
92
46
93
The hot path is `Asciify.rasterize()`. Everything else exists to make that loop cheap, so changes there are performance-sensitive. The precomputed pieces:
47
94
48
95
-**`LuminanceCharacterMap`** (`utils/`) — `Map<0..255, character>`. The character set is padded at the low end with `contrastRatio` spaces, then spread across all 256 luminance values, so luminance → character is a map lookup with no `Math.floor` at render time.
96
+
-**`GlyphAtlas`** (`utils/`) — the WebGL counterpart to `TextureCache`: every distinct character packed into one horizontal strip, plus a 256-entry `luminanceToSlot` table uploaded as a 256x1 texture. The table is uploaded rather than recomputed in GLSL so the contrast-ratio padding lives in exactly one place.
49
97
-**`TextureCache`** (`utils/`) — an `Array` subclass indexed by luminance, holding a sprite that is **opaque where the glyph covers and transparent elsewhere**, so it works directly as a `destination-in` mask. Sprites are **deduplicated by character**: 256 luminance slots typically resolve to a dozen or so glyphs, and every slot sharing a character points at the same object. That dedup is worth ~1.4x, but only once the per-cell state changes are gone — on its own it measured as noise. `blank` flags the whitespace slots so the rasterizer can skip them entirely. Sprites upgrade to `ImageBitmap` asynchronously; `initializedBitmaps` resolves when done.
50
98
-**`LookupTable`** (`utils/`) — two `Uint16Array`s giving each cell's `x`/`y` on the output canvas, indexed by `row * columnCount + column`.
51
99
52
-
`rasterize(buffer, flipY?)` runs two passes:
100
+
`Asciify2D.rasterize(buffer, flipY?)` runs two passes:
53
101
54
102
1.**Mask.** Walk the buffer, compute an integer luminance with bit shifts, and stamp the glyph into a full-size mask canvas. **Nothing in this loop touches context state** — that is the entire point. The previous implementation flipped `globalCompositeOperation` twice and assigned a `fillStyle` string per cell, and those state changes dominated the frame.
55
103
2.**Composite.** Paint the colour (one nearest-neighbour `drawImage` upscaling the `columnCount × rowCount` colour surface, so one source pixel becomes one flat cell), apply the mask with a single `destination-in`, then slide the background in underneath with `destination-over`.
56
104
57
-
Measured against the old per-cell approach on a 160×90 grid at 3840×2160, all cells dirty: 25.4 → 11.5 ms/frame.
105
+
The GL backends have no per-cell CPU work at all: get the source onto the GPU, set uniforms, one `drawArrays`. `AsciifyWebGL.rasterizeWebGLRenderer` skips the `readPixels` round trip the 2D renderer needs, uploading the source renderer's canvas straight into a texture — with `UNPACK_COLORSPACE_CONVERSION_WEBGL` set to `NONE`, since the browser's default colour management would otherwise shift values. `AsciifyPass.rasterizeTexture` skips even that, sampling a texture the host already holds.
106
+
107
+
### Measured cost
108
+
109
+
`requestAnimationFrame` timings are worthless here — rAF is vsync-bound and flattens everything to ~8 or ~16 ms regardless of real cost. `gl.finish()` is also **not** a reliable drain of Chrome's GPU process; it reported a 3840×2160 fragment pass at 0.027 ms/frame, which would be ~300 Gpixel/s. A 1×1 `readPixels` (or `getImageData` on a 2D context) is a genuine sync — use that.
110
+
111
+
Drained numbers, all three paths producing byte-identical output:
| 3840 × 2160 | 24px | 160×90 | 14,400 | 22.0 ms | 0.35 ms | 0.31 ms |
116
+
| 1920 × 1080 | 8px | 240×135 | 32,400 | 45.7 ms | 0.31 ms | 0.15 ms |
117
+
| 1280 × 720 | 8px | 160×90 | 14,400 | 21.5 ms | 0.31 ms | 0.16 ms |
118
+
119
+
Three things fall out of that table:
120
+
121
+
-**`Asciify2D` cannot hold 60 fps at a real output size.** It scales with cell count and pays full-frame compositing on top; 22–46 ms is 1.3–2.8 whole frames. The GL backends are 60–150× faster.
122
+
-**`AsciifyWebGL` is pinned near 0.31 ms regardless of resolution or cell count.** Its cost is not fragment work — it is the fixed per-frame cost of moving the source across a context boundary.
123
+
-**`AsciifyPass` removes exactly that fixed cost**, so it wins by ~0.16 ms at 1080p and below (2.1×) and converges with `AsciifyWebGL` at 4K, where fragment work finally dominates. In absolute terms 0.16 ms is under 1% of a 60 fps budget, so pick the pass for composition into an existing render graph as much as for the speed.
124
+
125
+
Beware when measuring any of this: a benchmark that lets the paths render at different surface sizes will produce confident nonsense. An earlier revision gave two paths a CSS size of W/4 and called `setSize()` with no arguments, which re-derived their backing stores from `getBoundingClientRect()` — one path ended up doing 16× the fragment work of the others, and the resulting conclusion ("zero copy is slower") was exactly backwards. The bench now asserts grid agreement across backends and pixel-diffs their output every run.
Copy file name to clipboardExpand all lines: README.md
+54-10Lines changed: 54 additions & 10 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -19,7 +19,9 @@ The API is documented inline — every export carries TSDoc, so your editor is t
19
19
20
20
### 🏃♀️ Fast
21
21
22
-
Asciify rasterizes directly to a canvas element, so it's much faster than other libraries that use the DOM to render text nodes. This comes at the cost of an actual textual representation, but if you're looking for a fast way to convert 3D animations to ASCII art, Asciify is a perfect fit.
22
+
Asciify rasterizes directly to a canvas, so it's much faster than other libraries that use the DOM to render text nodes. This comes at the cost of an actual textual representation, but if you're looking for a fast way to convert 3D animations to ASCII art, Asciify is a perfect fit.
23
+
24
+
By default it rasterizes the whole frame in a **single WebGL draw call** — around 0.3ms for a 4K output, with no per-character work on the CPU at all. Where WebGL2 isn't available it falls back to a Canvas2D rasterizer automatically, so you always get a picture.
|`AsciifyWebGL`| The default. One draw call per frame; cost is independent of how many characters you're drawing. |
96
+
|`Asciify2D`| Automatic fallback when WebGL2 is missing. Also the one to pick if you need a 2D context on the output canvas yourself — a WebGL context claims the canvas exclusively. |
97
+
|`AsciifyPass`| You already have a WebGL2 context and want asciify to render inside it, sampling a texture you already hold. Skips a per-frame upload; roughly 2× the WebGL renderer at 1080p. |
0 commit comments