「山手線 3Dジオラマ」を公開した翌日の朝、私はSNSのリプ欄を見て凍りつきました。
「iPhoneが手持ちで持てないくらい熱い」「5分で電池が10%減った」「ファンが回るような音がする(※iPadの話)」——こうした報告が立て続けに寄せられていたのです。
自分のPCのChromeでは快適に動いていたため、モバイル端末でここまで負荷が高いとは思っていませんでした。今回は、そこからのパフォーマンスチューニングと、実機で測定することの大切さを記します。

原因の特定

最初に疑ったのは、SafariのRendering Engineが何らかの問題を抱えているのでは、という可能性です。しかし冷静にプロファイリングしてみると、原因はもっと単純でした。

iPhone実機をMacに接続し、Safariのリモートデバッグで開発者ツールを開いてTimelineを記録したところ、毎フレーム60回、重いシェーダー計算が走っていたのが見えました。特にPLATEAUの建物ジオメトリが全建物分アップロードされており、ドローコールが1000を超えている状態でした。

自分のMacBook Pro(M2 Pro)では当然のように60fpsが出ていたので、「快適」と思い込んでいたのです。しかし、A13やA15といったモバイルSoCのGPUには桁違いに厳しい負荷だったと、計測して初めて理解しました。開発機だけを基準にしたパフォーマンス判定は、モバイル対応では意味がないという当たり前の事実を、公開後にようやく体感した瞬間でした。

対策1: フレームレートを30fpsに抑える

ゲームであれば60fpsを維持したいところですが、3Dジオラマのような「電車を眺める」系コンテンツは、30fpsでも体験が成立します。むしろ安定した30fpsのほうが、不安定な50fpsより気持ち良いくらいです。そこで、モバイル判定時はフレームレート上限を30fpsに制限しました。

const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const targetFPS = isMobile ? 30 : 60;
const frameInterval = 1000 / targetFPS;
let lastFrame = 0;

function loop(now) {
  requestAnimationFrame(loop);
  const delta = now - lastFrame;
  if (delta < frameInterval) return;
  lastFrame = now - (delta % frameInterval);
  render();
}
requestAnimationFrame(loop);

ポイントは、requestAnimationFrame を止めるのではなく、コールバック内で「まだ描画しない」判定を入れている点です。setTimeout で間引くやり方だと、画面リフレッシュとの同期がずれてジャダー(カクつき)が発生します。

これだけでiPhoneの表面温度が体感で明らかに下がり、電池消費も半減しました。最初は「30fpsだとカクついて見えるのでは」と心配しましたが、実際にユーザーから「スムーズさが下がった」という指摘はゼロでした。むしろ「熱くならなくなって遊びやすい」と好意的な反応ばかりで、「最高品質」を追い求めることが正解ではないと学びました。

ちなみにこの30fps制御を実装した時、一度大きなバグを埋め込みました。lastFrame = now - (delta % frameInterval) の計算を省いて単純に lastFrame = now にしてしまったところ、フレーム間隔が徐々にズレていく「ドリフト」が発生し、1分後には15fpsまで落ちていたのです。剰余を取ることで画面リフレッシュ周期の位相に乗せ続けるのが肝心で、この1行を復活させるのに夜中の3時まで悩みました。

対策2: バックグラウンド時は描画停止

もう一つやりがちなミスが、タブを裏に回しても描画ループを回し続けてしまう問題です。requestAnimationFrame は多くのブラウザで非アクティブタブ時には自動的に遅くなりますが、iOS Safariではタブ切替直後に一瞬だけ走り続けることがあります。また、ユーザーがホーム画面に戻った後に再びアプリを開いた時の「巻き戻し感」も気になっていました。

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    pauseSimulation();
    audioCtx.suspend();
  } else {
    resumeSimulation();
    audioCtx.resume();
  }
});

document.visibilityState を監視し、hiddenになったタイミングでシミュレーション自体を停止、audioContextも suspend() します。これをやる前は、Twitterを見てから戻ったら電車が走りすぎて別の駅にいた、という事故がよく起きていました。
また、バックグラウンド中に描画やタイマーが動き続けると、iOS側が「このサイトは重い」と判定して、次回読み込み時にTabを強制終了する挙動に繋がります。実際、私もこれで再訪問時にタブがリロードされる不具合に悩まされました。

ここでも一つハマったのが、audioCtx.resume() がiOSでは「ユーザー操作起点でしか成功しない」仕様です。画面復帰時に自動で呼んでも状態が suspended のままで、音が鳴らなくなる不具合を生みました。結局、復帰時は「再生ボタンを押してください」というオーバーレイを挟む形に変更しました。Web Audioのこうした制約は地味に辛いところです。

対策3: PLATEAU建物の描画距離制限

PLATEAU(国土交通省の3D都市モデル)は非常に精細ですが、全部を描画すると地獄を見ます。公開初期は「せっかくPLATEAUを使っているから全部見せたい」と欲張って、半径5km圏内のLOD2建物をすべて描画していました。これが発熱の最大の原因でした。

対策として、カメラから半径300m以内の建物のみ描画するように変更しました。

// カメラからの距離でビル描画をフィルタ
const MAX_BUILDING_DIST = 300; // meters
buildings.forEach(b => {
  const d = cameraPos.distanceTo(b.center);
  b.visible = d < MAX_BUILDING_DIST;
});

さらに、300m〜500mの建物はLOD1(立方体近似)で表示し、500m以遠は非表示にしました。こうすることで、視覚的な「街の連続感」を残しつつドローコールを1/10以下に削減できました。
最初は「近い建物しか表示されないと世界が狭く感じるのでは」と悩みましたが、MapLibreが提供するラスター地図(航空写真)が遠景をカバーしてくれるため、むしろ「近景は3D、遠景は写真」という二層構造が見栄え良く仕上がりました。

