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
Six causes of an empty or black 3D viewport, each measured on a variant of the Den Haag tile. what the Den Haag variants showedwhat the file showscausebounds up to 458,247not recentred: 0% in viewy extent 615.6, not 34.5Z-up not converted to Y-upfaces = 0, 172 bytesnothing was exportedplan extent 0.0055degrees: 0.01 px wide1,990 of 1,990 volumes < 0inside out: culled or unliteverything looks rightcamera, material or extensionsFive of the six are answerable without opening a browser spatialworkflow.io
Five of the six are visible in the file; only the last needs the viewer.

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.

On-screen width of each Den Haag tile variant through the same default-like camera. width on a 1,000 px screen, same cameranational grid (city_rd)0 px, 0% in viewplan in degrees0.01 px, 100% in viewrecentred, Z-up876 px, lying on its siderecentred, Y-up952 px, 98.7% in view0500 px1000 pxIn view is not visible: the degrees model is all in frame and a hundredth of a pixel wide spatialworkflow.io
in_view() on four variants of the same 1,990 buildings.

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.

The order to check a 3D model that will not render. check the file first1bounds and extentsis it near the camera?2face countis there anything in it?3up axisy should be the small one4volume signnegative means inside out5then the viewercamera, material, extensionsThree lines of trimesh answer the first four spatialworkflow.io
Work down the list; the file answers the first five questions without a browser.

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.

An empty viewport against a black model, and what each means. empty is not blackempty viewportβœ•model outside the frustumβœ•beyond the far clipping planeβœ•zero faces exportedβœ•scale so small it is sub-pixelblack modelβœ•normals invertedβœ•back faces being litβœ•material fully black or transparentβœ•no light in the sceneGeometry that is clearly present and unlit is a normals problem spatialworkflow.io
The two symptoms have disjoint causes, so telling them apart halves the search.

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.
  • .gltf plus .bin breaks if the sidecar is not served; .glb avoids 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.

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.