はじめに:非同期なのになぜ画面が止まるのか?
Unity開発におけるアセット管理のデファクトスタンダードであるAddressables Asset System (Addressables)。このシステムを導入する大きな目的の一つが、Addressables.LoadAssetAsyncを用いた「非同期ロードによるローディング画面の撤廃やプチフリーズ(スパイク)の回避」です。
しかし、設計通りに非同期APIを使用しているにもかかわらず、アセット読み込みを開始した瞬間に画面が一瞬ピタッと止まる現象に悩まされたことはないでしょうか。Profilerで確認すると、ロードAPIを呼んだフレームでメインスレッドの処理時間が跳ね上がっている(スパイクしている)のを目撃することがあります。
この謎を解き明かすための「例え話」をしてみましょう。
旅行に出かける際、衣服(アセット)を超圧縮の圧縮袋(LZMA)にギチギチに詰め込んで一つのスーツケースに入れたとします。旅先で「靴下1足」が必要になった場合、あなたはどうするでしょうか。袋のファスナーを全て開けて空気を入れ、中身を全て外に広げて(全体を解凍して)からでないと、靴下1足を取り出すことができません。これがLZMA圧縮です。
一方、整理整頓された引き出し付きのキャビネット(LZ4)を持っていたとします。靴下が必要なら、「靴下の引き出し(特定のチャンク)」だけを引いて中身を取り出せばよく、他の引き出しは触る必要すらありません。これがLZ4圧縮です。Unityが非同期ロードを行おうとしても、スーツケース全体を開ける作業(LZMA全体の解凍)はメインスレッドを長時間占有してしまうのです。
1. 根本的な原因:非同期ロードの裏で走る「LZMA解凍」の真実
UnityのAssetBundle(Addressablesの実体)の圧縮形式には、主にLZMAとLZ4の2種類が存在します。この2つの圧縮アルゴリズムの仕様と解凍時の動作こそが、非同期ロード中のメインスレッドスパイクを引き起こす最大の原因です。
| 圧縮形式 | 圧縮率(データサイズ) | 解凍時のメインスレッド負荷 | アセットロードの挙動 |
|---|---|---|---|
| LZMA | 極めて高い(サイズが最小) | 非常に高い(スパイクの原因) | バンドル全体を一度に一括解凍する必要がある |
| LZ4 | 中程度(LZMAより大きい) | 極めて低い(スパイクなし) | 必要なアセットが眠るチャンクのみをオンデマンド解凍 |
LZMAは「ストリームベース圧縮」と呼ばれる方式で、ファイル全体を一つの巨大なデータストリームとして圧縮します。このため、一部のアセットだけを読み込む場合でも、Unityはアセットバンドル全体のアーカイブを解凍・展開しなければなりません。
問題は、このLZMA解凍処理がI/Oスレッド(バックグラウンドスレッド)ではなく、メインスレッドをブロックする形で行われる点にあります。非同期ロードAPI(LoadAssetAsync)を呼び出すと、Unityは内部的に非同期処理のタスクを走らせますが、ファイルアーカイブのヘッダー解析や圧縮ファイルの整合性チェック、シリアライズ展開の初期処理の一部がメインスレッド上で同期的に実行されます。アセットバンドルのサイズが50MB〜100MBを超える大容量になっている場合、この解凍オーバーヘッドだけでメインスレッドが100ms〜500ms(数フレーム〜数十フレーム分)完全にフリーズし、プレイヤーには「画面のカクつき(スパイク)」として知覚されます。
2. 解決アプローチ:LZ4(Chunk-based)圧縮への移行手順
このロードスパイクを完全に解消するための最も効果的なアプローチが、Addressablesグループの圧縮設定を「LZMA」から「LZ4」へ変更することです。LZ4は「チャンクベース圧縮」を採用しており、ファイルを小さなブロック(チャンク)に分割してそれぞれ圧縮します。アセットをロードする際は、そのアセットが格納されている特定のチャンクだけを解凍するため、解凍に必要なメモリも時間も最小限に抑えられ、メインスレッドのスパイクが完全に消失します。
手順1:Addressables Groupsウィンドウでの設定変更
- Unityエディタのメニューから
Window > Asset Management > Addressables > Groupsを開きます。 - 対象のグループ(例:
Default Local Group)を選択し、Inspectorウィンドウを確認します。 Content Packing & Loadingセクション内の Bundle Compression 設定を見つけます。- 設定値を LZMA から LZ4(または
Uncompressed)に変更します。
LZ4に変更すると、アセットバンドルの物理ファイルサイズはLZMAに比べて1.2倍〜1.5倍程度大きくなります。ダウンロード容量(ビルド容量)を極限まで削りたいモバイルゲームでは懸念されることがありますが、ロード時のプレイヤーのストレス(カクつき)を考慮すると、基本的にはLZ4を使用すべきです。また、配信用のリモートグループ(Remote Group)のみダウンロードサイズ削減のためにLZMAを使用し、ロード前にバックグラウンドでローカルにLZ4キャッシュとして保存・解凍させておく設計もあります。
手順2:非同期ロードの安全な実装とキャッシュ管理
圧縮形式の変更に加え、C#コード側でも非同期ロードをスマートに制御し、ロードとインスタンス化(生成)の負荷を適切に切り離す必要があります。以下は、スパイクを防ぎつつアセットを安全に非同期ロード・キャッシュするための堅牢なマネージャクラスの実装例です。
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class AddressableAssetLoader : MonoBehaviour
{
// アセットの二重ロードを防ぐためのキャッシュ辞書
private Dictionary<string, AsyncOperationHandle> _loadedAssets = new Dictionary<string, AsyncOperationHandle>();
/// <summary>
/// アセットを非同期で安全にロードします(メインスレッドをブロックしません)
/// </summary>
public void LoadAssetSafe<T>(string address, Action<T> onComplete) where T : UnityEngine.Object
{
// すでにロード済みの場合はキャッシュから即時返却
if (_loadedAssets.TryGetValue(address, out AsyncOperationHandle existingHandle))
{
if (existingHandle.IsDone && existingHandle.Status == AsyncOperationStatus.Succeeded)
{
onComplete?.Invoke(existingHandle.Result as T);
return;
}
}
// 非同期ロード要求を開始
AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(address);
_loadedAssets[address] = handle;
handle.Completed += (op) =>
{
if (op.Status == AsyncOperationStatus.Succeeded)
{
// ロード完了時にコールバックをメインスレッドで実行
onComplete?.Invoke(op.Result);
}
else
{
Debug.LogError($"[Loader] アセットのロードに失敗しました: {address}");
_loadedAssets.Remove(address);
}
};
}
/// <summary>
/// アセットのメモリ解放処理
/// </summary>
public void ReleaseAsset(string address)
{
if (_loadedAssets.TryGetValue(address, out AsyncOperationHandle handle))
{
Addressables.Release(handle);
_loadedAssets.Remove(address);
Debug.Log($"[Loader] アセットをアンロードしました: {address}");
}
}
private void OnDestroy()
{
// シーン遷移時やオブジェクト破棄時に、ロードした全アセットを一括で安全に解放
foreach (var handle in _loadedAssets.Values)
{
Addressables.Release(handle);
}
_loadedAssets.Clear();
}
}
3. 実務で役立つ「ロードスパイク防止チェックリスト」
アセット管理周りの処理落ち対策として、実務リリース前に確認すべき重要ポイントをまとめました。
| チェック項目 | 推奨設定・アクション | 理由と効果 |
|---|---|---|
| ローカルグループの圧縮形式 | LZ4(Chunk-based)に統一 | 実機で解凍処理によるメインスレッドフリーズ(0.1s以上)を防ぐ。 |
| プレロード(温め)の実施 | バトルや重い演出が始まる前の静かなシーン(遷移中など)で事前ロード | 生成(Instantiate)やシェーダーコンパイルに必要な時間を、ゲームプレイに影響しないタイミングに隠蔽する。 |
| Shaderのプレコンパイル | Shader Variant Collectionを事前にロードして温める | アセットロード後に発生する「シェーダーコンパイル」による秒単位の重い処理落ち(Stall)を防ぐ。 |
| バンドルの細分化 | グループを機能やカテゴリー(3Dキャラ、SE、UI等)ごとに分割する | 無関係な大きなAssetBundleがメモリ上に乗るのを防ぎ、依存関係の循環を解決してロード・アンロードを高速化する。 |
まとめ:快適なローディングのために
非同期ロードAPIを使っているからといって安心せず、「アセットが裏でどのように解凍され、どのタイミングでメモリに展開されるか」という内部挙動(LZMA vs LZ4等)を常に意識することが重要です。
特にスマートフォンやスタンドアロンVRなどのサーマルスロットリング(熱暴走による性能低下)が起きやすい環境では、こうした解凍スパイクの蓄積がアプリの強制終了やカクつき、ひいてはユーザー満足度の低下に直結します。適切なグループ設定と非同期マネージャクラスのキャッシュを活用し、ストレスのない快適なアセットロード環境を構築しましょう。