LIFE LOG(MochiMochi3D)

MochiMochi3D

3D関係の記事を書きます

NEEとMISの実装 (NEE編)

この記事はCalendar for レイトレーシング(レイトレ) | Advent Calendar 2021 - Qiitaの記事として作成されました。

初めに

 この記事はレイトレの効率的な手法として知られているNEE(Next-Event-Estimation)とMIS(Multiple-Importance-Sampling)の説明と実装までをまとめた記事になります。

 私がレイトレに触れてから大体1年ぐらい経ち、そろそろ効率的なレンダラーを作ってみたいということになり、初歩的なやつとして時々聞くNEEやMISを実装してみようと11月ぐらいに思い立ちました。理論そのものは以前、先輩から聞いておりちゃちゃっとできるんじゃないかということでやってみたところ、中々に四苦八苦した後(多分)実装まで漕ぎつけることができました。

 12月にレイトレのアドベントカレンダーが始まったため、丁度いいし今まで勉強したことや実装のまとめとしてNEEとMISの記事を書いてみようということで書いたのがこの記事になります。

 NEEとMISの実装はshockerさんの記事や実装のスライドをがっつり参考にしてやっていました。

rayspace.xyz

rayspace.xyz

speakerdeck.com

特に3個目のスライドはわかりやすい上に実装まで書いてあり、物凄く良い資料で大変お世話になりました。この記事は初めて書いたのでわかりずらい説明等があると思いますので、その時は@Shockerさんの資料を見て頂ければ幸いです。

NEE(Next-Event-Estimation)とは

 Pathtraceはランダムにレイを飛ばし、ぶつかったらこれまた次にランダムにレイを飛ばすというように次々レイを曲げながらパスを構築していきます。この処理は一般に光源に当たった時に終了し、寄与が計算されます。

 従って、寄与を取りたいのならばパスを光源に繋げられるようにすればよいということです。しかし、純粋なPathTraceでは上記のようにランダムに方向を決めて進んでいくため、光源に当たる時というのは「ランダムに方向を決めてたまたま光源に当たった」ということになります。当然、光源に当たらない方向をサンプリングすることがあり、中々光源に当たらないパスというのも出てきます。

 一般的にはIBLを除けば、光源はシーンに対して小さいことが多いです。この時レイが光源に当たる確率というのはかなり小さく、純粋なPathTraceでは効率的に寄与を取ることができないという弱点があります。コーネルボックスでもPathTraceではノイズが多めなのはこうした光源に当たらないパスが頻繁に出てしまうことが原因です。

 そこで考えられた方法がNEE(Next-Event-Estimation)です。通常のようにレイを飛ばし、衝突地点で寄与を求めていくとします。PathTraceではここから方向をサンプリングしてランダムにレイを飛ばして行きますが、NEEではそうではなく光源上の点をサンプリングしてその方向へとレイを飛ばします。この時、レイが他の物体や光源に当たることなく、そのサンプリングした点に到達できた場合この光源上の点をつなぐパスが完成し、寄与を得ることができます。  

 要するに今いる地点と光源の点を繋げることで強引に寄与があるパスを作るというのがNEEの手法となっています。当然ながら光源から逆算するようにパスを作るのですからPathTraceと比べると光源に当たる確率ははるかに高く、寄与も効率的に求めることができるわけです。実際にLambertBRDFで構成されるシーンであれば同じ時間でのサンプリングでもNEEはPathTraceよりはるかに綺麗な絵を出すことができます。  

NEEの理論と実装

 NEEの紹介について終わりましたので、ここから具体的なNEEの理論と実装をやっていきます。

 まず、NEEの具体的な処理の流れを書いていきます。ほとんどの処理は実のところPathTraceと変わりませんが、光源に関わる処理は結構変わってきます。

For
レイを飛ばす
if 光源に当たった場合
  //移動で光源にヒットした場合は寄与を取らずに終了
  break

// NEEの処理
光源上の点をサンプリングする
衝突点からサンプリングした光源の点へレイを飛ばす
if 光源までに何も衝突しなかった場合
  output = 寄与の計算
  LTE += output

