[Next.js #51] Web Workersで作る無限の浮遊大陸とフライトシミュレーター

はじめに

前回の記事(#50)では、2Dのハイトマップノイズを使って無限に続く地形を生成し、その上を飛行機で飛び回るフライトシミュレーターの基盤を作りました。

しかし、2Dハイトマップには「地面の下がすべて埋まっている」「オーバーハング(えぐれた崖)や洞窟が作れない」という数学的な限界があります。

今回はその限界を突破します。

世界を 3Dの「密度(Density)」 で再定義し、空に浮かぶ巨大な大陸、立体的な雲、そして光の届かない深淵(Abyss)を、ブラウザ上で60FPSを維持したまま錬成する手法を解説します。

実装元のノイズ記事:

スクリーンショット:

動画(Youtube):

動画(PC):

モデルデータは、sketchfabから著作権フリーの素材をお借りしてます。

  model: {
    name: "PSX Saviola S21",
    author: "Gabriel Solon",
    url: "https://sketchfab.com/gabriel_solon",
    path: "./psx_saviola_s21.glb",
    scale: 1.5,
  },

1. 「高さ」から「密度」へ:世界を彫刻する数式

前回の記事までで作った地形は、いわゆる「2Dハイトマップ」と呼ばれる方式でした。 これは、ある地点 $(x, z)$ におけるノイズ関数の値を、そのまま「地形の高さ $y$」として扱うアプローチです。

// 2Dハイトマップの限界(前回のコード)
const y = noise2D(x, z) * amplitude;

この方法は計算が軽く、広大な地形を高速に作るのには向いています。しかし、数学的な構造上、「1つの $(x, z)$ 座標に対して、高さ $y$ は常に1つしか存在できない」という致命的な弱点があります。

つまり、上に出っ張った「オーバーハング(ひさし)」や、地中の「洞窟」、そして空に浮かぶ「浮遊大陸」は、この計算式からは絶対に生まれません。

発想の逆転:「空間をスキャン」する

この限界を突破するためには、パラダイムシフトが必要です。 「高さを求める」という発想を捨て、空間のあらゆる座標 $(x, y, z)$ に対して、「そこに物質が存在するかどうか(密度)」を判定するというアプローチに切り替えます。

具体的には、3D空間を細かく区切り、その1点1点について以下の「密度の数式」を計算します。

// 3D密度(Density)の基本数式
let density = -y + baseHeight + nv3D * 35;

// 密度が0より大きければ「岩」、0以下なら「空気」
if (density > 0) {
    placeBlock(x, y, z);
}

このたった1行の数式 density = -y + baseHeight + nv3D * 35; こそが、複雑な浮遊大陸を削り出す「彫刻刀」の正体です。各項目の役割を分解してみていきましょう。

① -y : 世界を支配する「重力」

高度 $y$ の値をマイナスにして足し合わせています。これは「高い場所に行けば行くほど、物質は存在しにくくなる」という、世界の大前提(重力)を作っています。これがないと、宇宙の果てまで岩がびっしりと詰まった世界になってしまいます。

② baseHeight : 基礎となる「大地の起伏」

これまで使っていた2Dノイズによる地形の高さです。これを足すことで、「本来なら重力に従って平らになるはずの地面」に、山や谷といった基本的なうねりを与えます。ここまでは、まだ2Dハイトマップと同じような見た目です。

③ nv3D * 35 : 空間を侵食する「魔法」

ここが最大のポイントです。$(x, y, z)$ の3次元座標から生成される3Dノイズ nv3D(値は -1.0 〜 1.0)を大きく増幅して足し合わせます。 この3Dノイズは、空間の特定の場所で密度を急激に上げたり(プラス)、逆に急激に下げたり(マイナス)します。

  • マイナスに働いた場合: 本来なら岩で埋まっているはずの山の中腹で密度が $0$ 以下になり、「洞窟」 や 「えぐれた崖」 が生まれます。
  • プラスに働いた場合: 本来なら何もないはずの上空($-y$ の力が強い場所)で密度が $0$ を上回り、空中に 「浮遊大陸」 が出現します。

「世界の底抜け」を防ぐ岩盤(Bedrock)補正

この数式は非常に強力ですが、3Dノイズの「削り取る力(マイナス)」が強すぎると、地形の底の方まで穴が空いてしまい、世界が底抜けになってしまうことがあります。

そこで、実践的なテクニックとして、高度が低い場所(地底付近)では、強制的に密度を極端に高くする「岩盤補正」を加えます。

// 地底(y が 0〜5 の範囲)での補正
const bedrockLevel = 5;
if (y < bedrockLevel) {
  // 深くなるほど、強烈なプラスの密度を足し合わせる
  density += Math.pow(bedrockLevel - y, 2) * 2;
}

これにより、上空には無数の島が浮かび、複雑にえぐれた地形を持ちながらも、足元は決して抜けることのない重厚な「深淵(Abyss)」を持つ世界が完成します。

たった数行の数学的な足し算と引き算が、これほどまでに有機的で複雑な立体空間を生み出す。これこそが、3Dノイズを使ったボクセル生成の最大のカタルシスなのです。

2. メインスレッドの悲鳴と「最強の調理場」の構築

空間の「密度」を計算して世界を削り出す手法は、まるで魔法のようですが、現実のコンピューターには残酷な代償を要求します。「計算量の爆発」です。

2Dハイトマップの時は、1チャンク($32 \times 32$ ブロック)あたり $1,024$ 回の計算で済んでいました。しかし、3D空間で上空から地底まで(例えば $Y=0$ から $64$ まで)スキャンするようになると、1チャンクあたりの計算回数は $32 \times 32 \times 64$、つまり 約65,000回 に跳ね上がります。サンプリングのステップを細かくすれば、その数は数十万回に達します。

ブラウザのJavaScriptは基本的に「シングルスレッド」で動いています。この膨大な計算をメインスレッド(描画を担当するスレッド)で実行しようとすると、飛行機が移動して新しいチャンクが必要になるたびに、画面が数秒間完全にフリーズ(応答なし)してしまいます。フライトシミュレーターとしては致命的です。

1人のシェフ(単一Worker)の限界

画面のフリーズを防ぐための第一歩は、Web Workers を使って計算処理をバックグラウンド(裏の別スレッド)に逃がすことです。メインスレッドは描画(Three.js)に専念し、裏で計算が終わったデータだけを受け取って表示します。

しかし、飛行機の移動速度(Speed)を上げていくと、新たな問題が発生します。 裏側で頑張っているWorker(シェフ)が1人しかいないため、「地形の注文スピード」に「調理スピード」が追いつかなくなるのです。結果として、飛行機が何もない虚無の空間に突っ込んでしまい、数秒遅れてポンッと地形が出現する現象が起きます。

CPUコアを使い倒す「Worker Pool(マルチスレッド調理場)」

この「生産能力の限界」を突破するために実装したのが、Worker Pool(ワーカープール) という仕組みです。

1人のシェフで足りないなら、自分のPCが持っているCPUのコア数(芯の数)だけシェフを雇い、巨大な「マルチスレッド調理場」を作ってしまえばいいのです。

// CPUのコア数を取得して、その数だけWorker(シェフ)を雇う
const poolSize = navigator.hardwareConcurrency || 4;
const pool = new WorkerPool(poolSize);

雇った複数のシェフに、効率よく注文をさばかせるための司令塔となるのが、以下の WorkerPool クラスです。

class WorkerPool {
  constructor(size) {
    this.workers = []; // 雇ったシェフたち
    this.queue = [];   // 待ち行列(未処理の注文)
    // 指定された数だけWorkerをインスタンス化
    for (let i = 0; i < size; i++) {
      this.workers.push({
        instance: new Worker('./chunkWorker.js', { type: "module" }),
        busy: false // 最初はみんなヒマ
      });
    }
  }

  // メインスレッドから新しいチャンクの注文が入る
  postTask(data, callback) {
    // 今、手が空いているシェフを探す
    const freeWorker = this.workers.find((w) => !w.busy);

    if (freeWorker) {
      freeWorker.busy = true; // シェフを「調理中」にする

      // 調理が終わったときの処理(コールバック)
      freeWorker.instance.onmessage = (e) => {
        freeWorker.busy = false; // 調理完了!手が空いた
        callback(e); // メインスレッドに料理(地形データ)を届ける
        this.processQueue(); // 行列に次の注文があれば、すぐに調理開始
      };

      // シェフに材料(座標などのデータ)を渡して調理スタート
      freeWorker.instance.postMessage(data);

    } else {
      // 全員が調理中なら、注文を「待ち行列(queue)」に入れて並ばせる
      this.queue.push({ data, callback });
    }
  }

  processQueue() {
    if (this.queue.length > 0) {
      const task = this.queue.shift();
      this.postTask(task.data, task.callback);
    }
  }
}

パラメーター変更時の「行列のキャンセル」も完璧に

さらにこの WorkerPool には、もう一つの強力な機能を持たせています。 GUIでノイズのパラメーター(スケールや高さなど)を調整した際、世界を瞬時に再生成(Rebuild)する必要がありますが、その時に「古いパラメーターでの計算待ちの行列」が大量に残っていると、動作が重くなってしまいます。

そこで、再生成の瞬間に this.queue = []; を呼び出して「待ち行列をすべてゴミ箱に捨てる」機能を実装しました。これにより、パラメーターをいじった瞬間から、最新の設定による世界が最優先でレンダリングされるようになります。

60FPSへの張り付き

このインフラ工事の恩恵は絶大です。 RTX 4070 Ti のような強力なGPUとマルチコアCPUを持つ環境であれば、飛行機の速度(Speed)をあり得ない数値まで引き上げても、裏側で複数のWorkerが次々と地形を錬成し続けるため、画面の描画は一切カクつくことなく、60FPSに張り付いたままになります。

プロシージャル生成において、「世界の生成がプレイヤーの移動速度を邪魔しない」というのは、最高の没入感を生み出す必須条件なのです。

3. 世界に命を吹き込む「環境演出」

色彩の揺らぎ(Color Jitter)と疑似AO(アンビエントオクルージョン)

地形をバイオーム(草原、崖、岩山など)ごとに単色で塗りつぶすと、どうしても昔のゲームのような「のっぺりとしたブロック感」が拭えません。そこで、色を決定する際に別のノイズ関数(2Dノイズ)を使って微細なムラ(Jitter)を加えます。

// 地点ごとの微細な色の揺らぎを計算 (別のノイズを使用)
const colorNoise = noise2D(wx * 0.1, wz * 0.1) * 0.15;

// 草原の場合:基本の緑色にノイズを足して、草の濃淡を表現
r = 0.3 + colorNoise;
g = 0.5 + colorNoise;
b = 0.2;

これだけで、同じ草原でも「青々とした場所」と「少し枯れた場所」が自然に混ざり合い、視覚的な情報量が劇的に増えます。

さらに、疑似AO(アンビエントオクルージョン) を実装します。本来のAOは周囲のオブジェクトの遮蔽を計算する重い処理ですが、ここでは「高度($y$)が低い場所ほど、基本の地形(baseHeight)の影になって暗くなるだろう」という推測に基づき、色を暗くする係数(shadowIdx)を掛け合わせます。

これにより、空中に浮かぶ浮遊島の下側に濃い影が落ち、下から見上げたときの立体感と巨大感が強調されます。

立体的な雲(3D Clouds)の造形と「風」

空の演出も、2Dの板ポリゴンではなく、地形と同じ3D密度のアルゴリズムを利用します。 上空の特定の範囲(例えば $Y=35$ 〜 $60$)に対して、ゆったりとしたスケールの3Dノイズを適用し、閾値を超えた場所を「雲ブロック」として配置します。

しかし、単に if (y > 45 && y < 50) のように高さで切り取ってしまうと、雲が「空に浮かぶ真っ平らな岩盤」のように見えてしまいます。雲特有の「もこもこ感」を出すには、中心の高度から離れるほど密度が弱まる(Fadeする)数式が必要です。

// 雲の中心高度からどれくらい離れているか(0.0 〜 1.0)
const dist = Math.abs(y - cloudCenterY) / cloudHeightRange;

// 中心に近いほど 1.0 になり、端に行くほど 0.0 に近づく「重み」
const weight = 1.0 - Math.pow(dist, 2); // 2乗することで境界が柔らかくなる

// timeを使ってノイズの参照座標をずらし、風を表現
const windShift = time * 40.0;
const cNoise = fbm3D((wx + windShift) * cloudScale, y * cloudScale, z * cloudScale...);

// 重みを掛け合わせて最終判定
if (cNoise * weight > cloudThreshold) {
    tempClouds.push(wx, y, wz);
}

また、上記のコードにある windShift は非常にエレガントなトリックです。 毎フレーム雲を動かすと計算負荷が跳ね上がりますが、「チャンク(地形の塊)が生成された瞬間の時間(time)」をノイズの $X$ 座標に足し合わせることで、遠くで新しく生まれる雲は常に「風で流された後の位置」に出現します。計算負荷ゼロで、世界に時間の流れ(風)を生み出すことができるのです。

地底の深淵(The Abyss)と世界の果て

フライトシミュレーターにおいて、「空の開放感」を際立たせるには、対極にある「地底の圧迫感」が必要です。

高度を下げて巨大な浮遊島の下へ潜り込んでいくにつれて、徐々に空が濁り、視界が闇に包まれていく演出を app.js のメインループ(animate 関数)に組み込みます。

// 現在のカメラの高度(y)を取得
const currentY = camera.position.y;

// y=30(上空) から y=5(地底) にかけて、1.0 から 0.0 に変化する係数を作る
const heightFactor = THREE.MathUtils.clamp((currentY - 5) / 25, 0, 1);

// 上空の空色(skyColor)と、地底の闇色(0x050508)を、高度に合わせてブレンド
const targetColor = new THREE.Color(skyColor).lerp(new THREE.Color(0x050508), 1 - heightFactor);
scene.background.copy(targetColor);
scene.fog.color.copy(targetColor);

// 地底に潜るほど、Fog(霧)の密度を濃くして視界を奪う
scene.fog.density = 0.005 + (1 - heightFactor) * 0.035;

さらに、地底($y < 5$)に到達した際は、chunkWorker.js 側で強烈な密度補正(Bedrock)をかけ、ブロックの色を強制的に黒に近い暗灰色に設定します。

これにより、ノイズの振幅(amplitude)をどれだけ大きく設定して地形が上下に引き伸ばされても、世界の底がスッポリと抜け落ちるのを防ぎます。 上空の美しい浮遊大陸から一転、操縦ミスで落下すれば、視界の効かない真っ暗な岩肌の迷宮が待ち受ける……という、ゲーム的な緊張感がここで完成します。

4. 衝突判定:ゲームへの第一歩

美しい浮遊大陸の間を猛スピードで飛び抜けるスリル。しかし、操作を誤って岩肌に突っ込んだ瞬間、機体が地形を「すり抜けて」しまっては、せっかくの没入感が台無しになります。

フライトシミュレーターを名乗るからには、「地形との衝突判定(Collision Detection)」 は避けて通れません。

Workerに聞いてはいけない理由

地形のデータはすべて裏方の Web Workers が持っています。ならば、「今、機体がある場所に岩はあるか?」と Worker に聞けばいいように思えます。

しかし、ここに 「非同期通信の罠」 が潜んでいます。 メインスレッドと Worker のやり取りは手紙のやり取りのようなもので、数ミリ秒のタイムラグ(遅延)が発生します。機体が猛スピードで飛んでいる場合、Worker から「そこに岩があります!」と返事が来た頃には、機体はすでに岩をすり抜けた後なのです。

衝突判定は、描画を行うメインスレッドで、毎フレーム(1秒間に60回)「今この瞬間、この座標でぶつかっているか」 を即座に判定する必要があります。

数学がもたらす魔法の判定

通常、3Dゲームの衝突判定には、複雑なポリゴン同士の交差判定や物理エンジン(Collider)を用います。非常に重い処理です。 しかし、私たちの世界は 「密度の数式」 という数学によって決定論的(Deterministic)に作られています。

つまり、重いポリゴンの判定などしなくても、「機体の現在の座標 $(x, y, z)$ を密度の数式に代入して、結果がプラス(岩)かマイナス(空気)かを見るだけ」 で、そこに地形が存在するかどうかが完璧に分かるのです。

メインスレッドでの軽量な密度チェック

app.js (メインスレッド)の冒頭に、Workerで使っているのと同じ密度の数式を、少しだけ軽量化(高速化)して持ち込みます。

// app.js (メインスレッド)
// 衝突判定用の簡易密度関数
function getDensityAt(x, y, z) {
  const scale = params.scale;

  // 判定用なので、複雑なFBM(複数回のノイズ合成)を省き、1オクターブのみで高速チェック
  const baseHeight = checkNoise2D(x * scale, z * scale) * params.amplitude;
  const nv3D = checkNoise3D(x * 0.04, y * 0.04, z * 0.04);

  let density = -y + baseHeight + nv3D * 35;
  if (y < 5) density += Math.pow(5 - y, 2) * 2; // 地底の岩盤補正も忘れずに

  return density;
}

あとは、メインループ(animate 関数)の中で、毎フレームこの関数を呼び出すだけです。

// 機体の衝突判定を更新する処理
function updateCollision() {
  if (params.collision) {
    // 機体(カメラ)の現在座標の密度を取得
    const d = getDensityAt(camera.position.x, camera.position.y, camera.position.z);

    // 密度が 0.5 より大きければ「岩の中にめり込んだ」と判定
    if (d > 0.5) {
      console.log("CRASH!");
      flight.speed = 0; // 速度をゼロに
      camera.position.set(0, 40, 150); // 初期位置へ強制送還(リセット)
    }
  }
}

プロシージャル生成の真骨頂

この方法の素晴らしいところは、「まだ画面に描画されていない(メッシュ化されていない)遠くの地形であっても、正確に衝突判定ができる」 という点です。世界そのものが「数式」であるため、わざわざ物体を作る前に、そこに何があるかが予言できるのです。

もちろん、純粋に絶景のフライトを楽しみたい時のために、GUI(lil-gui)に Collision Detection のオン/オフを切り替えるトグルスイッチも実装しました。

これで、ただのノイズの実験場が、プレイヤーの操縦スキルを試す「無限の探索型フライトゲーム」へと劇的な進化を遂げました。