From the web

ASCII Text

A 3D word rendered live as a field of characters.

Three.js · Canvas

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);
  }
}