Chapter 8: MVT Vector Tiles

Raster + Vector Tile Layer Stack

Introduction

MVT (Mapbox Vector Tiles) is the de facto standard in GIS, and many of you may have experience using it with MapLibre or QGIS. In 3D, you need to implement the conversion from tile-local coordinates (integers from 0 to 4096) to spherical geometry yourself.

A layer stack corresponds to the "stacking order" in a GIS layer panel. It uses a structure where vector data is overlaid on top of raster tiles, and you will also learn how to handle 3D-specific issues such as altitude offsets (to prevent Z-fighting), which are unnecessary in 2D maps.

What You Will Learn in This Chapter

  • MVT (Mapbox Vector Tile) format structure
  • Decoding with Protocol Buffers (PBF)
  • Converting MVT tile coordinates to spherical coordinates
  • Drawing three types of geometry: lines, polygons, and points
  • Polygon triangulation with earcut
  • VectorTileLayer architecture and throttling
  • Avoiding Z-fighting with altitude offsets

What Are Vector Tiles?

Raster tiles (Chapter 7) are image files. The map appearance is fixed and cannot be restyled on the client side. Vector tiles store the geometry and properties of geographic features (roads, buildings, rivers, etc.) in binary format. They can be decoded on the client side and rendered with any style.

Raster
Vector
Data Format
PNG/JPEG images
Protocol Buffers
Restyling
Not possible
Freely on the client side
Rotation/Zoom
Pixels become visible
Always smooth
Data Size
Large (images)
Small (coordinate data)
Decoding Cost
None
Yes (CPU processing)

MVT Format Structure

MVT (Mapbox Vector Tile) is a vector tile format designed by Mapbox, encoded using Protocol Buffers (protobuf).

MVT File Structure
.mvt file
+-- VectorTile
    +-- layers[]           <- Layers (streets, buildings, water, etc.)
        +-- features[]     <- Features
            +-- type        <- 1:Point, 2:LineString, 3:Polygon
            +-- geometry    <- Coordinate data (in extent space)
            +-- properties  <- Attribute data

Each layer has an extent parameter (typically 4096). Feature coordinates are expressed as integer values from (0, 0) to (extent, extent). The top-left of the tile is (0, 0) and the bottom-right is (extent, extent).

Decoding MVT

To decode MVT, we use the @mapbox/vector-tile library and the pbf library.

mvt-loader.ts - Decoding
import { VectorTile } from '@mapbox/vector-tile';
import Pbf from 'pbf';

fetch(item.url)
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.arrayBuffer();
  })
  .then((buf) => {
    const tile = new VectorTile(new Pbf(buf));
    // Access layers via tile.layers
  });

We use MVT tiles in the Shortbread schema distributed by OSMF (OpenStreetMap Foundation). They include layers such as ocean, water_polygons, streets, buildings, boundaries, and land.

Converting MVT Coordinates to Spherical Coordinates

MVT coordinates are in an integer space from (0, 0) to (extent, extent). The mvtToScene function converts these to 3D coordinates on the sphere.

mvt-mesh.ts - mvtToScene
const ALTITUDE_OFFSET = 50; // 50m: prevents z-fighting

function mvtToScene(
  px: number, py: number,
  extent: number,
  bounds: { west, east, north, south },
  mercYNorth: number, mercYSouth: number
): THREE.Vector3 {
  const u = px / extent;
  const v = py / extent;
  const lon = bounds.west + (bounds.east - bounds.west) * u;
  const mercY = mercYNorth + (mercYSouth - mercYNorth) * v;
  const lat = mercatorYToLat(mercY);

  const ecef = geodeticToEcef(lat, lon, ALTITUDE_OFFSET);
  const s = ecefToScenePosition(ecef.x, ecef.y, ecef.z);
  return new THREE.Vector3(s.x, s.y, s.z);
}

The conversion chain is the same as for tile mesh generation:

Coordinate Conversion Chain
MVT coords (px, py) --> (u, v) --> (lon, mercY) --> lat --> ECEF --> Scene coords

The two key points are:

  • Interpolation in Mercator Y space: Since MVT coordinates are in Mercator projection space, Y-axis interpolation is performed in Mercator Y space
  • Altitude offset (50m): Vector data is rendered directly above the raster tiles (at a position 50m above the ellipsoid surface) to prevent Z-fighting

