Post Details Page

【星々の幻境】ゲーム内セーブデータの最適化【無いのなら、作ってしまえ、風の行方!】

Translate

今日も今日とて「あの頃の風の行方」を更新中。

今回は以前から話をしていた「クリエイティブモード」の実装のためにゲーム内セーブの機能を触ったのですが、その際に発生した容量カツカツ問題とその解決策についてをまとめました。

同じようにゲーム内セーブの機能を使おうと思っている方や既に使っている方は参考になると思います。

ゲーム内セーブ機能について

とりあえずゲーム内セーブに関する公式ドキュメントはこちらです。

https://act.hoyoverse.com/ys/ugc/tutorial/detail/mhzcw29qjjma


簡単にまとめるとこんな感じ:


  • セーブデータは構造体の集合
  • セーブデータはプレイヤーのカスタム変数として読み書きが可能
  • 構造体内の変数の型に辞書や構造体は使えない
  • セーブデータには容量制限がある
  • 容量に制限があるためリストは配列として要素数を予め定義しておく必要がある


なるほど・・・、完全に理解した!

この段階で構造体とは?というレベルの人は、まずは構造体に関するドキュメントを読みましょう。

https://act.hoyoverse.com/ys/ugc/tutorial/detail/mh3fmi0t99ns

ゲーム内セーブ機能を用いた景観オブジェクトの保存

ということで実際にセーブデータの定義をしていきますが、その前に私がやりたいと思っている「クリエイティブモード」についても簡単に説明しておきます。

これは風の行方のマップをプレイヤー自身で作成できる機能で、いわゆる幻境版の塵歌壺だと思ってください。

シングルプレイ時にプレイヤー自身がオブジェクトを自由に配置しオリジナルのマップを作り、マルチプレイ時に自身が作成したマップで他の方と対戦できるというイメージです。


なので例えば一つの景観オブジェクトを記録するにはこういった要素を含む構造体が必要になりますよね。


  • 配置するオブジェクトの元となるプレハブのID
  • 配置するオブジェクトの座標
  • 配置するオブジェクトの回転量


構造体の作成は通常通り「アドバンスデータ管理」から行えます。

タブを切り替えることでゲーム内セーブ用の構造体を定義できます。

1つの景観オブジェクトを表す構造体

セーブデータの定義は「ステージ内データ管理」から行えます。

まずはセーブデータを定義

オブジェクトは最低でも100個くらいは置けるようにしたいですよね。

全て動的オブジェクトなので最終的にはクライアント側の負荷も考慮する必要がありますが、100個くらいはオブジェクトがないとかくれんぼとして話になりませんから。


右下の「ステージ内データを追加」でデータを追加し、構造体として「景観オブジェクト」を選択し、長さを100にします。

景観オブジェクトを100個保存するためのセーブデータ?

あれ・・・、データ量が真っ赤なんですけど、これもしかして容量的に無理ということ?

そう、私はレベル5クリエイターでありながら今までゲーム内セーブ機能については全く触れておらず知らなかったのですが、実はこの機能は容量の制限が滅茶苦茶シビアです・・・。

これをどう最適化したかというのが今回のお話です。

値の型や持ち方によるデータ量の確認

まずは最適化の前にどういったものが軽いのか/重いのかを把握する必要があります。

今回はプレハブIDや3Dベクトル、それらを数値として表すとしたら整数や浮動小数点数が使えそうということで、これら単体のデータ量やデータの持ち方による総量の変化についてを確認しました。


まずは各型が単体でどの程度のデータ量になるかを確認します。

例として整数型の検証結果画像を添付します。

1つの整数を格納する構造体を作成

1つの整数を格納する構造体を1つ含むセーブデータのデータ量は63

これを他の型についても確認しました。

以下その結果です。


  • 整数:63
  • 浮動小数点数:55
  • プレハブID:58
  • 3Dベクトル:75


一言で言って意味不明ですね・・・。

整数と浮動小数点数が内部的にint型とfloat型ならそれぞれ32bitで表現されているだろうに、データ量が異なります。

また3DベクトルにはXYZの3軸分の浮動小数点数が格納されているのに、データ量は浮動小数点数と比較して1.5倍以下に抑えられています。


とりあえず検証を進めましょう。

次はデータの持ち方でデータの総量がどう変わるのかを確認します。

各値は10個ずつ持つとします。

値の持ち方のパターンは次の3つを考えました。


  1. 構造体自体の数を増やす
  2. 構造体内のフィールド数を増やす
  3. 構造体内にリストを定義する


以下検証結果です。

1. 構造体自体の数を増やす

これは最初にやったパターンですね。

構造体自体を10個持つように設定します。

1つの整数を格納する構造体を10個含むセーブデータのデータ量は630

これを他の型についても確認しました。

以下その結果です。


  • 整数:630
  • 浮動小数点数:550
  • プレハブID:580
  • 3Dベクトル:750


これは分かりやすいですね。

単純に元の量の10倍になるようです。

2. 構造体内のフィールド数を増やす

構造体内に10個のフィールドを持たせます。

セーブデータには構造体を一つ持たせることで10個の情報を表現します。

10個の整数を格納する構造体を作成

10個の整数を格納する構造体を1つ含むセーブデータのデータ量は306

これを他の型についても確認しました。

以下その結果です。


  • 整数:306
  • 浮動小数点数:231
  • プレハブID:261
  • 3Dベクトル:426


どうやら構造体自体を複数持つよりも、構造体内に要素を複数持つ方がデータの総量は少なくなるようです。

構造体自体にメタデータ的な要素が含まれるのでしょうか。

よく分かりませんがこれは最適化に利用できそうですね。

3. 構造体内にリストを定義する

構造体内に一つのリストを持たせ、リストのサイズを10とします。

セーブデータには構造体を一つ持たせることで10個の情報を表現します。

要素数が10の整数リストを格納する構造体を作成

要素数が10の整数リストを格納する構造体を1つ含むセーブデータのデータ量は231

これを他の型についても確認しました。

以下その結果です。


  • 整数:231
  • 浮動小数点数:126
  • プレハブID:141
  • 3Dベクトル:306


フィールドを個別に定義するよりさらに軽くなりましたね。

フィールド自体にもメタデータ的な要素が含まれているようです。

4. 各方法の比較

とりあえず現段階でのまとめです。

構造体x10を基準とした場合の各方法との比率です。

まず分かることとして、どの型においても構造体を複数保持することが一番効率が悪く、次にフィールドを複数定義すること、一番効率が良いのが構造体内にリストを定義することだと分かります。

よって方針としては、構造体内にリストを定義しリストのサイズが最大数(100)を超えたりそもそもリストに含められない異なる型を格納する場合は別のフィールドを定義するのが良さそうです。

方針としてリスト定義方式で進めることになるので、リストの要素数を増やした際の変化も把握しておきましょう。

5. 構造体内に要素数100のリストを定義する

始めに記載した通り私は塵歌壺的な機能を幻境に実装したく、配置する景観オブジェクトに関するデータをなるべく沢山保持したいので、リストの要素数をMAXの100にしてデータ量がどうなるかを確認します。

要素数が100の整数リストを格納する構造体を作成

要素数が100の整数リストを格納する構造体を1つ含むセーブデータのデータ量は1851

これを他の型についても確認しました。

以下その結果です。


  • 整数:1851
  • 浮動小数点数:801
  • プレハブID:951
  • 3Dベクトル:2601


面白い結果になりましたね。

どうやら要素数を10倍にしてもデータ量は単純に10倍にはならないようです。

6. 改めて各方法の比較

改めて各方法を比較します。

フィールド複数定義方式はなしとして、構造体x100方式とリスト要素x100方式との比較になります。

ここまで見ると浮動小数点数の優秀さが際立ちますね。

整数と比較してもデータ量は半分以下になりますし、3Dベクトルとの比較も興味深いです。

3DベクトルはXYZの3つの浮動小数点数を保存する型ですが、リストの要素数が10のときは3つの浮動小数点数(126 x 3)を扱うより一つの3Dベクトル(306)を扱う方がデータの総量が少なかったです。

しかしリストの要素数を100にすると3Dベクトルを使うより、浮動小数点数を3つ個別に用意した方が容量が少なくなることが分かります。

つまり大量の情報を表現する際は、積極的に浮動小数点数を使っていくことがデータ量の削減に繋がりそうです。

ビットパッキングについて

