Validating glTF Assets with the Khronos Validator

This page runs the Khronos glTF-Validator over the geometry inside a 3D Tiles tileset — extracting GLB payloads from legacy .b3dm tiles, validating each in Node with the gltf-validator package, aggregating the JSON reports in Python, and failing a CI build according to a severity policy with an explicit allowlist, for tiles whose content sits in EPSG:4978 relative to tile centres.

Why you hit this

The 3D Tiles validator checks tileset structure: JSON schemas, bounding volumes, tile formats, metadata. It does not look deeply inside the glTF each tile contains, and that is where most rendering defects originate — accessor bounds that do not match the data, so culling removes visible geometry; normals that are not unit length, so lighting is wrong; NaN positions from a failed decimation, which some GPUs draw as spikes to infinity; index buffers referencing vertices that do not exist. The Khronos validator is the reference implementation of the glTF specification and catches all of these. Pairing the two is the subject of this page; the tileset-level checks are in writing 3D Tiles validator checks in CI.

Prerequisites

  • Node.js 18+ with gltf-validator from npm (the package publishes the same validator used by the online Khronos tool).
  • Python 3.10+ with the standard library; numpy>=1.24 only for summary statistics.
  • Tile content as .glb or .b3dm; .i3dm and .cmpt follow the same extraction pattern with different headers.

Step-by-Step

1. Extract GLB payloads from b3dm tiles

A .b3dm file is a 28-byte header, a feature table, a batch table, and then a complete GLB. Extracting the GLB lets the validator see exactly what the runtime decodes.

python
import struct
from pathlib import Path

def extract_glb(b3dm_path):
    data = Path(b3dm_path).read_bytes()
    magic, version, byte_len, ft_json, ft_bin, bt_json, bt_bin = struct.unpack_from("<4s6I", data, 0)
    if magic != b"b3dm":
        raise ValueError(f"{b3dm_path}: not b3dm ({magic!r})")
    if byte_len != len(data):
        raise ValueError(f"{b3dm_path}: header says {byte_len} bytes, file has {len(data)}")
    start = 28 + ft_json + ft_bin + bt_json + bt_bin
    glb = data[start:]
    if glb[:4] != b"glTF":
        raise ValueError(f"{b3dm_path}: payload at offset {start} is not GLB")
    return glb

SRC = Path("tiles/city")
WORK = Path("build/validate"); WORK.mkdir(parents=True, exist_ok=True)
manifest = {}
for tile in sorted(SRC.rglob("*.b3dm")) + sorted(SRC.rglob("*.glb")):
    out = WORK / (tile.relative_to(SRC).as_posix().replace("/", "__") + ".glb")
    out.write_bytes(extract_glb(tile) if tile.suffix == ".b3dm" else tile.read_bytes())
    manifest[out.name] = tile.relative_to(SRC).as_posix()
print(f"{len(manifest)} GLB payloads ready")

The byte-length check catches truncated uploads before validation reports a confusing buffer error, and the GLB magic check catches the old .b3dm layout from the 3D Tiles pre-1.0 era, whose header was 20 or 24 bytes and which some ancient tilers still write.

2. Validate each payload in Node

javascript
// validate.mjs — node validate.mjs build/validate > build/gltf_reports.json
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import validator from "gltf-validator";

const dir = process.argv[2];
const reports = {};
for (const name of readdirSync(dir).filter((f) => f.endsWith(".glb"))) {
  const bytes = new Uint8Array(readFileSync(join(dir, name)));
  const report = await validator.validateBytes(bytes, {
    uri: name,
    maxIssues: 200,
    externalResourceFunction: (uri) => Promise.reject(new Error(`external resource ${uri} not allowed in tiles`)),
  });
  reports[name] = {
    errors: report.issues.numErrors,
    warnings: report.issues.numWarnings,
    infos: report.issues.numInfos,
    messages: report.issues.messages.map(({ code, severity, pointer, message }) => ({ code, severity, pointer, message })),
    extensions: report.info?.extensionsUsed ?? [],
    triangles: report.info?.totalTriangleCount ?? null,
  };
}
process.stdout.write(JSON.stringify(reports));

