Home / Journal / 3D

How we built a live 3D Earth in the browser with Three.js and GSAP

Three images, a few shaders and one timeline become a 30-second film of Earth that runs live in your browser. Here is the build, with the code that matters.

RRixes Studio
2 Nov 2026 · 9 min read
3D

The globe on our Rixes Globe page looks like a rendered video. It is not. There is no video file anywhere: your graphics chip draws every frame of the 30-second film, live, from three images and about 700 lines of code. This is how it is put together, in the order we built it.

Try it firstOpen the live globe (it needs WebGL), press Explore, and drag the planet around. Then come back and the code below will make more sense.

The idea: a film that is code

We wanted a piece that shows what real-time 3D can do for a brand, without a heavy video and without a hero image that just sits there. So we set ourselves a rule: one scene, four chapters, 30 seconds, and everything on screen is drawn by code. That includes the headline that changes with each chapter and the little latitude, longitude and altitude readout in the corner.

Because it is live, it can do things a video cannot. The visitor can pause it, replay it, jump to a chapter or grab the camera and look around. Later, the same scene can show a client's own cities instead of ours.

The stack: no build step

Three.js draws the scene. GSAP runs the timeline. That is nearly the whole list. We load both from a CDN, and Three.js comes in through an import map, so the project is one HTML file and a folder of images. No bundler, no framework, nothing to install.

