3.5 在二维平面上渲染三维对象
让我们尝试使用所学的知识来渲染一个简单的三维形状,称为八面体。立方体有6个面,所有面都是正方形;而八面体有8个面,所有面都是三角形。你可以把八面体看成两个互相叠加的四边金字塔。图3-50显示了一个八面体的“骨架”。
图3-50 八面体的骨架拥有8个面和6个顶点。虚线显示了八面体在我们对面的边
如果它是一个实体,我们就看不到对面的边了,只能看到8个三角形面中的4个,如图3-51所示。
图3-51 八面体在当前位置可见的4个带编号的面
渲染八面体归根结底就是确定我们需要显示的4个三角形,并进行适当的着色。让我们看看应该怎么做吧。
3.5.1 使用向量定义三维对象
八面体是一个简单的例子,因为它只有6个角(顶点)。我们可以为其设置简单的坐标:(1, 0, 0)、(0, 1, 0)和(0, 0, 1)以及与它们相反的三个向量,如图3-52所示。
图3-52 八面体的顶点
这6个向量定义了八面体形状的边界,但是没有提供绘制八面体所需的全部信息。我们还需要决定连接哪些点作为图形的边。例如,图3-52中的顶点是(0, 0, 1),它通过边与平面上的所有4个点相连(见图3-53)。
图3-53 用箭头表示八面体的4条边
这些边勾勒出了八面体顶部金字塔的轮廓。注意,(0, 0, 1)和(0, 0, -1)之间没有边,因为这条线段位于八面体内部,而不是外部。每条边由一对向量定义:将边看作线段,两个向量分别表示其起点和终点。例如,(0, 0, 1)和(1, 0, 0)定义了其中一条边。
只有边还不足以完成绘图,还需要知道哪三个顶点和哪三条边能组成三角形,我们要用明暗不同的纯色填充这些三角形面。这就是方向的作用:我们不仅要知道哪些线段定义了各个面,还要知道它们是面向我们还是背向我们的。
策略如下:将一个三角形面建模为三个向量、和,用来定义它的边。(注意,这里我用下标1、2和3来区分三个不同的向量,而不是同一个向量的分量。)具体来说,我们会将、和排序,使指向八面体之外(见图3-54)。如果一个向外的向量是指向我们的,就意味着从我们的视角可以看到这个面。否则,这个面就是被遮挡的,不需要绘制。
图3-54 八面体的一个面。对定义面的三个点进行排序,使指向八面体外
我们可以将这8个三角形面都定义为三个向量、和的三元组,如下所示。
octahedron = [
[(1,0,0), (0,1,0), (0,0,1)],
[(1,0,0), (0,0,-1), (0,1,0)],
[(1,0,0), (0,0,1), (0,-1,0)],
[(1,0,0), (0,-1,0), (0,0,-1)],
[(-1,0,0), (0,0,1), (0,1,0)],
[(-1,0,0), (0,1,0), (0,0,-1)],
[(-1,0,0), (0,-1,0), (0,0,1)],
[(-1,0,0), (0,0,-1), (0,-1,0)],
]
实际上,有这些面的数据就足以渲染形状了,因为它们包含了边和顶点。例如,我们可以通过以下函数从面中获取顶点。
def vertices(faces):
return list(set([vertex for face in faces for vertex in face]))
3.5.2 二维投影
要把三维点变成二维点,必须选择我们的三维观察方向。一旦从我们的视角确定了定义“上”和“右”的两个三维向量,就可以将任意三维向量投射到它们上面,得到两个分量而不是三个分量。component
函数利用点积提取三维向量在给定方向上的分量。
def component(v,direction):
return (dot(v,direction) / length(direction))
通过对两个方向硬编码(在本例中是(1, 0, 0)和(0, 1, 0)),我们可以建立一种从三个坐标向下投影到两个坐标的方法。这个函数接收一个三维向量或三个数组成的元组,并返回一个二维向量或两个数组成的元组。
def vector_to_2d(v):
return (component(v,(1,0,0)), component(v,(0,1,0)))
我们可以将其描绘成把三维向量“压平”到平面上。删除分量会使向量的深度消失(见图3-55)。
图3-55 删除三维向量的分量,将其转换到平面上
最后,要把三角形从三维转换成二维的,我们只需要把这个函数应用到定义面的所有顶点上。
def face_to_2d(face):
return [vector_to_2d(vertex) for vertex in face]
3.5.3 确定面的朝向和阴影
为了给二维绘图着色,我们根据每个三角形面对给定光源的角度大小,为其选择一个固定的颜色。假设光源在基于原点的坐标(1, 2, 3)向量处,那么三角形面的亮度取决于它与光线的垂直度。另一种测量方法是借助垂直于面的向量与光源的对齐程度。我们不必担心颜色的计算,Matplotlib有一个内置的库来做这些工作。例如:
blues = matplotlib.cm.get_cmap('Blues')
提供了一个叫作blues
的函数,它将从0到1的数映射到由暗到亮的蓝色光谱上。我们的任务是找出一个0和1之间的数,表示一个面的明亮程度。
给定一个垂直于每个面的向量(法线)和一个指向光源的向量,它们的点积就说明了其对齐程度。此外,由于我们只考虑方向,可以选择长度为1的向量。那么,如果该面完全朝向光源,点积介于0和1之间。如果它与光源的角度超过90°,将完全不能被照亮。这个辅助函数接收一个向量,并返回另一个相同方向但长度为1的向量。
def unit(v):
return scale(1./length(v), v)
第二个辅助函数接收一个面,并返回一个垂直于它的向量。
def normal(face):
return(cross(subtract(face[1], face[0]), subtract(face[2], face[0])))
把它们结合起来,就得到了一个绘制三角形的函数。它调用draw
函数(我把draw
重命名为draw2d
,并相应地重命名了这些类,以区别于它们的三维版本)来渲染三维模型。
def render(faces, light=(1,2,3), color_map=blues, lines=None): polygons = [] for face in faces: unit_normal = unit(normal(face)) ←---- 对于每个面,计算一个长度为1、垂直于它的向量 if unit_normal[2 ] > 0 : ←---- 只有当向量的z分量为正时(换句话说,当它指向观察者时),才会继续执行 c = color_map(1 - dot(unit(normal(face)), unit(light))) ←---- 法线向量和光源向量的点积越大,阴影越少 p = Polygon2D(*face_to_2d(face), fill=c, color=lines) ←---- 为每个三角形的边指定一个可选的lines参数,显示正在绘制的形状骨架 polygons.append(p) draw2d(*polygons,axes=False, origin=False, grid=None)
使用下面的render
函数,只需要几行代码就可以生成一个八面体。图3-56显示了结果。
render(octahedron, color_map=matplotlib.cm.get_cmap('Blues'), lines=black)
图3-56 八面体的四个可见面,呈现出明暗不同的蓝色
这样看,带阴影的八面体并没有什么特别的地方,但是随着增加更多的面,阴影的作用就会显现出来(见图3-57)。你可以在本书的源代码中找到拥有更多面的预建形状。
图3-57 具有许多三角形边的三维形状,阴影的效果更加明显
3.5.4 练习
练习3.27(小项目):找到定义八面体12条边的向量对,并用Python绘制出所有的边。
解:八面体的顶部是(0, 0, 1)。它通过4条边与平面上的全部4个点相连。同样,八面体的底部是(0, 0, -1),它也连接到平面上的全部4个点。最后,平面上的4个点相互连接形成正方形(见图3-58)。
top = (0,0,1) bottom = (0,0,-1) xy_plane = [(1,0,0),(0,1,0),(-1,0,0),(0,-1,0)] edges = [Segment3D(top,p) for p in xy_plane] +\ [Segment3D(bottom, p) for p in xy_plane] +\ [Segment3D(xy_plane[i],xy_plane[(i+1)%4 ]) for i in range(0,4)] draw3d(*edges)
图3-58 最终生成的八面体的边
练习3.28:八面体的第一个面是[(1, 0, 0), (0, 1, 0), (0, 0, 1)]。这是定义该面顶点的唯一有效顺序吗?
解:不是,比如[(0, 1, 0), (0, 0, 1), (1, 0, 0)]是相同的三个点,按这个顺序,向量积仍然指向同一个方向。