The glTF import core: parser boundary, scene graph and extraction

CNA snapshot 009d40f5  ·  Deep Dives › Models, glTF & 3D  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the TARGET import core, ContentManager reader and test suites at 009d40f5; not built or executed. Renderer behaviour for each extension is renderer-qualified and is not proven by the importer tests.

CNA does not hide a small glTF reader inside ContentManager. It has one import library, CNA::Internal::GltfImport, whose parsing and semantic extraction are shared by the runtime .gltf/.glb reader and by the offline glTF-to-CNJ orchestration that the command-line converter, cna-content and cna_tool_gltf_to_cnb all call. This page follows a file through that core: the parser and validation boundary, the scene graph, mesh groups, what extraction preserves, the extension registry, the import report, and the failure and lifetime rules. It is for anyone importing third-party glTF content or maintaining the importer.

One core, several front ends

The core is GltfImportCore.hpp (about 2,200 lines) and GltfImportCore.cpp (about 5,400 lines) at this snapshot — roughly three times the size it had when the campaign that produced it began. Container parsing and accessor access are delegated to the vendored cgltf 1.15 header. What is shared is interpretation: turning a glTF document into scene-node records, mesh groups, packed vertex bytes, material records, skeletons, clips, cameras, lights and diagnostics. What is not shared is the last step: the runtime front end creates live GPU objects in one Model, while the offline orchestration writes .cnj descriptors and binary sidecars that a later reader recreates.

  .gltf / .glb
       |
       v
  cgltf parse (UTF-8 file callbacks) -> URI containment -> load buffers
       -> asset.version check -> ValidateGltfEXT
       |
       v
  shared import core  CNA::Internal::GltfImport
  BuildSceneGraph . CollectMeshGroups . BuildSkeleton . ExtractClips
  ExtractSceneNodeClips . ExtractMesh . lights . cameras . report
       |                                  |
       v                                  v
  ReadGltfModel (runtime)           ConvertGltfToCnj (compiled into cna_content)
  every group -> one Model          one .cnj + sidecars per mesh group
  live buffers, effects, textures        |               |              |
                                         v               v              v
                              cna_tool_gltf_to_cnj  cna-content    cna_tool_gltf_to_cnb
                                  (.cnj files)      (.cnb/.xnb)       (.cnb file)

  parsed  !=  represented  !=  drawn by this renderer  !=  pixel-proven
Figure. A glTF or GLB file is parsed by cgltf, checked for URI containment before any external buffer is loaded, version-checked and structurally validated, then interpreted by the shared import core. Two consumers use the core's records: the runtime reader, which assembles every mesh group into one live Model, and the glTF-to-CNJ orchestration, which writes one CNJ descriptor with sidecars per group and is reused by the standalone converter, by cna-content and by cna_tool_gltf_to_cnb. The last line is the reading rule the rest of this page applies: sharing a parser does not make the outputs equivalent, and parsing, representation, renderer support and pixel evidence are separate claims.

The orchestration lives in gltf_to_cnj.cpp. The content module's CMake file compiles that file into cna_content with CNA_GLTF_TO_CNJ_NO_MAIN=1, so cna-content (through ModelContentPipeline.cpp) and cna_tool_gltf_to_cnb call the same CNA::Tools::Gltf::ConvertGltfToCnj (GltfToCnjEntry.hpp) rather than compiling private interpretations; the standalone cna_tool_gltf_to_cnj compiles it again with its command-line entry point. The runtime reader is ReadGltfModel in ContentManager.cpp.

Two build invariants

GltfImportCore.cpp is the only translation unit that defines CGLTF_IMPLEMENTATION; every other consumer (the header, the converter, ContentManager.cpp) links against it. The same file compiles stb_image and stb_image_write with STB_IMAGE_STATIC/STB_IMAGE_WRITE_STATIC, giving every stb symbol internal linkage, because another translation unit in the tree — the graphics module's image loader — compiles its own vendored stb implementation the same way, and two external copies would collide at link time. Both are build invariants, not decoration. GltfVendoredCgltf.TheVendoredHeaderCarriesNoCnaEdits fails if the vendored cgltf header contains a CNA, CNAEXT, plan_gltf or GLTF-nnn marker or loses its LICENSE (a marker check, not a comparison with an upstream copy), and EveryKnownCgltfFaultStillHasItsCnaSideAnswer ties each known cgltf weakness to the CNA-side check that answers it.