Rejecting external resources is deliberate for tile content: a GLB inside a tileset should be self-contained, and a tile that references an external .bin or texture by URI works on the developer’s machine and 404s in production. The validator reports the failed resolution as an error, which is exactly the outcome wanted.

Validation pipeline for tile content Tiles in b3dm or glb format are unpacked by Python into plain GLB payloads. A Node script validates each payload with the Khronos gltf-validator and writes a combined JSON report. Python then applies a severity policy and an allowlist of issue codes and exits non-zero if any tile violates the policy, which fails the CI job. .b3dm / .glbtiles Pythonextract GLB Nodegltf-validator Python: policy+ allowlist → exit code The validator runs where it was written; policy lives where the rest of the pipeline does.
Using the validator's own JavaScript build avoids re-implementing the specification checks, and keeps the pass/fail rules in reviewable Python.

3. Apply a severity policy with an allowlist

python
import json
import subprocess
import sys
from collections import Counter

raw = subprocess.run(["node", "validate.mjs", str(WORK)], capture_output=True, text=True, check=True).stdout
reports = json.loads(raw)

SEVERITY = {0: "error", 1: "warning", 2: "info", 3: "hint"}
FAIL_ON = {"error"}
FAIL_ON_CODES = {"ACCESSOR_NON_UNIT", "ACCESSOR_INVALID_FLOAT", "ACCESSOR_MIN_MISMATCH", "ACCESSOR_MAX_MISMATCH"}
ALLOW = {
    "UNSUPPORTED_EXTENSION": "CESIUM_RTC and EXT_structural_metadata are handled by the runtime, not the validator",
    "UNUSED_OBJECT": "tilers emit unused samplers; harmless",
}

violations, codes = [], Counter()
for name, rep in reports.items():
    for m in rep["messages"]:
        codes[m["code"]] += 1
        sev = SEVERITY[m["severity"]]
        if m["code"] in ALLOW:
            continue
        if sev in FAIL_ON or m["code"] in FAIL_ON_CODES:
            violations.append((manifest[name], m["code"], m["pointer"], m["message"]))

print("most frequent codes:", codes.most_common(8))
for tile, code, pointer, message in violations[:20]:
    print(f"FAIL {tile}: {code} at {pointer}{message}")
print(f"{len(violations)} violations in {len({v[0] for v in violations})} of {len(reports)} tiles")
sys.exit(1 if violations else 0)

The policy has three layers. Every error fails. A short list of warnings that correspond to visible rendering defects also fails: non-unit normals break lighting, invalid floats produce spikes, and accessor min/max mismatches break culling, because runtimes use those bounds to decide whether geometry is on screen. Everything else is reported but tolerated, and the allowlist carries a written reason for each code it suppresses, so the next person to see UNSUPPORTED_EXTENSION knows it was a decision rather than an oversight.

Severity policy for tile content A layered policy. All errors fail the build. A named set of warnings that cause visible defects also fail: non-unit normals, invalid floats, and accessor minimum or maximum mismatches. Other warnings, infos and hints are reported. Codes on the allowlist, each with a written reason, are ignored. every error warnings: ACCESSOR_NON_UNIT · ACCESSOR_INVALID_FLOAT · ACCESSOR_MIN/MAX_MISMATCH other warnings, infos, hints allowlisted codes, each with a written reason fail report ignore fail
Promoting a handful of warnings to failures targets the defects a user would actually see, without failing builds over cosmetic issues.

4. Validate a sample on every build, everything on release

python
import random

def select_tiles(all_names, changed, sample_frac=0.02, seed=20260917):
    rng = random.Random(seed)
    sample = {n for n in all_names if rng.random() < sample_frac}
    return sorted(sample | set(changed))

changed = [n for n, src in manifest.items() if src.startswith("content/16/")]   # from the incremental build manifest
subset = select_tiles(list(manifest), changed)
print(f"validating {len(subset)} of {len(manifest)} tiles on this build")

