Skip to Content
ExamplesCustom Fires Shader

MapsGL Android - Custom fire symbols using a shader

This example customizes the symbols displayed by the fires-obs layer using a custom fragment shader to create an animated fire effect. Every fire observation draws procedurally — there is no sprite — with fractal noise driving the flicker, sized from the fire’s acreage, and turned to smoke once it is fully contained.

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

Wildfire observations across the United States drawn as animated orange flames with rising smoke

Reusable noise via #include <fbm>

The flame needs fractal Brownian motion. The SDK ships the same shader-chunk registry the web SDK uses, so the JS example’s #include <fbm> line ports across unchanged and resolves to the identical value-noise implementation:

#include <fbm>

fbm is the chunk this shader includes. An unknown name is left as a GLSL comment, so the compile error points at the #include.

v_random de-syncs the fires

u_time alone would make every fire on the map flicker in lockstep. The JS shader offsets each instance with vRandom; on Android that is v_random, a stable per-instance value the symbol vertex stage provides for free:

float t = (u_time + v_random * 10.0) * 0.5;

v_factor carries percent contained

IconPaint.factor feeds v_factor, and this example drives it from report.perContained:

// JS: ['/', ['coalesce', ['get', 'report.perContained'], 0], 100] paint.icon.factor = StyleValue.Expression( Expression.divide( Expression.coalesce(listOf(Expression.get("report.perContained"), 0.0)), 100.0, ), )

The shader then tests v_factor == 1.0 and swaps flame colour for smoke, so a fully contained fire reads differently without needing a second layer.

fwidth needs no extension

The JS shader requests GL_OES_standard_derivatives so it can call fwidth. In GLSL ES 3.x fwidth is core, so that #extension line is a compile error rather than a harmless no-op, and is simply dropped. The fwidth call itself ports across untouched.

Orientation and placement

The flames are screen-upright and their base sits on the fire:

paint.pitchWithMap = false // JS parity - do not tilt into the map plane paint.rotateWithMap = false // JS parity - do not swing with bearing paint.icon.anchor = StyleValue.Constant(Anchor.BOTTOM)

Keep the JS Y-flip

v_tex.y grows downward on screen: the vertex stage sets v_tex = (a_quad + 1) / 2, and billboard mode maps a_quad.y = +1 to a negative NDC offset, so v_tex.y = 1 is the quad’s bottom edge.

That is the same orientation as the JS vUv, so the example’s flip is ported verbatim:

vec2 uv = vec2(v_tex.x, 1.0 - v_tex.y);

Size: one expression per axis

The JS example sizes width and height independently per feature:

area = max(1, coalesce(report.areaAC, 0)) contained = coalesce(report.perContained, 0) f = area * (contained == 100 ? 0.75 : 1) / 10000 width = min(80, round(30 + 40 * f)) height = min(130, round(40 + 80 * f))

so a fire’s aspect ratio changes with its size — 30×40 (0.75) when small, 80×130 (0.62) when large.

IconPaint.iconSize carries constants only, and IconPaint.size is a single multiplier applied to both axes, so neither can express that on its own. IconPaint.iconWidth and IconPaint.iconHeight each resolve from their own expression per feature, so both JS formulas port across directly.

paint.icon.iconWidth = StyleValue.Expression( Expression.min( 80.0, Expression.round(Expression.add(30.0, Expression.multiply(40.0, JS_SIZE_F))), ), ) paint.icon.iconHeight = StyleValue.Expression( Expression.min( 130.0, Expression.round(Expression.add(40.0, Expression.multiply(80.0, JS_SIZE_F))), ), )

Set both iconWidth and iconHeight. With either unset, size uses iconSize, then size. IconPaint.size still multiplies the resolved box, so a global scale can sit on top of the per-axis formulas.

Watch the layer id

The built-in fires-obs-icons layer id climate.fire_obs.icons sizes icons from report.areaAC. This example uses its own id so iconWidth and iconHeight apply:

override val layer: SymbolLayerDescriptor = SymbolLayerDescriptor( id = "climate.fire_obs.flames", source = source.id, paint = SymbolLayerPaint(icon = IconPaint(), text = emptyList()), )

Attaching a symbol layer to the fires source

WeatherService.FiresObs pairs the fires-obs vector tiles with a circle layer, so it cannot carry an icon shader. Reusing WeatherSource.fires_obs under your own SymbolLayerDescriptor keeps the same tiles and the same report.* properties — the Android equivalent of the JS example’s type: 'symbol' override.

private class FireFlameConfiguration( service: WeatherService, ) : WeatherLayerConfiguration<VectorSourceDescriptor, SymbolLayerDescriptor> { override val code: LayerCode = LayerCode.FIRES_OBS override val source: VectorSourceDescriptor = WeatherSource.fires_obs(service) override val layer: SymbolLayerDescriptor = SymbolLayerDescriptor( id = "climate.fire_obs.flames", source = source.id, paint = SymbolLayerPaint(icon = IconPaint(), text = emptyList()), ) override var presentation: Presentation? = null override var legend: Legend? = null }

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; in float v_random; out vec4 fragColor; #include <fbm> void main() { // v_tex is the quad's 0..1 coordinate (no icon image bound), and v_tex.y grows downward on // screen, matching the JS vUv orientation - so the JS flip is kept verbatim. vec2 uv = vec2(v_tex.x, 1.0 - v_tex.y); // v_random de-syncs the fires; without it every flame flickers in lockstep. float t = (u_time + v_random * 10.0) * 0.5; vec2 q = uv; q.x *= 1.0; q.y *= 2.0; float strength = floor(q.x + 2.0); float T3 = max(3.0, 1.25 * strength) * t; q.x = mod(q.x, 1.0) - 0.5; q.y -= 0.25; float n = fbm(strength * q - vec2(0, T3)); float c = 1.0 - 16.0 * pow(max(0.0, length(q * vec2(1.8 + q.y * 1.5, 0.75)) - n * max(0.0, q.y + 0.25)), 1.2); float c1 = n * c * (1.5 - pow(1.25 * uv.y, 2.0)); c1 = clamp(c1, 0.0, 1.0); // flame colour vec3 col = vec3(3.5 * c1, 1.5 * c1 * c1 * c1, c1 * c1 * c1 * c1 * c1 * c1); float alpha = 1.0; // v_factor is percent contained; a fully contained fire goes to smoke. if (v_factor == 1.0) { col *= vec3(0.0); alpha = 0.7; } // smoke intensity: a lower number gives more smoke float a = c * (1.0 - pow(uv.y, 1.0)); col = mix(vec3(0.3), col, a); if (col.r == col.g && col.g == col.b) { alpha *= 1.0 - col.r; } if (uv.y > 0.5) { float dist = length(2.0 * uv - 1.0); float delta = fwidth(dist); alpha *= 1.0 - smoothstep(1.0 - delta - 0.5, 1.0, dist); } // v_color.a carries the collision fade. No premultiply: this renderer blends // GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, so the JS `rgb *= a` would darken twice. fragColor = vec4(col, clamp(alpha, 0.0, 1.0) * v_color.a); if (fragColor.a < 0.01) discard; }

The flame maths is unchanged from the JS example, which itself adapts Shadertoy Xtf3DX . Beyond the interface, the only removals are the premultiply at the end and the unused tDiffuse, resolution and dpr uniforms, which have no Android counterpart and which the effect never read.

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