A Three.js scene renders to an offscreen canvas, and every frame is sampled into a character grid by brightness: a 1970s terminal fed by a GPU.
A Three.js plane textured with the word is rendered to a hidden WebGL canvas, then that frame is downscaled onto a canvas with one pixel per character cell. Each pixel's brightness picks an index into a density-ordered character ramp, and the resulting rows are written into a monospace pre element, only for rows whose characters changed since the last frame. The pointer tilts the plane before it is rendered, so the characters re-sample a rotated word.
asciify(ctx, w, h) {
const imgData = ctx.getImageData(0, 0, w, h).data;
const { indices, prevIndices, lookup, spaceCode, charsetLast } = this;
// Brightness -> character index. Transparent pixels become spaces.
for (let y = 0; y < h; y++) {
const rowOffset = y * w;
for (let x = 0; x < w; x++) {
const i = (rowOffset + x) * 4;
if (imgData[i + 3] === 0) {
indices[rowOffset + x] = spaceCode;
continue;
}
const gray = (0.3 * imgData[i] + 0.6 * imgData[i + 1] + 0.1 * imgData[i + 2]) / 255;
let idx = Math.floor((1 - gray) * charsetLast);
if (this.invert) idx = charsetLast - idx;
indices[rowOffset + x] = idx;
}
}
// Rewrite only the rows whose characters changed.
for (let y = 0; y < h; y++) {
const rowOffset = y * w;
let dirty = false;
for (let x = 0; x < w && !dirty; x++) {
dirty = prevIndices[rowOffset + x] !== indices[rowOffset + x];
}
if (!dirty) continue;
let row = '';
for (let x = 0; x < w; x++) row += lookup[indices[rowOffset + x]];
this.rowEls[y].textContent = row;
prevIndices.set(indices.subarray(rowOffset, rowOffset + w), rowOffset);
}
}