Orbital lab

Every real spherical harmonic through l = 3. The superposition keeps evolving; each slider is how much that state is allowed to join in. Numbers on the right are the live coefficient right now.

click to zoom and orbit
interactive · click outside to release
ψ(θ,φ) = Σ clm · Ylm(θ,φ)

The live viewer

size margin speed orbit logo fade hint start container bg
container: …

This IS loader.html, inlined, running the take 2 D cut (the default): the final output, before any of the explanation. The lobes do not get cut to the mark and faded over: they MORPH into the logo's two leaves, flatten to the brand teal, and the crisp vector mark takes over on a single frame only once it is identical to the 3D. Click it once and your wheel zooms toward the cursor with the lab's exact springy feel; click anywhere else to give the wheel back to the page. Drag the teal corner or use the presets, the loader fills whatever you give it; the margin buttons retarget the framing live and the spring carries the camera to its new home. The loader here runs bg=transparent: tap the container bg swatches and the box's own color shows straight through the animation, which is how you sit it on a toolbar, next to a wordmark, or on any surface. Everything below explains how to ship it.

Ship it as a webview loader

loader.html on this page is the animation above distilled to ONE dependency-free file: raw WebGL, no Babylon, no CDN, no fonts, no fetch, about 44 KB. It boots instantly, which is the point: it plays while your webview's real content is still loading. About 18 KB gzipped, zero requests, one draw call over 12,800 vertex-shader-displaced triangles, and about 0.06 ms of JS per frame, so the CPU stays asleep. The wheel is tuned against the lab's measured response (same burst, same dip, same spring home), zooming toward the cursor with the springy feel that always breathes the framing back to center. Defaults are the tuned numbers; every knob is a URL parameter, and a fresh random state is rolled at every epoch so it never repeats.

Why raw WebGL, and not Babylon or three.js

A load screen exists to be on screen while your real app is still fetching. An animation that first downloads and parses a 3D engine defeats its own purpose, so the loader ships as ONE hand-rolled WebGL file and nothing else. The main viewer at the top of this page IS Babylon.js 9.18, where a full engine earns its weight (ArcRotate camera, GPU pixel readbacks for the fit measurement, tooling); the loader is that lab distilled.

approachpayload, gzippedrequestsfirst frame
Babylon.js + app~1.4 MB engine + your pageCDN + pageafter download and parse
three.js + app~170 KB engine + your pageCDN + pageafter download and parse
loader.html18,314 bytes, everything includedzerotens of milliseconds

The measured numbers

Measured on this build, not estimated: 44,918 bytes on disk and 18,314 bytes gzipped, including the shaders, the 286-point logomark vectors, the measured edge profiles, the 128-angle leaf table the morph lands on, and the whole timeline. Runtime: 0.057 ms of JavaScript per frame (16 coefficient cosines and uniform uploads, about 1.4% of a 240 Hz frame budget), then one draw call over 12,800 triangles / 6,561 vertices with all displacement in the vertex shader. requestAnimationFrame pauses it completely when the tab is hidden, so a backgrounded webview costs nothing. Spinner duty, measured at 32x32 on a 240 Hz display: one instance costs about 1 to 3% of a single core, eight concurrent instances about 9%, and eight instances capped with fps=30 about 2% total, all while the host page holds a locked 240 fps. The mode presets, same eight-spinner fleet: performance 8.2% of a core, lightweight 1.7%, about five times cheaper. On a 60 Hz machine divide by four. Hidden, the cost is exactly zero.

Fidelity guarantees

The loader is not a re-implementation of the look. tools/build-loader.py extracts the GLSL verbatim from the Babylon lab at build time (one source of truth), reproduces Babylon's left-handed view and projection matrices exactly, and the chirality is pixel-verified against the lab: teal lobe upper-right, navy lower-left. Same equation, same morph, same outline data, pixel-identical rendering. The camera feel is tuned against the lab's measured response, not copied constants: the same 12-notch wheel burst dips both viewers to the same radius (3.99 vs 3.995) and both spring home in the same beat, via a Babylon-style decaying inertia integral, the lab's 6% radius and 5% centering springs at its 240 fps cadence made frame-rate independent, zoom toward the cursor, and a glide home before every logo so the mark always lands undistorted.

