Skip to content

Split the style compiler and PNG encoder into their own bindings - #1

Merged
wf9a5m75 merged 19 commits into
mainfrom
feature/split-layer-bindings
Oct 25, 2026
Merged

wf9a5m75 merged 19 commits into
mainfrom
feature/split-layer-bindings

Conversation

@wf9a5m75

Copy link
Copy Markdown
Contributor

Brings the 17 commits of feature/split-layer-bindings into main. The SDKs already vendor builds of this branch (ios-vectorstyle, android-vectorstyle, react-sdk/vectorstyle, the PNG encoder in ios-sdk-core / android-sdk-core), so main is behind what they ship.

Includes the style compiler bindings (mvt-style-{ffi,jni,wasm}, bindings/js-style, bindings/ios-style, bindings/android/style), the PNG encoder bindings (tile-png-*), bounded tile caches, and test.yml.

The renderer learned to draw a tile as ground and labels separately in
255aaea, but only the Rust side knew. Every host still called the one
combined entry point, so a browser, an Android app and an iOS app all
drew a map with no labels on it. This is the binding work that lets them
ask for the halves.

Each surface gains the same shape:

- `render_geometry` / `render_labels` beside the combined `render`, so a
  host can mount two raster layers and let a glyph range landing redraw
  only the transparent one.
- Glyph and sprite loading — `needed_glyphs`, `add_glyphs`, `has_glyphs`,
  `sprite_urls`, `add_sprite` — because labels need fonts the style names
  and a tile cannot wait for a round trip before drawing.
- `attributions`, read out of the style's sources. It was being dropped on
  the floor. A style is data under someone's licence and the sample
  basemap draws OpenStreetMap, so the credit has to reach the host; the
  text stays as HTML because it is normally a link to the licence.

The label pixels cross each boundary as its cheapest shape rather than
one agreed shape: Android takes a native buffer it can hand straight to
the PNG encoder, because a 4 MiB Java array in and out per tile is the
cost the split was meant to save; the browser takes a PNG, because the
alternative is round-tripping through a canvas. Both are premultiplied
until encoded — encoding un-premultiplies, and skipping that turns every
halo grey.

Also here:

- `clock.rs`. `Instant::now()` *panics* on wasm32-unknown-unknown, and a
  line that existed only to report how long the pass took was taking the
  whole label pass down in the browser. Timings read as zero on the web
  and stay real on Android and iOS.
- `RenderedTileCache`, keeping rendered PNGs in Cache Storage across
  reloads. Off by default: a library helping itself to a user's disk quota
  behind their back is not a favour.
- The worker hands the host a tile the host owns. Transferring detaches
  the buffer, which was safe while wasm allocated a fresh array per
  render — but the empty label tile is a shared constant served for every
  tile with nothing on it, and water and fields make that common. The
  first one detached it and every one after died with a DataCloneError,
  in the worker, nowhere near the sharing. Copying seventy bytes fixes it
  and the comment is now the contract rather than an assumption.
- A solid-line fast path in the tessellator, which was keeping a projected
  Vec per feature — an allocation for every road under every style layer.
- `label_size_probe` and `pipeline_cost` examples, JS split tests, an iOS
  label binding test, and docs/android-performance.md.

Rust 141 tests and the JS suite pass. The browser end of this is verified
in react-sdk: 14 of 17 providers draw labels and the credit, and the
three that do not fail the same way on an unrelated page.
sync_web copies pkg, dist and the README into react-sdk/vectortile but
not the version, which was hand-maintained on both sides. So the copy
could go on claiming 0.1.0 while carrying a different build, and the
React binding pins a range against exactly that number.

The version field alone, not the whole package.json: the copy's scripts
say the build lives here rather than there, which is the point of it
being a copy.
This repo is the one renderer all three SDKs share, so breaking it breaks
android, ios and web at once. It had no CI at all.

Two jobs, because neither alone is enough. `cargo test` covers the
rasteriser, label placement and style handling; the JS suite covers the
wasm surface, where the Rust can be correct and the binding still wrong.
The wasm is gitignored, so that job builds it before testing.

