A 3D viewer shows nothing or a black screen
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 something at the horizon that turns out to be the whole city three thousand kilometres away.
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 causes, and the fastest way to distinguish them is to stop looking at the viewer and inspect the file.
Quick answer
Load the model in Python and print four numbers before touching the viewer:
import trimesh, numpy as np
m = trimesh.load("model.glb")
print("bounds:", np.round(m.bounds, 2))
print("extent:", np.round(m.extents, 2))
print("geometries:", len(getattr(m, "geometry", {})) or 1)
print("faces:", sum(g.faces.shape[0] for g in
(m.geometry.values() if hasattr(m, "geometry") else [m])))
| what you see | cause |
|---|---|
| bounds in the hundreds of thousands | not recentred โ the model is far from the camera |
| extents with the largest value in y | Z-up not converted to Y-up |
| faces = 0 | nothing was exported |
| extents of a few units | a unit mismatch, or a model in degrees |
| everything looks right | it is a camera, material or normals problem |
Step-by-step solution
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 default camera is at the origin looking at the origin, so the city is off-screen and, at that distance, usually beyond the far clipping plane as well. Recentre on a local origin and record it.
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; with an orbit camera that often means looking at it edge-on, which is a thin line or nothing.
print(m.extents) # for a city block expect roughly [x, y_small, z] after conversion
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. trimesh.util.concatenate on an empty list returns an empty mesh without complaint.
4. Check the scale
glTF has no unit field; metres are the convention. A model in feet, in centimetres, or worst of all in degrees โ extents of 0.01 โ is either microscopic or enormous relative to the camera's near and far planes.
5. Check the normals and the winding
A black model that is present and correctly placed is usually a lighting problem. Inverted normals mean the renderer is looking at back faces, which with back-face culling are invisible and without it are unlit.
import trimesh
trimesh.repair.fix_normals(m)
trimesh.repair.fix_inversion(m)
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. Vertex colours without a material that reads them are ignored by some viewers.
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 near the centre of the Earth. A root geometricError of 0 means the client never refines. Both load without error.
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.
Code examples
Example 1 โ the pre-flight check
import numpy as np, trimesh
def preflight(path, expect_metres=True):
scene = trimesh.load(path)
geoms = list(scene.geometry.values()) if hasattr(scene, "geometry") else [scene]
faces = sum(len(g.faces) for g in geoms)
bounds = scene.bounds
extents = scene.extents
report = {"geometries": len(geoms), "faces": faces,
"bounds": np.round(bounds, 2).tolist(),
"extents": np.round(extents, 2).tolist()}
problems = []
if faces == 0:
problems.append("no faces โ nothing was exported")
if np.abs(bounds).max() > 1e4:
problems.append(f"coordinates up to {np.abs(bounds).max():,.0f} โ recentre the model")
if expect_metres and extents.max() < 1:
problems.append(f"largest extent {extents.max():.4f} โ units look wrong (degrees?)")
if extents[1] > extents[0] and extents[1] > extents[2]:
problems.append("y is the largest extent โ is this Z-up data that was not rotated?")
if any(not g.is_winding_consistent for g in geoms):
problems.append("inconsistent winding โ expect unlit or invisible faces")
report["problems"] = problems
return report
print(preflight("city.glb"))
Example 2 โ recentre and rotate, then verify
import numpy as np, trimesh
def fix_for_viewer(path, out="fixed.glb"):
scene = trimesh.load(path)
mesh = trimesh.util.concatenate(
list(scene.geometry.values()) if hasattr(scene, "geometry") else [scene])
origin = mesh.bounds.mean(axis=0)
mesh.apply_translation(-origin)
if mesh.extents[2] < mesh.extents[1]:
pass # already Y-up
else:
mesh.apply_transform(trimesh.transformations.rotation_matrix(
-np.pi / 2, [1, 0, 0]))
trimesh.repair.fix_normals(mesh)
mesh.export(out)
print(f"origin {np.round(origin, 2)}, extents now {np.round(mesh.extents, 2)}")
return out, origin
The heuristic โ vertical extent smaller than the horizontal ones โ works for a city block and not for a single tower, which is why the origin and the convention belong in a sidecar rather than being inferred.
Example 3 โ validate a 3D Tiles tileset
import json, math
def check_tileset(path):
ts = json.loads(open(path).read())
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:
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:
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
Degrees in a region is the single most common tileset failure: valid JSON, valid schema, and a tile the client places near the Earth's core.
Explanation
Why a far-away model looks identical to an empty one
Renderers have near and far clipping planes. A default camera might have a far plane at a few thousand units; a model at 457,000 units is beyond it and is not drawn. Even inside the far plane, an orbit 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.
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 visual result depends on the camera: an orbit camera looking at a sideways city block 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.
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. All five are three lines of trimesh and none of them requires a browser, a server or a console. Only the material and camera questions genuinely need the viewer, and by then you know the file is sound.
Edge cases or notes
- 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. A 2 KB
.glbcontains nothing. - 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.