Environment Mapping

Oct 5, 2020 03:29 · 2677 words · 6 minute read

前回のポストで紹介したAvalon EmersonのMVで環境マッピングなるものがおそらく使われていて,原理を知りたいと思い,少し勉強した. 今回は環境マッピングの原理を理解し,GLSLコードに落とし込み,実際にレンダリングすることを目標にする. 環境マッピング自体は有名な手法で,周囲の環境を金属やガラスのように映し出すオブジェクトをレンダリングできる. あくまで擬似的な映り込みであるが,レイトレーシングみたいな計算量は必要ない. 以下は今回使用した Cinder というライブラリに入っている環境マッピングのサンプルである. まぁこんな感じのことをやりたいわけである. ちなみにCinderはOpenGLをモダンに抽象化したようなC++ライブラリである.似たようなものに openFrameworks があるが,Cinderの方がよりOpenGL内部に踏み込まないといけない.C++自体もCinderの方が今っぽい書き方ができる.



今回は環境マッピングのうちキューブマッピングというものを勉強した. 他にもスフィアマップなるものもあるみたいだが,本質は変わらなそうなので,今回はスキップする. キューブマッピングの原理は単純で, 以下のような6面のテクスチャからなる十分に大きいキューブで世界を包むことである.



オブジェクトにレイを飛ばし,反射光がキューブに当たったらそこをサンプリングする. ここでキューブは十分に大きいので,全てのオブジェクトはキューブの中心にあるとして良い. この場合,オブジェクトの位置によらず,反射光の方向のみによってサンプリングされるテクスチャが決定する(鏡面反射).



GLSLにはこのような反射方向からキューブマップテクスチャをサンプリングするためのsamplerCubeなる型が存在する. よってバーテックスシェーダとフラグメントシェーダは以下のように書ける.

#version 150

uniform mat4 ciModel;
uniform mat4 ciModelView;
uniform mat4 ciModelViewProjection;
uniform mat4 ciViewMatrix;
uniform mat4 ciViewMatrixInverse;
uniform mat3 ciNormalMatrix; // = transpose(inverse(mat3(ciModelView)))

in vec4	ciPosition;
in vec3	ciNormal;

out highp vec3 eyeDirectionWorld;
out highp vec3 normalWorld;

void main()
{
    gl_Position = ciModelViewProjection * ciPosition;
    
    vec4 positionView = ciModelView * ciPosition;
    vec4 eyeDirectionView = positionView - vec4(0, 0, 0, 1); // eye is always at 0,0,0 in view space
    eyeDirectionWorld = normalize(vec3(ciViewMatrixInverse * eyeDirectionView));
    
    vec3 normalView = ciNormalMatrix * ciNormal;
    normalWorld = normalize(vec3(vec4(normalView, 0) * ciViewMatrix));
    // normalWorld = normalize(transpose(inverse(mat3(ciModel))) * ciNormal);
}
#version 150

uniform samplerCube samplerCubeMap;
in vec3 eyeDirectionWorld;
in vec3 normalWorld;
out vec4 flagColor;

void main()
{
    vec3 reflectionDirection = reflect(eyeDirectionWorld, normalWorld);
    flagColor = texture(samplerCubeMap, reflectionDirection);
}

ここでフラグメントシェーダはバーテックスシェーダから渡された視線方向と法線から,ビルトイン関数reflectによって,反射方向を計算し,キューブマップテクスチャをサンプリングしている. 問題はバーテックスシェーダである. GLSLに慣れていないので,法線の世界座標をどうやって取っているのか,Cinderのサンプルを見てみたら以下のように書いてあった.

vec3 normalView = ciNormalMatrix * ciNormal;
normalWorld = normalize(vec3(vec4(normalView, 0) * ciViewMatrix));

まず,ビュー座標系に変換するのに,ciNormalMatrixなるuniform型変数が使われている. そもそもこのciNormalMatrixの意味がわからなかった.ciModelViewじゃダメなのかと.

そこで頂点$p$がある変換行列$T$によって$p'$に移るとき,$p$における法線$n$を$p'$における法線$n'$に移すような変換行列$U$は何かを考える. $Tm=T(q-p)=Tq-Tp=q'-p'=m'$より$T$は$p$における接線$m$を$p'$における接線$m'$に移す. 変換後の接線と法線は互いに垂直となることから,$m'^{T}n'=(Tm)^{T}(Un)=m^{T}T^{T}Un=0$が成り立てば良い. $T^{T}U=E$のとき$m'^{T}n'=m^{T}n=0$となるので,$U=(T^{T})^{-1}=(T^{-1})^{T}$は法線$n$を法線$n'$に移す変換の1つである.

また$T$の左上3x3部分行列が直交行列の場合には,新たな変換$U$を考えなくても,$T$によって法線としての性質は保たれる. これは直交行列は内積を保つ線形変換としても定義されることを思い出せば,ごく当たり前のことかもしれない.

また$T$が回転行列,平行移動のみから成っていれば,$T$の左上3x3部分行列は直交行列となる. また変換後の法線の長さを気にしなければ,$T$が全ての軸に均一なスケーリングを含んでいても,$T$によって法線としての性質は保たれる.

ということでciNormalMatrixの実態はtranspose(inverse(mat3(ciModelView)))らしい. ここまででvec3 normalView = ciNormalMatrix * ciNormal;の意味はわかった.

normalWorld = normalize(vec3(vec4(normalView, 0) * ciViewMatrix));の意味はなんだろう. 頂点を世界座標系からビュー座標系に移すのがciViewMatrixなので,法線を世界座標系からビュー座標系に移すのはtranspose(inverse(mat3(ciViewMatrix)))である. 今ビュー座標系における法線normalViewが手に入っているので,世界座標系に戻すためにさらにinverseしたものを左からかけて,

inverse(transpose(inverse(mat3(ciViewMatrix)))) * normalView 
= transpose(mat3(ciViewMatrix)) * normalView 
= normalView * mat3(ciViewMatrix)

としているんだと思う. これは1つのやり方で,他にもnormalWorld = normalize(transpose(inverse(mat3(ciModel))) * ciNormal);とかでもいいと思う.

今回キューブマップ用のテクスチャとして以下のような画像を用意した.



これで周囲の環境を完全鏡面反射するオブジェクトは以下のようにレンダリングできる.



次に肝心の環境そのものをレンダリングしなくてはならない. これはもっと単純でオブジェクトの頂点方向をサンプリングすれば良い.



これで以下のようにレンダリングできる.



最後に,今までは反射光のみをレンダリングしてきた.これをそれに加えて屈折光もレンダリングすることを考える. ただし簡単のため,2度目以降の屈折は考えない. 屈折光はGLSLのビルトイン関数refractで計算できる.reflectとの違いは屈折率の比を与えてやるということである. また反射光と屈折光の割合は フレネル反射率 で計算できる. 今回は大気の屈折率$n_{1} \fallingdotseq 1.0$,ガラスの屈折率$n_{2} \fallingdotseq 1.5$を用い, Schlickの近似式 により計算した.

フラグメントシェーダは以下のようになる.

#version 150

uniform samplerCube samplerCubeMap;
in vec3 eyeDirectionWorld;
in vec3 normalWorld;
out vec4 flagColor;

uniform float refractiveIndex = 1.0 / 1.5;

float fresnelReflectance(vec3 light, vec3 normal)
{
    float specularReflectance = pow(1.0 - refractiveIndex, 2.0) / pow(1.0 + refractiveIndex, 2.0);
    return specularReflectance + (1.0 - specularReflectance) * pow(1.0 - dot(-light, normal), 5.0);
}

void main()
{
    // refract the eye ray upong entering the surface (we're ignoring the second refraction when the ray exits the object).
    vec3 refractionDirection = refract(eyeDirectionWorld, normalWorld, refractiveIndex);
    // reflect the eye ray about the surface normal (all in world space)
    vec3 reflectionDirection = reflect(eyeDirectionWorld, normalWorld);

    vec4 flagColorRefracted = texture(samplerCubeMap, refractionDirection);
    vec4 flagColorReflected = texture(samplerCubeMap, reflectionDirection);
    
    flagColor = mix(flagColorRefracted, flagColorReflected, fresnelReflectance(eyeDirectionWorld, normalWorld));
}

屈折を考慮することで,背景がガラスのように透けて見えるようになった.


Reflection mapping

Refraction mapping


今後もいろいろなレンダリング手法を少しずつGLSLを通して勉強していきたい.