The cgltf faults answered on CNA's side

By policy (plan item GLTF-038) the vendored parser is kept byte-identical to upstream; its header has a single commit in the history, the one that vendored it, and nothing in the tree compares it with an upstream copy. A defect found in it is therefore never patched in the header, because a local edit is either silently lost at the next upgrade (and the fault returns) or silently kept (and the upgrade is not what it appears to be). Each proven fault is answered instead by a named function in GltfImportCore.cpp. GltfVendoredCgltf.EveryKnownCgltfFaultStillHasItsCnaSideAnswer (GltfVendoredCgltfTests.cpp) fails when any of the three answers is no longer declared or called there. A comment that merely mentions the name does not satisfy the check.

Fault in the vendored readerCNA's answer
A sparse accessor's values array is tightly packed by the specification, but cgltf_accessor_unpack_floats walks it at the base accessor's stride (cgltf's own validator sizes it tightly). The two agree whenever the base buffer view is tightly packed, so a corpus of such fixtures never exposes the fault. With an interleaved base, the first override is correct, the second receives the third override's value and the third reads past the values view.ApplySparseOverridesTightly re-reads the overrides with tight packing after every cgltf_accessor_unpack_floats call in the importer, including animation sampler output. A tightly packed base is left untouched (plan task GLTF-062).
Signed normalized components are divided without the specification's max(c/127, -1) / max(c/32767, -1) clamp, so the most negative integer decodes to −1.0079 (byte) or −1.00003 (short), outside the unit range.ClampNormalizedSigned clamps normalized signed byte and short accessors to −1; every other accessor takes the unchanged path (GLTF-056).
An accessor span computed with wrapping arithmetic can wrap to a small, plausible value that passes cgltf_validate.RequiredSpan computes offset + (count−1)·stride + elementSize and throws on overflow before any bounds check or read. Attribute and index decoding both use it (GLTF-039).

The sparse fault is also pinned in the opposite direction. GltfAccessorDecodeLock.VendoredParserStillMisreadsSparseValuesAtTheBaseStride asserts that the vendored reader still gets the interleaved case wrong. A cgltf upgrade that fixes the reader therefore fails a test and prompts retiring both copies of the workaround: the importer's and the one in the test-scope oracle DumpAccessorEXT. Index accessors are a separate case, because CNA does not decode indices through cgltf at all (see indices on the vertex-packing page).

Two BuildSkeleton overloads

GltfImportCore.hpp declares BuildSkeleton twice. The scene-aware form takes the graph that BuildSceneGraph produced and the world transform of the node instancing the skinned mesh. It fills the scene-node/palette maps and gives each root joint a parentWorldPrefix made of both terms: the ancestry above the joint set and the cancellation of the mesh node. Both production callers use it: the runtime reader in ContentManager.cpp (at unit scale 1.0) and the converter in gltf_to_cnj.cpp (with its unitScale argument).

The one-argument form BuildSkeleton(skin, unitScale) delegates with an empty scene graph and an identity mesh-node transform. It produces the same topological joint order and oldToNew remap, so the packed JOINTS_0 bytes are identical. For that reason the test-scope oracles that need only the palette order (the L5 buffer oracle and GltfOracleEXT) call it. Its scene-node maps stay empty or −1, and it never cancels the mesh node. Its source comment says every root joint keeps an identity prefix, but the code does something slightly different. Because no parent can be found in an empty graph, a root joint that has a parent node takes the cgltf_node_transform_world fallback and still receives the ancestry term. The prefix is identity only for a root joint without a parent. New loader code must use the scene-aware overload. With the one-argument form, a transformed mesh node goes uncancelled, which is half of the D8 problem described under skinning and animation. Checked by reading the sources above at 009d40f5; not executed.

The parser boundary

JSON .gltf and binary .glb enter through the same cgltf_parse_file call; there is no separate container code. The runtime reader installs CNA's file callbacks first (cgltf's default is a bare fopen, which opens through the ANSI code page on Windows), so the document and every external buffer and image resolve as UTF-8 paths. The steps then run in this order:

  1. URI containment, before any external file is read. ResolveExternalUriEXT refuses a drive-absolute name, any URI scheme other than a relative path (checked on the raw URI, so a percent-encoded %68ttp: does not become a scheme), a root-absolute or backslash-rooted path, a lexical escape such as a/../../b, and a path that escapes through a symbolic link (compared after weakly_canonical). Containment is compared component-wise, so /asset-evil never counts as inside /asset. This runs ahead of cgltf_load_buffers because that call resolves external buffer URIs itself and offers no veto hook. The offline orchestration runs the same check while collecting the file's external dependencies for the build manifest.
  2. Buffers and images. Embedded buffer views, base64 data: URIs and contained, percent-decoded external files are accepted for both buffers and images. Images are exposed as memory blocks; the runtime reader decodes them through a MemoryStream and Texture2D::FromStream without staging temporary files.
  3. Version. If asset.version is present it must equal "2.0"; the check is conditional, so a document without the field passes this step if cgltf accepted the rest. asset.minVersion is not inspected.
  4. Structural validation by ValidateGltfEXT, below.

