[Noise 入門 #53] Liquid Metal(液体金属)とRaymarchingの融合 — 数式で「T-1000」を錬成する

はじめに

今回は、これまでの「ポリゴン(頂点)を歪ませる」アプローチを捨てます。 Raymarching(レイマーチング)とSDF(Signed Distance Field:符号付き距離場)という、Generative Art界隈の奥義にFBMノイズを叩き込みます。

ポリゴンの制約から解き放たれたノイズは、まるで『ターミネーター2』のT-1000のように、ドロドロに溶け、融合し、周囲の光を反射する「液体金属」へと変貌します。

前回の記事:

実装

今回の記事内容である「液体金属」の物理表現をベースに、SDF(符号付き距離場)とテクスチャサンプリングを融合させました。
画像の輝度情報を深度(Depth)に変換し、smin 関数による滑らかな融合と、時間経過による波紋の計算を組み合わせることで、深淵から肖像が立ち現れる「Reveal(啓示)」を表現しています。
90年代サイバーパンクの質感と、現代のレイマーチング技術が交差する「Layer: 01 Existence」の世界をお楽しみください。

Redditにも投稿してみました。

1. SDFとノイズの融合原理 — ポリゴンの限界を超えて

これまで私たちは、Three.js の Vertex Shader を用いて、頂点(ポリゴン)をノイズで上下に動かす「Displacement(変位)」を行ってきました(第26回など)。しかし、ポリゴンベースの変形には「トポロジー(位相)を変化させられない」という致命的な弱点があります。

どれだけ激しくノイズをかけても、球体は「歪んだ球体」のままであり、ちぎれて2つの水滴に分かれたり、穴が空いたりすることはありません。無理に引き伸ばせば、ポリゴンの網目が破綻してしまいます。

そこで登場するのが、Raymarching(レイマーチング)とSDF(Signed Distance Field:符号付き距離場)です。

SDF:物体を「数式」で定義する

SDFとは、空間上の任意の点 $\mathbf{p}$ から、最も近い物体の表面までの「距離」を返す関数のことです。表面より外側ならプラス、内側ならマイナス、そして「距離がちょうどゼロになる場所」が物体の表面として描画されます。

例えば、空間の原点にある半径 $r$ のツルツルな球体のSDFは、極めてシンプルな数式で表されます。

$$d = | \mathbf{p} | - r$$

$\mathbf{p}$ は現在のレイ(視線)の座標(x, y, z)です。原点からの距離(ベクトルの長さ)から半径 $r$ を引くだけで、見事な球体が定義されます。

空間の距離をノイズで「騙す」

ここまではただの球体です。しかし、この美しく整った距離関数に、私たちが第1集から鍛え上げてきた 3D FBM(Fractal Brownian Motion)ノイズを暴力的に叩き込みます。

$$d = | \mathbf{p} | - r + \text{fbm}(\mathbf{p} \times \text{scale} + \text{time}) \times \text{amplitude}$$

この数式が意味するのは、「頂点を動かす」ことではありません。「空間そのものの距離の測り方を、ノイズで局所的に狂わせる」ということです。

  • scale: ノイズの細かさ(周波数)。これを大きくすると表面が細かく沸騰し、小さくすると大きくうねるスライムのようになります。
  • time: 時間経過。4Dノイズの要領で、静止した空間をダイナミックに変化させます。
  • amplitude: 歪みの強さ。

空間の距離が騙されることで、「本来なら何もないはずの空間(d > 0)」が「物体の内側(d < 0)」だと判定されたり、その逆が起きたりします。その結果、表面がブクブクと沸騰し、極端な値になれば物体がちぎれて空中に浮き、再び融合するという、ポリゴンでは絶対に不可能な「流体的な振る舞い」が自然発生するのです。

完璧な法線が「液体金属」の質感を創る

T-1000のような液体金属を表現するには、周囲の景色(環境光)を反射する鋭いハイライトが不可欠です。これには正確な「法線(Normal:表面がどちらを向いているか)」が必要です。

Raymarchingが圧倒的に美しい理由はここにあります。ポリゴンのように「頂点と頂点の間を適当に補間する」のではなく、SDFの数式に対して偏微分(Gradient:少しだけ座標をズラして距離の変化量を測る計算)を行うことで、ピクセル単位で数学的に完璧な法線を求めることができるのです。

歪みきった複雑な表面であっても、この計算によって一寸の狂いもない法線が得られます。そこに環境光の反射(Reflection)とフレネル効果(Fresnel)を乗せることで、ノイズは単なる「形」から、質量と反射率を持った「Liquid Metal(液体金属)」へと完全に昇華されます。

2. GLSL実装:液体金属のShader

Three.jsの ShaderMaterial に流し込むためのフラグメントシェーダー(GLSL)です。 画面全体を覆う1枚の板(PlaneGeometry)にこれを適用するだけで、無限の奥行きを持った3Dの液体金属が浮かび上がります。

// fragmentShader.glsl
uniform float uTime;
uniform vec2 uResolution;

varying vec2 vUv;

// --------------------------------------------------------
// 1. 3D Simplex Noise & FBM (これまでの知識の集大成)
// --------------------------------------------------------
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}

float snoise(vec3 v){
  // ... (Ashima Artsによる3D Simplex Noiseの高速実装) ...
  // ※コードの詳細は省略せずにそのまま記述して問題ありません
  const vec2  C = vec2(1.0/6.0, 1.0/3.0) ;
  const vec4  D = vec4(0.0, 0.5, 1.0, 2.0);

  vec3 i  = floor(v + dot(v, C.yyy) );
  vec3 x0 = v - i + dot(i, C.xxx) ;

  vec3 g = step(x0.yzx, x0.xyz);
  vec3 l = 1.0 - g;
  vec3 i1 = min( g.xyz, l.zxy );
  vec3 i2 = max( g.xyz, l.zxy );

  vec3 x1 = x0 - i1 + 1.0 * C.xxx;
  vec3 x2 = x0 - i2 + 2.0 * C.xxx;
  vec3 x3 = x0 - 1.0 + 3.0 * C.xxx;

  i = mod(i, 289.0 );
  vec4 p = permute( permute( permute(
             i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
           + i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
           + i.x + vec4(0.0, i1.x, i2.x, 1.0 ));

  float n_ = 1.0/7.0; // N=7
  vec3  ns = n_ * D.wyz - D.xzx;

  vec4 j = p - 49.0 * floor(p * ns.z *ns.z);

  vec4 x_ = floor(j * ns.z);
  vec4 y_ = floor(j - 7.0 * x_ );

  vec4 x = x_ *ns.x + ns.yyyy;
  vec4 y = y_ *ns.x + ns.yyyy;
  vec4 h = 1.0 - abs(x) - abs(y);

  vec4 b0 = vec4( x.xy, y.xy );
  vec4 b1 = vec4( x.zw, y.zw );

  vec4 s0 = floor(b0)*2.0 + 1.0;
  vec4 s1 = floor(b1)*2.0 + 1.0;
  vec4 sh = -step(h, vec4(0.0));

  vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
  vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;

  vec3 p0 = vec3(a0.xy,h.x);
  vec3 p1 = vec3(a0.zw,h.y);
  vec3 p2 = vec3(a1.xy,h.z);
  vec3 p3 = vec3(a1.zw,h.w);

  vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
  p0 *= norm.x;
  p1 *= norm.y;
  p2 *= norm.z;
  p3 *= norm.w;

  vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
  m = m * m;
  return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3) ) );
}

float fbm(vec3 p) {
    float value = 0.0;
    float amplitude = 0.5;
    float frequency = 1.0;
    for (int i = 0; i < 4; i++) {
        value += amplitude * snoise(p * frequency);
        frequency *= 2.0;
        amplitude *= 0.5;
    }
    return value;
}

// --------------------------------------------------------
// 2. SDF (距離関数) と ノイズの融合
// --------------------------------------------------------
float map(vec3 p) {
    // 基本の球体のSDF
    float sphere = length(p) - 1.5;

    // 時間経過でうねるノイズを付加 (ここでLiquid Metal化する)
    float displacement = fbm(p * 1.5 + uTime * 0.5) * 0.8;

    return sphere + displacement;
}

