Model, ModelMesh and ModelMeshPart: the runtime graph and its draw contract

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 sources and tests at 009d40f5; not built or executed. FNA comparisons are CNA's own source and test comments. Pixel-level draw evidence is the renderer example programs and glTF L7 campaigns, not the CPU unit tests.

A loaded Model looks like an XNA value type, but it is a thin wrapper around a graph of raw pointers whose lifetime is carried by one shared ownership handle. This page states exactly what Model::Draw and ModelMesh::Draw do, which invariants a normal content load supplies and a hand-built graph must supply itself, what a copy shares, how the five model-producing content routes differ in the graph they build, and where the collections deliberately diverge from FNA. It is written for engine code that draws, mutates or constructs models, and for maintainers of the model classes.

The object graph

The shape is XNA's: Model holds a ModelBoneCollection and a ModelMeshCollection; each ModelMesh holds a ModelMeshPartCollection and a derived ModelEffectCollection; each ModelMeshPart points at a VertexBuffer, an IndexBuffer and an Effect. The classes live in Model.hpp and its neighbours, implemented under modules/graphics/src/Xna. Every link in that graph is a non-owning raw pointer: parts store raw buffer, effect and Tag addresses, meshes store raw part and GraphicsDevice addresses, bones store raw parent and child addresses, and the model's two collections are vectors of raw bone and mesh pointers.

What keeps a loaded graph alive is a single private member, std::shared_ptr<void> ownedResources_, set through the CNAEXT hook setOwnedResources. Every content reader allocates its bones, meshes, parts, buffers, effects, textures and CNAEXT carriers into one private bundle and hands that bundle to the model before returning it. The distinction that matters at runtime is therefore not "which format did this come from" but "is this a reader-built, internally consistent graph or a hand-built one" — a hand-built graph has no owner at all unless its author installs one (see Hand-built graphs).

Several XNA members that FNA reserves for its own internal reader are public in CNA, because CNA's own readers and tests construct graphs through them: the CNAEXT Model constructors, ModelBone(int, std::string) and ModelBone::AddChild, the ModelMesh constructors, the read-write ModelMesh::setBoundingSphereProperty and setParentBoneProperty, and the CNAEXT ModelMeshPart mutators SetVertexBuffer, SetIndexBuffer, SetVertexOffset, SetNumVertices, SetStartIndex and SetPrimitiveCount (the header notes that XNA's own setters are content-pipeline-only). Exposing them turns reader-populated metadata into caller-maintained state, which is exactly where the draw contract below becomes important.

Model::CopyAbsoluteBoneTransformsTo, CopyBoneTransformsFrom and CopyBoneTransformsTo each throw ArgumentOutOfRangeException when the caller's vector is shorter than the bone count, and otherwise loop over Bones.Count rather than over the caller's array. A larger vector is accepted and its extra elements are left untouched; CNA's tests pin this (CopyBoneTransformsToAcceptsALargerDestinationIgnoringExtraElements) and their comments record it as a deliberate deviation from FNA, whose loop bound is the destination array length. For correctly sized callers nothing changes.

Two ways to draw

The convenience call is correct for any loaded, renderable model with at least a root bone:

Model model = getContentProperty().Load<Model>("models/ship");
model.Draw(world, camera.View(), camera.Projection());

A multi-bone model (a vehicle with independently animated wheels, an articulated arm) can also be drawn by hand, composing each mesh's own parent-bone absolute transform into world space first. The loop below keeps the matrix set generic through IEffectMatrices, which matters because direct glTF and converted .cnj models carry PbrEffect or SkinnedPbrEffect for ordinary metallic-roughness materials, so a dynamic_cast<BasicEffect*> loop silently skips them:

std::vector<Matrix> boneTransforms(model.getBonesProperty().getCountProperty());
model.CopyAbsoluteBoneTransformsTo(boneTransforms);

for (ModelMesh* mesh : model.getMeshesProperty()) {
    // Model::Draw treats a null ParentBone as bone 0; a manual loop must choose explicitly.
    ModelBone* parent = mesh->getParentBoneProperty();
    const int boneIndex = parent != nullptr ? parent->getIndexProperty() : 0;
    const Matrix meshWorld = boneTransforms[static_cast<std::size_t>(boneIndex)] * world;

    for (Effect* effect : mesh->getEffectsProperty()) {
        if (auto* matrices = dynamic_cast<IEffectMatrices*>(effect)) {
            matrices->setWorldProperty(meshWorld);
            matrices->setViewProperty(view);
            matrices->setProjectionProperty(projection);
        }
    }
    mesh->Draw();   // one indexed draw per positive-count part, per effect pass
}

The index selects the mesh's parent-bone absolute transform, not bone 0 and not the model root. This is the pattern for all current Model routes: the XNB reader assigns each mesh its serialized parent, a version-2 .cnj names a parentBone, and direct glTF parents a rigid mesh to the bone of the node that instances it. An earlier loose-JSON route assigned one default bone to every mesh, which welded independently placed parts together; that behaviour is gone. Skinned meshes are the exception: they hang off bone 0 by design and are positioned by their skin palette, not by this loop (see skin palettes).

What Model::Draw does, exactly

Model::Draw (Model.cpp) is a thin convenience path, not a validator or a scene graph. In order, it:

  1. grows a per-thread scratch vector of matrices (static thread_local std::vector<Matrix> sharedDrawBoneMatrices_) to the bone count if it is shorter — it never shrinks — and fills it with CopyAbsoluteBoneTransformsTo;
  2. visits every mesh, and for every entry of that mesh's ModelEffectCollection requires IEffectMatrices: an effect that does not implement it makes the call throw System::InvalidOperationException (the XNA exception type, pinned by ModelTest.DrawRejectsAnEffectWithoutIEffectMatricesWithXnaException), rather than being skipped or drawn with another convention;
  3. writes World = boneAbsolute[parentIndex] * world, then View and Projection, using bone index 0 when the mesh's ParentBone is null;
  4. calls ModelMesh::Draw().

It never installs a skin palette. A skinned effect's SetBoneTransforms is the application's (or an animation helper's) job; Model::Draw only sets the ordinary world, view and projection matrices.

ModelMesh::Draw (ModelMesh.cpp) first calls the renderer's Ensure3DSupported("ModelMesh::Draw") when the mesh has a device — the shared default is a no-op, and among the current identities only the DIRECT2D, HTML_DOM and SVG_DOM renderers override it to refuse at that point; other 2D-only renderers refuse deeper, inside the draw. It then skips any part whose Effect is null or whose PrimitiveCount is not positive, binds the part's vertex and index buffers, and for every pass of the effect's current technique calls Apply() and DrawIndexedPrimitives with the part's own CNAEXT getPrimitiveTypeEXTProperty() topology, its vertex offset, vertex count, start index and primitive count. The topology travels with the part (glTF points and lines are drawn as what they are); every part built by a non-glTF path defaults to TriangleList, so the draw is unchanged for them.

The null-effect skip is a CNA deviation (CNA's source comments record that FNA skips only the count case and then dereferences the effect). No other field is validated: a positive-count part passes its raw buffers, offsets and counts straight to GraphicsDevice, so a missing buffer or an out-of-range run fails at the device or renderer boundary rather than being skipped. A successfully constructed model is therefore not proof that it is drawable. One more sharp edge: Ensure3DSupported is guarded by a null check on the mesh's device, but the draw calls after it are not, so a positive-count part in a mesh built with a null device dereferences null.

Three invariants a normal load supplies

That compact loop depends on three properties that every reader establishes and that the public hand-build API does not enforce:

  • A drawable model needs at least one bone. Treating a null ParentBone as bone 0 is a C++ safety choice that makes a one-root hand-built model work (FNA dereferences the parent). It does not make a zero-bone model drawable: with an effect present, index 0 is read from a scratch vector the model never grew — an empty vector on a fresh thread, or a stale slot left by an earlier, larger model on the same thread — and operator[] is not bounds-checked. Give every model that can draw a root bone.
  • Bones must be stored parent before child. CopyAbsoluteBoneTransformsTo computes dest[i] = bone.Transform * dest[parentIndex] in a single forward pass. It neither sorts nor detects cycles, duplicate parents or an index that disagrees with the vector position; a child listed before its parent silently composes against an unwritten slot. Every reader emits parent-before-child order; a hand-built graph must do the same.
  • A manual loop has no built-in fallback. Unlike Model::Draw, which uses bone 0 for a null ParentBone, a loop that writes mesh->getParentBoneProperty()->getIndexProperty() dereferences null for a parentless mesh; the example above therefore chooses bone 0 explicitly. Dereferencing directly is the loud choice, for a call site that wants an invalid attachment to fail there instead of drawing at the root. Current loaders assign a parent to every mesh, with one exception: the XNB reader leaves ParentBone null when the file's serialized parent reference is null.

Keeping parts and effects connected

ModelMesh::Effects is a derived, distinct-effect view of the effects its parts use; it is what Model::Draw configures before ModelMesh::Draw applies each part's technique. The supported way to maintain it is the part setter:

part->setEffectProperty(&basicEffect); // adds basicEffect to the owning mesh's Effects if absent
// ...
part->setEffectProperty(nullptr);      // removes it only if no sibling part still uses it

ModelMeshPart::setEffectProperty (ModelMeshPart.cpp) is a no-op for the same pointer; otherwise it scans the sibling parts and removes the old effect only when none still uses it, then adds the new non-null effect only if the collection does not already contain it. Two parts sharing one BasicEffect therefore produce one matrix setup in Model::Draw but two indexed draws. The unit tests in ModelMeshPartTests.cpp cover exactly the retain-on-shared, release-on-last-user and no-duplicate transitions.

Order matters for hand-builders: a part only learns its owning mesh when the ModelMesh constructor adopts it, and setEffectProperty on an orphan part just stores the pointer. An effect set before the mesh exists never reaches ModelMesh::Effects, so Model::Draw would never set its matrices. CNA's own glTF and .cnj readers hold each part's effect aside and attach it only after constructing the mesh for that reason; material-variant switching likewise sets the effect last, after the vertex buffer, tag and samplers, so an observer that finds the new effect through Effects sees complete part state.

ModelEffectCollection::Add/Remove and ModelMesh::getEffectsPropertyMutable() are CNAEXT escape hatches that exist to make the readers possible. Add does not deduplicate and Remove removes only the first match; neither repairs a collection a caller has made inconsistent with its parts. A stale extra effect is configured by Model::Draw but never applied by any part, and a duplicate is configured twice. Do not populate Effects directly in game code: set each part's effect, and keep buffers and effects alive for as long as the model uses their raw pointers.

Threads and the scratch buffer

The scratch vector is thread_local. FNA shares one static matrix array across every model in the process; CNA's header comment explains that the array is pure scratch — overwritten in full at the top of every Draw and never read afterwards — so one buffer per thread is observably identical for single-threaded code, while a process-wide buffer let two threads resize and rewrite the same vector (a use-after-free, not merely a wrong matrix). ModelTest.ConcurrentDrawsOnDifferentModelsDoNotShareTheBoneScratchBuffer runs four threads drawing 2-bone and 150-bone models 400 times each and checks that every effect received its own thread's transform; its parts have a zero primitive count, so it exercises only the matrix binding, not the device.

That fixes the scratch race only. The graphics device, effects, buffers and a shared mutable model graph are not made thread-safe: concurrent mutation of bones, parts or effects is ordinary shared-state contention, and renderer resources keep their own thread-affinity rules. Keep a model's update and draw sequence on the thread that owns its graphics device.

What the tests cover and what they do not

The CPU tests in ModelTests.cpp (24 cases) cover transform composition order, destination-size checks, the four- and five-argument constructors, the whole-model bounding sphere, the IEffectMatrices refusal and the concurrency case; the part and collection suites cover the bookkeeping. Pixel-level drawing of Model graphs is exercised by EasyGL example programs under the EasyGL examples (easygl_model_draw_test, easygl_model_hierarchy_child_mesh_test, easygl_model_two_meshes_effects_test and the loose-model reader readbacks), while sdlrenderer_model_draw_throws_test checks that a fully populated, positive-count model reaches the 2D-only renderer's 3D refusal. The same three EasyGL programs are also compiled and registered as tests for other renderers instead of being rewritten: SDL GPU (its SdlGpu_EasyGLOracle_model_* loop, which cites SDLGPU-79), Vulkan (Vulkan_Model_TwoMeshesEffects, Vulkan_Model_HierarchyChildMesh and a model draw test), the Software renderer, OpenGL 4 (the EasyGL parity corpus) and DirectX 11 and 12 (the D3D parity fixtures Model_Draw, Model_HierarchyChildMesh and Model_TwoMeshesEffects); those registrations were read, not run. For the remaining renderers this page found no such registration and relies on the glTF L7 campaigns described on glTF conformance. No located test deliberately covers a zero-bone draw, invalid parent order or cycles, out-of-range part fields, or a directly desynchronised Effects collection: treat those as construction-time invariants, not as tested recovery paths.

Five content routes that produce a model

ContentManager::Load<Model>("name") (ContentManager.hpp) resolves, after the per-manager cache: name.xnb, then name.cnb, then — only when the name itself ends in .cnb — that literal file, then the literal name, then name.cnj, then name.gltf and name.glb. An .xnb sibling therefore hides everything, a .cnb hides the loose sources it was compiled from, and a same-named .cnj hides a .gltf/.glb; passing an existing literal name.gltf reaches that file before the extensionless .cnj probe. The full ladder is on Model Loading. The graphs the routes build differ in ways that are easy to miss:

RouteReaderBones and parentsBounds, Tag and sharing
.xnb (XNA/MonoGame/FNA or cna-content --format xnb)ModelReader in ModelContentTypeReaders.cppThe serialized bone hierarchy; each mesh gets its serialized parent (null when the file's reference is null); the model's Root is the serialized root index, passed through the five-argument constructor.Serialized BoundingSphere; shared VertexBuffer/IndexBuffer/Effect resources resolved as XNB shared resources; model, mesh and part Tag values are kept when they deserialize as a std::shared_ptr<System::Object> or as a string-keyed dictionary (boxed into CNA::Content::ObjectDictionaryEXT), any other non-null shape is a ContentLoadException.
.cnbCNB model decoder in ContentManager.cpp (schema 1 from glTF/.cnj, schema 2 for XNB-originated models)Schema 1: rebuilt through the same helpers as the .cnj reader (root bone 0). Schema 2: the serialized bone tree, each mesh's parent bone and the stored root bone, passed through the five-argument constructor.Schema 1: computed mesh spheres; skeleton, clips, morph data and lights restored. Schema 2: the serialized mesh BoundingSphere, shared vertex and index buffers and the five stock effects, without the glTF extras. Neither carries cameras, material variants or an import report. See CNB Format.
self-contained .cnjModelTypeReader in ContentManager.cppVersion 2: the serialized bones array and each mesh's parentBone. Version 1 (or a file without a hierarchy): one Root plus one synthetic child bone per mesh, named after the mesh.Mesh spheres computed from the vertex positions; SkinningData or ModelAnimationsEXT on Model::Tag, MorphTargetDataEXT on part tags, mesh Tag null. Stock effects and buffers are created per described part; a named custom effect goes through the manager's cached Load<std::shared_ptr<Effect>>, so repeated names can share one object — this is not XNB's shared-resource graph. sourceFile is rejected.
direct .gltf/.glbReadGltfModel in ContentManager.cppAn identity Root followed by one bone per reachable node of the selected scene, parent before child; a rigid mesh hangs off its instancing node's bone, a skinned mesh off Root.Mesh spheres computed from positions (after any default morph weights); every skin in Model::SkinsEXT, the first also on Tag; cameras, material variants and the import report filled. One effect per material, skin and import shape, shared by the primitives that use it. See the glTF import core.
.skinnedmodel.jsonSkinnedModelTypeReaderNot a Model at all: Load<std::shared_ptr<SkinnedModelEXT>> returns the Avatar-oriented type with its own skeleton arrays.Sidecars resolve relative to the manifest's own directory. See SkinnedModelEXT.

The legacy .skinnedmodel.json route is neither a .cnj alias nor an old name of the current Model descriptor: code loading it opts into a different public type, animation representation and draw path, and its presence is a compatibility route, not evidence that the generic Model reader accepts a fifth spelling. The historical .model.json name survives only in source comments and test-target names; it is not in the Model reader's extension list, which is .cnj, .gltf, .glb.

Which route for which job

Use .xnb when compiled XNA asset fidelity is the requirement, a .cnb for a compiled CNA asset, .cnj for a reviewable, self-contained description (including converter output with a unit scale or one asset per mesh group), direct glTF/GLB for in-process import of a glTF 2.0 file, and the legacy route only when the application consumes SkinnedModelEXT. The routes are not interchangeable even where tests prove them equivalent for a field: only direct glTF fills cameras, and only direct glTF and .cnj fill the import report and material variants. The CNJ route's own details are on the CNJ model toolchain.

Copies, caching and ownership

Model is copy-constructible with the compiler-generated copy. A copy duplicates the two collection wrappers, the Root and Tag raw pointers, the camera list, the skin list and the import report, and it shares the shared_ptr ownership handle and the material-variant state. Every ModelBone, ModelMesh, ModelMeshPart, buffer and effect below is the same object. A bone-transform, part or effect mutation made through one copy is therefore visible through every copy — shared mutable model state, not a way to pose or re-material one instance independently — and selecting a material variant on one copy switches the shared parts for all of them.

Two qualifications keep "shared" precise. Reassigning the top-level Tag changes only that wrapper's pointer; another copy still points at the original object. A mesh Tag, a part Tag or any bone, part or effect change is made on the shared graph and is seen everywhere. FNA returns its cached Model class reference, so managed assignment aliases even the top-level wrapper; CNA's value syntax is not a deep-copy promise.

Each reader fills the ownership handle before returning: the XNB reader's bundle retains bones, meshes, parts, shared buffers, effects and tag objects; the .cnj, .cnb and glTF readers share one ModelResources bundle that also retains decoded textures, skinning data, rigid clips and morph data. That is why a cached or caller-held model can keep raw pointers — and why Model::Tag and part tags stay valid for as long as any copy lives.

The cache follows from the representation:

Model first  = content.Load<Model>("ship");
Model second = content.Load<Model>("ship"); // another wrapper over the same bones, meshes, buffers

first.CopyBoneTransformsFrom(newPose);       // second observes the same bone transforms
content.Unload();                            // clears the manager's cache entries only
// first and second still keep the reader-owned graph alive through their ownership handle

This behaviour is test-pinned at this snapshot. GltfPerformance.LoadingOneNameTwiceReturnsTheSameInstanceAndAFreshManagerDoesNot (GltfPerformanceTests.cpp) shows two loads return the same parts and buffers, that an effect mutation through one load is seen through the other, and that a second ContentManager shares nothing; ACopiedModelSharesItsOwnedResourcesAndOutlivesTheOriginal shows a copy shares the vertex buffer and stays valid after the original is destroyed; UnloadThenLoadAgainYieldsAWorkingModel and AThousandLoadUnloadCyclesLeakNothing (a leak check under the sanitizer CI job) cover repeated unload. The XNB suite's LoadingTwiceReusesTheCachedModelLikeAnyOtherAsset pins the same cache identity for compiled models. A caller that needs two independent copies of one asset uses two managers.

ContentManager::Unload() clears the manager's asset map; it does not walk a loaded model or invalidate a caller-held value. A retained model keeps its bundle, but it still relies on the separately unowned GraphicsDevice: drop every model copy before tearing down the device, and do not mistake cache eviction for a safe concurrent reload or a universal resource destructor. The manager-wide cache and disposal rules are on content runtime internals.

Hand-built graphs

The CNAEXT constructors Model(GraphicsDevice*, bones, meshes) and Model(GraphicsDevice*, bones, meshes, meshParentBones, rootBoneIndex = 0) store the caller's raw bone and mesh addresses; they allocate nothing and install no ownership bundle. (The model constructors do not even keep the device pointer; meshes do.) The four/five-argument form requires meshParentBones to be empty — every ParentBone stays null, exactly like the three-argument form — or to have one entry per mesh, and throws std::out_of_range("meshParentBones") otherwise. The constructors are extensions precisely because FNA creates these objects inside its readers.

A hand-built model is drawable only while its whole graph and its device outlive every copy that uses them. Stack-allocated construction is fine for a synchronous test; returning the model after its bones, meshes, parts, buffers, effects or tag objects died leaves dangling pointers. An owner can attach one aggregate lifetime object with setOwnedResources(std::shared_ptr<void>), mirroring the readers; the hook retains only what the supplied object owns and does not make a raw device pointer, an externally assigned tag or any other unowned address safe.

Worked example: naming an arbitrary root bone

In XNA and FNA the Model constructor never sets Root: the content reader assigns model.Root = bones[rootBoneIndex] afterwards, and that index may name any bone. CNA's earlier four-argument constructor had no equivalent and always used bones[0] — right for the common case, unable to represent a model whose true root is not first. The fix is a fifth, defaulted parameter, additive so that no existing call changes behaviour:

CNAEXT Model(GraphicsDevice* graphicsDevice,
             std::vector<ModelBone*> bones,
             std::vector<ModelMesh*> meshes,
             std::vector<ModelBone*> meshParentBones,
             std::size_t rootBoneIndex = 0);   // the public equivalent of the reader's Root assignment

One edge case is easy to get wrong: an empty bones vector must leave Root null whatever index was requested, rather than throwing on the harmless default 0. The constructor body therefore checks emptiness first, matching the three-argument constructor's leniency, and bounds-checks only a genuinely non-empty vector:

if (!bones_.bones_.empty())
{
    if (rootBoneIndex >= bones_.bones_.size())
        throw std::out_of_range("rootBoneIndex");
    root_ = bones_.bones_[rootBoneIndex];
}
// bones_ empty: root_ stays nullptr regardless of rootBoneIndex

The regression tests are worth naming for the technique they show, not just for existing:

  • FiveArgConstructorDefaultRootBoneIndexMatchesFourArgBehavior
  • FiveArgConstructorHonorsNonZeroRootBoneIndex
  • FiveArgConstructorThrowsWhenRootBoneIndexOutOfRange
  • FiveArgConstructorEmptyBonesLeavesRootNullEvenWithDefaultIndex

The non-zero case builds a three-bone hierarchy, selects index 2 and asserts both equality with the expected bone and inequality with bone 0 — a test that asserted only equality could pass by coincidence if the parameter were ignored. The project's commit record describes a sabotage-and-revert check (the constructor temporarily edited to ignore the index, and exactly the two index-dependent tests failing); that run is historical evidence and was not reproduced here. The lesson stands on its own: a test suite's ability to catch a regression is itself worth verifying.

At this snapshot the XNB ModelReader uses this constructor to honour the file's serialized root index, and so does the schema-2 .cnb loader, which stores that root bone for XNB-originated models (a schema-1 .cnb uses the three-argument form and root 0). The loose routes never need it: direct glTF appends one bone per reachable node beneath a synthetic identity root, a version-2 .cnj serializes and rebuilds the same parent-before-child array, and the version-1 fallback appends one synthetic child per mesh — all three reserve index 0 as the root. A glTF skin's reordered palette hierarchy lives in SkinningData, not in the model-bone index space, so no loose route has an arbitrary root index to carry.

The four collections are not uniform

All four collections offer XNA's enumerator (GetEnumerator with Current/MoveNext/Reset) and CNAEXT begin()/end() for range-for, and every integer indexer rejects a negative index and one at or above Count with ArgumentOutOfRangeException rather than exposing raw vector[] behaviour. Beyond that they differ:

CollectionName lookupTryGetValue / ContainsNotes
ModelBoneCollectionoperator[](std::string); a missing name throws KeyNotFoundExceptionbothPopulated by Model or by ModelBone::AddChild (a bone's Children).
ModelMeshCollectionsame as bonesbothAn empty name throws ArgumentNullException, as for bones.
ModelMeshPartCollectionnone (parts have no name)neitherChecked integer access, Count, iterators.
ModelEffectCollectionnoneContains onlyIts enumerator carries a version stamp: an Add/Remove during enumeration makes MoveNext/Reset throw InvalidOperationException. Mutation is the CNAEXT escape hatch described above.

The empty-name rule is a correction worth stating plainly: TryGetValue on the bone and mesh collections throws ArgumentNullException for an empty string, which is CNA's spelling of FNA's null-or-empty rejection, so an empty name cannot be used as a "not supplied" sentinel and cannot find a deliberately empty-named mesh through that API. It is pinned by EmptyNameThrowsArgumentNull in both collection suites. All name searches are linear and return the first match; neither loaders nor constructors reject duplicate names, so names are handles, not unique identifiers. ModelCollectionIndexTests.cpp checks negative, Count and above-count indices for all four families plus first/last identity; ModelCollectionEnumeratorTests.cpp checks the enumerators, including that a child added after an enumerator was created does not extend its snapshot and that effect-collection mutation invalidates enumeration.

CNAEXT state carried by the graph

Several properties exist because glTF content needed somewhere to live that Tag (one object, already contended by SkinningData and ModelAnimationsEXT) could not provide. They are always compiled and are empty on routes that do not fill them:

  • getSkinsEXTProperty() — every independently posed skin as a ModelSkinEXT {name, SkinningData*, meshes}; the first skin is also Tag.
  • getCamerasEXTProperty() — ModelCameraEXT placements (direct glTF only). WorldTransform is a snapshot at import; a camera's live placement is the absolute transform of the bone its SceneNodeIndex names. A glTF perspective camera without zfar gets the free function CreateInfinitePerspectiveFieldOfViewEXT, whose far terms are the limits of XNA's finite builder rather than a large finite distance.
  • getGltfImportReportEXTProperty() — the structured import report (direct glTF and .cnj).
  • getBoundingSphereEXTProperty() — one sphere around every mesh at its current parent-bone placement, or std::nullopt for a model without meshes. It is recomputed from the current absolute bone transforms (so rigid animation is reflected), uses a conservative stretch bound that stays correct under the shear a composed hierarchy can create, and for a skinned mesh unions the mesh sphere under every matrix of the current palette on its SkinnedEffect/SkinnedPbrEffect. It is in model-root space, before the caller's world.
  • getMaterialVariantNamesEXTProperty(), getMaterialVariantEXTProperty(), setMaterialVariantEXTProperty(int) — KHR_materials_variants by source index; -1 is the default and is what a freshly loaded model uses. Selection is model-wide, swaps each bound part's complete material-dependent state (vertex buffer, vertex count, tag, samplers, then effect), and a part with no mapping for the selected variant returns to its default. Values below -1 or past the name list throw std::out_of_range.
  • On ModelMeshPart: getPrimitiveTypeEXTProperty() and per-slot getSamplerStatesEXTProperty() (five core slots) and getSpecularSamplerStatesEXTProperty() (two KHR_materials_specular slots), LinearWrap on every non-glTF route. The sampler setters ignore an out-of-range slot on purpose, so a slot count that grows cannot crash old content.

Evidence and limits of this page

Everything above was checked by reading the TARGET sources and tests named inline at 009d40f5; nothing was built or executed for this page. The test names establish that the tests exist and what they assert, not that they passed on any particular host. The FNA comparisons are CNA's own source and test comments, not a fresh reading of FNA. Related reading: Model Loading (user guide), Tutorial 35, vertex packing and skinning and animation.

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

Tests and validation
Test labels