The environment: the sun and the day/night cycle
In the previous chapter, I corrected the grass blades’
normal so that a blade leaning into the light gets brighter. Except I never said where that
light came from. The grass shader has been talking about a uSunDirection that nobody ever
introduced.
That’s the subject of this chapter: the Sun class. It follows exactly the same contract as
the wind — own the data and mutate it in place — but it adds something the wind didn’t have:
the passing of time.
One direction, everyone plugs into it
Sun owns the sun’s position as a unit vector pointing from the scene towards the sun. It’s
the single source of truth:
// Sun.js
// Single source of truth, shared BY REFERENCE in the uniforms.
// Mutated in place by updateSun, never reassigned.
this.sunDirection = new THREE.Vector3();
// Ambient intensity consumed by the shaders (non-reactive snapshot)
this.ambientIntensity = 0.4;
The grass receives the sun in its constructor, exactly as it received the wind, and plugs the vector into its uniforms:
// Grass.js
uSunDirection: { value: this.sun.sunDirection }, // shared (ref): mutated in place by updateSun
uAmbientLight: { value: this.sun.ambientIntensity },
It’s the same mechanism as uWindDirection: sunDirection is an object, so the uniform and
Sun point to the same Vector3 in memory. When the sun moves, Sun mutates the vector and the
grass sees the new value on the next frame, without a single line of code on the grass side.
And as with the wind, the ambient intensity is a number, so it’s a frozen copy: changing it at
runtime would require explicitly copying it over again, which I don’t do here because it never
moves.
Two lights for the terrain, one vector for the grass
Sun also creates two real Three.js lights:
setLights() {
this.ambientLight = new THREE.AmbientLight('#ffffff', 0.4)
this.scene.add(this.ambientLight)
this.directionalLight = new THREE.DirectionalLight('#ffffff', 1.5)
this.scene.add(this.directionalLight)
}
They’re not for the grass. The terrain uses a MeshStandardMaterial (see the
terrain chapter), so Three.js handles its lighting on its
own from the lights present in the scene. The grass has a ShaderMaterial: it sees no light at
all, it only knows the vector it was handed and computes its shading by hand.
Hence the last line of updateSun, which keeps both worlds in agreement:
this.directionalLight.position.copy(this.sunDirection);
The terrain is lit by a DirectionalLight whose position follows the vector, the grass reads
the vector directly. One source, two ways of consuming it, and a 0.4 found on both sides
(AmbientLight and ambientIntensity) so that the ground and the blades share the same ambient
base.
Drawing the arc: four parameters
That vector still has to be computed. A real ephemeris calculation would be accurate and perfectly useless here: the scene is stylized, what I want is a believable arc and, above all, a tweakable one. Four parameters are enough:
this.sunParams = {
hour: 12, // hour of the day (0-24)
inclination: 0.6, // tilt of the arc's PLANE: 0 = passes through the zenith
orientation: 0.0, // azimuth: rotates the whole arc around the vertical
declination: 0.41, // raises the whole arc: pushes sunrise and sunset apart (value found by trial and error)
};
updateSun turns them into a direction, in four steps that read like a geometric construction:
updateSun() {
// 0 at 8am (sunrise), PI at 8pm (sunset)
const arcAngle = (this.sunParams.hour - 8) * (Math.PI / 12)
// apply the arc angle to get the sun's direction in the vertical plane (X,Y)
this.sunDirection.set(Math.cos(arcAngle), Math.sin(arcAngle), 0)
// apply the inclination
this.sunDirection.applyAxisAngle(SUN_AXIS_X, this.sunParams.inclination)
// apply the orientation (azimuth): rotation around the vertical Y axis
this.sunDirection.applyAxisAngle(SUN_AXIS_Y, this.sunParams.orientation)
// 12 hours of daylight is too short (sunrise 8am, sunset 8pm), we raise the whole arc to lengthen the day
this.sunDirection.y += this.sunParams.declination
this.sunDirection.normalize()
this.directionalLight.position.copy(this.sunDirection)
}
The set(cos, sin, 0) lays down a perfect vertical half-circle: the sun rises due East
(arcAngle = 0, so the vector (1, 0, 0)), passes exactly through the zenith (PI/2, so
(0, 1, 0)), and sets due West (PI, so (-1, 0, 0)). The sin on .y is what gives the sun
its height, and the zero on Z forces the arc to pass exactly above the scene. It’s
geometrically clean and visually flat, because nobody has ever seen that outside of the equator
on an equinox.
The inclination fixes that by tilting the arc’s plane around the East-West axis. The important detail: the sunrise and sunset points sit on that X axis, so the rotation doesn’t move them. Only the high point of the path swings towards the South. The sun peaks lower, the light stays raking for longer, and the grass spends its day in more interesting light than a vertical noon.
The orientation rotates the entire arc around the vertical. It’s the framing control: it chooses where the sun rises relative to the camera, without changing the shape of the path at all.
The declination deserves a pause, because it’s an admitted cheat. Adding a constant to .y
raises the whole arc as a block, which pushes sunrise and sunset apart: the sun passes below the
horizon later, the day gets longer. With solar noon set at 2pm and a declination of 0.41, I get
a sunrise around 6am and a sunset around 10pm; at zero, the day shrinks to 8am-8pm. So I control
the length of the day with a single slider, which no honest astronomical calculation would let
me do.
The final normalize() cleans up after all this, since adding 0.41 to .y has obviously broken
the vector’s length. And the most discreet point is the most important one: these four
operations stay periodic over 24 hours. Nothing resets, nothing jumps at midnight. The hour can
loop forever, the direction follows without discontinuity.
An hour that advances on its own
The day/night cycle is nothing more than that hour being pushed forward:
this.dayDuration = 120 // seconds for one full 24h cycle
this.autoPlay = true
update() {
if (this.autoPlay) {
this.sunParams.hour = (this.sunParams.hour + (this.time.delta / this.dayDuration) * 24) % 24
this.updateSun()
if (this.hourController) this.hourController.updateDisplay() // the slider follows
}
}
Two details are worth the detour. The % 24 loops without any special case, precisely because
the arc is periodic: 11:59pm leads to 12:01am without the slightest visual hitch. And the
advance is proportional to time.delta, not a fixed increment per frame. So the cycle lasts 120
real seconds, whether the machine runs at 144 fps or struggles at 30.
On the debug side, manual input turns off autoplay, otherwise the two fight over the same variable and the slider keeps snapping back under your fingers:
this.hourController = folder
.add(this.sunParams, "hour", 0, 24, 0.1)
.name("Hour")
.onChange(() => {
this.autoPlay = false;
this.updateSun();
});
Lighting a blade
Once sunDirection is inside the grass shader, the most useful piece of data isn’t the whole
vector, it’s its vertical component. sunDirection.y measures the sun’s height: positive it’s
up, negative it’s down, zero is the horizon. That single value is enough to know that it’s
night.
// grass/fragment.glsl
float AmbientLight = uAmbientLight;
vec3 normal = normalize(vNormal);
if (!gl_FrontFacing) normal = -normal; // back face (DoubleSide): we use ITS outgoing normal
float sunOrientation = dot(uSunDirection, normal);
// 0 when the sun is below the horizon, 1 when it's well up
float dayFactor = smoothstep(-0.15, 0.05, uSunDirection.y);
float diffuse = max(sunOrientation * 0.5 + 0.5, 0.0) * dayFactor; // half lambert
The dot(uSunDirection, normal) is classic lighting: a blade facing the sun receives more light
than a blade seen edge-on. This is where the previous chapter’s normal correction pays off,
since it’s that corrected normal that goes into the dot product. The * 0.5 + 0.5 turns it into
a half lambert, which lifts the faces turned away from the sun instead of letting them fall to
zero: softer, more stylized, less realistic, and that’s exactly right.
The smoothstep(-0.15, 0.05, ...) is dusk summed up in one line. Direct light doesn’t switch
off at the exact moment the sun touches the horizon: it fades over a small band around zero.
Without that fade, night would fall like a light switch.
Then comes the line that saves the night:
vec3 col = mix(uBaseColor, uTipColor, vColor.x); // base -> tip gradient
col *= AmbientLight + diffuse * (1.0 - AmbientLight); // additive ambient
col = smoothstep(0.1, 0.9, col); // boosts contrast, more stylized look
The dayFactor only multiplies the diffuse, never the ambient. When the sun is down, diffuse
drops to zero and the 0.4 of ambient remains: the scene goes dark but stays readable, instead
of turning pitch black. And adding the ambient rather than applying it on top keeps a visible
directional shading even in raking light, at sunrise as at sunset.
The specular follows the same logic, filtered by dayFactor:
float specularFilter = pow(vColor.x, 30.0); // only the tip of the blade
vec3 lightReflection = reflect(-uSunDirection, normal);
float specular = pow(max(-dot(lightReflection, viewDirection), 0.0), 40.0);
col += specular * specularFilter * dayFactor * specularStrength;
The pow(vColor.x, 30.0) reuses the bend attribute from the wind chapter: that huge exponent
crushes everything except the very last centimetre of the blade, so only the tips catch the
light. The dayFactor does the rest and avoids the absurdity of sunlight glinting off the grass
at two in the morning.
What it looks like
The sun in this scene is a Vector3 and four numbers. updateSun rebuilds the arc every frame
with four geometric operations, mutates the vector in place, and stops there. It notifies nobody
and pushes no uniform: the terrain follows via a DirectionalLight positioned on the vector,
the grass reads it directly in its shader.
The day/night cycle asks for almost nothing more than an hour being pushed forward and a modulo.
All the visual richness comes from elsewhere: from a tilted rather than vertical arc, from a
smoothstep around the horizon rather than a binary test, and from an ambient that refuses to
go out.
But for now, night falls on an empty background: the scene darkens without anything above telling the story. The next chapter takes on the sky.