//次の衝突点への移動 PathTracerで移動する
方向サンプリング
throughputの計算
次に飛ぶレイの生成

 まず、PathTrace同様に視点からレイを飛ばし、衝突点(一旦光源に当たらないものとして)を得られたとします。この衝突点において光源上の点とパスを繋げ、それが成功するならば寄与を計算して足していきます。NEEで作ったパスで進めていくと当然すぐに光源に当たって脱出してしまうため、NEEで光源の寄与を取りますが、それで終わらずPathTraceで次に移動するレイを作ります。これでまた別の衝突点に行き、NEEをして、これまた別の衝突点にPathTraceで移動していくことを繰り返します。最終的には移動で光源に当たるまで行います。ただし移動において光源に当たっても寄与は計算しないことに注意してください。

NEEの理論

 処理を書きましたが、光源に無理やりパスをつないじゃっても理論的には大丈夫なのかという疑問があると思います。ですが、ちゃんと光源と繋げられるのであれば、そのパスを通るような光というのは実際に存在します。PathTraceでも基本的に取りうる範囲のパスでもありますので、このような形でパスを繋げることは問題ありません。しかしながら、そのパスを取る確率やウェイトの計算というのはPathTraceとは大きく変わっていきます。

点に関するサンプリング

 よく知られいるレンダリング方程式については

 \displaystyle{L_o(x,\vec{\omega_o}) = L_e(x,\vec{\omega_o}) + \int_\Omega f(x,\vec{\omega_i},\vec{\omega_o}) L_e(x,\vec{\omega_i})) |\vec{\omega_i} \cdot \vec{n}| d\sigma(\vec{\omega_i}) } \tag{1}

という形式があると思います。これはあくまで立体角での積分形式で要するに方向サンプリングをした時に考えるべき形式です。実はどこかの点をサンプリングして経路を考える場合はこの形式ではなく、3点形式と呼ばれる積分形式を使用する必要があります。

 \displaystyle{L_0(x_1  \rightarrow x_0) = L_i(x_1 \rightarrow x_0) + \int_M f(x_2 \rightarrow x_1 \rightarrow x_0) L_e(x_2 \rightarrow x_1) G(x_2 \rightarrow x_1)  dA(x_2)} \tag{2}

これはx_0,x_1の点のパスを考え、x_2という点をサンプリングして次のパスを繋げるものとして考えた形式です。なぜ、点をサンプリングしてパスを繋げるにはこのような式になるのかはshockerさんの記事をご覧ください

とにかく、方向をサンプリングしてパスを繋げる方法と点をサンプリングしてパスを繋げる方法の式は異なるということです。こうした理由でNEEでは光源中の点をサンプリングするため、式(2)のような3点形式をとる必要があるというわけです。

若干式が変わったせいで複雑に見えるかもしれませんが、意外とそこまで難しい話ではなく式(1)から \vec{\omega_i} \cdot \vec{n}が除かれGという項が加わっただけです。このGは幾何項と呼ばれており、その中身は

 \displaystyle{G(x_2 \rightarrow x_1)  = \frac{|\omega_i \cdot n| |\omega_i ' \cdot n'|}{|x_2 - x_1|^2} V(x_2 \rightarrow x_1)}  \tag{3}

となっています。各位置関係については以下のようになっています。

f:id:kinakomoti321:20211221023306p:plain

x_1におけるコサイン項|\omega_i \cdot n|に加え、x2におけるコサイン項|\omega_i ' \cdot n'|を計算し、x_2,x_1の距離二乗|x_2 - x_1|^2で割った形で示される。Vは可視関数と言い、x_2x_1の間に物体がない(遮蔽されない)時にV = 1、物体があった時にはV =0を返す関数です。これがついているのは方向サンプリングと違い、点のサンプリングは見えない部分の点もサンプリング範囲に含まれているためです。

Gは以上のように計算することができるもので、位置づけとしては式(1)のコサイン項のような感じになります。この形式においても当然ながらモンテカルロ積分が可能です。

p(x)は次に進む点xを取る確率密度関数とすれば、ウェイトとなるThroughputとしては

 \displaystyle{Throughput = \frac{f * G}{p}= \frac{f * \frac{|\omega_i \cdot n| |\omega_i ' \cdot n'|}{|x_2 - x_1|^2}}{p}}  \tag{5}

という値を取っていく必要があります。言ってしまえば通常のパストレにおけるコサイン項の部分を幾何項Gに置き換えて計算すればいいわけです。

 実装としては、 * シーン上の点のサンプリング * サンプリングした点の確率密度 * サンプリングした点に向かってレイを飛ばし、間に物体がないかの判定 * 幾何項Gの計算 ができるようにする必要があります。NEEでは光源上のみの点をサンプリングするため、今あるすべての光源からどこかしら1点取れるサンプリング用の機能をつけ、PDFを計算できるようにする機能を付けられれば良いわけです。

NEEにおけるパスの生成

 NEEでは光源上で点をサンプリングすることでパスを生成して寄与を計算していきます。

 基本的にパストレーサーは衝突した点からパスを作り、また別の点へと移動しさらに別の点へと移動していくことで計算を行っていきます。こうして移動していって光源に当たったら、一般的にはそれ以上の深さはあまり必要でないのでここで計算を終了します。

 ですがNEEでは光源へと無理やりパスを繋げる方法を取ります。NEEが成功しないのは光源との間に物体がある場合か光源でも見えない場所(裏側)の点をサンプリングしてしまった場合ぐらいです。そのため、多くの場合はNEEが成功します。

 もし普通のパストレーサーのように光源とのパスが作れた(NEEが成功した)時に計算を打ち切るようにしてしまうと、NEEは大抵成功するため、多くのパスが最初の衝突点で打ち切られてしまうわけです。そうしてしまうとDepthが高いパスが一切作られないことになってしまいます。私はこれについてわかっていなかったため、以上のバグを引き起こしてしまいました。

 これではいけないので、移動だけはパストレーサーと同様に行い、光源との寄与はNEE(光源サンプリング)で計算するという形で行います。具体的には以下のような手順でパスを作っていきます。

  • カメラレイで最初の衝突点を計算する
  • 衝突点でNEEを行い、寄与を計算する。
  • 方向サンプリングで次の衝突点を計算する。
  • その衝突点で同様にNEEを行い、寄与を計算する。
  • これを繰り返し行っていく。

f:id:kinakomoti321:20211221234924p:plain
パスの生成の流れ

 なので実はNEEの実装はPaceTraceに光源サンプリングして寄与の計算する機能を取り付けることで実装することができます。ただし、注意として移動として行っているPaceTraceでもし光源に当たった時に、PaceTraceとして寄与を計算することはしてはいけません。もし、PathTraceと同様に寄与を計算してしまうと、同じDepthにおけるNEEが既に行われたため、寄与の計算が2回行われてしまい(ダブルカウント)計算がおかしくなります。なので移動において光源が当たってしまった場合は寄与の追加もせずにそこで計算を打ち切るようにします。これが上記の実装で光源の寄与を取らないとしている理由です。

if 光源に当たった場合
  //移動で光源にヒットした場合は寄与を取らない
  break

光源サンプリングの実装

 以上によりNEEに必要なことは整いました。NEEの実装において前提として作らなければならないのが、

  • シーンの光源から1つの点をサンプリングして、点のデータを渡す機能
  • サンプリングした光源点を取る確率密度PDFを計算する機能 になります。

 私の実装では各種類のプリミティブで一様サンプリングする機能としてareaSamplingというのを各プリミティブに実装しました。三角形や球などのプリミティブでの一様サンプリングの仕方は丁度shockerさんが記事にまとめてくれていますのでこちらをご参照ください。

rayspace.xyz

 例として球の一様サンプリングは以下のように実装をしました。

  Vec3 areaSampling(const std::shared_ptr<Sampler>& sampler,IntersectInfo& info)const override{
    float u = sampler->sample(),v = sampler->sample();
    float z = - 2.0f * u + 1.0f;
    float y = std::sqrt(std::max(1.0f - z * z,0.0f)) * std::sin(2.0f * PI * v);
    float x = std::sqrt(std::max(1.0f - z * z,0.0f)) * std::cos(2.0f * PI* v);

    Vec3 pos = center + radius * Vec3(x,y,z);
    info.position = pos;
    info.normal = normalize(Vec3(x,y,z));
    info.uv = sphereUV(info.normal);
    return pos;
  }

 NEEの計算では光源面の法線n'などを使用するので光源の座標以外にも法線を返せるようにしておく必要があります。私の実装では基本的にIntersectInfoというクラスに点の情報を渡しています。

 シーン中の光源となるプリミティブはSceneクラスなどにまとめて置いて、いくつもあるプリミティブから1つだけランダムに選択し、そのプリミティブで一様サンプリングで1つの点をサンプリングします。このような流れで光源サンプリングを行います。

 実装ではSceneクラスに光源プリミティブをまとめたvectorであるlightgeoを用意しておいて、乱数でindexを作って1つのプリミティブを選択しています。これによって得られた光源サンプリング点のPDFについては「1/光源プリミティブの数」(そのプリミティブを選ぶ確率)×「1/選択したプリミティブの面積」(プリミティブの一様サンプリングのPDF)となります。

    Vec3 lightPointSampling(const std::shared_ptr<Sampler>& sampler, IntersectInfo& info, float& weight)const {
      //プリミティブの選択 indexを乱数から作る
        unsigned int idx = lightgeo.size() * sampler->sample();
        if (idx == lightgeo.size()) idx--;
      
       //PDF
        weight = 1.0f / (lightgeo[idx]->areaShape() * lightgeo.size());
    
      //光源サンプリング
        return lightgeo[idx]->areaSampling(sampler, info);
    }

 ただ、この実装では多分不十分で、これではどんな大きさの光源も等しく選択される可能性があります。なので極端に大きい(IBLなど)の光源と小さい光源が入り混じるシーンだと効率が落ちるかもしれません。これを正すにはすべての光源の面積を考慮して一様サンプリングする必要がありますが、こちらの実装が楽なので今回はこれで行いました。

NEEの実装

 以上でNEEの実装の準備が整いましたのでいよいよNEEそのものの実装に入ります。NEEの処理の流れは

  • レイを飛ばす
  • 衝突した点が光源かを判定→光源であれば終了
  • 光源サンプリング
  • 光源点と遮蔽されているか判定→遮蔽されないなら寄与の計算
  • 次に進む方向のレイを作り、Throughputの更新

という感じで行っていきます。実装例は以下の通りです。

  Vec3 integrate(const Ray& ray, const Scene& scene,
    std::shared_ptr<Sampler> sampler) const override {
    const int MaxDepth = 100;
    float p = 0.99;
    Vec3 throughput(1.0);
    Vec3 LTE(0);
    Ray next_ray = ray;

    for (int depth = 0; depth < MaxDepth; depth++) {

      // Russian roulette
      p = std::min(std::max(std::max(throughput[0], throughput[1]), throughput[2]), 1.0f);
      if (sampler->sample() > p) break;
      throughput /= p;

      //(1)レイを飛ばす
      IntersectInfo info;
      if (!scene.intersect(next_ray, info)) {
      //レイが光源に当たった場合
        if (depth == 0) {
          //最初に当たった時だけは光源の色を返す
          LTE = throughput * scene.skyLe(next_ray);
        }
        break;
      }

      const Object& obj = *info.object;
      if (obj.hasLight()) {
      //レイが光源に当たった場合
        if (depth == 0) {
          //最初に当たった時だけは光源の色を返す
          LTE += throughput * obj.Le();
        }
        break;
      }


      // wo: 入射方向,wi:反射方向
      Vec3 wo = -next_ray.direction;
      Vec3 wi;
      float pdf;
      Vec3 bsdf;

      Vec3 t, b;
      tangentSpaceBasis(info.normal, t, b);


      //(2)光源サンプリング
      IntersectInfo lightInfo;  //光源点の情報を入れておくクラス
      Vec3 lightPos = scene.lightPointSampling(sampler, lightInfo, pdf); //光源サンプリング

      Vec3 lightDir = normalize(lightPos - info.position); //現在地から光源点の方向
      Ray shadowRay(info.position, lightDir); //光源点方向へのレイ
      lightInfo.distance = norm(lightPos - info.position); //光源点までの距離

      shadowRay.Max = lightInfo.distance - 0.001f;
      IntersectInfo shadowInfo;
      if (!scene.intersect(shadowRay, shadowInfo)) {
        //(3)寄与の計算
        //各コサインの計算
        float cosine1 = std::abs(dot(info.normal, lightDir));
        float cosine2 = std::abs(dot(lightInfo.normal, -lightDir));
       
        wi = lightDir;

        Vec3 local_wo = worldtoLocal(wo, t, info.normal, b);
        Vec3 local_wi = worldtoLocal(wi, t, info.normal, b);
  
        //BSDFの計算
        bsdf = info.object->evaluateBSDF(local_wo, local_wi);
        
        //幾何項の計算
        float G = cosine1 * cosine2 / (lightInfo.distance * lightInfo.distance);

        LTE += throughput * (bsdf * G / pdf) * lightInfo.object->Le();
      }

      //(4)次に進む方向のサンプリング
      wo = worldtoLocal(-next_ray.direction, t, info.normal, b);
      // BSDF計算
      bsdf = info.object->sampleBSDF(wo, wi, pdf, sampler);

      const Vec3 next_direction = localToWorld(wi, t, info.normal, b);
      const float cosine = std::abs(dot(info.normal, next_direction));
      throughput *= bsdf * cosine / pdf;

      next_ray = Ray(info.position + 0.001f * info.normal, next_direction);
    }

    return LTE;

  };

実装例を見ていきながら、各処理について話します。

(1)レイを飛ばす

 これは単に衝突判定を行うだけです。実装では現在いる衝突点はinfoに格納されており、この衝突判定で次の衝突点へと更新します。

 この際、衝突した物体が光源であった場合、前述の通り特にLTE(寄与)に対して何もすることなくループを終了させます。しかし、ここで注意なのがDepthが0つまり最初のレイであった場合は光源の輝度をLTEに入れます。これは完全に光源の寄与を取らないようにすると光源の部分が真っ暗になってしまうからです。なので例外的にDepthが0つまり直接光源がカメラから見える場合だけは光源の寄与をここで取ります。

(2)光源サンプリング

 ここではシーン上の光源から1点をサンプリングする関数lightPointSamplingで光源サンプリングを行います。ここで得た光源の座標を用いて、その方向へと向かうレイshadowRayを生成し、光源点と現在地の間に物体がないか衝突判定を行います。レイの距離上限を光源点との距離にすることで光源点との間に限った衝突判定を実装しています(普通に衝突判定後で距離判定してもいいと思います)。  

(3)寄与の計算

 光源上の点と現在地が遮蔽なくつながることができる場合、その点からの寄与が計算できます。この値については前述したとおり、3点形式の式で寄与を計算する必要があるので幾何項Gを計算してあげる必要があります。こうして現在のThroughputを用いて寄与を計算して、LTEに加算する形で寄与を蓄積していきます。

(4)次に進む方向のサンプリング

 次の衝突点へと移動するため、PathTraceと同様に方向サンプリングして次に進む方向のレイを作ります。ここの処理はPathtraceとまったく同様です。

NEEの結果

 このようにして実装したNEEはこんな感じになります。50サンプリングですがかなり綺麗な画像になっていることがわかると思います。

f:id:kinakomoti321:20211221213809p:plain
NEE_50sampling

 PathTraceに比べてどれだけノイズが減っているかPathTraceの画像と比べてみましょう。比較についてですが、NEEの処理は1回のループにつき衝突判定を2回行うため、PathTraceよりは実行時間が結構長くなってしまいますので同じサンプル数ではなく同じ時間で比較すべきです。ちょっと今回は秒数レンダリングの実装になんかバグがあってできなかったので取り合えずNEEは50、PTは100サンプル(PTの方が少し実行時間長め)で行いましたのが以下の図です。左はNEE,右はPT

f:id:kinakomoti321:20211221213020p:plain
NEE_50sampling and PT_100sampling

こう見るとサンプル数が全然異なるのにもかかわらず、PathTraceに比べNEEはかなりノイズが減少していることがわかると思います。こうして見るとNEEはかなり効率的な手法だということがわかります。

NEEの弱点

 NEEは純粋なPathTraceに比べるとかなり実用的な手法でありますが、何ににでも使える訳ではなく弱点も存在します。

 まず、1つ目としては「FireFly(明るいノイズ)が出やすい」ことです。

f:id:kinakomoti321:20211221215438p:plain
NEEとPTのノイズ
 NEEとPTのノイズを比べるとNEEのノイズは明るい点々としてノイズが出ていると思います。一般にNEEのノイズとしてこうしたFireFlyというノイズが出てしまいがちな点があります。ですがまあノイズとしてみればPTより少ないので弱点というよりは特徴みたいなものとして考えた方がいいかもしれません。

 次に「光源近傍にノイズが出やすい」ことが挙げられます。  

f:id:kinakomoti321:20211221220109p:plain
NEEとPTの光源付近のノイズ
 NEEは全体的にはノイズを減らしますが、光源にかなり近い部分では話が異なってきます。図を見るとわかる通り、PathTraceでは綺麗に出ている光源に近い部分はNEEにおいては一変して激しめなノイズを作ってしまいます。これはNEEの寄与が光源との距離の二乗に反比例するため、光源に近づくほど極小さくなりがちになることや、PTの移動部分で光源の寄与を取らないことが原因です。NEEではこうした特徴的なノイズが生じます

 そして実用上で一番重要なのが「指向性が強いBSDF(BRDF)に弱い」ということが挙げられます。完全な指向性を持つ理想鏡面が存在するシーンをNEEでレンダリングしてみましょう。左がNEE,右がPTです。

f:id:kinakomoti321:20211221221454p:plain
理想鏡面があるシーン
 一目でわかる通りNEEでは理想鏡面に映る光源が表現できていません。理想鏡面は入射方向に対して完全な反射方向以外には0を返すBRDFです。NEEではBSDFの指向性などは一切考慮せず、光源上の点の方向へとパスを繋げるわけですが、このように指向性が強いBSDFでは全く寄与を取れない方向を取ってしまうことが多いです。特に完全な指向性を持つ理想鏡面では(デルタ関数なので)一切有効な方向が取れなく、このように真っ暗になってしまうわけです(例外的に処理すれば治らないことはないですが)。

 理想鏡面はかなり極端な例であり、GGXを使った例だとここまではひどくなく、むしろ結構回りのノイズが少ないのでNEEで綺麗に出ます。

f:id:kinakomoti321:20211221224504p:plain
GGXのあるシーンでの比較

しかし、グロッシーな物体のハイライト部分に注目するとやはりPathTraceよりノイズが残りやすいです。グロッシーな物体が多いシーンだとかなりこの影響は大きく、このNEEの弱点は実用上かなり問題となる部分であります。

f:id:kinakomoti321:20211221224622p:plain
ハイライト部分の比較

終わり

 NEEは実装はPathTraceに幾つか機能を加えるような形でできるのにも関わらず、PathTraceに比べ非常に綺麗な絵を少ないサンプリングで得ることができます。多くのシーンではPathTraceよりノイズは少なく済ませられ、純粋なPathTraceより実用的な手法となります。

 しかしながら、NEEではいくつか弱点があり、NEE一筋でレンダリングしていくには少々問題があります。そうした問題に対して、より発展的な手法として考えられたのが題名に名前があるMIS(Multi Importance Sampling)になります。この次の記事ではNEEより分散を減らせるMISの実装についてやっていこうと思います。

kinakomoti321.hatenablog.com

参考文献

rayspace.xyz