[Noise 入門 #45] Web Workers と 3D Noise — 非同期生成と「密度」で空間を定義する

はじめに

Noise 入門シリーズ第45回。

前回のChunk Managerの実装により、私たちは枠組みを越えて無限に広がる世界を手に入れました。しかし、カメラの移動速度を極めて高速で空を飛んでみると、新しいチャンクが生成される瞬間にフレームレートが落ち、「カクッ」と一瞬画面が止まる(スパイクが発生する)現象に直面したはずです。

今回は、この重たいチャンク生成の計算処理を裏側の別スレッドに丸投げする Web Workers(ウェブワーカー) を導入し、どれだけ高速に世界を駆け抜けても60FPSを維持する非同期最適化アプローチを実装します。

そして、パフォーマンスの壁を越えた先にある次なる次元、「2D(高さ)から3D(密度)へのパラダイムシフト」 の基礎となる数学的アプローチについて解説します。

前回の記事:

1. メインスレッドの限界と Web Workers

ブラウザのJavaScriptは基本的にシングルスレッドで動作します。つまり、UIの描画(Three.jsのレンダリング)と、何万ものブロックのノイズ計算・メッシュ生成は「同じ列に並んで」順番待ちをしています。

ブラウザのメインスレッドは、例えるなら「一人で厨房を回しているシェフ」です。接客(UI操作)も、盛り付け(レンダリング)も、大量の仕込み(何万ものノイズ計算)もすべて一人でこなしています。普段は問題なくても、急に大量の仕込みが必要な重い注文(新しいチャンクの生成)が入ると、どうしても手が止まってしまい、結果として接客や盛り付けが遅れてしまいます。

60FPSを維持するためには、1フレームのすべての処理を約 16.6ms 以内に終わらせる必要があります。チャンクのFBMノイズ計算に 30ms かかってしまうと、その間は画面の描画が完全にストップし、これが「カクつき(スパイク)」の正体となります。

Web Workersによる非同期化(マルチスレッド)

この問題を解決するのが Web Workers です。メインスレッド(描画担当のシェフ)とは別のバックグラウンドスレッド(裏で仕込みをする専用のスタッフ)を立ち上げ、重いノイズ計算をそちらへ「丸投げ」します。

処理の流れ:

  1. メインスレッド: 新しいチャンクの座標 $(x, z)$ に到達。「Workerさん、この座標の地形データを計算して!」とメッセージ(postMessage)を送る。
  2. Workerスレッド: メッセージを受け取り、裏でせっせとFBMノイズを計算し、ブロックの配置データ(配列)を作成する。
  3. メインスレッド: その間も一切止まらず、既存のチャンクを60FPSでスムーズに描画し続ける。
  4. Workerスレッド: 計算完了。「できたよ!」とデータをメインスレッドに送り返す。
  5. メインスレッド: データを受け取り、InstancedMesh に追加してポンッと世界に出現させる。

これにより、カメラがどれだけ高速に移動しても、メインのレンダリングループを阻害することなく、無限の大地が「滑らかに」湧き上がるようになります。

【重要】Transferable Objects(ゼロコピー転送)による最適化

ここで一つ、ボクセルエンジンの開発において非常に重要な最適化テクニックがあります。

Workerスレッドで計算した「何万個ものブロックの座標データ」をメインスレッドに送り返す際、通常のJavaScriptの配列(Array)やオブジェクトのまま送ると、内部でデータの「コピー(シリアライズとデシリアライズ)」が発生してしまいます。結果として、データを渡す行為そのものが重くなり、再びカクつきの原因になってしまいます。

これを防ぐために、Transferable Objects(移譲可能オブジェクト) を使用します。具体的には Float32Array などの型付き配列(TypedArray)を使い、データの「所有権」だけを瞬時にメインスレッドへパスします。

Worker側の実装イメージ (chunkWorker.js)

// Workerスレッド:重いノイズ計算を担当
import { createNoise2D } from 'simplex-noise';

const noise2D = createNoise2D();

self.onmessage = function(e) {
  const { chunkX, chunkZ, chunkSize } = e.data;

  // 座標を格納するための型付き配列(Float32Array)を用意
  // 1ブロックにつき x, y, z の3つの数値が必要
  const positions = new Float32Array(chunkSize * chunkSize * 3);

  let index = 0;
  for (let x = 0; x < chunkSize; x++) {
    for (let z = 0; z < chunkSize; z++) {
      // 実際のワールド座標を計算
      const worldX = chunkX * chunkSize + x;
      const worldZ = chunkZ * chunkSize + z;

      // FBMノイズなどで高さを計算
      const height = Math.floor(noise2D(worldX * 0.05, worldZ * 0.05) * 10);

      // 配列に座標を流し込む
      positions[index++] = worldX;
      positions[index++] = height;
      positions[index++] = worldZ;
    }
  }

  // 計算が終わったらメインスレッドへ送信
  // 第2引数に buffer を指定することで「ゼロコピー(Transferable)」で爆速転送する
  self.postMessage({ chunkX, chunkZ, positions }, [positions.buffer]);
};

メインスレッド側の実装イメージ (ChunkManager.js)

// メインスレッド:描画とWorkerの管理を担当
const worker = new Worker(new URL('./chunkWorker.js', import.meta.url), { type: 'module' });

// Workerに計算を依頼する
function requestChunkGeneration(chunkX, chunkZ) {
  worker.postMessage({ chunkX, chunkZ, chunkSize: 16 });
}

// Workerから計算結果を受け取る
worker.onmessage = function(e) {
  const { chunkX, chunkZ, positions } = e.data;

  // 受け取った positions (Float32Array) を使って InstancedMesh を更新
  // メインスレッドは計算せず、ただ配置するだけなので一瞬で終わる
  addChunkToScene(chunkX, chunkZ, positions);
};

このように、計算ロジックを分離し、Float32Array によるゼロコピー通信を行うことで、Three.jsの InstancedMesh は真のパフォーマンスを発揮します。

2. 2D(高さ)から 3D(密度)へのパラダイムシフト

Web Workersによって世界の生成速度という「パフォーマンスの制約」から解放された今、いよいよ地形そのものの複雑さを引き上げる時が来ました。

これまで私たちは、ノイズを 2Dのハイトマップ(高さ情報) として扱ってきました。上空から地形を見下ろして、各マス目の「標高」だけを決めている状態ですね。これを数式で表すとこうなります。

$$y = f(x, z)$$

ある座標 $(x, z)$ をノイズ関数に入れると、高さ $y$ が返ってくる。これは非常に計算が軽く広大な地形を作るのに向いていますが、決定的な弱点があります。 それは「1つの $(x, z)$ 座標に対して、高さは1つしか存在できない」ということです。

この数学的制約により、ハイトマップでは以下のような地形が絶対に作れません。

  • 切り立った崖のオーバーハング(えぐれ)
  • 空中に浮かぶラピュタのような「浮遊島」
  • 地下へと続く複雑な 「洞窟」

空間の「詰まり具合」を定義する

Minecraftのような複雑な世界を作るには、アプローチを根本から変える必要があります。「平面の高さを決める」のではなく、「3D空間のすべての点において、そこに物質が存在するかどうか(密度)を決める」 のです。

$$d = f(x, y, z)$$

ここで扱う $d$ は Density(密度) です。 空間の任意の点 $(x, y, z)$ において、密度の計算結果が特定の閾値(Threshold)を超えていれば「ブロックを置く」、超えていなければ「空気を置く(何もしない)」という判定を行います。

  • $d > 0$ : 地面(Solid / ブロックを配置する)
  • $d \le 0$ : 空気(Air / 何も配置しない)

平面のキャンバスに絵を描く(2D)のから、大理石のブロックから彫刻を削り出す(3D)ような感覚への変化、それがこのパラダイムシフトの本質です。

3. 密度の数式(Density Formula)

では、どうすれば自然な地形を生み出す「密度の数式」を作れるでしょうか? 純粋な3Dノイズだけで判定すると、宇宙空間に無数のブロックがフワフワと漂うだけのデブリ空間になってしまいます。

私たちが欲しいのは「基本的には地面(下に行くほど密度が高く、上に行くほど空気が多い)でありながら、ノイズによって局所的に削られたり、浮いたりする空間」です。

これを実現する基本的な密度の数式は以下のようになります。

$$Density(x, y, z) = -y + HeightMap(x, z) + 3DNoise(x, y, z) \times Amplitude$$

この式を分解して直感的に理解してみましょう。

  1. $-y$ (ベースとなる重力) $y$ 座標(高さ)が大きくなるほどマイナスになります。つまり、上空に行くほど密度が低く(空気になりやすく)、地下深くに行くほど密度が高く(岩盤になりやすく)なります。
  2. $HeightMap(x, z)$ (基本の地形) これまで使っていた2DのFBMノイズです。これを足すことで、基本となる山や谷の起伏ベースを作ります。
  3. $3DNoise(x, y, z) \times Amplitude$ (空間の浸食と隆起) ここが魔法のスパイスです。3D空間のノイズを加えることで、ある場所では密度が急激に下がって「空洞(洞窟)」になり、ある場所では空中で密度がプラスに転じて「浮遊島」が錬成されます。

この数式を全ボクセルの座標に対して計算(ループ)し、密度が 0 より大きい座標にのみブロックを InstancedMesh で配置していくことで、真に3Dなプロシージャル地形が完成します。

次回のハイライト:Procedural Caves — 洞窟の錬成

今回は世界の生成を非同期化し、空間の「密度」という新しい概念を手に入れました。

次回【Noise 入門 #46】では、この密度の数式と「Perlin Worms(パーリンの虫)」と呼ばれるテクニックを用いて、地下に複雑に絡み合う 「プロシージャルな洞窟」 を掘り進めます。

光の届かない地下深く、ノイズが織りなす未知の空間の錬成にご期待ください!