【VRChat】ImageDownloaderを使ってSkyboxを読み込むことでワールド容量を削減する

# 概要
VRChatのWorldSDKにデフォルトで含まれているImageDownloaderを使って、Skyboxを自分で用意したGitHubPagesから読み込む仕組みを作ったという記事です。
これによりワールド容量を1/3に減らすことができました。

https://x.com/VRC_Sakk/status/1685961686065483776

### 注意
ImageDownloaderの基本的な使い方には触れないので、[ハツェさんの記事](https://hatuxes.hatenablog.jp/entry/2023/02/25/164403)などを参照してください。
また、これは表示する画像を固定する想定の手法です。（一応変えられはしますがそれを主眼に置いてないです）
表示するものを自動で切り替えていくギミックについては[なまのなまこさんの記事](https://qiita.com/namanonamako/items/f7280a8cba2f941a5ecd)を参照してください。
そして、これは一般に勧められる手法ではなく、大量のSkyboxを切り替えて表示する場合にのみ有効と思われます。下記メリット・デメリットを比較してからやってみてください。

**メリット**
- 複数のSkyboxを切り替える必要がある場合、ワールド容量を大幅に削減できる

**デメリット**
- 読み込みに時間がかかる（読み込み完了まで最短25秒)
- メモリ使用量が結構増える
- 画像がWeb上に公開される(権利上問題ないものを使いましょう)

# 経緯
自分は[Scanned_Summits](https://vrchat.com/home/launch?worldId=wrld_8e9b6333-da54-4966-8d8f-a041467f98ba)という、登った山の山頂標のLiDARスキャンモデルと360度画像によるSkyboxを組み合わせたワールドを公開しています。

![VRChat_2023-12-02_22-59-48.275_3840x2160.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/f9e27a32-fee5-5b39-7348-c04b0d1715cb.png)


山ごとに3DモデルとSkyboxのセットがあり、追加していくうちにあっという間にQuest版で100MBを超えてしまいました。
これはSkyboxで使っているCubeMapは圧縮が効きづらいせいで、ASTCの8×8にしていても2Kサイズで8MBもあります。
そこで一度に表示するのは一つだし、必要なときに外部読み込みすれば大幅に削減できるのではと考えやってみました。
最初はGitHubのIssueに画像を貼ってそれを読んでいたんですが、仕様が変わって読めなくなったのでGitHubPageを使うやり方に移行しました。これでようやく人へ見せられる形になったかなと思ったので、この記事を書いている次第です。

(書いている途中で、Cubemapやめて6Sidedにすれば圧縮が効いて埋め込みでもある程度抑えられたなと気づきましたが、0にできたわけなので良しとします)

# 手順

## Skyboxに使う画像の準備
まずSkyboxに使う画像を用意します。自分の場合はinsta360 X3で撮影した画像を使いましたが、1枚の360度画像か6Sided用の形式であれば問題ないです。
1枚の360度画像の場合はImageDownloaderでそのまま扱うことができないので、通常の画像形式で問題のない6Sided向けに変換する必要があります。
いろいろ探したのですが、こちらのリポジトリのものくらいしかちゃんと変換できるものが無かったです。

https://github.com/matheowis/HDRI-to-CubeMap

ウェブ上でも使えますし、ローカルで動かすことも可能です。
元画像の解像度が8K以上である場合、保存の時に解像度を2Kにするとよいと思います。ImageDownloaderで扱えるのは2Kが最高サイズなのでそれ以上にはできません。形は一番下の6枚ばらばらの物を選んでください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/9a0f0400-9e08-c96f-3aff-55bec214993d.png)
そうするとZipになって保存されるので展開すると、以下のようになっているはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/f0ff910d-4e78-e01f-9971-b871b25f04c4.png)
使いたいSkyboxはすべてこの形になるようにしてください。

## GithubPagesの準備
画像の置き場となるGithubPagesを用意していきます。ImageDownloaderで扱えるドメインは他にもあるのですが、管理のしやすさやレート制限がない点でGitHubを選んでいます。
まず画像置き場として使うPublicリポジトリを作ります。(privateでもできるかも？)
こちらが自分の使っているリポジトリなので良かったら参考にしてください。

https://github.com/sack-kazu/ScanedSummitsSkybox

