DEV Community

Cover image for Sea Train: Writing a Quiet Ocean in Three.js
Wormhole404
Wormhole404

Posted on

Sea Train: Writing a Quiet Ocean in Three.js

Sea Train with Three.js

Sea Train began as one sentence: a metre of still water over the rails and a four-car train sliding across it; the windows are the only warm thing left, and the sky and water have stopped being two things. The complete scene source is in Makone.

That sentence breaks down into a few relationships. A long lens compresses the train into a dark line. The water reflects the same sky. The wires sag under gravity. Time moves the sun and train together. None of the mathematics is especially complicated, but every part has to agree with the others.

Choose one photograph before building the world

The lens comes first; only then does the required extent of the scene become clear. The camera has a narrow 15.5° field of view, roughly an 85 mm lens on full frame. It sits 22 metres to the side of the track and looks obliquely along it. The long lens presses a 71.8-metre consist into a dark horizontal strip strong enough to hold its own against the horizon.

const FOV = 15.5;
const CAM_POS = new THREE.Vector3(-52, 0.62, 22);
const CAM_TGT = new THREE.Vector3(115.5, 8.64, -6.93);

const camera = new THREE.PerspectiveCamera(
  FOV, width / height, 0.8, 9000
);
camera.position.copy(CAM_POS);
camera.lookAt(CAM_TGT);
Enter fullscreen mode Exit fullscreen mode

A wide lens did not work here. It made the near end of the train feel like a toy and shrank the last car to a pin. The track perspective also became louder than the still water. The narrow view is less “immersive,” but that flatness is exactly what the composition needs.

A long lens compresses the water, train, and wires into horizontal bands while the poles recede toward the horizon.

The camera never gets close to the train. The empty water is the subject; the train is the dark line that holds it down.

Draw the sky, then let the water photograph it

There is no physical atmosphere. A one-pixel ramp texture holds ten colour stops; the shader samples it by viewing elevation and lightly posterises the result. This lens only sees about ten degrees above the horizon, so most of the colour change lives there too.

const SKY_STOPS = [
  [0.00, '#ffdca6'], [0.11, '#fbb994'],
  [0.25, '#e199ac'], [0.40, '#97a8cb'],
  [0.68, '#4f8fb5'], [1.00, '#3c6b98'],
];

// fragment shader, condensed
float elevation = degrees(asin(clamp(dir.y, -1.0, 1.0)));
float p = elevation <= 14.0
  ? max(elevation, 0.0) / 14.0 * 0.72
  : 0.72 + 0.28 * smoothstep(14.0, 90.0, elevation);

float bands = 26.0;
p = mix(p, (floor(p * bands) + 0.5) / bands, 0.62);
vec3 sky = texture2D(uRamp, vec2(p, 0.5)).rgb;
Enter fullscreen mode Exit fullscreen mode

The water uses Reflector, not a second hand-matched gradient. It renders the same sky from a virtual camera below the surface. Since both sides of the horizon come from one image, there is no colour seam to hide.

const waterGeo = new THREE.PlaneGeometry(5200, 5200);
const water = new Reflector(waterGeo, {
  textureWidth: 2048,
  textureHeight: 1152,
  clipBias: 0.0006,
  shader: WaterShader,
});

water.rotation.x = -Math.PI / 2; // rotate the object, not the geometry
water.position.y = 0;
scene.add(water);
Enter fullscreen mode Exit fullscreen mode

That comment matters. Reflector derives its mirror normal from the object's matrixWorld. If you rotate the geometry first, the plane appears horizontal while the reflection camera still solves a vertical mirror. The sky will show up anyway, which makes the mistake easy to miss. Objects standing on the water will have no reflection.

Move the water less than you think

The first waves were too strong and turned the reflection into marble. The screen-space sample offset settled at 0.11, while the slope fades to zero in the distance. The reflected image is also mixed slightly toward the horizon colour, so the lower half does not contain a second train as black and crisp as the subject.

vec2 uv = clamp(vUv.xy / vUv.w + waveSlope * 0.11,
                0.0015, 0.9985);