A city tileset with half a million tiles takes hours to validate fully in one process. Validating every changed tile plus a fixed-seed random sample on each build keeps CI fast while still catching regressions that affect unchanged content, such as a tiler upgrade; a full run belongs on release branches or a nightly schedule. The changed set comes from the manifest described in incremental retiling of changed city blocks.

Expected Output & Verification

text
18342 GLB payloads ready
most frequent codes: [('UNSUPPORTED_EXTENSION', 18342), ('UNUSED_OBJECT', 9120), ('ACCESSOR_NON_UNIT', 214), ('MESH_PRIMITIVE_GENERATED_TANGENT_SPACE', 88)]
FAIL content/15/17622/11263.b3dm: ACCESSOR_NON_UNIT at /accessors/2 — 12 accessor elements not of unit length: 0.93…
…
214 violations in 31 of 18342 tiles

Verify the gate itself with known-bad fixtures. Keep three small GLB files in the repository — one with a NaN position, one with a normal of length 0.9, one with an accessor max smaller than the data — and assert that the policy fails each with the expected code. A validation step that has never been seen to fail is not known to work.

The fixtures earn their keep most when the validator itself is upgraded. New releases occasionally rename a code, split one check into two, or change a severity, and a policy keyed on code names then silently stops matching. Running the fixture assertions in the same job as the real validation turns a renamed code into a red build on the day of the upgrade, instead of a quiet gap discovered months later when a spiky tile reaches production. Pin the validator version in the lockfile, and treat a bump as a change that has to pass the fixtures before it merges.

python
fixtures = {"nan_position.glb": "ACCESSOR_INVALID_FLOAT", "short_normal.glb": "ACCESSOR_NON_UNIT",
            "bad_max.glb": "ACCESSOR_MAX_MISMATCH"}
fx = json.loads(subprocess.run(["node", "validate.mjs", "tests/fixtures"], capture_output=True,
                               text=True, check=True).stdout)
for name, code in fixtures.items():
    assert any(m["code"] == code for m in fx[name]["messages"]), f"{name} did not report {code}"
print("gate detects every fixture defect")
Where failing tiles come from A bar chart of failing issue codes by pipeline stage that introduced them in a city build. Decimation produced most non-unit normals and all invalid floats. The tiler's quantisation produced accessor minimum and maximum mismatches. Texture baking produced a small number of generated tangent-space warnings, which are reported but do not fail. decimation: NON_UNIT, INVALID_FLOAT quantisation: MIN/MAX_MISMATCH texture baking: tangent space (report only) failing codes, grouped by the pipeline stage that introduced them
Grouping issue codes by the stage that emits them turns a validation report into a to-do list for a specific team.

Common Errors

Error: Cannot find module 'gltf-validator'. The package was installed in a different directory from the script, or CI cached node_modules from another job. Install it in the job with npm ci against a lockfile that pins the validator version, so a validator upgrade is a reviewed change.

Every tile reports BUFFER_VIEW_TOO_BIG or GLB_UNEXPECTED_END_OF_CHUNK_DATA. The GLB was extracted from the wrong offset: a batch table length was ignored, or the file used the legacy b3dm header. Print the four header lengths for one failing tile and compare them against the file size.

The validator process runs out of memory on large tiles. validateBytes holds the full report and resources in memory. Validate files one per call as the script does, rather than collecting all bytes first, and increase Node’s heap with --max-old-space-size for photogrammetry tiles above a few hundred megabytes.

Frequently Asked Questions

Does validation replace visual QA?

No. A valid glTF can still be a wrong model: misplaced, mis-scaled or decimated to mush. Validation removes a class of silent specification defects so that visual review can concentrate on content.

Only when an extension is required and your runtime does not support it — then it is a real failure the validator cannot judge. Check extensionsRequired against the list your viewer supports as a separate, explicit rule.

Can I validate Draco- or meshopt-compressed tiles?

Yes. The validator checks the glTF structure and extension declarations; for deep checks of the decoded data, validate the uncompressed output of the stage before compression as well, where accessor bounds and normals can be checked directly.

Back to Data Validation & QA Gates for 3D Tiles.