9699 文字
24 分
日本語

弾幕はどの時計に従うべきか:BiliKitの弾幕同期を書き直した記録

BiliKitの弾幕について書いた前回の記事では、弾幕はプレイヤーの時間スナップショットに従って進み、前方へシークすると画面をクリアして新しい位置から続けていた。飛ばした区間の弾幕は補完しない。

この時間モデルは、その後置き換えた。きっかけは、シークした後に画面にあるはずの弾幕が出てこないことだった。たとえば1:00へ飛ぶと、0:55から流れ始めて今は画面の中央にいるはずの弾幕が、一つも出ない。

この問題は10月12日から10月23日まで直し続け、PRは十数本、根本のモデルも何度か変わった。時間の大半は、弾幕がどの時間に従うべきかを確かめることに使った。コードを書くこと自体にかかった時間は多くない。

今の実装では、各弾幕が今どこにあるかと、今どの弾幕が画面に出ているべきかを、同じ時間から計算している。その時間はプレイヤー自身の時計であるAVPlayerItem.timebaseからそのまま取り、制限を一つだけ加えた。順方向の再生中は、後ろへ戻らせない。弾幕側でも、ユーザーがシークしているかどうかをしきい値で推測することはなくなった。

本文の数値はすべて手元のマシン上のプローブで測ったものである(macOS 27、Apple Silicon)。有線または内蔵出力とBluetoothのAirPodsでは結果が大きく違うので、関係する箇所では分けて書く。これらの変更はまだ正式版に入っておらず、最新版は今も10月12日にリリースした1.3.2である。

弾幕スタックといくつかの概念#

BiliKitはmacOSネイティブのBilibiliクライアントで、Swift 6、SwiftUI + AppKit、AVPlayerで書いている。前回の記事から描画方法も変わった。各弾幕は今はCALayerで、中身はCore Textで事前にラスタライズしたテクスチャであり、CATextLayerはもう使っていない。スクロール弾幕の動きはCABasicAnimationで、beginTimeはその弾幕の出現時刻になる。弾幕はBilibiliのAPIから6分単位のセグメントで取得する。

パイプラインは三層に分かれている。レイアウト層は各弾幕にレーン、つまり画面上のどの行を流れるかを割り当てる。プレゼンテーション層は、今どの弾幕を画面に出すかと、テクスチャをいつ用意するかを決める。レンダリング層はCA layerとテクスチャを管理する。

以下の概念は本文で何度も出てくる。

  • timebase:AVPlayerItem.timebaseはプレイヤー内部のメディア時計である。一本の直線として考えられる。あるホスト時刻(システム時計上の一点)でメディア時間がある値を取り、そこから再生速度で進んでいく。一時停止中は速度が0になり、直線は水平になる。シークしたときや、システムが内部で時計を設定し直したときには、直線全体がずれて、メディア時間が跳ぶ。
  • スナップショット:メインスレッド上に1/30秒ごとに発火する定期オブザーバーがあり、そのたびに現在の再生位置を読む。これがスナップショットである。最初の弾幕スケジューリングはスナップショットで進んでいた。
  • 動きと表示集合:弾幕は二つの問いに答える必要がある。一つは、各弾幕が今画面上のどこにあるか。これはアニメーションと、それを駆動する時計で決まる。もう一つは、今どの弾幕が画面に出ているべきか。本文ではこの弾幕の集まりを表示集合と呼ぶ。

最初の実装は実行時のスケジューリングだった。メインスレッドがスナップショットに従ってカーソルを進め、出現時刻になった弾幕を一つずつ画面に出していた。

シーク後の補完が増えていった#

最初の変更は、シークが着地したあと、着地点から短い時間窓を再生し直して、今も流れているはずの弾幕を画面に出すものだった。

マージした後、別の問題が次々に出てきた。チラつき、着地点付近の小さなぶれ、一時停止中の残像、一時停止中にシークしてから再生するときの一瞬のチラつきである。danmaku-seek-catchupというブランチで一つずつ直したが、直すたびにスケジューラーにヒューリスティックが一つ増えた。0.5秒の後退許容、2.5秒の超過上限、着地点が安定したかの判定、そしてドラッグが150ms止まって初めて着地とみなすルールである。

