3D地図上で電車を走らせる際、一番の課題だったのが「背景(建物)の表現」です。
国土交通省の「PLATEAU(プラトー)」データは非常に精巧ですが、そのままベクトルタイルとして表示すると、どうしても「のっぺりとした白い箱」に見えてしまいます。

このシミュレーターの目指す世界観は「リアルな都市」ではなく、あくまで「デスクの上で遊ぶ鉄道模型」です。そこで、あえて写実性を捨てて「模型っぽい質感」を出すために行った工夫を紹介します。

1. 画像を使わずコードで「汚れ」を作る

建物の壁面にテクスチャ(画像)を貼ればリアルにはなりますが、大量の画像データを読み込むと動作が重くなります。そこで、HTML5の Canvas API を使い、ブラウザ上で動的にテクスチャを生成する方法を採用しました。

具体的には、「コンクリートやプラスチックの成型色」のような、少しザラついたノイズ画像をJavaScriptで描いています。

// 256x256のキャンバスにノイズを描画
function createBuildingTexture() {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    // ベースは明るいグレー(模型のプラ色)
    ctx.fillStyle = '#d6d6d6';
    ctx.fillRect(0, 0, 256, 256);

    // ランダムな点を打ってザラつきを表現
    for (let i = 0; i < 60000; i++) {
        const x = Math.random() * 256;
        const y = Math.random() * 256;
        // 白と黒の点を混ぜることで立体感を出す
        ctx.fillStyle = Math.random() > 0.5 ? 
            'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.08)';
        ctx.fillRect(x, y, 1, 1);
    }
    // ...
}

この生成された画像を MapLibre の fill-extrusion-pattern に適用することで、単色塗りつぶし特有のCGっぽさを消し、Nゲージのストラクチャー(建物模型)のような質感を出すことができました。

2. 「模型用照明」としてのライティング

鉄道模型には「照明演出」が欠かせません。MapLibre GL JSのライト設定を調整し、時間帯によってドラマチックな影が落ちるようにしています。

特に「夕方モード(Dusk)」では、環境光(Ambient)を落としつつ、太陽光(Directional)の色温度をオレンジに振ることで、部屋の照明を落として模型を眺めているような雰囲気を再現しました。

// 夕方モードの設定例
map.setLight({ 
    color: '#ffaa00', // オレンジ色の強い光
    intensity: 0.6,
    position: [1.5, 90, 80] // 長い影が落ちる角度
});

3. 航空写真とのハイブリッド

「航空写真モード」では、地面はリアルな写真(衛星画像)ですが、その上に立つ建物は半透明の3Dモデルとして表示しています。

これにより、Google Earthのような「リアルな世界」と、鉄道シミュレーターとしての「記号的な世界」の中間のような、不思議な没入感が生まれます。建物の透過度を 0.7 程度に設定することで、背後の道路や線路が見え隠れし、位置関係が把握しやすくなるメリットもありました。

4. カラーパレットの選定

鉄道模型の世界観を出すには、色使いも重要です。リアルな都市の建物はグレーやベージュ中心ですが、Nゲージのストラクチャー(建物模型)は、もう少し彩度が低く、均一な色味で塗装されています。

