基於WebAssembly 的H.265播放器研發

一、背景介紹

 

隨着近些年直播技術的不斷更新迭代,高畫質、低帶寬、低成本成爲直播行業追求的重要目標之一,在這種背景下,H.264 標準已成爲行業主流,而新一代的 HEVC(H.265)標準也正在直播領域被越來越廣泛地採用。花椒直播一直在對 HEVC(H.265)進行研究、應用以及不斷優化。

 

 

二、技術調研

 

HEVC(H.265)

 

高效率視頻編碼(High Efficiency Video Coding,簡稱 HEVC),又稱爲 H.265 和 MPEG-H 第 2 部分,是一種視頻壓縮標準,被視爲是 ITU-T H.264/MPEG-4 AVC 標準的繼任者。HEVC 被認爲不僅提升影像質量,同時也能達到 H.264/MPEG-4 AVC 兩倍之壓縮率(等同於同樣畫面質量下比特率減少到了 50%)。以下統稱爲 H.265。

 

H.265 相對於 H.264 的一些主要改進包括:

 

1. 更靈活的圖像區塊劃分

 

H.265 將圖像劃分爲更具有靈活性的"樹編碼單元(Coding Tree Unit, CTU)",而不是像 H.264 劃分爲 4×4~16×16 的宏塊(Micro Block)。CTU 利用四叉樹結構,可以被(遞歸)分割爲 64×64、32×32、16×16、8×8 大小的子區域。隨着視頻分辨率從 720P、1080P 到 2K、4K 不斷提升,H.264 相對較小尺寸的宏塊劃分會產生大量冗餘部分,而 H.265 提供了更靈活的動態區域劃分。同時,H.265 使用由編碼單元(Coding Unit, CU)、預測單元(Predict Unit, PU)和轉換單元(Transform Unit, TU)組成的新的編碼架構可以有效地降低碼率。

 

640?wx_fmt=png

 

2. 更加完善的預測單元

 

幀內預測:相對於 H.264 提供的 9 種幀內預測模式,H.265 提供了 35 種幀內預測模式(Planar 模式、DC 模式、33 種角度模式),並提供了更好的運動補償和矢量預測方法。

 

幀間預測:指當前圖像中待編碼塊從鄰近圖像中預測得到參考塊的過程,用於去除視頻信號的時間冗餘。H.265 有 8 種幀間預測方式,包括 4 種對稱劃分方式和 4 種非對稱劃分方式。

 

3. 更好的畫質和更低的碼率

 

H.265 在 Deblock 之後增加了新的"採樣點自適應補償(Sample Adaptive Offset)"濾波器,包括邊緣補償(EO,Edge Offset)、帶狀補償(BO,Band Offset)、參數融合模式(Merge),用於減少源圖像與重構圖像之間的失真,以及降低碼率。測試數據表明,雖然採用 SAO 會使得編解碼複雜度增加約 2%,但是卻可以減少 2%~6% 的碼流。

 

可以看到,H.265 在提供了更高的壓縮率、更低的碼率、更好的畫質的同時,也增加了編解碼的複雜度,有統計表明 H.265 解碼的運算量已經數倍於 H.264。

 

由於此次是針對 Web 端播放直播流進行的實踐,所以本文主要關注解碼部分。

 

 

硬解與軟解

 

解碼通常分爲硬解碼與軟解碼。硬解碼是指通過專門的解碼硬件而非 CPU 進行解碼,如 GPU、DSP、FPGA、ASIC 芯片等;軟解碼是指通過 CPU 運行解碼軟件來進行解碼。嚴格意義上來說並不存在純粹的硬解碼,因爲硬解碼過程仍然需要軟件來控制。

 

硬件解碼雖然可以獲得更好的性能,但是礙於專利授權費以及支持硬解碼的設備還並不普及(當前市場上只有部分 GPU 支持 H.265 硬解碼)。同時隨着計算機 CPU 性能的不斷快速提升,H.265 軟解碼已經開始得到廣泛使用。

 

 

Web 端軟解碼

 

目前各主流瀏覽器對 H.265 播放的原生支持情況不夠理想,Web 端幾大瀏覽器全部不支持 H.265 原生播放,Web 端的 H.265 播放需要通過軟件解碼來完成。

 

640?wx_fmt=png

 