Structural validation, in its deliberate order

ValidateGltfEXT is materially stronger than syntax parsing, and its order is not cosmetic:

  1. Alignment first. Every accessor's base, sparse-index and sparse-value offsets must be multiples of their component size, and a declared byteStride must be a multiple of 4. cgltf reads components through raw typed casts, so a misaligned view is undefined behaviour; it is refused. This check runs before cgltf_validate because the vendored validator itself reads index bytes through such a cast while bounding indices — a container fuzz under UBSan found the misaligned load inside the validator.
  2. Overflow-safe spans. Every accessor span (base and both sparse arrays) is recomputed with guarded arithmetic, and a bufferView whose offset plus length overflows is refused. cgltf's own arithmetic wraps silently, so an enormous declared count could otherwise pass its bounds check and later read far past the buffer.
  3. cgltf_validate; any failure is a rejection.
  4. Accessor bounds cross-check (CrossCheckAccessorBoundsEXT): float, non-normalized accessors whose decoded values fall outside their own declared min/max by more than a rounding tolerance produce a warning — aimed at decode errors such as all-zero sparse reads, not at last-digit exporter rounding.
  5. EXT_meshopt_compression on any bufferView is refused outright: cgltf parses the metadata but decoding needs a caller hook CNA does not provide, and an accessor over such a view would read undefined bytes.
  6. extensionsRequired: every entry the registry does not claim is refused by name.
  7. extensionsUsed: every entry the registry does not claim produces a warning carrying the registry's own note.

On the runtime path each rejection becomes a ContentLoadException naming the file; warnings go to the log and into the model's import report. None of this makes CNA a replacement for the independently pinned Khronos glTF Validator. The practical rule: run the Khronos validator on third-party content before shipping it. A successful CNA load proves the code paths it used accepted the file; it does not prove the document is conformant or that every optional extension was honoured.

Scene graph first, meshes second

BuildSceneGraph selects the document's default scene, falling back to the first scene. A file with no scenes array at all is not treated as empty: the importer walks every root node of the file instead, so its meshes keep the placement the file authored rather than landing at the origin. The walk is iterative (an explicit stack, because a pathological file can nest thousands deep), parent before child, preserving the file's own child order. Index 0 is a synthetic identity node named Root, because a glTF scene may have several roots while a Model has exactly one; each reachable glTF node follows exactly once, at the first path found.

Each node's local matrix comes from cgltf_node_transform_local, which already applies glTF's "matrix, or else TRS" rule, and is converted to CNA's row-vector convention by copying the affine basis. The world transform is composed as

W(i) = L(i) * W(parent(i)) — in row-vector form, the local transform first and then the parent's world.

No axis or handedness conversion is applied, and that is correct rather than an omission: glTF and the XNA conventions CNA uses are both right-handed with +Y up, −Z forward and a top-left UV origin. The only handedness value imported separately is a tangent's fourth component.

