OpenGL ES2(3) - シェーダの使い方 (2012/09/24, 2017/12/24)

前回はRaspberry Pi 特有の初期化についてでしたが、今回は OpenGL ES2 のプログラミングを説明します。前回と今回で Raspberry Pi で動作するOpenGL ES2のプログラムが作成できます。サンプルのダウンロードはこちらです。

OpenGL ES2 は、シェーダのコンパイルとシェーダの実行時に頂点や照明に関するデータを送る機能だけしか持っていません。OpenGL ES2 の関数はある程度パターン化された処理をするだけで、映像表現に関わる部分はすべてユーザが作成したシェーダプログラムの処理になります。

OpenGL ES2 の使い方は簡単に言うと次のようになります。


今回は2次元の三角形が移動するだけのごく簡単なシェーダを動作させるプログラムを解説します。3次元でキレイなアニメーションさせる場合でも、もっと複雑な処理をするシェーダとそのシェーダに送るデータが複雑になるだけです。 あと、テクスチャの扱いが重要ですが、画像フォーマットの話が必要になってくるので今回は省きます。


今回はちょっと長いので簡単な目次です。

シェーダの概要

バーテックスシェーダ(頂点シェーダ) とフラグメントシェーダ(ピクセルシェーダ) のソースを実行時にコンパイル、リンクして、できたプログラムオブジェクトをGPUに送ってグラフィックの描画に使います。 バーテックスシェーダは頂点毎に実行され、頂点の位置を出力します。フラグメントシェーダは頂点で囲まれた範囲のピクセル毎に呼び出され、その位置の色を出力します。図形が重なっていてもフラグメントシェーダは繰り返し実行されますが、三次元的に画面手前にある図形の色は上書き出力され、後から同じ画面位置に出力する場合に画面奥になる場合は無視されます。頂点と頂点の間は、頂点が持っている色、法線ベクトル、テクスチャ座標などの値を頂点間で自動的に補完してフラグメントシェーダに渡します。フラグメントシェーダは1画面描画するために数百万回実行される可能性があり、アニメーションを滑らかに動かすには 1秒間には数億回もの処理が必要になります。GPU がバーテックスシェーダとフラグメントシェーダを超高速に実行することで、CPUではスピード的に全く無理だった映像表現が可能となっています。

triangle

この図のように頂点の位置とその他の情報を頂点シェーダに渡すと、それらの情報は補間計算されてフラグメントシェーダに渡され、フラグメントシェーダが1つのピクセルの色を決めます。

バーテックスシェーダは gl_Position に頂点の座標を出力するように作成します。下の例ではユニフォーム変数 uFrame が、aPositionに渡された頂点位置に加算、減算をすることで画面上の頂点位置を左上にずらした値をgl_Position に出力しています。シェーダのソースは文字列としてアプリケーションのソースに埋め込むか、ファイルに格納します。実行時にOpenGL ES2のドライバがコンパイル、リンクするようにプログラムを作成します。変数宣言にattribute、uniform, varying といった指定が付いていますが、文法的にほとんど C言語と同じです。シェーダの実際の例は後述します。

  // バーテックスシェーダの例
  attribute vec3  aPosition;
  uniform   float uFrame;
  void main()
  {
    gl_Position = vec4(aPosition.x - uFrame, aPosition.y + uFrame, 0, 1);
  }

gl_FragColor には赤い色を出力しています。頂点に囲まれた範囲が赤くなるだけのフラグメントシェーダです。文法はバーテックスシェーダと同じですが、変数の使い方が少しだけ異なります。

  // フラグメントシェーダの例
  precision mediump float;
  void main()
  {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  };

シェーダ関連の初期化

アプリケーションの種類にかかわらず、いつも同じようなコードを使う部分となります。

シェーダソースのコンパイル

次の LoadShader 関数はシェーダのタイプ(バーテックスシェーダまたはフラグメントシェーダ)とシェーダのソースを文字列として受け取ってコンパイルします。glCreateShader にバーテックスシェーダまたはフラグメントシェーダを指定してシェーダオブジェクトを作成、glShaderSource でシェーダソースの文字列を設定して、glCompileShader でコンパイルします。glGetShaderiv でコンパイルでエラーが発生していないか確認します。

