Skip to main content

Sky, Sun and Shadows

One directional light and one shader sky, shared by the ground, the terrain, the buildings and the fog — so the map, the horizon and the haze all agree on where the sun is.

Sky, fog and the day cycle, running live on the web SDK. The code →

The sun​

import com.massifmaps.components.LightOptions

val light = LightOptions().apply {
sunAzimuth = 315f // degrees clockwise from north; default 315 (NW)
sunAltitude = 45f // degrees above the horizon; default 45
sunIntensity = 1.0f
ambientIntensity = 0.35f
ambientColor = Color(0xFFB8C8E0.toInt()) // cool sky tint in the shadows; default white
isTerrainLightingEnabled = true // shade the terrain surface with N·L
}
mapView.options.lightOptions = light

Instead of setting the angles, ask for a real sun position — NOAA's low-accuracy solar formula, good to about 0.1°:

light.setSunPositionFromTime(2026, 8, 14, 7, 30, 45.188, 5.719) // UTC + lat/lon
PropertyDefaultNotes
SunAzimuth315Degrees clockwise from north. The classic cartographic light is NW.
SunAltitude45Degrees above the horizon, clamped -90..90.
SunColor / SunIntensitywhite / 1.0Direct light.
AmbientIntensity1.0Light in the shadow, and the brightness floor everywhere.
AmbientColorwhiteTint of that shadow light. A cool blue is what makes dusk read as sky-lit rather than just darker. Applies to the terrain surface and to 3D buildings alike.
TerrainLightingEnabledfalseShade the terrain surface from its geometric normal.
ShadowStrength1.00 = no shadows. Not the depth drawn: it is multiplied by the sun's share of the scene light (MapBox's calculateGroundShadowFactor), so 1 is their shadow exactly and shadows fade to nothing as the sun sets. Values above 1 exaggerate.
ShadowMapSize / ShadowCascades2048 / 2Cascaded shadow map, up to 4 cascades. Two is MapBox's own count and the slices are theirs. A third page is a nearer, sharper one, but it also makes each box smaller and the screen-space derivatives larger, which is what serrated a building's vertical edges.
ShadowBias / ShadowSoftness / ShadowDistance1.0 / 0.5 / 0ShadowBias is a UNITLESS scale on MapBox's bias (a constant plus a capped slope term, in normalised light depth), so 1 is theirs unchanged. ShadowSoftness is the shadow edge's blur radius in shadow-map texels, the same for the ground, buildings and 3D models; 0 is a hard edge and the cheapest lookup. ShadowDistance 0 = derived from the view.
ShadowFollowsBuildingHeightfalseBuilding shadows keep the full height the buildings have, even while a style lays them down between tilt 80 and 90 (building-height-view-scale), which reads true from straight above. On, they shrink with the drawn buildings. The zoom ramp (building-height-scale) always moves them.
ShadowCasterMargin3Ring of off-screen tiles that may still cast into the view.
TerrainShadowCastingEnabledtrueWhether the relief casts shadows. Off, buildings and models still cast onto the ground, and the terrain's caster draws and the mountain-sized caster ring are skipped: for a flat or straight-down 3D view, where the relief has nothing to throw. A terrain with exaggeration 0 never casts.
Shadows with and without terrain

Buildings and 3D models cast shadows, and receive them, on a plain 2D map as well as on 3D terrain, and on the globe. It takes TerrainLightingEnabled and a ShadowStrength above 0, nothing else: no TerrainOptions is needed. On a flat map the ground takes the shadow as one screen-space mask, the buildings and models sample the shadow map themselves, and a 2D label standing in a shadow is dimmed with the ground under it. 3D models are not drawn on the globe yet, so none casts there. How each state of the map treats shadows is in 2D, 3D and everything between.

With the surface API​

light, sky and fog are each a spec and a property path, so the whole day cycle is writable from a slider without touching an options class:

map.light(Spec.of("light")
.set("sunAzimuth", 315)
.set("sunAltitude", 45)
.set("ambientColor", 0xffb8c8e0)
.set("terrainLightingEnabled", true));

map.set("light.sunAltitude", 12); // one path, one write
map.sky().apply(Spec.object()
.set("type", "SKY_TYPE_ATMOSPHERE")
.set("quality", "SKY_QUALITY_HIGH"));

Every property of all four options classes: the options reference. Runnable: the atmosphere example.

