Unityからexeを実行して、標準出力を読み込む

表題の通りです。

using System.Diagnostics;
using UnityEngine;

public class Sample : MonoBehaviour
{
    private Process proc;

    public void Handle()
    {
        this.proc = new Process();
        this.proc.StartInfo.FileName = "exeのpath";
        this.proc.StartInfo.Arguments = "exeの引数";
        this.proc.StartInfo.WindowStyle = ProcessWindowStyle.Normal;
        this.proc.OutputDataReceived += this.OnRecieved;
        this.proc.StartInfo.UseShellExecute = false;
        this.proc.StartInfo.RedirectStandardError = true;
        this.proc.StartInfo.RedirectStandardOutput = true;

        this.proc.Start();
        this.proc.BeginOutputReadLine();
    }

    private void OnRecieved(object sender, DataReceivedEventArgs e)
    {
        UnityEngine.Debug.Log(e.Data);
    }

    private void OnApplicationQuit()
    {
        if (!this.proc.HasExited)
        {
            this.proc.CloseMainWindow();
        }

        this.proc.Close();
        this.proc = null;
    }
}

OnRecievedはメインスレッド以外で呼ばれるので、SynchronizationContext等を組み合わせることが多い印象です。

Trilibを使った話

Trilibはランタイム中にFBX等の3Dデータを読み込むことができるアセットです。

assetstore.unity.com

色々ありTrilibを使う機会があったので、メモを記します。



UniTaskとの連携

UniTaskCompletionSourceを使って、連携をしました。

using System.Threading;
using Cysharp.Threading.Tasks;
using TriLibCore;
using UnityEngine;
using System;

public class FBX : IDisposable
{
    public GameObject Instance;

    private FBX(GameObject instance)
    {
        this.Instance = instance;

        // 必要なコンポーネントを参照したり
    }

    /// <summary>
    /// 指定されたPathのFBXをロードする
    /// </summary>
    /// <param name="path">fbxのpath</param>
    /// <param name="cancellationToken">cancellation</param>
    /// <returns>FBXのUniTaskCompletionSourceを返す</returns>
    public static UniTask<FBX> Load(string path, CancellationToken cancellationToken)
    {
        UniTaskCompletionSource<FBX> source = new UniTaskCompletionSource<FBX>();

        AssetLoader.LoadModelFromFile(
            path,
            null,
            (context) => source.TrySetResult(new FBX(context.RootGameObject)),
            null,
            null,
            null,
            null);

        return source.Task;
    }

    /// <summary>
    /// ロードしたら、FBXクラスのインスタンスを生成して、TrySetResultで返す
    /// cancelが発生していたら、FBXクラスのインスタンスをDisposeしてTrySetCanceledでStatusを更新する
    /// </summary>
    /// <param name="context">ロードした内容</param>
    /// <param name="source">fbxのUniTaskCompletionSource</param>
    /// <param name="cancellationToken">cancellation</param>
    private static void OnLoaded(AssetLoaderContext context, UniTaskCompletionSource<FBX> source, CancellationToken cancellationToken)
    {
        FBX fbx = new FBX(context.RootGameObject);

        if (cancellationToken.IsCancellationRequested)
        {
            fbx.Dispose();
            source.TrySetCanceled(cancellationToken);
            return;
        }

        source.TrySetResult(fbx);
    }

    public void Dispose()
    {
        if (this.Instance != null)
        {
            GameObject.Destroy(this.Instance);
        }
    }
}

FBX fbx = await FBX.Load("pathを指定",new CancellationTokenSource().Token);

Humanoid FBXを読み込む

HumanoidAvatarMapperをScriptableObjectとして生成して、対象となる3Dデータのスケルトン構造に合わせてファイルを編集する必要があります。 だいぶしんどいので、サンプルとして同封されているMixamo用のHumanoidAvatarMapperをDuplicateして編集することをお勧めします。

作成したHumanoidAvatarMapperをAssetLoaderOptionsに設定して、LoadModelFromFileの引数にして実行すると、Humanoid設定でFBXがロードされます。
またHumanoidAvatarMapperが読み込むHumanoid FBXに対して正常に作成できていない場合、例外を吐きます。

using TriLibCore;
using TriLibCore.Mappers;

[SerializeField]
private HumanoidAvatarMapper mapper;

AssetLoaderOptions assetLoaderOptions = AssetLoader.CreateDefaultLoaderOptions();

assetLoaderOptions.AnimationType = AnimationType.Humanoid;
assetLoaderOptions.HumanoidAvatarMapper = this.mapper;

AssetLoader.LoadModelFromFile("fbxのpath", null, (context) => {}, null, null, null, assetLoaderOptions);

Humanoid アニメーションが動いてるっぽくする

