C++ Advent Calendar 2023 16日目の記事は @niina さんの2023年のコンパイル時レイトレーシングでした。
constexpr 発展の歴史と C++23 版コンパイル時レイトレーシングの2本立てで、とても面白い記事でした。
やはりコンパイル時処理はわくわくしますね。
しかし、この記事を読んでいて私は思いました。
ファイルの入出力は流石にコンパイル時にはできません。
誰ができないって決めたんですか?
というわけで、この記事では画像ファイルの出力をコンパイル時にやります。
コンパイル時出力可能な画像形式
コンパイル時出力
コンパイル時に出力はできます。
昨年、そのことを記事にしました。
-
C++ コンパイル時「出力」 ~C++にできないことはない~
- この記事では、
static_assert
のエラーメッセージをエスケープシーケンスで無理やり繋げて、コンパイル時に文字列を出力する方法を紹介しました。
- この記事では、
-
ついに実現!実用的なC++20コンパイル時出力
- この記事では、アセンブリの
.print
命令を用いてアセンブル時に出力する方法を紹介しました。
- この記事では、アセンブリの
このように、コンパイル時であってもターミナルに文字列を出力することは可能です。
ここで、標準出力(もしくは標準エラー出力)に出力された内容は、リダイレクトによってファイルに出力することができます。
つまり、画像ファイルの内容をコンパイル時に出力できれば、コンパイル時に画像ファイルを作成することができます!
さて、コンパイル時に出力可能な画像ファイル形式はあるのでしょうか?
.print
命令
昨年の記事では、エスケープシーケンスによって、余分な出力を隠したり、改行の直前までカーソルを戻したりすることによって、任意の文字列の出力を実現していました。
しかし、この手法はリダイレクトによるファイル出力では使えません。
エスケープシーケンスはターミナル上の見た目を整えるものであって、リダイレクトするときは、エスケープシーケンスはそのままファイルに流れ込むだけで、何の効果も発揮しません。
なので、余分な出力がたくさん含まれる static_assert
によるコンパイル時出力は、使い物になりません。
アセンブラの .print
命令によるアセンブル時出力で実装する方針を考えます。
.print
命令は、それをアセンブルするときに、指定した文字列を末尾に改行をつけて出力するものである。
.print "Hello, Assembly!"
.print "42"
$ gcc test.s -c
Hello, Assembly!
42
これでアセンブル時に好きな文字列が出力できますが、コンパイル時に計算した内容を指定して出力することはできません。
そこで、すべての出力しうる文字列についてそれぞれ出力のコードを用意し、コンパイル時に constexpr if でどのコードをアセンブリに出力するかを選択することで、コンパイル時計算の結果をアセンブリコードに反映します。
つまり、出力できる文字列が用意したものに限られます。
しかし、画像のビットマップデータの出力には、画素値を出力するコードを 256 個1用意しておけばよいので、さほど問題ではありません。
画像の幅、高さなどのヘッダに含まれる情報は、マクロで何とかしましょう(妥協)。
問題なのは、末尾に改行がつくことです。
画素ごとに出力すると、その間にいちいち改行が挿入されることになります。
つまり、コンパイル時に出力できる画像ファイルは、画素値同士が改行で区切られるようなファイル形式のものに限られます。
そんな都合のいいファイル形式、あるはずもありません。
PPM (Portable PixMap format)
ありました。
PPM の ASCII 形式です。
これは、カラー画像を ASCII 文字列で表現するファイル形式で、画素値は改行を含む空白文字で区切ります。
つまり、コンパイル時出力可能の要件を満たします。
やったぜ!
コンパイル時画像出力
実装
実装したものがこちらになります。
実装はいたってシンプルで、テンプレート引数として受け取った std::array
オブジェクトの要素を、畳み込み式によるループで出力しているだけです。
print_value()
は、例によって手心をこめて実装しています。
template <std::uint8_t Value, std::size_t, int, std::uintmax_t>
void print_value() {
if constexpr (Value == 0) CONSTEXPR_PPM_PRINTER_PRINT("0");
else if constexpr (Value == 1) CONSTEXPR_PPM_PRINTER_PRINT("1");
else if constexpr (Value == 2) CONSTEXPR_PPM_PRINTER_PRINT("2");
else if constexpr (Value == 3) CONSTEXPR_PPM_PRINTER_PRINT("3");
else if constexpr (Value == 4) CONSTEXPR_PPM_PRINTER_PRINT("4");
else if constexpr (Value == 5) CONSTEXPR_PPM_PRINTER_PRINT("5");
else if constexpr (Value == 6) CONSTEXPR_PPM_PRINTER_PRINT("6");
else if constexpr (Value == 7) CONSTEXPR_PPM_PRINTER_PRINT("7");
else if constexpr (Value == 8) CONSTEXPR_PPM_PRINTER_PRINT("8");
else if constexpr (Value == 9) CONSTEXPR_PPM_PRINTER_PRINT("9");
else if constexpr (Value == 10) CONSTEXPR_PPM_PRINTER_PRINT("10");
else if constexpr (Value == 11) CONSTEXPR_PPM_PRINTER_PRINT("11");
else if constexpr (Value == 12) CONSTEXPR_PPM_PRINTER_PRINT("12");
else if constexpr (Value == 13) CONSTEXPR_PPM_PRINTER_PRINT("13");
else if constexpr (Value == 14) CONSTEXPR_PPM_PRINTER_PRINT("14");
else if constexpr (Value == 15) CONSTEXPR_PPM_PRINTER_PRINT("15");
else if constexpr (Value == 16) CONSTEXPR_PPM_PRINTER_PRINT("16");
else if constexpr (Value == 17) CONSTEXPR_PPM_PRINTER_PRINT("17");
else if constexpr (Value == 18) CONSTEXPR_PPM_PRINTER_PRINT("18");
else if constexpr (Value == 19) CONSTEXPR_PPM_PRINTER_PRINT("19");
// ...
constexpr をつけない
昨年のコンパイル時出力では、出力関数を constexpr にしようと頑張っていたみたいですが、constexpr 関数であろうがなかろうがアセンブリはコンパイル時に評価されるので、別に constexpr 関数にこだわる必要はありません。
constexpr 関数でなければ、constexpr 関数内でインラインアセンブラが評価できないことに悩まされることもありません。
constexpr_ppm_printer::print_ppm()
は constexpr ではありませんが、出力はコンパイル時(正確にはアセンプル時)に行われ、実装はより簡単になっています。
呼び出し毎にインスタンス化
アセンプル時出力では、コードの評価時ではなく、アセンブリの生成時に出力されます。
つまり、出力するごとに関数を作る、すなわち、コードの再利用をさせないようにする必要があります。
各画素については、そのインデックスをテンプレートパラメータとして print_value()
に与えることで、print_value()
は画素ごとにインスタンス化され、アセンブリを生成します。
しかし、(あまりないと思いますが)画像を複数出力したいときはこれでは不十分で、画素値とインデックスが同じときに print_value()
がインスタンス化されず、アセンブリが生成されません2。
そこで、print_ppm()
のテンプレートパラメータに Counter
を追加し、この値を変更することでもう一度すべての画素でインスタンス化されるようになります。
また、Counter
を自動で設定して画像を出力する関数形式マクロ print_ppm_constexpr()
を用意しています。
このマクロは、Counter
の値に __COUNTER__
マクロの値を設定します。
__COUNTER__
マクロは展開されるごとにユニークな数値を返します。
実行
3つの円を描いた画像を作るサンプル (sample.cpp) を用意したので、それを実行してみます。
C++20 以上で、最適化をつけてコンパイルします。
最適化をつけないと、アセンブラが死にました(たぶん、インスタンス化したテンプレートの情報が多すぎて、オーバーフローしてる?)。
ここで、リダイレクトで結果を受け取るようにします。
$ g++ -std=c++20 -O2 sample.cpp > image.ppm
これを実行すると、次の画像が出力されます!
(結構時間がかかります。このサイズ (64×48) の画像でも、私の環境 (CPU: i5-10210U, RAM: 8GB) では1分ぐらいかかりました。)
今回はエスケープシーケンスを使ってないので、画像サイズが小さければ Wandbox でも動きます。
https://wandbox.org/permlink/LzwjG1nSaPYo2nN5
また、実際に出力されるのはアセンブル時であるので、(狭義の)コンパイルだけを行うことで、計算済みで後は出力するだけのアセンブリファイルを作ることができます。
このファイルをアセンプルすると、同じように出力画像が得られます。
$ g++ -std=c++20 -O2 -S sample.s sample.cpp
$ g++ sample.s > image.ppm
完全コンパイル時レイトレーシング
コンパイル時画像出力ができたということは、ついにこれができます。
出力までコンパイル時に済ませる、完全コンパイル時レイトレーシングです!
レイトレーシングのコードは2023年のコンパイル時レイトレーシングのものを拝借して、main.cpp のインクルードと main()
を書き換えます。
ひよって画像サイズを小さめに設定しています。
// (省略)
#define IMAGE_SIZE_X 80
#define IMAGE_SIZE_Y 40
#include "config.hpp"
#define CONSTEXPR_PPM_PRINTER_WIDTH IMAGE_SIZE_X
#define CONSTEXPR_PPM_PRINTER_HEIGHT IMAGE_SIZE_Y
#include "constexpr_ppm_printer.hpp"
// (省略)
int main(int argc, char** argv)
{
constexpr auto image = conray::make_image(conray::cam, conray::w);
print_ppm_constexpr(image);
return 0;
}
CMake の出力がコンパイル時出力と混ざるので、CMake は使わずにビルドします3。
~/constexpr-raytracing/src $ g++ -std=c++23 -O2 -fconstexpr-ops-limit=34359738367 -I ../include -S main.s main.cpp > image.ppm
すると、コンパイルしかしてないのに、次の画像ができています!
出力も含めて15分ぐらいでできましたが、メモリが限界そうだったので、画像サイズをこれ以上大きくするのはやめておきます。
おわりに
中3女子の constexpr レイトレを知ってから、「画像の出力までコンパイル時にできるか」はずっと気になっていたことだったので、コンパイル時画像出力を実現できたのは結構うれしいです。
正直、本当にできるとは思ってませんでした。
コンパイル時出力、これはまだ擦れそうですね……。
constexpr の制限が緩くなって面白くなくなったと思うなら、また新たな縛りプレイ挑戦を見つけて挑めばいいのです。
みなさんもよいコンパイル時処理ライフを!