WebGLにHTMLを焼き付けた midori244 のビデオHUD再構築記
最初に新しいコードを流し込んだ瞬間、動画パネルは真っ黒だった。一度つまずいた。動画フレームを描画する関数が一度も走っていなかった。想定が甘い。
midori244は、HTMLで組んだHUDをメインスレッドを塞がずに描画できる技術で焼き直し、3D描画ライブラリの平面の形状へ貼り付ける改修だ。収入が途絶えた身としては無駄なレンダリングを減らすしかない。そこで得られた学びをここにまとめる。
作ったものの概要と最初の詰まり
狙ったのは、動画と操作パネルを1枚のテクスチャにまとめてHUDとして浮かせること。ところが実装では更新頻度120fpsのまま放置し、毎フレームDOM計測を叩いていた。これは課題。コメントは30fpsのままだったのに、実態はそうではなかった。
ターゲットFPSを落とせば更新関数の実行回数が一気に減る。意外だ。コメントではなく実測値を見ることが結局一番の近道だった。
動画とSVGを合成する基礎
HUDの本体は動画制御表示用のクラス。SVGテンプレートをImage経由でメインスレッドを塞がずに描画できる技術に描き、その上に動画フレームを重ねる。
// midori244/src/mp4_control_Display.js(抜粋)
async _updateClass() {
const rawSvg = this._generateRawSVG();
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(rawSvg);
return new Promise((resolve, reject) => {
try {
const image = new Image();
image.onload = () => {
this.context.clearRect(0, 0, this.width, this.height);
this.context.drawImage(image, 0, 0);
if (this.mesh && this.mesh.material?.map) {
this.mesh.material.map.needsUpdate = true;
}
resolve();
};
image.onerror = (err) => reject(err);
image.src = svgDataUrl;
} catch (err) {
reject(err);
}
});
}
_drawVideoFrame() {
if (!this.videoElement || this.videoElement.readyState < this.videoElement.HAVE_CURRENT_DATA) return;
const bounds = this._getVideoContainerBounds();
if (!bounds) return;
this.context.drawImage(this.videoElement, bounds.x, bounds.y, bounds.width, bounds.height);
if (this.mesh && this.mesh.material?.map) {
this.mesh.material.map.needsUpdate = true;
}
}
この心臓部が正しく働いているかを検証するため、ブラウザ単体で動くデモを作った。Play/Pause、±10秒シーク、FPS調整を備え、ボトルネックの発生位置を確かめられる。
ヒットボックス計測の落とし穴
動画コンテナの境界を取得する関数はSVGをDOMに貼り直し、要素の位置とサイズを取得する関数で矩形を拾う。毎フレームこれを実行すると、200近いノードを生成してしまう。リスクが高い。まずは単体計測で挙動を確認した。
続いて500フレーム分をまとめて測り、再生成・クローン・完全キャッシュを比較した。差は歴然だった。キャッシュすれば平均数十マイクロ秒で収まる。
RaycasterとSVG座標の橋渡し
クリック操作はマウス位置から3D空間のオブジェクトを検出する仕組みのテクスチャ座標をSVG座標に戻して判定する。縦軸が反転していること、平面のサイズが150×(150/アスペクト)であることを頭に入れておく必要がある。
// midori244/src/mp4_control_Display.js(Raycaster判定部分)
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, this.camera);
const intersects = raycaster.intersectObject(this.mesh);
if (intersects.length > 0) {
const uv = intersects[0].uv;
const xSVG = uv.x * this.width;
const ySVG = (1 - uv.y) * this.height;
this.interactiveElements.forEach((element) => {
if (element.contains(xSVG, ySVG)) {
element.callback();
}
});
}
座標のズレを目で追えるように専用キャンバスを用意した。これでデバッグが一気に楽になった。
背景を支える球体アニメーション
背景が単調だと没入感が薄れる。そこで球体アニメーションを作成する関数で10個の球体を生成し、半径15〜25、速度0.005〜0.01で軌道を描かせた。色は色相・彩度・明度で色を指定する関数で揺らしている。
さらにチャットアプリのような見た目にならないよう、動きに軽さを持たせた。
デバイス最適化と環境マップ
ブラウザが送る端末情報で判定してカメラパラメータを切り替えるのがデバイス最適化設定関数だ。モバイルなら視野角85度、画面の解像度の比率1.5倍、タブレットは80度/1.8倍、デスクトップは75度/2.0倍。カメラを回転・ズームできるコントロールの減衰値もそれぞれ変えている。
背景については高ダイナミックレンジ画像がない場合の代替手段として2048×2048の描画用の領域を生成し、200個の星を散らす。これだけでも寂しさはかなり減る。
パイプラインの流れを俯瞰する
最後に、SVG→Data URL→OffscreenCanvas→CanvasTexture→Mesh→Raycasterという流れを一枚で俯瞰できるようにした。備忘録としても役立つ。
使ってみて
実際に触りたい方はCTAからどうぞ。全画面表示のほうが細部が追いやすい。
要点は3つ。メインスレッドを塞がずに描画できる技術で映像とSVGを合成する。ヒットボックスは一度だけ計測してキャッシュする。テクスチャ座標はY軸を反転させる。それだけでHUDは安定する。
まとめ
- SVGテンプレートをメインスレッドを塞がずに描画できる技術に描き、動画フレームと合成してCanvasの内容を3D空間に貼り付けるためのテクスチャへ更新する仕組みを再構築した。
- 動画コンテナの境界を取得する関数のDOM生成を1回に限定し、キャッシュを使うことでフレーム落ちを防いだ。
- マウス位置から3D空間のオブジェクトを検出する仕組みのテクスチャ座標→SVG変換をデモで確認し、クリック判定のズレを解消した。
- デバイス最適化設定関数で視野角や画面の解像度比率を端末ごとに切り替え、代替手段の環境マップもコードで生成できるようにした。
- パイプライン全体を可視化し、次のHUD改良に備える土台を整えた。
さらに深く掘るなら
最後まで読んでくださり、ありがとうございました。