はじめに
前回の記事で、私たちはノイズから法線(Normal)を捏造し、光と影のシミュレーション(Procedural Lighting)を実装しました。のっぺりとした平面が、険しい山脈や起伏のある大地に見えるようになったはずです。
しかし、まだ世界は「ただの茶色い岩盤」のままです。
今回は、前回計算した 「高さ(Height)」 と 「傾き(法線/Normal)」 を利用して、以下のルールをプロシージャル(自動的)に適用していきます。
- 低い場所: 水が溜まる(Water)
- 高い場所: 雪が積もる(Snow)
- 平坦な場所(上向き): 苔や草が生える(Grass)
- 急斜面(横向き): 岩肌が露出する(Rock)
これらを組み合わせることで、単なるノイズは複雑な生態系を持つ「世界の一部」へと劇的に進化します。
前回の記事:
[Noise 入門 #18] Procedural Lighting — ノイズから法線を捏造し、光と影をシミュレーションする
ノイズの勾配から法線マップを捏造し、GLSLで光と影をシミュレーションするProcedural Lightingの実装手法。中心差分法を用いた法線計算とランバート反射について図解とコードで直感的に解説します。
https://humanxai.info/posts/noise-intro-18-procedural-lighting/1. マスク(Mask)とは何か? — mix 関数の魔法
Shader(GLSL)でプロシージャルな世界を構築する際、複数の色や質感をブレンドするための最も強力な武器となるのが mix 関数 です。
Photoshop などの画像編集ソフトを使ったことがある方なら、「レイヤーマスク」を想像すると分かりやすいでしょう。白黒の画像を使って、白い部分は下のレイヤーを表示し、黒い部分は隠す、といった合成手法です。
プロシージャル・マテリアルの世界でも考え方は全く同じです。 ただし、画像を用意するのではなく、数式(ノイズや計算結果)が吐き出す $0.0$ 〜 $1.0$ の値を「見えない白黒のマスク画像」として扱います。
mix 関数の仕組み(線形補間)
GLSLの mix 関数は、数学的には「線形補間(Linear Interpolation / 略して Lerp)」と呼ばれる処理を行っています。
vec3 finalColor = mix(colorA, colorB, mask);
この内部で行われている計算は、非常にシンプルです。 $colorA \cdot (1.0 - mask) + colorB \cdot mask$
つまり、第3引数である mask の値($0.0$ から $1.0$ の範囲)によって、colorA と colorB のどちらが強く出るかの「比率」が決まるのです。
mask = 0.0のとき: $100%$colorA(colorBは完全に消える)mask = 1.0のとき: $100%$colorB(colorAは完全に消える)mask = 0.5のとき:colorAとcolorBがちょうど半々で混ざるmask = 0.2のとき:colorAが $80%$、colorBが $20%$ の割合で混ざる
視点の切り替え:「ノイズを絵として見る」から「データとして使う」へ
これまでの連載では、ノイズ関数が返す $0.0$ 〜 $1.0$ の値をそのまま画面に出力して、「雲のような白黒の模様」として目で見て確認してきました。
ここから、視点を一段階シフトさせます。 計算された値を「色」として見るのではなく、「マスク(ルール)」として扱う のです。
例えば、岩(Rock)の色と草(Grass)の色を混ぜ合わせたいとします。
vec3 rockColor = vec3(0.5, 0.5, 0.5); // 灰色
vec3 grassColor = vec3(0.2, 0.6, 0.2); // 緑色
// noiseValue は FBMなどで計算した 0.0 〜 1.0 の値
vec3 surfaceColor = mix(rockColor, grassColor, noiseValue);
こうすると、ノイズの値が低い($0.0$ に近い)場所は岩肌になり、値が高い($1.0$ に近い)場所には草が生い茂る、という「自然界の分布ルール」を数式で定義したことになります。
今回は、単なるノイズの値だけでなく、「地形の高さ(Height)」 や、前回計算した 「地形の傾き(Normal)」 をこの mask の部分に代入していくのが最大のポイントです。
「高ければ雪」「平坦なら草」といった論理的なルールを mix 関数のマスクに流し込むことで、単一のノイズが「複雑な生態系(バイオーム)」へと変貌を遂げます。
2. 高さ(Height)による塗り分け — 標高でバイオームを分ける
まずは一番直感的でシンプルな「高さ」を基準にした塗り分けです。 FBMなどのノイズ関数が生成する値は、基本的に 0.0(黒)から 1.0(白)の範囲に収まります。この値をそのまま 「標高(0.0 が海底、1.0 が山の頂上)」 として見立てます。
水と陸の境界線を作る(smoothstep の活用)
標高が一定以下の場所を「水(海や湖)」、それ以上を「陸地(土)」にしてみましょう。
ここで、Shader プログラミングにおいて mix 関数と並んで最も頻繁に登場する smoothstep 関数 が大活躍します。
もし、境界線をくっきりと分けたい場合は step 関数を使うか、if 文で分岐させることもできます(ただし GLSL では if 文はパフォーマンス低下の原因になりやすいため、極力避けるのが鉄則です)。しかし、自然界の境界線は定規で引いたようにくっきりとはしていません。
smoothstep を使うことで、境界に滑らかなグラデーション(遷移領域)を作り出すことができます。
// noiseValueは 0.0(海底) 〜 1.0(山頂) の値
vec3 waterColor = vec3(0.1, 0.3, 0.6); // 水の色
vec3 dirtColor = vec3(0.4, 0.3, 0.2); // 土の色
// 0.3以下の場所は 0.0 (水)
// 0.35以上の場所は 1.0 (陸)
// 0.3 〜 0.35 の間は 0.0〜1.0 へ滑らかに変化
float waterMask = smoothstep(0.3, 0.35, noiseValue);
// マスクを使って水と土をブレンド
vec3 baseColor = mix(waterColor, dirtColor, waterMask);
この 0.3 から 0.35 という「幅」を持たせることが非常に重要です。このグラデーションの幅が、水と土が混ざり合う「濡れた泥」や「浅瀬の砂浜」のような自然な遷移を生み出します。
標高の高い場所に雪を降らせる
同じ考え方を山の頂上にも適用します。 標高が 0.8 以上の場所を「雪山」にしてみましょう。
vec3 snowColor = vec3(0.95, 0.95, 0.95); // 雪の色
// 0.7以下の場所は 0.0 (土のまま)
// 0.8以上の場所は 1.0 (完全に雪)
// 0.7 〜 0.8 の間は まばらに雪が積もった状態
float snowMask = smoothstep(0.7, 0.8, noiseValue);
// 先ほど作った baseColor に、上から雪をブレンドする
baseColor = mix(baseColor, snowColor, snowMask);
ここでも 0.7 から 0.8 という比較的広めの遷移幅を取っています。
これにより、「ある高さを境に突然真っ白になる」不自然な山ではなく、「標高が上がるにつれて少しずつ雪が積もり始め、頂上付近で完全に雪に覆われる」というリアルな積雪表現(Snow Line)を作ることができます。
3. 傾き(法線)による塗り分け — 地形に「重力」のルールを与える
高さ(標高)によるバイオーム分けができたら、次は「傾き」の判定です。
現実の山を想像してみてください。同じ標高であっても、「なだらかな高原(平坦)」 には土が積もり草が生い茂りますが、「切り立った崖(急斜面)」 は土が滑り落ちてしまい、ゴツゴツとした岩肌が露出しているはずです。
この「重力による自然界のルール」を Shader で実装するには、地形の「傾き」を知る必要があります。では、傾きはどうやって取得するのでしょうか?
実は、前回の「Procedural Lighting」で光と影を作るために苦労して計算した 法線ベクトル(Normal) が、ここで最高の働きをしてくれます。
$Y$ 軸の成分($N_y$)が「平坦さ」を教えてくれる
法線(Normal)とは、その表面が「どの方向を向いているか」を表す長さ $1.0$ の矢印(ベクトル)です。
- 地形が完全に平坦なとき:
法線は真っ直ぐ空(上)を向きます。つまり
vec3(0.0, 1.0, 0.0)となり、$Y$ 軸の成分は $1.0$ になります。 - 地形が切り立った絶壁(垂直)のとき:
法線は真横を向きます。例えば
vec3(1.0, 0.0, 0.0)のようになり、$Y$ 軸の成分は $0.0$ に近づきます。
つまり、法線の $Y$ 成分(normal.y)を見れば、その場所の傾斜がすぐに分かる のです。normal.y が $1.0$ に近ければ平地、$0.0$ に近ければ急斜面です。
岩肌(Rock)と草(Grass)をブレンドする
この normal.y の性質を利用して、「急斜面には岩(Rock)」を、「平坦な場所には草(Grass)」を配置してみましょう。
ここでも境界を自然に馴染ませるために smoothstep を使います。
vec3 rockColor = vec3(0.4, 0.4, 0.4); // 岩の灰色
vec3 grassColor = vec3(0.2, 0.5, 0.1); // 草の緑色
// normal.y は 0.0(絶壁) 〜 1.0(平地)
// normal.y が 0.6 以下なら 0.0(岩)
// normal.y が 0.8 以上なら 1.0(草)
// その間は岩と草が混ざり合う
float grassMask = smoothstep(0.6, 0.8, normal.y);
// 岩をベースにして、平坦な場所に草を上塗り(ブレンド)する
vec3 terrainColor = mix(rockColor, grassColor, grassMask);
💡 GLSL実装のポイント
smoothstepの引数はsmoothstep(最小値, 最大値, 対象の値)の順で書くのがGLSLの標準的なお作法です。そのため、「$0.6$〜$0.8$ で草が生えるマスク」としてgrassMaskを作り、mixの第1引数にrockColor、第2引数にgrassColorを置くことで、直感的かつ安全にブレンド処理を行うことができます。
陰影のためのデータが、質感のデータに化ける
ここで面白いのは、前回のライティングで使った「光を計算するための法線データ」を、そのまま「質感を決めるためのマスクデータ」として再利用している という点です。
プロシージャル生成の世界では、このようにひとつの数学的な計算結果(ノイズや法線)が、形を作り、影を落とし、さらには生態系(色)を決定するルールへと、連鎖的に再利用されていきます。これが Shader によるワールド生成の非常に美しく、かつパフォーマンスに優れた設計思想なのです。
4. 全てを統合する(GLSL実装イメージ) — 生態系を重ね塗る
ここまで解説した「高さ(標高)」と「傾き(法線)」のマスクを、いよいよ一つの世界に統合します。 Shaderで複数の質感をブレンドする際のコツは、「キャンバスの奥(または下地)から順番に重ね塗りをしていく」という意識を持つことです。
実際のGLSLのコード構成を見てみましょう。
// 1. 基本となる色(パレット)を定義
vec3 waterColor = vec3(0.1, 0.3, 0.6); // 水色
vec3 grassColor = vec3(0.2, 0.5, 0.1); // 草の緑色
vec3 rockColor = vec3(0.4, 0.4, 0.4); // 岩肌の灰色
vec3 snowColor = vec3(0.9, 0.9, 0.9); // 雪の白色
// 2. 下地作り:傾き(法線)ベースのブレンド(草と岩)
// 平坦なら草、急斜面なら岩をベースの地形とする
float rockMask = smoothstep(0.8, 0.6, normal.y);
vec3 surfaceColor = mix(grassColor, rockColor, rockMask);
// 3. 上塗り:高さベースのブレンド(雪)
// 高い場所は、下地が草だろうが岩だろうが「雪」で上書きする
float snowMask = smoothstep(0.7, 0.8, noiseValue);
surfaceColor = mix(surfaceColor, snowColor, snowMask);
// 4. さらに上塗り:高さベースのブレンド(水)
// 水位以下の場所は、問答無用で「水」で塗りつぶす
float waterMask = smoothstep(0.35, 0.3, noiseValue); // 水は低い場所なので数値を反転気味に
vec3 finalColor = mix(surfaceColor, waterColor, waterMask);
// 5. 仕上げ:前回のLighting(陰影)を掛けて立体感を与える!
finalColor *= diffuseLight;
レイヤー順序(合成順)の重要性
このコードの美しさは、mix 関数をチェーンのようにつなぎ合わせている点にあります。
まず世界全体を「草と岩」のルールで構築し(surfaceColor)、その上に「雪」のルールを上書きし、さらに一番上に「水」のルールを上書きしています。
複雑な if-else などの条件分岐を使わずに、数式の連続的な計算だけで優先順位(高いところは雪、低いところは水)を表現できるのが、GLSLの非常に強力で美しい点です。
💡 ワンポイント・プロテクニック:境界線を「自然のゆらぎ」で侵食する
上記のコードでも十分に美しい地形になりますが、実はまだ少し「CGっぽさ」が残ります。例えば、雪が積もる境界線(スノーライン)が、山の等高線に沿って綺麗に真っ直ぐ入りすぎてしまうためです。
自然界の境界線はもっと複雑に侵食し合っています。これを解決する魔法のテクニックが「閾値(しきいち)にノイズを足す」というアプローチです。// 別の細かいスケールのノイズ(例:detailNoise)を用意しておく float detailNoise = fbm(uv * 10.0);
// 雪の境界線(0.7)を、細かいノイズでわずかにランダムに揺らす!
float snowLine = 0.7 + (detailNoise - 0.5) * 0.1;
// 揺らいだ境界線を使ってマスクを作る
float snowMask = smoothstep(snowLine, snowLine + 0.1, noiseValue);このように
smoothstepの基準値自体を別のノイズで揺らす(ドメインワーピング的な発想)ことで、直線的だった雪と岩の境界が、ジグザグとした入り組んだ「自然界らしい境界線」へと劇的に進化します。海岸線の泥の混ざり具合など、あらゆる場面で使える 「ノイズによるマスクの破壊」 は、プロのテクニカルアーティストも多用する必須テクニックです。
次回予告
第2集(アルゴリズム編)完結!Shader のパフォーマンス最適化とミクロなディテール
平面だったノイズは、光を浴びて立体となり、ついに草や雪、水といった「マテリアル(質感)」を獲得しました。これにて、ノイズから世界を創り出すための基礎的なアルゴリズムはほぼ出揃いました。
次回(#20)は、このアルゴリズム編の集大成。 これまで重ねに重ねてきた FBM や法線計算、条件分岐によって重くなりつつある Shader の パフォーマンス最適化(LODの概念など) と、遠景だけでなく近景を見たときの ミクロなディテール(Bump Mapping)の追加 について解説します。
いよいよ第2集が完結し、本格的な「魔法の具現化(Shader実践編)」へと足を踏み入れます。お楽しみに!
💬 コメント