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

243 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).
---
## 5. Recommended plan (ordered by impact ÷ effort)
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.