# Morphogenesis — Scroll-Scrubbed WebGL Dissolve Hero

## Goal
Build a very tall hero (`175svh`) where scrolling makes a solid cream fill **sweep upward over a full-bleed portrait**, dissolving the image away behind an **organic, ragged, fbm-noise edge**. The fill is a Three.js fullscreen quad running a custom GLSL shader whose `uProgress` uniform is driven by Lenis scroll position — so it is fully scroll-scrubbed, not time-based. Once the cream has covered the lower part of the hero, a long paragraph sitting over it **fades in one word at a time**, each word's opacity tied directly to a ScrollTrigger's progress. Below, a dark closing section. The star effect is the noisy WebGL dissolve reveal.

## Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
- **`three`** — WebGL scene/renderer/shader material.
- **`gsap`** (3.x) plus the plugins **`ScrollTrigger`** and **`SplitText`**.
- **`lenis`** — smooth scroll; it also owns the scroll value that drives the shader.

```js
import { vertexShader, fragmentShader } from "./shaders.js";
import * as THREE from "three";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger, SplitText);
```

Keep the two GLSL shader strings in a separate `shaders.js` module that exports `vertexShader` and `fragmentShader`.

### Lenis ↔ GSAP wiring
```js
const lenis = new Lenis();
function raf(time) {
  lenis.raf(time);
  ScrollTrigger.update();
  requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
lenis.on("scroll", ScrollTrigger.update);
```

## Layout / HTML
Two sections. The hero stacks four absolutely-positioned layers (image, header text, WebGL canvas, content paragraph); the WebGL canvas sits **above** the image and header in paint order so the dissolve covers them.

```html
<section class="hero">
  <div class="hero-img">
    <img src="/path/to/portrait.jpg" alt="" />
  </div>

  <div class="hero-header">
    <h1>Morphogenesis</h1>
    <p>Solid form gives way to liquid movement.</p>
  </div>

  <canvas class="hero-canvas"></canvas>

  <div class="hero-content">
    <h2>
      An underlying field of motion pushes and pulls the image across its
      surface, redistributing pixels in a way that feels organic and
      constantly in flux.
    </h2>
  </div>
</section>

<section class="about">
  <p>
    This animation is driven by a real-time WebGL displacement process where
    interaction introduces force into the surface, causing form to bend,
    stretch, and reorganize dynamically. Rather than relying on fixed
    keyframes, the visual state evolves continuously, allowing motion to feel
    organic, responsive, and materially present as the page progresses.
  </p>
</section>

<script type="module" src="./script.js"></script>
```

Load-bearing selectors used by JS: `.hero`, `.hero-canvas`, `.hero-content h2`. Keep the demo copy as-is (neutral, no brands): title **"Morphogenesis"**, subtitle **"Solid form gives way to liquid movement."**

## Styling
Reset: `* { margin:0; padding:0; box-sizing:border-box; }`. `img { width:100%; height:100%; object-fit:cover; }`.

Fonts (Google Fonts): **Instrument Serif** (headings) and **Instrument Sans** (body).
- `h1, h2`: `font-family:"Instrument Serif"`, `text-transform:uppercase`, `font-weight:500`, `line-height:0.9`.
- `h1`: `font-size: clamp(4rem, 7.5vw, 10rem);`
- `h2`: `font-size: clamp(2.5rem, 4.5vw, 5rem);`
- `p`: `font-family:"Instrument Sans"`, `font-size:1.125rem`, `font-weight:400`.

Color tokens (exact hex — the dissolve fill color must match `--base-100`):
```css
--base-100: #ebf5df;  /* pale cream-green — the dissolve fill + about text */
--base-200: #84fe1d;  /* electric lime — hero heading + subtitle color */
--base-300: #0f0f0f;  /* near-black — hero-content h2 text + about background */
```

Layout / positioning (all the pieces that make the layering work):
- `.hero`: `position:relative; width:100%; height:175svh; color:var(--base-200); overflow:hidden;`
- `.hero-img`: `position:absolute; width:100%; height:100%;` — the portrait fills the entire 175svh hero.
- `.hero-header`: `position:absolute; width:100%; height:100svh; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:0.5rem; text-align:center;` — title + subtitle centered in the **first viewport**. `.hero-header p { width:75%; }`.
- `.hero-canvas`: `position:absolute; bottom:0; width:100%; height:100%; pointer-events:none;` — transparent WebGL layer over image + header.
- `.hero-content`: `position:absolute; bottom:0; width:100%; height:125svh; display:flex; justify-content:center; align-items:center; text-align:center;` — pinned to the **bottom 125svh** of the hero. `.hero-content h2 { width:75%; color:var(--base-300); }` (dark text that lands on the revealed cream fill).
- `.about`: `position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; background-color:var(--base-300); color:var(--base-100);` with `.about p { width:40%; text-align:center; }`.
- `@media (max-width:1000px)`: `.hero-content h2, .about p { width: calc(100% - 4rem); }`.

