Vector Overlays on 3D Tiles
A digital twin is rarely just geometry. The questions people bring to it are about parcels, zoning, easements, planned routes, flood extents, inspection findings and asset identifiers — all of it vector data that has to appear on the tiled 3D content without fighting it for depth, memory or frame time. This guide covers the five ways to put vector data over a 3D Tiles scene, what each one costs, and how to choose: draping polygons onto terrain and buildings, clamping polylines, extruding footprints into their own tiles, classifying existing tiles with polygon volumes, and rendering vector data as imagery for the cases where nothing else scales.
It is written for engineers building on CesiumJS over 3D Tiles produced by the pipelines in automated tile generation, with source vector data in a projected CRS such as EPSG:25832 that is transformed to EPSG:4979 for display.
Prerequisites
- CesiumJS 1.110 or newer, and Python 3.10+ with
geopandas>=0.14,shapely>=2.0,pyproj>=3.6for preparing the data. - Vector data with a declared CRS and valid geometry — invalid polygons drape as holes or not at all.
- A 3D Tiles tileset with known bounding volumes, and terrain if ground draping is involved.
- A decision about what “on the ground” means for each layer: on the terrain, on the buildings, or on both. That choice drives everything below.
The Five Strategies
Ground primitives: drape polygons on terrain
A GroundPrimitive renders a polygon by projecting it onto whatever is beneath it, using the depth buffer rather than geometry. Because the polygon has no height of its own, it follows every hill and every tile refinement without seams, and it needs no terrain sampling at load time.
const parcels = await Cesium.GeoJsonDataSource.load("/data/parcels_4326.geojson", {
clampToGround: true,
fill: Cesium.Color.fromCssColorString("#1f6b8a").withAlpha(0.35),
stroke: Cesium.Color.fromCssColorString("#15384a"),
strokeWidth: 2,
});
viewer.dataSources.add(parcels);
Key Practice: Keep draped layers under a few thousand polygons, and merge them into one primitive when you can. Every GroundPrimitive is a draw call with its own shadow-volume geometry; ten thousand separate parcel entities will cost more frame time than the whole city tileset. Where a layer is larger than that, move it to imagery or to its own tileset.
Classification: paint the buildings, not the ground
classificationType decides what a draped geometry is allowed to colour. Cesium.ClassificationType.TERRAIN paints terrain only, CESIUM_3D_TILE paints tile content only, and BOTH paints whatever is in front.
const zone = new Cesium.ClassificationPrimitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArrayHeights(flatCoordsWithHeights)
),
extrudedHeight: 60.0,
height: 0.0,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString("#c46a3d").withAlpha(0.5)
),
},
}),
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
});
scene.primitives.add(zone);
Key Practice: Give a classification volume an extruded height that comfortably exceeds the tallest building it must cover, and a base below the lowest terrain in its footprint. Classification works by testing what lies inside the volume, so a zone extruded to 20 m colours the bottom 20 m of a 60 m tower and leaves the rest untouched — which looks like a rendering bug and is a geometry mistake.
Extruded tiles: turn vector data into 3D Tiles
Above roughly ten thousand features, or whenever the overlay has real height — building footprints at LOD1, flood volumes, noise envelopes — the overlay belongs in its own tileset, streamed and culled exactly like the rest of the scene.
import geopandas as gpd
parcels = gpd.read_file("parcels.gpkg").to_crs(25832)
parcels["height"] = parcels["zoning_max_height_m"].fillna(12.0)
parcels["base"] = parcels["terrain_z"] # sampled from the DTM beforehand
parcels[["geometry", "height", "base", "parcel_id"]].to_file("zoning_prisms.gpkg")
Key Practice: Sample the terrain into the vector data before tiling and store the base height per feature. A tileset generated with a constant base floats over a valley and buries itself in a hill, and no runtime option can fix geometry that is already baked into tiles. The extrusion mechanics are in extruding footprints into LOD1 tiles.
Clamped polylines: routes, pipes and boundaries
Polylines are their own problem, because a line clamped to the ground disappears behind every bump and a line offset upward floats visibly on a slope.
viewer.entities.add({
polyline: {
positions: Cesium.Cartesian3.fromDegreesArray(routeLonLat),
width: 6,
clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH,
material: new Cesium.PolylineOutlineMaterialProperty({
color: Cesium.Color.fromCssColorString("#b0413e"),
outlineWidth: 2,
outlineColor: Cesium.Color.WHITE,
}),
},
});
Key Practice: Clamp, do not offset. clampToGround draws the line into the depth buffer of the surface beneath it, so it stays visible over terrain and buildings at every level of detail; an offset of “a metre or two” is a guess that fails on a 30% slope and inside a tile whose geometric error is larger than the offset. The cases where an offset is genuinely needed — a suspended cable, an aerial route — are in clamping polylines to terrain and buildings.
Imagery: vector data at raster scale
A cadastre of two million parcels, a national soil map, a basemap: these are not overlay geometry, they are pictures. Rendering them server-side into raster tiles and adding them as an imagery layer puts the cost on a tile server that caches, instead of on the client’s geometry budget.
viewer.imageryLayers.addImageryProvider(
new Cesium.UrlTemplateImageryProvider({
url: "https://tiles.example.org/cadastre/{z}/{x}/{y}.png",
maximumLevel: 20,
credit: "Cadastre © state survey",
})
);
Key Practice: Imagery drapes on terrain but not on buildings, so it is right for ground-level context and wrong for anything that must appear on a facade or a roof. Combine it with a small classification layer for the few features that need to reach up onto the buildings.
Choosing a Strategy
The decision is driven by three properties of the layer: how many features it has, whether it needs to appear on buildings as well as ground, and whether it has to be interactive — clickable, styled per feature, updated live.
| Layer | Features | Needs facades | Strategy |
|---|---|---|---|
| Site boundary, a few parcels | < 100 | no | ground primitive |
| District zoning | 100–5,000 | yes | classification volumes |
| City cadastre | > 100,000 | no | imagery |
| LOD1 zoning envelopes | > 10,000 | yes | overlay tileset |
| Inspection route | 1 polyline | yes | clamped polyline |
| Asset identifiers | 1,000–50,000 | n/a | label collection with clustering |
Key Practice: Never mix strategies for one layer to “get the best of both”. Two representations of the same features, one draped and one extruded, produce double-drawn edges, inconsistent picking and two places to fix a styling bug. Pick one per layer and switch wholesale when the data outgrows it.
What Overlays Cost, and Where
It helps to know which resource each strategy spends, because the symptoms differ and so do the fixes.
Draped geometry spends client memory and build time. Every polygon becomes a shadow volume — geometry extruded through the height range it covers — held for as long as the layer is on screen. A thousand parcels with twenty vertices each is nothing; a soil map with a million vertices takes seconds to build in a worker and hundreds of megabytes to hold, and the symptom is a stall when the layer is switched on rather than a low frame rate afterwards.
Classification volumes spend fill rate. The geometry is small — a few vertices per zone — but every pixel inside a volume is tested against the depth and stencil buffers, so overlapping volumes multiply the per-pixel work. The symptom is a frame rate that falls when the camera looks along a street where many zones overlap, and is fine when it looks down at the same zones from above.
An overlay tileset spends the same budget as the rest of the scene: requests, decode time and the tile cache. That is a feature rather than a cost, because it means the overlay is culled and streamed by the same machinery, and the familiar controls apply. The symptom of an overlay tileset that is too heavy is the same as for any tileset — tiles evicted and re-requested, covered in tuning tileset cache bytes.
Imagery spends network and cache storage on the server, and almost nothing on the client. A raster layer of a national cadastre is a few hundred megabytes of PNG at rest, served from a CDN, and the client holds a handful of textures for the tiles in view. What it cannot do is interact: there is no feature to pick and no way to restyle without rebuilding.
Labels and billboards spend text rasterisation and per-frame position updates. They are the only overlay whose cost is largely independent of the geometry underneath, and the only one where the readability limit arrives before the performance limit — a thousand labels in one view is unreadable long before it is slow.
Key Practice: Diagnose an overlay problem by which resource the symptom points at, before changing strategy. A stall on toggle is build time, a drop when looking sideways is fill rate, tiles re-requested is the cache, and unreadable clutter is a labelling policy — and each has a fix that does not involve rewriting the layer.
Keeping Attributes Attached
An overlay that cannot answer “what is this?” is decoration. Each strategy carries attributes differently, and deciding this at the pipeline stage saves rebuilding later.
Geometry instances in a primitive take an id, which can be a string or an object, and picking returns it directly — so the identifier and a small amount of context travel with the geometry. An overlay tileset carries feature metadata, which is richer and also styleable in the viewer through expressions. Imagery carries nothing, so an interactive raster layer needs a separate feature query by coordinate. Labels carry whatever you put in their id alongside their text.
The rule that keeps this maintainable is to ship one stable identifier and let everything else be looked up. Baking a dozen attributes into tiles means rebuilding the tiles when a value changes; baking the identifier means the tiles outlive every attribute change, and the viewer asks an API for the current values. For attributes that change by the minute — occupancy, status, sensor readings — that is the only workable arrangement, and it is described in streaming live sensor updates onto tilesets.
Key Practice: Put the identifier in the geometry and the values behind an API. An overlay whose only baked attribute is an identifier can be rebuilt rarely and restyled instantly, which is the opposite of the usual arrangement where a colour change means a tiling run.
Cross-Section Integration
Overlays are the point at which the twin’s geometry meets its attribute data, and that makes them the place where CRS discipline pays off or fails visibly. Vector data arrives in a national projected CRS; Cesium wants longitude, latitude and height on the WGS84 ellipsoid. A parcel layer transformed without its vertical datum drapes fine — draped geometry ignores its own heights — but the moment the same layer is extruded into a tileset, the missing geoid separation appears as a 40 m offset. The transformations are the ones in coordinate reference systems for 3D assets.
Overlays also inherit the tileset’s level of detail. A classification volume paints whatever tile content is loaded, so a building at a coarse level shows the zone on its blocky silhouette and the boundary shifts slightly as the tile refines. That is expected behaviour and worth explaining to users rather than trying to fix; the alternative, forcing a high detail level for the sake of an overlay, costs the memory budget described in tuning tileset cache bytes.
Key Practice: Transform vector data to EPSG:4979 once, in the pipeline, and ship the overlay in that frame with its CRS recorded. Transforming in the browser per feature is slow, hard to test, and puts a PROJ-equivalent in a place where nobody will notice it drifting out of date.
Production Checklist
Troubleshooting Matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Polygon appears on terrain but not on buildings | classificationType is TERRAIN |
set CESIUM_3D_TILE or BOTH |
| Zone colours only the lower part of towers | classification volume not extruded high enough | extrude past the tallest roof in the footprint |
| Overlay floats or sinks by tens of metres | vertical datum ignored when transforming | transform with the compound CRS |
| Frame rate collapses when a layer is enabled | thousands of separate primitives or entities | merge, or move the layer to imagery or a tileset |
| Line disappears behind terrain | line offset instead of clamped | clampToGround: true |
| Labels flicker and overlap | one label entity per feature, no declutter | label collection with clustering |
| Picked feature has no identifier | attributes dropped during tiling | carry the identifier as a feature ID or batch-table property |
Frequently Asked Questions
Can I style a draped layer per feature without splitting it into primitives?
Yes — a single Primitive can hold many GeometryInstances, each with its own colour attribute, and they batch into one draw call. Changing a colour later means updating the attribute rather than rebuilding the primitive.
Do overlays work on point cloud tilesets?
Classification does not; it needs surfaces to paint. Ground primitives drape on terrain under a point cloud, and an overlay tileset works anywhere. For colouring points by an attribute, style the point cloud itself instead.
How do I keep an overlay in sync with a changing database?
Serve the overlay from an endpoint that reads the database, cache it with a short lifetime, and version the URL when the schema changes. For frequently changing status data, keep the geometry static in a tileset and stream only the attribute values — the pattern in streaming live sensor updates onto tilesets.
Is deck.gl a better fit for large vector overlays?
For pure data visualisation over a basemap, often yes. In a twin where the vector data must interact with 3D Tiles geometry — classification, depth, picking against buildings — the Cesium primitives above are the ones with access to the depth buffer.
Should an overlay be toggled or always on?
Always-on overlays train users to ignore them, and every one of them costs part of the budget the tiles need. Ship the two or three layers that answer the questions users actually arrive with, and put the rest behind a layer switch that loads them on demand — a layer built lazily on first use costs nothing until somebody wants it.
Can two classification layers overlap?
They can, and the result is the blend of both colours with no defined order. Where zones overlap, decide the precedence in the pipeline and ship non-overlapping volumes, or use one volume per precedence level and toggle between them.
Related Guides
- Draping GeoJSON Polygons on 3D Tiles — the ground-primitive route in detail
- Clamping Polylines to Terrain and Buildings — routes and boundaries
- Extruding Footprints into LOD1 Tiles — when the overlay becomes a tileset
- Classifying 3D Tiles with Polygon Volumes — painting facades
- Rendering Thousands of Labels and Billboards — text without wrecking frame time
- Serving Vector Tiles as Imagery over Terrain — the scale escape hatch