Skip to content
Edit on GitHub

Author a continuous celestial day/night cycle

verifiedAgainst 05ea923 · verifiedOn 2026-09-10.

In Voxamine, the celestial cycle avoids common voxel-sandbox compromises. Instead of clamping the sun slightly above the horizon to avoid pitch-black nights, or rotating a single directional light around multiple axes, the system implements pure trigonometric elevation trajectories (DayNightCycleMath.cs) driven by a normalized time-of-day clock.

The sun genuinely sets below the horizon ($-\text{NoonElevation} \times \cos(2\pi \cdot t)$), passing through a soft twilight zone where blackbody color temperature warms from crisp noon white ($5600\text{ K}$) to deep sunset orange ($2300\text{ K}$). At night, primary directional lighting hands off to a separate, dim moon fill light whose elevation is floored at $\ge 12^\circ$. This floors the Lambertian grazing angle so flat ground surfaces retain readable specular and diffuse definition while HDRP’s physical sky darkness carries the nighttime atmosphere.

// Pure, deterministic day/night evaluation:
DayNightState state = DayNightCycleMath.Evaluate(timeOfDayFraction);
// state.SunElevationDegrees -> [-38°, +38°]
// state.MoonElevationDegrees -> [12°, 38°] (pitch floored)
// state.ColorTemperatureKelvin -> [2300 K, 5600 K]

The day/night cycle decouples pure mathematical celestial mechanics from Unity scene lights and HDRP sky volume rendering:

  • World Environment (VoxelSandbox.World.Environment) contains pure calculation math (DayNightCycleMath) and state structures (DayNightState) that run without scene objects or allocations.
  • Runtime Coordinator (DayNightCycleRuntime) samples the clock, evaluates DayNightState, and positions the sun/moon directional lights.
  • Presentation Integration (SkyCycleBlend & HDRP) reacts to sun elevation by dynamically dimming atmospheric ambient lighting, modulating Rayleigh scattering, and shifting directional shadows.
flowchart TD
  subgraph CLOCK["Time of Day Clock"]
    TIME["GameSessionRuntime / WorldClock\n(normalized fraction t ∈ [0, 1])"]
  end

  subgraph MATH["VoxelSandbox.World.Environment"]
    EVAL["DayNightCycleMath.Evaluate(t)\n(trigonometric arcs & twilight lerp)"]
    STATE["DayNightState struct\n(SunElevation, MoonElevation, Intensities, Kelvin)"]
  end

  subgraph RUNTIME["DayNightCycleRuntime (MonoBehaviour)"]
    SUN_XFORM["Sun Transform\nPitch = SunElevation, Yaw = 30°"]
    MOON_XFORM["Moon Transform\nPitch = MoonElevation, Yaw = 210°"]
    SUN_LIGHT["Sun Light Component\nIntensity & ColorTemperature"]
    MOON_LIGHT["Moon Light Component\nIntensity & Shadow handoff"]
  end

  subgraph HDRP["HDRP Sky & Volume Framework"]
    PHYS_SKY["Physically Based Sky\n(atmospheric Rayleigh & Mie scattering)"]
    AMBIENT["Dynamic Sky Ambient\n(surface illumination scales with darkness)"]
  end

  TIME --> EVAL
  EVAL --> STATE
  STATE --> RUNTIME
  RUNTIME --> SUN_XFORM
  RUNTIME --> MOON_XFORM
  RUNTIME --> SUN_LIGHT
  RUNTIME --> MOON_LIGHT
  SUN_XFORM --> PHYS_SKY
  MOON_LIGHT --> AMBIENT

Read Docs/MASTERPLAN.md §13.1 (“Celestial and environmental lighting”) and review:

Review these astronomical and lighting design parameters:

Parameter Value Rationale
Peak Sun Elevation $38^\circ$ Matches the scene’s authored noon key-light pitch so daytime surfaces retain natural shadows.
Sun Twilight End $-6^\circ$ Civil twilight limit; direct sunlight reaches zero at $-6^\circ$ below the horizon rather than cutting off sharply at $0^\circ$.
Moon Minimum Pitch $12^\circ$ The moon serves as a stylized fill light. Grazing light angles on flat ground have near-zero Lambertian reflectance; flooring the pitch guarantees readable ground geometry.
Noon Color Temperature $5600\text{ K}$ Standard daylight white point (D56 standard illuminant).
Horizon Color Temperature $2300\text{ K}$ Warm blackbody radiation characteristic of long atmospheric optical path lengths at dusk/dawn.
Time-of-Day Wrapping $\text{Repeat}(t, 1.0)$ Fractions outside $[0, 1)$ wrap smoothly; $0.0 = \text{midnight}$, $0.25 = \text{sunrise}$, $0.5 = \text{noon}$, $0.75 = \text{sunset}$.

In DayNightCycleMath.Evaluate, compute sun elevation using a single negative cosine curve:

float fraction = Mathf.Repeat(timeOfDayFraction, 1f);
// -cos peaks at +38° at fraction 0.5 (noon) and bottoms at -38° at fraction 0.0 (midnight)
float sunElevation = -NoonSunElevationDegrees * Mathf.Cos(2f * Mathf.PI * fraction);
// Moon opposes the sun, but its pitch is floored to prevent grazing shadow artifacts
float moonArcElevation = -sunElevation;
float moonElevation = Mathf.Max(moonArcElevation, MoonMinPitchDegrees);

Because the trajectory uses an unclamped cosine wave, the sun actually dips $38^\circ$ below the terrain plane at midnight.

Intensity calculations ensure there is never an unlit frame during dawn or dusk handoffs:

// Sun fades across [-6°, +38°]
float sunIntensity = Mathf.Clamp01(
(sunElevation + SunTwilightEndDegrees) / (NoonSunElevationDegrees + SunTwilightEndDegrees));
// Moon reaches full strength when its arc reaches +8°
float moonIntensity = Mathf.Clamp01(moonArcElevation / MoonFullElevationDegrees);
bool sunlightActive = sunElevation > -SunTwilightEndDegrees;
bool moonlightActive = moonArcElevation > 0f;

Because the moon reaches full intensity when moonArcElevation reaches $8^\circ$, it is already carrying surface illumination before the sun finishes sinking through civil twilight.

Rather than tinting lights with arbitrary RGB multipliers, the sun adjusts its physical color temperature:

float elevationProgress = Mathf.Clamp01(sunElevation / NoonSunElevationDegrees);
float colorTemperature = Mathf.Lerp(HorizonColorTemperature, NoonColorTemperature, elevationProgress);

At noon ($38^\circ$), color temperature reaches $5600\text{ K}$. As the sun dips toward the horizon ($0^\circ$), color temperature smoothly drops to $2300\text{ K}$, creating authentic golden hour and dusk atmospheres without manual color curves.

In DayNightCycleRuntime.Update, apply the evaluated state to the scene directional lights:

DayNightState state = DayNightCycleMath.Evaluate(timeOfDay);
if (sunLight != null)
{
sunLight.transform.rotation = Quaternion.Euler(state.SunElevationDegrees, sunYaw, 0f);
sunLight.intensity = state.SunIntensity * maxSunIntensity;
sunLight.colorTemperature = state.ColorTemperatureKelvin;
sunLight.enabled = state.SunlightActive;
}
if (moonLight != null)
{
// Note: moon pitch uses state.MoonElevationDegrees, floored at 12°
moonLight.transform.rotation = Quaternion.Euler(state.MoonElevationDegrees, moonYaw, 0f);
moonLight.intensity = state.MoonIntensity * maxMoonIntensity;
moonLight.enabled = state.MoonlightActive;
}

Add test coverage in DayNightCycleMathTests.cs validating exact angles and handoff boundaries:

[Test]
public void Evaluate_Noon_SunPeaksAndMoonZero()
{
DayNightState state = DayNightCycleMath.Evaluate(0.5f);
Assert.That(state.SunElevationDegrees, Is.EqualTo(DayNightCycleMath.NoonSunElevationDegrees).Within(0.01f));
Assert.That(state.SunIntensity, Is.EqualTo(1f).Within(0.01f));
Assert.That(state.MoonIntensity, Is.EqualTo(0f).Within(0.01f));
Assert.That(state.ColorTemperatureKelvin, Is.EqualTo(DayNightCycleMath.NoonColorTemperature).Within(1f));
}
[Test]
public void Evaluate_MoonPitchNeverBelowMinimumFloor()
{
for (int i = 0; i < 100; i++)
{
DayNightState state = DayNightCycleMath.Evaluate(i / 100f);
Assert.That(state.MoonElevationDegrees, Is.GreaterThanOrEqualTo(DayNightCycleMath.MoonMinPitchDegrees));
}
}

Run the focused EditMode tests via Unity’s batchmode runner:

Terminal window
/opt/unity/Editor/Unity -batchmode -nographics \
-projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \
-runTests -testPlatform EditMode \
-testFilter VoxelSandbox.Tests.DayNightCycleMathTests

Confirm all assertions pass:

  • Noon sun elevation equals $+38^\circ$ with $1.0$ intensity and $5600\text{ K}$.
  • Midnight sun elevation equals $-38^\circ$ with $0.0$ intensity.
  • Sunrise ($t=0.25$) and sunset ($t=0.75$) cross the horizon at $0^\circ$ elevation and $2300\text{ K}$.
  • Moon elevation never dips below $12^\circ$ regardless of time of day.
  1. Enter Play Mode in Gameplay.unity.
  2. Locate the DayNightCycleRuntime component in the hierarchy.
  3. Slowly drag the timeOfDay slider from $0.0$ to $1.0$:
    • At $0.20$–$0.25$ (dawn): Watch the horizon blush orange as color temperature warms.
    • At $0.50$ (noon): Sun reaches high overhead; shadows sharpen.
    • At $0.75$ (dusk): Sun sinks below horizon; moon light smoothly assumes ground shadows without popping.
    • At $0.00$ (midnight): Dark, starry sky illuminated softly by the floored moon fill light.

To adapt the celestial cycle for an extraterrestrial or high-latitude environment (e.g. Polar Midnight Sun or Binary Star System):

  1. Adjust elevation bounds: In a polar biome or seasonal mode, shift the cosine offset so the sun never dips below the horizon during summer months.
  2. Configure color temperature limits: Adjust NoonColorTemperature and HorizonColorTemperature to reflect atmospheric gas densities or spectral star types.
  3. Customize fill lighting: Update MoonMinPitchDegrees or add secondary orbital bodies to DayNightState.
  4. Add unit test cases: Ensure edge angles ($t=0, 0.25, 0.5, 0.75$) are covered in DayNightCycleMathTests.cs.
Symptom Cause Fix
Flat terrain turns completely black with jagged shadows at night. Moon pitch was allowed to track the raw negative sun arc down to $0^\circ$. Enforce Mathf.Max(moonArcElevation, MoonMinPitchDegrees) so the fill light never grazes the horizon unlit.
Ground lighting snaps dark instantly at sunset. Sun intensity was cut off at $0^\circ$ instead of extending through civil twilight. Include twilight tolerance: (sunElevation + SunTwilightEndDegrees) / (NoonSunElevationDegrees + SunTwilightEndDegrees).
Shadows pop or jitter during dawn and dusk. Both sun and moon cast hard real-time directional shadows simultaneously. Only enable shadow casting on the primary active light source (sunLight.shadows = LightShadows.Soft; moonLight.shadows = LightShadows.None;).
Day/night cycle stutters or resets across save reloads. Clock was updated via frame delta without persisting elapsed seconds. Persist the normalized weather/world clock in WorldSaveData and advance monotonically using Time.deltaTime / DayDurationSeconds.
Garbage collection spikes every frame in Play Mode. DayNightState was declared as a class instead of a readonly struct. Keep celestial state structures as lightweight, stack-allocated readonly structs.

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…