Android平臺上基於OpenGl渲染yuv視頻

本篇文章已授權微信公衆號 guolin_blog (郭霖)獨家發佈

更多音視頻開發文章,請看:音視頻開發專欄

介紹一個自己剛出爐的音視頻播放錄製開源項目

前言

這是我音視頻專欄的第一篇實例解析,也算是入門篇,重點講下如何使用OpenGl去渲染一個yuv視頻。

本篇博文涉及的知識點主要有三個:
1.yuv的概念
2.基於ndk進行C++程序的基本編寫
3.OpenGl紋理的繪製

本文將重點講知識點1和3,ndk開發部分就不細談,由於OpenGl知識體系龐大,本文也是根據重點來分析,所以如果沒有ndk開發基礎和OpenGl基礎的讀者看本文可能會比較困難。

談談YUV

YUV,是一種顏色編碼方法。常使用在各個影像處理組件中。Y”表示明亮度(Luminance、Luma),“U”和“V”則是色度、濃度(Chrominance、Chroma)相對我們都比較熟悉的編碼格式RGB,RGB訴求於人眼對色彩的感應,YUV則着重於視覺對於亮度的敏感程度。 YUV在對照片或影片編碼時,考慮到人類的感知能力,允許降低色度的帶寬。換句話說,也就是編碼的時候允許Y的量比UV要多,允許對圖片的UV分量進行下采樣,這樣數據佔用的空間就比RGB更小(關於下采樣,簡單來說就是以比原來更低的採樣率進行採樣。詳細可以看下維基百科:Downsampling (signal processing)也可以看下知乎這篇文章:oversampling,undersampling,downsampling,upsampling 四個概念的區別和聯繫是什麼?)。

圖像中的Y, U,和V組成:
圖像中的Y', U,和V組成

這樣說有點抽象,可以看看微軟這篇有名的文章進行理解:Video Rendering with 8-Bit YUV Formats

這裏主要講yuv的兩個方面,分別是採樣格式和存儲格式。採樣格式簡單可以理解一張原圖,每個像素怎麼採樣yuv各個分量,比如每隔幾個像素採一個y分量(或者u、v)。存儲格式簡單來說就是採樣之後,按照什麼方式存儲,比如哪個字節存儲y,第幾個字節存儲u。

yuv採樣格式:

文章裏面“YUV Sampling”一節詳細說明了各種不同格式的yuv是如何採樣的。
以下是對該章節的節選翻譯:

YUV的優點之一是,感知質量不會顯著下降的前提下,色度通道的採樣率與Y通道的採樣率相比更低。一般用一個叫做A:B:C(即y:u:v)的符號用來描述U和V相對於Y的採樣頻率,爲了方便理解,使用圖來描述,圖中y分量使用x表示,uv使用o表示:

4:4:4:

意味着色度通道沒有向下採樣,也就是說yuv三個通道都是全採樣:

4:4:4

4:2:2:

表示2:1水平下采樣,沒有垂直下采樣。每條掃描線包含四個Y樣本對應兩個U或V樣本。也就是水平方向按照y:uv使用2:1進行採樣,垂直方向全採樣的方式:

4:2:2

4:2:0:

表示2:1水平下采樣,2:1垂直下采樣。也就是水平方向按照y:uv使用2:1進行採樣,垂直方向按照y:uv使用2:1的方式:

4:2:0

注意這裏4:2:0並不代表y:u:v = 4:2:0,這裏指的是在每一行掃描時,只掃描一種色度分量(U 或者 V),和 Y 分量按照 2 : 1 的方式採樣。比如,第一行掃描時,YU 按照 2 : 1 的方式採樣,那麼第二行掃描時,YV 分量按照 2:1 的方式採樣。所以y和u或者v的比都是2:1。

4:1:1:

表示4:1水平下采樣,沒有垂直下采樣。每條掃描線包含四個Y樣本對應於每一個U或V樣本。

4:1:1抽樣比其他格式更少見,本文不詳細討論。

yuv存儲格式:

YUV存儲格式有兩大類:planar 和 packed:
packed:Y、U和V組件存儲在一個數組中。每個像素點的Y,U,V是連續交錯存儲的。和RGB的存儲格式類似。
planar :Y、U和V組件存儲爲三個獨立的數組中。

