ぷよぐらみんぐ勉強

勉強したやつとか疑問などを載せていきます。割りと雑に。

GLSLのコンピュートシェーダーでGPUパーティクル

OpenGLの勉強をしている時、
wgld.org
このページを見つけました。
ざっくりと言うと
初期化、更新時にはFrameBufferにパラメータを書き込み
描画時に頂点に番号を持たせてGPUパーティクルを行っている。とのこと。

見てて思ったのは面倒だなと。他に方法がないか探してみたらコンピュートシェーダーを使えば計算部分は任せられるのでこれを使うことに。

というわけでマウスポインタに向かって移動するパーティクルをコンピュート仕様で書いてみます。

1. 使用するバッファとパラメーターとコンピュートシェーダーの変数

まず使用するパーティクルの画像はこちら
f:id:himigami:20170430044934p:plain

次に使用する構造体

Point構造体
vec2 position
vec2 velocity

位置、速度のパラメーターとして設定

そしてこのパラメーターを保持するバッファが
Shader Storage Buffer Object(通称ssbo)
これはシェーダー内でも読み書きができるというUBOよりも良さそうなバッファ。ただし使用するにはOpenGL4.3以上が必要
定義は

layout(std430) buffer 名前
{
要素
} 変数名;

layoutのstd430はメモリのレイアウトを決める際に使うものらしい。
wikiを見ると配置の最適化やらがあるそう。
今回はPoint構造体の配列をメンバ変数として使う。

ssboの配列設定には決まりがあり、Point ins[];のように要素数を指定してなければ割り当てられたバッファの数分要素数が伸びる模様。
ただし要素数無記入の場合には必ずその変数は最後に書くこと。かつ無記入は一つしか作れない。

Point ins[];
float fParam[5];

Point ins[];
float fParam[];

これらのような指定はできないということ。

コンピュートシェーダーの組み込み変数

コンピュートシェーダーではスレッド番号的なものをコード内で取得ができる。
以下の変数で取得ができる。

  1. gl_NumWorkGroups glComputeDispatchにて設定した値がここに入る。
  2. gl_WorkGroupSize シェーダー内にあるlayoutのlocalsizeがここに入る。
  3. gl_WorkGroupID 0からgl_NumWorkGroups-1の範囲で入るスレッド毎の値。
  4. gl_LocalInvocationID 0からgl_WorkGroupSize-1の範囲でスレッド毎の値。
  5. gl_GlobalInvocationID gl_WorkGroupID * gl_WorkGroupSize + gl_LocalInvocationIDの式の値が入る。

2. 初期化のコンピュートシェーダー

#version 430

struct Point{
    vec2 position;
    vec2 velocity;
};
layout(std430) buffer Points
{
    Point ins[];
} point;
uniform ivec2 texSize;
uniform vec2 screenSize;
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;

void main()
{
    uint index = gl_WorkGroupSize.x * gl_NumWorkGroups.x * gl_GlobalInvocationID.y + gl_GlobalInvocationID.x;
    Point pos;
    pos.position.xy = vec2(index % texSize.x, index / texSize.x) + screenSize.xy / 2 - texSize / 2;
    pos.velocity = vec2(0);
    point.ins[index] = pos;
}

まず統一することとしてバッファの形式はPoint構造体を作って
それの配列を定義しておきます。
そして、indexの式について。
使用するxの長さ * yの現在値 + xの現在値となるように変数を使用。
こうしないとlocal sizeをxだけでなくyも使用するときにスレッド別に参照できない。

そしてpositionには使用するバッファの大きさをtexSizeとして定義して
描画時に画面中央に出せるように値をセット。

3. 更新時のコンピュートシェーダー

#version 430

struct Point{
    vec2 position;
    vec2 velocity;
};
layout(std430) buffer Points
{
    Point ins[];
} point;
uniform vec2 mousePosition;
uniform vec2 screenSize;
uniform float timeScale;
uniform float speed;
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;

