Skip to content

Add a glass of beer, rebuilt on shallow water - #24

Merged
TokyoDanInJapan merged 5 commits into
mainfrom
glass-of-beer
Sep 13, 2026
Merged

TokyoDanInJapan merged 5 commits into
mainfrom
glass-of-beer

Conversation

@TokyoDanInJapan

@TokyoDanInJapan TokyoDanInJapan commented Aug 17, 2026

Copy link
Copy Markdown
Owner

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:

  • Shallow water on a staggered grid: depth-dependent wave speed, upwind momentum, volume conserved by construction. A quarter-poured glass sloshes at half the pace of a full one, with a test that times it.
  • Breaking crests shed foam and spray, so a hard stir visibly thickens the head. Stirs drive the body of the beer - the bow wave emerges from the flux instead of being drawn - and a click jabs it.
  • No amplitude ceiling. waveMax is 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.
  • Standing fizz streams from stratified nucleation sites, a raft-borne head that ignores pops but rides sloshes, ballistic spray under the waves' own gravity, and pour: true to open on an empty glass that fills itself.
  • Finer defaults: bubbles at a hundredth of the height, 3 px cells, 64 palette levels, a stronger depth fade.
  • polar gains reverse - the radius read inside-out, for every effect.
  • Demo: nine new palettes (five single-hue, four travelling-hue on the sunset's recipe) and a random palette on each load.
  • Docs: how-it-works.md rewritten in plain British English, a fifth shorter with every measurement kept.

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.

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.
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.
@TokyoDanInJapan TokyoDanInJapan changed the title Add a glass of beer Add a glass of beer, rebuilt on shallow water Aug 29, 2026
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.
@TokyoDanInJapan
TokyoDanInJapan merged commit 72db495 into main Sep 13, 2026
1 check passed
@TokyoDanInJapan
TokyoDanInJapan deleted the glass-of-beer branch September 13, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant