[CG从零开始] 5. 搞清 MVP 矩阵理论 + 实践
在 4 中成功绘制了三角形以后,下面我们来加载一个 fbx 文件,然后构建 MVP 变换(model-view-projection)。简单介绍一下:
- 从我们拿到模型(主要是网格信息)文件开始,模型网格(Mesh)里记录模型的顶点位置信息,比方说 (-1,1,1) 点,那么这个点是相对于这个模型的(0,0,0)点来说的,这和我们在制作模型的时候有关,例如我可以让这个(0,0,0)点位于模型的中心也可以是底部。
- 接着我们需要通过放置许多的模型来构建整个场景,为了描述每个物体的位姿(位置和姿态),我们需要一个世界原点,然后所有物体的位姿信息都是相对于这个世界原点的。如果用过游戏引擎或者 DCC 软件的话,一般每个物体都会有一个 transform 来描述这件事情。因此第一步我们需要将物体的顶点从建模时候的坐标系,变换到世界坐标系下,这个变换矩阵就是我们说的 model 矩阵,也就是引擎中 transform 组件描述的变换。
- 将模型的顶点位置变换到世界坐标系下以后,我们还需要进行 view 矩阵的变换,view 变换的过程模拟眼睛看东西的过程,一般用一个相机来描述,这个相机是一般是看向 -z 方向的。我们需要将模型变换到相机的坐标系下,方便的后面的投影操作。这个 view 变换,其实不是相机特有的,因为我们可以将物体变换到任意一下坐标系下。
- 将物体变换到相机坐标系下后,最后要做一个投影的操作,一般来说三维场景做的都是透视变换,符合我看到的近大远小的规律。
上面用大白话简单描述了一下这几个矩阵,相关资料有很多,本系列重在实践,因为看再多的理论,不如自己亲手实践一下印象深刻,有时候不明白的原理,动手做一下就明白了。如果希望看相关的数学推导理论,证明之类的可以搜一搜有很多。我这里提供一下我之前写的关于变换的两个文章:
下面来实践一下,代码基于第 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)
我们可以看一下最终效果。

