概要

three.js + WebXR で作った 3D ギャラリー(仮想空間を歩いて作品を鑑賞するタイプの Web アプリ)のデモ動画を、外部の画面録画ソフトに頼らずアプリの中から撮る、という作業をしたときのメモです。canvas.captureStream()MediaRecorder を使うと録画自体は数行で書けるのですが、実際に「人に見せられる動画」にしようとすると、思っていたよりつまずく箇所がいくつかありました。

調べた限りで分かった落とし穴と対処、それから「カメラを自動で動かして録画し、ファイル保存まで無人で済ませる」自動化の方法を残しておきます。three.js に限らず、canvas に描く WebGL アプリ全般で同じ話が当てはまると思います。

前提:アプリ内 canvas 録画か、画面録画か

3D アプリの映像を録る方法は大きく2つあります。

  • アプリ内 canvas 録画:canvas.captureStream() で WebGL キャンバスのピクセルだけを録る。HUD やボタンなどの DOM は映らないので、3D の絵だけがクリーンに残る。許可ダイアログも出ない。
  • 画面(タブ)録画:navigator.mediaDevices.getDisplayMedia() で画面やタブを録る。DOM のオーバーレイも含めて全部映るが、毎回共有許可のダイアログが出て、カーソルやブラウザの枠が入ることもある。

「3D の鑑賞映像をきれいに撮りたい」だけなら前者が向いています。一方で、DOM 側で実装したビューア(後述)を映したい場合は前者では撮れず、後者が必要になります。この記事は主に前者(canvas 録画)の話です。

最小実装

まず素朴に書くとこうなります。

function startRecording(canvas, fps = 30) {
  const stream = canvas.captureStream(fps);
  const recorder = new MediaRecorder(stream, {
    mimeType: "video/webm;codecs=vp9",
    videoBitsPerSecond: 12_000_000,
  });
  const chunks = [];
  recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
  recorder.onstop = () => {
    const blob = new Blob(chunks, { type: "video/webm" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "demo.webm";
    a.click();
  };
  recorder.start();
  return recorder;
}

three.js の場合、録画する canvas は renderer.domElement です。setAnimationLoop(WebXR 対応のために通常使うループ)で毎フレーム描画していれば、その内容がそのままストリームに乗ります。これで録画はできるのですが、以下の点でつまずきました。

つまずき1:DOM オーバーレイは映らない

canvas.captureStream() が録るのは、その canvas のピクセルだけです。HTML のオーバーレイ(操作ヒント、作品名の表示、十字カーソルなど)は別レイヤーの DOM なので、映像には一切入りません。

これは「ボタン類が映らずクリーンな動画になる」という利点でもありますが、見せたい情報まで消えてしまう面もあります。今回特に問題になったのが2点でした。

作品名などのテキストが消える

通常は HTML の HUD で作品名を出していましたが、それは録画に映りません。対処として、テキストを 3D シーンの中のオブジェクトとして描くことにしました。Canvas 2D で文字を描いた CanvasTexture を板(Plane)に貼り、カメラの少し前に置きます。

// テキストを描いた板を、カメラの少し前・下に世界座標で置く
const offset = new THREE.Vector3(0, -0.3, -0.9).applyQuaternion(camera.quaternion);
captionMesh.position.copy(camera.position).add(offset);
captionMesh.quaternion.copy(camera.quaternion);
scene.add(captionMesh);

ここで一つはまったのが、板をカメラの子にすると描画されないことでした。renderer.render(scene, camera)scene を辿って描画するので、scene に入っていないカメラの子オブジェクトは走査対象になりません(カメラを scene に add していれば描画されますが、通常 add しないことが多いと思います)。プログラム上は不透明度などが正しく設定されているのに画面に出ず、原因にしばらく気づけませんでした。結局、カメラの子にするのをやめ、毎フレーム「カメラの前のワールド座標」に置き直す形にしました。

教訓として、録画の中身は実際に書き出したフレームを目で見て確認するべきでした。DOM 上の状態(要素の opacity が 1 になっている等)を確認しただけで「映っているはず」と判断したのが間違いで、フレームを抜き出して見たら出ていませんでした。

別レイヤーのビューアが映らない

高解像度画像ビューア(タイル画像を段階的に読み込んで拡大していくタイプ)を別ライブラリで DOM 上に重ねている場合、それも canvas 録画には入りません。これを動画に入れたい場合は、

  • getDisplayMedia() でタブごと録る(許可ダイアログと引き換え)、
  • そのビューアの canvas を WebGL 側に合成する(実装は重い)、
  • あるいは「3D シーンの中で対象に寄り、高解像度テクスチャに差し替える」演出で代替する、

のいずれかになります。今回は、録画で完結する3つ目(カメラを対象に寄せつつテクスチャを高精細版に差し替える)を採りました。本物のビューア UI を映したい場合だけ画面録画に切り替える、という整理です。

つまずき2:解像度が巨大になる

captureStream() が録るのは canvas のバッキングストア解像度です。Retina など devicePixelRatio = 2 の環境では、表示が幅 1800px でもバッキングは 3600px になり、録画もその大きさになります(正確には renderer.getPixelRatio() 倍で、これは devicePixelRatio を上限にクランプした値です)。実際、最初に撮れたのは 3600×2018 のファイルでした。

3D の鑑賞動画としては過剰で、ファイルも大きく、再生も重くなります。対処として、録画している間だけ描画解像度を抑えるようにしました。アスペクト比は保ったまま、高さの上限(例:1440px)を超えないように pixelRatio を下げ、録画が終わったら元に戻します。

// 録画中だけ高さ 1440px 相当へ。アスペクト比は維持
const targetH = 1440;
const cur = renderer.getPixelRatio();
const dpr = Math.min(cur, targetH / window.innerHeight);
renderer.setPixelRatio(dpr);          // canvas のバッキングストアが縮む = 録画解像度が下がる
composer.setPixelRatio(dpr);           // ポストプロセスのレンダーターゲットにも反映
// …録画終了後に元の値で setPixelRatio し直して復元

ここで一点はまりました。EffectComposer(ポストプロセス)は構築時の pixelRatio を内部に保持していて、composer.setSize() を呼んでもその値は更新されません。録画用に解像度を下げるなら composer.setPixelRatio(dpr) を使う必要があります。renderer.setPixelRatio() だけだと canvas(=録画解像度)は下がるものの、ポストプロセスのレンダーターゲットは元の大きさのまま残り、無駄な描画コストがかかります。

なお、EffectComposer を経由していると、レンダラの antialias: true が効きません(描画先が独自のレンダーターゲットになるため)。WebGL2 ではレンダーターゲットを multisample 化することでアンチエイリアスを戻せます。

if (renderer.capabilities.isWebGL2) {
  composer.renderTarget1.samples = 4;
  composer.renderTarget2.samples = 4;
}

額縁や什器のような細い高コントラストの輪郭は、動画圧縮でちらつき(シマー)として目立ちやすいので、ここを締めておくと圧縮後の見栄えが安定しました。

つまずき3:コーデックの選択(VP9 か H.264 か)

MediaRecordermimeType を指定しないときの既定は環境によりますが、WebM(多くの環境では VP8、環境によっては VP9)になります。VP9 はハードウェアデコード非対応の環境もそれなりにあり、その場合 CPU で再生するため、特に高解像度だと再生が重くなります。また WebM は QuickTime など一部の環境でそのまま再生できないことがあります。

対処として、H.264(MP4)が使えるなら優先するようにしました。MediaRecorder.isTypeSupported() で対応を確認してから選びます。

function pickMime() {
  const cands = [
    "video/mp4;codecs=h264",
    "video/mp4;codecs=avc1.42E01E",
    "video/webm;codecs=vp9",
    "video/webm;codecs=vp8",
    "video/webm",
  ];
  return cands.find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
}

調べた限り、最近の Chrome 系では MediaRecorder が MP4/H.264 を出せる環境が増えています(OS 側のハードウェアエンコーダがある場合に限られるようで、isTypeSupported() でのゲートが前提です)。これを選べると多くの再生環境でそのまま開けて、ハードウェアデコードで軽く再生できました。保存時の拡張子も blob.type から決めるようにしておくと安全です(常に .webm で保存すると中身と合わなくなることがあります)。ビットレートも解像度・fps に合わせて決める(おおよそ 0.1 bpp 前後)と、固定値よりは無駄が出にくくなります。

つまずき4:ヘッドレスでの自動撮影

撮影まで自動化したく、ヘッドレスの Chrome を CDP(Chrome DevTools Protocol)で操作して録画させようとしたのですが、ここが一番てこずりました。観測した範囲では次の挙動がありました。

  • ヘッドレスで GPU を使おうとすると黒いフレームになることがある。--use-angle=metal などで GPU を有効にしようとしても、ヘッドレスでは描画コンテキストが実質的に効かず、ほぼ真っ黒な映像(=ほとんど情報がないので極端に小さいファイル)になりました。確実に描画させるには、ソフトウェアレンダラ(SwiftShader: --use-gl=angle --enable-unsafe-swiftshader)を使うのが安定していました。画質は GPU に劣りますが、確実に中身のあるフレームが出ます。これはヘッドレス/GPU を持たない環境固有の話で、SwiftShader はそのための公式なソフトウェア経路です。
  • ウィンドウが見えない状態だと、描画ループそのものが間引かれる。requestAnimationFrame / setAnimationLoop の間引きは、フォーカスの有無ではなく可視性(visibility)で決まります。タブが背面、ウィンドウが最小化、あるいは他のウィンドウに完全に覆われている(document.visibilityState === "hidden"、いわゆる occluded)と間引かれます。逆に、前面でなくても画面に見えてさえいれば基本的には間引かれません。今回は別ウィンドウを起動して裏に回してしまい、captureStream が同じフレームを複製し続けて、ほとんど止まった動画(数十秒で 100KB 程度)になりました。

整理すると、無人での自動撮影と、GPU 品質の高解像度は同時に成立しにくい、というのが実感でした。使い分けとしては、

  • 変更の確認・回帰チェックを無人で回す → ヘッドレス+ソフトウェアレンダラ(解像度は控えめに。CPU 描画でフレームを落とさないため)
  • 本番のきれいな映像 → 自分が見ている前面タブでボタンを押して撮る(実 GPU・可視なので間引かれない)

という二段構えにしました。ソフトウェアレンダラでも、解像度を 720p 程度に抑えれば、約 30fps の連続フレームの動画がきちんと出ました(同じ尺で 100KB のときは止まっていて、25MB 出たときは連続していた、という具合に、ファイルサイズが連続性のおおまかな目安になります)。

CDP で起動から保存まで

CDP を使うと、起動 → ページ読み込み → アプリ内のツアー関数呼び出し → 録画 → ファイル保存、を一通り自動化できます。ポイントだけ挙げます。

  • Chrome を --headless=new --remote-debugging-port=… --user-data-dir=…(プロファイルは使い捨て)で起動し、/json エンドポイントからページのデバッグ用 WebSocket を取得して接続します。
  • 保存は Browser.setDownloadBehaviordownloadPath を指定しておけば、アプリ側のダウンロード(a.click())がそのフォルダに書き出されます(古い Page.setDownloadBehavior は非推奨)。
  • アプリのモジュールは Runtime.evaluate から動的 import() で呼べます。ここで一つはまったのが、Runtime.evaluate の式ではトップレベル await が使えないことです(既定モードの場合)。(async () => { … })() で包む必要があります。
  • 録画開始の前に、コンテンツの読み込みが安定するまで待つことも大事でした。今回は外部から画像を取得する作りだったため、「最初の1枚が出た時点」で録画を始めてしまうと、ほとんどの作品がまだ読み込まれていない状態の映像になりました。要素数が一定時間変化しなくなるまで待つようにして解決しました(あくまで読み込み完了の目安で、保証ではない点には注意します)。
// Runtime.evaluate に渡す式:トップレベル await は不可なので IIFE で包む
const expr = `(async () => {
  const tour = await import('./src/tour.js');
  await tour.startTour({ record: true });
  return true;
})()`;

検証:目で見る/プログラムで確かめる

録画系は「動いているように見えて、実は映っていない/止まっている」という失敗が起きやすいので、検証を2通り用意しておくと安心でした。

  1. フレームを抜き出して目で見る。mkdir -p frames && ffmpeg -i out.mp4 -vf fps=1 frames/%02d.png で 1 秒ごとに画像を取り出し(ffmpeg は出力先ディレクトリを作らないので先に作っておきます)、想定どおりの絵(テキストの焼き込み、寄り、被写体の位置)になっているかを確認します。前述の「テキストが映っていなかった」件は、これをやって初めて気づけました。
  2. プログラムでアサーションする。見た目で判断しにくい性質(例:被写体が什器をすり抜けていないか、カメラが壁の外に出ていないか)は、毎フレーム位置をサンプリングして数値で確かめると確実です。今回は、被写体の座標が当たり判定の領域に入っていないこと、カメラの半径が壁の内側に収まっていること、を全フレームでチェックしました。
// 例:毎フレーム、カメラが壁(半径 wall)の内側にいるかを確認
const id = setInterval(() => {
  const r = Math.hypot(camera.position.x, camera.position.z);
  if (r > wall + 0.05) console.warn("camera outside wall", r);
}, 16);

avg_frame_rate0/0(可変フレームレート)になるのは MediaRecorder の WebM では正常です。気になる場合は ffmpeg で固定フレームレートに変換できます。

見栄えの工夫(おまけ)

中身(録画方式)が固まってから、デモとして見やすくするために足したことです。いずれも地味ですが効きました。

  • 冒頭に空間を見渡す導入を入れる。いきなり作品に寄るのではなく、最初に空間全体をゆっくり見せると状況が伝わります。
  • 移動時間を距離に比例させる。区間ごとに固定秒数だと、近い対象では遅く、遠い対象では速く見えて不自然でした。距離 / 速度 をクランプして使うと一定速度感が出ます。
  • 全部を回らず数点に絞る。デモなので、均等に間引いて代表的な数点だけ巡るほうがテンポよく収まります。
  • 既存の当たり判定を再利用する。カメラやキャラクターを自動で動かすとき、通常操作で使っている移動解決(衝突したら軸ごとに滑る処理)を流用すると、什器のすり抜けを防げます。自前で別の移動ロジックを書くと、こういう既存の配慮が抜け落ちがちでした。

まとめ

  • canvas 録画(captureStream + MediaRecorder)は手軽ですが、DOM は映らない・解像度が巨大になる・コーデックが再生互換に効く、という3点でまずつまずきました。
  • テキストなど見せたい情報は 3D シーン内に描きます。カメラの子ではなく、世界座標でカメラ前に置くのが確実でした。
  • 録画時だけ解像度を抑える(composer.setPixelRatio も忘れずに)、H.264 を優先する、といった調整で、画質を保ちつつ扱いやすいファイルにできました。
  • 自動撮影はヘッドレス+ソフトウェアレンダラが安定です。ただし高解像度・GPU 品質は前面タブでの手動撮影に分があり、用途で使い分けるのが現実的でした。
  • 録画系はフレームを目で見る検証と、プログラムによるアサーションの両輪で確かめると、見落としを減らせました。

特別なライブラリを足さなくても、ブラウザの標準 API と CDP の範囲でここまではできる、というのが今回の所感です。