はじめに
前回の「[Next.js #53] Procedural Object Distribution」で、ノイズを用いた確率マップにより、広大な大地に木々や草を群生させることに成功しました。
[Next.js 3D #53] プロシージャルな植生配置とGUIによる動的コントロール — 確率マップで森を錬成する
ボクセル地形の地表に木や草を自然に群生させる「Object Distribution」の実装記録。lil-gui を導入し、森の広がりや木々の密度をスライダーで直感的に操作できるプロシージャル・デザインの醍醐味を紹介します。
https://humanxai.info/posts/nextjs-53-procedural-object-distribution-gui/しかし、生成された世界は完全に時間が止まった「静止画」です。
今回はこの世界に「時間」と「空気の流れ」を与えます。
理論編である【[Noise 入門 #48] Wind & Flow — 風向ノイズで草木を揺らす】で解説した数学的アプローチを元に、Three.js の onBeforeCompile を使って実際に10万本の草木と空の雲を動かす実装をまとめていきます。
[Noise 入門 #48] Wind & Flow — 風向ノイズで草木を揺らす
InstancedMeshで配置された大量の草木に対し、GLSLのVertex Shaderとノイズ関数を用いて自然な風の揺らぎ(Wind & Flow)を実装する手法。Three.jsのonBeforeCompileを活用したプロシージャルなアニメーションの …
https://humanxai.info/posts/noise-intro-48-wind-flow-vertex-shader/モデルデータは、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,
}
Miaru3d
View the profile and 3D models by Miaru3d. Character artist
https://sketchfab.com/miaru3dスクリーンショット:
動画(YouTube):
Three.jsで10万本の草木を揺らす — Procedural Wind & Flow(GLSL / Noise)
Three.jsとGLSLを用いて、10万本の草木をリアルタイムで揺らし、雲を流す「風のシミュレーション」を実装しました。本動画では、ノイズ(Noise)と時間(Time)を組み合わせた「風のベクトル場(Flow Field)」を構築し、Vertex Shaderで頂点を変形させることで、自然な風の揺らぎを表現し...
https://www.youtube.com/shorts/Lk5gFe7uKm8動画(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 アルゴリズム)」の実装へと進んでいきます。お楽しみに!
💬 コメント