轉自 | 卻把清梅嗅的博客
原文地址:
https://juejin.cn/post/7035645207278256165
/ 概述 /
之前看到自如團隊發布的自如客APP裸眼3D效果的實現 ,非常有趣,不久後,社區內Android的開發者們陸續提供了Flutter、 Android原生 、Android Jetpack Compose等不同的實現版本。
很快我看到了一個好玩的評論:
既然客戶端都捲成這樣了,乾脆破罐破摔,把Android OpenGL的實現版本也補齊,畢竟圖形學或許會遲到,但絕不會缺席。
實現效果如下,這一波屬實參與到社區內裸眼3D的客戶端大滿貫了:
/ 原理簡介&OpenGL的優勢 /
裸眼3D原理其它文章都拆解非常清晰了,本著不重複造輪子的原則,這裡引用Nayuta和付十一文章中的部分內容,再次感謝。裸眼3D效果的本質是——將整個圖片結構分為3層:上層、中層、以及底層。在手機左右上下旋轉時,上層和底層的圖片呈相反的方向進行移動,中層則不動,在視覺上給人一種3D的感覺:
也就是說效果是由以下三張圖構成的:
接下來,如何感應手機的旋轉狀態,並將三層圖片進行對應的移動呢?當然是使用設備自身提供各種各樣優秀的傳感器了,通過傳感器不斷回調獲取設備的旋轉狀態,對 UI 進行對應地渲染即可。
筆者最終選擇了Android平臺上的OpenGL API進行渲染,直接的原因是,無需將社區內已有的實現方案重複照搬。
另一個重要的原因是,GPU更適合圖形、圖像的處理,裸眼3D效果中有大量的縮放和位移操作,都可在java層通過一個矩陣對幾何變換進行描述,通過shader小程序中交給GPU處理 ——因此,理論上OpenGL的渲染性能比其它幾個方案更好一些。
本文重點是描述OpenGL繪製時的思路描述,因此下文僅展示部分核心代碼,對具體實現感興趣的讀者可參考文末的連結。
/ 具體實現 /
繪製靜態圖片
首先需要將3張圖片依次進行靜態繪製,這裡涉及大量OpenGL API的使用,不熟悉的讀可略讀本小節,以捋清思路為主。
首先看一下頂點和片元著色器的shader代碼,其定義了圖像紋理是如何在GPU中處理渲染的:
// 頂點著色器代碼
// 頂點坐標
attribute vec4 av_Position;
// 紋理坐標
attribute vec2 af_Position;
uniform mat4 u_Matrix;
varying vec2 v_texPo;
void main() {
v_texPo = af_Position;
gl_Position = u_Matrix * av_Position;
}
定義好了Shader,接下來在GLSurfaceView(可以理解為OpenGL中的畫布)創建時,初始化Shader小程序,並將圖像紋理依次加載到GPU中:
public class My3DRenderer implements GLSurfaceView.Renderer {
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
// 1.加載shader小程序
mProgram = loadShaderWithResource(
mContext,
R.raw.projection_vertex_shader,
R.raw.projection_fragment_shader
);
// ...
// 2. 依次將3張切圖紋理傳入GPU
this.texImageInner(R.drawable.bg_3d_back, mBackTextureId);
this.texImageInner(R.drawable.bg_3d_mid, mMidTextureId);
this.texImageInner(R.drawable.bg_3d_fore, mFrontTextureId);
}
}
接下來是定義視口的大小,因為是2D圖像變換,且切圖和手機屏幕的寬高比基本一致,因此簡單定義一個單位矩陣的正交投影即可:
public class My3DRenderer implements GLSurfaceView.Renderer {
// 投影矩陣
private float[] mProjectionMatrix = new float[16];
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
// 設置視口大小,這裡設置全屏
GLES20.glViewport(0, 0, width, height);
// 圖像和屏幕寬高比基本一致,簡化處理,使用一個單位矩陣
Matrix.setIdentityM(mProjectionMatrix, 0);
}
}
最後就是繪製,讀者需要理解,對於前、中、後三層圖像的渲染,其邏輯是基本一致的,差異僅僅有2點:圖像本身不同以及圖像的幾何變換不同。
public class My3DRenderer implements GLSurfaceView.Renderer {
private float[] mBackMatrix = new float[16];
private float[] mMidMatrix = new float[16];
private float[] mFrontMatrix = new float[16];
@Override
public void onDrawFrame(GL10 gl) {
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
GLES20.glUseProgram(mProgram);
// 依次繪製背景、中景、前景
this.drawLayerInner(mBackTextureId, mTextureBuffer, mBackMatrix);
this.drawLayerInner(mMidTextureId, mTextureBuffer, mMidMatrix);
this.drawLayerInner(mFrontTextureId, mTextureBuffer, mFrontMatrix);
}
private void drawLayerInner(int textureId, FloatBuffer textureBuffer, float[] matrix) {
// 1.綁定圖像紋理
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
// 2.矩陣變換
GLES20.glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0);
// ...
// 3.執行繪製
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
}
}
參考drawLayerInner的代碼,其用於繪製單層的圖像,其中textureId參數對應不同圖像,matrix參數對應不同的幾何變換。
現在我們完成了圖像靜態的繪製,效果如下:
接下來我們需要接入傳感器,並定義不同層級圖片各自的幾何變換,讓圖片動起來。
讓圖片動起來首先我們需要對Android平臺上的傳感器進行註冊,監聽手機的旋轉狀態,並拿到手機xy軸的旋轉角度。
// 2.1 註冊傳感器
mSensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
mAcceleSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
mMagneticSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
mSensorManager.registerListener(mSensorEventListener, mAcceleSensor, SensorManager.SENSOR_DELAY_GAME);
mSensorManager.registerListener(mSensorEventListener, mMagneticSensor, SensorManager.SENSOR_DELAY_GAME);
// 2.2 不斷接受旋轉狀態
private final SensorEventListener mSensorEventListener = new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
// ... 省略具體代碼
float[] values = new float[3];
float[] R = new float[9];
SensorManager.getRotationMatrix(R, null, mAcceleValues, mMageneticValues);
SensorManager.getOrientation(R, values);
// x軸的偏轉角度
float degreeX = (float) Math.toDegrees(values[1]);
// y軸的偏轉角度
float degreeY = (float) Math.toDegrees(values[2]);
// z軸的偏轉角度
float degreeZ = (float) Math.toDegrees(values[0]);
// 拿到 xy 軸的旋轉角度,進行矩陣變換
updateMatrix(degreeX, degreeY);
}
};
注意,因為我們只需控制圖像的左右和上下移動,因此,我們只需關注設備本身x軸和y軸的偏轉角度:
拿到了x軸和y軸的偏轉角度後,接下來開始定義圖像的位移了。
但如果將圖片直接進行位移操作,將會因為位移後圖像的另一側沒有紋理數據,導致渲染結果有黑邊現象,為了避免這個問題,我們需要將圖像默認從中心點進行放大,保證圖像移動的過程中,不會超出自身的邊界。
也就是說,我們一開始進入時,看到的肯定只是圖片的部分區域。給每一個圖層設置scale,將圖片進行放大。顯示窗口是固定的,那麼一開始只能看到圖片的正中位置。(中層可以不用,因為中層本身是不移動的,所以也不必放大)
這裡的處理參考自Nayuta的這篇文章,內部已經將思路闡述的非常清晰,強烈建議讀者進行閱讀。
https://github.com/DylanCaiCoding/ActivityResultLauncher
明白了這一點,我們就能理解,裸眼3D的效果實際上就是對 不同層級的圖像進行縮放和位移的變換,下面是分別獲取幾何變換的代碼:
public class My3DRenderer implements GLSurfaceView.Renderer {
private float[] mBackMatrix = new float[16];
private float[] mMidMatrix = new float[16];
private float[] mFrontMatrix = new float[16];
/**
* 陀螺儀數據回調,更新各個層級的變換矩陣.
*
* @param degreeX x軸旋轉角度,圖片應該上下移動
* @param degreeY y軸旋轉角度,圖片應該左右移動
*/
private void updateMatrix(@FloatRange(from = -180.0f, to = 180.0f) float degreeX,
@FloatRange(from = -180.0f, to = 180.0f) float degreeY) {
// ... 其它處理
// 背景變換
// 1.最大位移量
float maxTransXY = MAX_VISIBLE_SIDE_BACKGROUND - 1f;
// 2.本次的位移量
float transX = ((maxTransXY) / MAX_TRANS_DEGREE_Y) * -degreeY;
float transY = ((maxTransXY) / MAX_TRANS_DEGREE_X) * -degreeX;
float[] backMatrix = new float[16];
Matrix.setIdentityM(backMatrix, 0);
Matrix.translateM(backMatrix, 0, transX, transY, 0f); // 2.平移
Matrix.scaleM(backMatrix, 0, SCALE_BACK_GROUND, SCALE_BACK_GROUND, 1f); // 1.縮放
Matrix.multiplyMM(mBackMatrix, 0, mProjectionMatrix, 0, backMatrix, 0); // 3.正交投影
// 中景變換
Matrix.setIdentityM(mMidMatrix, 0);
// 前景變換
// 1.最大位移量
maxTransXY = MAX_VISIBLE_SIDE_FOREGROUND - 1f;
// 2.本次的位移量
transX = ((maxTransXY) / MAX_TRANS_DEGREE_Y) * -degreeY;
transY = ((maxTransXY) / MAX_TRANS_DEGREE_X) * -degreeX;
float[] frontMatrix = new float[16];
Matrix.setIdentityM(frontMatrix, 0);
Matrix.translateM(frontMatrix, 0, -transX, -transY - 0.10f, 0f); // 2.平移
Matrix.scaleM(frontMatrix, 0, SCALE_FORE_GROUND, SCALE_FORE_GROUND, 1f); // 1.縮放
Matrix.multiplyMM(mFrontMatrix, 0, mProjectionMatrix, 0, frontMatrix, 0); // 3.正交投影
}
}
這段代碼中還有幾點細節需要處理。
幾個反直覺的細節旋轉方向 ≠ 位移方向首先,設備旋轉方向和圖片的位移方向是相反的,舉例來說,當設備沿X軸旋轉,對於用戶而言,對應前後景的圖片應該上下移動,反過來,設備沿Y軸旋轉,圖片應該左右移動(沒太明白的同學可參考上文中陀螺儀的圖片加深理解):
// 設備旋轉方向和圖片的位移方向是相反的
float transX = ((maxTransXY) / MAX_TRANS_DEGREE_Y) * -degreeY;
float transY = ((maxTransXY) / MAX_TRANS_DEGREE_X) * -degreeX;
// ...
Matrix.translateM(backMatrix, 0, transX, transY, 0f);
其次,在定義最大旋轉角度的時候,不能主觀認為旋轉角度 = 0°是默認值。什麼意思呢?Y軸旋轉角度為0°,即degreeY = 0時,默認設備左右的高度差是0,這個符合用戶的使用習慣,相對易於理解,因此,我們可以定義左右的最大旋轉角度,比如Y ∈ (-45°,45°),超過這兩個旋轉角度,圖片也就移動到邊緣了。
但當X軸旋轉角度為0°,即degreeX = 0時,意味著設備上下的高度差是0,你可以理解為設備是放在水平的桌面上的,這個絕不符合大多數用戶的使用習慣,相比之下,設備屏幕平行於人的面部才更適用大多數場景(degreeX = -90):
因此,代碼上需對X、Y軸的最大旋轉角度區間進行分開定義:
private static final float USER_X_AXIS_STANDARD = -45f;
private static final float MAX_TRANS_DEGREE_X = 25f; // X軸最大旋轉角度 ∈ (-20°,-70°)
private static final float USER_Y_AXIS_STANDARD = 0f;
private static final float MAX_TRANS_DEGREE_Y = 45f; // Y軸最大旋轉角度 ∈ (-45°,45°)
還差一點就大功告成了,最後還需要處理下3D效果抖動的問題:
如圖,由於傳感器過於靈敏,即使平穩的握住設備,XYZ三個方向上微弱的變化都會影響到用戶的實際體驗,會給用戶帶來帕金森症候群的自我懷疑。
解決這個問題,傳統的OpenGL以及Android API似乎都無能為力,好在GitHub上有人提供了另外一個思路。
熟悉信號處理的同學比較了解,為了通過剔除短期波動、保留長期發展趨勢提供了信號的平滑形式,可以使用低通濾波器,保證低於截止頻率的信號可以通過,高於截止頻率的信號不能通過。
因此有人建立了這個倉庫,通過對Android傳感器追加低通濾波,過濾掉小的噪聲信號,達到較為平穩的效果:
private final SensorEventListener mSensorEventListener = new SensorEventListener() {
@Override
public void onSensorChanged(SensorEvent event) {
// 對傳感器的數據追加低通濾波
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
mAcceleValues = lowPass(event.values.clone(), mAcceleValues);
}
if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
mMageneticValues = lowPass(event.values.clone(), mMageneticValues);
}
// ... 省略具體代碼
// x軸的偏轉角度
float degreeX = (float) Math.toDegrees(values[1]);
// y軸的偏轉角度
float degreeY = (float) Math.toDegrees(values[2]);
// z軸的偏轉角度
float degreeZ = (float) Math.toDegrees(values[0]);
// 拿到 xy 軸的旋轉角度,進行矩陣變換
updateMatrix(degreeX, degreeY);
}
};
https://github.com/Bhide/Low-Pass-Filter-To-Android-Sensors
大功告成,最終我們實現了預期的效果:
/ 參考 /
最後是本文中提到的相關資料,再次感謝先驅者的付出實踐。
https://juejin.cn/post/6989227733410644005
拿去吧你!Flutter仿自如App裸眼3D效果:https://juejin.cn/post/6991409083765129229
https://juejin.cn/post/6992169168938205191
GitHub: Low-Pass-Filter-To-Android-Sensors:https://github.com/Bhide/Low-Pass-Filter-To-Android-Sensors
https://github.com/qingmei2/OpenGL-demo
版權申明:內容來源網絡,版權歸原創者所有。除非無法確認,都會標明作者及出處,如有侵權,煩請告知,我們會立即刪除並致歉!