Each flattened node becomes a ModelBone, index for index after the synthetic root, with the node's local transform. Vertex positions stay in mesh-local space, so one glTF mesh instanced by two nodes becomes two ModelMesh placements with different parent bones rather than one destructively pre-transformed buffer. A rigid mesh is parented to its instancing node's bone. A skinned mesh's node still exists as a bone and keeps its transform, but the mesh is parented to the synthetic root, because the skin's joint matrices already account for the mesh node's coordinate space; applying both would transform it twice (the cancellation itself is described on skinning and animation).

Nodes unreachable from the selected scene do not enter the graph, and a mesh they instance is not imported. If no reachable node references any mesh, CollectMeshGroups falls back to exposing every mesh of the file, unplaced, at the identity root — useful for incomplete exports, but not the same result as an authored scene.

Groups are an ownership boundary

CollectMeshGroups partitions the reachable mesh placements into one group per distinct skin plus one group for unskinned placements, in the file's node-array order (the converter's output naming depends on that order). Each placement record carries the source node, the mesh, the flattened scene-node index, the composed world transform, whether it is skinned, and whether its composed transform mirrors (a negative 3×3 determinant).

The front ends package groups differently. The runtime reader assembles every group into one Model: Model::getSkinsEXTProperty() maps each skin to exactly the meshes its palette drives, and Model::Tag remains a compatibility alias for the first skin. That avoids both the old first-group-only data loss and the equally wrong alternative of posing every mesh with one palette. The runtime effect cache includes the skin in its key, so two skins never share one mutable palette. The offline orchestration writes each group as its own .cnj model: a single group keeps the base name, and with several groups the unskinned group gets _static and each skinned group _<skinName> (or _skinN); two skin names that collide after filename sanitising are refused rather than overwritten. (A source comment above ReadGltfModel still describes the old first-group-only behaviour; the code below it imports every group.)

Unit scale

The core's unitScale multiplies translation-bearing quantities only: vertex positions, bind-pose and inverse-bind translations, ancestor terms, animation translation values and their cubic tangents, morph position deltas and camera placements. It never touches rotations or dimensionless scale factors. The runtime reader fixes it at 1.0 (glTF mandates metres); cna_tool_gltf_to_cnj takes an optional positive unitScale argument and cna_tool_gltf_to_cnb a --unit-scale option, while cna-content has no unit-scale setting. Convert offline when a file was not authored in metres.

What extraction preserves

