Use in the browser
npm install @reearth/ezuThe package ships browser, Node, and workerd builds — scalar and SIMD — behind
conditional exports, so your bundler picks one. The wasm module is stateful:
a Renderer holds the parsed style, its built graph, an in-memory asset bank,
and a per-tile binding buffer mirroring the style’s sources block.
The JS side owns all I/O. ezu never fetches. You supply decoded bytes.
The shape of a render
Section titled “The shape of a render”import init, { Renderer, simdEnabled } from '@reearth/ezu';
await init();const r = new Renderer(styleJson);
const z = 13, x = 7276, y = 3225;const mvt = new Uint8Array(await (await fetch(`/mvt/${z}/${x}/${y}`)).arrayBuffer());
r.clearSources();r.bindSource('basemap', mvt); // keyed by the `sources` entry name
// Fastest path to the screen: skip the PNG round trip.const rgba = r.renderTile(z, x, y, { format: 'rgba' });const w = r.tileSize;canvas.getContext('2d').putImageData( new ImageData(new Uint8ClampedArray(rgba.buffer), w, w), 0, 0);renderTile also returns 'png' (the default) or lossless 'webp'. Use RGBA
when the destination is a canvas; use PNG/WebP when the bytes are going into an
<img>, a cache, or over the network.
Params, per render
Section titled “Params, per render”A style’s declared params are overridable here the same way they are from the CLI or the tile server’s query string:
r.renderTile(z, x, y, { format: 'rgba', params: { paper: '#ffe0f0', softness: 2, labels: false },});Values are validated against the declarations — the wrong type, an out-of-range number, or a name the style does not declare throws rather than rendering something quietly wrong. Names you leave out keep their declared default.
One built graph serves every combination, and the cache keys on the values each node actually reads, so moving one slider re-evaluates only its subtree. That is what makes a params panel in a browser feel immediate.
Building the panel from the schema
Section titled “Building the panel from the schema”r.paramsSchema returns the JSON Schema of the declarations — the same document
the tile server serves at /style/params:
for (const [name, p] of Object.entries(r.paramsSchema.properties)) { // p.type: 'number' | 'string' (format 'color') | 'boolean' // p.default, p.minimum, p.maximum, p.description panel.append(control(name, p));}Drive your controls off that rather than reading the style’s params block
yourself. It follows setStyle, so the panel and the graph being rendered cannot
drift apart — and a style that declares a param you have no control for shows up
as a missing widget instead of a knob that silently does nothing.
Binding sources
Section titled “Binding sources”bindSource(name, bytes, opts?) dispatches on the declared type of that
source:
Declared type |
What the renderer does with the bytes | Lifetime |
|---|---|---|
brush |
parse the .myb, register in the bank |
persistent |
image, sprite |
decode PNG/WebP | persistent |
mvt, pmtiles |
decode MVT, bind each layer as tile.<layer> |
cleared by clearSources() |
dem |
decode and stitch the 3×3 neighbourhood | per tile |
raster |
decode RGBA imagery and stitch | per tile |
Neighbour tiles are bound with { coord: [dx, dy] }, dx, dy ∈ {-1, 0, 1}, the
centre being the default [0, 0].
Do not fetch a blind 3×3 for everything. Ask what the style actually reads:
r.requestedNeighborOffsets('basemap'); // e.g. [[-1,0],[1,0],[0,-1],[0,1]] — or []r.requestedNeighborOffsets('terrain'); // all eight, unless neighbor-fetch is offOnly cross-tile label collision and edge-continuous DEM/raster shading read
neighbours, and only for the sources that need them. A vector source answers from
the graph, so its list is often empty; a dem or raster stitches its window
into one buffer, so it asks for all eight unless it declares
neighbor-fetch: false, or no node reads it.
Bind every offset it reports. The centre tile alone is not a smaller render of
the same thing — the stitch fills the missing pad by clamping the centre’s edge
pixels, and anything sampling the pad drags that guess back inside the tile as a
seam along the border. The renderer warns when a dem or raster window comes
up short — a host with a LogSink installed will see it.
sourceTile(name, z, x, y) accepts off-grid coordinates for exactly this loop:
x wraps around the antimeridian, and a y off the top or bottom of the world
comes back unchanged, so its fetch misses and that edge clamps — which is what
the pole should look like.
Past a source’s maxzoom
Section titled “Past a source’s maxzoom”Sources end at different depths — a vector basemap at z15, a terrain pyramid at z12–14 — while the map keeps zooming. Ask which tile to fetch rather than hard-coding each ceiling, then say which zoom the bytes came from:
const t = r.sourceTile('terrain', z, x, y); // { z: 14, x: …, y: … } past the ceilingr.bindSource('terrain', await fetchDem(t.z, t.x, t.y), { sourceZoom: t.z });Vector geometry is reprojected into the tile’s frame; dem and raster have the
ancestor’s covering sub-rectangle resampled. Each neighbour resolves against its
own ancestor, so pass the neighbour’s coordinate to sourceTile too — a 3×3
window straddles two parents whenever the centre sits on a parent boundary.
Skip this and the source simply has nothing bound past its ceiling: the dem
node emits a zero-elevation field and the hillshade goes flat with no error. A
style that would rather fail than draw that says on-missing: error on the
source, and renderTile throws instead.
Glyphs and fonts
Section titled “Glyphs and fonts”This host cannot fetch lazily, so after binding vector sources and before rendering, ask what text needs:
r.neededGlyphRanges(); // { fonts: [0, 256, 8192] } → fetch `…/{fontstack}/0-255.pbf`, etc.r.neededCodepoints(); // exact codepoints, if you can build your own PBFBoth over-approximate. setGlyphBudget(bytes) caps what each fontstack keeps
resident; without it a fontstack keeps every range ever bound for the renderer’s
life.
system: font sources do not work here — there are no installed fonts in wasm.
Use a font file URL or a glyphs endpoint.
Memory and lifetime
Section titled “Memory and lifetime”memoryUsage() reports the renderer’s payloads (glyphs, fonts, images, cache)
plus module-wide heapBytes. heapBytes is a high-water mark: wasm cannot
return pages to the host, so it never falls. In a memory-capped isolate, poll it
and shed load — drop a renderer with free() — before an allocation fails.
setStyle(styleJson) swaps the style in place and returns the new node count,
reusing the module and the asset bank.
Parallel rendering
Section titled “Parallel rendering”Three builds sit side by side:
- scalar and SIMD (
+simd128) — stable Rust, run anywhere.simdEnabled()tells you which you loaded. - threads — renders across Web Workers via
wasm-bindgen-rayon. Nightly to build; at runtime the page must be cross-origin isolated (Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp, which means HTTPS or localhost).
if (threadsEnabled()) { await initThreadPool(navigator.hardwareConcurrency); r.renderTile(z, x, y, { parallel: true });}ezu serve hosts a self-contained demo page with exactly the headers the threads
build needs — the quickest way to see it working before wiring up your own
server.
Gotchas
Section titled “Gotchas”- Every
Uint8Arrayreturn value is copied across the wasm-bindgen boundary. A 512×512 RGBA buffer is 1 MB, comparable to a PNG decode on the JS side — so prefer RGBA only when you would otherwise re-decode the PNG into anImageBitmap. std::time::Instantpanics onwasm32-unknown-unknown, so timing in the rendering crates is compiled out on wasm. If you extend those crates, gate new timing the same way.- Per-node
tracingevents can be surfaced in the console by installing aLogSinkonce at startup — the same op/cache-hit/duration trace--verboseprints natively.
The full JS API, build commands, and benchmarks live in the
ezu-wasm README.
Map renders on this site are made fromOpenStreetMap data viaProtomaps (© OpenStreetMap contributors), elevation from Re:Earth Terrain,Mapterhorn andEGM2008 (NGA), and aerial imagery from GSI Japan(© 国土地理院). The painterly styles use CC0 brushes byDavid Revoy.