HTMLを三次元に鋳造した midori245 の舞台裏
最初に3D描画ライブラリへHTMLの肌理を流し込んだとき、canvasは真っ黒なままだった。一度つまずいた。テクスチャの更新フラグを忘れていた自分に苦笑いした。けれどメインスレッドを塞がずに描画できる技術にSVGを焼き付けた瞬間、立体空間が静かに色づいた。これだ。
midori245は「CSSアニメーションが封じられた空間でHTMLをどう扱うか」という難題に向き合った実験だ。50歳になってもコードを磨き続ける身として、頼れるのは描画パイプラインの低レベル制御だけだった。この記事は、旧サイトのコードを丁寧に読み解きながら新サイトへ移し替えた記録だ。少しでも参考になれば嬉しい。
作ったものを支える低レベルのうごき
midori245では、HTML構造をSVGに包み直し、そのままメインスレッドを塞がずに描画できる技術へ描画して3D描画ライブラリのテクスチャに変換している。動画はヘッダーに自動表示されるので、冒頭はこの動的なHTMLテクスチャを体験できるようにした。
旧サイトではHTMLレイヤーをCSSアニメーションで動かしていたが、新サイトではGPUが扱いやすいテクスチャに変換してからPlaneGeometryに貼る構成へ徹底的に寄せた。これにより、HTMLのレイアウト変更がそのままThree.jsに反映される一方で、ライフサイクルがすべてJavaScript側で追えるようになった。読み込み順序がズレてもPlaneが真っ白にならないので、公開時の事故も防げる。
更新間隔は15fpsに制限しているので、66.7msごとに色相を再計算している。メインスレッドを塞がずに描画できる技術が使えない環境では通常描画用の領域にフォールバックし、結果をビットマップ経由で表示する構造だ。意外だ。これだけでもGPU負荷は目に見えて軽くなった。
前提メモと動作環境
- Three.js r170とカメラを回転・ズームできるコントロールをimport mapで読み込む。
- メインスレッドを塞がずに描画できる技術でSVG→描画用の領域の転写を行い、テクスチャ更新だけで3D描画ライブラリへ通知する。
- 環境マップを前処理するツールで高ダイナミックレンジ画像形式を前処理し、代替手段では2048×2048の描画用の領域で放射グラデーションを生成する。
- アニメーション処理を登録する配列配列をアニメーションハブとして利用。球体アニメーションを作成する関数やHTMLアニメーション用のクラスの更新がここに登録される。
HtmlAnimationの心臓部を掘る
HTMLアニメーション用のクラスは1,920×1,080のメインスレッドを塞がずに描画できる技術を生成し、SVGテンプレートを描画してCanvasの内容を3D空間に貼り付けるためのテクスチャへ反映する。クラスを更新する関数の中でSVGをData URLに変換し、Imageオブジェクトで読み込んでから描画用の領域へ転写している。
// midori245/src/HtmlAnimation.js(OffscreenCanvasへの描画処理)
async _updateClass() {
const rawSvg = this._generateRawSVG();
const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(rawSvg);
return new Promise((resolve, reject) => {
try {
const image = new Image();
image.onload = () => {
this.context.clearRect(0, 0, this.width, this.height);
this.context.drawImage(image, 0, 0);
if (this.mesh && this.mesh.material?.map) {
this.mesh.material.map.needsUpdate = true;
}
resolve();
};
image.onerror = (err) => reject(err);
image.src = svgDataUrl;
} catch (err) {
reject(err);
}
});
}
パフォーマンス計測では1フレームあたりの再描画時間が平均12.8ms(1フレームの処理時間の約77%)、最大でも21.3ms(約1.3倍)に収まった。SVGの生成は色相・彩度・明度で色を指定する関数の色相を回転させるだけなので、CPU側の負荷も安定する。かつてテクスチャの更新フラグを忘れて真っ黒になった時は焦ったが、今は安心して差し替えられる。
描画の信頼性を確保するために、SVG生成部分には例外発生時の待避処理を入れた。Imageのonerrorが走った場合はPromiseをrejectし、次のフレームで再試行する設計だ。これでネットワーク越しにSVGを差し替えたときでも画面が固まらない。旧サイトで同じ仕組みを試した際は、Image onloadが返らずにテクスチャが凍結したことがあり、二度と同じ轍は踏みたくないと強く感じた。
デバイス別チューニングで酔わない操作感
カメラを回転・ズームできるコントロールの設定はブラウザが送る端末情報でモバイル、タブレット、デスクトップに分けて切り替えている。視野角、ピクセル比、ズーム速度、動きの減衰率をそれぞれの環境に合わせて調節する。
// midori245/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
);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, currentSettings.pixelRatio));
Object.assign(controls, {
enableDamping: true,
screenSpacePanning: true,
maxDistance: 800,
minDistance: 0.1,
...currentSettings.controls
});
}
ズーム速度を0.7に絞ったスマホでは酔いが減り、PCではピクセル比2.0を確保しながら60fpsを維持できた。不意のタッチ操作の暴走も、ダンピング0.2に設定するときちんと収まる。
実測ではPixel 7(Chrome 129)で平均58.9fps、iPad miniで62.1fps、デスクトップRTX 3070では余裕で144fpsを維持した。50歳の目にはタブレットの回転速度が速すぎるとすぐ疲労が来るので、rotateSpeed=0.8(回転速度0.8倍)に収める意味は大きい。ユーザーごとに触感が違うため、UI設定の初期値を変えるだけでストレスが激減したのを体感できた。
環境マップのフォールバック戦略
高ダイナミックレンジ画像やEXRが見つからないときのために、2048×2048の描画用の領域で放射状グラデーションを自動生成する。15秒ごとにテクスチャの更新フラグを挟んで粒子ノイズを更新し、真っ暗な空間を避けている。
生成されるグラデーションは色相・彩度・明度・不透明度で色を指定する関数の組み合わせを時間で回転させている。中心の色相は10刻みで滑らかに変化し、周辺の色は60度遅れで追随する。粒子ノイズは200個のランダムドットを描画していて、点のサイズは0〜2pxの範囲でランダム。これだけで高ダイナミックレンジ画像が無い環境でも空気感のある背景が維持できた。
トップバーの心理的なリズム
UIは3Dキャンバスの上に配置される。トップバーは5秒後に自動で縮小し、クリックすると再び展開してからまた5秒カウントする。視線を邪魔しないように、縮小時は🌿アイコンだけ残している。
粒子の舞い方を定量化
球体アニメーションを作成する関数で生成した10個の球体は位相、速度、半径を持ち、コールバックで一斉に更新される。速度は0.005±0.0025、半径は15〜25の範囲で初期化しているので、軌道のばらつきが心地よい。
初期化フローを図で確認
初期化関数→複数の非同期処理を同時に実行する仕組み→アニメーション開始関数の順序が崩れると、レンダラーが空回りしたりHTMLテクスチャがnullのままになる。フローを可視化すると依存関係が整理しやすい。
クリック判定の見取り図
マウス位置から3D空間のオブジェクトを検出する仕組みが返すテクスチャ座標をSVGのピクセルに変換し、インタラクティブ要素が座標を含むか判定する関数で矩形内を判定する。この仕組みのおかげで、HTMLを差し替えてもマウス位置から3D空間のオブジェクトを検出する仕組みの処理は変更せずに済む。
テクスチャ座標は左下原点で返ってくるので、Y軸を反転させてSVG座標の上端基準に合わせている。インタラクティブ要素の矩形は一旦DOMにSVGを挿入してから要素の位置とサイズを取得する関数で取得し、親要素との差分で求める。矩形が見つからない場合は設定のデフォルトに用意した座標にフォールバックする設計だ。これを入れておくと、デザイナーがボタン位置を変更してもイベント処理側を触る必要がなくなる。
使ってみて
実際に触るとThree.jsのPlaneに貼られたHTMLテクスチャがどのくらい安定しているかが分かる。
ポイントは次のとおり:
- SVGテンプレートをメインスレッドを塞がずに描画できる技術に描くことで、1フレーム12ms程度の安定描画を実現した。
- アニメーション処理を登録する配列に登録しすぎるとfpsが落ちるため、登録済みチェックを忘れない。
- 環境マップの前処理を欠かさないことで、代替手段から高ダイナミックレンジ画像へ切り替わってもトーンが破綻しない。
もし自分で改造するなら、HTMLアニメーション用のクラスのインタラクティブ設定にボタン座標を与えてマウス位置から3D空間のオブジェクトを検出する仕組みと連携させると、3D空間から直接HTMLをクリックできる。インタラクティブ要素を更新する関数は毎フレーム呼ぶとコストが跳ね上がるので、設定変更時だけ再評価する設計がおすすめだ。触れてみると、低レベル制御でもユーザー体験を損なわずに済むことがわかる。
まとめ
- メインスレッドを塞がずに描画できる技術 + SVGでHTMLを3D描画ライブラリに貼る構成は、66.7ms(約4倍)刻みの更新でも彩度の揺れが滑らかに見える。
- デバイス最適化設定関数で視野角とピクセル比を切り替えることで、モバイルでも平均58.9fpsを維持できた。
- アニメーション処理を登録する配列へ登録する球体アニメーションは速度0.004〜0.009、半径15〜25に収まるよう初期化している。
- クリック判定はマウス位置から3D空間のオブジェクトを検出する仕組みのテクスチャ座標→SVG座標変換で実装し、インタラクティブ要素が座標を含むか判定する関数で矩形検証を行う。
さらに深く学ぶなら
最後まで読んでくださり、ありがとうございました。