Skip to content

Coordinate Systems

Nimbus works with four coordinate systems, each suited to a different stage of the pipeline.

The Four Spaces

Space Range Used for
World (WGS84) lon: [-180, 180], lat: [-85.05, 85.05] Camera position, viewport bounds, tile boundary calculations
Tile (slippy map) z/x/y integers Tile fetching, parent/child relationships, over/dezoom fallback
MVT extent [0, 4096] per axis Raw geometry within a single tile (protobuf decoded)
Normalized Mercator [0, 1] per axis GPU vertex data (tile-relative), uniform buffer, projection math

Tile-Relative Mercator Encoding

This is the key insight that makes f32 GPU math work at high zoom levels (z14+).

The Problem

A naive approach stores absolute Mercator coordinates per vertex. At zoom 14, two adjacent vertices might be at 0.50001 and 0.50002 in Mercator space. When the GPU subtracts the camera center (~0.5) from each, both operands are similar magnitude — f32 loses significant bits to cancellation error, causing visible polygon jitter.

The Solution

Vertices are encoded as tile-relative Mercator offsets: (merc_pos - tile_center).

CPU (f64):
  vertex_merc = lon_to_mercator(lon)        // e.g. 0.510833...
  tile_center = tile.to_mercator_center()    // e.g. 0.510742...
  stored_f32  = (vertex_merc - tile_center)  // e.g. 0.000091 — small, safe as f32

GPU (f32):
  tile_offset = tile_merc_center - camera_merc_center  // recomputed per-frame in f64 on CPU
  world_x     = vertex.x + tile_offset.x               // small + small, no cancellation

The CPU performs the Mercator projection in f64, subtracts the tile center in f64, then casts the small residual to f32. The per-frame tile offset is also computed in f64 on the CPU and passed to the GPU via a storage buffer.

Storage Buffer Layout

Each tile slot has 32 bytes in the TileSlotData storage buffer:

struct TileSlotData {
    offset: vec2<f32>,       // tile_merc_center - camera_merc_center
    opacity: f32,            // fade-in animation [0, 1]
    _pad: f32,
    bounds_min: vec2<f32>,   // tile Mercator bounds (for clipping)
    bounds_max: vec2<f32>,
}

Tile bounds are stored per-slot (not per-vertex), and the fragment shader reads them via tile_data[tile_index] for ancestor clipping.

Globe Projection

When globe mode is active (zoom < 5), the shader reconstructs lon/lat from absolute Mercator via:

lat = atan(sinh(PI * (1 - 2 * merc_y)))

Then projects onto a sphere. Pre-computed cos_r, sin_r, cos_clat, sin_clat uniforms eliminate per-vertex trig calls.

Vertex Format

The vertex struct is 28 bytes (down from an earlier 56 bytes):

Field Type Description
world_pos vec2<f32> Tile-relative Mercator position
color u32 RGBA8 packed via pack_color_u32(), unpacked with unpack4x8unorm()
line_normal vec2<f32> Normal direction for line extrusion (Mercator space)
line_width f32 Line width in pixels
tile_index u32 Index into TileSlotData storage buffer (flat-interpolated)