The environment: the sky
Since the sun chapter, the scene knows what time it
is. One direction, a vector mutated in place, and everyone hooks into it: the grass for its
lighting, the rain for its dayFactor. But a day/night cycle with no visible sky is a scene
that darkens without you knowing why. The set is missing: the gradient, the sun’s disk, the
moon and the stars.
That is what this chapter is about. Everything that follows only reads the sun’s direction, without ever asking anything of it.
A dome that follows the camera
The sky is a sphere of radius 90 rendered from the inside:
// Sky.js, setMesh()
this.skyGeometry = new THREE.SphereGeometry(this.radius, 32, 16);
this.skyMaterial = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
// ...
});
BackSide flips the faces so you see the inside of the sphere, and depthWrite: false forbids
it from writing to the depth buffer, otherwise the dome would hide everything farther than
itself.
The radius of 90 is not a round number dropped at random: it has to stay below the camera’s
far, otherwise the dome would get cut off by the clipping plane. It is the kind of value you
set once and forget, until the day you touch the camera.
Finally, the dome re-centers on the camera every frame:
// Sky.js, update()
this.sky.position.copy(camera.position);
Without this, as you move away from the origin you would eventually leave the sphere and see the sky from the outside. By sticking it to the camera, the horizon always stays at the same distance, which is exactly how a real sky behaves.
Seven keyframes for one day
The sky’s color is not computed, it is chosen. I placed seven keyframes along the twenty-four hours, each giving three colors: the horizon, the zenith, and the tint of the sun’s halo.
// Sky.js
this.gradients = [
{ hour: 0, horizon: "#0a1030", zenith: "#05060f", sun: "#ffd9a0" }, // deep night
{ hour: 5, horizon: "#3a2a55", zenith: "#141a40", sun: "#ff9a6b" }, // dawn
{ hour: 6.5, horizon: "#ffae6b", zenith: "#5a7fb5", sun: "#ffb070" }, // sunrise
{ hour: 9, horizon: "#bfe3ff", zenith: "#2f7bd6", sun: "#fff4e0" }, // full day
{ hour: 20, horizon: "#bfe3ff", zenith: "#2f7bd6", sun: "#fff4e0" }, // day held
{ hour: 22, horizon: "#ff7a4a", zenith: "#3a5aa0", sun: "#ffb070" }, // sunset
{ hour: 23, horizon: "#ff5e3a", zenith: "#2a3a7a", sun: "#ff7a4a" }, // red dusk
];
Two things worth noting. First, the 9h and 20h keyframes are identical: this is deliberate, it freezes the full-day color over a long span rather than letting it drift slowly for eleven hours. The interesting moments are the transitions, not the middle of the day.
The hours are of course aligned with the cycle from the sun chapter, with sunrise around 6h, noon at 14h and sunset around 22h.
The interpolation looks for the two keyframes that bracket the current hour:
// Sky.js
updateColors(hour) {
const stops = this.gradients;
const count = stops.length;
let nextIndex = 0;
while (nextIndex < count && stops[nextIndex].hour <= hour) nextIndex++;
const to = stops[nextIndex % count];
const from = stops[(nextIndex - 1 + count) % count];
const span = (to.hour - from.hour + 24) % 24 || 24;
const t = ((hour - from.hour + 24) % 24) / span;
this.horizonColor.lerpColors(from.horizon, to.horizon, t);
this.zenithColor.lerpColors(from.zenith, to.zenith, t);
this.sunColor.lerpColors(from.sun, to.sun, t);
}
The modulos by 24 handle the wrap-around. Between 23h and midnight, the next keyframe is index
0, so to.hour - from.hour is -23, and the + 24) % 24 brings it back to 1 hour. Without this,
the night would end with an abrupt color jump at midnight. The || 24 covers the degenerate
case where both keyframes land on the same hour.
These colors are then used as uniforms for the sky (by reference).
The gradient and the halo
The dome’s fragment shader fits in six lines. The vertex only passes it the direction of the point on the sphere:
// sky/vertex.glsl
vDir = normalize(position);
// sky/fragment.glsl
// Sky color
// vDir.y = 1 at the top
float h = smoothstep(0.0, 0.4, vDir.y);
vec3 col = mix(uHorizonColor, uZenithColor, h);
// warm halo around the sun
float d = max(dot(normalize(vDir), normalize(uSunDirection)), 0.0); // d = 1 => pixel on the sun
float glow = pow(d, 32.0) * smoothstep(-0.1, 0.2, uSunDirection.y);
col += uSunColor * glow;
The gradient is a simple mix between horizon and zenith driven by height. The
smoothstep(0.0, 0.4, ...) concentrates the transition in the lower sky: beyond 0.4 in
normalized height, you are already at the zenith color. That is what gives a marked horizon band
rather than a mushy gradient spread across the whole dome.
The halo is a dot product between the pixel’s direction and the sun’s: it equals 1 when you look
straight at the sun and decreases as you move away. The pow(d, 32.0) sharply tightens that
falloff, turning a big blurry gradient into a compact aura around the disk. The exponent is the
tuning knob: the larger it is, the tighter the halo.
The smoothstep(-0.1, 0.2, uSunDirection.y) factor turns off the halo when the sun goes below
the horizon. Without it, a bright patch would keep crossing the night sky at the spot where the
sun sits, under the ground.
Two billboards: the sun and the moon
The sun’s disk is a plain 10 by 10 plane, repositioned every frame:
// Sky.js, update()
this.sunMesh.position
.copy(camera.position)
.addScaledVector(sunDir, this.radius * 0.9);
this.sunMesh.quaternion.copy(camera.quaternion);
this.sunMaterial.uniforms.uOpacity.value = THREE.MathUtils.smoothstep(
sunDir.y,
-0.1,
0.05,
);
You start from the camera, move 0.9 radius in the sun’s direction, and you land on the dome, slightly in front so as not to z-fight with it. The billboard is obtained by plainly copying the camera’s quaternion: the plane adopts its orientation, so it always faces it. This is the cheapest kind of billboard, and it is enough here since the disk is round and no rotation of its own makes sense.
The moon is the same object, on the opposite side:
this.moon.position
.copy(camera.position)
.addScaledVector(sunDir, -this.radius * 0.9);
this.moonMaterial.uniforms.uOpacity.value =
1.0 - THREE.MathUtils.smoothstep(sunDir.y, -0.2, 0.1);
The -sunDir places it at the sun’s antipode, which gives it a coherent cycle for free: it
rises when the sun sets. Its opacity is the exact inverse of the sun’s, with slightly different
thresholds so that the crossover at dawn and dusk is not symmetric to the pixel.
Both have renderOrder = 1 to pass after the dome, and depthWrite: false so as not to block
the set. They keep the default depth test though, so a cliff can still occlude them, which is
the intended behavior.
The moon’s crescent is a nice little shader trick, obtained by subtracting one disk from another:
// moon/fragment.glsl
float dist = length(vUv - 0.5) * 2.0;
float disk = smoothstep(0.5, 0.45, dist); // full disk
// Offset dark disk: carves the crescent by subtracting it from the disk.
float shadow = smoothstep(0.5, 0.45, length(vUv - vec2(0.62, 0.5)) * 2.0);
float crescent = clamp(disk - shadow, 0.0, 1.0);
Two identical disks, one offset by 0.12 in UV, a subtraction, and the crescent is what remains. Offsetting more thins it, offsetting less gives an almost full moon. It is a disguised lunar-phase parameter, even if I do not animate it.
Seven hundred stars that reveal themselves one by one
The stars reuse exactly the rain’s pattern: a random threshold frozen per element, compared to a global value. Except here the global value is not a density, it is the progress of the night.
At construction, each star gets a position on the upper cap of the dome, a threshold, a size and a phase:
// Sky.js, setStars()
const theta = Math.random() * Math.PI * 2;
const y = 0.05 + Math.random() * 0.95; // normalized height (avoids the horizon exactly)
const rxz = Math.sqrt(1 - y * y);
positions[i * 3 + 0] = Math.cos(theta) * rxz * r;
positions[i * 3 + 1] = y * r;
positions[i * 3 + 2] = Math.sin(theta) * rxz * r;
thresholds[i] = Math.random(); // 0 = appears early, 1 = only in deep night
scales[i] = 0.5 + Math.random() * 1.5; // varied sizes
phases[i] = Math.random() * Math.PI * 2; // out-of-phase twinkle
The rxz = sqrt(1 - y * y) is what keeps the points on the sphere: once the height y is
drawn, the radius available in the horizontal plane follows by Pythagoras. The draw starts at
0.05 and not 0 to avoid lining stars up exactly on the horizon, where they would end up
half-buried in the terrain.
On the shader side, appearance is a comparison between the star’s threshold and the depth of the night:
// stars/vertex.glsl
float appear = smoothstep(aThreshold, aThreshold + 0.15, uNight);
float twinkle = 0.65 + 0.35 * sin(uTime * 2.0 + aPhase);
vAlpha = appear * twinkle;
And uNight is pushed from the JS, as the inverse of the sun’s height:
this.starsMaterial.uniforms.uNight.value =
1.0 - THREE.MathUtils.smoothstep(sunDir.y, -0.35, 0.05);
The result is that at dusk, only the low-threshold stars light up. Then night sets in, uNight
rises, and the others join progressively. At sunrise, the movement reverses. Since the
thresholds are frozen once and for all, the same stars always appear first and in the same
place: this is exactly the property we were after for rain density, and for the same reason. A
draw redone every frame would give a sky that fizzes.
The twinkle is an out-of-phase sine per star that oscillates the intensity between 0.3 and 1.
Three configuration details matter as much as the shader:
blending: (THREE.AdditiveBlending, // bright points that add to the dark sky
// ...
(this.stars.frustumCulled = false)); // we move the object every frame (it follows the camera)
Additive blending is what makes a star add light to the sky instead of painting over it, so
the faintest ones blend naturally into the background. And frustumCulled = false is mandatory:
Three.js computes the bounding box once, at construction, while the object is moved onto the
camera every frame. Without it, the engine would judge it off-screen and the stars would vanish
in whole blocks depending on orientation.
What it looks like
The sky of this scene is a stack of three objects that only read the hour and the sun’s direction: a dome whose palette is interpolated across seven keyframes, two antipodal billboards for the sun and the moon, and a point cloud where each star has its own appearance threshold.
None of them knows the others. Each subscribes to the same source, the one from the sun chapter, and pulls from it what it needs. It is the same discipline as the wind and the rain: one piece of data mutated in a single place, consumers that read it.
There is still one uniform this chapter carefully avoided. Sky has a weatherBrightness that
multiplies the horizon and zenith colors after the hourly computation. It is 1 here, meaning a
perfectly clear sky, because nothing drives it yet. That is the subject of the next chapter:
wiring up a Weather class that, from a single preset, will swing the sky, the wind and the
rain from fair weather to storm.