The mouse: arming, zoom to cursor, and the springiness algorithm

Arming. A load screen must never eat input, so the loader is inert by default: clicks and wheel pass straight through to your page. ?interact=1 arms it (the preview above is armed): one click captures the wheel, a teal ring with a glow says so, and clicking anywhere else or leaving the frame releases it.

Zoom to cursor. Every notch records the cursor in normalized device coordinates. The world point under it is solved from the camera, w = n·r/m + pan, the radius step is applied, and the pan is re-solved so that exact world point stays pinned under the cursor: pan' = w − n·r'/m. Measured drift of the pinned point across a six-notch corner zoom: 0.002 world units.

The springiness. Three exponential systems, all frame-rate independent via pow(k, dt·240) so a 60 Hz laptop feels identical to a 240 Hz monitor:

systemrule, per framefeel
inertia integraleach notch deposits vel += r·0.085; consumed by step = vel·(1−0.8^(dt·240)) notches stack and glide instead of stepping
radius springr += (4.4−r)·(1−0.94^(dt·240)) zoom always breathes back home
centering pullpan −= pan·(1−0.95^(dt·240)) the mark drifts back to center

Tuned by measurement, not by copying constants. The obvious approach, lifting Babylon's wheelDeltaPercentage, nets to zero against the spring. So both viewers were driven with the identical 12-notch wheel burst in instrumented pup windows and the loader was tuned until the response curves matched: the lab dips to radius 3.995 and springs home in about 200 ms; the loader dips to 3.990 and springs home in about 250 ms, one sample bucket apart.

The epoch override. Whatever you have zoomed or panned, a glide window of max(0.6s, llead+0.2s, min(1.2s, dt)) eases the framing to the calibrated home pose, arriving exactly at epoch-start, the wheel is locked through the sit so the mark always lands undistorted, and home becomes the new state afterwards: the wheel frees the instant the sit ends and play resumes from the calibrated framing.

Resizing: it fills whatever it is given

Webviews get resized constantly, so the loader treats size as live input: it listens to window resize AND a ResizeObserver on the document, re-backs both canvases at devicePixelRatio (capped 2), and picks its FOV axis by the LIMITING dimension, so portrait, landscape, square, tiny or huge, the mark keeps its fraction of the container with no letterboxing and no stretching. Proven across 300x620, 1100x300, 420x420, 180x140, 1300x700 and 900x560: the GL buffer tracked the container 1:1 at every step, including mid-animation continuous resizes, and re-backing is coalesced into the render loop (events only mark dirty, the new size is applied and DRAWN in the same frame), so a continuous drag repaints clean every frame with no blank flashes. The repo ships resize-proof.html, a drag-to-resize torture rig around the real loader, so you can prove it in your own container shape.

Embed

<iframe src="loader.html"
        style="position:absolute;inset:0;width:100%;height:100%;border:0;background:#0d1117">
</iframe>
<!-- when your app is ready, fade the iframe out and remove it -->

Grab loader.html from this page's Files tab (or clone the repo) and serve it beside your app. It sizes to the limiting axis of any container, portrait or landscape.

Parameters

