Editor/Core/UnityMesh.cs

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.

File AccessNative Interop
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 } );
			}
	}
}