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

Metal #13 テッセレーション

#ComputerGraphics#GraphicsEngine#Metal

このノートではテッセレーションを扱います。粗いパッチを GPU 上で多数の小さな三角形へ分割し、地形、ディスプレイスメント、適応 LOD に用いる技法です。

Metal のテッセレーションパイプラインは異質

D3D11 や OpenGL 4 では、テッセレーションは三つの固定段です。hull shader → tessellator(固定機能)→ domain shader。

Metal は後ろの二つしか持ちません。テッセレーション係数はシェーダーステージではなく、通常のコンピュートカーネルがバッファへ書き込みます。

Plain Textコンピュートカーネル(自作) → 係数バッファ → tessellator(固定機能) → ポストテッセレーション頂点関数(自作)

最初は戸惑う設計ですが、実際にはより柔軟です。係数の計算が通常のコンピュートディスパッチになるため、他の GPU 処理とまとめたり、先に計算した可視性データを再利用したり、フレームをまたいでキャッシュしたりできます。

係数の計算

MSLkernel void tessellation_factors(
    device MTLQuadTessellationFactorsHalf *factors [[buffer(0)]],
    constant Patch *patches [[buffer(1)]],
    constant TessParams &params [[buffer(2)]],
    uint pid [[thread_position_in_grid]])
{
    float3 center = patches[pid].center;
    float  dist   = distance(center, params.cameraPosition);

    // 近いパッチほど細かく分割する
    float t = saturate(1.0 - (dist - params.nearDist) / (params.farDist - params.nearDist));
    float level = mix(1.0, params.maxTessellation, t);

    // 四辺と内部二方向
    factors[pid].edgeTessellationFactor[0] = half(edgeLevel(patches[pid], 0, params));
    factors[pid].edgeTessellationFactor[1] = half(edgeLevel(patches[pid], 1, params));
    factors[pid].edgeTessellationFactor[2] = half(edgeLevel(patches[pid], 2, params));
    factors[pid].edgeTessellationFactor[3] = half(edgeLevel(patches[pid], 3, params));
    factors[pid].insideTessellationFactor[0] = half(level);
    factors[pid].insideTessellationFactor[1] = half(level);
}

破綻しやすいのは辺の係数です。 隣り合う二つのパッチは辺を共有します。両者がその辺に異なる係数を出すと、生成される頂点が一致せず、あいだに亀裂が開きます。

対処は、辺の係数をパッチの性質ではなくその辺自身の性質だけに依存させることです。辺の両端点の中点までの距離から計算すれば、どちらのパッチからも同じ値が導かれます。

MSLfloat edgeLevel(Patch p, uint edge, constant TessParams &params) {
    float3 mid = (p.corner[edge] + p.corner[(edge + 1) % 4]) * 0.5;
    float  d   = distance(mid, params.cameraPosition);
    return clamp(params.maxTessellation / max(d, 1.0), 1.0, params.maxTessellation);
}

係数 0 はそのパッチをカリングすることを意味します。これは実に有用な性質で、同じカーネルで視錐台カリングと背面カリングを行え、カリングされたパッチはそもそもテッセレーションされません。

ポストテッセレーション頂点関数

MSL[[patch(quad, 4)]]
vertex TessOut tessellation_vertex(
    patch_control_point<ControlPoint> control [[stage_in]],
    float2 uv [[position_in_patch]],
    texture2d<float> heightMap [[texture(0)]],
    constant Uniforms &u [[buffer(11)]])
{
    // パッチ内部の位置をバイリニア補間で求める
    float3 top    = mix(control[0].position, control[1].position, uv.x);
    float3 bottom = mix(control[3].position, control[2].position, uv.x);
    float3 pos    = mix(top, bottom, uv.y);

    // ディスプレイスメント:ハイトマップで持ち上げる
    float2 texUV  = mix(mix(control[0].uv, control[1].uv, uv.x),
                        mix(control[3].uv, control[2].uv, uv.x), uv.y);
    float  height = heightMap.sample(heightSampler, texUV, level(0)).r;
    pos.y += height * u.displacementScale;

    TessOut out;
    out.position = u.viewProjection * float4(pos, 1);
    out.uv       = texUV;
    return out;
}

[[patch(quad, 4)]] は制御点 4 つの四角形パッチを宣言します。[[position_in_patch]] は tessellator が生成したパッチ内のパラメータ座標です(四角形なら uv、三角形なら重心座標)。

level(0) に注意してください。頂点段でのテクスチャサンプリングには明示的な LOD が必須です。頂点段には導関数がないためです。

パイプラインの設定

SwiftpipelineDescriptor.tessellationFactorStepFunction      = .perPatch
pipelineDescriptor.tessellationPartitionMode           = .fractionalEven
pipelineDescriptor.tessellationOutputWindingOrder      = .clockwise
pipelineDescriptor.maxTessellationFactor               = 16

tessellationPartitionMode は非整数の係数の扱いを決めます。

  • .pow2 — 2 の冪のみ。最速ですが LOD 切り替えが目に見えてポップします。
  • .integer — 整数へ丸め。やはりポップします。
  • .fractionalEven / .fractionalOdd小数の係数を許し、新しい頂点が既存の辺から滑らかに生えてきます。LOD のポッピングを避ける唯一の方法であり、地形ではほぼ常に fractional モードを使います。

描画には専用の API を使います。

Swiftencoder.setTessellationFactorBuffer(factorBuffer, offset: 0, instanceStride: 0)
encoder.drawPatches(numberOfPatchControlPoints: 4,
                    patchStart: 0, patchCount: patchCount,
                    patchIndexBuffer: nil, patchIndexBufferOffset: 0,
                    instanceCount: 1, baseInstance: 0)

ディスプレイスメントと法線

ディスプレイスメントマッピングは実際にジオメトリを動かします。これは法線マッピングとは質的に異なり、正しいシルエット、正しい自己遮蔽、正しい視差が得られます。

ただし落とし穴があります。変位後は元の法線が正しくありません。 方法は三つ。

  1. ハイトマップから法線を求める。 近傍テクセルの有限差分で計算します。単純ですが、サンプリングが 3 回増えます。
  2. 法線マップをベイクする。 DCC ツールでハイポリからベイクし、実行時にサンプリングします。最速でもっとも一般的です。
  3. フラグメント段でスクリーン空間導関数を使う。 normalize(cross(dfdx(posWS), dfdy(posWS)))。追加サンプリングは不要ですが、面法線になるためファセットが見えます。

使うべきとき、使わないべきとき

テッセレーションは地形では明確な勝ちです。ハイトマップ一枚と粗いグリッドだけで、手前は密に遠方は疎に、という適応的なジオメトリが、きわめて少ないデータから得られます。

ただしコストは明確に意識してください。テッセレーション段の処理能力は多くの GPU、とくにモバイルでは限られており、係数を大きくしすぎるとすぐにボトルネックになります。経験則は、最終的な三角形を画面上で 8 ピクセル以上に保つことです。それより小さいと quad のオーバーヘッドが性能の大半を食い、それなら密な静的メッシュのほうが安上がりだったということになります。

業界の潮流が変わりつつあることも知っておく価値があります。メッシュシェーダー(Metal では object shader + mesh shader)は、より汎用的なジオメトリ生成パイプラインを提供し、テッセレーションにできることをすべてこなしたうえ、meshlet カリングのようなテッセレーションにできないこともできます。対応ハードウェアを狙う新規プロジェクトなら、通常はメッシュシェーダーのほうが良い選択です。それでもテッセレーションを理解する価値はあります。既存のエンジンに広く残っており、概念的にはより単純だからです。

次回はポストプロセスを扱います。

このシリーズの記事

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