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

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

「一番メモリを消費するキャラクターは誰?」に即答したい。Unity アセットの静的解析と自動通知で実現したアセット肥大化を未然に防ぐ仕組み

この記事は Akatsuki Games Advent Calendar 2025 22日目の記事です。

はじめに

クライアントエンジニアの渡邊です。ゲームの新規機能開発やプロジェクト内部向けツールの製作を行なっています。

概要

運用型ゲームではキャラクターが増え続けますが、キャラクターのアセットが端末のメモリをどれくらい使用するかは常に気になる問題です。

「今、最もメモリを消費しているキャラクターは誰なのか?」

「複数キャラクターを同時に読み込んだ時、ゲームアプリがクラッシュしてしまうことはないだろうか?」

こうした問いに答えるために、私は、キャラクターのモデル、アイコンやサムネイルといった画像、VFX、サウンドデータなど複数の要素から構成されるキャラクターのアセットのメモリ上のサイズを推定して集計するツールと、メモリサイズ推定値が大きいキャラクターをランキング化して Slack に通知する仕組みを開発しました。

完成したツールの Editor Window
Slack へのランキング通知

本記事では、Unity エディタ上での集計ロジックの簡単な解説から、自動通知の仕組みまでを解説します。このツールは単なる計測に留まらず、アセット肥大化を早期に検知することができたり、アーティストさんへアセット最適化を促進したりと、当初の単なる「キャラクターのメモリ使用量測定」の枠を超えた変化を生み出すことができました。

現場からのリクエスト

モバイルゲームにおいて、メモリ不足によるアプリクラッシュはユーザの離脱に直結する致命的な問題になります。特に、キャラクターが多数登場するゲームにおいて、複数のキャラクターを同時にメモリにのせた時の負荷が懸念事項です。

そこで、現場から以下の要望が届きます。

「全キャラの中で最もメモリを消費する上位数体を特定してほしい。それらを同時に読み込んでもゲームがクラッシュしないかを検証したい。」

一見シンプルに見えるリクエストですが、いざ取り組もうとするとさまざまな困難に直面します。

アセットが肥大化する要因

アセットの容量の大部分を占めるのはテクスチャですが、そのテクスチャのアセットサイズの管理が一部難しい状況でした。キャラクターのメッシュであれば、シェーダーによって使用するテクスチャの最大枚数にある程度の制約を設けることができます。しかし、VFXの場合は、多種多様なPrefabを組み合わせて構成されるため、最大のアセットサイズの制限が難しく、気づかないうちにアセットが肥大化しやすい構造になっていました。

実装上の課題

特定のシーンでのキャラクターアセットの正確なメモリサイズを測定するなら、デバッグ機能を有効にしたアプリをビルドして端末にインストールし、ゲームを起動したのちに Memory Profiler と接続してメモリをキャプチャするのが確実な方法です。しかし、メモリをキャプチャするアプローチは複数の問題点を抱えています。

メモリ上の要素の複雑さ

実際のゲームシーンでは、キャラクター以外にも多くの要素がメモリにのります。例えば、UI、シーンごとに共有されるリソース、Unity 内部で動的に確保・解放されるメモリ領域などがあります。また、端末の状態や計測順序などによって計測値が多少変動することもあります。これらの要素が複雑に絡み合う中で、プロファイラ上の数MB単位の増減が、キャラクター起因なのか、アニメーションやエフェクトといった演出起因なのか、スクリプトやエンジン側の処理起因なのかを切り分けるのは至難の業です。このような状態ではキャラクター以外のノイズ要素が多く、メモリが増減した原因が不透明なため、「このキャラクターが多くメモリを消費しています」と断定しづらいです。

組み合わせ爆発

キャラクターの組み合わせの数も問題になります。例えばすでにキャラクターが100体いて、そのうちの3体を選ぶ組み合わせの数は、 {}_{100}  \mathrm{C}_{3} = 161700 通りになります。運用が続くにつれてキャラクター数は増えるため、この組み合わせ数も数百万、数千万、数億...といとも簡単に爆発的に増大していきますから、全ての組み合わせについてメモリをキャプチャする方法は現実的ではありません。

静的算出というアプローチ

そこで、私は実機でメモリ消費量を計測するアプローチではなく、アセットそのものの定義から静的にキャラクターのアセットのメモリサイズを推定するアプローチを選択しました。この静的算出アプローチを選択したのには以下の理由があります。

解析時間が短い

