はじめに
ここ最近、3Dモデルやアバターを AWS と組み合わせて使うことが増えてきました。そのときに気づいた注意点や、先に決めておいたほうがいいことをまとめておきます。
3Dモデルを扱う記事は、だいたいローカルにファイルを置いてUnityやUnreal Engineで動かすところから始まります。クラウドに置いて配る話になると、日本語の情報が急に少なくなります。
実際にVRMのアバターをS3とCloudFrontで配ってみたのですが、「あれ、これ圧縮効いてないのでは?」という場面がありました。
AWS上で3Dモデルを配信する手段
3Dモデルを扱うとき、AWS側の役割は大きく2通りに分かれます。
| 方式 | 使うサービス | 描画するのは | 回線に流れるもの | 課金の単位 |
|---|---|---|---|---|
| ファイルとして配る | S3 + CloudFront | 訪問者のGPU | モデル(最初の1回) | 転送量 |
| サーバー側で描いて映像を流す | Amazon GameLift Streams | GPUサーバー | 映像(ずっと) | GPU時間 |
ブラウザで3Dを表示する場合、ほとんどは1番目になります。S3に置いてCloudFrontから配り、three.js などのライブラリが訪問者の端末で描画します。AWS側がやっているのはファイルを届けることだけで、3Dの計算には一切関わりません。
2番目はピクセルストリーミングと呼ばれる方式です。サーバー側のGPUで描画して、結果の映像をWebRTCで流します。ゲームや、端末では描き切れない規模のモデルを扱うときに使います。訪問者の端末はただの画面になるので、スペックを問いません。
選ぶ基準は性能ではなく課金の単位で考える
どちらを選ぶかは、扱うモデルの重さよりも課金の形で決まります。
GameLift Streams の料金ページにこう書かれています。
You are charged for stream capacity per second, regardless of whether that allocated capacity is actively streaming or idle.
誰も見ていない時間も課金されます。枠を確保している限り止まりません。1つの枠につき同時1セッションなので、2人が同時に見るなら2枠です。単価はオレゴンの gen4n_high で $0.4982 / 容量-時間、gen6n_ultra_win2022 で $1.82 / 容量-時間。公式の試算例では20人同時 × 8時間 × 30日で $8,736/月となっています。
一方、S3 + CloudFront は転送量の課金です。誰も来なければゼロに近づきます。
アクセスが散発的で、モデルが端末で描ける範囲なら、迷わず1番目です。今回配ったのもアバター1体なので、こちらを選びました。
GameLift Streams 側は動かしていないので、上の数字は公式ドキュメントからの引用です。実際に組んだらまた違う発見があるかもしれません。
モデルサイズを削ってみる
ファイルとして配るなら、まず見るのはサイズです。
VRoid Studioから書き出した素のVRMは16.8MBありました。回線の細い場所だと、アバターが出るまで十数秒かかります。
Pythonで変換スクリプトを書いて削りました。
uv run --with numpy --with pillow python tools/avatar/make_cat_maid.py \
--max-texture 1024 --slim --webp \
input.vrm output.vrm
| やったこと | 効果 |
|---|---|
テクスチャを1024pxのWebP(EXT_texture_webp)に変換 |
2.9MB → 0.4MB |
| 使っていない表情モーフを57個中43個削除 | |
| 参照されていないデータを除去 | |
| 合計 | 16.8MB → 5.4MB |
いちばん効果があったのはテクスチャの変換です。VRMの中身はglTFなので、テクスチャはPNGで入っています。これをWebPに差し替えるだけで大きく落ちます。EXT_texture_webp という拡張に対応したローダーなら、そのまま読めます。
ただし解像度を落としすぎると顔の粗くなってしまいます。512pxのPNGを試したときは戻しました。1024pxのWebPなら、見た目を保ったまま2.9MBが0.4MBになります。
表情モーフのほうは、使っていないものを消しても見た目に影響しません。VRoidから書き出すと喜怒哀楽から母音の口の形まで揃って入ってきますが、実際に動かすのは数個です。そのため、使ってないアセットは全部削除しても構いません。
CloudFronで3Dモデルを扱う際の注意点
5.4MBまで落として配信したところ、レスポンスに content-encoding が付いていませんでした。自動圧縮が効いていません。CloudFrontの自動圧縮には条件があって、そのうち2つを3Dモデルは満たせません。
対応しているContent-Typeに入っていない
CloudFrontが自動で圧縮するのは、レスポンスの Content-Type が決められた48種類に該当する場合だけです。公式ドキュメントに一覧があります。
application/javascript や text/css 、 image/svg+xml が並んでいます。model/ で始まるものは1つもありません。VRMやGLBが使う model/gltf-binary も、 model/gltf+json も対象外でした。
CloudFrontの圧縮はWebページを速くするための機能なので、3Dモデルは想定の外にあります。
10MBを超えると圧縮されない
条件はもう1つあります。
CloudFront compresses objects that are between 1,000 bytes and 10,000,000 bytes in size.
圧縮されるのは1,000バイトから10,000,000バイトの範囲だけです。3Dモデルは10MBを超えることが珍しくないので、仮に Content-Type を対応しているものへ変えたとしても、大きいモデルは通りません。
この2つが重なるので、Content-Typeを直せば圧縮されるという読みは外れます。
自分で圧縮してから置いてみる
対処は同じドキュメントに書いてあります。
When a response from an origin includes the
Content-Encodingheader, CloudFront doesn't compress the object, regardless of the header's value.
オリジンが Content-Encoding を付けて返せば、CloudFrontは再圧縮せずそのまま流します。こちらで先に圧縮してS3へ置き、メタデータに Content-Encoding: gzip を書いておけば通ります。
ビルド時にgzipをかけます。
import { gzipSync } from 'node:zlib'
import { readFile, writeFile } from 'node:fs/promises'
const vrm = await readFile(source)
const gzipped = gzipSync(vrm, { level: 9 })
await writeFile(dest, gzipped)
S3へ置くときにCDK側でメタデータを付けます。
new BucketDeployment(this, 'Avatar', {
sources: [Source.asset(AVATAR_DIR)],
destinationBucket: bucket,
destinationKeyPrefix: 'avatar',
contentType: 'model/gltf-binary',
contentEncoding: 'gzip',
})
ブラウザは Content-Encoding: gzip を見て自動で展開するので、読み込む側のコードには手を入れていません。
公開したあと curl で測りました。
$ curl -sI https://<ドメイン>/avatar/maid.vrm
HTTP/2 200
content-type: model/gltf-binary
content-encoding: gzip
content-length: 2488567
cache-control: public, max-age=604800
5.4MBが2,488,567バイト、約2.4MBになりました。
同じ配信の index.html には content-encoding: br が付いています。CloudFrontの自動圧縮はHTMLには効いているわけです。1つのディストリビューションの中で、自動で圧縮されるファイルと、自分で圧縮しないといけないファイルが並ぶことになります。
手元で同じ構成を組むなら、まず curl -sI でレスポンスヘッダーを見てください。content-encoding が付いていなければ、CloudFrontは素通ししています。
キャッシュと読み込みのタイミング
配れるようになったら、最後に読ませ方を決めます。
CloudFrontのレスポンスヘッダーポリシーで、パスごとにキャッシュの寿命を変えました。
| パス | Cache-Control | 理由 |
|---|---|---|
index.html |
no-cache |
更新したらすぐ反映させたい |
/assets/* |
max-age=31536000, immutable |
ファイル名に中身のハッシュが付く |
/avatar/* |
max-age=604800(7日) |
モデルは滅多に変えない |
/images/* |
max-age=86400(1日) |
モデルは長く持たせる側に置きます。数MBのファイルを毎回取りに来られると、転送量がそのまま請求になります。
描画ライブラリもそれなりの重さです。three.js と @pixiv/three-vrm を合わせてgzipで約200KB。サイト本体のJSが140KBだったので、素直に入れると倍を超えます。ページが表示されて落ち着いてから読み込むようにしました。
const start = async () => {
const { MascotStage } = await import('../avatar/MascotStage.ts')
// アバターを組み立てる
}
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(start, { timeout: 3000 })
} else {
setTimeout(start, 1200)
}
画面幅が1024px未満のときは、ライブラリもモデルも読み込んでいません。小さい画面に2.4MBを送る理由が見つかりませんでした。
描画そのものにも制限をかけます。requestAnimationFrame をそのまま回すと120Hzのディスプレイでは120fpsで回り続けるので、30fpsで頭打ちにして、タブが裏に回ったら止めました。
さいごに
3Dモデルをクラウドで扱うといっても、S3 + CloudFront で配る構成では、AWS側は倉庫と配送しかしていません。モデル描画を行うのは、ユーザーのGPUです。
そのため設計で工夫すべき点は3D技術ではなく、CDNのキャッシュ、圧縮、MIMEタイプという地味なところになります。
ローカルに同梱する場合と違う制約も2つあります。訪問者ごとにダウンロードが発生することと、再配布のライセンスが問われることです。クラウドに置いて配るのはライセンス上「再配布」にあたるので、モデルやモーションの利用条件は公開前に確認しておいてください。
参考