Building for WASM & iOS
Nimbus is dual-target: it runs natively on desktop and in the browser via WebAssembly, with an additional iOS target via UniFFI Swift bindings.
WASM
Building
The release build enables SIMD128 for vectorized geometry processing:
RUSTFLAGS='--cfg=web_sys_unstable_apis -C target-feature=+simd128' \
wasm-pack build --target web --release --out-dir pkg
Output goes to pkg/ as an ES module ready for <script type="module">.
Keep the wasm-opt feature list explicit
[package.metadata.wasm-pack.profile.release] names each wasm feature on
purpose. Passing --all-features instead lets wasm-opt emit post-MVP
encodings (multibyte imports, custom descriptors) that no browser engine
parses, and the page dies at load with
CompileError: WebAssembly.Module doesn't parse at byte 619.
Serving
The demo is a Bun project in web/:
It serves the repository root, because web/index.html imports ../pkg/nimbus.js
and the wasm bundle lives outside web/. Port 8080 is what scripts/profile-wasm.mjs
expects (scripts/profile-zoom.mjs starts its own server instead). The browser needs
WebGPU; on Chrome without WebGPU enabled by default, launch it with
--enable-unsafe-webgpu.
Any static server rooted at the repository root works as well
(python3 -m http.server 8080), but without the routing proxy below.
Routing
web/server.ts proxies /api/route to a Valhalla
compatible service, which is what calculon speaks. The API key, when there is one,
stays on the server and never reaches the page:
The proxy decodes the encoded shape (Valhalla uses six decimal places, not the usual five) and answers with plain coordinates, the distance, the duration and the turns:
<route-panel> in web/routing.js is the UI over it: two places, a travel mode, the
turn list, and the route drawn with map.addPolyline. Picking either end or changing
the mode re-routes on its own, the route is framed on screen, and clicking a turn
centres the map on it. The ◎ buttons take the next map click as that end, naming it
by reverse geocode.
Searching for a place
The route is drawn with a narrow line over a rim of its own hue rather than a wide
white casing, and both ends of the palette swap when the system colour scheme does.
Clicking a turn draws that stretch over the route and frames it; clicking it again
puts the whole journey back. The router is asked for a couple of alternatives, which
appear as chips and are drawn faintly under the chosen one; near-identical answers
are dropped rather than offered twice. Fastest and Shortest are the same journey judged by
time or by distance (Valhalla's shortest costing option). Links open the journey in
Google Maps and HERE WeGo, and the route itself lives in the query string, so a link
carries the whole thing.
<locality-input> (web/locality-input.js) is the search field, over a port of
maps-js's LocalitiesService (web/localities.js): the request shaping and, the part
that matters, the session id shared by a round of autocomplete calls and the details
call that ends them.
It searches addresses, localities, postal codes and places of interest together, marks
what the query matched, and takes a pasted lat, lng as itself. /api/localities/*
proxies to Woosmap so the key stays on the server.
Framing a bounding box
map.fitBounds(west, south, east, north, { top: 16, right: 16, bottom: 16, left: 336 });
map.fitBounds(west, south, east, north, 24); // the same on every side
map.fitBounds(west, south, east, north, 24, 15); // ...and never past z15
Padding is in logical pixels and keeps that much of the window free, so a panel over
the map does not sit on what was framed: the zoom fits the bounds into what is left,
and the centre shifts by half the difference between opposite sides. Same
construction as mapbox-gl-js's cameraForBoxAndBearing, which is what maps-js's
fitBounds calls through to.
Drawing your own geometry
map.addPolyline("route", [lon0, lat0, lon1, lat1, ...], {
color: "#1a73e8",
width: 6,
dash: [2, 2], // optional, in stroke widths
casing: { color: "#ffffff", width: 12 }, // optional, drawn underneath
});
map.removePolyline("route");
map.clearPolylines();
Coordinates are a flat lon, lat list. A polyline is not tied to a tile: it is never
faded in, never clipped to tile bounds, and it draws over the map but under labels.
WASM Entry Point
src/web.rs is the WASM entry point. It:
- Creates a wgpu surface from an HTML
<canvas>element - Initializes
MapRendererwith a WASM-compatible HTTP client (src/wasm_fetch.rs) - Loads style, sprite, and glyph data asynchronously
- Drives the render loop via
requestAnimationFrame
Module Workers
Tile decode and tessellation are offloaded to module workers to keep the main thread free for rendering.
graph LR
Main[Main Thread] -->|postMessage tile bytes| Worker[Module Worker]
Worker -->|decode MVT + tessellate| Worker
Worker -->|postMessage vertices| Main
- Workers use
src/tile_loader/wasm_worker_entry.rsas their entry point - Data is serialized with postcard (compact binary, no JSON overhead)
WorkerTileResultis the wire type between worker and main thread- During initialization (before workers are ready),
spawn_localis used as a fallback
Tile cache
Tile responses are cached twice over. The browser's HTTP cache handles repeats
(the tile endpoint sends Cache-Control: public, max-age=86400), and
web/vt_worker.js keeps its own Cache API store named woosmap-tiles:
- cache-first, falling back to the network, then storing the response
- entries are keyed by the URL without its query string, so rotating the API key does not throw away the whole store
Cache-Control: no-storeis honoured, and anything expiring within 7 minutes is not stored at all- about 500 entries (~50MB), trimmed oldest-first every 50 inserts
Measured on a reload with a warm store: 48 network tile requests on first load, 0
on the reload. Same approach as mapbox-gl-js's src/util/tile_request_cache.js.
To inspect or clear it: DevTools > Application > Cache Storage > woosmap-tiles.
WASM-Specific Optimizations
- Global allocator:
talc::TalckWasmreplaces the default allocator for better WASM performance - Release profile:
opt-level = 2(not"s"),wasm-opt = ["-O3"] - Gzip decompression: Uses browser's
DecompressionStreamAPI instead of flate2 - Async HTTP:
src/wasm_fetch.rswraps the Fetch API
Glyph Loading on WASM
Range 0 (U+0000-U+00FF) is fetched for every font stack as soon as the style is applied:
get_glyph_info()returns the glyphs URL template + unique font stacks from resolved layerswasm_fetch::fetch_bytes()fetches PBF glyph rangesparse_pbf_glyphs()+atlas.add_glyphs()populate the atlas
Every other range is fetched only once a label turns out to need it, because which codepoints a tile's labels contain is not known until the tile is extracted:
- Extraction runs on a worker, which cannot fetch anything, so a glyph it cannot
resolve is recorded in
ExtractedTextData.demandsand travels back with the tile MapRenderer::pump_glyph_loadingfetches those ranges off the main thread- The atlas is republished (
publish_glyph_atlas_update), which bumps only the extraction context version and drops the tiles that asked, so they extract again
Complex scripts
Myanmar, Khmer, Thai, Lao, the Indic scripts, Arabic and Hebrew cannot be drawn one codepoint at a time: they reorder, stack marks, join and run right to left.
Only the runs that need it are shaped. A label like Street 390 ផ្លូវ ៣៩០ keeps its
Latin on the ordinary path, in the style's own font and sharing its glyphs with every
other label; the Khmer run is shaped and packed into the atlas as a single entry,
drawn as one quad, turned to the tangent when it sits on a line. Right-to-left labels
are the exception: they are shaped whole, Latin included, because the bidirectional
algorithm decides the order across the boundary and the two parts cannot be laid out
separately. Those labels need fallback configured, and only those.
A newline in a label is a line break, not a character to draw: styles write both of a place's names into one field separated by one. On a line label, where two names cannot both follow the same road, the first is used.
Shaped runs are stored under their own key (complex:<fontstack>) with ids above every
Unicode codepoint, so one lookup finds either kind and a run can never be mistaken for
a glyph. Their top is converted to the PBF convention on the way in: PBF glyphs
measure it from a fixed ascender line (PBF_ASCENT), not from the baseline.
The shaper is a separate wasm module, pkg-shaper/, built by make wasm-shaper
(which make wasm runs for you). The map's own module does not contain it, and
neither do the tile workers: src/complex_shaper_shim.js imports it the first time a
label actually needs shaping, and never otherwise. If it cannot be loaded, those
labels are not drawn and nothing else changes.
Shaping needs outline fonts, which the PBF glyph endpoint does not serve, so they are configured separately, by the style:
"complexFonts": {
"template": "https://example.com/fonts/{script}.ttf",
"overrides": { "khmer": "https://example.com/fonts/NotoSansKhmer-Regular.ttf" }
}
or by the page, which takes precedence:
map.setComplexFonts({
template: "https://notofonts.github.io/{script}/fonts/NotoSans{Script}/hinted/ttf/NotoSans{Script}-Regular.ttf",
overrides: { bengali: "https://.../unhinted/ttf/NotoSansBengali-Regular.ttf" },
fallback: "https://.../NotoSans-Regular.ttf",
});
map.setComplexShaperUrl("/pkg-shaper/nimbus_shaper_wasm.js"); // the default
web/index.html is wired this way already. Without a font for a script, its labels are
simply not drawn. {script} is the lowercase name, {Script} the same capitalised, so
one template covers a whole font repository. The names are myanmar, khmer, thai,
lao, devanagari, bengali, tamil, arabic, hebrew.
A label can carry a script nothing has a font for: Libyan places are labelled in Latin, Tifinagh and Arabic, and neither the PBF font nor Noto's per-script files cover Tifinagh. Those characters are left out rather than drawn as the font's empty box.
Characters drawn on the device
A CJK font is megabytes, so glyph servers do not carry the ideographs: asking for the
range covering 東 returns a valid, empty response (19968-20223 is 552 bytes and holds
no glyphs; the kana ranges 404). Every renderer that shows Chinese, Japanese or Korean
draws those characters itself from a font the device already has, and this one is no
different.
A missing glyph in one of those ranges is reported as a character to draw rather than a range to fetch. The main thread draws it with Canvas 2D at the same 24px base, crops it to its ink, turns the coverage into the same distance field a PBF glyph carries, and packs it into the atlas under the ordinary font stack and codepoint. Nothing downstream can tell the difference, and the glyph is reused by every label containing that character. These scripts need no shaping, so this is per character, not per label.
The default is the device's own sans-serif, which on anything that reads these
scripts covers them. Native builds have no canvas and do not draw them yet, so CJK is
missing from the offscreen renderer and iOS.
Note that fonts must be TTF or OTF outlines. A woff2 is compressed and a font
collection (.ttc) may not parse; either is rejected on registration and logged, and
that script's labels do not render.
Growing the atlas, and running out of it
Growing the glyph texture recomputes every UV rect, so geometry extracted before it points at the wrong glyphs. When that happens the whole extraction context is republished rather than only the tiles waiting on the new glyphs, which is why shaped labels are added in small batches: each batch that grows the atlas costs a full re-extraction.
The texture stops doubling at 4096 rows. One shaped label is a bitmap of its own, around 8000 pixels, so a few hundred of them fill it, and a full atlas refuses everything after it, glyph ranges included: a map panned far enough would stop drawing labels it had not drawn before, anywhere, and never recover.
So the atlas evicts. Every glyph keeps the 8-bit bitmap it was blitted from (an eighth of what the texture costs, and never sent to a worker), and when there is no room left the oldest half of whatever can be produced again locally is dropped and the rest laid out again from those bitmaps, with no network involved. That means shaped runs and characters drawn on the device alike, by age, so neither script starves the other. Fetched glyphs are always kept: there are few of them, every label shares them, and each one costs a round trip. Evicting moves every glyph, so it bumps the layout generation and everything is extracted again.
Two things keep the pressure down. Demands are only taken from tiles the viewport actually wants, because a coarse prefetch tile at low zoom covers a subcontinent and its labels would otherwise pull down a font for every script in it. And a label is rasterised once, at a fixed 24px base, as a distance field: its size on screen is a scale factor, so zooming never re-rasterises anything.
Models
loadModel takes either kind of model, told apart by its first bytes:
.nmdl, baked bynimbus-bake(see Models): read in the map's own module, with nothing to fetch first. The 205 loads in under 200 ms, against 400–500 ms as glTF.- glTF: read by a separate module, as below.
glTF is read by a separate wasm module, nimbus_model.js in the map's own pkg/,
built by make wasm-model (which make wasm runs first: its --no-pack build drops
the folder's package.json, which the map's build then writes). The gltf crate is
not in the map's module or the tile workers; src/model_reader_shim.js imports the
reader the first time a model is read, and a map with no models never fetches it.
import init, { loadModel } from "../pkg/nimbus.js";
map.addModel("car", await loadModel(bytes)); // .nmdl or glTF; the browser decodes the textures
setModelReaderUrl("/elsewhere/nimbus_model.js"); // only if it is not served beside nimbus.js
The reader hands the model back as postcard with its images still encoded, and the
browser decodes them (createImageBitmap, no colour conversion, no premultiplying),
so the web build carries no image decoders either. Native builds, the Swift one
included, read and decode in nimbus-model directly through load_model, which
takes either kind too: MapCoordinator.addModel(id:glb:) accepts .nmdl bytes as
they are.
iOS (UniFFI)
Architecture
src/ffi.rs exposes a Swift-friendly API via UniFFI 0.28 (proc-macro style).
MapEngineInner holds the wgpu Instance/Device/Queue and optional Surface/MapRenderer. unsafe impl Send/Sync is used because wgpu types are thread-safe on Metal.
Callback Interface
The Swift side implements MapViewDelegate as a callback interface:
The delegate is wrapped in Arc<dyn MapViewDelegate> internally for the waker closure that signals new tile arrivals.
Surface Creation
The surface is created from a raw CAMetalLayer pointer:
Building the XCFramework
This runs build-xcframework.sh, which:
- Builds for
aarch64-apple-darwin(macOS ARM) - Builds for
x86_64-apple-darwin(macOS Intel) - Builds for
aarch64-apple-ios(iOS device) - Builds for
aarch64-apple-ios-sim(iOS Simulator) - Packages into an
.xcframeworkwith generated Swift bindings
The Swift package is defined in WoosMapView/Package.swift as a binary target.
MSAA Disabled
MSAA is disabled in the FFI path (sample_count = 1) because MTKView's MSAA resolve triggers a Metal texture size mismatch. sRGB correction is controlled via a uniform flag instead.