Tracking Down Z-Fighting Between Terrain and Buildings
Flickering where a building meets the ground has two completely different causes that look identical in a viewer: the two surfaces are genuinely coincident and the depth buffer cannot separate them, or they are not coincident at all and a datum mismatch has pushed one through the other. This page measures which one you have, in that order, because the remedies share nothing.
Why you hit this
Terrain and buildings arrive from separate pipelines with separate vertical references, and they meet at exactly the place a viewer looks first. The flicker is reported as a rendering bug, gets triaged as a depth-buffer problem, and is fixed with a depth-bias tweak — which works, hides a 33 cm datum error, and leaves every clearance and flood result quietly wrong. The check that distinguishes them takes minutes and belongs before any renderer setting is touched.
The datum half of this is covered in handling vertical datums and geoid separation, and the cross-stage framing in cross-section failure modes.
Prerequisites
- CesiumJS 1.107+ with both layers loaded, and the ability to query terrain height at a coordinate.
- Python 3.10+ with
pyproj>=3.6andrasterio>=1.3for the source-side check. - The vertical CRS of both the terrain and the building footprints, from their manifests rather than from memory.
Step-by-Step
1. Measure the actual gap, before touching the renderer
Sample terrain height and building base at the same coordinate and difference them.
async function footingGap(viewer, lon, lat, buildingBaseHeightM) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const [sampled] = await Cesium.sampleTerrainMostDetailed(
viewer.terrainProvider, [carto]);
return {
terrain: sampled.height,
building: buildingBaseHeightM,
gap: buildingBaseHeightM - sampled.height,
};
}
const samples = await Promise.all(FOOTINGS.map((f) =>
footingGap(viewer, f.lon, f.lat, f.baseHeight)));
const gaps = samples.map((s) => s.gap).sort((a, b) => a - b);
const median = gaps[gaps.length >> 1];
const spread = gaps[gaps.length - 1] - gaps[0];
console.log(`median gap ${(median * 100).toFixed(1)} cm, spread ${(spread * 100).toFixed(1)} cm`);
The two numbers decide everything that follows. A median gap near zero with a small spread means the surfaces really are coincident and the flicker is depth precision. A median gap of tens of centimetres, consistent across every footing, is a datum mismatch and no renderer setting should be changed.
2. If the gap is a datum offset, confirm it at the source
A gap that matches the local geoid separation is not a coincidence.
from pyproj import Transformer
import rasterio
with rasterio.open("dem_wgs84.tif") as ds:
print("terrain CRS:", ds.crs, "| compound:", ds.crs.is_compound if hasattr(ds.crs, "is_compound") else "unknown")
to_ellip = Transformer.from_crs("EPSG:32633+5941", "EPSG:4979", always_xy=True)
e, n, ortho = 598120.4, 6643880.1, 42.6
_, _, ellip = to_ellip.transform(e, n, ortho)
print(f"geoid separation here: {ellip - ortho:.3f} m")
If the measured gap and the separation agree to a few centimetres, the diagnosis is settled: one layer is on ellipsoidal heights and the other on orthometric. The fix is to convert the offending layer through a compound CRS, and the flicker disappears as a side effect.
3. If the surfaces really are coincident, look at the depth buffer
Depth precision is not uniform — it is concentrated near the camera, and the near plane sets how quickly it degrades.
function depthResolutionMetres(distanceM, near, far, bits = 24) {
// Non-logarithmic depth: resolution degrades with the square of distance.
const n = 2 ** bits;
return (distanceM * distanceM * (far - near)) / (near * far * n);
}
for (const near of [0.1, 1.0, 10.0]) {
const r = depthResolutionMetres(2000, near, 1e7);
console.log(`near ${near} m → ${(r * 1000).toFixed(2)} mm resolution at 2 km`);
}
console.log('logarithmic depth enabled:', viewer.scene.logarithmicDepthBuffer);
Two settings dominate. A near plane of 0.1 m throws away most of the buffer’s range on the first metre in front of the camera; raising it to 1 m improves resolution at distance by an order of magnitude and costs nothing unless the camera genuinely goes that close to geometry. And a logarithmic depth buffer, which CesiumJS enables by default where supported, distributes precision far better across a planetary range — a scene that flickers with it enabled is usually a datum problem after all.
4. Separate the surfaces deliberately where they must touch
Where a building genuinely sits on the terrain, a small deliberate offset is more honest than a renderer trick.
import numpy as np
CLEARANCE_M = 0.05 # 5 cm — below survey tolerance, above depth resolution
def sink_footings(building_vertices, terrain_height_fn, clearance=CLEARANCE_M):
"""Lower each building so its base sits `clearance` below the terrain surface."""
base_z = building_vertices[:, 2].min()
x, y = building_vertices[:, 0].mean(), building_vertices[:, 1].mean()
ground = terrain_height_fn(x, y)
shift = (ground - clearance) - base_z
out = building_vertices.copy()
out[:, 2] += shift
return out, shift
Sinking the building slightly into the terrain is preferable to floating it above: a building whose base is five centimetres below the ground surface is hidden by the terrain and looks correct from every angle, while one floating five centimetres above shows a visible gap at a grazing view. Five centimetres is comfortably below survey tolerance and comfortably above the depth resolution at any distance a viewer will look from.
5. Gate the gap so it cannot recur
def assert_footings_seated(samples, max_gap_m=0.15, max_spread_m=0.10):
gaps = sorted(s["gap"] for s in samples)
median = gaps[len(gaps) // 2]
spread = gaps[-1] - gaps[0]
problems = []
if abs(median) > max_gap_m:
problems.append(f"median footing gap {median*100:.1f} cm — likely a datum mismatch")
if spread > max_spread_m:
problems.append(f"footing gap spread {spread*100:.1f} cm — terrain and buildings disagree locally")
return problems
The two thresholds catch different things. A large median with a small spread is a uniform datum offset. A small median with a large spread is a terrain resolution problem — the DEM is too coarse to follow the ground under each footing, which is a different fix again.
Expected Output & Verification
A representative check over forty footings:
median gap -33.4 cm, spread 4.1 cm
geoid separation here: -33.412 m
near 0.1 m → 6.10 mm resolution at 2 km
logarithmic depth enabled: true
That is a settled diagnosis: the median gap matches the geoid separation to within a centimetre, the spread is small, and the depth buffer is already logarithmic. The problem is a datum mismatch and no renderer change would have addressed it.
After the fix, the same check should read a median within a few centimetres of zero and a spread under ten, and the flicker should be gone without any depth setting having changed. If flicker persists once the gap is genuinely zero, that is when the near plane and the deliberate clearance in step 4 apply.
Common Errors
A depth bias made it go away, so it was a rendering problem. It made the symptom go away. Measure the gap first; a bias applied over a datum error hides the evidence and leaves every elevation-dependent product wrong.
The gap varies wildly between footings. The terrain is too coarse to follow the ground beneath each building, so each footing sits on an interpolated cell rather than on measured ground. That needs a finer DEM under the built area, not a datum fix.
Flicker only at certain camera angles. The two surfaces intersect rather than being merely coincident, which is the datum case seen from a grazing view. The gap measurement will show a sign change across the site.
Everything is correct and the flicker persists on one machine. That device fell back to a non-logarithmic depth buffer. Check scene.logarithmicDepthBuffer on the affected client rather than on yours.
Frequently Asked Questions
Should buildings always be sunk into the terrain?
Where they sit on it, yes, by a few centimetres. It is invisible, it is inside survey tolerance, and it removes the whole class of coincident-surface flicker without any renderer configuration.
Does raising the near plane have a downside?
Only if the camera goes closer to geometry than the new value, at which point geometry disappears. For a city viewer whose camera stays above street level, a near plane of one metre is safe and buys an order of magnitude of depth precision.
Can this be checked without a browser?
The gap can, and that is the check that matters. Sample the DEM at each footing coordinate with rasterio and difference it against the building base height from the footprint layer — no renderer involved.
A closing note on where this check belongs. It is cheap enough to run on every build — forty terrain samples and forty differences — and it catches a class of fault that no other gate sees, because both layers are individually valid and the defect exists only in their relationship. Running it as part of the tileset gate, alongside the bounding-volume containment check, costs a second and turns a datum mismatch from something a viewer reports into something a build refuses.
Related Guides
- Cross-Section Failure Modes in Digital Twins — why this fault spans two pipelines
- Handling Vertical Datums and Geoid Separation — the underlying cause in most cases
- Streaming & Runtime Diagnostics — the wider runtime symptom framework