1. 程式人生 > >安卓自定義View進階-Matrix Camera

安卓自定義View進階-Matrix Camera

本篇依舊屬於Matrix,主要講解Camera,Android下有很多相機應用,其中的美顏相機更是不少,不過今天這個Camera可不是我們平時拍照的那個相機,而是graphic包下的Camera,專業給View拍照的相機,不過既然是相機,作用都是類似的,主要是將3D的內容拍扁變成2D的內容。

眾所周知,我們的手機螢幕是一個2D的平面,所以也沒辦法直接顯示3D的資訊,因此我們看到的所有3D效果都是3D在2D平面的投影而已,而本文中的Camera主要作用就是這個,將3D資訊轉換為2D平面上的投影,實際上這個類更像是一個操作Matrix的工具類,使用Camera和Matrix可以在不使用OpenGL的情況下製作出簡單的3D效果。

⚠️ 警告:測試本文章示例之前請關閉硬體加速。

Camera常用方法表

方法類別 相關API 簡介
基本方法 save、restore 儲存、 回滾
常用方法 getMatrix、applyToCanvas 獲取Matrix、應用到畫布
平移 translate 位移
旋轉 rotat (API 12)、rotateX、rotateY、rotateZ 各種旋轉
相機位置 setLocation (API 12)、getLocationX (API 16)、getLocationY (API 16)、getLocationZ (API 16) 設定與獲取相機位置

Camera的方法並不是特別多,很多內容與之前的講解的Canvas和Matrix類似,不過又稍有不同,之前的畫布操作和Matrix主要是作用於2D空間,而Camera則主要作用於3D空間。

基礎概念

在具體講解方法之前,先補充幾個基礎概念,以便於後面理解。

3D座標系

我們Camera使用的3維座標系是左手座標系,即左手手臂指向x軸正方向,四指彎曲指向y軸正方向,此時展開大拇指指向的方向是z軸正方向

至於為什麼要用左手座標系呢?大概是因為趕工的時候右手不方便比劃吧,大霧。實際上不同平臺上使用的座標系也有不同,有的是左手,有的是右手,貌似並沒有統一的標準,只需要記住 Android 平臺上面使用的是左手座標系即可。

2D 和 3D 座標是通過Matrix關聯起來的,所以你可以認為兩者是同一個座標系,但又有差別,重點就是y軸方向不同。

座標系 2D座標系 3D座標系
原點預設位置 左上角 左上角
X 軸預設方向
Y 軸預設方向
Z 軸預設方向 垂直螢幕向內

3D座標系在螢幕中各個座標軸預設方向展示:

注意y軸預設方向是向上,而2D則是向下,另外本圖不代表3D座標系實際位置。

三維投影

三維投影是將三維空間中的點對映到二維平面上的方法。由於目前絕大多數圖形資料的顯示方式仍是二維的,因此三維投影的應用相當廣泛,尤其是在計算機圖形學,工程學和工程製圖中。

三維投影一般有兩種,正交投影透視投影

正交投影就是我們數學上學過的 “正檢視、正檢視、側檢視、俯檢視” 這些東西。

透視投影則更像拍照片,符合近大遠小的關係,有立體感,我們此處使用的就是透視投影。

攝像機

如果你學過Unity,那麼你對攝像機這一個概念應該會有比較透徹的理解。在一個虛擬的3D的立體空間中,由於我們無法直接用眼睛去觀察這一個空間,所以要藉助攝像機採集資訊,製成2D影像供我們觀察。簡單來說,攝像機就是我們觀察虛擬3D空間的眼睛

Android 上面觀察View的攝像機預設位置在螢幕左上角,而且是距螢幕有一段距離的,假設灰色部分是手機螢幕,白色是上面的一個View,攝像機位置看起來大致就是下面這樣子的(為了更好的展示攝像機的位置,做了一個空間轉換效果的動圖)。

攝像機的位置預設是 (0, 0, -576)。其中 -576= -8 x 72,雖然官方文件說距離螢幕的距離是 -8, 但經過測試實際距離是 -576 畫素,當距離為 -10 的時候,實際距離為 -720 畫素。不過這個數值72我也不明白是什麼東西,我使用了3款手機測試,螢幕大小和畫素密度均不同,但結果都是一樣的,知道的小夥伴可以告訴我一聲。

基本方法

基本方法就有兩個saverestore,主要作用為儲存當前狀態和恢復到上一次儲存的狀態,通常成對使用,常用格式如下:

camera.save();      // 儲存狀態
...          // 具體操作
camera.retore();   // 回滾狀態

常用方法

這兩個方法是Camera中最基礎也是最常用的方法。

getMatrix

void getMatrix (Matrix matrix)

計算當前狀態下矩陣對應的狀態,並將計算後的矩陣賦值給引數matrix。

applyToCanvas

void applyToCanvas (Canvas canvas)

計算當前狀態下單矩陣對應的狀態,並將計算後的矩陣應用到指定的canvas上。

平移

宣告:以下示例中 Matrix 的平移均使用 postTranslate 來演示,實際情況中使用set、pre 或 post 需要視情況而定。

void translate (float x, float y, float z)

和2D平移類似,只不過是多出來了一個維度,從只能在2D平面上平移到在3D空間內平移,不過,此處仍有幾個要點需要重點對待。

沿x軸平移

camera.translate(x, 0, 0);

matrix.postTranslate(x, 0);

兩者x軸同向,所以 Camera 和 Matrix 在沿x軸平移上是一致的。

結論:

一致是指平移方向和平移距離一致,在預設情況下,上面兩種均可以讓座標系向右移動x個單位。

沿y軸平移

這個就有點意思了,兩個座標系相互關聯,但是兩者的y軸方向是相反的,很容易把人搞迷糊。你可以這麼玩:

Camera camera = new Camera();
camera.translate(0, 100, 0);    // camera - 沿y軸正方向平移100畫素

Matrix matrix = new Matrix();
camera.getMatrix(matrix);
matrix.postTranslate(0,100);    // matrix - 沿y軸正方向平移100畫素

Log.i(TAG, "Matrix: "+matrix.toShortString());

在上面這種寫法,雖然用了5行程式碼,但是效果卻和 Matrix matrix = new Matrix(); 一樣,結果都是單位矩陣。而且看起來貌似沒有啥問題,畢竟兩次平移都是正向100。(如果遇見不懂技術的領導嫌你寫程式碼量少,你可以這樣多寫幾遍,反正一般人是看不出問題的。)

Matrix: [1.0, 0.0, 0.0][0.0, 1.0, 0.0][0.0, 0.0, 1.0]

結論:

由於兩者y軸相反,所以 camera.translate(0, -y, 0);matrix.postTranslate(0, y);平移方向和距離一致,在預設情況下,這兩種方法均可以讓座標系向下移動y個單位。

沿z軸平移

這個不僅有趣,還容易蒙逼,上面兩種情況再怎麼鬧騰也只是在2D平面上,而z軸的出現則讓其有了空間感。

當View和攝像機在同一條直線上時: 此時沿z軸平移相當於縮放的效果,縮放中心為攝像機所在(x, y)座標,當View接近攝像機時,看起來會變大,遠離攝像機時,看起來會變小,近大遠小

當View和攝像機不在同一條直線上時: 當View遠離攝像機的時候,View在縮小的同時也在不斷接近攝像機在螢幕投影位置(通常情況下為Z軸,在平面上表現為接近座標原點)。相反,當View接近攝像機的時候,View在放大的同時會遠離攝像機在螢幕投影位置。

