フォトンマッピングの実装
2021/12/06
#c++ #3dcg #raytracing
フォトンマッピングの実装について

この記事はフォトンマッピングの理論の続きです。前回はフォトンマッピングの理論的な定式化ついて扱いましたが、今回はその内容を元に実装について解説します。

コード全体は以下で見ることができるので、あわせて参考にして下さい。

計算対象の式

まずはフォトンマッピングで計算したい対象の式を確認します。

今、カメラ側からレイを飛ばしてx0,x1,,xtx_0, x_{1}, \cdots, x_tという経路を構築したとします。最後の点xtx_tにおいてフォトンマップを用いて放射輝度推定を行うとします。

この時jj番目の画素への寄与は、前回の式(3)から以下のように計算できます。

  • NN: カメラ側からの経路構築のサンプル数
  • NpN_p: 光源から飛ばしたフォトンの数
  • x0i,x1i,,xt(i)ix_0^i, x_1^i, \cdots, x_{t(i)}^i: ii番目のカメラ側からの経路(経路の長さはt(i)t(i))
  • y0p,y1p,,ys(p)py_0^p, y_1^p, \cdots, y_{s(p)}^p: pp番目のフォトンの光源からの経路(経路の長さはs(p)s(p))
  • βp\beta_p: pp番目のフォトンのThroughput
  • KK: カーネル関数
  • ff: 点xt(i)ix_{t(i)}^iにおけるBSDF
  • TiT_i: ii番目のカメラ側からの経路のThroughput
Ij1Ni=1N(1Npp=1NpβpK(ys(p)p,xt(i)i)f(ys(p)1pys(p)p,xt(i)ixt(i)1i))Ti(1)I_j \approx \frac{1}{N}\sum_{i=1}^N \bigg(\frac{1}{N_p}\sum_{p=1}^{N_p}\beta_p K(y_{s(p)}^p, x_{t(i)}^i)f(y_{s(p)-1}^p \to y_{s(p)}^p, x_{t(i)}^i \to x_{t(i)-1}^i)\bigg)T_i \tag{1}

この式はフォトンのThroughput×\timesカーネルの値×\times xtx_tにおけるBSDF×\timesカメラ側からのレイのThroughputの平均になっています。

式(1)の概念図
式(1)の概念図

βp,Ti\beta_p, T_iは以下のようになります。

βp=Le(y0py1p)G(y0p,y1p)p(y0p)k=1s(p)1f(yk1pykp,yk+1p)G(ykp,yk+1p)k=1s(p)p(ykpyk1p)(2)\beta_p = \frac{L_e(y_0^p \to y_1^p)G(y_0^p, y_1^p)}{p(y_0^p)}\frac{\prod_{k=1}^{s(p)-1} f(y_{k-1}^p \to y_k^p, \to y_{k+1}^p)G(y_{k}^p, y_{k+1}^p)}{\prod_{k=1}^{s(p)} p(y_k^p|y_{k-1}^p)} \tag{2}
Ti=k=1t(i)1f(xk+1ixkixk1i)G(xk+1i,xki)k=1t(i)p(xkixk1i)G(x1i,x0i)Wej(x1ix0i)p(x0i)(3)T_i = \frac{\prod_{k=1}^{t(i)-1}f(x_{k+1}^i \to x_k^i \to x_{k-1}^i)G(x_{k+1}^i, x_k^i)}{\prod_{k=1}^{t(i)} p(x_k^i|x_{k-1}^i)} \frac{G(x_1^i, x_0^i)W_e^{j}(x_1^i \to x_0^i)}{p(x_0^i)} \tag{3}

複雑に見えますが、基本的には経路の各点(光源とカメラ上の点を除く)でBSDFの値 ×\times 幾何項 ÷\div その点をサンプリングする確率密度の値 を計算して掛け合わせていく形です。TiT_iは通常のパストレーシングのThroughputと同じ形になっています。

フォトンマッピングでは式(1)を数値計算していきます。以降ではその実装方法について見ていきます。

フォトンマップの構築

データ構造

式(1)をもう一度見てみます。

