OpenGL 之 GPUImage 原始碼分析

glumes發表於2018-09-10

GPUImage 是 iOS 上一個基於 OpenGL 進行影象處理的開源框架,後來有人借鑑它的想法實現了一個 Android 版本的 GPUImage ,本文也主要對 Android 版本的 GPUImage 進行分析。

概要

在 GPUImage 中既有對影象進行處理的,也有對相機內容進行處理的,這裡主要以相機處理為例進行分析。

大致會分為三個部分:

  • 相機資料的採集
  • OpenGL 對影象的處理與顯示
  • 相機的拍攝

相機資料採集

相機資料採集實際上就是把相機的影象資料轉換成 OpenGL 中的紋理。

在相機的業務開發中,會給相機設定 PreviewCallback 回撥方法,只要相機處於預覽階段,這個回撥就會被重複呼叫,返回當前預覽幀的內容。

	camera.setPreviewCallback(GPUImageRenderer.this);
    camera.startPreview();
複製程式碼

預設情況下,相機返回的資料是 NV21 格式,也就是 YCbCr_420_SP 格式,而 OpenGL 使用的紋理是 RGB 格式,所以在每一次的回撥方法中需要將 YUV 格式的資料轉換成 RGB 格式資料。

GPUImageNativeLibrary.YUVtoRBGA(data, previewSize.width, previewSize.height,
						     mGLRgbBuffer.array());
複製程式碼

有了影象的 RGB 資料,就可以使用 glGenTextures 生成紋理,並用 glTexImage2D 方法將影象資料作為紋理。

另外,如果紋理已經生成了,當再有影象資料過來時,只需要更新資料就好了,無需重複建立紋理。

	// 根據影象資料載入紋理
    public static int loadTexture(final IntBuffer data, final Size size, final int usedTexId) {
        int textures[] = new int[1];
        if (usedTexId == NO_TEXTURE) {
            GLES20.glGenTextures(1, textures, 0);
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0]);
	        // 省略部分程式碼
            GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, size.width, size.height,
                    0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, data);
        } else {
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, usedTexId);
            // 更新紋理資料就好,無需重複建立紋理
            GLES20.glTexSubImage2D(GLES20.GL_TEXTURE_2D, 0, 0, 0, size.width,
                    size.height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, data);
            textures[0] = usedTexId;
        }
        return textures[0];
    }
複製程式碼

通過在 PreviewCallback 回撥方法中的操作,就完成了將影象資料轉換為 OpenGL 的紋理。

接下來就是如何將紋理資料進行處理,並且顯示到螢幕上。

在相機資料採集中,還有一些小的細節問題,比如相機前置與後置攝像頭的左右映象翻轉問題。

對於前置攝像頭,再把感測器內容作為紋理顯示時,前置攝像頭要做一個左右的翻轉處理,因為我們看到的是一個映象內容,符合正常的自拍流程。

在 GPUImage 的 TextureRotationUtil 類中有定義了紋理座標,這些紋理座標系的原點不是位於左下角進行定義的,而是位於左上角。

如果以左下角為紋理座標系的座標原點,那麼除了要將紋理座標向右順時針旋轉 90° 之外,還需要進行上下翻轉才行,至於為什麼要向右順時針旋轉 90° ,參考這篇文章:

Android 相機開發中的尺寸和方向問題

當我們把紋理座標以左上角為原點,並相對於頂點座標順時針旋轉 90 ° 之後,才能夠正常的顯示影象:

	// 頂點座標
    static final float CUBE[] = {
            -1.0f, -1.0f,
            1.0f, -1.0f,
            -1.0f, 1.0f,
            1.0f, 1.0f,
    };
    // 以左上角為原點,並相對於頂點座標順時針旋轉 90° 後的紋理座標
    public static final float TEXTURE_ROTATED_90[] = {
            1.0f, 1.0f,
            1.0f, 0.0f,
            0.0f, 1.0f,
            0.0f, 0.0f,
    };
複製程式碼

影象處理與顯示

在有了紋理之後,需要明確的是,這個紋理就是相機採集到的影象內容,我們要將紋理繪製到螢幕上,實際上是繪製一個矩形,然後紋理是貼在這個矩形上的。

