計算機圖形學&OpenGL系列教程(四) 座標系與座標變換

(本文由原作者轉自出處轉載請保留信息)

本章請使用電腦看,因爲github的限制帶來的排版問題,在窄屏設備上無法正常呈現本文中相當重要的一部分內容,還請使用電腦觀看。

  本次教程將圍繞OpenGL中的座標系和座標變換,講解有關的數學方面的內容,並完成所需的數學函數。本文的代碼和數學原理都是課程期末考試的重點和難點,同學們一定要弄懂請同學們找出大一上學期的線性代數課本以備不時之需

向量與矩陣

  這裏不再贅述向量與矩陣的基本概念與基本運算法則,以及線性空間的性質等內容,只講OpenGL中的特殊性。OpenGL中常用四維座標(x, y, z, w)表示點和向量,若第四個分量w的值爲1,則表示三維空間中的一個點(x, y, z),如果第四個分量值爲0,則表示一個向量,w分量取值這樣設計的好處在之後會體現出來。w的取值其實不限定於0和1,它在一次渲染過程中經過運算可能會變成其它的值並具有其它的用途,但牢記0和1這兩個取值對我們OpenGL編程來說初步是足夠了。OpenGL的向量是列向量,方便起見,在書寫時就略去了轉置符號T,即教程中書寫向量(點)(x, y, z, 1)它指代的是:
在這裏插入圖片描述
  體現在代碼上,我們這樣定義點,向量:

using Point3 = float[3];    //相當於typedef float Point3[3]; 使用using關鍵字定義類型別名通常更加直觀
using Vector3 = float[3];
using Point4 = float[4];
using Vector4 = float[4];

  因爲使用四維向量,所以進行相關運算(主要是座標變換)的矩陣爲四行四列的方陣。如果不特殊說明,本教程中術語矩陣也都特指四行四列的方陣。

  OpenGL的矩陣是所謂的列優先矩陣,體現在編程中其實是,如果我們使用下面的變量m

Vector4 m[4]; 

去儲存矩陣的元素,那麼矩陣第i行第j列的元素儲存在m[j][i],而非我們之前習慣的m[i][j]。這雖然是一個小問題但是如果不注意很容易造成實際編程中出現錯誤。

  於是我們定義出矩陣類Matrix4和相關的基本的運算:

class Matrix4 {
    float data[4][4];
public:
    Matrix4() {
        memset(data, 0, sizeof(data));
        makeUnit();
    }
    Matrix4(const Matrix4& m) {  //拷貝構造函數
        *this = m;
    }
    Matrix4(const std::initializer_list<Vector4> &vs) {
        assert(vs.end() - vs.begin() == 4);
        for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) {  //因爲OpenGL矩陣是列優先矩陣,這裏將輸入的數據轉置儲存
            data[i][j] = (*(vs.begin() + j))[i];
        }
    }
    //索引。注意OpenGL矩陣的第i行第j列元素存放在data[j][i]
    float& operator()(int i, int j) { return data[j][i]; }
    const float& operator()(int i, int j) const { return data[j][i]; }
    using float4 = Vector4;
    //下面兩個成員函數在對象被強制轉換爲float4時調用
    operator float4* () const { return const_cast<float4*>(data); }
    operator float4* () { return data; }
    //下面兩個成員函數在對象被強制轉換爲void*時調用
    operator void* () const { return const_cast<float4*>(data); }
    operator void* () { return data; }
    Matrix4& operator=(const Matrix4& m) {
        memcpy(data, (float4*)m, sizeof(data));
        return *this;
    }
    Matrix4& multiple(const Matrix4& m) {
        float4 tmp[4];
        for (int i = 0; i < 4; i++) {
            for (int j = 0; j < 4; j++) {
                tmp[j][i] = 0.0;  //注意i,j反過來
                for (int k = 0; k < 4; k++) {
                    tmp[j][i] += (*this)(i, k) * m(k, j);
                }
            }
        }
        memcpy(data, tmp, sizeof(data));
        return *this;
    }
    //單位陣
    Matrix4& makeUnit() {
        data[0][0] = data[1][1] = data[2][2] = data[3][3] = 1.0;
        return *this;
    }
    //向量點乘
    template<int N, class vectorType = float[N]>
    static float dot(const vectorType &v1, const vectorType &v2) {
        float res = 0.0;
        for (int i = 0; i < N; i++)
            res += v1[i] * v2[i];
        return res;
    }
    //向量-
    template<int N, class vectorType = float[N]>
    static void sub(const vectorType& v1, const vectorType& v2, vectorType& res) {
        for (int i = 0; i < N; i++)
            res[i] = v1[i] - v2[i];
    }
    //三維向量叉積
    static void cross(const Vector3& v1, const Vector3& v2, Vector3 &res) {
        res[0] = v1[1] * v2[2] - v1[2] * v2[1];
        res[1] = v1[2] * v2[0] - v1[0] * v2[2];
        res[2] = v1[0] * v2[1] - v1[1] * v2[0];
    }
    //向量長度
    template<int N, class vectorType = float[N]>
    static inline float length(const vectorType &vec) {
        float sum = 0.0;
        for (int i = 0; i < N; i++)
            sum += vec[i] * vec[i];
        return sqrtf(sum);
    }
    //向量歸一化(取單位向量)
    template<int N, class vectorType = float[N]>
    static void normalize(vectorType& vec) {
        float len = length<N>(vec);
        if (len < 1e-8)  //consider as a zero vector 
            return;
        for (int i = 0; i < N; i++)
            vec[i] /= len;
    }
};

