記事一覧ページへ移動

【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に置いています。説明するコードはほんの一部なので、並べて読んでください。

GitHub - watasuke102/gl-save-framebuffer: Save the result drawn by /OpenGL (4.0 | ES 3.0)/ as a BMP fileGitHub - watasuke102/gl-save-framebuffer: Save the result drawn by /OpenGL (4.0 | ES 3.0)/ as a BMP filehttps://github.com/watasuke102/gl-save-framebufferSave the result drawn by /OpenGL (4.0 | ES 3.0)/ as a BMP file - watasuke102/gl-save-framebuffer

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

  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