VRChat, Blog

[VRChat] ImageLoadingの制約を回避する方法

VRChatのワールド制作では、イベントのタイムテーブルや告知画像など、ワールドを再アップロードせずに外部から画像を更新したい場面があります。 VRChatには外部画像を取得して表示できる Image Loading の仕組みがあります。しかし、公式ドキュメントでは、Image Loading には以下の制限があると記載されています。 読み込める画像の最大解像度は 2048 × 2048 px 画像のダウンロードは 5秒に1枚 制限を超えて要求した画像はキューに入り、順番が保証されない 入出力バッファには最大32MBの制限がある イベント用のタイムテーブルのように、文字を潰さずに表示したい画像では、2048 px という上限が問題になる場合があります。また、複数枚の画像を連続して表示したい場合、5秒に1枚という取得間隔も表示体験に影響します。今回は、画像ファイルを Image Loading で直接取得するのではなく、画像のバイナリデータをBase64文字列としてJSONに埋め込み、String Loadingで取得した後にTexture2Dとして復元する方法を試しました。

今回実装した仕組み

今回の実装では、画像を以下の流れで表示します。 1, Unity Editor上で、Texture2Dの生データを取得する 2, 生データをBase64文字列へ変換する 3, 幅・高さ・Base64文字列をJSONとして出力する 4, VRChat上で VRCStringDownloader を使いJSONを取得する 5, VRCJson でJSONを解析する 6, 実行中のプラットフォームに対応するデータを取り出す 7, Base64文字列をバイト配列へ戻す 8, Texture2D.LoadRawTextureData でテクスチャを復元する 9, RawImage に設定して表示する 通常の Image Loading では、外部の画像ファイルをVRChat側が画像として読み込みます。今回の方式では、取得する対象は画像ではなくJSON文字列です。そして、取得後にワールド内のUdonSharpスクリプトで画像を組み立て直します。 そのため、Image Loading に設定されている 2048 × 2048 px の画像解像度制限を直接通らずに表示できる構成になります。 ただし、String Loading にも 5秒に1ファイル、最大100MB という制限があります。したがって、画像を1枚ずつ別々のJSONとして取得するだけでは、取得間隔の問題は残ります。複数画像へ展開する場合は、1回の取得に必要なデータをまとめる、あるいは読み込み単位を設計する必要があります。Unity Editor側:画像をBase64形式のJSONへ変換する まず、Unity Editor側でTexture2DのデータをJSONへ変換します。 今回作成した Base64Convert では、Inspectorに設定した Texture2D から以下の情報を取得しています。 ・画像の幅 ・画像の高さ ・テクスチャの生データをBase64化した文字列 処理の中心となる部分は以下です。

var data = new TextureJson
{
    width = sourceTexture.width,
    height = sourceTexture.height,
    base64 = Convert.ToBase64String(sourceTexture.GetRawTextureData())
};

outputJson = JsonUtility.ToJson(data, false);

GetRawTextureData() で取得した画像データを Convert.ToBase64String() に渡すことで、バイナリデータをJSONに保存可能な文字列へ変換しています。 生成されるJSONは、以下のような構造になります。

{
  "width": 4096,
  "height": 4096,
  "base64": "AAABAAEAAA..."
}

Base64化によって、画像そのものを外部URLとして読み込むのではなく、文字列データとして配信できるようになります。Base64Convert は、この内側の画像データオブジェクトを生成する実装です。

プラットフォームごとに画像データを用意する

VRChatは、PC・Android・iOSで使用するテクスチャ形式が異なります。 今回の受信側スクリプトでは、プラットフォームごとに以下の形式でTexture2Dを生成しています。

PC DXT1 Android ETC2_RGBA8Crunched iOS PVRTC_RGBA4

そのため、同じ画像であっても、PC用・Android用・iOS用にそれぞれ変換したデータを用意しています。 Unity Editor拡張では、Inspectorから現在のBuild Targetを確認し、PC・Android・iOSへ切り替えられるようにしました。

private enum BuildTargetOption
{
    PC,
    Android,
    iOS
}

Build Targetを変更した上でJSONを生成することで、各プラットフォーム向けのテクスチャデータを準備する想定です。 今回掲載しているエディタ拡張では、Build Targetの切り替えとJSON生成をInspector上から操作できます。ただし、複数プラットフォーム分のJSONを自動で一つの完成データへ統合する処理までは含まれていません。

VRChat側で読み込むJSON構造

受信側の JsonLoader では、以下のようにプラットフォーム別のデータを持つJSONを想定しています。