Layer Stack

Simply adding layers to the GlobeViewer's layers array automatically manages the rendering order. The first element in the array is drawn at the bottom (back), and the last element is drawn on top (front).

ch08/+page.svelte
const viewer = new GlobeViewer({
  canvas: canvasEl,
  layers: [
    new RasterTileLayer(),
    new VectorTileLayer()
  ]
});

Building Geometry: Lines

MVT features have three geometry types. Lines (type = 2: LineString) store consecutive vertex pairs as LineSegments (a sequence of line segments).

mvt-mesh.ts - buildLineGeometry
function buildLineGeometry(
  feature: VectorTileFeature,
  extent, bounds, mercYNorth, mercYSouth
): Float32Array | null {
  const geom = feature.loadGeometry();
  const positions: number[] = [];

  for (const ring of geom) {
    for (let i = 0; i < ring.length - 1; i++) {
      const a = mvtToScene(ring[i].x, ring[i].y, ...);
      const b = mvtToScene(ring[i+1].x, ring[i+1].y, ...);
      positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
    }
  }

  if (positions.length === 0) return null;
  return new Float32Array(positions);
}

Building Geometry: Polygons

Polygons (type = 3) are rendered with both outlines and fills. By sharing a vertex conversion cache, the expensive conversion from MVT coordinates to scene coordinates (which involves trigonometric functions) is performed only once.

Vertex Conversion Cache Sharing
convertFeatureVertices(feature, ...)
    |
    +--> buildPolygonOutlineFromCache(rings)  --> Outlines
    |
    +--> buildPolygonFillFromCache(rings)     --> Fills

Polygon Fill: earcut Triangulation

Polygon fills are implemented using the earcut library (developed by Mapbox; worst-case complexity is O(n^2) but runs in nearly linear time in practice for fast triangulation).

mvt-mesh.ts - buildPolygonFillFromCache
import earcut from 'earcut';

function buildPolygonFillFromCache(
  rings: ScenePoint[][]
): { positions: Float32Array; indices: Uint32Array } | null {
  const coords: number[] = [];
  const holeIndices: number[] = [];

  for (let r = 0; r < rings.length; r++) {
    if (r > 0) holeIndices.push(coords.length / 3);
    for (const pt of rings[r]) {
      coords.push(pt.x, pt.y, pt.z);
    }
  }

  const indices = earcut(coords, holeIndices, 3);
  return {
    positions: new Float32Array(coords),
    indices: new Uint32Array(indices)
  };
}

Key features of the earcut algorithm:

  • Supports polygons with holes (holeIndices specifies the starting positions of holes)
  • Supports 3D coordinates (the third argument dim=3 allows passing 3D coordinates directly)
  • Fast and robust implementation

The fill uses MeshBasicMaterial (no lighting), with DoubleSide to make it visible from either side of the sphere. The opacity is set to semi-transparent so the underlying raster tiles show through.

Column: Limitations of earcut on a Sphere

earcut is originally a 2D triangulation algorithm, but it can also be applied to polygons on a sphere by passing 3D coordinates directly. However, the quality of triangulation may degrade due to Mercator distortion at high latitudes. Additionally, there is a risk of self-intersection for very large polygons that span across tiles. Since our implementation targets localized polygons within individual tiles, this is unlikely to be an issue in practice.

Line Rendering: LineSegments2 and LineMaterial Pool

Standard Three.js LineSegments cannot change line width (a WebGL limitation). To draw thick lines, we use LineSegments2 from the Three.js examples.

Materials with the same style (color, line width, opacity) can be reused across multiple tiles, so they are managed using a pool pattern.

mvt-mesh.ts - LineMaterial Pool
const lineMaterialPool = new Map<string, LineMaterial>();

function getPooledLineMaterial(
  color: number, linewidth: number,
  opacity: number, resolution: THREE.Vector2
): LineMaterial {
  const key = `${color}-${linewidth}-${opacity}`;
  let mat = lineMaterialPool.get(key);
  if (!mat) {
    mat = new LineMaterial({
      color, linewidth,
      transparent: opacity < 1,
      opacity, depthTest: true,
      resolution
    });
    lineMaterialPool.set(key, mat);
  }
  return mat;
}

The resolution is passed by reference as a shared THREE.Vector2 across all materials, so viewport size changes are automatically reflected in all materials.

Style Definitions

