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


こんにちは。[ogukei](https://qiita.com/ogukei)です。社内ではiOS版ニコニコ生放送アプリを開発しています。

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

https://blog.nicovideo.jp/niconews/159982.html

![ios-pip.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/a50f7ba5-e313-8ffc-043e-eab2849bf225.jpeg)


## PiP機能とは

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/117945b0-d99a-7eb2-08d8-f8c1aef656d2.png" width="300"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/e2905cee-0c18-72b9-e4ca-7d6e5d4759ce.png" alt="PiP機能とは" width="300">

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

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

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

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

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

`AVPictureInPictureController` が `AVSampleBufferDisplayLayer` をサポートしたことで任意の動画をPiPに表示できるようになりました[^5]。

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

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


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

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

![library.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/84918775-9dd0-150d-4921-57fb851863a0.jpeg)

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


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

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

### 映像に文字列を直接描画してみる
CGContextで映像のCVPixelBufferを描画ターゲットに設定してNSAttributedStringをその上に描画します。

```swift
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の `shouldRasterize`[^9] に着想を得てコメントの文字列をビットマップにキャッシュする方法を採用しました。この方法ではコメントが画面に追加されたときに一度だけ文字列の描画を行ってそれ以降のフレームはそのビットマップを再利用することで高速化を目指します。

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/ae588f4b-2643-4687-aae7-64313963cbde.png)


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

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/1d0bb807-4801-d296-2724-74fc9b273fcd.png)

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

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

![video-fetch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/5341dec0-8cdf-add2-6c15-c8cd1f23433b.png)

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

![swapchain.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/a50fc6e4-39a7-abe1-b233-2783b5e0d9f0.png)

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

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

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

```cpp
#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);
}
```

<!-- 

これなくても分かる

![composite.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/285512f3-5197-985b-4b25-3f617b945dda.jpeg)

-->

### 全部まとめる

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

![ios-pip.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189818/a50f7ba5-e313-8ffc-043e-eab2849bf225.jpeg)

https://user-images.githubusercontent.com/25946200/144731143-03e928a5-bd7a-4d0f-84c4-1d88d0758a3a.mp4

<details>
<summary>全体コードイメージ</summary>
<div>

```swift
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)
    }
}
```
</div>

</details>

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

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

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

[^2]: https://blog.nicovideo.jp/niconews/144810.html
[^3]: https://developer.apple.com/videos/play/wwdc2021/10290/
[^4]: チーム内勉強会でメンバーが開発したデモが実際に動いていて凄かったです
[^5]: https://developer.apple.com/documentation/avkit/avpictureinpicturecontroller/contentsource/3750329-init
[^6]: https://developer.apple.com/videos/play/wwdc2021/10147/
[^7]: https://developer.apple.com/documentation/metal/setting_resource_storage_modes/choosing_a_resource_storage_mode_in_ios_and_tvos
[^8]: おそらく動画の内部ピクセル表現がYCbCrでRGBAへの変換処理が走っているのではないでしょうか
[^9]: https://developer.apple.com/documentation/quartzcore/calayer/1410905-shouldrasterize
[^10]: Core Imageのcomposited(over:)を用いる方法もありますがメモリ管理が難しく見送りました