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-validatorfrom npm (the package publishes the same validator used by the online Khronos tool). - Python 3.10+ with the standard library;
numpy>=1.24only for summary statistics. - Tile content as
.glbor.b3dm;.i3dmand.cmptfollow 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.
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
// 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.
3. Apply a severity policy with an allowlist
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.
4. Validate a sample on every build, everything on release
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
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.
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")
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.
Should extension-related infos ever fail the build?
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.
Related Guides
- Schema Validation Gates for Spatial Data — the same gate pattern for vector and point data
- Asserting CRS and Units with pyproj — the checks glTF validation cannot do
- Draco vs Meshopt Compression for glTF Tiles — the compression stage validated here