Use this guide to drive your own 2D or 3D lighting from a GameClock.
It complements Time-of-day lighting rigs.
That guide explains the prebuilt demo rigs. This one shows how to copy them,
which setting controls what, and how to update a custom visual from the
clock's own signals.
The core rule for every example below: derive the visual from the current clock instant. Do not keep a separate transition timer or animation state. Read the clock, compute the look, apply it. Then the same clock time always produces the same lighting, no matter how fast the clock ran to get there.
Resource-driven 2D example
The demo's 2D rig is a plain Node you can copy and drop into any scene.
Copy these files from
demo/lighting_rigs/into your project:time_of_day_lighting_rig_2d.gdtime_of_day_lighting_rig_binder.gdtime_of_day_directional_light_2d.gdtime_of_day_light_settings_group.gd
Build this minimal scene tree:
World (Node2D) ├── TimeOfDayAmbience (CanvasModulate) # global brightness + tint ├── TimeOfDayDirectionalLight2D (DirectionalLight2D) # supplementary sun/moon ├── WorldEnvironment (WorldEnvironment) # optional exposure/saturation/contrast └── LightingRig2D (Node, script: time_of_day_lighting_rig_2d.gd)Assign
clock,canvas_modulate,directional_light, andlighting_settings(aTimeOfDayLightingSettingsGroup2D).world_environmentis optional.
2D settings field map
Each time of day gets one TimeOfDayLightingSettings2D entry.
The rig blits between the previous and current entries across a transition.
Settings: ambient_color (Color → CanvasModulate.color),
exposure_multiplier (float → Environment.tonemap_exposure),
saturation (float → Environment.adjustment_saturation),
contrast (float → Environment.adjustment_contrast).
The directional light reads a separate TimeOfDayLightSettingsGroup.
Each time of day has one TimeOfDayLightSettings.
Settings: light_color (Color → DirectionalLight2D.color),
energy (float → DirectionalLight2D.energy),
height (float → DirectionalLight2D.height).
CanvasModulate and 2D lights combine — don't double-tint
CanvasModulate multiplies every pixel in the canvas; DirectionalLight2D
and PointLight2D add light on top of that result. Because they stack, a
saturated color applied strongly in both layers compounds into an oversaturated
frame. Pick one layer to carry the tint and keep the other near-neutral:
- Let
ambient_color(CanvasModulate) carry the tint. - Keep
light_colornear white. Useenergyfor supplementary light (night:0.08, day:0.45). - Use
PointLight2Dfor warm local pools, not a second global tint.
Simple script example
If you only need to update one custom visual, connect to the clock's signals
instead of copying a rig. The signal bus provides the current TimeOfDay and
transition progress. So you never recompute which period it is.
The example below is lifecycle-safe. It syncs from the current clock instant
in _ready(). It disconnects the previous progress before tracking a new one.
It tears down every connection in _exit_tree().
extends Node
## Fades a custom overlay using the clock's own time-of-day signals.
## Lifecycle-safe: synchronizes from the current clock instant in _ready(),
## disconnects the previous transition progress before tracking a new one, and
## tears down every connection in _exit_tree(). No day/night math here —
## Calendar Time provides the TimeOfDay and the transition ratio.
@export var clock:GameClock
@export var overlay:CanvasModulate
var _day_night:DayNightCycleService
var _from_color:Color
var _to_color:Color
var _progress:GameTimeProgress
func _ready()-> void:
if clock== null:
return
_day_night= DayNightCycleService.new(clock)
clock.signal_bus.time_of_day_changed.connect(_on_time_of_day_changed)
clock.signal_bus.day_night_transition_progress_changed.connect(_on_progress_changed)
_sync_from_clock()
func _exit_tree()-> void:
if clock!= null:
if clock.signal_bus.time_of_day_changed.is_connected(_on_time_of_day_changed):
clock.signal_bus.time_of_day_changed.disconnect(_on_time_of_day_changed)
if clock.signal_bus.day_night_transition_progress_changed.is_connected(_on_progress_changed):
clock.signal_bus.day_night_transition_progress_changed.disconnect(_on_progress_changed)
_clear_progress()
if _day_night!= null:
_day_night.reset()
func _sync_from_clock()-> void:
# Initialize from the current clock instant so the visual is correct before
# the next time-of-day transition fires (the signals only fire on change).
var tod:= _day_night.calculate_time_of_day(clock.date_time())
if tod== null:
return
var last:= _day_night.get_previous_time_of_day(tod)
_from_color= last.tint_colorif last!= null else tod.tint_color
_to_color= tod.tint_color
overlay.color= _to_color
func _on_time_of_day_changed(tod:TimeOfDay, last_tod:TimeOfDay)-> void:
# The signal supplies both TimeOfDay resources. Read their authored
# tint_color instead of working out which period is active. Record the
# blend endpoints; the transition progress (below) does the lerping.
_from_color= last_tod.tint_colorif last_tod!= null else tod.tint_color
_to_color= tod.tint_color
overlay.color= _from_color
func _on_progress_changed(progress:GameTimeProgress)-> void:
_clear_progress()# disconnect the old progress before tracking the new one
_progress= progress
if _progress!= null and not _progress.updated.is_connected(_on_updated):
_progress.updated.connect(_on_updated)
func _clear_progress()-> void:
if _progress!= null and _progress.updated.is_connected(_on_updated):
_progress.updated.disconnect(_on_updated)
_progress= null
func _on_updated(ratio:float)-> void:
# ratio runs 0..1 across the transition; lerp for a smooth crossfade.
overlay.color= _from_color.lerp(_to_color, ratio)Useful signals on clock.signal_bus:
time_of_day_changed(tod, last_tod)— TOD changes → bothTimeOfDayresources.day_night_transition_progress_changed(progress)— transition starts → aGameTimeProgresswhoseupdated(ratio)emits 0..1 each tick.date_time_changed(new, old)— every clock tick → current and previousDateTime.
3D example
The demo's 3D rig binds a GameClock to a DirectionalLight3D and a
WorldEnvironment. Copy it into your project and assign:
clock,directional_light,world_environment, andlighting_settings
The rig subscribes to clock.signal_bus.date_time_changed every tick.
It recomputes the look from clock.date_time(): finds the current and next
TOD, blends their settings by the transition ratio, and writes the environment.
No stored transition state.
3D settings field map
Each time of day gets one TimeOfDayLightingSettings3D entry.
background_color (Color → Environment.background_color),
ambient_light_color (Color → Environment.ambient_light_color).
fog_light_color (Color → Environment.fog_light_color),
fog_density (float → Environment.fog_density).
Sun direction comes from the clock, not the resource
The 3D rig computes the sun's pitch and yaw from the fractional hour of day.
The solar_noon_hour and sun_azimuth_span settings shape the arc.
The authored sun_rotation_x_degrees /
sun_rotation_y_degrees values are ignored at runtime.
This is what makes the lighting clock-driven. Shadows sweep as the clock
advances. Reaching 13:00 at any clock speed produces the identical sun angle.
If you write your own 3D binding, recompute from clock.date_time() on
date_time_changed rather than advancing a separate animation value.