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

Metal #17 レイトレーシング(一)レンダリングアルゴリズム

#ComputerGraphics#GraphicsEngine#Metal

レイトレーシング三部作の第一回です。アルゴリズムそのものと Metal の API 構成を扱います。

レンダリング方程式

物理ベースのレンダラーはすべて、同じ方程式(Kajiya, 1986)を近似しています。

Plain TextL_o(p, ω_o) = L_e(p, ω_o) + ∫_Ω f_r(p, ω_i, ω_o) · L_i(p, ω_i) · (n · ω_i) dω_i

言葉にすれば、点 p から方向 ω_o へ出ていく放射輝度は、p 自身が放つ光に、半球 Ω 全方向からの入射光を BRDF で重み付けした積分を加えたもの、となります。

この方程式は再帰的です。L_i はそれ自体が別の点の L_o だからです。この再帰こそ大域照明の数学的な本質です。一度跳ね返った光は、さらに跳ね返ります。ラスタライズはこれを直接扱えず、事前計算とスクリーン空間の工夫で近似しますが、レイトレーシングはこれを評価します。

モンテカルロ推定

この積分に閉じた形はなく、数値的に解くしかありません。モンテカルロ法はランダムサンプリングで推定します。

Plain Text∫ f(x) dx ≈ (1/N) · Σ f(x_i) / p(x_i)

p はサンプリングの確率密度です。この推定量は不偏で、期待値が真の積分と一致します。誤差はノイズとして現れ、標準偏差は 1/√N で下がります。

この 1/√N こそレイトレーシングの根本的な難しさです。ノイズを半分にするにはサンプルが 4 倍必要になります。1 spp と 100 spp の差は計算量 100 倍という話ではなく、リアルタイムレイトレーシングという分野が存在する理由そのものです。

重要度サンプリング

サンプリング分布 p は自由に選べるのですから、被積分関数の形にできるだけ近づけるべきです。pf に比例する場所を多くサンプリングすれば、分散は下がります。

GGX スペキュラ BRDF については、その法線分布に従ってハーフベクトルをサンプリングします。

MSLfloat3 importanceSampleGGX(float2 xi, float3 n, float roughness) {
    float a = roughness * roughness;
    float phi = 2.0 * M_PI_F * xi.x;
    float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));
    float sinTheta = sqrt(1.0 - cosTheta * cosTheta);

    float3 h = float3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
    return tangentToWorld(h, n);
}

拡散については半球を余弦加重でサンプリングします。レンダリング方程式に既に (n · ω_i) の因子があるからです。

MSLfloat3 cosineSampleHemisphere(float2 xi, float3 n) {
    float r = sqrt(xi.x);
    float phi = 2.0 * M_PI_F * xi.y;
    float3 d = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - xi.x)));
    return tangentToWorld(d, n);
}

余弦サンプリングと (n · ω_i) の因子はちょうど打ち消し合い、推定量は BRDF 値の単純平均に簡約されます。これが標準的な選択である理由です。

多重重要度サンプリング(MIS)はさらに戦略を組み合わせます。光源サンプリングは小さな光源で分散が小さく、BRDF サンプリングは滑らかな面で分散が小さい。MIS はバランスヒューリスティックで両者を重み付けし、それぞれの長所を取ります。パストレーサーにおいて単独でもっとも効果的な最適化です。

Metal の加速構造

レイトレーシングの中心的な操作は「このレイは何に当たったか」です。全三角形を調べれば O(n) で、実用になりません。加速構造(BVH)がこれを O(log n) にします。

Metal の加速構造は二層構造です。

BLAS(下位層)は実際のジオメトリを含みます。

Swiftlet geometry = MTLAccelerationStructureTriangleGeometryDescriptor()
geometry.vertexBuffer = vertexBuffer
geometry.vertexStride = MemoryLayout<Vertex>.stride
geometry.indexBuffer  = indexBuffer
geometry.indexType    = .uint32
geometry.triangleCount = triangleCount

let primitiveDesc = MTLPrimitiveAccelerationStructureDescriptor()
primitiveDesc.geometryDescriptors = [geometry]

TLAS(上位層)は BLAS のインスタンスを、それぞれ変換行列とともに保持します。

Swiftlet instanceDesc = MTLInstanceAccelerationStructureDescriptor()
instanceDesc.instancedAccelerationStructures = blasArray
instanceDesc.instanceCount = instanceCount
instanceDesc.instanceDescriptorBuffer = instanceBuffer

二層にする価値は再利用と増分更新にあります。同じ木が一万本あっても BLAS は一つと一万個のインスタンス変換で済みます。物体が動いたときは TLAS の再構築だけで(これは高速)、BLAS には触れません。

BLAS の更新が要るのは変形するジオメトリ——スキニングされたキャラクター、クロス——だけで、しかも完全な再構築ではなく refit で済みます。refit は BVH の木構造を保ったままバウンディングボックスだけを更新するのではるかに安価ですが、ジオメトリが元の形から離れるほど木の品質が劣化していきます。

構築にはスクラッチバッファと GPU コマンドが必要です。

Swiftlet sizes = device.accelerationStructureSizes(descriptor: primitiveDesc)
let accel = device.makeAccelerationStructure(size: sizes.accelerationStructureSize)!
let scratch = device.makeBuffer(length: sizes.buildScratchBufferSize, options: .storageModePrivate)!

let encoder = commandBuffer.makeAccelerationStructureCommandEncoder()!
encoder.build(accelerationStructure: accel, descriptor: primitiveDesc,
              scratchBuffer: scratch, scratchBufferOffset: 0)
encoder.endEncoding()

交差判定:intersector

Metal はレイを飛ばすための intersector テンプレートを提供します。コンピュートカーネルでも、フラグメントや頂点関数でも使えます(インラインレイトレーシング)。

MSL#include <metal_raytracing>
using namespace metal::raytracing;

kernel void raytrace(instance_acceleration_structure accel [[buffer(0)]],
                     texture2d<float, access::write> output [[texture(0)]],
                     constant Uniforms &u [[buffer(1)]],
                     uint2 gid [[thread_position_in_grid]])
{
    ray r;
    r.origin       = u.cameraPosition;
    r.direction    = computeRayDirection(gid, u);
    r.min_distance = 0.001;                  // 自己交差を避ける
    r.max_distance = INFINITY;

    intersector<instancing, triangle_data> i;
    i.assume_geometry_type(geometry_type::triangle);
    i.force_opacity(forced_opacity::opaque); // アルファテストがなければ高速化できる

    intersection_result<instancing, triangle_data> hit = i.intersect(r, accel);

    if (hit.type == intersection_type::none) {
        output.write(sampleSky(r.direction), gid);
        return;
    }

    float2 bary = hit.triangle_barycentric_coord;
    // bary で頂点属性を補間し、シェーディングへ ...
}

min_distance = 0.001 の行は省略できません。面から出たレイは浮動小数の誤差により、出発した三角形自身に即座に当たる確率が現実にあります。これは画面全体に散る黒いノイズとして現れます。レイトレーシング版の シャドウアクネ です。

より頑健な方法は、レイの原点を法線方向へオフセットし、そのオフセット量をカメラからの距離でスケールすることです。浮動小数の精度自体が相対的だからです。

force_opacity(opaque) は any-hit コールバックが不要であることをハードウェアに伝え、最初のヒットで走査を止められるようにします。葉のようなアルファテストのジオメトリではこれができず、intersection function table 経由で any-hit 関数を提供する必要があります。

次回はこれらのレイを影とライティングに使う方法を扱います。

このシリーズの記事

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