«GLFW 3 で Oculus Rift を使う (1) 最新 粒子のレンダリング (2) ポイントの移動»

床井研究室

※このブログは遅くとも 2027 年 3 月に管理者の定年退職により閉鎖します (移転先は管理者本人共々模索中)

■ 2018年10月14日 [OpenGL][GLSL] 粒子のレンダリング (1) ポイントの描画

2024年02月19日 08:53更新

ごめんやで

前の「GLFW 3 で Oculus Rift を使う (1) という記事から一年半近く間が空いてしまったというか("その (2)" 以降を書く気力などとうに失せてるし),去年は1本しか記事を書いてないという事態に驚愕するばかりではありますが,毎度愚痴ってる通り忙しいのは本当で,それでも新しい OpenGL のテキストを書いたり,それを紙の本にしてもらったり,ほかにも共著で本を書いていたり,もちろん研究も目ぼしい成果が得られないものの,それなりにごにょごにょやっていたりして,文章自体は結構書いていたりします.あと,今年からは学内的な仕事についても色々配慮していただいているみたいなので,ここでちゃんとまともな成果を出してそれに応えんといかんなと思うわけです.

まー,とは言いましても焦るとろくなことはないわけで,足元を踏み固めずに急に走り出してテキメンにすっ転んでしまったりして,落ち込んだりもするわけです.それと,コード書くってのはそれなりに時間がかかるわけで,特に新しい理論とか新しいデバイスとか SDK とか,あるいは自分の専門外の知識とかが必要だったりすると,当然勉強しながらコード書くわけですから更に時間がかかります.そういうコードを書く仕事をいくつも抱えていたりすると,どう考えても時間が足らないと思えてきます.だからと言って,そこで焦ったところでまともなコードが出てくるわけもなく,結局いろんな(特に放置気味の研究室の学生さん)方面に「ごめんやで」と頭を下げて回ることになるわけですな.

学生さんの中にはそんな状況下でも自分で地道にコツコツやってる人がいるんですけど,そういう人に限って就職が決まらなかったりします.ちゃんと自分で理論考えてコード書いて,私の理解の及ばない独自の境地を切り開いているように見えて素直に「すげえ」と思うんですけど,周りがすんなり決めてくる中で一人苦戦しているのは,やっぱりアピールが地道すぎるんでしょうかね.あと,アピールしていることと応募先がミスマッチしてるという気がしないでもありません.どっちにせよ,難しいもんだと思います.

空の雛形プログラム

前出の彼ではないのですが,他の複数の学生さんが粒子に関連したことをやるらしいので,粒子を使ったレンダリングについて簡単な説明を書こうと思います.粒子の描画については,以前にも「Point Sprite を使ってみる」や「シェーダで Point Sprite」などで取り上げていますが,ここではもう少しかみ砕いて,順を追って解説しようと思います.ベースにするのは次のプログラムです.これは授業第1回目の宿題プログラムとほとんど同じです.

Particle 構造体の追加

粒子 (partcile) を扱うクラスを定義します.ソースファイル名は Particle.h とします.まず,ベクトルを格納する 4 要素の array の別名を Vector という名前で作っておきます.

#pragma once
#include "gg.h"
using namespace gg;
 
// 標準ライブラリ
#include <array>
 
//
// ベクトルデータ
//
using Vector = std::array<GLfloat, 4>;

std::array は固定長の配列を作成するテンプレートで,例えば,

std::array<GLfloat, 4> v;

として宣言した変数 v は,v[0], v[1], v[2], v[3] という 4 つの要素を持った GLfloat 型の配列変数になります.ここでは using を使ってこれに Vector という別名を与えているので,

Vector v;

として宣言した変数 v は,上記と同じ 4 つの要素を持つ GLfloat 型の配列変数になります.

一つの粒子は位置と速度のデータを保持することにします.3 次元なのに 4 要素なのは同次座標系を使うという意味もありますが,むしろ今後書こうと思っていることの都合によるものです.実はこの解説は考えながら書いているので,このメンバは後で変更したり追加したりすると思います.

