MapLibre GL JS と Three.js を組み合わせた山手線3Dジオラマを、私はメインブラウザであるChromeで開発していました。ある程度形になってきたので「そろそろ公開しよう」と思って恐る恐るSafariで開いた瞬間、PLATEAUから流し込んだ新宿副都心のビル群が全部真っ黒に潰れていたのを目にした衝撃は今でも忘れられません。

その日から始まったブラウザ互換性の沼との戦いを、この記事ではできる限り赤裸々に残しておきたいと思います。

Chromeで完成しても、Safariで崩れる前提

最初に刷り込まれたのは、「Chromeで動いた=Webで動く」ではないという当たり前の事実でした。今はブラウザエンジンがChromium系に寄っていると言われますが、SafariはWebKit、FirefoxはGeckoで独立していますし、iOS上のブラウザは全部WebKitに強制的に統一される仕様(少なくとも当時までは)もあります。

山手線3Dジオラマは主にWebGL・Canvas・CSS合成機能を大量に使うので、ブラウザエンジンの微妙な違いがそのまま画面に出ます。Chromeで気持ちよく描けていた絵が、Safariではまるで別物になっていました。

事件1: PLATEAUの建物が真っ黒

SafariでPLATEAUの3D建物が黒く潰れていた原因は、フラグメントシェーダの精度指定にありました。Three.jsでカスタムマテリアルを書くとき、私は特に意識せずに

varying vec3 vNormal;
void main() {
    float brightness = dot(normalize(vNormal), vec3(0.3, 0.8, 0.5));
    gl_FragColor = vec4(vec3(brightness) * uColor, 1.0);
}

と書いていました。ChromeのWebGL実装は精度指定がなくても highp として解釈してくれるのですが、iOSのSafariは伝統的にモバイルGPU向けに mediump がデフォルトで、かつ未定義変数の扱いが厳しめでした。結果、vNormal の正規化結果が飽和して真っ暗になっていたわけです。

明示的に precision mediump float; を先頭に書き、かつ照明計算で使う法線は CPU側で事前にノーマライズした値を渡すように変えたところ、SafariでもChromeと見分けがつかない絵になりました。この修正に気づくまで2日間、「なぜSafariだけ?」と延々にらめっこをしていました。

精度指定の原則

以降、Three.jsのシェーダを自前で書くときは、以下のテンプレートから始めるようにしました。

#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif

これで「highpが使える端末では使い、使えない端末ではmediump」という互換コードになります。ちなみにiPhoneの最近の機種は全部highpを普通に使えますが、古いAndroid端末や一部タブレットでは今もmediumpしか無効、というケースがあるので、この書き方で損はしません。

事件2: iPhoneだけテクスチャが反転

黒潰れを直して喜んだのも束の間、今度はiPhoneだけ建物のテクスチャが上下反転していたのに気づきます。特に駅名表示の文字が鏡文字になっており、見た瞬間に背筋が凍りました。

原因は、WebGLの UNPACK_FLIP_Y_WEBGL フラグの挙動の違いです。Three.js側では texture.flipY = true がデフォルトですが、私がOffscreenCanvasから動的に生成したテクスチャに対しては、自前で gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) を呼んでいませんでした。

ChromeではOffscreenCanvasの内部バッファのY方向が偶然Three.jsの期待と一致していたため問題なく見えていたのですが、SafariのOffscreenCanvas実装は別物で、Y方向が逆になって描かれていたのです。明示的に以下を入れて解決しました。

const tex = new THREE.CanvasTexture(offscreen);
tex.flipY = true; // 明示
tex.needsUpdate = true;

// または生WebGLで動かす側では
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

このバグはPCのChromeでは絶対に発見できない類のもので、実機iPhoneを触らずに公開していたら完全に事故っていました。

事件3: Retina環境でのDPR問題

次に来たのは、MacBook ProのRetinaディスプレイでだけ全てのUI要素がぼんやりと滲む現象です。地図はシャープに見えるのに、オーバーレイで置いている速度計や駅名表示だけが、まるで近視の目で見たかのようにぼやけていました。

犯人は devicePixelRatio(DPR)。Retinaでは2.0、iPhoneの一部モデルでは3.0になります。Canvasの解像度をCSS上のサイズと同じに作ってしまうと、実ピクセルの半分でしか描画されず、拡大されてぼやけるのです。

function resizeCanvas(canvas) {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const rect = canvas.getBoundingClientRect();
    canvas.width  = Math.round(rect.width  * dpr);
    canvas.height = Math.round(rect.height * dpr);
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);
}

DPRを2で上限にしている理由は、iPhoneのDPR 3の端末で全解像度に合わせると、モバイルGPUの塗りピクセル数が9倍になってしまい、フレームレートが30fpsを割り込んだからです。「見た目の鮮明さ」と「描画負荷」のトレードオフで、2.0までに丸めるのが現実解でした。

willReadFrequently 属性の罠

ついでにもう一つ、Canvas関連で詰まった話を。速度計の描画で getImageData を多用していたのですが、Chrome 85くらい以降のバージョンで「Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true」という警告が頻発するようになりました。

