makepad/map2.md
andodeki d6d1f99ca9 Update fork to upstream dev 5d4483f
- Sync with latest upstream dev branch
- Include all map improvements: 2D/3D toggle, shadows, labels, overlays
- Include platform updates: location API, audio echo cancellation
- Preserve fork-specific re-exports (gltf, csg, test)
2026-07-31 18:47:03 +00:00

14 KiB
Raw Permalink Blame History

map2 — why the map falls apart when you zoom in, and what's missing to fix it

Analysis of makepad-example-map render quality at high zoom (Amsterdam, z1617), compared against the osm.org (openstreetmap-carto / Mapnik) reference render of the same area. Screenshot symptoms: roads that change width mid-street, butt-capped stubs ending in the middle of the screen, a canal that breaks and shifts sideways, wobbly/lumpy road edges, dark casing bars crossing junctions and tile seams.

TL;DR: the dataset is not the main problem. The renderer magnifies z14 geometry that was tessellated once, in absolute-Mercator f32 coordinates, with widths, AA, tolerance and layer-ordering all baked in at tile-build time. Almost every visible artifact traces back to one of five root causes below. Two of them are genuinely missing bits in the vector API (round joins, GPU-side stroke width); the other three are map-widget architecture and need no API change at all.


1. What the pipeline does today

  • Data source: noord-holland-shortbread-1.0.mbtiles — Geofabrik Shortbread 1.0 vector tiles, maxzoom 14 (verified from the metadata table). streets and buildings layers only exist at z14. View zoom goes to 17 (view.rs max_zoom: 17.0), but request_zoom_level() clamps tile requests to z14 (view.rs:1648). So everything past z14 is a 48× magnification of z14 tiles.
  • Tile build (tile.rs build_tile_buffers_from_body): MVT → fake Overpass JSON → re-projected to absolute web-Mercator world pixels at the tile zoom as f32 (lon_lat_to_world, geometry.rs:817). Strokes are merged per style (merge_stroke_polylines), clipped to tile+8px (ROAD_CLIP_PADDING, tile.rs:27), then tessellated on the CPU (libs/svg/src/tessellate.rs) into two cached GPU buffers per tile: one fill geometry, one stroke geometry. Stroke widths, the 1px AA fringe, the 0.25px flatten tolerance and the dash period are all in tile-zoom world units, fixed forever.
  • Draw (view.rs draw_walk): two loops — all tiles' fills, then all tiles' strokes — each tile as one draw call with map_scale = 2^(view_zoom tile_z) and a shared map_offset, transformed in the vertex shader (DrawMapVector, view.rs:21).

2. Why the osm.org reference looks so much better with "the same" data

It's the same OSM source, but not the same render-time inputs:

  1. osm-carto re-renders every zoom level from the full database. At z16 it has z16-selected geometry, z16-tuned widths, z16 label sizes. Our z16 is a scaled photograph of a z14 rendering. This is the single biggest difference.
  2. Global layer compositing. Mapnik draws all road casings in the map, then all road fills, per style layer, across the whole image. We interleave casing/center per tile (see §3.3), so tiles stamp over each other at seams.
  3. Screen-space-constant styling. Carto widths/dashes/halos are defined in output pixels per zoom. Ours are frozen world units that scale with magnification.
  4. Full-detail data past z14. Shortbread z14 is already simplified and quantized to a 4096 extent. However: 4096 quanta over a 256-world-px tile is 1/16 world px = 0.5 screen px at z17. The data would support a crisp z17 render — the artifacts we see (28 px scale) are renderer-made, not data-made.

So "the dataset is supposedly the same" is true enough that it's not the excuse: the ceiling imposed by shortbread-z14 at z17 is ~0.5px; we're leaving ~10× that on the floor.


3. Root causes, ranked by visual damage

3.1 f32 absolute-Mercator coordinates (precision) — the lumpy edges & offset seams

Geometry is stored as world pixels at tile zoom. Amsterdam at z14: x_world ≈ 0.5136 × 4,194,304 ≈ 2,154,000, which sits in [2^21, 2^22)f32 ULP = 0.25 world px. Every projected vertex is snapped to a 0.25px grid before tessellation; at z17 (×8) that's a 2 screen px quantization on every vertex. All the join/normal math in the tessellator then runs on these coarse inputs (short segments get visibly wrong normals — extra wobble in extrusions).

It gets worse in the shader (view.rs:28): pos * map_scale + map_offset in f32. At z17, pos * scale ≈ 1.7e7 ∈ [2^24, 2^25)ULP = 2 screen px on both terms of a catastrophic cancellation. Net: final vertex positions carry up to ~24 px of position-dependent error, and map_offset (same magnitude) rounds while panning → pan jitter and per-vertex "shimmer" of edges.