//
// 粒子データ
//
struct Particle
{
  // 位置
  Vector position;
 
  // 速度
  Vector velocity;
 
  // デフォルトコンストラクタ
  Particle()
  {}
 
  // 引数で初期化を行うコンストラクタ
  Particle(float px, float py, float pz,
    float vx = 0.0f, float vy = 0.0f, float vz = 0.0f)
    : position{ px, py, pz, 1.0f }, velocity{ vx, vy, vz, 0.0f }
  {}
 
  // デストラクタ
  ~Particle()
  {}
};

この「引数で初期化を行うコンストラクタ」について,一応説明します.まず,Particle 構造体のオブジェクト (メモリが割り当てられた実体) を生成するには,例えば,

Particle p;

というように,普通に Particle 構造体をデータ型として p という変数を宣言します.この場合は Particle 構造体の「デフォルトコンストラクタ」が使用され,p のメンバの position や velocity には値が格納されていません (でたらめな値が入っていると思っていた方がいいんじゃないでしょうか).そこで,オブジェクトの生成時 (メモリの確保時) に値を設定する方法が用意されています.これを初期化といいます.これは,普通の変数なら,

const int x = 10;

みたいに書きます.ちなみに,上の x には 10 が入っていますが,これは const なので値を代入することができません.すなわち,

x = 20;

という代入を行うとエラーになってしまいます.つまり,初期化代入異なる処理なのです.

ところで,この x の場合は保持できる値が一つしかないので上のような書き方ができましたが,Particle 構造体のオブジェクト p は全部で 6 個の GLfloat 型の値を保持します.したがって,これらのすべてに値を格納するには,この Particle 構造体の場合は,

Particle p(1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f);

というようにします.こうすれば「引数で初期化を行うコンストラクタ」が使用され,引数 px, py, pz にそれぞれ 1.0f, 2.0f, 3.0f,引数 vx, vy, vz にそれぞれ 4.0f, 5.0f, 6.0f が与えられます.そしてコンストラクタの 初期化並び

position{ px, py, pz, 1.0f }, velocity{ vx, vy, vz, 0.0f }

によって,メンバの position[0], position[1], position[2] にそれぞれ px, py, pz の値が格納され,position[3] に 1.0f が格納されます.また velocity[0], velocity[1], velocity[2] にはそれぞれ vx, vy, vz の値が格納され,velocity[3] には 0.0f が格納されます.なお引数 vx, vy, vz にはデフォルト引数として "= 0.0f" が設定されていますから,これらはコンストラクタの呼び出しの際に省略することができます.すなわち,

Particle p(1.0f, 2.0f, 3.0f);

のようにして Particle 構造体のオブジェクト p を初期化することができます.この場合は velocity のすべての要素に 0.0f が格納されます.

Blob クラスの追加

次に,粒子群,すなわち粒子の集合体を取り扱う Blob というクラスを追加します.ファイル名は Blob.h とします.まず,複数の Particle 構造体を格納する std::vector (さっきの Vector とは別のものです紛らわしくてすまん) の別名を Particles という名前で作っておきます.ちなみに std::vector は std::array と同様に配列を作成するテンプレートですが,要素数は必要に応じて自動的に拡張されます.これは動的配列と呼ばれます.

#pragma once
#include "Particle.h"
 
// 標準ライブラリ
#include <vector>
 
//
// 粒子群データ
//
using Particles = std::vector<Particle>;

このクラスでは粒子群を描画するために OpenGL の頂点配列オブジェクト (Vertex Array Object, VAO) や頂点バッファオブジェクト (Vertex Buffer Object, VBO) を管理します.この辺の話は授業二回目の資料とか新しい OpenGL のテキストとかを読んでくれ頼む.

Blob のコンストラクタは引数で与えられた Particle 構造体の動的配列 Particles を用いて,頂点バッファオブジェクトの確保と初期値の転送を行います.この処理の定義は Blob.cpp に記述します.また,Blob クラスのオブジェクトは vao や vbo という OpenGL のオブジェクトを保持しますので,Blob クラスのオブジェクトの別のコピーが作られ,さらにそれが削除されることによって,使用中の OpenGL のオブジェクトまで削除されることを防止するために,このオブジェクトのコピーを禁止しておきます.