座標與座標變換

  計算機圖形學要解決的一個基本問題便是如何將三維空間物體呈現在計算機的顯示器上。同時方法還要足夠方便,靈活,能夠充分利用計算機強大的數字計算能力。將三維物體呈現在二維介質上的問題,從很久之前就被研究了,數百年前(也許是上千年前?)畫家們就已經有了成熟的技術使得一副圖像看起來有較強立體感。想象你站在一條筆直向前的街道上,向前方望去,你會發現,越向遠方看去,本是平行的街道兩邊,竟然
會捱得越來越近,最終消失在同一點。因此畫家們定義出了“消失點(Vanish point)”:

在這裏插入圖片描述

那麼上圖中本來是矩形的紅色磚塊,就會變形稱爲梯形,而且水平方向本應同等長度的平行線,卻有着“近大遠小”的特點。依照在朝向遠處的消失點的一對平行線延長將會相交於消失點,以及“近大遠小”規律,就可以以此在2維平面上做出有立體感的矩形,立方體等。上面提到的只是所謂的“一點透視”,更加複雜一些的本教程不再講述,有興趣同學假期宅着太閒的話可以去學畫畫。除此之外,光照,圖形表面的細節也有助於提高其立體感,這在之後的教程中會提到。

  計算機圖形學爲了在計算機上完成這個任務,藉助線性代數中基變換和座標變換的方式,完成三維圖形的點座標到屏幕上二維座標的變換。相關線性代數內容可以參閱百度文庫或者拿出大一上學期線性代數課本好吧我知道你扔了。

  在OpenGL中,主要包括這樣五個座標系統(五組基)
  

  • 局部空間(Local Space) (也叫局部座標,Local Coordinate,這裏“空間”和“座標”的說法經常交換使用,通常指的是同一個概念,之後不再重複)

  •   
  • 世界空間(World Space)

  •   
  • 觀察空間(View Space) 也叫做攝像機空間(Camera Space)

  •   
  • 裁剪空間(Clip Space)

  •   
  • 屏幕空間(Screen Space)
  •   我們通常關注前4個空間之間的座標變換,從局部空間到世界空間的座標變換稱作“W變換”,從世界空間到觀察空間的變換稱作“V變換”,從觀察空間到裁剪空間的變換稱爲“P變換”(投影變換,Projection Transformation)。這也是我們編程時主要關注的地方,而裁剪空間之後的變換工作一般都主要由OpenGL完成。“W變換”,“V變換”,“P變換”合稱“WVP變換”。完成WVP變換後。OpenGL進行所謂的“透視除法”,即對WVP變換後點(x, y, z, w)變爲(x/w, y/w, z/w, 1)。一般來說,無論初始的模型的局部座標如何,“透視除法”之後,座標x, y, z的取值會歸一化到範圍在[-1. 1]之間的數,這個座標也稱作標準化設備座標(NDC, Normalized Device Coordinate),而“透視除法”後超出NDC座標範圍的點將被裁剪掉,然後再轉換到屏幕上窗口中的座標到屏幕空間(Screen Space)中,這時候的座標取前兩個分量(x,y)就已經是在屏幕上的窗口裏面的位置了,然而z座標並沒有被丟棄,z座標在深度測試和顏色合成時還有至關重要的用途,在下一章教程中會講到。到這裏三維的圖形的座標就變換到了二維的平面上了。其流程可以用下面的圖片直觀表示:

    在這裏插入圖片描述
    (圖片來源,圖中模型變換即W變換)

      之後展開介紹各個空間和空間之間的座標變換,並給出相關原理介紹和代碼實現

    首先說座標系

       在進入所謂的NDC空間之前,OpenGL和我們在線性代數課程中所學一樣使用右手座標系,即x軸正方向在紙面內水平向右,y軸正方向在紙面內豎直向上,z軸正方向垂直紙面向外。如下圖的右邊的座標系所示。而下圖中左邊的是左手座標系。
    在這裏插入圖片描述
    (圖片來自書籍 Introduction to 3D Game Programming with Directx 11的"向量代數"章節Figure1.5)

       之後所講的座標變換方法主要是在右手座標系中進行的,但是在左手座標系中也都成立,要注意Z軸的方向相反。在自測題目中會有左手座標系有關的題目。

    局部空間

       局部空間即只包含所關注三維物體的空間,通常以該物體的幾何中心或者某個頂點爲原點。在這個座標系下三維物體上的各個點的座標就是局部座標。例如一個棱長爲2的正方體,局部空間座標系原點位於其幾何中心,那麼它前面左上頂點的座標就是(-1, 1, 1, 1),這裏刻意使用了四維座標去表示:
    在這裏插入圖片描述
    (圖片來源)

       局部空間方便我們能夠關注三維物體本身,無論三維物體怎樣放置,物體上各個點在局部空間的座標始終保持不變。使用計算機三維設計軟件例如3DMax等設計出的模型,模型上各點的座標也都是模型局部空間內的局部座標。

    世界空間

       如果我們想把一堆模型加載到我們的畫面上,如果只有物體的局部座標,那麼他們大概會堆積在一起。我們想把它們排布在我們想放置的位置上,就需要把模型上各個頂點的局部座標變換到所謂的世界座標中,世界座標正如你所想,是指點相對於全局世界的位置座標。這個座標變換正是W變換。

    觀察空間

       量子力學的觀察者效應說,一個意識體的觀察會對物質世界的狀態產生重大影響,在計算機圖形的世界中尤其如此,在圖形的世界中存在一個假想的“攝像機”(Camera),攝像機觀測到的圖形的空間位置纔是物體最終呈現出來的空間位置。因此我們需要一個從物體的世界座標到攝像機的觀察座標的一個變換,這正是V變換。

    裁剪空間

       完成前面的W變換和V變換後,我們希望把模型各點的座標再變換到一個空間,這個空間模型各個點的座標都在一個可以確定的範圍內,這個就是裁剪空間。一方面便於裁減掉“出界”的部分,另一方面座標範圍確定便於之後轉換到屏幕座標。從觀察空間到裁剪空間的變換就是P變換(投影變換),投影又主要包括兩種,一種是正交投影,另一種是透視投影。正交投影的結果類似於三視圖,忠實地反應了可見的各部分的實際長度,而另一種遊戲編程中主要用到的透視投影的結果,就是仿照人眼觀察空間得到的圖形,有着近大遠小導致變形等特點。

    NDC空間

       完成P變換後,物體各個點座標(x, y, z, w)都除以w分量,變爲(x’, y’, z’, 1),且滿足各個x,y,z各個分量都在[-1, 1]之間(這個所謂的“透視除法”是OpenGL自動完成的,不需要編程中刻意去做,爲什麼滿足這個座標範圍之後投影變換部分會講)。NDC空間使用的是左手座標系(我也不知道爲什麼突然改用左手座標系了,這個後面投影的時候有用,先記住),空間內的座標與最終呈現圖像的設備的尺寸,寬高比等都無關,使的圖形能擺脫對支撐軟件、具體物理設備的依賴性,也方便能在不同應用和不同系統之間交換圖形信息。 一個初學者常有的誤區是,座標永遠都應該在[-1, 1]之間,其實不是,只是到NDC空間之後纔是這樣,在之前的局部座標,世界座標,觀察座標中,座標取值都沒有這個限制的,而是直到投影變換纔將座標變換到一個能確定的但是與參數有關的範圍,再進一步完成透視除法後纔到NDC座標有[-1, 1]的與參數無關的範圍。

    屏幕空間

      最後圖形要呈現在屏幕上,成爲圖像,座標就從NDC空間變換到屏幕空間上。屏幕空間即是指屏幕上窗口中顯示最終渲染所得圖像的空間,它的座標系統與第二章的圖像的座標系統一致,都以左上角爲原點(0, 0),水平向右爲x正方向,豎直向下爲y正方向。使用一個glViewport函數就可以指示屏幕空間在窗口的位置和大小。

    W變換

      W變換是從局部座標到世界座標的變換,W變換由包括縮放(Scale),旋轉(Rotate),平移(Translate)三部分。一般情況下,如果模型的中心位於局部座標系原點,我們通常按照先縮放,再旋轉,最後平移的原則進行W變換。

    縮放(S變換)

      縮放變換很簡單,由於物體最開始就是位於原點的,我們直接將各個點座標(x, y, z, 1)的前三個分量乘上各自方向上的縮放係數即可。爲了方便常用變換矩陣去實現,容易驗證,公式如下:

    在這裏插入圖片描述
    對應Matrix4類中的代碼如下:

      //伸縮變換,sx是x方向的縮放係數,sy, sz同理
        Matrix4& scale(float sx, float sy, float sz) {
            Matrix4 m;      //這裏依照公式構造變換矩陣m
            m(0, 0) = sx;
            m(1, 1) = sy;
            m(2, 2) = sz;
            multiple(m);    //將m表示的變換累加到this
            return *this;
        }
    

    旋轉(R變換)

    (由於github Readme不支持latex,下文部分內容包含數學公式過多,因此使用Latex排版後截圖放在這裏,手機上可能觀看不方便)

    在這裏插入圖片描述
    在這裏插入圖片描述
    (上圖來自Introduction to 3D Game Programming with Directx 11的Figure3.3)
    在這裏插入圖片描述
    在這裏插入圖片描述
    (上圖來自Introduction to 3D Game Programming with Directx 11的Figure3.3)
    在這裏插入圖片描述
    於是可以寫出作用旋轉變換的代碼:

        Matrix4& rotate(const Vector3& axis, float angle) {
            float c = cosf(angle), s = sinf(angle);
            Vector3 N;
            memcpy(N, axis, sizeof(N));
            normalize<3>(N);
            float xy = N[0] * N[1];
            float xz = N[0] * N[2];
            float yz = N[1] * N[2];
            Matrix4 trans({
                {c+(1-c)*N[0]*N[0], (1-c)*xy+s*N[2], (1-c)*xz-s*N[1], 0},
                {(1-c)*xy-s*N[2], c+(1-c)*N[1]*N[1], (1-c)*yz+s*N[0], 0},
                {(1-c)*xz+s*N[1], (1-c)*yz-s*N[0], c+(1-c)*N[2]*N[2], 0},
                {              0,               0,                 0, 1}
            });
            multiple(trans);
            return *this;
        }
    

    平移(T變換)

    在這裏插入圖片描述
    代碼:

        //平移變換
        Matrix4& translate(float tx, float ty, float tz) {
            Matrix4 m;
            m(0, 3) = tx;
            m(1, 3) = ty;
            m(2, 3) = tz;
            multiple(m);
            return *this;
        }
    

    V變換

        首先給出View Space的座標系統示意:
    在這裏插入圖片描述
       圖中的圓(爲了使得看起來有立體感進行了變形)是攝像機的鏡頭,或者眼睛的位置,正前方看向目標target。

        我們先進行一個位移變換,設攝像機的位置在(Ex, Ey, Ez),那麼空間中任意一點(x, y, z),應該對攝像機具有一個相對的座標(x - Ex, y - Ey, z - Ez),因此變換矩陣爲:
    在這裏插入圖片描述
        然後進行攝像機視角的變換,用target的位置減去eye的位置可以得到一個向量F,還需要兩個與之正交的向量可以構成三維空間內的一組基。我們需要知道指向眼睛上方的一個方向向量D(不一定是正上方),又F叉乘D可以得到眼睛的正右方向S(用右手定則判定叉乘結果向量方向),再又S和F叉乘得到眼睛的正上方方向向量U。這樣F,S,U三個線性無關且相互正交的向量構成三維空間內的一組基,F,S,U都單位化後可以構造過渡矩陣B(單位化是爲了保證圖形不變形,注意Z方向的問題,因爲右手座標系Z軸正方向垂直紙面向外,導致我們觀察物體的時候總是朝向Z軸負方向看的,因此下面的式子中對此有修正)。
    在這裏插入圖片描述
    在這裏插入圖片描述
        代碼如下:

        //函數接受三個參數,eyePosition給出眼睛的空間座標
        //                 targetPosition給出眼睛看向的目標的空間座標
        //                 upDirection給出指向眼睛上方的方向向量
        Matrix4& view(const Point3& eyePosition, const Point3& targetPosition, const Vector3& upDirection) {
            Vector3 F, S, U;
            sub<3>(targetPosition, eyePosition, F);
            normalize<3>(F);
            cross(F, upDirection, S);
            normalize<3>(S);
            cross(S, F, U);
            Matrix4 trans({
                {S[0], S[1], S[2],   -dot<3>(S, eyePosition)},
                {U[0], U[1], U[2],   -dot<3>(U, eyePosition)},
                {-F[0], -F[1], -F[2], dot<3>(F, eyePosition)},
                {0, 0, 0, 1}
            });
            multiple(trans);
            return *this;
        }
    

    P變換

    正交投影

    在這裏插入圖片描述
    圖片來源
    在這裏插入圖片描述
    圖片來源

      正交投影相當於是把長方體世界空間“壓縮”到一個正六面體空間中,如上圖(注意第二幅圖,到NDC空間後變成了左手座標系,Z軸正方向垂直紙面朝內)。相當於是縮放和平移變換的組合。這裏直接給出正交投影矩陣:
    在這裏插入圖片描述

    透視投影

      透視投影的原理就要複雜得多,它符合人眼觀察物體的特點,透視投影是增強圖形立體感的關健:
    在這裏插入圖片描述
    圖片來源,有修改
      圖中爲從眼睛看到的空間,透視投影就是要將View Space下的座標變換到上圖中白色棱臺內的空間中。該空間具有四個參數:場視角(FOV, Field of View,爲圖中紅色標出的的角,用字母α表示);近平面Z座標(zNear,用字母n表示),爲棱臺的形態學上頂面,同時限定了可見的點的最小Z座標,遠平面Z座標(zFar,用字母f表示),爲棱臺的形態學下底面,同時限定了可見的點的最大Z座標;Z值超出zNear和zFar範圍的最終點會被OpenGL裁剪掉。這也就是玩第一人稱3D遊戲時候,遊戲裏物體離你太遠會看不到,而離得太近“貼在臉前”也會看不到的原因。最後還有一個參數爲窗口的寬高比(Aspect Ratio,用字母r表示),也就是上圖中的Clip Window的寬高比,這個參數一般就等於glViewport指定的寬度和高度的比值。相當於透過Clip Window這個視窗去觀察後面的物體。Clip Window也叫Projection Window。

      我們還是要明確一件事情,由於完成V變換後的空間仍然使用的是右手座標系,即我們從Projection Window向“深處”觀察,越深處的Z座標數值越小,這裏給出透視投影空間的側視圖和俯視圖:

    在這裏插入圖片描述
    (透視投影空間側視圖,圖來自Introduction to 3D Game Programming with Directx 11 Figure5.23,有修改,注意Z軸方向是-Z方向)
    在這裏插入圖片描述
    (投影空間俯視圖,圖來自Introduction to 3D Game Programming with Directx 11 Figure5.23,有修改注意Z軸方向是-Z方向)
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述

      //透視投影
        Matrix4& perspective(float fovyAngle, float aspectRatio, float zNear, float zFar) {
            assert(aspectRatio > 1e-8);
            float cot2 = 1.0f / tanf(fovyAngle / 2.0f);
            Matrix4 trans({
                {cot2 / aspectRatio, 0, 0, 0},
                {0, cot2, 0, 0},
                {0, 0, -(zFar + zNear) / (zFar - zNear), -(2 * zFar * zNear) / (zFar - zNear)},
                {0, 0, -1, 0}
            });
            multiple(trans);
            return *this;
        }
    

    代碼驗證

       如果你學懂了這些變換的原理,並完成了Matrix4矩陣類的代碼,可以用你寫的代碼替換掉我寫的程序裏的Matrix4類,並編譯運行驗證,如果能出現這樣的一個彩色三棱錐體,並始終能在窗口可見範圍內旋轉,那大概是對了。。。

    在這裏插入圖片描述
      注意所給代碼的render函數的第356行附近:

        //WVP變換
        Matrix4 WVP;
        //SRTVP
        WVP.perspective(45.0f * acosf(-1) / 180.0f, 1.0f, 0.1f, 100.0f).
        view({ 0.0f, 0.0f, 3.0f }, { 0.0f, 0.0f, 0.0f }, { 0.0f, 1.0f, 0.0f }).
        translate(0.0f, 0.1f, -1.0f).rotate({ 0.0f, 1.0f, -0.4f }, angleRad).scale(1.5f, 1.5f, 1.5f);
    

      注意到變換時按照SRTVP的順序進行的,由於OpenGL座標變換過程中向量右乘的的緣故,先變換的後寫。可以修改給函數的參數,然後重新編譯運行看到效果。

    作者感言

      這部分難講,但也非常重要不能不講,雖然我會這些但沒有把握能講清楚,何況寫完之後我也發現很多東西我自己之前也不太清楚;在講這部分之前我一直糾結,是不是可以偷懶放幾個參考資料的鏈接,就把令人頭疼的數學原理和公式推導略過去了。但自己看了一些資料發現它們大都略去原理的推導,少數有原理推導的教程,有不少說法不一的地方。最終我還是決定把這難啃的部分寫下來,自己親自對公式進行推導,並編寫代碼與GLM數學庫給出的結果對比驗證,光是代碼驗證就花了兩天的吃飯睡覺打遊戲之外的時間,這篇教程也花了兩天完工,在編寫本章教程的過程中我也弄清楚了很多之前不清楚的細節。我個人認爲弄清楚這些原理是有必要的,比方說很多人用GLM等數學庫生成透視投影矩陣,都知道透視投影中將n調小一點,f比n大很多,場視角設置幾個常用角度。但它們到底怎麼設置好,大概不清楚。如果n和f差過小,同時場視角過大,可能導致畫面透視效應過強,看起來反而失真(像是沒把握好透視關係的畫師畫的畫一樣),弄懂原理還是能走得更遠一些。但弄懂原理後還是推薦使用GLM數學庫的,如果不是因爲課程期末考試這些變換矩陣推導是考點,也不用重複造輪子。GLM數學庫也在讓我能站在巨人的肩膀上,在編寫教程時給了我很大幫助。總而言之這個難啃的骨頭終於啃掉了,之後的教程就愉快多了~

    自測題目&啓示

  • 分別說明世界空間和NDC空間使用的是左手座標系還是右手座標系?
  • glVewport函數有什麼用?(題庫中有類似題目)
  • 使用NDC空間座標有什麼好處? (題庫原題)
  • 請給出旋轉變換矩陣的推導;如果OpenGL全使用左手座標系,你還要保持旋轉的方向不變,那麼這種情況下的旋轉變換矩陣又是怎樣的?(題庫原題)
  • 請給出透視投影矩陣的推導;如果OpenGL全用左手座標系,其餘條件不變,那麼透視投影矩陣又該是怎樣的?(題庫原題)
  • 證明由旋轉和平移組成的任何變序列都可以等價於一個以原點爲不動點的旋轉然後再進行一個平移的變換 (題庫原題)
  • 參考資料

    書《Introduction to 3D Game Programming with Directx 11》

    CSDN博客OpenGL之座標轉換

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章