🚀

HttpClient と YetAnotherHttpHandler で AssetBundle のダウンロードを HTTP/2 化してみる

2024/09/11に公開

はじめに

Addressables を利用しつつ、AssetBundle のダウンロードを HTTP/2 化することで、高速化できるような仕組みを考えたいと思います。

前提

Addressables では BundledAssetGroupSchemaAssetBundleProviderType に設定されている 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 で高速化できるところを目指してみたいと思います。

検証環境

CDN の HTTP/2 有効化

今回の検証では、AssetBundle の配信に Amazon S3Amazon CloudFront を使用します。
CloudFront の設定から HTTP/2 を有効化しておきます。

実装

Addressables そのものの使い方は大きく変更せず、Addressables.LoadAssetAsync<T>() でアセットをロードするインターフェースはそのままとし、HTTP/2 で AssetBundle をダウンロードできるような実装をしてみます。

IResourceProvider

Addressables デフォルトの AssetBundleProvider を参考に、IResourceProvider を実装します。
今回は検証なので、ResourceManegerIAssetBundleResource との橋渡しをすることだけに徹した簡単な実装となっています。

[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 化してみました。

今回は検証用で詳細な作り込みまでは実装できていませんが、IResourceProviderIAssetBundleResource を独自実装することで、Addressables そのものの使用感は大きく変更することなく、HTTP/2 化が実現できたのではないかと思います。

単一の Texture などをダウンロードする場合は、そこまで大きな違いはなさそうですが、依存関係が複雑なものをロードしたい場合は、かなりの効果が期待できるそうなので、総合的に見ても HTTP/2 化するメリットはありそうです。
また、今回の検証では実施していませんが、実運用では意識したい「アセット一括ダウンロード」のような機能も、HTTP/2 化するメリットは大きいのかなと考えているため、別途検証してみたいと思います。

Happy Elements

Discussion