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.htmlstyles.cssscript.js— the Three.js app (<script type="module" src="./script.js">), wrapped in aDOMContentLoadedlistener.shaders.js— a small module that exports four GLSL strings:simulationVertexShader,simulationFragmentShader,renderVertexShader,renderFragmentShader. Imported byscript.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 defaultz-index, below thez-index:2nav/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) andsimScene(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/#fef4b8colors 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.domElementtodocument.body. const mouse = new THREE.Vector2();andlet 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 }.FloatTypeis 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>sizedwidth × height(the DPR-scaled sim size), getctx = 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);thentextTexture.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 onmouse.x > 0).
The render loop (ping-pong — reproduce this order exactly)
const animate = () => { ... requestAnimationFrame(animate); } called once. Each frame:
simMaterial.uniforms.frame.value = frame++;(post-increment — first frame passes0).simMaterial.uniforms.time.value = performance.now() / 1000;- Simulation pass:
simMaterial.uniforms.textureA.value = rtA.texture;→renderer.setRenderTarget(rtB); renderer.render(simScene, camera);(reads A, writes the new state into B). - 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). - Swap:
const temp = rtA; rtA = rtB; rtB = temp;(B becomes the "previous" for next frame).
Simulation vertex & render vertex shaders (identical pass-throughs)
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).
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.
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, recomputefontSize = round(250 * devicePixelRatio), reset the creambold …pxfont + center alignment,fillText("softhorizon", newWidth/2, newHeight/2), thentextTexture.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
mouseleavethe 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 == 0clears both buffers) and only comes alive under pointer motion. - Performance is heavy / not mobile-safe: two full-viewport
FloatTyperender targets at the raw (uncapped) device pixel ratio, plus two render passes per frame.FloatTyperender 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 onLinearSRGBColorSpaceso the WebGL water and the HTML page read as one continuous orange field.
</content> </invoke>