Map example specification
Specification for interactive *-map example programs: small apps that exercise
language bindings and render-target integrations through a focused map demo.
The specification has four sections:
- Mise tasks — the build and run commands every example exposes.
- Shared baseline — map, render-session, frame-loop, and graphics contracts common to every profile.
- Desktop profile — windowed desktop hosts with CLI entry and keyboard/mouse input.
- Mobile profile — embedded view hosts with touch input.
Implement a desktop example by reading Shared baseline and Desktop profile. Implement a mobile example by reading Shared baseline and Mobile profile.
Implementations
Section titled “Implementations”| Example | Profile | Binding | Toolkit | Platforms | Backends |
|---|---|---|---|---|---|
examples/c-map |
Desktop | C | SDL3 | Linux, macOS | Vulkan, Metal, OpenGL |
examples/zig-map |
Desktop | Zig | SDL3 | Linux, macOS, Windows | Vulkan, Metal, OpenGL |
examples/go-map |
Desktop | Go | SDL3 | Linux | OpenGL |
examples/rust-map |
Desktop | Rust | winit | Linux, macOS, Windows | Vulkan, Metal, OpenGL |
examples/lwjgl-map |
Desktop | Kotlin/JVM | GLFW, LWJGL | Linux, macOS, Windows | Vulkan, Metal, OpenGL |
examples/android-map |
Mobile | Kotlin | Android view | Android | OpenGL/EGL |
examples/dotnet-map |
Desktop | C# | GLFW | Linux, macOS, Windows | Vulkan, Metal, OpenGL |
examples/swift-map |
Desktop | Swift | AppKit, SwiftUI | macOS | Metal |
examples/swift-map |
Mobile | Swift | UIKit | iOS | Metal |
The Compose map example follows the broad strokes of this specification, but uses its own renderer-integration architecture for Skiko texture sharing.
For examples built by native render-backend variant, “Backends” is the union of supported configured variants. Each native library artifact includes one render backend. A single run uses one graphics API, selected at build time (build-variant examples) or at startup from the loaded library (multi-context examples).
Mise tasks
Section titled “Mise tasks”Every example exposes the same task contract in its mise.toml:
build [preset]compiles the example against a native install prefix. The preset defaults to{{vars.host_native_preset}}, and the task depends on//:buildfor that preset. Both come from the rootffi:presettask template.run [render-target] [--preset <preset>]builds and launches the example by extending the rootffi:example-runtask template. The render target defaults toowned-texture, somise run //examples/zig-map:runworks with no arguments, and a mode name overrides it, as inmise run //examples/zig-map:run borrowed-texture.
An example that has not yet implemented every backend rejects an unsupported
preset with an error that names what it supports: go-map drives OpenGL
directly and accepts only *-egl presets.
The render-target argument is how a caller reaches the modes in Render-target selection; the program’s own CLI contract is unchanged.
Shared baseline
Section titled “Shared baseline”What every example provides
Section titled “What every example provides”- All map, runtime, and render access from application code through the project’s language binding for that language.
- Continuous map mode: runtime pumping, event draining, and repaint driven by map render events and user input.
- Initial style URL and camera per Shared defaults.
- Camera controls per the active profile (Desktop profile → Input or Mobile profile → Input).
- Every graphics API the host toolkit and target platform can support across configured variants (Vulkan, Metal, OpenGL/EGL as applicable).
- Render-target coverage per the active profile (Render-target coverage).
- Startup logging that identifies the active render-target mode and which native render backends the loaded library supports.
What an example is not
Section titled “What an example is not”A *-map program is a focused map demo. It MUST NOT include automated tests or
packaging/installer UX.
Shared defaults
Section titled “Shared defaults”- Style URL:
https://tiles.openfreemap.org/styles/bright - Load the style during map initialization, before the first render.
Initial camera
Section titled “Initial camera”| Field | Value |
|---|---|
| Center | latitude 37.7749, longitude -122.4194 (San Francisco) |
| Zoom | 13.0 |
| Bearing | 12.0 degrees |
| Pitch | 30.0 degrees |
Apply with an immediate jump_to on startup.
Map and runtime
Section titled “Map and runtime”- Runtime cache path:
:memory:(in-memory). - Map mode: continuous (
MLN_MAP_MODE_CONTINUOUS).
Architecture
Section titled “Architecture”Overview
Section titled “Overview”Every *-map example splits host responsibilities into the same logical
modules. Names differ by language; boundaries MUST NOT be collapsed into a
single monolithic type.
flowchart TB subgraph shell["App shell"] EL[Event loop] VP[Viewport] IN[Input] DG[Diagnostics] end subgraph mapstate["Map state"] RT[Runtime] MP[Map] RS[Render target] end subgraph gfx["Graphics host"] BE[Backend context] CP[Compositor] SC[Presentation] end Entry[Entry] --> shell shell --> mapstate mapstate --> gfx RS -->|texture modes| CP RS -->|native-surface| SCLogical modules
Section titled “Logical modules”| Module | Responsibility |
|---|---|
| App shell | Profile entry, toolkit lifecycle, main event loop, shutdown ordering. |
| Viewport | Map logical size, physical drawable size, and scale_factor for RenderTargetExtent. |
| Map state | Owns runtime, map, and active render target; loads style and initial camera. |
| Graphics context | Creates/configures the host presentation surface and owns host graphics API context and presentation resources. |
| Render target | Owns the render session and mode-specific resources such as compositors, borrowed textures/images, and acquired frames. |
| Compositor | Host pass that draws a map-owned or borrowed texture into the swapchain. |
| Input | Pointer and/or touch → map camera APIs; profile-specific control help. |
| Diagnostics | Optional log callback and consistent error messages on failed setup or camera commands. |
Implementations SHOULD mirror this layout in the source tree (separate files or packages per module).
Threads and loops
Section titled “Threads and loops”Examples run two loops on two native threads: a render loop and a runtime loop.
- The render loop thread owns the host window and its input events, the viewport, the graphics API context and presentation resources, the compositor, and the render session for the session’s whole lifetime. It attaches the session, renders through it, resizes it, and closes it.
- The runtime loop thread owns the runtime, the map,
pump, the event drain, and every map mutation. It never calls a render-session function. - Each loop MUST run on a native thread whose identity is stable for the life of the loop. Host mechanisms that may move a logical task between native threads, such as thread pools, green threads, and coroutine dispatchers without thread confinement, are not usable for either loop.
- The render loop thread MUST be the thread on which the host toolkit delivers window, input, and display-refresh callbacks.
- Cross-thread state MUST be limited to three channels: a camera-command queue from the render loop to the runtime loop, a render request from the runtime loop to the render loop, and a one-time publication from the runtime loop to the render loop carrying the map to attach against and the wake source that releases the runtime loop’s parked pump. A shutdown signal and a first-failure record MAY accompany them.
- The camera-command queue MUST NOT discard a queued command, and MUST NOT block the render loop to avoid discarding one; it grows instead. Nothing on it is safe to drop: a dropped delta is motion the drag never gets back, a dropped gesture bracket leaves the commands after it attributed to no gesture, and a dropped absolute command or transition cancellation is a camera state the map never reaches. Only a stalled runtime loop grows the queue, and a runtime loop stalled long enough to matter has already stopped the map.
This split exists because pump drains the work it finds rather than a fixed
slice, so a single call can take as long as a style parse. Keeping it off the
display-paced loop is what lets presentation continue during heavy runtime work.
Render loop thread by host toolkit
Section titled “Render loop thread by host toolkit”Where the host toolkit fixes which thread receives display-refresh and window callbacks, that thread is the render loop thread. Where a graphics API context is thread-current, such as OpenGL through EGL or WGL, the render loop thread is the only thread that makes it current.
| Example | Render loop thread | Runtime loop thread |
|---|---|---|
c-map |
process main thread (SDL window, graphics context) | spawned thread |
zig-map |
process main thread (SDL window, graphics context) | spawned thread |
go-map |
process main thread (SDL window, graphics context) | pinned goroutine |
rust-map |
winit event-loop thread | spawned thread |
lwjgl-map |
GLFW main thread | spawned thread |
dotnet-map |
GLFW main thread | dedicated Thread |
swift-map |
main run loop (CADisplayLink, AppKit/UIKit) |
dedicated Thread |
android-map |
UI thread (Choreographer) |
HandlerThread |
compose-map |
native surface bridge’s producer thread | spawned thread |
Attaching the render session
Section titled “Attaching the render session”A render session’s owner thread is the thread that attached it, and it does not change for the session’s lifetime. The render loop thread MUST therefore be the thread that attaches the session, and the same thread MUST close it.
Attach requires only that the map be live, not that the caller own it, so the render loop attaches against a map owned by the runtime loop. Attach follows this operation:
- The runtime loop creates the runtime and the map, then publishes the map to the render loop once. The publication provides the happens-before edge.
- The render loop creates its graphics context and its mode-specific resources, then attaches, becoming the session’s owner thread.
- The render loop closes the session before the runtime loop destroys the map. Destroying a map with an attached session fails, so the render loop MUST signal that its session is closed and the runtime loop MUST wait for that signal before destroying the map.
Attach creates the session’s graphics resources on the calling thread, so for graphics APIs whose context is thread-current the host context MUST be current on the render loop thread when it attaches. That thread is the only one that makes it current, and it need never give it up.
The requirement is specific to WGL: the session resolves
wglCreateContextAttribsARB through the calling thread’s current context, and
without one it falls back to a legacy context that cannot share with a context
current on another thread. Attaching where the host context is already current
avoids both failure modes. Vulkan and Metal have no thread-current context and
are unaffected.
Reattaching, which a graphics context change requires, is entirely local to the render loop thread: close the session, rebuild the mode-specific resources, attach again.
Thread identity on Apple platforms and managed runtimes
Section titled “Thread identity on Apple platforms and managed runtimes”Owner-thread checks are keyed on the native thread, so a host mechanism that serializes work without pinning it to one native thread is not usable for either loop. This rules out more than thread pools:
- A GCD serial
DispatchQueueguarantees serialization but not thread affinity, so Swift examples MUST use a dedicatedThreadfor the runtime loop rather than a queue, anactor, or aTask. - .NET examples MUST use a dedicated
System.Threading.Threadrather thanTask.Runor the thread pool. - Kotlin examples MUST use a
ThreadorHandlerThreadrather than a coroutine dispatcher.
Graphics API and mode matrix
Section titled “Graphics API and mode matrix”The example architecture MUST model the active graphics API separately from the
active render-target mode. Graphics context code owns API-level resources
(Vulkan, Metal, OpenGL/EGL/WGL as applicable). Render target code owns the
attached RenderSessionHandle, mode-specific resources, resize behavior,
render_update, and close behavior.
The loaded native library reports one render backend per library artifact
through mln_supported_render_backend_mask(). Examples built across native
render-backend variants expose the union of those backends in the
Implementations table.
Graphics API selection follows one of these patterns:
- Build-variant examples compile only the graphics API implementation that
matches the active native build variant (for example
zig-map,rust-map,swift-map). - Multi-context examples ship a graphics context per targeted API and select
the active API at startup from
supportedRenderBackends()(for examplelwjgl-map,dotnet-map).
OpenGL examples that can run with multiple context providers select EGL or WGL
from supportedOpenGLContextProviders().
Each process run uses one graphics API. Render-target mode selection follows the active profile (Entry or Entry and shell).
Adding a graphics API or render-target mode MUST require localized changes. Keep each graphics API and render-target mode in its own variant, class, or submodule rather than branching ad hoc through shared draw code.
Lifecycle
Section titled “Lifecycle”Startup
Section titled “Startup”Order MUST be:
- Parse profile entry configuration and validate the selected render mode.
- Read and log the loaded library’s supported native render backends from
mln_supported_render_backend_mask(), then validate that the loaded native library supports the graphics API selected for this run; fail fast with a readable message if not. - Create the host presentation surface and initialize the graphics backend for the selected graphics API, on the render loop thread.
- Start the runtime loop thread.
- Create runtime (
:memory:cache) on the runtime loop thread. - Create map with extent from the initial viewport and continuous mode.
- Load style and apply initial camera.
- Publish the map to the render loop thread.
- Attach the render target for the selected mode on the render loop thread, using descriptors produced by the graphics context there.
- Emit startup information:
- active render-target mode identifier
- active render-target status line
Steps 5 through 8 run on the runtime loop thread; steps 3 and 9 run on the render loop thread. A host that cannot create its graphics context before the window MUST still keep step 3 on the render loop thread.
On failure after partial setup, release already-created handles in reverse order (render target → map → runtime → graphics), with the render target closed on the render loop thread before the map is destroyed.
Shutdown
Section titled “Shutdown”On host termination or fatal error, close resources in order:
- Leave the render loop and finish or wait on in-flight GPU work if the backend requires it, on the render loop thread.
- Render target (compositor and borrowed texture/image before or with the session, according to graphics API lifetime rules), on the render loop thread.
- Signal the runtime loop that the session is closed, and stop it.
- Map
- Runtime, after which the runtime loop thread exits and the render loop thread joins it.
- Graphics context and host presentation surface, on the render loop thread.
Steps 4 and 5 run on the runtime loop thread, which MUST wait for the step 3 signal before step 4: destroying a map that still has an attached render session fails.
Handle ownership
Section titled “Handle ownership”- One runtime per process (the runtime loop thread drives
pump). - One map per runtime for the demo, sharing the runtime’s owner thread.
- One live render target per map at a time.
- One render session owner thread, fixed for the session’s lifetime: the thread that attached it, which is the render loop thread.
- Map configuration (style, camera) uses the map handle; render-target extent and present use the render target.
Frame loop
Section titled “Frame loop”The C API treats runtime pumping and presentation as separate concerns. pump
advances native scheduler work and fills the event queue; it is not
display-driven. One call drains the work it finds instead of running a fixed
slice, so a single call can take as long as a style parse. render_update draws
only when the render request is set.
*-map examples split the two across the loops described in
Threads and loops: the render loop is display-paced and
draws, and the runtime loop pumps. The runtime loop owns its own thread, so it
passes a positive timeout_ms and takes its cadence from the runtime’s own work
rather than from the display. pump parks the thread for up to that bound,
which is what lets the runtime loop idle while the map is quiet instead of
spinning.
Render loop iteration
Section titled “Render loop iteration”- Handle window, input, and resize events; translate camera input into commands and enqueue them for the runtime loop; signal the runtime loop’s wake source; set the render request.
- Apply pending viewport changes to graphics resources and to the render target, which follows them without losing its session.
- Consume the render request; when it was set, call
render_update. - Run
finishFrame().
sequenceDiagram participant RL as Render loop participant RQ as Render request participant RS as Render session participant BE as Backend
RL->>RL: Input and resize RL->>RQ: enqueue camera commands, signal wake source, set request RL->>RQ: consume request RL->>RS: render_update() when it was set RL->>BE: finishFrame()finishFrame() runs every iteration: swapchain or surface upkeep, resize
handling, and present hooks as required by the host graphics API.
A render loop iteration MUST NOT call pump or drain events.
Runtime loop iteration
Section titled “Runtime loop iteration”- Apply every queued camera command.
- Call
pumpexactly once, with a positivetimeout_ms. - Drain the runtime’s events once, updating the render request from the batch.
sequenceDiagram participant RTL as Runtime loop participant CQ as Command queue participant RT as Runtime participant RQ as Render request
RTL->>CQ: apply queued camera commands RTL->>RT: pump(timeout_ms) RTL->>RT: drain events RTL->>RQ: set request when a frame is neededCadence
Section titled “Cadence”While the map is visible and the example is active:
- The render loop MUST run at least one iteration per display refresh period,
and MUST subscribe to the host toolkit’s display refresh mechanism (for
example swapchain frame callbacks,
CADisplayLink, orChoreographer) to pace it. - The runtime loop MUST wake immediately when the render loop enqueues a camera
command or a viewport change. It acquires a wake source on its own thread,
publishes it to the render loop, and the render loop signals it. The parking
bound it passes to
pumpis a backstop rather than the cadence, so it MAY be longer than one display refresh period.
Display refresh paces the render loop; it does not pump. Each runtime loop
iteration MUST call pump exactly once, and no other loop calls it. A
single-loop host instead passes zero so the refresh mechanism remains its only
cadence; a two-loop example passes a positive bound because its pump thread has
no display to pace it.
When the profile stops the loops (for example mobile background), runtime progress stalls until they resume.
Render requests
Section titled “Render requests”The render request replaces a loop-local render_pending flag, because the two
loops set and consume it from different threads.
sequenceDiagram participant RL as Render loop participant RS as Render session participant CP as Compositor participant BE as Backend
RL->>RS: render_update() alt texture mode RS-->>RL: map texture / frame RL->>CP: draw into swapchain CP->>BE: present else native-surface RS->>BE: present via surface session endRequirements:
- The render request MUST be published and observed through the host language’s atomic or synchronized mechanism. An unsynchronized field is not sufficient.
- The runtime loop MUST call
pumponce per iteration while it is running. - The runtime loop MUST drain runtime events once per iteration and set the
render request when:
map_render_update_availabletargets this map (new map content to draw), ormap_render_frame_finishedtargets this map andneeds_repaintis true (continuous mode needs another frame, for example ongoing camera transitions).
- The render loop MUST set the render request when input changes the camera and when a resize or reattach completes.
- The render loop MUST call
render_updateonly when it consumed a set request. - The render loop MUST consume the render request before calling
render_update, and MUST set it again whenrender_updatereports any result other than a rendered frame. Consuming afterwards would discard a request the runtime loop published during the render call, and that frame would never be drawn. - When
render_updatereports a rendered frame, the render loop MAY set the render request again from the call’s returned repaint flag instead of waiting for the frame’smap_render_frame_finishedevent. map_render_frame_finishedis delivered by the runtime loop’s nextpumpafter the render loop rendered. An example MUST NOT treat it as a synchronous result of therender_updateit follows, and MUST NOT block a render loop iteration waiting for it.- After a session resize, the map applies its logical size on the runtime loop’s
next
pump, sorender_updatereports a pending size until then. The render loop MUST keep pacing and retry rather than treating it as a failure. - A compositor that cannot present the frame it was handed MUST report that as no frame rendered, and the render loop MUST set the render request again. A minimized or occluded window produces this, and so does a swapchain awaiting its rebuild. The map retains the update, so the retry draws it.
Texture modes: after render_update reports a rendered frame, MUST run the
compositor pass to copy the map texture into the host swapchain before present.
Viewport
Section titled “Viewport”The viewport value MUST contain:
| Field | Meaning |
|---|---|
logical_width, logical_height |
Map coordinate extent passed to MapOptions / RenderTargetExtent. |
physical_width, physical_height |
Drawable pixels of the host framebuffer. |
scale_factor |
Ratio between physical and logical sizes (content scale / pixel density). |
Derivation rules:
- Read logical and physical sizes from the host toolkit after surface creation and on every resize or backing-scale change.
- Compute logical dimensions from physical size and scale when the toolkit only
exposes physical pixels (use
ceil(physical / scale), minimum1). - Log viewport changes at informational level with field labels
logical=… physical=… scale=….
Pass logical_* and scale_factor to map creation, session attach, and session
resize.
Map state
Section titled “Map state”The map state module owns the runtime, map, and render session handles plus map-specific setup.
Creation
Section titled “Creation”- Create runtime with
:memory:cache. - Create map with current viewport extent and continuous mode.
- The example MUST select every event type it reads before it loads the style. A map queues an event only while its subscription selects that type.
- Load style URL.
- Apply initial camera.
- Attach a render target by dispatching on active graphics API and selected mode.
Event drain
Section titled “Event drain”- Drain the runtime’s events once per runtime loop iteration, and read every event the batch reports.
- Set the render request when either:
map_render_update_availabletargets this map, ormap_render_frame_finishedtargets this map andneeds_repaintis true.
Resize API
Section titled “Resize API”Expose resize(viewport) for the active render target, and resize API-level
resources separately when the graphics context requires it. The render target
decides for itself how to follow the new viewport, so a host resizes every mode
through the same call.
Render-target modes
Section titled “Render-target modes”Shared baseline defines three render-target modes (discriminant/class, attach paths, and present behavior). Each example implements only the modes required by its profile (Render-target coverage). Example architecture MUST model each implemented mode.
Mode comparison
Section titled “Mode comparison”| Mode identifier | C API concept | Compositor | Role |
|---|---|---|---|
owned-texture |
Session-owned backend texture | Required | Map allocates texture, host samples it. |
borrowed-texture |
Caller-owned texture borrowed by session | Required | Host allocates exportable texture; session renders into it. |
native-surface |
Window presentation surface | None | Map renders directly to the host presentation target. |
Startup status lines
Section titled “Startup status lines”Startup MUST print the active mode identifier and exactly one line from this table:
| Mode identifier | Printed line |
|---|---|
owned-texture |
render target status: samples MapLibre-owned texture frames into the host swapchain |
borrowed-texture |
render target status: renders into a host-owned texture, then samples it into the host swapchain |
native-surface |
render target status: renders directly to the host window surface |
owned-texture
Section titled “owned-texture”- Attach with the C API owned-texture descriptor for the active graphics API.
- Pass the host graphics context handles required by that descriptor (see Graphics API).
- On
render_update, acquire the frame/image from the session, draw via compositor, release/close the frame per the C API frame lifetime rules.
borrowed-texture
Section titled “borrowed-texture”- Host creates an exportable texture sized to the viewport (see Graphics API).
- Attach with the borrowed-texture descriptor referencing host-owned handles.
- On
render_update, sample that texture through the same compositor path asowned-texture. - On resize, allocate a host texture at the new size and hand it to the live
session with
set_target, which the render target does inside its ownresize(see Resize mechanics).
native-surface
Section titled “native-surface”- Attach with the C API surface descriptor for host presentation (see Graphics API).
render_updatepresents through the surface render target directly.drawTextureMUST NOT be called for this mode.- On resize, call session
resizeand rebuild host presentation. When the host toolkit supplies a new surface handle for the same graphics context, callset_targetwith it; reattach only when the context itself changed or was lost.
Compositor shaders
Section titled “Compositor shaders”For owned-texture and borrowed-texture, the host-owned compositor that
samples the map texture into the host swapchain MUST use a fullscreen triangle
covering the viewport:
- Vertex shader: three corners with pass-through UVs spanning the visible
[0, 1] × [0, 1]texture range (large-triangle technique). - Fragment shader:
texture(map_texture, uv)(straight copy, standard UV orientation).
SPIR-V, MSL, or GLSL source MAY differ by backend; the GPU output MUST match that pass.
Resize mechanics
Section titled “Resize mechanics”- Recompute viewport on host size or scale changes; skip rendering if extent is empty.
resize(viewport)is a render-target method, and each mode follows the new viewport its own way.owned-textureandnative-surfaceresize graphics-context resources, compositor resources for texture modes, and session extent in place.borrowed-texturecannot: the host-owned exportable texture is fixed to the viewport size, so it allocates one at the new size and hands it to the live session withset_target. A host callsresizefor every mode and branches on none of them.- The session stays live either way, and so does its renderer, so the map keeps its tiles and atlases across a resize. A scale factor change is the exception the C API documents, rebuilding the renderer for the new pixel ratio.
- Pick an ownership strategy for the handover and follow the C API’s rules for it. An example that keeps the outgoing target until the call returns can roll back: a rejected replacement leaves the session on the target it had, so it releases the replacement and keeps rendering. An example whose graphics layer frees the outgoing target first cannot roll back, and treats a rejected handover as a session to close and attach again. Caller-owned textures allow either, because no backend reads the outgoing texture.
- A Vulkan surface is the exception, and requires the retaining strategy: its
outgoing
VkSurfaceKHRmust still be valid whenset_targetis called, because the session destroys the swapchain built from it first. MLN_STATUS_NATIVE_ERRORmay mean the replacement was already under way, and a caller cannot tell it apart from a failure that came earlier. The session may therefore hold either target. Detach or close it before releasing either one, and attach again rather than reusing it; stopping before the next frame is not enough on its own, because the release still happens while the session holds the target. Every other status is reported before the target changes, which is what makes rolling back to the outgoing target safe.- Build the replacement to match what the session attached with, which the C API
states per backend and per function.
set_targetreportsMLN_STATUS_UNSUPPORTEDfor a target that differs, leaving the session on the one it has. - Reserve reattach for a target the live session cannot take: a new
graphics context or device, a target that
set_targetreports as unsupported, or a context that was lost. - Set the render request after any resize.
- The render loop owns the session, so an in-place resize is a local call. The
map applies the new logical size on the runtime loop’s next
pump, sorender_updatereports a pending size until then.
Reattach
Section titled “Reattach”Reattaching is for a target the live session cannot take, not for a resize. It happens entirely on the render loop thread, which owns both the session and the graphics resources. The sequence MUST be:
- Close the session, then destroy and recreate the host texture or surface.
- Attach the render target again against the published map.
- Set the render request.
Closing the session first matters: the render loop owns both the session and the graphics resources it borrows, and the session must stop referencing a texture before that texture is destroyed.
Profile sections define which host events trigger resize (Desktop profile → Resize triggers or Mobile profile → Resize triggers).
Diagnostics
Section titled “Diagnostics”- SHOULD register a native log callback during startup and clear it on shutdown.
- On setup or camera failure, print a short message including the native status and diagnostic strings returned by the C API.
- On startup, emit the items listed in Startup step 8 through the profile logging sink.
Graphics API
Section titled “Graphics API”Attach descriptors and shared context handles for each graphics API the example binary targets. Implement only the modes required by the active profile (Render-target coverage).
Vulkan
Section titled “Vulkan”- One shared Vulkan context (
VkInstance,VkDevice, queue, andVkSurfaceKHR) for compositor and render session. owned-texture: Vulkan owned-texture descriptor with those shared handles.borrowed-texture: exportableVkImageand view sized to the viewport; borrowed-texture descriptor.native-surface: surface / swapchain presentation descriptor for the hostVkSurfaceKHR.
A host swapchain in the texture modes MUST:
- Name the outgoing swapchain as
oldSwapchainwhen it builds the replacement, and destroy the retired one after that call returns. Destroying first leaves the surface without images, and the window goes black until the replacement presents. - Hold one present-wait semaphore per swapchain image and wait on the one that belongs to the acquired image. A semaphore shared across images lets one frame’s submit signal it while another frame’s present still waits on it, which Vulkan forbids and which presents half-drawn frames.
- Rebuild after
VK_SUBOPTIMAL_KHRfrom acquire or present, as it rebuilds afterVK_ERROR_OUT_OF_DATE_KHR. A suboptimal swapchain presents frames that no longer match the surface, and it stays suboptimal until it is rebuilt.
native-surface: Metal surface descriptor for the hostCAMetalLayer.owned-texture: Metal owned-texture descriptor; shared device and layer handles required by the C API.borrowed-texture: exportable Metal texture sized to the viewport; borrowed-texture descriptor.
A host compositor MUST treat a CAMetalLayer that hands out no drawable as a
frame to retry. A minimized or occluded window has none to give, and the
drawable pool empties under load.
OpenGL / EGL / WGL
Section titled “OpenGL / EGL / WGL”native-surface: OpenGL/EGL/WGL surface descriptor for the host platform GL surface.owned-texture: OpenGL owned-texture descriptor; shared GL context handles required by the C API.borrowed-texture: exportable GL texture sized to the viewport; borrowed-texture descriptor.
Render-target coverage
Section titled “Render-target coverage”| Profile | Required modes on every graphics API build variant the example ships |
|---|---|
| Desktop | owned-texture, borrowed-texture, native-surface |
| Mobile | native-surface |
Desktop profile
Section titled “Desktop profile”Desktop *-map examples add:
- One top-level resizable map window.
- CLI render-target mode selection across all three modes.
- Keyboard and mouse camera controls.
- Graceful process exit when the user closes the window.
Render-target selection
Section titled “Render-target selection”The process MUST accept a render-target mode name:
| Mode | CLI value |
|---|---|
| Session-owned texture | owned-texture |
| Caller-owned borrowed texture | borrowed-texture |
| Native window surface | native-surface |
The mode is a required positional argument (for example
zig-map owned-texture). There is no default mode.
On --help, print usage listing the three mode names and exit 0 before
creating a window. On invalid arguments, print usage listing the three mode
names and exit 1 before creating a window.
Other flags
Section titled “Other flags”The only permitted flag is --help. Implementations MUST NOT add other CLI
flags.
Shell and window
Section titled “Shell and window”- Initial logical size:
960×640pixels. - Window MUST be resizable.
- High-DPI / Retina: derive map
RenderTargetExtentfrom the window’s drawable size and content scale (see Viewport). - Shutdown triggers on window close.
Startup logging
Section titled “Startup logging”On startup, print the items listed in Startup step 8 to stdout, plus the control help text from Input below.
Control scheme
Section titled “Control scheme”Implementations MUST provide the following interactions and MUST print this help text once at startup:
Controls: left drag: pan right drag or Ctrl+left drag: rotate with X, pitch with Y scroll: zoom at cursor arrows or WASD: pan + / -: zoom at center Q / E: rotate ] / [: pitch 0: reset pitch and bearingBehavioral constants
Section titled “Behavioral constants”| Interaction | Behavior |
|---|---|
| Left drag | move_by with pointer delta in logical coordinates. |
| Right drag, or Ctrl+left drag | Adjust bearing by 0.5 × Δx degrees; adjust pitch by 0.5 × Δy degrees (same sign convention everywhere). |
| Scroll | Zoom about cursor: scale_by(2^(Δ * 0.25), anchor). Δ from the toolkit wheel event; scrolling up zooms in (use OS-adjusted deltas as reported—do not undo platform scroll inversion). |
| Arrow keys / WASD | Pan 120 logical units per key press. |
+ / - |
Zoom 1.25 / 1/1.25 about viewport center. |
Q / E |
Bearing ±10° with keyboard animation. |
] |
Pitch +5° (clamped to [0, 60]) with animation. |
[ |
Pitch −5° (clamped to [0, 60]) with animation. |
0 |
Animate bearing and pitch to 0 with keyboard animation. |
Keyboard animated moves SHOULD use ~160 ms duration. Pointer drags use
immediate move_by / jump_to / pitch_by.
On pointer down that starts a drag, cancel in-flight camera transitions before applying deltas, and set the map’s gesture-in-progress state. Clear that state when the drag ends, and hold it for the whole drag when a second button goes down and up during one. Keyboard interactions are discrete commands and leave the state clear.
Input handlers return whether the camera changed so the render loop can set the render request.
Resize triggers
Section titled “Resize triggers”- Subscribe to window size, framebuffer size, and display-scale / content-scale events (as available on the platform).
Mobile profile
Section titled “Mobile profile”Mobile *-map examples add:
- A full-screen or layout-driven map view embedded in the platform app shell.
- Touch camera controls.
- View lifecycle integration (appear, disappear, foreground, background).
Lifecycle
Section titled “Lifecycle”Mobile examples keep runtime and map state alive across brief disappear and background transitions. They tear down only on view destruction or app termination.
Track view visibility and app foreground separately. Run the display-paced render loop only while the view is visible and the app is in the foreground. The runtime loop keeps running across these transitions, so loading continues while the view is off screen.
When the host toolkit supplies a fresh presentation surface on the graphics
context the session attached with, hand it over with set_target on the render
loop thread, which is the thread that attached the session. The session keeps
its renderer, so the map returns warm rather than rebuilding its tiles and
atlases.
Close the render target only when the graphics context itself is gone, on that same render loop thread. Keep runtime and map handles alive either way, and attach again on that thread once a context and surface exist, per Reattach.
| Transition | Behavior |
|---|---|
| View will appear | Mark the view visible. If the app is in the foreground, start the render loop, refresh viewport, hand a parked session its surface or attach the render target, and set the render request. |
| View did disappear | Mark the view not visible. Stop the render loop. Park the session when the presentation surface goes away and the graphics context survives; close the render target only when that context is gone. |
| App foreground | Mark the app foreground. If the view is visible, start the render loop, refresh viewport, hand a parked session its surface or attach the render target, and set the render request. |
| App background | Mark the app background. Stop the render loop. Park the session when the presentation surface goes away and the graphics context survives; close the render target only when that context is gone. |
| View destroyed / app termination | Run Shared shutdown. |
Entry and shell
Section titled “Entry and shell”- The map view fills the available layout area or the screen.
- Derive the initial viewport from the view’s layout bounds and content scale after the view is on screen.
- Attach
native-surfacefor the hostCAMetalLayer,VkSurfaceKHR, or platform GL surface supplied by the view. - Shutdown follows view destruction or app termination.
- Minimal platform bundle files required to run on device or simulator are permitted. Store distribution and installer UX remain out of scope.
Translate platform touch input into distinct one-finger pan, two-finger scale-rotate, two-finger shove, and double-tap gesture states. Platform gesture recognizers and custom touch trackers are both valid implementations when they produce the required camera operations. Scale and rotation share one two-finger state so a single gesture can zoom and rotate in the same update. Shove is an exclusive two-finger vertical state selected only when vertical centroid motion dominates before scale or rotation begins.
Control scheme
Section titled “Control scheme”Implementations MUST provide the following touch interactions:
| Interaction | Behavior |
|---|---|
| One-finger drag | move_by with pointer delta in logical coordinates. |
| Pinch | Apply incremental scale deltas while preserving the geographic coordinate under the current two-touch centroid, resetting the scale baseline after each applied delta. |
| Two-finger rotate | Apply incremental bearing deltas from the change in the two-touch vector angle while preserving the geographic coordinate under the current two-touch centroid. |
| Two-finger vertical drag (shove) | pitch -= 0.1 × Δy degrees (clamp to [0, 60]), where Δy is the change in two-touch centroid Y in logical coordinates since the last applied update. |
| Double-tap | Zoom to round(zoom₀) + 1.0 about the tap location with animation (~160 ms). |
On any gesture begin, cancel in-flight camera transitions before applying deltas, and set the map’s gesture-in-progress state. Clear that state when the gesture ends, including when the platform cancels it. Gestures that run concurrently share one state, so a gesture ending while another is still live leaves it set, and the last one to end clears it. Double-tap is a discrete animated command and leaves the state clear.
Input handlers return whether the camera changed so the render loop can set the render request.
Resize triggers
Section titled “Resize triggers”- Subscribe to layout changes, orientation changes, safe-area changes, and display-scale / content-scale changes (as available on the platform).
Logging
Section titled “Logging”- Emit Startup step 8 items and viewport diagnostics through the
platform log sink (for example
OSLogon Apple platforms orlogcaton Android). - Control help is not required on mobile.