「山手線 3Dジオラマ」を公開してから、想像以上に多くのバグ報告が届きました。自分のPCとiPhoneでは完璧に動いていたつもりでも、世界中の様々な端末・OS・ブラウザで動かせば、想定外のことが次から次へと起こります。
今回は、ユーザーからのフィードバックで発覚した特に印象深いバグを5つ選び、原因と修正、そしてそこから得た学びをまとめてみました。

1. iPhone SE で「画面が真っ黒」になる

公開2日目、最もショックだった報告が「iPhone SE(第2世代)で開くと真っ黒で何も表示されない」というものでした。私の手元にはiPhone 13しかなく、当然再現できません。ユーザーに about:blank から開発者ツールでコンソールログを取ってもらい、ようやく RangeError: Invalid typed array length が出ていることが判明しました。

原因は、描画バッファのサイズ計算で devicePixelRatio を整数として扱っていたことでした。iPhone SEのDPRは2.0ですが、iOSのSafariでは画面回転時に一瞬だけDPRが小数値(2.0625など)を返す瞬間があり、それをそのまま Math.floor せずに new Float32Array() に渡していたのです。

// BEFORE(バグ)
const w = canvas.clientWidth * window.devicePixelRatio;
const buffer = new Float32Array(w * h * 4);

// AFTER(修正)
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.floor(canvas.clientWidth * dpr);
const buffer = new Float32Array(w * h * 4);

さらに dpr を2で頭打ちにしたことで、iPhone 15 Pro Maxなどの超高解像度端末でも発熱とメモリ使用量が大幅に改善されました。結果的に、1件の不具合報告が全端末のパフォーマンス向上につながった好例です。半日ほど端末の違いを疑っていた自分が恥ずかしいくらい、単純なミスでした。

この件で怖かったのは、私のiPhone 13では全く再現しなかったことです。iPhone SE(第2世代)はA13 Bionicチップを搭載していますが、GPUメモリのアライメントがより厳しく、Float32Arrayのサイズが端数を含むと即座に例外を投げます。手元の環境では「たまたま」割り切れる値だったため、10日以上バグを放置していたと考えると背筋が寒くなります。「うちの環境では動くから大丈夫」は何の保証にもならないと心底実感しました。

2. 恵比寿駅で「停まれない」

「恵比寿駅で減速しないで通過してしまう」という報告をTwitterのDMで受け取りました。私の環境では普通に停車するため、最初は「ユーザーのマスコン操作が速すぎるのでは?」と疑ってしまいました。しかし、動画を送ってもらったところ、確かにブレーキを早めに入れても行き過ぎています。

調査の結果、駅の停車位置データ(lat/lng)が実際の線形より2メートルほどズレていたことが判明。走行距離をLUTから計算する際、このズレが累積して「停止すべき位置」の判定が早すぎるタイミングでOFFになっていました。

// 駅スナップ処理(修正版)
const snap = snapStationToCurve(stationLngLat, curveLUT);
if (Math.abs(snap.dist - expected) > 3.0) {
  console.warn('Station snap drift:', snap, 'expected:', expected);
}
stations[name].dist = snap.dist;

駅座標は国土地理院のデータを使っていましたが、線形(レール)のデータはOpenStreetMap由来で、両者の測量精度に微妙な差がありました。修正としては、全駅について「最寄りのカーブ点へスナップする」後処理を追加し、スナップ誤差が3m以上の駅は警告ログを出すようにしました。データソースが複数ある場合、必ず整合性チェックを入れるという教訓を得ました。

ちなみにこのバグ、当初は「恵比寿でだけ発生する」と思っていましたが、ログを取り直したところ高輪ゲートウェイと新大久保でも数十cm〜1m程度のズレが起きていました。たまたま恵比寿が一番大きかっただけで、他駅も「ギリギリ止まれていた」という綱渡り状態だったと判明し、全駅に対して同じスナップ処理を入れる大規模改修になりました。

3. 長時間プレイで「カクつく」

「30分くらい遊んでいると、だんだん動きが重くなる」という報告を複数受け取りました。一度リロードすると直る、というヒントから、私はすぐに メモリリーク を疑いました。

Chrome DevToolsのPerformanceタブで30分間のヒープを記録したところ、綺麗な右肩上がりのグラフが出現。Memoryタブのスナップショットを比較すると、BufferGeometryTexture のインスタンスが時間経過で増え続けていました。

原因は、駅を通過するたびに動的に作っていた「駅名ラベル」のスプライトを、scene.remove() しか呼んでおらず、geometry.dispose()texture.dispose() を忘れていたことでした。Three.jsのオブジェクトはGPUリソースを抱えているため、JSのGCだけでは解放されません。

// ラベル削除時の正しい後片付け
function disposeLabel(sprite) {
  scene.remove(sprite);
  if (sprite.material.map) sprite.material.map.dispose();
  sprite.material.dispose();
  sprite.geometry?.dispose();
}

修正後、30分プレイ後のヒープは横ばいに収まり、報告も途絶えました。Three.jsではdispose系メソッドを癖のように書くという原則を、この時身体で覚えました。

メモリリークのやっかいなところは、短時間のテストでは気づかないことです。私は毎回のQAで「山手線を半周(5分程度)」走らせて確認していましたが、この時間ではヒープの増加は数MBに留まり、FPSも維持できてしまっていました。ユーザーは30分以上プレイすることもあるのだと気づかされ、以降は「長時間プレイ」も動作確認項目に入れるようにしています。具体的には、無人運転モードで1時間走らせ続ける簡易テストを、リリース前に必ず実施するようになりました。

