[CGクロニクル #08] カオスを制御する:ケン・パーリンとノイズの魂

[CGクロニクル #08] カオスを制御する:ケン・パーリンとノイズの魂

はじめに

前回までの旅で、私たちは完璧な「形」と「光」を手に入れた。数式で定義されたベジェ曲線はどこまでも滑らかな肌をティーポットに与え、フォン・シェーディングとレイトレーシングは、プラスチックや金属のような均質な質感をピクセルの中に宿した。

しかし、その完璧さゆえに、当時のCGには決定的な何かが欠けていた。それは「自然のゆらぎ」である。

1980年代初頭、画期的な全編CG映画『TRON』の制作現場において、CG技術の限界に直面していた一人の青年がいた。ケン・パーリン(Ken Perlin)である。

彼は、コンピューターが描き出す映像があまりにも「機械的」で「冷たい」ことに不満を抱いていた。現実の木目、大理石の模様、そして空に浮かぶ雲。これらは決して定規で引いたように均質ではなく、かといってテレビの砂嵐のような完全な無秩序(ホワイトノイズ)でもない。

彼が求めたのは、規則性と不規則性の狭間にある「制御されたカオス」だった。

前回の記事:

コヒーレント・ノイズの誕生

シェーダーやWebGLのプロジェクトを陰から支える強固な基盤として、この「パーリンノイズ」の数学的アプローチがいかにエレガントに構築されているか、アルゴリズムの内部挙動を具体的に紐解いていきましょう。

純粋なホワイトノイズ(例えばフラグメントシェーダーでよく使われる fract(sin(x) * 43758.5453) のようなハッシュ関数)は、隣接するピクセル間で値が完全に独立しています。ケン・パーリンが目指したのは、これを「連続性のある波」に変換することでした。

その計算プロセスは、大きく4つのステップで構成されています。

1. 空間の格子化(Grid)と勾配ベクトル(Gradient Vectors)

まず、計算対象の空間(例えば2Dの画面)を整数の格子(グリッド)に分割します。そして、それぞれの格子点(交点)に対して、擬似乱数を用いてランダムな向きのベクトル(勾配ベクトル)を割り当てます。 これがノイズの「骨組み」となります。ホワイトノイズが「ピクセルごと」に乱数を持つのに対し、パーリンノイズは「格子点ごと」に乱数(ベクトル)を持つのが最大の違いです。

2. 距離ベクトルの算出

次に、画面上の任意のピクセル(座標 PP )の濃淡を決定するために、そのピクセルを囲む格子点(2Dなら4点)を探します。そして、各格子点からピクセル PP に向かう距離ベクトルを計算します。

3. 内積(Dot Product)による影響度の計算

ここがアルゴリズムの肝です。各格子点において、ステップ1で定義した「勾配ベクトル」と、ステップ2で求めた「距離ベクトル」の内積(Dot Product)を取ります。

Influence=GradientDistance\text{Influence} = \vec{Gradient} \cdot \vec{Distance}

内積は「2つのベクトルがどれだけ同じ方向を向いているか」を示すスカラー値です。ピクセルが格子点に近づくほど、またピクセルの位置が勾配ベクトルの方向と一致するほど、その格子点からの影響度は大きくなります。

4. エルミート曲線による滑らかな補間(Interpolation)

最後に、周囲4つの格子点から得られた影響度を混ぜ合わせ(補間し)て、最終的なピクセルの出力値を決定します。 ここで単純な線形補間(mixlerp)を使うと、格子の境界をまたぐときに変化率が不連続になり、「カクカク」した不自然な線が出てしまいます。そこでパーリンは、以下のエルミート補間関数を用いました。

f(x)=3x22x3f(x) = 3x^2 - 2x^3

(※後にパーリン自身により、2次微分まで連続でより滑らかな 6x515x4+10x36x^5 - 15x^4 + 10x^3 という関数へと改良されています)

この関数を通すことで、格子の境界で微分値が0になり、隣のグリッドと完璧に滑らかに接続されます。「局所的にはランダムだが、連続的に変化する」という魔法は、この内積と曲線の組み合わせによって生み出されているのです。


この「格子・ベクトル・内積・補間」という一連のプロセスを視覚的に確認できるウィジェットを用意しました。頂点のベクトルがどのように空間の濃淡を作り出しているか、構造を分解して見てみましょう。

CPUベースでピクセルごとの計算を行うため解像度は抑えめにしていますが、「格子」「勾配ベクトル」「補間」というパーリンノイズのコアアルゴリズムを視覚的に理解するための教材として機能します。

解像度: 4

このコードでは、解説のステップで触れた「内積の計算(dotGridGradient)」と「滑らかな補間(fade関数の 6t515t4+10t36t^5 - 15t^4 + 10t^3)」を忠実に実装しています。MDX上で動作させれば、読者はスライダーでグリッドを分割し、シード値を変えてベクトルが再生成されるたびに、空間の波がどのように再計算されるのかを直感的に確認できるはずです。

フラクタルとの融合:自然界の解像度

無単一のパーリンノイズが作り出すのは、あくまで「一定の大きさを持った滑らかな波」に過ぎません。しかし自然界の構造——例えば山脈の稜線や、海岸線の入り組んだ形、あるいは空に浮かぶ雲——は、マクロなうねりの中にミクロなざわめきが無限に折り重なる「フラクタル(自己相似性)」の性質を持っています。

ケン・パーリンのノイズを真に実用的なものへと昇華させたのは、このフラクタル構造を模倣するfBM(Fractional Brownian Motion:フラクタル・ブラウン運動)という数学的アプローチとの融合でした。

fBMの計算式は、非常にシンプルでありながら劇的な効果をもたらします。

fBM(p)=i=0n1amplitudei×noise(frequencyi×p)fBM(p) = \sum_{i=0}^{n-1} amplitude_i \times noise(frequency_i \times p)

この数式は、以下のパラメータをコントロールしながら、複数のノイズレイヤー(オクターブ)を重ね合わせる処理を表しています。

  1. Octaves(オクターブ数): 重ね合わせるノイズの層の数。増やすほどディテールが細かくなります。
  2. Frequency(周波数): ノイズの細かさ。レイヤーが上がるごとに、一般的に Lacunarity(ラキュナリティ:周波数逓倍率) と呼ばれる係数(通常は2.0)を掛けて、波を細かくしていきます。
  3. Amplitude(振幅): ノイズの影響力(高さ)。レイヤーが上がるごとに、一般的に Gain / Persistence(ゲイン / パーシステンス:振幅減衰率) と呼ばれる係数(通常は0.5)を掛けて、細かな波形ほど影響力を弱くしていきます。

大きな波(低周波・大振幅)で地形の「山」と「谷」という大まかな骨格を作り、そこに中くらいの波で「岩肌の起伏」を足し、さらに微細な波で「表面のザラつき」を加算していく。この処理を数回〜数十回繰り返すことで、ただの数式が「大理石の模様」や「雲海」へと変貌するのです。

無限ワールドを生成する魔法 このアルゴリズムの最も恐ろしい点は、「入力された座標 (x,y,z)(x, y, z) に対して、常に一意の値を返す連続的な関数である」という事実です。

Three.jsなどでプロシージャル生成による無限ワールドを飛ぶフライトシミュレーターを構築する際、はるか彼方まで続く地形の高低差(ハイトマップ)や、ボクセルの配置データは、事前に巨大なファイルとして用意されているわけではありません。カメラが移動し、新たな座標が描画範囲に入った瞬間に、このfBMの数式がリアルタイムにその場所の「標高」を弾き出しているのです。

ノイズ関数の探求にどれほど多くの思考の軌跡を重ねようとも、底が見えない理由はここにあります。たった数行の数式とパラメータの調整だけで、世界の相貌は全く異なるものへと変化します。0と1の冷たいデジタル空間に、予測不能でありながらも調和の取れた「自然の魂」を吹き込んだこのゆらぎのアルゴリズムは、現代のあらゆるCG表現の根底で、今も静かに脈打っているのです。


fBMビジュアライザー

読者が「オクターブ(重ね合わせの回数)」や「パーシステンス(減衰率)」などのパラメータを実際に操作し、単一のノイズがどのようにして複雑な雲や地形のテクスチャへと進化していくのかを体験できるコンポーネントです。

オクターブ (階層): 6
パーシステンス: 0.50
ラキュナリティ: 2.00
全体スケール: 4.0

【一分間の数式美:ノイズが織りなす無限の雲海】

純粋なフラグメントシェーダーのみで記述された、fBM(フラクタル・ブラウン運動)による雲海の表現。単純な数式が幾重にも重なり合うことで、ただのピクセルの羅列が有機的な空気を纏う。60回の探求の果てに見るような、美しく数学的な空。

// Fragment Shader: Endless Cloudscape
#ifdef GL_ES
precision highp float;
#endif

uniform vec2 u_resolution;
uniform float u_time;

// 疑似乱数生成(ハッシュ関数)
float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// 2D Value Noise (計算負荷を抑えた簡易ノイズ)
float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);

    // エルミート補間による滑らかな接続
    vec2 u = f * f * (3.0 - 2.0 * f);

    float a = hash(i + vec2(0.0, 0.0));
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));

    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// fBM (Fractal Brownian Motion)