TrilibはHumnoaidアニメーションを読み込むことができません。ただ、AのFBXに含まれるアニメーションを、BのFBXにリターゲッティングする必要があったので、以下のような力技をしました。

Animationコンポーネントは、AssetLoaderOptionsを変更していなければ、読み込んだGameObjectからGetComponentすることができます。またHumanPoseHandlerを生成するために必要なAvatarは、 同じくGameObjectからAnimatorをGetComponentすることで参照することができます。
後は、Legacyアニメーションを実行した後に、FBX AのHumanPoseHandlerからHumanPoseを生成し、FBX BにSetHumanPoseからHumanPoseを流せばリターゲッティングが実現する。と思っていました。

一部のアニメーションが期待した通りに流れない

上記の方法だと、一部のアニメーションが正常に実行されない(なぜか回転値がおかしい)ことがありました。正確な原因は今もわかっていないのですが、Legacyアニメーションとして実行するところまでは正常、SetHumanPoseからリターゲッティングする際におかしくなっていました(TrilibのHumanoid読み込みがうまくいっていない?)。悩んだ結果、更なる力技で解決しました。

とりあえず、Legacyアニメーションは正常に実行できることが分かっていたので、リターゲッティングの元となるHumnaoid(図でいうとFBX A)には、Legacyアニメーションから更新した同じFBXのボーン座標をスクリプトから代入することで同期し、リターゲッティングを処理したいHumnaoid(図でいうとFBX B)には、従来通りHumanPoseHandlerから同期させました。

Muscle値を調整する

HumanPoseからmusclesを参照し、調整したいindex番目の要素に値を代入、最後にSetHumanPoseから調整したHumanPoseを反映すれば、実現します。

float strength = 0.5f;
HumanPoseHandler a;
HumanPoseHandler b;

HumanPose pose_a = defalut;
a.GetHumanPose(ref pose_a);

pose_a.muscles[index] += strength

b.SetHumanPose(ref pose_a);

どの身体部位がindexと対応しているかは、まとめてくれている方がいたので、参考にさせて頂きました。

gist.github.com

読み込んだMaterialにアクセスする

LoadModelFromFileのコールバックの引数から受け取れるAssetLoaderContextからLoadedMaterialsで参照できます。 LoadedMaterialsはConcurrentDictionary型で、ValuesでMaterial群を参照できます。

// contextはLoadModelFromFileのコールバックの引数として受け取ったAssetLoaderContext
ConcurrentDictionary<IMaterial, Material> loadedMaterials = context.LoadedMaterials;
Material[] materials = loadedMaterials.Values;

一部FBXが読み込めない時がある

FBXは読み込めない条件がいくつかある印象でした。
こちらで発生した特殊な事例としては、特定のバージョンのC4DからエクスポートしたFBXを読み込もうとした場合、非対応のFBX SDKのバージョンになるらしく、以下のようなメッセージを吐きました。

If your FBX file has been generated with the FBX SDK version 6 or previously, TriLib won't be able to load it.

Known Issues/Limitations - TriLib

ricardoreis.net

使ってみた感想

3Dデータをインポートすること自体は簡単な記述で実装することができる印象でした。また読み込んだデータを安全に破棄するための機能や、データのpathを参照するためのファイルブラウザ機能なども同封されており、良くできたアセットだなと思いました。
ただし、UnityEditorとは異なる仕組みで動作するImporterなため、「手動でUnityにインポートできたから、Trilibでも大丈夫だろう」と油断していると、何かしらが正常に動作しないみたいなことが起きて、途方にくれることが結構ありました。個人的には、UnityEditorに組み込まれているImporter機能をAPIとして叩けるようにしてくれないかなーと思いました。

ホロライブが大好きでARコンテンツが作りたくなった

僕はホロライブが大好きです。 カバー様は2次創作に寛容で、MMDを公開しています。

www.mmd.hololive.tv

素敵な3Dモデルを活用してARコンテンツを作りたいと思いました。 作りました。(アプリは公開していません。)

youtu.be

位置ベースのARコンテンツで、登録した特定の場所に移動するとライバー達が現れます。

技術的な話

3Dモデルとアニメーション

ライバーの3Dモデルは、公式のMMDをお借りしました。

www.mmd.hololive.tv

アニメーションは、以下の公開されているMMDアニメーションをお借りしました。

Paruparu様

bowlroll.net

KEITEL様

bowlroll.net

bowlroll.net

bowlroll.net

とりわかめ様

bowlroll.net

MMDはNora様のMMD4MecanimからFBXに変換してUnity内で使用しました。

stereoarts.jp

AR

Unity+ARFoundation(ARKit)でARを実装しました。 位置合わせ(空間マッピング)はImmersalを使用しました。

演出を実行するロケーションの作成

Immersalの公式アプリ等を使って、ARマーカーとなる3Dデータを生成することができます。

immersal.gitbook.io

生成したマーカーは、Developer Portal上にアップロードされ、ImmersalSDKを使用してSDKJobという形でマーカー情報をロードすることができます(2021年9月時点の手法)。

JobListJobsAsync j = new JobListJobsAsync();

j.token = m_sdk.developerToken;
j.OnResult += (SDKJobsResult results) =>
{
   // resultsからDeveloperPortal上のマーカー情報群が参照できる
};

await j.RunJobAsync();

SDKJobからARマーカーのidを参照することができ、そのidを元にImmersalSDKを用いて、ARマーカー(ARSpace)をロードしました(2021年9月時点の手法)。

JobLoadMapBinaryAsync j = new JobLoadMapBinaryAsync();

// SDKJobからidを参照して、設定
j.id = job.id;

j.OnResult += (SDKMapResult result) =>
{
    // ARSpace.LoadAndInstantiateARMapからARマップのインスタンスを生成できる
};

await j.RunJobAsync();

生成したARSpaceはGameObjectにアタッチされており、その子に追従させたいGameObjectを設定することで、現実空間に合わせた演出を実現することができます。
本コンテンツではギズモオブジェクトをipad上で操作して、追従する子オブジェクトの位置や回転をキャリブレーションできるようにしました。

また、Developer Portalからダウンロードした3Dデータの原点は下部でなく中心あたりに設定されていたため、ARから平面を検出する必要がありました。 ここは、ARFoundationのARPlaneManagerから解決しました。
以上の方法でキャリブレーションした情報をjsonにパースし、ロケーション情報として管理しました。

影の表現

こちらのForumを参考にさせて頂きました。

forum.unity.com

オクルージョンの表現

ARFoundationのAROcclusionManagerを使用しました。

docs.unity3d.com

作った感想

よくあるコンセプトのARですが、大好きなキャラクターが現れるのはうれしいものですね。ただARマーカーを作る姿がシュールすぎて恥ずかしかったです。

DIコンテナを自作してみたかった

Unityで動くDIコンテナを自作してみたかったので、自作しました。

リポジトリを公開していますが、もっと有名なライブラリが存在するし、自分が欲しいと思った機能しか実装していないので、使うメリットはないと思います。 また何か起きても責任を負いません。

github.com

導入方法

UPMから導入することを想定しています。 Package Managerからadd package from git URLで下記のリンクを入力するとimportできます。

https://github.com/satoshishi/NeContainer.git?path=unity-project/Assets/NeCo

使い方

hadashiA様のVContainerの書き方を参考にさせて頂いている部分が多いです。

github.com

Builderの生成

staticクラスから、builderを生成します。

using NeCo;

INeCoBuilder builder = _.Create();

Builderへの登録

builderに依存解決したいクラスを登録します。

// ClassAをSingletonで登録(Containerからインスタンス生成後、共有)
builder.RegistrationAsSingleton<ClassA>();

// ClassAをid付きで登録
builder.RegistrationAsSingleton<ClassA>("A");

// ClassAをTransientで登録(Resolveの度にインタスンス生成)
builder.RegistrationAsTransient<ClassA>();

// ClassAの生成済みインスタンスを登録(登録したインスタンスを共有)
builder.RegistrationAsConstant(instance);

Resolverの生成

Build()からResolverを生成します。

INeCoResolver resolver = builder.Build();

インスタンスの参照

Resolve()から依存解決したインスタンスTが参照することができます。

ClassA instance = resolver.Resolve<ClassA>();

// ClassA 且つ IdがAのインスタンスを参照
ClassA instance = resolver.Resolve<ClassA>("A");

注入先の指定

[inject]を対象の構文に付けることで、Resolve時に依存注入が実行されます。

using NeCo;

// コンストラクタインジェクション
public class ClassB
{
    public ClassA a;
    
    [Inject]
    public ClassB(ClassA a)
    {
        this.a = a;
    }
}

// メソッドインジェクション
public class ClassB
{
    public ClassA a;
    
    [Inject]
    private Injection(ClassA a)
    {
        this.a = a;
    }
}

// プロパティインジェクション
public class ClassB
{   
    [Inject]
    public ClassA A { get; private set; }
    
    // idを指定してinjection(プロパティインジェクション限定)
    [InjectFromId("A")]
    public ClassA A { get; private set; }    
}

entry pointの指定

builderへのRegistration時にentry pointに指定することで、Build()の時点で依存解決を実行させることができます。

// 第一引数をtrueで、entry pointに指定される
builder.RegistrationAsSingleton<ClassA>(true);

// この時点でClassAのインスタンスが生成と依存注入が実行される。
builder.Build();

ServiceLocator的な使い方

INeCoResolverを指定することでServiceLocator的な使い方ができます。

public class ClassB
{
    public ClassA a;
    
