從 0 到 1 實現 Web 端 H-265 播放器:YUV 渲染篇

前言

上一篇文章《視頻解碼篇》主要介紹了原始 HEVC 碼流如何解碼成 YUV 數據(通常視頻採用的都是 YUV 格式),本章主要介紹如何將解碼的 YUV 數據渲染成圖像。在此之前我們先回顧一下 DEMO 架構

undefined

上圖中可以看到,我們接收到 YUV 數據後需要使用 WebGL 對 YUV 處理轉換成 RGB 數據然後進行渲染。那麼爲什麼要轉換成 RGB 呢,首先我們先了解下什麼是 YUV,以及 YUV 和 RGB 的區別。

什麼是 YUV

(從上至下分別是原圖,Y 分量,U 分量,V 分量)

節選一段維基百科的描述:

YUV 是編譯 true-color 顏色空間的種類,Y'UV, YUV, YCbCr,YPbPr 等專有名詞都可以稱爲 YUV,彼此有重疊。“Y”表示明亮度(Luminance、Luma),“U”和 “V” 則是色度、濃度(Chrominance、Chroma)。通俗講就是 Y 可以用來渲染黑白圖像,而 UV 用來上色。

YUV Formats 分成兩個格式:

緊縮格式中的 YUV 是混合在一起的,對於 YUV4:4:4 格式而言,用緊縮格式很合適的,因此就有了 UYVY、YUYV 等。平面格式是指每 Y 分量,U 分量和 V 分量都是以獨立的平面組織的,也就是說所有的 U 分量必須在 Y 分量後面,而 V 分量在所有的 U 分量後面,此一格式適用於採樣。平面格式有 I420(4:2:0)、YV12、IYUV 等。

undefined

本文用例中的視頻爲 420p 採樣,故後續代碼均以 YUV-420p 採樣爲準

與 RGB 的區別

undefined

RGB,三原色光模式,又稱 RGB 顏色模型或紅綠藍顏色模型,是一種加色模型,將紅(Red)、綠(Green)、藍(Blue)三原色的色光以不同的比例相加,以合成產生各種色彩光。

至今爲止,所有的彩色顯示屏都是使用三原色光加色技術,以 RGB 三原色作爲子像素構成一像素,由多個像素構成整個畫面,通過發射出三種不同強度的電子束,使屏幕內側覆蓋的紅、綠、藍磷光材料發光而產生色彩。包括如今的液晶顯示屏(LCD)。

RGB 訴求於人眼對色彩的感應,YUV 則着重於視覺對於亮度的敏感程度。因爲人眼相比色度,對亮度更敏感。所以 YUV 對亮度的完全採樣,色度的選擇採樣。即可在人眼察覺不到的範圍內最大限度的壓縮圖像。色度抽樣

爲節省帶寬起見,大多數 YUV 格式平均使用的每像素位數都少於 24 位。主要的抽樣(subsample)格式有 YCbCr 4:2:0、YCbCr 4:2:2、YCbCr 4:1:1 和 YCbCr 4:4:4。YUV 的表示法稱爲 A:B:C 表示法:

undefined

由於 YUV 佔用較少的帶寬,而顯示器又是使用 RGB 發光,所以一般都是採用 YUV 傳輸,然後轉換成 RGB 渲染到顯示器上。

WebGL-YUV 渲染

目前在 Web 上高性能渲染 YUV 數據需要藉助 WebGL 的能力,將 YUV 轉 RGB 的計算過程放在 shader 裏可以獲得硬件加速。GPU 對浮點數運算要快於 CPU。

WebGL 工作原理

WebGL 脫胎於 OpenGL,Web 開發者可通過 HTML5Canvas 獲取 gl 對象從而使用 WebGL 能力爲圖像繪製提供硬件加速。

大家學過幾何的應該都知道點線面概念,而 WebGL 可以通過對應方法繪製點(Point)、線(Line)、三角(TRIANGLES),其它圖形則是通過拼湊三角而成,比如矩形就是兩個三角。繪製圖形需要用到着色器,主要分爲頂點着色器(vertex shader)和片段着色器(fragment shader),這兩者一般成對出現。着色器有着 C Like 語法的強類型腳本語言 GLSL,使用該語言進行函數計算。每一對組合關聯在一起就是一個 program(着色程序)。

