自作のライブラリGrimoire.jsでPBR(Physically Based Rendering)を実装したので自分の理解も兼ねて少しまとめます。
ちなみにこのシェーディングは次のバージョンで取り込まれます。
実際のサンプル
ここからは難しいかもしれない内容。
基礎的な理論
レンダリング方程式
ある点における明るさを求めるということはレンダリング方程式
と呼ばれる以下の式をとくことそのものです。
L_o(x,\omega_v) = L_e(x,\omega_v) + \int_\Omega f_r(x,\omega_i,\omega_v)L_i(x,\omega_l)(n \cdot \omega_i) d\omega_i
\\
x:明るさを求めたい点\\
\omega_v:xから視点への方向ベクトル\\
n:点xにおける法線ベクトル\\
fr:xの点における、\omega_iからの光が\omega_vへ反射する係数を求める式(後述)\\
\Omega:半球上の積分領域
こうして記述すると、非常に難解に見えますが、分解して考えます。
左辺
L_o(x,\omega_v)
この式における、Loは点xにおけるωv方向への光の強さを表します。これはつまり、この点xにおけるピクセルの色ということになります。
Le
L_e(x,\omega_v)
この部分は、自己発光色(Emission)の部分です。
ある点から目に入ってくる光というのはその点が発光した光のエネルギー
とその点に入射した光の中で、目に向かった光のエネルギーの合計
の和になります。
このLeの項目はこのうちの 発光したエネルギーにあたる部分 です。これ自身が火などの発光するものでない限りは通常必要ありません。
積分の部分
\int_\Omega f_r(x,\omega_i,\omega_v)L_i(x,\omega_l)(n \cdot \omega_i) d\omega_i
この部分における、ωiは積分変数ですが、xを中心とする半球上の方向ベクトルです。
この、半球上の各方向から、xに向かう光のうち、視線方向に反射される光の量だけを足し合わせたら求めたいものになりそうではないですか?
これをやるための積分なのです。
まずは、Ω上のある方向、ωiから光が入射したとして、そのときに目の方向へ向かうエネルギーに寄与する量を考えるとしましょう。
この時、ωiからxに入射される光の量をLi(x,ωi)で表しています。ここで、Lambartの余弦則によって、法線とωiとの傾きが多ければ大きいほど単位面積当たりのLi(x,ωi)によって照射されるエネルギーは減少し、これは法線とのなす角をθとしたとき、cosθを掛け合わせることにより、単位面積当たりのエネルギーがもとまることになります。
つまり、これは単位面積当たりの入射したエネルギーの合計です。
このエネルギーから特定の方向へのある角度当たりのエネルギーの比が求まれば、これを掛け合わせたものを合計すれば、他のところからやってくる光の反射によって光が目にはいる率は求まります。
frはxの点がどのような材質によって成り立っているかによって決まります。このfrはBRDFと呼ばれ、物質の特性から導かれる式です。
実用上のBRDF
実用上は、このBRDFは以下のDiffuse BRDF,Specular BRDFによって分割され計算されます。Diffuseは拡散光、Specularは反射光に寄与するBRDFです。
f_r = k_df_d + (1- k_d) f_s
ここで、kdは0から1までの範囲を取り、小さければ小さいほど反射によって色が決定するために寄与する率は大きくなります。
fsとfdについては、Unreal Engineのコースノートや、Coding Labs: Physically Based Rendering Cook-Torranceがとても参考になりました。
具体的な数式をここに記述するのは、すごく面倒なので上記のリンクを参照して欲しいですが、基本的にはUnreal Engineの採用している通りに実装しています。
つまり、
- Diffuse BRDF・・・Lambert
- Specular BRDF
- Microfacet項・・・GGX
- 幾何減衰項・・・GGX
- Fresnel項・・・Schlick近似
になっています。
実装の方針
WebGLのGLSLで、任意個のライトを表現するのには様々な課題が存在します。
特に、ループの回数に変数を取れないのが大きな課題でしょう。
拙作のライブラリの場合、依存するマクロが動的に変わった時だけ自動的にシェーダーをリコンパイルする機能が入っているので、この点はすぐに実装できますが、ライトの数が変動しすぎるシーンの場合どうしても重くなってしまうと言う欠点は存在します。
ライトのラディアンスの合計
シェーダー全体を貼るとすごく大きくなってしまうので、順番に解説します。
vec3 shading(vec3 baseColor,vec3 fragNormal,vec3 fragPosition,float roughness){
vec3 lightingColor = vec3(0);
#ifdef USE_DIR_LIGHT
lightingColor.rgb += directionalLight(baseColor,fragNormal,fragPosition,roughness);
#endif
#ifdef USE_POINT_LIGHT
lightingColor.rgb += pointLight(baseColor,fragNormal,fragPosition,roughness);
#endif
#ifdef USE_SPOT_LIGHT
lightingColor.rgb += spotLight(baseColor,fragNormal,fragPosition,roughness);
#endif
return lightingColor;
}
このライティングカラーは以下の式の部分になります。
\int_\Omega f_r(x,\omega_i,\omega_v)L_i(x,\omega_i)(n \cdot \omega_i) d\omega_i
しかし、リアルタイムな計算では積分計算などできないので、シーン中に存在するライト全てに対して1回目の反射しか考慮しないものとします。つまり、ライトから直接の入射以外ないと考え以下のように近似します。
\sum^{N-1}_{k=0}f_r(x,\omega_k,\omega_v)L_i^{(k)}(x,\omega_k)(n \cdot \omega_k)
ここで、ωkはk番目のライトの方向への方向ベクトル(ただし、ディレクショナルライトの場合、明確な位置は存在しないので、この場合ディレクショナルライト自身の方向)、また、Li(k)は、k番目のライトがωkの方向からxに放射する放射照度になります。
例えば、ディレクショナルライトでは以下のような実装になります。
float lambert(vec3 lightDirection,vec3 surfaceNormal) {
return max(0.0, dot(lightDirection, surfaceNormal));
}
vec3 directionalLight(vec3 baseColor,vec3 fragNormal,vec3 fragPosition,float roughness){
vec3 result = vec3(0,0,0);
for(int i = 0; i < DIR_LIGHT_COUNT;i++){
vec3 lI = lambert(fragNormal,-_dLightDir[i]) * _dLightColor[i];
vec3 lColor = lI * PBR_BRDF(baseColor,-_dLightDir[i],normalize(_cameraPosition - fragPosition),fragNormal,roughness);
result += lColor;
}
return result;
}
上記のPBR_BRDF
は後ほど説明しますが、上のfrにあたり、以下の5つを受け取っています。
- baseColor(アルベド)
- ライトへの方向(ωk)
- カメラへの方向(ωv)
- その点の法線(n)
- ラフネス
太字の項目以外は数式の方では、frの中に使われますが、ここではxに応じて異なるため一緒に渡しています。
実際のPBRの式は以下のようになっています。
#ifndef DIFFUSE_BRDF
#define DIFFUSE_BRDF lambertBRDF
vec3 lambertBRDF(vec3 c,vec3 i,vec3 o,vec3 n,float roughness){
return c / PI;
}
#endif
#ifndef SPECULAR_BRDF
#define SPECULAR_BRDF cookTorranceBRDF
#ifndef CT_D
#define CT_D ctd_GGX_Distribution
#endif
#ifndef CT_F
#define CT_F ctf_Schlick
#endif
#ifndef CT_G
#define CT_G ctg_GGX_GeometryTerm
#endif
float ctd_GGX_Distribution(vec3 l,vec3 v,vec3 n,vec3 h,float roughness){
float alpha2 = pow(roughness,4.0);
float nh2 = pow(dot(n,h),2.0);
return alpha2/(PI*pow(nh2*(alpha2 - 1.0) + 1.0,2.0));
}
float ctg_GGX_GeometryTerm(vec3 l,vec3 v,vec3 n,vec3 h,float roughness){
float k = pow(roughness + 1.0,2.0)/8.0;
float ln = dot(l,n);
float vn = dot(v,n);
return (ln/(ln*(1.-k) + k))*(vn/(vn*(1.-k) + k));
}
float ctf_Schlick(vec3 l,vec3 v,vec3 n,vec3 h,float roughness){
float f0 = refractive;
float vh = dot(v,h);
return f0 + pow(1.0-vh,5.0) * (1.0 - f0);
}
vec3 cookTorranceBRDF(vec3 l,vec3 v,vec3 n,float r){
vec3 h = normalize(l+v);
return vec3(CT_D(l,v,n,h,r) * CT_F(l,v,n,h,r) * CT_G(l,v,n,h,r)/(4.0 * dot(l,n) * dot(v,n)));
}
#endif
#ifndef PBR_BRDF
#define PBR_BRDF pbrBRDF
vec3 pbrBRDF(vec3 c,vec3 i,vec3 o,vec3 n,float r){
float k0 = kCoeff;
return DIFFUSE_BRDF(c,i,o,n,r) * k0 + SPECULAR_BRDF(i,o,n,r) * (1.0 - k0);
}
#endif
後から、式を入れ替えやすいように、マクロがかなり使われていて読みにくいですが、UEの式をそのまま持ってきたような形となっています。
あとは、ポイントライトや、スポットライトでも、同じBRDFが使えるので、その手前までを分けて以下のように書いてあげれば良いことになります。
#ifdef USE_POINT_LIGHT
vec3 pointLight(vec3 baseColor,vec3 fragNormal,vec3 fragPosition,float roughness){
vec3 result = vec3(0,0,0);
for(int i = 0; i < POINT_LIGHT_COUNT;i++){
vec3 l2p = _pLightPosition[i] - fragPosition;
float d = length(l2p);
vec2 param = _pLightParam[i];
float atten = max(0.,1.0-d/param.x)/(1.0 + param.y*param.y*d);
l2p = normalize(l2p);
vec3 lI = lambert(fragNormal,l2p)* _pLightColor[i] * atten;
vec3 lColor = lI * PBR_BRDF(baseColor,l2p,normalize(_cameraPosition - fragPosition),fragNormal,roughness);
result += lColor ;
}
return result;
}
#endif
#ifdef USE_SPOT_LIGHT
vec3 spotLight(vec3 baseColor,vec3 fragNormal,vec3 fragPosition,float roughness){
vec3 result = vec3(0);
for(int i = 0; i < SPOT_LIGHT_COUNT; i++){
float innerConeAngle = _sLightParam[i].x;
float outerConeAngle = _sLightParam[i].y;
float outCos=cos(outerConeAngle);
float innCos=cos(innerConeAngle);
vec3 p2l = _sLightPosition[i] - fragPosition;
float d = length(p2l);
p2l=p2l/d;
float c = dot(-p2l,normalize(_sLightDir[i]));
float decay = _sLightParam[i].z;//減衰係数
decay = 1.;
float angleDecay = decay;
//
float distDecayCoefficient = 1.0 / (d * d);
float angleDecayCoefficient = pow(clamp((c-outCos)/(innCos-outCos),0.0,1.0),angleDecay);
//
vec3 lI = lambert(p2l,fragNormal)*_sLightColor[i]*angleDecayCoefficient*distDecayCoefficient;
vec3 lColor = lI * PBR_BRDF(baseColor,p2l,normalize(_cameraPosition - fragPosition),fragNormal,roughness);
result += lColor;
}
return result;
}
#endif
すごくわかりづらいですが、大事なのはlIとしてその場所に入射する光のラディアンスを求め、そのままBRDFとの計算結果と掛け合わせてその点のカメラの点への照度がわかることになります。
シェーダーの部分は以下に置いてあるのでよかったら参考になれば。
また、これはgrimoireのカスタムマテリアル用のファイルなので、これについてはいかが参考になる。