{
  "timetable": {
    "ja": {
      "pc": {
        "width": 4096,
        "height": 4096,
        "base64": "..."
      },
      "android": {
        "width": 4096,
        "height": 4096,
        "base64": "..."
      },
      "ios": {
        "width": 4096,
        "height": 4096,
        "base64": "..."
      }
    }
  }
}

ここでは、特定の画像の日本語版について、PC・Android・iOSそれぞれの画像データを保持しています。 端末側では、実行中の環境に応じて使用するキーを切り替えています。

private string GetPlatformKey()
{
#if UNITY_ANDROID && !UNITY_EDITOR
    return "android";
#elif UNITY_IOS && !UNITY_EDITOR
    return "ios";
#else
    return "pc";
#endif
}

これにより、PCではPC向けのデータ、AndroidではAndroid向けのデータ、iOSではiOS向けのデータだけを使用してテクスチャを復元できます。 なお、現状の構造では、端末が使用しないプラットフォームのBase64データも同じJSONに含まれている場合、ダウンロード対象になります。容量を抑えたい場合は、プラットフォームごとにJSONファイルを分割する設計も検討できます。

String LoadingでJSONを取得する

VRChat側では、VRCStringDownloader.LoadUrl を使用してJSONを取得します。

public void CubeInteract()
{
    VRCStringDownloader.LoadUrl(jsonUrl, (IUdonEventReceiver)this);
}

取得に成功すると、OnStringLoadSuccess が呼ばれます。

public override void OnStringLoadSuccess(IVRCStringDownload result)
{
    if (!VRCJson.TryDeserializeFromJson(result.Result, out DataToken rootToken))
    {
        Debug.LogError("[JsonLoader] json parse failed: " + rootToken.ToString());
        return;
    }

    // JSONから必要なデータを取得
}

VRCStringDownloader は、.txt や .json のようなテキストデータを外部から読み込むための仕組みです。取得したJSON文字列は、VRCJson.TryDeserializeFromJson によって DataDictionary として扱えるようになります。コードでは、以下の順序でJSONを辿っています。

timetable
  └─ ja
      └─ pc / android / ios
          ├─ width
          ├─ height
          └─ base64

外部から取得したデータを扱うため、各キーが存在するか、想定した型になっているかを確認してから処理を進めています。

Base64文字列からTexture2Dを復元する

JSONからBase64文字列を取得した後は、通常のバイト配列へ戻します。

byte[] raw = System.Convert.FromBase64String(base64);

続いて、実行中のプラットフォームに対応する TextureFormat を指定して Texture2D を生成します。

loadedTexture = new Texture2D(width, height, format, false);
loadedTexture.LoadRawTextureData(raw);
loadedTexture.Apply(false, false);

targetRawImage.texture = loadedTexture;

この処理によって、外部から取得した文字列データを、VRChat内で表示可能な画像として復元できます。 また、再読み込み時には既に生成済みのテクスチャを破棄しています。

if (loadedTexture != null)
{
    Destroy(loadedTexture);
    loadedTexture = null;
}

外部画像を更新し続ける実装では、不要になったテクスチャを保持し続けるとメモリ使用量が増えていきます。そのため、以前のテクスチャを破棄してから新しいテクスチャを生成するようにしています。JsonLoader では、JSONの解析、プラットフォーム判定、Base64デコード、Texture2D生成、RawImageへの反映までを担当しています。

この方式で実現できること

今回の方式では、Image Loadingを使わずに画像を表示するため、Image Loading側の最大解像度チェックを直接通らずに済みます。 そのため、以下のような用途では有効だと考えています。 高解像度のタイムテーブル画像を表示する 小さい文字を含む案内画像を表示する PC・Android・iOSで異なる圧縮形式の画像を出し分ける 外部JSONを書き換えることで、ワールドを再アップロードせずに表示内容を更新する 特にイベント会場の案内板やタイムテーブルでは、文字の視認性が重要です。画像全体を2048 px以内に収めると、情報量の多い画像では文字が読みづらくなる場合があります。 今回の方法は、その問題に対して、画像を文字列データとして取得し、端末上でテクスチャとして復元するという別の経路を用意するものです。

注意点:Base64化によってデータ量は増える

Base64は、バイナリデータを文字列として扱えるようにする代わりに、データ量が増加します。 理論上、Base64化したデータは元のバイナリデータに対しておよそ 4/3倍、約33%増 になります。 例えば、4bppの圧縮形式を前提とした場合、画像データの概算は以下のようになります。

| 解像度 | 元データの概算 | Base64化後の概算 | | ----------- | --------: | ----------: | | 2048 × 2048 | 約2.0 MiB | 約2.67 MiB | | 4096 × 4096 | 約8.0 MiB | 約10.67 MiB | | 8192 × 8192 | 約32.0 MiB | 約42.67 MiB |

