Chapter 7: Raster Tile Layer

Mapping OpenStreetMap tiles onto a sphere

Introduction

In Leaflet, displaying tiles is as simple as a single line: L.tileLayer(url).addTo(map). This chapter explores how tile rendering is actually implemented inside a 3D engine. The knowledge accumulated in Chapters 1 through 6 comes together here.

GlobeViewer is a facade equivalent to a GIS Map class, integrating renderer, camera, controls, and layer management into a single interface. You will also learn about optimization techniques that 2D map libraries perform internally, such as asynchronous loading, LRU caching, and parent-child tile transitions.

What You Will Learn

  • Architecture and components of RasterTileLayer
  • Designing a tile texture loader with LRU cache
  • Concurrent request limiting and queuing
  • Differential updates (determining additions and removals by comparing with the previous frame)
  • Parent-child tile transitions (retaining the parent until child loading completes)
  • Layer lifecycle management — the common pattern of initialize / update / dispose

Overall Architecture

RasterTileLayer is a layer that integrates SSE-based tile selection from the previous chapter, tile mesh generation, and the texture loader explained in this chapter.

RasterTileLayer Architecture
RasterTileLayer
  |
  +-- selectVisibleTiles()  <- tile-tree.ts (Chapter 6)
  |     | List of required tiles
  +-- Differential update logic
  |     +-- Tiles to add --> loadTile()
  |     +-- Tiles to remove --> removeTile()
  +-- TileTextureLoader    <- tile-loader.ts
  |     +-- LRU cache
  |     +-- Concurrent request limiting
  |     +-- Cancellation mechanism
  +-- createTileMesh()      <- tile-mesh.ts
        | THREE.Mesh
      group.add(mesh)

GlobeViewer Facade

In previous chapters, we assembled the renderer, camera, controls, and render loop directly in each page. GlobeViewer is a facade pattern implementation that consolidates these into a single class.

The consumer simply passes a canvas and an array of layers and calls start(). Internally, ThreeRenderer (camera, controls, lighting), RenderLoop (per-frame updates), and LayerContext (providing information to layers) automatically coordinate.

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

// On cleanup
viewer.dispose();

TileTextureLoader

Texture loading is handled by the TileTextureLoader class. In WebGIS, a large number of HTTP requests are generated, so simply calling TextureLoader.load() causes problems:

  • Exceeding the browser's concurrent connection limit (even HTTP/2 has limits)
  • Requests for tiles that are no longer needed consume bandwidth
  • Network access occurs every time, resulting in slow performance
tile-loader.ts - TileTextureLoader
export class TileTextureLoader {
  private readonly loader = new THREE.TextureLoader();
  private readonly cache = new Map<string, THREE.Texture>();
  private readonly accessOrder: string[] = [];
  private activeRequests = 0;
  private readonly queue: Array<{ ... }> = [];
  private readonly orphaned = new Map<string, THREE.Texture>();
  private readonly cancelled = new Set<string>();

  constructor(
    private readonly urlGenerator = osmTileUrl,
    private readonly maxConcurrent = 6,
    private readonly maxCacheSize = 256
  ) {}
}

How the LRU Cache Works

The LRU (Least Recently Used) cache keeps recently used tiles in memory and evicts the oldest entries when the capacity (256 tiles) is exceeded. On a cache hit, the Promise resolves immediately, avoiding network access.

tile-loader.ts - load()
load(z: number, x: number, y: number): Promise<THREE.Texture> {
  const key = `${z}/${x}/${y}`;

  // Cache hit
  const cached = this.cache.get(key);
  if (cached) {
    this.touchCache(key);  // Update access order
    return Promise.resolve(cached);
  }

  const url = this.urlGenerator(z, x, y);

  return new Promise((resolve, reject) => {
    this.queue.push({ key, url, resolve, reject });
    this.processQueue();
  });
}

When the camera moves only slightly, most tiles result in cache hits, and only the difference requires new loading. Combined with the browser's HTTP cache as a second layer, subsequent accesses are nearly instantaneous.

Cache Management: touchCache and evict

tile-loader.ts - Cache Management
private touchCache(key: string): void {
  const idx = this.accessOrder.indexOf(key);
  if (idx !== -1) {
    this.accessOrder.splice(idx, 1);
    this.accessOrder.push(key); // Move to end = most recent
  }
}