そこで、建物の高さに応じてカラーパレットを3段階に分けました。

  • 低層(〜15m): 暖色系グレー(#c8beb0)— 木造家屋や小規模店舗のイメージ
  • 中層(15〜50m): 標準グレー(#b8b8b8)— マンションやオフィスビル
  • 高層(50m〜): 寒色系グレー(#a0a8b0)— タワーマンションや超高層ビル

高さデータはPLATEAUの属性値(measuredHeight)から取得しています。微妙な色味の違いですが、街全体を俯瞰したときに「高いビルほど青みがかって見える」という空気遠近法的な効果が生まれ、自然な立体感が出せるようになりました。

5. パフォーマンスへの影響と対策

プロシージャルテクスチャ生成はCPU処理のため、描画開始時に一定の計算コストがかかります。256x256ピクセルの Canvas を1枚生成するのに約2〜3ms。これを建物カテゴリ分(3種類)生成するだけなので、初期ロード時の負荷としては許容範囲内です。

一方、静的なテクスチャ画像をサーバーから配信する方式と比較した場合、プロシージャル生成にはネットワーク通信が不要というメリットがあります。画像ファイルのダウンロードを待つ必要がないため、特に通信環境の悪いモバイル端末では初期表示が速くなる効果がありました。

MapLibreのfill-extrusion-patternの制限

MapLibreの fill-extrusion-pattern プロパティは、スタイル仕様上は画像スプライト内の名前を指定する形式です。動的に生成した Canvas を直接指定することはできないため、map.addImage() でランタイムにスプライトへ登録するというワークアラウンドを使っています。

// 生成したCanvasをMapLibreのスプライトに登録
const texture = createBuildingTexture();
map.addImage('building-noise', texture, { pixelRatio: 2 });

// レイヤーのスタイルでパターンとして参照
map.setPaintProperty('building-3d', 'fill-extrusion-pattern', 'building-noise');

この方法の注意点として、addImage はスタイルのロード完了後に呼ぶ必要があります。map.on('style.load', ...) イベント内で実行するのが確実です。

6. ライティングで1週間溶かした話

模型っぽい質感を追求するうえで、実は一番時間を溶かしたのが「ライティング(照明)」の調整でした。テクスチャやカラーパレットはある程度ロジックで決められますが、光の当たり方だけは「見た目で判断する」しかなく、微調整のたびにリロードして眺める時間が延々と続きました。

ambientLightで真っ白になった日

最初に陥ったのが、環境光(ambientLight)の強度を上げすぎて、画面全体が真っ白に飛んでしまう失敗です。影がなくなれば建物が明るく見えるだろうと安直に intensity: 1.2 まで上げたところ、全ての面が均一に照らされ、まるで粘土細工のような立体感ゼロのシーンになりました。慌てて 0.4 まで下げ、directionalLightとの比率を 1:2 程度に保つのが正解だと気付くまで、ほぼ丸一日を費やしています。

// 最終的に落ち着いたライト設定
map.setLight({
    anchor: 'viewport',
    color: '#ffffff',
    intensity: 0.4,        // 環境光は控えめに
    position: [1.15, 210, 30]
});
// directionalLight相当は別途Three.js側で intensity: 0.85

directionalLightの角度で陰影が破綻

次にハマったのが、directionalLight の角度(position)を動かすたびに建物同士の影が不自然に干渉する現象です。太陽を真横から入れると長すぎる影が隣のビルを完全に覆ってしまい、逆に真上から落とすと影がほとんど出ずにのっぺりします。最終的には 高度30度・方位210度(やや南西寄りの午後3時くらいの光)に落ち着きましたが、ここに辿り着くまで10パターン以上試しました。

toneMappingで模型感が戻った話

「リアルに寄せたい」という欲が出て、Three.js側のレンダラに toneMapping: THREE.ACESFilmicToneMapping を設定した時期がありました。確かにハイライトが映画のような質感になるのですが、問題は「模型っぽくなくなる」ことでした。ACESは実写向けに調整されたトーンカーブなので、せっかくテクスチャで作ったザラついた成型色がグッと飲まれてしまい、CGムービーのワンシーンのような絵になってしまったのです。

// リアル寄りで失敗した設定
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;

// 模型感を取り戻した最終設定
renderer.toneMapping = THREE.LinearToneMapping;
renderer.toneMappingExposure = 1.1;
renderer.outputColorSpace = THREE.SRGBColorSpace;

結局 LinearToneMapping に戻した瞬間、「あ、これこれ」という模型の安っぽさ(褒め言葉)が戻ってきました。リアルすぎない質感を狙うなら、トーンマッピングは素直にLinearが正解という結論です。

shadowCameraで影が途中で切れる

建物の影をきれいに落とすために DirectionalLight の castShadow を有効にしたら、今度は「画面の手前側の建物だけ影があって、奥のビルには影がない」という現象に悩まされました。原因は shadow.camera のfrustum範囲が狭すぎて、カメラから離れた建物がシャドウマップの外に出てしまっていたことです。shadow.camera.far を 3000 まで、left/right/top/bottom を ±1500 まで広げることで、街区全体に影が落ちるようになりました。ただし範囲を広げるとシャドウマップの解像度が相対的に落ちるので、shadow.mapSize も 2048×2048 に引き上げてバランスを取っています。

7. 色指定でセンスを問われた日々

カラーパレットの章では「最終形」だけを書きましたが、実はそこに至るまでには何度もやり直しがありました。色覚のセンスに自信がない私にとって、色選びは最も辛い作業の一つです。

「冬みたい」と言われた寒色期

最初に試したのは、全体を少し青みがかったグレー(#a8b0b8 あたり)に統一した寒色系パレットでした。「都会的でクール」というイメージで作ったのですが、テストプレイしてもらった知人から「なんか冬みたい。寂しい」というフィードバックが返ってきました。確かに日本の都市の模型というと、もう少し乾いた暖色のイメージがあります。自分では「洗練された」つもりが、ただ「冷たい」だけになっていたのです。

「おもちゃっぽい」と言われた暖色期

じゃあ逆方向にと、暖色寄りのベージュ(#d4c8a8 あたり)を基調にしたところ、今度は「小学生の夏休みの工作みたい」「おもちゃっぽい」と酷評されました。模型っぽさを狙ったはずがチープさに直結してしまい、中々難しいものです。おそらく彩度が高すぎたのと、屋根・壁・窓枠を全て同系の暖色で揃えたせいで、全体が飴細工のような印象になっていました。

// Photoshopで作ったスワッチを順に試した履歴
const palettes = [
    ['#a8b0b8', '#98a0a8', '#889098'],   // 寒色期: 「冬みたい」
    ['#d4c8a8', '#c8bc9c', '#bcb090'],   // 暖色期: 「おもちゃっぽい」
    ['#c8beb0', '#b8b8b8', '#a0a8b0'],   // 最終版: 彩度低めの中間
];

Photoshopスワッチで中間を探る

結局、Photoshopのスウォッチパネルで寒色と暖色を並べてブレンドし、「彩度を20%落とした中間色」に着地しました。低層は若干暖かく、高層に行くほど青みを帯びる、という段階的なグラデーションにすることで、単調さも避けられています。色彩センスはロジックでは埋められないので、複数人に見せてフィードバックをもらうのが結局一番早いという、身も蓋もない学びを得ました。

8. PLATEAUデータ特有の罠

PLATEAUは素晴らしいオープンデータですが、使い込むと独特の「地雷」を踏むことになります。私も何度か盛大に踏みました。

uro:BuildingUsage属性を無視すると全部同色

最初のうち、建物属性として使っていたのは measuredHeight(高さ)だけで、用途情報は完全にスルーしていました。結果として、住宅も商業ビルもオフィスも全部同じテクスチャ・同じ色になり、街としての「表情」がまったく出ませんでした。PLATEAUの建物には uro:BuildingUsage という拡張属性があり、住宅・業務・商業・工業などの用途が入っています。これを読んで色相を少しずつずらすだけで、街にメリハリが生まれました。

// 用途で色をわずかにずらす
function colorByUsage(usage, baseColor) {
    const tints = {
        '業務施設': '#c8cad0',   // ほんの少し寒色
        '商業施設': '#d0c8b8',   // ほんの少し暖色
        '住宅':     '#c8beb0',   // 標準
        '工業施設': '#b8b4a8',   // 少しくすみ
    };
    return tints[usage] || baseColor;
}

建物が地中に埋まっていた事件

PLATEAUデータを初めて3D表示したとき、一部の建物が地面にめり込んで上半分しか見えないという現象が起きました。原因はPLATEAUのZ値が 標高(GEOID上の高さ)で記録されているのに対し、私の地形データは 楕円体高 を基準にしていたことです。日本付近だとこのGEOID高低差が約37m前後あり、その分まるごと建物が埋まっていました。各建物のZ座標に一律で +37 のオフセットをかけることで解消しましたが、気付くまで半日「なんでPLATEAUの建物って上の階しかないんだろう」と真顔で首を傾げていました。

新宿区だけやけに重い

地域によってデータ量に極端な差があるのもPLATEAUの罠です。山手線沿線を表示させていると、新宿区に入った瞬間だけFPSがガクッと落ちるという現象に悩まされました。新宿区はLOD2(屋根形状付き)の建物が他区より多く収録されており、同じ面積でも頂点数が段違いだったのです。最終的にはズームレベルに応じてLOD1(箱モデル)とLOD2を切り替える仕組みを入れ、遠景では強制的にLOD1を使うことでFPSを安定させました。「東京23区」と一括りに扱うとパフォーマンス設計が破綻するという、いい教訓でした。

まとめ

3Dモデルをただ置くだけでなく、質感(テクスチャ)や光の当たり方(ライティング)を少し調整するだけで、画面から受ける印象はガラリと変わります。
「リアルすぎない、ちょうどいい模型感」を目指したチューニングは、技術的にもデザイン的にも面白い試行錯誤でした。