さらに、今回のようにPC・Android・iOSの3種類を一つのJSONへ含める場合、実行端末が使用しない画像データも含めて取得する構成になり得ます。 大きな画像を表示できるようになる一方で、以下の点には注意が必要です。 JSONファイルの容量が大きくなる ダウンロード時間が増える JSON解析時の負荷が増える Base64デコード時に追加のメモリを使用する 復元したTexture2D自体もメモリを消費する この方式は、単に解像度を大きくすればよいというものではなく、画像容量と表示品質のバランスを取る必要があります。

iOSで発生したJSONのparseError

今回の実装では、iOS環境でJSONの parseError が発生する場合がありました。 現時点で確認できているのは、VRCStringDownloader による取得後、VRCJson.TryDeserializeFromJson が失敗する場合があるという現象です。

if (!VRCJson.TryDeserializeFromJson(result.Result, out DataToken rootToken))
{
    Debug.LogError("[JsonLoader] json parse failed: " + rootToken.ToString());
    return;
}

ただし、現段階では原因を断定できていません。(binにしたら治るかも) 可能性としては、以下のような点が考えられます。 Base64文字列を含むJSON全体が大きくなりすぎている JSON解析時に大きな文字列を扱う負荷が高い 1ファイルに複数プラットフォーム分のデータをまとめているため、不要なデータまで読み込んでいる 取得した文字列が想定どおり完全なJSONとして渡っていないケースがある ここで重要なのは、iOSのparseErrorの原因が、必ずしもBase64そのものだと確定しているわけではないという点です。 現在のコードでは、以下のログを出力するようにしています。

Debug.Log("[JsonLoader] result.Result.Length = " + result.Result.Length);
Debug.Log("[JsonLoader] result.ResultBytes.Length = " +
    (result.ResultBytes == null ? -1 : result.ResultBytes.Length));
Debug.Log("[JsonLoader] head100 = " + result.Result.Substring(0, len));

これにより、JSONの受信サイズ、バイト数、先頭部分が正しく取得できているかを確認できます。 今後原因を切り分けるには、以下のような検証が必要です。 同じ画像で解像度だけを下げた場合に再現するか Base64文字列の長さとエラー発生率に相関があるか PC・Android・iOSのデータを別JSONへ分割すると改善するか 画像データを複数ファイルへ分割した場合に改善するか JSON解析を必要最小限のメタデータだけに限定できるか また、公式仕様上、IVRCStringDownload からは文字列だけでなく ResultBytes としてダウンロード済みの生バイト列も取得できます。将来的には、JSONに巨大なBase64文字列を含めるのではなく、メタデータと画像バイナリの取得方法を分離できないか検証する余地があります。これは現時点では改善案であり、VRChat上での動作確認は別途必要です。実装して分かったこと 今回の実装によって、VRChatで外部画像を表示する際には、Image Loadingだけが選択肢ではないことが分かりました。 画像をそのまま取得するのではなく、テクスチャデータを文字列として取得し、ワールド内で復元することで、Image Loadingの解像度制限を直接受けない表示方法を作ることができます。 一方で、この方法には明確な代償もあります。 Base64化によってデータ量が増える JSONが巨大になりやすい プラットフォームごとの画像データ管理が必要になる モバイル環境、特にiOSでは安定性の確認が必要になる そのため、すべての画像表示をこの方式へ置き換えるのではなく、通常の案内画像にはImage Loadingを使い、高解像度が必要なタイムテーブルや文字情報の多い画像に限定して使用するのが現実的だと考えています。

まとめ

今回は、VRChat上で高解像度の画像を外部から表示するために、String LoadingでBase64入りのJSONを取得し、Texture2Dとして復元する方法を実装しました。 この方法では、Image Loadingが持つ2048 × 2048 pxの最大解像度制限を直接通らずに画像を表示できます。また、PC・Android・iOSごとに異なるテクスチャ形式を用意し、端末に応じて使用するデータを切り替えることも可能です。 ただし、String Loadingにも取得頻度と容量の制限があり、Base64化による容量増加や、iOSでのJSON解析エラーといった課題も残っています。 今後は、JSONの分割、プラットフォーム別ファイルの配信、Base64を使用しないバイナリ取得方式の検証などを進め、モバイル環境でも安定して扱える形へ改善していきたいと考えています。 VRChatの外部画像表示では、表示品質、通信量、メモリ使用量、対応プラットフォームのすべてを考慮する必要があります。今回の実装は、高解像度画像を扱うための一つの方法として、用途を限定して利用することで有効な選択肢になると考えています。

https://rin0700.com/blog/828c5b35-1114-452c-8e69-6fb2a1b046cf