private evict(): void {
  while (this.cache.size > this.maxCacheSize) {
    const oldKey = this.accessOrder.shift(); // Head = oldest
    if (oldKey) {
      const tex = this.cache.get(oldKey);
      if (tex) {
        this.cache.delete(oldKey);
        this.orphaned.set(oldKey, tex); // Mesh may still be using it
      }
    }
  }
}

The accessOrder array tracks usage order, and when maxCacheSize (256) is exceeded, the oldest entries are evicted.

The orphaned Map: Safe Texture Release

Immediately disposing evicted textures is dangerous. Consider the following scenario:

Texture Release Problem
1. Tile A's texture is in the LRU cache
2. The cache becomes full, and Tile A's texture is evicted
3. However, Tile A's Mesh is still displayed in the scene
4. The Mesh's material references this texture on the GPU
--> Disposing here causes the displayed tile to turn black

The orphaned map solves this problem. When evicted, the texture is moved to the orphaned map, and it is only disposed when the mesh is removed via removeTile() and loader.release() is called.

Concurrent Request Limiting

activeRequests limits the number of concurrent requests to maxConcurrent (default 6). By calling processQueue() recursively upon load completion, the queue is processed sequentially.

tile-loader.ts - processQueue()
private processQueue(): void {
  while (this.activeRequests < this.maxConcurrent
         && this.queue.length > 0) {
    const item = this.queue.shift()!;

    // Cached while waiting in the queue
    const cached = this.cache.get(item.key);
    if (cached) {
      this.touchCache(item.key);
      item.resolve(cached);
      continue;
    }

    this.activeRequests++;
    this.loader.load(
      item.url,
      (texture) => {
        this.activeRequests--;

        // Dispose if cancelled
        if (this.cancelled.has(item.key)) {
          this.cancelled.delete(item.key);
          texture.dispose();
          this.processQueue();
          return;
        }

        texture.colorSpace = THREE.SRGBColorSpace;
        this.addToCache(item.key, texture);
        item.resolve(texture);
        this.processQueue();
      },
      undefined,
      (err) => {
        this.activeRequests--;
        item.reject(err);
        this.processQueue();
      }
    );
  }
}

Cancellation Mechanism

When tiles become unnecessary due to camera movement, their loading is immediately cancelled to save bandwidth.

tile-loader.ts - cancel()
cancel(key: string): void {
  // Remove from queue
  const idx = this.queue.findIndex((item) => item.key === key);
  if (idx !== -1) {
    this.queue.splice(idx, 1);
    return;
  }
  // Set cancellation flag for in-flight requests
  this.cancelled.add(key);
}

Requests still in the queue can be removed directly, while in-flight requests are flagged for cancellation. When loading completes, the flag is detected and the texture is disposed.

Differential Updates: cameraMatrixChanged

RasterTileLayer.update() is called every frame, but tile additions and removals are performed only based on the difference from the previous frame. First, camera changes are detected.

tile-layer-utils.ts - cameraMatrixChanged
export function cameraMatrixChanged(
  camera: THREE.PerspectiveCamera,
  lastCameraMatrix: Float32Array
): boolean {
  const current = camera.matrixWorldInverse.elements;
  for (let i = 0; i < 16; i++) {
    if (lastCameraMatrix[i] !== current[i]) {
      lastCameraMatrix.set(current);
      return true;
    }
  }
  return false;
}

The 16 elements of the camera's matrixWorldInverse (view matrix) are compared one by one with the previous frame. This element-wise comparison with early return is efficient for the many frames where the camera is stationary (returning false at the first element).

buildTileSyncPlan: Shared Differential Computation

The differential update logic is shared in tile-layer-utils.ts, used by both RasterTileLayer and VectorTileLayer.

tile-layer-utils.ts - buildTileSyncPlan
export function buildTileSyncPlan(
  params: BuildTileSyncPlanParams
): TileSyncPlan {
  const neededKeys = new Set(
    params.needed.map((tile) => tileKey(tile.x, tile.y, tile.z))
  );

  // No diff if same as previous frame
  if (setsEqual(neededKeys, params.previousKeys)) {
    return { changed: false, ... };
  }

  // Add: tiles that are needed but not active/pending (lower zoom first)
  const toAdd = params.needed.filter(...)
  toAdd.sort((a, b) => a.z - b.z);

  // Remove: tiles that are active but not needed, with no pending children
  const toRemove: string[] = [];

  // Cancel: tiles that are pending but not needed
  const toCancel: string[] = [];

  return { changed: true, neededKeys, toAdd, toRemove, toCancel };
}
TileSyncPlan FieldDescription
changedWhether a diff was found (if false, others are empty)
neededKeysSet of required tile keys for the current frame
toAddTiles to add (sorted by lower zoom level first)
toRemoveTiles to remove (accounting for pending child tiles)
toCancelPending tiles to cancel

