类型:Site模板 · 收录:2026-11-05 01:12:03 · 源码:会员可见
# Recreate this site as a single HTML file: Seed
You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. Use an ES module script
with an importmap for three.js **r143** (`three@0.143.0` from unpkg — the scene uses
`THREE.WebGL1Renderer`, GLSL ES 1.0 shaders and the r143 `EffectComposer`, so do not upgrade it)
and Lenis 1.3 for smooth scrolling. Hardcode every value given here as a fixed constant.
## What it is
SEED. is a one-screen landing page for a fictional meadow brand, laid over a live macro of a
**dandelion clock at golden hour**: one clock in focus, backlit by a low sun at the upper right,
its pappus hairs glowing by forward scattering against an out-of-focus meadow of soft grass,
bokeh, other blurred clocks, drifting seeds and a midge swarm. The cursor is a **breath** — seeds
near it lean, let go and tumble off on the air, and the stem bends; a **click** (or the "Make a
Wish" pill) blows a gust that strips the clock in a wave; rest the cursor and it regrows.
Over the scene sits a white editorial frame: a giant serif wordmark "SEED.", a centred nav, hairline
rules that cross in tiny dots, a serif heading, a Mulish paragraph, cream glass pills, and a right
column with a looping video card and a forest-glass stat card. The page opens with a **preloader**:
a 2D line-drawn dandelion blooms on deep forest green with a 0→100 counter in its centre, then a
round, feathered hole opens from the centre onto the live scene while the frame draws itself in.
Palette: forest `#14200f`, cream `#fffbe6`, sage `#70745f`, umber `#77603c`, white type; the scene
carries its own golden light. Type: **Instrument Serif 400** (display) + **Mulish** (body), both
from Google Fonts.
## Page shell & libraries
```html
<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=Instrument+Serif&family=Mulish:wght@400&display=swap" rel="stylesheet">
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.143.0/build/three.module.js",
"three/examples/jsm/": "https://unpkg.com/three@0.143.0/examples/jsm/",
"lenis": "https://unpkg.com/lenis@1.3.26/dist/lenis.mjs"
} }
</script>
```
- `html, body { margin: 0; }` and `body { background: #000; color: #fff; }` — the black ground goes on
`body` **only** (it propagates to the canvas, under the `z-index: -10` scene). A background on
`html` as well makes `body` paint its own black box over the scene. The ground shows before the
first scene frame.
- **Adaptive rem grid** — the whole layout is in rem and the root font-size scales with the
viewport so the 1400×800 design stays proportional:
```css
html { font-size: 16px; }
@media (max-width: 1400px) { html { font-size: 1.142857vw; } } /* 16·100/1400 — desktop, laid out at 1400 */
@media (max-width: 1023px) { html { font-size: 2.083333vw; } } /* 16·100/768 — tablet, laid out at 768 */
@media (max-width: 639px) { html { font-size: 4.444444vw; } } /* 16·100/360 — phone, laid out at 360 */
```
Above 1400px, scale the root font-size up in JS, **damped** (coef 0.6666, not 1:1):
`html.style.fontSize = 16 * (1 + 0.6666 * (innerWidth - 1400) / 1400) + 'px'` on resize; at or
below 1400 remove the inline size so the media queries drive. **Every px value below is a design px: write it as `px/16` rem.**
- Three layouts: **desktop ≥ 1024px**, **tablet 640–1023px**, **phone ≤ 639px**.
- Lenis: `new Lenis({ smoothWheel: true })`, driven from a rAF loop (`lenis.raf(time)`); stop it
(`lenis.stop()`) and keep `window.scrollTo(0,0)` while the preloader is up, `lenis.start()` after.
Set `history.scrollRestoration = 'manual'`.
- `prefers-reduced-motion: reduce` → no preloader, no WebGL (show the still, see Assets), every
entrance at rest at once.
### Design tokens (CSS custom properties)
```css
:root {
--forest-950: #14200f; --forest-900: #13210e; --sage-600: #70745f; --cream: #fffbe6; --umber-600: #77603c;
--on-scene: #ffffff; /* text, rules and dots over the scene */
--on-glass: #14200f; /* text and icons on the cream pills */
--glass-pill-border: rgb(255 255 255 / .5); --glass-pill-border-hover: rgb(255 255 255 / .9);
--glass-card-border: rgb(255 255 255 / .2); --glass-card-border-hover: rgb(255 255 255 / .45);
--glass-pill-fill: linear-gradient(174.47deg, rgb(255 251 230 / .3) 11.947%, #fffbe6 104.77%); /* 212×50 pill */
--glass-pill-fill-large: linear-gradient(173.76deg, rgb(255 251 230 / .3) 11.947%, #fffbe6 104.77%); /* 60px-high pill */
--glass-card-fill: linear-gradient(to bottom, rgb(19 33 14 / .2), rgb(112 116 95 / .8));
--media-card-ground: rgb(19 33 14 / .8);
--scene-vignette: radial-gradient(77.45% 116.06% at 50% 50%, rgb(0 0 0 / 0) 0%, #000 100%);
--glass-glow-fill: radial-gradient(60% 110% at 50% 50%, rgb(255 255 255 / .5) 0%, rgb(255 255 255 / 0) 100%);
--glass-glow-halo: 0 0 1.5rem rgb(255 251 230 / .45);
--pill-radius: 2.5625rem; /* 41px */
--blur-pill: .25rem; --blur-pill-header: .75rem; --blur-card: 1rem;
--duration-normal: 250ms; --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
--preloader-size: min(72vmin, 30rem);
--preloader-hole: 72vmax; --preloader-feather: 14vmax;
}
```
Type scale: wordmark **196.899px** desktop / **160px** tablet / **72px** phone; display (h1 and
"200+") **64px** (h1 **40px** on phone); label (nav, pill labels) **20px**; body **16px**, Mulish,
`line-height: normal`; caption **14px** Mulish. Instrument Serif everything display, `line-height: 1`.
## The scene layer
A `<div id="scene">` fixed full-bleed **behind** everything: `position: fixed; left: 0; right: 0;
top: 0; height: 100lvh; z-index: -10; background: #000`. On touch devices, measure its px height
once and only re-measure on a **width** change (so the iOS toolbar does not resize the canvas
mid-scroll). The canvas is `display:block; width:100%; height:100%`, `aria-hidden="true"`, sized by
this box (ResizeObserver), never by `window.inner*`. The full scene is in **The WebGL scene** below.
## Layout & sections (one `<section aria-labelledby="hero-heading">`)
The hero is one markup with three layouts (one h1, no duplicated copy). Text colour white.
**Copy (verbatim):**
- wordmark (a link to `/`, `aria-label="SEED."`): `SEED.`
- nav (`<nav aria-label="Main">`, a `<ul>`): `Wind` (#wind) · `Contact` (#contact) · `Meadow` (#meadow) · `Seeds` (#seeds)
- button: `Make a Wish`
- h1 (`id="hero-heading"`): `Delicate Structures Carried by the Wind.` (breaks as "Delicate Structures / Carried by the Wind.")
- paragraph: `Where a single breath meets the open sky. Each seed carries a tiny parachute of silk, drifting for miles to plant new life wherever the wind decides.`
- CTA link (#meadow): `Explore the Meadow`
- stat card: `200+` and, on two lines, `Seeds on a Single` / `Dandelion Head`
- media card video alt (on its poster image): `A round archway of flowers, ferns and moss-covered stones opening onto a sunlit forest`
- scene still alt: `A dandelion clock backlit by a low golden sun in a meadow, its seeds glowing against soft out-of-focus grass`
**Vignette:** an `aria-hidden` layer `position:absolute; inset:0; pointer-events:none; background: var(--scene-vignette)` over the scene, under the content.
### Desktop (≥ 1024px) — the 1400×800 frame, `height: 100lvh`, every box absolute
| element | placement (design px) | spec |
|---|---|---|
| rule A (vertical) | `left 472; top 0; height 50%; width 1` | white |
| rule B (vertical) | `right 473; top 0; bottom 0; width 1` | white |
| rule C (horizontal) | `left 0; right 473; top 50%; margin-top -1; height 1` | white |
| rule D (horizontal) | `right 0; top 50%; margin-top -152; width 474; height 1` | white |
| dot 1 | `left 470; top 50%; margin-top -3; 5×5` | white, round |
| dot 2 | `right 471; top 50%; margin-top -3; 5×5` | white, round |
| dot 3 | `right 471; top 50%; margin-top -154; 5×5` | white, round |
| wordmark | `left 40; top 20` | Instrument Serif 196.899px, line-height 1; hover/focus `opacity .8` (CSS transition 250ms `--ease-entrance`) |
| Make a Wish | `right 40; top 40` | see **Glass pills** |
| nav | `left 50%; top 55; translateX(-50%)` | `<ul>` flex, gap 32 |
| h1 | `left 40; top 50%; margin-top 40; width 462` | Instrument Serif 64px, line-height 1, left aligned |
| paragraph | `left 483; top 100%; margin-top -100; width 403` | Mulish 16px, line-height normal |
| CTA | `left 40; bottom 40` | see **Glass pills** |
| card column | `right 40; bottom 40; top 50%; margin-top -111; width 394` | flex column, gap 4 |
| ↳ media card | `flex: 1` | radius 24, `overflow:hidden`, background `--media-card-ground` |
| ↳ stat card | height 112, `flex-shrink:0` | radius 24, 1px border `--glass-card-border` (hover `--glass-card-border-hover`), background `--glass-card-fill`, `backdrop-filter: blur(1rem)`; "200+" Instrument Serif 64px line-height 1 at `left 22; top 22`; the label Mulish 14px at `left 253; top 36`; `white-space: nowrap` |
### Tablet (640–1023px, laid out at 768)
The section is a CSS grid, `min-height: 100lvh`, `grid-template-columns: 1fr auto`,
`grid-template-rows: auto 1fr auto`. No desktop rules/dots.
- **Header band** (spans both columns): an inner grid `grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap 32`, padding `24 32 32`, `border-bottom: 1px solid #fff`. The wordmark (160px) spans both rows on the left; the Make a Wish pill is top-right (`justify-self:end; margin-top 8`); the nav sits bottom-right (`align-self:end; justify-self:end`). A 1px vertical rule at `left 388px`, full band height, separates wordmark and nav; a 5px dot sits where it meets the bottom rule (`left 386; bottom -3`).
- **Middle row** (spans both columns, `align-self:end`): the open meadow where the clock sits, with the h1 (64px) at its foot: padding `64 32 32`, `max-width 526`.
- **Bottom-left cell:** paragraph then CTA, `display:flex; flex-direction:column; justify-content:space-between; gap 32; padding 32; border-top: 1px solid #fff`.
- **Bottom-right cell:** the cards column, `width 384` (`24rem`), padding 32, `border-left` and `border-top` 1px white, a 5px dot at its top-left corner (`left -3; top -3`). The media card here has `aspect-ratio: 394 / 355`; the stat card is a flex row (`align-items:center; justify-content:space-between; gap 16; padding 0 22`) with the two texts in normal flow.
### Phone (≤ 639px, laid out at 360)
- **First screen** (`min-height: 100lvh; display:flex; flex-direction:column; padding: 16 16 24`):
- header: `display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; row-gap 20; margin: 0 -16px; padding: 0 16px 16px; border-bottom: 1px solid #fff`. Wordmark 72px and the Make a Wish pill (height 44, `width:auto`, padding-x 20) on one row; the nav full width under them, `justify-content: space-between; gap 16`.
- an empty `flex: 1; min-height: 40` spacer — the open band where the clock sits.
- h1 at 40px, then the paragraph (`margin-top 16`), then the CTA (`margin-top 24`).
- **Below it:** the cards column — `border-top: 1px solid #fff; padding: 24 16 24`, the media card `aspect-ratio 394/355`, the stat card as on tablet.
### Glass pills, nav links and their hovers
All hovers are physical springs (`tension 320, friction 26`) triggered by the whole control,
plus CSS colour transitions (`transition: border-color, color 250ms var(--ease-entrance)`).
- **Make a Wish** — a `<button type="button">`, `height 50; width 212; padding 0 40; gap 8;
display:flex; align-items:center; justify-content:center; border-radius: var(--pill-radius);
border: 1px solid var(--glass-pill-border); background: var(--glass-pill-fill);
backdrop-filter: blur(.75rem)`. Label Instrument Serif 20px colour `--on-glass`, then the small
ring icon 8×8 (inline SVG below). Click → `blow(-0.6, -0.05)` (see the scene: a gust from just
left of the clock).
- **Explore the Meadow** — one `<a>` (`display:flex; align-items:center; gap 4`) holding a pill
(`height 60; padding 0 32; gap 12; border-radius var(--pill-radius); 1px border
--glass-pill-border; background --glass-pill-fill-large; backdrop-filter: blur(.25rem)`; the 9×9
ring icon then the label Instrument Serif 20px `--on-glass`) and the 60×60 round arrow button SVG.
- **Hover glow** (both pills, and the arrow button): an `aria-hidden` layer inside the control,
`position:absolute; inset:0; border-radius:inherit; background: var(--glass-glow-fill);
box-shadow: var(--glass-glow-halo); opacity:0; transition: opacity 250ms var(--ease-entrance)`,
`opacity:1` on hover/focus-visible; the content after it is `position:relative`. The border goes
to `--glass-pill-border-hover`.
- **Hover motion:** Make a Wish — label `x 0→-2px`, ring `x 0→2px, scale 1→1.6`. CTA — ring
`scale 1→1.5`, arrow button `rotate 0→45deg, scale 1→1.06`.
- **Nav link** — `display:flex; align-items:center; gap 5; white-space:nowrap`, Instrument Serif
20px, line-height 1: a 4×4 white dot (inline SVG) then the word, and a 1px white hairline
`position:absolute; bottom -4; left 9; right 0; transform-origin:left`. Hover: hairline
`scaleX 0→1`, dot `x 0→2px`.
- **Media card hover:** the video layer `scale 1→1.05` on a slow spring (`tension 90, friction 24`).
- **Stat card hover:** its border goes to `--glass-card-border-hover`.
Icons (inline these SVGs verbatim):
```html
<!-- nav dot, 4×4 -->
<svg width="4" height="4" viewBox="0 0 4 4" fill="none"><circle cx="2" cy="2" r="2" fill="white"/></svg>
<!-- ring, 8×8 (Make a Wish) -->
<svg width="8" height="8" viewBox="0 0 8 8" fill="none"><path d="M7.5 4C7.5 5.933 5.933 7.5 4 7.5C2.067 7.5 0.5 5.933 0.5 4C0.5 2.067 2.067 0.5 4 0.5C5.933 0.5 7.5 2.067 7.5 4Z" stroke="#14200F"/></svg>
<!-- ring, 9×9 (CTA) -->
<svg width="9" height="9" viewBox="0 0 9 9" fill="none"><path d="M8.5 4.5C8.5 6.70914 6.70914 8.5 4.5 8.5C2.29086 8.5 0.5 6.70914 0.5 4.5C0.5 2.29086 2.29086 0.5 4.5 0.5C6.70914 0.5 8.5 2.29086 8.5 4.5Z" stroke="#14200F"/></svg>
<!-- arrow button, 60×60 -->
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
<circle cx="30" cy="30" r="30" fill="url(#seedArrowGlow)"/>
<circle cx="30" cy="30" r="29.5" stroke="white" stroke-opacity="0.5"/>
<path d="M27 24.6503C27 24.6503 33.9383 24.1076 34.9154 25.0846C35.8924 26.0617 35.3496 33 35.3496 33M34.5 25.5L24.5 35.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke="white"/>
<defs><radialGradient id="seedArrowGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(50 5) rotate(119.539) scale(51.7228)"><stop stop-color="#FFFE8F"/><stop offset="1" stop-color="#13210E"/></radialGradient></defs>
</svg>
```
### The media card video
A muted, looping, `playsinline` video (`preload="none"`, `aria-hidden`) over its poster image
(`<img>` with the alt above, `loading="lazy"`), both `position:absolute; inset:0; width:100%;
height:100%; object-fit:cover`. Sources: the 1080 file for `(min-width: 1024px)`, else the 720
file (see Assets). Set `video.muted = true` in JS; an IntersectionObserver (`threshold 0.01`, no
margin) sets `preload='auto'` and plays it when on screen, pauses it off screen; a refused
`play()` just leaves the poster. Under reduced motion show only the poster.
## The springs
Every entrance and hover is a damped spring (mass 1), integrated per frame with `dt` in seconds —
write one small helper and use it everywhere:
```js
// react-spring semantics: tension k, friction c, mass 1. Step with dt in seconds (sub-step at ≤ 1/240 s).
function spring({ tension = 170, friction = 26 } = {}) {
let x = 0, v = 0, target = 0;
return {
set(t) { target = t; }, jump(t) { x = target = t; v = 0; }, get value() { return x; },
step(dt) { const n = Math.ceil(dt / (1 / 240)), h = dt / n;
for (let i = 0; i < n; i++) { const a = -tension * (x - target) - friction * v; v += a * h; x += v * h; }
return x; },
get resting() { return Math.abs(x - target) < 1e-4 && Math.abs(v) < 1e-4; },
};
}
```
## The loader / reveal
A fixed overlay (`inset:0; z-index:60; display:flex; align-items:center; justify-content:center;
background: #14200f; color: #fffbe6`), in the HTML from the first paint (the hero renders at rest
under it), `aria-hidden`. It locks the scroll and returns the page to the top. Hidden by CSS under
`prefers-reduced-motion` and in a `<noscript>`.
### The 2D dandelion
A box `width = height = var(--preloader-size)` holding an SVG `viewBox="-150 -150 300 300"`,
`overflow:visible; fill:none; stroke:currentColor; stroke-linecap:round`, plus a centre disc.
Generate the seeds with a seeded PRNG so every load draws the same head:
```js
const mulberry = (seed) => { let a = seed; return () => { 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; }; };
const SEED_COUNT = 96, RIM_REACH = 100, HAIR = 26, CORE = 7;
const BLOOM_MS = 2600, GROW_MS = 980, OPEN_DELAY_MS = 340, OPEN_MS = 860;
const LAST_START_MS = BLOOM_MS - OPEN_DELAY_MS - OPEN_MS, FIRST_START_MS = 40;
const rnd = mulberry(29), golden = Math.PI * (3 - Math.sqrt(5));
function makeSeeds() {
const seeds = Array.from({ length: SEED_COUNT }, (_, i) => {
const y = 1 - ((i + 0.5) * 2) / SEED_COUNT, ring = Math.sqrt(1 - y * y), theta = golden * i;
const x = Math.cos(theta) * ring, z = Math.sin(theta) * ring; // z + toward the viewer
const rho = Math.min(1, Math.hypot(x, y)); // 1 on the rim, 0 face-on
const spread = 62 + (1 - rho) * 118; // a fan side-on, a star face-on
const count = Math.round(11 + (1 - rho) * 7);
const hair = HAIR * (0.88 + rnd() * 0.18);
let hairs = ""; const fullStar = spread >= 179;
for (let k = 0; k < count; k++) {
const deg = fullStar ? -180 + (360 * k) / count : -spread + (2 * spread * k) / (count - 1);
const b = ((deg + (rnd() - 0.5) * 5) * Math.PI) / 180, len = hair * (0.92 + rnd() * 0.12);
const cx = Math.sin(b * 0.7) * len * 0.55, cy = -Math.cos(b * 0.7) * len * 0.55;
const ex = Math.sin(b) * len, ey = -Math.cos(b) * len;
hairs += `M0 0Q${cx.toFixed(2)} ${cy.toFixed(2)} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
}
return { angle: (Math.atan2(x, -y) * 180) / Math.PI, reach: Math.max(CORE + 6, RIM_REACH * rho),
delay: rho * 200 + rnd() * 760, opacity: z >= 0 ? 0.62 + z * 0.33 : 0.3 + (1 + z) * 0.3,
achene: 4 + rho * 7, hairs, z };
});
const lo = Math.min(...seeds.map((s) => s.delay)), hi = Math.max(...seeds.map((s) => s.delay));
for (const s of seeds) s.delay = FIRST_START_MS + ((s.delay - lo) / (hi - lo)) * (LAST_START_MS - FIRST_START_MS);
return seeds.sort((a, b) => a.z - b.z); // back to front
}
// an under-damped spring's step response (ζ 0.55, ω 11) over x ∈ [0,1]: ~12 % overshoot, settled by 1
function springStep(x) { if (x <= 0) return 0; if (x >= 1) return 1;
const zeta = 0.55, omega = 11, wd = omega * Math.sqrt(1 - zeta * zeta), d = Math.exp(-zeta * omega * x);
return 1 - d * (Math.cos(wd * x) + ((zeta * omega) / wd) * Math.sin(wd * x)); }
const seedTransform = (s, t) => { const g = springStep((t - s.delay) / GROW_MS);
return `rotate(${(s.angle - (1 - Math.min(g, 1)) * 24).toFixed(2)}) scale(${g.toFixed(4)})`; };
const pappusTransform = (s, t) => { const o = springStep((t - s.delay - OPEN_DELAY_MS) / OPEN_MS);
return `translate(0 ${(-s.reach).toFixed(2)}) scale(${o.toFixed(4)})`; };
```
Each seed is a `<g transform=seedTransform stroke-opacity=opacity>` holding: the beak
`<line x1=0 y1=-7 x2=0 y2=-reach stroke-width=0.32>`; the achene `<line x1=0 y1=-8 x2=0
y2=-(8+achene) stroke-width=1.3 stroke="#77603c">`; and a `<g transform=pappusTransform><path
d=hairs stroke-width=0.24></g>`. One linear clock `t` runs 0 → 2600 ms and every seed reads it.
The SVG sits in a layer that **spins** clockwise at a steady 30°/s for the preloader's whole life
(`transform: rotate()` on the wrapping div, `will-change: transform`).
**Centre disc + counter:** a disc centred in the box, `width = height = 30%`, `border-radius:50%`,
`background: radial-gradient(circle closest-side, #14200f 55%, transparent 100%)`, holding a
`<canvas>` that fills it. The counter is drawn on that canvas in Instrument Serif at
`round(h · 0.4)` px, colour cream, centred (`textAlign center; textBaseline middle; y = h/2 + px·0.04`,
snapped to device pixels), DPR-scaled, redrawn only when the number changes and not before
`document.fonts.load()` resolves for that face. Value: `round(100 · s²(3 − 2s))` with `s = t/2600`.
### The lift
The overlay lifts when the bloom has finished **and** the scene has drawn its first frame (cap: 4 s
after the bloom if no frame arrives). Then: draw `100`, mark the hero **revealed** (starts the
entrances below), set `pointer-events:none` on the overlay, and run a soft over-damped spring
`reveal: 0 → 1` (`tension 15, friction 11.5`), writing `--reveal` on the overlay each frame. The
mask:
```css
.preloader-mask {
--hole: calc(var(--reveal, 0) * (var(--preloader-hole) + var(--preloader-feather)) - var(--preloader-feather));
-webkit-mask-image: radial-gradient(circle at 50% 50%, transparent var(--hole), black calc(var(--hole) + var(--preloader-feather)));
mask-image: radial-gradient(circle at 50% 50%, transparent var(--hole), black calc(var(--hole) + var(--preloader-feather)));
}
```
At the same time the dandelion box scales `1 + 0.25·reveal` and fades `opacity 1 − reveal`.
Remove the overlay once the feather has passed every corner: `reveal ≥ coverReveal() + 0.02`, where
`coverReveal() = (hypot(innerWidth, innerHeight)/2 + 0.14·vmax) / (0.86·vmax)` and
`vmax = max(innerWidth, innerHeight)`. The radius the hole shows, in px from the viewport centre, is
`seenRadius(r) = (r · 0.86 − 0.07) · vmax` — the headings below wait for it.
## Hero entrances (after the reveal starts)
Everything is drawn at rest in the HTML; when the reveal starts each element jumps to its `from`
state (still under the curtain) and springs to rest after its delay. Default spring `tension 120,
friction 28` (soft, no overshoot). Rules use `tension 60, friction 22`; dots `tension 260,
friction 16`. "Mask" means the element is `overflow:hidden` and an inner block moves inside it at
full opacity.
| element | delay (ms) | from → to |
|---|---|---|
| rule A | 100 | `scaleY 0→1`, origin top |
| rule B | 200 | `scaleY 0→1`, origin top |
| rule C | 250 | `scaleX 0→1`, origin left |
| rule D | 400 | `scaleX 0→1`, origin right |
| dots 1 / 2 / 3 | 800 / 880 / 960 | `scale 0→1` |
| tablet band rule / dot | 150 / 700 | `scaleY 0→1` / `scale 0→1` |
| tablet cards dot | 850 | `scale 0→1` |
| nav item *i* | 250 + 70·i | mask, inner `translateY 130% → 0` (the mask has `padding-bottom 6; margin-bottom -6` so the hairline fits) |
| Make a Wish | 300 | `clip-path: inset(0 50% 0 50% round 2.5625rem) → inset(0 0 0 0 round 2.5625rem)`; once settled (delay + 1600 ms) **remove the clip-path** — an ancestor clip-path kills `backdrop-filter` |
| media card | 350 | `clip-path: inset(100% 0 0 0 round 1.5rem) → inset(0 0 0 0 round 1.5rem)` |
| stat card | 500 | same as the media card |
| paragraph | 550 | mask, inner `translateY 105% → 0` |
| CTA | 650 | `clip-path: inset(0 100% 0 0 round 2.5625rem) → inset(0 0 0 0 round 2.5625rem)`, then removed at rest |
### Blur In — the wordmark and the h1
Both are held `visibility:hidden` (laid out) until the preloader's hole uncovers their centre
(`seenRadius(reveal) ≥` the distance from the viewport centre to the element's centre). Then
"Blur In": split the text into words (`display:inline-flex` per word, words separated by a flex
`column-gap` of **0.17em** — Instrument Serif's own space width, so the line breaks hold) and each
word into letter spans. After **150 ms**, letter *n* fades `opacity 0 → 1` on a spring
`tension 25, friction 12` starting at `n · 50 ms`. The blur is **one filter per word**, not per
letter: word *w* whose letters are `first … first+count−1` gets
`filter: blur(8 · (1 − r(elapsed − middle·50)) px)` with `middle = first + (count−1)/2`, no filter
before its first letter starts (`elapsed < first·50`) and none once under 0.05px:
```js
const R = Math.sqrt(12 * 12 - 4 * 25), R1 = (-12 + R) / 2, R2 = (-12 - R) / 2; // over-damped, ζ 1.2
const r = (ms) => ms <= 0 ? 0 : 1 - (R2 * Math.exp(R1 * ms / 1000) - R1 * Math.exp(R2 * ms / 1000)) / (R2 - R1);
// run `elapsed` linearly from 0 to (letters − 1)·50 + 2100 ms
```
## The WebGL scene
The dandelion is a three.js **r143** scene on a `WebGL1Renderer` (`antialias: false` — every frame
is drawn into the composer's targets, MSAA would smooth nothing). Its layers, back to front:
1. **Backdrop** — a full-screen shader at **⅓ resolution** into a render target: the warm haze sky,
an fbm tree line, the mottled meadow, the sun, 34 bokeh glints, 9 soft mid-distance grass blades
(some with backlit seed heads) and four soft out-of-focus clocks; blitted at the far plane.
2. **The plant** — a hand-built tube stem (48×18) that bends as a cantilever every frame, a pitted
receptacle and 16 reflexed bracts, 7 in-focus grass blades with veins, folded midribs and cuticle
sheen, translucent in the backlight.
3. **Fine hairs** on the stem and along the blade edges, dew drops that act as lenses.
4. **The seeds** — 210 hero seeds on a Fibonacci sphere + 14 drifting air seeds: instanced
ribbed achenes, and every beak and 44 pappus hairs per seed as anti-aliased screen-space fibres
with per-vertex **depth of field**; off the focal plane a seed becomes a soft backlit disc sprite.
5. **Motes** (70 out-of-focus fluff discs) and a **midge swarm** (12), additive.
6. **Foreground** — a premultiplied ⅓-resolution layer: a near out-of-focus clock, the meadow's dark
near edge, four soft foreground blades, out-of-focus seeds before the lens, the hero clock's limb
glow and lens veil/ghosts.
7. **Final pass** — a golden-hour grade (teal shadows, gold highlights, gentle S-curve), vignette,
grain.
**Interaction:** the pointer (on `window`) is smoothed and its *speed* is the breath — seeds within
`breathRadius` lean away and let go past a per-seed threshold, the stem bends away, nearby free seeds
are pushed. A `pointerdown` anywhere that is **not** on a link, button, input, label or dialog blows
a gust from the click point; the Make a Wish button blows from NDC `(-0.6, -0.05)`. A still cursor
near the clock regrows it faster. The camera sways slowly and parallaxes with the pointer.
**Framing:** the clock's screen position depends on the box width — phone (≤ 639) centred at
`x 0.5, y 0.41`; tablet (≤ 1023) `x 0.58, y 0.29`; desktop `x 0.471, y 0.556` (0 = left/top).
**Loop rules:** one clock in seconds for every timed motion (`dt` clamped to 0.1 s, the simulation
step to 0.05 s); on desktop (`(hover: hover) and (pointer: fine)`) gate the **draw** to every
≥ 12.5 ms (≈ 60 fps on a 120 Hz panel) while accumulating the simulated time; on phones draw every
frame (no cap). DPR capped at **1.5**. Resize only when the box's CSS size or DPR really changed,
and draw at once after a real resize. `preventDefault()` on `webglcontextlost` and rebuild the scene
on a fresh canvas (no fade-in on a rebuild). When the first frame has been drawn, tell the preloader.
If WebGL fails to start, show the still image instead.
Build it exactly from the code below — the shaders, the tuning, and the simulation are the effect.
Paste the three blocks, in order, into one `<script type="module">`.
### 1 · Shaders (verbatim GLSL ES 1.0)
```js
/**
* GLSL for the dandelion scene — ported verbatim from the MOTDES
* `dandelion.html` scene (GLSL ES 1.0, run on three.js r143's WebGL1Renderer).
* Only the final composite changed: the source's torus/bloom composers never
* had anything on their layers, so their (always black) textures are gone.
*/
// seed proportions (1 unit ≈ 18 mm): receptacle radius, achene top, beak top (pappus apex), hair length
const R0 = 0.12;
const A0 = 0.216;
const AP = 0.756;
const HL = 0.32;
const SEED_MID = 0.48;
const FULLSCREEN_VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
/* ---------- final composite: a grade, vignette, grain, fade ---------- */
const FINAL_VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }`;
const FINAL_FRAG = `
uniform sampler2D tDiffuse;
uniform float uFade, iTime, uGrain, uVignette, uWarm;
varying vec2 vUv;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
vec3 col = texture2D(tDiffuse, vUv).xyz;
// a warm golden-hour grade: green-teal in the shadows, gold in the highs, a gentle S, colour kept alive
col = max(col, 0.0);
float lum = dot(col, vec3(0.299, 0.587, 0.114));
col = mix(col, col * vec3(0.9, 1.03, 0.97), (1.0 - smoothstep(0.0, 0.45, lum)) * 0.55 * uWarm);
col = mix(col, col * vec3(1.06, 1.0, 0.86), smoothstep(0.4, 1.0, lum) * 0.6 * uWarm);
col = mix(vec3(lum), col, 1.0 + 0.1 * uWarm);
col = mix(col, col * col * (3.0 - 2.0 * col), 0.22);
col = col * 0.97 + vec3(0.012, 0.014, 0.006);
col *= mix(1.0 - 0.3 * uVignette, 1.0, smoothstep(1.25, 0.4, length((vUv - 0.5) * vec2(1.15, 1.0))));
col += (hash(vUv * 1000.0 + fract(iTime * 2.0)) - 0.5) * uGrain;
gl_FragColor = vec4(col * uFade, 1.);
}`;
const GLSL_COMMON = `
float h12(vec2 p){ vec3 p3 = fract(p.xyx * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float h11(float n){ return h12(vec2(n, n * 0.371 + 11.3)); }
float vnoise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h12(i), h12(i + vec2(1.0, 0.0)), f.x), mix(h12(i + vec2(0.0, 1.0)), h12(i + vec2(1.0, 1.0)), f.x), f.y); }
// forward scattering (Henyey-Greenstein, g 0.7, ×4π): 18.9 looking into the sun, 0.28 side-on
float fwdPh(float c){ float d = 1.0 + 0.49 - 1.4 * c; return 0.51 / (d * sqrt(d)); }
// 1 inside, soft edge of width b (never a reversed smoothstep)
float soft(float d, float r, float b){ return 1.0 - smoothstep(r - b, r + b, d); }
`;
const QROT = `vec3 qrot(vec4 q, vec3 v){ return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); }`;
// An anti-aliased fibre. Each segment is a screen-space quad at least one pixel wide plus a one-pixel soft ramp; a fibre
// thinner than a pixel keeps its true width as coverage (alpha), so the hairs read the same at any DPR and do not
// shimmer as they move. The ramp also carries the circle of confusion: a fibre off the focal plane spreads into a soft
// smear with the same energy (depth of field per vertex, so one seed can be sharp at its root and soft at its far hairs).
const GLSL_FIBER = `
uniform vec2 uVp; uniform float uFocus, uAper, uFDepth;
float cocPx(float d){ return min(48.0, uAper * max(0.0, abs(d - uFocus) - uFDepth) / max(d, 0.1) * uVp.y); }
vec4 fiber(vec3 w0, vec3 w1, float end, float side, float wd, out float across, out float halfW, out float cov, out float coc){
vec4 c0 = projectionMatrix * viewMatrix * vec4(w0, 1.0), c1 = projectionMatrix * viewMatrix * vec4(w1, 1.0);
vec4 c = end < 0.5 ? c0 : c1;
float cw = max(c.w, 0.05);
vec2 s0 = c0.xy / max(c0.w, 0.05) * 0.5 * uVp, s1 = c1.xy / max(c1.w, 0.05) * 0.5 * uVp;
vec2 dir = s1 - s0; float L = length(dir); dir = L > 1e-3 ? dir / L : vec2(1.0, 0.0);
vec2 nrm = vec2(-dir.y, dir.x);
float wpx = wd * projectionMatrix[1][1] * 0.5 * uVp.y / cw;
coc = cocPx(cw);
float ramp = 1.0 + coc, core = 0.5 * max(wpx, 1.0);
halfW = core + 0.5 * ramp; cov = min(wpx, 1.0); across = side * halfW;
vec2 off = nrm * across + dir * (end < 0.5 ? -0.5 : 0.5) * ramp;
c.xy += off / (0.5 * uVp) * c.w;
return c;
}
`;
const GLSL_FIBER_FRAG = `
float fiberA(float across, float halfW, float coc){ float ramp = 1.0 + coc, core = halfW - 0.5 * ramp;
return 1.0 - smoothstep(core - 0.5 * ramp, core + 0.5 * ramp, abs(across)); }
`;
/* ---------- the out-of-focus meadow (third resolution) ---------- */
const BACKDROP_VERT = FULLSCREEN_VERT;
const BACKDROP_FRAG = GLSL_COMMON +
`
uniform vec2 uRes, uSunP, uPar; uniform float uTime, uSunGlow, uBokeh, uClocks, uHaze, uGust, uSway;
uniform vec3 uSky, uTree, uMeadow, uShadeC, uSunC, uGlint, uFil;
varying vec2 vUv;
// an out-of-focus clock: a soft disc with a brighter limb, a hint of radial texture, a darker seed core and a soft stem
vec4 softClock(vec2 p, vec2 c, float r, float b, vec2 sp, float bare){
float t = uTime;
vec2 cc = c + vec2(sin(t * 0.55 + c.x * 9.0) + 0.6 * sin(t * 1.3 + c.y * 7.0), 0.0) * 0.004 * uSway * (1.0 + uGust * 4.0);
vec2 d = p - cc; float l = length(d);
float body = soft(l, r, b);
float ang = atan(d.y, d.x);
float streak = 0.82 + 0.18 * vnoise(vec2(ang * 7.0, 1.0)) * (1.0 - bare * 0.5);
float limb = exp(-pow((l - r * 0.88) / (b + r * 0.14), 2.0));
float core = 1.0 - smoothstep(0.0, r * 0.4, l);
float lit = 0.8 + 1.2 * exp(-length(cc - sp) * 2.4);
float side = 0.8 + 0.2 * dot(d / max(l, 1e-4), normalize(sp - cc));
float a = body * (0.5 + 0.35 * limb) * (1.0 - core * 0.3) * (1.0 - bare * 0.55) * uClocks;
vec3 cl = mix(uFil, uSunC, 0.45) * lit * side * streak;
// the stem
float yy = cc.y - p.y;
float sx = cc.x + 0.08 * yy * yy - 0.02 * yy;
float sw = r * 0.07 + b * 0.5;
float st = soft(abs(p.x - sx), sw, b * 0.6) * smoothstep(0.0, r * 0.3, yy) * uClocks;
vec3 sc = mix(uShadeC * 1.4, uMeadow, 0.5) + uSunC * 0.35 * lit * smoothstep(-sw, sw, (p.x - sx) * sign(sp.x - sx));
vec3 rgb = sc * st * 0.8;
float A = st * 0.8;
rgb = rgb * (1.0 - a) + cl * a; A = A + a * (1.0 - A);
return vec4(rgb, A);
}
void main(){
float asp = uRes.x / uRes.y, t = uTime;
vec2 p = (vUv - 0.5) * vec2(asp, 1.0);
vec2 sp = (uSunP - 0.5) * vec2(asp, 1.0);
vec2 q = p + uPar * 0.25; // the far layer barely moves
vec2 m = p + uPar * 0.12; // the middle distance
float ds = length(p - sp);
float sunW = exp(-ds * 2.4);
// sky: warm haze, paler near the sun
vec3 sky = mix(uSky * 0.78, mix(uSky, vec3(1.0, 0.96, 0.85), 0.35), sunW);
// the tree line: a soft fbm crown, never a line
float hz = 0.13 + (vnoise(vec2(q.x * 3.2, 1.7)) - 0.5) * 0.14 + (vnoise(vec2(q.x * 8.0 + 3.1, 4.2)) - 0.5) * 0.05;
float tree = 1.0 - smoothstep(hz - 0.09, hz + 0.09, q.y);
vec3 treeC = mix(uTree, uSky, 0.12 + 0.7 * sunW) * (0.85 + 0.3 * vnoise(q * vec2(6.0, 9.0)));
// the meadow below it, darker and greener toward the camera, mottled with light and shade
float md = clamp((0.04 - q.y) / 0.5, 0.0, 1.0);
vec3 meadow = mix(mix(uTree, uSky, 0.45), uMeadow, smoothstep(0.0, 0.4, md));
meadow = mix(meadow, uShadeC, smoothstep(0.35, 1.0, md) * 0.8);
float mot = vnoise(q * vec2(2.4, 5.0) + vec2(0.0, 3.0)) * 0.65 + vnoise(q * vec2(5.0, 11.0)) * 0.35;
meadow *= 0.62 + 0.55 * mot;
meadow += uSunC * 0.25 * exp(-length((q - sp) * vec2(0.8, 2.2)) * 2.5) * (1.0 - md);
float mm = 1.0 - smoothstep(-0.06, 0.08, q.y);
vec3 col = mix(sky, treeC, tree);
col = mix(col, meadow, mm);
// aerial perspective: the horizon band and everything near the sun lift toward the haze colour (soft horizon)
float band = exp(-pow((q.y - 0.07) / 0.17, 2.0));
col = mix(col, uSky * 1.02, clamp((band * 0.3 + sunW * 0.55) * uHaze, 0.0, 0.92));
// the sun
col += uSunC * (exp(-ds * ds * 260.0) * 0.9 + exp(-ds * ds * 22.0) * 0.32 + sunW * 0.22) * uSunGlow;
// bokeh: glints off wet grass and leaves, round, brighter rims, cat's-eye toward the frame edge
for (int i = 0; i < 34; i++) {
float fi = float(i);
vec2 c = vec2((h11(fi * 1.31) - 0.5) * asp * 1.1, -0.48 + h11(fi * 2.17) * 0.78);
c += vec2(sin(t * 0.21 + fi * 1.7), cos(t * 0.17 + fi * 2.3)) * 0.006;
float r = (0.018 + 0.05 * h11(fi * 3.1)) * (1.0 + clamp(0.1 - c.y, 0.0, 1.0) * 1.3);
vec2 d = m - c;
vec2 off = -c / max(length(c), 0.2) * r * 0.35 * smoothstep(0.2, 0.8, length(c));
float disc = soft(length(d), r, r * 0.1) * soft(length(d - off), r * 1.05, r * 0.1);
disc *= 0.7 + 0.3 * smoothstep(r * 0.5, r * 0.98, length(d));
float lit = exp(-length(c - sp) * 2.0);
vec3 bc = mix(mix(uMeadow * 1.6, uGlint, 0.35), uGlint, lit);
float tw = 0.75 + 0.25 * sin(t * 0.6 + fi * 3.7);
col += bc * disc * uBokeh * (0.05 + 0.1 * h11(fi * 5.9)) * (0.4 + 2.2 * lit) * tw;
}
// soft grass blades in the middle distance, rim-lit on the sun side
for (int i = 0; i < 9; i++) {
float fi = float(i);
float x0 = (h11(fi * 3.7 + 1.0) - 0.5) * asp * 1.05, y0 = -0.56;
float hg = 0.3 + 0.42 * h11(fi * 5.3), lean = (h11(fi * 7.1) - 0.5) * 0.6;
float bl = 0.008 + 0.014 * h11(fi * 9.7);
float yy = clamp((m.y - y0) / hg, 0.0, 1.0);
float cx = x0 + lean * hg * yy * yy + sin(t * 0.7 + fi * 1.7) * 0.008 * yy * yy * uSway * (1.0 + uGust * 4.0);
float w = (0.006 + 0.008 * h11(fi * 2.3)) * (1.0 - yy) + 0.001;
float dx = m.x - cx;
float a = soft(abs(dx), w, bl) * soft(m.y, y0 + hg, bl * 1.5) * 0.75;
float lit = exp(-length(vec2(cx, y0 + hg) - sp) * 1.8);
vec3 bcol = mix(uShadeC * 1.6, uMeadow * 0.8, 0.4 * yy) + uSunC * 0.5 * lit * smoothstep(0.0, w + bl, dx * sign(sp.x - cx));
col = mix(col, bcol, a);
// a backlit seed head on some
if (h11(fi * 4.4) > 0.55) {
vec2 tp = vec2(x0 + lean * hg, y0 + hg + 0.03);
vec2 dd = (m - tp) * vec2(1.0, 0.45);
float ph = soft(length(dd), 0.012, 0.012);
col = mix(col, mix(uGlint, uSunC, 0.5) * (0.6 + 1.4 * lit), ph * 0.55);
}
}
// four soft clocks at different depths: two behind, one far, one half-blown
vec4 k;
k = softClock(m, vec2(-0.68, 0.06), 0.085, 0.028, sp, 0.0); col = col * (1.0 - k.a) + k.rgb;
k = softClock(q, vec2(0.30, 0.19), 0.032, 0.012, sp, 0.0); col = col * (1.0 - k.a) + k.rgb;
k = softClock(m, vec2(0.50, 0.0), 0.07, 0.024, sp, 0.0); col = col * (1.0 - k.a) + k.rgb;
k = softClock(m, vec2(0.74, -0.24), 0.055, 0.02, sp, 0.7); col = col * (1.0 - k.a) + k.rgb;
col += (h12(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(col, 1.0);
}`;
/** Draws the backdrop render target behind everything (at the far plane). */
const BACKDROP_BLIT_VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.99999, 1.0); }`;
const BACKDROP_BLIT_FRAG = GLSL_COMMON +
`uniform sampler2D tBg; varying vec2 vUv;
void main(){ gl_FragColor = vec4(texture2D(tBg, vUv).rgb + (h12(gl_FragCoord.xy) - 0.5) / 200.0, 1.0); }`;
/* ---------- the plant: stem, receptacle, bracts, grass blades ---------- */
const PLANT_VERT = `attribute vec3 aSide; attribute float aId; varying vec3 vN, vW, vL, vS; varying vec2 vUv; varying float vId;
void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; vL = position; vN = normalize(mat3(modelMatrix) * normal); vUv = uv;
vS = mat3(modelMatrix) * aSide; vId = aId;
gl_Position = projectionMatrix * viewMatrix * w; }`;
const PLANT_FRAG = GLSL_COMMON +
`
uniform vec3 uBase, uTrans, uSun, uSunDir, uSky, uShade; uniform float uRim, uTransAmt, uEdge, uPit, uBack, uKind;
varying vec3 vN, vW, vL, vS; varying vec2 vUv; varying float vId;
// an anti-aliased stripe: 1 on lines at integer x, half-width w (in x units), filtered by the pixel footprint
float lines(float x, float w){ float d = abs(fract(x + 0.5) - 0.5), fw = fwidth(x), aa = fw * 0.75 + 1e-4;
return mix(1.0 - smoothstep(w - aa, w + aa, d), 2.0 * w, smoothstep(0.25, 0.5, fw)); } // fades to its mean where too dense to draw
void main(){
vec3 V = normalize(cameraPosition - vW);
vec3 Ng = normalize(vN); float ndv = dot(Ng, V); if (ndv < 0.0) Ng = -Ng;
vec3 N = Ng;
float fwd = fwdPh(dot(-V, uSunDir));
vec3 base = uBase; float trM = 1.0, alpha = 1.0, sheen = 0.0, edgeL = 0.0;
if (uPit > 0.5) { // the bare receptacle: a pale dome pitted where the seeds sat
vec3 n = normalize(vL); float c = vnoise(vec2(atan(n.z, n.x) * 9.0, n.y * 14.0));
base *= 0.7 + 0.45 * smoothstep(0.25, 0.6, c);
}
if (uKind > 1.5) {
// ---- a grass blade: parallel veins converging to the tip, a folded midrib, cuticle sheen, a drying tip ----
float x = vUv.y * 2.0 - 1.0, u = vUv.x, id = vId;
vec3 S = normalize(vS);
N = normalize(Ng + S * x * 0.55); // the V of the fold
float vx = (x * 0.5 + 0.5) * 8.0;
float vein = lines(vx, 0.1), mid = 1.0 - smoothstep(0.03, 0.03 + fwidth(x) * 1.5 + 0.02, abs(x));
float fib = vnoise(vec2(x * 22.0, u * 4.0 + id * 9.0)); // fine fibres between the veins
float blot = vnoise(vec2(x * 2.5 + id * 3.0, u * 18.0 + id * 7.0));
float h = h11(id * 1.7 + 0.3);
base *= mix(vec3(0.9, 1.04, 0.88), vec3(1.1, 1.0, 0.8), h); // each blade its own green
base *= 0.78 + 0.3 * smoothstep(0.0, 0.45, u); // darker toward the sheath
base = mix(base, base * vec3(1.3, 1.2, 0.62), smoothstep(0.62, 1.0, u) * 0.55); // yellowing toward the tip
base = mix(base, vec3(0.42, 0.32, 0.16), smoothstep(0.93, 0.985, u) * step(0.45, h11(id * 3.1))); // a dry tip on some
base *= (0.86 + 0.24 * blot) * (0.93 + 0.12 * fib) * (1.0 - vein * 0.2) * (1.0 + mid * 0.22);
trM = (1.0 - vein * 0.5 - mid * 0.5) * (0.85 + 0.3 * fib) * (1.0 + 0.35 * abs(x)); // veins are thicker: darker in transmitted light
sheen = 0.22;
float e = min(vUv.y, 1.0 - vUv.y), ea = fwidth(vUv.y);
alpha = smoothstep(0.0, ea * 1.6 + 1e-4, e); // an anti-aliased silhouette
edgeL = pow(abs(x), 10.0) * uEdge;
} else if (uKind > 0.5) {
// ---- the stem: a hollow scape, fine longitudinal ridges, a red-brown flush low down, paler under the head ----
float u = vUv.x, ar = vUv.y * 22.0;
vec3 T = normalize(vS), A = normalize(cross(T, Ng));
float rdg = sin(ar * 6.2832);
float aa = clamp(1.0 - fwidth(ar) * 1.4, 0.0, 1.0); // ridges fade out where they would alias
N = normalize(Ng + A * rdg * 0.16 * aa);
float streak = vnoise(vec2(ar * 0.5, u * 30.0)), mott = vnoise(vec2(vUv.y * 5.0, u * 9.0));
base = mix(base * vec3(1.32, 0.78, 0.72), base, smoothstep(0.55, 0.8, u) * 0.6 + 0.4);
base = mix(base, base * vec3(1.18, 1.14, 0.8), smoothstep(0.93, 1.0, u));
base *= (0.9 + 0.16 * streak) * (0.92 + 0.14 * mott) * (1.0 - 0.08 * aa * (0.5 - 0.5 * rdg));
trM = 0.85 + 0.35 * streak;
sheen = 0.3;
float nd = abs(ndv);
alpha = smoothstep(0.0, fwidth(nd) * 1.5 + 1e-4, nd);
} else if (uEdge > 0.0) base *= 0.88 + 0.12 * sin(vUv.y * 60.0) * (1.0 - abs(vUv.y * 2.0 - 1.0)); // bract veins
vec3 amb = mix(uShade * 1.2, uSky, N.y * 0.5 + 0.5) * 0.5;
vec3 col = base * (amb + uSun * max(dot(N, uSunDir), 0.0) * 0.7);
// light through the tissue: the sun is behind, the face we see is lit from the back
float tr = max(0.0, dot(-N, uSunDir)) * 0.8 + 0.2 * max(0.0, dot(-Ng, uSunDir));
col += uTrans * uSun * base * 2.2 * tr * trM * (0.25 + 0.12 * min(fwd, 8.0)) * uTransAmt * uBack;
// the waxy cuticle mirrors the bright haze at grazing angles
float fr = pow(1.0 - max(dot(N, V), 0.0), 4.0);
col += uSky * fr * sheen * (0.5 + 0.5 * N.y);
// the silhouette catches the light (fine hairs on the stem, the blade's edge)
float rim = pow(1.0 - max(dot(Ng, V), 0.0), 3.0);
col += uSun * rim * min(fwd, 10.0) * 0.12 * uRim * uBack;
col += uSun * edgeL * min(fwd, 10.0) * 0.1 * uBack;
if (uKind < 0.5 && uEdge > 0.0) col += uSun * pow(abs(vUv.y * 2.0 - 1.0), 10.0) * min(fwd, 10.0) * 0.1 * uEdge * uBack;
gl_FragColor = vec4(col, alpha);
}`;
/* ---------- fine hairs on the stem and the blade edges ---------- */
const HAIR_VERT = GLSL_FIBER +
`attribute vec3 aP1, aC; attribute float aId; varying float vAcr, vHalf, vCov, vCoc, vT; varying vec3 vW, vTan;
void main(){
float wd = mix(0.0016, 0.0007, aC.z * 0.5 + aC.x * 0.5);
gl_Position = fiber(position, aP1, aC.x, aC.y, wd, vAcr, vHalf, vCov, vCoc);
vW = aC.x < 0.5 ? position : aP1; vTan = normalize(aP1 - position + 1e-6); vT = aC.z * 0.5 + aC.x * 0.5;
}`;
const HAIR_FRAG = GLSL_COMMON +
GLSL_FIBER_FRAG +
`uniform vec3 uFil, uSun, uSunDir, uSky; uniform float uBack;
varying float vAcr, vHalf, vCov, vCoc, vT; varying vec3 vW, vTan;
void main(){
float fwd = fwdPh(dot(normalize(vW - cameraPosition), uSunDir));
float tl = dot(vTan, uSunDir), tf = sqrt(max(0.0, 1.0 - tl * tl));
vec3 col = uFil * 0.3 + uSky * 0.1 + uSun * min(fwd, 14.0) * 0.1 * uBack * (0.35 + 0.65 * tf);
float a = min(1.0, 1.2 * vCov * fiberA(vAcr, vHalf, vCoc)) * (0.7 + 0.3 * (1.0 - vT));
if (a < 0.003) discard;
gl_FragColor = vec4(col, a);
}`;
/* ---------- the seeds: beaks and pappus hairs as fibres ---------- */
const SEED_VERT = QROT +
GLSL_FIBER +
`
attribute vec4 hA; attribute vec4 hB; attribute float hS; attribute vec3 aPos; attribute vec4 aQuat; attribute vec4 aInfo;
uniform float uTime, uSize, uHairW;
varying float vA, vPart, vT, vAcr, vHalf, vCov, vCoc; varying vec3 vW, vTan;
vec3 seedPt(float t, out vec3 tg){
if (hB.z < 1.0) { tg = vec3(0.0, 1.0, 0.0); return vec3(0.0, mix(${A0}, ${AP}, t), 0.0); }
float open = aInfo.z;
float e = mix(0.1, hA.y, open) - hB.y * t * open;
vec3 dir = vec3(sin(e) * cos(hA.x), cos(e), sin(e) * sin(hA.x));
dir.y += sin(uTime * 5.0 + aInfo.w * 40.0 + hA.x * 5.0) * 0.035 * t;
tg = dir;
return vec3(0.0, ${AP}, 0.0) + dir * (${HL} * hB.x * t);
}
void main(){
float s = aInfo.y * uSize; vec3 tg0, tg1;
vec3 w0 = aPos + qrot(aQuat, seedPt(hA.z, tg0) * s), w1 = aPos + qrot(aQuat, seedPt(hA.w, tg1) * s);
float t = mix(hA.z, hA.w, hB.w);
// true widths: the beak about 70 microns, a pappus hair a few microns, thinning to its tip
float wd = (hB.z < 1.0 ? 0.0042 : 0.0017 * mix(1.0, 0.45, t) * uHairW) * s;
gl_Position = fiber(w0, w1, hB.w, hS, wd, vAcr, vHalf, vCov, vCoc);
vW = hB.w < 0.5 ? w0 : w1; vTan = qrot(aQuat, tg1); vA = aInfo.x; vPart = hB.z; vT = t;
}`;
const SEED_FRAG = GLSL_COMMON +
GLSL_FIBER_FRAG +
`
uniform vec3 uFil, uBeak, uSun, uSunDir, uSky; uniform float uBack;
varying float vA, vPart, vT, vAcr, vHalf, vCov, vCoc; varying vec3 vW, vTan;
void main(){
vec3 ray = normalize(vW - cameraPosition);
float fwd = fwdPh(dot(ray, uSunDir));
// a thin fibre scatters most when it lies across the light
float tl = dot(normalize(vTan), uSunDir); float tf = sqrt(max(0.0, 1.0 - tl * tl));
vec3 col; float a;
if (vPart < 1.0) { col = uBeak * 0.55 + uSun * min(fwd, 14.0) * 0.06 * uBack * (0.5 + 0.5 * tf); a = 0.8; }
else {
col = uFil * 0.42 + uSky * 0.12 + uSun * min(fwd, 14.0) * 0.085 * uBack * (0.4 + 0.6 * tf);
a = mix(1.0, 0.55, vT);
}
// far off the focal plane the fibres give way to the seed's soft disc (drawn by the bokeh sprite)
a *= vCov * fiberA(vAcr, vHalf, vCoc) * (1.0 - smoothstep(8.0, 30.0, vCoc));
a *= 1.0 + 0.012 * min(fwd, 14.0); // toward the sun the haze is near white: the hairs hold out by glowing denser
a = min(a, 1.0) * vA;
if (a < 0.003) discard;
gl_FragColor = vec4(col, a);
}`;
/* ---------- the achenes: a ribbed spindle ---------- */
const ACHENE_VERT = QROT +
GLSL_FIBER +
`attribute vec3 aPos; attribute vec4 aQuat; attribute vec4 aInfo; uniform float uSize;
varying vec3 vN, vW, vL; varying float vA;
void main(){ vec3 wp = aPos + qrot(aQuat, position * aInfo.y * uSize); vN = qrot(aQuat, normal); vW = wp; vL = position;
vec4 mv = viewMatrix * vec4(wp, 1.0);
vA = aInfo.x * (1.0 - smoothstep(3.0, 14.0, cocPx(-mv.z))); // a defocused seed body melts into its disc
gl_Position = projectionMatrix * mv; }`;
const ACHENE_FRAG = GLSL_COMMON +
`uniform vec3 uAch, uSun, uSunDir, uSky, uShade; uniform float uBack;
varying vec3 vN, vW, vL; varying float vA;
void main(){
if (vA < 0.02) discard;
vec3 V = normalize(cameraPosition - vW), N = normalize(vN);
float fwd = fwdPh(dot(-V, uSunDir));
float ridge = 0.78 + 0.22 * sin(atan(vL.z, vL.x) * 10.0);
float barb = 0.85 + 0.15 * step(0.6, fract(vL.y * 70.0)) * smoothstep(0.1, 0.17, vL.y);
vec3 amb = mix(uShade * 1.5, uSky, N.y * 0.5 + 0.5) * 0.8;
vec3 col = uAch * (amb + uSun * max(dot(N, uSunDir), 0.0) * 0.8) * ridge * barb;
col += uAch * uSun * min(fwd, 12.0) * 0.06 * uBack; // a little light through the thin seed coat
col += uSun * pow(1.0 - max(dot(N, V), 0.0), 2.0) * min(fwd, 10.0) * 0.1 * uBack;
gl_FragColor = vec4(col, vA);
}`;
/* ---------- the seed's soft disc: what a pappus becomes off the focal plane ---------- */
const SEED_BLUR_VERT = GLSL_COMMON +
`attribute vec2 aB; uniform vec3 uFil, uSun, uSunDir, uSky; uniform float uBack;
varying float vI; varying vec3 vC;
void main(){ gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0); gl_PointSize = aB.y > 0.002 ? aB.x : 0.0;
float f = fwdPh(dot(normalize(position - cameraPosition), uSunDir));
vC = uFil * 0.4 + uSky * 0.15 + uSun * min(f, 14.0) * 0.08 * uBack; vI = aB.y; }`;
const SEED_BLUR_FRAG = `varying float vI; varying vec3 vC;
void main(){ vec2 c = gl_PointCoord * 2.0 - 1.0; float r = length(c); if (r > 1.0) discard;
// a soft, slightly limb-brightened disc with a denser core where the beak and achene sit
float d = (1.0 - smoothstep(0.62, 1.0, r)) * (0.75 + 0.25 * smoothstep(0.3, 0.85, r)) + 0.25 * exp(-r * r * 9.0);
gl_FragColor = vec4(vC, d * vI); }`;
/* ---------- dew: drops sitting on the blades and the stem ---------- */
const DEW_VERT = `attribute float aR; uniform float uPx; uniform vec2 uSunNdc; varying vec2 vSd;
void main(){ vec4 mv = viewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv;
gl_PointSize = max(1.0, 2.0 * aR * uPx / -mv.z);
vec2 nd = gl_Position.xy / gl_Position.w; vSd = normalize(uSunNdc - nd + 1e-4); }`;
const DEW_FRAG = GLSL_COMMON +
`uniform vec3 uSun, uSky, uShade, uMeadowC; uniform float uDew, uBack; varying vec2 vSd;
void main(){
vec2 c = gl_PointCoord * 2.0 - 1.0; c.y = -c.y; float r2 = dot(c, c); if (r2 > 1.0) discard;
// a drop is a lens: it shows the world upside down — the bright haze at its bottom, the dark meadow at its top
vec3 col = mix(uShade * 1.3, uSky * 1.05, 1.0 - smoothstep(-0.6, 0.5, c.y + dot(c, vSd) * 0.3));
col = mix(col, uMeadowC * 0.6, smoothstep(0.35, 0.0, abs(c.y + 0.1)) * 0.25);
col *= 1.0 - smoothstep(0.55, 1.0, r2) * 0.75; // the dark refracting rim
col += uSun * exp(-dot(c + vSd * 0.5, c + vSd * 0.5) * 14.0) * 1.3 * uBack; // the sun focused on the far side
col += vec3(1.0, 0.97, 0.9) * exp(-dot(c - vSd * 0.55, c - vSd * 0.55) * 140.0) * 1.5; // the glint toward the sun
gl_FragColor = vec4(col, (1.0 - smoothstep(0.8, 1.0, r2)) * uDew);
}`;
/* ---------- out-of-focus fluff: discs sized by their circle of confusion ---------- */
const MOTE_VERT = GLSL_COMMON +
`attribute float aB; uniform float uPx, uFocus, uDrift, uTime, uBack; uniform vec3 uSunDir;
varying float vI;
void main(){
vec3 p = position;
p.x = mod(p.x + uDrift * (0.6 + aB * 0.6) + 9.0, 18.0) - 9.0;
p.y += sin(uTime * 0.25 + aB * 20.0) * 0.25; p.z += cos(uTime * 0.2 + aB * 13.0) * 0.2;
vec4 mv = viewMatrix * vec4(p, 1.0); gl_Position = projectionMatrix * mv;
float d = -mv.z;
float coc = 0.11 * abs(d - uFocus) / max(d, 0.1) * uPx;
float px = clamp(coc, 3.0, 110.0); gl_PointSize = px;
vec3 ray = normalize(p - cameraPosition);
vI = aB * clamp(7.0 / px, 0.05, 1.0) * (0.15 + 0.12 * min(fwdPh(dot(ray, uSunDir)), 12.0)) * uBack;
if (d < 0.3) vI = 0.0;
}`;
const MOTE_FRAG = `uniform vec3 uGlint; uniform float uBokeh; varying float vI;
void main(){ vec2 c = gl_PointCoord * 2.0 - 1.0; float r = length(c); if (r > 1.0) discard;
float disc = (1.0 - smoothstep(0.82, 1.0, r)) * (0.7 + 0.3 * smoothstep(0.4, 0.95, r));
gl_FragColor = vec4(uGlint * disc * vI * uBokeh, 1.0); }`;
/* ---------- midges: a loose swarm dancing in the backlight ---------- */
const MIDGE_VERT = GLSL_COMMON +
GLSL_FIBER +
`attribute vec4 aPh, aFq; uniform float uTime, uBack; uniform vec3 uCtr, uSunDir, uPush;
varying float vI;
void main(){
float t = uTime;
// a midge darts round the swarm's centre: two slow loops, a quick bob, never leaving the swarm
vec3 o = vec3(sin(t * aFq.x + aPh.x) * 0.42 + sin(t * aFq.y * 2.3 + aPh.y) * 0.1,
sin(t * aFq.z * 1.3 + aPh.z) * 0.26 + sin(t * aFq.w + aPh.x) * 0.035,
cos(t * aFq.x * 0.8 + aPh.y) * 0.4);
vec3 p = uCtr + o * (0.7 + 0.6 * aPh.w) + uPush * (0.6 + 0.8 * aPh.w);
vec4 mv = viewMatrix * vec4(p, 1.0); gl_Position = projectionMatrix * mv;
float d = -mv.z, coc = cocPx(d);
float core = max(2.0, 0.035 * projectionMatrix[1][1] * 0.5 * uVp.y / d);
float px = core + coc; gl_PointSize = px;
float wing = 0.55 + 0.45 * sin(t * aFq.w * 1.7 + aPh.z * 3.0); // wings catch the light as it turns
vI = (core * core) / (px * px) * wing * (0.2 + 0.07 * min(fwdPh(dot(normalize(p - cameraPosition), uSunDir)), 14.0)) * uBack;
}`;
const MIDGE_FRAG = `uniform vec3 uGlint; varying float vI;
void main(){ vec2 c = gl_PointCoord * 2.0 - 1.0; float r = length(c); if (r > 1.0) discard;
gl_FragColor = vec4(uGlint * vI * (1.0 - smoothstep(0.55, 1.0, r)), 1.0); }`;
/* ---------- the foreground layer (third resolution, premultiplied) ---------- */
const FOREGROUND_VERT = FULLSCREEN_VERT;
const FOREGROUND_FRAG = GLSL_COMMON +
`
uniform vec2 uRes, uSunP, uPar, uHero; uniform float uTime, uGust, uSway, uHeroR, uHeroFill, uFg, uLens, uClocks, uBokeh, uBack;
uniform vec3 uShadeC, uMeadow, uSunC, uGlint, uFil;
varying vec2 vUv;
void over(inout vec3 rgb, inout float A, vec3 c, float a){ rgb = rgb * (1.0 - a) + c * a; A = A + a * (1.0 - A); }
void blade(inout vec3 rgb, inout float A, vec2 p, vec2 sp, float x0, float hg, float lean, float w0, float bl, float ph){
float y0 = -0.62;
float yy = clamp((p.y - y0) / hg, 0.0, 1.0);
float cx = x0 + lean * hg * yy * yy + sin(uTime * 0.6 + ph) * 0.012 * yy * yy * uSway * (1.0 + uGust * 3.0);
float w = w0 * pow(1.0 - yy, 0.7) + 0.002;
float dx = p.x - cx;
float a = soft(abs(dx), w, bl) * soft(p.y, y0 + hg, bl) * 0.93 * uFg;
float lit = exp(-length(vec2(cx, y0 + hg * 0.7) - sp) * 1.6);
vec3 c = mix(uShadeC * 0.75, uMeadow * 0.55, yy * 0.5) + uSunC * (0.08 + 0.5 * lit) * smoothstep(-w * 0.2, w + bl, dx * sign(sp.x - cx)) * uBack;
over(rgb, A, c, a);
}
void main(){
float asp = uRes.x / uRes.y, t = uTime;
vec2 p = (vUv - 0.5) * vec2(asp, 1.0);
vec2 sp = (uSunP - 0.5) * vec2(asp, 1.0);
vec2 q = p - uPar * 0.45; // nearer than the focus: moves the other way
vec3 rgb = vec3(0.0); float A = 0.0;
// a near clock, bottom right, far out of focus: a big soft glow
{ vec2 c = vec2(0.80, -0.44) + vec2(sin(t * 0.5), 0.0) * 0.006 * (1.0 + uGust * 3.0); float r = 0.2, b = 0.09;
float l = length(q - c); float a = soft(l, r, b) * 0.32 * uClocks;
float limb = exp(-pow((l - r * 0.85) / 0.08, 2.0));
over(rgb, A, mix(uFil, uSunC, 0.5) * (0.75 + 0.5 * limb), a); }
// the meadow's near edge: a dark, very soft mass of grass along the bottom
{ float edge = -0.36 + (vnoise(vec2(q.x * 5.0, 2.0)) - 0.5) * 0.12 + (vnoise(vec2(q.x * 13.0, 5.0)) - 0.5) * 0.05;
float a = (1.0 - smoothstep(edge - 0.1, edge + 0.08, q.y)) * 0.72 * uFg;
float lit = exp(-length(vec2(q.x, edge) - sp) * 1.4);
over(rgb, A, mix(uShadeC * 0.8, uMeadow * 0.5, 0.3) + uSunC * 0.12 * lit * smoothstep(edge - 0.12, edge, q.y), a); }
// soft foreground grass
blade(rgb, A, q, sp, -0.74, 0.78, 0.22, 0.04, 0.045, 0.0);
blade(rgb, A, q, sp, -0.58, 0.40, -0.25, 0.032, 0.035, 2.0);
blade(rgb, A, q, sp, 0.70, 0.62, -0.38, 0.045, 0.055, 4.0);
blade(rgb, A, q, sp, 0.16, 0.16, 0.12, 0.05, 0.06, 5.5);
// out-of-focus seeds in front of the lens: big faint discs
for (int i = 0; i < 4; i++) { float fi = float(i);
vec2 c = vec2((h11(fi * 7.7) - 0.5) * asp, (h11(fi * 3.3) - 0.5) * 0.9) + vec2(sin(t * 0.07 + fi * 2.0) * 0.06, cos(t * 0.05 + fi) * 0.03);
float r = 0.05 + 0.05 * h11(fi * 9.1); float l = length(q - c);
float d = soft(l, r, r * 0.12) * (0.6 + 0.4 * smoothstep(r * 0.5, r, l));
rgb += uGlint * d * 0.035 * uBokeh * (0.5 + exp(-length(c - sp) * 2.0)); }
// the hero's limb glow: light scattered by the fringe of hairs, strongest on the sun side
{ vec2 d = p - uHero; float l = length(d);
float ring = exp(-pow((l - uHeroR * 0.93) / (uHeroR * 0.13), 2.0));
float fill = exp(-pow(l / uHeroR, 4.0));
float side = 0.4 + 0.6 * max(0.0, dot(d / max(l, 1e-4), normalize(sp - uHero)));
float prox = 0.6 + 0.8 * exp(-length(uHero - sp) * 1.5);
rgb += uSunC * (ring * 0.09 + fill * 0.035) * side * prox * uHeroFill * uBack; }
// the lens: a veil toward the sun and a few faint ghosts along the axis through the centre
{ float ds = length(p - sp);
rgb += uSunC * (exp(-ds * 1.8) * 0.06 + exp(-ds * ds * 30.0) * 0.08) * uLens;
for (int i = 0; i < 4; i++) { float fi = float(i);
float k = -0.35 - fi * 0.42; vec2 gc = sp * k; float r = 0.025 + 0.03 * h11(fi * 5.5 + 2.0);
vec3 tint = mix(vec3(1.0, 0.75, 0.4), vec3(0.55, 0.85, 0.8), h11(fi * 1.9));
rgb += tint * soft(length(p - gc), r, r * 0.35) * 0.03 * uLens; } }
rgb += (h12(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(max(rgb, 0.0), A);
}`;
/** Composites the premultiplied foreground render target over the frame. */
const FOREGROUND_BLIT_VERT = FULLSCREEN_VERT;
const FOREGROUND_BLIT_FRAG = `uniform sampler2D tFg; varying vec2 vUv; void main(){ gl_FragColor = texture2D(tFg, vUv); }`;
```
### 2 · Tuning (bake these in)
```js
/**
* The dandelion scene's tuning — ported from the MOTDES `dandelion.html`
* scene (its CONFIG block, values unchanged). These feed shader uniforms, not
* CSS: the palette is the scene's own light, so it lives here and not in the
* design tokens. Colours are 0xRRGGBB.
*
* DANDELION: a macro of a dandelion clock in a meadow at golden hour, backlit
* by a low sun. One hero clock is in focus (pitted receptacle, dark achenes,
* long beaks, an umbrella of fine pappus hairs that glow by forward
* scattering); other clocks, grass and seed heads sit in front of and behind
* it as soft bokeh. The cursor is a BREATH — seeds near it lean, let go and
* tumble off on the air, and the stem bends. A CLICK blows a gust that strips
* the clock in a wave. Rest the cursor and the clock regrows.
*/
const DANDELION_CONFIG = {
// palette
filamentColor: 0xfff3df, // the pappus hairs
beakColor: 0xefe4c6, // the long seed beak
acheneColor: 0x77603c, // the seed body
stemColor: 0x56742a,
grassColor: 0x3e5a19,
transColor: 0xd8e86a, // light through leaf tissue
sunColor: 0xffd98f,
skyColor: 0xf0c879, // the warm haze
treeColor: 0x4a5428, // the far tree line (soft)
meadowColor: 0x4f6a22,
shadeColor: 0x17260f, // deep meadow shade
glintColor: 0xffe2a6, // bokeh glints
receptacleColor: 0xcdb98a, // the bare, pitted receptacle
// the clock
seeds: 210, // hero seed count
filaments: 44, // pappus hairs per seed
seedSize: 1.0,
sway: 1.0, // the idle breeze
shed: 0.15, // seeds that let go on their own, per second
// the breath + the blow
breathRadius: 0.9,
breathPower: 1.0,
blowStrength: 1.0,
blowRadius: 4.0,
wind: 0.32, // the drift (+x, toward the sun)
lift: 0.05,
turbulence: 1.0,
tumble: 1.0, // how hard a released seed tumbles before it rights itself
flightTime: 14,
regrow: 6,
airSeeds: 14, // seeds from other clocks drifting through, at every depth
midges: 12, // a small swarm dancing in the backlight, right of the clock
stemHairs: 420, // fine hairs on the stem and along the blade edges
// the meadow
blades: 7, // in-focus grass blades
dew: 1.0,
motes: 70, // out-of-focus fluff and pollen
softClocks: 1.0, // the out-of-focus clocks behind and in front
foreground: 1.0, // the soft foreground grass
bokeh: 1.0,
// light
sunX: 0.62,
sunY: 0.7,
sunGlow: 1.0,
backlight: 1.0,
lensGlow: 1.0,
haze: 1.0,
// the lens: depth of field for everything that leaves the focal plane
aperture: 0.12, // circle-of-confusion gain
focusDepth: 1.1, // half-depth of the sharp zone round the clock
hairWidth: 1.0, // pappus hair thickness (sub-pixel, anti-aliased)
// camera
camDist: 7.4,
camHeight: 0.25,
orbit: 0.06,
parallax: 1.0,
fov: 30,
// grade
warmth: 1.0,
grain: 0.022,
mainAlpha: 1.0,
/** Device-pixel-ratio ceiling — the source scene's own cap. */
maxDpr: 1.5,
/** The intro: a fade up from black, after a short hold (seconds). */
introDelay: 0.2,
introDuration: 1.6,
};
/**
* Where the clock sits on screen, per layout (`x` 0 left … 1 right, `y` 0 top …
* 1 bottom), keyed by the box's CSS width — the same breakpoints as the hero
* (`sm` 640, `lg` 1024). The source aimed a fixed offset from the clock, so it
* wandered with the aspect ratio (cropped at a phone's left edge).
* - desktop: the Figma hero (node 7438:6957) — 47% across; 55.6% down is the
* source's own height;
* - phone: centred, in the open band between the header and the copy;
* - tablet: up and right, so the heading under it reads on darker meadow.
*/
const DANDELION_FRAMING = [
{ maxWidth: 639, x: 0.5, y: 0.41 },
// up and right of centre: the heading sits under the clock, left
{ maxWidth: 1023, x: 0.58, y: 0.29 },
{ maxWidth: Infinity, x: 0.471, y: 0.556 },
];
```
### 3 · The simulation and renderer
The small helpers it relies on come first; then the scene, verbatim.
```js
import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
const S = { R0, A0, AP, HL, SEED_MID, FINAL_VERT, FINAL_FRAG, BACKDROP_VERT, BACKDROP_FRAG, BACKDROP_BLIT_VERT,
BACKDROP_BLIT_FRAG, PLANT_VERT, PLANT_FRAG, HAIR_VERT, HAIR_FRAG, SEED_VERT, SEED_FRAG, ACHENE_VERT, ACHENE_FRAG,
SEED_BLUR_VERT, SEED_BLUR_FRAG, DEW_VERT, DEW_FRAG, MOTE_VERT, MOTE_FRAG, MIDGE_VERT, MIDGE_FRAG, FOREGROUND_VERT,
FOREGROUND_FRAG, FOREGROUND_BLIT_VERT, FOREGROUND_BLIT_FRAG };
const CONFIG = DANDELION_CONFIG;
const DANDELION_WISH_EVENT = "dandelion:wish"; // the Make a Wish button: window.dispatchEvent(new Event(DANDELION_WISH_EVENT))
let sceneReady = false; const readyListeners = new Set();
const markSceneReady = () => { if (sceneReady) return; sceneReady = true; readyListeners.forEach((l) => l()); readyListeners.clear(); };
const onSceneReady = (l) => { if (sceneReady) l(); else readyListeners.add(l); }; // the preloader subscribes
const createFrameClock = () => { let first = -1, last = -1; return {
tick(now) { if (first < 0) first = now; const dt = last < 0 ? 0 : Math.min(0.1, (now - last) / 1000); last = now;
return { dt: Math.max(0, dt), t: (now - first) / 1000 }; },
reset() { last = -1; } }; };
const createDrawGate = (enabled, intervalMs = 12.5) => { let since = Infinity; return {
shouldDraw(dt) { if (!enabled) return true; since += dt * 1000; if (since < intervalMs) return false; since = 0; return true; },
force() { since = Infinity; } }; };
const watchContext = (canvas, onChange) => {
const lost = (e) => { e.preventDefault(); onChange("lost"); }, restored = () => onChange("restored");
canvas.addEventListener("webglcontextlost", lost); canvas.addEventListener("webglcontextrestored", restored);
return () => { canvas.removeEventListener("webglcontextlost", lost); canvas.removeEventListener("webglcontextrestored", restored); }; };
const UP = new THREE.Vector3(0, 1, 0);
const color = (hex) => new THREE.Vector3(((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255);
const quadIndex = (n) => {
const idx = [];
for (let i = 0; i < n; i++) {
const b = i * 4;
idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
}
return idx;
};
/** A seeded PRNG — the scene's layout is the same on every load. */
const mulberry = (seed) => {
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;
};
};
const smooth01 = (a, b, x) => {
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
class Dandelion {
scene;
camera;
renderer;
finalU;
/** CSS size of the box. */
width = 1;
height = 1;
time = 0;
skipIntro;
pSm = new THREE.Vector2();
pPrev = new THREE.Vector2();
speed = 0;
breathPt = new THREE.Vector3(1e4, 0, 0);
breathPrev = new THREE.Vector3(1e4, 0, 0);
breathVel = new THREE.Vector3();
breath = 0;
head0 = new THREE.Vector3(0, 0, 0);
head = this.head0.clone();
bend = new THREE.Vector3();
bendV = new THREE.Vector3();
headQ = new THREE.Quaternion();
sunDir = new THREE.Vector3(0.3, 0.3, -1).normalize();
gustT = -1e9;
gustDir = new THREE.Vector3(1, 0, 0);
gustStr = 0;
gustEnv = 0;
still = 0;
drift = 0;
_v = new THREE.Vector3();
_v2 = new THREE.Vector3();
_v3 = new THREE.Vector3();
_v4 = new THREE.Vector3();
_q = new THREE.Quaternion();
common = {
uSun: { value: color(CONFIG.sunColor) },
uSunDir: { value: this.sunDir },
uSky: { value: color(CONFIG.skyColor) },
uShade: { value: color(CONFIG.shadeColor) },
uBack: { value: CONFIG.backlight },
uTime: { value: 0 },
};
lens = {
uVp: { value: new THREE.Vector2(1, 1) },
uFocus: { value: CONFIG.camDist },
uAper: { value: CONFIG.aperture },
uFDepth: { value: CONFIG.focusDepth },
};
// backdrop + foreground (third resolution)
bgU = {
uRes: { value: new THREE.Vector2(1, 1) },
uTime: { value: 0 },
uSunP: { value: new THREE.Vector2(CONFIG.sunX, CONFIG.sunY) },
uPar: { value: new THREE.Vector2() },
uSky: { value: color(CONFIG.skyColor) },
uTree: { value: color(CONFIG.treeColor) },
uMeadow: { value: color(CONFIG.meadowColor) },
uShadeC: { value: color(CONFIG.shadeColor) },
uSunC: { value: color(CONFIG.sunColor) },
uGlint: { value: color(CONFIG.glintColor) },
uFil: { value: color(CONFIG.filamentColor) },
uSunGlow: { value: CONFIG.sunGlow },
uBokeh: { value: CONFIG.bokeh },
uClocks: { value: CONFIG.softClocks },
uHaze: { value: CONFIG.haze },
uGust: { value: 0 },
uSway: { value: CONFIG.sway },
};
bgRT;
bgScene = new THREE.Scene();
bgCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
fgU = {
uRes: this.bgU.uRes, uTime: this.bgU.uTime, uSunP: this.bgU.uSunP, uPar: this.bgU.uPar, uGust: this.bgU.uGust, uSway: this.bgU.uSway,
uShadeC: this.bgU.uShadeC, uMeadow: this.bgU.uMeadow, uSunC: this.bgU.uSunC, uGlint: this.bgU.uGlint, uFil: this.bgU.uFil,
uHero: { value: new THREE.Vector2() },
uHeroR: { value: 0.3 },
uHeroFill: { value: 1 },
uFg: { value: CONFIG.foreground },
uLens: { value: CONFIG.lensGlow },
uClocks: this.bgU.uClocks, uBokeh: this.bgU.uBokeh, uBack: this.common.uBack,
};
fgRT;
fgScene = new THREE.Scene();
// the plant
NT = 48;
NR = 18;
stemPts = [];
stemGeo = new THREE.BufferGeometry();
stemPos;
stemNor;
stemSide;
ringC = [];
ringT = [];
ringS = [];
ringF = [];
headGroup = new THREE.Group();
// blades + dew
NS = 30;
bladeDefs = [];
bladeGeo = new THREE.BufferGeometry();
bladePos = new Float32Array(0);
bladeNor = new Float32Array(0);
bladeSide = new Float32Array(0);
drops = [];
dewPos = new Float32Array(0);
dewMesh = null;
dewU = null;
// stem hairs
hairs = [];
hP0 = null;
hP1 = null;
hP0a = new Float32Array(0);
hP1a = new Float32Array(0);
// seeds
seeds = [];
NH = 0;
shedAcc = 0;
aPos = null;
aQuat = null;
aInfo = null;
blurPos = null;
blurInfo = null;
blurPosA = new Float32Array(0);
blurInfoA = new Float32Array(0);
// motes + midges
moteU = null;
midgeU = null;
midgePush = new THREE.Vector3();
midgePushV = new THREE.Vector3();
constructor(scene, camera, renderer, finalU, skipIntro) {
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
this.finalU = finalU;
this.skipIntro = skipIntro;
const rtOpts = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: false };
this.bgRT = new THREE.WebGLRenderTarget(2, 2, rtOpts);
this.fgRT = new THREE.WebGLRenderTarget(2, 2, rtOpts);
const vtx = (this.NT + 1) * (this.NR + 1) * 3;
this.stemPos = new Float32Array(vtx);
this.stemNor = new Float32Array(vtx);
this.stemSide = new Float32Array(vtx);
this.buildBackdrop();
this.buildPlant();
this.buildSeeds();
this.buildBlades();
this.buildStemHairs();
this.buildMotes();
this.buildMidges();
this.buildForeground();
}
/* ---------- the out-of-focus meadow (third resolution) ---------- */
buildBackdrop() {
const q = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: this.bgU, depthWrite: false, depthTest: false, vertexShader: S.BACKDROP_VERT, fragmentShader: S.BACKDROP_FRAG }));
q.frustumCulled = false;
this.bgScene.add(q);
const bg = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
uniforms: { tBg: { value: this.bgRT.texture } }, depthWrite: false, depthTest: false,
vertexShader: S.BACKDROP_BLIT_VERT, fragmentShader: S.BACKDROP_BLIT_FRAG,
}));
bg.frustumCulled = false;
bg.renderOrder = -10;
this.scene.add(bg);
}
/* ---------- the plant: stem, receptacle, bracts ---------- */
plantMat(hex, opts) {
const uniforms = Object.assign({}, this.common, {
uBase: { value: color(hex) },
uTrans: { value: color(CONFIG.transColor) },
uRim: { value: opts.rim },
uTransAmt: { value: opts.trans },
uEdge: { value: opts.edge ?? 0 },
uPit: { value: opts.pit ?? 0 },
uKind: { value: opts.kind ?? 0 },
});
return new THREE.ShaderMaterial({
uniforms, side: THREE.DoubleSide, transparent: Boolean(opts.kind), extensions: { derivatives: true, fragDepth: false, drawBuffers: false, shaderTextureLOD: false },
vertexShader: S.PLANT_VERT, fragmentShader: S.PLANT_FRAG,
});
}
buildPlant() {
const { NT, NR } = this;
const stemMat = this.plantMat(CONFIG.stemColor, { rim: 1.0, trans: 0.3, kind: 1 });
// the stem: a hand-made tube so it can bend every frame; base well below the frame
const curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0.75, -7.5, -0.9), new THREE.Vector3(0.35, -4.5, -0.45), new THREE.Vector3(0.06, -1.8, -0.08), new THREE.Vector3(0, -0.1, 0),
]);
for (let i = 0; i <= NT; i++)
this.stemPts.push(curve.getPoint(i / NT));
const uv = new Float32Array((NT + 1) * (NR + 1) * 2);
const idx = [];
for (let i = 0; i <= NT; i++) {
for (let j = 0; j <= NR; j++) {
const k = i * (NR + 1) + j;
uv[k * 2] = i / NT;
uv[k * 2 + 1] = j / NR;
if (i < NT && j < NR) {
const a = k, b = k + NR + 1;
idx.push(a, b, a + 1, b, b + 1, a + 1);
}
}
}
const g = this.stemGeo;
g.setAttribute("position", new THREE.BufferAttribute(this.stemPos, 3));
g.setAttribute("normal", new THREE.BufferAttribute(this.stemNor, 3));
g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
g.setAttribute("aSide", new THREE.BufferAttribute(this.stemSide, 3));
g.setIndex(idx);
const stem = new THREE.Mesh(g, stemMat);
stem.frustumCulled = false;
stem.renderOrder = 1;
this.scene.add(stem);
for (let i = 0; i <= NT; i++) {
this.ringC.push(new THREE.Vector3());
this.ringT.push(new THREE.Vector3());
this.ringS.push(new THREE.Vector3());
this.ringF.push(new THREE.Vector3());
}
// the head: receptacle + reflexed bracts, carried by the stem's tip
this.scene.add(this.headGroup);
const rec = new THREE.Mesh(new THREE.SphereGeometry(S.R0, 28, 18), this.plantMat(CONFIG.receptacleColor, { rim: 0.5, trans: 0.2, pit: 1 }));
rec.scale.set(1, 0.82, 1);
this.headGroup.add(rec);
const rnd = mulberry(7);
const bp = [], bn = [], bu = [], bi = [];
const NB = 16, NSB = 8;
for (let j = 0; j < NB; j++) {
const ph = (j / NB) * Math.PI * 2 + rnd() * 0.2, len = 0.3 + rnd() * 0.18, w0 = 0.022 + rnd() * 0.01, out = 0.05 + rnd() * 0.05;
const rad = new THREE.Vector3(Math.cos(ph), 0, Math.sin(ph)), tan = new THREE.Vector3(-Math.sin(ph), 0, Math.cos(ph));
const base0 = bp.length / 3;
for (let k = 0; k <= NSB; k++) {
const s = k / NSB;
const c = new THREE.Vector3().addScaledVector(rad, 0.085 + out * Math.sin(s * 2.2)).add(new THREE.Vector3(0, -0.06 - len * s, 0));
const w = w0 * (1 - s * 0.85);
for (const sg of [-1, 1]) {
const p = c.clone().addScaledVector(tan, w * sg);
bp.push(p.x, p.y, p.z);
bn.push(rad.x, 0.3, rad.z);
bu.push(s, sg < 0 ? 0 : 1);
}
if (k < NSB) {
const a = base0 + k * 2;
bi.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
}
}
}
const bg = new THREE.BufferGeometry();
bg.setAttribute("position", new THREE.Float32BufferAttribute(bp, 3));
bg.setAttribute("normal", new THREE.Float32BufferAttribute(bn, 3));
bg.setAttribute("uv", new THREE.Float32BufferAttribute(bu, 2));
bg.setIndex(bi);
this.headGroup.add(new THREE.Mesh(bg, this.plantMat(CONFIG.stemColor, { rim: 0.6, trans: 0.25, edge: 0.3 })));
this.updateStem();
}
/* ---------- fine hairs on the stem: sparse, appressed, densest under the head; lit only when the sun is behind ---------- */
buildStemHairs() {
const M = Math.max(0, Math.round(CONFIG.stemHairs)), rnd = mulberry(41);
this.hairs = [];
for (let i = 0; i < M; i++) {
this.hairs.push({ host: -1, u: 1 - 0.36 * Math.pow(rnd(), 1.5), a: rnd() * Math.PI * 2, edge: 0, tilt: 0.35 + rnd() * 0.75, len: 0.018 + rnd() * rnd() * 0.06, kink: (rnd() - 0.5) * 0.6, lift: 0 });
}
// the grass blades carry fine marginal hairs (cilia) along their edges, leaning toward the tip
const nb = Math.round((M / 420) * 34);
if (M > 0) {
this.bladeDefs.forEach((_d, b) => {
for (let i = 0; i < nb; i++) {
this.hairs.push({ host: b, u: 0.12 + rnd() * 0.78, a: 0, edge: rnd() < 0.5 ? -1 : 1, tilt: 0.5 + rnd() * 0.6, len: 0.012 + rnd() * 0.03, kink: (rnd() - 0.5) * 0.5, lift: (rnd() - 0.5) * 0.8 });
}
});
}
const n = Math.max(1, this.hairs.length) * 2; // two segments per hair (a slight kink)
const corner = [], ids = [];
for (let i = 0; i < n; i++) {
for (const end of [0, 1]) {
for (const sd of [-1, 1]) {
corner.push(end, sd, i % 2);
ids.push(Math.floor(i / 2));
}
}
}
const g = new THREE.BufferGeometry();
this.hP0a = new Float32Array(n * 4 * 3);
this.hP1a = new Float32Array(n * 4 * 3);
this.hP0 = new THREE.BufferAttribute(this.hP0a, 3).setUsage(THREE.DynamicDrawUsage);
this.hP1 = new THREE.BufferAttribute(this.hP1a, 3).setUsage(THREE.DynamicDrawUsage);
g.setAttribute("position", this.hP0);
g.setAttribute("aP1", this.hP1);
g.setAttribute("aC", new THREE.Float32BufferAttribute(corner, 3));
g.setAttribute("aId", new THREE.Float32BufferAttribute(ids, 1));
g.setIndex(quadIndex(n));
const uniforms = Object.assign({}, this.common, this.lens, { uFil: { value: color(CONFIG.filamentColor) } });
const m = new THREE.ShaderMaterial({ uniforms, transparent: true, depthWrite: false, side: THREE.DoubleSide, vertexShader: S.HAIR_VERT, fragmentShader: S.HAIR_FRAG });
const mesh = new THREE.Mesh(g, m);
mesh.frustumCulled = false;
mesh.renderOrder = 2.5;
mesh.visible = M > 0;
this.scene.add(mesh);
}
updateStemHairs(t) {
if (!this.hairs.length || !this.hP0 || !this.hP1)
return;
const P0 = this.hP0a, P1 = this.hP1a, NT = this.NT;
const c = this._v4, rad = new THREE.Vector3(), d = new THREE.Vector3(), m = new THREE.Vector3(), e = new THREE.Vector3();
const tn = new THREE.Vector3(), sd = new THREE.Vector3(), nn = new THREE.Vector3();
this.hairs.forEach((h, i) => {
if (h.host >= 0) {
// a marginal hair on a blade
const bd = this.bladeDefs[h.host];
this.bladePoint(bd, h.u, t, c, tn, sd);
nn.crossVectors(sd, tn).normalize();
const w = bd.w * 1.7 * Math.pow(1 - h.u, 0.75) + 0.003;
c.addScaledVector(sd, w * h.edge * 0.98);
d.copy(tn).multiplyScalar(Math.cos(h.tilt)).addScaledVector(sd, h.edge * Math.sin(h.tilt)).addScaledVector(nn, h.lift * 0.4).normalize();
m.copy(c).addScaledVector(d, h.len * 0.5);
d.lerp(tn, 0.35 + h.kink * 0.3).normalize();
e.copy(m).addScaledVector(d, h.len * 0.5);
}
else {
const f = h.u * NT, i0 = Math.min(NT - 1, Math.floor(f)), k = f - i0;
const T = this.ringT[i0], Sd = this.ringS[i0], F = this.ringF[i0];
const r = 0.07 - 0.012 * h.u;
rad.copy(Sd).multiplyScalar(Math.cos(h.a)).addScaledVector(F, Math.sin(h.a));
c.copy(this.ringC[i0]).lerp(this.ringC[i0 + 1], k).addScaledVector(rad, r * 0.97);
d.copy(T).multiplyScalar(Math.cos(h.tilt)).addScaledVector(rad, Math.sin(h.tilt));
m.copy(c).addScaledVector(d, h.len * 0.5);
d.copy(T).multiplyScalar(Math.cos(h.tilt * (0.6 + h.kink))).addScaledVector(rad, Math.sin(h.tilt * (0.6 + h.kink)));
e.copy(m).addScaledVector(d, h.len * 0.5);
}
for (let s = 0; s < 2; s++) {
const a = s ? m : c, b = s ? e : m, base = (i * 2 + s) * 4 * 3;
for (let v = 0; v < 4; v++) {
const o = base + v * 3;
P0[o] = a.x;
P0[o + 1] = a.y;
P0[o + 2] = a.z;
P1[o] = b.x;
P1[o + 1] = b.y;
P1[o + 2] = b.z;
}
}
});
this.hP0.needsUpdate = true;
this.hP1.needsUpdate = true;
}
// the stem bends as a cantilever: the head carries the full offset, the base none
updateStem() {
const { NT, NR } = this, P = this.stemPos, N = this.stemNor, T = this.stemSide;
const tn = this._v2, side = this._v3, fw = new THREE.Vector3();
const ctr = [];
for (let i = 0; i <= NT; i++) {
const u = i / NT, w = (u * u * (3 - u)) / 2;
ctr.push(this.stemPts[i].clone().addScaledVector(this.bend, w));
}
for (let i = 0; i <= NT; i++) {
tn.subVectors(ctr[Math.min(NT, i + 1)], ctr[Math.max(0, i - 1)]).normalize();
side.set(0, 0, 1).cross(tn).normalize();
fw.crossVectors(side, tn);
const r = 0.07 - 0.012 * (i / NT);
this.ringC[i].copy(ctr[i]);
this.ringT[i].copy(tn);
this.ringS[i].copy(side);
this.ringF[i].copy(fw);
for (let j = 0; j <= NR; j++) {
const a = (j / NR) * Math.PI * 2, k = (i * (NR + 1) + j) * 3;
const ca = Math.cos(a), sa = Math.sin(a);
const nx = side.x * ca + fw.x * sa, ny = side.y * ca + fw.y * sa, nz = side.z * ca + fw.z * sa;
P[k] = ctr[i].x + nx * r;
P[k + 1] = ctr[i].y + ny * r;
P[k + 2] = ctr[i].z + nz * r;
N[k] = nx;
N[k + 1] = ny;
N[k + 2] = nz;
T[k] = tn.x;
T[k + 1] = tn.y;
T[k + 2] = tn.z;
}
}
this.stemGeo.attributes.position.needsUpdate = true;
this.stemGeo.attributes.normal.needsUpdate = true;
this.stemGeo.attributes.aSide.needsUpdate = true;
// the head rides the tip, tilted with the stem's last tangent
tn.subVectors(ctr[NT], ctr[NT - 2]).normalize();
this.headQ.setFromUnitVectors(UP, tn);
this.head.copy(ctr[NT]).addScaledVector(tn, 0.1);
this.headGroup.position.copy(this.head);
this.headGroup.quaternion.copy(this.headQ);
}
/* ---------- the seeds: instanced achenes + one batch of beak and pappus fibres ---------- */
buildSeeds() {
const NH = Math.max(20, Math.round(CONFIG.seeds)), NA = Math.max(0, Math.round(CONFIG.airSeeds)), N = NH + NA, F = Math.max(8, Math.round(CONFIG.filaments));
const rnd = mulberry(11);
// one quad per fibre segment: hA = (azimuth, elevation, t0, t1), hB = (length factor, curl, part, end), hS = side
const pos = [], hA = [], hB = [], hS = [];
const seg = (a, e, t0, t1, part, lf, curl) => {
for (const end of [0, 1]) {
for (const sd of [-1, 1]) {
pos.push(0, 0, 0);
hA.push(a, e, t0, t1);
hB.push(lf, curl, part, end);
hS.push(sd);
}
}
};
seg(0, 0, 0, 1, 0, 1, 0);
for (let i = 0; i < F; i++) {
const a = (i / F) * Math.PI * 2 + (rnd() - 0.5) * 0.2, e = 1.05 + rnd() * 0.32, lf = 0.82 + rnd() * 0.3, curl = 0.12 + rnd() * 0.25;
seg(a, e, 0, 0.5, 2, lf, curl);
seg(a, e, 0.5, 1, 2, lf, curl);
}
const aPos = new THREE.InstancedBufferAttribute(new Float32Array(N * 3), 3).setUsage(THREE.DynamicDrawUsage);
const aQuat = new THREE.InstancedBufferAttribute(new Float32Array(N * 4), 4).setUsage(THREE.DynamicDrawUsage);
const aInfo = new THREE.InstancedBufferAttribute(new Float32Array(N * 4), 4).setUsage(THREE.DynamicDrawUsage);
this.aPos = aPos;
this.aQuat = aQuat;
this.aInfo = aInfo;
const geo = new THREE.InstancedBufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute("hA", new THREE.Float32BufferAttribute(hA, 4));
geo.setAttribute("hB", new THREE.Float32BufferAttribute(hB, 4));
geo.setAttribute("hS", new THREE.Float32BufferAttribute(hS, 1));
geo.setIndex(quadIndex(pos.length / 12));
geo.setAttribute("aPos", aPos);
geo.setAttribute("aQuat", aQuat);
geo.setAttribute("aInfo", aInfo);
geo.instanceCount = N;
const sU = Object.assign({}, this.common, this.lens, {
uSize: { value: CONFIG.seedSize },
uFil: { value: color(CONFIG.filamentColor) },
uBeak: { value: color(CONFIG.beakColor) },
uAch: { value: color(CONFIG.acheneColor) },
uHairW: { value: CONFIG.hairWidth },
});
const mat = new THREE.ShaderMaterial({ uniforms: sU, transparent: true, depthWrite: false, side: THREE.DoubleSide, vertexShader: S.SEED_VERT, fragmentShader: S.SEED_FRAG });
const seedMesh = new THREE.Mesh(geo, mat);
seedMesh.frustumCulled = false;
seedMesh.renderOrder = 3;
this.scene.add(seedMesh);
// the achenes: a ribbed spindle, shared instance attributes
const lathe = new THREE.LatheGeometry([[0, 0], [0.008, 0.015], [0.015, 0.06], [0.017, 0.12], [0.013, 0.175], [0.005, 0.205], [0.002, S.A0]].map(([x, y]) => new THREE.Vector2(x, y)), 8);
const ag = new THREE.InstancedBufferGeometry();
ag.index = lathe.index;
ag.setAttribute("position", lathe.attributes.position);
ag.setAttribute("normal", lathe.attributes.normal);
ag.setAttribute("aPos", aPos);
ag.setAttribute("aQuat", aQuat);
ag.setAttribute("aInfo", aInfo);
ag.instanceCount = N;
const am = new THREE.ShaderMaterial({ uniforms: sU, transparent: true, vertexShader: S.ACHENE_VERT, fragmentShader: S.ACHENE_FRAG });
const acheneMesh = new THREE.Mesh(ag, am);
acheneMesh.frustumCulled = false;
acheneMesh.renderOrder = 2;
this.scene.add(acheneMesh);
// the seed's soft disc: what a pappus becomes off the focal plane — a round backlit blur sized by its circle of confusion
this.blurPosA = new Float32Array(N * 3);
this.blurInfoA = new Float32Array(N * 2);
this.blurPos = new THREE.BufferAttribute(this.blurPosA, 3).setUsage(THREE.DynamicDrawUsage);
this.blurInfo = new THREE.BufferAttribute(this.blurInfoA, 2).setUsage(THREE.DynamicDrawUsage);
const bgeo = new THREE.BufferGeometry();
bgeo.setAttribute("position", this.blurPos);
bgeo.setAttribute("aB", this.blurInfo);
const bmat = new THREE.ShaderMaterial({ uniforms: sU, transparent: true, depthWrite: false, vertexShader: S.SEED_BLUR_VERT, fragmentShader: S.SEED_BLUR_FRAG });
const blurMesh = new THREE.Points(bgeo, bmat);
blurMesh.frustumCulled = false;
blurMesh.renderOrder = 3.5;
this.scene.add(blurMesh);
// state
this.seeds = [];
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < N; i++) {
const s = {
hero: i < NH, state: 0, grow: 1, open: 1, rand: rnd(), phase: rnd() * 6.28, pos: new THREE.Vector3(), vel: new THREE.Vector3(), quat: new THREE.Quaternion(),
ang: new THREE.Vector3(), spin: 0, zHome: 0, updraft: 0, thresh: 0.3 + rnd() * 0.5, scale: 0.88 + rnd() * 0.24, pending: -1, age: 0, wdir: new THREE.Vector3(0, 1, 0), dir: UP.clone(),
};
if (s.hero) {
const y = 1 - (i / (NH - 1)) * 1.8, r = Math.sqrt(Math.max(0, 1 - y * y)), th = golden * i;
s.dir = new THREE.Vector3(Math.cos(th) * r + (rnd() - 0.5) * 0.08, y, Math.sin(th) * r + (rnd() - 0.5) * 0.08).normalize();
if (rnd() < 0.05) {
s.state = 2;
s.grow = rnd();
}
}
else
this.spawnAir(s, true);
this.seeds.push(s);
}
this.NH = NH;
this.shedAcc = 0;
}
/** The clock's place on screen for this box width (`DANDELION_FRAMING`). */
framing() {
return DANDELION_FRAMING.find((f) => this.width <= f.maxWidth) ?? DANDELION_FRAMING[DANDELION_FRAMING.length - 1];
}
/** How far right of the clock the camera aims, per unit of depth — puts the clock at `framing().x` at any aspect. */
aimK() {
return -(2 * this.framing().x - 1) * Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2) * (this.width / this.height);
}
/** How far above the clock the camera aims, per unit of depth — puts it at `framing().y`. */
aimUpK() {
return (2 * this.framing().y - 1) * Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2);
}
// the visible half-width / half-height of the frame at world depth z (the camera sits near z = camDist, aimed right of the clock)
viewBox(z) {
const D = Math.max(0.6, CONFIG.camDist - z), th = Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2);
return { cx: this.aimK() * D, hw: th * D * (this.width / this.height), hh: th * D };
}
// a seed from another clock: at any depth — some far behind, some right in front of the lens — so the lens has
// something to blur; most come in on the wind from the left, some rise on a warm updraft from the meadow on the right
spawnAir(s, anywhere) {
s.state = 1;
s.age = 0;
s.open = 1;
s.grow = 1;
const r = Math.random();
s.zHome = r < 0.3 ? -0.8 + Math.random() * 1.4 : r < 0.68 ? -4.2 + Math.random() * 3.0 : 1.2 + Math.random() * 2.6;
const B = this.viewBox(s.zHome);
s.updraft = 0;
if (anywhere)
s.pos.set(B.cx + (Math.random() * 2 - 1) * B.hw * 0.95, (Math.random() * 1.6 - 0.8) * B.hh, s.zHome);
else if (Math.random() < 0.35) {
s.pos.set(B.cx + (Math.random() * 1.1 - 0.05) * B.hw, -B.hh - 0.4 - Math.random() * 0.4, s.zHome);
s.updraft = 0.32 + Math.random() * 0.2;
}
else
s.pos.set(B.cx - B.hw - 0.5 - Math.random() * 1.2, (Math.random() * 1.5 - 0.65) * B.hh, s.zHome);
s.vel.set(CONFIG.wind, s.updraft, 0);
this._v.set((Math.random() - 0.5) * 0.5, 1, (Math.random() - 0.5) * 0.5).normalize();
s.quat.setFromUnitVectors(UP, this._v);
s.ang.set(0, 0, 0);
s.spin = (Math.random() - 0.5) * 0.8;
}
/* ---------- in-focus grass blades + dew ---------- */
buildBlades() {
const all = [
[-2.3, -0.25, 0.35, 0.18, 0.3, 0.05, 0.075], [-1.45, 0.35, -0.95, -0.3, 0.4, -0.5, 0.06], [1.35, -0.6, 1.05, -0.35, 0.45, 0.9, 0.075],
[2.25, 0.25, -0.35, 0.2, 0.5, -0.3, 0.065], [3.4, -0.35, 0.95, -0.6, 0.9, 0.12, 0.075], [-0.75, -0.75, -0.55, -0.2, 0.3, 1.3, 0.055], [0.65, 0.5, -1.15, 0.25, 0.3, 0.6, 0.055],
[-3.1, 0.1, 1.3, 0.5, 0.8, -0.8, 0.08], [2.9, 0.45, -1.2, 0.1, 0.2, 0.2, 0.06],
];
const NB = Math.max(0, Math.min(all.length, Math.round(CONFIG.blades))), NS = this.NS;
this.bladeDefs = all.slice(0, NB).map((d) => ({ x: d[0], z: d[1], tip: d[2], lean: d[3], arc: d[4], tw: d[5], w: d[6] * 1.4, ph: d[0] * 1.7 }));
const nv = NB * (NS + 1) * 2;
const g = this.bladeGeo, idx = [], uv = new Float32Array(nv * 2);
for (let b = 0; b < NB; b++) {
for (let k = 0; k <= NS; k++) {
const v0 = (b * (NS + 1) + k) * 2;
uv[v0 * 2] = k / NS;
uv[v0 * 2 + 1] = 0;
uv[v0 * 2 + 2] = k / NS;
uv[v0 * 2 + 3] = 1;
if (k < NS)
idx.push(v0, v0 + 2, v0 + 1, v0 + 1, v0 + 2, v0 + 3);
}
}
this.bladePos = new Float32Array(nv * 3);
this.bladeNor = new Float32Array(nv * 3);
this.bladeSide = new Float32Array(nv * 3);
g.setAttribute("position", new THREE.BufferAttribute(this.bladePos, 3));
g.setAttribute("normal", new THREE.BufferAttribute(this.bladeNor, 3));
g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
g.setAttribute("aSide", new THREE.BufferAttribute(this.bladeSide, 3));
g.setIndex(idx);
const ids = new Float32Array(nv);
for (let b = 0; b < NB; b++)
ids.fill(b + 1, b * (NS + 1) * 2, (b + 1) * (NS + 1) * 2);
g.setAttribute("aId", new THREE.BufferAttribute(ids, 1));
const m = new THREE.Mesh(g, this.plantMat(CONFIG.grassColor, { rim: 0.7, trans: 0.5, edge: 1.0, kind: 2 }));
m.frustumCulled = false;
m.renderOrder = 1;
this.scene.add(m);
// dew: drops sitting on the blades and the stem
const rnd = mulberry(23), drops = [];
for (let b = 0; b < NB; b++) {
const n = 1 + Math.floor(rnd() * 2.6);
for (let i = 0; i < n; i++)
drops.push({ b, s: 0.3 + rnd() * 0.55, o: (rnd() - 0.5) * 0.9, r: 0.022 + rnd() * 0.03 });
}
for (let i = 0; i < 3; i++)
drops.push({ b: -1, s: 0.55 + i * 0.1 + rnd() * 0.05, o: rnd() - 0.5, r: 0.018 + rnd() * 0.016 });
this.drops = drops;
this.dewPos = new Float32Array(drops.length * 3);
const dg = new THREE.BufferGeometry();
dg.setAttribute("position", new THREE.BufferAttribute(this.dewPos, 3));
dg.setAttribute("aR", new THREE.Float32BufferAttribute(drops.map((d) => d.r), 1));
const dewU = Object.assign({}, this.common, {
uPx: { value: 800 }, uDew: { value: CONFIG.dew }, uSunNdc: { value: new THREE.Vector2() }, uMeadowC: { value: color(CONFIG.meadowColor) },
});
this.dewU = dewU;
const dm = new THREE.ShaderMaterial({ uniforms: dewU, transparent: true, depthWrite: false, vertexShader: S.DEW_VERT, fragmentShader: S.DEW_FRAG });
this.dewMesh = new THREE.Points(dg, dm);
this.dewMesh.frustumCulled = false;
this.dewMesh.renderOrder = 4;
this.scene.add(this.dewMesh);
}
bladePoint(d, s, t, out, tan, side) {
const H = d.tip + 4.4, sw = CONFIG.sway;
const bx = (Math.sin(t * 0.8 + d.ph) * 0.06 + Math.sin(t * 1.9 + d.ph * 2.0) * 0.02) * sw * H * 0.3 + this.gustDir.x * this.gustEnv * this.gustStr * 0.25 + this.bend.x * 0.25;
const bz = Math.cos(t * 0.7 + d.ph) * 0.03 * sw * H * 0.3;
const f = (u) => out.set(d.x + d.lean * H * u + d.arc * u * u * Math.sign(d.lean || 1) * 0.6 + bx * u * u, -4.4 + H * (u - d.arc * 0.12 * u * u * u), d.z + bz * u * u);
if (tan) {
f(Math.min(1, s + 0.01));
tan.copy(out);
f(Math.max(0, s - 0.01));
tan.sub(out).normalize();
}
f(s);
if (side)
side.set(Math.cos(d.tw + s * 0.6), 0, Math.sin(d.tw + s * 0.6));
return out;
}
updateBlades(t) {
const P = this.bladePos, N = this.bladeNor, A = this.bladeSide, NS = this.NS;
const c = new THREE.Vector3(), tn = new THREE.Vector3(), sd = new THREE.Vector3(), n = new THREE.Vector3();
this.bladeDefs.forEach((d, b) => {
for (let k = 0; k <= NS; k++) {
const s = k / NS;
this.bladePoint(d, s, t, c, tn, sd);
n.crossVectors(sd, tn).normalize();
const w = d.w * 1.7 * Math.pow(1 - s, 0.75) + 0.003;
const v0 = (b * (NS + 1) + k) * 2 * 3;
P[v0] = c.x - sd.x * w;
P[v0 + 1] = c.y - sd.y * w;
P[v0 + 2] = c.z - sd.z * w;
P[v0 + 3] = c.x + sd.x * w;
P[v0 + 4] = c.y + sd.y * w;
P[v0 + 5] = c.z + sd.z * w;
N[v0] = N[v0 + 3] = n.x;
N[v0 + 1] = N[v0 + 4] = n.y;
N[v0 + 2] = N[v0 + 5] = n.z;
A[v0] = A[v0 + 3] = sd.x;
A[v0 + 1] = A[v0 + 4] = sd.y;
A[v0 + 2] = A[v0 + 5] = sd.z;
}
});
if (this.bladeDefs.length) {
this.bladeGeo.attributes.position.needsUpdate = true;
this.bladeGeo.attributes.normal.needsUpdate = true;
this.bladeGeo.attributes.aSide.needsUpdate = true;
}
if (!this.dewMesh)
return;
const D = this.dewPos, toCam = new THREE.Vector3(), cam = this.camera.position;
this.drops.forEach((dr, i) => {
if (dr.b < 0) {
// on the stem
const k = Math.round(dr.s * this.NT) * (this.NR + 1) + Math.floor((dr.o + 0.5) * this.NR * 0.5 + this.NR * 0.25);
const SP = this.stemPos;
c.set(SP[k * 3], SP[k * 3 + 1], SP[k * 3 + 2]);
toCam.subVectors(cam, c).normalize();
c.addScaledVector(toCam, 0.09);
}
else {
const d = this.bladeDefs[dr.b];
this.bladePoint(d, dr.s, t, c, tn, sd);
n.crossVectors(sd, tn).normalize();
toCam.subVectors(cam, c);
if (n.dot(toCam) < 0)
n.negate();
c.addScaledVector(sd, dr.o * d.w * 1.7 * Math.pow(1 - dr.s, 0.75)).addScaledVector(n, dr.r * 0.7);
}
D[i * 3] = c.x;
D[i * 3 + 1] = c.y;
D[i * 3 + 2] = c.z;
});
this.dewMesh.geometry.attributes.position.needsUpdate = true;
}
/* ---------- out-of-focus fluff: discs sized by their circle of confusion ---------- */
buildMotes() {
const M = Math.max(0, Math.round(CONFIG.motes)), rnd = mulberry(31), p = [], b = [];
for (let i = 0; i < M; i++) {
p.push(-9 + rnd() * 18, -3.5 + rnd() * 7.5, -14 + rnd() * 18.5);
b.push(0.3 + rnd() * 0.7);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(p.length ? p : [0, 0, 0], 3));
g.setAttribute("aB", new THREE.Float32BufferAttribute(b.length ? b : [0], 1));
const moteU = Object.assign({}, this.common, {
uPx: { value: 800 }, uFocus: { value: CONFIG.camDist }, uDrift: { value: 0 }, uGlint: { value: color(CONFIG.glintColor) }, uBokeh: { value: CONFIG.bokeh },
});
this.moteU = moteU;
const m = new THREE.ShaderMaterial({ uniforms: moteU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: S.MOTE_VERT, fragmentShader: S.MOTE_FRAG });
const motes = new THREE.Points(g, m);
motes.frustumCulled = false;
motes.renderOrder = 5;
motes.visible = M > 0;
this.scene.add(motes);
}
/* ---------- midges: a loose swarm dancing in the backlight right of the clock, just behind the focus ---------- */
buildMidges() {
const M = Math.max(0, Math.round(CONFIG.midges)), rnd = mulberry(53), ph = [], fq = [];
for (let i = 0; i < M; i++) {
ph.push(rnd() * 6.28, rnd() * 6.28, rnd() * 6.28, rnd());
fq.push(0.5 + rnd() * 0.7, 0.8 + rnd() * 1.2, 0.4 + rnd() * 0.6, 3 + rnd() * 4);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(M ? new Array(M * 3).fill(0) : [0, 0, 0], 3));
g.setAttribute("aPh", new THREE.Float32BufferAttribute(M ? ph : [0, 0, 0, 0], 4));
g.setAttribute("aFq", new THREE.Float32BufferAttribute(M ? fq : [1, 1, 1, 1], 4));
const midgeU = Object.assign({}, this.common, this.lens, {
uCtr: { value: new THREE.Vector3(2.9, 0.5, -1.6) }, uGlint: { value: color(CONFIG.glintColor) }, uPush: { value: new THREE.Vector3() },
});
this.midgeU = midgeU;
const m = new THREE.ShaderMaterial({ uniforms: midgeU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: S.MIDGE_VERT, fragmentShader: S.MIDGE_FRAG });
const midges = new THREE.Points(g, m);
midges.frustumCulled = false;
midges.renderOrder = 5;
midges.visible = M > 0;
this.scene.add(midges);
}
/* ---------- the foreground layer (third resolution, premultiplied) ---------- */
buildForeground() {
const q = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: this.fgU, depthWrite: false, depthTest: false, blending: THREE.NoBlending, vertexShader: S.FOREGROUND_VERT, fragmentShader: S.FOREGROUND_FRAG }));
q.frustumCulled = false;
this.fgScene.add(q);
const fg = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
uniforms: { tFg: { value: this.fgRT.texture } }, depthWrite: false, depthTest: false, transparent: true,
blending: THREE.CustomBlending, blendSrc: THREE.OneFactor, blendDst: THREE.OneMinusSrcAlphaFactor, blendSrcAlpha: THREE.ZeroFactor, blendDstAlpha: THREE.OneFactor,
vertexShader: S.FOREGROUND_BLIT_VERT, fragmentShader: S.FOREGROUND_BLIT_FRAG,
}));
fg.frustumCulled = false;
fg.renderOrder = 100;
this.scene.add(fg);
}
// the breath point: the cursor on the plane through the head facing the camera
pointerToWorld(p, out) {
const camera = this.camera;
const v = this._v.set(p.x, p.y, 0.5).unproject(camera).sub(camera.position).normalize();
const fwd = this._v2;
camera.getWorldDirection(fwd);
const t = this._v3.copy(this.head).sub(camera.position).dot(fwd) / Math.max(1e-4, v.dot(fwd));
return out.copy(camera.position).addScaledVector(v, t);
}
detach(s, push) {
s.state = 1;
s.age = 0;
s.pending = -1;
s.open = 1;
s.grow = 1;
const L = S.SEED_MID * s.scale * CONFIG.seedSize;
s.pos.copy(this.head).addScaledVector(s.wdir, S.R0 * 0.9 + L);
s.vel.copy(push);
s.quat.setFromUnitVectors(UP, s.wdir);
s.ang.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar((3 + Math.random() * 5) * CONFIG.tumble);
s.spin = (Math.random() - 0.5) * 1.4;
}
/** A click: blow a gust from where it landed (`p` in NDC). */
click(p) {
const c = this.pointerToWorld(p, new THREE.Vector3());
const v = new THREE.Vector3().subVectors(this.head, c);
v.z = 0;
const dist = v.length();
// you blow from where you clicked: across the clock and a little away from the lens, lifting
this.gustDir.copy(dist > 0.25 ? v.normalize() : new THREE.Vector3(1, 0, 0)).add(new THREE.Vector3(0.3, 0.3, -0.3)).normalize();
this.gustDir.y = Math.max(0.22, Math.min(0.6, this.gustDir.y));
this.gustDir.normalize();
this.gustStr = CONFIG.blowStrength * Math.max(0.3, Math.min(1, 1.5 - dist / CONFIG.blowRadius));
this.gustT = this.time;
this.still = 0;
const now = this.gustT;
for (const s of this.seeds) {
if (!s.hero || s.state !== 0)
continue;
const e = 0.5 + 0.5 * s.wdir.dot(this.gustDir); // the lee side goes first
if (Math.random() < this.gustStr * (0.3 + 0.62 * e))
s.pending = now + 0.04 + (1 - e) * 0.45 + Math.random() * 0.3;
}
}
/** The box's CSS size changed: size the soft layers and the lens. */
resize(width, height) {
this.width = width;
this.height = height;
this.bgU.uRes.value.set(width, height);
const v = this.renderer.getDrawingBufferSize(new THREE.Vector2());
this.bgRT.setSize(Math.ceil(v.x / 3), Math.ceil(v.y / 3));
this.fgRT.setSize(Math.ceil(v.x / 3), Math.ceil(v.y / 3));
const px = v.y / (2 * Math.tan(THREE.MathUtils.degToRad(CONFIG.fov) / 2));
if (this.dewU)
this.dewU.uPx.value = px;
if (this.moteU)
this.moteU.uPx.value = v.y;
this.lens.uVp.value.copy(v);
}
/** Step the simulation by `dt` seconds; `t` is the scene clock (seconds). */
update(dt, t, pointer) {
const camera = this.camera;
this.time = t;
const intro = this.skipIntro ? 1 : Math.max(0, Math.min(1, (t - CONFIG.introDelay) / CONFIG.introDuration));
this.finalU.uFade.value = intro * CONFIG.mainAlpha;
this.finalU.iTime.value = t;
this.bgU.uTime.value = t;
this.common.uTime.value = t;
// the gust envelope: a fast rise, a long tail
const gt = t - this.gustT;
this.gustEnv = gt < 0 ? 0 : (1 - Math.exp(-gt * 9)) * Math.exp(-gt * 0.38);
this.bgU.uGust.value = this.gustEnv * this.gustStr;
// the pointer, smoothed; its speed is the breath
this.pPrev.copy(this.pSm);
this.pSm.lerp(pointer.pos, 1 - Math.exp(-dt * 3.0));
const sp = this.pPrev.distanceTo(this.pSm) / Math.max(1e-3, dt);
this.speed += (sp - this.speed) * (1 - Math.exp(-dt * 6));
if (pointer.active) {
this.breathPrev.copy(this.breathPt);
this.pointerToWorld(this.pSm, this.breathPt);
if (this.breathPrev.x < 1e3 && dt > 0) {
this._v.subVectors(this.breathPt, this.breathPrev).divideScalar(Math.max(1e-3, dt));
this.breathVel.lerp(this._v, 1 - Math.exp(-dt * 8));
}
this.breath = Math.min(1.2, this.speed * 0.9) * CONFIG.breathPower;
}
else {
this.breath = 0;
this.breathVel.multiplyScalar(Math.exp(-dt * 6.3)); // the source's ×0.9 per 60 Hz frame
}
// a still cursor near the clock lets it regrow faster
const nearHead = this.breathPt.distanceTo(this.head) < 2.0;
this.still = pointer.active && this.speed < 0.06 && nearHead ? this.still + dt : 0;
const regrowBoost = 1 + Math.min(3, Math.max(0, this.still - 1.5) * 1.2); // after a moment of stillness, not straight after a blow
// the camera: a slow sway, parallax; the hero sits left of centre, the sun upper right
const a = Math.sin(t * 0.07) * CONFIG.orbit * 2 + this.pSm.x * 0.1 * CONFIG.parallax;
const el = this.pSm.y * 0.06 * CONFIG.parallax;
const D = CONFIG.camDist;
camera.position.set(this.head0.x + Math.sin(a) * D, this.head0.y + CONFIG.camHeight + Math.sin(el) * D, this.head0.z + Math.cos(a) * D);
camera.lookAt(this.head0);
camera.updateMatrixWorld();
const right = this._v.setFromMatrixColumn(camera.matrixWorld, 0), upv = this._v2.setFromMatrixColumn(camera.matrixWorld, 1);
camera.lookAt(this._v3.copy(this.head0).addScaledVector(right, this.aimK() * D).addScaledVector(upv, this.aimUpK() * D));
camera.updateMatrixWorld();
this.bgU.uPar.value.set(a * 1.6, el * 1.6);
// the sun: its screen position unprojected — one light direction for everything
this._v.set(CONFIG.sunX * 2 - 1, CONFIG.sunY * 2 - 1, 0.5).unproject(camera).sub(camera.position).normalize();
this.sunDir.copy(this._v);
this.dewU?.uSunNdc.value.set(CONFIG.sunX * 2 - 1, CONFIG.sunY * 2 - 1);
// the stem: a spring toward the breeze, the breath and the gust
const sw = CONFIG.sway;
const tgt = this._v3.set((Math.sin(t * 0.9) * 0.05 + Math.sin(t * 1.7 + 1.0) * 0.022 + Math.sin(t * 0.31) * 0.03) * sw + CONFIG.wind * 0.12, 0, Math.cos(t * 0.75) * 0.03 * sw);
if (this.breath > 0.001) {
const d = this.breathPt.distanceTo(this.head), f = Math.exp(-(d * d) / (2.2 * 2.2));
const away = this._v.subVectors(this.head, this.breathPt);
away.y *= 0.3;
away.normalize();
tgt.addScaledVector(away, this.breath * f * 0.22);
}
tgt.addScaledVector(this.gustDir, this.gustEnv * this.gustStr * 0.5);
tgt.y = 0;
this.bendV.addScaledVector(this._v.subVectors(tgt, this.bend), 22 * dt).multiplyScalar(Math.exp(-3.2 * dt));
this.bend.addScaledVector(this.bendV, dt);
this.updateStem();
this.updateBlades(t);
this.updateStemHairs(t);
this.updateSeeds(dt, t, regrowBoost);
this.updateLens();
this.drift += dt * (CONFIG.wind + this.gustDir.x * this.gustEnv * this.gustStr * 3.2 * 0.4) * 0.6;
if (this.moteU) {
this.moteU.uDrift.value = this.drift;
this.moteU.uFocus.value = CONFIG.camDist;
}
this.updateMidges(dt, t);
}
updateSeeds(dt, t, regrowBoost) {
const aPos = this.aPos, aQuat = this.aQuat, aInfo = this.aInfo;
if (!aPos || !aQuat || !aInfo)
return;
this.shedAcc += dt * CONFIG.shed;
const wind = CONFIG.wind, tu = CONFIG.turbulence, gs = this.gustEnv * this.gustStr * 3.2, sw = CONFIG.sway;
let attached = 0;
for (let i = 0; i < this.seeds.length; i++) {
const s = this.seeds[i];
if (s.hero && s.state !== 1) {
// on the clock: the breeze sways it, the breath leans it away — and may take it
s.wdir.copy(s.dir).applyQuaternion(this.headQ);
s.wdir.x += (Math.sin(t * 1.4 + s.phase) * 0.025 + Math.sin(t * 3.1 + s.phase * 2.0) * 0.01) * sw * (1 + this.gustEnv * 4);
s.wdir.z += Math.cos(t * 1.1 + s.phase * 1.3) * 0.02 * sw;
let lean = 0;
if (this.breath > 0.001) {
const mid = this._v.copy(this.head).addScaledVector(s.wdir, 0.75);
const d = mid.distanceTo(this.breathPt), R = CONFIG.breathRadius;
lean = this.breath * Math.exp(-(d * d) / (R * R));
if (lean > 0.002)
s.wdir.addScaledVector(mid.sub(this.breathPt).normalize(), lean * 0.45);
}
if (this.gustEnv > 0.01)
s.wdir.addScaledVector(this.gustDir, this.gustEnv * this.gustStr * 0.25 * (0.5 + 0.5 * s.rand));
s.wdir.normalize();
if (s.state === 0) {
if (lean > s.thresh) {
this.detach(s, this._v.copy(s.wdir).multiplyScalar(0.4 + lean * 0.8).addScaledVector(this.breathVel, 0.35).add(this._v2.set(wind * 0.5, 0.15, 0)));
}
else if (s.pending >= 0 && t >= s.pending) {
this.detach(s, this._v.copy(this.gustDir).multiplyScalar((1.2 + Math.random() * 1.6) * this.gustStr).addScaledVector(s.wdir, 0.5)
.add(this._v2.set((Math.random() - 0.5) * 0.6, Math.random() * 0.5, (Math.random() - 0.5) * 0.4)));
}
else if (this.shedAcc >= 1 && s.rand < 0.03) {
this.shedAcc -= 1;
this.detach(s, this._v.copy(s.wdir).multiplyScalar(0.25).add(this._v2.set(wind * 0.4, 0.1, 0)));
}
}
// detach() above may have just released it (TS can't see the mutation, hence the re-read)
const state = s.state;
if (state !== 1) {
if (s.state === 2) {
s.grow += (dt * regrowBoost) / Math.max(0.5, CONFIG.regrow);
if (s.grow >= 1) {
s.grow = 1;
s.state = 0;
}
}
s.open = s.state === 2 ? Math.max(0, Math.min(1, (s.grow - 0.55) / 0.45)) : 1;
s.open = s.open * s.open * (3 - 2 * s.open);
this._q.setFromUnitVectors(UP, s.wdir);
const o = this._v.copy(this.head).addScaledVector(s.wdir, S.R0 * 0.9);
const g = s.state === 2 ? s.grow : 1;
aPos.setXYZ(i, o.x, o.y, o.z);
aQuat.setXYZW(i, this._q.x, this._q.y, this._q.z, this._q.w);
aInfo.setXYZW(i, 1, s.scale * (0.25 + 0.75 * g), s.open, s.rand);
attached += g;
continue;
}
}
// in the air: the wind, the gust and the breath carry it; the pappus drags it upright, so a released seed tumbles, then hangs
s.age += dt;
const P = s.pos;
const nx = (Math.sin(t * 0.9 + P.y * 1.3 + s.phase) * 0.22 + Math.sin(t * 2.1 + P.x * 0.7 + s.phase * 2.0) * 0.1) * tu;
const ny = (Math.sin(t * 1.1 + P.x * 1.1 + s.phase * 1.7) * 0.16 + Math.sin(t * 2.7 + s.phase * 3.1) * 0.07) * tu;
const nz = Math.cos(t * 0.8 + P.x * 0.9 + s.phase) * 0.08 * tu;
let wx = wind + this.gustDir.x * gs + nx;
let wy = this.gustDir.y * gs + ny + CONFIG.lift - 0.09 + s.updraft * Math.exp(-s.age * 0.12);
const wz = this.gustDir.z * gs * 0.6 + nz - (P.z - s.zHome) * 0.05;
if (this.breath > 0.001 || this.breathVel.lengthSq() > 1e-4) {
const d = P.distanceTo(this.breathPt), R = CONFIG.breathRadius * 1.4, f = Math.exp(-(d * d) / (R * R));
wx += this.breathVel.x * f * 0.6;
wy += this.breathVel.y * f * 0.6;
if (this.breath > 0.001) {
const aw = this._v.subVectors(P, this.breathPt).normalize();
wx += aw.x * f * this.breath * 0.8;
wy += aw.y * f * this.breath * 0.8;
}
}
const k = 1 - Math.exp(-dt * 1.8);
s.vel.x += (wx - s.vel.x) * k;
s.vel.y += (wy - s.vel.y) * k;
s.vel.z += (wz - s.vel.z) * k;
P.addScaledVector(s.vel, dt);
// orientation: the righting torque points the pappus into the relative air, damped tumble + a slow spin about its axis
const axis = this._v.set(0, 1, 0).applyQuaternion(s.quat);
const want = this._v2.set(wx - s.vel.x, wy - s.vel.y + 0.25, wz - s.vel.z).normalize();
const tq = this._v3.crossVectors(axis, want).multiplyScalar(7);
s.ang.addScaledVector(tq, dt).multiplyScalar(Math.exp(-dt * 2.2));
const w = this._v2.copy(s.ang).addScaledVector(axis, s.spin), wl = w.length();
if (wl > 1e-5) {
this._q.setFromAxisAngle(w.divideScalar(wl), wl * dt);
s.quat.premultiply(this._q).normalize();
}
const L = S.SEED_MID * s.scale * CONFIG.seedSize;
axis.set(0, 1, 0).applyQuaternion(s.quat);
let al;
if (s.hero) {
al = Math.max(0, Math.min(1, (CONFIG.flightTime - s.age) / 2.5));
if (s.age >= CONFIG.flightTime || P.x > 6 || P.x < -6 || P.y > 4.5 || P.y < -4) {
s.state = 2;
s.grow = 0;
s.open = 0;
s.age = 0;
al = 0;
}
}
else {
al = Math.min(1, s.age / 1.5);
const B = this.viewBox(P.z);
if (P.x > B.cx + B.hw + 1.0 || P.x < B.cx - B.hw - 3.5 || P.y > B.hh + 1.0 || P.y < -B.hh - 1.2 || P.z > CONFIG.camDist - 1.0)
this.spawnAir(s, false);
}
aPos.setXYZ(i, P.x - axis.x * L, P.y - axis.y * L, P.z - axis.z * L);
aQuat.setXYZW(i, s.quat.x, s.quat.y, s.quat.z, s.quat.w);
aInfo.setXYZW(i, al, s.scale, 1, s.rand);
}
aPos.needsUpdate = true;
aQuat.needsUpdate = true;
aInfo.needsUpdate = true;
this.fgU.uHeroFill.value = attached / this.NH;
}
// the lens: focus on the clock; every free seed gets a disc sized by its circle of confusion
updateLens() {
const camera = this.camera, aInfo = this.aInfo;
if (!aInfo || !this.blurPos || !this.blurInfo)
return;
const fwd = camera.getWorldDirection(this._v3);
const focus = this._v.subVectors(this.head, camera.position).dot(fwd);
const H = this.lens.uVp.value.y, P11 = camera.projectionMatrix.elements[5] * 0.5 * H;
this.lens.uFocus.value = focus;
const BP = this.blurPosA, BI = this.blurInfoA;
for (let i = 0; i < this.seeds.length; i++) {
const s = this.seeds[i];
if (s.state !== 1) {
BI[i * 2 + 1] = 0;
continue;
}
const sc = s.scale * CONFIG.seedSize;
const axis = this._v2.set(0, 1, 0).applyQuaternion(s.quat);
const c = this._v.copy(s.pos).addScaledVector(axis, (S.AP - S.SEED_MID + S.HL * 0.3) * sc);
const d = Math.max(0.3, this._v4.subVectors(c, camera.position).dot(fwd));
const coc = Math.min(48, (CONFIG.aperture * Math.max(0, Math.abs(d - focus) - CONFIG.focusDepth)) / d * H);
const dp = (2 * S.HL * 0.95 * sc * P11) / d;
const sz = dp + coc;
BP[i * 3] = c.x;
BP[i * 3 + 1] = c.y;
BP[i * 3 + 2] = c.z;
BI[i * 2] = Math.min(400, sz);
BI[i * 2 + 1] = aInfo.getX(i) * 0.3 * Math.pow(dp / sz, 1.6) * smooth01(3, 16, coc);
}
this.blurPos.needsUpdate = true;
this.blurInfo.needsUpdate = true;
// the hero on screen, for the limb glow
const hp = this._v.copy(this.head).project(camera);
const asp = this.width / this.height;
this.fgU.uHero.value.set(hp.x * 0.5 * asp, hp.y * 0.5);
const ep = this._v2.copy(this.head).addScaledVector(this._v3.setFromMatrixColumn(camera.matrixWorld, 0), 1.08 * CONFIG.seedSize).project(camera);
this.fgU.uHeroR.value = Math.abs(ep.x - hp.x) * 0.5 * asp;
}
// the swarm: a gust or a breath near it scatters it; it springs back to its dance
updateMidges(dt, t) {
if (!this.midgeU)
return;
const C = this.midgeU.uCtr.value, tg = this._v.set(0, 0, 0);
tg.addScaledVector(this.gustDir, this.gustEnv * this.gustStr * 0.9);
const db = this.breathPt.distanceTo(C);
if (this.breath > 0.001)
tg.addScaledVector(this._v2.subVectors(C, this.breathPt).normalize(), this.breath * Math.exp((-db * db) / 1.5) * 0.8);
this.midgePushV.addScaledVector(this._v2.subVectors(tg, this.midgePush), 9 * dt).multiplyScalar(Math.exp(-2.5 * dt));
this.midgePush.addScaledVector(this.midgePushV, dt);
this.midgeU.uPush.value.copy(this.midgePush);
C.set(2.9 + Math.sin(t * 0.09) * 0.35, 0.5 + Math.sin(t * 0.13 + 1.0) * 0.18, -1.6);
}
/** Render the two soft layers into their render targets (before the main pass). */
renderLayers() {
const r = this.renderer;
r.setRenderTarget(this.bgRT);
r.render(this.bgScene, this.bgCam);
r.setRenderTarget(this.fgRT);
r.render(this.fgScene, this.bgCam);
r.setRenderTarget(null);
}
dispose() {
this.bgRT.dispose();
this.fgRT.dispose();
for (const root of [this.scene, this.bgScene, this.fgScene]) {
root.traverse((o) => {
if (o instanceof THREE.Mesh || o instanceof THREE.Points) {
o.geometry.dispose();
const mats = Array.isArray(o.material) ? o.material : [o.material];
mats.forEach((m) => m.dispose());
}
});
}
}
}
/** Elements a pointer press on should not blow the clock (links, buttons, the consent UI). */
const INTERACTIVE = "a, button, input, select, textarea, label, summary, [role='dialog'], [role='button']";
/**
* Build the scene into `canvas` (sized by its parent box). The scene owns its
* render loop and listeners; `dispose()` stops and frees everything except
* the GL context itself (`releaseContext` is the caller's).
*/
const createDandelionScene = (canvas, { skipIntro, onContextChange }) => {
const box = canvas.parentElement ?? canvas;
// No MSAA: every frame is drawn into the composer's (non-multisampled) targets and
// only the final full-screen quad reaches the canvas, so a multisampled canvas
// resolved a buffer per frame and smoothed nothing (optimization-history.md).
const renderer = new THREE.WebGL1Renderer({ canvas, antialias: false });
const gl = renderer.getContext();
const unwatch = watchContext(canvas, onContextChange);
const dprOf = () => Math.min(window.devicePixelRatio || 1, CONFIG.maxDpr);
renderer.setPixelRatio(dprOf());
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(CONFIG.fov, 1, 0.05, 200);
scene.add(camera);
const finalU = {
tDiffuse: { value: null },
uFade: { value: 0 },
iTime: { value: 0 },
uGrain: { value: CONFIG.grain },
uVignette: { value: 1 },
uWarm: { value: CONFIG.warmth },
};
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new ShaderPass(new THREE.ShaderMaterial({ uniforms: finalU, vertexShader: S.FINAL_VERT, fragmentShader: S.FINAL_FRAG })));
const sim = new Dandelion(scene, camera, renderer, finalU, skipIntro);
const pointer = { pos: new THREE.Vector2(0, 0), active: false };
let rect = box.getBoundingClientRect();
const toNdc = (e, out) => out.set(((e.clientX - rect.left) / rect.width) * 2 - 1, -(((e.clientY - rect.top) / rect.height) * 2 - 1));
const onMove = (e) => {
toNdc(e, pointer.pos);
pointer.active = true;
};
const onLeave = () => {
pointer.active = false;
};
const onDown = (e) => {
if (e.target instanceof Element && e.target.closest(INTERACTIVE))
return;
toNdc(e, pointer.pos);
sim.click(pointer.pos);
};
// "Make a Wish": a gust from just left of the clock, across it toward the sun.
const WISH_FROM = new THREE.Vector2(-0.6, -0.05);
const onWish = () => sim.click(WISH_FROM);
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("pointerdown", onDown, { passive: true });
window.addEventListener(DANDELION_WISH_EVENT, onWish);
document.addEventListener("pointerleave", onLeave);
// Desktop panels draw at ~60 fps; phones draw every frame (no cap).
const gate = createDrawGate(window.matchMedia("(hover: hover) and (pointer: fine)").matches);
const clock = createFrameClock();
let frames = 0;
let pending = 0; // seconds simulated since the last draw
let lastT = 0;
const draw = (dt, t) => {
sim.update(Math.min(0.05, dt), t, pointer);
sim.renderLayers();
composer.render();
frames++;
// Every program is compiled once a frame has been drawn: the preloader may lift.
if (frames === 1)
markSceneReady();
};
// Resize only on a real change (CSS size or DPR), then draw in the same frame.
let size = { w: 0, h: 0, dpr: 0 };
const resize = () => {
const w = Math.max(1, Math.round(box.clientWidth)), h = Math.max(1, Math.round(box.clientHeight)), dpr = dprOf();
if (w === size.w && h === size.h && dpr === size.dpr)
return false;
size = { w, h, dpr };
rect = box.getBoundingClientRect();
renderer.setPixelRatio(dpr);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
composer.setPixelRatio(dpr);
composer.setSize(w, h);
sim.resize(w, h);
return true;
};
resize();
const observer = new ResizeObserver(() => {
if (resize()) {
draw(0, lastT);
gate.force();
}
});
observer.observe(box);
const onScrollOrResize = () => {
rect = box.getBoundingClientRect();
};
window.addEventListener("resize", onScrollOrResize, { passive: true });
let raf = 0;
const frame = (now) => {
raf = requestAnimationFrame(frame);
const { dt, t } = clock.tick(now);
lastT = t;
pending += dt;
if (!gate.shouldDraw(dt))
return;
draw(pending, t);
pending = 0;
};
raf = requestAnimationFrame(frame);
return {
canvas,
gl,
frames: () => frames,
dispose: () => {
cancelAnimationFrame(raf);
observer.disconnect();
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerdown", onDown);
window.removeEventListener(DANDELION_WISH_EVENT, onWish);
window.removeEventListener("resize", onScrollOrResize);
document.removeEventListener("pointerleave", onLeave);
unwatch();
sim.dispose();
composer.renderTarget1.dispose();
composer.renderTarget2.dispose();
renderer.dispose();
},
};
};
```
### 4 · Mounting it
```js
const box = document.getElementById("scene");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let handle = null;
function mount() {
const canvas = document.createElement("canvas");
canvas.style.cssText = "display:block;width:100%;height:100%";
canvas.setAttribute("aria-hidden", "true");
box.appendChild(canvas);
try {
handle = createDandelionScene(canvas, { skipIntro: true, onContextChange: (ev) => {
if (ev !== "lost") return;
handle?.dispose(); handle = null;
setTimeout(mount, 0); // rebuild on a fresh canvas; no fade-in
} });
} catch (err) {
canvas.remove(); showStill(); markSceneReady();
}
}
if (reduced) showStill(); else mount();
```
`skipIntro: true` — the preloader covers the build and reveals the scene already at full light.
`showStill()` puts the still `<picture>` (see Assets) in the box: the landscape still from
`(min-width: 1024px)`, the portrait one below, `width:100%; height:100%; object-fit:cover`, with
the scene-still alt text.
## Fixed parameters (bake these in)
Everything tunable is in **Tuning** above (palette, 210 seeds × 44 hairs, breath radius 0.9, blow
radius 4, wind 0.32, flight 14 s, regrow 6 s, 7 blades, 70 motes, 12 midges, 420 stem hairs, sun at
`0.62, 0.7`, aperture 0.12, focus depth 1.1, camera distance 7.4, height 0.25, fov 30, grain 0.022,
max DPR 1.5). Page timings: preloader bloom 2600 ms, spin 30°/s, scene-ready cap 4000 ms, reveal
spring `15/11.5`; entrance springs `120/28` (rules `60/22`, dots `260/16`), hover `320/26`, card
hover `90/24`; Blur In `25/12`, 50 ms stagger, 150 ms delay, 8px blur. Breakpoints 640 and 1024.
## Assets
Runtime files are included in the source project under public/assets/hero:
```
ASSET_BASE_URL = ./assets/hero
```
- `${ASSET_BASE_URL}/hero-grotto-1080.mp4` — the media card loop, desktop (`media="(min-width: 1024px)"`), H.264
- `${ASSET_BASE_URL}/hero-grotto-720.mp4` — the media card loop, tablet and phone
- `${ASSET_BASE_URL}/hero-grotto-poster.webp` — its poster (1080×810, the clip's first frame), under the video
- `${ASSET_BASE_URL}/dandelion-desktop.webp` — the scene still, landscape (1440×900), for reduced motion / no WebGL, `(min-width: 1024px)`
- `${ASSET_BASE_URL}/dandelion-mobile.webp` — the scene still, portrait (824×1830), below 1024px
The icons are the inline SVGs above. The scene itself loads no files.
## MOTDES 源码格式说明
以上完整提示词的输出目标是单个 HTML;本次交付的原版源码是 Next.js 工程,两者格式不同。提示词所用文件在工程的 public/assets/hero/ 内,路径改为实际随包素材。保留原版设计与参数,未补造导航子页面或接通外部业务服务。当前仅完成源码静态检查和交付整理,未验证依赖安装、构建、运行或真机效果。[/hidecontent]