在 Web 端進行軟解碼首先會想到使用 JavaScript。libde265.js 是用 C 開發的開源 H.265 編解碼器 libde265 的 JavaScript 版本(確切地說是 libde265 的 asm.js 版本,後面會說明)。經測試,使用 libde265.js 並不是一個音視頻播放的完善方案,存在幀率偏低和音視頻不同步等問題。此外,JavaScript 作爲解釋型腳本語言,對於 H.265 解碼這種重度 CPU 密集型的計算任務而言,也不是理想的選擇,於是繼續探尋更優方案。

 

 

Chrome 原生的 audio/video 播放器原理

 

查找 Chromium Projects 文檔 ,我們可以看到整體大致過程爲:

 

640?wx_fmt=png

 

  1. video 標籤創建一個 DOM 對象,實例化一個 WebMediaPlayer

  2. player 驅使 Buffer 請求多媒體數據

  3. FFmpeg 進行解封裝和音視頻解碼

  4. 把解碼後的數據傳給相應的渲染器對象進行渲染繪製

  5. video 標籤顯示或聲卡播放

 

視頻解碼的目的就是解壓縮,把視頻數據還原成原始的像素,聲音解碼就是把 mp3/aac 等格式還原成原始的 PCM 格式。FFmpeg 是一套老牌的、跨平臺音視頻處理工具,歷史悠久,功能強大,性能卓著,市場上有大量基於 FFmpeg 的編解碼器和播放器。可以看到 Chrome 也使用了它做爲它的解碼器之一。根據原生的 audio/video 播放器原理,我們可以利用 FFmpeg 自己來實現 H.265 的播放。

 

FFmpeg 從早期的 2.1 版本已經開始支持對 H.265 視頻進行解碼,但是花椒直播是基於 HTTP-FLV 的 H.265 視頻流,而 FFmpeg 官方到目前爲止並不支持 "HEVC over FLV (and thus RTMP) ",當然這肯定不是因爲 FFmpeg 在技術方面存在什麼問題,而是因爲 Adobe 官方到目前爲止也還沒有支持以 FLV 來封裝 H.265 數據。

 

 

HTTP-FLV 擴展

 

HTTP-FLV 屬於三大直播協議之一(另外兩種是 RTMP 和 HLS),顧名思義,就是把音視頻數據封裝爲 FLV 格式,然後通過 HTTP 協議進行傳輸。HTTP-FLV 延遲低,基於 80 端口可以穿透防火牆的數據流協議,並且支持 HTTP 302 進行調度和負載均衡。

 

上面我們提到,FFmpeg 官方並不支持以 FLV 格式來封裝 H.265 數據的編解碼,但是非官方的解決方案已經存在,比如國內廠商金山視頻雲就對 FFmpeg 做了擴展,爲 FFmpeg 添加了支持 FLV 封裝的 H.265 數據的編解碼功能。由此,經過擴展的 FFmpeg 可以支持解碼 HTTP-FLV 直播流的 FLV 格式的 H.265 數據了。

 

但我們知道,FFmpeg 是用 C 語言開發的,如何把 FFmpeg 運行在 Web 瀏覽器上,並且給其輸入待解碼的直播流數據呢?使用 WebAssembly 能夠解決我們的問題。

 

 

WebAssembly

 

WebAssembly 是一種新的編碼方式,可以在現代的網絡瀏覽器中運行 - 它是一種低級的類彙編語言,具有緊湊的二進制格式,併爲其他語言提供一個編譯目標,以便它們可以在 Web 上運行。它也被設計爲可以與 JavaScript 共存,允許兩者一起工作。近幾年已經被各主流瀏覽器所廣泛支持:

 

640?wx_fmt=png

 

在瞭解 Wasm 的特點和優勢之前,先來看一下 JavaScript 代碼在 V8 引擎裏是如何被解析和運行的,這大致可以分爲以下幾個步驟(不同的 JS 引擎或不同版本的引擎之間會存在一些差異):

 

  1. JavaScript 代碼由 Parser 轉換爲抽象語法樹 AST

     

  2. Ignition 根據 AST 生成字節碼(V8 引擎 v8.5.9 之前沒有這一步,而是直接編譯成機器碼,v8.5.9 之後 Ignition 字節碼解釋器則會默認啓動)

     

  3. TurboFan(JIT) 優化、編譯字節碼生成本地機器碼

 

640?wx_fmt=png

 

其中第 1 步生成 AST,JS 代碼越多,耗時就會越長,也是整個過程中相對較慢的一個環節。而 Wasm 本身已經就是字節碼,無需這個環節,所以整體運行速度要更快。

 

在第 3 步中,由於 Wasm 的數據類型已經是確定的,因此 JIT 不需要根據運行時收集的信息對數據類型進行假設,也就不會出現重複優化的週期。此外,由於 Wasm 是字節碼,比實現同等功能的 JavaScript 代碼(即使是壓縮後的)體積也會小很多。

 

由此可見,實現等效功能的 Wasm 無論是下載速度還是運行速度都會比 JavaScript 更好。前面提到過的 asm.js,在本質上也是 JavaScript,在 JS 引擎中運行時同樣要經歷上述幾個步驟。

 

到目前爲止,已經有很多高級語言先後支持編譯生成 Wasm,從最早的 C/C++、Rust 到後來的 TypeScript、Kotlin、Scala、Golang,甚至是 Java、C# 這樣的老牌服務器端語言。開發語言層面支持 Wasm 的態勢如此百花齊放,也從側面說明 WebAssembly 技術的發展前景值得期待。

 

前面我們說到,WebAssembly 技術可以幫我們把 FFmpeg 運行在瀏覽器裏,其實就是通過 Emscripten 工具把我們按需定製、裁剪後的 FFmpeg 編譯成 Wasm 文件,加載進網頁,與 JavaScript 代碼進行交互。

 

640?wx_fmt=png

 

 

三、實踐方案

 

整體架構/流程示意圖

 

640?wx_fmt=png

 

涉及技術棧

 

WebAssembly、FFmpeg、Web Worker、WebGL、Web Audio API

 

 

關鍵點說明

 

Wasm 用於從 JavaScript 接收 HTTP-FLV 直播流數據,並對這些數據進行解碼,然後通過回調的方式把解碼後的 YUV 視頻數據和 PCM 音頻數據傳送回 JavaScript,並最終通過 WebGL 在 Canvas 上繪製視頻畫面,同時通過 Web Audio API 播放音頻。

 

 

Web Worker

 

Web Worker 爲 Web 內容在後臺線程中運行腳本提供了一種簡單的方法。線程可以執行任務而不干擾用戶界面。此外,他們可以使用 XMLHttpRequest 執行 I/O 。一旦創建, 一個 worker 可以將消息發送到創建它的 JavaScript 代碼, 通過將消息發佈到該代碼指定的事件處理程序。

 

在主線程初始化兩個 Web Worker,Downloader 和 Decoder,分別用於拉流和解碼,其中 Decoder 與 Wasm 進行數據交互,三個線程之間通過 postMessage 通信,在傳送流數據時使用 Transferable 對象,只傳遞引用,而非拷貝數據,提高性能。

 

Downloader 使用 Streams API 拉取直播流。Fetch 拉取流數據並返回一個 ReadableStreamDefaultReader 對象(默認),它可以用來從一個流當中讀取一個個 Chunk。該對象的 read 方法返回一個 Promise 對象,通過這個 Promise 對象可以連續獲得一組{done,value} 值,其中 done 表示當前流是否已結束,如果未結束的話,value.buffer 即是此次拉取到的二進制數據段,這段數據會通過 postMessage 發送給 Decoder。

 

Decoder 負責與由 FFmpeg 編譯生成的 Wasm 發送原始待解碼數據和接收已解碼後的數據。向 Wasm 發送原始數據時,把每個數據段放進一個 Uint8Array 數組中,用 Module._malloc 分配一塊同等長度的 buffer 內存空間,再用 Module.HEAPU8.set 把這段數據寫入 buffer 指向的內存空間中,最後把 buffer 在內存中的起始地址和這段數據的長度一起傳遞給 Wasm。在從 Wasm 接收解碼後的數據時,通過在 Decoder 中定義的視頻數據回調和音頻數據回調兩個 Callback 方法接收,之後會通過 postMessage 傳送給主線程。

 

音頻解碼完成會放到主線程的 AudioQueue 隊列裏面,視頻解碼完成會放到主線程 VideoQueue 隊列裏面,等待主線程的讀取。作用是爲了保證流暢的播放體驗,也進行音視頻同步處理。

 

 

FFmpeg

 

FFmpeg 主要是由幾個 lib 目錄組成:

 

libavcodec:提供編解碼功能

 

libavformat:封裝(mux)和解封裝(demux)

 

libswscale:圖像伸縮和像素格式轉化

 

首先使用 libavformat 的 API 把容器進行解封裝,得到音視頻在這個文件存放的位置等信息,再使用 libavcodec 進行解碼得到圖像和音頻數據。

 

 

YUV 視頻數據的呈現

 

YUV 的採樣主要有 YUV4:4:4,YUV4:2:2,YUV4:2:0 三種,分別表示每一個 Y 分量對應一組 UV 分量、每兩個 Y 分量共用一組 UV 分量、每四個 Y 分量共用一組 UV 分量,YUV4:2:0 所需的碼流最低。

 

