Edited at
WebGLDay 16

three.js + キューブマップでお手軽IBL

More than 1 year has passed since last update.

この記事は,WebGL Advent Calendar 2016 16日目の記事です。

今年も何もしてないなぁと思いながら生きていたのですが、WebGLで色々成果を挙げている友人に感化されて飛び入り参加しました。よろしくお願いします。

今回は環境マップでよく利用されているキューブマップを使って、WebGL上で擬似的なIBLを、拡張機能を利用する方法としない方法の2つ実装しました。

この記事では実装の中で得た知見を共有できればと思います。

今回作ったもの

https://github.com/kaneta1992/cubemapIBL

image


IBL(Image Based Lighting)とは


IBLと略すこともある。実在する風景の写真や高精細な画像をライティングの色情報に使用して、シーンをレンダリングする方法。広いレンジの明るさ情報を記録できるHDRIを用いることで、自然な光と陰影を表現できる。ミラーボールなどを使って現実世界を撮影した写真をライティングに用いると、実写によくなじむCGを制作できる。そのため、特に実写合成で多用されている。


引用元:CGWORLD Entry.jp

要約すると、写真のピクセル一つ一つを光源とみなして写実的な表現を目指すというのがIBLです。

HDR画像を利用するととても品質が良くなるのですが、今回は用意できなかったのでLDR画像を使用しました。


アプローチ

オフラインレンダでは、正しくIBLを計算するために表面の材質(BRDF)に沿ってピクセルごとに無数のサンプリングを必要とします。

コレは0.016秒で1フレーム書き上げる必要があるリアルタイムレンダでは現実的でありません。

なので今回は、キューブマップのミップマップテクスチャに予め処理したテクスチャを格納してシェーダでサンプリングする手法を使います。

本来であれば粗さ(以下ラフネス)に合わせて元のキューブマップを積分する必要がありますが、今回はお手軽ということでボックスフィルタで1x1まで縮小した画像を利用して擬似的に対応します。


実装するぞ!

今回実装するにあたって有名なthree.jsと言うライブラリを使用しました。バージョンはr82です。

面倒くさい宣言とかもライブラリがやってくれるし、やることはシェーダーでミップマップレベル弄るだけだから余裕じゃん!

と意気揚々と実装をしている段階で気づいてしまいました、明示的にミップマップレベルを指定する方法が無いことに...

OpenGL ES 2.0相当の現在のWebGLでは、なんとミップマップレベルを明示的に指定できるのは頂点シェーダーのみのようです。

OpenGL ES 2.0 Reference Card

https://www.khronos.org/files/opengles20-reference-card.pdf

しかし、WebGLの拡張機能を利用することで目的の関数を追加することが出来るようでひとまず安心。


拡張機能を利用して実装


拡張機能を有効にする

今回使用する拡張は EXT_shader_texture_lod です。

three.jsのWebGLRendererを作成した直後にGLコンテキスト経由で有効にします。

var renderer = new THREE.WebGLRenderer();

renderer.context.getExtension('EXT_shader_texture_lod');

有効にすると幾つかGLSLの関数が追加されますが、今回使用するのはこちらです。第三引数でミップマップレベルを指定できる優れもの。

vec4 textureCubeLodEXT(samplerCube sampler, vec3 coord, float lod);


キューブマップを読み込む

var cubeLoader = new THREE.CubeTextureLoader();

var cubeMap = cubeLoader.load( imgPaths, function(loadedCubeMap){
loadedCubeMap.generateMipmaps = true;
loadedCubeMap.needsUpdate = true;
customShader.uniforms.MaxLodLevel.value = maxLodLevel(loadedCubeMap.image[0].width, loadedCubeMap.image[0].height);
customShader.uniforms.CubeMap.value = loadedCubeMap;
});

キューブマップテクスチャの読み込みにTHREE.CubetextureLoader を利用します。

cubeLoader.loadの第一引数に+x面, -x面, +y面, -y面, +z面, -z面の順番でファイルパスが格納された配列を渡すと、後は自動で読み込みをしてくれます。

第二引数には全ての読み込みが完了した際のコールバック関数を指定します。


ミップマップテクスチャを作成する

読み込みが完了したらミップマップテクスチャを作成します。

