Styling Tiles by Metadata with Cesium3DTileStyle

This page writes the style expressions that turn a metadata-bearing tileset into a thematic map — colour ramps by construction year, filters by building function, point-cloud styling by classification — and covers the expression constructs that are cheap, the ones that are expensive, and how to keep a legend honest about missing data.

Why you hit this

Once the metadata from defining tileset and group metadata is in the tileset, styling is how anyone gets value from it. A Cesium3DTileStyle is evaluated per feature and compiled to a shader, so the colouring happens on the GPU at no per-frame cost — which makes it the right mechanism for thematic display of hundreds of thousands of features, and a trap if the expression is written as if it were JavaScript.

The two things that go wrong are performance and honesty. An expression with 200 conditions compiles to a shader that stalls; a colour ramp that treats a missing construction year as 1900 produces a map with a confident cluster of buildings that do not exist.

Prerequisites

  • A 3D Tiles 1.1 tileset with metadata, loaded in CesiumJS 1.100 or later.
  • Property names as declared in the schema — they are case-sensitive in expressions.
  • Browser dev tools for the frame-time measurements.

Step-by-Step

1. Start with the expression model

javascript
const style = new Cesium.Cesium3DTileStyle({
  // Every top-level key is an expression evaluated per feature.
  show: '${quality} !== "estimated"',
  color: {
    conditions: [
      ['${yearOfConstruction} === 0', "color('#9aa3ad')"],       // noData first
      ['${yearOfConstruction} < 1900', "color('#7c3f2e')"],
      ['${yearOfConstruction} < 1945', "color('#b0413e')"],
      ['${yearOfConstruction} < 1970', "color('#c46a3d')"],
      ['${yearOfConstruction} < 1990', "color('#d9a05b')"],
      ['true', "color('#1f6b8a')"],
    ],
  },
  pointSize: '3.0',
});

tileset.style = style;

The expression language is deliberately small: property references with ${name}, arithmetic, comparisons, &&/||/!, a set of built-in functions, and conditions arrays that evaluate top to bottom and stop at the first true test. There is no iteration, no function definition and no access to anything outside the feature.

conditions evaluating in order is the property to build on. Putting the no-data test first means every subsequent condition can assume a real value, which is both faster and clearer than guarding each one.

show returning false removes the feature from rendering entirely, which is cheaper than colouring it transparent — a transparent feature still rasterises and still participates in blending.

2. Handle no-data before anything else

javascript
export function yearRampStyle({ domain = [1850, 2026], bands = 7,
                                noDataValue = 0 } = {}) {
  const [lo, hi] = domain;
  const palette = ['#5b2f22', '#7c3f2e', '#b0413e', '#c46a3d',
                   '#d9a05b', '#7f9f6f', '#1f6b8a'];
  const conditions = [
    [`\${yearOfConstruction} === ${noDataValue}`, "color('#9aa3ad')"],
  ];
  for (let b = 0; b < bands; b++) {
    const edge = lo + ((hi - lo) * (b + 1)) / bands;
    conditions.push([`\${yearOfConstruction} < ${Math.round(edge)}`,
                     `color('${palette[b % palette.length]}')`]);
  }
  conditions.push(['true', `color('${palette[palette.length - 1]}')`]);

  return {
    style: new Cesium.Cesium3DTileStyle({ color: { conditions } }),
    legend: [
      { label: 'no recorded year', colour: '#9aa3ad', noData: true },
      ...Array.from({ length: bands }, (_, b) => ({
        label: `${Math.round(lo + ((hi - lo) * b) / bands)}`
             + `${Math.round(lo + ((hi - lo) * (b + 1)) / bands) - 1}`,
        colour: palette[b % palette.length],
      })),
    ],
  };
}

Generating the legend from the same code that generates the conditions is the only way to keep them consistent. A legend written separately drifts the first time a band edge changes, and a wrong legend on a thematic map is worse than no legend.

The no-data entry belongs in the legend, visibly, with its own colour. A map where 8% of buildings are grey and the legend does not say why invites the reader to assume the grey means something.

The noData value has to match the schema’s declaration. Where the schema says noData: 0, the expression tests for 0; where the property is simply absent, the test is ${yearOfConstruction} === undefined, and the two are not interchangeable.

Two ways to handle a missing construction year Two histograms of the same 412000 buildings by construction decade. On the left, the missing-year value of zero falls into the oldest band, producing a spike of 33000 buildings apparently built before 1860 that do not exist. On the right, the no-data test runs first and those buildings are shown grey and counted separately in the legend, leaving the real distribution visible. no-data not handled no-data tested first 33 k a pre-1860 spike that is not real grey 33 k counted as "no recorded year"
The same data, and one condition's ordering decides whether the map shows a fabricated cluster of Victorian buildings.

3. Filter with show, and combine conditions cheaply

javascript
export function buildFilterStyle({ functions = null, minHeight = null,
                                   maxHeight = null, districts = null,
                                   qualityAtLeast = null } = {}) {
  const clauses = [];
  if (functions?.length) {
    clauses.push('(' + functions.map((f) => `\${function} === "${f}"`).join(' || ') + ')');
  }
  if (minHeight !== null) clauses.push(`\${measuredHeight} >= ${minHeight}`);
  if (maxHeight !== null) clauses.push(`\${measuredHeight} <= ${maxHeight}`);
  if (minHeight !== null || maxHeight !== null) {
    clauses.push('${measuredHeight} > 0');              // exclude the noData sentinel
  }
  if (districts?.length) {
    clauses.push('(' + districts.map((d) => `\${districtCode} === "${d}"`).join(' || ') + ')');
  }
  if (qualityAtLeast) {
    const rank = { surveyed: 0, photogrammetric: 1, extruded_footprint: 2, estimated: 3 };
    const allowed = Object.entries(rank)
      .filter(([, v]) => v <= rank[qualityAtLeast])
      .map(([k]) => `\${quality} === "${k}"`);
    clauses.push('(' + allowed.join(' || ') + ')');
  }
  const show = clauses.length ? clauses.join(' && ') : 'true';
  return { style: new Cesium.Cesium3DTileStyle({ show }), show,
           clauses: clauses.length };
}

console.log(buildFilterStyle({ functions: ['commercial', 'mixed'], minHeight: 12,
                               qualityAtLeast: 'photogrammetric' }).show);

Building the expression as a string from structured filter state, rather than writing expressions by hand for each combination, is what keeps a filter UI maintainable. Every filter becomes a clause, absent filters contribute nothing, and the whole thing is one && chain.

The height clause pairs with an explicit > 0 test because the no-data sentinel is negative — without it, measuredHeight <= 40 includes every building whose height is unknown, which is exactly backwards.

Enum comparisons in an expression use the enum’s name as a string, not its integer value, which is a convenience the client provides and a frequent source of confusion when debugging against the raw property table.

4. Style point clouds by classification

javascript
export const LIDAR_CLASS_STYLE = new Cesium.Cesium3DTileStyle({
  color: {
    conditions: [
      ['${Classification} === 2', "color('#8a7a5c')"],   // ground
      ['${Classification} === 3', "color('#9fbf7a')"],   // low vegetation
      ['${Classification} === 4', "color('#74a85a')"],   // medium vegetation
      ['${Classification} === 5', "color('#4f7a4d')"],   // high vegetation
      ['${Classification} === 6', "color('#b0413e')"],   // building
      ['${Classification} === 9', "color('#1f6b8a')"],   // water
      ['${Classification} === 11', "color('#5b6471')"],  // road surface
      ['${Classification} === 1', "color('#c9c2b4')"],   // unclassified
      ['true', "color('#9aa3ad')"],
    ],
  },
  pointSize: 'clamp(${POSITION}[2] * 0.0 + 3.0, 2.0, 6.0)',
  show: '${Classification} !== 7 && ${Classification} !== 18',   // drop noise
});

export const INTENSITY_STYLE = new Cesium.Cesium3DTileStyle({
  color: {
    conditions: [
      ['${Intensity} === undefined', "color('#9aa3ad')"],
      ['true', 'rgb(clamp(${Intensity} / 4, 0, 255), '
             + 'clamp(${Intensity} / 4, 0, 255), '
             + 'clamp(${Intensity} / 3, 0, 255))'],
    ],
  },
  pointSize: '2.5',
});

Point clouds are where style expressions earn the most, because there is no other way to recolour 200 million points interactively. Both examples are a handful of comparisons per point, evaluated in a shader, so switching between them is instant regardless of the cloud’s size.

Dropping ASPRS classes 7 and 18 — low and high noise — with show rather than colouring them is the right call: they are usually a few percent of the points and removing them from rasterisation is a measurable frame-rate gain on a dense cloud.

The rgb(...) form computing colour arithmetically, rather than bucketing into conditions, is the cheaper construct for a continuous variable and produces a smooth ramp. It is available because the expression language has arithmetic and clamp.

5. Keep the expression cheap