如上圖所示,vertex array 指的是模型數據,主要分爲 VBO 和 IBO,前者是頂點數據,後者是頂點索引。輸入到 vertex shader 確定頂點座標,通過 IBO 確定哪幾個 VBO 連接成三角形,再將這些三角形進行光柵化(通俗講就是矢量圖轉像素圖)。fragment shader 接收到光柵化後的像素面進行着色。

undefined

在 JS 中創建着色器並關聯 program 的步驟如下:

const gl = canvas.getContext('webgl')
// 創建着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
const program = gl.createProgram()
if (!(vertexShader && fragmentShader && program)) {
    console.warn('shaders create failed')
}
// vertexShaderScript420 爲 yuv420p 頂點着色器腳本內容,後文再介紹
gl.shaderSource(vertexShader, vertexShaderScript420)
gl.compileShader(vertexShader)
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
    console.warn('Vertex shader failed to compile: ', gl.getShaderInfoLog(vertexShader))
}
// fragmentShaderScript420 爲 yuv420p 片段着色器腳本內容
gl.shaderSource(fragmentShader, fragmentShaderScript420)
gl.compileShader(fragmentShader)
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
    console.log('Fragment shader failed to compile: ', gl.getShaderInfoLog(fragmentShader))
}
// 關聯並使用此着色程序
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.log('Program failed to compile: ', gl.getProgramInfoLog(program))
}
gl.useProgram(program)

GLSL 腳本

基礎概念

前段代碼中提到的vertexShaderScript420fragmentShaderScript420都是對應着色器的腳本代碼內容,基於 GLSL 腳本語言。下面將簡單介紹下 GLSL 中的概念和語法:

代碼實例

WebGL 繪製只關心兩件事:裁剪空間中的座標值和顏色值。頂點着色器提供裁剪空間座標值,片斷着色器提供顏色值。

那我們要怎麼繪製視頻圖像數據呢,大家玩過 3D 遊戲的遊戲都有聽說過貼圖這個說法吧,在 WebGL 裏這個技術叫做紋理映射。把紋理空間的像素映射到幾何物體的表面。FFmpeg 產生的每一幀 YUV 數據都可以當作是一個紋理圖案,映射到 2 個三角形拼接的矩形上。如下圖所示:

undefined

紋理空間的座標系稱爲 UV(ST) 座標,分別表示顯示器水平、垂直方向的座標。一般取值範圍爲 0-1。由於 Canvas 座標系 Y 軸朝下,與紋理座標對比相當於 Y 軸翻轉。所以要麼使用 GL 的方法gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1),要麼針對頂點座標作特殊處理。

首先我們先編寫着色器腳本程序,代碼如下:

attribute vec4 vertexPos;   // 頂點座標
attribute vec2 texturePos;   // 紋理座標
varying vec2 textureCoord;   // 傳遞紋理座標

void main() {
    gl_Position = vertexPos;   // 設置頂點座標
    textureCoord = texturePos;   // 設置紋理座標
}
// 片斷着色器沒有默認精度,所以我們需要設置一個精度
// 這裏選擇高精度
precision highp float;
varying highp vec2 textureCoord;   // 接收紋理座標
uniform sampler2D ySampler;   // y圖片紋理數據取樣器
uniform sampler2D uSampler;   // u...
uniform sampler2D vSampler;   // v...
const mat4 YUV2RGB = mat4(
    1.1643828125, 0, 1.59602734375, -.87078515625,
    1.1643828125, -.39176171875, -.81296875, .52959375,
    1.1643828125, 2.017234375, 0, -1.081390625,
    0, 0, 0, 1
);    // YUV 轉 RGB 的數學計算公式。

void main(void) {
    highp float y = texture2D(ySampler,  textureCoord).r; // .r等同於.x、.s、[0]
    highp float u = texture2D(uSampler,  textureCoord).r;
    highp float v = texture2D(vSampler,  textureCoord).r;
    // gl_FragColor是一個片斷着色器主要設置的變量,後面則是矩陣運算,將YUV轉換成RGB
    gl_FragColor = vec4(y, u, v, 1) * YUV2RGB;
}

vertexShaderScript420代碼負責接收設置頂點座標、接收並傳遞紋理座標。fragmentShaderScript420代碼負責接收 yuv 紋理貼圖數據並通過轉換公式(GLSL 支持矩陣向量乘法運算)將 YUV 轉換成 RGB。