最初にボックスフィルタで縮小したものをミップマップテクスチャに格納して利用すると言いましたが、WebGLにはそれを自動でやってくれる generateMipmap という関数があるので使わせてもらいましょう。

three.jsではテクスチャの generateMipmaps というパラメータをtrueにすると、テクスチャを利用する際に自動で呼び出してくれます。

パラメータに変更を加えた際には needsUpdate をtrueにすることを忘れないようにしましょう。

また、シェーダで利用する最大ミップマップレベルを maxLodLevel で計算します。

ハードコーディングでも構いませんが以下の関数で求めることが出来ます。

function maxLodLevel(width, height)

{
return Math.log2(Math.max(width,height));
}


キューブマップ表示用のスカイボックスを作成する

three.jsではキューブマップ表示用のシェーダーが既に入っているので、そちらを利用します。

このシェーダーには tFlip というキューブマップのX軸を制御するパラメータがあるのですが、デフォルトで-1が入っておりシェーダー内で軸を反転しています。

コレを1にしておかないとスカイボックスとライティングが一致しなくなります。

2時間ほどハマりました..

var cubeShader = THREE.ShaderLib[ 'cube' ];

cubeShader.uniforms[ 'tCube' ].value = cubeMap;
cubeShader.uniforms[ 'tFlip' ].value = 1;

var skyBoxMaterial = new THREE.ShaderMaterial({
fragmentShader: cubeShader.fragmentShader,
vertexShader: cubeShader.vertexShader,
uniforms: cubeShader.uniforms,
depthWrite: false,
side: THREE.BackSide
});
var skyMesh = new THREE.Mesh( new THREE.BoxGeometry( 400, 400, 400, 1, 1, 1 ), skyBoxMaterial);
scene.add( skyMesh );


シェーダーを書く

キューブマップを利用するフラグメントシェーダーを書きます。

ここでさきほど有効にした拡張機能が活躍します。

#extension GL_EXT_shader_texture_lod : enable

varying vec3 wNormal;
varying vec4 wPosition;
uniform float Roughness;
uniform float Metallic;
uniform vec3 BaseColor;
uniform float MaxLodLevel;
uniform samplerCube CubeMap;

vec3 schlick(vec3 f0, float product)
{
return f0 + (1.0 - f0) * pow((1.0 - product), 5.0);
}

void main(void)
{
// 法線
vec3 N = normalize(wNormal);
// フラグメントからカメラ位置へのベクトル
vec3 V = normalize((vec4(cameraPosition, 1.0) - wPosition).xyz);
// 視線ベクトルと法線の反射ベクトル
vec3 R = reflect(-V,N);

// IBL
vec3 diffuse = textureCubeLodEXT(CubeMap, N, MaxLodLevel).xyz;
vec3 specular = textureCubeLodEXT(CubeMap, R, log2(Roughness * pow(2.0, MaxLodLevel))).xyz;

// フレネル反射係数
vec3 F0 = mix(vec3(0.08), BaseColor, Metallic);
vec3 fresnel = schlick(F0, max(0.0, dot(N,V)));

vec3 color = BaseColor * (1.0 - Metallic) * diffuse * (1.0 - fresnel) + specular * fresnel;
gl_FragColor = vec4(color,1.0);
}


シェーダでも拡張機能を有効にする

#extension GL_EXT_shader_texture_lod : enable

javascript側で有効にした拡張機能をglslでも有効にしましょう。

この1行が無いとGLSLコンパイルエラーになります。


キューブマップからライト情報を取得する

    // IBL

vec3 diffuse = textureCubeLodEXT(CubeMap, N, MaxLodLevel).xyz;
vec3 specular = textureCubeLodEXT(CubeMap, R, log2(Roughness * pow(2.0, MaxLodLevel))).xyz;

今までphongモデル等で拡散反射や鏡面反射の計算をしていたと思いますが、今回はキューブマップが光源となるので全てキューブマップからのフェッチになります。

diffuseは、入射した光があらゆる方向に拡散するので、法線の先の色を一番大きいミップマップレベルからフェッチします。

一方でspecularは、視線と法線の反射ベクトルの先の色をフェッチしますが、こちらはラフネスに合わせてミップマップレベルを変えます。

image

画像出典:http://tattatatakekeke.blog.fc2.com/blog-entry-67.html