javascript
export function expressionCost(styleJson) {
  /** A rough cost model: conditions and property reads become shader work. */
  const text = JSON.stringify(styleJson);
  const conditions = (text.match(/\[\s*"/g) || []).length;
  const properties = new Set(text.match(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g) || []);
  const regexUse = (text.match(/regExp|regexp/g) || []).length;
  const stringCompares = (text.match(/===\s*\\?"/g) || []).length;

  const findings = [];
  if (conditions > 16) {
    findings.push({ issue: `${conditions} conditions`, severity: 'high',
                    why: 'each becomes a branch; bucket into ≤16 bands' });
  }
  if (regexUse) {
    findings.push({ issue: `${regexUse} regular expression(s)`, severity: 'high',
                    why: 'evaluated per feature on the CPU; precompute a property instead' });
  }
  if (stringCompares > 24) {
    findings.push({ issue: `${stringCompares} string comparisons`, severity: 'medium',
                    why: 'use enums, which compare as integers' });
  }
  if (properties.size > 6) {
    findings.push({ issue: `${properties.size} properties referenced`, severity: 'low',
                    why: 'each property table column is fetched with the content' });
  }
  return { conditions, properties: [...properties], findings,
           verdict: findings.some((f) => f.severity === 'high') ? 'will hurt'
                  : findings.length ? 'acceptable' : 'cheap' };
}

console.log(expressionCost(yearRampStyle().style.style));

Three constructs account for nearly every styling performance problem. Long condition lists become long branch chains; regular expressions cannot be evaluated in a shader at all and force per-feature CPU evaluation; and string comparisons are far more expensive than integer ones, which is the practical argument for enums in the schema.

A style referencing many properties has a subtler cost: every referenced property table column is fetched with the tile content, so a style that touches eight properties downloads eight columns for every tile whether or not the user looks at them.

Expression constructs ranked by cost A table of five style expression constructs with their relative cost and the reason. Arithmetic with clamp is the cheapest and produces a smooth ramp. Up to sixteen bucketed conditions compile to a short branch chain. Enum comparisons are integer comparisons. String comparisons are an order of magnitude more expensive. A regular expression cannot run in a shader at all and forces per-feature evaluation on the CPU. constructcostwhy rgb() with clamp()cheapestone expression for any range up to 16 conditionscheapa short branch chain in the shader enum comparisoncheapinteger comparison string comparison10× an enumstring work in a shader regExp()seconds per applycannot run in a shader at all The top three are free at any feature count; the bottom row is why enums belong in the schema. A style with 200 conditions compiles to 200 branches and stalls on apply.
Arithmetic beats conditions, enums beat strings, and a regular expression leaves the GPU entirely.

6. Switch styles without stalling

javascript
export class StyleSwitcher {
  constructor(tileset) {
    this.tileset = tileset;
    this.cache = new Map();
    this.current = null;
  }

  register(name, factory) {
    this.cache.set(name, { factory, style: null });
    return this;
  }

  apply(name, options = {}) {
    const entry = this.cache.get(name);
    if (!entry) throw new Error(`unknown style ${name}`);
    const key = `${name}:${JSON.stringify(options)}`;
    if (this.current === key) return { applied: false, reason: 'already current' };
    if (!entry.style || entry.key !== key) {
      const built = entry.factory(options);
      entry.style = built.style ?? built;
      entry.legend = built.legend;
      entry.key = key;
    }
    const t0 = performance.now();
    this.tileset.style = entry.style;
    this.current = key;
    return { applied: true, name, legend: entry.legend,
             applyMs: Number((performance.now() - t0).toFixed(2)) };
  }

  /** Change only a numeric threshold: cheaper than rebuilding the whole style. */
  updateThreshold(name, propertyName, value) {
    const entry = this.cache.get(name);
    if (!entry?.style) return { updated: false };
    entry.style[propertyName] = value;          // e.g. style.pointSize
    this.tileset.makeStyleDirty();
    return { updated: true, property: propertyName, value };
  }
}

Caching the built style objects matters because assigning tileset.style with a structurally new expression triggers a shader recompile, which is tens of milliseconds and visible as a hitch. Assigning a previously used style object reuses the compiled program.

makeStyleDirty() is the mechanism for re-evaluating an existing style against changed property values without recompiling — which is what the live-data pattern in streaming live sensor updates onto tilesets relies on.

Style application cost by construct A bar chart of measured milliseconds to apply a style to a tileset with 412000 features. Eight bucketed conditions on an enum take 6 milliseconds. Sixteen conditions take 11 milliseconds. Arithmetic colour with clamp takes 5 milliseconds. Two hundred string conditions take 340 milliseconds. A style containing a regular expression takes 1120 milliseconds because it cannot be evaluated in a shader. 8 bucketed enum conditions: 6 ms 16 conditions: 11 ms arithmetic rgb() with clamp: 5 ms 200 string conditions: 340 ms one regular expression in the style: 1,120 ms — evaluated per feature on the CPU 412,000 features; the first three recompile a shader, the last two do not compile at all
Bucketed conditions and arithmetic are milliseconds; a regular expression is a second, because it leaves the GPU.

Expected Output & Verification

text
${function} === "commercial" || ${function} === "mixed" && ${measuredHeight} >= 12 && …
{ conditions: 9, properties: ['${yearOfConstruction}'], findings: [], verdict: 'cheap' }
{ applied: true, name: 'yearRamp', applyMs: 6.41, legend: [ … 8 entries … ] }

A cheap verdict with one property referenced and nine conditions is the target shape for a thematic style. The 6.4 ms application cost is a one-off shader compile, not a per-frame cost.

Verify the style produces the distribution you expect, rather than trusting that it looks right:

javascript
export function styleDistributionCheck(tileset, { property = 'yearOfConstruction',
                                                  bands = null } = {}) {
  const counts = new Map();
  let features = 0, missing = 0;
  for (const tile of tileset._selectedTiles ?? []) {
    const content = tile.content;
    if (!content || typeof content.featuresLength !== 'number') continue;
    for (let i = 0; i < content.featuresLength; i++) {
      const f = content.getFeature(i);
      const v = f.getProperty(property);
      features += 1;
      if (v === undefined || v === null || v === 0) { missing += 1; continue; }
      const key = bands
        ? bands.find((b) => v < b) ?? 'above'
        : String(v);
      counts.set(key, (counts.get(key) ?? 0) + 1);
    }
  }
  const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
  return {
    features, missing,
    missingFraction: Number((missing / Math.max(features, 1)).toFixed(3)),
    bands: top.map(([band, n]) => ({ band, n,
      share: Number((n / Math.max(features - missing, 1)).toFixed(3)) })),
  };
}

console.log(styleDistributionCheck(tileset,
  { bands: [1900, 1945, 1970, 1990, 2010, 2027] }));

The missingFraction is the number to put in front of whoever asked for the map. Eight percent missing is a caveat; forty percent missing means the map is mostly about data coverage rather than about buildings, and the honest presentation is different.

Then verify the style is not costing frame time, by measuring with it on and off:

javascript
export async function styleFrameCostCheck(viewer, tileset, style,
                                          { frames = 120 } = {}) {
  const sample = async (label) => {
    const times = [];
    for (let i = 0; i < frames; i++) {
      const t0 = performance.now();
      viewer.scene.render();
      times.push(performance.now() - t0);
      await new Promise((r) => requestAnimationFrame(r));
    }
    times.sort((a, b) => a - b);
    return { label,
             medianMs: Number(times[frames >> 1].toFixed(2)),
             p95Ms: Number(times[Math.floor(frames * 0.95)].toFixed(2)) };
  };

  tileset.style = undefined;
  const off = await sample('no style');
  tileset.style = style;
  const on = await sample('styled');
  return {
    off, on,
    medianDeltaMs: Number((on.medianMs - off.medianMs).toFixed(2)),
    p95DeltaMs: Number((on.p95Ms - off.p95Ms).toFixed(2)),
    acceptable: on.p95Ms - off.p95Ms < 1.5,
  };
}

A well-written style adds well under a millisecond to the frame, because it is a few comparisons in a shader that already runs. A delta of several milliseconds means the expression is being evaluated on the CPU — check for regular expressions and for property types the shader cannot handle.

Performance Notes

  • Keep conditions at 16 or fewer. Beyond that the branch chain shows up in the fragment shader.
  • Prefer arithmetic to conditions for continuous variables. rgb(clamp(...)) is one expression regardless of the range.
  • Enum comparisons beat string comparisons by roughly an order of magnitude, which is the practical reason to declare enums in the schema.
  • Never use regExp() in a style. It forces per-feature CPU evaluation and costs seconds on a city.
  • Cache built style objects. Re-assigning the same object avoids a recompile; a structurally new expression does not.
  • show: false is cheaper than a transparent colour. Hidden features are not rasterised.
  • Every referenced property is downloaded. A style touching one property fetches one column per tile.

Common Errors

Style has no effect. The property name does not match the schema, including case, or the features carry the property under a different name than the tileset metadata declares.

Everything is the fallback colour. The conditions array has no matching test — usually because a numeric property is being compared against a string, or an enum against its integer value rather than its name.

A fabricated cluster at one end of the ramp. No-data sentinel falling into a real band. Test for it first.

Frame rate drops when the style is applied. A regular expression, or more than a few dozen string comparisons.

Applying a style takes half a second. A condition per feature category. Bucket instead.

show filter excludes buildings with valid heights. The no-data sentinel is negative and passes a <= max test. Add the > 0 clause.

Legend and map disagree. They are generated separately. Generate both from one function.

Frequently Asked Questions

Can a style reference tile or group metadata?

Yes — group and tile metadata properties are available to expressions alongside feature properties, which is what makes district-level filtering a single condition rather than a per-feature test.

How do I show a continuous ramp rather than bands?

Use arithmetic: normalise the property into 0–1 and build the colour with rgb() or hsl(). It is cheaper than banding and produces a smooth result.

Can I style by something not in the tileset?

Only by writing it onto features at runtime with setProperty and referencing that name, which is the live-data pattern. There is no way for an expression to reach outside the feature.

Back to Tileset Metadata and 3D Tiles Next.