床井研究室

バンプマッピングを GLSL で実装してみる

GLSL を使ったバンプマッピングの実装例はオレンジブックにありますが、ここではこれを参考に、以前に書いた dot3 バンプマッピングを使って球にバンプマッピングするサンプルプログラムを、GLSL を使って書き直してみようと思います。

GLSL で書くと楽かもー

以前に「dot3 バンプマッピングは面倒」とか書いてましたけど、GLSL ではこれをかなり簡単に実装できます。特に、接空間における光線ベクトルの算出をバーテックスシェーダ内に実装できるため、モデルの描画に光源を関わらせずにすみ、アプリケーションプログラムはかなりすっきりとしたものになります。ここでは球にバンプマッピングするサンプルプログラムから、バンプマッピングに関する処理を取り除いたものを雛形にします。

このプログラムは高さマップ dotbump.raw から作成した法線マップを、そのままテクスチャとして球に貼り付けたものを表示します。材質には赤色を設定していますが、テクスチャ環境を GL_REPLACE に設定しているので、法線マップがそのまま色として貼り付けられます。

法線マップをそのまま色のテクスチャとして貼り付けた球

シェーダプログラムの読み込み

ファイル main.cpp に、シェーダプログラムを読み込む手続きを追加します。これは第1回と同様です。まず glsl.h を #include して、プログラムオブジェクト名に使う変数 gl2Program を宣言します。

/* OpenGL / GLSL 関連の宣言 */
#include "glsl.h"

/* 法線マップを作成する関数の宣言 */
#include "normalmap.h"

/* 球を描く関数の宣言 */
#include "sphere.h"

/* トラックボール処理用関数の宣言 */
#include "trackball.h"

/* 標準ライブラリ */
#include <stdio.h>
#include <stdlib.h>

/* 1 ならティーポットを描く */
#define DRAW_TEAPOT 0

/*
** 光源
*/
static const GLfloat lightpos[] = { 4.0f, 5.0f, 6.0f, 1.0f }; /* 位置    */
static const GLfloat lightcol[] = { 1.0f, 1.0f, 1.0f, 1.0f }; /* 直接光強度 */
static const GLfloat lightamb[] = { 0.1f, 0.1f, 0.1f, 1.0f }; /* 環境光強度 */

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

バンプマッピングでは接空間の基底ベクトル、すなわち法線ベクトル $\mathbf{n}$、接線ベクトル $\mathbf{t}$、および従接線ベクトル $\mathbf{b}$ を使います。法線ベクトル $\mathbf{n}$ は図形の頂点属性として与えられており、従接線ベクトルは $\mathbf{b} = \mathbf{n}\times\mathbf{t}$ で求めることができるので、このプログラムオブジェクトには、接線ベクトル (接線ベクトル) $\mathbf{t}$ を追加の頂点属性として渡すことにします。

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

あと、もちろんバンプマッピングに必要な法線マップの作製に使う高さマップのテクスチャも用意します。

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

/*
** テクスチャ
*/
#define TEXWIDTH  256                               /* テクスチャの幅    */
#define TEXHEIGHT 256                               /* テクスチャの高さ   */
static const char texture_file[] = "dotbump.raw";   /* テクスチャファイル名 */

そして、初期化の際にシェーダプログラムの読み込み、プログラムオブジェクトを作成します。

/*
** 初期化
*/
static void init()
{
  /* GLSL の初期化 */
  if (glslInit()) exit(1);

  /* シェーダオブジェクトの作成 */
  GLuint vertShader = glCreateShader(GL_VERTEX_SHADER);
  GLuint fragShader = glCreateShader(GL_FRAGMENT_SHADER);

  /* シェーダのソースプログラムの読み込み */
  if (readShaderSource(vertShader, "bump.vert")) exit(1);
  if (readShaderSource(fragShader, "bump.frag")) exit(1);

シェーダプログラムのリンクに成功したら、プログラムオブジェクトから接線ベクトルを渡すために使う attribute 変数の場所と、テクスチャのサンプラの uniform 変数の場所を取り出します。attribute 変数の場所は glGetAttribLocation() 関数、uniform 変数の場所は glGetUniformLocation() を使って調べることができます。

  /* シェーダプログラムのリンク結果 */
  GLint linked;

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

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

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

テクスチャユニット0を指定してテクスチャを作成します。

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

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

  /* テクスチャを拡大・縮小する方法の指定 */
  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);

高さマップを読み込んで法線マップを作成し、テクスチャに格納します。

  /* テクスチャ画像はワード単位に詰め込まれている */
  glPixelStorei(GL_UNPACK_ALIGNMENT, 4);

  /* テクスチャの読み込みに使う配列 */
  GLubyte texture[TEXHEIGHT][TEXWIDTH][4];

  /* 法線マップの作成 */
  makeNormalMap(texture, TEXWIDTH, TEXHEIGHT, 20.0, texture_file);

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

あと、その他の初期設定を行います。

  /* 初期設定 */
  glClearColor(0.3f, 0.3f, 1.0f, 0.0f);
  glEnable(GL_DEPTH_TEST);
  glDisable(GL_CULL_FACE);

  /* 光源の初期設定 */
  glEnable(GL_LIGHTING);
  glEnable(GL_LIGHT0);
  glLightfv(GL_LIGHT0, GL_DIFFUSE, lightcol);
  glLightfv(GL_LIGHT0, GL_SPECULAR, lightcol);
  glLightfv(GL_LIGHT0, GL_AMBIENT, lightamb);
  glLightModeli(GL_LIGHT_MODEL_LOCAL_VIEWER, GL_TRUE);
}

シェーダプログラムの作成

ここでバーテックスシェーダ (bump.vert) とフラグメントシェーダ (bump.frag) に第3回で作成したテクスチャの参照を行うものをそのまま用いれば、この球にテクスチャを貼った状態で陰影を付けることができます。

第3回のバーテックスシェーダ

#version 120

// texture.vert

// ラスタライザに送る視点座標系の頂点の位置
varying vec4 position;

// ラスタライザに送る視点座標系の法線ベクトル
varying vec3 normal;

void main()
{
  // 頂点のクリッピング座標値
  gl_Position = ftransform();

  // テクスチャ座標
  gl_TexCoord[0] = gl_TextureMatrix[0] * gl_MultiTexCoord0;

  // 視点座標系の頂点の位置
  position = gl_ModelViewMatrix * gl_Vertex;

  // 視点座標系の法線ベクトル
  normal = normalize(gl_NormalMatrix * gl_Normal);
}

第3回のフラグメントシェーダ

#version 120

// texture.frag

// ラスタライザから受け取る視点座標系の頂点の位置の補間値
varying vec4 position;

// ラスタライザから受け取る視点座標系の法線ベクトルの補間値
varying vec3 normal;

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

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

  // 視点座標系の法線ベクトル
  vec3 fnormal = normalize(normal);

  // 視点座標系の光線ベクトル
  vec3 light = normalize((gl_LightSource[0].position * position.w
    - gl_LightSource[0].position.w * position).xyz);

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

  // 視点座標系の視線ベクトル
  vec3 view = -normalize(position.xyz);

  // 視点座標系の中間ベクトル
  vec3 halfway = normalize(light + view);

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

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

ちゃんとハイライトも付いています。

法線マップをそのまま色のテクスチャとして貼り付けて陰影をつけた球

バンプマッピングの実装

球の描画手続きの変更

ファイル sphere.cpp で定義している球の描画手続きにも、若干の変更を加えます。頂点位置を設定する際に、法線マップのテクスチャ座標や法線ベクトルに加えて、接線ベクトルも設定します。

まず、法線ベクトルから接線ベクトルを算出する手続き setTangent() を定義します。接線ベクトルは、球の上方向のベクトル $(0, 1, 0)$ と法線ベクトルとの外積により求めることにします。これをバーテックスシェーダで算出することもできますが、この算出方法は球のモデルに依存していますから、シェーダプログラムに汎用性を持たせるために、接線ベクトルの算出手続きを球の描画手続きとセットにしておくことにします。

/*
** 球の描画
*/
#include "sphere.h"

/* 標準ライブラリ */
#include <math.h>