ここでは「オブジェクト」という用語を,頂点バッファオブジェクトなどのOpenGL のオブジェクトC++ のオブジェクトの二通りの意味で使っています.これは混乱を招くこと甚だしいとは思うのですが,どっちの世界でもそれを「オブジェクト」と呼んでいて,私はうまく言い換えることができませんでした.
//
// 粒子群オブジェクト
//
class Blob
{
  // 頂点配列オブジェクト名
  GLuint vao;
 
  // 頂点バッファオブジェクト名
  GLuint vbo;
 
  // 頂点の数
  const GLsizei count;
 
public:
 
  // コンストラクタ
  Blob(const Particles &particles);
 
  // デストラクタ
  virtual ~Blob();
 
  // コピーコンストラクタによるコピー禁止
  Blob(const Blob &blob) = delete;
 
  // 代入によるコピー禁止
  Blob &operator=(const Blob &blob) = delete;
 
  // 初期化
  void initialize(const Particles &particles) const;
 
  // 描画
  void draw() const;
};

Blob.cpp の内容は次のようになります.コンストラクタでは,最初に頂点配列オブジェクト vao を作成します.

//
// 粒子群オブジェクト
//
#include "Blob.h"
 
// コンストラクタ
Blob::Blob(const Particles &particles)
  : count(static_cast<GLsizei>(particles.size()))
{
  // 頂点配列オブジェクトを作成する
  glGenVertexArrays(1, &vao);
  glBindVertexArray(vao);
次に,頂点バッファオブジェクト vbo を作成します.ここで vbo を glBindBuffer() により結合することで,vao への組み込みも同時に行います.この頂点バッファオブジェクトのデータ転送には initilaize() メソッドを使うので,ここではデータ転送を行わないとして glBufferData() の第 3 引数 data に nullptr を指定しておきます.initilaize() メソッドの実装は後述します.
  // 頂点バッファオブジェクトを作成する
  glGenBuffers(1, &vbo);
  glBindBuffer(GL_ARRAY_BUFFER, vbo);
  glBufferData(GL_ARRAY_BUFFER, count * sizeof(Particle), nullptr, GL_STATIC_DRAW);

Particle 構造体のオブジェクトには position と velocity の二つのメンバが詰め込まれていますから,glVertexAttribPointer() の最後の引数 pointer には,Position の各メンバの頂点バッファオブジェクトの先頭 (0) からのオフセットをポインタにして指定します.また,それぞれの頂点バッファオブジェクトの index (バーテックスシェーダの in 変数の location) に 0 と 1 を割り当てます.size は Vertex の要素数 std::tuple_size<Vector>::value,stride は Particle 構造体のサイズ sizeof(Particle) です.

  // 結合されている頂点バッファオブジェクトを in 変数から参照できるようにする
  glVertexAttribPointer(0, std::tuple_size<Vector>::value, GL_FLOAT, GL_FALSE,
    sizeof(Particle), &static_cast<const Particle *>(0)->position);
  glEnableVertexAttribArray(0);
  glVertexAttribPointer(1, std::tuple_size<Vector>::value, GL_FLOAT, GL_FALSE,
    sizeof(Particle), &static_cast<const Particle *>(0)->velocity);
  glEnableVertexAttribArray(1);

最後に initialize() メソッドを使って,この頂点バッファオブジェクトに初期データを転送しておきます.

  // 頂点バッファオブジェクトにデータを格納する
  initialize(particles);
}

デストラクタではコンストラクタで作成した vao と vbo を削除します.

// デストラクタ
Blob::~Blob()
{
  // 頂点配列オブジェクトを削除する
  glDeleteBuffers(1, &vao);
 
  // 頂点バッファオブジェクトを削除する
  glDeleteBuffers(1, &vbo);
}

