22
7

More than 1 year has passed since last update.

iOS 15で実現するPiP動画のコメント表示

Last updated at Posted at 2021-12-05

こんにちは。ogukeiです。社内ではiOS版ニコニコ生放送アプリを開発しています。

先々月にiOS版ニコニコ生放送アプリのリリース5.27.0で、ピクチャ・イン・ピクチャ(PiP)機能で視聴時にコメントが流れるようになりました。本機能はiOS 15で初めて使えるようになった新APIを活用しています。今回はそのリリースの背景についてご紹介します。主にコメントを安定した動作で表示するための最適化について触れています。

ios-pip.jpg

PiP機能とは

他アプリを使用しながら小窓で動画を再生できる機能です。iOS版ニコニコ生放送アプリでは昨年プレミアム会員の方を対象に本機能をリリースしました1

しかしリリース当初表示できたのはコメント無しの動画でした。OSが提供するAPIの制限によって基本的には動画に手を加えることができませんでした。動画にコメントが流れるというのはニコニコのサービスとして非常に重要なので何としてもコメント表示を実現したい思いがありました。

PiP機能とは

なぜPiPのコメント表示がそんなに難しいのか

動画の上にコメントを重ねるだけじゃん、と開発当初考えていたのですが、これがめちゃくちゃ難しいです。PiPではOSが提供するAPIでできることが限られているためです。当時基本的にできることは動画ソース元URLをOS標準のプレイヤーに指定するだけでした。つまり動画をクライアントサイドで自由に加工できませんでした。

イメージとしては以下のようなコードです。

// ここで動画ソースURLを指定するだけ
let player = AVPlayer(playerItem: AVPlayerItem(URL: ...))
let playerLayer = AVPlayerLayer(player: player)
let controller = AVPictureInPictureController(playerLayer: playerLayer)

当時よくユーザーの方からもPiPにコメントが欲しいというご意見がありました。なんとかできないか開発チームで試行錯誤を続けていましたが結局これという手は見つかりませんでした。ちなみに試した中で最も有力だったのはHLSのプロキシサーバーをクライアント内部で立ててセグメントファイルを動的に生成する方法でした2。もっとも黒魔術すぎて導入は現実的ではなかったです。

iOS 15の新機能

悪戦苦闘のさなか、突如として悲願の新機能がWWDC2021で公開されました3

AVPictureInPictureControllerAVSampleBufferDisplayLayer をサポートしたことで任意の動画をPiPに表示できるようになりました4

画像を表す CMSampleBuffer を連続的にAVSampleBufferDisplayLayer に与えると動画が再生されます。CMSampleBuffer が持つ画像は自由に加工できます。

let displayLayer = AVSampleBufferDisplayLayer()
let controller = AVPictureInPictureController(contentSource: .init(sampleBufferDisplayLayer: displayLayer))
// 任意の画像を渡せる!
displayLayer.enqueue(sampleBuffer)

コメントの描画

さてここまで来れば後は実装だけになります。まずコメントの描画に関する実装についてご紹介したいと思います。

ニコニコ生放送アプリではどうやってコメントを描画しているのかというと社内にコメント描画ライブラリがありそれを用いています。そのライブラリは社内ではCommeDawara(こめだわら)と呼ばれています。先人たちによって作成されたこのライブラリはコメントの表示タイミングに合わせてコメントの位置を調整し適切にコメントを描画してくれます。PiPでもそのライブラリを用いることでアプリ内UIと同様の一貫性のあるコメント描画を実現できます。

library.jpg

次にPiPでコメントを描画する上で重要なことはリアルタイム処理の動作パフォーマンスです。例えば最新のiPadではProMotionディスプレイに対応しており120Hzのリフレッシュレートで画面が更新されることがあります。つまり動画1フレームの画像にコメントを合成する処理は 8.3ms 以内に完了しなければなりません。処理が遅いと動画やコメントがカクついて見えてしまいます。いかにスムーズなリアルタイム処理を実現するか工夫が必要でした。実装を踏まえていくつかその取り組みをご紹介します。

文字列の描画

CommeDawaraからは現在の画面上に存在すべきコメントの文字列とサイズ、位置が得られます。これらの情報を用いてコメントの文字列を描画していきます。

struct Comment {
    let bounds: CGRect
    let text: NSAttributedString
}
let comments: [Comment] = commeDawaraRenderer.comments(at: time)

映像に文字列を直接描画してみる

CGContextで映像のCVPixelBufferを描画ターゲットに設定してNSAttributedStringをその上に描画します。

let context = CGContext(data: videoPixelBuffer, width: width, height: height, ...)
UIGraphicsPushContext(context)
for comment in comments {
    comment.text.draw(at: comment.bounds.origin)
}
UIGraphicsPopContext(context)

しかし実行時間を計測したところこの方法では問題があることが分かりました。文字列を1つ描画するのにおよそ 1.0ms±0.5ms かかります。特に文字列に影が付いているせいか処理時間がかかるようでした。番組によっては数十個のコメントが同時に画面に表示されるため容易に1フレームで処理できる時間を超えてしまいます。

文字列の描画結果をキャッシュする

そこでCore Animationの shouldRasterize5 に着想を得てコメントの文字列をビットマップにキャッシュする方法を採用しました。この方法ではコメントが画面に追加されたときに一度だけ文字列の描画を行ってそれ以降のフレームはそのビットマップを再利用することで高速化を目指します。

func makeCommentTextPixelBuffer(string: NSAttributedString) -> CVPixelBuffer {
    let pixelBuffer = CVPixelBufferCreate(...)
    let context = CGContext(data: pixelBuffer, ...)
    UIGraphicsPushContext(context)
    context.saveGState()
    context.translateBy(x: 0, y: CGFloat(height))
    context.scaleBy(x: 1.0, y: -1.0)
    string.draw(at: .zero)
    context.restoreGState()
    UIGraphicsPopContext(context)
    return pixelBuffer
}

以下のようなビットマップが得られます。コメント自体は描画できました。次は動画とコメントを合成するステップに移ります。
text.png

動画とコメントの合成

iOSで動きのある描画を行うにはCore Animationを用いる方法が代表的です。画面更新のタイミングを取得できるタイマーの役割を持っている CADisplayLink を用いて毎フレーム処理を行います。

func createDisplayLink() {
    let displayLink = CADisplayLink(target: self,
                                    selector: #selector(step))
    displayLink.add(to: .current, forMode: .defaultRunLoopMode)
}

func step(displayLink: CADisplayLink) {
    // ここに毎フレームの処理
}

毎フレームの処理を step メソッドに記述します。ここで次の画面更新(VSync)の時刻を displayLink.targetTimestamp で取得できます6。次のVSyncのタイミングに間に合うように映像とコメントを合成するレンダリングを行います。例えばリフレッシュレートが120Hzの場合以下の図のようになります。

vsync.png

動画フレームの非同期取得

動画フレームは AVPlayerItemVideoOutput#copyPixelBuffer(forItemTime:) で取得できます。処理速度を計測してみるとおよそ 8ms±3ms でした。動画フレームのコピーにかかるコストが大きいことが分かります7

画面更新ごとに動画フレームを取得すると8.3msのbudgetを超えてしまいます。そこで可能な限りの頻度で非同期に動画フレームを取得します。動画は60FPS以下のものがほとんどです。画面ほど頻繁に更新する必要はそうそうありません。

video-fetch.png

フレームバッファ

動画とコメントを合成するためには合成した結果を格納するメモリが必要です。しかし1280x720などの大きな画像を画面更新ごとに毎回メモリ確保して破棄するのは効率があまりよくありません。そのため必要なだけバッファをあらかじめメモリ確保しておき再利用することで最適化を図ります。一般的にダブルバッファリングと呼ばれる手法を参考にしています。以下の図に例えばフレームバッファが3つあったときの再利用の流れを示します。

swapchain.png

Metalを用いた合成処理

最後にあらかじめ描画されたコメントのビットマップを動画フレームと合成します。合成処理には速度を重視してMetalを採用しました8。また負荷をGPUに分散させて文字列描画のためのCPUリソースをできるだけ確保するためでもあります。

合成の際 CVMetalTextureCacheCreateTextureFromImage を用いることでMetalからCVPixelBufferを操作できます。iOSではUnified Memory ModelがサポートされておりCPUとGPUでメモリを共有しています9。両者間でデータのコピーが不要になるケースがあります。

描画されたコメントは半透明ビットマップに保存されています。アルファブレンドによってコメント付き画像を生成します。これにはMetal Compute Pipelineを用いました。

#include <metal_stdlib>
using namespace metal;

// @see https://en.wikipedia.org/wiki/Alpha_compositing
kernel void sourceOver(texture2d<half, access::read> inputs [[texture(0)]],
                       texture2d<half, access::read_write> outputs [[texture(1)]],
                       constant int4& offset [[buffer(0)]],
                       uint2 gid [[thread_position_in_grid]])
{
    // ...
    int2 target = gid.xy + offset.xy;
    half4 source = inputs.read(gid);
    half4 destination = outputs.read(target);
    half3 color = source.rgb + destination.rgb * (1.0 - source.a);
    outputs.write(half4(color, 1.0), target);
}

全部まとめる

動きました!端末によっては全くカクつかないことはないのですが、コメントの多い番組でもヌルヌル安定した動作を実現できました。

ios-pip.jpg


全体コードイメージ
func step(displaylink: CADisplayLink) {
    let targetTimestamp = displayLink.targetTimestamp
    backgroundQueue.async {
        // 映像フレームの取得
        let itemTime = videoOutput.itemTime(forHostTime: targetTimestamp)
        if videoOutput.hasNewPixelBuffer(forItemTime: itemTime) {
            self.videoPixelBuffer = videoOutput.copyPixelBuffer(forItemTime: itemTime)
        }
    }
    backgroundQueue.async {
        // フレームバッファの取得
        let frameBuffer = frameBufferPool.acquireCurrentFrame()
        // 合成
        let sampleBuffer = renderer.render(frameBuffer: frameBuffer,
                                           video: self.videoPixelBuffer,
                                           comments: self.comments,
                                           displayAt: targetTimestamp)
        displayLayer.enqueue(sampleBuffer)
    }
}

// コメントの追加
func addComment(comment: Comment) {
    commentRenderingBackgroundQueue.async {
        let pixelBuffer = makeCommentTextPixelBuffer(string: comment.text)
        self.comments += CommentCache(comment, pixelBuffer)
    }
}

リリース

本機能は複数人(2人)で開発する少し特殊な体制を取りました。描画部分とアプリ実装部分にタスクを分割しました。おかげでiOS 15の新APIが発表されてから迅速に実装を終えることができました。

当初iOS 15のリリースと同時に本機能をリリースする心づもりでした。もっともXcode 13へのアップデートに伴う動作確認の準備の見通しが甘くリリースまでに時間がかかってしまいました。しかし新しいAPIを活用した機能をここまでスピーディに開発できたことやその環境があることに心が躍る思いです。

まとめ

iOS 15のPiPで動画にコメントが流れるようになりました。様々な最適化を施しました。優れた動作パフォーマンスは安定した視聴に欠かせないものです。PiPにとどまらず、さらにスムーズな視聴ができる環境をつくるべく改善を重ねていきます。


  1. https://blog.nicovideo.jp/niconews/144810.html 

  2. チーム内勉強会でメンバーが開発したデモが実際に動いていて凄かったです 

  3. https://developer.apple.com/videos/play/wwdc2021/10290/ 

  4. https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller/contentsource/3750329-init 

  5. https://developer.apple.com/documentation/quartzcore/calayer/1410905-shouldrasterize 

  6. https://developer.apple.com/videos/play/wwdc2021/10147/ 

  7. おそらく動画の内部ピクセル表現がYCbCrでRGBAへの変換処理が走っているのではないでしょうか 

  8. Core Imageのcomposited(over:)を用いる方法もありますがメモリ管理が難しく見送りました 

  9. https://developer.apple.com/documentation/metal/setting_resource_storage_modes/choosing_a_resource_storage_mode_in_ios_and_tvos 

22
7
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
7