Skip to content

Vector style support, tile server semantics, and marker tile performance - #16

Merged
wf9a5m75 merged 29 commits into
developfrom
feature/bearing-clockwise
Oct 24, 2026
Merged

wf9a5m75 merged 29 commits into
developfrom
feature/bearing-clockwise

Conversation

@wf9a5m75

@wf9a5m75 wf9a5m75 commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

概要

feature/bearing-clockwise のうち、まだ develop に入っていない 28 コミットを取り込みます(bearing の変更は #10 でマージ済みです)。各プロバイダーの develop は、すでにこの PR の API(VectorStyleSupport、RasterTilePreference、BlankMapStyle、renderLocalTile など)を使っています。そのため、この PR がマージされるまでは、今のコアの develop に対してプロバイダーをビルドできません。

ベクタースタイル・デザイン

  • VectorStyleSupport を追加しました。ベクタースタイルを自分で描けるマップはこれを登録します。VectorStyleAsDesign はスタイルをデザインとして使い、1 フレーム後に元のスタイルに戻します。
  • ローカルタイルサーバーに file/document ルートを追加しました。オフラインパッケージを配信でき、必要なら upstream にフォールバックします。
  • BlankMapStyle を追加しました。各バックエンドの「ベースマップなし」はこれで表します(android/js と同じスタイルです)。
  • ベクタータイルのソースズームの上書きを渡せるようにしました。

タイルサーバー

  • 空タイルは透明 PNG、描画の失敗は 503(Retry-After、no-store)、何も指していないパスは 404 で返すようにしました。これまでは nil が 404 や 503 になっていました。Google Maps が 404 をセッション中ずっと覚えてしまい穴が残る問題と、SDK が 503 を何度もリトライし続ける問題を直しています。
  • provider の cancellable なオーバーロードに throws を付けました。既存の実装はそのままビルドできますが、公開 API の変更です。
  • 描画はサイズに上限のあるユーティリティ用のキュー(6 並列)で行うようにしました。これまではピンチすると同期描画が際限なく走り、ジェスチャーが固まっていました。
  • LocalTileServer.renderLocalTile(url:isCancelled:) を追加しました。キャンセルできるバックエンド(ArcGIS)向けに、HTTP を通さずプロセス内でタイルを描きます。
  • 透明タイルを公開しました。バックエンドが描画の代わりに返せます。
  • RasterTilePreference を追加しました。プロバイダーが希望のタイルサイズを伝えられます。アプリが明示した値が常に優先されます。

マーカー

  • MarkerListIdentity を追加しました。前回と同じマーカーリストは参照比較ですぐに返します(144k マーカーでピンチ操作が重かった件の対策です)。
  • declutterPx を追加しました(android-sdk と同じオプションで、値はタイルのピクセル単位です)。間引きはインデックス側で行います。144,183 マーカー、zoom 9 で 281ms → 64ms になりました。
  • MarkerGridIndex を追加しました(android と同じ実装を Morton の正方セルに作り直したものです)。あわせて、日付変更線をまたぐ範囲が空になる不具合と、findNearest が最寄りでないマーカーを返す不具合を直しました。
  • オーバーレイの state が自分で変更を通知するようにしました。50,000 件の同期が 2,175ms → 52ms になりました。
  • 半径検索で、結果を使わないのにソートしていた処理をやめました。
  • マーカータイルの描画を次のように改善しました。
    • 整数ピクセルに描くようにしました。
    • 自前の bitmap context で描くようにしました。
    • まったく同じ位置に重なったマーカーは 1 回だけ描くようにしました。
    • クエリのパディングをアイコンのサイズから計算するようにしました。
  • PNG エンコードを Rust(TilePng.xcframework、TilePngEncoder)で行うようにしました。使えない環境ではプラットフォームのエンコーダーにフォールバックします。
  • GPU マーカーラスタライザーで、アトラスの行が上下反転していたのを直しました。そのうえで、GPU 描画を再びデフォルトにしました。

その他

  • Web Mercator の整合性テストを追加しました(android/web とベクトル・期待値が一致することを確かめます)。
  • API ベースラインを更新しました。ベースラインを生成した Xcode のバージョンを api/TOOLCHAIN に記録しています。

masashi and others added 29 commits September 1, 2026 11:40
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
@wf9a5m75
wf9a5m75 merged commit c61ec32 into develop Oct 24, 2026
2 checks passed
@wf9a5m75
wf9a5m75 deleted the feature/bearing-clockwise branch October 24, 2026 10:22
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