A parser and mesh utility for Unity Mesh objects read from serialized data or created as built-in primitives. It reads vertex buffers and index buffers (from YAML or binary-like structures), builds positions, normals, UVs and submesh triangle lists, computes vertex layouts, and can emit OBJ text or produce standard Unity primitive meshes.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace ImportUnityPackage;
/// <summary>
/// A Unity Mesh object (class 43) read from its serialized vertex and index buffers: positions, normals, the first UV
/// set and one triangle list per submesh, in Unity's left-handed object space. Meshes come from text files (buffers as
/// hex strings) or from the binary reader (byte arrays); both use the same field names. Also Unity's built-in
/// primitives (cube, sphere, capsule, cylinder, plane, quad) at Unity's sizes.
/// </summary>
internal sealed class UnityMesh
{
public string Name { get; init; }
public List<double[]> Positions { get; } = new();
public List<double[]> Normals { get; } = new();
public List<double[]> Uvs { get; } = new();
public List<List<int>> Submeshes { get; } = new();
/// <summary>The GUID Unity uses for its built-in resources, whose meshes are addressed by file ID.</summary>
public const string BuiltinGuid = "0000000000000000e000000000000000";
public static readonly IReadOnlyDictionary<long, string> Primitives = new Dictionary<long, string>
{
[10202] = "Cube", [10206] = "Cylinder", [10207] = "Sphere", [10208] = "Capsule", [10209] = "Plane", [10210] = "Quad"
};
static object Field( object node, string key ) => node switch
{
YamlNode yaml => yaml[key],
Dictionary<string, object> map => map.GetValueOrDefault( key ),
_ => null
};
static double Number( object value, double fallback = 0 ) => value switch
{
YamlNode yaml => yaml.Number ?? fallback,
long l => l, double d => d, bool b => b ? 1 : 0,
_ => fallback
};
static IEnumerable<object> Items( object value ) => value switch
{
YamlNode yaml => yaml.Items,
List<object> list => list,
_ => Array.Empty<object>()
};
static byte[] Bytes( object value )
{
if ( value is byte[] bytes ) return bytes;
if ( value is not YamlNode yaml || string.IsNullOrEmpty( yaml.Value ) ) return Array.Empty<byte>();
var hex = yaml.Value.Trim();
if ( hex.Length % 2 != 0 ) throw new InvalidDataException( "A mesh buffer has an odd number of hex digits." );
return Convert.FromHexString( hex );
}
// Vertex attribute sizes: Unity 2019+ VertexAttributeFormat, and the older channel formats (Unity 5 to 2018).
static readonly int[] NewSizes = { 4, 2, 1, 1, 2, 2, 1, 1, 2, 2, 4, 4 };
static readonly int[] OldSizes = { 4, 2, 1, 1, 4 };
/// <summary>Reads a Mesh object's body (a YAML node or a binary reader dictionary).</summary>
public static UnityMesh Read( object body )
{
var mesh = new UnityMesh { Name = (Field( body, "m_Name" ) as YamlNode)?.Value ?? Field( body, "m_Name" ) as string ?? "Mesh" };
var vertexData = Field( body, "m_VertexData" );
var count = (int)Number( Field( vertexData, "m_VertexCount" ) );
var data = Bytes( Field( vertexData, "_typelessdata" ) );
if ( data.Length == 0 && Field( vertexData, "m_DataSize" ) is byte[] binary ) data = binary;
if ( count == 0 || data.Length == 0 )
{
var compressed = Number( Field( Field( Field( body, "m_CompressedMesh" ), "m_Vertices" ), "m_NumItems" ) );
throw new InvalidDataException( compressed > 0 ? "The mesh uses Unity's mesh compression, which is not read." : "The mesh has no vertices." );
}
var channels = Items( Field( vertexData, "m_Channels" ) )
.Select( c => (Stream: (int)Number( Field( c, "stream" ) ), Offset: (int)Number( Field( c, "offset" ) ), Format: (int)Number( Field( c, "format" ) ), Dimension: (int)Number( Field( c, "dimension" ) ) & 0xF) )
.ToList();
// Unity 5 to 2017 have 8 channels (position, normal, color, uv0-3, tangent); later versions 14 (position, normal, tangent, color, uv0-7, weights, indices).
var newLayout = channels.Count > 8;
int UvChannel = newLayout ? 4 : 3;
var layout = Layout( channels, count, NewSizes );
if ( layout.Total > data.Length ) layout = Layout( channels, count, OldSizes );
if ( layout.Total > data.Length ) throw new InvalidDataException( "The mesh's vertex data is shorter than its channels describe." );
double Read( int channel, int vertex, int component )
{
var c = channels[channel];
var at = layout.StreamStart[c.Stream] + vertex * layout.Stride[c.Stream] + c.Offset + component * layout.Sizes( c.Format );
return c.Format switch
{
0 => BitConverter.ToSingle( data, at ),
1 => (double)BitConverter.ToHalf( data, at ),
2 => data[at] / 255.0,
_ => 0
};
}
bool Has( int channel ) => channel < channels.Count && channels[channel].Dimension > 0 && channels[channel].Format is 0 or 1 or 2;
for ( var v = 0; v < count; v++ )
{
mesh.Positions.Add( new[] { Read( 0, v, 0 ), Read( 0, v, 1 ), Read( 0, v, 2 ) } );
if ( Has( 1 ) ) mesh.Normals.Add( new[] { Read( 1, v, 0 ), Read( 1, v, 1 ), Read( 1, v, 2 ) } );
if ( Has( UvChannel ) && channels[UvChannel].Dimension >= 2 ) mesh.Uvs.Add( new[] { Read( UvChannel, v, 0 ), Read( UvChannel, v, 1 ) } );
}
var indices = Bytes( Field( body, "m_IndexBuffer" ) );
var wide = Number( Field( body, "m_IndexFormat" ) ) == 1;
var indexSize = wide ? 4 : 2;
foreach ( var submesh in Items( Field( body, "m_SubMeshes" ) ) )
{
var triangles = new List<int>();
var first = (int)Number( Field( submesh, "firstByte" ) ) / indexSize;
var indexCount = (int)Number( Field( submesh, "indexCount" ) );
var topology = (int)Number( Field( submesh, "topology" ) );
var baseVertex = (int)Number( Field( submesh, "baseVertex" ) );
int Index( int i ) => (wide ? (int)BitConverter.ToUInt32( indices, (first + i) * 4 ) : BitConverter.ToUInt16( indices, (first + i) * 2 )) + baseVertex;
if ( (first + indexCount) * indexSize > indices.Length ) throw new InvalidDataException( "A submesh lies outside the mesh's index buffer." );
if ( topology == 0 ) for ( var i = 0; i + 2 < indexCount; i += 3 ) triangles.AddRange( new[] { Index( i ), Index( i + 1 ), Index( i + 2 ) } );
else if ( topology == 2 ) for ( var i = 0; i + 3 < indexCount; i += 4 ) triangles.AddRange( new[] { Index( i ), Index( i + 1 ), Index( i + 2 ), Index( i ), Index( i + 2 ), Index( i + 3 ) } );
if ( triangles.Any( t => t < 0 || t >= count ) ) throw new InvalidDataException( "A submesh index points past the mesh's vertices." );
mesh.Submeshes.Add( triangles );
}
if ( mesh.Submeshes.All( s => s.Count == 0 ) ) throw new InvalidDataException( "The mesh has no triangles." );
return mesh;
}
sealed record VertexLayout( int[] StreamStart, int[] Stride, int Total, int[] FormatSizes )
{
public int Sizes( int format ) => format < FormatSizes.Length ? FormatSizes[format] : 4;
}
/// <summary>Streams hold their channels interleaved; each stream starts at a 16-byte boundary after the previous one.</summary>
static VertexLayout Layout( List<(int Stream, int Offset, int Format, int Dimension)> channels, int count, int[] sizes )
{
var streams = channels.Where( c => c.Dimension > 0 ).Select( c => c.Stream ).DefaultIfEmpty( 0 ).Max() + 1;
var start = new int[streams]; var stride = new int[streams];
foreach ( var c in channels.Where( c => c.Dimension > 0 ) )
stride[c.Stream] = Math.Max( stride[c.Stream], c.Offset + (c.Format < sizes.Length ? sizes[c.Format] : 4) * c.Dimension );
var total = 0;
for ( var s = 0; s < streams; s++ )
{
start[s] = total;
total += stride[s] * count;
if ( s + 1 < streams ) total = (total + 15) & ~15;
}
return new( start, stride, total, sizes );
}
/// <summary>
/// The mesh as OBJ text in a model file's axes, which the model pipeline maps to Unity as (-x, y, z): X is mirrored.
/// Unity and OBJ both face a triangle along cross(b - a, c - a), and the mirror reverses that, so each triangle is
/// written in reverse order to keep its front (as Unity reverses FBX triangles when it mirrors). Each submesh is a material slot
/// named Submesh0, Submesh1 and so on, matching the renderer's material order. Units are meters.
/// </summary>
public string ToObj()
{
var text = new StringBuilder();
string F( double v ) => v.ToString( "0.######", CultureInfo.InvariantCulture );
text.Append( "# Converted from a Unity mesh\ng " ).Append( SafeName( Name ) ).Append( '\n' );
foreach ( var p in Positions ) text.Append( "v " ).Append( F( -p[0] ) ).Append( ' ' ).Append( F( p[1] ) ).Append( ' ' ).Append( F( p[2] ) ).Append( '\n' );
foreach ( var n in Normals ) text.Append( "vn " ).Append( F( -n[0] ) ).Append( ' ' ).Append( F( n[1] ) ).Append( ' ' ).Append( F( n[2] ) ).Append( '\n' );
foreach ( var t in Uvs ) text.Append( "vt " ).Append( F( t[0] ) ).Append( ' ' ).Append( F( t[1] ) ).Append( '\n' );
bool normals = Normals.Count == Positions.Count, uvs = Uvs.Count == Positions.Count;
string Corner( int i ) => (i + 1) + (uvs || normals ? "/" + (uvs ? (i + 1).ToString( CultureInfo.InvariantCulture ) : "") + (normals ? "/" + (i + 1) : "") : "");
for ( var s = 0; s < Submeshes.Count; s++ )
{
if ( Submeshes[s].Count == 0 ) continue;
text.Append( "usemtl Submesh" ).Append( s ).Append( '\n' );
var triangles = Submeshes[s];
for ( var i = 0; i + 2 < triangles.Count; i += 3 )
text.Append( "f " ).Append( Corner( triangles[i] ) ).Append( ' ' ).Append( Corner( triangles[i + 2] ) ).Append( ' ' ).Append( Corner( triangles[i + 1] ) ).Append( '\n' );
}
return text.ToString();
}
static string SafeName( string name ) => new( (string.IsNullOrWhiteSpace( name ) ? "Mesh" : name).Select( c => char.IsLetterOrDigit( c ) || c is '_' or '-' ? c : '_' ).ToArray() );
/// <summary>One of Unity's built-in primitive meshes, with Unity's sizes, UV layout and single submesh.</summary>
public static UnityMesh Primitive( string name )
{
var mesh = new UnityMesh { Name = name };
var triangles = new List<int>();
mesh.Submeshes.Add( triangles );
// A quad from four corners (counter-clockwise seen from the front, in Unity's left-handed view: clockwise).
void Quad( double[] a, double[] b, double[] c, double[] d, double[] normal, double u0 = 0, double v0 = 0, double u1 = 1, double v1 = 1 )
{
var i = mesh.Positions.Count;
foreach ( var (p, uv) in new[] { (a, new[] { u0, v0 }), (b, new[] { u1, v0 }), (c, new[] { u1, v1 }), (d, new[] { u0, v1 }) } )
{
mesh.Positions.Add( p ); mesh.Normals.Add( normal ); mesh.Uvs.Add( uv );
}
// Unity's front faces wind clockwise as seen from the normal side.
triangles.AddRange( new[] { i, i + 2, i + 1, i, i + 3, i + 2 } );
}
switch ( name )
{
case "Quad": // 1 x 1 in the XY plane, facing -Z (towards a camera looking along +Z).
Quad( new[] { -0.5, -0.5, 0.0 }, new[] { 0.5, -0.5, 0.0 }, new[] { 0.5, 0.5, 0.0 }, new[] { -0.5, 0.5, 0.0 }, new[] { 0.0, 0.0, -1.0 } );
break;
case "Plane": // 10 x 10 in the XZ plane, facing +Y, 10 x 10 cells.
for ( var z = 0; z < 10; z++ )
for ( var x = 0; x < 10; x++ )
{
double x0 = 5 - x, x1 = 4 - x, z0 = 5 - z, z1 = 4 - z;
Quad( new[] { x0, 0, z0 }, new[] { x1, 0, z0 }, new[] { x1, 0, z1 }, new[] { x0, 0, z1 }, new[] { 0.0, 1.0, 0.0 }, x / 10.0, z / 10.0, (x + 1) / 10.0, (z + 1) / 10.0 );
}
break;
case "Cube": // 1 x 1 x 1.
foreach ( var (n, u, v) in new (double[] N, double[] U, double[] V)[]
{
(new[] { 0.0, 0, -1 }, new[] { 1.0, 0, 0 }, new[] { 0.0, 1, 0 }), (new[] { 0.0, 0, 1 }, new[] { -1.0, 0, 0 }, new[] { 0.0, 1, 0 }),
(new[] { -1.0, 0, 0 }, new[] { 0.0, 0, -1 }, new[] { 0.0, 1, 0 }), (new[] { 1.0, 0, 0 }, new[] { 0.0, 0, 1 }, new[] { 0.0, 1, 0 }),
(new[] { 0.0, 1, 0 }, new[] { 1.0, 0, 0 }, new[] { 0.0, 0, 1 }), (new[] { 0.0, -1, 0 }, new[] { 1.0, 0, 0 }, new[] { 0.0, 0, -1 })
} )
{
double[] P( double a, double b ) => new[] { n[0] * 0.5 + u[0] * a + v[0] * b, n[1] * 0.5 + u[1] * a + v[1] * b, n[2] * 0.5 + u[2] * a + v[2] * b };
Quad( P( -0.5, -0.5 ), P( 0.5, -0.5 ), P( 0.5, 0.5 ), P( -0.5, 0.5 ), n );
}
break;
case "Sphere": // Diameter 1.
Revolve( mesh, triangles, 24, 16, t => (0.5 * Math.Sin( Math.PI * t ), -0.5 * Math.Cos( Math.PI * t )), ( t, y ) => (Math.Sin( Math.PI * t ), -Math.Cos( Math.PI * t )) );
break;
case "Capsule": // Height 2, diameter 1: hemispheres joined by a 1 m cylinder.
Revolve( mesh, triangles, 24, 18, t =>
{
if ( t < 1 / 3.0 ) { var a = t * 1.5 * Math.PI; return (0.5 * Math.Sin( a ), -0.5 - 0.5 * Math.Cos( a )); }
if ( t > 2 / 3.0 ) { var a = (t - 2 / 3.0) * 1.5 * Math.PI + Math.PI / 2; return (0.5 * Math.Sin( a ), 0.5 - 0.5 * Math.Cos( a )); }
return (0.5, (t - 0.5) * 3);
}, ( t, y ) =>
{
if ( t < 1 / 3.0 ) { var a = t * 1.5 * Math.PI; return (Math.Sin( a ), -Math.Cos( a )); }
if ( t > 2 / 3.0 ) { var a = (t - 2 / 3.0) * 1.5 * Math.PI + Math.PI / 2; return (Math.Sin( a ), -Math.Cos( a )); }
return (1, 0);
} );
break;
case "Cylinder": // Height 2, diameter 1, with caps.
Revolve( mesh, triangles, 24, 1, t => (0.5, t * 2 - 1), ( t, y ) => (1, 0) );
foreach ( var top in new[] { 1.0, -1.0 } )
{
var center = mesh.Positions.Count;
mesh.Positions.Add( new[] { 0.0, top, 0 } ); mesh.Normals.Add( new[] { 0.0, top, 0 } ); mesh.Uvs.Add( new[] { 0.5, 0.5 } );
for ( var i = 0; i <= 24; i++ )
{
var a = 2 * Math.PI * i / 24;
mesh.Positions.Add( new[] { 0.5 * Math.Cos( a ), top, 0.5 * Math.Sin( a ) } ); mesh.Normals.Add( new[] { 0.0, top, 0 } );
mesh.Uvs.Add( new[] { 0.5 + 0.5 * Math.Cos( a ), 0.5 + 0.5 * Math.Sin( a ) } );
}
for ( var i = 0; i < 24; i++ )
triangles.AddRange( top > 0 ? new[] { center, center + i + 2, center + i + 1 } : new[] { center, center + i + 1, center + i + 2 } );
}
break;
default: throw new InvalidDataException( $"Unknown built-in mesh '{name}'." );
}
return mesh;
}
/// <summary>A surface of revolution about Y: profile(t) gives (radius, height) and normal(t) (radial, vertical) for t in 0..1.</summary>
static void Revolve( UnityMesh mesh, List<int> triangles, int segments, int rings, Func<double, (double R, double Y)> profile, Func<double, double, (double R, double Y)> normal )
{
var start = mesh.Positions.Count;
for ( var ring = 0; ring <= rings; ring++ )
{
var t = (double)ring / rings;
var (r, y) = profile( t );
var (nr, ny) = normal( t, y );
for ( var s = 0; s <= segments; s++ )
{
var a = 2 * Math.PI * s / segments;
mesh.Positions.Add( new[] { r * Math.Cos( a ), y, r * Math.Sin( a ) } );
mesh.Normals.Add( new[] { nr * Math.Cos( a ), ny, nr * Math.Sin( a ) } );
mesh.Uvs.Add( new[] { (double)s / segments, t } );
}
}
for ( var ring = 0; ring < rings; ring++ )
for ( var s = 0; s < segments; s++ )
{
int a = start + ring * (segments + 1) + s, b = a + segments + 1;
// Outward faces, clockwise as seen from outside (Unity's front-face winding).
triangles.AddRange( new[] { a, b, a + 1, a + 1, b, b + 1 } );
}
}
}