Chapter 9: 3D Tiles Integration

Raster + PLATEAU 3D Building Models

Introduction

OGC 3D Tiles is a streaming specification for large-scale 3D data, originally developed by the Cesium team. It is widely used for 3D visualization in GIS and has been adopted for PLATEAU data distribution.

The SSE-based LOD control from Chapter 6 and the ECEF coordinate transformations from Chapter 3 reappear here, organically connecting the knowledge built so far. In this chapter, we implement everything from tileset.json parsing, SSE-based tile traversal, to B3DM/GLB loading without relying on external libraries.

What You Will Learn

  • Overview and structure of the 3D Tiles format
  • Six-module architecture and design of the custom loader
  • Parsing tileset.json and building the tile tree
  • SSE-based tile traversal algorithm
  • B3DM/GLB content loading and matrix composition
  • LRU cache and concurrent request limiting
  • Draco compression support
  • ECEF to scene coordinate matrix transformation
  • Integration of PLATEAU 3D Tiles data

What Are 3D Tiles?

3D Tiles is a streaming delivery format for 3D geospatial data standardized by the Open Geospatial Consortium (OGC). It was originally designed by the Cesium team and is now an OGC standard.

Key features of 3D Tiles:

  • Hierarchical LOD: The entire tileset is organized as a tree structure with built-in level-of-detail control based on distance
  • Bounding volumes: Each tile has a defined bounding sphere or bounding box
  • Content formats: glTF/GLB (3D models), point clouds, nested 3D Tiles, etc.
  • ECEF coordinate system: Tile positions are specified in the ECEF coordinate system

Data Structure of the OGC 3D Tiles Specification

The data is organized as a tree structure rooted at tileset.json. Each node contains the following information:

  • boundingVolume — The spatial extent occupied by this tile (box, region, sphere)
  • geometricError — The geometric coarseness of this tile (in meters)
  • content.uri — The URL of the actual mesh data (.glb, etc.)
  • children — An array of more detailed child tiles

SSE-based LOD control follows the same principle as raster tiles in Chapter 6. The SSE is calculated from the geometricError and camera distance, and child tiles are loaded when the threshold is exceeded.

Two-Layer Stack

In this chapter, we display two layers simultaneously: raster tiles and 3D Tiles. Each has its own independent LOD control, and GlobeViewer calls update() uniformly every frame.

ch09/+page.svelte
const viewer = new GlobeViewer({
  canvas: canvasEl,
  layers: [
    new RasterTileLayer(),
    new Tiles3DLayer({
      url: '...tileset.json'
    })
  ]
});

Custom Loader Module Architecture

In this book, we implement 3D Tiles loading and LOD control from scratch without external libraries. The six modules in the renderer/tiles3d/ directory each have clearly defined responsibilities.

Module Architecture
renderer/tiles3d/
├── types.ts            // Type definitions (Tiles3DNode, SceneBounds, etc.)
├── tileset-parser.ts   // tileset.json -> tree construction
├── tile-traversal.ts   // SSE-based tile traversal
├── bounding-volume.ts  // BV -> SceneBounds conversion
├── tile-cache.ts       // LRU cache + request management
└── content-loader.ts   // B3DM/GLB parsing + GLTF loading

renderer/
└── tiles3d-adapter.ts  // Control layer integrating the above

The only external dependencies are GLTFLoader and DRACOLoader (Three.js add-ons). Tileset parsing, traversal, and cache management are all implemented from scratch.

ModuleResponsibility
types.tsType definitions for Tiles3DNode, SceneBounds, TraversalResult, etc.
tileset-parser.tsFetching tileset.json and tree construction (transform accumulation, v1.0/v1.1 compatible)
tile-traversal.tsSSE-based recursive traversal (REPLACE/ADD refinement support)
bounding-volume.tsregion/box/sphere to SceneBounds conversion, visibility testing
tile-cache.tsLRU cache, concurrent request limiting, priority queue, deferred disposal
content-loader.tsB3DM header parsing, GLB loading, RTC_CENTER extraction, matrix composition

Parsing tileset.json and Building the Tile Tree

tileset-parser.ts fetches tileset.json and recursively builds the tile tree. Each node's transform is accumulated by multiplying with the parent's transformation matrix and propagated to child nodes.

tileset-parser.ts - buildNode (excerpt)
function buildNode(
  tileJson, parentTransform, parentRefine, baseUrl, depth, parent
): Tiles3DNode {
  // Accumulate transform
  let worldTransform = parentTransform;
  if (tileJson.transform) {
    const local = new Float64Array(tileJson.transform);
    worldTransform = multiplyMatrices(parentTransform, local);
  }

  // Inherit refine (use parent's if not specified)
  const refine = tileJson.refine
    ? tileJson.refine.toUpperCase() : parentRefine;

  // Resolve content URI (v1.0: content.url, v1.1: content.uri)
  const rawUri = tileJson.content?.uri ?? tileJson.content?.url;
  const contentUri = rawUri ? resolveUri(rawUri, baseUrl) : null;

  // Recursively build child nodes
  for (const childJson of tileJson.children ?? []) {
    node.children.push(
      buildNode(childJson, worldTransform, refine, baseUrl, depth + 1, node)
    );
  }
}

Compatibility between v1.0 and v1.1 is handled by the content URI resolution (content.uri ?? content.url) and refine inheritance logic. Matrix arithmetic using Float64Array maintains double precision to minimize errors in the ECEF coordinate system.

Bounding Volume Conversion

bounding-volume.ts converts the three types of bounding volumes defined in the 3D Tiles specification (region, box, sphere) into SceneBounds (center coordinates + radius) usable for traversal and visibility testing.

bounding-volume.ts - region conversion
// region: [west, south, east, north, minH, maxH] (radians)
// 18-point sampling (longitude 3 x latitude 3 x altitude 2) to generate bounding sphere
const lonSamples = [west, (west + east) / 2, east];
const latSamples = [south, (south + north) / 2, north];
const hSamples = [minH, maxH];

for (const lon of lonSamples)
  for (const lat of latSamples)
    for (const h of hSamples)
      points.push(ecefToScenePosition(...geodeticToEcef(lat, lon, h)));

Conversion rules:

  • region: Convert 18 points from ECEF to scene coordinates and compute the circumscribed sphere. Per the specification, transform is not applied
  • box: Convert the center to ECEF using worldTransform and compute the bounding sphere radius from the three half-axis lengths
  • sphere: Convert the center using worldTransform and use the radius as-is

SSE-Based Tile Traversal

tile-traversal.ts recursively traverses the tile tree and determines which tiles to display (desired), which to remove (toRemove), and which requests to cancel (toCancel) based on the camera viewpoint.

tile-traversal.ts - visit algorithm
function visit(node: Tiles3DNode): void {
  // 1. Bounding volume -> SceneBounds
  const bounds = toSceneBounds(node.boundingVolume, node.worldTransform);

  // 2. Horizon + frustum culling
  if (!isVisible(bounds, frustumPlanes, cameraPosition, cameraDistance))
    return;

  // 3. SSE calculation
  const sse = computeSSE(node.geometricError, distance, screenHeight, fovRad);

  // 4. Refinement decision
  const shouldRefine = sse > sseThreshold && hasChildren;

  if (!shouldRefine) {
    // This tile is sufficient -> add to desired
    desired.push(node);
  } else if (node.refine === 'REPLACE') {
    // All visible children ready? -> traverse children
    // Not ready? -> fallback to parent + add children to desired
    ...
  } else {
    // ADD: display both parent and children
    desired.push(node);
    for (const child of node.children) visit(child);
  }
}

With the REPLACE strategy, parent tiles are displayed as a fallback while child tiles are still loading, and the switch to children occurs once all visible children are ready (cached or in scene). This prevents screen flickering. During fallback, only immediate children are added to the desired list without recursing deeper. Once children finish loading, deeper levels are reached through the regular path in the next traversal pass.

The desired list has a maxDesired cap (default 64). Active tiles (already in the scene) are always added, while new tiles are only added within the limit. The final desired list is sorted by ascending camera distance, prioritizing loading of closer tiles first.

visit(node)
│
├── BV -> SceneBounds conversion
├── Horizon culling (exclude behind the horizon)
├── Frustum culling (exclude outside the view frustum)
├── SSE = geometricError x screenHeight / (distance x 2 x tan(fov/2))
│
├── SSE <= threshold -> add to desired (this tile is sufficient)
│
└── SSE > threshold (refinement needed)
    ├── REPLACE: All visible children ready? -> traverse children
    │            Not ready?                  -> parent + children to desired (fallback)
    └── ADD:     parent to desired + recursively traverse children

LRU Cache and Concurrent Request Limiting

tile-cache.ts handles cache management for loaded tiles and controls network requests.

ConstantValueRole
MAX_CACHE_SIZE128Maximum number of cache entries
MAX_CONCURRENT6Maximum concurrent HTTP requests
DISPOSAL_BUDGET_MS2Per-frame disposal time budget (ms)
MAX_DISPOSALS_PER_FRAME3Maximum disposals per frame

Cache behavior:

  • LRU eviction: When the cache reaches MAX_CACHE_SIZE, the least recently accessed entries are evicted. However, protectedKeys (tiles currently displayed in the scene) are exempt from eviction
  • Priority queue: When the concurrent request limit is exceeded, tiles with higher SSE values are loaded first
  • Deferred disposal: Object3D disposal is performed incrementally within a frame budget to prevent frame drops
  • Failure recording: Failed load keys are recorded to prevent infinite retries

B3DM/GLB Content Loading

content-loader.ts loads the actual tile content. The first 4 bytes of the magic number determine whether the content is B3DM (0x6d643362) or GLB (0x46546c67).

content-loader.ts - B3DM header parsing
// B3DM Header (28 bytes):
//   [0-3]   magic "b3dm"
//   [4-7]   version (1)
//   [8-11]  byteLength
//   [12-15] featureTableJSONByteLength
//   [16-19] featureTableBinaryByteLength
//   [20-23] batchTableJSONByteLength
//   [24-27] batchTableBinaryByteLength

// Extract RTC_CENTER from Feature Table JSON
const featureTableJson = JSON.parse(decoder.decode(featureTableJsonBytes));
if (featureTableJson.RTC_CENTER) {
  rtcCenter = featureTableJson.RTC_CENTER;
}

// Also check CESIUM_RTC extension in GLB (takes priority over B3DM RTC_CENTER)
const cesiumRtc = extractCesiumRtcFromGlb(glbBuffer);

RTC_CENTER (Relative-To-Center) is an origin offset in the ECEF coordinate system. It allows vertex coordinates to be represented as small values near the origin, avoiding float32 precision issues. Both the B3DM Feature Table and the GLB CESIUM_RTC extension are checked.

The loaded Object3D has a composite matrix of worldTransform x T(rtcCenter) x R(Y-up to Z-up) applied to it. This converts from glTF's Y-up convention to the ECEF Z-up coordinate system while compositing the tile's accumulated transform and RTC offset.

DRACO Compression Support

PLATEAU's 3D Tiles data uses Draco compression for glTF models. Draco is a library (developed by Google) that efficiently compresses mesh vertex data, significantly reducing file sizes.

content-loader.ts - Draco configuration
export class ContentLoader {
  private readonly gltfLoader: GLTFLoader;
  private readonly dracoLoader: DRACOLoader;

  constructor() {
    this.dracoLoader = new DRACOLoader();
    this.dracoLoader.setDecoderPath(
      `https://unpkg.com/three@0.${REVISION}.0/examples/jsm/libs/draco/gltf/`
    );
    this.gltfLoader = new GLTFLoader();
    this.gltfLoader.setDRACOLoader(this.dracoLoader);
  }
}

DRACOLoader uses a WebAssembly decoder. The decoder URL is dynamically generated using the REVISION constant to automatically track the Three.js version. This dynamic generation is important because a version mismatch between the decoder and Three.js can cause WebAssembly compatibility issues.

ECEF to Scene Coordinate Matrix Transformation

3D Tiles models are positioned in the ECEF coordinate system. To convert them to Three.js scene coordinates, an axis transformation matrix is applied to a Group.

tiles3d-adapter.ts - matrix transformation
// ECEF --> scene coordinate axis transformation matrix applied to group
// ecefToScenePosition: (x, y, z) --> (x, z, -y)
const ecefToScene = new THREE.Matrix4().set(
  1,  0,  0,  0,   // ECEF X --> Scene X
  0,  0,  1,  0,   // ECEF Z --> Scene Y
  0, -1,  0,  0,   // ECEF Y --> Scene -Z
  0,  0,  0,  1    // Homogeneous coordinate
);
this.group.applyMatrix4(ecefToScene);
this.group.add(this.tileGroup);

By applying this matrix to the group, all models within the tileGroup underneath are automatically converted to scene coordinates. This leverages Three.js's transformation matrix inheritance mechanism.

Why Matrices Instead of Functions?

The ecefToScenePosition function from Chapter 3 transformed individual vertices, but in 3D Tiles, the ContentLoader works with Object3Ds containing a large number of vertices received from GLTFLoader. Applying a function to each individual vertex is not feasible.

Instead, by setting a matrix on a Three.js Group, it is applied on the GPU side as the model transformation matrix. There is no need to perform vertex transformations on the CPU.

Tiles3DAdapter: The Integration Layer

tiles3d-adapter.ts is the control layer that integrates the six modules above. It executes the following processing flow in every frame's update() call.

