はじめに
Noise 入門シリーズ第28回。これまで「面」や「立体」を歪ませてきたノイズを、今度は「何万もの独立した点(パーティクル)」に適用します。
第8回で学んだ Curl Noise(回転ノイズ) を再召喚し、流体シミュレーションを使わずに、魔法のような光のうねりや煙の軌跡を GLSL と Three.js で錬成する手法を深掘りします。
[Noise 入門 #27] Normal Recomputation — 歪んだ世界に正しい光と影を取り戻す
Noise 入門シリーズ第27回。頂点をノイズで動かしただけでは、元のツルツルの法線が維持されてしまい光と影が破綻します。近隣頂点のサンプリングと偏微分・外積を用いた「法線再計算(Normal Recomputation)」の数学的アプローチとGLSL実装を深 …
https://humanxai.info/posts/noise-intro-27-normal-recomputation/1. 「面」から「点」へ — 頂点を解き放つ
これまで Noise入門シリーズでは、地形(Terrain)を隆起させたり、水面(Water)を波立たせたりするために、頂点同士が線で結ばれた「ポリゴンの網目(Mesh)」を主に扱ってきました。
しかし、この「頂点同士の繋がり(面)」を断ち切り、一つ一つの頂点を空間に漂う独立した「粒子(Particle)」として扱ったらどうなるでしょうか?
束縛からの解放:Mesh から Points へ
Three.js の世界において、通常のオブジェクトは THREE.Mesh を使って描画されます。これは頂点(Vertex)の座標情報を元に、3つの頂点を結んで三角形の「面(Face)」を作り、その中を塗りつぶす(ラスタライズする)という処理を行っています。
一方、頂点を結ばず、単なる「点の集合」として描画するのが THREE.Points です。
- Mesh(面):布や地形のように、連続した表面を持つ。面の裏表や光の反射(法線)を計算する必要がある。
- Points(点):蛍の群れや宇宙の星屑のように、空間に独立して存在する座標の集合。面を描画する重い計算が不要。
この THREE.Points を用いることで、GPU は数万から数十万個という膨大な数の頂点を、驚くほど低負荷で一気に処理・描画できるようになります。制約から解き放たれた頂点は、画面を埋め尽くす圧倒的な情報量となって私たちの目に飛び込んできます。
なぜ物理演算ではなく「ノイズ」で動かすのか?
何万ものパーティクルを空間に配置したとします。次に考えるのは「それをどう動かすか」です。
一般的に、重力(下に向かって落ちる)や単純な風(一定方向に流れる)といった基本的な物理演算を加えるのが定石です。しかし、それだけではすべての粒子が同じ方向に動いてしまい、単調な「ただの雨」や「ただの雪」にしかなりません。
ここに、「ノイズ」という魔法を介入させます。
空間(3D座標)全体に、ノイズ関数から生成した「見えないベクトル場(力の流れる向きと強さのマップ)」を敷き詰めます。そして、その空間内にパーティクルを放り込むのです。
ノイズ・パーティクルの視覚的効果
- 局所的なランダム性:空間の座標ごとに力の向きが異なるため、粒子一つ一つが予測不能な動きをする。
- 全体的な連続性(滑らかさ):完全なランダム(乱数)とは違い、ノイズは「滑らかに変化する」性質を持つため、近くにいる粒子同士は似た軌道を描く。
結果として、粒子たちは完全にバラバラに散らばるのではなく、見えない川の急流に飲まれたり、空中の見えない渦に巻き込まれたりするように、「有機的なうねり」や「束になった流れ」を生み出します。
単純な物理シミュレーションでは到底たどり着けない、まるで生命の息吹や魔法の奔流のような視覚体験。それが、パーティクルシステムにノイズを掛け合わせる最大の理由です。
2. なぜ Curl Noise なのか?(流体としてのノイズ)
パーティクルを動かすための「見えないベクトル場(風の向き)」を作る際、「じゃあ、使い慣れた 3D Simplex Noise をそのまま使えばいいのでは?」と思うかもしれません。
しかし、ここに大きな罠が潜んでいます。
単純なノイズが抱える「ダマ」の問題
単なる Simplex Noise などから取得したベクトル(向きと強さ)でパーティクルを動かし続けると、視覚的に致命的な問題が発生します。
ある空間の座標では「すべての力の矢印が一点に向かう(吸い込み)」状態になり、また別の座標では「すべての矢印が外に逃げる(湧き出し)」状態になってしまうのです。 結果として、何万もの美しい粒子たちが、時間が経つにつれて特定の場所にギュッと密集して「不自然なダマ」になったり、まるで見えない壁に押し付けられたように停滞したりしてしまいます。これでは、美しい魔法の粉や煙には見えません。
救世主 Curl Noise と「発散ゼロ」の魔法
ここで満を持して再召喚されるのが、第8回 [#08] で解説した Curl Noise(カールノイズ / 回転ノイズ) です。
[Noise 入門 #08] Curl Noise — ノイズは“流れ”になる。流体シミュレーションを使わずに流体を作る
Noise 入門シリーズ第8回。静止したノイズを「動き(ベクトル場)」に変換する Curl Noise (回転ノイズ) を解説。スカラー場、勾配、Curl演算子を用いて、発散ゼロ(Divergence Free)の流体挙動をGLSLで実装する方法をまとめます。
https://humanxai.info/posts/noise-intro-08-curl-noise/Curl Noise の数学的な最大の特徴は、「発散ゼロ(Divergence Free)」 という性質を持っていることです。
💡 発散ゼロ(Divergence Free)とは?
空間上に「水が湧き出す蛇口(Source)」も「水を吸い込む排水溝(Sink)」も存在しない状態のこと。
Curl Noise によって作られた力の場には、吸い込み口も湧き出し口もありません。これはつまり、「粒子がどこか一点にブラックホールのように密集したり、虚無に消えたりすることが絶対にない」 ということを意味します。
粒子同士がぶつかることなく、何か見えない障害物を避けるように回り込み、そしてまた別の流れと合流して美しい渦を巻く。水や煙が空間を滑らかに「循環」し続けるのと同じ振る舞いを見せます。
流体を「フェイク」する最適解
本来、こうした流体の動きをコンピュータ上で再現するには、ナビエ・ストークス方程式などの重い流体シミュレーション(物理演算)を毎フレーム計算する必要があります。しかし、リアルタイムレンダリングにおいてそれは非常に高コストです。
Curl Noise は、その重い計算を一切行わずに、数式一発で「流体っぽい、説得力のあるうねり」を完璧にフェイクしてくれます。何万ものパーティクルを、風や水流に乗せて美しく漂わせるためのアルゴリズムとして、Curl Noise はまさに「最適解」なのです。
3. Shader 実践:パーティクルをうねらせる
理論が理解できたところで、いよいよ GLSL の Vertex Shader を使って何万もの粒子に命を吹き込んでいきましょう。
今回採用するのは、前フレームの計算結果をテクスチャに保存して使い回す「GPGPU」という手法ではありません。もっとシンプルで軽量な、「初期位置と時間(Time)から、その瞬間の位置を数学的に決定する」 というステートレス(状態を持たない)なアプローチをとります。
Vertex Shader の構成
各パーティクルが「自分が今どこにいるべきか」を、現在時刻と Curl Noise を使って毎フレーム計算します。
uniform float uTime;
uniform float uSpeed;
uniform float uNoiseScale;
// パーティクルの初期位置(AttributeとしてJavaScriptから渡す)
attribute vec3 aPosition;
// Curl Noise の関数 (Simplex Noise を微分して計算)
vec3 curlNoise(vec3 p) {
// ... (#08 のCurl Noise実装を配置) ...
}
void main() {
// 1. パーティクルの初期位置をベースにする
vec3 pos = aPosition;
// 2. 空間と時間を歪める:ノイズ空間をサンプリングするための座標を計算
// 初期位置にスケールを掛け、時間経過とともに座標をずらしていく
vec3 noisePos = pos * uNoiseScale + uTime * uSpeed;
// 3. Curl Noise から「変位(うねり)」のベクトルを取得
vec3 displacement = curlNoise(noisePos);
// 4. 元の初期位置に変位を足して、現在のフレームでの最終的な位置とする
// ※ 実際のアニメーションでは、時間経過とともに変位の係数を大きくして
// 徐々に拡散していくような工夫を入れるとより美しくなります。
pos += displacement * 2.0;
// 5. 3D空間の座標を、画面上の2D座標に変換(カメラとモデルの行列を乗算)
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
// 6. 遠近法(パースペクティブ)の適用
// カメラから遠いパーティクルは小さく、近いものは大きく描画する
gl_PointSize = 10.0 * (1.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
深掘りポイント:時間と空間のスケール
この Shader を自在に操り、思い通りの「魔法」を表現するためには、以下の2つの uniform 変数が鍵を握ります。
-
uNoiseScale(空間に対するノイズの細かさ) -
大きくする(例: 5.0):サンプリングするノイズの周波数が高くなり、小さな空間に無数の細かい渦が生まれます。チリチリとした激しい火の粉や、沸騰するようなエネルギー表現に向いています。
-
小さくする(例: 0.5):ゆったりとした、画面全体を覆うような巨大なうねりになります。海流やオーロラ、ゆっくり漂う幻想的な胞子のような表現に最適です。
-
uSpeed(時間経過によるノイズの変化速度) -
粒子がノイズのベクトル場を突き進んでいくスピードです。ここを調整することで、時間の流れをスローモーションにしたり、逆に早送りにして荒れ狂う嵐のように見せたりすることができます。
また、最後の行にある gl_PointSize の計算も地味ですが重要です。1.0 / -mvPosition.z を掛けることで、カメラの奥(Z軸のマイナス方向)に行くほど粒子が小さく描画され、圧倒的な「奥行き(Z深度)」を感じさせることができます。
4. 命を吹き込む「描画」の魔法(Fragment Shader)
Vertex Shader で完璧な「動き(うねり)」を手に入れましたが、そのまま画面に出力すると、何万もの「ただの四角いピクセル」が飛び交う殺風景な画面になってしまいます。
これらに命を吹き込み、発光する魔法の粉や、燃え盛る火の粉のように見せるためには、Fragment Shader(ピクセルごとの色計算) と Three.js 側のブレンド設定(加算合成) という2つの魔法を掛け合わせる必要があります。
Fragment Shader:四角形を「光の球」に削り出す
WebGL において、gl_PointSize で描画される点はデフォルトで「正方形」です。これを丸く、かつ中心から外側に向かってふんわりと光が減衰するように計算します。
void main() {
// 1. gl_PointCoord はパーティクル内のローカルUV座標 (0.0 〜 1.0)
// 中心 (0.5, 0.5) から現在のピクセルまでの距離を計算します
float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
// 2. 光の減衰(グロウ効果)を計算
// 中心に近づくほど値が無限大に近づき、外側に向かって滑らかに消えていく数式
float alpha = 0.05 / distanceToCenter - 0.1;
// 3. 色の指定(ここでは幻想的なブルー)
// ※ 実際の案件では、Vertex Shader から位置や時間に応じた varying 変数を渡して
// パーティクルごとに色を変えることも多いです。
vec3 color = vec3(0.2, 0.6, 1.0);
// 4. 最終的な色と透明度を出力(alphaが0以下の部分は完全に透明になる)
gl_FragColor = vec4(color, alpha);
}
💡 数学で光を描く
0.05 / distanceToCenter - 0.1というシンプルな数式は、中心(芯)が鋭く光り、周囲にぼんやりとした後光(ハロー)を纏うような美しい光のカーブを作り出します。テクスチャ画像を読み込むよりも軽量で、拡大しても粗くなりません。
Three.js 側での「加算合成」設定(ここが最重要!)
Shader で丸い光を作っても、まだ完成ではありません。重なり合ったパーティクルのエッジが黒く抜けてしまったり、奥のパーティクルが手前の透明部分に隠れてしまったりする現象が起きます。
これを解決し、圧倒的な美しさを引き出すのが JavaScript(Three.js)側の ShaderMaterial の設定です。以下の3つのパラメータを必ず有効にします。
transparent: trueマテリアルの透明度(アルファチャンネル)を有効にします。depthWrite: false(※超重要) Zバッファへの書き込みを無効にします。これをしないと、手前にあるパーティクルの「透明な四角形のフチ」が、奥にあるパーティクルを隠してしまい、汚いノイズのようなチラつきが発生します。blending: THREE.AdditiveBlending「加算合成」モード。色が重なれば重なるほど、RGBの値が足し算されて白(強い光)に近づきます。
Curl Noise と加算合成の“究極のシナジー”
ここまでのすべてが繋がる瞬間です。
Curl Noise によって動かされたパーティクルは、完全にランダムに散らばるのではなく、「見えない流体のうねり」に沿って、一時的に特定の軌道へ束になって密集するという特性を持っています。
この「粒子が密集する瞬間」に AdditiveBlending(加算合成)が組み合わさることで、密集した部分の光が強く足し合わされ、強烈な光の塊(コア)や、輝く魔法の軌跡となって画面に浮かび上がるのです。逆に、流れが拡散して粒子が散らばると、フワッと光が弱まり、儚く消えていくように見えます。
流体の動きと光の合成。この2つが完璧に噛み合った時、ただの点が「息を呑むような視覚体験」へと昇華されます。
5. 次なるノイズの旅路へ
今回は、10万の粒子をノイズのうねりに乗せる「Particle System × Noise」の基礎と実践を解説しました。
単なる乱数ではなく、Curl Noise(回転ノイズ)の「発散ゼロ」という数学的特性を利用することで、流体シミュレーションを行わずとも、息を呑むような「自然なうねり」と「光の軌跡」を生み出せる。このことが、画面越しの視覚体験としてハッキリと体感できたはずです。
現在のアプローチの「限界」
しかし、今回実装した「Vertex Shader で初期位置と時間から現在位置を計算する手法(ステートレス・アプローチ)」には、構造上の決定的な弱点が存在します。
それは、「粒子が記憶を持てない」 ということです。
毎フレーム、初期位置から現在時刻の方程式を解いてワープしているだけなので、「前のフレームで自分がどこにいて、どれくらいのスピードで飛んでいたか」を知る術がありません。そのため、以下のような表現が非常に困難になります。
- 慣性(Inertia):急に風が止んでも、しばらく惰性で飛び続ける動き。
- 寿命(Life):生まれてから徐々に色が変わり、燃え尽きて消え、また別の場所からリスポーンするサイクル。
- 相互作用(Interaction):マウスカーソルが近づいたら弾き飛ばされる動き。
これらを解決し、「100万の粒子一つ一つに、独自の意志(状態)と物理法則を持たせる究極のパーティクル制御」 を行うためには、いよいよフロントエンド・グラフィックスにおける神領域の扉を開く必要があります。
究極の魔法「GPGPU」の領域へ
次回、#29 では:
パーティクルの現在位置や速度といった「状態」を、画面に出力するのではなく「見えないテクスチャ(画像データ)」のピクセル一つ一つに焼き付けて毎フレーム更新していく、GPUの限界を引き出す錬金術。 「GPGPU × Noise — 100万の粒子が描く流体シミュレーション」 へと進むか。
あるいは、今回のパーティクルのうねりをカメラのレンズ(画面全体)に適用し、世界そのものを光の粒で歪める空間ハック、 「Post-Processing × Particles」 へと向かうか……。
ノイズという見えない魔法のベクトルを、あなたは次にどう具現化したいですか?
あなたの創造力が赴くままに、次なるノイズの旅の行き先を選んでください。次回の展開も、ぜひお楽しみに!
💬 コメント