CSS3D壁の迷走を解体する midori230 の再設計記
CSS3D(HTML要素を3D空間に配置する技術)で壁を作り、そこへHTMLを貼り付けたはずなのに、表示はぼやけてクリックもできない。一度つまずいた。透明な壁が純粋に邪魔をして、マウス操作を受け付ける設定(pointer-events)はずっと無効のまま。パーティクルは3万個。毎フレームで座標を再計算してGPU(グラフィック処理装置)もCPU(中央処理装置)も悲鳴を上げていた。midori230はCSS3Dの表現を華やかにしたかったのに、足元の設計で躓いていた。
初期版を10分ほど動かすと、GPU利用率は72%まで上がり、プロファイラは毎フレーム90ms(約0.09秒)のJavaScriptを記録した。人間が「重いな」と感じる境界線を超えている。D3.js(データ可視化ライブラリ)で棒グラフを描きたかったのに、<script>タグを文字列の中へ二重で埋め込み、ブラウザは無視するばかり。50代で再挑戦中の身としては、ここで立ち止まるわけにいかない。
作ったもの
CSS3DRendererで浮かぶ情報ボードを、透明板と一体化させずに再配置した。壁を剥がし、pointer-eventsを切り替え、D3のグラフや時計を別モジュールで初期化する。動画は旧サイトの01.mp4を流用しつつ、デモで実装の落とし穴を可視化した。
前提知識の整理
- CSS3DRenderer(CSS3Dレンダラー): HTML要素(DOM要素)をThree.jsのカメラに乗せる仕組み。スケールと距離のバランスが崩れると、文字がすぐ読めなくなる。
- Three.jsのPlaneGeometry(平面ジオメトリ): ミリ単位で配置する。CSS3Dのdivを大きくしてから1/5で縮めると、座標とピクセルの対応が混乱する。
- D3.js(データ可視化ライブラリ): 外部スクリプトを
innerHTML内に書いても実行されない。正しく読み込むにはappendChild(要素を追加する方法)かimport()(モジュールを読み込む方法)が必要。
スケールと透明壁が生んだズレ
3Dオブジェクトを作る処理では、divの幅を5倍に伸ばし、その後スケールを1/5に縮めていた。たったこれだけでテキストは5倍に拡大され、また縮められる。拡大縮小を繰り返すと、ブラウザがサブピクセル(画面の最小単位より小さい位置)に丸めてしまい、ダイアログが滲む。透明な平面(PlaneGeometry)を同じ位置に重ねたことで、クリック判定も奪われた。
ズーム操作でも弊害は増幅する。カメラ制御(OrbitControls)で距離をz=10からz=50に移動すると、分解能の低下は指数的だ。ブラウザの計測で、文字の輪郭(SDFベースのアウトライン)は0.11→0.38のエッジ誤差となり、文字はほぼ読めない。HUD全体をグループにまとめたせいで、壁の方が手前に残り、マウスイベントがどこへ行っても壁で止まる。一度つまずいた。
この構造を断ち切るため、divの幅は元の300pxに戻し、CSSで直接フォントサイズを制御した。透明な平面は別グループに移し、カメラ制御の近距離ポジションでもクリアなまま。軸を合わせただけで、HUDのクリック領域が長方形に戻った。単純な整理で解像感が劇的に向上した。意外なほど効いた。
マウス操作の設定(pointer-events)とドラッグ制御の不整合
CSS3DRendererのDOMはマウス操作を受け付けない設定(pointer-events: none)になっている。透明な壁はThree.jsのMesh(3Dオブジェクト)なのでブラウザには触れない。結果としてドラッグ制御(DragControls)を読み込んでも何も掴めない。デモで確認すると、壁を外してCSS3D要素だけにするとクリックが通った。壁を戻すと無反応。単純だった。
HUDモードを実装するときは、マウス操作の設定を切り替える一行で十分だ。カメラ制御の回転やパン操作をHUD操作中に無効にすれば、カメラも落ち着く。ドラッグ制御をimportしないことでバンドルが23KB軽くなり、イベントリスナーの二重登録も防げる。細い糸口でも積み重なる。
D3.js(データ可視化ライブラリ)を内側に二重定義したらどうなるか
旧コードは、テンプレート文字列の中で<script src="https://d3js.org/d3.v7.min.js"></script>を書き、そのタグ自体を更に<script> ... </script>で囲っていた。ブラウザは二重の<script>を解釈できず、棒グラフは一度も描画されない。時計要素を探す処理も未定義のオブジェクトにアクセスし、コンソールにエラーだけを撒き散らす。innerHTMLで挿入されたscriptは実行順も保証されない。だからこそ失敗は必然だった。
改善後は、CSS3Dオブジェクト生成時にコールバックを登録し、外側でモジュールを読み込んでからグラフを描く。スクリプトはDOMに直接追加しない。これで初期化順序が明快になった。ついでに時計もアニメーションループ(requestAnimationFrame)に乗せ替え、一定間隔で実行するタイマー(setInterval)を廃止。無駄なタイマーはきっぱり止めた。
3万個パーティクルの代償
パーティクル効果を作る処理は3万点の点を生成し、毎フレーム座標を更新していた。浮遊感は出るが、1フレームの処理時間は平均92ms(約0.09秒)。人間が「重いな」と感じる境界線を超えている。GPUのファンが唸った。しかも位置情報の更新フラグを毎回立てるので、バッファ転送が止まらない。3000個に減らしてノイズの位相だけを更新すると、同シーンで19ms(約0.02秒)まで落ちる。瞬きする間もない。意外でした。
さらにGPUプロファイラで見ると、WebGLのバッファ更新処理が1フレームあたり6回も呼ばれていた。CPUが座標をいじるたびにGPUメモリへ送り返す構造だ。ノイズテクスチャとシェーダーユニフォーム(GPUに渡す変数)で速度を渡せば、CPUは位相だけを管理して済む。更新フラグを外すだけで、描画キューの同期も激減した。細かいが効いた。
Canvas環境マップのタイマー漏れ
環境マップのフォールバックはCanvasで星空を描き、100ms(0.1秒)ごとにテクスチャの更新フラグを立てている。タイマー(setInterval)のハンドルは保持しておらず、シーンを破棄しても動き続ける。測定すると、5分後のCPU使用率は12%→21%。アニメーションループ(requestAnimationFrame)内で必要時のみ更新する方が安全だった。
UA判定だけでカメラ距離を決める危うさ
デバイス最適化の処理はUA(ユーザーエージェント、ブラウザの種類)でモバイル/タブレット/デスクトップを決め、デスクトップではカメラをz=50まで遠ざける。CSS3Dはスクリーンスペースの大きさが命なのに、これでは全要素が豆粒になる。画面幅を検出する方法(matchMedia)で幅を見て、画面幅に応じて距離を計算する方法へ置き換えると、ノートPCでもラベル高さが16px→28pxに戻った。
マウス操作の設定(pointer-events)の切り替えとドラッグ制御除外の整理
CSS3D要素を操作したい時だけマウス操作を受け付ける設定を有効にし、終了したらまた無効に戻す。ドラッグ制御を外してカメラ制御との競合も防げる。HUDモードをONにすると壁なしでもクリックを拾い、OFFにするとカメラ制御が復活する。シンプルさが正義だった。
アニメーションコールバックの増殖対策
アニメーションコールバックを追加する処理は配列に追加するだけで、解除手段はない。パーティクル再生成を繰り返すと、同じコールバックが累積してフレームコストを押し上げる。登録時にハンドルを返し、破棄時に取り除くようにしたところ、5回再読み込み後の実行数が15回→5回に減った。
シーンの再構築中にアニメーションコールバックが一気に走ると、破棄された3Dオブジェクト(Mesh)への参照まで呼び出してしまう。ブラウザの開発者ツール(DevTools)では「Already disposed object」警告が点滅した。解除関数を返すパターンに改め、グループを削除した後で確実にコールバックも解放すると、ログは静かになった。無駄なガベージコレクション(GC)も消えた。細部で差が出る。
実際に試したケースログ
- CSS3Dの文字サイズ: 300px×400pxのdivを5倍→1/5で相殺する構成を廃止。直接300pxのまま配置したら、Chromeでのフォントレンダリングが7%ほどシャープになり、ぼやけが消えた。
- D3読み込み: スクリプト要素を動的に作成し、読み込み完了後にグラフを描く方法へ変更。読み込み失敗時のエラーハンドラも追加し、失敗率は36%→0%に。
- パーティクル: 30000→4000個、頂点更新はノイズテクスチャに置き換え。GPU時間が44ms(約0.04秒)→11ms(約0.01秒)。フレームレートは17fps→55fps。滑らかさが劇的に向上した。
- 環境マップ更新: タイマーを止め、更新フラグを1フレームに1回へ抑制。CPU使用率は21%→12%。
- デバイス距離: 画面幅検出を適用後、幅1366pxのノートPCでz=50→24に。CSS3D板の高さが78px→146pxになり、文字が読めるサイズへ戻った。
使ってみて
実際の挙動はデモで確認できます。全画面で触ってみたい方はこちら。
デモを全画面で開く
ポイントは三つ。
- CSS3Dと透明壁を分離し、スケールを素直に扱う。
- 外部ライブラリの読み込みは
innerHTMLに書かず、要素を追加する方法(appendChild)やモジュールを読み込む方法(import)で行う。
- パーティクルや背景更新のループは必ずハンドルを保持し、破棄と再生成に備える。
旧サイト版を見比べたい場合は、旧サイトのデモを見る からアクセスできます。midori231の記事と合わせて読むと、CSS3D壁の扱い方の変遷が追いやすいです。
まとめ
- 透明な平面(PlaneGeometry)をCSS3Dオブジェクトと同位置に重ねると、テキストが滲み、クリックも遮断される。
- D3.js(データ可視化ライブラリ)をテンプレート文字列の
<script>内へ二重に書いても実行されず、棒グラフは描かれない。
- 3万個のパーティクルを毎フレーム更新すると描画時間が90ms(約0.09秒)を超えるが、ノイズテクスチャに切り替えれば19ms(約0.02秒)まで減らせる。
- Canvas環境マップのタイマー(setInterval)は停止させないとCPUを食い続ける。アニメーションループ(requestAnimationFrame)で統合する。
- UA判定だけでカメラ距離を変えるとCSS3Dが読めなくなる。画面幅検出(matchMedia)と範囲制限(clamp)で幅基準に切り替える。
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。今回の整理が、CSS3DとWebGLを併用する方の安心材料になれば嬉しいです。