Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキゲームスが運営しています。

Unity を用いた開発で発見しづらい場所に溜まっていた不要データの大掃除

この記事は Akatsuki Games Advent Calendar 2025 - Adventar 17日目の記事です。

はじめに

クライアントエンジニアの渡邊です。新卒で入社して4年目で、主にゲームの新規機能開発と運用向けの内部ツール等を開発しています!

概要

ゲームの見た目、面白さ、演出、UI などさまざまな用途で用いられるアセットですが、開発中、様々な要因で不要なデータが知らず知らずのうちに蓄積していきます。例えば、ゲームの要件・仕様変更に伴って使わなくなったデータをエンジニアが削除し忘れる、アセットを作成してくださるアーティストさんそれぞれの経験値・制作手順の微妙な違いで不要なデータが生じてしまうといった人的要因などが挙げられます。

今回は、私が所属する開発現場で生じた不要データの事例を二つ紹介します。

事例紹介

事例1: YAML に忍びこむ合計600万文字のテキストを除去!🧹

Unity のアセットは YAML 形式で保存されていますが、その中には、エディタ上で Inspector などに表示される目に見える情報だけではなく、内部的に保持されており普段私たちがほとんど目にすることのない情報も格納されています。今回は、アセットが保持する内部的な情報に意図せず長い文字列が格納されていたことが発覚し、それらの文字列を全て削除したことで、約600万文字の削減を達成した事例を紹介します。

問題発見

大量の(Clone)が名前についているクリップ

私が所属しているプロジェクトのクライアントエンジニアが、ゲーム中のメモリをキャプチャし、Unity の Memory Profiler を眺めていたところ、(Clone) という文字列が大量に続くオブジェクトがメモリに乗っていることに気づきました。

初めてこの報告を見た時は、一体いつの間にこんなに (Clone) がついてきているのかと驚きしかなかったです。

原因

Unity のエンジン側の実装を確認したところ、GameObject や Prefab をインスタンス化(Instantiate)すると、無条件で名前の末尾に (Clone) という文字列を付与していることがわかりました。 Hierarchy 等に表示される GameObject であれば Play Mode 中に Prefab の Instantiate 等で Prefab 名の後ろに (Clone) がついているのを皆さんも見たことがあると思うのですが、Timeline クリップの m_Name は、Timeline のクリップ上に表示される名前ではなく、内部的な名前であり、Memory Profiler 等を使わないと表示されないものであるため、なかなか気づきにくいと思います。

クリップの複製

今回の Timeline のクリップの名前についてしまう (Clone) の文字列ですが、Timeline タブで Timeline アセットを編集する際に、トラックに配置されているクリップを複製すると (Clone) が付与されてしまうようでした。試しにテスト用の Timeline アセットを用意し、Activation Track を一つ追加します。Activation Clip を一つだけ付与した後、コンテクストメニューから「複製」を何度か行い、複製されたクリップの情報を見てみます。テスト用の Timeline アセットの中身を見てみたものが以下のスクリーンショットになります(※ コピーアンドペーストでも問題ありません)。

m_Name に (Clone) がたくさん出現している

m_Name の部分に確かに (Clone) がたくさん出現していることがわかります。また、エディタ上でクリップの上に表示されている名前(m_DisplayName フィールド)とも異なり、内部的に保持されているフィールドであることも確認できます。

Timeline アセットの編集時、クリップを複製する操作は多用するため、m_Name に(Clone) が付与されたクリップをさらに複製する操作を何度も繰り返すことで大量に (Clone) が付与されてしまったことが今回の問題発生の原因のようです。

対処

アセットの保存時に、Timeline のクリップ内部に保持している m_Name を全て空文字列(string.Empty)に置換しました。実装は以下のようになります。

