系列文章:
安卓特效相機(一) Camera2的使用
安卓特效相機(二) EGL基礎
安卓特效相機(三) OpenGL ES 特效渲染
安卓特效相機(四) 視頻錄制
特效的實現(xiàn)原理
接下來這篇文章我們講下特效的具體實現(xiàn)原理比原。
由于預覽畫面的渲染是將Surface傳給CameraDevice由它去繪制的,而且我沒有找到什么可以接管或者添加渲染效果的接口,所以并不能直接去處理攝像頭的畫面兄猩。
于是這里我們只能用一種游戲中常用的手段去處理,這種手段的名字叫做RTT(render to texture),中文名叫做渲染到紋理。
玩法是先將我們想要處理的畫面,不直接繪制到屏幕,而是繪制成一張圖片,然后我們再拿這張圖片去做一些特殊的處理,或者特殊的用途:
例如游戲中水面的倒影一種比較古老的實現(xiàn)方法就是先將岸上的畫面繪制成一張圖片,然后倒過來然后做一些扭曲、模糊坏快、淡化等處理,然后貼到水面上草巡。
又例如下面這種狙擊鏡的實現(xiàn)原理就是先將攝像頭位置調(diào)到遠處,將遠處的畫面繪制到一張貼圖上,然后將攝像頭位置再調(diào)回角色處,把剛剛得到的遠處的畫面的圖片直接貼到狙擊鏡上:
所以在這個特效相機的例子里面我們的實現(xiàn)原理如下:
OpenGL實現(xiàn)
我們使用OpenGL ES 2.0版本,這個版本要求我們用GLSL實現(xiàn)頂點著色器和片元著色器。這兩個著色器其實是兩個運行在GPU的程序眠寿。
GLSL全稱是OpenGL Shading Language即OpenGL著色語言,它在語法上和C語言有點像躬翁。只是看的話相信大家都能看懂,我就不仔細介紹語法了。
OpenGL可編程渲染管線的整個流程比較復雜,作為初學者我們只要理解其中的頂點著色器和和片元著色器就可以了盯拱。簡單來講就是OpenGL會在頂點著色器確定頂點的位置,然后這些頂點連起來就是我們想要的圖形盒发。接著在片元著色器里面給這些圖形上色:
我們直接看看兩個著色器的代碼。
頂點著色器
OpenGL會將每個頂點的坐標傳遞給頂點著色器,我們可以在這里改變頂點的位置狡逢。例如我們給每個頂點都加上一個偏移,就能實現(xiàn)整個圖形的移動宁舰。
在這個demo里面我們不改變頂點的坐標,只是簡單的將它從二維轉(zhuǎn)換成四維。現(xiàn)實世界里面都是三維的,那為什么要裝換成四維呢?原因是我們可以用4*4的矩陣對坐標進行旋轉(zhuǎn)奢浑、縮放明吩、平移等變換,但是4*4的矩陣只能和四維向量相乘,所以需要在xyz之外加多一個維度,我們一般情況下直接把這個維度的值設成1就好。然后將計算得到的四維坐標放到gl_Position作為最終結(jié)果值:
attribute vec2 vPosition;
attribute vec2 vCoord;
varying vec2 vPreviewCoord;
uniform mat4 matTransform;
void main() {
gl_Position = vec4(vPosition, 0, 1);
vPreviewCoord = (matTransform * vec4(vCoord.xy, 0, 1)).xy;
}
然后除了vPosition這個頂點的坐標,大家還會看到vCoord,它是紋理坐標殷费。什么是紋理坐標呢?
紋理其實可以理解成圖片,我們將圖片的左下角定義成原點(0,0),左上角印荔、右上角、右下角分別為(0,1)详羡、(1,1)仍律、(1,0):
我們的每個頂點,除了攜帶頂點坐標之外,還攜帶了紋理坐標的信息,頂點坐標確定了這個圖形的形狀,而紋理坐標則確定貼圖要怎么樣貼到這個圖形上。然后在片元著色器里面就可以根據(jù)這個紋理坐標去給圖形貼上貼圖了:
不過看到代碼可以看到,我們這里還用matTransform這個矩陣對紋理坐標進行了變換实柠。這里是由于我們的圖片不是普通的圖片,而是將攝像頭的畫面畫到另外一個surface之后拿過來的,需要進行變換水泉。這塊等下再仔細講解。
片元頂點著色器
#extension GL_OES_EGL_image_external : require
precision highp float;
varying vec2 vPreviewCoord;
uniform samplerExternalOES texPreview;
uniform mat4 uColorMatrix;
void main() {
gl_FragColor = uColorMatrix * texture2D(texPreview, vPreviewCoord).rgba;
}
片元著色器就比較簡單了,第一行是由于我們使用了samplerExternalOES需要開啟特殊配置,這個是由于在安卓上我們只能用samplerExternalOES類型的紋理去接收攝像頭的畫面,而使用samplerExternalOES需要開啟GL_OES_EGL_image_external功能。
然后這個texPreview就是我們攝像頭畫面繪制成的那張圖片了,我們用texture2D這個方法去讀取圖片某個像素的顏色值,它的第一個參數(shù)就是我們的紋理,第二個參數(shù)就是我們的紋理坐標,也就是上一步頂點著色器計算的到的紋理坐標:
vPreviewCoord = (matTransform * vec4(vCoord.xy, 0, 1)).xy;
這里有同學可能會疑問我們在頂點著色器不是只計算了頂點的紋理坐標嗎?那圖形邊上和內(nèi)部的紋理坐標又是怎么來的呢?
沒錯頂點著色器只是處理頂點的,有多少個頂點,頂點著色器就會執(zhí)行多少次,處理完所有的頂點之后,我們將值傳給varying類型的變量,OpenGL就會幫我們對varying變量做插值,計算出圖像上每個像素對應的紋理坐標,然后每個像素都會調(diào)用片元著色器去處理草则。于是運行完所有像素的片元著色器之后整個圖像就顯示出來了:
通過texture2D函數(shù)獲得這個像素在預覽畫面對應的顏色值之后我們再用一個特效處理矩陣去和它相乘做特效處理钢拧。例如黑白、懷舊炕横、反相的處理就是不同的矩陣去和這個顏色相乘,得到最終顯示出來的顏色源内。
例如一個顏色(r,g,b)反相效果其實就是(1.0-r, 1.0-b, 1.0-f),所以我們可以用這個矩陣去和像素顏色相乘:
-1.0f 0.0f 0.0f 1.0f
0.0f -1.0f 0.0f 1.0f
0.0f 0.0f -1.0f 1.0f
0.0f 0.0f 0.0f 1.0f
至于原理的話不知道大家記不記得線性代數(shù)的知識:
-1.0f 0.0f 0.0f 1.0f r -r + a
0.0f -1.0f 0.0f 1.0f * g = -g + a
0.0f 0.0f -1.0f 1.0f b -b + a
0.0f 0.0f 0.0f 1.0f a a
然后我們把alpha通道設置成1,0,就是[1.0-r, 1.0-g, 1.0-b, 1.0]就是(r,g,b,1)的反相顏色了。
其他的效果類似的,我這邊列出兩個特效矩陣給大家用:
// 去色效果矩陣
0.299f 0.587f 0.114f 0.0f
0.299f 0.587f 0.114f 0.0f
0.299f 0.587f 0.114f 0.0f
0.0f 0.0f 0.0f 1.0f
// 懷舊效果矩陣
0.393f 0.769f 0.189f 0.0f
0.349f 0.686f 0.168f 0.0f
0.272f 0.534f 0.131f 0.0f
0.0f 0.0f 0.0f 1.0f
創(chuàng)建渲染器
我們寫好頂點著色器和片元著色器之后要讓他們在我們的OpenGL程序里面運行份殿。
我們可以用下面代碼創(chuàng)建著色器
public int loadShader(int shaderType, InputStream source) {
// 讀取著色器代碼
String sourceStr;
try {
sourceStr = readStringFromStream(source);
} catch (IOException e) {
throw new RuntimeException("read shaderType " + shaderType + " source failed", e);
}
// 創(chuàng)建著色器并且編譯
int shader = GLES20.glCreateShader(shaderType); // 創(chuàng)建著色器程序
GLES20.glShaderSource(shader, sourceStr); // 加載著色器源碼
GLES20.glCompileShader(shader); // 編譯著色器程序
// 檢查編譯是否出現(xiàn)異常
int[] compiled = new int[1];
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
if (compiled[0] == 0) {
String log = GLES20.glGetShaderInfoLog(shader);
GLES20.glDeleteShader(shader);
throw new RuntimeException("create shaderType " + shaderType + " failed : " + log);
}
return shader;
}
它最關鍵的其實就是中間這三行代碼:
int shader = GLES20.glCreateShader(shaderType); // 創(chuàng)建著色器程序
GLES20.glShaderSource(shader, sourceStr); // 加載著色器源碼
GLES20.glCompileShader(shader); // 編譯著色器程序
在GLES20.glCreateShader的時候需要指定著色器類型,頂點著色器(GLES20.GL_VERTEX_SHADER)或者片元著色器(GLES20.GL_FRAGMENT_SHADER)創(chuàng)建出來的著色器程序需要鏈接到我們的渲染程序當中:
public int createProgram(InputStream vShaderSource, InputStream fShaderSource) {
// 創(chuàng)建渲染程序
int program = GLES20.glCreateProgram();
GLES20.glAttachShader(program, loadShader(GLES20.GL_VERTEX_SHADER, vShaderSource));
GLES20.glAttachShader(program, loadShader(GLES20.GL_FRAGMENT_SHADER, fShaderSource));
GLES20.glLinkProgram(program);
// 檢查鏈接是否出現(xiàn)異常
int[] linked = new int[1];
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0);
if (linked[0] == 0) {
String log = GLES20.glGetProgramInfoLog(program);
GLES20.glDeleteProgram(program);
throw new RuntimeException("link program failed : " + log);
}
return program;
}
最后調(diào)用GLES20.glUseProgram方法使用創(chuàng)建的渲染程序:
AssetManager asset = context.getAssets();
try {
mProgram = createProgram(asset.open(VERTICES_SHADER), asset.open(FRAGMENT_SHADER));
} catch (IOException e) {
throw new RuntimeException("can't open shader", e);
}
GLES20.glUseProgram(mProgram);
glViewport
這里有個比較重要的方法要先講一下,GLES20.glViewport定義了視窗的位置膜钓。
OpenGL雖然是在Surface上繪制,但我們可以不鋪滿整個Surface,可以只在它的某部分繪制,例如我們可以用下面代碼只用TextureSurface的左下角的四分之一去顯示OpenGL的畫面:
//width卿嘲、height是TextureView的寬高
GLES20.glViewport(0, 0, width/2, height/2);
當然一般情況下我們都是鋪滿整個Surface
GLES20.glViewport(0, 0, width, height);
填充頂點信息
從頂點著色器代碼來看,我們的頂點攜帶了兩種信息,一個是頂點的坐標颂斜、一個是紋理坐標:
attribute vec2 vPosition;
attribute vec2 vCoord;
在java代碼中,glUseProgram之后我們可以這樣拿到他們的id:
mPositionId = GLES20.glGetAttribLocation(mProgram, "vPosition");
mCoordId = GLES20.glGetAttribLocation(mProgram, "vCoord");
然后就可以通過這兩個id,去給這兩個變量填充值了。那具體要填充些什么值呢?
在OpenGL中,三角形是基本圖形,任何的圖形都可以由三角形組合而來拾枣。我們的TextureView其實是一個矩形,它可以由兩個三角形組成沃疮。但是這個矩形的坐標應該設置成多少呢?
默認情況下當我們設置一個頂點的x=0,y=0的時候它就在OpenGL畫面的中心,x軸正方向在右邊,y軸正方向在上邊,畫面的上下左右分別是y=1、y=-1梅肤、x=-1司蔬、x=1:
無論z坐標是多少都會忽略,只會管x,y坐標。有同學可能會疑惑,OpenGL不是可以處理三維圖形運算的嗎?
沒錯,但是OpenGL ES 2.0將整個三維運算都交給了我們,我們需要自己去乘觀察矩陣和投影矩陣才能得到三維的效果,這塊比較復雜這里就不講了凭语。我們不去乘的話OpenGL就變成了上面說的這樣葱她。
好了現(xiàn)在可以定義我們的頂點的坐標了:
我們當然可以用六個點去定義兩個三角形:
float[] VERTICES = {
// 左下角三角形
-1.0f, 1.0f,
-1.0f, -1.0f,
1.0f, -1.0f,
// 右上角三角形
1.0f, -1.0f,
1.0f, 1.0f,
-1.0f, 1.0f
};
但是這樣的話有兩個交點被重復定義了,占用內(nèi)存比較多,更多情況下我們會用四個點,然后再加一個序號數(shù)組去標識三角形的頂點:
private static final float[] VERTICES = {
-1.0f, 1.0f,
-1.0f, -1.0f,
1.0f, -1.0f,
1.0f, 1.0f
};
private static final short[] ORDERS = {
0, 1, 2, // 左下角三角形
2, 3, 0 // 右上角三角形
};
設置頂點坐標的代碼如下:
mVertices = CommonUtils.toFloatBuffer(VERTICES);
GLES20.glVertexAttribPointer(mPositionId, 2, GLES20.GL_FLOAT, false, 0, mVertices);
GLES20.glEnableVertexAttribArray(mPositionId);
...
public static FloatBuffer toFloatBuffer(float[] data) {
ByteBuffer buffer = ByteBuffer.allocateDirect(data.length * 4);
buffer.order(ByteOrder.nativeOrder());
FloatBuffer floatBuffer = buffer.asFloatBuffer();
floatBuffer.put(data);
floatBuffer.position(0);
return floatBuffer;
}
紋理坐標同理:
private static private float[] TEXTURE_COORDS = {
0.0f, 1.0f,
0.0f, 0.0f,
1.0f, 0.0f,
1.0f, 1.0f
};
...
mCoords = CommonUtils.toFloatBuffer(TEXTURE_COORDS);
GLES20.glVertexAttribPointer(mCoordId, 2, GLES20.GL_FLOAT, false, 0, mCoords);
GLES20.glEnableVertexAttribArray(mCoordId);
填充顏色特效矩陣
片元著色器中的uColorMatrix的設置類似,只不過由于它是uniform類型的變量,我們用GLES20.glUniformMXXXX去設置:
private static float[] COLOR_MATRIX = {
-1.0f, 0.0f, 0.0f, 1.0f,
0.0f, -1.0f, 0.0f, 1.0f,
0.0f, 0.0f, -1.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f
};
mColorMatrixId = GLES20.glGetUniformLocation(mProgram, "uColorMatrix");
GLES20.glUniformMatrix4fv(mColorMatrixId, 1, true, COLOR_MATRIX, 0);
glUniformMatrix4fv方法的第三個參數(shù)比較值得注意,這里我們填了true撩扒,代表需要轉(zhuǎn)置,這是由于OpenGL的矩陣是列優(yōu)先的:
因為我們的COLOR_MATRIX是一個一維數(shù)組,其實實際上是這樣的:
private float[] COLOR_MATRIX = {-1.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 0.0f, 0.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f};
它去到GPU之后設置給uColorMatrix得到了這個4*4的矩陣:
-1.0f, 0.0f, 0.0f, 0.0f,
0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, -1.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f
所以我們需要給他做轉(zhuǎn)置操作得到:
-1.0f, 0.0f, 0.0f, 1.0f,
0.0f, -1.0f, 0.0f, 1.0f,
0.0f, 0.0f, -1.0f, 1.0f,
0.0f, 0.0f, 0.0f, 1.0f
紋理變換矩陣
在頂點著色器里面我們講到了matTransform這個變換矩陣用于變換紋理坐標,它是從SurfaceTexture里面拿到的:
private float[] mTransformMatrix = new float[16];
...
mPreviewTexutre.getTransformMatrix(mTransformMatrix);
...
mTransformMatrixId = GLES20.glGetUniformLocation(mProgram, "matTransform");
...
GLES20.glUniformMatrix4fv(mTransformMatrixId, 1, false, matrix, 0);
SurfaceTexture從哪里來的我們等下再說,我們的攝像頭就是往這里繪制畫面似扔。可以用getTransformMatrix方法得到變換矩陣:
/**
* Retrieve the 4x4 texture coordinate transform matrix associated with the texture image set by
* the most recent call to updateTexImage.
*
* This transform matrix maps 2D homogeneous texture coordinates of the form (s, t, 0, 1) with s
* and t in the inclusive range [0, 1] to the texture coordinate that should be used to sample
* that location from the texture. Sampling the texture outside of the range of this transform
* is undefined.
*
* The matrix is stored in column-major order so that it may be passed directly to OpenGL ES via
* the glLoadMatrixf or glUniformMatrix4fv functions.
*
* @param mtx the array into which the 4x4 matrix will be stored. The array must have exactly
* 16 elements.
*/
public void getTransformMatrix(float[] mtx) {
...
}
它返回4*4的紋理坐標變換矩陣:
Retrieve the 4x4 texture coordinate transform matrix associated with the texture image
然后它是列優(yōu)先的可以直接使用不用轉(zhuǎn)置:
The matrix is stored in column-major order so that it may be passed directly to OpenGL ES via the glLoadMatrixf or glUniformMatrix4fv functions.
所以第三個參數(shù)我們設置成false:
GLES20.glUniformMatrix4fv(mTransformMatrixId, 1, false, matrix, 0);
創(chuàng)建紋理繪制攝像頭畫面
我們一直說要將攝像頭的畫面畫到圖片上,那圖片是怎么來的呢?并不是用安卓上常見的Bitmap去畫,而是用GLES20.glGenTextures創(chuàng)建一張OpenGL的紋理:
public int getTexture() {
if (mGLTextureId == -1) {
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
mGLTextureId = textures[0];
}
return mGLTextureId;
}
但是創(chuàng)建出來就只是一個id,要怎么給攝像機去用呢?不知道大家還就不記得第一篇博客里面講到如何設置攝像機畫面的接收Surface:
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
...
mPreviewSurface = new Surface(surface);
...
}
...
CaptureRequest.Builder builder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
builder.addTarget(mPreviewSurface);
session.setRepeatingRequest(builder.build(), null, null);
所以我們也要將這個紋理轉(zhuǎn)換成Surface放到CaptureRequest的Target里面?zhèn)鹘oCameraDevice:
mCameraTexture = new SurfaceTexture(mGLRender.getTexture());
...
CaptureRequest.Builder builder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
builder.addTarget(mCameraTexture);
session.setRepeatingRequest(builder.build(), mCaptureCallback, mHandler);
這里我們傳入了個mCaptureCallback,攝像機畫面繪制到紋理上之后會調(diào)用回調(diào),我們需要在回調(diào)里面將畫面上傳到GPU,前面說的紋理轉(zhuǎn)換矩陣也是在這個時候才去獲取的:
@Override
public void onCaptureCompleted() {
mCameraTexture.updateTexImage();
mCameraTexture.getTransformMatrix(mTransformMatrix);
...
}
這里有說明OpenGL ES里面只能用GL_TEXTURE_EXTERNAL_OES這種紋理去接收:
/**
* Update the texture image to the most recent frame from the image stream. This may only be
* called while the OpenGL ES context that owns the texture is current on the calling thread.
* It will implicitly bind its texture to the GL_TEXTURE_EXTERNAL_OES texture target.
*/
public void updateTexImage() {
nativeUpdateTexImage();
}
所以我們拿到片元著色器里的texPreview之后需要將它綁定到GLES11Ext.GL_SAMPLER_EXTERNAL_OES:
mTexPreviewId = GLES20.glGetUniformLocation(mProgram, "texPreview");
...
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES11Ext.GL_SAMPLER_EXTERNAL_OES, mGLTextureId);
GLES20.glUniform1i(mTexPreviewId, 0);
繪制與雙緩沖
最后的最后我們要執(zhí)行繪制操作,將整個畫面繪畫出來:
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glDrawElements(GLES20.GL_TRIANGLES, ORDERS.length, GLES20.GL_UNSIGNED_SHORT, mOrder);
EGL14.eglSwapBuffers(mEGLDisplay, mEGLSurface);
這個GLES20.glClear用于將上一幀的畫面清除,要不然如果有透明通道的話兩幀的畫面就會重疊搓谆。
而GLES20.glDrawElements代表用mOrder這個頂點順序去繪制圖形,GLES20.GL_TRIANGLES代表要繪制的是三角形炒辉。
最后的mGLCore.swapBuffers代表交互緩沖區(qū),這是由于OpenGL使用了雙緩沖的技術。
什么是雙緩沖呢?就是有兩個緩沖區(qū)域:前臺緩沖和后臺緩沖泉手。前臺緩沖即我們看到的屏幕,后臺緩沖則在內(nèi)存當中黔寇。
我們會先在后臺緩沖繪制圖像,繪制完成之后調(diào)用EGL14.eglSwapBuffers交換兩個緩沖區(qū),原先繪制的緩沖就變成了前臺緩沖,顯示在屏幕上:
為什么需要雙緩沖呢?這是為了解決繪制的時候屏幕閃爍的問題。我們都知道一般手機屏幕的刷新率是60Hz斩萌,而且有些高端的手機甚至比這個更高缝裤。
也就是說屏幕一秒鐘至少從前臺緩沖中獲取60次畫面顯示出來,如果只有一個緩沖的話,假設我們的繪制比較復雜耗時比較多,那可能屏幕會拿到畫到一半的圖片,就會造成閃爍。而兩個緩沖的話就畫到一半的圖像都在后臺緩沖并不會顯示,只有完全畫好才會交換變成前臺緩沖去顯示,就解決了這個閃爍的問題颊郎。
完整代碼
完整代碼見github(注意是feature_shader分支,master分支是第一篇文章的demo)