Chapter 40 GPU Packing and the Stride ABI
Between glTF’s typed accessors and a renderer’s vertex fetch lies a compact byte protocol that is not declared as a C++ type. ExtractMesh chooses one of ten layouts, writes tightly packed bytes, and records only the stride. Later, ContentManager switches on that stride to choose an upload route. Renderer shaders must independently agree with the same offsets. The stride is therefore a de-facto ABI.
40.1 The ten glTF layouts
| Stride | Fields and byte offsets | Effect family |
|---|---|---|
| 20 | position 0, UV 12 | dual texture |
| 24 | position 0, colour u8x4 at 12, UV 16 | basic, vertex colour |
| 32 | position 0, normal 12, UV 24 | basic |
| 48 | position 0, normal 12, tangent vec4 at 24, UV 40 | PBR |
| 52 | position 0, normal 12, UV 24, weights 32, joint u8x4 at 48 | skinned |
| 56 | stride 52 plus colour u8x4 at 52 | skinned, vertex colour |
| 60 | rigid PBR stride 48, UV1 at 48, colour u8x4 at 56 | PBR, dual UV/colour |
| 68 | position 0, normal 12, tangent 24, UV 40, weights 48, joint u8x4 at 64 | skinned PBR |
| 76 | skinned PBR stride 68 plus UV1 at 68 | skinned PBR, dual UV |
| 80 | stride 76 plus colour u8x4 at 76 | skinned PBR, dual UV/colour |
Selection is semantic rather than user-configurable. A base-colour plus occlusion texture on an unskinned, uncoloured, non-PBR primitive selects stride 20 and DualTextureEffect; colour selects 24 unless skinning changes it to 56; normal or metallic–roughness materials select a tangent-bearing PBR layout; and skinning otherwise selects 52. PBR layouts widen to carry two independently selected source UV sets and COLOR_0. Missing normals, UVs, and colours receive stable defaults.
The downstream upload switch also recognizes stride 16 for a typed VertexPositionColor path used by non-glTF raw content. That makes the upload switch an eleven-case protocol, but stride 16 is not an eleventh layout emitted by the current glTF selection expression. Keeping those two counts separate avoids a recurring documentation contradiction.
40.2 Why ordinary C++ structs were unsafe
Several CNA vertex structs implement polymorphic IVertexType. Their in-memory C++ objects consequently contain a vtable pointer, so sizeof(Vertex...) is larger than the clean GPU layout and the first declared field need not begin at byte 0. Reinterpreting the tightly packed glTF byte array as an array of those objects shifts every field. The renderer then sees plausible-sized buffers containing nonsense, a root cause of the long-lived “invisible model” symptom family.
The current bridge is explicit:
-
•
strides 16, 20, 24, and 32 are reconstructed field-by-field as real typed vertex objects before typed SetData;
-
•
strides 48, 52, 56, 60, 68, 76, and 80 use SetDataRaw, preserving the packed bytes; and
-
•
the glTF extractor emits the ten layouts above. A hand-written Model CNJ naming any other stride is outside this inferred-layout contract and must not be described as another supported layout.
This fixes the object-layout error, but it leaves the protocol stated in more than one place: the packer, the upload switch, renderer declarations, morph-target offset logic, and the L5 golden oracle. CNA now also states all eight layouts once in VertexDeclarationFidelity.hpp; stride-inferring renderers use that table to refuse a declared layout they cannot reproduce faithfully instead of fetching the wrong bytes. That table is a safety oracle, not a generator: the glTF packer, content upload switch, renderer-native layouts, morph offsets, and independent L5 golden tables remain separate statements. A future refactor could generate the production views from one declarative source. Until then, tests that compare independently stated tables are more valuable than comments claiming they agree.
40.3 Indices: source width is not destination width
CNA decodes glTF indices itself rather than relying on cgltf’s index helper, because the helper cannot report why a sparse read failed. The decoder accepts unsigned byte, unsigned short, and unsigned int scalar accessors, honours buffer-view/accessor offsets and sparse overrides, and uses memcpy for possibly unaligned components. A non-indexed primitive receives the implicit sequence .
Every decoded index is checked against the position count before packing. Only after that proof does the destination width get selected:
The source component type is deliberately irrelevant. A glTF accessor stored as 32-bit indices for a 100-vertex mesh becomes a 16-bit GPU index buffer; the preceding range check makes that narrowing safe. This is a useful example of proving a conversion rather than preserving a source representation merely because it existed.
The decoder’s component reads are alignment-safe, but the final front-end handoff is not yet as careful: both direct glTF and Model CNJ cast a vector<uint8_t>::data() address to a uint16_t* or uint32_t* before calling IndexBuffer::SetData. The callee immediately forwards the region as bytes, but the converted pointer is not guaranteed to meet that type’s alignment; any typed access through it would have undefined behaviour. Copying into aligned typed storage or adding a byte-oriented upload would remove that avoidable contract hazard without changing the GPU bytes.
40.4 All seven topology modes have an explicit route
D5 is fixed end to end. Points, line lists, and line strips retain their topology; a line loop is closed and becomes a line strip; triangle strips and fans are expanded into winding-correct triangle lists; triangle lists remain lists. ModelMeshPart’s CNAEXT PrimitiveType property travels through the direct and CNJ routes, and primitive counts use the selected topology rather than universally dividing by three. An incomplete final primitive/run is dropped with a counted diagnostic. Conversion is therefore visible in the source-vs-output topology fields, not a silent reinterpretation.
40.5 Tangents and winding
If a glTF primitive supplies TANGENT, CNA preserves it. Otherwise PBR extraction computes tangent and handedness from positions, normals, and UV gradients. Contributions are weighted by corner angle, degenerate UV triangles are skipped, and the result is Gram–Schmidt-orthogonalized. This is intentionally not a bit-exact MikkTSpace implementation: corners are not welded across matching position/normal seams.
No coordinate-system conversion is required, but one winding case remains: a node with a negative-determinant transform changes front-face orientation. CNA carries and reports the per-placement mirror fact, but does not change draw-time cull state; flipping a shared index buffer would break an unmirrored instance of the same mesh. Default culling can therefore hide that placement. This is an open application/state boundary separate from tangent handedness.
40.6 Hard limits carried by the bytes
The ABI makes several limits concrete rather than advisory:
-
•
at most two distinct source UV sets reach PBR effects; a third requested set is reported;
-
•
only JOINTS_0/WEIGHTS_0 are imported: four influences per vertex;
-
•
joint indices are four unsigned bytes, so at most 256 palette addresses are encodable;
-
•
SkinnedEffect and SkinnedPbrEffect cap actual palettes at 72 bones; and
-
•
morph targets are CPU-blended into the base byte array and re-uploaded. Position, normal, and tangent offsets are resolved from the canonical layout table; absent or illegal target streams are reported rather than inferred from an old stride subset.
These are different layers of limit. Raising the shader palette above 72 does not widen an 8-bit joint index, and widening the index does not add a fifth influence or a second UV set. Any format change must update the full stride ABI and its oracles together.