【Unity】3Dオブジェクトの残像処理


本記事は Unity #2 Advent Calendar 2020 23日目の記事です
https://qiita.com/advent-calendar/2020/unity2

【22 日】『【Unity】DOTween で PS4 のトロフィー獲得通知みたいな演出を作ってみる』(@sakasa_ さん)
【23 日】本記事
【24 日】『インテリアマッピング(interior mapping)~1m³距離編~ 』(@copo さん)

初めに

3Dオブジェクトの残像処理については色々な手法があり、自分が調べた限りでも以下の種類があります。
これらは使用用途によって良し悪しが変わるのでどれが一番良いかは断言できませんが、今回は下記の中の【ボーン構造だけ同じ残像用オブジェクトを生成する】という手法を使って、残像処理を実装してみました。

  • 単純に同じオブジェクトを複製する
  • RenderTextureを使う
  • ボーン構造だけ同じ残像用オブジェクトを生成する
  • Animator付の場合、Animatorをアタッチした残像用オブジェクトを生成して、生成時のアニメーション情報を渡す
  • SkinnedMeshRenderer付の場合、BakeMeshを使ってメッシュを生成して残像オブジェクトにアタッチする
  • etc...

※処理の簡略化の為コード内でUniRx/UniTaskを使用しています。予めご了承ください。

開発・動作テスト環境

Windows10
Unity2019.4.11f1 / URPプロジェクト
※使用アセットなど詳細情報は下記のサンプルプロジェクト内のReadmeを参照

サンプルプロジェクト

サンプルプロジェクトのリンクは以下になります(自作コード類はScriptsフォルダ以下に纏めてあります)
https://github.com/madoramu/AfterImage

スクショ(動画はGithubのReadmeにリンクがあります)
© UTJ/UCL

実装内容の概要

構成

以下の三つが根幹となって構成されている
* 残像オブジェクトクラス(AfterImageBase)
* 残像の生成や管理を行うコントローラークラス(AfterImageControllerBase)
* 残像オブジェクトのプーリングクラス(AfterImagePool)

※コントローラーと残像オブジェクトのクラスは、MeshRendererとSkinnedMeshRendererでそれぞれコンポーネントを分けている(内部処理が微妙に違うため)

実行時の処理の流れ

  • コントローラーのAwake時に残像のプーリングクラスを生成。予め指定した個数分残像オブジェクトを生成しておく
  • 生成処理のフラグが建ったら、一定時間ごとにプールから残像オブジェクトを取得する
  • 取得した残像オブジェクトに、現在のボーン情報を反映させるための情報を渡して設定処理を行う
  • 表示した残像オブジェクトは、一定時間後にプールに返却される

以下は上記処理が掛かれた部分の抜粋

AfterImageControllerBase.cs
        // プールの準備
        _pool = new AfterImagePool(_afterImage, _afterImageParent);
        _pool.PreloadAsync(_preLoadCount, 1)
            .TakeUntilDestroy(this)
            .Subscribe(_ => { }, exception => { Debug.LogException(exception); }, () => { isInitialized = true; });

        // フラグに応じて処理の登録と破棄を行う
        _isCreate
            .TakeUntilDestroy(this)
            .Where(_ => isInitialized)
            .Subscribe(enable =>
            {
                if (!enable)
                {
                    _disposable.Clear();
                    return;
                }

                Observable.Interval(TimeSpan.FromSeconds(_createIntervalTime))
                        .Subscribe(_ =>
                        {
                            // プールから残像を取得してオリジナルのポーズと合わせる
                            AfterImageBase image = _pool.Rent();
                            image.Setup(_param);

                            // 時間経過処理と終了時にプールに戻す処理を登録しておく
                            float currentTime = 0f;
                            _updateTrigger.UpdateAsObservable()
                                .TakeUntilDisable(image)
                                .Subscribe(unit =>
                                {
                                    currentTime += Time.deltaTime;
                                    image.rate = currentTime / _afterImageLifeTime;
                                    if (currentTime >= _afterImageLifeTime)
                                    {
                                        _pool.Return(image);
                                    }
                                }
                                //,() => { Debug.Log("正常に破棄されています"); }
                                );
                        })
                        .AddTo(_disposable);
            });

