HTML多層HUDを再演した midori247 の同期実験記
最初にコードを立ち上げた瞬間、HUDが爆散した。一度つまずいた。
四枚の HtmlAnimation(HTMLアニメーション用のクラス)を直列で呼んだせいで 640ms(約0.6秒)も待たされ、シーンが白く点滅した。これは課題。
Promise.all(複数の非同期処理を同時に実行する仕組み)で同時起動に振り替え、モード切替と環境マップの欠陥を洗い直した記録。
作ったもの
四枚の HtmlAnimation を 200 フレーム周期で normal / spiral / chaos に切り替え、Mesh の位置・回転・スケールを補間する多層 HUD。
setupInteractiveUI() の Promise.all で初期フレーム落ちを 190ms まで短縮し、window.animationCallbacks で 4 本の update を同時に回します。
これだ。
spiralRadius=800、verticalAmplitude=400、rotationSpeed=0.02〜0.04 の組合せで軌道を作り、modePhase % 200 === 0 でモードを再抽選する仕組みが見えてきます。
前提として押さえたこと
- Three.js 0.170.0: OrbitControls(カメラを回転・ズームできるコントロール) / DragControls / GLTFLoader(3Dモデルを読み込むツール) / PMREMGenerator(環境マップを前処理するツール)を Import map で読み込み。
- OffscreenCanvas: HtmlAnimation 全系で 1920×1080 の SVG を描画し CanvasTexture(Canvasの内容を3D空間に貼り付けるためのテクスチャ)を更新。
fps = 20(更新頻度20fps)で差分更新を抑制。
- Promise.all 初期化: シーン設定・環境マップ・UI 群を非同期タスクに分割し、失敗時は alert で再読込を促す設計。
- deviceSettings: UA(ブラウザが送る端末情報)正規表現で mobile / tablet / desktop を切り替え、
pixelRatio(画面の解像度の比率)上限と controls.zoomSpeed(ズーム速度)を最適化。
- animationCallbacks: window グローバル配列に update 関数を登録し、animate() が逐次呼ぶ構造。登録漏れがあると HUD が止まる。
4枚のHUDを同時起動に切り替えた理由
従来は HtmlAnimation を順番に await していたため、初期化だけで最大 640ms(約0.6秒)もブロックされた。Promise.all で 4 インスタンスを並列に起動すると、最初のフレームまで平均 190ms(約0.2秒)。体感で 450ms(約0.5秒)の差。短いようで長い。シーンの白飛びが気になった。
フレームごとの modePhase と globalPhase += 0.015 の関係を可視化すると、normal → spiral → chaos への切替が 200 フレーム周期で巡回するのが分かる。spiral は Math.cos(time*2) による渦、chaos は Math.sin(chaosTime*1.5) で縦揺れを起こす。数式の癖ははっきりしている。
背景フォールバックが静止画のまま
環境マップの読み込みに失敗したときは 2048px の Canvas を生成し、放射状グラデーションと星 200 個を撒く実装。でもテクスチャの更新フラグだけしか呼んでいない。星の座標は再描画していないので、背景は止まったまま。想定が甘かった。
更新フラグだけでは星の輝きが凍ったまま。再描画してから更新フラグを叩くと初めて動き出す。なるほど。ここで 100ms タイマーを利用したのが裏目に出た。
インタラクティブ領域が空振りした理由
HtmlAnimation は .clickable-area を探して Raycaster(マウス位置から3D空間のオブジェクトを検出する仕組み)のヒットボックスに変換します。ところがテンプレートにはクラスが存在しない。default: { x: 10, y: 10, width: 100, height: 50 } が延々と返り、クリックは全部座標 (10,10) に吸い込まれる。これは課題。
結果はゼロ。fallback のダミー座標だけ。Raycaster 側の計算は合っているのに当たる相手がいない。SVG テンプレートに .clickable-area を追加するか、config を差し替える必要がある。
OffscreenCanvas フォールバックが逆
HtmlAnimation4 だけ if (typeof OffscreenCanvas == 'undefined') { this.canvas = new OffscreenCanvas(...) } になっていて条件が逆。OffscreenCanvas 非対応環境では ReferenceError で即終了。焦った。でも対処できた。
Safari 16 系で確実に落ちる。typeof OffscreenCanvas !== 'undefined' へ修正し、フォールバックで HTMLCanvasElement を生成するのが正解。
デバイス最適化プリセットを洗い直す
UA 判定(ブラウザが送る端末情報で判定)で mobile / tablet / desktop を振り分け、fov(視野角)と pixelRatio(画面の解像度の比率)、controls.zoomSpeed(ズーム速度)を切り替える設計です。renderer.setPixelRatio(Math.min(window.devicePixelRatio, preset.pixelRatio))(ピクセル比の上限を設定する処理)の上限が効いているか確認しておきたかった。
Pixel 8 (devicePixelRatio=2.75) では mobile の上限 1.5 が効き、GPU 使用率を 18% 抑えられた。desktop プリセットは camera.position.z=50 で OrbitControls のバウンドが広い。調整の余地はある。
Zファイティングの実験と polygonOffset
template_ContentDisplay.js は polygonOffset(ポリゴンの深度を調整する設定)と depthWrite=false(深度書き込みを無効化)を有効にして、CanvasTexture を貼った複数平面のちらつきを抑えています。無効にすると同じ深度の平面が交互に反転して見える。
polygonOffset を切ると境界が瞬く。depthWrite を true に戻すと透過 HUD がすぐ濁る。やはり両方必要だと納得した。
animationCallbacks 配列の負荷
animate() は window.animationCallbacks.forEach(cb => cb()) で登録済みの update を全走査します。HtmlAnimation を増やしすぎると配列長とループ時間が線形に伸びる。数えておかないと注意が必要です。
20 個まで積むと 1 フレームで 0.4ms ほど飛ぶ。promise.all で一括登録した後はきちんと解除しないとジワジワ重くなる。
コード抜粋: setupInteractiveUI のモード制御
const meshes = [anihtml, anihtml2, anihtml3, anihtml4].map((instance, index) => ({
mesh: instance.getMesh(),
initialPos: initialPositions[index],
spiralRadius: 800,
verticalAmplitude: 400,
rotationSpeed: 0.02 + Math.random() * 0.02,
timeOffset: Math.random() * Math.PI * 2,
uniqueOffset: Math.random() * Math.PI * 2,
mode: 'normal'
}));
addAnimationCallback(() => {
globalPhase += 0.015;
modePhase++;
if (modePhase % 200 === 0) {
const modes = ['normal', 'spiral', 'chaos'];
meshes.forEach(({ mesh }) => {
mesh.userData.mode = modes[Math.floor(Math.random() * modes.length)];
});
}
meshes.forEach(({ mesh }) => { /* モード別補間処理 */ });
});
200 フレームごとにモードを再抽選し、normal では 50px 振幅のゆらぎ、spiral では半径 800 を使う渦、chaos では 0.5〜2.0 のスケール変動が入ります。数字がそのまま動きの癖になっている。
モード切替を全画面で追いかける
不安があった。
でも数式とモード時間を触っているうちに挙動の筋が見えた。ぜひ全画面でデモを走らせ、modePhaseの遷移を追いながら操作ログを残してみてほしい。それが今回の学びを体に刻む一番の近道。
使ってみて
実際の HUD は旧サイトで試せます。
旧サイトの midori247 を開く
.clickable-area をテンプレートに追加しない限り、Raycaster のヒットは全て (10,10) に吸い込まれます。
- mobile プリセットで
pixelRatio=1.5、zoomSpeed=0.7 に抑えると、Pixel 8 で 33fps に戻りました。
- OffscreenCanvas 非対応ブラウザでは HtmlAnimation4 だけ例外で落ちるので、条件分岐の修正は必須です。
modePhase を追跡しながら HUD の状態を可視化する
まとめ
今回は、HTML多層HUDを再演した同期実験の全体の流れを説明しました。
ポイントは以下の5つ:
- Promise.allによる同時起動:HtmlAnimationを同時起動し、初期フレーム落ちを640ms(約0.6秒)→190ms(約0.2秒)へ圧縮
- 環境マップのフォールバック:テクスチャの更新フラグだけでは動かない。星の再描画が不可欠
- インタラクティブ領域の修正:
.clickable-areaとOffscreenCanvas判定のバグでHUDが空振りしていた。テンプレートと条件分岐を修正する必要がある
- デバイス最適化:画面の解像度の比率上限が効くことで、mobileでGPU使用率を18%削減
- アニメーションコールバックの監視:不要なupdateを解除しないと1フレームのループ時間が伸びる
まずは全体の流れを理解することが重要です。モード切替の挙動を一緒に追ってくれる仲間がいるだけで救われます。
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。