- 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)
243 lines
14 KiB
Markdown
243 lines
14 KiB
Markdown
# 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). `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 **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** 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 (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: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: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`:
|
||
|
||
1. **`LineJoin::Round` is not implemented.** `calculate_joins` flags Round the
|
||
same as Bevel (`tessellate.rs:333–338`) 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:704–754`), 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:875–955`,
|
||
`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:124–139`); 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 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).
|
||
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.
|