先に答えを書いてしまいますが、複数の情報を一つの数値に含める手法として「ビットパッキング」というものがあります。

例えば分かりやすい例としてON/OFFや0/1を表すブール値を数値に含めて効率的に管理できます。

幻境の整数はよくある32bitのint型です(内部の実装については知りませんが、実際に-2,147,483,648~2,147,483,647までしか値を表現できないことからそう判断できます)。

これは2進数で32桁の00000000000000000000000000000000から11111111111111111111111111111111となりますが、この各桁をON/OFFのブール値に見立てることで、一つの整数で32個のブール値を表現できることが分かると思います。


つまりこの「ブール値リスト32」の構造体は、表現できる情報としては1つの整数を持つ構造体と全く同じです。

要素数が32のブール値リストを格納する構造体は、32bitの情報を扱える

1つの整数を格納する構造体も、32bitの情報を扱える

この構造体をそれぞれセーブデータに使うとどうなるか。

このように表現できる情報の量は変わらないのに、1つの整数を使った方がデータ量が少なくなります。

これがビットパッキングによる最適化です。

要素数が32のブール値リストを格納する構造体を1つ含むセーブデータのデータ量は147

1つの整数を格納する構造体を1つ含むセーブデータのデータ量は63

ブール値を32個も表現できてもあまり嬉しくないのですが、考え方としては同じです。

例えば0から3までの4通りの数値、2進数での00から11までのパターンを表現したいなら2bitで十分です。

同様に0から7までの8通りの数値、2進数での000から111までのパターンを表現したいなら3bitで十分です。

このように様々な情報を何ビットで表現できるかを考えて、それぞれのビットを組み合わせて1つないしは複数の数値を表すことでゲータ量の削減が可能になります。

配置された景観オブジェクトを表すのに必要なビット数を決める

改めて、一つの景観オブジェクトを記録するにはこういった要素が必要になります。


  • 配置するオブジェクトの元となるプレハブのID
  • 配置するオブジェクトの座標
  • 配置するオブジェクトの回転量


まずプレハブIDですが、プレハブを256種類以下に制限したら、これは2^8なので8bitで表現できることになります。

0~255の数値を実際のプレハブIDに変換する処理はセーブデータの外に辞書を定義すればよいでしょう。

プレハブの数を256種類にするのはそもそも定義できるプレハブの数が300が限界のためです。

新たに追加する「クリエイティブモード」用のプレハブ以外にも既存のゲームを構成するためのプレハブが既にあるので、そういったことを考慮すると256種類以下に制限するというのは十分現実的な落とし所です。


次に座標です。

マップは横方向が-50mから50m、縦方向が0mから25mもあれば風の行方用のマップとしては十分です。

問題は分解能で、どの程度細かい単位でものを置けるようにするかで割くべきビット数が決まります。

仮にXYZ軸全て9bitずつ、つまり2^9で最大512通り、切り良く500通りの値を取れるとしたら分解能は横方向が20cm、縦方向が5cmとなります。

風の行方では偽装オブジェクトのダミーを屋根上など地面以外の場所にも置きたくなるでしょうから、様々な状況で自然な高さに設置できるよう、横方向に比べて縦方向の分解能は高くする必要があると判断しました(5cmでも本当は足りない気がしますが・・・)。


最後に回転です。

これは愚直にやるとXYZの3軸となりますが、正直3軸回転させたくなることもそこまで多くないでしょうし、私自身マップ作成でY軸以外の回転を使うことは稀です。

塵歌壺でもY軸以外の回転はできませんし、ここは思い切ってデータ量の削減のためにもXZ軸のデータを捨てましょう。

表現できる値を2^5の32通りとすると、分解能は11.25度となります。


まとめるとこんな感じです。

必要なビット数は合計で40bitとなります。


  • プレハブID:8bit
  • X座標:9bit
  • Y座標:9bit
  • Z座標:9bit
  • Y軸回転:5bit


データの格納先を決める

この40bitのデータをどのように格納するかを決めます。

まず整数ですがこちらは先に述べたように32bitなので一つの数には収まりません。

整数を使う場合、2つの整数を用いて1つの景観オブジェクトを表す必要があります。


次の候補は浮動小数点数です。

細かい説明は省きますが、これは整数で扱える以上の大きな数も表現できますが、沢山の数を表現できるわけではありません。

