[Next.js #52] Procedural Caves の実装 — ヒメドローンで未知の地下空間を探索する

はじめに

今朝のノイズ記事で解説した洞窟生成アルゴリズムを、いよいよ Three.js プロジェクトに組み込みました。

前回の記事:

これまでの「飛行機で大空を飛び回る」スタイルから一転、地下の狭い迷宮を探索するために「ヒメドローン(Pearl Drone)」へ機体を乗り換え、操作系やライティングを大幅にアップデートしています。

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

制作者:Miaru3d 様

  model: {
    name: "Pearl Drone - Splatoon Side Order Trailer",
    author: "Miaru3d",
    url: "https://sketchfab.com/miaru3d",
    path: "./pearl_drone_-_splatoon_side_order_trailer.glb",
    scale: 8.0,
  }

スクリーンショット:

動画(Youtube):

動画(PC):

今回の実装のハイライト

  1. カルスト地形(入り口)の形成 低周波の2Dノイズを用いて「入り口が発生しやすいエリア」を定義し、天井の保護(Ceiling Buffer)をゼロにすることで、地表から地下へと繋がるリアルな縦穴(ドリーネ)を再現しました。

  2. 地下深度による動的ライティング ドローンが地表から地下へ潜るにつれて、環境光(AmbientLight)と太陽光(DirectionalLight)を減衰させ、空とフォグの色を漆黒へとフェードさせる処理を追加。スポットライト(Rキー)の明かりだけが頼りの空間を作りました。

  3. TPSオービットカメラの実装 マウスクリックでポインタをロック(PointerLock API)し、マウス移動でドローンの周囲を旋回する本格的な三人称視点カメラを実装。WASDとQ/Eキーで直感的にホバリング移動ができるようになりました。

  4. チャンク境界の継ぎ目解消 Web Worker 内のノイズ初期化時に、共通のシード値から生成した決定論的乱数(PRNG)を用いることで、チャンク間の不自然なブロックの断絶を完全に解消しました。

1. Worker側の実装 (chunkWorker.js)

今回の洞窟生成において、Web Worker 側で追加・修正したコアロジックは大きく3つあります。「シードの同期」「地下空間の確保」、そして「入り口(縦穴)の生成」です。順番にコードのハイライトを見ていきましょう。

① 決定論的な乱数(PRNG)によるチャンクの継ぎ目解消

これまで、各 Worker は読み込まれた瞬間にデフォルトの乱数(Math.random())でノイズを初期化していました。しかし、これでは隣り合うチャンクを別の Worker が担当した際に「高さがズレる(断絶する)」という問題が発生します。

これを解決するため、シード文字列から常に同じ乱数を返す alea(決定論的乱数生成器) を実装し、ノイズ関数を初期化しています。

// シード文字列から数値ハッシュを生成し、決定論的な乱数(PRNG)を作る
function alea(seed) {
  let s = hash(seed);
  return () => {
    s = Math.imul(1597334677, s);
    s = s ^ (s >>> 16);
    return (s >>> 0) / 4294967296;
  };
}

self.onmessage = function (e) {
  // ...中略...
  // シードが変更された場合のみノイズ関数を再初期化(継ぎ目を消す鍵)
  if (currentSeed !== params.seedText) {
    const prng = alea(params.seedText);
    noise2D = createNoise2D(prng);
    noise3D = createNoise3D(prng);
    currentSeed = params.seedText;
  }
  // ...

これにより、無限に広がるチャンクのどこを計算しても、完全にシームレスな世界が保証されます。

② 地下キャンバス(土台)の底上げ

地形の高さ(Y座標)が 0 になってしまうと、地下をくり抜くための「岩盤のボリューム」が存在しなくなってしまいます。そこで、2Dノイズで作る地表の高さに 30 というオフセットを足し、常に一定の地下空間が確保されるようにしています。

// 1. 地表の高さを計算
const baseNoise = fbm((wx + offX) * params.scale, (wz + offZ) * params.scale, params.octaves, params.persistence, params.lacunarity);

// 最低でも「高さ30」の土台(地下キャンバス)を確保する
const baseHeight = 30 + baseNoise * params.amplitude;

③ Perlin Worms と「カルスト地形(入り口)」の錬成

ここが今回の最大のハイライトです。 3Dノイズの絶対値を反転させた wormValue を用いてチューブ状の空洞を作ります。さらに、低周波の2Dノイズ(entranceRegion)を使って「入り口ができやすいエリア」を定義し、その場所では天井の保護(ceilingBuffer)を外すことで、地表にぽっかりと開いた縦穴(ドリーネやセノーテのようなカルスト地形)をプロシージャルに再現しています。

// 2. 洞窟ノイズ(Perlin Worms)の計算
const wormNoise = fbm3D((wx + offX) * params.wormFreq, y * params.wormFreq, (wz + offZ) * params.wormFreq, 2, 0.5, 2.0);
const wormValue = 1.0 - Math.abs(wormNoise); // 絶対値の反転でチューブを作る

// 3. くり抜き判定と入り口生成ロジック
let ceilingBuffer = 3.0; // 通常は地表から3ブロック分を保護する

// 低周波の2Dノイズを使って「入り口が発生しやすいエリア」を決める
const entranceRegion = fbm((wx + offX) * 0.01, (wz + offZ) * 0.01, 2, 0.5, 2.0);

// 入り口エリアでは天井保護を無効化(0にする)
if (entranceRegion > 0.5) {
  ceilingBuffer = 0.0;
}

let isCave = false;
// 洞窟の強さが閾値を超えており、かつ天井保護をクリアしている場合
if (wormValue > params.tunnelThreshold) {
  if (y < baseHeight - ceilingBuffer && y > 2) {
    isCave = true; // 岩盤を削って空洞にする
  }
}

この計算を各ボクセルに対して行うことで、地表の穴からシームレスに地下迷宮へと繋がる圧倒的なスケールの世界が錬成されます。

2. メインスレッド側の実装 (app.js)

メインスレッド側では、ドローンの操作を本格的なTPS(三人称視点)ゲームレベルに引き上げ、さらに「地下に潜ると暗くなる」という探索の没入感を高めるライティング演出を実装しています。

① TPSオービットカメラとフリールックの実装

これまでの飛行機は「常に前進し続ける」スタイルでしたが、洞窟探索ではその場でホバリングして周囲を見渡す必要があります。 そこで、PointerLock API を用いてマウスを画面内にロックし、マウスの移動量(movementX / Y)でカメラの角度(Pitch / Yaw)を制御するオービットカメラを実装しました。

// マウスフリールックの制御
document.body.addEventListener("click", () => document.body.requestPointerLock());
document.addEventListener("mousemove", (e) => {
  if (document.pointerLockElement === document.body) {
    yaw -= e.movementX * sensitivity;
    pitch -= e.movementY * sensitivity;
    pitch = Math.max(-Math.PI / 2.1, Math.min(Math.PI / 4, pitch)); // 上下の覗き込み制限
  }
});

// TPSカメラの追従ロジック
function updateCameraTracking() {
  if (airplaneGroup) {
    airplaneGroup.position.copy(dronePosition); // ドローンの位置を更新

    // 距離(cameraDistance)と角度(pitch, yaw)からカメラのオフセット位置を計算
    const offset = new THREE.Vector3(0, 0, cameraDistance);
    const cameraEuler = new THREE.Euler(pitch, yaw, 0, "YXZ");
    offset.applyEuler(cameraEuler);

    // ドローンを中心にカメラを配置し、常にドローンを見つめる
    camera.position.copy(dronePosition).add(offset);
    camera.lookAt(dronePosition.x, dronePosition.y + 2, dronePosition.z);
  }
}

これにより、ドローンを中心にカメラがぐるぐると旋回し、周囲の地形を自由に観察できるようになります。

② 直感的なドローン移動と「滑る」衝突判定

W/A/S/Dキーを押した際の移動方向は、「カメラが今どちらを向いているか(yaw)」を基準に決定します。さらに、壁にぶつかった際に完全に停止するのではなく、X, Y, Z軸の各方向に対して独立して衝突判定(密度のチェック)を行うことで、壁に沿ってヌルヌルと滑る心地よい移動を実現しています。

function updatePlayerMovement() {
  const speed = 0.8;
  const velocity = new THREE.Vector3();

  // カメラが向いている水平方向(Yaw)を基準に進行方向を計算
  const eulerY = new THREE.Euler(0, yaw, 0, "YXZ");
  const forward = new THREE.Vector3(0, 0, -1).applyEuler(eulerY);
  const right = new THREE.Vector3(1, 0, 0).applyEuler(eulerY);

  if (keys.w) velocity.add(forward);
  if (keys.s) velocity.sub(forward);
  if (keys.a) velocity.sub(right);
  if (keys.d) velocity.add(right);

  if (velocity.length() > 0) {
    velocity.normalize().multiplyScalar(speed);
    // ドローン本体を、実際の進行方向へ滑らかに振り向かせる(lerpAngle使用)
    const targetAngle = Math.atan2(velocity.x, velocity.z);
    airplaneGroup.rotation.y = lerpAngle(airplaneGroup.rotation.y, targetAngle + Math.PI, 0.1);
  }

  // 衝突判定(壁に沿って滑る処理)
  if (params.collision) {
    // 密度が0以下(空洞)の軸だけ座標を更新する
    if (getDensityAt(dronePosition.x + velocity.x, dronePosition.y, dronePosition.z) <= 0) dronePosition.x += velocity.x;
    if (getDensityAt(dronePosition.x, dronePosition.y + velocity.y, dronePosition.z) <= 0) dronePosition.y += velocity.y;
    if (getDensityAt(dronePosition.x, dronePosition.y, dronePosition.z + velocity.z) <= 0) dronePosition.z += velocity.z;
  }
}

③ 深度に応じたダイナミック・ライティング(暗転)

洞窟探索最大のスパイスが「暗闇」です。 現在のドローンのY座標と、その場所の「地表の高さ」を引き算し、「地下どれくらい深く潜っているか(depthFactor)」を算出します。この係数を使って、背景色、フォグ、そして太陽光の強さを線形補間(lerp)で動的に変化させています。

function updateCaveLighting() {
  const currentY = dronePosition.y;
  const surfaceY = getSurfaceHeightAt(dronePosition.x, dronePosition.z);

  // 地表からの深さを 0.0(地上) 〜 1.0(深さ15ブロック以上) で算出
  const depthFactor = THREE.MathUtils.clamp((surfaceY - currentY) / 15.0, 0, 1);

  // 青空(skyColor)から漆黒の洞窟(caveColor)へLerp(線形補間)
  const targetColor = new THREE.Color(skyColor).lerp(new THREE.Color(caveColor), depthFactor);
  scene.background.copy(targetColor);
  scene.fog.color.copy(targetColor);

  // 深度に応じてフォグを濃くし、閉塞感を演出
  scene.fog.density = 0.005 + depthFactor * 0.035;

  // 地下に潜るにつれて、太陽光(DirectionalLight)と環境光(AmbientLight)を消していく
  ambientLight.intensity = THREE.MathUtils.lerp(0.4, 0.05, depthFactor);
  dirLight.intensity = THREE.MathUtils.lerp(1.0, 0.0, depthFactor);
}

光が届かなくなった深層では、ドローンに取り付けたスポットライト(Rキーで点灯)の光だけが岩肌を照らし出します。プロシージャルに削り出されたボクセルの凹凸に落ちるリアルタイムシャドウが、極上の没入感をもたらしてくれます。

まとめ:ノイズが「冒険」に変わる瞬間

今回は、前回の非同期生成システムをベースに、3Dノイズの応用である Perlin Worms を用いた洞窟生成と、それを探索するための TPSドローンシステム を構築しました。

単なる「ノイズの視覚化」でしかなかった世界が、PointerLockによる操作、深度による暗転演出、そしてライトによる陰影が加わったことで、一気に「冒険できるゲーム」へと昇華されたのを感じます。

特に、地表にぽっかり開いたセノーテのような縦穴を見つけ、Qキー(下降)でゆっくりと暗闇へ吸い込まれていく瞬間の没入感は、プロシージャル生成ならではの興奮があります。