LoginSignup
30
24

More than 1 year has passed since last update.

AddressableでAssetBundleを暗号化して扱う

Last updated at Posted at 2022-01-18

はじめに

Addressable Asset System(以下Addressable)がリリースされてしばらく経ち、普通にAddressableを利用する事例はそこそこ見るようになりました。
今回は、デフォルトのAddressableの仕組みではカバーされていないものの一例として「AssetBundleの暗号化」を取り上げます。
Addressableで暗号化を扱う具体例としてだけでなく、Addressableのオレオレカスタマイズを行うヒントとしてご活用いただければ嬉しいです。

なお、本記事で行なった作業内容は以下のリポジトリに上がっています。

環境

Unity2020.3.17f1
Addressable v.1.19.13

想定する読者層

  • AssetBundleはなんとなくわかる(とりあえず作れる、使える)人
  • AssetBundleを暗号化したい人
  • AssetBundleのプロになるとお金になると聞いた人
  • Addressableについて、何か嬉しいかなんとなくわかっている人
  • Addressableをカスタマイズしたい人
  • Addressableで暗号化AssetBundleを扱いたい人

本記事ではAddressableやAssetBundleに関する基本的な解説は行っておりません。
それぞれ、深い知識は必要ありませんが、何のためにあるのかくらいの前提知識はあると読みやすいと思われます。

AssetBundleやAddressableに関しては、私も参考にさせていただいた素晴らしい解説が沢山あり、以下の記事をここでは挙げておきます。

AssetBundleに関してはそこまで大きな変更がないので多少(2,3年)古い記事でも参考にして大丈夫だと思いますが、Addressableはそうでもありません。Addressableに関する情報は、可能な限り新しいバージョンで書かれたものを参考にするのが良いと思います。「◯◯はできない」と記事に書かれていても、実は記事が古く、現在ではできる、ということがあるかもしれません。
特に、本記事で扱う「Addressableで暗号化AssetBundleを扱う方法」については、記事執筆時点では、調べても、どうやらそれは難しそうだ、大変な作業が必要そうだという情報が多く出てくるのですが、現バージョンではそこまで難しくない方法で可能であるため、その一例となっているかと思います。

前置きが長くなってしまいましたが、それでは実際にAddressableでAssetBundleを暗号化して扱うために必要な手順を解説していきます。

第一章 AssetBundleを暗号化/復号する

AddressableはAssetBundleを扱う際の面倒ごとを引き受けてくれる存在であり、扱う対象自体はこれまで私たちが使ってきたAssetBundleと変わりません。
ですので、Addressableで暗号化を行うことを考えるには、AssetBundleをどのように暗号化するかを考える必要があります。

まずはAddressableとは無関係に、AssetBundleそのものの暗号化/復号について明確にしましょう。

AssetBundleを読み込む3つのメソッド

AssetBundleを実際に暗号化、復号する具体的方法を検討するため、AssetBundleを読み込み方法にどのようなものがあるかを知る必要があります。
AssetBundleを読み込むメソッドは

  • LoadFromFile
  • LoadFromMemory
  • LoadFromStream

の3つがあります。(これに加え、同期、非同期の区別はあります。)
それぞれ簡単に特徴を解説します。

LoadFromFile

LoadFromFileは以下のように使用します。

// ここではAssetBundleのメタ情報だけがメモリに読み込まれる
var assetBundle = AssetBundle.LoadFromFile("アセットバンドルファイルのパス");
// ここでは、指定したアセットだけがメモリに読み込まれる
var texture = assetBundle.LoadAsset<Texture2D>("アセット名");

不要なメモリ確保が無く、また公式に、最速でAssetBundleを読み込む方法だと断言されています。
ただし、暗号化されたAssetBundleには使用できません
(暗号化されてしまうと、Unityから見ればただの謎のバイナリなため当然)
LoadFromFileを使用しながら暗号化も施したいならば、
AssetBundleを暗号化した状態で配信して、ゲーム内で必要になる前に復号して、復号したものをストレージに置いておく、などの対応が必要になるでしょう。

LoadFromMemory

LoadFromMemoryは以下のように使用します。

var binary = File.ReadAllBytes("アセットバンドルファイルのパス");
var assetBundle = AssetBundle.LoadFromMemory(binary);
var texture = assetBundle.LoadAsset<Texture>("アセット名");

