🫧

Processingでメタボールを作る - GLSLシェーダーで有機的なアニメーション

に公開

はじめに

メタボール(Metaball)とは、複数の球体が近づくと滑らかに融合するような、有機的で流動的な形状を表現する技術です。1980年代にCG分野で開発され、現在でもゲームやアート作品で広く使用されています。

この記事では、ProcessingとGLSLシェーダーを使って、リアルタイムで動くメタボールを実装する方法を解説します。

完成イメージ

https://x.com/chicochoo/status/1994839341584232584

今回作るのは、以下のような特徴を持つメタボールアニメーションです:

  • ボールが重力と摩擦の影響を受けながら動く
  • ボール同士が近づくと滑らかに融合して見える
  • 「T」キーで時間停止/再開のトグル
  • スペースキー長押しでボールを撹拌

メタボールの原理

基本的な考え方

メタボールは**等値面(Isosurface)**の概念に基づいています。

各ボールから空間上の全ての点に対して「影響度」を計算します。影響度は距離に反比例し、ボールに近いほど大きくなります。

影響度 = 定数 / 距離

複数のボールがある場合、各点での影響度の合計値を計算します。この合計値が閾値(threshold)を超える領域を描画することで、ボール同士が融合したような見た目が実現できます。

数式で表すと

P における影響度 S は、n 個のボールに対して以下のように計算されます:

S(P) = \sum_{i=1}^{n} \frac{r_i}{||P - C_i||}

ここで:

  • r_i: ボール i の半径(影響の強さ)
  • C_i: ボール i の中心座標
  • ||P - C_i||: 点 P とボール中心の距離

ヒートマップにするとこんなかんじ

実装

プロジェクト構成

metaball/
├── metaball.pde      # メインのProcessingスケッチ
└── data/
    └── metaball.glsl # フラグメントシェーダー

Processing側のコード(metaball.pde)

まず、メインのProcessingスケッチを作成します。

変数の宣言

PShader metaShader;
int numBlobs = 12; 
Blob[] blobs = new Blob[numBlobs];
float[] blobCoords = new float[numBlobs * 2];

// 余白管理用の変数
float marginTop = 20;
float marginBottom = 40;
float marginLeft = 20;
float marginRight = 20;

// 時間操作用の変数
boolean isTimeStopping = false; 
float currentFriction = 0.993;   
float currentGravity = 0.2;     

// イージング用の進行度(0.0 = 通常 〜 1.0 = 完全停止)
float stopProgress = 0.0; 

setup関数

void setup() {
  size(800, 800, P2D); 
  metaShader = loadShader("metaball.glsl");
  
  for (int i = 0; i < numBlobs; i++) {
    blobs[i] = new Blob(random(width), random(height/2));
  }
}

ポイント:

  • P2Dレンダラーを使用することでシェーダーが有効になります
  • loadShader()でGLSLファイルを読み込みます

draw関数 - イージングによる時間停止

void draw() {
  // 停止/再開の進行度を管理
  if (isTimeStopping) {
    stopProgress += 0.02;
    if (stopProgress > 1.0) stopProgress = 1.0;
  } else {
    stopProgress -= 1;
    if (stopProgress < 0.0) stopProgress = 0.0;
  }
  
  // イージング計算(2乗のカーブで自然な減速感)
  float t = stopProgress;
  float ease = t * t;

  // イージング値を物理パラメータに変換
  currentFriction = map(ease, 0.0, 1.0, 0.993, 0.80);
  currentGravity = map(ease, 0.0, 1.0, 0.2, 0.0);

イージングのポイント:

ease = t * t(2乗)を使うことで、急に止まるのではなく徐々に減速する自然な動きを実現しています。

t ease 体感
0.1 0.01 ほぼ変化なし
0.5 0.25 少し減速
0.9 0.81 かなり減速
1.0 1.00 完全停止

draw関数 - ボールの更新とシェーダーへの送信

  // スペースキー長押しで撹拌
  if (keyPressed && key == ' ') {
    for (Blob b : blobs) {
      PVector force = PVector.random2D();
      force.mult(15);
      b.vel.add(force);
    }
  }

  // ボールの座標を更新し、シェーダー用の配列に格納
  for (int i = 0; i < numBlobs; i++) {
    blobs[i].update();
    blobCoords[i*2] = blobs[i].pos.x;
    blobCoords[i*2+1] = blobs[i].pos.y;
  }

  // シェーダーにパラメータを送信
  metaShader.set("u_resolution", float(width), float(height));
  metaShader.set("blobs", blobCoords, 2);
  metaShader.set("numBlobs", numBlobs);
  
  // シェーダーで描画
  shader(metaShader);
  rect(0, 0, width, height);
  resetShader();
}