メモリキャプチャは、アプリビルド、シーン遷移とメモリのキャプチャ、キャプチャしたスナップショットの解析と一つの作業ですら時間が多くかかるプロセスが積み重なります。
これに対して、Unity の Editor 上で静的算出するアプローチであれば、キャラクターが増え続けても、全キャラクターの解析を数秒〜数十秒程度で完了させることができます。

同じ物差しで比較する価値が高い

実機計測では、計測時の端末状態、計測順序などの要因によって計測値がブレる懸念がありました。一方、静的算出するアプローチであれば、常に同じ計算式を用いて同じ基準で全キャラクターを評価することができます。

「正確なメモリサイズ値」ではなく「信頼できる推定値」で十分だから

現場からのリクエストで最も重要なのは、メモリを多く消費するキャラクター上位数体を特定することです。たくさんキャラクターがいる中で、相対的にこのキャラクターのアセットサイズが重いという事実がわかれば、100%正確な値がとれていなくても、キャラクターの検索の優先順位をつけるための確度の高い指標として十分機能し、実務上は十分であると判断しました。

実装

キャラクターを構成する主なアセットは大まかに以下の五つに分けられます。

  • テクスチャ
  • SEやボイスなどのサウンドデータ
  • マテリアル
  • メッシュ
  • Prefab

このうち、最もメモリサイズに寄与しそうなのはテクスチャですので、今回は主にテクスチャサイズの推定に焦点を当てて説明します。

テクスチャのメモリサイズの推定

テクスチャのメモリサイズは bpp(ビット/ピクセル) × width × height で算出することができます。テクスチャ(texture)の縦横サイズは、それぞれ width プロパティ、height プロパティで取得できるので、bpp を推定できるとテクスチャのメモリサイズが推定できそうです。

bpp は Unity 公式ドキュメントの推奨、デフォルト、およびサポートされているテクスチャ形式 (プラットフォーム別) - Unity マニュアルというページの「テクスチャ形式 (品質別)」に記載の表の「ビット/ピクセル」の値を参考にテクスチャのフォーマットごとに算出します。コード上では単純にパターンマッチングしています。

🔽ソースコード(クリックで展開・収納します)

private static float GetBitsPerPixel(TextureImporterFormat format)
{
    return format switch
    {
        TextureImporterFormat.DXT1 => 4,
        TextureImporterFormat.DXT1Crunched => 4,
        TextureImporterFormat.DXT5 => 8,
        TextureImporterFormat.BC4 => 4,
        TextureImporterFormat.BC5 => 8,
        TextureImporterFormat.BC6H => 8,
        TextureImporterFormat.BC7 => 8,
        TextureImporterFormat.ETC_RGB4 => 4,
        TextureImporterFormat.ETC_RGB4Crunched => 4,
        TextureImporterFormat.ETC2_RGB4 => 4,
        TextureImporterFormat.ETC2_RGBA8 => 8,
        TextureImporterFormat.ETC2_RGBA8Crunched => 8,
        TextureImporterFormat.ETC2_RGB4_PUNCHTHROUGH_ALPHA => 4,
        TextureImporterFormat.EAC_RG => 8,
        TextureImporterFormat.EAC_R => 4,
        TextureImporterFormat.ASTC_4x4 or TextureImporterFormat.ASTC_HDR_4x4 => 8,
        TextureImporterFormat.ASTC_5x5 or TextureImporterFormat.ASTC_HDR_5x5 => 5.12f,
        TextureImporterFormat.ASTC_6x6 or TextureImporterFormat.ASTC_HDR_6x6 => 3.56f,
        TextureImporterFormat.ASTC_8x8 or TextureImporterFormat.ASTC_HDR_8x8 => 2,
        TextureImporterFormat.ASTC_10x10 or TextureImporterFormat.ASTC_HDR_10x10 => 1.28f,
        TextureImporterFormat.ASTC_12x12 or TextureImporterFormat.ASTC_HDR_12x12 => 0.89f,
        TextureImporterFormat.PVRTC_RGB2 or TextureImporterFormat.PVRTC_RGBA2 => 2,
        TextureImporterFormat.PVRTC_RGB4 or TextureImporterFormat.PVRTC_RGBA4 => 4,
        TextureImporterFormat.R8 => 8,
        TextureImporterFormat.R16 => 16,
        TextureImporterFormat.Alpha8 => 8,
        TextureImporterFormat.RG32 => 32,
        TextureImporterFormat.RGBAHalf => 64,
        TextureImporterFormat.RGB9E5 => 32,
        TextureImporterFormat.RGB16 => 16,
        TextureImporterFormat.RGBA16 => 16,
        TextureImporterFormat.RGB24 => 24,
        TextureImporterFormat.RGBA32 or TextureImporterFormat.ARGB32 => 32,
        TextureImporterFormat.RGB48 => 48,
        TextureImporterFormat.RGBA64 => 64,
        _ => 32
    };
}

