Subterranean Flower

JavaScriptからGPGPU(WebGL2)を利用する&パーティクル描画

Author
古都こと
ふよんとえんよぷよぐやま!!!

WebGL2ではTransformFeedbackという機能が使えるようになりました。TransformFeedbackがあればGPUでの計算結果をバッファに書き出すことができるようになります。これを使えば、なかなか面白いことができるようになります。

この記事では、TransformFeedbackを利用したGPGPUと、パーティクル描画について解説します。

前提知識

この記事では、WebGL2について、基礎的な知識を持っていることを前提としています。まだWebGL2に慣れ親しんでいない人は、以下の記事を参考にしてください。

また、WebGL1/WebGL2について、@h_doxas様がwgld.orgという解説サイトを開いています。そちらも参考にしてください。

ソースコード

この記事で使われているプログラムの完全なコードはGithubに置いてあります。必要な方は以下のリンクからどうぞ:

WebGL2によるGPGPU

GPGPUってなんだろう

GPGPUという言葉を聞いたことがあるでしょうか。少し前に流行った言葉ですが、最近はあまり聞かないかもしれません。廃れたというわけではなく、単に極めて一般的な技術になったため、あまり明言する必要がなくなったのです。

GPGPUというのは、General-Purpose computing on GPU(GPUによる汎用計算)の意味で、GPUにいろいろな計算をやらせてみようという趣旨の言葉です。GPUというのはPCのグラフィックボードやスマートフォンなどに搭載されている画像処理プロセッサのことです。

汎用的な計算をするために複雑な条件分岐などを扱うCPUに対して、GPUは画像処理に特化しているため、作りがとても単純です。作りがシンプルなおかげで大量のコアを搭載でき、CPUがせいぜい6コアや8コアとか言ってる横で、GPUは2500コアぐらい搭載しています。画像処理はピクセルごとに独立した計算をすればいいため、2500コアで並列処理をすればとても効率的に扱うことができます。

ここでGPUの性能に目をつけた人たちがいました。「この性能を画像処理だけに使うのは勿体無いのではないか?」「高性能な計算機として使えるのではないか?」と。これがGPGPUの始まりです。

GPUはベクトル計算や行列計算が得意です。そういった処理をGPUに任せてしまえば、効率的に計算をすることができます。一方でCPUほど高機能ではないので、条件分岐などは苦手です。CPUに任せる処理と、GPUに任せる処理、その見極めが大事になってきます。

WebGLとGPGPU

GPGPUの実現方法にはいくつかあります。WebGL1のような単純なAPIを使うときは、計算結果をテクスチャに書き出して、テクスチャの値を読み込むことでGPGPUを実現していました。しかしこれはだいぶ遠回りです。

WebGL2ではTransformFeedbackという機能が追加されました。これはGPUによる計算結果をバッファに書き出せるという機能です。TransformFeedbackを使えば、シンプルにGPGPUを実現することができます。

今までは、バッファ(入力)→バーテックスシェーダ→フラグメントシェーダ→canvas、と一方通行の処理の流れがありました。ここでTransfromFeedbackを使うと、バーテックスシェーダの出力を横取りして使うことができます。つまり、バッファ(入力)→バーテックスシェーダ→バッファ(出力)、のような使い方ができるようになります。

これを使えば、バーテックスシェーダを使って、GPGPUによる並列計算ができるようになります。

WebGL2によるGPGPU

例えば2つの四次元ベクトルを足すだけのプログラムを考えてみましょう。TransformFeedbackを使えばバーテックスシェーダの計算結果を横取りできるので、バーテックスシェーダで計算します。ソースは以下のようになります:

#version 300 es

// 入力
in vec4 vecA;
in vec4 vecB;

// 出力(バッファに書き出す)
out vec4 result;

void main() {
  // 計算してバッファに書き出す
  result = vecA + vecB;
}

非常に単純ですね。あとはこいつをシェーダプログラムとしてコンパイルして利用してやればいいだけです。

ですがシェーダプログラムは対になるフラグメントシェーダが必要です。計算結果を横取りされて特に役目のないフラグメントシェーダですが、一応ダミーが必要になります。中身はなんでもいいので、適当に書きましょう:

#version 300 es

precision highp float;

out vec4 fragmentColor;

void main() {
  fragmentColor = vec4(1.0);
}

このふたつのシェーダを読み込んでシェーダプログラムとしてコンパイルします。

'use strict';

// canvasを作ってDOMに追加する
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// WebGL2のコンテキストを取得する
const gl = canvas.getContext('webgl2');

// シェーダのソースを読み込む関数
async function fetchShaderSource(vertexShaderPath, fragmentShaderPath) {
    const fetchVs = fetch(vertexShaderPath).then((response) => response.text());
    const fetchFs = fetch(fragmentShaderPath).then((response) => response.text());

    return Promise.all([fetchVs, fetchFs]);
}

// メイン関数
(async function main() {
    // シェーダのソースを取得する
    const VSHADER_PATH = './vertex_shader.glsl';
    const FSHADER_PATH = './fragment_shader.glsl';
    const [vertexShaderSource, fragmentShaderSource] = await fetchShaderSource(VSHADER_PATH, FSHADER_PATH);

    // 取得したソースを使ってシェーダをコンパイルする
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vertexShaderSource);
    gl.compileShader(vertexShader);

    const vShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
    if(!vShaderCompileStatus) {
        const info = gl.getShaderInfoLog(vertexShader);
        console.log(info);
    }

    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fragmentShaderSource);
    gl.compileShader(fragmentShader);

    const fShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
    if(!fShaderCompileStatus) {
        const info = gl.getShaderInfoLog(fragmentShader);
        console.log(info);
    }

    //
    // ここに書き足していく
    //
})();

