[Next.js 49] InstancedMeshの表現力拡張 — シード生成・レトロパレット・動的ライティング

はじめに

前回の記事では、Three.js の InstancedMesh を駆使して、ブラウザ上で100万個以上のブロック(ボクセル)を60FPSで描画する限界テストを行いました。 今回はその無機質な緑のブロック群に、「色彩」と「環境の移ろい」を与え、世界観を劇的に変化させるアレンジを加えます。

実装元のノイズ記事:

前回の記事:

スクリーンショット:

動画(Youtube):

動画(PC):

1. シード値によるランダム生成(Mulberry32の導入)

毎回異なる地形を生成しつつ、「特定の文字列を入力すれば、世界中の誰のブラウザでも全く同じ地形が再現できる」。Minecraftなどでお馴染みの「シード値(Seed)」による世界生成システムを実装します。

JavaScript標準の Math.random() は実行するたびに結果が変わるため、この「再現性」を担保できません。そこで、初期値(シード)を与えると常に同じ順番で乱数を返す疑似乱数生成器(PRNG)を自作して導入します。

1.1 文字列を数値に変換する(ハッシュ関数)

シード値として「humanxai」のような任意の文字列を受け付けるため、まずは文字列を一意の整数(32ビット整数)に変換するハッシュ関数を用意します。

// 文字列からシード数値を生成するハッシュ関数
function generateHash(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    // 31という素数を掛けてビットを散らし、文字コードを足し込む
    hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0;
  }
  return hash;
}

これはJavaの String.hashCode() などでも使われる古典的で軽量なアルゴリズムです。少しでも文字が違えば、全く異なる巨大な整数が返ってきます。

1.2 高速な疑似乱数生成器「Mulberry32」

次に、生成したハッシュ数値をシードとして受け取り、0.0〜1.0の乱数を返す関数を作ります。ここでは、C言語などのゲーム開発界隈で「超高速かつ省メモリなPRNG」として有名な Mulberry32 アルゴリズムを採用します。

