# Water Ripple Text Simulation — build prompt

## Goal
Build a full-screen hero for a fictional product studio called **"Soft Horizon"**. The whole viewport is a WebGL water surface: a giant canvas-drawn wordmark (**"softhorizon"**) sits under a real-time **wave simulation**, and as the pointer moves across the screen the cursor injects pressure into the fluid, leaving **rippling wakes that refract the text and throw off bright specular glints**, like poking the surface of a still pool. Nav and footer text float above the water. The star effect is the physics itself: a double-buffered (ping-pong) GPU wave-equation solver whose gradient field distorts and lights the text every frame. **No GSAP, no scroll** — everything is driven by `mousemove` on the canvas plus a `requestAnimationFrame` loop.

## Tech
Vanilla HTML/CSS/JS with ES module imports. The only runtime dependency is **`three`** (npm), imported as `import * as THREE from "three"`. There is **no GSAP, no Lenis, no scroll library**. Assume a fresh Vite project with `three` installed via npm.

Split the code into four files:
- `index.html`
- `styles.css`
- `script.js` — the Three.js app (`<script type="module" src="./script.js">`), wrapped in a `DOMContentLoaded` listener.
- `shaders.js` — a small module that exports **four** GLSL strings: `simulationVertexShader`, `simulationFragmentShader`, `renderVertexShader`, `renderFragmentShader`. Imported by `script.js`.

## Layout / HTML
Two fixed UI layers over the canvas; the `<canvas>` is created and appended to `<body>` by JS at runtime.

```
nav
  .logo > p            → "Soft Horizon"
  .nav-items
    p ×3               → "Product", "Concept", "Partners"
    button             → "Try now"
footer
  h1                   → "Expanding perspectives with serene and boundless possibilities"
  .footer-links
    p ×2               → "Sign Up", "Log In"
```

## Styling
- Font: a **bold neo-grotesque sans-serif**. The original uses `"Test Söhne"`; any similar grotesque (Söhne / Helvetica Neue / Inter / Arial) is fine as long as a **bold** weight is available for the canvas text. `body { font-family: "Test Söhne", sans-serif; }`.
- Colors — only two, and they are load-bearing (the canvas reuses the exact same hexes so the WebGL surface blends into the page):
  - Background **orange** `#fb7427`.
  - Text / foreground **pale cream-yellow** `#fef4b8`.
- Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`.
- `body`: `width:100%; height:100%; background:#fb7427; color:#fef4b8;`
- `h1`: `font-size:36px; font-weight:400; line-height:1.25;`
- `p`: `font-size:15px; line-height:1.25;`
- `nav`: `position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;`
- `.nav-items`: `display:flex; align-items:center; gap:2em;`
- `button`: `outline:none; border:2px solid #fef4b8; border-radius:2em; color:#fef4b8; background:transparent; font-family:"Test Söhne"; font-size:15px; padding:0.5em 1em;`
- `footer`: `position:fixed; bottom:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:flex-end; z-index:2;`
- `footer h1`: `width:40%;`
- `.footer-links`: `display:flex; gap:2em;`
- `canvas`: `position:fixed; top:0; left:0; width:100vw; height:100vh;` (it sits at the default `z-index`, below the `z-index:2` nav/footer).

## The effect (be exhaustive — this is the whole component)

The technique is a **ping-pong wave simulation**: each frame, a simulation shader reads the previous water state from render target A and writes the new state into render target B; then a render shader samples B to refract and light a text texture onto the screen; then A and B are swapped. The water state is stored in an **RGBA float texture** where the four channels mean: **R = pressure (height), G = pressure velocity, B = ∂pressure/∂x (x gradient), A = ∂pressure/∂y (y gradient)**.

### Renderer / scenes / camera
- **Two scenes:** `scene` (final on-screen pass) and `simScene` (off-screen simulation pass).
- `camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)` — maps a `[-1,1]` quad to the full viewport (shared by both passes).
- `renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true })`.
  - **`renderer.outputColorSpace = THREE.LinearSRGBColorSpace;`** — critical. Disable sRGB output encoding so shader output is written to the framebuffer verbatim and the `#fb7427` / `#fef4b8` colors match the CSS exactly (mirrors the old three r128 pipeline). Without this the orange/cream will look washed out.
  - `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));`
  - `renderer.setSize(window.innerWidth, window.innerHeight);`
  - Append `renderer.domElement` to `document.body`.
- `const mouse = new THREE.Vector2();` and `let frame = 0;`

### Render targets (the double buffer)
- Compute simulation resolution at **full device pixel ratio** (not the capped renderer ratio):
  `const width = window.innerWidth * window.devicePixelRatio;`
  `const height = window.innerHeight * window.devicePixelRatio;`
- Options: `{ format: THREE.RGBAFormat, type: THREE.FloatType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, stencilBuffer: false, depthBuffer: false }`. **`FloatType` is required** — the wave state needs full-precision floats.
- `let rtA = new THREE.WebGLRenderTarget(width, height, options);`
  `let rtB = new THREE.WebGLRenderTarget(width, height, options);` (declared with `let` so they can be swapped).

### Materials & quads
- `simMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader: simulationVertexShader, fragmentShader: simulationFragmentShader })` with uniforms:
  - `textureA: { value: null }` (the previous water state)
  - `mouse: { value: mouse }` (the shared Vector2, in device pixels)
  - `resolution: { value: new THREE.Vector2(width, height) }`
  - `time: { value: 0 }`
  - `frame: { value: 0 }`
- `renderMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader: renderVertexShader, fragmentShader: renderFragmentShader, transparent: true })` with uniforms:
  - `textureA: { value: null }` (new water state)
  - `textureB: { value: null }` (the text texture)
- `const plane = new THREE.PlaneGeometry(2, 2);`
- `simQuad = new THREE.Mesh(plane, simMaterial);` → `simScene.add(simQuad);`
- `renderQuad = new THREE.Mesh(plane, renderMaterial);` → `scene.add(renderQuad);`

### The text texture (canvas-drawn wordmark)
Draw the wordmark once into a 2D canvas and wrap it as a `CanvasTexture` — this is the image the water refracts (there are **no image files**).
- Create a `<canvas>` sized `width × height` (the DPR-scaled sim size), get `ctx = canvas.getContext("2d", { alpha: true })`.
- Fill the whole canvas with the **orange** background first: `ctx.fillStyle = "#fb7427"; ctx.fillRect(0, 0, width, height);`.
- `const fontSize = Math.round(250 * window.devicePixelRatio);`
- `ctx.fillStyle = "#fef4b8";` (cream text)
- `ctx.font = \`bold ${fontSize}px Test Söhne\`;`
- `ctx.textAlign = "center"; ctx.textBaseline = "middle";`
- `ctx.textRendering = "geometricPrecision"; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";`
- `ctx.fillText("softhorizon", width / 2, height / 2);` — one lowercase word, centered, huge.
- `const textTexture = new THREE.CanvasTexture(canvas);` then `textTexture.minFilter = THREE.LinearFilter; textTexture.magFilter = THREE.LinearFilter; textTexture.format = THREE.RGBAFormat;`

### Pointer input (on `renderer.domElement`)
- `mousemove`: convert to **device pixels with a Y flip** (WebGL origin is bottom-left):
  `mouse.x = e.clientX * window.devicePixelRatio;`
  `mouse.y = (window.innerHeight - e.clientY) * window.devicePixelRatio;`
- `mouseleave`: `mouse.set(0, 0);` — parks the pointer at the origin, which disables ripple injection (the sim gates on `mouse.x > 0`).

### The render loop (ping-pong — reproduce this order exactly)
`const animate = () => { ... requestAnimationFrame(animate); }` called once. Each frame:
1. `simMaterial.uniforms.frame.value = frame++;` (post-increment — first frame passes `0`).
2. `simMaterial.uniforms.time.value = performance.now() / 1000;`
3. **Simulation pass:** `simMaterial.uniforms.textureA.value = rtA.texture;` → `renderer.setRenderTarget(rtB); renderer.render(simScene, camera);` (reads A, writes the new state into B).
4. **Screen pass:** `renderMaterial.uniforms.textureA.value = rtB.texture; renderMaterial.uniforms.textureB.value = textTexture;` → `renderer.setRenderTarget(null); renderer.render(scene, camera);` (samples the fresh state B + the text, draws to the canvas).
5. **Swap:** `const temp = rtA; rtA = rtB; rtB = temp;` (B becomes the "previous" for next frame).

### Simulation vertex & render vertex shaders (identical pass-throughs)
```glsl
varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```

### Simulation fragment shader (the wave equation — reproduce the math exactly)
Uniforms: `sampler2D textureA`, `vec2 mouse`, `vec2 resolution`, `float time`, `int frame`; varying `vec2 vUv`. Constant: `const float delta = 1.4;` (the simulation timestep).

