[CG從零開始] 5. 搞清 MVP 矩陣理論 + 實踐

在 4 中成功繪製了三角形以後,下面我們來加載一個 fbx 文件,然後構建 MVP 變換(model-view-projection)。簡單介紹一下:

  1. 從我們拿到模型(主要是網格信息)文件開始,模型網格(Mesh)裏記錄模型的頂點位置信息,比方說 (-1,1,1) 點,那麼這個點是相對於這個模型的(0,0,0)點來說的,這和我們在製作模型的時候有關,例如我可以讓這個(0,0,0)點位於模型的中心也可以是底部。
  2. 接着我們需要通過放置許多的模型來構建整個場景,爲了描述每個物體的位姿(位置和姿態),我們需要一個世界原點,然後所有物體的位姿信息都是相對於這個世界原點的。如果用過遊戲引擎或者 DCC 軟件的話,一般每個物體都會有一個 transform 來描述這件事情。因此第一步我們需要將物體的頂點從建模時候的座標系,變換到世界座標系下,這個變換矩陣就是我們說的 model 矩陣,也就是引擎中 transform 組件描述的變換。
  3. 將模型的頂點位置變換到世界座標系下以後,我們還需要進行 view 矩陣的變換,view 變換的過程模擬眼睛看東西的過程,一般用一個相機來描述,這個相機是一般是看向 -z 方向的。我們需要將模型變換到相機的座標系下,方便的後面的投影操作。這個 view 變換,其實不是相機特有的,因爲我們可以將物體變換到任意一下座標系下。
  4. 將物體變換到相機座標系下後,最後要做一個投影的操作,一般來說三維場景做的都是透視變換,符合我看到的近大遠小的規律。

上面用大白話簡單描述了一下這幾個矩陣,相關資料有很多,本系列重在實踐,因爲看再多的理論,不如自己親手實踐一下印象深刻,有時候不明白的原理,動手做一下就明白了。如果希望看相關的數學推導理論,證明之類的可以搜一搜有很多。我這裏提供一下我之前寫的關於變換的兩個文章:

下面來實踐一下,代碼基於第 4 篇文章繼續完善。
完整的代碼:https://github.com/MangoWAY/CGLearner/tree/v0.2,tag v0.2

1. 加載 fbx 模型

在第 3 篇中介紹瞭如何安裝 pyassimp,這回我們來用一下,我們先定義一個簡單的 Mesh 和 SubMesh 類保存加載的模型的數據,然後再定義一個模型加載類,用來加載數據,代碼如下所示,比較簡單。

# mesh.py
class SubMesh:
    def __init__(self, indices) -> None:
        self.indices = indices

class Mesh:
    def __init__(self) -> None:
        self.vertices = []
        self.normals = []
        self.subMeshes = []

# model_importer.py
# pyassimp 4.1.4 has some problem will lead to randomly crash, use 4.1.3 to fix
# should set link path to find the dylib
import pyassimp
import numpy as np
from .mesh import Mesh, SubMesh

class ModelImporter:
    def __init__(self) -> None:
        pass

    def load_mesh(self, path: str):
        scene = pyassimp.load(path)
        mmeshes = []
        for mesh in scene.meshes:
            mmesh = Mesh()
            mmesh.vertices = np.reshape(np.copy(mesh.vertices), (1,-1)).squeeze(0)
            print(mmesh.vertices)
            mmesh.normals = np.reshape(np.copy(mesh.normals),(1,-1)).squeeze(0)
            mmesh.subMeshes = []
            mmesh.subMeshes.append(SubMesh(np.reshape(np.copy(mesh.faces), (1,-1)).squeeze(0)))
            mmeshes.append(mmesh)
        return mmeshes

2. 定義 Transform

Transform 用來描述物體的位置、旋轉、縮放信息,可以說是比較基礎的,所以必不可少,詳細的解釋在代碼的註釋裏。

import numpy as np
from scipy.spatial.transform import Rotation as R

class Transform:

    def __init__(self) -> None:
        # 爲了簡單,目前我用歐拉角來存儲旋轉信息
        self._eulerAngle = [0,0,0]
        self._pos = [0,0,0]
        self._scale = [1,1,1]

    # -- 都是常規的 get set,這裏略去
    # ......

    # 這就是我們所需要的 model 矩陣,注意這裏沒有考慮的物體的層級
    # 關係,默認物體都是在最頂層,所以 local 和 world 座標是一樣
    # 後續的文章會把層級關係考慮進來
    def localMatrix(self):
        # 按照 TRS 的構建方式
        # 位移矩陣 * 旋轉矩陣 * 縮放矩陣
        mat = np.identity(4)
        # 對角線是縮放
        for i in range(3):
            mat[i,i] = self._scale[i]
        rot = np.identity(4)
        rot[:3,:3] = R.from_euler("xyz", self._eulerAngle, degrees = True).as_matrix()
        mat = rot @ mat
        for i in range(3):
            mat[i,3] = self._pos[i]
        return mat

    # 將世界座標變換到當前物體的座標系下,注意這裏也是沒有考慮層級關係的
    # 這個可以用來獲得從世界座標系到相機座標系的轉換。
    def get_to_Local(self):
        mat = self.localMatrix()
        ori = np.identity(4)
        ori[:3,:3] = mat[:3,:3]
        ori = np.transpose(ori)
        pos = np.identity(4)
        pos[0:3,3] = -mat[0:3,3]
        return ori @ pos
        

3.定義相機

最後我們定義相機,目前相機的 Transform 信息可以用來定義 View 矩陣,其他例如 fov 等主要用來定義投影矩陣。

from math import cos, sin
import math
import numpy as np

class Camera:
    def __init__(self) -> None:
        self._fov = 60
        self._near = 0.3
        self._far = 1000
        self._aspect = 5 / 4

    # -- 都是常規的 get set,這裏略去
    # ......
    
    # 完全參照投影矩陣的公式定義
    def getProjectionMatrix(self):
        r = math.radians(self._fov / 2)
        cotangent = cos(r) / sin(r)
        deltaZ = self._near - self._far
        projection = np.zeros((4,4))
        projection[0,0] = cotangent / self._aspect
        projection[1,1] = cotangent
        projection[2,2] = (self._near + self._far) / deltaZ
        projection[2,3] = 2 * self._near * self._far / deltaZ
        projection[3,2] = -1
        return projection

4. 構建 MVP 矩陣

完成了上述的步驟後,我們就可以構建 MVP 矩陣了。

...
# 定義物體的 transform
trans = transform.Transform()
trans.localPosition = [0,0,0]
trans.localScale = [0.005,0.005,0.005]
trans.localEulerAngle = [0,10,0]
# 獲取 model 矩陣
model = trans.localMatrix()

# 定義相機的 transform
viewTrans = transform.Transform()
viewTrans.localPosition = [0,2,2]
viewTrans.localEulerAngle = [-40,0,0]
# 獲取 view 矩陣
view = viewTrans.get_to_Local()

# 定義相機並獲得 projection 矩陣
cam = Camera()
proj = cam.getProjectionMatrix()
# 構建 MVP 矩陣
mvp = np.transpose(proj @ view @ model)
# 作爲 uniform 傳入 shader 中,然後 shader 中將頂點位置乘上mvp矩陣。
mshader.set_mat4("u_mvp", mvp)
...

然後加載模型,構建一下頂點數組和索引數組,我給每個頂點額外添加了隨機的顏色

importer = ModelImporter()

meshes = importer.load_mesh("box.fbx")
vert = []
for i in range(len(meshes[0].vertices)):
    if i % 3 == 0:
        vert.extend([meshes[0].vertices[i],meshes[0].vertices[i + 1],meshes[0].vertices[i + 2]])
        vert.extend([meshes[0].normals[i],meshes[0].normals[i + 1],meshes[0].normals[i + 2]])
        vert.extend([random.random(),random.random(),random.random()])
inde = meshes[0].subMeshes[0].indices
# 開一下深度測試
gl.glEnable(gl.GL_DEPTH_TEST)

我們可以看一下最終效果。

img

總結:

  1. 通過 Transform 我們可以獲得 model 矩陣和 view 矩陣;
  2. 通過相機的參數,我們可以獲得 projection 矩陣;
  3. 按照 p * v * m * pos 的順序,即可將頂點位置進行投影;
  4. 本文代碼沒有考慮層級關係,爲了簡潔,原理都是一樣的;
  5. 爲了簡潔旋轉採用的歐拉角進行存儲,沒有用四元數。
    希望本文的例子,可以幫助理解 MVP 矩陣,以及學習一下如何加載、渲染模型的 API 等。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章