インポートしたモデルのマテリアルをまとめて外に出す [Unityエディター拡張]

# はじめに
Unityでは、インポートした3Dモデルをインポートした時にマテリアルはモデルの中に格納されている状態で表示されます。
![スクリーンショット (218).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/69ecffc9-134e-e5a7-b4fd-90cc8b460654.png)

モデルに格納されている状態のマテリアルはリードオンリーであり、このままではパラメータをいじることができません。
![スクリーンショット (217).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/7233b8ba-0fb9-dbc9-1d36-b1de05514cf7.png)

このようなインポートされたマテリアルを調整したい場合は、インポート設定の`Extract Materials...`ボタンを押下してマテリアルを抽出する必要があります。
![スクリーンショット (216).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/7def0cc5-6b8d-b48c-110b-1869d26c270d.png)

モデルが少数であれば特にこの作業も問題ないのですが、モデルが大量に存在するようなプロジェクトだと、いちいち個別のモデルに対してボタンを押下→出力先のフォルダを作成・指定→エクスポートのような順で作業するのは面倒です。
この記事では以下のようなUIでこの作業をまとめて行えるエディター拡張の実装を紹介します。
![スクリーンショット 2022-12-04 234330.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/4ed80638-47e4-b9aa-e2a4-87a5f50db078.png)

# 実装の準備
まず、抽出用のウィンドウを作る前に、エディター拡張内で用いている、`Path`属性の実装を示します。これはProjectタブからファイルやフォルダをドラッグアンドドロップすることでパスを指定することができる領域を描画する属性です。
kan_kikuchiさんの以下のブログ記事を参考にさせていただきました。

https://kan-kikuchi.hatenablog.com/entry/PathAttribute_1

```CS:PathAttribute.cs
using UnityEngine;

/// <summary>
/// ドラッグアンドドロップでパスを設定するための属性
/// 参考：https://kan-kikuchi.hatenablog.com/entry/PathAttribute_1
/// </summary>
public class PathAttribute : PropertyAttribute
{
}
```

属性を実際に描画する`PropertyDrawer`の実装を示します。
```CS:PathAttributeDrawer.cs
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

/// <summary>
/// PathAttributeが付加されたフィールドの描画
/// 参考：https://kan-kikuchi.hatenablog.com/entry/PathAttribute_1
/// </summary>
[CustomPropertyDrawer(typeof(PathAttribute))]
public class PathAttributeDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        //string以外に設定されている場合はスルー
        if (property.propertyType != SerializedPropertyType.String)
        {
            return;
        }

        //D&D出来るGUIを作成、 ドロップされたオブジェクトのリストを取得
        var dropObjects = CreateDragAndDropGUI(position);

        //オブジェクトがドロップされたらパスを設定
        if (dropObjects.Count > 0)
        {
            property.stringValue = AssetDatabase.GetAssetPath(dropObjects[0]);
        }

        //現在設定されているパスを表示
        GUI.Label(position, property.displayName + " : " + property.stringValue);
    }

    //D&DのGUIを作成
    private static List<Object> CreateDragAndDropGUI(Rect rect)
    {
        var dropObjects = new List<Object>();

        //D&D出来る場所を描画
        GUI.Box(rect, "");

        //マウスの位置がD&Dの範囲になければスルー
        if (!rect.Contains(Event.current.mousePosition))
        {
            return dropObjects;
        }

        //現在のイベントを取得
        var eventType = Event.current.type;

        //ドラッグ＆ドロップで操作が 更新されたとき or 実行したとき
        if (eventType is EventType.DragUpdated or EventType.DragPerform)
        {
            //カーソルに+のアイコンを表示
            DragAndDrop.visualMode = DragAndDropVisualMode.Copy;

            //ドロップされたオブジェクトをリストに登録
            if (eventType == EventType.DragPerform)
            {
                dropObjects = new List<Object>(DragAndDrop.objectReferences);

                //ドラッグを受け付ける
                DragAndDrop.AcceptDrag();
            }

            //イベントを使用済みにする
            Event.current.Use();
        }

        return dropObjects;
    }
}
#endif
```
以上のスクリプトをプロジェクトの任意のフォルダに配置して下さい。