所以,這裡可以回顧一下 OpenGL 是如何繪製矩形的,並且將紋理貼到矩形上。

OpenGL 學習系列---紋理

在 GPUImage 中,GPUImageFilter 類就完成了上述的操作,它是 OpenGL 中所有濾鏡的基類。

解析 GPUImageFilter 的程式碼實現:

在 GPUImageFilter 的構造方法中會確定好需要使用的頂點著色器和片段著色器指令碼內容。

init 方法中會呼叫 onInit 方法和 onInitialized 方法。

  • onInit 方法會建立 OpenGL 中的 Program,並且會繫結到著色器指令碼中宣告的 attributeuniform 變數欄位。
  • onInitialized 方法會給一些 uniform 欄位變數賦值,在 GPUImageFilter 類中還對不同型別的變數賦值進行了對應的方法,比如對 float 變數:
    protected void setFloat(final int location, final float floatValue) {
        runOnDraw(new Runnable() {
            @Override
            public void run() {
                GLES20.glUniform1f(location, floatValue);
            }
        });
    }
複製程式碼

在 onDraw 方法中就是執行具體的繪製了,在繪製的時候會執行 runPendingOnDrawTasks 方法,這是因為我們在 init 方法去中給著色器語言中的變數賦值,並沒有立即生效,而是新增到了一個連結串列中,所以需要把連結串列中的任務執行完了才接著執行繪製。

    public void onDraw(final int textureId, final FloatBuffer cubeBuffer,
                       final FloatBuffer textureBuffer) {
        GLES20.glUseProgram(mGLProgId);
        // 執行賦值的任務
        runPendingOnDrawTasks();
		// 頂點和紋理座標資料
        GLES20.glVertexAttribPointer(mGLAttribPosition, 2, GLES20.GL_FLOAT, false, 0, cubeBuffer);
        GLES20.glEnableVertexAttribArray(mGLAttribPosition);
        GLES20.glVertexAttribPointer(mGLAttribTextureCoordinate, 2, GLES20.GL_FLOAT, false, 0, textureBuffer);
        GLES20.glEnableVertexAttribArray(mGLAttribTextureCoordinate);
		// 在繪製前的最後一波操作
        onDrawArraysPre();
        // 最終繪製
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }
複製程式碼

在繪製時,還需要給頂點座標賦值,給紋理座標賦值,GPUImageFilter 並沒有去管理頂點座標和紋理座標,而是通過傳遞引數的形式,這樣就不用去處理在前置攝像頭與後置前攝像頭、手機豎立放置與橫屏放置時的關係了。

在執行具體的 glDrawArrays 方法之前,還提供了一個 onDrawArraysPre 方法,在這個方法裡面還可以執行繪製前的最後一波操作,在某些濾鏡的實現中有用到了。

最後才是 glDrawArrays 方法去完成繪製。

當我們不需要 GPUImageFilter 進行繪製時,需要將它銷燬掉,在 destroy 方法去進行銷燬,並且提供 onDestory 方法去為某些濾鏡提供自定義的銷燬。

    public final void destroy() {
        mIsInitialized = false;
        GLES20.glDeleteProgram(mGLProgId);
        onDestroy();
    }

    public void onDestroy() {
    }
複製程式碼

在 GPUImageFilter 方法中定義了片段著色器指令碼,這個指令碼是將影象內容原樣貼到了矩形上,並沒有做特殊的影象處理操作。

而其他濾鏡中,更改了著色器指令碼,也就會對影象進行其他的處理,在整個 GPUImage 專案中,最精華的也就是那些著色器指令碼內容了,如何通過著色器去做影象處理又是一門高深的學問了~~~

解析 GPUImageFilterGroup 的程式碼實現

當想要對影象進行多次處理時,就得考慮使用 GPUImageFilterGroup 了。

GPUImageFilterGroup 繼承自 GPUImageFilter, 顧名思義就是一系列 GPUImageFilter 濾鏡的組合,可以把它類比為 ViewGroup ,ViewGroup 即可以包含 View ,也可以包含 ViewGroup ,同樣 GPUImageFilterGroup 即可以包含 GPUImageFilter,也可以包含 GPUImageFilterGroup。