そして先ほど準備したSkybox画像をPushしていきます。
階層は基本自由ですが、Skyboxごとにフォルダを切るとよいと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/6ccab612-5b8d-541c-81a1-27f747e39d50.png)

次にに実際のページとなるindex.htmlを用意してRootに配置します。
中身はひたすらリポジトリ内の画像を表示するだけのものです。srcはこのhtmlファイルからの相対パスになります。

``` index.html
<!DOCTYPE html>
<html>
<head>
    <title>Skybox置き場</title>
</head>
<body>
    <h1>会津駒</h1>
    <img src="Images/AizuKoma/nx.png">
    <img src="Images/AizuKoma/ny.png">
    <img src="Images/AizuKoma/nz.png">
    <img src="Images/AizuKoma/px.png">
    <img src="Images/AizuKoma/py.png">
    <img src="Images/AizuKoma/pz.png">

    .
    .
    .
    .
    
</body>
</html>
```

実際のページはこちらですが人が見るものではないですね…

https://sack-kazu.github.io/ScanedSummitsSkybox/

後はGithubPagesのセットアップをしましょう。
公式ドキュメントはこちらです。

https://docs.github.com/ja/pages/getting-started-with-github-pages/creating-a-github-pages-site#creating-your-site

まずは、リポジトリの**Setings**から**Pages**を開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/b6a35c62-4a03-9b6d-b9cf-3872148ac9ea.png)

次に**Build and deployment**で**Deploy from branch**を選ぶ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/980af1d0-eee7-b34c-4bd9-1826053634e7.png)

最後に**Branch**でmainを選べば、mainブランチに更新があった時にデプロイしてくれるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/bfd9a8a1-62e3-80fa-9e64-e835153cbed8.png)

これでGithubPagesの準備は完了です。

## Unityの実装
### Materialの用意
まずSkybox用のMaterialを用意します。Shaderは`skybox/6 Sided`で、パラメータは初期値がいいと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/96ce9f1f-b145-bbb6-e544-f282d189ae1c.png)

### 画像URLの取得
次に画像をダウンロードしてくるURLを用意しましょう。
先ほど作ったGitHubPagesに行って、画像のアドレスをコピーすると以下のようになっています。
`https://sack-kazu.github.io/ScanedSummitsSkybox/Images/AizuKoma/nx.png`
ところでこのURLは公開されている画像のURLなので、このQiitaにおいて、
`![image.png](https://sack-kazu.github.io/ScanedSummitsSkybox/Images/AizuKoma/nx.png)`
と書くことで以下のように画像を表示できます。
![image.png](https://sack-kazu.github.io/ScanedSummitsSkybox/Images/AizuKoma/nx.png)

閑話休題

さてURLの作りを見ると、GitHubPageのベース部分`https://sack-kazu.github.io/`と、画像までのパス`ScanedSummitsSkybox/Images/AizuKoma/nx.png`に分かれていることが分かります。これを基に読み込み時に生成したくなりますが、ImageDownloaderに渡すURLはstringではなくVRCUrl型である必要があり、残念ながらこれはランタイムで生成できないので事前にSerializeFieldなどで持たせておく必要があります。
とは言えさすがに大変なのでエディタ拡張を書きました。`ModelItem`というクラスは実際のワールドで使っているもので、publicなVRCUrlの配列`imageUrls`を持っています。

``` .cs
using UnityEngine;
using UnityEditor;
using VRC.SDKBase;

[CustomEditor(typeof(ModelItem))]
public class ModelItemEditor : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        ModelItem modelItem = target as ModelItem;

        if (GUILayout.Button("Generate URLs"))
        {
            modelItem.imageUrls = GenerateImageUrls(modelItem.name);
        }
    }

    private VRCUrl[] GenerateImageUrls(string mountainName)
    {
        //画像の名前　上で紹介したツールを通すとこうなる
        string[] imageNames = { "nx", "ny", "nz", "px", "py", "pz" };
        VRCUrl[] imageUrls = new VRCUrl[imageNames.Length];

        for (int i = 0; i < imageNames.Length; i++)
        {
            //決め打ちでURLの形をハードコード
            imageUrls[i] = new VRCUrl($"https://sack-kazu.github.io/ScanedSummitsSkybox/Images/{mountainName}/{imageNames[i]}.png");
        }
        return imageUrls;
    }
}
```

手間とミスを減らすため、Skybox名(今回は山の名前`mountainName`)とGameObject名を一致させるようにしています。これによってボタン一つでURLの配列を用意できるようになりました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/2773ab5d-1988-94fc-200e-0b7f6214561a.png)



