Skip to content

feat: add the Aquarium screensaver (#66) - #189

Merged
BernardJen merged 2 commits into
mainfrom
feat/aquarium
Aug 10, 2026
Merged

feat: add the Aquarium screensaver (#66)#189
BernardJen merged 2 commits into
mainfrom
feat/aquarium

Conversation

@BernardJen

Copy link
Copy Markdown
Contributor

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.

  1. No wrapping. boids.js lives on a torus. A fish that teleports across
    the 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.
  2. Exhaustive neighbours, not sampled ones. boids.js draws 32 random
    neighbours 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.
  3. Steering, not force integration. Velocity is a unit heading times a
    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() and
shaftField() — lights the surface, the god rays, the sand and the fish, so a
fish 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.05 black-level tint.

Things worth knowing, found the hard way

  • createGLRuntime() does not size the canvas. It only calls resize()
    inside runtime.start()'s loop, so canvas.width/height are still the DOM
    default 300×150 in start(). Every saver that reads canvasAspect(canvas) or
    particleSide(canvas, …) there — including boids.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 touched
    the others, but they look wrong.
  • orientedQuadOffset must be applied in pixel space. boids.js folds
    uQuadScale into the size before rotating, which scales the rotated
    vector's y component by the x conversion factor — a 5x shear on the wall,
    invisible at 16:9. This module converts after rotating.
  • Big bright regions fog, they do not glow. The surface band covers ~15% of
    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.
  • Orientation flip. orientedQuadOffset's perpendicular axis has the sign
    of dir.x, so an asymmetric fish swimming left renders belly-up. The vertex
    shader passes sign(dir.x) through and the fragment shader multiplies local y
    by it.

Acceptance criteria

  • Fish school with visible flocking behaviour, oriented along travel
    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.
  • Fish stay within the tank without visible wrapping or teleporting
    soft margin forces, no torus anywhere. Checked at t = 15/25/70/110/150/180/240s.
  • At least one background layer beyond flat colour — four: OKLab water
    gradient, sheared god rays, caustic-lit sand floor, swaying kelp
    silhouettes. Plus an undulating surface and a near-focus foreground.
  • Composition fills 6000×1200 as a wide tank — shoals are assigned
    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.
  • Fish readable as fish at 12% washout in wall mode — captured at
    --washout 0.12; the forked tail, dorsal fin and eye all still read.
  • Appears in the preview list and the random rotation — one import and
    one array entry in registry.js.
  • stop() releases GL resources; no leak across repeated start/stop
    cycles
    — five programs, two instanced-quad VAOs, the ping-pong pair, the
    attribute texture, the post chain and the runtime are all released and
    nulled. shadercheck drives create/start/stop 5x per saver.

Verification

npm run lint          pass
npm test              285 passed (23 files)
shadercheck           135 runs, 0 fail

structure-baselines.js gains one line ("Aquarium": 0.085), set below the
0.0989 and 0.0926 measured across two full harness runs so seed variance cannot
false-positive. npm run baselines was 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 stochastic
ones 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.

BernardJen and others added 2 commits August 10, 2026 00:29
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.
@BernardJen
BernardJen merged commit bfba76c into main Aug 10, 2026
1 check passed
@BernardJen
BernardJen deleted the feat/aquarium branch August 11, 2026 18:02
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.

feat: aquarium screensaver (schooling fish)

1 participant