public class TimelineClipNameReplacer : AssetModificationProcessor
{
    private static string[] OnWillSaveAssets(string[] paths)
    {
        foreach (var path in paths)
        {
            var timeline = AssetDatabase.LoadAssetAtPath<TimelineAsset>(path);
            if (timeline == null)
            {
                continue;
            }

            foreach (var track in timeline.GetOutputTracks())
            {
                foreach (var clip in track.GetClips())
                {
                    if (clip.asset == null)
                    {
                        continue;
                    }
                    var playableAsset = clip.asset as ScriptableObject;
                    if (playableAsset == null)
                    {
                        continue;
                    }

                    playableAsset.name = string.Empty;
                    UnityEditor.EditorUtility.SetDirty(playableAsset);
                }
            }
        }

        return paths;
    }
}

m_Name (コード上では playableAsset.name)を空文字列にすると、Memory Profiler 上では "<No Name>" と表示されるため、完全に空文字列にするのではなく "(Clone)" という文字列のみ string.Empty に置き換えるといった方法でも良いと思いますが、今回の場合は、Memory Profiler 上で型(Type)がわかり、それ以上の情報があるわけでもないので、空文字列にする方法を選びました。

Timeline エディタ上でクリップの上に表示されている名前は m_Name ではなく m_DisplayName ですので、m_Name を空文字列にしても、Timeline アセットの編集には影響がありません。

また、ゲーム開発のリポジトリで Timeline アセットのバリデーションを行っている部分があるので、 m_Name に空ではない文字列が入っていたらエラーとして通知する仕組みを追加しました。基本的に通知が発生することはないのですが、防波堤として用意しています。

結果

全ての Timeline アセットのクリップの m_Name を空文字列(string.Empty)に置き換えた変更をコミットした作業ブランチを、開発ブランチにマージするための Pull Request を作成し、差分を確認したところ、約9.5万行の削除と表示されました。仮に 9.5万行全てが m_Name の削除差分だとして、一つの m_Name あたり約60〜70文字程度とすると、約600万文字のデータを削除し、数MB程度削減できたことになります。

(Clone) が大量に付与されてしまっても、その文字列の削除自体はそこまで難しくなく、また、アセットの保存時に m_Name を空文字列に置き換えるという簡単な仕組みで不要データの生成を抑制できます! 不要データの削減を行なったことによる効果はそこまで大きくないですが、文字列が占有するメモリ量としては意外と無視できない量に膨れ上がってしまう恐れがあるため、積み上がる前に対処しておくと良さそうです。

事例2: ゲームに影響を与えずにVFXのデータを2000万行削減!✨

ゲームの演出に欠かすことのできないVFXは、Unity では主に Particle System を用いて作られています。Particle System はとても便利ですが、一つコンポーネントを付与するだけで大量にデータが書き込まれるため、使用しないデータが大量に残っているとゲームアプリの容量を肥大化させてしまいます。本事例では、エンジニアとアーティスト双方が協力して不要な Particle System の削減に取り組み、約2000万行のデータの除去に成功した例を紹介します。

問題発見

私が所属しているプロジェクトのクライアントエンジニアが、ゲーム中のメモリをキャプチャし、Unity の Memory Profiler を眺めていたところ、Particle System がランタイムのメモリの10%ほど使用していることに気づきました。 Particle System によって生じるパーティクルが持つマテリアル・テクスチャの容量が大きいとか、単にパーティクルの量が多いとか、生存期間が不必要に長いといった演出の問題などではなく、Particle System コンポーネントそのものがメモリの10%ほどを使用しているのがポイントです。

原因

開発の過程で、VFX 作成・編集の時に、見た目調整等の理由で不要になった Particle System コンポーネントがそのまま残存してしまったことが原因として挙げられます。

Prefab の中身を適当なテキストエディタなどで開いてみると確認できますが、Particle System コンポーネントは非常に大きなコンポーネントです。仮にどのモジュールも使用しなかったとしても、コンポーネントが GameObject に一つ付与されただけで、5000行ほどの増分が見られます。デカすぎ...。

対処

まず、確実に削除対象となる Particle System コンポーネントのルールを決め、該当するコンポーネントを含むアセットをスクリプトで洗い出しました。

確実に未使用とわかる Particle System コンポーネントの例