4. 音が「二重に鳴る」

「駅に停まるたびに『プシュー』という空気音が2回鳴る」という報告。最初はユーザーのスピーカー設定を疑いましたが、複数人から同様の声が上がったため調査に着手しました。

Web Audio APIで効果音を鳴らす仕組みを、私はこう書いていました。

// BEFORE(バグ)
function playAirBrake() {
  const src = audioCtx.createBufferSource();
  src.buffer = airBrakeBuffer;
  src.connect(gainNode);
  src.start();
  // src.stop() や disconnect() を呼び忘れ
}

駅到着イベントが2回発火するケース(速度がゼロを跨いで微小に揺れる場合)があり、そのたびに新しい BufferSource が生成され、前のノードが接続されたまま残っていたのです。古いノードは再生終了後もガベージコレクトされず、次の再生と被って聞こえていました。

修正では、停車イベントを「0km/hになった瞬間の立ち上がりエッジのみ」に絞り、さらに BufferSourceonended ハンドラを付けて自動的に disconnect() するようにしました。Web Audioのノードは、明示的に切断しないと残り続けるという仕様を、このバグで初めて実感しました。

この不具合を調べる過程で、Web Audio APIのデバッガーがChrome DevToolsに存在しないことにも苦しみました。結局、全ての createBufferSource 呼び出しにラッパー関数を通してカウントするしかなく、window.__activeAudioNodes という簡易カウンタを仕込んで目視確認しました。リッチなプロファイラを当たり前と思っていた自分には、Web Audioの世界はまだまだ原始的に感じます。

5. iOS で「マスコンが反応しない」

これは一番時間を溶かしたバグです。「iPhoneでマスコンを動かしても反応しない。ボタンは反応する」という報告があり、私のiPhoneでは再現せず、3日ほど保留していました。

ある時、友人のiPhone 12を借りて試したら、確かに全くドラッグが効かない。iPhone 13との違いを調べるうちに、「iOS 16.4以降で変わった動作」であることが分かりました。具体的には、touchmoveaddEventListener で追加した際、iOS Safariは passive をデフォルトで true にするため、preventDefault() が効かないのです。そのため、ページがスクロールしてしまいドラッグが乗っ取られていました。

// BEFORE
element.addEventListener('touchmove', handler);

// AFTER
element.addEventListener('touchmove', handler, { passive: false });

修正は1行の追加でしたが、そこに辿り着くまでに pointer-eventstouch-action の設定を何度も試し、無駄な時間を使いました。「iOSはpassive: falseを明示する」という一点を、今ではマスコン系UIのテンプレートに書き込んでいます。

面白いのは、iPhoneのiOSバージョンによって挙動が違う点です。iOS 15.7の端末では私のコードでも動いていたため、最初「iOSの問題ではない」と誤認してしまいました。後になってApple公式のリリースノートを掘り返し、passiveのデフォルト変更がiOS 16.4で導入されたと知った時は、リリースノートを読み込む習慣の大切さも痛感しました。

ユーザーフィードバックへの向き合い方

これら5件のバグに共通するのは、いずれも私の開発環境では一度も再現しなかったことです。もしユーザーからの報告がなければ、未来永劫気づけなかった不具合ばかりでした。報告してくれた方々には本当に感謝しています。

再現環境の聞き方

運営初期に一番困ったのは、「動きません」だけの短い報告です。これに「詳しく教えてください」と返すと、多くの場合返信が来ません。そこで私は、お問い合わせフォームに以下のテンプレを用意しました。

  • 端末(例: iPhone 13 mini)
  • OSバージョン(例: iOS 17.3)
  • ブラウザ(例: Safari / Chrome)
  • いつ起きたか(例: 電車がカーブに差し掛かった瞬間)
  • 可能であればスクリーンショット・動画

このテンプレを置いただけで、報告のクオリティが段違いに上がりました。特に「動画」を任意でお願いしているのは、文章では伝わらない現象が多いためで、発覚したバグの7割以上は動画が決め手になっています。

テンプレに加えて、ユーザーが簡単にUser-Agent情報を送れるように、お問い合わせフォーム内に「デバッグ情報をコピーする」ボタンを用意しました。クリックすると、UA・ウィンドウサイズ・DPR・WebGLバージョン・GPU名( WEBGL_debug_renderer_info 経由)・再生中のBGMなどを一括取得して貼り付けられるようにしています。プライバシーに配慮し、取得前に「この情報を送信しますか?」という確認ダイアログを挟むことも忘れないようにしました。この改善で、不明な端末での不具合原因切り分けが劇的に早くなりました。

返信の温度感

もう一つ大事にしているのは、返信のトーンです。バグ報告してくれるユーザーは、わざわざ時間を割いて連絡してくれている貴重な協力者。こちらが「それは仕様です」「再現しません」と冷たく返すと、次の報告は二度と来ません。
私は必ず「お知らせいただきありがとうございます」から始め、修正が終わったら結果を報告するようにしています。たった一行のお礼でも、その後も継続的に報告してくださるリピーターになることが多く、ユーザーフィードバックは宝だと痛感する日々です。

まとめ

バグは恥ずかしいものではなく、むしろ改善の最大のチャンスだと、運営を通じて強く感じるようになりました。自分の開発環境だけで「完璧だ」と思い込むのが一番危険で、多様な実環境からのフィードバックが品質を支えてくれています。
今後もユーザーさんの声を大切にしながら、一つずつ直していきたいと思っています。