從 0 到 1 實現 Web 端 H-265 播放器:YUV 渲染篇
前言
上一篇文章《視頻解碼篇》主要介紹了原始 HEVC 碼流如何解碼成 YUV 數據(通常視頻採用的都是 YUV 格式),本章主要介紹如何將解碼的 YUV 數據渲染成圖像。在此之前我們先回顧一下 DEMO 架構
undefined
上圖中可以看到,我們接收到 YUV 數據後需要使用 WebGL 對 YUV 處理轉換成 RGB 數據然後進行渲染。那麼爲什麼要轉換成 RGB 呢,首先我們先了解下什麼是 YUV,以及 YUV 和 RGB 的區別。
什麼是 YUV
節選一段維基百科的描述:
YUV 是編譯 true-color 顏色空間的種類,Y'UV, YUV, YCbCr,YPbPr 等專有名詞都可以稱爲 YUV,彼此有重疊。“Y”表示明亮度(Luminance、Luma),“U”和 “V” 則是色度、濃度(Chrominance、Chroma)。通俗講就是 Y 可以用來渲染黑白圖像,而 UV 用來上色。
YUV Formats 分成兩個格式:
-
緊縮格式(packed formats):將 Y、U、V 值存儲成 Macro Pixels 數組,和 RGB 的存放方式類似。
-
平面格式(planar formats):將 Y、U、V 的三個分量分別存放在不同的矩陣中。
緊縮格式中的 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
-
4:4:4 表示完全取樣。
-
4:2:2 表示 2:1 的水平取樣,垂直完全採樣。
-
4:2:0 表示 2:1 的水平取樣,垂直 2:1 採樣。
-
4:1:1 表示 4:1 的水平取樣,垂直完全採樣。
由於 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 腳本
基礎概念
前段代碼中提到的vertexShaderScript420
和fragmentShaderScript420
都是對應着色器的腳本代碼內容,基於 GLSL 腳本語言。下面將簡單介紹下 GLSL 中的概念和語法:
-
屬性(Attributes)和緩衝(WebGLBuffer)
-
緩衝(WebGLBuffer)用來發送到 GPU 的數據隊列,你可以用來存儲位置、法向量等任何數據。
-
屬性(Attributes)用來指明怎麼從緩衝中獲取所需數據並將它提供給頂點着色器。
-
全局變量(Uniforms)
-
全局變量在着色程序運行前賦值,在運行過程中全局有效。
-
紋理(Textures)
-
紋理是一個數據序列,可以在着色程序運行中隨意讀取其中的數據。大多數情況存放的是圖像數據。
-
可變量(Varyings)
-
可變量是一種頂點着色器給片斷着色器傳值的方式。即可以在片段着色器代碼中訪問頂點着色器的 varying 可變量
代碼實例
WebGL 繪製只關心兩件事:裁剪空間中的座標值和顏色值。頂點着色器提供裁剪空間座標值,片斷着色器提供顏色值。
那我們要怎麼繪製視頻圖像數據呢,大家玩過 3D 遊戲的遊戲都有聽說過貼圖這個說法吧,在 WebGL 裏這個技術叫做紋理映射。把紋理空間的像素映射到幾何物體的表面。FFmpeg 產生的每一幀 YUV 數據都可以當作是一個紋理圖案,映射到 2 個三角形拼接的矩形上。如下圖所示:
undefined
紋理空間的座標系稱爲 UV(ST) 座標,分別表示顯示器水平、垂直方向的座標。一般取值範圍爲 0-1。由於 Canvas 座標系 Y 軸朝下,與紋理座標對比相當於 Y 軸翻轉。所以要麼使用 GL 的方法gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
,要麼針對頂點座標作特殊處理。
首先我們先編寫着色器腳本程序,代碼如下:
- vertexShaderScript420
attribute vec4 vertexPos; // 頂點座標
attribute vec2 texturePos; // 紋理座標
varying vec2 textureCoord; // 傳遞紋理座標
void main() {
gl_Position = vertexPos; // 設置頂點座標
textureCoord = texturePos; // 設置紋理座標
}
- fragmentShaderScript420
// 片斷着色器沒有默認精度,所以我們需要設置一個精度
// 這裏選擇高精度
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。
紋理映射
創建了着色器的實例對象以及着色器的內部計算邏輯後,需要填充頂點數據,告訴着色器要繪製幾個頂點,以及紋理與幾何面的關係。
-
頂點座標取值範圍爲 - 1 到 1,我們渲染平面圖,所以只提供 x,y 座標即可。總共兩個三角片,頂點每三個連接在一起即 [1, 1, -1, 1, 1, -1, 1, -1, -1, 1, -1, -1]
-
紋理座標取值範圍爲 0 到 1,因爲 canvas 和 uv 座標爲 y 軸翻轉關係,正常來說我們需要對頂點也做翻轉處理。即 [1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0]
爲了讓各位更清晰的瞭解紋理映射的關係,我們把頂點座標固定(代表三角形也是固定的),紋理座標則羅列三種情況如下所示:
undefined
-
不進行座標翻轉渲染出了紋理的原圖,但可以發現圖片的方向反了,原因便是前面提到的 Canvas Y 軸朝下的原因。需要額外調用
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
即可正常渲染 -
A 三角座標翻轉,同時 B 座標映射亂序一下,會發現 A 是正常的,但 B 卻是旋轉了 45 度的翻轉圖。
-
對 A、B 都做正常順序的翻轉映射,不需要調用額外的 API 也可正常渲染
這裏我們選擇第三種情況的座標映射關係,具體代碼如下:
// 創建緩衝並存入相關頂點數據
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