2012年4月13日金曜日

OpenGl ステンシルバッファ

ちょっと OpenGL(ES) のステンシル関連が頭の中でまとまらないので、書きながら考えてみます。最終的に目指すのは複数のマスクを置くこと。ただし1つの絵に対して複数のマスクを設定することについては考えていません。

何かのお役にたったらとっても幸いかもということで。正確性については話半分とし、公式のリファレンスなどでご確認ください。

OpenGL ES API リファレンス

まずはメソッドのリファレンスを和訳しながら、あいだに補足を書いていきます。

void glStencilFunc(GLenum func, GLint ref, GLuint mask);

ステンシルテストの検査方法と比較値を設定する。

func
ステンシルテストの方法を指定する。
ref
ステンシルテストのための比較値を指定する。初期値はゼロ。
mask
ステンシルテストが行われた時の、ref の値とステンシル値に対してビット単位のAND演算する値を指定する。初期値は2進数で表した時に11111…となる数値。

ステンシルテストではデプスバッファと同じように「そのピクセル描いてよいかどうか」を検査する(つまりフラグメントシェーダー段階の処理)。ステンシル面は OpenGL のプリミティブとして描かれ、これを型紙として画像や物体が描かれる。ステンシルはマルチパスレンダリングアルゴリズムでよく使われ、デカール・アウトラインといった特殊効果に利用される。

例えば(x, y)=(25, 322)の位置のピクセルについて黄色がかった赤い色を付けたいんだけどどうよ?というのがステンシルテストというものです。テストで不合格ならばそれ(25, 322への黄赤)は最終的な絵には反映されないことになります。ステンシルはマスク機能にも使われます。

ステンシルテストは「比較値」と「ステンシルバッファの値」を比較した結果を判断し、そのピクセルへの描画を許可するかを決める。ステンシルテストを有効化するためには
glEnable(GL_STENCIL_TEST);
とする。ステンシルテストの結果の扱いは glStencliOp() で指定する。

引数として func, ref, mask があるが、実際は表と裏があってそれぞれに設定が必要。両面とも同じ設定ならば glStencliFunc() を使用し、別々に設定するならば glStencilFuncSeparate() を使用する。

引数の func は下に示す8つの定数を設定し、これでステンシルテストの検査方法を決定する。ref はステンシルの比較で使用される整数の値。これは0から2^n-1までの値を取り、nはステンシルバッファの bitplanes の数である(後述)。mask は比較値(ref の値)およびステンシル値と比較(ビット単位のAND演算)される。この結果の2つの値がさらに比較される。
(この段は sonson@Picture&software の記述を参考にしています。)

つまりは3回の比較が行われるみたい。
  1. ref vs mask の比較
  2. ステンシル値 vs mask の比較
  3. 1.の結果 vs 2.の比較検査(引数 func で指定された方法で)
1番と2番の比較は「ビット単位のAND演算」で行われ、最後に行われる3番の検査で不合格なら新しい情報をフレームバッファには描画しない、となる。

ちなみに mask の値については、1番と2番の比較検査をすることがない場合、111111…と1で埋まった値を使います。実際には「~0」というような値を使います(ビット演算の知識)。

bitplanes というワードが出ましたが、これについては以下の記事に書きました。
ビット平面の解説

ステンシルバッファにも1つ1つのピクセルについて10011110100…という値(ステンシル値)が記録されています。例えばこの値の第3桁をステンシル値としてステンシルテストはどうよ?というような検査を行います(この辺は推測を含みます。検証はしてません。)

で、bitplanes の数がどうのという話でした。これは言い換えるとステンシル値を2進数で表現した時の桁数となります。10110なら5桁なので5、111011011なら9桁でbitplanes は9枚(平面だから数え方は枚?)となります。さらに言うと bitplanes の数と同じだけのステンシルの型紙を作ることができる、という理解で良いと思います。

(わざわざ bitplanes を持ち出す必要があったのかしら。複数のビットとAND演算するとか?)

リファレンスに戻ります。

以下のリストは引数 func によって指定できる比較検査関数のそれぞれの処理である。この表で "stencil" は対応する位置のステンシルバッファ内の値(ステンシル値)を表すものである。この値は0から2^n-1の値をとる。また、ステンシルテストの合格が意味するのはラスタライズの次のステップへ進むということであり、これに合格したからといって描画されると決まるわけでない。

GL_NEVER:すべて不合格
GL_LESS:( ref & mask ) < ( stencil & mask ) で合格
GL_LEQUAL:( ref & mask ) <= ( stencil & mask ) で合格
GL_GREATER:( ref & mask ) > ( stencil & mask ) で合格
GL_GEQUAL:( ref & mask ) >= ( stencil & mask ) で合格
GL_EQUAL:( ref & mask ) = ( stencil & mask ) で合格
GL_NOTEQUAL:( ref & mask ) != ( stencil & mask ) で合格
GL_ALWAYS:すべて合格
※[&]はビット単位のAND演算

あまり理解は進んでいませんが、とりあえずもう1つの関数のリファレンスも訳してみましょう。

void glStencilOp(GLenum sfail,  GLenum dpfail,  GLenum dppass);

ステンシルテストによる処理を設定する。

sfail
ステンシルテストが失敗した時の処理を指定する。
dpfail
ステンシルテストは通ったけどデプステストが通らなかった時の処理を指定する。
dppass
  1. ステンシルテスト・デプステスト両方が通った
  2. ステンシルテストが成功したけどデプスバッファが無いかデプスバッファが無効(glDisable)である
場合の処理を指定する。

これらの3つの引数は(ステンシルテストが有効であって)記録されているステンシル値がどうなるか、ということを指定するものである。もしステンシルテストが不合格ならば、カラーバッファやデプスバッファーは変更されず、そのときは sfailの指定によりステンシルバッファの値がどのようになるかが決定される。

それぞれに指定する値は以下。デフォルト値は3引数ともに GL_KEEP。

GL_KEEP : 現在の値を保持する。
GL_ZERO : 値をゼロとする。
GL_REPLACE : 値を glStencilFunc() の引数の ref の値にする。
GL_INCR : 現在の値を+1する。
GL_INCR_WRAP : 現在値+1するが、最大値を超える場合はゼロにする。
GL_DECR : 現在の値を-1する。ゼロ以上に補正される。
GL_DECR_WRAP : 現在値-1するが、ゼロ以下になるなら最大値にする。
GL_INVERT : 現在の値をビット演算で反転させた値にする。

ステンシルバッファの「値」というものは最大値がある。これは 2^n-1 と表され、nの実際の値は GL_STENCIL_BITS で取得できる。

実はビット数値をそのまま表示して何だこの中途半端な3175という数字は、ということをやっていたのですが、glGet*() 系の関数で取得するんですね。iPhone プロジェクトで8でした(on シュミレータ)。つまり値としては0〜255、ビット単位では8桁、ということになります。

dpfail と dppass の引数についてはデプステストの結果が出たあとの処理を書くことになる。引数 sfail と同様に↑の8つの定数を指定する。

初期状態ではステンシルテストは無効。もしステンシルバッファーが無いのならが、ステンシルはテストされず、すべてのピクセルに対して描画は許可される。

さて。ここからは OpenGL de プログラミングさんのコード を参考に考えていきます。一部修正しています。/* */ のコメントの箇所は実際には何らかのコードを書く場所で、// はただのコメント。

読む前に簡単に振り返っておくと、 glStencilFunc() は「ステンシルテストの検査方法を指定」し、glStencilOp は「ステンシルテストの結果によってステンシルバッファの内容をどう変化させるか」ということを設定する。

※下記のコードは仮想的なものです。

void setUp()
{
    //========================================
    /* 初期設定の処理 */
    //========================================
    
    glEnable(GL_DEPTH_TEST);
    glEnable(GL_STENCIL_TEST);//ステンシルテストを有効化
}

void renderAtEachFrame()
{
    // 前回の描画情報を掃除
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT| GL_STENCIL_BUFFER_BIT);
    
    //========================================
    /* その他準備の処理 */
    //========================================
    
    // 以降で描くポリゴンなどは絵として描かない
    glColorMask(0,0,0,0);
    glDepthMask(0);

    // 値はステンシルテストで検査せず(GL_ALWAYS)、そのまま書き込みをする。
    glStencilFunc(GL_ALWAYS, 1, ~0);

    // テスト合格でステンシル値を glStencilFunc() の第2引数の値に書き換える(GL_REPLACE)
    // ステンシルバッファがない場合はステンシル値はそのまま(第1引数=GL_KEEP)
    glStencilOp(GL_KEEP, GL_REPLACE, GL_REPLACE);
    
    //========================================
    /* ポリゴンなどでステンシルの型紙部分を描く処理 */
    // 「描かない」宣言をしてるので、ここで描いたオブジェクトは絵には反映されない。
    // ステンシルバッファにのみ書き込まれる。
    //========================================

    // 以降で描くポリゴンなどは絵として描く
    glColorMask(1,1,1,1);
    glDepthMask(1);

    // 以降でステンシル値は書き換えない
    glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);

    // ステンシル値が1の所だけを書き込む
    glStencilFunc( GL_EQUAL, 1, ~0);

    //========================================
    /* ポリゴンなどで実際の絵を描く処理その1 */
    // 個々のピクセルについてステンシル値が1ならば描画される。
    //========================================

    // ステンシル値が0の所だけを書き込む
    glStencilFunc(GL_EQUAL, 0, ~0);
    
    //========================================
    /* ポリゴンなどで実際の絵を描く処理その2 */
    // 個々のピクセルについてステンシル値が0ならば描画される。
    //========================================
}

ここからはいろいろテストしながら実際の処理を確認して行きましょう。はっきり言ってステンシル値のあたりがよくわかってません。

とりあえずテスト用のポリゴンを用意。

 

左が実際に描画するポリゴン4枚(ナンバリングは下から順に0, 1, 2, 3)。右が型紙として使用するポリゴン2枚です(左から0, 1)。

まずステンシルの書き込みですが、左はステンシル値1で、右は2で書き込んでみます。※コードは Objective-C, iPhone5.1 ベース。(ちなみに GLKView に対してステンシルバッファを使用することを明示しなければならない。←はまった。)

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{    
    glEnable(GL_DEPTH_TEST);
    glEnable(GL_STENCIL_TEST);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

    glClearColor(0.0f, 0.0f, 0.0f, 1.0);// 背景色

    /* いろいろ必要な処理 */

    // 描画開始
    [self.effect prepareToDraw];

    // ステンシルの設定を開始しまーす。
    glStencilOp(GL_KEEP,GL_REPLACE,GL_REPLACE);// 以降はステンシル値を書き込みます。
    glColorMask(0,0,0,0);
    glDepthMask(0);

    // ここからはステンシル値を1で書き込みます。
    glStencilFunc(GL_ALWAYS, 1, ~0);
    
    // 型紙1枚目描画
    glBindVertexArrayOES(stencilVertexArrayIDs_[0]);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // ここからはステンシル値を2で書き込みます。
    glStencilFunc(GL_ALWAYS, 2, ~0);
    
    // 型紙2枚目を描画
    glBindVertexArrayOES(stencilVertexArrayIDs_[1]);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // ここからは絵(ポリゴン)を描きまーす。
    // 設定を絵の描画用に戻す。
    glColorMask(1,1,1,1);
    glDepthMask(1);
    glStencilOp(GL_KEEP,GL_KEEP ,GL_KEEP);// 以降はステンシル値は変更しない。
    
    // ここからはステンシル値がxの部分だけ描画します。
    glStencilFunc(GL_EQUAL, x, ~0);// ※
    
    for (int i=0; i<4; i++)
    {
        glBindVertexArrayOES(vtxArrayIDs_[i]);
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }
    
    glBindVertexArrayOES(0);
}

ここで、上のコードの※の関数の第2引数xを0, 1, 2にしてみます。1つずつ見ていきましょう。

※の第2引数x=1→ステンシル値が1ならば描画

ステンシル値が1に設定されているのは左側の型紙の箇所だけ。というわけで左側の型紙と重複される箇所だけが描画されます。

※の第2引数x=2→ステンシル値が2ならば描画
同様に、ステンシル値が2に設定されているのは右の型紙のとこだけ。右の型紙と重複される箇所だけが描画されます。

※の第2引数x=0→ステンシル値が0ならば描画
ステンシル値の初期値はゼロに設定されています。 2つの型紙がある部分に関してはステンシル値が1と2に書き換えられてしまっていますが、残りの部分はステンシル値は初期値のゼロのままです。よって型紙以外の部分の絵が描画されます。

もう少し遊んでみましょう。コードの※の箇所をすぐ下にある forループの中に入れてしまい、ステンシルの比較値はループの i を使います。

    for (int i=0; i<4; i++)
    {
        glStencilFunc(GL_EQUAL, i, ~0);
        
        glBindVertexArrayOES(vtxArrayIDs_[i]);
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }

下から順に比較値は0, 1, 2, 3

どうでしょう、予想したとおりになりましたか?

一番下のステンシル比較値はゼロ。よってステンシル値がゼロに該当する部分、つまり型紙の外だけ描画。

下から2番目のポリゴンの比較値は1。よってステンシル値が1である部分、つまり左側の型紙の中だけ描画されます。

1つ飛ばして一番上のポリゴンの比較値は3。ステンシル値=3となる箇所はないので、このポリゴンは描画されません。

うんうん、だいたい分かって来ましたね。glStaincliFunc() の機能が2重なのがまぎらわしいですが。

じゃあ型紙が重なってしまったときはどうなるの?というわけでやってみましょう。

型紙の形を変更します。



ちょっとわかりずらいかな?四角形などの方がわかりやすいかもしれませんが。

これを型紙として同じように描画してみると。



下から3つめのポリゴンは良いとして、下から2つめのポリゴンは右側の型紙でオーバーラップされた箇所が見えなくなってしまいました。本当は左側の型紙だけで切り抜きたいのですが。

さてどうしますかね。。。というわけで。シンプルな解決策が見つかったら続きを書こうと思います。誰か教えてエライ人っ。

と、思いましたが意外と単純なことでした。1時間ほどで解決。手順として

左の型紙→右の型紙→ポリゴン4枚

という順番で行なっていましたが、これを

左の型紙→下2つのポリゴン→右の型紙→上2つのポリゴン

の順番で行うと、思ったようになりました。GPU に渡した時点で型紙の位置は固定だと思ってましたが、描画の順番によるようですね。



これで複数マスクはできそうです。ステンシル自体についてもおおよそ理解できたかなと思います。

以上、ステンシルバッファのもろもろでした。

と思いきや、 毎回ステンシルバッファにポリゴンなどを描画するのって非効率ですよね。型紙が変わらないならはじめにステンシルバッファを作ってしまって、あとは絵を描画するときに型紙だけ指定すればオッケイにしたいんですが、やり方が思いつきません。どうしたもんですかね。。。
(追記:多分引数 mask で制限すればよさそう。)

それから iOS の環境ではステンシルは8枚(8ビットだから)なので、個人的には違う方法を模索することになるかも、という状況です。8つマスク書いて→クリア→8マスク→クリア→…でも行けそうですがあまり現実的ではありません。ちょびショックー。

10 件のコメント:

  1. サンプルコード公開とかしないんです?

    返信削除
    返信
    1. > 前田さん
      プロジェクトを探してみたのですが、どこかに行ってしまいました。というわけで公開予定は無しということで。

      読み返してみると色々不備のある記事でもありますので、コード公開含めて他の場所で書けたらと思ってます(時間あれば)。

      削除
  2. わざわざ返信をありがとうございます。
    私もiOSでステンシルバッファを使いたかったので、ちょうどいい記事が
    あったと思い質問させていただきました。

    そこでなのですが、もしお時間がよろしければ上記コードの
    [self.effect prepareToDraw];で
    具体的にどんなことをやっているのかを教えていただけないでしょうか。
    stencilVertexArrayIDs_やvtxArrayIDs_の定義や設定をおこなっているのでしょうか。

    返信削除
    返信
    1. > 前田さん
      self.effect は GLKBaseEffect クラスのインスタンス変数であり、このクラスの prepareToDraw メソッドを呼んでいます。

      GLKBaseEffect クラスはテクスチャやライトなどを扱う便利クラスで、prepareToDraw は描画の前に呼ぶこと、とあったように思います。Xcode の新規プロジェクトで OpenGL のテンプレートから作成すると同様のコードがあると思います(少なくとも Xcode4 では)。

      stencilVertexArrayIDs_やvtxArrayIDs_の定義や設定については初期化段階で行います。お湯をわかすために毎回お隣さんにヤカンを借りに行くことはしないですね。ヤカンを買っておいて、描画関数 glkView:drawInRect の中で水を入れて、火にかけてといったことをやっています。もちろんお湯が湧いたら火を止めることも忘れずに。

      削除
  3. ありがとうございます。本当に何度もしつこく申し訳ないのですが、
    // 型紙1枚目描画
    glBindVertexArrayOES(stencilVertexArrayIDs_[0]);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    ここの処理は
    GLfloat triv[] = {
    0, 0,
    0, 500,
    420,400,
    };

    //長方形を構成する四つの頂点の色を指定します
    //ここではすべての頂点を同じ色にしています
    const GLubyte triangleColors[] = {
    static_cast(red), static_cast(green), static_cast(blue) ,static_cast(alpha),
    static_cast(red), static_cast(green), static_cast(blue) ,static_cast(alpha),
    static_cast(red), static_cast(green), static_cast(blue) ,static_cast(alpha),
    };

    //三角形を描画します
    glVertexPointer(2, GL_FLOAT, 0, triv);
    glEnableClientState(GL_VERTEX_ARRAY);
    glColorPointer(4, GL_UNSIGNED_BYTE, 0, triangleColors);
    glEnableClientState(GL_COLOR_ARRAY);

    glDrawArrays(GL_TRIANGLES, 0, 3);
    これでも可能ですかね?
    やっぱりGLuintを使った描画方法でステンシルバッファに書き込みを
    しないといけないでしょうか。
    素人ですみません。

    返信削除
    返信
    1. > 前田さん

      始めは誰でも素人です。僕もまだ素人の範疇だと思います。

      glVertexPointer() や glColorPointer() は OpenGLES1 の関数でありまして、残念ながら自分は GLES1 はほぼやったことがありません。なので上のコードが正しく動くかはわからないです。

      ただ、VAO や VBO を使う描画方法(GLuintを使った描画方法)は必須ではありません。方法はどうあれ、結局は三角形が描ければ良いのです。

      型紙だけを描くプロジェクトを作って表示ができたら、その描画の部分だけ抜き出してステンシルバッファとして描画する、という手順だと理解もしやすいと思います。

      削除
  4. あけましておめでとうございます。
    何度も何度もすみません。
    ステンシルバッファを使用するための設定はどのように
    おこなっているのでしょうか。
    どうやら
    http://sonson.jp/?p=62
    などを見るとステンシルバッファを使うには
    特定の設定の処理が必要らしいのですが、
    もしかして[self.effect prepareToDraw];
    がそれにあたるのでしょうか。

    返信削除
    返信
    1. > 前田さん

      「ステンシルバッファを使うための初期設定」という意味では iOS ならば

      GLKView *view = (GLKView *)self.view;
      view.context = self.context;
      view.drawableDepthFormat = GLKViewDrawableDepthFormat24;
      view.drawableStencilFormat = GLKViewDrawableStencilFormat8;// これ

      が必要だと思います。ステンシルバッファを有効にするコードです。これは描画領域に対する初期設定なので、つまりはヤカンを買っておくの手順ですので、viewDidLoad などで最初に一度だけ行います。

      後は本ページと sonson.jp のページの手順でステンシルバッファを利用することができるはずです。

      削除
  5. お礼を書き忘れていました。
    できました。何度もありがとうございました。

    返信削除