プロジェクトのゲーム中のスクリプトから、Particle System コンポーネントを動的に取得して操作したり、モジュール自体に変更を加えるものがないとわかっていたので、Particle System のモジュールが全て無効化されていれば確実に未使用であり、演出に全く影響を与えないので削除して良いとわかります。

削除対象となる Prefab の列挙の仕方ですが、所属しているプロジェクトでは、アセットの命名規則がきっちり定められており、VFX アセットの Prefab は特定の接頭辞で始まるようになっていました。これを利用し、特定のディレクトリ以下にある指定された接頭辞で始まる Prefab を全て列挙します。全てのモジュールが無効になっているかどうかの判定は、Particle System コンポーネントが取得できれば判定できます。コードにすると以下のようにモジュールが有効になっているかどうかを全て地道に判定する実装になります。

public static bool IsAllModuleDisabled(this ParticleSystem ps)
{
    var emission = ps.emission;
    var shape = ps.shape;
    var velocity = ps.velocityOverLifetime;
    var limitVelocity = ps.limitVelocityOverLifetime;
    var inheritVelocity = ps.inheritVelocity;
    var lifetimeByEmitterSpeed = ps.lifetimeByEmitterSpeed;
    var force = ps.forceOverLifetime;
    var colorOverLifetime = ps.colorOverLifetime;
    var colorBySpeed = ps.colorBySpeed;
    var sizeOverLifetime = ps.sizeOverLifetime;
    var sizeBySpeed = ps.sizeBySpeed;
    var rotationOverLifetime = ps.rotationOverLifetime;
    var rotationBySpeed = ps.rotationBySpeed;
    var externalForces = ps.externalForces;
    var noise = ps.noise;
    var collision = ps.collision;
    var trigger = ps.trigger;
    var subEmitters = ps.subEmitters;
    var textureSheet = ps.textureSheetAnimation;
    var lights = ps.lights;
    var trails = ps.trails;
    var customData = ps.customData;
    var renderer = ps.GetComponent<ParticleSystemRenderer>();

    return !emission.enabled &&
           !shape.enabled &&
           !velocity.enabled &&
           !limitVelocity.enabled &&
           !inheritVelocity.enabled &&
           !lifetimeByEmitterSpeed.enabled &&
           !force.enabled &&
           !colorOverLifetime.enabled &&
           !colorBySpeed.enabled &&
           !sizeOverLifetime.enabled &&
           !sizeBySpeed.enabled &&
           !rotationOverLifetime.enabled &&
           !rotationBySpeed.enabled &&
           !externalForces.enabled &&
           !noise.enabled &&
           !collision.enabled &&
           !trigger.enabled &&
           !subEmitters.enabled &&
           !textureSheet.enabled &&
           !lights.enabled &&
           !trails.enabled &&
           !customData.enabled &&
           (renderer == null || !renderer.enabled);
}

コンポーネントの削除の仕方が肝なのですが、Particle System コンポーネントを付与する時に、裏で Particle System Renderer というコンポーネントが別途付与されており、Particle System コンポーネントだけ削除しても Particle System Renderer だけ残り、マテリアル表示が Inspector 上に残ってしまう問題点があります。なので、Particle System と Particle System Renderer 両方を削除する必要があります。

obj を GameObject とすると以下のような実装になります。IsAllModuleDisabled() は先ほど掲載したコードに記載があります。

var particleSystem = obj.GetComponent<ParticleSystem>();
if (particleSystem != null && particleSystem.IsAllModuleDisabled())
{
    var renderer = obj.GetComponent<ParticleSystemRenderer>();
    if (renderer != null)
    {
        Object.DestroyImmediate(renderer, true);
    }
    Object.DestroyImmediate(particleSystem, true);
}

次に、機械的に削除して良いかどうかが判断ができないものについては、Prefab の最終編集者に個別に確認・対応してもらいました。最終編集者がいらっしゃらない場合は、担当領域に最も近いアーティストさんに依頼しました。特定のモジュールは有効化されているが、実際は使われていないといったもの、プロジェクト側で用意されているマテリアルではなく、Unity デフォルトのマテリアルが設定されていて実質不要か設定ミスとわかるものなど、様々なパターンがありました。

