はじめに
Noise 入門シリーズ第32回。前回の「平面(Plane)への干渉」から次元を一つ引き上げ、Three.jsの SphereGeometry とカメラ制御(OrbitControls)を組み合わせます。 数式によって隆起した未知の惑星の表面を、自らの手で回し、触れ、局所的に地形を歪ませる「プロシージャルな天体探索」の実装手法を深掘りします。
前回の記事:
[Noise 入門 #31] 第4集開幕 — Three.js × GLSLで「触れるノイズ」を実装する
Noise 入門シリーズ第31回。これまでの「鑑賞するノイズ」から「体験するノイズ」へ。Three.jsのRaycasterとShaderを連携させ、マウスホバーによる局所的なDomain Warping(空間のねじれ)を実装するインタラクションの基礎を深掘り …
https://humanxai.info/posts/noise-intro-31-interactive-noise-threejs/1. 2D UVから「3D座標(ローカル空間)」への脱却 — 包装紙から彫刻へ
これまで私たちは、平面(Plane)に対してノイズを描画してきました。このとき使っていたのは、テクスチャの縦横を示す UV座標(2D) です。 しかし、舞台が「球体(Sphere)」へと変わった瞬間、この2DのUV座標に頼るアプローチは完全に破綻します。
なぜ破綻するのか? その理由と、次元を超えるための「3Dノイズサンプリング」というパラダイムシフトについて解説します。
1-1. 2D UVマッピングの限界 — 「ピンチング」と「シーム」
地球儀を思い浮かべてみてください。長方形の世界地図(2Dノイズ)を球体に無理やり貼り付けようとすると、主に2つの致命的な問題が発生します。
- ピンチング(Pinch / 極点の収束) UV座標の $v = 0$(南極)や $v = 1$(北極)の部分では、テクスチャの「横幅全体のピクセル」が、球体の「たった1つの頂点」にギュッと押し潰されてしまいます。その結果、極点周辺のノイズは放射状に歪み、不自然な特異点が生まれます。
- シーム(Seam / 継ぎ目) UV座標の $u = 0$(左端)と $u = 1$(右端)が背中合わせになる経線上で、ノイズの値がシームレスに繋がっていなければ、くっきりと「テクスチャの切れ目」が見えてしまいます。
2Dの平面ノイズを「包装紙」のように球体に包む手法では、この歪みと切れ目から逃れることはできません。
1-2. 3D座標によるサンプリング — 「大理石の彫刻」というパラダイムシフト
この問題を解決する絶対法則が、ノイズの引数(座標)を2Dから3Dへと次元拡張することです。
包装紙で包むのではなく、「すでにノイズが充満している3Dの空間(大理石のブロック)から、球体の形を彫り出す」という考え方にシフトします。
具体的には、Vertex Shader(頂点シェーダー)に渡される頂点の ローカル座標(3Dベクトル) を、そのまま3Dノイズ関数 snoise3() の空間座標として渡します。
$$\text{NoiseValue} = \text{snoise3}(\vec{p} \cdot \text{frequency})$$
ここで言う $\vec{p}$ は、球体を構成する各頂点の position($x, y, z$)です。
3Dノイズは、空間のどの座標 $(x, y, z)$ を入力しても、全方位に対して連続的で滑らかな値を返します。そのため、極点だろうと裏側だろうと、「そもそも継ぎ目という概念が存在しない」完璧にシームレスな表面模様が生成されるのです。
1-3. GLSL実装における「球体」ならではのハック
実際のGLSLコードでこの3Dサンプリングを行う際、球体(Sphere)ならではの美しい数学的性質を利用できます。
原点 $(0, 0, 0)$ を中心とする真球の場合、「頂点のローカル座標を正規化(Normalize)したベクトル」は、「その頂点の法線ベクトル(Normal)」と完全に一致します。
つまり、空間からノイズをサンプリングするための座標として、頂点の position を使うことも、normal を使うことも、本質的には同じ方向を指し示していることになります。(※地形を隆起させる前の初期状態において)
// 頂点のローカル座標そのものを3D空間のサンプリングポイントとして使う
vec3 noisePos = position * u_frequency;
// 3D Simplex Noise で連続的な値を取得
float n = snoise(noisePos);
このように「次元を一つ上げる」だけで、3Dモデル特有のUVの呪縛から解放され、宇宙のどこから見ても破綻のない美しい惑星の素体が手に入ります。
2. Vertex Shaderによる「球体の隆起(Displacement)」 — 法線を操り、世界を形作る
前セクションで、3D空間の座標を使ってシームレスなノイズを取得する方法を学びました。しかし、この時点ではまだ「ツルツルの球体の表面に、ノイズの模様が描かれているだけ」です。
ここから、このノイズの値を物理的な「高さ(Height)」へと変換し、球体をボコボコとした未知の惑星へと変形(Displacement)させていきます。
2-1. 法線ベクトル(Normal)という「外側への道標」
平面の地形(PlaneGeometry)を隆起させるときは、シンプルにY軸方向(上方向)へ頂点を移動させるだけで済みました。しかし、球体において「上」とはどこでしょうか? 北極の頂点にとってはY軸プラス方向ですが、赤道の頂点にとってはX軸やZ軸方向になります。
ここで活躍するのが法線ベクトル(Normal)です。
法線ベクトルとは、その頂点が「向いている方向」を示す矢印です。真球の場合、すべての頂点の法線ベクトルは「球の中心から外側へ向かって垂直に伸びる線」になります。つまり、頂点を隆起させるための「完璧な外側への道標」が、すでに normal としてVertex Shaderに渡されているのです。
2-2. スカラー(ノイズ値)とベクトル(法線)の掛け算
取得したノイズの値(ここでは仮に -1.0 〜 1.0 を行き来するとします)を、この法線ベクトルに掛け合わせます。
数学的な変位(Displacement)の式は以下のようになります。
$$\vec{p}_{new} = \vec{p} + \vec{n} \cdot \text{NoiseValue} \cdot \text{amplitude}$$
- $\vec{p}$ (position):元の頂点のローカル座標
- $\vec{n}$ (normal):頂点の法線ベクトル(向き)
- $\text{NoiseValue}$:ノイズから得られた値(移動距離)
- $\text{amplitude}$:隆起の最大スケール(山の高さや谷の深さを決める係数)
ノイズの値がプラスであれば、法線と同じ方向(外側)へ押し出されて「山」になり、マイナスであれば、法線と逆方向(内側)へ引っ張られて「谷」や「海溝」になります。
2-3. GLSLでの実装と、FBMへの拡張(自然界の複雑さへ)
実際の Vertex Shader でのコードを見てみましょう。さらに今回は、時間(u_time)をパラメータに加えることで、「地形がゆっくりとうねり、脈打つ」ような表現(4D化)への布石も打っておきます。
// 1. 頂点のローカル座標から、3Dノイズの値を生成(時間が経つと模様が変化する)
float n = snoise(position * u_frequency + u_time * 0.2);
// 2. 隆起の計算(元の位置 + 法線方向 × ノイズ値 × 強さ)
vec3 displacedPosition = position + normal * n * u_amplitude;
// 3. 最終的なスクリーン座標への変換
gl_Position = projectionMatrix * modelViewMatrix * vec4(displacedPosition, 1.0);
【さらなる深淵へ:FBMの適用】
実は、上記の snoise を1回だけ使った隆起では、非常になだらかで「スライムのような」単純な起伏しか生まれません。
リアルな惑星の山脈、ひび割れ、複雑な大陸の形を作り出すには、連載第5回で学んだ FBM(Fractal Brownian Motion) をここで発動させます。
オクターブを重ねて生成したFBMの値を n として代入することで、マクロな大陸の隆起の表面に、ミクロな岩肌のディテールが重なり、圧倒的な説得力を持つ「プロシージャルな天体」が産声を上げます。
3. 3D Raycasting — 「マウスが触れた3D座標」をShaderへ送る
地形が隆起し、惑星としての形を成しました。次は、この星に「触れる」ための仕組みを作ります。
前回の #31(平面へのインタラクション)では、マウスの位置を -1.0 〜 1.0 に正規化した2Dの座標(vec2)としてShaderに送るだけで事足りました。しかし、今回は「3D空間に浮かび、回転する球体」が相手です。画面上の2D座標だけでは、それが球体の「表側」を触っているのか、何もない「宇宙空間」を指しているのか、Shaderには判断がつきません。
ここで登場するのが、Three.jsが誇る魔法の杖 Raycaster です。
3-1. Raycaster:カメラから放たれる「レーザーポインター」
Raycasterの概念は非常にシンプルです。 「カメラの位置」から、「画面上のマウスカーソル(2D)」を通って、3D空間の奥深くへと見えないレーザービーム(Ray)を撃ち放ちます。そして、そのレーザーが3Dオブジェクト(今回の場合は惑星のメッシュ)に「命中したか?」を判定してくれます。
3-2. intersect.point:ワールド座標という「絶対的な位置」
レーザーが惑星に命中したとき、Raycasterは衝突に関する様々な情報を配列(intersects)として返してくれます。その中で私たちが喉から手が出るほど欲しいのが、intersect.point です。
これは、レーザーがメッシュの表面に当たった「3Dワールド空間における絶対座標(vec3)」です。
惑星がどれだけ回転していようが、カメラがどれだけズームしていようが、intersect.point は「今まさにマウスが触れている3D空間上のピンポイントな位置($x, y, z$)」を正確に教えてくれます。
3-3. JS(CPU)からGLSL(GPU)へ:uniform の架け橋
Three.js(JavaScript)側で取得したこの魔法の3D座標を、GLSL(Shader)側へ渡すための橋渡しが uniform 変数です。
【Three.js側の処理(再掲と解説)】
// 1. マウスの2D座標とカメラを元に、レーザーを放つ方向をセット
raycaster.setFromCamera(mouse2D, camera);
// 2. レーザーが惑星(planetMesh)と交差したか判定
const intersects = raycaster.intersectObject(planetMesh);
if (intersects.length > 0) {
// 3. 命中した場合、intersects[0] が一番手前にある衝突点。
// その3Dワールド座標(point)を、Shaderの u_mouse3D にコピーして送る
planetMaterial.uniforms.u_mouse3D.value.copy(intersects[0].point);
} else {
// 命中していない場合(宇宙空間を指している場合)の処理
// (※遥か遠くの座標をセットして、Shader側の影響をゼロにする等の工夫をします)
}
これで準備は整いました。
GPU(Shader)側には、毎フレーム「ユーザーの指先(マウス)が、3D世界のどこに触れているか」という絶対座標が u_mouse3D として届けられます。
あとはShaderの世界で、この座標を中心とした局所的な「空間のねじれ(Domain Warping)」や「地形の隆起(Displacement)」を起こすだけです。
4. Shader内での「距離」の計算とインタラクションの波及 — 触れた場所から波紋が広がる
Three.jsのRaycasterから、ユーザーが触れている絶対的な3D座標(u_mouse3D)がUniform変数として届けられました。
ここから先はGPU(Shader)の領域です。頂点シェーダー(Vertex Shader)は、惑星を構成するすべての頂点に対して同時に実行され、各頂点は次のような自問自答を行います。
「自分は今、ユーザーが触れている場所(u_mouse3D)からどれくらい離れているだろうか?」
4-1. 空間を統一する — worldPosition の計算
距離を測る前に、絶対に忘れてはならない罠があります。
それは「空間の統一」です。Three.jsから送られてきた u_mouse3D は「ワールド空間(絶対座標)」ですが、Shaderに入ってきたばかりの頂点の position は「ローカル空間(モデルの中心を原点とした座標)」です。
これらを比較するためには、まず頂点の座標をワールド空間に変換(modelMatrix を掛ける)する必要があります。
// 頂点のローカル座標をワールド座標に変換
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
4-2. 距離(Distance)の計算と、数学的な美しさ
空間が統一できたら、2つの点の距離 $d$ を測ります。数学的には、2つのベクトルの差分の大きさ(ノルム)を求める以下の式になります。
$$d = |\vec{p}{world} - \vec{u}{mouse3D}|$$
GLSLには、これを一発で計算してくれる組み込み関数 distance() が用意されています。
// マウスとの距離を計算
float dist = distance(worldPosition.xyz, u_mouse3D);
4-3. smoothstep による「オーガニックな減衰」
距離 dist が分かりました。もしここで if (dist < u_radius) のように単純な条件分岐を使ってしまうと、影響を受ける境界線がバキッと折れ曲がり、いかにも「機械的な円」が浮き出てしまいます。
私たちが作りたいのは、指で柔らかい粘土を押したような、あるいは魔法の力がじんわりと周囲に浸透していくような「オーガニックな減衰」です。
ここで、Noise入門の基礎編でも大活躍した smoothstep が再び火を噴きます。
// smoothstepで影響範囲を滑らかに減衰させる
float influence = 1.0 - smoothstep(0.0, u_radius, dist);
smoothstep(0.0, u_radius, dist)は、距離が0.0なら0.0、距離がu_radius(影響半径)以上になれば1.0を返す滑らかなカーブを描きます。- これを
1.0 -で反転させることで、「マウスの中心点は 1.0(影響力MAX)、半径の縁に向かって滑らかに 0.0(影響なし)にフェードアウトする」という完璧な影響力(Influence)のマスクが完成します。
4-4. インタラクションの適用 — 新たなノイズの加算
影響力(influence)という最強の武器を手に入れたら、あとはそれを法線方向の隆起(Displacement)に掛け合わせるだけです。
通常の地形ノイズとは別に「触れた時専用の激しいノイズ(Domain Warpingなど)」を用意し、それを足し合わせます。
// 通常の地形隆起
vec3 displacedPosition = position + normal * baseNoiseValue * u_amplitude;
// 触れた場所専用の荒ぶるノイズ(例として時間を強くかける)
float extraNoiseValue = snoise(position * u_frequency * 2.0 + u_time * 5.0);
// 触れた場所(influenceが1.0に近い場所)だけ、さらに地形が歪む
displacedPosition += normal * influence * extraNoiseValue;
// 最終的な画面上の座標へ
gl_Position = projectionMatrix * modelViewMatrix * vec4(displacedPosition, 1.0);
これにより、ユーザーがマウスでなぞった軌跡に沿って、惑星の表面がボコボコと脈打つように隆起する、圧倒的なインタラクションが完成します。
5. OrbitControlsとの融合による「探索」の体験 — 宇宙の創造主になる
ここまでの実装で、私たちは「シームレスに隆起する3Dノイズ地形」と「触れた場所が歪む魔法のインタラクション」を手に入れました。
しかし、この惑星はまだ画面の真正面に固定されたままです。これを「触れられる本物の世界」として完成させる最後のピースが、Three.js の OrbitControls です。
5-1. カメラワークがもたらす「圧倒的な実体感」
OrbitControls を追加することで、ユーザーはマウスのドラッグで惑星を自由にグルグルと回し、裏側の見知らぬ大陸へ回り込み、スクロールで地表スレスレまでズームインできるようになります。
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
// カメラコントローラーの初期化
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 滑らかな慣性(ダンピング)をつけることで、重厚感のある操作感に
5-2. 観察と干渉のループ
この「自由に視点を動かせる」体験と、「Raycasterによる地形操作」が組み合わさった瞬間、次元が一段階跳ね上がります。
自分が指(マウス)でなぞった軌跡に沿って山脈が隆起し、それを横からのアングルで眺め、今度はズームインしてクレーターを穿つ。 数式によって無限に生成され続ける「プロシージャルな未知の惑星」を、自らの手で回しながら探索する。これこそが、Three.jsとGLSLが織りなす「動くノイズの天国」の醍醐味です。
次回の記事への引き(#33 の予告)
今回は地形の形(ジオメトリ)を動的に変化させ、手で回せる惑星の「素体」を錬成しました。 しかし、宇宙から見た星の美しさは「地形のデコボコ」だけでは語れません。
次回(#33)は、この隆起したノイズ地形に「生命の息吹(色彩)」を与えます。
地形の「高さ(Height)」や「傾斜(Slope)」をShader内で計算し、低い場所には深い海を、中腹には緑の森を、頂上には白い雪化粧を動的に塗り分ける「Procedural Biome(バイオーム生成)」。 そして、その周囲をうっすらと包み込み、光の散乱をシミュレーションする「大気(Atmosphere)」の表現に挑みます。
自らの手で回す無機質な惑星に、海が満ち、山脈が連なり、美しい青い大気の層がまとわりつく瞬間をお見せします。
次回の更新も、どうぞお楽しみに!
💬 コメント