YUV 數據的排列包括 Planar 和 Packed 兩種格式。Planar 格式的 YUV 依次連續存儲像素點的 Y、U、V 數據;Packed 格式的 YUV 交替存儲每個像素點的 Y、U、V 數據。

 

這裏我們解碼出的視頻數據是 YUV420P 格式的,但是 Canvas 不能直接渲染 YUV 格式的數據,而只能接收 RGBA 格式的數據。把 YUV 數據轉換爲 RGBA 數據,會消耗掉一部分性能。我們通過 WebGL 處理 YUV 數據再渲染到 Canvas 上,這樣可以省略掉數據轉換的開銷,利用了 GPU 的硬件加速功能,提高性能。

 

 

內存環/環形緩衝區 (Circular-Buffer)

 

直播流是一個不斷進行傳輸、未知總長度的數據源,拉取到的數據在被 Decoder Worker 讀取之前會進行暫存,被讀取之後需要及時清除或覆蓋,否則會導致客戶端被佔用過多的內存和磁盤資源。

 

一個可行方法是把每次拉取到的數據段寫入到一個環形的內存空間中,由一個 Head 指針指向 Decoder 每次解碼所需要讀取數據的內存起始地址,再用一個 Tail 指針指向後續流數據段寫入的內存地址,並隨着解碼的進行,不斷向後移動兩個指針指向的位置,這樣就可以讓流數據在這個內存環中不斷寫入、被解碼、被覆蓋,使得總體內存使用量可控,在直播過程中不會耗費客戶端過多的資源。

 

640?wx_fmt=png

 

FFmpeg 自定義數據 IO

 

FFmpeg 允許開發者自定數據 IO 來源,比如文件系統或內存等。在我們的方案中使用內存來向 FFmpeg 發送待解碼數據,也就是通過 avio_alloc_context 創建一個 AVIOContext,AVIOContext 結構體定義如下:

 

640?wx_fmt=png

 

buffer 是指向一塊自定義的內存緩衝區的指針;

 

buffer_size 是這塊緩衝區的長度;

 

write_flag 是標識向內存中寫數據(1,編碼時使用)還是其他,比如從內存中讀數據(0,解碼時使用);

 

opaque 包含一組指向自定義數據源的操作指針,是可選參數;

 

read_packet 和 write_packet 是兩個回調函數,分別用於從自定義數據源讀取和向自定義數據源寫入,注意這兩個方法在待處理數據不爲空時是循環調用的;

 

seek 用於在自定義數據源中指定的字節位置。FFmpeg 通過自定義 IO 讀取數據進行解碼的處理過程如下圖所示:

 

640?wx_fmt=png

 

Wasm 體積的優化

 

FFmpeg 提供了對大量媒體格式的封裝/解封裝、編碼/解碼支持,以及對各種協議、顏色空間、過濾器、硬件加速等的支持,可以使用 ffmpeg 命令來詳細查看當前 FFmpeg 版本的具體信息。

 

640?wx_fmt=png

 

由於我們此次主要針對 H.265 的解碼進行實踐,所以可以在編譯時通過參數來定製 FFmpeg 只支持必要的解封裝和解碼器。不同於常規編譯 FFmpeg 時使用的./configure,在編譯給 Wasm 調用的 FFmpeg 時需要使用 Emscripten 提供的 emconfigure ./configure:

 

640?wx_fmt=jpeg

 

這樣定製後編譯的 FFmpeg 版本,與解碼器 C 文件合併編譯生成的 Wasm 大小爲 1.2M,比優化之前的 1.4M 縮小了 15%,提升加載速度。

 

 

四、實踐結果

 

實現花椒 Web 端 H.265 直播流解碼播放。經測試,在 MacBook Pro 2.2GHz Intel Core i7 / 16G 內存筆記本上,使用 Chrome 瀏覽器長時間觀看直播,內存使用量穩定在 270M ~ 320M 之間,CPU 佔用率在 40% ~ 50% 之間。

 

 

五、主要參考資料或網站

 

  1. FFmpeg 官網(http://ffmpeg.org/)

  2. 關於 FFmpeg 不支持 HTTP-FLV/RTMP 的討論

    (http://trac.ffmpeg.org/ticket/6389)

  3. WebAssembly 官網 (https://webassembly.org/)

  4. 谷歌 V8 引擎(https://v8.dev/)

  5. Emscripten 官網(https://emscripten.org/)

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