Use from Go
go get github.com/reearth/ezu/goThe module path ends in go and the package is called ezu, so most files name
it in the import:
import ezu "github.com/reearth/ezu/go"It embeds the renderer as a 4.2 MiB wasm module and runs it on
wazero. Nothing is compiled at go get time: the module is
a committed artefact, so there is no Rust toolchain to install, no cgo, no C
compiler, and cross-compiling stays GOOS=… GOARCH=… go build. go test works
in a fresh checkout.
The bargain is the same one the browser bindings strike, over the same host-neutral renderer: ezu fetches nothing. You bind the bytes — vector tiles, DEM or imagery, GeoJSON, sprites, fonts, glyph ranges, brushes — and ask for a tile. Rendering the same style from a Go service and from a browser gives the same pixels.
The shape of a render
Section titled “The shape of a render”ctx := context.Background()
rt, err := ezu.NewRuntime(ctx) // compiles the module — once per processif err != nil { return err}defer rt.Close(ctx)
r, err := rt.NewRenderer(ctx, styleJSON) // one wasm instance, one styleif err != nil { return err}defer r.Close(ctx)
if err := r.BindSource(ctx, "basemap", mvt); err != nil { return err}png, err := r.RenderTile(ctx, ezu.Tile{Z: 14, X: 14554, Y: 6454}, ezu.Render{})if err != nil { return err}r.ClearSources(ctx) // between tilesRuntime holds the compiled module and is safe for concurrent use.
Renderer holds a style, its built graph and its asset banks, and is not — see
one renderer, one goroutine.
ezu.Render{} renders PNG at the style’s own canvas size. Format: ezu.FormatRGBA skips the container when the bytes are going into an image
buffer, ezu.FormatWebP for lossless WebP, and Params overrides the style’s
declared params per render, validated the same way the
CLI’s --param is.
The bind loop
Section titled “The bind loop”Ask the renderer what the style declares rather than reading the style yourself:
sources, err := r.Sources(ctx)Each ezu.Source carries the four things a fetch needs:
| Field | What it is |
|---|---|
Name |
the sources.<name> key, which is what BindSource takes |
Type |
the style’s own word — mvt, pmtiles, dem, raster, geojson, sprite, font, glyphs, brush, image — and what the bind dispatches on |
TileScoped |
whether the binding is dropped by ClearSources and rebound per tile, or goes into a persistent bank and is bound once |
URL |
where the bytes come from, as the style wrote it |
URL is empty only for a geojson source with inline data: the style carries
the payload, so there is nothing to fetch and nothing to bind. A sprite source
also reports IndexURL when its index is a URL rather than inline — fetch it and
pass the text as ezu.WithSpriteIndex(…) alongside the atlas image.
A glyphs URL arrives with {fontstack} already substituted and
percent-encoded the way MapLibre encodes it, and {range} left for you, so the
only thing to fill in is the block you are fetching:
url := strings.ReplaceAll(g.URL, "{range}", fmt.Sprintf("%d-%d", start, start+255))That division is the point of the call. A host that re-derives the kinds, the
scopes and the {fontstack} spelling from the style keeps a second copy of style
interpretation, and the two drift; Sources hands you the one the renderer
itself used. BoundSources answers the opposite question — what is already bound
— and cannot start the loop.
One ezu.Tile carries every coordinate in here — the tile being rendered, the
tile a source says to fetch, and each neighbour — so nothing in the loop converts
between widths, and tile.Add(offset) is the whole of the neighbour arithmetic.
A tile then looks like this:
func renderTile(ctx context.Context, r *ezu.Renderer, tile ezu.Tile) ([]byte, error) { sources, err := r.Sources(ctx) if err != nil { return nil, err } if err := r.ClearSources(ctx); err != nil { return nil, err }
for _, s := range sources { if !s.TileScoped || s.URL == "" { continue // persistent banks are bound once; inline GeoJSON not at all } // Where the source stops short of this zoom, fetch the covering // ancestor and say which zoom the bytes came from. from, err := r.SourceTile(ctx, s.Name, tile) if err != nil { return nil, err } body, err := fetch(tileURL(s.URL, from)) if err != nil { return nil, err } if err := r.BindSource(ctx, s.Name, body, ezu.FromZoom(from.Z)); err != nil { return nil, err }
// And whichever neighbours this source actually asks for. offsets, err := r.RequestedNeighborOffsets(ctx, s.Name) if err != nil { return nil, err } for _, o := range offsets { from, err := r.SourceTile(ctx, s.Name, tile.Add(o)) if err != nil { return nil, err } body, err := fetch(tileURL(s.URL, from)) if err != nil { continue // off the top or bottom of the world: that edge clamps } if err := r.BindSource(ctx, s.Name, body, ezu.AtOffset(o), ezu.FromZoom(from.Z)); err != nil { return nil, err } } }
// This host cannot fetch lazily, so text needs its glyphs before the render. needed, err := r.NeededGlyphRanges(ctx) if err != nil { return nil, err } for name, ranges := range needed { if err := bindGlyphs(ctx, r, sources, name, ranges); err != nil { return nil, err } } return r.RenderTile(ctx, tile, ezu.Render{})}Three things in there are worth saying plainly, because each of them is a source of tiles that are wrong rather than missing:
- Do not fetch a blind 3×3.
RequestedNeighborOffsetsnames what the style reads, per source: often nothing for a vector source, and all eight for ademorraster, which stitch their window into one buffer. A neighbour left unbound there is not a smaller render — the stitch fills the pad by clamping the centre’s edge pixels, and anything sampling it drags that guess back inside the tile as a seam. SourceTileowns each source’s ceiling, so you do not keep a copy of every maxzoom. It accepts off-grid coordinates for exactly the neighbour loop above:xwraps around the antimeridian, and ayoff 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.- Missing glyphs are not an error. Text whose glyphs were never bound is
dropped with a warning rather than a returned error, so the order above —
bind the features, ask
NeededGlyphRangeswhat they need, bind that, then render — is load-bearing, and getting it wrong loses labels quietly. Give the runtime a logger and you hear about it.NeededCodepointsis the precise form ofNeededGlyphRangesfor a host that can assemble its own PBF; on CJK labels that is the difference between a few thousand glyphs and tens of megabytes.
Hearing that a tile came out wrong
Section titled “Hearing that a tile came out wrong”The failures above do not fail. A DEM bound without the neighbours its style asked for still renders, and seams at the tile border; text whose glyphs never arrived is dropped and the tile comes back a label short. Both are warnings, and a warning nobody collects is a tile that is wrong in a way only a human looking at the picture will notice.
So give the runtime a logger, once, and every renderer it makes — including the
ones inside a Pool — reports through it:
rt, err := ezu.NewRuntime(ctx, ezu.WithLogger(slog.Default()))The package drains the module’s buffer after each call, so the lines arrive
attributed to the call that produced them, at their own level, with the instance
that drew the tile named in a renderer attribute. ezu.WithLogLevel chooses how
much is collected; the default is warn, which is everything a host is likely to
act on.
InitLog and DrainLogs are the pull side of the same buffer, for a host that
wants the lines itself — to attach them to a response, or to assert on them in a
test. Use one or the other: a runtime with a logger has already emptied the
buffer by the time you ask.
One renderer, one goroutine
Section titled “One renderer, one goroutine”A Renderer is one wasm instance, and a wasm instance is single-threaded. It
has one linear memory, one allocator and no locks anywhere inside it. Two
goroutines calling into the same instance interleave two allocation sequences
that were written on the assumption that they could not, which is heap corruption
rather than a slow answer.
Nothing serialises for you. A second goroutine entering a call already in
progress gets ezu.ErrConcurrentUse and no side effects — an error rather than a
lock, because a caller who asked for parallel tiles and silently got a queue
would see correct pixels, no error, and none of the throughput, and would find
out in production.
Rendering tiles in parallel therefore means several renderers. ezu.Pool is the
bookkeeping that makes that the easy thing to do:
pool, err := rt.NewPool(ctx, 4, styleJSON)if err != nil { return err}defer pool.Close(ctx)
err = pool.Do(ctx, func(r *ezu.Renderer) error { out, err := renderTile(ctx, r, tile) if err != nil { return err } return write(out)})Do acquires, runs and releases; Acquire / Release are there when the
lifetime does not fit a function. Acquire blocks until a renderer is free or
ctx is done.
Size the pool by memory, not by cores. Every renderer is a full linear memory
with its own copies of whatever it has been given — glyphs, sprites, its own
render cache — so N renderers cost roughly N times one renderer’s
MemoryUsage, and that is usually the binding constraint long before cores are.
The persistent banks are per renderer too: a brush, font, sprite or glyph range
bound to one is unknown to the others, so bind to each of them, or bind per tile.
The expensive artefact is the compiled module, and that is in the Runtime,
which is shared: instantiating a renderer from it is a fresh linear memory and
little else.
Memory limits and running out
Section titled “Memory limits and running out”WithMemoryLimit caps the linear memory any one renderer from that runtime may
grow to, in bytes, rounded up to wasm’s 64 KiB page:
rt, err := ezu.NewRuntime(ctx, ezu.WithMemoryLimit(512<<20))There is deliberately no default and no recommended number here. What a render
needs is a property of the style and the tile size — a 512 px tile through a few
blurs is not a 1024 px one through a brush engine and a CJK label pass — so
measure your own styles with MemoryUsage and set it from what you see. Zero,
the default, means the wasm maximum of 4 GiB and leaves the limiting to whatever
the process runs under.
MemoryUsage reports what a renderer is holding, so you can shed load before an
allocation fails rather than after:
u, err := r.MemoryUsage(ctx)// u.HeapBytes is what the cap applies to. It is a high-water mark: freeing Rust// values returns them to the allocator, never to the host, so it only grows.// u.GlyphBytes is usually what grew — glyphs survive ClearSources.SetGlyphBudget caps the glyph bytes each bound fontstack keeps resident, which
is the one bank that otherwise accumulates for the life of a renderer;
ClearGlyphBudget lifts the cap again. Two calls rather than one taking a
pointer, because a budget of 0 is a real budget — keep nothing — and should not
be the same word as having none.
Hitting the limit is not a silent kill. The module’s allocator reports the failed
allocation on its way out, so the call returns an *ezu.Error named
OutOfMemory carrying the number of bytes it could not have:
if ezu.IsKind(err, ezu.KindOutOfMemory) { … }That instance is finished. The allocation trapped mid-render, leaving
half-built values and an allocator whose bookkeeping is whatever it was, so every
later call on it returns ezu.ErrRendererDead rather than reading a linear
memory nobody can vouch for. This is diagnosis, not recovery: close the renderer
and build another. A Pool does that for you — a renderer that trapped is closed
rather than handed to the next caller, and the pool shrinks by one, so one failed
render does not become every later one. A pool that has shrunk to nothing makes
Acquire block, which is the signal to rebuild it.
Errors
Section titled “Errors”A failure reported by the renderer itself comes back as an *ezu.Error with the
kind’s name in Name:
Name |
When |
|---|---|
InvalidStyle |
NewRenderer / SetStyle rejected the JSON, or a render option was not one of the accepted values |
BrushParse |
a brush source’s .myb JSON would not parse |
MvtDecode |
vector tile bytes would not decode |
DemDecode |
a raster-DEM tile would not decode |
RasterDecode, GeoJsonDecode, SpriteDecode, FontParse, GlyphDecode |
the same, for the other source kinds |
UnknownSource |
BindSource got a name the style’s sources block does not declare |
RenderFailed |
a node’s evaluation failed — a missing brush, a downcast mismatch |
PngEncode, WebpEncode |
encoding the output failed |
OutOfMemory |
the wasm heap could not grow |
These are the same names the npm package raises as a thrown Error’s .name
(the table in the ezu-wasm README
is the same list), because both shells expose one renderer’s own error kinds. A
style that is rejected in a browser is rejected with the same word in a Go
service, so the two hosts describe a failure the same way and handling written
against one reads as handling for the other.
ezu.IsKind(err, name) is the branch, and ezu.KindInvalidStyle,
KindUnknownSrc, KindRenderFailed and KindOutOfMemory are constants for the
names most worth branching on; the rest are plain strings. Error.Message is the
detail without the name, and Error.Bytes is the size an OutOfMemory could not
have.
ErrConcurrentUse and ErrRendererDead are not renderer errors but rules of
this package, and are plain sentinel values.
Cold start
Section titled “Cold start”Compiling the module costs about 1.3 s, once per process. A compilation cache makes the next start about 45 ms:
rt, err := ezu.NewRuntime(ctx, ezu.WithCompilationCacheDir("/var/cache/ezu"))if err != nil { return err}if err := rt.CompilationCacheError(); err != nil { slog.Warn("ezu compiled without its cache", "err", err)}A cache is an optimisation and the package treats it as one: if the module cannot
be compiled with it, the runtime is built again without it and the start costs a
full compile instead of failing. CompilationCacheError is how you hear about
that, and it is worth logging — otherwise the only symptom is a start that is a
second slower than whoever configured the cache expects, every time.
The cache is keyed by the module’s bytes and by wazero’s version, architecture and OS, so a rebuilt module or an upgraded wazero misses and recompiles rather than serving something stale. The runtime owns the cache it builds and closes it with itself, so there is nothing else to hold on to.
The option takes a path rather than a wazero.CompilationCache because a path
is all that was ever accepted: the interface is, in wazero’s own words,
“decoupling, not third-party implementations”, and it is type-asserted to
wazero’s concrete type with no comma-ok, so anything but the return of
wazero.NewCompilationCacheWithDir panics inside wazero.
Baking the cache into an image
Section titled “Baking the cache into an image”If 1.3 s sits on somebody’s critical path, the cache can be filled at build time and shipped. The shape that works is an embedded cache entry unpacked to a fresh temp directory at startup — not a pre-built cache directory mounted read-only:
- In the build, a
mainpackage using wazero compiles the same module bytes into an empty directory and prints the entry’s filename, which is the cache key.go:embedthe entry (gzipped, it is about the size of the wasm itself; raw it is roughly four times that) and the key. - At startup:
os.MkdirTemp,wazero.NewCompilationCacheWithDir(tmp), then write the decompressed entry into the singlewazero-<version>-<arch>-<os>subdirectory that call creates, under the key filename. Entries are read lazily at compile time, so writing after construction is fine. os.RemoveAll(tmp)on shutdown.
That takes a start to roughly 60 ms raw, or 129 ms gzipped, and the unpack itself is about 10 ms of it. Three things make the shape non-obvious, and each one has bitten:
- A miss against a read-only directory is a startup failure, not a slow start. wazero writes new entries into the cache directory, so a miss there is a write error out of compilation — and a miss is exactly what a rebuilt module or a wazero upgrade produces. A fresh temp directory is writable by construction.
- A damaged entry errors rather than recompiling. A truncated copy gives
unexpected EOF, a flipped byte gives a checksum mismatch; the CRC catches corruption but wazero does not recover from it. (This package’s fallback turns both into a slow start, which is what makes the recipe safe to attempt at all — but it means a bad entry costs you the whole speedup silently, so logCompilationCacheError.) Regenerate the entry whenever the wasm module changes or the wazero dependency is bumped. Drift’s only symptom is a silent return to 1.3 s starts, so a CI check that recomputes the key and compares is worth the one line.
And the honest version: the case for baking is proportional to how often a process starts. For a long-lived service — one process per pod, restarted on deploy — 1.3 s once is not worth a build-time dependency, and the right answer is an ordinary cache directory, or nothing at all. It earns its keep for per-request workers, CLI-shaped invocations, scale-to-zero, and anything where a cold start sits in front of a user.
Byte-for-byte with the CLI — except WebP
Section titled “Byte-for-byte with the CLI — except WebP”Nothing in the render path depends on the host, so a tile rendered through wazero
is the same file the CLI writes. The package’s tests pin exactly that, against
hashes produced by ezu tile.
WebP is the one exception, and it is a container difference rather than a
picture difference. For the same tile the CLI and the module produce two
different files of identical length whose decoded RGBA is identical — 0 of
1,048,576 samples differ. The lossless encoder’s cost estimates run through
floating-point transcendentals, and Rust serves those from the platform’s libm
natively and from its own implementation on wasm; a last-bit difference there
moves a tie and picks another encoding of the same size. It is not the SIMD
build: a module compiled without +simd128 agrees with one that has it, in both
formats. PNG is unaffected because deflate does no floating-point arithmetic at
all.
So: compare PNG bytes across the two if you like, and do not write a test that asserts the CLI and the module produce the same WebP bytes — it will pass on some styles and fail on others, and neither outcome means anything. Pin the module’s WebP against the module, or compare decoded pixels.
Keeping the module honest
Section titled “Keeping the module honest”The wasm module is committed, which is what spares consumers a Rust toolchain and
what allows it to go stale. Three things catch that: the ABI version is checked
when a renderer is instantiated, a Rust test compares the committed module’s
export list against what the source builds, and CI runs the Go suite a second
time against a freshly built module. go generate ./... rebuilds it, and
ezu.WithModule runs a module you supply in place of the embedded one.
OpCount is the smoke test for the wasm module’s life-before-main constructors:
zero means they never ran and every style is about to fail with “unknown op”.
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.