void main()
{
    uint index = gl_WorkGroupSize.x * gl_NumWorkGroups.x * gl_GlobalInvocationID.y + gl_GlobalInvocationID.x;
    Point pos = point.ins[index];
    pos.velocity.xy += normalize(mousePosition - pos.position.xy) * timeScale * speed;
    pos.position.xy += pos.velocity.xy;
    
    pos.position.x = clamp( pos.position.x, 0, screenSize.x);
    pos.position.y = clamp( pos.position.y, 0, screenSize.y);
    point.ins[index] = pos;
}

indexは統一してこの値を呼出。
そしてuniform変数で持ってきたマウスのポジションのベクトルをとり
スピードと1フレームの処理時間をかけて可変フレームレートでも同じ動きをするようにした。

ただしポジションは画面外に行かないようclampで制御。
最後は必ずバッファに代入。

4. 描画時のシェーダー

#version 430
struct Point{
    vec2 position;
    vec2 velocity;
};
layout(std430) buffer Points
{
    Point ins[];
} point;
in vec2 position;
in vec2 texUV;

uniform sampler2D mainTexture;
uniform float pointScale;
uniform vec2 mousePosition;
uniform vec2 screenSize;
out vec4 color;
out vec2 useTexUV;

void main()
{
    ivec2 texSize = textureSize(mainTexture, 0);
    vec2 rate = vec2(2) / screenSize;
    Point pos = point.ins[gl_InstanceID];
    vec4 info = vec4(0);
    info.xy = pos.position.xy * rate - vec2(1);
    info.y *= -1;
    gl_Position = vec4(info.xy + (position.xy * (texSize / screenSize)), 0, 1);
    useTexUV = texUV;
    color = vec4(vec3(1,1,1), 0.1f);
}

//以下はフラグメントシェーダー

#version 430
in vec4 color;
in vec2 useTexUV;
uniform sampler2D mainTexture;
out vec4 destColor;

void main()
{
    destColor = texture(mainTexture, useTexUV) * color;
}

まず頂点シェーダー
頂点位置の設定の流れとしては
バッファの座標をスクリーン座標に変換したもの+頂点バッファで設定した座標
ただしバッファの座標を使う際にはy座標が下に行くほど増えるようにしているので-1をかけておく。
※今回colorには直で色を指定している。

フラグメントシェーダー
使用するテクスチャから色を取得して設定した色をかけてあげるだけ。

5. 実行

バッファとuniform変数を適宜割り当てると
このようになる。

初期状態
f:id:himigami:20170505032206p:plain
ちょっと動かし
f:id:himigami:20170505033953p:plain

動画で
www.youtube.com


これは64x64のパーティクル数で表示したもの。
アルファブレンド処理を入れているため数を増やすとかなり重くなる。
こちらの環境 ryzen1700 gtx970を使用したところ、1024x1024で30fps前後で出力ができていた。
今回やったパーティクル、不具合がありbool型を構造体に入れて動かしていたらそのバイト数に調節しているのにもかからわず、バッファが足りてないのか動いていないパーティクルが出現・・・
ほかはssboのバインドの設定がuboあたりの同じなのでどういう割当で動かすかの研究が必要。

終わり

今回シェーダーをフルに活用して演出面の研究ができたと思います。
まだまだOpenGLについて知らないことがあるし、ssboなんてuboよりも使用できる領域が大きいし、使い勝手も良さそうなのに全然知らなかった。
DirectXにも同じような機能があるらしいので再現は可能。
あと思うところは今回やったところはほとんど日本語の記事がなかったので、英語のリファレンスやらチュートリアルやらをひたすら見つめる作業でした。
なのでもう少し記事が出てくれればなぁと。

assertの式を理解していく。

glfwとglewを使ってOpenGLのライブラリを作っているのですが

  1. assertと同じ形式
  2. ReleaseではMessageBoxを表示してログ出力後終了
  3. Debugでは普通のassert実行