プラットフォームごとにテクスチャの設定が異なるため、上書き設定を読み出して、bpp を算出します。もし Automatic に指定されている場合はフォーマットの推定を行なっています。コードは以下のようになります(GetBitsPerPixel は上記の折りたたまれたコードの中に記載されています)。

var format = platformSettings.overridden ? platformSettings.format : defaultPlatformSettings.format;
if (format is TextureImporterFormat.Automatic)
{
    var hasAlpha = importer.DoesSourceTextureHaveAlpha();
    var isNormalMap = importer.textureType is TextureImporterType.NormalMap;

    format = platform switch
    {
        BuildTarget.iOS => isNormalMap switch
        {
            true => TextureImporterFormat.PVRTC_RGB4,
            false => hasAlpha ? TextureImporterFormat.PVRTC_RGBA4 : TextureImporterFormat.PVRTC_RGB4
        },
        BuildTarget.Android => hasAlpha ? TextureImporterFormat.ETC2_RGBA8 : TextureImporterFormat.ETC2_RGB4,
        _ => throw new ArgumentOutOfRangeException()
    };
}

var bpp = GetBitsPerPixel(format);
var estimatedAssetSizeByte = width * height * bpp / 8;


mipmap が有効になっている場合は、推定値に 4/3 をかけます。mipmap は元のテクスチャから横幅、縦幅がそれぞれ半分になっていくテクスチャが利用され、 1 + 1/4 + 1/16 + ... = 4/3 であるので 4/3 をかけることによって推定値がより正確になります。

Read/Write が有効な場合は推定値を2倍します。CPU側へのコピー分のサイズを計算に含めるためです。

その他のアセットのサイズ推定

テクスチャに次いでSEやボイスなどのサウンドデータがメモリサイズに影響を与えるのですが、Unity のアセット管理外にあるためメモリ消費量を推定することはできません。その代わり、ビルド時に生成されるデータをキャラごとに合算して消費メモリの見積もりをだすアプローチをとりました。

マテリアルやメッシュ、Prefab については設定ファイルでありテクスチャに比べれば軽量なので、FileInfo を用いてざっくり推定する程度にとどめました。

メモリ上のサイズを推定したいという要望からは少し外れてしまいますし、値の正確性は落ちてしまいますが、メモリ上のサイズを多く占め、最適化の余地が大きいのはテクスチャなので、この辺の推定値の精度を向上させる実装は後のアップデートでも良いと判断しました。

完成した Unity Editor ツール

完成したツールの Editor Window

キャラクターのアセットごとに、合計メモリサイズ推定値、Prefab からたどれるテクスチャ・マテリアル・メッシュ、音声データなどの推定メモリサイズ値を TreeView を用いて可視化しました。Prefab については、さらに「概要」「詳細」ボタンを用意し、「概要」には Texture や GameObject、Material といったタイプごとのメモリサイズ推定値を表示、「詳細」は1ファイルごとのメモリサイズ推定値を表示するようにしました。

行をクリックすると対象のアセットに Unity エディタ上で素早く移動することができます。また、フィルタリング機能を備えているので、キャラクターのカテゴリーやリリース日、特定のアセットが指定メモリサイズ以上のものだけに絞り込むといったことも容易にできます。

これらの機能をうまく利用することで、どのキャラクターのどのアセットが最適化の余地があるのかを素早く特定することが可能になっています。

Slack へランキング通知

Unity エディタ上でのツールは、Unity を触る人にとっては便利ですが、プロジェクトに所属している全ての人が Unity のライセンスを所持し、インストールしているわけではありません。
そこで、GitHub Actions を用いてキャラクターアセットのメモリサイズ推定値ランキングを作成する処理を走らせ、その作成したランキングから一部の情報を抽出して Slack へ通知する仕組みを導入しました。

Slack へのランキング通知

リリース前・もうすぐリリース・リリース済みの三つのカテゴリに分けて、合計メモリサイズ推定値の大きい順にキャラクターをランキング表示しています。リリース前・もうすぐリリースのものは最適化の余地があり情報の重要度が高いので5件表示しています。