ReadAllBytesは一例ですが、それに類する何らかの方法でまずバイト列として.bundle全体をメモリに載せ、そこから読み込みます。
テクスチャなどはアンマネージドメモリ(Profilerで"Unity"と書かれている部分)にだけ置いておきたいはずが、マネージドメモリ(Profilerで"Mono"と書かれている部分)にも一時的に乗るし、今はテクスチャだけ読み込みたいのに、同じAssetBundleに同梱されている他のアセットの分のメモリも一緒に確保されるしで、メモリには優しくないです。
一方で、AssetBundleを思い切ってただのバイト列として扱っているため、暗号化には柔軟に対応できます。
これを使用する場合は、暗号化を施すかどうかに関わらず、1アセット1AssetBundleで運用するなどして、メモリ面のリスクの軽減は検討した方がいいでしょう。

LoadFromStream

LoadFromStreamは以下のように使用します。

var fileStream = new FileStream("アセットバンドルファイルのパス", FileMode.Open, FileAccess.Read);
// ここではAssetBundleのメタ情報だけがメモリに読み込まれる
var assetBundle = AssetBundle.LoadFromStream(fileStream);
// ここでは、指定したアセットだけがメモリに読み込まれる
var texture = assetBundle.LoadAsset<Texture>("アセット名");

LoadFromFileと比較し、Streamを扱う必要が出てきます。
平文のAssetBundleを扱う場合は完全にLoadFromFileに劣っていますが、LoadFromStreamは暗号化されたAssetBundleを使用する際に真価を発揮します。

LoadFromMemoryがどうしてもメモリ的に不利な処理を強制されるところが、LoadFromStreamであれば暗号化していてもその点大丈夫なやり方があるわけです。

では、これらの特徴を実際に確認してみます。

検証

準備

適当な音声ファイルを20個用意しました。全てmp3のときに総容量は98.6MB、平均して1ファイル5MBで、サイズに極端に大きな差異はありません。(だいたい4~7MB)
これをインポート後、LZ4圧縮で1つのAssetBundleにビルドしました。(平文AssetBundleと呼びます)
さらに、その上で暗号化Streamで暗号化したものを用意しました。(暗号文AssetBundleと呼びます)
この暗号化の手法には、先ほどの記事をそのまま流用させてもらいました。

平文AssetBundle、暗号文AssetBundle共にサイズは85.2MBでした。
また、アプリはWindows向けにデバッグビルドし、Profilerでメモリ状況を確認しました。

検証

LoadFromFile, LoadFromStream, LoadFromMemoryを用いた3つの検証を行いました。
それぞれ、1.AssetBundleを読み込む段階と、その後2.LoadAssetでAudioClipを1つ読み込む段階でメモリの使用状況を確認し、UsedMemoryの変化を記録しました。
LoadFromStreamのみ暗号文AssetBundleを使用し、他は平文AssetBundleを使用しています。
LoadFromMemoryは0.ReadAllBytesを事前に行うため、そこも確認しています。

結果

単位:MB

LoadFromFile LoadFromStream LoadFromMemory
0.ReadAllBytes Mono: 7 -> 88
Unity: 110 -> 110
1.AssetBundle Mono: 7 -> 7
Unity: 110 -> 110
Mono: 7 -> 7
Unity: 110 -> 110
Mono: 88 -> 88
Unity: 110 -> 191
2.AudioClip Mono: 7 -> 7
Unity: 110 -> 116
Mono: 7 -> 7
Unity: 110 -> 116
Mono: 88 -> 88
Unity: 191 -> 197

暗号化/復号に関しての結論

結果から、確かに暗号化されたAssetBundleの読み込みはLoadFromStreamで行うのがメモリ的には良さそうです。
また、暗号化はテラシュールブログさんで提案されている方法を用います。
平文AssetBundleは公式に最速と断言されているLoadFromFileで行えば良さそうです。

補足:暗号化したらAssetBundleデフォルトのキャッシュ機能が使えない件

