Repository navigation
Split the style compiler and PNG encoder into their own bindings - #1
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the 17 commits of
feature/split-layer-bindingsinto 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, andtest.yml.