Three.js From Zero · Article s0-05

Earth Shader

Earth Shader is Article s0-05 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS0-05 · Quick Wins

Season 0 · Article 05 · Quick Wins

Procedural Earth — continents from noise, ocean blue, polar caps, atmospheric rim glow, slow rotation. No textures, no models. Just one sphere, one custom shader, ~120 lines of GLSL. The viral demo every Three.js portfolio needs.

— fps

Texture-free Earth — why

You can ship a more "realistic" Earth with NASA's Blue Marble texture set (~30MB of imagery). For a portfolio loop, procedural beats that: zero load time, infinite zoom without pixelation, color-grading the whole planet by changing a uniform. This article uses 3D simplex noise sampled on the sphere surface to fake continents.

Step 1 — The sphere

const geo = new THREE.SphereGeometry(1, 96, 96);   // high segment count for smooth silhouette
const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader,
  fragmentShader,
});
const earth = new THREE.Mesh(geo, mat);
scene.add(earth);

96×96 is overkill for a textured sphere but matters here — our shader paints continents on the fragment level, and a low-poly sphere reveals its facets at the silhouette.

Step 2 — Vertex shader (pass through position)

const vertexShader = /*glsl*/`
  varying vec3 vNormal;
  varying vec3 vPosition;
  void main() {
    vNormal = normalize(normalMatrix * normal);
    vPosition = position;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

The fragment shader needs two things from the vertex stage: the original sphere-space position (to drive noise per latitude/longitude) and the view-space normal (to compute the atmospheric rim).

Step 3 — Fragment shader: continents from noise

const fragmentShader = /*glsl*/`
  varying vec3 vNormal;
  varying vec3 vPosition;
  uniform float uTime;

  // 3D simplex noise — Ashima Arts implementation
  vec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
  vec4 mod289(vec4 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }
  vec4 permute(vec4 x){ return mod289(((x*34.0)+1.0)*x); }
  vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
  float snoise(vec3 v) {
    /* ...standard simplex implementation, ~50 lines... */
    return 0.0; // placeholder
  }

  void main() {
    // Slow continental drift so it animates
    float n = snoise(vPosition * 1.6 + vec3(0.0, uTime * 0.02, 0.0));
    n = (n + 1.0) * 0.5;

    // Continents above 0.5, ocean below
    vec3 ocean = vec3(0.05, 0.18, 0.36);
    vec3 land = mix(vec3(0.13, 0.40, 0.10), vec3(0.50, 0.42, 0.20), smoothstep(0.55, 0.85, n));
    vec3 base = mix(ocean, land, smoothstep(0.48, 0.52, n));

    // Polar ice caps from latitude
    float lat = abs(vPosition.y);
    base = mix(base, vec3(0.95, 0.95, 1.0), smoothstep(0.78, 0.95, lat));

    // Day/night via light direction
    vec3 lightDir = normalize(vec3(1.0, 0.6, 1.0));
    float diff = max(dot(vNormal, lightDir), 0.0);
    vec3 lit = base * (0.18 + diff * 0.9);

    // Atmospheric rim — bright when facing edge-on
    float rim = pow(1.0 - max(dot(vNormal, vec3(0.0, 0.0, 1.0)), 0.0), 2.5);
    lit += vec3(0.3, 0.5, 1.0) * rim * 0.5;

    gl_FragColor = vec4(lit, 1.0);
  }
`;

Five effects layered: noise-driven land/ocean, blended biome colors, latitude-based ice caps, diffuse lighting, and a rim glow for atmosphere. Each is a few lines and tunable.

Pitfall. Don't include the full noise function inline in your tutorial article — it's 50 lines of boilerplate. Link to Ashima's implementation. The interesting code is the 15 lines that USE the noise, not the noise itself.

Step 4 — The atmosphere shell

The rim glow inside the fragment shader fakes part of the atmosphere, but the real trick is a second, larger sphere with backface rendering:

const atmoMat = new THREE.ShaderMaterial({
  vertexShader: /* same as earth */,
  fragmentShader: /*glsl*/`
    varying vec3 vNormal;
    void main() {
      float intensity = pow(0.6 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
      gl_FragColor = vec4(0.4, 0.7, 1.0, 1.0) * intensity;
    }
  `,
  side: THREE.BackSide,
  blending: THREE.AdditiveBlending,
  transparent: true,
});
const atmosphere = new THREE.Mesh(new THREE.SphereGeometry(1.04, 64, 64), atmoMat);
scene.add(atmosphere);

BackSide is the key — we render the inside faces of the larger sphere. From the camera, that means only the silhouette is visible, which is exactly the atmosphere we want.

Step 5 — Rotation

renderer.setAnimationLoop((t) => {
  earth.rotation.y = t * 0.0001;
  mat.uniforms.uTime.value = t * 0.001;
  renderer.render(scene, camera);
});

The mesh rotation gives the surface visible motion; the uTime uniform drives slow continental drift inside the noise. Two scales of motion = "alive."

Common first-time pitfalls

"Whole sphere is one color." Noise function returned 0 (you used the placeholder). Paste in a real simplex noise implementation — Ashima Arts is the canonical one and is MIT-licensed.
"Atmosphere is a solid blue ring." Forgot BackSide. Without it, you see the front of the atmosphere sphere, which blocks the Earth.
"Continents look like noise — no land shapes." Noise scale is wrong. vPosition * 1.6 is the sweet spot; vPosition * 8.0 looks like static, vPosition * 0.3 looks like a single mega-continent.

Exercises

  1. Add a cloud layer. Third sphere at radius 1.02, fragment shader does a separate noise sample with higher frequency, alpha based on noise threshold. Rotates faster than Earth.
  2. Night side lights. When diff < 0.05 (deep night), add yellow city-light dots from a high-frequency noise threshold above 0.7. Multiply by inverse diffuse so they only show in shadow.
  3. Multiple planets. Mars (red, no atmosphere, lower mountain noise scale), gas giant (banded noise on Y axis only, no land/ocean). Same shader, different uniforms.