Classifying 3D Tiles with Polygon Volumes
This page colours existing 3D Tiles content by zone without touching the tiles — building extruded classification volumes from polygons, sizing them so they cover the tallest building in their footprint, choosing what each volume is allowed to paint, keeping per-feature picking, and moving to a classification tileset when the number of volumes outgrows a primitive.
Why you hit this
The most common request after a twin’s buildings are on screen is “colour them by something”: zoning, ownership, energy class, survey status. Rebuilding the tileset with the attribute baked in takes hours and has to be repeated whenever the attribute changes. Classification does it at render time — the volume is a shadow-volume geometry and the buildings keep their own textures, so switching from zoning to energy class is one style change. What goes wrong is geometric: a volume that does not fully contain the buildings paints only part of them, and the result looks like a rendering bug. The choice between this and the other overlay strategies is in vector overlays on 3D Tiles.
Prerequisites
- CesiumJS 1.110+ with a stencil buffer —
viewer.scene.context.stencilBuffermust be true, which it is on all current desktop and mobile browsers with WebGL 2. - A loaded 3D Tiles tileset of buildings, and polygons in EPSG:4326 with a zone attribute and a stable identifier.
- Height information for the footprints the volumes cover: a
height_mcolumn, the building tileset’s own metadata, or a DSM to sample.
Step-by-Step
1. Size the volumes from the buildings they must cover
import geopandas as gpd
import numpy as np
import rasterio
zones = gpd.read_file("zoning.gpkg").to_crs(25832)
buildings = gpd.read_file("footprints.gpkg").to_crs(25832) # with height_m and base_z
joined = gpd.sjoin(buildings[["geometry", "height_m", "base_z"]], zones[["geometry", "zone_id"]],
how="inner", predicate="intersects")
tops = joined.groupby("zone_id").apply(lambda g: float((g["base_z"] + g["height_m"]).max()))
bottoms = joined.groupby("zone_id").apply(lambda g: float(g["base_z"].min()))
zones = zones.merge(tops.rename("top_z"), on="zone_id").merge(bottoms.rename("bottom_z"), on="zone_id")
zones["top_z"] = zones["top_z"] + 10.0 # clearance above the tallest roof
zones["bottom_z"] = zones["bottom_z"] - 20.0 # below the lowest terrain in the zone
print(zones[["zone_id", "bottom_z", "top_z"]].describe().loc[["min", "max"]])
zones.to_crs(4326).to_file("web/zones_4326.geojson", driver="GeoJSON")
The extents come from the data, not from a round number. A zone containing a 60 m tower needs a volume that reaches above it, and a zone on a hillside needs a base below the lowest ground in its own footprint — otherwise the lower storeys of the downhill buildings sit outside the volume and stay unpainted. The generous margins cost nothing: a classification volume is invisible, so making it 10 m taller than necessary has no visual effect at all.
2. Build one batched classification primitive
async function classifyZones(url, scene, colorFor) {
const fc = await (await fetch(url)).json();
const instances = [];
for (const f of fc.features) {
const polys = f.geometry.type === "Polygon" ? [f.geometry.coordinates] : f.geometry.coordinates;
for (const rings of polys) {
instances.push(new Cesium.GeometryInstance({
geometry: new Cesium.PolygonGeometry({
polygonHierarchy: new Cesium.PolygonHierarchy(
Cesium.Cartesian3.fromDegreesArray(rings[0].flat()),
rings.slice(1).map((r) => new Cesium.PolygonHierarchy(Cesium.Cartesian3.fromDegreesArray(r.flat())))
),
height: f.properties.bottom_z,
extrudedHeight: f.properties.top_z,
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(colorFor(f.properties)),
show: new Cesium.ShowGeometryInstanceAttribute(true),
},
id: f.properties.zone_id,
}));
}
}
const primitive = new Cesium.ClassificationPrimitive({
geometryInstances: instances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
asynchronous: true,
});
scene.primitives.add(primitive);
await primitive.readyPromise;
return primitive;
}
const zonePalette = {
residential: Cesium.Color.fromCssColorString("#1f6b8a").withAlpha(0.45),
mixed: Cesium.Color.fromCssColorString("#c46a3d").withAlpha(0.45),
industrial: Cesium.Color.fromCssColorString("#5b6471").withAlpha(0.45),
protected: Cesium.Color.fromCssColorString("#4f7a4d").withAlpha(0.45),
};
const zoneLayer = await classifyZones("/web/zones_4326.geojson", viewer.scene,
(p) => zonePalette[p.zone] ?? Cesium.Color.GRAY.withAlpha(0.35));
ClassificationPrimitive requires every instance to carry a colour attribute — it has no appearance of its own — and batches them into one draw. The heights come from the data prepared in step 1, in metres above the ellipsoid; if the source heights are orthometric, convert them first, because a 47 m geoid separation puts the base of every volume above the ground.
3. Choose what each layer paints
zoneLayer.classificationType = Cesium.ClassificationType.CESIUM_3D_TILE; // buildings only
// TERRAIN for a ground-plane zone, BOTH for a volume that covers everything inside it
The three options answer three different questions. Zoning that regulates buildings should paint buildings, so the street between them keeps its own appearance and the zone boundary stays legible. A flood extent should paint BOTH, because water covers the ground and the lower storeys alike. A soil or noise map belongs on TERRAIN.
4. Pick, filter and restyle without rebuilding
const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction((m) => {
const picked = viewer.scene.pick(m.position);
if (!Cesium.defined(picked)) return;
// A click on a classified building can return either the zone instance or the building feature
if (picked instanceof Cesium.Cesium3DTileFeature) {
console.log("building", picked.getProperty("building_id"));
} else if (Cesium.defined(picked.id)) {
console.log("zone", picked.id);
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
function showOnly(zoneIds) {
for (const f of allZoneIds) {
const attrs = zoneLayer.getGeometryInstanceAttributes(f);
attrs.show = Cesium.ShowGeometryInstanceAttribute.toValue(zoneIds.has(f));
}
}
Picking over classified geometry is ambiguous by nature: the pixel belongs to a building and to a zone. Deciding which one a click means — usually the building, with the zone shown as context — is an application decision worth making once and documenting, because users will otherwise get different behaviour depending on which pixel they hit.
5. Move to a classification tileset when the layer is large
A primitive holds its geometry in memory all the time. Past a few thousand volumes, the same geometry belongs in a tileset with classificationType set, so it streams and culls.
const zoneTiles = await Cesium.Cesium3DTileset.fromUrl("/tiles/zone_volumes/tileset.json", {
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
});
viewer.scene.primitives.add(zoneTiles);
zoneTiles.style = new Cesium.Cesium3DTileStyle({
color: {
conditions: [
["${zone} === 'protected'", "color('#4f7a4d', 0.45)"],
["${zone} === 'industrial'", "color('#5b6471', 0.45)"],
["true", "color('#1f6b8a', 0.4)"],
],
},
});
The volumes are built exactly like the prisms in extruding footprints into LOD1 tiles, with the base and top from step 1 instead of a building height, and with the zone attribute carried as feature metadata so the style expression above can read it.
Expected Output & Verification
bottom_z top_z
min 478.20 521.40
max 502.66 578.10
Verify coverage rather than appearance, by sampling the classified result along the height of the tallest building in each zone:
async function verifyCoverage(tallestByZone) {
for (const [zoneId, { lon, lat, base, top }] of Object.entries(tallestByZone)) {
const heights = [base + 1, (base + top) / 2, top - 1];
for (const h of heights) {
const world = Cesium.Cartesian3.fromDegrees(lon, lat, h);
const win = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, world);
const picked = win && viewer.scene.pick(win);
if (!picked) console.warn(`zone ${zoneId}: nothing picked at ${h.toFixed(1)} m`);
}
}
}
Checking near the base, the middle and just under the roof is what catches a volume that is too short or whose base is above the terrain. A zone where the top sample misses is the classic under-extruded volume; one where the base sample misses has its bottom above the ground, usually from orthometric heights used as ellipsoidal.
Also confirm the hardware supports the technique at all, and that the layer is one primitive:
console.log("stencil buffer:", viewer.scene.context.stencilBuffer);
console.log("primitives:", viewer.scene.primitives.length);
Performance Notes
- Volumes are shadow geometry, so their cost is proportional to vertex count and to the height range they span. Simplify polygons and avoid volumes that span a whole valley.
- One primitive per layer, restyled through attributes. Rebuilding to change a colour is the mistake that makes classification feel slow.
- Overlapping volumes multiply fragment work. Where zones overlap, ship non-overlapping volumes from the pipeline rather than letting the renderer blend them.
- Hide rather than remove.
showon an instance attribute costs nothing; removing and re-adding a primitive rebuilds geometry. - Watch memory alongside the tileset’s. Classification geometry counts against the same budget as tile content, which is sized in tuning tileset cache bytes.
Common Errors
Nothing is coloured, and no error appears. Either the stencil buffer is unavailable, or classificationType is TERRAIN while the target is a tileset, or the volume’s height range does not intersect the buildings. Check all three in that order.
Only the ground floor of every building is coloured. The volume’s extrudedHeight is below the roofs. Recompute the top from the maximum of base plus height in the zone, as step 1 does.
The colour is applied to the terrain as well and swamps the scene. BOTH was set on a layer that only needed buildings. Zoning almost never wants BOTH.
Volumes appear to shift as the camera moves closer. The building tiles are refining, and classification paints whatever level of detail is loaded. This is inherent; explain it rather than fighting it.
Frequently Asked Questions
Can I classify with a polygon that has holes?
Yes — pass the inner rings in the PolygonHierarchy, as in step 2. Holes are the right way to model a zone with an excluded parcel, and they cost nothing extra.
Does classification change the tileset’s own styling?
No. The tileset keeps its style and its textures; classification draws over the fragments inside the volume. Both are visible at once, which is why alpha around 0.4–0.5 works better than an opaque colour.
Is there a limit on the number of volumes in one primitive?
No hard limit, but each instance adds geometry to the batch, and build time and memory grow linearly. Several thousand is comfortable; beyond that, the tileset route is the answer.
Related Guides
- Vector Overlays on 3D Tiles — how classification compares with draping and imagery
- Extruding Footprints into LOD1 Tiles — building the volumes as a tileset
- Styling Tiles by Metadata with Cesium3DTileStyle — styling the buildings directly when the attribute is already in the tiles
Back to Vector Overlays on 3D Tiles.