これらのルールはどれも、位置の変化からユーザーがシークしたかどうかを推測していた。たとえば後退許容とは、位置の後退が0.5秒未満ならシークとみなさないということである。しきい値の一つ一つが、ユーザーの意図についての推測に対応していた。ルールは増える一方で、このブランチは結局マージしなかった。それ以来、あるルールが継ぎはぎかどうかは、主に別のルールの副作用を直しているかどうかで判断している。

その後、システムが明確な時計イベントを提供していることに気づいた。timebaseはTimeJumped(時間の跳躍)とEffectiveRateChanged(実効速度の変化)の二つの通知を出す。そこで再生層がこの二つを購読し、「シーク中」と「ある位置に着地した」という二つの状態を外に公開するようにした。弾幕スケジューラーは着地だけを扱い、画面をクリアしてから着地点で一度だけ補完する。それまでのしきい値はすべて削除した。

この方式はマージしたが、一時停止中にシークしてから再生すると一瞬チラつく問題が残り、当時は原因を特定できなかった。もう一つの問題として、TimeJumpedは低レベルの時計イベントで、システムが内部で時計を設定し直したときにも出る。ユーザーがシークしたかったことを意味するわけではなく、速度だけでは確実に区別できない。このとき決めた原則は、次の段階で解決できる問題については、この方式にこれ以上継ぎはぎを加えないということだった。

弾幕をメディア時間だけに依存させる#

次の段階では考え方を変えた。ある時刻に画面にどの弾幕があり、それぞれがどこにあるかをメディア時間tだけから計算できれば、ユーザーがどうシークしても、ドラッグしても、速度を変えても、画面は今のtだけで決まる。そうなればシークを識別する必要はない。この考え方に沿って、三つの変更を加えた。

  • 動きはメディア時計に任せる。弾幕のレイヤーをAVSynchronizedLayerの下に置く。このレイヤーは、子レイヤーのアニメーションをプレイヤーの時間に従わせるので、一時停止、速度変更、シークはすべてシステムが処理する。
  • レーンは決定的なレイアウトにする。弾幕を(time, id)でオフラインに並べ、各弾幕のレーンを事前に計算しておく。同じ時刻からは常に同じ画面が得られる。
  • 表示集合は一つの時間窓だけを見る。時刻tにまだ画面に残っている弾幕は、出現時刻がt − 最長表示時間より前ではありえない。そこに先読みを加えて、まもなく出る弾幕を前もって準備する。したがって毎回、[t − 最長表示時間, t + 先読み]の区間にある弾幕だけを追加・削除すればよい(reconcile)。

ローカルのloopback上で配信したHLSで試したところ、シーク、ドラッグ、速度変更、バッファリング、再生終了のどれでもずれはなく、1000個のlayerを同時に置いても約9msだった。

この段階に入る前に、オープンソースの実装がどうしているかを調べた。表の「実行時の貪欲割り当て」は弾幕が出現した時点で空いているレーンを探す方式、「事前計算レイアウト」はすべての弾幕の位置を前もって決めておく方式を指す。

分類代表例シーク後に画面が埋まるかリサイズ時
実行時の貪欲割り当て(ネイティブ)BarrageRenderer、DanmakuKit、DFM、IINAプラグインいいえ。シーク時にクリアし、補完しない新しい幅でxを再計算し、yは変えない。重なることがある
実行時の貪欲割り当て(Web)weizhenye/Danmaku、DPlayer、ArtPlayer、danmu.js、CCLいいえクリアせず、yも並べ直さない
事前計算レイアウトASS/libass、uosc_danmaku、niconicommentsはい仮想の1920×1080ステージ上でレイアウトし、描画側で拡大縮小する

シーク後に画面が埋まるのは、事前計算レイアウトの系統だけである。これらは位置がもともと時間の関数になっているので、どの時刻へ飛んでも、その時刻の画面を直接計算できる。オープンソースの弾幕プロジェクトでAVSynchronizedLayerを使っているものは見当たらなかった。BilibiliのWeb版とアプリも試したところ、どちらもシーク後すぐに画面が埋まった。前もってレイアウトしておく方向は、公式の体験とも一致している。

この段階にも固有の問題があった。最初のレイアウトは物理ピクセル単位で計算していたので、スクロールの所要時間もレーン数も映像領域のサイズに依存し、ウィンドウが変わるたびにレイアウト全体をやり直す必要があった。そこからいくつかの補正が派生した。セグメントをまたいで引き継ぐレイアウトのシード、遅れて届いたセグメントの再レイアウト、フルスクリーンや拡大縮小や設定変更時の全面的な再レイアウト、そして約200msのクロスフェードである。クロスフェードはマージ前に取り除いた。

ビューポート#

サイズの問題には「仮想ステージ」の考え方を借りたが、フォントサイズはウィンドウに合わせて変えないことにした。レイアウトは現在の画面の最大サイズで一度だけ計算し、ウィンドウはそのレイアウトのどの部分が見えるかだけを決めるビューポートとして扱う。右端から左へどれだけの幅が見えるか、上から何本のレーンが見えるか、である。スクロール弾幕のコンテナは右端に、上部と下部の弾幕のコンテナはそれぞれ中央と下端に固定する。リサイズしても再レイアウトはせず、フォントサイズも変わらない。

これが成り立つ前提は二つある。一つは、弾幕の速度が一定で映像領域の幅に依存しないこと。そのため、ウィンドウが広くても狭くても、ある弾幕のある時刻における右端からの距離は同じになる。もう一つは、レーンを上から順に割り当てていること。最初のk本のレーンの結果は総レーン数に影響されず、ウィンドウが低くなっても下のレーンが何本か見えなくなるだけで済む。

代償として、ウィンドウが大きくなった瞬間、それまでビューポートの外にあった部分の弾幕がまだ「届いて」いないことがある。これは既知の過渡状態である。フォントサイズを拡大縮小に追従させるかどうかは、今後「画面の拡大縮小に追従」というスイッチとして、ビューポートの上に重ねる予定である。

シークバーをドラッグしている間、弾幕は完全にメディア時間に従う。遠くまでドラッグすれば画面は自然に空になり、着地した後に画面全体が入れ替わる。表示集合を再計算するきっかけも変えた。以前は「不連続カウンター」、つまり時間が不連続になるたびに一つ増える数を見ていたが、今は現在位置が、すでにレイアウトした時間窓の中にあるかどうかだけを見ている。

一時停止で弾幕が後ろへ戻る#

AVSynchronizedLayerで駆動するようにしてから、一時停止したときにスクロール弾幕が4–10ptほど後ろへ戻るようになった。

Instruments(Time Profiler + System Trace)で一時停止の瞬間を記録すると、ブロックしている箇所が二つ見つかった。

一つ目はLive Text(一時停止中の画面から文字を認識するシステムの機能)に関係していた。pause()の内部でrateのKVOコールバックが呼ばれ、AVKitの画像解析がそのコールバックの中でメインスレッドからcurrentTimeを読む。このときプレイヤーは音声の一時停止のハンドシェイクのためにロックを持っていて、メインスレッドはそのぶん約20ms余計に待たされていた。allowsVideoFrameAnalysisをオフにすると、一時停止時のブロックは約55msから約35msに減った。弾幕のレイヤーは映像の上に重なっているので、一時停止したフレームから文字を選択する機能はもともと使えない。

二つ目はAudioQueuePauseのAwaitIOCycle、つまり現在の音声I/Oの一巡が終わるのを待つ処理で、これもロックを持っていた。

AVSynchronizedLayerがいつ止まるかはメインスレッドに左右される。メインスレッドがブロックされている間も弾幕のアニメーションは進み続け、レイヤーが実際に止まった時点で、弾幕は一時停止した瞬間の位置へ戻る。それが後ろへ戻るように見えていた。

独自のクロック層#

問題はメインスレッドにあったので、メインスレッドを通さず、バックグラウンドのシリアルキューでtimebaseに追従するクロック層を書いた。

