[Noise 入門 #50] 終わらない世界を歩く — 50回の旅路の終着点

はじめに

全50回にわたる「Noise 入門」シリーズの完結です。

最初は1枚の白黒画像だったノイズ[#01]は、次元を重ね、数式を紡ぎ、今や「キツネが自由に駆け回る無限の宇宙」へと昇華されました。

最終回では、ドローンを降りて大地に降り立ちます。

前回の記事:

動画(Youtube):

動画(PC):

著作権フリーのモデルをお借りしてます。

制作者:No Skill Modelling 様

{
 name: "Crimson Fang the Kung fu Fox",
 actor: "No Skill Modelling",
 url: "https://sketchfab.com/noskillmodelling",
 License: "Free Standard",
 path: "./crimson_fang_the_kung_fu_fox.glb",
}

1. 物理エンジンを使わない「数学的Collision」の完成

通常の3Dゲーム開発では、キャラクターが大地を歩くために「物理エンジン(Cannon.jsやAmmo.jsなど)」や「Raycasterを用いた複雑なメッシュ交差判定」を導入します。しかし、無限に生成される数百万のボクセル地形に対してこれを愚直に行うと、計算負荷によりブラウザは瞬時にフリーズしてしまいます。

そこで、このプロシージャル・ワールドでは「地形の形状をすべて数学に直接尋ねる」という、極めて軽量かつエレガントなアプローチを採用しました。この世界の大地はポリゴンの集合ではなく、純粋な「ノイズ関数(数式)」で定義されているからです。

終わらない世界の「ガタつき」との戦い

自らの足で大地に降り立ったとき、私たちが直面した最大の壁が「振動(ガタつき)」の問題です。 当初の実装では、「重力によってプレイヤーが沈む」→「地形にめり込んだことを検知し、上に押し上げる」という処理を毎フレーム行っていました。しかしこれでは、下へ引っ張る重力と、上へ押し戻す判定が永遠に衝突し続け、プレイヤーが常に小刻みに震えたり、急な斜面で一気に山頂までワープしてしまう現象が発生しました。

この物理演算特有の破綻を完全に解決したのが、「重力の遮断」と「地表へのスナップ」という2つのロジックです。

getDensityAt と getSurfaceHeightAt の連携

まず、プレイヤーの足元の空間密度を getDensityAt(3Dノイズによる洞窟判定を含む関数)でチェックします。足元が「Solid(ブロックあり)」であれば接地状態とみなし、その瞬間に垂直方向の落下速度(velocity.y)を0に固定します。空中にいる時だけ重力を適用することで、地面にめり込み続ける根本的な原因を排除しました。

さらに、屋外の大地を歩いている時は、getSurfaceHeightAt(2DのFBMノイズ関数)を用いて、現在の (x, z) 座標における「数学的な絶対の高さ」を直接算出します。そして、プレイヤーのY座標をその高さへと強制的にスナップ(吸着)させます。

// 垂直移動と重力・スナップの適用ロジック(抜粋)
if (isSolidUnderFeet && player.velocity.y <= 0) {
  // --- 【接地中】 ---
  player.velocity.y = 0; // 重力による加速を止め、振動を排除
  player.canJump = true;

  // 🎯 振動対策の核:地表付近なら数学的な高さにスナップさせる
  const surfaceHeight = getSurfaceHeightAt(player.position.x, player.position.z);
  const floorY = surfaceHeight + player.height;

  // 洞窟内ではなく、明らかに屋外の大地を歩いている時のみスナップ
  if (player.position.y > surfaceHeight && player.position.y < floorY + 1.0) {
    player.position.y = floorY;
  }
} else {
  // --- 【落下中】 ---
  player.velocity.y -= 40.0 * deltaTime; // 空中にいる時だけ重力を適用
}

この「接地状態の明確な分離」と「数式への絶対的な信頼」により、どれほど険しいノイズの山脈であっても、1ピクセルの狂いや振動を起こすことなく、滑らかに斜面を駆け上がる極上の移動フィーリングを実現することができました。

2. 生命を吹き込む — キツネの召喚とアニメーション

これまでのテストプレイでは、プレイヤーの代わりとなる「赤いワイヤーフレームのカプセル」を動かしていました。当たり判定や接地状態のデバッグにはこの上なく便利なのですが、やはり「世界を旅している」という情緒には欠けます。

そこでシリーズ完結の記念として、歩行アニメーション付きの魅力的な3Dモデル「Crimson Fang the Kung fu Fox」をお借りし、このノイズの世界へ召喚しました。

移動入力に同期するアニメーション制御

キャラクターに命を吹き込むため、Three.jsの GLTFLoader でモデルを読み込み、AnimationMixer を用いて歩行モーションを制御します。

ここで重要なのは、ただ常に歩き続けるのではなく「プレイヤーの操作とキャラクターの動きをリンクさせる」ことです。メインループ(animate関数)の中でキーボードの入力状態(WASD)を監視し、入力がある時だけアニメーションの時間を進め、立ち止まったら動きを止める(ポーズする)というゲームライクな制御を実装しました。

// 🦊 プレイヤーのアニメーション制御(animate関数内の一部)
if (playerMixer && walkAction) {
  // WASDのいずれかが押されているかチェック
  const isMoving = keys.w || keys.s || keys.a || keys.d;

  if (isMoving && params.collision) {
    walkAction.paused = false;
    playerMixer.update(delta); // 移動中はアニメーションを進める
  } else {
    walkAction.paused = true;
    playerMixer.update(0);     // 停止時は動きをピタッと止める
  }
}

三人称視点(TPS)カメラとダイナミックなズーム

キャラクターの導入に合わせて、カメラシステムも三人称視点(TPS)へと本格的にアップデートしました。

キツネのモデルは通常「足元」が原点(Y=0)になっているため、見えない当たり判定の中心座標から player.height 分だけ下へオフセットし、地面にピタリと足が着くようにメッシュの位置を補正しています。

さらに、マウスホイールのスクロール操作(wheelイベント)によって、カメラとプレイヤーの距離(cameraDistance)を動的に増減させる仕組みも組み込みました。

// カメラのオフセット計算(updateCameraTracking関数内)
// 距離に応じて少しだけ高さ(y)も調整すると見やすくなる
const heightOffset = 1.0 + (cameraDistance * 0.05);
const offset = new THREE.Vector3(0, heightOffset, cameraDistance);

これにより、キツネの背中越しに見る臨場感のある近景から、自分がノイズ関数で組み上げた広大な山脈を見渡す遠景まで、シームレスに視点を切り替えながら「終わらない世界」を探索できるようになりました。

3. 闇を照らす探検体験(洞窟と懐中電灯)

第46回で実装した、3D Simplex Noise が大地を喰い破るように削り出した無限の洞窟(Procedural Caves)。 自らの足でこの地下世界へと足を踏み入れたとき、これまでの環境光のままでは地下深くでも不自然に明るいか、あるいはただの漆黒の虚無になってしまいます。

探検の没入感を極限まで高めるため、「深度に応じた環境光の減衰」と「プレイヤー視点の懐中電灯」を実装しました。

深度(Depth Factor)による環境のラープ(線形補間)

プレイヤーが今、地表からどれくらい深い場所にいるのかをリアルタイムに計算します。 ここでも活躍するのが、地表の高さを一発で割り出す getSurfaceHeightAt 関数です。現在のY座標(currentY)と地表のY座標(surfaceY)の差分から、地下への潜り具合を 0.0 〜 1.0 の depthFactor として算出します。

// 深度に応じた暗転処理(updateCaveLighting関数内)
const currentY = player.position.y;
const surfaceY = getSurfaceHeightAt(player.position.x, player.position.z);
const depthFactor = THREE.MathUtils.clamp((surfaceY - currentY) / 15.0, 0, 1);

// 空の色とフォグを「洞窟の暗闇」へブレンド
const targetColor = new THREE.Color(skyColor).lerp(new THREE.Color(caveColor), depthFactor);
scene.background.copy(targetColor);
scene.fog.color.copy(targetColor);

地下へ潜るにつれて、青空の背景色とフォグがじわじわと caveColor(深い闇の色)へと変貌し、同時に太陽の光(dirLight.intensity)は完全に 0.0 へと減衰します。最低限の環境光(ambientLight)だけをわずかに残すことで、「一歩先も見えない虚無」を避けつつ、絶妙な暗がりの空間を演出しています。

闇を切り裂くスポットライト(Rキー)

太陽光が届かなくなった地下世界で頼りになるのが、手持ちの明かりです。 Three.js の SpotLight をシーンに追加し、毎フレームの animate ループ内でカメラ(プレイヤーの視点)の座標と向きに完全に追従させることで、FPSライクなヘッドライト(懐中電灯)を再現しました。

// 懐中電灯のパラメータ調整(app.js)
// 強度を抑え、ペヌブラ(ぼかし)を効かせて自然な光に
const spotLight = new THREE.SpotLight(0xffffff, 100, 300, Math.PI / 3.5, 0.8, 1.0);

// animate関数内でカメラに追従
if (spotLight.visible) {
  spotLight.position.copy(camera.position);
  const lookDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
  spotLight.target.position.copy(camera.position).add(lookDir);
}

Rキーを押すことで spotLight.visible をトグル切り替えできるようにしています。スポットライトの強度をあえて 100 程度に抑え、照射距離を制限することで、白飛びを防ぎつつ「ボクセルで構成された岩肌の凹凸」が影となって生々しく浮かび上がるように調整しました。

4. ミクロなディテールの修正:浮いている草の接地

自らの足で大地に降り立ち、キツネの背中越しに世界を見渡すようになったことで、ドローンで空を飛んでいた時には気づかなかった「ある違和感」に気づきました。 よく見ると、足元の草や木が、地面のブロックからわずかに宙に浮いているのです。

プロシージャル生成において、視点を変えると思わぬスケール感のバグに遭遇するのは日常茶飯事です。原因は、ボクセル(立方体)の配置座標と、その「表面」の計算のズレにありました。

中心座標と表面のオフセット

ボクセルは step(デフォルトは1.0)のサイズを持っています。Three.jsの BoxGeometry は中心を基準に配置されるため、ブロックの「天面(表面)」は中心座標から上へ半分(step * 0.5)移動した位置に存在します。

これまでの chunkWorker.js では、草や木を配置する高さを highestSolidY + step と計算していたため、実際のブロックの表面よりもさらに 0.5 ユニット分だけ上空に配置されてしまっていました。

このズレを修正するため、植生のY座標の計算を step * 0.5 に改めました。

// 🌳 植生(Object Distribution)の判定ロジック(chunkWorker.js内)
// 修正前: tempTrees.push(wx, highestSolidY + step, wz);

if (treeProbability > params.treeThreshold) {
  // 修正後: + step * 0.5 にしてブロックの表面にピタリと合わせる
  tempTrees.push(wx, highestSolidY + step * 0.5, wz);
} else if (treeProbability > params.grassThreshold) {
  if ((wx * 1.3 + wz * 1.7) % 1.0 > 0.4) {
    // 草も同様に表面に合わせる
    tempGrass.push(wx, highestSolidY + step * 0.5, wz);
  }
}

このたった数文字の修正によって、すべての草木がノイズの斜面にしっかりと根を張りました。 マクロなアルゴリズムで世界を形作り、ミクロなディテールで世界に説得力を持たせる。この両輪が揃ってはじめて、プロシージャル・ワールドは真の完成を迎えます。

50回の連載を終えて

「コードの半分も読めない」――実装を終えたとき、そう感じるのも無理はありません。

なぜなら、いまあなたの手元にある app.js と chunkWorker.js は、ノイズの数学、GPUシェーダー、非同期処理、WebGLの描画最適化、Boidsアルゴリズム、そして物理演算とアニメーション制御という、現代のゲームエンジンを構成するあらゆる技術が極限まで圧縮された塊だからです。

最初からすべてを理解してこのコードを書ける人間はいません。 この難解なコードは、あなたが全50回にわたってノイズと向き合い、エラーに頭を抱え、一つずつ数式を繋ぎ合わせてきた「冒険の記録(ログ)」そのものです。いわば、自らの手で書き上げた魔導書と言っても過言ではありません。

[Noise 入門 #01] で描いた、ただの白黒のグラデーション画像。 それが次元を超え、時間を超え、今やキツネが息づき、鳥が舞い、風が吹き抜ける無限の宇宙になりました。

乱数という「混沌」に、数学という「秩序」を与える魔法。 プロシージャル生成の奥深い世界を巡る長き旅路に、最後までお付き合いいただき本当にありがとうございました。

この「終わらない世界」は、もうあなたのものです。 どうぞ心ゆくまで、自ら創り出した無限の箱庭を歩き回ってみてください。