このノートでは Metal のフラグメント関数と、その前後にある固定機能ステージを扱います。
フラグメントはピクセルではない
フラグメントとは、「ある三角形があるサンプル点を覆った」という事実から生じる候補データです。一つのピクセルの最終色は複数のフラグメントから来ることもあれば(半透明の重ね、MSAA のサンプル)、一つも来ないこともあります(カリングされた、あるいはジオメトリがない)。
この区別は重要です。フラグメント関数の実行回数は、三角形がサンプル点をいくつ覆ったかで決まり、画面にピクセルがいくつあるかでは決まりません。オーバードローが性能問題になるのはまさにこのためです。
MSLfragment float4 fragment_main(VertexOut in [[stage_in]],
constant Params ¶ms [[buffer(12)]])
{
float3 n = normalize(in.normalWS);
float ndotl = saturate(dot(n, params.lightDirection));
return float4(params.baseColor * ndotl, 1.0);
}
normalize(in.normalWS) に注目してください。頂点関数は単位法線を出力しましたが、線形補間は長さを保存しません。三角形内部で補間された法線は必ず単位長より短くなり、中心に近いほどずれます。再正規化を省くと、面ごとに目に見える暗い塊が現れます。
補間修飾子
既定では VertexOut のすべてのメンバが透視補正つきで補間されます。メンバ単位で変更できます。
MSLstruct VertexOut {
float4 position [[position]];
float3 normalWS; // 既定:透視補正あり
float2 screenUV [[center_no_perspective]]; // 線形。スクリーン空間の処理向け
uint materialID [[flat]]; // 補間しない。最初の頂点の値を取る
};
[[flat]]— 補間なし。整数型(マテリアル ID、インスタンス番号)には必須です。整数を補間する妥当な方法がないからです。[[center_no_perspective]]— スクリーン空間の線形補間。フルスクリーンクアッドの UV にはこちらが正しく、かつ安価です。[[sample_perspective]]— MSAA でフラグメントごとではなくサンプルごとに評価します。品質は上がりますが、フラグメント関数の実行回数がサンプル数倍になります。
quad こそが実際の実行単位
GPU は単独のフラグメントを実行しません。ラスタライザは 2×2 の quad 単位でディスパッチします。四つのうち一つしか三角形に覆われていなくてもです。
これはテクスチャサンプリングが導関数を必要とするためです。ミップレベルを選ぶには隣接ピクセル間で UV がどれだけ変化したかを知る必要があり、その差分は同じ quad 内の隣同士の UV を比較してしか得られません。dfdx / dfdy はまさにこの仕組みです。
ここから二つの帰結が直接導かれます。
- 細長い三角形は非常に高価。 数ピクセルしか覆わない三角形でも、部分的に覆われた quad ごとにフラグメント関数が 4 回走り、うち 3 回の結果は捨てられます。密なメッシュが遠景で予想以上に遅くなる理由であり、メッシュ LOD が存在する理由の一つでもあります。
- 分岐の中でのサンプリングは危険。 quad 内の四つのフラグメントが別々の分岐に入ると、導関数は意味を失います。Metal はテクスチャサンプリングを非一様な制御フローの外で行うことを要求します。分岐内でサンプリングが必要なら、明示的な LOD を使ってください。
texture.sample(s, uv, level(0))。
深度テストと early-Z
深度テストは固定機能で、MTLDepthStencilState を通して設定します。
Swiftlet depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .less
depthDescriptor.isDepthWriteEnabled = true
depthState = device.makeDepthStencilState(descriptor: depthDescriptor)
encoder.setDepthStencilState(depthState)
仕様上、深度テストはフラグメント関数の後に行われます。フラグメント関数が原理的に深度を変更しうるからです。しかしシェーダーが [[depth]] を書かず、discard_fragment() を呼ばず、alpha-to-coverage を有効にしないかぎり、ハードウェアは early-Z を行い、シェーダーを走らせる前に遮蔽されたフラグメントを捨てます。
つまり、discard_fragment() を使うシェーダー——アルファテストの葉など——は、その draw call 全体から early-Z を奪います。アルファテストの植生を描くときは、別グループにまとめて不透明ジオメトリの後に描くのが有利なことが多いです。
シェーダーがどうしても深度を書く必要があり、かつ深度を遠ざける方向にしか書かないと保証できるなら、保守的深度を宣言してハードウェアに最適化の一部を残せます。
MSLfragment float4 f(..., float d [[depth(greater)]]) { ... }
ブレンド
半透明はブレンドステートで設定します。これはエンコーダではなくパイプラインステートに属します。
Swiftlet attachment = pipelineDescriptor.colorAttachments[0]!
attachment.isBlendingEnabled = true
attachment.rgbBlendOperation = .add
attachment.sourceRGBBlendFactor = .sourceAlpha
attachment.destinationRGBBlendFactor = .oneMinusSourceAlpha
実際に使う組み合わせは次のとおり。
| 効果 | source | destination |
|---|---|---|
| 通常の半透明 | .sourceAlpha | .oneMinusSourceAlpha |
| 乗算済みアルファ | .one | .oneMinusSourceAlpha |
| 加算(発光、炎) | .one | .one |
| 乗算(影のデカール) | .destinationColor | .zero |
ブレンドの決定的な制約は順序依存であることです。a over b は b over a と等しくありません。したがって半透明オブジェクトは奥から手前へ描き、深度書き込みを切る(isDepthWriteEnabled = false)必要があります。そうしないと手前の半透明面が、本来透けて見えるはずのものを隠します。オブジェクトが互いに貫入するとソートは破綻します。半透明がどのエンジンでも厄介であり続ける理由です。
乗算済みアルファについても一文添えておきます。これはフィルタリングとミップマップに対してブレンドを頑健にします。乗算済みの二色を線形補間しても正しい乗算済みの色になりますが、ストレートアルファではエッジに黒い縁が出るためです。
複数レンダーターゲット
一つのフラグメント関数が複数のアタッチメントへ同時に書き込めます。これが遅延レンダリングの基礎です。
MSLstruct GBufferOut {
float4 albedo [[color(0)]];
float4 normal [[color(1)]];
float4 position [[color(2)]];
};
fragment GBufferOut gbuffer_fragment(VertexOut in [[stage_in]]) {
GBufferOut out;
out.albedo = float4(baseColor, 1);
out.normal = float4(normalize(in.normalWS) * 0.5 + 0.5, 1);
out.position = float4(in.positionWS, 1);
return out;
}
CPU 側ではレンダーパスディスクリプタとパイプラインディスクリプタの両方に同数のカラーアタッチメントを設定し、ピクセルフォーマットを完全に一致させる必要があります。一致しなければパイプライン生成が失敗します。
Apple Silicon ではここにもう一つの機会があります。タイルメモリです。複数のアタッチメントをオンチップに留めたまま、デバイスメモリへ書き戻さずに済ませられます。詳しくは《Metal #11 遅延レンダリング》で扱います。
次回はテクスチャです。