Roblox Mesh Format

( 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!

144 Likes

This is a highly needed subject! Good job you uploaded it as a load of people ask how this works.

3 Likes

This was previously a DevHub article that I wrote, but it was recently taken down for some reason. I guess they decided it was too niche to maintain, so I’ll take it from here.

9 Likes

DevHub articles are documentation. We do not document and do not promise anything about the mesh format, it’s purely internal and we can drop support for any format at any point once we convert all existing assets - so DevHub is not a good place for this information.

10 Likes

How do you actually get the meshes from the roblox server?

3 Likes

This is another one of those “things I’m not entirely sure I will need but I’ll learn it anyways”.

Great post! I’m surprised you actually took the time to decipher their system.

6 Likes

Edit from 2023: This edit is severely belated, but a little while after the audio privacy update, asset?id= stopped returning raw data and now just 404s. While there may be other ways to retrieve raw assets from the website, I don’t know anything about those methods. Please know that the following information is long outdated.


The asset?id=[number] link always provides the raw asset. If you copy the link for an asset and paste it into your browser, Roblox will simply provide you the asset as a data stream. For example, using this decal, you can use the asset link, which returns just the data for the image. Web browsers will cause a download prompt to appear. You can then apply the .png extension locally, and your OS will correctly register that the image is a PNG file.image
This is also applicable to everything else that Roblox serves in this manner, e.g. audio and meshes.

8 Likes

Hey everyone! I have extended this to include a WIP specification for version 4.00 of Roblox’s mesh format. I still haven’t fully deciphered the purpose of all the data it stores, but I’ve got it down enough to where it can at least be read fully.

Check out the version 4.00 [WIP] section to learn more!
Let me know if you guys have any insights to things I left stubbed out or wasn’t sure about.

10 Likes

Some time ago, I discovered some Python 2 code for a .mesh parser on GitHub and modified it to run standalone. Though these changes weren’t major, I’ve since lost it to hard-disk damage.

5 Likes

Um I have a question I gotta ask, Um how do you write the script mesh format 4.00 on the roblox studio?. Because I was trying to import a mesh the has like between 46k and 70k tris. And I was trying to figure out a way how to import them. But I don’t how to make a script for it to reduce it’s self. And I really need some help to learn how to do it.

Has anyone written anything that can read 1.00 mesh data and export it to a .obj?

1 Like

Really very interesting, I think I will use it for a project. Thanks for that! P.S.: How did you even get this information out?

I reverse engineered it, nothing too special lol.

2 Likes

Wow, I didn’t know. Anyway, it sounds very interesting, thanks for that!

This to me, seems to be incorrect, at least for 1.00 meshes. I wrote a parser for 1.x and 2/3 meshes, and whenever I invert the tex_V coord like mentioned here, textures look incorrect


This could just be a quirk with THREE.js or something with my code.

3 Likes

From checking some of the S15_Lola V4.00 mesh files I have the following comments that may be useful.

  1. The meshHeader field numSkinData is only 16 bits not 32 (and is followed by a 16 bit number set to 1 in mesh files used for S15_Lola)

  2. There is no meshHeader field to define the number of Envelopes so it is the same as numVertices except when the numBones is zero when there are no Envelope structs in the file.

  3. The SkinData struct appears to be an extension to the LOD data (the facesBegin and facesLength contain the same information).

  4. The SkinData struct would appear to define a different section of the shared vertex and face array and to allow the mapping of bone indices to the bone array.

2 Likes

I did notice a couple of these the other day when I was fixing some implementation issues with the mesh format in Rbx2Source

2 Likes

Hey everyone.

I’ve made a revision to the spec which accounts for the new tangent vector data, which is packed into the 4 bytes that were previously used for the UV’s dummied out tw Z-coordinate.

Let me know if you have any questions!

3 Likes

Made another set of revisions to finalize a majority of the version 4.00 mesh format specification!

4 Likes

ofc maybe we can of version format revisions that’s how mesh like question