はじめに
海と大地、そして大気を纏った私たちの惑星。しかし、宇宙から見下ろす星の美しさは「地形」だけでは語れません。まだ空には「気象」が足りないのです。
Noise 入門シリーズ第34回。今回は、この惑星の周囲に「流れる雲海(Procedural Clouds on Sphere)」を展開します。静かな無機質の星が、気象現象を持つ「生きた惑星」へと進化する瞬間を、Three.jsとGLSLを用いて実装していきましょう。
前回の記事:
[Noise 入門 #33] Procedural Biome と大気散乱 — ノイズ惑星に海と森、そして「青い空」を纏わせる
Three.jsとGLSLを用いて、ノイズで生成した惑星に海や森などのバイオームを動的に割り当て、フレネル効果で青い大気を纏わせる実装手法を図解とコードで解説。無機質な星が美しい惑星へと進化する過程を体験しましょう。
https://humanxai.info/posts/noise-intro-33-procedural-biome-atmosphere/1. 雲海の基本構造:もうひとつの「透明な球体」
雲を地形(Terrain)と同じ球体のシェーダー内で計算することも可能ですが、コードが肥大化し、雲の「浮遊感」や「立体感」を表現するのが難しくなります。
そこで今回は、地形の球体よりも「わずかに大きな球体」をもう一つ被せるというシンプルなアプローチをとります。
なぜ「1つのシェーダー」にまとめないのか?
プロシージャルな世界を構築する際、「すべてを1つの強大なウルトラシェーダー(Uber Shader)で書く」という誘惑に駆られることがあります。実際、地形の色計算の最後に雲の白さを足し合わせることは数学的に可能です。
しかし、そのアプローチには以下の致命的な弱点があります。
- 視差(Parallax)が生まれない: 地表のテクスチャと空の雲が全く同じ球体表面に描画されるため、カメラを動かしても「奥にある地面」と「手前にある雲」の位置関係がズレません。結果として、空がペラペラな壁紙のように見えてしまいます。
- 独立した動き(Rotation)の欠如: 雲は風に流され、自転とは異なる速度で移動します。同じ球体に描画してしまうと、地形と雲を別々の速度で回転させるためのUV座標(または3D空間座標)の計算が極めて複雑になります。
- ライティングの破綻: 地形の法線(Normal)と雲の法線は全く異なります。同じピクセル上で両方を計算し、光を当てて合成するのはGPUの負荷を無駄に跳ね上げます。
半径 $R + \epsilon$ の大気圏
これらの問題を一挙に解決するのが「多重球体(Multi-Sphere)モデル」です。物理的なアプローチに立ち返り、世界を層(Layer)で構成します。
- Terrain Sphere(地形): 半径 $R$ (例: 1.0)
- Cloud Sphere(雲): 半径 $R + \epsilon$ (例: 1.0 + 0.05)
$\epsilon$(イプシロン)は、地表から雲までの距離、つまり「大気圏の厚み」を表す微小な値です。この $\epsilon$ を調整することで、分厚い雲海を持つ金星のような星にするか、薄い大気を持つ火星のような星にするかをコントロールできます。
Three.js でのセットアップ(魔法の入れ物)
実装は非常にシンプルです。Three.js側で同じ SphereGeometry を使い回し、マテリアルとスケールを変えた2つのメッシュ(Mesh)を同じ座標に配置するだけです。
// 共通のジオメトリ(頂点数は必要に応じて調整)
const sphereGeometry = new THREE.SphereGeometry(1, 128, 128);
// 1. 地形(Terrain)のセットアップ
const terrainMaterial = new THREE.ShaderMaterial({
vertexShader: terrainVertexShader,
fragmentShader: terrainFragmentShader,
// ... uniforms etc.
});
const terrainMesh = new THREE.Mesh(sphereGeometry, terrainMaterial);
scene.add(terrainMesh);
// 2. 雲(Cloud)のセットアップ
const cloudMaterial = new THREE.ShaderMaterial({
vertexShader: cloudVertexShader,
fragmentShader: cloudFragmentShader,
transparent: true, // 雲のない部分を透明にする
depthWrite: false, // Zバッファへの書き込みを無効化(奥の地形を透かして描画するため)
blending: THREE.NormalBlending,
// ... uniforms etc.
});
const cloudMesh = new THREE.Mesh(sphereGeometry, cloudMaterial);
// 半径 R + ε を表現するために、少しだけスケールを大きくする
const epsilon = 0.05;
cloudMesh.scale.setScalar(1.0 + epsilon);
scene.add(cloudMesh);
このように層を分けることで、cloudMesh.rotation.y += 0.001; のように記述するだけで、地形とは独立してゆっくりと流れる大気圏をThree.jsのレイヤーで簡単に表現できるようになります。
この透明なキャンバス(Cloud Sphere)が用意できたら、次はこのキャンバスに 4D FBMノイズ を流し込み、気象システムを削り出していく作業に入ります。
2. 4D FBMノイズ:時間を喰ってうねる気象システム
雲の形状生成には、これまで学んできた FBM(Fractal Brownian Motion) を使用します。ただし、今回は球体上の3D座標に「時間(Time)」を加えた 4D Noise $N(x, y, z, t)$ です。
なぜ「2D」ではなく「3D座標」なのか?
通常、テクスチャを貼る際には2DのUV座標を使いますが、球体に対してUV座標でノイズを生成すると、極(北極や南極)の部分でテクスチャがギュッと縮んで不自然な「特異点」が生まれてしまいます。さらに、テクスチャの継ぎ目(シーム)を消すのも一苦労です。
しかし、3D空間における頂点のローカル座標($x, y, z$)をそのままノイズ関数の入力として使えば、「空間そのものから雲の形をサンプリングする」ことになるため、どこから見ても継ぎ目のない完全なシームレスノイズを得ることができます。
そこに時間軸 $t$ を加えることで、雲はただ自転するだけでなく、内部から湧き上がり、形を変えながら流れる「生きた気象」へと進化します。
雲の「輪郭」を削り出す(smoothstepの魔法)
4D FBMノイズを計算した直後の出力値は、おおむね 0.0 から 1.0 の間の滑らかなグラデーションです。これをそのまま色や透明度(Alpha)に適用してしまうと、惑星全体が薄モヤに包まれた「のっぺりとした霧の星」になってしまいます。
空に浮かぶ「はっきりとした雲の塊」を作るには、GLSLの強力な組み込み関数 smoothstep(edge0, edge1, x) を使って、空間を数学的に「削り取る」必要があります。
// vPosition は Vertex Shader から渡された 3D ローカル座標
// uTime は Three.js から毎フレーム送られてくる経過時間
float n = fbm4d(vec4(vPosition * uCloudScale, uTime * uCloudSpeed));
// smoothstep で雲の輪郭を削り出す
// 0.4 以下は 0.0(完全に透明)
// 0.6 以上は 1.0(完全に不透明)
// 0.4 〜 0.6 の間は滑らかに補間される
float cloudAlpha = smoothstep(0.4, 0.6, n);
// 最終的な色(白)と計算した透明度を出力
gl_FragColor = vec4(vec3(1.0), cloudAlpha);
この smoothstep の2つの閾値が、気象をコントロールする「神のパラメータ」になります。
- 低い値(例: n < 0.4): smoothstep は 0.0 を返します。つまりアルファ値がゼロになり、完全に透明な「晴れ間」になります。地形がくっきりと見えます。
- 中間値(例: 0.4 <= n <= 0.6): 0.0 から 1.0 へ滑らかにカーブを描いて補間されます。ここが「雲の境界(フワッとした輪郭)」となり、雲の柔らかさを表現します。
- 高い値(例: n > 0.6): 1.0 を返します。光を通さない「濃い雲の塊」となります。
気象のダイナミクスを操作する
この smoothstep の閾値を Uniform 変数として Three.js から操作できるように(例: uCloudCover のようなパラメータを足す)すれば、スライダー一つで気象を操ることができます。
- smoothstep(0.7, 0.8, n) にすれば、ノイズの高い部分だけが残るため、「雲ひとつない快晴(たまに薄雲)」になります。
- smoothstep(0.2, 0.4, n) にすれば、「星全体を覆う分厚い嵐の雲」になります。
時間経過でこの閾値を微小に揺らがせれば、特定の地域で雲が生まれ、徐々に消えていくような、非常にリアルでダイナミックな気象システムを構築できるのです。
3. アルファブレンドとDepth Writeの罠
Three.jsやWebGLの世界において、不透明なオブジェクト(地形や岩)をレンダリングするのは比較的素直です。しかし、そこに「透明(アルファ値)」が絡むと、途端に物理法則が崩壊するような奇妙なバグに直面します。
雲の球体(Cloud Sphere)を描画してカメラを回した瞬間、「裏側の雲が手前の雲を隠してしまう」「雲の向こう側にあるはずの美しい地形が、四角く切り取られたように完全に消え去ってしまう」といった現象が起きたことはないでしょうか?
これが、3Dレンダリングにおける最大の鬼門の一つ、「描画順序(Zソート)とデプスバッファ(Depth Buffer)の罠」です。
なぜ地形が消えるのか?(Depth Writeのジレンマ)
GPUは無駄な計算を省くため、手前にあるピクセルを描画した際、その「深さ(Depth)」をデプスバッファに記録します。そして、それより奥にあるピクセルは「どうせ見えないから」と描画をスキップ(カリング)します。
もし Cloud Sphere の depthWrite が有効(デフォルトの true)なまま透明な雲を描画してしまうと、GPUは「透明な晴れ間」の部分にも「ここには手前に物体がある」という見えない壁の深度を記録してしまいます。 その結果、後から描画されようとした地形が「壁の向こう側だ」と判定されてしまい、描画がキャンセルされ、宇宙の背景がそのまま透けてしまうのです。
透過マテリアルの鉄則:3つの鍵
これを防ぎ、惑星を包み込む美しい大気層を構築するためのThree.js側のマテリアル設定の鉄則が以下の3つです。
const cloudMaterial = new THREE.ShaderMaterial({
vertexShader: cloudVertex,
fragmentShader: cloudFragment,
// 1. 透明度を有効化(アルファチャンネルを解釈させる)
transparent: true,
// 2. 深度の書き込みを無効化(見えない壁を作らない)
depthWrite: false,
// 3. ブレンドモードの指定(光をどう重ねるか)
blending: THREE.NormalBlending
});
特に depthWrite: false が最大の鍵です。「私の姿は描画してほしいけれど、私の深度は記録しないでくれ」とGPUに指示することで、奥にある地形(Terrain Sphere)が確実に描画されるようになります。
NormalBlending vs AdditiveBlending(光の物理学)
最後に指定する blending(ブレンドモード)は、雲の「質感」を決定づけます。
- THREE.NormalBlending(通常合成): 背景色と雲の色を、アルファ値(透明度)に基づいて物理的に正しく混ぜ合わせます。雲が濃い部分は太陽の光を遮り、白く(あるいは灰色に)なります。現実の気象や、光を透過・拡散させる物理的な雲を作る場合はこちらが適しています。
- THREE.AdditiveBlending(加算合成): 背景色に対して、雲の色を「足し算」します。暗い背景ほど明るく輝き、重なれば重なるほど白飛びしていきます。これは過去の記事(#23の魔法陣や、#30のプロシージャル宇宙)で活躍した技術です。魔法的な発光を伴う星雲や、プラズマの嵐などを表現したい場合は、あえてこちらを使うのも非常に面白いアプローチになります。
今回は「生きた気象システム」としての雲海を錬成するため、NormalBlending を採用し、重厚感のある雲の層を重ねていきましょう。
4. 影の錬成:地表に落ちる雲の影(Cloud Shadows)
雲の層(Cloud Sphere)が完成して惑星を回してみると、ある重大な違和感に気づくはずです。
「雲が、空に浮いているように見えない」
雲が地形にベタッと張り付いた白い模様のように見えてしまう原因、それは「影(Shadow)」の不在です。私たちの脳は、物体が落とす影のズレを無意識に計算し、そこから「高さ(浮遊感)」を感じ取っているのです。
Shadow Map の限界と「空間ハック」
Three.js には castShadow や receiveShadow といった便利な標準機能がありますが、今回のようなプロシージャルな雲に対しては無力です。なぜなら、雲の形状はポリゴン(頂点)ではなく、フラグメントシェーダー内の計算(ピクセル単位の透明度)で表現されているため、標準の Shadow Map は「ただの巨大な透明の球体」として影を落としてしまうか、あるいは処理負荷が天文学的に跳ね上がる専用のカスタム深度マテリアルを要求してきます。
そこで、Shaderの知識をフル活用した「空間ハック(Spatial Hack)」の出番です。光のシミュレーションを真面目に計算するのをやめ、数学の力で「影を捏造」します。
影を捏造する 3 つのステップ
雲の正体が「数学的関数(4D FBM)」であるという強みを活かします。つまり、地形を描画している最中に「自分の頭上に雲があるかどうか」を計算で割り出すことができるのです。
具体的なGLSLの実装手順は以下の通りです。
-
Terrain(地形)シェーダーに同じノイズ関数を移植する Cloud Sphere で使用している fbm4d 関数と全く同じものを、Terrain Sphere のフラグメントシェーダー内にも定義します。パラメータ(スケールや時間)も完全に同期させます。
-
頭上の雲の濃度をサンプリングする 地形のピクセルを描画する際、その座標($vPosition$)を元に、上空のノイズ値を計算します。
// 地形のFragment Shader内
// vPosition は現在の地形のローカル座標
// 1. 上空の雲の濃度を計算(Cloud Sphereと全く同じ計算)
float cloudDensity = fbm4d(vec4(vPosition * uCloudScale, uTime * uCloudSpeed));
// 2. smoothstepで雲の輪郭を削り出し、影のマスク(0.0〜1.0)を作る
float shadowMask = smoothstep(0.4, 0.6, cloudDensity);
- 地形の色を「乗算」で暗くする 算出した shadowMask が 1.0 に近い(=頭上に濃い雲がある)ほど、その地形のピクセルカラーに暗い色を掛け合わせます。真っ黒よりも、少し青みを含んだ暗い色を乗算すると、大気中の環境光(アンビエント)が表現されて一気にリアルになります。
// 3. 影の色を定義(完全な黒ではなく、暗いネイビーブルーなど)
vec3 shadowColor = vec3(0.1, 0.15, 0.25);
// 影の濃さの調整パラメータ
float shadowStrength = 0.6;
// 地形の色(baseColor)に影をブレンド
// shadowMask が 1.0 の場所ほど、shadowColor が掛け合わされて暗くなる
vec3 finalColor = mix(baseColor, baseColor * shadowColor, shadowMask * shadowStrength);
gl_FragColor = vec4(finalColor, 1.0);
【ワンランク上の魔法】影を斜めに落とす
もし太陽(光源)が真上にあるなら上記のコードで完璧ですが、太陽が斜めから照らしている場合、影も斜めにズレるべきです。
この時、ノイズ関数に渡す vPosition に対して、「光源の逆方向へ少しだけ座標をズラしてサンプリングする」という数行を加えるだけで、影が太陽の角度に応じて斜めに落ちるようになり、圧倒的な立体感とスケール感が生まれます。
この手法を用いれば、どれだけ雲が複雑にうねり、風に流されようとも、影の描画コストは「フラグメントシェーダーでノイズ関数をもう1回呼ぶだけ」という超軽量な処理で完結します。まさに、プロシージャル生成ならではの美しくエレガントなハックです。
5. まとめ:無機質な星が「呼吸」を始める瞬間
今回は、地形の球体(Terrain Sphere)のすぐ外側に、もう一つの透明なキャンバス(Cloud Sphere)を被せ、4D FBMノイズを流し込むことで「生きた気象システム」を実装しました。
ここでの重要なポイントは以下の3つです。
- 層(Layer)を分ける: 雲と地形の球体を分離することで、視差(パララックス)を生み出し、大気圏の独立した動きを可能にする。
- 透過の鉄則: transparent: true と depthWrite: false を組み合わせ、Zバッファの罠を回避しつつ、美しい雲海を重ねる。
- 影の空間ハック: 光源計算に頼らず、地形のシェーダー内で「頭上のノイズ濃度」を再計算し、雲と完全に同期した影を超高速にフェイクする。
ただの凸凹したノイズの球体だった私たちの惑星は、海を持ち、森が広がり、そして今回、絶えず形を変えて流れる「雲」と「落ちる影」を手に入れました。
ゆっくりと自転する惑星。その地表を滑るように流れていく雲の影を見つめていると、数式とコードの塊であるはずのこの星が、まるで静かに呼吸をしているかのように感じられないでしょうか。プロシージャル生成が「ただの模様」から「世界」へと昇華する、最高に楽しい瞬間です。
次回予告:空に触れる — マウスで気象を操り「巨大な台風」を錬成する
地形を作り、大気を纏わせ、雲を流しました。 しかし、私たちはまだ、この美しい星を「外から眺めているだけ」です。
次回(#35)は、「Interactive Storm(インタラクティブな嵐)」。 ついに、この気象システムに干渉する神の視点(インタラクション)を実装します。
Three.jsの Raycaster を用いて、マウスでなぞった軌跡の3D座標を取得。その座標を中心に、第8回で学んだ Curl Noise(回転ノイズ) をベクトル場として発生させ、流れる雲のFBMノイズを局所的にかき乱します。
自らの手で大気をかき混ぜ、惑星の表面に「巨大な台風の渦」を意図的に発生させる実装に挑みます。
鑑賞するノイズから、触れて遊ぶノイズへ。 次回の更新も、どうぞお楽しみに!
💬 コメント