Mapping OpenStreetMap tiles onto a sphere
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.
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
|
+-- 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)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.
const viewer = new GlobeViewer({
canvas: canvasEl,
layers: [
new RasterTileLayer()
]
});
viewer.start();
// On cleanup
viewer.dispose();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:
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
) {}
}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.
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.
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.
Immediately disposing evicted textures is dangerous. Consider the following scenario:
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 blackThe 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.
activeRequests limits the number of concurrent requests to maxConcurrent (default 6). By calling processQueue() recursively upon load completion, the queue is processed sequentially.
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();
}
);
}
}When tiles become unnecessary due to camera movement, their loading is immediately cancelled to save bandwidth.
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.
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.
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).
The differential update logic is shared in tile-layer-utils.ts, used by both RasterTileLayer and VectorTileLayer.
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 Field | Description |
|---|---|
| changed | Whether a diff was found (if false, others are empty) |
| neededKeys | Set of required tile keys for the current frame |
| toAdd | Tiles to add (sorted by lower zoom level first) |
| toRemove | Tiles to remove (accounting for pending child tiles) |
| toCancel | Pending tiles to cancel |
cameraMatrixChanged() compares 16 elements. If unchanged, skip everythingbuildTileSyncPlan uses setsEqual() to skip diff computation if identical to the previous frametoAdd.sort((a, b) => a.z - b.z) loads lower zoom (visually larger) tiles firsttoCancel immediately cancels pending requests that are no longer needed, saving bandwidthWhen 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.
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.
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 parentOSM tiles are served as 256x256px PNG images using the following URL format:
https://tile.openstreetmap.org/{z}/{x}/{y}.pngFor example, the tile at z=2, x=3, y=1 is requested at /2/3/1.png.
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.
All layers inherit from a common Layer abstract class and implement three methods:
initialize(ctx) — Acquire initial resourcesupdate(ctx) — Called every frame. Process within the 12ms frame budgetdispose() — Release all resourcesLayerContext 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.
Differential updates require a key string to uniquely identify each tile. tile-layer-utils.ts provides three key utility functions.
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)In this chapter, we implemented the complete raster tile layer.