Skip to content

Choose the antimeridian world copy per tile, and clip a tile to itself - #7

Merged
beetlebugorg merged 2 commits into
mainfrom
fix/antimeridian-wrap-tears-primitives
Sep 10, 2026
Merged

Choose the antimeridian world copy per tile, and clip a tile to itself#7
beetlebugorg merged 2 commits into
mainfrom
fix/antimeridian-wrap-tears-primitives

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

Wide views of a style rendered as horizontal bands stretched across the map, in area fills and lines. Each vertex stage placed a vertex at the world copy nearest the camera with world.x = p.x + rint(wrap_x - p.x). That rule dates from tile-local geometry, where p.x spans one tile and every vertex of a tile gets the same result. The renderer builds one scene against one origin, so p.x spans the whole view and the result changes halfway across it. At z2 centred on lon -95, a probe over the concatenated scene measured vertex x from -0.4910 to 0.5188 and 86 triangles whose corners fall on different copies. Six corners of a quad share one anchor, so label quads were unaffected.

Camera.placeTileX picks the copy by the tile's centre. Camera.wrappedCopy names the second copy when the view is wide enough to see both, from around z1 down, and the tile is then listed and placed twice. Camera.mvpOrigin wraps the origin-to-camera delta in x, so a camera that crossed the antimeridian since the scene was built moves the whole scene at once. The vertex stages only project.

The second commit is a separate defect at the same seam between what DESIGN.md describes and what the renderer does. A translucent fill drew the tile grid into the map as a lattice of darker strips, about 14 px wide at z6, with a darker square where two crossed, and grey hairlines either side of every boundary. An MVT tile holds its features clipped to its own bounds plus an overhang, so two neighbours both hold the strip along the edge they share. Rendering land-forest alone (fill-opacity: 0.1) at z5.8 gave four colours: the background, the fill once, the fill twice, and the fill three times where two strips cross. The outline the clipper leaves along the buffer's own edge is a linestring, and a line layer strokes it, which drew the hairlines.

The overhang stays in the geometry, because a line's joins and caps are built from it, and the draw trims it instead. Each triangle range holds the bounds of its tile in the scene frame, and the fragment stage drops fragments outside them. The test is in world space because a scissor rect cannot express a tile under a rotated view. The vertex stage passes the fragment stage the position its fragments cover, the vertex position plus its screen-space offset converted to world units, so a wide line is clipped along its stroke. Uniforms grows to 144 bytes for clip_rect, with world_per_px in the slot the retired per-vertex wrap left.

The clip joins the batcher's merge key. The draw count for the reference view is 180 with and without it, because parts concatenate tile-major and one layer's ranges from two tiles were already separated by the contiguity test. Frame times over three runs of the zoom benchmark are p50 0.07 ms and p95 around 1.0 ms either way.

Rendering land-forest alone after the change gives two colours. A pixel diff of a line-only render at the same view removes 1550 pixels and adds none, 1504 of them within 12 px of a tile edge and concentrated in two columns of 640 and 625 pixels at the buffer boundaries, which are the hairlines. Line work stays continuous across every boundary, and a 35 degree rotated view of the same place shows no lattice.

zig build and zig build test pass, 233 tests with 18 skipped. Spec conformance is 575/577, the same two output failures as the base. The Vulkan .spv artifacts are rebuilt with glslangValidator and checked with spirv-val. The Vulkan and D3D12 changes are compiled but not run: this machine renders through Metal.

Wide views rendered as horizontal bands stretched across the map, in
area fills and lines.

Each vertex stage computed world.x = p.x + rint(wrap_x - p.x), which
places a vertex at the world copy nearest the camera. That rule dates
from tile-local geometry, where p.x spans one tile and every vertex of a
tile gets the same result. The renderer builds one scene against one
origin, so p.x spans the whole view and the result changes halfway
across. A triangle lying over the half-world seam ended up with corners
a whole world apart. Six corners of a quad share one anchor, so label
quads were unaffected.

Camera.placeTileX picks the copy by the tile's centre.
Camera.wrappedCopy names the second copy when the view is wide enough to
see both, from around z1 down, and the tile is then listed and placed
twice. Camera.mvpOrigin wraps the origin-to-camera delta in x, so a
camera that crossed the antimeridian since the scene was built moves the
whole scene at once. The vertex stages now only project. Uniforms keeps
wrap_x as a reserved field to hold the block at 128 bytes.
charttable's vertex stages placed each vertex at the world copy nearest
the camera, and wanted that offset stated beside the MVP. Applied per
vertex it split any primitive lying across the half-world seam, so the
choice moved to the host, per tile and per scene. Camera.mvpOrigin,
which the overlay already builds its MVP with, wraps the x delta itself.
The wrap_x field is gone from Uniforms.
@beetlebugorg
beetlebugorg merged commit 2a4b6d3 into main Sep 10, 2026
6 checks passed
@beetlebugorg
beetlebugorg deleted the fix/antimeridian-wrap-tears-primitives branch September 10, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant