Raster + PLATEAU 3D Building Models
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.
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:
The data is organized as a tree structure rooted at tileset.json. Each node contains the following information:
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.
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.
const viewer = new GlobeViewer({
canvas: canvasEl,
layers: [
new RasterTileLayer(),
new Tiles3DLayer({
url: '...tileset.json'
})
]
});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.
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 aboveThe only external dependencies are GLTFLoader and DRACOLoader (Three.js add-ons). Tileset parsing, traversal, and cache management are all implemented from scratch.
| Module | Responsibility |
|---|---|
| types.ts | Type definitions for Tiles3DNode, SceneBounds, TraversalResult, etc. |
| tileset-parser.ts | Fetching tileset.json and tree construction (transform accumulation, v1.0/v1.1 compatible) |
| tile-traversal.ts | SSE-based recursive traversal (REPLACE/ADD refinement support) |
| bounding-volume.ts | region/box/sphere to SceneBounds conversion, visibility testing |
| tile-cache.ts | LRU cache, concurrent request limiting, priority queue, deferred disposal |
| content-loader.ts | B3DM header parsing, GLB loading, RTC_CENTER extraction, matrix composition |
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.
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.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.
// 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:
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.
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 childrentile-cache.ts handles cache management for loaded tiles and controls network requests.
| Constant | Value | Role |
|---|---|---|
| MAX_CACHE_SIZE | 128 | Maximum number of cache entries |
| MAX_CONCURRENT | 6 | Maximum concurrent HTTP requests |
| DISPOSAL_BUDGET_MS | 2 | Per-frame disposal time budget (ms) |
| MAX_DISPOSALS_PER_FRAME | 3 | Maximum disposals per frame |
Cache behavior:
protectedKeys (tiles currently displayed in the scene) are exempt from evictioncontent-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).
// 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.
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.
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.
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.
// 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.
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.
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 is a thin wrapper that extends the Layer abstract class and holds a Tiles3DAdapter internally. It supports custom shader replacement through callbacks.
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:
Partial<Props> & Pick<Props, 'url'> expresses at the type level that only url must be specifiedPLATEAU 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.
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.
In this chapter, we implemented 3D Tiles loading and LOD control from scratch without relying on external libraries.