vec3 reflection = texture2D(tDiffuse, uv).rgb;
reflection = mix(reflection, uHorizon, 0.17);

vec3 color = mix(uDeep, reflection, fresnel);
color = mix(color, uHorizon,
            smoothstep(150.0, 1300.0, distance) * 0.40);
Enter fullscreen mode Exit fullscreen mode

The gold road on the water is a separate, narrow wedge of specular light. It sits to the right of the train because the train's own reflection blocked it on the left. That was not a palette problem. It was reflection geometry.

Looking along the track aligns the train, sun, and reflection on the same perspective.

This is not the cover angle, but it catches floating rails, a sun road passing through the train, and wires that miss their poles.

Let the wires sag

The thinnest objects can make the whole scene feel fake. A straight segment from one pole top to the next turns the world into a plastic model. A catenary provides the 30-metre span and 0.8 metres of sag; a few Newton iterations find the parameter a.

const SPAN = 30;
const SAG = 0.8;

let a = (SPAN * SPAN) / (8 * SAG);
for (let i = 0; i < 6; i++) {
  const u = SPAN / (2 * a);
  const f = a * (Math.cosh(u) - 1) - SAG;
  const df = Math.cosh(u) - 1 - u * Math.sinh(u);
  a -= f / df;
}

const catenary = (u) =>
  a * (Math.cosh((u - 0.5) * SPAN / a) - Math.cosh(SPAN / (2 * a)));
Enter fullscreen mode Exit fullscreen mode

A real 12 mm wire is far below one pixel at a hundred metres, so LineSegments2 draws these at about 1.4 pixels. The width is a visual concession; the sag is not. One keeps the line visible, while the other gives it weight.

Build the train as a shell

The body is deliberately near-black teal. Every warm colour is reserved for windows, lamps, and the sun path on the water. A carriage cannot be one solid block with holes cut through it, either. Seen at a shallow angle, a 2.7-metre-deep opening becomes a tunnel and hides the entire interior.

The final car uses two 70 mm side skins, then a roof, floor, and end walls. Warm interior walls and ceiling lights sit behind those skins.

const SKIN = 0.07;
const insideHalfWidth = CAR_W / 2 - SKIN;

const windowLight = new THREE.MeshBasicMaterial({ color: 0xffcf87 });
const trainBody = new THREE.MeshStandardMaterial({
  color: 0x2a4c4c, roughness: 0.76, metalness: 0.04,
});

const skin = new THREE.ExtrudeGeometry(sideOutlineWithWindows(), {
  depth: SKIN,
  bevelEnabled: false,
  steps: 1,
});

const nearSide = new THREE.Mesh(skin.clone(), trainBody);
nearSide.position.z = insideHalfWidth;
const farSide = new THREE.Mesh(skin.clone(), trainBody);
farSide.position.z = -CAR_W / 2;
body.add(nearSide, farSide);
Enter fullscreen mode Exit fullscreen mode

Time gets one input as well. apply(t) sets the sun elevation, train position, and wake together. seekTo(0.5) returns the same frame every time, which matters far more when comparing the composition than vaguely continuous motion.

At the midpoint of the timeline, the windows remain the only artificial warm light in the frame.

function apply(t) {
  const u = THREE.MathUtils.clamp(t / duration, 0, 1);
  setSunElevation(THREE.MathUtils.lerp(3.4, 2.1, u));
  train.position.x = centerAt(u);
  water.material.uniforms.uWake.value.set(train.position.x - consist / 2, speedAt(u));
}

function seekTo(progress) {
  time = THREE.MathUtils.clamp(progress, 0, 1) * duration;
  apply(time);
}
Enter fullscreen mode Exit fullscreen mode

In the finished scene, only a few relationships are doing the real work: the long lens flattens space, the water reflects the same sky, gravity bends the wires, and the windows shine through a thin carriage skin. Each can be written as mathematics. Three.js lets all of them hold true in the same frame.

Originally published at https://www.wormhole404.com

Source code: https://github.com/wormholeportal/Makone

Top comments (0)