はじめに
以前書いた記事:
p5.jsのフレームバッファを使ってGPGPUで点描画を実行する
のコードを改変して、wgld.orgにあるようなパーティクルの描画をします。
GPGPU でパーティクルを大量に描く
ほぼ一緒なので、コードの説明は大幅にカットします。uniformをいくつか追加したうえで、updateのロジックを書き換えるだけなので。加えて、displayについても色がリアルタイムで変化するように改変します。
コード全文
パフォーマンスを表示するためにcreateGraphics()でオフスクリーンを作って描画することを試みたんですが、なぜかテクスチャが競合を起こしてしまい最後まで解消できなかったので、それについては自作ライブラリを使っています。
p5 GPU particle
// VTFできましたね
// bindTextures()ですね
// baseMaterialShader()の方はこれをやってるんですよね
// 必須ですね
// https://wgld.org/d/webgl/w083.html
// infoでグラフィック落とすのだめですね
// パフォーマンスが知りたいので自作ライブラリに頼ることにします
// というか要は別キャンバスに書いて上から重ねればいい
let pfc;
let fbo0, fbo1;
let dataBuf, indexBuf;
let updateShader;
let displayShader;
const TEX_SIZE = 512; // 262144個
let velocity = 0.0; // マウスが押されてるとき1.0で、離すと減衰する
const inputVS =
`#version 300 es
in float aIndex;
in vec4 aData;
out vec4 vData;
uniform vec2 uSize;
void main(){
float x = mod(aIndex, uSize.x);
float y = floor(aIndex/uSize.y);
vec2 uv = (vec2(x,y)+0.5)/uSize;
uv = 2.0*uv - 1.0;
vData = aData;
gl_Position = vec4(uv, 0.0, 1.0);
gl_PointSize = 1.0;
}
`;
const inputFS =
`#version 300 es
precision highp float;
in vec4 vData;
out vec4 color;
void main(){
color = vData;
}
`;
const updateVS =
`#version 300 es
in float aIndex;
out vec2 vUv;
uniform vec2 uSize;
void main(){
// 位置を取得して送るだけ
float x = mod(aIndex, uSize.x);
float y = floor(aIndex/uSize.y);
vec2 uv = (vec2(x,y)+0.5)/uSize;
vUv = uv;
// y座標は逆にする。お約束。
gl_Position = vec4(uv.x*2.0-1.0, 1.0-uv.y*2.0, 0.0, 1.0);
gl_PointSize = 1.0;
}
`;
const updateFS =
`#version 300 es
precision highp float;
in vec2 vUv;
out vec4 finalData;
uniform sampler2D uData;
uniform vec2 uMouse;
uniform bool uMouseIsPressed;
uniform float uVelocity;
const float SPEED = 0.05;
void main(){
vec4 tex = texture(uData, vUv);
vec2 pos = tex.xy;
vec2 vel = tex.zw;
// 詳しくはwgld参照。これすごいですよね。
// ざっくりいうとマウス位置に向かって緩やかに進行する感じ
// uVelocityはマウスが離れてる場合減衰する。その場合velは一定。
vec2 v = normalize(uMouse - pos)*0.2;
vec2 w = normalize(v + vel);
vec4 data = vec4(pos + w * SPEED * uVelocity, w);
if(!uMouseIsPressed) data.zw = vel;
finalData = data;
}
`;
const displayVS =
`#version 300 es
in float aIndex;
uniform sampler2D uTex;
uniform vec2 uSize;
uniform float uPointScale;
uniform vec2 uAdjust;
void main(){
float x = mod(aIndex, uSize.x);
float y = floor(aIndex/uSize.y);
vec2 uv = (vec2(x,y)+0.5)/uSize;
vec2 p = texture(uTex, uv).xy * uAdjust;
gl_Position = vec4(p, 0.0, 1.0);
gl_PointSize = 0.1 + uPointScale;
}
`;
const displayFS =
`#version 300 es
precision highp float;
out vec4 color;
uniform vec3 uBaseColor;
void main(){
// 透明度を下げてるのは気分です(雑)
color = vec4(uBaseColor, 0.5);
}
`;
function setup() {
createCanvas(windowWidth, windowHeight, WEBGL);
pixelDensity(1);
const _gl = this._renderer;
const gl = _gl.GL;
const iArray = [];
const fArray = [];
for(let k=0; k<TEX_SIZE*TEX_SIZE; k++){
iArray.push(k);
const x = -1+2*(k%TEX_SIZE)/TEX_SIZE;
const y = -1+2*floor(k/TEX_SIZE)/TEX_SIZE;
fArray.push(x, y, 0, 0);
}
indexBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(iArray), gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
dataBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, dataBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(fArray), gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
const inputShader = createShader(inputVS, inputFS);
fbo0 = createFramebuffer({
width:TEX_SIZE, height:TEX_SIZE, format:FLOAT, textureFiltering:NEAREST
});
fbo1 = createFramebuffer({
width:TEX_SIZE, height:TEX_SIZE, format:FLOAT, textureFiltering:NEAREST
});
// これでflip-flopできるはずです。
// input.
fbo0.begin();
shader(inputShader);
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
inputShader.enableAttrib(inputShader.attributes.aIndex, 1);
gl.bindBuffer(gl.ARRAY_BUFFER, dataBuf);
inputShader.enableAttrib(inputShader.attributes.aData, 4);
inputShader.setUniform("uSize",[TEX_SIZE, TEX_SIZE]);
_gl._setPointUniforms(inputShader);
gl.drawArrays(gl.POINTS, 0, TEX_SIZE*TEX_SIZE);
inputShader.unbindShader();
fbo0.end();
updateShader = createShader(updateVS, updateFS);
displayShader = createShader(displayVS, displayFS);
// もうめんどうなのでp5wgexのPerformanceChecker使いましょう
// いいよこれで
pfc = new p5wgex.PerformanceChecker(this.canvas);
}
function draw() {
const _gl = this._renderer;
const gl = _gl.GL;
// マウスを離すと減衰する
if(mouseIsPressed){
velocity = 1.0;
}else{
velocity *= 0.95;
}
// update.
fbo1.begin();
shader(updateShader);
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
updateShader.enableAttrib(updateShader.attributes.aIndex, 1);
updateShader.setUniform("uData", fbo0.color);
updateShader.setUniform("uSize",[TEX_SIZE, TEX_SIZE]);
// マウスが押されてるかどうかのフラグ。タッチでも機能する。
updateShader.setUniform("uMouseIsPressed", mouseIsPressed);
// マウス位置を実際の位置に合うように調整する
const mouseVector = [2*mouseX/width-1, 1-2*mouseY/height];
const mouseCoeff = (width > height ? [width/height,1] : [1,height/width]);
updateShader.setUniform("uMouse", [mouseVector[0]*mouseCoeff[0], mouseVector[1]*mouseCoeff[1]]);
updateShader.setUniform("uVelocity", velocity);
updateShader.bindTextures();
gl.drawArrays(gl.POINTS, 0, TEX_SIZE*TEX_SIZE);
updateShader.unbindShader();
fbo1.end();
// flip-flop.
const tmp = fbo1;
fbo1 = fbo0;
fbo0 = tmp;
// display.
shader(displayShader);
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
displayShader.enableAttrib(displayShader.attributes.aIndex, 1);
displayShader.setUniform("uTex", fbo0.color);
displayShader.setUniform("uSize",[TEX_SIZE, TEX_SIZE]);
// 時間経過で色を変化させる
const hueValue = floor((frameCount%1800)/5);
const col = color(`hsb(${hueValue}, 70%, 80%)`);
displayShader.setUniform("uBaseColor", [
red(col)/255, green(col)/255, blue(col)/255
]);
// 位置を調整して正方形が画面に収まるようにする
const adjustment = (width > height ? [height/width,1] : [1,width/height]);
displayShader.setUniform("uAdjust", adjustment);
// 点のサイズはvelocityを使ってますね。
displayShader.setUniform("uPointScale", velocity);
// これに相当する処理でfillとstrokeはやってるんですよね。bindTextures()を。
_gl._setPointUniforms(displayShader);
// でもpointはやってないんですよね。それで失敗するわけですね。
// blend処理はupdateにも影響するので、描画時のみ有効化し使ったらオフにする
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
background(0);
displayShader.bindTextures();
gl.drawArrays(gl.POINTS, 0, TEX_SIZE*TEX_SIZE);
displayShader.unbindShader();
gl.disable(gl.BLEND);
pfc.update();
}
なおパーティクルの個数は記事に倣って512x512で262144個です。
実行結果:
updateShaderの変更
前回の記事では単に壁で反射させるだけでしたが、今回は本家のロジックを移植しています。
#version 300 es
in float aIndex;
out vec2 vUv;
uniform vec2 uSize;
void main(){
// 位置を取得して送るだけ
float x = mod(aIndex, uSize.x);
float y = floor(aIndex/uSize.y);
vec2 uv = (vec2(x,y)+0.5)/uSize;
vUv = uv;
// y座標は逆にする。お約束。
gl_Position = vec4(uv.x*2.0-1.0, 1.0-uv.y*2.0, 0.0, 1.0);
gl_PointSize = 1.0;
}
#version 300 es
precision highp float;
in vec2 vUv;
out vec4 finalData;
uniform sampler2D uData;
uniform vec2 uMouse;
uniform bool uMouseIsPressed;
uniform float uVelocity;
const float SPEED = 0.05;
void main(){
vec4 tex = texture(uData, vUv);
vec2 pos = tex.xy;
vec2 vel = tex.zw;
// 詳しくはwgld参照。これすごいですよね。
// ざっくりいうとマウス位置に向かって緩やかに進行する感じ
// uVelocityはマウスが離れてる場合減衰する。その場合velは一定。
vec2 v = normalize(uMouse - pos)*0.2;
vec2 w = normalize(v + vel);
vec4 data = vec4(pos + w * SPEED * uVelocity, w);
if(!uMouseIsPressed) data.zw = vel;
finalData = data;
}
マウス位置やマウスが押されているかどうかのフラグを受け取ってそれに基づいて処理しています。詳しくは本家の方に書かれています。マウスが押されていない場合、徐々に速度が減衰していきます。具体的にはuVelocityのところです。
// update.
fbo1.begin();
shader(updateShader);
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
updateShader.enableAttrib(updateShader.attributes.aIndex, 1);
updateShader.setUniform("uData", fbo0.color);
updateShader.setUniform("uSize",[TEX_SIZE, TEX_SIZE]);
// マウスが押されてるかどうかのフラグ。タッチでも機能する。
updateShader.setUniform("uMouseIsPressed", mouseIsPressed);
// マウス位置を実際の位置に合うように調整する
const mouseVector = [2*mouseX/width-1, 1-2*mouseY/height];
const mouseCoeff = (width > height ? [width/height,1] : [1,height/width]);
updateShader.setUniform("uMouse", [mouseVector[0]*mouseCoeff[0], mouseVector[1]*mouseCoeff[1]]);
updateShader.setUniform("uVelocity", velocity);
updateShader.bindTextures();
gl.drawArrays(gl.POINTS, 0, TEX_SIZE*TEX_SIZE);
updateShader.unbindShader();
fbo1.end();
パーティクルの位置が画面に収まる正方形となるようにマウス位置の情報を若干いじっています。mouseIsPressedを普通に使っています。これはタッチでも機能します。velocityは次の規則で更新します。
// マウスを離すと減衰する
if(mouseIsPressed){
velocity = 1.0;
}else{
velocity *= 0.95;
}
これも本家と一緒ですね。
displayShaderの変更
displayについても変更します。
#version 300 es
in float aIndex;
uniform sampler2D uTex;
uniform vec2 uSize;
uniform float uPointScale;
uniform vec2 uAdjust;
void main(){
float x = mod(aIndex, uSize.x);
float y = floor(aIndex/uSize.y);
vec2 uv = (vec2(x,y)+0.5)/uSize;
vec2 p = texture(uTex, uv).xy * uAdjust;
gl_Position = vec4(p, 0.0, 1.0);
gl_PointSize = 0.1 + uPointScale;
}
#version 300 es
precision highp float;
out vec4 color;
uniform vec3 uBaseColor;
void main(){
// 透明度を下げてるのは気分です(雑)
color = vec4(uBaseColor, 0.5);
}
点の大きさは本家に倣って速度を使っています。ですがあんま機能してないですね...おそらく1未満だと1に切り詰められる?(そこら辺詳しくないので不明)。色についてはp5.Colorの機構を流用しています:
// display.
shader(displayShader);
gl.bindBuffer(gl.ARRAY_BUFFER, indexBuf);
displayShader.enableAttrib(displayShader.attributes.aIndex, 1);
displayShader.setUniform("uTex", fbo0.color);
displayShader.setUniform("uSize",[TEX_SIZE, TEX_SIZE]);
// 時間経過で色を変化させる
const hueValue = floor((frameCount%1800)/5);
const col = color(`hsb(${hueValue}, 70%, 80%)`);
displayShader.setUniform("uBaseColor", [
red(col)/255, green(col)/255, blue(col)/255
]);
// 位置を調整して正方形が画面に収まるようにする
const adjustment = (width > height ? [height/width,1] : [1,width/height]);
displayShader.setUniform("uAdjust", adjustment);
// 点のサイズはvelocityを使ってますね。
displayShader.setUniform("uPointScale", velocity);
// これに相当する処理でfillとstrokeはやってるんですよね。bindTextures()を。
_gl._setPointUniforms(displayShader);
// でもpointはやってないんですよね。それで失敗するわけですね。
// blend処理はupdateにも影響するので、描画時のみ有効化し使ったらオフにする
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
background(0);
displayShader.bindTextures();
gl.drawArrays(gl.POINTS, 0, TEX_SIZE*TEX_SIZE);
displayShader.unbindShader();
gl.disable(gl.BLEND);
hsbで取得してred,green,blueの各関数で0~255にしたうえで255により割っています。実はp5wgexのcoulour3という便利な関数があるんですが、極力p5の範疇でやりたいので使ってません。ブレンドはあっちと一緒です。おそらく微妙な違いがあるんですが、その辺は自由にやらせてもらってます。大体同じような見た目になってるのでこれでよしとします。
たとえば速いパーティクルほど色が白っぽくなるようにするとか、いろいろ工夫できると思います。ぜひお試しください。
PerformanceCheckerについて
上にも述べましたが、パフォーマンスを調べようとして
resetShader();
texture(info);
plane(40);
のようなコードを書いて実験したところ、テクスチャコンフリクトが起きてしまいました。結局最後まで解消できなかったので諦めました。点描画で好き勝手したのが災いしたようです。なので、自作ライブラリ:
p5wgex.js
のパフォーマンスチェッカーを使いました。使い方は簡単で、
pfc = new p5wgex.PerformanceChecker(this.canvas);
グローバルにおいた変数をキャンバスで初期化して
pfc.update();
ループの最後にこれをやるだけです。
おわりに
3年前、p5.jsでこのGPGPUのパーティクルをやろうと思って色々調べたのがついこの間のことのように思い返されます。あの当時書いた記事では(例えばこの辺)
GPU particle (old version)
見ればわかるように自作の機構でばりばりやっていて、見通しの悪いコードになっていました。こんなにネイティブに頼らないとできないなら「p5.jsでwebglやる意味ねぇ」とか言われたりもしました。現在はある程度枠組みが整ってきたので、こんなこともできるようになりました。感慨深いですが、点描画の仕組みがまだ整ってないので、もう少しですね。
(上記のコードはオリジナルのものをgithubのissueでの説明用に引っ張り出したもので、本家の方は:
GPGPU_TEST
こんな感じで魔改造しまくってあります。ブルーム、INVERT, ミラー複製とかいろいろやってます。日付を見ると2021年の11月20日ですね。懐かしい。)
ここまでお読みいただいてありがとうございました。
だそうなので、Threeだとこの手の処理がどんな風になるのか調べてみました。こちらの記事が参考になると思われます。
Three.jsのGPGPUのサンプルが難しすぎるから解体して勉強してみる
機会があったら勉強してみようと思います。こっちは3次元でやってるので、位置と速度で別々にフレームバッファを用意しているようです。
簡単なんだろうか。
それはそれとして、こういうのも作ってみたい...
テキストを分解しパーティクルで表現する演出
3次元化にしても、この手の応用的な内容にしても、この3年間触れてこれなかったのは怠慢ですね...(枠組み作りやらモチベーションの低下やらパソコンは壊れるわいろいろあった)、これからはこういうのにも触れて、せっかくパーティクル表現ができるので、いろいろやってみたいです。
追記
logoのinfoがどうしても付けたかったので、自作ライブラリのrenderTextureという関数で無理やり実現しました。ごめんなさい。p5のTextureの管理の仕方に問題があるんだろうけど調べてる余裕はないです。
// え?
_node = new p5wgex.RenderNode(this._renderer.GL);
_node.registTexture("logo",(function(){
const gr = createGraphics(width, height);
gr.textAlign(CENTER, CENTER);
gr.textStyle(ITALIC);
gr.textSize(19);
gr.noStroke();
gr.fill(255);
gr.text("GPGPU particle test", width/2, height/2);
return gr;
})());
これでtextureの機構に「logo」という名前でp5のオフスクリーン2Dキャンバスとしてのテクスチャが登録されます。
// ええええ....
_node.renderTexture("tex","logo",{depthTest:false, blend:"blend"});
これをrenderTextureという関数で呼び出して描画します。depthは切ります。ブレンドはデフォルトだと上書きなのでちゃんと「blend」を指定します。これでlogoが入ります。renderTextureはほぼステートレスな処理なので他の機構でRendererがいじられていても我関せずを貫きます(強い)。
やっぱりお手軽に文字くらいは入れたいところですね。p5はなぜ失敗するんだろうね...
もっともパーティクルで何にも作らなかったわけではないことだけ触れておきます(まあこれしか作れなかったので何も作れなかったようなもんだけど...):
sand
さらに追記:Threeの場合
調子に乗ってThree.jsとも共存できないか調べてみましたが、だめでした。Threeではプログラムの設定、バッファのバインディングなど、描画に必要な処理を一度しか実行しない仕組みになっているようです。そのせいで、他のプログラムを勝手に実行(use)するとかき乱されてうまくいかなくなるようです...
全部毎フレーム用意するp5のやり方を自分は真似して自作ライブラリを構築したので、そもそも共存は無理ということですね...もっと勉強しないとですね。
参考までにトランスフォームフィードバックで実行したコードを置いておきます。
TF_particles
いずれ、webgpuの枠組みでcomputeShaderを使ってやりたいですね。