3D空間にHTMLを表示するシステムを安定化させた
WebGLとHTMLの融合システムを作り直した。
midori256の実装は複雑すぎた。バグが多い。安定しない。
シンプルに作り直した。構造を整理して、拡張しやすくした。
なぜ作ったか
midori256まで、3D空間にHTMLを表示する実装を試してきた。
技術的には動く。でも、コードが複雑になりすぎた。
機能を追加するたびに、別の部分が壊れる。クラス設計が悪かった。
写真展示で使うことを考えると、安定性が重要。来場者の前でクラッシュするわけにはいかない。
作り直すことにした。
シンプルな設計
1ページ1クラス
midori256では、複数のページを1つのクラスで管理していた。
複雑だった。状態管理が難しい。
midori257では、1ページ1クラスに変更。
class HtmlDisplayOriginal {
constructor(width, height) {
this.width = width;
this.height = height;
this.canvas = new OffscreenCanvas(width, height);
this.context = this.canvas.getContext('2d');
this.mesh = null;
}
}
シンプル。各ページが独立している。
1つのページが壊れても、他のページには影響しない。
Web Componentsでカプセル化
HTMLテンプレートをWeb Componentsで定義。
class SimpleComponent extends HTMLElement {
static get template() {
return `
<style>
.wrapper { /* スタイル */ }
</style>
<div class="wrapper">
<h1>タイトル</h1>
<p>コンテンツ</p>
</div>
`;
}
connectedCallback() {
this.innerHTML = SimpleComponent.template;
}
}
customElements.define('simple-page', SimpleComponent);
カスタム要素として定義。他のコンポーネントと干渉しない。
Canvasのサポート追加
midori256では、Canvasを表示できなかった。
Canvas要素をプレースホルダーとして配置して、描画結果をコピーする仕組みを追加。
this.canvasConfigs = [
{
selector: '#canvasPlaceholder1',
default: { width: 320, height: 240 }
}
];
設定を追加するだけで、Canvasが表示できる。
SVG変換の改良
HTMLをSVGに変換する処理を最適化した。
変換のキャッシュ
HTMLが変わっていない時は、変換をスキップ。
async _updateSVG() {
const rawSvg = await this._generateRawSVG();
// 前回と同じならスキップ
if (this._lastRawSvg && rawSvg === this._lastRawSvg) return;
this._lastRawSvg = rawSvg;
// SVG → Canvas → Texture
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(rawSvg);
// ... 以下、変換処理
}
静的なコンテンツなら、1回だけ変換。CPU使用率が50%削減。
エラーハンドリング
SVG変換は失敗することがある。画像の読み込み遅延、不正なHTML構造。
try-catchで囲んで、エラーが起きても継続できるようにした。
try {
await this._updateSVG();
} catch (e) {
console.warn('SVG変換エラー:', e);
// 前回のテクスチャを使い続ける
}
エラーが起きても、画面が真っ白にならない。
インタラクション処理
Raycasterによる判定
3Dメッシュのクリックを検出して、HTML要素に転送。
attachInteraction(camera, domElement) {
domElement.addEventListener('click', (event) => {
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(this.mesh);
if (intersects.length > 0) {
// 座標変換してHTML要素を特定
const element = this.findElementAt(intersects[0].point);
if (element) {
element.dispatchEvent(new MouseEvent('click'));
}
}
});
}
クリック、マウスオーバー、ホイールなど、全てのイベントに対応。
3D空間のHTMLが、普通のWebページと同じように操作できる。
イベントの種類
対応するイベント:
- click, dblclick
- mouseover, mouseout, mousemove
- mousedown, mouseup
- wheel
- touchstart, touchmove, touchend
これだけあれば、ほとんどのUIを実装できる。
ハマったポイント
座標変換の精度
Raycasterで取得した交点を、SVG座標に変換する処理。
何度も計算を間違えた。
クリックしても、別の要素が反応する。10px以上ズレる。
原因は、メッシュの中心座標の考え方。
PlaneGeometryは中心が(0, 0)。でも、SVGは左上が(0, 0)。
補正が必要だった。
// PlaneGeometryの中心を左上に補正
const xSVG = ((localPoint.x + planeWidth/2) / planeWidth) * width;
const ySVG = ((planeHeight/2 - localPoint.y) / planeHeight) * height;
これで、正確にクリック判定できるようになった。
Canvasの描画タイミング
Canvas要素の描画結果を、SVGに反映させる処理。
タイミングが難しかった。
Canvasの描画が完了する前に、SVGを生成してしまう。Canvas部分が真っ白。
requestAnimationFrame()を使って、描画完了後にSVG生成するようにした。
drawCanvas(context, config, index) {
const canvasElem = this.canvasElements[index];
// Canvasに描画
const ctx = canvasElem.getContext('2d');
// ... 描画処理
// SVG用のコンテキストにコピー
context.drawImage(canvasElem, offsetX, offsetY, width, height);
}
各フレームで、Canvasの内容をコピー。リアルタイムに更新される。
メモリ管理
最初、ページを切り替えるたびに、メモリが増えた。
古いメッシュを削除しても、テクスチャが残っていた。
明示的にdispose()を呼ぶ。
stop() {
if (this.mesh) {
if (this.mesh.geometry) this.mesh.geometry.dispose();
if (this.mesh.material.map) this.mesh.material.map.dispose();
this.mesh.material.dispose();
this.mesh = null;
}
}
これで、メモリリークが解消された。
パフォーマンス測定
フレームレートを測定した。
静的コンテンツ
テキストのみのページ: 30fps
SVG変換は最初の1回だけ。その後はキャッシュを使う。
軽い。問題なし。
動的コンテンツ
動画や時計を含むページ: 15fps
毎フレーム、SVG変換が必要。負荷が高かった。
でも、15fpsなら使える。
Canvas含むコンテンツ
Canvasでアニメーション: 20fps
Canvasの描画は軽い。SVG変換も速い。
意外と軽快だった。
試してみた結果
写真ギャラリー
20枚の写真を3D空間に配置。
クリックで拡大表示。HTMLのオーバーレイでキャプション表示。
視覚的に美しい。操作も直感的。
でも、写真の読み込みに時間がかかる。20枚で約5秒。
遅延読み込みを実装する必要がある。これはmidori258で対応した。
動画プレーヤー
動画をHTMLのvideo要素で表示。
再生、停止、シークバーも動く。
ただし、負荷が高かった。動画が再生されていると、フレームレートが10fpsまで落ちる。
1つの動画なら問題ない。複数は厳しい。
インタラクティブUI
ボタン、スライダー、入力欄。全て動く。
3D空間の中で、普通のHTMLフォームが使える。
新鮮だった。「3Dの中でWebページを操作している」感覚。
使ってみて
実際に安定版を使ってみて、midori256との違いを実感した。
クラッシュしない。安定して動く。コードも読みやすい。
ポイントは以下の3つ:
- 1ページ1クラス設計でシンプル化(複雑な状態管理を排除)
- SVG変換キャッシュでCPU使用率50%削減(静的コンテンツは1回のみ変換)
- Canvas要素のサポート追加(プレースホルダー方式で描画結果をコピー)
次のmidori258、midori259で、さらに拡張した。
同じようなWebGL+HTML融合を試している方の参考になれば嬉しいです。
まとめ
今回は、3D空間にHTMLを表示するシステムを安定化させました。
ポイントは以下の4つ:
- シンプルな設計に作り直し(1ページ1クラス、独立性重視)
- Web Componentsでカプセル化(他コンポーネントと干渉なし)
- Canvas要素のサポート追加(リアルタイム描画に対応)
- エラーハンドリング強化(SVG変換失敗時も継続動作)
複雑さを排除して、安定性を優先した。
これが、midori258、midori259の基礎になった。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。