どういうことかと言うと、浮動小数点数は内部では符号部・指数部・仮数部の組み合わせで値を表現しており、当然これらは全て固定長です。

例えばfloat型の場合、符号部1bit、指数部8bit、仮数部23bitで構成されます。

double型の場合、符号部1bit、指数部11bit、仮数部52bitで構成されます。

普通に生活していればdouble型で困ることはほとんどないのですが、ここは星々の幻境、そんな潤沢にメモリを使えるはずもなく、案の定浮動小数点数の実体はfloat型のようです(内部の実装については知りませんが、実際に確認できた最大値が3.402823 x 10^38であることからそう判断しました)。

float型の場合10進数で有効桁が7桁ほどなので、123,456,789程度の整数値も正確に表現できません・・・。


またまた細かいことは省きますが、float型で正確に表現できる整数値の限界は16,777,215だそうです。

ただいろいろ触ってみると実際には16,777,215は星々の幻境では表現できませんでした。

例えばエディタに入力すると何故か16,777,220に丸められてしまうことが確認できます。

2^23である8,388,608は正確に表現できたため、浮動小数点数を使う場合23bitまでは安全に活用できると考えて良さそうです。

今回の用途的には1つの浮動小数点数では足りないので、こちらについても2つの浮動小数点数を用いて1つの景観オブジェクトを表す必要があります。


最後の候補は3Dベクトルです。

これは内部で3つの浮動小数点数を持っており、上と同じように23bitを使うとしたら1つの値で69bitを確保できます。


ここで景観オブジェクトを100個表現するために必要なデータ量を見積もります。

整数の場合、要素数100のリストが2つ必要なので1851 x 2で3702です。

浮動小数点数の場合も要素数100のリストが2つ必要なので801 x 2で1602です。

3Dベクトルの場合、要素数100のリストは1つで済むため2601 x 1で2601です。


  • 整数:3702
  • 浮動小数点数:1602
  • 3Dベクトル:2601


浮動小数点数が最も容量が少なくなることが分かりました。

浮動小数点数を用いていくで決まりですね!

仮にオブジェクトを150個置けるようにしても801 x 3の2403で、複数マップを作成するためのセーブデータを3つ定義しても余裕でお釣りが来る計算になります。


実際に浮動小数点数のリスト(要素数100)を3つ含む構造体を3つ含むセーブデータを定義してみましたが、データ量は6993でした。

この場合1つの構造体が1つのマップを表します。

構造体内の300個の浮動小数点数を用いて最大150個の景観オブジェクトを定義できます。

景観オブジェクトを最大150個配置できるマップを3つ作ることを想定したセーブデータは実現可能

Pythonで仮実装

方針が定まったので、プレハブID・X座標・Y座標・Z座標・回転を受け取り、2つの浮動小数点数の値を返す処理を実装していきます。

2つの値にはそれぞれ以下のようにデータを格納するものとします。


  1. 予備(6bit)|プレハブID(8bit)|Y座標(9bit)
  2. X座標(9bit)|Z座標(9bit)|回転(5bit)


しかし章タイトルにもある通りいきなりノードを組んだりはしません。

やり方考えるのも面倒くさいですからね。

ということでやりたいことや背景、方針などをGeminiに共有し、実現するための具体的な処理を一旦Pythonのコードとして出力してもらいました。


ここにコードを貼ったらとても見辛いので詳細は こちら を確認ください。

実行できるようにもしたので、興味のある方はよければ Google Colab で実際に触ってみてください。

Google ColabはGoogleアカウントを持っていれば誰でも無料で使えます。


実際にGoogle Colabで値を変えながら動かしてみた結果です。

上のスライダが入力で、下の文字列がエンコードして得られた数値と、その数値をデコードして復元された情報です。

問題なく動いてますね!

実はPythonのfloat型は内部的にはC言語のdouble型なので、幻境の浮動小数点数より精度が高いとかそういった差異はありますが、ロジック的にはこれで問題なさそうです。

異常値や想定外の値を受け取った際のエラーなどは考慮する必要がありますが、それはまた追々・・・。

ノードグラフとして実装

さて、ここからはPythonのコードを元にノードグラフで処理を再現していく作業になります。

