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:
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) |