[Next.js #50] 無限のボクセル世界を飛ぶ — フライトシミュレーターとボクセル雲の実装

はじめに

前回の [Next.js #49] では、InstancedMesh を用いて大量のブロックを描画し、バイオームを表現しました。

さらに [Noise 入門 #44] で実装した ChunkManager により、世界はついにカメラの移動に合わせて無限に広がるようになりました。

今回はこれまでの集大成として、この無限に生成されるボクセルの大地を自由に飛び回る 「フライトシミュレーター」 のプロトタイプを実装します。

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

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

スクリーンショット:

動画(YouTube):

動画(PC):

番外編:

速度の限界に挑みましたが100にしてもFPSが落ちる事は無かったので、驚異的な速度。

1. カメラと機体を連動させる(チェイスカメラ)

これまでは OrbitControls を使って世界を「神の視点」で俯瞰していましたが、自らを「操縦者」とするために独自のフライト物理を実装します。

3D空間での自由な旋回(ピッチ、ヨー、ロール)を rotation.x += … のようなオイラー角(Euler)で計算しようとすると、特定の角度で回転軸が重なり、機体が荒ぶって制御不能になる「ジンバルロック」という現象に見舞われます。これを防ぐため、回転の計算には常に最短ルートで姿勢を補間できる THREE.Quaternion(四元数)を使用します。

旋回ロジック:世界の軸と機体の軸

空を飛ぶための回転計算において、最も重要なのは「どの軸を基準に回るか」です。

// animateループ内のフライト物理
const tempQuat = new THREE.Quaternion();
const upVec = new THREE.Vector3(0, 1, 0);       // 世界の「真上」
const rightVec = new THREE.Vector3(1, 0, 0);    // 機体の「右翼」

// A/Dキーでの左右旋回(Yaw)
tempQuat.setFromAxisAngle(upVec, flight.yaw);
camera.quaternion.premultiply(tempQuat); // 【重要】世界のY軸基準で回転

// W/Sキーでの上下旋回(Pitch)
tempQuat.setFromAxisAngle(rightVec, flight.pitch);
camera.quaternion.multiply(tempQuat);    // 【重要】機体のローカル右軸基準で回転

ここで premultiply と multiply を使い分けているのには明確な理由があります。

  • premultiply(左側から掛ける): ワールド座標系(固定された世界)を基準に回転します。機体がどれだけ上を向いていようが、左右キーを押した時は「世界の水平」に沿って旋回します。
  • multiply(右側から掛ける): ローカル座標系(機体自身)を基準に回転します。機首を上げる(ピッチ)動作は、常に「現在の自分の両翼(右軸)」を軸にして行われます。

この2つを組み合わせることで、航空機として破綻のない自然な旋回が可能になります。

推進力の計算:前進とは何か?

カメラの向きが決まったら、次は「現在向いている方向」へ機体を進ませます。

// カメラの向いている方向(ローカルのZマイナス方向)をワールドベクトルに変換
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
camera.position.addScaledVector(forward, flight.speed);

Three.jsでは、カメラは常に「自身のZ軸のマイナス方向(奥)」を向いています。applyQuaternion を使うことで、この「自分にとっての前」という概念を、「世界の中での絶対的な進行方向」へと変換し、現在のスピード分だけ座標を足し合わせています。

モデルの追従とロール(傾き)の演出

カメラの計算が終わったら、その少し前方に「機体モデル」を配置し、三人称視点(チェイスカメラ)を完成させます。

単に camera.add(airplaneGroup) とカメラの子要素にしてしまうと、カメラに完全に固定されてしまい「遊び」がなくなります。毎フレームワールド座標をコピーし、そこにオフセット(距離)を足し込むことで、カメラと機体が独立した空間に存在しつつ連動する状態を作ります。

if (airplaneGroup) {
  // カメラの視点から「真下に2、手前に15」離れた位置を計算
  const offset = new THREE.Vector3(0, -2, -15).applyQuaternion(camera.quaternion);
  airplaneGroup.position.copy(camera.position).add(offset);
  airplaneGroup.quaternion.copy(camera.quaternion);

  // 目標の傾き(targetRoll)へ滑らかに補間
  flight.roll = THREE.MathUtils.lerp(flight.roll, flight.targetRoll, 0.1);

  // Z軸(機体の前後軸)を基準にロール回転を上乗せ
  tempQuat.setFromAxisAngle(new THREE.Vector3(0, 0, 1), flight.roll);
  airplaneGroup.quaternion.multiply(tempQuat);
}

ここで最高のスパイスとなるのが、旋回時の傾きを計算する THREE.MathUtils.lerp(線形補間)です。 キーを押した瞬間にパッと機体が傾くのではなく、「現在の傾き」から「目標の傾き」へ、毎フレーム10%ずつ(0.1の割合で)ジワジワと近づいていきます。

この「Zenoのパラドックス」のような漸近線の動きが、空気の抵抗や機体の重みといった「物理的な手触り(Inertia)」を数式だけで生み出しているのです。

2. ボクセルクラウド(雲)の錬成:2Dノイズの「押し出し」

地形が完成し、自由に空を飛べるようになると、今度は「空の空虚さ」が気になり始めます。 Minecraftのような世界観を強調し、かつフライトの指標(スケール感や障害物)となる要素として、空に浮かぶ雲も「ブロックの集合体」としてプロシージャルに生成します。

なぜ CloudManager を分離するのか?

地形を生成する ChunkManager の中に雲の処理を混ぜてしまうことも可能ですが、今回は意図的に CloudManager という別クラス(別ファイル)に分離しています。

理由は以下の通りです。

  1. マテリアルが違う: 地形は光を反射する不透明なマテリアルですが、雲は transparent: true と opacity を持つ半透明のマテリアルを使用します。これらを同じ InstancedMesh に混ぜることはできません。
  2. ノイズのスケールが違う: 地形の起伏(デコボコ)に対して、雲はより巨大でゆったりとしたノイズ(低い周波数)を必要とします。
  3. 将来の拡張性: クラスを分けておけば、後から「雲だけ風で流して動かす」処理を入れたり、あるいはボクセルをやめてシェーダー(レイマーチング)による本物のボリューム雲に差し替えたりする際、メインシステムを壊さずに済みます。

閾値(Threshold)による空間の切り抜き

雲を生成するロジックの核心は、「2Dノイズの値を、立体的な厚みに変換する」という数学的アプローチにあります。

// CloudManager.js より抜粋
const threshold = 0.5; // 雲が発生するノイズの境界値
const cloudBaseY = 60; // 雲の基本高度

// 地形よりも大きなスケール(cloudScale)で、2Dノイズによる「雲の分布」を計算
const d = this.fbm(wx * cloudScale, wz * cloudScale, 2, 0.5, 2.0);

// ノイズ値が閾値を超えた場所だけ、「雲」として実体化する
if (d > threshold) {
  // 閾値をどれだけ超えたか(超過分)を計算し、それを雲の「厚み」に変換する
  const thickness = Math.floor((d - threshold) * 20);

  // 厚みの分だけ、Y軸方向へブロックを積み上げる(押し出し)
  for (let h = 0; h < thickness; h++) {
    candidates.push({ x: wx, y: cloudBaseY + h, z: wz });
  }
}

ここでやっているのは、$f(x, z)$ という2Dのノイズ関数が吐き出す $0.0 \sim 1.0$ の値をそのまま高さにするのではなく、「$0.5$ 以下なら何もない青空」「$0.5$ を超えたら、超えた分だけブロックを上に積み上げる(押し出し:Extrude)」 という処理です。

ノイズのピーク(山の頂上)に近づくほど $d$ の値は大きくなるため、結果として中心が分厚く、縁(ふち)にいくほど薄くなる、巨大で有機的な雲の塊が空中に錬成されます。

物理的な「障害物」としての雲

マテリアルに transparent: true と opacity: 0.5 を設定することで、半透明の巨大な雲の塊が空に出現します。

現代のフライトゲームの多くは、雲を単なる「霧」として通り抜けられるように作られています。しかしこのシステムでは、雲は InstancedMesh で描画された「物理的なブロックの集合体」です。 プレイヤーは視界を遮る巨大なボクセルの塊を前にして、「雲の中へ突っ込む」か「雲を避けて飛ぶか」 という、プロシージャル生成ならではの立体的なフライト体験を味わうことになります。

3. 光速の最適化:Speed 100 の世界

このシステムの最大の強みであり、そして最も驚くべきポイントは「圧倒的な処理の軽さ」にあります。

インスペクター(GUI)から飛行スピードの制限を外し、100 という「光速」レベルのスピードに引き上げてみてください。凄まじい勢いで大地と雲が後ろへ飛び去っていくにもかかわらず、画面のFPSメーターは 60FPS に張り付いたまま、ヌルヌルと世界が描画され続けるはずです。

オープンワールドのゲームにおいて、高速移動は最大の敵(処理落ちの原因)です。それにもかかわらず破綻しないのは、これまでの連載で積み上げてきた以下のアーキテクチャが完璧に機能している証拠です。

① InstancedMesh による Draw Call の極小化

通常、数万個のブロックを描画しようとすると、CPUからGPUへ数万回の「これを描画してくれ」という命令(Draw Call)が飛び、一瞬で処理がパンクします。しかし InstancedMesh を用いることで、たった1つのジオメトリとマテリアルをGPUに送り、「この10万個の座標リスト全部に、同じスタンプを押してくれ」というたった1回の命令で済ませています。描画負荷のボトルネックが根本から解消されているのです。

② Chunk Manager の空間ハッシュと完全な破棄

プレイヤーが猛スピードで進むということは、それだけ「次のチャンク」を瞬時に割り出し、過去のチャンクを素早く消し去る必要があります。 私たちの ChunkManager は、座標を文字列キーにした Map を使っているため、世界がどれだけ広がろうと常に $O(1)$ の計算量で次の空間を特定できます。そして、視界から外れたチャンクに対しては、単に非表示にするのではなく .dispose() を呼び出してVRAMから完全にデータを破棄しています。どれだけ移動しても、メモリにゴミが溜まることはありません。

③ 手続き型(Procedural)生成の真骨頂

ハードディスクから巨大なマップデータを読み込むのではなく、「プレイヤーが到達した瞬間に、ノイズの数式から大地を計算して実体化(召喚)する」というプロシージャル生成の強みが、この圧倒的なパフォーマンスを支えています。

次回予告:再びノイズの深淵へ

今回のフライトシミュレーター・プロトタイプは、今後のNext.jsプロジェクトで「オープンワールド」や「サンドボックスゲーム」を構築するための、文句なしの最強のスターターキットになります。このベースがあれば、飛行機だけでなく、車やキャラクターの操作に切り替えることも容易です。

しかし、空から世界を見下ろしていて、一つの「限界」に気づいたかもしれません。 今の地形は、2Dノイズで作られた「ただの起伏(ハイトマップ)」です。どれだけ高くそびえる山を作れても、「地面に空いた洞窟」や「宙に浮く島(オーバーハング)」 を作ることは絶対にできません。

ここから先は、一旦「Noise入門」シリーズへと戻りましょう。 次回はいよいよ、平面のデコボコから次元を一つ上げ、空間の「詰まり具合(密度)」を定義して世界を彫り出す 「3D Noise(密度関数)」 の深淵へと足を踏み入れます。

あなたの世界に、底知れぬ地下洞窟が開通する日はもうすぐそこです。お楽しみに!