はじめに
前回の #28 では、Curl Noise を用いて10万個のパーティクルを美しくうねらせました。
[Noise 入門 #28] Particle System × Curl Noise — 10万の粒子をノイズの“うねり”に乗せる
3D空間に放たれた10万個のパーティクルを、Curl Noise(回転ノイズ)を用いたベクトル場で制御し、流体シミュレーションなしで美しいうねりや光の軌跡をGLSLとThree.jsで実装する手法を直感的に解説します。
https://humanxai.info/posts/noise-intro-28-particle-system-curl-noise/しかし、これらは「CPUで生成した初期位置」をVertex Shader内でノイズによって毎フレーム変形(Displacement)させていたに過ぎません。
もし、「パーティクル同士が影響し合う」「パーティクルが前のフレームの速度を記憶して慣性を持つ」といった状態(State)を持たせようとすると、CPUで毎フレーム計算してGPUに送る必要があり、数万個で処理落ちしてしまいます。
そこで登場するのが、GPGPU(General-Purpose computing on Graphics Processing Units)です。 今回は、画面に出力するためではなく、「データを保存・計算するため」にGPUとテクスチャを使う錬金術を解説します。100万の粒子をノイズの奔流に乗せましょう。
1. テクスチャを「配列」として扱う空間ハック
GPGPUの根幹は、「テクスチャ(画像)のピクセルを、データ格納用の配列として扱う」という発想の転換です。
通常の画像は、1ピクセルあたり RGB(赤・緑・青)と A(透明度)の4つの値を持ちます。 これを、空間の座標や速度に見立てるのです。
- R (Red) $\rightarrow$ $x$ 座標(または速度の $x$ 成分)
- G (Green) $\rightarrow$ $y$ 座標(または速度の $y$ 成分)
- B (Blue) $\rightarrow$ $z$ 座標(または速度の $z$ 成分)
- A (Alpha) $\rightarrow$ $w$ 寿命(ライフタイム)や固有のランダムシード
例えば、$1024 \times 1024$ ピクセルのテクスチャを用意すれば、それだけで約104万個($1,048,576$個)のパーティクル情報を格納できる巨大な2D配列が完成します。
💡 直感理解
画面には描画されない「裏のキャンバス(Framebuffer Object: FBO)」を用意し、そこに「色」ではなく「座標データ」を書き込んでいる状態です。
1.1 なぜ「配列(Array)」ではなく「テクスチャ(Texture)」なのか?
「別にGPUにも普通の配列を用意すればいいのでは?」と思うかもしれません。しかし、GPUのアーキテクチャ上、テクスチャこそが最も高速にランダムアクセスできるデータ構造なのです。
GPUには「テクスチャサンプリングユニット」という、画像を読み込むためだけに特化した専用のハードウェア回路が備わっています。100万個の粒子が「隣の粒子の位置を知りたい」と思った時、ただのメモリ配列を読みに行くよりも、テクスチャのピクセルを「サンプリング(抽出)」する方が、圧倒的な並列処理能力を発揮できます。
1.2 致命的な罠:「浮動小数点テクスチャ(Float Texture)」の必須性
ここで、GPGPU初心者が必ず陥る落とし穴があります。それは「普通の画像フォーマットを使ってしまうこと」です。
通常のPNGやJPG画像は、各チャンネル(RGBA)が 8bit で構成されています。つまり、$0 \sim 255$ の整数(シェーダー内では $0.0 \sim 1.0$ に正規化される)しか保存できません。
もしパーティクルの位置が $x = -500.25$ だった場合、8bitのテクスチャではマイナスの値や、$1.0$ を超える広大なワールド座標、そして小数点以下の細かい動きを記録できず、データが完全に破綻してしまいます。
そこでGPGPUでは、色を扱うための8bitではなく、数値を扱うための 32bit 浮動小数点テクスチャ(Float Texture) を使用します。 Three.jsでは、テクスチャを生成する際に以下のように型を指定します。
// THREE.FloatType を指定することで、マイナスも小数も保存可能な「真のデータ配列」になる
const dataTexture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat, THREE.FloatType);
1.3 データの読み出し方:「インデックス」の代わりに「UV」を使う
CPU(JavaScript)の世界では、10万番目のパーティクルデータにアクセスする際、particles[100000] のようにインデックス番号を使います。
しかし、テクスチャは2Dの「面」であるため、1次元のインデックスではなく、2次元のUV座標($u, v$) を使ってアクセスする必要があります。
$1024 \times 1024$ のテクスチャの中で、特定のパーティクル(頂点)が「自分のデータ」を読み取るための計算式は以下のようになります。
$$u = \frac{x\text{座標} + 0.5}{\text{テクスチャの幅}}$$
$$v = \frac{y\text{座標} + 0.5}{\text{テクスチャの高さ}}$$
⚠️ 魔法の「+ 0.5」
ピクセルの「境界線」ではなく「中心」を正確にサンプリング(読み取り)するために、$0.5$ ピクセル分だけ座標をズラすのが、GPGPUにおける鉄則のハックです。
このように、頂点シェーダー(Vertex Shader)に「あなたが読み取るべきテクスチャ上のUV座標」をAttributeとして渡してあげることで、100万個の頂点が一斉に「見えないテクスチャ」から自分の現在位置と速度を引っ張り出し、空間上に実体化させることができるのです。
2. Ping-Pong バッファ — 「過去」から「未来」を計算する
パーティクルを動かすには、「現在の位置」に「速度(ノイズによる力)」を足して、「次の位置」を求める必要があります。
$$P_{new} = P_{old} + V_{current} \times \Delta t$$
しかし、GPUは「自分が今読み込んでいるテクスチャに、同時に書き込む」ことができません。 そこでPing-Pong(ピンポン)技術を使います。
- Texture A(過去)と Texture B(未来)の2枚を用意する。
- Frame 1: Aを読み込み、Curl Noiseで計算し、結果をBに書き込む。
- Frame 2: Bを読み込み、Curl Noiseで計算し、結果をAに書き込む。
- これを毎フレーム交互(Ping-Pong)に繰り返す。
これにより、CPUを一切介さず、GPUの中だけで100万個のパーティクルの「状態」が毎フレーム更新され続ける無限ループが完成します。
2.1 GPUのジレンマ:「自己言及」のハードウェア的禁止
なぜ、1枚のテクスチャで完結できないのでしょうか? それは、GPUの持つ「超並列処理の仕様」に起因します。
GPUは100万個のピクセル(=パーティクル)の計算を、数千のコアで「同時多発的」に処理します。もし1枚のテクスチャを読み書き両用にしてしまうと、「スレッドAが書き込んでいる最中の半端なデータを、スレッドBが読み込んでしまう」という競合状態(Race Condition)が発生し、データが瞬く間に破綻します。
例えるなら、「読んでいる最中の本のページを、別の人が横から消しゴムで消して新しい文章を書き殴っている状態」です。これを防ぐため、WebGL(OpenGL)の仕様として、「サンプリング元(読み込み用)」としてバインドされているテクスチャを、「レンダーターゲット(書き込み用)」として同時に指定することは固く禁じられているのです。
2.2 オフスクリーン・レンダリング(FBO)という計算キャンバス
では、具体的にどのように「テクスチャへの書き込み」を行うのでしょうか。 ここで登場するのが、FBO(Frame Buffer Object)を用いたオフスクリーン・レンダリングです。
GPGPUにおける「状態更新」のフェーズでは、実は3D空間にパーティクルを描画しているわけではありません。 画面には一切映らない裏のメモリ空間で、「テクスチャと全く同じサイズの巨大な板ポリゴン(Fullscreen Quad)」を1枚だけ描画しているのです。
- カメラを板ポリゴンの真正面に置く。
- Fragment Shader(ピクセルシェーダー)が、板ポリゴンの全ピクセルに対して走る。
- 各ピクセルは、過去のテクスチャから自分の位置($x, y, z$)を読み取る。
- Curl Noiseの計算式を適用し、新しい位置を算出する。
- その結果を、「色(
gl_FragColor)」として出力する。
この出力された「色」こそが、そのまま未来のテクスチャBに焼き付けられる「新しい座標データ」となります。
2.3 Ping-Pongの正体:ポインタ(参照)のすげ替え
Frame 1が終わると、Texture Bには最新のパーティクル位置が焼き付けられています。 Frame 2ではどうするか? データをTexture BからTexture Aにわざわざ「コピー」するのでしょうか?
いいえ、GPUで巨大なデータのコピーを行うのは御法度です(重すぎます)。 ここでやるのは、JavaScript側での「変数の参照(ポインタ)」のすげ替えだけです。
// Frame 1 の終わり
let readTarget = TextureA;
let writeTarget = TextureB;
// 毎フレームの最後に入れ替える(Ping-Pong)
let temp = readTarget;
readTarget = writeTarget;
writeTarget = temp;
// 次のフレーム(Frame 2)では、
// Bを読み込み(readTarget)、Aに書き込む(writeTarget)ことになる
このように、役割(読み込み用と書き込み用)のラベルを毎フレームパチパチと入れ替えるだけで、データの物理的な移動を一切行わずに「過去から未来への無限の更新ループ」が成立します。
このスマートな仕組みによって、100万の粒子は自身の過去の「慣性」を記憶したまま、Curl Noiseのうねりの中で永遠に生き続けることができるのです。
3. GPGPUにおける Curl Noise の役割
前回のVertex ShaderでのCurl Noiseは「その瞬間の位置のズレ」を計算していました。 今回のGPGPU(Compute Shader相当、Three.jsではFragment Shaderで処理)では、Curl Noiseは「速度(Velocity)に与える力(Force)」として作用します。
速度更新用のShader内の擬似コードは以下のようになります。
// 現在の位置と速度を「見えないテクスチャ」から読み込む
vec3 position = texture2D(texturePosition, uv).xyz;
vec3 velocity = texture2D(textureVelocity, uv).xyz;
// 現在位置におけるCurl Noise(渦の力)を計算
vec3 curlForce = computeCurlNoise(position * frequency);
// 速度にCurl Noiseの力を加算(慣性を持たせる)
velocity += curlForce * deltaTime;
// 摩擦をかけて無限に加速しないようにする
velocity *= 0.98;
// 「新しい速度」としてテクスチャに書き出す(出力はgl_FragColorへ)
gl_FragColor = vec4(velocity, 1.0);
そして位置更新用のShaderでは、この計算された新しい速度を使って座標を動かします。
vec3 newPosition = position + velocity * deltaTime;
gl_FragColor = vec4(newPosition, 1.0);
3.1 ノイズを「位置」ではなく「力(Force)」として扱う意味
前回の #28 で実装した「頂点シェーダーでのDisplacement」は、いわば「毎フレーム、指定された座標に強制的に瞬間移動させられている」状態でした。これでもうねりは表現できますが、粒子自体は過去の自分の動きを覚えていないため、動きがどこか機械的で、風に舞うような「余韻」がありません。
一方、GPGPUによるアプローチでは、ノイズは直接「位置」をいじるのではなく、パーティクルを押す「加速度(Acceleration / Force)」として働きます。 物理学におけるニュートンの運動方程式($F = ma$)を、パーティクルの質量 $m=1$ としてシェーダーの世界に持ち込んでいるのです。
$$a = \text{CurlNoise}(P_{old})$$
$$V_{new} = V_{old} + a \cdot \Delta t$$
$$P_{new} = P_{old} + V_{new} \cdot \Delta t$$
これにより、パーティクルは「慣性(Inertia)」を獲得します。渦の力で加速し、力が弱まってもすぐには止まらずに惰性でカーブを描きながら滑っていく。これが、生命感あふれる圧倒的な「流体らしさ」を生み出します。
3.2 魔法の係数「減衰(Damping)」— 宇宙空間から流体への変換
速度更新のコード内にある velocity *= 0.98; という一行。一見地味ですが、これがシミュレーションの崩壊を防ぐ最も重要な魔法の係数です。
もしこの摩擦(減衰)処理がないとどうなるでしょうか? パーティクルは毎フレーム Curl Noise から力を受け続けるため、速度が無限に上がり続け、一瞬で画面外(宇宙の彼方)へ吹き飛んでしまいます。これは空気抵抗ゼロの宇宙空間と同じ状態です。
ここに $0.98$(つまり毎フレーム $2%$ の速度を失う)という摩擦係数をかけることで、「空気抵抗」や「流体の粘性」をシミュレーションしています。 ノイズからの「加速」と、摩擦による「減速」が釣り合うポイント(終端速度)が生まれるため、粒子は一定の心地よいスピードで渦を巻き続けることができるのです。
0.99に近づけると、ツルツル滑るような氷上の動き(またはサラサラの液体)。0.90に下げると、泥やハチミツの中を泳いでいるような重たい動き。
このたった一つの数値が、世界の「材質(マテリアル)」を決定づけます。
3.3 2つの世界を同期させる「4枚のテクスチャ」
お気づきでしょうか。ここで更新している「状態」は「位置(Position)」と「速度(Velocity)」の2つです。 つまり、先ほど解説したPing-Pongバッファの仕組みが、位置用と速度用のそれぞれで必要になります。
- 位置 A / 位置 B(Ping-Pong用)
- 速度 A / 速度 B(Ping-Pong用)
合計4枚の見えないテクスチャが、GPUのメモリ上で毎フレーム猛烈な勢いで入れ替わりながら、100万の粒子の物理演算を行っているのです。 速度シェーダーが位置テクスチャを読み込み、位置シェーダーが速度テクスチャを読み込む。この相互依存の無限ループこそが、GPGPUを用いたパーティクルシステムの心臓部となります。
4. Three.js による実装の架け橋 GPUComputationRenderer
このPing-Pongバッファの仕組みをゼロからWebGLで書くのは非常に骨が折れます。
しかし、Three.jsにはこの錬金術を簡略化する強力なヘルパー、GPUComputationRenderer が存在します。
実装のステップ
- 初期化:
GPUComputationRendererインスタンスを作成(例: $1024 \times 1024$)。 - 変数追加:
.addVariable()でtexturePositionとtextureVelocityを定義。 - 初期データ注入: 最初はCPUでランダムな初期位置をテクスチャに書き込んでセットする。
- 依存関係: 位置は速度に依存し、速度は位置に依存するため
.setVariableDependencies()で互いを結びつける。 - マテリアルへの適用: レンダリング用のVertex Shader(実際に画面に描画するShader)のUniformに、計算結果のテクスチャを渡す。
レンダリング用Vertex Shaderでは、自身の uv 座標を使って計算済みテクスチャを覗き見ます。
// 計算済みの位置データをテクスチャから取得
vec3 computedPos = texture2D(texturePosition, referenceUV).xyz;
// 頂点座標を計算済みの位置で上書き
vec4 mvPosition = modelViewMatrix * vec4(computedPos, 1.0);
gl_Position = projectionMatrix * mvPosition;
4.1 生のWebGLの「儀式」を隠蔽する神クラス
生のWebGLでPing-Pongバッファを実装しようとすると、FBO(フレームバッファオブジェクト)の生成、テクスチャのアタッチ、画面サイズの板ポリゴンの用意、そして毎フレームのテクスチャバインドの切り替え……と、本質的ではない「儀式」のようなコードを何百行も書く羽目になります。
Three.jsの examples/jsm/misc/GPUComputationRenderer.js は、この面倒な背景処理をすべてブラックボックス化してくれます。開発者は「初期データ(Float32Array)」と「計算用のFragment Shader(GPGPU用)」を用意するだけで、即座に計算ループを回すことができるのです。
4.2 魔法のメソッド:.setVariableDependencies()
ステップ4の「依存関係の定義」は、このクラスの最も美しい機能です。 位置を計算するためには「現在の位置」と「現在の速度」が必要です。速度を計算するためにも「現在の位置(Curl Noise用)」と「現在の速度(慣性用)」が必要です。
// 位置計算シェーダーには、位置と速度の両方のテクスチャを渡す
gpuCompute.setVariableDependencies(positionVariable, [positionVariable, velocityVariable]);
// 速度計算シェーダーにも、両方を渡す
gpuCompute.setVariableDependencies(velocityVariable, [positionVariable, velocityVariable]);
これを定義するだけで、GPUComputationRenderer は内部でPing-Pongの入れ替えを自動で行い、各シェーダーのUniform変数として最新のテクスチャを毎フレーム正しくバインドしてくれます。私たちはただ gpuCompute.compute() を毎フレーム呼ぶだけで済むのです。
4.3 レンダリング用シェーダーの秘密:referenceUV とは何か?
最後のステップ5、画面にパーティクルを描画するVertex Shaderを見てみましょう。
ここで注意すべき罠があります。コード内にある referenceUV は、通常のジオメトリが持っている uv (画像のテクスチャマッピング用)ではありません。
100万個の頂点(THREE.Points や THREE.InstancedMesh)を描画する際、「自分は100万個のうちの何番目のパーティクルなのか?」を頂点自身が知る必要があります。
そのため、CPU側で初期化を行う際、各頂点に対して「見えないテクスチャ上のどこを読みに行けばいいか」を示す専用のUV座標($0.0 \sim 1.0$)を、BufferAttribute として割り当てておく必要があるのです。
// CPU側での準備:各パーティクルにテクスチャ参照用のUVを持たせる
const referenceUVs = new Float32Array(particlesCount * 2);
for (let i = 0; i < particlesCount; i++) {
const x = (i % width) / width;
const y = Math.floor(i / width) / height;
referenceUVs[i * 2 + 0] = x;
referenceUVs[i * 2 + 1] = y;
}
geometry.setAttribute('referenceUV', new THREE.BufferAttribute(referenceUVs, 2));
この「CPU側で配られた整理券(referenceUV)」を握りしめて、GPU側で100万個の頂点が一斉に「計算済みテクスチャ(現在位置)」へアクセスしに行く。
この連携プレイが完璧に組み合った瞬間、画面上に100万の粒子がノイズのうねりに乗って顕現します。
5. 100万の粒子が描く圧倒的な世界
このGPGPUの仕組みとCurl Noiseが融合した時、画面には何が起きるのか。
100万個の光の粒が、決して交差することのない複雑な流体のベクトル場に沿って滑らかに流れ始めます。それはもはや「ノイズ」というより、星雲の誕生や深海の微小生物の群れをシミュレーションしているかのような圧倒的な情報量と生命感を生み出します。
CPUでは絶対に到達できない、GPUの並列計算能力を極限まで引き出したプロシージャル・アートの極致です。
5.1 加算合成(Additive Blending)による「密度」の可視化
100万個のパーティクルをそのまま不透明な点として描画すると、画面はただ真っ白に塗りつぶされてしまいます。ここで不可欠になるのが、Three.jsのマテリアル設定における加算合成(blending: THREE.AdditiveBlending)と、極限まで下げた透明度(Opacity: 0.01など)です。
個々の粒子はほとんど見えないほど薄く暗い光の点です。しかし、Curl Noiseの渦によって粒子が「密集した場所(密度が高い場所)」だけが、光が重なり合って強烈に発光し始めます。 逆に粒子が散開した場所は暗く沈むため、結果として「流体の密度のうねり」そのものが、ボリュメトリックな光の帯となって浮かび上がるのです。
5.2 永遠に淀まない「発散ゼロ」の美しさ
ここで、第8回(#08)で学んだ Curl Noise の最大の特性である「発散ゼロ(Divergence-Free)」が真価を発揮します。
もし単純なPerlin NoiseやSimplex Noiseをそのまま力(Force)として使った場合、パーティクルは特定の「ノイズの谷間(Sink)」に吸い寄せられて一点に固まってしまうか、あるいは無限の彼方へ吹き飛んで消え去ってしまいます。 しかし、Curl Noiseが生み出すベクトル場は「必ずどこかから入って、どこかへ抜けていく」という流体の連続性を数学的に保証しています。
そのため、100万の粒子は永遠に一点に留まることなく、かといって完全に散逸することもなく、画面の中で永久機関のように美しく渦を巻き続けることができるのです。Ping-Pongバッファによる「記憶」と、Curl Noiseによる「流動」が完全に噛み合った瞬間です。
次回予告:#30 第3集完結 — 全てを束ねる「Procedural Universe」
いかがでしたか?「見えないテクスチャにデータを焼く」という発想の転換から始まり、Ping-Pongバッファによる過去と未来の接続、そしてGPUの限界を引き出すGPGPUの実装まで。 これはシェーダープログラミングにおいて、一段階上の次元へ至るための強力な錬金術です。
さて、いよいよ次回は第3集「Shader(GLSL)実践編」の最終回、#30 です。 これまで学んできた技術のすべて——FBMの複雑なフラクタル、Domain Warpingによる空間のねじれ、極座標での展開、そして今回のGPGPUによる100万の粒子の流動。
これらすべての魔法を統合し、一つの完成された圧倒的な作品、「Procedural Universe(プロシージャルな宇宙 / 銀河系の錬成)」を構築します。
数学とノイズが織りなす世界の創造、その一つの到達点を共に目撃しましょう。 次回の展開も、ぜひお楽しみに!
💬 コメント