このミップマップレベルについてですが、今回は事前のぼかしにボックスフィルタを利用したのでラフネス値を、反射した先のキューブマップのテクセルのサイズに置き換えて計算しています。

8x8のテクスチャを例にしてみましょう。

この解像度のテクスチャをキューブマップに使用すると、ミップマップレベルが最大3まで作成されます。

ラフネスが0.25に設定されている場合、テクセルサイズに置き換えると 0.25 * 8 で2になるのでミップマップレベル1を選択したいのは下の図を見てもらえばわかると思います。

それを計算しているのが以下の部分です。

log2(Roughness * pow(2.0, MaxLodLevel))

ここに気づかず最初 Roughness * MaxLodLevel でレベルを指定していたのですが、上の例に当てはめると(Roughness = 0.25, MaxLodLevel = 3)ミップマップレベル0.75を参照することになり入力のラフネスに比べてつるつるした質感になってしまっていました。

image


フレネル反射率

今回見た目を良くするためにフレネル反射を入れてみました。

フレネル反射率というのは物体に入射した光が屈折せずに反射する確率のことで、反射率の計算にシュリックの近似式を利用しました。

この式は、面に対して垂直に光が入射した時の反射率 f0 と、法線と入射光のなす角の余弦 product を引数として反射率の近似値を求めます。

今回の実装では、拡散反射の割合(1 - 反射率)と鏡面反射の割合(反射率)として利用しています。

こちらの記事がとても詳しく解説してくださっています。

http://d.hatena.ne.jp/hanecci/20130525/p3

vec3 schlick(vec3 f0, float product)

{
return f0 + (1.0 - f0) * pow((1.0 - product), 5.0);
}


完成!

最後に金属やフレネルを考慮してdiffuseとspecularを合成すれば完成です!

    vec3 color = BaseColor * (1.0 - Metallic) * diffuse * (1.0 - fresnel) + specular * fresnel;

gl_FragColor = vec4(color,1.0);

image

今回作成したデモを置いておきます。

http://xn--u8jxb0b.com/threejs/cubemapibl/ext.html


スマホ(nexus5)で動かない

やったーできたーと喜んで私物のスマホでも動くのか確認したのですが、動きませんでした...

どうやら今回利用した拡張機能はnexus5では利用できないようで、せっかくWebGLで作ったのにスマホで見れないとなるとがっかりです。

せっかくここまで作ったので拡張機能を利用しない手法も試すことにしました。


拡張機能を使わずに挑戦した記録


アプローチ

まず拡張機能を利用しないということは textureCubeLodEXT が利用できないので標準の textureCube を利用するしかありません。

vec4 textureCube(samplerCube sampler, vec3 coord, float bias)

この関数は bias という引数があり、ハードウェアが自動で計算した最適なミップマップレベルにこの値を加えることが出来ます。

一度この関数で置き換えてみます。

// IBL

vec3 diffuse = textureCube(CubeMap, N, MaxLodLevel).xyz;
vec3 specular = textureCube(CubeMap, R, log2(Roughness * pow(2.0, MaxLodLevel))).xyz;

image

キューブマップの境界や輪郭付近でミップマップが自動計算されてひどい見た目になってしまいました。

デモ

http://xn--u8jxb0b.com/threejs/cubemapibl/no_ext1.html

しかし、逆に考えるとハードウェアが計算するミップマップレベルをなんとか取得できれば bias にマイナスの値を設定することで相殺できるのでは?となったわけなのですが、ミップマップレベルを計算するために使う偏微分関数は拡張機能なので今回は使いたくありません。

そこで各ミップマップテクスチャのアルファ値にレベルを書き込むという手法で解決することにしました。


レベル付ミップマップテクスチャを作成する

ミップマップテクスチャを弄るということは、前回のように generateMipmap に頼らずに自分で作成する必要があります。

3時間ほどthree.jsの機能でそういったものがあるのか探したのですが、キューブマップで該当の機能を見つけることが出来ませんでした。。(知っている方がいらっしゃれば教えてください..)

そこでWebGLのAPIを直接叩いてキューブマップを読み込んだ直後に、自前のミップマップに置き換えてみます。

当初は THREE.CubeTextureLoader.load 関数のコールバックでできないかと思ったのですが、読み込んだ直後はまだWebGL側のテクスチャが生成されていないらしく作成することができなかったので、 THREE.CubeTexture.onUpdate にミップマップを作成する関数を仕込んで対応しました。

この関数はその名の通りテクスチャが更新された後に呼び出されます。

以下はミップマップテクスチャを作成する箇所の抜粋です。

function getPixel( data, w, x, y ) {

var position = ( x + w * y ) * 4;
return new THREE.Vector4(data[position],data[position + 1],data[position + 2],data[position + 3]);
}

// 正方形POTテクスチャ専用
function generateMipLevel(prevMipLevelInfo, targetLevel, maxLevel) {
var mipWidth = prevMipLevelInfo.width;
var mipHeight = prevMipLevelInfo.height;
// ミップマップレベル0以外はテクスチャを半分に縮小する
if(targetLevel != 0) {
mipWidth /= 2;
mipHeight /= 2;
}
var mipLevelImage = new Uint8Array(mipWidth * mipHeight * 4);
for(var i = 0; i < mipHeight; i++) {
for(var j = 0; j < mipWidth; j++) {
var col = null;
if (targetLevel == 0) {
// ミップマップレベル0は元画像をそのまま利用する
col = getPixel(prevMipLevelInfo.image, mipWidth, j, i);
} else {
// その他のミップマップは2x2ボックスフィルタで縮小する
col = getPixel(prevMipLevelInfo.image, mipWidth * 2, j * 2, i * 2).add(
getPixel(prevMipLevelInfo.image, mipWidth * 2, j * 2 + 1, i * 2)).add(
getPixel(prevMipLevelInfo.image, mipWidth * 2, j * 2, i * 2 + 1)).add(
getPixel(prevMipLevelInfo.image, mipWidth * 2, j * 2 + 1, i * 2 + 1)).multiplyScalar(0.25);
}
var position = ( j + mipWidth * i ) * 4;
mipLevelImage[position + 0] = col.x;
mipLevelImage[position + 1] = col.y;
mipLevelImage[position + 2] = col.z;
// ミップマップレベルをアルファにマッピング
mipLevelImage[position + 3] = targetLevel / maxLevel * 255.0;
}
}
return { image: mipLevelImage, width: mipWidth, height: mipHeight };
}

cubeMap.onUpdate= function() {
var gl = renderer.context;
// three.jsのオブジェクトからテクスチャIDを取得する
var textureProperties = renderer.properties.get(cubeMap);
gl.bindTexture( gl.TEXTURE_CUBE_MAP, textureProperties.__image__webglTextureCube );
var width = cubeMap.image[0].width;
var height = cubeMap.image[0].height;
var maxLevel = maxLodLevel(width,height);
// 各面に対してミップマップテクスチャを生成する
for(var face = 0; face < 6; face++) {
var faceImage = getImageData(cubeMap.image[face]);
var prevMipLevelInfo = { image: faceImage.data, width: width, height: height };
for(var level = 0; level <= maxLevel; level++) {
var mipLevelInfo = generateMipLevel(prevMipLevelInfo, level, maxLevel)
gl.texImage2D( gl.TEXTURE_CUBE_MAP_POSITIVE_X + face, level, gl.RGBA, mipLevelInfo.width, mipLevelInfo.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, mipLevelInfo.image );
prevMipLevelInfo = mipLevelInfo;
}
}
}

generateMipLevel が実際にミップマップテクスチャを作成している関数です。

この関数を利用して、キューブマップのアルファにミップマップレベルをマッピングしたテクスチャを作成します。


シェーダーを修正する

キューブマップのフェッチ部分を以下のように修正します。

 // IBL

vec3 diffuse = textureCube(CubeMap, N, 100000.0).xyz;
float mipLevel = textureCube(CubeMap, R).a * MaxLodLevel;
vec3 specular = textureCube(CubeMap, R, log2(Roughness * pow(2.0, MaxLodLevel)) - mipLevel).xyz;

diffuseは、ミップマップレベルが一番高いテクスチャを選択したいので適当に大きな値をバイアスに設定します。

一方スペキュラは、ラフネスをバイアスに設定するだけでは上手く表示されません。

なので実際にラフネスに合わせてフェッチする前に、ハードウェアが選択したミップマップレベルのテクスチャをフェッチし、予めアルファにマッピングしておいたミップマップレベルを取得します

その後、事前に取得したミップマップレベルをラフネスから引くことで、ハードウェアに選択されるミップマップレベルを相殺します。


問答無用でミップマップレベル0が選択される問題

ここまでで拡張機能に対応していないブラウザでも動作するようになったので見てみましょう。

image

おお動いてる!

となったのも束の間、すぐに問題が目につきました。

静止画だとわかりにくいですが、立方体のディフューズがおかしい...

デモ

http://xn--u8jxb0b.com/threejs/cubemapibl/no_ext2.html

image

左:今回の実装               右:最初の実装

金属みたいにキラキラしていて、どれだけラフネスを設定しようとミップマップレベルのバイアスを巨大にしようと、ミップマップレベル0のテクスチャが使用されているのが原因のようです。

球は上手く言ってるのにどうしてだー


原因と回避策

ここで、ハードウェアで行われているミップマップの仕組みを調べてみると、あるテクスチャのミップマップレベルを決定する際に、スクリーン上の描画先ピクセルと近傍のピクセルが参照しているテクセル座標の距離を見ていることがわかりました。

具体的なミップマップレベルは log2( max(length(ddx(uv)), length(ddy(uv))) ) です。

ddx, ddyについては、こちらで実例を踏まえて解説してくださっています。

dFdxとdFdyでわずか4行のお手軽エッジ検出!

問題の立方体の状態に当てはめると、なぜこのような結果になってしまったのかわかりました。

今回diffuseは、キューブマップから法線方向にあるテクセルをフェッチしてきています。

もうお分かりかと思いますが、立方体の各面は平面で法線は一定なので、近傍ピクセルとの参照先テクセル同士の距離は0になります。

log2(0)はマイナス無限大なので、どれだけバイアスを加えても意味が無い状態となっていました。

(glsl上では必ずミップマップレベル0を選択するようになっているのでしょうか?)

そこで、回避策としてdiffuseを参照する際の法線に、小さいノイズを加えます。

幸い、参照するミップマップレベルは最大の粗いテクスチャなので、ノイズの値が小さければそこまで気にする必要は無さそうです。

しかし、ノイズが小さすぎるとテクセル間の距離が1テクセル以下になる可能性もあり、log2(1)以下はマイナスになるので適当な大きめの数値をバイアスに設定することで調節しました。

// IBL

vec3 diffN = normalize(N + rand3(wPosition.xyz) * 0.001);
vec3 diffuse = textureCube(CubeMap, diffN, 1000000.0).xyz;

image

だいぶ良くなりました!

デモ

http://xn--u8jxb0b.com/threejs/cubemapibl/no_ext3.html

しかし、こちらのバージョンには実はもう一つ問題があり、同じくミップマップの計算に起因するものです。

視線ベクトルと法線ベクトルの反射ベクトルで参照しているspecularも平面に限り参照先のテクセル間の距離が1ピクセル以下になり、ミップマップレベルがマイナスで指定されてしまっています

今回の方法ではどのようにしても 0 ~ MaxLodLevel のミップマップレベルにしか対応が出来ませんでした。

やはり正確にしようと思うと自前でミップマップレベルを計算するしかなさそうです..


さいごに

かなり長々となってしまいましたが今回はキューブマップとミップマップを組み合わせてお手軽にIBLを実装しました。

小賢しいマネをして拡張機能を使わずに動作するよう実装しましたが、WebGL 2.0相当のOpenGL ES 3.0には textureCubeLodEXT とほぼ同機能の関数が存在しています。

全てのブラウザがWebGL 2.0に対応すればこんなことしなくても済みますね!

しかし回りくどい実装を通して学びもありました。特にミップマップは普段気にすること無く使用していましたが、詳しい挙動を知ることが出来て有意義でした。

今後、挑戦・改善したい点は4つあります


  • texture2Dを自前でキューブマップとして扱ってミップマップを使わずに実装したい

  • 今回はボックスフィルタで縮小しただけのキューブマップを使用しましたが世の中にはBRDFに合わせた事前フィルタをキューブマップに掛けることで品質を上げる手法があるらしいのでやってみたい。

  • 偏微分関数の拡張は使うことになるが、自前でミップマップレベルを計算して最後の問題点を改善したい。

  • キューブマップにHDR画像を利用したい

今回は急ピッチで作業してしまったので今後はゆったりやっていければと思います。