この記事は、MIERUNE Advent Calendar 2021の12日目の記事です。
はじめに
luma.glでWebGLに入門するで、luma.glでWebGLを使う方法を紹介しました。さてluma.glがThree.jsに勝る点として、WebGL2への対応が挙げられます。全く対応していないということではなさそうですが、たとえばWebGL2で追加された機能であるTransform Feedback
はThree.jsではまだ使えず、luma.glでは使えます(他の機能の対応状況はわかりません)。
ということでluma.glでTranform Feedbackをやってみました。
今回の成果物はこちら(スマホ非対応)。
Transform Feedbackとは?
WebGLは激しく初心者なので、詳細な説明は上記ページにお願いするとします。
短く言うと、あるフレームのGPUの演算結果をそのまま次のフレームに持ち越せる機能です。
さて今回Transform Feedbackでやりたいことは、テクスチャ画像をもとにした頂点(パーティクル)の操作です。
テクスチャに対するパーティクルの位置に応じて移動…、次のフレームでは、テクスチャに対する"移動後の"パーティクルの位置に応じて移動…、というのを繰り返すと面白いアニメーションが作れるわけです。ということでやってみます。
ちなみに、WebGL1の世界でもこの操作自体は実現可能で(私は実装したことがないですが)、CPUで頂点を操作する方法と、GPUだけで処理する方法があるようです。当然ながら、CPUで操作する場合は扱える頂点数が少ないです。
テクスチャの準備
先行事例から、画像を借りてきます(License: MPL-2.0)。
これは世界全体の風向きを表現するテクスチャです。RGBA値のうち、意味のある値が入っているのはRとGだけです。Rの0-255でx方向の風速を、Gでy方向の風速をそれぞれ表現します。0は-1, 255は1です(つまり風速の絶対値ではなく指数)。BにはGと同じ値が入っています。つまり画像で赤っぽいところは、右方向への風が、青っぽいところには上方向への風が吹いているということになります。
この画像が表現する風向きどおりにパーティクルを動かすことを目指します。
実装
この記事の知識がある前提で実装を説明していきます。
準備
前回と同様、AnimationLoopクラス
を用いて実装してみます。
npm install @luma.gl/webgl @luma.gl/engine
import { AnimationLoop } from '@luma.gl/engine';
const loop = new AnimationLoop({
// @ts-ignore
onInitialize({gl}) {
// 頂点やシェーダーの初期化
// TransformFeedbackの定義
return { hoge, fuga };
},
// @ts-ignore
onRender({gl, hoge, fuga...}) {
// ...フレームごとの処理
},
});
loop.start(); // 描画開始
頂点の初期配置
- 頂点数は100000個とします
- 各頂点はvec3型とし、各成分の意味合いは(x座標, y座標,
Age
)とします- Ageとは、その頂点が生成されてからのフレーム数を意味し、一定値を超えたらdropするためのフラグです
- 初期値として与えるVec3の各要素の値は、-1 <= value <= 1の範囲でランダムとします
- こうすることで、キャンバス全体にランダムに頂点が配置されます
以上の条件の頂点をBuffer
クラスとして生成します。
onInitialize({ gl }) {
const numOfParticle = 100000;
const sourcePositionBuffer = new Buffer(
gl,
new Float32Array(numOfParticle * 3).map(
() => (Math.random() - 0.5) * 2,
),
);
// ...
テクスチャの読み込み
下記のように書くだけでTexture
クラスを初期化できます。
onInitialize({ gl }) {
// ...頂点の初期化
const texture = new Texture2D(gl, {
data: 'wind_data.png', // 画像への相対パス
});
シェーダー(Model, Transform)
- Canvasへ描画する内容は、前回と同様に
Model
で定義します - Modelが描画すべき頂点情報は、前回ではJavaScriptで生成した頂点情報の配列を渡していましたが、今回はTransformFeedbackにより計算された結果を送り込みます
Model
- drawMode=gl.POINTS
- 受け取った頂点情報(vec2)を加工することなくそのまま描画する
- テクスチャの値に応じて色分け
- 詳細な説明は割愛しますが、頂点の移動が早ければ赤い、逆は白くなります
onInitialize({ gl }) {
// ...頂点の初期化、テクスチャの読み込み
const model = new Model(gl, {
vs: `
in vec3 position;
out vec4 texValue;
uniform sampler2D texture;
void main() {
vec2 uv = vec2(position.x - 1.0, 1.0 - position.y) * 0.5;
texValue = texture2D(texture, uv);
gl_Position = vec4(position.xy, 0.0, 1.0);
gl_PointSize = 2.0;
}
`,
fs: `
in vec4 texValue;
void main() {
float velocity = length(vec2(texValue.r, texValue.g) - vec2(0.49803922)); // テクスチャのRG値からその地点の相対速度を求める
gl_FragColor = vec4(1.0, vec2(1.0 - velocity * 10.0), 1.0); // 相対速度から色分け、早いほど赤く、逆は白い
}
`,
uniforms: {
texture, // 初期化済みのテクスチャを読み込む
},
drawMode: gl.POINTS,
vertexCount: numOfParticle,
});
Transform Feedback
さてここからが本番…、ですが一旦飛ばして次章へ。
onRender
onRender({ gl, model, transform, texture }) {
const time = performance.now();
// uniformを与えつつtransformを実行
transform.run({
uniforms: {
texture,
time,
},
});
clear(gl, { color: [0, 0, 0, 1] });
// transformの結果をmodelに反映・描画
model
.setUniforms({ time })
.setAttributes({
position: transform.getBuffer('targetPosition'),
})
.draw();
// sourcePositionBufferとtargetPositionBufferをswap
transform.swap();
},
ほぼコメントのとおりです。swap()はTransform Feedback
のイディオムのようなもので、1回目の処理結果を、2回目の処理の入力にする…という意味があります。
Transform Feedbackのシェーダーを書く
Transform Feedback
で書くべきシェーダーはVertex Shaderです。Vertex Shaderで頂点を変換(Transform)するわけですね。前回の記事でも、頂点のTransformは発生しています(回転)。ですがアレは、頂点情報はあくまでJavaScriptで定義した値のままで、uniformのtimeをもとに計算された回転角度に応じて回していただけです。Transform Feedbackではちゃんと頂点自体を操作します。
テクスチャのRGBA値による頂点の移動
今回の画像のRGBA値の意味は前述のとおりです(Rがx方向、Gがy方向を示し、BとAは意味なし)。まずTransform
を定義する全コードを紹介します。以下の点に留意しながら読んでください。
- Transform Feedbackは、
source
となるBufferとtarget
となるBufferのペアで実行します -
source
をTransformした結果がtarget
に書き込まれます
onInitialize({ gl }) {
// ...頂点の初期化、テクスチャ、モデルの定義
const transform = new Transform(gl, {
vs: `\
in vec3 sourcePosition;
out vec3 targetPosition;
uniform sampler2D texture;
uniform float time;
mat2 rotation(float rad) {
return mat2(
cos(rad), sin(rad),
-sin(rad), cos(rad)
);
}
vec2 rand(vec2 co){
vec2 rco = rotation(time) * co;
return vec2(
fract(sin(dot(rco.xy ,vec2(12.9898,78.233))) * 43758.5453),
fract(cos(dot(rco.yx ,vec2(8.64947,45.097))) * 43758.5453)
)*2.0-1.0;
}
float randf(vec2 co){
return fract(sin(dot(co.xy * rotation(time) ,vec2(12.9898,78.233))) * 43758.5453);
}
void main() {
// 頂点に対応するテクスチャ値の取得
vec2 uv = vec2(sourcePosition.x - 1.0, 1.0 - sourcePosition.y) * 0.5;
vec4 texValue = texture2D(texture, uv);
float age = floor(sourcePosition.z);
// 頂点の移動
float speedFactor = 0.05;
vec2 distVec = (vec2(texValue.r, texValue.g) * 2.0 - vec2(1.0)) * speedFactor;
if (
(abs(sourcePosition.x) > 1.0 || abs(sourcePosition.y) > 1.0) ||
(age > 10.0) ||
(length(distVec) < 0.000001)
) {
// 頂点の再配置
targetPosition = vec3(vec2(rand(sourcePosition.xy)), 0.0);
} else {
float noiseFactor = speedFactor * 0.0;
vec2 noise = rand(sourcePosition.xy) * sin(time) * noiseFactor;
targetPosition = vec3(sourcePosition.xy + distVec + noise, age + 1.05 * randf(sourcePosition.xy));
}
}
`,
sourceBuffers: {
sourcePosition: sourcePositionBuffer,
},
feedbackBuffers: {
targetPosition: targetPositionBuffer,
},
feedbackMap: {
// source-targetの関係をGLSL内の変数名によるkey:valueで定義。
sourcePosition: 'targetPosition',
},
elementCount: numOfParticle,
});
// returnしてonRender()へ渡す
return { model, transform, texture };
},
テクスチャ値による頂点の移動
float speedFactor = 0.05;
vec2 distVec = (vec2(texValue.r, texValue.g) * 2.0 - vec2(1.0)) * speedFactor;
vec2(texValue.r, texValue.g) * 2.0 - vec2(1.0)
で、0~1の値を-1~1に正規化します。
この値のまま頂点を移動させると、移動距離が大きくなりすぎるので、speedFactor
で調整します。
頂点の再配置
上記でアニメーション自体はできましたが、これだけで動かすと、頂点がどんどん画面外に出て行ってしまい、描画されるパーティクルが少なくなっていきます。なので画面外に出てしまった頂点を画面内に再配置
します。条件式は以下です。
abs(sourcePosition.x) > 1.0 || abs(sourcePosition.y) > 1.0
また、全く動きがない頂点を再配置
しないと、一箇所に多数の頂点がとどまってしまい、動いて見える頂点数が少なくなってしまいます。条件式は以下です。
length(distVec) < 0.000001
最後に、生成から一定時間が経過した頂点を再配置
しないと、一定のルートをとおる頂点しかなくなっていってしまい、これもまた動いて見える頂点数が少なくなってしまいます。頂点の生成に対する経過フレーム数はsourcePosition
のz成分に保存することにしていましたね。Transform
により、この値を更新しつつ、一定値を超えたら頂点を再配置します。
float age = floor(sourcePosition.z);
// 条件式
age > 10.0
// ageのインクリメント
// randfは事前に定義した乱数発生関数(0~1)
// 乱数でインクリメントしないと全頂点がほとんど同じタイミングで再配置されてしまう
age + 1.05 * randf(sourcePosition.xy)
ノイズを足してみる
テクスチャの値だけで移動させるのではなく、ランダムなノイズを与えてあげると動きがリアルな感じになることがあります。
float noiseFactor = speedFactor * 0.01;
// 時間、頂点位置をシードとする乱数
vec2 noise = rand(sourcePosition.xy) * sin(time) * noiseFactor;
完成
これで冒頭のサムネイルのようなパーティクルのアニメーション表示が完成しました。
今回の全コードはこちら。
終わりに
10万の頂点を60fpsで難なく操作できるGPUのパワーはやはりまだまだ可能性を感じますね。
私はGIS屋さんなので、地図方面での活用を試していきたいです。