MOUSEFX_

docs / tendrils

TENDRILS

canvas 2D verlet

Verlet rope chains anchored to your cursor. Whip them, and they spray sparks.

fps --ms --n --

# Overview

Sixteen glowing ropes hang from the cursor. Each rope is a chain of points that keep a fixed distance from their neighbours. Gravity pulls each rope in its own direction, so the bundle opens like a sea anemone. A wind that changes with time moves the rope ends. When you move the cursor fast, the ropes whip, and the fast tips throw sparks.

The effect is plain canvas 2D. There is no physics library and no WebGL. All state is in typed arrays: four Float32Array for each rope (position and previous position, x and y).

The element count in the statistics is the total number of rope points. It changes between page loads, because each rope gets a random length: 16 ropes of 29 to 38 points give 464 to 608 points on a desktop viewport. A viewport narrower than 800 px gets 9 ropes of 17 to 26 points.

# Interaction

  • Move. Point 0 of each rope is pinned to the cursor. The rope has no velocity variable, so a fast cursor move becomes a large displacement of point 0, and the constraint solver passes that displacement down the chain. This is the whip.
  • Move fast. When a rope tip moves faster than 900 px/s, it emits sparks that keep 35% of the tip velocity.
  • Hold the button. The ropes become electrified: gravity goes to zero, the drag becomes much stronger, a large random force shakes each point on each frame, and the ropes are drawn white.
  • Idle. After 2.5 s with no input, the shared pointer goes into autopilot and moves on a slow path. The ropes follow it, so the background does not freeze.

The module stores the reduced flag from init, but update does not read it. Reduced motion does not slow this effect.

# Model

Verlet integration

A point has a position x and a previous position px. The velocity is not stored. It is the difference between the two, and the step is:

v      = (x − px) · drag
px     = x
x      = x + v + a · dt²
Position Verlet with drag. a is the acceleration, dt the time step.

This form is the reason that the effect is stable with a simple constraint solver. When a constraint moves a point, the velocity of the next frame changes with it, and no separate velocity correction is necessary.

One property is important: v is a displacement for each frame, not for each second, and drag is applied one time for each frame. The motion thus depends a little on the frame rate. dt is limited to 1/30 s, and on a usual 60 Hz display the result is the tuned one.

Forces

Each point i of a rope with n points gets this acceleration:

a.x = wind(t) · (i / n) + G · cos(gdir)
a.y = wave(t, i) · (i / n) + G · sin(gdir)

wind(t)    = 160 · sin(t · freq + phase) + 80 · sin(0.37 · t + r)
wave(t, i) =  90 · sin(1.3 · t + phase + 0.2 · i)
The factor i / n makes the wind zero at the anchor and full at the tip.
  • G = 380 px/s² is gravity. It does not point straight down. Each rope r has its own direction gdir = π/2 + ((r + 0.5) / R − 0.5) · 2.4. For 16 ropes this is a fan of approximately ±64° around straight down (the comment in the source rounds this to ±70°).
  • wind is horizontal. It is the sum of a fast term with a random frequency and phase for each rope, and a slow term that all ropes share apart from an offset of r.
  • wave is vertical. The term 0.2 · i shifts the phase along the rope, so the wave travels from the anchor to the tip.

Distance constraints

After the integration, neighbour points are no longer at the rest distance L. The solver goes along the chain and moves each pair back:

diff = (d − L) / d
first segment :  x[1]   −= Δ · diff            (the anchor does not move)
other segments:  x[i]   += Δ · diff · 0.5
               x[i+1] −= Δ · diff · 0.5
d is the current distance between points i and i + 1.

One pass does not satisfy all constraints, because the correction of one segment disturbs the segment before it. The module runs 4 passes on each frame. This is Gauss-Seidel relaxation. With 4 passes the ropes are not fully rigid: they stretch a little when you whip them, and that elastic look is intended.

# Frame pipeline

  1. Fade the canvasreads previous framewrites canvas

    A rectangle in the background colour is drawn with alpha 1 − 0.78^(60 · dt). This keeps 78% of the old image for each 1/60 s at all frame rates, and it makes the light trails.

  2. Pin the anchorreads pointer.x, pointer.ywrites x[0], px[0]

    Point 0 goes to the cursor plus an offset of 6 px on a circle that turns at 0.8 rad/s. Each rope has its own angle on that circle, so the ropes do not start from one pixel.

  3. Integratereads x, pxwrites x, px

    Points 1 to n − 1 do the Verlet step with gravity, wind, and wave.

  4. Relax the constraintsreads xwrites x

    Four passes along the chain.

  5. Emit sparksreads tip x, pxwrites spark pool

    Tip speed is (x − px) / dt. Above 900 px/s, a spark is made with a probability of 0.6 on that frame.

  6. Draw the ropereads xwrites canvas

    Two strokes of one smooth path with additive blend (lighter): a wide glow and a thin bright core.

  7. Update and draw the sparksreads spark poolwrites spark pool, canvas

    All 900 slots are examined. Live sparks fall with 600 px/s², lose 2% of their velocity for each frame, and are drawn as 2 × 2 px squares.

  8. Draw the anchor glowreads pointer.x, pointer.ywrites canvas

    A radial gradient of 40 px radius at the cursor.

