Longplay
aadmin2026-11-05 发布Site模板2 阅读

Longplay

类型:Site模板 · 收录:2026-11-05 01:12:53 · 源码:会员可见

[hidecontent type="vip1"]
# Recreate this site as a single HTML file: LONG.PLAY — Tape doesn't last.

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline in a
`<script type="module">`. **three.js and Lenis are the only external code**, pulled through an
import map; the spring solver, the shared ticker, the word-by-word blur reveal, the glass pills, the
viewfinder frame and the counting curtain are all written by hand. Hardcode every value given here
as a fixed constant.

Where this document quotes source verbatim it is the project's own TypeScript — **strip the type
annotations and use it as it stands**. Every number in it is the shipped default. Nothing in it is
a suggestion.

## What it is

A **single full-screen hero** for **long.play**, a service that digitises VHS, Hi8, Super 8 and
MiniDV tapes. The whole page is one viewport: a **WebGL meadow** — 480,000 instanced blades of blue
grass on rolling seeded dunes under a procedural cumulus sky, a warm low sun off to the side, a
depth-of-field composite with a punchy split-tone grade and a rounded-box vignette — with a
**floating CRT television** standing on the crest ahead, its screen a hot orange picture that
**lights the grass beneath it** and blooms over its own bezel. Cyan motes drift and rise over the
field. The camera is placed once in the canopy and sways a few centimetres like a held shot. The
**cursor bends the grass**: a decaying trail map on the GPU remembers where the pointer went and a
per-texel spring gives the blades inertia, so a sweep leaves a wake that swings back and settles;
the television turns to follow the cursor, never showing its back.

Over the canvas sits a **viewfinder-frame UI** drawn in white on the scene: a 65px header with the
`long.play` wordmark, four frosted-glass nav pills centred and a "Get started" pill on the right;
hairlines 65px from the top and 62px from the bottom, verticals 24px in from each side, an
18px white bracket in every corner; the `<h1>` — **"Tape doesn't last."** in Dancing Script at 80px
over **"The players are going too."** in Open Sans at 56px, dimmed; a centred 614px body line;
a cream frosted **"Start with one tape"** capsule with a black icon tile; and a 62px frosted band
along the foot carrying a shield icon and "PRIVACY-FIRST. NO DATA RESOLD."

The page opens behind a **white curtain** carrying a 180px Dancing Script percentage: a pale ghost
of the number, filled from the bottom up by a blue-to-lilac gradient clipped to the real load
progress, climbing at most 62% a second so it always takes ~1.6 s. When the scene has drawn its
first complete frame the curtain slides up on an overdamped spring, a **gust** sweeps the field
(a shared 32° lean with a long wave rolling through it, calming over ~6 s), and the UI **blurs in**
in a fixed order: frame hairlines, logo, nav pills, header action, headline word by word,
sub-headline, body, the capsule (surface, label, icon), the band, the trust line.

Everything is written to a **1440×800 board** in `rem` with the root font-size scaling with the
viewport, so every rem below is one board pixel at 1440. Below 640px the nav hides and the two
headline sizes halve — there is no phone design; that is the project's own adaptation.

## Page shell & libraries

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>long.play — VHS, Hi8, Super 8 & MiniDV digitisation</title>
<meta name="description" content="We scan VHS, Hi8, Super 8 and MiniDV frame by frame, correct the color by hand, and send back files you'll still be able to open in twenty years.">
<meta name="theme-color" content="#005bdb">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&family=Mulish:wght@400&family=Onest:wght@400&family=Open+Sans:wght@400&display=swap" rel="stylesheet">
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.1/examples/jsm/",
    "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs"
  }
}
</script>
```

**three is 0.160.1 — exactly, not newer.** The blade and ground shaders end with
`#include <tonemapping_fragment>` and `#include <colorspace_fragment>`, the sky and screen with
`<colorspace_fragment>` alone; those chunk names exist from r152 and the scene was tuned against
r160's ACES curve, `HalfFloatType` targets and `compileAsync`. Imports: `GLTFLoader` from
`three/addons/loaders/GLTFLoader.js`, `DRACOLoader` from `three/addons/loaders/DRACOLoader.js`.
Nothing else from the addons is used — the depth of field, the bloom, the bend map and the spring
pass are all hand-written full-screen passes on one quad.

**`tv.glb` is Draco-compressed and will not parse without a `DRACOLoader`.** Its decoder comes
from the same pinned three build on the CDN, never from the bucket:
`https://cdn.jsdelivr.net/npm/three@0.160.1/examples/jsm/libs/draco/gltf/` (set with `setDecoderPath`).

**Every texture is fetched cross-origin from the bucket** and read by the GPU, so
`THREE.TextureLoader` must run with `crossOrigin = "anonymous"` (its default — do not change it) and
`GLTFLoader` likewise. **A tainted or failed blade alpha map decodes as 0, every fragment hits
`discard`, and the field silently vanishes leaving a bare hill under a perfect sky** — if a fetch
fails, show a visible banner naming the URL rather than a blank meadow.

### Fonts

All four come from Google Fonts; nothing is self-hosted.

```css
:root {
  --font-onest: "Onest", system-ui, sans-serif;            /* bound as --font-sans; nothing visible uses it */
  --font-mulish: "Mulish", system-ui, sans-serif;          /* --font-ui: nav pills, header action, trust line */
  --font-open-sans: "Open Sans", system-ui, sans-serif;    /* --font-body: logo, sub-headline, body, capsule */
  --font-dancing-script: "Dancing Script", cursive;        /* --font-script: the headline and the counter, weight 700 */
}
```

`font-display: swap` everywhere. The curtain covers the page for at least 1.8 s, so nothing
visible reflows on font arrival.

### Smooth scroll

The page is one screen tall and does not scroll, but the shell still runs Lenis exactly as the
project does — `new Lenis({ smoothWheel: true })` on `window.lenis`, `lenis.raf(time)` called
first in your one `requestAnimationFrame` loop — so an anchor or a taller viewport behaves
identically. `history.scrollRestoration` is left alone; the page starts at `window.scrollTo(0, 0)`.

### The root font-size

The design is 1440 wide. The root scales with the viewport in four bands, and above 1920 grows
damped:

```css
html { font-size: 16px; }
@media (max-width: 1920px) { html { font-size: 0.833333vw; } }  /* 16 * 100 / 1920 */
@media (max-width: 1440px) { html { font-size: 1.111111vw; } }  /* 16 * 100 / 1440 */
@media (max-width: 1024px) { html { font-size: 1.5625vw; } }    /* 16 * 100 / 1024 */
@media (max-width: 640px)  { html { font-size: 4.444444vw; } }  /* 16 * 100 / 360 */
```

```js
// Above 1920 the root grows, damped by 0.6666; at or below it the media queries drive.
const FONT_BASE = 16, BASE_WIDTH = 1920, COEF = 0.6666;
const applyRoot = () => {
  const w = window.innerWidth;
  const size = FONT_BASE - (FONT_BASE * (((BASE_WIDTH - w) / BASE_WIDTH) * 100 * COEF)) / 100;
  if (size > FONT_BASE) document.documentElement.style.setProperty("font-size", size + "px");
  else document.documentElement.style.removeProperty("font-size");
};
applyRoot(); window.addEventListener("resize", applyRoot);
```

### Breakpoints and the class vocabulary used below

Layout is described in Tailwind v4 utility names, which translate directly: the spacing unit is
`0.25rem` (`px-6` = padding-inline 1.5rem = 24 board px, `h-16.25` = 4.0625rem = 65 px,
`w-153.5` = 38.375rem = 614 px, `size-4.5` = 1.125rem = 18 px, `top-5.75` = 1.4375rem = 23 px).
`sm:` means `@media (min-width: 640px)`, `max-sm:` its complement. `h-dvh` / `h-lvh` are the
dynamic and largest viewport heights. `translate`, `scale` and `rotate` are the individual CSS
properties, not `transform` functions. `backdrop-blur-glass` is `backdrop-filter: blur(0.25rem)`,
`backdrop-blur-band` is `blur(0.5rem)`.

## Fixed parameters — the whole token block, verbatim

Tier 1 and Tier 2 from `globals.css`, exactly as shipped, then the `@theme` bindings that give
each utility its name:

```css
/* ---------------------------------------------------------------------------
   TIER 1 — PRIMITIVES  (`--raw-*`)
   Raw literal values. The only tier allowed to contain a hex/px/ms literal.
   Never referenced in markup, never bound into @theme. Rename freely.
   Grammar: --raw-<category>-<name>[-<shade>]
   See obsidian/frontend/design-system.md → "Token naming convention".
   --------------------------------------------------------------------------- */
:root {
  --raw-color-white: #ffffff;
  --raw-color-neutral-100: #ededed;
  --raw-color-neutral-900: #171717;
  --raw-color-neutral-950: #0a0a0a;

  /* Sky horizon of the grass-field scene. Mirrors `colors.skyHorizon` in
     src/components/scene/grass-field/grass-field.config.ts — a GLSL uniform
     cannot read a CSS token, so the value is declared in both places; keep
     them in sync. */
  --raw-color-sky-horizon: #cfe4f5;
  /* Preloader palette — sampled from the author's reference (2026-09-14): a
     pale blue-white ghost counter, filled by a blue-to-lilac gradient. */
  --raw-color-counter-ghost: #f3f5fd;
  --raw-color-counter-blue: #3d5af1;
  --raw-color-counter-lilac: #dcbad8;

  /* Hero overlay palette — read from Figma (Get Layers, node 3255:783). */
  --raw-color-black: #000000;
  --raw-color-white-alpha-10: rgb(255 255 255 / 0.1);
  --raw-color-white-alpha-20: rgb(255 255 255 / 0.2);
  --raw-color-white-alpha-60: rgb(255 255 255 / 0.6);
  --raw-color-cream-alpha-5: rgb(255 251 246 / 0.05);
  --raw-color-cream-alpha-80: rgb(255 251 246 / 0.8);
  /* Hover states — no hover exists in Figma; these step the same two surfaces
     one notch brighter rather than introducing new colours. */
  --raw-color-cream: rgb(255 251 246);

  /* Type scale of the hero overlay (design px / 16 at the 1440 tier). */
  --raw-font-size-14: 0.875rem;
  --raw-font-size-18: 1.125rem;
  --raw-font-size-28: 1.75rem;
  --raw-font-size-40: 2.5rem;
  --raw-font-size-56: 3.5rem;
  --raw-font-size-80: 5rem;
  --raw-font-size-64: 4rem;
  --raw-font-size-180: 11.25rem;
  --raw-line-height-120: 1.2;

  --raw-radius-6: 0.375rem;
  --raw-radius-14: 0.875rem;
  --raw-radius-16: 1rem;

  --raw-blur-4: 0.25rem;
  --raw-blur-8: 0.5rem;

  --raw-duration-fast: 150ms;
  --raw-duration-normal: 250ms;
}

/* ---------------------------------------------------------------------------
   TIER 2 — SEMANTIC  (role names, no namespace prefix)
   Purpose, not appearance. Always `var(--raw-*)` — never a literal.
   This is the themeable layer: dark mode overrides happen HERE, which is why
   these are separate from the @theme bindings below (see the note there).
   Grammar: --<role>[-<variant>][-<state>]
   --------------------------------------------------------------------------- */
:root {
  --background: var(--raw-color-white);
  --foreground: var(--raw-color-neutral-900);

  /* Painted behind a WebGL canvas until its first frame lands, and left
     visible when a device has no WebGL at all. */
  --scene-backdrop: var(--raw-color-sky-horizon);

  /* UI drawn over the scene. Not overridden in dark mode: the scene behind it
     looks the same in either scheme, so the overlay must too. */
  --overlay-text: var(--raw-color-white);
  --overlay-text-muted: var(--raw-color-white-alpha-60);
  --overlay-hairline: var(--raw-color-white-alpha-20);
  --overlay-glass: var(--raw-color-white-alpha-10);
  --overlay-glass-hover: var(--raw-color-white-alpha-20);
  --overlay-band: var(--raw-color-cream-alpha-5);
  --overlay-action: var(--raw-color-cream-alpha-80);
  --overlay-action-hover: var(--raw-color-cream);
  --overlay-action-text: var(--raw-color-black);
  --overlay-action-icon: var(--raw-color-black);

  --preloader-surface: var(--raw-color-white);
  --preloader-counter: var(--raw-color-counter-ghost);
  --preloader-fill-start: var(--raw-color-counter-blue);
  --preloader-fill-end: var(--raw-color-counter-lilac);

  --type-label: var(--raw-font-size-14);
  --type-body: var(--raw-font-size-18);
  --type-subtitle: var(--raw-font-size-56);
  --type-display: var(--raw-font-size-80);
  --type-counter: var(--raw-font-size-180);
  /* ≤ 640 px tier (360 base): 180 would make "100%" nearly the full width. */
  --type-counter-compact: var(--raw-font-size-64);
  /* No phone frame exists in Figma. These halve the two headline sizes so the
     ≤640 px tier (base width 360) fits — an adaptation, not a design value. */
  --type-subtitle-compact: var(--raw-font-size-28);
  --type-display-compact: var(--raw-font-size-40);
  --line-height-copy: var(--raw-line-height-120);

  --pill-radius: var(--raw-radius-6);
  --action-radius: var(--raw-radius-16);
  --action-icon-radius: var(--raw-radius-14);

  --glass-blur: var(--raw-blur-4);
  --band-blur: var(--raw-blur-8);

  --duration-fast: var(--raw-duration-fast);
  --duration-normal: var(--raw-duration-normal);
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: var(--raw-color-neutral-950);
    --foreground: var(--raw-color-neutral-100);
  }
}
```

```css
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-scene-backdrop: var(--scene-backdrop);
  --font-sans: var(--font-onest);
  --font-ui: var(--font-mulish);
  --font-body: var(--font-open-sans);
  --font-script: var(--font-dancing-script);

  --color-overlay-text: var(--overlay-text);
  --color-overlay-text-muted: var(--overlay-text-muted);
  --color-overlay-hairline: var(--overlay-hairline);
  --color-overlay-glass: var(--overlay-glass);
  --color-overlay-glass-hover: var(--overlay-glass-hover);
  --color-overlay-band: var(--overlay-band);
  --color-overlay-action: var(--overlay-action);
  --color-overlay-action-hover: var(--overlay-action-hover);
  --color-overlay-action-text: var(--overlay-action-text);
  --color-overlay-action-icon: var(--overlay-action-icon);
  --color-preloader-surface: var(--preloader-surface);
  --color-preloader-counter: var(--preloader-counter);
  --color-preloader-fill-start: var(--preloader-fill-start);
  --color-preloader-fill-end: var(--preloader-fill-end);

  --text-label: var(--type-label);
  --text-body: var(--type-body);
  --text-subtitle: var(--type-subtitle);
  --text-display: var(--type-display);
  --text-counter: var(--type-counter);
  --text-counter-compact: var(--type-counter-compact);
  --text-subtitle-compact: var(--type-subtitle-compact);
  --text-display-compact: var(--type-display-compact);
  --leading-copy: var(--line-height-copy);

  --radius-pill: var(--pill-radius);
  --radius-action: var(--action-radius);
  --radius-action-icon: var(--action-icon-radius);

  --blur-glass: var(--glass-blur);
  --blur-band: var(--band-blur);

  /* Leading floor for clipped TextEngine reveals — see
     obsidian/frontend/text-engine.md → "Alignment & line-height". */
  --leading-display: 1.1;

  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
}
```

Plain body: `background: var(--background); color: var(--foreground); min-height: 100lvh;
width: 100%;` — no centring flex box.

## The motion engine you write

Every UI motion is a spring; nothing uses CSS `@keyframes`. The only CSS transitions are the hover
nudges named as such (`duration-[var(--duration-normal)]` with `cubic-bezier(0.2, 0, 0, 1)`).

### The spring solver

`@react-spring/web`'s integrator, per animated value: `tension`, `friction`, `mass: 1`, stepped at
1 ms inside each frame with the frame delta capped at 64 ms.

```js
// per 1 ms step
const springForce  = -tension * 0.000001 * (position - to);
const dampingForce = -friction * 0.001 * velocity;
velocity += (springForce + dampingForce) / mass;   // dt = 1
position += velocity;
// at rest when |velocity| < precision / 10 and |position - to| <= precision  (precision 0.01)
```

A spring value can be a number or a unit string (`"0%"`, `"blur(12px)"`): lerp the numbers inside
the string and keep its shape. A spring with `delay` waits that many ms before starting toward its
new target. `y` and `scale`/`scaleX`/`scaleY` are written as `translate` / `scale` properties
(`y: 18` → `translate: 0 18px`), `filter` as a `filter` string, `opacity` as opacity.

**`Spring` element semantics**: an element with `from` and `to`; while `enabled` it animates to
`to` (after `delayIn`), while not it animates back to `from` (after `delayOut`). `mode="once"`:
once it has played it stays played. Setting the same target twice is a no-op — a re-render never
restarts a spring. The element is rendered in its `from` state until enabled (so the hero is
server-rendered at opacity 0 under the curtain).

### The shared ticker — verbatim

```ts
/**
 * Shared animation ticker.
 *
 * A single, app-wide `requestAnimationFrame` loop. Every animation hook that
 * needs per-frame work subscribes here instead of starting its own rAF — so a
 * page with N scroll-driven components runs **one** loop, not N.
 *
 * The loop is reference-counted: it starts on the first subscriber and stops
 * (`cancelAnimationFrame`) when the last one leaves, so an idle page costs
 * nothing. Each subscriber is throttled independently by its own framerate.
 *
 * 📖 Docs: obsidian/frontend/animation-system.md
 */

export type TickerCallback = (time: number) => void;

interface Subscriber {
  callback: TickerCallback;
  /** Read live each frame so framerate prop changes take effect. */
  getFramerate: () => number;
  /** Timestamp of this subscriber's last invocation. */
  last: number;
}

const subscribers = new Set<Subscriber>();
let rafId: number | null = null;

const frame = (time: number): void => {
  // Snapshot first: a callback may subscribe/unsubscribe during iteration.
  for (const sub of [...subscribers]) {
    if (!subscribers.has(sub)) continue;
    if (time - sub.last <= sub.getFramerate()) continue;
    sub.last = time;
    try {
      sub.callback(time);
    } catch (error) {
      // Isolate failures — one bad subscriber must not kill the shared loop.
      console.error("[ticker] subscriber threw:", error);
    }
  }
  rafId = requestAnimationFrame(frame);
};

const start = (): void => {
  if (rafId === null) rafId = requestAnimationFrame(frame);
};

const stop = (): void => {
  if (rafId !== null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
};

/**
 * Subscribe a callback to the shared rAF loop.
 *
 * @param callback - invoked with the rAF timestamp, no more often than `getFramerate()` ms apart.
 * @param getFramerate - returns the current minimum gap (ms) between invocations; read live every frame.
 * @returns an unsubscribe function.
 */
export const subscribeToTicker = (
  callback: TickerCallback,
  getFramerate: () => number,
): (() => void) => {
  const subscriber: Subscriber = {
    callback,
    getFramerate,
    last: performance.now(),
  };
  subscribers.add(subscriber);
  start();

  return () => {
    subscribers.delete(subscriber);
    if (subscribers.size === 0) stop();
  };
};
```

Lenis subscribes first with `() => 0` (never throttled); the preloader's counter with `() => 0`;
the scene with `() => frameBudgetMs(tier)`.

### `Reveal` — the entrance presets, verbatim

Every hero element enters through one of these, keyed on the store's `revealed` flag (below), with
`mode="once"` and its own `delay` in ms **after the curtain starts leaving**.

```ts
/** Soft, slightly overdamped — settles in ~1.2 s without overshoot. */
const SMOOTH = { tension: 64, friction: 22 };
/** For long thin lines, which read better a little slower. */
const DRAW = { tension: 36, friction: 18 };

const PRESETS = {
  /** Default content entrance: rise, unblur, fade. */
  rise: {
    from: { opacity: 0, y: 18, filter: "blur(12px)" },
    to: { opacity: 1, y: 0, filter: "blur(0px)" },
    config: SMOOTH,
  },
  /** Same without travel — for things pinned to an edge. */
  focus: {
    from: { opacity: 0, filter: "blur(10px)" },
    to: { opacity: 1, filter: "blur(0px)" },
    config: SMOOTH,
  },
  /** Opacity only — for full-bleed frosted panels, where scale or blur would show. */
  fade: {
    from: { opacity: 0 },
    to: { opacity: 1 },
    config: SMOOTH,
  },
  /**
   * Glass surfaces. No `filter` on purpose: a filtered element (or ancestor)
   * becomes a backdrop root, and the frosted `backdrop-blur` would stop
   * sampling the scene behind it.
   */
  surface: {
    from: { opacity: 0, scale: 0.92 },
    to: { opacity: 1, scale: 1 },
    config: SMOOTH,
  },
  /** Horizontal hairline drawing out from its centre. */
  drawX: {
    from: { opacity: 0, scaleX: 0 },
    to: { opacity: 1, scaleX: 1 },
    config: DRAW,
  },
  /** Vertical hairline drawing down from the top. */
  drawY: {
    from: { opacity: 0, scaleY: 0 },
    to: { opacity: 1, scaleY: 1 },
    config: DRAW,
  },
  /** Corner brackets: settle in from slightly larger. */
  bracket: {
    from: { opacity: 0, scale: 1.6, filter: "blur(6px)" },
    to: { opacity: 1, scale: 1, filter: "blur(0px)" },
    config: SMOOTH,
  },
};
```

**The glass rule is load-bearing.** A frosted pill is two elements: an absolutely positioned
`surface` span (the translucent fill with `backdrop-filter`) and the label over it. The surface
animates with `surface` (opacity + scale only), the label with `focus` (opacity + blur). Never put
`filter` or `opacity < 1` on the pill itself or on any ancestor of the surface — it becomes a
backdrop root and the frosting stops sampling the scene behind it.

### `RevealText` — word-by-word blur reveal

The headline, sub-headline and body use it. Split the string on spaces into **words**; the
container is `display: flex; flex-wrap: wrap; column-gap: 0.3em` (so it is centred with
`justify-content: center`, never `text-align`), each word an `inline-block` span with its own
spring. Render the full string once in a visually-hidden span for assistive technology and mark
the animated words `aria-hidden="true"`. **No `overflow` clip anywhere** — the clip box shaved the
script's swashes and the sans descenders; a blur reveal needs no mask.

```js
const wordOut = { opacity: 0, y: 16, filter: "blur(12px)" };
const wordIn  = { opacity: 1, y: 0,  filter: "blur(0px)" };
const wordConfig = SMOOTH;                       // { tension: 64, friction: 22 }
// word i starts at delayIn + i * stagger, once the store's `revealed` is true; plays once.
```

### The page-ready store

Three flags, shared by the curtain, the scene and every entrance:

```js
const store = {
  progress: 0,            // 0..1, never goes backwards
  ready: false,           // the scene has drawn a complete frame
  revealed: false,        // the curtain has started leaving — entrances key off this
  setProgress(p) { store.progress = Math.max(store.progress, Math.min(p, 1)); },
  markReady()    { store.ready = true; store.progress = 1; },
  reveal()       { store.revealed = true; /* start every Reveal / RevealText, fire scene.gust() */ },
};
```

The scene reports `onLoadProgress(fraction)` — mapped as `setProgress(1 - 0.85 + fraction * 0.85)`
(the first 15% stands for the script chunk, which in this single file you may count as done on
module evaluation) — and `onReady()` → `markReady()`.

## The page — one screen

```html
<body class="font-sans">
  <!-- the curtain, fixed, z-50 — see "The preloader" -->
  <div class="relative h-dvh w-full overflow-hidden bg-scene-backdrop">
    <!-- the scene: lvh, not dvh — a collapsing mobile URL bar must not re-allocate the framebuffer -->
    <canvas id="field" aria-hidden="true"
            class="absolute inset-x-0 top-0 block h-lvh w-full transform-gpu backface-hidden"></canvas>
    <!-- the UI overlay: pointer events pass through to the canvas except on the links -->
    <div class="pointer-events-none absolute inset-0 flex flex-col">
      <header class="pointer-events-none relative h-16.25 w-full shrink-0">…</header>
      <main class="flex min-h-0 w-full flex-1 flex-col">
        <section aria-labelledby="hero-title" class="flex min-h-0 w-full flex-1 flex-col">
          <div class="pointer-events-none relative flex min-h-0 w-full flex-1 flex-col items-center px-6 pt-12 pb-10">
            <!-- the frame, the h1, the body, the capsule -->
          </div>
          <div class="pointer-events-none relative flex h-15.5 w-full shrink-0 items-center justify-center px-4">
            <!-- the band and the trust line -->
          </div>
        </section>
      </main>
    </div>
  </div>
</body>
```

The layout box is `dvh` (what is actually visible, so the band never hides behind a mobile URL
bar); the canvas is `lvh` (the largest viewport) and the overflow is clipped. `bg-scene-backdrop`
(`#cfe4f5`, the sky horizon) is painted behind the canvas until its first frame, and stays if there
is no WebGL. If `WebGLRenderer` construction throws, replace the canvas with
`<img src="https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/poster.webp" class="absolute inset-0 h-full w-full object-cover" alt="">`
and call `markReady()` at once.

Every interactive element inside the overlay carries `pointer-events-auto`; everything else lets
the pointer through, because the grass bend and the television both listen for `pointermove` on
the canvas.

## The header — 65 px, three groups

```html
<header class="pointer-events-none relative h-16.25 w-full shrink-0">
  <a href="/" class="group pointer-events-auto absolute top-5.75 left-6 font-body text-body leading-none text-overlay-text">
    <span class="reveal block" data-preset="rise" data-delay="350">
      <span>long.</span><span class="text-overlay-text-muted transition-colors duration-[var(--duration-normal)] ease-entrance group-hover:text-overlay-text">play</span>
    </span>
  </a>
  <nav aria-label="Primary" class="pointer-events-auto absolute top-4 left-1/2 -translate-x-1/2 max-sm:hidden">
    <ul class="flex gap-1">
      <!-- four pills, w-25 each: SERVICES #services · FORMATS #formats · PRICING #pricing · Contacts #contacts -->
    </ul>
  </nav>
  <!-- header action: a pill, absolute top-4 right-6 w-32.5: "Get started" #start -->
</header>
```

