同一3D空間に複数のHTMLページを配置した
WebGLとHTMLの融合システムを安定化させた。
midori257の実装を改良。複数のHTMLページを同じ3D空間に配置できるようにした。
コードをクリーンアップして、拡張しやすくした。
なぜ作ったか
midori257で、3D空間にHTMLコンテンツを表示する基礎ができた。
でも、1つのページしか表示できなかった。
写真展示で使うには、複数のページが必要。写真のキャプション、説明文、操作パネル。それぞれ別のHTMLページとして作りたい。
技術的な課題があった。
複数のHTMLコンテンツを同時にSVGに変換すると、干渉する。idやclassが重複すると、スタイルが崩れる。
コンポーネント化する必要があった。
実装の改良
コンポーネント間の独立性
各HTMLページを完全に独立させた。
Web Componentsを使って、カスタム要素として定義。
class SimpleComponent extends HTMLElement {
constructor() {
super();
// 独自のテンプレート
}
static get template() {
return `
<style>
.wrapper_${TAG_NAME} {
/* スタイル */
}
</style>
<div class="wrapper_${TAG_NAME}">
<!-- コンテンツ -->
</div>
`;
}
}
customElements.define('custom-page-1', SimpleComponent);
タグ名とクラス名を動的に生成。他のコンポーネントと干渉しない。
複数ページの同時表示
3つのHTMLページを作成した。
- ページ1: 写真ギャラリー
- ページ2: 動画プレーヤー
- ページ3: 説明文パネル
それぞれ独立したコンポーネント。同じ3D空間に配置。
const page1 = new HtmlDisplayOriginal(1920, 1080);
const page2 = new HtmlDisplayOriginal2(1920, 1080);
const page3 = new StaticHtmlDisplay(1920, 1080);
await Promise.all([
page1.start(camera, renderer.domElement),
page2.start(camera, renderer.domElement),
page3.start(camera, renderer.domElement)
]);
page1.getMesh().position.set(-300, 0, 0);
page2.getMesh().position.set(0, 0, 0);
page3.getMesh().position.set(300, 0, 0);
scene.add(page1.getMesh());
scene.add(page2.getMesh());
scene.add(page3.getMesh());
並列初期化して、位置を調整。
最初、順番に初期化していた。3つで6秒かかった。
並列にしたら、2秒に短縮。Promise.all()で同時実行。
html_Display_original.jsの改良
midori257のhtml_Display_original.jsをベースに、拡張性を強化した。
#### カスタマイズ可能なテンプレート
HTMLテンプレートを外部から指定できるようにした。
static get template() {
return `
<div class="wrapper">
<!-- 自由にHTMLを記述 -->
<button id="myButton">クリック</button>
<div id="content"></div>
</div>
`;
}
各コンポーネントで、テンプレートをオーバーライドできる。
#### インタラクティブ要素の登録
クリック可能な要素を配列で管理。
this.interactiveConfig = [
{
selector: '#myButton',
default: { x: 0, y: 0, width: 100, height: 50 }
}
];
自動的にクリック判定の対象になる。
Raycasterで3Dメッシュを判定して、該当するHTML要素にイベントを転送。
#### メディア要素のサポート
画像、動画、Canvasを簡単に追加できるようにした。
this.imageConfigs = [
{
url: 'images/photo1.jpg',
selector: '#imagePlaceholder1'
}
];
設定を追加するだけで、画像が表示される。
パフォーマンスの改善
SVG変換のキャッシュ
HTMLが変わっていない時は、SVG変換をスキップ。
const rawSvg = await this._generateRawSVG();
if (this._lastRawSvg && rawSvg === this._lastRawSvg) return;
this._lastRawSvg = rawSvg;
静的なコンテンツなら、1回だけ変換。その後は何もしない。
これで、フレームレートが10fps→20fpsに改善した。
フレームレート制限
各ページのフレームレートを個別に制限。
const now = performance.now();
if (this.lastUpdate && now - this.lastUpdate < 1000 / this.fps) return;
this.lastUpdate = now;
静的なページは5fps、動的なページは15fps。
無駄な更新を減らして、全体のパフォーマンスを向上させた。
インタラクティブ性の実装
クリック判定
3Dメッシュのクリックを、HTML要素に転送する仕組み。
// Raycasterで交差判定
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(mesh);
if (intersects.length > 0) {
// ワールド座標→ローカル座標
const localPoint = intersection.point
.clone()
.applyMatrix4(inverseMatrix);
// SVG座標に変換
const xSVG = ((localPoint.x + planeWidth/2) / planeWidth) * width;
const ySVG = ((planeHeight/2 - localPoint.y) / planeHeight) * height;
// HTML要素を探す
for (let element of interactiveElements) {
if (element.contains(xSVG, ySVG)) {
element.elem.dispatchEvent(clickEvent);
}
}
}
座標変換がポイント。
ワールド座標(3D空間)→ローカル座標(メッシュ内)→SVG座標(HTML内)。
この変換を間違えると、クリックが正しく判定されない。
何度も試して、正しい計算式を見つけた。
ホバー効果
マウスオーバーで、HTML要素が反応する。
element.addEventListener('mouseover', () => {
element.style.opacity = '0.8';
});
element.addEventListener('mouseout', () => {
element.style.opacity = '1.0';
});
3D空間内のHTMLでも、普通のWebページと同じように操作できる。
ハマったポイント
コンポーネントの名前衝突
最初、2つのコンポーネントに同じカスタムタグ名を使ってしまった。
customElements.define('html-page', Component1);
customElements.define('html-page', Component2); // エラー!
「DOMException: Failed to execute 'define'」エラー。
タグ名を動的に生成するようにした。
const TAG_NAME = 'html-page-' + uniqueId;
customElements.define(TAG_NAME, Component);
これで、複数のコンポーネントが共存できるようになった。
SVG変換のタイミング
HTMLの画像読み込みが完了する前に、SVGに変換していた。
画像が表示されない。
画像のload イベントを待つ処理を追加。
await Promise.all(
imageConfigs.map(config =>
new Promise(resolve => {
const img = new Image();
img.onload = resolve;
img.src = config.url;
})
)
);
全画像の読み込み完了後、SVG変換を開始。
これで、正しく表示されるようになった。
メモリリーク
ページを切り替えると、メモリが増え続けた。
古いメッシュやテクスチャを破棄していなかった。
dispose()メソッドを追加。
dispose() {
if (this.mesh.geometry) this.mesh.geometry.dispose();
if (this.mesh.material.map) this.mesh.material.map.dispose();
this.mesh.material.dispose();
// イベントリスナーも削除
this.domElement.removeEventListener('click', this._clickHandler);
}
ページ切り替え時に、古いリソースを全て解放。
メモリ使用量が安定した。
試してみた結果
3ページの同時表示
写真ギャラリー、動画プレーヤー、説明文パネルの3つを配置。
視覚的に面白い。情報が立体的に配置されている。
でも、カメラを動かすと、どのページを見ているか分からなくなる。
ナビゲーションが必要だと感じた。「現在、ページ2を見ています」と表示する仕組み。
インタラクティブ操作
ボタンをクリックして、コンテンツが変わる。
普通のWebページと同じように操作できる。
ユーザーからのフィードバックで、「3D空間なのに、普通のWebサイトみたい」と言われた。
良いのか悪いのか、分からない。でも、使いやすいのは確か。
パフォーマンス
3ページ同時表示で、15fps程度。
滑らかではない。でも、操作はできる。
ハイスペックPCなら、問題ない。スマホだと、厳しい。
使ってみて
実際に複数HTMLページを3D空間に配置してみて、可能性を感じた。
普通のWebサイトと3Dアートの中間。新しい表現形式。
ポイントは以下の3つ:
- Web Componentsで各ページを独立(タグ名動的生成、スタイル干渉なし)
- 並列初期化で読み込み時間を短縮(順次6秒→並列2秒)
- SVG変換キャッシュでフレームレート改善(10fps→20fps)
実用性は微妙。でも、技術的には面白い。
同じようなWebGL+HTML統合を試している方の参考になれば嬉しいです。
まとめ
今回は、3D空間に複数のHTMLページを配置するシステムを実装しました。
ポイントは以下の4つ:
- Web Componentsでコンポーネント化(名前衝突を回避)
- 複数ページの並列初期化(Promise.all()で高速化)
- Raycaster座標変換でクリック判定(ワールド→ローカル→SVG座標)
- リソース管理を徹底(dispose()でメモリリーク防止)
midori257の基礎を、拡張性のある形に改良できた。
次のmidori259では、さらに大規模な統合を実現した。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。