在用 GPUImageFilterGroup 進行繪製時,需要把所有的濾鏡內容都進行一遍繪製,而對於 GPUImageFilterGroup 包含 GPUImageFilterGroup 的情況,就需要把子 GPUImageFilterGroup 內的濾鏡內容拆分出來,最終是用 mMergedFilters 變數表示所有非 GPUImageFilterGroup 型別的 GPUImageFilter 。

	// 拿到所有非 GPUImageFilterGroup 的 GPUImageFilter
    public void updateMergedFilters() {
        List<GPUImageFilter> filters;
        for (GPUImageFilter filter : mFilters) {
	        // 如果濾鏡是 GPUImageFilterGroup 型別,就把它拆分了
            if (filter instanceof GPUImageFilterGroup) {
	            // 遞迴呼叫 updateMergedFilters 方法去拆分
                ((GPUImageFilterGroup) filter).updateMergedFilters();
                // 拿到所有非 GPUImageFilterGroup 的 GPUImageFilter
                filters = ((GPUImageFilterGroup) filter).getMergedFilters();
                if (filters == null || filters.isEmpty())
                    continue;
                // 把 GPUImageFilter 新增到 mMergedFilters 中
                mMergedFilters.addAll(filters);
                continue;
            }
            // 如果是非 GPUImageFilterGroup 直接新增了
            mMergedFilters.add(filter);
        }
    }
複製程式碼

在 GPUImageFilterGroup 執行具體的繪製之前,還建立了和濾鏡數量一樣多的 FrameBuffer 幀緩衝和 Texture 紋理。

		// 遍歷時,選擇 mMergedFilters 的長度,因為 mMergedFilters 裡面才是儲存的所有的 濾鏡的長度。
        if (mMergedFilters != null && mMergedFilters.size() > 0) {
            size = mMergedFilters.size();
            // FrameBuffer 幀緩衝數量
            mFrameBuffers = new int[size - 1];
            // 紋理數量
            mFrameBufferTextures = new int[size - 1];

            for (int i = 0; i < size - 1; i++) {
				// 生成 FrameBuffer 幀緩衝
                GLES20.glGenFramebuffers(1, mFrameBuffers, i);
                // 生成紋理
                GLES20.glGenTextures(1, mFrameBufferTextures, i);
                GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mFrameBufferTextures[i]);
                GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0,
                        GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);
                // 省略部分程式碼
                GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[i]);
                // 紋理繫結到幀緩衝上
                GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
                        GLES20.GL_TEXTURE_2D, mFrameBufferTextures[i], 0);
                // 省略部分程式碼
            }
        }
複製程式碼

如果對 FrameBuffer 的使用不熟悉的話,請參考這篇文章:

OpenGL 之 幀緩衝 使用實踐

        if (mMergedFilters != null) {
            int size = mMergedFilters.size();
            // 相機原始影象轉換的紋理 ID
            int previousTexture = textureId;
            for (int i = 0; i < size; i++) {
                GPUImageFilter filter = mMergedFilters.get(i);
                boolean isNotLast = i < size - 1;
                // 如果不是最後一個濾鏡,繪製到 FrameBuffer 上,如果是最後一個,就繪製到了螢幕上
                if (isNotLast) {
                    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[i]);
                    GLES20.glClearColor(0, 0, 0, 0);
                }
				// 濾鏡繪製程式碼
                if (i == 0) {
	                // 第一個濾鏡繪製使用相機的原始影象紋理 ID 和引數傳遞過來的頂點以及紋理座標
                    filter.onDraw(previousTexture, cubeBuffer, textureBuffer);
                } else if (i == size - 1) {
	                // 
                    filter.onDraw(previousTexture, mGLCubeBuffer, (size % 2 == 0) ? mGLTextureFlipBuffer : mGLTextureBuffer);
                } else {
	                // 中間的濾鏡繪製在之前紋理基礎上繼續繪製,使用 mGLTextureBuffer 紋理座標
                    filter.onDraw(previousTexture, mGLCubeBuffer, mGLTextureBuffer);
                }

                if (isNotLast) {
	                // 繫結到螢幕上
                    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
                    previousTexture = mFrameBufferTextures[i];
                }
            }
        }
複製程式碼