あとはこの2つのシェーダをリンクするだけですが、ここでひとつ準備があります。プログラムをリンクする前に、どの変数を横取りするかを指定しなければいけません。横取りする変数はtransformFeedbackVaryingメソッドで指定できます。このメソッドには、シェーダプログラム、横取りする変数の名前の配列、書き出し方式、の3つが必要になります。以下のように書きます:

    // シェーダプログラムの作成
    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    //
    // プログラムのリンク前にtransformFeedbackVaryingsを使って
    // どの変数を書き戻すかを配列で指定しておく。
    // 第三引数はINTERLEAVED_ATTRIBSかSEPARATE_ATTRIBSのどちらか。
    // 今回はひとつだけなのでSEPARATE_ATTRIBSにする。
    //
    gl.transformFeedbackVaryings(program, ['result'], gl.SEPARATE_ATTRIBS);
    gl.linkProgram(program);

    // リンクできたかどうかを確認
    const linkStatus = gl.getProgramParameter(program, gl.LINK_STATUS);
    if(!linkStatus) {
        const info = gl.getProgramInfoLog(program);
        console.log(info);
    }

    // プログラムを使用
    gl.useProgram(program);

ここではout変数resultだけを横取りして、SEPARATE_ATTRIB方式で書き出すことを指定してます。SEPARATE_ATTRIBは、ひとつの変数につきひとつのバッファを使う方式で、一方INTERLEAVED_ATTRIBSは複数の変数をひとつのバッファにまとめて書き出す方式です。今回はひとつしか使わないのでSEPARATE_ATTRIBSを指定しています。

あとは普通にバッファにデータを転送します:

    //
    // バッファにデータを転送する
    //
    const vecABuffer = gl.createBuffer();
    const vecBBuffer = gl.createBuffer();
    const vecALocation = gl.getAttribLocation(program, 'vecA');
    const vecBLocation = gl.getAttribLocation(program, 'vecB');

    // vec4なので4要素
    const vecSize = 4;

    gl.bindBuffer(gl.ARRAY_BUFFER, vecABuffer);
    gl.enableVertexAttribArray(vecALocation);
    gl.vertexAttribPointer(vecALocation, vecSize, gl.FLOAT, false, 0, 0);

    const vecA = new Float32Array([1.0, 2.0, 3.0, 4.0]);
    gl.bufferData(gl.ARRAY_BUFFER, vecA, gl.STATIC_DRAW);

    gl.bindBuffer(gl.ARRAY_BUFFER, vecBBuffer);
    gl.enableVertexAttribArray(vecBLocation);
    gl.vertexAttribPointer(vecBLocation, vecSize, gl.FLOAT, false, 0, 0);

    const vecB = new Float32Array([5.0, 6.0, 7.0, 8.0]);
    gl.bufferData(gl.ARRAY_BUFFER, vecB, gl.STATIC_DRAW);

これはいいでしょう。

さて、ここからです。まずバーテックスシェーダでの計算結果を書き出すためのバッファを用意します。これは普通のバッファで大丈夫です。このバッファには、あらかじめ書き出すための領域を確保しておきます。

次にWebGLTransformFeedbackオブジェクトを作成します。これはcreateTransformFeedbackというメソッドで作ることができます。

    //
    // ここからGPGPUの処理を行う
    // 具体的にはTransformFeedbackを利用し
    // バッファに結果を書き出す
    //
    const tfBuffer = gl.createBuffer();
    const transformFeedback = gl.createTransformFeedback();

    // バッファをバインドして初期化する
    // 結果がvec4なのでsizeはFloat32Array.BYTES_PER_ELEMENT * 4になる
    // 用途は適当に(今回はDYNAMIC_COPYにしてある)
    const size = Float32Array.BYTES_PER_ELEMENT * 4;
    gl.bindBuffer(gl.ARRAY_BUFFER, tfBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, size, gl.DYNAMIC_COPY);
    gl.bindBuffer(gl.ARRAY_BUFFER, null); // バインド解除

これでバーテックスシェーダの結果をバッファに書き出す準備はだいたい整いました。あとはもう少し準備をして計算するだけです。

まず、計算の前にラスタライザを無効化します。これは計算だけをしたいので描画は無効化したいためです。したくないならしなくても大丈夫です。

そして先ほど作成したWebGLTransformFeedbackオブジェクトをTRANSFORM_FEEDBACKに、書き出し先のバッファをTRANSFORM_FEEDBACK_BUFFERにそれぞれバインドします。

    // 一時的にラスタライザを無効化しておく
    gl.enable(gl.RASTERIZER_DISCARD);

    // それぞれバインドする
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, tfBuffer);

バインドするときには、それぞれbindTransformFeedbackメソッドと、bindBufferBaseメソッドを使います。

あとは描画命令を出すだけです。描画命令の前にbeginTransformFeedbackというメソッドを呼び出し、終わったらendTransformFeedbackというメソッドを呼び出す必要があります。

    // 今回は1回だけ実行するので1つだけ点を描画する命令を発行する
    gl.beginTransformFeedback(gl.POINTS);
    gl.drawArrays(gl.POINTS, 0, 1);

    // フィードバック終わり
    gl.endTransformFeedback();

    // もし必要であればラスタライザを有効化しておく
    gl.disable(gl.RASTERIZER_DISCARD);

描画命令は、今回はベクトルの足し算を1回だけ行いたいので、1点分だけ呼び出しています。これでTRANSFORM_FEEDBACK_BUFFERにバインドしたバッファに結果が書き出されます。あとはgetBufferSubDataを使って読み出せば結果が得られます。

    // 結果を読み出す。vec4なので4要素のFloat32Array
    const result = new Float32Array(4);
    const offset = 0;
    gl.getBufferSubData(gl.TRANSFORM_FEEDBACK_BUFFER, offset, result);

    // 結果を表示する
    console.log(result);