Rendering styles for each layer are defined as objects. Layers not present in the style are skipped with continue, so this also functions as a filter to suppress rendering of unwanted layers.

VectorTileLayer - Default Style
// By default, only the streets layer is rendered
style: {
  streets: { color: 0xdddddd, linewidth: 2 }
}

// Additional layers can be added as needed:
style: {
  ocean:          { color: 0x4488cc, opacity: 0.6 },
  water_polygons: { color: 0x4488cc, opacity: 0.6 },
  streets:        { color: 0xdddddd, linewidth: 2 },
  buildings:      { color: 0xaaaaaa, opacity: 0.5 },
  boundaries:     { color: 0xccaa44 },
  land:           { color: 0x44aa44, opacity: 0.3 }
}

By default, only the road network (streets) is rendered as lines. Since rendering cost increases with more layers, the design allows for incremental addition as needed.

For polygon fills, the opacity is calculated as fillOpacity = (styleEntry.opacity ?? 1) * 0.5. By setting the fill opacity to half of the style-specified opacity, the rendering becomes semi-transparent enough for the underlying raster tiles to show through.

The reason THREE.DoubleSide is used for polygon rendering is that polygons on a sphere may be visible from the back face depending on camera position. While raster tiles use FrontSide (with back-face culling enabled), vector data has inconsistent normal directions, making double-sided rendering the safe choice.

VectorTileLayer Throttling

Since MVT decoding and coordinate conversion are more CPU-intensive than raster tiles (which only load textures), VectorTileLayer implements throttling to limit the update frequency.

VectorTileLayer.ts - Throttling
const UPDATE_THROTTLE_MS = 200;

update(ctx: LayerContext): void {
  if (!cameraMatrixChanged(ctx.camera, this.lastCameraMatrix))
    return;

  const now = performance.now();
  if (now - this.lastUpdateTime < UPDATE_THROTTLE_MS) {
    // Throttled --> schedule a deferred update
    if (!this.pendingUpdate) {
      this.pendingUpdate = true;
      setTimeout(() => {
        this.pendingUpdate = false;
        this.doUpdate(ctx);
      }, UPDATE_THROTTLE_MS);
    }
    return;
  }
  this.doUpdate(ctx);
}

The separation between update() and doUpdate():

  • update(): Only performs camera change detection + throttling checks
  • doUpdate(): Handles the actual SSE selection and differential updates

The pendingUpdate flag prevents duplicate scheduling of deferred updates.

Resource Disposal: disposeMvtGroup

Resource disposal of MVT groups requires properly disposing three types of objects.

mvt-mesh.ts - disposeMvtGroup
export function disposeMvtGroup(group: THREE.Group): void {
  for (const child of group.children) {
    if (child instanceof LineSegments2) {
      child.geometry.dispose();
      // LineMaterial is managed by the pool, so don't dispose it
    } else if (child instanceof THREE.Points
               || child instanceof THREE.Mesh) {
      child.geometry.dispose();
      (child.material as THREE.Material).dispose();
    }
  }
  group.clear();
}
  • LineSegments2: Only dispose Geometry (LineMaterial is excluded because it is pool-managed)
  • THREE.Points: Dispose both Geometry and Material
  • THREE.Mesh: Dispose both Geometry and Material (used for polygon fills)

Altitude Offset

If vector tile geometry is placed at exactly the same height as the raster tile's spherical mesh, the GPU cannot correctly determine the rendering order, causing Z-fighting (flickering artifacts).

To avoid this, vector tiles are offset 50m above the ground surface. 50m was chosen as a distance that does not appear visually unnatural even when close to the surface, while reliably preventing Z-fighting.

Summary

In this chapter, we implemented MVT vector tile rendering.

  • MVT Format: Vector data encoded with Protocol Buffers
  • Decoding: @mapbox/vector-tile + pbf libraries
  • Coordinate Conversion: MVT extent space -> Mercator Y interpolation -> ECEF -> Scene coordinates
  • Geometry: Lines (LineSegments2), polygon outlines + fills (earcut), points (Points)
  • Z-fighting Prevention: 50m altitude offset
  • Styling: Per-layer color, line width, and opacity via VectorStyle, with filter functionality
  • Throttling: 200ms update interval limit to reduce CPU load
  • Resource Management: Proper disposal of three types of objects via disposeMvtGroup