// シェーダの指定とコンパイル

GLuint LoadShader(GLenum type, const char *shaderSource)
{
  GLuint shader;
  GLint compiled;

  // シェーダオブジェクトの作成
  shader = glCreateShader(type);
  if (shader == 0) return 0;

  glShaderSource(shader, 1, &shaderSource, NULL);
  glCompileShader(shader);

  // コンパイル結果の取得
  glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);

  if (!compiled) { // コンパイルエラーの場合
    GLint infoLen = 0;
    glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &infoLen);
    if (infoLen > 0) {
      char* infoLog = malloc(sizeof(char) * infoLen);
      glGetShaderInfoLog(shader, infoLen, NULL, infoLog);
      printf("Error compiling shader:\n%s\n", infoLog);
      free(infoLog);
    }
    glDeleteShader(shader);
    return 0;
  }
  return shader;
}

プログラムオブジェクトを作成

コンパイルしたバーテックスシェーダとフラグメントシェーダをリンクしてプログラムオブジェクトを作成します。一旦リンクしてプログラムオブジェクトを作成したらシェーダは不要となります。

// シェーダをリンクしてプログラムオブジェクトを作成

int InitShaders(GLuint *program, char const *vShSrc, char const *fShSrc)
{
  GLuint vShader;
  GLuint fShader;
  GLint  linked;
  GLuint prog;

  vShader = LoadShader(GL_VERTEX_SHADER, vShSrc);
  fShader = LoadShader(GL_FRAGMENT_SHADER, fShSrc);

  // プログラムオブジェクトを作成
  prog = glCreateProgram();
  if (prog == 0) return 0;

  // バーテックスシェーダを設定
  glAttachShader(prog, vShader);
  // フラグメントシェーダを設定
  glAttachShader(prog, fShader);

  // リンク
  glLinkProgram(prog);

  // リンクのエラーチェック
  glGetProgramiv (prog, GL_LINK_STATUS, &linked);
  if (!linked) { // error
    GLint infoLen = 0;
    glGetProgramiv(prog, GL_INFO_LOG_LENGTH, &infoLen);
    if (infoLen > 0) {
      char* infoLog = malloc(sizeof(char) * infoLen);
      glGetProgramInfoLog(prog, infoLen, NULL, infoLog);
      printf("Error linking program:\n%s\n", infoLog);
      free ( infoLog );
    }
    glDeleteProgram (prog);
    return GL_FALSE;
  }
  // リンクして不要になったシェーダを削除
  glDeleteShader(vShader);
  glDeleteShader(fShader);

  *program = prog;
  return GL_TRUE;
}

シェーダ本体と変数の位置

固定機能を使っていた OpenGL と異なって、シェーダプログラム(GLSL)の書き方で自由に機能を実現できるため、アプリケーションの目的ごとに変化する部分です。シェーダは C と非常に似た文法を持っていて、ほとんどどんなプログラムでも書くことができます。テクスチャの読み書きができるため、テクスチャをデータの転送に使うことでグラフィックスと関係のない計算でも可能です。シェーダの世界は非常に奥が深いのですが、OpenGL ES2 のシェーダは WebGLと同じなので情報は取得しやすいと思います。

バーテックスシェーダ (頂点シェーダ)

頂点バッファに設定した頂点ごとにバーテックスシェーダが呼び出されます。このときアトリビュート変数である aPosition には頂点座標、aTex にはテクスチャ座標が入っています。 gl_Position に頂点位置を代入します。 縦横とも -1.0 から +1.0 の範囲が画面に表示される範囲となります。 今回の例では 3つの頂点座標(-0.5, -0.5, 0.0)、(0.5, -0.5, 0.0)、(0.0, 0.5, 0.0)に対して設定された、 代入する値が表示される頂点位置となりますが、下の図のようにユニフォーム変数 uFrame に設定された値が加減算されます。結果として uFrame の値に従って、三角形が右下から左上の範囲で位置が変わります。ここではバーテックスシェーダのソースを文字配列として定義します。

