Editor/Core/UnityMaterial.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ImportUnityPackage;
/// <summary>Best-effort conversion of text-serialized Standard/URP material properties.</summary>
public sealed class UnityMaterial
{
public Dictionary<string, string> Textures { get; } = new();
public Dictionary<string, double> Numbers { get; } = new();
public Dictionary<string, double[]> Colors { get; } = new();
public List<string> Warnings { get; } = new();
readonly Dictionary<string, string> channelImages = new();
readonly Dictionary<string, double[]> textureScale = new();
readonly Dictionary<string, double[]> textureOffset = new();
bool shaderAlphaTest;
bool shaderBackfaces;
bool shaderTranslucent;
bool terrainLayer;
HashSet<string> declaredProperties;
static readonly string[] ColorProperties = { "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" };
public IEnumerable<string> ColorTextureGuids => ColorProperties.Where( Textures.ContainsKey ).Select( p => Textures[p] );
bool AlphaTest => shaderAlphaTest || Number( "_Mode", 0 ) == 1 || Number( "_AlphaClip", 0 ) == 1;
bool Translucent => shaderTranslucent || Number( "_Mode", 0 ) >= 2 || Number( "_Surface", 0 ) == 1;
public string ShaderName { get; private set; }
public string ShaderGuid { get; private set; }
static readonly Regex GuidPattern = new( @"guid:\s*([a-fA-F0-9]{32})" );
public static IEnumerable<string> References( string text ) => GuidPattern.Matches( text )
.Select( m => m.Groups[1].Value ).Where( g => g.Any( c => c != '0' ) ).Distinct( StringComparer.OrdinalIgnoreCase );
public static UnityMaterial Parse( string text )
{
if ( text.Contains( "TerrainLayer:" ) && !text.Contains( '\0' ) ) return ParseTerrainLayer( text );
if ( !text.Contains( "Material:" ) || text.Contains( '\0' ) )
throw new InvalidDataException( "Material is not Unity text YAML. Re-export it with Asset Serialization set to Force Text in Unity." );
var result = new UnityMaterial();
result.ShaderGuid = Regex.Match( text, @"m_Shader:[^\r\n]*guid:\s*([a-fA-F0-9]{32})" ).Groups[1].Value;
string property = null;
foreach ( var line in text.Split( '\n' ) )
{
var item = Regex.Match( line, @"^\s*-\s*(_[A-Za-z0-9_]+):\s*(.*)$" );
if ( item.Success )
{
property = item.Groups[1].Value;
var value = item.Groups[2].Value.Trim();
if ( double.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) && double.IsFinite( number ) )
result.Numbers[property] = number;
if ( value.StartsWith( "{" ) )
{
var channels = Regex.Matches( value, @"[rgba]:\s*([-+0-9.eE]+)" );
if ( channels.Count == 4 ) result.Colors[property] = channels.Select( m => double.Parse( m.Groups[1].Value, CultureInfo.InvariantCulture ) ).ToArray();
}
}
if ( property != null && line.Contains( "m_Texture:" ) )
{
var guid = GuidPattern.Match( line );
if ( guid.Success && guid.Groups[1].Value.Any( c => c != '0' ) ) result.Textures[property] = guid.Groups[1].Value;
}
if ( property != null && (line.Contains( "m_Scale:" ) || line.Contains( "m_Offset:" )) )
{
var xy = Regex.Match( line, @"x:\s*([-+0-9.eE]+),\s*y:\s*([-+0-9.eE]+)" );
if ( xy.Success ) (line.Contains( "m_Scale:" ) ? result.textureScale : result.textureOffset)[property] =
new[] { double.Parse( xy.Groups[1].Value, CultureInfo.InvariantCulture ), double.Parse( xy.Groups[2].Value, CultureInfo.InvariantCulture ) };
}
if ( line.Contains( "m_Scale:" ) && !Regex.IsMatch( line, @"x:\s*1(?:\.0+)?\s*,\s*y:\s*1(?:\.0+)?\s*}" ) ||
line.Contains( "m_Offset:" ) && !Regex.IsMatch( line, @"x:\s*0(?:\.0+)?\s*,\s*y:\s*0(?:\.0+)?\s*}" ) )
result.Warnings.Add( "Texture tiling/offset needs manual adjustment." );
}
if ( result.Textures.ContainsKey( "_MetallicGlossMap" ) || result.Textures.ContainsKey( "_MaskMap" ) )
result.Warnings.Add( "Packed metallic/smoothness or HDRP mask maps need channel separation; scalar metallic/roughness values were used." );
result.Warnings.Add( "Review the converted material: custom shaders, normal-map conventions and advanced Unity settings are not reproduced exactly." );
return result;
}
public void ConfigureShader( string source )
{
ShaderName = Regex.Match( source, "Shader\\s+\"([^\"]+)\"" ).Groups[1].Value;
declaredProperties = Regex.Matches( source, "(?m)^\\s*(?:\\[[^\\]\\r\\n]*\\]\\s*)*(_[A-Za-z0-9_]+)\\s*\\(\\s*\"[^\"]*\"\\s*," )
.Select( m => m.Groups[1].Value ).ToHashSet();
if ( declaredProperties.Count > 0 )
{
// Unity retains old shader properties in materials. Only use properties declared by the active shader.
foreach ( var stale in Textures.Keys.Where( k => !declaredProperties.Contains( k ) ).ToArray() ) Textures.Remove( stale );
}
shaderAlphaTest = Regex.IsMatch( source, "\"RenderType\"\\s*=\\s*\"TransparentCutout\"" );
shaderBackfaces = Regex.IsMatch( source, @"(?m)^\s*Cull\s+Off\s*$", RegexOptions.IgnoreCase );
shaderTranslucent = Regex.IsMatch( source, "\"RenderType\"\\s*=\\s*\"Transparent\"" );
if ( !string.IsNullOrEmpty( ShaderName ) ) Warnings.Add( $"Shader '{ShaderName}' is approximated using s&box's complex shader; shader code and graph behavior are not translated." );
}
public void PrepareChannels( Func<string, int, double, bool, string> extract )
{
void Channel( string target, string guid, int channel, double scale = 1, bool invert = false )
{
var path = extract( guid, channel, scale, invert );
if ( path != null ) channelImages[target] = path;
}
if ( Textures.TryGetValue( "_MetallicGlossMap", out var packed ) )
{
Channel( "metal", packed, 0 );
Channel( "rough", packed, 3, Number( "_GlossMapScale", Number( "_Smoothness", 1 ) ), true );
}
if ( Textures.TryGetValue( "_MaskMap", out var mask ) )
{
Channel( "metal", mask, 0 );
Channel( "ao", mask, 1 );
// TerrainLit stores height in B; HDRP Lit stores a detail mask instead.
if ( terrainLayer ) Channel( "height", mask, 2 );
Channel( "rough", mask, 3, 1, true );
}
if ( Textures.TryGetValue( "_OcclusionMap", out var ao ) ) Channel( "ao", ao, 1 );
if ( AlphaTest || Translucent )
{
foreach ( var property in ColorProperties )
{
if ( !Textures.TryGetValue( property, out var guid ) ) continue;
Channel( "opacity", guid, 3 );
if ( channelImages.ContainsKey( "opacity" ) ) break;
}
}
if ( channelImages.Count > 0 ) Warnings.RemoveAll( w => w.StartsWith( "Packed metallic/" ) || w.StartsWith( "Unity terrain mask channels" ) );
}
static UnityMaterial ParseTerrainLayer( string text )
{
var result = new UnityMaterial { terrainLayer = true };
foreach ( var (source, target) in new[] { ("m_DiffuseTexture", "_MainTex"), ("m_NormalMapTexture", "_BumpMap"), ("m_MaskMapTexture", "_MaskMap") } )
{
var line = text.Split( '\n' ).FirstOrDefault( l => l.TrimStart().StartsWith( source + ":", StringComparison.Ordinal ) );
if ( line != null && References( line ).FirstOrDefault() is string guid ) result.Textures[target] = guid;
}
foreach ( var (source, target) in new[] { ("m_Metallic", "_Metallic"), ("m_Smoothness", "_Smoothness") } )
{
var match = Regex.Match( text, @"(?m)^\s*" + source + @":\s*([-+0-9.eE]+)" );
if ( match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ) result.Numbers[target] = value;
}
result.Warnings.Add( "Terrain layer converted; review world tiling, normal strength and remapping in the terrain material editor." );
if ( result.Textures.ContainsKey( "_MaskMap" ) ) result.Warnings.Add( "Unity terrain mask channels need separation; packed mask was preserved as a source texture." );
return result;
}
string Texture( Func<string, string> resolve, params string[] names )
{
foreach ( var name in names )
if ( Textures.TryGetValue( name, out var guid ) && resolve( guid ) is string path ) return path;
return null;
}
public double Number( string name, double fallback ) => Numbers.GetValueOrDefault( name, fallback );
static string N( double value ) => double.IsFinite( value ) ? value.ToString( "0.######", CultureInfo.InvariantCulture ) : "0";
internal static string Quote( string value ) => JsonSerializer.Serialize( value );
public string ToVmat( Func<string, string> resolve )
{
var text = new StringBuilder( "Layer0\n{\n\tshader \"shaders/complex.shader_c\"\n" );
void Set( string key, string value ) => text.AppendLine( $"\t{key} {Quote( value )}" );
Set( "TextureColor", Texture( resolve, "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" ) ?? "materials/default/default_color.tga" );
var colorProperty = new[] { "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" }.FirstOrDefault( p => Textures.TryGetValue( p, out var guid ) && resolve( guid ) != null );
if ( colorProperty != null )
{
if ( textureScale.TryGetValue( colorProperty, out var scale ) ) Set( "g_vTexCoordScale", $"[{N( scale[0] )} {N( scale[1] )}]" );
if ( textureOffset.TryGetValue( colorProperty, out var offset ) ) Set( "g_vTexCoordOffset", $"[{N( offset[0] )} {N( offset[1] )}]" );
}
Set( "TextureNormal", Texture( resolve, "_BumpMap", "_Normal", "_NormalMap" ) ?? "materials/default/default_normal.tga" );
Set( "TextureAmbientOcclusion", channelImages.GetValueOrDefault( "ao" ) ?? Texture( resolve, "_OcclusionMap", "_Occlusion" ) ?? "materials/default/default_ao.tga" );
Set( "TextureRoughness", channelImages.GetValueOrDefault( "rough" ) ?? "materials/default/default_rough.tga" );
var roughness = 1 - Math.Clamp( Number( "_Smoothness", Number( "_Glossiness", 0.5 ) ), 0, 1 );
Set( "g_flRoughnessScaleFactor", N( channelImages.ContainsKey( "rough" ) ? 1 : roughness ) );
if ( channelImages.TryGetValue( "metal", out var metal ) )
{
text.AppendLine( "\tF_METALNESS_TEXTURE 1" );
Set( "TextureMetalness", metal );
}
Set( "g_flMetalness", N( Math.Clamp( Number( "_Metallic", 0 ), 0, 1 ) ) );
if ( Colors.TryGetValue( "_BaseColor", out var color ) || colorProperty == "_Diffuse" && Colors.TryGetValue( "_MainColor", out color ) || Colors.TryGetValue( "_Color", out color ) )
Set( "g_vColorTint", $"[{N( color[0] )} {N( color[1] )} {N( color[2] )} {N( color[3] )}]" );
if ( channelImages.TryGetValue( "opacity", out var opacity ) ) Set( "TextureTranslucency", opacity );
if ( AlphaTest )
{
text.AppendLine( "\tF_ALPHA_TEST 1" );
Set( "g_flAlphaTestReference", N( Number( "_Cutoff", 0.5 ) ) );
}
if ( shaderBackfaces || Number( "_Cull", 2 ) == 0 ) text.AppendLine( "\tF_RENDER_BACKFACES 1" );
if ( Translucent ) text.AppendLine( "\tF_TRANSLUCENT 1" );
var emission = Texture( resolve, "_EmissionMap", "_EmissiveColorMap" );
if ( emission != null )
{
text.AppendLine( "\tF_SELF_ILLUM 1" );
Set( "TextureSelfIllumMask", emission );
}
return text.AppendLine( "}" ).ToString();
}
public string ToTmat( Func<string, string> resolve )
{
// TerrainMaterial image fields are source image paths, not .vmat references.
var values = new Dictionary<string, object>
{
["__version"] = 1,
["AlbedoImage"] = "materials/default/default_color.tga",
["NormalImage"] = "materials/default/default_normal.tga",
["RoughnessImage"] = "materials/default/default_rough.tga",
["AOImage"] = "materials/default/default_ao.tga",
["HeightImage"] = "materials/default/default_height.tga"
};
void Set( string key, params string[] names )
{
var value = Texture( resolve, names );
if ( value != null ) values[key] = value;
}
Set( "AlbedoImage", "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" );
Set( "NormalImage", "_BumpMap", "_Normal", "_NormalMap" );
Set( "AOImage", "_OcclusionMap", "_Occlusion" );
Set( "HeightImage", "_ParallaxMap", "_HeightMap" );
foreach ( var (channel, field) in new[] { ("rough", "RoughnessImage"), ("ao", "AOImage"), ("height", "HeightImage") } )
if ( channelImages.TryGetValue( channel, out var image ) ) values[field] = image;
return JsonSerializer.Serialize( values, new JsonSerializerOptions { WriteIndented = true } );
}
}