Aduok Code

A WebGL Ghost That Follows Your Cursor, Built with a Fragment Shader and Spring Physics

Introduction

This effect renders a soft, translucent blob that trails the mouse around the screen, with two eyes and a mouth that emerge out of a noisy, ever-shifting surface. It is not a sprite or a pre-rendered animation — every frame, a WebGL fragment shader samples a canvas texture for the blob's silhouette, subtracts a face shape from it, and distorts the whole thing with simplex noise, while a completely separate spring-physics system decides where the trail of dots that make up that silhouette actually sits on screen.

The technique rests on four ideas: a chain of dots that each spring toward the dot in front of them, drawn with radial gradients onto a 2D canvas to form the trail's silhouette — that silhouette handed to WebGL as a texture, so the GPU only ever has to sample a mask instead of tracking twenty-five individual shapes — a face carved out of that mask using pure signed-distance-style math inside the fragment shader, rotated toward the direction of movement — and a small state machine that swaps between "moving" and "idle" behavior to drive a smile and a bit of gravity sag on the tail.

What you will learn

How to rasterize a chain of physics-driven dots into a canvas texture and feed it to WebGL as a mask — how to carve eyes and a mouth out of that mask directly inside a GLSL fragment shader — how simplex noise turns a flat blob into something that looks alive — how to tie pointer motion to a smile/gravity animation without a tweening library — and how to build a small, dependency-free control panel to tune it all live.

01
Part One
A Blob Carved from Noise

Step 1 — Sampling a Mask, Then Distorting It

The fragment shader never draws the trail itself — it samples a single-channel mask from a sampler2D uniform (u_texture) that a 2D canvas has already rasterized on the CPU side. That mask value, trailMask, is the raw silhouette. Simplex noise is layered on top of it as a multiplier rather than an addition, scaled by the current pointer size and offset over time by u_time, so the edges of the blob constantly ripple instead of sitting still. A smoothstep gradient is folded into the noise along the vertical axis, which is what gives the tail its slightly "melting" look toward the bottom rather than a uniform fuzziness everywhere.

GLSL
float trailMask = texture2D(u_texture, vec2(v_uv.x, 1.0 - v_uv.y)).r;

float grain = snoise(uv * vec2(0.7, 0.6) / u_size + vec2(0.0, 0.0015 * u_time));
grain += 1.2;
grain *= 2.1;
grain += smoothstep(-0.8, -0.2, uv.y / u_size);

float shape = trailMask - faceMask(uv, faceRotation);
shape *= grain;
UniformTypeRole
u_texturesampler2DThe rasterized trail mask, redrawn on the CPU every frame
u_timefloatDrives the scrolling simplex noise so the surface never sits still
u_sizefloatScales both the noise frequency and the face geometry together
u_pointer / u_target_pointervec2Eased vs. raw pointer position, in 0–1 UV space
02
Part Two
A Physics-Driven Pointer Trail

Step 2 — Dots That Spring Toward the Dot Ahead of Them

The trail is a small chain of TrailDot instances. The lead dot snaps straight to the pointer's eased position every frame; every dot after it doesn't follow the pointer at all — it only ever chases the dot immediately in front of it, accumulating velocity toward that leader's position, damping that velocity by a friction factor, and optionally getting nudged downward by a shared gravity value. Because each dot only looks one step ahead, the whole chain reads as a single elastic tail without anything needing to know the pointer`s position except the very first link.

JS
followLeader(leader, spring, friction, gravity) {
  this.vx += (leader.x - this.x) * spring
  this.vx *= friction

  this.vy += (leader.y - this.y) * spring
  this.vy *= friction
  this.vy += gravity

  this.x += this.vx
  this.y += this.vy
}

Every dot in the trail solves the exact same tiny spring equation. The illusion of a long, elastic tail comes entirely from chaining twenty-five identical one-step followers, not from any single piece of code that understands the whole shape.

Each dot also carries a decaying radius and opacity, computed once from its index in the chain, so dots further from the head are smaller and fainter. Every frame, PointerTrail.draw() clears its offscreen canvas to black and stamps a radial gradient for each dot on top — that canvas is the exact texture the shader samples as u_texture.

03
Part Three
A Procedural Face That Reacts to Motion

Step 3 — Eyes and a Mouth, Built from Distance Fields

