From 36e4b8930f1f2a1288bb02c1728c8d9abe6942f6 Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Fri, 30 Oct 2026 19:36:26 +0200 Subject: [PATCH 1/2] feat(site): Huginn flies in and draws in the page On the home page Huginn now opens the visit. He comes in over the viewer's shoulder from just past the top-left corner, close enough to fill a good part of the screen, glides out over the page with a few slow, deep wingbeats, swoops low to the bottom right and leaves: circling back to the left, on into the background, or off to the right. Each load varies the line, and some add a roll, a steeper dive, wide circles or an S-bend. The header, title, intro and cards wait, hidden, until he reaches them. As he does, the aurora sweep draws each one in the direction he is flying, paced to trail just behind him. If he cannot fly (reduced motion, no OffscreenCanvas or WebGL, a background tab) or has not reached the first within 4 s, the usual entrance plays instead. Switching sky plays a fresh fly-in on the new one. After the fly-in, flights come every 20 to 25 s, and a flight that cannot be planned for the view is tried again in a second or two instead of leaving the sky empty for a whole gap. The worker runs at 60 fps while he is close, and his texture is twice the size so he stays sharp up close. --- apps/site/src/components/Aurora.astro | 73 ++++- apps/site/src/layouts/Base.astro | 101 +++++++ apps/site/src/pages/index.astro | 7 +- apps/site/src/scripts/aurora.raven.ts | 376 ++++++++++++++++++++++--- apps/site/src/scripts/aurora.worker.ts | 163 ++++++++++- 5 files changed, 676 insertions(+), 44 deletions(-) diff --git a/apps/site/src/components/Aurora.astro b/apps/site/src/components/Aurora.astro index 24aa18f..888a982 100644 --- a/apps/site/src/components/Aurora.astro +++ b/apps/site/src/components/Aurora.astro @@ -155,6 +155,7 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag import type { SiteTimes } from "../scripts/aurora.ahead"; import type { AuroraMessage, + PageBox, SkyLabel, SkyLine, WorkerMessage, @@ -206,6 +207,61 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag const info = document.querySelector(".sky-info-button"); let post: ((message: AuroraMessage) => void) | null = null; + // Huginn's fly-in draws in the header, title, intro and cards as he + // reaches them. Base.astro hides them (`ng-await`) until the worker says + // he has reached each one; without a fly-in they come in by the usual + // entrance instead. The panes go first (the header, then the cards): + // the worker plans his path through the first two. + const page = document.documentElement; + const panes = page.classList.contains("ng-await") + ? [ + ...document.querySelectorAll(".ng-enter-pane"), + ...document.querySelectorAll(".ng-enter-text"), + ] + : []; + function isAwaiting(): boolean { + return page.classList.contains("ng-await"); + } + function paneBoxes(): PageBox[] { + if (!isAwaiting()) return []; + return panes.map((el) => { + const r = el.getBoundingClientRect(); + return [r.left, r.top, r.width, r.height]; + }); + } + function showAllPanes(): void { + page.classList.remove("ng-await"); + } + let lastAngle = 135; + let backstop = 0; + // Sweep the pane in toward `angle` (a CSS gradient angle), drifting a + // little along his line, over `duration` seconds. + function brushPane(i: number, angle: number, duration: number): void { + const el = panes[i]; + if (!el || !isAwaiting() || el.classList.contains("ng-brushed")) return; + lastAngle = angle; + const a = (angle * Math.PI) / 180; + const [dx, dy] = [Math.sin(a), -Math.cos(a)]; + const k = Math.max(Math.abs(dx), Math.abs(dy)) || 1; + el.style.setProperty("--ng-angle", `${angle}deg`); + el.style.setProperty( + "--ng-from", + `${50 + (50 * dx) / k}% ${50 + (50 * dy) / k}%`, + ); + el.style.setProperty( + "--ng-to", + `${50 - (50 * dx) / k}% ${50 - (50 * dy) / k}%`, + ); + el.style.setProperty("--ng-dx", dx.toFixed(3)); + el.style.setProperty("--ng-dy", dy.toFixed(3)); + el.style.setProperty("--ng-brush-duration", `${duration}s`); + el.classList.add("ng-brushed"); + // Should the rest never come (the worker stalled), they follow anyway. + backstop ||= window.setTimeout(() => { + panes.forEach((_, j) => brushPane(j, lastAngle, 1.6)); + }, 8000); + } + // All WebGL runs in the worker. Without OffscreenCanvas the CSS gradient // stays, so the main thread never pays for the effect. const canvas = document.querySelector(".aurora canvas"); @@ -235,12 +291,16 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag site: chosen, still: motion.matches, hidden: document.hidden, + targets: paneBoxes(), }, [offscreen], ); worker.onmessage = ({ data }: MessageEvent) => { if (typeof data === "object") { - if ("pick" in data) { + if ("brush" in data) { + if (data.brush < 0) showAllPanes(); + else brushPane(data.brush, data.angle ?? 135, data.duration ?? 1.6); + } else if ("pick" in data) { showPick(data.pick); } else if ("times" in data) { showSiteTimes(data.times, data.zone); @@ -251,7 +311,10 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag return; } const on = data === "on"; - if (!on) showPick(null); + if (!on) { + showPick(null); + showAllPanes(); + } canvas.classList.toggle("on", on); if (info) info.hidden = !on; }; @@ -270,12 +333,16 @@ import { version as gmtVersion } from "../../node_modules/@northguild/gmt/packag type: "size", width: canvas.clientWidth, height: canvas.clientHeight, + targets: paneBoxes(), }), 150, ); }).observe(canvas); } - if ("requestIdleCallback" in window) { + // The header and cards wait for the fly-in: start at once. + if (isAwaiting()) { + startWorker(canvas); + } else if ("requestIdleCallback" in window) { requestIdleCallback(() => startWorker(canvas), { timeout: 1500 }); } else { setTimeout(() => startWorker(canvas), 200); diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro index 663fc6f..33a6af1 100644 --- a/apps/site/src/layouts/Base.astro +++ b/apps/site/src/layouts/Base.astro @@ -35,6 +35,27 @@ const { type="font/woff2" crossorigin /> + { + entrance && ( + + ) + }
@@ -160,6 +181,86 @@ const { } } + /* ---- Huginn's wake ---- + * On the home page the header, title, intro and cards wait, hidden, for + * the fly-in + * (`ng-await`, set in the head). As Huginn passes each one the page adds + * `ng-brushed` with his heading: the same aurora light sweeps across it in + * the direction he flies (`--ng-angle`, from `--ng-from` to `--ng-to`), + * paced to him (`--ng-brush-duration`), and the pane drifts a few pixels + * along his line into place, as if his wings brushed it there. + * + * The mask is three times the pane's size, so the sweep's edge enters the + * pane a quarter of the way through and leaves it at three quarters: at a + * steady pace it trails just behind him as he crosses. */ + html.ng-await :is(.ng-enter-pane, .ng-enter-text):not(.ng-brushed) { + animation-name: none; + opacity: 0; + } + :is(.ng-enter-pane, .ng-enter-text).ng-brushed { + --ng-sweep: linear-gradient( + var(--ng-angle, 135deg), + #000 38%, + transparent 62% + ); + mask-size: 300% 300%; + mask-repeat: no-repeat; + animation-duration: var(--ng-brush-duration, 1.6s); + animation-timing-function: linear; + animation-delay: 0s; + } + .ng-enter-pane.ng-brushed { + animation-name: ng-brush-pane; + } + .ng-enter-text.ng-brushed { + animation-name: ng-brush-text; + } + @keyframes ng-brush-text { + 0% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-from, 100% 100%); + translate: calc(var(--ng-dx, 0.7) * -10px) calc(var(--ng-dy, 0.7) * -10px); + filter: blur(12px); + color: var(--ng-aurora); + text-shadow: 0 0 24px color(from var(--ng-aurora) srgb r g b / 0.7); + box-shadow: 0 0 48px 32px transparent; + } + 45% { + filter: blur(3px); + color: var(--ng-aurora); + text-shadow: 0 0 18px color(from var(--ng-aurora) srgb r g b / 0.45); + } + 100% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-to, 0% 0%); + translate: 0 0; + filter: blur(0); + text-shadow: 0 0 0 transparent; + box-shadow: 0 0 48px 32px transparent; + } + } + @keyframes ng-brush-pane { + 0% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-from, 100% 100%); + translate: calc(var(--ng-dx, 0.7) * -10px) calc(var(--ng-dy, 0.7) * -10px); + filter: blur(10px); + border-color: color(from var(--ng-aurora) srgb r g b / 0.7); + box-shadow: 0 0 44px -4px color(from var(--ng-aurora) srgb r g b / 0.5); + } + 45% { + filter: blur(2px); + border-color: color(from var(--ng-aurora) srgb r g b / 0.45); + box-shadow: 0 0 32px -6px color(from var(--ng-aurora) srgb r g b / 0.3); + } + 100% { + mask-image: var(--ng-sweep); + mask-position: var(--ng-to, 0% 0%); + translate: 0 0; + filter: blur(0); + } + } + /* Reduced motion: no drifting light or blur, just a short fade. */ @media (prefers-reduced-motion: reduce) { .ng-enter-text, diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 7584170..ad79027 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -50,9 +50,10 @@ const repos = [ pointer-events: auto; } - /* The entrance (`.ng-enter-*`, Base.astro): the header leads, then the - title, the line under it, and the cards in turn, landing as the sky's own - fade-in (1.6s) finishes. */ + /* Usually Huginn's fly-in draws these in as he reaches them (Aurora.astro). + Without it, the entrance (`.ng-enter-*`, Base.astro) plays: the header + leads, then the title, the line under it, and the cards in turn, landing + as the sky's own fade-in (1.6s) finishes. */ h1 { --ng-delay: 0.25s; } diff --git a/apps/site/src/scripts/aurora.raven.ts b/apps/site/src/scripts/aurora.raven.ts index 36953ee..3b92aa4 100644 --- a/apps/site/src/scripts/aurora.raven.ts +++ b/apps/site/src/scripts/aurora.raven.ts @@ -2,9 +2,16 @@ // 3D world as the sky. Units: metres and seconds. World frame: x east, y up, // z north, the viewer's eye at the origin. // -// Each flight is planned in the camera's frame so that it crosses the window: -// usually a pass along a gentle curve 20 to 65 m away, sometimes a few lazy -// circles farther out, low over the horizon where the aurora lights the sky. The bird is a small mesh posed every frame. It flaps in +// The first flight is a fly-in: he comes in over the viewer's shoulder from +// just past the top-left corner, close enough to fill a good part of the +// screen, glides out over the page, swoops low to the bottom right and +// leaves into the distance, a little differently every time. His flight +// draws in the page's header, title and cards (see aurora.worker.ts). +// +// After that each flight is planned in the camera's frame so that it crosses +// the window: usually a pass along a gentle curve 20 to 65 m away, sometimes +// a few lazy circles farther out, low over the horizon where the aurora +// lights the sky. The bird is a small mesh posed every frame. It flaps in // bouts at about 3.4 beats a second, the hand folding back on the upstroke, // and glides in between. It banks into its turns and now and then rolls onto // its back for a moment, as ravens do. Proportions follow the common raven: @@ -27,18 +34,27 @@ export type RavenView = { /** The highest a flight's middle goes on screen (-1 bottom, 1 top): clear * of the page's cards. */ ceiling: number; + /** The boxes a fly-in should pass behind, header first, on screen (-1 to + * 1, y up). Empty for a fly-in over the usual places. */ + targets: ScreenBox[]; }; +/** A box on screen: left, bottom, right, top, each -1 to 1, y up. */ +export type ScreenBox = [number, number, number, number]; + export type RavenTiming = { - /** Seconds before the first flight. */ - first: [number, number]; + /** Seconds before the fly-in, once the sky is up. */ + entrance: [number, number]; /** Seconds between flights. */ gap: [number, number]; }; -/** The first flight as the page opens, then a new one about 30 s after - * each ends. */ -export const RAVEN_TIMING: RavenTiming = { first: [0.5, 1.5], gap: [27, 33] }; +/** The fly-in as the sky fades in, then a new flight 20 to 25 s after each + * ends. */ +export const RAVEN_TIMING: RavenTiming = { + entrance: [0.5, 0.8], + gap: [20, 25], +}; const G = 9.81; /** Metres over which the bird fades in at a flight's start and out at its @@ -392,6 +408,13 @@ type Path = { length: number; /** Airspeed, m/s. */ speed: number; + /** Airspeed at the start, reached up to `speed` over the first `launch` + * metres: the fly-in eases up to speed as he goes into the swoop. */ + startSpeed: number; + launch: number; + /** Whether to fade in over the first FADE metres: not for a fly-in, which + * starts out of frame and close by. */ + fadeIn: boolean; soar: boolean; /** Distance along the path at which to roll over, or -1 for none. */ rollAt: number; @@ -449,7 +472,23 @@ function curve(through: V[], speed: number, soar: boolean, roll: number): Path { } const length = dist[dist.length - 1]!; const rollAt = Math.random() < roll ? length * randomBetween(0.35, 0.6) : -1; - return { points, dist, length, speed, soar, rollAt }; + return { + points, + dist, + length, + speed, + startSpeed: speed, + launch: 0, + fadeIn: true, + soar, + rollAt, + }; +} + +/** The airspeed a distance s along the path. */ +function speedAt(path: Path, s: number): number { + if (path.launch <= 0) return path.speed; + return lerpNumber(path.startSpeed, path.speed, smoothstep(s / path.launch)); } /** Where along the path, by distance flown. */ @@ -505,12 +544,25 @@ function frame(view: RavenView) { Math.max(horizon + lo, Math.min(horizon + hi, view.ceiling)), ); } + // The point at a depth (metres along the view) behind a point on screen. + function pointAtDepth(nx: number, ny: number, depth: number): V { + return addVectors( + scaleVector(fwd, depth), + addVectors( + scaleVector(right, nx * tanX * depth), + scaleVector(up, ny * view.tanY * depth), + ), + ); + } return { right, up, fwd, + tanX, + horizon, isInFrame, rayThroughScreen, + pointAtDepth, randomHeightAboveHorizon, }; } @@ -539,8 +591,8 @@ function leave(p: V, d: V, isInFrame: (p: V) => boolean): V { } /** The point raised, if need be, to the least height above the ground. */ -function keepAboveGround(p: V, view: RavenView): V { - return [p[0], Math.max(p[1], GROUND - view.eye), p[2]]; +function keepAboveGround(p: V, view: RavenView, least = GROUND): V { + return [p[0], Math.max(p[1], least - view.eye), p[2]]; } /** A pass across the view along a gentle curve. */ @@ -603,6 +655,28 @@ function pass(view: RavenView): Path | null { return spare; } +/** Points every 30 degrees round a level circle, for so many turns, climbing + * by `climb` metres a turn: x is r cos a and z is r sin a, from angle `start`, + * turning one way or the other by `sense`. */ +function ringAround( + centre: V, + r: number, + turns: number, + sense: number, + climb: number, + start: number, +): V[] { + const steps = Math.ceil(turns * 12); + return Array.from({ length: steps + 1 }, (_, k): V => { + const a = start + (sense * k * Math.PI) / 6; + return addVectors(centre, [ + r * Math.cos(a), + (climb * k) / 12, + r * Math.sin(a), + ]); + }); +} + /** A few slow circles on still air, then away. */ function circles(view: RavenView): Path | null { const { isInFrame, rayThroughScreen, randomHeightAboveHorizon } = frame(view); @@ -619,18 +693,8 @@ function circles(view: RavenView): Path | null { const sense = Math.random() < 0.5 ? -1 : 1; const climb = randomBetween(-4, 4); const start = randomBetween(0, 2 * Math.PI); - const steps = Math.ceil(turns * 12); - const ring: V[] = []; - for (let k = 0; k <= steps; k++) { - const a = start + (sense * k * Math.PI) / 6; - ring.push( - addVectors(centre, [ - r * Math.cos(a), - (climb * k) / 12, - r * Math.sin(a), - ]), - ); - } + const ring = ringAround(centre, r, turns, sense, climb, start); + const steps = ring.length - 1; if (!ring.every((p) => skyward(p, view) && isInFrame(p, 0.95))) continue; // Join and leave the circle where the tangent leaves the frame soonest, // within its first and last turn, keeping at least three quarters of a @@ -679,6 +743,221 @@ function circles(view: RavenView): Path | null { return null; } +/** The least height of a fly-in, metres above the ground or sea: he skims + * low over the water as he dives past the cards. */ +const LOW = 0.5; + +/** Where the header and cards usually sit on screen, for a fly-in with none + * given (after switching sky, when the cards are already up). */ +const USUAL_TARGETS: ScreenBox[] = [ + [-1, 0.84, 1, 1], + [-0.62, -0.25, 0.62, 0.35], +]; + +/** The variations on a fly-in: a plain swoop, a roll while he is still + * close, a steeper and lower dive, a few wide circles in the distance, or an + * S-bend on the way down. */ +type Flourish = "swoop" | "roll" | "stoop" | "spiral" | "weave"; +const FLOURISHES: Flourish[] = ["swoop", "roll", "stoop", "spiral", "weave"]; + +/** Which way a fly-in leaves after the swoop: circling back to the left + * into the distance, on into the background more or less straight ahead, + * or off to the right. */ +type Exit = "left" | "away" | "right"; +const EXITS: Exit[] = ["left", "away", "right"]; + +/** For each way out: where on screen (x, -1 to 1) he turns and where he + * heads, and how far off that is (metres; nearer on a tall screen, where he + * is close as he comes out of the swoop). Off to the right he heads out of + * frame. */ +const EXIT_SHAPES: Record< + Exit, + { + turnX: [number, number]; + farX: [number, number]; + depth: [number, number]; + tallDepth: [number, number]; + } +> = { + left: { + turnX: [0.5, 0.85], + farX: [-0.7, -0.25], + depth: [70, 110], + tallDepth: [45, 70], + }, + away: { + turnX: [0.3, 0.55], + farX: [-0.15, 0.3], + depth: [90, 130], + tallDepth: [55, 80], + }, + right: { + turnX: [0.75, 0.95], + farX: [1.15, 1.4], + depth: [35, 55], + tallDepth: [18, 30], + }, +}; + +/** The point moved to one side of the line from a to b (sideways and level), + * by k times the line's length. */ +function besideLine(p: V, a: V, b: V, k: number): V { + const d = subtractVectors(b, a); + const aside = normalize([-d[2], 0, d[0]]); + return addVectors(p, scaleVector(aside, k * Math.hypot(...d))); +} + +/** The fly-in: from just past the top-left corner, close enough to fill a + * good part of the screen, a long swoop down past the header and cards to + * low over the water at the bottom right, then away: circling back to the + * left, on into the background, or off to the right. */ +function entrance(view: RavenView): Path { + const { fwd, tanX, horizon, isInFrame, pointAtDepth } = frame(view); + const boxes = view.targets.length ? view.targets : USUAL_TARGETS; + const card = boxes[1] ?? USUAL_TARGETS[1]!; + const flourish = FLOURISHES[Math.floor(Math.random() * FLOURISHES.length)]!; + const stoop = flourish === "stoop"; + const side = Math.random() < 0.5 ? -1 : 1; + + // A point at a depth behind a point on screen, brought nearer if need be + // so that it stays above the water (below the horizon a far point would + // be under it), but no nearer than `least`: past that it is raised. + function lowPoint(nx: number, ny: number, depth: number, least = 4): V { + const slope = pointAtDepth(nx, ny, 1)[1]; + const floor = LOW - view.eye; + const most = slope < 0 && floor < 0 ? floor / slope : Infinity; + return keepAboveGround( + pointAtDepth(nx, ny, Math.min(depth, Math.max(most, least))), + view, + LOW, + ); + } + + // In over the viewer's shoulder, overtaking him: just past the top-left + // corner and so close that the wings (reaching 0.6 m from his middle) + // start out of frame too, flying mostly away so that he grows out of the + // corner and draws off smoothly rather than darting across. + const near = randomBetween(1.3, 1.6); + const start = pointAtDepth( + -(1 + 0.75 / (near * tanX)) * randomBetween(1, 1.08), + (1 + 0.75 / (near * view.tanY)) * randomBetween(0.9, 1), + near, + ); + // One long, straight-ish glide out over the title to above the first + // card, a few powerful wingbeats carrying him. + const [x0, y0, x1, y1] = card; + const glide = lowPoint( + lerpNumber(x0, x1, randomBetween(0.35, 0.75)), + lerpNumber(y0, y1, randomBetween(0.55, 0.95)), + randomBetween(7, 10), + ); + // Then the long swoop: down past the cards and low over the water toward + // the bottom-right corner. On a wide screen the horizon is near the + // bottom, so he stays just above it; on a tall one the cards are over + // the water and he comes down close, skimming it, to pass below them. + const tall = horizon > -0.3; + const lowY = Math.max(-0.92, randomBetween(-0.88, -0.6) - (stoop ? 0.08 : 0)); + const low = lowPoint( + randomBetween(0.35, 0.75), + tall ? lowY : Math.max(lowY, horizon + 0.04), + randomBetween(14, 20) * (stoop ? 0.75 : 1), + // Always on away from the viewer, never back toward him. + dotProduct(glide, fwd) + 3, + ); + // Then out of the swoop, still low, banking the way he leaves... + const exit = EXITS[Math.floor(Math.random() * EXITS.length)]!; + const shape = EXIT_SHAPES[exit]; + const turning = lowPoint( + randomBetween(...shape.turnX), + (tall ? lowY : Math.max(lowY, horizon + 0.04)) + randomBetween(0.05, 0.15), + // On a tall screen he is close down there; a nearer turn keeps him + // from shooting up toward the horizon as he draws away. + tall ? randomBetween(7, 11) : randomBetween(22, 32), + dotProduct(low, fwd) + 3, + ); + // ...and on into the distance, rising only as the distance lifts him + // toward the horizon, against the sky above the skyline. + function distantPoint(): V { + let p: V = [0, 0, 0]; + for (let tries = 0; tries < 10; tries++) { + p = pointAtDepth( + randomBetween(...shape.farX), + Math.min(0.6, horizon + randomBetween(0.08, 0.2) + tries * 0.05), + randomBetween(...(tall ? shape.tallDepth : shape.depth)), + ); + if (skyward(p, view)) return p; + } + return keepAboveGround(p, view); + } + const far = distantPoint(); + + const through: V[] = [start, glide]; + if (flourish === "weave") { + through.push( + besideLine(lerpVectors(glide, low, 0.5), glide, low, side * 0.2), + ); + } + through.push(low, turning, far); + + const flat = normalize([far[0] - turning[0], 0, far[2] - turning[2]]); + // A few wide circles around a centre beside the distant point, joined + // where he arrives there and turning the way he is going. + function spiral(): V[] | null { + const r = randomBetween(10, 16); + const centre = besideLine(far, [0, 0, 0], flat, side * r); + const a0 = Math.atan2(far[2] - centre[2], far[0] - centre[0]); + const tangent: V = [-Math.sin(a0), 0, Math.cos(a0)]; + const sense = dotProduct(tangent, flat) >= 0 ? 1 : -1; + const ring = ringAround( + centre, + r, + randomBetween(1, 1.75), + sense, + randomBetween(2, 5), + a0, + ).slice(1); + return ring.every((p) => skyward(p, view) && isInFrame(p, 0.98)) + ? ring + : null; + } + const ring = flourish === "spiral" ? spiral() : null; + if (ring) { + through.push(...ring); + } else { + // Gliding on the way he is going, a little higher. + through.push( + addVectors( + far, + addVectors(scaleVector(flat, randomBetween(25, 35)), [ + 0, + randomBetween(1, 3), + 0, + ]), + ), + ); + } + const last = through[through.length - 1]!; + const before = through[through.length - 2]!; + through.push( + keepAboveGround( + leave(last, normalize(subtractVectors(last, before)), isInFrame), + view, + ), + ); + + // An easy glide, gathering a little speed as he goes down into the swoop. + const path = curve(through, randomBetween(8, 9), false, 0); + path.startSpeed = randomBetween(6.5, 7); + path.launch = 15; + path.fadeIn = false; + // The roll comes early in the swoop, while he is still near. + if (flourish === "roll") { + path.rollAt = + Math.hypot(...subtractVectors(glide, start)) + randomBetween(2, 5); + } + return path; +} + // --------------------------------------------------------------------------- export type Raven = { @@ -688,6 +967,12 @@ export type Raven = { /** Land: no bird until the next flight is due, after `wait` seconds (a * fresh gap if not given). */ reset(wait?: number): void; + /** Land, and make the next flight a fly-in, after `wait` seconds. */ + enter(wait: number): void; + /** Whether the flight under way is a fly-in. */ + entering: boolean; + /** Where the bird is, in world space. */ + position: V; /** The bird's triangles in world space (RAVEN_STRIDE floats a vertex). */ mesh: Float32Array; /** Vertices to draw, 0 when there is no bird. */ @@ -698,7 +983,8 @@ export type Raven = { export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { const mesh = new Float32Array(TRIS * 3 * RAVEN_STRIDE); - let wait = randomBetween(...timing.first); + let wait = randomBetween(...timing.entrance); + let flyIn = true; let path: Path | null = null; let s = 0; let size = 1; @@ -719,20 +1005,36 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { mesh, count: 0, alpha: 0, + entering: false, + position: [0, 0, 0], reset(next) { path = null; bird.count = 0; + bird.entering = false; wait = next ?? randomBetween(...timing.gap); }, + enter(next) { + bird.reset(next); + flyIn = true; + }, step(dt, view) { if (!path) { wait -= dt; if (wait > 0) return; - path = Math.random() < 0.3 ? circles(view) : pass(view); - if (!path) return bird.reset(); + path = flyIn + ? entrance(view) + : Math.random() < 0.3 + ? circles(view) + : pass(view); + // No flight fits this view: try again shortly rather than leave the + // sky empty for a whole gap. + if (!path) return bird.reset(randomBetween(1, 2)); + bird.entering = flyIn; + flyIn = false; s = 0; size = randomBetween(0.94, 1.08); - rate = randomBetween(3.2, 3.6); + // Slower, deeper wingbeats on the fly-in. + rate = bird.entering ? randomBetween(2.7, 3) : randomBetween(3.2, 3.6); phase = LEVEL_PHASE; flap = flapTarget = path.soar ? 0 : 1; wantFlap = !path.soar; @@ -741,9 +1043,12 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { rollT = -1; up = null; } - s += path.speed * dt; + const speed = speedAt(path, s); + s += speed * dt; if (s >= path.length) return bird.reset(); - bird.alpha = smoothstep(s / FADE) * smoothstep((path.length - s) / FADE); + bird.alpha = + (path.fadeIn ? smoothstep(s / FADE) : 1) * + smoothstep((path.length - s) / FADE); // Heading and bank. The lift must carry the bird's weight and turn it, // so it leans along gravity plus the turn's acceleration (a coordinated @@ -752,8 +1057,9 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { const p = along(path, s); const ahead = along(path, s + ds); const behind = along(path, s - ds); + bird.position = p; const fwd = normalize(subtractVectors(ahead, behind)); - const k = (path.speed / ds) ** 2; + const k = (speed / ds) ** 2; const accel = scaleVector( subtractVectors(addVectors(ahead, behind), scaleVector(p, 2)), k, @@ -799,9 +1105,13 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { ? wantFlap ? randomBetween(0.6, 1.2) : randomBetween(5, 10) - : wantFlap - ? randomBetween(1, 2.6) - : randomBetween(0.7, 2.2); + : bird.entering + ? wantFlap + ? randomBetween(0.8, 1.5) + : randomBetween(2, 4) + : wantFlap + ? randomBetween(1, 2.6) + : randomBetween(0.7, 2.2); } } let flapping = wantFlap && !rolling; diff --git a/apps/site/src/scripts/aurora.worker.ts b/apps/site/src/scripts/aurora.worker.ts index 73c5dc2..389d24c 100644 --- a/apps/site/src/scripts/aurora.worker.ts +++ b/apps/site/src/scripts/aurora.worker.ts @@ -31,7 +31,12 @@ import { import { getSystemTimeZone, getUnixNow } from "@northguild/gmt"; import { SUN_UP, lookAhead, type Seer, type SiteTimes } from "./aurora.ahead"; import { MOON } from "./aurora.moon"; -import { RAVEN_STRIDE, RAVEN_TIMING, raven } from "./aurora.raven"; +import { + RAVEN_STRIDE, + RAVEN_TIMING, + raven, + type ScreenBox, +} from "./aurora.raven"; import { SITES, type Site } from "./aurora.sites"; import { PLANETS, @@ -46,13 +51,15 @@ import { SKYLINE as HELSINKI } from "./skylines/helsinki"; import { SKYLINE as ISAFJORDUR } from "./skylines/isafjordur"; const FRAME_MS = 1000 / 30; +/** During the fly-in, when he is close and crossing the screen fast. */ +const FLY_IN_FRAME_MS = 1000 / 60; const BASE_SCALE = 0.6; const MAX_EDGE = 1280; const SPARKS = 4000; const RAD = Math.PI / 180; /** The raven's own texture, square: big enough that scaling it down to the * bird's size on screen smooths its edges. */ -const RAVEN_TEX = 256; +const RAVEN_TEX = 512; // Wall-clock epoch milliseconds from Temporal (via gmt): the stars, planets, // moon and sun need the real time. Frame timing uses rAF stamps, not the wall @@ -82,9 +89,13 @@ export type AuroraMessage = site: number; still: boolean; hidden: boolean; + /** The page's header, cards, title and intro, to draw in as the + * fly-in reaches them, in CSS pixels: left, top, width, height. Later + * fly-ins aim at them too. Empty when the page draws them in itself. */ + targets: PageBox[]; } | { type: "site"; site: number } - | { type: "size"; width: number; height: number } + | { type: "size"; width: number; height: number; targets: PageBox[] } | { type: "still"; still: boolean } | { type: "hidden"; hidden: boolean } /** What is drawn near this point (CSS pixels), within reach pixels. */ @@ -95,6 +106,9 @@ export type AuroraMessage = * about to be). */ | { type: "times"; want: boolean }; +/** A box on the page in CSS pixels: left, top, width, height. */ +export type PageBox = [number, number, number, number]; + /** One unbroken run of a constellation line in CSS pixels, [x, y, x, y, …], * drawn from the end nearer the hovered star; step counts lines out from it. */ export type SkyLine = { step: number; points: number[] }; @@ -126,6 +140,10 @@ export type WorkerMessage = | "off" | { site: number } | { pick: SkyLabel | null } + /** Paint in the page's box `brush` (an index into the init's targets) now, + * sweeping toward `angle` (a CSS gradient angle) over `duration` seconds; + * -1 paints them all in, as there will be no fly-in. */ + | { brush: number; angle?: number; duration?: number } /** When the sun and moon next show in this window, per site in SITES order. */ | { times: SiteTimes[]; zone: string }; @@ -634,8 +652,132 @@ function start(init: Extract): void { // Landscape: below the middle of the cards. Portrait: the cards sit // below the horizon, so up toward the top of the frame. ceiling: 0.3 + 0.55 * portrait, + // Where the page's boxes are, for every fly-in, so the one after a + // sky switch flies the same line as the first. + targets: targets.map(onScreen), }; } + + // The page's header, title, intro and cards, hidden until the fly-in + // reaches them: his flight draws each one in. As he reaches a box the page + // starts a sweep across it the way he is flying, paced so its edge + // follows just behind him (it enters the box a quarter of the way into + // the sweep and crosses it in the middle half; see Base.astro). A box he + // never reaches is drawn in once he is past its middle on his way down + // the screen (top left to bottom right), or soon after the last one. + let targets: PageBox[] = init.targets; + let painted = targets.map(() => false); + let seen: [number, number] | null = null; + let heading: [number, number] = [Math.SQRT1_2, Math.SQRT1_2]; + // His speed across the screen, CSS pixels a second, and the seconds since + // a box was last drawn in. + let pace = 0; + let sincePaint = 0; + /** A page box on screen, -1 to 1 with y up, as the raven plans in. */ + function onScreen([x, y, w, h]: PageBox): ScreenBox { + const [W, H] = [width || 1, height || 1]; + return [ + (2 * x) / W - 1, + 1 - (2 * (y + h)) / H, + (2 * (x + w)) / W - 1, + 1 - (2 * y) / H, + ]; + } + /** The box's corners along the heading: the nearest, and how far across. */ + function spanAlongHeading([x, y, w, h]: PageBox): [number, number] { + const along = [ + [x, y], + [x + w, y], + [x, y + h], + [x + w, y + h], + ].map(([cx, cy]) => cx! * heading[0] + cy! * heading[1]); + const lead = Math.min(...along); + return [lead, Math.max(...along) - lead]; + } + function paint(i: number): void { + const [, span] = spanAlongHeading(targets[i]!); + painted[i] = true; + sincePaint = 0; + scope.postMessage({ + brush: i, + angle: Math.round( + (Math.atan2(heading[0], -heading[1]) / RAD + 360) % 360, + ), + // The sweep's edge crosses the box in the middle half of the sweep, + // a little slower than he is going now: he slows on screen as he + // draws away, and the edge should never overtake him. + duration: + Math.round( + Math.min(5, Math.max(1.6, (2 * span) / (0.7 * pace || 1))) * 100, + ) / 100, + }); + } + function paintTheRest(): void { + painted.forEach((done, i) => { + if (!done) paint(i); + }); + } + function paintInWake(dt: number): void { + if (!painted.includes(false)) return; + if (!bird.entering) { + // The fly-in is over and he never passed some: paint them now. + if (seen) paintTheRest(); + return; + } + const at = project({ cam, tanY }, width, height, bird.position); + if (!at) return; + if (seen) { + const [dx, dy] = [at[0] - seen[0], at[1] - seen[1]]; + const moved = Math.hypot(dx, dy); + if (moved > 0.5 && dt > 0) { + // Smoothed, so a wobble in the curve does not swing the wave. + const hx = heading[0] * 0.6 + (dx / moved) * 0.4; + const hy = heading[1] * 0.6 + (dy / moved) * 0.4; + const n = Math.hypot(hx, hy) || 1; + heading = [hx / n, hy / n]; + pace = moved / dt; + } + } + seen = at; + const [bx0, by0, bx1, by1] = birdOnPage(); + targets.forEach((box, i) => { + if (painted[i]) return; + const [x, y, w, h] = box; + // His body reaching it, not just a wing tip: his middle within a + // quarter of his size of the box. + const [mx, my] = [(bx1 - bx0) / 4, (by1 - by0) / 4]; + const over = + at[0] > x - mx && + at[0] < x + w + mx && + at[1] > y - my && + at[1] < y + h + my; + // Along the screen's diagonal, which his swoop follows overall; his + // heading at the moment can swing back as he turns. + const [W, H] = [width || 1, height || 1]; + const past = at[0] * W + at[1] * H >= (x + w / 2) * W + (y + h / 2) * H; + if (over || past) paint(i); + // One his path never reaches (a card below his swoop on a phone) + // follows on soon after the last, while he is clear of it. + else if (sincePaint > 1.1 && painted.includes(true)) paint(i); + }); + sincePaint += dt; + } + /** The bird's extent on the page in CSS pixels: left, top, right, bottom. */ + function birdOnPage(): [number, number, number, number] { + const m = bird.mesh; + let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; + for (let i = 0; i < bird.count * RAVEN_STRIDE; i += RAVEN_STRIDE) { + const xy = project({ cam, tanY }, width, height, [ + m[i]!, + m[i + 1]!, + m[i + 2]!, + ]); + if (!xy) continue; + [x0, x1] = [Math.min(x0, xy[0]), Math.max(x1, xy[0])]; + [y0, y1] = [Math.min(y0, xy[1]), Math.max(y1, xy[1])]; + } + return [x0, y0, x1, y1]; + } function moveToSite(index: number): void { site = SITES[index] ?? SITES[0]!; // Field lines climb toward magnetic south at the site's dip. @@ -1129,11 +1271,12 @@ function start(init: Extract): void { function onFrame(stamp: number): void { raf = nextFrame(onFrame); const elapsed = stamp - last; - if (elapsed < FRAME_MS - 2) return; + if (elapsed < (bird.entering ? FLY_IN_FRAME_MS : FRAME_MS) - 2) return; if (last) { const dt = Math.min(elapsed, 100) / 1000; t += dt; bird.step(dt, currentRavenView()); + paintInWake(dt); adaptQuality(elapsed); } last = stamp; @@ -1150,6 +1293,11 @@ function start(init: Extract): void { resizeCanvas(); drawFrame(); scope.postMessage("on"); + // No raven to draw, or no motion: the page paints its boxes in itself. + if (targets.length && (!ravenOk || still)) { + painted = painted.map(() => true); + scope.postMessage({ brush: -1 }); + } startLoop(); // Stars arrive after the first frame so they never delay it, then the star @@ -1184,7 +1332,11 @@ function start(init: Extract): void { if (level > 2) return; if (data.type === "site") { held = NOTHING; - bird.reset(); + // Anything not yet painted in comes in now; the new sky gets its own + // fly-in once it is up. + paintTheRest(); + if (still) bird.reset(); + else bird.enter(1); moveToSite(data.site); skyAt = readWallClock(); now = sky(site, skyAt); @@ -1194,6 +1346,7 @@ function start(init: Extract): void { } else if (data.type === "size") { width = data.width; height = data.height; + if (data.targets.length === targets.length) targets = data.targets; resizeCanvas(); drawFrame(); followHeld(); From 25653a01b386d4d393fa2352ef4cfe882b7f9be7 Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Fri, 30 Oct 2026 20:01:59 +0200 Subject: [PATCH 2/2] fix(site): Huginn's fly-in flown by physics, clear of the mountains The fly-in is now flown rather than drawn. A simple glider steers through the waypoints under gravity, with drag on a raven's glide polar and no more lift than his wings bear, so his speed, turns and pull-outs are ones a raven could fly, and he flaps only when he is slow or climbing. He comes in over the top edge at the far left, close and wide (about a third of a desktop screen, at most half of a phone's), swoops down and away and leaves. Up close, time runs slower with his distance, as wildlife film shows big birds, so he is large and unhurried and his speed across the screen stays calm. The fly-in ends once he has left the frame or is a speck in the distance. Every flight now keeps at least 2.5 degrees above the skyline, eased along the path so it never kinks: at Isafjordur the mountains fill the left of the view and he used to fly in front of them. Flights come every 15 to 20 s. The header is drawn in as his wing sweeps it on the way in, and the fly-in runs at 60 fps only while he is close and the device keeps up. --- apps/site/src/scripts/aurora.raven.ts | 320 +++++++++++++++++++++---- apps/site/src/scripts/aurora.worker.ts | 22 +- 2 files changed, 298 insertions(+), 44 deletions(-) diff --git a/apps/site/src/scripts/aurora.raven.ts b/apps/site/src/scripts/aurora.raven.ts index 3b92aa4..d5da40e 100644 --- a/apps/site/src/scripts/aurora.raven.ts +++ b/apps/site/src/scripts/aurora.raven.ts @@ -49,11 +49,11 @@ export type RavenTiming = { gap: [number, number]; }; -/** The fly-in as the sky fades in, then a new flight 20 to 25 s after each +/** The fly-in as the sky fades in, then a new flight 15 to 20 s after each * ends. */ export const RAVEN_TIMING: RavenTiming = { entrance: [0.5, 0.8], - gap: [20, 25], + gap: [15, 20], }; const G = 9.81; @@ -408,10 +408,13 @@ type Path = { length: number; /** Airspeed, m/s. */ speed: number; - /** Airspeed at the start, reached up to `speed` over the first `launch` - * metres: the fly-in eases up to speed as he goes into the swoop. */ - startSpeed: number; - launch: number; + /** For a flown path (see fly()), the speed at each point; null for + * `speed` all the way. */ + speeds: number[] | null; + /** Slow motion near the viewer, as wildlife film does it: time runs + * slower the nearer he is (see timeScaleAt), so up close he is big and + * unhurried and his speed across the screen stays calm. */ + slowNear: boolean; /** Whether to fade in over the first FADE metres: not for a fly-in, which * starts out of frame and close by. */ fadeIn: boolean; @@ -477,8 +480,8 @@ function curve(through: V[], speed: number, soar: boolean, roll: number): Path { dist, length, speed, - startSpeed: speed, - launch: 0, + speeds: null, + slowNear: false, fadeIn: true, soar, rollAt, @@ -487,22 +490,43 @@ function curve(through: V[], speed: number, soar: boolean, roll: number): Path { /** The airspeed a distance s along the path. */ function speedAt(path: Path, s: number): number { - if (path.launch <= 0) return path.speed; - return lerpNumber(path.startSpeed, path.speed, smoothstep(s / path.launch)); + const { speeds, dist } = path; + if (!speeds) return path.speed; + const [lo, hi, k] = bracket(path, s); + return lerpNumber(speeds[lo]!, speeds[hi]!, dist[hi] === dist[lo] ? 0 : k); } -/** Where along the path, by distance flown. */ -function along(path: Path, s: number): V { - const { points, dist } = path; +/** Within NEAR_SLOW metres time runs slower, in step with his distance, + * down to NEAR_SLOWEST of real time (at 3 m). */ +const NEAR_SLOW = 12; +const NEAR_SLOWEST = 0.25; + +/** How fast time runs for him a distance s along the path. */ +function timeScaleAt(path: Path, s: number): number { + if (!path.slowNear) return 1; + const d = Math.hypot(...along(path, s)); + return Math.min(1, Math.max(NEAR_SLOWEST, d / NEAR_SLOW)); +} + +/** The two points either side of a distance along the path, and how far + * between them it is. */ +function bracket(path: Path, s: number): [number, number, number] { + const { dist } = path; const c = Math.min(Math.max(s, 0), path.length); let [lo, hi] = [0, dist.length - 1]; - while (hi - lo > 1) { + for (let step = 0; step < 32 && hi - lo > 1; step++) { const mid = (lo + hi) >> 1; if (dist[mid]! <= c) lo = mid; else hi = mid; } const span = dist[hi]! - dist[lo]! || 1; - return lerpVectors(points[lo]!, points[hi]!, (c - dist[lo]!) / span); + return [lo, hi, (c - dist[lo]!) / span]; +} + +/** Where along the path, by distance flown. */ +function along(path: Path, s: number): V { + const [lo, hi, k] = bracket(path, s); + return lerpVectors(path.points[lo]!, path.points[hi]!, k); } /** The view's axes, and whether a point is within the frame (with a margin). */ @@ -590,6 +614,85 @@ function leave(p: V, d: V, isInFrame: (p: V) => boolean): V { return addVectors(p, scaleVector(d, 100)); } +/** Degrees either side of a bearing over which the skyline is taken at its + * highest, so that a path kept clear of it does not follow every notch. */ +const SKYLINE_SPREAD = 4; +/** How far above the skyline a path keeps (rad): seen against the sky, never + * in front of the mountains (Ísafjörður's fill the left of its view). */ +const SKYLINE_CLEARANCE = 2.5 * RAD; + +/** The highest the skyline stands within SKYLINE_SPREAD of a bearing + * (degrees), radians above the horizontal. */ +function skylineNear(view: RavenView, az: number): number { + let top = -Math.PI / 2; + for (let d = -SKYLINE_SPREAD; d <= SKYLINE_SPREAD; d++) { + top = Math.max(top, view.ridge(az + d)); + } + return top; +} + +/** The point raised, if need be, so that it is seen against the sky clear + * of the skyline. (Every ridge is far beyond his flights, so he is never + * behind one, only ever in front.) */ +function clearOfSkyline(p: V, view: RavenView, extra = 0): V { + const flat = Math.hypot(p[0], p[2]); + if (flat < 0.5) return p; + const az = Math.atan2(p[0], p[2]) / RAD; + const least = + flat * Math.tan(skylineNear(view, az) + SKYLINE_CLEARANCE + extra * RAD); + return p[1] >= least ? p : [p[0], least, p[2]]; +} + +/** Metres either side over which a lift clear of the skyline is eased. */ +const LIFT_EASE = 4; + +/** The path with every point kept clear of the skyline. */ +function keptClearOfSkyline(path: Path, view: RavenView): Path { + // How far each point must rise, then eased along the path (the most + // within a few metres either side, then averaged) so that lifting him + // clear never puts a kink in his line. + const rise = path.points.map((p) => clearOfSkyline(p, view)[1] - p[1]); + if (!rise.some((r) => r > 0)) return path; + function near(i: number, f: (j: number) => void): void { + const { dist } = path; + for (let j = i; j >= 0 && dist[i]! - dist[j]! <= LIFT_EASE; j--) f(j); + for ( + let j = i + 1; + j < rise.length && dist[j]! - dist[i]! <= LIFT_EASE; + j++ + ) { + f(j); + } + } + const most = rise.map((_, i) => { + let m = 0; + near(i, (j) => (m = Math.max(m, rise[j]!))); + return m; + }); + const points = path.points.map((p, i): V => { + let [sum, n] = [0, 0]; + near(i, (j) => { + sum += most[j]!; + n++; + }); + return [p[0], p[1] + Math.max(rise[i]!, sum / n), p[2]]; + }); + const dist = [0]; + for (let i = 1; i < points.length; i++) { + dist.push( + dist[i - 1]! + Math.hypot(...subtractVectors(points[i]!, points[i - 1]!)), + ); + } + const length = dist[dist.length - 1]!; + return { + ...path, + points, + dist, + length, + rollAt: Math.min(path.rollAt, length), + }; +} + /** The point raised, if need be, to the least height above the ground. */ function keepAboveGround(p: V, view: RavenView, least = GROUND): V { return [p[0], Math.max(p[1], least - view.eye), p[2]]; @@ -747,6 +850,127 @@ function circles(view: RavenView): Path | null { * low over the water as he dives past the cards. */ const LOW = 0.5; +// --------------------------------------------------------------------------- +// Flying a path: rather than following a drawn curve, the fly-in is flown by +// a simple glider steered through its waypoints, so its speed, turns and +// pull-outs are what a raven's wings could really do. + +/** A raven's best glide: at about this speed (m/s), some 9 m on for each + * metre down. Slower or faster costs more. */ +const BEST_GLIDE = 8.5; +const LIFT_TO_DRAG = 9; +/** The most lift his wings bear in a turn or pull-out, in g. */ +const MOST_LOAD = 2; +/** Below this speed (m/s) he flaps to hold it, and how hard (m/s²). */ +const SLOWEST = 6; +const THRUST = 3; +/** About how long (s) he takes to swing onto a new heading. */ +const STEER = 0.9; +/** The step the flight is worked out in (s), and the longest it may run. */ +const FLY_STEP = 1 / 60; +const FLY_LONGEST = 40; + +/** Beyond this (metres) he is a speck, and a flown path ends. */ +const FLY_FARTHEST = 110; + +/** Fly through the waypoints from the first, setting off toward the second + * at `speed`: gravity, drag on a raven's glide polar, lift up to MOST_LOAD + * steering him at each waypoint in turn (easing toward the one after as he + * nears it), and flapping when he is slow or climbing. `floor` is the + * lowest he goes (y, metres). It ends once he has been in view and left + * it, or is FLY_FARTHEST off. Each step's position and speed. */ +function fly( + through: V[], + speed: number, + floor: number, + isInFrame: (p: V, margin?: number) => boolean, +): { points: V[]; speeds: number[] } { + let p = through[0]!; + let v = scaleVector(normalize(subtractVectors(through[1]!, p)), speed); + const points: V[] = [p]; + const speeds = [speed]; + let k = 1; + let seen = false; + for (let i = 0; i < FLY_LONGEST / FLY_STEP && k < through.length; i++) { + const shown = isInFrame(p, 1.2); + seen ||= shown; + if ((seen && !shown) || Math.hypot(...p) > FLY_FARTHEST) break; + const vNow = Math.hypot(...v); + const dir = scaleVector(v, 1 / vNow); + const to = subtractVectors(through[k]!, p); + const away = Math.hypot(...to); + // Reached (or flown past) this waypoint: on to the next. + if (away < Math.max(1.5, vNow * 0.4) || dotProduct(to, dir) < 0) { + k++; + continue; + } + let aim = scaleVector(to, 1 / away); + const next = through[k + 1]; + if (next) { + const ease = 0.6 * smoothstep(1 - away / (vNow * 2)); + aim = normalize( + lerpVectors(aim, normalize(subtractVectors(next, p)), ease), + ); + } + // Low over the water, he pulls up. + if (p[1] < floor + 1.5) { + aim = normalize(addVectors(aim, [0, (floor + 1.5 - p[1]) * 0.6, 0])); + } + // Lift holds him up across his line and turns him toward his aim, + // within what his wings bear. + const across = subtractVectors(aim, scaleVector(dir, dotProduct(aim, dir))); + const angle = Math.acos(Math.min(1, Math.max(-1, dotProduct(aim, dir)))); + const turn = + Math.hypot(...across) > 1e-6 + ? scaleVector(normalize(across), (vNow * angle) / STEER) + : ([0, 0, 0] as V); + let lift = addVectors( + subtractVectors([0, G, 0], scaleVector(dir, G * dir[1])), + turn, + ); + const load = Math.hypot(...lift); + if (load > MOST_LOAD * G) lift = scaleVector(lift, (MOST_LOAD * G) / load); + // Drag on the glide polar: least at the best glide speed. + const ratio = (vNow / BEST_GLIDE) ** 2; + const drag = ((G / LIFT_TO_DRAG) * (ratio + 1 / ratio)) / 2; + // Flapping: to hold his speed when slow, and to carry him up a climb. + const thrust = Math.max( + vNow < SLOWEST ? THRUST + (SLOWEST - vNow) * 2 : 0, + dir[1] > 0.05 ? G * dir[1] * 0.9 : 0, + ); + const accel = addVectors( + addVectors(lift, [0, -G, 0]), + scaleVector(dir, thrust - drag), + ); + v = addVectors(v, scaleVector(accel, FLY_STEP)); + p = addVectors(p, scaleVector(v, FLY_STEP)); + points.push(p); + speeds.push(Math.hypot(...v)); + } + return { points, speeds }; +} + +/** A path along flown points. */ +function flownPath(points: V[], speeds: number[]): Path { + const dist = [0]; + for (let i = 1; i < points.length; i++) { + dist.push( + dist[i - 1]! + Math.hypot(...subtractVectors(points[i]!, points[i - 1]!)), + ); + } + return { + points, + dist, + length: dist[dist.length - 1]!, + speed: speeds[0]!, + speeds, + slowNear: false, + fadeIn: true, + soar: false, + rollAt: -1, + }; +} + /** Where the header and cards usually sit on screen, for a fly-in with none * given (after switching sky, when the cards are already up). */ const USUAL_TARGETS: ScreenBox[] = [ @@ -833,36 +1057,40 @@ function entrance(view: RavenView): Path { ); } - // In over the viewer's shoulder, overtaking him: just past the top-left - // corner and so close that the wings (reaching 0.6 m from his middle) - // start out of frame too, flying mostly away so that he grows out of the - // corner and draws off smoothly rather than darting across. - const near = randomBetween(1.3, 1.6); + // In over the top edge at the far left of the screen, close over the + // viewer's shoulder, wings at full span: his middle just above the frame + // so that his wings are in view from the start and he comes into it + // while still under 2 m off, big on the screen. + // Never wider than about half the screen (his span is 1.2 m), so on a + // narrow screen he comes in a little farther off. + const near = Math.max(randomBetween(1.25, 1.45), 1.2 / (2 * tanX * 0.5)); const start = pointAtDepth( - -(1 + 0.75 / (near * tanX)) * randomBetween(1, 1.08), - (1 + 0.75 / (near * view.tanY)) * randomBetween(0.9, 1), + randomBetween(-0.85, -0.65), + 1 + 0.4 / (near * view.tanY), near, ); - // One long, straight-ish glide out over the title to above the first - // card, a few powerful wingbeats carrying him. + // He swoops down and away over the title and through the first card to + // the bottom of his dive, low over the water. + const tall = horizon > -0.3; const [x0, y0, x1, y1] = card; - const glide = lowPoint( - lerpNumber(x0, x1, randomBetween(0.35, 0.75)), - lerpNumber(y0, y1, randomBetween(0.55, 0.95)), - randomBetween(7, 10), + const dip = lowPoint( + lerpNumber(x0, x1, randomBetween(0.3, 0.7)), + tall + ? lerpNumber(y0, y1, randomBetween(0.1, 0.4)) + : Math.max(horizon + 0.03, -0.85), + randomBetween(9, 13), ); // Then the long swoop: down past the cards and low over the water toward // the bottom-right corner. On a wide screen the horizon is near the // bottom, so he stays just above it; on a tall one the cards are over // the water and he comes down close, skimming it, to pass below them. - const tall = horizon > -0.3; const lowY = Math.max(-0.92, randomBetween(-0.88, -0.6) - (stoop ? 0.08 : 0)); const low = lowPoint( randomBetween(0.35, 0.75), tall ? lowY : Math.max(lowY, horizon + 0.04), randomBetween(14, 20) * (stoop ? 0.75 : 1), // Always on away from the viewer, never back toward him. - dotProduct(glide, fwd) + 3, + dotProduct(dip, fwd) + 3, ); // Then out of the swoop, still low, banking the way he leaves... const exit = EXITS[Math.floor(Math.random() * EXITS.length)]!; @@ -891,11 +1119,9 @@ function entrance(view: RavenView): Path { } const far = distantPoint(); - const through: V[] = [start, glide]; + const through: V[] = [start, dip]; if (flourish === "weave") { - through.push( - besideLine(lerpVectors(glide, low, 0.5), glide, low, side * 0.2), - ); + through.push(besideLine(lerpVectors(dip, low, 0.5), dip, low, side * 0.2)); } through.push(low, turning, far); @@ -945,15 +1171,22 @@ function entrance(view: RavenView): Path { ), ); - // An easy glide, gathering a little speed as he goes down into the swoop. - const path = curve(through, randomBetween(8, 9), false, 0); - path.startSpeed = randomBetween(6.5, 7); - path.launch = 15; + // Flown, not drawn: his speed comes from gravity and his height, his + // turns from what his wings bear. Every waypoint but the first is kept + // clear of the skyline first, so his line does not need lifting after. + const { points, speeds } = fly( + [start, ...through.slice(1).map((q) => clearOfSkyline(q, view, 1.5))], + randomBetween(6.5, 7.2), + LOW - view.eye, + isInFrame, + ); + const path = flownPath(points, speeds); + path.slowNear = true; path.fadeIn = false; - // The roll comes early in the swoop, while he is still near. + // The roll comes as he pulls out of the dive. if (flourish === "roll") { path.rollAt = - Math.hypot(...subtractVectors(glide, start)) + randomBetween(2, 5); + Math.hypot(...subtractVectors(dip, start)) + randomBetween(2, 5); } return path; } @@ -1029,6 +1262,7 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { // No flight fits this view: try again shortly rather than leave the // sky empty for a whole gap. if (!path) return bird.reset(randomBetween(1, 2)); + path = keptClearOfSkyline(path, view); bird.entering = flyIn; flyIn = false; s = 0; @@ -1043,6 +1277,7 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { rollT = -1; up = null; } + dt *= timeScaleAt(path, s); const speed = speedAt(path, s); s += speed * dt; if (s >= path.length) return bird.reset(); @@ -1116,6 +1351,8 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { } let flapping = wantFlap && !rolling; if (!rolling && fwd[1] > 0.07) flapping = true; + // On a flown path he flaps whenever he needed the thrust. + if (!rolling && path.speeds && speed < SLOWEST + 0.3) flapping = true; if (fwd[1] < -0.07) flapping = false; if (flapping && flap === 0) phase = LEVEL_PHASE; if (flapping) flapTarget = 1; @@ -1139,7 +1376,8 @@ export function raven(timing: RavenTiming = RAVEN_TIMING): Raven { smoothstep((-fwd[1] - 0.06) / 0.14), rolling ? 0.6 : 0, ); - rest = blendPoses(rest, TUCK, tuck); + // On the fly-in's swoop the wings stay half open, broad on screen. + rest = blendPoses(rest, TUCK, tuck * (bird.entering ? 0.3 : 1)); const pose = flap > 0 ? blendPoses(rest, beat(phase), smoothstep(flap)) : rest; diff --git a/apps/site/src/scripts/aurora.worker.ts b/apps/site/src/scripts/aurora.worker.ts index 389d24c..8b9455f 100644 --- a/apps/site/src/scripts/aurora.worker.ts +++ b/apps/site/src/scripts/aurora.worker.ts @@ -744,8 +744,12 @@ function start(init: Extract): void { if (painted[i]) return; const [x, y, w, h] = box; // His body reaching it, not just a wing tip: his middle within a - // quarter of his size of the box. - const [mx, my] = [(bx1 - bx0) / 4, (by1 - by0) / 4]; + // quarter of his size of the box. The header, a thin strip across the + // top, he sweeps with a wing as he comes in: any of him will do. + const [mx, my] = + i === 0 + ? [(bx1 - bx0) / 2, (by1 - by0) / 2] + : [(bx1 - bx0) / 4, (by1 - by0) / 4]; const over = at[0] > x - mx && at[0] < x + w + mx && @@ -1268,10 +1272,22 @@ function start(init: Extract): void { } } + // Whether the fly-in can run at 60 fps here, and the time between its + // frames lately (ms). + let smooth = true; + let paceMs = FLY_IN_FRAME_MS; function onFrame(stamp: number): void { raf = nextFrame(onFrame); const elapsed = stamp - last; - if (elapsed < (bird.entering ? FLY_IN_FRAME_MS : FRAME_MS) - 2) return; + // The fly-in at 60 fps while he is close and crossing the screen fast, + // and only while frames keep up: an uneven 60 looks worse than a steady + // 30. + const fast = bird.entering && smooth && Math.hypot(...bird.position) < 15; + if (elapsed < (fast ? FLY_IN_FRAME_MS : FRAME_MS) - 2) return; + if (fast && last && elapsed < 200) { + paceMs = paceMs * 0.9 + elapsed * 0.1; + if (paceMs > 22) smooth = false; + } if (last) { const dt = Math.min(elapsed, 100) / 1000; t += dt;