これを実行してみます。

ちゃんとベクトルの足し算ができています!

この程度の計算ならCPUでやったほうが速いとは思いますが、もっと複雑なベクトル計算・行列計算が出てきた場合は、GPGPUを使ってみましょう。

行列計算

変数がvecではなくmatの場合、少し転送方法を考える必要があります。以下のようなバーテックスシェーダがあるとします:

#version 300 es

// 入力
in mat2 matA;
in mat2 matB;

// 出力(バッファに書き出す)
out mat2 result;

void main() {
  // 計算してバッファに書き出す
  result = matA + matB;
}

このとき、matAとmatBにはそのまま値をセットすればいけそうですが、残念ながらそれはできません。WebGL2では基本的にはバッファへの値の転送はベクトルで行われるからです。

なので例えばmat2であればvec2が2個、mat4ならvec4が4個と考えて分けて送る必要があります。具体的には以下のようにします:

    //
    // バッファにデータを転送する
    //
    const matABuffer = gl.createBuffer();
    const matBBuffer = gl.createBuffer();
    const matALocation = gl.getAttribLocation(program, 'matA');
    const matBLocation = gl.getAttribLocation(program, 'matB');

    // mat2なので2x2要素
    const matRowSize = 2;
    const mat2Size = 2 * 2;
    const matOffset = matRowSize * Float32Array.BYTES_PER_ELEMENT;

    // matA
    gl.bindBuffer(gl.ARRAY_BUFFER, matABuffer);
    gl.enableVertexAttribArray(matALocation + 0);
    gl.enableVertexAttribArray(matALocation + 1);
    gl.vertexAttribPointer(matALocation + 0, matRowSize, gl.FLOAT, false, mat2Size, matOffset * 0);
    gl.vertexAttribPointer(matALocation + 1, matRowSize, gl.FLOAT, false, mat2Size, matOffset * 1);

    const matA = new Float32Array([1.0, 2.0, 3.0, 4.0]);
    gl.bufferData(gl.ARRAY_BUFFER, matA, gl.STATIC_DRAW);

    // matB
    gl.bindBuffer(gl.ARRAY_BUFFER, matBBuffer);
    gl.enableVertexAttribArray(matBLocation + 0);
    gl.enableVertexAttribArray(matBLocation + 1);
    gl.vertexAttribPointer(matBLocation + 0, matRowSize, gl.FLOAT, false, mat2Size, matOffset * 0);
    gl.vertexAttribPointer(matBLocation + 1, matRowSize, gl.FLOAT, false, mat2Size, matOffset * 1);

    const matB = new Float32Array([5.0, 6.0, 7.0, 8.0]);
    gl.bufferData(gl.ARRAY_BUFFER, matB, gl.STATIC_DRAW);

ここまでのコード

#version 300 es

// 入力
in vec4 vecA;
in vec4 vecB;

// 出力(バッファに書き出す)
out vec4 result;

void main() {
  // 計算してバッファに書き出す
  result = vecA + vecB;
}
#version 300 es

precision highp float;

out vec4 fragmentColor;

void main() {
  fragmentColor = vec4(1.0);
}
'use strict';

// canvasを作ってDOMに追加する
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// WebGL2のコンテキストを取得する
const gl = canvas.getContext('webgl2');

// シェーダのソースを読み込む関数
async function fetchShaderSource(vertexShaderPath, fragmentShaderPath) {
    const fetchVs = fetch(vertexShaderPath).then((response) => response.text());
    const fetchFs = fetch(fragmentShaderPath).then((response) => response.text());

    return Promise.all([fetchVs, fetchFs]);
}