AssetBundleを暗号化するのではなく、AssetBundleに含むアセットをまず暗号化してTextAssetとして扱い、その後AssetBundleとしてビルドする、という考えもあります。
この場合も、Addressableでビルド、暗号化、読み込む際に必要な知識は変わりません。
「ビルドして暗号化」が「暗号化してビルド」に、「復号して読み込み」が「読み込んで復号」に、処理の順序が変わるだけです。
この方法のメリットとして、"AssetBundle"を暗号化しているわけではないため、次章で解説する「Provider内部におけるAssetBundleの取得」において、UnityWebRequestAssetBundleを使用して、AssetBundleデフォルトのキャッシュ機能を使用することができるのではないかと思っています。
残念ながら、自分の経験上キャッシュは普通に自作していたのでこちらの使用経験がなく、説明をすることができないのですが、デフォルトのキャッシュ機能を使いたい!という場合は「単一アセットを暗号化してから、それをAssetBundleとしてビルド」を検討してもよいのかなと思います。
アセット同士の依存関係とか正しく反映されるのかなとかやや不安なところはありますが、その辺クリアできるなら十分検討の価値はありそうです。

参考

次は、暗号化されたAssetBundleを読み込む方法を解説します。

第二章 暗号化されたAssetBundleをAddressableの仕組みで読み込む

普通のAddressableのAssetBundle読み込み

AddressableのAssetBundle読み込みは、以下のように行えます。

[SerializeField] private RawImage _rawImage;
var assetLoader = Addressables.LoadAssetAsync<Texture2D>("欲しいテクスチャのアドレスやラベル");
assetLoader.Completed += op =>
{
    _rawImage.texture = op.Result;
};

この書き方で、AssetBundleが今サーバにあろうがローカルにあろうが関係無く取得できる、というのが、Addressableの強みの一つです。
今回は、この書き方を変えずに、LoadAssetAsyncの中の処理を差し替えることで、AssetBundleの所在だけでなく、暗号文であることも意識せずに読み込む方法を解説します。

AddressableがAssetBundleを読み込む処理の本体はProvider

AddressableでビルドされたAssetBundleがどのように読み込まれるかは、使用するProviderと呼ばれるスクリプトで決定されます。
使用するProviderは、Addressable GroupのScriptableObjectで、Groupごとに切り替えることができます。

このScriptableObjectをInspectorで見ると
Provider.png
AssetBundleProviderという項目がありますね。ここでProviderを設定できます。
Providerに関しては以下の記事を参考にしました。

デフォルトのAssetBundleProviderも参考にし、読み込み部分をLoadFromStreamに置き換えます。

暗号化されたAssetBundleを復号しつつ読み込むProvider

以下、自作Providerの全文となります。

using System;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.ResourceManagement.ResourceLocations;
using UnityEngine.ResourceManagement.ResourceProviders;

/// <summary>
/// 暗号化されたAssetBundleからアセットを読み込むProvider
/// </summary>
public class CustomAssetBundleResource : IAssetBundleResource
{
   private AssetBundle assetBundle;
   private AsyncOperation requestOperation;
   private ProvideHandle provideHandle;
   private AssetBundleRequestOptions options;
   private SeekableAesStream assetBundleLoadStream;
   const string password = "password";

   /// <summary>
   /// 初期化する
   /// </summary>
   public void Setup(ProvideHandle handle)
   {
       assetBundle = null;
       downloadHandler = null;
       provideHandle = handle;
       options = provideHandle.Location.Data as AssetBundleRequestOptions;
       requestOperation = null;
       provideHandle.SetProgressCallback(GetProgress);
   }

   /// <summary>
   /// ロード・ダウンロードする
   /// </summary>
   public void Fetch()
   {
       var path = provideHandle.ResourceManager.TransformInternalId(provideHandle.Location);
       if (File.Exists(path))
       {
           // 暗号化したAssetBundleを取得
           DecryptAndLoadAssetBundle();
           return;
       }

       // ローカルに無い場合にサーバから取ってくる
       // 暗号化されたAssetBundleを適当なサーバに置いて、普通に取ってくる
       var uwp = new UnityWebRequest("AssetBundleダウンロードURL");
       var handler = new DownloadHandlerFile(path);
       handler.removeFileOnAbort = true;
       uwp.downloadHandler = handler;
       var req = uwp.SendWebRequest();
       req.completed += _ =>
       {
           if (!File.Exists(path)) return;

           // 暗号化したAssetBundleを取得
           DecryptAndLoadAssetBundle();
       };


       // ローカルにあったときも、サーバから落としてきた時も通る処理
       // 復号して、AssetBundleを読み込む
       void DecryptAndLoadAssetBundle()
       {
           var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read);
           var bundleName = Path.GetFileNameWithoutExtension(path);
           var uniqueSalt = Encoding.UTF8.GetBytes(bundleName); // AssetBundle名でsaltを生成
           // Streamで暗号化を解除しつつAssetBundleをロードする
           assetBundleLoadStream = new SeekableAesStream(fileStream, password, uniqueSalt);
           requestOperation = AssetBundle.LoadFromStreamAsync(assetBundleLoadStream);
           requestOperation.completed += op =>
           {
               assetBundle = (op as AssetBundleCreateRequest).assetBundle;
               provideHandle.Complete(this, true, null);
           };
       }
   }

   /// <summary>
   /// アンロードする
   /// </summary>
   public void Unload()
   {
       if (assetBundle != null)
       {
           assetBundle.Unload(true);
           assetBundle = null;
       }

       assetBundleLoadStream?.Dispose();
       requestOperation = null;
   }

   /// <summary>
   /// ロード・ダウンロードされたAssetBundleを取得する
   /// </summary>
   public AssetBundle GetAssetBundle() => assetBundle;

   /// <summary>
   /// ロード・ダウンロード進捗を取得する
   /// </summary>
   private float GetProgress() => requestOperation?.progress ?? 0.0f;
}

