( Latest Revision: 4/28/2023 )
When a mesh is uploaded to Roblox, it is converted into an in-house format that the game engine can read and efficiently work with. Roblox’s mesh format isn’t a format you can normally export to or import into Roblox Studio, but you can download meshes by their assetId using the assetdelivery API.
This resource aims to be an unofficial spec for interpreting this format, so you might be able to write code externally that can read Roblox mesh files. It is assumed the reader is familiar with interpreting C-like structure declarations and how that maps to raw binary data.
Version Header
Every mesh in Roblox starts with a header that is 12 characters in length, followed by a newline ( \n
) character. The header is represented in ASCII, and it is used to indicate what version of the mesh format is being used.
Currently, there are 10 known versions that exist:
version 1.00
version 1.01
version 2.00
version 3.00
version 3.01
version 4.00
version 4.01
version 5.00
version 6.00
version 7.00
Each version has it’s own specific rules and quirks that may need to be addressed while reading the file. Increments to the decimal part of the version usually indicate miscellaneous changes that don’t change the actual structure of the format.
version 1.00
This is the original version of Roblox’s mesh format, which is stored purely in ASCII and can be read by humans. These files are stored as 3 lines of text:
version 1.00
num_faces
(data...)
The num_faces line represents the number of polygons to expect in the data line.
The (data…) line represents a series of concatenated Vector3
pairs, stored in-between brackets with the XYZ coordinates separated by commas as such: [1.00,1.00,1.00]
Data Specification
For every polygon defined in the mesh, there are 3 vertex points that make up the face. Each vertex point contains 3 Vector3 pairs, representing the Position , Normal, and UV respectively.
Thus, you should expect to read num_faces * 9
concatenated Vector3 pairs in the (data…) line!
Each vertex is tokenized like so:
[pos_X,pos_Y,pos_Z][norm_X,norm_Y,norm_Z][tex_U,tex_V,tex_W]
Position
The 1st Vector3, [pos_X,pos_Y,pos_Z]
is the position of the vertex point.
If the mesh is specified to be using version 1.01
, then you can safely assume the mesh is using the correct scale.
Normal
The 2nd Vector3, [norm_X,norm_Y,norm_Z]
is the normal vector of the vertex point, which is used to smooth out the shading of the mesh.
This Vector3 is expected to be a unit vector, so its Magnitude should be exactly 1
. The mesh might have unexpected behavior if this isn’t the case!
UV
The 3rd Vector3, [tex_U,tex_V,tex_W]
is the UV texture coordinate of the vertex point, which is used to determine how the mesh’s texture is applied to the mesh. The tex_W coordinate is unused, so you can expect its value to be 0
.
version 2.00
The version 2.00 format is stored in a binary format, and files may differ in structure depending on factors that aren’t based on the version number.
Data Specification
Once you have read past the version 2.00\n
text at the beginning of the file, the binary data begins!
There will be three struct types used to read the file:
- MeshHeader
- Vertex
- Face
The variables in each of these structs are defined in a specific order, and the type of each variable specifies how many bytes should be sequentially read and copied into the variable.
MeshHeader
The first chunk of data is the MeshHeader, represented by the following struct definition:
struct MeshHeader
{
ushort sizeof_MeshHeader;
byte sizeof_Vertex;
byte sizeof_Face;
uint numVerts;
uint numFaces;
}
If you read MeshHeader.sizeof_MeshHeader
and it does not share the same sizeof(MeshHeader)
as the one in your code, then it is unlikely that you’ll be able to read it correctly! This could be due to a revision to the format though, as will be discussed below.
Vertex
Once you have read the MeshHeader, you should expect to read an array, Vertex[numVerts] vertices;
using the following struct:
struct Vertex
{
float px, py, pz; // Position
float nx, ny, nz; // Normal Vector
float tu, tv; // UV Texture Coordinates
sbyte tx, ty, tz, ts; // Tangent Vector & Bi-Normal Direction
byte r, g, b, a; // RGBA Color Tinting
}
This array represents all of the vertices in the mesh, which can be linked together into faces.
While the Position, Normal, and UV coordinates are stored with floats, the Tangent uses 4 bytes that are mapped from a signed-byte value of [-127, 127]
to a decimal value of [-1, 1]
. The first 3 bytes are the XYZ values, while ts
is the sign value of the tangent (either -127 or 127, which is mapped to -1 or 1)
Face
Finally, you should expect to read an array, Face[numFaces] faces;
using the following struct:
struct Face
{
uint a; // 1st Vertex Index
uint b; // 2nd Vertex Index
uint c; // 3rd Vertex Index
}
This array represents indexes in the Vertex array that was noted earlier. The three Vertex
structs that are indexed using the Face
are used to form a polygon in the mesh!
version 3.00
Version 3 of the mesh format is a minor revision which introduces support for LOD meshes.
MeshHeader
Firstly, here are the changes to the MeshHeader
:
struct MeshHeader
{
ushort sizeof_MeshHeader;
byte sizeof_Vertex;
byte sizeof_Face;
[+] ushort sizeof_LOD;
[+] ushort numLODs;
uint numVerts;
uint numFaces;
}
LODs
After reading the faces of the mesh file, there will be (numLODs * 4
) bytes at the end of the file, representing an array of numLODs
ints, or just:
uint mesh_LODs[numLODs];
The mesh_LODs
array uses integers, assuming sizeof_LOD == sizeof(uint)
.
It represents a series of face ranges, the faces of which form meshes that can be used at various distances by Roblox’s mesh rendering system.
For example, you might have an array that looks like this:
{ 0, 1820, 2672, 3045 }
This array is interpreted as follows:
- The Main mesh is formed using faces
[0 - 1819]
- The 1st LOD mesh is formed using faces
[1820 - 2671]
- The 2nd LOD mesh is formed using faces
[2672 - 3044]
All of these faces should be stored in whatever array of Faces you have defined.
version 4.00
Version 4 of the mesh format is a major revision that introduces skeletal data and mesh deformation. Data such as vertices, faces, and LODs remain relatively unchanged, but everything else is shifted around and new.
Mesh
A full v4 mesh file can be represented with the following pseudo-header:
struct Mesh
{
MeshHeader header;
Vertex[numVerts] verts;
Envelope[numVerts] envelopes?;
Face[numFaces] faces;
uint[numLODs] lods;
Bone[numBones] bones;
byte[nameTableSize] nameTable;
MeshSubset[numSubsets] subsets;
}
MeshHeader
The following struct describes the new MeshHeader revision:
struct MeshHeader
{
ushort sizeof_MeshHeader;
ushort lodType;
uint numVerts;
uint numFaces;
ushort numLODs;
ushort numBones;
uint sizeof_boneNamesBuffer;
ushort numSubsets;
byte numHighQualityLODs;
byte unused;
}
There is no longer any size information for anything besides the header itself. sizeof_MeshHeader
is expected to be 24
in v4 meshes.
The lodType
is metadata from Roblox describing what technique was used to auto-generate LODs for this mesh. It maps to the following enum, but otherwise is unimportant:
enum MeshLodType
{
None = 0,
Unknown = 1,
RbxSimplifier = 2,
ZeuxMeshOptimizer = 3,
}
Vertex
Once you’ve read the new MeshHeader, you will read the same Vertex[numVerts] vertices;
array as you did in version 2.00
:
struct Vertex
{
float px, py, pz;
float nx, ny, nz;
float tu, tv;
sbyte tx, ty, tz, ts;
byte r, g, b, a;
}
The difference this time is that Vertex
will always have RGBA data. Each Vertex
is 40 bytes in size. Tangent data may still be zero’d out, so make sure to continue handling invalid tangent vectors.
Envelope
If numBones > 0
, then you’ll then read Envelope[numVerts] envelopes;
using the following struct:
struct Envelope
{
byte bones[4];
byte weights[4];
}
Envelopes define the deformation influences of up to 4 bones for each vertex. Each byte in the bones
table represents an index to the mesh subset associated with vertex[i]
, which in-turn indexes the bones[numBones]
table. More information about mesh subsets will follow near the bottom of the v4 spec.
Each byte in the weights
table is a weight value weight[i]
(between 0-255) for how much the bone at index bones[meshSubset[i]]
can deform the vertex at verts[i]
.
As mentioned before, this data will not be present if MeshHeader.numBones == 0
. There are meshes on production that do this, so watch out for them!
Faces
Next, you’ll read Face[numFaces] faces
and int[numLODs] lods
in the same way as a v3 mesh:
Face[numFaces] faces;
uint[numLODs] lods;
Nothing has changed regarding how the faces are read and how the LOD range indices are interpreted.
Bones
Next you’ll read Bone[numBones] bones
, using the following struct:
struct Bone
{
uint boneNameIndex;
ushort parentIndex;
ushort lodParentIndex;
float culling;
float r00, r01, r02;
float r10, r11, r12;
float r20, r21, r22;
float x, y, z;
}
Each bone holds a position and rotation matrix with the same behavior as Roblox’s CFrames (Y-up, -Z forward). It also holds an index into the bone names buffer, which will be explained in the next stage. Note that the CFrame value is stored in world-space instead of object-space.
The parentIndex
and lodParentIndex
values index a parent bone in the bones table. When the value is 0xFFFF
, you can assume the bone has no parent (i.e. it’ll be parented to the MeshPart).
In 99% of cases [citation needed], parentIndex
and lodParentIndex
are equal. The intent is to allow for skeletons to be simplified at different LOD levels, though in practice this may not be necessary.
The culling
value stores the distance between the bone and the furthest vertex that can be influenced by the bone. It is used to help compute a bounding box around the MeshPart for frustum culling.
Bone Names Buffer
After reading the bones, you’ll read:
byte[sizeof_boneNamesBuffer] boneNamesBuffer;
The boneNamesBuffer
is an array of null-terminated UTF-8 characters stored in a raw buffer, and it holds the names of each bone.
The boneNameIndex
field of the Bone
struct is the starting index of that bone’s name in the name table. To get the name of a bone, you’ll need to read from that index until you encounter a null character in the sequence.
MeshSubset
Finally, you’ll read MeshSubset[numSubsets] subsets;
using the following struct:
struct MeshSubset
{
uint facesBegin;
uint facesLength;
uint vertsBegin;
uint vertsLength;
uint numBoneIndices;
ushort boneIndices[26];
}
Each MeshSubset
defines a range of vertices and faces that are influenced by no more than 26 distinct bones. This specific number is used because some older GPUs can only support 26 bones per mesh, so Roblox splits the mesh up into subsets to work around the limitation.
The envelope
data you read above indexes into these subsets. You can mark each vertex’s associated subset by iterating vertsLength
vertices, starting from vertsBegin
.
The boneIndices
table will always have 26 values, while numBoneIndices
should be a value between [1-26]. Any extra values in the boneIndices
table will be set to 0xFFFF
.
version 5.00
Version 5 of the mesh format is a major, but isolated revision to version 4 of the mesh format. It adds a blob of additional data at the end of the file to assist with driving facial animation through the Facial Action Coding System standard.
MeshHeader
In this revision, MeshHeader
received the following changes:
struct MeshHeader
{
ushort sizeof_MeshHeader;
ushort numMeshes;
uint numVerts;
uint numFaces;
ushort numLODs;
ushort numBones;
uint sizeof_boneNameBuffer;
ushort numSubsets;
byte numHighQualityLODs;
byte unusedPadding;
[+] uint facsDataFormat;
[+] uint facsDataSize;
}
The facsDataFormat
field should always have a 32-bit value of 1. If the value is not 1, you can assume there is no valid FACS data in the mesh.
The facsDataSize
field represents how many bytes have been appended at the end of the file for the FACS data. It is a separate chunk of data that can be ignored if you don’t intend to support facial animation yourself. If present, this data is appended directly after the mesh subset data described in version 4.
Mesh
The pseudo-header for Mesh
has the following addition:
struct Mesh
{
MeshHeader header;
Vertex[numVerts] verts;
Envelope[numVerts] envelopes?;
Face[numFaces] faces;
uint[numLODs] lods;
Bone[numBones] bones;
byte[nameTableSize] nameTable;
MeshSubset[numSubsets] subsets;
[+] byte[facsDataSize] facsDataBuffer;
}
If you have no intention of supporting facial animation, these new blocks of data can be freely ignored and you’ll still be able to read it as normal.
FacsData
The FacsData
of a mesh stores both the FACS Poses and the Combination Poses that were authored by the mesh artist. The binary blob facsDataBuffer
is represented by the following struct:
struct FacsData
{
uint sizeof_faceBoneNamesBuffer;
uint sizeof_faceControlNamesBuffer;
ulong sizeof_quantizedTransformsBuffer;
uint sizeof_twoPoseCorrectivesBuffer;
uint sizeof_threePoseCorrectivesBuffer;
byte faceBoneNamesBuffer[sizeof_faceBoneNamesBuffer];
byte faceControlNamesBuffer[sizeof_faceControlNamesBuffer];
byte quantizedTransformsBuffer[sizeof_quantizedTransformsBuffer];
byte twoPoseCorrectivesBuffer[sizeof_twoPoseCorrectivesBuffer];
byte threePoseCorrectivesBuffer[sizeof_threePoseCorrectivesBuffer];
}
Face Bone/Control Names
The faceBoneNamesBuffer
and faceControlNamesBuffer
variables correlate to variable-width arrays of UTF-8 strings, similar to the boneNameBuffer
in v4 meshes.
This time however, there’s nothing explicitly mapping where each string starts, nor any indication of how many strings are in both buffers. You instead just have to manually split each buffer into a list of strings, where \0
is the terminator for each string.
The bone names represent which bones are targeted by the face poses, while the control names are 50 shorthand abbreviations for the FACS Poses mentioned above.
QuantizedTransforms
The binary blob quantizedTransformsBuffer
is represented by the following struct:
struct QuantizedTransforms
{
QuantizedMatrix px;
QuantizedMatrix py;
QuantizedMatrix pz;
QuantizedMatrix rx;
QuantizedMatrix ry;
QuantizedMatrix rz;
}
QuantizedMatrix
(which will be spec’d below) represents a 2D array of compressed floating point values, indexed through the specified FACS pose and target bone for that coordinate. The 6 matrices stored sequentially in QuantizedTransform
correlate to the positions and rotations mapped out for each facial animation pose.
QuantizedMatrix
The QuantizedMatrix
object itself is defined with the following struct:
struct QuantizedMatrix
{
ushort version;
uint rows, cols;
// if version == 1 ...
float v1_matrix[rows][cols];
// elseif version == 2 ...
float v2_min, v2_max;
ushort v2_matrix[rows][cols];
}
The version
field indicates whether the floating point values are stored raw (1) or quantized (2).
When quantized, the values in v2_matrix
map in the range [0-65535]
to the specified [v2_min-v2_max]
floats of the struct. Each value then gets converted into a float through the following formula:
value = v2_min + (quantizedValue * precision)
where precision = (v2_max - v2_min) / 65535f
The rows
of the matrix map to bones defined in the Face Bone Names table declared earlier, and the cols
map to any number of the 50 standard FACS poses, plus any corrective poses declared.
You can expect the value of cols
to be:
numFaceControlNames + (sizeof_twoPoseCorrectivesBuffer / 4) + (sizeof_threePoseCorrectivesBuffer / 6)
Corrective Poses
In addition to the usual 50 standard FACS poses, Roblox also allows the definition of corrective poses, which are poses that represent the combination of 2-3 FACS poses.
The 2-3 combinations for these poses are mapped using twoPoseCorrectivesBuffer
and threePoseCorrectivesBuffer
respectively. Each buffer represents an array of 16-bit integers, whose number of values are divisible by 2 and 3 respectively. We can map them out as such:
var twoPoseCorrectives = new TwoPoseCorrective[sizeof_twoPoseCorrectivesBuffer / 4]
var threePoseCorrectives = new ThreePoseCorrective[sizeof_threePoseCorrectivesBuffer / 6]
The TwoPoseCorrective
and ThreePoseCorrective
types match the following declarations:
struct TwoPoseCorrective
{
ushort controlIndex0;
ushort controlIndex1;
}
struct ThreePoseCorrective
{
ushort controlIndex0;
ushort controlIndex1;
ushort controlIndex2;
}
Additional Notes
- The rotation of each face bone is applied in ZYX order (i.e.
CFrame.Angles(rx, ry, rz)
) - The position of the face bone is a translation offset relative to the rotation (i.e.
rotation * CFrame.new(position)
)
version 6.00
To be determined…
Wrapping Up
Congratulations! You should now hopefully have a surface-level idea of how Roblox’s mesh format works! If you have any questions or insights, please feel free to post them below!