timebaseの通知は、MediaToolbox(AVFoundationの下にあるメディアフレームワーク)のロックを持ったスレッドから同期的に送られてくる。そのため、コールバックの中ではAVPlayerやAVPlayerItemのAPIを一切呼んではいけない。たとえばコールバックでcurrentTimeを読むと、同じロックを取りに行って自分でデッドロックする。コールバックは、イベントを専用のシリアルキューへ渡すことだけをする。

キューの中で、クロック層はCMSyncGetRelativeRateAndAnchorTimeを呼び、ホスト時計に対するtimebaseの速度と、一組のアンカーを得る。ホスト時刻がHaのとき、メディア時間はTaである。実測では返ってくるHaは常に0だったので、アンカーを現在時刻nowまでずらしてからレイヤーに書き込む必要がある。

Core Animationでは、レイヤーのローカル時間は(親の時間 − beginTime)× speed + timeOffsetになる。次のように書けば、レイヤーのローカル時間はちょうど現在のメディア時間に等しくなり、そこからrateで進む。

beginTime = now
timeOffset = Ta + (now − Ha) · rate
speed = rate

書き込みには明示的なCATransactionを使い、暗黙のアニメーションは無効にする。動画を切り替えるときは、クロック層が新しいitemのtimebaseに付け替え、generation(付け替えるたびに一つ増える番号)を使って古いitemのコールバックを捨てる。古いtimebaseはitemを切り替えた後も通知を送り続け、有線出力では最大+33ms、Bluetoothでは+103msまで届いた。

プローブでは31回の一時停止で一度も後退しなかった。同じ状況で、AVSynchronizedLayerは3.5–7.5pt後退した。

時計のフィルタリングは残さなかった#

クロック層に切り替えると、timebaseの小さな跳躍がそのまま弾幕に表れるようになった。再生を再開すると20–40ms前へ跳び、同時に映像も2フレーム跳ぶ。シーク後の再生開始では約57ms後退する。速度を変えると、約45ms止まってから前へ跳ぶ。

オープンソースの実装の多くは、この程度の小さな跳躍を意図的に無視している。DFMは滑らかに追いつき、CCLとIINAはしきい値を使う。BiliKitでも時計のフィルタを一度書いた。ずれが0.25–0.5秒に達したらその場で合わせ、それより小さければ速度を5%上げるか下げるかして、数百ミリ秒かけて追いつく。

この版でもすぐに特例が出てきた。連続的なフィルタの処理がハイブリッドな状態機械に押し込まれ、一時停止中は追いつくための速度が上限に張り付いたまま誤差だけがたまり続けた(制御理論でいうwindupである)。描画と表示する弾幕の選択がそれぞれ別の時計を使っていたので、二つのモジュールの間で許容誤差を共有する必要もあった。前のヒューリスティックと同じ種類の問題で、このブランチも削除した。

要件を整理し直す#

この時点で、最初の要件を見直した。「弾幕を映像の時間と正確に同期させる」は本当の要件ではなく、AVSynchronizedLayerを使ったことで持ち込まれた手段だった。それまでの継ぎはぎの多くはこの手段の副作用をめぐるもので、前に挙げた「要件」も、ほとんどは具体的なバグから逆算したものだった。

そこで、どう実装しても成り立たなければならない性質として、不変条件をいくつか書き出した。どれも視聴者に見える結果だけを記述している。

不変条件意味
アンカー弾幕の時間は視聴者の時間とほぼ等しい。視聴者の時間は映像の時間を取る。視覚が先に立つからである
視聴者の時間一定の速さで流れ、視聴者の操作によってだけ跳ぶ
画面は時刻だけで決まる同じ時刻に見える弾幕の集合は一意である(セグメントの到着履歴、セグメントをまたぐレイアウトのシード、テクスチャの予算が同じであることが前提)
物体の恒常性画面上の弾幕が理由なく消えたり、再び現れたり、レーンを変えたりしない
読める重ならず、チラつかない
内容を邪魔しない弾幕の問題で映像の足を引っ張らない

次に確かめるべきは、一つ目の「弾幕の時間は視聴者の時間とほぼ等しい」をそのまま成り立たせる完璧な時間が、プレイヤーの中にあるかどうかだった。もしあれば、弾幕はそれに従うだけでよい。

プレイヤーの時計の実測挙動#

これを確かめるために、プローブのプログラムを書いた。AVPlayerViewの設定はBiliKitと完全に同じにしてある。テスト動画は60fpsで、各フレームにバーコードを埋め込み、今どのフレームが画面に出ているかを読み取れるようにした。HLSは二段階のビットレートを持ち、音声は独立したEXT-X-MEDIAにした。ローカルサーバーは帯域を絞ることも、止めることもできる。プログラムはリポジトリのタイムラインアダプタをそのまま流用し、さまざまな状況でtimebase、メインスレッドのスナップショット、画面に出ているフレーム番号、メインスレッドのブロックを同時に記録する。

AVFoundationのドキュメントは、こうした挙動にほとんど触れていない。timebaseがいつ、どれだけ跳ぶか、AVKitがどのスレッドでseekを呼ぶか、KVOがいつ同期的に発火するかは、どれも自分で測るしかない。今回の設計判断は、どれも先に独立したプローブのプログラムでデータを取ってから決めた。

出力デバイス別の数値は次のとおりである。

挙動有線/内蔵出力BluetoothのAirPods
初回再生、シーク後の再生開始、停滞からの回復、item切り替え後の再生開始で、timebaseが音声出力の遅延とほぼ同じだけ後ろへ跳ぶ約−37ms−97…−215ms
一時停止から再開すると、timebaseが前へ跳ぶ+24…+38ms+11…+127ms
再生中にシークすると、スナップショットが先に目標に着き、その後timebaseより先行する約37ms93–184ms
再生中にシークすると、メインスレッドがブロックされる56–72ms260–280ms

一行目の跳躍量は、出力デバイスによっても変わる。つまり、再生を始めるたびにtimebaseはまず少し後ろへ下がり、timebaseに厳密に従う弾幕も一緒に後ろへ戻る。

ほかに観察できたことは次のとおりである。

  • Bluetoothでは、timebaseが通知なしに黙ってアンカーを付け直し、+2…+31msの離散的な跳躍が起きる。誤差は最大±49msになる。通知がないので、弾幕側がすぐに知る方法はない。
  • ビットレートの切り替えの前後でtimebaseは同じままで、直線も変わらず、通知もない。
  • HLSを4倍速で再生すると、短い時間に大量のTimeJumped通知が届き、4秒ごとに6–66回になる。
  • まだバッファされていない区間へドラッグすると、スナップショットは約4msで目標へ跳ぶが、timebaseは0.2–4秒、あるいはそれ以上その場から動かず、両者は数十秒ずれる。このとき処理の一部がスナップショットを、別の一部がtimebaseを見ていると、両者が見る時間は数十秒ずれる。
  • メインスレッドが止まると、スナップショットは生成されなくなる。キューにたまることも、まとめられることもない。
  • 有線出力では、画面に出るフレームは厳密にfloor(timebase × fps)と等しく、映像とtimebaseは一致している。シーク後は、目標のフレームがtimebaseより先に画面に出始め、約93msそのまま止まる。
  • 一時停止の後はメインスレッドが約31ms、速度変更の後は24–42msブロックされる。

音声も跳ぶ#

timebaseの跳躍の多くは音声から来ている。timebaseが推定しているのは音声が実際に再生されている位置で、音声の停止にも開始にも遅延がある。音声が本当に止まった瞬間を取れれば、再開時の前への跳躍をなくせるかもしれないと考え、音声も測った。

結果は半分しか成り立たなかった。一時停止や速度変更のとき、音声はtimebaseが止まってから17–21ms余分に鳴ってから止まり、この部分は安定している。しかし再開時には、音声自体がすでにレンダリング済みの61–76msを飛ばす。システムには使える公開の「音声停止」シグナルもない。kAudioProcessPropertyIsRunningOutputは変化するまでに約7.4秒かかり、リスナーのブロックは一度も呼ばれない。MTAudioProcessingTapはこれらの量を約10倍に増幅し(音声は4096フレーム単位でレンダリングされる)、しかもHLSには取り付けられない。

再開時には、timebase、映像、音声のどれもが跳ぶ。プレイヤーの中に、アンカーとして使える連続した時間はない。

もう一つ確かめたことがある。AVPlayerは速度を変えると音声が途切れる。これはシステム自体の挙動で、Safariでも同じであり、避けるには音声を自分でレンダリングする(AVAudioEngine + TimePitch)しかない。

再生開始をつなぎ合わせる試み#

逆の方向、つまりtimebaseを跳ばせない方法も試した。AVPlayerを継承してrateのsetterをオーバーライドし、再生開始時にsetRate(_:time:atHostTime:)で「どのホスト時刻に、どのメディア時間から再生するか」を指定する。こうしてtimebaseの直線を、一時停止で止まった点にちょうどつなげる。このspikeはmp4ではきれいに動いたが、いくつか問題があった。

  • 判定条件が11個に増え、そのうちいくつかは自分自身の副作用を直していた。
  • 速度変更時の映像の停止が約45msから約150msに延び、「視聴者の時間は一定の速さで流れる」に反した。
  • 失敗したときの挙動が現状より悪かった。HLSではシークの途中で固まり、mp4では直前のシークを取り消し、AVPlayerの準備ができていないとNSExceptionを投げた。
  • AVKitのシークバーはバックグラウンドスレッドからseekを呼ぶので、メインスレッドで登録すると取りこぼす。

これも継ぎはぎにあたるので、採用しなかった。

二つの時間源#

spikeを進めるのと並行して、サブエージェントに敵対的レビューを頼み、コードベースの歴代の弾幕実装をすべて評価してもらった。最初の実行時スケジューリング、catchupブランチ、システムイベント方式、メディア時計方式、ビューポートからクロック層までの何回かの変更、そして時計のフィルタリングである。

レビューの結論は、どの版にも時間源が二つあるというものだった。前に書いたように、弾幕は二つの問いに答える必要がある。各弾幕がどこにあるかと、どの弾幕が画面に出ているべきかである。一つ目の問いに答える時計は、root layer、同期レイヤー、クロック層と何度か替わった。ところが二つ目の問いは、ずっとメインスレッドのスナップショットで判断していた。スナップショットは1/30秒ごとで、まとめられることも、後ろへ戻ることもある。二つの時間は一度も統一されておらず、両者が食い違うたびに、弾幕の位置と、出るべきかどうかが噛み合わなくなっていた。

たとえばシーク後に再生を始めると、timebaseが57ms後ろへ跳び、そのせいでスナップショットがすでにレイアウトした時間窓の外に出る。すると再計算が走り、再生し終わった弾幕がもう一度画面に出て、全画面でまとめて表示するために保留していた画面もばらばらになる。

それ以前の「第一原理」による調査は時計の層だけを見ていて、表示集合がどう決まるかを見ていなかった。調査の範囲はシステム全体にすべきで、どこかを変えなくてよい部分だと最初から決めてかかってはいけない。

レビューでは二つのやり方が役に立った。複数のレビューが互いの結果を知らないようにしたことと、設計書の作者が自分で最も攻撃されるべき三点を先に挙げ、レビューがそのリストに沿って進めることである。

レビューは最終的に14項目の評価リストをまとめた。そのうちのいくつかを挙げる。

  1. 単一の時間源:弾幕の位置と表示集合を、同じ時計の同じ時刻で計算する。
  2. 意図を識別しない:しきい値、カウンター、範囲外判定でシークを推測しない。
  3. ルールに予算を設ける:直前のルールの副作用を直すためだけのルールを作らない。
  4. テストは弾幕が実際に見えていることを確かめ、追加された(installed)ことだけを確かめるのではない。

別のレビューでは、「前にしか進まない」ルールと単一の時間源は一緒に入れなければならないことが確認された。そうしないと、4倍速でTimeJumpedが大量に届く状況で、見えるべき弾幕の30%が欠ける。

現在の設計#

今の方式も引き続きtimebaseを基準にしていて、プレイヤーの挙動を変えようとはしていない。その上に、視聴者の側から見たルールを一つ加えただけである。順方向の再生中、時間は前にしか進まない。

前にしか進まない#

クロック層は今も厳密にtimebaseに従う。違うのは、順方向の再生中にtimebaseが少し後ろへ跳んだとき、クロック層は一緒に戻らず、その場で止まってtimebaseが追いつくのを待ち、追いついてから一緒に進むことである。画面上では、弾幕が後ろへ戻る代わりに、一瞬止まって見える。

これは一回の書き込みで実現できる。前に出したローカル時間の式を使い、beginTimeを未来に設定してfillMode = .backwardsを指定する。こうすると、beginTimeが来るまで、レイヤーのローカル時間はtimeOffsetのまま保たれる。

beginTime = now + (D − T) / r
timeOffset = D
fillMode = .backwards

ここでDは今表示している時間、Tはこの瞬間のtimebaseの時間、rは速度である。timebaseはDよりD − Tだけ遅れているので、速度rで追いつくには(D − T) / r秒かかる。これがちょうどbeginTimeを先へ延ばす量になる。追いつくまでローカル時間はDに留まり、追いついた後はrで進む。タイマーもしきい値も要らない。速度が0のときの跳躍には、これまでどおり従う。

手元のマシンでは、シーク後の再生開始と初回再生のときに、timebaseが約125ms後退する。以前は弾幕が約16pt後ろへ戻っていたが、変更後は診断ログに39回の待機が記録され、後退は一度もなかった。

単一の時間源#

クロック層はレイヤーに書き込むたびに、そのとき使った直線のパラメータを、エポック(クロック層が連続した時間の区間ごとに振る番号)と一緒にメインスレッドへ公開する。メインスレッドは同じ関数DanmakuClockWrite.mediaTimeでその直線から表示時刻Dを計算する。これがコードの中でDを求める唯一の式である。こうしてメインスレッドとレイヤーが同じ時間を使うようになった。

表示集合、再計算、遅れて届いたテクスチャ、セグメントの破棄は、すべて表示時刻で判断するように変えた。メインスレッドのスナップショットの役目は三つだけになった。現在の再生対象の識別(identity)、チェックの頻度の決定、そしてセグメントのリクエストである。

コードレビューで、マージを止める問題が一つ見つかった。動画を切り替えるとき、付け替えを解除した後も止まった表示時刻を公開し続けていたため、読み込み中の黒い画面に止まった弾幕が出ていた。付け替えを解除した後は公開しないようにして直した。

保留をなくす#

ここまで変えた後も、プレゼンテーション層には保留の仕組みが残っていた。時間が跳んだ後、新しい画面をいったん保留し、必要なテクスチャがそろってから画面全体をまとめて表示するものである。これにはいくつかの形があった。欠けているテクスチャが8個を超えたときだけ全画面で待ち、8個未満なら一つずつ表示するもの、後から来た保留に置き換えられたり、一部だけ確定したりできる保留、全画面の維持、そしてレーンを並べ直すときの全画面の保留である。

不変条件から考えると、プレゼンテーション層に必要なルールは一つだけである。

テクスチャの準備ができていて、今画面に出ているべき弾幕は、すぐに画面に出す。ほかの弾幕を待つために保留しない。

新しい画面を保留するというのは、しばらく古い画面を出しておくことであり、意味があるのは古い画面がまだ有効な場面である場合に限られる。単一の時間源のもとでは、時間が跳んだ時点で古いレイヤーのほとんどはすでに自分のアニメーションの時間の外にあり、古い画面は実際には空になっている。それを残すのは空の画面を残すのと同じである。したがって、上に挙げた保留はすべて、再計算の三通りの分岐と合わせて削除でき、代わりのルールも要らない。

削除した後も、キャッシュにあるテクスチャはprepareの中で同期的にコールバックされるので、すでに見た位置へ遠く飛んだときには、弾幕は今までどおり全画面で一度に出る。キャッシュにないものは一つずつ出て、300個なら約4フレームと見積もっている。設定、画面、拡大率を変えたときは、レーンをその場で入れ替える。テクスチャのキーはレーンに依存しないので、backend.updateが一回の呼び出しで画面上の弾幕をすべて新しいレーンへ移す。

ルールの数は20から12に、継ぎはぎにあたるルールは4から1に減った。弾幕側には分類のためのしきい値がもうなく、残っているのはバッチのパラメータの間の不等式一つ(ステップ + チェック間隔 < 先読み)だけで、テストで固定している。

一つ採用しなかった変更がある。スナップショットの時刻も表示集合に加え、まだバッファされていない区間へドラッグしたとき、時計が着地点へ跳んだそのフレームで画面がもう埋まっているようにするものだった。テクスチャの予算には上限があり、いっぱいになると一部を追い出す必要があるが、どれを追い出すかは「すでに出現したかどうか」だけで決まる。レビューによると、そうするとスナップショット側ですでに流れ終わった弾幕が、表示時刻側でまもなく必要になる弾幕を追い出してしまう。プローブでもそれを確かめた。密度の高い動画では64MiBに290–844個のテクスチャしか入らず、予算が飽和しているのが普通である一方、この変更の効果は約1フレームしかないので、いったん見送った。

単一の時間源の実装の詳細#

以下は実装の細部なので、考え方だけ知りたい場合は読み飛ばしてよい。

  • クロック層の三つの時間プロパティは不可分に書き込む必要がある。そうしないと、一部だけ更新された中間状態を誰かが読むことがある(tearing)。三回の代入だけをCATransaction.lock()で囲み、commitはロックの外に置いた。グローバルロックを持ったままcommitするとデッドロックする。commitの内部では、先にcontextのロック、次にグローバルロックという順で取るからである。プローブでは、この書き方でtearingは起きなかった(20000回中0回)。
  • バックグラウンドからの明示的なcommitは自分で変更したレイヤーだけを確定するが、同じレイヤーにメインスレッドがまだ確定していないframeの変更があると、それも一緒に画面に出る。これもプローブで確認した。
  • 「前にしか進まない」ルールはエポックごとに下限を取り、エポックが変わった後はtimebaseに厳密に従う。あわせて、祖先レイヤーの時間プロパティが恒等かどうかというプラットフォームの前提も確かめる。そうであって初めてレイヤーの親の時間がホスト時刻と等しくなり、前の式が成り立つ。通常のウィンドウ、ネイティブのフルスクリーン、detachedのフルスクリーン、ピクチャ・イン・ピクチャのどれでも、実測では恒等だった。
  • セグメントの破棄では、「スナップショットの時刻 ∪ 表示時刻」の両側で必要なセグメントを守る必要がある。これで、破棄とリクエストの間の無限ループを直した。
  • currentItemのKVOはreplaceCurrentItemの呼び出しの中で同期的に発火する。そこで画面をクリアし、時計を付け替えるようにしたので、古い時計がメインスレッドの一周ぶん余計に動くことはなくなった。

既知の制限#

制限大きさ理由
再生中にシークした後、メインスレッドがブロックされている間は着地点の画面が空になる有線では約70ms以下、Bluetoothでは約280ms以下(推定)根本的に直すには、再計算をクロック層のキューへ移すか、スナップショットの時刻を表示集合に加える必要がある(後者は見送り中)
再開時と速度変更時に20–40ms前へ跳ぶ2–3pttimebaseの真の値であり、映像も跳んでいる
Bluetoothの黙ったアンカーの付け直し±49ms通知がないので、ポーリングかフィルタリングでしか対応できず、どちらも継ぎはぎになる
テクスチャの予算が厳しいとき、どの弾幕が欠けるかは完了の順番に左右される2xの高密度動画でのみ「画面は時刻だけで決まる」にあらかじめ書いた例外
キャッシュにないとき弾幕が一つずつ出る数フレーム意図したもので、保留しない
弾幕はどの時計に従うべきか:BiliKitの弾幕同期を書き直した記録
https://www.shiinayane.com/ja/posts/danmaku-sync-rewrite/
著者
YANKAI WANG
公開日
2026-10-23
ライセンス
CC BY-NC-SA 4.0