Rendering Perlin Noise into ASCII instead of Pixels
Use a 3D noise field and a timestep to create "text-terrain".
The drifting texture on the entry page of this site is a grid of characters, rewritten about thirty times a second. You can select it with your cursor, paste it into a note, and see the dots and blocks it’s actually made of.
Here’s how it works.
The noise function
The component uses Stefan Gustavson’s port of Simplex noise, sampled in three dimensions. Two dimensions are space. The third is time.
simplex.noise3d(x / (width * 1.25), (x * y * 0.035) / 128, t);
Hold t fixed and you get a still image — one horizontal slice through a 3D field. Advance t a little and you get the next slice, almost identical. Advance it enough and the slices become unrelated. The animation is a slow fall through a solid block of noise, which is cheaper and smoother than trying to animate a 2D field directly.
The timestep is 0.001728, chosen by eye. Too large and the field boils. Too small and it stops reading as motion. The sweet spot is where you can almost convince yourself it isn’t moving, then notice a second later that it is.
Zoom
The spatial arguments aren’t raw x and y. They’re x / (width * 1.25) and a term involving both. The division is the zoom control — feed noise3d raw pixel coordinates and adjacent pixels are a full noise-unit apart, which is essentially uncorrelated, so the output looks like static. Dividing the coordinates down walks the field in smaller steps. 1.25 is an empirical value for a field this wide.
The x * y term in the second argument is currently multiplied by zero. It’s a shear knob, left in as a reminder that an earlier version of the animation wobbled diagonally.
Mapping float to glyph
A noise function returns a float; a terminal prints characters. You need a lookup table.
const gradientMap = [
" ",
" ",
"·",
"░",
" ",
" ",
"·",
"·",
"░",
"░",
"▓",
" ",
"·",
"░",
"█",
"█",
"█",
];
Seventeen slots, five distinct glyphs. The redundancy is the point. A normalized noise value clusters around the middle of its range, so five equal-width buckets would make the three middle buckets do almost all the work and the output would look flat.
The table reshapes the histogram without touching the math. Space appears five times, so low values resolve to nothing more often than they do to a dot. █ appears three times, so bright regions resolve with weight instead of flickering. You could do the same thing with a remapping function, but a table is easier to tune because you can see the shape while editing.
▒ is absent on purpose. With antialiased text it picks up a different vertical rhythm than the other glyphs and the rows it appears in visibly buckle. Deleting the glyph was simpler than fixing the renderer.
The horizon
let nVal = ((simplex.noise3d(...) + 1) / 2) * (1 - y * 0.035);
The (1 - y * 0.035) dims the output as y increases. That’s the only reason the banner has a horizon — without it, the field has no orientation.
The mouse
The animation advances t on its own. Cursor position gets mixed into t as well, but eased:
timeTravel.current =
timeTravel.current + (timeTarget.current * effect - timeTravel.current) * 0.1;
The target is clientX / innerWidth. The value actually used, timeTravel, moves 10% of the remaining distance toward the target per frame. A direct assignment would jump-cut on every flick of the cursor; the easing gives the field some weight. Same trick as smooth-scroll.
What the cursor is doing, in effect, is scrubbing a one-dimensional slice of time. The field has about a second of memory after you stop moving, then the autonomous clock takes over again.
Grid markers
Look closely and you’ll sometimes see a + or • where a · or space would normally be:
const isMidBand = val > 3 && val < 8;
const isGridCoordinate = (x % 15) - 4 === 0 && (y % 5) - 2 === 0;
const isReplacable = isMidBand && isGridCoordinate;
char = !isReplacable ? char : char === "·" ? "+" : char === " " ? "•" : char;
Every fifteen columns and five rows, if the current cell is in the mid-brightness band, dots become pluses and spaces become bullets. One modulo per cell, and it does more for the overall look than any of the noise parameters — pushes the image from “texture” toward “diagram.”
Recipe
- Sample 3D noise over a grid.
- Advance the third argument each frame.
- Map the output through a hand-tuned character ramp.
- Dim the bottom rows.
- Mix the cursor into
t, with easing.