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
Triage of six causes of an empty or black 3D viewport with the check that identifies each.
Five of the six are visible in the file; only the last needs the viewer.

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.

Vertical steps from inspecting bounds, axes and face count through scale, normals and materials to the viewer itself.
Work down the list; the file answers the first five questions without a browser.

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.

Two panels separating causes of an empty 3D viewport from causes of a black model, with four entries each.
The two symptoms have disjoint causes, so telling them apart halves the search.

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.
  • .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. A 2 KB .glb contains nothing.
  • 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.