2020-11-16〜2020-11-23 に開催された VIRTUAL ART BOOK FAIR の Web サイト開発を担当した。
また、イベントの一環として、開発の裏側についてYoutube Liveで話したりした。
https://2020.virtualartbookfair.com/
VIDEO www.youtube.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演出がない代わりに、テーブルの配置の仕組みや、画像を大量にロードするための仕組み作りに苦戦することになった。
エントランスの空間には、東京都現代美術館 の外側にラファエル・ローゼンダールの作品が展示されている他、特設ページや外部サイトへのリンクとなるオジェクトが多数配置されている。
ラファエル・ローゼンダールの作品展示については、アーティストと直接メールでやり取りしつつ、オブジェクトの配置や見せ方を決めていった。
ユーザーに作品の大きさを感じさせるため、作品にフォーカスしたときには背後に他の作品や美術館が見えるようにした。
また、今回の展示作品はすべて白一色であり、重なったときに形が把握しづらいという問題があったため、サイト全体のテイストと衝突しない範囲でフォグを追加するなどの調整を行った。
フォグやライトの調整で視認性を確保している
制作の進め方については、ディレクター兼フロントエンド担当の萩原さんのインタビュー記事があるので、こちらもどうぞ。
sb-rs.com
開発の体制
開発は大きくWebチームとグラフィック、建築チーム、運営チームに分かれて作業を進めた。
3D空間のデザインや仕様については、主に建築チームと僕とで検討し、グラフィックデザイナーに監督してもらうという形で開発をすすめた。
Webサイトの開発に建築系の人が入るのはメチャクチャ珍しいし、僕も建築系の人と一緒に仕事をするのは初めてだった。
最近は建築の世界でもRhinoceros やHoudiniなどの3Dソフトを使うというのは伝え聞いていたが、実際に会話してみると、ライティングやマテリアルに関する話など、3Dの専門的な知識について特に注釈を入れなくても会話が通じることが多くて感動した。
Webチームは開発期間が短かったこともあり、担当分野でスッパリ分業する体制になった。
僕はReact-WebGL 連携用のHooksを用意したり、たまにAWS のコンソールで設定をいじったりする以外は、ほとんどWebGL 部分だけを開発することになった。
技術的な構成
構成
フロントエンド: Gatsby , Three.js
バックエンド: AWS (Amplify, S3, CloudFront, Lightsail)
アプリのホスティング にはAmplifyが採用された。GitHub にpushすると自動でstaging環境がデプロイされて便利。
環境構築は全てバックエンド担当の黒田さんがやってくれた。
出展者のデータはLightsail上のWordPress で管理し、フロントから取得するデータは全て事前にJSON に吐き出してもらった。
フロントとバックエンドは完全分業だったので、API の事を考える必要がなくてやりやすかった。
技術的に頑張ったこと
文字表示
WebGL 内で描画されているテキスト
今回はブース名やリンクの表示のため、テキストを3D空間上に描画する必要があった。
WebGL 内にテキストを描画する方法はいくつかあるけど、今回は
OSに関わらずフォントを指定したかった(Univers + ヒラギノ )
使用する文字が限られていた
という理由から、MSDFを事前に生成して表示する方法を採用した。
この手法では、フォントのグリフ(字形)の形状を表す画像を事前に生成しておき、シェーダーで描画している。UnityのTextMeshProなどでも採用されている方式だ。
他の手法と比べると、フォントデータを1枚の画像に収められる、テキストを拡大しても誤差が出にくい、といったメリットがある。
生成されたMSDF(一部)
今回はこれに加え、日本語と英語でフォントを切り替える必要があったため、Fontforge でフォントを合成している。
全体の流れは以下の通り。
WebGL で文字を表示するその他の方法については、以下の記事が詳しい(英語)。
css-tricks.com
パフォーマンス調整
今回はとにかくパフォーマンス調整が大変だった。
言い訳になってしまうけど、オープン前日までモデルの入れ替えなどで忙殺されており、本来やろうと思っていたパフォーマンス周りの調整まで手が届かなかったので、オープン後にも必死でパフォーマンス改善に取り組むことになったのだった……。
おかげで、現在ではスマートフォン でもある程度快適に閲覧できるようになったはず。
今回、パフォーマンス問題は大きく3種類に分けて考えた。
ロード時間はRAILで言うところのLoadに, FPS は Animationに相当する。
FPS 維持やクラッシュ回避については、不要なオブジェクトを無効化するとか、Squooshでテクスチャを軽量化するといった地味な改善で対処した。
また、PC版ではシーン遷移のアニメーションを実現するため、美術館のシーンとブース一覧のシーンを並行して動かしているのだけど、モバイルではメモリ使用量を減らすため、前のシーンを破棄した後に次のシーンを初期化するようにした。
クラッシュは、特にモバイルにおいて、ブース一覧でカメラを移動する際に発生することが多かった。
デバイス をUSBケーブルでPCに接続し、Chrome やSafari でプロファイルをとるにしても、一度クラッシュしてしまうとプロファイルも消えてしまうので、原因を探るのに苦労した記憶がある。
結局、他のメモリ使用量対策に加え、モバイル版ではカメラをズームインすることで一度に表示されるブースの数を減らすという原始的な対策をとった所、クラッシュはほとんど発生しなくなった。
ロード時間短縮
並列ロード
初歩的な事だけど一応。
今回、WebGL 部分はすべてTypeScriptで書いた。
ゲーム内のオブジェクトやステージはクラスとして表現したんだけど、WebGL のデータはモデルのロードなど、時間のかかる処理を行う必要があり、コンストラク タで初期化を完結することが難しかった。
例えば以下の例だと、プロパティを参照するたびにundefinedを考慮する必要があったり、コンストラク タを呼び出した側で初期化の完了を待つのが難しいという問題がある。
class StageA {
model: Model | undefined ;
constructor() {
loadModel() .then( model => {
this .model = model;
} );
}
loop() {
this .model?.update();
}
}
const a = new StageA();
console.log( a.model);
そこで、今回はオブジェクトの初期化はすべて 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 にまとめる
テクスチャを格納した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で固めてロードできるんですね。
次回こういう需要があったら使おうかな。
github.com
やってないこと
今回はやらなくても良いと判断したものや、やりたいけど出来なかった事などがあります。
GPU インスタンシング
GPU インスタンシングは、同じオブジェクトを大量に描画することができる実装テクニックである。
雪や炎といったパーティクル表現から、魚や鳥の群れなど、大量のオブジェクトを動かす場面で多く使われている。
今回はテーブル/壁を大量に描画する必要があり、GPU インスタンシングが有効である可能性があったが、
ブースの形が一定ではなく、管理が大変そう
他の工夫によって十分な FPS が得られた
という事で採用を見送った。
Tree Shaking
Three.js は一般的な JS のライブラリと比べファイルサイズが大きく、bundlefobia によると GZIP 圧縮なしで 633KB にもなる。 (jQuery は 87KB)
このようなファイルサイズの大きいライブラリを使う場合、ライブラリのうち不必要なモジュールを削る "Tree Shaking" というテクニックが用いられる。
Three.js で Tree Shaking を行う方法については以下の記事が詳しい。
この記事によると、Webpack等のプラグイン を使うか、Three.js自体のカスタムビルドを行うのが簡単なようだ。
note.com
今回はJS以外のアセットのサイズが大きく、Tree Shakingで得られるメリットは労力に見合わないと判断し、採用を見送った。
感想
TABFはいつか参加してみたいと思っていたが、まさか開発側として参加する事になるとは思っていなかった。
「即売会のプラットフォーム」というWebコンテンツも、ここまで長い期間開催されるオンラインイベントもとても珍しい。
出展者の熱量も高く、簡素な仕組みの上に工夫をこらしたコンテンツが用意されており、個人的な思い出としても非常に貴重な体験となった。
一方、開発者として反省すべき点も多かった。
3D的な演出を提案する余裕があまりなかった事や、リリース前に十分なパフォーマンスを出せなかった事など色々あるけど、冷静に振り返ってみると、根本にあるのは情報共有の問題という気がしている。
スケジュールについては、リリース直前にデータの修正が出た場合に他のタスクにどれくらい影響が及ぶのか、早い時点で共有できていれば優先順位をつけるのがラク だったと思う。
パフォーマンスの問題については、プロジェクト初期に開発陣で最低限のラインや理想のラインなどを決めておき、例えば定期MTG でLighthouseスコアを見ながら議論するとか、皆で問題を共有しておけばより話がスムーズだっただろう。
ともあれ、大変だけど楽しい仕事だった。
参加者の皆様ならびに関係者の皆様、ありがとうございました。