リリース前ともうすぐリリースのカテゴリに属するキャラクターについては、合計メモリサイズの推定値に閾値を設け、一定以上の値になっているキャラクターについては警告マークやパトランプのマークをつけて目立たせています。パトランプのマークがついたものは特別な事情がない限りは容量削減をしたいもの、警告マークはできれば容量削減してほしいもの、というように一目でわかるようになっています。

また、警告マークやパトランプマークが付いてしまったものについては、Git 上のログから最終編集者とその編集日時をたどって表示しています。これは、アセット最適化の相談先を明確にするための主治医探しの目的で表示しています。CI でも利用できる Git 上のログから最終編集者と編集日時を取得する実装については、git コマンドを叩いてコミットしたユーザ名と日時を1行の文字列に出力した後、その文字列をパースすることで解析しています。

🔽ソースコード(クリックで展開・収納します)

※ 前提として、GitHub Actions で実行するための yml ファイルが設定されており、そこでリポジトリの履歴を全て取得しています

private (string userName, DateTime lastEditDate) GetLastEditorName(string assetPath)
{
    var workingDirectory = Directory.GetParent(Application.dataPath)?.FullName;
    if (string.IsNullOrEmpty(workingDirectory))
    {
        UnityEngine.Debug.LogError("プロジェクトのルートディレクトリの取得に失敗しました。");
        return (string.Empty, DateTime.MinValue);
    }

    var args = $"log -1 --no-merges --format=\"%an|%ai\" \"{assetPath}\"";
    try
    {
        var startInfo = new ProcessStartInfo
        {
            FileName = "git",
            Arguments = args,
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true,
            WorkingDirectory = workingDirectory 
        };

        using var process = Process.Start(startInfo);
        if (process != null)
        {
            var output = process.StandardOutput.ReadToEnd();
            process.WaitForExit();

            if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
            {
                // 出力例: "Taro Yamada|2025-12-11 10:00:00 +0900"
                var parts = output.Trim().Split('|');

                if (parts.Length >= 2)
                {
                    var userName = parts[0];
                    var lastEditDate = DateTime.Parse(parts[1]);
                    return (userName, lastEditDate);
                }
            }
            
            return (string.Empty, DateTime.MinValue);
        }
    }
    catch (Exception e)
    {
        UnityEngine.Debug.LogError($"最終編集者取得中にエラーが発生しました: {e.Message}");
    }
    return (string.Empty, DateTime.MinValue);
}


ツール開発によりもたらされた変化

ツール開発により以下の変化がもたらされました。

  1. キャラクターアセットのうちメモリサイズが大きいものを簡単に探せるようになった
  2. エンジニアがアセットの調査をしなくても潜在的な問題が自然と浮き出てくるようになった
  3. アセット最適化に意識が向きやすくなった

一つ目については、「全キャラの中で最もメモリを消費する上位数体を特定」したいという元々の要望に応えられたと思っています。どれだけキャラクターが増えても検索性が高く作られているので、いつでもこの問いにスムーズに答えることができます。

二つ目については、アプリがクラッシュしたり、動作が重くなっているといった報告が上がってからエンジニアが調査するという流れが現場で発生しがちですが、そうなる前に問題に気づきやすくなります。実際に、同じモデルだが演出上異なるキャラクターとして納品されているにも関わらずメモリサイズ推定値が大きく異なる二つのアセットが検出され、アーティストさんとエンジニアの相談結果、数十MBの削減を達成した事例がありました。この問題は、ツールを開発していなければおそらく誰も気づかないままリリースされていたので、良い影響を与えられたと感じています。

三つ目については、週1回の通知でも、どのキャラクターがどれくらい重いかが明確にわかりますし、リリース前・もうすぐリリースのキャラクターアセットは件数を多く表示したり、警告表示、最終編集者表示を追加するなど情報量を多く表示しているので、アセット製作中でも「他と比べて重そうだからリリース前に見直そう」という意識や、「このアセットはどうして大きくなっているのだろうか?」といった疑問から調査する契機を生み出しやすくなったと思います。

おわりに

今回製作したツールによって、メモリの問題が起きてから「どこが原因だろう?」と頭を悩ませながら調査する受け身の姿勢ではなく、みんなで数値を意識して問題を発見次第解消に取り組む、という攻めの姿勢に変えられたと思います。

この記事が、モバイルゲームのアセット肥大化やメモリ周りの問題に悩む開発者の皆様に、何か開発のヒントとなれば幸いです。