feat: add the Bicycle Horizon screensaver (#81) - #191
Merged
Conversation
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.
This was referenced Aug 11, 2026
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.
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, withpost-fx.jsfor bloom/tonemap/dither andglsl-lib.jsfor 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
tthe distance to a curtain at offsetXis justX / 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 tandd = h/-vboth differentiate exactly). Near thevanishing 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:
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.
running against a field that no longer meant anything once the band had taken
over. Gated on the resolved fraction.
fixed-point solver diverges below
|sin t| ~ 0.16. The path correction isfaded out there, and the path amplitude is bounded below the nearest tall
curtain's offset so the solve stays well conditioned.
Verification
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 baselineswas 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
procedurally generated rather than from art assets.
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.
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.
--washout 0.12; adaylight 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).
registry.js(one import, one array entry).stop()releases GL resources and any video element; no leak acrossrepeated start/stop cycles. There is no video element and no non-GL
resource; teardown is
createShaderScreensaver's, which destroys the postchain, the program and the runtime. shadercheck exercises
create/start/stop five times over.
Closes #81