Run the render loop
Pumping the runtime advances MapLibre Native. A pump drains queued map work and services network and database work. Drawing is separate: the map publishes a render update, and the render session draws it.
Pump the runtime and drain its events on the runtime owner thread. Both operations report a wrong-thread status from another thread. Choose the owner thread before creating the runtime.
Choose the runtime owner thread
Section titled “Choose the runtime owner thread”Pump from the display thread when the host already has a frame callback and the map is the main work on that thread. The runtime, map, and render session then share one thread, and the loop needs no shared state. Pass a drain budget so one busy pump spreads its work across frames instead of spanning an entire style parse inside one callback. A single task still runs to completion, so keep headroom in the frame for the longest one.
Use a dedicated thread when a long pump would stall the display, the map must load while the display is idle, or the host draws no frames. That thread parks inside the pump, and runtime work sets the loop cadence. The display thread attaches the render session and draws. This design needs shared state and a wake source.
Pump from the frame callback
Section titled “Pump from the frame callback”Pass a timeout of zero. The call drains what is queued and returns, because the display already sets the pace. Pass a budget of a few milliseconds so the pump leaves the rest of the frame to drawing; work that the budget defers re-arms the pump, so the next callback continues it. One drain then takes the events that the pump produced.
This loop reads two event types, render-update-available and render-frame-finished. It selects them before the map loads a style, as Handle events describes.
mln_runtime_pump(runtime, 0, 8);
mln_runtime_event_batch batch = mln_runtime_event_batch_default();if (mln_runtime_drain_events(runtime, 0, &batch) != MLN_STATUS_OK) return;
for (size_t index = 0; index < batch.event_count; index++) { const char* bytes = (const char*)batch.events + index * batch.event_size; if (wants_a_frame((const mln_runtime_event*)bytes, map)) *pending = true;}The helper compares each event’s source against the map before interpreting the event. Every map in one runtime shares the queue. It requests a frame after a render update and after a finished frame with its repaint flag set. Repaint frames advance camera animations and label placement.
A display-paced host stops pumping when it stops requesting frames. Request a frame after every map change. This lets an idle loop process the change.
Draw a pending frame after draining events. The render call reports which outcome it reached. A no-update result and a pending-size result both mean that the map has not published an update for the session’s extent, which is expected at startup and after an attach or resize. A target-not-ready result means that the render target had no frame to draw into, so the next turn tries again.
A rendered frame clears the pending flag, unless the map asked for another frame while rendering this one, as during a camera transition. The render call returns that repaint flag directly, so the loop re-arms in the same turn instead of waiting for the frame’s events to arrive.
if (!*pending) return;
mln_render_result result = MLN_RENDER_RESULT_NO_UPDATE;bool needs_repaint = false;const mln_status status = mln_render_session_render_update(session, &result, &needs_repaint);// A rendered frame clears the request unless the map asked for another// frame while rendering it. Any other result keeps the frame pending for// the next turn.if (status == MLN_STATUS_OK && result == MLN_RENDER_RESULT_RENDERED) { *pending = needs_repaint;}Pump on a thread of your own
Section titled “Pump on a thread of your own”The pump thread owns the runtime and map. The display thread owns the render session. Each operation reports a wrong-thread status when called from the other thread.
The two threads share a flag that the pump thread sets when a frame is due, a flag that the display thread sets to stop the loop, and a wake source.
typedef struct pump_channel { atomic_bool render_pending; atomic_bool stop_requested; mln_wake_source wake;} pump_channel;Acquire the wake source and select the event types the loop reads, both on the runtime owner thread before the loop starts.
channel->wake = MLN_HANDLE_NULL;if ( mln_runtime_wake_source_acquire(runtime, &channel->wake) != MLN_STATUS_OK) { return;}
// Only a selected event wakes this thread.mln_map_set_event_mask( map, MLN_RUNTIME_EVENT_MASK_MAP_RENDER_UPDATE_AVAILABLE | MLN_RUNTIME_EVENT_MASK_MAP_RENDER_FRAME_FINISHED);Any thread may signal the source, and the signal releases a parked pump. The display thread signals it after it queues work for the map or asks the loop to stop.
void pump_channel_wake(pump_channel* channel) { mln_wake_source_signal(channel->wake);}The loop parks until owner-thread work, a selected event, or the wake source signals it. Use a positive timeout to reach the stop check even when no wake arrives. The runtime registers no timers of its own. This snippet parks for 100 ms and checks for a stop at least ten times a second.
while (!atomic_load(&channel->stop_requested)) { mln_runtime_pump(runtime, park_timeout_ms, -1); drain_events(runtime, map, channel);}Call a parking pump outside any lock that a signalling thread also acquires. The display thread reads the pending flag on its next frame and draws.
Use a dedicated operating-system thread. The runtime records that specific thread at creation. Serial dispatch queues, actors, and pooled executors provide serialization but can move work between threads. Calls that move report a wrong-thread status.