Rendering Thousands of Labels and Billboards
This page puts asset markers and text over a tiled 3D scene at a scale that stays interactive — using billboard and label collections instead of entities, clamping markers to terrain or tile content, hiding them by distance, clustering them when they crowd, and keeping only the nearest few hundred labels alive as the camera moves.
Why you hit this
Every twin ends up needing markers: inspection points, sensors, addresses, defect locations, asset tags. The straightforward implementation — one entity with a label and a billboard per feature — is comfortable up to a few hundred and unusable at ten thousand, because each label is a textured quad whose glyphs are packed into an atlas and whose position is recomputed every frame. The scene does not crash; it drops to single-digit frame rates and the labels become an unreadable stack of overlapping text. Both problems have standard answers in CesiumJS, and neither is the default. The overlay strategies around this are in vector overlays on 3D Tiles.
Prerequisites
- CesiumJS 1.110+ over a tileset and, for clamped markers, terrain.
- Point data with a stable identifier, a label string and a category, in EPSG:4326 with heights or with a height reference.
- Python 3.10+ with
geopandas>=0.14for preparing the points.
Step-by-Step
1. Prepare points with the fields the renderer needs
import geopandas as gpd
pts = gpd.read_file("assets.gpkg", layer="inspection_points").to_crs(4326)
pts["label"] = pts["asset_tag"].astype(str)
pts["category"] = pts["asset_type"].fillna("other")
pts["priority"] = pts["risk_score"].fillna(0).astype(int) # drives what stays visible when crowded
keep = ["asset_id", "label", "category", "priority", "geometry"]
pts[keep].to_file("web/assets_4326.geojson", driver="GeoJSON")
print(f"{len(pts)} points, {pts['category'].nunique()} categories")
A priority field is what makes the later steps possible. Once a scene has more markers than it can show, something has to decide which ones survive, and doing that by risk, status or size is far more useful than by arbitrary order.
2. Use collections, not entities
const billboards = viewer.scene.primitives.add(new Cesium.BillboardCollection({
scene: viewer.scene, // enables depth testing against terrain and tiles
}));
const labels = viewer.scene.primitives.add(new Cesium.LabelCollection({ scene: viewer.scene }));
const ICONS = {
valve: "/icons/valve.png",
hydrant: "/icons/hydrant.png",
other: "/icons/generic.png",
};
const fc = await (await fetch("/web/assets_4326.geojson")).json();
const records = fc.features.map((f) => {
const [lon, lat] = f.geometry.coordinates;
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
const p = f.properties;
const billboard = billboards.add({
position,
image: ICONS[p.category] ?? ICONS.other,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
scale: 0.6,
scaleByDistance: new Cesium.NearFarScalar(200, 1.0, 3000, 0.4),
translucencyByDistance: new Cesium.NearFarScalar(2000, 1.0, 6000, 0.0),
id: p.asset_id,
});
const label = labels.add({
position,
text: p.label,
font: "13px sans-serif",
fillColor: Cesium.Color.fromCssColorString("#1f2937"),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
pixelOffset: new Cesium.Cartesian2(0, -28),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 900.0),
id: p.asset_id,
});
return { ...p, position, billboard, label };
});
console.log(`${records.length} markers in 2 collections`);
A collection batches its members into one draw call and one texture atlas, which is the difference between a scene that holds ten thousand markers and one that holds five hundred. Passing scene to the constructor is what lets heightReference work, because clamping needs access to the terrain and tile surfaces.
The two distance controls do different jobs. scaleByDistance keeps a marker readable near the camera and small far away; distanceDisplayCondition removes it entirely beyond a range, which is the only one that saves work. Text is the expensive part, so labels get a much shorter visibility range than their icons — 900 m against 6 km here.
3. Clamp markers to the right surface
for (const r of records) {
r.billboard.heightReference = r.category === "roof_sensor"
? Cesium.HeightReference.RELATIVE_TO_3D_TILE
: Cesium.HeightReference.CLAMP_TO_GROUND;
if (r.category === "roof_sensor") r.billboard.position = Cesium.Cartesian3.fromDegrees(
r.lon, r.lat, 1.5); // 1.5 m above whatever tile surface is beneath
}
Height references decide what a marker sits on, and the choice is per category rather than per layer. A hydrant belongs on the ground; a roof sensor belongs on the building, which means clamping to tile content rather than terrain. The tile-relative references are recent additions to CesiumJS, so check the version you ship against the reference names you use — an unsupported value is ignored and the marker falls to the ellipsoid, which looks like a data error.
For markers that must stay visible through geometry — a defect on the far side of a building that a user is navigating to — disableDepthTestDistance: Number.POSITIVE_INFINITY draws them on top of everything. Use it sparingly: a scene where every marker ignores depth loses all sense of which markers are near.
4. Cluster when markers crowd
const ds = await Cesium.GeoJsonDataSource.load("/web/assets_4326.geojson", { clampToGround: true });
viewer.dataSources.add(ds);
ds.clustering.enabled = true;
ds.clustering.pixelRange = 40;
ds.clustering.minimumClusterSize = 4;
const pinBuilder = new Cesium.PinBuilder();
ds.clustering.clusterEvent.addEventListener((clustered, cluster) => {
cluster.label.show = true;
cluster.label.text = String(clustered.length);
cluster.label.font = "bold 14px sans-serif";
cluster.label.fillColor = Cesium.Color.WHITE;
cluster.billboard.show = true;
cluster.billboard.image = pinBuilder
.fromColor(Cesium.Color.fromCssColorString("#1f6b8a"), 44)
.toDataURL();
cluster.billboard.verticalOrigin = Cesium.VerticalOrigin.BOTTOM;
});
Clustering works in screen space: markers within pixelRange pixels of each other collapse into one pin carrying a count. That is the behaviour users expect from a map, and it solves the readability problem as well as the performance one. The data-source route is the one with clustering support built in, so a scene with both — collections for a large static layer, a clustered data source for the interactive one — is a normal arrangement rather than a contradiction.
5. Keep only the nearest markers alive
const MAX_LABELS = 400;
let lastUpdate = 0;
viewer.scene.postRender.addEventListener(() => {
const now = performance.now();
if (now - lastUpdate < 250) return; // throttle: 4 Hz is plenty
lastUpdate = now;
const camera = viewer.scene.camera.positionWC;
for (const r of records) {
r.dist = Cesium.Cartesian3.distance(camera, r.position);
}
const visible = records
.filter((r) => r.dist < 1500)
.sort((a, b) => (b.priority - a.priority) || (a.dist - b.dist))
.slice(0, MAX_LABELS);
const keep = new Set(visible.map((r) => r.asset_id));
for (const r of records) {
const on = keep.has(r.asset_id);
if (r.label.show !== on) r.label.show = on;
}
});
This is the control that makes an arbitrarily large dataset behave. Labels are budgeted — at most four hundred on screen — and the budget is spent on the highest-priority markers first, then the nearest. Throttling to four updates a second is imperceptible and keeps the sort off the frame path. Toggling show only when it changes matters: assigning it every tick dirties the collection and forces a rebuild of the label batch.
Expected Output & Verification
18,204 points, 7 categories
18,204 markers in 2 collections
Verify three things: the frame rate with the layer on and off, the number of labels actually drawn, and that picking returns the identifier.
let shown = 0;
for (const r of records) if (r.label.show) shown++;
console.log(`labels shown: ${shown} of ${records.length}`);
const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction((m) => {
const picked = viewer.scene.pick(m.position);
console.log("picked asset:", picked && picked.id);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
The count of shown labels should track the budget, not the dataset — if it equals the dataset size, the culling loop is not running or show was overridden by a data source that owns the same entities. Picking a marker must return the asset identifier that was set on both the billboard and the label, so a click on either the icon or the text resolves to the same asset.
Performance Notes
- One collection per icon set. A collection packs its images into a texture atlas; mixing forty distinct icons in one collection is fine, but 4,000 unique images will thrash the atlas.
- Keep icons small and square — 32 to 64 px — and pre-scale them rather than relying on
scaleto shrink a 512 px source, which wastes atlas space. - Text is dearer than icons. Budget labels aggressively and let icons run further; users can see where things are long before they need to read tags.
- Throttle camera-driven work. A
postRenderhandler that sorts 18,000 records every frame costs more than the labels it is trying to save. - Use
requestRenderModefor scenes that are mostly static: with explicit render requests, a scene with thousands of markers costs nothing while nobody is moving the camera.
Common Errors
Markers sit at sea level under the terrain. heightReference needs the scene reference, which the collection constructor only gets if you pass it. Without it, clamping is ignored.
Labels flicker on and off as the camera moves. The culling threshold has no hysteresis, so markers at the boundary toggle every update. Cull at 1,500 m and restore at 1,400 m, or keep the last visible set and only change it when the difference is material.
Every label is drawn on top of the buildings. disableDepthTestDistance was set globally. Reserve it for a selected marker.
Clustering does nothing. Clustering is a property of a data source’s entity cluster, not of a primitive collection. A layer built with BillboardCollection has to implement its own grouping or be loaded as a data source instead.
Frequently Asked Questions
Should I use a data source or collections?
Collections for large, mostly static layers where you control visibility yourself; a data source when you want clustering, entity semantics and time dynamics for free. Mixing both in one scene is normal.
How do I avoid overlapping text without clustering?
Cesium’s LabelCollection has no general declutter, so the practical options are the distance and priority budget from step 5, shortening the text, or showing text only for a selection. A screen-space collision test in JavaScript is possible but expensive at scale.
Can labels carry the tileset’s own metadata?
Yes — read the feature properties from the tileset and create labels for the features in view, which keeps the label set to what is loaded. That works well for building identifiers and badly for a dataset that is not in the tiles.
Related Guides
- Vector Overlays on 3D Tiles — the other four overlay strategies
- Streaming Live Sensor Updates onto Tilesets — when the markers’ values change continuously
- Tuning Tileset Cache Bytes for Memory-Constrained Clients — the budget markers share with tiles
Back to Vector Overlays on 3D Tiles.