■ 2015年06月07日 [OpenGL][テクスチャ] 投影テクスチャマッピングとシャドウマッピング
結婚記念日
実は今日6月7日は結婚記念日なので、昨日の夕食は前祝いに家族でたこ焼きを食べに行きました。本当は焼き鳥を食べに行く予定だったのですが、予約するのを忘れていて満席で入れませんでした。このたこ焼き屋も店舗が小さいこともあって満席のことが多いのですが、たまたま昨日は空席がありました。ここは以前から私たちのお気に入りの店で、昨日も非常に満足させていただきました。ありがとうございます。
ところで、帰り際になって息子さまがテレビの時間を気にし始め、何かあったかなって思って家に帰ったら、「総選挙」やってました。
投影テクスチャマッピング
授業の解説用に投影テクスチャマッピングのデモプログラムを書きました。投影マッピングはテクスチャを光のように物体にマッピングするテクニックで、テクスチャの色を使って陰影を計算します。同じようなプログラムは昔も書いたんですけど (おっと 11 年前!)、その時はシェーダを使いませんでした。また今回は、テクスチャに USB カメラでキャプチャしたライブ映像を使います。そのために OpenCV も使います。
このプログラムも授業用の補助プログラム gg を使っています。こういうのは自分で作るより GLM (usagi さんの解説) とか使ったほうがいいとは思うんですけど、もう作っちゃったから使ってもいいですよね。自分のプログラムなんだし。そのせいでプログラムが読みにくかったとしたら謝ります。そのうちこの解説も書きたいと思っていますけど、めんどくさいので書かないかもしれません。
投影像
投影像はテクスチャに保持します。このテクスチャに OpenCV を使ってキャプチャした画像を転送します。テクスチャのターゲットには GL_TEXTURE_RECTANGLE すなわち矩形テクスチャを使うことにします。これはキャプチャした画像のサイズを 2n に合わせるのが面倒というかコストが高いように思えたからですが、今の CPU なら知れてるかもしれません。
// 投影する画像を保存するテクスチャを準備する GLuint image; glGenTextures(1, &image); glBindTexture(GL_TEXTURE_RECTANGLE, image); glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGB, capture_width, capture_height, 0, GL_BGR, GL_UNSIGNED_BYTE, nullptr); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
このテクスチャの境界色を設定します。GL_CLAMP_TO_BORDER を指定しているので、テクスチャからはみ出たところには GL_TEXTURE_BORDER_COLOR で指定した色が使われます。このテクスチャはプロジェクタの投影像みたいに物体にマッピングされますから、テクスチャの外側には本当は光が届きません。しかし、それだと物体のその部分が環境光の反射光だけになって陰影の変化がなくなってしまうので (こゆことをすればリアルになるんですがw)、少し値を設定して、光源としての役割を持たせます。
// テクスチャの境界色を指定する (投影像の外側の漏洩光の強度として使う) const GLfloat borderColor[] = { 0.1f, 0.1f, 0.1f, 1.0f }; glTexParameterfv(GL_TEXTURE_RECTANGLE, GL_TEXTURE_BORDER_COLOR, borderColor);
シャドウマッピング
投影テクスチャマッピングはテクスチャマッピングですから、投影範囲にある物体が全てテクスチャのマッピング対象になります。そうすると、物体の裏側やテクスチャの投影元の "光源" (プロジェクタ) から見えない部分、すなわち影の部分にもテクスチャがマッピングされてしまいます*1。
それを避けるために、ここではシャドウマッピングのテクニックを使って、影の部分にはテクスチャをマッピングしないようにします。シャドウマッピングでは、視点を光源の位置に置いてレンダリングしたときのデプスバッファの内容 (深度値の画像) を、シャドウマップとして利用します。これを作成するために、デプスバッファの内容を保存する FBO (Frame Buffer Object) を用意します。この FBO の解像度が高いとレンダリングに時間がかかりますが、あまり低いと影の品質が下がってしまうので、とりあえず投影する画像と同じにしておきます。
// FBO のサイズはキャプチャ画像と同じにする const GLsizei fbo_width(capture_width), fbo_height(capture_height); // シャドウマップを作成する FBO 用のテクスチャを準備する GLuint depth; glGenTextures(1, &depth); glBindTexture(GL_TEXTURE_RECTANGLE, depth); glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_DEPTH_COMPONENT, fbo_width, fbo_height, 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
このテクスチャにも、境界色を設定します。これはデプステクスチャ (デプスマップ) なので、境界色はテクスチャの外側の深度になります。投影像の外側には投影像による影が落ちません*2から、境界色を 1 にしてテクスチャの外側には物体はない (視錐台の後方面が見える) ことにします。なお、テプスマップの境界色も RGBA の 4 要素を持ちますが、深度として用いられるのは、一つ目の R 要素だけです。
// テクスチャの境界深度を指定する (最初の要素がデプステクスチャの外側の深度として使われる) const GLfloat borderDepth[] = { 1.0f, 1.0f, 1.0f, 1.0f }; glTexParameterfv(GL_TEXTURE_RECTANGLE, GL_TEXTURE_BORDER_COLOR, borderDepth);
シャドウマッピングでは、テプステクスチャを 3 次元のテクスチャ座標の (x, y) 成分でサンプリングし、得られた値とサンプリングに用いたテクスチャ座標の z 成分とを比較し、その結果をもとにテクスチャの値 (白黒の 2 値) を決定します。この設定を行います。
// 書き込むポリゴンのテクスチャ座標値の r 要素とテクスチャとの比較を行うようにする glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
サンプリングしたデプステクスチャの値とテクスチャ座標の z 値との比較方法を決定します。テクスチャ座標の方に影が落ちる側の物体の深度値を格納するので、それが影を落とす側の物体の深度値であるデプステクスチャの値より小さければ、光源の光が当たっている (日向) として、テクスチャの値として 1 を返すようにします。
// もし r 要素の値がテクスチャの値以下なら真 (つまり日向) glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
このデプステクスチャをデプスバッファに割り当てた FBO を作ります。カラーバッファは使いません。
// シャドウマップを取得する FBO 用を準備する GLuint fb; glGenFramebuffers(1, &fb); glBindFramebuffer(GL_FRAMEBUFFER, fb); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_RECTANGLE, depth, 0);
シャドウマップに使うデプステクスチャの作成
描画はシャドウマップに使うデプステクスチャの作成と実際のレンダリングの 2 パスで行います。デプステクスチャを作成する際は、視点の位置を光源の位置にします。これを視野変換行列 mv に格納します。一方、実際のレンダリングの際の視点の位置は自由に動かせるようにしますが、初期値は光源の位置にします。
// 視野変換行列 (視点の位置の初期値を光源の位置にする) const GgMatrix mv(ggLookat(light.position[0], light.position[1], light.position[2], 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f)); // 投影像のアスペクト比 const GLfloat aspect(GLfloat(capture_width) / GLfloat(capture_height));
毎フレーム描画のたびに OpenCV の cv::VideoCapture クラスの grab() メソッドで入力映像をキャプチャし、成功したら投影像のテクスチャに転送します。このとき、前は画像の上下を反転するために、OpenCV の関数 flip() を使ったりしたんですけど、今回はシェーダ側でテクスチャ座標を反転することにします。
// ウィンドウが開いている間繰り返す while (window.shouldClose() == GL_FALSE) { if (camera.grab()) { // キャプチャ映像から画像を切り出す cv::Mat frame; camera.retrieve(frame, 3); // 切り出した画像をテクスチャに転送する glBindTexture(GL_TEXTURE_RECTANGLE, image); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, frame.cols, frame.rows, GL_BGR, GL_UNSIGNED_BYTE, frame.data); }
シャドウマッピング用の変換行列 (モデル・視野・投影変換行列) を求めます。視野変換行列 mv には、視点の位置に光源の位置を設定したものを格納しています。これに画像を投影するための投影変換行列を乗じます。この投影変換行列は、通常のレンダリングの際の透視変換と同じようにして設定します。画角 fovy は光源に用いるプロジェクタの画角です。これをマウスホイールで調整できるようにして、ズーム機能を実現します。アスペクト比 aspect は投影像のアスペクト比です。前方面の位置 near と後方面の位置 far には、光源の位置から見た物体の最近点と最遠点の位置を設定します。
// シャドウマッピング用の変換行列 const GgMatrix ms(ggPerspective(window.getZoom() * 0.01f + 0.3f, aspect, 3.2f, 6.8f) * mv);
描画先を FBO に切り替えて、デプスバッファだけにレンダリングする準備をします。カラーバッファへの書き込みを禁止し、デプスバッファだけを消去します。また、ビューポートを FBO のサイズと一致させます。
// 描画先をフレームバッファオブジェクトに切り替える glBindFramebuffer(GL_FRAMEBUFFER, fb); // カラーバッファは無いので読み書きしない glDrawBuffer(GL_NONE); // デプスバッファだけを消去する glClear(GL_DEPTH_BUFFER_BIT); // ビューポートを FBO のサイズに設定する glViewport(0, 0, fbo_width, fbo_height);
デプステクスチャの作成に使うシェーダを選択します。shadow.use() は glUseProgram() を呼び出しているだけです。このシェーダの uniform 変数にシャドウマッピング用の変換行列を設定します。
// シャドウマップに使うデプステクスチャ作成用のシェーダを選択する shadow.use(); // シャドウマッピング用の投影変換行列を設定する glUniformMatrix4fv(msLoc, 1, GL_FALSE, ms.get());
attachShader() メソッドの引数には、そのインスタンスの図形 (Alias OBJ 形式) に設定されている材質を設定する GgSimpleShader クラスのシェーダを指定します。しかし、デプステクスチャの作成には図形の材質設定は必要ないので、ここでは nullptr を設定しておきます (シェーダの uniform 変数に材質のデータを設定しません)。
// デプステクスチャの作成には図形の材質設定は必要ない obj.attachShader(nullptr);
描画します。この描画は図形の背面ポリゴンだけを対象にします。実際に光が当たるのは図形の前面ポリゴンですが、これと前面ポリゴンで作成したシャドウマップとの比較を行うと、シャドウマップの深度値が量子化されているために、影の判定が不安定になってしまいます (depth fighting)。
これを避けるには、シャドウマップとの比較の際にポリゴンをオフセットさせるなどの手法が採られます。しかし、ここでは光の当たる前面ポリゴンから離れた背面ポリゴンを使ってシャドウマップを作成するという、伝統的な手法を採用します。この方法でも後方面で同様に影の判定が不安定になってしまいますが、光源に対する後方面は常に影なので、判定結果に関わりなく同じ色 (環境光の反射光) になります。
// 背面ポリゴンだけをシャドウマップ用のフレームバッファオブジェクトに描画する glCullFace(GL_FRONT); obj.draw(); glCullFace(GL_BACK);
デプステクスチャの作成に使うシェーダは、次のようになります。バーテックスシェーダは頂点座標を座標変換して gl_Position に代入するだけです。
#version 150 core #extension GL_ARB_explicit_attrib_location : enable // 変換行列 uniform mat4 ms; // 正規化されたシャドウマップの座標系への変換行列 // 頂点属性 layout (location = 0) in vec4 pv; // ローカル座標系の頂点位置 void main(void) { // シャドウマップの作成時には陰影付け等は行わないので座標変換だけを行う gl_Position = ms * pv; }
フラグメントシェーダでは、カラーバッファに何も書かないために、全く何もすることがありません。デプスバッファへの書き込みは、フラグメントシェーダで何もしなくても行われます。
#version 150 core #extension GL_ARB_explicit_attrib_location : enable void main(void) { // カラーバッファに出力しないので何もすることがない (デプスバッファには自動的に書き込まれる) }
シャドウマップを参照しながらレンダリング
描画先を通常のフレームバッファに切り替えて、視点側から見た図形を通常の方法でレンダリングします。
// 描画先を通常のフレームバッファに切り替える glBindFramebuffer(GL_FRAMEBUFFER, 0); // カラーバッファへの書き込みを有効にする (ダブルバッファリングなのでバックバッファに描く) glDrawBuffer(GL_BACK);
この clear() メソッドでは、画面上の表示領域のサイズをビューポートに設定し、カラーバッファとデプスバッファを消去します。その後、描画用のシェーダを選択し、光源の情報と投影変換行列、視野変換行列を設定します。視野変換行列はトラックボール処理の行列を乗じて、図形をマウスで回転できるようにします。
// 画面を消去する window.clear(); // 描画用のシェーダを選択する simple.use(light, window.getMp(), mv * window.getLtb());
また、図形の材質設定をシェーダに送るために、attachShader() メソッドに使用するシェーダを設定します。また、このシェーダに対してシャドウマッピング用の変換行列を設定します。
// 図形の材質設定を行う obj.attachShader(simple); // シャドウマップの作成に使った投影変換行列を投影像のテクスチャ変換行列に使う glUniformMatrix4fv(mtLoc, 1, GL_FALSE, ms.get());
投影像のテクスチャとシャドウマップのデプステクスチャを有効にして、図形を描画します。
// 投影する映像のテクスチャユニットを指定する glUniform1i(imageLoc, 0); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_RECTANGLE, image); // シャドウマップのテクスチャユニットを指定する glUniform1i(depthLoc, 1); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_RECTANGLE, depth); // 図形を描画する obj.draw(); // カラーバッファを入れ替えてイベントを取り出す window.swapBuffers(); }
この描画に用いるバーテックスシェーダは次のようになります。光源、材質、変換行列は OpenGL の固定機能シェーダの陰影付モデルに沿ったものを用いています。テクスチャ座標の変換行列 mt も宣言しています。
#version 150 core #extension GL_ARB_explicit_attrib_location : enable // 光源 uniform vec4 lamb; // 環境光成分 uniform vec4 ldiff; // 拡散反射光成分 uniform vec4 lspec; // 鏡面反射光成分 uniform vec4 pl; // 位置 // 材質 uniform vec4 kamb; // 環境光の反射係数 uniform vec4 kdiff; // 拡散反射係数 uniform vec4 kspec; // 鏡面反射係数 uniform float kshi; // 輝き係数 // 変換行列 uniform mat4 mw; // 視点座標系への変換行列 uniform mat4 mc; // クリッピング座標系への変換行列 uniform mat4 mg; // 法線ベクトルの変換行列 uniform mat4 mt; // テクスチャ座標の変換行列
入力する頂点属性や out 変数も、OpenGL の固定機能シェーダの陰影付モデルに沿ったものにしています。out 変数としてテクスチャ座標 texcoord を宣言しています。
// 頂点属性 layout (location = 0) in vec4 pv; // ローカル座標系の頂点位置 layout (location = 1) in vec4 nv; // 頂点の法線ベクトル // ラスタライザに送る頂点属性 out vec4 iamb; // 環境光の反射光強度 out vec4 idiff; // 拡散反射光強度 out vec4 ispec; // 鏡面反射光強度 out vec3 texcoord; // 投影像とシャドウマップのテクスチャ座標
Blinn-Phong のモデルを用いて陰影を求めます。
void main(void) { // 座標計算 vec4 p = mw * pv; // 視点座標系の頂点の位置 vec4 q = mw * pl; // 視点座標系の光源の位置 vec3 v = normalize(p.xyz / p.w); // 視線ベクトル vec3 l = normalize((q * p.w - p * q.w).xyz); // 光線ベクトル vec3 n = normalize((mg * nv).xyz); // 法線ベクトル vec3 h = normalize(l - v); // 中間ベクトル // 陰影計算 iamb = kamb * lamb; idiff = max(dot(n, l), 0.0) * kdiff * ldiff; ispec = pow(max(dot(n, h), 0.0), kshi) * kspec * lspec;
頂点座標にテクスチャ座標の変換行列を乗じて、[-1, 1] の範囲の正規化デバイス空間におけるテクスチャ座標 t を求めます。
// 投影像のスクリーン座標 vec4 t = mt * pv;
これを t の w 要素で割って実座標を求め、1 を足して 0.5 倍することにより [0, 1] のテクスチャ空間における座標値に変換します。
// 投影像とシャドウマップのテクスチャ座標はスクリーン座標に 1 を足して 2 で割る texcoord = (t.xyz / t.w + 1.0) * 0.5;
視点側から見た座標値を gl_Position に格納します。
gl_Position = mc * pv; }
フラグメントシェーダは次のようになります。シャドウマップのサンプラとして depth を宣言しています。矩形テクスチャのデプステクスチャを用いているので、サンプラのデータ型に sampler2DRectShadow を用います。
#version 150 core #extension GL_ARB_explicit_attrib_location : enable // シャドウマップ uniform sampler2DRectShadow depth; // 投影像のテクスチャ uniform sampler2DRect image;
ラスタライザからは、補間された環境光の反射光強度、拡散反射光強度、鏡面反射光強度、およびテクスチャ座標の補間値を受け取ります。環境光の反射光強度は一定なので、これをラスタライザに補間させる必要は、実はありません。
// ラスタライザから受け取る頂点属性の補間値 in vec4 iamb; // 環境光の反射光強度 in vec4 idiff; // 拡散反射光強度 in vec4 ispec; // 鏡面反射光強度 in vec3 texcoord; // 投影像とシャドウマップのテクスチャ座標
シャドウマップのテクスチャ座標は、補間されたテクスチャ座標 texcoord の (x, y) 成分にテクスチャのサイズを乗じて求めます。投影像のテクスチャ座標は、texcoord の上下を反転してからテクスチャのサイズを乗じます。これらのテクスチャ座標を用いてサンプリングして得たそれぞれのテクスチャの値を、バーテックスシェーダで求めた陰影に乗じて影の影響を反映し、最終的なフラグメントの色を決定します。
// フレームバッファに出力するデータ layout (location = 0) out vec4 fc; // フラグメントの色 void main(void) { // シャドウマップのテクスチャ座標を求める vec3 s = vec3(textureSize(depth) * texcoord.xy, texcoord.z); // 投影像に使うキャプチャ画像は上下が反転しているのでテクスチャ座標を上下反転する vec2 t = textureSize(image) * vec2(texcoord.x, 1.0 - texcoord.y); // 光源に用いる投影像の画素値を光源強度として用いる fc = iamb + (idiff + ispec) * texture(depth, s) * texture(image, t); }
プロジェクションマッピング
今回は「投影テクスチャマッピング」の説明でしたけど、以前は「投影マッピングと書いてました。これだと「プロジェクションマッピング」になってしまうので、区別するために「テクスチャ」を入れました。でも、このプログラムはプロジェクションマッピングのシミュレータみたいなもんなんですね。んで、私のところでもプロジェクションマッピングっぽい研究をしていたりします。
これは 2014 年のとある学会で悔しい思いをした学生さんが、そのことをバネに研究を進め、2015 年の同じ学会で発表したものです。ですが、やはり力及ばずでリベンジはなりませんでした。しかし、学会の取材に来ておられた日刊工業新聞の記者さんの目に留まり、記事にしてもらいました。ありがとうございます。学生さんの努力が浮かばれました。
このような仕事は、Rhizomatiks の真鍋大度氏 が Perfume のステージパフォーマンスをはじめ、数多く手がけておられます。私たちは全周形状を計測しながらプロジェクションマッピングをするというところにアドバンテージを見つけようとしましたが、技術的にも品質の面でも、これに全然追いつけていません。でも、以前は自分たちがこういう領域に関われるとは思ってもいなかったので、もう少し頑張ってみたいと思っています。
プロジェクションマッピングはクラッシクな技術ですが、Augmented Realityとマッチしますね。CGがスクリーンから飛び出し、現実世界と融合する世界を創りましょう!
還暦プログラマさま、コメントありがとうございます。CG の世界を現実の空間に引き出してみたり、現実と混ぜたりするために、プロジェクションマッピングをうまく使う方法を考えたいと思います。今後ともよろしくお願いいたします。
サンプルプログラムをVS2017で動かそうとしたのですが、うまく動きませんでした。<br>どうしたら、動かせるでしょうか?
たーさま、コメントありがとうございます。<br>NuGet で組み込んでいる GLFW や OpenCV が Visual Studio 2017 に対応していません。お手数をおかけしますが、「プロジェクト」「NuGetパッケージの管理」の「インストール済み」からこれらを削除して、代わりにご自身でバイナリをリンクするなどしていただけますでしょうか。<br>http://marina.sys.wakayama-u.ac.jp/~tokoi/GLFW.pdf<br>よろしくお願いします。
GL_TEXTURE_2DとGL_TEXTURE_RECTANGLEの違いって何なのでしょうか?
てててさま,お返事が遅くなり申し訳ありません.<br>初期の OpenGL では,GL_TEXTURE_2D で使える画像の縦横の画素数が 2^n でないといけないという制限がありました.GL_TEXTURE_RECTANGLE にはその制限がありませんでした.また,GL_TEXTURE_2D では画像の縦横の画素数にかかわらずテクスチャ空間が ±1 なのに対して,GL_TEXTURE_RECTANGLE のテクスチャ空間は画素数と一致しています.このほか GL_TEXTURE_RECTANGLE にはミップマップが使えない,ラッピングモードには GL_CLAMP_TO_EDGE と GL_CLAMP_TO_BORDER しか指定できないなどの制限があります.<br><br>現在は GL_TEXTURE_2D で使える画素数にその制限はありませんので,GL_TEXTURE_RECTANGLE のこのような制限を考えると,これを積極的に使う理由はあまりないと思います.テクスチャ空間が画素数と一致していることくらいかなと思います.