TFFでGPUパーティクル
16万個!
TFF編は今回で一区切りです。今後取り上げる予定があるかどうかわかりません。とりあえずおおまかな使い方はわかったのではないでしょうか。どんな道具もまずはどんな使い方でもいいので要領を知ることが大事です。道具はなんぼあってもいいですからね。
TFFでパーティクルを動かします。点描画は点の数を増やしてなんぼです。ただの点なので。今回動かすのはどーんと16万個です。これだけ大きいので、1024個の時みたいにグローテクニックは使いません。やったら消滅してしまいます。というかフラグメントシェーダであまり複雑なことをしたくないというのもあります。多すぎるので...。
基本的にはwgldのサイトにある方法に準拠しますが、あっちは前回紹介したフロートテクスチャスワップを使っているので参考にできないんです。ただ個別更新なのでTFFで問題なく実行できます。前回は触れませんでしたが、フロートテクスチャスワップにも弱点があって、それはアトリビュートへの変換が困難という点です。あっちでは位置と速度をフェッチしたデータから無理やり構築していますが、こっちのやり方ならVAOを余分に作るだけで足ります。そういう利点はあります。
またあっちと違って400x400でやります。512x512でもいいんですが、こっちとしてはできるだけ400x400の縛りに合わせたい、それともうひとつ、端数だと初期化時の見栄えがいまいちという問題があります。点なので、跨ぐと見た目に粗が出てしまいます。綺麗さを重視しました。
前置きが長くなりましたが、解説に入ります。一応インタラクションまで入れるつもりですが、Vanillaで用意しようとすると長くなるので外部ライブラリに頼ります。foxIA,といいます。あと、色も借りましょうか。レインボーはWebGLだと難しいので。ちなみに上記のwgldのサンプルはスマホでは動かないので、この記事をスマホで見ている人は気を付けてくださいね。確認する手段が無いので。
コード全文
コードの解説
今回用意するプログラムは2つです。
- 初期化用:点の位置を400x400に配置する
- 更新・描画用:更新して、描画する
説明が雑。更新して描画するというのはそのまんまの意味です。今回は点描画なのでそのまま描いちゃおうというわけです。まあわざわざ分けなくてもできるならその方が手っ取り早いですからね。初期化というのはこれもまんまの意味で、400x400の位置に、つまり各ピクセルにダイレクトに置きます。なおDPRを考慮しないと綺麗に配置されないので注意してください。ドローコールでもやった...やってないか。あのですね、gl_PointSizeはDPRを考えないといけないんです。というのも結局DPRの分キャンバスが大きくなっているんですが、gl_PointSizeはそれを考えないので、そのまま反映されるんですね。なのでキャンバスに合わせて大きくしないといけないわけです。それだけですね。
ちなみにあっちのサンプルはDPRなんか考慮していません。まあそれ以前にスマホで動かないのでどうでもいいですね。
初期化用プログラム
まあハードコーディングなんですが、場合によりけりだと思います。今回はこれでいいでしょう。
#version 300 es
out vec2 vPosition;
out vec2 vVelocity;
void main(){
int i = gl_VertexID;
int ix = i % 400;
int iy = i / 400;
vec2 fp = (vec2(float(ix), float(iy))-199.5)/200.0;
vPosition = fp;
vVelocity = vec2(0.0);
}
#version 300 es
void main(){}
WebGL2なので堂々と「%」を使います。まあ時代はもうWebGPUなので。それで、これで位置が決まります。プログラムと一緒にもうバッファを2つ分作ってしまいましょう。
const pgInit = createShaderProgram(gl, {vs:vsInit, fs:fsInit, outVaryings:["vPosition", "vVelocity"], separate:false});
const buf0 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf0);
gl.bufferData(gl.ARRAY_BUFFER, 16*160000, gl.DYNAMIC_COPY);
const buf1 = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf1);
gl.bufferData(gl.ARRAY_BUFFER, 16*160000, gl.DYNAMIC_COPY);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
const initialize = (buf) => {
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buf);
gl.useProgram(pgInit);
gl.beginTransformFeedback(gl.POINTS);
gl.enable(gl.RASTERIZER_DISCARD);
gl.drawArrays(gl.POINTS, 0, 160000);
gl.disable(gl.RASTERIZER_DISCARD);
gl.endTransformFeedback();
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
}
initialize(buf0);
bindBufferBaseを1回やるだけ、それだけなのでこれで充分ですね。TFFにはもう慣れたでしょうか。まあこの節が終わっても自習するなりして復習したらいいと思います。これを最初に1回やって、それで描画開始です。まあguiでリセットできるようにするんですが。
それではメインプログラムに入りましょう。
更新・描画プログラム
更新メソッドはwgldの記事そのままです。ぶっちゃけ理屈が未だによくわからないんですが、綺麗な見た目になるので一つの正しい答えなんだと思います。16万個というのはとてつもない数です。何が言いたいかというと、下手にコードを書くと散漫でパッとしない見た目になるんです。例えばですが、2つ前の節でやったような単純な反射を書くとスカスカでパッとしない内容になるんですよ。まあやってみればわかりますが...ですから、このような工夫が必要になります。数が多いから強いというのは単純思考で、多い場合は多いなりの工夫をしないとゴミコードになってしまいます。やらないで分かることというのは実際ほとんどないんですね。
前置きはさておき、内容に行きましょう:
#version 300 es
layout (location = 0) in vec2 aPosition;
layout (location = 1) in vec2 aVelocity;
out vec2 vPosition;
out vec2 vVelocity;
const float SPEED = 0.05; // 基本速度
uniform vec2 uMouse;
uniform bool uMouseIsPressed;
uniform float uSpeedFactor;
uniform float uDPR;
void main(){
vec2 p = aPosition;
vec2 v = aVelocity;
vec2 v1 = normalize(uMouse - p) * 0.2;
vec2 v2 = normalize(v1 + v);
vec2 newP = p + v2 * SPEED * uSpeedFactor;
vPosition = newP;
vVelocity = (uMouseIsPressed ? v2 : v);
// 描いちゃえ
gl_Position = vec4(newP, 0.0, 1.0);
gl_PointSize = uDPR;
}
#version 300 es
precision highp float;
uniform vec3 uBaseColor;
out vec4 fragColor;
void main(){
fragColor = vec4(uBaseColor, 0.0);
}
今回参考にしたのはwgldのあの記事ですが、インタラクション関連は2年前に作った自作コードを参考にしました。生まれて初めてTFFをやった時のですね。嬉しさのあまりTLにコードを上げたら完全無視されたのはいい思い出です。それはいいとして、内容ですね。
アトリビュートは位置と速度です。0と1に置きます。アウトプットも同じようなのを2つ分です。uMouseはマウス位置ですが、このマウス位置というのが厄介で、なんとマウスダウンしてなくても値が必要なわけです。どうせいっちゅうねんって感じです。p5とかなら思考停止でmouseX,mouseYを用意するんでしょうが、それはどうでもいいので今回は秘密兵器を使います。説明は後で。
なおuniformとかもすべてwgld準拠です。フラグメントシェーダで色を使っているのも一緒です。この色ですが、wgldのサンプルでは時間経過により色が変わる仕様になっています。こっちでも似たようなことをfisceを使ってやってます。そろそろ解説しましょうか。
VAOとTFO
とりあえずVAOとTFO, ここまでやってきたことと一緒です。インターリーブ的なバッファの取り扱いにはもう慣れたでしょうか。
let bufIndex = 0;
const bufs = [buf0, buf1];
const vaos = [];
const tfos = [];
for(let i=0; i<2; i++){
const curBuf = bufs[i];
const otherBuf = bufs[1-i];
// まずVAO
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, curBuf);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 16, 0);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 16, 8);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
gl.bindVertexArray(null);
// 次にTFO
const tfo = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tfo);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, otherBuf);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
vaos.push(vao);
tfos.push(tfo);
}
解説することが無いですね...次に行きましょう。内容的にはTFO用意しましたのところですね。
インタラクション関連
要件としては、次のような感じです:
- マウスやスタイラスペンの場合はその位置を取得してほしい。また、押してるかどうかを取得できるようにしてほしい。
- タッチの場合は最後にタッチした位置を取得してほしい。また、タッチされているかどうかを取得できるようにしてほしい。
- 離してから再び押すときにいきなりその位置に行くのではなく徐々にそこに向かうようにしてほしい。
この辺りの条件を満たす仕事をしてくれるのがLocaterです。じゃじゃ馬ですが、いい仕事してくれるやつです。具体的にはキャンバスで初期化して毎フレーム更新、getPosでさっき述べたような位置情報取得、isActiveでさっき述べたような有効状態の取得をしてくれます。ついでに勝手に位置座標の正規化もしてくれます(左上0,0,右下1,1)。まあWebGL専用ではないので。以上ですね。
基本的にfoxIAの子たちはそれぞれ役割があるんですが、一般的にはpointerの仕様が仕事してるんですよね。ただそれだとこういうざっくりと「位置」がほしいという要求に応えるのが難しいうえ、pointerが存在しないと仕事できないので不向きなんですね。それでざっくりと「位置」をくれる仕様という意味でLocaterと名付けました。
なお徐々に向かうという仕様は作るときにfactorを指定すると機能します:
const LC = new fisce.foxIA.Locater(cvs, {factor:0.1});
Locaterいい仕事してくれました。おつかれ!
色関連
色関連ですが、coulour3というのを使っています。ざっくり言うとお手軽にhsv指定でRGB配列を取得する為の関数です。まあドンピシャで役に立つ関数というわけです。あんま説明する必要も無いんですが、こうですね:
// coulour3の方は分かりやすいですね。
uniformX(gl, pg , "3f", "uBaseColor", ...coulour3("hsv", hueValue, 0.85, 1));
見ての通り、"hsv"を指定するとそれっぽい色をくれます。以上ですね。扱いが雑!まあこっちもお疲れさんです。
メインループ
あとは普通に全文を見てくれればいいんですが、一応メインループを載せておきます。なおラスタライズの無効化をしてないのは先に述べた通りです。TFFで描画を実行することはめったにないので、珍しいコードではありますね。
const loop = () => {
gl.bindVertexArray(vaos[bufIndex]);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tfos[bufIndex]);
gl.useProgram(pg);
// ロケーターは逐次更新です。getPosで「位置」が取得できます。
// ここで言う「位置」というのはマウスやタッチペンの場合はその「位置」で、
// タッチの場合は最後にタッチした「位置」という感じですね
LC.update();
const p = LC.getPos({normalize:true});
uniformX(gl, pg, "2f", "uMouse", 2*p.x-1, 1-2*p.y);
// LC.isActive()というのはざっくりいうとマウスが押されてないとかタッチがされてない
// という意味なんですがLocaterはそれでも位置情報を持ってるんですね。factorというのは
// 減衰率で、球に位置が変わってもすぐにその場所にはワープせず徐々に...まあいいか。
if (LC.isActive()) {
speedFactor = 1.0;
uniformX(gl, pg, "1i", "uMouseIsPressed", true);
} else {
speedFactor *= 0.95;
uniformX(gl, pg, "1i", "uMouseIsPressed", false);
}
uniformX(gl, pg, "1f", "uSpeedFactor", speedFactor);
// coulour3の方は分かりやすいですね。
uniformX(gl, pg , "3f", "uBaseColor", ...coulour3("hsv", hueValue, 0.85, 1));
uniformX(gl, pg, "1f", "uDPR", DPR);
// 今回はそのまま描画してしまうのでラスタライザ無効化は無しです
// 今回は特別です。まあ点描画ですからそのまま描いちゃった方が早いでしょう。
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.beginTransformFeedback(gl.POINTS);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.POINTS, 0, 160000);
gl.disable(gl.BLEND);
gl.endTransformFeedback();
gl.bindVertexArray(null);
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
gl.flush();
hueValue += 0.001;
if(hueValue > 1){
hueValue -= 1;
}
bufIndex = 1-bufIndex;
// 更新なので、これでbufs[bufIndex]がカレントになる。
if(resetFlag){
initialize(bufs[bufIndex]);
resetFlag = false;
}
window.requestAnimationFrame(loop);
}
DPRを先に説明したようにuniformで送っています。こうしないと同じ見た目にならないので。DPRはそのままgl_PointSizeで使っています。wgldの記事で0.1とか出てきていますが0.1などというサイズは存在しないので小細工無しでやってます。多分そのせいで全く同じ見た目にはなってないですね。まあそれは良いとして、あとはここまでに説明した通りです。resetFlagどうこう、とあるのはguiでリセットするための仕様です。クリックすると最初の見た目に戻ります。以上となります。
この記事を書いてる間にドジャースが延長18回を制してワールドシリーズ第三戦、ブルージェイズにフリーマンのホームランでサヨナラ勝ちしました。すごいですね。ここに記しておきます。日記かこれは。
TFFのおわりに
今現在のp5のインタラクションがどうなっているかというと、まあ2.0.5前提で話しますが、まずmouseIsPressedについてですが、タッチの機能がおかしなことになってて、なんとキャンバス外をタッチしても機能する仕組みになっています。なんでやねん。しかもそのままキャンバス内にスライドするとmouseIsPressedが入りっぱなしになります。キャンバス内でタッチを離すと、です。摩訶不思議な仕様です。まあどうでもいいんですが。バグだらけのようです。ほんとに来夏の本リリースに間に合うのか??
私は自分のスマホが大好きなので、基本的にすべての作品はスマホでも楽しみたいと思っています。まあすべてというわけにはいかないんですが。たとえばスマホでTwitterを見ていて気になる作品が流れてきたらそのまま閲覧したいと思いますよね。それだけのことです。しかしコードによってはスマホだと激重だったりします。その際に、スマホでも軽くなる選択肢があるんだったら選ばない手はないですよね。それだけのことです。その辺りでなるべく妥協したくないだけですね。
なにはともあれTFF編お疲れ様でした。後半はUBOです。TFFほどしゃべることが無いのであっさりした内容になるかと思います。自分も詳しくないので。ブロックの仕様とかもきちんと理解すると難しいのであっさり触るだけとします。自分もそこまで興味無いので。前回話したと思いますが、uniformはきっちり限界が決められているんですがUBOはそれを突破できます。そういう話をできればと思います。