VTuberが富士山を飛んで配信する日 — CesiumJSとThree.jsを縫い合わせる夜
深夜 2 時、配信画面のなかで VTuber が富士山の上空を飛んでいる。高度 3,200m の数字が HUD に点滅し、右下のミニマップには視聴者の地域分布が淡く光っている。スパチャが届いた瞬間、機体がぐっと加速して雲を突き抜ける。視聴者チャットでは「次どこ飛ぶ? A: スカイツリー / B: 北海道の海岸線」の投票が走っている。
これ、OBS で素材を合成した画面ではなく、ブラウザで動いている 1 枚の WebGL だ。背景のリアル地形も、その上を飛ぶアバターも、スパチャで反応する HUD も、同じ Canvas のなかで同時に走っている。カラクリは、別物と思われている 2 つの 3D ライブラリを強引に縫い合わせるところにある。普段このブログでは NFT の話しか書いていないが、今回はあえて別方向に寄り道する。それくらい、この縫い目の設計は遊び甲斐がある。
※ 上の配信シーンはまだ誰も作っていない。この記事はその設計ノートで、既存デモと公開実装を下敷きに「こう組めば届く」を書く。なお想定は 個人 VTuber / 個人開発者。大手事務所所属 Liver の配信で採用する場合は、各事務所のガイドラインと収益化条項を別途確認してほしい。
きっかけは "Vibe coding" で作られた地球飛行シム
発端は r/threejs に上がった web-flight-simulator というデモだった[^1]。u/dimartarmizi という個人が Vite + 素の JavaScript で組んだ Web 版フライトシミュレーターで、地球全体の衛星画像を舞台に F-15 が飛べる。操作は 6 軸(ピッチ・ロール・ヨー・スロットル・高度・ブースト)、HUD には速度・高度・方位が出て、右下には GPS ミニマップ。
デモはそのまま動くのでまず遊んでみるといい。ただしライセンスはリポジトリ側を必ず自分の目で確認してから改変・配信転用を決めること[^2]。OSS デモでも「コードは自由、収益化配信は別ライセンス」が混ざるのはよくある。
面白いのは作者自身が README で「Vibe coding」と謙遜していることだ。雰囲気で作りました、の意。なのにコメント欄には「After Burner vibes(セガの名作を思い出す)」「エベレストでも動くの?」と歓声が並んでいる[^1]。気負いのなさと完成度のギャップがそのまま、このプロジェクトの思想になっている。
この設計の飛行機を VRM アバターに差し替える。そこから VTuber 配信演出としての可能性が見えてくる。
配信セットが地球になるとき — 応用パターン 3 つ
リアルロケーション配信:聖地を飛ぶアバター
富士山上空、北海道の海岸線、旅先で印象に残った街並み。衛星画像の実地形を背景にアバターが旋回する。事前ロケハンもグリーンバックもいらないし、天候も時間帯も自由だ。グリーンバック配信と決定的に違うのは、世界が配信と独立して継続している感覚が出ること。風景は書き割りではなく、地理データそのものだ。
ただし舞台選びは慎重に。「自宅」「通学路」のような個人特定につながる場所は避ける。衛星画像は民家が識別できる解像度まで寄れるので、視聴者の目の前でドクシングが起きかねない。旅先か、ランドマークか、「記憶の場所」にとどめる。
インタラクティブ配信:視聴者が共同操縦する
チャット欄に「A: 富士山 / B: スカイツリー」と出す。投票結果に応じて機体の目的地が変わり、自動操縦で移動する。配信者は実況に集中していればいい。配信の双方向性が "リアクション" から "共同操縦" に近づく。
実装面の現実:YouTube Live にはネイティブの A/B 投票 API がないので、チャットメッセージを集計する bot を自作するか、Streamlabs などの中継ツールの Polls 機能を使う。Twitch なら EventSub の channel.poll.begin/progress/end でネイティブに拾える[^12]。プラットフォームで工数が違うことは先に知っておいたほうがいい。
動的 HUD:配信メトリクスが計器に溶ける
スパチャが届いたらエンジンが吹く、同接数が伸びたら高度上限が上がる、ミニマップに視聴者の地域分布(国単位・集計値・YouTube Analytics は 5 分前後のラグあり)を表示する。KPI を演出に溶かし込む発想だ。
設計上の線引きだけ意識しておく。これは「金額で視聴者に特典を与える」のではなく「演出がリアクションする」構造に徹する[^3]。機体挙動が派手になるのは視聴者全員の画面で同じに見える、個別の機能差ではない。この境界を守れば YouTube Super Chat の運用ポリシーの範囲で動かせる。
ここまでは "絵" の話。ここからは、この絵を 60fps で破綻させずに動かすための "縫い目" の話をする。
2 つの 3D 世界が衝突する地点 — 座標系という国境
CesiumJS と Three.js は、どちらも "3D ライブラリ" と呼ばれるが、住んでいる宇宙が違う。
- CesiumJS: 地理座標系。原点は地球の中心、位置は
Cartesian3(x, y, z)または緯度経度高度で表現する。単位はメートルで、地球半径ぶんの約 6,378,000 という桁の値が平気で飛んでくる[^4] - Three.js: ローカル座標系。原点はシーンの任意の点、位置は
Vector3(x, y, z)。スケールは基本メートル単位で、近傍 1〜数万の範囲で遊ぶのが素直
この 2 つを素朴に重ねると、いくつかの破綻ポイントが見える。地球スケールの浮動小数点誤差でアバターがカクつく(いわゆる jitter)。カメラの FOV や near/far が噛み合わず z-fighting(描画の前後関係が崩れる現象)が起きる。ライティングの流儀が違って片方だけ真っ暗になる。
統合レイヤーを自分で書く — 最小の設計図
CesiumGS 公式も推奨する定石は、2 つの canvas を重ねてカメラを同期させ、精度は floating origin で取り戻すという構成だ[^5]。核心は 3 つ。
1. カメラ同期
CesiumJS の viewer.camera から毎フレーム viewMatrix を取り出し、Three.js の PerspectiveCamera に反映する。擬似コード:
function syncCamera(cesiumCamera, threeCamera) {
threeCamera.matrixAutoUpdate = false;
threeCamera.matrixWorld.fromArray(cesiumCamera.inverseViewMatrix);
threeCamera.matrixWorldInverse.copy(threeCamera.matrixWorld).invert();
// Cesium の frustum 角は radians、Three は degrees
threeCamera.fov = Cesium.Math.toDegrees(cesiumCamera.frustum.fovy);
threeCamera.updateProjectionMatrix();
}
Cesium の Matrix4 は column-major で Three.js と互換なので、fromArray でそのまま渡せる。
2. 座標変換 — 公式 API を踏む
地理座標 → ローカル座標は Cesium.Transforms.eastNorthUpToFixedFrame(origin) で ENU↔ECEF の 4x4 行列を取り、Matrix4.multiplyByPoint で原点近傍に落とす[^4]。Cartesian3.fromDegrees(lng, lat, height) は原点を作るときに使うだけにする。自前の三角関数で ENU を書くと緯度依存の誤差を踏む。
基準点(floating origin)を固定しないのが肝だ。アバターから数十 km 離れたらジャンプさせて再設定する。これをサボると地球半径クラスの数値を毎フレーム扱うことになり、float32 の精度が負ける。
3. レンダリングの 2 パス合成
Cesium の canvas を下、Three.js の canvas を上に重ねる。カメラ同期さえ取れていれば、Cesium 側は multi-frustum 機構で遠景〜近景の深度精度を自力で吸収してくれる[^5]。Three.js 側は logarithmicDepthBuffer: true を有効化する(Cesium 側の scene.logarithmicDepthBuffer も既定で true、両方整合させるのがポイント[^4])。
ライティングは、Cesium の太陽ベース光源と Three.js の AmbientLight / DirectionalLight が二重に効きがち。片側に寄せるか、強度を半分に落とすのが安全。雲やポストエフェクトの合成位置を変えるだけで配信映像の質感は大きく変わるので、ここは遊ぶ場所でもある。
ここまで組めれば、乗るのは飛行機でなくていい。アバターでも、AI エージェントでも、画面の向こうを覗く "顔" でもいい。
週末に試す最初の一歩、そしてその先
やることは 4 つ。
- web-flight-simulator を clone して
npm run dev(LICENSE は改変前に自分で確認、配信収益化する場合は特に) - 航空機モデルを VRM/GLB アバターに差し替え、回転軸だけ合わせる。VRM 1.0 ならメタの
avatarPermission/commercialUsage/modificationを配信用途で許可しているモデルだけを選ぶ[^6] - 緯度経度のプリセットを 3 つ用意(ランドマーク、旅先、記憶の場所)、キーで切り替え
- チャット連動は、YouTube なら Streamlabs / StreamElements などの中継 WebSocket、Twitch なら EventSub WebSocket を引いて、スパチャ件数 → スロットル、同接数 → 高度上限にマッピング[^7]。配信プラットフォームとブラウザの間には別途 数秒のラグがあるので、「ほぼ即時」ではなく「数秒遅れで気持ちよく連動」を設計目標にする
4 のマッピングまで到達すれば、配信メトリクスが機体挙動に溶ける最小プロトが動く。地形配信については、Cesium Ion Community Tier は非商用前提なので、収益化配信を回すなら有償プランへの切替か、MapTiler / OpenStreetMap 派生など別タイルソースへの差し替えを検討する[^4]。国土地理院タイルを使う場合はクレジット表示が必須[^8]。
その先にある拡張も 2 つだけ挙げておく。ひとつは LiquidTRO の pokebox[^9] が使っている Off-Axis Perspective Projection[^10] の導入だ。MediaPipe で視聴者の顔位置を拾い、視差に合わせて射影行列を動かす。配信画面そのものが "のぞき窓" になって、地球が奥行きを持って見える。もうひとつは ai-agent-session-center[^11] のような R3F + WebSocket 構成への移行で、配信サーバー内部のイベント伝搬を 3〜17ms 程度に詰められる(配信プラットフォームを跨ぐ遅延は別物、秒単位で乗る)。内部バスが速いと、同接数連動や投票結果の反映が引っかからなくなる。
「地球を背景にアバターを飛ばす」はもう個人開発の射程内にある。難所はカメラ同期・座標変換・深度と照明の整合、この 3 つだけ。縫い目を閉じ込める関数が手元にあれば、あとはどこを舞台にするかの選択だけが残る。
Sources / 参考文献
[^1]: Web Flight Simulator demo & GitHub — u/dimartarmizi, r/threejs posted 2026-02-01(内部digest: notes/ideas/daily/digests/2026-02-01-threejs-flight-simulator.md) [^2]: web-flight-simulator repository — LICENSE および同梱 3D モデルの再配布条件は改変・収益化配信前に必ず確認。 [^3]: YouTube Super Chat policies — 金額連動演出は「視聴者全員に等しく見える画面上のリアクション」にとどめるのが無難 [^4]: Cesium Cartesian3 API Reference(位置はメートル)/ Cesium Transforms.eastNorthUpToFixedFrame / Cesium Scene logarithmicDepthBuffer / Cesium Ion Community Tier(Streaming 15GB/月、非商用前提) [^5]: CesiumGS — Integrating Cesium with Three.js / CesiumGS/cesium-threejs-experiment — multi-frustum + floating origin + 別 canvas 重ねの定石 [^6]: VRM 1.0 meta 仕様 — avatarPermission / commercialUsage / modification を必ず確認 [^7]: YouTube liveChatMessages API(REST ポーリング、同接数は videos.list.liveStreamingDetails.concurrentViewers で数十秒ラグ)/ Streamlabs Socket API / Twitch EventSub[^8]: 地理院タイル一覧・利用規約 — 帰属表示義務あり [^9]: selop/pokebox — Three.js × MediaPipe ホログラムトレカ(内部digest: notes/ideas/daily/digests/2026-02-23-pokebox-threejs-holo.md) [^10]: Robert Kooima "Generalized Perspective Projection" (2008) — Off-Axis Projection の事実上の一次文献。Web 実装は Jamie Santell, Portals with Asymmetric Projection[^11]: coding-by-feng/ai-agent-session-center — R3F + WebSocket で file-based queue → 内部イベント伝搬 3〜17ms(配信プラットフォーム経由の遅延は別物)(内部digest: notes/ideas/daily/digests/2026-02-26-react-r3f-ai-agent-dashboard.md) [^12]: Twitch EventSub Subscription Types — channel.poll.begin/progress/end を含む全 subscription type の一覧