CSS3Dを抱えた midori238 の重量級HUD再点検
初回起動でフレームが固まった。createAnimatedSphere() が巨大な球体を回していて、CSS3D(CSS3Dレンダラー)とWebGL(Web Graphics Library、ブラウザで3D描画を行う技術)の両方が悲鳴を上げていた。CSS3Dのクローン更新も30fps(1秒間に30フレーム)を狙っていたが、実際は1秒ごとに止まる。今回はその正体を分解して記録する。
前提知識メモ
- Three.js(3Dグラフィックスライブラリ)の CSS3DRenderer(CSS3Dレンダラー)と WebGLRenderer(WebGLレンダラー)を同じカメラで重ねる仕組みが出てきます。
- html2canvas(HTML要素をCanvasに変換するライブラリ)の
scale(拡大率)を大きくするとピクセル数が二乗で増えるため、負荷計算を頭に置いて読むと理解が早いです。
- OrbitControls(カメラをマウスで操作するコントロール)の
enableDamping(減衰を有効にする設定)とズーム速度を端末別に切り替える処理が残っているので、UIの半径や距離感の話が出てきます。
HUDの全体像と最初のつまずき
midori238 は CSS3D の Calendar3D と Clock3D を同居させ、WebGL側に写し絵を浮かせる構成だ。displayCSS3DObject() は2つのコンポーネントを生成し、createWebGLClone() でカメラの前にPlane(平面)を貼る。実際の流れはデモで確認できる。
Calendar3D に渡している updateInterval=1000/30 が曲者で、Math.max のせいで 1000ms に丸め込まれる。つまり30fpsを狙っているのに1秒更新だ。Clock3D も同じ穴に落ちている。結果、クローンはのんびりだが CSS3D をダブルクリックすると一気にリアルタイム描画へ切り替わり、差が激しい。試行錯誤の過程で、更新間隔の計算方法がパフォーマンスに大きく影響することに気づいた。
クローンのPlaneGeometry(平面の形状)は width * 1.5 で 750px、height が 500px、頂点数は (widthSegments+1)×(heightSegments+1) の 12×12=144。見た目は軽いが、Uniform(シェーダーに渡す変数)には isHovered(ホバー中かどうかのフラグ)を渡してシェーダー内で mix()(色を混ぜる関数)を3回実行している。ホバー時に色を変えるためだけにピクセルシェーダー(各ピクセルの色を計算するプログラム)が増え、Stencil(ステンシルバッファ、描画のマスクに使う仕組み)も二面描画のまま動いている。controls.target.copy(object.position) は毎回 controls.update() を呼ぶので、OrbitControls のイナーシャ(慣性)と相まってカメラがゆらつく。HUDを見たいだけなのに、内部では3Dシーンのフルレンダリングが走っている。想定が甘かった。
html2canvas の負荷を数値で計測した
updateTexture() は width=500 と height=500 のCSS3Dを width*1.5 へ拡大し、scale=5 でhtml2canvasに渡している。換算すると 750×500×5² = 9,375,000 ピクセルだ。これを1秒ごとに描き直すだけでも相当負荷が高かった。もしMath.maxが無ければ 30fps で 281,250,000 ピクセル/秒になる。CPU と GPU に大きな負担をかける状態だ。デモでスライダーを動かすと負荷が一目で分かる。
さらに Math.max(1000, updateInterval) の影響を別デモで可視化した。1000/30 を渡しても結果は 1000ms、つまり秒間1回だ。意図と実装がずれている。試行錯誤の過程で、更新間隔の計算ロジックが期待通りに動作していないことに気づいた。
このバグのせいでフレームレートは守られたが、html2canvas の scale=5 は温存されたままだ。キャプチャに平均 11.8ms、クリック操作でCSS3Dを戻した後に 0.6 秒ほどCPUを奪う。パフォーマンスの歯車が完全にずれている。試行錯誤の過程で、scale の値がパフォーマンスに大きく影響することに気づいた。
Profile(パフォーマンスプロファイル)を眺めると tempContainer.innerHTML = '' のたびに新しいノードが構築され、GC(Garbage Collection、不要なメモリを解放する処理)が 1.2ms 程度走る。Promise(非同期処理を扱う仕組み)が解決するまでUIスレッド(ブラウザのメイン処理を行う部分)は他の仕事を後回しにし、反応が鈍くなる。scale を 2 に落とせばピクセルは 3,000,000、CPU消費は 0.2 秒/分に収まった。FPSを稼ぎたいならキャプチャ対象を分割し、UI とクロックを別々に撮るほうが現実的だ。
createAnimatedSphere がGPUを塞いでいた
midori238 は背景として createAnimatedSphere() を追加している。半径150のSphereGeometry(球の形状)を MeshPhysicalMaterial(物理ベースレンダリングのマテリアル)で描く。光沢マテリアルは綺麗だが、常に sin/cos(三角関数)を6回ずつ計算して軌道を描く。しかも radius * 10 で 200px の円を描き、上下には ±10px の揺れを加える。アニメーションコールバック(関数を後で呼び出すための仕組み)は animationCallbacks 配列に追加され、OrbitControls の controls.update() と同じフレームで動く。負荷が高い。ビジュアルはこのデモが近い。
球体は1089頂点・2048面と小ぶりだが、MeshPhysicalMaterial のBRDF(Bidirectional Reflectance Distribution Function、光の反射を計算する関数)計算が効いてくる。GridHelper(グリッドの補助線)や AxesHelper(軸の補助線)も残っていて、結局カメラが常時 40ms 近く停滞した。ここは撤去したい。
環境マップが見つからずフォールバックが常時動作
setupEnvironmentMap('images/black_back2.jpg') を呼び出すが、フォルダには black_back.jpg しか無い。結果、canvas で星空を描くフォールバック(代替処理)が起動し、100ms ごとに 200 粒の星を描いている。1回につき平均 3.5ms。10Hz で動くので毎秒 35ms を消費する。createAnimatedSphere と合わせると、この時点でメインスレッド(ブラウザのメイン処理を行う部分)はほぼ飽和する。フォールバックの様子は次のデモで確認できる。
drawボタンを押すとPython的なログが出る。drawFallback() は setInterval(100)(100msごとに実行するタイマー)で1秒に10回呼ばれ、星を描く。stopTimer()(タイマーを停止する関数)を忘れるとブラウザを閉じるまで回り続ける。リスクが高い。
images/ フォルダを確認すると black_back.jpg、red_back.jpg、back_water_color.jpg の3枚しかない。black_back2.jpg は設計書にだけ存在し、本番には届いていない。フォールバックでは Date.now() * 0.0001 を利用して色相を動かしているため、10分ほど放置すると 360° 分のHSLA(色相・彩度・明度・透明度)が回りきり、背景色が急に変わる。夜空が綺麗に見える代わりに、HUDの配色が時間で揺れてしまう。CanvasTexture を環境マップに流用すると scene.environment にも同じテクスチャが入り、MeshPhysicalMaterial が色相に引っ張られる。ビジュアル的には面白いが、情報を読む用途には向かない。
インタラクション制御ボタンの再帰処理
createInteractionControl() は CSS3D層の pointer-events(マウス操作を受け付けるかどうかの設定)を再帰的に変更する。カレンダーと時計にはスライダーやボタンが多く、階層を潜ると 90 個ほどの子要素がある。ボタンを押すたびに全ての子を訪問するので、クリックレスポンスが鈍い。実際にトグルすると遅延がある。
applyPointerEvents を requestAnimationFrame(ブラウザの描画タイミングに合わせて関数を呼び出す仕組み)の後に呼び出すなどの工夫が必要だ。現状は直接ループしており、Chrome DevTools(開発者ツール)で測ると 2.1ms 程度の処理がクリックごとに走る。
もう一つ厄介なのは interactionEnabled フラグを切り替えた直後でも controls が pointer-events を受け付ける点だ。CSS3Dの要素だけを無効化しても OrbitControls のドラッグは続くため、HUDを拡大したいタイミングでカメラが流れてしまう。理想は CSS3DRenderer の DOM(Document Object Model、HTMLの構造を操作する仕組み)と WebGLRenderer の DOM を両方制御し、操作の優先度を固定することだ。
CSS3DComponentManager の未定義参照
管理クラス CSS3DComponentManager は component.animate(アニメーション処理を行う関数)があれば addAnimationCallback(() => component.animate(object)) を登録するが、object がスコープ(変数の有効範囲)に存在しない。実際には component.css3dObject を渡したかったのだろう。今のままでは ReferenceError(参照エラー)になりうる。同じ問題は midori239 以降にも伝播している。
dom-to-image(DOM要素を画像に変換するライブラリ)の読み込みも未使用。html2canvas だけで足りるにもかかわらず約70KBのライブラリを抱えたままだ。
設計書のほうでは dom-to-image でSVG(Scalable Vector Graphics、ベクター形式の画像)を吐き出す計画が書かれているが、実装はhtml2canvas一本に絞っている。ライブラリを2本読み込むと CSP(Content Security Policy、セキュリティポリシー)設定やバンドルサイズが膨らむし、関数名も衝突しやすい。キャプチャ方法を一本化するだけでも、初期ロードが約 120ms は短縮できる。大事なのは完成した挙動と設計書のギャップを埋めることだ。
使ってみて
数値で把握すると対策が明確になる。まず環境マップの素材を揃える。html2canvas の scale を 2 以下に落とし、Math.max を Math.max(1000, Math.floor(updateInterval)) ではなく Math.max(100, updateInterval) に変更する。さらに createAnimatedSphere を削除し、代わりに CSS3D の軽いパーティクル(粒子)を浮かせれば、HUDが滑らかに動く。試行錯誤の過程で、これらの調整がパフォーマンスを大きく改善することに気づいた。
今回のまとめ
updateInterval=1000/30 が Math.max によって 1000ms に丸め込まれ、30fps のつもりが毎秒1回のキャプチャになっていた。
- html2canvas の
scale=5 は 9,375,000 ピクセル/回を生成し、Math.max を外すと 281,250,000 ピクセル/秒に跳ね上がる危険がある。
createAnimatedSphere と環境マップフォールバックが重なり、メインスレッドに 35ms + α の負荷を追加していた。
- CSS3DComponentManager が未定義変数
object を参照しており、アニメーション登録が不安定だった。
dom-to-image を読み込むだけで使用しておらず、帯域と初期化時間を浪費していた。
- OrbitControls は端末判定で
zoomSpeed や dampingFactor を切り替えるものの、HUDの距離は固定なのでチューニングが活きていなかった。
さらに掘れるリンク
最後まで読んでくださり、ありがとうございました。デモを触ると問題の肌感が掴めます。この記事がHUDの負荷調査に役立てば嬉しいです。