// NG: 古い書き方
const ctx = canvas.getContext('2d');

// OK: 頻繁に読み出す場合
const ctx = canvas.getContext('2d', { willReadFrequently: true });

この属性は「GPUアクセラレーションを諦めてCPU側にバックバッファを置く」ための指定です。読み出しが多い用途では速くなりますが、読み出しがほぼない通常レンダリングで指定すると逆にFPSが落ちます。知らずに全Canvasに付けて回って「なぜかゲーム全体が重くなった」と頭を抱えたこともあり、これは本当に適材適所です。

事件4: backdrop-filter がAndroid Chromeで効かない

UIのガラス風(フロステッドグラス)表現に、CSSの backdrop-filter: blur(10px) を使っていました。iOSとmacOSのSafari、PCのChromeでは綺麗にぼかしが効くのに、Android Chromeの一部バージョンで完全に無視されて背景が素通しになる現象が報告されました。

原因は、Android Chromeのハードウェアアクセラレーション周りの事情で、backdrop-filter がフラグ付きの実装だった期間があったこと。現在は有効ですが、古いバージョンを使っている端末ではフォールバックが必要です。

.glass {
    background: rgba(30, 30, 30, 0.6);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
}
@supports not ((backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px))) {
    .glass { background: rgba(20, 20, 20, 0.92); }
}

@supports not でフォールバックを書いておくと、backdrop-filterが効かない環境では半透明ではなく濃いめの塗りに切り替わり、UIの視認性は確保できます。見た目の高級感は諦める必要がありますが、「文字が読めない」を避けるのが優先です。

事件5: Firefoxでフォントレンダリングが違う

最後に地味ですが厄介だったのが、Firefoxだけ日本語フォントの見た目がChromeより細く見える現象です。速度計の数字表示のウェイトが、Chromeだと「太め」、Firefoxだと「普通」に見えて、Firefoxユーザーから「数字が読みにくい」とフィードバックが来ていました。

調べてみると、ChromeとFirefoxでデフォルトのサブピクセルレンダリング設定が違い、同じフォントでも視覚的な太さが異なって見えるそうです。これはアンチエイリアシング関連のCSSで多少コントロールできます。

.speedometer {
    font-weight: 700;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: geometricPrecision;
}

ただし完全一致は無理なので、Firefoxでは font-weight を800に上げて視覚的な太さを合わせる、といったブラウザ別調整を最終的に入れています。やりすぎるとChromeユーザーにとっては太すぎるので、ここは目視でのバランス調整が必要でした。

検証マトリクスを作って潰す

ここまで書いたような差異を、思いついたときに場当たり的に直していくと、必ずどこかでデグレが発生します。そこで途中から、「ブラウザ × OS × 画面」の検証マトリクスを手元で運用するようにしました。

| 画面                  | Chrome/Mac | Safari/Mac | Chrome/iOS | Safari/iOS | Chrome/Android | Firefox/PC |
| ---                   | ---        | ---        | ---        | ---        | ---            | ---        |
| 起動スプラッシュ       | OK         | OK         | OK         | OK         | OK             | OK         |
| シミュレーター(夜景)   | OK         | OK         | OK         | OK         | OK             | OK         |
| マスコン操作          | OK         | OK         | OK         | OK         | OK             | OK         |
| 駅名パネル            | OK         | OK         | OK         | OK         | OK             | OK         |
| 関連記事一覧          | OK         | OK         | OK         | OK         | OK             | OK         |

表はただのMarkdownですが、新しい不具合が見つかったらこのマトリクスに「NG」を書き込み、直ったらOKに戻します。網羅的に確認する必要がある画面を5〜6個に絞ったので、リリース前に30セルを1時間くらいで1周できるようになりました。

ちなみに、iPhoneのSafariはキャッシュが独特で、開発中は「シークレットウィンドウで開き直す」を癖にしないと古いJSが残ることがあります。これで本番デプロイ後に「直ってない」と勘違いして、存在しないバグを追いかけたことが何度かありました。

まとめ

ブラウザ互換性の問題は、原因を一つに絞ると見つからず、複数の要因が絡み合うと一気に牙をむく厄介な分野です。WebGLの精度、テクスチャの向き、DPR、CSSの対応状況、フォントのレンダリング——どれも単独ならドキュメントに書いてある話ですが、アプリケーションとして統合したときに、どこでどれが効いているかを切り分けるのが本当に難しい。

山手線3Dジオラマでは最終的に、iOS Safari / Android Chrome / macOS Safari / PC Chrome / PC Firefox の5環境を必ず実機で確認するポリシーに落ち着きました。個人開発で5環境揃えるのは正直しんどいですが、これを怠った結果「iPhoneで文字が鏡文字になっている」ような決定的な不具合を公開してしまうと、ユーザーが離れるスピードがとんでもなく速いので、必要なコストだと割り切っています。次回は、PLATEAUの建物データをWebGL向けに軽量化したときの工夫について書いてみたいと思います。