// メイン関数
(async function main() {
    // シェーダのソースを取得する
    const VSHADER_PATH = './vertex_shader.glsl';
    const FSHADER_PATH = './fragment_shader.glsl';
    const [vertexShaderSource, fragmentShaderSource] = await fetchShaderSource(VSHADER_PATH, FSHADER_PATH);

    // 取得したソースを使ってシェーダをコンパイルする
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vertexShaderSource);
    gl.compileShader(vertexShader);

    const vShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
    if(!vShaderCompileStatus) {
        const info = gl.getShaderInfoLog(vertexShader);
        console.log(info);
    }

    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fragmentShaderSource);
    gl.compileShader(fragmentShader);

    const fShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
    if(!fShaderCompileStatus) {
        const info = gl.getShaderInfoLog(fragmentShader);
        console.log(info);
    }

    // シェーダプログラムの作成
    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    //
    // プログラムのリンク前にtransformFeedbackVaryingsを使って
    // どの変数を書き戻すかを配列で指定しておく。
    // 第三引数はINTERLEAVED_ATTRIBSかSEPARATE_ATTRIBSのどちらか。
    // 今回はひとつだけなのでSEPARATE_ATTRIBSにする。
    //
    gl.transformFeedbackVaryings(program, ['result'], gl.SEPARATE_ATTRIBS);
    gl.linkProgram(program);

    // リンクできたかどうかを確認
    const linkStatus = gl.getProgramParameter(program, gl.LINK_STATUS);
    if(!linkStatus) {
        const info = gl.getProgramInfoLog(program);
        console.log(info);
    }

    // プログラムを使用
    gl.useProgram(program);

    //
    // バッファにデータを転送する
    //
    const vecABuffer = gl.createBuffer();
    const vecBBuffer = gl.createBuffer();
    const vecALocation = gl.getAttribLocation(program, 'vecA');
    const vecBLocation = gl.getAttribLocation(program, 'vecB');

    // vec4なので4要素
    const vecSize = 4;

    gl.bindBuffer(gl.ARRAY_BUFFER, vecABuffer);
    gl.enableVertexAttribArray(vecALocation);
    gl.vertexAttribPointer(vecALocation, vecSize, gl.FLOAT, false, 0, 0);

    const vecA = new Float32Array([1.0, 2.0, 3.0, 4.0]);
    gl.bufferData(gl.ARRAY_BUFFER, vecA, gl.STATIC_DRAW);

    gl.bindBuffer(gl.ARRAY_BUFFER, vecBBuffer);
    gl.enableVertexAttribArray(vecBLocation);
    gl.vertexAttribPointer(vecBLocation, vecSize, gl.FLOAT, false, 0, 0);

    const vecB = new Float32Array([5.0, 6.0, 7.0, 8.0]);
    gl.bufferData(gl.ARRAY_BUFFER, vecB, gl.STATIC_DRAW);

    //
    // ここからGPGPUの処理を行う
    // 具体的にはTransformFeedbackを利用し
    // バッファに結果を書き出す
    //
    const tfBuffer = gl.createBuffer();
    const transformFeedback = gl.createTransformFeedback();

    // バッファをバインドして初期化する
    // 結果がvec4なのでsizeはFloat32Array.BYTES_PER_ELEMENT * 4になる
    // 用途は適当に(今回はDYNAMIC_COPYにしてある)
    const size = Float32Array.BYTES_PER_ELEMENT * 4;
    gl.bindBuffer(gl.ARRAY_BUFFER, tfBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, size, gl.DYNAMIC_COPY);
    gl.bindBuffer(gl.ARRAY_BUFFER, null); // バインド解除

    // 一時的にラスタライザを無効化しておく
    gl.enable(gl.RASTERIZER_DISCARD);

    // それぞれバインドする
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, tfBuffer);

    // 今回は1回だけ実行するので1つだけ点を描画する命令を発行する
    gl.beginTransformFeedback(gl.POINTS);
    gl.drawArrays(gl.POINTS, 0, 1);

    // フィードバック終わり
    gl.endTransformFeedback();

    // もし必要であればラスタライザを有効化しておく
    gl.disable(gl.RASTERIZER_DISCARD);

    // 結果を読み出す。vec4なので4要素のFloat32Array
    const result = new Float32Array(4);
    const offset = 0;
    gl.getBufferSubData(gl.TRANSFORM_FEEDBACK_BUFFER, offset, result);

    // 結果を表示する
    console.log(result);
})();

パーティクル

TransformFeedbackを使えば、ほぼGPUだけでパーティクル(粒子)アニメーションを描画することができます。ちょっと触ってみましょう。

シェーダの準備

まずはシェーダを作ってみましょう。パーティクルに必要そうなのは、現在位置、経過時間、発生点、速度、年齢、寿命、あたりでしょう。これらを全部バーテックスシェーダで計算します。以下のようにします:

#version 300 es

//
// 全パーティクル共通の変数(uniform変数)
//

// パーティクル発生の中心点
uniform vec2 origin;

// 前回からの経過時間
uniform float elapsedTimeDelta;

//
// パーティクルごとの変数(in変数)
//

// パーティクルの現在位置
in vec2 particlePosition;

// パーティクルの速度
in vec2 particleVelocity;

// パーティクルの経過時間
in float particleAge;

// パーティクルの寿命
in float particleLife;

//
// 書き出す変数
// TransformFeedbackバッファから利用できる
//
out vec2 vertexPosition;
out vec2 vertexVelocity;
out float vertexAge;
out float vertexLife;

//
// メイン処理
//
void main() {
  if(particleAge > particleLife) {
    // 寿命を超えたら元の場所に戻す
    vertexPosition = origin;
    vertexVelocity = particleVelocity;
    vertexAge = 0.0;
    vertexLife = particleLife;
  } else {
    // 超えてなければ更新する
    vertexPosition = particlePosition + (particleVelocity * elapsedTimeDelta);
    vertexVelocity = particleVelocity;
    vertexAge = particleAge + elapsedTimeDelta;
    vertexLife = particleLife;
  }
}

このバーテックスシェーダを「particle_updater_vs.glsl」として保存しましょう。これの計算結果をバッファに書き出して、その書き出した値を使って描画をする、という流れになります。

そしてこのバーテックスシェーダの対になるダミーのフラグメントシェーダが必要になります。実際は使わないので内容はなんでもいいのですが、例えば以下のようにします:

#version 300 es

//
// GPGPU用のフラグメントシェーダ
// ダミーなので中身はなんでも良い
//

precision highp float;

void main() {
  discard;
}

これを「particle_updater_fs.glsl」として保存します。

そして今回はもうひとつシェーダプログラムを作成します。計算だけでなく、パーティクルを描画したいので、描画用のシェーダプログラムを用意します。

描画のために必要なのは位置情報だけです。なのでバーテックスシェーダは以下のようになります:

#version 300 es

//
// パーティクル出力用のバーテックスシェーダ
//

// 入力変数
in vec2 particlePosition;

//
// メイン処理
//
void main() {
  gl_PointSize = 2.0;
  gl_Position = vec4(particlePosition, 0.0, 1.0);
}

これを「particle_renderer_vs.glsl」として保存します。そして描画用のフラグメントシェーダも用意します。今度は本当に使うので真面目に書きます:

#version 300 es

//
// パーティクル出力用のフラグメントシェーダ
//

precision highp float;

out vec4 fragColor;

void main() {
  fragColor = vec4(1.0); // 白色
}

これを「particle_renderer_fs.glsl」として保存します。

