程序员数学:用Python学透线性代数和微积分
上QQ阅读APP看书,第一时间看更新

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)]是相同的三个点,按这个顺序,向量积仍然指向同一个方向。