はじめに
「ただの凸凹」から「意味のある大地」へ。 Perlin Noise をそのまま適用した地形は、ただの「滑らかな丘」に過ぎません。険しい山脈、平原、そして雪解け水が削ったような谷を作るには、ノイズの「重ね方」に物理的な意味を持たせる必要があります。今回は、頂点シェーダー(Vertex Shader)を使って、平面(Plane)をリアルタイムに隆起させ、Ridge Noise と Masking を駆使して「大陸」をデザインします。
前回の記事:
[Noise 入門 #09] Volumetric Clouds — ノイズで空に雲を浮かべる(レイマーチング基礎)
Noise 入門シリーズ第9回。ボリューメトリック・レンダリングの基礎となるレイマーチングの手法を学びます。3D FBM による密度生成、Beer's Law を用いた光の減衰計算など、ノイズを『空間の密度』として扱う技術を体系的に整理します。
https://humanxai.info/posts/noise-intro-09-volumetric-clouds/1. 導入:空から降り立つ
前回、私たちはレイマーチングという強力な武器を手に入れ、数学的に生成された雲の中を自由に飛び回りました。 視界を覆う白い霧、雲海を抜けた瞬間の青空。空はもう「書き割り(スカイボックス)」ではなく、奥行きのある「場所」になりました。
今、パラシュートで地上へ降り立ったと想像してください。 足元に広がっているのは何でしょうか?
今のところ、そこには 何もない「無限の平面」 が広がっています。
Three.js で言えば PlaneGeometry、ただの平らな板ポリゴンです。これでは世界とは呼べません。
神(プログラマー)は言った。「山あれ」と。
私たちはすでにノイズという道具を持っています。 「平面の頂点を、ノイズの値に合わせて持ち上げればいいんじゃないか?」 そう考えるのは自然なことです。
// 頂点シェーダーでの単純な変位
float h = noise(position.xz * frequency);
position.y += h * amplitude;
しかし、これを実行しても、そこに現れるのは「山」ではありません。 それはまるで 「固まったオートミール」 か、あるいは 「溶けかけたアイスクリーム」 のような、ヌルっとした滑らかな丘です。
なぜなら、Perlin Noise や Simplex Noise は「滑らかであること」を目的に作られた関数だから です。
自然界の山岳地帯は、もっと荒々しく、雨風に削られた鋭い尾根(Ridge)を持ち、複雑な侵食(Erosion)の痕跡があります。ただの noise() では、この「険しさ」が表現できないのです。
今回は、この「滑らかな丘」に数学的なメスを入れ、Ridge Noise(尾根ノイズ) や Domain Warping を駆使して、本物のアルプス山脈のような「大地」へと隆起させていきます。
2. Heightmap(ハイトマップ)の基礎
地形生成の最も基本的なアプローチ、それが Heightmap(ハイトマップ) です。
直訳すると「高さの地図」。2次元のグリッド上の各点(ピクセルや頂点)に、「高さ(Elevation)」という情報を割り当てたものです。
色を「高さ」に変換する
通常、ハイトマップはグレースケールの画像として表現されます。
画像の「明るさ」を、そのまま地形の「高さ」として解釈するのです。
- 黒(0.0): 最も低い場所。海底、あるいは平坦な平原。
- 白(1.0): 最も高い場所。険しい山頂。
- グレー(0.5): その中間の斜面や丘。
今回は画像データを使わず、この 0.0 〜 1.0 の値を ノイズ関数(Perlin Noise や FBM) でリアルタイムに計算します。
Vertex Displacement:色から「形」へ
これまでの連載(#01〜#09)では、主に フラグメントシェーダー(Fragment Shader) を使って、平面や空間の「色」を計算してきました。 しかし今回は、バーテックスシェーダー(Vertex Shader) が主役です。
バーテックスシェーダーは、3D空間にある「頂点(Vertex)」の位置を操作するプログラムです。ここでノイズを使うことで、平面の頂点を物理的に持ち上げ、形そのものを変形させます。これを Vertex Displacement(頂点変位) と呼びます。
// Vertex Shader のイメージ
void main() {
// 1. 現在の頂点のXZ座標(地面の広がり)を取得
vec2 pos = position.xz;
// 2. ノイズ関数で「高さ」を計算
// frequency: 地形の細かさ
// amplitude: 山の高さ
float h = noise(pos * frequency) * amplitude;
// 3. Y座標(高さ)に加算して持ち上げる
vec3 newPosition = position;
newPosition.y += h;
// 4. 変換して描画
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
注意点:
この手法を使うには、対象となる PlaneGeometry(板ポリゴン)に 十分な分割数(segments) が必要です。頂点が4つしかない板では、どんなに複雑なノイズを計算しても、山を作ることはできません。
new PlaneGeometry(100, 100, 256, 256)のように、細かく分割されたメッシュを用意しましょう。
3. 「山」らしくする魔法:Ridge Noise(尾根ノイズ)
ただの y = noise(x) で作った地形は、どんなにパラメータ(Frequency / Amplitude)を調整しても「丸い」ままです。
現実の山、特にアルプス山脈や火山のような険しい地形には、雨水が削ったような 「鋭い谷」 や、切り立った 「尾根(Ridge)」 が存在します。
普通の Perlin Noise や FBM は「滑らかであること」が特徴なので、この「鋭さ(Sharpness)」が決定的に不足しています。
解決策:絶対値 abs() を使う反転テクニック
ここで、高校数学で習った 絶対値(Absolute Value) が魔法のような効果を発揮します。
サイン波(sin(x))を想像してください。滑らかな波です。
これに絶対値をかけて |sin(x)| にするとどうなるでしょうか? マイナスの部分がプラスに折り返され、0 の地点で 「V字型」の鋭い谷 が生まれます。
この性質を利用して、ノイズの波形を鋭く尖らせるのが Ridge Noise(尾根ノイズ) です。 別名「Swiss Turbulence(スイス・タービュランス)」とも呼ばれるこのテクニックは、以下の3ステップで作られます。
- 折り返す:
abs(noise(p))
- これで滑らかな波が、鋭い「谷」を持つ波に変わります。
- 反転する:
1.0 - abs(noise(p))
- 谷(下向きの鋭さ)をひっくり返して、山(上向きの鋭さ=尾根) にします。
- 尖らせる:
pow(n, exponent)
- さらに
pow(累乗)をかけることで、裾野を広げつつ山頂だけを鋭くピンと立たせます。
数式とコード
GLSLで書くと、たったこれだけの変更です。
// 普通のノイズ(-1.0 ~ 1.0)
float n = noise(p);
// Ridge Noise への変換
// 1. 絶対値で折り返し、反転して「尾根」を作る (0.0 ~ 1.0)
n = 1.0 - abs(n);
// 2. 累乗で鋭さを強調する
// exponent(指数)が大きいほど、壁のように切り立った山になる
n = pow(n, 4.0);
// これを高さとして加算
position.y += n * strength;
この数行の魔法をかけるだけで、先ほどまでの「溶けかけたアイスクリーム」のような丘が、突如として 「人を寄せ付けない険しい岩山」 へと変貌します。 これが、プロシージャル地形生成における最も重要で、最も費用対効果の高いトリックです。
4. Domain Warping 再考:川と浸食
第6回「Domain Warping — 座標をねじると世界が壊れる」で学んだあの技術を、ここで再び召喚しましょう。 ただし今回は、空間をカオスに破壊するためではなく、「自然の複雑さ」を模倣するため に使います。
直線を曲げる
Ridge Noise で作った山脈は、鋭くて格好いいのですが、一つ欠点があります。 それは、「形が整いすぎている」 ことです。 Perlin Noise のグリッド構造の影響で、山脈が妙に直線的だったり、不自然な規則性が見えてしまうことがあります。
自然界の山や川はどうでしょうか? 川は蛇行(Meander)し、地層は圧力で歪み、谷底は水流によって削られながら曲がりくねっています。 この 「ぐにゃりと曲がった地形」 を表現するのに、Domain Warping は最適です。
「安価な浸食(Erosion)」としての利用
本来、リアルな地形を作るには「水力浸食(Hydraulic Erosion)」というシミュレーションを行います。 これは「雨粒を数万個降らせて、土砂を削り、低い場所に運ぶ」という物理計算を行うもので、非常に計算コストが高く、リアルタイム(60fps)で動かすシェーダーには向きません。
そこで Domain Warping の出番です。
地形の高さを計算する 前に、座標(position.xz)を少しだけノイズでずらします。
// 1. 座標を少しずらす(Warping)
vec2 q = vec2(
fbm(p + vec2(0.0, 0.0)),
fbm(p + vec2(5.2, 1.3))
);
// 2. ずらした座標を使って、高さを計算する
// p そのものではなく p + q を使う
float height = fbm(p + q * warpStrength);
// 3. その高さで Ridge Noise を適用
height = 1.0 - abs(height);
height = pow(height, 2.0);
たったこれだけで、山脈はまるで巨大な力でねじ曲げられたように歪み、谷底は川のように蛇行を始めます。 これは物理的な浸食シミュレーションではありませんが、「何万年もの風化と地殻変動を経たような見た目」 を、計算コストほぼゼロで手に入れるための、プロシージャル生成における 「安価で効果的なトリック」 です。
5. Biomes(バイオーム):高度と傾斜による塗り分け
頂点シェーダーで「形」ができたら、次はフラグメントシェーダーで「色(テクスチャ)」を塗ります。
手作業でテクスチャを描くわけではありません。ここでも数学的なルール、すなわち バイオーム(Biome) の定義を使って、自動的に色を決定します。
高度(Height)による判定:レイヤーケーキを作る
最も単純なルールは、「高さ」に応じて色を変えることです。
地形の高さを 0.0(底)から 1.0(頂上)としたとき、以下のように層(Layer)を定義します。
- Water (0.0 - 0.2): 海や湖(青)
- Grass (0.2 - 0.6): 平原や森林(緑)
- Rock (0.6 - 0.8): 森林限界を超えた岩場(茶・グレー)
- Snow (0.8 - 1.0): 万年雪に覆われた山頂(白)
vec3 color;
if (height < 0.2) {
color = vec3(0.0, 0.0, 0.8); // Water
} else if (height < 0.6) {
color = vec3(0.1, 0.6, 0.1); // Grass
} else if (height < 0.8) {
color = vec3(0.5, 0.5, 0.5); // Rock
} else {
color = vec3(1.0, 1.0, 1.0); // Snow
}
これだけで、それっぽい山に見えてきます。しかし、これだけでは「塗り分け」が水平すぎて、不自然な縞模様(レイヤーケーキ)になってしまいます。
傾斜(Slope)による判定:リアリズムの鍵
ここでプロシージャル生成の醍醐味、傾斜(Slope) の登場です。
自然界を観察してみてください。草は「高さ」だけで生えているわけではありません。どんなに低い場所でも、切り立った「崖」に草は生えませんよね? そこは岩肌が露出しているはずです。
これをシェーダーで表現するには、法線ベクトル(Normal) を使います。
法線とは「面が向いている方向」のことです。
- 平らな地面: 法線は真上
(0, 1, 0)を向いている。 - 垂直な崖: 法線は横
(1, 0, 0)などを向いている。
この性質を利用して、「真上ベクトル」と「現在の法線」の 内積(Dot Product) を取ると、その地面がどれくらい平らか(傾斜度)がわかります。
// 上方向との内積をとる(1.0なら平坦、0.0なら垂直な崖)
float slope = dot(vNormal, vec3(0.0, 1.0, 0.0));
// 崖(slopeが小さい)なら岩、平らなら草
// mix関数で滑らかにブレンドする
vec3 finalColor = mix(rockColor, grassColor, smoothstep(0.3, 0.6, slope));
このロジックを入れることで、「平原の中に隆起した岩山」 や 「雪山の中の切り立った崖」 が自動的に生成され、地形のリアリティが一気に「世界」のレベルへと昇華します。
6. 実装:Three.js + ShaderMaterial
理論は完璧です。あとはコードに落とし込むだけです。
Three.js の ShaderMaterial を使って、これまでのロジック(Ridge Noise, Domain Warping, Biome Color)を統合します。
1. PlaneGeometry:キャンバスを用意する
まず、地形のベースとなる板ポリゴンを用意します。
ここで最も重要なのは 分割数(Segments) です。バーテックスシェーダーで頂点を持ち上げるため、頂点の数が少なければカクカクの粗い地形にしかなりません。
// 幅20, 奥行き20の世界。
// 分割数は 256x256(合計約6.5万頂点)以上推奨。
// PCスペックが許すなら 512x512 にするとディテールが劇的に向上します。
const geometry = new THREE.PlaneGeometry(20, 20, 256, 256);
// マテリアル(シェーダー)
const material = new THREE.ShaderMaterial({
vertexShader: vertexShader, // 後述
fragmentShader: fragmentShader, // 後述
uniforms: {
uTime: { value: 0 },
uColorWater: { value: new THREE.Color('#1a5178') },
uColorGrass: { value: new THREE.Color('#3a9e3a') },
uColorRock: { value: new THREE.Color('#635748') },
uColorSnow: { value: new THREE.Color('#ffffff') },
// Fogの設定はThree.jsのScene設定と連動させるのが少し手間なので
// ここでは簡易的にUniformで色を渡します
uFogColor: { value: new THREE.Color('#e0e0e0') },
uFogDensity: { value: 0.15 },
},
// 地形の裏側も見たい場合は DoubleSide
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2; // 地面として寝かせる
scene.add(mesh);
2. Vertex Shader:形状を変形する
ここが心臓部です。Ridge Noise を計算し、position.y を書き換えます。
また、フラグメントシェーダーで「傾斜(Slope)」による塗り分けを行うために、変形後の 法線(Normal) を再計算する必要があります。
// --- Vertex Shader ---
uniform float uTime;
varying float vElevation; // 高さをFragmentに渡す
varying vec3 vNormal; // 法線をFragmentに渡す
varying vec2 vUv;
// ここに noise(), fbm() 関数を定義...
float getElevation(vec2 p) {
// Ridge Noise の計算
float h = fbm(p * 2.0);
h = 1.0 - abs(h); // 鋭くする
h = pow(h, 3.0); // さらに尖らせる
return h * 2.0; // 高さの係数
}
void main() {
vUv = uv;
vec3 pos = position;
// 1. 高さを適用
float h = getElevation(pos.xz);
pos.y += h;
vElevation = h; // 高さ情報を保存
// 2. 法線の再計算(近隣の点をサンプリングして傾きを調べる)
// これをしないと、形が変わっても影や傾斜判定が正しく出ません
float offset = 0.01;
vec3 tangentX = vec3(offset, getElevation(pos.xz + vec2(offset, 0.0)) - h, 0.0);
vec3 tangentZ = vec3(0.0, getElevation(pos.xz + vec2(0.0, offset)) - h, offset);
vNormal = normalize(cross(tangentZ, tangentX));
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
3. Fragment Shader:世界に色を塗る
高さ(vElevation)と傾斜(vNormal)を使って色を決定し、最後に霧(Fog)をかけて遠近感を出します。
// --- Fragment Shader ---
uniform vec3 uColorWater;
uniform vec3 uColorGrass;
uniform vec3 uColorRock;
uniform vec3 uColorSnow;
uniform vec3 uFogColor;
uniform float uFogDensity;
varying float vElevation;
varying vec3 vNormal;
varying vec2 vUv;
void main() {
// 1. 基本の色を決定(高さベース)
vec3 color = uColorGrass;
// 水面
if(vElevation < 0.1) {
color = uColorWater;
}
// 岩肌(高い場所)
else if(vElevation > 1.2) {
color = uColorRock;
}
// 雪山(最高地点)
if(vElevation > 1.8) {
color = uColorSnow;
}
// 2. 傾斜ミックス(崖には草を生やさない)
// 法線の上方向成分(y)を見る。1.0に近いほど平坦。
float slope = dot(vNormal, vec3(0.0, 1.0, 0.0));
// 傾斜が急(slope < 0.5)なら岩の色を混ぜる
float rockMix = 1.0 - smoothstep(0.3, 0.6, slope);
color = mix(color, uColorRock, rockMix);
// 3. 簡易的なライティング(あると立体感が出る)
vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5));
float light = max(dot(vNormal, lightDir), 0.0);
color *= (0.5 + light * 0.5); // 環境光 + 拡散光
// 4. Fog(霧)を追加:空気遠近法
// カメラからの距離に応じて白く霞ませる
float depth = gl_FragCoord.z / gl_FragCoord.w;
float fogFactor = exp2(-uFogDensity * uFogDensity * depth * depth * 1.44);
fogFactor = clamp(fogFactor, 0.0, 1.0);
// 霧の色と合成
vec3 finalColor = mix(uFogColor, color, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
これで、遠くの山々が霧に霞み、近くの崖は険しく切り立った、プロシージャルな絶景の完成です。
7. まとめ:世界は計算された
モニターの向こうに広がっている、霧に包まれた険しい山脈。
信じられないかもしれませんが、この風景の中には 「画像データ」は1枚も使われていません。
テクスチャも、モデリングソフトで作ったポリゴンデータもありません。
ここにあるのは、たった数行の 数式(Noise function) と、それを制御する パラメータ(Frequency, Amplitude, Exponent) だけです。
あなたがコードを書き換えたその瞬間に、GPUが数億回の計算を行い、何もない空間に「世界」を隆起させたのです。
無限のキャンバス
この世界の最大の特徴は、「果てがない」 ことです。
画像データで作られた背景(スカイボックスや地形メッシュ)には、必ず「解像度の限界」や「端っこ」があります。近づけばボケるし、遠くまで行けば壁にぶつかります。
しかし、プロシージャル生成されたこの大地には、境界線がありません。
カメラをどこに向けても、どこまで歩き続けても、ノイズ関数は新たな地形を生成し続けます。
微小な岩肌に顕微鏡レベルで近づいても、フラクタルの性質によりディテールが保たれます。
「空」と「大地」の融合
前回学んだ Volumetric Clouds(雲) と、今回学んだ Procedural Terrain(大地)。 この2つが揃った今、あなたのブラウザの中には、もはや「デモ」ではなく、一つの完全な 「世界」 が存在しています。
Noise 入門 第1集(基礎編):完結
これにて、全10回にわたる「Noise 入門シリーズ:第1集(基礎編)」は完結です。
ランダム関数の基礎から始まり、グリッド、勾配、FBM、Warping、そして雲と大地へ。
ここまでお付き合いいただき、本当にありがとうございました。
しかし、世界にはまだ「動き」と「色彩」が足りません。
静止した大地に、命を吹き込む必要があります。
次なる旅へ:第2集(表現編)スタート
次回からは、「Noise 入門 第2集:具体的な表現編」 へと突入します。
基礎理論を卒業し、より実践的で、より高度な「自然現象」の再現に挑みます。
- Water: 寄せては返す波、深海の色。
- Time: 移ろいゆく空の色、昼と夜のサイクル。
- Life: 風に揺れる草花、自動生成される森。
- Magic: 炎、雷、魔法エフェクト(VFX)。
最初のテーマは「水」です。
ノイズと物理シミュレーション(Gerstner Wave)を組み合わせ、美しく波打つ 「海」 を作ります。
[Noise 入門 第2集 #11] Procedural Water — ノイズで海面を波立たせる(Gerstner Wave vs Noise)
旅はまだまだ続きます。
エンドレスなノイズの海で、またお会いしましょう。
次回予告へのブリッジ(第2集へ)
これで、Noise入門の「第1集(基礎〜世界生成編)」は完結です。
私たちは、0と1の乱数からスタートし、グリッドを描き、雲を浮かべ、そして大地を隆起させるところまで辿り着きました。
しかし、この世界を見渡すと、まだ決定的に足りないものがあります。
- 水(Water): 寄せては返す波、川のせせらぎ(Flow Map)、深海の青。
- 時間(Time): 移ろいゆく空の色、昼と夜のサイクル、動的な天候変化。
- 生命(Life): 風に揺れる草花、自動生成される森(Instanced Mesh)。
静止した「箱庭」から、息づく「世界」へ。
次回からは、より高度なトピック、そして具体的な自然現象の再現にフォーカスした新シリーズ「第2集:表現編」へと足を踏み入れます。
最初のテーマは、生命の源である「水」です。
ノイズと物理シミュレーションを組み合わせ、美しく波打つ海面を作ります。
[Noise 入門 第2集 #11] Procedural Water — ノイズで海面を波立たせる(Gerstner Wave vs Noise)
旅はまだまだ続きます。
エンドレスなノイズの海で、またお会いしましょう。
💬 コメント