// シード付き乱数生成関数 (Mulberry32)
function seededRandom(a) {
  return function () {
    // 黄金比が絡むマジックナンバーを足して状態を更新
    var t = (a += 0x6d2b79f5);

    // ビットを右シフト(>>>)してXOR(^)を取ることで、数値を激しく掻き混ぜる
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);

    // 最後に 2の32乗(4294967296) で割って 0.0 ~ 1.0 の小数に正規化
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

💡 コードの深掘り:黒魔術の正体
一見すると暗号のようなコードですが、これは「C言語などの低レイヤー向けアルゴリズムをJavaScriptに直訳したもの」です。

  • Math.imul: JavaScriptの数値は内部的にすべて小数として扱われるため、普通に掛け算すると乱数が狂います。これを「強制的に32ビット整数の掛け算」にするための特殊な関数です。
  • 4294967296: これは 2 32(2の32乗)のことです。昔のコンパイラやブラウザの最適化(定数畳み込み)の名残で、計算負荷を1bitでも減らすために計算済みの数値をベタ書きする文化があり、その姿のまま現代にコピペで伝わっています。現在のV8エンジン等では (2 32) と書いてもパフォーマンスは全く変わらないため、可読性を優先して書き換えても問題ありません。

1.3 ノイズ宇宙の「別の座標」へジャンプする

シード付き乱数関数が完成したら、あとはこれを「ノイズを取得する座標の巨大なズレ(オフセット)」として適用するだけです。

// GUIから受け取った文字列("humanxai"など)から乱数関数を生成
const seedNumber = generateHash(params.seedText);
const randomFunc = seededRandom(seedNumber);

// 巨大なオフセット値を計算
const seedOffsetX = randomFunc() * 100000;
const seedOffsetZ = randomFunc() * 100000;

// (中略:地形生成のループ内)
for (let x = 0; x < params.gridSize; x++) {
  for (let z = 0; z < params.gridSize; z++) {

    // ノイズをサンプリングする際、基本座標にオフセットを足し込む
    const nx = (x + seedOffsetX) * params.scale;
    const nz = (z + seedOffsetZ) * params.scale;

    // FBMノイズを取得
    const noiseVal = fbm(nx, nz, params.octaves, params.persistence, params.lacunarity);
    // ...
  }
}

ノイズ関数が描く宇宙は無限に広がっています。シード値を変えるということは、ノイズの数式を書き換えるのではなく、「カメラの初期位置を10万キロ先の全く別の銀河へワープさせる」というアプローチなのです。オフセットが小さいと「さっきの地形の隣」になってしまうため、100000 のような巨大な数を掛けて大きくジャンプさせるのがコツです。

2. 高さによるバイオーム判定(C64レトロパレット)

前回のコードでは、すべてのブロックが単一の緑色で塗りつぶされた無機質な風景でした。今回は InstancedMesh に備わっている setColorAt(index, color) という強力なメソッドを利用して、ブロックの「Y座標(高さ)」に応じた生態系(バイオーム)を構築します。

2.1 ベースマテリアルは「純白(0xffffff)」のキャンバスにする

色を塗る前に、Three.js における非常に重要な前提知識があります。 InstancedMesh のベースとなる Material の色は、必ず 「純白(0xffffff)」 に設定してください。

// ❌ ベースを緑などにすると、setColorAtの色と乗算されて暗く濁る
const material = new THREE.MeshLambertMaterial({ color: 0x228b22 });

// ⭕️ ベースを純白にすることで、setColorAtの色がそのまま発色する
const material = new THREE.MeshLambertMaterial({ color: 0xffffff });

内部のVertex Shader(頂点シェーダー)では、描画時に ベースカラー × インスタンスカラー という乗算処理が行われます。ベースが純白(光の三原色がすべてMAX)のキャンバスであって初めて、後から乗せるバイオームの色が意図した通りに正確に発色します。

2.2 パフォーマンスの罠:100万回の new THREE.Color() を避ける

もう一つ、WebGLエンジニアが絶対に避けるべきパフォーマンスの罠があります。それは、「ループの中で新しくオブジェクトを生成しない」ということです。

100万回まわる地形生成ループの中で new THREE.Color() を呼び出すと、毎フレーム(あるいは生成時に)100万個の不要なオブジェクトがメモリに積み上がり、ガベージコレクション(GC)が発生してブラウザが一瞬完全にフリーズしてしまいます。

// ⭕️ ループの外で1つだけインスタンス化する
const biomeColor = new THREE.Color();

let index = 0;
for (let x = 0; x < params.gridSize; x++) {
  for (let z = 0; z < params.gridSize; z++) {
    // ... 高さを計算 ...

    // ⭕️ ループ内では .setHex() で中身の値だけを書き換えて使い回す
    if (y <= 0) {
      biomeColor.setHex(0x70a4b2);
    } else {
      biomeColor.setHex(0xffffff);
    }

    instancedMesh.setColorAt(index, biomeColor);
    index++;
  }
}

このように、1つの biomeColor インスタンスをループ外に用意し、中身の色情報だけを .setHex() で書き換えて使い回すのが、パフォーマンスを維持する鉄則です。

2.3 C64(Commodore 64)風のレトロパレットによる演出

バイオームの色を決める際、現代的なフルカラーグラデーションを使うのも美しいですが、今回はあえて「Commodore 64(C64)」などの8ビットレトロPCを彷彿とさせる固定パレットに色を制限するアプローチをとりました。

  • C64 Light Blue (0x70a4b2): 海
  • C64 Brown (0x6f4f25): 砂浜
  • C64 Green (0x588d43): 草原
  • C64 Dark Grey (0x444444): 岩肌
  • C64 White (0xffffff): 雪山

ノイズによって生み出される有機的な地形に対して、色数を極端に絞った少し渋い発色のレトロパレットを適用することで、単なるマイクラの模倣ではない、独特のプロシージャル・ボクセルアートとしての美学が際立ちます。

2.4 水面を「平ら」にならす(水平線の表現)

さらに、世界観を決定づけるもう一つの工夫が「水面の処理」です。 ただY座標が0以下のブロックを青く塗るだけだと、海底の起伏(谷底の形)がそのまま水面になってしまい、スライムの塊のような不自然な見た目になってしまいます。

そこで、水ブロックの高さの描画位置(renderY)を強制的に 0 に固定します。

// ノイズから本来の地形の高さ(y)を計算
const y = Math.floor(noiseVal * params.amplitude);

// その場所が水(Y <= 0)かどうかを判定
const isWater = y <= 0;

// 水であれば高さを 0 に固定し、陸地であれば本来の高さ(y)を採用する
const renderY = isWater ? 0 : y;

dummy.position.set(x - halfGrid, renderY, z - halfGrid);
dummy.updateMatrix();
instancedMesh.setMatrixAt(index, dummy.matrix);

この数行を加えるだけで、水面がピシッと平らになり、広大な「水平線(地平線)」が生まれます。視界の奥まで続く真っ直ぐな海面と、そこから隆起する大地とのコントラストが、地形のスケール感をより一層強調してくれます。

2.5 忘れてはいけない needsUpdate

最後に、色の設定が終わったら必ず以下のフラグを立ててください。

instancedMesh.instanceColor.needsUpdate = true;

setColorAt を100万回呼んだだけでは、そのデータはCPU側のメモリに留まったままです。このフラグを true にすることで初めて、「色情報の配列が更新されたので、次のフレームでGPUへ転送してね」というThree.jsへの指示が完了します。

3. 動的ライティングによる時間の表現

色とシード値によって個性を持った世界に、最後に「時間の流れ」を与えます。

現実世界と同じように、風景の美しさは光の当たる角度によって刻一刻と変化します。Three.jsでは、太陽光のような平行光源である DirectionalLight と、影を落とす ShadowMap の機能を使って、この劇的な環境変化を非常に低コストでシミュレーションできます。

3.1 太陽を円軌道で回す数学

すでにシーンには DirectionalLight が設置され、castShadow = true によってブロックが影を落とす設定が完了しています。あとは、毎フレーム実行される animate ループの中で、この光源(太陽)の座標を動かし続けるだけです。

function animate() {
  stats.begin();
  requestAnimationFrame(animate);

  // --- 時間を取得してライトを円軌道で回す ---
  // Date.now() で得られるミリ秒に極小の係数を掛け、ゆったりとした時間の流れを作る
  const time = Date.now() * 0.0001;

  // 三角関数(sin, cos)を用いて、光源を大きな円軌道で動かす
  dirLight.position.x = Math.cos(time) * 200;
  dirLight.position.y = Math.sin(time) * 200;
  dirLight.position.z = Math.sin(time) * 100;
  // --------------------------------------------------

  controls.update();
  renderer.render(scene, camera);
  stats.end();
}

3.2 コードの仕組み(三角関数の魔法)

ここで行っているのは、中学校の数学で習う三角関数(サイン・コサイン)を利用した円運動です。

  1. Date.now(): 常に増え続ける「現在の時間(ミリ秒)」を取得します。そのままでは太陽が高速スピンしてしまうため、0.0001 という小さな係数を掛けて、ゆったりとした天体の動きに変換しています。
  2. Math.cos と Math.sin: 増加し続ける time をこれらの関数に渡すと、-1.0 〜 1.0 の間を滑らかに往復する値に変換されます。
  3. 軌道のスケーリング: その値に 200 や 100 といった距離(半径)を掛けることで、原点(世界の中央)を中心とした巨大な円軌道を描いて太陽が空を巡るようになります。

3.3 ボクセル地形と動的シャドウの相性

たったこれだけのコード追加ですが、その視覚効果は絶大です。

立方体(ボクセル)で構成された地形は、それぞれのブロックが明確な「角」と「面」を持っているため、光の角度によって非常にシャープで美しい影を落とします。太陽が真上にある真昼の時間は谷底まで光が差し込み、太陽が沈みかける夕暮れ時には、山の稜線から長く伸びた影が大地を覆い隠していきます。

静止画だった世界に、稜線を這うように動く「影のうねり」が加わることで、圧倒的な「空気感」と「スケール感」が生み出されます。

💡 補足:夜の表現について dirLight.position.y の値がマイナスになる(太陽が地平線の下に沈む)と、世界から直接光が消え去ります。この時、シーン全体が真っ暗にならないのは、環境光である AmbientLight が常に弱い光で世界全体を底上げして照らしているからです。もし本格的な「昼夜サイクル(Day/Night Cycle)」を実装する場合は、太陽の高さに応じて背景色(空の色)や環境光の強さを連動させて変化させると、より没入感のある世界になります。

ノイズが「世界」に変わる瞬間

前回の「100万ブロックの描画限界テスト」という技術的な挑戦から一歩進み、今回は「色彩」と「時間」という命を吹き込みました。

今回実装した3つの要素を振り返ります。

  • 再現性のある宇宙(シード値): Mulberry32 という高速な疑似乱数生成器を導入し、文字列一つで特定の地形を呼び出せる「設計図」の仕組みを構築しました。
  • 情緒的な風景(バイオーム): setColorAt と C64 風のレトロパレットを組み合わせ、ボクセル特有のドット感を活かした独自の美学を表現しました。
  • 移ろう空気感(動的ライティング): 三角関数を用いた太陽の円軌道アニメーションにより、刻一刻と変化する影の表情を実装しました。

わずか220行足らずのコードですが、ここにはプロシージャル生成の核心が詰まっています。ノイズが描く「座標」に対して、適切な「色」と「光」を割り当てるだけで、数学的なデータはたちまち「旅をしたくなる風景」へと変貌するのです。

次回のハイライト:無限の地平へ

400万ブロックという広大な大地も、まだ「固定された箱」の中に過ぎません。

次回 【Next.js 50】(あるいは Noise 入門 #44)では、いよいよ本丸である 「チャンクシステム(Chunk System)」 の実装に挑みます。

  • 動的な生成と破棄: プレイヤーの歩みに合わせて、視界の先を生成し、背後の世界を消し去る最適化。
  • 終わらない世界の構築: メモリ消費を一定に保ちながら、どこまでも歩き続けられる真の無限世界のロジック。

ボクセルエンジンの真髄、チャンクマネージャーの構築。あなたの世界が、いよいよ枠を越えて無限に広がり始めます。