Skip to Content
ExamplesCustom Lightning Shader

MapsGL Android - Custom lightning symbols using a shader

This example customizes the appearance of lightning symbols with a custom fragment shader on IconPaint.shader. Each strike draws procedurally — there is no sprite — and a noise function gates when it flashes, animated and varying with the age of the strike.

It is the Android port of the MapsGL JS Custom lightning symbols using a shader example. The complete activity ships in the MapsGL Android SDK demo  as CustomLightningShaderActivity.

Lightning strikes over the Caribbean drawn as white flashes of varying brightness on a dark basemap

Age drives everything

The only paint expression is the factor, straight from the JS example:

// JS: factor: ['/', ['-', 200, ['get', 'age']], 200] paint.icon.factor = StyleValue.Expression( Expression.divide( Expression.subtract(200.0, Expression.get("age")), 200.0, ), )

A fresh strike is near 1.0; a 200-second-old one reaches 0.0. Inside the shader v_factor appears four times — flash duration, flash frequency, flash intensity, and overall alpha — so a recent strike flashes often and brightly while an old one barely registers. That single expression is what makes the layer feel alive, and the demo screen has a toggle that pins it high so you can see what it is doing.

age is a real property on these features: the built-in LightningStrikes product colours its circles with Expression.step(Expression.get("age"), …), so the same value is available here.

Size is a plain constant

The JS size: { width: 60, height: 60 } is constant and square, so iconSize carries it directly — no need for the per-axis iconWidth / iconHeight that the fires example requires:

paint.icon.iconSize = IconSize(60f, 60f)

The basemap is not decoration

The JS example loads dark-v9. That matters: the flash colour is vec4(235, 241, 245, 255) / 255 — very nearly white — so on the default light style there is almost nothing to see.

Loading a style replaces it wholesale, which discards a custom layer added beforehand, so add the layer inside the load callback:

controller.mapView.mapboxMap.loadStyle(Style.DARK) { controller.addWeatherLayer(config) }

Straight alpha

Symbol layers composite with straight alpha. Write fragColor with that alpha and leave out gl_FragColor.rgb *= gl_FragColor.a. Where two flashes overlap, the nearer fragment is the one that shows.

The flash uses rand, noise, and FLASH_POWER.

Watch the layer id

WeatherService.LightningStrikes pairs this source with a circle layer, and WeatherService.LightningStrikesIcons gives its symbols a sprite — which would turn v_tex into atlas coordinates and break the distance maths. This layer uses its own id and sets no icon image:

override val layer: SymbolLayerDescriptor = SymbolLayerDescriptor( id = "severe.lightning.flash", source = source.id, paint = SymbolLayerPaint(icon = IconPaint(), text = emptyList()), )

The ported shader

#version 310 es precision highp float; uniform float u_time; in vec2 v_tex; in vec4 v_color; in float v_factor; out vec4 fragColor; float rand(float x) { return fract(sin(x) * 75154.32912); } float noise(float x) { float i = floor(x); float a = rand(i), b = rand(i + 1.0); float f = x - i; return mix(a, b, f); } #define COL1 vec4(0, 0, 0, 0) / 255.0 #define COL2 vec4(235, 241, 245, 255) / 255.0 #define FLASH_POWER 0.8 void main() { // Radially symmetric about the quad centre, so v_tex needs no Y flip here. vec2 pos = v_tex; float dist = length(2.0 * pos - 1.0) * 2.0; float x = u_time + 0.1; float m = 0.2 + 0.2 * v_factor; // max duration of strike float i = floor(x / m); float f = x / m - i; float k = v_factor; // frequency of strikes float n = noise(i); float t = ceil(n - k); // occurrence float d = max(0.0, n - k) / (1.0 - k); // duration float o = ceil(t - f - (1.0 - d)); // occurrence with duration float fx = 4.0; if (o == 1.0) { fx += 10.0 * v_factor; } fx = max(4.0, fx); float g = fx / (dist * (10.0 + 20.0)) * FLASH_POWER; // smooth out edges to avoid fading extending beyond the symbol's bounds float edgeFadeFactor = smoothstep(0.5, 1.0, dist); float invertedEdgeFadeFactor = 1.0 - edgeFadeFactor; vec4 color = mix(COL1, COL2, g); color.a *= min(1.0, 0.5 + v_factor) * invertedEdgeFadeFactor; // v_color.a carries the collision fade. No premultiply: this renderer blends // GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA. fragColor = vec4(color.rgb, clamp(color.a, 0.0, 1.0) * v_color.a); if (fragColor.a < 0.01) discard; }
© 2026 Xweather (opens in a new tab)Terms of Service (opens in a new tab)Privacy Policy (opens in a new tab)