これで計算用プログラムと、描画用プログラムができました。あとはこれをJavaScript側でシェーダプログラムとして構成するだけです。

JavaScriptの前準備

前準備として、いくつか関数を作っておきます。シェーダプログラムを作るcreateProgram関数と、VertexArrayObjecを作成するcreateVAO関数です。以下のように作ります:

'use strict';

// canvasを作ってDOMに追加する
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// WebGL2のコンテキストを取得する
const gl = canvas.getContext('webgl2');

// シェーダのソースを読み込む関数
async function fetchShaderSource(vertexShaderPath, fragmentShaderPath) {
    const fetchVs = fetch(vertexShaderPath).then((response) => response.text());
    const fetchFs = fetch(fragmentShaderPath).then((response) => response.text());

    return Promise.all([fetchVs, fetchFs]);
}

// プログラムをリンクして返す関数
function createProgram(vsSource, fsSource, feedbackVariables = []) {
    // シェーダをコンパイルする
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vsSource);
    gl.compileShader(vertexShader);

    const vShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
    if(!vShaderCompileStatus) {
        const info = gl.getShaderInfoLog(vertexShader);
        console.log(info);
    }

    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fsSource);
    gl.compileShader(fragmentShader);

    const fShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
    if(!fShaderCompileStatus) {
        const info = gl.getShaderInfoLog(fragmentShader);
        console.log(info);
    }

    // シェーダプログラムの作成
    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    // 書き出す変数
    if(feedbackVariables.length !== 0) {
        gl.transformFeedbackVaryings(program, feedbackVariables, gl.INTERLEAVED_ATTRIBS);
    }

    gl.linkProgram(program);

    // リンクできたかどうかを確認
    const linkStatus = gl.getProgramParameter(program, gl.LINK_STATUS);
    if(!linkStatus) {
        const info = gl.getProgramInfoLog(program);
        console.log(info);
    }

    return program;
}

// Vertex Array Objectを作成する関数
function createVAO(program, buffer, attributes, stride, data = null, usage = gl.STATIC_DRAW) {
    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

    let offset = 0;
    for(const attr of attributes) {
        const attrLocation = gl.getAttribLocation(program, attr.name);
        gl.enableVertexAttribArray(attrLocation);
        gl.vertexAttribPointer(attrLocation, attr.size, attr.type, false, stride, offset);
        offset += attr.byteSize;
    }

    if(data !== null) {
        gl.bufferData(gl.ARRAY_BUFFER, data, usage);
    }

    gl.bindVertexArray(null);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);

    return vao;
}

createProgram関数はtransformFeedbackVaryingsする変数の一覧を受け取っておきます。また、今回は複数の変数を扱うので、バッファへの書き出し方式はINTERLEAVED_ATTRIBSにしておきます。

VertexArrayObjectはバッファの情報をまとめるためのオブジェクトだと思ってください。VAOを作ることでバッファが扱いやすくなります。

パーティクルプログラムを作っていく

まずは必要なパラメータの定義や、シェーダプログラムのコンパイルなどの準備をしましょう。プログラムは計算用と描画用の2つ作ります:

// メイン関数
(async function main() {
    const PARTICLE_NUM = 1000; // パーティクルの数
    const MAX_SPEED = 0.001; // パーティクルの最大速度
    const MAX_LIFE = 1000.0; // パーティクルの最大寿命
    const originPoint = new Float32Array([0.0, 0.0]); // 原点

    // マウスポインタの場所をパーティクル発生の原点にする
    canvas.addEventListener('mousemove', (mEvent) => {
        originPoint[0] = -1.0 + (mEvent.clientX / canvas.width) * 2;
        originPoint[1] = 1.0 - (mEvent.clientY / canvas.height) * 2;
    });

    // シェーダのソースを取得する
    const UPDATER_VS_PATH = './particle_updater_vs.glsl';
    const UPDATER_FS_PATH = './particle_updater_fs.glsl';
    const [updaterVertexShaderSource, updaterFragmentShaderSource] = await fetchShaderSource(UPDATER_VS_PATH, UPDATER_FS_PATH);

    const RENDERER_VS_PATH = './particle_renderer_vs.glsl';
    const RENDERER_FS_PATH = './particle_renderer_fs.glsl';
    const [rendererVertexShaderSource, rendererFragmentShaderSource] = await fetchShaderSource(RENDERER_VS_PATH, RENDERER_FS_PATH);

    // 書き戻す変数
    const feedbackVariables = [
        'vertexPosition',
        'vertexVelocity',
        'vertexAge',
        'vertexLife'
    ];

    // Update用のプログラムとRender用のプログラムを作成する
    const updaterProgram = createProgram(updaterVertexShaderSource, updaterFragmentShaderSource, feedbackVariables);
    const rendererProgram = createProgram(rendererVertexShaderSource, rendererFragmentShaderSource);

    // バッファの初期化時に黒で初期化するようにする
    gl.clearColor(0.0, 0.0, 0.0, 1.0);

    //
    // ここに追加していく
    //
})();

次にパーティクルの初期値として使う値を用意します:

    // パーティクルの初期データを用意する
    const particleInitialData = [];
    for(let i = 0; i < PARTICLE_NUM; i++) {
        // 初期xy座標
        particleInitialData.push(originPoint[0]);
        particleInitialData.push(originPoint[1]);

        // 速度(-MAX_SPEEDから+MAX_SPEEDまでの間)
        const vx = -MAX_SPEED + (Math.random() * (MAX_SPEED * 2));
        const vy = -MAX_SPEED + (Math.random() * (MAX_SPEED * 2));
        particleInitialData.push(vx);
        particleInitialData.push(vy);

        // 年齢
        particleInitialData.push(0.0);

        // 寿命
        const life = Math.random() * MAX_LIFE;
        particleInitialData.push(life);
    }

    const particleInitialDataF32 = new Float32Array(particleInitialData);

