床井研究室

輪講の当番

輪講の当番を学生さん自身に決めてもらったら、その中になぜか私も組み込まれていたので、GPU Gems 2Chapter 8: Per-Pixel Displacement Mapping with Distance Functions を取り上げてみました。これは Displacement Mapping を高速化するために 3D テクスチャ (Distance Map) を使うという、結構男っぽい手法です。これを自分で実装してみる気力は今の私には微塵も無いので、とりあえずこの論文の Previous Work として引用されている視差マッピング (Parallax Mapping) を試してみることにしました。視差マッピングは t-pot さんも紹介されていますが、ここでは GLSL による実装を考えてみます(ったって t-pot さんのと変わらんのだけども)。

バンプマッピングの問題点

いま、次のようなへこみのある四角形を描くことを考えます。

へこみのある四角形

このへこみをバンプマッピングで表現しているとき、この四角形を斜めから平行投影すると、次のような表示が得られます。

バンプマッピングによるへこみの表現

しかし、このへこみが実際にへこんだ形をしていれば、この四角形は次のように表示されるはずです。

実際のへこみの表現

つまり実際にへこんだ形状では、視点からの距離がへこんだ部分と周囲とで異なるために、斜めから見たときにこのようなずれが生じます。ところがバンプマッピングでは、陰影の変化のみによってへこみの表現を行っているために、このずれを表現することができません。視差マッピングではこのずれを再現することによって、よりリアルな凹凸を表現することができます。

視差マッピングはもともとバーチャルリアリティの研究において開発された手法のようです。バーチャルリアリティにおいて両眼視差による立体視表示を行う際、バンプマッピングでは凹凸に視差の影響が反映されないために、凹凸の立体感が消失してしまいます。視差マッピングはバンプマッピングに視差の影響を加味することによって、この問題を解消することができます。

ディフューズテクスチャを追加する

雛型にはGLSL によるバンプマッピングで作成したプログラムを使います。まず最初に、これにディフューズテクスチャ(拡散反射光成分のテクスチャ)のマッピングを追加します。テクスチャには、以前に使ったこの画像を使います。

シェーダのソースプログラムは、bump.vert と bump.frag を parallax.vert と parallax.frag という名前でコピーしたものを使います(アプリケーションプログラムが readShaderSource() で読み込むファイル名も、それに合わせて変更してください)。

マルチテクスチャを使うので、法線マップのサンプラの uniform 変数の場所 colorLoc に加えて、ディフューズテクスチャのサンプラの uniform 変数の場所 dcolorLoc を用意しておきます。

/*
** テクスチャのサンプラの uniform 変数の場所
*/
static GLuint colorLoc;
static GLuint dcolorLoc;

シェーダプログラムのリンクに成功したら、それぞれの uniform 変数の場所を取り出します。なお Windows では glActiveTexture() が使えるようにしておく必要があります(この関数のエントリポイントは glslInit() でも取得しています)。

  /* 接線ベクトルを渡すために使う attribute 変数の場所を得る */
  tangentLoc = glGetAttribLocation(gl2Program, "tangent");

  /* テクスチャのサンプラの uniform 変数の場所を得る */
  colorLoc = glGetUniformLocation(gl2Program, "color");
  dcolorLoc = glGetUniformLocation(gl2Program, "dcolor");

#if defined(WIN32)
  glActiveTexture =
    (PFNGLACTIVETEXTUREPROC)wglGetProcAddress("glActiveTexture");
#endif

テクスチャユニット0には、これまで通り高さマップ dotbump.raw から作成した法線マップを割り当てます。そのあとテクスチャユニット1に切り替えて、ディフューズテクスチャ dot.raw を割り当てます。

  /* テクスチャユニット1を指定する */
  glActiveTexture(GL_TEXTURE1);

  /* テクスチャユニット1用のテクスチャオブジェクトの作成と結合 */
  GLuint dtex;
  glGenTextures(1, &dtex);
  glBindTexture(GL_TEXTURE_2D, dtex);

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

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

  /* テクスチャ画像の読み込み */
  FILE *fp = fopen("dot.raw", "rb");
  if (fp != NULL) {
    fread(texture, sizeof texture, 1, fp);
    fclose(fp);
  }

  /* テクスチャの割り当て */
  glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, TEXWIDTH, TEXHEIGHT, 0,
    GL_RGBA, GL_UNSIGNED_BYTE, texture);

プログラムオブジェクトからディフューズテクスチャのサンプラ用 uniform 変数 dcolor の場所を取り出し、シェーダプログラムの実行時にこれにテクスチャユニット番号1を設定します。

...

/*
** プログラムオブジェクト
*/
static GLuint gl2Program;

/*
** 接線ベクトルを格納する attribute 変数の場所
*/
static GLint tangentLoc;

/*
** 法線マップのサンプラの uniform 変数の場所
*/
static GLuint colorLoc;

/*
** ディフューズテクスチャのサンプラの uniform 変数の場所
*/
static GLuint dcolorLoc;

...

static void init()
{
  ...

  /* シェーダプログラムのリンク */
  glLinkProgram(gl2Program);
  glGetProgramiv(gl2Program, GL_LINK_STATUS, &linked);
  printProgramInfoLog(gl2Program);
  if (linked == GL_FALSE) {
    fprintf(stderr, "Link error.\n");
    exit(1);
  }

  /* テクスチャのサンプラの uniform 変数の場所を得る */
  colorLoc = glGetUniformLocation(gl2Program, "color");
  dcolorLoc = glGetUniformLocation(gl2Program, "dcolor");

  /* 接ベクトルを渡すために使う attribute 変数の場所を得る */
  tangentLoc = glGetAttribLocation(gl2Program, "tangent");
}

...

/*
** 描画
*/
static void display()
{
  /* シェーダプログラムの適用 */
  glUseProgram(gl2Program);

  /* テクスチャのサンプラにテクスチャユニットを指定する */
  glUniform1i(colorLoc, 0);
  glUniform1i(dcolorLoc, 1);

  ...
}

フラグメントシェーダにディフューズテクスチャのサンプラ用 uniform 変数 dcolor を追加し、フラグメントの色にディフューズテクスチャの色を反映します。

#version 120

// bump.frag

// ラスタライザから受け取る接空間の光線ベクトルの補間値
varying vec3 tlight;

// ラスタライザから受け取る接空間の視線ベクトルの補間値
varying vec3 tview;

// テクスチャのサンプラ
uniform sampler2D color;

// 拡散反射色のサンプラ
uniform sampler2D dcolor;

void main ()
{
  // テクスチャから画素の色を得る
  vec4 fcolor = texture2DProj(color, gl_TexCoord[0]);

  // 接空間における法線ベクトル
  vec3 normal = fcolor.rgb * 2.0 - 1.0;

  // 接空間における光線ベクトル
  vec3 light = normalize(tlight);

  // 拡散反射率
  float diffuse = max(dot(normal, light), 0.0);

  // 接空間における視線ベクトル
  vec3 view = normalize(tview);

  // 接空間における中間ベクトル
  vec3 halfway = normalize(light + view);

  // 鏡面反射率
  float specular = pow(max(dot(normal, halfway), 0.0),
    gl_FrontMaterial.shininess);

  // 拡散反射色
  vec4 ecolor = texture2DProj(dcolor, gl_TexCoord[0]);

  gl_FragColor = gl_FrontLightProduct[0].ambient * ecolor
               + gl_FrontLightProduct[0].diffuse * diffuse * ecolor
               + gl_FrontLightProduct[0].specular * specular;
}

法線マップに高さマップを埋め込む

視差マッピングでは、フラグメントシェーダ内で高さマップを参照して、テクスチャをサンプリングする位置(テクスチャ座標)をずらします。しかし、テクスチャユニットには限りがあるので、高さマップ専用にテクスチャユニットを消費してしまうのは避けたいところです。 幸い、法線マップは RGB の3チャンネルしか使っておらず、アルファチャンネルが空いています。そこで、法線マップのアルファチャンネルに高さマップを埋め込んでしまいます。関数 makeNormalMap() を次のように変更します。

/*
** 高さマップをもとに法線マップを作成する
*/
void makeNormalMap(void *data, int width, int height, double nz, const char *name)
{
  GLubyte *tex = (GLubyte *)data;
  FILE *fp = fopen(name, "rb");

  if (fp) {
    unsigned char *map = (unsigned char *)malloc(width * height);

    if (map) {
      unsigned long size = width * height;

      /* 高さマップを読み込む */
      fread(map, height, width, fp);
      fclose(fp);

      for (unsigned long y = 0; y < size; y += width) {
        for (int x = 0; x < width; ++x) {
          /* 隣接する画素との値の差を法線ベクトルの成分に用いる */
          double nx = map[y + x] - map[y + (x + 1) % width];
          double ny = map[y + x] - map[(y + width) % size + x];
          /* 法線ベクトルの長さを求めておく */
          double nl = sqrt(nx * nx + ny * ny + nz * nz);

          *(tex++) = (GLubyte)(nx * 127.5 / nl + 127.5);
          *(tex++) = (GLubyte)(ny * 127.5 / nl + 127.5);
          *(tex++) = (GLubyte)(nz * 127.5 / nl + 127.5);

          /* アルファ値に高さマップを埋め込む */
          *(tex++) = map[y + x];
        }
      }

      free(map);
    }
    else {
      fprintf(stderr, "Can't allocate memory\n");
    }
  }
  else {
    fprintf(stderr, "Can't open file: %s\n", name);
  }
}

視差マッピングの実装

ようやくここから本題です。物体表面に高さマップを重ねたと考えると、それによる注視点位置のずれは下図の様になります。ただし、これは高さマップが十分なだらかであると仮定した場合の近似です。

注視点のずれ

したがってフラグメントシェーダ内で高さマップを参照し、それに視線の方向ベクトルを掛けて、テクスチャ座標をずらす量を決定します。そのために、先に接空間における視線の方向単位ベクトル view を求めます。

#version 120

// parallax.frag

// ラスタライザから受け取る接空間の光線ベクトルの補間値
varying vec3 tlight;

// ラスタライザから受け取る接空間の視線ベクトルの補間値
varying vec3 tview;

// テクスチャのサンプラ
uniform sampler2D color;

// 拡散反射色のサンプラ
uniform sampler2D dcolor;

void main ()
{
  // テクスチャ要素の色を得る
  vec4 fcolor = texture2DProj(color, gl_TexCoord[0]);

  // 接空間における視線ベクトル
  vec3 view = normalize(tview);

視線の方向ベクトルの xy 成分に法線マップのアルファチャンネルに入れておいた高さマップの値を乗じます。これをテクスチャ座標から引いて、ずらしたテクスチャ座標 texcoord を求めます。係数の 0.02 はバンプの実際の高さの最高値のようなもので、この値が大きいほどバンプが強くなります。しかし、バンプ内での隠面消去処理を行っていないので、あまり大きな値を設定すると不自然な表示になります。

  // 高さマップを使ってテクスチャ座標を接空間の視線ベクトル方向にずらす
  vec2 texcoord = gl_TexCoord[0].xy - view.xy * fcolor.a * 0.02;

ずらしたテクスチャ座標を使って法線マップをサンプリングし、法線ベクトル normal を求めます。また、ディフューズテクスチャもこのテクスチャ座標を使ってサンプリングします。あとは、これまでと同じ手順で陰影を求めます。

  // ずらしたテクスチャ座標を使って法線マップを取り出して法線ベクトルを求める
  vec3 normal = vec3(texture2D(color, texcoord)) * 2.0 - 1.0;

  // ずらしたテクスチャ座標を使って拡散反射色を取り出す
  vec4 dcolor = texture2D(dcolor, texcoord);

  // 接空間における光線ベクトル
  vec3 light = normalize(tlight);

  // 拡散反射光強度
  float diffuse = max(dot(normal, light), 0.0);

  // 接空間における中間ベクトル
  vec3 halfway = normalize(light + view);

  // 鏡面反射光強度
  float specular = pow(max(dot(normal, halfway), 0.0),
    gl_FrontMaterial.shininess);

  // フラグメントの色
  gl_FragColor = gl_FrontLightProduct[0].ambient * dcolor
               + gl_FrontLightProduct[0].diffuse * diffuse * dcolor
               + gl_FrontLightProduct[0].specular * specular;
}

バンプマッピングと視差マッピングを見比べてみます。左がバンプマッピングで、右が視差マッピングです。視差マッピングのほうがバンプが強く出るというか、全体的に盛り上がったような感じになっています。また視点の移動によって盛り上がり部分のずれが変化するため、実際にへこみがあるように感じられます(よくわからないという人は、前述の係数 0.02 を 0.05 くらいにしてみてください)。

バンプマッピング 視差マッピング

視差マッピングの問題点

視差マッピングの問題点は、前述のとおりバンプ自体の隠面消去処理を行っていないために、バンプを強くしたり浅い角度から眺めたりしたときに、膨らんだところに隠されて見えないはずの部分が見えてしまうところにあります。また、高さマップが急激に変化するような場合は、得られる注視点のずれの誤差が大きくなってしまうという問題もあります。

注視点のずれの誤差

これらを解消するには、視線が高さマップと交差する位置を正確に求める、いわゆるディスプレースメントマッピングを用いる必要があります。ただ、実際にこれを実現しているレリーフマッピングや輪講で取り上げた Per-Pixel Displacement Mapping with Distance Functions は、いずれも視線と高さマップの交点をもとめるためにレイトレーシング的な手法を用いています。

なおレリーフマッピングでは、多層の高さマップを用いて近似的な交点を高速に求める手法が提案されています。また Per-Pixel Displacement Mapping with Distance Functions は、高さマップからの距離を 3D テクスチャに格納した Distance Map に対して Sphere Tracing というレイトレーシングの高速化手法を実行することによって、交点の算出を効率化しています。