CSS3Dと組子を同居させた midori229 の実験録
最初にビルドしたとき、CSSパネルをクリックした瞬間にOrbitControlsが暴走した。一度つまずいた。指先のイライラを飲み込みつつ、組子の花びらを1,600枚もGPUに押し付けていた自分を反省した夜の記録を残します。
先に共有しておきたい前提
- Three.js(3Dグラフィックスライブラリ)基本操作: カメラ、ライト、ジオメトリを扱った経験があると読みやすいです。
- CSS3DRenderer(CSS 3Dレンダラー)の概念: WebGL(Web Graphics Library、ブラウザで3D描画を行う技術)とDOM(Document Object Model、HTMLの構造)を同時に描画するとpointer-events(ポインターイベント)の落とし穴が生まれます。
- 日本の組子文様: 麻の葉や八重桜など、幾何学パターンを3Dメッシュとして再現します。
- GPU(グラフィックス処理装置)メモリ管理: ジオメトリ(3D形状)やマテリアル(材質)の破棄処理を呼ばないとリロードするまでVRAM(ビデオメモリ)が解放されません。
今回の実験で組み上げたもの
Scene(シーン、3D空間)上に木組み文様のメッシュを大量生成し、CSS3D(CSS 3D Transform、CSSで3D変換を行う技術)でGUI(ユーザーインターフェース)と情報パネルを重ねる構成です。組子コントロールの設定関数ではサイズ10を初期値にしており、麻の葉だけでも900本のBoxGeometry(箱状の3D形状)を投げ込みます。
OrbitControls(軌道制御、カメラ操作)に抱き合わせてCSS3DObject(CSS 3Dオブジェクト)を配置するため、Three.js(3Dグラフィックスライブラリ)のグループとDOM(Document Object Model、HTMLの構造)側のdivを常に対にして扱う必要がありました。panel1とpanel2を前後にずらし、奥行きを感じさせるインターフェースを目指していますが、pointerイベント(ポインターイベント)の扱いを誤るとただの飾りになります。50歳の自分でも迷わない導線を作る、というのが裏のテーマです。
// midori229/webgl.js(抜粋 24-81)
function setupKumikoControls() {
const gui = new GUI();
const params = {
kumikoType: '麻の葉',
size: 10,
depth: 0.2
};
gui.add(params, 'kumikoType', ['麻の葉', '胡麻柄', '竜胆柄', '七宝柄', '八重桜'])
.name('組子の種類')
.onChange(() => {
displayKumiko(params.kumikoType, params.size, params.depth);
});
// ... existing code ...
guiDiv.appendChild(gui.domElement);
const gui3DObject = new CSS3DObject(guiDiv);
guiContainer.add(gui3DObject);
}
まずはメッシュ生成を縮小版で確認してください。
全画面で試したい方は 旧サイトのデモ からも触れます。パーティクルではなく木組みなので、ズームしすぎるとジャギーが暴れる点に注意してください。
いきなり直面したポインターの混線
CSS3DRenderer(CSS 3Dレンダラー)のDOM(Document Object Model、HTMLの構造)ルートに pointerEvents: none(ポインターイベント無効)を噛ませたまま、子要素のパネルに auto(ポインターイベント有効)をセットしてもイベントは届きません。これは課題。組子コントロールの設定関数内のトグルボタンはCSSパネルを操れるつもりでしたが、実際には3D操作の切り替えが半端です。
調査の過程では、親要素→子要素の順にpointer-events(ポインターイベント)を切り替えないとブラウザがイベントターゲットを再計算してくれないこと、OrbitControls(軌道制御、カメラ操作)を無効化するタイミングでトラックパッドの慣性が残ることなど、細かなクセも洗い出せました。特にmacOSでは指を離した後もカメラが漂うので、CSSモードへ切り替える直前にコントロールの更新処理を明示的に呼ぶ必要がありました。
修正版では親DOMも一緒に切り替えないと意味がないと痛感しました。下のデモで、3DモードとCSSモードを切り替えたときの体感を再現しています。
ポインタを奪う瞬間を意識すると、操作説明の書き方も変わります。「今はどちらの世界がアクティブなのか」を画面に残すだけで混乱が減りました。
displayKumiko を分解して見えた重さ
組子表示関数はシーンから既存オブジェクトを取り除くだけで、Dispose処理(リソースの破棄処理)をしていません。想定が甘かった。パターンを組み替えるたびにGPU(グラフィックス処理装置)メモリが積み上がります。
原因はシンプルで、組子オブジェクトに格納したグループをシーンから削除する処理で外しているだけだからです。Mesh(メッシュ、3D形状)やExtrudeGeometry(押し出し形状)は参照が残ったままGC(ガベージコレクション、メモリの自動解放)されず、ChromeのChrome://gpuで観測すると割り当てサイズが右肩上がりになりました。記事公開版ではジオメトリ(3D形状)とマテリアル(材質)の破棄処理を組み込む計画です。
フローを追いながら課題を整理したのがこちらです。
失敗コードをこうやって図解すると、対応策として geometry.dispose() を挟む箇所がすぐ見つかります。予想外。
八重桜のエクストリームな花びら地獄
八重桜ユニット1つで外側8枚+内側8枚、計16枚のExtrudeGeometryを作ります。size=10では100ユニットなので1,600枚。Chromeのメモリパネルが赤く染まる理由がこれです。
外側の花びらは petalLength * 0.6 の半径で円形に配置し、内側の小花をさらに0.3倍の位置に置くという凝ったつくりです。花びら1枚ごとにExtrudeGeometryを生成するので、法線計算とテッセレーションのコストが跳ね上がります。心情的には残したい意匠ですが、負荷削減のためにはテクスチャ化やInstancingの導入が必要だと感じました。
組み方を2Dに落として確認できるようにしました。見た目が美しくても処理は容赦ありません。
内側花びらまで描くとポリゴン数が跳ね上がるので、デフォルトでは size=6 に抑える案をメモしておきます。意外でした。
端末最適化のパラメータと現実
setupDeviceOptimization() はユーザーエージェントからモバイル/タブレット/デスクトップを推測し、それぞれFOVとpixelRatio、操作速度を変えています。数値は以下の通りです。
Pixel 7で動作確認するとFOV85の効果で視野が広がり、GUIが歪まない一方でpixelRatio1.5でも描画レイテンシが大きくなります。iPadではdampingFactor 0.15に抑えることでタッチ操作のもたつきを少し解消しましたが、正直に言えばユーザーエージェント判定よりも matchMedia('(pointer: coarse)') など機能検出ベースにした方が安定します。
メッシュ数見積もりの甘さを数字で殴る
パターンを変えるとメッシュ数が一気に跳ねます。createAsanoha() は1ユニット9メッシュ、createRindougara() は30メッシュ。この差を把握せずにsize=10を触ると地獄行きです。
開発当初はカウンタを用意していなかったので、「重い」と感じた瞬間に初めてMesh数に思い当たるという有様でした。デバイス別にMesh数の上限を設ける仕組みを作るため、内部的には displayKumiko を呼ぶたびに統計を取って警告を出すようにする予定です。
ChromeのDevToolsで描画フレームのコストを確認すると、1,500メッシュを超えたあたりでフレーム時間が16msを超えます。ここは正直にsizeの上限を落とすか、LODを導入するべきでした。課題が残った。
遊んで気づいたヒューマンエラー
やり込み中に何度も「CSSモードのまま3D操作しようとして失敗する」ので、遊び要素としてログを残す仕掛けを作っておきました。軽口ですが心のログです。
初期化のPromiseを洗い直す
Promise.all に登録している3つのタスクのうち、環境マップ読み込みPromiseはresolveを即時に呼んでしまい、TextureLoader.load() の完了を待っていません。焦りすぎでした。
TextureLoader.load() をPromise化し、onLoad からresolveする形に改修すれば、環境マップを確実に読み込んでからSceneに入れられます。また、Statsを活かすなら addAnimationCallback を通じて統計更新を差し込むと責務が分けられるので、未使用関数を成仏させることにもつながります。
実行順をタイムラインで可視化すると原因が一目瞭然です。
startAnimation() 側でStatsをコメントアウトしているのももったいないので、Promiseをawait化した上でパフォーマンス値をログに出す案を検討中です。これだ。
使ってみて
実際に触っていただくときは以下のポイントを意識すると迷子になりません。
自分は展示会場でノートPCを開きつつ説明する場面を想定しているので、モード切り替えを声に出して案内する癖をつけました。視界共有が難しいときこそ、画面上のサインと口頭説明を同期させるのが安心材料になります。
- Asanohaプレビューを全画面で開く(デバッグ向け)
3D/CSS切替 ボタンを押すたびに画面右上へ現在モードを表示する
- sizeは6以下に抑えるとGPU負荷が安定
- pointer-eventsを切り替えるときは親要素から順に変更する
同じ混乱を味わっている方の参考になれば嬉しいです。
今回の要点を整理
全体を振り返ると、UIの表現とメモリ管理の両方を見直す必要があると分かりました。ひとまずはユーザー体験を守るためにモード表示を優先し、その後でDispose処理やPromiseの整備を段階的に進める計画です。
- CSS3DRenderer(CSS 3Dレンダラー)のルートを
pointer-events: auto(ポインターイベント有効)に戻さない限りGUI(ユーザーインターフェース)は操作できない
- size=10の八重桜は1,600メッシュ、Dispose処理(リソースの破棄処理)せずに切り替えるとVRAM(ビデオメモリ)を浪費する
- デバイス最適化関数のpixelRatio=2(ピクセル比2)は美しいが、木組みの大量描画では負荷が高い
- 環境マップPromise(非同期処理)が即resolve(解決)なので、読み込み失敗時のリカバリーが効いていない
- DragControls(ドラッグ制御)とanimationCallbacks(アニメーションコールバック)は未使用のまま残っており、バンドルサイズだけ増やしている
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。