Skip to main content

Lighting a map by the hour

One palette, no night theme. The scene light is read off a curve, and every colour on the map — 2D fills and lines, labels and their halos, the map background, 3D walls and roofs — is derived from it. Move the sun and the whole map follows, with no re-decode: the tiles are untouched, so it is a redraw.

This page is the two halves of that: what a style has to say for its colours to be lit at all, and how an app replaces the curve that decides what the light is at a given hour.

Light the map by the hour, running live on the web SDK. The code →

The short version​

map.light(Spec.of("light")
.set("dayCycleLightsEnabled", true) // the sun's height drives the light
.set("sunOverridingStyle", true) // the app's sun beats the style's
.set("terrainLightingEnabled", true) // needed for cast shadows - see below
.set("shadowStrength", 1)); // 1 = MapBox's depth; it fades itself out at dusk

// The curve is anchored on the sun's HEIGHT, so that is what to drive.
map.light().apply(Spec.object()
.set("sunAltitude", 10) // dusk
.set("sunAzimuth", 270)); // setting; 90 would make the same height dawn
Drive the height, not the hour

An hour is one step further away — it has to be turned into a height first, and a plausible day cycle crosses the twilight band (3° to 12°) in about 33 minutes. On a 0–24 slider that is 2.3% of the travel, so dawn and dusk are unreachable by dragging even though the curve passes exactly through them. Setting sunAltitude directly lands on every preset:

MapBox presetsunAltitudesunAzimuth
dawn4090 (east — rising)
day70any
dusk10270 (west — setting)
night−30any

Nothing but the direction of travel separates dawn from dusk at the same height, and the SDK reads that from the sun's easting.

With dayCycleLightsEnabled off nothing is derived and the style's own values stand, which is what every map did before this existed.

Half one: the style says how much light each colour takes​

MapBox's model, and the SDK's: *-emissive-strength is how much of a colour is emitted rather than lit.

shown = authored × (emissive + (1 − emissive) × radiance)

radiance is what the scene light does to a flat, upward-facing surface — a per-channel value, so a light's colour moves the hue and not only the brightness. At emissive 1 a colour is drawn as authored under any light; at 0 the scene owns it entirely.

PropertyDefaultApplies to
polygon-emissive-strength, line-emissive-strength1fills and lines (and their -pattern- forms)
text-emissive-strength, shield-…, marker-…1the label's ink
text-halo-emissive-strength, shield-…, marker-…the label's ownthe halo alone
building-emissive-strength (Map)03D extrusions
background-emissive-strength (Map)1the map background

Every default is 1 except the buildings, so a style that says nothing renders exactly as it did before any of this existed. A style that wants to be lit has to ask:

Map { background-color: #f4f1ec; background-emissive-strength: 0; }
#water { polygon-fill: #8fb8d8; polygon-emissive-strength: 0; }
#road { line-color: #ffffff; line-emissive-strength: 0.25; }
/* Bright ink, dark outline: the name stays readable while the map goes down around it. */
#poi { text-fill: #ffffff; text-emissive-strength: 0.8; text-halo-emissive-strength: 0; }
Why a road usually wants 0.15–0.3 rather than 0

MapBox defaults geometry to 0, and Standard then states a higher value on nearly everything it wants to hold up at night. A style with no emissive anywhere takes the 0 everywhere and collapses to a few per cent of its colour the moment the sun is down — far darker than Standard at the same hour. That is not the SDK being wrong; it is a style with no light model being lit as if it had one. The converter's --geometry-emissive puts a floor under it.

view::brightness, for a colour that should change rather than dim​

The grade only ever darkens — radiance is at most 1. MapBox Standard's labels do not get darker at night, they get lighter: it ramps text-color over ["measure-light", "brightness"].

The SDK exposes the same scalar as a view variable, so a style can do it too:

#poi_label {
text-fill: linear([view::brightness], (0.05, #ffffff), (0.4, #333333));
text-halo-emissive-strength: 0;
}

view::brightness is MapBox's own calculateLightsBrightness and lands on their numbers: day 0.478, dawn 0.396, dusk 0.027. It is resolved per frame, so a ramp over it follows the hour with no re-decode.

With the day cycle on, it reads the curve with each hold (two stops with the same light, like dusk at 3° and 12°) ramping on to the next light instead. A palette ramped over 0.25–0.3, as the Massif styles are, then flips about 8–9° above a setting sun rather than mid-afternoon at 26–28°. The light colours still hold; a curve without a hold reads as before.

Half two: the app replaces the curve​

LightOptions.dayCycleLightStops is a list of LightStops — a light anchored on a sun height. The SDK holds below the first and above the last, and smoothsteps between two in linear colour space.

Through the facade it is one JSON property. Each stop is an object, because five fields of three different kinds have no order anybody would guess:

[
{ "sunAltitude": -9, "ambientColor": "#464d69", "ambientIntensity": 0.5,
"sunColor": "#3f4455", "sunIntensity": 0.5 },
{ "sunAltitude": 3, "ambientColor": "#363e5e", "ambientIntensity": 0.8,
"sunColor": "#fec286", "sunIntensity": 0.2 },
{ "sunAltitude": 12, "ambientColor": "#363e5e", "ambientIntensity": 0.8,
"sunColor": "#fec286", "sunIntensity": 0.2 },
{ "sunAltitude": 38, "ambientColor": "#ffffff", "ambientIntensity": 0.8,
"sunColor": "#ffffff", "sunIntensity": 0.2 }
]

That is the built-in curve for a setting sun — MapBox Standard's four light setups at the sun heights it states them for. A rising sun gets Standard's dawn at the two twilight stops instead (ambient #ffecdc × 0.75, sun #feca8b × 0.5). Passing an empty list selects that built-in pair.

The night ambient is the one value that is not Standard's own (hsl(217,100%,11%)). Their night preset keeps a directional light 30° above the horizon whatever the hour — an artistic moon — and half of what their night ground receives comes from it. A real day cycle has the sun under the ground, so that half is dropped and only their very dark ambient is left: the ground came out at (0.00, 0.06, 0.16) against the (0.20, 0.22, 0.30) mapbox-gl draws, with no red at all. The moon's contribution is folded into the ambient instead, which lands on their number. The curve exists to reproduce what Standard renders, not what it states.

  • sunAltitude is required; a stop with no height has no place on a curve and is refused.
  • Colours read #rgb, #rgba, #rrggbb, #rrggbbaa (alpha last, as in a style sheet), or a plain ARGB number — the same spellings every colour property takes. Written back as #rrggbbaa.
  • The doubled twilight stop is deliberate: it holds the light flat from 3° to 12°, so the sun passes through dusk instead of crossing it.
  • dayCycleRisingLightStops gives a rising sun its own curve, which is how dawn differs from dusk at the same height. Left empty while dayCycleLightStops is set, that one curve is used all day.

Set it and everything downstream follows — the 2D grade, the sun and ambient the buildings and the terrain are lit with, and view::brightness. There is no second theme to keep in step.

Cast shadows​

shadowStrength alone is not enough: terrainLightingEnabled must be on too, because the shadow multiply lives in the same block that lights the ground. The renderer says what it decided in its log:

MapRenderer: shadows off (strength 1.00, requested map 2048 x 2 cascades, terrain lighting 1, cover tiles 0)
MapRenderer: shadows ACTIVE (strength 1.00, requested map 2048 x 2 cascades, terrain lighting 1, cover tiles 1)

Buildings and models cast on 3D terrain, on a plain 2D map and on the globe alike. A map with no terrain needs nothing else; a 3D one casts from the drape pass onto the terrain surface.

terrainLightingEnabled must also be on, because the shadow multiply lives in the same block that lights the ground. A style that says colors-prelit is not lit twice by this: that flag states the 2D colours already carry the light, so turning terrain lighting on only lets the shadows land.

Known gaps​

  • The grade only darkens. A colour that should get lighter at night needs a view::brightness ramp in the style, as above; a bare emissive cannot do it.
  • One curve drives everything. There is no separate curve for buildings. What a style can vary independently is building-ambient, building-light-intensity and building-emissive-strength.
  • A label in a shadow is dimmed. On a map with no terrain the ground's shadow is multiplied into the frame before the 3D pass, and 2D labels are already drawn by then.
  • A translucent extrusion has no path — see the rendering notes.