How to Render 3D Tiles, LiDAR Point Clouds, and Gaussian Splats in GeoLibre Using deck.gl
GeoLibre renders 3D Tiles, LiDAR point clouds, and Gaussian splats through a single interleaved deck.gl overlay (map.__deck) that is shared across all visualization types using the MapboxOverlay adapter from @deck.gl/mapbox
This article explains how the open-source GeoLibre project (built on MapLibre) integrates deck.gl to render high-performance 3D geospatial data. All three visualization types—I3S 3D Tiles, USGS LiDAR point clouds, and Gaussian splats—draw into a unified overlay system rather than creating separate WebGL contexts.
The Shared Deck.gl Overlay Architecture
GeoLibre centralizes deck.gl rendering through a singleton overlay attached to each MapLibre map instance.
How the Overlay Is Created
The overlay lifecycle is managed in packages/plugins/src/plugins/shared-deck-overlay.ts. When any 3D visualization plugin activates, it calls app.getDeckGL() to retrieve or create the shared MapboxOverlay instance.
// From shared-deck-overlay.ts
// The overlay is stored at map.__deck for persistence
const overlay = new MapboxOverlay({
// deck.gl props
layers: [],
// Interleaved rendering: deck.gl draws into MapLibre's GL context
interleaved: true
});
map.__deck = overlay;
map.addControl(overlay, "top-left");
Interleaved rendering means deck.gl shares WebGL context with MapLibre, eliminating z-fighting and synchronization issues between the two libraries.
Projection Constraint: Mercator-Only Rendering
All three plugin types enforce Mercator projection while active. Deck.gl's tile-based rendering does not support other projections, so each plugin temporarily locks the map:
maplibre-3d-tiles.tsusesforceI3sMercatorProjection()maplibre-usgs-lidar.tsemits:[maplibre-usgs-lidar] control failed to mount; deactivate the plugin to restore the projection
Rendering 3D Tiles (ArcGIS I3S)
The ArcGIS I3S plugin renders indexed 3D scene layers using deck.gl's Tile3DLayer.
Implementation in maplibre-3d-tiles.ts
1. Layer Construction
When a user adds a 3D Tiles source, the plugin constructs a Tile3DLayer from @deck.gl/geo-layers:
// packages/plugins/src/plugins/maplibre-3d-tiles.ts
import { Tile3DLayer } from '@deck.gl/geo-layers';
import { I3SLoader } from '@loaders.gl/i3s';
const layer = new Tile3DLayer({
id: `i3s-layer-${sourceId}`,
data: tilesetUrl,
loader: I3SLoader,
// Streaming: tiles load on-demand based on viewport
onTilesetLoad: (tileset) => {
tileset.setOptions({ maximumScreenSpaceError: 4 });
}
});
2. Data Pipeline
| Stage | Component | Purpose |
|---|---|---|
| Tileset parsing | I3SLoader |
Parses tileset.json and node pages |
| Geometry extraction | @loaders.gl/i3s |
Draco-compressed meshes → deck.gl geometry |
| Texture loading | Shared I3S material definitions | PBR materials, embedded textures |
| Rendering | Tile3DLayer |
LOD-aware culling and streaming |
3. Overlay Integration
The plugin mounts the layer to the shared overlay:
// Get or create the singleton overlay
const deckOverlay = app.getDeckGL();
// Add layer to deck.gl's layer stack
deckOverlay.setProps({
layers: [...existingLayers, i3sLayer]
});
Key source: packages/plugins/src/plugins/maplibre-3d-tiles.ts — implements lazy loading of @loaders.gl/i3s, Mercator projection lock, and overlay lifecycle management.
Rendering USGS LiDAR Point Clouds
The LiDAR plugin streams LAS/LAZ files and renders them as colored point clouds with depth-sorted splatting.
Implementation in maplibre-usgs-lidar.ts
1. Lazy Module Loading
The plugin dynamically imports maplibre-gl-usgs-lidar to reduce initial bundle size:
// packages/plugins/src/plugins/maplibre-usgs-lidar.ts
const { LidarControl, LidarLayerAdapter } = await import(
'maplibre-gl-usgs-lidar'
);
2. Control and Adapter Pattern
The LidarControl manages UI state (progress, classification filters), while LidarLayerAdapter translates LiDAR data into deck.gl layers:
// LidarLayerAdapter creates deck.gl layers
const lidarLayer = new SimpleMeshLayer({
id: 'lidar-points',
data: copcReader, // Cloud-Optimized Point Cloud iterator
mesh: new CubeGeometry({ size: pointSize }),
getPosition: d => [d.x, d.y, d.z],
getColor: d => d.classification ? CLASSIFICATION_COLORS[d.classification] : [255, 255, 255],
// Depth sorting for correct transparency
depthSort: true
});
3. COPC and LAZ Streaming
The plugin uses @loaders.gl/las and @loaders.gl/copc for progressive loading of cloud-optimized point clouds:
// Streaming from S3 or HTTP range requests
const copcSource = await load(lazUrl, COPCLoader);
for await (const chunk of copcSource) {
// Yield points to deck.gl as they're decompressed
adapter.addPoints(chunk);
}
Key source: packages/plugins/src/plugins/maplibre-usgs-lidar.ts — handles control creation, point-cloud streaming, and shared overlay integration.
Rendering Gaussian Splats
Gaussian splatting renders 3D scenes as collections of oriented, textured ellipsoids—billboarded quads with Gaussian alpha falloff. GeoLibre supports this through an optional plugin bundle.
Implementation in maplibre-components.ts
1. Plugin Resolution
The Gaussian Splat plugin loads from maplibre-gl-splat with a fallback to a bundled version:
// packages/plugins/src/plugins/maplibre-components.ts
async function resolveSplatModule() {
try {
// Prefer external, newer version
return await import('maplibre-gl-splat');
} catch {
// Fallback to bundled implementation
return import('./fallback-splat-bundle');
}
}
2. Control Factory
createSplattingControl() instantiates the control with predefined SPLATTING_OPTIONS:
export async function createSplattingControl(app) {
const { GaussianSplatControl, GaussianSplatLayerAdapter } =
await resolveSplatModule();
const control = new GaussianSplatControl({
...SPLATTING_OPTIONS, // Sh, ch levels, alpha threshold
deck: app.getDeckGL() // Shared overlay instance
});
return control;
}
3. Splat Rendering Pipeline
| Component | Description |
|---|---|
GaussianSplatLayerAdapter |
Creates SimpleMeshLayer with custom shaders |
| Splats | Oriented quads with 3D covariance (mean, scale, rotation) |
| Blending | Additive + depth-aware alpha for volume rendering |
| Sorting | Per-frame sort by depth (handled in adapter) |
4. Programmatic Usage
import { createSplattingControl } from "@geolibre/plugins";
const splatControl = await createSplattingControl(app);
// Load trained Gaussian splat model
await splatControl.loadFromUrl("https://example.com/model.ply");
// Or convert from point cloud
await splatControl.loadPointCloud("https://example.com/points.laz");
Key source: packages/plugins/src/plugins/maplibre-components.ts — resolves splat modules, configures GaussianSplatControl with SPLATTING_OPTIONS, and registers with the shared MapboxOverlay.
Practical Code Examples
Adding 3D Tiles Through UI
await app.addLayer({
id: "i3s-tiles-01",
source: {
type: "arcgis-i3s",
url: "https://tiles.arcgis.com/tiles/example/arcgis/rest/services/Buildings/SceneServer"
},
});
Behind the scenes: maplibre-3d-tiles.ts → Tile3DLayer + I3SLoader → shared overlay.
Adding LiDAR Point Cloud
await app.addLayer({
id: "lidar-autzen",
type: "lidar-url",
source: {
url: "https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz"
},
options: {
classificationFilter: [2, 5, 6], // Ground, vegetation, building
pointSize: 2
}
});
Behind the scenes: maplibre-usgs-lidar.ts → LidarControl + LidarLayerAdapter → deck.gl SimpleMeshLayer.
Adding Gaussian Splat Layer (Programmatic)
import { createSplattingControl } from "@geolibre/plugins";
async function initSplat() {
const control = await createSplattingControl(app);
// Configure rendering quality
control.setOptions({
shDegree: 3, // Spherical harmonics degree
chDegree: 0, // No chromatic aberration
alphaThreshold: 0.01
});
await control.loadFromUrl(
"https://example.com/gaussian-model.ply"
);
return control;
}
Performance and Architectural Considerations
Single Overlay Benefits
- One WebGL context: Eliminates context overhead and resource duplication
- Unified depth buffer: Correct occlusion between MapLibre basemap and deck.gl layers
- Synchronized camera: No lag between map navigation and 3D overlays
Memory and Streaming
| Visualization | Streaming Strategy | Memory Management |
|---|---|---|
| 3D Tiles (I3S) | LOD-based tile culling | Tile cache with MRU eviction |
| LiDAR | COPC range requests | Chunked, progressive loading |
| Gaussian Splats | Full model + adaptive rendering | Shader-based splat culling |
Summary
-
GeoLibre uses one shared deck.gl overlay (
MapboxOverlayfrom@deck.gl/mapbox) for all 3D visualizations, stored atmap.__deck -
3D Tiles render via
Tile3DLayerwithI3SLoaderinmaplibre-3d-tiles.ts, enforcing Mercator projection -
LiDAR point clouds stream through
LidarControlandLidarLayerAdapterinmaplibre-usgs-lidar.ts, usingSimpleMeshLayerwith depth sorting -
Gaussian splats resolve through
createSplattingControl()inmaplibre-components.ts, with fallback bundling andGaussianSplatLayerAdapterfor oriented splat rendering -
All three plugins rely on lazy loading, shared deck.gl instances, and Mercator projection locking to ensure consistent, performant 3D rendering on MapLibre maps
Frequently Asked Questions
Why does GeoLibre lock the map to Mercator projection for 3D visualizations?
Deck.gl's tile-based rendering—including Tile3DLayer and point-cloud layers—only supports Web Mercator projection. The plugins in maplibre-3d-tiles.ts and maplibre-usgs-lidar.ts temporarily force Mercator while active and restore the previous projection on deactivation. This ensures geometric correctness without duplicating deck.gl's projection logic.
Can multiple 3D layer types (3D Tiles, LiDAR, splats) render simultaneously?
Yes. All layers draw into the same MapboxOverlay instance retrieved via app.getDeckGL(). The overlay manages deck.gl's layer stack, so setProps({ layers: [...] }) can include any combination of Tile3DLayer, SimpleMeshLayer, and custom splat layers. Layer ordering follows the beforeId option relative to MapLibre layers.
How does GeoLibre handle large LiDAR point clouds without freezing the UI?
The LiDAR plugin uses Cloud-Optimized Point Cloud (COPC) format and @loaders.gl/copc for streaming. Points are yielded progressively through async iterators, and the LidarLayerAdapter batches updates to deck.gl. The viewer sees data appear incrementally while the main thread remains responsive.
What happens if maplibre-gl-splat is not installed?
The createSplattingControl() factory in maplibre-components.ts wraps the dynamic import in a try/catch block. If the external module fails to load, it falls back to a bundled (potentially older) version of the splatting implementation. This guarantees the UI control is always available even with partial dependency installation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →