Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

Chapter 38 Models, Meshes, and Drawing

38.1 The runtime hierarchy

Model owns a ModelBoneCollection and a ModelMeshCollection; each ModelMesh owns a ModelMeshPartCollection and a ModelEffectCollection; each ModelMeshPart references a shared VertexBuffer / IndexBuffer pair and an Effect. This is the same shape as real XNA. The reader-side contract is deliberately separate (and is covered by §38.4 below and Chapter 33); at runtime, the useful distinction is between a normally loaded, internally consistent model and a hand-built one. The latter can use several CNAEXT construction/mutation hooks, so it must preserve the invariants that a content reader normally supplies. The exact boundary, including the remaining intentional C++ safety deviations from FNA, matters more than a blanket claim of zero gaps. Model exposes CopyAbsoluteBoneTransformsTo, CopyBoneTransformsFrom / To, and a convenience Draw(world, view, projection); one small, intentionally-kept deviation from FNA is that CNA’s Copy*BoneTransforms* methods loop by Bones.Count rather than trusting the caller’s array length the way FNA does — strictly safer against an oversized destination array, never a compatibility problem for correctly-sized callers.

ModelBone carries Name, Index, a get/set Transform, its Parent, and a Children collection built via a CNAEXT AddChild. CNA also exposes setters for a ModelMesh’s BoundingSphere and ParentBone; FNA reserves both setters for its internal reader. They are useful for CNA hand construction and the XNB reader, but turn reader-populated metadata into caller-maintained state, which is exactly where the two model loaders and the draw contract below diverge sharply.

38.2 Two ways to draw a loaded model

The simplest path is the convenience method already named above:

1 model.Draw(worldMatrix, camera.View(), camera.Projection());

This is correct for a loaded, renderable model with at least a root bone, even if it has no interesting hierarchy to speak of. A real, multi-bone model (a character, a vehicle with independently-animated wheels) needs each mesh drawn with its own absolute bone transform composed into world space first — the classic pattern, grounded directly in the real CopyAbsoluteBoneTransformsTo and IEffectMatrices signatures:

1 std::vector<Matrix> boneTransforms(model.getBonesProperty().getCountProperty());
2 model.CopyAbsoluteBoneTransformsTo(boneTransforms);
3
4 for (ModelMesh* mesh : model.getMeshesProperty()) {
5 Matrix meshWorld = boneTransforms[mesh->getParentBoneProperty()->getIndexProperty()]
6 * worldMatrix;
7
8 for (Effect* effect : mesh->getEffectsProperty()) {
9 if (auto* matrices = dynamic_cast<IEffectMatrices*>(effect)) {
10 matrices->setWorldProperty(meshWorld);
11 matrices->setViewProperty(camera.View());
12 matrices->setProjectionProperty(camera.Projection());
13 }
14 }
15 mesh->Draw(); // issues actual indexed GPU draws for each valid ModelMeshPart.
16 }

boneTransforms[mesh.getParentBoneProperty()->getIndexProperty()] selects the mesh’s parent-bone absolute transform, not bone 0 or the model root. The former loose-JSON route assigned one default bone to every mesh and welded parts together. That defect is fixed; the example applies to current XNB, CNJ, and direct glTF/GLB outputs.

38.3 The draw contract: a model is more than a bag of pointers

Model::Draw is deliberately a thin convenience path, not a validator or a general scene graph. It retains one thread_local static vector of absolute bone matrices (the same scratch-buffer shape as FNA’s static array, but one instance per thread), grows it only when a later model on that thread has more bones, copies the absolute transforms into it, then visits every mesh. For every distinct effect listed by that mesh it requires the matrix interface, writes boneMatrix * world, view, and projection, and only then asks the mesh to issue its part draws. An effect in the mesh’s effect collection that does not implement that interface is therefore a hard runtime_error; it is not silently ignored or drawn with some other matrix convention.

That compact loop establishes three input invariants which a normal content load satisfies, but which the public/extended hand-build API does not enforce:

  • A renderable mesh needs an applicable bone matrix. CNA deliberately treats a null ParentBone as bone index 0, whereas FNA immediately dereferences the mesh parent to obtain its index and fails for null. This is a sensible C++ safety improvement for a one-root-bone hand-built model, and the real EasyGL/Bgfx hierarchy pixel tests exercise it for their root mesh. It does not make a zero-bone model renderable: with a positive-effect mesh, index 0 is outside an initially empty scratch vector; after another model has grown that thread’s vector, it can instead reuse that earlier model’s stale slot. FNA’s static array is likewise retained, but its usual null ParentBone path fails first rather than recovering. Give every model that can draw at least one bone, and give each mesh a parent explicitly when it is not that root.

  • CopyAbsoluteBoneTransformsTo computes slots in collection order. A child therefore has to name a parent whose result was already written; it neither topologically sorts nor detects cycles, duplicate parents, or an index that disagrees with the vector position. The XNB reader’s malformed-graph boundary is documented in Chapter 33; the same ordering rule applies to a hand-built graph.

  • The manual loop in the previous section intentionally dereferences the mesh parent pointer so that an invalid attachment is visible at the call site. Unlike Model::Draw, it has no null-to-root fallback. Well-formed outputs of the current loaders assign a mesh parent, but an XNB shared-resource reference of zero can still leave it null; a manually assembled mesh must either set one or choose and check its root fallback before indexing boneTransforms.

ModelMesh::Draw has a different, narrower safety rule: it skips a part with a null Effect or a non-positive PrimitiveCount. FNA skips only the latter, then dereferences a null effect, so the CNA skip is another deliberate no-undefined-behaviour deviation. It does not validate the remaining draw fields: a positive-count part still passes its raw vertex buffer, index buffer, offsets, vertex count, start index, and primitive count to GraphicsDevice. A missing buffer or invalid range consequently fails at the graphics-device/renderer boundary rather than becoming a harmless skipped part. This is why a model’s successful construction is not proof that it is drawable.

38.3.1 Keep parts and effects connected through the part setter

The mesh’s ModelEffectCollection is a derived, distinct-effect view of the effects used by its parts; it is what Model::Draw configures before ModelMesh::Draw applies each part’s current technique. The supported way to maintain that relationship is:

1 part.setEffectProperty(&basicEffect); // adds basicEffect to mesh.Effects if absent
2 // ...
3 part.setEffectProperty(nullptr); // removes it only if no other part still uses it

The implementation first retains an old effect while any sibling part still points to it, then removes it only for the final user; it adds a new non-null effect only if the collection does not already contain that pointer. Thus two parts sharing one BasicEffect produce one matrix setup in Model::Draw, while each part still produces its own indexed draw. The focused part/effect unit tests cover exactly this retain-on-shared/release-on-last-user transition and the no-duplicate case.

ModelEffectCollection::Add/Remove and ModelMesh::getEffectsPropertyMutable() exist only as CNAEXT escape hatches. Unlike FNA’s internal-only collection mutation, CNA exposes them to make its content reader possible. They neither deduplicate Add nor repair a collection that a caller has manually made inconsistent with its parts: a stale extra effect is configured but never applied by a part, and a duplicate is configured twice. Do not populate Effects directly in ordinary game code; set each part’s effect and retain the buffers/effects for at least as long as the model uses their raw pointers.

38.3.2 Thread-local scratch and evidence boundary

At alpha.1 the bone-matrix scratch vector is thread_local. Draws on different threads therefore no longer resize or overwrite the same scratch storage. That change does not make a graphics device, its effects, buffers, or a shared mutable model graph generally thread-safe: concurrent mutation of bones, effects, or parts remains ordinary shared-state contention, and renderer resources retain their platform-specific thread-affinity rules. Keep a model’s update and draw sequence on its owning graphics thread unless the application supplies a stronger synchronization contract.

The focused CPU tests cover transform multiplication, destination-size checks, constructor parent wiring, and part-to-effect collection bookkeeping. Renderer tests go further: the EasyGL root/hierarchy model tests read back pixels, Bgfx has equivalent two-mesh/hierarchy tests, and the SDL_Renderer test proves that a fully populated positive-primitive model reaches the shared 3D draw failure boundary. No located test deliberately covers zero-bone rendering, invalid parent order/cycles, raw out-of-range part fields, direct mutable-effect desynchronisation, or concurrent Draw; treat those as construction-time invariants, not tested recovery paths.

38.4 Four model asset routes, not two

The old two-loader and .model.json framing no longer describes the resolver. CNA now has four distinct model-producing paths: the wire-compatible XNB ModelReader, self-contained .cnj, direct .gltf/.glb, and the older Avatar-specific .skinnedmodel.json reader. The first three return the ordinary Model; the fourth returns SkinnedModelEXT, a separate runtime type that must not be mistaken for another serialization of the same graph. For an extensionless cache miss, ContentManager looks for name.xnb, then a literal name, then name.cnj, name.gltf, and name.glb. Thus a same-named CNJ hides a glTF/GLB asset, and an XNB sibling hides both; passing an existing literal name.gltf explicitly reaches that literal before the extensionless sidecar probe. The full resolver contract is in Chapter 33; the resulting runtime graphs are the important distinction here.

The real .xnb ModelReader

(CNA::Internal::Xnb::ModelReader) is wire-compatible with real XNA/MonoGame/FNA compiled model assets. It reconstructs the full bone hierarchy, assigns each mesh’s serialized ParentBone, computes its serialized BoundingSphere, and resolves shared VertexBuffer/IndexBuffer/Effect resources. Its exact malformed-graph, draw-field, tag, and shared-resource boundary is covered in Chapter 33.

The self-contained .cnj ModelTypeReader route

validates a Model CNJ envelope and rejects sourceFile; the former .model.json name survives only in historical comments and test-target names, not in this reader’s extension list. A version-2 descriptor reconstructs its complete parent-before-child bones array and attaches every mesh to the indexed parentBone; this is how the converter preserves the selected glTF scene hierarchy and rigid placement. A version-1 descriptor (or one without a general hierarchy) retains the older compatibility shape: one root plus a synthetic named child bone per mesh. An optional skeleton/animations sidecar pair builds SkinningData on Model.Tag, and morph data lives on the corresponding part’s Tag.

This route still leaves BoundingSphere degenerate and ModelMesh.Tag null. It allocates a fresh vertex/index buffer and built-in stock effect for each described mesh part; a named custom effect instead goes through the manager’s cached shared_ptr<Effect> route. This is not XNB’s serialized shared-resource graph, even where repeated custom-effect names happen to share a cached object.

The direct .gltf/.glb ModelTypeReader route

parses glTF 2.0 and its buffers without producing a CNJ or binary sidecars first. It reuses the offline converter’s import core, including sparse-accessor-safe extraction, topological skin ordering, material/image extraction, and morph/animation data. Its runtime shape is one Load<Model> containing the selected scene’s mesh placements, including independent skin groups, with a fixed unit scale of 1.0. Model::SkinsEXT preserves each skin-to-mesh mapping while the legacy Model.Tag convention aliases the first skin. Convert offline when a scale factor or one-output-per-group asset layout matters.

Direct glTF creates an identity synthetic root followed by one ModelBone per reachable node of the selected scene, in parent-before-child order. A rigid mesh is attached to its instancing node; a skinned mesh is attached to the synthetic root so the skin palette does not double-apply the mesh-node transform. The separate reordered skin hierarchies are retained through Model::SkinsEXT; morph targets and their tracks are retained on each ModelMeshPart.Tag. Imported cameras live in Model::CamerasEXT; material variants have a source-order selection API; and GltfImportReportEXT records counts plus named dropped/approximated features. Extracted images are decoded in memory and cached by glTF image pointer during that one import, but mesh buffers and effects remain per primitive.

The legacy .skinnedmodel.json route

dispatches to SkinnedModelTypeReader and produces SkinnedModelEXT, the Avatar-oriented runtime described later in Chapter 41. It is neither a CNJ alias nor the historical name of the current Model descriptor. Code loading it opts into a different public type, animation representation, and draw path; its continued presence is therefore a compatibility route, not evidence that the generic Model reader accepts a fourth file spelling.

The evidence is much stronger than the old chapter wording suggested. The focused CNJ tests cover descriptors, envelope rejection, report persistence, variants, cameras, and shared clips. The generated glTF corpus and its L1–L7 ladder cover scene graphs, multiple skins, rigid and skeletal animation, morphs, material state, and pixels; Chapter 43 states the exact denominators. Those tests do not make the four routes interchangeable: use XNB for compiled XNA asset fidelity, CNJ for a controlled self-contained/imported description, direct glTF/GLB for an in-process glTF 2.0 import, and the legacy route only when the application consumes SkinnedModelEXT.

38.5 Lifetime and copies: value syntax, one mutable graph

Model is a copy-constructible C++ value, but copying it is not a deep model clone. Its compiler-generated copy operation copies the two collection vectors, Root, and Tag as raw pointers, while copying its private type-erased shared_ptr<void> ownership handle. The vectors are separate little wrappers after the copy, but every ModelBone, ModelMesh, ModelMeshPart, buffer, effect, and the graph below them is the same object. A bone-transform or part/effect mutation observed through one copy is therefore observed through every copy; this is shared mutable model state, not a way to pose or material-override one instance independently.

Each normal reader intentionally fills that last ownership handle before returning. The XNB reader’s private bundle retains unique bones, meshes, and parts plus shared vertex buffers, index buffers, and effects. The CNJ and direct-glTF routes share a corresponding bundle which also retains their buffers, graph objects, effects, decoded textures, skinning data, and morph data. That is why a cached or caller-held loaded Model can safely keep the raw pointers used by its collections: the bundle itself is shared among every copy. It also explains why CNJ/glTF’s Model.Tag and part Tags remain valid while such a model lives — their SkinningData and morph objects are in that same bundle. XNB rejects non-null serialized Tags, so it has no analogous reader-provided Tag object to retain.

The visible cache behavior follows directly from that representation:

1 Model first = content.Load<Model>("ship");
2 Model second = content.Load<Model>("ship"); // another C++ wrapper, same bones/meshes/buffers
3
4 first.CopyBoneTransformsFrom(newPose); // second now observes the same bone transforms
5 content.Unload(); // removes manager cache entries only
6 // first and second still retain the reader-owned graph through their shared ownership handles

The real XNB test checks the decisive GPU identity part of this rule: two loads expose the same underlying VertexBuffer, not a fresh decode/upload. The generic content-cache and Unload() tests separately prove cache eviction, but no located test holds a Model through Unload(), copies one and checks a shared mutation, or exercises the final bundle destruction. The code makes the first two outcomes clear; the missing tests remain an evidence boundary rather than permission to assume a complete shutdown protocol.

There are two qualifications to “shared” worth keeping precise. Reassigning the top-level Tag changes only that particular C++ wrapper’s raw pointer; it does not rewrite another wrapper’s Tag field, although both initially point to the same reader-owned object. In contrast, a mesh Tag or a bone/part/effect change is made on the shared pointed-to graph and is visible through all copies. FNA returns its cached Model class reference, so ordinary managed assignment aliases even the top-level wrapper; CNA’s value syntax is consequently not a deep-copy compatibility promise.

38.5.1 Hand-built graphs have no automatic owner

The public three/five-argument Model constructors merely store the caller’s raw bone and mesh addresses. Likewise, meshes store raw part and graphics-device addresses; parts store raw buffer, index-buffer, effect, and Tag addresses; and bones store raw parent/child addresses. They allocate none of those objects and do not install an ownership bundle. The constructors are CNAEXT extensions precisely because FNA creates these graph objects through internal readers rather than asking game code to manage them.

Consequently a hand-built model is drawable only while its complete graph outlives every model copy that uses it; its GraphicsDevice must survive for the same draw lifetime. Stack-allocated construction is fine for a local, synchronous test, but returning the model after its backing bones, meshes, parts, buffers, effects, or Tag objects have died leaves dangling pointers. An advanced CNA owner can deliberately attach one aggregate lifetime object with the CNAEXT setOwnedResources(shared_ptr<void>) hook, mirroring the three readers; otherwise the application must retain its own aggregate explicitly. The hook retains only what its supplied shared object owns—it does not make a raw graphics-device pointer, an externally assigned Tag, or another unowned raw address safe by itself.

Finally, ContentManager::Unload() clears its generic asset and texture lookup maps; it does not walk a loaded model or invalidate a caller-held C++ value. A retained reader-created model therefore keeps its bundle after unload, but still relies on the separately unowned graphics device being alive when it draws. Drop every model copy before tearing down that device, and do not mistake cache eviction for a safe concurrent reload or a universal resource destructor. Chapter 33 gives the manager-wide cache, thread, and disposal rules; this section supplies the model-specific raw-pointer consequence.

38.6 Skinning: two independent systems, not one

CNA ships two separate, unrelated systems for animated meshes, and conflating them is an easy mistake to make. AnimationPlayer (CNAEXT, mirroring Microsoft’s own unshipped XNA “Skinned Model Sample” reference code rather than anything in the framework assembly itself) drives a bone-track timeline: StartClip(clip), Update(TimeSpan, relativeToCurrentTime, loop=true), and GetBoneTransforms() / GetWorldTransforms() / GetSkinTransforms() — the last of which is exactly what feeds SkinnedEffect::SetBoneTransforms (Chapter 15). Its skinning data (bone count, hierarchy, bind pose, inverse bind pose, animation clips) is stashed on a loaded Model’s own Tag — the same convention the original Microsoft sample used, since real XNA’s Model has no dedicated skinning-data property of its own to hold it in.

MorphTargetEXT (also CNAEXT) is a completely independent, glTF-style morph-target blending system: CPU-side re-blend plus a full vertex-buffer re-upload on every call to SetMorphWeightsEXT, a deliberate simplicity-over-throughput tradeoff. Because glTF’s “weights” animation channel targets a mesh instance node rather than a bone index, this system has no relationship to AnimationPlayer’s bone timeline at all; it supports linear, step, and real cubic-spline (Hermite) interpolation, and attaches itself via a loaded ModelMeshPart’s own Tag — the same attachment convention, applied to a different object in the hierarchy, for a different purpose.

A third, still more separate system exists for the Avatar subsystem specifically: SkinnedModelEXT (its own .skeleton.bin / .clip.bin binary formats) is deliberately not built on Model / ModelBone / ModelMesh at all, has its own full test coverage, and is covered alongside the rest of the Avatar system in this book.

38.6.1 Driving AnimationPlayer with a test-adapted example

AnimationPlayerTests.cpp builds its fixtures around the smallest rig that can actually exercise bone-hierarchy composition: two bones, where bone 1 is a child of bone 0 offset by (0,1,0) in bind pose, and bone 0’s own track moves it from the origin to (2,0,0) over one second. Adapted directly from that fixture:

1 SkinningData data;
2 data.BoneCount = 2;
3 data.SkeletonHierarchy = {-1, 0}; // bone 0 = root, bone 1’s parent = 0
4 data.BindPose = {Matrix::getIdentityProperty(),
5 Matrix::CreateTranslation(Vector3(0, 1, 0))};
6 data.InverseBindPose = {Matrix::getIdentityProperty(), Matrix::getIdentityProperty()};
7
8 BoneTrackEXT track;
9 track.BoneIndex = 0;
10 track.Keys.push_back(Keyframe{System::TimeSpan::FromSeconds(0.0), Vector3(0, 0, 0)});
11 track.Keys.push_back(Keyframe{System::TimeSpan::FromSeconds(1.0), Vector3(2, 0, 0)});
12
13 AnimationClip clip;
14 clip.Duration = System::TimeSpan::FromSeconds(1.0);
15 clip.Tracks.push_back(track);
16 data.AnimationClips["Move"] = clip;
17
18 AnimationPlayer player(data);
19 player.StartClip(data.AnimationClips["Move"]);
20
21 // Every frame:
22 player.Update(gameTime.getElapsedGameTimeProperty(), /*relativeToCurrentTime=*/true);
23 skinnedEffect.SetBoneTransforms(player.GetSkinTransforms());

Two details this fixture makes concrete rather than abstract: SkeletonHierarchy is a flat parent-index array (-1 means “no parent, this is a root”), the same representation glTF and most DCC export pipelines use, not a pointer-based tree; and GetWorldTransforms() versus GetSkinTransforms() answer two different questions — the former is each bone’s absolute transform (what §38.4’s manual mesh-draw loop above would want for a non-skinned mesh attached to a bone), the latter is pre-multiplied by each bone’s own InverseBindPose, which is specifically the form SkinnedEffect::SetBoneTransforms expects, since GPU skinning needs the bone’s delta from bind pose, not its absolute pose.

38.6.2 A two-target morph from the test suite

MorphTargetEXTTests.cpp builds every one of its cases around the same small, hand-crafted fixture, which doubles as a clear, minimal illustration of how the whole system fits together: a single stride-32 triangle (three vertices, each Position/Normal/TextureCoordinate) as the base pose, and two morph targets — target 0 pushes every vertex by +1 in Z with no normal change; target 1 moves only vertex 0 by +2 in X and tilts its normal toward +X:

1 MorphTargetDataEXT morph;
2 morph.BaseVertexBytes = BuildBaseTriangleBytes(); // 3 verts, stride-32, +Z normal, origin plane
3 morph.Stride = 32;
4
5 morph.PositionDeltas.push_back({Vector3(0, 0, 1), Vector3(0, 0, 1), Vector3(0, 0, 1)}); // target 0
6 morph.NormalDeltas.emplace_back(); // no normal delta, target 0
7
8 morph.PositionDeltas.push_back({Vector3(2, 0, 0), Vector3(0, 0, 0), Vector3(0, 0, 0)}); // target 1
9 morph.NormalDeltas.push_back({Vector3(1, 0, 0), Vector3(0, 0, 0), Vector3(0, 0, 0)});
10
11 morph.Weights = {0.0f, 0.0f}; // both targets off -- base pose
12
13 part.setTagProperty(&morph); // ModelMeshPart::Tag, the same attachment convention SkinningData
14 // uses on Model::Tag, but one level down the hierarchy

Driving both targets to full weight at once is the case worth reasoning through by hand, because it is where an easy mental model (“morph targets are independent”) breaks in a way the real math does not: additive combination means vertex 0 (the only vertex both targets touch) receives both deltas simultaneously, while vertices 1 and 2 (untouched by target 1) receive only target 0’s:

1 SetMorphWeightsEXT(part, {1.0f, 1.0f}); // re-blends CPU-side, re-uploads the vertex buffer
2
3 // Vertex 0: base (0,0,0) + target 0’s (0,0,1) + target 1’s (2,0,0) = (2, 0, 1)
4 // Vertex 1: base (1,0,0) + target 0’s (0,0,1) + target 1’s zero-for-this-vertex = (1, 0, 1)

The blended normal at vertex 0 is the second detail worth internalizing, because it is a place BlendMorphTargetsEXT does strictly more work than a naive weighted sum would: the base normal (0,0,1) plus target 1’s full-weight delta (1,0,0) sums to (1,0,1), which has length 2, not 1 — BlendMorphTargetsEXT renormalizes every blended normal back to unit length before returning, exactly because a weighted sum of unit vectors is not itself a unit vector in general. TextureCoordinate (and, by the same rule, BlendWeight/ BlendIndices on a rigged mesh part) is copied from the base pose completely unchanged by any of this — only Position and Normal ever participate in morph blending. Passing a weight vector whose length does not match PositionDeltas.size() throws rather than silently truncating or ignoring the extra entries, verified directly by the test suite’s own WrongWeightCountThrows case.

Per-frame animated weights layer directly on top of this via EvaluateMorphWeightsEXT and a MorphWeightTrackEXT attached to MorphTargetDataEXT::WeightTrack — the same three interpolation modes glTF’s own “weights” animation channel supports (LINEAR, STEP hold-last-value, and real Hermite CUBICSPLINE when a keyframe’s tangents are populated), evaluated independently of AnimationPlayer’s own bone-track timeline, consistent with this system’s complete independence from bone-based skinning noted above:

1 const auto weights = EvaluateMorphWeightsEXT(morph.WeightTrack, animationTimeSeconds);
2 SetMorphWeightsEXT(part, weights);

38.7 A worked example: naming an arbitrary root bone, and how the fix was proven to work

Model’s hand-build constructor had a real, previously-undocumented gap versus FNA, confirmed by reading FNA’s own Model.cs directly: FNA’s constructor never sets Root at all — it is real XNA’s own ModelReader that assigns model.Root = bones[rootBoneIndex] afterward, where rootBoneIndex can name any bone in the model, not necessarily the first one. CNA’s original 4-argument constructor (the one that also accepts meshParentBones) had no equivalent parameter at all, silently defaulting Root to bones[0] unconditionally — correct for the common case, but unable to represent a hand-built model whose true root bone is not the first entry in its own bones vector.

The fix is a fifth, defaulted constructor parameter, additive-only so no existing call site’s behavior changes:

1 Model(GraphicsDevice* graphicsDevice,
2 std::vector<ModelBone*> bones,
3 std::vector<ModelMesh*> meshes,
4 std::vector<ModelBone*> meshParentBones,
5 std::size_t rootBoneIndex = 0); // CNAEXT -- FNA’s ModelReader assigns this
6 // externally; this is the public equivalent
7 // for a hand-built Model.

One edge case is worth reasoning through explicitly, because a naive implementation gets it wrong: an empty bones vector must leave Root as nullptr regardless of the requested index, rather than throwing on the harmless default value 0 — the real constructor body checks emptiness first, matching the existing 3-argument constructor’s own leniency, and only bounds-checks rootBoneIndex against a genuinely non-empty bones vector:

1 if (!bones_.bones_.empty())
2 {
3 if (rootBoneIndex >= bones_.bones_.size())
4 throw std::out_of_range("rootBoneIndex");
5 root_ = bones_.bones_[rootBoneIndex];
6 }
7 // bones_ empty: root_ stays nullptr regardless of rootBoneIndex, matching the
8 // 3-argument constructor’s own existing behavior for this case.

The real regression tests earned by this fix (ModelTests.cpp) are worth naming for the verification technique they demonstrate, not just their existence:

1 FiveArgConstructorDefaultRootBoneIndexMatchesFourArgBehavior
2 FiveArgConstructorHonorsNonZeroRootBoneIndex
3 FiveArgConstructorThrowsWhenRootBoneIndexOutOfRange
4 FiveArgConstructorEmptyBonesLeavesRootNullEvenWithDefaultIndex

The nonzero-index case uses a three-bone hierarchy, selects index 2, and checks both equality with the expected bone and inequality with bone 0. A test that only checked equality could pass even if the parameter were silently ignored and the implementation happened to default to the requested bone by coincidence. The fix’s own commit history records a real sabotage-and-revert check applied to confirm these tests actually exercise what they claim to: with the constructor temporarily edited to ignore rootBoneIndex entirely (falling back to the old, hardcoded bones[0]), exactly the two tests that depend on honoring a non-default index failed as predicted, while the other 24 ModelTest.* cases were unaffected — the same discriminating-power methodology this book applies throughout (a test suite’s own ability to catch a real regression is itself worth verifying, not assumed from the fact that it currently passes).

This fix closes the hand-build runtime-API gap only. Neither loose route calls the five-argument constructor, and both reserve index 0 as their root. Direct glTF/GLB appends one ModelBone per reachable selected-scene node beneath a synthetic identity root; a generated version-2 CNJ serializes and reconstructs that same parent-before-child array. The version-1 CNJ fallback instead appends one synthetic child per mesh. A glTF skin’s independently reordered palette hierarchy still lives in SkinningData, not in the model-bone index space. Consequently neither route has a serialized arbitrary rootBoneIndex for this constructor parameter to represent. The gap is closed for a hand-built model, but remains deliberately inapplicable to the two loose content routes; the reader-specific distinctions are tracked in §38.4, not hidden by the runtime fix.

38.8 Collections are read views, but not uniform ones

The four model collections deliberately expose C++ iteration rather than FNA’s managed enumerators, but they do not have one identical API.

ModelBoneCollection has checked integer lookup, name lookup, TryGetValue, Contains, and iterators. Its storage is populated by Model or a bone’s CNAEXT AddChild; name lookup returns the first matching name, so names are useful handles, not enforced unique identifiers.

The mesh collection has the same named-lookup family. Its integer lookup is checked, as is the bone collection’s, so an invalid index throws rather than invoking C++ undefined behaviour. One small FNA divergence remains: FNA rejects a null or empty mesh-name argument before searching, while CNA’s std::string API has no null spelling and accepts the empty string. It can consequently return a deliberately empty-named mesh or simply return false. Do not use an empty name as a portable not-supplied sentinel.

ModelMeshPartCollection has checked integer access, Count, and iterators — there is no part name, Contains, or TryGetValue. Its operator explicitly rejects a negative index and one greater than or equal to Count with ArgumentOutOfRangeException, matching the bone/mesh collections rather than exposing raw vector[] behaviour.

ModelEffectCollection likewise has integer access, Count, Contains, and iterators, but no name lookup or TryGetValue. Its integer operator has the same explicit negative/upper-bound checks. Its mutable operations are the CNAEXT escape hatches described in §38.3, not a fourth normal collection-builder API.

All name searches are linear and return the first matching pointer; neither loader nor collection constructor rejects duplicate names. The focused bone/mesh suites cover normal name/index/iterator behaviour and missing names, while ModelCollectionIndexTests.cpp checks negative, Count, and above-count indices for all four collection families plus first/last identity. No located collection test covers the empty-name FNA difference. The safe cross-collection rule is therefore simple: keep the model’s ownership and topology coherent at construction time and use name lookup only where that collection actually exposes it; invalid integer indices fail loudly but are still caller errors.

Type at least three characters. Results are ranked by how often and where the words occur.