Optimization Points

  1. Camera change check: cameraMatrixChanged() compares 16 elements. If unchanged, skip everything
  2. Set comparison: buildTileSyncPlan uses setsEqual() to skip diff computation if identical to the previous frame
  3. Load priority: toAdd.sort((a, b) => a.z - b.z) loads lower zoom (visually larger) tiles first
  4. Cancelling unnecessary requests: toCancel immediately cancels pending requests that are no longer needed, saving bandwidth

Parent-Child Tile Transitions

When zooming in, a parent tile is replaced by four child tiles. However, since loading child tiles takes time, the parent tile must remain displayed until loading completes.

tile-layer-utils.ts - hasChildrenPending
export function hasChildrenPending(
  parentKeyStr: string,
  neededKeys: Set<string>,
  isTileActive: (key: string) => boolean
): boolean {
  const { x, y, z } = parseTileKey(parentKeyStr);
  const childKeys = [
    tileKey(x*2,   y*2,   z+1),
    tileKey(x*2+1, y*2,   z+1),
    tileKey(x*2,   y*2+1, z+1),
    tileKey(x*2+1, y*2+1, z+1)
  ];

  for (const ck of childKeys) {
    if (neededKeys.has(ck) && !isTileActive(ck)) {
      return true;  // A needed child is not yet active
    }
  }
  return false;
}

By accepting isTileActive as a callback, both RasterTileLayer and VectorTileLayer can use their own activeMeshes map for the determination.

Transition Timeline

Parent-Child Tile Transition Flow
Frame 1: Parent tile (1,0,1) displayed
Frame 2: Zoom in
  --> Child tiles (2,0,2),(3,0,2),(2,1,2),(3,1,2) needed
  --> Start loading children, retain parent
Frame 3: (2,0,2) loading complete
  --> Parent still retained (other children pending)
Frame 4: All children loaded
  --> Remove parent

Tile URL

OSM tiles are served as 256x256px PNG images using the following URL format:

Tile URL
https://tile.openstreetmap.org/{z}/{x}/{y}.png

For example, the tile at z=2, x=3, y=1 is requested at /2/3/1.png.

Resource Release

RasterTileLayer - removeTile
private removeTile(key: string): void {
  const mesh = this.activeMeshes.get(key);
  if (mesh) {
    this.group.remove(mesh);
    mesh.geometry.dispose();       // Release vertex buffers
    (mesh.material as THREE.Material).dispose(); // Release shader
    this.activeMeshes.delete(key);
    this.loader.release(z, x, y);   // Release texture if not in cache
  }
}

When removing a tile, we remove it from the Group, and dispose the Geometry (vertex buffers) and Material (shader program). Since the texture may still be in the LRU cache, release() only disposes it if it is no longer in the cache.

Layer Lifecycle

All layers inherit from a common Layer abstract class and implement three methods:

  • initialize(ctx) — Acquire initial resources
  • update(ctx) — Called every frame. Process within the 12ms frame budget
  • dispose() — Release all resources

LayerContext includes the camera, screen height, WebGL renderer, remaining frame budget time, the selectVisibleTiles helper, and more, giving layers access to all the information they need.

Tile Key Utility Functions

Differential updates require a key string to uniquely identify each tile. tile-layer-utils.ts provides three key utility functions.

tile-layer-utils.ts - Key Utilities
tileKey(10, 20, 5)     // --> "5/10/20"  (z/x/y format)
parseTileKey("5/10/20") // --> { x: 10, y: 20, z: 5 }
parentKey(10, 20, 5)    // --> "4/5/10"  (divide coordinates by 2, decrease z by 1)
parentKey(0, 0, 0)       // --> null  (root has no parent)

Summary

In this chapter, we implemented the complete raster tile layer.

  • TileTextureLoader: LRU cache (256 tiles) + concurrent request limiting (6) + cancellation mechanism
  • Differential updates: Determine additions and removals via set operations against the previous frame
  • Camera change detection: Identify skippable frames through matrix comparison
  • Parent-child transitions: Retain parent tiles until child tile loading completes
  • Resource management: Reliable disposal of Geometry, Material, and Texture