Skip to content

feat: add the Bicycle Horizon screensaver (#81) - #191

Merged
BernardJen merged 2 commits into
mainfrom
feat/bicycle-horizon
Aug 10, 2026
Merged

feat: add the Bicycle Horizon screensaver (#81)#191
BernardJen merged 2 commits into
mainfrom
feat/bicycle-horizon

Conversation

@BernardJen

Copy link
Copy Markdown
Contributor

feat: add the Bicycle Horizon screensaver (#81)

A first-person ride along a coastal path: trees and a grass verge on one side,
a low kerb and open water on the other, tarmac running to a vanishing point,
and the rider's handlebars fixed in the lower frame.

Approach

Option B — layered 2.5D parallax, as decided on the issue, with the one
deviation recorded there: every layer is generated rather than sourced as
art. No video, no sprite sheet, no texture upload — one fragment shader through
createShaderScreensaver, with post-fx.js for bloom/tonemap/dither and
glsl-lib.js for simplex/fbm/hash/OKLab. Bundle cost is zero.

How it works, and why it looks the way it does

Cylindrical projection. Azimuth is linear in screen x, so the horizon stays
straight and verticals stay vertical across a 140-degree view; the vertical
axis is the tangent of elevation, so the ground still recedes correctly. A
rectilinear camera wide enough for 5:1 would smear the ends of the wall. The
cost is a ~2.2x horizontal squeeze, which is the right price for this subject:
a road receding to a vanishing point is exactly what a very wide frame flatters.

Curtains. Everything vertical — hills, three tree lines, scrub, the verge,
the kerb — is a plane parallel to the path at a fixed lateral offset. For a ray
at world azimuth t the distance to a curtain at offset X is just
X / sin(t): a closed form, no marching, one inversion per pixel. Perspective,
convergence on the vanishing point and inter-layer parallax all fall out of
that. Curtains never need depth-sorting against the ground either, because a
curtain is visible exactly where it is nearer than the ground at that pixel, so
the ground is painted first and the curtains composite over it.

Analytic anti-aliasing, no supersampling. Every edge is a smoothstep whose
width comes from the closed-form screen-space derivative of the coordinate it
is cut in (d = X/sin t and d = h/-v both differentiate exactly). Near the
vanishing point the filter width grows without bound and detail dissolves into
haze rather than boiling — something no fixed sample count can do.

Time of day. A 25-minute arc drives sun elevation and azimuth, plus a
per-activation heading for the path so the sun can be ahead, behind or to
either side. Everything else follows: sky and haze colour, whether the trees
are front-lit green or backlit silhouettes, which way their shadows fall across
the path, and where the glitter sits on the water. The rotation shows each
saver for 10 minutes, so a showing never sees the same light it opened with.

Exposure. Illumination is normalised to a constant key and fill; the
colour of both tracks the sun, the level does not. Golden hour is genuinely
four times darker on the ground than noon, and rendering that faithfully just
produces an underexposed frame — the one thing a lit-room no-signal display
cannot afford.

The foreground breathes. The bars carry a lateral rock at pedalling
cadence, a bob at twice cadence, and a slow steering shear. The camera bobs and
pitches out of phase with them, and leans into bends — so on a curve the
horizon tilts while the bar stays level, which is the correct relationship and
worth more than any amount of extra wobble.

Things that had to be got right, recorded in the source

Three failure modes ate most of the time and each has a comment where the fix
lives, because each is easy to reintroduce:

  • Needles at the vanishing point. A curtain viewed end-on samples a crown
    through a sliver, drawing a picket fence of 100m spikes. Fixed by swapping
    the row for its average band once the along-row footprint is anisotropic, and
    by writing the derivative in terms of the solved distance rather than the
    nominal offset.
  • A fish-scale mottle across the middle distance. Canopy detail noise kept
    running against a field that no longer meant anything once the band had taken
    over. Gated on the resolved fraction.
  • Horizontal streaking either side of the vanishing point. The curtain's
    fixed-point solver diverges below |sin t| ~ 0.16. The path correction is
    faded out there, and the path amplitude is bounded below the nearest tall
    curtain's offset so the solve stays well conditioned.

Verification

npm run lint     pass
npm test         285 passed (23 files)
npm run shadercheck  134/135 runs OK

The one shadercheck failure is DVD Logo, on its own structure baseline
(0.00169 measured against a 0.00182 floor) — a pre-existing frame-timing
sensitivity in that saver, untouched by this change. Bicycle Horizon passes all
five seeds; its measured edge density is 0.0616, added as its own line in
structure-baselines.js (npm run baselines was not run).

Screenshots reviewed over eight rounds at 3000x600 (the wall aspect at half
res), 1920x1080, and native 6000x1200-equivalent crops, across seeds 555, 17,
2, 101, 777, 12345 and 999999 — covering both layouts (sea left and sea right)
and elevations from 13 to 58 degrees.

Frame cost: 1.6-2.8 fps at 3000x600 on SwiftShader. That number says
nothing about a GPU — SwiftShader is a software rasteriser and this is a
fill-rate-bound fragment shader with no simulation state, no ping-pong targets
and no readback. It is the most expensive fragment in the set, which is why it
does not also pay for supersampling.

Acceptance criteria

  • Approach chosen and noted on the issue before implementation — option B,
    procedurally generated rather than from art assets.
  • Composition designed for 5:1; horizon and handlebars framed sensibly at
    6000x1200. Horizon at 55% of the frame height, the tree mass anchoring one
    end and the sea wedge the other, bars across ~40% of the width so both
    ends stay open. The bar half-width follows the aspect ratio so 16:9 gets
    the same read rather than grips 12% outside the frame.
  • Loops or runs indefinitely with no visible seam or restart pop. Nothing
    wraps or repeats: the ride advances ~3.8km over a 10-minute slot and the
    day arc is 25 minutes, so neither period closes within a showing.
  • Holds up at 12% washout in wall mode. Verified with --washout 0.12; a
    daylight scene keyed this bright loses very little, and it is a strong
    candidate for the lit-room default the issue asks about (bug: particle screensavers are near-invisible on the 6000x1200 videowall #88).
  • Appears in the preview list and the random rotation — registered in
    registry.js (one import, one array entry).
  • stop() releases GL resources and any video element; no leak across
    repeated start/stop cycles. There is no video element and no non-GL
    resource; teardown is createShaderScreensaver's, which destroys the post
    chain, the program and the runtime. shadercheck exercises
    create/start/stop five times over.

Closes #81

BernardJen and others added 2 commits August 10, 2026 00:33
A first-person ride along a coastal path: a tree line and grass verge on
one side, a low kerb and open water on the other, tarmac running to a
vanishing point, and the rider's handlebars fixed in the lower frame.

Option B from the issue -- layered 2.5D parallax -- with every layer
generated rather than sourced as art, so the bundle cost is zero and it
matches the asset-free architecture of the rest of the folder.

Composed for 6000x1200 from the start rather than cropped from the
portrait reference: a cylindrical projection keeps the horizon straight
across 140 degrees, the tree mass anchors one end and the sea wedge the
other, and the bars cross ~40% of the width so both ends stay open.

Every vertical layer is a plane parallel to the path at a fixed lateral
offset, inverted in closed form -- one division per pixel places a tree
line in perspective, and inter-layer parallax falls out of it. All edges
are cut with analytic screen-space filter widths rather than
supersampled, so detail at the vanishing point dissolves into haze
instead of aliasing.

A 25-minute sun arc drives sky and haze colour, foliage backlighting,
shadow direction and the glitter path on the water, against a 10-minute
rotation slot, so no showing repeats the light it opened with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three branches each appended one import and one array entry to registry.js and
one line to structure-baselines.js. Resolved by keeping all of them; the
baseline line is placed to preserve the file's descending-value order.
@BernardJen
BernardJen merged commit 41e4074 into main Aug 10, 2026
1 check passed
@BernardJen
BernardJen deleted the feat/bicycle-horizon 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: bicycle horizon screensaver (first-person cycling POV)

1 participant