Blobクラス - 物理シミュレーション

class Blob {
  PVector pos, vel;
  
  Blob(float x, float y) {
    pos = new PVector(x, y);
    vel = new PVector(random(-2, 2), random(-2, 2));
  }
  
  void update() {
    vel.y += currentGravity;  // 重力
    vel.mult(currentFriction); // 摩擦
    pos.add(vel);
    
    // 壁の跳ね返り(余白を考慮)
    if (pos.x < marginLeft) { pos.x = marginLeft; vel.x *= -0.95; }
    if (pos.x > width - marginRight) { pos.x = width - marginRight; vel.x *= -0.95; }
    if (pos.y < marginTop) { pos.y = marginTop; vel.y *= -0.95; }
    if (pos.y > height - marginBottom) { pos.y = height - marginBottom; vel.y *= -0.95; }
  }
}

GLSLシェーダー(metaball.glsl)

GLSLシェーダーがメタボールの描画を担当します。CPUで全ピクセルを計算すると非常に重いですが、GPUを使うことでリアルタイム描画が可能になります。

シェーダーの全体構造

#ifdef GL_ES
precision mediump float;
#endif

uniform vec2 u_resolution;
uniform vec2 blobs[100];
uniform int numBlobs;

void main() {
    vec2 st = gl_FragCoord.xy;
    st.y = u_resolution.y - st.y; // 上下反転補正(Processing座標系に合わせる)

    float sum = 0.0;
    
    // 各ボールからの影響度を合計
    for (int i = 0; i < 100; i++) {
        if (i >= numBlobs) break;
        float d = distance(st, blobs[i]);
        if (d > 0.0) {
            sum += 6000.0 / d;  // 距離に反比例する影響度
        }
    }

ポイント:

  • gl_FragCoord.xy: 現在処理中のピクセル座標
  • 6000.0 / d: この定数がボールの「見た目の大きさ」を決定

輪郭線の描画

メタボールを塗りつぶしではなく、輪郭線のみで描画しています。

    vec3 bgColor = vec3(0.05, 0.05, 0.1);  // 背景:暗い青
    vec3 lineColor = vec3(0.2, 1.0, 0.9);  // 輪郭:シアン

    float threshold = 350.0;  // この値を超える領域がメタボール内部
    float range = 12.0;       // 輪郭線の太さ

    // 閾値からの距離を計算
    float distFromPeak = abs(sum - threshold);
    
    // smoothstepで滑らかなグラデーション
    float t = 1.0 - smoothstep(0.0, range, distFromPeak);
    
    vec3 finalColor = mix(bgColor, lineColor, t);
    gl_FragColor = vec4(finalColor, 1.0);
}

描画ロジックの解説:

  1. sum - threshold: 影響度が閾値をどれだけ超えているか
  2. abs(): 閾値の「境界」からの距離(内側でも外側でも正の値)
  3. smoothstep(): 境界から離れるほど0に近づく(滑らかに)
  4. mix(): 背景色と輪郭色をブレンド

これにより、メタボールの「等高線」だけが光って見えます。

パラメータ調整のコツ

パラメータ 効果 推奨値
6000.0 (シェーダー内) ボールの影響半径 3000〜10000
threshold メタボール境界の位置 200〜500
range 輪郭線の太さ 5〜20
currentFriction 速度減衰 0.98〜0.999
currentGravity 重力の強さ 0.1〜0.5

カスタマイズのアイデア

1. 色を変える

シェーダー内の色を変更するだけで、雰囲気が大きく変わります。

vec3 lineColor = vec3(1.0, 0.3, 0.5); // ピンク

2. 塗りつぶしにする

輪郭線ではなく塗りつぶしにするには:

float t = smoothstep(threshold - range, threshold, sum);

3. ボールの数を増やす

numBlobsを変更し、シェーダーの配列サイズ(blobs[100])内に収まる範囲で増減できます。

まとめ

この記事では、ProcessingとGLSLを使ったメタボールの実装方法を解説しました。

学んだこと:

  • メタボールの数学的原理(等値面)
  • ProcessingからGLSLシェーダーへのデータ送信
  • イージングを使った自然なアニメーション
  • フラグメントシェーダーでの輪郭描画テクニック

GLSLシェーダーは最初は難しく感じますが、一度理解すると非常に強力なツールになります。ぜひ自分なりにパラメータを調整して、オリジナルのメタボール表現を作ってみてください!

参考リンク


コード全文はGitHubで公開しています:
https://github.com/iuti/metaballPDE

Discussion