MOUSEFX_

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.

fps --ms --n --

# 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 VORTEX to SINK. The module reads only the level of pointer.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
xf and yf are the fractional parts of x and y. u and v are smoothstep weights. The last line is bilinear interpolation, multiplied out.

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)
s = 0.0028 lattice cells for each px. One lattice cell of n1 is 357 px. One lattice cell of n2 is 170 px.

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)
A = 1.7 and rad = +0.7 with no button. A = 3.06 and rad = −1.2 while the button is held. The 1 in r prevents a division by zero at the centre.
  • (−dy, dx) / r is the tangent direction. On a canvas, y points down, so this rotation is clockwise on the screen.
  • fall is 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 r is 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 → /
The buckets are centred on 0°, 45°, 90° and 135°. An angle near π rounds to 4, and mod 4 makes it 0.

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 HOT glyphs + * # @ 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 is 0.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
k is the index of the cell that contains the packet. There is no interpolation between cells.

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

  1. Limit the time stepreads dt, reducedwrites sdt

    sdt is dt, limited to 1/30 s, and multiplied by 0.5 when reduced motion is set.

  2. 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 by 2.6 · sdt, not below 0.

  3. 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.

  4. Clear the canvasreads nonewrites canvas

    A full fill with #050508 and the blend mode source-over. No part of the old frame stays. The trails come from the heat array, not from the canvas.

  5. 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.

  6. Draw the readoutreads pointer.x, pointer.y, pointer.downwrites canvas

    One fillText call 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.

src/lib/mousefx/effects/asciiField.jslines 13–20
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.

src/lib/mousefx/effects/asciiField.jslines 68–84
// 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.

src/lib/mousefx/effects/asciiField.jslines 101–112
// 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.

src/lib/mousefx/effects/asciiField.jslines 121–138
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.

src/lib/mousefx/effects/asciiField.jslines 40–54
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

cell sizethis.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.
noise scaleconst 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.
noise to angle(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.
base magnitudelet 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.
cursor radiusMath.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.
vortex strengthconst 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.
source and sinkconst 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.
windp.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.
HOT limitif (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.
packet countconst 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.
packet speedconst 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.
heat decaythis.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 vnoise calls, which are 8 integer hashes and 2 pairs of smoothstep weights. Each vnoise call also makes the arrow function h again.
  • One cos, one sin, one exp, and one square root in field().
  • One more square root and, for most cells, one atan2 in the draw loop.
  • One globalAlpha change and one drawImage call.

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, no fillStyle change, and no shadowBlur. A fillText call must shape and draw the text each time, and the glow would need a shadow for each glyph. The atlas moves this work to init. Five tint rows and a variable globalAlpha give the full brightness range with only 125 tiles.
  • The arrays fx, fy, heat, and hchar are allocated in resize, 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.
viewportbuildfpsupdate mscells
1024 × 768static page (before Next.js)55 to 60about 14,738
1710 × 930Next.js development604.69,405
1710 × 930Next.js production604.79,405
Conditions: M-series Mac, Chromium, device pixel ratio 2. ms is the CPU time of update() only.

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.