このノートでは Metal の頂点関数と、頂点データが CPU からシェーダーへ届くまでの道筋を扱います。
頂点関数がしていること
頂点関数は頂点ごとに一度実行され、唯一の必須の仕事はクリップ空間座標を出力することです。それ以外にはいくつでもデータを出力でき、それらはラスタライズ段階で補間されてからフラグメント関数へ渡されます。
MSL#include <metal_stdlib>
using namespace metal;
struct VertexIn {
float4 position [[attribute(0)]];
float3 normal [[attribute(1)]];
float2 uv [[attribute(2)]];
};
struct VertexOut {
float4 position [[position]]; // 必須。この属性を持たせる必要がある
float3 normalWS;
float2 uv;
};
vertex VertexOut vertex_main(VertexIn in [[stage_in]],
constant Uniforms &uniforms [[buffer(11)]])
{
VertexOut out;
float4 posWS = uniforms.modelMatrix * in.position;
out.position = uniforms.projectionMatrix * uniforms.viewMatrix * posWS;
out.normalWS = (uniforms.normalMatrix * in.normal);
out.uv = in.uv;
return out;
}
Metal 固有の修飾子をいくつか。
[[stage_in]]:この引数を buffer から手で読むのではなく、頂点ディスクリプタが組み立てることを示します。[[attribute(n)]]:そのディスクリプタの属性 n に対応します。[[position]]:どの出力がクリップ空間位置かを示します。これがないとコンパイルできません。
頂点ディスクリプタ
MTLVertexDescriptor は「buffer のバイト列を構造体のフィールドとしてどう解釈するか」を記述します。二つの部分から成ります。attributes(各属性のフォーマット、オフセット、どの buffer から来るか)と layouts(各 buffer のストライドとステップ方式)です。
Swiftlet descriptor = MTLVertexDescriptor()
// position: buffer 0 のオフセット 0 にある float3
descriptor.attributes[0].format = .float3
descriptor.attributes[0].offset = 0
descriptor.attributes[0].bufferIndex = 0
// normal: その直後の float3
descriptor.attributes[1].format = .float3
descriptor.attributes[1].offset = MemoryLayout<SIMD3<Float>>.stride
descriptor.attributes[1].bufferIndex = 0
// uv: float2
descriptor.attributes[2].format = .float2
descriptor.attributes[2].offset = MemoryLayout<SIMD3<Float>>.stride * 2
descriptor.attributes[2].bufferIndex = 0
descriptor.layouts[0].stride = MemoryLayout<Vertex>.stride
descriptor.layouts[0].stepFunction = .perVertex
pipelineDescriptor.vertexDescriptor = descriptor
ここでもっとも間違えやすいのが stride と size の違いです。MemoryLayout<T>.size は実際に占めるバイト数、stride はアラインメントのパディングを含めた配列内の隣接要素間の距離です。頂点レイアウトには必ず stride を使います。 size を使うとパディングのある構造体でずれが生じ、症状はクラッシュではなくメッシュの歪みとして現れるため、原因の特定が難しくなります。
具体的な罠をひとつ。SIMD3<Float> の size は 12、stride は 16 で、Metal の float3 も 16 バイト境界に整列します。CPU 側が独立した三つの Float を使い、シェーダー側が float3 を宣言していると、両者のレイアウトは一致しません。
インターリーブと分離
上のように position / normal / uv を一つの buffer に頂点ごとに交互配置するのがインターリーブです。もう一方は属性ごとに buffer を分ける分離(planar)配置です。
インターリーブは一頂点のデータが同じキャッシュラインに載るため頂点フェッチの局所性がよく、既定の選択になります。分離は一つの属性だけを更新できる(スキニング後の位置など)利点があり、位置しか要らないパス——シャドウマップや深度プリパス——では buffer を一つだけバインドして帯域を節約できます。
MDLMesh でモデルを読み込むときは、MTKModelIOVertexDescriptorFromMetal() で Metal の頂点ディスクリプタを Model I/O 用に変換し、読み込まれた buffer の配置をパイプラインの期待と一致させます。この手順を飛ばすと、「モデルの読み込みは成功するのに描画が絡まった塊になる」という典型的な症状が出ます。
uniform を渡す三つの方法
頂点関数は行列や時間といったフレームごとの定数を必要とします。Metal はデータサイズに応じた三つの経路を用意しています。
1. setVertexBytes(4 KB 未満)
Swiftvar uniforms = Uniforms(modelMatrix: model, viewMatrix: view, projectionMatrix: proj)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 11)
Metal がデータをコマンドバッファへ直接コピーするため、MTLBuffer の寿命を管理する必要がありません。小さな uniform の第一選択です。
2. setVertexBuffer(4 KB 超、または再利用する場合)
Swiftencoder.setVertexBuffer(uniformBuffer, offset: 0, index: 11)
インスタンス行列の配列やボーン行列といった大きなデータはこちらを使います。GPU がまだ前フレームの buffer を読んでいる可能性があるため、トリプルバッファリングとセマフォが必要です。さもないと使用中のデータを上書きします。
3. 関数定数(コンパイル時)
MSLconstant bool hasNormalMap [[function_constant(0)]];
これは実行時データではなく、パイプライン生成時に特殊化される定数です。コンパイラが分岐そのものを除去できます。他 API のシェーダーバリアントに相当しますが、#define の組み合わせ爆発を抱えずに済みます。
buffer インデックスの割り当て
[[buffer(n)]] の n はバインディングスロットです。頂点属性はふつう 0 から低い番号を占めるため、uniform は高い番号(多くのプロジェクトでは 11 や 12)に置いて衝突を避けます。
これらのインデックスは CPU と GPU で共有するヘッダに定義しましょう。
C// Common.h。Swift のブリッジングヘッダと .metal の両方から include する
typedef enum {
BufferIndexVertices = 0,
BufferIndexUniforms = 11,
BufferIndexParams = 12
} BufferIndices;
こうすれば両者がずれることはありません。同じヘッダに Uniforms 構造体そのものを定義することもできます。これは Metal が OpenGL に対して持つ明確な利点です。CPU と GPU が一つの C 構造体定義を共有でき、手動で同期を取る必要がありません。
変換を頂点関数で行う理由
MVP 変換は CPU でもできますが、頂点関数のほうが適しています。
- 頂点数はフラグメント数よりはるかに少なく、オブジェクト数よりはるかに多い。頂点あたり一回の行列積は、まさに GPU が得意とするバルク処理です。
- 頂点データは 1 フレーム、あるいは複数フレームにわたって GPU 上に置いたままにでき、CPU は数個の行列を更新するだけで済みます。
- ここで計算したワールド空間の位置と法線は、補間される出力としてそのままフラグメント段階へ渡せるので、再計算が不要です。
ひとつ例外を挙げておきます。法線はモデル行列で変換しません。 非一様スケールがある場合、法線はモデル行列の逆転置で変換しなければ、面に垂直でなくなります。この行列はシェーダー内で頂点ごとに逆行列を求めるのではなく、CPU 側で計算して uniform に入れるべきです。
次回は補間の後、フラグメント関数で何が起きるかを見ます。