HTMLギャラリーを三次元へ持ち上げた midori249 の記録
最初に巨大な写真群をThree.js(3D描画ライブラリ)へ持ち上げようとしたとき、正直に言えば不安だった。一度つまずいた。テクスチャが真っ白になり、CORS(クロスオリジンリソース共有の制限)と格闘する羽目になった。けれど、OffscreenCanvas(メインスレッドを塞がずに描画できる技術)とSVGテンプレートを組み合わせれば、この混沌もギャラリーに変えられると確信できた瞬間があった。
このノートでは、旧サイトのHTMLコンテンツを3D空間に埋め込みつつ、画像資産を傷めずに再配置した手順を細かく書き残す。同じような構想を練っている方が、罠を先回りして回避できれば嬉しい。
作ったものを覗くライトギャラリー
midori249は、HTMLで組んだ写真ギャラリーをThree.jsのPlaneGeometryへ張り付け、複数のボタンでCSS効果をライブに変化させる展示。動画がある場合は冒頭に自動で再生されるようにしたものの、写真展示が主役なので最初の関門は画像資産の圧縮と整流。
元画像の一枚は4640×6005ピクセルで、総ピクセル数は27,863,200。画像をBASE64形式に変換する関数で幅300pxに縮小すると、出力は300×388=116,400ピクセルになり、99.58%の削減。マズい。縮小前はGPU転送だけで100ms以上かかっていたが、縮小後は15ms以下で済む。瞬きする間もない。
前提メモ
- Three.js r170: midori249ではモジュール版Three.jsをimport mapで読み込む。
- OffscreenCanvas: サポートされない環境では通常Canvasへフォールバックするが、描画APIは同じ。
- SVG + foreignObject: HTMLをSVGの中に埋め込み、DrawImage(画像を描画する関数)でCanvasへ転写している。
- dat.GUI / OrbitControls: GUI自体は封印したが、OrbitControls(カメラを回転・ズームできるコントロール)の調整値はそのまま流用。
立体ギャラリーの背骨
写真のリスト、エフェクト値、アニメーションキューをまとめて抱えるのがhtml_Displayクラス(HTML表示用のクラス)だ。convertImageToBase64(画像をBASE64形式に変換する関数)でBASE64化したあとは、SVGテンプレートの再描画、Canvasテクスチャ更新、Raycaster(マウス位置から3D空間のオブジェクトを検出する仕組み)判定という流れで処理が連なる。
// midori249/src/html_Display.js(画像をBASE64へ変換する処理)
async convertImageToBase64(url, maxWidth = 300) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = function () {
const ratio = Math.min(1, maxWidth / this.width);
const canvasWidth = this.width * ratio;
const canvasHeight = this.height * ratio;
const canvas = document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(this, 0, 0, canvasWidth, canvasHeight);
const dataUrl = canvas.toDataURL("image/jpeg");
resolve(dataUrl);
};
image.onerror = function (err) {
reject(err);
};
image.src = url;
});
}
クロスオリジンで画像を読み込むとCanvasが汚染され、SVG→Canvas変換がぽっかり穴になる。最初はcrossOrigin="anonymous"(クロスオリジンリクエストを許可する設定)を付け忘れて真っ白なテクスチャに泣いた。以降はすべての画像をBASE64に変換してからSVGへ埋め込み、描画ごとにtexture.needsUpdate = true(テクスチャの更新フラグ)だけをトリガーにすることで安定した。
Animationクラス自体はduration・easing・onUpdateを引数に取るだけの小さなクラスだが、Promiseの完了ハンドラから次のAnimationをpushすることで「彩度を2秒で上げて2秒で戻す」「回転を1.2秒かけて往復する」といった双方向モーションを簡単に積み重ねられる。数値をthis.effectsに保持しておけば、SVGテンプレートはCSS変数を拾うだけで表情が変わる。
アニメーションキューはwindow.animationCallbacks(アニメーション処理を登録する配列)に登録し、1フレームで複数の効果をまとめて更新する方式にした。登録済みのコールバックを再度pushしないガードを忘れていた頃は、Stats.js(パフォーマンス計測ツール)の平均が41.6fpsまで落ち込んだ。修正後は59.3fpsを安定して維持できた。ヨシ。
画像搬送の失敗と改善
一度つまずいた。初期のmidori249は、画像ファイルを直接PlaneGeometryのテクスチャに指定していた結果、読み込み順序によって展示がちらついた。BASE64化したことで、描画順序が完全に制御可能になり、HTML+CSS由来のビジュアルがThree.js側の制御に干渉しなくなった。なるほど。
同時にSVGテンプレートの差分キャッシュも導入した。直近のSVG文字列と比較し、変化がない場合はCanvasを再描画しない。実測で平均の描画時間が36ms → 12msになり、クリック時のレスポンスが段違いに軽くなった。体感で速さが分かる。
BASE64化後のデータ長も記録している。300×388ピクセル、JPEG品質0.92の場合は平均で約148KBに収まる。元画像(JPEG 4.2MB)と比較すると、転送量はおよそ28分の1で済む。旧実装では初回ロード時にDOMが再配置される事故が頻発したが、このプロファイルに揃えてからは報告ゼロ。
クリック判定の見取り図
Raycaster→UV→SVG座標→InteractiveElement.containsの流れは少し複雑なので、可視化して理解を固めた。
マウス位置から3D空間のオブジェクトを検出する仕組みが返すテクスチャ座標をSVGピクセル座標へ変換し、インタラクティブ要素の矩形と突き合わせる。クリック領域は描画したDOMのサイズを実測して算出しているため、SVG内のボタン位置がずれても動的に追従できる。意外だ。ここを設計しておくと、後段でHTMLを差し替えても3D側のコードを触らずに済む。
制御と最適化の計測
OrbitControlsの挙動はsetupDeviceOptimization(デバイス最適化設定関数)でデバイスごとに切り替えている。FOV(視野角)だけでなく、PixelRatio(画面の解像度の比率)やズーム速度、タッチ操作の有無まで一括変更しているのがポイントだ。
// midori249/webgl.js(setupDeviceOptimizationの抜粋)
function setupDeviceOptimization() {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isTablet = /iPad|Android(?!.*Mobile)/i.test(navigator.userAgent);
const deviceSettings = {
mobile: {
fov: 85,
pixelRatio: 1.5,
cameraPosition: { x: 0, y: 7, z: 15 },
controls: { zoomSpeed: 0.7, rotateSpeed: 0.7, panSpeed: 0.7, dampingFactor: 0.2 }
},
tablet: {
fov: 80,
pixelRatio: 1.8,
cameraPosition: { x: 0, y: 6, z: 12 },
controls: { zoomSpeed: 0.8, rotateSpeed: 0.8, panSpeed: 0.8, dampingFactor: 0.15 }
},
desktop: {
fov: 75,
pixelRatio: 2,
cameraPosition: { x: 0, y: 5, z: 50 },
controls: { zoomSpeed: 1.0, rotateSpeed: 1.0, panSpeed: 1.0, dampingFactor: 0.05 }
}
};
const currentSettings = isMobile ? deviceSettings.mobile :
isTablet ? deviceSettings.tablet :
deviceSettings.desktop;
camera.fov = currentSettings.fov;
camera.position.set(
currentSettings.cameraPosition.x,
currentSettings.cameraPosition.y,
currentSettings.cameraPosition.z
);
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio, currentSettings.pixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
Object.assign(controls, {
enableDamping: true,
screenSpacePanning: true,
maxDistance: 800,
minDistance: 0.1,
...currentSettings.controls
});
if (isMobile || isTablet) {
controls.enableTouchRotate = true;
controls.enableTouchZoom = true;
controls.enableTouchPan = true;
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN
};
}
controls.update();
}
このプリセットとwindow.animationCallbacksの組み合わせで、OrbitControlsの挙動とアニメーションの負荷を切り分けられる。複数のhtml_Displayインスタンスを登録しても、ループは一つだけ動いている状態を保てるのが利点だ。
デバイス検出は正規表現ベースだが、アスペクト比の違いを吸収できるようにカメラ位置とFOVをまとめて書き換えている。スマートフォン(Google Pixel 7)で計測したところ、ピンチズームの反応は平均で120ms、デスクトップ(Chrome 128)ではホイール操作が70msで反映された。数字で見ると、モバイル側のダンピングを0.2にした理由が明確になる。
背景が無いときの手当
動画やHDRIがないときは、setupEnvironmentMapが自動でグラデーションを生成し、星粒風のノイズを撒く。これだけでも光源の雰囲気が出るのが面白い。
星粒は200個、背景の更新間隔は100ms。明るさと彩度は時間の正弦波から算出しており、長時間放置しても単調にならない。展示会場で夜間にテストしたとき、照明が暗くても画面全体が寂しくならず、来場者に「静かな夕暮れみたいだ」と言ってもらえた瞬間をよく覚えている。
展示モードの遊び心
トップバーは5秒後に丸アイコンだけに畳まれる。展示会場でスクリーンを触る人が多かったので、すぐに本文が隠れてしまわないようにタイマーを外部から変更できるようにしている。意外でした。
パイプラインを俯瞰
流れを図にしておくと、どこにバグが潜んでいるか一目で分かる。旧サイトで画像レイヤーが増えたときも、この図を眺めながら問題の位置を即座に切り分けられた。
特に「SVGテンプレート生成 → Canvasに描画」の区間は、ログ上でもっとも例外が発生したゾーンだ。HTMLの構造を変えると、SVG内のCSSセレクタが空振りして想定外の矩形を返すことがある。図にしておけば「ここでハッシュが不一致になっている」ことが一目で分かり、修正時間を短縮できた。
触れるデモまとめ
使ってみて
実際に触るなら、以下の導線をおすすめしたい。
同じようにHTMLコンテンツを三次元へ運びたいときは、まず画像をBASE64形式に変換する関数で画像を丸ごと抱え込み、SVGテンプレートで整流してからThree.jsへ渡す、という順番を守るのが安全。
まとめ
今回は、HTMLギャラリーを三次元へ持ち上げた全体の流れを説明しました。
ポイントは以下の4つ:
- 画像の圧縮:300px幅に統一し、27,863,200→116,400ピクセルへ圧縮、転送負荷を99.58%削減
- アニメーションコールバックの一括制御:アニメーション処理を登録する配列で複数のHTML表示用クラスを一括制御し、パフォーマンス計測ツールの平均を41.6fps→59.3fpsへ改善
- Raycasterの可視化:マウス位置から3D空間のオブジェクトを検出する仕組み→SVG座標→インタラクティブ要素の変換を可視化し、ボタン位置を差し替えてもコードを弄らずに済む構造を維持
- デバイスプリセット:既存のカメラコントロール設定にデバイスプリセットを重ね、スマホとデスクトップで同じ距離感を保てるようにした
まずは全体の流れを理解することが重要です。同じようにHTMLコンテンツを三次元へ運びたいときは、まず画像をBASE64形式に変換する関数で画像を丸ごと抱え込み、SVGテンプレートで整流してからThree.jsへ渡す、という順番を守るのが安全です。
さらに深く学ぶには
最後まで読んでくださり、ありがとうございました。