LoginSignup
28

More than 5 years have passed since last update.

WebGLライブラリ向けのDeferred Renderingにおける動的ライト、シャドウマップの実装法について[jThreeガチ勢向け(?)]

Last updated at Posted at 2015-12-15

これはWebGL Advent Calender(http://qiita.com/advent-calendar/2015/webgl )16日目の記事です。

僕は普段jThreeというOSSなライブラリの新バージョンを作ることをしている。この新しいjThreeのv3であるがWebGLにおける新たなライブラリのあり方を定義しようという思いを持って開発は行われている日本発のOSSなWebGLライブラリである。
このjThreeは今までのjThree v2のいいところを継承しながら、three.jsやその他のライブラリとはまったく違う側面を持ったライブラリである。その用途は比較的ゲーム向けではなく、Webサービスの一部として融合されることを目指しているのであるが、これについては2日後にpnlybubblesがTechnicalPreviewについて記述するそうですのでそちらを参考にしていただきたい。

さて、僕がこのライブラリに関わっているのも、前バージョンまでは、完全にthree.js依存であったjThreeを専用のレンダラで作って欲しいとの要請を受けたのである。
それが今年4月のことであるが、それから半年余りの間遅延シェーディングを用いたライブラリに日々コミットメントしてきた。
この記事では、WebGLにおける遅延シェーディング(特にライトプレパス型)実現のために乗り越えなければならない壁、それの達成法について述べていく。特に、使えるAPIに限りが多いWebGLでは動的な数のドロップシャドウやライト、ライトの種類を実現するのに困難が多いのでこれについてメインで書いていく。
ただし、遅延シェーディングという高度なトピック故に、ある程度の前提知識が必要になってしまうことをご理解の上読んでいただきたい。
より深くコードベースで知りたい方は、jThreev3のレポジトリ(https://github.com/jThreeJS/jThree )を参照いただきたい。スター&プルリク大歓迎である。一緒に日本発のWebGLライブラリを作ろう!

G-buffer

浮動小数点テクスチャについて

遅延シェーディングのまず最初のステージであると言えるであろうG-bufferの構造の決定について。
まず、WebGLは標準では浮動小数点テクスチャを用いることができない。G-bufferの基本的な要素である法線マップや深度マップを使う上で困ってしまう。
ただし、WebGLの拡張規格「OES_float_texture」(https://www.khronos.org/registry/webgl/extensions/OES_texture_float/ )を用いることにより浮動小数点テクスチャを用いることができる。
気になるのはサポート率であるが、webglstats.comによれば、95%近いサポート率になっている。
image
本来では拡張規格を使うのは避けたいところではあるが、遅延シェーディングという高度な技術を用いる以上は致し方ないと判断し浮動小数点テクスチャを用いることにした。

各テクスチャバッファ構造

ライトプレパス型であるがゆえに、2段階のバッファの生成が必要である。1段階目はG-buffer、2段階目はライトバッファとなる。
image

また、G-bufferステージのテクスチャ構成及び、ライティングステージのテクスチャ構成は以下のようになっている。
structure-g-buffer.png
structure-lighting.png

入力となるジオメトリは、最初と最後はどうしてもシーンのジオメトリ全てになってしまう。これはパフォーマンス
に大きな影響を及ぼすが、ライトプレパスでない完全に一回だけのバッファ生成からは板ポリをジオメトリの入力として扱ってしまう方式であると、半透明オブジェクトの扱いに非常に不得手であるのと、マテリアルへの複数対応にも不得手である。
jThreeで求められたのはWebの5年後の標準になりうるレンダリングエンジンであることと、拡張性の良さであった。このためライトプレパス型のディファードシェーディングが採用されたのである。

各G-bufferテクスチャの例

ここでは以下のシーンを例に、実際にどのようなバッファが生成されているかについて見ていく。各バッファの生成に関してのノウハウは後述する。なお、キャプチャのタイミングの関係でそれぞれの表示結果にはずれがある。

最終レンダリング結果

result.png

G-buffer 1

  • R&G 圧縮済み法線
  • B 深度
  • A Specular係数

アルファ含めた状態
primary-1.png
アルファを常に1とした状態
primary-1-alpha-cut.png

G-buffer 2

Diffuse albedo
secoundary.png

G-buffer 3

Specular albedo
third.png

バッファ生成に関するノウハウ

法線の圧縮について

バッファ生成時に関して特に苦労した点といえば、法線のfloat値2つへの圧縮操作が主だと思われる。法線情報をFloat2つ分に抑えたいので、三次元のベクトルを2次元のベクトルに圧縮する必要がある。

最初は以下のような方法を考えたのだが、特定の条件下でうまくいかなかったが、失敗の部分も共有したいので述べる。
(前にDirectX11を用いてWindows上でテストした際にはうまく動作した記憶がある。)
圧縮時には既に正規化されている法線nを入力として、以下のようにして圧縮後の2次元ベクトルeを得ることができる。
encode1.png
このように圧縮された2次元ベクトルは以下の2段階を経て元々の法線へ復元することができる。
decode.png
すなわち、バッファ生成時のフラグメントシェーダーで圧縮しfloatテクスチャに書き込み、ライティング計算時のフラグメントシェーダーで同様の手法で元の法線データを取り出すことができる。
これは頂点シェーダーで行うことはできない、これは頂点シェーダーでp,q点の圧縮済みベクトルを計算し、それをt:(1-t)に内分する点がフラグメントシェーダーに渡るときには以下の式右辺となるが、これがフラグメントシェーダーで計算した時の期待する結果左辺と等しくないことが下式により検証できるからである。
7075c7a0e98ed62b5ff5fd50ff1fdbff.png

しかし、ここまでやったところではあるが、上記の圧縮手法だと、法線が特定の場合でうまく復元されないことがある。
当然であるが、復元時のzが0の時だけ復元処理はうまくいかないので、if文でうまくしてやる必要があるが、さらに頭を悩ませたのは計算誤差により圧縮後がNaNになってしまうことがあることだった。(原因はよく分からないがルート内が微妙に負になる?)

その後、法線の圧縮についてリサーチしていると、素晴らしい記事を発見。「Compact Normal Storage for small G-buffers」(http://aras-p.info/texts/CompactNormalStorage.html)
各種法線圧縮方法について考察されている。ここで、法線の圧縮方法についてはCry Engine3と同様の実装であるらしいSphere map transformという手法を用いた。
この手法では以下の式において法線の圧縮を行う。
24527a486309000a87518194786c94a0.png
逆に復元は以下のようにできる。
2120e309b7d2a2803dae69114ef2dd8e.png

なお、参考までに前の方式で法線を描画した場合以下のような感じに描画される。
b62ec0df414e1d8d58428e56428e2bd6.png

ライトバッファの例

G-bufferの例と同じシーンを用いてDiffuseライトバッファ及びSpecularライトバッファを以下に示す。

Diffuse lighting buffer

diffuse.png

Specular lighting buffer

specular.png

ライトの計算に関して、G-bufferから情報を復元した上で固定長個のライトを描画するのは決して難しい話ではない。ここでは特に、どのようにすれば動的な数のライト、動的な数のシャドウ、動的な種類のライトをレンダリングできるかについて語っていく。

まず、WebGLにおいて問題になるのが、WebGLで採用されているGLSLのバージョンでは配列を動的にアクセスすることができないことである。
例えば以下のように書くとエラーになる。

uniform mat4 matrices[128];
attribute vec4 position;
void main(void)
{
   int index = 10;
   vec4 position = matrices[index] * position;//エラー Index expression must be constant
}

一方でfor文に関しても問題が伴う。動的な回数のループを実行することができないのである。
例えば以下のように書くとエラーになる。

void main(void)
{
   int n = 10;
    for(int i = 0; i < n; i++)//エラー ループの条件文は定数でなくてはならない。
    {
        //SOMETHING
    }
}

このforに関する解決としては、以下のようにしてbreakでループを切ることにより解決ができる。


void main(void)
{
   int n = 10;
   for(int i = 0; i > -1;i++)
   {
      if(i == n)break;
      //SOMETHING
   }
}

一方で配列の方はどうしようもないので、テクスチャに変換する方法をとるといいだろう。各ライトに必要な引数をテクスチャに変換してこれを用いることができる。
これは一般的な方法であったが、ここではさらにライトが複数種類あり、種類の数が動的に変化することを想定する。

この場合、例えば最初の要素にライトの種類indexを入れることにより解決することができる。例えばあるポイントライトが必要な引数はその座標(vec3)、ライト色(vec3)、減衰距離(float)、減衰係数(float)であるが、これにポイントライトに割り当てたライトの種類Index(この例では0)を追加した以下のようなテクスチャを考えることにより全ての引数をシェーダーに渡すことができる。この際、このテクスチャの幅は全ての種類の中で要求する最大のfloat変数の数+1を4で割ったものを繰り上げた形になる。一方高さはライトの数だとしてテクスチャを生成すれば良い。

cbbd1e13b76cb024e43f69dafc5bb782.png

このようにしてテクスチャを生成すると、ライトのIDとパラメータのindexからパラメータを取る必要がある。
これも以下のようにしてライトのID(i)とパラメータのID(j)から対応するUV値を取れるのでこれをもとにtexture2Dをシェーダー側で呼び出してやれば良い。

fca3543312a735f43328ee08f1c7ff79.png

ライトの種類に対応するために、ある程度生成してあるテンプレートからライト用のシェーダーを組み合わせるのをランタイム時に行うことによって対応する。例えば、Diffuseライト用のシェーダーのテンプレートは以下のようになっている。

//Header should be inserted above like below
//precision mediump float
//#define SHADOW_MAP_LENGTH <<Max count of shadow map>>
//uniform variables
uniform highp   sampler2D primary;
uniform lowp sampler2D secoundary;
uniform lowp sampler2D third;
uniform highp   sampler2D lightParam;

uniform vec2 lightParamSize;

uniform mat4 matIP;
uniform mat4 matV;
uniform mat4 matIV;

uniform highp sampler2D shadowParam;
uniform lowp sampler2D shadowMap;
uniform float shadowMapMax; //sqrt(maximum shadow map count)<- because this is count of edge for shadow matrix texture

//varying variables
varying vec2 vUV;

//Get depth from texture
float getDepth()
{
    return texture2D(primary,vUV).z;
}
//Get normal from texture
vec3 getNormal()
{
    highp vec2 compressed = texture2D(primary,vUV).xy * 4. - vec2(2.,2.);
    highp vec3 result;
    float f = dot(compressed,compressed);
    float g = sqrt(1. - f/4.);
    result.z = 1. - f/2.;
    result.xy = compressed * g;
    return normalize(result);
}
//Reconstruct position
vec3 getPosition(float depth)
{
    vec4 reconstructed = matIP*vec4(vUV * 2. - vec2(1.,1.),depth,1.);
    return reconstructed.xyz / reconstructed.w;
}
//Get light parameter from uv
vec2 getLightParameterUV(int lightIndex,int parameterIndex)
{
    float xStep = 1./lightParamSize.x;
    float yStep = 1./lightParamSize.y;
    return vec2(xStep / 2. + float(parameterIndex) * xStep,1.-( yStep / 2. + float(lightIndex) * yStep));
}
//Get light parameter from light index and prameter index
vec4 getLightParameter(int lightIndex,int parameterIndex)
{
   return texture2D(lightParam,getLightParameterUV(lightIndex,parameterIndex));
}
//Get light type
float getLightType(int lightIndex)
{
    return getLightParameter(lightIndex,0).r;
}

vec4 getDiffuseAlbedo()
{
    return texture2D(secoundary,vUV);
}

vec3 getSpecularAlbedo()
{
    return texture2D(third,vUV).rgb;
}

float getSpecularCoefficient()
{
    return texture2D(primary,vUV).a;
}

highp float unpackFloat(vec3 rgb){
       const vec3 bit_shift = vec3( 1.0/(256.0*256.0), 1.0/256.0, 1.0);
       highp float res = dot(rgb, bit_shift);
       return res*2.0 -1.0;
}

mat4 getShadowMatrix(float shadowIndex,float paramIndex)
{
    const float matrixCount = 2.;
    const float textureWidth = matrixCount * 8.;
    float y = 1./(2.*shadowMapMax) + 1./shadowMapMax*shadowIndex;
    return mat4(
    texture2D(shadowParam,vec2(1./textureWidth+1./matrixCount*paramIndex,y)),
    texture2D(shadowParam,vec2(3./textureWidth+1./matrixCount*paramIndex,y)),
    texture2D(shadowParam,vec2(5./textureWidth+1./matrixCount*paramIndex,y)),
    texture2D(shadowParam,vec2(7./textureWidth+1./matrixCount*paramIndex,y)));
}

bool isInTextureUVRange(vec2 uv)
{
    return uv.x >= 0. && uv.x <= 1. && uv.y >= 0. && uv.y <=1.;
}

///<<< LIGHT FUNCTION DEFINITIONS

void main(void)
{
    float depth = getDepth();
    if(depth== -1.)discard;
    vec3 position = getPosition(depth);
    vec3 normal = getNormal();
    vec4 diffuse = getDiffuseAlbedo();
    gl_FragColor.rgb = vec3(0,0,0);
    for(float i = 0.;i>-1.; i++)
    {
        if(lightParamSize.y == i)break;
        ///<<< LIGHT FUNCTION CALLS
    }
}

///<<< LIGHT FUNCTION DEFINITIONSの部分にライトの関数の定義が挿入され、///<<< LIGHT FUNCTION CALLSの部分にライトの関数をそれぞれ呼ぶ処理が自動的に挿入される。

例えば、Directional Lightの場合は以下のようなシェーダーの一部を指定することによって///<<< LIGHT FUNCTION CALLSの部分を自動的に生成される。


vec3 calcSpotLight(vec3 position,vec3 normal,int i,vec4 diffuse)
{
  vec3 accum=vec3(0,0,0);
  vec3 color = getLightParameter(i,0).yzw;
  vec3 lpos = getLightParameter(i,1).xyz;
  vec3 ldir = getLightParameter(i,2).xyz;
  vec3 params = getLightParameter(i,3).xyz;
  vec3 l2p = normalize(position-lpos);
  accum += color * diffuse.xyz * max(0.,dot(-l2p,normal)) * pow(max(0.,min(1.,dot(l2p,ldir)/(params.x-params.y) - params.y/(params.x - params.y))),params.z); // spot light range attenuation
  return accum;
}

これにより以下のような呼び出しが///<<<LIGHT FUNCTION CALLSの部分に生成される。なお、コード中の4はライトの種類Indexであり自動的にランタイム時に決定される。

if(getLightType(int(i)) == 4.)gl_FragColor.rgb+=calcSpotLight(position,normal,int(i),diffuse);

このようにしてシェーダーをランタイムに生成することによってライブラリでユーザーがライトの種類をシェーダーの断片だけ用意すれば追加可能な拡張性を得ることができる構造ができる。

動的ドロップシャドウ

シャドウの演出はリソースを複数使う上にライトの種類によってシャドウマップを使うかどうかは異なるためライトのパラメーターの中でも動的化が難しい。
ドロップシャドウを使う上ではテクスチャを使うことになるが、GLSLではサンプラの配列を用意することができない(用意できたとしてレジスタの数が小さいと思うが)
そのため、1つの大きなテクスチャを複数個のテクスチャとして区切って利用することによりこれを実現することができる。
ae49e389b32629ed3719add9d57fd6d2.png
例えばこれが2*2の状態だった場合にレンダリングされた深度バッファは以下のようになる。
8d2563122f5d4b930bc2739063d418a6.png

シャドウマップの列と行がわかれば以下のような行列を生成し、後続する数式のように掛け合わせることにより射影されたUV座標を得ることができシャドウマップを取得可能である。

60d1c3ab02830849ba11033665c2b71a.png

最後に

本当はもう少し共有できる内容もあったのだが時間がギリギリになってしまったので別のQiitaの記事にでも書こうと思う。
今回の内容は僕がアメリカに留学中に発表したプレゼンにも同様の内容が含まれている。より深く知りたい方は参照していただきたい。
http://www.slideshare.net/LimeStreem/dynamic-lighting-and-dropping-shadow-in-webgl
http://www.slideshare.net/LimeStreem/efficacy-of-deferred-rendering-in-webgl?related=1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28