HttpClient と YetAnotherHttpHandler で AssetBundle のダウンロードを HTTP/2 化してみる
はじめに
Addressables を利用しつつ、AssetBundle のダウンロードを HTTP/2 化することで、高速化できるような仕組みを考えたいと思います。
前提
Addressables では BundledAssetGroupSchema
の AssetBundleProviderType
に設定されている IResourceProvider
の実装に従って、AssetBundle をロード・アンロードします。
Addressables のデフォルトでは、AssetBundleProvider
という実装が設定されており、内部的に AssetBundleResource
という実装を利用して、UnityWebRequestAssetBundle.GetAssetBundle()
から UnityWebRequest
を生成し、AssetBundle をロードしています。
Addressables.WebRequestOverride
を設定している場合、このタイミングでフックされます。
ここで問題となるのが UnityWebRequest
で、現状 HTTP/1.1 のみのサポートとなるため、HTTP/2 のようにストリームの多重化を行ってダウンロードを高速化することは難しそうです。
一応、WebRequestQueue
の設定で UnityWebRequest
の最大並列数を増やすことは可能ですが、あまりやりすぎると TCP コネクション数が増えてしまい、最悪クラッシュなどに繋がる可能性もあります。
そこで、Addressables の AssetBundleProvider
は、IResourceProvider
を実装できていれば任意のものに差し替えできるようになっているため、今回は、可能な限り Addressables の仕組みに乗りつつ、AssetBundle のダウンロード部分の実装を差し替えて、いい感じに HTTP/2 で高速化できるところを目指してみたいと思います。
検証環境
- Unity 6000.0.10f1 (Windows)
- Addressables 2.1.0
- YetAnotherHttpHandler 1.5.3
https://github.com/Cysharp/YetAnotherHttpHandler - UniTask 2.5.5
https://github.com/Cysharp/UniTask - Amazon S3 + Amazon CloudFront
CDN の HTTP/2 有効化
今回の検証では、AssetBundle の配信に Amazon S3
と Amazon CloudFront
を使用します。
CloudFront
の設定から HTTP/2
を有効化しておきます。
実装
Addressables そのものの使い方は大きく変更せず、Addressables.LoadAssetAsync<T>()
でアセットをロードするインターフェースはそのままとし、HTTP/2 で AssetBundle をダウンロードできるような実装をしてみます。
IResourceProvider
Addressables デフォルトの AssetBundleProvider
を参考に、IResourceProvider
を実装します。
今回は検証なので、ResourceManeger
と IAssetBundleResource
との橋渡しをすることだけに徹した簡単な実装となっています。
[DisplayName("HTTP2 AssetBundle Provider")]
public sealed class Http2AssetBundleProvider : IResourceProvider
{
public static readonly string Identifier = typeof(Http2AssetBundleProvider).FullName;
public string ProviderId => Identifier;
public ProviderBehaviourFlags BehaviourFlags => ProviderBehaviourFlags.None;
public bool CanProvide(Type type, IResourceLocation resourceLocation) => GetDefaultType(resourceLocation).IsAssignableFrom(type);
public void Provide(ProvideHandle provideHandle) => Http2AssetBundleResource.Load(ref provideHandle);
public void Release(IResourceLocation _, object instance) => Http2AssetBundleResource.Unload(instance);
public Type GetDefaultType(IResourceLocation _) => typeof(IAssetBundleResource);
}
IAssetBundleResource
Addressables デフォルトの AssetBundleResource
を参考に、IAssetBundleResource
を実装します。
HttpClient を使用してダウンロードしてきた AssetBundle のデータをローカルのディスクに保存し、それを AssetBundle.LoadFromFileAsync()
経由でロードします。
すでにローカルにダウンロード済の場合は、AssetBundle.LoadFromFileAsync()
でロードしますが、CRC の検証に失敗した場合は、再ダウンロードを行うようにしています。
今回は検証なので、実運用では考慮しておきたいリトライ処理やエラー処理などは実装せず、簡単な実装となっています。
public sealed class Http2AssetBundleResource : IAssetBundleResource
{
private static readonly Lazy<HttpClient> _HttpClient = new Lazy<HttpClient>(static () => CreateHttpClient(), true);
private static HttpClient HttpClient => _HttpClient.Value;
public static readonly string AssetBundleCacheDirectoryPath = Path.Combine(Application.persistentDataPath, "AssetBundle");
private ProvideHandle provideHandle = default;
private AssetBundle assetBundle = default;
private readonly CancellationTokenSource cancellationTokenSource = default;
private Http2AssetBundleResource(ref ProvideHandle provideHandle)
{
this.provideHandle = provideHandle;
this.cancellationTokenSource = new CancellationTokenSource();
}
public static void Load(ref ProvideHandle provideHandle)
{
var instance = new Http2AssetBundleResource(ref provideHandle);
instance.LoadAsync().Forget();
}
public static void Unload(object target)
{
if (target is not Http2AssetBundleResource instance)
{
return;
}
instance.Unload();
}
public AssetBundle GetAssetBundle() => assetBundle;
private async UniTask LoadAsync()
{
try
{
var resourceLocation = provideHandle.Location;
var cancellationToken = cancellationTokenSource.Token;
// RequestOptions取得
if (resourceLocation.Data is not AssetBundleRequestOptions assetBundleRequestOptions)
{
throw new Exception($"Invalid AssetBundleRequestOptions");
}
// ファイル名取得
var fileName = resourceLocation.PrimaryKey;
// CRC取得
var crc = assetBundleRequestOptions.Crc;
// ダウンロード済AssetBundleロード
var assetBundleCacheFilePath = Path.Combine(AssetBundleCacheDirectoryPath, fileName);
if (File.Exists(assetBundleCacheFilePath))
{
assetBundle = await AssetBundle.LoadFromFileAsync(assetBundleCacheFilePath, crc).WithCancellation(cancellationToken);
}
// ダウンロード
if (assetBundle == null)
{
// リモートファイル名取得
var url = provideHandle.ResourceManager.TransformInternalId(resourceLocation);
var data = default(byte[]);
await using (_ = UniTask.ReturnToMainThread())
{
// ファイルダウンロード
using var response = await HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
// ファイル保存
await File.WriteAllBytesAsync(assetBundleCacheFilePath, data, cancellationToken).ConfigureAwait(false);
}
// AssetBundleロード
assetBundle = await AssetBundle.LoadFromMemoryAsync(data, crc);
}
// 完了処理
provideHandle.Complete(this, true, null);
}
catch (Exception exception)
{
provideHandle.Complete<Http2AssetBundleResource>(null, false, exception);
}
}
private void Unload()
{
if (cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
cancellationTokenSource.Dispose();
}
if (assetBundle != null)
{
assetBundle.Unload(true);
assetBundle = null;
}
}
private static HttpClient CreateHttpClient()
{
var httpHandler = new YetAnotherHttpHandler();
httpHandler.Http2Only = true;
return new HttpClient(httpHandler);
}
}
検証
AssetBundle ビルド設定
Addressables デフォルトの AssetBundleProvider
と、上記で実装した Http2AssetBundleProvider
のそれぞれで AssetBundle をビルドします。
それぞれでビルドすると、AssetBundle のファイル自体に差分はなく、カタログデータに差分がでます。
検証用アセット
Textrure
依存関係もない単純な Texture ですが、そこそこ大きめのものを使用しています。
Prefab
依存関係が多く、そこそこ複雑なものを使用しています。
パフォーマンス検証
今回実装した Http2AssetBundleProvider
が動作してアセットが正常にロードできることを確認した上で、AssetBundleProvider (HTTP/1.1 UnityWebRequest)
と Http2AssetBundleProvider (HTTP/2 HttpClient)
でどの程度の速度差になるのか、Texture を100個と、Prefab を10個用意し、単純な直列ロードと、並列でまとめてロードするような処理で検証したいと思います。
また、WebRequestQueue
の最大並列数の設定を変更し、どの程度影響があるのかも検証したいと思います。
検証用プログラム
初期化処理などは割愛しますが、基本的には以下のように Addressables.LoadAssetAsync<T>()
でアセットをロードしていきます。
今回、AssetBundleProvider
でビルドしたカタログと、Http2AssetBundleProvider
でビルドしたカタログのそれぞれを Addressables にロードしている関係で、ひとつのキーに対して複数の ResourceLocation が存在することになるため、明示的に ProviderId
から ResourceLocation を検索して、ロードするようにしています。
直列ロード
foreach (var assetPath in assetPaths)
{
// ResourceLocation取得
var resourceLocations = await Addressables.LoadResourceLocationsAsync(assetPath, typeof(T));
var resourceLocation = resourceLocations.FirstOrDefault(x => x.Dependencies[0].ProviderId == providerId);
// ロード
var handle = Addressables.LoadAssetAsync<T>(resourceLocation);
await handle.WithCancellation(cancellationToken);
// 解放
Addressables.Release(handle);
}
並列ロード
var tasks = new List<UniTask>();
foreach (var assetPath in assetPaths)
{
// ResourceLocation取得
var resourceLocations = await Addressables.LoadResourceLocationsAsync(assetPath, typeof(T));
var resourceLocation = resourceLocations.FirstOrDefault(x => x.Dependencies[0].ProviderId == providerId);
tasks.Add(UniTask.Defer(() => LoadAssetAsync(resourceLocation, cancellationToken)));
}
// ロード
await UniTask.WhenAll(tasks);
static async UniTask LoadAssetAsync(IResourceLocation resourceLocation, CancellationToken cancellationToken)
{
// ロード
var handle = Addressables.LoadAssetAsync<T>(resourceLocation);
await handle.WithCancellation(cancellationToken);
// 解放
Addressables.Release(handle);
}
TCP コネクション数計測
UnityEditor 限定ですが、下記のような実装で PC 内で有効な TCP コネクション数を取得し、コネクション数の目安を簡易的に確認できるようにしています。
var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
var activeTcpConnections = ipGlobalProperties.GetActiveTcpConnections();
var connectionCount = activeTcpConnections.Count(static x => x.State is TcpState.Established && !IPAddress.IsLoopback(x.RemoteEndPoint.Address));
UnityEditor
-
WebRequestQueue 最大並列数3 (デフォルト)
-
WebRequestQueue 最大並列数8
-
WebRequestQueue 最大並列数無制限
Android (Pixel 7)
-
WebRequestQueue 最大並列数3 (デフォルト)
-
WebRequestQueue 最大並列数8
-
WebRequestQueue 最大並列数無制限
結果
Texture のロードについては、依存関係がなく、単一の AssetBundle のダウンロードになるため、HTTP/1.1 でも HTTP/2 でもそこまで大きな差はなさそうな結果となりました。
しかし Prefab のように依存関係があり、複数の AssetBundle をダウンロードする必要がある場合、1回のオペレーションの中で並列数が稼げると非常に優位な結果となっています。
WebRequestQueue
の最大並列数を上げることで、AssetBundleProvider
でも速度を上げることが期待できそうですが、Http2AssetBundleProvider
より若干遅い結果で、どこまで最大並列数を上げるか別途検討も必要そうです。
おわりに
ということで、HttpClient と YetAnotherHttpHandler を利用して、Addressables の AssetBundle ダウンロードを HTTP/2 化してみました。
今回は検証用で詳細な作り込みまでは実装できていませんが、IResourceProvider
や IAssetBundleResource
を独自実装することで、Addressables そのものの使用感は大きく変更することなく、HTTP/2 化が実現できたのではないかと思います。
単一の Texture などをダウンロードする場合は、そこまで大きな違いはなさそうですが、依存関係が複雑なものをロードしたい場合は、かなりの効果が期待できるそうなので、総合的に見ても HTTP/2 化するメリットはありそうです。
また、今回の検証では実施していませんが、実運用では意識したい「アセット一括ダウンロード」のような機能も、HTTP/2 化するメリットは大きいのかなと考えているため、別途検証してみたいと思います。
Discussion