Ij1Ni=1N(1Npp=1NpβpK(ys(p)p,xt(i)i)f(ys(p)1pys(p)p,xt(i)ixt(i)1i))TiI_j \approx \frac{1}{N}\sum_{i=1}^N \bigg(\frac{1}{N_p}\sum_{p=1}^{N_p}\beta_p K(y_{s(p)}^p, x_{t(i)}^i)f(y_{s(p)-1}^p \to y_{s(p)}^p, x_{t(i)}^i \to x_{t(i)-1}^i)\bigg)T_i

これを計算するためにはフォトンのThroughput βp\beta_p, フォトンが格納された時のフォトンの位置ys(p)py_{s(p)}^p, フォトンが格納された点におけるフォトンの入射方向 ys(p)1pys(p)py_{s(p)-1}^p \to y_{s(p)}^pが必要です。

そのためフォトンマップにはこの3つのデータを保存しておくようにします。フォトンを表現する構造体Photonは以下のように定義すると良いでしょう。

struct Photon {
  Vec3f throughput; // スループット
  Vec3f position; // フォトンの位置
  Vec3f wi; // フォトンの入射方向
};

式(1)の計算では全フォトンに関して総和を取っていますが、前回も述べたように実際にはカーネル関数としては

K(x,y)={1πr2(xyr)0(otherwise)K(x, y) = \begin{cases} \frac{1}{\pi r^2} & (\|x-y\| \le r) \\ 0 & (otherwise) \end{cases}

のように距離がrr以上離れたフォトンに対してはカーネルの値が0になるようなものを使います。そのためxt(i)ix_{t(i)}^iの近傍にあるフォトンに対してのみ総和を取れば良いです。そのためフォトンマップには近傍探索が効率良く行えるデータ構造を採用します。

近傍探索が効率良く行えるデータ構造としてはKd-treeやOctreeが挙げられます。ここではKd-treeを実装することにします。

Kd-treeの実装

これについては既に良い記事があるのでここでは省略します。以下を参照してください。

私の実装例は以下で見れます。

フォトントレーシング

Kd-treeを実装できたら、次は光源からシーンに向けてフォトンを飛ばし、フォトンマップにフォトンを格納していく処理を書いていきます。

フォトンの生成

まずは光源からフォトンを生成し、シーン中にフォトンを飛ばす必要があります。以下のような流れでこれを行っていきます。

  1. シーンから光源を1つ選ぶ
  2. 選んだ光源上で点y0y_0をサンプリングする
  3. 方向ωy0\vec{\omega_{y_0}}をサンプリングする
  4. y0y_0から方向ωy0\vec{\omega_{y_0}}にフォトンを飛ばす
光源上での点と方向のサンプリング
光源上での点と方向のサンプリング
光源の選択

今シーンに光源がNlN_l個あるとします。1に関してはNlN_l個の光源から等確率でランダムに1つの光源を選ぶような処理が簡単でしょう。この場合ii番目の光源を選ぶ確率PLP_L

PL(i)=1Nl(4)P_L(i) = \frac{1}{N_l} \tag{4}

となります。

光源上の点サンプリング

光源を選んだら光源上で点y0y_0をサンプリングします。光源の表面で一様に点をサンプリングすると仮定すると、光源の表面積をAAとして点サンプリングの確率密度は

p(y0)=1A(5)p(y_0) = \frac{1}{A} \tag{5}

となります。

光源上の方向サンプリング

続いて方向サンプリングを行います。拡散光源ならコサイン比例半球サンプリングをし、指向性光源の場合は特定の方向だけをサンプリングするようにすると良いでしょう。今、方向サンプリングとしてコサイン比例半球サンプリングを使ったと仮定すると、方向ωy0\vec{\omega_{y_0}}をサンプリングする確率密度は

p(ωy0)=1πcosθy0(6)p(\vec{\omega_{y_0}}) = \frac{1}{\pi}\cos{\theta_{y_0}} \tag{6}

となります。ここでθy0\theta_{y_0}は方向ωy0\vec{\omega_{y_0}}と点y0y_0における法線のなす角度です。

フォトンのThroughputの初期化

最後にフォトンのThroughputを初期化してフォトンを飛ばします。この時点でのフォトンのThroughputは式(2)から

βp=Le(y0y1)G(y0,y1)PL(i)p(y0)p(y1y0)(7)\beta_p = \frac{L_e(y_0 \to y_1)G(y_0, y_1)}{P_L(i) p(y_0)p(y_1 | y_0)} \tag{7}

となります。ここで光源をランダムに1つ選択することによって分母に光源を選ぶ確率PLP_Lが入っていることに注意します。また、点x0x_0から方向ω0\vec{\omega_0}を選んだことによって暗黙的に次の点y1y_1が定まっていることに注意します。

次の点は現在の点と方向から暗黙的に定まる
次の点は現在の点と方向から暗黙的に定まる

式(7)をω0\vec{\omega_0}を使って書き直すことを考えます。y1y_1における法線とωy0-\vec{\omega_{y_0}}のなす角度をθ1\theta_1とすると、幾何項は定義から

G(y0,y1)=cosθy0cosθy1y1y02G(y_0, y_1) = \frac{\cos{\theta_{y_0}}\cos{\theta_{y_1}}}{\|y_1 - y_0\|^2}

となります。また、p(y1y0)p(y_1 | y_0)は点サンプリングと方向サンプリングの確率密度の変換公式

p(y1y0)=G(y0,y1)cosθy0p(ωy0)p(y_1 | y_0) = \frac{G(y_0, y_1)}{\cos\theta_{y_0}}p(\vec{\omega_{y_0}})

を使うことで、式(7)は以下のように書き換えられます。

βp=Le(y0y1)cosθy0PL(i)p(y0)p(ωy0)(8)\beta_p = \frac{L_e(y_0 \to y_1)\cos\theta_{y_0}}{P_L(i)p(y_0)p(\vec{\omega_{y_0}})} \tag{8}

式(4), 式(5), 式(6)を代入すると最終的に以下のような形になります。

βp=NlAπLe(y0y1)\beta_p = N_l A \pi L_e(y_0 \to y_1)

この値でフォトンのThroughputを初期化すれば良いです。

実装

ここまでの処理をコードにすると以下のようになります。

// 光源上から出るレイをサンプリングして返す。この時throughputも初期化する。
Ray sampleRayFromLight(const Scene& scene, Sampler& sampler,
                        Vec3f& throughput) {
  // 光源をシーンからランダムに1つ選ぶ
  float light_choose_pdf;
  const std::shared_ptr<Light> light =
      scene.sampleLight(sampler, light_choose_pdf);

  // 光源上の点をサンプリングする
  float light_pos_pdf;
  const SurfaceInfo light_surf = light->samplePoint(sampler, light_pos_pdf);

  // 方向をサンプリングする
  float light_dir_pdf;
  const Vec3f dir =
      light->sampleDirection(light_surf, sampler, light_dir_pdf);

  // サンプリングした点, 方向を使ってレイを生成
  Ray ray(light_surf.position, dir);

  // Throughputを初期化する
  throughput = light->Le(light_surf, dir) /
                (light_choose_pdf * light_pos_pdf * light_dir_pdf) *
                std::abs(dot(dir, light_surf.shadingNormal));

  return ray;
}

この実装ではThroughputの初期化を式(8)で行っています。

光源側からの経路構築とフォトンの格納

次にフォトンをシーン中に飛ばし、光源側からの経路構築を行っていきます。経路構築の途中でDiffuse面に当たる度にフォトンをフォトンマップに追加していきます。

光源からの経路構築
光源からの経路構築

実装は以下のようになります。ここではフォトンの配列を最初に用意して、そこにフォトンを追加していき、最後にKd-treeを構築するような流れになっています。

また、高速化のためにフォトンの追跡はOpenMPを用いて並列に行っています。throughputβp\beta_pに対応します。

std::vector<Photon> photons;

// スレッドごとにSamplerを用意する
std::vector<std::unique_ptr<Sampler>> samplers(omp_get_max_threads());
for (int i = 0; i < samplers.size(); ++i) {
  samplers[i] = sampler.clone();
  samplers[i]->setSeed(samplers[i]->getSeed() * (i + 1));
}

// OpenMPを用いて並列にフォトンを飛ばす
// nPhotonsGlobal: フォトン数
// globalPhotonMap: フォトンマップ
#pragma omp parallel for
for (int i = 0; i < nPhotonsGlobal; ++i) {
  auto& sampler_per_thread = *samplers[omp_get_thread_num()];

  // 光源からレイをサンプリングし、Throughputを初期化する
  Vec3f throughput;
  Ray ray = sampleRayFromLight(scene, sampler_per_thread, throughput);

  // 光源からの経路構築
  for (int k = 0; k < maxDepth; ++k) {
    IntersectInfo info;
    if (scene.intersect(ray, info)) {
      // レイが当たった点のBSDFの種類を取得
      const BxDFType bxdf_type = info.hitPrimitive->getBxDFType();

      // Diffuse面ならフォトンを配列に追加
      if (bxdf_type == BxDFType::DIFFUSE) {
#pragma omp critical
        {
          photons.emplace_back(throughput, info.surfaceInfo.position,
                                -ray.direction);
        }
      }

      // ロシアンルーレットを行って経路構築を続けるか決める
      if (k > 0) {
        const float russian_roulette_prob = std::min(
            std::max(throughput[0], std::max(throughput[1], throughput[2])),
            1.0f);
        if (sampler_per_thread.getNext1D() >= russian_roulette_prob) {
          break;
        }
        throughput /= russian_roulette_prob;
      }

      // BSDFを使って方向サンプリングを行い、次のレイの方向を決める
      Vec3f dir;
      float pdf_dir;
      const Vec3f f = info.hitPrimitive->sampleBxDF(
          -ray.direction, info.surfaceInfo, TransportDirection::FROM_LIGHT,
          sampler_per_thread, dir, pdf_dir);

      // Throughputとレイを更新する
      throughput *= f *
                    cosTerm(-ray.direction, dir, info.surfaceInfo,
                            TransportDirection::FROM_LIGHT) /
                    pdf_dir;
      ray = Ray(info.surfaceInfo.position, dir);
    } else {
      // フォトンがシーン外に飛んでいった場合
      break;
    }
  }
}

// フォトンマップの構築(Kd-treeのビルド)
globalPhotonMap.setPhotons(photons);
globalPhotonMap.build();

経路構築の部分はパストレーシングと同様のアルゴリズムになっています。ここではロシアンルーレットを行って経路構築を確率的に打ち切ることを行っています。

Throughputの更新は式(2)の後ろの項

k=1s1f(yk1pykp,yk+1p)G(ykp,yk+1p)k=2sp(ykpyk1p)(9)\frac{\prod_{k=1}^{s-1} f(y_{k-1}^p \to y_k^p, \to y_{k+1}^p)G(y_{k}^p, y_{k+1}^p)}{\prod_{k=2}^{s} p(y_k^p|y_{k-1}^p)} \tag{9}

に対応しています。元々の式は分母がk=1k=1から始まっていますが、k=1k=1の場合は既に式(8)で計算されているので、この式ではスキップされています。

ここでは点ykpy_k^pは点yk1py_{k-1}^pからBSDFを用いた方向サンプリングによって暗黙的に取っているので、Throughputの初期化の所で述べたのと同様に、点サンプリングと方向サンプリングの確率密度の変換公式

p(ykpyk1p)=G(ykp,yk1p)cosθyk1p(ωyk1p)p(y_k^p | y_{k-1}^p) = \frac{G(y_k^p, y_{k-1}^p)}{\cos_{\theta_{y_{k-1}}}}p(\vec{\omega_{y_{k-1}^p}})

を用いることで、式(9)は以下のように書き換えられます。

k=1s1f(yk1pykp,yk+1p)cosθykpk=2sp(ωyk1p)\frac{\prod_{k=1}^{s-1} f(y_{k-1}^p \to y_k^p, \to y_{k+1}^p)\cos{\theta_{y_k^p}}}{\prod_{k=2}^{s} p(\vec{\omega_{y_{k-1}^p}})}

Throughputの更新の所のコードはこの式を計算しています。

放射輝度の計算

フォトンマップを構築することが出来たので、次はカメラ側からレイを飛ばしてフォトンマップを用いた放射輝度推定を行います。

式(1)をもう一度見てみます。

Ij1Ni=1N(1Npp=1NpβpK(ys(p)p,xt(i)i)f(ys(p)1pys(p)p,xt(i)ixt(i)1i))TiI_j \approx \frac{1}{N}\sum_{i=1}^N \bigg(\frac{1}{N_p}\sum_{p=1}^{N_p}\beta_p K(y_{s(p)}^p, x_{t(i)}^i)f(y_{s(p)-1}^p \to y_{s(p)}^p, x_{t(i)}^i \to x_{t(i)-1}^i)\bigg)T_i

フォトンマップの構築でβp\beta_p, ys(p)py_{s(p)}^p, ys(p)1pys(p)py_{s(p)-1}^p \to y_{s(p)}^pは既に求まっています。ここで計算する必要があるのはK(ys(p)p,xt(i)i)f(ys(p)1pys(p)p,xt(i)ixt(i)1i)TiK(y_{s(p)}^p, x_{t(i)}^i)f(y_{s(p)-1}^p \to y_{s(p)}^p, x_{t(i)}^i \to x_{t(i)-1}^i)T_iの部分です。

カメラ側からの経路構築

今、カメラ側からパストレーシングによって経路構築をして経路x0i,x1i,,xt(i)ix_0^i, x_1^i, \cdots, x_{t(i)}^iを得て、xt(i)ix_{t(i)}^iにおいてフォトンマップを用いて放射輝度推定を行うとします。

フォトンマップを用いた放射輝度推定
フォトンマップを用いた放射輝度推定

式(1)からxt(i)ix_{t(i)}^iがDiffuse面の場合は近傍にあるフォトンを求めることで、ii番目のサンプルのjj番目の画素への寄与は以下で計算できます。

Iji=1Npp=1Nxt(i)iβpK(ys(p)p,xt(i)i)f(ys(p)1pys(p)p,xt(i)ixt(i)1i)Ti(10)I_j^i = \frac{1}{N_p}\sum_{p=1}^{N_{x_{t(i)}^i}}\beta_p K(y_{s(p)}^p, x_{t(i)}^i)f(y_{s(p)-1}^p \to y_{s(p)}^p, x_{t(i)}^i \to x_{t(i)-1}^i)T_i \tag{10}

ここでNxt(i)iN_{x_{t(i)}^i}xt(i)ix_{t(i)}^iの近傍にある(カーネルの値が0にならないような)フォトンの数です。ここで総和を近傍にあるフォトン数Nxt(i)iN_{x_{t(i)}^i}ではなく、フォトンマップ構築時に飛ばしたフォトン数NpN_pで割っていることに注意してください。

xt(i)ix_{t(i)}^iがSpecular面の場合はその近くにフォトンはないので、次の方向をサンプリングしてレイを再帰的に追跡し、どこかでDiffuse面に当たったら式(10)を計算するようにします。

実装

まずは式(10)の計算をコードに落とし込みます。TiT_iの項は後で掛け合わせてあげることにして、それ以外の項を計算するようなコードを書くと以下のようになります。

// ある点から反射する放射輝度をフォトンマップを用いて計算する
// nPhotonsGlobal: 光源から飛ばしたフォトンの総数(N_p)
// nEstimationGlobal: 何個の近傍フォトンを推定に使うか(K)
// globalPhotonMap: フォトンマップ
Vec3f computeRadianceWithPhotonMap(const Vec3f& wo,
                                    const IntersectInfo& info) const {
  // 近傍にあるK個フォトンのインデックスを取得する(K近傍探索)
  float max_dist2;
  const std::vector<int> photon_indices =
      globalPhotonMap.queryKNearestPhotons(info.surfaceInfo.position,
                                            nEstimationGlobal, max_dist2);

  // 式(10)の計算(T_iの項はまだない)
  Vec3f Lo;
  for (const int photon_idx : photon_indices) {
    const Photon& photon = globalPhotonMap.getIthPhoton(photon_idx);
    const Vec3f f = info.hitPrimitive->evaluateBxDF(
        wo, photon.wi, info.surfaceInfo, TransportDirection::FROM_CAMERA);
    Lo += f * photon.throughput;
  }
  if (photon_indices.size() > 0) {
    Lo /= (nPhotonsGlobal * PI * max_dist2);
  }
  return Lo;
}

この実装ではカーネル関数として

K(x,y)={1πr2(xyr)0(otherwise)K(x, y) = \begin{cases} \frac{1}{\pi r^2} & (\|x-y\| \le r) \\ 0 & (otherwise) \end{cases}

を使用し、半径rrKK個の近傍フォトンの最大距離になるようにしています。つまり半径rrが点xt(i)ix_{t(i)}^i周辺のフォトンの集まり具体によって変わる形になっています。Jensenの青い本ではそのような実装になっていたので、それに合わせました。

もちろん半径rrを固定するカーネル関数を使っても良いです。ただしこの場合は飛ばしたフォトン数が少ないと半径rr内に近傍フォトンがなく、推定した値が0になってしまうという問題が起こります。

一方で半径rrKK個の近傍フォトンの最大距離にするような実装では、必ずKK個のフォトンの総和を取ることになるのでそういった問題は起こりません。

カメラ側から経路構築をし、式(10)を計算するコードは以下のようになります。以下のコードではthroughputTiT_iに対応します。

// ray_inの方向から来る放射輝度をフォトンマップを用いて計算する
Vec3f integrate(const Ray& ray_in, const Scene& scene,
                Sampler& sampler) const {
  Ray ray = ray_in;
  Vec3f throughput(1, 1, 1);

  // カメラ側からの経路構築
  for (int k = 0; k < maxDepth; ++k) {
    IntersectInfo info;
    if (scene.intersect(ray, info)) {
      // 光源に直接当たった場合は光源の放射輝度を直接返す
      if (info.hitPrimitive->hasAreaLight()) {
        return throughput *
                info.hitPrimitive->Le(info.surfaceInfo, -ray.direction);
      }

      // レイが当たった点のBSDFの種類を取得
      const BxDFType bxdf_type = info.hitPrimitive->getBxDFType();

      // Diffuse面に当たった場合にはフォトンマップを用いて式(10)を計算
      if (bxdf_type == BxDFType::DIFFUSE) {
        return throughput *
                computeRadianceWithPhotonMap(-ray.direction, info);
      }
      // Specular面に当たった場合には次の方向をサンプリングして、再帰的にレイを追跡する
      else if (bxdf_type == BxDFType::SPECULAR) {
        // 方向サンプリング
        Vec3f dir;
        float pdf_dir;
        Vec3f f = info.hitPrimitive->sampleBxDF(
            -ray.direction, info.surfaceInfo, TransportDirection::FROM_CAMERA,
            sampler, dir, pdf_dir);

        // Throughputとレイを更新する
        throughput *= f *
                      cosTerm(-ray.direction, dir, info.surfaceInfo,
                              TransportDirection::FROM_CAMERA) /
                      pdf_dir;
        ray = Ray(info.surfaceInfo.position, dir);
      }
    } else {
      // レイがシーンに当たらなかった場合
      break;
    }
  }

  return Vec3f(0);
}

引数のray_inはカメラから生成したレイです。

フォトンマップを用いた放射輝度推定では光源から直接カメラに入ってくるような経路や、光源からSpecular面が続いてカメラに至るような経路を含めることができないので、レイが光源に直接当たった場合には光源の放射輝度をThroughputをかけて返すようにします。

Throughputの更新では光源側からの経路構築の時と同様の議論から

k=1t(i)1f(xk1ixki,xk+1i)cosθxkik=2t(i)p(ωxk1i)\frac{\prod_{k=1}^{t(i)-1} f(x_{k-1}^i \to x_k^i, \to x_{k+1}^i)\cos{\theta_{x_k^i}}}{\prod_{k=2}^{t(i)} p(\vec{\omega_{x_{k-1}^i}})}

を計算しています。

以上で式(10)を計算できるようになりました。これでIjiI_j^iが求まるので、あとはi=1,,Ni=1,\cdots,Nに関して平均を取ってあげればjj番目の画素の値が求まります。

Ij=1Ni=1NIjiI_j = \frac{1}{N}\sum_{i=1}^N I_j^i

画像を出してみる

ここまでの処理を実装してコーネルボックスをレンダリングしてみると以下のようになります。

パラメーター
フォトン数(NpN_p) 1000000
近傍フォトンの数(KK) 100
サンプル数(NN) 100
フォトンマッピングによるコーネルボックスのレンダリング
フォトンマッピングによるコーネルボックスのレンダリング

フォトン数はかなり多いにも関わらず、謎の模様が出てしまっていることが分かります。これはカーネル関数近似の誤差によるものです。

この模様を取り除くためにはさらに多くのフォトンを使ってカーネルの半径を縮小させていく必要があります。しかしこれ以上多くのフォトンを使うのはメモリ的に厳しくなってきます。

そこでJensenの青い本ではFinal Gatheringという手法を使ってこの模様を取り除くことを行っています。

長くなってきたので今回はここで終わりにしようと思います。次回はFinal Gatheringについて説明していきます。

次回

References