Performance
Most slow maps are slow for one of four reasons: too many vector elements, too much geometry per element, a style that lays out more labels than it draws, or terrain settings that ask for more mesh than the view needs. Each has its own knob.
Numbers, methods and dead ends from this SDK's own optimisation work are in the performance log and rendering / performance. A change that is not measured on a device is a guess.
Many elements: index them, or tile them
A LocalVectorDataSource walks its elements on every cull pass. Below roughly a thousand simple
markers that is free; above it, give it a spatial index:
- Java
- Swift
LocalVectorDataSource source =
new LocalVectorDataSource(proj, LocalSpatialIndexType.LOCAL_SPATIAL_INDEX_TYPE_KDTREE);
let source = MSFLocalVectorDataSource(
projection: proj, spatialIndexType: .LOCAL_SPATIAL_INDEX_TYPE_KDTREE)
The index trades a little latency for a lot of CPU: it is not rebuilt while the map moves, so an overlay appears a few hundred milliseconds after a pan rather than during it.
Past tens of thousands of features, stop using vector elements. Feed the data through the GeoJSON vector tiling pyramid instead — it tiles once and styles with CartoCSS, and it was measured 3.3× faster than elements on long lines. That is the right answer for tracks, route networks and any dataset the user zooms around in.
Complex geometry: simplify it
A GPS trace recording a point per second is one element with a hundred thousand vertices, and no spatial index helps — the whole thing is on screen. Simplify it instead:
- Java
- Swift
source.setGeometrySimplifier(new DouglasPeuckerGeometrySimplifier(1.0f / 320.0f));
source?.setGeometrySimplifier(MSFDouglasPeuckerGeometrySimplifier(tolerance: 1.0 / 320.0))
Simplification is zoom-dependent — aggressive when zoomed out, absent when zoomed in — and runs in two passes: radial-distance rejection, then Ramer–Douglas–Peucker. A one-pixel tolerance is invisible and often halves the vertex count.
Cache tiles on disk
Every network tile fetched twice is a frame's worth of work and a user's data. Put a persistent cache in front of any HTTP source:
{"type": "persistent-cache",
"databasePath": "…/osm-cache.db",
"capacity": 268435456,
"source": {"type": "http", "url": "https://…/{z}/{x}/{y}.pbf", "maxZoom": 14}}
capacity is bytes. This is what every example does, and the reason a demo re-run
starts instantly.
Labels
Label layout, not label drawing, is the cost. A style that attaches text to every feature of a dense layer pays for placement on each tile-set change even when almost nothing is visible.
- Give labels a minimum zoom in the style rather than relying on collision to hide them.
- Prefer one text attachment per feature. Two attachments on the same feature double the placement work — see label styling.
allow-overlapskips collision for that layer, which is faster but only correct for sparse data.
The mechanism, and which part of it is expensive, is documented in rendering / labels.
Terrain
3D terrain builds a mesh per tile and re-drapes what is painted onto it. Three knobs matter, all
on TerrainOptions:
meshResolution | vertices per tile. The single biggest terrain cost. |
viewDistanceFactor | how far the ground is drawn. Pair a short one with fog or it ends on a hard edge. |
autoFlattenParallax | renders flat once the on-screen relief is below N pixels — free at low zoom, invisible to the user. |
map.set("terrain.meshResolution", 32);
map.set("terrain.autoFlattenParallax", 1.5);
Draping lines onto the terrain is separately expensive; drapeLines is the A/B.
What the SDK already does for you
Worth knowing, so you do not re-implement it:
- Tiles are decoded on background threads and cached in memory and on disk.
- Labels are placed on their own worker, not on the render thread.
- A style parameter change repaints without re-decoding tiles — use live style parameters instead of rebuilding a layer to change a colour or a selection.
- Nothing is redrawn unless something changed. The surface is drawn on demand, so an idle map costs nothing.