**Entrance timings (ms after `reveal()`):** logo 350 (`rise`); nav pill *i* at 450 + i·90 —
surface with `surface`, label with `focus` 120 ms later; header action surface 850, label 970.

**The glass pill** (`GlassPillLink`):

```html
<a href="#services" class="group relative flex items-center justify-center rounded-pill px-3 py-2 font-ui text-label leading-copy whitespace-nowrap text-overlay-text uppercase w-25">
  <span aria-hidden="true" class="reveal pointer-events-none absolute inset-0 block rounded-pill bg-overlay-glass backdrop-blur-glass transition-colors duration-[var(--duration-normal)] ease-entrance group-hover:bg-overlay-glass-hover group-focus-visible:bg-overlay-glass-hover"></span>
  <span class="reveal relative block">SERVICES</span>
</a>
```

Width is set by the caller (100 px nav, 130 px action); the label is centred inside it; 14px
Mulish uppercase, line-height 1.2, `rgba(255,255,255,.1)` fill with a 4px backdrop blur, radius
6px, hover to 20% white. Focus-visible: a 2px outline in `--overlay-text`, offset 2px.

## The hero — the frame, the h1, the body, the capsule, the band

### The viewfinder frame — `HeroFrame`

Decorative, `aria-hidden`, absolutely filling the padded hero box. Two horizontal hairlines
(`absolute inset-x-0 top-0 block h-px bg-overlay-hairline` and the same at `bottom-0`) draw with
`drawX` from their centre at **150 ms**. Inside an inner box `absolute inset-y-0 right-5.75 left-6`,
two vertical hairlines (`absolute inset-y-0 left-0 block w-px origin-top bg-overlay-hairline`, and
`right-0`) draw with `drawY` from the top at **450 ms**. Four corner brackets, `absolute block
size-4.5` at each corner of that inner box, enter with `bracket` at **1050 + i·70 ms** (top-right,
top-left, bottom-right, bottom-left). Each is the same 18×18 SVG — the exported vector is the
top-right corner and the other three are mirrors of it, the mirror on the image
(`-scale-x-100`, `-scale-y-100`, `-scale-100`) and the entrance scale on its wrapper so the two never
fight over one transform. The wrapper's `transform-origin` is its own corner
(`origin-top-right`, …).

```html
<img src="https://storage.getlayers.ai/assets/longplay-6cc706c729/hero/corner-bracket.svg" alt="" width="18" height="18" class="block size-4.5">
```

The bracket is `<path d="M0 0.5H17.5V18" stroke="white"/>` in an 18×18 viewBox.

### The `<h1>`

```html
<h1 id="hero-title" class="flex flex-col items-center text-overlay-text">
  <!-- RevealText, delay 600, stagger 110 -->
  <span class="justify-center text-center font-script text-display-compact leading-none font-bold sm:text-display">Tape doesn't last.</span>
  <!-- RevealText, delay 1000, stagger 70 -->
  <span class="justify-center text-center font-body text-subtitle-compact leading-none text-overlay-text-muted sm:text-subtitle">The players are going too.</span>
</h1>
```

Dancing Script 700 at 80px (40px under 640), line-height 1; Open Sans at 56px (28px under 640),
white 60%. Both sit at the top of the hero box: the box is `pt-12` (48px) under the 65px header,
so the headline's top lands at 113 board px and the sub-line at 193, as the design draws them.

### The body and the capsule

```html
<!-- RevealText tag="p", delay 1450, stagger 18 -->
<p class="mt-auto w-153.5 max-w-full justify-center text-center font-body text-body leading-copy text-overlay-text">We scan VHS, Hi8, Super 8 and MiniDV frame by frame, correct the color by hand, and send back files you'll still be able to open in twenty years.</p>

<a href="#start" class="group pointer-events-auto mt-8.25 relative flex items-center gap-6 rounded-action py-0.5 pr-0.5 pl-8">
  <span aria-hidden="true" class="reveal pointer-events-none absolute inset-0 block rounded-action bg-overlay-action backdrop-blur-band transition-colors duration-[var(--duration-normal)] ease-entrance group-hover:bg-overlay-action-hover group-focus-visible:bg-overlay-action-hover"></span> <!-- surface, 1750 -->
  <span class="reveal relative block"> <!-- focus, 1900 -->
    <span class="relative block font-body text-body leading-copy whitespace-nowrap text-overlay-action-text">Start with one tape</span>
  </span>
  <span class="reveal relative block shrink-0"> <!-- bracket, 2000 -->
    <span class="relative flex size-12 shrink-0 items-center justify-center overflow-clip rounded-action-icon bg-overlay-action-icon">
      <img src="https://storage.getlayers.ai/assets/longplay-6cc706c729/hero/approximately-equal.svg" alt="" width="24" height="24" class="block size-6 transition-transform duration-[var(--duration-normal)] ease-entrance group-hover:scale-115">
    </span>
  </span>
</a>
```

`mt-auto` pins the body to the bottom of the flex column, so at 800 tall it sits at 570 and the
capsule at 646 (52px tall: a 48px black tile with 2px padding); on other heights the frame
stretches between the headline group and this group. The capsule's surface is
`rgb(255 251 246 / 0.8)` with an 8px backdrop blur and a 16px radius, going fully opaque cream on
hover; the tile is black, radius 14px; the icon grows to 1.15 on hover. The icon is two round-capped
strokes, `#FFB79F` and `#9FABFF`:

```svg
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M4 7.927C6.66667 4.9281 9.33333 5.80578 12 8C14.6667 10.1942 17.3333 11.0719 20 8.073" stroke="#FFB79F" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 15.927C6.66667 12.9281 9.33333 13.8058 12 16C14.6667 18.1942 17.3333 19.0719 20 16.073" stroke="#9FABFF" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
```

### The band and the trust line

```html
<div class="pointer-events-none relative flex h-15.5 w-full shrink-0 items-center justify-center px-4">
  <span aria-hidden="true" class="reveal absolute inset-0 block bg-overlay-band backdrop-blur-band"></span> <!-- fade, 2150 -->
  <p class="reveal relative flex items-center gap-2 font-ui text-label leading-copy whitespace-nowrap text-overlay-text-muted uppercase sm:text-body"> <!-- rise, 2300 -->
    <img src="https://storage.getlayers.ai/assets/longplay-6cc706c729/hero/security.svg" alt="" width="30" height="30" class="block size-7.5 shrink-0">
    Privacy-first. no data resold.
  </p>
</div>
```

The band is 62px, `rgb(255 251 246 / 0.05)` with an 8px backdrop blur; the frame's bottom hairline
sits on its top edge. The trust line is Mulish uppercase, 18px from 640 up and 14px below, white
60%. The shield:

```svg
<svg width="30" height="30" viewBox="0 0 30 30" fill="none"><path d="M23.386 4.36933C21.0206 3.19243 18.1261 2.50015 15 2.50015C11.8739 2.50015 8.97938 3.19243 6.61395 4.36934C5.45398 4.94648 4.87399 5.23505 4.312 6.14239C3.75 7.04972 3.75 7.92825 3.75 9.68533V14.0465C3.75 21.1507 9.42795 25.1006 12.7163 26.7924C13.6334 27.2643 14.0919 27.5001 15 27.5001C15.9081 27.5001 16.3666 27.2643 17.2836 26.7924C20.572 25.1006 26.25 21.1507 26.25 14.0465V9.68534C26.25 7.92826 26.25 7.04972 25.688 6.14239C25.126 5.23505 24.546 4.94648 23.386 4.36933Z" fill="white" fill-opacity="0.3"/><path d="M15 9.875C15.5303 9.875 16.039 10.0858 16.4141 10.4609C16.789 10.8359 17 11.3445 17 11.875C17 12.3392 16.8385 12.7867 16.5469 13.1426L16.4141 13.2891C16.2578 13.4454 16.0773 13.5711 15.8828 13.667L16.6709 17.3428C16.7184 17.5642 16.6629 17.7955 16.5205 17.9717C16.3781 18.1477 16.1639 18.25 15.9375 18.25H14.0625C13.8361 18.25 13.6219 18.1477 13.4795 17.9717C13.3371 17.7955 13.2816 17.5642 13.3291 17.3428L14.1162 13.667C13.9803 13.5999 13.8509 13.519 13.7324 13.4219L13.5859 13.2891C13.211 12.9141 13 12.4055 13 11.875C13 11.3445 13.211 10.8359 13.5859 10.4609C13.961 10.0858 14.4697 9.875 15 9.875ZM14.9902 16.75H15.0098L15 16.7041L14.9902 16.75Z" fill="white"/></svg>
```

### The full entrance schedule, in one place

| ms after `reveal()` | what | preset |
|---|---|---|
| 150 | top and bottom hairlines | drawX |
| 350 | logo | rise |
| 450 | vertical hairlines | drawY |
| 450 + i·90 | nav pill i surface (label +120) | surface / focus |
| 600 + i·110 | headline word i | RevealText |
| 850 | header action surface (label 970) | surface / focus |
| 1000 + i·70 | sub-headline word i | RevealText |
| 1050 + i·70 | corner bracket i | bracket |
| 1450 + i·18 | body word i | RevealText |
| 1750 / 1900 / 2000 | capsule surface / label / icon tile | surface / focus / bracket |
| 2150 | band | fade |
| 2300 | trust line | rise |

## The preloader — a white curtain with a filling count

```html
<div id="curtain" class="fixed inset-0 z-50 flex items-center justify-center bg-preloader-surface">
  <div role="progressbar" aria-label="Loading long.play" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
       class="relative font-script text-counter-compact leading-none font-bold whitespace-nowrap select-none sm:text-counter">
    <!-- reserves the widest label so the centred count never shuffles sideways; the padding
         gives the script's ascenders and swashes room inside the box the clip is measured against -->
    <span aria-hidden="true" class="invisible block px-[0.12em] py-[0.14em]">100%</span>
    <span id="ghost" aria-hidden="true" class="absolute inset-0 flex items-center justify-center text-preloader-counter">0%</span>
    <span id="fill" aria-hidden="true" style="clip-path: inset(100% 0 0 0); background-size: 100% 0.01%"
          class="absolute inset-0 flex items-center justify-center bg-linear-to-b from-preloader-fill-start to-preloader-fill-end bg-bottom bg-no-repeat bg-clip-text text-transparent">0%</span>
  </div>
</div>
```

The count is drawn twice: a pale ghost (`#f3f5fd`), and over it a gradient copy
(`linear-gradient(to bottom, #3d5af1, #dcbad8)` as `background-clip: text`) **clipped from the bottom
up** to the shown progress. The gradient's `background-size` height is the filled percentage,
anchored to the bottom, so its blue edge always rides the fill line. 180px Dancing Script 700
(64px under 640).

The constants and the per-frame logic, verbatim (run it from the ticker with `() => 0`):

```ts
/** Seconds for the shown progress to close ~63 % of the gap to the real one. */
const FOLLOW_SECONDS = 0.3;
/**
 * Fastest the counter may climb, in progress per second. A fast load would
 * otherwise jump 0 → 100 in a few frames; the count is the show, so it always
 * takes at least ~1.6 s.
 */
const MAX_RATE = 0.62;
/** Before anything reports, the counter creeps to this, so it never looks frozen. */
const CREEP_LIMIT = 0.18;
const CREEP_MS = 2400;
/** Real progress below `ready` is scaled into this, so 100 % means done. */
const PRE_READY_CEILING = 0.92;
/** A beat at 100 % before the curtain lifts. */
const HOLD_MS = 260;
/** Unmount after the curtain has cleared the viewport. */
const EXIT_UNMOUNT_MS = 1700;

/** Overdamped and unhurried — the curtain accelerates away with no bounce. */
const CURTAIN_CONFIG = { tension: 58, friction: 22 };
```

```ts
    const startedAt = performance.now();
    let lastFrame = startedAt;
    let shown = 0;
    let completedAt: number | null = null;
    let left = false;
    let lastPercent = -1;

    const unsubscribe = subscribeToTicker(() => {
      const now = performance.now();
      const delta = Math.min((now - lastFrame) / 1000, 0.1);
      lastFrame = now;
      const elapsed = now - startedAt;

      const store = usePreloader.getState();
      if (!store.ready && elapsed > timeout) store.markReady();

      const creep = Math.min(elapsed / CREEP_MS, 1) * CREEP_LIMIT;
      const target = store.ready
        ? 1
        : Math.max(store.progress * PRE_READY_CEILING, creep);
      const eased = (target - shown) * (1 - Math.exp(-delta / FOLLOW_SECONDS));
      shown += Math.min(eased, MAX_RATE * delta);
      if (store.ready && shown > 0.995) shown = 1;

      const percent = Math.round(shown * 100);
      if (percent !== lastPercent) {
        lastPercent = percent;
        const label = `${percent}%`;
        if (ghostRef.current) ghostRef.current.textContent = label;
        if (fillRef.current) fillRef.current.textContent = label;
        rootRef.current?.setAttribute("aria-valuenow", percent.toString());
      }
      if (fillRef.current) {
        const filled = shown * 100;
        fillRef.current.style.clipPath = `inset(${100 - filled}% 0 0 0)`;
        // The gradient spans only the filled part, anchored to the bottom, so
        // the fill line is always its first colour.
        fillRef.current.style.backgroundSize = `100% ${Math.max(filled, 0.01)}%`;
      }

      if (shown === 1 && completedAt === null) completedAt = now;
      if (
        !left &&
        completedAt !== null &&
        now - completedAt >= HOLD_MS &&
        elapsed >= minDuration
      ) {
        left = true;
        setExiting(true);
        store.reveal();
      }
```

`minDuration` is **1800** and `timeout` **12000**. When it leaves: the whole curtain animates
`y: "0%" → "-100%"` on `{ tension: 58, friction: 22 }` (overdamped, no bounce), becomes
`pointer-events: none`, and is removed from the DOM **1700 ms** later. `store.reveal()` fires the
moment it starts leaving — that is what starts every entrance and the gust, so the UI is already
blurring in while the curtain is still climbing.

## The scene — the grass field, the sky, the television

Five modules, quoted whole. Read them in this order; the builder is last because it uses the
others. The tuning defaults are the ones in `GRASS_FIELD_CONFIG`; nothing here reads anything
else at run time.

### Device tiers — `device.ts`, verbatim

```ts
/**
 * Device tiering — the single source of every per-device scene decision.
 *
 * Ported from the `optimize-3d-scene` skill's canonical `device.ts`
 * (helion/mycelia). DPR, frame budget, pointer listening, instance and particle
 * counts and post-pass budgets all read the tier from here, so they can never
 * drift apart.
 *
 * 📖 Docs: obsidian/workflows/optimize-3d-scene.md
 */

export type DeviceTier = "mobile" | "tablet" | "desktop";

const TABLET_MAX = 1180;
const MOBILE_MAX = 768;

/**
 * The media-query half of the tier. Subscribe to it: DevTools emulation and a
 * mouse plugged into a tablet flip it without any `resize` event.
 */
export const COARSE_POINTER_QUERY = "(hover: none) and (pointer: coarse)";

export const isCoarsePointer = (): boolean =>
  typeof window !== "undefined" &&
  typeof window.matchMedia === "function" &&
  window.matchMedia(COARSE_POINTER_QUERY).matches;

/**
 * Read at scene construction and held in a mutable slot. Never recompute per
 * frame — but DO re-read it when the width or the pointer media query changes,
 * or a window dragged across a breakpoint keeps the phone tier on a desktop
 * viewport until reload.
 */
export const deviceTier = (): DeviceTier => {
  if (typeof window === "undefined") return "desktop";
  const width = window.innerWidth;
  if (width < MOBILE_MAX || isCoarsePointer()) return "mobile";
  if (width < TABLET_MAX) return "tablet";
  return "desktop";
};

/**
 * Clamped device pixel ratio. The skill's 0.85 mobile ceiling is for soft point
 * sprites; this scene is hair-thin blades, which alias visibly below 1.0, so
 * mobile stops at 1.0 — the skill's own exception for hard-edged geometry.
 */
export const clampedPixelRatio = (tier: DeviceTier = deviceTier()): number => {
  if (typeof window === "undefined") return 1;
  const dpr = window.devicePixelRatio || 1;
  if (tier === "mobile") return Math.min(dpr, 1);
  if (tier === "tablet") return Math.min(Math.max(dpr, 0.75), 1.25);
  return Math.min(Math.max(dpr, 0.75), 1.5);
};

/**
 * Minimum ms between scene frames. The ticker skips while `time - last <=
 * budget`, so a flat `1000 / 30` lands at ~26 fps on a 120 Hz display; the
 * 1 ms margin makes the stated rate the real one.
 */
export const frameBudgetMs = (tier: DeviceTier = deviceTier()): number => {
  if (tier === "mobile") return 1000 / 30 - 1;
  if (tier === "tablet") return 1000 / 45 - 1;
  return 0;
};

export const prefersReducedMotion = (): boolean =>
  typeof window !== "undefined" &&
  typeof window.matchMedia === "function" &&
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/**
 * Best-effort "spend less here". Data Saver is the nearest web-exposed proxy
 * for iOS Low Power Mode, which has no API.
 */
export const isEnergySaver = (): boolean => {
  if (typeof navigator === "undefined") return false;
  const nav = navigator as Navigator & {
    connection?: { saveData?: boolean };
    deviceMemory?: number;
  };
  if (nav.connection?.saveData === true) return true;
  if (typeof nav.deviceMemory === "number" && nav.deviceMemory > 0) {
    return nav.deviceMemory <= 2;
  }
  return false;
};

/**
 * Play the entrance, then stop drawing on a settled frame. WebGL keeps the last
 * frame on the canvas, so a frozen scene costs nothing.
 */
export const sceneShouldFreeze = (tier: DeviceTier = deviceTier()): boolean =>
  prefersReducedMotion() || (tier === "mobile" && isEnergySaver());

/** Whether to attach pointer listeners at all. */
export const wantsPointer = (tier: DeviceTier = deviceTier()): boolean =>
  tier !== "mobile";

export const byTier = <T,>(tier: DeviceTier, values: Record<DeviceTier, T>): T =>
  values[tier];
```

### The configuration — `grass-field.config.ts`, verbatim

The only edits against the shipped file are the four URLs (the model, its decoder, the two blade
textures), which are absolute here.