[System.ComponentModel.DisplayName("Custom AssetBundle Provider")]
public class CustomAssetBundleProvider : ResourceProviderBase
{
   /// <summary>
   /// ProvideHandleに入っている情報が示すAssetBundleを読み込む処理
   /// </summary>
   public override void Provide(ProvideHandle providerInterface)
   {
       var res = new CustomAssetBundleResource();
       res.Setup(providerInterface);
       res.Fetch();
   }

   /// <summary>
   /// 読み込み結果としてAssetロード用のProviderに渡すための型を返却
   /// Assets from Bundles Providerを使う場合にはIAssetBundleResourceを指定
   /// </summary>
   public override Type GetDefaultType(IResourceLocation location) => typeof(IAssetBundleResource);

   /// <summary>
   /// 解放処理
   /// </summary>
   public override void Release(IResourceLocation location, object asset)
   {
       if (location == null) { throw new ArgumentNullException(nameof(location)); }

       if (asset == null)
       {
           Debug.LogWarningFormat("Releasing null asset bundle from location {0}.  This is an indication that the bundle failed to load.", location);
           return;
       }
       if (asset is CustomAssetBundleResource bundle) { bundle.Unload(); }
   }
}

Addressableのメモリ管理に関する部分を除けば、ごく普通のAssetBundleの読み込みスクリプトです。

このスクリプトにはエラーハンドリングが無かったり、事前にダウンロードする仕組みも別途必要かなとは思いますが、ともあれこれで、暗号化AssetBundleを読み込むProviderが完成しました。

ところで、AssetBundleProviderの上にAssetProviderなるものもありますが、
Provider.png
今回はこれを変更する必要はありませんでした。

実際に読み込む

ここまで出来たら、普通のAddressableの使い方でアセットを読み込めば終了です。
Providerを自作しようがしまいが、ここは変わりません。

public class LoadStarter : MonoBehaviour
{
    [SerializeField] private RawImage _rawImage;

    private void Start()
    {
        // UniTaskを入れてAddressable Supportを有効にするとawaitできるようになって幸せになれます
        var assetLoader = Addressables.LoadAssetAsync<Texture2D>("欲しいテクスチャのアドレス");
        assetLoader.Completed += op =>
        {
            _rawImage.texture = op.Result;
        };
    }
}

暗号化されたAssetBundleを、暗号化されていると意識することなく読み込むことができます。
githubに上げたサンプルには暗号化AssetBundleと平文AssetBundleを混在させていますので、是非そちらで、どちらも同じように読み込めることをご確認ください。

では最後に、Addressableで管理しているアセットを、Addressableの仕組みで、暗号化も施しつつビルドする方法を解説します。

第三章 Addressableの仕組みでビルドしつつ、AssetBundleの暗号化を行う

普通にAddressableでビルドする

まずは、暗号化のことは考えず、普通にAddressableを使っている場合はどのようにビルドするのかを解説します。
Addressableのビルドは、Window > Asset Management > Addressables > Groupsで開けるエディタから行えます。
このエディタはビルドの他にも、Addressを与えられているアセットを管理する、Addressableの中心的なエディタとなっているので、頻繁に見ることになると思います。
AddressableAssetGroups.png
ビルドするには、適当にAssetを追加し、Buildボタンから、Default Build Scriptを選びましょう。

スクリプトでビルドする

スクリプトからは、以下の書き方でビルドできます。シンプルですね。

AddressableAssetSettings.BuildPlayerContent();

暗号化を考慮してAddressableでビルドする

上記メソッドでビルドできるのならば、これを使って生成されたAssetBundleを引き続き暗号化すればいいのかなと思うのですが、このBuildPlayerContentというメソッドは戻り値がvoidで、生成されたAssetBundleを取得できません。

なので、別の方法を検討する必要があります。
ここで、AddressableAssetSetting.assetを見てみましょう。
デフォルトから移動させていなければここにあります。
setting.png
AddressableAssetSettingに以下のような設定項目があり、ビルドスクリプトを差し替えることが可能となっています。
BuildScript.png

この中のDefault Build Scriptが、普通にAddressableでビルドしていたときに呼ばれていた処理となります。
なお、他のUse Asset Databaseなどは、「ビルドしなくてもエディタ上では動作できるようにする」みたいな、開発時に楽をするためのシミュレーション用のスクリプトですので、そこに手を入れる気が無いなら触らないようにしましょう。
ではここに入れる、自作の、「暗号化も行うビルドスクリプト」を作成していきます。
今回作りたいのは「基本的にAddressableの仕組みに従ってビルドするけど、最後に暗号化を施す」ビルドスクリプトですので、
デフォルトの、Default Build Scriptとして設定されているBuildScriptPackedModeなるスクリプトをコピペして、ちょこっと暗号化処理を差し込んで自作したと言い張りましょう。

ビルドしつつ暗号化を行うスクリプト

スクリプトが非常に長いため(1000行くらいある)、スクリプト本体は以下のリンクを参照ください。

大部分はDefault Build Scriptとして利用されている、BuildScriptPackedMode.csをコピペしたものです。しかし、一部internalなメソッドやプロパティを参照しているところがあるため、ちょっと書き換えます。

一応重要な部分にはコメントを入れていますが、なにぶん長い上に、追記内容そのものよりは、どこに追記したかが重要なので、私のスクリプトとBuildScriptPackedMode.csの差分を見ていただくのが良いかなと思います。
差分を見るには、手軽さで言えば以下のサイトなどはオススメです。

BuildScriptPackedMode.csからの変更点は、クラス名やエディタでの表示名を変更したり、
Addressableの設定に従ってビルドして、その後出来上がったAssetBundleに暗号化を施しているというだけです。

ちょっと工夫したポイントとしては、Providerを見て、平文でいいAssetBundleには何もしないというところです。何でもかんでも暗号化してしまってもいいかもしれないんですが、平文でいいなら平文で扱った方が楽なのは間違いありませんから、必要なところだけ暗号化する、みたいなのも簡単にできるのは嬉しいですね。

スクリプトをAddressableのビルドスクリプトとして登録する

さて、スクリプトを記述できたら、ビルドスクリプトのScriptableObjectを作成し、
CreateBuilder.png

AddressableAssetSettingに登録しましょう。
SetBuilder.png

Default Build Scriptを差し替えてもよかったんですが、一旦追加してみました。
そうすることで、暗号化も行うビルドスクリプトがAddressablesGroupsからも選択できるようになりました。
SelectBuilder.png

もちろん、追加ではなく差し替えにすることで、

AddressableAssetSettings.BuildPlayerContent();

で実行されるビルド処理を暗号化を行うものに差し替えることも可能です。
例えばJenkinsからAssetBundleのビルドを指示するような場合はスクリプトから処理を呼べた方が便利ですから、こちらを選択すると良いでしょう。

これで、暗号化されたAssetBundleを、普通のAssetBundleと同じようにビルドすることができるようになりました。

まとめ

以上で、AddressableでAssetBundleを暗号化して扱う方法についての解説を終わります。
手順を簡単にまとめますと

  • 暗号化AsseetBundleはLoadFromStreamで読み込み、それに合わせて暗号化手法を選ぶ。
  • Addressableのビルドシステムは自作ビルドシステムに差し替えることができるので、既存のスクリプトをちょっとだけ変更した暗号化ビルドを自作して差し替える。
  • 読み込みはProviderが行うため、復号しつつ読み込みを行うProviderを自作して、GroupのProviderとして設定する。

となります。

重ねてになりますが、これらを実際に行ったものをgithubに上げておりますので、そちらをご覧いただき、実際にご自身の手でいじってみることで、より理解が深まるのではないかと思います。

長くなってしまいましたが、最後までお読みくださり、ありがとうございました。

30
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
24