Skip to Content
ExamplesCustom Earthquake Shader

MapsGL Android - Custom earthquake symbols using a shader

This example customizes the symbols displayed by the earthquakes weather layer using a custom fragment shader. Each quake is drawn procedurally — there is no sprite involved at all — and the shader turns the symbol’s quad into concentric waves that travel outward over time, sized by the quake’s magnitude.

Custom shaders can be used for symbol layer types to create a unique animated visual effect for each symbol based on the feature’s data and properties.

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

Earthquakes across south-east Asia drawn as orange concentric ripples, larger for stronger quakes

Drawing with no icon image

IconPaint.shader normally recolors a sprite. Set it with no IconPaint.source and the renderer takes its icon-less path instead: it still lays the quad out from IconPaint.size / IconPaint.iconSize, but hands the fragment stage a clean 0..1 quad coordinate in v_tex rather than an atlas rectangle.

v_tex is then the JS example’s vUv: a 0..1 coordinate across the quad. Leave IconPaint.source unset. With an icon image set, v_tex is an atlas coordinate instead.

Shader language version

The symbol vertex shader is #version 310 es and is compiled with your fragment shader as written. Declare the same version so the program links on OpenGL ES 3.1 and newer:

#version 310 es precision highp float;

The shader interface

The symbol vertex stage hands your fragment shader this interface, in GLSL ES 3.x rather than the WebGL1 GLSL ES 1.00 the web SDK uses.

#version 310 es precision highp float; // required - the vertex stage uses highp uniform sampler2D u_sdfTex; // glyph / SDF atlas uniform sampler2D u_rasterTex; // sprite atlas uniform vec4 u_haloColor; uniform float u_haloWidth; uniform int u_raster_mode; // != 0 for sprite icons, 0 for SDF uniform float u_time; // seconds since process start in vec2 v_tex; // 0..1 across the quad when no icon image is set in vec4 v_color; // .a carries the collision fade - always respect it in float v_factor; // per-feature IconPaint.factor, default 1.0 in float v_random; // stable per-instance random, for de-syncing effects out vec4 fragColor;

Porting the web shader

Translating the JS example’s shader needs five changes and nothing else.

JS (paint.symbol.shader)Android (IconPaint.shader)
varying vec2 vUvin vec2 v_tex (0..1 only with no icon image)
varying float vFactorin float v_factor, from IconPaint.factor
varying float vRandomin float v_random
uniform float timeuniform float u_time
gl_FragColor = …out vec4 fragColor
precision mediump floatprecision highp float (the vertex stage uses highp)

Two web-only details are dropped rather than translated:

  • #extension GL_OES_standard_derivatives — fwidth is core in ES 3.x, so requesting the extension is a compile error rather than a harmless no-op.
  • gl_FragColor.rgb *= gl_FragColor.a — symbol layers blend straight alpha. Leave this line out so the color is not darkened.

The JS resolution and dpr uniforms have no Android counterpart. This effect never reads them, so nothing is lost; an effect that needs pixel size should derive it from the quad instead.

One deliberate change to the wave math: the JS example’s alpha is 1.0 - d, which goes negative outside the quad’s inscribed circle. A negative alpha reaching GL_ONE_MINUS_SRC_ALPHA brightens the basemap in the quad’s corners instead of leaving it alone, so the port clamps with max(0.0, 1.0 - d) and discards near-zero fragments.

#version 310 es precision highp float; uniform sampler2D u_sdfTex; uniform sampler2D u_rasterTex; uniform vec4 u_haloColor; uniform float u_haloWidth; uniform int u_raster_mode; uniform float u_time; in vec2 v_tex; in vec4 v_color; in float v_factor; out vec4 fragColor; vec3 hsb2rgb(in vec3 c) { vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); rgb = rgb * rgb * (3.0 - 2.0 * rgb); return c.z * mix(vec3(1.0), rgb, c.y); } void main() { // With no icon image bound, v_tex is the quad's own 0..1 coordinate - the JS example's vUv. vec2 pos = v_tex; float t = u_time; float dist = length(2.0 * pos - 1.0) * 2.0; vec2 p = vec2(dist); float r = length(p) * 0.9; vec3 color = hsb2rgb(vec3(0.03, 0.7, 0.6)); float a = pow(r, 3.0); float b = sin(r * 1.8 - 1.6); float c = sin(r - 0.010); float s = sin(a - t * 2.0 + b) * c; color *= abs(1.0 / (s * 1.8)) - 0.01; // Fade to nothing at the quad's inscribed circle; max() keeps alpha out of negatives. float d = length(2.0 * pos - 1.0); float alpha = max(0.0, 1.0 - d); // v_color.a carries the collision fade. No premultiply: this renderer does not blend // premultiplied. fragColor = vec4(color, alpha * v_color.a); if (fragColor.a < 0.01) discard; }

