DEV Community

Cover image for Drawing day and night on a 3D globe: the shader behind my world clock
Yevhen Lysenko
Yevhen Lysenko

Posted on

Drawing day and night on a 3D globe: the shader behind my world clock

Every world clock I've used is a table. You read "Tokyo +9, São Paulo −3" and you still don't feel who's asleep. I wanted the opposite: look at the Earth, see the daylight band, and know instantly whether it's a reasonable hour to ping someone.

That became Timetate — a world clock and meeting planner where the main interface is a 3D globe with a day/night terminator that tracks the real sun.

This post is about the part people ask about: how the terminator is drawn, and why the naive version looks wrong.

The setup: three spheres, not one

The globe is three concentric spheres, each doing one job:

{/* 1. Earth — the shader that matters */}
<mesh>
  <sphereGeometry args={[2, 64, 64]} />
  <shaderMaterial args={[earthMaterial]} />
</mesh>

{/* 2. Clouds — a plain textured shell, slowly rotating */}
<mesh ref={cloudsRef}>
  <sphereGeometry args={[2.015, 64, 64]} />
  <meshStandardMaterial
    map={cloudsTexture}
    transparent
    opacity={0.18}
    depthWrite={false}
    blending={THREE.AdditiveBlending}
  />
</mesh>

{/* 3. Atmosphere — inside-out shell for the rim glow */}
<mesh>
  <sphereGeometry args={[2.03, 64, 64]} />
  <shaderMaterial side={THREE.BackSide} transparent blending={THREE.AdditiveBlending} ... />
</mesh>
Enter fullscreen mode Exit fullscreen mode

Two details worth stealing:

  • depthWrite={false} on the clouds. Without it the transparent cloud shell writes depth and punches holes in the Earth behind it.
  • side={THREE.BackSide} on the atmosphere. You render the inside of a slightly larger sphere so the glow appears as a rim around the planet instead of a haze in front of it.

Textures are five 1024px WebP maps — day, city lights, clouds, specular, normal. At 1024 the whole set is small enough that the globe is usable on a mid-range phone, which mattered more to me than crisper coastlines.

Where the sun actually is

Before any shading, you need one vector: the direction from the Earth's centre toward the sun.

I expected to need an astronomy library. I didn't. Two values define the subsolar point — the spot where the sun is directly overhead:

  • Declination — how far north or south of the equator the sun sits today. Swings ±23.44° over the year.
  • Subsolar longitude — where local solar noon is right now. Moves 15° west per hour.
useFrame(() => {
  const d = new Date(time);

  const utcHours =
    d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600;

  const dayOfYear = Math.floor(
    (d.getTime() - new Date(d.getUTCFullYear(), 0, 0).getTime()) / 86_400_000
  );

  // 80 ≈ the March equinox; 365.25 keeps leap years from drifting
  const declination = 23.44 * Math.sin((2 * Math.PI * (dayOfYear - 80)) / 365.25);

  // Noon UTC → 0°, and the subsolar point moves 15°/hour westward
  const subsolarLon = (12 - utcHours) * 15;

  material.uniforms.sunDirection.value.copy(
    latLngToVector3(declination, subsolarLon, 1)
  );

  cloudsRef.current.rotation.y += 0.0001;
});
Enter fullscreen mode Exit fullscreen mode

That's the whole solar model. It ignores the equation of time, so the terminator can be off by up to ~16 minutes at the extremes of the year.

I decided that was fine, and I'd argue it for any visualisation like this: the terminator is a band tens of kilometres wide on screen. A 16-minute error moves it by less than the softness of the transition itself. Nobody looking at a globe to decide whether to Slack someone in Lisbon is affected. Precision you can't see is precision you shouldn't pay for — in bundle size or in complexity.

If you are building something where it matters (solar panel angles, astrophotography), swap in a proper algorithm. For a clock, this is enough.

The bug everyone hits: which normal?

Here's the one that cost me an evening. The vertex shader passes two normals:

varying vec3 vNormal;       // view space
varying vec3 vWorldNormal;  // world space

void main() {
  vUv = uv;
  vNormal = normalize(normalMatrix * normal);
  vWorldNormal = normalize(vec3(modelMatrix * vec4(normal, 0.0)));

  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  vViewPosition = -mvPosition.xyz;
  gl_Position = projectionMatrix * mvPosition;
}
Enter fullscreen mode Exit fullscreen mode

normalMatrix gives you the normal in view space — relative to the camera. It's what most Three.js examples use, because for ordinary lighting it's what you want.

But sunDirection is a world space vector. Dot those two together and the terminator quietly rotates with your camera: orbit the globe and the sun follows you around like a badly rigged studio light. Everything renders, nothing errors, and the result is subtly, maddeningly wrong.

The fix is vWorldNormal — the normal transformed by modelMatrix alone, with w = 0.0 so translation is ignored. Every sun-dependent calculation uses that one. vNormal stays for view-space effects.

Rule of thumb: both vectors in a dot product must live in the same space. Obvious written down, invisible at 1am.

The terminator itself

The core is one line:

float intensity = dot(worldNormal, sunDirection);
float blend = smoothstep(-0.05, 0.08, intensity);
Enter fullscreen mode Exit fullscreen mode

intensity is 1.0 at the subsolar point, 0.0 where the sun is exactly on the horizon, −1.0 at solar midnight. step() would give you a hard edge — a black-and-white planet, which reads as a bug. smoothstep() gives you dusk.

