CSS3D初任務 midori235 の跳躍カレンダー再点検記
カメラ前へ移動ボタンを押した瞬間、カレンダーが画面の端へ飛んでいった。CSS3DRenderer(CSS3Dレンダラー、HTML要素を3D空間に配置する仕組み)を初めて試した midori235 のコードを改めて読み返すと、勢いだけで積み上げた罠がいくつも眠っていた。こういう基礎実験ほど丁寧に記録しておきたい。次のバージョンで同じ穴に落ちないための手当てをまとめる。
作ったもの
midori235 は Three.js(3Dグラフィックスライブラリ)の CSS3DRenderer を使い、HTMLベースのカレンダーを3D空間へ持ち込んだ試作だ。Calendar3D クラスで月切り替え・スタイル変更・位置調整・サイズ調整をまとめ、WebGL(Web Graphics Library、ブラウザで3D描画を行う技術)シーンの手前に HUD(Head-Up Display、画面上に情報を表示する仕組み)として浮かせている。動画 01.mp4 では 6種類のテーマカラーがパッと切り替わる派手な演出が目を引くが、裏側では UI 操作とカメラ制御が噛み合っていない。環境マップ(背景画像)もファイルパスの打ち間違いでフォールバック(代替処理)が走りっぱなしになり、CPU がずっと忙しい。初期実験らしい荒さが随所に残る。
初期HUDで目についた違和感
pointer-events トグルが OrbitControls と衝突
createInteractionControl() は CSS3DRenderer の DOM(Document Object Model、HTMLの構造を操作する仕組み)を再帰的に辿り、pointer-events(マウス操作を受け付けるかどうかの設定)を none に切り替えてくれる。しかし OrbitControls(カメラをマウスで操作するコントロール)は常に有効のまま。ボタンをロックしてもカメラは 16ms ごとに更新され、カレンダーは視界の外へ滑り落ちる。HUDを守りたいときこそカメラ側を止めるべきだった。
回避策としては、トグル時に controls.enabled = interactionEnabled を同期させるだけで良い。CSS3D の DOM を守りたければ WebGL 側の input も束ねて止める発想が必要だった。
css3dRenderer の DOM を数えると 312 ノードあった。applyStyleRecursively は毎回それらをループし、1クリックで 6.4ms ほどかかる。瞬きする間もない短い時間だが、そこまで頑張って pointer-events を切ったのに、OrbitControls が 16ms 間隔でカメラを更新し続けるため効果はゼロ。この歪さが midori235 の操作感を大きく損ねていた。
moveToCamera が100ユニット先へ跳躍
move-to-camera ボタンはカメラ位置を取得し、direction * 100 を足した地点へ CSS3D を移動させる。FOV(Field of View、視野角)=75°、Z=500 のまま実行すると、Z=400 まで一気に前進。スライダーのレンジ [-200,200] を軽々と飛び越えてしまう。暴走だ。
減衰を入れて 20〜30 ユニットに抑えれば、HUD は穏やかにカメラへ寄ってくる。カメラ方向ベクトルを正規化したあと距離を別パラメータに切り出すだけで改善できるのに、初期版では勢いで 100 を書き込んでしまった。
試しに 30 ユニットへ落とすと、スライダーのレンジ内にギリギリ収まり、Z=470 付近で静止する。pointer lock(マウスカーソルを固定する機能)なしでも UI が視界から消えず、位置調整も秒単位で済むようになる。距離の固定はただの数字変更に見えて、体験を劇的に変える重要な分岐だった。
位置スライダーはクリック専用
X/Y/Z スライダーには mousedown.preventDefault() と touchstart.preventDefault() が仕込まれている。ドラッグするとイベントが潰され、値が変わらない。クリックだけで目標値を算出する仕組みのため、ユーザーは 1 ピクセルの誤差も許されないクリックを強いられる。これでは調整にならない。
イベントを潰す代わりに input イベントを使い、value をそのまま position に反映させれば、スライダーは普通に機能する。ほんの数行で解決できたのに気付けなかった。
環境マップが見つからず星空が10Hzで発動
setupEnvironmentMap('images/black_back2.jpg') と呼んでいるが、フォルダには black_back.jpg しかない。読み込みに失敗すると CanvasTexture(Canvas要素から生成したテクスチャ)を生成し、setInterval(drawSky, 100) で 200 粒の星を描き続ける。1回あたり平均 3.7ms。1秒で 20 回。つまり毎秒 4,000 個近い arc()(円弧を描く関数)が走る。CPU に負担をかけ続けている状態だ。
ファイル名を正せば解決する話だが、フォールバック自体もアニメーションせず 15 秒ごとに更新する程度へ緩めたい。星が200粒も要る場面ではない。
Promise.all 初期化タスクが一斉に走る
init() の Promise.all(複数の非同期処理を並列実行する仕組み)には setupScene(), setupControls(), setupDeviceOptimization()、そして setupEnvironmentMap() がまとめて突っ込まれている。CSS3D オブジェクトの生成も同じ配列に入るため、環境マップが失敗してフォールバックを描くと、その 100ms ループが完了するまで HUD の描画も待たされる。実測すると初期ロードが 1.8 秒に伸び、threeContainer には空の WebGL キャンバスだけが残った。ロードスピナー(読み込み中の表示)を用意するか、環境マップを別タスクへ分離しておくべきだった。
デバイス最適化は距離がばらばら
UA(User Agent、ブラウザの種類を識別する情報)判定で FOV と cameraPosition を切り替えているものの、Z だけ 15 / 12 / 50 と極端に差がある。Calendar3D のサイズは固定なので、desktop だけ文字が 0.36 倍まで縮む。pixelRatio(ピクセル比)=2 で高精細に描いても、距離が離れすぎれば読みやすさは戻らない。
Z を 20 付近に揃え、zoomSpeed だけ調整した方が HUD は安定する。midori236 以降で距離を揃えた結果、「小さくて読めない」というフィードバックはなくなった。
animationCallbacks は push するだけで呼ばれない
animationCallbacks 配列にコールバック(関数を後で呼び出すための仕組み)を詰め込むものの、animate() は renderer.render を呼ぶだけ。積み上がったクロージャ(関数とその周囲の変数をセットにしたもの)は一度も実行されない。Calendar3D が animate を持たないから事故にならなかっただけで、別のコンポーネントを追加すればメモリリーク(メモリが解放されずに残り続ける問題)になる。
実際に addAnimationCallback(() => component.animate(object)); を10回呼べば、配列は 10 エントリのまま永遠に残り続ける。本来想定していた「1フレームごとに更新する HUD のアニメーション」は、ループが存在しない時点で機能しない。for (const cb of animationCallbacks) cb(); を入れるだけで小刻みな更新が可能になるのに、その肝心な1行を忘れていた。
配列を回して実行しつつ、remove 時に掃除する仕組みが必要だった。midori236 で ComponentManager を導入したのは、この穴埋めの意味も大きい。
DragControls / GUI / Stats を読み込むだけで使っていない
試作の勢いで import(外部ライブラリを読み込む処理)したが、実装では一度も触れていない。Stats(パフォーマンス計測ツール)はコメントアウトされたまま残り、DragControls(ドラッグ操作のコントロール)も初期化されない。読み込んだ時点で 70KB 以上のモジュールがネットワークにぶら下がるだけだ。
必要なものだけ import し、dat.GUI を使うなら操作パネルとセットで設計する。試作でもこの鉄則は忘れてはいけないと痛感した。
使ってみて
HUD を弄っていると、カメラ前へ飛ぶボタンが便利そうに見えるが、距離を固定しないと画面外へ消える。pointer-events ボタンも OrbitControls と連動させなければ意味がない。基本のループこそ丁寧に設計するべきだと改めて学んだ。試行錯誤の過程で、距離の調整が操作感を大きく左右することに気づいた。
まとめ
- CSS3D のインタラクションを止めても OrbitControls が動き続け、HUD が毎回フレームアウトする。
moveToCamera() が forward ベクトルへ 100 倍して加算し、Z=500 → 400 へ跳躍するため UI が行方不明になる。
- 位置スライダーはドラッグイベントを
preventDefault() しており、クリック以外の操作を受け付けない。
images/black_back2.jpg が存在せず、星空フォールバックが 10Hz で走り続け CPU を占有する。
- デバイス最適化は Z を 15 / 12 / 50 に分けてしまい、デスクトップだけ HUD が読めなくなる。
- animationCallbacks は push されるだけで一度も実行されず、コンポーネントを増やすとリークが発生する。
- DragControls / dat.GUI / Stats を import するだけで使っておらず、バンドルが肥大化している。
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。デモを触ると初期実装の癖がその場で分かります。次の HUD 改修の下敷きとして、同じミスを繰り返さない一助になれば嬉しい。