clippy is deliberately not a gate yet: it reports 17 warnings today, and a
CI that is red on its first run stops being read. Clean those up and add
`-D warnings` then.

wasm-pack comes from cargo rather than a third-party action or a piped
installer. This repo produces the binaries the other three trust; the
fewer things it pulls in, the better.
Style dimensions are screen units, so turning them into pixels takes
two numbers: how many pixels the caller asked for, and how much screen
the tile will be shown across. Only the first was passed. The second
was assumed to be 512 dp, in two places -- style sizes were scaled by
pixels/512, and zoom-dependent expressions were read at the tile's own
level, which is the zoom a 512 dp tile of that level is shown at.

That assumption held for every host until ArcGIS's 3D SceneView, which
puts a tile in a 256 dp slot whatever pixel size it carries. There, the
same code drew every label and every road at half size and read the
style one zoom too deep. Changing the tile size did not help and could
not: the apparent size works out to the style's value times dp/512, and
the pixel count cancels.

A renderer now carries the display size and derives both rules from it:

    paint scale = output pixels / display dp
    style zoom  = tile level + log2(display dp / 512)

The default is 512, which reproduces the old numbers exactly, so hosts
that do not pass one are unaffected. The JNI, C and wasm constructors
take it; the C and wasm ones read 0 or a missing value as "the usual".
The device recorded a SIGSEGV inside nativeRender with a fault address
made of style-layer name bytes -- "otorway-" sat in a register where a
pointer belonged. That is a freed renderer whose block had already been
handed to something else: tiles are drawn on the tile server's worker
threads, and close() freed the handle from whichever thread dropped the
layer, without waiting for the calls already inside the library.
Switching providers or leaving the page mid-pan is exactly when both
happen at once, which is where it was seen.

Calls now take the read side of a lock and close() takes the write
side, so renders still run in parallel and the free waits for the ones
in flight -- bounded by a single tile, since nothing here loops. A call
that arrives after the free finds the handle cleared and throws, which
was already the contract.

Not reproduced on demand: sixteen scripted provider switches with pans
did not hit the window. The fix closes it by construction rather than
by timing.
A tile shown across 256 dp sits where a 512 dp host would show the level
above it. The paint already knew that -- the display size fixed the zoom
the style is read at -- but the data did not: the tile still fetched its
own level. A vector source's content changes by level, so the two hosts
drew different maps of the same ground. At zoom 6.5 the z=6 tile carries
the 新潟市 label and z=7 does not, and that is what ArcGIS's SceneView
was missing beside MapLibre.

The offset rides the magnification the renderer already does past a
source's maxzoom: one tile fetched a level up, a scale, and an offset
into the quadrant the target sits in. `plan_tile_at` takes the style zoom
and the source offset; `plan_tile` is it with neither, unchanged.

The label ring is now built from the target's own eight neighbours,
mapped to whatever source tile each reads from and deduplicated. At a
tile's own level that is the same nine requests as before. Magnified, the
neighbours that fall inside the fetched tile collapse into it and only
the ones whose ground lies outside are asked for -- three more for a tile
in a parent's corner, none for one deep inside. The magnified path used
to ask for no ring at all, which lost exactly the edge labels the ring
exists for.

A plan also carries the zoom it was made at. The decode step asks the
style which of its layers are symbol layers at that zoom, and it was
re-deriving the zoom from the requests -- right until the style zoom and
the tile's level stopped being the same number.

Measured on a Pixel 5a at unified zoom 6.5, ArcGIS 3D against MapLibre
drawing the same style through the same rasteriser: the 館山市 label is
55x17 px on one and 57x18 px on the other, and the two label sets match.
Every label on the map was placed correctly and drawn in nothing.

