ゲーム向けファイルシステムの設計の難しさ

ゲーム向けのファイルシステム抽象化は近年超クッソ激烈に複雑化しており、大分手を付けられない問題になってきた。

ゲーム配布/ストレージ様式の変化


...とにかく歴史的に様々な方式が使用されている。例えば、ゲームを発売後パッチするにはHDDやSSDのようなFSを持った大容量ストレージが必要になるが、それが可能になったのは2001年以降(PS B.B. Unitやoriginal Xboxなどの登場以後)と言える。(サテラビュー('95)やDreamcast('98)のようなプラットフォームでもDLCの類は存在したがゲームの不具合修正をpost releaseで行う目的には使えなかった。)
PS4ではプログレッシブダウンロード(ゲームを複数セグメントに分割し、一部のダウンロードが完了した段階で起動許可)をサポートしているが、導入自体はPS3The Last of Usの方が早い( https://www.theverge.com/2013/5/18/4344260/the-last-of-us-play-while-downloading-ps4-ps3 )。
光学ディスクは非常に長いこと使用されているが、実は光学ディスクから直接ゲームを起動できるのは現行機ではPS4だけで、XboxOneは完全インストールを前提としている。PS4も、ディスクと同容量のHDDをゲームプレイには必要としている。

ゲームストレージの課題

ゲームは大量のデータを消費者に届け、かつ動画のようなストリーミングと異なり複雑なアクセスパターンを持つという点では特異なユースケースと言える。このため様々なゲーム固有の考察がファイルシステムには求められている。
シークは非常に古典的だが現在も問題になる。CD-ROMゲーム機の登場以降、ゲームはCD/DVD/BDのような光学ディスクで配布されるだけでなく、ダウンロードゲームも結局はHDDにインストールされる可能性があるためよく使用されるデータはコピーしてでも近傍に配置する等の配慮が必要になる。
"スクールガールストライカーズの内製クライアントエンジン"( http://www.jp.square-enix.com/conference/2014/technical_seminar/img/pdf/SQEX_DevCon_sugimoto.pdf )は既に4年も前の講演だが現代でも有効な内容で、この領域にいくつかの知見を提供している。スクールガールストライカーズのようなid引きおよびオンメモリ配列によるによるファイルアクセスは多くのゲームで伝統的に使用されており、パッケージ化されたミドルウェアとしては例えばファイルマジックPRO( http://www.cri-mw.co.jp/product/cs/filemajikpro/index.html )が有る。
(モバイルデバイスではフラッシュメモリを前提として良くなったため、データのコピー等を伴うシーク最適化はほぼ不要になった。しかし、プラットフォーム/OSのファイルアクセスは依然非効率なケースがあるため特に理由の無い限りはパックファイルを採用する方がパフォーマンス上は有利なことが多い気がする。)
パッチはシークに比べると最近出てきた問題だがどのプラットフォームでも等しく問題になっている。例えば、UWPではパッチのダウンロードサイズを減らすために以下のようなアドバイスをしている: https://blogs.msdn.microsoft.com/appinstaller/2016/12/03/differential-updates-for-uwp-apps/

  1. パッケージに含まれるファイル一つ一つのサイズを小さくする
  2. ファイルの変更ではなく、ファイルの追加を行う
  3. ファイルを変更しなければならない場合は変更を64KiBのブロック単位にする

そもそもUWPではファイル単位でのパッチを行うが、Unreal Engineのような通常のゲームエンジンはファイルをpackしてしまうためUWPのパッチ戦略はあまり上手く適合していない。もし本当にパッチを効率的に配信する必要があるならば、パックファイル内のファイルを64KiB境界にアラインする必要がある。
...当然このアライン単位はプラットフォーム毎に異なり、例えばSteamの場合は128MiBになっている。Androidはbsdiffを採用しているためアライン単位は存在しない https://developers-jp.googleblog.com/2016/12/saving-data-reducing-the-size-of-app-updates-by-65-percent.html (が、そもそもサイズ制約から.apkでゲームアセット全体を配布するのはあまり現実的でもない)。
大きく分けて、パッチには2戦略存在する:

  1. 完全適用型: ネットワークからダウンロードしたパッチファイルを元にストレージ上のパッケージを書き換えてしまうもの。ユーザはパッチの適用処理を待つ必要がある。UWPやSteam等プラットフォームが提供するパッチシステムはこちらを実装している事が多い。
  2. オーバーレイ型: パッチではファイルの追加を行い、ゲーム側でファイル読み取り先を置き換えるもの。前出のファイルマジックPROはこちらを実装している。適用コストは安いがパッケージの占める容量は膨張していくため大きな修正には向かない。

ゲームエンジンから見た場合、これらのパッチ戦略はプラットフォームが提供するものに従うしかないためなかなか匙加減が難しい。ファイルを一切パックせずにシステムが提供するファイルシステムに全てを任せるのが最も(パッチという面では)効率的になるが、パックファイルを使用したアクセス面での効率化を図ることができないことになる。
プログレッシブダウンロードは新しい話題で、ゲームを完全にダウンロードする前でも起動できるようにし、必要なアセットをOn Demandで供給したり不要となったアセットをデバイスから削除して容量を節約できるようにする。この分野ではiOSのOn demand resourcesが必要な機能性をよく要約している https://developer.apple.com/jp/documentation/FileManagement/Conceptual/On_Demand_Resources_Guide/index.html
iOSのOn-demand resourceではファイルを"タグ"に分類し、アプリケーションからは必要なタグを都度宣言させる方法を取る。タグに対応したファイルのダウンロードとマウントおよび削除iOS側で自動的に管理される。
プログレッシブダウンロードの難しい点はデバッグで、ネットワーク処理はiOSが代行するもののエラー等に関してはアプリケーション側でハンドルする必要がありアプリケーション実装の複雑さを増加させてしまう。しかもプラットフォーム要求なので使わないわけにも行かないという問題がある。(マルチプラットフォームでのリリースを考えると、デバッグのことを最初から考慮して自前のシステムを組んだ方が楽だが。。)

どこまで抽象化できるか

とりあえず現時点では、UnityやUnreal Engineのような統合型のゲームエンジンを採用するプロジェクトではあんまりチャンスが無い。これらのゲームエンジンでは自前のアセットパイプラインを前提に置いており、エンジンのユーザには細かい制御を提供していない。

Unity公式の仕組みには細かくファイル(アセット)を「管理する」という設計思想がありませんので、Unityなら共通であろうという処理が作れないためです。

Unityのシーンシステムを使わず、ゲーム内の複数シーンを単一シーンに纏めるといった工夫をするケースも有るが、この"宴"のケースのようにゲームエンジンゲームエンジンを載せてしまうといった解法も有る。(ただし、Unity 2018からAddressable Asset System https://www.slideshare.net/UnityTechnologiesJapan/unite-2018-tokyo-96499382/UnityTechnologiesJapan/unite-2018-tokyo-96499382スクリプト可能なアセットパイプラインでこれらの問題への対処を始めた。)
クライアント実装の基本的な指針としては:

  1. 可能な限りOSのファイルシステム機能を直接使用しない。例外: プログレッシブダウンロードなどプラットフォーム機能が必要な場合。通常、ミドルウェア類はファイルシステムインターフェースを外部から与えられることが多い。通常のシチュエーションではstdioのような標準ライブラリが使用できなくなるのに注意する。
  2. 階層化ファイルシステムを期待しない。実はパスの解決は重い操作で、ファイルシステムによってはシークが必要になる可能性がある。例えば"Assets/a/b/c.png"のようなパスはディレクトリをAssets→a→bと辿る操作が入る可能性がある。一般的には、ファイルをpackすることで階層化ファイルシステムのオーバヘッドを削減していることが多い。
  3. "カレントディレクトリ"を使用しない(変更しない)。過去にはWinCEのようにカレントディレクトリのコンセプト自体が無いシステムも存在したが、殆どのシステムには依然カレントディレクトリのコンセプト自体は存在する。しかし、packファイル上に実現されるストレージでカレントディレクトリをエミュレーションするのは非効率と言える。また、カレントディレクトリはプロセスグローバルなので、スレッド環境下では良く使えない。"Multithreaded applications and shared library code should not use the GetCurrentDirectory function and should avoid using relative path names. " ( https://msdn.microsoft.com/en-us/library/aa364934%28VS.85%29.aspx )
  4. 可能な限り文字列パスではなくIDベースのアクセスを採用する(= パスオブジェクトを抽象化する)。ただし、これが有効なシチュエーションは非常に限られている(自前のアーカイブシステムを実装する余地があるケースくらい)。Addressable Assetのように文字列をファイルの識別子として使用できることが多く、ミドルウェア類も通常は文字列でのファイル指定を期待していることが多い。
  5. ファイルがダウンロードされていない可能性を考慮し、ファイルのavailabilityをクエリするインターフェースを用意する。例えば、iOSのOn demand resourceではタグの単位でファイルのavailablityが変わることになる。

エンジン実装時のポイントになり得るのは、良いプロファイラを実装することと、プラットフォームのパッチ/ストリーミング戦略に良く適合することと言える。ファイルアクセスのプロファイラは一般的な機能になりつつある。例えばゲーム起動時にアクセスされるファイルをpackファイル中の近所に配置することでシーク距離を減らしHDDインストールでのパフォーマンスを向上できる。
プラットフォームのパッチ/ストリーミング戦略への適合は今のところ良い指針が無いが、現状のプラットフォームではiOSのOn demand resourceが最も保守的な実装となっているため、そこに合わせるのが良いと考えられる。