SDF & Dual-Color Trick
How SDF icons work
Mapbox GL renders SDF icons using only the alpha channel of the sprite texture. The fragment shader reads:
lowp float dist = texture2D(u_texture, tex).a; // alpha only
It then uses a threshold (buff) to decide what color to apply:
- Fill pass:
buff ≈ 0.75— pixels with alpha above this are colored withicon-color - Halo pass: threshold shifts outward based on
icon-halo-width— pixels between the halo and fill thresholds are colored withicon-halo-color
The alpha values don't encode "distance" in a strict mathematical sense — they're just values. The shader applies smoothstep around the threshold, so any pattern of alpha values works. You could put a checkerboard pattern in the alpha channel and the shader would color it correctly with the two-color scheme.
The dual-color trick
The two-pass rendering gives us two independently colorable regions from a single SDF icon:
graph LR
A["Alpha ≈ 0.25<br/>(casing)"] -->|"below fill threshold"| B["icon-halo-color<br/>(background)"]
C["Alpha = 1.0<br/>(icon SVG)"] -->|"above fill threshold"| D["icon-color<br/>(foreground)"]
- The casing (background shape) is drawn at alpha ≈ 0.25 — below the fill threshold, but reached by the halo pass
- The icon (foreground/stroke) is drawn at alpha = 1.0 — above the fill threshold
Then in the Mapbox GL style:
icon-color→ foreground (the icon stroke/shape at high alpha)icon-halo-color→ background (the casing fill at low alpha)icon-halo-width→ controls how far out the halo extends, picking up the low-alpha casing region
Road shield example
The road shield icon (road.svg) is a stroke-only rounded rectangle rendered at alpha 1.0. The sprite generator adds a Filled casing behind it at alpha 0.25.
The style then sets:
{
"icon-color": "rgba(200, 200, 200, 1)",
"icon-halo-color": "rgba(255, 255, 255, 1)",
"icon-halo-width": 1
}
Result: white filled background (icon-halo-color) with a gray border (icon-color). Both colors are fully controllable at runtime — per zoom level, per data condition, whatever you want.
The road icon is also stretchable (9-patch), so it stretches horizontally to fit highway reference labels via icon-text-fit: "both".
POI icon example
POI icons use Circle casing drawn at alpha 0.25. The icon SVG sits on top at alpha 1.0.
icon-halo-colorfills the circle backgroundicon-colortints the symbol
This is how every POI on the map gets its category color — a single SDF icon recolored per feature type.
Shader details
The relevant shader logic from symbol_sdf.fragment.glsl:
lowp vec4 color = fill_color;
lowp float buff = (256.0 - 64.0) / 256.0; // ≈ 0.75
if (u_is_halo) {
color = halo_color;
buff = (6.0 - halo_width / fontScale) / SDF_PX;
}
lowp float dist = texture2D(u_texture, tex).a;
highp float alpha = smoothstep(buff - gamma_scaled, buff + gamma_scaled, dist);
gl_FragColor = color * (alpha * opacity * fade_opacity);
The icon is rendered twice per frame: first the halo pass (u_is_halo = true), then the fill pass (u_is_halo = false). The halo pass uses a lower threshold, so it picks up more of the alpha range. The fill pass only picks up the high-alpha region.
Key insight
There's nothing magic about the 0.25 and 1.0 alpha values — they're just a convention. The shader uses smoothstep with a threshold, so any alpha value above the threshold renders as fill, and any value between the halo threshold and fill threshold renders as halo. The "direction" (high = fill, low = halo) is just how the shader happens to be written.
What matters is that you have two distinct alpha ranges and two color controls. That gives you two-tone icons from a single-channel texture.