頂点バッファオブジェクトにデータを転送する initialize() メソッドの実装は,次のようにします.このデータ転送は Particle 構造体のメンバごとに個別に行います.glMapBuffer() は GPU の頂点バッファオブジェクトを CPU 側のプログラムのメモリ領域としてアクセスできるようにして,その先頭のポインタを返します.それを Particle 構造体のポインタにキャストすることにより,Particle 構造体の配列変数のように扱うことができます.

// 初期化
void Blob::initialize(const Particles &particles) const
{
  // 頂点バッファオブジェクトを結合する
  glBindBuffer(GL_ARRAY_BUFFER, vbo);
  
  // 頂点バッファオブジェクトにデータを格納する
  Particle *p(static_cast<Particle *>(glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY)));
  for (auto particle : particles)
  {
    p->position = particle.position;
    p->velocity = particle.velocity;
    ++p;
  }
  glUnmapBuffer(GL_ARRAY_BUFFER);
}

粒子群を描画するメソッドも用意しておきます.粒子群は点 GL_POINT で描きます.

// 描画
void Blob::draw() const
{
  // 描画する頂点配列オブジェクトを指定する
  glBindVertexArray(vao);
 
  // 点で描画する
  glDrawArrays(GL_POINTS, 0, count);
}

粒子群オブジェクトの生成

この雛形プログラムでは,メインの処理を GgApplication::run() というメソッドに実装します.このメソッドは flow.cpp で定義しています.ここに粒子群オブジェクトの生成処理を追加します.粒子群オブジェクトは粒子を単純に乱数でばらまくだけでは面白くないので,粒子の密度が中心からの距離に対して正規分布になっている玉を複数個置くような感じにします.

//
// アプリケーション本体
//
#include "GgApplication.h"
  
// 粒子群オブジェクト
#include "Blob.h"
  
// 標準ライブラリ
#include <memory>
#include <random>
  
// 生成する粒子群の数
const int bCount(8);
  
// 生成する粒子群の中心位置の範囲
const GLfloat bRange(1.5f);
  
// 一つの粒子群の粒子数
const int pCount(1000);
  
// 一つの粒子群の中心からの距離の平均
const GLfloat pMean(0.0f);
  
// 一つの粒子群の中心からの距離の標準偏差
const GLfloat pDeviation(0.3f);

粒子の密度が中心からの距離に対して正規分布するように粒子を発生する手続きは,次のようになります.粒子の分布のさせ方については「ポイントのアニメーション」とか「SSAO (Screen Space Ambient Occlusion)」あたりを見てください.

//
// 粒子群の生成
//
//   paticles 粒子群の格納先
//   count 粒子群の粒子数
//   rn メルセンヌツイスタ法による乱数
//   cx, cy, cz 粒子群の中心位置
//   mean 粒子の粒子群の中心からの距離の平均値
//   deviation 粒子の粒子群の中心からの距離の標準偏差
//
void generateParticles(Particles &particles, int count,
  GLfloat cx, GLfloat cy, GLfloat cz,
  std::mt19937 &rn, GLfloat mean, GLfloat deviation)
{
  // 一様実数分布
  //   [0, 2) の値の範囲で等確率に実数を生成する
  std::uniform_real_distribution uniform(0.0f, 2.0f);
  
  // 正規分布
  //   平均 mean、標準偏差 deviation で分布させる
  std::normal_distribution normal(mean, deviation);
  
  // 格納先のメモリをあらかじめ確保しておく
  particles.reserve(particles.size() + count);
  
  // 原点中心に直径方向に正規分布する粒子群を発生する
  for (int i = 0; i < count; ++i)
  {
    // 緯度方向
    const GLfloat cp(uniform(rn) - 1.0f);
    const GLfloat sp(sqrt(1.0f - cp * cp));
    
    // 経度方向
    const GLfloat t(3.1415927f * uniform(rn));
    const GLfloat ct(cos(t)), st(sin(t));
    
    // 粒子の粒子群の中心からの距離 (半径)
    const GLfloat r(normal(rn));
    
    // 粒子を追加する
    particles.emplace_back(r * sp * ct + cx, r * sp * st + cy, r * cp + cz);
  }
}

