2020-11-16〜2020-11-23 に開催された VIRTUAL ART BOOK FAIR の Web サイト開発を担当した。
また、イベントの一環として、開発の裏側についてYoutube Liveで話したりした。
https://2020.virtualartbookfair.com/
この記事では、プロジェクト参加の経緯や開発のすすめ方、僕が技術的に頑張ったことについて振り返る。
VIRTUAL ART BOOK FAIR とは
毎年開催されている TOKYO ART BOOK FAIR のバーチャル版。
TOKYO ART BOOK FAIR (以下 TABF) は、アートに関する本や ZINE 等、一風変わった書籍を扱う即売会である。 芸術系のコミケのようなものだ。 TABFは2009年から開催されており、去年は東京都現代美術館で2万人を超える来場者が訪れるなど、非常に大規模なイベントになっている。 今年も東京都現代美術館での開催を予定していたようだが、コロナ禍の影響でリアルでの開催を見送り、代わりにWebブラウザで閲覧できるバーチャル会場を作って開催することになった。
3D部分は当初Unityで作ることも検討されたが、Web部分との連携やモバイル対応の難しさなどからWebGLを採用することになり、WebGL活動をしている僕に依頼が来たのだった。 僕はすでに他の案件で週の半分以上が埋まっていたので週5で参加する訳にはいかず、10月中旬までは週1で、最後の一ヶ月は他の案件を調整して週3ペースで参加することになった。
世界観ができるまで
僕が参加した9月頭の時点では、「エントランスは東京都現代美術館」「メイン会場は果物か野菜の表面」という事は決まっていたけど、実際にどういう見せ方をするかは決まっておらず、zoomで皆から寄せられるアイデアに対して、実装の難しさを判断したり、逆にこういうアイデアもありますよね、といった実装者観点の提案をしていく形になった。
メイン会場は、当初は一人称視点でブースを歩き回る想定だったが、一覧性をあげたいということでクオータービューになった。 メイン会場ではいわゆる3D的な演出はほとんど無いが、ブースごとにテーブルや壁の配置が異なっていたり、出展者がテーブル画像を工夫してくれたことで、見て回るだけで楽しい空間ができた。 ただ、3D演出がない代わりに、テーブルの配置の仕組みや、画像を大量にロードするための仕組み作りに苦戦することになった。
エントランスの空間には、東京都現代美術館の外側にラファエル・ローゼンダールの作品が展示されている他、特設ページや外部サイトへのリンクとなるオジェクトが多数配置されている。 ラファエル・ローゼンダールの作品展示については、アーティストと直接メールでやり取りしつつ、オブジェクトの配置や見せ方を決めていった。 ユーザーに作品の大きさを感じさせるため、作品にフォーカスしたときには背後に他の作品や美術館が見えるようにした。 また、今回の展示作品はすべて白一色であり、重なったときに形が把握しづらいという問題があったため、サイト全体のテイストと衝突しない範囲でフォグを追加するなどの調整を行った。
制作の進め方については、ディレクター兼フロントエンド担当の萩原さんのインタビュー記事があるので、こちらもどうぞ。
開発の体制
開発は大きくWebチームとグラフィック、建築チーム、運営チームに分かれて作業を進めた。 3D空間のデザインや仕様については、主に建築チームと僕とで検討し、グラフィックデザイナーに監督してもらうという形で開発をすすめた。
Webサイトの開発に建築系の人が入るのはメチャクチャ珍しいし、僕も建築系の人と一緒に仕事をするのは初めてだった。 最近は建築の世界でもRhinocerosやHoudiniなどの3Dソフトを使うというのは伝え聞いていたが、実際に会話してみると、ライティングやマテリアルに関する話など、3Dの専門的な知識について特に注釈を入れなくても会話が通じることが多くて感動した。
Webチームは開発期間が短かったこともあり、担当分野でスッパリ分業する体制になった。 僕はReact-WebGL連携用のHooksを用意したり、たまにAWSのコンソールで設定をいじったりする以外は、ほとんどWebGL部分だけを開発することになった。
技術的な構成
アプリのホスティングにはAmplifyが採用された。GitHubにpushすると自動でstaging環境がデプロイされて便利。 環境構築は全てバックエンド担当の黒田さんがやってくれた。
出展者のデータはLightsail上のWordPressで管理し、フロントから取得するデータは全て事前にJSONに吐き出してもらった。 フロントとバックエンドは完全分業だったので、APIの事を考える必要がなくてやりやすかった。
技術的に頑張ったこと
文字表示
今回はブース名やリンクの表示のため、テキストを3D空間上に描画する必要があった。 WebGL内にテキストを描画する方法はいくつかあるけど、今回は
- OSに関わらずフォントを指定したかった(Univers + ヒラギノ)
- 使用する文字が限られていた
という理由から、MSDFを事前に生成して表示する方法を採用した。 この手法では、フォントのグリフ(字形)の形状を表す画像を事前に生成しておき、シェーダーで描画している。UnityのTextMeshProなどでも採用されている方式だ。 他の手法と比べると、フォントデータを1枚の画像に収められる、テキストを拡大しても誤差が出にくい、といったメリットがある。
今回はこれに加え、日本語と英語でフォントを切り替える必要があったため、Fontforgeでフォントを合成している。 全体の流れは以下の通り。
- Fontforgeで日本語/英語フォント合成
- ブース一覧JSONから使用されている文字を抜き出す
- soimy/msdf-bmfont-xml でフォントアトラスを生成
- Jam3/three-bmfont-text でテキストを表示
WebGLで文字を表示するその他の方法については、以下の記事が詳しい(英語)。
パフォーマンス調整
今回はとにかくパフォーマンス調整が大変だった。
言い訳になってしまうけど、オープン前日までモデルの入れ替えなどで忙殺されており、本来やろうと思っていたパフォーマンス周りの調整まで手が届かなかったので、オープン後にも必死でパフォーマンス改善に取り組むことになったのだった……。 おかげで、現在ではスマートフォンでもある程度快適に閲覧できるようになったはず。
今回、パフォーマンス問題は大きく3種類に分けて考えた。
- ロード時間
- FPS維持
- クラッシュ回避
ロード時間はRAILで言うところのLoadに, FPSは Animationに相当する。
FPS維持やクラッシュ回避については、不要なオブジェクトを無効化するとか、Squooshでテクスチャを軽量化するといった地味な改善で対処した。 また、PC版ではシーン遷移のアニメーションを実現するため、美術館のシーンとブース一覧のシーンを並行して動かしているのだけど、モバイルではメモリ使用量を減らすため、前のシーンを破棄した後に次のシーンを初期化するようにした。
クラッシュは、特にモバイルにおいて、ブース一覧でカメラを移動する際に発生することが多かった。 デバイスをUSBケーブルでPCに接続し、ChromeやSafariでプロファイルをとるにしても、一度クラッシュしてしまうとプロファイルも消えてしまうので、原因を探るのに苦労した記憶がある。 結局、他のメモリ使用量対策に加え、モバイル版ではカメラをズームインすることで一度に表示されるブースの数を減らすという原始的な対策をとった所、クラッシュはほとんど発生しなくなった。
ロード時間短縮
並列ロード
初歩的な事だけど一応。
今回、WebGL部分はすべてTypeScriptで書いた。 ゲーム内のオブジェクトやステージはクラスとして表現したんだけど、WebGLのデータはモデルのロードなど、時間のかかる処理を行う必要があり、コンストラクタで初期化を完結することが難しかった。 例えば以下の例だと、プロパティを参照するたびにundefinedを考慮する必要があったり、コンストラクタを呼び出した側で初期化の完了を待つのが難しいという問題がある。
class StageA { model: Model | undefined; // 初期化が非同期なので、undefinedが入ってしまう constructor() { loadModel().then(model => { this.model = model; }); } loop() { this.model?.update(); // undefinedを考慮する必要がある } } const a = new StageA(); console.log(a.model); // undefined
そこで、今回はオブジェクトの初期化はすべて static async init(): Promise<T>
に統一し、初期化処理を async/await で書き下せるようにした。
これにより、 Promise.all
で簡単に初期化処理を並列化でき、ワールド遷移のアニメーション処理も async/await で書けて便利だった。
class Game { private world: World; private constructor(private worlds: World[], worldId: number) { this.world = worlds[worldId]; // 現在のワールドを決定 } /** 初期化 */ static async init(worldId: number): Promise<Game> { // ワールドを初期化 const worlds = await Promise.all([ WorldA.init(), WorldB.init(), ]); return new Game(worlds, worldId); } /** ワールド遷移の処理 (簡略化したもの) */ async changeWorld(worldId: number) { // 現在のワールドの非表示アニメーション await this.world.leave(); this.world = this.worlds[worldId] // 次のワールドの表示アニメーション await this.world.enter() } }
画像を GLB にまとめる
ブース一覧画面では、出展者のアイコン、テーブル、壁の画像をすべてロードする必要があった。 全部で約1000枚にもなるため、これを愚直にロードするとリクエストが多すぎて困ったことになる。 開発当初は全て普通にロードしていたので、画像が全てロード完了するまで非常に時間がかかっていた。
最初に考えたのは、CSSスプライトのように画像を1枚にまとめてしまう方法だった。 アイコン画像ひとつの画像サイズを128x128とすると、2048x2048のテクスチャ1枚に収めることができる(2048 / 128 = 16なので、16 * 16 = 256枚までいける)。 node-canvasを使ってアイコン画像を1枚のテクスチャにまとめるスクリプトを書き、実際に試してみたんだけど、今度はFPSが激しく低下する現象にみまわれた。 詳しい原因はわからないが、おそらく1枚のテクスチャのフェッチ回数が多すぎてバスが詰まっていたのでは無いか……。
なんとかして1つのファイルに固められないかな~ということで思いついたのが、GLBファイルを画像アーカイブとして使う方法だった。
今回、WebGLで使う3DモデルはすべてGLB形式でロードしている。 GLBはモデルデータとマテリアル、テクスチャのデータをひとつのバイナリに固めたもので、最近のWebGLアプリでは大抵これが使われている。 GLBはテクスチャを含むことができるので、すべてのブースの画像をGLBファイルにまとめられるのでは?と考えたのだった。
というわけで、画像ファイルからOBJ/MTLファイルを経由してGLBファイルを生成するスクリプトを書いてみた。 OBJ/MTLファイルは3Dモデルを扱う形式の一つ。中身はただのテキストであり、テクスチャは画像ファイルへのパスを書く形式なので、自動生成するのがとても楽だった。。
処理の流れは以下の通り。
aws s3 sync
で出展者のアイコン、テーブル、壁の画像を手元にダウンロード- ImageMagick (mogrify) で 2 のべき乗サイズにリサイズ
- obj/mtl を生成
- obj2gltf で GL
生成されたGLBファイルをロードしてテクスチャを利用してみたところ、特に問題なく表示できたので、今回はこれを採用した。 1000 リクエストが 3 リクエストに減ってめでたい。
……と、ブログを書いてる途中に見つけたんだけど、yomotsu/zipLoaderを使えばブラウザで普通にリソースをzipで固めてロードできるんですね。 次回こういう需要があったら使おうかな。
やってないこと
今回はやらなくても良いと判断したものや、やりたいけど出来なかった事などがあります。
GPU インスタンシング
GPU インスタンシングは、同じオブジェクトを大量に描画することができる実装テクニックである。 雪や炎といったパーティクル表現から、魚や鳥の群れなど、大量のオブジェクトを動かす場面で多く使われている。
今回はテーブル/壁を大量に描画する必要があり、GPU インスタンシングが有効である可能性があったが、
- ブースの形が一定ではなく、管理が大変そう
- 他の工夫によって十分な FPS が得られた
という事で採用を見送った。
Tree Shaking
Three.js は一般的な JS のライブラリと比べファイルサイズが大きく、bundlefobia によると GZIP 圧縮なしで 633KB にもなる。 (jQuery は 87KB) このようなファイルサイズの大きいライブラリを使う場合、ライブラリのうち不必要なモジュールを削る "Tree Shaking" というテクニックが用いられる。
Three.js で Tree Shaking を行う方法については以下の記事が詳しい。 この記事によると、Webpack等のプラグインを使うか、Three.js自体のカスタムビルドを行うのが簡単なようだ。
今回はJS以外のアセットのサイズが大きく、Tree Shakingで得られるメリットは労力に見合わないと判断し、採用を見送った。
感想
TABFはいつか参加してみたいと思っていたが、まさか開発側として参加する事になるとは思っていなかった。 「即売会のプラットフォーム」というWebコンテンツも、ここまで長い期間開催されるオンラインイベントもとても珍しい。 出展者の熱量も高く、簡素な仕組みの上に工夫をこらしたコンテンツが用意されており、個人的な思い出としても非常に貴重な体験となった。
一方、開発者として反省すべき点も多かった。 3D的な演出を提案する余裕があまりなかった事や、リリース前に十分なパフォーマンスを出せなかった事など色々あるけど、冷静に振り返ってみると、根本にあるのは情報共有の問題という気がしている。 スケジュールについては、リリース直前にデータの修正が出た場合に他のタスクにどれくらい影響が及ぶのか、早い時点で共有できていれば優先順位をつけるのがラクだったと思う。 パフォーマンスの問題については、プロジェクト初期に開発陣で最低限のラインや理想のラインなどを決めておき、例えば定期MTGでLighthouseスコアを見ながら議論するとか、皆で問題を共有しておけばより話がスムーズだっただろう。
ともあれ、大変だけど楽しい仕事だった。 参加者の皆様ならびに関係者の皆様、ありがとうございました。