A 3D viewer shows nothing or a black screen
On this page
Problem statement
The file loaded. There are no errors in the console, the network tab shows the model arriving, and the viewport is empty β or black, or showing a sliver at the edge that turns out to be the whole district, half a kilometre tall and lying on its side.
3D viewers fail silently by design: a renderer draws what is in front of the camera, and everything that puts geometry somewhere else produces exactly the same blank screen. There are six common causes, and the fastest way to distinguish them is to stop looking at the viewer and inspect the file.
This page makes each of them for real from one model β 1,990 LoD2 buildings of a Den Haag tile from the CityJSON sample data, in the Dutch national grid β and shows what the file says about each.
Quick answer
Load the model in Python and print four numbers before touching the viewer:
import numpy as np
import trimesh
for path in ["city_rd.glb", "city.glb", "empty.glb"]:
m = trimesh.load(path)
geoms = list(m.geometry.values())
print(f"{path:12} bounds {np.round(m.bounds, 1).tolist() if m.bounds is not None else None}")
print(f"{'':12} extents {np.round(m.extents, 1).tolist() if m.extents is not None else None}, "
f"geometries {len(geoms)}, faces {sum(len(g.faces) for g in geoms):,}")
city_rd.glb bounds [[78342.8, 457631.2, 3.0], [79020.6, 458246.8, 37.5]]
extents [677.8, 615.6, 34.5], geometries 1990, faces 35,602
city.glb bounds [[-354.2, -6.0, -353.9], [323.6, 28.5, 261.7]]
extents [677.8, 34.5, 615.6], geometries 1990, faces 35,602
empty.glb bounds None
extents None, geometries 0, faces 0
city_rd.glb is the model exported straight from the national grid: its bounds are in the hundreds of thousands, and its largest extent after the ground plan is 615.6 m in y β which glTF reads as up. city.glb is the same model recentred and rotated: centred on zero, 34.5 m tall in y. empty.glb loaded without an error and has no geometry at all.
| what you see | cause |
|---|---|
| bounds in the hundreds of thousands | not recentred β the model is far from the camera |
| extents with a ground-plan-sized value in y | Z-up not converted to Y-up |
| faces = 0, or no bounds | nothing was exported |
| extents of a fraction of a unit | a unit mismatch, or a model in degrees |
| negative volume | faces inside out β expect black or invisible |
| everything looks right | it is a camera, material or viewer problem |
Camera, up axis, export, scale and normals
1. Check the model is where the camera is
A model on a national grid has coordinates in the hundreds of thousands of metres. A camera looking at the origin does not see it, and at that distance it is usually beyond the far clipping plane as well. in_view() below projects every vertex through a camera like three.js's default PerspectiveCamera β 50Β° field of view, near 0.1, far 2,000 β placed 800 units from the origin, and reports how much of the model it can see and how big it would be on a 1,000-pixel screen:
def in_view(path, eye=(0, 400, 700), fov=50, near=0.1, far=2000, width_px=1000):
"""Share of vertices inside a look-at-origin camera's frustum, and their on-screen width."""
m = trimesh.load(path)
if not m.geometry:
return 0.0, 0.0
v = np.vstack([g.vertices for g in m.geometry.values()])
eye = np.asarray(eye, float)
forward = -eye / np.linalg.norm(eye)
right = np.cross(forward, [0, 1, 0]); right /= np.linalg.norm(right)
up = np.cross(right, forward)
rel = v - eye
depth = rel @ forward
t = np.tan(np.radians(fov) / 2)
x = (rel @ right) / (depth * t)
y = (rel @ up) / (depth * t)
seen = (depth > near) & (depth < far) & (np.abs(x) <= 1) & (np.abs(y) <= 1)
width = (x[seen].max() - x[seen].min()) / 2 * width_px if seen.any() else 0.0
return seen.mean(), width
for path in ["city_rd.glb", "city.glb"]:
share, width = in_view(path)
print(f"{path:12} {share:6.1%} of vertices in view, {width:5.0f} px wide")
city_rd.glb 0.0% of vertices in view, 0 px wide
city.glb 98.7% of vertices in view, 952 px wide
The camera that shows 98.7% of the recentred model and draws it 952 pixels wide sees none of the national-grid one. Recentre on a local origin and record the offset in a sidecar, because the viewer will need it again to put the model back on the map. Recentring also matters for a second reason: glTF stores positions as 32-bit floats, and at a northing of 457,000 m a 32-bit float can only step in 3.1 cm increments.
rd = trimesh.load("city_rd.glb")
exact = np.vstack(meshes_rd)
stored = np.vstack([g.vertices for g in rd.geometry.values()])
print(f"largest vertex shift from float32 storage: {np.abs(stored - exact).max() * 100:.1f} cm")
print(f"float32 spacing at 457,000: {np.spacing(np.float32(457000)) * 100:.2f} cm")
largest vertex shift from float32 storage: 1.5 cm
float32 spacing at 457,000: 3.12 cm
Exported straight from the national grid, the tile's vertices moved by up to 1.5 cm β half a float32 step β before any viewer saw them.
2. Check the up axis
glTF is Y-up. A Z-up model exported without the β90Β° rotation about X is lying on its side:
for path in ["city_zup.glb", "city.glb"]:
share, width = in_view(path)
print(f"{path:12} extents {np.round(trimesh.load(path).extents, 1).tolist()}, {share:.0%} in view, {width:.0f} px wide")
city_zup.glb extents [677.8, 615.6, 34.5], 100% in view, 876 px wide
city.glb extents [677.8, 34.5, 615.6], 99% in view, 952 px wide
Both are fully in view β the camera cannot tell you which is right. The extents can: the recentred Z-up file is 615.6 m tall in glTF's y, the height of the tile's northβsouth extent, and 34.5 m deep; the rotated one is 34.5 m tall. With an orbit camera the sideways version often shows up as a thin wall seen edge-on, which reads as nothing.
3. Check something was actually exported
An empty scene, a mesh with zero faces, or a geometry list of length zero all produce a file that loads:
print(type(trimesh.util.concatenate([])).__name__, "with", len(trimesh.util.concatenate([]).faces), "faces")
print("empty.glb:", Path("empty.glb").stat().st_size, "bytes; bounds", trimesh.load("empty.glb").bounds)
Trimesh with 0 faces
empty.glb: 172 bytes; bounds None
trimesh.util.concatenate on an empty list β what a filter that matched no buildings hands on β returns an empty mesh without complaint, and exporting it writes a valid 172-byte glTF. A file that small cannot contain a building.
4. Check the scale
glTF has no unit field; metres are the convention. A model whose plan coordinates are still in degrees is the worst case, because its height is usually in metres:
deg = trimesh.load("city_degrees.glb")
print("extents:", np.round(deg.extents, 4).tolist())
share, width = in_view("city_degrees.glb")
print(f"{share:.0%} of vertices in view, {width:.2f} px wide")
extents: [0.0099, 34.478, 0.0055]
100% of vertices in view, 0.01 px wide
Ten thousandths of a unit across and 34.5 units tall: in a viewer that is a vertical line, and from 800 units away it is less than a pixel wide. A model in centimetres or feet fails the same way in the other direction.
5. Check the normals and the winding
A black model that is present and correctly placed is usually a lighting problem. Faces wound the wrong way have normals pointing inwards, and the renderer is looking at back faces β invisible with back-face culling, unlit without it. Winding consistency does not catch it when every face is reversed; the sign of the volume does:
inside_out = trimesh.load("city_inside_out.glb")
buildings = list(inside_out.geometry.values())
print("winding consistent:", sum(b.is_winding_consistent for b in buildings), "of", len(buildings))
print("negative volume: ", sum(b.volume < 0 for b in buildings), "of", len(buildings))
for b in buildings:
trimesh.repair.fix_inversion(b)
print("after fix_inversion:", sum(b.volume < 0 for b in buildings), "negative")
winding consistent: 1990 of 1990
negative volume: 1990 of 1990
after fix_inversion: 5 negative
Every building is still consistently wound β consistently backwards β and every one has a negative volume. fix_inversion flips a mesh whose volume is negative, which needs a closed mesh to mean anything: 1,985 of the 1,990 buildings are watertight, and the five it left inside out are the five that are not.
6. Check the material
A glTF with no material gets a default; a glTF with a fully transparent or fully black material gets what it asked for:
import struct
def gltf_json(path):
"""The JSON chunk of a .glb: 12-byte header, then chunk length, chunk type, chunk."""
data = Path(path).read_bytes()
length = struct.unpack_from("<I", data, 12)[0]
return json.loads(data[20:20 + length])
for path in ["city.glb", "city_black.glb"]:
materials = gltf_json(path).get("materials", [])
colours = {str(m.get("pbrMetallicRoughness", {}).get("baseColorFactor")) for m in materials}
print(f"{path:15} {len(materials)} material(s), base colours {sorted(colours)}")
city.glb 0 material(s), base colours []
city_black.glb 1 material(s), base colours ['[0.0, 0.0, 0.0, 1.0]']
city.glb has no material at all, so each viewer applies its own default. city_black.glb is the same geometry with a base colour of zero: present, correctly placed, and black under any light.
7. For 3D Tiles, check the tileset before the payload
A tileset whose region bounding volume is in degrees rather than radians places the tile on the wrong side of the planet, and a root geometricError of 0 means the client never refines. Both load without error; check_tileset() in Example 3 finds them.
8. Look at it in a second viewer
If it renders in one viewer and not another, the problem is the viewer's expectations β extensions, up-axis handling, Draco support β rather than the file.
A pre-flight check for a Den Haag tile
The data used on this page
The Den Haag tile DenHaag_01 from the CityJSON v2.0 sample datasets (source: Gemeente Den Haag, CC BY 4.0), in RD New (EPSG:7415). The setup builds a mesh per LoD2 building and writes the variants this page inspects: the model straight from the national grid, recentred but Z-up, recentred and Y-up, with plan coordinates in degrees, inside out, black, and empty.
import json
import os
from pathlib import Path
import numpy as np
import requests
import trimesh
from pyproj import Transformer
if not os.path.exists("model.city.json"):
reply = requests.get("https://3d.bk.tudelft.nl/opendata/cityjson/3dcities/v2.0/DenHaag_01.city.json", timeout=300)
reply.raise_for_status()
open("model.city.json", "wb").write(reply.content)
d = json.load(open("model.city.json"))
V = np.array(d["vertices"]) * d["transform"]["scale"] + d["transform"]["translate"]
meshes_rd = []
buildings = []
for oid, o in d["CityObjects"].items():
for g in o.get("geometry", []):
if g["type"] != "Solid" or "RelativeRidgeHeight" not in o.get("attributes", {}):
continue
faces = [[ring[0], ring[k], ring[k + 1]]
for shell in g["boundaries"] for surface in shell
for ring in surface[:1] for k in range(1, len(ring) - 1)]
m = trimesh.Trimesh(V, faces, process=False)
m.remove_unreferenced_vertices()
buildings.append(m)
meshes_rd.append(m.vertices.copy())
def scene_of(meshes):
return trimesh.Scene([trimesh.Trimesh(m.vertices.copy(), m.faces.copy(), process=False) for m in meshes])
origin = np.vstack(meshes_rd).mean(axis=0).round(0)
z_to_y = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
local = [trimesh.Trimesh(b.vertices - origin, b.faces, process=False) for b in buildings]
scene_of(buildings).export("city_rd.glb")
scene_of(local).export("city_zup.glb")
yup = [m.copy() for m in local]
for m in yup:
m.apply_transform(z_to_y)
scene_of(yup).export("city.glb")
to_wgs84 = Transformer.from_crs(28992, 4326, always_xy=True)
degrees = []
for b in buildings:
lon, lat = to_wgs84.transform(b.vertices[:, 0], b.vertices[:, 1])
m = trimesh.Trimesh(np.column_stack([lon - 4.3, lat - 52.08, b.vertices[:, 2]]), b.faces, process=False)
m.apply_transform(z_to_y)
degrees.append(m)
scene_of(degrees).export("city_degrees.glb")
scene_of([trimesh.Trimesh(m.vertices, m.faces[:, ::-1], process=False) for m in yup]).export("city_inside_out.glb")
black = trimesh.visual.material.PBRMaterial(baseColorFactor=[0, 0, 0, 255])
trimesh.Scene([trimesh.Trimesh(m.vertices, m.faces, process=False,
visual=trimesh.visual.TextureVisuals(material=black)) for m in yup]).export("city_black.glb")
trimesh.util.concatenate([]).export("empty.glb")
Path("sidecar.json").write_text(json.dumps({"crs": "EPSG:7415", "origin": origin.tolist(), "up": "Y"}))
print(f"{len(buildings):,} buildings, {sum(len(b.faces) for b in buildings):,} triangles, origin {origin.tolist()}")
print({p.name: f"{p.stat().st_size // 1024} KB" for p in sorted(Path('.').glob('*.glb'))})
1,990 buildings, 35,602 triangles, origin [78697.0, 457893.0, 9.0]
{'city.glb': '1158 KB', 'city_black.glb': '1183 KB', 'city_degrees.glb': '1177 KB', 'city_inside_out.glb': '1158 KB', 'city_rd.glb': '1107 KB', 'city_zup.glb': '1158 KB', 'empty.glb': '0 KB'}
Example 1 β the pre-flight check
def preflight(path, expect_metres=True):
scene = trimesh.load(path)
geoms = list(scene.geometry.values())
faces = sum(len(g.faces) for g in geoms)
if faces == 0:
return {"faces": 0, "problems": ["no faces β nothing was exported"]}
bounds, extents = scene.bounds, scene.extents
problems = []
if np.abs(bounds).max() > 1e4:
problems.append(f"coordinates up to {np.abs(bounds).max():,.0f} β recentre the model")
if expect_metres and min(extents[0], extents[2]) < 1:
problems.append(f"plan extent {min(extents[0], extents[2]):.4f} β units look wrong (degrees?)")
if extents[1] > 3 * min(extents[0], extents[2]) and extents[1] > 100:
problems.append(f"y extent {extents[1]:.0f} β is this Z-up data that was not rotated?")
closed = [g for g in geoms if g.is_watertight]
if closed and sum(g.volume < 0 for g in closed) > len(closed) / 2:
problems.append("most closed meshes have negative volume β faces are inside out")
return {"faces": faces, "extents": np.round(extents, 2).tolist(), "problems": problems}
for path in ["city_rd.glb", "city_zup.glb", "city_degrees.glb", "city_inside_out.glb", "empty.glb", "city.glb"]:
print(f"{path:20} {preflight(path)['problems'] or 'ok'}")
city_rd.glb ['coordinates up to 458,247 β recentre the model', 'y extent 616 β is this Z-up data that was not rotated?']
city_zup.glb ['y extent 616 β is this Z-up data that was not rotated?']
city_degrees.glb ['plan extent 0.0055 β units look wrong (degrees?)']
city_inside_out.glb ['most closed meshes have negative volume β faces are inside out']
empty.glb ['no faces β nothing was exported']
city.glb ok
Every broken variant is caught by a property of the file, and the correct one passes. The black model is not in the list because nothing about its geometry is wrong β that is the one the viewer, or a look at the materials, has to find.
Example 2 β recentre and rotate, then verify
def fix_for_viewer(path, out="fixed.glb", up="Z"):
scene = trimesh.load(path)
mesh = trimesh.util.concatenate(list(scene.geometry.values()))
origin = mesh.bounds.mean(axis=0)
mesh.apply_translation(-origin)
if up == "Z":
mesh.apply_transform(trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0]))
mesh.export(out)
print(f"origin {np.round(origin, 1).tolist()}, extents now {np.round(mesh.extents, 1).tolist()}, "
f"{preflight(out)['problems'] or 'preflight ok'}")
return out, origin
fix_for_viewer("city_rd.glb")
origin [78681.7, 457939.0, 20.2], extents now [677.8, 34.5, 615.6], preflight ok
The up axis is an argument, not a guess. The heuristic in preflight() β a vertical extent much larger than the smaller plan extent β works for a district and not for a single tower, which is why the origin and the convention belong in a sidecar rather than being inferred. The origin printed here is the centre of the model's bounds; it differs from the setup's rounded mean of the vertices, which is exactly why whichever one was used has to be written down.
Example 3 β validate a 3D Tiles tileset
import math
def check_tileset(ts):
problems = []
def walk(node, parent_error=math.inf, depth=0):
ge = node.get("geometricError")
if ge is None:
problems.append(f"depth {depth}: missing geometricError")
elif depth == 0 and ge == 0 and node.get("children"):
problems.append("root geometricError is 0 β the client will never refine")
elif ge > parent_error:
problems.append(f"depth {depth}: geometricError {ge} > parent {parent_error}")
bv = node.get("boundingVolume", {})
if "region" in bv:
w, s, e, n, lo, hi = bv["region"]
if abs(w) > math.pi or abs(s) > math.pi / 2 or abs(e) > math.pi or abs(n) > math.pi / 2:
problems.append(f"depth {depth}: region looks like degrees, not radians")
if w >= e or s >= n or hi < lo:
problems.append(f"depth {depth}: region bounds are out of order")
elif not bv:
problems.append(f"depth {depth}: no boundingVolume")
if "content" not in node and not node.get("children"):
problems.append(f"depth {depth}: leaf with no content")
for child in node.get("children", []):
walk(child, ge if ge is not None else parent_error, depth + 1)
walk(ts.get("root", {}), ts.get("geometricError", math.inf))
return problems
# the tile's real footprint and heights
corners = [to_wgs84.transform(x, y) for x in (78342.8, 79020.7) for y in (457631.3, 458246.9)]
w, e = min(c[0] for c in corners), max(c[0] for c in corners)
s, n = min(c[1] for c in corners), max(c[1] for c in corners)
radians = [math.radians(v) for v in (w, s, e, n)] + [3.0, 37.5]
child = {"boundingVolume": {"region": radians}, "geometricError": 0, "content": {"uri": "city.glb"}}
good = {"geometricError": 64, "root": {"boundingVolume": {"region": radians}, "geometricError": 16, "children": [child]}}
bad = json.loads(json.dumps(good))
bad["root"]["boundingVolume"]["region"] = [w, s, e, n, 3.0, 37.5]
bad["root"]["geometricError"] = 0
print("region (radians):", [round(v, 6) for v in radians[:4]])
print("good:", check_tileset(good))
print("bad: ", check_tileset(bad))
region (radians): [0.074494, 0.909345, 0.07467, 0.909443]
good: []
bad: ['root geometricError is 0 β the client will never refine', 'depth 0: region looks like degrees, not radians']
Degrees in a region is a classic tileset failure: valid JSON, a valid schema, and numbers a client will read as radians. The Den Haag tile's west edge, 4.27Β°, read as radians is 244.6Β°, and its south edge, 52.08, is not a latitude in radians at all.
Far-away, sideways and black models
Why a far-away model looks identical to an empty one
Renderers have near and far clipping planes. With three.js's default far plane of 2,000 units, a model at 457,000 units is beyond it and is not drawn. Even inside the far plane, a camera framing the origin has the model outside its frustum. Both produce an empty viewport with no error, because there is nothing wrong from the renderer's point of view β in_view() saw 0% of the national-grid model from the same camera that saw 98.7% of the recentred one.
Why the up axis is such a reliable trap
Nothing in a glTF says which convention the producer used, and both Y-up and Z-up files load fine β the Z-up tile was fully in view. The visual result depends on the camera: an orbit camera looking at a sideways district may show a thin edge, a plausible-looking but wrong view, or nothing, depending on where it starts.
Why normals produce black rather than nothing
A mesh with inverted normals is still in front of the camera. With back-face culling on, the renderer skips the faces and you see through the building to whatever is behind it. With culling off, the faces are drawn but lit from inside, which under a directional light is black. That is the signature: geometry that is clearly present and completely unlit β and it is why the volume sign, not winding consistency, is the check for it.
Why checking in Python beats debugging in the browser
Five of the six causes are properties of the file: position, orientation, face count, scale and winding. preflight() found all five on the real tile in a few lines of trimesh, with no browser, server or console. Only the material and camera questions genuinely need the viewer, and by then you know the file is sound.
Draco, split files, CORS and lighting
- Draco-compressed meshes need decoder support; without it, nothing renders and nothing errors.
- Very large models can exhaust GPU memory and fail silently on mobile.
.gltfplus.binbreaks if the sidecar is not served;.glbavoids it.- CORS blocks the payload while the tileset loads, so the tree appears and the geometry does not.
- Double-sided materials hide winding problems rather than fixing them.
- Ambient-only lighting makes everything flat rather than black.
- Check the file size. The empty export here was 172 bytes; a file that small cannot contain a building.
- Coordinates are float32. Positions in a glTF are 32-bit, which at a northing of 457,000 m resolves only to about 3 cm. Recentre before export, not after.
- Some viewers auto-frame, some do not. Do not read the camera as evidence.
Internal links
- How to export a 3D city model to glTF in Python β where the origin and rotation are set
- 3D Tiles and the web delivery of 3D city models explained β the tileset fields being checked
- How to publish a 3D building layer to a web map β the route with fewest failure modes
- A building mesh is not watertight and its volume is wrong β the winding problem measured rather than rendered
- Extruded buildings float above or sink into the ground β the vertical placement version
- A web map layer is not appearing β the 2D equivalent checklist
- Spatial API CORS errors β the payload that never arrives
- Map tiles are offset or in the wrong place β georeferencing in 2D
FAQ
Why is my 3D viewer empty?
Most often the model is not recentred, so it sits hundreds of thousands of units from the camera and beyond the far clipping plane. Check mesh.bounds before anything else.
Why is my model black?
Inverted normals. The renderer is lighting the inside of the faces, or culling them entirely. trimesh.repair.fix_normals usually resolves it.
Why does my model look like a thin line?
It is Z-up in a Y-up format and the camera is looking at it edge-on. Rotate β90Β° about X before export.
My glTF is only a few kilobytes β is that normal?
No. A file that small usually contains an empty scene. Check the face count in Python.
Why does my 3D Tiles tileset load but show nothing?
A region bounding volume in degrees rather than radians, or a root geometricError of 0 so the client never refines. Both are valid JSON.
Should I debug in the browser or in Python?
Python first. Five of the six causes β position, orientation, face count, scale and winding β are properties of the file.