float fbm(vec2 p) {
    float value = 0.0;
    float amplitude = 0.5;
    float frequency = 1.0;

    // 異なる周波数と振幅のノイズを6レイヤー重ね合わせる(オクターブ)
    for (int i = 0; i < 6; i++) {
        value += amplitude * noise(p * frequency);
        frequency *= 2.0;    // 周波数を倍に(細かく)
        amplitude *= 0.5;    // 振幅を半分に(影響を小さく)
    }
    return value;
}

void main() {
    // ピクセル座標の正規化とアスペクト比の補正
    vec2 st = gl_FragCoord.xy / u_resolution.xy;
    st.x *= u_resolution.x / u_resolution.y;

    // 時間経過で雲をゆっくりと流す
    vec2 pos = st * 3.0 + vec2(u_time * 0.1, u_time * 0.05);

    // fBMによる雲の形状生成
    float n = fbm(pos);

    // 空間の色定義
    vec3 skyColor = vec3(0.15, 0.35, 0.65); // 深みのある空の青
    vec3 cloudColor = vec3(1.0, 0.95, 0.9); // 光を帯びた雲の白

    // ノイズの値を閾値で切り取り、雲の輪郭を滑らかに補間
    float cloudCover = smoothstep(0.3, 0.7, n);

    // 空と雲をブレンド
    vec3 color = mix(skyColor, cloudColor, cloudCover);

    gl_FragColor = vec4(color, 1.0);
}