This is the fifth animated background on this site. The previous four are 2D noise clouds, 3D polygon clouds, a scroll-driven parallax mountain valley, and boids flocking. Each loads randomly with 20% probability. This one renders a wireframe ocean surface from a helicopter angle.
See it full page. Source on GitHub.
The grid
The surface is a PlaneGeometry with 120x120 segments, rotated to lie flat. That gives 14,641 vertices to displace every frame. Before the animation loop starts, I store each vertex’s base X and Z coordinates in typed arrays so the wave function only needs to write the Y value.
const GRID_SIZE = 200;
const SEGMENTS = 120;
const waveGeo = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE, SEGMENTS, SEGMENTS);
waveGeo.rotateX(-Math.PI / 2);
const positions = waveGeo.attributes.position.array;
const baseX = new Float32Array(vertCount);
const baseZ = new Float32Array(vertCount);
for (let i = 0; i < vertCount; i++) {
baseX[i] = positions[i * 3];
baseZ[i] = positions[i * 3 + 2];
}
Setting wireframe: true on the material gives the mesh look without needing a custom shader. Three.js draws the triangle edges as lines.
Wave displacement
The height function layers several sine waves at different frequencies and directions, plus two octaves of simplex FBM noise for organic variation.
function waveHeight(x, z, time) {
let h = 0;
// Primary swell
h += Math.sin(x * 0.06 + z * 0.04 + time * 0.8) * 2.5;
h += Math.sin(x * 0.03 - z * 0.07 + time * 0.6) * 1.8;
// Cross waves
h += Math.sin(x * 0.12 + z * 0.09 - time * 1.1) * 0.8;
h += Math.sin(-x * 0.08 + z * 0.15 + time * 0.9) * 0.6;
// Noise detail
h += fbm((x + time * 3) * 0.02, (z + time * 2) * 0.02, 4) * 3.0;
h += fbm((x - time * 1.5) * 0.05, (z + time * 2.5) * 0.05, 3) * 1.2;
return h;
}
The sine waves create broad, readable swells. The FBM adds the choppier detail that makes it look like water rather than a rubber sheet. Moving the noise coordinates with time at different speeds and directions prevents any visible tiling or repetition.
Height-based colour
Each vertex gets a colour based on its current wave height. The height is normalised to a 0-1 range and mapped across four colour bands: deep troughs, mid-level, crests, and foam highlights.
const t = (h + 6) / 16;
if (t < 0.3) {
// deep -> mid
const f = t / 0.3;
r = colors.deep.r + (colors.mid.r - colors.deep.r) * f;
// ...
} else if (t < 0.6) {
// mid -> crest
} else if (t < 0.85) {
// crest -> foam
} else {
// foam caps
}
The colours come from the same palette system used across all five backgrounds. Each page route has its own col1/col2/col3 tuple that gets mapped to the four bands. In dark mode the troughs are near-black and the crests are muted purple. In light mode everything shifts to pale pastels.
Scroll-driven camera
The camera starts at a helicopter angle and tilts toward overhead as you scroll down. Five waypoints define the path, interpolated with smoothstep and an exponential follow for lag.
const cameraStops = [
{ pos: new THREE.Vector3(0, 18, 45), look: new THREE.Vector3(0, -2, 0) },
{ pos: new THREE.Vector3(0, 25, 30), look: new THREE.Vector3(0, -1, 0) },
{ pos: new THREE.Vector3(0, 40, 15), look: new THREE.Vector3(0, -1, 0) },
{ pos: new THREE.Vector3(0, 55, 8), look: new THREE.Vector3(0, 0, 0) },
{ pos: new THREE.Vector3(0, 70, 3), look: new THREE.Vector3(0, 0, 0) },
];
One thing I had to solve: when the camera gets close to directly overhead, Three.js lookAt suffers from gimbal lock. The camera’s up vector becomes ambiguous and the view spins. Keeping the final z at 3 instead of 0 avoids this.
Performance
14,641 vertex position writes, 14,641 colour writes, and a computeVertexNormals() call every frame. On an M1 MacBook this holds 60fps comfortably. The wireframe material helps since there’s no face rasterisation, just line segments.
The computeVertexNormals() is the most expensive part. The normals only affect lighting on the wireframe lines, which is subtle. For a production version you could skip it and use an unlit material to save a chunk of CPU time.