```ts
// 📖 Docs: obsidian/frontend/components/scene.md

/**
 * Grass field scene configuration.
 *
 * Every tunable the scene reads lives here as a named constant — the scene
 * modules never inline a literal (hard rule #4, and the "tier constants belong
 * in the scene module" clause of obsidian/workflows/optimize-3d-scene.md).
 *
 * The colours below are **shader/material inputs**, not UI styles: they are fed
 * to `THREE.Color` uniforms inside a WebGL program, never to a `className`.
 * Design tokens in `globals.css` cannot reach a GLSL uniform, so this file is
 * their single source of truth. The one exception is `--scene-backdrop`, the
 * CSS token painted behind the canvas before the first frame — it mirrors
 * `colors.skyHorizon` on purpose; keep the two in sync.
 */

/** Fixed PRNG seed — pins terrain relief *and* blade layout, so the meadow is identical on every load. */
export const GRASS_FIELD_SEED = 2241217164;

export const GRASS_FIELD_CONFIG = {
  /**
   * Blade buffer size, drawn in a single instanced call. This is the largest
   * tier's count; smaller tiers draw a prefix of it (`tiers.instances`).
   */
  instances: 480_000,
  /**
   * Per-device budgets, read through `src/lib/scene/device.ts` (ADR-0042).
   * Buffers are always allocated for the largest tier, so a tier change mid-
   * session is an integer write — never a rebuild, never a recompile.
   */
  tiers: {
    /**
     * Blades drawn. Every blade's position is an independent draw from the
     * scatter, so the first N are a uniform sample of the field — truncating
     * thins it evenly rather than deleting a region.
     */
    instances: { desktop: 480_000, tablet: 340_000, mobile: 220_000 },
    /** Motes drawn, via `setDrawRange` on a buffer sized for `particles.count`. */
    particles: { desktop: 1400, tablet: 1000, mobile: 600 },
    /**
     * MSAA on the offscreen scene target. Not zero on mobile: the blades are
     * hair-thin and shimmer badly without it, even at DPR 1.
     */
    msaaSamples: { desktop: 4, tablet: 4, mobile: 2 },
    /** Bloom strength multiplier. */
    bloomScale: { desktop: 1, tablet: 1, mobile: 0.6 },
    /** Ceiling on blur pairs, whatever `bloom.iterations` asks for. */
    bloomMaxIterations: { desktop: 7, tablet: 5, mobile: 3 },
    /**
     * Largest TV texture edge uploaded, px. Read at construction only: a map
     * already downscaled cannot be restored without re-fetching the model.
     */
    maxTextureSize: { desktop: 1024, tablet: 512, mobile: 512 },
  },
  /**
   * Blades are scattered on a disc centred under the camera, not on a square.
   *
   * A square field has to end somewhere, and at this eye height its far edge
   * lands in frame as a line where grass stops and bare ground starts. A disc
   * reaching past the visible horizon has no edge to see.
   *
   * `fieldDensityBias` is the exponent in `r = radius * random^bias`, and the
   * direction is the opposite of what it looks like: **higher concentrates
   * blades near the camera.** The fraction landing within distance d is
   * `(d/radius)^(1/bias)`, so 0.5 is uniform density per unit area and anything
   * above it crowds the foreground. This was set to 0.58 — near-uniform by area
   * — which spent most of the budget on distance, where a blade is sub-pixel:
   * of 420,000 blades only ~9,900 fell within ten metres of the eye — a bald
   * foreground. At 1.75 with a 78-unit disc, 480,000 blades put ~148,000 in that
   * same ten metres, fifteen times as many.
   *
   * Aspect ratio matters here in a way it does not elsewhere: the vertical FOV
   * is fixed, so a wide viewport widens the *ground* in frame without adding any
   * height, and the near field has to cover far more of it. A density that looks
   * full at 16:9 thins out visibly on an ultrawide.
   */
  fieldRadius: 78,
  /**
   * Was 1.75, which packed ~30% of all blades into the first ten metres and left
   * the mid-distance hills under the television thin enough to show bare ground.
   * Once the scatter was limited to the camera's arc (`fieldArcDegrees`), every
   * blade landed in frame, so the bias could come down. The near field is still
   * denser than it was, and the 20-45 unit hills are ~4x denser.
   */
  fieldDensityBias: 1.3,
  /**
   * Width of the wedge, centred on the camera heading, that blades are scattered
   * in. The camera never turns, so blades outside its horizontal field of view
   * are never seen, and a full disc wasted ~3/4 of the instance budget behind
   * and beside the lens. 115 degrees covers the frustum at a 32:9 viewport
   * (half-angle ~51 degrees with the 38 degree vertical FOV) plus a margin for
   * the sway and the look-at toward the television. Widen it if a wider aspect
   * or a turning camera is ever supported.
   */
  fieldArcDegrees: 115,
  /**
   * The ground mesh runs well past the blades so the relief keeps going to the
   * horizon instead of ending at a visible edge. Fog does the rest of the work.
   */
  groundWidth: 420,
  groundSegments: 220,

  /**
   * `width` is a quarter of the pmndrs original's 0.12: fat blades read as
   * macaroni from this locked camera, so the blades are thinned to hair and the
   * instance count raised ~4x to keep the lawn dense.
   */
  blade: { width: 0.042, height: 0.5, joints: 5 },

  /**
   * Blade height variation. Real grass is not uniformly random: it grows in
   * patches, and within a patch most blades sit near one height with a few
   * running away above them.
   *
   * `clumpScale` is the size of those patches in world units; `clumpAmount` how
   * far a patch departs from the mean. `skew` shapes the within-patch
   * distribution — above 1 it pushes the mass toward short, so tall blades read
   * as occasional stragglers instead of half the field.
   */
  height: {
    clumpScale: 7,
    clumpAmount: 0.42,
    minStretch: 0.15,
    maxStretch: 1.55,
    skew: 1.9,
  },

  /**
   * The floating television.
   *
   * Placed along the camera's own view direction, and on the highest ground
   * found between `searchRange` metres down that ray — not at a fixed distance.
   * The relief is noise, so a fixed distance lands wherever it happens to land:
   * the first attempt at 23 m dropped the set into a hollow six metres below the
   * eye, behind a rise that occluded it completely. Searching for the crest puts
   * it against the sky, and keeps doing so if the terrain or the camera moves.
   *
   * `lateral` is metres right of the ray, `hover` is height above the ground it
   * settles over.
   */
  tv: {
    /** Authored Z-up with the screen facing -X; the loader yaws it to face +Z. */
    modelUrl: "https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/tv.glb",
    /** Draco decoder — the same three.js the import map pins, from its CDN (0.160.1). */
    dracoDecoderPath: "https://cdn.jsdelivr.net/npm/three@0.160.1/examples/jsm/libs/draco/gltf/",
    /** Material name of the glass inside the model — the picture is drawn on it. */
    screenMaterialName: "screen",
    /**
     * The model arrives with standard materials, so unlike everything else in
     * this scene it needs actual lights. These two exist for it alone — nothing
     * else in the graph reads a light.
     */
    lighting: { sun: 2.2, sky: 1.1 },
    /** Cabinet width in world units; the model is scaled to match. */
    width: 7.2,
    searchRange: [18, 44],
    lateral: 1.5,
    hover: 3.8,
    scale: 2.4,
    /** Slow vertical drift, and a slow roll — it is floating, not hovering rigidly. */
    bobAmplitude: 0.3,
    bobSpeed: 0.5,
    swayDegrees: 6,
    swaySpeed: 0.33,
    /**
     * Resting attitude when the pointer is away. Both are **relative to facing
     * the camera**, as are the limits below: the set turns on every axis but is
     * never allowed to show its back, because the screen is both the light
     * source and the subject.
     */
    tiltDegrees: -12,
    yawDegrees: -16,
    /** Per-frame easing toward where the cursor is — lower is heavier. */
    followRate: 0.05,
    /**
     * How far the cursor nudges the set off its rest attitude, in degrees at the
     * edge of the frame. These *are* the limits — the nudge is bounded by its own
     * amplitude, so the set cannot turn its back however far the cursor goes.
     */
    follow: { yaw: 26, pitch: 18, roll: 14 },

    screen: {
      /** Hot core and the colour the picture falls off into. */
      coreColor: "#fffdf2",
      edgeColor: "#ff9a30",
      intensity: 13.75,
      /** Amplitude of the CRT's unsteadiness, 0 = rock steady. */
      flicker: 0.07,
      /**
       * Where the picture starts fading toward the rim of the glass, 0..1. Near
       * 1 the whole panel is lit right to its edge; lower and it retreats into
       * the middle. Not a radial falloff — that lights an oval and leaves the
       * corners dark, which reads as a blob inside the cabinet.
       */
      edgeStart: 0.86,
      /** How much brighter the middle of the picture is than its edges. */
      centreLift: 0.55,
    },
    light: {
      color: "#ff8a2a",
      intensity: 35.5,
      /** Metres at which the screen's light has fallen to a quarter. */
      radius: 11,
      /**
       * How much the light is confined to the direction the screen faces.
       * Below 1 — i.e. broader than a Lambertian emitter — on purpose: at 1.5
       * the cone pointed at the camera and the grass directly beneath the set,
       * which is the whole point of the effect, got almost nothing.
       */
      focus: 0.05,
    },
  },

  /**
   * Drifting motes over the field.
   *
   * `count` sizes the buffers, so changing it needs a rebuild. Everything else
   * is a uniform.
   */
  particles: {
    count: 1400,
    color: "#7fd8ff",
    /** Point size in pixels at 60 units away; attenuates with distance. */
    size: 8,
    /** Well above 1 so the motes clear the bloom threshold and glow. */
    brightness: 3.35,
    /** Disc radius and band height around the camera, in world units. */
    radius: 70,
    height: 26,
    /** Lateral wander speed and reach. */
    speed: 0.45,
    drift: 2.25,
    /** How fast the band scrolls upward. */
    rise: 0.55,
    /** 0 = steady, 1 = fully pulsing. */
    twinkle: 0,
    twinkleSpeed: 0.6,
    /**
     * `size` is in pixels, so on a smaller canvas each mote covered more of the
     * frame. Below this viewport the size scales with
     * `min(width / w, height / h)` — the design frame, the same one the UI's rem
     * grid is built on. Never scaled up past 1, so large screens look as tuned.
     */
    referenceViewport: { width: 1440, height: 800 },
    /** Floor for that scale, so motes stay visible on a phone. */
    minViewportScale: 0.4,
  },

  /**
   * Bloom.
   *
   * A real one: bright pixels are extracted from the scene buffer, blurred at
   * reduced resolution and added back before the grade. The additive quad this
   * replaces could only ever glow *around* the set — light that has been blurred
   * across the frame buffer washes over the bezel, so the screen's own edge
   * dissolves, which is what the reference does and what a quad cannot.
   *
   * `threshold` is in linear light: the screen is `toneMapped: false` and sits
   * well above 1, so it blooms hard while the lit grass barely does.
   */
  bloom: {
    threshold: 1.8,
    knee: 0.6,
    intensity: 0.2,
    /** Frame-buffer divisor for the blur. Higher is softer and cheaper. */
    downsample: 4,
    /** Horizontal+vertical blur pairs. Each one roughly doubles the spread. */
    iterations: 4 as number,
    /** Texel step of the first blur pair; later pairs widen from it. */
    spread: 0.6,
  },

  /**
   * Final colour grade, applied in display space at the end of the composite
   * pass. Deliberately not a neutral look: the brief is punchy and unnatural,
   * and the split-tone is what sells it — shadows pushed to the sky's blue, the
   * highlights to the screen's orange, so the two light sources read as two
   * different colours of light rather than as one with a tint.
   */
  grade: {
    contrast: 1.26,
    saturation: 1.45,
    lift: -0.055,
    shadowTint: "#1c3f7a",
    highlightTint: "#ffb257",
    tintStrength: 0.6,
    /**
     * Edge darkening, shaped as a rounded rectangle rather than an ellipse — an
     * ellipse inscribed in the frame bites deepest at the middle of each edge
     * and leaves the corners brightest, which is backwards.
     *
     * `vignette` is how far it goes toward `vignetteColor` at full falloff,
     * `vignetteRadius` is the fraction of the frame the untouched plateau
     * covers, `vignetteRoundness` is corner rounding (0 = square corners,
     * 1 = fully round), and `vignetteSoftness` is the width of the ramp in
     * height-relative units.
     */
    vignette: 1,
    vignetteRadius: 0.74,
    vignetteRoundness: 0.7,
    vignetteSoftness: 0.43,
    vignetteColor: "#000000",
  },

  /** Multiplies the base wind clock (`elapsed / 4`). */
  windSpeed: 1,
  /**
   * The gust that sweeps the field when the page is revealed (`scene.gust()`).
   *
   * Strong wind reads as the *whole field leaning one way*, with broad waves
   * rolling through it — not as every blade thrashing faster. The first version
   * multiplied the per-blade noise (×2.3 amplitude, ×4 speed) and looked like
   * static. So the gust is mostly `lean`: a shared bend in the wind's direction,
   * modulated by a long travelling wave, with the noise only nudged.
   *
   * The envelope is smooth at both ends: a smoothstep rise over `attack`, then a
   * Gaussian fall (`exp(-(t/calm)²)`) with zero slope at the peak, so there is
   * no corner where the rise turns into the calm.
   */
  windGust: {
    /**
     * Extra bend at the peak, as a quaternion half-angle in radians, on top of
     * the calm sway. 0.28 is roughly a 32° lean. Past ~0.4 the tips meet the
     * ground when the noise peaks too.
     */
    lean: 0.28,
    /** How much the broad wave modulates the lean: 0 is a flat push, 1 fully pulses. */
    wave: 0.45,
    /** Per-blade sway amplitude multiplier — kept small; this is what reads as jitter. */
    sway: 1.2,
    /** Wind-noise speed multiplier — kept small for the same reason. */
    speed: 1.5,
    /** Mote wander and rise. */
    particles: 2,
    /** Cloud drift. */
    clouds: 3,
    /** Seconds to reach full strength. */
    attack: 1.1,
    /** Seconds for the Gaussian calm to fall to ~37 %; settled by ~2.5 × this. */
    calm: 2.2,
  },

  pointer: {
    /**
     * The cursor's dent in the field, written into a decaying map rather than
     * applied straight from the pointer position — see `BEND_STAMP_FRAGMENT_SHADER`.
     * Without the map a blade is upright again the frame the pointer leaves it,
     * and a fast sweep heals behind the cursor as fast as it is drawn.
     */
    /** World-unit radius of the dent the pointer presses. */
    radius: 7,
    /** How far a fully dented blade bows, in world units at the tip. */
    strength: 0.5,
    /**
     * Seconds for a dent to fade to ~37% of its depth. Higher is a field that
     * stays trampled longer; this is the recovery the old version had none of.
     */
    recovery: 1.1,
    /**
     * Distance compensation. `radius` and `strength` are world units, so the
     * same dent that fills the near hill shrinks to a few pixels on the hills
     * under the television, 20-40 units out. Past `distanceReference` both grow
     * linearly with distance from the camera, which keeps the dent roughly the
     * same size on screen, up to the `far*` multipliers.
     */
    distanceReference: 10,
    /** Largest multiplier `radius` reaches with distance. */
    farRadius: 1.75,
    /** Largest multiplier `strength` reaches with distance. */
    farStrength: 3,
    /** Texels per side of the bend map. */
    resolution: 512,
    /**
     * Seconds for the stamped pointer to close ~63% of the gap to the real
     * cursor. Frame-rate independent. Higher is a lazier trail that rounds off
     * jerky mouse movement before it ever reaches the grass.
     */
    follow: 0.12,
    /**
     * Blade inertia. The blades do not take the trampled pose directly: every
     * texel of the field is a damped spring chasing it, so a dent builds up
     * behind the cursor, and when it recovers the blades swing back past upright
     * and settle. See `BEND_SPRING_FRAGMENT_SHADER`.
     */
    spring: {
      /** Natural frequency, Hz. Lower is heavier, slower blades. */
      frequency: 1.1,
      /** Damping ratio. 1 settles without overshoot; lower wobbles longer. */
      damping: 0.5,
    },
    /** Parking coordinate, far off the field: breeze absent rather than flagged off. */
    parked: 1e6,
  },

  camera: {
    fov: 38,
    near: 0.1,
    far: 2000,
    /** Standing point. Chosen by scanning the seeded relief, not by eye. */
    x: -30,
    z: -15,
    /** Eye height above the ground beneath it — blades reach ~2.8, so this looks over the canopy. */
    eyeHeight: 1.3,
    /** Compass heading, degrees: 0 looks down -Z, 90 looks down +X. */
    yawDeg: 16,
    /**
     * Pitch, degrees, negative looking down. Aim is expressed as an angle rather
     * than as a point on the ground: a ground-relative target silently tilts the
     * camera up whenever it sits on higher ground than the eye, which throws the
     * near grass out of frame and takes the foreground bokeh with it.
     */
    pitchDeg: -2,
    /**
     * Handheld drift. The camera is no longer strictly locked — it is placed
     * once and then offset by a few centimetres and a fraction of a degree from
     * layered sines, which reads as a held shot rather than a tripod.
     */
    sway: {
      positionAmplitude: 0.09,
      rotationDegrees: 0.22,
      speed: 0.16,
    },
  },

  renderer: {
    // DPR is clamped per tier in src/lib/scene/device.ts (`clampedPixelRatio`).
    /** Lifted from the port's 0.58: the reference is a bright, open daylight meadow. */
    toneMappingExposure: 1.35,
    // MSAA samples on the offscreen scene target are per tier: `tiers.msaaSamples`.
  },

  /** Bluish exponential haze so the far grass melts into the sky, not into white. */
  fog: { density: 0.0045 },

  /**
   * Terrain octaves — amplitude and feature size, in world units.
   *
   * Retuned away from the port's single broad swell to the reference's rolling
   * dunes: a dominant mound every ~30 units, a secondary swell breaking it up,
   * and a fine octave so the silhouette is not a clean sine.
   */
  terrain: [
    { amplitude: 5.0, scale: 46 },
    { amplitude: 1.9, scale: 17 },
    { amplitude: 0.35, scale: 5 },
  ],

  /**
   * Depth of field. `focusDistance` is metres from the eye; everything nearer or
   * further ramps to `maxBlur` (in pixels at 1080p) across `focusRange`.
   * The reference's foreground is blurred harder than its background, which is
   * what `nearBlurBoost` buys.
   */
  dof: {
    /** Metres from the eye to the sharp band — the near mound in the reference. */
    focusDistance: 19,
    /** Near and far are ramped separately: the foreground blurs hard and fast, */
    nearRange: 1,
    maxNearBlur: 8.5,
    /** while the hills and sky only ever go soft, never milky. */
    farRange: 200,
    maxFarBlur: 8.5,
  },

  /**
   * Lighting for the whole scene. There are no `THREE.Light`s at all — the
   * blades and the ground are both custom shaders and neither reads one, so a
   * light in the graph would do nothing but cost a program recompile. These
   * constants are the light, and both materials read the same ones so the field
   * and the floor under it always agree.
   */
  grassLight: {
    /**
     * Floor brightness — what the sky dome alone puts on a blade facing away.
     * Deliberately low: a high ambient floor is exactly what makes a field look
     * flat, because it drowns the difference between the lit and unlit side.
     */
    ambient: 0.52,
    /** Green inter-reflection from the surrounding field — keeps shadows alive. */
    bounce: 0.16,
    /** Sun contribution at full facing. High relative to ambient = contrast. */
    diffuse: 0.9,
    /** How much of the sun term comes from the blade's own facing vs the ground's. */
    bladeWeight: 0.5,
    /**
     * Wrapped-diffuse width. Light bleeds this far past the terminator, which is
     * what thin foliage does — it buys contrast without a black shadow side.
     */
    wrap: 0.35,
    /**
     * Darkening at the root, easing to none at the tip. This is the canopy's own
     * occlusion and it is the single strongest source of depth in a lawn — a
     * dense sward is close to black at the soil and bright at the tips.
     */
    rootShade: 0.5,
    /**
     * How dark a blade gets when it is short relative to the patch around it.
     * Blades buried under their neighbours sit in shade; without this every
     * blade is lit as though it stood alone, which is most of the plasticine
     * look — a uniformly lit mass reads as one moulded object, not thousands
     * of separate leaves.
     */
    canopyAo: 0.38,
    /** Per-blade brightness spread, low and high multipliers. */
    tintRange: [0.82, 1.28],
    /** Fraction of blades that carry a flower colour at the tip. */
    flowerFraction: 0.035,
    /**
     * The raw terrain dot product only spans ~0.5..1.0 on dunes this gentle, so
     * a mound's lit and shaded faces come out nearly the same value. These are
     * the low and high ends that band is stretched across.
     */
    terrainContrast: [0.02, 0.62],
    /** Drifting cloud shadow over the field — large-scale light and shade. */
    cloudShadow: { scale: 0.014, speed: 0.35, strength: 0.3 },
    /**
     * Cast shadow from the terrain up-sun, baked on a grid (`sun-shadow.ts`).
     * `strength` 1 means a fully shadowed slope receives no sun at all, leaving
     * it lit by the sky and the television only — which is the brief for the
     * near hill. `reach` is how far up-sun the march looks for a blocker.
     */
    sunShadow: {
      resolution: 320,
      reach: 70,
      steps: 28,
      softness: 1.6,
      strength: 1,
    },
    /** Blinn-Phong highlight — the shine on blades that lean into the light. */
    specular: 1.0,
    shininess: 40,
    /** Glow through a back-lit blade. Grass is thin; this is most of its life. */
    translucency: 1.35,
    translucencyPower: 3.5,
  },

  sky: {
    radius: 1200,
    widthSegments: 64,
    heightSegments: 48,
    /** 0 = clear, 1 = overcast. Raised for the reference's fair-weather cumulus. */
    cloudCover: 0.38,
    /**
     * Where the large clumping noise lets clouds exist at all: the smoothstep
     * range (`cloudGateLow` to `cloudGateHigh`) over that noise. Lower values
     * open more of the dome to cloud; the original 0.28 to 0.62 left mostly
     * clear sky between a few clumps.
     */
    /**
     * Cloud look. Opacity < 1 keeps the sky reading through them. Brightness
     * scales the whole cloud and caps it (a multiple of `colors.cloud`); it has
     * to stay below what the composite grade can take without clipping — at
     * the current `grade` (contrast 1.26 plus an additive highlight tint) that
     * is ~0.6. Raise the grade's contrast or tint and this must come down.
     */
    cloudOpacity: 0.68,
    cloudBrightness: 0.6,
    cloudGateLow: 0.02,
    cloudGateHigh: 0.42,
    cloudSpeed: 1,
    /** How strongly the warm horizon band reads, 0 = none. */
    horizonGlowStrength: 0.62,
    /**
     * Lower than the port's 34 was raised to. A high sun lights everything from
     * straight above and every slope shades alike — the single biggest cause of
     * a flat-looking field. At 36° the dune faces separate, blades rim-light,
     * and the halo brightens the sky on the sun's side.
     *
     * It stays out of frame on elevation alone: the top edge of this camera sits
     * at ~16°, so the disc never enters however the azimuth moves.
     */
    sunElevationDeg: 22,
    /**
     * Side light, ~90° off the view axis. Not a free choice — it is what the two
     * highlight terms need. A sun in *front* of the camera cannot form a
     * Blinn-Phong lobe back toward it (the half-vector degenerates when light and
     * view are opposed), so the blades stay matte however high `specular` goes.
     * Side-on, the dune faces also separate into a lit and a shaded side, which
     * is where the contrast across the field comes from.
     */
    sunAzimuthDeg: 68,
  },

  colors: {
    tip: "#003d75",
    bottom: "#006680",
    skyTop: "#005bdb",
    /** Also the fog colour, and the source of the `--scene-backdrop` CSS token. */
    skyHorizon: "#c8dcea",
    cloud: "#ffffff",
    /** Warm band at the horizon — where the light is coming from. */
    horizonGlow: "#ffe9cf",
    /**
     * Aerial haze, and it is no longer the same value as `skyHorizon`. With a
     * low sun the sky at the horizon goes warm while distant hills stay cool and
     * blue — tying the two together would drag the hills warm with the sky.
     */
    haze: "#ebf7ff",
    sun: "#ff7a95",
    /**
     * The ground is the floor of the sward, not soil — see the ground shader.
     * These two are its ramp, deliberately darker than the blade ramp, because
     * what shows through a canopy is grass in shade.
     */
    groundBase: "#000000",
    groundTip: "#004f80",
    /** Straw, mixed into the small fraction of blades that read as dried out. */
    dry: "#6181c2",
    /** Flower colour, for the small fraction of blades that carry one. */
    flower: "#faffb8",
    /** Sky/ambient light colour — the cool half of the two-light model. */
    skyLight: "#cfe2ff",
  },

  /**
   * The pmndrs `grass-shader` blade textures, fetched from the bucket. The
   * loader MUST run with `crossOrigin = "anonymous"` (three's TextureLoader
   * default): a tainted or failed alpha map decodes as 0, every fragment hits
   * `discard`, and the field vanishes leaving a bare hill under a perfect sky.
   */
  textures: {
    diffuse: "https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/blade-diffuse.jpg",
    alpha: "https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/blade-alpha.jpg",
  },
} as const;
```

### The terrain — `terrain.ts`, verbatim

```ts
// 📖 Docs: obsidian/frontend/components/scene.md

/**
 * Deterministic terrain for the grass field.
 *
 * One 32-bit seed fixes the whole meadow. Two independent streams derive from
 * it: `SEED` shuffles the simplex permutation table (the relief), and
 * `SEED ^ 0x9e3779b9` drives the blade layout — so the field reproduces exactly
 * on every load and every geometry rebuild. No `Math.random()` may enter scene
 * generation.
 */

/** Small, fast, seedable PRNG. Returns a function producing [0, 1). */
export const mulberry32 = (seed: number): (() => number) => {
  let a = seed;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
};

/**
 * 2D simplex noise (Gustavson, public domain), inlined rather than pulled from
 * `simplex-noise` because the constructor must accept an RNG — the permutation
 * shuffle has to be seeded for the relief to be reproducible.
 */
export class SimplexNoise {
  private readonly perm: Uint8Array;
  private readonly permMod12: Uint8Array;
  private readonly grad3: Float32Array;

  constructor(random: () => number = Math.random) {
    this.perm = new Uint8Array(512);
    this.permMod12 = new Uint8Array(512);

    const p = new Uint8Array(256);
    for (let i = 0; i < 256; i++) p[i] = i;
    for (let i = 255; i > 0; i--) {
      const n = Math.floor((i + 1) * random());
      const q = p[i];
      p[i] = p[n];
      p[n] = q;
    }
    for (let i = 0; i < 512; i++) {
      this.perm[i] = p[i & 255];
      this.permMod12[i] = this.perm[i] % 12;
    }

    // prettier-ignore
    this.grad3 = new Float32Array([
      1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0,
      1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1,
      0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
    ]);
  }

  noise2D(xin: number, yin: number): number {
    const grad3 = this.grad3;
    const perm = this.perm;
    const permMod12 = this.permMod12;
    const F2 = 0.5 * (Math.sqrt(3) - 1);
    const G2 = (3 - Math.sqrt(3)) / 6;

    let n0 = 0;
    let n1 = 0;
    let n2 = 0;

    const s = (xin + yin) * F2;
    const i = Math.floor(xin + s);
    const j = Math.floor(yin + s);
    const t = (i + j) * G2;
    const x0 = xin - (i - t);
    const y0 = yin - (j - t);

    let i1: number;
    let j1: number;
    if (x0 > y0) {
      i1 = 1;
      j1 = 0;
    } else {
      i1 = 0;
      j1 = 1;
    }

    const x1 = x0 - i1 + G2;
    const y1 = y0 - j1 + G2;
    const x2 = x0 - 1 + 2 * G2;
    const y2 = y0 - 1 + 2 * G2;
    const ii = i & 255;
    const jj = j & 255;

    let t0 = 0.5 - x0 * x0 - y0 * y0;
    if (t0 >= 0) {
      const gi0 = permMod12[ii + perm[jj]] * 3;
      t0 *= t0;
      n0 = t0 * t0 * (grad3[gi0] * x0 + grad3[gi0 + 1] * y0);
    }
    let t1 = 0.5 - x1 * x1 - y1 * y1;
    if (t1 >= 0) {
      const gi1 = permMod12[ii + i1 + perm[jj + j1]] * 3;
      t1 *= t1;
      n1 = t1 * t1 * (grad3[gi1] * x1 + grad3[gi1 + 1] * y1);
    }
    let t2 = 0.5 - x2 * x2 - y2 * y2;
    if (t2 >= 0) {
      const gi2 = permMod12[ii + 1 + perm[jj + 1]] * 3;
      t2 *= t2;
      n2 = t2 * t2 * (grad3[gi2] * x2 + grad3[gi2 + 1] * y2);
    }

    return 70 * (n0 + n1 + n2);
  }
}

/** Samples the terrain height at a world XZ position. */
export type HeightSampler = (x: number, z: number) => number;

/** One simplex octave: how tall, and how wide its features are. */
export interface TerrainOctave {
  amplitude: number;
  scale: number;
}

/**
 * Sums the configured simplex octaves on the CPU. This is the single source of
 * truth for **both** the ground mesh and every blade's Y offset — which is why
 * the blades sit exactly on the surface instead of hovering or sinking, and why
 * retuning the relief moves the grass with it.
 */
export const createHeightSampler = (
  seed: number,
  octaves: readonly TerrainOctave[],
): HeightSampler => {
  const simplex = new SimplexNoise(mulberry32(seed));

  return (x, z) => {
    let y = 0;
    for (const octave of octaves) {
      y += octave.amplitude * simplex.noise2D(x / octave.scale, z / octave.scale);
    }
    return y;
  };
};

/**
 * A bare seeded 2D noise field, for things that are not terrain height —
 * grass-height clumping and per-blade variation patches. Seeded separately from
 * the relief so retuning one does not reshuffle the other.
 */
export const createNoise2D = (
  seed: number,
): ((x: number, y: number) => number) => {
  const simplex = new SimplexNoise(mulberry32(seed));
  return (x, y) => simplex.noise2D(x, y);
};

/**
 * Surface normal at a world XZ, by central difference on the height field.
 *
 * Used to bake a per-blade ground-light term: the blades are unlit geometry, so
 * without it a mound and the hollow beside it shade identically and the relief
 * reads flat no matter how good the silhouette is.
 */
export const terrainNormal = (
  sample: HeightSampler,
  x: number,
  z: number,
  epsilon = 0.75,
): { x: number; y: number; z: number } => {
  const dx = sample(x + epsilon, z) - sample(x - epsilon, z);
  const dz = sample(x, z + epsilon) - sample(x, z - epsilon);
  // Gradient -> normal, for a height field y = f(x, z).
  const nx = -dx;
  const ny = 2 * epsilon;
  const nz = -dz;
  const length = Math.hypot(nx, ny, nz);
  return { x: nx / length, y: ny / length, z: nz / length };
};
```

### The baked sun shadow — `sun-shadow.ts`, verbatim

```ts
// 📖 Docs: obsidian/frontend/components/scene.md

/**
 * Baked sun-visibility map for the terrain.
 *
 * With the sun low, the crest the television stands on throws a long shadow
 * back over everything in front of it — which is exactly the look the brief
 * asks for, and which no amount of per-blade shading can fake: shading depends
 * only on which way a surface points, and a slope in shadow points the same way
 * as a slope in sunlight.
 *
 * Marched on a grid rather than per blade. Per-blade would be ~420,000 marches
 * against a three-octave height field, several seconds of it; the terrain is
 * smooth at this scale, so a grid the shaders sample bilinearly is
 * indistinguishable and costs a fraction.
 */

import * as THREE from "three";

import type { HeightSampler } from "./terrain";

export interface SunShadowOptions {
  /** World-space centre of the covered square. */
  centerX: number;
  centerZ: number;
  /** Side length of the covered square, in world units. */
  size: number;
  /** Texels per side. */
  resolution: number;
  /** How far to march toward the sun, in world units. */
  reach: number;
  /** Marching steps. More is smoother along the shadow's length, not its edge. */
  steps: number;
  /**
   * Softness of the terminator, in world units of height. The march records how
   * close it came to being blocked rather than a hard yes/no, so the edge has a
   * penumbra instead of a staircase.
   */
  softness: number;
}

export interface SunShadowMap {
  texture: THREE.DataTexture;
  /** Bottom-left corner of the covered square. */
  origin: THREE.Vector2;
  size: number;
  dispose: () => void;
}

export const createSunShadowMap = (
  getYPosition: HeightSampler,
  sunDirection: THREE.Vector3,
  options: SunShadowOptions,
): SunShadowMap => {
  const { centerX, centerZ, size, resolution, reach, steps, softness } = options;

  const originX = centerX - size / 2;
  const originZ = centerZ - size / 2;
  const step = size / (resolution - 1);

  // Horizontal march direction, and how fast the ray to the sun climbs per
  // world unit travelled along it.
  const horizontal = Math.hypot(sunDirection.x, sunDirection.z);
  const dirX = horizontal > 1e-6 ? sunDirection.x / horizontal : 1;
  const dirZ = horizontal > 1e-6 ? sunDirection.z / horizontal : 0;
  const climb = horizontal > 1e-6 ? sunDirection.y / horizontal : 1e6;

  const data = new Uint8Array(resolution * resolution);
  const marchStep = reach / steps;

  for (let row = 0; row < resolution; row++) {
    const z = originZ + row * step;
    for (let column = 0; column < resolution; column++) {
      const x = originX + column * step;
      const height = getYPosition(x, z);

      // Deepest intrusion of the terrain above the ray, over the whole march.
      let blocked = 0;
      for (let s = 1; s <= steps; s++) {
        const travelled = s * marchStep;
        const rayHeight = height + travelled * climb;
        const terrain = getYPosition(x + dirX * travelled, z + dirZ * travelled);
        const intrusion = terrain - rayHeight;
        if (intrusion > blocked) blocked = intrusion;
      }

      const shadow = Math.min(Math.max(blocked / softness, 0), 1);
      data[row * resolution + column] = Math.round((1 - shadow) * 255);
    }
  }

  const texture = new THREE.DataTexture(
    data,
    resolution,
    resolution,
    THREE.RedFormat,
    THREE.UnsignedByteType,
  );
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  // Outside the baked square there is nothing to cast a shadow worth having, so
  // the edge texel repeats rather than wrapping a shadow round to the far side.
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  texture.needsUpdate = true;

  return {
    texture,
    origin: new THREE.Vector2(originX, originZ),
    size,
    dispose: () => texture.dispose(),
  };
};
```

