Computer Graphics · Graphics Tooling
glTF Buffer Views, Accessor Strides, and Matrix Column Padding
How buffer views and accessors describe typed views over one binary blob, what the alignment rules forbid, and why matrix columns carry padding bytes.
A glTF asset describes geometry twice. The JSON half names a mesh, its primitives, and its attributes; the binary half holds nothing but bytes. The bridge between the two is a three-layer indirection — buffer, buffer view, accessor — and each layer exists to solve a distinct problem. The buffer is a blob the loader may find in a file, in a GLB chunk, or at a URL. The buffer view is a byte window into that blob. The accessor is a typed interpretation of that window: how many elements, of what type, at what stride, with what normalization. Collapsing any layer produces a format that cannot describe real vertex data, and the reasons are worth working through.
Three layers, three problems
A buffer carries a byte length and a URI. In a GLB file the first buffer has no URI and refers to the binary chunk; subsequent buffers may point at external files or at data URIs embedded in the JSON. The buffer layer exists so a loader can place the bytes anywhere: memory-mapped from disk, streamed into a GPU-side staging area, or inflated from a data URI. It has no knowledge of structure.
A buffer view adds a byte offset and byte length inside one buffer, plus an optional target (element array buffer or array buffer) and an optional byteStride. It exists because one buffer usually holds many unrelated things: positions, indices, inverse bind matrices, animation keyframes. A buffer view is the smallest unit that a loader can bind; it is also the smallest unit that can be shared between accessors, which matters for interleaved data where several attributes live in the same window.
An accessor adds the semantics: componentType, type, count, byteOffset relative to the view, normalized, and min/max. This layer exists because the GPU API does not take byte windows, it takes typed attribute descriptions, and because the same bytes can legitimately be read at two different offsets — an interleaved record read as separate attributes.
The indirection is not free. Three lookups per attribute is more work than one pointer, and a loader that resolves accessors lazily pays it on every draw. What it buys is that a single binary blob can serve dozens of meshes at different densities, and that a tool can deduplicate geometry by comparing accessor descriptors rather than byte ranges.
Decoding the GLB container
GLB packs the JSON and binary into one file with a simple framing. A 12-byte header holds the magic glTF as four ASCII bytes, a version word with value 2, and the total file length, all little-endian 32-bit. Then chunks follow, each with a 4-byte length, a 4-byte type, and the payload. The JSON chunk uses type 0x4E4F534A and the binary chunk 0x004E4942.
Two padding rules are specified and one is load-bearing for alignment. Every chunk length must be a multiple of four, so the JSON chunk is padded with space characters (0x20) and the binary chunk with zero bytes. The binary chunk’s payload therefore starts at an offset that is a multiple of four from the file start, which is what allows a loader to map the chunk and hand out views whose offsets are already suitably aligned. A tool that writes the JSON chunk without trailing spaces produces a file whose binary chunk begins at a misaligned offset; the symptoms appear later as alignment validation errors on accessors that are individually correct.
The alignment rules and what they exclude
The specification constrains offsets in ways that form a network of requirements. For any accessor:
accessor.byteOffset + bufferView.byteOffsetmust be a multiple of the component type’s size in bytes.- For a buffer view used as vertex attribute source or index source, both
byteOffsetandbyteStridemust be multiples of four. byteStridemust lie between 4 and 252 inclusive.- For matrix accessors, each column starts on a four-byte boundary, and the padding bytes carry no defined value.
The first rule explains a constraint on the second layer, and the constraint is not arbitrary. A VEC3 of floats is twelve bytes, so consecutive elements in a tightly packed array start at offsets $0, 12, 24, \dots$ — every one of them a multiple of four, which satisfies the accessor rule. Now consider a tight array of VEC3 unsigned shorts: six bytes per element, so element $n$ starts at $6n$, and the offset $6$ is a multiple of two, so that case survives too. The rule bites on element sizes that are not multiples of the component size, and for vectors that cannot happen; the rule becomes a constraint only once matrices introduce padding that is not a whole number of components.
The second rule is where the hardware shows through. Vertex attribute fetch on widely deployed GPU architectures reads at four-byte granularity, and a view whose base offset is not four-byte aligned requires either a slower path or a copy. Making the requirement explicit at the format level lets exporters fail at write time rather than produce a file that renders incorrectly on one vendor’s driver and correctly on another’s.
Why matrices are not contiguous
A matrix accessor is stored column-major, and every column must begin at a four-byte boundary. When a column’s natural size is already a multiple of four — every float matrix — the constraint costs nothing. When it is not, the writer inserts pad bytes, and the element size exceeds the product of rows, columns, and component size:
| Accessor type | Component | Natural column | Stored column | Element size |
|---|---|---|---|---|
MAT2 | unsigned byte (1 B) | 2 B | 4 B | 8 B |
MAT2 | float (4 B) | 8 B | 8 B | 16 B |
MAT3 | unsigned byte (1 B) | 3 B | 4 B | 12 B |
MAT3 | unsigned short (2 B) | 6 B | 8 B | 24 B |
MAT3 | float (4 B) | 12 B | 12 B | 36 B |
MAT4 | unsigned byte (1 B) | 4 B | 4 B | 16 B |
MAT4 | float (4 B) | 16 B | 16 B | 64 B |
A MAT3 of unsigned bytes occupies twelve bytes for nine bytes of data: each column is three bytes plus one pad byte. A reader that copies nine contiguous bytes into a 3×3 array gets column 0 correctly, then the pad byte, then the first two bytes of column 1 — a matrix that looks like a shear combined with a shift. The same trap appears in reverse when an exporter writes nine bytes and lets the accessor claim MAT3.
The trap has a sibling in GLSL. A mat3 in a uniform block under the std140 layout occupies 48 bytes, because std140 rounds each column to the size of a vec4 rather than to a four-byte boundary. Three columns of four floats is twelve floats. The glTF rule and the std140 rule are different rules that happen to be phrased similarly, and confusing them produces buffers that are correct for one consumer and wrong for the other. The OpenGL specification defines std140 in its chapter on programmable shading; glTF’s rule is stated in its own accessor section and is strictly weaker.
Index buffers get a separate constraint: component types are unsigned byte, unsigned short, or unsigned int, and a primitive’s indices must all reside in a single buffer view. Splitting indices across views is not expressible.
Strides, interleaving, and the cost of layout choice
byteStride is defined on the buffer view, not on the accessor, because a stride is a property of the memory the view exposes rather than of the interpretation. With a stride of zero or absent, the view is tightly packed and each element follows the previous one. With a stride of 32, elements are spaced 32 bytes apart regardless of their size, and the bytes between elements belong to some other attribute.
Interleaved layout is the reason the field exists. Five attributes of a vertex — position as three floats, normal as three floats, tangent as four floats, and two UV sets as two floats each — occupy a combined payload per vertex that a tightly packed set of views would split across five byte ranges. Interleaving puts them in one 96-byte record so that a single vertex fetch pulls one cache line per vertex instead of five. The trade is against partial updates: changing every normal touches every cache line in the buffer, whereas a separate normal view would touch only its own range. Skinning reads positions, normals, and joint indices in the same pass, so the interleaved form is usually the better default for animated meshes.
Multiple accessors over one view with one stride is how the decoder recovers the individual attributes: each accessor names the same view, gives its own byteOffset within the record, and the stride comes from the view. A VEC4 attribute at offset 24 in a 96-byte record therefore reads bytes 24–39 of each record, and the next element begins 96 bytes later.
Two invariants must hold and are commonly violated by exporters that compute strides by summing attribute sizes. First, every accessor’s byteOffset within the view must be a multiple of its component size; a VEC3 float placed at record offset 10 is invalid, and the resulting fetch is undefined. Second, the largest element must fit within the remainder of the record — but a loader has no obligation to check this, and the out-of-range read lands in the next vertex’s data, which produces geometry that renders subtly wrong rather than failing.
Normalized integers and the precision they buy
normalized: true reinterprets integers as fixed-point values in $[0, 1]$ for unsigned types and $[-1, 1]$ for signed ones. The mappings are exact and worth memorizing because the reverse conversion on export must mirror them:
/* Decode a normalized glTF integer component to float, per the
specification's formula. The signed case divides by 127, not 128. */
static float decode(unsigned int raw, int bits, int is_signed)
{
if (!is_signed)
return (float)raw / (float)((1u << bits) - 1u); /* 255, 65535 */
/* Sign-extend from the low 'bits' bits. */
int mask = (1 << bits) - 1;
int v = (int)raw & mask;
if (v & (1 << (bits - 1)))
v -= (1 << bits);
return (float)fmaxf((float)v / (float)mask, -1.0f);
}
The fmax is in the specification because the most negative signed value, $-128$ for a byte, divides to $-1.008$ and must be clamped. The asymmetry is deliberate: the format maps integers onto a closed interval at both ends, so the negative side has a slightly coarser step than it would if it divided by 128. For a unit-length normal stored as three normalized bytes, the quantization error per component is on the order of $1/255$, and the resulting normal is off-axis by a corresponding angle before renormalization; the decoder must renormalize after fetch to keep lighting consistent. The details of choosing component sizes per attribute are in vertex attribute quantization.
Sparse accessors exist for the case where an attribute is mostly a constant with a few exceptions — a blend shape that touches a handful of vertices, for instance. A sparse accessor adds indices and values sub-objects, each with its own bufferView, byteOffset, and componentType, plus a count. Decoding means reading the base accessor’s count elements from the base view if one exists, then overwriting the elements named by the sparse indices. The base view may be absent entirely, in which case every base value is the type’s zero. Sparse accessors are more expensive to resolve than dense ones because the loader cannot hand a pointer to the GPU; it either expands to a dense buffer or uploads the sparse correction as a second binding.
POSITION accessors are required to carry min and max as exactly the per-component extremes of the stored values, not of the reconstructed ones. Loaders use the bounds for frustum culling before any data is uploaded, so a min that is wider than the true extent causes over-inclusion and one that is narrower causes geometry to disappear at the screen edge.
Where the model breaks down
The indirection has known rough edges. Every accessor costs a JSON object, and JSON is compacted against a byte budget rather than a node budget: an asset with hundreds of small primitives can spend more bytes on descriptors than on geometry, which is why exporters aggressively share buffer views and merge primitives.
Alignment interacts badly with editing. Inserting a single float into a buffer shifts everything after it, violating every accessor offset downstream. Tools therefore rewrite the whole binary chunk rather than patching in place, which means an import-edit-export round trip rarely preserves byte layout and rarely preserves accessor identity — the very thing that would have allowed deduplication. This is the same structural problem that incremental build systems attack from the other direction, described in content-addressed incremental asset builds.
And the format has no notion of a per-mip or per-tile layout. Every buffer view is a linear byte range, so a texture mip chain must be laid out linearly in the blob even though the GPU would prefer a tiled or blocked arrangement. The container that solves that for GPU textures is a separate specification, covered in KTX2 containers and Basis transcoding.
References
- glTF 2.0 Specification — Khronos Group
- Khronos Data Format Specification — Khronos Group
- The OpenGL Graphics System: A Specification — Khronos Group