GAMES 6320 · Assignment 08

Binary mesh files built by MeshBuilder

MeshBuilder now writes binary mesh files, and the game loads them with four extractions and no parsing.

14 min readC++ · Direct3D · OpenGL

Course: GAMES 6320-001 — Game Engineering II (Fall 2026)

Download (one click, ZIP): MyGame_Assignment08_Release_x64.zip

The ZIP contains a Direct3D Release build (x64). Unzip it and run MyGame.exe.

Controls:

Key What it does
Arrow keys Move the sphere left / right / up / down
Space (hold) The sphere becomes a cone
A / D Move the camera left / right
W / S Move the camera forward / back
Esc Exit

1. What the assignment was about

For the last two assignments my meshes were stored as Lua files: easy for a person to read, easy
for Maya to write, and easy to diff in git. But the game read those files the same way, which
means that every time it started it ran a Lua interpreter over half a megabyte of text for a
sphere and converted every number from text to a float.

This assignment separates the two jobs. The human-readable file stays the source asset, and
the asset build turns it into a binary file that is exactly the bytes the GPU wants:

  • MeshBuilder (a tool that runs at build time) now does all of the Lua reading. It writes the
    vertex count, the index count, the vertex array and the index array straight from memory to the
    built file: four writes, no loops.
  • The game loads the built file into memory with one call and finds the four pieces in it with
    a little pointer arithmetic. There is no parsing and not a single loop in the mesh loading code.

All the slow, forgiving work (parsing, validation, helpful error messages, converting the winding
order) happens once on my machine during the build. The game only does the fast part.

I also did the small optional challenge of giving built meshes their own extension: source files are
still .mesh, built files are .meshbin, so it is obvious which kind of file you are looking at.


2. Screenshots of the game

The game looks exactly like it did last week, which is the point: only the file format changed.

The floor, the sphere and the torus (Direct3D)

Holding Space turns the sphere into the cone:

The cone (Direct3D)

The OpenGL build renders the same scene (the sphere’s color is animated over time, which is why
its shade is a little different in each screenshot):

The OpenGL build


3. A built binary mesh file

The easiest mesh to explain is the floor: a plane with 4 vertices and 2 triangles (6 indices).
This is a hex view of the built file data/meshes/floor.meshbin from the Direct3D build, with
the four parts outlined in different colors:

Hex view of floor.meshbin with the four parts outlined

How to read it (all numbers are little-endian, so the low byte comes first):

  • Red, bytes 0–1: 04 00 = 4, the number of vertices (uint16_t).
  • Blue, bytes 2–3: 06 00 = 6, the number of indices (uint16_t).
  • Green, bytes 4–67: the 4 vertices, 16 bytes each (the dashed lines mark where one
    vertex ends and the next begins). Each vertex is three floats for the position followed by four
    bytes of color. For example the first vertex is 00 00 80 C0 = −4.0, 00 00 00 00 = 0.0,
    00 00 80 40 = 4.0, then 33 73 40 FF = the color (51, 115, 64, 255), a dark green.
  • Orange, bytes 68–79: the 6 indices (uint16_t): 0, 2, 1 and 2, 3, 1.

The whole file is 2 + 2 + 4 × 16 + 6 × 2 = 80 bytes. The human-readable floor.mesh that it
was built from is 1,599 bytes.

The order, and why

I chose: vertex count, index count, vertex array, index array.

  • The counts have to come before the arrays. To find the index array the game has to know how
    big the vertex array is, and that needs the vertex count. Putting a count after its array would
    be one of the wrong orders: the game couldn’t find it without already knowing it.
  • Both counts first makes a tiny fixed-size header. The first 4 bytes are always the two
    counts, so the game reads them and can check right away that the file is exactly
    4 + 16 × vertexCount + 2 × indexCount bytes long, before it looks at any of the array data.
    A truncated or corrupt file is caught with a single comparison.
  • Everything ends up naturally aligned, for free. The loaded file starts at an address from
    malloc(), which is aligned for anything. The vertex array starts at offset 4, which is a
    multiple of 4 (what floats need), and the index array starts at 4 + 16 × vertexCount, which
    is even (what uint16_ts need). With the other “count, array, count, array” order the vertex
    array would start at offset 2, which is not a multiple of 4. On x86 that still works, but it
    isn’t how the data is supposed to be laid out. I didn’t need any padding to get this.

4. Why binary files?