この距離フィルタ処理も毎フレーム全建物に対して distanceTo を呼ぶと、それ自体がボトルネックになりました。解決策として、建物を100m四方のグリッドでバケット化し、カメラが属するグリッド±3マスのみを走査するように変更。1フレームあたりの計算回数が数千から数十に減り、CPU使用率がさらに10%ほど下がりました。空間インデックスの基本ですが、Webフロントエンドだと意外と軽視されがちなテクニックだと感じます。

対策4: devicePixelRatio の頭打ち

iPhoneのDPRは多くの機種で3.0、iPad Proでは2.0ですが、「描画ピクセル数」は指数的に効いてきます。縦長で持った画面(約400×900pt)でDPR 3.0だと、1200×2700ピクセル=324万ピクセル。これを60fpsで描画すると、GPUには相当な負荷です。

そこで、モバイル時は devicePixelRatio を最大1.5に制限しました。

const dpr = Math.min(window.devicePixelRatio || 1, isMobile ? 1.5 : 2);
renderer.setPixelRatio(dpr);

正直、最初は画質が落ちることを恐れていました。しかし実機で比較しても、動いている電車を見ている限り1.5と3.0の差はほとんど分かりません。GPU負荷は半分以下になり、発熱も大幅に改善。静止画で見ると確かに文字がやや滲みますが、「60秒電車を見る」体験としては全く問題ないレベルでした。

ちなみにDPRを変更した後、既にレンダラを生成済みのケースで「画面が半分にズレる」というバグを出しました。renderer.setPixelRatio() を呼んだ後に renderer.setSize() を呼び忘れていたのが原因です。Three.jsの内部バッファはピクセル比とサイズの両方に依存するため、片方だけ変えると壊れるという仕様を、身をもって学びました。

対策5: 省電力モードの検出

iPhoneの「低電力モード」をオンにすると、requestAnimationFrame は自動的に30fps相当にスロットリングされます。これを逆手に取って、低電力モード時はさらに軽量な描画設定に切り替えるようにしました。

// battery APIで低電力モードを検出(Safari未対応だが念のため)
async function detectLowPower() {
  if (!navigator.getBattery) return false;
  const battery = await navigator.getBattery();
  return battery.level < 0.2 || battery.charging === false;
}
// フレーム間隔から推定する方式
let slowFrameCount = 0;
function detectLowPowerByFrame(delta) {
  if (delta > 25) slowFrameCount++;
  if (slowFrameCount > 30) enableLowPowerMode();
}

Safari(iOS)では navigator.getBattery() がサポートされていないため、実運用では「最近30フレームの平均描画時間が25msを超えたら低電力モード判定」という経験則ベースの検出に切り替えました。これによって、端末が熱暴走する前に自動的に軽量モードへ落ちる仕組みができました。

この判定はあえて片道通行にしてあり、一度軽量モードに落ちたら再ロードするまで元に戻らない仕様です。フレーム時間が波打つ端末では、軽量モードと通常モードを行ったり来たりすると画質が瞬間的に変わって気持ち悪いため、「下がるだけ」のヒステリシスを入れたほうが体験として自然でした。計測値に引っ張られて挙動を頻繁に切り替えないことも、モバイル向けUIの大切なポイントだと感じています。

測定は実機、シミュレータは参考程度

今回の一連の対策で痛感したのは、Xcode Simulatorは全く当てにならないということです。Simulatorは「見た目」を再現するだけで、GPU負荷や発熱はMac本体のスペックに依存します。Simulator上では60fpsサクサクでも、実機のiPhone SEでは10fpsになる、ということが平然と起こります。

私が愛用しているのは、iPhone実機をMacにケーブル接続し、Safariの「開発メニュー」から「Web Inspector」を開く方法です。これならCPU/GPUプロファイル、ネットワーク、コンソールログまで、実機の挙動をそのまま観察できます。
また、Androidの場合はChromeの chrome://inspect から同様に実機デバッグができるので、両方のOSで定期的にチェックする習慣を付けました。

発熱そのものを数値で確認したい時は、iPhoneの「設定 > プライバシーとセキュリティ > 解析とデータ」から、対象サイト閲覧時の thermalstate ログを眺めています。nominal から fairseriouscritical へ遷移する様子が見えるので、チューニング前後で明らかに改善していく手応えを得られるのが嬉しかったです。数値で進捗が見えるのは開発者にとって大きなモチベーションになります。

失敗談:Appleシリコンだけで確認していた油断

一番恥ずかしい話をすると、私は対策前に「M2 ProのSafariでテストしたから大丈夫」と自信満々だったのです。MacのSafariと、iPhoneのSafariは見た目が似ているため、同じエンジンと誤解していました。
実際はGPUも熱設計も全然違うのに、それに気づけなかったことが、公開直後の炎上(というほどでもないですが冷や汗レベル)の一番の原因でした。以降、「モバイル動作確認は実機でしか判定しない」を鉄則にしています。

まとめ

3DコンテンツをWebで公開する時、デスクトップのChromeだけでは絶対に気づけない問題が、モバイル実機では当たり前のように発生します。
今回紹介した5つの対策——フレームレート制限、バックグラウンド停止、建物描画距離、DPR頭打ち、省電力モード検出——は、どれか1つでも欠けていたら未だに「熱い」と言われ続けていたと思います。
「3Dは重くて当たり前」と諦めるのではなく、一つずつ丁寧にチューニングしていくことで、モバイル端末でも快適に遊べる表現が可能だと、今は信じています。