The range is deliberately asymmetric: -0.05 to 0.08. A symmetric band looks fine mathematically and wrong visually. City lights are bright; if you fade them in symmetrically they start glowing while the ground is still lit, and the evening side turns to mush. Biasing the range pushes the blend slightly toward the day side, so lights appear once it's actually dark.

Then the twilight colour, which is a trick worth knowing:

float sunsetFactor = smoothstep(0.02, -0.02, intensity)
                   * smoothstep(-0.06, 0.02, intensity);
vec3 sunsetColor = vec3(0.8, 0.3, 0.1) * sunsetFactor * 0.1;
Enter fullscreen mode Exit fullscreen mode

Two smoothsteps multiplied together. The first ramps down (note the reversed edges), the second ramps up. Their product is a narrow band that peaks only in the overlap — a cheap bump function, no exp() required. That's the thin orange line along the terminator.

The * 0.1 is not a rounding error. My first version had this at full strength and the planet looked like it was on fire.

Night, day, and the ocean

Night is the city lights texture, warmed and pushed hard:

vec3 nightLights  = nightColor * vec3(1.0, 0.9, 0.7) * 2.5;
vec3 nightAmbient = vec3(0.005, 0.008, 0.015);
vec3 nightFinal   = nightLights + nightAmbient;
Enter fullscreen mode Exit fullscreen mode

The 2.5 multiplier is there because raw NASA lights data is far too dim once it's competing with a bright day side. The tiny blue ambient keeps unlit ocean from going pure black, which reads as a hole in the geometry rather than as night.

Day gets a specular highlight, masked so only water reflects:

vec3 lightDir      = normalize(sunDirection);
vec3 reflectDir    = reflect(-lightDir, worldNormal);
float spec         = pow(max(dot(viewDir, reflectDir), 0.0), 24.0);
vec3 specularColor = vec3(1.0, 1.0, 0.95) * spec * specular * 0.25;
Enter fullscreen mode Exit fullscreen mode

specular is sampled from the specular map — white over oceans, black over land — so continents stay matte while the sea catches the sun. That single multiply does more for realism than anything else in the shader.

And the composite:

vec3 finalColor = mix(nightFinal, dayColor + specularColor, blend);
finalColor += sunsetColor;

float fresnel     = pow(1.0 - max(dot(worldNormal, viewDir), 0.0), 3.0);
float sunSideGlow = smoothstep(-0.2, 0.5, intensity);
finalColor += vec3(0.2, 0.5, 1.0) * fresnel * 0.15 * sunSideGlow;

gl_FragColor = vec4(finalColor, 1.0);
Enter fullscreen mode Exit fullscreen mode

The fresnel term is the blue edge glow — strongest where the surface turns away from the camera. Gating it with sunSideGlow means the atmosphere only lights up where the sun actually hits it. Without that gate you get a planet with a glowing halo on its night side, which looks like a UI bug rather than physics.

The normal map is folded in at 5% strength (vWorldNormal + normalMapColor * 0.05) — just enough for mountain ranges to catch the light near the terminator without turning the whole globe lumpy.

Country borders that survive both sides

Borders are drawn as line meshes with their own tiny shader, because a fixed colour that reads well over dark ocean disappears over golden city lights:

uniform vec3 color;       // #40ffff
uniform vec3 sunDirection;
uniform float opacity;
varying vec3 vWorldNormal;

void main() {
  float intensity   = dot(vWorldNormal, sunDirection);
  float blend       = smoothstep(-0.1, 0.1, intensity);
  float nightFactor = 1.0 - blend;

  float brightness  = 1.0 + nightFactor * 1.5;
  gl_FragColor = vec4(color * brightness, opacity + nightFactor * 0.2);
}
Enter fullscreen mode Exit fullscreen mode

Same terminator maths, opposite intent: lines get brighter and more opaque on the night side. Hovered borders swap in a variant with 1.5 + nightFactor * 2.0 and full opacity.

Building those meshes is the expensive part of startup, so it's chunked into idle time rather than done in one blocking pass:

const step = (deadline) => {
  while (i < features.length && (deadline.timeRemaining() > 4 || deadline.didTimeout)) {
    buildCountry(features[i], i);
    i++;
  }
  if (i < features.length) requestIdleCallback(step);
  else setBorders({ borderLines, countryMeshes, lineMaterial });
};
requestIdleCallback(step);
Enter fullscreen mode Exit fullscreen mode

timeRemaining() > 4 leaves headroom inside a 16ms frame. The globe is interactive immediately and borders fill in over the next second or so, instead of the page freezing while a hundred-odd countries get tessellated.

What I'd tell past me

  1. Check your coordinate spaces first. The world-vs-view normal bug produces a result that looks plausible until you orbit the camera.
  2. Multiply two smoothsteps when you want a band instead of an edge. Cheaper and more controllable than any exponential.
  3. Asymmetric ranges beat symmetric ones wherever human perception is involved.
  4. Turn every effect down. Sunset at 0.1, fresnel at 0.15, normal map at 0.05. Every one of those started at 1.0 and looked like a screensaver.
  5. Match precision to pixels. A 16-minute solar error is invisible on a 900px globe. Spend the complexity budget elsewhere.

The globe is live at timetate.com — add a few cities and watch the terminator move across them, or open the meeting planner if you actually need to schedule something across time zones.

Happy to answer questions about any of the shader work below.

Top comments (0)