Three.js Experiments

Scroll-Driven 3D Parallax With Three.js

The idea was a 3D scene you move through by scrolling. Not a background parallax effect with layered 2D images. An actual Three.js scene where the camera descends from above a mountain range into a valley, driven entirely by scroll position.

The result is here. Two versions: v1 is terrain and fog, v2 adds trees, water, a sky shader, shadows, and post-processing.

v1: above the valley
v1: terrain + exponential fog
v2: above the valley
v2: sky shader, trees, shadows, post-processing

Procedural terrain

The terrain is a PlaneGeometry rotated flat, with vertex heights set by fractal Brownian motion. v1 used a hash-based noise function:

function noise2d(x, z) {
  const s = Math.sin(x * 0.67 + z * 1.37) * 43758.5453;
  return s - Math.floor(s);
}

It works, but the output is lumpy up close. v2 replaced this with simplex noise, which produces smoother ridgelines and more natural-looking detail at all scales.

The height function layers several noise octaves at different frequencies:

function getHeight(x, z) {
  const mountains = fbm(x * 0.003, z * 0.003, 7) * 200;
  const valleyDist = Math.abs(x) / 80;
  const valleyFactor = Math.min(1, valleyDist * valleyDist);
  const ridges = fbm(x * 0.012, z * 0.012, 4) * 30;
  const detail = fbm(x * 0.05, z * 0.05, 3) * 8;
  return (mountains * valleyFactor + ridges + detail) - 40;
}

The valley is carved by valleyFactor, which squashes height to zero along the x=0 axis and ramps back up quadratically. Mountains, ridges, and fine detail are separate octave groups rather than one big fbm call, which makes it easier to control the shape.

Colouring by height and slope

v1 coloured vertices purely by height. Green valley, grey rock, white peaks. It looked flat because steep cliff faces and gentle slopes got the same treatment.

v2 samples the height at neighbouring points to estimate slope:

const dx = getHeight(x + 2, z) - getHeight(x - 2, z);
const dz = getHeight(x, z + 2) - getHeight(x, z - 2);
const slope = Math.sqrt(dx*dx + dz*dz) / 4;

Steep faces get rock colours regardless of altitude. Snow only accumulates on gentle slopes. The slope check is cheap (four extra getHeight calls per vertex at build time) and makes a visible difference.

The parallax scroll

The camera path is five positions with lookAt targets. Scroll progress (0 to 1) maps to a position along these stops with smoothstep interpolation between them:

function getTargets(t) {
  const seg = t * (cameraStops.length - 1);
  const i = Math.min(Math.floor(seg), cameraStops.length - 2);
  const f = seg - i;
  const sf = f * f * (3 - 2 * f);
  targetPos.lerpVectors(stops[i].pos, stops[i+1].pos, sf);
  targetLook.lerpVectors(stops[i].look, stops[i+1].look, sf);
}

The smoothstep (f*f*(3-2*f)) prevents jerky transitions at each waypoint. On top of that, the actual camera position lerps toward the target each frame with exponential smoothing, which adds a slight lag that feels cinematic rather than mechanical.

A subtle breathing motion (Math.sin(time * 0.3) * 1.2 on x, Math.sin(time * 0.2) * 0.6 on y) keeps the scene alive when you stop scrolling.

The path itself took a few attempts. The first version had seven waypoints scattered across the landscape and read as erratic in motion. Reducing to five waypoints on roughly the same forward axis, descending steadily, gave it one continuous movement.

What v2 added

Sky shader. Instead of a flat fog colour for the background, a sphere with a fragment shader that blends from warm horizon to blue zenith, with a sun glow computed from dot(viewDirection, sunDirection). The sphere follows the camera so the sky is always surrounding you.

Shadows. Enabling renderer.shadowMap with PCFSoftShadowMap and a 2048px shadow map from the directional light. The shadow camera frustum needs to be large enough to cover the visible terrain, which meant setting it to 400 units in each direction.

Trees. 3000 instanced cones placed on slopes below the treeline, above the water level, and only where the slope is gentle enough. Each instance gets a random scale, rotation, and colour variation. InstancedMesh handles this without any performance issues.

Water. A single plane at the valley floor with low roughness and moderate metalness. It catches the sun reflection and gives the valley depth. A slow sine wave on its y position fakes a gentle ripple.

Post-processing. An EffectComposer with bloom (subtle, strength 0.25) to catch bright snow and water reflections, plus a vignette shader that also warms the colour grade slightly (red +2%, blue -4%).

v1: in the valley
v1: valley floor
v2: in the valley
v2: valley floor with trees and water

Fog density by altitude

The fog thickens as the camera descends. FogExp2 density is a function of camera height:

const heightRatio = Math.max(0, Math.min(1, camera.position.y / 220));
scene.fog.density = 0.002 + (1 - heightRatio) * 0.005;

At the top, the fog is thin and you can see the full range. At the valley floor, visibility drops and the distant mountains dissolve. The original version had fog particles (600 point sprites) but they rendered as hard white squares when the camera got close. Removing them and relying on the exponential fog looked better.

Text anchoring

Three text labels appear during the scroll: “Above the clouds”, “Through the mist”, “Into the valley”. The first and last are anchored to the start and end of the scroll rather than to a camera position. The middle one has a wider visibility window.

if (at === 0) {
  show = seg < 0.6;
} else if (at === maxSeg) {
  show = scrollProgress > 0.75;
} else {
  show = Math.abs(seg - at) < 0.5;
}

The first label stays visible until you’ve scrolled a fair way. The last label appears once you’re 75% through and stays on. This is simpler than trying to pin text to world positions, which would require projecting 3D coordinates to screen space and dealing with the text drifting around.

Deployment

The whole thing is two HTML files and a three-line Dockerfile:

FROM nginx:alpine
COPY . /usr/share/nginx/html/
EXPOSE 80

Deployed to a Hetzner VPS via Dokploy with a Cloudflare DNS record pointing 3d-parallax.danieljohnmorris.com at the server. Push to main triggers a redeploy.