## The effect (be exhaustive — WebGL dissolve + word-by-word fade)

There are three coupled systems: **(A)** the Three.js fullscreen-quad setup, **(B)** the fbm-dissolve fragment shader whose `uProgress` is the whole show, and **(C)** the Lenis-scroll → progress mapping. A separate ScrollTrigger drives **(D)** the SplitText word fade.

### CONFIG (exact values)
```js
const CONFIG = {
  color: "#ebf5df", // dissolve fill color (matches --base-100)
  spread: 0.5,      // how far noise perturbs the dissolve edge
  speed: 2,         // scroll→progress multiplier (dissolve completes in first half of scroll)
};
```

### (A) Three.js scene — a single clip-space quad
- `scene = new THREE.Scene()`.
- `camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)` — maps directly to clip space.
- `renderer = new THREE.WebGLRenderer({ canvas: document.querySelector(".hero-canvas"), alpha: true, antialias: false })` — **`alpha:true`** so transparent shader pixels reveal the portrait beneath.
- `geometry = new THREE.PlaneGeometry(2, 2)` — a 2×2 plane that exactly fills the ortho camera's `-1..1` range → a fullscreen quad.
- `material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, transparent: true, uniforms: {...} })`.
- `resize()`: `renderer.setSize(hero.offsetWidth, hero.offsetHeight)` and `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))`. Call once and on `window` `resize`.

Uniforms:
```js
uniforms: {
  uProgress:   { value: 0 },
  uResolution: { value: new THREE.Vector2(hero.offsetWidth, hero.offsetHeight) },
  uColor:      { value: new THREE.Vector3(r, g, b) }, // CONFIG.color parsed to 0..1 rgb
  uSpread:     { value: CONFIG.spread }, // 0.5
}
```
Parse `CONFIG.color` hex → normalized rgb (each channel `/255`) for `uColor`. On `window` `resize` also update `uResolution` to the new `hero.offsetWidth/offsetHeight`.

Render loop (runs continuously):
```js
let scrollProgress = 0;
function animate() {
  material.uniforms.uProgress.value = scrollProgress;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
animate();
```

### (B) Fragment shader — fbm-noise dissolve (reproduce exactly)
Vertex shader just passes `vUv = uv` and `gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0)`.

Fragment shader — the ragged upward-sweeping dissolve. The dissolve edge is a horizontal line at `uv.y = uProgress*1.2`, perturbed by value-noise fbm so it reads as an organic torn edge; below the edge alpha=1 (cream fill), above alpha=0 (transparent → portrait shows). A ~1px `smoothstep` keeps the edge crisp.

```glsl
uniform float uProgress;
uniform vec2  uResolution;
uniform vec3  uColor;
uniform float uSpread;
varying vec2  vUv;

float Hash(vec2 p) {
  vec3 p2 = vec3(p.xy, 1.0);
  return fract(sin(dot(p2, vec3(37.1, 61.7, 12.4))) * 3758.5453123);
}

float noise(in vec2 p) {          // smoothed value noise
  vec2 i = floor(p);
  vec2 f = fract(p);
  f *= f * (3.0 - 2.0 * f);       // smoothstep interpolation
  return mix(
    mix(Hash(i + vec2(0.0, 0.0)), Hash(i + vec2(1.0, 0.0)), f.x),
    mix(Hash(i + vec2(0.0, 1.0)), Hash(i + vec2(1.0, 1.0)), f.x),
    f.y
  );
}

float fbm(vec2 p) {               // 3 octaves, 0.5 / 0.25 / 0.125
  float v = 0.0;
  v += noise(p * 1.0) * 0.5;
  v += noise(p * 2.0) * 0.25;
  v += noise(p * 4.0) * 0.125;
  return v;
}

void main() {
  vec2 uv = vUv;
  float aspect = uResolution.x / uResolution.y;
  vec2 centeredUv = (uv - 0.5) * vec2(aspect, 1.0); // aspect-correct so noise cells stay square

  float dissolveEdge = uv.y - uProgress * 1.2;      // edge rises as progress grows
  float noiseValue   = fbm(centeredUv * 15.0);      // noise frequency ×15 → fine ragged detail
  float d            = dissolveEdge + noiseValue * uSpread;

  float pixelSize = 1.0 / uResolution.y;
  float alpha = 1.0 - smoothstep(-pixelSize, pixelSize, d); // d<0 → filled, d>0 → transparent

  gl_FragColor = vec4(uColor, alpha);
}
```

