docs / ascii-field
ASCII FIELD
canvas 2D flow field
A vector field drawn in terminal glyphs. Your cursor is a vortex. Packets ride the flow.
# Overview
The screen is a grid of green terminal glyphs. Each glyph is one of -, /, |, \, and it shows the direction of a flow at that cell. The flow is a slow noise pattern. The cursor adds a vortex to it, so the glyphs near the cursor make rings. Amber and white hexadecimal digits move along the flow. These are the trails of the packets. A small amber text next to the cursor shows its position and the mode.
The effect is plain canvas 2D. The CPU computes one field vector for each cell on each frame. The grid loop does not draw text. All glyphs come from an atlas that the module draws one time in init, and each cell is one drawImage call.
The cell size comes from the font size fs. fs is 14 px, or 12 px when the viewport is narrower than 800 px at init. A cell is round(0.7 · fs) wide and round(1.2 · fs) high, which is 10 × 17 px (8 × 14 px for the small font). resize computes cols = ceil(width / cw) and rows = ceil(height / ch).
The element count in the statistics (this.count) is cols × rows, the number of cells. A 1024 × 768 viewport gives 103 × 46 = 4,738 cells. A 1710 × 930 viewport gives 171 × 55 = 9,405 cells. The packets (90, or 40 on a narrow viewport) are not part of the count.
# Interaction
- Move. The vortex centre is the smoothed pointer position (
pointer.sx,pointer.sy), which follows the cursor with a time constant of 0.1 s. The rings thus move behind a fast cursor and then catch up. The text readout uses the raw position (pointer.x,pointer.y). - Move fast. The pointer velocity (
pointer.vx,pointer.vy, in px/s) is added to the field as a wind, with the same falloff as the vortex. At 1,000 px/s the wind is 2.5 field units, which is more than the vortex (1.7). A fast stroke thus turns the glyphs along the stroke, pushes the packets with it, and makes cells with the glyphs+ * # @. - Hold the button. The cursor becomes a sink. The radial term changes from +0.7 (a weak source that pushes away from the cursor) to −1.2 (inward), and the vortex strength is multiplied by 1.8. Packets move in a spiral to the cursor and stay there. The readout changes from
VORTEXtoSINK. The module reads only the level ofpointer.down. The press edge has no function of its own. - Idle. After 2.5 s with no input, the shared pointer goes into autopilot and moves on a slow path. The vortex follows it.
update reads the reduced flag and multiplies the time step sdt by 0.5. The packets then move at half speed, and the lit cells cool at half speed. The field does not become slower: the noise scroll, the field rotation, and the + * # @ animation use the clock t directly.
There is no URL parameter. The narrow-viewport branch (width < 800) sets the font size to 12 px and the packet count to 40. The module evaluates it in init only, so a later resize does not change these two values.
# Model
Value noise
vnoise(x, y, seed) gives a smooth value from 0 to 1. An integer hash gives a pseudo-random 16-bit value for each point of the integer lattice. Between the lattice points, the function interpolates the four corner values a, b, c, d:
u = xf² · (3 − 2 · xf) v = yf² · (3 − 2 · yf) vnoise = a + (b − a) · u + (c − a) · v + (a − b − c + d) · u · v
The smoothstep weights have zero slope at the lattice points. Without them, the interpolation is linear, and the lattice lines show as creases in the flow.
The field
The field is a sum of four terms. Its unit is abstract: one unit moves a packet at 160 px/s. x, y is the cell centre in px and t is the time in seconds.
n1 = vnoise(s · x, s · y + 0.08 · t, 1) n2 = vnoise(2.1 · s · x + 40, 2.1 · s · y − 0.05 · t, 2) ang = (0.7 · n1 + 0.3 · n2) · 4π + 0.1 · t mag = 0.35 + 0.5 · n2 V_noise = mag · (cos ang, sin ang)
The noise value becomes an angle, not a vector. The mix of the two layers, from 0 to 1, maps to 0 to 4π, which is two full turns. The time has three functions. n1 scrolls along y at 0.08 lattice cells for each second (approximately 29 px/s). n2 scrolls in the opposite direction at 0.05 (approximately 9 px/s). The term 0.1 · t turns all vectors together at 0.1 rad/s. n2 also sets the magnitude, from 0.35 to 0.85.
The cursor terms use the distance from the smoothed pointer:
(dx, dy) = (x − sx, y − sy) r = √(dx² + dy²) + 1 fall = exp(−(dx² + dy²) / (2 · 190²)) V_vortex = A · fall · (−dy, dx) / r V_radial = rad · fall · (dx, dy) / r V_wind = 0.0025 · fall · (p.vx, p.vy)
(−dy, dx) / ris the tangent direction. On a canvas,ypoints down, so this rotation is clockwise on the screen.fallis a Gaussian with σ = 190 px: 61% at 190 px and 14% at 380 px. A physical vortex such as the Rankine vortex decreases with 1/r. The Gaussian goes to zero faster, so the noise field far from the cursor stays as it is.- Because
ris the distance plus 1, the vortex and radial terms are zero at the centre and rise to almost the full value in a few px. Their sum is largest approximately 33 px from the cursor: 1.76 units with no button, 3.14 units with the button.
The module does not smooth the field between frames: fx and fy are fully overwritten on each frame. The only smoothing in time comes from the pointer module (smoothed position and smoothed velocity).
From vector to glyph
A line glyph has no head, so a vector and its opposite get the same glyph. The code folds the angle into the range 0 to π and divides it into four buckets of 45° (π/4):
a = atan2(vy, vx) if a is negative: a = a + π b = round(a / (π / 4)) mod 4 b: 0 → - 1 → \ 2 → | 3 → /
DIR is the string -/|\, so bucket 1 would be /. But on a canvas y points down. A vector with positive vx and vy points down and to the right, which is the shape of \. The code thus exchanges indices 1 and 3.
The magnitude and the heat of the cell select the glyph set, the tint row of TINTS, and the alpha:
- Heat more than 0.05: the hexadecimal digit of the packet. White (row 4) while the heat is more than 0.8, then amber (row 3). Alpha is
0.1 + 0.6 · heat. - Magnitude less than 0.3: the dot
·in dark green (row 0), alpha 0.35. The noise term is never less than 0.35, so a dot shows only where the cursor terms cancel the noise. - Magnitude 0.3 to 1.6: a direction glyph in neon green (row 1). Alpha is
0.04 + 0.28 · mag. For the noise alone this is 0.14 to 0.28, so the background is dim. - Magnitude 1.6 to 2.6: a direction glyph in pale green (row 2), same alpha formula, up to 0.77.
- Magnitude more than 2.6: one of the
HOTglyphs+ * # @in pale green. The direction is not shown. The index is(7 · c + 13 · r + floor(8 · t)) mod 4, so each cell steps through the four glyphs 8 times for each second, and adjacent cells are at different steps. Alpha is0.18 · mag, limited to 0.55. The limit is lower than for a line glyph because#and@light more pixels.
Packets
A packet is a position and a hexadecimal digit. It has no velocity of its own. On each frame it reads the field of the cell that it is in and moves:
px = px + fx[k] · 160 · sdt py = py + fy[k] · 160 · sdt heat[k] = 1
In the noise flow a packet moves at 56 to 136 px/s. A packet that goes out of the viewport comes back in on the opposite side. There is no respawn: packets are never removed. The packet itself is not drawn. It sets the heat of its cell to 1, and the heat decreases by 2.6 for each second, so a cell stays lit for approximately 0.37 s. The line of cooling cells behind the packet is its trail.
# Frame pipeline
- Limit the time stepreads dt, reducedwrites sdt
sdtisdt, limited to 1/30 s, and multiplied by 0.5 when reduced motion is set. - Sample the field and cool the cellsreads pointer.sx, sy, vx, vy, down; twrites fx, fy, heat
For each cell,
field()computes the vector at the cell centre and stores it. In the same loop, the heat of the cell decreases by2.6 · sdt, not below 0. - Move the packetsreads fx, fy, px, pywrites px, py, heat, hchar, pc
Each packet finds its cell, moves with the vector of that cell, and wraps at the edges. Then it sets the heat of the cell to 1 and writes its digit into
hchar. With a probability of 0.05 on each frame, the packet gets a new random digit. - Clear the canvasreads nonewrites canvas
A full fill with
#050508and the blend modesource-over. No part of the old frame stays. The trails come from theheatarray, not from the canvas. - Draw the gridreads fx, fy, heat, hchar, atlaswrites canvas
The blend mode is
lighter. For each cell, the code selects the glyph column, the tint row, and the alpha, and copies one tile from the atlas. - Draw the readoutreads pointer.x, pointer.y, pointer.downwrites canvas
One
fillTextcall draws the amber text at the cursor, for example[x:0512 y:0384 VORTEX]. When the cursor is near the right edge, the text goes to the left side of the cursor.
The packets move after the heat decreases. A cell that contains a packet is thus always drawn with a heat of exactly 1.
# Code
Value noise
The arrow function h is the hash. It mixes the two lattice coordinates and the seed with large odd multipliers, folds the high bits down with two xor-shift steps, and keeps 16 bits. The seed argument gives the two noise layers independent values on the same lattice.
function vnoise(x, y, seed) {
// 2D value noise with a cheap integer hash
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const h = (a, b) => { let n = (a * 374761393 + b * 668265263 + seed * 1274126177) | 0; n = ((n ^ (n >> 13)) * 1274126177) | 0; return ((n ^ (n >> 16)) & 0xffff) / 0xffff; };
const a = h(xi, yi), b = h(xi + 1, yi), c = h(xi, yi + 1), d = h(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}The field function
Read it as four additions to vx, vy: noise, vortex, radial term, wind. fall is computed one time and used by the three cursor terms. The function writes into the array out, so the cell loop does not make a new object for each cell.
// field vector at (x,y) in px → writes to out[0..1]
field(x, y, t, out) {
const p = this.pointer;
const s = 0.0028;
const n1 = vnoise(x * s, y * s + t * 0.08, 1), n2 = vnoise(x * s * 2.1 + 40, y * s * 2.1 - t * 0.05, 2);
const ang = (n1 * 0.7 + n2 * 0.3) * Math.PI * 4 + t * 0.1;
let mag = 0.35 + n2 * 0.5;
let vx = Math.cos(ang) * mag, vy = Math.sin(ang) * mag;
const dx = x - p.sx, dy = y - p.sy, r2 = dx * dx + dy * dy, r = Math.sqrt(r2) + 1;
const fall = Math.exp(-r2 / (2 * 190 * 190));
const A = 1.7 * fall * (p.down ? 1.8 : 1);
vx += (-dy / r) * A; vy += (dx / r) * A; // vortex
const rad = p.down ? -1.2 : 0.7; // hold: sink, else gentle source
vx += (dx / r) * rad * fall; vy += (dy / r) * rad * fall;
vx += p.vx * 0.0025 * fall; vy += p.vy * 0.0025 * fall; // wind from cursor motion
out[0] = vx; out[1] = vy;
},Packets
The cell index k is computed before the move, so a packet lights the cell that it leaves. The clamp of c and r keeps the index in the arrays when a position is on the edge of the viewport or outside it after a resize.
// packets ride the field
for (let i = 0; i < this.P; i++) {
let c = (this.px[i] / cw) | 0, r = (this.py[i] / ch) | 0;
c = Math.min(cols - 1, Math.max(0, c)); r = Math.min(rows - 1, Math.max(0, r));
const k = r * cols + c;
const spd = 160;
this.px[i] += this.fx[k] * spd * sdt; this.py[i] += this.fy[k] * spd * sdt;
if (this.px[i] < 0) this.px[i] += w; if (this.px[i] >= w) this.px[i] -= w;
if (this.py[i] < 0) this.py[i] += h; if (this.py[i] >= h) this.py[i] -= h;
this.heat[k] = 1; this.hchar[k] = this.pc[i];
if (Math.random() < 0.05) this.pc[i] = (Math.random() * 16) | 0;
}Glyph selection and the blit
The three branches are in order of priority: heat, then weak flow, then direction. Note the exchange of buckets 1 and 3 for the canvas y axis. The loop ends with two canvas calls only: globalAlpha and drawImage. glyph is the atlas column and tint is the atlas row.
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const i = r * cols + c;
const vx = this.fx[i], vy = this.fy[i], mag = Math.sqrt(vx * vx + vy * vy);
const heat = this.heat[i];
let glyph, tint, alpha;
if (heat > 0.05) { glyph = 8 + this.hchar[i]; tint = heat > 0.8 ? 4 : 3; alpha = 0.1 + heat * 0.6; }
else if (mag < 0.3) { glyph = 24; tint = 0; alpha = 0.35; }
else {
let a = Math.atan2(vy, vx); if (a < 0) a += Math.PI; // 0..π
const b = Math.round(a / (Math.PI / 4)) % 4; // 0:- 1:/ 2:| 3:\
// canvas y points down, so "/" and "\" swap
glyph = b === 1 ? 3 : (b === 3 ? 1 : b);
if (mag > 2.6) { glyph = 4 + (((c * 7 + r * 13 + (t * 8 | 0)) % 4)); tint = 2; alpha = Math.min(0.55, mag * 0.18); }
else { tint = mag > 1.6 ? 2 : 1; alpha = Math.min(0.8, 0.04 + mag * 0.28); }
}
ctx.globalAlpha = alpha;
ctx.drawImage(atlas, glyph * acw, tint * ach, acw, ach, c * cw, r * ch, cw, ch);
}The glyph atlas
ATLAS is a string of 25 characters: DIR (columns 0 to 3), HOT (4 to 7), HEX (8 to 23), and the dot (24). The atlas canvas has one row for each of the 5 TINTS, so it has 125 tiles. Each tile has the size of a cell in device pixels (20 × 34 at a pixel ratio of 2), so a blit does not scale the glyph. Rows 3 and 4 (amber and white) get a shadowBlur. This puts the glow of the packet glyphs into the atlas, and the frame loop does not pay for a shadow.
buildAtlas() {
const fs = this.fs, pr = this.pr;
this.cw = Math.round(fs * 0.7); this.ch = Math.round(fs * 1.2);
const cw = this.cw * pr, ch = this.ch * pr;
const a = document.createElement('canvas');
a.width = cw * ATLAS.length; a.height = ch * TINTS.length;
const c = a.getContext('2d');
c.font = `${fs * pr}px "JetBrains Mono", Menlo, Consolas, monospace`;
c.textAlign = 'center'; c.textBaseline = 'middle';
TINTS.forEach((tint, ti) => {
c.fillStyle = tint; c.shadowColor = tint; c.shadowBlur = ti >= 3 ? 5 * pr : 0;
for (let i = 0; i < ATLAS.length; i++) c.fillText(ATLAS[i], i * cw + cw / 2, ti * ch + ch / 2);
});
this.atlas = a; this.acw = cw; this.ach = ch;
},fillText is used in two places only: 125 times here, one time in init, and one time for each frame for the cursor readout.
# Tuned constants
this.cw = Math.round(fs * 0.7); this.ch = Math.round(fs * 1.2);- controls
- Cell width and height as a ratio of the font size. 10 × 17 px for the 14 px font.
- why
- A monospace glyph is approximately 0.6 of the font size wide. 0.7 and 1.2 give a small gap, so the glyphs read as a terminal grid. Smaller cells increase the cell count, and the full cost of the effect scales with that count.
const s = 0.0028;- controls
- Lattice cells for each px. One lattice cell of the first layer is 357 px, approximately 36 glyph columns.
- why
- With a larger value, adjacent glyphs have unrelated directions and the grid looks like static. With a smaller value, the full screen points in one direction. The second layer uses 2.1 times this scale and an offset of 40, so its lattice lines do not fall on those of the first layer.
(n1 * 0.7 + n2 * 0.3) * Math.PI * 4 + t * 0.1- controls
- Mix of the two layers, the angle range (two full turns), and the rotation of the field in rad/s.
- why
- Value noise is an average of four values, so most results are near 0.5. With a range of one turn, most vectors point in almost the same direction. Two turns spread the angles over the full circle. The rotation of 0.1 rad/s changes each glyph in approximately 8 s when the noise does not move it.
let mag = 0.35 + n2 * 0.5;- controls
- Magnitude of the noise term, 0.35 to 0.85 units.
- why
- The minimum is above the dot limit of 0.3, so the noise alone always gives a direction glyph. The maximum is one half of the vortex strength, so the vortex is always stronger than the noise near the cursor.
Math.exp(-r2 / (2 * 190 * 190))- controls
- σ of the Gaussian falloff of the three cursor terms, in px.
- why
- 190 px is 19 columns and 11 rows. A much smaller radius covers too few cells to show a ring with only four glyph directions. A much larger radius covers the noise field on the full screen.
const A = 1.7 * fall * (p.down ? 1.8 : 1);- controls
- Tangential speed in units, and the multiplier while the button is held (3.06).
- why
- 1.7 is two times the noise maximum, so the rings are clean near the cursor and mix with the noise where the falloff is approximately 0.5. The held value is above the HOT limit of 2.6, so the sink gets a core of + * # @ glyphs.
const rad = p.down ? -1.2 : 0.7;- controls
- Radial speed in units: positive is away from the cursor.
- why
- With a vortex only, packets near the cursor go around it in closed circles. The source of 0.7 turns the circles into an outward spiral of approximately 22°, so packets leave. The sink of −1.2 with the vortex of 3.06 gives an inward spiral of approximately 21°. A much stronger radial term shows straight spokes and hides the rotation.
p.vx * 0.0025 * fall- controls
- Field units for each px/s of pointer velocity.
- why
- A slow move of 200 px/s gives 0.5 units, less than the noise. A fast stroke of 1,000 px/s gives 2.5 units, more than the vortex. The pointer module limits the velocity to 5,000 px/s, so the wind is never more than 12.5 units.
if (mag > 2.6)- controls
- Magnitude above which a cell shows + * # @ and not a direction glyph.
- why
- With no button and a cursor that does not move, the cursor terms reach 1.76 and the noise 0.85, a sum of approximately 2.6 in the best case. The limit is thus at the top of the quiet range: HOT glyphs show almost only for a fast stroke or a held button.
const P = width < 800 ? 40 : 90;- controls
- Number of packets.
- why
- 90 packets with a trail of 2 to 5 cells each light a few percent of 9,405 cells. Many more packets hide the direction glyphs, which are the subject of the effect. A narrow viewport has fewer cells and gets 40.
const spd = 160;- controls
- px/s for each field unit.
- why
- In the noise flow the largest step is 136 px/s ÷ 30 = 4.5 px, less than the cell width of 10 px, so a packet does not jump over cells and the trail has no gaps. A much larger value makes gaps. A much smaller value makes packets that look static.
this.heat[i] - sdt * 2.6- controls
- Heat loss for each second. A cell is lit for (1 − 0.05) ÷ 2.6 = 0.37 s.
- why
- At 56 to 136 px/s a packet moves 20 to 50 px in that time, which gives a trail of 2 to 5 columns. A lower rate makes long trails that fill the grid with digits. A higher rate makes one blinking digit with no trail.
# Performance
All the work is on the CPU, and it scales with the cell count. It does not scale with the pixel count: the field is sampled one time for each cell, not for each pixel. For each cell and each frame, the code does this:
- Two
vnoisecalls, which are 8 integer hashes and 2 pairs of smoothstep weights. Eachvnoisecall also makes the arrow functionhagain. - One
cos, onesin, oneexp, and one square root infield(). - One more square root and, for most cells, one
atan2in the draw loop. - One
globalAlphachange and onedrawImagecall.
At 9,405 cells this is 18,810 noise calls and 9,405 canvas blits for each frame. The packet loop and the readout are small in comparison. No cell is skipped: a dim cell far from the cursor costs the same as a bright one.
Limits that the module applies:
- The device pixel ratio is limited to 2. This limits the size of the canvas and of the atlas tiles. It does not change the cell count.
- The canvas is opaque (
alpha: false), so the browser does not blend it with the page below it. - The grid loop has no
fillText, nofillStylechange, and noshadowBlur. AfillTextcall must shape and draw the text each time, and the glow would need a shadow for each glyph. The atlas moves this work toinit. Five tint rows and a variableglobalAlphagive the full brightness range with only 125 tiles. - The arrays
fx,fy,heat, andhcharare allocated inresize, not in the frame loop. - A viewport narrower than 800 px gets 40 packets. Its 12 px font makes smaller cells, so this branch does not decrease the cell count for a given area.
| viewport | build | fps | update ms | cells |
|---|---|---|---|---|
| 1024 × 768 | static page (before Next.js) | 55 to 60 | about 1 | 4,738 |
| 1710 × 930 | Next.js development | 60 | 4.6 | 9,405 |
| 1710 × 930 | Next.js production | 60 | 4.7 | 9,405 |
The static page at 1710 × 930 read 4.8 ms. The higher time in the last two rows thus comes from the larger viewport and not from the Next.js build. This is the most expensive of the eight effects on the CPU. The first row is an approximate value, and the time increases more than the cell count between the rows, although the code does the same work for each cell.
# References
- Wikipedia, Value noise. The noise type of
vnoise: random values on a lattice with smooth interpolation between them. - Wikipedia, Vector field. The object that the grid shows, and the terms source, sink, and rotation.
- Wikipedia, Vortex. The velocity profiles of physical vortices. Compare them with the Gaussian falloff that this module uses.
- MDN, Optimizing canvas. The reasons for an offscreen pre-render, an opaque canvas, and few state changes.
- Wikipedia, ASCII art. The tradition of pictures made from text glyphs.