記事一覧ページへ移動

【OpenGL (ES)】描画結果を取得し、BMPとして保存する

2024-11-20
2024-11-19

最近OpenGLを触っていて、描画された結果を取得したいという需要が発生したのですが、手順が意外とネットにまとまっていないと感じたので、備忘録として書いておきます。

ちなみに、自身の理解を整理するという目的も兼ねて、ざっくりとした解説はしますが、OpenGLの包括的なチュートリアル記事ではないので、ご了承ください。

環境

Arch Linux, x86_64, linux-zen kernel

HP 14s-fq1073AU というラップトップで動作確認をしています。fastfetchによると GPU: AMD Lucienne だそうです。

まあ基本的にはどの環境でも同じだと思います。初期化処理が若干変わったりするかもしれませんが。

また、OpenGL 4.0 あるいは OpenGL ES 3.0 のどちらにも対応できるようにプログラムを書いています。試していませんが、これ以上のバージョンでも動くのではないでしょうか。

どのように描画結果を取得するのか

Framebuffer Objectに描画して、その結果を glReadPixels で取得するという流れです。

Framebufferとは

Framebuffer Object (FBO) は、描画結果を保持しておくオブジェクトです。描画結果は、色情報・深度情報・stencil情報 1 の3つに分類できます。これらの情報は、Texture BufferあるいはRender Bufferに保存可能で、Framebuffer Objectとは、この (Texture|Render) Buffer の集合体です。

つまり以下のようになります。Framebuffer Objectが最上位で、これには最大3種類のバッファをアタッチすることができます:

  • Framebuffer Object
    • Color Buffer (色)
    • Depth Buffer (深度)
    • Stencil Buffer

(Color|Depth|Stencil) Buffer は概念のようなもので、その実体として (Texure|Render) Buffer を登録できます。今回は2Dで描画するので、Color Buffer のみアタッチします。

なお、今回はわざわざFBOを作成していますが、たぶん作成しなくても大丈夫です(contextにFBOがあるはずなので)。

手順

今回説明するプログラムのソースコードをGitHubに置いています。説明するコードはほんの一部なので、並べて読んでください。

こんな感じで処理を行っています:

  1. OpenGLを初期化
  2. VAO/VBOを作成して三角形の頂点情報を保存
  3. 描画対象となるFramebuffer Objectを作成してそれに描画
  4. 描画結果を取得してBMPとして保存する

実行すると、こんなBMPが保存されます。

保存された描画結果。灰色の背景と、緑色の三角形

三角形の頂点情報を作成

VAO (Vertex Array Object) を作って2、VBO (Vertex Buffer Object) を作って頂点情報をコピーします3

GLuint vertex_array;
glGenVertexArrays(1, &vertex_array);

GLuint pos_buffer;
GLfloat vertices[] = {
  0.f, 1.f,    // 上
  1.f, -1.f,   // 右下
  -1.f, -1.f,  // 左下
};
glGenBuffers(1, &pos_buffer);
glBindBuffer(GL_ARRAY_BUFFER, pos_buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);

Framebuffer Objectの作成

Color Bufferの実体として Texture Buffer を使います。Render Bufferとの差異は、おそらくシェーダーからアクセス出来るか否かだと思います。今回は別にどっちでも良いのですが、参考資料ではColor BufferとしてTexture Bufferを使っているので合わせました。

まず Texture Buffer を作ります。formatはRGBAです。サイズに用いている WIDTH および HEIGHT は、ソースコード冒頭で定数として定義しているものです。 glTexParameteri でちょっとした設定もしておきます。

GLuint color_buffer;
glGenTextures(1, &color_buffer);
glBindTexture(GL_TEXTURE_2D, color_buffer);
glTexImage2D(
    GL_TEXTURE_2D, 0, GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE,
    0
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

その後、Framebuffer Objectを作成し、Texture Buffer をアタッチします。

GLuint frame_buffer;
glGenFramebuffers(1, &frame_buffer);
glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer);
glFramebufferTexture2D(
    GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_buffer, 0
);