### The shaders — `shaders.ts`, verbatim

Every GLSL string, character for character. `${SIMPLEX_GLSL}`, `${TV_LIGHT_GLSL}` and
`${SUN_SHADOW_GLSL}` are JavaScript template interpolations of the constants above them, exactly as
written.

```ts
// 📖 Docs: obsidian/frontend/components/scene.md

/**
 * GLSL for the grass field and its sky dome.
 *
 * Tone mapping and colour management stay in-material rather than in a pass:
 *
 * - The grass appends `<tonemapping_fragment>` then `<colorspace_fragment>`, so
 *   it obeys `toneMappingExposure`.
 * - The sky is `toneMapped: false` and appends **only** `<colorspace_fragment>`,
 *   making it exposure-independent — the grass exposure can be pushed around
 *   without washing the horizon band to white.
 *
 * The one real pass is depth of field (`DOF_FRAGMENT_SHADER`), which composites
 * the offscreen colour + depth to screen. It is the last thing that runs, and it
 * therefore reads and writes sRGB-encoded values — it converts to linear itself
 * to average them, because blurring in gamma space eats highlights.
 *
 * Both chunks are resolved by three's `WebGLProgram` for a `ShaderMaterial`, and
 * `<colorspace_fragment>` exists only from r0.152 onward — hence the pinned
 * r0.160 in `package.json`.
 */

/** 2D simplex noise (Ashima / Gustavson, public domain) for the vertex stage. */
export const SIMPLEX_GLSL = /* glsl */ `
vec3 mod289(vec3 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec2 mod289(vec2 x){ return x - floor(x*(1.0/289.0))*289.0; }
vec3 permute(vec3 x){ return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v){
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i  = floor(v + dot(v, C.yy));
  vec2 x0 = v -   i + dot(i, C.xx);
  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1;
  i = mod289(i);
  vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m; m = m*m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
  vec3 g;
  g.x  = a0.x  * x0.x  + h.x  * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}
`;

/**
 * Blade vertex stage. The ordering is load-bearing:
 *
 * 1. `frc` is the vertex's fraction up the blade (0 root, 1 tip). It weights the
 *    twist, the colour ramp and the cursor bend — which is why the roots stay
 *    planted while the tips move.
 * 2. The wind noise samples `(time - offset.x/50, time - offset.z/50)`: time is
 *    *added* on both axes while position is subtracted, so the noise field
 *    scrolls diagonally and gusts read as travelling bands, not per-blade jitter.
 * 3. The slerp runs from a pure-yaw quaternion (rebuilt from the half-angle
 *    sin/cos) to the blade's full orientation, weighted by `frc`. Twisting the
 *    quad along its length is what makes a flat plane look like a curved blade.
 * 4. The wind bend is applied after the twist, in blade space, so it compounds
 *    toward the tip.
 * 5. The instance `offset` is added last — the blade is built, twisted and bent
 *    at the origin, then translated onto its spot on the hill.
 */
/** Shared by everything the sun lights. */
export const SUN_SHADOW_GLSL = /* glsl */ `
// Sun visibility, baked on a grid (see sun-shadow.ts). 1 = full sun, 0 = in the
// shadow of terrain up-sun of this point.
float sunVisibility(vec2 worldXZ, sampler2D shadowMap, vec2 origin, float size){
  vec2 uv = (worldXZ - origin) / size;
  // Outside the baked square, assume open sky rather than shadow.
  if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
  return texture2D(shadowMap, uv).r;
}
`;

/** Shared by everything the television lights. */
export const TV_LIGHT_GLSL = /* glsl */ `
// The television's screen as a light. Not a THREE.Light — nothing in this scene
// reads one (ADR-0030) — so every surface that wants it runs this.
//
// Three factors: inverse-square-ish falloff, how squarely the surface faces the
// set, and how squarely the *screen* faces the surface. The third is what keeps
// it reading as a screen rather than a bare bulb: a CRT throws its light forward
// into a cone, so the grass in front of the set lights up and the grass behind
// it stays dark, which is most of what makes the object look like it belongs.
vec3 screenLight(vec3 worldPos, vec3 normal, vec3 tvPos, vec3 tvDir,
                 vec3 tvColor, float intensity, float radius, float focus,
                 float flicker){
  vec3 toTv = tvPos - worldPos;
  float dist = length(toTv);
  vec3 L = toTv / max(dist, 0.0001);
  float falloff = 1.0 / (1.0 + (dist / radius) * (dist / radius) * 4.0);
  // Wrapped, because grass is thin and a hard terminator on a point light
  // looks like a decal rather than illumination.
  float facing = max((dot(normal, L) + 0.45) / 1.45, 0.0);
  float cone = pow(max(dot(-L, tvDir), 0.0), focus);
  return tvColor * intensity * falloff * facing * cone * flicker;
}
`;

export const GRASS_VERTEX_SHADER = /* glsl */ `
attribute vec3 offset;
attribute vec4 orientation;
attribute float halfRootAngleSin;
attribute float halfRootAngleCos;
attribute float stretch;
attribute float groundLight;
attribute vec2 variation;
uniform float time;
// The wind has its own clock and gain, so a gust can race the ripples across
// the field without also speeding up the cloud shadows, which run on the
// time uniform shared with the ground.
uniform float windTime;
uniform float windStrength;
// Gust: a shared lean in the wind direction, modulated by a long wave that
// rolls across the field. Zero when there is no gust.
uniform float windLean;
uniform float windWave;
uniform float bladeHeight;
uniform sampler2D uBendMap;
uniform vec2 uBendOrigin;
uniform float uBendSize;
uniform float uMouseStrength;
uniform float uBendReference;
uniform float uBendFarStrength;
uniform float uRootShade;
uniform float uCloudShadowScale;
uniform float uCloudShadowSpeed;
uniform float uCloudShadowStrength;
varying vec2 vUv;
varying float frc;
varying vec3 vNormal;
varying vec3 vView;
varying float vGroundLight;
varying float vRootShade;
varying vec2 vVariation;
varying float vSunMask;
varying float vDepth;
varying vec3 vWorldPos;
${SIMPLEX_GLSL}
// rotate a vector by a quaternion
vec3 rotateVectorByQuaternion(vec3 v, vec4 q){
  return 2.0 * cross(q.xyz, v * q.w + cross(q.xyz, v)) + v;
}
// spherical linear interpolation between two quaternions
vec4 slerp(vec4 v0, vec4 v1, float t){
  normalize(v0); normalize(v1);
  float d = dot(v0, v1);
  if (d < 0.0) { v1 = -v1; d = -d; }
  const float THRESHOLD = 0.9995;
  if (d > THRESHOLD) return normalize(v0 + t * (v1 - v0));
  float theta_0 = acos(d);
  float theta = theta_0 * t;
  float sin_theta = sin(theta);
  float sin_theta_0 = sin(theta_0);
  float s0 = cos(theta) - d * sin_theta / sin_theta_0;
  float s1 = sin_theta / sin_theta_0;
  return (s0 * v0) + (s1 * v1);
}
void main() {
  // relative position of vertex along the blade (0 root -> 1 tip)
  frc = position.y / float(bladeHeight);
  // scrolling wind noise
  float noise = 1.0 - (snoise(vec2((windTime - offset.x / 50.0), (windTime - offset.z / 50.0))));
  // orientation slerps from the root-yaw quaternion to the blade's own
  vec4 direction = vec4(0.0, halfRootAngleSin, 0.0, halfRootAngleCos);
  direction = slerp(direction, orientation, frc);
  vec3 vPosition = vec3(position.x, position.y + position.y * stretch, position.z);
  vPosition = rotateVectorByQuaternion(vPosition, direction);
  // the quad's own face normal, carried through the same rotations as the vertex
  vec3 bladeNormal = rotateVectorByQuaternion(vec3(0.0, 0.0, 1.0), direction);
  // apply the wind bend (tips bend more)
  // The wind quaternion's axis is (1, 0, -1), which tips blades toward +X +Z,
  // so the wave travels that way too — a gust rolling downwind. Wavelength
  // ~28 units, moving ~7 units per second of wind clock.
  float gustWave = sin(dot(offset.xz, vec2(0.7071)) * 0.22 - windTime * 6.0);
  float lean = windLean * (1.0 - windWave * 0.5 + windWave * 0.5 * gustWave);
  float halfAngle = noise * 0.15 * windStrength + lean;
  vec4 windQuat = normalize(vec4(sin(halfAngle), 0.0, -sin(halfAngle), cos(halfAngle)));
  vPosition = rotateVectorByQuaternion(vPosition, windQuat);
  bladeNormal = rotateVectorByQuaternion(bladeNormal, windQuat);
  // Cursor breeze, read from the bend map rather than from the pointer itself.
  // The map remembers: a blade stays down after the pointer has gone and comes
  // back up as the map decays, which is what makes a sweep leave a wake instead
  // of a line that heals the instant it is drawn.
  vec2 bendUv = (offset.xz - uBendOrigin) / uBendSize;
  // RG is the spring's current displacement (a signed vector, not the trail
  // itself), so the blade lags the dent, overshoots it and settles.
  vec2 bendDisplacement = texture2D(uBendMap, bendUv).rg;
  // Outside the mapped square there is no dent to read; without this the edge
  // texel would smear a permanent bend across the whole far field.
  float inside = step(0.0, bendUv.x) * step(bendUv.x, 1.0)
               * step(0.0, bendUv.y) * step(bendUv.y, 1.0);
  // Deeper with distance, so a dent on the far hills reads like one up close.
  float bendDistanceScale = clamp(
    distance(offset.xz, cameraPosition.xz) / max(uBendReference, 0.001),
    1.0, max(uBendFarStrength, 1.0)
  );
  vec2 push = bendDisplacement * uMouseStrength * bendDistanceScale * inside * frc;
  float bend = length(push);
  vPosition.x += push.x;
  vPosition.z += push.y;
  vPosition.y -= bend * bend * 0.35;
  // There are no lights in the scene graph at all; this shader and the ground
  // shader are the lighting model, and they read the same constants so the
  // canopy and the floor under it always agree. Finished per fragment: shading a
  // blade per-vertex bands badly across six rows, and a specular highlight
  // computed per-vertex is worse than none.
  vec3 worldPosition = offset + vPosition;
  vNormal = bladeNormal;
  vView = cameraPosition - worldPosition;
  vGroundLight = groundLight;
  vVariation = variation;
  // Contact shade, squared. The canopy's occlusion does not fall off linearly
  // with height - light drops away fast near the soil - and the linear ramp is
  // what made the sward read as one smoothly shaded surface.
  float shade = mix(1.0 - uRootShade, 1.0, pow(frc, 1.4));
  // times this blade's own burial under its neighbours
  vRootShade = shade * variation.y;

  // Cloud shadows drifting over the field. Big, soft bands of light and shade
  // are how an open meadow actually looks under fair-weather cumulus, and they
  // give the eye a large-scale contrast that per-blade shading cannot: without
  // them every part of the field is lit identically and the whole thing reads
  // as one evenly lit surface however good the close-up shading is.
  float shadowNoise = snoise(
    offset.xz * uCloudShadowScale + vec2(time * uCloudShadowSpeed)
  );
  vSunMask = mix(
    1.0 - uCloudShadowStrength, 1.0, smoothstep(-0.30, 0.45, shadowNoise)
  );

  vUv = uv;
  vWorldPos = worldPosition;
  vec4 mvPosition = modelViewMatrix * vec4(worldPosition, 1.0);
  // Distance from the eye, for fog. The grass had none: scene.fog only reaches
  // materials that implement it, and this shader never did — so the ground
  // hazed into the sky with distance while the blades in front of it stayed
  // fully saturated, and the two came apart at the horizon.
  vDepth = -mvPosition.z;
  gl_Position = projectionMatrix * mvPosition;
}
`;

/**
 * Blade fragment stage.
 *
 * The double `mix` is not a typo: both interpolate *toward the texture* by
 * `frc`, so the first pushes `tipColor` into the low end and the second pushes
 * `bottomColor` in on top of that — the compound is a dark base rising into
 * saturated green, with the diffuse only fully visible near the tip.
 *
 * The `discard` is this scene's single point of failure: if the alpha map fails
 * to decode it samples as 0, every blade is thrown away, and the field renders
 * as a bare hill under a perfect sky — geometry intact, grass apparently gone.
 * The textures are therefore served same-origin from `public/`.
 */
export const GRASS_FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D map;
uniform sampler2D alphaMap;
uniform vec3 tipColor;
uniform vec3 bottomColor;
uniform vec3 uSunColor;
uniform vec3 uSkyColor;
uniform vec3 uDryColor;
uniform vec2 uTintRange;
uniform vec2 uTerrainContrast;
uniform vec3 uSunDir;
uniform float uAmbient;
uniform float uBounce;
uniform float uDiffuse;
uniform float uBladeWeight;
uniform float uWrap;
uniform float uSpecular;
uniform float uShininess;
uniform float uTranslucency;
uniform float uTranslucencyPower;
varying vec2 vUv;
varying float frc;
varying vec3 vNormal;
varying vec3 vView;
varying float vGroundLight;
varying float vRootShade;
varying vec2 vVariation;
varying float vSunMask;
varying float vDepth;
varying vec3 vWorldPos;
uniform vec3 uFogColor;
uniform float uFogDensity;
uniform vec3 uFlowerColor;
uniform float uFlowerFraction;
uniform vec3 uTvPosition;
uniform vec3 uTvDirection;
uniform vec3 uTvColor;
uniform float uTvIntensity;
uniform float uTvRadius;
uniform float uTvFocus;
uniform float uTvFlicker;
uniform sampler2D uShadowMap;
uniform vec2 uShadowOrigin;
uniform float uShadowSize;
uniform float uShadowStrength;
${TV_LIGHT_GLSL}
${SUN_SHADOW_GLSL}
void main() {
  float alpha = texture2D(alphaMap, vUv).r;
  if (alpha < 0.15) discard;
  vec4 col = vec4(texture2D(map, vUv));
  col = mix(vec4(tipColor, 1.0), col, frc);
  col = mix(vec4(bottomColor, 1.0), col, frc);

  // Per-blade variation. Every blade sharing one ramp is the other half of the
  // plasticine look: a real sward is thousands of slightly different greens, and
  // a fraction of it is always dying back to straw. Without this the field is
  // one material with a shadow on it.
  float tint = vVariation.x;
  col.rgb *= mix(uTintRange.x, uTintRange.y, tint);
  col.rgb = mix(col.rgb, uDryColor, smoothstep(0.86, 1.0, tint) * 0.5);
  // A small fraction of blades carry a flower at the tip. Cheapest possible
  // flowers — no extra geometry, just the top of an existing blade repainted.
  float flower = step(1.0 - uFlowerFraction, tint) * smoothstep(0.55, 0.95, frc);
  col.rgb = mix(col.rgb, uFlowerColor, flower);

  vec3 N = normalize(vNormal);
  vec3 V = normalize(vView);
  vec3 L = normalize(uSunDir);
  // A blade is a double-sided quad with no back, so the normal is flipped to
  // face the camera rather than abs()-ed. abs() throws away which way the blade
  // leans, and with it every highlight that depends on the lean.
  if (dot(N, V) < 0.0) N = -N;

  // Wrapped diffuse: light bleeds a little past the terminator, which is what
  // thin foliage does and what keeps the shadow side from going black.
  float ndl = dot(N, L);
  float wrapped = max((ndl + uWrap) / (1.0 + uWrap), 0.0);
  // Terrain-scale shading and blade-scale shading, mixed. The first makes a
  // mound read as a mound; the second is the per-blade break-up.
  //
  // The terrain term is remapped before use: these are gentle dunes, so the raw
  // dot product only ever spans roughly 0.5..1.0 and the lit and shaded faces of
  // a mound come out nearly the same value. Stretching that narrow band across
  // the full range is what makes the relief read as lit from one side.
  float terrain = smoothstep(uTerrainContrast.x, uTerrainContrast.y, vGroundLight);
  float sunTerm = mix(terrain, wrapped, uBladeWeight);

  // Two coloured lights, not one scalar. The shadow side is lit by the sky and
  // goes cool; the lit side is lit by the sun and goes warm. That split is what
  // a photograph has and a single grey multiplier never can — it is most of the
  // difference between "shaded" and "flat".
  // Plus a bounce term in the blade's own colour. A grass field inter-reflects
  // heavily — the light in a hollow has been green twice over — and without it
  // a blue-tinted ambient desaturates every shadow to grey.
  vec3 ambientLight = uSkyColor * uAmbient + col.rgb * uBounce;
  // Cast shadow from the terrain up-sun. With the sun low this is what puts the
  // near slope in shade while the crest stays lit — and it is not something the
  // shading term can express, since a shadowed slope points the same way as a
  // sunlit one.
  float sunVis = sunVisibility(vWorldPos.xz, uShadowMap, uShadowOrigin, uShadowSize);
  sunVis = mix(1.0, sunVis, uShadowStrength);
  vec3 sunLight = uSunColor * uDiffuse * sunTerm * vSunMask * sunVis;
  vec3 lit = col.rgb * (ambientLight + sunLight) * vRootShade;

  // Specular. This is the "shine": a tight Blinn-Phong lobe weighted toward the
  // tip, so the blades that happen to lean into the light flare and the rest
  // stay matte. It is what separates grass from felt.
  vec3 H = normalize(L + V);
  // Gloss varies per blade too — an even sheen across the whole field reads as
  // a wet plastic surface rather than as leaves.
  float spec = pow(max(dot(N, H), 0.0), uShininess) * uSpecular * frc
             * mix(0.35, 1.7, tint) * vSunMask * sunVis;
  lit += uSunColor * spec;

  // Translucency: a blade lit from behind glows, because it is thin. Strongest
  // when looking toward the sun through a blade that faces away from it.
  float through = pow(max(dot(V, -L), 0.0), uTranslucencyPower);
  lit += col.rgb * uSunColor * through * uTranslucency * max(-ndl, 0.0) * frc * sunVis;

  // The television.
  lit += col.rgb * screenLight(vWorldPos, N, uTvPosition, uTvDirection, uTvColor,
                               uTvIntensity, uTvRadius, uTvFocus, uTvFlicker);

  gl_FragColor = vec4(lit, col.a);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
  // Fog last, matching three's own ordering (it applies fog after tone mapping
  // and encoding, not before).
  float fogFactor = 1.0 - exp(-uFogDensity * uFogDensity * vDepth * vDepth);
  gl_FragColor.rgb = mix(gl_FragColor.rgb, uFogColor, clamp(fogFactor, 0.0, 1.0));
}
`;

/** Forwards the un-normalised local position as a view direction. */
export const SKY_VERTEX_SHADER = /* glsl */ `
varying vec3 vDir;
void main(){ vDir = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
`;

/** Gradient + sun disc + halo + drifting FBM clouds. Writes linear; the chunk encodes. */
export const SKY_FRAGMENT_SHADER = /* glsl */ `
uniform vec3 uTop; uniform vec3 uHorizon; uniform vec3 uSunColor; uniform vec3 uSunDir;
uniform float uTime; uniform vec3 uCloudColor; uniform float uCloudCover; uniform float uCloudSpeed;
uniform vec2 uCloudGate;
uniform float uCloudOpacity; uniform float uCloudBrightness;
uniform vec3 uHorizonGlow; uniform float uHorizonGlowStrength;
varying vec3 vDir;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
float vnoise(vec2 p){
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  float a = hash(i), b = hash(i + vec2(1.0, 0.0)), c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p){
  float v = 0.0, a = 0.5;
  for(int i = 0; i < 4; i++){ v += a * vnoise(p); p = p * 2.02 + 7.3; a *= 0.5; }
  return v;
}
// Billow noise: fbm over the ABSOLUTE value of signed noise. Folding the noise
// at zero turns every smooth zero-crossing into a crease, which is what gives
// cumulus their lumpy, cauliflower structure. Plain fbm, thresholded at any
// level, is a blurry stain - it has no such feature to find.
float billow(vec2 p){
  float v = 0.0, a = 0.5;
  for(int i = 0; i < 4; i++){
    v += a * abs(vnoise(p) * 2.0 - 1.0);
    p = p * 2.05 + 4.1;
    a *= 0.5;
  }
  return v;
}
// Domain warp on top: feeding the noise a noise-displaced coordinate breaks the
// remaining regularity out of the lobes.
float clouds(vec2 p){
  vec2 warp = vec2(fbm(p + 3.1), fbm(p + 7.7));
  return billow(p + warp * 0.6);
}
void main(){
  vec3 d = normalize(vDir);
  float h = clamp(d.y, 0.0, 1.0);
  vec3 col = mix(uHorizon, uTop, pow(h, 0.5));

  // Warm band hugging the horizon, biased to the sun's side of the dome.
  // A sky that is only a vertical ramp has no direction in it — this is what
  // tells the eye where the light is coming from when the sun itself is out of
  // frame, and it is most of the difference between "a blue gradient" and
  // "late afternoon".
  float horizonBand = pow(1.0 - h, 7.0);
  // not named flat: that is a reserved interpolation qualifier in GLSL ES 3.0
  vec2 viewFlat = normalize(d.xz + vec2(1e-5));
  vec2 sunFlat = normalize(uSunDir.xz + vec2(1e-5));
  float sunSide = dot(viewFlat, sunFlat) * 0.5 + 0.5;
  col = mix(col, uHorizonGlow, horizonBand * mix(0.25, 1.0, sunSide) * uHorizonGlowStrength);

  float sd = max(dot(d, normalize(uSunDir)), 0.0);
  col += uSunColor * pow(sd, 400.0) * 3.0;    // sun disc
  col += uSunColor * 0.20 * pow(sd, 6.0);     // soft halo

  // --- drifting cumulus on the dome ---
  // divide by (d.y + const), NOT max(d.y, const): the additive term keeps the
  // projection finite and smooth right down to the horizon, so there is no frozen
  // band and no vertical "drip" streaks.
  // horizonFade still ramps rather than snaps - a narrow band puts a hard lower
  // edge on the cloud haze that reads as a horizontal seam splitting the sky.
  // But the original ramp ran to 0.75, and this camera looks near-horizontal:
  // the whole visible sky sits below d.y ~ 0.4, so every cloud in frame was
  // being multiplied down to a pale wash. It now reaches full by 0.3.
  float horizonFade = smoothstep(-0.03, 0.18, d.y);
  vec2 cp = (d.xz / (d.y + 0.35)) * 0.88;
  cp += vec2(uTime * 0.02 * uCloudSpeed, uTime * 0.006 * uCloudSpeed);

  // Billow sits lower than fbm (it is a sum of folded magnitudes, not of
  // signed lobes), so the threshold range is its own, not the fbm one.
  float cover = mix(0.60, 0.16, clamp(uCloudCover, 0.0, 1.0));
  float n = clouds(cp);
  float density = smoothstep(cover, cover + 0.13, n);

  // A second, much larger noise gates where clouds are allowed at all. Without
  // it the field is an even speckle across the whole dome; cumulus come in
  // clumps with clear blue between them, and that separation is most of what
  // reads as "fair weather" rather than "haze".
  float clump = smoothstep(uCloudGate.x, uCloudGate.y, fbm(cp * 0.34 + 19.7));

  // Fake the third dimension: sample the same cloud field again, shifted toward
  // the sun. Where the shifted sample is thinner, this part of the cloud faces
  // the light and is a lit top; where it is thicker, the cloud is shadowing
  // itself and this is an underside. That difference is the whole illusion of
  // volume, and it costs one extra noise fetch.
  vec2 sunFlow = normalize(uSunDir.xz + vec2(0.001)) * 0.22;
  float towardSun = clouds(cp + sunFlow);
  float selfShadow = clamp((n - towardSun) * 2.6 + 0.5, 0.0, 1.0);

  // A thinner, faster high layer for depth - real skies are never one plane.
  vec2 hp = cp * 2.3 + vec2(uTime * 0.012 * uCloudSpeed, 0.0);
  float high = smoothstep(cover + 0.20, cover + 0.42, fbm(hp)) * 0.4;

  float ca = clamp(density * clump + high * clump * horizonFade, 0.0, 1.0) * horizonFade;

  vec3 cloudShadow = uCloudColor * 0.52;
  vec3 cloudLit = uCloudColor;
  vec3 cloudCol = mix(cloudShadow, cloudLit, selfShadow);
  // Sun warmth as a *tint*, not added light. Adding the rim (+0.45) and the
  // warm tops (+0.28) on top of a white cloud pushed clouds near the sun to
  // ~1.7 in linear light — past the bloom threshold, so a cloud drifting
  // toward the sun blew out into a glowing blob with pink fringes.
  float sunTint = smoothstep(0.3, 0.9, sd);
  cloudCol = mix(cloudCol, cloudCol * mix(vec3(1.0), uSunColor * 1.6, 0.35), sunTint);
  cloudCol = mix(cloudCol, uSunColor, 0.18 * smoothstep(0.55, 1.0, sd) * (1.0 - density));
  // Brightness is scaled as a whole (keeping lit-vs-shadow contrast) and then
  // capped. The ceiling is set by the *grade*, not by bloom: the composite
  // raises contrast (×1.26) and then ADDS a warm highlight tint (up to +0.6), so
  // anything above ~0.58 linear lands on pure white on screen. A cap of 0.86
  // stopped the bloom blow-out but still clipped a large cloud mass to a flat
  // white patch.
  cloudCol = min(cloudCol * uCloudBrightness, uCloudColor * uCloudBrightness);
  // Soft, never opaque, and thinner still right against the sun's glare.
  float nearSun = smoothstep(0.82, 1.0, sd);
  col = mix(col, cloudCol, ca * uCloudOpacity * (1.0 - 0.55 * nearSun));

  // dither: break up 8-bit banding on the smooth blue ramp so the gradient
  // reads as one continuous sky instead of splitting into flat stepped bands
  float dither = (hash(gl_FragCoord.xy) - 0.5) / 255.0;
  col += dither;

  gl_FragColor = vec4(col, 1.0);
  #include <colorspace_fragment>
}
`;

/** Fullscreen triangle-quad: `position` is already in clip space, so no camera is read. */
export const DOF_VERTEX_SHADER = /* glsl */ `
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;

/**
 * Depth-of-field composite.
 *
 * Scatter-as-gather: each pixel reads its own depth, works out a circle of
 * confusion, and averages a golden-angle spiral of taps across that radius.
 * Near and far are ramped independently — a foreground that dissolves and a
 * background that only softens is what the reference frame does, and a single
 * symmetric ramp cannot express it.
 *
 * Why this is hand-rolled rather than three's `BokehPass`: that pass re-renders
 * the scene with an override depth material, which would drop the instanced
 * attributes and stack all 190k blades at the origin. Reading the depth texture
 * the normal pass already wrote sidesteps the problem entirely.
 */
export const DOF_FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform sampler2D uBloom;
uniform float uBloomIntensity;
uniform vec2 uTexel;
uniform float uNear;
uniform float uFar;
uniform float uFocusDistance;
uniform float uNearRange;
uniform float uFarRange;
uniform float uMaxNearBlur;
uniform float uMaxFarBlur;
uniform float uContrast;
uniform float uSaturation;
uniform float uLift;
uniform vec3 uShadowTint;
uniform vec3 uHighlightTint;
uniform float uTintStrength;
uniform float uVignette;
uniform float uVignetteRadius;
uniform float uVignetteRoundness;
uniform float uVignetteSoftness;
uniform vec3 uVignetteColor;
uniform float uAspect;
varying vec2 vUv;

const int TAPS = 28;
const float GOLDEN_ANGLE = 2.39996323;

/** Window-space depth -> positive distance from the eye, in world units. */
float viewDistance(float depth){
  float ndc = depth * 2.0 - 1.0;
  return (2.0 * uNear * uFar) / (uFar + uNear - ndc * (uFar - uNear));
}

/** Linear -> sRGB, the real piecewise transfer rather than a 1/2.2 approximation. */
vec3 linearToSRGB(vec3 c){
  c = max(c, 0.0);
  return mix(c * 12.92, 1.055 * pow(c, vec3(0.41666667)) - 0.055, step(vec3(0.0031308), c));
}

/** Blur radius in pixels for a given distance from the eye. */
float blurRadius(float dist){
  float near = clamp((uFocusDistance - dist) / uNearRange, 0.0, 1.0) * uMaxNearBlur;
  float far  = clamp((dist - uFocusDistance) / uFarRange, 0.0, 1.0) * uMaxFarBlur;
  return max(near, far);
}

void main(){
  float centreDist = viewDistance(texture2D(uDepth, vUv).x);
  float radius = blurRadius(centreDist);

  // The offscreen buffer is linear, so these average correctly as they are.
  vec3 sum = texture2D(uScene, vUv).rgb;
  float weight = 1.0;

  for (int i = 0; i < TAPS; i++){
    float t = (float(i) + 0.5) / float(TAPS);
    float angle = float(i) * GOLDEN_ANGLE;
    // sqrt spreads the taps evenly over the disc's AREA, not its radius —
    // without it the samples bunch at the centre and the bokeh looks ringed
    vec2 offset = vec2(cos(angle), sin(angle)) * sqrt(t) * radius * uTexel;
    vec2 uv = vUv + offset;

    // A tap is only allowed to bleed in if it is itself at least as blurred as
    // the pixel it lands on. That is what keeps a sharp mound from smearing
    // into the soft grass in front of it.
    float tapRadius = blurRadius(viewDistance(texture2D(uDepth, uv).x));
    float w = clamp(tapRadius / max(radius, 0.0001), 0.0, 1.0);

    sum += texture2D(uScene, uv).rgb * w;
    weight += w;
  }

  // Bloom added in linear light, before the encode — which is the whole point.
  // An additive quad in front of the set can only ever sit *around* the screen;
  // light that has been blurred across the frame buffer washes over the bezel,
  // so the screen's own edge dissolves the way a real over-exposed highlight
  // does.
  vec3 blurred = sum / weight + texture2D(uBloom, vUv).rgb * uBloomIntensity;

  // Grade in display space, after the encode. Contrast and saturation applied
  // to linear light behave nothing like the numbers suggest — a contrast of 1.3
  // on linear values crushes the shadows to black and blows the sky out — and
  // every grading intuition is a display-space intuition anyway.
  vec3 c = linearToSRGB(blurred);

  c = (c - 0.5) * uContrast + 0.5 + uLift;

  float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));
  c = mix(vec3(luma), c, uSaturation);

  // Split tone: shadows toward the sky, highlights toward the screen, so the
  // two light sources read as two colours of light rather than one with a cast.
  c += uShadowTint * (1.0 - smoothstep(0.0, 0.55, luma)) * uTintStrength;
  c += uHighlightTint * smoothstep(0.45, 1.0, luma) * uTintStrength;

  // Vignette: a rounded-rectangle plateau across the middle, then a soft ramp
  // out to the edges.
  //
  // The plateau is the whole point — an earlier version ramped from the very
  // first pixel off centre, leaving no untouched region, which is why raising it
  // dimmed the whole frame like an exposure control.
  //
  // The *shape* matters too. A radial falloff gives an ellipse inscribed in the
  // frame, so it bites deepest exactly at the middle of each edge and leaves the
  // corners comparatively bright — the opposite of what a lens does. A rounded
  // box hugs the frame, so the darkening sits in a band of even width with the
  // corners deepest.
  //
  // Worked in height-relative units (x scaled by aspect) so the corner radius is
  // circular on screen rather than stretched with the frame.
  vec2 vignettePoint = vUv - 0.5;
  vignettePoint.x *= uAspect;
  vec2 vignetteHalf = vec2(0.5 * uAspect, 0.5) * uVignetteRadius;
  float vignetteCorner =
    min(vignetteHalf.x, vignetteHalf.y) * clamp(uVignetteRoundness, 0.0, 1.0);
  // Signed distance to a rounded box: negative inside the plateau, positive out.
  vec2 vignetteQ = abs(vignettePoint) - vignetteHalf + vignetteCorner;
  float vignetteSdf =
    min(max(vignetteQ.x, vignetteQ.y), 0.0) +
    length(max(vignetteQ, 0.0)) -
    vignetteCorner;
  float vignetteFalloff =
    smoothstep(0.0, max(uVignetteSoftness, 0.001), vignetteSdf);
  // mix toward a colour rather than multiplying down, so the vignette can be
  // tinted; with black it is exactly the old multiply.
  c = mix(c, uVignetteColor, clamp(uVignette * vignetteFalloff, 0.0, 1.0));

  gl_FragColor = vec4(clamp(c, 0.0, 1.0), 1.0);
}
`;

/** Ground vertex stage. The mesh has an identity transform, so position is world. */
export const GROUND_VERTEX_SHADER = /* glsl */ `
varying vec3 vWorld;
varying vec3 vGroundNormal;
varying float vDepth;
void main(){
  vWorld = position;
  vGroundNormal = normal;
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  vDepth = -mvPosition.z;
  gl_Position = projectionMatrix * mvPosition;
}
`;

/**
 * Ground fragment stage — the floor of the sward, not soil.
 *
 * Nothing here is meant to be looked at directly. Its whole job is that wherever
 * the eye finds a gap between blades, or looks past the far edge of the blade
 * disc, what it finds is the dark base of grass rather than bare earth. So it
 * runs the *same* lighting as the blades — terrain term with the same remap, the
 * same drifting cloud shadow, the same sun and sky colours — and is tinted from
 * the same two-colour ramp. Give the ground its own look and every gap in the
 * canopy turns into a visible patch of dirt.
 */
export const GROUND_FRAGMENT_SHADER = /* glsl */ `
uniform vec3 uBaseColor;
uniform vec3 uTipColor;
uniform vec3 uSunColor;
uniform vec3 uSkyColor;
uniform vec3 uSunDir;
uniform float uAmbient;
uniform float uDiffuse;
uniform vec2 uTerrainContrast;
uniform float uTime;
uniform float uCloudShadowScale;
uniform float uCloudShadowSpeed;
uniform float uCloudShadowStrength;
uniform vec3 uFogColor;
uniform float uFogDensity;
uniform vec3 uTvPosition;
uniform vec3 uTvDirection;
uniform vec3 uTvColor;
uniform float uTvIntensity;
uniform float uTvRadius;
uniform float uTvFocus;
uniform float uTvFlicker;
uniform sampler2D uShadowMap;
uniform vec2 uShadowOrigin;
uniform float uShadowSize;
uniform float uShadowStrength;
varying vec3 vWorld;
varying vec3 vGroundNormal;
varying float vDepth;
${SIMPLEX_GLSL}
${TV_LIGHT_GLSL}
${SUN_SHADOW_GLSL}
float gHash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
float gNoise(vec2 p){
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  float a = gHash(i), b = gHash(i + vec2(1.0, 0.0));
  float c = gHash(i + vec2(0.0, 1.0)), d = gHash(i + vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
void main(){
  vec3 N = normalize(vGroundNormal);
  vec3 L = normalize(uSunDir);

  // Mottling at two scales. A flat colour under the blades reads as a moulded
  // plane the moment any of it is visible; broken up, it reads as more grass.
  float mottle = gNoise(vWorld.xz * 1.7) * 0.6 + gNoise(vWorld.xz * 7.0) * 0.4;
  vec3 albedo = mix(uBaseColor, uTipColor, mottle * 0.5);

  float terrain = smoothstep(uTerrainContrast.x, uTerrainContrast.y, max(dot(N, L), 0.0));

  float shadowNoise = snoise(
    vWorld.xz * uCloudShadowScale + vec2(uTime * uCloudShadowSpeed)
  );
  float sunMask = mix(
    1.0 - uCloudShadowStrength, 1.0, smoothstep(-0.30, 0.45, shadowNoise)
  );

  float sunVis = mix(
    1.0,
    sunVisibility(vWorld.xz, uShadowMap, uShadowOrigin, uShadowSize),
    uShadowStrength
  );
  vec3 lit = albedo * (uSkyColor * uAmbient + uSunColor * uDiffuse * terrain * sunMask * sunVis);
  lit += albedo * screenLight(vWorld, N, uTvPosition, uTvDirection, uTvColor,
                              uTvIntensity, uTvRadius, uTvFocus, uTvFlicker);

  gl_FragColor = vec4(lit, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
  float fogFactor = 1.0 - exp(-uFogDensity * uFogDensity * vDepth * vDepth);
  gl_FragColor.rgb = mix(gl_FragColor.rgb, uFogColor, clamp(fogFactor, 0.0, 1.0));
}
`;

/** Screen vertex stage — geometry position is forwarded, not UVs. */
export const TV_SCREEN_VERTEX_SHADER = /* glsl */ `
varying vec3 vLocal;
void main(){
  vLocal = position;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

/**
 * Screen fragment stage: a hot core falling to the edge colour.
 *
 * The falloff is computed from the glass's own bounding box rather than from
 * UVs — the model's UV layout is its own business, and a picture that does not
 * land exactly on the glass is worse than no picture. The thin axis of the box
 * is ignored, so this works whichever way the panel faces.
 */
export const TV_SCREEN_FRAGMENT_SHADER = /* glsl */ `
uniform vec3 uCore;
uniform vec3 uEdge;
uniform float uIntensity;
uniform float uGlow;
uniform vec3 uCenter;
uniform vec3 uExtent;
uniform float uEdgeStart;
uniform float uCentreLift;
varying vec3 vLocal;
void main(){
  vec3 offset = (vLocal - uCenter) / max(uExtent, vec3(1e-4));
  // Drop the shallowest axis: that is the glass's thickness, not its face.
  vec2 face = uExtent.x < uExtent.y && uExtent.x < uExtent.z
    ? offset.yz
    : (uExtent.y < uExtent.z ? offset.xz : offset.xy);

  // Box falloff, not radial. A radial one lights an oval in the middle of the
  // glass and leaves the corners dark, which reads as a blob sitting inside the
  // cabinet rather than as a screen that is on. This holds full brightness
  // across the picture area and only softens at the rim, so the whole screen
  // is bright, blooms, and dissolves its own edge.
  float edge = max(abs(face.x), abs(face.y));
  float lit = 1.0 - smoothstep(uEdgeStart, 1.0, edge);
  // A gentle centre lift, because a CRT is brighter in the middle — but it
  // rides on top of a fully lit panel instead of being the whole picture.
  float centre = 1.0 - smoothstep(0.0, 1.4, length(face));
  vec3 col = mix(uEdge, uCore, centre * uCentreLift);
  gl_FragColor = vec4(col * uIntensity * uGlow * lit, 1.0);
  #include <colorspace_fragment>
}
`;

/**
 * Bloom, pass 1 of 3: bright extraction with a soft knee.
 *
 * Reads the linear scene buffer, so the threshold is in linear light and the
 * screen — which is `toneMapped: false` and deliberately over 1 — is the only
 * thing far enough above it to bloom hard.
 */
export const BLOOM_BRIGHT_FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D uScene;
uniform float uThreshold;
uniform float uKnee;
varying vec2 vUv;
void main(){
  vec3 c = texture2D(uScene, vUv).rgb;
  float brightness = max(c.r, max(c.g, c.b));
  // Soft knee, so a surface hovering at the threshold fades in instead of
  // popping as the frame moves.
  float contribution = smoothstep(uThreshold, uThreshold + max(uKnee, 1e-4), brightness);
  gl_FragColor = vec4(c * contribution, 1.0);
}
`;

/**
 * Bloom, passes 2 and 3: separable Gaussian.
 *
 * Run horizontally then vertically, at reduced resolution, repeatedly with a
 * widening step. Separable because a 2D kernel wide enough to dissolve the
 * screen's edge would cost hundreds of taps per pixel; two 9-tap passes give the
 * same result for eighteen.
 */
export const BLOOM_BLUR_FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D uSource;
uniform vec2 uDirection;
varying vec2 vUv;
void main(){
  // Gaussian, sigma ~2 texels, normalised.
  float w0 = 0.227027;
  float w1 = 0.194594;
  float w2 = 0.121621;
  float w3 = 0.054054;
  float w4 = 0.016216;
  vec3 sum = texture2D(uSource, vUv).rgb * w0;
  sum += texture2D(uSource, vUv + uDirection * 1.0).rgb * w1;
  sum += texture2D(uSource, vUv - uDirection * 1.0).rgb * w1;
  sum += texture2D(uSource, vUv + uDirection * 2.0).rgb * w2;
  sum += texture2D(uSource, vUv - uDirection * 2.0).rgb * w2;
  sum += texture2D(uSource, vUv + uDirection * 3.0).rgb * w3;
  sum += texture2D(uSource, vUv - uDirection * 3.0).rgb * w3;
  sum += texture2D(uSource, vUv + uDirection * 4.0).rgb * w4;
  sum += texture2D(uSource, vUv - uDirection * 4.0).rgb * w4;
  gl_FragColor = vec4(sum, 1.0);
}
`;

/**
 * Drifting motes.
 *
 * Animated entirely in the vertex stage from a per-particle seed: position,
 * wander and twinkle are all functions of time and that seed, so nothing is
 * written per frame from the CPU and the whole field is one draw call.
 */
export const PARTICLE_VERTEX_SHADER = /* glsl */ `
attribute vec3 seed;
uniform float uTime;
uniform float uSpeed;
uniform float uRise;
uniform float uDrift;
uniform float uSize;
uniform float uPixelRatio;
uniform float uViewportScale;
uniform float uBaseY;
uniform float uHeight;
varying float vPhase;
varying float vFade;
void main(){
  vPhase = seed.x * 6.2831853;

  vec3 p = position;
  // Lateral wander, each mote on its own period so the field never pulses.
  p.x += sin(uTime * uSpeed * (0.6 + seed.y) + vPhase) * uDrift;
  p.z += cos(uTime * uSpeed * (0.5 + seed.z) + vPhase * 1.7) * uDrift;
  // Rise, wrapped: a mote that reaches the top of the band reappears at the
  // bottom, so the swarm never thins out and nothing has to be respawned.
  float travel = uTime * uRise * (0.4 + seed.y);
  p.y = uBaseY + mod(p.y - uBaseY + travel, uHeight);

  vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
  // Fade the two ends of the band so motes do not pop in and out at the seams.
  float bandPosition = (p.y - uBaseY) / uHeight;
  vFade = smoothstep(0.0, 0.12, bandPosition) * (1.0 - smoothstep(0.82, 1.0, bandPosition));

  // Capped: a mote drifting right past the lens would otherwise become a
  // screen-sized additive sprite — pure fill for a soft blur nobody can read.
  gl_PointSize = min(
    uSize * uPixelRatio * uViewportScale * (60.0 / max(-mvPosition.z, 0.1)),
    48.0 * uPixelRatio
  );
  gl_Position = projectionMatrix * mvPosition;
}
`;

/** Round, soft-edged, additive — and bright enough to reach the bloom threshold. */
export const PARTICLE_FRAGMENT_SHADER = /* glsl */ `
uniform vec3 uColor;
uniform float uBrightness;
uniform float uTwinkle;
uniform float uTwinkleSpeed;
uniform float uTime;
varying float vPhase;
varying float vFade;
void main(){
  float d = length(gl_PointCoord - 0.5) * 2.0;
  float core = pow(max(1.0 - d, 0.0), 2.2);
  if (core <= 0.001) discard;
  float twinkle = mix(1.0, 0.45 + 0.55 * sin(uTime * uTwinkleSpeed + vPhase), uTwinkle);
  gl_FragColor = vec4(uColor * uBrightness * twinkle * core * vFade, core * vFade);
  #include <colorspace_fragment>
}
`;

/**
 * Bend map, stamped and decayed once per frame.
 *
 * This is the field's memory. The previous version bent blades as a pure
 * function of where the cursor *is*, so the moment it moved on, every blade was
 * upright again in the same frame — sweeping quickly left a field that snapped
 * back behind the pointer instead of a wake that settles.
 *
 * RG holds the direction to push, encoded; B holds how hard. Each frame the
 * whole map fades toward zero — that fade *is* the recovery — and a fresh stamp
 * is written where the pointer went, taking the stronger of the two so an old
 * dent is not erased by a glancing pass.
 *
 * The stamp is a capsule along the segment from the previous pointer position to
 * the current one, not a disc at the current one: at speed the pointer can cross
 * many metres between frames, and discs leave a dotted line of separate dents.
 */
export const BEND_STAMP_FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D uPrevious;
uniform vec2 uOrigin;
uniform float uSize;
uniform vec2 uFrom;
uniform vec2 uTo;
uniform float uRadius;
uniform float uDecay;
uniform float uActive;
uniform vec2 uCamera;
uniform float uReference;
uniform float uFarRadius;
varying vec2 vUv;

/** Distance from p to the segment ab, and the position along it. */
float segmentDistance(vec2 p, vec2 a, vec2 b){
  vec2 ab = b - a;
  float lengthSquared = max(dot(ab, ab), 1e-6);
  float t = clamp(dot(p - a, ab) / lengthSquared, 0.0, 1.0);
  return distance(p, a + ab * t);
}

vec2 segmentClosest(vec2 p, vec2 a, vec2 b){
  vec2 ab = b - a;
  float lengthSquared = max(dot(ab, ab), 1e-6);
  float t = clamp(dot(p - a, ab) / lengthSquared, 0.0, 1.0);
  return a + ab * t;
}

void main(){
  vec3 previous = texture2D(uPrevious, vUv).rgb;
  // Recovery: the dent fades rather than being released. Applied per frame with
  // a factor the CPU derives from real elapsed time, so the rate does not change
  // with frame rate.
  previous.b *= uDecay;

  vec2 world = uOrigin + vUv * uSize;
  vec2 closest = segmentClosest(world, uFrom, uTo);
  float d = distance(world, closest);
  // Wider with distance from the camera, measured at the pointer's path rather
  // than at this texel, so the dent stays round instead of stretching away.
  float radiusScale = clamp(
    distance(closest, uCamera) / max(uReference, 0.001), 1.0, max(uFarRadius, 1.0)
  );
  float stamp = smoothstep(uRadius * radiusScale, 0.0, d) * uActive;

  vec2 away = world - closest;
  vec2 direction = length(away) > 1e-4 ? normalize(away) : vec2(1.0, 0.0);

  // Take the stronger of old and new, so a light pass does not wipe a deep dent.
  float take = step(previous.b, stamp);
  vec2 encoded = mix(previous.rg, direction * 0.5 + 0.5, take);
  gl_FragColor = vec4(encoded, max(previous.b, stamp), 1.0);
}
`;

/**
 * Blade inertia, integrated once per frame over the whole bend map.
 *
 * The trail map says where the field *should* be pressed. Reading it directly
 * made the blades jump into the pose the frame the cursor arrived. Here each
 * texel is a damped spring instead: RG is its displacement, BA its velocity, and
 * the target is the trail's direction scaled by its depth. Semi-implicit Euler,
 * stable while 2 pi frequency delta < 2; the CPU substeps to hold that at low
 * frame rates.
 */
export const BEND_SPRING_FRAGMENT_SHADER = /* glsl */ `
uniform sampler2D uState;
uniform sampler2D uTrail;
uniform float uDelta;
uniform float uStiffness;
uniform float uDamping;
varying vec2 vUv;

void main(){
  vec4 state = texture2D(uState, vUv);
  vec3 trail = texture2D(uTrail, vUv).rgb;
  vec2 target = (trail.rg * 2.0 - 1.0) * trail.b;
  vec2 velocity = state.ba;
  vec2 acceleration = (target - state.rg) * uStiffness - velocity * uDamping;
  velocity += acceleration * uDelta;
  vec2 displacement = state.rg + velocity * uDelta;
  gl_FragColor = vec4(displacement, velocity);
}
`;
```

### The motes — `particles.ts`, verbatim

```ts
// 📖 Docs: obsidian/frontend/components/scene.md

/**
 * Drifting motes over the field.
 *
 * One `THREE.Points` draw call. Every particle's wander, rise and twinkle is a
 * function of time and a per-particle seed evaluated in the vertex shader, so
 * nothing is written from the CPU per frame — the only per-frame cost is a
 * uniform update.
 *
 * They are bright and `toneMapped: false`, which puts them over the bloom
 * threshold: the glow around each mote is the bloom pass, not a sprite texture.
 */

import * as THREE from "three";

import { GRASS_FIELD_CONFIG as CONFIG } from "./grass-field.config";
import {
  PARTICLE_FRAGMENT_SHADER,
  PARTICLE_VERTEX_SHADER,
} from "./shaders";
import { mulberry32 } from "./terrain";

export interface ParticleHandle {
  object: THREE.Points;
  uniforms: {
    uColor: THREE.IUniform<THREE.Color>;
    uBrightness: THREE.IUniform<number>;
    uSize: THREE.IUniform<number>;
    uSpeed: THREE.IUniform<number>;
    uRise: THREE.IUniform<number>;
    uDrift: THREE.IUniform<number>;
    uTwinkle: THREE.IUniform<number>;
    uTwinkleSpeed: THREE.IUniform<number>;
  };
  setTime: (elapsed: number) => void;
  setPixelRatio: (pixelRatio: number) => void;
  /** CSS size of the canvas — shrinks the motes on viewports below the reference. */
  setViewport: (width: number, height: number) => void;
  /** Draw only the first `count` motes — the buffer stays allocated. */
  setCount: (count: number) => void;
  dispose: () => void;
}

export const createParticles = (
  seed: number,
  center: THREE.Vector2,
  baseY: number,
): ParticleHandle => {
  const { particles } = CONFIG;

  const positions = new Float32Array(particles.count * 3);
  const seeds = new Float32Array(particles.count * 3);
  const random = mulberry32(seed);

  for (let i = 0; i < particles.count; i++) {
    // Scattered on a disc around the camera rather than in a box, for the same
    // reason the blades are: a box has corners the eye can find.
    const radius = particles.radius * Math.sqrt(random());
    const theta = random() * Math.PI * 2;
    positions[i * 3] = center.x + Math.cos(theta) * radius;
    positions[i * 3 + 1] = baseY + random() * particles.height;
    positions[i * 3 + 2] = center.y + Math.sin(theta) * radius;

    seeds[i * 3] = random();
    seeds[i * 3 + 1] = random();
    seeds[i * 3 + 2] = random();
  }

  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute("seed", new THREE.BufferAttribute(seeds, 3));

  const timeUniform: THREE.IUniform<number> = { value: 0 };
  const pixelRatioUniform: THREE.IUniform<number> = { value: 1 };
  const viewportScaleUniform: THREE.IUniform<number> = { value: 1 };
  const uniforms = {
    uColor: { value: new THREE.Color(particles.color) },
    uBrightness: { value: particles.brightness } as THREE.IUniform<number>,
    uSize: { value: particles.size } as THREE.IUniform<number>,
    uSpeed: { value: particles.speed } as THREE.IUniform<number>,
    uRise: { value: particles.rise } as THREE.IUniform<number>,
    uDrift: { value: particles.drift } as THREE.IUniform<number>,
    uTwinkle: { value: particles.twinkle } as THREE.IUniform<number>,
    uTwinkleSpeed: {
      value: particles.twinkleSpeed,
    } as THREE.IUniform<number>,
    uTime: timeUniform,
    uPixelRatio: pixelRatioUniform,
    uViewportScale: viewportScaleUniform,
    uBaseY: { value: baseY },
    uHeight: { value: particles.height },
  };

  const material = new THREE.ShaderMaterial({
    uniforms,
    vertexShader: PARTICLE_VERTEX_SHADER,
    fragmentShader: PARTICLE_FRAGMENT_SHADER,
    transparent: true,
    blending: THREE.AdditiveBlending,
    // Depth *test* on so the hill occludes them; depth *write* off so they never
    // occlude each other into hard edges.
    depthWrite: false,
    toneMapped: false,
  });

  const points = new THREE.Points(geometry, material);
  // Positions are animated in the shader, so the bounding sphere three computes
  // from the buffer is wrong the moment the clock starts.
  points.frustumCulled = false;

  return {
    object: points,
    uniforms: {
      uColor: uniforms.uColor,
      uBrightness: uniforms.uBrightness,
      uSize: uniforms.uSize,
      uSpeed: uniforms.uSpeed,
      uRise: uniforms.uRise,
      uDrift: uniforms.uDrift,
      uTwinkle: uniforms.uTwinkle,
      uTwinkleSpeed: uniforms.uTwinkleSpeed,
    },
    setTime: (elapsed) => {
      timeUniform.value = elapsed;
    },
    setPixelRatio: (pixelRatio) => {
      pixelRatioUniform.value = pixelRatio;
    },
    setCount: (count) => {
      geometry.setDrawRange(0, Math.max(0, Math.min(count, particles.count)));
    },
    setViewport: (width, height) => {
      const { width: refWidth, height: refHeight } = particles.referenceViewport;
      const scale = Math.min(width / refWidth, height / refHeight, 1);
      viewportScaleUniform.value = Math.max(scale, particles.minViewportScale);
    },
    dispose: () => {
      geometry.dispose();
      material.dispose();
    },
  };
};
```

### The television — `tv.ts`, verbatim

```ts
// 📖 Docs: obsidian/frontend/components/scene.md

/**
 * The floating television.
 *
 * A glTF model (`public/assets/grass-hero/tv.glb`) that loads asynchronously and
 * drops into a group placed up front — the scene does not wait for it, because
 * the group's position is what the camera frames and what the crest search
 * decided, neither of which depends on the geometry arriving.
 *
 * It is also a light source. Its screen lights the grass beneath it, and that is
 * done by handing the field's shaders a position, a facing and a colour (see
 * `TvHandle.light`) — not by adding a `THREE.Light`, which nothing in this scene
 * reads (ADR-0030).
 */

import * as THREE from "three";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

import { GRASS_FIELD_CONFIG as CONFIG } from "./grass-field.config";
import { TV_SCREEN_FRAGMENT_SHADER, TV_SCREEN_VERTEX_SHADER } from "./shaders";

/** What the field's shaders need to know about the screen each frame. */
export interface TvLightState {
  position: THREE.Vector3;
  /** Unit vector the screen faces. */
  direction: THREE.Vector3;
  /** Flicker multiplier on the screen's output, around 1. */
  flicker: number;
}

export interface TvHandle {
  object: THREE.Object3D;
  light: TvLightState;
  screenUniforms: {
    uCore: THREE.IUniform<THREE.Color>;
    uEdge: THREE.IUniform<THREE.Color>;
    uIntensity: THREE.IUniform<number>;
    uEdgeStart: THREE.IUniform<number>;
    uCentreLift: THREE.IUniform<number>;
  };
  /**
   * Advance the float, the flicker and the turn.
   *
   * `pointer` is the cursor in normalised device coordinates — x and y both in
   * [-1, 1], y up — or `null` when it has left, which returns the set to rest.
   */
  update: (
    elapsed: number,
    pointer: { x: number; y: number } | null,
    cameraPosition: THREE.Vector3,
  ) => void;
  dispose: () => void;
}

export interface FloatingTvOptions {
  /** Shared loading manager, so the model's requests count toward scene progress. */
  manager?: THREE.LoadingManager;
  /** Called once the model has landed, or failed to — either way it is settled. */
  onSettled?: () => void;
  /**
   * Largest texture edge to upload, in px. The model ships 30 maps at 1024²;
   * decoded to RGBA with mipmaps that is ~160 MB of VRAM, for a set that covers
   * a hundred-odd pixels on a phone. Larger maps are downscaled on the CPU
   * during the loader, before their first (and only) upload.
   */
  maxTextureSize?: number;
}

export const createFloatingTv = (
  restPosition: THREE.Vector3,
  { manager, onSettled, maxTextureSize = Infinity }: FloatingTvOptions = {},
): TvHandle => {
  const { tv } = CONFIG;

  const group = new THREE.Group();
  group.position.copy(restPosition);

  /* ------------------------------------------------------------ the screen */

  const screenGlowUniform: THREE.IUniform<number> = { value: 1 };
  // Local-space centre and half-extent of the glass, filled in once the model is
  // measured. The screen shader works from geometry position rather than UVs,
  // because the model's UV layout is its own business and the picture has to
  // land on the glass exactly.
  const screenCenterUniform: THREE.IUniform<THREE.Vector3> = {
    value: new THREE.Vector3(),
  };
  const screenExtentUniform: THREE.IUniform<THREE.Vector3> = {
    value: new THREE.Vector3(1, 1, 1),
  };
  const screenUniforms = {
    uCore: { value: new THREE.Color(tv.screen.coreColor) },
    uEdge: { value: new THREE.Color(tv.screen.edgeColor) },
    uIntensity: { value: tv.screen.intensity } as THREE.IUniform<number>,
    uGlow: screenGlowUniform,
    uCenter: screenCenterUniform,
    uExtent: screenExtentUniform,
    uEdgeStart: { value: tv.screen.edgeStart } as THREE.IUniform<number>,
    uCentreLift: { value: tv.screen.centreLift } as THREE.IUniform<number>,
  };

  const screenMaterial = new THREE.ShaderMaterial({
    uniforms: screenUniforms,
    vertexShader: TV_SCREEN_VERTEX_SHADER,
    fragmentShader: TV_SCREEN_FRAGMENT_SHADER,
    toneMapped: false,
  });

  /* -------------------------------------------------------------- the model */

  // Screen centre and facing in the group's own space. Defaults are replaced by
  // measurements once the model lands; until then the light sits at the group
  // origin, which is within a metre of where it ends up.
  const screenLocal = new THREE.Vector3(0, 0, 0);
  const facingLocal = new THREE.Vector3(0, 0, 1);

  // The model's meshes are Draco-compressed, so the loader needs a decoder —
  // `tv.dracoDecoderPath`, the three 0.160.1 build the import map pins.
  const dracoLoader = new DRACOLoader(manager);
  dracoLoader.setDecoderPath(tv.dracoDecoderPath);
  const loader = new GLTFLoader(manager);
  loader.setDRACOLoader(dracoLoader);

  // This model carries two texture entries with an empty `extensions: {}` and no
  // `source` — an export bug. Every built-in plugin declines them, the default
  // path then reads `images[undefined].uri`, and the whole load rejects: one
  // malformed texture costs the entire television. They are base-colour maps, so
  // a white 1x1 lets the material fall back to its own base colour, which is
  // what a missing base-colour map should do anyway.
  const fallbackTexture = new THREE.DataTexture(
    new Uint8Array([255, 255, 255, 255]),
    1,
    1,
  );
  fallbackTexture.needsUpdate = true;
  loader.register((parser) => ({
    name: "grass-field-missing-texture-source",
    loadTexture(textureIndex: number) {
      const textureDef = parser.json.textures[textureIndex];
      const hasSource = textureDef?.source !== undefined;
      const hasExtension =
        textureDef?.extensions !== undefined &&
        Object.keys(textureDef.extensions).length > 0;
      if (hasSource || hasExtension) return null;
      return Promise.resolve(fallbackTexture);
    },
  }));
  /** Redraw any map larger than `maxTextureSize` into a smaller canvas. */
  const capTextureSizes = (root: THREE.Object3D): void => {
    if (!Number.isFinite(maxTextureSize)) return;
    const seen = new Set<THREE.Texture>();
    root.traverse((child) => {
      if (!(child instanceof THREE.Mesh)) return;
      const materials = Array.isArray(child.material) ? child.material : [child.material];
      for (const material of materials) {
        for (const value of Object.values(material)) {
          if (!(value instanceof THREE.Texture) || seen.has(value)) continue;
          seen.add(value);
          const image = value.image as { width?: number; height?: number } | undefined;
          const width = image?.width ?? 0;
          const height = image?.height ?? 0;
          if (Math.max(width, height) <= maxTextureSize) continue;
          const scale = maxTextureSize / Math.max(width, height);
          const canvas = document.createElement("canvas");
          canvas.width = Math.max(1, Math.round(width * scale));
          canvas.height = Math.max(1, Math.round(height * scale));
          const context = canvas.getContext("2d");
          if (!context) continue;
          context.drawImage(value.image as CanvasImageSource, 0, 0, canvas.width, canvas.height);
          const previous = value.image as { close?: () => void };
          value.image = canvas;
          value.needsUpdate = true;
          previous.close?.();
        }
      }
    });
  };

  const handleModel = (gltf: { scene: THREE.Group }): void => {
    const model = gltf.scene;
    capTextureSizes(model);

    // The model is authored Z-up with its screen facing -X. The rest of this
    // scene assumes a set whose screen faces local +Z, so it is yawed a quarter
    // turn; the glTF node transform already handles Z-up to Y-up.
    model.rotation.y = Math.PI / 2;
    model.updateMatrixWorld(true);

    // Normalise: scale so the cabinet is `tv.width` across, then sit the model's
    // centre on the group origin, so the float and the turn pivot through it.
    const bounds = new THREE.Box3().setFromObject(model);
    const size = new THREE.Vector3();
    const center = new THREE.Vector3();
    bounds.getSize(size);
    bounds.getCenter(center);
    const scale = size.x > 0 ? tv.width / size.x : 1;
    model.scale.setScalar(scale);
    model.position.set(-center.x * scale, -center.y * scale, -center.z * scale);
    group.add(model);
    group.updateMatrixWorld(true);

    // Find the glass and light it. GLTFLoader splits a multi-material mesh into
    // one child per primitive, so this is a material-name match, not a mesh one.
    let screenMesh: THREE.Mesh | null = null;
    model.traverse((child) => {
      if (!(child instanceof THREE.Mesh)) return;
      const material = child.material;
      const name = Array.isArray(material) ? "" : (material?.name ?? "");
      if (name === tv.screenMaterialName) screenMesh = child;
    });

    if (screenMesh !== null) {
      const mesh: THREE.Mesh = screenMesh;
      const previous = mesh.material;
      if (!Array.isArray(previous)) previous.dispose();
      mesh.material = screenMaterial;

      // Local bounds drive the picture's falloff; world bounds put the light on
      // the glass itself rather than at a guessed offset from the cabinet.
      mesh.geometry.computeBoundingBox();
      const localBounds = mesh.geometry.boundingBox;
      if (localBounds) {
        const localCenter = new THREE.Vector3();
        const localSize = new THREE.Vector3();
        localBounds.getCenter(localCenter);
        localBounds.getSize(localSize);
        screenCenterUniform.value.copy(localCenter);
        screenExtentUniform.value.set(
          Math.max(localSize.x, 1e-4) * 0.5,
          Math.max(localSize.y, 1e-4) * 0.5,
          Math.max(localSize.z, 1e-4) * 0.5,
        );
      }

      const worldBounds = new THREE.Box3().setFromObject(mesh);
      const worldCenter = new THREE.Vector3();
      worldBounds.getCenter(worldCenter);
      screenLocal.copy(group.worldToLocal(worldCenter));
    }
  };

  // A failed load still settles: the scene renders without its television
  // rather than holding the page behind a preloader forever.
  loader.load(
    tv.modelUrl,
    (gltf) => {
      handleModel(gltf);
      onSettled?.();
    },
    undefined,
    () => onSettled?.(),
  );

  /* ------------------------------------------------------------- animation */

  const restTilt = THREE.MathUtils.degToRad(tv.tiltDegrees);
  const restYaw = THREE.MathUtils.degToRad(tv.yawDegrees);
  const sway = THREE.MathUtils.degToRad(tv.swayDegrees);

  // YXZ: yaw about world up first, then pitch about the set's own side axis,
  // then roll. With the default XYZ the pitch is applied before the yaw, so a
  // turned set tips sideways instead of tipping back.
  group.rotation.order = "YXZ";
  group.rotation.set(restTilt, restYaw, 0);

  let currentYaw = restYaw;
  let currentPitch = restTilt;
  let currentRoll = 0;

  /** Shortest signed representation of an angle, in (-π, π]. */
  const wrapAngle = (angle: number): number => {
    let wrapped = angle % (Math.PI * 2);
    if (wrapped > Math.PI) wrapped -= Math.PI * 2;
    if (wrapped < -Math.PI) wrapped += Math.PI * 2;
    return wrapped;
  };

  const approachAngle = (from: number, to: number, rate: number): number =>
    from + wrapAngle(to - from) * rate;

  const light: TvLightState = {
    position: new THREE.Vector3(),
    direction: new THREE.Vector3(),
    flicker: 1,
  };

  const update = (
    elapsed: number,
    pointer: { x: number; y: number } | null,
    cameraPosition: THREE.Vector3,
  ): void => {
    // Rest attitude is "facing the camera", plus the configured offsets. The
    // cursor then nudges it from there, a little on each axis. Because the nudge
    // is bounded by its own range there is no clamping to do and no way for the
    // set to turn its back — the constraint is the amplitude, not a cage.
    const toCameraX = cameraPosition.x - group.position.x;
    const toCameraZ = cameraPosition.z - group.position.z;
    const toCameraY = cameraPosition.y - group.position.y;
    const cameraYaw = Math.atan2(toCameraX, toCameraZ);
    const cameraPitch = -Math.atan2(toCameraY, Math.hypot(toCameraX, toCameraZ));

    const nudgeX = pointer ? pointer.x : 0;
    const nudgeY = pointer ? pointer.y : 0;

    const targetYaw =
      cameraYaw + restYaw + nudgeX * THREE.MathUtils.degToRad(tv.follow.yaw);
    // Rotating by θ about X sends local +Z to (0, -sinθ, cosθ): to look *up* at
    // a cursor near the top of the frame, θ has to go negative.
    const targetPitch =
      cameraPitch +
      restTilt -
      nudgeY * THREE.MathUtils.degToRad(tv.follow.pitch);
    const targetRoll = -nudgeX * THREE.MathUtils.degToRad(tv.follow.roll);

    currentYaw = approachAngle(currentYaw, targetYaw, tv.followRate);
    currentPitch += (targetPitch - currentPitch) * tv.followRate;
    currentRoll += (targetRoll - currentRoll) * tv.followRate;

    group.position.y =
      restPosition.y + Math.sin(elapsed * tv.bobSpeed) * tv.bobAmplitude;
    group.rotation.y = currentYaw;
    group.rotation.z = currentRoll + Math.sin(elapsed * tv.swaySpeed) * sway;
    group.rotation.x =
      currentPitch + Math.sin(elapsed * tv.swaySpeed * 0.7) * sway * 0.4;
    group.updateMatrixWorld();

    // Two offset sines rather than a random: a CRT's unsteadiness drifts, and
    // per-frame noise reads as a strobe at 60fps.
    const flicker =
      1 +
      (Math.sin(elapsed * 11.3) * 0.6 + Math.sin(elapsed * 27.1) * 0.4) *
        tv.screen.flicker;
    light.flicker = flicker;
    screenGlowUniform.value = flicker;

    light.position.copy(screenLocal).applyMatrix4(group.matrixWorld);
    light.direction
      .copy(facingLocal)
      .transformDirection(group.matrixWorld)
      .normalize();
  };

  update(0, null, new THREE.Vector3(0, 0, 1));

  return {
    object: group,
    light,
    screenUniforms: {
      uCore: screenUniforms.uCore,
      uEdge: screenUniforms.uEdge,
      uIntensity: screenUniforms.uIntensity,
      uEdgeStart: screenUniforms.uEdgeStart,
      uCentreLift: screenUniforms.uCentreLift,
    },
    update,
    dispose: () => {
      dracoLoader.dispose();
      fallbackTexture.dispose();
      screenMaterial.dispose();
      group.traverse((child) => {
        if (!(child instanceof THREE.Mesh)) return;
        child.geometry.dispose();
        const material = child.material;
        if (Array.isArray(material)) {
          for (const entry of material) entry.dispose();
        } else if (material !== screenMaterial) {
          material.dispose();
        }
      });
    },
  };
};
```

### The builder — `create-grass-scene.ts`, verbatim

Imports (`three` and the modules above):

```ts
import * as THREE from "three";

import {
  GRASS_FIELD_CONFIG as CONFIG,
  GRASS_FIELD_SEED,
} from "./grass-field.config";
import {
  createHeightSampler,
  createNoise2D,
  mulberry32,
  terrainNormal,
} from "./terrain";
import {
  BEND_SPRING_FRAGMENT_SHADER,
  BEND_STAMP_FRAGMENT_SHADER,
  BLOOM_BLUR_FRAGMENT_SHADER,
  BLOOM_BRIGHT_FRAGMENT_SHADER,
  DOF_FRAGMENT_SHADER,
  DOF_VERTEX_SHADER,
  GRASS_FRAGMENT_SHADER,
  GRASS_VERTEX_SHADER,
  GROUND_FRAGMENT_SHADER,
  GROUND_VERTEX_SHADER,
  SKY_FRAGMENT_SHADER,
  SKY_VERTEX_SHADER,
} from "./shaders";
import { createSunShadowMap } from "./sun-shadow";
import { createParticles, type ParticleHandle } from "./particles";
import { createFloatingTv, type TvHandle } from "./tv";
// byTier, clampedPixelRatio, DeviceTier — from the device module below
```

The handle, the builder and the frame:

```ts
/** Imperative handle the leaf drives. */
export interface GrassSceneHandle {
  /** Advance the clocks and draw one frame. */
  render: () => void;
  /** Re-read the canvas size — pixel ratio, drawing buffer and camera aspect. */
  resize: () => void;
  /** Aim the breeze at a viewport point (client coordinates). */
  setPointer: (clientX: number, clientY: number) => void;
  /** Park the breeze off the field, so it is simply absent. */
  parkPointer: () => void;
  /** Blow one strong gust across the field that then calms (`windGust`). */
  gust: () => void;
  /**
   * Re-apply every per-tier budget: DPR, blade and mote counts, MSAA, bloom.
   * Sizes, counts and uniforms only — it never changes a define, so it never
   * compiles a program.
   */
  retune: (tier: DeviceTier) => void;
  /** Release every GPU resource this scene allocated. */
  dispose: () => void;
}

/** Hand-rolled quaternion multiply on `Vector4`, matching the source field builder. */
const multiplyQuaternions = (a: THREE.Vector4, b: THREE.Vector4): void => {
  const qax = a.x;
  const qay = a.y;
  const qaz = a.z;
  const qaw = a.w;
  const qbx = b.x;
  const qby = b.y;
  const qbz = b.z;
  const qbw = b.w;
  a.set(
    qax * qbw + qaw * qbx + qay * qbz - qaz * qby,
    qay * qbw + qaw * qby + qaz * qbx - qax * qbz,
    qaz * qbw + qaw * qbz + qax * qby - qay * qbx,
    qaw * qbw - qax * qbx - qay * qby - qaz * qbz,
  );
};

export interface GrassSceneOptions {
  /** Device tier at construction. Retune later with `handle.retune(tier)`. */
  tier?: DeviceTier;
  /** Fraction (0–1) of the scene's network requests that have finished. */
  onLoadProgress?: (fraction: number) => void;
  /**
   * Called once, after every texture and the television have settled **and** a
   * frame has been drawn with them — so shader compilation is already paid for
   * and nothing pops in after a preloader lifts.
   */
  onReady?: () => void;
}

export const createGrassScene = (
  canvas: HTMLCanvasElement,
  { tier: initialTier = "desktop", onLoadProgress, onReady }: GrassSceneOptions = {},
): GrassSceneHandle => {
  const { colors, blade, camera: cameraConfig, pointer } = CONFIG;

  /* ---------------------------------------------------------------- renderer */

  let tier: DeviceTier = initialTier;

  // `antialias: false` on every tier: the default framebuffer only ever draws
  // the full-screen composite quad, so MSAA there anti-aliased nothing. Geometry
  // is anti-aliased on the offscreen scene target instead (`tiers.msaaSamples`).
  // Opaque canvas, no stencil anywhere in the chain.
  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: false,
    alpha: false,
    stencil: false,
    powerPreference: tier === "desktop" ? "high-performance" : "default",
  });
  renderer.setPixelRatio(clampedPixelRatio(tier));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = CONFIG.renderer.toneMappingExposure;

  const scene = new THREE.Scene();
  // No `scene.fog`: it only reaches materials that implement it, and every
  // material here is custom. The blades and the ground apply the same
  // exponential fog from these uniforms instead, so they recede together — which
  // they did not when the fog reached only the ground, leaving the far hills
  // hazed into the sky behind fully saturated grass.
  const fogColorUniform: THREE.IUniform<THREE.Color> = {
    value: new THREE.Color(colors.haze).convertSRGBToLinear(),
  };
  const fogDensityUniform: THREE.IUniform<number> = {
    value: CONFIG.fog.density,
  };

  // The television's screen, as seen by everything it lights. One set of
  // uniform objects shared by the grass and the ground, so the pool of light
  // under the set is continuous across the two materials instead of being two
  // pools that drift apart.
  const tvPositionUniform: THREE.IUniform<THREE.Vector3> = {
    value: new THREE.Vector3(),
  };
  const tvDirectionUniform: THREE.IUniform<THREE.Vector3> = {
    value: new THREE.Vector3(0, 0, 1),
  };
  const tvFlickerUniform: THREE.IUniform<number> = { value: 1 };
  const tvLightUniforms = {
    uTvPosition: tvPositionUniform,
    uTvDirection: tvDirectionUniform,
    uTvColor: { value: new THREE.Color(CONFIG.tv.light.color) },
    uTvIntensity: { value: CONFIG.tv.light.intensity } as THREE.IUniform<number>,
    uTvRadius: { value: CONFIG.tv.light.radius } as THREE.IUniform<number>,
    uTvFocus: { value: CONFIG.tv.light.focus } as THREE.IUniform<number>,
    uTvFlicker: tvFlickerUniform,
  };

  // Placed once and never re-aimed — no orbit, zoom, pan or parallax. The only
  // motion is a few centimetres of handheld sway applied per frame as an offset
  // from this placement (see `camera.sway`), which is why the placement is kept
  // rather than accumulated into. Height is resolved against the terrain further
  // down, once the height sampler exists.
  const camera = new THREE.PerspectiveCamera(
    cameraConfig.fov,
    1,
    cameraConfig.near,
    cameraConfig.far,
  );

  /* -------------------------------------------------------------------- sun */

  const sun = new THREE.Vector3();
  sun.setFromSphericalCoords(
    1,
    THREE.MathUtils.degToRad(90 - CONFIG.sky.sunElevationDeg),
    THREE.MathUtils.degToRad(CONFIG.sky.sunAzimuthDeg),
  );

  /* --------------------------------------------------------------- textures */

  // One manager for every request the scene makes, so progress is the scene's
  // own and not whatever else happens to use three's global default manager.
  const loadingManager = new THREE.LoadingManager();
  // Progress is half network (the manager) and half the blade build, which is
  // sliced across frames and can outlast the requests on a slow CPU.
  let networkFraction = 0;
  let buildFraction = 0;
  const reportProgress = (): void => {
    onLoadProgress?.((networkFraction + buildFraction) / 2);
  };
  loadingManager.onProgress = (_url, loaded, total) => {
    if (total > 0) networkFraction = loaded / total;
    reportProgress();
  };
  // Settled assets, counted explicitly: the manager's own `onLoad` fires early,
  // before the Draco decoder's requests have even started.
  let pendingAssets = 4; // two textures, the television, the blade buffers
  let readyReported = false;
  const settleAsset = (): void => {
    pendingAssets -= 1;
  };

  const loader = new THREE.TextureLoader(loadingManager);
  const grassTexture = loader.load(CONFIG.textures.diffuse, settleAsset, undefined, settleAsset);
  grassTexture.colorSpace = THREE.SRGBColorSpace; // diffuse is colour data
  // alpha is data — stays linear
  const alphaMap = loader.load(CONFIG.textures.alpha, settleAsset, undefined, settleAsset);

  /* --------------------------------------------------------------- the sky */

  // Typed handles on the uniforms the render loop writes. The uniform objects
  // themselves stay inferred object literals so they satisfy three's
  // `{ [uniform: string]: IUniform }` without an assertion.
  const skyTimeUniform: THREE.IUniform<number> = { value: 0 };
  // Shared with the ground, so one cloud shadow crosses both as a single band.
  const grassTimeUniform: THREE.IUniform<number> = { value: 0 };
  const windTimeUniform: THREE.IUniform<number> = { value: 0 };
  const windStrengthUniform: THREE.IUniform<number> = { value: 1 };
  const windLeanUniform: THREE.IUniform<number> = { value: 0 };
  const windWaveUniform: THREE.IUniform<number> = { value: CONFIG.windGust.wave };

  const skyUniforms = {
    uTop: { value: new THREE.Color(colors.skyTop).convertSRGBToLinear() },
    uHorizon: {
      value: new THREE.Color(colors.skyHorizon).convertSRGBToLinear(),
    },
    uSunColor: { value: new THREE.Color(colors.sun).convertSRGBToLinear() },
    uSunDir: { value: sun.clone() },
    uTime: skyTimeUniform,
    uCloudColor: { value: new THREE.Color(colors.cloud).convertSRGBToLinear() },
    uCloudCover: { value: CONFIG.sky.cloudCover } as THREE.IUniform<number>,
    uCloudOpacity: { value: CONFIG.sky.cloudOpacity } as THREE.IUniform<number>,
    uCloudBrightness: { value: CONFIG.sky.cloudBrightness } as THREE.IUniform<number>,
    uCloudGate: {
      value: new THREE.Vector2(CONFIG.sky.cloudGateLow, CONFIG.sky.cloudGateHigh),
    },
    uCloudSpeed: { value: CONFIG.sky.cloudSpeed },
    uHorizonGlow: {
      value: new THREE.Color(colors.horizonGlow).convertSRGBToLinear(),
    },
    uHorizonGlowStrength: { value: CONFIG.sky.horizonGlowStrength },
  };

  const skyMaterial = new THREE.ShaderMaterial({
    side: THREE.BackSide,
    depthWrite: false,
    fog: false,
    // Exposure-independent: the grass exposure can move without washing the
    // horizon band this camera frames out to white.
    toneMapped: false,
    uniforms: skyUniforms,
    vertexShader: SKY_VERTEX_SHADER,
    fragmentShader: SKY_FRAGMENT_SHADER,
  });

  const skyGeometry = new THREE.SphereGeometry(
    CONFIG.sky.radius,
    CONFIG.sky.widthSegments,
    CONFIG.sky.heightSegments,
  );
  const sky = new THREE.Mesh(skyGeometry, skyMaterial);
  sky.frustumCulled = false;
  scene.add(sky);

  /* ---------------------------------------------------------------- terrain */

  // One sampler feeds the ground mesh, every blade's Y offset and the camera
  // height, so the blades sit exactly on the surface and the eye sits exactly in
  // the canopy however the relief is retuned.
  const getYPosition = createHeightSampler(GRASS_FIELD_SEED, CONFIG.terrain);

  const eyeY =
    getYPosition(cameraConfig.x, cameraConfig.z) + cameraConfig.eyeHeight;
  camera.position.set(cameraConfig.x, eyeY, cameraConfig.z);

  /* ----------------------------------------------------------- sun shadow */

  const shadow = createSunShadowMap(getYPosition, sun, {
    centerX: cameraConfig.x,
    centerZ: cameraConfig.z,
    // Covers the blade disc, with room for blockers just outside it.
    size: CONFIG.fieldRadius * 2.4,
    resolution: CONFIG.grassLight.sunShadow.resolution,
    reach: CONFIG.grassLight.sunShadow.reach,
    steps: CONFIG.grassLight.sunShadow.steps,
    softness: CONFIG.grassLight.sunShadow.softness,
  });

  const sunShadowUniforms = {
    uShadowMap: { value: shadow.texture },
    uShadowOrigin: { value: shadow.origin },
    uShadowSize: { value: shadow.size },
    uShadowStrength: { value: CONFIG.grassLight.sunShadow.strength },
  };


  // `yawDeg` chooses the direction the shot looks; the *aim* is settled below,
  // once the television has been placed, by pointing the camera at it. Framing
  // the subject beats guessing a pitch that happens to put it near the middle.
  const yaw = THREE.MathUtils.degToRad(cameraConfig.yawDeg);

  // The ground runs far past the blades so the relief carries to the horizon
  // instead of ending on a visible edge; fog dissolves the far end of it.
  const groundGeometry = new THREE.PlaneGeometry(
    CONFIG.groundWidth,
    CONFIG.groundWidth,
    CONFIG.groundSegments,
    CONFIG.groundSegments,
  );
  groundGeometry.rotateX(-Math.PI / 2);
  const groundPosition = groundGeometry.attributes.position;
  for (let i = 0; i < groundPosition.count; i++) {
    groundPosition.setY(
      i,
      getYPosition(groundPosition.getX(i), groundPosition.getZ(i)),
    );
  }
  groundGeometry.computeVertexNormals();

  const groundMaterial = new THREE.ShaderMaterial({
    uniforms: {
      uBaseColor: { value: new THREE.Color(colors.groundBase) },
      uTipColor: { value: new THREE.Color(colors.groundTip) },
      uSunColor: { value: new THREE.Color(colors.sun) },
      uSkyColor: { value: new THREE.Color(colors.skyLight) },
      uSunDir: { value: sun.clone() },
      uAmbient: { value: CONFIG.grassLight.ambient },
      uDiffuse: { value: CONFIG.grassLight.diffuse },
      uTerrainContrast: {
        value: new THREE.Vector2(...CONFIG.grassLight.terrainContrast),
      },
      uTime: grassTimeUniform,
      uCloudShadowScale: { value: CONFIG.grassLight.cloudShadow.scale },
      uCloudShadowSpeed: { value: CONFIG.grassLight.cloudShadow.speed },
      uCloudShadowStrength: { value: CONFIG.grassLight.cloudShadow.strength },
      uFogColor: fogColorUniform,
      uFogDensity: fogDensityUniform,
      ...tvLightUniforms,
      ...sunShadowUniforms,
    },
    vertexShader: GROUND_VERTEX_SHADER,
    fragmentShader: GROUND_FRAGMENT_SHADER,
  });
  const ground = new THREE.Mesh(groundGeometry, groundMaterial);
  scene.add(ground);

  /* ------------------------------------------------------------- the field */

  // Preallocated typed arrays, filled in slices across frames (see
  // `buildBlades`). The old build pushed into plain arrays and allocated three
  // Vector3s per blade — 1.4 M objects in one 110 ms task on a desktop, several
  // times that on a phone, freezing the preloader while it ran.
  const total = CONFIG.instances;
  const offsets = new Float32Array(total * 3);
  const orientations = new Float32Array(total * 4);
  const stretches = new Float32Array(total);
  const halfRootAngleSin = new Float32Array(total);
  const halfRootAngleCos = new Float32Array(total);
  const groundLights = new Float32Array(total);
  /** Per instance: x = colour/brightness tint, y = canopy occlusion. */
  const variations = new Float32Array(total * 2);

  // Patch field for blade height. Seeded apart from the relief so retuning one
  // does not reshuffle the other.
  const clumpNoise = createNoise2D((GRASS_FIELD_SEED ^ 0x85ebca6b) >>> 0);

  const quaternion0 = new THREE.Vector4();
  const quaternion1 = new THREE.Vector4();

  // Layout stream, derived from the same seed as the relief but independent of
  // it — so the field comes back identical on every rebuild.
  const rand = mulberry32((GRASS_FIELD_SEED ^ 0x9e3779b9) >>> 0);

  // The camera heading as a polar angle in the scatter's (cos, sin) → (x, z)
  // convention: yaw 0 looks down -Z.
  const headingTheta = Math.atan2(-Math.cos(yaw), Math.sin(yaw));
  const fieldArc = THREE.MathUtils.degToRad(CONFIG.fieldArcDegrees);

  /** Rotation about a principal axis as a unit quaternion, written into `out`. */
  const axisQuaternion = (
    out: THREE.Vector4,
    axis: 0 | 1 | 2,
    angle: number,
  ): THREE.Vector4 => {
    const half = Math.sin(angle / 2);
    return out.set(
      axis === 0 ? half : 0,
      axis === 1 ? half : 0,
      axis === 2 ? half : 0,
      Math.cos(angle / 2),
    );
  };

  const writeBlade = (i: number): void => {
    // Polar scatter from the camera, within the arc it can actually see. `sqrt`
    // would give uniform area density; the configured bias concentrates blades
    // where they cover pixels instead of wasting them on the far distance.
    const radius =
      CONFIG.fieldRadius * Math.pow(rand(), CONFIG.fieldDensityBias);
    const theta = headingTheta + (rand() - 0.5) * fieldArc;
    const x = cameraConfig.x + Math.cos(theta) * radius;
    const z = cameraConfig.z + Math.sin(theta) * radius;
    offsets[i * 3] = x;
    offsets[i * 3 + 1] = getYPosition(x, z);
    offsets[i * 3 + 2] = z;

    // Bake how much sun the ground under this blade catches. Blades are unlit
    // geometry, so this is the only thing that makes a mound read as a mound.
    const normal = terrainNormal(getYPosition, x, z);
    groundLights[i] = Math.max(
      normal.x * sun.x + normal.y * sun.y + normal.z * sun.z,
      0,
    );

    // 1) random full Y-axis rotation
    let angle = Math.PI - rand() * (2 * Math.PI);
    halfRootAngleSin[i] = Math.sin(0.5 * angle);
    halfRootAngleCos[i] = Math.cos(0.5 * angle);
    axisQuaternion(quaternion0, 1, angle);

    // 2) small random X-axis tilt, combined in
    angle = rand() * 0.5 - 0.25;
    multiplyQuaternions(quaternion0, axisQuaternion(quaternion1, 0, angle));

    // 3) small random Z-axis tilt, combined again
    angle = rand() * 0.5 - 0.25;
    multiplyQuaternions(quaternion0, axisQuaternion(quaternion1, 2, angle));

    orientations[i * 4] = quaternion0.x;
    orientations[i * 4 + 1] = quaternion0.y;
    orientations[i * 4 + 2] = quaternion0.z;
    orientations[i * 4 + 3] = quaternion0.w;

    // Height: a patch value times a within-patch draw. The original picked a
    // flat random for two thirds of blades and a taller flat random for the
    // rest, which produces an even spread of every height everywhere — grass
    // does not do that. Patches give the field its coarse structure and the
    // skewed draw keeps tall blades to a minority inside each patch.
    const patch =
      1 +
      CONFIG.height.clumpAmount *
        clumpNoise(x / CONFIG.height.clumpScale, z / CONFIG.height.clumpScale);
    const draw = Math.pow(rand(), CONFIG.height.skew);
    stretches[i] =
      patch *
      (CONFIG.height.minStretch +
        (CONFIG.height.maxStretch - CONFIG.height.minStretch) * draw);

    // A blade shorter than its own patch is buried under its neighbours and sits
    // in their shade. `draw` is already that relative height, so the occlusion
    // comes free — no neighbour search.
    variations[i * 2] = rand();
    variations[i * 2 + 1] = 1 - CONFIG.grassLight.canopyAo * (1 - draw);
  };

  // Translated up by half its height so the origin sits at the root: every
  // rotation in the vertex shader then pivots about the ground, which is what
  // makes a blade bend instead of swing. 1x5 segments = six vertex rows, the
  // entire bending resolution — fewer and the bend visibly creases.
  const baseGeometry = new THREE.PlaneGeometry(
    blade.width,
    blade.height,
    1,
    blade.joints,
  ).translate(0, blade.height / 2, 0);

  const grassGeometry = new THREE.InstancedBufferGeometry();
  grassGeometry.index = baseGeometry.index;
  grassGeometry.setAttribute("position", baseGeometry.attributes.position);
  grassGeometry.setAttribute("uv", baseGeometry.attributes.uv);
  const bladeAttributes = [
    new THREE.InstancedBufferAttribute(offsets, 3),
    new THREE.InstancedBufferAttribute(orientations, 4),
    new THREE.InstancedBufferAttribute(stretches, 1),
    new THREE.InstancedBufferAttribute(halfRootAngleSin, 1),
    new THREE.InstancedBufferAttribute(halfRootAngleCos, 1),
    new THREE.InstancedBufferAttribute(groundLights, 1),
    new THREE.InstancedBufferAttribute(variations, 2),
  ];
  const bladeAttributeNames = [
    "offset",
    "orientation",
    "stretch",
    "halfRootAngleSin",
    "halfRootAngleCos",
    "groundLight",
    "variation",
  ];
  bladeAttributes.forEach((attribute, index) =>
    grassGeometry.setAttribute(bladeAttributeNames[index], attribute),
  );
  // Nothing drawn until the buffers are complete — the preloader covers it.
  grassGeometry.instanceCount = 0;
  baseGeometry.dispose();

  /** Longest slice of main-thread time the blade build may take per frame. */
  const BUILD_BUDGET_MS = 8;
  let bladesBuilt = 0;
  /**
   * Fill the next slice of blades. On completion the buffers are flagged for a
   * single upload and the tier's count is applied. Returns whether it is done.
   */
  const buildBlades = (): boolean => {
    if (bladesBuilt >= total) return true;
    const deadline = performance.now() + BUILD_BUDGET_MS;
    // Check the clock every 512 blades, not every blade.
    while (bladesBuilt < total && performance.now() < deadline) {
      const end = Math.min(bladesBuilt + 512, total);
      for (let index = bladesBuilt; index < end; index++) writeBlade(index);
      bladesBuilt = end;
    }
    buildFraction = bladesBuilt / total;
    reportProgress();
    if (bladesBuilt < total) return false;
    for (const attribute of bladeAttributes) attribute.needsUpdate = true;
    grassGeometry.instanceCount = byTier(tier, CONFIG.tiers.instances);
    settleAsset();
    return true;
  };

  // The square the bend map covers: centred on the camera and sized to the blade
  // disc, so every blade the eye can reach has a texel.
  const bendSize = CONFIG.fieldRadius * 2;
  const bendOrigin = new THREE.Vector2(
    cameraConfig.x - bendSize / 2,
    cameraConfig.z - bendSize / 2,
  );

  const grassUniforms = {
    map: { value: grassTexture },
    alphaMap: { value: alphaMap },
    time: grassTimeUniform,
    windTime: windTimeUniform,
    windStrength: windStrengthUniform,
    windLean: windLeanUniform,
    windWave: windWaveUniform,
    bladeHeight: { value: blade.height },
    tipColor: { value: new THREE.Color(colors.tip) },
    bottomColor: { value: new THREE.Color(colors.bottom) },
    uBendMap: { value: null } as THREE.IUniform<THREE.Texture | null>,
    uBendOrigin: { value: bendOrigin },
    uBendSize: { value: bendSize },
    uMouseStrength: {
      value: pointer.strength,
    } as THREE.IUniform<number>,
    uBendReference: { value: pointer.distanceReference } as THREE.IUniform<number>,
    uBendFarStrength: { value: pointer.farStrength } as THREE.IUniform<number>,
    uSunDir: { value: sun.clone() },
    uSunColor: { value: new THREE.Color(colors.sun) },
    uSkyColor: { value: new THREE.Color(colors.skyLight) },
    uDryColor: { value: new THREE.Color(colors.dry) },
    uTintRange: {
      value: new THREE.Vector2(...CONFIG.grassLight.tintRange),
    },
    uTerrainContrast: {
      value: new THREE.Vector2(...CONFIG.grassLight.terrainContrast),
    },
    uCloudShadowScale: { value: CONFIG.grassLight.cloudShadow.scale },
    uCloudShadowSpeed: { value: CONFIG.grassLight.cloudShadow.speed },
    uCloudShadowStrength: { value: CONFIG.grassLight.cloudShadow.strength },
    uAmbient: { value: CONFIG.grassLight.ambient },
    uBounce: { value: CONFIG.grassLight.bounce },
    uDiffuse: { value: CONFIG.grassLight.diffuse },
    uBladeWeight: { value: CONFIG.grassLight.bladeWeight },
    uWrap: { value: CONFIG.grassLight.wrap },
    uRootShade: { value: CONFIG.grassLight.rootShade },
    uSpecular: { value: CONFIG.grassLight.specular },
    uShininess: { value: CONFIG.grassLight.shininess },
    uTranslucency: { value: CONFIG.grassLight.translucency },
    uTranslucencyPower: { value: CONFIG.grassLight.translucencyPower },
    uFogColor: fogColorUniform,
    uFogDensity: fogDensityUniform,
    uFlowerColor: { value: new THREE.Color(colors.flower) },
    uFlowerFraction: { value: CONFIG.grassLight.flowerFraction },
    ...tvLightUniforms,
    ...sunShadowUniforms,
  };

  const grassMaterial = new THREE.ShaderMaterial({
    // A blade is a single quad strip — it must be visible from both sides.
    side: THREE.DoubleSide,
    uniforms: grassUniforms,
    vertexShader: GRASS_VERTEX_SHADER,
    fragmentShader: GRASS_FRAGMENT_SHADER,
  });

  const grassMesh = new THREE.Mesh(grassGeometry, grassMaterial);
  // The base blade's bounding sphere is meaningless once instances are
  // scattered in the shader — leaving culling on pops the whole field.
  grassMesh.frustumCulled = false;
  scene.add(grassMesh);

  /* ------------------------------------------------------------ interaction */

  const raycaster = new THREE.Raycaster();
  const ndc = new THREE.Vector2();
  const mouseTarget = new THREE.Vector2(pointer.parked, pointer.parked);
  // The cursor in normalised device coordinates. The television turns from this
  // rather than from the ground hit the breeze uses — a ground hit cannot say
  // "above the horizon", and the set needs to be able to look up.
  const pointerNdc = new THREE.Vector2();
  let pointerPresent = false;

  const setPointer = (clientX: number, clientY: number): void => {
    // Measured against the canvas rect rather than the window: the canvas is a
    // section on a page here, not a full-window overlay.
    const rect = canvas.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) return;

    ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
    ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
    pointerNdc.copy(ndc);
    pointerPresent = true;
    raycaster.setFromCamera(ndc, camera);

    // The grass never participates: 190k instances exist only on the GPU, and
    // the ground is the same height field anyway. A ray that misses keeps the
    // previous target, so the breeze holds rather than snapping away.
    // The breeze still wants a world point; a ray that misses the ground keeps
    // the previous one, so the wake holds rather than snapping away.
    const hits = raycaster.intersectObject(ground);
    if (hits.length > 0) mouseTarget.set(hits[0].point.x, hits[0].point.z);
  };

  const parkPointer = (): void => {
    mouseTarget.set(pointer.parked, pointer.parked);
    pointerPresent = false;
  };

  /* ------------------------------------------------------------ television */

  // Positioned along the camera's own view direction rather than at a world
  // point, so it stays framed if the camera is re-aimed.
  const viewX = Math.sin(yaw);
  const viewZ = -Math.cos(yaw);

  // Walk the view ray and keep the highest ground in the search window. A fixed
  // distance lands wherever the noise happens to put it — which the first
  // attempt did, in a hollow behind a rise, invisible.
  const [searchFrom, searchTo] = CONFIG.tv.searchRange;
  let tvX = cameraConfig.x + viewX * searchFrom;
  let tvZ = cameraConfig.z + viewZ * searchFrom;
  let crestHeight = -Infinity;
  for (let distance = searchFrom; distance <= searchTo; distance += 0.5) {
    const x = cameraConfig.x + viewX * distance - viewZ * CONFIG.tv.lateral;
    const z = cameraConfig.z + viewZ * distance + viewX * CONFIG.tv.lateral;
    const height = getYPosition(x, z);
    if (height > crestHeight) {
      crestHeight = height;
      tvX = x;
      tvZ = z;
    }
  }
  const tv: TvHandle = createFloatingTv(
    new THREE.Vector3(tvX, crestHeight + CONFIG.tv.hover, tvZ),
    {
      manager: loadingManager,
      onSettled: settleAsset,
      maxTextureSize: byTier(tier, CONFIG.tiers.maxTextureSize),
    },
  );
  scene.add(tv.object);

  /* ------------------------------------------------------------- particles */

  const particles: ParticleHandle = createParticles(
    (GRASS_FIELD_SEED ^ 0xc2b2ae35) >>> 0,
    new THREE.Vector2(cameraConfig.x, cameraConfig.z),
    eyeY - CONFIG.particles.height * 0.35,
  );
  scene.add(particles.object);

  // Lights are back — for the television only. ADR-0030 removed them because
  // nothing read one: the grass, ground and sky are all custom shaders that
  // ignore the light list entirely. The model is different, it arrives with
  // standard materials, and without a light it renders black. Since nothing else
  // in the scene consults them, these two illuminate exactly the set and cost a
  // program recompile on its materials alone.
  const keyLight = new THREE.DirectionalLight(
    new THREE.Color(colors.sun),
    CONFIG.tv.lighting.sun,
  );
  keyLight.position.copy(sun).multiplyScalar(50);
  scene.add(keyLight);
  const fillLight = new THREE.HemisphereLight(
    new THREE.Color(colors.skyLight),
    new THREE.Color(colors.groundTip),
    CONFIG.tv.lighting.sky,
  );
  scene.add(fillLight);

  // Now that the subject exists, frame it.
  camera.lookAt(tv.object.position);

  /* --------------------------------------------------------- the bend map */

  const makeBendTarget = () =>
    new THREE.WebGLRenderTarget(pointer.resolution, pointer.resolution, {
      depthBuffer: false,
      type: THREE.HalfFloatType,
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      wrapS: THREE.ClampToEdgeWrapping,
      wrapT: THREE.ClampToEdgeWrapping,
    });
  const bendTargets = [makeBendTarget(), makeBendTarget()];
  for (const target of bendTargets) target.texture.colorSpace = THREE.NoColorSpace;
  let bendSource = 0;

  const bendFrom: THREE.IUniform<THREE.Vector2> = {
    value: new THREE.Vector2(pointer.parked, pointer.parked),
  };
  const bendTo: THREE.IUniform<THREE.Vector2> = {
    value: new THREE.Vector2(pointer.parked, pointer.parked),
  };
  const bendDecay: THREE.IUniform<number> = { value: 1 };
  const bendActive: THREE.IUniform<number> = { value: 0 };

  const bendMaterial = new THREE.ShaderMaterial({
    depthTest: false,
    depthWrite: false,
    uniforms: {
      uPrevious: { value: bendTargets[0].texture },
      uOrigin: { value: bendOrigin },
      uSize: { value: bendSize },
      uFrom: bendFrom,
      uTo: bendTo,
      uRadius: { value: pointer.radius } as THREE.IUniform<number>,
      uDecay: bendDecay,
      uActive: bendActive,
      uCamera: { value: new THREE.Vector2(cameraConfig.x, cameraConfig.z) },
      uReference: grassUniforms.uBendReference,
      uFarRadius: { value: pointer.farRadius } as THREE.IUniform<number>,
    },
    vertexShader: DOF_VERTEX_SHADER,
    fragmentShader: BEND_STAMP_FRAGMENT_SHADER,
  });

  const pointerFollow: { seconds: number } = { seconds: pointer.follow };

  /** Recovery time (to ~37%) converted to a per-frame multiplier. */
  const bendRecovery: { seconds: number } = { seconds: pointer.recovery };

  // Inertia: a second ping-ponged pair holding a damped spring per texel that
  // chases the trail above. The grass reads this one, never the trail.
  const springTargets = [makeBendTarget(), makeBendTarget()];
  for (const target of springTargets) target.texture.colorSpace = THREE.NoColorSpace;
  let springSource = 0;
  const bendSpring: { frequency: number; damping: number } = { ...pointer.spring };
  const springDelta: THREE.IUniform<number> = { value: 0 };
  const springStiffness: THREE.IUniform<number> = { value: 0 };
  const springDamping: THREE.IUniform<number> = { value: 0 };

  const springMaterial = new THREE.ShaderMaterial({
    depthTest: false,
    depthWrite: false,
    uniforms: {
      uState: { value: springTargets[0].texture },
      uTrail: { value: bendTargets[0].texture },
      uDelta: springDelta,
      uStiffness: springStiffness,
      uDamping: springDamping,
    },
    vertexShader: DOF_VERTEX_SHADER,
    fragmentShader: BEND_SPRING_FRAGMENT_SHADER,
  });

  /** Longest step the explicit spring takes before it is split into substeps. */
  const SPRING_MAX_STEP = 1 / 60;

  const renderBend = (delta: number): void => {
    bendDecay.value = Math.exp(-delta / Math.max(bendRecovery.seconds, 0.01));
    quad.material = bendMaterial;
    bendMaterial.uniforms.uPrevious.value = bendTargets[bendSource].texture;
    renderer.setRenderTarget(bendTargets[1 - bendSource]);
    renderer.render(dofScene, dofCamera);
    bendSource = 1 - bendSource;

    const omega = 2 * Math.PI * Math.max(bendSpring.frequency, 0.01);
    springStiffness.value = omega * omega;
    springDamping.value = 2 * Math.max(bendSpring.damping, 0) * omega;
    const steps = Math.max(1, Math.min(Math.ceil(delta / SPRING_MAX_STEP), 6));
    springDelta.value = delta / steps;
    quad.material = springMaterial;
    springMaterial.uniforms.uTrail.value = bendTargets[bendSource].texture;
    for (let step = 0; step < steps; step++) {
      springMaterial.uniforms.uState.value = springTargets[springSource].texture;
      renderer.setRenderTarget(springTargets[1 - springSource]);
      renderer.render(dofScene, dofCamera);
      springSource = 1 - springSource;
    }
    grassUniforms.uBendMap.value = springTargets[springSource].texture;
  };

  /* ------------------------------------------------------- depth of field */

  // The scene renders here first. `depthTexture` is the point of the exercise:
  // the DOF pass reads the depth the normal pass already wrote, so it never has
  // to re-render the field with an override material.
  const renderTarget = new THREE.WebGLRenderTarget(1, 1, {
    depthBuffer: true,
    samples: byTier(tier, CONFIG.tiers.msaaSamples),
    // Half float because the buffer holds linear light: 8 bits of linear bands
    // visibly in the sky gradient and crushes everything in the shadows.
    type: THREE.HalfFloatType,
  });
  renderTarget.depthTexture = new THREE.DepthTexture(1, 1);
  renderTarget.depthTexture.format = THREE.DepthFormat;
  renderTarget.depthTexture.type = THREE.UnsignedIntType;
  // Left un-managed on purpose. three forces linear output into any render
  // target, so this buffer is linear light and the DOF pass does the sRGB
  // encode on the way to screen — tagging it here would double-convert.
  renderTarget.texture.colorSpace = THREE.NoColorSpace;
  renderTarget.texture.minFilter = THREE.LinearFilter;
  renderTarget.texture.magFilter = THREE.LinearFilter;

  const texelUniform: THREE.IUniform<THREE.Vector2> = {
    value: new THREE.Vector2(1, 1),
  };
  // Frame aspect, so the vignette's corner radius stays circular on screen
  // instead of stretching with the frame.
  const aspectUniform: THREE.IUniform<number> = { value: 1 };

  const dofMaterial = new THREE.ShaderMaterial({
    depthTest: false,
    depthWrite: false,
    uniforms: {
      uScene: { value: renderTarget.texture },
      uDepth: { value: renderTarget.depthTexture },
      uBloom: { value: null },
      uBloomIntensity: {
        value: CONFIG.bloom.intensity,
      } as THREE.IUniform<number>,
      uTexel: texelUniform,
      uNear: { value: cameraConfig.near },
      uFar: { value: cameraConfig.far },
      uFocusDistance: { value: CONFIG.dof.focusDistance },
      uNearRange: { value: CONFIG.dof.nearRange },
      uFarRange: { value: CONFIG.dof.farRange },
      uMaxNearBlur: { value: CONFIG.dof.maxNearBlur },
      uMaxFarBlur: { value: CONFIG.dof.maxFarBlur },
      uContrast: { value: CONFIG.grade.contrast },
      uSaturation: { value: CONFIG.grade.saturation },
      uLift: { value: CONFIG.grade.lift },
      uShadowTint: { value: new THREE.Color(CONFIG.grade.shadowTint) },
      uHighlightTint: { value: new THREE.Color(CONFIG.grade.highlightTint) },
      uTintStrength: { value: CONFIG.grade.tintStrength },
      uVignette: { value: CONFIG.grade.vignette },
      uVignetteRadius: { value: CONFIG.grade.vignetteRadius },
      uVignetteRoundness: { value: CONFIG.grade.vignetteRoundness },
      uVignetteSoftness: { value: CONFIG.grade.vignetteSoftness },
      uVignetteColor: { value: new THREE.Color(CONFIG.grade.vignetteColor) },
      uAspect: aspectUniform,
    },
    vertexShader: DOF_VERTEX_SHADER,
    fragmentShader: DOF_FRAGMENT_SHADER,
  });

  const dofGeometry = new THREE.PlaneGeometry(2, 2);
  // One quad and one camera, reused by every full-screen pass — the material is
  // swapped between them. The vertex shader writes clip space directly, so the
  // camera is only here because `render()` demands one.
  const quad = new THREE.Mesh(dofGeometry, dofMaterial);
  const dofScene = new THREE.Scene();
  dofScene.add(quad);
  const dofCamera = new THREE.Camera();

  /* ------------------------------------------------------------------ bloom */

  const makeBloomTarget = () =>
    new THREE.WebGLRenderTarget(1, 1, {
      depthBuffer: false,
      type: THREE.HalfFloatType,
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
    });
  const bloomTargets = [makeBloomTarget(), makeBloomTarget()];
  for (const target of bloomTargets) target.texture.colorSpace = THREE.NoColorSpace;

  const bloomBrightMaterial = new THREE.ShaderMaterial({
    depthTest: false,
    depthWrite: false,
    uniforms: {
      uScene: { value: renderTarget.texture },
      uThreshold: { value: CONFIG.bloom.threshold } as THREE.IUniform<number>,
      uKnee: { value: CONFIG.bloom.knee } as THREE.IUniform<number>,
    },
    vertexShader: DOF_VERTEX_SHADER,
    fragmentShader: BLOOM_BRIGHT_FRAGMENT_SHADER,
  });

  const bloomBlurDirection: THREE.IUniform<THREE.Vector2> = {
    value: new THREE.Vector2(),
  };
  const bloomBlurMaterial = new THREE.ShaderMaterial({
    depthTest: false,
    depthWrite: false,
    uniforms: {
      uSource: { value: bloomTargets[0].texture },
      uDirection: bloomBlurDirection,
    },
    vertexShader: DOF_VERTEX_SHADER,
    fragmentShader: BLOOM_BLUR_FRAGMENT_SHADER,
  });

  const bloomSpread: THREE.IUniform<number> = { value: CONFIG.bloom.spread };
  let bloomIterations = CONFIG.bloom.iterations;
  const bloomTexel = new THREE.Vector2(1, 1);

  /** Config bloom strength, before the tier's scale is applied. */
  let bloomBaseIntensity: number = CONFIG.bloom.intensity;
  const applyBloomIntensity = (): void => {
    dofMaterial.uniforms.uBloomIntensity.value =
      bloomBaseIntensity * byTier(tier, CONFIG.tiers.bloomScale);
  };
  applyBloomIntensity();

  /** Bright pass, then widening horizontal/vertical blur pairs, ping-ponged. */
  const renderBloom = (): void => {
    // A zero-strength bloom would still run a full-screen chain every frame.
    if (dofMaterial.uniforms.uBloomIntensity.value <= 0.001) return;
    quad.material = bloomBrightMaterial;
    renderer.setRenderTarget(bloomTargets[0]);
    renderer.render(dofScene, dofCamera);

    quad.material = bloomBlurMaterial;
    let source = 0;
    const iterations = Math.min(bloomIterations, byTier(tier, CONFIG.tiers.bloomMaxIterations));
    for (let i = 0; i < iterations; i++) {
      // Each pair steps wider, so a handful of 9-tap passes reach much further
      // than their kernel — the cheap way to a glow that crosses the frame.
      const step = bloomSpread.value * Math.pow(2, i);
      for (const axis of [0, 1]) {
        bloomBlurDirection.value.set(
          axis === 0 ? bloomTexel.x * step : 0,
          axis === 1 ? bloomTexel.y * step : 0,
        );
        bloomBlurMaterial.uniforms.uSource.value = bloomTargets[source].texture;
        renderer.setRenderTarget(bloomTargets[1 - source]);
        renderer.render(dofScene, dofCamera);
        source = 1 - source;
      }
    }
    dofMaterial.uniforms.uBloom.value = bloomTargets[source].texture;
  };

  /* ------------------------------------------------------------------ frame */

  const clock = new THREE.Clock();

  const resize = (): void => {
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    if (width === 0 || height === 0) return;

    const pixelRatio = clampedPixelRatio(tier);
    renderer.setPixelRatio(pixelRatio);
    particles.setPixelRatio(pixelRatio);
    particles.setViewport(width, height);
    // `false` — the canvas is CSS-sized, so three must not write style width/height.
    renderer.setSize(width, height, false);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    aspectUniform.value = width / height;

    // The offscreen target carries the pixel ratio too. Clamping the renderer
    // but not the target throws the DPR saving straight back away.
    const bufferWidth = Math.round(width * pixelRatio);
    const bufferHeight = Math.round(height * pixelRatio);
    renderTarget.setSize(bufferWidth, bufferHeight);
    const bloomWidth = Math.max(
      1,
      Math.round(bufferWidth / CONFIG.bloom.downsample),
    );
    const bloomHeight = Math.max(
      1,
      Math.round(bufferHeight / CONFIG.bloom.downsample),
    );
    for (const target of bloomTargets) target.setSize(bloomWidth, bloomHeight);
    bloomTexel.set(1 / bloomWidth, 1 / bloomHeight);
    // Blur radii are authored in pixels at 1080p, so they scale with the buffer
    // rather than changing character between a laptop and a phone.
    const blurScale = bufferHeight / 1080;
    texelUniform.value.set(
      blurScale / bufferWidth,
      blurScale / bufferHeight,
    );
  };

  // The placed camera, kept so the sway is an offset from it rather than an
  // accumulation that drifts away over a long session.
  const basePosition = camera.position.clone();
  const baseQuaternion = camera.quaternion.clone();
  const swayScale: THREE.IUniform<number> = { value: 1 };

  // Clocks that are integrated rather than read off `elapsed`, so a gust can
  // speed them up and let them slow down again without the motion jumping.
  let windClock = 0;
  let skyClock = 0;
  let particleClock = 0;
  let gustStartedAt: number | null = null;
  const windGust: Record<keyof typeof CONFIG.windGust, number> = { ...CONFIG.windGust };

  /**
   * 0 → 1 over `attack` (smoothstep), then a Gaussian calm back to 0. Both
   * halves have zero slope where they meet, so the gust never visibly "turns".
   */
  const gustEnvelope = (elapsed: number): number => {
    if (gustStartedAt === null) return 0;
    const t = elapsed - gustStartedAt;
    const attack = Math.max(windGust.attack, 0.001);
    if (t < attack) {
      const s = t / attack;
      return s * s * (3 - 2 * s);
    }
    const u = (t - attack) / Math.max(windGust.calm, 0.01);
    if (u > 3) {
      gustStartedAt = null;
      return 0;
    }
    return Math.exp(-u * u);
  };

  const gust = (): void => {
    gustStartedAt = clock.elapsedTime;
  };

  // The bend and spring passes only matter while a dent exists. With the
  // pointer unbound (mobile) they are skipped once the last dent has had time
  // to recover and the springs to settle.
  let pointerEnabled = true;
  let bendQuietAt = 0;

  // Prewarm (skill §3): once every asset has settled, upload every texture and
  // compile every scene program in one go, while the preloader still owns the
  // screen — never lazily on the first frame that happens to need them.
  let prewarmState: "pending" | "running" | "done" = "pending";
  const prewarm = (): void => {
    prewarmState = "running";
    scene.traverse((object) => {
      const material = (object as THREE.Mesh).material;
      const materials = Array.isArray(material) ? material : material ? [material] : [];
      for (const entry of materials) {
        for (const value of Object.values(entry)) {
          if (value instanceof THREE.Texture) renderer.initTexture(value);
        }
        const uniforms = (entry as THREE.ShaderMaterial).uniforms;
        if (!uniforms) continue;
        for (const uniform of Object.values(uniforms)) {
          if (uniform.value instanceof THREE.Texture) renderer.initTexture(uniform.value);
        }
      }
    });
    // Compiled against the offscreen target the scene really renders into: a
    // program's variant depends on its target (linear output, no tone mapping
    // there), so compiling for the default framebuffer builds variants that
    // are never used and still leaves the real ones to compile on first draw.
    // The bend and spring passes are skipped on tiers without a pointer, so
    // they would otherwise first compile on a later tier switch — after the
    // loader. Draw them once now, whatever the tier.
    renderBend(1 / 60);
    renderer.setRenderTarget(renderTarget);
    // `compileAsync` (KHR_parallel_shader_compile where available) keeps the
    // preloader's counter moving; `compile` blocked the main thread ~115 ms.
    renderer
      .compileAsync(scene, camera)
      .catch(() => renderer.compile(scene, camera))
      .finally(() => {
        prewarmState = "done";
      });
    renderer.setRenderTarget(null);
  };

  const render = (): void => {
    if (!buildBlades()) {
      // Keep the clock honest while building, so the first real frame does not
      // receive the whole build time as its delta.
      clock.getDelta();
      return;
    }
    if (prewarmState === "pending" && pendingAssets <= 0) prewarm();
    if (prewarmState === "running") {
      clock.getDelta();
      return;
    }
    const assetsSettledAtFrameStart = pendingAssets <= 0;
    // Clamped hard: a tab-switch return would otherwise hand the scene a
    // multi-second delta and every integrated clock would jump.
    const delta = Math.min(clock.getDelta(), 0.05);
    const elapsed = clock.elapsedTime;
    // Two clocks on purpose. The grass runs quartered, because the wind noise is
    // sampled in the same units as world position and full-speed time makes the
    // field shimmer. The sky runs raw and scales itself down inside the shader.
    grassTimeUniform.value = (elapsed / 4) * CONFIG.windSpeed;
    const envelope = gustEnvelope(elapsed);
    const boost = (peak: number) => 1 + (peak - 1) * envelope;
    windClock += (delta / 4) * CONFIG.windSpeed * boost(windGust.speed);
    windTimeUniform.value = windClock;
    windStrengthUniform.value = boost(windGust.sway);
    windLeanUniform.value = windGust.lean * envelope;
    windWaveUniform.value = windGust.wave;
    skyClock += delta * boost(windGust.clouds);
    skyTimeUniform.value = skyClock;
    particleClock += delta * boost(windGust.particles);
    // Ease the pointer, then stamp the segment it travelled this frame. The
    // segment matters: at speed the cursor crosses metres between frames, and
    // stamping only its current position leaves a dotted trail of dents.
    bendFrom.value.copy(bendTo.value);
    if (pointerPresent) {
      // Exponential follow on real time, so the trail is equally lazy at 30 and
      // 144 fps. The old fixed per-frame lerp was close to instant at 60.
      const follow = Math.max(pointerFollow.seconds, 0.001);
      bendTo.value.lerp(mouseTarget, 1 - Math.exp(-delta / follow));
      bendActive.value = 1;
    } else {
      bendActive.value = 0;
    }
    if (bendFrom.value.x > pointer.parked * 0.5) bendFrom.value.copy(bendTo.value);

    tv.update(elapsed, pointerPresent ? pointerNdc : null, camera.position);
    particles.setTime(particleClock);
    tvPositionUniform.value.copy(tv.light.position);
    tvDirectionUniform.value.copy(tv.light.direction);
    tvFlickerUniform.value = tv.light.flicker;

    if (pointerPresent) bendQuietAt = elapsed + bendRecovery.seconds * 5 + 3;
    if (pointerEnabled || elapsed < bendQuietAt) renderBend(delta);

    // Handheld drift, applied to the placed camera each frame. Three sines at
    // unrelated periods, so the motion never visibly repeats.
    const swayPosition = CONFIG.camera.sway.positionAmplitude * swayScale.value;
    const swayRotation =
      THREE.MathUtils.degToRad(CONFIG.camera.sway.rotationDegrees) *
      swayScale.value;
    const swayTime = elapsed * CONFIG.camera.sway.speed;
    camera.position.set(
      basePosition.x + Math.sin(swayTime * 1.0) * swayPosition,
      basePosition.y + Math.sin(swayTime * 1.37 + 1.1) * swayPosition * 0.7,
      basePosition.z + Math.cos(swayTime * 0.83 + 2.3) * swayPosition,
    );
    camera.quaternion.copy(baseQuaternion);
    camera.rotateX(Math.sin(swayTime * 1.19 + 0.4) * swayRotation);
    camera.rotateY(Math.cos(swayTime * 0.91 + 1.7) * swayRotation);

    renderer.setRenderTarget(renderTarget);
    renderer.render(scene, camera);

    renderBloom();

    quad.material = dofMaterial;
    renderer.setRenderTarget(null);
    renderer.render(dofScene, dofCamera);

    // Reported after a finished frame that already had every asset in it.
    if (!readyReported && assetsSettledAtFrameStart) {
      readyReported = true;
      onReady?.();
    }
  };

  const dispose = (): void => {
    grassGeometry.dispose();
    grassMaterial.dispose();
    groundGeometry.dispose();
    groundMaterial.dispose();
    skyGeometry.dispose();
    skyMaterial.dispose();
    grassTexture.dispose();
    alphaMap.dispose();
    tv.dispose();
    particles.dispose();
    bendMaterial.dispose();
    for (const target of bendTargets) target.dispose();
    springMaterial.dispose();
    for (const target of springTargets) target.dispose();
    keyLight.dispose();
    fillLight.dispose();
    shadow.dispose();
    bloomBrightMaterial.dispose();
    bloomBlurMaterial.dispose();
    for (const target of bloomTargets) target.dispose();
    dofGeometry.dispose();
    dofMaterial.dispose();
    renderTarget.depthTexture.dispose();
    renderTarget.dispose();
    dofScene.clear();
    scene.clear();
    renderer.dispose();
  };

  const retune = (nextTier: DeviceTier): void => {
    tier = nextTier;
    if (bladesBuilt >= total) {
      grassGeometry.instanceCount = byTier(tier, CONFIG.tiers.instances);
    }
    particles.setCount(byTier(tier, CONFIG.tiers.particles));
    const samples = byTier(tier, CONFIG.tiers.msaaSamples);
    if (renderTarget.samples !== samples) {
      // A different sample count needs a new framebuffer, not a new program.
      renderTarget.dispose();
      renderTarget.samples = samples;
    }
    applyBloomIntensity();
    pointerEnabled = tier !== "mobile";
    if (!pointerEnabled) parkPointer();
    resize();
  };

  retune(tier);

  return { render, resize, setPointer, parkPointer, gust, retune, dispose };
};
```

Read these before you transcribe it, because each is a place a faithful copy is easy to break:

- **The camera is aimed by `lookAt(tv.object.position)`** after the set has been placed on the
  crest found along the view ray — not by `pitchDeg`. The heading `yawDeg` chooses the ray; the
  crest search chooses the subject; the look-at settles the pitch. The blade scatter uses the same
  heading (`headingTheta`) so every blade is in the 115° wedge the lens sees.
- **The offscreen target is linear, half-float, with its own depth texture**, tagged
  `NoColorSpace`. The DOF pass is the last thing that runs and does the sRGB encode itself; bloom
  is added before the encode, in linear light; the grade runs after it, in display space.
- **Bloom threshold and screen brightness are coupled.** The screen is `toneMapped: false` at
  intensity 13.75, well above 1 in the linear buffer, which is the only reason a threshold of 1.8
  catches it and not the sunlit grass.
- **The bend chain is two ping-ponged pairs of 512² half-float targets**: the stamp pass writes a
  decaying trail (direction in RG, depth in B, capsule-stamped along the segment the pointer
  travelled this frame), the spring pass integrates a damped spring per texel toward it
  (displacement RG, velocity BA, semi-implicit Euler, substepped to at most 1/60 s, at most six
  substeps). **The grass reads the spring targets, never the trail.**
- **The prewarm runs before the first visible frame**: every texture through `initTexture`, the
  bend and spring passes drawn once, `compileAsync(scene, camera)` **with the offscreen target
  bound** — a program's variant depends on its target. Only after `prewarmState === "done"` does
  `render` draw, and `onReady` fires after the first frame that had every asset settled.
- **The blade buffers fill in 8 ms slices** across frames (`buildBlades`) and report half the
  progress; the loading manager reports the other half. `instanceCount` stays 0 until the build
  completes.
- **`delta` is clamped to 0.05 s** and three clocks are integrated (`windClock`, `skyClock`,
  `particleClock`) so the gust can speed them up and let them slow again without a jump;
  `tv.update` takes raw `elapsed` on purpose so the set's float stays calm.
- **`renderer.setSize(width, height, false)`** — the canvas is CSS-sized; three must not write
  style width/height.

### Mounting it — what `grass-field.tsx` does, in plain JavaScript

```js
import { createGrassScene } from "…";           // the builder above
const canvas = document.getElementById("field");
let tier = deviceTier();
let scene;
try {
  scene = createGrassScene(canvas, {
    tier,
    onLoadProgress: (f) => store.setProgress(0.15 + f * 0.85),
    onReady: () => store.markReady(),
  });
} catch {
  // No WebGL, or context creation refused: swap in the poster and let the page open.
  showPoster(); store.markReady();
}