今回は1000個のパーティクルを描画したいので、1000個分のデータを用意します。

次に必要なVAOを作ります。これは先ほど用意したcreateVAO関数で簡単に作れます。バッファは入力用バッファ(input)と書き出し用バッファ(output)の2つ用意します。VAOは入力用バッファに対して計算用と描画用、書き出し用バッファに対して計算用と描画用の、合計4つが必要になります。

    // 各VAOを作成する
    // [input, output] * [Update, Render] の計4つ

    // 入力用のバッファと出力用のバッファ
    const inputBuffer = gl.createBuffer();
    const outputBuffer = gl.createBuffer();
    
    const updaterAttributes = [
        {
            name: 'particlePosition',
            size: 2,
            type: gl.FLOAT,
            byteSize: 2 * Float32Array.BYTES_PER_ELEMENT,
        },
        {
            name: 'particleVelocity',
            size: 2,
            type: gl.FLOAT,
            byteSize: 2 * Float32Array.BYTES_PER_ELEMENT,
        },
        {
            name: 'particleAge',
            size: 1,
            type: gl.FLOAT,
            byteSize: 1 * Float32Array.BYTES_PER_ELEMENT,
        },
        {
            name: 'particleLife',
            size: 1,
            type: gl.FLOAT,
            byteSize: 1 * Float32Array.BYTES_PER_ELEMENT
        }
    ];

    const rendererAttributes = [
        {
            name: 'particlePosition',
            size: 2,
            type: gl.FLOAT,
            byteSize: 2 * Float32Array.BYTES_PER_ELEMENT
        }
    ];

    const STRIDE = updaterAttributes.reduce((prev, current) => prev + current.byteSize, 0);

    // input - Update
    const inputBufferUpdateVAO = createVAO(updaterProgram, inputBuffer, updaterAttributes,
                                           STRIDE, particleInitialDataF32, gl.DYNAMIC_COPY);

    // output - Update
    const outputBufferUpdateVAO = createVAO(updaterProgram, outputBuffer, updaterAttributes,
                                            STRIDE, particleInitialDataF32, gl.DYNAMIC_COPY);

    // input - Render
    const inputBufferRenderVAO = createVAO(rendererProgram, inputBuffer, rendererAttributes, STRIDE);

    // output - Render
    const outputBufferRenderVAO = createVAO(rendererProgram, outputBuffer, rendererAttributes, STRIDE);

あとはフィードバックを作成するのと、細かい準備です:

    // フィードバックを作成する
    const transformFeedback = gl.createTransformFeedback();
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);

    const buffers = [inputBuffer, outputBuffer];
    const updateVAOs = [inputBufferUpdateVAO, outputBufferUpdateVAO];
    const renderVAOs = [inputBufferRenderVAO, outputBufferRenderVAO];
    let inputIndex = 0;
    let outputIndex = 1;

ここでバッファやVAOを配列にまとめたのは、後でするちょっとしたテクニックのためです。

最後にアニメーションループを作成します。

    // ループを開始する
    let prevTimeMs = performance.now(); // 前回処理したときの時間(ミリ秒)
    function loop(timestampMs) {
        // 前回からの経過時間を計算する
        const elapsedTime = timestampMs - prevTimeMs;

        // カラーバッファをクリアする
        gl.clear(gl.COLOR_BUFFER_BIT);

        // 計算用のプログラムを使用する
        gl.useProgram(updaterProgram);

        // uniform変数をセットする
        gl.uniform2fv(gl.getUniformLocation(updaterProgram, 'origin'), originPoint);
        gl.uniform1f(gl.getUniformLocation(updaterProgram, 'elapsedTimeDelta'), elapsedTime);

        // ラスタライザを無効化
        gl.enable(gl.RASTERIZER_DISCARD);

        // 計算してフィードバックする
        gl.bindVertexArray(updateVAOs[inputIndex]);
        gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buffers[outputIndex]);
        gl.beginTransformFeedback(gl.POINTS);
        gl.drawArrays(gl.POINTS, 0, PARTICLE_NUM); // PARTICLE_NUM個の計算をする
        gl.endTransformFeedback();

        // バインド解除
        gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);

        // ラスタライザの再有効化
        gl.disable(gl.RASTERIZER_DISCARD);

        // 描画用のプログラムを使用する
        gl.useProgram(rendererProgram);
        gl.bindVertexArray(renderVAOs[outputIndex]);
        gl.drawArrays(gl.POINTS, 0, PARTICLE_NUM);

        // swap
        [inputIndex, outputIndex] = [outputIndex, inputIndex];

        // 繰り返す
        prevTimeMs = timestampMs;
        requestAnimationFrame((ts) => loop(ts));
    }

    // ループ開始
    requestAnimationFrame((ts) => loop(ts));

ラスタライザを無効化してフィードバック、フィードバックが終わったら再有効化、という流れはGPGPUのときと変わりません。

今回は、まず計算用のプログラムを有効化し、パーティクルの位置などを計算します。ループごとにマウスの位置(origin)と前回からの経過時間(eplasedTimeDelta)をセットしています。そして入力用のVAOをバインドし、バッファに計算結果をフィードバックします。これで計算が完了します。計算はPARTICLE_NUM個(1000個)同時に行なっており、これで各パーティクルの位置が更新されます。

次に描画用のプログラムを有効化します。先ほどの計算で出力結果が保存されたバッファをVAOでバインドし、普通に描画します。これで各パーティクルが実際に画面に描画されます。