Feel: at `uProgress=0` the fill sits just below the bottom edge; as progress climbs, the cream sweeps up the full 175svh with a jagged, noisy, ever-shifting frontier, blotting out the portrait.

### (C) Scroll → progress mapping (Lenis, not ScrollTrigger)
`scrollProgress` is computed on every Lenis scroll event, **clamped to 1.1** and multiplied by `CONFIG.speed`, so the dissolve reaches full coverage within roughly the first half of the hero's scrollable range:
```js
lenis.on("scroll", ({ scroll }) => {
  const heroHeight   = hero.offsetHeight;
  const windowHeight = window.innerHeight;
  const maxScroll    = heroHeight - windowHeight;
  scrollProgress = Math.min((scroll / maxScroll) * CONFIG.speed, 1.1);
});
```

### (D) SplitText word-by-word fade (ScrollTrigger, scrubbed manually)
The hero-content paragraph is split into words; each word's opacity is a direct function of a ScrollTrigger's `progress`, so words illuminate sequentially left-to-right as that section scrolls through.

```js
const heroH2 = document.querySelector(".hero-content h2");
const split  = new SplitText(heroH2, { type: "words" });
const words  = split.words;

gsap.set(words, { opacity: 0 });

ScrollTrigger.create({
  trigger: ".hero-content",
  start: "top 25%",
  end: "bottom 100%",
  onUpdate: (self) => {
    const progress = self.progress;
    const total = words.length;
    words.forEach((word, index) => {
      const wordProgress     = index / total;
      const nextWordProgress = (index + 1) / total;
      let opacity = 0;
      if (progress >= nextWordProgress) {
        opacity = 1;
      } else if (progress >= wordProgress) {
        opacity = (progress - wordProgress) / (nextWordProgress - wordProgress);
      }
      gsap.to(word, { opacity, duration: 0.1, overwrite: true });
    });
  },
});
```
Each word owns an equal `1/total` slice of the trigger's 0→1 progress: fully lit once progress passes its slice, linearly fading in while inside its slice, hidden before it. The tiny `duration:0.1` + `overwrite:true` keeps it responsive to scrub while smoothing per-frame jumps. No `scrub` property is set — this ScrollTrigger reveals purely via `onUpdate`.

## Assets / images
- **1 full-bleed portrait**, `object-fit: cover`, filling a very tall (`175svh`) hero — so the source (a roughly 3:4 portrait) is cropped to a narrow vertical slice. The real asset is a warm, golden-hour editorial photo: a young man with curly brown hair in a coral/salmon tank top, leaning against a **sage-green** stucco wall on the left; behind him a sunlit Mediterranean street recedes with potted greenery (a terracotta planter, small trees) and pale cream-and-white buildings under a soft blue sky. Dominant colors are muted sage green, warm coral, terracotta and sun-washed cream. The soft, warm light and greenery read well because the pale cream dissolve progressively erases the image from the bottom up. Any high-quality editorial portrait with a warm, softly-lit subject and airy background works; no brands or logos.

## Behavior notes
- **Trigger:** scroll only. The dissolve is fully scrubbed by Lenis scroll position (no timeline, no autoplay); parking the scroll freezes the dissolve mid-sweep. The word fade is likewise scrubbed via ScrollTrigger `onUpdate`.
- **Fresh load (scroll=0):** portrait fully visible with the lime title + subtitle centered; cream fill just off the bottom; the hero-content paragraph is invisible (all words `opacity:0`).
- **Layering:** the transparent WebGL canvas paints over the image and header; the dark `h2` sits in the bottom 125svh so it lands on the revealed cream. `pointer-events:none` on the canvas keeps it non-interactive.
- **Pixel ratio** capped at 2 for perf; renderer resizes with the hero. `antialias:false` (the smoothstep edge handles anti-aliasing).
- No reduced-motion guard in the original.