eyeShape() and mouthShape() both work the same way: they take a UV coordinate already centered on the blob, fold it (abs(uv.x) for the eyes, so both sides are computed from one formula), squash and shift it, then measure length(uv) as a rough distance field and raise it to a small exponent so the falloff reads as a hard-edged shape rather than a smooth gradient. faceMask() rotates the incoming coordinate by the difference between the pointer's eased and target positions first — that rotation is what makes the whole face tilt toward the direction the cursor is moving, entirely without any per-frame "look at" logic beyond that one angle.

GLSL
float faceMask(vec2 uv, float rotation) {
  uv = rotate(uv, rotation);
  uv /= (0.27 * u_size);
  float eyes = 10.0 * eyeShape(uv);
  float mouth = 20.0 * mouthShape(uv);
  return mix(mix(0.0, 1.0, eyes), 1.0, mouth);
}

The u_smile uniform bends both shapes at once — added into the eyes' vertical offset and multiplied into the mouth's curvature — so a single float sliding from -0.1 to 1 is enough to swing the face between a startled, wide-eyed frown and a relaxed smile. That float isn't animated by a tween; it's nudged a fixed amount every frame in _updateFaceAnimation(), decreasing while the pointer is moving and creeping back up once it stops.

04
Part Four
Wiring It Together in One Controller

Step 4 — One Class Owns the GL Context, the Trail, and the Loop

GhostCursor is the only class that touches requestAnimationFrame. Each frame it updates the smile/gravity state machine, eases the pointer toward its target, pushes fresh uniforms to the shader, asks PointerTrail to step its physics and redraw its canvas, uploads that canvas as a texture, and finally draws a single full-screen triangle strip. Everything else in the file — PointerState for the moving/idle timer, PointerTrail for the dots, the shader-compilation helpers — is a plain, independently testable unit that GhostCursor composes rather than inherits from.

JS
_renderFrame() {
  this._updateFaceAnimation()
  this.pointer.ease()

  this.trail.update(this.pointer.x, this.pointer.y,
    this.params.spring, this.params.friction, this.params.gravity)
  this.trail.draw()

  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.trail.canvas)
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)

  this._rafId = requestAnimationFrame(() => this._renderFrame())
}
A resize edge case worth double-checking

Dot radii are computed from window.innerHeight, so a resize event has to call refreshRadii() on the whole trail as well as resize the canvas — otherwise the mask keeps rasterizing at the old window's scale while the GL viewport has already moved to the new one, and the blob visibly shrinks or grows out of proportion to the page.

05
Part Five
A Dependency-Free Control Panel

Step 5 — Sliders and Color Pickers Without a UI Library

Rather than pulling in a third-party controls library, a small ControlPanel class builds its own collapsible DOM: a header that toggles an is-collapsed class, and rows built from three tiny factory methods — _sliderRow(), _colorRow(), and _checkboxRow() — each wiring a native <input> straight to a setter on GhostCursor (setSize, setMainColor, setFlatColor). Native <input type="color"> elements hand back hex strings, so a pair of small converters translate between that and the [r, g, b] float triples the shader uniforms expect.

JS
function hexToRgbFloat(hex) {
  const int = parseInt(hex.slice(1), 16)
  return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255]
}

The panel also hooks its own mouseenter/mouseleave events to a setControlsPadding() call on the ghost, which keeps the pointer state from getting pulled toward the pointer while the user is dragging a slider inside the panel itself — the same trick the original pointer-tracking logic used to avoid the trail fighting with the UI.

Nothing in the control panel knows what a shader or a spring is. It only calls public setter methods on GhostCursor, so the same panel could drive a completely different visual effect with almost no changes.

Tuning Reference

ConstantDefaultEffect
size0.1Base scale for the blob, the face, and the noise frequency
dotsNumber25Length of the physics chain making up the trail
spring1.4How hard each dot pulls toward the one ahead of it
friction0.3Velocity damping applied to every dot each frame
gravity0 → ~2.5×sizeDownward sag added to the tail, ramping up while idle
smile-0.1 to 1Drives both eye and mouth curvature from one float
isFlatColorfalseSwaps the soft gradient border for a hard-edged silhouette

Full Source Code

The complete effect is a single self-contained HTML document — a vertex/fragment shader pair, a handful of small ES classes (TrailDot, PointerTrail, PointerState, GhostCursor, ControlPanel), and no external runtime dependencies beyond the browser's own WebGL and Canvas 2D APIs.

HTML — ghost-cursor.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/ghost-cursor.html

One HTML file, one shader, and a chain of dots that only ever look one step ahead of themselves. The whole effect — trail physics, noise-distorted silhouette, procedural face, resize handling, and a from-scratch control panel — is well under 500 lines of code.

Want to see it in action?Watch the full build on YouTube