Vertex packing: the glTF stride ABI, index widths and topology
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 importer, upload path, fidelity header and stride tests at 009d40f5; not built or executed. Renderer-side agreement rests on the stride-conformance tests CNA runs in CI on five identities, not on every renderer.
Between glTF's typed accessors and a renderer's vertex fetch lies a compact byte protocol: the importer writes tightly packed vertex records and a stride, the content upload path turns that stride into a vertex declaration, and every renderer, the morph blender and the test oracles must agree on the same offsets. This page states that stride ABI at this snapshot, how a primitive's layout is chosen, why ordinary C++ vertex structs could not carry it, how indices are narrowed safely, how the seven topologies are routed, how tangents and mirrored placements are handled, and which hard limits the bytes impose. It is for renderer maintainers, importer maintainers and anyone writing .cnj sidecars by hand.
The stride is an ABI
ExtractMesh in GltfImportCore.cpp packs each vertex into one of a fixed set of layouts and records only the byte stride. The meaning of each stride is stated once, as data, in the canonical table in VertexDeclarationFidelity.hpp (InferredLayoutForStride). Offsets are bytes; colours and joint indices are four unsigned bytes.
| Stride | Fields and byte offsets | Effect family | Emitted by the glTF importer? |
|---|---|---|---|
| 16 | position 0, colour 12 | vertex-coloured (VertexPositionColor) | no — non-glTF content only |
| 20 | position 0, UV 12 | DualTextureEffect | yes (unlit base colour + occlusion) |
| 24 | position 0, colour 12, UV 16 | BasicEffect with vertex colour | yes (unlit, coloured) |
| 32 | position 0, normal 12, UV 24 | BasicEffect | yes (unlit, uncoloured) |
| 48 | position 0, normal 12, tangent (vec4) 24, UV 40 | PbrEffect | yes |
| 52 | position 0, normal 12, UV 24, weights 32, joints 48 | SkinnedEffect | yes (skinned unlit) |
| 56 | stride 52 + colour 52 | SkinnedEffect with vertex colour | yes (skinned unlit, coloured) |
| 60 | stride 48 + UV1 48 + colour 56 | PbrEffect, two UV sets and/or COLOR_0 | yes |
| 68 | position 0, normal 12, tangent 24, UV 40, weights 48, joints 64 | SkinnedPbrEffect | yes |
| 76 | stride 68 + UV1 68 | SkinnedPbrEffect, two UV sets | yes |
| 80 | stride 76 + colour 76 | SkinnedPbrEffect with COLOR_0 | yes |
So the canonical table has eleven entries and the glTF importer emits ten of them; stride 16 is the typed VertexPositionColor layout used by non-glTF content. Keeping those two counts apart avoids a recurring documentation contradiction. Stride 60 originally padded the naturally 56-byte rigid PBR-plus-UV1 record to four more bytes, because 56 already meant "skinned with colour" and one stride must have one meaning; those four bytes later became the packed COLOR_0 of a vertex-coloured metallic-roughness primitive, written as opaque white (the identity multiplier) when the primitive has no colour. Stride 76 is exactly seven fields with no spare bytes, so the skinned counterpart needed a stride of its own: 80, with the whole stride-76 record as a byte-for-byte prefix.
How a primitive's layout is chosen
Selection is semantic, not user-configurable, and it is data: VertexLayoutTableEXT() returns the ordered rows and SelectVertexLayoutEXT the first row whose flags match. The flags are skinned (JOINTS_0 and WEIGHTS_0 present), coloured (COLOR_0), PBR (the material model is metallic-roughness, which is every material except one declaring KHR_materials_unlit), dual-texture (unlit, uncoloured, unskinned, with base-colour and occlusion maps) and "needs a second UV set". Skinned rows come first because they are a different family: skinned + PBR selects 68, widened to 76 for two sampled UV sets and to 80 when coloured; skinned unlit selects 52, or 56 when coloured. Unskinned PBR selects 48, widened to 60 for a second UV set or a colour; unskinned unlit selects 32, or 24 when coloured, or 20 for the dual-texture case. Missing UVs default to (0, 0) and missing colour alpha to 255; missing normals are computed for a primitive that has faces (triangle lists, strips and fans; see tangents), while a point or line primitive, which has no surface to compute one from, keeps the packer’s placeholder normal (0, 0, 1).
Each row also names what it cannot carry. Stride 24 has no normal slot and stride 20 has neither normal nor tangent, so a primitive landing there loses its authored normals and cannot be lit; the row's text says so and the loss is reported. Coloured metallic-roughness primitives used to be downgraded to stride 24 exactly that way, losing normals, tangents and every PBR factor and map; they now keep the PBR layout (60 or 80), which is why 24 and 56 are reached only by unlit materials. The table's tests in GltfVertexLayoutTableTests.cpp walk every flag combination, check that each selected stride carries what the combination asks for, that every lossy row says what and why, and that the committed corpus agrees with the table on every primitive it extracts.
Why ordinary C++ structs were unsafe
CNA's built-in vertex structs derive from the polymorphic IVertexType, so the C++ objects carry a vtable pointer: sizeof is larger than the clean GPU layout and the first declared field need not start at byte 0. Reinterpreting a tightly packed byte array as an array of those structs shifts every field, and the renderer receives plausible-sized buffers full of nonsense — the root cause of a long-running "invisible model" symptom family recorded in the loader's comments.
The bridge is now explicit, in BuildVertexBufferFromRawBytes in ContentManager.cpp, shared by the glTF, .cnj and .cnb readers:
- strides 16, 20, 24 and 32 are read field by field from their clean offsets, reconstructed as real typed vertex objects and uploaded through typed
SetData; - strides 48, 52, 56, 60, 68, 76 and 80 go through
VertexBuffer::SetDataRaw, which copies the packed bytes unchanged; - every buffer is created with a
VertexDeclarationbuilt from the same canonical table entry, so the renderer boundary sees the real element list rather than an empty declaration and a stride; - a stride outside the table throws
ContentLoadExceptioninstead of returning an empty, never-filled vertex buffer — which is what an unlisted stride silently produced before.
A hand-written .cnj naming any other stride is therefore not "another supported layout": it is refused at load.
One table, several restatements
The canonical table exists because seven rasterizing renderers (Vulkan, Software, WebGPU, SDL_GPU and the three Direct3D renderers) do not translate a VertexDeclaration: they choose a native input layout from a stride table and discard the rest. A stride does not determine element composition, so two declarations with one stride share one native layout and the one that does not match it would be read from the wrong bytes. The header is not a translator; it is the safety boundary that makes the gap safe. Its predicate accepts a declaration only if the native record advance equals the declared stride, no native fetch reads bytes a declared element owns under another semantic, every declared element has a native attribute with the same usage, usage index, offset and format, every element lies inside the stride and no two elements overlap. A native attribute the declaration does not name is not a violation — a position-only stride-12 buffer renders correctly on Vulkan through its position-and-colour fallback. EasyGL is checked differently, against the selected stock program's own ordered input list, because it binds elements by their index in the declaration and its programs give one attribute location different meanings (colour at stride 16, UV at 20, normal at 32).
At this snapshot the table is also queried, not just restated, by the upload path (the declaration above) and by the morph blender, which looks up the normal and tangent offsets for a stride instead of keeping its own list. It is still stated independently by the importer's selection rows, by each renderer's native layouts, and by the L5 golden tables — the Python generator's STRIDE_LAYOUTS in l5.py and the C++ oracle, which GltfBufferOracle.TheCppLayoutTableAgreesWithTheOneTheGeneratorPackedWith compares on the reasoning that a table stated once cannot catch its own drift. GltfStrideAndBuffer.EveryImportedGltfStrideCarriesItsCanonicalVertexDeclaration and the RendererStrideConformance.* cases run in CI on STUB, HEADLESS, OPENGLES3, VULKAN and SOFTWARE; a renderer that cannot bind a colour-carrying PBR stride must refuse the draw by name rather than fetch the wrong bytes. Generating every view from one declarative source remains a possible refactor; until then, tests that compare independently stated tables are worth more than comments claiming agreement.
Indices: source width is not destination width
CNA decodes glTF indices itself rather than through cgltf's index helper, which returns 0 with no error channel for a sparse accessor — the original defect D4, where a sparse index accessor decoded to all zeros. The decoder accepts unsigned byte, short and int scalar accessors, honours buffer-view and accessor offsets and sparse overrides (including an accessor with no base view), reads each component with memcpy because a view may start at any byte offset, computes the required span with overflow-safe arithmetic, and names the offending value in every error. A non-indexed primitive receives the implicit sequence 0, 1, …, n−1.
Every decoded index is proved below the vertex count before anything consumes it. Only then is the destination width chosen, from the packed vertex count alone:
| Packed vertex count | GPU index width |
|---|---|
| at most 65,535 | 16 bits |
| more than 65,535 | 32 bits |
The source component type is deliberately irrelevant: a 100-vertex mesh stored with 32-bit indices becomes a 16-bit index buffer, and the preceding range proof is what makes that narrowing safe. The count is the packed count, which can exceed the source count when flat normals split vertices. The .cnj reader applies the same rule to its sidecars (a mirror of XNA's own processor, which switches to 32-bit indices above 65,535 vertices), and GltfStrideAndBuffer.TheIndexBufferElementSizeFollowsTheVertexCountOnBothSides pins the rule.
A historical sharp edge is worth knowing about because it was a real contract hazard: the final hand-off used to cast a std::vector<std::uint8_t>::data() address to std::uint16_t* or std::uint32_t* before IndexBuffer::SetData, a misaligned typed access and so undefined behaviour even though the allocator usually returned aligned memory. Current code copies the bytes with memcpy into properly typed storage (IndicesFromBytes) once per part at load time; the GPU bytes are unchanged.
All seven topology modes have an explicit route
ClassifyPrimitiveTopology maps glTF modes 0–6 and throws for any other value rather than assuming triangles. Before packing, an incomplete tail is dropped and counted (a fourth index is not a triangle; a strip or loop shorter than one primitive draws nothing). A LINE_LOOP becomes a LINE_STRIP carrying its own closing segment; TRIANGLE_STRIP and TRIANGLE_FAN are rewritten into winding-correct triangle lists; triangles, lines, line strips and points keep their topology. The part's CNAEXT PrimitiveType travels through the direct, .cnj and .cnb routes to ModelMesh::Draw, and the primitive count follows the part's own topology — n/3 for a triangle list, n/2 for lines, n−1 for a line strip, n for points — rather than dividing by three everywhere. A conversion is reported as topology-converted and an incomplete tail as incomplete-indices-dropped, so neither is a silent reinterpretation. Draco-compressed primitives are restricted to triangle lists and strips by that extension's own rules. This closes the forensic defect D5, where every mode was decoded as a triangle list.
Normals, tangents and winding
When a primitive that has faces (triangle lists, strips and fans) authors no NORMAL, the importer computes flat normals, splitting vertices so that each face owns its corners, as the glTF specification requires (a point or line primitive has no faces and keeps the packer’s placeholder normal (0, 0, 1)); faces that are only nearly parallel may still share a vertex within a reproducibility tolerance, which is reported. A morphed primitive without authored normals is fully split and has its flat normals recomputed from the morphed positions at every pose (see morph targets).
If a primitive on a PBR layout supplies TANGENT, CNA preserves it; legacy _TANGENT/_BINORMAL VEC3 pairs are imported as a tangent with a handedness derived from the binormal; an authored tangent is ignored (and reported) when the normals were generated, because it was built against normals the file did not supply. Otherwise ComputeTangentsEXT derives tangent and handedness from positions, normals and the UV set the normal map actually samples: per-triangle position/UV-gradient tangents, skipped for degenerate-UV triangles, accumulated at each corner weighted by that corner's angle, then Gram–Schmidt-orthogonalised against the normal. It is intentionally not a bit-exact MikkTSpace port — it owns one tangent per glTF vertex and does not weld corners across matching position/normal seams — and CNA documents its measured distance from the reference algorithm. Only the PBR strides have a tangent slot, so an authored tangent on any other layout is dropped and reported.
No coordinate conversion is needed, but one winding case remains. A placement whose composed transform has a negative 3×3 determinant mirrors the mesh and so reverses its front faces. CNA records the fact per placement and reports mirrored-winding-unapplied, but does not change draw-time cull state, and flipping the shared index buffer would break an unmirrored placement of the same mesh. Under the default cull mode such a placement can disappear; cull state is caller-owned device state, exactly as for the material's carried doubleSided flag (PbrEffect's setDoubleSidedEXTProperty). This is an application boundary, separate from tangent handedness.
Hard limits carried by the bytes
- Two UV sets. At most two distinct source texture-coordinate sets reach a PBR effect; a map asking for a third falls back to packed channel 0 and is reported as
uv-set-mismatchon both the runtime and offline paths. - Four influences. Only
JOINTS_0/WEIGHTS_0are imported; further influence sets are dropped with their worst dropped weight reported, and the retained weights are renormalised so the skin becomes coarser rather than collapsing. - One byte per joint index.
BlendIndicesisByte4; the remapped palette index is checked against 255 explicitly so that it can never wrap. - 72 palette matrices.
SkinnedEffect::MaxBonesandSkinnedPbrEffect::MaxBonesare 72, and a skin with more joints is refused at import with the joint count named — truncation would leave the extra joints at identity and collapse their vertices toward the origin. - One colour set.
COLOR_0only. - CPU morphing. Morph targets are blended on the CPU into the base byte array and re-uploaded; position, normal and tangent offsets come from the canonical table, and morph deltas for texture coordinates or colours are dropped with a report.
These are different layers of limit. Raising the shader palette above 72 would not widen a one-byte joint index, and widening the index would not add a fifth influence or a third UV set. Any format change must update the whole stride ABI and its independently stated oracles together.
Evidence
Checked by reading the TARGET sources and tests at 009d40f5; not built or executed. Byte-level agreement is the L5 layer (140 committed vertex/index goldens) and renderer-side agreement the stride-conformance cases described on the conformance ladder; the user-facing layout story is on 3D Rendering and Tutorial 51.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- 3D Rendering · Model Loading
- Architecture
- Graphics architecture
- Internals
- Content runtime internals
- Tests and validation
- Test labels (gltf-conformance)
- Deep dives
- glTF import core · Skinning, animation and morphs