- 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)
14 KiB
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, z16–17),
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).streetsandbuildingslayers only exist at z14. View zoom goes to 17 (view.rsmax_zoom: 17.0), butrequest_zoom_level()clamps tile requests to z14 (view.rs:1648). So everything past z14 is a 4–8× 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 asf32(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 withmap_scale = 2^(view_zoom − tile_z)and a sharedmap_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:
- 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.
- 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.
- Screen-space-constant styling. Carto widths/dashes/halos are defined in output pixels per zoom. Ours are frozen world units that scale with magnification.
- 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 (2–8 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 ~2–4 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:139style table, applied attile.rsbuild 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.5world 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 tofwidth-based coverage indraw_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.5world 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.25world px (geometry.rs:12) → 2px chords on curves. - Vertex thinning:
project_way_points_with_nodesdrops any point closer than 0.5 world px to the previous one (tile.rs:580,< 0.25squared) → up to 4 screen px of curve detail discarded at z17. - Dash periods (rail
shape_id 10, tunnels11):v_stroke_distis baked in world units, so dashes stretch ×8 (view.rs get_stroke_mask/draw_vector.rs dash()— the shader never seesmap_scalefor distances). - Clip padding:
ROAD_CLIP_PADDING = 8world 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:474–548). 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:29–32) 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:
LineJoin::Roundis not implemented.calculate_joinsflags Round the same as Bevel (tessellate.rs:333–338) andstroke()only ever emitsemit_bevel_join(tessellate.rs:419). The NanoVGroundJoinarc emission was never ported. Every bend on a road/canal is a bevel; magnified, that reads as faceted/lumpy.- No inner-bevel correction (NanoVG
chooseBevelforPT_INNERBEVEL).emit_bevel_joinextrudes the inner side with raw segment normals (tessellate.rs:704–754), so sharp angles fold geometry over itself. Invisible with opaque strokes at 1×, visible as edge lumps under magnification. - No GPU-side stroke width ("gpu_expand_stroke"). Fills have a
gpu_expand_fillmode that anchors vertices on the edge and encodes the outward normal for shader-side expansion (tessellate.rs:875–955,fill_gpu()indraw_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, resolvewidth_pxand fringe in the vertex shader from a uniform. (v_stroke_dist/u-side encoding already exists, so the vertex layout barely changes.) - 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
LineCapfor everything. - No draw-layer separation in accumulated geometry.
append_tessellated_geometryflattens 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/centerGeometryobjects per tile — doubles buffer count but works today. Nicer API: per-layer index ranges within one buffer, drawable as sub-range draw calls.) - Dash/dot distances are geometry-space.
dash()/dots()usev_stroke_distraw (draw_vector.rs:124–139); a scaled consumer likeDrawMapVectorneedsv_stroke_dist × map_scaleso patterns are stable in screen px. One-line fix in the map shader, but worth a note in the API docs. - Not missing, but unused by the map:
cur_tolerance/cur_fill_aa/cur_stroke_aahooks onDrawVectorexist 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).
5. Recommended plan (ordered by impact ÷ effort)
- Tile-local coordinates. Tessellate everything relative to the tile origin
(subtract
tile_key.{x,y} × 256before building paths; also do the projection math in f64 until that subtraction). Indraw_walk, compute each tile'smap_offsetin f64 asglobal_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. - 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. - 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. - Overzoom tile subdivision: synthesize z15–17 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).
- Tessellator quality: port NanoVG round joins +
chooseBevelinner-bevel; use Round caps for true dead ends, Butt only at clip cuts (needs §4.4 or a per-part cap flag inappend_stroke_pass). - Water: suppress
water_linescasing (or the whole stroke) wherewater_polygonscover the same area at z12+; at minimum draw waterway strokes under polygon fills or in the polygon color. - Mixed-zoom fallback hygiene (§3.4): skip a parent when all four children are ready, evict replaced parents immediately, optional 100ms fade.
- 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.