Hi all — I’ve been building Timetate, a world clock where the interface is the globe rather than a table of numbers. You add cities, and the daylight band tells you who’s awake before you message them.
The rendering side is three concentric spheres:
- Earth (r=2, 64×64) with a custom
ShaderMaterial— day, city lights, specular and normal maps, blended by the terminator. - Clouds (r=2.015) — plain
meshStandardMaterial, additive,opacity: 0.18,depthWrite: false, rotating at0.0001rad/frame. - Atmosphere (r=2.03) —
side: BackSide, additive, fresnel-only shader.
The terminator comes down to one dot product and a smoothstep:
float intensity = dot(worldNormal, sunDirection);
float blend = smoothstep(-0.05, 0.08, intensity);
Two things I’d flag for anyone doing the same:
1. World space, not view space. The vertex shader carries both:
vNormal = normalize(normalMatrix * normal); // view space
vWorldNormal = normalize(vec3(modelMatrix * vec4(normal, 0.0))); // world space
sunDirection is a world vector, so every sun-dependent term uses vWorldNormal. Using normalMatrix there renders fine and looks fine — until you orbit, and the sun follows the camera.
2. The twilight band is a product of two smoothsteps.
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;
One ramps down, one ramps up, and the product peaks only in the overlap — a bump function without an exp(). Note the * 0.1: at full strength the planet looks like it’s on fire.
The asymmetric range in blend (-0.05 to 0.08) is deliberate too. A symmetric band lets city lights bleed through ground that’s still lit; biasing it toward the day side fixes that.
Sun position is a plain analytic approximation, no library:
const declination = 23.44 * Math.sin((2 * Math.PI * (dayOfYear - 80)) / 365.25);
const subsolarLon = (12 - utcHours) * 15;
That ignores the equation of time, so it can be ~16 minutes off at the extremes of the year — invisible on a terminator that’s a soft band tens of pixels wide.
Country borders are line meshes with their own small shader that gets brighter on the night side, since a fixed cyan that reads well over dark ocean vanishes over city lights. Building ~200 of them blocks for a while, so they’re chunked into requestIdleCallback with a timeRemaining() > 4 guard — the globe is interactive immediately and borders fill in behind it.
Stack: React Three Fiber, Next.js App Router with static generation for the ~1000 non-globe pages, Zustand, Tailwind. Textures are five 1024px WebP maps — deliberately modest so it stays usable on mid-range phones.
Live: https://timetate.com
Happy to go deeper on any of it — especially interested if anyone has a better approach to the border-mesh build cost, that’s still the slowest part of startup.