Skip to content

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 with icon-color
  • Halo pass: threshold shifts outward based on icon-halo-width — pixels between the halo and fill thresholds are colored with icon-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)"]
  1. The casing (background shape) is drawn at alpha ≈ 0.25 — below the fill threshold, but reached by the halo pass
  2. 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-color fills the circle background
  • icon-color tints 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.