---
title: "Turning WebGL into ASCII, every frame"
description: "A three.js plane rendered to a hidden canvas, then read pixel-by-pixel and rewritten as ASCII characters in a <pre>. The trick is sampling the render at one pixel per glyph. From B3's ai-arena."
date: "2026-05-31"
tags: ["three", "webgl", "shaders", "frontend"]
---

This titled the HypeDuel arena over in [ai-arena](https://hypeduel.com), and
it's a neat trick: real 3D text, rendered with shaders, then converted to ASCII
in the browser every single frame.

Move your cursor over it — the plane tilts and the colors hue-shift to follow.

## Two canvases doing different jobs

It starts ordinary. The word gets drawn to an offscreen 2D canvas, uploaded as
a texture, and mapped onto a subdivided `PlaneGeometry`. A vertex shader rolls
the vertices on sine waves; a fragment shader nudges the R, G, and B channels by
slightly different amounts so the edges chromatically shimmer.

```glsl
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
transformed.z += sin(time + position.x) * waveFactor;
```

## The ASCII pass

Here's the part that makes it. Each frame, the WebGL output is drawn into a
*second*, tiny canvas — sized so one pixel equals one output character. Then
every pixel is read back, converted to brightness, and mapped to a glyph from a
ramp ordered dark-to-light:

```ts
const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
const idx = Math.floor((1 - gray) * (charset.length - 1));
str += charset[idx]; // " .:-=+*#%@" style ramp, just longer
```

The whole string drops into a `<pre>` with `mix-blend-mode: difference` and a
gradient clipped to the text. Cursor position feeds both the plane's rotation
and a `hue-rotate` on the container, so the thing tracks the mouse with zero
per-character work.

## Keeping it off the server

three runs in the browser only. On Cloudflare Workers, importing it into the SSR
graph crashes the render, so the component is a thin loader that pulls the heavy
scene in with a dynamic `import()` inside `useEffect`. Same pattern I used for
the [Infinite Terrain](/components/infinite-terrain) scene.

From B3's ai-arena; the underlying effect comes from the React Bits community.
Grab it on the [ASCII Text](/components/ascii-text) page.