html · import map
<script type="importmap">
{"imports":{
  "three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
  "three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
</script>

The planet itself comes from NASA: the Blue Marble image for daytime, the Black Marble image for the city lights at night, and a black-and-white water mask. They are three JPEGs, 4096 by 2048 for day and night and 2048 by 1024 for the mask, about 1.8 MB in total. That is the only heavy download in the whole project.

One shader for day and night

A normal Three.js material would give us a lit sphere. We wanted a sunset line, glowing cities and a sun glint on the sea, so we wrote a custom ShaderMaterial. For every pixel it asks one question: how much does this point on the planet face the sun? That number (the dot product of the surface direction and the sun direction) decides how much of the day image and how much of the night image to show.

glsl · day and night (trimmed)
float ndl    = dot(N, uSun);                  // how much this point faces the sun
float dayMix = smoothstep(-0.10, 0.24, ndl);  // a soft sunset line, not a hard edge
vec3  day    = texture2D(uDay, vUv).rgb * (0.05 + 1.35 * max(ndl, 0.0));

float lum    = dot(texture2D(uNight, vUv).rgb, vec3(0.3, 0.59, 0.11));
vec3  lights = warm * pow(lum, 1.5) * 2.6;    // city lights come from the night image

vec3  col    = mix(lights, day, dayMix);

The same number drives two more details. The water mask makes a bright, tight highlight on the sea only where the sun hits at the right angle. And a thin rim of blue, strongest at the edge of the globe, fakes the air you see at the horizon from orbit.

Clouds with no image at all

Instead of downloading a cloud texture, we generate the clouds in a second shader on a sphere just 1.1% larger than the Earth. It stacks five layers of simplex noise (a standard way to make natural-looking randomness), bends them with a second noise, and squeezes them into latitude bands so the clouds feel like weather and not static. A time value nudges the noise a little every frame, so the clouds drift. Near the sunset line they pick up an orange edge. When the camera dives close, their opacity drops so the cities underneath can still be seen.

Atmosphere, stars and sun

  • Atmosphere. A larger sphere (1.16 times the Earth's size) drawn from the inside with additive blending. Where you look through more of it, near the edge, it glows more.
  • Stars. 7,000 points in a single draw call. Each has its own size, a slightly blue or warm colour, and a twinkle phase, so they never pulse together.
  • Sun glare. A 256-pixel canvas with a radial gradient, used as a sprite placed far out in the sun's direction.

Putting cities on a sphere

To place Delhi, or any city, on the globe we convert latitude and longitude into a 3D point. It is three lines of trigonometry:

js · latitude and longitude to a 3D point
const ll2v = (lat, lon, r = 1) => {
  const la = lat * DEG, lo = lon * DEG;
  return new THREE.Vector3(
    Math.cos(la) * Math.cos(lo) * r,
    Math.sin(la) * r,
    -Math.cos(la) * Math.sin(lo) * r
  );
};

For the flight routes we take two points, swing between them along the surface with a spherical interpolation (slerp), and lift the middle of the path above the globe. Longer trips are lifted higher. The points become a smooth curve, and the curve becomes a thin tube.

js + glsl · one flight arc (trimmed)
// longer trips fly higher
const ang = a.angleTo(b), h = 0.05 + 0.34 * (ang / Math.PI);
for (let i = 0; i <= 72; i++) {
  const t = i / 72;
  pts.push(slerp(a, b, t).multiplyScalar(1.004 + h * Math.sin(Math.PI * t)));
}
const geo = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 180, 0.0034, 8, false);

// fragment shader: uHead runs 0 -> 1.3 and reveals the tube along its length
float drawn = step(vT, uHead);
float tail  = smoothstep(uHead - 0.28,  uHead, vT) * drawn;
float head  = smoothstep(uHead - 0.035, uHead, vT) * drawn;
float i     = (0.35 * drawn + tail * 1.6 + head * 3.0) * uAlpha;

The reveal is the nice part. A single number, uHead, moves along the tube. Everything behind it is lit, with a bright head and a fading tail. Because the arcs use additive blending and the bloom pass is on, where two routes cross they simply glow brighter, which is what light does.

City labels are plain HTML, not 3D text. Every frame we project each marker's 3D position onto the screen, move the label there, and fade it out when the city is turning behind the globe.

One state object, one timeline

This is the part that keeps the project calm. We did not animate the camera, the sun and the effects separately. There is one plain object, S, that holds every number that can change: camera distance, latitude and longitude, sun angle, how far the “brand wave” has spread. GSAP tweens that object over a 30-second timeline. Each frame, one function reads S and writes it into the camera and the shaders.

js · one state object, one timeline (trimmed)
const S = { dist: 9.5, lat: 8, lon: 12, sunAz: 2.8, fade: 1, brand: 0, wave: 0, arcs: 1, home: 0 };

const tl = gsap.timeline({ repeat: -1, paused: true });
tl.to(S, { dist: 5.4, duration: 6, ease: 'power2.out' }, 0)                         // 01 Orbit
  .to(S, { dist: 2.3, lon: 77.2, lat: 24, duration: 5.6, ease: 'power3.inOut' }, 6) // 02 Descent
  .to(S, { wave: 3.4, duration: 5, ease: 'sine.inOut' }, 20.4);                     // 04 Signal

gsap.ticker.add(() => {
  // read S, write the camera and the shader uniforms, then draw
  earthMat.uniforms.uWave.value = S.wave;
  composer.render();
});

That gives us a lot for free. The chapter buttons just seek the timeline. The timecode and the progress bars read the timeline's time. Pause and replay are one line each. And the four-chapter story, from Orbit to Descent to Reach to Signal, reads like a script instead of a pile of callbacks.

In the last chapter a wave spreads from Delhi across the globe and turns the city lights from warm orange to our emerald, and the atmosphere shifts the same way. It is one number going from 0 to 3.4, read by the earth shader.

Bloom: the difference between 3D and film

The scene is not drawn straight to the screen. It goes through an EffectComposer with a bloom pass, which blurs the brightest pixels and adds them back. That is what makes the arcs, the markers and the sun look like light and not like coloured lines. Cinematic grading, in one pass.

Explore mode, phones and reduced motion

  • Explore. Pressing it pauses the film and lets the visitor drag to rotate and scroll or pinch to zoom, with limits so they cannot flip the planet over. Pressing it again resumes the film from the chapter they were in.
  • Phones. The pixel density is capped lower on small screens (1.5 instead of 2), and the globe is shifted up to leave room for the headline.
  • Reduced motion. If the visitor has asked their device for less motion, the pointer parallax is switched off and the timeline runs at three-quarter speed.
  • No WebGL. If the browser cannot create a WebGL context, the page shows a short plain-language message instead of a blank screen.

What we would change for production

This is a study, and we would not ship it unchanged on a client site. Four things we would do first:

  • Slim the textures. The day image is about 1.2 MB. For a client page we would serve a smaller version on phones and move to compressed GPU formats.
  • Self-host the libraries. Three.js and GSAP come from a CDN here. A client site should serve them itself and pin the versions.
  • Real data. The nine destination cities are placeholders. For a client they would be real stores, offices or customers.
  • Lazy start. On our case-study page the scene only loads when you press the button, so the page stays fast. We would do the same anywhere 3D is not the first thing on the screen.
What this gets youThe same building blocks (a custom material, a timeline, a bloom pass) go into product viewers, interactive maps and animated hero scenes. Same craft, a different subject.

Keep reading

All articles →
Let's talk

Have a project
in mind?

Tell us what you need in a minute. We reply on WhatsApp or email, usually within one working day.