本記事は、CyberAgent Advent Calendar 2022 11日目の記事です。

はじめに

初めまして。株式会社Craft Eggの3D開発室に所属しておりますUnityエンジニアの江口大喜と申します。

3D開発室はCraft Eggの中で5月に発足したセクションでエンジニアとクリエイターで構成されています。

私の業務としてはグラフィックスエンジニアとして描画周りであったり、TA(テクニカルアーティスト)のような業務としてクリエイターさんの作業効率化を行うためのツール開発などをおこなっております。

今回お話しさせていただくのは最後にあげたツール開発に関連して、3Dアセットのフローを整えた話になります。

アセットフローでの当時の課題点

ではまず初めに、アセットをUnityにインポートしたとき、クリエイターさんの必要な手作業は何があるかを洗い出しました。今回は例として背景のようなアセットを挙げてみます。

fbx(中にMaterialとModel)とTextureをMayaから出力していただいた際、上の図で右側に示したような手作業が発生することが予想されました。特に絵作りまでの1から7までの手作業がクリエイターさんの工数を圧迫する要因になるかと思います。

クリエイターさんがクリエイティブ・演出にこだわっていただくためにこのような手作業の部分は自動化を行なっていきたい、という話になり今回アセットフローの自動化を実装いたしました。

最終アセットフロー

細かい実装に入る前にまずは最終的にどのような効率化となったかを述べさせていただきます。

今回自動化によって以下のようなアセットフローになりました。

導入前には多かった手作業ですが、導入後には「アセットを所定のディレクトリに格納する」、ということを行えばリリース用のアセットが作られ、絵作りが行えるようになっています。

Unityの設定等を知らない方でも安心して作業をすることができるようになったと思います。

所定のディレクトリに格納する、というところもMaya上のツールを制作し、さらにクリエイターさんの作業を減らしていきたいと思っておりますが、その部分は別のエンジニアさんが行うため今回は割愛させていただきます。

意識しておきたいポイント

例えば今回のようにfbx(ModelとTexture)、Textureを別にインポートするとき「どちらから先にインポート処理が走るかわからない」ということを意識して実装を行いました。

クリエイターさんの方でfbxとTextureを複数選択してUnityにドラッグアンドドロップするかもしれませんし、fbxを先にTextureを後にインポートする。またはその逆も考えられます。

〇〇側からインポートする。というレギュレーションを決めることも考えられますが、ヒューマンエラーやその後のツールのことを考えればどちら側からインポートされてもしっかりとアセットの準備(インポート処理)が走ることが重要だと考えました。

今回の背景のモデルでどのように実装したのかを図で説明します。

最終的な処理フローは以下のようになります。

ではまず、fbxを先にインポートされてTextureを後にインポート処理が走ったとします。

この場合、まだTextureがインポートされていないのでMaterialの処理で「Textureを探して参照つけ」というところが行えません。下の図で言うと灰色のハイライトで表したところが処理されません。

なのでこの時点ではMaterialとTextureの参照付けが行えていないことになります。

ただ、Textureが後でインポートされると下図で青枠で括った場所が処理されます。

これによって先ほどfbxのインポート時には走らなかった「Materialに対してのTexture参照つけ」がTextureインポート時に処理され、全体としてしっかりとアセットが整います。

 

では逆にTextureのインポートが先でfbxのインポートが後の場合です。

この場合Textureの処理としてMaterialを探して参照つけを行いたいのですがfbxがまだ(Materialが抜かれていない)なので処理が行えません。

ただ、次のfbxのインポートの際、Materialの処理として下図の青色の枠で囲った処理が走り、今度はTextureがUnity内にあるのでMaterialとTextureの参照つけが行えて一連のアセットフローが整います。

このように、どの順番からインポートされても一連のアセットインポート処理がしっかりと行えるような実装というのは背景モデルに関わらず、意識して実装を行いました。

アセットフローのための実装

それでは細かい実装に入っていきます。

インポート処理

まずfbxがインポートされた時に処理を走らせます。以下のようにAssetPostprocessorを継承したクラスではアセットのインポート時に処理を入れることができます。

public sealed class TestAssetImporter : AssetPostprocessor
{
    /// <summary>
    /// あらゆる種類の任意の数のアセットがインポートが完了したときに呼ばれる処理
    /// </summary>
    static void OnPostprocessAllAssets(
        string[] importedAssets,
        string[] deletedAssets,
        string[] movedAssets,
        string[] movedFromPath)
    {
        
    }
    
    /// <summary>
    /// モデルアセットのインポート前処理
    /// </summary>
    private void OnPreprocessModel()
    {
        
    }
    
    /// <summary>
    /// テクスチャアセットのインポート前処理
    /// </summary>
    private void OnPreprocessTexture()
    {
        
    }
}

ここで注意しておきたいことは、後の処理でAssetDatabase.LoadAtPath(path)のようなAssetDatabaseとアセットパスを使った処理が入るかどうかです。

Modelがインポートされた時、 OnPostprocessAllAssets()でもOnPreprocessModel()でも処理が走るのですが、OnPreprocessModelではassetImporter.assetPathは入っているものの、AssetDatabase.LoadAssetAtPath(path)の結果はnullになってしまいます。

そのため、アセットパスを扱うAssetDatabase.LoadAtPath(path)のようなものを使用するときはOnPostprocessAllAssetsに処理を記述しました。

fbxからMaterialを抽出する

fbxの中にMaterialの参照をMayaで入れておき、Unityで手作業でMaterialを抽出するときはImportSettingでMaterials欄にある以下の赤枠で示す「Extract Materials」のボタンを押していました。

このボタンを押すとfbxの中にあるMaterialを抽出できるのですが、アセットの数が多くなるとクリエイターさんの作業コストも上がってしまいます。この問題を解決するために自動化を試みました。

ただ、このUnityEditorのExtractMaterialsのボタンを押した時に呼ばれるExtractMaterialsというAPIはinternalなので、デメリットは多い方法だとは思いますが同じような処理を独自実装いたしました。

そのステップが以下のようなものです。

ステップ1

IEnumerable<Object> materialsInFbx = AssetDatabase.LoadAllAssetsAtPath(assetPath)
    .Where(x => x.GetType() == typeof(Material));

まずfbxの中にあるMaterialをLoadします。

ステップ2

/// <summary>
/// FbxからMaterialを抜く処理
/// </summary>
private static string extractMaterials(Object material)
{
    string path = "ここにMaterialを保存するパスを書く"
    return AssetDatabase.ExtractAsset(material, path);
}

fbxの中にあるMaterialを抜くためにAssetDatabase.ExtractAssetを行います。

ステップ3

/// <summary>
/// FBXから抜かれたMaterialを作る処理
/// </summary>
private static void createMaterial(string assetPath)
{
    AssetDatabase.WriteImportSettingsIfDirty(assetPath);
    AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
}

最後に抜いたMaterialを保存します。また、この処理を実行する前にMaterialの名前が被っているものがないようにHaseSet<string>でアセットパスを格納してからこのcreateMaterialを実行しました。

ここまでのステップを踏めばfbxからMaterialを抜いて保存することができました。

fbxからPrefabを生成する

Prefabを生成するのは以下のようにPrefabUtilityを使用して実装しました。

/// <summary>
/// FBXからPrefabを作成する
/// </summary>
private static void createPrefabFromFbx(string assetPath)
{
    GameObject instance = PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(assetPath)) as GameObject;

    string savePath = "ここのPrefabを保存するpathを書いてください"
    
    PrefabUtility.SaveAsPrefabAsset(instance, savePath);

    GameObject.DestroyImmediate(instance);
}

Prefabのインポート時にMaterialの参照を付け直す

Prefabのインポート時にMaterialのパスからMaterialの参照を付け直す処理を入れます。

/// <summary>
/// Prefabのマテリアル設定
/// </summary>
private static void initializeBackgroundPrefab(string assetPath)
{
    GameObject instance = PrefabUtility.LoadPrefabContents(assetPath);

    string materialPath1 = "マテリアル1のパスを入れる";
    string materialPath2 = "マテリアル2のパスを入れる";
    string materialPath3 = "マテリアル3のパスを入れる";

    // attachMaterialのメソッドは引数にマテリアルとGameObjectを渡されて参照を付け直す実処理
    attachMaterial(
        instance,
        AssetDatabase.LoadAssetAtPath<Material>(materialPath1),
        AssetDatabase.LoadAssetAtPath<Material>(materialPath2),
        AssetDatabase.LoadAssetAtPath<Material>(materialPath3));

    PrefabUtility.SaveAsPrefabAsset(instance, assetPath);
    PrefabUtility.UnloadPrefabContents(instance);
}

