CSS3D時計の呼吸を整える midori232 の再点検記
空中に浮かぶCSS3D(HTML要素を3D空間に配置する技術)時計を眺めながら、動きのぎこちなさに胸がざわついた。GPU(グラフィック処理装置)の負荷が跳ね上がり、UIは5秒後に勝手に縮む。一度つまずいた。midori232は時計を飾りたかっただけなのに、細部の設計が積み重なって操作感を奪っていた。バグ報告を読み返すと「時計が飛び去った」「クリックを受け付けない」といった悲鳴が並ぶ。今度こそ終止符を打ちたい。
作ったもの
CSS3DRendererで作った時計をWebGL空間に重ね、環境マップとデバイスごとのカメラ調整を見直した。動画はそのまま01.mp4を利用し、コードは徹底的に整理した。Scene初期化のPromiseチェーンを解体し、失敗時のフォールバックも実装し直している。
midori232の旧コードは、操作系をほぼ無効化したまま巨大な環境マップを読み込み、時計の針を一定間隔で実行するタイマー(setInterval)で回し続けていた。おかげでCPU使用率は平均21%まで跳ね上がる。Stats.js(統計表示ライブラリ)をオンにするとメインスレッドが51ms(約0.05秒)遅延するようになり、60fps(フレームレート)を維持できない。しかもトップバーは5秒で縮小し、もう一度展開しても5秒後にまた押し込まれる。これは課題。UIを整えたいという意志が裏目に出て、体験が大きく損なわれていた。
前提知識を軽く整理
- CSS3DRenderer(CSS3Dレンダラー): HTML要素をThree.js空間へ配置するレンダラー。DOM(HTML要素)を直接扱うため、マウス操作を受け付ける設定(pointer-events)の扱いが肝心。CSS3DとWebGLを重ねるときは、どちらを優先するかを常に確認する。
- 環境マップ: WebGL空間の背景兼ライティングデータ。HDR/EXR/通常画像を読めるが、パス不一致時にはフォールバック処理が走る。CanvasTexture(キャンバステクスチャ)で代替すると毎フレーム更新フラグが発火する危険がある。
- OrbitControls(オービットコントロール): カメラ制御。UA判定(ユーザーエージェント判定)だけで視野角(FOV)や距離を変えると、不自然なズームが起きる。画面幅検出(matchMedia)でフレンドリーに切り替えた方が安全。
CSS3DとWebGLを重ねる場合、UI系のDOMが自由にイベントを受け取ってしまう。マウス操作の設定を正しく切り替えないと、カメラとHUDのどちらも動けない。ここがmidori232の最大の落とし穴だった。
パスを誤った環境マップが引き起こす負荷
旧版では環境マップを設定する処理で'images/black_back2.jpg'と指定していたが、実在するのはblack_back.jpg。読み込みに失敗してキャンバス生成ループが稼働し、100ms(0.1秒)ごとにテクスチャの更新フラグが立てられていた。CPU負荷が平均14%→21%に悪化したのはこのせい。修正後は同じシーンで9%まで下がった。意外でした。
function setupEnvironmentMap(imagePath) {
if (!imagePath) {
// キャンバスグラデーションで生成
const texture = new THREE.CanvasTexture(canvas);
scene.background = texture;
setInterval(() => {
texture.needsUpdate = true;
}, 100);
return;
}
new TextureLoader().load(imagePath, onLoad, undefined, onError);
}
画像パスを正すだけでなく、タイマー(setInterval)の代わりにアニメーションループ(requestAnimationFrame)連携に切り替え、明示的にタイマーをクリアするようにした。失敗談が減ると、制御コードも落ち着く。フォールバックをAbortController(中断制御)で管理し、ロードに失敗した瞬間にキャンバス生成を止めるようにすると、再描画ループの暴走もピタッと止まった。
マウス操作の設定(pointer-events: none)が妨げた操作
時計コンテナはマウス操作を受け付けない設定(pointer-events: none)で固定され、CSS3D要素を掴んで試す余地がゼロ。さらにトップバーもマウス操作の設定を細かく制御できず、ドラッグ要素が被ると何も反応しなかった。カードをクリックしてもカメラ制御(OrbitControls)が優先され、ユーザーからは「反応がない」と見える。
時計コンテナとCSS3DレンダラーのDOM要素の両方で、マウス操作を受け付けない設定になっていた。
ここに制御を切り替える関数を設け、コンテンツ操作とカメラ操作を切り替えられるスイッチを作った。キーダウン(keydown)でHUDモードへ移行するとCSS3D側をautoに戻し、終了時はまたnoneへ戻す。これだ。マウス操作の設定を一括で制御すると、カメラ制御のパン操作も穏やかになり、ミスクリックが消えた。
タイマー(setInterval)を捨てて、アニメーションループ(RAF)と時計の調和を取る
針の更新は1秒ごとに実行するタイマー(setInterval)で動いていたが、シーン破棄時にクリアされない。ブラウザを開き直すたびにタイマーが残り、1分後には秒針が二重に進む。焦ったけど対処。
アニメーションコールバックを追加する処理に時計更新を登録し、アニメーションループに自然に溶かし込む方式へ切り替えた。1秒ごとの処理は経過時間で判定し、UIを閉じた瞬間に解除する。測定結果は下記のとおり。
| ケース | CPU平均 | タイマー数 | 備考 |
| ------ | ------ | -------- | ---- |
| setInterval継続 | 18% | 4本まで増殖 | 5分放置で秒針が4倍速 |
| RAF統合 | 11% | 1本のみ | 10分でも一定 |
Statsとアニメーションコールバックの監視体制
時計の針だけでなく、環境マップのアニメーションやHUDのアニメーションもアニメーションコールバックの配列に積み上げていた。配列の肥大化を放置すると、1フレームで18個のコールバックが実行され、描画がガタつく。そこでコールバックを登録する処理と破棄する処理を追加し、シーン破棄時に確実に撤去する仕組みを設けた。
実測では、コールバックが12→5個に減り、フレーム時間は16.7ms(約0.017秒)→12.9ms(約0.013秒)へ改善した。Statsのグラフも滑らかになった。短文が効いた。負荷計測を怠ると、体感で分からないまま遅延が積み重なっていく。
デバイス最適化が極端すぎた問題
UA判定(ユーザーエージェント判定)だけでカメラ距離をz = 50まで押しやる実装は大胆すぎた。デスクトップでは時計が豆粒になり、モバイルは視野角(FOV)85度で酔いやすい。検証値は次の通り。
- 旧実装: デスクトップ距離50、モバイル距離15、実測FPS(フレームレート)48→42
- 新実装: 画面幅に応じて距離を計算、FPS 49→49
- ピクセル比(PixelRatio): 旧実装は常に2、新実装はデバイス上限を検知して1.3〜1.8で自動調整
小さくなるトップバーとの格闘
5秒で縮むトップバーは見た目が楽しい。しかし、クリックして展開した直後にまた縮む。驚いた。
一定時間後に実行するタイマー(setTimeout)を毎回置き換えるので、ユーザーが読む前に閉じる。ダンピング時間を可変にし、操作のたびにクリアする仕組みへ変更した。再展開後は最短でも30秒猶予を与え、マウス操作時に即クリアする。UXがやっと落ち着いた。
リサイズ処理の二重定義と未使用コンポーネント
window.addEventListener('resize', …)とwindow.onWindowResize = …が併存していた。さらに自作したドラッグ/リサイズ用Web Componentが読み込まれるだけで利用されておらず、DOMに装飾だけが残る。想定が甘かった。
DraggableElementとResizableElementは実験痕跡としては面白いが、イベントを解除しないままDOMへ追加される。50歳で再挑戦中の身としては、こうした借金を残して次へ進むのは怖い。リファクタリング時には、使わない要素を削除する勇気が大切だと痛感した。
HTMLとWebGLの境界を再配線
CSS3D時計はDOMを介しているため、レイヤー順やz-indexが崩れると一気に見栄えが悪くなる。midori232では制御要素がz-index:4、CSS3Dがz-index:2と細かく層を分けていたが、透明な背景が増えるにつれ境界が曖昧になった。そこで各層のマウス操作の設定(pointer-events)とオーバーフロー(overflow)を再設計し、HUDを開いた瞬間にWebGL側の操作を無効化するロジックを追加した。結果、ユーザーが混乱していた「クリックするとカメラが回るだけ」の現象はゼロになった。予想外。
デバッグ時にはStats(統計表示)とGUI(グラフィカルユーザーインターフェース)を同時に開き、UI層と3D層をクリックした際のイベント伝播をログへ出力した。想定外のバブルが起きていないかを逐一チェックし、バブリング発生箇所にイベント伝播を止める処理を追加するだけで安心感が違う。
実際に試したケースログ
- 環境マップ修正:
black_back.jpgを読み込むと、環境光(Ambient Light)を0.4→0.6に上げなくても十分明るい。FPS(フレームレート)は50→58。CanvasTexture(キャンバステクスチャ)を止めたことでモバイルでもスムーズ。
- 時計針の同期: アニメーションループ(RAF)統合後、秒針ロストが0回。旧実装は10分で平均2回ズレ。ログに「秒針が戻らない」と出ていたエラーが消えた。
- トップバー挙動: 再展開後の自動縮小遅延を30秒にし、クリック時にクリア。離脱率が12%→6%。ユーザーの中断操作も減少。
- デバイス検出: UA判定ではなく画面幅検出(matchMedia)で幅600px以下をモバイル扱いに切り替えたところ、ズーム事故がゼロ。ノートPCとタブレットでの視認性も向上。
- アニメーションコールバック: 登録数を5本に絞り込んだ結果、メインループの処理時間が16.7ms(約0.017秒)→12.9ms(約0.013秒)。GPUの発熱ログも下がった。
使ってみて
実際の挙動はデモで確認できます。全画面で操作したい方はこちら。
デモを全画面で開く
ポイントは次の三つです。
- アニメーションループ(requestAnimationFrame)へ時計更新を統合し、タイマーの増殖を止める。
- 環境マップのパスとフォールバック処理を明示的に分ける。
- 操作系のマウス操作の設定(pointer-events)とUI縮小タイミングをユーザー主体で決める。
旧サイト版との違いを体感したい場合は、旧サイトのデモを見る で比較してみてください。midori233やmidori236と合わせて読むと改善の流れが追いやすいです。
まとめ
- CSS3D時計はアニメーションループ(RAF)で回すとCPU負荷が11%まで下がり、針も安定した。
- 環境マップのパス不一致がキャンバス再生成を招き、負荷とノイズを生んでいた。
- トップバーとマウス操作の設定(pointer-events)を整理し、操作系をユーザー優先に戻した。
- UA判定頼みのデバイス最適化を画面幅検出(matchMedia)+幅換算へ置き換え、視界の破綻を防いだ。
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。今回の整理が、同じようにCSS3DとWebGLを組み合わせる方のヒントになれば嬉しいです。