[Next.js #54] Procedural Wind & Flow — Three.jsで草木を揺らし、雲を流す実装

はじめに

前回の「[Next.js #53] Procedural Object Distribution」で、ノイズを用いた確率マップにより、広大な大地に木々や草を群生させることに成功しました。

しかし、生成された世界は完全に時間が止まった「静止画」です。

今回はこの世界に「時間」と「空気の流れ」を与えます。
理論編である【[Noise 入門 #48] Wind & Flow — 風向ノイズで草木を揺らす】で解説した数学的アプローチを元に、Three.js の onBeforeCompile を使って実際に10万本の草木と空の雲を動かす実装をまとめていきます。

モデルデータは、sketchfabから著作権フリー素材をお借りしています。

制作者:Miaru3d 様

  model: {
    name: "Pearl Drone - Splatoon Side Order Trailer",
    author: "Miaru3d",
    url: "https://sketchfab.com/miaru3d",
    path: "./pearl_drone_-_splatoon_side_order_trailer.glb",
    scale: 8.0,
  }

スクリーンショット:

[Next.js #54] Procedural Wind & Flow — Three.jsで草木を揺らし、雲を流す実装

動画(YouTube):

動画(PC):

1. 風の Uniforms と GLSL ノイズの準備

Three.js の標準マテリアル(MeshLambertMaterial や MeshPhongMaterial)の光と影の恩恵を受けつつ、頂点だけを動かすためには onBeforeCompile によるシェーダーの拡張が必要です。

まずは、GUI から風速や強さをリアルタイムに操作できるよう、共通の Uniforms を定義し、GLSL用の 3D Simplex Noise の文字列を用意しておきます。

// 風のパラメータとUniforms
const windUniforms = {
  uTime: { value: 0 },
  uWindSpeed: { value: params.windSpeed }, // 例: 0.25
  uMaxWindStrength: { value: params.windStrength }, // 例: 10.0
};

// アニメーションループ内で毎フレーム更新する
// windUniforms.uTime.value = performance.now() * 0.001;

// GLSL内に注入する Simplex Noise 3D のコード(長いため関数名のみ記載)
const simplex3DCode = `
// ... (snoise関数の実装) ...
float snoise(vec3 v) { ... }
`;

2. 草木を風で揺らす(根元固定の頂点変形)

植物を風で揺らす際の最大のポイントは、「根元は地面に固定し、先端に行くほど二次曲線的に大きくしならせる」という点です。また、幹(Trunk)は葉(Leaves)よりも揺れにくくする物理的な調整も加えます。

以下の関数は、渡されたマテリアルに対して風の頂点シェーダーを注入します。

function applyWindShader(material, yMin, height, isTrunk = false) {
  material.onBeforeCompile = (shader) => {
    shader.uniforms.uTime = windUniforms.uTime;
    shader.uniforms.uWindSpeed = windUniforms.uWindSpeed;
    shader.uniforms.uMaxWindStrength = windUniforms.uMaxWindStrength;

    shader.vertexShader = shader.vertexShader.replace(
      '#include <common>',
      `#include <common>
      uniform float uTime;
      uniform float uWindSpeed;
      uniform float uMaxWindStrength;
      ${simplex3DCode}
      `
    );

    shader.vertexShader = shader.vertexShader.replace(
      '#include <begin_vertex>',
      `#include <begin_vertex>
      // 1. インスタンスのワールド座標を取得
      vec3 worldPos = (instanceMatrix * vec4(position, 1.0)).xyz;
      float time = uTime * uWindSpeed;

      // 2. FBMによる風の波面計算(大きなうねり+細かな乱気流)
      float globalWind = snoise(vec3(worldPos.x * 0.05, worldPos.z * 0.05, time * 0.5));
      float turbulence = snoise(vec3(worldPos.x * 0.5, worldPos.z * 0.5, time * 2.0));

      vec3 windDirection = normalize(vec3(1.0, 0.0, 0.5));
      float windStrength = (globalWind * 0.7 + turbulence * 0.3) * uMaxWindStrength;

      // 3. 根元固定のウェイト計算(ローカルY座標から二次曲線を生成)
      float weight = pow(clamp((position.y - (${yMin.toFixed(1)})) / ${height.toFixed(1)}, 0.0, 1.0), 2.0);
      ${isTrunk ? 'weight *= 0.4;' : ''} // 幹は葉より硬くする

      // 4. インスタンスのY軸回転を考慮し、風の方向をローカル空間へ変換
      vec3 right = normalize(instanceMatrix[0].xyz);
      vec3 forward = normalize(instanceMatrix[2].xyz);
      vec3 localWind = vec3(dot(windDirection, right), 0.0, dot(windDirection, forward));

      // 5. 頂点変形を適用
      transformed += localWind * windStrength * weight;
      `
    );
  };
}

影も揺らすための注意点

Three.js では、落ちる影(ShadowMap)は描画用のマテリアルとは別の深度マテリアルで計算されます。影も風に合わせて揺らすためには、MeshDepthMaterial を作成し、同様にシェーダーを注入して customDepthMaterial にセットする必要があります。

const leavesMat = new THREE.MeshLambertMaterial({ color: 0x2d5a27 });
const leavesDepth = new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking });

applyWindShader(leavesMat, -1.5, 3.0, false);
applyWindShader(leavesDepth, -1.5, 3.0, false);

// ... InstancedMesh生成時 ...
leavesMesh.customDepthMaterial = leavesDepth;

3. 空を流れる雲(無限ループするドリフト)

木が風で揺れるようになったら、次はその風に乗って空を流れる「雲」を実装します。 雲は地面に固定されていないため、根元のウェイト計算は不要です。単純に風のベクトルに沿って transformed 座標をオフセットさせます。

しかし、ここで問題が発生します。時間が経つにつれてオフセット量が大きくなり、雲がチャンク(描画エリア)の外へ飛び出して消えてしまうのです。 これを解決するために、描画距離(renderDistance)から世界全体の幅を計算し、Modulo(余り)計算を用いて画面端から反対側へワープ(ループ)させる処理を組み込みます。

function applyCloudDriftShader(material) {
  material.onBeforeCompile = (shader) => {
    shader.uniforms.uTime = windUniforms.uTime;
    shader.uniforms.uWindSpeed = windUniforms.uWindSpeed;
    shader.uniforms.uMaxWindStrength = windUniforms.uMaxWindStrength;

    // (Uniformsの追加処理は省略)

    shader.vertexShader = shader.vertexShader.replace(
      '#include <begin_vertex>',
      `#include <begin_vertex>
      vec3 windDirection = normalize(vec3(1.0, 0.0, 0.5));

      // 描画範囲に合わせてループ範囲を動的に定義
      // (renderDistance * 2 + 1) * chunkSize
      float wrapRange = ${params.chunkSize.toFixed(1)} * (${(params.renderDistance * 2 + 1).toFixed(1)});

      float driftFactor = 0.5;
      vec3 drift = windDirection * uTime * uWindSpeed * uMaxWindStrength * driftFactor;

      // Modulo (余り) 計算で無限ループを作る
      float offsetX = mod(drift.x, wrapRange);
      float offsetZ = mod(drift.z, wrapRange);

      transformed.x += offsetX;
      transformed.z += offsetZ;

      // 境界を跨いだときのテレポート処理(負の座標対応)
      if(transformed.x > wrapRange * 0.5) transformed.x -= wrapRange;
      if(transformed.x < -wrapRange * 0.5) transformed.x += wrapRange;
      if(transformed.z > wrapRange * 0.5) transformed.z -= wrapRange;
      if(transformed.z < -wrapRange * 0.5) transformed.z += wrapRange;
      `
    );
  };
}

カリングの無効化

シェーダー内で頂点を大きく移動させた場合、Three.js は「メッシュが画面外に出た」と誤認して描画をスキップ(カリング)してしまうことがあります。雲のメッシュに対しては以下の設定を忘れずに行います。

cloudMesh.frustumCulled = false;

4. ドローンへの風の影響(仕上げ)

最後に、プレイヤーが操作するドローンにも風の影響を与えます。これを行うことで、世界に対する「没入感」が格段に向上します。

animate ループ内で、サイン波と風の強さを掛け合わせて、機体が僅かに煽られるような揺れをシミュレートします。

// animate() 関数内
if (airplaneGroup) {
  // 風の強さに応じて、左右に揺れ、上下にふわふわと浮遊する
  const windEffect = Math.sin(performance.now() * 0.003) * params.windStrength * 0.0003;
  airplaneGroup.rotation.z += windEffect;
  airplaneGroup.position.y += Math.cos(performance.now() * 0.001) * 0.2;
}

おわりに

これで、ノイズ関数で生成された広大な地形に、風が吹き、植物がなびき、雲が流れる「生きた環境」が完成しました。単なるスタティックな背景が、プロシージャルなアニメーションによって流体的な世界へと変貌する過程は、実装していて非常に楽しい部分です。

環境としての土台はこれで整いました。次回は、この風と気流が流れる空を舞台に、自律的に飛び交う「鳥の群れ(Boids アルゴリズム)」の実装へと進んでいきます。お楽しみに!