CSS3Dで傾けたHTMLに overflow: hidden を付けたら、Chromeの当たり判定が0点になった
3D空間のモニターに置いたHTMLにクリックが届かない原因を、elementFromPointの格子計測で特定した記録。背景穴あけ合成と樽型魚眼レンズの立体射影もあわせて解説。
#Three.js#CSS#デバッグ
このポートフォリオサイトは2026年10月、それまで宇宙に浮かんでいたボクセルの惑星(v1)から、廃棄されたロケットコロニーの中を増改築した「自分の部屋(現行版)」へと世界観を全面的に作り替えました。
新しい世界では、読み手は部屋の中央に立ち、首を振って周囲の5区画を見渡します。その正面の作業机には、トリプルディスプレイのデスクトップPCが置かれており、自己紹介の区画には空中に文字が浮かぶ3Dホログラムがあります。
このPCモニターとホログラムを設計するにあたって、私は「画面や文字をテクスチャ(絵)として描かない」と決めました。テクスチャに文字を描いてしまうと、検索エンジンにも画面読み上げ機能(スクリーンリーダー)にも一切読まれず、以前 Google AdSense で「有用性の低いコンテンツ」と判定された痛烈な教訓に逆行してしまうからです。
そこで採用したのが、Three.js の CSS3DRenderer(あるいは drei の <Html transform>)を使い、本物の HTML 要素(DOM)を 3D 空間の中に配置するというハイブリッド構成でした。
しかし、この仕組みを実装した瞬間、Webフロントエンド開発の歴史でも滅多にお目にかかれない奇妙なバグに直面しました。見た目は3D空間内のモニターに寸分の狂いもなく美しく描画されているのに、マウスクリックやホバーなどの当たり判定(Hit-testing)が1ピクセルも届かないという謎です。
この当たり判定消失事件を、document.elementFromPoint による格子計測で特定したデバッグ劇と、3D空間における「背景穴あけ合成」の仕組みについて記録します。
「手前に重ねる」ホログラムと、「後ろに置いて穴をあける」PC画面
3D空間の中に HTML を置くと言っても、WebGL の Canvas と DOM 要素の重なり順には根本的に異なる2つのアプローチがあります。
| 方式 | 構造 | メリット | デメリット | 採用箇所 |
|---|---|---|---|---|
| 手前重ね | WebGL Canvas の上に HTML を CSS3D で重ねる | 背景が透ける半透明表現が容易 | 手前にある3Dオブジェクト(机や椅子)に隠れず、常に最前面にはみ出る | 自己紹介ホログラム |
| 背景穴あけ(Punch-through) | WebGL Canvas の背面に HTML を置き、モニター画面のポリゴンを透明にして穴をあける | 手前にある3Dオブジェクト(椅子の背もたれやデスクライト)で正しく遮蔽(オクルージョン)される | 穴の向こうが透明なため、中立な背景色設計が必要 | デスクトップPC画面 |
自己紹介のホログラムは、空中に浮かぶ光の文字なので「手前重ね」で十分です。
しかし、机の上のPCモニターに「手前重ね」を使うと、手前にある椅子の背もたれ(高さ0.98m)の上に、PCモニターのタスクバーやウィンドウが不自然に重なってはみ出してしまいます。モニターは物理的な家具の奥にあるべきだからです。
そこでPCモニターには**「背景穴あけ合成」**を採用しました。
HTMLのレイヤーを WebGL Canvas の背後に配置し、Three.js 側のPCモニターの画面メッシュには transparent: true かつ blending: THREE.NoBlending、アルファ値 0.0 のマテリアルを貼り付けます。これにより、WebGLの描画パイプラインが画面部分のピクセルだけを「完全な透明の穴」としてくり抜き、背後にある実物の HTML がモニターの枠の中にぴったり覗き見えるようになります。
これで、手前に椅子の背もたれやデスクライトが被っても、それらは通常の3Dメッシュとして前面に描画され、自然な遮蔽関係が成立しました。
謎のバグ:画面は完璧に見えているのに、クリックが届かない
背景穴あけ合成によって、見た目は完璧になりました。モニターの中にはOSライクなデスクトップ画面が広がり、ファイルアイコンが並び、閉じるボタン(×)がついたウィンドウが表示されています。
ところが、そのファイルアイコンをクリックしようとマウスを重ねても、カーソルがポインターに変わりません。クリックしても、ダブルクリックしても、ドラッグしても、何のイベントも発火しないのです。
ブラウザの「要素を検証(DevTools)」でカーソルを当ててみると、確かにそこに <div class="desktop-icon"> が存在しているように見えます。しかし、実際のクリックイベントは虚空へと吸い込まれていました。
最初は誰もが疑うような原因を疑いました。
- 「前面の WebGL Canvas に
pointer-events: noneが効いていないのではないか?」 - 「CSS3D の 1,000倍スケーリング(
scale(0.0005))の計算誤差で、座標が狂っているのではないか?」 - 「3Dトランスフォームの
perspective()関数の指定位置が悪いのか?」
しかし、どれをいじっても状況は1ミリも変わりませんでした。
elementFromPoint による格子計測デバッグ
目測や推測で直すのを諦めた私は、Chromeのレンダリングエンジン(Blink)が画面上のどこに当たり判定を認識しているのかを物理的に測定するため、画面全体に格子状に座標を打って判定を数えるデバッグスクリプトを書きました。
// 画面全体を格子状に走査し、ヒットした要素をカウントする
let hitCount = 0;
const step = 20;
for (let x = 0; x < window.innerWidth; x += step) {
for (let y = 0; y < window.innerHeight; y += step) {
const el = document.elementFromPoint(x, y);
if (el && el.closest('.css3d-screen')) {
hitCount++;
}
}
}
console.log(`Hit test count: ${hitCount}`);
このスクリプトを走らせながら、怪しいCSSプロパティを1つずつ足し引きして、ヒット数の変化を測定しました。
| テストした状態 | ヒット数 | 考察 |
|---|---|---|
| 初期状態(すべての3D変形あり) | 0 点 | 完全に当たり判定が消失している |
| スケール値を 0.0005 から 1.0 に変更 | 0 点 | 縮尺の浮動小数点丸め誤差ではない |
perspective 関数をプロパティに変更 | 0 点 | 遠近法の構文の違いではない |
| 3D変形(CSS transform)をすべて外す | 485 点 | 平面状態では完全にヒットする |
| 遠近法(perspective)のみを外す | 134 点 | 3D変形がかかっていても、遠近がなければ一部ヒットする |
3D要素の親から overflow: hidden を外す | 252 点 | 一気に当たり判定が復活! |
犯人は、モニター画面を角丸にクリップするために親要素につけていた overflow: hidden でした。
Chrome の罠:3D変形と overflow: hidden が合わさると判定が死ぬ
Chrome のレンダリングエンジンにおいて、CSSの3D変形(transform-style: preserve-3d や回転・傾き)が適用された要素ツリーの中に overflow: hidden が存在すると、レイヤーの平面投影計算がバグを起こし、ポインターイベントのヒットテスト対象領域が「面積ゼロ(空集合)」として処理されてしまうというブラウザ固有の挙動があったのです。
画面上ではGPUによってピクセルが正しく変形されて描画されているのに、CPU側の入力イベント判定システムは「この要素は3D空間でクリップされた結果、画面上のどの点にも存在しない」と誤認していたわけです。
解決策はシンプルでした。親のコンテナ要素から overflow: hidden を完全に撤去し、モニターの枠からはみ出る部分は3Dモデル側のベゼル(額縁メッシュ)の物理的な厚みで覆い隠すように設計を変更しました(なお、ウィンドウ内のスクロール領域である overflow-y: auto は無害でした)。
overflow: hidden を外した瞬間、画面上のヒット数は 0点から 252点へと跳ね上がり、マウスカーソルがファイルアイコンの上でピタッと指マークに変わり、ダブルクリックでウィンドウが小気味よく開くようになりました。
部屋を見渡すもう1つの工夫:樽型の立体射影レンズ
こうして3D空間の中に本物のHTML操作画面が組み込まれましたが、最後に「部屋全体を中央から見渡す」ためのレンズ設計の工夫も必要でした。
部屋の中央に立って標準画角(水平67°)で見回すだけでは、一度に部屋の片面しか視界に入りません。そこでズームアウト(視点3段階の最広角)として画角161°の魚眼レンズを導入しました。
しかし、Three.js の通常の遠近投影カメラ(PerspectiveCamera)で画角を161°まで広げると、正面の机が豆粒のように縮み、画面の左右の端にある展示棚や家具が数倍に不自然に引き伸ばされてしまいます。通常の遠近投影は平面に投影するため、画角が180°に近づくほど端の拡大率が無限大に発散するからです。
そこで、CubeCamera で周囲6方向を描画し、カスタムシェーダーで画素ごとに視線方向をマッピングする**樽型の立体射影(Stereographic Projection: $r \propto \tan(\theta/2)$)**を実装しました。
立体射影は、中心からの角度 $\theta$ に対して等角写像(共形写像)の性質を持つため、視野を161°まで極限まで広げても、画面の端に映る家具の縦横のプロポーション(形状)が歪まずに保たれます。中央のPCモニターも読める大きさを維持したまま、左側の作業台から右側の展示棚までが1つのパノラマとして視界に収まるようになりました。
ただし、この樽型魚眼レンズは画面全体をシェーダーで歪ませるため、通常の透視投影しか描けない CSS3D の HTML 要素は魚眼の段では非表示にし、代わりに画面をテクスチャとして焼き付けたプレースホルダーを表示する、という切り分けを行っています。
見た目を作る技術と、中身を読めるようにする技術。その境界で起きる摩擦を1つずつ解きほぐすことで、この「ロケットの部屋」はただの3Dグラフィックスではなく、本物の文字とUIが息づくポートフォリオとして完成しました。