そして、全ての VFX Prefab と全てのマテリアルについて、どのアセットから参照されているのかを検索し、参照が0件だったアセットを削除しました。アセットが他のアセットから参照されているかどうかの判定は、アセットが YAML 形式で保持されているので、GUID をキーとしてテキストを検索する方法*1をとりました。この検索を走らせるプロセスは、自身のPC環境で数時間かかる見込みであったため、通常の業務に影響がない時間帯に走らせました。

ここまで終わったら、全てのモジュールが無効になっている Particle System が存在したらエラーとして通知するバリデーションをCIに追加して、今後の開発で不要なデータが開発ブランチにマージされないように防ぎます。

結果

まず、不要な Particle System コンポーネントが含まれる VFX の Prefab だけで1000個以上検知できました。不要な Particle System コンポーネントを全て削除した後、どのアセットからも参照されていない VFX Prefab とマテリアルをスクリプトを用いて検索をかけたところ、VFX Prefab はおよそ150件、マテリアルは1万個以上あるうちの12%がどのアセットからも参照されていないことが発覚しました。想定よりだいぶ多くの個数が検知されて驚きました。

これらの不要データを削除し、GitHub 上の差分を見たところ、およそ2000万行のデータを削除することに成功しました。このうちの3分の2が Particle System と Particle System Renderer の削除差分であり、この YAML 形式のテキスト差分だけで推定2GB程度の容量を削減することに成功しました。もともと問題であった Particle System コンポーネントがゲーム中のメモリの10%程度を使用していた問題も少し改善し、7〜8%程度の使用量に抑えられました。

特にモバイル端末では、使用できるメモリ量がPC端末と比較するとかなり限られているため、少しでも削減できることは嬉しいですし、アセットの容量を数GB削減できると、アプリサイズ自体が小さくなるだけではなく、ゲームの更新時に発生するアセットのダウンロード時間も短縮できるので、長期的にみて大きな効果が得られると思います。

どこからも使用されていないコンポーネントの削除、アセットの削除であるため、削除後は実際のゲームには何ら影響がないはずと頭では理解しているものの、ここまで大量のアセットを一度に修正・削除を行うとどこかで予期せぬエラーがでるのではないかと不安になりましたが、特にミスなく作業を終えることができました。

おわりに

このようなアセットの不要データ削除のような取り組みは、プレイヤーには直接見えることのない取り組みであり、問題を発見したとしても、完全に対応するには根気がいる作業になるかもしれません。実際、今回の Particle System コンポーネントの例では、完全に対応が終わるまで14営業日ほどかかりました。これは、単純に修正するアセットの物量が多かったこと、自分自身の普段の機能開発業務に加えて合間に作業していたこと、アーティストさんとエンジニアでコミュニケーションを取り綿密に計画を練る必要があり、ミスがあると取り返しがつかないことになるため慎重に作業する必要があったことが絡んでいます。時間はかかったものの、ゲーム全体に好影響を与えられる対応だったため、取り組む価値は十分あったと思います!

昨今、ゲームをプレーする端末のスペックの向上により、アセットの容量節約が後回しにされがちですが、アセットは全てのプレイヤーが必ず端末にダウンロードするものであるからこそゲームの核となる面白さを磨くのと同様にクリーンな状態でお届けすることが大事であると、アセットの不要データ削除を通して改めて感じました。

本記事が、皆さんのプロジェクトにおいてアセットの最適化を促進する手助けとなれば幸いです!

*1:自分が業務で使用しているPCが macOS だったので、mdfind コマンドと ripgrep コマンドを併用しました。mdfind コマンドは実行は高速ですが、Spotlight 側が構築しているインデックスの内容によってはアセットからの参照を取得できないことがあります。一方、ripgrep コマンドは確実に参照を取得できますが実行に時間がかかります。双方のコマンドの良いところを活用するため、mdfind コマンドで高速にアセットの参照を検索したのち、参照が0件とわかったアセットを再度 ripgrep コマンドで検索して、両方で参照が0件となったものを削除対象としました。