はじめに
前回の [Noise 入門 #35] では、Raycaster と Curl Noise を組み合わせて、マウス操作で大気をかき混ぜ「巨大な台風」を錬成しました。うねる雲のボリューム感は出たものの、荒れ狂う嵐としてはまだ何かが足りません。
そう、「光(エネルギー)」 です。
今回は、FBMノイズの鋭い閾値(Threshold)を利用して、雲の内部でランダムに瞬く「稲妻(Lightning)」をプロシージャルに実装します。光と影を明滅させ、環境全体を照らし出すダイナミックなライティング手法に挑戦しましょう。
前回の記事:
[Noise 入門 #35] Interactive Storm — マウスで気象を操り「巨大な台風」を錬成する
Three.jsとGLSLを用いて、プロシージャルな雲のFBMノイズにCurl Noiseのベクトル場を局所的に適用。Raycasterを使った3D座標の取得とDomain Warpingの応用により、マウスで大気をかき混ぜて台風を錬成するインタラクションを実 …
https://humanxai.info/posts/noise-intro-35-interactive-storm/1. 滑らかな雲から「鋭い閃光」を取り出す数学
これまで雲を作るために使ってきた FBM(Fractal Brownian Motion)は、基本的に「滑らかでモクモクとした」形状を作り出します。しかし、稲妻は鋭利で直線的、かつコントラストが極端です。
この滑らかなノイズから鋭い光の筋を取り出すために、Ridge(尾根) の考え方と Threshold(閾値) を応用します。
数学的なアプローチとしては、ノイズ関数の絶対値を取り、それを反転させることで鋭いピーク(尾根)を作り出します。
$$\text{Ridge}(\vec{p}) = 1.0 - |\text{noise}(\vec{p})|$$
この関数の結果に対して、smoothstep を用いて極端に高い値(例えば 0.95 〜 1.0 の間)だけを切り出すことで、空間の特定の場所にだけ走る「光の亀裂」を偽装することができます。
波を折りたたんで「尾根」を作るプロセス
この数式が空間の中でどのような変化を起こしているのか、順を追って想像してみてください。
- 通常のノイズ関数($-1.0$ から $1.0$) ベースとなる Simplex Noise や Perlin Noise は、なだらかな丘と谷が連続する波のような値を出力します。
- 絶対値をとる($0.0$ から $1.0$) $|\text{noise}(\vec{p})|$ を計算すると、マイナスの谷底がプラス方向に「折りたたまれます」。すると、元の値が $0$ だった場所(ゼロクロス点)で、グラフの傾きが急激に変化する「鋭い谷(V字の溝)」が生まれます。
- 反転させる($1.0$ から $0.0$) $1.0 - |\text{noise}(\vec{p})|$ とすることで、先ほどの「鋭い谷」が上下反転し、「鋭い山頂(尾根)」へと変わります。
実はこれ、第10回の「地形生成(Terrain Generation)」で険しい山脈を隆起させる際に使った手法と全く同じです。しかし、今回はこれを岩ではなく「光」として扱います。
プラズマの亀裂を抽出する(Thresholding)
Ridge ノイズを作った時点では、空間全体に太い線が網の目のように広がっている状態です。稲妻として扱うには、この線を「極限まで細いプラズマの経路」へと絞り込む必要があります。
ここでGLSLの smoothstep 関数が魔法をかけます。
// 座標 p に対して少し高周波(細かい)ノイズを生成
float n = snoise(p * 5.0);
// 絶対値と反転で鋭い尾根(Ridge)を作る
float ridge = 1.0 - abs(n);
// 閾値(Threshold)を設定し、先端だけを切り出す
// 0.98以下の値はすべて0.0に潰され、0.98〜1.0の間だけが残る
float lightningMask = smoothstep(0.98, 1.0, ridge);
この処理により、値が 0.98 に満たない広大な空間はすべて真っ暗($0.0$)になり、一番高い尾根の「ほんの先端」だけが鋭い線として空間に浮かび上がります。これが稲妻のベースとなる形状です。
フラクタル化で「枝分かれ」を生み出す
現実の稲妻は一本の単純な線ではなく、細かくジグザグに折れ曲がり、周囲に無数の細かい枝を伸ばします。これを再現するためには、先ほどの Ridge ノイズをベースにした FBM(フラクタル・ブラウン運動) を構築します。
周波数(Frequency)を上げながら振幅(Amplitude)を下げていく通常の FBM ループの中で、毎回 1.0 - abs() を計算して加算していきます。
float fbm_ridge(vec3 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 4; i++) {
// ノイズを取得し、Ridge化して加算
float n = 1.0 - abs(snoise(p * frequency));
value += n * amplitude;
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
この fbm_ridge の結果に対して先ほどと同様に厳しい smoothstep をかけると、太い幹から細い枝がフラクタル状に伸びる、極めて自然で複雑な「稲妻のネットワーク」が空間内に生成されます。
2. GLSLで雷の形状(Shape)を定義する
実際に Shader 内で閃光のベースとなる形状を作ってみましょう。通常の雲の密度計算とは別に、雷用の高周波な FBM を用意します。
// 雷の形状を生成する関数(イメージ)
float getLightningShape(vec3 p) {
// 雲よりも高周波(細かい)ノイズを使用
float n = fbm(p * 5.0);
// 絶対値と反転で鋭い亀裂(Ridge)を作る
float ridge = 1.0 - abs(n);
// 閾値を設定し、ごく一部だけを光らせる
// 0.98以下の値はすべて0になり、鋭い線だけが残る
return smoothstep(0.98, 1.0, ridge);
}
この getLightningShape が返す値が 1.0 に近い場所が、雲の中でプラズマが走っている座標となります。逆に 0.0 の場所は、光が存在しない闇の空間です。
「魔法の数字」の正体(パラメータのチューニング)
Shader を書く際、こうした「魔法の数字(Magic Numbers)」が何を意味しているのかを理解することが、プロシージャル表現を自在に操るための鍵となります。
- p * 5.0(周波数のスケール): 雲の形状を作る FBM が、例えば p * 1.0 というスケールで作られているとします。雷は雲よりもはるかに細かく、鋭く折れ曲がるディテールが必要です。そのため、空間座標 p に 5.0 などの係数を掛けて周波数(Frequency)を上げ、ノイズをギュッと圧縮しています。
- smoothstep(0.98, 1.0, ridge)(雷の太さの決定): ここが最も重要なポイントです。この 0.98 という高い閾値(Threshold)が、雷の「細さ」を決定しています。 もしこの値を 0.80 に下げると、太くぼんやりとした光の帯になってしまい、プラズマの鋭さが失われます。逆に 0.995 など極端に高くすると、糸のように細く鋭利な亀裂になります。作りたい雷のスケールに合わせて、この閾値をシビアに調整する必要があります。
雲と雷のレイヤーを分離する意味
「なぜ雲の FBM をそのまま流用せずに、わざわざ別の関数を用意するのか?」と疑問に思うかもしれません。
それは、雲(Volume)と雷(Light)では、求められる性質が全く異なるからです。雲の密度計算はモクモクとした柔らかい形(通常の FBM)が必要ですが、雷は鋭い亀裂(Ridge FBM)が必要です。
また、Volumetric Clouds の重いレイマーチング(Raymarching)のループ内で計算を行うため、雷の FBM はオクターブ(Octave)数を 2〜3 回程度に抑えるなど、パフォーマンス最適化(第20回で触れた考え方ですね)の観点からも分離しておく方が制御しやすくなります。
3. 時間(Time)による「明滅(ストロボ)」の制御
雷は常に光っているわけではありません。「ピカッ、ピカッ」と不規則に瞬くストロボ効果があって初めて、稲妻としてのリアリティが生まれます。
この「不規則な時間の揺らぎ」も、もちろんノイズで制御します。今回は空間の座標(xyz)ではなく、時間(time)のみを入力とした 1D ノイズ、あるいは高周波なサイン波の合成を使用します。
// 時間軸に対する不規則なフラッシュ
float getLightningFlash(float time) {
// 時間を高速化し、ランダムなノイズを取得
// ※GLSLの仕様上、2DノイズのY成分を0にして1Dノイズとして代用しています
float flashNoise = snoise(vec2(time * 10.0, 0.0));
// 一定の閾値を超えた時だけ強烈に光るようにする
// 0.8未満は0.0(消灯)、0.8以上は1.0(点灯)になる
return step(0.8, flashNoise);
}
この時間による明滅係数と、先ほどの空間的な形状係数を掛け合わせることで、「特定の場所が、特定の瞬間にだけ鋭く光る」 という現象が完成します。
なぜ smoothstep ではなく step を使うのか?
空間の形状作り(Shape)では smoothstep を使って境界を滑らかに切り出しましたが、時間(Time)の制御では step 関数を採用しています。ここには明確な意図があります。
稲妻は「フワッと明るくなって、フワッと消える」ものではありません。ある瞬間、ゼロフレームで突然最大エネルギーに達し(ON)、一瞬で消え去る(OFF)という、極めてデジタルで暴力的な光り方をします。 step(0.8, flashNoise) を使うことで、ノイズの値が 0.8 を超えた瞬間にバツンと 1.0 に切り替わる「ハードなエッジ」を持ったストロボ効果を生み出すことができるのです。
時間のスケール(Frequency of Time)
コード内の time * 10.0 という係数も、雷のリアリティを左右する重要なパラメータです。
ノイズに渡す時間が遅すぎると、ホタルの光や心臓の鼓動のような「ゆっくりとした明滅」になってしまいます。時間を $10$ 倍、$20$ 倍と高速化することで、1フレーム単位で激しく値が乱高下するようになり、自然界の稲妻が持つ「チカチカッ!」という連続的でヒステリックなフラッシュを擬似的に再現できます。
空間と時間の融合(Multiplication)
ここまで作ってきた2つの関数を、Shaderの main 関数(またはレイマーチングのループ内)でどのように統合するのかを見てみましょう。
// 1. 空間的な雷の形状を取得(0.0 〜 1.0)
float shape = getLightningShape(p);
// 2. 時間的な明滅を取得(0.0 または 1.0)
float flash = getLightningFlash(time);
// 3. 掛け合わせて最終的な雷の強度を決定
float lightningIntensity = shape * flash;
非常にシンプルですね。flash が 0.0 の瞬間は、どれだけ立派な雷の形状(shape)が生成されていても、掛け算によってすべて闇(0.0)に沈みます。そして flash が 1.0 になった一瞬だけ、空間に刻まれたプラズマの亀裂が暗闇の中に浮かび上がる仕組みです。
4. 雲を内部から照らす(Dynamic Illumination)
雷の真骨頂は、閃光そのものだけでなく、「周囲の雲を明るく青白く照らし出す(Subsurface Scattering 的な発光)」 ことにあります。
前回の雲のレンダリングループ(Raymarching)の中で、雷の発生源に近い雲のピクセルに対して、雷のカラー(例えば vec3(0.8, 0.9, 1.0))を加算します。
- 雲の基本色 = 太陽光のランバート反射 + アンビエント光
- 最終的な色 = 雲の基本色 + (雷のカラー × 雷の強度 × 明滅係数)
これにより、台風の目や分厚い雲の奥深くで稲妻が走ると、その周辺の雲全体がフワッと青白く浮かび上がる、極めてリッチなプロシージャル表現が実現します。
レイマーチング・ループへの組み込み
実際に Volumetric Clouds の Raymarching ループ内で、この「発光」をどう処理するのかを見てみましょう。視点(Camera)からレイを飛ばし、雲の密度をサンプリングしていく過程で、雷の計算も同時に行います。
vec3 cloudColor = vec3(0.0);
float totalDensity = 0.0;
vec3 lightColor = vec3(0.8, 0.9, 1.0); // 稲妻の青白い光
for(int i = 0; i < MAX_STEPS; i++) {
vec3 p = rayOrigin + rayDir * distance; // 現在のサンプリング座標
// 1. 雲の密度を取得
float density = getCloudDensity(p);
if(density > 0.0) {
// 2. この空間における雷の強度を取得(Shape × Time)
float lightning = getLightningShape(p) * getLightningFlash(time);
// 3. 雲が濃い場所ほど、内部で光が拡散(Scattering)するように見せる
// 密度(density)を掛けることで、雲が存在しない空間は光らないようにする
vec3 localLightningColor = lightColor * lightning * density * 5.0;
// 4. ベースの雲の色に、雷の発光を加算(Additive)
vec3 baseColor = calculateLighting(p) * density;
cloudColor += baseColor + localLightningColor;
totalDensity += density;
}
distance += STEP_SIZE;
if(totalDensity >= 1.0) break; // 完全に不透明になったらループを抜ける
}
なぜ「加算(Additive)」なのか?
Shader における色の合成には様々な手法がありますが、ここでは +(加算)を使っています。
太陽の光を「反射」するだけの通常の雲とは異なり、雷はそれ自体が強烈なエネルギーを放つ「光源(Emissive)」です。そのため、ベースとなる暗い雲の色に対して、雷のカラーベクトルを直接足し合わせることで、画面上のピクセルが白飛び(1.0 以上)するほどの強烈な輝きを放つようになります。
光の拡散(Scattering)を偽装する
コード内の lightning * density という部分も小さな魔法の一つです。
現実世界の雲の中で雷が鳴ると、光は水滴に乱反射し、雷の芯から離れた周囲の雲までもがぼんやりと光ります(Subsurface Scattering / 表面下散乱)。 厳密にこれをシミュレーションしようとすると計算負荷が跳ね上がりますが、「雷が発生している座標の雲の密度(density)が高いほど、光が強く拡散して明るく見える」というフェイクを入れることで、GPUのパフォーマンスを維持したまま、巨大な台風の分厚さやスケール感を圧倒的なリアリティで表現できるのです。
まとめ:嵐は完成した
今回の実装における重要なポイントは以下の3点です。
- abs() と smoothstep の組み合わせ: 滑らかな FBM ノイズを折りたたみ、極端な閾値で切り出すことで、空間に鋭い「光の亀裂(Ridge)」を作り出す。
- 時間軸の step 処理: 空間とは独立して、時間(Time)のみを入力とした高周波ノイズを用い、デジタルで暴力的な「ストロボ明滅」を制御する。
- 加算(Additive)による内部発光: 雲のレイマーチング内で、密度(Density)に応じて雷の色を加算し、分厚い雲が内部から青白く照らし出される巨大なスケール感をシミュレーションする。
これらを前回の Interactive Storm と融合させることで、ついにあなたのブラウザ内に「触れることができる、生きた嵐」が完成しました。マウスを動かして大気をかき混ぜ、巨大な台風の渦を作り出し、その深い闇の奥でランダムに瞬く雷鳴を楽しんでください。
ただの数学的な計算結果が、GPUの力と少しのハックによって「圧倒的な自然現象」へと姿を変える。これこそが、Shaderとノイズが持つ真の魔法です。
次回予告:[Noise 入門 #37] Procedural Night Lights — ノイズが描く「文明の灯火」と夜の境界線
気象をコントロールし、荒れ狂う嵐の表現を手に入れました。 しかし、この惑星にはまだ「夜の顔」がありません。
次回は、太陽の光が届かない暗闇(Dark Side)の領域にフォーカスします。Voronoi Noise の細胞的な幾何学パターンを応用し、まるで宇宙ステーションから見下ろしたかのような「無数の街の明かり(City Lights)」をプロシージャルに錬成します。
荒ぶる自然と、静かに瞬く文明の境界線。この「生きた惑星」をさらに進化させる新たなステップへ進みましょう。お楽しみに!
💬 コメント