The two big advantages:

  1. They are much smaller. A float is always 4 bytes in binary, while in text “-0.4045085”
    is 10 characters plus a name, an = sign, a comma and spaces. On top of that, the built file
    only has the data the engine actually uses, while the source file also keeps normals, tangents and
    texture coordinates for the future. Less data means less to read from disk (or download).
  2. They are much faster to load. The bytes in the file are already in the exact layout the
    engine and the GPU use. Loading is “read the file into memory, point at the right places”. There
    is no tokenizing, no Lua tables, no text-to-number conversion and no per-vertex work at all.

(A third, smaller one: the game doesn’t need a parser, so the Lua library and all the error handling
for badly written files aren’t needed for meshes at run time any more.)

5. Binary vs. human-readable

Human-readable (Lua) Binary
Size Large Small
Load speed Slow (parse everything) Fast (no parsing)
Can a person read or fix it? Yes, in any text editor Only with a hex editor and the layout in mind
Useful diffs and merges in git Yes, line by line No, git sees “binary file changed”
Good error messages Yes (file and line number) No, a bad byte is just a wrong number
Survives engine changes Yes, it’s independent of how the engine stores data No, it must match the engine exactly
Can differ per platform No need Yes, can be tuned for each platform

That is why we use both, each where its advantages matter:

  • Source control stores the human-readable files. That’s where people work with the data:
    reviewing changes, merging two people’s edits, fixing something by hand, finding out why a model
    looks wrong. Those files are also the real source of truth: if I change the binary format next
    week (say, add normals or 32-bit indices), I just rebuild, and every mesh comes out in the new
    format. If only binary files were stored, changing the format would mean converting every one of
    them by hand, or losing them.
  • The game uses binary files. The player never reads a mesh file, so readability is worth
    nothing at run time, while size and speed matter every time the game starts. The binary files
    are generated from the source files, so they don’t belong in source control at all (like
    .obj or .exe files); they are rebuilt whenever they’re needed.

The asset build is the bridge: it is the one place that pays the price of parsing, and it pays it
on the developer’s machine, once, instead of on every player’s machine, every time.


6. Are the built files the same for Direct3D and OpenGL?

No. They differ in exactly one way: the order of the indices inside each triangle.

Here is the OpenGL build of the same floor:

Hex view of the OpenGL floor.meshbin

The first 68 bytes (the counts and all four vertices) are identical. Only the orange index array
is different:

Triangle 1 Triangle 2
Direct3D build 0, 2, 1 2, 3, 1
OpenGL build 0, 1, 2 2, 1, 3

The human-readable file lists every triangle counter-clockwise (right-handed), which is what
OpenGL expects of a front face, and is also what Maya uses. Direct3D expects front faces to be
clockwise (left-handed). Until now the game swapped the second and third index of every
triangle at run time on Direct3D. That was a loop over every triangle, and this assignment doesn’t
allow any loops in the loading code. More importantly, it was work the game repeated every time
it started, although the answer is always the same. So MeshBuilder now does the swap when it builds
the Direct3D version, and the game hands the indices to the GPU as they are:

// The source file uses right-handed winding order (like OpenGL).
// Direct3D uses left-handed winding order, and so the order of each triangle's indices is reversed here
// so that the game can give the built indices to the GPU without changing them.
#if defined( EAE6320_PLATFORM_D3D )
for ( size_t i = 0; i < indices.size(); i += 3 )
{
std::swap( indices[i + 1], indices[i + 2] );
}
#endif

MeshBuilder is compiled once per platform (x64 is Direct3D and x86 is OpenGL in my solution),
so the #if picks the right behavior for the game it builds assets for.

Everything else is identical on purpose. The vertex layout (sVertex_mesh: 3 floats + 4
uint8_ts = 16 bytes) is the same on both platforms, both run on little-endian x86 CPUs, and I
always write the counts and indices with explicit sizes (uint16_t) rather than int or size_t,
whose sizes depend on the platform. This is also why the binary files are a build product: it is
normal for built assets to be platform-specific (my shaders already are), while the source asset
is shared.


7. Extracting the four pieces at run time

The game loads the whole file with Platform::LoadBinaryFile() and then extracts the four pieces.
This is the actual function (with its two error checks kept, because they show the idea of the
header nicely):

auto currentOffset = reinterpret_cast<uintptr_t>( i_dataFromFile.data );
const auto finalOffset = currentOffset + i_dataFromFile.size;

