3D空間のインターフェースデザインを試した
拡張現実のインターフェースを考えた。
平面のUIと違って、3D空間ではどう配置すれば使いやすいか。色々なパターンを作って試した。
なぜ作ったか
これまで、3D空間にHTMLコンテンツを表示する仕組みを実装してきた(midori257、midori258、midori259)。
技術的には動く。でも、使いやすいかは別問題。
ボタンをどこに配置するか。メニューはどう見せるか。操作フィードバックはどう表現するか。
平面のWebサイトなら、長年の実績がある。ヘッダーは上、メニューは左、ボタンは中央。セオリーがある。
3D空間には、セオリーがない。
VRアプリやゲームのUIを参考にしたけど、それをそのまま使えるわけじゃない。
自分で試して、感覚を掴むしかなかった。
作ったパターン
パターン1: 中央配置型
最初に試したのは、中央配置。
3D空間の中心にパネルを浮かせて、ボタンを並べる。シンプル。
でも、問題があった。
カメラを回転させると、パネルが見えなくなる。常にカメラ正面を向くようにする必要がある。
Three.jsのSprite を使えば、常にカメラを向く。でも、HTMLコンテンツはSpriteに適用できない。
自分でカメラの向きに合わせて回転させる処理を追加した。
// パネルをカメラ正面に向ける
mesh.lookAt(camera.position);
これで、カメラがどこにあっても、パネルは正面を向く。
でも、カメラがパネルの裏側に回ると、パネルが反転して見える。これは課題。
DoubleSideマテリアルにして、両面表示にした。でも、やっぱり違和感がある。
パターン2: 固定配置型
次に試したのは、固定配置。
3D空間の特定の位置にパネルを固定する。カメラの向きに関係なく、同じ場所にある。
例えば、左上に常にメニューパネル。右下に操作パネル。
これは分かりやすかった。ユーザーが「左上を見ればメニューがある」と覚えられる。
でも、別の問題が出た。
パネルが遠すぎると、文字が読めない。近すぎると、視界を塞ぐ。
距離の調整が難しい。
試行錯誤の結果、カメラから200~300ピクセル離した位置が最適だと分かった。これより近いと圧迫感、遠いと見えない。
パターン3: レイヤー型
画面を複数のレイヤーに分けて、それぞれにUIを配置するパターン。
- 最前面: 常に表示されるボタン(ホーム、設定)
- 中間: コンテンツエリア(画像、動画)
- 背景: 3D空間(装飾、環境)
これは、従来のWebサイトの構造に近い。理解しやすい。
実装も簡単だった。z座標で前後を管理するだけ。
menuPanel.position.z = 100; // 最前面
contentPanel.position.z = 0; // 中間
background.position.z = -200; // 背景
でも、レイヤーが多すぎると、奥行きが分かりにくい。
3層までが限界だと感じた。4層以上になると、どこに何があるか把握できない。
パターン4: コンテキストメニュー型
オブジェクトをクリックすると、その周辺にメニューが表示されるパターン。
スマホのコンテキストメニューに近い。
object.addEventListener('click', (e) => {
const menu = createContextMenu();
menu.position.copy(object.position);
menu.position.x += 50; // 少し右にずらす
scene.add(menu);
});
これは便利だった。必要な時だけメニューが出る。普段は邪魔にならない。
でも、メニューが多いと、画面がごちゃごちゃする。
メニュー項目は3個までが限界。4個以上になると、選びにくい。
インタラクティブ性の工夫
ホバー効果
マウスオーバーで、UIが反応するようにした。
ボタンの透明度を下げる。色を変える。サイズを大きくする。
button.addEventListener('mouseover', () => {
animateStyles(
button,
{ opacity: { from: 1, to: 0.8 } },
{ duration: 150, easing: 'easeInOutQuad' }
);
});
視覚的なフィードバックがあると、操作しやすい。
でも、やりすぎると邪魔。透明度を少し変えるだけで十分だった。
クリック反応
クリックした時に、何かが起きたと分かるようにした。
ボタンが拡大してから縮む。色が変わる。
button.addEventListener('dblclick', () => {
animateStyles(
button,
{
transform: {
from: 1,
to: 1.2,
parser: (value) => `scale(${value})`
}
},
{
duration: 200,
callback: () => {
// 元に戻す
animateStyles(
button,
{
transform: {
from: 1.2,
to: 1,
parser: (value) => `scale(${value})`
}
},
{ duration: 200 }
);
}
}
);
});
短時間(200ms)で拡大・縮小。これで、クリックしたと分かる。
オーバーレイメッセージ
操作結果を、画面上部にオーバーレイで表示した。
「画像1が選択されました」と1秒間表示。
const overlayMsg = document.createElement('div');
overlayMsg.textContent = '画像1が選択されました';
overlayMsg.style.position = 'fixed';
overlayMsg.style.top = '20px';
overlayMsg.style.left = '50%';
overlayMsg.style.transform = 'translateX(-50%)';
document.body.appendChild(overlayMsg);
setTimeout(() => {
overlayMsg.remove();
}, 1000);
これは分かりやすかった。何が起きたか、一目で分かる。
でも、表示時間が長すぎると邪魔。1秒が最適だった。
配色の実験
インターフェースの配色も重要。
ダークテーマ
最初はダークテーマで作った。
背景: 濃いグレー (#1e1f29)
テキスト: 白 (#fff)
ボタン: 青 (#007bff)
3D空間の背景が明るい時、ダークなUIパネルは見やすい。コントラストがある。
でも、背景が暗い時は、パネルが溶け込む。見えにくい。
半透明背景
次に試したのは、半透明背景。
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
背景をぼかして、パネルを浮き上がらせる。
これは美しかった。でも、文字が読みにくい。
透明度を調整した。0.1だと薄すぎる。0.3だと濃すぎる。
0.15が最適だった。背景が透けつつ、文字も読める。
背景色の動的変更
3D空間の背景色に合わせて、UIパネルの色を変える仕組みも試した。
背景が明るい → パネルを暗く
背景が暗い → パネルを明るく
自動で調整するのは難しかった。結局、手動で設定する方が確実だと分かった。
ハマったポイント
SVG変換の遅延
HTMLコンテンツをSVGに変換して、テクスチャとして3Dメッシュに適用している。
この変換に時間がかかる。1フレームあたり50~100ms。
15fpsに制限しても、カクつく。
最適化した。SVGが変わっていない時は、変換をスキップ。
if (this._lastRawSvg && rawSvg === this._lastRawSvg) return;
this._lastRawSvg = rawSvg;
これで、静的なUIなら60fps維持できるようになった。
でも、時計のように毎秒更新されるUIは、やっぱり負荷が高かった。
クリック判定の精度
3D空間のメッシュにクリックイベントを渡すのが難しかった。
Raycasterで交差判定して、メッシュのローカル座標に変換。その座標が、HTML要素のどこに対応するか計算する。
// メッシュのワールド行列の逆行列を求める
const inverseMatrix = new THREE.Matrix4()
.copy(this.mesh.matrixWorld)
.invert();
const localPoint = intersection.point
.clone()
.applyMatrix4(inverseMatrix);
// SVG座標に変換
const xSVG = ((localPoint.x + (planeWidth / 2)) / planeWidth) * this.width;
const ySVG = ((planeHeight / 2 - localPoint.y) / planeHeight) * this.height;
座標変換の計算を何度も間違えた。クリックしても、別の要素が反応する。
試行錯誤して、やっと正しい座標に変換できた。
メモリリーク
最初、インターフェースを切り替えると、メモリが増え続けた。
古いメッシュやテクスチャを破棄していなかった。
明示的にdispose()を呼ぶようにした。
if (this.mesh.geometry) this.mesh.geometry.dispose();
if (this.mesh.material.map) this.mesh.material.map.dispose();
this.mesh.material.dispose();
これで、メモリ使用量が安定した。
試してみた結果
中央配置型: 使いやすい、でもカメラ制御が必要
シンプルで分かりやすい。初めて見る人でも、すぐに理解できた。
でも、カメラを自由に動かせないのは窮屈。UIを避けながら3D空間を見る必要がある。
写真展示には向かない。来場者がカメラを操作する時、UIが邪魔になる。
固定配置型: 安定、でも距離調整が難しい
場所が決まっているので、迷わない。
でも、カメラの位置によって、パネルが遠すぎたり近すぎたりする。
3D空間を自由に動き回るアプリには不向き。固定視点のアプリなら使える。
レイヤー型: 理解しやすい、3層までが限界
従来のWebサイトに近いので、違和感がない。
ユーザーが操作を覚えやすい。「最前面にボタン、中間にコンテンツ、背景に装飾」と直感的。
実用的だと感じた。写真展示でも使えそう。
コンテキストメニュー型: 便利、でもメニュー項目は3個まで
必要な時だけ表示されるので、邪魔にならない。
でも、メニューが多いと選びにくい。3個までなら使いやすい。
使ってみて
実際に色々なパターンを試して、3D空間のUIデザインの難しさを実感した。
ポイントは以下の3つ:
- レイヤー型が最も使いやすい(3層まで、平面Webサイトに近い構造)
- カメラからの距離は200~300px が最適(近すぎず遠すぎず)
- インタラクティブ要素は3個まで(多すぎると選びにくい)
3D空間のUIは、平面UIとは全く違う。セオリーがないから、自分で試して感覚を掴むしかない。
同じような3Dインターフェースのデザインをしている方の参考になれば嬉しいです。
まとめ
今回は、3D空間のインターフェースデザインを実験しました。
ポイントは以下の4つ:
- 4つのパターンを試した(中央配置、固定配置、レイヤー型、コンテキストメニュー型)
- レイヤー型が最も実用的(3層までが理解しやすい)
- カメラ距離200~300pxが最適(文字が読める、圧迫感がない)
- ホバー効果とクリック反応で操作性向上(透明度変化、サイズ変化)
セオリーがない分野だから、試行錯誤が必要だった。
でも、その分、発見があって面白かった。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。