Skip to main content

The web (wasm) build

The same all/native core, compiled by emscripten and rendering into a <canvas> through WebGL 2. It exists for the style preview on the website — and as a development loop that does not need an Android emulator.

source ~/emsdk/emsdk_env.sh
python3 scripts/build-web.py --profile lite --configuration RelWithDebInfo --build-demo
python3 web/demo/serve.py # http://localhost:8088

Add --website to put the same module under website/static/preview, which is what the documentation site's style preview page runs.

Size, measured with emscripten 6.0.9 on the lite profile:

ArtefactReleaseRelWithDebInfo
.wasm4.47 MB348.9 MB
.wasm gzipped1.74 MB
.wasm brotli1.30 MB
.mjs loader265 KB429 KB

DWARF is the whole difference: RelWithDebInfo is 78x the size and is a development artefact only - never quote it, and never deploy it. Over the wire the number that matters is the compressed one, and GitHub Pages serves brotli, so the module costs about 1.3 MB.

A preloaded font adds its own .data file on top (3.0 MB for the bench's fonts and styles; Roboto alone is 306 KB).

What has been tried on size, and what it cost

Release compiles with -Oz. Two things that look like free wins are not, both measured on the same tree:

.wasmgzippedbrotli
-Oz, as shipped4 687 9261 823 1101 367 486
+ -flto=thin4 800 1681 845 5411 385 803
+ -flto (full)4 978 6201 877 0091 408 265

LTO makes it bigger - 2.4% thin, 6.2% full, and still bigger after compression. Cross-module inlining wins back less than it spends, which is the usual way -Oz and LTO disagree: -Oz is already refusing the inlines LTO then goes and takes. The Android build uses -flto=thin and is a different trade; do not copy it here without measuring.

Passing -Oz at LINK as well changed the output by zero bytes - emcc already runs wasm-opt from the compile flags recorded in the objects - so it is not set either.

What is left is emscripten's closure pass on the loader (265 KB, so worth little) and dropping pthreads, which is not available: the tile pools need them.

Use --configuration RelWithDebInfo while developing, but do not quote its size: it carries DWARF and the .wasm comes out around 340 MB.

The bench

web/demo is the browser's answer to the Android demo's intent extras: the map is configured from the query string, so a camera and a style are a link.

ParameterMeaning
lon, lat, zoomThe camera. Defaults to Paris at z12
sourceTile URL template. A .png/.jpg/.jpeg/.webp in it means raster, anything else vector
cssURL-encoded CartoCSS, used when the source is vector
projectA CartoCSS project directory under web/demo/styles, preloaded like the fonts
styleWhich entry file of that project to use, without its extension. Default project
terrainA DEM tile URL or .pmtiles archive; turns on 3D terrain
terrainMaxZoomDeepest level of that DEM. Default 12, which is Mapterhorn's

project is how a converted MapBox style is previewed:

curl "https://api.mapbox.com/styles/v1/mapbox/standard?access_token=$TOKEN" -o standard.json
node tools/style-cli/dist/cli.js mapbox2css standard.json web/demo/styles/mapbox-standard \
--source-schema mapbox

Pass --sprite-key or there are no POI icons at all: Mapbox serves the sprite sheet from the style's own URL, which needs the token, and without it the converter says Sprite not loaded ... icons will be dropped and carries on.

Mapbox Standard's 150 layers convert and render. CompiledStyleSet wants the entry file at the root of its package, so the package is the project directory and the style name is the file without its extension - project, or night for one of the themes the converter also writes.

Its labels name fonts nobody has (DIN Pro Medium), and MBVectorTileDecoder asks for a font by name STRICTLY so that a font list can fall through to its next entry. The bench therefore registers every TTF in /fonts with addFallbackFont, which is what makes the labels appear at all.

?zoom=14&source=<url-encoded MVT template>&css=<url-encoded CartoCSS>

The style is passed in rather than fetched because main() runs on the browser's main thread, where a synchronous fetch is illegal. The JavaScript binding is what will replace it.

Copy web/demo/config.example.json to web/demo/config.json to set what /demo/ shows with no query string at all. It is gitignored because it holds a tile-provider token; the query string still wins over it. The buttons in the corner fly the camera through the facade, which is the shortest example of driving the map from JavaScript.

Gestures

WebMapView handles the mouse itself and leaves touch to the SDK's TouchHandler, which already knows pinch and two-finger rotate. The speeds are ported from maplibre-gl-js (src/ui/handler/mouse.ts, scroll_zoom.ts) so the map feels like any other web map:

InputWhat it doesConstant
DragPanthe SDK's own
WheelZoom toward the pointer1/450, sigmoid 2/(1+exp(-abs(Δ·rate)))
Trackpad pinch (wheel + ctrl)Zoom toward the pointer1/100
Right-drag, or ctrl-dragRotate and tilt0.8 and 0.5 degrees per CSS pixel
Double-clickZoom in 1, shift for out, toward the pointer0.3 s

Two sign traps, both found by feel and then explained:

  • Both rotate and pitch are negated against maplibre's constants. maplibre turns the camera's bearing where rotate() turns the map under it, and this SDK's tilt is 90 looking straight down where mapbox's pitch is 0 there. Taken as written the map moved the wrong way on both axes.
  • The speeds are per CSS pixel, but pointer coordinates are scaled to device pixels for onInputEvent, so the delta has to be divided back out or a drag turns the map twice as far on a 2x display.

A mouse also needs a much smaller click tolerance than a finger: ClickHandlerWorker would not start panning until the pointer had travelled 0.2 inch, which is about 32 CSS pixels and made every drag feel stuck. Options.clickMovingTolerance names that threshold in dp - still 32 by default, so Android and iOS are unchanged - and the web host sets 3, which is maplibre's clickTolerance.

Zoom: Options.ZoomOffset, and the three places a convention hides

The SDK calibrates zoom the way a 256-pixel slippy map does; maplibre and mapbox-gl calibrate on a 512-pixel tile, so the same NUMBER is one level closer there. Measured at zoom 13 in Paris on a 2x display, before: massif 12.5729 m per CSS pixel against maplibre's 6.2864 - a ratio of exactly 2.0000. The web host now sets Options.ZoomOffset = 1 and the same measurement gives 6.2864, ratio 1.00000. Every other platform keeps the SDK's own convention; the option defaults to 0.

It is a renumbering and nothing else. Offset 1 at zoom 13 is offset 0 at zoom 14 - the same camera, the same tiles, the same picture, a different number. That was checked by screenshotting both and comparing: identical down to the street labels, with only the readout differing.

Getting there needed three things to move together, and each of the first two was tried alone and was wrong in a way that looks like the answer:

The cameraViewState::calculateZoom0Distance. Alone, it renumbers the camera and leaves the tiles behind, so the same tile is drawn twice as large - every label and line with it.
The tile levelTileLayer's targetTileZoom cap. It is this cap, not the screen-area rule, that ties a tile level to the zoom number. Scaling the area rule instead is a trap: it fetches a level COARSER and draws it bigger, which doubles labels just the same.
The rendererViewState::getRenderZoom, handed to vt::ViewState. vt sizes everything by 2^(zoom - tileZoom), so it has to be told the zoom the tiles were picked for. Miss this and labels are still double, with everything else already right.

The arithmetic is all/native/graphics/ZoomConvention.h, header-only and covered by tests/api/ZoomConventionTest.cpp - which asserts the traps above rather than only the happy path.

Two things the offset deliberately does not do. A raster source's 256-pixel tiles are drawn at 512 points and blur, exactly as they would in maplibre without tileSize: 256; set TileDrawSize to compensate. And a style's zoom stops do not move: they are evaluated at the TILE zoom (mapnikvt/TileReader.cpp), which the offset leaves alone, so a converted MapBox style is no more and no less aligned than before.

TileDrawSize picks the tile, it does not magnify the style

The other way to adopt maplibre's convention is TileDrawSize = 512, which renumbers the camera and moves the tile the layer picks - z13 tiles at view 13.2, as maplibre fetches them, where the offset alone leaves the SDK on z14. It used to double every label and line with them: vt sizes a style in fractions of the tile it was DECODED against, that was fixed at 256, and the tile was then painted 512 dp wide. Measured in the preview grid at z13.51: a residential casing of 2.68 style px drew 5.36 CSS px against maplibre's 2.68.

VectorTileLayer now hands TileDrawSize to the decoder beside the pixel scale, so a style pixel is a dp whichever size is set. Both are read when the layer JOINS the map - set them before adding it, and a later change reaches the camera but not the style.

Method: screenToMap at two points a known number of DEVICE pixels apart (the centre of the drawing buffer maps exactly to focusPos, which is how the convention was confirmed), divided by devicePixelRatio. Do not pan and read focusPos - kinetic pan keeps gliding after mouseup and inflated the first measurement by 1.26x.

A drag that ends off the canvas

Mouse down is bound to the canvas, but move and up go to the document, and blur to the window - maplibre's own arrangement, for the reason its handler_manager.ts gives: there is no pointer capture to lean on, so a release outside the canvas only ever reaches a document-level listener. Bound to the canvas, a drag that ended anywhere else never saw its mouseup, the map stayed in the drag, and every later hover panned it.

Two things follow. The move handler must ignore everything unless a drag is actually running, because it now sees the pointer crossing the whole page. And coordinates have to come from clientX/clientY minus the canvas rect, not from targetX/targetY - those are relative to whatever the listener was bound to, which is no longer the map.

Labels shredded by buildings from zoom 12

Not a glyph bug, though it looks like one: the text is drawn and then PAINTED OVER, so the letters come out with pieces missing. It starts at zoom 12 because that is where OpenMapTiles begins serving building.

Buildings default to RENDER_ORDER_LAST and are drawn after the flat label pass, by design. The answer is not to move that order but to make the labels billboards - text-placement: billboard for a point, billboard-line-repeat along a line - because the billboard pass is the one that runs after the buildings (TileRenderer::onDrawFrame3D). The starters do that.

Nothing web-specific here; it is how the renderer is meant to be used, and the same style would behave the same way on a phone.

Two double-click zooms

The host listened for the browser's dblclick and zoomed. So did the SDK, from the pointer stream it is already given: TouchHandler::doubleClick puts the map into SINGLE_POINTER_ZOOM, which is BOTH the double-tap zoom and the double-tap-and-drag zoom, and on release it only adds the tap zoom when the gesture did not actually drag (singlePointerZoomStop).

A double-tap-and-drag therefore zoomed continuously, and then zoomed once more when the button came up - the browser's dblclick arriving on top of a gesture the SDK had already handled. There is no dblclick listener now; the SDK owns the gesture.

A flight and a gesture fighting over the camera

TouchHandler cancelled the pan, rotation, tilt and zoom animations whenever the user touched the map, but not a flight - stopFlight was called from nowhere except the facade method. So a flyTo still in the air kept interpolating along its own path while the drag moved the camera too, and the map flickered between the two: the flight's position, which near the start is still the place you left, and the place you had just dragged to. Every platform, not only the web.

The fix is one more line at each of the eleven places the other four are stopped.

While there: use flyTo, not fitBounds, to travel. fitBounds moves through the pan and zoom animations, whose duration is taken literally, so a fixed one drifts across a country at the same rate it crosses a suburb. flyTo follows van Wijk's arc and, given a duration of 0, picks S / 1.4 seconds itself - maplibre's rule, already implemented in AnimationHandler and easy to override by accident. Paris to Grenoble comes out at 4.7 s.

Range requests, and why PMTiles failed on the web

A PMTiles archive is read by HTTP range, and HTTPClient checked the Content-Range of every 206 against the offset it asked for. A browser hides Content-Range from the page unless the server sends Access-Control-Expose-Headers, and tile hosts generally do not - so the header read as absent, the offset compared as 0, and every range request was rejected with Content range mismatch: 0/127. The check now applies only when the header is actually readable: the range was honoured either way, and refusing it makes every PMTiles archive unreadable in a browser.

Mapterhorn's planet archive (https://download.mapterhorn.com/planet.pmtiles) is 705 GB of Terrarium-coded WebP, z0-12, and serves access-control-allow-origin: * - only the ranges actually read are fetched.

3D terrain, and what a desktop should set differently

?terrain=<url> attaches a DEM. Verified against Mapterhorn's planet archive and against AWS's terrarium tiles; both read the same elevation, so Terrarium is the right decoder for Mapterhorn (?demEncoding=mapbox switches to Terrain-RGB and is visibly wrong - the clearance shoves the camera out to zoom 6).

Elevation comes back in PROJECTED units, not metres: Mont Blanc reads 6849 where the mountain is 4808, because the Mercator scale at 45.8 degrees is 1/cos = 1.435. 4808 x 1.435 = 6900. Nothing is wrong with the DEM when that number looks too big.

The bench sets two things a phone would not:

SettingPhone defaultWebWhy
autoFlattenTilt / autoFlattenParallax88 / 20 / 0 (off)Auto-flatten drops the height field when the map looks straight down, to save a phone the cost. A desktop can hold it up, and dropping it every time the map returns to 88 degrees is a visible sink-and-rise
meshResolution64128Cells per tile edge, clamped to 2..256. 64 is a phone budget

Tile LOD: maplibre's numbers, and why buildings stop when you flatten the tilt

setTileLODProfile(TILE_LOD_PROFILE_REFERENCE) - maplibre's and mapbox's own values, unmodified.

The obvious complaint about them is that a tilted view loses its 3D buildings partway out, and that it gets WORSE the flatter you tilt, which sounds backwards. It is not, and it is worth writing down because two plausible explanations are both wrong.

The LOD applies no tilt penalty. Measured at the reference values: cosThetaExponent is -0.306 at tilt 24, 32, 60 and 90, and uniformLevelDrop is 0 at every one of them - the tile-count cap is inert here, exactly as its own doc says. Neither term moves with pitch.

The cliff is in the DATA. OpenMapTiles carries building from zoom 13 (transportation from 4, water and place from 0). Flatten the tilt and the far ground covers very few pixels, so the area rule correctly coarsens it - measured at tilt 18, the frame fetches z7 through z14. Every tile below 13 has no building geometry at all, so instead of simpler buildings you get none, while the roads and water keep drawing. The boundary moves toward the camera as you tilt down.

Buying it back means refining the far field, and that is expensive. Measured at 2.3376/48.8606, zoom 15.5, tilt 30, reading TileLayer.visibleTileCount:

MaxZoomLevelsOnScreenvisiblepreloadingvs the default
9.314 (maplibre, shipped)4471.0x
7.5107142.4x
6.0259185.9x

Not worth it: the boundary moves a little, the tile count multiplies, and the map reads as less smooth. What mapbox and maplibre actually do with that horizon is hide it behind fog, not buy tiles for it - FogOptions is the lever, and it costs nothing per tile.

Measuring any of this needs a camera move. No LOD option is in MapRenderer's option-change list, so changing one at runtime does not re-cull: the tile list, and the counter, stay as they were until the camera actually moves. Three identical readings in a row are the symptom.

Draw distance

Options.drawDistance defaults to 16, which is a phone's battery talking: tilt the map and buildings and terrain stop at a near band. The web host sets 96, because a desktop GPU can afford what mapbox and maplibre draw - from Chamonix at tilt 35 that reaches Lausanne.

DPI

syncCanvasSize sets Options.DPI to Const::UNSCALED_DPI * devicePixelRatio, the same thing the iOS host does with UIScreen's scale. Miss it and the SDK takes a 2x canvas for a 1x screen: every tile is drawn at half the size it should be, so roughly four times as many are on screen at once and the map looks tiny and loads slowly. It is set on every resize rather than once, so it follows a window dragged to another display.

Fonts

A style that names a font it does not ship falls through to SystemFontUtils, which on the web means /fonts/<name>.ttf in the virtual filesystem. Drop TTFs into web/demo/fonts/ and the link preloads the directory as /fontsRoboto.ttf there is what text-face-name: 'Roboto' resolves to. The directory is gitignored: fonts are a licensing question, so the bench asks for one rather than shipping one. Without it the build carries no font and a style with a text rule draws no labels.

The platform layer

web/native/ is the platform directory, the same shape as android/native and ios/native, and CMake globs it under elseif(EMSCRIPTEN). There is no wrapper directory: the web build has no SWIG bindings and is meant to be driven through the facade's C ABI.

FileWhat it does
network/HTTPClientEmscriptenImplThe Fetch API, called synchronously
ui/WebMapViewThe canvas host: WebGL 2 context, requestAnimationFrame, pointer/touch/wheel events
utils/AssetUtilsAssets from the emscripten virtual filesystem, under /assets by default
utils/SystemFontUtilsFonts from /fonts — a browser has no font API to read outlines out of
utils/PlatformUtilsPLATFORM_TYPE_WEB; the app identifier is location.origin, which is what a tile server checks
utils/ThreadUtils, components/TaskTrivial; a web worker has no priority to set
graphics/BitmapCanvasWebImplNot implemented — see below

vt::parseFontNames gained a web platform tag, so a style can write web:Inter next to android:Roboto.

The threading contract

The SDK's tile pools and its cull, label and billboard workers are real threads, so the build is -pthread and the page must be cross-origin isolatedCross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which is all web/demo/serve.py adds over http.server. Without them the browser refuses SharedArrayBuffer and the module never starts.

Two rules follow from that split, and both cost a debugging session to find:

  • GL lives on the main thread only. The context is created there and every frame is drawn from requestAnimationFrame. A GL call from a worker fails with GLctx is undefined.
  • requestAnimationFrame is main-thread only too. The redraw request comes from the tile and label workers, so WebMapView::requestRedraw marshals it with emscripten_async_run_in_main_runtime_thread. Calling it directly from a worker does nothing at all: the first frame draws, the flag stays set, and the map is frozen with tiles arriving behind it.

Isolation on GitHub Pages

The docs site is on GitHub Pages, which serves no custom headers at all — so the threaded module cannot start there. emscripten fails with SharedArrayBuffer transfer requires self.crossOriginIsolated before a single tile is fetched.

web/demo/coi-serviceworker.js is the way around it: registered from the page, it re-serves every response with the two headers and reloads once, which is what makes a static host isolated. COEP is credentialless, not require-corp — a tile server sends no Cross-Origin-Resource-Policy, and require-corp would block every tile.

Unverified. The mechanism is the standard one, but the embedded browser used to build this refuses to register any service worker, so the shim has never actually run. Check it in a real browser before relying on it:

python3 web/demo/serve.py --no-headers # serve the way GitHub Pages does

The page should reload itself once and then render; crossOriginIsolated in the console tells you which side of the fence you are on.

PTHREAD_POOL_SIZE=8 covers the pools plus the three workers. The pool is pre-warmed because pthread_create on the main browser thread cannot block waiting for a worker to spawn.

A synchronous emscripten_fetch is illegal on the main thread and legal on a worker, which is exactly where the SDK does its blocking tile loads — so the arrangement above is what makes the HTTP client work at all.

emscripten's response headers need trimming

emscripten_fetch_unpack_response_headers keeps the space after the colon and copies one character too many, so every value arrives as " 32818\n". Content-Length then fails boost::lexical_cast<uint64_t> and every tile load dies as bad lexical cast. The impl trims both key and value.

The JavaScript binding

web/js/massif.mjs wraps the facade's C ABI. It is deliberately thin - the facade is a table, so a new SDK feature reaches JavaScript without touching the binding:

const massif = new Massif(module); // module is the emscripten Module
const camera = await MassifCamera.attach(massif);
camera.zoom; // 13.29
camera.flyTo({ position: [2.35, 48.86], zoom: 15, duration: 1.5 });
massif.call(camera.handle, 'screenToMap', { x: 100, y: 200 });

Three things it has to get right, and each was a bug first:

  • -sEXPORTED_FUNCTIONS REPLACES the default list, so _main has to be in it or the program is stripped and nothing runs at all. The list is generated from MassifApiC.h by CMake, so a new mm_ function reaches JavaScript by existing.
  • The host has to adopt its map - MassifInterop::adopt("map", "map", view) - or the camera has nothing to point at.
  • await Module(...) resolves before main() has run when pthreads are on, so reading the map straight away is a race. MassifCamera.attach polls for it.
  • mm_call answers with a result HANDLE, not a string buffer. Read it like any other object and release it with mm_destroy_handle, or the context holds it forever. Reading it as a string buffer silently returned garbage for every method that produces a value.
  • Out-parameters must be zeroed first. _malloc does not, so a void method's result length came back as whatever was in that memory - 4, which then parsed as a control character.

bindings/typescript/massif.d.ts describes what can be passed; it is generated by scripts/gen-api-bindings.sh.

What the build does not carry

  • BitmapCanvas, and with it the Text and BalloonPopup vector elements. The other platforms draw those through a system 2D text API; the browser's is asynchronous and main-thread-only, which this synchronous interface cannot reach from a worker. Every call warns once and produces an empty bitmap.
  • The lite profile only so far: no sqlite, so no persistent tile cache, no offline packages, no routing or geocoding.
  • 3D terrain, shadows and the sky are barely tested here. They compile, but the MRT and depth-texture paths have never been run against a WebGL 2 driver. Raster tiles, MVT decoded through mapnikvt and styled by CartoCSS, and labels with halos and accented glyphs all render — that part is observed, not inferred.

The style preview on the documentation site

/preview on the site is this module with a React page around it. Two things are worth knowing.

locateFile has to be explicit. Emscripten's fallback resolves the .wasm and the preloaded .data against the DOCUMENT, and the page sits at a different depth in the two places it runs: /preview/ on the dev server, /preview.html once built. The built one asked for /massif-demo.data, one directory too high - a 404 that appeared only in production, which is exactly the kind that reaches a user first.

A rebuilt wasm needs a no-store dev server. Its URL never changes, so a browser will happily run yesterday's renderer against today's page - which reads exactly like "my change did nothing". website/plugins/style-preview sends Cache-Control: no-store for that reason. Cost an afternoon once: an A/B that appeared to disprove a fix was measuring the cached build.

Where the wasm comes from. It is a build artefact and is not tracked. .github/workflows/web-preview.yml builds it whenever all/native, libs-massif, web/ or the build scripts change on master, and uploads it as the web-preview artefact; docs.yml downloads the newest successful one into website/static/preview before building the site. That download is best-effort — without it the page says the module is missing and the rest of the site deploys unaffected. It is a separate workflow because docs.yml also runs on every documentation push and nightly for the roadmap page, and an hour of emscripten per typo is not a trade worth making.

The page adds no C++. It runs the same web/demo module and drives it entirely through the facade's C ABI: clear the layer list, build a layer from a spec, add it. The one thing that had to reach the facade for it is addFallbackFont on the style kind — a decoder built from a spec had no way to be given font bytes, so every converted MapBox style, which all name DIN Pro, lost its labels. web/demo/main.cpp also adopts its Layers now, which is what makes the map replaceable at all.

MapBox style JSON is translated in the page, by mapbox2css — the CLI's own TypeScript, which runs in a browser unchanged apart from sprites. sprite.ts used to reach straight for node:fs and pngjs; it goes through a SpriteHost now, and the browser one fetches the sheets and uses the exact PNG codec in png.ts. That codec exists because the obvious route — createImageBitmap into a 2D canvas — stores premultiplied colour, so reading it back changes every partly transparent texel. On an SDF sheet, whose alpha channel is a distance field and not opacity, that is not a rounding error but a mangled icon.

What emcc found that no other build did

stdext's utf8_filesystem::fseek64, ftell64 and ftruncate64 had a branch for Windows, Android and Apple and no #else — so on emscripten the functions had no body at all. That is undefined behaviour, and at -O2 LLVM folded it backwards through the caller: URLFileLoader reported that it could not open the file without ever calling fopen, so every file:// read on the web failed identically whether the file existed or not. The fix is a generic POSIX branch (fseeko/ftello/ftruncate), which is what Apple was already using.

Beyond that, the two latent portability bugs (stdext's unistring, harfbuzz's -Wunused) were already fixed for the style tools' wasm build, which is what proved vt, mapnikvt, cartocss, freetype and harfbuzz compile under emcc in the first place. What was new here is vt's GL renderer: the style tools build it with EXCLUDE_GL=ON, so GLTileRenderer had never seen emscripten until this build. It compiled unchanged.

Two dependencies needed wiring rather than fixing: zlib comes from libs-external (there is no system one, same as UWP), and pion is dropped (it is boost::asio sockets, which a browser has none of).