グラフィックスプログラミングに泣かされる : OpenGLが抱える問題について

リアルタイムのグラフィックAPIのメインストリームであるOpenGLとDirect3Dは、プログラマが異なるハードウェアと相互にやりとりする方法としては恐らく最も広く利用されているものでしょう。しかし、これらの行うCPU-GPUインテグレーションの品質は到底受け入れがたいものです。良いパフォーマンスを実現するためには、CPU側のコードとGPU側のシェーダプログラムを緊密に調整する必要がありますが、今のAPIではCPUとGPUは独立して実行するものとして扱われています。そのため、文字列型のインターフェイスや大量のボイラープレート、貧しいGPU固有のプログラミング言語が存在することになってしまうのです。

この投稿では、必ずしも愉快とは思えないOpenGLアプリの些細な現実をいくつかお見せします。プログラムリスト全ソースコードを参照しながらお読みください。

シェーダは文字列

3Dでオブジェクトの外見を定義する場合、リアルタイムコンピュータグラフィックスでは シェーダと呼ばれるアプリケーションが使用されます。これは、レンダリングパイプラインの一部としてGPU上で実行させる小さいプログラムです。シェーダにはいくつか種類がありますが、最もよく使用されるのは、オブジェクトのメッシュの頂点の位置を特定する頂点シェーダとオブジェクトの表面色のピクセルを作成するフラグメントシェーダです。シェーダを書く時に特別なC言語に似たプログラミング言語が使用されます。OpenGLではGLSLというシェーダ専用言語が使用されます。

これがうまくいかない原因となるのです。シェーダを設定するために、ホストプログラムが、シェーダのソースコードを含む文字列をグラフィックスカードドライバへと送信します。ドライバは実行時にソースコードをGPUの内部アーキテクチャにコンパイルし、ハードウェアにロードします。

簡略化したGLSL 頂点シェーダとフラグメントシェーダのC言語文字列リテラルのペアは次のようになります。

const char *vertex_shader =
  "in vec4 position;\n"
  "out vec4 myPos;\n"
  "void main() {\n"
  "  myPos = position;\n"
  "  gl_Position = position;\n"
  "}\n";

const char *fragment_shader =
  "uniform float phase;\n"
  "in vec4 myPos;\n"
  "void main() {\n"
  "  gl_FragColor = ...;\n"
  "}\n";

(起動時にテキストファイルからシェーダコードをロードする方法も一般的です)inoutuniform修飾子は、CPUとGPUの間やGPUの異なる段階にあるレンダリングパイプラインの間の通信チャネルを示します。myPos変数は、頂点シェーダからフラグメントシェーダへとデータを渡すのに役立ちます。頂点シェーダのmain関数はgl_Positionという魔法の組み込み変数を出力のために割り当て、フラグメントシェーダは、gl_FragColor変数を割り当てます。

シェーダプログラムをコンパイルしてロードする方法は次のようになります。

// Compile the vertex shader.
GLuint vshader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vshader, 1, &vertex_shader, 0);

// Compile the fragment shader.
GLuint fshader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fshader, 1, &fragment_shader, 0);

// Create a program that stitches the two shader stages together.
GLuint shader_program = glCreateProgram();
glAttachShader(shader_program, vshader);
glAttachShader(shader_program, fshader);
glLinkProgram(shader_program);

このボイラープレートを使ってshader_programを呼び出してオブジェクトを描画する準備ができました。

文字列シェーダのインターフェイスはグラフィックスプログラミングの原罪なのです。つまり、完全なプログラムのセマンティックスの一部はランタイムまで知ることはできないことを意味します。これには、異なるハードウェアを標的にしていること以外に特に理由はありません。これはJavaScriptのeval関数のようですが、これ以上に面倒なのは、全てのOpen GLプログラムではコードを文字列に詰め込むことが必要になるのです。

Direct3Dや次世代のグラフィックスAPIであるMantleMetalVulkanは、生のソースコードの代わりにバイトコードを使用してシェーダを実行することで、きれいなプログラムにできます。しかし、IRへのプリコンパイルシェーダープログラムは根本的な問題を解決してはいません。その問題とは、「CPUコードとGPUコードとの間のインターフェイスが無駄に動的であるため、異種のプログラム全体を静的に論証できない」というものです。

文字列に型付けされたバインディングのボイラープレート

文字列でラップされたシェーダコードがOpenGLの自己資金投資の痛みなら、CPUとGPUの通信インターフェイスを介して、痛みの配当金を集めます。

頂点シェーダのposition変数とフラグメントシェーダのphase変数をそれぞれ確認してみてください。in修飾子とuniform修飾子はCPUからのパラメータです。このパラメータを使用するには、ホストプログラムはまず各変数の位置情報を探します

GLuint loc_phase = glGetUniformLocation(program, "phase");
GLuint loc_position = glGetAttribLocation(program, "position");

変数の名前を文字列として渡して探します。phaseパラメータは単にスカラー型のfloatですが、positionは位置ベクトルの動的配列のため、バックバッファを設定するには、さらにボイラープレートが必要になります。

次にこれらハンドルを使用してシェーダにデータを渡し、それぞれのフレームを描画します。

// The render loop.
while (1) {
  // Set the scalar `phase` variable.
  glUniform1f(loc_phase, sin(4 * t));

  // Set the `location` array by copying data into the buffer.
  glBindBuffer(GL_ARRAY_BUFFER, buffer);
  glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(points), points);

  // Use these parameters and our shader program to draw something.
  glUseProgram(shader_program);
  glDrawArrays(GL_TRIANGLE_FAN, 0, NVERTICES);
}

Verbose モード(詳細な起動ログを出力するモード)によって気をそらされますが、glUniform1fglBufferSubDataでの呼び出しはlet variable = valueの代わりにset("variable", value)と書くのと同じことになのです。C言語やGLSLのコンパイラはCPUとGPUコードを別々にチェックし最適化してくれます。文字列型のCPUとGPUのインターフェイスはそれぞれのコンパイラがプログラム全体に影響を与えないようにしてくれます。

異質性の時代

ハードウェア異質性の時代においては、OpenGLやこれに相当するものは哀れな主唱者となってしまいます。異質性は急速にユビキタスになってきています。そのため、異なる機能を持つハードウェアに対応できるソフトウェアを書くためのより良い方法が必要とされています。OpenGLのプログラミングモデルは、「異質のソフトウェアは、疎結合な複数の個別プログラムで構成されるべき」という単純な観点を重視しています。

説得力のある異質性が成功するには、20世紀的見解を捨てる必要があります。必要となるプログラミングモデルは、複数の実行内容を可能にする1つのプログラムを書くことができることなのです。しかし、異質性の複雑さの本質をなくすことはできません。しかし、CPUコード以外のものをもう少し重んじることができるのではないでしょうか。