さて、問題は次です。計算に使ったバッファと出力に使ったバッファを入れ替えています。これは「次のフレームでは今回の計算結果のバッファを入力として計算する」という処理をやりたいためです。出力結果のバッファを次のフレームで計算用に使うことにより、「今の値を使って次の値を計算する」ということが実現できます。

完成

ここまでのコード

#version 300 es

//
// 全パーティクル共通の変数(uniform変数)
//

// パーティクル発生の中心点
uniform vec2 origin;

// 前回からの経過時間
uniform float elapsedTimeDelta;

//
// パーティクルごとの変数(in変数)
//

// パーティクルの現在位置
in vec2 particlePosition;

// パーティクルの速度
in vec2 particleVelocity;

// パーティクルの経過時間
in float particleAge;

// パーティクルの寿命
in float particleLife;

//
// 書き出す変数
// TransformFeedbackバッファから利用できる
//
out vec2 vertexPosition;
out vec2 vertexVelocity;
out float vertexAge;
out float vertexLife;

//
// メイン処理
//
void main() {
  if(particleAge > particleLife) {
    // 寿命を超えたら元の場所に戻す
    vertexPosition = origin;
    vertexVelocity = particleVelocity;
    vertexAge = 0.0;
    vertexLife = particleLife;
  } else {
    // 超えてなければ更新する
    vertexPosition = particlePosition + (particleVelocity * elapsedTimeDelta);
    vertexVelocity = particleVelocity;
    vertexAge = particleAge + elapsedTimeDelta;
    vertexLife = particleLife;
  }
}
#version 300 es

//
// GPGPU用のフラグメントシェーダ
// ダミーなので中身はなんでも良い
//

precision highp float;

void main() {
  discard;
}
#version 300 es

//
// パーティクル出力用のバーテックスシェーダ
//

// 入力変数
in vec2 particlePosition;

//
// メイン処理
//
void main() {
  gl_PointSize = 2.0;
  gl_Position = vec4(particlePosition, 0.0, 1.0);
}
#version 300 es

//
// パーティクル出力用のフラグメントシェーダ
//

precision highp float;

out vec4 fragColor;

void main() {
  fragColor = vec4(1.0); // 白色
}
'use strict';

// canvasを作ってDOMに追加する
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);

// WebGL2のコンテキストを取得する
const gl = canvas.getContext('webgl2');

// シェーダのソースを読み込む関数
async function fetchShaderSource(vertexShaderPath, fragmentShaderPath) {
    const fetchVs = fetch(vertexShaderPath).then((response) => response.text());
    const fetchFs = fetch(fragmentShaderPath).then((response) => response.text());

    return Promise.all([fetchVs, fetchFs]);
}

// プログラムをリンクして返す関数
function createProgram(vsSource, fsSource, feedbackVariables = []) {
    // シェーダをコンパイルする
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vertexShader, vsSource);
    gl.compileShader(vertexShader);

    const vShaderCompileStatus = gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS);
    if(!vShaderCompileStatus) {
        const info = gl.getShaderInfoLog(vertexShader);
        console.log(info);
    }

    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fsSource);
    gl.compileShader(fragmentShader);

    const fShaderCompileStatus = gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS);
    if(!fShaderCompileStatus) {
        const info = gl.getShaderInfoLog(fragmentShader);
        console.log(info);
    }

    // シェーダプログラムの作成
    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    // 書き出す変数
    if(feedbackVariables.length !== 0) {
        gl.transformFeedbackVaryings(program, feedbackVariables, gl.INTERLEAVED_ATTRIBS);
    }

    gl.linkProgram(program);

    // リンクできたかどうかを確認
    const linkStatus = gl.getProgramParameter(program, gl.LINK_STATUS);
    if(!linkStatus) {
        const info = gl.getProgramInfoLog(program);
        console.log(info);
    }

    return program;
}

// Vertex Array Objectを作成する関数
function createVAO(program, buffer, attributes, stride, data = null, usage = gl.STATIC_DRAW) {
    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

    let offset = 0;
    for(const attr of attributes) {
        const attrLocation = gl.getAttribLocation(program, attr.name);
        gl.enableVertexAttribArray(attrLocation);
        gl.vertexAttribPointer(attrLocation, attr.size, attr.type, false, stride, offset);
        offset += attr.byteSize;
    }

    if(data !== null) {
        gl.bufferData(gl.ARRAY_BUFFER, data, usage);
    }

    gl.bindVertexArray(null);
    gl.bindBuffer(gl.ARRAY_BUFFER, null);

    return vao;
}

