Matplotlib 有一个非常棒的3D 接口,它拥有许多功能(也有一些限制),并且在用户中非常受欢迎。然而,对于一些用户(或者可能是大多数用户)来说,3D 渲染仍然被认为是一种“魔法”。因此,我想在这篇文章中解释一下,一旦你理解了一些基本概念,3D 渲染其实非常简单。为了证明这一点,我们将使用 60 行 Python 代码和一个 Matplotlib 调用来渲染上面的兔子模型。也就是说,我们不会使用 3D 轴。
广告:这篇文章来自一本即将出版的关于使用 Python 和 Matplotlib 进行科学可视化的开放获取书籍。如果您想支持我的工作并提前访问这本书,请访问https://github.com/rougier/scientific-visualization-book。
加载兔子模型#
首先,我们需要加载我们的模型。我们将使用斯坦福兔子模型的简化版本。该文件使用Wavefront 格式,这是一种最简单的格式之一,因此让我们编写一个非常简单的(但容易出错的)加载器,它只需完成这篇文章(以及这个模型)所需的任务。
V, F = [], []
with open("bunny.obj") as f:
for line in f.readlines():
if line.startswith('#'):
continue
values = line.split()
if not values:
continue
if values[0] == 'v':
V.append([float(x) for x in values[1:4]])
elif values[0] == 'f':
F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1
V
现在是一组顶点(如果你愿意,可以称为 3D 点),F
是一组面(= 三角形)。每个三角形由 3 个相对于顶点数组的索引描述。现在,让我们标准化顶点,以便整个兔子模型适应单位盒。
V = (V-(V.max(0)+V.min(0))/2)/max(V.max(0)-V.min(0))
现在,我们可以通过仅获取顶点的 x、y 坐标并去除 z 坐标来初步查看模型。为此,我们可以使用功能强大的PolyCollection 对象,它允许高效地渲染非规则多边形集合。由于我们想要渲染一堆三角形,因此这是一个完美的匹配。所以让我们首先提取三角形并去除z
坐标。
T = V[F][...,:2]
现在我们可以渲染它了。
fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,+1], ylim=[-1,+1],
aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1,
facecolor="None", edgecolor="black")
ax.add_collection(collection)
plt.show()
你应该得到类似这样的结果(bunny-1.py)。
透视投影#
我们刚刚进行的渲染实际上是正交投影,而上面的兔子使用了透视投影。
在这两种情况下,定义投影的正确方法是首先定义一个视景体,即我们要在屏幕上渲染的 3D 空间中的体积。为此,我们需要考虑 6 个裁剪平面(左、右、上、下、远、近),它们相对于相机包围视景体(平截头体)。如果我们定义一个相机位置和一个观察方向,则每个平面都可以用一个标量来描述。一旦我们有了这个视景体,我们就可以使用正交投影或透视投影将其投影到屏幕上。
对我们来说幸运的是,这些投影非常著名,并且可以用 4x4 矩阵表示。
def frustum(left, right, bottom, top, znear, zfar):
M = np.zeros((4, 4), dtype=np.float32)
M[0, 0] = +2.0 * znear / (right - left)
M[1, 1] = +2.0 * znear / (top - bottom)
M[2, 2] = -(zfar + znear) / (zfar - znear)
M[0, 2] = (right + left) / (right - left)
M[2, 1] = (top + bottom) / (top - bottom)
M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
M[3, 2] = -1.0
return M
def perspective(fovy, aspect, znear, zfar):
h = np.tan(0.5*radians(fovy)) * znear
w = h * aspect
return frustum(-w, w, -h, h, znear, zfar)
对于透视投影,我们还需要指定光圈角,它(或多或少)设置了近平面相对于远平面的尺寸。因此,对于高光圈,你会得到很多“变形”。
但是,如果你查看上面的两个函数,你会发现它们返回 4x4 矩阵,而我们的坐标是 3D 的。那么如何使用这些矩阵呢?答案是齐次坐标。长话短说,齐次坐标最适合处理 3D 中的变换和投影。在我们的例子中,因为我们处理的是顶点(而不是向量),所以我们只需要将 1 作为第四个坐标(w
)添加到所有顶点中。然后我们可以使用点积应用透视变换。
V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T
最后一步,我们需要重新标准化齐次坐标。这意味着我们将每个变换后的顶点除以最后一个分量(w
),以便每个顶点的w
始终为 1。
V /= V[:,3].reshape(-1,1)
现在我们可以再次显示结果了(bunny-2.py)。
哦,奇怪的结果。哪里出错了?问题在于相机实际上在兔子内部。为了进行正确的渲染,我们需要将兔子移开相机,或者将相机移开兔子。让我们执行后者。相机当前位于 (0,0,0) 处,并且沿着 z 方向向上看(因为存在平截头体变换)。因此,我们需要将相机在 z 负方向上稍微移开一点,并且**在透视变换之前**。
V = V - (0,0,3.5)
V = np.c_[V, np.ones(len(V))] @ perspective(25,1,1,100).T
V /= V[:,3].reshape(-1,1)
现在你应该得到(bunny-3.py)。
模型、视图、投影 (MVP)#
可能不太明显,但最后的渲染实际上是一个透视变换。为了使其更加明显,我们将围绕兔子旋转。为此,我们需要一些旋转矩阵 (4x4),并且我们可以在此期间定义平移矩阵。
def translate(x, y, z):
return np.array([[1, 0, 0, x],
[0, 1, 0, y],
[0, 0, 1, z],
[0, 0, 0, 1]], dtype=float)
def xrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[1, 0, 0, 0],
[0, c, -s, 0],
[0, s, c, 0],
[0, 0, 0, 1]], dtype=float)
def yrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[ c, 0, s, 0],
[ 0, 1, 0, 0],
[-s, 0, c, 0],
[ 0, 0, 0, 1]], dtype=float)
我们现在将要应用的变换分解为模型(局部变换)、视图(全局变换)和投影,以便我们可以计算一个全局 MVP 矩阵来一次完成所有操作。
model = xrotate(20) @ yrotate(45)
view = translate(0,0,-3.5)
proj = perspective(25, 1, 1, 100)
MVP = proj @ view @ model
现在我们编写:
V = np.c_[V, np.ones(len(V))] @ MVP.T
V /= V[:,3].reshape(-1,1)
你应该得到(bunny-4.py)。
现在让我们稍微调整一下光圈,这样你就可以看到区别。请注意,我们还必须调整到相机的距离,以便兔子具有相同的表观尺寸(bunny-5.py)。
深度排序#
现在让我们尝试填充三角形(bunny-6.py)。
如你所见,结果“有趣”且完全错误。问题在于 PolyCollection 将按照给定的顺序绘制三角形,而我们希望它们从后到前绘制。这意味着我们需要根据它们的深度对它们进行排序。好消息是我们已经在应用 MVP 变换时计算了此信息。它存储在新的 z 坐标中。但是,这些 z 值是基于顶点的,而我们需要对三角形进行排序。因此,我们将采用平均 z 值作为三角形深度的代表。如果三角形相对较小且不交叉,则效果非常好。
T = V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
I = np.argsort(Z)
T = T[I,:]
现在一切都正确渲染了(bunny-7.py)。
让我们使用深度缓冲区添加一些颜色。我们将根据每个三角形的深度对其进行着色。PolyCollection 对象的美妙之处在于,你可以使用 NumPy 数组指定每个三角形的颜色,所以让我们这样做。
zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap("magma")(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]
现在一切都正确渲染了(bunny-8.py)。
最终脚本只有 57 行(但几乎不可读)。
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.collections import PolyCollection
def frustum(left, right, bottom, top, znear, zfar):
M = np.zeros((4, 4), dtype=np.float32)
M[0, 0] = +2.0 * znear / (right - left)
M[1, 1] = +2.0 * znear / (top - bottom)
M[2, 2] = -(zfar + znear) / (zfar - znear)
M[0, 2] = (right + left) / (right - left)
M[2, 1] = (top + bottom) / (top - bottom)
M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
M[3, 2] = -1.0
return M
def perspective(fovy, aspect, znear, zfar):
h = np.tan(0.5*np.radians(fovy)) * znear
w = h * aspect
return frustum(-w, w, -h, h, znear, zfar)
def translate(x, y, z):
return np.array([[1, 0, 0, x], [0, 1, 0, y],
[0, 0, 1, z], [0, 0, 0, 1]], dtype=float)
def xrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[1, 0, 0, 0], [0, c, -s, 0],
[0, s, c, 0], [0, 0, 0, 1]], dtype=float)
def yrotate(theta):
t = np.pi * theta / 180
c, s = np.cos(t), np.sin(t)
return np.array([[ c, 0, s, 0], [ 0, 1, 0, 0],
[-s, 0, c, 0], [ 0, 0, 0, 1]], dtype=float)
V, F = [], []
with open("bunny.obj") as f:
for line in f.readlines():
if line.startswith('#'): continue
values = line.split()
if not values: continue
if values[0] == 'v': V.append([float(x) for x in values[1:4]])
elif values[0] == 'f' : F.append([int(x) for x in values[1:4]])
V, F = np.array(V), np.array(F)-1
V = (V-(V.max(0)+V.min(0))/2) / max(V.max(0)-V.min(0))
MVP = perspective(25,1,1,100) @ translate(0,0,-3.5) @ xrotate(20) @ yrotate(45)
V = np.c_[V, np.ones(len(V))] @ MVP.T
V /= V[:,3].reshape(-1,1)
V = V[F]
T = V[:,:,:2]
Z = -V[:,:,2].mean(axis=1)
zmin, zmax = Z.min(), Z.max()
Z = (Z-zmin)/(zmax-zmin)
C = plt.get_cmap("magma")(Z)
I = np.argsort(Z)
T, C = T[I,:], C[I,:]
fig = plt.figure(figsize=(6,6))
ax = fig.add_axes([0,0,1,1], xlim=[-1,+1], ylim=[-1,+1], aspect=1, frameon=False)
collection = PolyCollection(T, closed=True, linewidth=0.1, facecolor=C, edgecolor="black")
ax.add_collection(collection)
plt.show()
现在轮到你来玩了。从这个简单的脚本开始,你可以获得有趣的结果。