Skip to content

Calendar Time v2.1.0

Connect Custom Lighting to the Clock

Use this guide to drive your own 2D or 3D lighting from a GameClock. It complements [Time-of-day lighting rigs](time-of-day-lighting-rigs.md). That guide explains the prebuilt demo rigs. This one show

Status
Current
Version
v2.1.0
Source updated
2026-08-09
Generated on
2026-09-06

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.

  1. Copy these files from demo/lighting_rigs/ into your project:

    • time_of_day_lighting_rig_2d.gd
    • time_of_day_lighting_rig_binder.gd
    • time_of_day_directional_light_2d.gd
    • time_of_day_light_settings_group.gd
  2. 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)
  3. Assign clock, canvas_modulate, directional_light, and lighting_settings (a TimeOfDayLightingSettingsGroup2D). world_environment is 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_color near white. Use energy for supplementary light (night: 0.08, day: 0.45).
  • Use PointLight2D for 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 → both TimeOfDay resources.
  • day_night_transition_progress_changed(progress) — transition starts → a GameTimeProgress whose updated(ratio) emits 0..1 each tick.
  • date_time_changed(new, old) — every clock tick → current and previous DateTime.

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, and lighting_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.