Chapter 39 From glTF File to Model: the Import Core
CNA does not have a small “glTF reader” hidden inside ContentManager. It has a shared import library, CNA::Internal::GltfImport, used by two independent front ends: the runtime .gltf/.glb path and the offline cna_tool_gltf_to_cnj converter. This distinction matters. Parsing and semantic extraction are shared; turning the extracted records into live graphics objects or persistent CNJ sidecars is not.
The shared core is about 2,500 lines across GltfImportCore.hpp and GltfImportCore.cpp. It delegates container parsing and accessor decoding to the vendored cgltf 1.15 library. Exactly one translation unit defines CGLTF_IMPLEMENTATION; the stb image implementations in the same translation unit are declared static so they cannot collide with another copy linked through SDL image support. That is a build invariant, not incidental preprocessor decoration.
39.1 The parser boundary
Both JSON .gltf and binary .glb enter through the same cgltf_parse_file path. If asset.version is present, both front ends require it to equal "2.0"; their explicit check is conditional, however, so a document with the field absent can pass this boundary if cgltf accepts the rest. asset.minVersion is not inspected. Buffers are then loaded relative to the source path. Embedded buffer views, base64 data URIs, and external percent-decoded file URIs are supported. Images use the same three storage forms and are exposed as memory blocks; the runtime front end decodes those blocks without staging temporary files. External paths pass through ResolveExternalUriEXT: URI schemes, drive-absolute and root-absolute names, lexical escapes, and symlink-resolved escapes outside the asset directory are refused.
Alpha.1 performs a structural gate before semantic extraction. Metadata-only alignment and overflow-safe span checks run before cgltf_validate, because the vendored validator can otherwise dereference misaligned index data while attempting to validate it. The importer then cross-checks authored accessor bounds, refuses undecoded EXT_meshopt_compression, rejects every unsupported extensionsRequired entry, and records warnings for unsupported optional extensionsUsed. This is materially stronger than syntax parsing, although it does not make CNA’s importer a replacement for Khronos’s independently pinned validator.
Practical rule.
Run the Khronos validator before shipping third-party content. A successful CNA load proves that the code paths it used were acceptable; it does not prove that the complete document is spec-conformant or that every required extension was honoured.
39.2 Scene graph first, meshes second
BuildSceneGraph selects the document’s default scene, falling back to the first scene, then walks it iteratively in parent-before-child order. Index 0 is a synthetic identity node named Root; each reachable glTF node follows exactly once. The core asks cgltf for the local matrix, converts it to CNA’s row-vector convention, and composes
The conversion copies the affine basis into CNA’s matrix convention. It does not apply an axis or handedness conversion, and that is correct: both glTF and the XNA conventions used here are right-handed, use as up, as forward, and place the UV origin at the top left. The only handedness value imported separately is a tangent’s fourth component.
Each flattened scene node becomes a ModelBone, index-for-index after the synthetic root. Vertex positions remain in mesh-local space. Consequently one glTF mesh instanced by two nodes yields two ModelMesh placements with distinct parent bones instead of one destructively pre-transformed vertex buffer. For rigid geometry the mesh is parented to its instancing node. For skinned geometry the node still exists, but the mesh is parented to the synthetic root because skin matrices already account for the mesh-node coordinate space; doing both would apply the transform twice.
Nodes unreachable from the selected scene do not enter this graph. If no scene node references any mesh, the importer has a compatibility fallback that exposes every mesh at the identity root. That fallback is useful for incomplete authoring exports, but it is not the same semantic result as a fully authored scene.
39.3 Groups are an ownership boundary
CollectMeshGroups partitions scene instances into one group for each distinct skin pointer plus one group for unskinned content. A group record contains the source node, mesh, flattened scene-node index, composed world transform, and whether the instance is skinned. This division is the unit that later becomes one model output.
The offline converter writes every group as a separate CNJ model, with stable suffixes for the static group and named skins. The runtime path instead assembles all groups into one Model. Model::SkinsEXT maps each independent skin to the meshes using its palette, while Model.Tag remains a compatibility alias for the first skin. This avoids both the old groups.front() data loss and the equally incorrect alternative of posing all meshes with one palette.
The two front ends also differ in scale control. The core’s unitScale multiplies translations—including vertices, bind translations, animation translations and tangents, morph position deltas, inverse binds, and ancestor terms—but never rotations or dimensionless scale factors. The runtime path fixes it at 1.0; only the command-line converter exposes a caller-supplied value.
39.4 What extraction preserves
For each triangle primitive, the core produces an explicit semantic record before any graphics object is allocated. It decodes positions, normals, tangents, texture coordinates, colours, joint indices and weights; validates every index; synthesises a sequential index stream for a non-indexed primitive; and computes tangents when required by a normal-mapped material. Sparse attributes and sparse indices, including an accessor with no base buffer view, are handled.
The factor-only material failure recorded as D7 is fixed. The extracted material record retains base colour, metallic, roughness, emissive, IOR/specular factors, alpha mode/cutoff, double-sidedness, seven texture slots, independent UV-set selectors, per-map transforms, samplers, and colour-space intent. Rigid and skinned PBR effects receive the same shared parameter convention. This is a broad transport contract, not proof that every renderer implements every optional extension texture.
The source-controlled extension registry is the acceptance authority. Representative outcomes include:
-
•
KHR_texture_transform, independent per map rather than baked into one stream;
-
•
KHR_materials_unlit, variants, IOR, emissive strength, and core material factors;
-
•
KHR_materials_specular, whose factors are implemented but whose optional texture bindings retain renderer-specific gaps;
-
•
KHR_materials_transmission and archived specular–glossiness, accepted only as named approximations and therefore not claimed when required;
-
•
KHR_lights_punctual, reduced to at most three directional-light approximations with lost range/cone/intensity facts reported; and
-
•
KHR_draco_mesh_compression, only when CNA was compiled with libdraco.
PNG/JPEG fallbacks can rescue a texture that also advertises KTX2/BasisU or WebP; without a fallback those formats remain unsupported. Clearcoat, sheen, and volume are parsed but ignored; meshopt compression and GPU instancing are refused or reported at their actual loss boundary. Cameras and material variants are preserved through CNAEXT Model properties. GltfImportReportEXT carries scene counts and ordered diagnostics for every dropped or approximated feature on both direct and CNJ routes. Keep “parsed”, “represented”, “reported”, and “drawn by this renderer” as separate claims.
39.5 Failure and lifetime boundaries
The core throws std::runtime_error for several semantic rejections, including an unsupported primitive topology. The runtime reader does not normalize all of these into ContentLoadException; a caller catching only the latter can miss a glTF import failure even though it derives from std::runtime_error. This exception asymmetry is part of the current contract.
On success, the runtime front end creates model bones, meshes, parts, buffers, effects, textures, skinning records, and morph records in one private ownership bundle, then attaches that bundle to the otherwise raw-pointer-heavy Model. Images are cached by source image pointer for that one import. The result is self-retaining, but repeated primitives still receive their own mesh-part graphics resources unless the front end explicitly shares them.