T.TAO
ブログへ戻る
/10 min read/Graphics Engine

Metal #18 レイトレーシング(二)影とライティング

#ComputerGraphics#GraphicsEngine#Metal

レイトレーシング三部作の第二回です。レイから影と大域照明をどう得るかを扱います。

シャドウレイ

影はレイトレーシングでもっとも即効性のある応用です。シェーディング点から光源へレイを飛ばし、途中に何かあれば影の中です。

MSLbool isOccluded(float3 origin, float3 normal, float3 lightPos,
                instance_acceleration_structure accel)
{
    float3 toLight = lightPos - origin;
    float  dist    = length(toLight);

    ray r;
    r.origin       = origin + normal * 0.001;   // 自己ヒット対策に法線方向へオフセット
    r.direction    = toLight / dist;
    r.min_distance = 0.001;
    r.max_distance = dist - 0.001;              // 光源自体には当てない

    intersector<instancing> i;
    i.accept_any_intersection(true);            // 「あるか」だけを問い、「どれか」は問わない
    return i.intersect(r, accel).type != intersection_type::none;
}

accept_any_intersection(true) が鍵となる最適化です。シャドウレイに最近接の交点は不要で、交点が存在するかどうかだけがわかればよいので、最初のヒットで走査を打ち切れます。これによりシャドウレイは通常のレイのおおむね二倍速くなります。

シャドウマップと比べると、トレースされた影には解像度の限界も、調整すべきバイアスも、カスケードの継ぎ目も、Peter Panning もありません。単純に正しいのです。

ソフトシャドウ

現実の影に半影があるのは、光源に面積があるからです。手法は点を狙うのではなく光源の表面をサンプリングすることです。

MSLfloat3 sampleSphereLight(Light light, float2 xi) {
    float3 dir = uniformSampleSphere(xi);
    return light.position + dir * light.radius;
}

// ピクセルあたり 1 本、時間方向の累積でデノイズする
float shadow = isOccluded(p, n, sampleSphereLight(light, rand2(gid, frame)), accel) ? 0.0 : 1.0;

半影の幅は光源の大きさと、遮蔽物から受光面までの距離から自然に決まります。パラメータは一切ありません。これが PCF に対するレイトレーシングのもっとも目に見える利点です。PCF は一定の半径でぼかしますが、実際の半影は遮蔽物が地面に接する箇所では鋭く、離れるにつれて広がります。

1 サンプルではノイズが強く、残りは時間方向の累積(TAA や専用のシャドウデノイザー)が回復します。

Next event estimation

素朴なパストレーシングは BRDF だけをサンプリングし、レイがたまたま光源に当たることを期待します。小さな光源ではその確率は微小で、画面は長いあいだ斑点だらけになります。

Next event estimation(NEE、直接光サンプリングとも)は、経路の各頂点で光源へ明示的にシャドウレイを飛ばします。

MSLfloat3 directLighting(float3 p, float3 n, float3 wo, Material m,
                      constant Light *lights, uint lightCount,
                      instance_acceleration_structure accel, float2 xi)
{
    // 光源を一様に選び、あとで選択確率で割る
    uint  li    = min(uint(xi.x * lightCount), lightCount - 1);
    float pdfL  = 1.0 / float(lightCount);

    float3 lp   = sampleLightSurface(lights[li], xi);
    float3 wi   = normalize(lp - p);
    float  dist = length(lp - p);

    if (dot(n, wi) <= 0.0) return 0.0;
    if (isOccluded(p, n, lp, accel)) return 0.0;

    float3 brdf = evaluateBRDF(m, n, wi, wo);
    float  G    = dot(n, wi) / (dist * dist);
    return brdf * lights[li].color * G / pdfL;
}

NEE があれば、1 バウンスでも直接光はきれいに出て、ノイズは間接項に限定されます。パストレーシングを「理論的に正しい」から「実用になる」へ移す一歩がこれです。

NEE と BRDF サンプリングは MIS で組み合わせるべきです。NEE は小さな光源に、BRDF サンプリングは大きな光源と鏡面反射に強く、加重して組み合わせれば両方をカバーできます。

パストレーシングのループ

完全なパストレーサーはこれらを再帰ではなくループで組み立てます。GPU には再帰に使えるスタックがないためです。

MSLfloat3 pathTrace(ray r, instance_acceleration_structure accel,
                 constant Scene &scene, thread uint &seed)
{
    float3 radiance   = 0.0;
    float3 throughput = 1.0;     // 累積した BRDF / pdf

    for (uint bounce = 0; bounce < MAX_BOUNCES; bounce++) {
        auto hit = trace(r, accel);
        if (hit.type == intersection_type::none) {
            radiance += throughput * sampleEnvironment(r.direction);
            break;
        }

        SurfaceData s = unpackSurface(hit, scene);

        // 直接光(NEE)
        radiance += throughput * directLighting(s.position, s.normal, -r.direction,
                                                s.material, scene.lights, scene.lightCount,
                                                accel, rand2(seed));

        // 次の方向をサンプリング
        float3 wi; float pdf;
        float3 brdf = sampleBRDF(s.material, s.normal, -r.direction, rand2(seed), wi, pdf);
        if (pdf <= 0.0) break;

        throughput *= brdf * abs(dot(s.normal, wi)) / pdf;

        // ロシアンルーレット
        if (bounce > 2) {
            float q = max(throughput.x, max(throughput.y, throughput.z));
            if (rand(seed) > q) break;
            throughput /= q;      // 補償して推定量を不偏に保つ
        }

        r.origin    = s.position + s.normal * 0.001;
        r.direction = wi;
    }
    return radiance;
}

ロシアンルーレットは説明に値します。固定の深さで経路を打ち切ると偏りが生じます。捨てられた長い経路のエネルギーが失われ、画は暗くなります。ロシアンルーレットは確率 1-q で経路を終了させる代わりに、生き残った経路の寄与を q で割って補償します。期待値は変わらないので推定量は不偏のまま、平均経路長は大きく下がります。

q に throughput の最大成分を使うのが標準です。寄与が既に小さい経路ほど打ち切られやすくなります。

ハイブリッドレンダリング

純粋なパストレーシングはリアルタイムの予算に収まりません。現在の実務的な構成はハイブリッドです。一次可視性はラスタライズが担い、レイトレーシングは得意な部分を担当します。

Plain TextG-Buffer(ラスタライズ) ─┬─> トレースした影 ───┐
                         ├─> トレースした反射 ─┼─> デノイズ ─> 合成 ─> ポスト
                         └─> トレースした AO/GI ┘

理由は具体的です。ラスタライズによる一次可視性はノイズがほぼなくコストが予測可能であり、影・反射・AO はまさにスクリーン空間手法がもっとも目に見えて破綻する箇所だからです。

G-Buffer からレイを飛ばすのは、カメラから飛ばすよりはるかに安上がりです。最初の交差判定はすでにラスタライザが済ませているからです。さらに G-Buffer の法線と粗さを使って、ピクセルごとに何本のレイが必要かを決められます。滑らかな反射は少数で足り、粗いものは IBL へフォールバックできます。

デノイズ

1 spp のトレース結果はほぼノイズです。それを実用にするのがデノイザーであり、その重要性はトレーシング自体に劣りません。

方向は二つ。

時間方向の累積。 モーションベクトルで前フレームの結果を再投影し、新しいサンプルと混ぜます。実効サンプル数を数十倍にします。難しいのは履歴がまだ有効かの判定です。遮蔽やライティングが変化したら履歴を捨てなければならず、さもないとゴーストが出ます。

空間方向のフィルタ。 近傍のサンプルで補いますが、必ずエッジを意識したフィルタでなければなりません。法線と深度が近い近傍とだけ混ぜないと、異なる面のライティングが混ざってしまいます。À-trous ウェーブレットフィルタは、ストライドを段階的に広げながら繰り返しフィルタすることで、O(n) のコストで広いカーネルを実現します。

Apple は MPSSVGF(時空間分散ガイドフィルタ)を既成の実装として提供しています。両者を組み合わせ、分散推定でフィルタ強度を決めます。ノイズの大きい領域ほど強くフィルタされます。

次回はレイトレーシングの性能最適化を扱います。

このシリーズの記事

Metal
  1. 01Metal #0 Swift 復習
  2. 02Metal #1 初期化
  3. 03Metal #2 レンダリングパイプライン
  4. 04Metal #3 頂点関数
  5. 05Metal #4 フラグメント関数
  6. 06Metal #5 テクスチャ
  7. 07Metal #6 カメラと操作
  8. 08Metal #7 ライティング
  9. 09Metal #8 マテリアル
  10. 10Metal #9 レンダーパス
  11. 11Metal #10 シャドウ
  12. 12Metal #11 遅延レンダリング
  13. 13Metal #12 パーティクルシステム
  14. 14Metal #13 テッセレーション
  15. 15Metal #14 ポストプロセス
  16. 16Metal #15 反射と屈折
  17. 17Metal #16 アニメーション
  18. 18Metal #17 レイトレーシング(一)レンダリングアルゴリズム
  19. 19Metal #18 レイトレーシング(二)影とライティング
  20. 20Metal #19 レイトレーシング(三)性能最適化
  21. 21Metal #21 [付録] Compute Shader
  22. 22Metal #22 [付録] SwiftUI における Metal