この作業が一番退屈ですね、マウス操作がメインになるので肩こるし・・・。


上記の通り異常系の処理など、最終的にはいろいろ追加しなければならないものがありますから、一旦最低限の処理だけを定義して問題なく動くかを確認します。

先程テストした結果の中から2番目のパターンを幻境で再現してみましょう。


  • prefab_id: 200
  • x: 19.20
  • y: 1.75
  • z: -28.80
  • rot: 90.00


エンコードに成功すれば2つの浮動小数点数は以下のようになるはずです。


  • fa: 102435.00
  • fb: 5672264.00


丸めが発生しないキリの良い数字を選んでいるので、デコードに成功すればそれぞれの要素はそのまま誤差なく元の値に戻るはずです。


とりあえずプレイヤーのカスタム変数に検証に必要な入出力用の変数を定義します。

画面上で確認できるようにテキストUIも作成します。

Pythonのコードを元にエンコードとデコードの処理を実装します。

なお余りを求めるモジュロ演算ノードが幻境の場合整数しか入力できなかったため、この処理についてはそのまま移植できませんでした。

360度が入力された際に0度に変換するだけなので、最終的には分岐の処理でも入れて対応しようと思います。


プレイヤー作成時にカスタム変数から値を読み、エンコード・デコードの結果を再びカスタム変数に保存します。

エンコードの処理

デコードの処理

実行結果です。

無事に意図した通りの結果になりました!

余談ですが、このゲーム内セーブデータの圧縮については同じようなことを考えている方が既にいて、アセットセンターにビットパッキング用のノードが公開されています。

難しいことを考えずサクッとやりたい方はこちらのアセット "Compression Library - Save File Optimizations" を利用すると良いかもしれません。

ただこの記事を読んだ方は分かると思いますが、結局どういったデータをどの程度の量保存したいかで自身にとっての最適解は変わってきますから、私が行ったように自分で計算して実装した方がより良い最適化に繋がるはずです。

とりあえずセーブデータの容量問題は概ね解決されましたね。

次のボトルネックはクライアント負荷でしょう・・・。


景観オブジェクトは全て動的オブジェクトとして作成するので、滅茶苦茶負荷が大きくなることが想定されます。

私の「あの頃の風の行方」はカジュアルなPvP故に、端末負荷レベルは「低」で提供しています。

新しく「クリエイティブモード」の機能を追加した際に負荷レベルを上げることは想定していませんから、どこまで負荷を抑えながら安定したマップ製作機能を提供できるかが勝負になりそうです。


ではまた何か進捗あり次第報告したいと思います。

セーブデータの保存・読み込みの確認

型によって容量が異なるのは数値の保存を行う際に丸め処理を行っているからでは?というコメントをいただいたのでそれに対する追加の検証結果です。

確かにゲーム内セーブ機能に関する話をしているのに、実際にセーブデータに値を保存したり再読み込みすることなく記事が終わっていたのは不十分でしたね。

ということで実際にセーブデータに数値を保存して再読み込みした際に、値が完全に復元されるのかを確認してみました。


まず浮動小数点数を用いて表現できる整数値について改めて確認しました。

16,777,215はエディタに入力すると16,777,220に丸められてしまい、8,388,608(2^23)であれば入力できたというのは既に記載の通りです。

ここで言っているエディタというのは「戦闘プリセット画面上のプレイヤーのカスタム変数の入力欄」のことです。

今回はエディタ上ではなくノードグラフ上で値を表現できるかを検証します。


星々の幻境ではループは重たく回数にも制限があるため、0からではなく境界値について確認します。

結果は以下の通りです。

ループで値を16,777,200から1ずつ増やしていくと、本来16,777,217となるべきところが16,777,216となってしまいます。

その後も16,777,219となるべきところが16,777,220となったり、値が飛び飛びになっていることが分かりますね。

これが正しく浮動小数点数は整数よりも大きな数を表現できるが、沢山の数を表現できるわけではないということの現れです。

以上の結果から、エディタ上では値を入力できなかったものの内部の処理では浮動小数点数は整数値を16,777,215まで正確に保持できるであろうことが分かりました。

つまり星々の幻境のエディタ上で浮動小数点数の有効桁数が7桁というだけで、内部的にはそれ以上の精度を持っているということですね。