// --------------------------------------------------------
// 3. 法線計算 (近傍のSDFの勾配から法線を捏造する)
// --------------------------------------------------------
vec3 getNormal(vec3 p) {
    float d = map(p);
    vec2 e = vec2(0.001, 0.0);
    vec3 n = d - vec3(
        map(p - e.xyy),
        map(p - e.yxy),
        map(p - e.yyx)
    );
    return normalize(n);
}

// --------------------------------------------------------
// 4. Raymarching メインループ
// --------------------------------------------------------
void main() {
    // 画面中央を(0,0)に正規化
    vec2 uv = (vUv - 0.5) * 2.0;
    uv.x *= uResolution.x / uResolution.y;

    // カメラとレイのセットアップ
    vec3 ro = vec3(0.0, 0.0, 4.0); // レイのスタート位置(カメラ)
    vec3 rd = normalize(vec3(uv, -1.0)); // レイの飛ぶ方向

    // マーチング処理
    float t = 0.0;
    vec3 p = vec3(0.0);
    for(int i = 0; i < 100; i++) {
        p = ro + rd * t;
        float d = map(p);
        if(d < 0.001 || t > 10.0) break; // 衝突または遠すぎたらループ抜け
        t += d;
    }

    // --------------------------------------------------------
    // 5. ライティングと金属表現 (クロムメッキのような反射)
    // --------------------------------------------------------
    vec3 color = vec3(0.0); // 背景色(黒)

    if(t < 10.0) {
        // 物体に衝突した場合
        vec3 normal = getNormal(p);

        // 疑似的な環境マップ反射(Matcapアプローチに近い計算)
        vec3 viewDir = normalize(ro - p);
        vec3 reflectDir = reflect(-viewDir, normal);

        // 周囲の擬似環境色(青と紫のサイバーなライティング)
        vec3 envColor = vec3(0.1, 0.3, 0.5) * max(0.0, reflectDir.y)
                      + vec3(0.5, 0.1, 0.3) * max(0.0, -reflectDir.y);

        // 強烈なハイライト(Specular)
        float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);

        // フレネル反射(エッジが明るくなる)
        float fresnel = pow(1.0 - max(dot(normal, viewDir), 0.0), 3.0);

        // 全てを合成して液体金属の輝きを作る
        color = envColor * 0.8 + vec3(1.0) * spec + vec3(0.8, 0.9, 1.0) * fresnel;
    }

    gl_FragColor = vec4(color, 1.0);
}

コードの解剖とディレクター視点の解説

このShaderは、大きく5つのブロックで構成されています。一つずつ読み解いていきましょう。

1. 3D Simplex Noise & FBM

コードの半分以上を占める謎の数学の羅列ですが、恐れる必要はありません。これはAshima Artsによる有名な3D Simplex Noiseの高速実装です。私たちはこれを「空間を滑らかに歪めるための乱数発生器」としてブラックボックス的に扱います。その出力を fbm() 関数で4回(4オクターブ)重ね合わせ、自然界のような複雑なうねりを作り出しています。

2. map 関数:世界の形を決める神の数式

ここがレイマーチングの心臓部です。 float sphere = length(p) - 1.5; で半径1.5の球体を作り、そこに fbm(p * 1.5 + uTime * 0.5) * 0.8 を足し込んでいます。

uTime(時間)がノイズの座標系を押し流すため、結果として「形が刻々と変化し続けるスライム」が定義されます。

3. getNormal 関数:光を反射するための「法線の捏造」

SDFは単なる距離データであり、ポリゴンのように「面」を持っていません。そこで、現在の座標 p から、ほんの少しだけX, Y, Z軸方向にズレた場所(0.001)の距離を再計算します。 「少し右に行ったら距離が減った」=「表面は右を向いている」という勾配(Gradient)の原理を利用して、数学的に完璧な法線(Normal)を算出しています。この精度が、後述する金属の艶やかさを決定づけます。

4. メインループ:視線(レイ)を飛ばす