# Material Extractorの実装
実際に抽出を行うエディタウィンドウの実装を示します。
```CS:MaterialExtractor.cs
#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;

public class MaterialExtractor : EditorWindow
{
    /// <summary>
    /// 抽出する対象ディレクトリのパス。
    /// このディレクトリ内の全てのモデルのマテリアルを抽出する。
    /// </summary>
    [Path] public string TargetPath = "Assets/Models/";

    /// <summary>
    /// マテリアル抽出先ディレクトリのサフィックス
    /// </summary>
    public string Suffix = "materials";

    /// <summary>
    /// 子ディレクトリも抽出の対象にするか
    /// </summary>
    public bool IncludeChildDirectories = true;

    [MenuItem("Window/MaterialExtractor")]
    public static MaterialExtractor ShowWindow()
    {
        return GetWindow<MaterialExtractor>(nameof(MaterialExtractor));
    }

    private void OnGUI()
    {
        // 自身のSerializedObjectを取得
        var so = new SerializedObject(this);
        so.Update();
        EditorGUILayout.PropertyField(so.FindProperty(nameof(TargetPath)), true);
        EditorGUILayout.PropertyField(so.FindProperty(nameof(Suffix)), true);
        EditorGUILayout.PropertyField(so.FindProperty(nameof(IncludeChildDirectories)), true);
        // SerializedObjectに変更を適用
        so.ApplyModifiedProperties();

        if (GUILayout.Button("マテリアルを抽出"))
        {
            var models = GetModels(TargetPath);
            models.ForEach(ExtractMaterials);
        }

        if (GUILayout.Button("抽出されたマテリアルをモデルに復元"))
        {
            var models = GetModels(TargetPath);
            models.ForEach(RestoreMaterials);
        }
    }

    private void ExtractMaterials(string modelPath)
    {
        var materials =
            AssetDatabase.LoadAllAssetsAtPath(modelPath).Where(x => x.GetType() == typeof(Material)).ToArray();

        Debug.Log($"{modelPath} has {materials.Length} materials.");
        if (materials.Length == 0) return;

        var assetsToReload = new HashSet<string> { modelPath };

        // 出力先のパス(Assets以下)
        var destinationPath =
            $"{Path.GetDirectoryName(modelPath)}/{Path.GetFileNameWithoutExtension(modelPath)}_{Suffix}/";
        Directory.CreateDirectory(destinationPath);

        foreach (var material in materials)
        {
            var newAssetPath = destinationPath + material.name + ".mat";
            newAssetPath = AssetDatabase.GenerateUniqueAssetPath(newAssetPath);
            assetsToReload.Add(newAssetPath);

            var error = AssetDatabase.ExtractAsset(material, newAssetPath);
            if (!string.IsNullOrEmpty(error))
            {
                Debug.Log($"[MaterialExtractor] error: {error}");
            }
        }

        foreach (var path in assetsToReload)
        {
            AssetDatabase.WriteImportSettingsIfDirty(path);
            AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);
        }
    }

    private void RestoreMaterials(string modelPath)
    {
        var materialPath =
            $"{Path.GetDirectoryName(modelPath)}/{Path.GetFileNameWithoutExtension(modelPath)}_{Suffix}";
        if (!Directory.Exists(materialPath)) return;

        File.Delete(materialPath + ".meta");
        Directory.Delete(materialPath, true);

        var externalObjectKeys = AssetImporter.GetAtPath(modelPath).GetExternalObjectMap().Keys;
        foreach (var key in externalObjectKeys)
        {
            AssetImporter.GetAtPath(modelPath).RemoveRemap(key);
        }

        AssetDatabase.WriteImportSettingsIfDirty(modelPath);
        AssetDatabase.ImportAsset(modelPath, ImportAssetOptions.ForceUpdate);
        AssetDatabase.Refresh();
    }

    private List<string> GetModels(string path)
    {
        var models = new List<string>();

        foreach (var fileInfo in new DirectoryInfo(path).GetFiles())
        {
            var importedObject = AssetDatabase.LoadAssetAtPath($"{path}/{fileInfo.Name}", typeof(Object));
            if (importedObject == null) continue;
            var prefabType = PrefabUtility.GetPrefabAssetType(importedObject);

            if (prefabType == PrefabAssetType.Model)
            {
                models.Add($"{path}/{fileInfo.Name}");
            }
        }

        if (!IncludeChildDirectories) return models;

        foreach (var directoryInfo in new DirectoryInfo(path).GetDirectories())
        {
            models.AddRange(GetModels($"{path}/{directoryInfo.Name}"));
        }

        return models;
    }
}
#endif
```
このスクリプトもプロジェクトの任意のフォルダに配置して下さい。

# 使い方
`Window/MaterialExtractor`から起動します。
![スクリーンショット (220).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/01b16616-1e7a-6f0b-1b83-f16f020d93f2.png)
`Target Path`の上にフォルダをドラッグアンドドロップすることで、抽出対象のフォルダを指定することができます。
![Inkedスクリーンショット 2022-12-04 234330 - コピー.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/b43bcd8e-f7dc-5e55-19f9-8e9f528d6df7.jpeg)
`マテリアルを抽出`ボタンを押すと抽出が実行されます。
モデルと同階層の`モデル名_materials`フォルダの中にマテリアルが抽出されます。
![スクリーンショット (222).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/82054f27-40b7-1276-504b-d9156c0262fd.png)
`抽出されたマテリアルをモデルに復元`を押すとモデルの中にマテリアルが再格納され、外に抽出されていたマテリアルは削除されます。
![スクリーンショット (223).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2987563/d4334722-040b-58cc-0576-e099e460c7d2.png)

# さいごに
いかがだったでしょうか。
エディター拡張は一回作っておくと複数プロジェクトで使い回せるので、ぜひ使ってみてください。