y、u、v每個採樣點使用8bit存儲。

接下來詳細講下集中常見的yuv格式存儲方式:

4:2:2格式:

主要有兩種具體格式:

YUY2:

屬於packed類型,YUY2格式,數據可視爲unsigned char數組。第一個字節包含第一個Y樣本,第二個字節包含第一U (Cb)樣本,第三字節包含第二Y樣本,第四個字節包含第V (Cr)樣本,以此類推,如圖:
YUY2
可以看到,Y0 和 Y1 公用 U0 V0 分量,Y2 和 Y3 公用 U1 V1 分量,以此類推。

UYVY:

也是屬於屬於packed類型的,和YUY2和類似,只是存儲方向是相反的:
UYVY

4:2:0格式

該格式又包含多種存儲方式,這裏重點將以下幾種:

YUV 420P 和 YUV 420SP 都是基於 Planar 平面模式 進行存儲的,先存儲所有的 Y 分量後, YUV420P 類型就會先存儲所有的 U 分量或者 V 分量,而 YUV420SP 則是按照 UV 或者 VU 的交替順序進行存儲了,具體查看看下圖(圖來源於:音視頻基礎知識—像素格式YUV):

YUV420P:

(這裏需要敲黑板,因爲本文播放的yuv就是YUV420P格式,熟悉它的存儲格式纔可以理解代碼中讀取視頻幀數據的邏輯)
YUV420P
正是因爲 YUV420P是2:1水平下采樣,2:1垂直下采樣,所以y分量數量等於視頻寬高,u和v分量都是視頻寬乘以高/4

YUV420SP

YUV420SP
4:2:0格式還有YV12、YU12、NV12 、NV21等存儲格式,這裏因爲篇幅關係就不做細談。

yuv轉RGB:

目前一般解碼後的視頻格式爲yuv,但是一般顯卡渲染的格式是RGB,所以需要把yuv轉化爲RGB。

關於yuv轉RGB這裏有個公式可以知己使用:
在這裏插入圖片描述
或者直接用yuv的矩陣乘以以下矩陣得到對應的RGB矩陣:
在這裏插入圖片描述

yuv就先介紹到這裏,熟悉yuv對於後面yuv視頻播放至關重要。

談談OpenGl

OpenGL是行業領域中最爲廣泛接納的 2D/3D 圖形 API。OpenGL是一個跨平臺的軟件接口語言,用於調用硬件的2D、3D圖形處理器。由於只是軟件接口,所以具體底層實現依賴硬件設備製造商。

關於OpenGl的知識,可能寫20篇博文也介紹不完,這裏只介紹和當前播放yuv相關的,不會很詳細,詳細教程可以看這個網站:歡迎來到OpenGL的世界(以下描述也部分節選該網站)

安卓使用的是OpenGl ES版本,即OpenGL的一個子集,裁剪了一些功能,專門使用在嵌入式設備。

OpenGL圖形渲染管線

首先要解釋的是OpenGl的圖形渲染管線:指的是一堆原始圖形數據途經一個輸送管道,期間經過各種變化處理最終出現在屏幕的過程。分爲兩個主要部分:第一部分把你的3D座標轉換爲2D座標,第二部分是把2D座標轉變爲實際的有顏色的像素。

圖形渲染管線接受一組3D座標,然後把它們轉變爲你屏幕上的有色2D像素輸出。圖形渲染管線可以被劃分爲幾個階段,每個階段將會把前一個階段的輸出作爲輸入。

當今大多數顯卡都有成千上萬的小處理核心,它們在GPU上爲每一個(渲染管線)階段運行各自的相互獨立的並行處理小程序,從而在圖形渲染管線中快速處理你的數據。這些小程序叫做着色器(Shader),因爲它們運行在GPU中,所以解放了CPU的省生產力

圖形渲染管線的每個階段的展示:

在這裏插入圖片描述