### Skyboxを設定する部分の実装
次に実際にSkyboxを設定するクラスを作りますが、とりあえず丸ごと貼ります。

``` SkyboxLoader.cs
using UdonSharp;
using UnityEngine;
using VRC.SDK3.Image;
using VRC.SDKBase;
using VRC.Udon;

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class SkyboxLoader : UdonSharpBehaviour
{
    [SerializeField]
    private Material skyboxMaterial;

    private VRCUrl[] imageUrls = new VRCUrl[6];
    private int downloadCount = 0;
    private Texture2D[] textures = new Texture2D[6];
    private TextureInfo info = new TextureInfo();
    private VRCImageDownloader imageDownloader = null;

    void Start()
    {
        //端っこが変にならないようにWrapModeの設定
        info.WrapModeU = TextureWrapMode.Clamp;
        info.WrapModeV = TextureWrapMode.Clamp;
        info.WrapModeW = TextureWrapMode.Clamp;

        ResetTextures();
    }

    /// <summary>
    /// Skyboxの画像と回転を設定する
    /// </summary>
    /// <param name="imageUrls">画像のURL配列　長さは6前提</param>
    /// <param name="rotation"></param>
    public void SetSkybox(VRCUrl[] imageUrls, float rotation)
    {
        Debug.Assert(imageUrls.Length == 6, "imageUrls.Length must be 6");
        if (imageUrls.Length != 6) return;

        //諸々リセット
        downloadCount = 0;
        ResetTextures();
        //これで今読んであった画像がすべて破棄される
        if (imageDownloader != null) imageDownloader.Dispose();

        //Downloaderの新しいインスタンスを用意
        imageDownloader = new VRCImageDownloader();

        this.imageUrls = imageUrls;
        skyboxMaterial.SetFloat("_Rotation", rotation);

        DownLoadImages();
    }

    /// <summary>
    /// 指定したURLから画像をダウンロードしてくる
    /// </summary>
    private void DownLoadImages()
    {
        imageDownloader.DownloadImage(imageUrls[downloadCount], null, this.GetComponent<UdonBehaviour>(), info);
    }

    /// <summary>
    /// 画像のダウンロードが成功したときの処理
    /// </summary>
    public override void OnImageLoadSuccess(IVRCImageDownload result)
    {
        //ダウンロードした画像が今読んでいた画像と一致しているか確認
        //タイミングによっては前回の残りが混ざることがあるため
        if (imageUrls[downloadCount] != result.Url) return;
        
        textures[downloadCount] = result.Result;
        //ダウンロードした画像をSkyboxにセット
        SetTextures(downloadCount);
        downloadCount++;
        //まだダウンロードしていない画像があれば再帰的にダウンロード
        if (downloadCount < textures.Length)
        {
            DownLoadImages();
        }
    }

    /// <summary>
    /// 画像のダウンロードが失敗したときの処理
    /// </summary>
    public override void OnImageLoadError(IVRCImageDownload result)
    {
        base.OnImageLoadError(result);
        Debug.LogError($"[SkyboxLoader] {result.ErrorMessage}");
    }

    /// <summary>
    /// 指定された画像をSkyboxにセットする
    /// </summary>
    /// <param name="imageIndex"></param>
    private void SetTextures(int imageIndex)
    {
        switch (imageIndex)
        {
            case 0:
                skyboxMaterial.SetTexture("_RightTex", textures[0]);
                break;
            case 1:
                skyboxMaterial.SetTexture("_DownTex", textures[1]);
                break;
            case 2:
                skyboxMaterial.SetTexture("_BackTex", textures[2]);
                break;
            case 3:
                skyboxMaterial.SetTexture("_LeftTex", textures[3]);
                break;
            case 4:
                skyboxMaterial.SetTexture("_UpTex", textures[4]);
                break;
            case 5:
                skyboxMaterial.SetTexture("_FrontTex", textures[5]);
                break;
        }
    }

    /// <summary>
    /// Skyboxのテクスチャをリセットする
    /// </summary>
    private void ResetTextures()
    {
        skyboxMaterial.SetTexture("_RightTex", null);
        skyboxMaterial.SetTexture("_DownTex", null);
        skyboxMaterial.SetTexture("_BackTex", null);
        skyboxMaterial.SetTexture("_LeftTex", null);
        skyboxMaterial.SetTexture("_UpTex", null);
        skyboxMaterial.SetTexture("_FrontTex", null);
    }

    private void OnDestroy()
    {
        if (imageDownloader != null) imageDownloader.Dispose();
    }
}
```