A glyph range is fetched from `{fontstack}/{range}.pbf` and is, by
construction, the stack that was asked for; the `name` field inside is
redundant, and versatiles leaves it out -- its ranges carry their 256
glyphs and nothing else. The store filed those under the empty name, and
every lookup, which goes by the style's `text-font`, missed. `placed` in
the log stayed healthy the whole time, because placement needs metrics
and metrics come from the glyphs that were not there either: it fell back
to none and laid out nothing, over and over, on every tile.

A nameless face is now tried after the named ones, so a server that
fills the field in is unaffected. What this cannot do is tell two
nameless stacks apart: a style asking for a regular and a bold face gets
whichever arrived last wherever they share a character. Doing that needs
the name from the side that knows it -- the caller that chose the URL --
which is an argument `add_glyphs` does not have yet.
On ArcGIS the vector layer left most of the map missing: the basemap
underneath showed through the sea and the land between the roads, and
only a handful of tiles carried any geometry at all. The labels, which
take the CPU path, drew perfectly the whole time -- so the data, the
plan and the fetches were all fine, and the CPU rasteriser drew the same
tiles correctly when forced. Two defects in the GPU path, both invisible
until a tile was drawn from a shallower level.

**The background was the last background layer, not the stack.** A style
may have several, and versatiles' colorful has five: its cream
background and four `background-opacity: 0` layers that exist only as
slots for other layers to be inserted into. The tessellator overwrote the
reported colour on each one, so the host was told to clear every tile to
nothing. `Color::over` composites them now, and the CPU rasteriser blends
its own fill instead of replacing, so the two paths agree.

**Magnified geometry landed outside its tile.** The projection divided by
a hardcoded 4096 and multiplied by it again. Those cancel while nothing
is magnified -- which is why the tessellator looked correct for a year --
but with a scale and an offset they do not, and the extent they should
have been is the tile's unified extent, which Shortbread makes 2048 as
often as 4096. Every vertex came out a tile-width to the left: for
9/455/201 drawn from its parent, 628,146 vertices of which 7 were inside
the tile. Now 28%, which is what a quarter of a parent plus its edges
looks like.

Measured on a Lenovo TB520FU at zoom 12 over Tokyo: the GPU output now
matches a CPU render of the same screen to within 12/255 on 98.85% of
pixels, the rest being MSAA edges against analytic coverage.

The tessellate feature's own tests had stopped compiling when the
renderer learned about display size; they are repaired here, which is how
the second defect became testable at all.
Labels were being sliced down the middle at tile seams: 中野区 lost its
left six pixels, 世田谷区 was reduced to a lone 区.

A tile does not decide on its own which labels to draw -- it runs a
collision contest -- and a label that straddles a seam is drawn half by
each neighbour, so the two must reach the same verdict. The ordering of
that contest was already keyed on world position for exactly this reason.
What was not shared was the field: a candidate was dropped before the
contest unless it reached into this tile, and a rival that touches only
the neighbour still decides the outcome.

高円寺 sits 40 px west of a seam and 中野区 19 px east of it, overlapping.
The west tile saw both and gave the spot to 高円寺. The east tile could
not see 高円寺 at all, so it kept 中野区 and drew it -- with the six pixels
that belong to the west tile missing, because the west tile had drawn
something else there.

Candidates are now kept with a whole tile of margin, which is exactly the
data the ring of neighbours already provides, and what does not reach the
tile is dropped after placement rather than before it. Nothing further
away can overlap a label that touches this tile, so the margin need not
be larger.

This was never magnification's doing -- it is as old as the ring -- but
256 dp tiles put four times as many seams on the screen, which is what
made it show.

Measured on a Lenovo TB520FU over Tokyo: the labelling pass runs in 4-6 ms
a tile against 3-35 ms before, so the wider field costs nothing worth
having; and stitching the four tiles around Nakano off-device, every label
is now whole where two were cut.
The ring of neighbours was handed the same bundled tile as the tile
itself. No tile server can do that: a vector source clips its features to
the tile they belong to, so the same name appearing in two neighbours is
one feature in one place, not nine copies at nine places.

It stopped mattering once labels were judged against a whole tile of
margin. The labeller keeps one copy per name per patch of world, and with
nine copies of every label in play it kept one belonging to a neighbour
and drew nothing on the tile under test -- correctly, for the world the
fixture had described.