// Pointer — bound or unbound per tier, never "attach and ignore" on a touch device.
const onMove = (e) => scene.setPointer(e.clientX, e.clientY);
const onLeave = () => scene.parkPointer();
let pointerBound = false;
const setPointerBinding = (bind) => {
  if (bind === pointerBound) return;
  pointerBound = bind;
  if (bind) {
    canvas.addEventListener("pointermove", onMove, { passive: true });
    canvas.addEventListener("pointerleave", onLeave, { passive: true });
  } else {
    canvas.removeEventListener("pointermove", onMove);
    canvas.removeEventListener("pointerleave", onLeave);
    scene.parkPointer();
  }
};
setPointerBinding(wantsPointer(tier));

// Tiers
let budget = frameBudgetMs(tier);
let freeze = sceneShouldFreeze(tier);
const retune = (next) => {
  tier = next; budget = frameBudgetMs(tier); freeze = sceneShouldFreeze(tier);
  setPointerBinding(wantsPointer(tier)); scene.retune(tier);
};

// Resize — rAF-coalesced; a height-only change on a coarse pointer is the URL bar collapsing
// and is ignored (the canvas is lvh-sized, nothing is uncovered).
let lastWidth = window.innerWidth, lastHeight = window.innerHeight, resizeFrame = null;
const onViewportChange = () => {
  const width = window.innerWidth, height = window.innerHeight;
  const heightOnly = width === lastWidth && height !== lastHeight;
  lastWidth = width; lastHeight = height;
  if (heightOnly && isCoarsePointer()) return;
  const next = deviceTier();
  if (next !== tier) retune(next); else scene.resize();
};
const onResize = () => {
  if (resizeFrame !== null) return;
  resizeFrame = requestAnimationFrame(() => { resizeFrame = null; onViewportChange(); });
};
window.addEventListener("resize", onResize, { passive: true });
window.addEventListener("orientationchange", onResize, { passive: true });
window.matchMedia(COARSE_POINTER_QUERY).addEventListener("change", onResize);