// メイン関数
(async function main() {
    const PARTICLE_NUM = 1000; // パーティクルの数
    const MAX_SPEED = 0.001; // パーティクルの最大速度
    const MAX_LIFE = 1000.0; // パーティクルの最大寿命
    const originPoint = new Float32Array([0.0, 0.0]); // 原点

    // マウスポインタの場所をパーティクル発生の原点にする
    canvas.addEventListener('mousemove', (mEvent) => {
        originPoint[0] = -1.0 + (mEvent.clientX / canvas.width) * 2;
        originPoint[1] = 1.0 - (mEvent.clientY / canvas.height) * 2;
    });

    // シェーダのソースを取得する
    const UPDATER_VS_PATH = './particle_updater_vs.glsl';
    const UPDATER_FS_PATH = './particle_updater_fs.glsl';
    const [updaterVertexShaderSource, updaterFragmentShaderSource] = await fetchShaderSource(UPDATER_VS_PATH, UPDATER_FS_PATH);

    const RENDERER_VS_PATH = './particle_renderer_vs.glsl';
    const RENDERER_FS_PATH = './particle_renderer_fs.glsl';
    const [rendererVertexShaderSource, rendererFragmentShaderSource] = await fetchShaderSource(RENDERER_VS_PATH, RENDERER_FS_PATH);

    // 書き戻す変数
    const feedbackVariables = [
        'vertexPosition',
        'vertexVelocity',
        'vertexAge',
        'vertexLife'
    ];

    // Update用のプログラムとRender用のプログラムを作成する
    const updaterProgram = createProgram(updaterVertexShaderSource, updaterFragmentShaderSource, feedbackVariables);
    const rendererProgram = createProgram(rendererVertexShaderSource, rendererFragmentShaderSource);

    // バッファの初期化時に黒で初期化するようにする
    gl.clearColor(0.0, 0.0, 0.0, 1.0);

    // パーティクルの初期データを用意する
    const particleInitialData = [];
    for(let i = 0; i < PARTICLE_NUM; i++) {
        // 初期xy座標
        particleInitialData.push(originPoint[0]);
        particleInitialData.push(originPoint[1]);

        // 速度(-MAX_SPEEDから+MAX_SPEEDまでの間)
        const vx = -MAX_SPEED + (Math.random() * (MAX_SPEED * 2));
        const vy = -MAX_SPEED + (Math.random() * (MAX_SPEED * 2));
        particleInitialData.push(vx);
        particleInitialData.push(vy);

        // 年齢
        particleInitialData.push(0.0);

        // 寿命
        const life = Math.random() * MAX_LIFE;
        particleInitialData.push(life);
    }

    const particleInitialDataF32 = new Float32Array(particleInitialData);

    // 各VAOを作成する
    // [input, output] * [Update, Render] の計4つ

    // 入力用のバッファと出力用のバッファ
    const inputBuffer = gl.createBuffer();
    const outputBuffer = gl.createBuffer();

    const updaterAttributes = [
        {
            name: 'particlePosition',
            size: 2,
            type: gl.FLOAT,
            byteSize: 2 * Float32Array.BYTES_PER_ELEMENT,
        },
        {
            name: 'particleVelocity',
            size: 2,
            type: gl.FLOAT,
            byteSize: 2 * Float32Array.BYTES_PER_ELEMENT,
        },
        {
            name: 'particleAge',
            size: 1,
            type: gl.FLOAT,
            byteSize: 1 * Float32Array.BYTES_PER_ELEMENT,
        },
        {
            name: 'particleLife',
            size: 1,
            type: gl.FLOAT,
            byteSize: 1 * Float32Array.BYTES_PER_ELEMENT
        }
    ];

    const rendererAttributes = [
        {
            name: 'particlePosition',
            size: 2,
            type: gl.FLOAT,
            byteSize: 2 * Float32Array.BYTES_PER_ELEMENT
        }
    ];

    const STRIDE = updaterAttributes.reduce((prev, current) => prev + current.byteSize, 0);

    // input - Update
    const inputBufferUpdateVAO = createVAO(updaterProgram, inputBuffer, updaterAttributes,
                                           STRIDE, particleInitialDataF32, gl.DYNAMIC_COPY);

    // output - Update
    const outputBufferUpdateVAO = createVAO(updaterProgram, outputBuffer, updaterAttributes,
                                            STRIDE, particleInitialDataF32, gl.DYNAMIC_COPY);

    // input - Render
    const inputBufferRenderVAO = createVAO(rendererProgram, inputBuffer, rendererAttributes, STRIDE);

    // output - Render
    const outputBufferRenderVAO = createVAO(rendererProgram, outputBuffer, rendererAttributes, STRIDE);

    // フィードバックを作成する
    const transformFeedback = gl.createTransformFeedback();
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);

    const buffers = [inputBuffer, outputBuffer];
    const updateVAOs = [inputBufferUpdateVAO, outputBufferUpdateVAO];
    const renderVAOs = [inputBufferRenderVAO, outputBufferRenderVAO];
    let inputIndex = 0;
    let outputIndex = 1;

    // ループを開始する
    let prevTimeMs = performance.now(); // 前回処理したときの時間(ミリ秒)
    function loop(timestampMs) {
        // 前回からの経過時間を計算する
        const elapsedTime = timestampMs - prevTimeMs;

        // カラーバッファをクリアする
        gl.clear(gl.COLOR_BUFFER_BIT);

        // 計算用のプログラムを使用する
        gl.useProgram(updaterProgram);

        // uniform変数をセットする
        gl.uniform2fv(gl.getUniformLocation(updaterProgram, 'origin'), originPoint);
        gl.uniform1f(gl.getUniformLocation(updaterProgram, 'elapsedTimeDelta'), elapsedTime);

        // ラスタライザを無効化
        gl.enable(gl.RASTERIZER_DISCARD);

        // 計算してフィードバックする
        gl.bindVertexArray(updateVAOs[inputIndex]);
        gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buffers[outputIndex]);
        gl.beginTransformFeedback(gl.POINTS);
        gl.drawArrays(gl.POINTS, 0, PARTICLE_NUM); // PARTICLE_NUM個の計算をする
        gl.endTransformFeedback();

        // バインド解除
        gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);

        // ラスタライザの再有効化
        gl.disable(gl.RASTERIZER_DISCARD);

        // 描画用のプログラムを使用する
        gl.useProgram(rendererProgram);
        gl.bindVertexArray(renderVAOs[outputIndex]);
        gl.drawArrays(gl.POINTS, 0, PARTICLE_NUM);

        // swap
        [inputIndex, outputIndex] = [outputIndex, inputIndex];

        // 繰り返す
        prevTimeMs = timestampMs;
        requestAnimationFrame((ts) => loop(ts));
    }

    // ループ開始
    requestAnimationFrame((ts) => loop(ts));
})();