床井研究室

1年もかかってしまった

実はこの blog でテクスチャマッピングのシリーズを書き始めた頃に、ある人に「そのうち影付けの方法について説明を書く」と約束していました。ところが、ここにたどり着くまでに、結局丸1年かかってしまいました。面目ありません。でも、OpenGL 1.4 で標準機能に取り込まれたこの機能自体、それまでの OpenGL の機能拡張の集大成とも思える部分もあります。

実際これ以降の機能拡張は、キューブマッピングやシャドウマッピングのような固定機能の機能追加から、プログラマブルシェーダによる機能増強に重点が移っています。シャドウマッピングを実装した時点で、こういうスタイルでの機能追加は、もう限界に来たということかも知れません。

というわけで、今回もサンプルプログラムをもとに解説します。なおこのプログラムは、ビデオカードが OpenGL 1.4 以降をサポートしていなければ、正常に動作しません。

影付け処理

影の存在は単にシーンのリアリティを向上するだけでなく、オブジェクトやキャラクタの「接地感」の表現や、オブジェクト同士の相対的な位置関係の把握などに重要な役割を果たします。しかし、影付け処理は比較的コストの高い処理のため、しばしばこれはレンダリングのパフォーマンスとのトレードオフとなります。

影付け処理の手法には、あらかじめテクスチャに影を「焼き込む」ライトマップや、影をテクスチャとして投影するプロジェクティブシャドウ、シャドウボリューム法をステンシルバッファを使って効率的に処理するステンシルシャドウ、それに今回説明するシャドウマップ法など、様々なものが提案されています。

このうちシャドウマップ法はセルフシャドウ(凹部をもつオブジェクトが自分自身に落とす影)も正しく生成できるなど、影の生成における制限の少ない方法です。しかし、効率の面から、そのほかの方法もよく使用されます。影付け処理はベストな方法が存在するというものではなく、色々な手法を適材適所で使い分けるというのが一般的なようです。

シャドウマッピング法

シャドウマッピング法はテクスチャマッピングの機能を使って影付け処理を実現する手法です。

シャドウマッピング

以下にこの手順の概略を示します。

  1. 上図において視点から見た図形をレンダリングする際、図形の表面上のある点(参照点と呼ぶことにします)が影の領域に入っていなければ日向、影の領域に入っていれば影として、その点の陰影を求めます。この処理を行うためには、参照点のワールド座標形状での位置を求める必要があります。これはテクスチャ座標の自動生成を行うことによって、テクスチャ座標として得ることができます。
  2. 次に、参照点が影の領域にあるかどうかを判定するために、参照点のテクスチャ座標を光源位置に視点を置いた空間上に座標変換するよう、テクスチャ変換行列を設定します。こうして光源位置に視点を置いた空間における参照点の座標値を、テクスチャ座標値として得ます。
  3. 最後に、参照点が光源から見えるかどうかを判定します。参照点が光源から見えていれば、参照点は日向になります。参照点が他のオブジェクトに隠されている場合は、参照点は影になります。したがって、この判定は隠面消去処理と同じです。
  4. そこで、あらかじめ光源位置に視点を置いてレンダリングし、そのデプスバッファの内容をテクスチャメモリに転送しておきます。そして通常のテクスチャマッピングと同様に、参照点のテクスチャ座標を用いてこのテクスチャをサンプリングします。そしてサンプリングされた値(Z値)とテクスチャ座標のZ成分を比較し、テクスチャ座標のZ成分の方が小さければ参照点を日向とします。

以降、次のようなシーンに対して影付け処理を追加する方法について説明します(ファイル main.cpp)。

元のシーン

デプステクスチャ

テクスチャの画素の値として、色ではなく奥行き値を保持しているものを、テプステクスチャ、あるいはデプスマップと呼びます。デプステクスチャは隠面消去処理に用いるデプスバッファの内容として得られます。テクスチャとしてデプステクスチャを用いるには、glTexImage2D()targetformatGL_DEPTH_COMPONENT を指定します。

/*
** 初期化
*/
static void init()
{
  /* テクスチャの割り当て */
  glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, TEXWIDTH, TEXHEIGHT, 0,
    GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE, 0);

  /* テクスチャを拡大・縮小する方法の指定 */
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

  /* テクスチャの繰り返し方法の指定 */
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);

デプステクスチャは色の情報ではないので、マッピングしたときも色としては用いられません。そのかわり、ある画素にマッピングされた値は、その画素におけるテクスチャ座標との比較に用いることができます。ここではテクスチャ座標 (s t r q) によってサンプリングされたテクスチャの値と、そのテクスチャ座標の r 成分との比較を行うよう設定します。

  /* 書き込むポリゴンのテクスチャ座標値のRとテクスチャとの比較を行うようにする */
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE);

  /* もしRの値がテクスチャの値以下なら真(つまり日向) */
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

GL_TEXTURE_COMPARE_FUNC に指定するテクスチャの比較方法には、OpenGL 1.4 では GL_GEQUALGL_LEQUAL が指定できます。ちなみに OpenGL 1.5 以降では、他に GL_NEVER, GL_LESS, GL_EQUAL, GL_NOTEQUAL, GL_GREATER, および GL_ALWAYS が指定できます。

この比較の結果を GL_LUMINANCE(輝度値)としてポリゴンにマッピングします。この輝度値は、比較の結果が真なら 1、偽なら 0 となるので、この値によって下地の色が変調されて、真なら下地の色がそのまま現れ、偽なら黒がマッピングされることになります。

  /* 比較の結果を輝度値として得る */
  glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_LUMINANCE);

あとはテクスチャ座標の自動生成の設定をします。テクスチャ座標は視点座標系で生成し、それがそのまま (s t r q) に用いられるようにします。

  /* テクスチャ座標に視点座標系における物体の座標値を用いる */
  glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
  glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
  glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
  glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);

  /* 生成したテクスチャ座標をそのまま (S, T, R, Q) に使う */
  static const GLdouble genfunc[][4] = {
    { 1.0, 0.0, 0.0, 0.0 },
    { 0.0, 1.0, 0.0, 0.0 },
    { 0.0, 0.0, 1.0, 0.0 },
    { 0.0, 0.0, 0.0, 1.0 },
  };
  glTexGendv(GL_S, GL_EYE_PLANE, genfunc[0]);
  glTexGendv(GL_T, GL_EYE_PLANE, genfunc[1]);
  glTexGendv(GL_R, GL_EYE_PLANE, genfunc[2]);
  glTexGendv(GL_Q, GL_EYE_PLANE, genfunc[3]);

デプステクスチャの作成

デプステクスチャは光源位置から見たシーンをレンダリングして作成します。そのためにはレンダリング画像をテクスチャに使うテクニックを用いる必要があります。光源位置から見たシーンをレンダリングした後、視点位置から見てレンダリングするので、モデルビュー変換行列や透視変換行列、それにビューポートなどを一旦保存しておく配列を用意しておきます。このほか、アニメーションに用いる時間の変数も求めておきます。

/* アニメーションのサイクル */
#define FRAMES 360

static void display()
{
  static int frame = 0;    /* フレーム数のカウント     */
  double t = (double)frame / (double)FRAMES; /* 経過時間  */

  /* アニメーションのサイクルごとにフレーム数をリセットする */
  if (++frame >= FRAMES) frame = 0;

  GLint viewport[4];       /* ビューポートの保存用     */
  GLdouble modelview[16];  /* モデルビュー変換行列の保存用 */
  GLdouble projection[16]; /* 透視変換行列の保存用     */

デプステクスチャを作成する場合はフレームバッファは用いないので、デプスバッファのみをクリアしておきます。またビューポートはテクスチャのサイズに、透視変換行列は単位行列(つまり無変換)に設定しておきます。

  /*
  ** 第1ステップ:デプステクスチャの作成
  */

  /* デプスバッファをクリアする */
  glClear(GL_DEPTH_BUFFER_BIT);

  /* 現在のビューポートを保存しておく */
  glGetIntegerv(GL_VIEWPORT, viewport);

  /* ビューポートをテクスチャのサイズに設定する */
  glViewport(0, 0, TEXWIDTH, TEXHEIGHT);

  /* 現在の透視変換行列を保存しておく */
  glGetDoublev(GL_PROJECTION_MATRIX, projection);

  /* 透視変換行列を単位行列に設定する */
  glMatrixMode(GL_PROJECTION);
  glLoadIdentity();

モデルビュー変換行列には、視点を光源位置に置き、シーン全体が視野に収まるよう透視変換とビュー変換を設定します。後で透視変換とビュー変換の合成変換が必要になるので、透視変換行列は使用せずに、モデルビュー変換行列に透視変換とビュー変換を合成しておきます。ここでは陰影付けを行わないので、このようにしても問題ありません。また、求めたモデルビュー変換行列の内容を保存しておきます。

  /* 光源位置を視点としシーンが視野に収まるようモデルビュー変換行列を設定する */
  glMatrixMode(GL_MODELVIEW);
  glLoadIdentity();
  gluPerspective(40.0, (GLdouble)TEXWIDTH / (GLdouble)TEXHEIGHT, 1.0, 20.0);
  gluLookAt(lightpos[0], lightpos[1], lightpos[2], 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);

  /* 設定したモデルビュー変換行列を保存しておく */
  glGetDoublev(GL_MODELVIEW_MATRIX, modelview);

あとはフレームバッファへの書き込みを禁止し、陰影付けもオフにしておきます。背面ポリゴンを描画するようにしているのは、影の領域がオブジェクトの(光源に対する)背面から始まるようにするためです。こうしないと影の領域がオブジェクトの光の当たっている面と重なってしまい、影が日向の領域にはみ出てしまうことがあります。

  /* デプスバッファの内容だけを取得するのでフレームバッファには書き込まない */
  glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);

  /* したがって陰影付けも不要なのでライティングをオフにする */
  glDisable(GL_LIGHTING);

  /* デプスバッファには背面のポリゴンの奥行きを記録するようにする */
  glCullFace(GL_FRONT);

  /* シーンを描画する */
  scene(t);

この結果のデプスバッファの内容をテクスチャメモリに転送します。こうしてシーンを光源から見た時の奥行きがデプステクスチャに得られます。

  /* デプスバッファの内容をテクスチャメモリに転送する */
  glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, TEXWIDTH, TEXHEIGHT);

描画の方法を通常に戻します。ビューポートと透視変換行列を元に戻し、フレームバッファへの描画と陰影付けを有効にします。また、前面ポリゴンを描画するようにします。

  /* 通常の描画の設定に戻す */
  glViewport(viewport[0], viewport[1], viewport[2], viewport[3]);
  glMatrixMode(GL_PROJECTION);
  glLoadMatrixd(projection);
  glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
  glEnable(GL_LIGHTING);
  glCullFace(GL_BACK);

視点位置から見た図形の描画

次に、視点位置から見た図形の描画を行います。モデルビュー変換行列には、視点位置の設定とトラックボール式の回転を与えます。

  /*
  ** 第2ステップ:全体の描画
  */

  /* フレームバッファとデプスバッファをクリアする */
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

  /* モデルビュー変換行列の設定 */
  glMatrixMode(GL_MODELVIEW);
  glLoadIdentity();

  /* 視点の位置を設定する(物体の方を奥に移動する)*/
  glTranslated(0.0, 0.0, -10.0);

  /* トラックボール式の回転を与える */
  glMultMatrixd(trackballRotation());

  /* 光源の位置を設定する */
  glLightfv(GL_LIGHT0, GL_POSITION, lightpos);

描画するシーンには、デプステクスチャをマッピングします。そのために、テクスチャ変換行列の設定を行います。なお、以下の説明に対して、その下のプログラムの実行順序は逆になっています。

自動生成したテクスチャ座標値はワールド座標系におけるオブジェクトの座標値です。まずこれに、デプステクスチャを生成したときに用いた座標変換に含まれていない、現在のモデルビュー変換の逆変換を行います。これは現在のモデルビュー変換行列を取り出して逆行列を求めても構わないのですが、ここでは実際に行った変換の逆変換を用いています。

これでデプステクスチャを生成したときのオブジェクトのワールド座標値が得られますから、これに保存しておいた光源位置に視点を置いたときのモデルビュー変換と透視変換の合成変換を施して、光源位置を視点とするスクリーン上の座標値に変換します。

こうして得られた座標値は、正規化デバイス座標系(クリッピング座標系)の2点 (-1, -1)、(1, 1) を結ぶ線分を対角線とする領域内にあるので、これを2点 (0, 0)、(1, 1) を結ぶ線分を対角線とするテクスチャ空間の領域に収める変換を行います。

  /*
  ** 第3ステップ:日向の部分を描画する
  */

  /* テクスチャ変換行列を設定する */
  glMatrixMode(GL_TEXTURE);
  glLoadIdentity();

  /* テクスチャ座標の [-1,1] の範囲を [0,1] の範囲に収める */
  glTranslated(0.5, 0.5, 0.5);
  glScaled(0.5, 0.5, 0.5);

  /* テクスチャのモデルビュー変換行列と透視変換行列の積をかける */
  glMultMatrixd(modelview);

  /* 現在のモデルビュー変換の逆変換をかけておく */
  glMultTransposeMatrixd(trackballRotation());
  glTranslated(0.0, 0.0, 10.0);

テクスチャ変換行列の設定が終わったら、設定対象の変換行列をモデルビュー変換行列に戻し、テクスチャマッピングを有効にしてシーンを描画します。

  /* モデルビュー変換行列に戻す */
  glMatrixMode(GL_MODELVIEW);

  /* テクスチャマッピングとテクスチャ座標の自動生成を有効にする */
  glEnable(GL_TEXTURE_2D);
  glEnable(GL_TEXTURE_GEN_S);
  glEnable(GL_TEXTURE_GEN_T);
  glEnable(GL_TEXTURE_GEN_R);
  glEnable(GL_TEXTURE_GEN_Q);

  /* 光源の明るさを日向の部分での明るさに設定 */
  glLightfv(GL_LIGHT0, GL_DIFFUSE, lightcol);
  glLightfv(GL_LIGHT0, GL_SPECULAR, lightcol);

  /* シーンを描画する */
  scene(t);

  /* テクスチャマッピングとテクスチャ座標の自動生成を無効にする */
  glDisable(GL_TEXTURE_GEN_S);
  glDisable(GL_TEXTURE_GEN_T);
  glDisable(GL_TEXTURE_GEN_R);
  glDisable(GL_TEXTURE_GEN_Q);
  glDisable(GL_TEXTURE_2D);

  /* ダブルバッファリング */
  glutSwapBuffers();
}

これで、次のような影が得られます。

影をつけたシーン

見てのとおり、この影は真っ黒で、とても不自然です。また、球の光源に対する裏面も、なんだか汚いことになっています。これは物体の光源に対する背面から始まる影の領域と、物体表面が重なっているために起こります。

影と日向の合成

デプステクスチャによって直接輝度値を制御すると、影の部分の陰影が失われてしまいます。これを防ぐ方法はいくつかあるように思いますが、ここではデプステクスチャとの比較の結果をアルファ値に反映し、アルファテストを使って影と日向の部分を別々にレンダリングして合成する手法について説明します。これまでのプログラムにアルファテストを追加するのでで、その部分を #if USE_ALPHA_TEST#endif で挟むことにします。

/* アルファテストを使う場合は 1 */
#define USE_ALPHA_TEST 1

まず、比較の結果を輝度値 (GL_LUMINANCE) として得る代わりに、アルファ値 (GL_ALPHA) として得ます。また、アルファテストのしきい値を設定しておきます。

/*
** 初期化
*/
static void init()
{
  ...

  /* 書き込むポリゴンのテクスチャ座標値のRとテクスチャとの比較を行うようにする */
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE);

  /* もしRの値がテクスチャの値以下なら真(つまり日向) */
  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);

#if USE_ALPHA_TEST
  /* 比較の結果をアルファ値として得る */
  glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_ALPHA);

  /* アルファテストの比較関数(閾値) */
  glAlphaFunc(GL_GEQUAL, 0.5f);
#else
  /* 比較の結果を輝度値として得る */
  glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_LUMINANCE);
#endif

こうしてアルファテストを有効にして描画すると、日向の部分だけを描画することができます。

日向の部分だけを描画

そこで、日向の部分を描く前に、光源の明るさを落としてシーン全体を描きます。このとき影の部分にハイライトが発生しないよう、光源強度の鏡面反射成分は0にしておきます。

static void display()
{
  ...

  /*
  ** 第2ステップ:全体の描画
  */

  /* フレームバッファとデプスバッファをクリアする */
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

  /* モデルビュー変換行列の設定 */
  glMatrixMode(GL_MODELVIEW);
  glLoadIdentity();

  /* 視点の位置を設定する(物体の方を奥に移動する)*/
  glTranslated(0.0, 0.0, -10.0);

  /* トラックボール式の回転を与える */
  glMultMatrixd(trackballRotation());

  /* 光源の位置を設定する */
  glLightfv(GL_LIGHT0, GL_POSITION, lightpos);

#if USE_ALPHA_TEST
  /* 光源の明るさを影の部分での明るさに設定 */
  glLightfv(GL_LIGHT0, GL_DIFFUSE, lightdim);
  glLightfv(GL_LIGHT0, GL_SPECULAR, lightblk);

  /* シーンを描画する */
  scene(t);
#endif

そしてアルファテストを有効にして、日向の部分を描きます。日向の部分は先に描いた光を落としたシーンに重ねるので、奥行きが一致した部分は後から描いたものが反映されるように、奥行きの比較関数を変更しておきます。

  /* テクスチャマッピングとテクスチャ座標の自動生成を有効にする */
  glEnable(GL_TEXTURE_2D);
  glEnable(GL_TEXTURE_GEN_S);
  glEnable(GL_TEXTURE_GEN_T);
  glEnable(GL_TEXTURE_GEN_R);
  glEnable(GL_TEXTURE_GEN_Q);

#if USE_ALPHA_TEST
  /* アルファテストを有効にして影の部分だけを描画する */
  glEnable(GL_ALPHA_TEST);

  /* 日向の部分がもとの図形に重ねて描かれるように奥行きの比較関数を変更する */
  glDepthFunc(GL_LEQUAL);
#endif

そのあと、行きの比較関数をもとに戻します。

  /* 光源の明るさを日向の部分での明るさに設定 */
  glLightfv(GL_LIGHT0, GL_DIFFUSE, lightcol);
  glLightfv(GL_LIGHT0, GL_SPECULAR, lightcol);

  /* シーンを描画する */
  scene(t);

#if USE_ALPHA_TEST
  /* 奥行きの比較関数を元に戻す */
  glDepthFunc(GL_LESS);

  /* アルファテストを無効にする */
  glDisable(GL_ALPHA_TEST);
#endif

  ...
}

これで、次のように影の部分のオブジェクトにも陰影をつけることができます。

影の部分に陰影をつけて描画

おまけ:ソフトシャドウ

おまけと言っちゃなんですが、main_accum.cppアキュムレーションバッファ glAccum()1 を使った、ソフトシャドウの実装を添付しています。

ソフトシャドウのアニメーション

(ああ、くたびれた)

  1. アキュムレーションバッファは OpenGL 3.0 でコア機能に取り入れられた Framebuffer Object (FBO) の機能で代替できるため、今は使われていません。また、ソフトシャドウはシェーダを使った Percentage Closer Filtering (PCF)Variance Shadow Maps (VSM) などの手法が提案されています。(2026 年 5 月 19 日追記)