Skip to content

Commit c2ed695

Browse files
committed
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.
1 parent ed1ecfb commit c2ed695

2 files changed

Lines changed: 126 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,29 @@ yarn check-types # typecheck only
1414
yarn clean # tsc -b --clean
1515
yarn lint # oxlint, then oxfmt --check
1616
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
1719
yarn demo # http-server on :8081 — serves the repo root so demos can import /out/index.js
1820
yarn release # compile + release-it
1921
```
2022

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

2341
Yarn 4 (`packageManager: yarn@4.18.0`) with `nodeLinker: node-modules`. Node >= 24.
2442

@@ -41,20 +59,70 @@ The sources still carry full TSDoc — `@category Main | Helper | Configuration
4159

4260
## Architecture
4361

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`.
4592

4693
The hot path is `Asciify.rasterize()`. Everything else exists to make that loop cheap, so changes there are performance-sensitive. The precomputed pieces:
4794

4895
- **`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.
4997
- **`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.
5098
- **`LookupTable`** (`utils/`) — two `Uint16Array`s giving each cell's `x`/`y` on the output canvas, indexed by `row * columnCount + column`.
5199

52-
`rasterize(buffer, flipY?)` runs two passes:
100+
`Asciify2D.rasterize(buffer, flipY?)` runs two passes:
53101

54102
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.
55103
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`.
56104

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:
112+
113+
| output | cell | grid | cells | `Asciify2D` | `AsciifyWebGL` | `AsciifyPass` |
114+
| ----------- | ---- | ------- | ------ | ----------- | -------------- | ------------- |
115+
| 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.
58126

59127
Two consequences worth knowing:
60128

README.md

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ The API is documented inline — every export carries TSDoc, so your editor is t
1919

2020
### 🏃‍♀️ Fast
2121

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

2426
### 🔍 Small
2527

@@ -44,28 +46,26 @@ npm install --save @sister.software/asciify
4446
### Deno
4547

4648
```ts
47-
import { Asciify } from "https://deno.land/x/asciify/index.ts"
49+
import { createAsciify } from "https://deno.land/x/asciify/index.ts"
4850
```
4951

5052
## Usage
5153

5254
```ts
53-
import { Asciify, readFromThreeJS } from "@sister.software/asciify"
55+
import { createAsciify } from "@sister.software/asciify"
5456

55-
// Create an Asciify instance and attach it to a canvas...
57+
// Create an Asciify renderer and attach it to a canvas. This picks WebGL where it can,
58+
// and falls back to Canvas2D where it can't.
5659
const canvas = document.createElement("canvas")
57-
const asciify = new Asciify(canvas)
60+
const asciify = createAsciify(canvas)
5861

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

64-
const rendererContext = renderer.getContext()
65-
6667
asciify.setSize(window.innerWidth, window.innerHeight)
67-
// Set the size of the 3D renderer so that each pixel of ASCII art
68-
// corresponds to a single pixel in the 3D scene...
68+
// Size the 3D renderer so that each pixel of the scene becomes one ASCII character...
6969
renderer.setSize(asciify.columnCount, asciify.rowCount)
7070

7171
// Render a 3D scene...
@@ -74,7 +74,51 @@ const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerH
7474
renderer.render(scene, camera)
7575

7676
// Rasterize the scene into ASCII art!
77-
asciify.rasterizeWebGLRenderer(renderer, rendererContext)
77+
asciify.rasterizeWebGLRenderer(renderer)
78+
```
79+
80+
Images work the same way:
81+
82+
```ts
83+
const asciify = createAsciify(canvas)
84+
85+
asciify.setSize(640, 480)
86+
await asciify.rasterizeImage(myImageElement)
87+
```
88+
89+
## Choosing a renderer
90+
91+
`createAsciify` handles this for you, but the pieces are exported if you want them directly.
92+
93+
| renderer | when |
94+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
95+
| `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. |
98+
99+
Pass a preference if you need to force one:
100+
101+
```ts
102+
createAsciify(canvas, { renderer: "2d" }) // "auto" (default) | "webgl" | "2d"
103+
```
104+
105+
### Three.js post-processing
106+
107+
If you're already running an `EffectComposer`, asciify can be the last pass in the chain:
108+
109+
```ts
110+
import { AsciifyComposerPass } from "@sister.software/asciify"
111+
112+
const composer = new EffectComposer(renderer)
113+
composer.addPass(new RenderPass(scene, camera))
114+
115+
const asciiPass = new AsciifyComposerPass(renderer, { fontSize: 12 })
116+
asciiPass.renderToScreen = true
117+
composer.addPass(asciiPass)
118+
119+
// Asciify wants one source pixel per character, so the composer's buffers
120+
// are sized to the character grid rather than to the canvas.
121+
asciiPass.syncComposerSize(composer)
78122
```
79123

80124
Check out our [examples](https://github.com/sister-software/asciify/tree/main/demo) for more info on how Asciify can be used!

0 commit comments

Comments
 (0)