在執行具體的繪製時,只要不是最後一個濾鏡,那麼就會先繫結到 FrameBuffer 上,然後在 FrameBuffer 上進行繪製,這時繪製是繪製到了 FrameBuffer 繫結的紋理上,繪製結束後再接著解綁,繫結到螢幕上。

如果是最後一個濾鏡,那麼就不用繫結到 FrameBuffer 上了,直接繪製到螢幕上即可。

在這裡有個細節,就是如下的程式碼:

    filter.onDraw(previousTexture, mGLCubeBuffer, (size % 2 == 0) ? mGLTextureFlipBuffer : mGLTextureBuffer);
複製程式碼

如果是最後一個濾鏡,並且濾鏡個數為偶數,則使用 mGLTextureFlipBuffer 的紋理座標,否則使用 mGLTextureBuffer 的紋理座標。

// 對應的紋理座標為 TEXTURE_NO_ROTATION
 mGLTextureBuffer.put(TEXTURE_NO_ROTATION).position(0);

// 對應的紋理座標為 TEXTURE_NO_ROTATION,並且 true 的參數列示進行垂直上下翻轉
float[] flipTexture = TextureRotationUtil.getRotation(Rotation.NORMAL, false, true);
mGLTextureFlipBuffer.put(flipTexture).position(0);
複製程式碼

在第一個濾鏡繪製時,使用的是引數傳遞過來的頂點座標和紋理座標,中間部分的濾鏡使用的是 mGLTextureBuffer 紋理座標,它對應的紋理座標陣列為 TEXTURE_NO_ROTATION

在前面講到過,GPUImage 的紋理座標原點是位於左上角的,所以使用 TEXTURE_NO_ROTATION 的紋理座標實質上是將影象進行了上下翻轉,兩次呼叫TEXTURE_NO_ROTATION紋理座標時,又將影象復原了,這也就可以解釋為什麼濾鏡個數為偶數時,需要使用 mGLTextureFlipBuffer 紋理座標將影象再進行一次翻轉,而 mGLTextureBuffer 紋理座標不需要了。

當明白了 GPUImageFilter 和 GPUImageFilterGroup 的實現之後,再去看具體的 Renderer 的程式碼就明瞭多了。

onSurfaceCreatedonSurfaceChanged 方法中分別對濾鏡進行初始化以及設定寬、高,在 onDrawFrame 方法中呼叫具體的繪製。

當切換濾鏡時,先將上一個濾鏡銷燬掉,然後初始化新的濾鏡並設定寬、高。

				final GPUImageFilter oldFilter = mFilter;
                mFilter = filter;
                if (oldFilter != null) {
                    oldFilter.destroy();
                }
                mFilter.init();
                GLES20.glUseProgram(mFilter.getProgram());
                mFilter.onOutputSizeChanged(mOutputWidth, mOutputHeight);
複製程式碼

影象拍攝儲存處理

在 GPUImage 中相機的拍攝是呼叫 Camera 的 takePicture 方法,在該方法中返回相機採集的原始影象資料,然後再對該資料進行一遍濾鏡處理後並儲存。

呼叫的最後都是通過 glReadPixels 方法將處理後的影象讀取出來,並儲存為 Bitmap 。

    private void convertToBitmap() {
        int[] iat = new int[mWidth * mHeight];
        IntBuffer ib = IntBuffer.allocate(mWidth * mHeight);
        mGL.glReadPixels(0, 0, mWidth, mHeight, GL_RGBA, GL_UNSIGNED_BYTE, ib);
        int[] ia = ib.array();
		// glReadPixels 讀取的內容是上下翻轉的,要處理一下
        for (int i = 0; i < mHeight; i++) {
            for (int j = 0; j < mWidth; j++) {
                iat[(mHeight - i - 1) * mWidth + j] = ia[i * mWidth + j];
            }
        }
        mBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
        mBitmap.copyPixelsFromBuffer(IntBuffer.wrap(iat));
    }
複製程式碼

小結

對 GPUImage 的分析以及濾鏡架構的設計大致就是這樣了,這些都還不是它的精華啦,重要的還是它的那些著色器指令碼,從那些著色器指令碼中學會如果通過 GLSL 去實現影象處理演算法。

對 OpenGL 感興趣的朋友,歡迎關注微信公眾號:【紙上淺談】,獲得最新文章推送~~~

掃碼關注

相關文章