openFrameworksでGPUParticleを実装してみた。


openFrameworksを普段からゆるりといじっていますが、CPU計算だけだと大量の処理を同時に行うことが難しいので、最近GPU計算に興味を持ち始め、シェーダを少しかけるようになったので、GPUParticleを実装してみました。まだ拙いところはあると思いますが、参考にしていだければ幸いです。また、修正したほうがいい箇所などありましたら、指摘していただけると嬉しいです。
アルゴリズムについては、Nature of CodeのAttractorを参考にしていて、引力の中心であるAttractorの座標は音楽によって制御するようにしています。

パーティクルの数は512 * 512で約25万個くらいで、60fps出ました。強い。。。

GPUParticleとは

大量の粒子(Particle)の頂点計算をする際に、CPUで計算するのではなく、大量の並列計算が得意なGPUで計算することで、処理速度を早くし、滑らかな表現を実現する手法です。
この記事の内容はシェーダを少しかじっているくらいの人を対象に書いているので、完全な初学者の方には少し難しい部分があるかもしれません。その際にはbook of shaderなどで勉強することをお勧めします。

実行環境

  • macOS High Sierra 10.13.4
  • openFrameworks 0.10.0

addon

  • ofxGui

実装

GPUParticleでは、1フレーム前の頂点、速度情報などをテクスチャに保存し、GPU側(シェーダ)で読み込み計算することで、パーティクルの更新をしています。そのためにPingPongBufferを用意し、FBOを切り替えながら、テクスチャに情報を書き込んでいきます。
また、PingPongBufferについてはYasuhiro Hoshinoさんの例を参考にしていて、初期設定や、テクスチャに書き込んだりする部分は、加速度を保存する用のテクスチャを足した以外、ほとんど変わらないので割愛します。
以下では主にシェーダ(GLSL)について説明していきます。GLSLと聞くと難しく感じるかもしれませんが、今回の実装方法は簡素な作りとなっているので、あまり慣れていない人でも比較的わかりやすいかと思います。

1. 位置を更新するためのフラグメントシェーダ(posUpdate.frag)

#version 150
precision mediump float;

uniform sampler2DRect posData;
uniform sampler2DRect velData;
uniform sampler2DRect accData;
uniform vec3 attractor;
uniform bool isAttract;
uniform vec2 mouse;
uniform float strength;
uniform float time;

in vec2 vTexCoord;

out vec4 vFragColor0;
out vec4 vFragColor1;
out vec4 vFragColor2;

// ----- 3D -----
vec3 checkEdges(vec3 p, vec3 v) {
    if(p.x < 0.0 || p.x > 1.0) {
        v.x *= -1;
    }
    if(p.y < 0.0 || p.y > 1.0) {
        v.y *= -1;
    }
    if(p.z < 0.0 || p.z > 1.0) {
        v.z *= -1;
    }
    return v;
}

void main() {
    vec3 pos = texture(posData, vTexCoord).xyz;
    vec3 vel = texture(velData, vTexCoord).xyz;
    vec3 acc = texture(accData, vTexCoord).xyz;

//    vec2 m = mouse / vec2(1024.0, 768.0); // mouse position
    vec3 m = attractor / vec3(1024.0, 768.0, 1024.0);
    vec3 dir = m - pos; // caluculate direction
    float dist = length(m - pos) * 2.0; // distance from mouse to pos

    float limit = 0.005;
    if(dist < limit) dist = limit;

    float st = strength / (dist * dist); // attractive calc
    dir *= st;
    if(isAttract) acc += dir; // attract
    else acc -= dir * 0.1;    // repulsion
    vel += acc;
    vel *= 0.01;
    if(vel.x > 2.0) vel.x = 2.0;
    if(vel.y > 2.0) vel.y = 2.0;
    if(vel.z > 2.0) vel.z = 2.0;

    vec3 nextPos = pos + vel;
    vel = checkEdges(nextPos, vel);

    // Update the Position
    pos += vel;

    vFragColor0 = vec4(pos, 1.0);
    vFragColor1 = vec4(vel, 1.0);
    vFragColor2 = vec4(acc, 1.0);
}

このシェーダでは、送られてきた位置、速度、加速度のテクスチャを読み込み、値を更新してvFragColorで出力しています。値の更新に使ったAttractのアルゴリズムは以下の通り。

  1. 粒からAttractorへの向きdirを求める。
  2. 粒とAttractorの距離distを求める。
  3. 二点間の力の強さstを求める。
二点間にかかる力の強さ\hspace{5mm}
G\frac{m1*m2}{r^2}

ここでの$G$は万有引力定数、$m1,m2$は二物体の質量を表します。今回は、質量などは考えないことにしているので、分子を丸々uniform変数strengthで置き換えて計算しています。
4. 加速度accdir*stを代入する。
5. 速度velに加速度accを足す。
6. 位置posに速度velを足す。

以上のアルゴリズムを実装すると上記のようになります。こうやって書き起こしてみると今回のコードが簡素であることがわかるかと思います。checkEdgesに関しては、粒が遠くに行き過ぎないように位置に制限をかけています。

2. レンダリング用のシェーダ(render.vert)

#version 150
precision mediump float;

uniform mat4 modelViewProjectionMatrix;
uniform float time;

in vec2 texcoord;
in vec4 color;

uniform sampler2DRect posTex;
uniform vec2 screen;

out vec4 vColor;
out vec2 vTexCoord;

void main() {
    vec4 pixPos = texture(posTex, texcoord);
    vColor = vec4(pixPos.xy, 1.0 - pixPos.y, 1.0);
    pixPos.x = pixPos.x * screen.x - screen.x / 2;
    pixPos.y = pixPos.y * screen.y - screen.y / 2;
    pixPos.z = pixPos.z * screen.x - screen.x / 2;

    vTexCoord = texcoord;
    gl_Position = modelViewProjectionMatrix * pixPos;
}

ここでは、先ほどのposUpdateで更新した位置情報をもとに、レンダリングする際の頂点の位置を決定しています。GLSLでは基本的に値が0~1の間に正規化されていることが多く、pixPosも例外ではないので、この値に拡大したい値screenをかけることによってスクリーン上で綺麗にみることができます。描画用のフラグメントシェーダrender.fragでは、ここで送った色を出力しているだけなので割愛します。

まとめ

テクスチャを切り替えたりということがわかりづらかったりしますが、実際に作ってみるとそんなに難しいことはしていないようです。GLSLまだまだ全然理解できてないから、レイマーチングとかもやっていきたい。。。

リンク