GPU Image 詳解與框架原始碼分析
一、前言
這篇文章咱們來看一下 ofollow,noindex">cats-oss 的 android-gpuimage 。根據作者自己的解釋,該專案的創意來自於 GPUImage2" target="_blank" rel="nofollow,noindex">IOS GPUImage 。而GPU Image 的作用是利用 OpenGL 幫助我們實現圖片初級處理,像高斯模糊,亮度,飽和度,白平衡等一些基礎的濾鏡。另外,GPU Image 幫助我們搭建好了一個框架,使得我們可以忽略使用 Open GL 過程中的各種繁鎖的步驟,我們只要專注於自己的業務,通過繼承 GPUImageFilter 或者組合其他的 Filter 就可以實現我們自己需要的功能。例如應用於人像美容處理的美顏,磨皮,美白等功能。那麼,先來看看效果圖吧。

原圖

Invert濾鏡
當然,受限於作者的水平以及精力,文章不會對演算法的細節進行分析,而主要就是分析框架本身的架構以及邏輯。
二、基本應用
這裡主要是對官文的一個簡讀。
1.依賴
當前的最新版本是 2.0.3
repositories { jcenter() } dependencies { implementation 'jp.co.cyberagent.android:gpuimage:2.0.3' }
2.帶預覽介面
一般可以結合相機一起使用,以實現實時濾鏡功能
@Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity); Uri imageUri = ...; gpuImage = new GPUImage(this); gpuImage.setGLSurfaceView/">SurfaceView((GLSurfaceView) findViewById(R.id.surfaceView)); // this loads image on the current thread, should be run in a thread gpuImage.setImage(imageUri); gpuImage.setFilter(new GPUImageSepiaFilter()); // Later when image should be saved saved: gpuImage.saveToPictures("GPUImage", "ImageWithFilter.jpg", null); }
3.使用GPUImageView
GPUImageView 繼承自 FrameLayout,其他就主要就是個幫助類,幫助我們整合使用 GpuImageFilter 和 SurfaceView/TextureView
xml
<jp.co.cyberagent.android.gpuimage.GPUImageView android:id="@+id/gpuimageview" android:layout_width="match_parent" android:layout_height="match_parent" app:gpuimage_show_loading="false" app:gpuimage_surface_type="texture_view" /> <!-- surface_view or texture_view -->
java code
@Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity); Uri imageUri = ...; gpuImageView = findViewById(R.id.gpuimageview); gpuImageView.setImage(imageUri); // this loads image on the current thread, should be run in a thread gpuImageView.setFilter(new GPUImageSepiaFilter()); // Later when image should be saved saved: gpuImageView.saveToPictures("GPUImage", "ImageWithFilter.jpg", null); }
4.不帶預覽介面
和帶預覽介面是相對的,其專業的名稱是離屏渲染,後面在分析程式碼的時候會再詳情講解
public void onCreate(final Bundle savedInstanceState) { public void onCreate(final Bundle savedInstanceState) { Uri imageUri = ...; gpuImage = new GPUImage(context); gpuImage.setFilter(new GPUImageSobelEdgeDetection()); gpuImage.setImage(imageUri); gpuImage.saveToPictures("GPUImage", "ImageWithFilter.jpg", null); }
OpenGL 原生的使用方式真是十分的囉嗦,過程繁多。而 Android 官方也沒有出一個好用的 SDK 用以完善生態,減少開發者的工作。
三、原始碼分析
1.框架概覽
框架圖

image.png
上面是一個從輸入——處理——輸出的角度所繪製的一個框圖,雖然 GPUImage 所涉及的知識是 OpenGL 等一些較有難度的影象知識,但其封裝的框架相對來說是比較簡單的。如上圖所示,輸入可以是一個 Bitmap 或者 一個 YUV 格式(一般是相機原始資料格式)的資料,然後經由 GPUImage 模組中的 GPUImageRender 進行渲染處理,在渲染之前先由 GPUImageFilter 進行處理,然後才真正渲染到 GLSurfaceView/GLTextureView 上,也就是螢幕上。或者也可以通過離屏渲染將結果渲染到 Buffer 中,最後儲存到 Bitmap 中。
框架類圖

GPUImage Main.jpg
GPUImage可以看作是模組對外的介面,它封裝了主要的類 GPUImageRenderer及其渲染的一些屬性,而 GPUImageFilter 與 GLSurfaceView 均由外部傳入,並與GPUImageRenderer 建立起聯絡。
GPUImageRenderer其繼承自 Render 類,主要負責呼叫 GPUImageFilter 進行影象的處理,再渲染到 GLSurfaceView 中。而這裡所謂的處理,也就是通常所說的運用一些影象處理演算法,只不過其不是通過 CPU 進行運算而是通過 GPU 進行運算。
GPUImageFilter是所有 filter 的基類,其預設實現是不帶任何濾鏡效果。而其子類可以直接繼承自 GPUImageFilter 從而實現單一的濾鏡效果。或者也可以繼承如 GPUImageFilterGroup 實現多個濾鏡的效果。而關於如何組合,可以繼承類圖中如 GPUImage3x3TextureSamplingFilter 實現 3 張圖片紋理取樣的濾鏡效果。當然也可以自己定義組織規則。
通過上面的框架圖和框架類圖,對 GPUImage 應該有一個整體的認知了。接下來我們按照 帶預覽介面 這個 demo 的流程先來分析一下更細節的實現原理。
2.帶預覽介面的渲染實現
初始化——構建 GPUImage

GPUImage初始化.jpg
/** * Instantiates a new GPUImage object. * * @param context the context */ public GPUImage(final Context context) { if (!supportsOpenGLES2(context)) { throw new IllegalStateException("OpenGL ES 2.0 is not supported on this phone."); } this.context = context; filter = new GPUImageFilter(); renderer = new GPUImageRenderer(filter); }
GPUImage 的構建非常簡單,就是依次構建了 GPUImageFilter 和 GPUImageRender。GPUImageFilter 是所有 filter 的基類,它是不帶任何濾鏡效果的。同時它通過定義多個勾子方法來完成初始化,處理以及銷燬的生命週期。如下圖所示。

image.png
public GPUImageFilter() { this(NO_FILTER_VERTEX_SHADER, NO_FILTER_FRAGMENT_SHADER); } public GPUImageFilter(final String vertexShader, final String fragmentShader) { runOnDraw = new LinkedList<>(); this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; }
關於著色器指令碼,是一種 glsl 語言,風格類似於 c 語言,對此感興趣的可以參考一下相關的 wiki 。而這兩個著色器的作用分別是 OpenGL 流水線中用於計算頂點位置和給頂點上色的 2 個工序。對於完全沒有接觸過 OpenGL 的同學可能覺得這裡看不明白,先不用著急,這裡先有這個概念就可以了。
接著是建立 GPUImageRenderer,來看看其構造方法。
public GPUImageRenderer(final GPUImageFilter filter) { // 接收 filter this.filter = filter; // 建立 2 個任務佇列 runOnDraw = new LinkedList<>(); runOnDrawEnd = new LinkedList<>(); // 建立頂點 Buffer 並賦值 glCubeBuffer = ByteBuffer.allocateDirect(CUBE.length * 4) .order(ByteOrder.nativeOrder()) .asFloatBuffer(); glCubeBuffer.put(CUBE).position(0); // 建立紋理 Buffer glTextureBuffer = ByteBuffer.allocateDirect(TEXTURE_NO_ROTATION.length * 4) .order(ByteOrder.nativeOrder()) .asFloatBuffer(); // 設定旋轉方向 setRotation(Rotation.NORMAL, false, false); }
GPUImageRenderer 的構造方法主要是構建了自己的執行時環境。其中最主要的是建立頂點 Buffer,建立紋理 Buffer 以及設定旋轉方向。這裡的 Buffer 分配涉及到的是 Java 的 NI/O,其分配置的記憶體空間是在 native 層。而這裡 * 4 是因為 float 佔 4 個位元組。
先來看看 CUBE 的定義
public static final float CUBE[] = { -1.0f, -1.0f,//左下角座標 1.0f, -1.0f,//右下角座標 -1.0f, 1.0f,//左上角座標 1.0f, 1.0f,//右上角座標 };
這不是一堆沒有意義的數字,這裡其實是定義了一個 2 * 4 的頂點陣列,2 代表是 2 維的,即 2 維座標系中的某個點 (x,y);而 4 則代表是有 4 個頂點。再來看看這些數字的值,它們都在 -1 到 1 之間。這個就是與 OpenGL 中的眾多座標系相關了。OpenGL 的座標系是 3 維的,它是以原點(0,0,0) 為中心,並有 3 個不同的方向 (x,y,z) 軸所組成的。這裡所定義的頂點中,沒有 z 座標,即深度為 0。而之所以是在 -1 到 1 之間,是因為被歸一化了。OpenGL 在流水線中,在最後做 NDC 運算後,會將所有的座標都對映到 -1 到 1 之間。 如下是一個常見的 3 維座標系。

image.png
而我們的這裡定義的數字可以看成如下座標系。

image.png
最終我們會拿這 4 個頂點來構造出 2 個三角形,從而形成一個面。在這個形成的面上,會將圖片以紋理的形式貼在這個區域上。
再來看看紋理座標 TEXTURE_NO_ROTATION 以及其他旋轉角度的定義
public static final float TEXTURE_NO_ROTATION[] = { 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, }; public static final float TEXTURE_ROTATED_90[] = { 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, }; public static final float TEXTURE_ROTATED_180[] = { 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, }; public static final float TEXTURE_ROTATED_270[] = { 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, };
紋理坐又是另一個座標系,即紋理座標系。我們熟悉的是 Android 的螢幕座標系原點是在左上角的,而紋理座標系的原點是以紋理的左下角為原點。並且是在 0 到 1 之間。而不管原來的圖片寬高為多少,所有的座標都會被對映成 0 到 1 之間的數值。對比一下如下紋理座標系。當不進行任何旋轉時,那麼得到的座標就是 TEXTURE_NO_ROTATION,而當作逆時針旋轉 90 度時,得到的就是 TEXTURE_ROTATED_90。另外 2 個同理。

image.png
OpenGL 中的座標系比較多,短短几句是講不清楚的。這裡只是根據座標系的規則簡單的描述了頂點和紋理座標這些數值的由來。只做適當展開,不作詳細深究。後面有機會會再專門進行 OpenGL 座標系的講解。
接著往下看 setRotation(),其還有另外 2 個引數代表是否要進行橫向和眾向的翻轉,這與相機的角度和成像原理有關係,這裡先不深入。看看其進一步呼叫的 adjustImageScaling()
private void adjustImageScaling() { float outputWidth = this.outputWidth; float outputHeight = this.outputHeight; // 豎屏情況下 if (rotation == Rotation.ROTATION_270 || rotation == Rotation.ROTATION_90) { outputWidth = this.outputHeight; outputHeight = this.outputWidth; } // 這裡相當於是把圖片根據視口大小(簡單理解為 GLSurfaceView的大小)進行比例縮放 float ratio1 = outputWidth / imageWidth; float ratio2 = outputHeight / imageHeight; float ratioMax = Math.max(ratio1, ratio2); int imageWidthNew = Math.round(imageWidth * ratioMax); int imageHeightNew = Math.round(imageHeight * ratioMax); float ratioWidth = imageWidthNew / outputWidth; float ratioHeight = imageHeightNew / outputHeight; // 獲取頂點資料 float[] cube = CUBE; // 獲取對應角度的紋理座標,並根據翻轉引數進行相應的翻轉 float[] textureCords = TextureRotationUtil.getRotation(rotation, flipHorizontal, flipVertical); // 根據 scaleType 對紋理座標或者頂點座標進行計算 if (scaleType == GPUImage.ScaleType.CENTER_CROP) { float distHorizontal = (1 - 1 / ratioWidth) / 2; float distVertical = (1 - 1 / ratioHeight) / 2; textureCords = new float[]{ addDistance(textureCords[0], distHorizontal), addDistance(textureCords[1], distVertical), addDistance(textureCords[2], distHorizontal), addDistance(textureCords[3], distVertical), addDistance(textureCords[4], distHorizontal), addDistance(textureCords[5], distVertical), addDistance(textureCords[6], distHorizontal), addDistance(textureCords[7], distVertical), }; } else { cube = new float[]{ CUBE[0] / ratioHeight, CUBE[1] / ratioWidth, CUBE[2] / ratioHeight, CUBE[3] / ratioWidth, CUBE[4] / ratioHeight, CUBE[5] / ratioWidth, CUBE[6] / ratioHeight, CUBE[7] / ratioWidth, }; } // 最後把頂點座標和紋理座標送到相應的 buffer 中 glCubeBuffer.clear(); glCubeBuffer.put(cube).position(0); glTextureBuffer.clear(); glTextureBuffer.put(textureCords).position(0); }
假設這裡的 scaleType 是 CENTER_CROP,並假設圖片的寬高為 80 * 200,而視口的寬高為 100 * 200,那麼得到的效果如下圖所示——注意超出橙色線框外的影象是不可見的,這裡只是為了展示效果。

image.png
如果不是 CENTER_CROP,而是 CENTER_INSIDE,那麼是改變頂點的位置。效果圖如下。有興趣的同學也可以自己仔細的推導一下。

image.png
這裡最主的是通過 adjustImageScaling() 方法的計算,最終確定了頂點座標以及紋理座標,並送進了相應的 Buffer ,而這 2 個 Buffer 中的數字最終會被送到 OpenGL 的流水線中進行渲染。
建立與GLSurfaceView 的關聯——GPUImage#setGLSurfaceView()
/** * Sets the GLSurfaceView which will display the preview. * * @param view the GLSurfaceView */ public void setGLSurfaceView(final GLSurfaceView view) { surfaceType = SURFACE_TYPE_SURFACE_VIEW; glSurfaceView = view; glSurfaceView.setEGLContextClientVersion(2); glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0); glSurfaceView.getHolder().setFormat(PixelFormat.RGBA_8888); glSurfaceView.setRenderer(renderer); glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); glSurfaceView.requestRender(); }
該方法中設定了OpenGL 的版本,圖片格式,重新整理模式等,而最主要的是將 GPUImageRender 設定給了 GLSurfaceView,在 render 的 onDrawFrame() 勾子方法中將資料渲染到GLSurfaceView 的 Buffer 中。
GLSurfaceView 是 Android 自已定義的,除此之外,框架還定義了一個 GLTextureView,其繼承自 TextureView。它的主要功能就是在模仿 GLSurfaceView,建立一個GLThread然後不斷回撥 render 的 onDrawFrame() ,從而達到不斷重新整理 View 的目的。關於SurfaceView 和 TextureView,這裡稍微展開一下,有興趣的可以瞭解一下,不感興趣的也可以跳過:
SurfaceView是一個有自己獨立Surface的View,它的渲染可以放在單獨執行緒而不是主執行緒中。作為一個 view 在App 程序中它也是在 view hierachy 中的,但在系統的 WindowManagerService 以及 SurfaceFlinger 中,它是有自己的 WindowState 和 Surface 的,簡單理解就是有自己的畫布——Buffer。因為它是不作變形和動畫的。
TextureView跟普通的View一樣,在App程序中和系統的 WindowManagerService 以及 SurfaceFlinger 中都同屬一個 view hierachy、widnowstate 和 surface。由 4.0 引入,早期還是靠主執行緒來渲染,在 5.0 之後加入了渲染執行緒,才由渲染執行緒來專門渲染。當然,和普通 View 一樣,它是支援變形和動畫的。另外,還有更重要的一點是,它必須在支援硬體加速的 window 中進行渲染,否則就會是一片空白。
最後的 glSurfaceView.requestRender() 會喚醒執行緒進行後續的渲染。
設定/更新圖片源——GPUImage#setImage()/updatePreviewFrame()
設定圖片源,可以是直接設定一個圖片,圖片可以是 bitmap,檔案或者 URI。而其更常用的一個場景是相機的預覽幀——YUV原始資料。當然,YUV資料也要轉成通常所使用的 RGB 資料才能交給 Render 對其進行渲染。關於 YUV 請參考 YUV 資料格式詳解 和 Video Rendering with 8-Bit YUV Formats 。也可以看看下圖直觀的感受一下,“Y”表示明亮度(Luminance、Luma),“U”和“V”則是色度、濃度(Chrominance、Chroma)

image.png
不管是直接設定圖片,還是原始YUV資料,都要將其繫結到 OpenGL 中的紋理 ID 中去。以 onPreviewFrame 來看一看。
public void onPreviewFrame(final byte[] data, final int width, final int height) { if (glRgbBuffer == null) { glRgbBuffer = IntBuffer.allocate(width * height); } if (runOnDraw.isEmpty()) { runOnDraw(new Runnable() { @Override public void run() { // YUV 轉 RGB GPUImageNativeLibrary.YUVtoRBGA(data, width, height, glRgbBuffer.array()); // 載入紋理 glTextureId = OpenGlUtils.loadTexture(glRgbBuffer, width, height, glTextureId); if (imageWidth != width) { imageWidth = width; imageHeight = height; adjustImageScaling(); } } }); } }
GPUImageNativeLibrary.YUVtoRBGA() 就不看了,來看一看 OpenGlUtils.loadTexture()。
public static int loadTexture(final IntBuffer data, final int width, final int height, final int usedTexId) { int textures[] = new int[1]; if (usedTexId == NO_TEXTURE) { // 產生紋理 ID 陣列,這裡取樣器只有一個,因此 1 個元素就夠了 GLES20.glGenTextures(1, textures, 0); // 繫結紋理取樣器到紋理 ID GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0]); // 設定取樣的方式 GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); // 將圖片 buffer 送進 OpenGL 的紋理取樣器中 GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, 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, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, data); textures[0] = usedTexId; } return textures[0]; }
這個方法的其他細節請參考註釋即可。通過這個方法的主要目的就是將圖片送進 OpenGL 的 sample2D 取樣器中,此 Sample2D 取樣器是在 片元 Shader 指令碼中定義的。如下定義中的 inputImageTexture。
public static final String NO_FILTER_FRAGMENT_SHADER = "" + "varying highp vec2 textureCoordinate;\n" + " \n" + "uniform sampler2D inputImageTexture;\n" + " \n" + "void main()\n" + "{\n" + "gl_FragColor = texture2D(inputImageTexture, textureCoordinate);\n" + "}";
設定 Filter——GPUImage#setImageFilter()
/** * Sets the filter which should be applied to the image which was (or will * be) set by setImage(...). * * @param filter the new filter */ public void setFilter(final GPUImageFilter filter) { this.filter = filter; renderer.setFilter(this.filter); requestRender(); }
呼叫了 render 的 setFilter,並再次發起渲染請求。來進一步看看。
public void setFilter(final GPUImageFilter filter) { runOnDraw(new Runnable() { @Override public void run() { final GPUImageFilter oldFilter = GPUImageRenderer.this.filter; GPUImageRenderer.this.filter = filter; // 如果存在有舊的 filter,則先銷燬 if (oldFilter != null) { oldFilter.destroy(); } // 然後呼叫 fiter.ifNeedInit() 進行初始化 GPUImageRenderer.this.filter.ifNeedInit(); // 設定 OpenGL 上下文所使用的程式 ID GLES20.glUseProgram(GPUImageRenderer.this.filter.getProgram()); // 更新視口大小 GPUImageRenderer.this.filter.onOutputSizeChanged(outputWidth, outputHeight); } }); }
這裡的主要過程就對應了前面 GPUImageFilter 生命週期的流程圖。其首先判斷是否有舊的 filter,如果有則先銷燬。銷燬很簡單,主要就是通過 GLES20.glDeleteProgram(glProgId) 銷燬 OpenGL 當前執行的程式 ID,然後再通過勾子方法 onDestroy() 通知 GPUImageFilter 的子類釋放其他所用到的資源。這裡重點需要了解一下的是其初始化的過程。
ififNeedInit() 主要就是呼叫了 onInit()
public void onInit() { glProgId = OpenGlUtils.loadProgram(vertexShader, fragmentShader); glAttribPosition = GLES20.glGetAttribLocation(glProgId, "position"); glUniformTexture = GLES20.glGetUniformLocation(glProgId, "inputImageTexture"); glAttribTextureCoordinate = GLES20.glGetAttribLocation(glProgId, "inputTextureCoordinate"); isInitialized = true; }
建立程式ID,獲取 頂點位置屬性 "position",紋理座標屬性"inputTextureCoordinate",統一變數"inputImageTexture"。這裡主要是 loadProgram() 需要說一下,其主要完成的功能便是載入頂點以及片元著色器,然後建立程式,附加著色器,最後連結程式。這些過程都是 OpenGL 程式設計過程中所必須經歷的步驟,這裡只稍做了解即可。為了文章的完整性,這裡也將相關的程式碼貼出來。
public static int loadProgram(final String strVSource, final String strFSource) { int iVShader; int iFShader; int iProgId; int[] link = new int[1]; iVShader = loadShader(strVSource, GLES20.GL_VERTEX_SHADER); if (iVShader == 0) { Log.d("Load Program", "Vertex Shader Failed"); return 0; } iFShader = loadShader(strFSource, GLES20.GL_FRAGMENT_SHADER); if (iFShader == 0) { Log.d("Load Program", "Fragment Shader Failed"); return 0; } iProgId = GLES20.glCreateProgram(); GLES20.glAttachShader(iProgId, iVShader); GLES20.glAttachShader(iProgId, iFShader); GLES20.glLinkProgram(iProgId); GLES20.glGetProgramiv(iProgId, GLES20.GL_LINK_STATUS, link, 0); if (link[0] <= 0) { Log.d("Load Program", "Linking Failed"); return 0; } GLES20.glDeleteShader(iVShader); GLES20.glDeleteShader(iFShader); return iProgId; }
public static int loadShader(final String strSource, final int iType) { int[] compiled = new int[1]; int iShader = GLES20.glCreateShader(iType); GLES20.glShaderSource(iShader, strSource); GLES20.glCompileShader(iShader); GLES20.glGetShaderiv(iShader, GLES20.GL_COMPILE_STATUS, compiled, 0); if (compiled[0] == 0) { Log.d("Load Shader Failed", "Compilation\n" + GLES20.glGetShaderInfoLog(iShader)); return 0; } return iShader; }
至此,可以說用來渲染圖片的環境是已經建立好了。如確定頂點座標,縮放方式,建立OpenGL的渲染環境等等。下面就看如何繪製出來了。
渲染——渲染 Filter
前面在介紹 Render 的時候有講過,GLSurfaceView 就是通過 GLThread 不斷回撥 render 的勾子方法 onDrawFrame() 來達到重新整理 view 的目的。那麼我們來看看 GPUImageRenderer 的 onDrawFrame()。
@Override public void onDrawFrame(final GL10 gl) { GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); runAll(runOnDraw); filter.onDraw(glTextureId, glCubeBuffer, glTextureBuffer); runAll(runOnDrawEnd); if (surfaceTexture != null) { surfaceTexture.updateTexImage(); } }
其實應該能想得到,其最主要的就是通過呼叫 filter 的 onDraw() 進行渲染。
public void onDraw(final int textureId, final FloatBuffer cubeBuffer, final FloatBuffer textureBuffer) { // 激程式 ID GLES20.glUseProgram(glProgId); runPendingOnDrawTasks(); if (!isInitialized) { return; } // 將頂點 buffer 的資料送給屬性 "position",並使能屬性 cubeBuffer.position(0); // 下面的 2 表示每個點的 size 大小,即這裡的一個座標只需要取 2 個表示 (x,y) 即可。如果為 3 則表示 (x,y,z) GLES20.glVertexAttribPointer(glAttribPosition, 2, GLES20.GL_FLOAT, false, 0, cubeBuffer); GLES20.glEnableVertexAttribArray(glAttribPosition); // 將紋理座標 buffer 的資料送給屬性 "inputTextureCoordinate",並使能屬性 textureBuffer.position(0); GLES20.glVertexAttribPointer(glAttribTextureCoordinate, 2, GLES20.GL_FLOAT, false, 0, textureBuffer); GLES20.glEnableVertexAttribArray(glAttribTextureCoordinate); if (textureId != OpenGlUtils.NO_TEXTURE) { // 啟用,繫結紋理,並指定取樣器 "inputImageTexture" 為 0 號紋理 GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); GLES20.glUniform1i(glUniformTexture, 0); } onDrawArraysPre(); // 繪製 3 角形 GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); GLES20.glDisableVertexAttribArray(glAttribPosition); GLES20.glDisableVertexAttribArray(glAttribTextureCoordinate); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); }
渲染的過程就是 OpenGL 方法的一些呼叫,其中的意思也都在程式碼裡增加了註釋說明。其子類 Filter 也都採用這個 onDraw() 進行繪製。而決定每個 filter 渲染出什麼樣的濾鏡效果就都在其定義的頂點著色器和片元著色器裡了。
至此,將圖片經 GPUImageFilter 渲染到 GLSurfaceView 上的過程已經分析完了。如前面所說,有了 GPUImage 這個框架,就不需要我們去處理 OpenGL 裡面的各種繁瑣的細節了。一般的,我們只需要寫好我們自己的著色器,剩下的就都可以交給 GPUImage 來完成了。
3.離屏渲染
所謂離屏渲染,就是將Render渲染出來的圖片不送進 GLSurfaceView,而儲存在特定的 Buffer 中。下面看看它的時序圖。

離屏渲染.jpg
初始化離屏渲染的環境
其中的 1 - 4 步比較簡單,就不展開了。從 getBitmapWithFilterApplied () 開始。
/** * Gets the given bitmap with current filter applied as a Bitmap. * * @param bitmapthe bitmap on which the current filter should be applied * @param recycle recycle the bitmap or not. * @return the bitmap with filter applied */ public Bitmap getBitmapWithFilterApplied(final Bitmap bitmap, boolean recycle) { ...... GPUImageRenderer renderer = new GPUImageRenderer(filter); renderer.setRotation(Rotation.NORMAL, this.renderer.isFlippedHorizontally(), this.renderer.isFlippedVertically()); renderer.setScaleType(scaleType); PixelBuffer buffer = new PixelBuffer(bitmap.getWidth(), bitmap.getHeight()); buffer.setRenderer(renderer); renderer.setImageBitmap(bitmap, recycle); Bitmap result = buffer.getBitmap(); filter.destroy(); renderer.deleteImage(); buffer.destroy(); this.renderer.setFilter(filter); if (currentBitmap != null) { this.renderer.setImageBitmap(currentBitmap, false); } requestRender(); return result; }
省略的部分與 GLSurfaceView 相關,主要主是銷燬的相關工作。構造 GPUImageRenderer 前面也分析過了。這裡主要只分析 PixelBuffer 相關的呼叫。首先看看其建構函式。
public PixelBuffer(final int width, final int height) { this.width = width; this.height = height; int[] version = new int[2]; int[] attribList = new int[]{ EGL_WIDTH, this.width, EGL_HEIGHT, this.height, EGL_NONE }; // No error checking performed, minimum required code to elucidate logic // 建立 egl egl10 = (EGL10) EGLContext.getEGL(); // 獲取 default_display eglDisplay = egl10.eglGetDisplay(EGL_DEFAULT_DISPLAY); egl10.eglInitialize(eglDisplay, version); eglConfig = chooseConfig(); // Choosing a config is a little more int EGL_CONTEXT_CLIENT_VERSION = 0x3098; int[] attrib_list = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE }; // 建立上下文 eglContext = egl10.eglCreateContext(eglDisplay, eglConfig, EGL_NO_CONTEXT, attrib_list); // 在視訊記憶體中開闢一個 Buffer,渲染後的圖片將存放在這裡 eglSurface = egl10.eglCreatePbufferSurface(eglDisplay, eglConfig, attribList); egl10.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext); gl10 = (GL10) eglContext.getGL(); // Record thread owner of OpenGL context mThreadOwner = Thread.currentThread().getName(); }
關鍵過程在註釋中都有新增。這裡主要關注的是 eglCreatePbufferSurface() 的呼叫,其主要作用就是在視訊記憶體中開闢一個 buffer,並不關聯任何螢幕上的 window。那與之對應的 GLSurfaceView 是否有在螢幕的 window 上開闢一個 buffer 呢。
public EGLSurface createWindowSurface(EGL10 egl, EGLDisplay display, EGLConfig config, Object nativeWindow) { EGLSurface result = null; try { result = egl.eglCreateWindowSurface(display, config, nativeWindow, null); } catch (IllegalArgumentException e) { ...... } return result; }
如 GLSurfaceView 中建立 EGLSurface 的程式碼所示,果然是有的,只不過它呼叫的是另一個方法 eglCreateWindowSurface()。這裡所傳的引數裡需要注意的是nativeWindow,它其實就是 SurfaceHolder。
到這裡也就建立好了離屏渲染所需要的環境,接著與之前一樣,給 GPUImageRenderer 設定圖片以及 Filter 並作好相關渲染準備。
獲取渲染結果
先呼叫 getBitmap() ,該方法中會進一步呼叫 render 的 onDrawFrame,從而使得圖片按照 filter 所希望的效果將圖片渲染到 PixelBuffer 中所建立的 EGLSurface 中。然後呼叫 convertToBitmap() 方法將EGLSurface 中的 buffer 中的內容轉換成 bitmap。
convertToBitmap() 只是一個簡單的呼叫,其進一步呼叫了 native 函式 GPUImageNativeLibrary.adjustBitmap(bitmap) 來真正執行轉換的操作。
JNIEXPORT void JNICALL Java_jp_co_cyberagent_android_gpuimage_GPUImageNativeLibrary_adjustBitmap(JNIEnv *jenv, jclass thiz, jobject src) { unsigned char *srcByteBuffer; int result = 0; int i, j; // 宣告一個 AndroidBitmapInfo 結構 AndroidBitmapInfo srcInfo; // 從圖片中獲取 info result = AndroidBitmap_getInfo(jenv, src, &srcInfo); if (result != ANDROID_BITMAP_RESULT_SUCCESS) { return; } // 將圖片 src 的資料指標賦值給 srcByteBuffer result = AndroidBitmap_lockPixels(jenv, src, (void **) &srcByteBuffer); if (result != ANDROID_BITMAP_RESULT_SUCCESS) { return; } int width = srcInfo.width; int height = srcInfo.height; // 從當前 EGL 執行環境中讀取圖片資料並儲存在 srcByteBuffer 中,也就儲存到了位圖裡面了 glReadPixels(0, 0, srcInfo.width, srcInfo.height, GL_RGBA, GL_UNSIGNED_BYTE, srcByteBuffer); int *pIntBuffer = (int *) srcByteBuffer; // OpenGL和Android的Bitmap色彩空間不一致,這裡需要做轉換。以中間為基線進行對調。 for (i = 0; i < height / 2; i++) { for (j = 0; j < width; j++) { int temp = pIntBuffer[(height - i - 1) * width + j]; pIntBuffer[(height - i - 1) * width + j] = pIntBuffer[i * width + j]; pIntBuffer[i * width + j] = temp; } } AndroidBitmap_unlockPixels(jenv, src); }
這段程式碼可能有些是似曾相識的。當我們在完成截圖功能時,如果碰到有 video 的時候,截出來是黑的。有很多大神提供實現工具,而其內部的原理就是這個,即讀取當前上下文的 buffer 中的圖片資料,然後儲存到 bitmap 或者 建立 bitmap。由於在 OpenGL 的 buffer 中其順序是 左上 到 右下,而圖片紋理的順序是 左下 到 右上。因此需要以中間為基準將資料進行對調。
以上,便是離屏渲染的大致分析。
四、後記
同樣感謝你能讀到此文章,也希望你能有所收穫。當然,對於 GPUImage 的分析與閱讀需要有一定的 OpenGL 的基礎,不然會覺得裡面的概念繁多而且也比較抽象。另外,文章主要只是分析了 GPUImage 使用 filter 進行介面渲染或者離屏渲染過程的一個解讀。由於我在圖形影象領域也只是一個稍微入了門的小菜鳥,對於影象處理演算法更是知之甚少,所以對於 Filter 的具體演算法實現沒有進行分析。對於文中的分析,如存在錯誤或者有不清楚的地方,也歡迎留言討論,將不勝感激。