最近團隊在用 WASM + FFmpeg 打造一個 WEB 播放器。咱們是經過寫 C 語言用 FFmpeg 解碼視頻,經過編譯 C 語言轉 WASM 運行在瀏覽器上與 JavaScript 進行通訊。默認 FFmpeg 去解碼出來的數據是 yuv,而 canvas 只支持渲染 rgb,那麼此時咱們有兩種方法處理這個yuv,第一個使用 FFmpeg 暴露的方法將 yuv 直接轉成 rgb 而後給 canvas 進行渲染,第二個使用 webgl 將 yuv 轉 rgb ,在 canvas 上渲染。第一個好處是寫法很簡單,只需 FFmpeg 暴露的方法將 yuv 直接轉成 rgb ,缺點呢就是會耗費必定的cpu,第二個好處是會利用 gpu 進行加速,缺點是寫法比較繁瑣,並且須要熟悉 WEBGL 。考慮到爲了減小 cpu 的佔用,利用 gpu 進行並行加速,咱們採用了第二種方法。javascript
在講 YUV 以前,咱們先來看下 YUV 是怎麼獲取到的:
因爲咱們是寫播放器,實現一個播放器的步驟一定會通過如下這幾個步驟:php
- 將視頻的文件好比 mp4,avi,flv等等,mp4,avi,flv 至關因而一個容器,裏面包含一些信息,好比壓縮的視頻,壓縮的音頻等等, 進行解複用,從容器裏面提取出壓縮的視頻以及音頻,壓縮的視頻通常是 H26五、H264 格式或者其餘格式,壓縮的音頻通常是 aac或者 mp3。
- 分別在壓縮的視頻和壓縮的音頻進行解碼,獲得原始的視頻和音頻,原始的音頻數據通常是pcm ,而原始的視頻數據通常是 yuv 或者 rgb。
- 而後進行音視頻的同步。
能夠看到解碼壓縮的視頻數據以後,通常就會獲得 yuv。html
對於前端開發者來講,YUV 其實有點陌生,對於搞過音視頻開發的通常會接觸到這個,簡單來講,YUV 和咱們熟悉的 RGB 差很少,都是顏色編碼方式,只不過它們的三個字母表明的意義與 RGB 不一樣,YUV 的 「Y」 表示明亮度(Luminance或Luma),也就是灰度值;而 」U」 和 」V」 表示的則是色度(Chrominance或Chroma),描述影像色彩及飽和度,用於指定像素的顏色。前端
爲了讓你們對 YUV 有更加直觀的感覺,咱們來看下,Y,U,V 單獨顯示分別是什麼樣子,這裏使用了 FFmpeg 命令將一張火影忍者的宇智波鼬圖片轉成YUV420P:java
ffmpeg -i frame.jpg -s 352x288 -pix_fmt yuv420p test.yuv
在 GLYUVPlay
軟件上打開 test.yuv
,顯示原圖:
Y份量單獨顯示:
U份量單獨顯示:
V 份量單獨顯示:
由上面能夠發現,Y 單獨顯示的時候是能夠顯示完整的圖像的,只不過圖片是灰色的。而U,V則表明的是色度,一個偏藍,一個偏紅。ios
常見的YUV的採樣有YUV444,YUV422,YUV420:git
注:黑點表示採樣該像素點的Y份量,空心圓圈表示採用該像素點的UV份量。
YUV的存儲格式有兩類:packed(打包)和 planar(平面):github
舉個例子,對於 planar 模式,YUV 能夠這麼存 YYYYUUVV,對於 packed 模式,YUV 能夠這麼存YUYVYUYV。web
YUV 格式通常有多種,YUV420SP、YUV420P、YUV422P,YUV422SP等,咱們來看下比較常見的格式:canvas
其中YUV420P和YUV420SP根據U、V的順序,又可分出2種格式:
YUV420P
:U前V後即YUV420P
,也叫I420
,V前U後,叫YV12
。YUV420SP
:U前V後叫NV12
,V前U後叫NV21
。數據排列以下:
I420: YYYYYYYY UU VV =>YUV420P YV12: YYYYYYYY VV UU =>YUV420P NV12: YYYYYYYY UV UV =>YUV420SP NV21: YYYYYYYY VU VU =>YUV420SP
至於爲啥會有這麼多格式,通過大量搜索發現緣由是爲了適配不一樣的電視廣播制式和設備系統,好比 ios 下只有這一種模式NV12
,安卓的模式是 NV21
,好比 YUV411
、YUV420
格式多見於數碼攝像機數據中,前者用於 NTSC
制,後者用於 PAL
制。至於電視廣播制式的介紹咱們能夠看下這篇文章【標準】NTSC、PAL、SECAM三大制式簡介
以YUV420P存儲一張1080 x 1280圖片爲例子,其存儲大小爲 ((1080 x 1280 x 3) >> 1)
個字節,這個是怎麼算出來的?咱們來看下面這張圖:
以 Y420P 存儲那麼 Y 佔的大小爲 W x H = 1080x1280
,U 爲(W/2) * (H/2)= (W*H)/4 = (1080x1280)/4
,同理 V爲(W*H)/4 = (1080x1280)/4
,所以一張圖爲 Y+U+V = (1080x1280)*3/2
。
因爲三個部份內部均是行優先存儲,三個部分之間是Y,U,V 順序存儲,那麼YUV的存儲位置以下(PS:後面會用到):
Y:0 到 1080*1280 U:1080*1280 到 (1080*1280)*5/4 V:(1080*1280)*5/4 到 (1080*1280)*3/2
## WEBGL
簡單來講,WebGL是一項用來在網頁上繪製和渲染複雜3D圖形,並容許用戶與之交互的技術。
在 webgl 世界中,能繪製的基本圖形元素只有點、線、三角形,每一個圖像都是由大大小小的三角形組成,以下圖,不管是多麼複雜的圖形,其基本組成部分都是由三角形組成。
着色器是在GPU上運行的程序,是用OpenGL ES着色語言編寫的,有點相似 c 語言:
具體的語法能夠參考着色器語言 GLSL (opengl-shader-language)入門大全,這裏不在多加贅述。
在 WEBGL 中想要繪製圖形就必需要有兩個着色器:
其中頂點着色器的主要功能就是用來處理頂點的,而片元着色器則是用來處理由光柵化階段生成的每一個片元(PS:片元能夠理解爲像素),最後計算出每一個像素的顏色。
1、提供頂點座標
由於程序很傻,不知道圖形的各個頂點,須要咱們本身去提供,頂點座標能夠是本身手動寫或者是由軟件導出:
在這個圖中,咱們把頂點寫入到緩衝區裏,緩衝區對象是WebGL系統中的一塊內存區域,咱們能夠一次性地向緩衝區對象中填充大量的頂點數據,而後將這些數據保存在其中,供頂點着色器使用。接着咱們建立並編譯頂點着色器和片元着色器,並用 program 鏈接兩個着色器,並使用。舉個例子簡單理解下爲何要這樣作,咱們能夠理解成建立Fragment 元素: let f = document.createDocumentFragment()
,
全部的着色器建立並編譯後會處在一種遊離的狀態,咱們須要將他們聯繫起來,並使用(能夠理解成 document.body.appendChild(f)
,添加到 body,dom 元素才能被看到,也就是聯繫並使用)。
接着咱們還須要將緩衝區與頂點着色器進行鏈接,這樣才能生效。
2、圖元裝配
咱們提供頂點以後,GPU根據咱們提供的頂點數量,會挨個執行頂點着色器程序,生成頂點最終的座標,將圖形裝配起來。能夠理解成製做風箏,就須要將風箏骨架先搭建起來,圖元裝配就是在這一階段。
3、光柵化
這一階段就比如是製做風箏,搭建好風箏骨架後,可是此時卻不能飛起來,由於裏面都是空的,須要爲骨架添加布料。而光柵化就是在這一階段,將圖元裝配好的幾何圖形轉成片元(PS: 片元能夠理解成像素)。
4、着色與渲染
着色這一階段就比如風箏布料搭建完成,可是此時並無什麼圖案,須要繪製圖案,讓風箏更加好看,也就是光柵化後的圖形此時並無顏色,須要通過片元着色器處理,逐片元進行上色並寫到顏色緩衝區裏,最後在瀏覽器才能顯示有圖像的幾何圖形。
總結
WEBGL 繪製流程能夠概括爲如下幾點:
因爲每一個視頻幀的圖像都不太同樣,咱們確定不可能知道那麼多頂點,那麼咱們怎麼將視頻幀的圖像用 webgl 畫出來呢?這裏使用了一個技巧—紋理映射。簡單來講就是將一張圖像貼在一個幾何圖形表面,使幾何圖形看起來像是有圖像的幾何圖形,也就是將紋理座標和 webgl 系統座標進行一一對應:
如上圖,上面那個是紋理座標,分爲 s 和 t 座標(或者叫 uv 座標),值的範圍在【0,1】之間,值和圖像大小、分辨率無關。下面那張圖是webgl座標系統,是一個三維的座標系統,這裏聲明瞭四個頂點,用兩個三角形組裝成一個長方形,而後將紋理座標的頂點與 webgl 座標系進行一一對應,最終傳給片元着色器,片元着色器提取圖片的一個個紋素顏色,輸出在顏色緩衝區裏,最終繪製在瀏覽器裏(PS:紋素你能夠理解爲組成紋理圖像的像素)。可是若是按圖上進行一一對應的話,成像會是反的,由於 canvas 的圖像座標,默認(0,0)是在左上角:
而紋理座標則是在左下角,因此繪製時成像就會倒立,解決方法有兩種:
// 1表明對紋理圖像進行y軸反轉 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
(0.0,1.0)
對應的是webgl 座標(-1.0,1.0,0.0)
,(0.0,0.0)
對應的是(-1.0,-1.0,0.0)
,那麼咱們倒轉過來,(0.0,1.0)
對應的是(-1.0,-1.0,0.0)
,而(0.0,0.0)
對應的是(-1.0,1.0,0.0)
,這樣在瀏覽器成像就不會是反的。詳細步驟
// 頂點着色器vertexShader attribute lowp vec4 a_vertexPosition; // 經過 js 傳遞頂點座標 attribute vec2 a_texturePosition; // 經過 js 傳遞紋理座標 varying vec2 v_texCoord; // 傳遞紋理座標給片元着色器 void main(){ gl_Position=a_vertexPosition;// 設置頂點座標 v_texCoord=a_texturePosition;// 設置紋理座標 } // 片元着色器fragmentShader precision lowp float;// lowp表明計算精度,考慮節約性能使用了最低精度 uniform sampler2D samplerY;// sampler2D是取樣器類型,圖片紋理最終存儲在該類型對象中 uniform sampler2D samplerU;// sampler2D是取樣器類型,圖片紋理最終存儲在該類型對象中 uniform sampler2D samplerV;// sampler2D是取樣器類型,圖片紋理最終存儲在該類型對象中 varying vec2 v_texCoord; // 接受頂點着色器傳來的紋理座標 void main(){ float r,g,b,y,u,v,fYmul; y = texture2D(samplerY, v_texCoord).r; u = texture2D(samplerU, v_texCoord).r; v = texture2D(samplerV, v_texCoord).r; // YUV420P 轉 RGB fYmul = y * 1.1643828125; r = fYmul + 1.59602734375 * v - 0.870787598; g = fYmul - 0.39176171875 * u - 0.81296875 * v + 0.52959375; b = fYmul + 2.01723046875 * u - 1.081389160375; gl_FragColor = vec4(r, g, b, 1.0); }
let vertexShader=this._compileShader(vertexShaderSource,gl.VERTEX_SHADER);// 建立並編譯頂點着色器 let fragmentShader=this._compileShader(fragmentShaderSource,gl.FRAGMENT_SHADER);// 建立並編譯片元着色器 let program=this._createProgram(vertexShader,fragmentShader);// 建立program並鏈接着色器
let vertexBuffer = gl.createBuffer(); let vertexRectangle = new Float32Array([ 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0 ]); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); // 向緩衝區寫入數據 gl.bufferData(gl.ARRAY_BUFFER, vertexRectangle, gl.STATIC_DRAW); // 找到頂點的位置 let vertexPositionAttribute = gl.getAttribLocation(program, 'a_vertexPosition'); // 告訴顯卡從當前綁定的緩衝區中讀取頂點數據 gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0); // 鏈接vertexPosition 變量與分配給它的緩衝區對象 gl.enableVertexAttribArray(vertexPositionAttribute); // 聲明紋理座標 let textureRectangle = new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]); let textureBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer); gl.bufferData(gl.ARRAY_BUFFER, textureRectangle, gl.STATIC_DRAW); let textureCoord = gl.getAttribLocation(program, 'a_texturePosition'); gl.vertexAttribPointer(textureCoord, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(textureCoord);
//激活指定的紋理單元 gl.activeTexture(gl.TEXTURE0); gl.y=this._createTexture(); // 建立紋理 gl.uniform1i(gl.getUniformLocation(program,'samplerY'),0);//獲取samplerY變量的存儲位置,指定紋理單元編號0將紋理對象傳遞給samplerY gl.activeTexture(gl.TEXTURE1); gl.u=this._createTexture(); gl.uniform1i(gl.getUniformLocation(program,'samplerU'),1);//獲取samplerU變量的存儲位置,指定紋理單元編號1將紋理對象傳遞給samplerU gl.activeTexture(gl.TEXTURE2); gl.v=this._createTexture(); gl.uniform1i(gl.getUniformLocation(program,'samplerV'),2);//獲取samplerV變量的存儲位置,指定紋理單元編號2將紋理對象傳遞給samplerV
// 設置清空顏色緩衝時的顏色值 gl.clearColor(0, 0, 0, 0); // 清空緩衝 gl.clear(gl.COLOR_BUFFER_BIT); let uOffset = width * height; let vOffset = (width >> 1) * (height >> 1); gl.bindTexture(gl.TEXTURE_2D, gl.y); // 填充Y紋理,Y 的寬度和高度就是 width,和 height,存儲的位置就是data.subarray(0, width * height) gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(0, uOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.u); // 填充U紋理,Y 的寬度和高度就是 width/2 和 height/2,存儲的位置就是data.subarray(width * height, width/2 * height/2 + width * height) gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset, uOffset + vOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.v); // 填充U紋理,Y 的寬度和高度就是 width/2 和 height/2,存儲的位置就是data.subarray(width/2 * height/2 + width * height, data.length) gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset + vOffset, data.length) ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); // 繪製四個點,也就是長方形
上述那些步驟最終能夠繪製成這張圖:
完整代碼:
export default class WebglScreen { constructor(canvas) { this.canvas = canvas; this.gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); this._init(); } _init() { let gl = this.gl; if (!gl) { console.log('gl not support!'); return; } // 圖像預處理 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); // GLSL 格式的頂點着色器代碼 let vertexShaderSource = ` attribute lowp vec4 a_vertexPosition; attribute vec2 a_texturePosition; varying vec2 v_texCoord; void main() { gl_Position = a_vertexPosition; v_texCoord = a_texturePosition; } `; let fragmentShaderSource = ` precision lowp float; uniform sampler2D samplerY; uniform sampler2D samplerU; uniform sampler2D samplerV; varying vec2 v_texCoord; void main() { float r,g,b,y,u,v,fYmul; y = texture2D(samplerY, v_texCoord).r; u = texture2D(samplerU, v_texCoord).r; v = texture2D(samplerV, v_texCoord).r; fYmul = y * 1.1643828125; r = fYmul + 1.59602734375 * v - 0.870787598; g = fYmul - 0.39176171875 * u - 0.81296875 * v + 0.52959375; b = fYmul + 2.01723046875 * u - 1.081389160375; gl_FragColor = vec4(r, g, b, 1.0); } `; let vertexShader = this._compileShader(vertexShaderSource, gl.VERTEX_SHADER); let fragmentShader = this._compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER); let program = this._createProgram(vertexShader, fragmentShader); this._initVertexBuffers(program); // 激活指定的紋理單元 gl.activeTexture(gl.TEXTURE0); gl.y = this._createTexture(); gl.uniform1i(gl.getUniformLocation(program, 'samplerY'), 0); gl.activeTexture(gl.TEXTURE1); gl.u = this._createTexture(); gl.uniform1i(gl.getUniformLocation(program, 'samplerU'), 1); gl.activeTexture(gl.TEXTURE2); gl.v = this._createTexture(); gl.uniform1i(gl.getUniformLocation(program, 'samplerV'), 2); } /** * 初始化頂點 buffer * @param {glProgram} program 程序 */ _initVertexBuffers(program) { let gl = this.gl; let vertexBuffer = gl.createBuffer(); let vertexRectangle = new Float32Array([ 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0 ]); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); // 向緩衝區寫入數據 gl.bufferData(gl.ARRAY_BUFFER, vertexRectangle, gl.STATIC_DRAW); // 找到頂點的位置 let vertexPositionAttribute = gl.getAttribLocation(program, 'a_vertexPosition'); // 告訴顯卡從當前綁定的緩衝區中讀取頂點數據 gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0); // 鏈接vertexPosition 變量與分配給它的緩衝區對象 gl.enableVertexAttribArray(vertexPositionAttribute); let textureRectangle = new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]); let textureBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer); gl.bufferData(gl.ARRAY_BUFFER, textureRectangle, gl.STATIC_DRAW); let textureCoord = gl.getAttribLocation(program, 'a_texturePosition'); gl.vertexAttribPointer(textureCoord, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(textureCoord); } /** * 建立並編譯一個着色器 * @param {string} shaderSource GLSL 格式的着色器代碼 * @param {number} shaderType 着色器類型, VERTEX_SHADER 或 FRAGMENT_SHADER。 * @return {glShader} 着色器。 */ _compileShader(shaderSource, shaderType) { // 建立着色器程序 let shader = this.gl.createShader(shaderType); // 設置着色器的源碼 this.gl.shaderSource(shader, shaderSource); // 編譯着色器 this.gl.compileShader(shader); const success = this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS); if (!success) { let err = this.gl.getShaderInfoLog(shader); this.gl.deleteShader(shader); console.error('could not compile shader', err); return; } return shader; } /** * 從 2 個着色器中建立一個程序 * @param {glShader} vertexShader 頂點着色器。 * @param {glShader} fragmentShader 片段着色器。 * @return {glProgram} 程序 */ _createProgram(vertexShader, fragmentShader) { const gl = this.gl; let program = gl.createProgram(); // 附上着色器 gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); // 將 WebGLProgram 對象添加到當前的渲染狀態中 gl.useProgram(program); const success = this.gl.getProgramParameter(program, this.gl.LINK_STATUS); if (!success) { console.err('program fail to link' + this.gl.getShaderInfoLog(program)); return; } return program; } /** * 設置紋理 */ _createTexture(filter = this.gl.LINEAR) { let gl = this.gl; let t = gl.createTexture(); // 將給定的 glTexture 綁定到目標(綁定點 gl.bindTexture(gl.TEXTURE_2D, t); // 紋理包裝 參考https://github.com/fem-d/webGL/blob/master/blog/WebGL基礎學習篇(Lesson%207).md -> Texture wrapping gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // 設置紋理過濾方式 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); return t; } /** * 渲染圖片出來 * @param {number} width 寬度 * @param {number} height 高度 */ renderImg(width, height, data) { let gl = this.gl; // 設置視口,即指定從標準設備到窗口座標的x、y仿射變換 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); // 設置清空顏色緩衝時的顏色值 gl.clearColor(0, 0, 0, 0); // 清空緩衝 gl.clear(gl.COLOR_BUFFER_BIT); let uOffset = width * height; let vOffset = (width >> 1) * (height >> 1); gl.bindTexture(gl.TEXTURE_2D, gl.y); // 填充紋理 gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(0, uOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.u); gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset, uOffset + vOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.v); gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset + vOffset, data.length) ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } /** * 根據從新設置 canvas 大小 * @param {number} width 寬度 * @param {number} height 高度 * @param {number} maxWidth 最大寬度 */ setSize(width, height, maxWidth) { let canvasWidth = Math.min(maxWidth, width); this.canvas.width = canvasWidth; this.canvas.height = canvasWidth * height / width; } destroy() { const { gl } = this; gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); } }
最後咱們來看下效果圖:
在實際開發過程當中,咱們測試一些直播流,有時候渲染的時候圖像顯示是正常的,可是顏色會偏綠,經研究發現,直播流的不一樣主播的視頻寬度是會不同,好比在主播在 pk 的時候寬度368,熱門主播寬度會到 720,小主播寬度是 540,而寬度爲 540 的會顯示偏綠,具體緣由是 webgl 會通過預處理,默認會將如下值設置爲 4:
// 圖像預處理 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
這樣默認設置會每行 4 個字節 4 個字節處理,而 Y份量每行的寬度是 540,是 4 的倍數,字節對齊了,因此圖像可以正常顯示,而 U,V 份量寬度是 540 / 2 = 270
,270 不是4 的倍數,字節非對齊,所以色素就會顯示偏綠。目前有兩種方法能夠解決這個問題:
// 圖像預處理 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
圖像視頻編碼和FFmpeg(2)——YUV格式介紹和應用 - eustoma - 博客園
YUV pixel formats
https://wiki.videolan.org/YUV/
使用 8 位 YUV 格式的視頻呈現 | Microsoft Docs?redirectedfrom=MSDN)
IOS 視頻格式之YUV - 簡書
圖解WebGL&Three.js工做原理 - cnwander - 博客園