圖形渲染管線的第一個部分是頂點着色器(Vertex Shader),它把一個單獨的頂點作爲輸入。頂點着色器主要的目的是把3D座標轉爲另一種3D座標(座標系統的轉化),同時頂點着色器允許我們對頂點屬性進行一些基本處理。頂點着色器代碼是每個頂點執行一次。

圖元裝配(Primitive Assembly)階段將頂點着色器輸出的所有頂點作爲輸入(如果是GL_POINTS,那麼就是一個頂點),並所有的點裝配成指定圖元的形狀。比如將頂點裝配爲三角形或者矩形。

幾何着色器的輸出會被傳入光柵化階段(Rasterization Stage),這裏它會把圖元映射爲最終屏幕上相應的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment,OpenGL渲染一個像素所需的所有數據)。

片段着色器的主要目的是計算一個像素的最終顏色,這也是所有OpenGL高級效果產生的地方。片段着色器是每個片段(像素)執行一次

而我們要處理的,主要就是頂點着色器和片段着色器的代碼邏輯,着色器是用叫GLSL的類C語言寫成的,它包含一些針對向量和矩陣操作的有用特性。詳細語法見着色器

OpenGL座標系

要寫頂點着色器代碼,首先就要知道OpenGL頂點座標系:

按照慣例,OpenGL是一個右手座標系。簡單來說,就是正x軸在你的右手邊,正y軸朝上,而正z軸是朝向後方的。想象你的屏幕處於三個軸的中心,則正z軸穿過你的屏幕朝向你:

在這裏插入圖片描述

(這裏要提的一點事,OpenGl在執行頂點着色器之後,會像流水線一樣將座標進行5個步驟的變換:局部座標–世界座標–觀察座標–裁剪座標–屏幕座標,這裏因爲實例是2D的,暫時還不需要關心這些)

現在需要記得的是,OpenGL僅當3D座標在3個軸(x、y和z)上都爲-1.0到1.0的範圍內時才處理它。所有在所謂的標準化設備座標(Normalized Device Coordinates)範圍內的座標纔會最終呈現在屏幕上(在這個範圍以外的座標都不會顯示)。

2D情況下,既不考慮z軸,則一般來說頂點座標系如下所示:

頂點座標系

OpenGL紋理繪製

通過頂點着色器和片段着色器,我們可以指定要繪製的物體形狀大小以及顏色,但是如果我們要做類似將一張圖片繪製上去,該如何做呢?

OpenGl提供了紋理這個概念,讓你可以將一張圖片“貼”到你想要的位置。
(詳細見 紋理

那麼紋理是如何“貼”到圖形上去的呢?其實就是對圖片進行採樣,再將採樣到的顏色數據繪製到圖形相應的位置。

爲了能夠把紋理映射(Map)到我們的圖形上,我們需要指定圖形的每個頂點各自對應紋理的哪個部分。所以圖形的每個頂點都會關聯一個紋理的座標,用來標明該從紋理圖像的哪個部分採樣。

通俗來說,就是比方你頂點座標提供的是一個矩形,現在要將一張圖片(紋理)“貼”到矩形上,那麼需要指定一個紋理座標,告訴OpenGl矩形光柵化處理後的每個片段對應圖片的哪個像素的顏色。紋理座標,簡單來說就是以一張紋理圖片的某個點作爲原點的座標系。

類似下圖所示:
在這裏插入圖片描述
由上圖可以看到紋理座標系的模樣了,不過在Android平臺,紋理座標如下:

在這裏插入圖片描述

即以圖片的左上角爲原點的座標系。

所以在提供了頂點座標和紋理座標之後,OpenGL就知道如何通過採樣紋理上的像素的顏色數據,將顏色繪製到頂點座標所表達的圖形上的對應位置。

紋理就先講到這裏,還有許多具體的採樣細節需要注意,還請看詳細教程紋理

程序實例分析

所謂工欲善其事必先利其器,基礎知識講得差不多了,那麼又要進入最重要的將代碼環節了,這裏使用的yuv格式爲yuv420p

這裏使用cmake進行構建,native-lib爲項目自定義的動態庫名稱,其餘需要鏈接的動態庫如下配置:

find_library( # Sets the name of the path variable.
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              log )

target_link_libraries( # Specifies the target library.
                       native-lib
                       GLESv2
                       EGL
                       android
                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib} )