紋理映射

創建了着色器的實例對象以及着色器的內部計算邏輯後,需要填充頂點數據,告訴着色器要繪製幾個頂點,以及紋理與幾何面的關係。

爲了讓各位更清晰的瞭解紋理映射的關係,我們把頂點座標固定(代表三角形也是固定的),紋理座標則羅列三種情況如下所示:

undefined

這裏我們選擇第三種情況的座標映射關係,具體代碼如下:

// 創建緩衝並存入相關頂點數據
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, 1, -1, 1, 1, -1, 1, -1, -1, 1, -1, -1]), gl.STATIC_DRAW)
// 找到頂點座標屬性(Attribute)的地址
const vertexPos = gl.getAttribLocation(program, 'vertexPos')
// 告訴WebGL怎麼從緩衝中獲取數據傳遞給屬性
gl.enableVertexAttribArray(vertexPos)
gl.vertexAttribPointer(vertexPos, 2, gl.FLOAT, false, 0, 0) // (屬性地址, 座標數, 32位浮點數, 不標準化, stride, offset)

gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0]), gl.STATIC_DRAW)

const texturePos = gl.getAttribLocation(program, 'texturePos')
gl.enableVertexAttribArray(texturePos)
gl.vertexAttribPointer(texturePos, 2, gl.FLOAT, false, 0, 0)

綁定了頂點數據之後,還需要綁定下紋理數據。

function createTexture(gl: WebGL2RenderingContext) {
    const texture = gl.createTexture()
    gl.bindTexture(gl.TEXTURE_2D, texture)
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)    // 當放大時選擇4個像素混合
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)    // 當縮小時選擇4個像素混合
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) // 表示U方向不需要重複貼圖
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) // 表示V方向不需要重複貼圖
    gl.bindTexture(gl.TEXTURE_2D, null)
    return texture
}
// 創建y紋理對象
const yTexture = createTexture(gl)
// 找到ySampler地址,並告訴sampler取樣器使用第0個紋理單元,即gl.TEXTURE0
const ySampler = gl.getUniformLocation(program, 'ySampler')
gl.uniform1i(ySampler, 0)

const uTexture = createTexture(gl)
const uSampler = gl.getUniformLocation(program, 'uSampler')
gl.uniform1i(uSampler, 1)

const vTexture = createTexture(gl)
const vSampler = gl.getUniformLocation(program, 'vSampler')
gl.uniform1i(vSampler, 2)

繪製 YUV 數據

在《視頻解碼篇》中,我們通過 FFmpeg 解碼得到了每一幀的 YUV 數據,且採用了 yuv420p 排列,所以平鋪模式下 y 數據在前,u 數據緊跟,v 數據最後,將 yuv 數據分別填充到對應的紋理取樣器中即可繪製出圖像了

// buffer 即爲解碼後的幀數據,videoWidth、videoHeight分別爲視頻畫面的寬和高

const size = videoWidth * videoHeight
gl.viewport(0, 0, videoWidth, videoHeight)

// 根據前面YUV的說明已經清楚,有多少個像素就有多少y分量,所以y分量數據長度=寬*高
const yLen = size
const yData = buffer.subarray(0, yLen)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, yTexture)
// 指明紋理的具體屬性
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, videoWidth, videoHeight, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, yData)

// 420模式下u和v都爲y分量的1/4.
const uLen = size / 4
const uData = buffer.subarray(yLen, yLen + uLen)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, uTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, videoWidth / 2, videoHeight / 2, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, uData)

const vLen = uLen
const vData = buffer.subarray(yLen + uLen, yLen + uLen + vLen)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, vTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, videoWidth / 2, videoHeight / 2, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, vData)

// 按照多個三角形的方式繪製,從頂點0開始繪製,總計6個頂點
gl.drawArrays(gl.TRIANGLES, 0, 6)

結語

《視頻解碼篇》中通過 FFmpeg 解碼出的幀數據即可通過以上步驟渲染到 Canvas 中。以上內容是我在 H265 播放器應用中的 WebGL 實踐總結,WebGL 的世界很大,本人也尚在學習中,此文如有錯誤之處歡迎指出。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-YI2Xfjkns98-j7TR8sKJw