【iOS】Metal Best Practicesの解説（10）レンダーコマンドエンコーダー

[Metal Best Practices](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/index.html#//apple_ref/doc/uid/TP40016642-CH27-SW1)は、iOS／MacOS／tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。

本稿では、何回かに分けてこのガイドを読み解き、コード上での実験を交えて解説していきます。
読んでそのまま理解できそうなところは飛ばしますので、原文を読みながら原文のガイドとしてご利用下さい。
また、iOSの記事なので他のOS(MacOS, tvOS)についての記載は割愛します。

他の記事の一覧は、初回記事よりご覧下さい。

https://qiita.com/TokyoYoshida/items/521619d6d8dd8d8ef9e4

### [Render Command Encoders (iOS and tvOS)(レンダーコマンドエンコーダー)](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/RenderCommandEncoders.html#//apple_ref/doc/uid/TP40016642-CH30-SW1)

ベストプラクティス：可能な場合は、レンダーコマンドエンコーダーをマージします。

レンダーコマンドエンコーダーをマージできるならマージして不要なものを減らすと、パフォーマンスが向上する、という話です。

レンダーコマンドエンコーダーをマージできるかどうかは依存関係によります。大まかに書くと次のようになります。

- 同じフレームの中で、あるレンダーコマンドエンコーダーの結果をそのまま利用しているレンダーコマンドエンコーダーがある場合は、それらをマージできる

- あるレンダーコマンドエンコーダーの結果にサンプリングの依存関係がある場合はそれらをマージできない

本家のページには、マージするための条件が細かく記載されていましたが、概ね上の条件で考え、細かい条件を詰めるときに本家のページを参照すると良いかもしれません。

### コードで確認してみる

コードで確認してみます。
今回はこちらのサンプルコードを改変して検証してみます。

https://github.com/TokyoYoshida/MetalExamples/tree/main/MetalExamples/Samples/MultiPassRendering

**仕上がりのイメージ**
しあがりは、このように赤い図形と青い図形が重なるようになります。

![IMG_04DB019AD2C2-1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/aa09abde-6e80-9742-3e6c-d6a24a70f9ad.jpeg)

**その１：レンダーコマンドエンコーダーを複数作成した場合**

まずは、レンダーコマンドエンコーダーを複数作成した場合を作っていきます。

検証しやすいように、シェーダーには無駄なループを入れて処理を重くします。

```c++:SimpleShapeShader.metal
// 赤い図形を描くシェーダー
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);
}
```

次に、レンダーコマンドエンコーダーを２つ用意します。

```swift:MultiPassRenderingMetalView
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)!

    // 赤い図形を描くシェーダーを指定した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
    // ２つ目のコマンドエンコーダーは、.loadアクションにすることで、赤い図形の上に青い図形を描くことができる
    renderPassDescriptor2.colorAttachments[0].loadAction = .load

    // ２つ目のコマンドエンコーダー
    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を使って処理の状況を確認してみましょう。
このツールの使い方はこちらをご覧下さい。

https://qiita.com/TokyoYoshida/items/c904e79daf0a1a72375a

描画は4FPS程度となりました。リフレッシュにかかる時間は平均すると248msでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/e3b4ca42-ec32-1b88-6faf-d2e323dec76f.png)

処理時間のほとんどはフラグメントシェーダによるものだということがわかります。
２つのフラグメントシェーダーは別々のレンダーコマンドエンコーダーでエンコードしたので、２つのコマンドとして表示されていました。

**その２：レンダーコマンドエンコーダーをマージして１つにした場合**

次に、レンダーコマンドエンコーダーをマージして１つにしてみます。

```swift:MultiPassRenderingMetalView
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でした。
レンダーコマンドエンコーダーをマージすることとでパフォーマンスが上がっていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116010/6f4cf7d7-e186-406b-74ce-00320e585713.png)

コマンドは１つとして表示されました。レンダーパイプラインステートを切り替えても１つのコマンドとして認識されるんですね。

### 結論

レンダーコマンドエンコーダーをマージすることで処理が劇的に早くなり、ベストプラクティスのとおりになることがわかりました。

今回は検証のためにあえてコマンドエンコーダー同士がマージできるような依存関係にしてみましたが、実際にコードを書いていて、このような関係を見つけるのは難しそうです。ただ、うまくマージできると大きな効果が期待できますね。

### 最後に

今回のコードは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