という条件を持つassertマクロが欲しくなってきました。
元のassertではReleaseの際には無視されるのでログとして出力したいのが一番・・・
ですがassert文の認識が「この形式でやればいい」という浅い認識で使っていたので、このままじゃ作れもしないのでこの際理解しておこうと思い調べました。

  • まずinclude内にて定義されているdebug時のassert
#define assert(expression) (void)((!!(expression))||(_wassert(_CRT_WIDE(#expression), _CRT_WIDE(__FILE__), (unsigned)(__LINE__)), 0)

C++初心者の身としては割りと複雑に見える印象。
とりあえず少しずつ分解して理解していきます。マクロ構文もあまり覚えていませんしね・・・

C++、Cの仕様を把握していない状態なので、間違いがあったりするかもしれないので、
ありましたら指摘お願いします。

まずはじめに

(!!(expression))の!!について

どうやら

  • 論理否定で0か1で返すことが保証される。

というのを利用している。
論理否定は主に真偽結果に対して使っているけどintなどに使っても問題はないと。
ただ、C#はこんな変換は許してくれませんね・・・まあ無理矢理感があるので使いませんが。

処理の流れとしては
1回目で0か1に変換、2回目で逆転した真偽判定を戻すという感じ。

(!!(expression))の引数

assertに渡す基本形が
変数 && "test"とかですが、なぜbool判定ができるんだという疑問がありました。
で、文字列の方ポインタだということに気が付きました。
ポインタなら普通に0以外返ってきますし・・・・
まだポインタ慣れしていない証拠ですね。

最初の(void)

これはRelease時のassertの実装と同じで無視する対象になる。

_wassertにある #マクロ引数

完全に入門の内容・・・

#をつけると引数の変数名や文字列がそのまま文字列になる。
例として渡した引数が

false && "s"

だと出力時にまんまこれが表示される。
がそのまま表示される。assertの出力結果を見るとまんま表示されてますね。

(メソッド(), 0) 構文

演算子の項目を見るとカンマ演算子なるものがありました。
カンマ演算子
>|cpp|

int test = (12,23,34,4);
std::cout << test << std::endl;

|

これで表示されるのは4です。
()内の一番右にある数値が代入される模様。
そして数値以外にも式やメソッドが指定可能です。

  • 式の場合
int test = 0;
int test2 = 10;
test += (test += test2, test += 20);
std::cout << test << std::endl;

f:id:himigami:20170119145313p:plain
この場合testの値は60となりました。
処理順はtest2を加算、20を加算、加算された合計を更に加算

  • メソッド
void show()
{
    std::cout << "show method" << std::endl;
}

int main()
{
    int test = 0;[f:id:himigami:20170119145226p:plain]
    test = (show(), test += 20, 0);
    std::cout << test << std::endl;
    return 0;
}

結果はこちら
f:id:himigami:20170119145226p:plain
メソッドが実行され、加算がされたあと0を代入という結果。
動作を見る限り式とメソッドは必ず行うみたいですね。
もちろんメソッドの戻り値も利用可能です。
他には()がなければ演算子として認識はされませんでした。
どうやらカンマ演算子演算子の中で一番優先度の低いものなので認識されなかったと。

このことから
assertでは(メソッド, 0)を実行して戻ってくるのは0。
ここのメソッドで色々指定しとけばOKということですね。

処理のまとめ

  1. (!!(引数))でbool判定。もしfalseであれば次へ
  2. (メソッド, 0)でメソッド実行後0を返す。
  3. (void)にしているため受け取る必要はない。

こんな感じですね。

その結果できたコードがこちら。命名は適当にしてるので変更予定
についてはassertのやつをまんまコピー
f:id:himigami:20170119150404p:plain
f:id:himigami:20170119152458p:plain

今回調べたものはC++、Cの入門のものしかなかったので細かいところ見てないんだなって思いました。
C++er、Cerの人たちはこれを軽々扱うのが怖い...


あと・・・保存用として・・・

別枠 マクロ __FILE__ と __LINE__

__FILE__はファイル名(フルパス)
__LINE__はファイルの行番号
を表します。