Author a continuous celestial day/night cycle
verifiedAgainst 05ea923 · verifiedOn 2026-09-10.
What you will build
Section titled “What you will build”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]Where this sits
Section titled “Where this sits”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, evaluatesDayNightState, 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
Before you start
Section titled “Before you start”Read Docs/MASTERPLAN.md §13.1 (“Celestial and environmental lighting”) and review:
DayNightCycleMath, the pure elevation, intensity, and temperature equations.DayNightState, the immutable evaluation snapshot.DayNightCycleRuntime, the component driving the scene lights.DayNightCycleMathTests, test suite validating celestial transitions.
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}$. |
The build, step by step
Section titled “The build, step by step”1. The Pure Elevation Equations
Section titled “1. The Pure Elevation Equations”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 artifactsfloat 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.
2. Twilight and Moon Handoff Intensity
Section titled “2. Twilight and Moon Handoff Intensity”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.
3. Correlated Color Temperature Shift
Section titled “3. Correlated Color Temperature Shift”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.
4. Updating Scene Lights in Runtime
Section titled “4. Updating Scene Lights in Runtime”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;}5. Validate with Focused EditMode Tests
Section titled “5. Validate with Focused EditMode Tests”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)); }}Verify
Section titled “Verify”1. Execute Unit Tests Headlessly
Section titled “1. Execute Unit Tests Headlessly”Run the focused EditMode tests via Unity’s batchmode runner:
/opt/unity/Editor/Unity -batchmode -nographics \ -projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \ -runTests -testPlatform EditMode \ -testFilter VoxelSandbox.Tests.DayNightCycleMathTestsConfirm 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.
2. Verify in Play Mode
Section titled “2. Verify in Play Mode”- Enter Play Mode in
Gameplay.unity. - Locate the
DayNightCycleRuntimecomponent in the hierarchy. - Slowly drag the
timeOfDayslider 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.
Now do your own
Section titled “Now do your own”To adapt the celestial cycle for an extraterrestrial or high-latitude environment (e.g. Polar Midnight Sun or Binary Star System):
- 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.
- Configure color temperature limits: Adjust
NoonColorTemperatureandHorizonColorTemperatureto reflect atmospheric gas densities or spectral star types. - Customize fill lighting: Update
MoonMinPitchDegreesor add secondary orbital bodies toDayNightState. - Add unit test cases: Ensure edge angles ($t=0, 0.25, 0.5, 0.75$) are covered in
DayNightCycleMathTests.cs.
Pitfalls
Section titled “Pitfalls”| 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.