我知道,這樣說你們肯定是蒙逼的,話說為啥遠離攝像機的時候會接近攝像機在螢幕投影位置(´・_・`),肯定覺得我在逗你們玩,完全是前後矛盾,邏輯都不通,不過這個在這裡的確是不矛盾的,因為遠離是在3D空間裡的情況,而接近只是在2D空間的投影,看下圖。

假設大矩形是手機螢幕,白色小矩形是View,攝像機位於螢幕左上角,請注意上面View與攝像機的距離以及下方View的大小以及距離左上角(攝像機在螢幕投影位置)的距離。

至於為什麼會這樣,因為我們人眼視覺就是這樣的,當我們看向遠方的時候,視線最終都會消失在視平線上,如果你站在兩條平行線中間,看起來它們會在遠方(視平線上)相交,雖然在3D空間上兩者距離不變,但在2D投影上卻是越來越接近,如下圖(圖片來自網路):

結論:

關於3D效果的平移說起來比較麻煩,但你可以自己實際的體驗一下,畢竟我們是生活在3D空間的,拿一張紙片來模擬View,用眼睛當做攝像機,在眼前來回移動紙片,多試幾次大致就明白是怎麼回事了。

平移 重點內容
x軸 2D 和 3D 相同。
y軸 2D 和 3D 相反。
z軸 近大遠小、視線相交。

旋轉

旋轉是Camera製作3D效果的核心,不過它製作出來的並不能算是真正的3D,而是偽3D,因為View是沒有厚度的。

// (API 12) 可以控制View同時繞x,y,z軸旋轉,可以由下面幾種方法複合而來。
void rotate (float x, float y, float z);

// 控制View繞單個座標軸旋轉
void rotateX (float deg);
void rotateY (float deg);
void rotateZ (float deg);

這個東西瞎扯理論也不好理解,直接上圖:

以上三張圖分別為,繞x軸,y軸,z軸旋轉的情況,至於為什麼沒有顯示z軸,是因為z軸是垂直於手機螢幕的,在螢幕上的投影就是一個點。

關於旋轉,有以下幾點需要注意:

旋轉中心

旋轉中心預設是座標原點,對於圖片來說就是左上角位置。

我們都知道,在2D中,不論是旋轉,錯切還是縮放都是能夠指定操作中心點位置的,但是在3D中卻沒有預設的方法,如果我們想要讓圖片圍繞中心點旋轉怎麼辦? 這就要使用到我們在Matrix原理提到過的方法:

Matrix temp = new Matrix();      // 臨時Matrix變數
this.getMatrix(temp);          // 獲取Matrix
temp.preTranslate(-centerX, -centerY);  // 使用pre將旋轉中心移動到和Camera位置相同。
temp.postTranslate(centerX, centerY);  // 使用post將圖片(View)移動到原來的位置

官方示例-Rotate3dAnimation

說到3D旋轉,最經典的應該就是ApiDemo裡面的 Rotate3dAnimation 了,見過不少博文都是根據Rotate3dAnimation修改的效果,這是一個非常經典的例子,鑑於程式碼也不長,就貼在這裡和大家一起品鑑一下。

public class Rotate3dAnimation extends Animation {
    private final float mFromDegrees;
    private final float mToDegrees;
    private final float mCenterX;
    private final float mCenterY;
    private final float mDepthZ;
    private final boolean mReverse;
    private Camera mCamera;
    /**
     * 建立一個繞y軸旋轉的3D動畫效果,旋轉過程中具有深度調節,可以指定旋轉中心。
     * 
     * @param fromDegrees   起始時角度
     * @param toDegrees     結束時角度
     * @param centerX       旋轉中心x座標
     * @param centerY       旋轉中心y座標
     * @param depthZ        最遠到達的z軸座標
     * @param reverse       true 表示由從0到depthZ,false相反
     */
    public Rotate3dAnimation(float fromDegrees, float toDegrees,
            float centerX, float centerY, float depthZ, boolean reverse) {
        mFromDegrees = fromDegrees;
        mToDegrees = toDegrees;
        mCenterX = centerX;
        mCenterY = centerY;
        mDepthZ = depthZ;
        mReverse = reverse;
    }
    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);
        mCamera = new Camera();
    }
    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        final float fromDegrees = mFromDegrees;
        float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);
        final float centerX = mCenterX;
        final float centerY = mCenterY;
        final Camera camera = mCamera;
        final Matrix matrix = t.getMatrix();
        camera.save();

        // 調節深度
        if (mReverse) {
            camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime);
        } else {
            camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime));
        }

        // 繞y軸旋轉
        camera.rotateY(degrees);

        camera.getMatrix(matrix);
        camera.restore();

        // 調節中心點
        matrix.preTranslate(-centerX, -centerY);
        matrix.postTranslate(centerX, centerY);
    }
}

可以看到,短短的幾十行程式碼就完成了,而核心程式碼(有註釋部分)僅僅幾行而已,簡潔易懂。不過呢,這一份程式碼依舊是一份未完成的程式碼(不然怎麼叫ApiDemo呢?),並且很多人不知道怎麼修改。

不知諸位在使用的時候可否發現了一個問題,同一份程式碼在不同手機上顯示效果也是不同的,在畫素密度較低的手機上,旋轉效果比較正常,但是在畫素密度較高的手機上顯示效果則會很誇張,具體會怎樣的,下面就來看一下具體效果。

可以看到,圖片不僅因為形變失真,而且在中間一段因為形變過大導致圖片無法顯示,當然了,單個手機失真,你可以用depthZ忽悠過去,當 depthZ 設定的數值比較大大時候,影象在翻轉同時會遠離攝像頭,距離比較遠,失真就不會顯得很嚴重,但這仍掩蓋不了在不同手機上顯示效果不同。

如何解決這一問題呢?

想要解決其實也不難,只要修改兩個數值就可以了,這兩個數值就是在Matrix中一直被眾多開發者忽略的 MPERSP_0MPERSP_1

下面是修改後的程式碼(重點部分都已經標註出來了):

public class Rotate3dAnimation extends Animation {
    private final float mFromDegrees;
    private final float mToDegrees;
    private final float mCenterX;
    private final float mCenterY;
    private final float mDepthZ;
    private final boolean mReverse;
    private Camera mCamera;
    float scale = 1;    // <------- 畫素密度

    /**
     * 建立一個繞y軸旋轉的3D動畫效果,旋轉過程中具有深度調節,可以指定旋轉中心。
     * @param context     <------- 新增上下文,為獲取畫素密度準備
     * @param fromDegrees 起始時角度
     * @param toDegrees   結束時角度
     * @param centerX     旋轉中心x座標
     * @param centerY     旋轉中心y座標
     * @param depthZ      最遠到達的z軸座標
     * @param reverse     true 表示由從0到depthZ,false相反
     */
    public Rotate3dAnimation(Context context, float fromDegrees, float toDegrees,
                             float centerX, float centerY, float depthZ, boolean reverse) {
        mFromDegrees = fromDegrees;
        mToDegrees = toDegrees;
        mCenterX = centerX;
        mCenterY = centerY;
        mDepthZ = depthZ;
        mReverse = reverse;

        // 獲取手機畫素密度 (即dp與px的比例)
        scale = context.getResources().getDisplayMetrics().density;
    }

    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);
        mCamera = new Camera();
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        final float fromDegrees = mFromDegrees;
        float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);
        final float centerX = mCenterX;
        final float centerY = mCenterY;
        final Camera camera = mCamera;
        final Matrix matrix = t.getMatrix();
        camera.save();

        // 調節深度
        if (mReverse) {
            camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime);
        } else {
            camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime));
        }

        // 繞y軸旋轉
        camera.rotateY(degrees);

        camera.getMatrix(matrix);
        camera.restore();

        // 修正失真,主要修改 MPERSP_0 和 MPERSP_1
        float[] mValues = new float[9];
        matrix.getValues(mValues);               //獲取數值
        mValues[6] = mValues[6]/scale;         //數值修正
        mValues[7] = mValues[7]/scale;         //數值修正
        matrix.setValues(mValues);               //重新賦值

        // 調節中心點
        matrix.preTranslate(-centerX, -centerY);
        matrix.postTranslate(centerX, centerY);
    }
}

修改後效果:

上下對比差別還是很大的,順便附上測試程式碼吧,layout檔案就不寫了,隨便放一個ImageView就行了。

setContentView(R.layout.activity_test_camera_rotate2);
ImageView view = (ImageView) findViewById(R.id.img);
assert view != null;
view.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        // 計算中心點(這裡是使用view的中心作為旋轉的中心點)
        final float centerX = v.getWidth() / 2.0f;
        final float centerY = v.getHeight() / 2.0f;

        //括號內參數分別為(上下文,開始角度,結束角度,x軸中心點,y軸中心點,深度,是否扭曲)
        final Rotate3dAnimation rotation = new Rotate3dAnimation(MainActivity.this, 0, 180, centerX, centerY, 0f, true, 2);

        rotation.setDuration(3000);                         //設定動畫時長
        rotation.setFillAfter(tr