Metal Best Practicesは、iOS/MacOS/tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。
本稿では、何回かに分けてこのガイドを読み解き、コード上での実験を交えて解説していきます。
読んでそのまま理解できそうなところは飛ばしますので、原文を読みながら原文のガイドとしてご利用下さい。
また、iOSの記事なので他のOS(MacOS, tvOS)についての記載は割愛します。
他の記事の一覧は、初回記事よりご覧下さい。
Render Command Encoders (iOS and tvOS)(レンダーコマンドエンコーダー)
ベストプラクティス:可能な場合は、レンダーコマンドエンコーダーをマージします。
レンダーコマンドエンコーダーをマージできるならマージして不要なものを減らすと、パフォーマンスが向上する、という話です。
レンダーコマンドエンコーダーをマージできるかどうかは依存関係によります。大まかに書くと次のようになります。
同じフレームの中で、あるレンダーコマンドエンコーダーの結果をそのまま利用しているレンダーコマンドエンコーダーがある場合は、それらをマージできる
あるレンダーコマンドエンコーダーの結果にサンプリングの依存関係がある場合はそれらをマージできない
本家のページには、マージするための条件が細かく記載されていましたが、概ね上の条件で考え、細かい条件を詰めるときに本家のページを参照すると良いかもしれません。
コードで確認してみる
コードで確認してみます。
今回はこちらのサンプルコードを改変して検証してみます。
仕上がりのイメージ
しあがりは、このように赤い図形と青い図形が重なるようになります。
その1:レンダーコマンドエンコーダーを複数作成した場合
まずは、レンダーコマンドエンコーダーを複数作成した場合を作っていきます。
検証しやすいように、シェーダーには無駄なループを入れて処理を重くします。
// 赤い図形を描くシェーダー
fragment float4 simpleShapeFragmentShader(
ColorInOut in [[ stage_in ]],
constant Uniforms &uniforms [[buffer(1)]]) {
float3 destColor = float3(1.0, 1.0, 1.0);
float2 position = (in.position.xy * 2.0 - uniforms.resolution.xy) / min(uniforms.resolution.x, uniforms.resolution.y);
// 無駄にループして重くする
for(float i = -1 ; i < 1 ; i += 0.001) {
if (inCircle (position, float2( i, -0.1), 0.5)) {
destColor *= float3(1.0, 0.0, 0.0);
}
}
return float4(destColor, 1);
}
// 青い図形を描くシェーダー
fragment float4 simpleShapeFragmentShader2(
ColorInOut in [[ stage_in ]],
constant Uniforms &uniforms [[buffer(1)]]) {
float3 destColor = float3(1.0, 1.0, 1.0);
float2 position = (in.position.xy * 2.0 - uniforms.resolution.xy) / min(uniforms.resolution.x, uniforms.resolution.y);
// 無駄にループして重くする
for(float i = -1 ; i < 1 ; i += 0.001) {
if (inRect(position, float2(i, -0.5), 0.25)) {
destColor *= float3(0.0, 0.0, 1.0);
}
}
// 赤い図形の上から描くので、色をつけたくない場所は何もしないようにする
if (destColor.r == 1) {
discard_fragment();
}
return float4(destColor, 1);
}
次に、レンダーコマンドエンコーダーを2つ用意します。
func draw(in view: MTKView) {
let commandBuffer = metalCommandQueue.makeCommandBuffer()!
guard let drawable = view.currentDrawable else {return}
renderPassDescriptor1.colorAttachments[0].texture = drawable.texture
// 1つ目のコマンドエンコーダー
let renderEncoder1 = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor1)!
// 赤い図形を描くシェーダーを指定したRenderPipelineState(事前に準備しておく)
guard let renderPipeline = offScreenRenderPipeline else {fatalError()}
renderEncoder1.setRenderPipelineState(renderPipeline)
renderEncoder1.setVertexBuffer(vertextBuffer, offset: 0, index: 0)
renderEncoder1.setVertexBuffer(texCordBuffer, offset: 0, index: 1)
renderEncoder1.setFragmentTexture(texture, index: 0)
renderEncoder1.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 1)
renderEncoder1.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder1.endEncoding()
renderPassDescriptor2.colorAttachments[0].texture = drawable.texture
// 2つ目のコマンドエンコーダーは、.loadアクションにすることで、赤い図形の上に青い図形を描くことができる
renderPassDescriptor2.colorAttachments[0].loadAction = .load
// 2つ目のコマンドエンコーダー
let renderEncoder2 = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor2)!
// 青い図形を描くシェーダーを指定したRenderPipelineState(事前に準備しておく)
guard let renderPipeline = onScreenRenderPipeline else {fatalError()}
renderEncoder2.setRenderPipelineState(renderPipeline)
renderEncoder2.setVertexBuffer(vertextBuffer, offset: 0, index: 0)
renderEncoder2.setVertexBuffer(texCordBuffer, offset: 0, index: 1)
renderEncoder2.setFragmentTexture(texture, index: 0)
renderEncoder2.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 1)
renderEncoder2.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder2.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
}
このコードを実行してみます。
Metal System Traceを使って処理の状況を確認してみましょう。
このツールの使い方はこちらをご覧下さい。
描画は4FPS程度となりました。リフレッシュにかかる時間は平均すると248msでした。
処理時間のほとんどはフラグメントシェーダによるものだということがわかります。
2つのフラグメントシェーダーは別々のレンダーコマンドエンコーダーでエンコードしたので、2つのコマンドとして表示されていました。
その2:レンダーコマンドエンコーダーをマージして1つにした場合
次に、レンダーコマンドエンコーダーをマージして1つにしてみます。
func draw(in view: MTKView) {
let commandBuffer = metalCommandQueue.makeCommandBuffer()!
guard let drawable = view.currentDrawable else {return}
renderPassDescriptor1.colorAttachments[0].texture = drawable.texture
let renderEncoder1 = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor1)!
// 赤い図形を描くシェーダーを指定
guard let renderPipeline = offScreenRenderPipeline else {fatalError()}
renderEncoder1.setRenderPipelineState(renderPipeline)
renderEncoder1.setVertexBuffer(vertextBuffer, offset: 0, index: 0)
renderEncoder1.setVertexBuffer(texCordBuffer, offset: 0, index: 1)
renderEncoder1.setFragmentTexture(texture, index: 0)
renderEncoder1.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 1)
renderEncoder1.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
// 青い図形を描くシェーダーを指定
guard let renderPipeline = onScreenRenderPipeline else {fatalError()}
// 同じレンダーコマンドエンコーダーに別のRenderPipelineStateを指定して描画する
renderEncoder1.setRenderPipelineState(renderPipeline)
renderEncoder1.setVertexBuffer(vertextBuffer, offset: 0, index: 0)
renderEncoder1.setVertexBuffer(texCordBuffer, offset: 0, index: 1)
renderEncoder1.setFragmentTexture(texture, index: 0)
renderEncoder1.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 1)
renderEncoder1.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder1.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
}
このコードを実行してMetal System Traceで確認します。
描画は4.9FPS程度となりました。リフレッシュにかかる時間は平均すると201msでした。
レンダーコマンドエンコーダーをマージすることとでパフォーマンスが上がっていることがわかります。
コマンドは1つとして表示されました。レンダーパイプラインステートを切り替えても1つのコマンドとして認識されるんですね。
結論
レンダーコマンドエンコーダーをマージすることで処理が劇的に早くなり、ベストプラクティスのとおりになることがわかりました。
今回は検証のためにあえてコマンドエンコーダー同士がマージできるような依存関係にしてみましたが、実際にコードを書いていて、このような関係を見つけるのは難しそうです。ただ、うまくマージできると大きな効果が期待できますね。
最後に
今回のコードはGPUパワーを相当使うせいか、実行したまま放置していたらiPhoneがかなり熱くなっていました。同じ処理を試す場合は注意下さい😅
iOSを使った3D処理やAR、ML、音声処理などの作品やサンプル、技術情報を発信しています。
作品ができたらTwitterで発信していきますのでフォローをお願いします🙏
Twitterは作品や記事のリンクを貼っています。
https://twitter.com/jugemjugemjugem
Qiitaは、iOS開発、とくにARや機械学習、グラフィックス処理、音声処理について発信しています。
https://qiita.com/TokyoYoshida
Noteでは、連載記事を書いています。
https://note.com/tokyoyoshida
Zennは機械学習が多めです。
https://zenn.dev/tokyoyoshida