鏡面反射光をテクスチャで作る
前回,スフィアマッピングを使って Phong のスムーズシェーディングができるという話を書きました.このテクニックは,以前,構造色(いわゆる「玉虫色」)のレンダリングに関する研究をしていた学生さんが使っていたのですが,今いる学生さんには継承されていません.私自身もちゃんとプログラムを書いたことはありません.なので,実際にやってみました.今回は演習形式ではなく,ソースプログラムの説明をします.
パラメータ設定
まず,光源とマテリアルを決めておきます.この方法では物体表面上の点の位置を扱えないので,光源は無限の彼方に追いやる(平行光線ですね)ことにします.テクスチャのサイズはそんなに大きくとらないでも大丈夫なのですが(暇だったら試しに小さくして結果を比べてみてください),とりあえずこれくらいにしておきます.
/*
** テクスチャサイズ
*/
#define TEXWIDTH 256 /* テクスチャの幅 */
#define TEXHEIGHT 256 /* テクスチャの高さ */
/*
** 光源
*/
static const GLfloat lpos[] = { 0.0, 0.0, 1.0, 0.0 }; /* 位置 */
static const GLfloat lcol[] = { 1.0, 1.0, 1.0, 1.0 }; /* 直接光強度 */
static const GLfloat lamb[] = { 0.2, 0.2, 0.2, 1.0 }; /* 環境光強度 */
/*
** マテリアル
*/
static const GLfloat kdiff[] = { 0.0, 0.1, 0.3, 1.0 }; /* 拡散反射係数 */
static const GLfloat kspec[] = { 0.6, 0.6, 0.6, 1.0 }; /* 鏡面反射係数 */
static const GLfloat kshi = 20.0; /* 輝き係数 */
このデータをもとに,鏡面反射分布のテクスチャを作ります.拡散反射成分には OpenGL による陰影付けをそのまま使うことにして,それにこの鏡面反射成分のテクスチャを加算します.
テクスチャの作成
鏡面反射強度の算出には,視線の逆ベクトル $-\mathbf{u}$ と光線ベクトル $\mathbf{l}$ の中間ベクトル $\mathbf{h}$ と,物体表面の法線ベクトル $\mathbf{n}’$ との内積を求める方法を採用します.これは OpenGL の陰影付けで用いられている方法です.

/*
** テクスチャの作成
*/
static void makeTexture(unsigned char *t, int w, int h)
{
/* 光線ベクトルと視線ベクトルの中間ベクトル (hx, hy, hz) を求める */
float l2 = lpos[0] * lpos[0] + lpos[1] * lpos[1] + lpos[2] * lpos[2];
float l = sqrt(l2);
float hx = lpos[0], hy = lpos[1], hz = lpos[2] + l;
l2 += lpos[2] * l;
if (l2 > 0.0) {
double m = sqrt(l2 + l2);
hx /= m;
hy /= m;
hz /= m;
}
次に,テクスチャ座標から対応する法線ベクトルを求めます.まずテクスチャの画素の位置 $ (x, y) $ からテクスチャ座標 $ (s, t) $ を求め,その位置における法線ベクトルの xy 成分 $ \left(n’_x, n’_y\right) $ を求めます.法線ベクトルの z 成分は $ {n’_z}^2 = 1 - {n’_x}^2 - {n’_y}^2 $ から求めます.
この法線ベクトル \(\mathbf{n}' = \left(n'_x, n'_y, n'_z\right)\) と中間ベクトル $ \mathbf{h} $ から鏡面反射光強度を、Phong の陰影付けモデルの \(I_s = k_{spec}l_i\left(\mathbf{n}'\cdot\mathbf{h}\right)^{k_{shi}}\) により求めます.
/* 中間ベクトルと法線ベクトルの内積値でテクスチャを作る */
for (int y = 0; y < h; ++y) {
float ny = (float)(y + y - h) / (float)h;
float ny2 = ny * ny;
for (int x = 0; x < w; ++x) {
float nx = (float)(x + x - w) / (float)w;
float nx2 = nx * nx;
float nz2 = 1.0 - nx2 - ny2;
/* nz2 >= 0 なら「円内」 */
if (nz2 >= 0.0) {
float nz = sqrt(nz2);
float rs = pow(hx * nx + hy * ny + hz * nz, kshi) * 255.0;
*(t++) = (GLubyte)(kspec[0] * rs * lcol[0]);
*(t++) = (GLubyte)(kspec[1] * rs * lcol[1]);
*(t++) = (GLubyte)(kspec[2] * rs * lcol[2]);
}
else {
*(t++) = 0;
*(t++) = 0;
*(t++) = 0;
}
}
}
これで次のようなテクスチャが得られます.

スフィアマッピングの設定
このテクスチャをスフィアマッピングします.
/*
** 初期化
*/
static void init()
{
/* テクスチャの読み込みに使う配列 */
static GLubyte texture[TEXHEIGHT * TEXWIDTH * 3];
/* テクスチャの作成 */
makeTexture(texture, TEXWIDTH, TEXHEIGHT);
/* テクスチャ画像はバイト単位に詰め込まれている */
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
/* テクスチャの割り当て */
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, TEXWIDTH, TEXHEIGHT, 0,
GL_RGB, GL_UNSIGNED_BYTE, texture);
テクスチャの拡大フィルタ (GL_TEXTURE_MAG_FILTER) には線形補間 GL_LINEAR を行うようにします.これでテクスチャのサイズが小さくても,鏡面反射を滑らかに見せることができます.またこのテクスチャは充分滑らかなので,テクスチャの縮小フィルタ (GL_TEXTURE_MIN_FILTER) は最近傍法 GL_NEAREST のままでも問題ないと思いますが,これにも線形補間 GL_LINEAR を用いることにします.ただし、この時はテクスチャのラッピングモード (GL_TEXTURE_WRAP_S や GL_TEXTURE_WRAP_T) を GL_CLAMP_TO_EDGE にしないと、テクスチャの範囲外がマッピングされている領域の色が、テクスチャの最外周の色と違ってきます。なお、GL_CLAMP_TO_EDGE は OpenGL 1.2 からコア機能(標準機能)に取り入れられました。
/* テクスチャを拡大・縮小する方法の指定 */
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_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
/* スフィアマッピング用のテクスチャ座標を生成する */
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP);
テクスチャによる鏡面反射光は,OpenGL の陰影付けによる拡散反射光に加算して合成します.このために,glTexEnvi() で GL_TEXTURE_ENV_MODE に GL_ADD を指定します.これは OpenGL 1.3 からコア標準に取り入れられました。
/* テクスチャ環境(ポリゴンの陰影に加算する) */
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_ADD);
/* 光源の初期設定 */
glEnable(GL_LIGHTING);
glEnable(GL_LIGHT0);
glLightfv(GL_LIGHT0, GL_DIFFUSE, lcol);
glLightfv(GL_LIGHT0, GL_SPECULAR, lcol);
glLightfv(GL_LIGHT0, GL_AMBIENT, lamb);
サンプルプログラムでは,スフィアマッピングを使って Phong のスムーズシェーディングを実現した図形の他に,これと同じパラメータを使って OpenGL 本来の陰影付けを行った図形を表示して,生成画像を比較できるようにしています.
スフィアマッピングは鏡面反射成分にのみ利用するので,この方法では拡散反射成分と鏡面反射成分を別々に計算することになります.そこで比較の対象とする OpenGL 本来の陰影付けでも,拡散反射光と鏡面反射光を別々に補間するようにします.これは陰影付けモデルの設定の GL_LIGHT_MODEL_COLOR_CONTROL に GL_SEPARATE_SPECULAR_COLOR を設定します。この機能も OpenGL 1.2 からコア機能に取り入れられました。
/* 拡散反射光と鏡面反射光を別々に補間する */
glLightModeli(GL_LIGHT_MODEL_COLOR_CONTROL, GL_SEPARATE_SPECULAR_COLOR);
/* その他の初期設定 */
glClearColor(0.3, 0.3, 1.0, 0.0);
glEnable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
VC++ 6.0 付属のソフトウェア開発キット (Windows SDK) に含まれている gl.h は OpenGL 1.1 のものなので、これ以降に OpenGL のコア機能に取り入れられた関数や定数は宣言されていません。その場合は、OpenGL の拡張機能を定義している glext.h を #include する必要があります。
#if defined(__APPLE__)
# define GL_SILENCE_DEPRECATION
# include <GLUT/glut.h>
# include <OpenGL/glext.h>
#else
# if defined(_WIN32)
//# pragma comment(linker, "/subsystem:\"windows\" /entry:\"mainCRTStartup\"")
# define _USE_MATH_DEFINES
# define _CRT_SECURE_NO_WARNINGS
# endif
# include <GL/glut.h>
# include <GL/glext.h>
#endif
シーンの描画
シーンは同じ図形を2つ描き,一方にスフィアマッピングします.スフィアマッピングする方の図形は,OpenGL の陰影付けの鏡面反射係数を 0 にしておきます.
/*
** シーンの描画
*/
static void scene()
{
/* 鏡面反射成分をテクスチャマッピングするときの鏡面反射係数 */
static const GLfloat knone[] = { 0.0, 0.0, 0.0, 1.0 };
/* 材質の設定 */
glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, kdiff);
glMaterialf(GL_FRONT, GL_SHININESS, kshi);
/* テクスチャをマッピングせずに図形を描く */
glMaterialfv(GL_FRONT, GL_SPECULAR, kspec);
glPushMatrix();
glTranslated(-1.7, 0.0, 0.0);
glMultMatrixd(trackballRotation());
showShape();
glPopMatrix();
/* テクスチャをマッピングして図形を描く */
glMaterialfv(GL_FRONT, GL_SPECULAR, knone);
glEnable(GL_TEXTURE_2D);
glEnable(GL_TEXTURE_GEN_S);
glEnable(GL_TEXTURE_GEN_T);
glPushMatrix();
glTranslated( 1.7, 0.0, 0.0);
glMultMatrixd(trackballRotation());
showShape();
glPopMatrix();
glDisable(GL_TEXTURE_GEN_T);
glDisable(GL_TEXTURE_GEN_S);
glDisable(GL_TEXTURE_2D);
}
実行結果
この実行結果は次のようになります.左が OpenGL 本来の陰影付けによる結果です.よく見ると,かすかにポリゴンの境界が見えています(この画像はちょっと小さいけど,プログラムのウィンドウを拡大するとよくわかります).

上の図ではあまり区別がつかないので,粗めのポリゴンで作った球で比較してみます.

ということで,見事に Phong のスムーズシェーディングらしくなっています.また図形を回転させると,Gouraud のスムーズシェーディングの問題の1つである鏡面反射の消失が発生していないことがわかります.
ただしこの方法では,スクリーンに対して垂直に近い面で,テクスチャが期待した通りにマッピングされないことがあります.この部分でテクスチャ座標がテクスチャ空間の中央部に戻ってきているように見えます.スフィアマッピングに用いる円状のテクスチャの円周部は,$\mathbf{f}$ = (0, 0, -1) 方向の色を示します.これと視点方向に向かうベクトル $-\mathbf{n} = \left(0, 0, 1\right)$ との中間ベクトル $\mathbf{h}$ は不定になってしまいすから,これは仕方が無いんでしょう.
テクスチャのパターンを変えてみる
次に,テクスチャを作成するときに使った陰影付けの関数を変更して,下のようなテクスチャを使ってみます.

この場合はこういう画像になります.ライトがくっきり移り込んでいるように見える分,表面がつややかに感じられる(人もいる)と思います.

んで,こういうテクスチャでもやってみました.

これはこんな具合になります.

これは物体表面の細かな起毛などによって,物体表面(基準面)の正反射方向への鏡面反射が少なく,入射角が浅いときに鏡面反射光が散乱するような場合を模したものです…とか,それらしいことを書いているけど,実は適当です.こういう複雑な表面の場合は幾何減衰(表面の凹凸による自己遮蔽による減衰)とか,いろいろ考える要素があるらしいんですけど,あんまり良く知らないんで(今は)パス.暇があったらゴム網シミュレータ(本来の研究の方はちゃんと布らしくなってきた)に組み込んで眺めてみようと思います.
毛の陰影については Kajiya-Kay model なんかをキーワードにググってみてください.Kajiya さんの論文の載った 1989 年の SIGGRAPH の Proceedings は,多分 807号室の本棚のどっかにあります(裏表紙に確か熊のぬいぐるみの絵が描いてあったと思う).
光の反射率を物体表面に対する光の入射方向と反射方向をパラメータとする4次元(光の波長を無視した場合)の関数で表したものを BRDF (Bi-directional Reflectance Distribution Function, 双方向反射分布関数) と言います.視線方向と光線方向を固定すれば,これを物体表面の向きをパラメータとする2次元の関数で置き換えることができます.ということは,2次元のテクスチャマッピングが使えるわけですね.ただし,スフィアマッピングでは物体表面の接空間を定義できませんから,実装するにはあとひとひねり必要になります.このあたりもググると何か出てくるんじゃないでしょうか.
おまけ
光源の位置(今の場合は方向ですね)を中心からずらします.
/*
** 光源
*/
static const GLfloat lpos[] = { 3.0, 4.0, 5.0, 0.0 }; /* 位置 */
static const GLfloat lcol[] = { 1.0, 1.0, 1.0, 1.0 }; /* 直接光強度 */
static const GLfloat lamb[] = { 0.2, 0.2, 0.2, 1.0 }; /* 環境光強度 */
この状態で作成したテクスチャを,例によって回転してしまいます.
/****************************
** GLUT のコールバック関数 **
****************************/
/* アニメーションのサイクル */
#define FRAMES 360
static void display()
{
/* フレーム数をカウントして時間として使う */
static int frame = 0; /* フレーム数 */
double t = (double)frame / (double)FRAMES; /* 時間とともに 0→1 に変化 */
/* アニメーションのサイクルごとにフレーム数をリセットする */
if (++frame >= FRAMES) frame = 0;
/* テクスチャ行列の設定 */
glMatrixMode(GL_TEXTURE);
glLoadIdentity();
glTranslated(0.5, 0.5, 0.0);
glRotated(t * 360.0, 0.0, 0.0, 1.0);
glTranslated(-0.5, -0.5, 0.0);
/* モデルビュー変換行列の設定 */
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
これを実行し,マウスのボタンを(ドラッグせずに)押し続けてみてください.光を異なる方向から当てるようなアニメーションになります.ただし,これは光源方向が視線を中心に回転しているだけで,光源を任意の位置に移動できるわけではありませんから.残念っ!
追記:うう,拡散反射光を回すの忘れてた…