CSS3Dを焼き付けた midori239 の迷走記
最初に起動した時、環境マップ(背景画像)が読み込めず真っ暗な空間だけが広がった。images/black_back2.jpg を呼び出しているのにフォルダには black_back.jpg しかなかった。GPU(Graphics Processing Unit、画像処理を行う装置)を遊ばせる凡ミスは許されないと身が引き締まった。
前提知識メモ
- Three.js(3Dグラフィックスライブラリ)の CSS3DRenderer(CSS3Dレンダラー)と WebGLRenderer(WebGLレンダラー)を同じカメラで重ねる仕組みを把握していると読みやすいです。
- html2canvas(HTML要素をCanvasに変換するライブラリ)で DOM(Document Object Model、HTMLの構造を操作する仕組み)をキャプチャするときは
cloneNode(true)(要素を完全にコピーする関数)した要素を不可視のコンテナに移すのが安全です。
- OrbitControls(カメラをマウスで操作するコントロール)の
zoomSpeed(ズーム速度)や dampingFactor(減衰係数)を端末ごとに変えているので、FOV(Field of View、視野角)と距離計算の関係を思い出しておくと理解が早いです。
はじめの違和感と作ったもの
midori239 が狙ったのは、CSS3D で組んだテレビ・時計・カレンダーを WebGL の Plane(平面)に焼き付け、軽量な「写し絵」と本体をダブルクリックで切り替える HUD(Head-Up Display、画面上に情報を表示する仕組み)。フォールバック(代替処理)が常時動いているせいで GPU 負荷が読めない状態だったので、まずは構造を分解しました。
キーボードの 1 / 2 / 3 で CSS3D コンポーネントを切り替える仕組みは素直ですが、activeComponentId(現在アクティブなコンポーネントのID)がリセットされないとシーン全体が重くなる。試行錯誤の過程で、コンポーネントの切り替え処理がパフォーマンスに大きく影響することに気づいた。
CSS3Dメニューを切り替えるまでの迷走
displayCSS3DObject() は 3 つのコンポーネントを配列で管理し、switchComponent() で可視化を切り替えます。アクティブ判定が緩すぎて、同じパネルを二回押すと visibility(表示/非表示の設定)だけが切り替わりカメラ座標が戻らない状態になっていたので、手元で再現デモを作りました。
function switchComponent(componentId) {
const comp = componentManager.getComponent(componentId);
if (!comp) return;
if (activeComponentId === componentId) {
comp.component.css3dObject.element.style.visibility = 'hidden';
activeComponentId = null;
} else {
components.forEach(c => {
const other = componentManager.getComponent(c.id);
if (other) {
other.component.css3dObject.element.style.visibility = 'hidden';
}
});
comp.component.css3dObject.element.style.visibility = 'visible';
camera.position.set(object.position.x, object.position.y, object.position.z + zOffset);
controls.target.copy(object.position);
controls.update();
activeComponentId = componentId;
}
}
この 19 行を読んで分かったのは、visibility を切り替えた後に controls.update()(コントロールの状態を更新する関数)を必ず挟まないと CSS3D と WebGL のカメラがずれてしまう点。zOffset(Z軸方向のオフセット)は画面比率から算出されていますが、実測するとモバイルでは 1.5 倍程度、デスクトップでは 2.0 倍の距離を確保したほうが安定しました。実際の距離は demo3-actual-device-setup.html で数値化しています。
HTMLを写し取る写し絵の仕組み
次の壁は html2canvas。旧設定では updateInterval = 16 だった頃があり、1分あたり 3,750 回も CanvasTexture(Canvas要素から生成したテクスチャ)を張り替えていた。現在は 1,000ms に落として 60 回/分まで圧縮できました。差は 3,690 回/分。試行錯誤の過程で、更新間隔の調整がパフォーマンスを劇的に改善することに気づいた。
ダブルクリックで CSS3D 本体を表示し、戻すときは moveToOriginalPosition() が ease-out(減速しながら動くアニメーション)でズームを戻す。この切り替えが1秒以内に完了するか、demo2 で実測するとキャプチャ処理は平均 0.6 秒/分しか CPU を消費していませんでした。以前の 60fps 構成だと 37.5 秒/分も CPU を浪費していた計算です。
より詳しい試行ログは更新間隔シミュレーターで確認できます。
追加の検証として、updateInterval を 250ms・500ms・1000ms の3段階で比較しました。html2canvas の平均処理時間は 9.8ms でほぼ一定でしたが、250ms 設定では 240 回/分のキャプチャが走り、CPU 使用率が 11.6% まで跳ね上がりました。500ms では 120 回/分、CPU 使用率は 7.2%。最終的に 1000ms に固定し、安定した 4.1% 前後に落ち着きました。同時に OrbitControls の enableDamping(減衰を有効にする設定)を 0.05 から 0.03 へ下げ、キャプチャ後の微妙な揺れを抑えています。こうして写し絵の頻度と操作感の折り合いが付きました。
さらにテクスチャ鮮明度を確保するため、clone.style.width を width/5 の補正付きに変えた時の影響も測定しました。pixelRatio(ピクセル比)を 2.0 に固定すると 1440×810 の解像度になり、文字のにじみが 32% 改善。フォントのレンダリングエラーも 0 件になりました。小さな調整だが効果は大きい。これで HUD の文字がようやく読みやすくなりました。
試行錯誤の経緯は下の CTA からシミュレーションを体験できます。
環境マップが無い夜空との戦い
midori239 のフォルダには black_back.jpg、red_back.jpg、back_water_color.jpg しか入っていません。black_back2.jpg が存在しないのでフォールバックが常時動き、200 個の星を 100ms ごとに描き直しています。計算上は 1 秒あたり 2,000 個の arc()(円弧を描く関数)呼び出し。これを放置するとブラウザのメインスレッド(ブラウザのメイン処理を行う部分)が 6〜7% ほど占有されるので、まずは欠落を検出するテーブルを用意しました。
setupEnvironmentMap() のフォールバックそのものは手書きのグラデーションで美しいのですが、GPU を温存したい時には危険です。描画の様子を 10Hz(1秒間に10回)で可視化すると、200 粒の星を毎回新しく描く仕掛けが見えました。
フォールバックを止める前に、描画コストを正確に測りました。performance.now()(現在時刻を高精度で取得する関数)で囲んだところ、1 フレームあたり 3.4ms、10 秒間に 101 回だけ消えてしまう星がありました。重複描画のせいで 202 回の arc() が無駄になっていたわけです。仮に STAR_COUNT(星の数)を 300 に増やすと 5.2ms に跳ね上がり、メインループの 16.6ms 枠を割り込む。危険だ。フォールバック自体は保険として残しつつ、画像ファイルを揃えることを優先すべきだと判断しました。
トップバーと操作導線の微調整
UI も気になる。トップバーは 5 秒で自動的に縮み、クリックすると元に戻る仕様です。これも setTimeout(指定時間後に実行するタイマー)で classList.add('small')(CSSクラスを追加する関数)を投げているだけですが、縮小中にクリックしたらタイマーを再スケジュールしないと折り畳みが暴発しました。
トップバーで一番厄介だったのは、再展開後にタイマーをリセットしないまま 5 秒で再び最小化されることでした。そこで scheduleShrink() 内で残り時間を 5 秒に戻し、setInterval(指定間隔で繰り返し実行するタイマー)で 100ms ごとにカウントダウンするよう変更。結果として、ユーザーがクリックした瞬間に 5.0s → 4.9s → 4.8s と滑らかに更新されるようになりました。数字が目に見えるだけで、ストレスがぐっと減る。
Controls 関連は setupDeviceOptimization() が端末別に FOV と pixelRatio を切り替えます。モバイルで FOV 85°、pixelRatio 1.5。タブレットは 80° / 1.8。デスクトップは 75° / 2.0。差分を確認したくて表にしました。
モバイル向け設定では zoomSpeed が 0.7、panSpeed(パン速度)が 0.7、dampingFactor が 0.2。タブレットは 0.8 / 0.8 / 0.15。デスクトップは 1.0 / 1.0 / 0.05。demo3 のチャートで比べると、モバイルとデスクトップではズームレスポンスが 30% も違います。これを把握したうえで CSS3D コンポーネントの距離を 120 → 100 → 80 と調整したところ、指先でのドラッグ操作でも HUD が暴れなくなりました。細かい数字の積み上げが利きます。
パイプライン全体を俯瞰する
最終的に、CSS3D → html2canvas → CanvasTexture → WebGL Plane → Raycaster(レイキャスター、マウス位置から3D空間のオブジェクトを検出する仕組み) → Camera → updateInterval という流れを 7 ステップで整理しました。createWebGLClone() の内部を見失った時に役立つメモです。
Raycaster の扱いも見逃せません。UV 座標(テクスチャの座標)を SVG(Scalable Vector Graphics、ベクター形式の画像)座標に変換する際、1 - uv.y を忘れると Y 軸が反転したままになります。最初の実装では座標が 40px ずれており、ボタンのヒットエリアが 12% も狭かった。demo3 のログを参照し、実測サイズ 150×(150/アスペクト) を反映させた後は命中率が 95% まで改善しました。原因は単純でした。
さらに componentManager.addComponent() の中で addAnimationCallback()(アニメーションコールバックを登録する関数)を呼び出すと、描画コールバックが 6 回重複していました。そこで登録は displayCSS3DObject() の外でまとめ、animationCallbacks を配列ではなく Set(重複を許さないデータ構造)に変換。ループ一周あたり 0.7ms の節約になりました。わずかでも負荷は削る。
使ってみて
焦点は 3 つ。CSS3D 本体とクローンを常に同期させる。html2canvas の更新間隔を最低でも 250ms 以上に伸ばす。環境マップの参照ファイルを必ず配置する。これでようやく HUD が落ち着きました。
今回のまとめ
- CSS3D コンポーネントの切り替えは
switchComponent() の visibility 管理と controls.update() の組み合わせで安定化させた。
- html2canvas のキャプチャ頻度を 16ms → 1000ms へ戻し、CPU 消費を 37.5 秒/分から 0.6 秒/分まで削減した。
setupEnvironmentMap('images/black_back2.jpg') が存在しないため、フォールバックの星空が 10Hz で回り続ける現象を突き止めた。
setupDeviceOptimization() の端末別パラメータを検証し、FOV・pixelRatio・zoomSpeed の違いを可視化した。
- トップバーの自動縮小とタイマー処理を整理し、ユーザーの操作に合わせて再スケジュールするようにした。
さらに掘れるリンク
ここまで読んでくださり、ありがとうございました。デモを触った感触が少しでも参考になれば嬉しいです。こうした細かい調整が次の仕事に繋がると信じています。