Steps 2 to 6 run one time for each rope.

# Code

Rope setup

Each rope gets its own length, segment length, colour, wind phase and frequency, line width, and gravity direction. All points start in a straight line below the cursor, with px = x, so the start velocity is zero.

src/lib/mousefx/effects/tendrils.jslines 20–28
for (let r = 0; r < this.R; r++) {
  const n = this.S - ((Math.random() * 10) | 0);
  const rope = { n, x: new Float32Array(n), y: new Float32Array(n), px: new Float32Array(n), py: new Float32Array(n),
    len: 5 + Math.random() * 6, col: COLORS[r % COLORS.length], phase: Math.random() * 6.28, freq: 0.6 + Math.random() * 1.2, width: 1.2 + Math.random() * 1.6,
    // each rope falls in its own direction (a fan of ±70° around straight down) so the bundle spreads like an anemone
    gdir: Math.PI / 2 + ((r + 0.5) / this.R - 0.5) * 2.4 };
  for (let i = 0; i < n; i++) { rope.x[i] = rope.px[i] = pointer.x; rope.y[i] = rope.py[i] = pointer.y + i * rope.len; }
  this.ropes.push(rope);
}

Integration

drag and G are set one time for each frame from the button state. Note that the force is multiplied by dt2 (dt²), and the velocity term is not multiplied by a time value. The last line pins point 0, and it also sets px[0], so the anchor has no velocity of its own.

src/lib/mousefx/effects/tendrils.jslines 66–79
// anchor: cursor with a small rotating offset so ropes fan out
const a = t * 0.8 + (r / this.ropes.length) * Math.PI * 2;
const ax = p.x + Math.cos(a) * 6, ay = p.y + Math.sin(a) * 6;
// integrate
const wind = Math.sin(t * rope.freq + rope.phase) * 160 + Math.sin(t * 0.37 + r) * 80;
const gx = Math.cos(rope.gdir) * G, gy = Math.sin(rope.gdir) * G;
for (let i = 1; i < n; i++) {
  const vx = (x[i] - px[i]) * drag, vy = (y[i] - py[i]) * drag;
  px[i] = x[i]; py[i] = y[i];
  let fx = wind * (i / n) + gx, fy = gy + Math.sin(t * 1.3 + rope.phase + i * 0.2) * 90 * (i / n);
  if (elec) { fx += (Math.random() - 0.5) * 9000; fy += (Math.random() - 0.5) * 9000; }
  x[i] += vx + fx * dt2; y[i] += vy + fy * dt2;
}
x[0] = ax; y[0] = ay; px[0] = ax; py[0] = ay;

Constraint relaxation

|| 1e-4 prevents a division by zero when two points are at the same position. The first segment gives the full correction to point 1. All other segments divide it between the two points.

src/lib/mousefx/effects/tendrils.jslines 82–91
// constraints
const L = rope.len;
for (let k = 0; k < 4; k++) {
  for (let i = 0; i < n - 1; i++) {
    let dx = x[i + 1] - x[i], dy = y[i + 1] - y[i];
    const d = Math.sqrt(dx * dx + dy * dy) || 1e-4, diff = (d - L) / d;
    if (i === 0) { x[1] -= dx * diff; y[1] -= dy * diff; }
    else { dx *= diff * 0.5; dy *= diff * 0.5; x[i] += dx; y[i] += dy; x[i + 1] -= dx; y[i + 1] -= dy; }
  }
}

Sparks from the tip

The tip velocity is converted to px/s first, so that the 900 px/s limit does not depend on the frame rate.

src/lib/mousefx/effects/tendrils.jslines 94–96
// tip speed → sparks
const tip = n - 1, tvx = (x[tip] - px[tip]) / sdt, tvy = (y[tip] - py[tip]) / sdt, tsp = Math.hypot(tvx, tvy);
if (tsp > 900 && Math.random() < 0.6) this.spark(x[tip], y[tip], tvx * 0.35 + (Math.random() - 0.5) * 200, tvy * 0.35 + (Math.random() - 0.5) * 200, r % COLORS.length);

The pool is a ring buffer of 900 slots. A new spark overwrites the oldest slot, so there is no allocation in the frame loop:

src/lib/mousefx/effects/tendrils.jslines 113–122
// sparks
const s = this.sp;
for (let i = 0; i < this.P; i++) {
  if (s.life[i] <= 0) continue;
  s.life[i] -= sdt; s.vy[i] += 600 * sdt; s.vx[i] *= 0.98; s.vy[i] *= 0.98;
  s.x[i] += s.vx[i] * sdt; s.y[i] += s.vy[i] * sdt;
  const c = COLORS[s.c[i]];
  ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${Math.min(1, s.life[i] * 2)})`;
  ctx.fillRect(s.x[i], s.y[i], 2, 2);
}

Drawing

The points are the control points of quadratic curves, and the curves end at the midpoints between the points. This gives a smooth line through a chain of straight segments with no spline library.

src/lib/mousefx/effects/tendrils.jslines 99–108
// draw: wide glow + bright core, smoothed through midpoints
const c = rope.col;
const path = () => {
  ctx.beginPath(); ctx.moveTo(x[0], y[0]);
  for (let i = 1; i < n - 1; i++) ctx.quadraticCurveTo(x[i], y[i], (x[i] + x[i + 1]) / 2, (y[i] + y[i + 1]) / 2);
  ctx.lineTo(x[n - 1], y[n - 1]);
};
path();
ctx.strokeStyle = elec ? 'rgba(200,230,255,0.18)' : `rgba(${c[0]},${c[1]},${c[2]},0.14)`; ctx.lineWidth = rope.width * 6; ctx.stroke();
ctx.strokeStyle = elec ? 'rgba(255,255,255,0.95)' : `rgba(${c[0]},${c[1]},${c[2]},0.85)`; ctx.lineWidth = rope.width; ctx.stroke();

# Tuned constants

ropes, pointsthis.R = mobile ? 9 : 16; this.S = mobile ? 26 : 38;
controls
Number of ropes and the maximum points for each rope.
why
16 ropes fill the fan with no gaps. 38 points of 5 to 11 px give ropes of 190 to 420 px, which is long on a desktop screen and too long on a phone.
segment lengthlen: 5 + Math.random() * 6
controls
Rest distance L between neighbour points, in px.
why
Short segments make smooth curves. The random part makes ropes of different total length, so the tips do not move together.
gravity, dragG = elec ? 0 : 380, drag = elec ? 0.9 : 0.985
controls
Gravity in px/s², and the part of the velocity that stays after each frame.
why
0.985 lets a whip travel to the tip before it dies. 0.9 in the electrified mode removes energy quickly, which is necessary because the random force adds a lot of it.
gravity fan- 0.5) * 2.4
controls
Spread of the gravity directions, in radians, from the first rope to the last.
why
With one direction, all ropes hang in one line and hide each other.
constraint passesfor (let k = 0; k < 4; k++)
controls
Relaxation passes for each frame.
why
More passes make stiffer ropes and cost more. Four is the minimum at which the ropes do not look like rubber.
electric force(Math.random() - 0.5) * 9000
controls
Random acceleration for each axis in the electrified mode, ±4500 px/s².
why
It must be much larger than gravity (380) to show as a crackle against the strong drag.
spark limittsp > 900 && Math.random() < 0.6
controls
Tip speed in px/s above which sparks occur, and the probability for each frame.
why
Normal cursor movement stays below 900 px/s at the tip. Only a real whip goes above it. The probability prevents a solid line of sparks.
spark poolconst P = 900
controls
Maximum number of live sparks.
why
16 tips × 0.6 × 60 frames × about 0.8 s life is approximately 460 sparks in the worst case, so 900 is never full in normal use.
spark gravitys.vy[i] += 600 * sdt
controls
Downward acceleration of sparks in px/s².
why
Stronger than the rope gravity, so sparks fall away from the ropes and do not float with them.
trail decayMath.pow(0.78, sdt * 60)
controls
Part of the old image that stays after 1/60 s.
why
Lower values remove the trails. Higher values fill the screen with light, because the blend is additive.
step limitMath.min(dt, 1 / 30)
controls
Largest time step that the simulation accepts.
why
A larger dt² makes the force term so large that the constraint solver cannot hold the rope together.

# Performance

The cost is on the CPU, and it has two parts. The physics is small: about 540 points × 4 constraint passes is approximately 2,200 square roots for each frame. The larger part is canvas drawing: 32 stroked paths with round caps and additive blend, and one full-screen fill for the fade.

Limits that the module applies:

  • The device pixel ratio is limited to 2.
  • The canvas is opaque (alpha: false), so the browser does not blend it with the page below it.
  • The spark loop has no allocation. It skips dead slots with one comparison.
  • The rope count and length go down on viewports narrower than 800 px.
viewportbuildfpsupdate mspoints
1024 × 768static page (before Next.js)600.7539
1710 × 930Next.js development600.2505
1710 × 930Next.js production600.3530
Conditions: M-series Mac, Chromium, device pixel ratio 2. ms is the CPU time of update() only.

The point count is different in each row because the rope lengths are random.

# References