feat: add the Aquarium screensaver (#66) - #189
Merged
Merged
Conversation
Schooling fish in a wide tank, built on the boids flocking maths with soft tank boundaries instead of a torus, exhaustive rather than sampled neighbour search, and rate-limited steering so a fish has a turn radius. The scene is layered: OKLab water column, undulating surface, sheared god rays, caustic-lit sand and swaying kelp behind the shoals; bubbles, marine snow and out-of-focus near kelp in front. The god-ray and caustic fields are sampled by the fish shader as well as the background, so a fish brightens as it crosses a shaft. Fish are an asset-free SDF on instanced oriented quads, with a travelling-wave tail beat, countershading, a forked caudal fin and an eye. The quad's perpendicular axis flips sign with the heading, so sign(dir.x) is passed through to keep world-up on top and stop a left-swimming fish rendering belly-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both branches appended one import and one array entry to registry.js. Resolved by keeping both, Physarum first since it is already on main.
This was referenced Aug 11, 2026
12 tasks
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.
Closes #66
What this is
An aquarium: two or three shoals of fish sweeping a wide tank, a few large slow
independents that the shoals scatter away from, and a scene built around the
light. Asset-free — the fish are an SDF, the plants are analytic curves, the
caustics are a Voronoi web.
Why it looks the way it does
It is boids, but three things had to change before fish read as fish.
boids.jslives on a torus. A fish that teleports acrossthe tank stops being a fish instantly, so the walls here are soft steering
forces ramping quadratically through a margin band, with the fish's own turn
rate boosted while it banks away. The hard clamp at the glass exists only so
a pathological frame delta cannot put one outside; in normal running it is
never reached.
boids.jsdraws 32 randomneighbours out of 4,096 each frame, so the forces flicker (quality: Boids screensaver — spatial hash, oriented birds, and stop the flock tearing #127). For
abstract dots that reads as energy; for a school it reads as broken. The
shoal here is deliberately small — a fish is ~40x the screen area of a boid,
so a few hundred already fill a tank — and at that size every fish tests
every other fish. 676² = 457k interactions/frame is less work than the bird
flock does, and there is no sampling noise left to filter.
cruise speed, and the heading is a rate-limited low-pass of the desired
heading. That is what a fish physically does, and it means the simulation
cannot explode: speed is assigned, never accumulated.
The light is the trick. One family of functions —
causticWeb()andshaftField()— lights the surface, the god rays, the sand and the fish, so afish visibly brightens crossing a shaft and dims on the far side. That shared
sample is what sells "underwater"; without it the fish look pasted onto a blue
background.
Layers, depth-ordered into one HDR target: water column + surface shimmer +
god rays + caustic-lit sand + kelp silhouettes → the shoals (instanced oriented
quads, sorted far-to-near by construction) → large fish → bubbles, marine snow
and out-of-focus near kelp → bloom / ACES / dither.
Ambient light is answered with a real lightness range (OKLab L runs 0.10 at
the floor to 0.72 under the surface) plus a modest exposure lift on wall-sized
canvases. No flat
palette * 0.05black-level tint.Things worth knowing, found the hard way
createGLRuntime()does not size the canvas. It only callsresize()inside
runtime.start()'s loop, socanvas.width/heightare still the DOMdefault 300×150 in
start(). Every saver that readscanvasAspect(canvas)orparticleSide(canvas, …)there — includingboids.js, whose comment says"createGLRuntime has now sized the canvas" — is computing them for a 2:1
postage stamp. On the wall that makes the simulation's world space 2.5x too
narrow. This module calls
runtime.resize()explicitly; I have not touchedthe others, but they look wrong.
orientedQuadOffsetmust be applied in pixel space.boids.jsfoldsuQuadScaleinto the size before rotating, which scales the rotatedvector's y component by the x conversion factor — a 5x shear on the wall,
invisible at 16:9. This module converts after rotating.
a 5:1 canvas; with a six-level bloom pyramid it turned the entire frame into a
milky wash. The threshold is now set so only the ripple crests, the caustic
filaments and the bubble rims cross it.
orientedQuadOffset's perpendicular axis has the signof
dir.x, so an asymmetric fish swimming left renders belly-up. The vertexshader passes
sign(dir.x)through and the fragment shader multiplies local yby it.
Acceptance criteria
direction — separation/alignment/cohesion with a long cohesion radius,
plus a saturating pull toward each shoal's own wandering target. Heading
comes from the velocity and the sprite is built on it, so orientation is
correct by construction; the large fish go further and take velocity as
the exact analytic derivative of their path.
soft margin forces, no torus anywhere. Checked at t = 15/25/70/110/150/180/240s.
gradient, sheared god rays, caustic-lit sand floor, swaying kelp
silhouettes. Plus an undulating surface and a near-focus foreground.
separate lanes down the tank and their targets sweep it over a couple of
minutes, so there is a subject and negative space rather than uniform
texture. Verified at 3000×600 (wall aspect, half res) and 1920×1080.
--washout 0.12; the forked tail, dorsal fin and eye all still read.one array entry in
registry.js.stop()releases GL resources; no leak across repeated start/stopcycles — five programs, two instanced-quad VAOs, the ping-pong pair, the
attribute texture, the post chain and the runtime are all released and
nulled.
shadercheckdrives create/start/stop 5x per saver.Verification
structure-baselines.jsgains one line ("Aquarium": 0.085), set below the0.0989 and 0.0926 measured across two full harness runs so seed variance cannot
false-positive.
npm run baselineswas not run.Frame cost: the issue does not ask for a number, and the only figure
available here is meaningless for the wall — 2.0-2.8 fps at 3000×600 under
SwiftShader, which is a CPU rasteriser. For scale: the whole scene is five
fullscreen-ish passes plus 400-676 instanced quads, and the O(N²) flocking is
457k interactions/frame at the wall cap, against
boids.js' 131k stochasticones at 4,096 boids. Needs a look on real hardware before it ships.
Screenshots
Captured with the headless harness at the wall aspect and at 16:9, several
seeds, with and without the ambient-light wash, at times from 12s to 240s.