PBRマテリアルでパストレーシングしてみた!(Disney Principled BRDF)

2025-06-06

いかにもCGで作りましたっていうクッキリハッキリした反射ではなく、少し不完全な金属の光沢や表面のざらざら具合を表現するためのマテリアル表現をパストレーサーに組み込んでみた。

PBRマテリアルでパストレーシング

640x480, 25,000サンプル/ピクセル, 39.03分, M1MacbookAir (8コア)

解説動画:

物理ベースレンダリング用のマテリアル

写実的なCGを実現するためのマテリアル表現として、 Physically Based Shading At Disneyという発表でディズニーのCG映画制作での経験からアーティストが扱いやすくて表現の幅が広いマテリアル構成が示された (Disney Principled BRDF)。 それを元にReal Shading in Unreal Engine 4でリアルタイムCGに向けた工夫が発表された。 これらがいわゆる「業界標準」となって、オフラインでもリアルタイムでも、今では様々なCGソフトやゲームエンジンで使われている。

主要なパラメータとして、

  • ラフネス:表面の粗さ
  • メタリック:金属かどうか(値としては0.0〜1.0だが中間値は設定しない方がとのこと)

がある。 今回はこの2つを組み込んでみる。

理論

物体表面のざらざら具合の表現はマイクロファセット理論という考え方が元になっている。 目には見えないスケールで表面に凹凸があり、その具合によって反射の像がクッキリするかぼやけるかが決まるとする。

また金属は非金属に比べて反射率がだいぶ高いので異なる扱いにする必要がある。

計算方法

物体表面での光の反射の計算を鏡面反射と拡散反射に分けて考える。 鏡面反射ではその反射率という係数があるんだけど、どんな場合にも一定というわけではなく見る角度によって変わってくる(フレネル反射)。 フレネル反射は「材質が鏡や水面なら適用、マットな素材には適用しない」とかいった場合分けはせずに、どんなマテリアルに対しても適用する (ラフネスが高く粗い面であれば反射率が高くなっても結局ぼやけた像になるので一概に光沢が入るわけではない)。

そして鏡面反射率で反射されずに残った分を拡散反射として計算する。 拡散反射では波長(RGB)によって吸収率が異なることで色が付加される。

パラメータがどのように影響するか?

ラフネス:鏡面反射時の反射方向のぶれ具合にだけ関わり、拡散反射には関係しない(元から完全に粗いので)。 ラフネスが高いと反射像のぶれが大きくなり、ぼやけた反射になる。

メタリック:材質が金属かどうかで見た目が全く異なるので本来は真偽値で指定すべきなんだけど、非金属の見た目とのブレンド率となっている。 大きくはデフォルトの反射率に関わる。 非金属の場合直角から見た場合の反射率はかなり低いが(4%くらい)、金属であればずっと高くて直角で見ても大半が反射される。 でパラメータを削減する目的か設定しやすさなのか、非金属の場合に拡散反射でのベースカラーとして指定する色をメタリックの場合にはデフォルトの反射率として扱う (具体的にはメタリックの値によって線形補間)。 そして鏡面反射はメタリックの場合ベースカラーを、非金属の場合は1.0を乗算するよう線形補間する。 また拡散反射はないものとする(具体的には黒と線形補間して影響0にする)。

パストレーサーに組み込む

先ほどのPBRマテリアルをパストレーサーに組み込む。

ベースのソースコード:smallpt

ベースとなるパストレーサーはsmallptという、C++で99行という短さのコードを利用させてもらう。 smallptはコーネルボックスのシーンを描画できて、またOpenMPの並列forを使って並列計算でそこそこ高速に動作する。 形状に球しか扱えないという制約はあるが、簡潔なコードで理解・変更しやすいという利点がある。

smallptではマテリアルを拡散反射DIFF・鏡面反射SPEC・屈折REFRと場合分けしているが、それを統合して扱うよう変更するのが主な目的。

フレネル反射率

元のコードでもフレネル反射は屈折の場合のみ扱われていたが、どんなときにも適用するよう変更する。 ただし直角に覗き込んだときの反射率をメタリックかどうかによって決定する。 また透過の場合には屈折率から求めて、物体内部から出ていく場合には全反射となるように反射率を計算する。

マイクロファセット法線分布

ラフネスでの表面の粗さが実際にどうやって反映されるかというと、衝突面の法線ベクトルをぶらすことで実現する。 計算方法は”Real Shading in Unreal Engine 4”中のImportanceSampleGGXを使用する:

float3 ImportanceSampleGGX( float2 Xi, float Roughness, float3 N )
{
float a = Roughness * Roughness;
float Phi = 2 * PI * Xi.x;
float CosTheta = sqrt( (1 - Xi.y) / ( 1 + (a*a - 1) * Xi.y ) );
float SinTheta = sqrt( 1 - CosTheta * CosTheta );

float3 H;
H.x = SinTheta * cos( Phi );
H.y = SinTheta * sin( Phi );
H.z = CosTheta;

float3 UpVector = abs(N.z) < 0.999 ? float3(0,0,1) : float3(1,0,0);
float3 TangentX = normalize( cross( UpVector, N ) );
float3 TangentY = cross( N, TangentX );
// Tangent to world space
return TangentX * H.x + TangentY * H.y + N * H.z;
}
  • 上記はシェーダーのコードで、Xiは0.0〜1.0の乱数を表す
  • ラフネスRoughnessに従って、法線Nを半球状にぶらしたベクトルが返る

ぶらした法線が向きによっては問題になる

  • ラフネスが高く法線が大きくぶれて視線方向とは逆向きだと、衝突するはずがない裏面にぶつかったことになってしまう
  • またぶらした法線で反射させた時に元の面法線に向かう方向になると、再度自分にめり込むことになるので意図せぬ屈折になってしまう

このような場合には棄却して再度サンプルすることにした (それでいいのかどうかは不明…)。

透過・屈折

smallptにはガラス玉のような透過・屈折する球体も配置されている。 これも活かしたいので「不透明度」というパラメータを追加した。 拡散反射成分にはopacityを乗算し、透過・屈折分には1.0 - opacityを乗算して加算することで背景が透けて見えることになる。

実際には拡散反射分と透過分という複数方向をトレースしてしまうと指数的に計算が増えてしまうので、乱数が不透明度以下かどうかでどちらかを選択するようにした。

透過材質のフレネル計算

透過材質の場合、入射前と入射後の媒質の屈折率でフレネル反射率が決まってくる。 また屈折率が高い媒質から低い材質に入射する場合には屈折せずに全反射になる。 その場合と整合するように入射のベクトルを使って計算する。 このような計算が必要なため、不透明な場合とは別計算することになった(smallptどおり)。

全コード

なんとか元と同じく99行に詰め込んだ(ただし横幅は100文字程度に緩和、閉じインデントは犠牲に):

#include <math.h>   // Original: smallpt, a Path Tracer by Kevin Beason, 2008
#include <stdlib.h> // Make : g++ -O3 -fopenmp smallpt.cpp -o smallpt
#include <stdio.h> // Modified by tyfkda, added PBR material.
struct Vec {
double x, y, z; Vec(double xyz_=0) { x=y=z=xyz_; }
Vec(double x_, double y_, double z_){ x=x_; y=y_; z=z_; }
Vec operator+(const Vec &b) const { return Vec(x+b.x,y+b.y,z+b.z); }
Vec operator-(const Vec &b) const { return Vec(x-b.x,y-b.y,z-b.z); }
Vec operator*(double b) const { return Vec(x*b,y*b,z*b); }
Vec mult(const Vec &b) const { return Vec(x*b.x,y*b.y,z*b.z); }
Vec norm() const { return *this * (1/sqrt(x*x+y*y+z*z)); }
double dot(const Vec &b) const { return x*b.x+y*b.y+z*b.z; } // cross:
Vec operator%(const Vec&b) const {return Vec(y*b.z-z*b.y,z*b.x-x*b.z,x*b.y-y*b.x);} };
inline Vec reflect(const Vec& v, const Vec& n) { return v-n*2*n.dot(v); }
inline Vec refract(const Vec& v, const Vec& n, double nnt) {
double ddn=v.dot(n), cos2t=1-nnt*nnt*(1-ddn*ddn);
return (v*nnt - n*(ddn*nnt+sqrt(cos2t))).norm(); }
struct Ray { Vec o, d; Ray(const Vec& o_, const Vec& d_) : o(o_), d(d_) {} };
struct Sphere {
double rad; Vec p, e, c; double roughness, metallic, opacity;
Sphere(double rad_, Vec p_, Vec e_, Vec c_, double rgh=.2, double metl=0, double opc=1):
rad(rad_), p(p_), e(e_), c(c_), roughness(rgh), metallic(metl), opacity(opc) {}
double intersect(const Ray &r) const { // returns distance, 0 if nohit
Vec op = p-r.o; // Solve t^2*d.d + 2*t*(o-p).d + (o-p).(o-p)-R^2 = 0
double t, eps=1e-4, b=op.dot(r.d), det=b*b-op.dot(op)+rad*rad;
if (det<0) return 0; else det=sqrt(det);
return (t=b-det)>eps ? t : ((t=b+det)>eps ? t : 0); } };
Sphere spheres[] = {//Scene: radius, position, emission, color, material
Sphere(1e5, Vec(-1e5+1,40.8,81.6), Vec(),Vec(.75,.25,.25)),//Left
Sphere(1e5, Vec( 1e5+99,40.8,81.6),Vec(),Vec(.25,.25,.75), .2, 1),//Rght
Sphere(1e5, Vec(50,40.8,-1e5), Vec(),Vec(.75,.75,.75)),//Back
Sphere(1e5, Vec(50,40.8, 1e5+296), Vec(),Vec() ),//Frnt
Sphere(1e5, Vec(50,-1e5, 81.6), Vec(),Vec(.75,.75,.75)),//Botm
Sphere(1e5, Vec(50, 1e5+81.6,81.6),Vec(),Vec(.75,.75,.75)),//Top
Sphere(16.5,Vec(27,16.5,47), Vec(),Vec(1, .70, .29), .35, 1),//Mirr
Sphere(16.5,Vec(73,16.5,78), Vec(),Vec(.5,1,1), .2, 0, .5),//Glas
Sphere(8,Vec(47,8,110), Vec(),Vec(1,0,0), .1),
Sphere(600, Vec(50,681.6-.27,81.6),Vec(12,12,12), Vec() ,0),//Lite
};
inline double clamp(double x){ return x<0 ? 0 : x>1 ? 1 : x; }
inline int toInt(double x){ return int(pow(clamp(x),1/2.2)*255+.5); }
inline bool intersect(const Ray &r, double &t, int &id){
int n=sizeof(spheres)/sizeof(Sphere); double d, inf=t=1e20;
for(int i=n;i--;) if((d=spheres[i].intersect(r))&&d<t){t=d;id=i;} return t<inf; }
Vec F(const Vec& F0, const Vec& V, const Vec& H) { // Fresnel reflection
return F0 + (Vec(1.0) - F0) * pow(1.0 - fmax(V.dot(H), 0.0), 5.0); } // Schlick approximation
Vec importanceSampleGGX(unsigned short *Xi, double Roughness, const Vec& N) {
double a = Roughness * Roughness, Phi = 2.0 * M_PI * erand48(Xi), xiy = erand48(Xi);
double CosTheta = sqrt((1.0 - xiy) / (1.0 + (a * a - 1.0) * xiy));
double SinTheta = sqrt(1.0 - CosTheta * CosTheta);
Vec H(SinTheta * cos(Phi), SinTheta * sin(Phi), CosTheta);
Vec UpVector = abs(N.z) < 0.999 ? Vec(0.0, 0.0, 1.0) : Vec(1.0, 0.0, 0.0);
Vec TangentX = (UpVector % N).norm(), TangentY = N % TangentX;
return TangentX * H.x + TangentY * H.y + N * H.z; }
Vec radiance(const Ray &r, int depth, unsigned short *Xi){
double t; int id=0; if (!intersect(r, t, id)) return Vec(); // if miss, return black
const Sphere &obj = spheres[id]; ++depth; // the hit object
Vec x=r.o+r.d*t, n=(x-obj.p).norm(), c=obj.c;
if (c.x == .75 && c.y == .75 && c.z == .75 && n.y > -.1) { // Checkered pattern.
double s=0.1; int tx=fmod((x.x+s*1000)*s,2), ty=fmod(x.y*s,2), tz=fmod(x.z*s,2);
if (((tx+ty+tz) & 1) == 0) c=c*(1./16); }
bool into=n.dot(r.d)<0; Vec nl=into?n:n*-1, V=r.d*-1, N, L;
do { N = importanceSampleGGX(Xi, obj.roughness, nl); L = reflect(r.d, N); } while (L.dot(nl)<=0);
const Vec &H = N /*==(V + L).norm()*/; double m = obj.metallic, opacity = obj.opacity;
Vec F0 = Vec(0.04) * (1-m) + c * m, Ks = F(F0, V, H), tdir=L;
if (opacity < 1.0) {
double nc=1, nt=1.5, nnt=into?nc/nt:nt/nc, ddn=r.d.dot(N);
if (1-nnt*nnt*(1-ddn*ddn)>=0) { // Refraction
tdir=refract(r.d,N,nnt); double a=nt-nc, b=nt+nc, R0=a*a/(b*b), cc=1-(into?-ddn:tdir.dot(n));
double Re=R0+(1-R0)*pow(cc,5); Ks = Vec(Re) + (c - Vec(Re)) * (m * opacity);
} else Ks = Vec(1); /* Total internal reflection */ }
Vec Kd = Vec(1.0) - Ks; double q = fmax(Ks.x,fmax(Ks.y,Ks.z)), p=1;
if (erand48(Xi) < q){ // SPECULAR reflection
return obj.e + Ks.mult(radiance(Ray(x,L),depth,Xi)) * (1/q);
} else if (erand48(Xi) < opacity * opacity){ // DIFFUSE reflection
if (depth>5){p=fmax(c.x,fmax(c.y,c.z))*.99; if (erand48(Xi)>=p) return obj.e;} //R.R.
Vec d = importanceSampleGGX(Xi, 1.0, n); // Random sampling for outward hemisphere
return obj.e + c.mult(radiance(Ray(x,d),depth,Xi)).mult(Kd) * ((1-m)/(p*(1-q)));
} else { // Dielectric REFRACTION
Vec c2 = Vec(sqrt(c.x), sqrt(c.y), sqrt(c.z));
if (depth>5){p=fmax(c2.x,fmax(c2.y,c2.z))*.99; if (erand48(Xi)>=p) return obj.e;} //R.R.
return obj.e + c2.mult(radiance(Ray(x,tdir),depth,Xi)).mult(Kd) * (1/(p*(1-q))); }
}
int main(int argc, char *argv[]){
int w=1024/2, h=768/2; long samps = argc==2 ? atoi(argv[1]) : 100; // # samples
Ray cam(Vec(50,52,295.6), Vec(0,-0.042612,-1).norm()); // cam pos, dir
Vec cx=Vec(w*.5135/h,0,0), cy=(cx%cam.d).norm()*.5135, *c=new Vec[w*h];
for (long s=0, ns=8; ++s <= samps; ){
fprintf(stderr, "\rRendering (%ld/%ld spp) %5.2f%%", s, samps, 100.*s/samps);
#pragma omp parallel for schedule(dynamic, 1) // OpenMP
for (int y=0; y<h; y++){ // Loop over image rows
unsigned short Xi[3]; Xi[0]=(-3*y)*((s*131)>>7); Xi[1]=s*s; Xi[2]=y*y*y;
for (int x=0, i; i=(h-y-1)*w+x, x<w; x++) { // Loop cols
Vec d = (cx*((erand48(Xi)+x)/w-.5) + cy*((erand48(Xi)+y)/h-.5) + cam.d).norm();
c[i] = c[i] + radiance(Ray(cam.o,d),0,Xi); } }
if (s >= ns || s >= samps) {
FILE *f = fopen("image.ppm", "w"); fprintf(f, "P3\n%d %d\n%d\n", w, h, 255);
for (int i=0; i<w*h; i++)
fprintf(f,"%d %d %d ", toInt(c[i].x/s), toInt(c[i].y/s), toInt(c[i].z/s));
fclose(f); ns <<= 1; } } }

描画結果

以下、左上がパラメータの値0.0で右下が1.0(0.111刻み)

非金属でラフネス変化
非金属(メタリック0.0)でラフネス変化

メタリック変化
滑らか面(ラフネス0.0)でメタリック変化(非推奨)

金属でラフネス変化
金属(メタリック1.0)でラフネス変化:接点で赤くなってるの気になる

透過材質でラフネス変化
透過材質(不透明度0.0)でラフネス変化

不透明度変化
滑らか面(ラフネス0.0)で不透明度変化

あれこれ

  • importanceSampleGGX
    • ラフネスに1.0を渡せば拡散反射の半球状のサンプリングにも利用可能
    • これもあり名前の通りcosに比例した分布になっているので(多分)、入射角と法線との内積の乗算は省略できる
    • GGX = Ground Glass Unknown、といわれてもわからん
  • ぶらした法線が無効な向きだった場合、元のマイクロファセットでは遮蔽項で無効化されるのだろうが、やってみたら暗くなってしまったので棄却して再サンプリングするようにしてみた(これはこれで別の偏りを生まないか不安なところ)
  • パストレーシングで再帰の枝分かれを増やさないために、鏡面・拡散・透過屈折のどれか1つを追跡
    • 鏡面反射成分q (==Ks)と不透明度opacityで乱数によって選択
    • メタリックの場合KsがRGBで異なる可能性があるので、Ks*(1/q)(1,1,1)とは限らないため省かない
      • Kd(==Vec(1)-Ks)と*(1/(1-q))も同様
    • 透過させるかどうかをopacityに線形だと濃いように感じたので二乗にしてみた (でも不透明の場合とちょっと違うのが気になる)
  • フレネル反射の計算にはハーフベクトルを渡すようになっているが、光源方向は反射ベクトルから求めるので常にマイクロファセットの法線と等しくなるが、それでいいっぽい
  • 再帰数に関わらず一発目からロシアンルーレットしても期待値は同じでしょ、と思ったがノイズが酷いので元のコードと同様に初めの5回はしないようにした
  • 透過の場合に掛けるカラーは入る時と出る時の2回かかってそのままだと濃くなってしまうので、sqrtにしてみた
    • すると鏡面反射・拡散反射・透過の全ケース一律にカラー値でロシアンルーレットしたところノイズが酷かったので、透過の場合にはsqrtした値でロシアンルーレットさせた
  • 全トレースが終わるまでなにも出力されないのは辛いので、サンプリング進行ごとに画像出力するようにしてみた。間隔を倍々にしたのでそんなに負荷はかからないはず。
  • 透過かつメタリックというのはまず写実的ではない
smallptに関して
  • 元のシーンでは四方の壁が球の内側になっていて扱いづらいので、外側になるようにした
  • 視点位置が実際には部屋の外側に配置されていてまずいので後面の壁を奥にずらした、 それに伴い始点を無理やり前に動かしていたのをやめる
  • プライマリレイに2x2のサブピクセル・テントフィルターを使用しているのを行数のため省いたら光源付近がジャギジャギに見える…謎、いちおうアンチエイリアスは効いている模様
  • カラー値を保持するfにロシアンルーレット分の補正を入れるとわかりにくくなったのでやめた
  • ベクトルクラスの修正点:
    • コンストラクタのデフォルト引数をやめて、1引数ならxyzすべてその値にした(シェーダー言語風)
    • 正規化normメソッドで自分は更新せずに正規化されたベクトルを返すよう変更

参考

記事リンク