這篇文章將給大家講解如何在Android系統上基於OpenGL ES 2.0來實現相機實時圖片塗鴉效果,所塗內容跟隨人臉出現、消失、移動、旋轉及縮放,在這裡,我們假設您:
在開始講解之前,先簡要介紹一下OpenGL ES 2.0的一些必要的基礎知識,方便對文章的理解。
基礎知識一:OpenGL的坐標系為方便講解,以下只講解二維的情況,在OpenGL使用中,我們主要會涉及到以下三個坐標系:
屏幕坐標系
屏幕坐標系就是我們手機屏幕的坐標系,以像素為單位,左上角是坐標系原點,即(0,0),x的取值範圍為0~屏幕寬度,y的取值範圍為0~屏幕高度,詳見下圖:
世界坐標系
它是OpenGL內部的繪圖區域的坐標系,x、y的取值範圍都是-1~1,坐標原點在繪圖區域的中心,見下圖,假設綠色區域是一個OpenGL的繪圖區域:
紋理坐標系
就是紋理本身的坐標系,坐標原點在紋理的左上角,s(x)、t(y)的取值範圍都是0~1,見下圖,假設
黃色區域是一個紋理貼圖:
Shader就是OpenGL的著色器,分為頂點著色器(Vertex Shader)和片元著色器(Fragment Shader),這兩個著色器都由一段小程序來實現,用OpenGL Shading Language編寫,語法類似C語言,使用時將相應shader程序代碼載入OpenGL即可。
OpenGL在把點繪到屏幕上之前,點會依次經過頂點著色器和片元著色器的處理。頂點著色器是處理頂點的位置、大小、旋轉等操作,比如希望顯示一個經過順時針旋轉90度、並放大1倍的紋理,可以在頂點著色器中編寫相應的代碼;片元著色器主要處理顏色操作,比如希望將一個紋理中某個區域的顏色變成紅色,可以在片元著色器中編寫相應的代碼。
相機實時圖片塗鴉實現思路下面開始循序漸進地講解塗鴉的實現,首先先來實現一個簡單的框架:在相機預覽的界面的中央畫一個貼圖。
Part1: 一個簡單的框架先來定義一下Vertex Shader和Fragment Shader,這兩個Shader是必不可少的。
Vertex Shader:
簡要介紹一下這個Vertex Shader的含義,正如前文所說的,Vertex Shader的作用是對頂點進行一些位置、大小、旋轉等變換操作,但在現在這個shader裡,這些都沒有涉及,只是一個最簡單的Shader,各變量及其含義:
a_Position
頂點數據,代表了要畫的每個頂點,注意,這裡的a_Position只是一個點,那麼它如何能代表要畫的每個頂點?這是剛接觸Shader時很容易會產生的疑惑之一,實際上,Shader代碼會被OpenGL反覆調用多次,每畫一個點就會調用一次,a_Position就代表當前要畫的點,反覆不停地調用,a_Position就被賦上了不同頂點的值。
a_TextureCoordinates
紋理坐標數據,用於描述要畫的紋理頂點,在這裡,沒有對它作任何處理,直接賦給了v_TextureCoordinates。
v_TextureCoordinates
用於將Vertex Shader中接受到的紋理頂點數據傳遞到Fragment Shader中,等會兒會看到在Fragment Shader中也有一個名字相同的變量。
gl_Position
最終告訴OpenGL要畫的頂點位置,這裡直接將a_Position賦給了它,不作任何變換。
Fragment Shader:
同樣,如前文所提到的,Fragment Shader主要處理顏色操作,各變量含義:
u_TextureUnit
java層傳遞過來的紋理,例如一張待繪製的圖片
v_TextureCoordinates
這個就是剛才說的Vertex Shader中傳遞過來的,其值就是Vertex Shader中的a_TextureCoordinates
gl_FragColor
最終告訴OpenGL要畫的頂點顏色,這裡texture2D(u_TextureUnit, v_TextureCoordinates)是什麼意思呢?就是取u_TextureUnit紋理中的v_TextureCoordinates點,而v_TextureCoordinates點又是Vertex Shader中傳遞過來的紋理的點,所以相當於是在這個紋理中取對應的點,Fragment Shader和Vertex Shader一樣也是會反覆地調用,這樣gl_FragColor就取到了這個紋理每一個點的顏色,結果就是將這個紋理畫了出來。
java關鍵代碼
首先創建兩個類,CameraView繼承GLSurfaceView並實現SurfaceTexture.OnFrameAvailableListener接口,MyRenderer實現GLSurfaceView.Renderer接口,在CameraView的構造函數裡做一些OpenGL必要的初始化:
值得一提的是setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY),OpenGL可以將渲染設置為每幀都自動渲染或者是你要求它渲染它才渲染,這裡的GLSurfaceView.RENDERMODE_WHEN_DIRTY屬於後者,在onFrameAvailable()回調裡調用GLSurfaceView的requestRender()方法觸發渲染,也就是觸發onDrawFrame()的調用。
下面在MyRenderer類中,我們先將剛才的那兩個Shader給Load進來:
然後在onSurfaceCreated中做一些變量的初始化:
其中IMAGE_POSITION_VERTEX是紋理圖片的位置坐標數組,它的作用是確定要把紋理圖片畫在屏幕的什麼地方,它裡面的坐標值是對應世界坐標系中的坐標值,IMAGE_TEXTURE_VERTEX是紋理圖片本身的頂點坐標數組,它的作用是確定要畫這個紋理圖片的什麼部分,如下圖所示:
IMAGE_POSITION_VERTEX所指定的位置即相當於上圖中「繪製位置」,IMAGE_TEXTURE_VERTEX指定的紋理繪製部分即相當於上圖中的「繪製部分」。
如果想把一個紋理圖片的全部部分畫在屏幕中央,可以將IMAGE_POSITION_VERTEX及IMAGE_TEXTURE_VERTEX取值如下:
這裡注意一點,vertex點的取法是和畫法有關的,這裡採用的畫法是GLES20.GL_TRIANGLE_FAN,如果採用其它畫法,vertex點數組要作相應地調整,否則畫面會錯亂。
然後在onDrawFrame中繪製圖片:
至此,我們有了一個簡單的框架,可以在相機預覽界面繪製一個圖片了。
Part2: 塗鴉畫布簡介
下面來介紹塗鴉畫布的創建以及將手指在屏幕上觸摸的位置繪製貼圖。
塗鴉畫布是一個獨立於相機預覽幀的繪圖區域,它的作用是可以將已繪製好的塗鴉暫存起來,否則因為相機預覽幀每一幀都是新的,需要把之前繪製過的東西再重新繪製一次,即就算塗鴉結束了,每幀也都需要調用多次OpenGL繪製方法將之前塗鴉的內容繪製到相機預覽幀上,否則在新的幀上就看不見之前塗的內容,示意圖如下:
有了塗鴉畫布後,就可以將塗鴉內容畫到塗鴉畫布上,然後對每一個新的相機預覽幀,直接將整個畫布畫上去,將畫布畫上去只需要調用一次OpenGL繪圖方法:
這裡的畫布實際上就是一個空的texture,創建方法和創建一個普通的texture是一個樣的,即用GLES20.glGenTextures()來創建,然後進行一些初始化等操作:
為什麼需要framebuffer?因為OpenGL默認是渲染到屏幕的,我們往畫布上畫東西並不希望馬上顯示出來,因為畫布還要貼到臉上,之後再顯示出來。
坐標變換
有了塗鴉畫布之後,下一步就是如何將塗鴉的內容畫到畫布上。首先討論坐標系的轉換,引入畫布之後,現在相關的坐標系又多了一個畫布的坐標系,手指在屏幕上觸摸之後,如何讓圖案最終在觸摸的位置畫出來呢?
手指在屏幕上觸摸之後,onTouchEvent()中所得到的坐標是屏幕坐標系中的坐標,而相機有一個預覽寬高的設置,這個寬高可以和屏幕寬高不一樣,比如1080*1920的屏幕,相機的預覽寬高可以設置為720*960,因此第一個坐標系的轉換就是將屏幕坐標系中的觸摸點坐標轉換成與相機預覽寬高相對應的坐標,相機預覽的坐標系原點及x、y軸方向與屏幕坐標系相同:
得到了觸摸點在相機預覽畫面中的坐標之後,下一步是轉換成它在畫布中的坐標,因為畫布是跟隨人臉移動、旋轉及縮放的,因此這一步稍微有一點複雜,這裡畫布貼到人臉上採用的方案是將畫布中心對準人臉的鼻尖位置(鼻尖坐標由人臉檢測SDK得到),示意圖如下:
可能有人會問,從圖中看,屏幕中有些部分超出了畫布,這部分是否能塗上去?是塗不上去的,只能塗在塗鴉畫布上,因此實際使用的時候,會把塗鴉畫布設置成比屏幕大一些,一般可以自己試一下,比如把手機放遠,看看人臉縮小後畫布要設置能多大還能覆蓋屏幕,一般不用設置得太大,因為人臉縮得太小後,人臉通常也識別不出來了,這時候也不用擔心畫布被縮得太小了。
繼續沿用之前的例子,前面是得到了觸摸點在相機預覽畫面中的坐標是(200,400),它如何對應到塗鴉畫面上面呢?這裡的方法是先計算觸摸點相對於人臉鼻尖的位置,因為塗鴉畫布是將畫布中心對準了人臉鼻尖位置,所以再通過算出來的相對位置轉換成塗鴉畫布上的對應位置,以保證它在塗鴉畫布上還是手指觸摸的那個地方。假設畫布的實際尺寸設置為600*600,畫布中心點坐標是(300,300),人臉鼻尖坐標是(360,320)先從簡單的情況看起,假設畫布貼上去之前,沒有進行移動、旋轉和縮放,那麼將是:
以上是一種簡單的情況,那麼如果人臉先旋轉了一下呢?這時畫布也是跟著旋轉了,這時的坐標如何轉換?其實思路很簡單,就是畫的時候,計算點坐標時把它當作還沒轉的情況來計算,算出來後再轉相應的角度就行了:
如何計算點(x,y)的值呢?有個神奇的公式,它可以計算一個點繞某個點逆時針旋轉後的點坐標:
其中x、y是旋轉前的點坐標,x0、y0是繞著旋轉的點坐標,x』、y』是旋轉後的點坐標,α是旋轉角度。
下面來看看,如果人臉縮放了,如何計算正確的坐標,這裡採取的方法是,當第一次把塗鴉畫布貼到人臉上的時候,先記錄人臉的初始寬度,之後的幀裡再用當前人臉的寬度和記錄的初始人臉寬度就行對比,從而得知人臉縮放的比例。人臉寬度的計算要依賴於人臉檢測SDK,只需要用人臉檢測出的人臉兩邊邊的對應點相減就行了:
人臉縮放後,要保持觸摸點轉換成塗鴉畫布上的正確位置,只需要把觸摸點與人臉鼻尖點之間的差值相應地縮放就可以了:
這裡有一點需要注意的是,假設塗鴉畫布的實際尺寸是600*600,它隨人臉進行縮放後,它的實際尺寸仍然是600*600,只不過顯示的時候被縮放了,因此在將觸摸點轉換成塗鴉畫布上的對應點時,仍要按塗鴉畫布是600*600來計算。
另外,還可以給畫布設置一個顯示的縮放比例,這個是什麼意思呢?之前說過,塗鴉畫布在實際使用的時候,會設置成比屏幕大一些,以確保在人臉縮小後,畫布不至於被跟著縮小至比屏幕還小,不然有些地方就塗不上去了,將塗鴉畫布設大,可以把它的實際尺寸設大,也可以是把它進行顯示放大,為什麼需要進行顯示放大?因為如果塗鴉畫布實際尺寸設置得很大,相當於畫布的解析度很高,這樣畫出的東西就比較精細,從而耗時也會增加,而進行顯示放大不會增加塗鴉畫布的實際尺寸,只相當於把一個小的東西在顯示時扯大了,會稍微變模糊一些。因此,可以將塗鴉畫布的實際大小設置得適中一些,再進行適當地顯示放大,來使得畫布不至於被跟著縮小至比屏幕還小,同時又讓畫布的分辨不會過高而增加繪製耗時。
加上了塗鴉畫布顯示縮放比例後,坐標換轉的計算邏輯也要相應地作修改,假設display_scale是設置的畫布顯示縮放比例,沿用之前的例子,如果畫布被放大顯示了,算出的點會有相應的偏移,調整示意圖如下:
現在可以將手指在屏幕上觸摸時在onTouchEvent()回調中所得到的觸摸坐標正確地轉換成塗鴉畫布中的坐標了,那麼如何在對應的坐標點畫塗鴉圖案呢?前面已經講解了一個簡單的繪圖框架,現在實際就是確定一下前文所說的IMAGE_POSITION_VERTEX以及IMAGE_TEXTURE_VERTEX該如何取值。將一個貼圖畫到一個位置上,那麼這張圖的哪個部分對準到這個點上呢?為了解決這個問題,這裡引入一個概念叫「錨點」,所謂錨點就是紋理圖片上用於對準的點,如下圖所示:
實際上,錨點的設置並不是OpenGL本身的功能,不過我們可以對IMAGE_POSITION_VERTEX稍作修改便可以指定自己想要的錨點,例如我們指定錨點為紋理貼圖的中心:
至此,塗鴉畫布的坐標系轉換就講完了
塗鴉畫布的平移、旋轉及縮放
下面這部分講解如何實現塗鴉畫布隨人臉平移、旋轉及縮放,前面提到過,Vertex Shader會對每個要畫的點都調一次,因此對每個點做對應的變換,也就實現了對塗鴉畫布的變換,平移、旋轉及縮放都有對應地矩陣操作可以方便地實現,將這些操作寫在Vertex Shader中對傳進Vertex Shader中的點進行變換就行了。
以下均假設變換前的點為x0、y0,變換後的點為x、y。
平移變換:
其中Δx、Δy分別表示在x、y軸上的平移量。
旋轉變換:
其中θ表示繞原點逆時針旋轉的角度。
tips:如果希望繞某個特定點旋轉,可以先作平移操作,讓特定點在平衡後處於原點的位置,再進行旋轉操作,旋轉結束後再按原路平移回去,如下圖所示:
縮放變換:
其中k1、k2分別表示x、y坐標的縮放比例。
至此,本文已接近尾聲,總結一下幾個關鍵點:
塗鴉畫布的創建,本質上是創建一個空的texture當作畫板
坐標轉換,關係著塗鴉位置是否正確,涉及到多個坐標系的轉換,一旦某步出錯,可能導致最後結果存在很大偏差
Vertext Shader中平移、旋轉及縮放代碼的編寫,本質上是套用變換矩陣
作者簡介:kenneyqin(覃華崢),天天P圖Android工程師