画面の各ピクセルから、カメラ(ro)を出発点として奥(rd)へ向かって視線を飛ばします。
map() 関数で「最も近い物体までの距離 d」を測り、その分だけ前進する……これを最大100回繰り返します。距離が 0.001 以下になった瞬間、レイは「液体金属の表面に激突した」と判定されます。

5. ライティング:ただのノイズを「液体金属」へ変成させる

激突したポイントの法線(normal)を使い、質感を塗っていきます。 今回は本物の環境マップ(HDRI画像など)を使わず、計算だけで「青と紫のサイバーパンクなネオン街に置かれたクロムメッキ」をフェイクしています。

  • reflectDir: カメラの視線が表面でどう反射するかを計算します。
  • envColor: 反射した方向が「上(Yがプラス)」なら青を、「下(Yがマイナス)」なら紫を混ぜることで、上下から異なる光を浴びている環境を捏造します。
  • spec / fresnel: 金属特有の「鋭いハイライト」と「輪郭が光るフレネル現象」を足し合わせます。

これら全てを gl_FragColor に合成した瞬間、数学の塊は、息を呑むほどリアルな液体金属へと変貌を遂げるのです。

3. Three.js のセットアップ(神の視点用コンソール)

GLSL(フラグメントシェーダー)という強力な魔法陣が完成しました。あとは、それをブラウザ上に召喚するための「器」を用意するだけです。

上記のGLSLをThree.jsで動かすための最小構成がこちらです。今回は3DのMesh(球体など)を配置するのではなく、画面全体を覆う「1枚の平らな板(Plane)」に対して描画を行います。

import * as THREE from 'three';

// 1. シーンとカメラの準備
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
camera.position.z = 1;

// 2. レンダラー
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 3. マテリアル(先ほどのGLSLを流し込む)
const material = new THREE.ShaderMaterial({
    vertexShader: `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = vec4(position, 1.0);
        }
    `,
    fragmentShader: document.getElementById('fragmentShader').textContent, // GLSLコード
    uniforms: {
        uTime: { value: 0.0 },
        uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
    }
});

// 4. フルスクリーンの板ポリゴン
const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// 5. アニメーションループ
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);
    material.uniforms.uTime.value = clock.getElapsedTime();
    renderer.render(scene, camera);
}
animate();

// リサイズ対応
window.addEventListener('resize', () => {
    renderer.setSize(window.innerWidth, window.innerHeight);
    material.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);
});

「1枚の板」に世界を投射するプロジェクター

Three.jsの通常のセットアップ(PerspectiveCameraを置き、BoxやSphereを配置する)とは少し毛色が違うことにお気づきでしょうか。レイマーチングにおけるThree.jsは、いわば「GLSLを投影するための単なるプロジェクター」に過ぎません。

ここで行っているパラダイムシフトを直感的に解剖します。

1. OrthographicCamera と PlaneGeometry(2, 2) の意味

遠近感を持たない OrthographicCamera(平行投影カメラ)を使用し、幅2・高さ2の PlaneGeometry を配置しています。 WebGLのクリップ座標系(画面に表示される座標の範囲)は、XとYがそれぞれ -1.0 から 1.0 の範囲(つまり幅2、高さ2)です。このカメラと板の組み合わせにより、「ブラウザの画面サイズにぴったりと張り付く、たった1枚の巨大なポリゴン」が生成されます。 私たちはこの2Dのスクリーンの各ピクセル(断片)に対して、フラグメントシェーダーから視線(レイ)を撃ち出し、奥にある架空の3D空間を捏造しているのです。

2. 極限まで削ぎ落とされた Vertex Shader

ShaderMaterial に書かれているVertex Shader(頂点シェーダー)は、驚くほどシンプルです。 通常ならカメラの位置やプロジェクション行列を掛け合わせて頂点を変換しますが、今回はすでに画面にぴったり張り付く板を用意しているため、gl_Position = vec4(position, 1.0); とそのまま位置を渡すだけで完結します。ポリゴンを動かす(Displacement)必要もありません。変形はすべて、ピクセル単位のSDF計算が引き受けます。