This alone explains the wobbly Nassaukade edges and most of the "doesn't fit together" impression. It is the classic web-mercator-in-float problem; every GPU map renderer (MapLibre etc.) solves it with tile-local coordinates (0..256 — exact in f32) plus a per-tile translation computed in f64 on the CPU. Our shader already takes per-draw-call map_scale/map_offset uniforms — the fix is purely to make geometry tile-local and compute offset + tile_origin × scale in f64 per tile. No vector-API change needed.

3.2 Overzoom: everything styled in baked tile-zoom units

Because tiles stop at z14 and buffers are cached per tile only (never per view zoom), at z17 all of these are magnified ×8:

  • Stroke widths (view.rs:139 style table, applied at tile.rs build time): residential casing 2.0 world px → 16 screen px. Roads look bloated and toy-like.
  • AA fringes: strokes are expanded by aa/2 = 0.5 world px per side (tessellate.rs stroke, hw = w/2 + aa/2) → roads are drawn 8 screen px wider than styled at z17 (the fragment AA itself stays crisp thanks to fwidth-based coverage in draw_vector.rs:251 — the edge position is what's wrong, not the sharpness).
  • Fill fringe bloat: fills push their AA fringe outward by woff = 0.5 world px (tessellate.rs fill, non-GPU-expand path) → every polygon (water, buildings, landuse) grows ~4 screen px at z17. Small courtyards close up; unrelated fills touch.
  • Flatten tolerance DEFAULT_FLATTEN_TOLERANCE = 0.25 world px (geometry.rs:12) → 2px chords on curves.
  • Vertex thinning: project_way_points_with_nodes drops any point closer than 0.5 world px to the previous one (tile.rs:580, < 0.25 squared) → up to 4 screen px of curve detail discarded at z17.
  • Dash periods (rail shape_id 10, tunnels 11): v_stroke_dist is baked in world units, so dashes stretch ×8 (view.rs get_stroke_mask / draw_vector.rs dash() — the shader never sees map_scale for distances).
  • Clip padding: ROAD_CLIP_PADDING = 8 world px becomes a 64-screen-px overlap strip along every tile edge at z17, which supersizes the seam problem below.

3.3 Per-tile painter's order breaks casing/center layering at seams

Within a tile, tile.rs draws correctly: pass 1 all casings, pass 2 all centers (tile.rs:474548). But both passes live in one geometry buffer per tile, and tiles are drawn one draw call after another (view.rs:517). In the ±8 world px overlap strip that both neighbors tessellate, the later tile's casing paints on top of the earlier tile's road center — a casing-colored plug across every road at every tile boundary, 64 screen px long at z17. The same mechanism produces the dark bars across the canal (waterway casing) in the screenshot. The ROAD_CENTER_OVERLAY_* constants (tile.rs:2932) are an existing partial hack around the intra-tile version of this; they can't help across tiles.

Related: merged stroke chains end with butt caps at junctions where degree ≠ 2 or the style changes (merge_stroke_polylines only chains degree-2 endpoints), so T-junctions get notches and overlapping wedges, magnified ×8.

3.4 Mixed-zoom fallback tiles drawn side by side

fill_draw_tile_keys (view.rs:1100) substitutes any ready ancestor/descendant for a missing tile and draws it in the same frame with a different map_scale. A z13 parent next to a z14 tile has different simplification, ×2 widths, and different f32 rounding → the visibly offset canal segment (Bloemgracht) and the Rozengracht width jump, with duplicate labels at two sizes. There is no fade, no "children fully cover parent → skip parent" logic, and stale parents can persist because eviction is soft (tiles.len() > 640).

3.5 Water drawn twice, cased

Shortbread ships Amsterdam canals as water_polygons and water_lines. We fill the polygon, then stroke the centerline on top with a dark casing (MapWaterwayRule canal: casing #4a8fc3 1.5 → 12 screen px at z17). Carto keeps waterway centerlines invisible where a water polygon exists (or same-color, no casing). The tube-on-polygon look plus its butt caps at tile seams is a large part of the "canals look broken" impression.


4. Actual missing bits in the vector API

The user hypothesis ("missing bits in the vector API") is partially right. In libs/svg/src/tessellate.rs / draw/src/vector / DrawVector:

  1. LineJoin::Round is not implemented. calculate_joins flags Round the same as Bevel (tessellate.rs:333338) and stroke() only ever emits emit_bevel_join (tessellate.rs:419). The NanoVG roundJoin arc emission was never ported. Every bend on a road/canal is a bevel; magnified, that reads as faceted/lumpy.
  2. No inner-bevel correction (NanoVG chooseBevel for PT_INNERBEVEL). emit_bevel_join extrudes the inner side with raw segment normals (tessellate.rs:704754), so sharp angles fold geometry over itself. Invisible with opaque strokes at 1×, visible as edge lumps under magnification.
  3. No GPU-side stroke width ("gpu_expand_stroke"). Fills have a gpu_expand_fill mode that anchors vertices on the edge and encodes the outward normal for shader-side expansion (tessellate.rs:875955, fill_gpu() in draw_vector.rs). Strokes have no equivalent: width and AA fringe are baked into vertex positions. This is the API bit that would make zoom-independent road widths possible without re-tessellating — emit centerline-anchored vertices + normal + side, resolve width_px and fringe in the vertex shader from a uniform. (v_stroke_dist/u-side encoding already exists, so the vertex layout barely changes.)
  4. Single cap style per path. Map rendering needs butt at tile-clip cuts and round at true dead ends, ideally per polyline end. The API takes one LineCap for everything.
  5. No draw-layer separation in accumulated geometry. append_tessellated_geometry flattens everything into one interleaved buffer; there's no way to draw "index range of pass 1 across N geometries, then pass 2". (Workaround without API change: build separate casing/center Geometry objects per tile — doubles buffer count but works today. Nicer API: per-layer index ranges within one buffer, drawable as sub-range draw calls.)
  6. Dash/dot distances are geometry-space. dash()/dots() use v_stroke_dist raw (draw_vector.rs:124139); a scaled consumer like DrawMapVector needs v_stroke_dist × map_scale so patterns are stable in screen px. One-line fix in the map shader, but worth a note in the API docs.
  7. Not missing, but unused by the map: cur_tolerance / cur_fill_aa / cur_stroke_aa hooks on DrawVector exist precisely to bake fringes/tolerance for a known display scale — the tile builder ignores them and uses fixed world-unit constants.

Explicitly not API problems: the f32 precision issue (§3.1 — fixable with tile-local coords + existing per-draw uniforms), the per-tile layer ordering (§3.3 — fixable with two buffers), and overzoom styling (§3.2 — fixable with re-tessellation per zoom bucket even without item 3).


  1. Tile-local coordinates. Tessellate everything relative to the tile origin (subtract tile_key.{x,y} × 256 before building paths; also do the projection math in f64 until that subtraction). In draw_walk, compute each tile's map_offset in f64 as global_offset + tile_origin × scale, cast at the end. Fixes §3.1 completely — both CPU-side tessellation precision and shader cancellation — with zero API changes. Biggest single win at high zoom.
  2. Split stroke buffers into casing + center geometries per tile; draw three loops (fills → all casings → all centers). Kills the seam plugs and junction wedges of §3.3, and makes the ROAD_CENTER_OVERLAY_* hack removable.
  3. Re-tessellate per view-zoom bucket (key tile cache by (tile, render_zoom), build in the existing worker pool): widths from a per-zoom ramp (carto-style ~×1.6/zoom), aa/tolerance/thinning thresholds divided by the render scale so they stay ~1 device px. Alternatively (more work, better): implement gpu_expand_stroke (§4.3) and drive width per frame from a uniform — then one tessellation serves all zooms and continuous zoom is perfectly crisp.
  4. Overzoom tile subdivision: synthesize z1517 tiles by clipping the z14 data (the MVT decode already yields per-feature geometry), so clip padding, world-unit constants and per-tile precision keep their intended meaning at every rendered zoom. Combines naturally with (3).
  5. Tessellator quality: port NanoVG round joins + chooseBevel inner-bevel; use Round caps for true dead ends, Butt only at clip cuts (needs §4.4 or a per-part cap flag in append_stroke_pass).
  6. Water: suppress water_lines casing (or the whole stroke) where water_polygons cover the same area at z12+; at minimum draw waterway strokes under polygon fills or in the polygon color.
  7. Mixed-zoom fallback hygiene (§3.4): skip a parent when all four children are ready, evict replaced parents immediately, optional 100ms fade.
  8. Style/label polish toward the reference: per-zoom style curves (min-zoom per road class, width ramps), label font size scaling with zoom + halo (currently fixed font_scale 0.92, no halo — view.rs:1444), building fill contrast + outline, dashed footpath/cycleway styling.

Verification

Use the headless render harness (MAKEPAD=headless render-to-png) with a fixture at a fixed center/zoom over cached tiles: render z14 (native) and z17 (overzoom) crops and eyeball/golden-diff them per step above. Step 1 should visibly remove edge wobble; step 2 removes seam bars; step 3 brings road weights in line with the osm-carto reference.