ExtractMesh produces an explicit semantic record per primitive before any graphics object exists. Along the way it:

  • requires every attribute of the primitive to have the same element count as POSITION, as the glTF specification requires, refusing a mismatch that would otherwise index past a shorter stream;
  • decodes positions, normals, tangents, up to two texture-coordinate sets, COLOR_0 (VEC3 or VEC4, float or normalized integer), JOINTS_0 and WEIGHTS_0, honouring offsets, strides, normalization and sparse overrides — including a sparse accessor with no base buffer view;
  • decodes indices with its own bounds-checked reader, synthesises the implicit sequence for a non-indexed primitive, and proves every index is below the vertex count before anything consumes it (details on vertex packing);
  • computes flat normals when a primitive that has faces (triangle lists, strips and fans) authors none, splitting vertices so each face owns its corners, in which case the specification also requires any authored tangents to be ignored (a point or line primitive has no surface, so it keeps the packer's placeholder normal (0, 0, 1)); generates a tangent basis for PBR primitives that lack one;
  • renormalises joint weights that do not sum to 1, leaves all-zero weights unweighted rather than binding them to an arbitrary joint, keeps only the first influence set, and reports each of those by count and worst magnitude;
  • ignores _* custom attributes by design and drops colour sets past COLOR_0 with a report entry, because XNA layouts carry one colour channel;
  • imports legacy _TANGENT/_BINORMAL VEC3 pairs as a tangent with a derived handedness.

Materials and the effect a primitive gets

The effect is chosen by the material model the file declares, not by which maps it happens to carry. glTF's default material is metallic-roughness, so a primitive with no material, a factor-only material, a normal-map-only material and a vertex-coloured metallic-roughness material all take the PBR route (PbrEffect, or SkinnedPbrEffect for a skinned primitive). Only a material declaring KHR_materials_unlit leaves it: it maps to BasicEffect with lighting disabled and the base colour as diffuse, or — for a skinned primitive — to SkinnedEffect with an all-white ambient and no directional light, since XNA's skinned effect has no lighting switch. The old forensic defect D7 (a factor-only material became an untextured BasicEffect with every property lost) is fixed; the rule and its history are in the import core's comments and the defect ledger.

The extracted material record carries the base-colour factor and alpha, metallic and roughness, emissive factor and KHR_materials_emissive_strength, IOR and KHR_materials_specular factor and colour, normal scale, occlusion strength, alpha mode and cutoff, double-sidedness, seven texture slots (base colour, normal, metallic-roughness, emissive, occlusion, specular, specular colour), an independent packed-UV selector and KHR_texture_transform per slot, a sampler per slot, and colour-space intent. Rigid and skinned PBR effects receive the same parameter convention. Samplers map onto XNA exactly — all eight min/mag/mip filter combinations have an XNA TextureFilter, and CLAMP_TO_EDGE/MIRRORED_REPEAT/REPEAT become Clamp/Mirror/Wrap — and are stored per part in ModelMeshPart::getSamplerStatesEXTProperty(). PNG and JPEG images import with one level, so a mipmapped minFilter is reported rather than silently served by a generic box-filtered chain that would be wrong for normal and packed-data maps.

Three narrower routes remain. KHR_materials_pbrSpecularGlossiness is converted (diffuse to base colour, metallic 0, roughness 1 − glossiness) and its specular factor dropped, with a warning when that specular was strong. KHR_materials_transmission is approximated as alpha blending with alpha = 1 − factor, always reported, and not physical. A DualTextureEffect lightmap approximation — base colour plus an occlusion map rewritten for XNA's "0.5 is neutral" convention — is reached only by an unskinned, uncoloured primitive whose material is unlit and carries both maps; an ordinary metallic-roughness material with an occlusion map goes to PbrEffect, which samples a real occlusion map (the remap is described on the CNJ toolchain page). A SkinnedEffect part without a base-colour texture is given a 1×1 white texture, because XNA's skinned effect samples an unbound texture as black while glTF defines a missing base-colour texture as white.

Punctual lights are applied to every non-unlit effect that implements IEffectLights: at most three, each approximated as a directional light (point and spot lights aimed at the scene origin), color × intensity clamped to [0,1], range and cone ignored — every one of those losses counted. A file that declares no light imports correctly and still renders black under PbrEffect's own defaults until the application sets lighting; the reader logs "zero lights contributed" for exactly that case.

The extension registry is the acceptance authority

One source-controlled registry, GltfExtensionRegistryEXT(), holds 21 records, each with a support level, whether the extension is claimed, a note and an owning task. extensionsRequired may only name claimed extensions; the support levels are Implemented, ImplementedWithNamedLimit, Approximated, ParsedButIgnored, Unsupported and NotDesired (a recorded decision not to implement). The claimed and unclaimed lists are on Model Loading. CNA's limitations document is generated from the registry and checked against it by GltfLimitationsDoc.ExtensionTableAgreesWithTheRegistry. Representative outcomes:

  • KHR_texture_transform is independent per map rather than baked into one UV stream.
  • KHR_materials_specular factors are consumed by every PBR renderer; its two texture maps are sampled by eight renderer families (DirectX 9, 11 and 12, EasyGL, OpenGL 4, SDL_GPU, Vulkan and WebGPU, per SpecularTextureInventoryClassifiesEveryPbrRenderer) and not by METAL, which is factor-only. Required use is still refused. (The registry's own prose lists seven of those eight families; the test is the machine-checked partition.)
  • KHR_texture_basisu and EXT_texture_webp have no decoder: a PNG/JPEG fallback in the file is used when present, and otherwise the dropped map is named per slot.
  • Clearcoat, sheen and volume are ParsedButIgnored for the stock effects; their factor values are nevertheless copied onto the imported material so the engine layer's glTF material bridge can use them (see CNAEXT engine layer).
  • KHR_draco_mesh_compression is claimed only in a build with the decoder (CNA_ENABLE_DRACO, on by default and off under Emscripten); with Draco only triangle lists and strips are accepted.
  • EXT_mesh_gpu_instancing imports each node's single placement and not its per-instance transforms, so a forest renders as one tree; the file-level count is reported.

Keep four claims separate for every row: parsed, represented on the model, reported in the import report, and drawn by the renderer you ship.

The import report

GltfImportReportEXT (GltfImportReportEXT.hpp) is filled by the direct reader and carried through converter-produced .cnj; every other route returns the all-zero default. It holds node, mesh-placement, distinct-mesh, shared-mesh, depth, camera-node, light-node, imported-light, primitive, skin, animation and clip counts, plus an ordered list of GltfImportDiagnosticEXT entries. Each entry has a stable lower-case Code, a Severity (Information or Warning), a Kind (Information, GeneratedData, InvalidSourceData, Approximation, DroppedData, UnsupportedFeature), a subject, an occurrence count, a WorstMagnitude whose unit the code defines, detail names and a human message.

AnythingLost() is true when any warning is present; getDroppedFeatureCountProperty() sums dropped-data and unsupported-feature occurrences and getApproximationCountProperty() sums approximations. Branch on codes such as uv-set-mismatch, rigid-animation-dropped-on-skinned-model, mirrored-winding-unapplied, gpu-instancing-dropped, influence-sets-dropped or transmission-approximated, never on message text or vector position. The third-UV-set case in particular is reported on the runtime path as well as by the converter.

Failure and exception boundaries

The core throws std::runtime_error for semantic rejections: an undefined primitive mode, an attribute count mismatch, an out-of-range index, a skin with more than 72 joints, a joint listed twice, an inconsistent joint hierarchy. ReadGltfModel itself converts parse, containment, buffer, version and validation failures into ContentLoadException. For everything else, ContentManager::Load<T> catches any std::exception thrown by a loose-file reader and rethrows it as ContentLoadException("ContentManager: could not load asset '…'", inner), keeping the original as the inner exception. A caller catching ContentLoadException therefore sees every glTF import failure at this snapshot; earlier revisions let extraction errors escape as bare std::runtime_error, a gap closed on 2026-09-01. The offline converter prints error: … and exits with status 1.

What one import allocates, and what it shares

On success the runtime reader creates bones, meshes, parts, buffers, effects, textures, skinning data, rigid clips and morph records in one private ModelResources bundle and attaches it with setOwnedResources, so the otherwise raw-pointer model is self-retaining (see model lifetime). Within one import:

  • images are decoded once per cgltf_image and shared; the occlusion image rewritten for DualTextureEffect has its own cache, because the same source image may also feed an unmodified PbrEffect occlusion map;
  • effects are shared per (source material, skin, import shape, packed UV mapping), so primitives that use one material share one effect — including material-variant overrides — while two skins never share one palette;
  • each glTF mesh placement becomes one ModelMesh with one ModelMeshPart per primitive and one bounding sphere, named after the glTF mesh, with the node name appended only when the mesh has several placements (an unnamed mesh falls back to its node's name, then to an index);
  • vertex and index buffers are per primitive, and a variant with a different vertex layout gets its own buffer and its own morph carrier.

Across loads, sharing is the ContentManager's cache: a second Load of the same name in one manager returns the same graph, and a second manager decodes everything again. Cameras (ModelCameraEXT) are imported by walking the scene graph, so a camera instanced by two nodes is two placements; a perspective camera without zfar receives an infinite projection, an orthographic one a volume of twice its half extents, and an absent aspect ratio is recorded as assumed.

Evidence

Checked by reading the TARGET sources and tests at 009d40f5; not built or executed. The behaviours above are pinned by the suites under modules/content/tests/CNA/Internal/GltfImport (container validation and robustness, URI containment, extension registry, scene selection, node hierarchy, sampler mapping, unlit material, material variants, cameras, import report and more) and by the direct-load suite RuntimeGltfModelTests.cpp; how those suites are organised into a ladder, and what the pixel evidence is, is on glTF conformance. Users start at Model Loading and Tutorial 110.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
Test labels (gltf-conformance)