uint16_t vertexCount = 0;
uint16_t indexCount = 0;
if ( ( currentOffset + sizeof( vertexCount ) + sizeof( indexCount ) ) > finalOffset )
{
// ...the file is too small to contain the counts
return eae6320::Results::InvalidFile;
}
memcpy( &vertexCount, reinterpret_cast<const void*>( currentOffset ), sizeof( vertexCount ) );
currentOffset += sizeof( vertexCount );
memcpy( &indexCount, reinterpret_cast<const void*>( currentOffset ), sizeof( indexCount ) );
currentOffset += sizeof( indexCount );

const auto vertexArraySize = sizeof( eae6320::Graphics::VertexFormats::sVertex_mesh ) * vertexCount;
const auto indexArraySize = sizeof( uint16_t ) * indexCount;
if ( ( currentOffset + vertexArraySize + indexArraySize ) != finalOffset )
{
// ...the file size doesn't match the counts
return eae6320::Results::InvalidFile;
}
o_meshData.vertexCount = vertexCount;
o_meshData.indexCount = indexCount;
o_meshData.vertexData = reinterpret_cast<const eae6320::Graphics::VertexFormats::sVertex_mesh*>( currentOffset );
currentOffset += vertexArraySize;
o_meshData.indexData = reinterpret_cast<const uint16_t*>( currentOffset );

Four extractions, no loops. The counts are copied out with memcpy() (the standards-compliant way
to read a value from an arbitrary address), and the two arrays aren’t copied at all: the mesh data
just points into the loaded file. That memory only has to live until the vertex and index buffers
have been created on the GPU, which happens inside cMesh::Load() while the file data is still in
scope.

For comparison, the writing side in MeshBuilder is the mirror image, four writes of whole blocks:

outputFile.write( reinterpret_cast<const char*>( &vertexCount ), sizeof( vertexCount ) );
outputFile.write( reinterpret_cast<const char*>( &indexCount ), sizeof( indexCount ) );
outputFile.write( reinterpret_cast<const char*>( i_vertices.data() ),
static_cast<std::streamsize>( sizeof( i_vertices[0] ) * vertexCount ) );
outputFile.write( reinterpret_cast<const char*>( i_indices.data() ),
static_cast<std::streamsize>( sizeof( i_indices[0] ) * indexCount ) );

How MeshBuilder is organized

MeshBuilder is built around those four pieces of data. One self-contained part takes the path of
the Lua file and produces a std::vector of vertices and a std::vector of indices; that is
essentially last week’s loader moved out of the game. Then the Direct3D winding swap, and then a
function that writes the four pieces and doesn’t care where they came from. If I ever want to read
a different source format, only the first part changes.

The error handling changed along with the move. In the game, errors went to the log file; in
MeshBuilder they go through OutputErrorMessageWithFileInfo(), which formats them the way Visual
Studio expects, so they show up in the Error List and double-clicking one opens the mesh file.
Typing sdf into floor.mesh now fails the build with:

...\MyGame_\Content\meshes/floor.mesh(10): error : attempt to call a nil value (global 'sdf')

The engine’s Lua library already puts the line number into its messages, so MeshBuilder picks it
out and passes it on as the line number. Visual Studio then jumps straight to line 10. Other problems are
reported the same way (with the file), for example “Triangle 3 refers to a vertex that doesn’t
exist (every index must be an integer from 0 to 3)” or “The mesh has 70000 vertices, but a mesh
can’t have more than 65535”.

That last limit changed slightly: last week a mesh could have 65,536 vertices (every value a
uint16_t index can hold). Now the count is also a uint16_t, and it can only go up to 65,535,
so that is the new maximum, for vertices and for indices.


8. Measuring the difference with a big helix

To see the advantages with real numbers I made a helix in Maya: 5 coils, 109 subdivisions per
coil and 20 sides. That gives 21,836 triangles = 65,508 indices, which is as close to the
65,535 limit of a uint16_t index count as I could get with a helix (the triangle count has to
be even, and it depends on the coil and side counts). I exported it with my exporter from last week. It
turned out the exporter makes one vertex for every triangle corner on this shape, so it also has
65,508 vertices. Both counts are just under their limit. I didn’t commit the helix; it was only
in the project for the measurements.