残像オブジェクトの仕様

  • 生成してから一定時間後に消える。プーリングの都合上Destroyはせずにオブジェクトを使い回す
  • Unlitのマテリアルを使用
  • Gradientを使用して、経過時間に応じて色と透過度を変えていく

モーション(ボーン)が無い単純な3Dオブジェクトの場合

ボーン構造が無いオブジェクトが単純に生成元のTransform情報を残像オブジェクト出現時に渡してあげているだけ
ボーン構造がある場合も基本的にはこの処理とやっていることは同じ

SimpleAfterImage.cs
    /// <summary>
    /// 残像の開始時の設定処理
    /// </summary>
    public override void Setup(IAfterImageSetupParam param)
    {
        if (param is SimpleAfterImageParam simpleParam)
        {
            transform.position = simpleParam.transform.position;
            transform.rotation = simpleParam.transform.rotation;
            transform.localScale = simpleParam.transform.localScale;
        }
        else
        {
            Debug.LogError("引数がSimpleAfterImageParamにキャスト出来ませんでした");
        }

        rate = 0f;
    }

モーション(ボーン)がある3Dオブジェクトの場合

生成元のボーン情報をListで受け取り、残像側のボーン情報と比較して一致しているボーンのTransform情報を反映させていく

AfterImageSkinnedMesh.cs
    /// <summary>
    /// 残像の開始時の設定処理
    /// </summary>
    public override void Setup(IAfterImageSetupParam param)
    {
        if (param is AfterImageSkinnedMeshParam useBonesParam)
        {
            Transform tmp = null;
            for (int i = 0; i < _bones.Count; i++)
            {
                tmp = useBonesParam.transforms.Find(b => b.name == _bones[i].name);
                if (tmp != null)
                {
                    _bones[i].position = tmp.position;
                    _bones[i].rotation = tmp.rotation;
                    _bones[i].localScale = tmp.localScale;
                }
            }
        }
        else
        {
            Debug.LogError("引数がAfterImageSkinnedMeshParamにキャスト出来ませんでした");
        }

        rate = 0f;
    }

おまけ:残像をアニメーションさせたい場合

  • 発生元のオブジェクトと同じボーン構造・Animator・AnimatorControllerを用意してあげれば可能
  • ここに関しては正直同じオブジェクトの複製と大差ないので割愛。

改善点

今回の実装では取り敢えず残像側にAnimatorやAnimation情報を渡さずに、ボーン情報だけで良い感じの残像を作成する事ができました。
しかし結構粗削りな部分があるため、色々と改善できそうな部分があります(以下執筆時点で思いついている改善点)。

  • 今回の残像仕様は全てのメッシュで同じマテリアルを使用しているので、残像側でメッシュ統合処理が出来ればもっと軽量化と高速化が見込めそう(特にUnityちゃんモデル)。
  • 生成元オブジェクトのボーン情報を残像のボーンに反映させる処理で、List.Findを使っているのでかなり処理負荷がかかっている。渡す側と受け取る側のボーンリストの個数と並び順が完全に一致している、のであれば単純なfor文を回すだけで事足りる。

※今回Unityちゃんの残像オブジェクトのボーンは、残像に不要なスクリプトやオブジェクトが多数あったので意図的に少し減らしてます。そのためFindでボーン名の一致を確認してから情報を格納しています。

おわりに

残像を出すといっても結構色々な手法があったり、どんな残像を出したいかによってまた実装方法が変わる事もあるので、結構奥が深いなと感じました。ここに書いたのはほんの一例なので、他にもこんな手法があるなどあればコメントしてくれると有難いです。