Butter-Smooth Maps and a 3D Car That Won't Cooperate

I wanted a map that panned like butter and a 3D car that followed the road. A week later I had learned which one was lying to me.

9 min read#flutter#mapbox#mobile#performance

I wanted butter-smooth map playback with a real 3D vehicle inside Flutter and Mapbox. What I got first was a laggy camera, duplicate paths, and a jittery dot that looked like it was having a worse day than I was.

The idea sounds simple: replay a saved journey on a map, pan the camera along the route, and put a real 3D model on it instead of a flat pin. The playback preview I’m building needs both, inside a Flutter shell, on top of Mapbox GL. How hard could it be?

Quite hard, as it turned out. I spent most of a week chasing the wrong culprit.

The preview was lying

The first time I hit play, the demo looked broken in almost every way that matters. The camera lagged and shook behind the route. Two path layers, travelled and remaining, drew on top of each other like neither knew the other existed. Cars didn’t show up at all. The fallback position dot jittered like it was sampling GPS from a washing machine on spin cycle.

I kept tweaking the vehicle renderer because that’s what had changed. It wasn’t what was wrong.

Most of the early mess turned out to be camera choreography, not rendering. I rebuilt the sequence into four beats: pull back to show the whole route, cut to the start, follow the vehicle, then ease into the arrival. Planes, trains, and boats follow great-circle arcs (the shortest path on a sphere). Cars and bikes get road-snapped coordinates from Mapbox Directions so they sit on the network instead of cutting through buildings.

The model heading was a separate problem. Mapbox models need a yaw relative to the map, not just the route bearing. I ended up with yaw = bearing - mapBearing + orbitOffset, which keeps the vehicle pointing along the road even when the camera orbits around it. The rendering API is easy. Alignment math and camera choreography are not.

Native GLB and the green circle

The next step was a real 3D vehicle via Mapbox’s ModelLayer: a GLB model pinned to a GeoJSON point that moves each frame. Getting it on screen felt like progress until I saw it: backwards, tiny, and for a while sitting inside a green circle with the location dot on top, like the car had been arrested. The dot was still rendering above the model, and the GLB’s scale and forward axis were wrong.

GLB files don’t agree on which way is “forward”. A car, plane, and boat each needed their own rotation offset so the model’s nose matched the route direction. Road snapping made that worse: snapping to dense vertices produced tiny bearing changes frame to frame, so the model twitched even when its position looked fine.

Preview and export also want different things. The in-app preview needs a live 3D model on the map. Export renders frame-by-frame video, and updating a ModelLayer every frame through the Flutter bridge was far too slow for that pipeline. Export fell back to a 2D marker and let the moving camera sell the shot. Two surfaces, two performance budgets. I learned that the expensive way.

Four lanes, one real bottleneck

New vehicle models didn’t help. It was still laggy. At this point I had four different ways to put a vehicle on screen, and I needed to understand which part of the stack was actually expensive.

LaneApproachWhy it hurt
1GeoJSON + native ModelLayerEvery frame: update the point in Dart, cross the platform channel, update Mapbox style properties.
2Screen-locked flutter_scene overlayMap renders underneath; Flutter renders 3D on top. Two separate GPU passes every frame.
3Baked 2D spritesSmooth, but a pre-rendered PNG can’t show a plane banking or a car turning through 360 degrees.
4Static image overlaySmooth. Confirmed the overlay widget itself was cheap.

Lane 4 was the clue. If a static image over the map is smooth, the overlay is not the bottleneck.

The red herring that ate two days was “why is a PNG over the map slow?” Three unrelated bugs were stacked on top of each other. Parent setState was rebuilding the entire MapJourneyView, including the Mapbox widget, roughly twenty times a second. Sprite assets weren’t in the bundle because pubspec.yaml didn’t recurse into assets/sprites/plane/. A camera throttle I’d added for dot mode was still active during playback, so the timeline advanced at 60 Hz while the camera only moved at ~14 Hz.

I turned off every vehicle layer. The map was still slow.

That told me the problem sat below the vehicle code. Flutter was fine as a UI shell. During playback, Dart was sending camera.moveTo at 60 Hz across the platform channel. Each call made Mapbox’s native GL thread fetch and rasterize tiles for the new viewport. Dart-side work was cheap. Platform-thread Mapbox work was not. avgFrameMs could look acceptable while motion still felt wrong, because the timeline and the camera were running at different rates.

The vehicle renderer looked guilty because it was the newest code. It wasn’t where the milliseconds were going.

The decision gate

I built a Map Perf Lab to isolate the problem. Stage 1 is a bare map pan with no playback logic. Stage 8 is the full scene. If Stage 1 is smooth but playback janks, the ceiling is bridge latency: too much work crossing from Dart to native every frame. That test ended a lot of arguments about vehicle orientation and sprite colours.

Lane A vs Lane C

Lane A keeps the map in Flutter (mapbox_maps_flutter). During playback, Dart sends camera.moveTo and vehicle style updates every frame. Still jittery. Same root cause.

Lane C moves playback into a native module (native_map_playback): a Kotlin/Swift PlatformView with its own Mapbox surface. Flutter sends the route keyframes once at load. After that, a native playback clock tied to vsync advances the camera and vehicle on the GL thread with zero per-frame bridge traffic. Camera smooth. Early integration handed me crashes, ANRs, and the occasional dual-map flash where Flutter’s map and the native map both rendered for a frame. The direction was obvious even when the build wasn’t green.

I parked sprites-on-play, hybrid GLB, Filament/SceneKit spikes, and a raster-hero cache that only made sense in the perf lab. The bar for Lane C was concrete: max frame time ≤24ms, jank ≤8%.

London to Iceland

Short urban routes hide interpolation bugs. A long single-leg great-circle flight over Atlantic tiles does not. I picked London to Iceland as the torture test and immediately regretted it.

The symptoms were specific. Ghosting behind the plane. A dark line from the plane’s nose where the route polyline bled through during cruise. Jumpiness at fixed timestamps, around 8 seconds and again at 10-11 seconds, same place every single play. The camera was smooth. The map was smooth. The model hopped backward a frame or two, sitting visually behind where the route said it should be. Bugs that hit the same timestamp every run feel personal.

London to Keflavik with the native module: smooth camera, smooth map, model hopping behind the route.

The underlying issues were mostly about interpolation, not rendering:

SymptomCauseFix
Model cuts corners across the oceanPosition was chord-lerped (straight line through the earth) instead of following the great-circle arcRouteProgressSampler for arc-aware lat/lng
Dark line from the plane’s noseRoute polyline still visible during cruise camera angleHide route layers during usesFlightPitch
Stutter at regular intervalsPlayback clock used wall time instead of display refreshNative PlaybackClock tied to Choreographer
Model slides sideways on the arcPosition followed the arc but yaw was still chord-derivedArc-derived bearing from the route sampler
Jumps at segment boundariesToo few keyframes over a long legCap raised from 600 to 1000, tighter distance spacing
Model hops despite smooth cameraCamera moved too fast for Mapbox to keep up at close zoomZoom out (fewer tiles); lengthen journey time (slower camera)

I ruled out the shadow sprite, altitude lock, and model complexity (the yacht is about 1,200 tris). Chord lerp on a great circle is visibly wrong over this distance: the model takes a shortcut through the planet while the route line curves. Yaw and position computed differently can look like a GPU glitch when it’s maths.

The last fix was the least glamorous. Zooming out means Mapbox rasterizes fewer, simpler tiles per frame. Lengthening the journey time means the camera crosses fewer tiles per second. The model stopped hopping because I stopped asking the map to do too much, too fast.

Where things landed

Flutter UI

Preview • Controls • Scrub

keyframes once

native_map_playback (PlatformView)

Native Mapbox GL

  • Camera animation
  • Vehicle ModelLayer
  • Route rendering

Flutter owns the chrome: preview bar, controls, scrubbing. A native PlatformView owns the map, the playback clock, and the vehicle. Keyframes cross the bridge once at load; everything that needs to run at 60 Hz stays on the native side.

Lane A (Flutter MapWidget plus per-frame bridge calls) remains fallback. Lane B (Flutter map plus flutter_scene/sprites on top) is parked.

What I’d tell past-me

The vehicle renderer mattered less than the camera bridge. Every lane that felt slow had the same pattern: Dart commanding the map at 60 Hz. A Flutter overlay is not “just an image” when parent setState, missing asset bundles, and the wrong camera throttle can make a trivial widget expensive.

The Mapbox GL thread is the real budget. Tile fetch and rasterize on every camera move. Long-haul flights expose interpolation bugs that a commute around the block never will: chord lerp, sparse keyframes, and multiple clocks (Flutter timeline, native playback clock, Mapbox render thread, vsync) can each look fine in isolation and still produce a hop at exactly 8.0 seconds every time.

When that happens, stop tuning shaders and start bisecting clocks. Map Perf Lab stages, frame captures, and PlaybackDiagnostics turned “it feels wrong” into something I could argue with. I used Cursor heavily for native module boilerplate and crash logs; the useful work was still deciding what to measure.

The plane glides from Heathrow to Keflavik without complaint now. Sometimes the fix is zooming out and slowing down, not one more pass at the interpolation math.

Related