Android OpenGL仿自如APP裸眼3D效果詳解

原理簡介 & OpenGL 的優勢

裸眼 3D 效果的本質是——將整個圖片結構分為 3 層:上層、中層、以及底層。在手機左右上下旋轉時,上層和底層的圖片呈相反的方向進行移動,中層則不動,在視覺上給人一種 3D 的感覺:

也就是說效果是由以下三張圖構成的:

接下來,如何感應手機的旋轉狀態,並將三層圖片進行對應的移動呢?當然是使用設備自身提供各種各樣優秀的傳感器瞭,通過傳感器不斷回調獲取設備的旋轉狀態,對 UI 進行對應地渲染即可。

筆者最終選擇瞭 Android 平臺上的 OpenGL API 進行渲染,直接的原因是,無需將社區內已有的實現方案重復照搬。

另一個重要的原因是,GPU 更適合圖形、圖像的處理,裸眼3D效果中有大量的縮放和位移操作,都可在 java 層通過一個 矩陣 對幾何變換進行描述,通過 shader 小程序中交給 GPU 處理 ——因此,理論上 OpenGL 的渲染性能比其它幾個方案更好一些。

本文重點是描述 OpenGL 繪制時的思路描述,因此下文僅展示部分核心代碼。

具體實現

1. 繪制靜態圖片

首先需要將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;
}
// 頂點著色器代碼
// 頂點坐標
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 參數對應不同的幾何變換。

現在我們完成瞭圖像靜態的繪制,效果如下:

接下來我們需要接入傳感器,並定義不同層級圖片各自的幾何變換,讓圖片動起來。

2. 讓圖片動起來

首先我們需要對 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,將圖片進行放大。顯示窗口是固定的,那麼一開始隻能看到圖片的正中位置。(中層可以不用,因為中層本身是不移動的,所以也不必放大)

明白瞭這一點,我們就能理解,裸眼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.正交投影
    }
}

這段代碼中還有幾點細節需要處理。

3. 幾個反直覺的細節

3.1 旋轉方向 ≠ 位移方向

首先,設備旋轉方向和圖片的位移方向是相反的,舉例來說,當設備沿 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); 

3.2 默認旋轉角度 ≠ 0°

其次,在定義最大旋轉角度的時候,不能主觀認為旋轉角度 = 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的效果。

4. 帕金森綜合征?

還差一點就大功告成瞭,最後還需要處理下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);
    }
};

大功告成,最終我們實現瞭預期的效果:

源碼

源碼地址

以上就是Android OpenGL仿自如APP裸眼3D效果詳解的詳細內容,更多關於Android OpenGL裸眼3D的資料請關註WalkonNet其它相關文章!

推薦閱讀: