DEV Community

cy cy
cy cy

Posted on

Your GLB is a 134-meter headphone — what AR Quick Look punishes before your customer ever sees it

We ship 3D product assets for e-commerce (GLB models that end up in AR Quick Look, <model-viewer>, and Sketchfab). Last week we ran our own delivery gate over one of our own catalog models — a headphone we had already sold — and the report said:

GLBcheck — model_lite.glb
  structure : 40 root(s), depth 1, 40 meshes, 40 primitives
  geometry  : 13540 tris / 10242 verts, span 134.028 units
  textures  : 24 images, 25.17 MP total
  ! [WARN] structure/multi-root: 40 root nodes
  ! [WARN] geometry/scale: model spans ~134.03 units - AR Quick Look expects real-world meters
  verdict   : PASS_WITH_WARNINGS (0 fails, 2 warnings)
Enter fullscreen mode Exit fullscreen mode

Span 134 units. AR Quick Look renders 1 unit as 1 meter. That is a headphone the size of a twelve-story building appearing in a customer's living room, and the only way they can fix it is pinching for thirty seconds. Desktop viewers never show this problem — they auto-frame. Only real-scale runtimes punish it.

That one report is a good map of the whole class of bugs that pass on desktop and explode on phones. Here are the three that bit us hardest, and the checks that catch each one in ~20 lines of stdlib Python.

1. Real scale: nobody agrees on what a unit is

glTF says 1 unit = 1 meter. DCC tools say "whatever looks right." So the fix belongs in your delivery gate, not your artist's intuition. You already have the numbers: POSITION accessors carry min/max (and viewers use them for framing — if they're missing, add that as its own finding).

# scan mesh primitives; track the largest absolute coordinate
for mesh in doc.get("meshes", []):
    for prim in mesh.get("primitives", []):
        pos = prim["attributes"].get("POSITION")
        acc = accessors[pos]
        span = max(abs(v) for v in acc.get("max", []))
        if span < 0.01 or span > 100:   # cm-class model or building-class model
            warn("geometry/scale", f"model spans ~{span:.2f} units; AR expects meters")
Enter fullscreen mode Exit fullscreen mode

A jewelry model at 0.004 units and a furniture model at 134 units are the same bug in opposite directions.

2. mimeType is a claim; bytes are the truth

This one hard-crashes some mobile viewers while every desktop tool passes it silently:

{ "bufferView": 0, "mimeType": "image/jpeg" }   // ...but the bytes are PNG
Enter fullscreen mode Exit fullscreen mode

The GLB container gives you everything you need to check it without any image library. Images either live in the BIN chunk (via a bufferView) or inline as data URIs. Read the bytes, read the header:

def real_magic(blob):
    if blob[:8] == b"\x89PNG\r\n\x1a\n": return "image/png"
    if blob[:2] == b"\xff\xd8":          return "image/jpeg"
    return None
Enter fullscreen mode Exit fullscreen mode

Claim vs reality mismatch → FAIL, not warn. A viewer that trusts mimeType to pick a decoder will feed PNG bytes to its JPEG path. While you're in there, parse IHDR (bytes 16–24) or the JPEG SOF marker for dimensions — you get texture resolution and a total-megapixels budget for free, which matters because 25 MP of textures on a 8 MB model is its own conversation.

3. The container lies too

A .glb is a tiny container format: 12-byte header (glTF, version, total length), then chunks (JSON first, optional BIN after). Every field is checkable:

  • version must be 2, and asset.version must be "2.0"
  • header length must equal the actual file size (truncation is a classic FTP/CDN artifact)
  • first chunk type must be JSON, length 4-byte aligned, space-padded
  • extensionsUsed tells you what the file demands — and AR Quick Look will not load Draco or meshopt-compressed meshes. That's a FAIL in delivery-strict mode, not a warning.
magic, version, length = struct.unpack_from("<III", data, 0)
assert magic == 0x46546C67 and version == 2
if length != len(data):
    fail("container/length", f"header says {length}, file is {len(data)}")
Enter fullscreen mode Exit fullscreen mode

Why this belongs in CI, not in a viewer

Desktop viewers are forgiving because they want to render something. Your delivery gate is allowed to be rude. The whole auditor is ~330 lines of stdlib Python (container, structure, geometry, materials, textures, extensions), exit codes 0/1/2 so CI can gate on it:

python glbcheck.py model.glb --json --strict && upload_to_store.sh
Enter fullscreen mode Exit fullscreen mode

If you'd rather not write it, that auditor is exactly what GLBcheck is — $29, includes the 14-check self-test that builds its own GLB fixtures with struct (mime spoof, truncation, bad magic, multi-root, Draco strict), so you can see every rule fire. It found the 134-meter headphone in our own catalog; your pipeline probably has one too.

The deeper point: the bugs that cost you customers are the ones the desktop can't see. Audit for the runtime your customer actually uses.

Top comments (0)