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);
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.
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;
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);
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);
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.
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)));
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);
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.
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);
}
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)