Writing an extension
The SDK does not need to contain every format. A data source it has no factory for can live in its own library, be written in Kotlin or Swift, and still be a first-class facade object that specs and layers point at.
That is deliberate. GDAL is the example everyone reaches for — it would add tens of megabytes, a
proj.db, four Android cross-builds and five iOS slices to a map SDK that most apps use for tiles.
As an extension it costs the SDK nothing and costs an app only what it asks for.
massif-maps/gdal-extension reads GeoTIFF and OGR vector files this way, and doubles as a worked example of cross-building a minimal GDAL.
How it works
Three SDK pieces, all of which already exist:
TileDataSource and VectorDataSource are SWIG directors | so a binding can subclass them and override loadTile / loadElements |
TileData takes a BinaryData | so an override can return bytes it decoded itself |
MassifInterop.adopt(kind, id, source) | registers the instance under a facade id, so specs and layers can reach it |
An extension is therefore an ordinary Android or iOS library. It ships whatever native code it needs, and the SDK never links against it.
A tile source, in Kotlin
class GeoTiffSource(private val path: String) : TileDataSource(0, 18) {
override fun loadTile(tile: MapTile): TileData? {
// Your own decode. The SDK asked for one tile of its grid; hand back an image.
val png = NativeGdal.renderTile(path, tile.zoom, tile.x, tile.y) ?: return null
return TileData(BinaryData(png))
}
}
loadTile runs on the SDK's tile threads, not the UI thread, and several at once — it has to
be thread-safe. Each call crosses JNI, which is irrelevant next to a decode but would not be next
to a memory lookup.
Then register it and use it like any other source:
val source = GeoTiffSource("/sdcard/dem.tif")
MassifInterop.adopt("source", "dem", source)
map.addLayer("dem", Spec.of("raster").set("source", "dem"))
A vector source
VectorDataSource is the same shape — override loadElements(cullState) and return a
VectorData of VectorElements you built. Because the elements are yours, so is the styling:
pick a MarkerStyle or LineStyle per feature in Kotlin, where the rules are easier to read than
in any expression language.
class ShapefileSource(projection: Projection) : VectorDataSource(projection) {
override fun loadElements(cullState: CullState): VectorData {
val elements = VectorElementVector()
for (feature in readFeatures(cullState.viewState)) {
elements.add(Marker(feature.pos, if (feature.big) bigStyle else smallStyle))
}
return VectorData(elements)
}
}
A tile source in C or C++
An extension whose real work is native — GDAL, a proprietary decoder — does not have to bounce
through the binding language. mm_source_create_custom registers a source implemented as a plain
C function pointer:
#include <MassifApiC.h>
static int load_tile(void* user, int z, int x, int y, mm_tile_sink sink, void* sink_data) {
unsigned char rgba[256 * 256 * 4];
if (!render_tile(user, z, x, y, rgba)) {
return MM_OK; /* no such tile - a hole, not an error */
}
/* Copied inside this call, so the stack buffer above is fine and there is nothing to free. */
sink(sink_data, rgba, sizeof(rgba), MM_TILE_RGBA8, 256, 256);
return MM_OK;
}
mm_tile_source source = { 0, 14, load_tile, close_dataset, dataset };
mm_handle handle;
mm_source_create_custom(mm_context_default(), "dem", &source, &handle);
The id then works exactly as an adopted one does — Spec.of("raster").set("source", "dem").
MM_TILE_RGBA8 hands over decoded pixels, so nothing is encoded only to be decoded again — the
Kotlin path gets the same with TileData(pixels, width, height) (premultiplied RGBA8; such tiles are
not persistently cached). Use MM_TILE_ENCODED when what you have really is a file, including
vector tile protobuf for a "vector" layer.
Link against libmassif.so for the mm_* symbols only — they are the exported surface, and the
extension's own .so is otherwise independent of the SDK.
A layer you draw yourself
mm_layer_create_custom registers a layer whose drawing is a C function. It is called on the GL
thread with plain GL ES 3.0, after every layer and billboard of the frame, with the depth test off:
a screen-space overlay for a heatmap, an annotation or a HUD. For meshes and volumes there is a scene
mode, below.
static void render(void* user, const mm_render_params* p) {
Heat* heat = user;
if (!heat->program && !heat_init(heat)) return; /* create GL objects here, not before */
glBindFramebuffer(GL_FRAMEBUFFER, p->framebuffer); /* never 0: with a post-process effect the scene is offscreen */
heat_draw(heat, p->mvp_local, p->opacity);
/* restore every piece of GL state you changed - see the header */
}
mm_custom_layer layer = {
.size = sizeof(layer), .mode = MM_CUSTOM_LAYER_OVERLAY,
.on_removed = release_gl, .on_context_lost = forget_gl,
.render = render, .destroy = free, .user_data = heat,
};
mm_handle handle;
mm_layer_create_custom(mm_context_default(), "heat", &layer, &handle);
/* then add the handle to the map's layers like any other layer */
The rules that matter, all in MassifApiC.h:
- Restore the GL state you touch — program, VAO, buffers, active texture and its binding, blend, depth, cull face, viewport, scissor. A leak corrupts the next layer, and looks like an SDK bug.
- Draw into
framebuffer, not into 0. - Use
mvp_localfor vertex transforms and subtractoriginin double first; a float matrix at world scale jitters. - Opacity is yours to apply — it arrives in the params.
- Do not ask for a frame every frame. Use
mm_layer_request_redrawwhen something changed; it is safe from any thread. MM_CUSTOM_LAYER_SCENEinstead draws inside the 3D scene, depth tested against the terrain and the tiles. AppendMM_CUSTOM_LAYER_GLSL_DEPTHto your vertex shader and finish it withgl_Position = mm_applyDepth(gl_Position), uploadingdepth_layer_offsetanddepth_shift; without it you z-fight or punch through the terrain.- Clicks: give
hit_test(a ray in, a distance and your own feature id out) andon_click(return non-zero to consume it). Both run on the input thread, concurrently withrender. on_removedruns on the GL thread with the context current, so delete your GL objects there;on_context_lostmeans they are already gone, so do not.
This is a draw hook, not a tile pipeline: to supply pixels use a custom source above, to shade them a custom raster layer.
What an extension cannot do
- Register a spec
type. The spec table is generated at build time, so{"type": "geotiff"}in JSON is not available. Construct the source and adopt it, or usemm_source_create_custom; specs then reference it by id or handle. - Subclass an SDK class from C++. The SDK compiles into one library with
-fvisibility=hiddenand an Android link that exports onlyJava_*,CSharp_*,SWIG*andmm_*, so another native library cannot derive frommassif::TileDataSourcehowever many headers it has. That is whatmm_source_create_customandmm_layer_create_customexist for — a function pointer instead of a vtable. - Supply vector ELEMENTS from C. The C ABI covers tile sources. A
VectorDataSourceproducingVectorElements is a binding-language subclass, as above. - Avoid thread safety. Every one of these — the two overrides and the C loader — is called concurrently from tile and culling threads.
Why this rather than a build profile
An earlier attempt put GDAL in the SDK: a gdal build profile, a full+gdal published variant,
a CI job cross-building GDAL for four ABIs, and a plan to vendor GDAL and PROJ into
libs-external. It worked on paper and made every SDK build carry a GIS dependency's problems —
proj.db's nine megabytes, five iOS slices, a driver set nobody agreed on.
The extension does the same job with no SDK change at all. That is the pattern for anything with a heavy native dependency: if the SDK does not need it to draw a map, it belongs in an extension.