adapter.update(camera, renderer)
│
├── processDisposalQueue()         // Process deferred disposals
├── Camera change check            // Compare matrices with previous frame
├── Camera movement throttling     // Limit updates to 200ms intervals
│
├── setProtectedKeys(activeKeys)   // Protect displayed tiles from LRU eviction
├── readyKeys = active ∪ cached    // Build ready set for REPLACE decisions
│
├── traverseTileset()              // SSE-based tree traversal
│     ├── BV → SceneBounds
│     ├── Horizon + frustum culling
│     ├── SSE calculation + refinement decision
│     ├── REPLACE: stable parent/child switching using readyKeys
│     └── Generate desired / toRemove / toCancel
│
├── Cancel processing              // Abort toCancel requests
├── Scene removal                  // Remove toRemove from tileGroup
├── Cache hit → add to scene       // desired & cached → add
└── Cache miss → load request      // desired & !cached → request

When the camera is not moving, traversal is skipped to avoid unnecessary CPU load. cameraMatrixChanged holds the previous frame's camera matrix as a Float32Array and compares element by element. Camera-movement-triggered updates are throttled at 200ms intervals, but updates triggered by tile load completion are executed immediately to process REPLACE fallback transitions without delay.

protectedKeys is the set of tile keys currently displayed in the scene, preventing these tiles from being evicted during LRU eviction. readyKeys is the union of activeKeys (in scene) and cachedKeys (cached), used for the "all child tiles are ready" check during REPLACE refinement. Since cached tiles can be added to the scene instantly within the same frame, including cachedKeys (not just activeKeys) prevents flip-flopping (alternating parent/child switching) in traversal results.

Tiles3DLayer

Tiles3DLayer is a thin wrapper that extends the Layer abstract class and holds a Tiles3DAdapter internally. It supports custom shader replacement through callbacks.

Tiles3DLayer.ts (excerpt)
export class Tiles3DLayer extends Layer<Tiles3DLayerProps> {
  private adapter: Tiles3DAdapter | null = null;

  initialize(ctx: LayerContext): void {
    this.adapter = new Tiles3DAdapter(
      { url, errorTarget, maxDepth, fetchOptions },
      {
        onTilesetLoaded: (sphere) => { ... },
        onLoadError: (error, url) => { ... },
        // Replace material when shader mode is specified
        onModelLoaded: (scene) => {
          applyHeightGradient(scene, ...);  // or applyFlatColor / applySunLighting
        },
        onModelDisposed: (scene) => {
          disposeCustomMaterials(scene);
        }
      }
    );
    this.group.add(this.adapter.group);
  }

  update(ctx: LayerContext): void {
    this.adapter?.update(ctx.camera, ctx.renderer);
  }
}

Key points:

  • url is required: Partial<Props> & Pick<Props, 'url'> expresses at the type level that only url must be specified
  • Adapter created in initialize: Initialization occurs after LayerContext becomes available
  • Shader replacement via callbacks: onModelLoaded allows replacing each tile's material with a custom shader
  • update simply delegates: LOD control is left to the Adapter's internal traversal algorithm

What Is PLATEAU?

PLATEAU is a nationwide 3D city model development project led by Japan's Ministry of Land, Infrastructure, Transport and Tourism (MLIT). 3D models of buildings, roads, bridges, and more are published as open data.

This demo uses LOD1 (box-shaped building models) data for Chiyoda Ward. LOD1 is a simplified model consisting only of building footprints (area) and heights. Compared to LOD2 (with roof shapes) and LOD3 (with details like windows and doors), it has a smaller data size and is well suited for wide-area display.

Using PLATEAU data
new Tiles3DLayer({
  url: 'https://assets.cms.plateau.reearth.io/assets/.../tileset.json'
})

errorTarget: 16 (default) is a parameter equivalent to the SSE threshold. Lower values produce higher quality (loading more detailed tiles), but also increase the number of displayed tiles and requests.

Summary

In this chapter, we implemented 3D Tiles loading and LOD control from scratch without relying on external libraries.

  • 3D Tiles: OGC standard 3D streaming format with hierarchical LOD and ECEF coordinate system
  • Six-module architecture: Separation of concerns across types / tileset-parser / tile-traversal / bounding-volume / tile-cache / content-loader
  • tileset-parser: Tree construction with transform accumulation and v1.0/v1.1 compatibility
  • tile-traversal: SSE calculation with REPLACE/ADD refinement, stable parent/child switching via readyKeys, maxDesired cap
  • tile-cache: LRU cache (displayed tile protection via protectedKeys), concurrent request limiting, priority queue
  • content-loader: B3DM parsing, RTC_CENTER extraction, GLTFLoader/DRACOLoader
  • Matrix transformation: ECEF to scene transformation matrix applied to Group for GPU-side axis conversion
  • PLATEAU: Integration of Chiyoda Ward LOD1 building models