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.
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.
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.
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
BackSide. Without it, you see the front of the atmosphere sphere, which blocks the Earth.vPosition * 1.6 is the sweet spot; vPosition * 8.0 looks like static, vPosition * 0.3 looks like a single mega-continent.Exercises
- 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.
- 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. - 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.
UP NEXT
S0-06 — Hologram Shader → Scanlines, fresnel rim, glitch jitter. The sci-fi pattern you'll re-use in every UI.