また、マテリアルの参照を入れ直す実処理は以下のような処理が入っています。

if (assetObject.TryGetComponent<Renderer>(out Renderer renderer))
{
    renderer.sharedMaterial = assetObject.name switch
    {
        materialName1 => material1,
        string assetName when assetName.StartsWith(material2Prefix) => material2,
        _ => material3
    };
}

これはRendererがあるかを検知し、sharedMaterialにMaterialの参照を入れ直すものになっています。

上は命名規則から、名前の一致や接頭語に特定の文字が入っているかどうか、またはそれ以外か。で、該当のMaterialを付け直しています。

Materialの設定を行う

private static void initializeBackgroundMaterials(string assetPath)
{
    string texturePath = "ここにテクスチャのパスを入れる";
    Texture texture = AssetDatabase.LoadAssetAtPath<Texture>(texturePath);

    string shaderName = assetPath.Contains(Background1Suffix)
        ? ShaderName1
        : ShaderName2

        initializeMaterial(
        AssetDatabase.LoadAssetAtPath<Material>(assetPath), 
        Shader.Find(shaderName),
        texture);
}

上の処理は命名規則から該当のテクスチャやShaderをLoadして参照をつける処理になります。その実処理は以下です。

/// <summary>
/// 指定したMaterialにShaderを設定する
/// </summary>
private static void initializeMaterial(Material material, Shader shader, Texture texture)
{
    if (material == null)
    {
        return;
    }

    material.shader = shader;

    if (texture != null)
    {
        material.SetTexture(BackgroundMainTexPropertyID, texture);
    }
}

Textureのインポート処理

次にTextureがインポートされたときの処理は以下のようにしました。Materialを探して参照をつけるやり方もありましたが、Materialのインポート側にTexture、Shaderの参照をつける処理が書かれているので単純にMaterialをReImportするという方針をとりました。

private static void reimportMaterial()
{
    string materialPath = "ここにMaterialのパスを入れます"
    AssetDatabase.ImportAsset(materialPath, ImportAssetOptions.ForceUpdate);
}

インポート処理全体を見て

上記の実装で上図で示しているようなフローを行えました。最後にこれらを呼べるように以下のようなメソッドをOnPostprocessAllAssetsから呼ばれるようにしました。

public static void ImportBackground(string assetPath)
{
    string pathExtension = Path.GetExtension(assetPath);
    string directoryPath = "ディレクトリ名を入れる"

    // メソッドの引数にディレクトリのパスが入っているのは、実際は中身をもっとカスタマイズしているからです。
    // ディレクトリ名を渡して、命名規則等から新しく作るアセットや保存するアセットのパスを内部で作っています。
    switch (pathExtension)
    {
        case FbxExtension:
            createPrefabFromFbx(assetPath, directoryPath);
            createMaterialsFromFbx(assetPath, directoryPath);
            break;
        
        case PrefabExtension:
            initializeBackgroundPrefab(assetPath, directoryPath);
            break;
        
        case MaterialExtension:
            initializeBackgroundMaterials(assetPath, directoryPath);
            break;
        
        case PngExtension:
            initializeBackgroundTextures(assetPath, directoryPath);
            break;
        
        default:
            break;
    }

最後に

これで1つのアセットフローが完了しました。

クリエイターさんがクリエイティブに集中していただく環境整備はゲーム全体の完成度にも関わってくると思います。

現在、背景アセットだけでなくキャラアニメーション、カメラモーションなども自動化によるアセットフロー効率化をおこなっています。今後はTimelineの自動作成なども実装していき、クリエイターさんの手作業をさらになくしていく環境整備に取り組んでいきたいと思います。

この記事が開発の手助けになれば幸いです。ありがとうございました。