Skip to main content

The demo app, and how to debug the renderer

Everything about scripts/android-dev: the two screens, the intent extras, the probes that work, and the measurement discipline. CLAUDE.md routes here rather than carrying it, because none of it is needed to answer an ordinary question.

The app has TWO screens, and the bench is NOT the one it opens on​

ActivityWhat it is
.MainActivitythe example gallery — one file per example, on the facade API. Not for debugging. See examples.md
.ExampleActivityruns one example: --es example <id>, plus --es ui false, --es globe true and --es lon/lat/zoom/tilt/rotation
.BenchActivitythe composable debugging/measurement map — every layer switch, every intent extra, DemoLive

A debugging run names .BenchActivity explicitly; am start with no activity opens the gallery.

The fast loop​

cd scripts/android-dev && ./gradlew :app:assembleDebug -x lint # ~40 s incremental (native included)
adb install -r -t app/build/outputs/apk/debug/app-debug.apk # -t: the APK is test-only
adb shell am force-stop com.massifmaps.MassifDemo
adb shell am start -n com.massifmaps.MassifDemo/.BenchActivity --es ui false --es drape false

-PabiFilters=arm64-v8a narrows the native build to one ABI — enough for a device or an arm64 emulator, and much faster than the default four.

  • Install from app/build/outputs/apk/debug/. app/build/intermediates/apk/debug/ also holds an app-debug.apk and it is stale. Verify with unzip -p <apk> classes*.dex | strings | grep <new symbol> (the demo's Java lands in classes5.dex).
  • pm clear com.massifmaps.MassifDemo resets camera/caches. Without it the persistent tile caches stay warm, which is usually what you want — but two runs that differ only in cache state are not a valid A/B.

Settle time​

Tiles need 60–90 s to settle before a screenshot means anything (network + persistent cache + label placement). For anything involving 3D extrusions — shadows above all — this is not optional: buildings arrive asynchronously, and before they cast there are no shadows to be wrong. A frame taken too early shows a clean scene that looks exactly like a fix.

The iOS demo as a Mac app​

scripts/ios-dev also builds as a native Mac Catalyst app that takes the same knobs as launch arguments and captures screenshots and frame bursts in-process, with the app's own Massif style and offline data, so a rendering bug reproduces in a minute without the simulator or NativeScript. See the Catalyst demo loop.

The demo files​

File (under app/src/main/java/com/massifmaps/MassifDemo/)Role
demo/DemoConfig.javaevery default, one static field per knob + the intent-extra key map
demo/DemoCfg.javacfgBool/cfgFloat/cfgInt/cfgStr/cfgColor/cfgEnum intent readers (--es key value)
demo/DemoMap.javabuilds/updates the map through the facade only (the bench carries no object API): layer registry, shared sources, terrain/light/sky, camera
demo/DemoStyles.javastyle specs (dir / zip / inline CartoCSS / style project) + demo shaders
demo/DemoSky.javaday-cycle sun/sky + generated sky shader
demo/DemoPanel.javaon-screen panel — writes DemoConfig, then calls a DemoMap.apply*()
demo/DemoTests.javaone-shot actions (routing, search, GeoJSON)
ui/main/SecondFragment.javaAndroid glue only (view, permissions, map listener)

Change defaults in DemoConfig only. These files may carry uncommitted local edits (camera, per-demo knobs): read before touching, keep changes additive, never restore from a backup or an older commit.

Intent extras​

Standalone layers (map = the base map, satLayer, hillshade, hypso, contourLayer, contourTiles, spans, routes, elements, bugs) toggle with --es <name> true|false (--es satUnder true puts the satLayer raster UNDER the base map, as a hybrid style's imagery); the base map has --es base plain|composite and --es style dir|zip|inline|project. dir reads the style from a FOLDER via DirAssetPackage (/sdcard/alpimaps_mbtiles/osm), falling back to osm.zip then to inline CartoCSS.

radar is the animated raster layer over LibreWXR (RainViewer's API): the past hour and the nowcast, 18 steps. A frame clock advances the position at --es radarFps 2 steps a second; the RADAR LAYER section of the panel has playing, a position slider that scrubs (and follows the clock), the step's time (marked forecast past now) and the rate. --es radarPlay false starts paused. Judged at --es radar true --es lon 5.72 --es lat 45.19 --es zoom 8 --es tilt 0: no pop at a step boundary, no darkening where two steps overlap. Needs the network.

The gallery example custom-layer-heatmap (Android and iOS) is the same layer with a 3D toggle, a Drape toggle and an opacity slider; --es drape 0 starts it undraped on Android. customOverlay and customScene are the custom layers of app/src/main/cpp/custom_layer_demo.c, written against MassifApiC.h alone (the app builds that file with CMake, and looks the mm_* functions up in the loaded libmassif.so). The scene layer is a heatmap draped on the terrain: DemoMap.createHeatmapLayer samples the DEM on a 160 x 160 grid over 8 km around the start position (off the UI thread), a few synthetic hot spots are the data, and the C layer draws the grid depth tested, so a ridge in front hides it. A tap on it logs massif-demo with the cell and value (hit_test marches the ray over the grid) and dims the heatmap. The overlay is the colour legend, in screen space, tinted by the layer's opacity. Judged at --es customOverlay true --es customScene true --es terrain true --es lon 5.76 --es lat 45.24 --es zoom 12.2 --es tilt 35 (3D; tilt is the angle from the horizon, 90 is straight down) and the same with --es terrain false --es tilt 90 (2D): the heatmap follows the relief with only slivers cut out on the steepest ridge edges, and sits flat in 2D. Mercator metres are converted to world units in C, so the grid is right whatever the base projection.

models is the 3D MODELS layer (DemoMap.createModelsLayer): a jittered grid of trees as ONE MultiPoint around the start position and three spinning wind turbines 500 m north-east of it, drawn from assets/style/models/*.glb by the Massif model rules; a tap tints the model hit (setModelHighlight). The base map does the same for the Massif style's poi trees and turbines. --es vectorFile rhones_alpes/rhone-alpes.pmtiles reads the base map from a .pmtiles under the data root instead of vectorUrl - offline OpenMapTiles data with every mapped tree:

--es base plain --es style assetzip --es styleAssetZip massif --es vectorFile rhones_alpes/rhone-alpes.pmtiles \
--es lon 5.7305 --es lat 45.1855 --es zoom 17.8 --es tilt 60

bugs is the STYLE REGRESSION repro layer (DemoStyles.bugStyle, DemoMap.createBugsLayer): synthetic GeoJSON on the start camera, one feature per reported symptom, each with its A/B knob — bugLabelSize, bugBackOpacity, bugLineColor, bugLineBorder (pair with --es bugBackOpacity -1), bugAllowOverlap/bugTextClip. --es bugs true --es ui false shows all four.

It also carries the bridge / tunnel span (bugbridge, bugtunnel): one chord across the Bastille ridge, 207 m at its south end and 641 m at its north, with the ground bulging ~60 m ABOVE the chord in between — so "goes straight" is unmistakable, and a tunnel on the same chord is genuinely inside the hill at mid-span. They are their own vt layers so the drape bake can be turned off by name:

--es bugs true --es terrain true --es noDrape '^contour|bug(bridge|tunnel)'

Quote the filter for the DEVICE shell (the | is re-parsed there), and note noDrape needs a RELAUNCH — an already-baked drape texture is not invalidated when the filter changes. Real bridges are a poor substitute: Millau's DEM sits 220 m above the actual deck and the viaduct is longer than a z14 tile. The span keeps INTERMEDIATE vertices on purpose (bugSpanVertices) — a two-point line is already straight once it leaves the bake and would show a fix that is not there.

--es demo terrain|project|composite is a legacy shorthand for base/style. Every knob read in DemoConfig.applyIntentOverrides is an intent extra, so most experiments need no rebuild:

  • camera: lon lat zoom tilt rotation, fovY (0 = the SDK default); zoomTo <zoom>,<seconds> (CONFIG broadcast only) animates the zoom, the scripted stand-in for a slow pinch
  • terrain: drape drapeLines drapeResolution meshResolution exaggeration, viewDistance viewDistanceMeters, autoFlatten autoFlattenTilt autoFlattenMs, stitch
  • composite overlays: hs sat satZoom contour; buildings: bld3d
  • stand-alone raster layer (satLayer): rasterUrl rasterCacheDb rasterMaxZoom rasterBias; rasterBias is live (CONFIG broadcast, or the "satellite zoom bias" panel slider). The tile cache is not keyed by URL: change rasterCacheDb with it
  • light/shadow: daycycle sunHour sunAzimuth sunAltitude shadow terrainShadows (shadows need terrainLight true)
  • labels: textOcclusion, roadLabelOcclusion (a re-decode)
  • ui false (hide the panel), anim zoom|pan|rotate|zoomseq|approach
  • showFps true (bench and .ExampleActivity): frames drawn per second and the GL-thread draw time, bottom-left and once a second in logcat (devtap.py logs android --grep fps); WHEN_DIRTY draws on request only, so it reads idle on a still map

--es buildings 0|1|2 drives a COMPILED style's own buildings parameter (none / footprints / extrusions), which is what a converted Mapbox Standard gates its 3D on. --es bld3d sets it too.

--es vectorZoomBias -1 for a source authored for MapBox's 512 px tiles. This SDK's tiles are 256 px, so at the same view it asks for a level DEEPER than mapbox-gl does. The on-screen readout prints z=<sdk> (mb <sdk-1>) for this reason.

A second DEM behind an OrderedTileDataSource: demEncoding, dem2Url dem2Encoding dem2MaxZoom. dem_encoding is resolved per TILE. Launch-only, and the encoding is stored WITH the cached tile — a relabelled source keeps rendering with the old one until pm clear.

Changing a knob on the RUNNING app​

adb shell am broadcast -a com.massifmaps.MassifDemo.CONFIG --es fog false
adb shell am broadcast -a com.massifmaps.MassifDemo.CONFIG --es fogPreset dusk --es zoom 14

Same keys as the launch extras; only the option groups whose keys arrive are re-applied, and the camera is left alone unless a camera key is sent. This is how an A/B gets run without the tile set changing underneath it. am start on an already-running bench does the same thing — the activity is singleTop and BenchActivity.onNewIntent feeds its extras back through DemoLive.

The gallery has the same channel through ExampleLive (same short keys), plus --es styleParam name=value, a style parameter on the example's basemap layer (buildings=1 turns Mapbox Standard's extrusions into footprints). A light, fog or sky knob on an example that never set one builds a default object first (as tools/style-preview/massif-pane.html does); a terrain knob needs the example's own elevation source and is ignored with a warning. --es flattened false --es tilt 38 switches a flat example to 3D in place; the 2D/3D button in terrain-2d-3d flies to its summit. Two traps:

  • ExampleLive applies the writes on its own worker thread: a receiver runs on the main thread with a deadline, and a write blocks on whatever the render thread holds, so writing inline ANRed the app.
  • The receiver must be registered RECEIVER_EXPORTED — adb shell am broadcast runs as the shell uid, and a NOT_EXPORTED receiver drops every one silently (result=0, no log).

ExampleActivity logs the camera on every move, in the form the launch extras take:

python3 scripts/devtap.py logs android --grep camera

Things that look like bugs and are not​

  • A style knob needs a re-decode, not just an option apply. Anything written into the CartoCSS (style styleLight bld3d bldLight bldAmbient bldGradient bldGradientHeight) is carried by the TILES, so DemoLive rebuilds the base layer for those keys. The inline style's whole sun/shadow/ building block is gated on --es styleLight true.
  • A screenshot after a state change needs the map to have drawn TWICE. The surface is double-buffered and WHEN_DIRTY draws exactly what was requested, so one frame lands in the back buffer and the screen keeps the old state — permanently, not briefly. If a change "sometimes does not apply", check the frame count before suspecting the change (01-frame.md).
  • Camera clamp: the terrain keeps a cameraClearance above the ground, so a start position inside a slope auto-zooms out and you get an empty grid screen. Zoom out a notch instead of assuming the render broke.
  • --es shadowDistance with a large value hangs the app — the caster ring is sized by the shadow throw, so a big cutout explodes the tile count.

Debugging the renderer — what actually works​

Measure first, look second. Reading a screenshot, or a raw log, is by far the most expensive thing in a debugging loop — in wall-clock and, for an agent, in context. scripts/devtap.py does the bulk work on disk and prints a summary; the full artifact lands in build/devtap/ for grepping. Use it instead of adb logcat, adb screencap, xcrun simctl log show or opening a .ips:

python3 scripts/devtap.py logs android --max 20 # 2087 raw lines -> 10
python3 scripts/devtap.py logs ios --since 3m # 15193 -> 8
python3 scripts/devtap.py crash android # tombstone, ndk-stack symbolicated
python3 scripts/devtap.py crash ios # 112 KB .ips -> faulting thread, 17 lines
python3 scripts/devtap.py shot android --compare before.png
python3 scripts/devtap.py diff before.png after.png

logs android scopes to the demo app's pid, drops the system noise tags, and collapses lines that differ only in numbers into one xN entry — the header names what it dropped, so a surprising count is itself a signal. --grep REGEX bypasses every filter for one specific probe. --device (or ANDROID_SERIAL) is mandatory when several emulators are attached; devtap refuses to guess.

shot and diff print per-band mean/stddev and the changed-region bbox, not the image. mean 0.00 max 0 means the two frames are identical — that is the answer, and it is how you catch a knob that never applied. Only open the png when a number moved, and crop first (--crop x0,y0,x1,y1) rather than reading a full 1080×2400 frame.

Counting label churn on a device​

A label blink is either the placement pass flipping a label, or something feeding it a different input. The SDK counts both, behind MASSIF_VT_RENDER_STATS, and prints one RenderStats: line set a second. Build any app with the counters in and read them as a timeline:

# Alpi Maps, SDK from source (MASSIF_SDK_HOME = the checkout under test)
MASSIF_SDK_HOME=<sdk checkout> DEBUGMASSIF=1 ns run android --gradleArgs=-PprofileRender
# the demo: ./gradlew :app:assembleDebug -PprofileRender
python3 scripts/devtap.py stats android --device <serial> --package <the app's id>

One row a second (cullUpd, tileRecalc, cullPasses, visFlips, occFlips, lineLayouts...), the intervals with visFlips > 0 marked, and the mean of every counter in those intervals against the quiet ones. Reproduce the blink, run it straight after, and read which counter jumps with the flips:

Counter that moves with the flipsThe cause
cullUpd, tileRecalcthe view changed: MapRenderer::viewChanged re-culled, and the tile set was recalculated, which asks for a placement pass
cullPasses without thema placement pass the view did not ask for: consumeLabelPlacementOwed / consumeLabelReanchorOwed, a zoom step of 0.25, an option setter
occFlips, occludedthe building occlusion verdict re-ranks labels (06-labels)
lineLayouts, reVisiblea line run (road name, arrow) re-laid on a new view matrix, or re-anchored
labelMaps up, labelMapSkips flatlabels rebuilt on a tile-set change

The counters are process-wide and logcat keeps a few minutes, so read it right after the repro.

  • A/B by feature, per screen row band, before probing the plumbing. Screenshot with a layer on and off (--es hs false, --es sat false, --es drape false) and devtap diff the two. If a band is 0.0% different, that content is not being drawn there; if it differs, it is drawn and the problem is elsewhere. This is what separated "tile never loaded" from "tile drawn but depth-rejected".
  • Probes go in these places, in this order — layer culling → draw data → vt render tiles → draw: TileLayer::buildFetchTiles (visible set + cache misses, typeid(*this).name() tells you which layer), TileRenderer::refreshTiles (per-zoom tiles/bitmaps/geometries, log this to separate a composite's children), RasterTileLayer::FetchTask::loadTile, ElevationTextureCache::getTexture, and GLTileRenderer::renderGeometry2D.
  • vt has no logger — use __android_log_print(4, "massif", ...). When throttling a probe with a frame counter shared by several renderer instances, use a prime modulus: % 120 with 4 instances always logs the same one.
  • Shader::getUniformLoc returns 0 for a uniform the compiler dropped, and 0 is a valid location — writing to it clobbers whatever lives at 0. SkyRenderer uses glGetUniformLocation and >= 0 guards; copy that pattern.
  • setDebugWireframe / setDebugSurfacePrefill in TileRenderer.cpp are hardwired false and mostly wash the frame out; the A/B diff above is more informative.
  • Strip every probe before committing.

OpenGL ES 3 is a hard requirement​

Both platforms create an ES3 context and nothing falls back to ES2: android/java/com/massifmaps/ui/ConfigChooser.java asks for EGL_OPENGL_ES3_BIT in every EGL config, and ios/objc/ui/MapView.mm uses kEAGLRenderingAPIOpenGLES3. GLES3/gl3.h is included directly (renderers/utils/GLContext.h).

So an ES3-only format or entry point needs no extension check and no ES2 path — GL_R8, glInvalidateFramebuffer, MRT, sampler2DShadow. What is still version-dependent is the shader language: programs are built at #version 100 unless they ask for ESSL3_FLAG, and a driver that refuses the 3.00 variant falls back to 1.00 (GLTileRenderer::hasShaderVersionFallback), so a shader written for ESSL 3.00 must still have a 1.00 form. Note also that precision mediump float blocks overflow on functions like tan() near π/2 — write the numerically safe form.

Terrain, fog and sky wiring​

Detail: 08-lighting-sky-fog.md. The pointers worth knowing before reading it:

  • One resolution point for both: all/native/components/StyleEnvironment.h — resolveLighting() and resolveFog() merge the app's LightOptions/TerrainOptions with the style's Map-block values, and resolveFog also lights the fog. Every consumer (TileRenderer → vt, BackgroundRenderer, SkyRenderer) must go through them, or the ground and the sky end up with different fog.
  • SkyRenderer draws a full-screen ray-direction quad. Apps can replace the body with SkyOptions.setShaderSource; the wrapper declares u_sunDir/u_sunColor/... plus the fog block of FogOptions.setShaderSource (fogHorizonBlend(dir), ...) — redeclaring any of them is a compile error and the renderer silently falls back to the built-in sky.
  • BackgroundRenderer draws the flat z=0 plane past the terrain. It uses Options.getBackgroundBitmap() — not the CartoCSS Map { background-color }.
  • TerrainOptions.ViewDistanceFactor ends the ground (tangram's rule). Pair a short one with fog or the ground ends on a hard edge.
  • Fog is FogOptions, independent of the terrain. --es fog <color> or --es fog false is the master switch; fogRangeStart fogRangeEnd are in multiples of the camera-to-focus distance. fogBlend scales the GROUND as well as the sky — one angular term for both is what makes the two meet at the skyline with no seam.
  • The sky is a physical atmosphere by default. --es skyType gradient gets the two-colour ramp back; the raymarch runs per fragment of VISIBLE SKY, so a low tilt is what pays for it.

Comparing against older SDK code​

A/B-ing a regression takes two steps, not one:

git checkout <sha> -- all/ libs-massif/ # 1. old sources (libs-massif is in-tree since 2026-08-23)
cd scripts && python3 swigpp-java.py --profile "standard+objectapi+valhalla+geocoding+routing+packagemanager" \
--swig /Volumes/dev/carto/mobile-swig/swig # 2. regenerate wrappers, else the build fails

generated/ is gitignored, not tracked: there is no git checkout that brings it back, and a swigpp-java.py run overwrites the tree's wrappers with whatever --profile you passed. Restore the same way (git checkout HEAD -- all/ libs-massif/, regenerate).

A temporary WIP commit is the safe way to set work aside for a comparison — the git stash stack is shared with the main checkout and every other worktree.