The neighbours now answer empty, which is an ordinary answer and one this
fixture can give truthfully.
A tile drawn from a shallower level decodes the whole of its ancestor,
and every child of that ancestor does it again: two levels down, one
tile is decoded sixteen times so that sixteen tiles can each draw a
sixteenth of it. The label pass has cached that decode for a while. The
geometry pass did not.

It shows up on ArcGIS's 3D view, which asks for about 120 tiles to fill
an iPad and draws them from a handful of ancestors. Measured on a Mac
over Tokyo at z=13: 12.9 ms a tile before, 11.1 ms after, of which the
decode was 2.6 ms.

The cache is small -- eight entries against the label cache's forty-eight
-- because a full decode holds every layer of the tile rather than its
symbol layers alone, and the working set is only the ancestors a screen
magnifies from.

On the device it is worth less than that: the iPad's per-tile time is
mostly waiting for one of six render slots, not decoding. p50 went 124 ms
to 120 ms over 119 tiles. The queue is the thing to shorten next, and 42
of those 119 tiles are levels the view flew through and threw away.
`close()` freed the native renderer at once, and a call already inside it
on another thread then read freed memory. The provider is rebuilt when the
map under it changes its tile size, and ArcGIS draws its tiles on a queue
of their own, so a render was still running when the old provider closed:
switching to ArcGIS put the app on the home screen a second later. The
app's own log shows the shape -- the old routes answering 404 at 37.405s,
one more render on them finishing at 38.004s, nothing after.

Calls take the read side of a lock, close takes the write side and waits
for every call in flight. A call that arrives after the close finds the
handle cleared and throws instead. android-sdk's binding hit the same
fault and holds the same lock.

Reproduced on an iPad with a UI test that switches from Google Maps while
tiles are flowing: the app crashed every time without the lock, and
survived twelve seconds of checking with it. The package also runs the
race forty times in-process.
A tile drawn two levels down from its source shares that source with fifteen siblings, and each walked and triangulated it again for its own sixteenth. tessellate_packed now decodes through the source tile cache and, when every request is magnified, triangulates the whole source at that level's scale (no window), keeps it, and cuts each child out by translation and a per-triangle clip; several sources merge in style layer order. Measured on an iPad (ArcGIS 3D, 128 pt tiles): ground tile serve p50 119 -> 41 ms cold, 56 -> 26 ms steady; the totals barely move because fetch waits dominate. On a tablet with 256 px tiles: tessellate p50 8 -> 2 ms, p90 288 -> 39 ms. The doc-hidden render_source_block experiment (draw a 4x4 block at once, parked: 27% at z9, nothing at z13) rides along in lib.rs.
Renderer::query_features reads one source tile (through the same
source-tile cache and magnified-ancestor window the drawing uses),
keeps the features of a layer that pass a style filter and whose
string values contain a text, and reports each with its anchor
lon/lat, bounds and properties. Exposed through the C, JNI and wasm
bindings and the three binding wrappers.

The SDKs do not use it yet: walking every tile in view is fine from
an offline package but is a tile download per tile online, so the
online case wants a server-side index. The primitive stays here as
the basis for that.
node --test expands the "test/*.test.mjs" glob itself only from Node 21.
On 20 it looks for a file literally named that and fails without running
anything.
MemoryCache read std::time::Instant::now() for its idle expiry, which
panics on wasm32-unknown-unknown. Every wasm render that touched the
tessellation cache trapped with 'unreachable', and 13 of bindings/js's
30 tests failed.

On wasm the clock now reads as one fixed moment, the way clock.rs already
does for timings: nothing expires for being idle, and the cache evicts
by count and bytes alone. Android and iOS keep the idle expiry.
@wf9a5m75
wf9a5m75 merged commit ea9f43c into main Oct 25, 2026
3 checks passed
@wf9a5m75
wf9a5m75 deleted the feature/split-layer-bindings branch October 25, 2026 06:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant