MapLibre Native currently supports two ways to provide vector tile data:
- URL-based sources (
vectorsource type) — tiles are fetched according to the protocol of the URL (http://,file://, etc.). - CustomGeometrySource — accepts GeoJSON features programmatically, but internally re-encodes them into vector tiles, adding overhead and limiting control over the tile encoding.
This proposal comes from the need for an efficient and kotlin native way to asynchronously connect a tile data source similar to vector tile. CustomGeometrySource was considered, but it has some obvious drawbacks:
- Tile requests are in the format of a bounding box — since it's not a tile X/Y/Z it is pretty unclear what logic it
conforms to.
- Is it just a tile X/Y/Z but converted to geo bounds?
- Can it ever request data in a bounding box that is bigger than just one X/Y/Z tile
- Additional boilerplate of mapping geo bounds into tile X/Y/Z
- Internally CustomGeometrySource spawns a 4 thread threadpool and runs tasks one by one inside. It's a good old technique but Kotlin has things like coroutines which are a more ergonomic way to handle suspendable operations (like network requests).
- CustomGeometrySource operates over GeoJSON. So in case of a vector tiles like format it needs to be converted to GeoJSON and then internally it will be cut down into vector tile format back.
Introduce a new source type, CustomVectorSource, that lets the application deliver raw binary tile data (starting with
MVT) directly to the rendering pipeline. The source participates in the standard tile loading lifecycle — the core
requests tiles by CanonicalTileID, and the application responds asynchronously with encoded data or an error.
┌─────────────────────────────────────────────────────────────┐
│ Platform layer (Kotlin) │
│ │
│ CustomVectorTileProvider.fetchTile(z, x, y) → TileData │
│ ↕ (JNI / platform bridge) │
├─────────────────────────────────────────────────────────────┤
│ Core C++ layer │
│ │
│ CustomVectorSource │
│ ├── Options { fetchTileFunction, cancelTileFunction, │
│ │ zoomRange } │
│ ├── setTileData(CanonicalTileID, shared_ptr<string>, │
│ │ TileDataFormat) │
│ ├── setTileError(CanonicalTileID, exception_ptr) │
│ └── invalidateTile(CanonicalTileID) │
│ │
│ CustomVectorTileLoader (Actor on Scheduler::GetBackground) │
│ └── Receives fetch/cancel requests from tiles │
│ │
│ CustomVectorTile (extends Tile) │
│ └── Parses MVT data via VectorTileData, delegates to │
│ standard GeometryTileWorker for symbol/line/fill │
│ layout │
│ │
│ RenderCustomVectorSource (extends RenderTileSetSource) │
│ └── Standard tile pyramid rendering │
├─────────────────────────────────────────────────────────────┤
│ Existing rendering pipeline (unchanged) │
│ GeometryTileWorker → Buckets → Drawables → GPU │
└─────────────────────────────────────────────────────────────┘
-
Binary data, not GeoJSON. For the Kotlin layer to send data to C++, it needs to be marshaled into some data format that can be passed across languages. One could come up with a new binary format, implement an encoder in Kotlin and a decoder in C++. But it turns out MVT is already such a format — just a binary blob that can be passed simply as a sized buffer. Existing infrastructure of
MVTtile data is just reused as is. -
Actor-based concurrency.
CustomVectorTileLoaderruns on a background thread via MapLibre's Actor system. The platform callback (fetch/cancel) is invoked on this thread; the platform layer is responsible for dispatching to its own preferred context (e.g., coroutines on Android). -
Cooperative cancellation. When a tile is no longer needed (e.g., user panned away), the core calls
cancelTileFunction. The platform layer can abort in-flight work. -
Standard tile pipeline reuse. Once binary data arrives, it goes through the same
VectorTileData→GeometryTileWorkerpath as HTTP-fetched vector tiles. No custom rendering code is needed. -
Extensible format enum.
TileDataFormatis an enum starting withMVT. Future formats (e.g., a hypothetical compact binary format) can be added without API changes. And obvious next candidate isMLTformat support in CustomVectorSource.
Main thread: Source::update() → tile needs data
↓
Background Actor: CustomVectorTileLoader::fetchTile()
↓
Platform bridge: fetchTileFunction callback → platform code
↓ (async, any thread)
setTileData() → mailbox → CustomVectorTile::setData()
↓
GeometryTileWorker: parse MVT, create buckets (background pool)
↓
Main thread: Tile ready → render
namespace mbgl::style {
enum class TileDataFormat : uint8_t { MVT = 0 };
using TileFunction = std::function<void(const CanonicalTileID&)>;
class CustomVectorSource final : public Source {
public:
struct Options {
TileFunction fetchTileFunction;
TileFunction cancelTileFunction;
Range<uint8_t> zoomRange = {0, 18};
};
CustomVectorSource(std::string id, const Options& options);
void setTileData(const CanonicalTileID&,
const std::shared_ptr<const std::string>& data,
TileDataFormat format = TileDataFormat::MVT);
void setTileError(const CanonicalTileID&, std::exception_ptr error);
void invalidateTile(const CanonicalTileID&);
};
} // namespace mbgl::styleinterface CustomVectorTileProvider {
suspend fun fetchTile(z: Int, x: Int, y: Int): TileData
}
sealed class TileData {
class Mvt(val data: ByteArray) : TileData()
}
class CustomVectorSource(
id: String,
provider: CustomVectorTileProvider,
scope: CoroutineScope,
minZoom: Int = 0,
maxZoom: Int = 18
) : Source()The Android API uses Kotlin coroutines for async tile fetching. The CoroutineScope is caller-provided. Meaning
CustomVectorSource can run on a threadpool but not limited to a threadpool. CoroutineScope abstracts it away.
Not included in this initial implementation. The core C++ API is platform-agnostic and can be wrapped in Swift/Obj-C following the same pattern.
All changes are additive. No existing APIs are modified or removed. The new source type is opt-in — applications that don't use it are unaffected.
- A new
SourceType::CustomMVTVectorenum value is added to the core. - Existing
CustomGeometrySourceremains unchanged and is not deprecated.
CustomGeometrySource is designed around GeoJSON features and internally manages geometry encoding. Bolting binary tile
support onto it would conflate two different data models and complicate its already non-trivial implementation.
This approach (e.g., mbtiles:// or custom://) requires the application to write tile data to disk or implement a
custom FileSource. It adds I/O overhead, doesn't support cooperative cancellation, and is harder to integrate with
structured concurrency frameworks like Kotlin coroutines.