All timings were done on a Release build (x64, Direct3D) with the Time functions, in
milliseconds, and repeated five times:

  • Human-readable: timing code in MeshBuilder, from just before the Lua file is loaded until
    just before the binary file is written (loading, running and reading every vertex and triangle
    out of the Lua tables).
  • Binary: timing code in cMesh::Load(), from just before LoadBinaryFile() until the four
    pieces have been extracted, written to the log file. I started MyGame.exe directly from its
    folder (not from Visual Studio), so no debugger was attached.

The timing code was temporary and has been removed again.

Size on disk

File Size
helix.mesh (human-readable) 23,960,033 bytes (about 24 MB)
helix.meshbin (binary) 1,179,148 bytes (about 1.2 MB)

The binary file is exactly 4 + 65,508 × 16 + 65,508 × 2 = 1,179,148 bytes, as predicted, and it is
about 20 times smaller. The human-readable file needs about 366 bytes per vertex; the binary one
needs 18 (16 for the vertex and 2 for its index).

Load time

Run Human-readable (MeshBuilder) Binary (game)
1 427.8 ms 0.476 ms
2 420.8 ms 0.496 ms
3 446.1 ms 0.469 ms
4 448.8 ms 0.487 ms
5 454.5 ms 0.550 ms
Median 446.1 ms 0.487 ms

Loading the binary file is about 900 times faster.

What the numbers mean

The size difference shows up in the zipped game (smaller download), on disk and in how much data has
to be read when the game starts. It’s 20× here partly because text is bulky and partly because the
source file keeps data the engine doesn’t use yet. Both are fine for a source file and a waste for
the game.

The speed difference is much larger than the size difference, and that is the interesting part: the
binary file is not 900 times faster because it is smaller. It is faster because there is no work to
do
. Reading 1.2 MB into memory is close to free on a modern PC; what took almost half a second
for the Lua file was running it, building tables for 65,508 vertices and 21,836 triangles, and
looking up and converting every value one by one. The binary version does none of that. It costs
two memcpy()s of two bytes each and some pointer arithmetic, no matter how big the mesh is.

To put half a second into perspective: a game running at 60 frames per second has about 16.7 ms per
frame. Parsing this one helix at load time would take as long as about 27 frames, while loading the
binary file fits into a frame 34 times over. A real level has hundreds of meshes, and loading
screens (or streaming meshes in while the player moves through the world) are exactly where this
matters. And all of that cost is now paid once, by the asset build on my machine, instead of every
time the game starts on every player’s machine.

(Both measurements were taken with the file already in the operating system’s file cache, because
it had just been built or loaded. A cold read from a disk would add time to both, more to the 24 MB
file than to the 1.2 MB one.)


9. Build / test

All four configurations (Debug/Release × x64/x86) build from scratch without errors or
warnings, and both platforms render the same scene. Building only the game project and running it
shows the “Initialization failed” message and exits; after building only BuildMyGameAssets the
game runs. Adding a typo like sdf to a mesh file makes the asset build fail with the error in the
Error List, as shown above.


10. Reflection

The nicest part was how little the game’s code became. cMesh::Load() used to contain a whole Lua
reader with seven helper functions; now it is “load the file, extract four things, create the
buffers”. Because the reader was written as a separate piece last week (path in, vertices and
indices out), moving it into MeshBuilder was mostly a matter of changing where errors are reported.

The decision I thought about most was the winding order. Converting at build time makes the
Direct3D and OpenGL files different. At first I didn’t like that, because one file for both
platforms is simpler. But the game was redoing the same conversion on every start, and shaders are
already built per platform. Once I thought of the built files as “data prepared for one specific
platform”, having them differ made sense.

Something I hadn’t expected: my first attempt at turning Lua’s error messages into Visual Studio
errors didn’t find the line number at all, because the engine’s Lua library formats its messages
as file(line) : error: instead of the usual file:line:. Testing the sdf case in a real build,
instead of assuming it worked, is what caught it.

The helix measurement also changed how I think about “loading”. I had expected binary to be faster
mostly because the file is smaller, but the size only explains a factor of 20. The other factor of
about 45 is simply not doing work.

What I would do next, or in a real game:

  • Add the data the engine will need soon (normals for lighting, texture coordinates) to the binary
    vertex format. The source files already contain it, so it’s just a rebuild.
  • Support 32-bit indices for meshes that need them, while still using 16-bit indices for
    everything else, since most meshes are small.
  • Add a small header with a version number (and maybe a magic number) to the binary file, so that
    the game could recognize a file from an older build instead of misreading it. With the asset build
    always rebuilding everything this hasn’t been a problem yet, but it would be in a game that patches
    files.