Time of day simulation for Unity. A single clock drives physically derived sun and moon positions, which in turn drive directional lights, shadow handover and post processing.
- Open Window → Package Manager.
- Click + → Add package from git URL... and add Celestia:
https://github.com/ZenoxZX/celestia.git
The repository root is the package root, so no ?path= suffix is needed.
Or declare it directly in Packages/manifest.json:
"com.zenoxzx.celestia": "https://github.com/ZenoxZX/celestia.git"The package has no dependencies. Post processing support compiles itself in when the Universal Render Pipeline is present and stays out of the build when it is not, so nothing has to be installed first.
-
GameObject > Celestia > Sky Rigbuilds the hierarchy and wires every reference:Celestia WorldClock, CelestialHandler, CelestialLightBinder Sun Light Directional light Moon Light Directional light Sky Volume Volume, CelestialPostProcessBinder -
Assets > Create > Celestia > Celestial Presetcreates a preset. Assign it to theCelestialHandler. -
Set the latitude, season and moon phase on the preset. Press play.
WorldClock keeps the day as a 0..1 progress value and raises events as
seconds, minutes, hours and days roll over. It ticks itself or accepts an
external tick, and the speed is set by real seconds per day multiplied by a
time scale.
CelestialHandler listens to the clock, samples CelestialSolver with the
preset, and publishes a CelestialState containing both body directions,
altitudes, azimuths, moon illumination and sky phase.
Binders consume that state. They never compute anything themselves, so you can add your own listener for UI, audio or gameplay without touching the core.
CelestialSolver returns altitude above the horizon and azimuth measured
clockwise from north. CelestialState.SunDirection points at the body;
SunLightForward points the way the light travels, which is what a
Light transform needs.
Hours are local solar time, so noon is when the sun peaks. Longitude is stored on the preset for reference but is not yet part of the calculation — that needs a time zone and the equation of time.
Year progress is continuous so a full year can be simulated:
| Year progress | Season | Sun declination |
|---|---|---|
| 0.00 | Spring equinox | 0° |
| 0.25 | Summer solstice | +23.44° |
| 0.50 | Autumn equinox | 0° |
| 0.75 | Winter solstice | −23.44° |
Both equinoxes share a declination of zero, so the sun follows an identical path on each. The moon does not: its position depends on phase, so spring and autumn diverge once the phase is anything other than full.
Phase runs 0..1: new moon at 0, first quarter at 0.25, full at 0.5, last
quarter at 0.75. Phase sets how far the moon sits from the sun, which is why a
full moon rises at sunset and peaks at midnight.
A useful consequence: a winter full moon climbs as high at midnight as a summer sun does at noon, while a summer full moon stays low. Season choice changes night shadows dramatically.
CelestialPostProcessRig drives White Balance and Color Adjustments from sky
phase, where 0 is astronomical night, 0.167 is the horizon and 1 is the sun at
zenith. Curves and gradients live on the preset. The rig is plain C# and takes
an ICelestialSource, so it runs from a component or a container — the same
split the lighting side uses with CelestialLightRig.
CelestialPostProcessBinder is the MonoBehaviour wrapper: assign a handler and
a volume, and it owns a rig for as long as it is enabled.
The rig copies the volume profile at runtime and restores the original on
unbind, so the source asset is never written to and produces no diff. Missing
overrides are added to that copy automatically; turn off Add Missing Overrides to drive only what the profile already declares.
This lives in its own assembly and compiles only when URP is installed. Without URP the rest of the package still works.
CelestialLightBinder expects two directional lights. Only one casts shadows at
a time and the handover uses a hysteresis band so it cannot flicker at the
horizon. Intensity fades as a body approaches the horizon, and a light at zero
intensity is disabled outright so it costs no shadow work.
Moon intensity is scaled by illumination, so a new moon goes dark on its own.
CelestialDebugBehaviour draws the sky in the scene view: the horizon dome with
elevation rings, N/E/S/W ticks, the full sun and moon arcs for the day, and
markers for where both bodies are right now. Arc segments below the horizon are
dimmed. Its inspector shows the same readout as the handler — time, altitude,
azimuth, illumination, sky phase and the active light rotation.
Drawing is editor only, so the component costs nothing in a build. Always Draw
keeps the gizmo visible when the object is not selected; horizon, arcs and body
markers can each be turned off, and Dome Radius scales the whole thing to the
scene.
The Sky Rig menu item adds one automatically. It reads from any
ICelestialSource, so the container path can use it too — see below.
CelestialScheduler runs actions at a time of day, at a sky event, across a
range, or on an interval. In the inspector each entry pairs a trigger with a
UnityEvent, so anything reachable from there works — SetActive, enabled
flags, your own methods.
The same schedules can be built from code:
var scheduler = GetComponent<CelestialScheduler>();
scheduler.At(19, 42, () => lamps.SetActive(true));
scheduler.On(SkyEvent.Sunrise, () => lamps.SetActive(false));
scheduler.Every(ScheduleInterval.EveryHour, ChimeBell);
scheduler.Between(
new TimeOfDay(19, 0), new TimeOfDay(6, 0),
entered: () => streetLights.SetActive(true),
exited: () => streetLights.SetActive(false));Each call returns the schedule, so it can be adjusted or removed later:
var curfew = scheduler.At(23, 0, LockGates);
curfew.Once = true;
scheduler.Find("19:42").Enabled = false;
scheduler.Remove(curfew);Adding or removing schedules from inside a callback is safe.
Triggers are matched against the span of time covered since the previous frame, not against the current instant, so nothing is missed when the clock runs fast.
WorldClock exposes a static Active property holding the first clock that
enabled itself. Leave a scheduler's clock field empty to use it. The events
themselves stay per-instance, so several clocks can coexist.
SetProgress and SetTime take a mode:
clock.SetProgress(0.27f); // Resync (default)
clock.SetProgress(0.27f, TimeChangeMode.Replay); // walk through the spanResync moves the clock and realigns every listener without pretending time
passed — no hour events, no range enter/exit for a transition that never
happened. It raises Resynced so schedules can re-evaluate which range they
sit in. Use it when another system owns the time.
Replay treats the jump as elapsed time: boundary events fire, ranges
transition, day counters advance. Use it for sleep or fast-forward mechanics.
Both work while paused, so a save-load or cutscene can set the time and resume cleanly.
The package works without VContainer. When the package is present an extra assembly compiles in, and the same core classes can be used without a single MonoBehaviour.
CelestiaInstaller is serializable, so it carries its own configuration. A
scope only needs one field, and a shared scope stays free of Celestia specific
clutter.
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] private CelestiaInstaller m_Celestia;
protected override void Configure(IContainerBuilder builder)
{
m_Celestia.Install(builder);
}
}Then inject anywhere:
public class DayNightUI : IStartable
{
private readonly IWorldClock m_Clock;
private readonly IScheduleRunner m_Schedules;
[Inject]
public DayNightUI(IWorldClock clock, IScheduleRunner schedules)
{
m_Clock = clock;
m_Schedules = schedules;
}
void IStartable.Start()
{
m_Schedules.On(SkyEvent.Sunset, () => Debug.Log("dusk"));
}
}The installer holds a CelestiaConfig plus a CelestialLights pair. Lights
live on the installer rather than the config because a ScriptableObject cannot
reference scene objects — the config asset is shared project data, while the
lights belong to one scene.
Leave both light fields empty and the installer creates a directional light pair at runtime, destroying only what it created when the scope is disposed. Assign them and the scene lights are used untouched. Intensity, color and shadows are always written from the preset, so whatever those lights carry in the scene is overwritten on the first frame.
CelestiaConfig is a ScriptableObject holding the preset, clock speed and the
light driving flags — everything that is safe to share between scenes. Its
Dont Destroy Generated Lights option is off by default; turn it on only when
the scope itself outlives the scene, otherwise the generated lights would
survive into the next scene and fight its own lighting.
The registered services are IWorldClock, ICelestialSource,
IScheduleRunner and ICelestiaLightProvider, plus a CelestiaRuntime entry
point that ticks the clock through VContainer's player loop.
Turn on Create Debug View and the installer spawns a [Celestia Debug] object
at runtime, bound to the container's celestial source, and destroys it when the
scope is disposed. It gives the container path the same scene view readout the
component path gets from its rig.
CelestiaPostProcessInstaller adds the post processing rig. It carries the
Volume for the same reason the main installer carries the lights: a Volume is
a scene object, so it cannot come from a shared asset. Install it after
CelestiaInstaller, which registers the ICelestialSource it drives from.
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] private CelestiaInstaller m_Celestia;
[SerializeField] private CelestiaPostProcessInstaller m_CelestiaPostProcess;
protected override void Configure(IContainerBuilder builder)
{
m_Celestia.Install(builder);
m_CelestiaPostProcess.Install(builder);
}
}It registers CelestialPostProcessRig plus a CelestiaPostProcessRuntime
entry point that binds it. The bridge sits in its own assembly and compiles
only when URP and VContainer are both present.
- Unity 6000.3 or newer
- Universal Render Pipeline — optional, only needed for post processing
- VContainer — optional, only needed for the DI integration
MIT — see LICENSE.md.