const char vShaderSrc[] =
  "attribute vec3  aPosition;\n"
  "attribute vec2  aTex;     \n"
  "varying   vec2  vTex;\n"
  "uniform   float uFrame;\n"
  "void main()        \n"
  "{                  \n"
  "  vTex = aTex;     \n"
  "  gl_Position = vec4(aPosition.x - uFrame, aPosition.y + uFrame, 0, 1);\n"
  "}                  \n";

3次元グラフィックを行う場合は、座標変換、透視投影変換などをバーテックスシェーダ内で計算することになります。

フラグメントシェーダ (ピクセルシェーダ)

頂点に囲まれた範囲のすべてのピクセルに対してフラグメントシェーダが呼び出されます。 各頂点に与えられたテクスチャ座標が直線補間されて vTex に入ります。縦方向のテクスチャ座標を赤、横方向のテクスチャ座標を緑、青は0.5に設定して gl_FragColor に代入しています。 gl_FragColor に代入された色はそのまま表示されます。 フラグメントシェーダのソースも文字配列として定義します。

const char fShaderSrc[] =
  "precision mediump float;\n"\
  "varying   vec2  vTex;\n"
  "void main()        \n"
  "{                  \n"
  "  gl_FragColor = vec4(vTex.y, vTex.x, 0.5, 1.0);\n"
  "}                  \n";

今回の例は非常に簡単なものですが、リアルな3次元グラフィックの表示に必要な照明や光の反射に関する計算はフラグメントシェーダ内で行うことになります。

シェーダに与えるパラメータ用の情報

頂点バッファからバーテックスシェーダに渡すアトリビュート変数、バーテックスシェーダやフラグメントシェーダに対する引数のように使用するユニフォーム変数はシェーダ内の位置を番号として記録しておいて、値を設定するときに変数名ではなくその番号を使います。 番号を記録しておくための変数をまとめて構造体で宣言しています。

typedef struct {
  GLint   aPosition;
  GLint   aTex;
  GLint   uFrame;
} ShaderParams;

物体の定義

アプリケーションが実際に表示する物体の定義には、頂点情報と頂点を結んだ三角形の情報として与える必要があります。 実行時にファイルから読みだしたり、実行時に計算して求めることもできますが、今回のサンプルでは配列に定数として格納することにします。

頂点の情報

少なくとも三次元空間内の頂点の位置 (x, y, z) が必要です。ここでは三次元の頂点座標 (x, y, z) と二次元のテクスチャ座標 (u, v) を1つの構造体にまとめて持たせています。

typedef struct {
    GLfloat x, y, z;    // 頂点座標
    GLfloat u, v;       // テクスチャ座標
} VertexType;

頂点データ

実際の頂点毎のデータを上記の構造体の配列として登録しています。三角形1つだけなので頂点3つ、奥行き方向のZ座標はすべて 0 としています。

VertexType vObj[] = {
  {.x = -0.5f, .y = -0.5f, .z = 0.0f, .u = 0.0f, .v = 1.0f},
  {.x =  0.5f, .y = -0.5f, .z = 0.0f, .u = 1.0f, .v = 1.0f},
  {.x =  0.0f, .y =  0.5f, .z = 0.0f, .u = 0.5f, .v = 0.0f},
};

頂点インデックス

1頂点あたり頂点座標 (x, y, z) やテクスチャ座標 (u, v) などを毎回指定して多角形を設定する事も出来ますが、貴重なGPUのメモリを有効に使うため上記の配列のインデックス(番号)で頂点を指定する事ができます。今回の例では三角形1つだけなので効果はありませんが、頂点数の多い物体を多く表示する場合には劇的な効果があります。

unsigned short iObj[] = {
  0, 1, 2
};

GPUにデータ転送

頂点や三角形に関する情報は、CPU から描画するたびに GPU に対して送ることもできますが、GPU側に頂点データを前もってすべて転送しておくとCPUに負荷もかからず効率的に描画できます。glGenBuffers でバッファオブジェクトを生成しておいて、glBindBuffer でバッファオブジェクトとバッファの種類 (頂点かインデックスか) を指定、glBufferData で実際にデータをアプリケーションのメモリから GPU に転送します。ここでは頂点データ(vObj, iObj)もバッファオブジェクト(の番号)を格納する変数(g_vbo, g_ibo)もグローバル変数としていますが、この部分の扱い方が異なるだけで、アプリケーションであまり変わらない部分です。

// GPU上のバッファオブジェクトにデータを転送

void createBuffer()
{
  // VBOの生成
  glGenBuffers(1, &g_vbo);
  glBindBuffer(GL_ARRAY_BUFFER, g_vbo);
  // データの転送
  glBufferData(GL_ARRAY_BUFFER, sizeof(vObj), vObj, GL_STATIC_DRAW);

  // インデックスバッファの作成
  glGenBuffers(1, &g_ibo);
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, g_ibo);
  // データの転送
  glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(iObj), iObj, GL_STATIC_DRAW);
}

描画

描画はシェーダプログラムを指定して、頂点データを保持するバッファを指定して、バッファから供給されるデータを受け取るアトリビュート変数を指定した後、 glDrawElementsで描画します。データの与え方で複数の3角形を一気に表示したり、複数回に分けて表示したりできます。複雑なシーンでは glUseProgram で複数のシェーダを切り替えて使用したり、glDrawElements

// 描画

void Draw ()
{
  // 使用するシェーダを指定
  glUseProgram(g_program);
  // 有効にするバッファオブジェクトを指定
  glBindBuffer(GL_ARRAY_BUFFER, g_vbo);
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, g_ibo);
  // シェーダのアトリビュート変数をアクセス可能にする
  glEnableVertexAttribArray(g_sp.aPosition);
  glEnableVertexAttribArray(g_sp.aTex);
  // 頂点情報のサイズ、オフセットを指定
  glVertexAttribPointer(g_sp.aPosition, 3, GL_FLOAT, GL_FALSE, 20, (void*)0);
  glVertexAttribPointer(g_sp.aTex, 2, GL_FLOAT, GL_FALSE, 20, (void*)12);
  glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, 0);
}

main 関数

main 関数は初期化した後、描画を6000回(10秒間)繰り返しています。描画ループ内で使っているシェーダのユニフォーム変数に値を設定する glUniform1f1f の部分は、「float の引数1つ」を表します。例えば glUniform4f とすれば、「float の引数4つ」を渡すことができます。また glUniform2i とすれば、「int の引数2つ」となります。

#include <stdio.h>
#include <sys/time.h>
#include <unistd.h>
#include <GLES2/gl2.h>
#include <EGL/egl.h>

略

// グローバル変数
ScreenSettings  g_sc;
ShaderParams    g_sp;
GLuint          g_vbo;
GLuint          g_ibo;
GLuint          g_program;

略

int main ( int argc, char *argv[] )
{
  unsigned int frames = 0;
  int res;

  // 初期化
  bcm_host_init();
  res = WinCreate(&g_sc);
  if (!res) return 0;
  res = SurfaceCreate(&g_sc);
  if (!res) return 0;
  res = InitShaders(&g_program, vShaderSrc, fShaderSrc);
  if (!res) return 0;

  // 頂点データの転送
  createBuffer();

  // シェーダの変数番号を記録
  g_sp.aPosition = glGetAttribLocation(g_program, "aPosition");
  g_sp.aTex = glGetAttribLocation(g_program, "aTex");
  g_sp.uFrame  = glGetUniformLocation(g_program, "uFrame");

  // 画面消去の色
  glClearColor(0.0f, 0.3f, 0.0f, 0.5f);

  /* 600frame / 60fps = 10sec */
  while(frames < 600) {
    glViewport(0, 0, g_sc.width, g_sc.height);

    // 画面消去
    glClear(GL_COLOR_BUFFER_BIT);

    // ユニフォーム変数に頂点位置のオフセットを設定
    glUniform1f(g_sp.uFrame, (float)(frames % 240) / 150.0 - 0.8);

    // 描画
    Draw();

    // 表示
    eglSwapBuffers(g_sc.display, g_sc.surface);
    frames++;
  }
  return 0;
}

ソースのダウンロードと使い方

今回の三角形が移動するだけのサンプルのソースです。

$ ls -l
-rwxr-xr-x 1 pi pi   211 Dec  7 16:07 build
-rwxr-xr-x 1 pi pi 14596 Dec  7 16:08 triangle
-rw-r--r-- 1 pi pi  7178 Dec  7 16:01 triangle.c

triangle.c が今回のソース全体、triangle はコンパイルした実行ファイル、build はコンパイル用のシェルスクリプトで以下の内容です。

#!/bin/sh

INCS="-I/opt/vc/include -I/opt/vc/include/interface/vcos/pthreads"
LIBS="-lGLESv2 -lEGL -lm -lbcm_host -L/opt/vc/lib"
CFLAGS="-O2 -Wall"

gcc ${CFLAGS} triangle.c -o triangle ${INCS} ${LIBS}

Raspbian Stretch の場合は:

#!/bin/sh

INCS="-I/opt/vc/include -I/opt/vc/include/interface/vcos/pthreads"
LIBS="-lbrcmGLESv2 -lbrcmEGL -lm -lbcm_host -L/opt/vc/lib"
CFLAGS="-O2 -Wall"

gcc ${CFLAGS} triangle.c -o triangle ${INCS} ${LIBS}

公式OSの Raspbian の場合、pi ユーザ(または video グループ権限を持つユーザ) で以下のようにファイルを解凍して、コンパイル、実行できます。10秒間動作して終了します。

$ tar zxf triangle01.tar.gz
$ cd triangle01
$ ./build
$ ./triangle

使用した OpenGL ES2 の関数

今回のサンプルに使用した OpenGL ES2 の関数の一覧を示しますが、使用頻度を大まかに示すために説明の後ろに○、◎、●を付けています。 絶対的なものではありませんが目安にして下さい。 ●マークは通常アプリケーションの初期化時だけ使う関数を示します。○マークは物体を新たに生成する場合やその他の条件を変更する場合に使う関数です。◎は毎フレームの描画に使用する可能性の高い関数です。

関数 意味
glCreateShader シェーダオブジェクトの作成 ●
glShaderSource シェーダソースの指定 ●
glCompileShader シェーダソースをコンパイル ●
glGetShaderiv シェーダの情報を取得 ●
glGetShaderInfoLog シェーダのコンパイルエラーログを取得 ●
glDeleteShader シェーダオブジェクトの削除 ●
glCreateProgram プログラムオブジェクトの作成 ●
glAttachShader プログラムにシェーダを登録 ●
glLinkProgram シェーダをプログラムにリンク ●
glGetProgramiv プログラムの情報を取得 ●
glGetProgramInfoLog プログラムのリンクエラーログを取得 ●
glDeleteProgram プログラムオブジェクトの削除 ●
glGetAttribLocation シェーダのアトリビュート変数の位置を取得 ●
glGetUniformLocation シェーダのユニフォーム変数の位置を取得 ●
glGenBuffers バッファオブジェクトを作成 ○
glBufferData バッファオブジェクトにデータを転送 ○
glVertexAttribPointer アトリビュート変数にバッファ内のオフセットを指定 ◎
glClearColor 画面消去時の色を指定 ○
glEnableVertexAttribArray 頂点バッファを有効にする ◎
glUseProgram 描画に使用するプログラムを指定 ◎
glBindBuffer バッファオブジェクトをシェーダのデータとして指定 ◎
glViewport 画面位置とサイズをピクセル単位で指定 ◎
glClear 画面消去 ◎
glUniform1f シェーダのユニフォーム変数に値を設定 ◎
glDrawElements 頂点インデックスを使って描画 ◎

詳細は...

OpenGL ES2 の詳細は khronosのサイトオンラインマニュアル があります。日本語の参考書籍としては「Open GL ES 2.0 プログラミングガイド」が詳しいです。またシェーダ(GLSL)に関しては WebGL 用を参考にすることができます。 このサイトでももう少し続けるつもりです。


続く...