Brad Woods Digital Garden

Notes / JavaScript / three.js / Animate a mesh on a sphere's surface

The Warhammer 40k Adeptus Mechanicus symbol

Table of contents

Sphere with a mesh travelling on its surface

Animate a mesh across a sphere's surface

Planted: 

Status: seed

Hits: 640

Intended Audience: Creative coders, Front-end developers

Tech: three.js, GSAP

How to animate a mesh across the surface of a sphere using three.js and GSAP.

Point on a sphere

We first need to define two positions on the surface to animate between. A convenient way to do this is by using longitude and latitude as a coordinate system. These values act as an intuitive UI (user interface) for selecting any point on the sphere. We can then convert them to 3D coordinates using latLongToVector3.

/index.js

function latLongToVector3({
latitude,
longitude,
center = new THREE.Vector3(...config.meshes.sphere.position),
radius = config.meshes.sphere.radius
}) {
const { sin, cos, PI } = Math;
const phi = (90 - latitude) * (PI / 180);
const theta = (longitude + 180) * (PI / 180);
const x = -radius * sin(phi) * cos(theta);
const y = radius * cos(phi);
const z = radius * sin(phi) * sin(theta);
return new THREE.Vector3(x, y, z).add(center);
}
function moveMarker({ marker, latitude, longitude }) {
const pos = latLongToVector3({ latitude, longitude });
marker.position.copy(pos);
}

Path

Next we need to create a path between the two positions. This is done by calculating a series of points between them, using calcPathPoints. I'm rendering a line (using createPath) only for visualizing purposes — the animation will only require the points.

/index.js

// Generate a series of points along the shortest path, between two positions, on a sphere's surface (great circle arc).
function calcPathPoints({
start,
end,
center = new THREE.Vector3(...config.meshes.sphere.position),
radius = config.meshes.sphere.radius,
segments = 64
}) {
const points = [];
// Moves the start and end points as if the sphere's center is at (0, 0, 0).
// Making it easier to work with rotations.
const startLocal = start.clone().sub(center);
const endLocal = end.clone().sub(center);
// Unit vectors pointing from the sphere center to the positions on the surface.
const startNorm = startLocal.normalize();
const endNorm = endLocal.normalize();
// Calculate the rotation to move from start to end.
const quaternion = new THREE.Quaternion().setFromUnitVectors(
startNorm,
endNorm
);
// Loop runs from t=0 (start point) to t=1 (end point)
for (let i = 0; i <= segments; i++) {
const t = i / segments;
// Using spherical linear interpolation (slerp) to compute a quaternion that's t percent of the way from the start to the end rotation.
const stepQuat = new THREE.Quaternion().slerpQuaternions(
new THREE.Quaternion(), // identity quaternion (no rotation)
quaternion, // full rotation from start to end
t // the fraction of how far along the path we are
);
// Rotates the start vector gradually towards the end vector along the great circle path.
const pointLocal = startNorm
.clone()
.applyQuaternion(stepQuat)
// Multiplying by radius scales the unit vector back to the sphere's radius.
.multiplyScalar(radius);
// Move the point so it no longer treats the sphere's center as being at (0, 0, 0). Instead use the passed in value.
const pointWorld = pointLocal.add(center);
points.push(pointWorld);
}
return points;
}
function createPath({ start, end }) {
const { width, color } = config.meshes.path;
const points = calcPathPoints({ start, end });
const mesh = new Line2(
new LineGeometry().setFromPoints(points),
new LineMaterial({
color,
linewidth: width, // in world units
resolution: new THREE.Vector2(
config.viewport.width,
config.viewport.height
),
dashed: false
})
);
mesh.computeLineDistances();
return mesh;
}

Animate

Finally we add a mesh, called box, that travels along surface. It's animated by first creating a spline using our path points. A spline is a smooth curve that passes through or near a set of points.

Next, we use GSAP to interpolate (gradually change) a value, t, from 0 to 1. t represents the animation's progress and what point on the spline box should be positioned at:

  • â–ª t=0: start of animation and the first point on the spline.
  • â–ª t=0.5: middle of animation and the middle point on the spline.
  • â–ª t=1: end of animation and the last point on the spline.

/index.js

function animateMeshAlongPath({ mesh, path, points }) {
// Creates a smooth spline that passes through all points. Allowing us to interpolate any position between them smoothly.
const spline = new THREE.CatmullRomCurve3(points);
const tweenTarget = { t: 0 };
gsap.to(tweenTarget, {
t: 1,
duration: 5,
ease: "power1.inOut",
onUpdate: () => {
const t = tweenTarget.t;
const point = spline.getPoint(t);
const quaternion = calcMeshQuaterionAlongPath({ spline, point, t });
mesh.position.copy(point);
if (config.gui.orientateBox) {
mesh.quaternion.copy(quaternion);
}
},
repeat: -1,
yoyo: true
});
}

Geometry origin

By default, a mesh's origin (pivot point) is at (0, 0, 0) in local space, which is usually the center of its geometry. As a result, when box moves along the path, it passes through the surface of the sphere instead of resting on top of it. To fix this, we shift box's geometry up so the bottom aligns with the mesh's local y = 0 position.

/index.js

function setOriginYBottom(geometry) {
// Populate the .boundingBox property (three.js doesn't do this automatically)
geometry.computeBoundingBox();
// The lowest Y value of all vertices in the geometry.
const yOffset = -geometry.boundingBox.min.y;
geometry.translate(0, yOffset, 0);
}

Mesh rotation

Another thing we need to do is rotate the mesh so it:

  • â–ª faces forward along the spline (make the +Z axis point in the direction of movement) and
  • â–ª sits upright on the surface of the sphere (make the +Y axis points away from the sphere's center).

We do this by calling calcMeshQuaterionAlongPath everytime the mesh moves to a new position.

/index.js

function calcMeshQuaterionAlongPath({
spline,
point,
t,
sphereCenter = new THREE.Vector3(...config.meshes.sphere.position)
}) {
// Create a unit vector that points forward (along the direction of movement) using the tangent at time 't' along the spline.
// We'll use this so the mesh's +Z points towards the direction it's travelling.
const forward = spline.getTangent(t).normalize();
// Create a unit vector from the center of the sphere to the mesh's position.
// A normal vector — indicates which direction a surface is facing.
// We'll use this so the mesh's +Y points away from the center of the sphere.
// So the mesh sits up-right relative to the sphere's surface.
const up = point.clone().sub(sphereCenter).normalize();
// Create a unit vector to use for the mesh's +X by calculating a direction perpendicular to both up and forward.
// Required for creating a rotation matrix.
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
// Recompute the forward vector
// Due to floating-point errors, the forward vector may be misaligned after calculating up and right.
// This ensures the forward vector is perpendicular to both right and up.
const correctedForward = new THREE.Vector3()
.crossVectors(right, up)
.normalize();
const rotationMatrix = new THREE.Matrix4().makeBasis(
right, // X axis
up, // Y axis
correctedForward // Z axis
);
// Convert rotationMatrix to quaternion
return new THREE.Quaternion().setFromRotationMatrix(rotationMatrix);
}

Feedback

Have any feedback about this note or just want to comment on the state of the economy?

Where to next?