The Dissolution

Artificial Noodles ·

Inspired by Viscimation on Wikipedia

Built with Pure WebGL2 · FBO Ping-Pong Particle Simulation · Canvas Text Rasterization

Techniques FBO Particle Simulation · Canvas Text Rasterization · Curl Noise Turbulence · Thermodynamic Buoyancy Physics · Additive Blending

Direction Reading is a kind of dissolution. We consume words and they disappear — text enters you and leaves the page.

Result Text rendered as thousands of particles. Move your cursor near the words and they sublimate upward in serpentine convection plumes. Dissolving each word reveals the next.

The Story

Add a single drop of water to a glass of whisky and watch. For a moment, nothing happens. The surface is still. Then, from the point of contact, threads begin to emerge — oily, coiling filaments that twist and curl through the liquid like living things. Whisky enthusiasts have a name for this: awakening the serpent.

The phenomenon is called viscimation, and its engine is the Marangoni effect. Whisky is a mixture of ethanol and water, and these two liquids have different surface tensions. Water pulls harder — roughly 72 millinewtons per meter compared to ethanol’s 22. When a drop of water enters the whisky, it creates a local gradient: a region of high surface tension surrounded by lower. The liquid surface contracts toward the water, dragging fluid along with it. But alcohol evaporates faster than water, which means the gradient perpetuates itself — the surface tension imbalance keeps regenerating, driving continuous flow.

The result is the viscimetric whorl. Serpentine threads of fluid, neither turbulent nor laminar, coiling in patterns that look more biological than physical. The threads are visible because ethanol and water have different refractive indices — the mixing zones bend light differently, making the convection currents visible as sinuous, rope-like structures. They twist because the Marangoni flow interacts with itself: one current meets another, and the collision sends both curving away in opposite directions, spiraling.

What makes viscimation remarkable is the disproportion. A single drop — the gentlest possible perturbation — triggers minutes of elaborate, self-sustaining motion. The whisky was not still because it was simple. It was still because its complexity was in equilibrium. The drop did not add chaos. It revealed the chaos that was already there, concealed by surface tension’s fragile truce.

Stillness conceals complexity. A single drop awakens the serpent.


The Take

The experience opens to a dark teal field. Thousands of particles are scattered randomly across the viewport — a dispersed cloud, formless. Over 2.5 seconds, they condense. Each particle is spring-pulled toward a computed home position, and the cloud tightens into a word: STILLNESS. The particles settle into place, breathing subtly, each one a point of silver-teal light.

A hint fades in: “move your cursor to dissolve.” On mobile: “drag to dissolve.”

The cursor is a heat source. Move it near the word and particles within a Gaussian radius begin to warm. Temperature drives buoyancy — heated particles rise. Curl noise turbulence adds lateral perturbation proportional to temperature, so the rising particles don’t go straight up. They twist. They coil in serpentine columns that echo the Marangoni whorls of water meeting whisky. The color shifts with temperature: silver-teal at rest, ember-orange as they heat, gold at peak temperature. Additive blending means the rising plumes accumulate brightness — coiling threads of warm light ascending from the dissolving text.

Cold particles snap back. The homing spring reasserts itself as temperature drops, pulling cooled particles toward their letter positions. The interaction is a tug-of-war between dissolution and formation — the cursor destroys, the spring restores.

Every half second, the simulation counts how many particles are displaced from their home positions. When the ratio exceeds 60%, the word is considered dissolved. The home positions update in-place: each particle keeps its current position and velocity but receives a new destination. The cloud flows from the wreckage of one word into the shape of the next.

STILLNESS gives way to CONCEALS. Then COMPLEXITY. Then A SINGLE DROP. AWAKENS. THE SERPENT. Six words, dissolved one by one.

After the final word dissolves, the particles reform into the complete phrase: STILLNESS CONCEALS COMPLEXITY, rendered across two lines. The source attribution fades in. The simulation continues running — the final state is a permanent sandbox. The text is always there, always dissolvable, always reforming. The serpent never sleeps.


The Tech

Canvas Text Rasterization: Extracting Particle Positions from Glyphs

Each word is rendered to an offscreen HTML5 canvas using ctx.fillText() at 700 weight in the Recursive typeface. The font size is computed to fill roughly 70% of the viewport width, with a floor of 28px for mobile readability. After rendering, getImageData() reads back the pixel buffer. Every pixel with a red channel above 128 is considered “on” — part of a letterform.

The raw glyph contains far more on-pixels than the particle budget allows. A sampling step controls density: the algorithm counts total on-pixels, computes step = floor(sqrt(totalOn / maxParticles)), and then iterates through the pixel grid at that stride. On desktop, the target is 25,000 particles. On mobile, 12,000. Each sampled on-pixel becomes a particle home coordinate, normalized to the 0-1 range for GL: x / viewportWidth and 1.0 - y / viewportHeight (Y-flipped for OpenGL’s bottom-left origin). The text is centered in the viewport by offsetting the canvas-local coordinates by (viewport - canvas) / 2 before normalization.

FBO Ping-Pong Simulation: Persistent State in RGBA32F Textures

The particle system stores all state in two pairs of floating-point textures, arranged in a ping-pong configuration. The position FBO stores (xy = current position, zw = home position) in RGBA32F. The velocity FBO stores (xy = velocity, z = temperature, w = age). Both are square textures sized to ceil(sqrt(maxParticles)) — typically 158x158 for 25,000 particles.

