はじめに
地形を作り、大気を纏わせ、雲を流した第34回。美しい惑星が完成しましたが、私たちはまだこの星を「外から眺めているだけ」でした。
Noise 入門シリーズ第35回。今回はいよいよ、この気象システムに干渉する「神の視点(インタラクション)」を実装します。Three.jsの Raycaster で取得した3D座標を起点に、第8回で学んだ Curl Noise(回転ノイズ) をベクトル場として発生させ、流れる雲のFBMノイズを局所的にかき乱します。
自らの手で大気をかき混ぜ、惑星の表面に「巨大な台風の渦」を錬成しましょう。
前回の記事:
[Noise 入門 #34] Procedural Clouds on Sphere — 惑星を包み込む「生きた雲海」を錬成する
Noise 入門シリーズ第34回。地形用の球体の外側にもう一つの球体(Cloud Sphere)を配置し、4D FBMノイズで流れる雲を表現します。透過処理(アルファブレンド)の最適化や、GPU上で高速に影をフェイクする空間ハックまで、生きた惑星を錬成する実装 …
https://humanxai.info/posts/noise-intro-34-procedural-clouds-sphere/1. Three.js:神の指先(Raycaster)で座標を取得する
私たちが普段操作しているマウス座標は、モニター上の「2D」のピクセル座標に過ぎません。これを、Shaderが理解できる3D世界のワールド座標へ変換し、リアルタイムに送り続ける仕組みを構築します。
実装のステップは以下の3段階に分かれます。
1.1 NDC(正規化デバイス座標)への変換
まず、マウスの位置を取得します。画面の左上が (0, 0)、右下が (width, height) となるピクセル座標を、Three.js の Raycaster が解釈できる NDC(正規化デバイス座標:Normalized Device Coordinates) に変換する必要があります。
NDCは、画面の中心を原点とし、X軸とY軸がそれぞれ $[-1, 1]$ の範囲に収まる座標系です。変換の計算式は以下のようになります。
$x = \left(\frac{\text{clientX}}{\text{window.innerWidth}}\right) \times 2 - 1$ $y = -\left(\frac{\text{clientY}}{\text{window.innerHeight}}\right) \times 2 + 1$
※Y軸は、ブラウザのピクセル座標(下方向がプラス)と3D空間のNDC(上方向がプラス)で向きが逆になるため、マイナスを掛けて反転させます。
1.2 Raycasterによる交差判定とUniform設計
マウスのNDC座標が求まったら、カメラの位置からその座標に向かって見えない光線(Ray)を撃ち出し、雲のメッシュ(cloudSphere)とぶつかるポイントを計算します。
ここで重要になるのが、「マウスが惑星に触れていない時」の処理です。 常に交差点の座標を送り続けると、マウスを画面外に外した瞬間に台風が不自然な位置に取り残されてしまいます。これを防ぐため、Shaderに送るUniform変数には、座標(vec3)だけでなく、現在触れているかどうかの判定フラグ(float)も一緒に渡す設計にします。
1.3 Three.js側の実装コード
実際のJavaScript(Three.js)の実装コードは以下のようになります。
import * as THREE from 'three';
// 1. Raycasterとマウスベクトルの初期化
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// ShaderMaterialのUniforms定義
const cloudUniforms = {
uTime: { value: 0 },
uMousePos: { value: new THREE.Vector3(0, 0, 0) },
uIsInteracting: { value: 0.0 } // 0.0 = 触れていない, 1.0 = 触れている
};
// 雲のメッシュ(事前に作成・配置済みと想定)
// const cloudSphere = new THREE.Mesh(geometry, cloudMaterial);
// 2. マウス(ポインター)イベントの取得とNDC変換
window.addEventListener('pointermove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
// 3. アニメーションループ(毎フレームの更新)
const tick = () => {
// カメラからマウス座標へ向かって光線をセット
raycaster.setFromCamera(mouse, camera);
// cloudSphereとの交差判定
const intersects = raycaster.intersectObject(cloudSphere);
if (intersects.length > 0) {
// 交差している場合:ワールド座標を渡し、フラグをONにする
cloudUniforms.uMousePos.value.copy(intersects[0].point);
cloudUniforms.uIsInteracting.value = 1.0;
} else {
// 交差していない場合:フラグをOFFにする
cloudUniforms.uIsInteracting.value = 0.0;
}
// 時間の更新
cloudUniforms.uTime.value += 0.01;
renderer.render(scene, camera);
requestAnimationFrame(tick);
};
tick();
ポイントの解説
- pointermove イベントの使用: mousemove ではなく pointermove を使うことで、スマホやタブレットのタッチ操作でも同じように台風を引き起こすことができるようになります。
- uIsInteracting の重要性: このフラグをShader側で利用することで、「台風の強さ」を 0 にフェードアウトさせたり、処理自体をスキップさせたりすることが可能になり、表現の自然さとパフォーマンスの両方に貢献します。
これで、Three.js側から「今、世界のどの座標に触れているか」という情報(uMousePos)と「介入中であるか」(uIsInteracting)をShaderに送り込むパイプラインが完成しました。
続いて、セクション2の「GLSL:台風の『目』と影響範囲を定義する」の実装詳細へ進みます。
Fragment Shader内で smoothstep を使って、この uMousePos を中心とした影響範囲のマスクを作成する具体的なコードを組み上げます。
2. GLSL:台風の「目」と影響範囲を定義する
Shaderの世界では、計算はすべてのピクセル(または頂点)に対して並列で実行されます。 そのため、「今計算しているこの場所」が、神の指先(uMousePos)からどれほど離れているかを知る必要があります。
2.1 距離の計算とマスクの生成
現在のピクセル座標 $\mathbf{p}$(GLSLでは vWorldPos などとしてVertex Shaderから渡されるワールド座標)と、マウスの交差座標 $\mathbf{m}$(uMousePos)との距離 $d$ を求めます。
数学的には以下のようになります。
$$d = |\mathbf{p} - \mathbf{m}|$$
GLSLにはこの距離を計算するための組み込み関数 distance() が用意されているため、非常にシンプルに記述できます。
次に、この距離 $d$ に応じて台風の影響力を決定します。中心に近いほど強く、遠ざかるほど影響がゼロになるよう、smoothstep 関数で滑らかなグラデーションのマスク(重み付け $w$)を作ります。影響半径を $R$ とした場合の式はこちらです。
$$w = 1.0 - \text{smoothstep}(0.0, R, d)$$
2.2 インタラクションフラグの統合
セクション1でThree.jsから送った uIsInteracting フラグをここで掛け合わせます。 これにより、マウスが惑星から外れている時(uIsInteracting == 0.0)は、どれだけ距離が近くても影響力 $w$ が強制的に 0.0 になり、ノイズへの干渉が完全にシャットアウトされます。
2.3 GLSLの実装コード
これらをまとめたFragment Shader(またはVertex Shader)の一部がこちらです。
// Three.jsから渡されるUniform変数
uniform vec3 uMousePos;
uniform float uIsInteracting;
// Vertex Shaderから渡される現在のワールド座標
varying vec3 vWorldPos;
void main() {
// 1. 台風の影響半径(好みに応じて調整)
float radius = 2.5;
// 2. 現在のピクセルとマウス座標との距離 d を計算
float d = distance(vWorldPos, uMousePos);
// 3. 影響力のマスク w を生成 (0.0 〜 1.0)
// 距離が 0.0 なら 1.0(中心)、radius以上なら 0.0(範囲外) になる
float w = 1.0 - smoothstep(0.0, radius, d);
// 4. マウスが触れていない場合は影響力を強制リセット
w *= uIsInteracting;
// --- 確認用デバッグ出力 ---
// マスクが正しく作られているか白黒で画面に出力して確認してみる
// gl_FragColor = vec4(vec3(w), 1.0);
}
ポイントの解説
- smoothstep の魔法: 単純な線形補間(mix や一次関数)ではなく smoothstep を使うことで、台風の境界線がふんわりと自然に周囲の大気に溶け込みます。エルミート補間による「滑らかな減衰」は、自然界の表現において必須のテクニックです。
- デバッグの重要性: 複雑なノイズを組む前に、まずは gl_FragColor = vec4(vec3(w), 1.0); のようにマスクそのものを画面に出力し、「マウスに追従して白いぼんやりとした円が描画されるか」を確認するのが、Shader実装における鉄則です。
これで、空間のどこを、どれくらいの強さでねじ曲げればいいのかを示す「見えない影響マップ」が完成しました。
次はセクション3ですね。いよいよ第8回で学んだ Curl Noise を召喚し、このマスク $w$ を使って空間座標そのものを強制的に渦巻かせます(Domain Warping)。
3. Curl Noiseの再召喚:ベクトル場で雲をかき回す
セクション2で作成したマスク(重み $w$)は、いわば「神の力が及ぶ範囲」を示すただの数値です。この範囲内の大気(雲)をどう動かすか? ここで、第8回で学んだ Curl Noise(回転ノイズ) を再召喚します。
Curl Noiseは、発散ゼロ(Divergence Free)という流体力学的な性質を持ち、空間上に「どこにも消えない自然な渦巻き(ベクトル場)」を作り出します。これを利用して、雲の形を決定している空間座標そのものをねじ曲げます。
3.1 座標を歪める(Domain Warpingの応用)
通常、プロシージャルな雲は以下のように、現在のワールド座標 $\mathbf{p}$ をそのままFBM関数に渡して生成します。
float cloudDensity = fbm(p);
しかし今回は、第6回で学んだ Domain Warping(ドメインワーピング) の技術を応用します。 マウス周辺の座標 $\mathbf{p}$ に対して、Curl Noiseから得た3Dベクトル $\mathbf{v}_{curl}$ を足し合わせてからFBMに渡すのです。
計算式は以下のようになります。
ここで、
- $\mathbf{p}_{warped}$ : 歪んだ後の新しい座標
- $\mathbf{v}_{curl}(\mathbf{p})$ : その地点におけるCurl Noiseのベクトル(渦の向きと強さ)
- $w$ : セクション2で計算した影響力のマスク(0.0 〜 1.0)
- $S$ : 台風の強さ(Strength)を決める任意の係数
3.2 GLSLでの実装コード
先ほどのセクション2のコードに、Curl Noiseによる座標のねじれ(Warping)を追加してみましょう。
// --- 事前定義関数(第8回、第5回で実装済みのもの) ---
// vec3 curlNoise(vec3 p);
// float fbm(vec3 p);
// セクション2の続き...
void main() {
// 1 & 2. 影響範囲の計算
float radius = 2.5;
float d = distance(vWorldPos, uMousePos);
float w = 1.0 - smoothstep(0.0, radius, d);
w *= uIsInteracting; // マウス干渉フラグ
// 3. 台風のパラメータ設定
float stormStrength = 1.5; // 渦の強さ
float curlFreq = 2.0; // 渦の細かさ
// 4. Curl Noiseによるベクトル場の取得
// ※ 時間(uTime)を足すことで、渦そのものも常にうねり続けるようにする
vec3 curlVec = curlNoise(vWorldPos * curlFreq + uTime * 0.5);
// 5. Domain Warping:座標をねじ曲げる
// 影響範囲(w)内でのみ、強さ(stormStrength)に応じて座標がズレる
vec3 warpedPos = vWorldPos + (curlVec * w * stormStrength);
// 6. 歪んだ座標を使って雲の密度(FBM)を計算
float cloudDensity = fbm(warpedPos);
// --- 描画処理へ ---
// 例:密度をベースに雲の色を出力
// gl_FragColor = vec4(vec3(1.0), cloudDensity);
}
ポイントの解説
- マスク $w$ の役割: curlVec * w と掛け算しているのが最大のポイントです。これにより、マウスから遠い場所($w = 0.0$)では warpedPos が元の vWorldPos と全く同じになり、通常の静かな雲が描画されます。マウス周辺でのみ座標が激しくかき回されます。
- 時間の付加: curlNoise の引数に uTime を加えることで、マウスを止めていても台風の渦が内部でゴロゴロとうねり続ける、生きた気象システムになります。
これで、神の視点から惑星の大気をかき混ぜる「Interactive Storm」のコアとなるロジックが完成しました。単なる数式の組み合わせが、GPUの力によって圧倒的なスケールの自然現象へと昇華される瞬間です。
4. 動的インタラクションの調整と最適化
数式上での台風は完成しましたが、体験としてのリアリティを高めるためには、時間経過とパフォーマンスのバランスを取る「最適化」が不可欠です。
4.1 時間経過(Time)による「うねり」の付加
セクション3でも少し触れましたが、Curl Noiseの計算に時間(uTime)を組み込むことは非常に重要です。時間を足さない場合、マウスを止めた瞬間に台風の渦も「静止画」のようにピタッと止まってしまいます。
$$\mathbf{v}_{curl} = \text{curlNoise}(\mathbf{p} \times \text{freq} - \text{uTime} \times \text{speed})$$
このように uTime を引数に加えることで、空間の歪みそのものが常に変化し続け、マウスを動かさなくても内部でゴロゴロと雲が湧き上がるような、生きた嵐を表現できます。マイナス方向(- uTime)に時間を進めることで、渦が内側へ巻き込むような動きを強調するのもテクニックの一つです。
4.2 状態を持たない軽量な「余韻」の表現
リアルな流体シミュレーション(第29回で扱ったPing-PongバッファによるGPGPU実装)であれば、マウスが通り過ぎた後も「風の速度(Velocity)」が空間に残り、雲がゆっくりと元の状態に戻っていく余韻を描画できます。
しかし今回は、あえて状態を保存しない純粋な座標Warping(Domain Warping)で実装しています。 これには大きな理由があります。テクスチャの読み書き(FBO)を行わないため、スマートフォンや低スペックなGPUでも圧倒的に軽量(高速)に動作するからです。
状態を持たずに「引きずられるような余韻」をフェイクするには、Three.js側からマウスの「移動方向と速度(uMouseVelocity)」のベクトルを渡し、影響範囲のマスクの中心を進行方向の逆へ少しズラす(Skewingする)などの空間ハックを用いることで、流体力学を使わずにそれらしい視覚効果を捏造することができます。
まとめ:鑑賞するノイズから、触れて遊ぶノイズへ
お疲れ様でした。これで「Interactive Storm」の実装は完了です。
- Three.jsのRaycaster で神の指先となる3D座標を取得し、
- smoothstep で影響範囲のマスクを作り、
- Curl Noise のベクトル場で空間座標をねじ曲げ(Domain Warping)、
- その歪んだ座標で FBMノイズ をサンプリングする。
これまで第1集〜第2集で一つずつ積み上げてきた数学とアルゴリズムの知識が、WebGLというキャンバス上で完全に一つに繋がり、ユーザーの意思で大気をかき回せるシステムが完成しました。計算の塊でしかない数式が、触れられる「自然現象」へと昇華する瞬間を楽しんでいただけたでしょうか。
次回予告:[Noise 入門 #36] Procedural Lightning — 台風の目に「閃光」を落とす
巨大な台風を生み出すことには成功しました。しかし、荒れ狂う嵐には「光」が足りません。 次回は、FBMノイズの鋭い閾値(Threshold)を利用して、雲の内部でランダムに瞬く「稲妻(Lightning)」をプロシージャルに実装します。光と影を明滅させ、環境全体を照らし出すダイナミックなライティングに挑戦します。お楽しみに!
💬 コメント