feat(render3d): give the moon a phase

The moon was fixed opposite the sun and worth a constant trickle of light. It
now carries a phase, 0 new .. 0.5 full .. 1 new again, and that one number
decides three things at once: the disc's terminator, the fraction of its light
that reaches the ground, and where in the sky it rides.

The moon is where the sun was `lag` of a day ago, so a full moon rises as the
sun sets and a new moon travels with the sun and is never seen. A new moon is
now a properly dark night, which is what makes a carried light worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 16:15:19 +03:00
parent 2542cb591d
commit 6043a67631
2 changed files with 94 additions and 7 deletions

View file

@ -16,6 +16,49 @@ float starField(vec3 dir, float t) {
float twinkle = 0.7 + 0.3 * sin(t * (1.5 + 3.0 * h2) + h2 * 40.0);
return bright * disc * twinkle * (0.5 + h2);
}
// The moon: a disc a little over half a degree across, shaded by a terminator taken from
// its phase, with a few soft maria on it and a halo that opens up when the air is damp.
// It is the light the night already had (daylight.ludic repoints the sun after dusk) —
// this is that light finally having something you can look at.
uniform vec3 u_moon_dir;
uniform float u_moon_haze; // the weather's overcast: dims the disc, widens the halo
uniform float u_moon_phase; // 0 new .. 0.5 full .. 1 new
uniform float u_moon_illum;
vec3 moonColour(vec3 dir, float night) {
if (night <= 0.001 || u_moon_dir.y < -0.08) return vec3(0.0);
float ang = acos(clamp(dot(dir, u_moon_dir), -1.0, 1.0));
const float R = 0.0096; // about 0.55 degrees
// the halo first: wide, weak, and thicker through damp air
float halo = exp(-ang / (R * 6.0)) * (0.02 + 0.35 * u_moon_haze) * u_moon_illum;
vec3 col = vec3(0.55, 0.60, 0.75) * halo;
if (ang < R) {
// where on the disc: a unit offset from its centre, built in the sky's own frame
vec3 up = abs(u_moon_dir.y) > 0.95 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0);
vec3 rx = normalize(cross(up, u_moon_dir));
vec3 ry = cross(u_moon_dir, rx);
vec2 p = vec2(dot(dir, rx), dot(dir, ry)) / R;
float r2 = clamp(dot(p, p), 0.0, 1.0);
// the sphere's normal, and the direction its light comes from, turned by the phase
vec3 n = vec3(p, sqrt(max(1.0 - r2, 0.0)));
float a2 = u_moon_phase * 6.28318530718;
vec3 lit = normalize(vec3(sin(a2), 0.0, -cos(a2)));
float lam = smoothstep(-0.06, 0.10, dot(n, lit));
// maria: two or three soft dark blotches, so it is not a flat white circle
float m = 0.0;
m += smoothstep(0.34, 0.0, length(p - vec2(-0.28, 0.22)));
m += 0.7 * smoothstep(0.26, 0.0, length(p - vec2(0.24, -0.10)));
m += 0.5 * smoothstep(0.20, 0.0, length(p - vec2(0.02, 0.44)));
float edge = smoothstep(1.0, 0.86, sqrt(r2));
vec3 disc = mix(vec3(1.0, 0.98, 0.93), vec3(0.62, 0.63, 0.68), clamp(m, 0.0, 1.0));
// The lit face has to be bright enough to read as the moon and no brighter: the night
// runs at an exposure of about five, so anything much over one saturates the whole
// disc to a flat white circle and the terminator disappears. The dark limb keeps a
// trace of earthshine, which is what makes a crescent look like a sphere.
col += disc * (lam + 0.010 * (1.0 - lam)) * edge * 0.55;
}
return col * night * (1.0 - 0.9 * u_moon_haze);
}
void main() {
vec4 a = u_inv_vp * vec4(v_uv * 2.0 - 1.0, 1.0, 1.0);
vec3 dir = normalize(a.xyz / a.w - u_cam_pos);
@ -31,6 +74,7 @@ void main() {
float stars = starField(dir, u_time) * smoothstep(-0.02, 0.15, dir.y);
nightCol += stars * vec3(0.55, 0.6, 0.7) * night;
col += nightCol * night;
col += moonColour(dir, night);
}
// below the horizon the HDRI ground is replaced by the fog colour
float below = smoothstep(0.0, -0.08, dir.y);