Each frame, the update shader reads from source pair [pingPong] and writes to destination pair [1 - pingPong] using Multiple Render Targets (MRT). A persistent framebuffer for each destination has both position and velocity textures attached as COLOR_ATTACHMENT0 and COLOR_ATTACHMENT1, with drawBuffers set once at creation. The fullscreen quad vertex shader maps clip-space corners to UV coordinates. The fragment shader reads the source textures via texture(), computes physics, and writes to both outputs simultaneously. After the draw call, pingPong flips. No CPU readback occurs during normal rendering — the GPU handles everything internally.

Unused particle slots are marked by home = (0, 0) and short-circuit the shader immediately — no physics computed, no output changed. This means the texture can be larger than the active particle count without performance cost.

Thermodynamic Physics: Cursor Heat, Buoyancy, and Cooling

The cursor is mapped to normalized viewport coordinates and acts as a Gaussian heat source with radius 0.18. The heat function is exp(-dist^2 / (radius^2 * 0.5)), where distance accounts for aspect ratio to produce circular influence on non-square viewports. Heat accumulates at 5.0 units per second at the center, clamped to 1.5.

Temperature drives two forces. Buoyancy is a direct upward acceleration proportional to temperature: velocity.y += temp * 1.2 * dt. This is the sublimation — heated particles rise. The homing spring works inversely: its strength is (1.0 - smoothstep(0, 0.2, temp)) * 6.0, meaning hot particles are effectively free while cold particles are strongly pulled home. The crossover at temperature 0.2 creates a sharp behavioral boundary — particles are either anchored or liberated, with a narrow transition zone.

Temperature cools at 0.35 per second, floored at zero. Damping varies with temperature: cold particles have 0.97 damping (settling quickly) while hot particles get 0.985 (retaining momentum longer, enabling the long coiling plumes). This asymmetric damping is critical — it means hot particles travel farther before decelerating, producing the extended serpentine trails rather than short puffs.

Curl Noise for Serpentine Motion

The twisting, coiling character of the plumes comes from 2D curl noise applied proportional to temperature. The noise field uses a GLSL simplex noise implementation. Curl is computed via finite differences: sample the noise at (x, y+eps), (x, y-eps), (x+eps, y), (x-eps, y) with epsilon 0.001, then compute curl = (dN/dy, -dN/dx). This produces a divergence-free vector field — particles follow the curl lines rather than converging or diverging, creating smooth rotational motion.

The noise is sampled at position * 3.5 + (time * 0.4, time * 0.25) — a spatial frequency of 3.5 means roughly 3-4 vortex cells across the viewport, and the temporal offset scrolls the field slowly to prevent static curl patterns. The curl force is scaled by temp * 0.25 * dt. At peak temperature (1.5), this contributes roughly 0.375 units of lateral force per second — enough to visibly deflect the rising particles into curved paths, but not enough to overpower the buoyancy. The ratio of buoyancy to curl determines the plume character: approximately 3:1, producing predominantly vertical motion with lateral wobble — the serpentine signature.

Word Transitions: In-Place Home Position Updates

Every 0.5 seconds during the interactive phase, the simulation reads back the position FBO via gl.readPixels() and counts displaced particles. A particle is “displaced” if distance(position, home) > 0.015 — about 1.5% of viewport width, enough to exclude the subtle ambient breathing but catch any meaningful departure.

When displacement exceeds 60%, the transition triggers. The next word is rasterized to extract new home positions. The CPU reads the current position FBO, overwrites the zw (home) components with the new positions while preserving the xy (current position) and uploads the modified data to both ping-pong buffers via texSubImage2D. This is the key design: particles inherit their current scattered positions but receive new destinations. The spring physics then pulls them to the new word naturally — the transition is not an animation, it is the physics responding to changed boundary conditions.

If the new word has fewer particles than the old one, excess slots are zeroed out (home = 0,0), and the shader’s early-exit check makes them invisible. If it has more, previously unused slots activate. The particle budget is the texture size, allocated once at startup.

Rendering: Points, Gaussians, and Temperature-Mapped Color

Particles are rendered as gl.POINTS with no geometry buffer — gl_VertexID indexes directly into the position FBO via texelFetch. The vertex shader reads both position and velocity textures, converts normalized 0-1 coordinates to clip space (pos * 2.0 - 1.0), and passes temperature and alpha as varyings. Point size is 3px base on desktop (3.5px on mobile), plus up to 2px additional at peak temperature — hot particles are slightly larger.

The fragment shader computes a soft Gaussian disc: alpha = exp(-d^2 * 3.0) where d is the distance from point center in gl_PointCoord space. Pixels outside the unit circle are discarded. The color palette is a three-stop temperature ramp: silver-teal (0.78, 0.85, 0.85) at cold, ember-orange (1.0, 0.42, 0.21) at mid-temperature, and gold (1.0, 0.67, 0.0) at peak. Blending uses SRC_ALPHA, ONE — additive — so overlapping particles accumulate brightness. Dense regions of the text glow brighter than edges, and the rising plumes produce bright coiling threads against the dark #0a1a1a background.

Alpha itself varies with displacement and temperature: settled particles are nearly opaque (0.9), displaced particles fade to 0.45, and very hot particles fade further to 30% of their base alpha. This creates a visual hierarchy — the formed text is solid, the dissolving margins are translucent, and the highest-rising smoke is ghostly.


Experience: The Dissolution


This blog post was AI generated with Claude Code. Authored by Artificial Noodles.