[Noise 入門 #37] Procedural Night Lights — ノイズが描く「文明の灯火」と夜の境界線

はじめに

気象をコントロールし、荒れ狂う嵐の表現を手に入れた前回。しかし、この惑星にはまだ「夜の顔」がありませんでした。

今回は、太陽の光が届かない暗闇(Dark Side)の領域にフォーカスします。Voronoi Noiseの細胞的な幾何学パターンを応用し、まるで宇宙ステーションから見下ろしたかのような「無数の街の明かり(City Lights)」をプロシージャルに錬成します。荒ぶる自然と、静かに瞬く文明の境界線を描き出しましょう。

前回の記事:

1. 昼と夜を分かつ「境界線(Terminator)」の計算

惑星に「街の明かり」を灯す前に、まずは「今は昼なのか、夜なのか」をShaderに判定させる必要があります。太陽光がさんさんと降り注ぐ昼間に街の明かりがギラギラ光っていては不自然だからです。

天文学において、天体の昼と夜の境界線のことをターミネーター(Terminator)と呼びます。これをプロシージャルに計算するために、3Dグラフィックスにおける光の基礎である内積(Dot Product)を使用します。

太陽の位置と地面の向きを測る「内積」

頂点の法線ベクトル(地面が向いている方向)を $N$、光源への方向ベクトル(太陽がある方向)を $L$ としたとき、その表面が受ける光の強さは以下の式で表されます。

$$\text{Diffuse} = N \cdot L = |N||L|\cos(\theta)$$

GLSLではこれを dot(normal, lightDir) という非常にシンプルな関数で計算できます。ベクトルの長さが1(正規化済み)である場合、この計算結果は -1.0 から 1.0 の範囲 になります。

  • 1.0 のとき: 太陽が真上にある(真昼)
  • 0.0 のとき: 太陽が地平線にある(夕暮れ・夜明け = 境界線)
  • マイナス(< 0.0)のとき: 太陽の裏側に隠れている(夜)

この性質を利用して、「夜の領域」だけを白(1.0)、昼の領域を黒(0.0)として扱うマスク(Night Mask)を作成します。

smoothstep で「夕暮れ」をデザインする

もし if (daylight < 0.0) のようにパキッと分けてしまうと、昼と夜の境界線がピクセル単位で鋭利に分かれてしまい、大気を持たない月のような無機質な見た目になってしまいます。

地球のような大気を持つ惑星では、光が散乱するため夕暮れ(トワイライト)というグラデーションの時間が存在します。街の明かりも、日が沈みきる少し前からポツポツと点灯し始めますよね。

これを表現するために smoothstep 関数を使用します。

// 1. 昼夜の判定(-1.0 〜 1.0)
// ※内積を計算する前に、必ずnormalize()でベクトルの長さを1に揃えます
float daylight = dot(normalize(vNormal), normalize(uLightDir));

// 2. 夜の領域だけを1.0にするマスクを作成
// daylightが 0.1(夕暮れ時)から -0.2(完全な夜)にかけて、
// 街の明かりのマスクが 0.0 から 1.0 へと滑らかにフェードインする
float nightMask = smoothstep(0.1, -0.2, daylight);

💡 Tips: 境界線のチューニング
smoothstep(0.1, -0.2, daylight) の数値を調整することで、惑星の環境を変えることができます。例えば smoothstep(0.05, -0.05, daylight) にすれば境界がくっきりし、大気の薄い惑星を表現できます。

この nightMask を出力してプレビューすると、太陽の光が当たっている側は真っ黒、裏側は真っ白で、その境界がふんわりとボケた球体が確認できるはずです。

これが、文明の光を浮かび上がらせるための漆黒のキャンバスになります。

2. なぜ Voronoi Noise なのか?(都市網の幾何学)

夜の闇を描き出したら、次はいよいよ光を灯します。 しかし、ここで単純なランダム関数(White Noise)や、これまで多用してきたSimplex Noiseを使ってしまうと、ただの「星空」や「光るカビ」のような見た目になってしまいます。

なぜなら、文明の明かりはランダムではないからです。

現実の夜景や宇宙ステーションから撮影された地球の写真を見ると、街の明かりには明確な「ネットワーク状の構造」があります。巨大な大都市(ハブ)が強烈な光を放ち、そこから幹線道路が血管のように伸びて、周囲の衛星都市へと繋がっていく幾何学的な広がりを持っています。

この「点と線で構成されたインフラ」を数式で捏造するために最適解となるのが、第12回で細胞やクリスタルの表現に使った Voronoi Noise(ボロノイ・ノイズ) です。

F1とF2で「都市」と「道路」を分解する

Voronoi Noiseの真骨頂は、空間上にばら撒かれた特徴点(Seed)からの距離を計算できることです。 一番近い点までの距離を F1、二番目に近い点までの距離を F2 としたとき、この2つの値を使って「都市の中心」と「都市を繋ぐ道路網」を別々に錬成します。

① ハブ(都市の中心)を F1 で作る

F1 は、特徴点(中心)にいるとき 0.0 になり、離れるほど値が大きくなります。 これを反転(1.0 - F1)させて中心を明るくし、さらに pow や smoothstep で極端に閾値を絞り込むと、「周囲は暗く、中心だけが鋭く光る点(=大都市)」が完成します。

② 道路網(インフラ)を F2 - F1 で作る

F2 - F1 は、2つの特徴点のちょうど中間(細胞の境界線)で 0.0 になるという性質を持っています(第12回のひび割れ表現と同じ原理です)。 これも反転させて細く抽出することで、大都市同士を最短距離で結ぶ「光の幹線道路」として機能します。

GLSLによる都市網の実装

これをGLSLのコードに落とし込むと、以下のようになります。 (※ voronoi() 関数は、内部で F1 と F2 の両方を計算して vec2(F1, F2) で返すものを想定しています)

// ボロノイ関数の呼び出し(スケールを大きくして細かくする)
vec2 v = voronoi(vUv * 50.0);
float f1 = v.x;
float f2 = v.y;

// 1. 都市の中心(Hubs)
// 中心を明るくし、光の広がりを鋭く絞り込む
float hubs = smoothstep(0.8, 1.0, 1.0 - f1);
// より強い光にしたい場合は乗算する
hubs *= 2.0;

// 2. 道路網(Roads)
// 細胞の境界線を細く抽出し、うっすらとした光の線にする
float borders = 1.0 - (f2 - f1);
float roads = smoothstep(0.9, 1.0, borders);
// 道路はハブより少し暗めにする
roads *= 0.5;

// 3. 都市網の完成
// ハブと道路を足し合わせて、ひとつのネットワークにする
float cityPattern = hubs + roads;

💡 Tips: FBMとの掛け合わせで「発展の偏り」を作る
ボロノイだけで作ると、均等に街が配置されてしまい少し人工的すぎることがあります。ここに Simplex Noise による緩やかな FBM(低周波ノイズ)を掛け合わせることで、「人口密集地帯」と「過疎地帯」のムラを作ることができます。地形の起伏だけでなく、ノイズで「文明の偏り」すらもシミュレーションできるのです。

この cityPattern を出力すると、まるでニューラルネットワークや蜘蛛の巣のように張り巡らされた、幾何学的で美しい光の網目が確認できるはずです。

3. 陸地マスクとの融合(海を暗闇に沈める)

夜の闇の領域(Night Mask)と、光の都市網(Voronoi Pattern)が完成しました。しかし、この2つを単純に掛け合わせるだけでは、惑星の裏側が海であろうと陸であろうとお構いなしに光り輝いてしまいます。

我々が錬成しているのは、地球型惑星です。少なくともこの惑星の現在の文明レベルでは、広大な海の上に巨大都市は存在しません。明かりを灯すべきは「陸地だけ」です。

高さ(Elevation)データをマスクとして再利用する

第33回「Procedural Biome」で、惑星の地形を隆起させるためにベースとなる高さデータ(elevation)を生成し、海面レベル(uWaterLevel)を設定したのを覚えているでしょうか。

このデータをそのまま流用し、「海面より高い場所=陸地」という陸地マスク(Land Mask)を作成します。ここで活躍するのが、閾値を境に 0.0 か 1.0 をパキッと返す step 関数です。

// 海面(uWaterLevel)より elevation が高ければ 1.0(陸)、低ければ 0.0(海)
float landMask = step(uWaterLevel, elevation);

これで、陸地だけを白く切り抜いたマスクが手に入りました。

3つの要素を掛け合わせる(論理積)

あとは、これまでに作った要素をすべて乗算(*)で掛け合わせるだけです。Shaderにおける 0.0 と 1.0 の掛け算は、プログラミングにおける AND 条件(論理積)と全く同じ働きをします。

// 前のステップで作った都市網のパターン
// float cityPattern = hubs + roads;

// 最終的な街の明かりの強度
// 「都市網の形」かつ「夜の領域」かつ「陸地」の場所だけが光る
float cityLights = cityPattern * nightMask * landMask;

💡 Tips: 標高による文明の制限(Mountain Mask)
海だけでなく、「高すぎる山脈(エベレストの頂上など)」にも巨大な都市は作られにくいですよね。さらにリアリティを追求するなら、smoothstep を使って標高が高くなるにつれて明かりが減衰するマスクを追加してみてください。
float mountainMask = 1.0 - smoothstep(0.6, 0.8, elevation);
これをさらに掛け合わせることで、海岸線や平野部に文明が集中し、険しい山脈は暗闇に沈むという、より生々しい生態系の分布をシミュレーションできます。

この cityLights の値をプレビューすると、海の部分が完全に漆黒に沈み、大陸の形に沿って無数の都市が浮かび上がる、息を呑むような夜景が姿を現すはずです。

4. GLSLでの質感ブレンド(Coloring the Night)

都市の配置が決まり、海や山脈のマスクも完璧に機能しました。しかし、現状の明かりはただの「真っ白な値(1.0)」です。これでは電子基板のようで、人間の営みを感じられません。

仕上げに、この白い光に「文明の温度」を与え、ベースとなる惑星の地形カラー(Biome)に溶け込ませていきましょう。

ナトリウムランプの熱と、LEDの冷たさ

現実の夜景を想像してみてください。古い街並みや高速道路を照らすナトリウムランプの温かいオレンジ色と、近代的なビル群やLED照明が放つ冷たい青白色(シアン)が混ざり合って、夜景特有の美しいグラデーションを生み出しています。

これをGLSLで表現するために、低周波のノイズ(緩やかなSimplex Noiseなど)を使って空間ごとに色を塗り分けます。

// 緩やかな空間ノイズを取得(-1.0 〜 1.0 を 0.0 〜 1.0 に正規化)
float colorNoise = snoise(vUv * 3.0) * 0.5 + 0.5;

// 文明の光の色(オレンジとシアンをノイズでブレンド)
vec3 warmLight = vec3(1.0, 0.6, 0.2); // ナトリウムランプ(オレンジ)
vec3 coolLight = vec3(0.5, 0.8, 1.0); // LED(青白)
vec3 lightColor = mix(warmLight, coolLight, colorNoise);

これで、ある地域はオレンジ色に輝き、海を隔てた別の大陸では青白く光るという、文化や技術レベルの違いすら予感させる色彩が生まれました。

加算合成(Additive Blending)で闇を照らす

色が決まったら、いよいよこれを惑星の表面に焼き付けます。 ここで重要なのは、mix 関数で地形の色を上書きするのではなく、「加算(Add)」 することです。光は物質を覆い隠すものではなく、空間に足されるエネルギーだからです。

第33回で作った昼間の地形の色(biomeColor)に対して、街の明かりを足し合わせます。夜の領域では biomeColor 自体が暗くなっている(または黒になっている)ため、加算された光だけが鮮やかに浮かび上がります。

// uCityIntensity: 外部(JavaScript側)から明るさを調整するパラメータ
float intensity = uCityIntensity;

// ベースの地形色に、夜の領域だけ街の明かりを加算する
vec3 finalColor = biomeColor + (lightColor * cityLights * intensity);

gl_FragColor = vec4(finalColor, 1.0);

💡 ワンポイントアドバイス:光の瞬き(Twinkle)で生命を吹き込む
宇宙から見る夜景が美しいのは、大気の揺らぎによって光がチカチカと瞬いているからです。これをShaderで再現するには、uTime(経過時間)と高周波のサイン波(sin)、あるいは細かいノイズを cityLights に乗算します。

// 時間と空間座標を使った細かい瞬きの計算
float twinkle = sin(uTime * 5.0 + vUv.x * 100.0) * 0.5 + 0.5;
// 最低限の明るさを担保しつつ、瞬きを乗算
cityLights *= mix(0.5, 1.0, twinkle);

たったこれだけの数式を追加するだけで、静止画だった夜景が突如として「動画」になり、無数の人々がそこで生活しているかのような鼓動を打ち始めます。まさに、プロシージャル生成における魔法のひと手間です。

まとめ:生命と文明の息吹

今回の実装で、あなたの惑星は「ただ回っているだけの岩の塊」から、「知的生命体が活動する世界」へと劇的な進化を遂げました。

昼の領域(Day Side)では嵐が吹き荒れ、豊かな森が広がり、夜の領域(Dark Side)では文明の灯火が静かに、そして力強く輝く。自然のフラクタルな乱数と、文明の幾何学的なパターンが、ターミネーター(境界線)を挟んで共存しています。ノイズという単なる数学が、ここまでエモーショナルな光景を描き出すのです。

次回予告:[Noise 入門 #38] Procedural Aurora — 磁力線とノイズが織りなす極地の光のカーテン 夜の表現を手に入れた惑星ですが、極地に目を向けるとまだ空が寂しいままです。次回は、Domain Warping を一方向に強く引き伸ばし(Stretching)、惑星の磁場に沿って揺らめく「オーロラ」をShaderで錬成します。大気圏の表現をさらに一段階引き上げましょう!お楽しみに。