// Visibility — draw only while the tab is visible and the canvas is within a viewport of the screen.
let onScreen = true;
new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; }, { rootMargin: "100% 0px" }).observe(canvas);

const FREEZE_SETTLE_MS = 1200;   // frames kept drawing after the entrance before a freeze-tier scene stops
const GUST_SETTLE_MS = 8000;     // how long the reveal gust takes to die away (attack + ~3 × calm)
let revealedAt = null;
subscribeToTicker(() => {
  if (document.hidden || !onScreen) return;
  if (freeze && revealedAt !== null && performance.now() - revealedAt > FREEZE_SETTLE_MS) return;
  scene.render();
}, () => budget);

// The reveal gust — fired from store.reveal(). A freeze-tier device (reduced motion, energy
// saver on mobile) skips it and settles straight away; everyone else gets the gust and keeps drawing.
function onReveal() {
  if (freeze) { revealedAt = performance.now(); return; }
  scene.gust();
  revealedAt = performance.now() + GUST_SETTLE_MS;
}
```

The canvas is `aria-hidden`; the section it sits in has its accessible name from the `<h1>`.

## Responsive rules, in one place

- **≥ 640px**: everything as drawn on the 1440×800 board; the root scales with the viewport in
  the bands above, so the composition holds at any width. Heights other than 800: header and
  headline group stay anchored to the top, body, capsule and band to the bottom, the frame
  stretches between.
- **< 640px**: the nav is hidden (`max-sm:hidden`); the headline is 40px and the sub-headline
  28px (`text-display-compact` / `text-subtitle-compact`); the trust line is 14px; the counter is
  64px. The root is `4.444444vw` (a 360 base). Nothing else changes.
- **Tiers** (the scene, not the layout): mobile is `< 768px` or a coarse pointer — DPR ≤ 1,
  30 fps, 220 k blades, 600 motes, MSAA 2, bloom ×0.6 with at most 3 blur pairs, TV textures
  capped at 512, **no pointer listeners**; tablet `< 1180px` — DPR 0.75–1.25, 45 fps, 340 k
  blades, 1000 motes, MSAA 4, bloom ×1 / 5 pairs, textures 512; desktop — DPR 0.75–1.5, every
  tick, 480 k blades, 1400 motes, MSAA 4, bloom ×1 / 7 pairs, textures 1024. A tier change on
  resize re-applies all of it without compiling a program.
- **`prefers-reduced-motion`**, or Data Saver / ≤ 2 GB device memory on mobile: the scene plays
  its entrance, skips the gust, and stops drawing 1.2 s after the reveal, holding the last frame.

## Accessibility

One `<h1>` (`#hero-title`, both headline lines inside it); `<header>`, `<nav aria-label="Primary">`,
`<main>`, `<section aria-labelledby="hero-title">`. The curtain is a `role="progressbar"` with
`aria-valuenow` written every percent. The canvas is `aria-hidden`. Every reveal renders its full
text for assistive technology and hides only the animated copy. Decorative images carry
`alt=""`. Focus-visible outlines: 2px in `--overlay-text`, 2px offset, on every link.

