Editor helper that resolves which materials to assign to FBX model slots using the model .meta importer settings, applying Unity semantics for embedded vs external materials and legacy material name/search rules.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace ImportUnityPackage;
/// <summary>Resolves FBX slot materials from the model's own ModelImporter settings, as Unity does before any prefab overrides.</summary>
internal static class UnityModelMaterials
{
internal const string ExternalObject = "external-object";
internal const string MaterialSearch = "unity-material-search";
internal static void Apply( UnityArchive archive )
{
var materials = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material && a.Path.EndsWith( ".mat", StringComparison.OrdinalIgnoreCase ) ).ToArray();
var guids = materials.Select( a => a.Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );
var byPath = materials.ToDictionary( a => a.Path, StringComparer.OrdinalIgnoreCase );
var byFile = materials.GroupBy( a => Path.GetFileName( a.Path ), StringComparer.OrdinalIgnoreCase ).ToDictionary( g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase );
foreach ( var model in archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ) )
{
// A .meta that cannot be read gives the model no import settings; the package still loads.
YamlNode importer;
try { importer = model.MetadataDocument?["ModelImporter"]; }
catch ( InvalidDataException ex ) { archive.Skipped.Add( $"{model.Path}.meta: not read ({ex.Message})" ); continue; }
if ( importer == null ) continue;
var info = model.ModelInfo;
var settings = importer["materials"];
int Setting( string key, int fallback ) => (int?)settings?[key]?.Integer ?? fallback;
// materialImportMode (Unity 2019.3+) replaced importMaterials. With import off, Unity renders its default
// material until a prefab assigns one, so no model-level material exists.
if ( ((int?)(settings?["materialImportMode"] ?? settings?["importMaterials"])?.Integer ?? 1) == 0 )
{
info.MaterialImport = "none";
continue;
}
// Explicit per-slot remaps, written by Unity 2017.2+ when materials are extracted or remapped.
foreach ( var entry in importer["externalObjects"]?.Items ?? Array.Empty<YamlNode>() )
{
var type = entry["first"]?["type"]?.Value ?? "";
if ( type != "Material" && !type.EndsWith( ":Material", StringComparison.Ordinal ) ) continue;
var reference = entry["second"]?.Reference ?? default;
if ( entry["first"]?["name"]?.Value is string slot && slot.Length > 0 && reference.HasGuid && guids.Contains( reference.Guid ) )
info.ImporterMaterials[slot] = new( reference.Guid, ExternalObject );
}
// materialLocation 1 embeds materials in the model; only externalObjects can replace them.
if ( Setting( "materialLocation", 0 ) != 0 )
{
info.MaterialImport = "embedded";
continue;
}
// Legacy external materials: Unity names a .mat from the slot, then looks for it in Materials folders.
var naming = Setting( "materialName", 0 );
var search = Setting( "materialSearch", 1 );
info.MaterialImport = $"external (materialName {naming}, materialSearch {search})";
var modelName = Path.GetFileNameWithoutExtension( model.Path );
foreach ( var slot in info.Materials.Where( s => !info.ImporterMaterials.ContainsKey( s ) ) )
{
var texture = info.MaterialAlbedoFiles.GetValueOrDefault( slot ) is string file ? Path.GetFileNameWithoutExtension( file ) : null;
// ModelImporterMaterialName: 0 texture name, 1 material name, 2 model-material, 3 texture or model-material.
var name = naming switch { 1 => slot, 2 => modelName + "-" + slot, 3 => texture ?? modelName + "-" + slot, _ => texture ?? slot };
if ( Find( model.Path, name, search, byPath, byFile ) is UnityAsset found ) info.ImporterMaterials[slot] = new( found.Guid, MaterialSearch );
}
}
}
/// <summary>ModelImporterMaterialSearch: 0 the model's Materials folder, 1 Materials folders up to Assets, 2 then anywhere (if unique).</summary>
static UnityAsset Find( string modelPath, string name, int search, Dictionary<string, UnityAsset> byPath, Dictionary<string, UnityAsset[]> byFile )
{
// Unity replaces characters that are invalid in file names; try the plain name first.
var names = new[] { name, string.Concat( name.Select( c => "/\\:.@\"<>*|?".Contains( c ) ? '_' : c ) ) }.Distinct().ToArray();
var directory = Path.GetDirectoryName( modelPath )?.Replace( '\\', '/' ) ?? "";
while ( true )
{
foreach ( var candidate in names )
if ( byPath.TryGetValue( (directory.Length > 0 ? directory + "/" : "") + "Materials/" + candidate + ".mat", out var found ) ) return found;
if ( search == 0 || directory.Length == 0 ) break;
directory = Path.GetDirectoryName( directory )?.Replace( '\\', '/' ) ?? "";
}
if ( search < 2 ) return null;
var matches = names.SelectMany( n => byFile.GetValueOrDefault( n + ".mat" ) ?? Array.Empty<UnityAsset>() ).Distinct().ToArray();
return matches.Length == 1 ? matches[0] : null;
}
}