Exporting OBJ and glTF for Web Viewers

The client opens the link and waits. Forty seconds later a grey model appears, sitting a few hundred metres from where the basemap says the site is, and it shimmers when they zoom in. Every one of those three symptoms is an export decision rather than a modelling one, and all three are avoidable in the same twenty lines of code.

This page covers the export stage of mesh and texture generation automation: choosing the container, handling coordinates that a 32-bit float cannot hold, keeping the texture attached to the geometry, and verifying the result without opening a browser.

Why the container matters more than the mesh

OBJ is a text format. Every vertex is written as a decimal string — around 40 bytes where the underlying data is 12 — and the parser must convert millions of strings back to numbers before anything renders. A five-million-triangle textured mesh lands at roughly 600 MB and takes tens of seconds to parse in a browser, during which the page is unresponsive.

glTF binary (.glb) stores the same arrays as packed binary buffers. The same mesh is about 90 MB, parses in roughly a second because there is nothing to parse, and carries the texture inside the same file rather than as a sidecar. It is also the format every web viewer actually wants; OBJ support in browsers is a conversion step wearing a loader’s clothing.

The one thing OBJ has is universality in desktop CAD. Where a client genuinely needs it, export both and deliver the GLB as the thing they click.

The same mesh in three containers, by size and browser load time A comparison of three containers holding an identical five-million-triangle textured mesh. Text OBJ with a sidecar texture is six hundred and ten megabytes and takes thirty-eight seconds to load. Binary glTF is ninety-two megabytes and loads in one point four seconds. Draco-compressed glTF is twenty-six megabytes and loads in two point one seconds, trading a longer decode for a much smaller download. A note observes that on a slow connection the Draco variant wins overall and on a fast one plain glTF does. one mesh · 5 M triangles · 8 k texture OBJ + MTL 610 MB · 38 s glTF binary 92 MB · 1.4 s glTF + Draco 26 MB · 2.1 s (decode-bound) On a 10 Mbit connection the Draco variant arrives first by a wide margin. On a fast office connection plain glTF wins, because decoding is the bottleneck rather than download. OBJ never wins. It is a compatibility format, not a delivery format.

Figure 1 — The container is a bigger lever than any mesh optimisation available at this stage.

The float32 problem, concretely

glTF stores vertex positions as 32-bit floats. A float32 has about 24 bits of mantissa, so its resolution is roughly the magnitude of the value divided by 16.7 million. A UTM easting of 500,000 m therefore resolves to about 3 cm, and a northing of 5,600,000 m to about 33 cm.

That is the shimmer. The geometry is quantised to a lattice whose spacing depends on where in the coordinate system it sits, and as the camera moves, vertices snap between lattice positions. It also means the model cannot express anything finer than the quantisation, so a survey delivered at 1 cm accuracy arrives with a third of a metre of vertical noise.

The fix is to translate the mesh to a local origin and record the offset. Everything downstream — a viewer’s georeferencing, a measurement tool, a comparison against a later model — uses the offset to get back to the projected frame.

import json
from pathlib import Path

import numpy as np
import trimesh


def export_glb_local_origin(mesh: trimesh.Trimesh, out_path: str,
                            crs: str, *, draco: bool = False) -> dict:
    """Export to .glb in a local frame, with the offset recorded beside it.

    The offset is rounded to whole metres so it stays exactly representable
    and reads cleanly in the metadata. What matters is that it is recorded:
    a model in a local frame with no offset is a model nobody can place.
    """
    origin = np.round(mesh.bounds.mean(axis=0)).astype(float)
    local = mesh.copy()
    local.apply_translation(-origin)

    residual = float(np.abs(local.vertices).max())
    resolution = residual / 2 ** 23
    if resolution > 0.005:
        raise ValueError(
            f"local extent {residual:.0f} m still quantises to {resolution*1000:.1f} mm "
            "— tile the model before export")

    local.export(out_path)
    meta = {"crs": crs, "origin_offset": origin.tolist(),
            "position_resolution_m": resolution,
            "bytes": Path(out_path).stat().st_size}
    Path(out_path).with_suffix(".json").write_text(json.dumps(meta, indent=2))
    return meta

The residual check is the part worth keeping. Shifting to a local origin fixes the problem for a site a few hundred metres across; a corridor survey ten kilometres long still quantises to half a centimetre at its ends, and the answer there is tiling rather than a different offset.

OBJ and glTF compared for a survey deliverable Two columns. The OBJ column notes that it is plain text and inspectable, universally readable by desktop CAD and GIS, carries no scene graph or units declaration, references its material and textures as sibling files that are easily separated from it, and is large on the wire. The glTF column notes that it is binary and compact, carries units, a scene graph and material definitions in the file, embeds or references textures explicitly, streams to a browser without conversion, and is harder to inspect by hand when something is wrong. OBJ plain text, inspectable by hand read by every desktop CAD and GIS no units or scene graph declared materials and textures as loose siblings large on the wire glTF / GLB binary and compact units, scene graph and materials in the file textures embedded or explicitly referenced streams to a browser unconverted harder to inspect when something is wrong Desktop hand-off favours OBJ; anything that will be opened in a browser favours glTF.

Figure 3 — The destination decides, and shipping both is cheap.

Edge-case matrix

Input variant Naive export Correct handling
Mesh in UTM coordinates 3–33 cm quantisation, shimmer Translate to a local origin, record it
Corridor model 10 km long Local origin insufficient Tile, one origin per tile
Texture as a separate PNG Sidecar lost in transit Export .glb, texture embedded
Vertex colours, no texture Rendered grey by some viewers Bake colours to a small texture, or set the material
Mesh with no normals Flat, faceted shading Compute smooth normals before export
Double-sided geometry needed Back faces culled, holes appear Set doubleSided in the material
Very large texture (16 k) Rejected on mobile Split, or downsample to 8 k
Z-up survey convention Model appears lying on its side Rotate to glTF’s Y-up convention

The last row produces the most support tickets. Survey data is Z-up; glTF is Y-up. Viewers vary in whether they apply a correction, so exporting with an explicit rotation is the only way to be sure.

import numpy as np
import trimesh


def to_gltf_orientation(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
    """Rotate a Z-up survey mesh into glTF's Y-up convention explicitly."""
    m = mesh.copy()
    m.apply_transform(trimesh.transformations.rotation_matrix(
        angle=-np.pi / 2, direction=[1, 0, 0], point=[0, 0, 0]))
    return m

Verification snippet

Everything that goes wrong in export is visible in the exported file, which means it can be checked in CI rather than by a client.

import json
from pathlib import Path

import numpy as np
import trimesh


def verify_export(glb_path: str, expect_max_extent_m: float = 2000.0) -> dict:
    """Post-export checks: placement, precision, material, orientation."""
    m = trimesh.load(glb_path, process=False, force="mesh")
    meta_path = Path(glb_path).with_suffix(".json")
    meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}

    extent = float(np.abs(m.bounds).max())
    problems = []
    if "origin_offset" not in meta:
        problems.append("no origin offset recorded — the model cannot be georeferenced")
    if extent > expect_max_extent_m:
        problems.append(f"vertices reach {extent:.0f} m from the origin — "
                        "positions are quantising")
    if getattr(m.visual, "uv", None) is None:
        problems.append("no UV coordinates — the model will render untextured")
    if not m.is_winding_consistent:
        problems.append("inconsistent winding — expect black faces")
    if m.vertex_normals is None or len(m.vertex_normals) == 0:
        problems.append("no normals — shading will be flat")

    return {"bytes": Path(glb_path).stat().st_size,
            "faces": int(len(m.faces)), "extent_from_origin_m": extent,
            "position_resolution_mm": extent / 2 ** 23 * 1000,
            "problems": problems}

Reporting position_resolution_mm rather than a pass/fail is deliberate: it is the number that tells a reviewer whether the model can support the measurement the client intends to take from it, and it belongs in the delivery note.

Float32 position resolution against distance from the coordinate origin A curve of achievable position resolution against the magnitude of a coordinate, on a logarithmic horizontal axis from one metre to ten million metres. At one hundred metres the resolution is about six thousandths of a millimetre. At five hundred thousand metres, a typical UTM easting, it is thirty millimetres. At five point six million metres, a typical northing, it is three hundred and thirty millimetres. A shaded band marks the region below ten millimetres where a survey model is usable, and a marker shows that a local origin brings any site back into that band. usable for survey work 1 m 100 m 10 km 500 km 5 600 km coordinate magnitude resolution UTM easting — 30 mm UTM northing — 330 mm after a local origin shift One subtraction moves a model four orders of magnitude up this curve.

Figure 2 — The shimmer, quantified. Nothing about the mesh changes; only where it sits in the number line.

Delivering a model somebody can actually place

An exported GLB in a local frame is only half a deliverable. The other half is the information needed to put it back where it belongs, and three fields cover it: the CRS, the origin offset, and the orientation convention used. Shipping them as a small JSON sidecar next to the model — or, better, as glTF extras inside the file — means the model is self-describing.

def embed_georeference(gltf_dict: dict, crs: str, origin: list[float],
                       up_axis: str = "Y") -> dict:
    """Write placement metadata into the glTF asset extras, not a loose file."""
    gltf_dict.setdefault("asset", {}).setdefault("extras", {}).update({
        "crs": crs,
        "originOffset": origin,
        "upAxis": up_axis,
        "note": "add originOffset to vertex positions to return to the CRS",
    })
    return gltf_dict

The note field looks redundant and is not. Whoever opens this file in two years will not be the person who exported it, and a single sentence saying which direction the offset applies removes the most likely error — applying it with the wrong sign, which puts the model exactly twice as far from the site as no correction at all.

Finally, decide the Draco question by the audience rather than by preference. Draco compression roughly quarters the file at the cost of a decode step, so it wins on any connection slower than about 50 Mbit and loses on a fast office link. Where the audience is mixed, ship both and let the viewer choose — the extra storage is trivial next to the imagery the model came from.

When to escalate

  • The model still renders untextured in one specific viewer. Check the material’s alpha mode and double-sided flag before re-baking anything; several viewers render a correctly textured but back-facing mesh as flat grey.
  • Positions are correct and the model is in the wrong place on the basemap. That is a CRS problem, not an export one — see managing coordinate reference systems in GDAL.
  • The file is under the size budget and still loads slowly. The bottleneck is texture decode rather than geometry. Reduce the atlas resolution before reducing the triangle count.

Mesh and Texture Generation Automation