```glsl
vec2 uv = vUv;
if (frame == 0) { gl_FragColor = vec4(0.0); return; }   // clear the buffer on the very first frame

vec4 data = texture2D(textureA, uv);
float pressure = data.x;
float pVel     = data.y;

vec2 texelSize = 1.0 / resolution;
float p_right = texture2D(textureA, uv + vec2( texelSize.x, 0.0)).x;
float p_left  = texture2D(textureA, uv + vec2(-texelSize.x, 0.0)).x;
float p_up    = texture2D(textureA, uv + vec2(0.0,  texelSize.y)).x;
float p_down  = texture2D(textureA, uv + vec2(0.0, -texelSize.y)).x;

// Neumann (reflective) boundaries: mirror the neighbor at each edge
if (uv.x <= texelSize.x)        p_left  = p_right;
if (uv.x >= 1.0 - texelSize.x)  p_right = p_left;
if (uv.y <= texelSize.y)        p_down  = p_up;
if (uv.y >= 1.0 - texelSize.y)  p_up    = p_down;

// Laplacian → acceleration on the pressure-velocity (split into x and y halves)
pVel += delta * (-2.0 * pressure + p_right + p_left) / 4.0;
pVel += delta * (-2.0 * pressure + p_up   + p_down) / 4.0;

// integrate height
pressure += delta * pVel;

// restoring force toward zero (spring) + velocity damping + pressure damping
pVel     -= 0.005 * delta * pressure;
pVel     *= 1.0 - 0.002 * delta;
pressure *= 0.999;

// pointer injection: a small circular pressure bump under the cursor
vec2 mouseUV = mouse / resolution;
if (mouse.x > 0.0) {
    float dist = distance(uv, mouseUV);
    if (dist <= 0.02) {
        pressure += 2.0 * (1.0 - dist / 0.02);   // radius 0.02, peak +2.0 at the exact pointer
    }
}

// pack: R=pressure, G=velocity, B=x-gradient, A=y-gradient
gl_FragColor = vec4(pressure, pVel, (p_right - p_left) / 2.0, (p_up - p_down) / 2.0);
```
Notes on the constants (they define the feel): `delta = 1.4` timestep; wave speed from the averaged 5-point Laplacian; `-0.005*delta*pressure` pulls the surface back to flat; `*= 1.0 - 0.002*delta` bleeds off velocity; `*= 0.999` slowly damps height so ripples fade to stillness; injection radius `0.02` (in UV space) with linear falloff to peak `+2.0` — a **small, sharp, intense** poke that spreads outward as a ring.

### Render fragment shader (refraction + specular — reproduce exactly)
Uniforms: `sampler2D textureA` (water state), `sampler2D textureB` (text texture); varying `vec2 vUv`.

```glsl
vec4 data = texture2D(textureA, vUv);

// refraction: offset the text-sample UV by the water's gradient (B,A channels)
vec2 distortion = 0.3 * data.zw;
vec4 color = texture2D(textureB, vUv + distortion);

// build a surface normal from the gradient and light it for a specular glint
vec3 normal   = normalize(vec3(-data.z * 2.0, 0.5, -data.w * 2.0));
vec3 lightDir = normalize(vec3(-3.0, 10.0, 3.0));
float specular = pow(max(0.0, dot(normal, lightDir)), 60.0) * 1.5;

gl_FragColor = color + vec4(specular);   // refracted text + additive white highlight
```
So the visible ripples come from two things at once: the **`0.3 * gradient` UV displacement** that warps the wordmark, and the **tight `pow(...,60.0) * 1.5` specular** that paints bright white crests along the wave slopes.

### Resize (`window` `resize`)
Recompute `newWidth = innerWidth * devicePixelRatio`, `newHeight = innerHeight * devicePixelRatio`, then:
- `renderer.setSize(innerWidth, innerHeight);`
- `rtA.setSize(newWidth, newHeight); rtB.setSize(newWidth, newHeight);`
- `simMaterial.uniforms.resolution.value.set(newWidth, newHeight);`
- **Redraw the text canvas at the new size**: resize the canvas, re-fill `#fb7427`, recompute `fontSize = round(250 * devicePixelRatio)`, reset the cream `bold …px` font + center alignment, `fillText("softhorizon", newWidth/2, newHeight/2)`, then `textTexture.needsUpdate = true;`

## Assets / images
**None** — there are zero image files. The only "texture" is the wordmark rendered into a 2D canvas at runtime (see *The text texture*). Do not add any `<img>` or external asset.

## Behavior notes
- **Desktop, pointer-driven.** No scroll, click, or keyboard interaction. On `mouseleave` the cursor parks at `(0,0)` so injection stops and the existing ripples simply damp out to a flat surface.
- The simulation runs **forever** at rAF cadence; the surface starts flat (`frame == 0` clears both buffers) and only comes alive under pointer motion.
- **Performance is heavy / not mobile-safe:** two full-viewport `FloatType` render targets at the raw (uncapped) device pixel ratio, plus two render passes per frame. `FloatType` render targets require float-texture support (`OES_texture_float` / WebGL2). There is no reduced-motion branch in the original.
- Keep the two hexes (`#fb7427`, `#fef4b8`) identical between CSS, the canvas fill/text, and rely on `LinearSRGBColorSpace` so the WebGL water and the HTML page read as one continuous orange field.
</content>
</invoke>