Two details worth knowing about the shadow model: the shadow pass floors the sun altitude at 15° (a 9° sun throws a 4.4 km shadow off a 700 m hill and the cascade ladder goes useless), while N·L lighting keeps the true sun; and the shadow map is snapped and cached, so a still camera re-renders nothing.

The sky​

SkyOptions draws a single full-screen pass before everything else — one quad whatever the camera does.

import com.massifmaps.components.SkyOptions
import com.massifmaps.graphics.Color

val sky = SkyOptions().apply {
isEnabled = true
type = SkyType.SKY_TYPE_ATMOSPHERE // the default: Rayleigh + Mie scattering
atmosphereSunIntensity = 10f
isSunDiscEnabled = true
}
mapView.options.skyOptions = sky

By default the sky is a physical atmosphere: Rayleigh and Mie single scattering integrated along each view ray, so the blue zenith, the reddening at a low sun and the halo around it come out of the model rather than out of a colour ramp. Move LightOptions.sunAltitude through the day and the sky follows on its own.

SKY_TYPE_GRADIENT is the older two-colour ramp — pick it for a flat, stylised or brand-coloured sky, or when the scattering costs too much on a target device.

PropertyDefaultNotes
EnabledtrueOff → the legacy sky bitmap band (Options.setSkyColor) is drawn instead.
TypeSKY_TYPE_ATMOSPHERESKY_TYPE_GRADIENT for the two-colour ramp, which ignores every Atmosphere* property.
QualitySKY_QUALITY_MEDIUMScattering samples: LOW 5×3, MEDIUM 8×4, HIGH 12×5. The cost is per fragment of visible sky, so a low tilt is what pays for it.
AtmosphereSunIntensity10Mapbox sky-atmosphere-sun-intensity. Brightens the whole sky, not only the disc.
AtmosphereColor / HaloColoropaque whiteTints on the Rayleigh and Mie terms (sky-atmosphere-color / -halo-color). Alpha scales the term, so a lower alpha thins the atmosphere.
AtmosphereLuminance1Exposure. Lower brightens the sky — what a night or a heavily tinted atmosphere needs.
SkyColor / HorizonColorblue / pale blueZenith and horizon of the GRADIENT type.
GroundColorunset (the sky's horizon)What a ray below the mathematical horizon sees where no map is drawn. Left unset it continues the sky's own horizon colour, so there is no band; set, it is used as is (see below). Transparent leaves the clear color.
HorizonBlend12Degrees of blend between horizon and sky, GRADIENT only.
SunDiscEnabledtrueDraw the sun disc and its glow. Both types.
ShaderSource—Replace the whole appearance (below).

No band under the sky​

A ray below the mathematical horizon never meets sky. It sees the map, or - past the view distance, or where a tile has not loaded yet - nothing. That gap used to be filled with a fixed GroundColor (a light blue). Against a scattered or fogged horizon of any other colour (grey-brown at dusk, a white haze) it showed as a flat band between the sky gradient and the far hills.

GroundColor is now unset by default: such a ray takes the colour of the sky a hair above the horizon (for ATMOSPHERE the scattered horizon, for GRADIENT the HorizonColor), with the fog applied on top as everywhere else, so the sky simply continues down behind the far terrain. The sun disc is not repeated below the horizon. Nothing to configure, in 3D terrain or a panorama, with the fog on or off.

Calling setGroundColor makes it explicit and the old behaviour applies: that exact colour fills the gap. Do that only for a deliberately flat floor, and then match it to the fog colour (or the horizon colour with fog off) or the band comes back. getGroundColor reports the horizon colour while it is unset. There is no way back to unset on an existing SkyOptions; create a new one.

A style sets the sky from its Map block too — sky-type (0 gradient, 1 atmosphere), sky-atmosphere-sun-intensity, sky-atmosphere-color, sky-atmosphere-halo-color, sky-atmosphere-luminance — and the style wins where it has an opinion.

A custom sky shader​

setShaderSource takes GLSL ES 1.00 that defines one function:

vec4 skyColor(vec3 rayDir) { // rayDir: world-space view ray, x east, y north, z up
float t = clamp(rayDir.z, 0.0, 1.0);
vec4 color = mix(u_horizonColor, u_skyColor, t);
return sunDisc(color, rayDir); // the built-in disc and glow, if you want them
}

Do not fog it yourself. The SDK applies the same haze to whatever skyColor returns as it applies to the ground, once, so the two still meet at the skyline — and a custom fog shader reaches a custom sky too.

Below the horizon the wrapper hands your skyColor a ray already tipped up to just above the horizon (azimuth kept) whenever GroundColor is unset, so the sky continues down on its own and a if (rayDir.z < 0.0) return groundBelowHorizon(rayDir); branch is never taken. Only when the app set GroundColor do you see the true ray, and groundBelowHorizon returns that colour. sunDisc draws nothing for a tipped ray; a sun you draw yourself would repeat down it as a stripe, so keep it to rayDir.z > 0.002.

The wrapper already declares the uniforms and the helpers atmosphereTint, starAmount, sunDisc, groundBelowHorizon (plus atmosphere and tonemap under SKY_TYPE_ATMOSPHERE) — redeclaring any of them is a compile error and the renderer silently falls back to the built-in sky, which is the usual reason a custom sky "does nothing".

UniformMeaning
vec3 u_sunDirunit vector towards the sun, world space
vec4 u_sunColor, float u_sunIntensityfrom LightOptions
vec4 u_skyColor, u_horizonColor, u_groundColorthe configured colours; u_groundColor is the horizon colour while the ground colour is unset
float u_groundAuto1 while GroundColor is unset (rays below the horizon arrive tipped up), 0 once set
sampler2D u_belowLUT, float u_bakeWidthreserved by the renderer, which bakes the tipped rays into a texture; do not declare or read them
float u_horizonBlend, u_sunDiscthe configured blend / sun-disc switch
vec4 u_atmosphere, u_atmosphereColor, u_haloColorscattering sun intensity and exposure, and the two tints
float u_starIntensitythe star field's brightness
the fog blockuFogColor, uFogHighColor, uFogSpaceColor, uFogParams, uFogVertical, uFogRay and the helpers — see the fog section
float u_time, u_zoom, u_cameraHeightseconds since the view was created, fractional zoom, metres
vec2 u_resolutionviewport size in pixels

Fog (the atmosphere)​

FogOptions is modelled on the Mapbox fog style property, so a value tuned for a Mapbox style transfers directly. It is resolved once per frame from the options and the style's Map block, lit by the same sun (dark at night, warm at a low sun), and handed to the tile content, the background plane and the sky. That single resolution point is why the ground and the horizon match.

Fog does not need 3D terrain: it fogs a plain 2D map, and it fogs the background plane past the loaded tiles, which is what fills the far distance.

val fog = FogOptions().apply {
color = Color(0xFFDC9F9F.toInt()) // alpha = how opaque the fog gets; transparent = no fog
rangeStart = 0.8f // multiples of the camera-to-focus distance
rangeEnd = 8.0f // where it reaches full strength
highColor = Color(0xFF245BDE.toInt())
spaceColor = Color(0xFF000000.toInt())
horizonBlend = 0.5f
starIntensity = 0.15f
}
mapView.options.fogOptions = fog
terrain.viewDistanceFactor = 1.0f // where the ground ends — pair it with fog
PropertyDefaultMapboxNotes
Enabledtrue—The switch. Off keeps every value configured, so a UI toggle never has to drive a colour or a range through zero. It stops the haze only: HighColor, SpaceColor and StarIntensity are the sky's, and survive it.
ColortransparentcolorAlpha = strength at full distance. Transparent = no fog.
RangeStart / RangeEnd0.8 / 8rangeMultiples of the camera-to-focus distance, not metres. That distance is a function of the zoom alone, so one setting holds at every zoom. Under FREE_ROAM_MODE_FIRST_PERSON the unit is the view distance, as the focus moves with the look: 1 is fully fogged at the far cut.
HighColortransparenthigh-colorThe lit upper atmosphere. Transparent leaves the SkyOptions gradient alone.
SpaceColortransparentspace-colorThe zenith, beyond the atmosphere.
HorizonBlend0.133horizon-blendHow far up the sky the haze reaches: exp(-3 · (sin θ / blend)²). The ground is scaled by the same factor, which is why haze and sky meet along the skyline with no seam at any tilt.
VerticalRangeStart / VerticalRangeEnd0 / 0vertical-rangeAltitudes in metres the fog fades out between, so summits stand clear of a haze filling the valley. Equal values disable it.
StarIntensity0star-intensityDrawn by the built-in sky shader only.
ShaderSource——Replace the blend (below).

A style sets the same from its Map block — fog-enabled, fog-color, fog-range-start, fog-range-end, fog-high-color, fog-space-color, fog-horizon-blend, fog-vertical-range-start, fog-vertical-range-end, fog-star-intensity — and the style wins where it has an opinion. Only the style can make any of them zoom-dependent:

Map {
fog-color: #dc9f9f;
fog-range-start: 0.8;
fog-range-end: linear([view::zoom], (11, 8), (15, 4));
fog-high-color: #245bde;
fog-space-color: #000000;
fog-horizon-blend: 0.5;
fog-vertical-range-start: 1800;
fog-vertical-range-end: 3200;
fog-star-intensity: 0.15;
sky-atmosphere-sun-intensity: 12;
sky-atmosphere-luminance: linear([view::zoom], (8, 1.0), (14, 1.6));
}

Every Map block setting on this page survives compilation to Mapnik XML: css2xml writes them as attributes of the <Map> element under the same names, and only the ones the style actually set — an absent attribute is what tells the SDK the application's own LightOptions / FogOptions value stands.

ViewDistanceFactor ends the ground (tangram's rule: 2 × camera height / cos(pitch + fovy/2), capped at 127 tile widths; 1 = their rule verbatim). Without fog it ends on a hard edge.

A custom fog shader​

FogOptions.setShaderSource takes GLSL ES 1.00 defining three functions, and it is compiled into every pass that fogs — the tile content and its labels, the background plane, the terrain surface, the sky, the celestial objects and the vector elements — so one source covers the whole frame in 2D and in 3D:

// The ground and anything drawn in the scene. color is PREMULTIPLIED, dir is the normalized
// world-space view ray (x east, y north, z up), dist is in the same units as the range, and
// heightM is the fragment's altitude in metres.
vec4 applyFog(vec4 color, vec3 dir, float dist, float heightM) {
float amount = fogOpacity(fogRange(dist)) * fogHorizonBlend(dir);
amount *= 1.0 - fogVertical(heightM);
vec3 tint = mix(uFogColor.rgb, uFogHighColor.rgb, clamp(dist * uFogParams.y, 0.0, 1.0));
return vec4(mix(color.rgb, tint * color.a, amount), color.a);
}

// The sky and anything at infinity: no distance, only the angle.
vec4 skyFog(vec4 color, vec3 dir) {
return vec4(mix(color.rgb, uFogColor.rgb * color.a, uFogColor.a * fogHorizonBlend(dir)), color.a);
}

// What a label's alpha is multiplied by. Return 1 to never hide one.
float fogLabelFade() {
return 1.0 - smoothstep(0.9, 1.0, fogOpacity(fogRange(length(fogRayVec()) / gl_FragCoord.w * uFogParams.z)));
}
UniformMeaning
vec4 uFogColorthe resolved fog colour, already lit by the sun
vec4 uFogHighColor, uFogSpaceColorthe atmosphere colours
vec4 uFogParamsrange start, 1 / (end - start), internal → range units, horizon blend
vec4 uFogVerticalvertical range start and end in metres, metres per internal unit, camera height in metres
mat3 uFogRaythe view ray basis — fogRayVec() is what reads it

These helpers are always declared too, so a custom source can build on the model instead of restating it — or ignore them and compute its own:

HelperMeaning
vec3 fogRayVec()the unnormalized world-space ray through this fragment
float fogRange(float dist)the distance remapped to 0 at the start of the range and 1 at the end
float fogOpacity(float t)the distance ramp, already scaled by the colour's alpha
float fogHorizonBlend(vec3 dir)the angular term — 1 below the horizon, so it is a no-op on the ground
float fogVertical(float heightM)how much of the fog this altitude escapes

Why applyFog multiplies by fogHorizonBlend too: that is what makes the ground and the sky one model. Below the horizon the term is exactly 1, so distant ground is fogged by distance alone; a ridge standing above the horizon takes exactly what the sky just above it takes; and the two meet along the skyline with no seam at any tilt or zoom.

A source that fails to compile falls back to the built-in blend and logs the error. Changing it rebuilds every shader program, so set it once rather than per frame.

For an effect that needs the whole composited frame instead — including the layers drawn above the ground — see Post-processing.

See also​