Add a glass of beer, rebuilt on shallow water - #24
Merged
Merged
Conversation
An eighth effect. A glass poured to `fill`, with bubbles rising through it and a head of foam on top: air above, foam, the liquid line, beer below, and the order they stack in is the whole picture. The bubbles are metaballs in the strict sense. `falloff` is imported from `metaballs.ts` rather than copied, because the claim is only true if it is the same kernel - each bubble adds Wyvill's cubic to a shared field, the field is thresholded, and a pair that pass close bulge towards each other and fuse. The physics fuses them only once they are within `merge` of the sum of their radii, which is far closer than the field needs to have joined them, so the merge is on screen before it happens and the swap from two bubbles to one is invisible. The head is not drawn anywhere. A bubble bursts when its top edge breaks the surface and hands its own area to the foam above it; the foam drains exponentially and levels sideways; what you see is where those two rates balance. Turn `rate` down and the head thins on its own, turn `drain` down and it climbs until `headMax` stops it. Nothing sets a thickness, which is why the dials behave the way a glass does rather than the way a slider does. A pop deposits an area rather than a thickness, and that took two attempts. Dropping the foam into the one column under the bubble's centre gives a thickness of that area over the column's width, so on a fine field it is enormous, `headMax` clips almost all of it away, and the head comes out thin - at 384 columns it settled at less than half what the same parameters gave at 96. Spread over the columns the bubble covers, and divided by the width of those columns, it is the same foam at any resolution, exactly. The surface is a wave equation rather than an animation: one height and one velocity per column, stepped every frame with reflecting walls. The first version had a procedural sine ripple and a tilted-cosine rock oscillator bolted together, and the wave field replaced both with less state and better behaviour, because both were hand-drawn imitations of things a wave field does on its own. The slosh is its fundamental mode, with a period of `2 * aspect / waveSpeed`, so a wider glass sloshes slower - true of real glasses, and not true of the oscillator. Bursts splash the same field, which is the whole of the idle shimmer: turn the fizz off and the glass goes glassy still, which is what a flat pint does. Three numerical points there, each with a test. It substeps to a CFL limit of half a cell of travel, since at the full-cell stability bound the scheme is maximally dispersive and a sharp splash rings, with damping applied per substep so the physics is a function of elapsed time rather than of how the frames landed. The velocity updates first and the position uses the new velocity, or the explicit form feeds energy in and the surface works itself rough instead of settling. And the mean height is subtracted every frame, which is an exact volume guarantee rather than a fudge - stirs and splashes promise nothing about summing to zero, but every travelling wave and the slosh itself are zero-mean shapes, so levelling the mean removes only conjured beer and never motion. Drag to stir it, and three things come out of the one gesture. Bubbles near the pointer are eased towards its speed rather than shoved by it, so the fizz is carried at the speed of the drag and never faster however long it is held there. The drag scrapes fresh bubbles into being, which is how a bubble starts in the first place. And near the surface it ploughs a bow wave - risen ahead of the motion, dipped behind it - whose wake sloshes between the walls. The bow wave is antisymmetric, so a stir moves beer about without adding any, and it attenuates with depth, so a drag along the bottom stirs the fizz there rather than the line half a screen above it. Only the newest sample ploughs: every live stir is a sample of the same pointer, and letting each of them push would have a fast drag plough twice over, once through its speed and once through the extra samples that speed produced. Bubbles rise with the square of their radius, which is Stokes drag. A linear law was the obvious first guess and the square is visibly better for one reason - an area-conserving merge grows the radius by root two, so the merged pair pulls away from the crowd at twice the speed rather than 1.4 times, and the finest fizz hangs almost still, which is what gives the glass depth. Capped at four times the mean speed, because merges compound and an uncapped square lets a lucky chain teleport. The renderer only pays full price where the picture is, which was worth profiling for: 98% of the frame was the render loop and nearly all of that was cells that were plain liquid or plain air. Three lanes now. Rows above the surface band are one `fill(0)`; rows below it are wet in every column, so each is its depth shade written with a fill, plus the bubbles applied over their own bounding boxes; only the band itself walks its cells. The accumulation never gets a full-field clear either, because the box pass zeroes each cell as it consumes it. The depth shading is measured from the pour line rather than the wavy surface so that a row's shade is one number, an error of the wave height times `depthFade` and a fraction of one palette level, and a test forces the same bubble through both routes and requires identical cells. Merges are found along a sorted sweep rather than by checking every pair, which was the one cost that grew as the square of the count. 1.05ms a frame at 1080p before, 0.40ms after, with the physics about 40us of that. `fieldScale` is 1 like the rain and the ridges, because the bubbles are two or three cells across and the foam's mottling is one cell, and bilinear interpolation would blur exactly the two things worth seeing. The guards get tests of their own - single-column glasses, steps with no time in them, `rise` and `radius` and `shoulder` at zero - because `test:coverage` holds the repository to 99% of statements and a guard nothing exercises is a guess. Two depth clamps went instead of getting tests, being unreachable by construction: `y` cannot pass the bottom of the glass, and below the surface band it cannot be above the pour line either, because the band reaches at least to the deepest column's line and the waves are zero-mean. Bundle size goes from 17.9 kB to 21.0 kB, measured the usual way: npx esbuild src/index.ts --bundle --minify --format=esm | gzip | wc -c The mirrored figure in the hebberd.com index post needs the same change.
TokyoDanInJapan
force-pushed
the
glass-of-beer
branch
from
August 17, 2026 22:45
814d4b6 to
5880df0
Compare
The surface was a plucked string: one wave speed everywhere, a restoring force pulling every column to the same line, and no notion of how much beer any of it stood for. It is shallow water now - a height per column and a depth-averaged flow on the staggered faces between them - and three behaviours fall out free. Waves cross a full glass faster than a half-poured one, as sqrt(g * depth) says they must. The flow carries itself, read upwind of itself, so a hard-driven front steepens. And volume is conserved by construction, because every drop that leaves a column through a face arrives in its neighbour. `waveSpeed` keeps its meaning - gravity is derived from it - so a full glass still sloshes at `2 * aspect / waveSpeed` and the old emergence test survives. A crest driven too steep breaks: the face is let down to a slope beer can stand in, and what comes over the top is thrown as foam and the odd droplet, which is why stirring hard now visibly thickens the head. A stir drives the body of the beer rather than raking a bow-wave shape into the surface - the shape was the answer, not the cause - so the bow wave emerges from the flux and the beer piles up the leading wall and sloshes back. Pops press the flow rather than the heights, so two dozen arrivals a second read as a live surface instead of a tremor. A press is a jab: a splash, a burst of spray, and fresh fizz. The amplitude has no ceiling any more. `waveMax` is gone, and the only bounds are the glass's own - a crest may climb to the very top of the frame. That surfaced the one real bug of the branch: with the substep count capped, easing gravity alone let a savage swirl hand the solver more flow than the substeps could represent. The advection shredded the surface into a grid-scale sawtooth whose own slopes pumped the flow back up whenever gravity returned - a boil that never settled, bounded by the clamps and the breaker but never released by them. Each frame now has the speed budget its substeps can carry, split in a fixed order: the flow is clamped first, because it is the one input a pointer can make arbitrarily large, and gravity is eased to what the flow left, never to nothing. A regression test swirls at the worst dial setting on a 1080p-sized field and requires the ring-down. The rest of the pour follows the same discipline. The fizz streams up fixed nucleation sites, stratified across the width so no stretch of glass goes bare. The head rides a raft - the surface smoothed sideways, followed at a pace set by how far behind it is - so pops no longer judder it and sloshes still carry it. Spray flies under the waves' own gravity and splashes the surface it lands on. `level` is live state, so `pour: true` opens on an empty glass that fills itself in two seconds, fizzing at two and a half times the rate on the way; reduced motion gets the settled pint, because its one still frame must not be a picture of nothing. Defaults moved with the physics: bubbles at a hundredth of the height, three CSS pixels a cell, 64 palette levels so the strengthened depth fade reads as a gradient rather than bands, and `headGain` and `spread` retuned so the finer fizz builds the same head - measured, not reasoned, like everything above. Also in this change, smaller and separable in spirit if not in files: `polar` gains `reverse`, one subtraction in `polarSample` that reads the radius inside-out, so the rain falls in and the beer pools round the centre with its head ringing it. The demo gains nine palettes - five single-hue families and four travelling-hue skies built on the sunset's recipe, hue moving while lightness climbs - and rolls a random one on load. And how-it-works.md is rewritten in plain British English: a fifth shorter, every measured number and recorded wrong turn kept. Tests go from 854 to 903, still at 100% of functions and 99% of statements. Physics at a 1080p field measures 0.7ms a frame, the renderer 0.6ms, and the doc now says so instead of a figure from the coarser field it used to describe. Bundle size goes from 21.0 kB to 23.0 kB, measured the usual way: npx esbuild src/index.ts --bundle --minify --format=esm | gzip | wc -c The mirrored figure in the hebberd.com index post needs the same change.
CI failed at the format check. The changes are whitespace and quote style only.
Short sentences, active voice, and no dashes, semicolons or cleft sentences. The README now lists Beer in the per-effect defaults and parameter groups, and names the package check that npm run check includes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits: the eighth effect, and its rebuild.
Add a glass of beer landed the effect: metaball bubbles, a head that is a balance of two rates rather than a drawn thickness, and a linear wave surface.
Rebuild the beer on shallow water replaces that surface and grows the pour, porting what the sakamichi splash page taught:
waveMaxis gone; the frame is the rim. That exposed a real bug - a savage swirl could leave the surface in a permanent grid-scale boil - fixed by budgeting each frame's substeps flow-first, with a regression test at the worst dial setting.pour: trueto open on an empty glass that fills itself.polargainsreverse- the radius read inside-out, for every effect.Tests 854 → 903 (100% functions, 99% statements). Physics 0.7 ms and render 0.6 ms a frame at a 1080p field. Bundle 21.0 → 23.0 kB.