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
| Activity | What it is |
|---|---|
.MainActivity | the example gallery — one file per example, on the facade API. Not for debugging. See examples.md |
.ExampleActivity | runs one example: --es example <id>, plus --es ui false and --es lon/lat/zoom/tilt/rotation |
.BenchActivity | the 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 anapp-debug.apkand it is stale. Verify withunzip -p <apk> classes*.dex | strings | grep <new symbol>(the demo's Java lands in classes5.dex). pm clear com.massifmaps.MassifDemoresets 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 demo files
File (under app/src/main/java/com/massif-maps/test/) | Role |
|---|---|
demo/DemoConfig.java | every default, one static field per knob + the intent-extra key map |
demo/DemoCfg.java | cfgBool/cfgFloat/cfgInt/cfgStr/cfgColor intent readers (--es key value) |
demo/DemoMap.java | builds/updates the map: layer registry, shared sources, terrain/light/sky, camera |
demo/DemoStyles.java | style decoders (dir / zip / inline CartoCSS / style project) + demo shaders |
demo/DemoSky.java | day-cycle sun/sky + generated sky shader |
demo/DemoPanel.java | on-screen panel — writes DemoConfig, then calls a DemoMap.apply*() |
demo/DemoTests.java | one-shot actions (routing, search, GeoJSON) |
ui/main/SecondFragment.java | Android 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
Layers (base, satellite, hillshade, hypso, contour, contourTiles, routes, elements,
bugs) toggle with --es <name> true|false; 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.
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 picks the configuration (default composite). Every knob in
applyTerrainConfig/applyCameraConfig/applySkyAndLightConfig is an intent extra, so most
experiments need no rebuild:
- camera:
lon lat zoom tilt rotation - terrain:
drape drapeLines drapeResolution meshResolution exaggeration,viewDistance viewDistanceMeters,autoFlatten autoFlattenTilt autoFlattenMs,stitch - layers:
hs sat satZoom contour bld3d - light/shadow:
daycycle sunHour sunAzimuth sunAltitude shadow - labels:
textOcclusion,roadLabelOcclusion(a re-decode) ui false(hide the panel),anim zoom|pan|rotate|zoomseq|approach
--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). Two traps:
ExampleLive.onReceivewrites the properties inline on the main thread, so a broadcast sent while the render thread is busy can ANR the app. Send config early, before the tiles load.- The receiver must be registered
RECEIVER_EXPORTED—adb shell am broadcastruns as the shell uid, and aNOT_EXPORTEDreceiver 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, soDemoLiverebuilds 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
cameraClearanceabove 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 shadowDistancewith 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 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.
- 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) anddevtap diffthe 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, logthisto separate a composite's children),RasterTileLayer::FetchTask::loadTile,ElevationTextureCache::getTexture, andGLTileRenderer::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:% 120with 4 instances always logs the same one. Shader::getUniformLocreturns 0 for a uniform the compiler dropped, and 0 is a valid location — writing to it clobbers whatever lives at 0.SkyRendererusesglGetUniformLocationand>= 0guards; copy that pattern.setDebugWireframe/setDebugSurfacePrefillinTileRenderer.cppare 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()andresolveFog()merge the app'sLightOptions/TerrainOptionswith the style's Map-block values, andresolveFogalso lights the fog. Every consumer (TileRenderer→ vt,BackgroundRenderer,SkyRenderer) must go through them, or the ground and the sky end up with different fog. SkyRendererdraws a full-screen ray-direction quad. Apps can replace the body withSkyOptions.setShaderSource; the wrapper declaresu_sunDir/u_sunColor/...and afogAmount(rayDir)helper — redeclaring any of them is a compile error and the renderer silently falls back to the built-in sky.BackgroundRendererdraws the flat z=0 plane past the terrain. It usesOptions.getBackgroundBitmap()— not the CartoCSSMap { background-color }.TerrainOptions.ViewDistanceFactorends 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 falseis the master switch;fogRangeStart fogRangeEndare in multiples of the camera-to-focus distance.fogBlendscales 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 gradientgets 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 three steps, not one:
git checkout <sha> -- all/ # 1. old sources
(cd libs-massif && git checkout <matching-sha>) # 2. matching submodule commit
cd scripts && python3 swigpp-java.py --profile "standard+valhalla+geocoding+routing+packagemanager" \
--swig /Volumes/dev/carto/mobile-swig/swig # 3. 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/, submodule back to its branch, 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.