基於 Cocos 遊戲引擎的音視頻研發探索

本文首發於公衆號:流利說技術團隊(lls_tech)

原作者:Alex Li

 

本文主要介紹了流利說團隊基於 Cocos 遊戲引擎進行音視頻相關需求開發過程中所遇到的問題和解決方案。文章中將依次闡述 Cocos 引擎直接渲染視頻的方案,繼而引申出多線程環境下 OpenGL 環境的管理方法,最後說明音視頻處理流水線模型需要解決的問題與我們的方案。

 

讓 Cocos 引擎直接渲染視頻

爲什麼?

可能大家首先會疑惑,爲什麼要讓 Cocos 引擎來負責渲染視頻呢?而不利用原生平臺的渲染機制,如使用 Android 平臺的SurfaceView 或 TextureView。

讓我們來分析下利弊:

原生機制優勢:

1.常規播放器接口的直接支持

2.視頻渲染性能穩定

3.代碼簡單

原生機制劣勢:

1.無法更精確的調整視頻與遊戲元素的層級關係(只能置遊戲其上或其下)

2.遊戲控制播放器動畫有性能損耗(遊戲→ native 損耗)

因此,如果要在視頻層上播放遊戲顯示遊戲元素或遊戲動畫,利用原生機制無疑會轉移大量的遊戲業務在native端完成。這無論從項目管理和實現複雜度考慮都是不可取的。因此我們決定讓 cocos 引擎使用外接紋理渲染視頻。

 

實現方案

分析 Cocos 引擎本身的渲染機制,我們發現 Cocos 引擎中封裝了cocos2d::Sprite對象用於渲染顯示,Sprite對象需要我們提供 cocos2d::Texture2D 對象和尺寸信息。因此我們可以封裝一個 Cocos 引擎中的基礎節點 cocos2d::ui::Widget 專門用於視頻顯示,Cocos 中 widge 對象是類似於 native 中 View 功能的組件,負責管理組織繪製大小,繪製位置和繪製內容。我們可以在其回調的 Draw 方法中完成視頻繪製。

在 native 層我們將視頻輸出數據轉爲紋理,再傳遞至Widget中轉化爲 Cocos 的Texture2D對象交由 Sprite 繪製。而 Sprite 渲染尺寸則由 widget 提供。由此我們可以得出下面這樣一個簡易的轉化鏈:

 

多線程OpenGL

但是上面這條轉化鏈並不能簡單的實現。首先 Cocos 引擎是在單獨開啓的一個線程中進行工作的,以下簡稱 Cocos 線程。也就是說我們最終 OpenGL 的繪製都會在 Cocos 線程中操作。我們用 Cocos 線程的OpenGL context 去進行紋理轉化,甚至增加貼圖美顏等功能都是不合適的。音視頻中有一些 OpenGL 操作,很有可能使 Cocos 整個 OpenGL 狀態機被破壞掉。所以需要將所有的音視頻轉化、處理操作都限制在子線程中。

假如我們需要在多線程下共享紋理數據,需要讓 OpenGL Context 共享同一個ShareGroup。因此我們需要接管整個架構環境中所有 OpenGLContext的構建過程。如Android端我們需要在 Cocos 引擎 Cocos2dxActivity的中將 Cocos2dxGLSurfaceView::setEGLContextFactory 修改爲我們自己的提供的方法。除此之外,紋理轉化和處理模塊的 OpenGL 環境也需要統一構建共享 ShareGroup 的 context。

Android端有一點特殊之處,屏蔽了 ShareGroup 的概念。但是我們只要在 OpenGL Context 的構造函數傳入一個 Context,即可讓兩個 Context共享 ShareGroup。

音視頻處理流水線模型

建立模型

 爲了整合音視頻處理的各個環節,構建統一的錯誤處理、線程管理、生命週期管理機制,我們對音視頻處理流程進行了抽象,建立起一個以音視頻源、線程分發器、消費者鏈組成的流水線模型。

抽象出的音視頻源負責加載本地或網絡視頻資源,而後進行解碼操作。亦或者爲採集攝像頭數據的採集器,最終輸出視頻幀數據。而消費者組成消費處理鏈,負責接收處理幀數據或紋理數據。如我們自定義的 cocos::Widget 可以作爲消費鏈的最後一個消費者。

線程分發器即是負責連接源與消費者。線程分發器創建管理音視頻各自的工作線程,把外部命令和音視頻數據分發至目標線程再回調給消費者,保證消費者內部方法在同一線程執行,從而消除消費者模塊的中的線程安全問題。

按照這樣的方式建立的流水線模型具備較好的穩定性和擴展性,可以保證如 OpenGL 上下文管理,視頻幀數據轉化爲紋理等諸多模塊的複用。另外由於消費者和生產者的完全解耦,也能夠實現諸如動態切換音視頻源的功能。此外多線程流水線也能很好的發揮多核 CPU 的性能。

儘管模型已經建立,但在細節方面還存在不少問題等待我們去解決。下面我就簡單說明幾個問題以及我們的探索。

 

音視頻幀數據的複用

爲了避免內存頻繁分配而造成的不必要的耗時。我們通常會對構建的之前生成過得音視頻數據進行復用,因爲存在多分辨率切換的問題,由此會生成諸多大小不一的內存塊。因此複用的前提是被複用的內存>=需要分配的內存。在 Android 端即是指 ByteBuffer 的capacity需要滿足上述條件。因此我們可以建立一個ByteBuffer對象池用於緩存已經被消費完成的ByteBuffer,在複用時遍歷緩存池找尋符合大小條件的 ByteBuffer。在一般情況下視頻數據需要 ByteBuffer 數組來存儲,因此我們可以對對象池的每個對象增加標籤屬性,保證相同分辨率的視頻數據可以快速找到可被複用的內存。

那麼音視頻數據被回收的時機是什麼呢?單線程模型下是極爲容易確定的,但是多線程環境下事情就變得複雜了,我們無法知道什麼時候數據才被真正的消費完成。因此我們參考圖片加載框架Fresco中對Bitmap回收問題的解決方案引入 Closeable References(可回收引用)概念。CloseableRef 對象包裹我們需要緩存的對象,內置的引用計數會在我們所有線程持有的引用都被 close 後纔會回收。在回收的回調方法中我們將其加入緩存對象池中。

 

工作線程的阻塞監控

開發多線程複雜項目我們必須考慮到在低配機型下,工作線程積攢大量任務無法被消費處理的情況。如音頻和視頻的處理線程,如果視頻處理過慢可能會導致嚴重的音畫不同步。因此我們需要建立可以被管理的工作線程任務隊列。我們在 Android 端的實踐爲:基於 HandlerThread ,另外增加一個可以被管理的Queue。每當產生任務,我們將任務入棧,並向 HandlerThread 發送一條出棧指令,HandlerThread  從 Queue 末尾出棧處理任務。

Queue 中可以記錄多項重要參數用於決策處理。如綜合任務預期執行時間、任務的處理時間和隊列積攢數量進行進行策略性丟棄。或者根據一段時間的綜合情況來決定是否降級生產音視頻的分辨率等參數等。

 

總結

以上講述的幾個技術關鍵點是我們團隊在項目開發過程中不斷探討與發現得出的。整套框架方案已經在項目中落地,獲得了還不錯的開發結果。希望能爲大家帶來些許幫助。另外敬請期待我們少兒流利說即將上線的直播課功能。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章