Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

配信コメントバー 〜 PiP 描画パフォーマンスとの向き合い方

こんにちは、クライアントエンジニアのちぎら(@_naru_jpn)です。先日 配信コメントバー 〜 iOS15 で実現する新しい PiP 体験 という記事を公開しました。ミラティブアプリ上では Unity を利用して 3D のアバターであるエモモを描画しており、配信コメントバーの描画コストが大きすぎるとエモモの動きや見た目に影響しますし、画面のスクロール操作にも支障が生じます。今回は、複数の描画方法についての計算コストの計測結果と共に、配信コメントバーの描画をいかに最適化したかについて書きたいと思います。

描画の基本的な方針

f:id:naru-jpn:20211129182916p:plain
配信コメントバーの全体像

前回の記事AVSampleBufferDisplayLayerCMSampleBuffer に言及しました。CMSampleBuffer に表示する内容を描画する方法は、たくさんの種類があります。 画像をそのまま書き込んだり、ピクセルの値を直接いじったり、描画を Metal で行うといったこともできます。1 配信コメントバーではメンテナンスコストや属人性を考えて、個々の要素の描画内容の管理やレイアウトは UIKit に任せるという選択肢を採りました。つまり、UIView 上に描画される内容を CMSampleBuffer に書き込むというシンプルな方法です。

はじめに試した方法では、描画にコストがかかりすぎることが分かりましたが、いくつかの改善を経て、最低限実用に耐えるくらいのパフォーマンスを実現することができました。

3つの描画方法と描画コスト

改善の途中経過である2つのステップ、最適化をした描画方法とそれらの描画コストについて記載します。

アプリ上で 60fps を保つ場合、1回の描画あたりメインスレッドが約 16.7ms 以上の時間をかけてしまうと画面が固まったり、ユーザー操作を受け付けなくなったりなどが発生します。アプリ上では他の処理もしていますので、より短い時間で描画処理を終えなければなりません。

アプリの動作への影響を測るために、描画処理にどれくらいの時間がかかっているかを把握することは大切です。処理時間の計測には このようなプログラム を利用して、描画にかかった時間の平均や、ばらつきを見る為の標準偏差を計算しました。2

以下に登場する変数 view は描画対象のビュー、renderContextCIContext クラスのインスタンスです。3

1. drawHierarchy 方式

UIView の描画内容を UIImage として取得する際のおなじみの手法です。

UIGraphicsBeginImageContextWithOptions(renderingSize, false, 0.0)
// 描画
view.drawHierarchy(in: .init(origin: .zero, size: renderingSize), afterScreenUpdates: true)
let image: UIImage? = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
if let cgImage = image?.cgImage {
  renderContext.render(CIImage(cgImage: cgImage), to: pixelBuffer)
}

この方法で 100 回の描画処理を行なった際の平均処理時間は 49.53 [ms] でした。ここまで遅いと PiP への描画のタイミングでアプリの挙動に明確に影響が出ます。ユーザーの目から見ても PiP の表示時にアプリに悪影響があるのは明らかであり、機能の公開をかなり躊躇するレベルです。

2. layer.render 方式

UIGraphics... を使用していないので上の方法とだいぶ異なって見えますが、パフォーマンスに大きく影響を与える処理は描画に layer.render を用いている部分のみです。実際に UIGraphics... 経由で CGContext を取得した場合も、このケースとほぼ同じパフォーマンスが得られます。

let imageRenderer = UIGraphicsImageRenderer(size: renderingSize)
let image = imageRenderer.image { context in
  // 描画
  view.layer.render(in: context.cgContext)
}
if let cgImage = image.cgImage {
  renderContext.render(CIImage(cgImage: cgImage), to: pixelBuffer)
}

この方法で 100 回の描画処理を行なった際の平均処理時間は 27.81 [ms] でした。1. の方法と比べるとマシにはなりましたが、まだ求めるパフォーマンスには程遠いように感じます。

3. 階層分割方式

描画対象である view 上には複数の子ビューが配置されており、UIImageView などでエモモの画像や背景を描画していたり、UIStackView で配置を管理していたりしています。エモモの画像や背景は毎回描画していますが、これらの描画内容が変わる頻度は稀です。配置されているアイコン画像に至っては、表示開始から表示終了まで変更されることはありません。一方で、コメントの文章や配信時間などは頻繁に内容が変更されます。

そこで、「描画内容がたまにしか変わらない階層」と「描画内容が頻繁に変わる階層」に分割し、描画内容がたまにしか変わらない部分については、あらかじめ一枚の画像に加工しておくという方法を考えました。

f:id:naru-jpn:20211130155219p:plain
描画内容がたまにしか変わらない階層

f:id:naru-jpn:20211130155256p:plain
描画内容が頻繁に変わる階層

実際には描画内容が頻繁に変わる階層にはテキストしか含まれていませんので、あらかじめ用意された一枚絵の上に個別のテキストを描画するといった処理になっています。4 描画内容がたまにしか変わらない階層は、setNeedsDisplay 的なノリで内容が更新されて再描画フラグが立っている場合のみ内容が更新されるようになっています。

  let imageRenderer = UIGraphicsImageRenderer(size: Self.renderingSize)
  let image = imageRenderer.image { context in
    // 描画内容がたまにしか変わらない階層の描画
    renderStableContents(in: context.cgContext)
    // 描画内容が頻繁に変わる階層の描画
    renderRedrawnContents(on: view, in: context.cgContext)
  }
  if let cgImage = image.cgImage {
    renderContext.render(CIImage(cgImage: cgImage), to: pixelBuffer)
  }

// ...

/// 描画内容がたまにしか変わらない階層の描画
private func renderStableContents(in context: CGContext) {
  // stableContentsImage はあらかじめ作成しておいた1枚絵
  if let image = stableContentsImage.cgImage {
    context.translateBy(x: 0.0, y: renderingSize.height)
    context.scaleBy(x: 1.0, y: -1.0)
    context.draw(image, in: .init(origin: .zero, size: renderingSize))
    context.scaleBy(x: 1.0, y: -1.0)
    context.translateBy(x: 0.0, y: -renderingSize.height)
  }
}

/// 描画内容が頻繁に変わる階層の描画
private func renderRedrawnContents(on view: UIView, in context: CGContext) {
  if view.tag == DrawingPolicy.redrawn.tag {
    view.layer.draw(in: context)
  }
  for subview in view.subviews {
    let origin = subview.frame.origin
    // layer.draw(in: context) は原点を起点に描画されるので、ハンコを押すときに下の紙だけ動かす要領で位置をずらして描画をしている。
    context.translateBy(x: origin.x, y: origin.y)
    renderRedrawnContents(on: subview, in: context)
    context.translateBy(x: -origin.x, y: -origin.y)
  }
}

この方法で 100 回の描画処理を行なった際の平均処理時間は 4.88 [ms] でした。まだ 5 ミリ秒も食うんか、という気持ちもありますが、現実的な値に近づいたのではないでしょうか。このくらいになると古めの端末でもエモモの違和感やスクロール操作への悪影響は感知されなくなります。

各方法の処理時間の比較

最後にそれぞれの方式の平均処理時間(±1標準偏差) を並べて記載します。いくつかの改善によって、初期の状態から10分の1程度の処理時間にすることができました。処理時間が短くなるにつれ平均に対するばらつきの割合は大きくなっていますが、アプリ環境に依存する回避できない一定の処理時間のブレがあるように見えます。この結果は Unity 上でエモモが動作している環境で調べているので、アプリ上で何もしていない場合は計測結果が変わってくるかも知れません。

drawHierarchy 方式 layer.render 方式 階層分割方式
49.52 ± 4.79 [ms] 27.81 ± 3.03 [ms] 4.88 ± 1.16 [ms]

まとめ

配信コメントバーの描画の工夫と改善結果について書きました。今回の最適化については希望のある結果になりましたが、ストレスのない実装をする為には、作ろうと思っている機能が最適化できる構造であるかも大切になると思います。また、表示したい内容によって描画の頻度や最適化の方法も工夫していく必要があります。付属的な機能であったとしてもよりよい体験を作っていけるように、技術的に邁進していきたいですね。

We are hiring!

ミラティブでは一緒にアプリを作ってくれる iOS エンジニアを募集しています!
少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。 www.mirrativ.co.jp

面談まではしたくないが雑談くらいならしたい、という方はこちらから!

meety.net

エンジニア向け会社紹介資料

speakerdeck.com


  1. 検証段階では、ピクセルの RGB をいじり PiP をレインボーに光らせてゲーミングミラティブバーとか言って遊んでいたりしました。Metal での実装は今回は実際には試していませんが MTLTexture へ描画からの CVPixelBuffer に変換とかでいけると思います。

  2. ignores という変数があります。はじめの描画には大抵平均より長い処理時間がかかるのですが、アプリの動作への影響は少ないので、はじめの1回分は計測に含めないという計測をしています。計測に用いた端末は iPhone12mini です。開発時は iPad mini 6 でも試しましたが、こちらの方が少しいいパフォーマンスが出ていました。

  3. CIContext の作成コストは馬鹿になりません。Getting the Best Performance にも CIContext は都度作成せずに再利用せよと書いてあります。処理時間のかかる場所がピンポイントで特定できなかったので詳細な数値は出しづらいのですが、1回の描画あたり 10ms くらいの影響があると見てよいと思います。

  4. コード中に DrawingPolicy.redrawn といった記述がありますが、都度描画する必要がある要素なのかどうか、というのは実装上はビューの tag の値で判断しています。たまたまここに含まれるビューは UILabel のみですが、そうでない場合は描画処理を見直す必要があるでしょう。