はじめに
前回の「Object Distribution」で、ノイズの確率マップを用いて大地に草木を群生させることができました。世界は緑で満たされましたが、今はまだ時間が止まった「静止画」の世界です。
今回は、この世界に「空気の流れ」を作り出します。時間軸(Time)と空間のノイズを組み合わせた「風のベクトル場」を定義し、Vertex Shader(頂点シェーダー)を用いて10万本の草木を波打つように揺らすVFXテクニックを解説します。
前回の記事:
[Noise 入門 #47] Object Distribution — ノイズを「確率マップ」にして植生を群生させる
地形生成の次は植生分布。Three.jsと多重ノイズを用いて、大地の環境に依存した自然な森や平原をプロシージャルに生成する「確率マップ」のテクニックを学びます。InstancedMeshを用いた描画最適化の考え方も併せて解説。
https://humanxai.info/posts/noise-intro-47-object-distribution/1. 風とは何か? — 空間と時間が作るベクトル場
前回の記事で、広大な大地に10万本の草木を群生させることに成功しました。しかし、現在の世界は完全に時間が止まった「静止画」です。どれほど美しく木々を配置しても、ピクリとも動かない森はどこか不気味で、生命力を感じさせません。
世界に命を吹き込むために必要なもの。それは「空気の流れ=風」です。
では、プロシージャルな世界において「風」をどうやって表現すればよいのでしょうか?
単なる「傾き」から「流体的なベクトル場」へ
一番簡単なアプローチは、すべての草木の頂点に対して、一律にX方向やZ方向へずらすオフセットを加えることです。しかし、これをやってみるとすぐに違和感に気づきます。すべての草木が全く同じタイミングで、同じ角度に傾く様子は、風というより「全体が傾くプラスチックのおもちゃ」のようになってしまうからです。
現実の風は、地形に沿ってうねり、通り抜ける場所によって強弱が生まれる流体的なベクトル場です。ある場所で突風が吹いているとき、少し離れた場所ではまだ風が到達しておらず静穏だったりします。
「見えない風の波」を3Dノイズで可視化する
ここで再び、私たちの強力な武器である「ノイズ」の出番です。 風という見えない波面を数学的にシミュレーションするために、空間座標(X, Z)に時間(Time)を加えた3Dノイズを使用します。
大地の特定の座標 $(x, z)$ に対して、時間 $t$ をノイズの第3の軸(Z軸またはW軸)として連続的に入力し続けます。すると、ノイズが描く「まだら模様の波」が、時間経過とともに空間上をスライドするように移動していきます。
これが、世界を通り抜けていく「風の波面」の正体です。植物は、自分の立っている座標にノイズの高い値(強い風)がやってきたときだけ大きく揺れ、低い値のときは静かに立ち尽くすようになります。
FBMによる風の解像度アップ(Global + Turbulence)
しかし、1つの単純なノイズだけで風を作ると、水中を漂う海藻のように「のっぺりとした、ゆっくりすぎる動き」になってしまいます。現実の風には、木を大きくしならせる「うねり」と、葉っぱを細かく震わせる「乱気流」が同時に存在しています。
これは[Noise 入門 #05]で学んだFBM(Fractal Brownian Motion)の考え方そのものです。周波数(Frequency)と振幅(Amplitude)が異なる複数のノイズレイヤーを足し合わせることで、風の動きをより自然界の複雑さに近づけます。
本実装では、大きく以下の2つのレイヤーをブレンドして風を構築します。
| 風の要素 | 周波数 (Frequency) | 役割 | 視覚的な効果 |
|---|---|---|---|
| Global Wind | 低い (Low) | 全体的な風の流れ | 草原全体が「ザワザワ」と大きく波打つようなうねり。風の主成分。 |
| Turbulence | 高い (High) | 局所的な乱気流 | 突風や空気の乱れ。葉の先が細かくブルブルとランダムに震える動き。 |
この2つを合成することで、「大きく揺れながらも、先端は細かく震えている」という、極めてリアルで説得力のある風のベクトル場を計算することができるのです。次項では、この見えない風の力を、実際に草木のメッシュ(頂点)に適用するための数学的アプローチを見ていきましょう。
2. 頂点シェーダーでの「根元固定」の数学
前項で「風のベクトル場」という見えない力の波面を定義しました。次はこの力を、InstancedMeshで描画された大量の草木の頂点(Vertex)に直接ぶつけていきます。
しかし、ここで非常に物理的かつ視覚的な問題が発生します。
草木が「空中に浮遊」する問題
頂点シェーダー(Vertex Shader)内で、計算した風のベクトルをそのまま草木の全頂点座標に足し合わせてしまうとどうなるでしょうか?
答えは、「世界中の草木が、地面から引っこ抜かれて空中を平行移動する」というホラー現象です。風に揺れる植物を表現するための絶対条件、それは「根元は地面に固定され、先端に行くほど大きく変形(スウェイ)する」ということです。
揺れの強さを「高さ」から計算する
これを数学的に解決するためには、各頂点のローカル座標の高さ(Y座標)を利用して、風の影響を受け入れる「重み(Weight)」を計算します。
ある頂点のローカルの高さを $y$、植物のモデル全体の高さを $h$ としたとき、揺れの強さの係数 $w$ は、最もシンプルに考えると以下のようになります。
$$w = \frac{\max(0.0, y)}{h}$$
(※ $\max(0.0, y)$ を使っているのは、地面より下にある根っこの部分が逆方向に動いてしまうのを防ぐための安全策です)
この式では、根元($y=0$)のときは $w=0$ となり風の影響を受けず、先端($y=h$)のときは $w=1$ となり風の影響を100%受けます。
しかし、この「線形(リニア)」な変化では、植物が下敷きのように「真っ直ぐなまま斜めに傾く(シアー変形)」だけの硬い動きになってしまい、自然界の植物らしさが全く出ません。
二次曲線(べき乗)で「しなり」を生み出す
現実の植物の茎や幹は、風を受けると弓なりに「しなる」ように曲がります。このしなやかなカーブを表現するためには、先ほどの式に指数(べき乗)を加えます。
$$w = \left( \frac{\max(0.0, y)}{h} \right)^n$$
ここで、指数 $n$ を $2.0$ 程度(二次曲線)に設定します。
こうすることで、根元付近では揺れが急激に抑えられ(固く保たれ)、先端に向かうにつれて指数関数的に揺れ幅が大きくなる、物理法則に近い「しなやかな曲線」を頂点シェーダー上で再現できるのです。
この係数 $w$ を、先ほど計算した風のベクトルに掛け合わせることで、初めて「大地に根を張りながら、しなやかに風に身を任せる植物」の挙動が完成します。
3. GLSLによる風の合成と頂点変形
理論と数学の準備が整いました。いよいよ、これらをGPU上で実行されるVertex Shader(頂点シェーダー)のコードへと翻訳し、10万本の草木に生命を吹き込みます。
ここでは、Simplex Noiseの関数(snoise)が既にシェーダー内で利用可能であるという前提で、頂点座標をどのように計算・更新していくのかを見ていきましょう。
見えない波をコードに落とし込む
まずは、JavaScript側から uniform 変数として渡される時間(uTime)と、各インスタンスのワールド空間における位置(instanceMatrix から取得される座標など)を利用します。風は広大な大地全体を吹き抜けるため、ローカル座標ではなくワールド座標系のXとZをノイズのサンプリングに使用するのがポイントです。
// 疑似コード: Vertex Shader内での風の計算と頂点変形
// --- 1. 時間と空間のセットアップ ---
// uTimeに風のスピードを掛けて、ノイズ空間を移動させる速度を決定
float time = uTime * uWindSpeed;
// ワールド座標のXとZを取得(インスタンスの配置座標)
// ※ Three.jsのInstancedMeshの場合、instanceMatrixから抽出します
vec3 worldPos = (instanceMatrix * vec4(position, 1.0)).xyz;
// --- 2. 風のベクトル場の計算 (FBMアプローチ) ---
// レイヤー1: Global Wind (低周波数=大きなうねり)
// vec3(x, z, time) で3Dノイズをサンプリングし、見えない波面を作る
float globalWind = snoise(vec3(worldPos.x * 0.05, worldPos.z * 0.05, time * 0.5));
// レイヤー2: Turbulence (高周波数=細かい乱気流)
// 周波数を上げ、時間変化も速くして葉の「ブルブル」とした震えを作る
float turbulence = snoise(vec3(worldPos.x * 0.5, worldPos.z * 0.5, time * 2.0));
// --- 3. 風の合成と方向付け ---
// 基本的な風の吹く方向(例: X方向と少しのZ方向)
vec3 windDirection = normalize(vec3(1.0, 0.0, 0.5));
// 2つのノイズをブレンドして最終的な「風の強さ」を決定
// (-1.0〜1.0のノイズ値を適切にスケーリング)
float windStrength = (globalWind * 0.7 + turbulence * 0.3) * uMaxWindStrength;
// --- 4. 頂点への適用(根元固定の数学) ---
// 前項で解説した「しなり」の計算。ローカルのY座標(position.y)を利用する
// ここではモデルの高さを仮に 1.0 とし、指数を 2.0 に設定
float weight = pow(clamp(position.y / 1.0, 0.0, 1.0), 2.0);
// 風の方向 × 強さ × 根元からの重み で、最終的な移動量(Displacement)を算出
vec3 windDisplacement = windDirection * windStrength * weight;
// 頂点のローカル座標に足し合わせる
vec3 finalPosition = position + windDisplacement;
// この後、gl_Position = projectionMatrix * modelViewMatrix * vec4(finalPosition, 1.0); へと続く
このコードがもたらす魔法
この数行のGLSLコードがGPU上で実行されると、劇的な変化が起こります。
時間 $t$ をノイズのZ軸(またはW軸)として流し込むことで、ノイズが描く「起伏」が空間を滑るように移動し始めます。ワールド座標 $(x, z)$ に基づいて風の強さが決まるため、隣り合う草木は「少しだけ遅れて、同じ波を共有する」ことになります。
結果として、10万本の草木が完全にバラバラに動くのではなく、見えない巨大な風の塊が草原を撫でるように通り抜けていく、極めて連動性のある美しいウェーブを描き出すのです。根元はしっかりと大地を掴みながら、先端は乱気流によって細かく震え、大きなうねりに身を任せます。
4. Three.js × InstancedMesh への組み込み(onBeforeCompile の活用)
前項のGLSLコードをそのまま実行できれば完璧ですが、Three.jsのエコシステムにおいて「1からVertex Shaderを書く(ShaderMaterial を使う)」ことには大きな代償が伴います。それは、Three.jsが誇る物理ベースレンダリング(PBR)の美しい光と影の計算をすべて手放さなければならないということです。
10万本の草木に、太陽の光(DirectionalLight)や落ちる影(ShadowMap)を適用したまま、風の揺らぎだけを追加したい。このワガママを叶えるための強力な黒魔術が、Three.jsの onBeforeCompile です。
標準マテリアルへの「外科手術」
MeshStandardMaterial などの組み込みマテリアルには、シェーダーがGPUにコンパイルされる直前に呼び出される onBeforeCompile というコールバックが用意されています。
これを利用して、Three.jsが自動生成したシェーダーコードの文字列(チャンク)に対して、正規表現や replace メソッドを使って先ほどの「ノイズ関数」と「頂点変形のロジック」を外科手術のように注入(Inject)します。
具体的なJavaScript側の実装構造は以下のようになります。
// --- 1. Uniformsの準備 ---
// アニメーションループで更新するための時間変数をオブジェクトとして定義
const customUniforms = {
uTime: { value: 0 },
uWindSpeed: { value: 1.5 },
uMaxWindStrength: { value: 0.2 }
};
// --- 2. マテリアルの生成とフック ---
const material = new THREE.MeshStandardMaterial({
color: 0x44aa44,
roughness: 0.8,
});
material.onBeforeCompile = (shader) => {
// 後からuTimeを更新できるように参照を保存
shader.uniforms.uTime = customUniforms.uTime;
shader.uniforms.uWindSpeed = customUniforms.uWindSpeed;
shader.uniforms.uMaxWindStrength = customUniforms.uMaxWindStrength;
// --- 3. Uniformsとノイズ関数の注入 ---
// シェーダーの先頭(共通部分)に変数定義とSimplex Noise関数を追加
shader.vertexShader = shader.vertexShader.replace(
'#include <common>',
`
#include <common>
uniform float uTime;
uniform float uWindSpeed;
uniform float uMaxWindStrength;
// ここにSimplex 3D Noise関数 (snoise) のGLSLコードをペースト
// float snoise(vec3 v) { ... }
`
);
// --- 4. 頂点変形ロジックの注入 ---
// Three.jsが頂点座標(transformed)を計算し始める直前のフックポイント
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`
#include <begin_vertex>
// InstancedMeshのワールド座標を取得(インスタンスマトリクスから位置を抽出)
vec3 worldPos = (instanceMatrix * vec4(position, 1.0)).xyz;
float time = uTime * uWindSpeed;
// 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;
// 根元を固定し、先端ほど揺れる二次曲線(高さ1.0を想定)
float weight = pow(clamp(position.y / 1.0, 0.0, 1.0), 2.0);
// transformed(Three.js内部の頂点変数)を直接書き換える
transformed += windDirection * windStrength * weight;
`
);
};
// --- 5. InstancedMeshの作成 ---
const instancedMesh = new THREE.InstancedMesh(geometry, material, 100000);
// (ここで行列計算と配置を行う...)
// --- 6. アニメーションループでの更新 ---
function animate() {
requestAnimationFrame(animate);
// 毎フレーム時間を進めることで風を流す
customUniforms.uTime.value = performance.now() * 0.001;
renderer.render(scene, camera);
}
影(Shadow)の破綻を防ぐための注意点
この手法で世界に風を吹かせたとき、一つだけ問題が起こる可能性があります。それは「草木は揺れているのに、地面に落ちる影が静止したまま」という現象です。
Three.jsのシャドウマッピングは、描画用のマテリアルとは別に「影を計算するための専用の深度マテリアル(customDepthMaterial)」を内部で使用しています。そのため、影も揺らすためには、上記と全く同じ onBeforeCompile の処理を mesh.customDepthMaterial にも適用してあげる必要があります。ここをしっかり押さえておくことで、光と影、そして風が完全に連動した圧倒的なプロシージャルワールドが完成します。
まとめ — 時間軸がもたらす「生命の息吹」
今回は、静止していた大地に「風」という目に見えない生命を吹き込むプロセスを解説しました。
ただ頂点を一定方向に動かすのではなく、「時間(Time)」と「空間(Space)」を掛け合わせたノイズ(FBM)を流体的なベクトル場として扱い、そこに「根元の固定」と「二次曲線のしなり」という物理的な数学を加えること。これにより、10万本の草木がまるで互いに連携しているかのように、美しく波打ち始めました。
また、Three.jsの onBeforeCompile を用いたシェーダーの拡張は、標準マテリアルが持つPBR(物理ベースレンダリング)の美しい光と影の恩恵を受けながら、プロシージャルな頂点変形を両立させる、非常に強力で実践的なエンジニアリングの武器となります。
時間という次元(4D)が加わったことで、私たちのプロシージャルワールドは単なる「背景」から「生きた環境」へと進化を遂げました。
次回予告:[Noise 入門 #49] Boids × Flow Field — 風に乗る「鳥の群れ」を空に放つ
大地が削り出され、植物が群生し、そこに風が吹きました。 環境としての世界は完成しつつありますが、この空にはまだ「動く生き物」がいません。
次回は、この生きた環境に「自律的な生命」を誕生させます。 個々の個体が周囲と協調して動く「Boids(群れ)アルゴリズム」に、今回構築した「風のベクトル場(Flow Field)」やCurl Noiseを掛け合わせます。ただランダムに飛ぶのではなく、気流を読み、風に流されながらも群れをなして大空を舞う鳥たちのGPGPUシミュレーションをThree.jsで実装します。
環境と生命が交差する、プロシージャルワールドのさらなる深淵へ。お楽しみに!
💬 コメント