はじめに
Noise入門シリーズ第42回。第5集「Procedural World編」の実装フェーズが開幕します。
前回学んだチャンクの概念とFBM(Fractal Brownian Motion)をベースに、今回は Three.js で数万〜数十万個のボクセル(ブロック)を 60FPS で描画する技術 を解説します。
「鑑賞するノイズ」から、その中に入り込める「構築するノイズ」へ。 ブラウザの中に、あなただけの「最初の大地」を錬成しましょう。
前回の記事:
[Noise 入門 #41] 第5集開幕 — 終わらない世界を錬成する(Minecraft的なボクセル地形とチャンクの基礎)
Noise入門シリーズ第41回、いよいよ第5集「Procedural World編」が開幕。FBMノイズを用いたMinecraftのような無限に広がるボクセル地形の生成原理と、世界を分割管理する「チャンク(Chunk)」の概念を解説します。
https://humanxai.info/posts/noise-intro-41-voxel-terrain-chunks/1. 巨大な大地を描く壁:「Draw Call」との戦い
Minecraftのようなボクセル世界を作る時、誰もが最初に思いつくのは「ループ処理で THREE.Mesh を大量に生成し、少しずつ座標をずらして scene.add() する」というアプローチです。
// ❌ 悪魔のコード(絶対にやってはいけない)
for (let x = 0; x < 100; x++) {
for (let z = 0; z < 100; z++) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, 0, z);
scene.add(mesh);
}
}
「とりあえずこれで床を作ってみよう」とコードを実行した瞬間、たった 100 × 100 = 10,000個のブロックでも画面は激しくカクつき、PCのファンが唸り声を上げ始めます。60FPSを維持するどころか、ブラウザがフリーズしかけるかもしれません。
現代のGPUは数百万ポリゴンを平気で処理できるはずなのに、なぜたった1万個の単純な立方体で悲鳴を上げるのでしょうか?
原因はGPUの性能不足ではありません。Draw Call(ドローコール)の渋滞 です。
Draw Call とは何か?
Draw Call とは、一言で言えば 「CPUからGPUに対する『これを描画して!』という命令」 のことです。
先ほどの「悪魔のコード」を実行すると、1フレーム(約16ミリ秒)の間に、CPUは1万回も以下の作業を繰り返します。
- 状態の準備: 「次のオブジェクトはあのジオメトリで、このマテリアルで、座標はここで…」とWebGLのステート(状態)を設定する。
- 描画命令: GPUに「準備できた!描いて!」と通信(Draw Call)を送る。
これは「1万個の小さな荷物を、1台ずつ軽トラを手配して1万回に分けて運んでいる状態」に似ています。 荷物(ポリゴン)自体は軽くても、配送の手続き(CPUのAPI呼び出しオーバーヘッド)が多すぎて、運送会社(システム全体)がパンクしてしまうのです。
WebGL(およびThree.js)において、パフォーマンスを落とす最大の要因は「ポリゴン数」ではなく、この「Draw Callの回数」です。一般的に、ブラウザ上で快適なフレームレートを維持するには、Draw Callを数百回、多くても数千回程度に抑える必要があります。
パラダイムシフト:Hardware Instancing の魔法
この「1万回の軽トラ配送」という絶望的な壁を突破し、「1台の巨大な貨物列車で1万個の荷物を一度に運ぶ」ための魔法のオブジェクト。
それが THREE.InstancedMesh(ハードウェア・インスタンシング)です。
InstancedMesh を使えば、「形状(Geometry)」と「質感(Material)」という重いデータをGPUのメモリに一度だけ転送し、あとは「それぞれの位置・回転・スケールのリスト」だけを渡すことで、1万個のブロックをたった1回のDraw Callで描画 することができます。
この概念を知ることで、Three.js のパフォーマンスは劇的に跳ね上がります。 それでは、実際に InstancedMesh を使って「最初の大地」をセットアップしていきましょう。
2. InstancedMesh の実践セットアップ
InstancedMesh は、「同じジオメトリ(形状)」と「同じマテリアル(質感)」を持つ大量のオブジェクトを、たった1回のDraw Call でまとめてGPUに描画させる仕組みです。
セットアップのコード自体は非常にシンプルです。
import * as THREE from 'three';
// 1. ジオメトリとマテリアルは「1つだけ」メモリに用意する
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshLambertMaterial({ color: 0x55aa55 });
// 2. 描画したい総数(ここでは 100 x 100 = 10,000個)
const count = 10000;
// 3. InstancedMeshの生成
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
scene.add(instancedMesh);
たったこれだけで、1万個のブロックを描画する準備が整いました。 しかし、この裏側ではThree.jsが非常に重要な 「メモリの事前確保(Pre-allocation)」 を行っています。
count が意味する「器」の正体
THREE.InstancedMesh を生成する際、第3引数に渡した count(ここでは10,000)は、単なる描画回数ではありません。これは 「GPUのメモリ上に、最大1万個分の『変換行列(Matrix)』を保存する巨大な配列を確保せよ」 という命令です。
3D空間におけるオブジェクトの「位置・回転・スケール」は、4×4=16個の数値(Float32)で構成される「変換行列(Matrix4)」として表現されます。
つまり、count = 10000 と宣言した瞬間、裏側では 10000 × 16 = 160,000 個の数値を入れるための巨大な一列の配列(Float32Array)がバシッと確保されます。これが instancedMesh.instanceMatrix というプロパティの正体です。
💡 Tips: count は「最大値」である
一度確保した配列のサイズ(16万個の数値枠)は、後から増やすことができません。もし途中でブロックを2万個に増やしたくなった場合は、InstancedMesh自体を作り直す必要があります。逆に「今日は5,000個だけ描画しよう」という場合は、instancedMesh.count = 5000; と指定すれば、確保した器の半分だけを使って描画させることが可能です。
重なり合う1万個のブロック
さて、上記のコードを実行して画面を見てみましょう。 おそらく、画面の中央に緑色のブロックが「1個だけ」ポツンと置かれているように見えるはずです。
しかし、実際には1個ではありません。 1万個のブロックが (0, 0, 0) の座標に完全に重なり合い、「超高密度の豆腐」になっている状態なのです。
先ほど確保した16万個の数値配列(instanceMatrix)には、まだ初期値しか入っていないため、すべてのブロックが原点に配置されてしまっています。
この密集したブロックたちを、100×100のグリッド状に解き放ち、広大な「床」へと展開していく必要があります。そのために必須となるのが、Matrix(行列)の操作 です。
3. Matrix(行列)操作と setMatrixAt
前回、「原点に完全に重なり合い、超高密度の豆腐になっている1万個のブロック」ができました。 この密集したブロックたちを、100×100のグリッド状に解き放ち、広大な「床」へと展開していく必要があります。
それぞれのブロックを異なる座標に移動させるためには、セクション2で確保した巨大な配列(instanceMatrix)の中身を、各ブロックの「変換行列(Matrix4)」で埋める必要があります。
しかし、4×4の行列計算(回転やスケールを含めた複雑なもの)をJavaScriptで1万回手動で行うのは、非常に骨が折れますし、バグの温床になります。
そこで、Three.js には計算を楽にするための THREE.Object3D(ダミーオブジェクト) を使う便利なテクニックがあります。
3.1. ダミーオブジェクトのテクニック
この手法では、実際にシーンに追加しない「計算用のダミーオブジェクト」を1つだけ用意し、それを使い回します。
// シーンには追加しない、計算用のダミー
const dummy = new THREE.Object3D();
let index = 0; // ブロックの通し番号(0 〜 9999)
for (let x = 0; x < 100; x++) {
for (let z = 0; z < 100; z++) {
// 1. ダミーオブジェクトを目的の座標(x, 0, z)に移動
dummy.position.set(x, 0, z);
// 2. 移動情報から「変換行列」を計算させる(超重要!)
dummy.updateMatrix();
// 3. 計算された行列を、InstancedMeshの「index番目」の器にコピー
instancedMesh.setMatrixAt(index, dummy.matrix);
index++; // 次のブロックへ
}
}
このループで行っていることのキモは、dummy.updateMatrix() です。 通常、Mesh などのオブジェクトは、描画される直前に自動で行列を計算します。しかし、ダミーオブジェクトはシーンに追加されていないため、自分で updateMatrix() を呼んで、dummy.position の値を dummy.matrix(4×4行列)へと変換させる必要があります。
そして、instancedMesh.setMatrixAt(index, dummy.matrix) は、セクション2で解説した巨大な Float32Array の「index番目の区画」に対して、ダミーオブジェクトが計算した16個の行列数値を一気に書き込む命令です。
これにより、複雑な行列計算をThree.jsの内部機能に任せつつ、効率的に全ブロックの初期位置を設定できます。
3.2. 魔法の発動:needsUpdate = true
ループ処理が完了した段階では、まだ画面には何も変化が起きません。 JavaScript側の配列(CPU)の中身は書き換わりましたが、それがまだGPU(ビデオメモリ)に送信されていないからです。
最後に、以下の1行を呼ぶことで、すべての準備が完了します。
// 最後に必ずこれを呼ぶ(CPUからGPUに更新を通知)
instancedMesh.instanceMatrix.needsUpdate = true;
このプロパティを true に設定することは、いわば 「GPUへの送信ボタン」 です。Three.jsは、次のレンダリングフレームで、1万個分の新しい行列データ(16万個の数値)を一度にGPUへとアップロードし、描画命令を実行します。
これにより、原点に密集していた1万個のブロックが、一瞬にして 100 × 100 の広大な「フラットな床」へと展開されます。
この瞬間、Draw Call はたった1回。 これが、ハードウェア・インスタンシングの真の力です。
4. FBM と座標の接続:ノイズで大地を隆起させる
フラットな 100 × 100 の床が完成しました。次はいよいよ、この無機質な平面に FBM(Fractal Brownian Motion)を流し込み、自然界の複雑さを持つ「大地」へと隆起させます。
ブロックの高さ(Y座標)は、X座標とZ座標を入力としたノイズ関数によって決定されます。前回の記事で提示したボクセル地形の基本数式を思い出してください。
$y_{height} = \lfloor \text{FBM}(x, z) \times \text{amplitude} \rfloor$
この数式を JavaScript のループ処理に組み込みます。
const dummy = new THREE.Object3D();
let index = 0;
// ノイズのパラメータ(ここを調整すると地形の性質が変わる)
const scale = 0.05; // ノイズのスケール(地形のなだらかさ)
const amplitude = 15; // 地形の最大高低差
for (let x = 0; x < 100; x++) {
for (let z = 0; z < 100; z++) {
// 1. サンプリング座標を縮小し、FBMでノイズ値を取得(-1.0 〜 1.0を想定)
const noiseVal = fbm(x * scale, z * scale);
// 2. 振幅を掛けて、整数に切り捨てる(ここがボクセル化のキモ!)
const y = Math.floor(noiseVal * amplitude);
// 3. 計算された(x, y, z)をダミーに適用し、行列を更新
dummy.position.set(x, y, z);
dummy.updateMatrix();
instancedMesh.setMatrixAt(index, dummy.matrix);
index++;
}
}
// 全てのブロックの配置が終わったら、GPUへ転送
instancedMesh.instanceMatrix.needsUpdate = true;
※ fbm() 関数の内部実装には、第5回で学んだアルゴリズムを用いるか、npmで提供されている simplex-noise などのライブラリを活用して多重化(オクターブ加算)したものを想定しています。
この短いコードの中で、地形生成における 「3つの重要な魔法」 が実行されています。
4.1. 空間のズームイン(scale の役割)
ループ変数の x と z は整数の連番(0, 1, 2…)です。これをそのままノイズ関数 fbm(x, z) に渡すとどうなるでしょうか? ノイズの性質上、整数単位で大きく移動すると値が激しくランダムに変化し、隣り合うブロックの高さがバラバラの「ただのノイズテレビ画面」のような地形になってしまいます。
そこで、座標に scale(例:0.05)を掛けます。 これにより、サンプリングする座標が (0.00, 0.00) -> (0.05, 0.00) -> (0.10, 0.00) と微小に変化するようになり、ノイズ空間を「ズームイン(拡大)」して滑らかな丘陵地帯を切り取ることができるのです。
4.2. 地形のダイナミクス(amplitude の役割)
FBMの戻り値は通常、-1.0 〜 1.0(あるいは 0.0 〜 1.0)の正規化された範囲に収まります。 このままでは、どんなに高くてもY座標が「1」の平坦な地形になってしまいます。これを現実世界のスケール(ブロック何個分の高さにするか)に引き上げるのが amplitude(振幅)です。
amplitude を 15 に設定すれば、ノイズの波打ちが最大15ブロック分の高低差を持つダイナミックな山と谷へと増幅されます。
4.3. 連続から離散へ:ボクセル化のキモ(Math.floor)
そして、Minecraftのような表現において最も重要なのが Math.floor()(切り捨て処理)です。
もし Math.floor() を外すと、ブロックのY座標は 3.14 や 3.89 のような小数になり、斜面に沿ってブロックが滑らかに(しかし互いにめり込みながら)配置されてしまいます。 Math.floor() によって小数を強制的に整数へ「量子化(Quantization)」することで、連続的だったノイズの波がカクカクとした「階段状(テラス状)」に変換され、整然と積み上げられた美しいボクセル地形が誕生するのです。
5. パフォーマンスの体感:10万ブロックの海へ
理論と実装が繋がりました。いよいよ、あなたのブラウザに本来のGPUの力を解放する時です。
先ほどのコードの count を 10000 から一気に 100000(10万個)へ引き上げ、ループの範囲も x < 316、z < 316 付近まで拡大してみてください。
// 10万個の大地を錬成する
const count = 100000;
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
// ... (中略) ...
for (let x = 0; x < 316; x++) {
for (let z = 0; z < 316; z++) {
// ノイズによる高さ計算と行列の適用
}
}
コードをリロードした瞬間、視界の彼方まで続く広大なボクセル地形が、60 FPSのまま全く引っかかることなく滑らかにレンダリングされるはずです。
第1章で紹介した「ループ内で毎回 scene.add(mesh) を呼ぶ悪魔のコード」であれば、数秒でブラウザがフリーズし、タブがクラッシュしていた計算量です。しかし、InstancedMesh によって「1回のDraw Call」に圧縮された今、GPUは10万個の立方体を描画することなど準備運動にすぎないかのように、静かに、そして高速に大地を描き出します。
(※ぜひ OrbitControls や、第4集で作成した FlyControls をシーンに追加して、自ら錬成したノイズの山脈の周りを飛び回ってみてください。そのスケール感に圧倒されるはずです。)
無機質だった平面のグリッドが、FBMの数学的な揺らぎによって「山」と「谷」を形成し、立派な大地として誕生しました。これこそが、Procedural World(手続き型生成世界)の第一歩です。
次回のハイライト:世界に「色彩」と「無限」を
広大な「大地」は完成しましたが、現在は全て同じ緑色のマテリアルを使っているため、少し寂しい風景です。高い山も、深い谷も、ただの緑のブロックの塊にしか見えません。
次回【Noise 入門 #43】では、生成された「高さ(Y座標)」を利用して、この無機質な大地に生態系(バイオーム)を与えます。
- 動的なカラーリング: 海、砂浜、草地、雪山を InstancedMesh にどうやって塗り分けるか?(新たに setColorAt という強力なメソッドが登場します)
- シード値の導入: リロードするたびに全く違う地形を生成する「ランダムシード」の制御。
- 無限の世界へ(Chunking): プレイヤーの移動に合わせて、視界の先のブロックを動的に生成し、背後のブロックを破棄する「チャンクシステム」の基礎。
あなたの世界が、いよいよ色鮮やかに呼吸を始めます。 第43回、「Three.js × バイオーム実装編」でお会いしましょう。お楽しみに!
💬 コメント