はじめに
Noise 入門シリーズ第43回。第5集「Procedural World編」の第3弾です。
前回は InstancedMesh を駆使し、10万個のブロックからなる広大な「大地」を60FPSで描画することに成功しました。しかし、現状の世界はすべてのブロックが単一の緑色で塗りつぶされており、少し無機質で寂しい風景です。
今回は、生成されたブロックの「高さ(Y座標)」というデータを手がかりに、この世界に海、砂浜、森、雪山といった「生態系(バイオーム)」を与えます。さらに、リロードするたびに異なる地形を生み出す「シード値」の制御と、プレイヤーの移動に合わせて世界を無限に広げていく「チャンク(Chunk)」システムの基礎概念を解説します。
いよいよ、あなたの世界が色鮮やかに呼吸を始めます。
前回の記事:
[Noise 入門 #42] Three.js × InstancedMesh — 10万のブロックで「最初の大地」を構築する
Three.jsのInstancedMeshを使ってMinecraftのようなボクセル地形を生成する方法を解説。FBMノイズを利用し、10万個のブロックを60FPSで描画するパフォーマンス最適化の実装を学びます。
https://humanxai.info/posts/noise-intro-42-instanced-mesh/1. 標高に基づく動的カラーリング(バイオームの実装)
無機質な緑色のブロック群に「命」を吹き込む最初のステップは、地形の「高さ(Y座標)」に応じた色の割り当てです。現実世界の生態系が標高によって変化するように、私たちのプロシージャルな世界にも高度によるルールを定めます。
Three.jsの InstancedMesh には、インスタンス(個々のブロック)ごとに異なる色を割り当てる setColorAt(index, color) という強力なメソッドが用意されています。
1.1 ベースマテリアルは「純白(0xffffff)」にする
色を塗る前に、非常に重要なシェーダーの前提知識があります。 InstancedMesh に設定するベースの Material の色は、必ず 「白(0xffffff)」 に設定してください。
// ❌ ベースを緑などにすると、setColorAtの色と乗算されて暗く濁る
const material = new THREE.MeshLambertMaterial({ color: 0x228B22 });
// ⭕️ ベースを純白にすることで、setColorAtの色がそのまま発色する
const material = new THREE.MeshLambertMaterial({ color: 0xffffff });
内部のVertex Shaderでは ベースカラー × インスタンスカラー という乗算処理が行われます。キャンバス(ベース)を純白にしておくことで、後から乗せるバイオームの色が正確に発色します。
1.2 パフォーマンスの罠:THREE.Color の使い回し
10万個のブロックをループ処理する際、絶対にやってはいけないのが 「ループの中で new THREE.Color() を呼び出す」 ことです。毎フレーム、あるいは生成時に10万個のオブジェクトをメモリに確保すると、ガベージコレクション(GC)が発生し、一瞬画面がフリーズする原因になります。
ループの外で1つだけ THREE.Color のインスタンスを作り、ループ内では .setHex() で中身の値だけを書き換えて使い回すのが、WebGL最適化の基本パラダイムです。
1.3 高さ(Height)によるバイオーム判定
まずは最もシンプルな、「高さ」だけを基準にした生態系を定義します。
- Water (海): Y座標が 0 以下(青)
- Sand (砂浜): Y座標が 0〜2(砂色)
- Grass (草原): Y座標が 2〜12(緑)
- Rock (岩肌): Y座標が 12〜18(グレー)
- Snow (雪山): Y座標が 18 以上(白)
これを前回のループ処理に組み込みます。
import * as THREE from 'three';
import { createNoise2D } from 'simplex-noise';
const noise2D = createNoise2D();
// ベースは純白に
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshLambertMaterial({ color: 0xffffff });
const count = 10000; // 100 x 100のチャンクを想定
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
const matrix = new THREE.Matrix4();
const color = new THREE.Color(); // ループ外で1つだけインスタンス化
let i = 0;
for (let x = 0; x < 100; x++) {
for (let z = 0; z < 100; z++) {
// ノイズから高さを取得(本来はFBMなどを使用)
const height = Math.floor(noise2D(x * 0.05, z * 0.05) * 10) + 5;
matrix.setPosition(x, height, z);
instancedMesh.setMatrixAt(i, matrix);
// --- バイオーム(色)の判定 ---
if (height <= 0) {
color.setHex(0x1E90FF); // DodgerBlue (海)
} else if (height <= 2) {
color.setHex(0xF4A460); // SandyBrown (砂浜)
} else if (height <= 12) {
color.setHex(0x228B22); // ForestGreen (草原)
} else if (height <= 18) {
color.setHex(0x808080); // Gray (岩肌)
} else {
color.setHex(0xFFFAFA); // Snow (雪山)
}
// 使い回しているcolorオブジェクトの現在の状態をインスタンスに焼き付ける
instancedMesh.setColorAt(i, color);
i++;
}
}
// ⚠️超重要:色情報をGPUへ転送するフラグを立てる
instancedMesh.instanceColor.needsUpdate = true;
instancedMesh.setColorAt() を呼んだだけでは、画面上の色は変わりません。最後に必ず instancedMesh.instanceColor.needsUpdate = true; を記述し、CPU側で更新したカラー配列(Instance Attributes)をGPUへ転送するようThree.jsに指示を出してください。
1.4 【応用】もう一つのノイズで「気候(Moisture)」を作る
高さだけで塗り分けると、世界は「等高線」のように同じ高さが同じ色になる、少し単調な風景になります。
Minecraftのようなより自然な地形を作るには、地形の高低差を決めるノイズ(Height Map)とは全く別のノイズ(Moisture Map / 湿度マップ)をもう一つ用意します。
- Height(高さ): 山を作るか、谷を作るか。
- Moisture(湿度): その場所が乾燥しているか、湿潤か。
例えば、「高さは同じ『平地(Grassレベル)』でも、Moistureノイズの値が低ければ『砂漠(黄)』になり、高ければ『ジャングル(深緑)』になる」といった二次元の判定グラフ(バイオームテーブル)を構築することで、地形の複雑さが飛躍的に増し、真の「プロシージャルな自然」が生み出されます。
※この「多重ノイズによる複雑な生態系の構築」は、今後の応用編でさらに深掘りしていく予定です。まずは今回の「Y座標による色分け」で、InstancedMeshに正しく色を乗せる感覚を掴んでください。
2. ランダムシードの導入:一期一会の世界を作る
現在のコードでは、ブラウザをリロードしても毎回全く同じ地形が生成されてしまいます。これは、Perlin Noise や Simplex Noise といったアルゴリズムが「座標に対して常に同じ値を返す(決定論的である)」という特性を持っているためです。
ノイズ関数が描く無限の宇宙は、実は最初から形が一つに決まっています。では、プレイヤーごとに異なる「一期一会の世界」を作るにはどうすればよいのでしょうか?
2.1 巨大な「オフセット」で別の座標へジャンプする
ノイズの数式そのものを書き換える必要はありません。広大なノイズ宇宙の中で、「世界を切り取る初期位置(カメラの位置)」を遠くへずらすのが最もシンプルで強力なアプローチです。
// ランダムな乱数を元に、巨大なオフセット(ズレ)を生成
const seedOffsetX = Math.random() * 10000;
const seedOffsetZ = Math.random() * 10000;
for (let x = 0; x < 100; x++) {
for (let z = 0; z < 100; z++) {
// ノイズを取得する際、基本座標(x, z)にオフセットを足し込む
const nx = (x + seedOffsetX) * frequency;
const nz = (z + seedOffsetZ) * frequency;
const height = Math.floor(noise2D(nx, nz) * amplitude);
// ...以降は同じ
}
}
たったこれだけで、リロードするたびに全く違う景色が広がるようになります。オフセット値に 10000 のような大きな数を掛けているのは、数ブロック隣にずれただけでは「さっきの地形のすぐ隣」になってしまい、似たような景色が続いてしまうからです。大きく座標を飛ばすことで、全く新しい大陸に降り立つことができます。
2.2 Math.random() の限界と「再現性」
しかし、上記の Math.random() を使った実装には致命的な弱点があります。それは「再現性が無い」ということです。
もし、偶然とてつもなく美しい絶景(例えば、海に囲まれた巨大な雪山)が生成されたとしても、その地形を保存したり、友人に「このシード値を入れてみて!」と共有したりすることができません。
真のシードシステムを構築するには、JavaScript標準の Math.random() を捨て、「シード値(Seed)を与えると、常に同じ順番で乱数を返す関数(疑似乱数生成器:PRNG)」を導入する必要があります。
2.3 文字列から世界を創る(シードジェネレーター)
ゲームでよくある「“humanxai” という文字列を入力すると、毎回決まった地形になる」という仕組みは、以下の2つのステップで実装されます。
- ハッシュ化: 入力された文字列(“humanxai”)を、固定の数値(例えば 837492)に変換する。
- 疑似乱数生成: その数値をシード値としてPRNGに渡し、オフセット用の乱数を生成する。
JavaScriptには標準でシード付き乱数がないため、実運用では seedrandom のような軽量ライブラリを導入するか、以下のようなシンプルなPRNG関数(Mulberry32など)を自作して使用するのが一般的です。
// 1. 文字列からシードとなる数値を生成する簡易的なハッシュ関数
function generateHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = Math.imul(31, hash) + str.charCodeAt(i) | 0;
}
return hash;
}
// 2. シード値を元に乱数を生成する関数 (Mulberry32)
function seededRandom(a) {
return function() {
var t = a += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
// ----------------------------------------------------
// 実装例
// ----------------------------------------------------
const seedString = "humanxai"; // プレイヤーが入力した任意の文字列
const seedNumber = generateHash(seedString); // 数値に変換
const randomFunc = seededRandom(seedNumber); // シード付き乱数関数の完成!
// Math.random() の代わりに、生成した randomFunc() を使う
const seedOffsetX = randomFunc() * 100000;
const seedOffsetZ = randomFunc() * 100000;
// 以降は同じ。 "humanxai" を入力する限り、常に同じオフセットが計算される
この仕組みを導入することで、あなたのプロシージャルな世界は「ただのランダムなノイズ」から、「意図して共有・再現できる一つの確定した宇宙」へと昇華されます。
💡 Tips: simplex-noise ライブラリの強力な機能 もし npm で simplex-noise を利用している場合、ライブラリの初期化時にカスタムの乱数関数を渡すことができます。 const noise2D = createNoise2D(randomFunc); このように記述すれば、オフセットを自前で計算せずとも、ノイズ空間全体を一発で特定のシードに固定することが可能です。
3. 無限の世界へ(Chunkingの基礎)
10万個のブロックを一度に描画できるようになったとはいえ、宇宙の広さに比べればほんの小島に過ぎません。もし1000万個、1億個のブロックをそのまま生成しようとすれば、ブラウザはメモリ不足で確実にクラッシュします。
プレイヤーが歩き続ける限り、どこまでも続く「終わらない世界」を作るためのコア技術。それが「チャンク(Chunk)」システムです。
3.1 チャンクとは何か?(世界の分割管理)
世界全体を巨大な1つの塊として扱うのではなく、「16×16ブロック」や「32×32ブロック」といった固定サイズの区画(チャンク)にグリッド状に分割して管理する考え方です。
チャンクシステムは、以下の3つのサイクルを毎フレーム(あるいは数フレームごとに)回すことで無限を表現します。
- 検知: プレイヤー(カメラ)の現在位置から、視界に入る必要なチャンク範囲(例:周囲半径5チャンク分)を計算する。
- 生成: その範囲内に「まだ存在しないチャンク」があれば、ノイズ関数を使って新しく地形データを生成し、シーンに追加する。
- 破棄: プレイヤーが移動し、遠く離れて見えなくなったチャンクは、メモリから破棄(または非表示・プール化)する。
この「プレイヤーの周囲だけを常に読み込み続ける」というランニングマシンのような仕組みによって、どれだけ移動してもメモリ使用量を一定に保つことができます。
3.2 2つの座標系を行き来する数学
チャンクシステムを構築する際、エンジニアは常に「2つの異なる座標系」を意識してコードを書くことになります。
- ワールド座標 (World Coordinates): ブロックごとの絶対的な空間位置。(x: 105, z: -42)
- チャンク座標 (Chunk Coordinates): その場所が「第何番目のチャンク区画」に属しているか。(cx: 6, cz: -3)
この2つを変換する計算式は非常にシンプルですが、世界生成の根幹を成す重要な数式です。チャンクサイズを 16 とした場合、以下のように求めます。
const CHUNK_SIZE = 16;
// ワールド座標からチャンク座標を割り出す
const chunkX = Math.floor(worldX / CHUNK_SIZE);
const chunkZ = Math.floor(worldZ / CHUNK_SIZE);
ここで重要なのは、単なる割り算ではなく必ず Math.floor()(切り捨て) を使う点です。これにより、マイナス座標(例:worldX = -5 のとき chunkX = -1)の世界へ足を踏み入れても、グリッドの境界線が破綻することなく正確に空間を分割できます。
3.3 データ構造:Mapオブジェクトの活用
生成されたチャンクは、JavaScriptの Map オブジェクトを使って管理するのがベストプラクティスです。キー(Key)にチャンク座標の文字列を指定することで、「そのチャンクが既に生成済みかどうか」を一瞬で判定できます。
// チャンクを保存する辞書
const chunkMap = new Map();
function updateChunks(playerX, playerZ) {
const currentChunkX = Math.floor(playerX / CHUNK_SIZE);
const currentChunkZ = Math.floor(playerZ / CHUNK_SIZE);
// 例:周囲1チャンク分(3x3)をチェック
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
const targetCX = currentChunkX + x;
const targetCZ = currentChunkZ + z;
const chunkKey = `${targetCX},${targetCZ}`; // 例: "6,-3"
if (!chunkMap.has(chunkKey)) {
// まだ生成されていなければ、新規にチャンクを生成する処理を呼ぶ
const newChunk = generateChunk(targetCX, targetCZ);
chunkMap.set(chunkKey, newChunk);
}
}
}
}
3.4 Three.js におけるチャンクと InstancedMesh の関係
Three.jsで実装する場合、「すべてのチャンクのブロックを1つの巨大な InstancedMesh にまとめる」のは推奨されません。なぜなら、背後にある見えないブロックまで常にGPUに送信され、カリング(描画省略)が効かなくなるからです。
正解は、「1チャンクにつき、1つの InstancedMesh を生成する」というアプローチです。 1チャンクが 32x32 = 1024 ブロックだとしたら、1024個の要素を持つ InstancedMesh をチャンクの数だけシーンに追加します。これにより、Three.js標準のフラスタムカリング(カメラの視界外にあるMeshを自動で描画スキップする機能)がチャンク単位で完璧に機能し、劇的なパフォーマンス向上に繋がります。
次回のハイライト:チャンクマネージャーの実装
無機質だった緑の配列は、色鮮やかなバイオームと無限の可能性を秘めた土台へと進化しました。ノイズの魔法が、ついに「ゲーム世界」の基盤を作り上げようとしています。
次回【Noise 入門 #44】では、今回概念を学んだ「チャンク」をThree.jsのクラス(OOP)として設計し、プレイヤーの動きに合わせて世界を動的に管理する Chunk Manager を本格的に実装します。
- Mapの活用: 生成済みチャンクの座標をキャッシュし、重複生成を防ぐ仕組み。
- 動的更新: カメラの移動を検知し、非同期で新しいブロックを生み出す最適化アプローチ。
ついにあなたの世界は、枠を越えて無限に広がり始めます。次回もお楽しみに!
💬 コメント