はじめに
#25までの壮大な旅路を経て、ついにノイズが「色(ピクセル)」の世界から「形(ジオメトリ)」の世界へと次元の壁を越える瞬間です。
これまではFragment Shaderでスクリーンという平面に魔法をかけてきましたが、今回はVertex Shaderという新しい舞台で、3Dモデルそのものをノイズでねじ曲げ、生命を吹き込んでいきます。
前回の記事:
[Noise 入門 #25] Three.js × GLSL の架け橋 — ShaderMaterial と EffectComposer でノイズをブラウザに解き放つ
Noise 入門シリーズ第25回。純粋なGLSLコードをThree.jsに統合し、インタラクティブなノイズ表現を実現するエンジニアリングの基礎を学びます。ShaderMaterialによるマテリアル制御、Uniformsによる時間同 …
https://humanxai.info/posts/noise-intro-25-threejs-shader-bridge/1. ピクセルの海から、立体の脈動へ
これまでの連載では、主に「Fragment Shader(フラグメントシェーダー)」を用いて、ピクセルごとにノイズを計算し、色や模様、仮想的な立体感を作り出してきました。第18回で学んだ Procedural Lighting や、第20回の Bump Mapping がその最たる例です。光と影の計算を工夫することで、ただのツルツルな表面に、ごつごつとした岩肌の凹凸や、細かな波のうねりを表現してきました。
しかし、それらは言わば「平面のキャンバスに精巧な騙し絵を描く技術」に過ぎません。正面から見れば立派な山脈に見えても、カメラを横に回り込ませて側面から見れば、そこにあるのは1枚の平らなポリゴン板です。フラグメントシェーダーは表面の「見え方(現象)」を操作することはできても、オブジェクトの「存在そのもの(構造)」を変えることはできないのです。
次元の壁を越える:Vertex Shaderの領域
今回から、私たちはその限界を突破します。舞台はピクセルがスクリーンに塗られるよりも前の段階、「Vertex Shader(頂点シェーダー)」へと移ります。
ターゲットは、3Dモデルを形作る無数の「頂点(Vertex)」たちです。これまでは Three.js が用意したジオメトリの position 座標をそのまま受け取り、画面上の位置に変換するだけでしたが、いよいよこの過程にノイズ関数を介入させます。頂点の X, Y, Z 座標そのものにノイズの値を適用し、3D空間内で物理的に位置をずらす(Displacement:ディスプレイスメント)アプローチです。
「騙し絵」から「生きた彫刻」へ
この「形そのものを歪める」という変化は、レンダリング結果に決定的なリアリティをもたらします。
- シルエットの変形: 表面の模様だけでなく、オブジェクトの輪郭線(アウトライン)そのものがノイズに合わせて歪みます。
- 物理的な実体感: 頂点が実際に移動しているため、カメラをどこから回り込ませて見ても立体感が破綻せず、他のオブジェクトとの交差もリアルになります。
- 有機的な鼓動の獲得: 空間座標に加えて時間(uTime)をノイズのパラメータとして流し込むことで、心臓の鼓動、不定形に波打つスライム、ゆっくりと呼吸する未知の鉱石などを作り出せます。
冷たく固定された頂点の配列に、ノイズという「不確実な揺らぎ」を流し込む。それは単なる描画処理を超えた、デジタルな世界に生命の息吹を与えるプロセスです。静的だったポリゴンの塊が、ノイズの力で「生きたオブジェクト」へと変貌する瞬間を、ここから実装していきましょう。
2. 頂点変形の基本原理:法線(Normal)に沿って押し出す
頂点の座標をプログラムで書き換えられるようになったからといって、ただ無作為な方向へ頂点を散らしてしまうと、意味のある造形は生まれません。ポリゴン同士が自己交差を起こし、破綻したトゲトゲのノイズの塊(意図的なグリッチ表現としてはアリですが)になってしまいます。
立体を自然に、かつ美しく変形させ、「有機的な鼓動」を与えるための絶対的なルール。それは「頂点を、その頂点が本来向いている方向へ押し出す」ことです。
法線(Normal)という羅針盤
3Dモデルが持つ各頂点は、単なる空間上の位置座標(Position)だけでなく、「自身がどの方向を向いているか」を示す法線ベクトル(Normal)を持っています。球体であれば中心から外へ向かう放射状の矢印であり、平面であれば真上を向く真っ直ぐな矢印です。
この法線ベクトルこそが、モデルの「元の形(構造のアイデンティティ)」を維持しつつ変形を行うための羅針盤となります。
変形を司る美しい方程式
この「法線に沿った変形(Displacement)」を数式で表すと、驚くほどシンプルで美しい形に収束します。元の頂点座標を $P$、法線ベクトルを $N$、ノイズの値を決める関数を $F_{\text{noise}}$、変形の強さを $A$(Amplitude)とすると、新しい頂点座標 $P_{\text{new}}$ は以下のように定義できます。
$$P_{\text{new}} = P + N \times F_{\text{noise}}(P, t) \times A$$
この式の各項が果たしている役割は、以下の通りです。
- $P$ (現在地): 変形前の頂点のローカル座標(Position)。すべての出発点です。
- $N$ (方向): 頂点の法線ベクトル(Normal)。長さが $1$ に正規化された、変形する「向き」の基準です。
- $F_{\text{noise}}(P, t)$ (揺らぎ): 頂点座標と時間(Time)をシードにした 3D/4D ノイズ。$-1.0$ から $1.0$ の連続的なスカラー値を返します。これが変形の「魂」です。
- $A$ (スケール): 変形の物理的な大きさ(強度)。ノイズの効き具合を制御するボリュームの役割を果たします。
「呼吸する立体」が生まれるメカニズム
この式において最も重要なのは、ノイズ関数 $F_{\text{noise}}$ が返す値が「スカラー(単なる数値)」であり、それが法線ベクトル $N$ に掛け合わされるという事実です。
ノイズ関数が正(プラス)の値を返せば、頂点は法線の向き(外側)へと押し出され、表面が膨らみます。逆に負(マイナス)の値を返せば、法線とは逆向き(内側)へと沈み込み、表面が凹みます。
そして、時間 $t$ が進むにつれて、ノイズ関数が返す値は $-1.0$ と $1.0$ の間を滑らかに推移します。空間的に連続したノイズ(Simplex Noise や Perlin Noise)を使っているため、隣り合う頂点同士は似たような値を取りながらも、少しずつ異なるタイミングで膨張と収縮を繰り返します。
結果として、ただの数式とベクトルの計算が、まるで波打つ水面や、脈打つ心臓の鼓動のような生命感(Displacement)を生み出すのです。純粋な幾何学の世界に「揺らぎ」を掛け合わせることで、オブジェクトは初めて空間に対して物理的な干渉を始めます。
3. 最大の罠:「分割数(Segments)」の重要性
いざVertex Shaderで完璧なノイズの方程式を書き上げ、ブラウザをリロードした瞬間。多くの初心者がここで、ある「絶望的な罠」に直面します。 「数式もコードも絶対に合っているはずなのに、モデルがカクカクのトゲトゲになっている……」あるいは「全く変形せず、ただ全体が単調に揺れているだけに見える……」。
この現象の理由は、GPUのレンダリングパイプラインにおける冷酷な事実に起因しています。それは、「Vertex Shaderは、そこにある『頂点』しか動かせない(シェーダー内で新しい頂点を生み出すことはできない)」という絶対法則です。
解像度という名のキャンバス
例えば、Three.js で標準的な立方体 new THREE.BoxGeometry(1, 1, 1)(分割数1)を生成したとします。このジオメトリが持つ頂点は、空間上にわずか「8個」しか存在しません。
この8つの頂点が存在する空間に、どれほど滑らかで高次元な 4D Simplex Noise のフィールドを展開したとしても、シェーダーはその「8つの点」におけるノイズのサンプリング結果しか取得できません。滑らかな波のうねり(高周波成分)があったとしても、頂点と頂点の間がスカスカであれば、その波の形は完全に無視されてしまいます。
結果として、8つの角がでたらめに伸び縮みするだけの無惨な姿を晒すことになります。これは画像処理における解像度不足と同じで、「表現したいノイズの波の細かさに対して、観測点(頂点)の数が圧倒的に足りていない」状態なのです。
ノイズの波をすくい取る「網の目」を細かくする
Vertex Shaderでノイズの滑らかな変形(Displacement)を美しく表現するには、ベースとなるジオメトリの解像度(ポリゴン数)をあらかじめ上げておく必要があります。
つまり、Three.js 側でジオメトリを生成する時点で、セグメント数(分割数)を細かく設定し、ノイズの波をすくい取るための「高密度なメッシュ(網の目)」をCPU側からGPUへ渡してやることが、美しさの絶対条件となります。
// ✖️ 悪い例:頂点が少なすぎてノイズの波を表現できない
// 頂点がスカスカだと、カクカクのローポリゴンが暴れるだけになる
const lowPolyGeo = new THREE.SphereGeometry(1.0, 8, 8);
// ⭕️ 良い例:分割数を増やし、ノイズを適用する頂点を大量に用意する
// 128x128の分割により、滑らかな波のうねりを表現できる
const highPolyGeo = new THREE.SphereGeometry(1.0, 128, 128);
128 x 128 の分割を指定すれば、数万個の頂点が高密度に配置されます。この無数の頂点群が、Vertex Shader 内でそれぞれ独立してノイズをサンプリングし、法線方向に押し出されることで、初めて私たちが想像する「滑らかに波打つ曲面」が視覚化されるのです。
💡 パフォーマンスとのトレードオフ 頂点数を増やせば増やすほど変形は滑らかになりますが、当然GPUの計算負荷(Vertex Shaderの実行回数)は跳ね上がります。第20回で解説した LOD(Level of Detail)の考え方を思い出し、表現したいディテールとパフォーマンスのバランスを取って適切な分割数を見極めることも、プロシージャル表現における重要な設計の一つです。
4. GLSL実装:Vertex Shaderで立体を歪める
理論と下準備(分割数の確保)が整いました。いよいよ、静的なメッシュに命を吹き込む「呪文」を Vertex Shader に書き込みます。
第15回で学んだ 4D Simplex Noise の関数 snoise(vec4 v) が、シェーダー内で既に定義されていると仮定しましょう。Three.js の ShaderMaterial を使えば、position(頂点座標)と normal(法線ベクトル)はAttributesとして自動的に渡されます。私たちが書くべきコアロジックは、驚くほどシンプルです。
// Uniforms(JavaScript側から毎フレーム送られてくる制御パラメータ)
uniform float uTime; // 時間(アニメーションの進行)
uniform float uFrequency; // ノイズの空間的な細かさ(波のうねり具合)
uniform float uAmplitude; // 変形の大きさ(押し出しの強さ)
void main() {
// 1. ノイズ空間のサンプリング(空間座標 + 時間)
// 頂点のローカル座標をスケールして、ノイズの「周波数」を決定する
vec3 noisePos = position * uFrequency;
// 3D空間座標に時間(uTime)を加えた4次元ベクトルでノイズ関数を叩く
float n = snoise(vec4(noisePos, uTime)); // 結果は -1.0 ~ 1.0 の連続値
// 2. 頂点の押し出し計算(Displacement)
// セクション2で解説した「現在地 + 法線方向 * ノイズ値 * 強さ」の数式をそのままコード化
vec3 newPosition = position + normal * n * uAmplitude;
// 3. 画面上の最終的な座標を計算(WebGLのお決まりの変換)
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
コードが織りなす魔法の解剖
このわずか数行のコードの中で、立体が「生きたオブジェクト」へと変貌する劇的な処理が行われています。
- 空間の拡縮(uFrequency): position * uFrequency によって、ノイズ関数に読み込ませる空間のスケールを変えています。この値を大きくすると、モデルの表面に対してノイズの波が密集し、「ウニ」のように細かくチクチクと波打ちます。逆に小さくすると、ノイズの波がゆったりと広がり、全体が「スライム」や「アメーバ」のように大きくゆっくりとうねります。
- 時間の注入(uTime): 空間の3次元(X, Y, Z)に、時間の1次元(W成分としての uTime)を加えた4次元ベクトル vec4(noisePos, uTime) を作っています。これにより、形が歪むだけでなく、時間経過とともに出力値が滑らかに変化し、永遠に止まらない「呼吸」や「鼓動」を獲得します。
- 法線方向への移動(normal * n * uAmplitude): ノイズ関数が弾き出した $-1.0$ から $1.0$ のスカラー値 n を、頂点が向いている方向(normal)に掛け合わせます。最後に uAmplitude で変形のスケール(ボリューム)を調整し、元の位置(position)に足し合わせることで、新しい形 newPosition が完成します。
静止していた冷たいポリゴンの塊は、このロジックを通過した瞬間、有機的な躍動を始めます。純粋な数学の関数が、目に見える「物理的な質量と鼓動」に変換されたのです。
5. 形が変わると「光」が壊れる?(次への伏線)
Vertex Shaderの魔法によって、無機質なポリゴンが生命を得たようにうねり、波打つ感動。しかし、その動くメッシュにライティング(光と影)を適用した瞬間、あなたは奇妙な違和感に襲われるはずです。
「形は間違いなく凸凹に歪んでいるのに、なぜか光の当たり方は元のツルツルの球体のままだ……」と。
まるで、透明なガラス玉の中にうごめくスライムを閉じ込めたような、あるいは平面のシールを無理やり波打たせているような、非常に不自然でプラスチックな質感になってしまうのです。
なぜ光と影が破綻するのか?
その理由は、3Dグラフィックスにおける「光の計算(ライティング)」の仕組みそのものにあります。
Fragment Shaderが陰影を計算するとき、頼りにしているのはジオメトリの形(座標)ではなく、「その面がどちらを向いているか」を示す法線(Normal)です。 先ほどのコードで、私たちは position をノイズで押し出して newPosition を作りました。しかし、面が向いている方向である normal の値は一切書き換えていません。
つまり、頂点がどれだけ鋭く隆起し、深く沈み込もうとも、GPUは「ここは元のツルツルな球体の表面ですよ」という古い法線データを信じ込んで光を計算してしまうのです。これが、形と光の間に生じる決定的な矛盾(ディスコネクト)の正体です。
過去の知識が火を噴く瞬間
この矛盾を解決し、変形した凸凹の表面に対して正しく影を落とすには、変形した後の新しい形に合わせて「法線を再計算(Normal Recomputation)」しなければなりません。
具体的には、ある頂点のすぐ隣にある仮想的な頂点の高さをサンプリングし、その「傾き(偏微分)」から新しい法線ベクトルを捏造する計算が必要です。 ……ピンときた方もいるかもしれません。そう、これは第18回「Procedural Lighting — ノイズから法線を捏造し、光と影をシミュレーションする」で、2Dのノイズ地形に対して行ったアプローチと全く同じ数学です!
あの時は平面(2D)の勾配を計算しましたが、今回はそれを立体(3D)へと拡張します。これまでに積み上げてきたノイズの基礎知識が、ここに来て次々とパズルピースのように組み合わさっていくのです。
次回:光と影の完全な統合へ
とはいえ、一度にすべてを詰め込むと魔法の構造が複雑になりすぎます。
今回の第26回では、まずは「Vertex Shaderで形そのものを動かす」という純粋な物理的変形の感動をしっかりと味わってください。静的だった世界が、あなたの書いた数式で脈打ち始めた事実こそが最大の成果です。
次回、#27「Normal Recomputation — 歪んだ世界に正しい光と影を取り戻す」(仮)では、この波打つ立体に対して偏微分を用いた法線計算を適用し、プロシージャルな造形美を極限まで高めていきます。
Three.js側のJavaScriptとShaderを繋ぐ、具体的なボイラープレート(ベースコード)を整理。
いよいよブラウザ上に、生きているとしか思えない未知のオブジェクトが産声を上げます。
次回の展開も、ぜひお楽しみに!
💬 コメント