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.
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
| Property | Default | Notes |
|---|---|---|
SunAzimuth | 315 | Degrees clockwise from north. The classic cartographic light is NW. |
SunAltitude | 45 | Degrees above the horizon, clamped -90..90. |
SunColor / SunIntensity | white / 1.0 | Direct light. |
AmbientIntensity | 1.0 | Light in the shadow, and the brightness floor everywhere. |
AmbientColor | white | Tint 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. |
TerrainLightingEnabled | false | Shade the terrain surface from its geometric normal. |
ShadowStrength | 1.0 | 0 = 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 / ShadowCascades | 2048 / 2 | Cascaded 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 / ShadowDistance | 1.0 / 0.5 / 0 | ShadowBias 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. |
ShadowFollowsBuildingHeight | false | Building 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. |
ShadowCasterMargin | 3 | Ring of off-screen tiles that may still cast into the view. |
TerrainShadowCastingEnabled | true | Whether 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. |
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.
| Property | Default | Notes |
|---|---|---|
Enabled | true | Off → the legacy sky bitmap band (Options.setSkyColor) is drawn instead. |
Type | SKY_TYPE_ATMOSPHERE | SKY_TYPE_GRADIENT for the two-colour ramp, which ignores every Atmosphere* property. |
Quality | SKY_QUALITY_MEDIUM | Scattering 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. |
AtmosphereSunIntensity | 10 | Mapbox sky-atmosphere-sun-intensity. Brightens the whole sky, not only the disc. |
AtmosphereColor / HaloColor | opaque white | Tints on the Rayleigh and Mie terms (sky-atmosphere-color / -halo-color). Alpha scales the term, so a lower alpha thins the atmosphere. |
AtmosphereLuminance | 1 | Exposure. Lower brightens the sky — what a night or a heavily tinted atmosphere needs. |
SkyColor / HorizonColor | blue / pale blue | Zenith and horizon of the GRADIENT type. |
GroundColor | unset (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. |
HorizonBlend | 12 | Degrees of blend between horizon and sky, GRADIENT only. |
SunDiscEnabled | true | Draw 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".
| Uniform | Meaning |
|---|---|
vec3 u_sunDir | unit vector towards the sun, world space |
vec4 u_sunColor, float u_sunIntensity | from LightOptions |
vec4 u_skyColor, u_horizonColor, u_groundColor | the configured colours; u_groundColor is the horizon colour while the ground colour is unset |
float u_groundAuto | 1 while GroundColor is unset (rays below the horizon arrive tipped up), 0 once set |
sampler2D u_belowLUT, float u_bakeWidth | reserved by the renderer, which bakes the tipped rays into a texture; do not declare or read them |
float u_horizonBlend, u_sunDisc | the configured blend / sun-disc switch |
vec4 u_atmosphere, u_atmosphereColor, u_haloColor | scattering sun intensity and exposure, and the two tints |
float u_starIntensity | the star field's brightness |
| the fog block | uFogColor, uFogHighColor, uFogSpaceColor, uFogParams, uFogVertical, uFogRay and the helpers — see the fog section |
float u_time, u_zoom, u_cameraHeight | seconds since the view was created, fractional zoom, metres |
vec2 u_resolution | viewport 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
| Property | Default | Mapbox | Notes |
|---|---|---|---|
Enabled | true | — | 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. |
Color | transparent | color | Alpha = strength at full distance. Transparent = no fog. |
RangeStart / RangeEnd | 0.8 / 8 | range | Multiples 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. |
HighColor | transparent | high-color | The lit upper atmosphere. Transparent leaves the SkyOptions gradient alone. |
SpaceColor | transparent | space-color | The zenith, beyond the atmosphere. |
HorizonBlend | 0.133 | horizon-blend | How 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 / VerticalRangeEnd | 0 / 0 | vertical-range | Altitudes in metres the fog fades out between, so summits stand clear of a haze filling the valley. Equal values disable it. |
StarIntensity | 0 | star-intensity | Drawn 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)));
}
| Uniform | Meaning |
|---|---|
vec4 uFogColor | the resolved fog colour, already lit by the sun |
vec4 uFogHighColor, uFogSpaceColor | the atmosphere colours |
vec4 uFogParams | range start, 1 / (end - start), internal → range units, horizon blend |
vec4 uFogVertical | vertical range start and end in metres, metres per internal unit, camera height in metres |
mat3 uFogRay | the 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:
| Helper | Meaning |
|---|---|
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
- 3D Terrain — the surface all of this lights.
- Objects in the Sky — a sun disc, a moon, stars, an aircraft.
- Post-processing Effects — full-screen shaders over the lit scene.