paramdefaultmeaning
pt5playground seconds of free evolution between epochs
dt1.5seconds to steer onto the mark (the dive), and back out
ct0.6seconds the morph takes: the lobes become the leaves before the epoch, and become lobes again after it. Keep it in the late part of dt
st1seconds the mark is held dead still (the crisp vector mark shows here)
llead0.9seconds before the sit the 3D starts flattening to teal, and after it the shading comes back
tlead0.4seconds before the epoch the dark lobe turns teal
ov10 makes the morph its own act after the steer
intro10 skips the teal logo intro
interact01 arms click-to-zoom (for previews and demos, never load screens)
modeperformancelightweight bundles fps 30, dpr 1, seg 48, no antialias for spinner fleets and dialogs; performance (alias sexy) is the full-quality default. Explicit params override the mode
seg80sphere tessellation, 24 to 128 (80 is 12,800 triangles, 48 is 4,608)
aa10 disables antialiasing
dpr2devicePixelRatio cap for the backing store, 1 to 3
fpsoffcap the render rate for spinner duty (floor 20). rAF already yields between frames and stops when hidden; fps=30 renders one frame in eight on a 240 Hz display
bg0d1117hex background, or transparent to composite over the container's own background (GL context runs with alpha). Also settable live by postMessage
hint01 shows the small "click to zoom" pill in armed contexts. Off by default: production stays pure. Armed contexts can also flip it live by postMessage
emerge01 replaces the logo intro: the equation runs from t=0 and the surface condenses out of nothing (2.6 s scale and fade) straight into play, then the normal timeline. Keep pt above ~3 s so the first epoch never lands mid-birth. A taste toggle: try the start buttons in the rig above
margin0percent of the container left empty around the mark: 80 sits it small in the middle of a big window, 20 nearly fills. NEGATIVE margins bleed: -50 moves the camera 50% closer so the animation runs off every edge (clamped at -70 to protect the near plane). Zoom-in still brings it to the screen; zoom-out caps at 40% past the margined framing. Live-settable by postMessage
speed100percent playback rate for the animation clocks (timeline, evolution, birth): 10 runs it at a tenth speed. Camera springs and user interaction stay real time so the feel never goes gluey. Live-settable by postMessage
logo100how flat the 3D gets for the logo moment, in percent: 100 is the flat brand teal, and only then does the crisp vector mark take over during the sit; lower keeps some 3D shading; 0 keeps the full shading (the morph still lands on the mark's shape). Live-settable by postMessage
orbit0automatic camera yaw during PLAY in degrees per second (try 12). As the morph starts the angle glides to the nearest whole turn, so every epoch still lands square-on for the logo and play resumes with no unwinding sweep. Live-settable by postMessage

How the logo moment works (take 2)

A pure 2p lobe is a ball touching the origin. Its cross-section disk is mapped onto the logo's leaf, radially from a center on the leaf's axis: the ball's center lands on that center, its rim on the leaf's outline, the node at the origin on the leaf's corner. Heights are kept, so the ball becomes a leaf-shaped solid whose outline IS the vector mark. Over the ct window the live orbital blends into that solid while perspective flattens away (each vertex goes where an orthographic camera at the same scale would put it), so at the epoch the 3D silhouette is the flat outline exactly. The shading keeps the orbital's own normals, so the map never creases the light. Over llead the shading flattens to the brand teal, and the crisp vector mark replaces the 3D whole, on one frame, only during the sit, where the two are identical. Nothing is cut, and nothing crossfades. Every vertex within one mesh step of the node collapses to the corner, so no triangle can bridge the two leaves mid-morph.

The tuned cuts

All ship in the Final tab's snapshot pulldown, and each maps to loader URL params. pt is free play between epochs, dt the dive onto the mark, ct the morph, st the dead-still hold, logo how flat the 3D gets, llead how early it starts flattening, tlead how early the dark lobe turns teal.

cutnumberswhy it feels the way it does
take 2 D
(the default, and the preview above)
pt 5 · dt 1.5 · ct 0.6 · st 1
logo 100% · llead 0.9 · tlead 0.4
A 1.5s dive with the morph in its last 0.6s, so the orbital has settled to two round lobes before they flow into the leaves, then a 1s sit on the crisp mark. Unhurried, and nothing reads as a trick. Loader: no params needed.
john fav pt 5 · dt 0.7 · ct 0.2 · st 1
logo 100% · llead 0.6 · tlead 0.2
The previous default: a fast 0.7s dive into a 1s sit. With take 2 the 0.2s morph reads as a snap. Loader: ?pt=5&dt=0.7&ct=0.2&st=1&llead=0.6&tlead=0.2
john 1 pt 4.5 · dt 3 · ct 0.3 · st 0
logo 100% · llead 1 · tlead 1.5
The stately cut: a long 3s dinner call, no sit, the logo moment on its 1s lead. Loader: ?pt=4.5&dt=3&ct=0.3&st=0&llead=1&tlead=1.5

Take 1, kept

loader-take1.html is the earlier approach, kept as its own file: the orbital is cut to the mark per pixel (no seam line, no fins), the part outside the quadrant melts away, the outer edge is trimmed to the exact vector outline with antialiasing, and the flat mark is handed over inside the sit. Same parameters, with ct as the cut and logo as the flat overlay. It is a built snapshot: tools/build-loader.py regenerates loader.html (take 2) only.