【OpenGL (ES)】描画結果を取得し、BMPとして保存する
最近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に置いています。説明するコードはほんの一部なので、並べて読んでください。
こんな感じで処理を行っています:
- OpenGLを初期化
- VAO/VBOを作成して三角形の頂点情報を保存
- 描画対象となるFramebuffer Objectを作成してそれに描画
- 描画結果を取得して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
早くなってる!すげ〜
参考資料
- Framebuffer Object - OpenGL Wiki
- [OpenGL] FrameBufferとRenderBufferについてメモ #WebGL - Qiita
- 床井研究室 - フレームバッファオブジェクトの使い方あげいん
- Windows bitmap - Wikipedia
- Pixel Buffer Object - OpenGL Wiki
- OpenGL - PBO - PukiWiki for PBCGLab
Footnotes
-
日本語で何と書けばよいのかわかりません ↩
-
OpenGL ESでは(contextに暗黙的なVAOが含まれるので?)作成しなくて良いらしいです。おかげでOpenGL ESでは動くのにOpenGLでは動かないみたいな謎現象にハマって大変でした ↩
-
リポジトリに上げているコードではスコープを切っているので、一見バッファの中身こと
vertices
にスコープ外からアクセスしているように見えるかもしれません。しかし、 glBufferDataの仕様 によると、第3引数はバッファにコピーされるらしいので、スコープを切っても構わないというわけです ↩ -
参考サイトでは glMapBuffer を使っていたのですが、この関数はOpenGL ESにはありませんでした ↩
-
てきとうに探したら出てきました。こだわりとかはないです(なんでも良いとおもいます) ↩
Comments
Powered by Giscus