    [Inject]
    private Injection(INeCoResolver locator)
    {
        this.a = locator.Resolve<SampleA>();
    }
}

Scene上のMonoBehaviourを継承したクラスの登録

Scene上のMonoBehaviourを継承したクラスを登録する際は、特定のメソッドから登録する必要があります。

builder.RegistrationMonoBehaviour_AsConstant(componentA);

Prefabの登録

Prefabを登録する際は、特定のメソッドから登録する必要があります。 登録したPrefabはResolve時にInstantiateされます。

// Resolve時にparentのchildとしてInstantiateする(Instantiate後のResolveは生成済みのインスタンスを返す)
builder.RegistrationPrefab_AsSingleton(componentA, new PrefabRegistrationOptions()
{
    IsThisEntryPoint = true,
    Parent = null,
    DontDestoryOnLoad = true,
    Id = "Sample05"
});

// Resolve時にparentのchildとしてInstantiateする(Resolveの度にInstantiateを実行)
builder.RegistrationPrefab_AsTransient(componentA);

Funcの登録

VContainerに憧れて、Funcを登録できるようにしました。

builder.RegisterFunc_AsSingleton<float, Effect>(resolver => 
{
    return damage =>
    {
        if(damage <= 50f)
        {
            // ResolverからDIしながらInstantiate(これも憧れで)
            return resolver.Instantiate<Effect>(this.normalEffect, null);
        }

        return resolver.Instantiate<Effect>(this.criticalEffect, null);
    };
});        

破棄する

BuilderとResolverはDisposeから破棄できます。

builder.Dispose();
resolver.Dispose();

Resolver生成までを補助する機能

Builderへの登録からRevolverを生成するところまで補助する機能を用意しました。

GameObjectにアタッチされているコンポーネントを登録する

登録したいScene上のコンポーネントをアタッチし、スクリプトからRegistrationAndBuild()を実行するとResolverが取得できます。

[SerializeField]
private MonoBehaviourRegistrationHelper helper;

void Start()
{
    // builderの生成、登録、resolverの生成までを実行する
    INeCoResolver resolver = helper.RegistrationAndBuild();
}

Prefabを登録する

スクリプトがアタッチされたprefabをアタッチし、スクリプトからRegistrationAndBuild()を実行するとResolverが取得できます。

[SerializeField]
private PrefabRegistrationHelper helper;

void Start()
{
    // builderの生成、登録、resolverの生成までを実行する
    INeCoResolver resolver = helper.RegistrationAndBuild();
}

Scene上のHelper群から登録する

RegistrationAndBuild()を実行すると、Scene上にアタッチされているHelperクラス群を参照、登録してResolverを返します。

[SerializeField]
private SceneRegistrationHelper helper;

void Start()
{
    // builderの生成、登録、resolverの生成までを実行する
    INeCoResolver resolver = helper.RegistrationAndBuild();
}

Helperクラスを自作する

RegistrationHelperGameObjectというクラスを継承することで、Helperクラスを自作することができます。

public class MyHelper : RegistrationHelperGameObject
{
    public override INeCoBuilder Registration(INeCoBuilder container = null)
    {
        // containerへの登録処理を記述

        return container;
    }
}

ScriptableObjectから登録する

ScriptableObjectにRegistrationHelperGameObjectを継承したコンポーネントをアタッチすることで、Helper群からResolverを生成することができます。 PrefabRegistrationHelperや自作HelperクラスなどをアタッチしたPrefabを作成し、それをScriptableObjectに登録する。みたいな使い方を想定しています。

[SerializeField]
private RegistrationHelperScriptableObject helper;

void Start()
{
    // builderの生成、登録、resolverの生成までを実行する
    INeCoResolver resolver = helper.RegistrationAndBuild();
}

その他

RegistrationHelperGameObjectを継承した自作Helperスクリプトを生成して、そのスクリプトをアタッチしたPrefabを生成するエディタ拡張も作成しましたが、Unity2021のどこかのバージョンでInitializeOnLoadの挙動が変わったようで、現在機能していません。

詰まった所など

当初、dynamic(ExpressionTree)を使って実装しようと思っていたのですが、IL2CPPで動作しないこと(link.xmlを作成すれば一部動作するようですが)を知らず、ILコード周りの知識もなかったので、結局リフレクションで作りました。なので性能は良くないと思います。またNeContainerは依存注入を再帰的に処理しているのですが、依存関係に循環参照があると無限ループで死んでしまうため、対策をする必要がありました。強引な方法ですが、FormatterServices.GetUninitializedObjectで不完全なインスタンスを参照先同士に渡し、各インスタンスが行き渡った後にリフレクションからInvokeして完全体にする方法で対応しました(ただし注入するインタスンスがSingletonでない場合はnullが注入されてしまいます)。

learn.microsoft.com