Repository navigation
Vector style support, tile server semantics, and marker tile performance - #16
Merged
Merged
Conversation
bearing を「値を増やすと地図が時計回りに回る」向きに統一し、ネイティブ SDK との変換をコアの CameraBearing に集約する。
The destination came straight out of the projection, so it landed on a fraction of a pixel almost every time, and a non-integer rectangle makes the context resample every marker. Measured on the simulator with 20k markers: 2503 ms unaligned against 545 ms aligned. Rounding moves a pin by at most half a pixel, which is not visible at icon scale. The same change measured 20x on Android and 7x in Chromium. Also stop the second query+prepare pass from firing on every tile. The fixed 32pt assumption is smaller than the real icons, so the "conservative first pass" never paid off and simply doubled the work.
Measured on the simulator, 1024px tile: 7.5 ms for UIImage.pngData() against 4.4 ms here, with the output pixel-identical (worst channel delta 0, which is what says the un-premultiplication is right — CoreGraphics hands over premultiplied pixels and PNG stores straight alpha). The scratch buffer is per-thread because renderTile runs concurrently and a tile is several megabytes of RGBA. Allocating it per call cost as much as the encoding: 7.9 ms per tile against 3.9 ms of actual work. It is cleared before each draw, since a partly transparent tile would otherwise show the previous one through its gaps. Every failure returns nil and lets pngData() take over. Worth reading before deciding to keep this: 1.7x is a far weaker result than Android's 6-20x, because Apple's PNG encoder is already good where Bitmap.compress is not. Against a whole marker tile the encoder is roughly a quarter of a sparse one and a rounding error on a dense one.
Renamed from NativePngEncoder and moved next to the tile server. The vector tile module's Metal path needs it, and the alternative was a second compiled copy of the same encoder in every app. The pointer overload exists for that path: Metal hands back raw RGBA with no UIImage to wrap it in, and it is straight alpha rather than premultiplied, which the caller now says explicitly instead of the encoder assuming.
UIGraphicsImageRenderer records the drawing and replays it when the image is materialised, so its cost scales with the number of draw calls rather than the size of the canvas. The renderer's own profile said so plainly once the block outside the loop was timed too: the draw loop reported 81 ms while the block containing it took 1739 ms, for a fixed 612px buffer. Measured head to head on an iPad Pro, 20k blits into that buffer: 539 ms through the image renderer against 59 ms into a plain bitmap context. Owning the buffer removes two more things. The crop pass is gone — markers draw straight into a tile-sized canvas translated by the padding, so what overhangs the edge is clipped where it used to be composited and then cut away. And the encoder no longer needs its extraction copy, since the pixels are already in a buffer laid out the way it wants. Per tile on an iPad Pro, before -> after: z12 2000 markers 14.6 ms -> 6.2 ms z6 2000 markers 186.4 ms -> 33.0 ms z12 20000 markers 88.2 ms -> 18.8 ms z6 20000 markers 1944.0 ms -> 414.3 ms Two vertical flips have to cancel here: Core Graphics puts the origin at the bottom left and draws images bottom-up, while tile coordinates run top-down. MarkerTilePlacementTests pins both halves — position alone would pass with the icons upside down, so it also counts which side of the anchor is painted.
Zoom out far enough and a whole city collapses onto a few hundred pixels: at z6 a dataset of 20k markers resolves to a couple of thousand distinct positions, and the rest are drawn underneath copies of themselves. Keeping the last of each group is what would have been visible anyway, since drawing is in painters order. Only exact agreement counts — same rounded rectangle, same icon — so nothing that could peek out from behind another is dropped. Where markers share a rectangle but not an icon, none are dropped: a different icon may be transparent where the one above it is not, and then the one underneath does show through. This is deliberately not the decluttering the docs describe. That thins markers that are merely close, which changes what the map shows and is a product decision; this only removes drawing that has no effect. A test renders fifty stacked copies and one marker and asserts the two tiles are identical. Per tile at z6 with 20k markers: 414 ms -> 261 ms on an iPad Pro, 852 ms -> 617 ms on a Pixel 5a. The Android gain is smaller because its tile is 1344px against 512px, so fewer markers land on the same pixel.
The conservative first pass assumed 32pt and every tile then discovered the real icons were larger, so query and prepare both ran twice — every time, on every tile. Measured on an iPad Pro at z6 with 20k markers, that second pass was 102 ms of a 261 ms tile. The renderer already knows its default icon, so it can start from that icon's own extent instead. Markers with larger custom icons still widen it on their first tile, which is what the field was for; it just no longer fires for the common case where nobody has one. Per tile on an iPad Pro, before -> after: z12 2000 markers 6.3 ms -> 4.2 ms z6 2000 markers 29.0 ms -> 23.1 ms z12 20000 markers 20.1 ms -> 15.7 ms z6 20000 markers 261.0 ms -> 156.8 ms
findMarkersInBounds asks the cell registry for everything within a radius and then uses only the cells, but the search sorted its results by distance first. A cell carries a String, so sorting 20k of them moves 20k refcounted values through some 286k comparisons. Measured on an iPad Pro at 20k markers: sorted search 41.7 ms unsorted search 1.8 ms whole query 64.5 ms -> 12.4 ms The sorted variant stays for callers that do want the nearest first. Flattening also happens in one pass now, instead of building an array of id sets, then a flat array of ids, then an array of entities. KDTreeRadiusTest pins the two traversals against each other over a spread of radii, including ones that select nothing and everything. The unordered traversal was written out separately rather than derived, and a pruning bug that only shows at the boundary would survive a single radius. Per tile on an iPad Pro at z6 with 20k markers: 156.8 ms -> 107.3 ms.
MarkerGridIndex.swift is the Kotlin index line for line: one Int64 a marker, its cell key above its position in a snapshot, sorted once and binary-searched per cell. Same 0.005 degree cells, same scan-instead threshold, same reasons — the hex registry it replaces was built at zoom 20, about 12 cm a cell, so it grouped nothing while charging a HexCell and a String id per marker. Writing the tests it deserved turned up two defects, both of which were in the Android version as shipped an hour ago: A box crossing the antimeridian has its east corner west of its west one. The cell walk ran lonFrom..lonTo straight, which is an empty range in Kotlin — no markers, silently — and a trap in Swift. It now walks to the unwrapped end and folds each column back onto the globe, which also covers the ring search near 180 degrees. findNearest stopped one ring after its first hit. A ring is a square, so a hit in a corner sits about 1.4 cells further out than a hit on an edge, and the marker it returned was simply not the nearest one — on 5,000 markers it was wrong for two of three probe points. The rule is now a distance: having finished ring r, everything unsearched is at least r cells away, so stop once that exceeds the best found. Neither would have thrown. Both would have quietly returned the wrong markers, which is why the tests compare against a brute-force scan on every query rather than checking that a query returns something. Also fills the gaps left when the index changed hands. findByIdPrefix builds the hex registry itself now — nothing else builds it any more, so it had been returning nothing at all — and getMemoryStats counts the grid instead of reporting no spatial index at all.
android-sdk reached this from the opposite direction. There, one snapshot observer read every state to find in-place changes, and that cost the size of the collection on every commit — 350 ms a frame while dragging 144k markers. iOS already pushed: each state published its changes and the collector subscribed. The bill arrives somewhere else. Subscribing is one `map`, one `eraseToAnyPublisher`, one `receive(on:)` and one `sink` per state, plus the `AnyCancellable` holding them, and it does not stay linear: states sync now 1,000 47ms 1ms 10,000 236ms 15ms 50,000 2,175ms 52ms 144,183 — 134ms on the main actor, on a simulator. The last row is Tokyo's street trees, which is what raised the question. Whatever the old curve was doing between 10k and 50k, it was not going to make that a number anyone would wait for. Replacing the fingerprint chain with `objectWillChange` was measured first, since the states are all ObservableObject and that publisher already exists. It came out worse — 2,898 ms at 50,000. The cost is the subscription machinery, not the chain fed into it, so the fix is to have none: `didSet` on each `@Published` property calls a closure the collector installed. `asFlow()` stays. Several provider controllers subscribe to it directly rather than through the collector, and they still pay per state — around a second to subscribe 50,000 on the same machine. That is a separate path and a separate change. `overlayChangePublisher()` had exactly one caller, the collector, so the protocol requirement goes with it and the six one-line conformances become empty. The web SDK was measured too and needs nothing: it also pushes, one closure per state, and 144,183 markers land in 58 ms.
Keeping one marker per cell of a few pixels is what makes 144,183 street trees readable below street level: they are planted metres apart along a road, so most of them sit on top of one another, and thinning them halves the tile bytes while showing the same map. android-sdk grew the option when that sample was built; porting the sample to the other two turned up its absence. Zero, the default, keeps the behaviour these renderers already had — drop only markers that agree exactly, same rectangle and same icon, which cannot change the tile. Above zero the group is a cell and the icon stops being part of it: the caller has said markers that close together are interchangeable, so one of them stands for the rest whatever they draw. The value is tile pixels on all three, not points or dp. It is compared against destination rectangles the renderer has already rounded into the tile's own pixel grid, and passing it through unscaled is what makes 14 mean the same density on a 3x iPhone as on Android.
`zoom-golden.txt` is an archive of what each platform returned before the migration, ios-for-mapbox's missing zoom offset included. It is not a specification, so comparing those three files says nothing about whether the current implementations agree. They did not: three of thirty-four rows. android truncated to Float32, and only this platform clamped latitude and wrapped longitude -- the other two returned 238107693 for `lat=90` and `-Infinity` for `lat=-90`. android and web have been brought here rather than the other way round, so this side is unchanged; this is the test that holds it. The vectors and the expected answers are byte-identical in all three repos. Any one drifting fails its own CI. api/TOOLCHAIN records which Xcode the api baseline was generated with. Upgrading Xcode produced a 3,606-line diff with the sources untouched -- `@available` reordered, `_modify` gone, conformances newly spelled out. Without the version written down there is no way to tell that from a real API change.
`declutterPx` was applied after the work rather than before it. Every marker in the tile got a placement and a rendered icon, and only then was one per cell kept — so the option that exists to save work added some. Measured on a 20,000 marker tile it cost 82.8 ms without decluttering and 178.5 ms with it. android-sdk asks its index instead: told a separation the caller considers interchangeable, the grid returns one marker per cell and reads its cells rather than its markers. At zoom 9 that is the ~5,000 cells holding Tokyo's street trees instead of all 144,183, and merely reading a position off an entity costs 0.72 microseconds. `inBoundsThinned` and the two-argument `findMarkersInBounds` are ports of that, and the renderer now calls them. The dictionary pass afterwards stays: the index declines when its 0.005 degree cells are coarser than the separation asked for — every zoom past 11, in the street tree sample — and then the real rule still has to be applied. On an iPad Pro 11, 144,183 markers at zoom 9: 281 ms to 64 ms. Decluttering now pays 22x where it paid 7x, matching android's 20x. The three thinning tests come from android's MarkerGridIndexTest. The first is the one that matters: the set of populated cells must match a brute-force scan exactly, because what this must never return is a hole — a cell with markers in the box that answers with nothing, which on a map is a patch of the city with no trees and no error anywhere. MarkerTileCostTests and IconSizeProbeTests are measurement fixtures sharing their seed, distribution and zooms with android's, so the numbers from the two can be compared directly.
The flat 0.005-degree grid could answer at exactly one scale. The key is now a Morton code over a hierarchy that starts from two square root cells (the hemispheres), so every prefix of a key is a cell and a query walks the level its box calls for. Square cells matter: the geomodel root made every cell twice as wide as tall, so satisfying a separation in longitude over-resolved latitude and each tile query returned twice the markers it kept (9,715 returned, 2,156 kept at z11; 52ms -> 32ms a tile on device). Thinned queries pick their winner without looking at the box, because two neighbouring tiles ask about the same border cell with different boxes, and disagreeing about the winner cuts an icon down the seam. The geocell string (hemisphere digit + 0123456789abcdef) is what the three SDKs' conformance fixtures compare; android-sdk and react-sdk generate byte-identical answers from the same vectors.
…er lanes A tile that renders to nil became a 404, and Google Maps remembers a 404 per coordinate for the session -- one transient empty during ingest became a permanent square hole with its neighbours' icons cut at the edge. Empty now returns a shared transparent PNG, a render failure returns 503 no-store (which map SDKs retry), and 404 is reserved for paths that name nothing. A transparent tile served while the manager holds markers fires onEmptyTileWhilePopulated so the provider can re-verify and drop the SDK's tile cache when the transparent was a lie. Tile drawing moves off the connection queue into a bounded utility lane (6 wide, matching android-sdk's 8-worker pool): a pinch used to fan out unbounded synchronous renders and starve the gesture. The declutter winner is now canonical -- world-anchored cell, key from world coordinates -- instead of last-enumerated, and the query grows by one declutter cell so a cell straddling a tile edge is seen whole by both sides. MetalMarkerRasterizer (GPU tile drawing) is committed default-off: on device it drops markers at the tile's right edge and swaps icon colours under configurations DeviceSeamReproTests reproduces (3 of 49 seams against 0 for the CPU path). MAPCONDUCTOR_MARKER_TILE_GPU=1 opts in; the repro tests are the regression net for fixing it. MarkerListIdentity rejects a re-sent identical marker list by reference, one line per provider controller: SwiftUI re-evaluates map content on every camera change, and each pass re-walked all 144k markers on the main thread -- the 'iOS pinches heavy' report, measured at a third of all main-thread hang samples.
… default again CGContext(data:) has its origin at the bottom left, so an icon packed into atlas row r actually lands in memory row rows-1-r. The UVs were computed top-down, so every instance sampled the icon one row-flip away -- unit test: a 3x3 atlas swapped rows 0 and 2 wholesale -- and an icon whose flipped position fell on the last row's empty cells vanished entirely. On the map that read as icons cut at tile seams and one circle in two colours, because the same marker resolved to different atlas rows in neighbouring tiles' atlases. The icons themselves stayed upright: CG flips image drawing the same way, and the two flips cancel within a cell but not across rows. The v coordinates are now measured from the bottom, with a half-texel inset against nearest-sampling bleed. MetalMarkerRasterizerTests pins the atlas down in isolation (colour identity across rows, orientation, edge overhang, determinism); on device, DeviceSeamReproBench went from 3 broken seams in 49 to 0, and GpuCpuTileDiff matches the CPU path pixel for pixel across 400 tiles, sequential and 6-way concurrent. Why the earlier 300-tile parity run missed it: its detector compared only the red and blue channels with loose thresholds, and the palette assigned similar hues to neighbouring indices, so row-swapped colours slipped under the tolerance. The new tests compare exact colour identity.
Only the marker renderer produced the transparent tile for an empty spot, so only marker tiles got the right answer. Every other provider that says 'nothing here' the same way -- geojson, kml, ground image, vector tile -- returned nil, and the server read nil as a failed render and answered 503. A map SDK retries a 503, so those layers had every genuinely empty tile asked for again and again, forever. The meaning now lives in one place, as it does in android-sdk-core: a provider returns nil for 'empty' and throws for 'could not draw it now'. The server answers empty with a transparent PNG at the density the path asked for, a throw with 503 and Retry-After, and keeps 404 for requests that name nothing. The protocol's cancellable overload gained "throws". Nothing that conforms to it had to change -- Swift lets a non-throwing method satisfy a throwing requirement, and the four extension modules and nine providers build untouched -- but it is a change to the published surface, so the dump moves with it. LocalTileServerSemanticsTests pins the four answers over real HTTP, the same four android checks on device. It also found its own bug first: run through URLSession.shared, the second test was served the first one's answer out of the HTTP cache, which is the very behaviour this change exists to get right.
Tile size is the app's choice and 512 is the cheaper default -- the per-tile costs (an HTTP round trip through the local server, a render, a PNG encode, a decode) scale with the count, so fewer, bigger tiles win for the same screen. But some SDKs have an opinion the layer supplying the tiles cannot see. ArcGIS's 3D SceneView is one: it picks the level as though every tile were 256 pixels, so 512-pixel tiles make it fetch one level deeper and cover the screen four times over. The picture is right, which is what makes it easy to miss, and the cost lands on the slowest devices. A provider can now register RasterTilePreference and be answered; saying nothing means it has no opinion and the supplier's default stands. An explicit tile size from the app always wins over both.
LocalTileServer.renderLocalTile(url:isCancelled:) draws a tile without going through HTTP, for backends whose tile callback can be cancelled (ArcGIS). The HTTP path cannot notice a client that gave up: an HTTP client half-closes after sending, so isComplete fires at once; recorded as an expected failure. removeProviderRegistrations also drops RasterTilePreferenceKey.
BlankMapStyle: the JSON, its one colour, and a file URL written once into Caches for the SDKs that only take a URL. android-sdk-core and js-sdk-core carry the same style.
The all-zero camera used to mean "position only, keep the zoom"; that is now the moveCameraTo(position:) overload's job.
Counterpart of android-sdk-core: a map that draws vector styles itself registers VectorStyleSupport; VectorStyleAsDesign makes the style the design and restores the previous one a frame later. The tile server serves documents and directories (an offline package) with an optional upstream fallback.
…ockwise # Conflicts: # api/MapConductorCore.api.swift
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.
概要
feature/bearing-clockwiseのうち、まだdevelopに入っていない 28 コミットを取り込みます(bearing の変更は #10 でマージ済みです)。各プロバイダーのdevelopは、すでにこの PR の API(VectorStyleSupport、RasterTilePreference、BlankMapStyle、renderLocalTileなど)を使っています。そのため、この PR がマージされるまでは、今のコアのdevelopに対してプロバイダーをビルドできません。ベクタースタイル・デザイン
VectorStyleSupportを追加しました。ベクタースタイルを自分で描けるマップはこれを登録します。VectorStyleAsDesignはスタイルをデザインとして使い、1 フレーム後に元のスタイルに戻します。BlankMapStyleを追加しました。各バックエンドの「ベースマップなし」はこれで表します(android/js と同じスタイルです)。タイルサーバー
Retry-After、no-store)、何も指していないパスは 404 で返すようにしました。これまでは nil が 404 や 503 になっていました。Google Maps が 404 をセッション中ずっと覚えてしまい穴が残る問題と、SDK が 503 を何度もリトライし続ける問題を直しています。throwsを付けました。既存の実装はそのままビルドできますが、公開 API の変更です。LocalTileServer.renderLocalTile(url:isCancelled:)を追加しました。キャンセルできるバックエンド(ArcGIS)向けに、HTTP を通さずプロセス内でタイルを描きます。RasterTilePreferenceを追加しました。プロバイダーが希望のタイルサイズを伝えられます。アプリが明示した値が常に優先されます。マーカー
MarkerListIdentityを追加しました。前回と同じマーカーリストは参照比較ですぐに返します(144k マーカーでピンチ操作が重かった件の対策です)。declutterPxを追加しました(android-sdk と同じオプションで、値はタイルのピクセル単位です)。間引きはインデックス側で行います。144,183 マーカー、zoom 9 で 281ms → 64ms になりました。MarkerGridIndexを追加しました(android と同じ実装を Morton の正方セルに作り直したものです)。あわせて、日付変更線をまたぐ範囲が空になる不具合と、findNearestが最寄りでないマーカーを返す不具合を直しました。TilePng.xcframework、TilePngEncoder)で行うようにしました。使えない環境ではプラットフォームのエンコーダーにフォールバックします。その他
api/TOOLCHAINに記録しています。