Java層首先創建一個集成GLSurfaceView的類:

public class YuvPlayer extends GLSurfaceView implements Runnable, SurfaceHolder.Callback, GLSurfaceView.Renderer {
	//這裏將yuv視頻文件放在sdcard目錄中
    private final static String PATH = "/sdcard/sintel_640_360.yuv";

    public YuvPlayer(Context context, AttributeSet attrs) {
        super(context, attrs);
        setRenderer(this);
    }

    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        new Thread(this).start();
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {

    }

    @Override
    public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {

    }

    @Override
    public void run() {
        loadYuv(PATH,getHolder().getSurface());
    }
	//定義一個native方法加載yuv視頻文件
    public native void loadYuv(String url, Object surface);

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {

    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {

    }

    @Override
    public void onDrawFrame(GL10 gl) {

    }
}

進入native層的loadYuv方法:

Java_com_example_yuvopengldemo_YuvPlayer_loadYuv(JNIEnv *env, jobject thiz, jstring jUrl,
                                                 jobject surface) {
    const char *url = env->GetStringUTFChars(jUrl, 0);
	//打開yuv視頻文件	
    FILE *fp = fopen(url, "rb");
    if (!fp) {
    	//打Log方法
        LOGD("oepn file %s fail", url);
        return;
    }
    LOGD("open ulr is %s", url);

首先是從Java層傳入的jstring變量轉爲char*,然後打開yuv視頻文件。

接下來是初始化EGL:

這裏簡單解釋下EGL是什麼。

EGL™是Khronos呈現api(如OpenGL ES或OpenVG)與底層本機平臺窗口系統之間的接口。它處理圖形上下文管理、表面/緩衝區綁定和呈現同步,並使用其他Khronos api支持高性能、加速、混合模式的2D和3D呈現。EGL還提供了Khronos之間的互操作能力,以支持在api之間高效地傳輸數據——例如在運行OpenMAX AL的視頻子系統和運行OpenGL ES的GPU之間。

通俗來講就是,EGL是渲染API(如OpenGL, OpenGL ES, OpenVG)和本地窗口系統之間的接口。EGL可以理解爲OpenGl ES ES和設備之間的橋樑,EGL是爲OpenGl提供繪製表面的。因爲OpenGl是跨平臺的,當它訪問不同平臺的設備的時候需要EGL作爲中間的適配器。

在這裏插入圖片描述
EGL的使用步驟:
在這裏插入圖片描述
具體的代碼:

//1.獲取原始窗口
ANativeWindow *nwin = ANativeWindow_fromSurface(env, surface);
    //獲取OpenGl ES的渲染目標。Display(EGLDisplay) 是對實際顯示設備的抽象。
    EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
    if (display == EGL_NO_DISPLAY) {
        LOGD("egl display failed");
        return;
    }
    //2.初始化egl與 EGLDisplay 之間的連接,後兩個參數爲主次版本號
    if (EGL_TRUE != eglInitialize(display, 0, 0)) {
        LOGD("eglInitialize failed");
        return;
    }
	//創建渲染用的surface
    //2.1 surface配置
    EGLConfig eglConfig;
    EGLint configNum;
    EGLint configSpec[] = {
            EGL_RED_SIZE, 8,
            EGL_GREEN_SIZE, 8,
            EGL_BLUE_SIZE, 8,
            EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
            EGL_NONE
    };

    if (EGL_TRUE != eglChooseConfig(display, configSpec, &eglConfig, 1, &configNum)) {
        LOGD("eglChooseConfig failed");
        return;
    }

    //2.2創建surface(將egl和NativeWindow進行關聯,即將EGl和設備屏幕連接起來。最後一個參數爲屬性信息,0表示默認版本)。Surface(EGLSurface)是對用來存儲圖像的內存區FrameBuffer 的抽象。這就是我們要渲染的Surface
    EGLSurface winSurface = eglCreateWindowSurface(display, eglConfig, nwin, 0);
    if (winSurface == EGL_NO_SURFACE) {
        LOGD("eglCreateWindowSurface failed");
        return;
    }

    //3 創建關聯上下文
    const EGLint ctxAttr[] = {
            EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE
    };
    //創建egl關聯OpenGl的上下文環境 EGLContext 實例。EGL_NO_CONTEXT表示不需要多個設備共享上下文。Context (EGLContext) 存儲 OpenGL ES繪圖的一些狀態信息。上面的代碼只是egl和設備窗口的關聯,這裏是和OpenGl的關聯
    EGLContext context = eglCreateContext(display, eglConfig, EGL_NO_CONTEXT, ctxAttr);
    if (context == EGL_NO_CONTEXT) {
        LOGD("eglCreateContext failed");
        return;
    }
    //將EGLContext和opengl真正關聯起來。綁定該線程的顯示設備及上下文
    //兩個surface一個讀一個寫。
    if (EGL_TRUE != eglMakeCurrent(display, winSurface, winSurface, context)) {
        LOGD("eglMakeCurrent failed");
        return;
    }

創建初始化EGL,接下來就是真正的OpenGl繪製代碼。

先看下着色器代碼。看着色器代碼之前,先了解下GLSL一些基礎:
常見的變量類型:
attritude:一般用於各個頂點各不相同的量。如頂點位置、紋理座標、法向量、顏色等等。
uniform:一般用於對於物體中所有頂點或者所有的片段都相同的量。比如光源位置、統一變換矩陣、顏色等。
varying:表示易變量,一般用於頂點着色器傳遞到片段着色器的量。
vec2:包含了2個浮點數的向量
vec3:包含了3個浮點數的向量
vec4:包含了4個浮點數的向量
sampler1D:1D紋理着色器
sampler2D:2D紋理着色器
sampler3D:3D紋理着色器

首先編寫頂點着色器代碼:

//頂點着色器,每個頂點執行一次,可以並行執行
#define GET_STR(x) #x
static const char *vertexShader = GET_STR(
        attribute vec4 aPosition;//輸入的頂點座標,會在程序指定將數據輸入到該字段
        attribute vec2 aTextCoord;//輸入的紋理座標,會在程序指定將數據輸入到該字段
        varying vec2 vTextCoord;//輸出的紋理座標,輸入到片段着色器
        void main() {
            //這裏其實是將上下翻轉過來(因爲安卓圖片會自動上下翻轉,所以轉回來。也可以在頂點座標中就上下翻轉)
            vTextCoord = vec2(aTextCoord.x, 1.0 - aTextCoord.y);
            //直接把傳入的座標值作爲傳入渲染管線。gl_Position是OpenGL內置的
            gl_Position = aPosition;
        }
);

這裏邏輯很簡單。使用兩個attribute變量,一個接受頂點座標,一個接收紋理座標,這裏以標準的OpenGl的紋理座標爲標準,即和安卓平臺是上下翻轉關係的(在本文OpenGL紋理繪製一節有說到),所以對傳進來的紋理座標在0.0~1。0之間進行上下翻轉,再賦值給varying類型變量vTextCoord,vTextCoord將通過渲染管線傳給片段着色器。最後將傳進來的頂點座標賦值給gl_Position ,gl_Position 是OpenGL內置的表示頂點座標的變量。gl_Position 被賦值之後,將通過渲染管線傳給後面的階段,在圖元裝配的時候,將頂點連接起來。在光柵化圖元的時候,將兩個頂點之間的線段分解成大量的小片段,varying數據在這個過程中計算生成,記錄在每個片段中,之後傳遞給片段着色器

然後編寫片段着色器代碼:

//圖元被光柵化爲多少片段,就被調用多少次
static const char *fragYUV420P = GET_STR(
        precision mediump float;
        //接收從頂點着色器、光柵化處理傳來的紋理座標數據
   	    varying vec2 vTextCoord;
        //輸入的yuv三個紋理
        uniform sampler2D yTexture;//y分量紋理
        uniform sampler2D uTexture;//u分量紋理
        uniform sampler2D vTexture;//v分量紋理
        void main() {
        	//存放採樣之後的yuv數據
            vec3 yuv;
            //存放yuv數據轉化後的rgb數據
            vec3 rgb;
            //對yuv各個分量對應vTextCoord的像素進行採樣。這裏texture2D得到的結果是一個vec4變量,它的r、g、b、a的值都爲採樣到的那個分量的值
            //將採樣到的y、u、v分量的數據分別保存在vec3 yuv的r、g、b(或者x、y、z)分量
     	    yuv.r = texture2D(yTexture, vTextCoord).g;
            yuv.g = texture2D(uTexture, vTextCoord).g - 0.5;
            yuv.b = texture2D(vTexture, vTextCoord).g - 0.5;
            //這裏必須把yuv轉化爲RGB
            rgb = mat3(
                    1.0, 1.0, 1.0,
                    0.0, -0.39465, 2.03211,
                    1.13983, -0.5806, 0.0
            ) * yuv;
            //gl_FragColor是OpenGL內置的,將rgb數據賦值給gl_FragColor,傳到渲染管線的下一階段 ,gl_FragColor 表示正在呈現的像素的 R、G、B、A 值。 
            gl_FragColor = vec4(rgb, 1.0);
        }
);

這裏要將yuv三個分量分別用三層紋理來渲染,然後將多層紋理混合一起顯示。代碼中三個sampler2D類型變量就是紋理圖片,需要從外部程序傳入。然後通過texture2D方法採樣得到對應紋理座標位置的顏色數據,將yuv三個分量的採樣值放入vec3 類型變量yuv的三個分量中,因爲OpenGl只支持RGB的渲染,所以需要將vec3類型的 yuv通過公式轉爲一個rgb 的vec3 類型變量。最後將rgb 變量構建一個vec4變量,作爲最終顏色賦值給gl_FragColor 。

着色器代碼定義完,接下來就是渲染邏輯部分。

首先是將前面的定義的着色器加載、編譯以及創建、鏈接、激活着色器程序:

GLint vsh = initShader(vertexShader, GL_VERTEX_SHADER);
    GLint fsh = initShader(fragYUV420P, GL_FRAGMENT_SHADER);

    //創建渲染程序
    GLint program = glCreateProgram();
    if (program == 0) {
        LOGD("glCreateProgram failed");
        return;
    }

    //向渲染程序中加入着色器
    glAttachShader(program, vsh);
    glAttachShader(program, fsh);

    //鏈接程序
    glLinkProgram(program);
    GLint status = 0;
    glGetProgramiv(program, GL_LINK_STATUS, &status);
    if (status == 0) {
        LOGD("glLinkProgram failed");
        return;
    }
    LOGD("glLinkProgram success");
    //激活渲染程序
    glUseProgram(program);

其中initShader函數:

GLint initShader(const char *source, GLint type) {
    //創建shader
    GLint sh = glCreateShader(type);
    if (sh == 0) {
        LOGD("glCreateShader %d failed", type);
        return 0;
    }
    //加載shader
    glShaderSource(sh,
                   1,//shader數量
                   &source,
                   0);//代碼長度,傳0則讀到字符串結尾

    //編譯shader
    glCompileShader(sh);

    GLint status;
    glGetShaderiv(sh, GL_COMPILE_STATUS, &status);
    if (status == 0) {
        LOGD("glCompileShader %d failed", type);
        LOGD("source %s", source);
        return 0;
    }

    LOGD("glCompileShader %d success", type);
    return sh;
}

傳入頂點座標數組給頂點着色器:

//加入三維頂點數據。這裏就是整個屏幕的矩形。
    static float ver[] = {
            1.0f, -1.0f, 0.0f,
            -1.0f, -1.0f, 0.0f,
            1.0f, 1.0f, 0.0f,
            -1.0f, 1.0f, 0.0f
    };
	//獲取頂點着色器的aPosition屬性引用
    GLuint apos = static_cast<GLuint>(glGetAttribLocation(program, "aPosition"));
    glEnableVertexAttribArray(apos);
    //將頂點座標傳入頂點着色器的aPosition屬性
    //各個參數意義:apos:頂點着色器中aPosition變量的引用。3表示數組中三個數字表示一個頂點。GL_FLOAT表示數據類型是浮點數。
    //GL_FALSE表示不進行歸一化。0表示stride(跨距),在數組表示多種屬性的時候使用到,這裏因爲這有一個屬性,設置爲0即可。ver表示所傳入的頂點數組地址
    glVertexAttribPointer(apos, 3, GL_FLOAT, GL_FALSE, 0, ver);

(習慣了Java開發的同學恐怕看到這種代碼很不習慣吧??)

傳入紋理座標數組給頂點着色器:

//加入紋理座標數據,這裏是整個紋理。
    static float fragment[] = {
            1.0f, 0.0f,
            0.0f, 0.0f,
            1.0f, 1.0f,
            0.0f, 1.0f
    };
    ////將紋理座標數組傳入頂點着色器的aTextCoord屬性
    GLuint aTex = static_cast<GLuint>(glGetAttribLocation(program, "aTextCoord"));
    glEnableVertexAttribArray(aTex);
    //各個參數意義:aTex :頂點着色器中aTextCoord變量的引用。2表示數組中三個數字表示一個頂點。GL_FLOAT表示數據類型是浮點數。
    //GL_FALSE表示不進行歸一化。表示stride(跨距),在數組表示多種屬性的時候使用到,這裏因爲這有一個屬性,設置爲0即可。fragment表示所傳入的頂點數組地址
    glVertexAttribPointer(aTex, 2, GL_FLOAT, GL_FALSE, 0, fragment);

如果能把傳入頂點座標數組給頂點着色器理解,這一段就沒有什麼難度了。

接着是紋理對象的處理:

這裏要講一下幾個概念:紋理對象、紋理目標、紋理單元
1.紋理對象是我們創建的用來存儲紋理的顯存,在實際使用過程中使用的是創建後返回的紋理ID。
2.紋理目標可以簡單理解爲紋理的類型,比如指定是渲染2D還是3D等。
3.紋理單元:紋理的操作容器,有GL_TEXTURE0、GL_TEXTURE1、GL_TEXTURE2等,紋理單元的數量是有限的,最多16個。 所以在最多隻能同時操作16個紋理。可以簡單理解爲第幾層紋理。

創建紋理對象:

   //指定紋理變量在哪一層紋理單元渲染
   glUniform1i(glGetUniformLocation(program, "yTexture"), GL_TEXTURE0);
    glUniform1i(glGetUniformLocation(program, "uTexture"), GL_TEXTURE1);
    glUniform1i(glGetUniformLocation(program, "vTexture"), GL_TEXTURE2);
    //紋理ID
    GLuint texts[3] = {0};
    //創建3個紋理對象,並且得到各自的紋理ID。之後對紋理的操作就可以通過該紋理ID進行。
    glGenTextures(3, texts);

將紋理對象和相應的紋理目標進行綁定:

//yuv視頻寬高
int width = 640;
int height = 360;
//通過 glBindTexture 函數將紋理目標和以texts[0]爲ID的紋理對象綁定後,對紋理目標所進行的操作都反映到該紋理對象上
    glBindTexture(GL_TEXTURE_2D, texts[0]);
    //縮小的過濾器(關於過濾詳細可見 [紋理](https://learnopengl-cn.github.io/01%20Getting%20started/06%20Textures/))
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    //放大的過濾器
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    //設置紋理的格式和大小
    // 當前綁定的紋理對象就會被渲染上紋理
     glTexImage2D(GL_TEXTURE_2D,
                 0,//指定要Mipmap的等級
                 GL_LUMINANCE,//gpu內部格式,告訴OpenGL內部用什麼格式存儲和使用這個紋理數據。 亮度,灰度圖(這裏就是隻取一個亮度的顏色通道的意思,因這裏只取yuv其中一個分量)
                 width,//加載的紋理寬度。最好爲2的次冪
                 height,//加載的紋理高度。最好爲2的次冪
                 0,//紋理邊框
                 GL_LUMINANCE,//數據的像素格式 亮度,灰度圖
                 GL_UNSIGNED_BYTE,//一個像素點存儲的數據類型
                 NULL //紋理的數據(先不傳,等後面每一幀刷新的時候傳)
    );

這裏要注意視頻的寬高一定設置正確,不然渲染的數據就都是錯誤的。

這裏要說明下glTexImage2D第三個參數,告訴OpenGL內部用什麼格式存儲和使用這個紋理數據(一個像素包含多少個顏色成分,是否壓縮)。常用的常量如下:

在這裏插入圖片描述

這裏yuv三個分量的代碼都是一樣的,只是傳入的寬高不同,對於u和v來說,寬高各位視頻寬高的二分之一:

//設置紋理的格式和大小
    glTexImage2D(GL_TEXTURE_2D,
                 0,//細節基本 默認0
                 GL_LUMINANCE,//gpu內部格式 亮度,灰度圖(這裏就是隻取一個顏色通道的意思)
                 width / 2,
                 height / 2,//v數據數量爲屏幕的4分之1
                 0,//邊框
                 GL_LUMINANCE,//數據的像素格式 亮度,灰度圖
                 GL_UNSIGNED_BYTE,//像素點存儲的數據類型
                 NULL //紋理的數據(先不傳)
    );

爲什麼是width / 2,height / 2呢?還記得上文說過的yuv420p的採樣和存儲格式麼? YUV420P是2:1水平下采樣,2:1垂直下采樣,所以y分量數量等於視頻寬乘以高,u和v分量都是視頻寬/2乘以高/2。

從視頻文件中讀取yuv數據到內存中:

	unsigned char *buf[3] = {0};
    buf[0] = new unsigned char[width * height];//y
    buf[1] = new unsigned char[width * height / 4];//u
    buf[2] = new unsigned char[width * height / 4];//v
	//循環讀出每一幀
    for (int i = 0; i < 10000; ++i) {
        //讀一幀yuv420p數據
        if (feof(fp) == 0) {
        	//讀取y數據
            fread(buf[0], 1, width * height, fp);
            //讀取u數據
            fread(buf[1], 1, width * height / 4, fp);
            //讀取v數據
            fread(buf[2], 1, width * height / 4, fp);
        }

還是回顧剛纔敲黑板的地方,由圖可得yuv420p中,是先存儲視頻寬高個y元素,再存儲視頻寬乘以高/4個u,再存儲視頻寬乘以高/4個v,所以for循環中讀取一幀才按照yuv的順序和數量依次讀到內存的數組中。
在這裏插入圖片描述
在讀出一幀後,更新數據到紋理對象上。
buf[0]即y分量的數據渲染到紋理上:

//激活第一層紋理,綁定到創建的紋理
      
        glActiveTexture(GL_TEXTURE0);
        //綁定y對應的紋理
        glBindTexture(GL_TEXTURE_2D, texts[0]);
        //替換紋理,比重新使用glTexImage2D性能高多
        glTexSubImage2D(GL_TEXTURE_2D, 0,
                        0, 0,//相對原來的紋理的offset
                        width, height,//加載的紋理寬度、高度。最好爲2的次冪
                        GL_LUMINANCE, GL_UNSIGNED_BYTE,
                        buf[0]);

u和v也是一樣,只是寬高換爲width / 2, height / 2。

最後將畫面顯示出來:

glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
//窗口顯示,交換雙緩衝區
eglSwapBuffers(display, winSurface);

如此循環,就將每一幀渲染出來,也就播放了yuv視頻:

這裏我使用ffmpeg命令將《龍貓》中截取10秒的視頻轉化爲yuv,錄屏的gif不知爲何總是上傳不了,所以這裏只上傳了一張截圖 = = 。
在這裏插入圖片描述

雖然只是10秒的視頻,但是已經超過github的最大上傳量,所以視頻沒有上傳。各位如果需要可以自己用ffmpeg命令轉換任何一個格式支持視頻文件爲yuv420p格式來運行。

接觸音視頻開發領域時間不長,如有錯誤疏漏,請各位指正~

項目地址:YuvVideoPlayerDemo

參考文獻:

learnopengl
Video Rendering with 8-Bit YUV Formats
音視頻基礎知識—像素格式YUV
《OpenGl超級寶典 第五版》
Android OpenGL ES 視頻應用開發教程目錄
Android 自定義相機開發(三) —— 瞭解下EGL

原創不易,如果你覺得好,隨手點贊,也是對筆者的肯定~

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