描画

glBindFramebuffer で作成したFramebuffer Objectを描画対象に設定して、描画します。

glBindFramebuffer(GL_DRAW_FRAMEBUFFER, frame_buffer);
glViewport(0, 0, WIDTH, HEIGHT);
glClearColor(0.5f, 0.5f, 0.5f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(program_id);

glBindVertexArray(vertex_array);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, pos_buffer);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_TRIANGLES, 0, 6);

結果の読み取り

glReadPixels を用いて、描画結果をピクセルとして取得します。formatがRGBAなので、1ピクセルあたり4バイトです。

(2024-11-20追記:vectorを使わずPixel Buffer Objectを使ったほうが高速らしいです。記事末尾に追記しています)

std::vector<GLubyte> pixels(WIDTH * HEIGHT * 4);
glBindFramebuffer(GL_READ_FRAMEBUFFER, frame_buffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glReadPixels(0, 0, WIDTH, HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());

BMPとして保存

あとはもう消化試合みたいなものです。取得したピクセルをBMPに加工します。

BMPのヘッダは、ファイルヘッダと情報ヘッダに分かれています。ヘッダが終わったらピクセルデータとなります。BMPには、画像の下から上の順にピクセルデータを格納するようになっていますが、OpenGLの座標も左下が基準になっているので、そのまま保存できます。

constexpr uint32_t imagesize = WIDTH * HEIGHT * 3;
constexpr uint32_t filesize  = imagesize + /* header size = */ 54;
constexpr uint8_t  header[] = {
  // file header
  0x42, 0x4d, // magic
  (filesize & 0x0000'00ff),
  (filesize & 0x0000'ff00) >> 8,
  (filesize & 0x00ff'0000) >> 16,
  (filesize & 0xff00'0000) >> 24,
  0, 0, 0, 0, // reserved
  0x36, 0, 0, 0, // offset to pixel data
  // info header
  0x28, 0, 0, 0, // header size
  WIDTH&0x00ff, (WIDTH&0xff00) >> 8, 0, 0,
  HEIGHT&0x00ff, (HEIGHT&0xff00) >> 8, 0, 0,
  1, 0, // plane number; must be 1
  24, 0, // bits per pixel
  0, 0, 0, 0, // compression = RGB
  (imagesize & 0x0000'00ff),
  (imagesize & 0x0000'ff00) >> 8,
  (imagesize & 0x00ff'0000) >> 16,
  (imagesize & 0xff00'0000) >> 24,
  0, 0, 0, 0, 0, 0, 0, 0,
  0, 0, 0, 0, 0, 0, 0, 0, // neadless info
};

FILE* f = fopen("texure.bmp", "w+b");
fwrite(header, sizeof(header), 1, f);
for (int y = 0; y < (int)HEIGHT; ++y) {
  for (int x = 0; x < (int)WIDTH; ++x) {
    std::fwrite(&pixels[(WIDTH * y + x) * 4 + 2], 1, 1, f);  // B
    std::fwrite(&pixels[(WIDTH * y + x) * 4 + 1], 1, 1, f);  // G
    std::fwrite(&pixels[(WIDTH * y + x) * 4 + 0], 1, 1, f);  // R
  }
}
fclose(f);

ここまでやれば、先程見せたとおり、灰色の背景に緑色の三角形が描画された texture.bmp が作成されるはずです。

PBOを用いた高速化(2024-11-20追記)

コメントを頂きました。PBO (Pixel Buffer Object) を使うと軽くなるらしいです。ほほう。

GPUとデータをやり取りするのにDMAを用いるので速いそうです。描画結果を取得する方法を模索していた時、そういえばそういうワードも見たような気がします。

というわけで実装しました。実装は pbo ブランチにあります。

+  // create Pixel Buffer Object
+  GLuint pixel_buffer;
+  glGenBuffers(1, &pixel_buffer);
+  glBindBuffer(GL_PIXEL_PACK_BUFFER, pixel_buffer);
+  glBufferData(
+      GL_PIXEL_PACK_BUFFER, WIDTH * HEIGHT * 4, nullptr, GL_STREAM_READ
+  );
   // get pixels
-  std::vector<GLubyte> pixels(WIDTH * HEIGHT * 4, 0xff);
   glBindFramebuffer(GL_READ_FRAMEBUFFER, frame_buffer);
   glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
-  glReadPixels(0, 0, WIDTH, HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
+  glReadPixels(0, 0, WIDTH, HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, 0);
+  auto pixels = (GLubyte*)glMapBufferRange(
+      GL_PIXEL_PACK_BUFFER, 0, WIDTH * HEIGHT * 4, GL_MAP_READ_BIT
+  );
   // 略
   for (int y = 0; y < (int)HEIGHT; ++y) {
     for (int x = 0; x < (int)WIDTH; ++x) {
       std::fwrite(&pixels[(WIDTH * y + x) * 4 + 2], 1, 1, f);  // B
       std::fwrite(&pixels[(WIDTH * y + x) * 4 + 1], 1, 1, f);  // G
       std::fwrite(&pixels[(WIDTH * y + x) * 4 + 0], 1, 1, f);  // R
     }
   }

こんな感じで、std::vectorを使うのをやめて、バッファを GL_PIXEL_PACK_BUFFER にバインドして、glReadPixelを呼びます。その後、glMapBufferRange 4 を用いてメモリにマッピングし、ピクセルデータにアクセスします。

当然ですが実行結果は変わりません。multitime を使って5ベンチマークを取ってみましょう。描画サイズを8000x6000にしてやってみます。

まず、PBOを使わない時の実行結果はこんな感じです:

$ multitime -n5 ./gl
===> multitime results
1: ./gl
            Mean        Std.Dev.    Min         Median      Max
real        3.785       0.221       3.564       3.717       4.210
user        2.348       0.019       2.316       2.351       2.369
sys         0.750       0.053       0.657       0.762       0.816
$ multitime -n5 ./gles
===> multitime results
1: ./gles
            Mean        Std.Dev.    Min         Median      Max
real        3.653       0.132       3.544       3.608       3.913
user        2.347       0.018       2.322       2.347       2.376
sys         0.691       0.036       0.643       0.673       0.742

PBOを使う時の実行結果はこんな感じです:

$ multitime -n5 ./gl
===> multitime results
1: ./gl
            Mean        Std.Dev.    Min         Median      Max
real        3.119       0.097       3.004       3.127       3.235
user        1.929       0.020       1.903       1.928       1.953
sys         0.609       0.033       0.561       0.630       0.645
$ multitime -n5 ./gles
===> multitime results
1: ./gles
            Mean        Std.Dev.    Min         Median      Max
real        3.159       0.095       2.997       3.153       3.259
user        1.945       0.040       1.911       1.936       2.021
sys         0.628       0.035       0.559       0.641       0.655

早くなってる!すげ〜

参考資料

Footnotes

  1. 日本語で何と書けばよいのかわかりません

  2. OpenGL ESでは(contextに暗黙的なVAOが含まれるので?)作成しなくて良いらしいです。おかげでOpenGL ESでは動くのにOpenGLでは動かないみたいな謎現象にハマって大変でした

  3. リポジトリに上げているコードではスコープを切っているので、一見バッファの中身こと vertices にスコープ外からアクセスしているように見えるかもしれません。しかし、 glBufferDataの仕様 によると、第3引数はバッファにコピーされるらしいので、スコープを切っても構わないというわけです

  4. 参考サイトでは glMapBuffer を使っていたのですが、この関数はOpenGL ESにはありませんでした

  5. てきとうに探したら出てきました。こだわりとかはないです(なんでも良いとおもいます)


Comments

Powered by Giscus