3. 世界を駆動する Uniforms(uTime と uResolution)

JavaScript側からGLSLの魔法陣に介入できる唯一のパラメータが Uniforms です。

  • uTime: THREE.Clock() を使って経過時間を毎フレーム送り込みます。これがノイズ空間を時間軸(4D)で押し流し、液体金属を永遠に沸騰させ続ける「心臓の鼓動」になります。
  • uResolution: 画面の縦横サイズです。これがないと、ウィンドウを横長にしたときにレイが飛ぶ方向が歪み、液体金属が楕円形に伸びてしまいます。

何万ものブロックやパーティクルの座標をJavaScript側で管理するのではなく、たった1つの四角形に「数式」と「時間」だけを流し込む。あとはGPUが全ピクセルを並列計算し、圧倒的な質感を叩き出す。これがレイマーチングとThree.jsの美しい架け橋の構造です。

4. ディレクター向け・チューニングガイド

無事に画面へ銀色の物体が召喚されたでしょうか? しかし、ここからがノイズアートの本当の醍醐味です。あなたは今、数式が作り出す世界の「神の視点(ディレクター)」を手に入れました。

GLSLコードの map 関数内にあるたった数文字のパラメータを書き換えるだけで、この物質の物理法則は劇的に変化します。ぜひ、自分の手で以下のチューニングを試してみてください。

パターンA:無重力空間の「滑らかな水銀」

【変更箇所】周波数(Frequency)を下げる

// Before
float displacement = fbm(p * 1.5 + uTime * 0.5) * 0.8;

// After
float displacement = fbm(p * 0.5 + uTime * 0.5) * 0.8;

空間から「細かいノイズ(高周波)」を取り除き、「大きなうねり(低周波)」だけを残します。表面のザラつきや沸騰感が消え去り、まるで無重力空間を漂う巨大な水銀の雫や、ゆっくりと変形するスライムのような、静かで有機的な流体へと変化します。

パターンB:エネルギーの暴走「激しい沸騰」

【変更箇所】時間係数(Time Multiplier)を上げる

// Before
float displacement = fbm(p * 1.5 + uTime * 0.5) * 0.8;

// After
float displacement = fbm(p * 1.5 + uTime * 3.0) * 0.8;

4Dノイズ空間を突き進む「時間(uTime)」のスピードを強制的に加速させます。ゆっくりとした変形が、一転して制御不能なエネルギーの暴走へ。表面が激しく波打ち、今にも爆発しそうな不安定な液体金属になります。オーディオビジュアライザーで「激しいビート」が鳴った瞬間にこの数値を跳ね上げると、非常に強烈なVFXになります。

パターンC:トポロジーの崩壊「千切れる破片」

【変更箇所】ノイズ強度(Amplitude)を極端に上げる

// Before
float displacement = fbm(p * 1.5 + uTime * 0.5) * 0.8;

// After
float displacement = fbm(p * 1.5 + uTime * 0.5) * 2.0; // 0.8 -> 2.0

ノイズが空間の距離を歪める力(振幅)を、元の球体の半径(1.5)を超えるレベルまで引き上げます。すると、距離関数が完全に崩壊。一つの塊だった液体金属が空間のあちこちで「千切れ」、空中に漂う無数の破片となって独立し、また近づいてはドロリと融合します。 「頂点が繋がっているポリゴン」では絶対に不可能な、Raymarching最大の魔法がここにあります。

おわりに

いかがでしたでしょうか。 ポリゴンの「Displacement(変位)」という物理的な制約から解放され、空間の「距離」そのものをノイズで歪めるRaymarchingの世界。数式とノイズだけで、ちぎれては融合する完全な流体の質感が錬成されました。

地味で孤独な数学の実装の果てに、この圧倒的で生々しいルックが出力される瞬間。これこそが、プロシージャル表現における最大の報酬です。

次回、「[Noise 入門 #54] プロシージャル・プラズマとエネルギーシールド」では、このSDFの等高線を抽出し、SFゲームに登場するような「攻撃を弾くバリア」や「発光する力場」をThree.js上で展開します。お楽しみに。