## Fixed parameters (bake these in)

- Board: 1440×800; root bands 0.833333vw / 1.111111vw / 1.5625vw / 4.444444vw; scale-up
  coefficient 0.6666 above 1920.
- Springs: `SMOOTH { tension: 64, friction: 22 }`, `DRAW { tension: 36, friction: 18 }`,
  curtain `{ tension: 58, friction: 22 }`; precision 0.01; frame delta cap 64 ms.
- Preloader: FOLLOW_SECONDS 0.3, MAX_RATE 0.62, CREEP_LIMIT 0.18, CREEP_MS 2400,
  PRE_READY_CEILING 0.92, HOLD_MS 260, EXIT_UNMOUNT_MS 1700, minDuration 1800, timeout 12000,
  asset share 0.85.
- Entrance delays and staggers: the table above.
- Scene: everything in `GRASS_FIELD_CONFIG` and `GRASS_FIELD_SEED = 2241217164`; the tier table in
  `device.ts`; `BUILD_BUDGET_MS 8`; `SPRING_MAX_STEP 1/60`, six substeps at most;
  `FREEZE_SETTLE_MS 1200`; `GUST_SETTLE_MS 8000`; pointer parked at 1e6.
- Lenis `{ smoothWheel: true }`.
- Links: `/` (logo), `#services`, `#formats`, `#pricing`, `#contacts`, `#start` (header action and
  capsule). They are placeholders in the project too.