The wave math is unchanged from the JS example, which itself adapts Shadertoy ldycR3 . Only the shader interface moved.

Keeping the effect animating

animated: true in the JS paint has no direct Android equivalent, and a shader that samples u_time does not by itself keep the map painting: once the camera settles and the timeline is paused, Mapbox stops calling the renderer and the ripples freeze.

The renderer treats a symbol layer as continuously animated when IconPaint.spin is set, so this example sets spin with spinDegreesPerSecond at 0. Frames keep arriving and u_time keeps advancing, while the vertex stage’s rotation term stays zero.

paint.icon.spin = true paint.icon.spinDegreesPerSecond = 0f

Sizing from magnitude

The JS example sets size: { width: s, height: s } where s = 20 + 100 * (report.mag / 10), in JS icon pixels.

Android splits that into two properties: IconPaint.iconSize is the pixel box but takes constants only, while IconPaint.size is a multiplier and accepts an expression. Dividing the JS pixel expression by IconSize.JS_REFERENCE_WIDTH_PX — 40, the width Android measures multipliers against — converts one into the other and lands on the same pixel size per feature.

import com.xweather.mapsgl.style.Expression import com.xweather.mapsgl.style.StyleValue // JS: ['+', 20, ['*', 100, ['/', ['coalesce', ['get', 'report.mag'], 1], 10]]] private val MAGNITUDE_SIZE_SCALE: Expression = Expression.divide( Expression.add( 20.0, Expression.multiply( 100.0, Expression.divide( Expression.coalesce(listOf(Expression.get("report.mag"), 1.0)), 10.0, ), ), ), 40.0, ) paint.icon.size = StyleValue.Expression(MAGNITUDE_SIZE_SCALE)

coalesce covers quakes that report no magnitude, which would otherwise size to nothing.

Attaching a symbol layer to the earthquakes source

WeatherService.Earthquakes pairs the earthquakes GeoJSON with a circle layer, matching the default product, so it cannot carry an icon shader. The JS example sidesteps this with type: 'symbol', source: 'earthquakes'; on Android the equivalent is to reuse WeatherSource.earthquakes under your own SymbolLayerDescriptor, which keeps the same data and the same report.* feature properties.

import com.xweather.mapsgl.controls.legend.Legend import com.xweather.mapsgl.layers.spec.SymbolLayerDescriptor import com.xweather.mapsgl.sources.source.spec.GeoJSONSourceDescriptor import com.xweather.mapsgl.style.IconPaint import com.xweather.mapsgl.style.SymbolLayerPaint import com.xweather.mapsgl.weather.LayerCode import com.xweather.mapsgl.weather.WeatherLayerConfiguration import com.xweather.mapsgl.weather.WeatherService import com.xweather.mapsgl.weather.WeatherSource import com.xweather.mapsgl.weather.common.Presentation private class EarthquakeRippleConfiguration( service: WeatherService, ) : WeatherLayerConfiguration<GeoJSONSourceDescriptor, SymbolLayerDescriptor> { override val code: LayerCode = LayerCode.EARTHQUAKES override val source: GeoJSONSourceDescriptor = WeatherSource.earthquakes(service) override val layer: SymbolLayerDescriptor = SymbolLayerDescriptor( id = "climate.earthquakes.ripple", source = source.id, paint = SymbolLayerPaint(icon = IconPaint(), text = emptyList()), ) override var presentation: Presentation? = null override var legend: Legend? = null }

Complete example

CustomEarthquakeShaderActivity.kt
override fun customizeLayers(controller: MapboxMapController) { val config = EarthquakeRippleConfiguration(controller.service) val paint = config.layer.paint paint.icon.shader = RIPPLE_SHADER paint.pitchWithMap = true // JS parity; also the Android default paint.icon.size = StyleValue.Expression(MAGNITUDE_SIZE_SCALE) // Keeps frames coming so u_time advances; the rotation term stays at zero. paint.icon.spin = true paint.icon.spinDegreesPerSecond = 0f controller.addWeatherLayer(config) }

The camera matches the JS example’s center: [124.091, 26.232], zoom: 2 — on Android that is Coordinate(26.232, 124.091), since Coordinate takes latitude first.

Changing icon paint after the layer is on the map needs MapController.refreshGlVectorLayerPaint. Setting it before addWeatherLayer, as above, needs no refresh.

Reusable shader chunks

Custom symbol shaders support the same #include <name> chunk mechanism as the web SDK, so effects that need noise do not have to re-type it:

#include <fbm>

fbm is value-noise fractal Brownian motion, the same chunk the fire example uses. An unknown name is left as a GLSL comment, so the compile error points at the #include.

© 2026 Xweather (opens in a new tab)Terms of Service (opens in a new tab)Privacy Policy (opens in a new tab)