The Challenge: A Globe That Moves
The tectonic plates simulation had a clear visual target: a globe of Earth with visible plate boundaries that slowly drift, collide and subduct in real time. Simple to describe. Surprisingly tricky to implement.
The main difficulty isn't the plate mechanics — it's the geometry of a sphere. Everything that's easy on a flat plane becomes moderately awkward on a sphere: distances, interpolation, UV mapping, normal computation after displacement.
Building the Sphere Correctly
Three.js ships SphereGeometry which is fine, but it has a
pole singularity problem: the UV coordinates pinch at the north and
south poles, causing texture seams and distortion. For a globe, that's
visible as ugly artefacts around Antarctica and the Arctic.
The standard fix is to use a cube-sphere instead: start with a cube whose faces are subdivided, then project all vertices onto the unit sphere. This distributes vertices much more uniformly.
// Project cube vertex onto sphere
function cubeToSphere(x, y, z) {
const x2 = x * x, y2 = y * y, z2 = z * z;
return {
x: x * Math.sqrt(1 - y2/2 - z2/2 + y2*z2/3),
y: y * Math.sqrt(1 - z2/2 - x2/2 + z2*x2/3),
z: z * Math.sqrt(1 - x2/2 - y2/2 + x2*y2/3)
};
}
Displacement Maps for Topography
The terrain height is driven by a displacement map — a greyscale texture where white = high elevation, black = ocean floor. In the vertex shader, each vertex is pushed outward along its normal by an amount proportional to the texture value:
// Vertex shader snippet
float height = texture2D(displacementMap, vUv).r;
vec3 displaced = position + normal * height * displacementScale;
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
After displacement, normals need to be recomputed — displaced normals
point in the wrong direction and produce incorrect lighting. Three.js
can compute tangent-space normals from a normalMap, but
for procedural displacement I did it analytically in the shader using
partial derivatives.
Plate Boundaries and Drift
Each of Earth's ~15 major plates is assigned an angular velocity vector (the real geological values, scaled down by 10⁸ for useful visual speed). The plate membership of each texel is stored in a separate mask texture.
At each frame, I rotate each plate's texture region by its angular velocity using a rotation matrix in the fragment shader. Plate boundaries are highlighted by computing the gradient of the plate-ID texture — a sharp value gradient means a boundary.
The Trigonometry Trap
Spherical geometry has a reputation for subtlety, and it deserves it. I spent most of one afternoon debugging why my distance calculations were slightly wrong. The issue: I was computing Euclidean distances between longitude/latitude pairs instead of great-circle distances.
On a sphere, the shortest path between two points is a great circle arc. The haversine formula computes this correctly:
function haversineDistance(lat1, lon1, lat2, lon2, R = 6371) {
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1 * Math.PI/180) *
Math.cos(lat2 * Math.PI/180) *
Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
Try the simulation at /tectonic-plates/. You can toggle between realistic and accelerated drift speed.