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: 

    Tended: 

    Status: seed

    Hits: 32772

    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.
    const spline = new THREE.CatmullRomCurve3(points);
    const startPoint = points[0];
    const endPoint = points[points.length - 1];
    // Calculate the orientation (rotation) of the mesh at the start and end of the path.
    // We'll interpolate between these during the animation.
    const startQuat = calcMeshQuaterionAlongPath({
    point: startPoint,
    t: 0,
    spline
    });
    const endQuat = calcMeshQuaterionAlongPath({ point: endPoint, t: 1, spline });
    // Calculate the Matrix4 transformation of the mesh at the start and end of the path.
    // (matrix4 combines position, rotation and scale)
    const startMatrix = new THREE.Matrix4().compose(
    startPoint,
    startQuat,
    new THREE.Vector3(1, 1, 1)
    );
    const endMatrix = new THREE.Matrix4().compose(
    endPoint,
    endQuat,
    new THREE.Vector3(1, 1, 1)
    );
    // Create a function that can return a Matrix4 transformation at any point along the path
    const interpolateMatrix = createSphereSpaceInterpolator({
    startWorldMatrix: startMatrix,
    endWorldMatrix: endMatrix
    });
    const tweenTarget = { t: 0 };
    gsap.to(tweenTarget, {
    t: 1,
    duration: 5,
    ease: "power1.inOut",
    onUpdate: () => {
    const t = tweenTarget.t;
    const matrix = interpolateMatrix(t);
    mesh.matrix.copy(matrix);
    // Prevent three.js recalculating the matrix on the next frame.
    // We don't need it because we have manual control of position, rotation and scale.
    mesh.matrixAutoUpdate = false;
    },
    repeat: -1,
    yoyo: true
    });
    }

    Mesh rotation

    In addition to moving the mesh, we need to rotate it 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).

    calcMeshQuaterionAlongPath returns the correct orientation at any point along the path.

    /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 toward the target.
    // Making the mesh face fowards as it travels the spline.
    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.
    // Making the mesh sit up-right on the surface of the sphere.
    const up = point.clone().sub(sphereCenter).normalize();
    // Calculate a 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 forward to ensure orthogonality
    // 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);
    }

    matrix4 transformation

    A Matrix4 transformation is a mathematical representation of an object's position, rotation, and scale in 3D space. animateMeshAlongPath calculates the mesh's matrix at the start and end of the path. It then uses createSphereSpaceInterpolator to interpolate between these two. At any point along the path, it returns a matrix, which, when applied to the mesh, positions it on the path and orients it to face the right direction.

    /index.js

    function createSphereSpaceInterpolator({
    startWorldMatrix,
    endWorldMatrix,
    sphereCenter = new THREE.Vector3(...config.meshes.sphere.position)
    }) {
    // Calculate start and end positions in sphere space using world positions.
    // (vectors from sphere center to the mesh)
    const startPos = new THREE.Vector3()
    .setFromMatrixPosition(startWorldMatrix)
    .sub(sphereCenter);
    const endPos = new THREE.Vector3()
    .setFromMatrixPosition(endWorldMatrix)
    .sub(sphereCenter);
    // Extract start and end rotations
    const startQuat = new THREE.Quaternion().setFromRotationMatrix(
    startWorldMatrix
    );
    const endQuat = new THREE.Quaternion().setFromRotationMatrix(endWorldMatrix);
    // Define a curved path on the sphere
    const arcQuat = new THREE.Quaternion().setFromUnitVectors(
    startPos.clone().normalize(),
    endPos.clone().normalize()
    );
    return (t) => {
    // Interpolate position along the arc
    const stepQuat = new THREE.Quaternion().slerpQuaternions(
    new THREE.Quaternion(), // identity quaternion
    arcQuat,
    t
    );
    const interpPos = startPos
    .clone()
    .applyQuaternion(stepQuat)
    .add(sphereCenter);
    // Interpolate orientation (rotation)
    const interpQuat = new THREE.Quaternion().slerpQuaternions(
    startQuat,
    endQuat,
    t
    );
    // Combine position, rotation and scale into a matrix4 transformation
    return new THREE.Matrix4().compose(
    interpPos,
    interpQuat,
    new THREE.Vector3(1, 1, 1)
    );
    };
    }

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

    Feedback

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

    Where to next?