## Assets

```
ASSET_BASE_URL = https://storage.getlayers.ai/assets/longplay-6cc706c729
```

Load every file from that folder, absolutely.

| what | URL | notes |
|---|---|---|
| the television | `https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/tv.glb` | 912 KB. **Draco-compressed — needs `DRACOLoader`**, decoder from `https://cdn.jsdelivr.net/npm/three@0.160.1/examples/jsm/libs/draco/gltf/`. Thirty 1024² maps inside; two texture entries have no `source`, hence the fallback plugin in `tv.ts` |
| blade diffuse | `https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/blade-diffuse.jpg` | 38 KB, colour data (`SRGBColorSpace`) |
| blade alpha | `https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/blade-alpha.jpg` | 20 KB, data (linear). **If this fails the whole field discards** |
| poster | `https://storage.getlayers.ai/assets/longplay-6cc706c729/grass-hero/poster.webp` | 158 KB, 1920×1067 frame of the scene — the no-WebGL fallback only |
| capsule icon | `https://storage.getlayers.ai/assets/longplay-6cc706c729/hero/approximately-equal.svg` | 599 B, 24×24, or inline the markup above |
| corner bracket | `https://storage.getlayers.ai/assets/longplay-6cc706c729/hero/corner-bracket.svg` | 230 B, 18×18, one file mirrored four ways |
| shield | `https://storage.getlayers.ai/assets/longplay-6cc706c729/hero/security.svg` | 1.4 KB, 30×30 |

**The two blade textures and the model are read by the GPU from another origin: load them with
`crossOrigin = "anonymous"`** (three's loaders default to it — leave it). **A failed load must be
visible**: if the alpha map, the diffuse or the model cannot be fetched, show a banner naming the
URL rather than a bare hill under a perfect sky.
[/hidecontent]
← 返回首页