emplace_back() は std::vector にデータを追加するメソッドです.引数はコンストラクタに渡され,std::vector の最後に追加されたメモリ上にオブジェクトを生成します.このプログラムの場合,指定している三つの引数によって Particle(r * sp * ct + cx, r * sp * st + cy, r * cp + cz) というコンストラクタが実行され,これらの引数は Particle 構造体の position メンバのそれぞれの要素に格納されます.また velocity メンバのすべての要素に 0.0f が格納されます.この関数 generateParticles() を GgApplication::run() の中で呼び出して,粒子群オブジェクトを生成します.

//
// アプリケーションの実行
//
void GgApplication::run()
{
  // ウィンドウを作成する
  Window window("Flow");
  
  //
  // 粒子群オブジェクトの作成
  //
  
  // 乱数の種に使うハードウェア乱数
  //std::random_device seed;
  
  // メルセンヌツイスタ法による乱数の系列を設定する
  //std::mt19937 rn(seed());
  std::mt19937 rn(54321);
  
  // 粒子群データの初期値
  Particles initial;
  
  // 一様実数分布
  //   [-1.0, 1.0) の値の範囲で等確率に実数を生成する
  std::uniform_real_distribution center(-bRange, bRange);
  
  // 発生する粒子群の数だけ繰り返す
  for (int i = 0; i < bCount; ++i)
  {
    // 点の玉中心位置
    const GLfloat cx(center(rn)), cy(center(rn)), cz(center(rn));
    
    // 中心からの距離に対して密度が正規分布に従う点の玉を生成する
    generateParticles(initial, pCount, cx, cy, cz, rn, pMean, pDeviation);
  }

これで Particle 構造体の std::vector である initial に生成した粒子群が格納されます.これを使って粒子群オブジェクトクラス Blob のオブジェクトを生成します.これはスマートポインタに格納しておいて,不要になったときにオブジェクトが自動的に削除されるようにしておきます.

くどいですが,「粒子群オブジェクトクラス」のオブジェクトは OpenGL のオブジェクトのことを意図していて,「Blob のオブジェクト」は C++ のオブジェクトのことを言っています.
  // 粒子群オブジェクトを作成する
  std::unique_ptr<const Blob> blob(new Blob(initial));

描画処理

粒子群オブジェクトの生成に続いて,この粒子群オブジェクトの描画処理を追加します.まず Blob クラスに描画に用いるシェーダのプログラムオブジェクト名と,そのシェーダで使用する変換行列の uniform 変数の場所を保持するメンバを追加します.Blob.h を次のように変更します.

//
// 粒子群オブジェクト
//
class Blob
{
  // 頂点配列オブジェクト名
  GLuint vao;
 
  // 頂点バッファオブジェクト名
  GLuint vbo;
 
  // 頂点の数
  const GLsizei count;
 
  // 描画用のシェーダ
  const GLuint drawShader;
 
  // unform 変数の場所
  const GLint mpLoc, mvLoc;
  
public:
  
  ...
また,draw() メソッドの定義も,変換行列を引数で受け取るように変更します.
  ...
 
  // 初期化
  void initialize(const Particles &particles) const;
  
  // 描画
  void draw(const GgMatrix &mp, const GgMatrix &mv) const;
  
  // 更新
  void update() const;
};

コンストラクタで追加したメンバに初期値を設定します.Blob.cpp を次のように変更します.シェーダのソースプログラム point.vert と point.frag については後述します.また ggLoadShader() 関数や,この後に出てくる ggLookat() 関数,ggPerspective() 関数と,GgMatrix クラスは授業の宿題の補助プログラムで用意しています.詳細はドキュメント(PDF) を参照してください.

// コンストラクタ
Blob::Blob(const Particles &particles)
  : count(static_cast<GLsizei>(particles.size()))
  , drawShader(ggLoadShader("point.vert", "point.frag"))
  , mpLoc(glGetUniformLocation(drawShader, "mp"))
  , mvLoc(glGetUniformLocation(drawShader, "mv"))
{
  ...
}

このシェーダを draw() メソッドで使うようにします.

// 描画
void Blob::draw(const GgMatrix &mp, const GgMatrix &mv) const
{
  // 描画する頂点配列オブジェクトを指定する
  glBindVertexArray(vao);
  
  // 点のシェーダプログラムの使用開始
  glUseProgram(drawShader);
  
  // uniform 変数を設定する
  glUniformMatrix4fv(mpLoc, 1, GL_FALSE, mp.get());
  glUniformMatrix4fv(mvLoc, 1, GL_FALSE, mv.get());
  
  // 点で描画する
  glDrawArrays(GL_POINTS, 0, count);
}
次に,flow.cpp の描画ループの前でビュー変換行列を設定しておきます. シェーダのプログラムオブジェクトを作成します.これも「オブジェクト」ですね,ややこしいですね.
  ...
  
  //
  // 描画の設定
  //
  
  // ビュー変換行列を求める
  const GgMatrix view(ggLookat(0.0f, 0.0f, 5.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f));
  
  // 背面カリングを有効にする
  glEnable(GL_CULL_FACE);

一方,投影変換行列やモデル変換行列は描画ループの中で設定します.これはウィンドウのリサイズやオブジェクトのマウス操作などに対応するためです.

  // デプスバッファを有効にする
  glEnable(GL_DEPTH_TEST);
  
  // 背景色を指定する
  glClearColor(0.1f, 0.2f, 0.3f, 0.0f);
  
  // 時計をリセットする
  glfwSetTime(0.0);
  
  // ウィンドウが開いている間繰り返す
  while (window)
  {
    // ウィンドウを消去する
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    // 透視投影変換行列を求める
    const GgMatrix projection(ggPerspective(1.0f, window.getAspect(), 1.0f, 10.0f));
    
    // モデル変換行列を求める
    const GgMatrix model(window.getTrackball());
    
    // モデルビュー変換行列を求める
    const GgMatrix modelview(view * model);
    
    // 粒子群オブジェクトを描画する
    blob->draw(projection, modelview);
    
    // カラーバッファを入れ替える
    window.swapBuffers();
  }
}