总结:
- 通过 Transform 我们可以获得 model 矩阵和 view 矩阵;
- 通过相机的参数,我们可以获得 projection 矩阵;
- 按照 p * v * m * pos 的顺序,即可将顶点位置进行投影;
- 本文代码没有考虑层级关系,为了简洁,原理都是一样的;
- 为了简洁旋转采用的欧拉角进行存储,没有用四元数。
希望本文的例子,可以帮助理解 MVP 矩阵,以及学习一下如何加载、渲染模型的 API 等。
[CG从零开始] 5. 搞清 MVP 矩阵理论 + 实践的更多相关文章
- [CG从零开始] 6. 加载一个柴犬模型学习UV贴图
在第 5 篇文章中,我们成功加载了 fbx 模型,并且做了 MVP 变换,将立方体按照透视投影渲染了出来.但是当时只是随机给顶点颜色,并且默认 fbx 文件里只有一个 mesh,这次我们来加载一个柴犬 ...
- Android应用中MVP最佳实践
转自:http://www.jianshu.com/p/ed2aa9546c2c 文/Jude95(简书作者)原文链接:http://www.jianshu.com/p/ed2aa9546c2c著作权 ...
- 2017微软 MVP 数据实践技术活动日(北京站)
Power BI | 交互式数据可视化 BI 工具 EXCEL BI :无所不能的业务数据分析利器 EXCEL +POWERBI=EXCEL BI https://edu.hellobi.com/co ...
- [CG从零开始] 3. 安装 pyassimp 库加载模型文件
assimp 是一个开源的模型加载库,支持非常多的格式,还有许多语言的 binding,这里我们选用 assimp 的 python 的 binding 来加载模型文件.不过社区主要是在维护 assi ...
- [CG从零开始] 4. pyopengl 绘制一个正方形
在做了 1-3 的基础工作后,我们的开发环境基本 OK 了,我们可以开始尝试利用 pyopengl 来进行绘制了. 本文主要有三个部分 利用 glfw 封装窗口类,并打开窗口: 封装 shader 类 ...
- Android开发MVP模式解析
http://www.cnblogs.com/bravestarrhu/archive/2012/05/02/2479461.html 在开发Android应用时,相信很多同学遇到和我一样的情况,虽然 ...
- android MVP框架
原文地址:http://blog.csdn.net/guxiao1201/article/details/40147209 在开发Android应用时,相信很多同学遇到和我一样的情况,虽然项目刚开始构 ...
- Android MVP Presenter 中引发的空指针异常
一.概述 最近对 googlesamples/android-architecture 中的 MVP-dagger 进行了学习.对照项目的 MVP-dagger 分支,对 MVP-dagger 进行了 ...
- 使用MVP模式重构代码
之前写了两篇关于MVP模式的文章,主要讲得都是一些概念,这里谈谈自己在Android项目中使用MVP模式的真实感受,并以实例的形式一起尝试来使用MVP模式去重构我们现有的代码. 有兴趣的童鞋可以先去阅 ...
随机推荐
- File类创建删除功能的方法和File类遍历目录功能
File类创建删除功能的方法 public boolean createNewFile();当且仅当具有该名称的文件尚不存在的时候,创建一个新的空文件 public boolean delete(); ...
- 【MySQL 8】Generated Invisible Primary Keys(GIPK)
从MySQL 8.0.30开始,MySQL支持在GIPK模式下运行时生成不可见的主键.在这种模式下运行时,对于任何在没有显式主键的情况下创建的InnoDB表,MySQL服务器会自动将生成的不可见主键 ...
- 工作效率-十五分钟让你快速学习Markdown语法到精通排版实践备忘
关注「WeiyiGeek」公众号 设为「特别关注」每天带你玩转网络安全运维.应用开发.物联网IOT学习! 希望各位看友[关注.点赞.评论.收藏.投币],助力每一个梦想. 文章目录: 0x00 前言简述 ...
- 自定义bean对象实现序列化接口
上一个word count的案例中,我们为了理解mapreduce的流程,写了上面的代码.现在我们要把一个实体类序列化.比如现在有这么一个文件,里面的数据格式是这样的: 第一列是时间戳,第二列是手机号 ...
- PHP反向代理(转)
<?php class PhpReverseProxy{ public $publicBaseURL; public $outsideHeaders; public $XRequestedWit ...
- linux 磁盘io利用率高,分析的正确姿势
一.背景简介 作为一个DBA难免不了会遇到性能问题,那么我们遇到性能问题该如何进行排查呢?例如我们在高并发的业务下,出现业务响应慢,处理时间长我们又该如何入手进行排查,本片文章将分析io高的情况下如何 ...
- 《吐血整理》进阶系列教程-拿捏Fiddler抓包教程(12)-Fiddler设置IOS手机抓包,你知多少???
1.简介 Fiddler不但能截获各种浏览器发出的 HTTP 请求,也可以截获各种智能手机发出的HTTP/ HTTPS 请求. Fiddler 能捕获Android 和 Windows Phone 等 ...
- JavaDoc文档生成详细操作
JavaDoc练习 JavaDoc是一种将注释生成HTML文档的技术,是用来生成自己API文档的. 参数信息 /* @author 作者名 @version 版本号 @since 知名最早需要使用的j ...
- Vue 列动态取值
在前端开发过程中,可能会遇到列动态取值的情况,即列表中某列的取值由两个或以上的字段的值决定. 用 Vue 实现的话可以用如下代码解决 <template slot-scope="sco ...
- Java 断点下载(下载续传)服务端及客户端(Android)代码
原文: Java 断点下载(下载续传)服务端及客户端(Android)代码 - Stars-One的杂货小窝 最近在研究断点下载(下载续传)的功能,此功能需要服务端和客户端进行对接编写,本篇也是记录一 ...