/*
** ローカル座標系から normal を法線ベクトルとする接線ベクトル tangent を求める
*/
static void setTangent(const double normal[3], double tangent[3])
{
  double length = hypot(normal[0], normal[2]);
  
  /* 接線ベクトル = (0, 1, 0) × n */
  if (length > 0) {
    tangent[0] = normal[2] / length;
    tangent[1] = 0.0;
    tangent[2] = -normal[0] / length;
  }
  else {
    tangent[0] = tangent[1] = tangent[2] = 0.0;
  }
}

次に、この関数 setTangent() を、球の描画において頂点位置を設定している部分に追加します。そして求めた接線ベクトル tangent を、glVertexAttrib3dv() を使って attribute 変数としてバーテックスシェーダに渡します。引数 tangentLoc は、この attribute 変数 tangent の場所です。

/*
** 球の描画
*/
void sphere(double radius, int slices, int stacks, GLint tangentLoc)
{
  /* 球を描く */
  for (int j = 0; j < stacks; ++j) {
    double t0 = (double)(j + 1) / (double)stacks;
    double t1 = (double)j / (double)stacks;
    double r0 = sin(M_PI * t0);
    double r1 = sin(M_PI * t1);
    double normal[2][3], position[2][3], tangent[3];
    
    /* 法線単位ベクトルの y 成分 */
    normal[0][1] = -cos(M_PI * t0);
    normal[1][1] = -cos(M_PI * t1);
    
    /* 頂点の y 座標値 */
    position[0][1] = radius * normal[0][1];
    position[1][1] = radius * normal[1][1];
    
    /* 法線マップのテクスチャ座標の算出 */
    t0 *= 4.0;
    t1 *= 4.0;
    
    glBegin(GL_QUAD_STRIP);
    for (int i = 0; i <= slices; ++i) {
      double s = (double)i / (double)slices;
      double a = -2.0 * M_PI * s;
      
      /* 法線単位ベクトルの x, z 成分 */
      normal[0][0] = r0 * cos(a);
      normal[0][2] = r0 * sin(a);
      normal[1][0] = r1 * cos(a);
      normal[1][2] = r1 * sin(a);
      
      /* 頂点の x, z 座標値 */
      position[0][0] = radius * normal[0][0];
      position[0][2] = radius * normal[0][2];
      position[1][0] = radius * normal[1][0];
      position[1][2] = radius * normal[1][2];
      
      /* 法線マップのテクスチャ座標の算出 */
      s *= 8.0;

      /* 法線マップのテクスチャ座標を設定する */
      glTexCoord2d(s, t0);
      
      /* 法線ベクトルを設定する */
      glNormal3dv(normal[0]);

      /* 接線ベクトルを求める */
      setTangent(normal[0], tangent);

      /* 接線ベクトルを attribute 変数に設定する */
      glVertexAttrib3dv(tangentLoc, tangent);

      /* 頂点位置 */
      glVertex3dv(position[0]);
      
      /* 法線マップのテクスチャ座標を設定する */
      glTexCoord2d(s, t1);
      
      /* 法線ベクトルを設定する */
      glNormal3dv(normal[1]);

      /* 接線ベクトルを求める */
      setTangent(normal[1], tangent);
      
      /* 接線ベクトルを attribute 変数に設定する */
      glVertexAttrib3dv(tangentLoc, tangent);

      /* 頂点位置 */
      glVertex3dv(position[1]);
    }
    glEnd();
  }
}

なお、もし接線ベクトルの算出をバーテックスシェーダで行うなら、図形の描画手続きにまったく手を加える必要はありません。

バーテックスシェーダの変更

第2回で作成した Phong の陰影付けのシェーダプログラムでは、陰影計算を視点座標系で行うために、オブジェクト表面上の点の位置 position と、その点における法線ベクトル normal を varying 変数にしてバーテックスシェーダからフラグメントシェーダに送っていました。バンプマッピングでは接空間において陰影計算を行わなければならないため、代わりに接空間における光線ベクトル tlight と視線ベクトル tview を varying 変数にします。

#version 120

// bump.vert

// 頂点の接線ベクトル
attribute vec3 tangent;

// ラスタライザに送る接空間の光線ベクトル
varying vec3 tlight;

// ラスタライザに送る接空間の視線ベクトル
varying vec3 tview;

まず、視点座標系における光線ベクトルと視線ベクトルを求めます。光源位置 gl_LightSource[0].position は視点座標系にあるとします。

void main()
{
  // 頂点のクリッピング座標値
  gl_Position = ftransform();

  // テクスチャ座標
  gl_TexCoord[0] = gl_TextureMatrix[0] * gl_MultiTexCoord0;

  // 視点座標系の頂点の位置
  vec4 position = gl_ModelViewMatrix * gl_Vertex;

  // 視点座標系の法線ベクトル
  vec3 normal = normalize(gl_NormalMatrix * gl_Normal);

  // 視点座標系の光線ベクトル
  vec3 light = normalize((gl_LightSource[0].position * position.w
    - gl_LightSource[0].position.w * position).xyz);

  // 視点座標系の視線ベクトル
  vec3 view = -normalize(position.xyz);

同様に、視点座標系における法線ベクトル $\mathbf{n}$ と接線ベクトル $\mathbf{t}$ を求めます。次に、これらの外積により従法線ベクトル $\mathbf{b}$ を求めておきます。そして、これらを使って視点座標系から接空間への変換を行います。下のプログラムでは GLSL の組み込み関数 dot() を用いて、$\begin{pmatrix}\mathbf{t}&\mathbf{b}&\mathbf{n}\end{pmatrix}^\top$ による変換を行っています。

  // 法線ベクトルと接線ベクトルから接空間への変換行列
  vec3 n = normal;
  vec3 t = normalize(gl_NormalMatrix * tangent);
  vec3 b = cross(n, t);

  // 接空間における光線ベクトル
  tlight.x = dot(t, light);
  tlight.y = dot(b, light);
  tlight.z = dot(n, light);

  // 接空間における視線ベクトル
  tview.x = dot(t, view);
  tview.y = dot(b, view);
  tview.z = dot(n, view);
}

フラグメントシェーダの変更

フラグメントシェーダの変更点のポイントは、法線ベクトルをテクスチャから取り出してくることと、その法線ベクトルを使った陰影計算を接空間の座標系で行うところにあります。したがってこの陰影計算には、視点座標系の位置 position や法線ベクトル normal の代わりに、バーテックスシェーダで計算した接空間における光線ベクトル tlight と視線ベクトル tview を用います。

#version 120

// bump.frag

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

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

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

テクスチャは法線マップなので、これをサンプリングした値 color を用いてフラグメントの法線ベクトル fnormal を求めます。法線マップに格納されている法線ベクトルの各要素の値は [0,1] に収められているので、これを [-1,1] に引き伸ばします。接空間の光線ベクトル tlight は varying 変数で受け取った補間値なので、ここで GLSL の組み込み関数 normalize() を使って正規化しておきます。この値 flight と法線ベクトル fnormal の内積により diffuse を求めます。

void main ()
{
  // 法線マップから接空間の法線ベクトルを得る
  vec4 fcolor = texture2DProj(color, gl_TexCoord[0]);
  vec3 fnormal = fcolor.rgb * 2.0 - 1.0;

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

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

接空間の視線ベクトル tview も varying 変数として得ていますから、これを正規化して fview としておきます。中間ベクトル halfwayflightfview の逆ベクトルの和から求めます。拡散反射係数には材質として設定したものを用いるので、それと光源強度の拡散反射成分との積 gl_FrontLightProduct[0].diffuse を使って拡散反射光強度を求めます。これに材質として設定した環境光の反射係数と環境光強度の積 gl_FrontLightProduct[0].ambient と鏡面反射光強度 specular を加えて、gl_FragColor に代入します。

  // 接空間における中間ベクトル
  vec3 fhalfway = normalize(tlight + tview);

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

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

シェーダプログラムを使って球にバンプマッピング

接線ベクトルをバーテックスシェーダ内ででっち上げてやれば、ティーポットにバンプマッピングするなんてこともできなくはありません。ただし glutSolidTeapot() は面の表裏が反転しているので1、凹凸も逆になってしまいます。

シェーダプログラムを使ってティーポットにバンプマッピング

  1. これはオリジナルの GLUT の話です。FreeGLUT では直っていました。(2026 年 6 月 28 日追記)