getAspect() メソッドや getTrackball() メソッドは GgApplication::Window クラスで定義しています.それぞれ描画ウィンドウのアスペクト比 (縦横比) とマウスの左ボタンドラッグによる回転の変換行列を返します.

シェーダ

ずーっと言ってなかったんですけど,実は「シェーダ」っていうたびに私は「イヤミ」を連想してたんですね,とかそんなことどうでもよくて,バーテックスシェーダのソースプログラム point.vert は次のようになります.とりあえず粒子の position[0] をモデルビュー変換,投影変換するだけです.

#version 430 core
  
// 頂点属性
layout (location = 0) in vec4 position;             // 現在の位置
  
// 変換行列
uniform mat4 mp;                                    // 投影変換行列
uniform mat4 mv;                                    // モデルビュー変換行列
  
void main()
{
  // モデルビュー変換
  vec4 p = mv * position;                           // 視点座標系の頂点の位置
  
  // 投影変換
  gl_Position = mp * p;
}

フラグメントシェーダのソースプログラム point.frag は,次のようになります.単に白い点を描いているだけです.

#version 430 core
  
// フレームバッファに出力するデータ
layout (location = 0) out vec4 color;               // フラグメントの色
  
void main()
{
  // フラグメントの色の出力
  color = vec4(1.0);
}

結果

実行すると,こんな感じの絵が出ます.マウスの左ボタンドラッグで回転できます.粒子の分布の平均 pMean や標準偏差 pDeviation を色々変更してみてください.

点で描画した粒子群
コメント(1) [コメントを投稿する]
miho 2024年02月19日 08:53

すばらしい!説明ですね!!


編集 «GLFW 3 で Oculus Rift を使う (1) 最新 粒子のレンダリング (2) ポイントの移動»