ちなみに8,388,608についても同様に確認しました。

こちらについては8,388,608を超えても特に問題なく値を表現できており、安全に整数値として扱えるであろうことが分かりました。

数値の桁数も7桁なのでまぁ想定通りです。

また私の幻境では今のところ扱う予定はありませんが小数値についても確認しました。

こちらもエディタ上では入力時に小数点2桁に丸められてしまうものの、内部的には精度の高い値を保持できていることが分かりました。

これらの数値を実際にセーブデータに保存後、再読み込みして丸めが発生せずに完全に復元されるかを確認します。

私の場合セーブデータで使用する予定があるのは浮動小数点数のリストだけですが、コメントにて要望があったように浮動小数点数と3Dベクトルの2種類のリストについて検証します。


1つ目のパターンでは浮動小数点数で表現できる整数の限界値であろう16,777,215までの数値を30個保存します。

2つ目のパターンでは今回私が使用予定の8,388,608までの数値を30個保存します。

3つ目のパターンでは0から1までのランダムな小数値を30個保存します。

これらの全リストをセーブデータに保存します。

実行結果です。

少々見づらいですが、次のような値を含むリストが作成されました。

パターン1、浮動小数点数リストの中身

パターン1、3Dベクトルリストの中身

パターン2、浮動小数点数リストの中身

パターン2、3Dベクトルリストの中身

パターン3、浮動小数点数リストの中身

パターン3、3Dベクトルリストの中身

エディタ(テストプレイデータ管理画面)上でセーブデータの中身についても確認してみます。

パターン2で保存した数値以外についてはエディタ上では丸められている事が分かりますね。

問題はこの丸めがエディタ上の表示だけなのか、それとも保存の際に実際に丸められているのかということです。

パターン1、セーブデータ内の浮動小数点数リストの中身(エディタ上での表示)

パターン1、セーブデータ内の3Dベクトルリストの中身(エディタ上での表示)

パターン2、セーブデータ内の浮動小数点数リストの中身(エディタ上での表示)

パターン2、セーブデータ内の3Dベクトルリストの中身(エディタ上での表示)

パターン3、セーブデータ内の浮動小数点数リストの中身(エディタ上での表示)

パターン3、セーブデータ内の3Dベクトルリストの中身(エディタ上での表示)

セーブデータを読み込んで値を確認してみます。

なお信号を使っているのはループの回数が多すぎて処理が途中で止まらないようにするためであり、今回の数値の確認の件とは本質的に無関係です。

実行結果です。

少々見づらいですが、セーブデータに保存した際のリストと等しいリストが生成できました。

セーブデータの中身はエディタ上で確認しましたが、エディタ上で確認できた値は表示上丸められていただけで実際の値は丸められておらず、入力された値がそのまま保存されていたことが証明されましたね。

パターン1、セーブデータから復元した浮動小数点数リストの中身

パターン1、セーブデータから復元した3Dベクトルリストの中身

パターン2、セーブデータから復元した浮動小数点数リストの中身

パターン2、セーブデータから復元した3Dベクトルリストの中身

パターン3、セーブデータから復元した浮動小数点数リストの中身

パターン3、セーブデータから復元した3Dベクトルリストの中身

まとめです。

コメントにて、型によって容量が異なったり謎に軽くなるのは数値の保存を行う際に丸め処理を行っているからでは?的な指摘をいただきましたが、実際にはそんなことはありませんでした。

浮動小数点数も3Dベクトルもノード上で生成した値をそのまま保存し読み込むことができます。

これで安心してゲーム内セーブ機能を使えますね!


また追加の気付きですが、整数を浮動小数点数を用いて表す際、エディタ上で表示が丸められるだけで実際には16,777,215まで表現できることも分かりました。

小数についても同様で、エディタ上で表示が丸められるだけで実際には小数点第3位以下も保持しているようです。

デバッグしにくいし普通に紛らわしいのでアプデでエディタの表示直してくれないかな~と思ったり。

とにかく浮動小数点数の保存と読み込みに問題がないことが分かって良かったです。

Genshin Impact • Discussions & Sharing
Repost allowed
Miliastra Wonderland
Craftspeople Forum
Windtrace
8
5
1
8
0
All comments 5

Loading...

All Tools

Recommended Users