外から6枚分の画像URLを`SetSkybox()`に渡して順番にダウンロードし、先ほど用意したマテリアルにテクスチャを設定しています。自分の場合は先ほど出てきた`ModelItem`クラスから呼び出しています。
ここで読み込む順番は渡した配列の順番になっており、今回自分は[-x, -y, -z, +x, +y, +z]にしているので、テクスチャの設定順がこうなっています。ここは各自で調整してください。
``` .cs
private void SetTextures(int imageIndex)
    {
        switch (imageIndex)
        {
            case 0:
                skyboxMaterial.SetTexture("_RightTex", textures[0]);
                break;
            case 1:
                skyboxMaterial.SetTexture("_DownTex", textures[1]);
                break;
            case 2:
                skyboxMaterial.SetTexture("_BackTex", textures[2]);
                break;
            case 3:
                skyboxMaterial.SetTexture("_LeftTex", textures[3]);
                break;
            case 4:
                skyboxMaterial.SetTexture("_UpTex", textures[4]);
                break;
            case 5:
                skyboxMaterial.SetTexture("_FrontTex", textures[5]);
                break;
        }
    }
```
注意が必要なのが、使わなくなった画像の破棄です。Texture2D自体を破棄しても良いですが、`imageDownloader.Dispose();`を呼ぶことで確実かつまとめて破棄できます。ただその後実際にGCが行われるまでImageDownloaderのインスタンスが使えなくなるようなので、都度新しく作り直しています。（良くないかも）

``` .cs
//これで今読んであった画像がすべて破棄される
if (imageDownloader != null) imageDownloader.Dispose();

//Downloaderの新しいインスタンスを用意
imageDownloader = new VRCImageDownloader();
```

他に気を付ける点は末端処理の設定でしょうか。`WrapMode`を`Clamp`にすることで、境界に筋が入ってしまうのを防ぎます。
``` .cs
//端っこが変にならないようにWrapModeの設定
info.WrapModeU = TextureWrapMode.Clamp;
info.WrapModeV = TextureWrapMode.Clamp;
info.WrapModeW = TextureWrapMode.Clamp;
```
これを怠ると、このように境界が見えてしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2341187/be49e50c-f67d-f36b-4524-feeec16fb1fc.png)
参考

https://spirits.appirits.com/doruby/9200/

これで無事Skyboxの外部からロードができました！

### おまけ　画像差し替えについて
この仕組みはリポジトリに存在する画像を参照するものなので、画像置き場のリポジトリで同名で上書き更新すればUnity側の作業なく更新可能です。

# デメリットについて

## ロード時間について
6枚の画像を読み込む場合、最短で25秒かかります。これはImageDownloaderのレート制限として5秒に1回しか呼び出せないためです。
一応全部読み終えてからまとめて切り替えるという策もありますが、それはそれでレスポンスが悪くてなんだかなあという感じです。さらに後述のメモリ問題もあります。

## メモリ消費について
外部から生の画像を読み込む関係で、Unity上の画像であれば行われているGPU形式での圧縮が行われていないことに注意が必要です。
MemoryProfilerで確認したところ**2K一枚当たり28MB**のメモリを消費していました。6枚合計で168MBです。
ちなみにASTC8×8の場合は1枚2.7MB程度で済みます。
PCであればまったく気にしなくていいと思いますが、Questなどのモバイル環境だと問題になるケースがあるかもしれません。そこで念のため、同時に複数のSkybox分の画像を保持しないようにしています。

## 画像データがWeb上で公開される
仮にリポジトリ自体をPrivateにしたとしてもGitHubPages自体は公開されるので、だれでもそこから画像をダウンロードできてしまいます。
これは再配布に当たる可能性があるので、規約的に問題のないものを使いましょう。

# まとめ

さてここまで書いてきて、やっぱりこれが活きるのは相当なエッジケースだなと感じました。
誰の役にも立たない可能性がありますがせっかく書いたので公開します。
もしこれが役立ちそうなレアな状況の人がいたら、ぜひやってみてください。