Meta QuestのExoPlayerを使って60fpsの動画をなめらかに再生する

# はじめに
3年ほど前に私が制作しているVR動画プレイヤーで実施した「ExoPlayerで動画をなめらかに再生する対策」について解説します。

# 問題定義
## 検証動画
- [CIMG3991_quest_original_60Hz_60fps.mp4](http://www.amarectv.com/download/CIMG3991_quest_original_60Hz_60fps.mp4)

この動画はMeta QuestのSDKに含まれる（現在はSDKとは別に配布されている）[サンプルプログラム](#ダウンロード)を使って[60fpsの動画](#ダウンロード)を再生した様子をレンズ越しに秒間480コマのハイスピードカメラで撮影したものです。
Quest 2でリフレッシュレートを60Hzにして撮影しています。
動画にはフレームカウンターが映っているので正常であれば映っているカウンターに重複や欠番はないはずですが・・・



## 解析結果
動画からカウンター部分を切り取ったのが次の画像です。左上から下に向かって進んでいきます。
![CIMG3991_quest_original_60Hz_60fps.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/a2a68dac-b085-c135-c15b-cb2396ac7784.png)

386フレームから867フレームまでの482フレーム（約1秒間に相当）に注目すると以下となります。

<details>

```
ハイスピードカメラに写っているフレームカウンターの値 xは重複
3080
3080x
3081
3083
3083x
3084
3086
3086x
3087
3089
3089x
3091
3091x
3092
3094
3094x
3095
3097
3097x
3098
3100
3100x
3101
3103
3103x
3104
3106
3106x
3107
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140

欠落番号（写っていない番号）
3082
3085
3088
3090
3093
3096
3099
3102
3105
3108
```
</details>

前半は10の重複フレームと、それに伴う10の欠落フレームがあります。
後半は重複も欠落もなくうまく再生できています。
このようにExoPlayerで60Hz、60fpsの条件だとうまく再生できたり不安定になったりを周期的に繰り返します。

今回はこれを改善し きっちり再生する方法を このサンプルプログラムを例に解説します。



# 環境
- Unity 2022.3.10f1
- Meta Quest 2/3



# 対策方針
ディスプレイの動作タイミングに合わせてExoPlayerの出力処理(processOutputBuffer())を行うようにする

具体的にはExoPlayerの出力処理の中でスレッドをロックし「待つ」仕組みを追加します。
そして、Unityのメインスレッドから1フレーム期間に一回だけExoPlayerのロックを解除します。

厳密には「ディスプレイの動作＝メインスレッドの動作」ではないですが、これで60fpsの滑らかな再生が可能になります。
ハイスピードカメラを使わなくてもサンプル動画のスクロールの滑らかさで60fpsの再生が出来ているかどうか判断できます。



# ダウンロード
- Meta Questのサンプルプログラム
https://github.com/oculus-samples/Unity-StarterSamples

- 検証用動画
[krkr.zip](http://www.amarectv.com/cgi-bin/kz_dl_counter.cgi?http://www.amarectv.com/download/krkr.zip)



# 対策内容
1. [プロジェクトの作成とサンプルプログラムの実行](#１プロジェクトの作成とサンプルプログラムの実行)
2. [ExoPlayerの利用](#２exoplayerを利用する)
3. [ExoPlayerをカスタマイズする](#３exoplayerをカスタマイズする)

1と2はQuestでExoPlayerを使えるようにするための準備です。アプリ開発の経験があり対策だけ知りたい方は3を参照してください。



# １．プロジェクトの作成とサンプルプログラムの実行
1. プロジェクト作成
Unity 2022.3.10f1「3D」テンプレートを使って新しいプロジェクトを作成します。
![new project.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/1417c15c-69ff-4744-71de-c3cbf7100402.png)


2. サンプルプログラム
Meta Questのサンプルプログラムを[ダウンロード](#ダウンロード)して解凍してできるファイルを丸ごとプロジェクトへ上書きコピーします。
![file_copy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/6ee4792d-eda5-3bce-eca1-814ba4a0dc0a.png)





3. とりあえず実行
Assets\StarterSamples\UsageにあるStereo180Videoをダブルクリックしてシーンを読み込む。
![stereo180video_prefab.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/4427cfef-65c7-5e5f-c6a2-19ade6a26211.png)
メニューのファイルにあるBuild Settingsから「Android」を選択して「Switch Platform」ボタンを押す。
![build setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/f5c259d1-fc6e-1291-1e19-6fa934306cce.png)
ビルド＆RUNでとりあえず実行できます。
![build_and_run.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/952c35e2-2929-33cf-c775-9e2ce5f2882c.png)
再生する動画が無いのでただの真っ黒な画面になります。
![run.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/b45cbcc9-9b1e-6436-ef7e-53b1f4a266cf.png)Aボタンでコンソールが表示されます。
ホームボタンから終了してください。




4. 動画を再生する
krkr.zipを[ダウンロード](#ダウンロード)
解凍してできるmp4ファイルを全てQuestのMoviesフォルダへ転送
![krkr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/7dfe19de-8408-f268-2f4e-7615c2c5055b.png)




5. サンプルプログラムを編集
Stereo180Videoのメインとなるスクリプト「MoviePlayerSample.cs」を編集していきます。
Assets\StarterSamples\Core\Video\ScriptsにあるMoviePlayerSample.csを開く。
![MoviePlayerSample_cs.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/08ee4de9-4fea-2e13-0fa9-704ac09a7a1e.png)
    - 編集1
        初めの方にusing UnityEngine.UIを追加
        ```c#:MoviePlayerSample.cs 24行目当たり
        using UnityEngine;
        using System;
        using System.IO;
        using UnityEngine.UI; // 追加
        ```
    - 編集2
        void Awake()の最後へMySetup();を追加
        ```c#:MoviePlayerSample.cs 95行目当たり
            #if UNITY_EDITOR
                overlay.currentOverlayShape = OVROverlay.OverlayShape.Quad;
                overlay.enabled = true;
            #endif
            MySetup(); // 追加
        ```
    - 編集3
        private System.Collections.IEnumerator Start() の処理を行わないようにする
        ```c#:MoviePlayerSample.cs 194行目当たり
        private System.Collections.IEnumerator Start()
        {
            yield break; // 追加 以降の処理（動画読み込み）を行わないようにする
            if (mediaRenderer.material == null)
        ```
    - 編集4
        void Update()の最後へMyUpdate();を追加
        リフレッシュレートの制御をしている個所をコメントアウト
        ```c#:MoviePlayerSample.cs 342行目当たり
            else
            {
                NativeVideoPlayer.SetListenerRotation(Camera.main.transform.rotation);
                IsPlaying = NativeVideoPlayer.IsPlaying;
                PlaybackPosition = NativeVideoPlayer.PlaybackPosition;
                Duration = NativeVideoPlayer.Duration;
                
                // コメントアウト リフレッシュレートの制御
                //if (IsPlaying && (int)OVRManager.display.displayFrequency != 60)
                //{
                // OVRManager.display.displayFrequency = 60.0f;
                //}
                //else if (!IsPlaying && (int)OVRManager.display.displayFrequency != 72)
                //{
                // OVRManager.display.displayFrequency = 72.0f;
                //}
                // コメントアウト リフレッシュレートの制御
            }
            MyUpdate(); // 追加
        ```
    - 編集5
        class MoviePlayerSampleの最後へMySetup()とMyUpdate()を実装
        <details>
        
        ```c#:MoviePlayerSample.cs 416行目当たり
        private Text debug_text;
        private string[] video_list = {
            "krkr_60fps.mp4",
            "krkr_72fps.mp4"};
        private int video_idx = 0;
        
        private void MySetup()
        {
            GameObject mp = GameObject.Find("MoviePlayer");
            mp.transform.position = new Vector3(0, 0, 30);
            mp.transform.localScale = new Vector3(32f, 18f, 1f);
            
            GameObject canvas = GameObject.Find("Canvas");
            GameObject debug = new GameObject("debug_text");
            debug.transform.parent = canvas.transform;
            debug_text = debug.AddComponent<Text>();
            debug.transform.localPosition = Vector3.zero;
            debug.transform.localScale = Vector3.one;
            debug.GetComponent<RectTransform>().sizeDelta = new Vector2(1000, 200);
            debug.layer = canvas.layer;
            debug_text.font = Resources.GetBuiltinResource(typeof(Font), "Arial.ttf") as Font;
            debug_text.fontSize = 32;
            debug_text.color = new Color(1f, 0f, 0.8f);
            
            MovieName = "/storage/emulated/0/Movies/" + video_list[video_idx];
            Shape = VideoShape.Quad;
            Stereo = VideoStereo.Mono;
            Play(MovieName, DrmLicenseUrl);
        }
        
        private void MyUpdate()
        {
            if (OVRInput.GetDown(OVRInput.Button.Two)) OVRManager.display.displayFrequency = (OVRManager.display.displayFrequency == 60.0f) ? 72.0f : 60.0f;
            if (OVRInput.GetDown(OVRInput.Button.PrimaryHandTrigger))
            {
                video_idx++;
                if (video_idx > video_list.Length) video_idx = 0;
                MovieName = "/storage/emulated/0/Movies/" + video_list[video_idx];
                Play(MovieName, DrmLicenseUrl);
            }
            string info = overlay.isExternalSurface ? "Playing ExoPlayer with SurfaceObject" : "Playing Unity VideoPlayer";
            debug_text.text =
                $"{info}\n" +
                $"{MovieName}\n" +
                $"Display={OVRManager.display.displayFrequency:0} Hz\n" +
                $"";
            //NativeVideoPlayer.onRenderEvent();
        }
        ```
        </details>
6. Project Settings
MoviesフォルダへアクセスするためにProject Settingsで内蔵ストレージへのアクセス権を要求するようにする
![project_settings_write_permission.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/072aee80-59b9-7e7d-7b0d-9ae9b87cfd35.png)


7. ビルドして実行
krkr_60fps.mp4が再生されます。
![run_unity_player.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/72cfcc4d-8e0b-1fd5-f006-0e91f0a696d5.png)
この段階ではUnityのプレイヤー機能を使って再生されます。
Bボタンでディスプレイのリフレッシュレートが切り替わります。
グリップボタンを押すごとに再生する動画が切り替わります。
    - krkr_60fps.mp4を再生して60Hzにすると なめらかに再生されます
    - krkr_72fps.mp4を再生して72Hzにすると なめらかに再生されます
    ※ Quest3の場合は 60Hzにできません。


    :::note info
    サンプルプログラムには2つのシナリオがあり一つはUnityのプレイヤー機能を使い、もう一つはExoPlayerを使うようになっていてoverlay.isExternalSurfaceで選択されます。
    Unity EditorやOculus Riftで実行する場合はUnityのプレイヤー機能で再生するようになっています（ExoPlayerはAndroidの機能なのでPCでは使えない）。
    Questの実機で実行した場合でもExoPlayerの準備ができていない場合はUnityのプレイヤー機能を使って動作するようになっています。
    :::



# ２．ExoPlayerを利用する
1. ExoPlayerを有効にする
UnityのメニューにあるOculusから「Enable Naitive Android Video Player」を有効にします。
![enable_native_android_video_player.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/3ef45805-3690-cb7e-130c-5fe2252d0113.png)

2. 各設定
以下の設定をしてください。
    - Project SettingsでTarget API Levelを「Android 12.0 (API level 31)」にする(既になっている)
    - 以下の3つのファイルのプロパティでAndroidをOnにしてApplyボタンを押す
        Assets\StarterSamples\Core\Video\Plugins\Android\Audio360にある
        - audio360.aar
        - audio360-exo218.aar
        
        Assets\StarterSamples\Core\Video\Plugins\Android\java\com\oculus\videoplayerにある
        - NativeVideoPlayer.java
        ![native_video_player_prop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/c9839361-d25f-2bfb-851f-936522f5f600.png)




    - ExoPlayerのバージョンを2.18.1にする
        Assets\Plugins\AndroidにあるmainTemplate.gradleを開いて編集します。
        ```gradle:mainTemplate.gradle
        apply plugin: 'com.android.library'
        **APPLY_PLUGINS**
    
        dependencies {
        implementation fileTree(dir: 'libs', include: ['*.jar'])
        implementation 'com.google.android.exoplayer:exoplayer:2.18.1'
        ```




3. ビルドして実行
Playing ExoPlayerとなっていればExoPlayerで再生できています。
![run_playing_exoplayer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/cb9320cd-6b2c-e342-88a6-b8c1edc6b6ce.png)
ただし、Unityのプレヤー機能と比べ 時々カクカクするのが解ると思います（これが本題）。

:::note info
Unityのプレイヤー機能の方が滑らかなのでUnityのプレイヤー機能を使えば良くない？
恐らくUnityのプレイヤー機能はゲームのオープニングとか必殺技の前のカットイン動画のように制作時にあらかじめ用意した動画を目的としたものだとおもいます。
メディアプレイヤーの様に利用者が用意した動画やネットワーク再生等になると再生が途切れたり、読み込み、シークなどで待たされる場面があります。
:::






# ３．ExoPlayerをカスタマイズする
以下の3つのスクリプトを編集します。

1. メインスクリプト
    Assets\StarterSamples\Core\Video\Scriptsにある
    MoviePlayerSample.cs

    onRenderEvent()のコメントアウトを外す
    ```c#:MoviePlayerSample.cs 467行目あたり
    NativeVideoPlayer.onRenderEvent(); // コメントアウトを外す
    ```
    onRenderEvent()関数をUpdate()の中で呼び出すことで 1フレーム期間に一回だけExoPlayerのスレッドのロックを解除します。

2. ネイティブプレイヤースクリプト
    Assets\StarterSamples\Core\Video\Plugins\Androidにある
    NativeVideoPlayer.cs

    このクラスはUnityのメインスクリプトとExoPlayerのjavaスクリプトの間を取り持つクラスです。
クラスの最後にある public static void SetListenerRotation()の後にonRenderEvent()を実装します。
    ```c#:NativeVideoPlayer.cs SetListenerRotation()の後
    private static System.IntPtr onRenderEventMethodId;
    public static void onRenderEvent()
    {
        if (onRenderEventMethodId == System.IntPtr.Zero)
        {
            onRenderEventMethodId = AndroidJNI.GetStaticMethodID(VideoPlayerClass, "onRenderEvent", "()V");
        }       
        AndroidJNI.CallStaticVoidMethod(VideoPlayerClass, onRenderEventMethodId, EmptyParams);
    }
    ```
3. javaスクリプト
    Assets\StarterSamples\Core\Video\Plugins\Android\java\com\oculus\videoplayerにある
    NativeVideoPlayer.java

    これがExoPlayerのコア処理になります。


    - 編集1
        importを追加する
        ```java:NativeVideoPlayer.java 74行目あたり
        import com.google.android.exoplayer2.util.Assertions;
        import com.google.android.exoplayer2.C;
        import com.google.android.exoplayer2.ExoPlaybackException;
        import com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
        import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter;
        import com.google.android.exoplayer2.video.MediaCodecVideoRenderer;
        import com.google.android.exoplayer2.video.VideoFrameReleaseHelper;
        import java.lang.reflect.Constructor;
        import java.nio.ByteBuffer;
        import android.os.SystemClock;
        ```






    - 編集2
        メインスクリプトから呼び出すonRenderEvent()および各カスタムクラスを実装する
        <details>
    
        ```java:NativeVideoPlayer.java クラスの最後 setPlaybackPosition()の後
        private static Object lock = new Object();
        public static void onRenderEvent()
        {
            synchronized (lock) {
                lock.notifyAll();
            }
        }
        
        // exoplayer ver 2.18.1
        private static class CustomMediaCodecVideoRenderer extends MediaCodecVideoRenderer
        {
            public CustomMediaCodecVideoRenderer(
                Context context,
                MediaCodecAdapter.Factory codecAdapterFactory,
                MediaCodecSelector mediaCodecSelector,
                long allowedJoiningTimeMs,
                boolean enableDecoderFallback,
                Handler eventHandler,
                VideoRendererEventListener eventListener,
                int maxDroppedFramesToNotify) {
            
                super(
                    context,
                    codecAdapterFactory,
                    mediaCodecSelector,
                    allowedJoiningTimeMs,
                    enableDecoderFallback,
                    eventHandler,
                    eventListener,
                    maxDroppedFramesToNotify,
                    30
                    );
                frameReleaseHelper = new VideoFrameReleaseHelper(context);
            }
            
            private final VideoFrameReleaseHelper frameReleaseHelper;
            
            
            @Override
            protected boolean processOutputBuffer(
                long positionUs,
                long elapsedRealtimeUs,
                MediaCodecAdapter codec,
                ByteBuffer buffer,
                int bufferIndex,
                int bufferFlags,
                int sampleCount,
                long bufferPresentationTimeUs,
                boolean isDecodeOnlyBuffer,
                boolean isLastBuffer,
                Format format)
                throws ExoPlaybackException {
            
                double playbackSpeed = getPlaybackSpeed();
                boolean isStarted = getState() == STATE_STARTED;
                long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000;
                long earlyUs = (long) ((bufferPresentationTimeUs - positionUs) / playbackSpeed);
                if (isStarted) {
                    // Account for the elapsed time since the start of this iteration of the rendering loop.
                    // earlyUs -= elapsedRealtimeNowUs - elapsedRealtimeUs;
                }
            
                long outputStreamOffsetUs = getOutputStreamOffsetUs();
                long presentationTimeUs = bufferPresentationTimeUs - outputStreamOffsetUs;
                
                // Skip frames in sync with playback, so we'll be at the right frame if the mode changes.
                if (isBufferLate(earlyUs)) {
                    skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
                    updateVideoFrameProcessingOffsetCounters(earlyUs);
                    return true;
                }
                
                // Compute the buffer's desired release time in nanoseconds.
                long systemTimeNs = System.nanoTime();
                long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000);
                
                long adjustedReleaseTimeNs = frameReleaseHelper.adjustReleaseTime(unadjustedFrameReleaseTimeNs);
                // earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000;
                
                if (Util.SDK_INT >= 21) {
                    // Let the underlying framework time the release.
                    if (earlyUs < 50000) {
                        try {
                            synchronized (lock) {
                                lock.wait();
                            }
                        } catch (InterruptedException e) {
                        }
                        //notifyFrameMetadataListener(presentationTimeUs, adjustedReleaseTimeNs, format);
                        renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, adjustedReleaseTimeNs);
                        updateVideoFrameProcessingOffsetCounters(earlyUs);
                        return true;
                    }
                } else {
                }
                
                // We're either not playing, or it's not time to render the frame yet.
                return false;
            }
                
            private static boolean isBufferLate(long earlyUs) {
                // Class a buffer as late if it should have been presented more than 30 ms ago.
                return earlyUs < -30000;
            }
        }
        
        
        // exoplayer ver 2.18.1
        private static class CustomRenderersFactory2 extends DefaultRenderersFactory
        {
            public CustomRenderersFactory2(Context context) {
                super(context);
            }
            
            @Override
            public DefaultRenderersFactory setAllowedVideoJoiningTimeMs(long allowedVideoJoiningTimeMs) {
                super.setAllowedVideoJoiningTimeMs(allowedVideoJoiningTimeMs);
                return this;
            }
            
            @Override
            public Renderer[] createRenderers(
                Handler eventHandler,
                VideoRendererEventListener videoRendererEventListener,
                AudioRendererEventListener audioRendererEventListener,
                TextOutput textRendererOutput,
                MetadataOutput metadataRendererOutput) {
            
                Renderer[] renderers =
                    super.createRenderers(
                        eventHandler,
                        videoRendererEventListener,
                        audioRendererEventListener,
                        textRendererOutput,
                        metadataRendererOutput);
                
                ArrayList<Renderer> rendererList = new ArrayList<>(Arrays.asList(renderers));
                
                // The output latency of the engine can be used to compensate for sync
                double latency = engine.getOutputLatencyMs();
                
                // Audio: opus codec with the spatial audio engine
                // TBE_8_2 implies 10 channels of audio (8 channels of spatial audio, 2 channels of
                // head-locked)
                if (audio360Sink == null) {
                    audio360Sink = new Audio360Sink(spat, ChannelMap.TBE_8_2, latency);
                }
                final OpusRenderer audioRenderer = new OpusRenderer(audio360Sink);
                
                // place our audio renderer first in the list to prioritize it
                rendererList.add(0, audioRenderer);
                Log.e(TAG, "TEMP Added OpusRenderer to renderers list!!");
                
                renderers = rendererList.toArray(renderers);
                return renderers;
            }
            
            @Override
            protected void buildVideoRenderers(
                Context context,
                @ExtensionRendererMode int extensionRendererMode,
                MediaCodecSelector mediaCodecSelector,
                boolean enableDecoderFallback,
                Handler eventHandler,
                VideoRendererEventListener eventListener,
                long allowedVideoJoiningTimeMs,
                ArrayList<Renderer> out) {
            
                MediaCodecVideoRenderer videoRenderer =
                    new CustomMediaCodecVideoRenderer(
                        context,
                        getCodecAdapterFactory(),
                        mediaCodecSelector,
                        allowedVideoJoiningTimeMs,
                        enableDecoderFallback,
                        eventHandler,
                        eventListener,
                        MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY);
                out.add(videoRenderer);
                
                if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) {
                    return;
                }
                int extensionRendererIndex = out.size();
                if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) {
                    extensionRendererIndex--;
                }
            
                try {
                    // Full class names used for constructor args so the LINT rule triggers if any of them move.
                    Class<?> clazz = Class.forName("androidx.media3.decoder.vp9.LibvpxVideoRenderer");
                    Constructor<?> constructor =
                        clazz.getConstructor(
                            long.class,
                            android.os.Handler.class,
                            com.google.android.exoplayer2.video.VideoRendererEventListener.class,
                            int.class);
                    Renderer renderer =
                        (Renderer)
                        constructor.newInstance(
                            allowedVideoJoiningTimeMs,
                            eventHandler,
                            eventListener,
                            MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY);
                    out.add(extensionRendererIndex++, renderer);
                    Log.i(TAG, "Loaded LibvpxVideoRenderer.");
                } catch (ClassNotFoundException e) {
                    // Expected if the app was built without the extension.
                } catch (Exception e) {
                    // The extension is present, but instantiation failed.
                    throw new RuntimeException("Error instantiating VP9 extension", e);
                }
            
                try {
                    // Full class names used for constructor args so the LINT rule triggers if any of them move.
                    Class<?> clazz = Class.forName("androidx.media3.decoder.av1.Libgav1VideoRenderer");
                    Constructor<?> constructor =
                        clazz.getConstructor(
                            long.class,
                            android.os.Handler.class,
                            com.google.android.exoplayer2.video.VideoRendererEventListener.class,
                            int.class);
                    Renderer renderer =
                        (Renderer)
                        constructor.newInstance(
                            allowedVideoJoiningTimeMs,
                            eventHandler,
                            eventListener,
                            MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY);
                    out.add(extensionRendererIndex++, renderer);
                    Log.i(TAG, "Loaded Libgav1VideoRenderer.");
                } catch (ClassNotFoundException e) {
                    // Expected if the app was built without the extension.
                } catch (Exception e) {
                    // The extension is present, but instantiation failed.
                    throw new RuntimeException("Error instantiating AV1 extension", e);
                }
            }
        }
        ```
        
        </details>
    
        ```java
        public static void onRenderEvent()
        ```
        Unityのメインスクリプトから呼ばれる関数です。
        スレッドのロックを全て解除します。
    
        ```java
        protected boolean processOutputBuffer()
        ```
        ExoPlayerの出力処理の関数です これを自作のものに置き換えてスレッドをロックする仕組みを追加します。
    
        この関数は引数で渡されたベースタイム（positionUs）とフレームデータのプレゼンタイム（bufferPresentationTimeUs）を比較して
        1. まだ早いと判断した場合は何もせずfalseを返す。ほかの処理（オーディオストリームなど）をした後、再びprocessOutputBuffer()が呼ばれる
        2. 丁度いい時間と判断した場合はrenderOutputBufferV21()を実行してtrueを返す
        3. 大幅に遅れた場合はskipOutputBuffer()を実行してtrueを返す（ドロップフレーム、これを表示していると音ずれする）
    
        ザックリ言ってこのような働きをします。
    なので、丁度いいと判断した際にすぐに処理せずスレッドを止めて、onRenderEvent()から解除してもらうのを待つようにします。
        ```java
        private static class CustomMediaCodecVideoRenderer extends MediaCodecVideoRenderer
        ```
        processOutputBuffer()はMediaCodecVideoRendererクラスのメンバーなので
    MediaCodecVideoRendererを継承した自作クラスCustomMediaCodecVideoRendererを作りオーバーライトします。
        ```java
        private static class CustomRenderersFactory2 extends DefaultRenderersFactory
        ```
        自作したCustomMediaCodecVideoRendererを読み込むために用意したクラスです。
        CustomRenderersFactoryとしたいところですが、サンプルプログラムに同じ名前のクラスがあるので2を付けて区別しています。
        protected void buildVideoRenderers()をオーバーライトして自作したCustomMediaCodecVideoRendererを読み込みます。




    - 編集3
        自作したCustomRenderersFactory2をExoPlayerへ組み込む
        358行目あたりにあるExoPlayerのインスタンスを作るところでCustomRenderersFactoryを登録しているので コメントアウトして CustomRenderersFactory2へ差し替えます。
        ```java:NativeVideoPlayer.java 358行目あたり
            ExoPlayer.Builder playerBuilder =
                new ExoPlayer.Builder(context)
                .setMediaSourceFactory(buildMediaSourceFactory(context, dataSourceFactory))
                // .setRenderersFactory(new CustomRenderersFactory(context));
                .setRenderersFactory(new CustomRenderersFactory2(context));
        ```




    以上で完了です。
    ビルドして実行すればUnityのプレイヤー機能と同じようにExoPlayerでも なめらかな再生になります。



# さいごに
今回の対策はリフレッシュレートと動画のフレームレートが近い場合専用です。
- 60Hzのデイスプレイへ60fpsの動画を再生する場合のみうまく動作します
- Quest3の場合は60Hzに出来ないので 72Hzで72fpsの動画を再生した場合のみ上手く動作します

リフレッシュレートより動画のfpsが高い場合は同期をとらない方が良いし、なによりprocessOutputBuffer()の多くの処理を省略しています。
本格的なメディアプレイヤーを制作する場合は さらに120Hzで60fpsだったり 72Hzで60fpsなどでも均等なタイミングで動作するような仕組みが必要となります。