500 行或更少
3D 建模器

Erick Dransch

Erick 是一位软件开发人员,也是 2D 和 3D 计算机图形爱好者。他曾在电子游戏、3D 特效软件和计算机辅助设计工具方面工作。如果涉及模拟现实,他很可能想了解更多。你可以在 erickdransch.com 找到他。

简介

人类天生具有创造力。我们不断设计和构建新颖、实用且有趣的事物。在现代,我们编写软件来辅助设计和创作过程。计算机辅助设计 (CAD) 软件允许创作者在构建物理版本的设计之前设计建筑物、桥梁、电子游戏艺术、电影怪物、3D 可打印物体等等。

CAD 工具的核心是将三维设计抽象为可以在二维屏幕上查看和编辑的内容。为了满足这一定义,CAD 工具必须提供三个基本的功能。首先,它们必须具有一个数据结构来表示正在设计的对象:这是计算机对用户正在构建的三维世界的理解。其次,CAD 工具必须提供某种方式在用户屏幕上显示设计。用户正在设计一个具有 3 个维度的物理物体,但计算机屏幕只有 2 个维度。CAD 工具必须模拟我们如何感知物体,并将它们以用户可以理解物体所有 3 个维度的方式绘制到屏幕上。第三,CAD 工具必须提供一种与正在设计的对象进行交互的方式。用户必须能够添加和修改设计,以产生所需的结果。此外,所有工具都需要一种方法从磁盘保存和加载设计,以便用户可以协作、共享和保存他们的工作。

特定领域 CAD 工具为该领域的特定要求提供了许多附加功能。例如,建筑 CAD 工具将提供物理模拟来测试气候对建筑物的压力,3D 打印工具将具有检查物体是否实际上有效打印的功能,电子 CAD 工具将模拟电流通过铜线的物理特性,而电影特效套件将包括准确模拟热动力学的功能。

但是,所有 CAD 工具都必须至少包括上面讨论的三个功能:一个数据结构来表示设计、能够将其显示到屏幕上以及一种与设计进行交互的方法。

考虑到这一点,让我们探讨如何在 500 行 Python 代码中表示 3D 设计、将其显示到屏幕上并与之交互。

渲染作为指南

3D 建模器中许多设计决策的驱动力是渲染过程。我们希望能够在设计中存储和渲染复杂的对象,但我们希望保持渲染代码的复杂性较低。让我们检查渲染过程,并探索设计的数据结构,该结构允许我们使用简单的渲染逻辑存储和绘制任意复杂的对象。

管理界面和主循环

在我们开始渲染之前,我们需要设置一些东西。首先,我们需要创建一个窗口来显示我们的设计。其次,我们希望与图形驱动程序通信以渲染到屏幕上。我们不想直接与图形驱动程序通信,因此我们使用一个跨平台抽象层,称为 OpenGL,以及一个名为 GLUT(OpenGL 实用工具包)的库来管理我们的窗口。

关于 OpenGL 的说明

OpenGL 是一个用于跨平台开发的图形应用程序编程接口。它是跨平台开发图形应用程序的标准 API。OpenGL 有两个主要变体:传统 OpenGL 和现代 OpenGL。

OpenGL 中的渲染基于由顶点和法线定义的多边形。例如,要渲染一个立方体的侧面,我们指定 4 个顶点和该侧面的法线。

传统 OpenGL 提供了一个“固定功能管道”。通过设置全局变量,程序员可以启用和禁用照明、着色、面剔除等功能的自动化实现。然后,OpenGL 会自动使用已启用的功能渲染场景。此功能已弃用。

另一方面,现代 OpenGL 具有可编程渲染管道,程序员可以在其中编写称为“着色器”的小程序,这些程序在专用图形硬件 (GPU) 上运行。现代 OpenGL 的可编程管道已取代传统 OpenGL。

在这个项目中,尽管它已弃用,但我们仍然使用传统 OpenGL。传统 OpenGL 提供的固定功能对于保持代码大小很小非常有用。它减少了所需的线性代数知识量,并且简化了我们将编写的代码。

关于 GLUT

GLUT 与 OpenGL 捆绑在一起,允许我们创建操作系统窗口并注册用户界面回调。此基本功能足以满足我们的需求。如果我们想要一个更全面的库来管理窗口和用户交互,我们可以考虑使用 GTK 或 Qt 等完整的窗口工具包。

查看器

为了管理 GLUT 和 OpenGL 的设置,并驱动建模器的其余部分,我们创建了一个名为 Viewer 的类。我们使用单个 Viewer 实例来管理窗口创建和渲染,并包含我们程序的主循环。在 Viewer 的初始化过程中,我们创建 GUI 窗口并初始化 OpenGL。

函数 init_interface 创建建模器将渲染到的窗口,并指定在需要渲染设计时要调用的函数。函数 init_opengl 设置项目所需的 OpenGL 状态。它设置矩阵、启用背面剔除、注册一个光源来照亮场景,并告诉 OpenGL 我们希望对象是彩色的。函数 init_scene 创建 Scene 对象,并放置一些初始节点以帮助用户入门。我们将在稍后看到有关 Scene 数据结构的更多信息。最后,init_interaction 注册用户交互的回调函数,我们将在稍后讨论。

初始化 Viewer 后,我们调用 glutMainLoop 将程序执行传递给 GLUT。此函数永远不会返回。当这些事件发生时,我们已在 GLUT 事件上注册的回调函数将被调用。

class Viewer(object):
    def __init__(self):
        """ Initialize the viewer. """
        self.init_interface()
        self.init_opengl()
        self.init_scene()
        self.init_interaction()
        init_primitives()

    def init_interface(self):
        """ initialize the window and register the render function """
        glutInit()
        glutInitWindowSize(640, 480)
        glutCreateWindow("3D Modeller")
        glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB)
        glutDisplayFunc(self.render)

    def init_opengl(self):
        """ initialize the opengl settings to render the scene """
        self.inverseModelView = numpy.identity(4)
        self.modelView = numpy.identity(4)

        glEnable(GL_CULL_FACE)
        glCullFace(GL_BACK)
        glEnable(GL_DEPTH_TEST)
        glDepthFunc(GL_LESS)

        glEnable(GL_LIGHT0)
        glLightfv(GL_LIGHT0, GL_POSITION, GLfloat_4(0, 0, 1, 0))
        glLightfv(GL_LIGHT0, GL_SPOT_DIRECTION, GLfloat_3(0, 0, -1))

        glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE)
        glEnable(GL_COLOR_MATERIAL)
        glClearColor(0.4, 0.4, 0.4, 0.0)

    def init_scene(self):
        """ initialize the scene object and initial scene """
        self.scene = Scene()
        self.create_sample_scene()

    def create_sample_scene(self):
        cube_node = Cube()
        cube_node.translate(2, 0, 2)
        cube_node.color_index = 2
        self.scene.add_node(cube_node)

        sphere_node = Sphere()
        sphere_node.translate(-2, 0, 2)
        sphere_node.color_index = 3
        self.scene.add_node(sphere_node)

        hierarchical_node = SnowFigure()
        hierarchical_node.translate(-2, 0, -2)
        self.scene.add_node(hierarchical_node)

    def init_interaction(self):
        """ init user interaction and callbacks """
        self.interaction = Interaction()
        self.interaction.register_callback('pick', self.pick)
        self.interaction.register_callback('move', self.move)
        self.interaction.register_callback('place', self.place)
        self.interaction.register_callback('rotate_color', self.rotate_color)
        self.interaction.register_callback('scale', self.scale)

    def main_loop(self):
        glutMainLoop()

if __name__ == "__main__":
    viewer = Viewer()
    viewer.main_loop()

在我们深入研究 render 函数之前,我们应该讨论一些线性代数。

坐标空间

对于我们的目的,坐标空间是一个原点和一组 3 个基向量,通常是 \(x\)\(y\)\(z\) 轴。

三维空间中的任何点都可以表示为从原点在 \(x\)\(y\)\(z\) 方向上的偏移。点的表示相对于点所在的坐标空间。同一个点在不同的坐标空间中具有不同的表示。三维空间中的任何点都可以表示在任何三维坐标空间中。

向量

向量是 \(x\)\(y\)\(z\) 值,分别表示两个点在 \(x\)\(y\)\(z\) 轴上的差值。

变换矩阵

在计算机图形学中,使用多个不同的坐标空间来表示不同类型的点很方便。变换矩阵将点从一个坐标空间转换为另一个坐标空间。要将向量 \(v\) 从一个坐标空间转换为另一个坐标空间,我们乘以一个变换矩阵 \(M\)\(v' = M v\)。一些常见的变换矩阵是平移、缩放和旋转。

模型、世界、视图和投影坐标空间

Figure 13.1 - Transformation Pipeline

图 13.1 - 变换管道

要将项目绘制到屏幕上,我们需要在几个不同的坐标空间之间转换。

图 13.11 的右侧,包括从眼空间到视口空间的所有变换,都将由 OpenGL 为我们处理。

从眼空间到齐次裁剪空间的转换由 gluPerspective 处理,而到标准化设备空间和视口空间的转换由 glViewport 处理。这两个矩阵相乘并存储为 GL_PROJECTION 矩阵。对于这个项目,我们不需要知道这些矩阵的术语或工作原理的细节。

但是,我们需要自己管理图表的左侧。我们定义一个矩阵,该矩阵将模型(也称为网格)中的点从模型空间转换为世界空间,称为模型矩阵。我们还定义了视图矩阵,它将世界空间转换为眼空间。在这个项目中,我们将这两个矩阵组合在一起以获得 ModelView 矩阵。

要了解有关完整图形渲染管道的更多信息,以及所涉及的坐标空间,请参阅 实时渲染 的第 2 章,或其他计算机图形学入门书籍。

使用查看器进行渲染

函数 render 从设置在渲染时需要执行的任何 OpenGL 状态开始。它通过 init_view 初始化投影矩阵,并使用交互成员中的数据使用将场景空间转换为世界空间的变换矩阵初始化 ModelView 矩阵。我们将在下面看到有关 Interaction 类的更多信息。它使用 glClear 清除屏幕,并告诉场景渲染自身,然后渲染单位网格。

我们在渲染网格之前禁用 OpenGL 的照明。禁用照明后,OpenGL 将使用纯色渲染项目,而不是模拟光源。这样,网格在视觉上与场景区分开来。最后,glFlush 向图形驱动程序发出信号,表明我们已准备好刷新缓冲区并将其显示到屏幕上。

    # class Viewer
    def render(self):
        """ The render pass for the scene """
        self.init_view()

        glEnable(GL_LIGHTING)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        # Load the modelview matrix from the current state of the trackball
        glMatrixMode(GL_MODELVIEW)
        glPushMatrix()
        glLoadIdentity()
        loc = self.interaction.translation
        glTranslated(loc[0], loc[1], loc[2])
        glMultMatrixf(self.interaction.trackball.matrix)

        # store the inverse of the current modelview.
        currentModelView = numpy.array(glGetFloatv(GL_MODELVIEW_MATRIX))
        self.modelView = numpy.transpose(currentModelView)
        self.inverseModelView = inv(numpy.transpose(currentModelView))

        # render the scene. This will call the render function for each object
        # in the scene
        self.scene.render()

        # draw the grid
        glDisable(GL_LIGHTING)
        glCallList(G_OBJ_PLANE)
        glPopMatrix()

        # flush the buffers so that the scene can be drawn
        glFlush()

    def init_view(self):
        """ initialize the projection matrix """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        aspect_ratio = float(xSize) / float(ySize)

        # load the projection matrix. Always the same
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()

        glViewport(0, 0, xSize, ySize)
        gluPerspective(70, aspect_ratio, 0.1, 1000.0)
        glTranslated(0, 0, -15)

渲染内容:场景

现在我们已经初始化了渲染管道以处理在世界坐标空间中绘制,我们要渲染什么?回想一下,我们的目标是拥有一个包含 3D 模型的设计。我们需要一个数据结构来保存设计,并且需要使用此数据结构来渲染设计。请注意上面我们从查看器的渲染循环中调用了 self.scene.render()。场景是什么?

Scene 类是我们用来表示设计的 data structure 的接口。它抽象了 data structure 的细节,并提供了与设计交互所需的必要接口函数,包括渲染、添加项目和操作项目的功能。有一个由查看器拥有的 Scene 对象。Scene 实例保存场景中所有项目的列表,称为 node_list。它还跟踪选定的项目。场景上的 render 函数只是在 node_list 的每个成员上调用 render

class Scene(object):

    # the default depth from the camera to place an object at
    PLACE_DEPTH = 15.0

    def __init__(self):
        # The scene keeps a list of nodes that are displayed
        self.node_list = list()
        # Keep track of the currently selected node.
        # Actions may depend on whether or not something is selected
        self.selected_node = None

    def add_node(self, node):
        """ Add a new node to the scene """
        self.node_list.append(node)

    def render(self):
        """ Render the scene. """
        for node in self.node_list:
            node.render()

节点

在 Scene 的 render 函数中,我们在 Scene 的 node_list 中的每个项目上调用 render。但是列表的元素是什么?我们称它们为 *节点*。从概念上讲,节点是任何可以放置在场景中的东西。在面向对象软件中,我们编写 Node 作为抽象基类。任何表示要放置在 Scene 中的对象的类都将继承自 Node。此基类允许我们抽象地推断场景。代码库的其余部分不需要了解它显示的对象的细节;它只需要知道它们属于 Node 类。

每个类型的Node定义了其自身的渲染行为以及其他任何交互行为。Node会跟踪关于自身的重要数据:平移矩阵、缩放矩阵、颜色等。将节点的平移矩阵乘以其缩放矩阵得到从节点模型坐标系到世界坐标系的变换矩阵。节点还存储一个轴对齐包围盒 (AABB)。我们将在下面讨论选择时看到有关 AABB 的更多信息。

Node 最简单的具体实现是一个基元。基元是一个可以添加到场景的单个实体。在本项目中,基元是CubeSphere

class Node(object):
    """ Base class for scene elements """
    def __init__(self):
        self.color_index = random.randint(color.MIN_COLOR, color.MAX_COLOR)
        self.aabb = AABB([0.0, 0.0, 0.0], [0.5, 0.5, 0.5])
        self.translation_matrix = numpy.identity(4)
        self.scaling_matrix = numpy.identity(4)
        self.selected = False

    def render(self):
        """ renders the item to the screen """
        glPushMatrix()
        glMultMatrixf(numpy.transpose(self.translation_matrix))
        glMultMatrixf(self.scaling_matrix)
        cur_color = color.COLORS[self.color_index]
        glColor3f(cur_color[0], cur_color[1], cur_color[2])
        if self.selected:  # emit light if the node is selected
            glMaterialfv(GL_FRONT, GL_EMISSION, [0.3, 0.3, 0.3])

        self.render_self()

        if self.selected:
            glMaterialfv(GL_FRONT, GL_EMISSION, [0.0, 0.0, 0.0])
        glPopMatrix()

    def render_self(self):
        raise NotImplementedError(
            "The Abstract Node Class doesn't define 'render_self'")

class Primitive(Node):
    def __init__(self):
        super(Primitive, self).__init__()
        self.call_list = None

    def render_self(self):
        glCallList(self.call_list)


class Sphere(Primitive):
    """ Sphere primitive """
    def __init__(self):
        super(Sphere, self).__init__()
        self.call_list = G_OBJ_SPHERE


class Cube(Primitive):
    """ Cube primitive """
    def __init__(self):
        super(Cube, self).__init__()
        self.call_list = G_OBJ_CUBE

渲染节点基于每个节点存储的变换矩阵。节点的变换矩阵是其缩放矩阵和其平移矩阵的组合。无论节点类型如何,渲染的第一步都是将 OpenGL 模型视图矩阵设置为变换矩阵,以从模型坐标系转换为视图坐标系。一旦 OpenGL 矩阵更新完毕,我们调用render_self 来告诉节点执行必要的 OpenGL 调用以绘制自身。最后,我们撤消对该特定节点的 OpenGL 状态所做的任何更改。我们在 OpenGL 中使用glPushMatrixglPopMatrix 函数在渲染节点之前和之后保存和恢复模型视图矩阵的状态。请注意,节点存储其颜色、位置和比例,并在渲染之前将它们应用于 OpenGL 状态。

如果节点当前处于选中状态,我们使其发出光线。这样,用户可以直观地识别出他们选择了哪个节点。

为了渲染基元,我们使用 OpenGL 中的调用列表功能。OpenGL 调用列表是一系列 OpenGL 调用,这些调用只定义一次,并在单个名称下捆绑在一起。可以使用glCallList(LIST_NAME) 分发这些调用。每个基元 (SphereCube) 定义了渲染它所需的调用列表(未显示)。

例如,立方体的调用列表绘制了立方体的 6 个面,中心在原点,边长正好为 1 个单位。

# Pseudocode Cube definition
# Left face
((-0.5, -0.5, -0.5), (-0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (-0.5, 0.5, -0.5)),
# Back face
((-0.5, -0.5, -0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (0.5, -0.5, -0.5)),
# Right face
((0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (0.5, 0.5, 0.5), (0.5, -0.5, 0.5)),
# Front face
((-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)),
# Bottom face
((-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, -0.5, 0.5)),
# Top face
((-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.5, 0.5, -0.5))

仅使用基元对于建模应用程序来说将非常有限。3D 模型通常由多个基元(或三角形网格)组成,三角形网格超出了本项目的范围。幸运的是,我们对Node 类的设计便于使用由多个基元组成的Scene 节点。事实上,我们可以支持节点的任意分组,而不会增加任何复杂性。

作为动机,让我们考虑一个非常基本的图形:一个典型的雪人,或雪人,由三个球体组成。即使该图形由三个独立的基元组成,我们希望能够将其作为一个整体进行处理。

我们创建一个名为HierarchicalNode 的类,它是一个包含其他节点的Node。它管理一个“子节点”列表。分层节点的render_self 函数只是简单地对每个子节点调用render_self。使用HierarchicalNode 类,可以很容易地将图形添加到场景中。现在,定义雪人就像指定组成它的形状以及它们之间的相对位置和大小一样简单。

Figure 13.2 - Hierarchy of `Node` subclasses

图 13.2 - Node 子类的层次结构

class HierarchicalNode(Node):
    def __init__(self):
        super(HierarchicalNode, self).__init__()
        self.child_nodes = []

    def render_self(self):
        for child in self.child_nodes:
            child.render()

class SnowFigure(HierarchicalNode):
    def __init__(self):
        super(SnowFigure, self).__init__()
        self.child_nodes = [Sphere(), Sphere(), Sphere()]
        self.child_nodes[0].translate(0, -0.6, 0) # scale 1.0
        self.child_nodes[1].translate(0, 0.1, 0)
        self.child_nodes[1].scaling_matrix = numpy.dot(
            self.scaling_matrix, scaling([0.8, 0.8, 0.8]))
        self.child_nodes[2].translate(0, 0.75, 0)
        self.child_nodes[2].scaling_matrix = numpy.dot(
            self.scaling_matrix, scaling([0.7, 0.7, 0.7]))
        for child_node in self.child_nodes:
            child_node.color_index = color.MIN_COLOR
        self.aabb = AABB([0.0, 0.0, 0.0], [0.5, 1.1, 0.5])

您可能会注意到Node 对象形成了树形数据结构。render 函数通过分层节点对树进行深度优先遍历。在遍历过程中,它会保留一个ModelView 矩阵栈,用于转换为世界空间。在每一步中,它都会将当前ModelView 矩阵推入栈中,当它完成所有子节点的渲染后,它会将矩阵弹出栈,将父节点的ModelView 矩阵留在栈顶。

通过以这种方式使Node 类可扩展,我们可以在不更改任何其他场景操作和渲染代码的情况下,将新的形状类型添加到场景中。使用节点概念来抽象掉一个Scene 对象可能有多个子节点的事实被称为组合设计模式。

用户交互

现在我们的建模器能够存储和显示场景,我们需要一种与之交互的方式。我们需要促进两种类型的交互。首先,我们需要能够改变场景的视角。我们希望能够在场景中移动眼睛或相机。其次,我们需要能够添加新节点并在场景中修改节点。

为了启用用户交互,我们需要知道用户何时按下按键或移动鼠标。幸运的是,操作系统已经知道这些事件何时发生。GLUT 允许我们在某个事件发生时注册一个要调用的函数。我们编写函数来解释按键和鼠标移动,并告诉 GLUT 在按下相应的按键时调用这些函数。一旦我们知道用户正在按哪个键,我们就需要解释输入并对场景应用预期的操作。

监听操作系统事件并将事件解释为有意义的操作的逻辑位于Interaction 类中。我们之前编写的Viewer 类拥有Interaction 的单个实例。我们将使用 GLUT 回调机制来注册在按下鼠标按钮 (glutMouseFunc)、移动鼠标 (glutMotionFunc)、按下键盘按钮 (glutKeyboardFunc) 和按下箭头键 (glutSpecialFunc) 时要调用的函数。我们很快将看到处理输入事件的函数。

class Interaction(object):
    def __init__(self):
        """ Handles user interaction """
        # currently pressed mouse button
        self.pressed = None
        # the current location of the camera
        self.translation = [0, 0, 0, 0]
        # the trackball to calculate rotation
        self.trackball = trackball.Trackball(theta = -25, distance=15)
        # the current mouse location
        self.mouse_loc = None
        # Unsophisticated callback mechanism
        self.callbacks = defaultdict(list)

        self.register()

    def register(self):
        """ register callbacks with glut """
        glutMouseFunc(self.handle_mouse_button)
        glutMotionFunc(self.handle_mouse_move)
        glutKeyboardFunc(self.handle_keystroke)
        glutSpecialFunc(self.handle_keystroke)

操作系统回调

为了有意义地解释用户输入,我们需要结合鼠标位置、鼠标按钮和键盘的信息。由于将用户输入解释为有意义的操作需要很多行代码,因此我们将它封装在单独的类中,远离主代码路径。Interaction 类隐藏了与其他代码库无关的复杂性,并将操作系统事件转换为应用程序级事件。

    # class Interaction 
    def translate(self, x, y, z):
        """ translate the camera """
        self.translation[0] += x
        self.translation[1] += y
        self.translation[2] += z

    def handle_mouse_button(self, button, mode, x, y):
        """ Called when the mouse button is pressed or released """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - y  # invert the y coordinate because OpenGL is inverted
        self.mouse_loc = (x, y)

        if mode == GLUT_DOWN:
            self.pressed = button
            if button == GLUT_RIGHT_BUTTON:
                pass
            elif button == GLUT_LEFT_BUTTON:  # pick
                self.trigger('pick', x, y)
            elif button == 3:  # scroll up
                self.translate(0, 0, 1.0)
            elif button == 4:  # scroll up
                self.translate(0, 0, -1.0)
        else:  # mouse button release
            self.pressed = None
        glutPostRedisplay()

    def handle_mouse_move(self, x, screen_y):
        """ Called when the mouse is moved """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - screen_y  # invert the y coordinate because OpenGL is inverted
        if self.pressed is not None:
            dx = x - self.mouse_loc[0]
            dy = y - self.mouse_loc[1]
            if self.pressed == GLUT_RIGHT_BUTTON and self.trackball is not None:
                # ignore the updated camera loc because we want to always
                # rotate around the origin
                self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)
            elif self.pressed == GLUT_LEFT_BUTTON:
                self.trigger('move', x, y)
            elif self.pressed == GLUT_MIDDLE_BUTTON:
                self.translate(dx/60.0, dy/60.0, 0)
            else:
                pass
            glutPostRedisplay()
        self.mouse_loc = (x, y)

    def handle_keystroke(self, key, x, screen_y):
        """ Called on keyboard input from the user """
        xSize, ySize = glutGet(GLUT_WINDOW_WIDTH), glutGet(GLUT_WINDOW_HEIGHT)
        y = ySize - screen_y
        if key == 's':
            self.trigger('place', 'sphere', x, y)
        elif key == 'c':
            self.trigger('place', 'cube', x, y)
        elif key == GLUT_KEY_UP:
            self.trigger('scale', up=True)
        elif key == GLUT_KEY_DOWN:
            self.trigger('scale', up=False)
        elif key == GLUT_KEY_LEFT:
            self.trigger('rotate_color', forward=True)
        elif key == GLUT_KEY_RIGHT:
            self.trigger('rotate_color', forward=False)
        glutPostRedisplay()

内部回调

在上面的代码片段中,您会注意到,当Interaction 实例解释用户操作时,它会使用描述操作类型的字符串调用self.triggerInteraction 类的trigger 函数是我们用于处理应用程序级事件的简单回调系统的一部分。回想一下,Viewer 类上的init_interaction 函数通过调用register_callbackInteraction 实例上注册回调。

    # class Interaction
    def register_callback(self, name, func):
        self.callbacks[name].append(func)

当用户界面代码需要在场景上触发事件时,Interaction 类会调用它为该特定事件保存的所有回调。

    # class Interaction
    def trigger(self, name, *args, **kwargs):
        for func in self.callbacks[name]:
            func(*args, **kwargs)

这个应用程序级回调系统抽象掉了系统其余部分了解操作系统输入的需要。每个应用程序级回调都代表应用程序中的一个有意义的请求。Interaction 类充当操作系统事件和应用程序级事件之间的翻译器。这意味着,如果我们决定将建模器移植到除 GLUT 之外的其他工具包,我们只需要将Interaction 类替换为一个将新工具包的输入转换为相同的一组有意义的应用程序级回调的类。我们在表 13.1 中使用回调和参数。

表 13.1 - 交互回调和参数
回调 参数 目的
pick x:number, y:number 选择鼠标指针位置的节点。
move x:number, y:number 将当前选中的节点移动到鼠标指针位置。
place shape:string, x:number, y:number 将指定类型的形状放置在鼠标指针位置。
rotate_color forward:boolean 在颜色列表中向前或向后旋转当前选中的节点的颜色。
scale up:boolean 根据参数向上或向下缩放当前选中的节点。

这个简单的回调系统提供了我们这个项目所需的所有功能。但是,在生产型 3D 建模器中,用户界面对象通常是动态创建和销毁的。在这种情况下,我们需要一个更复杂的事件监听系统,其中对象既可以注册事件的回调,也可以取消注册事件的回调。

与场景交互

有了我们的回调机制,我们就可以从Interaction 类接收关于用户输入事件的有意义的信息。我们已准备好将这些操作应用于Scene

移动场景

在本项目中,我们通过变换场景来实现相机运动。换句话说,相机位于固定位置,用户输入移动的是场景,而不是移动相机。相机放置在[0, 0, -15] 处,面向世界空间原点。(或者,我们可以改变透视矩阵来移动相机而不是场景。这个设计决策对项目其余部分的影响很小。)重新审视Viewer 中的render 函数,我们会看到Interaction 状态用于在渲染Scene 之前变换 OpenGL 矩阵状态。与场景的交互有两类:旋转和平移。

使用轨迹球旋转场景

我们通过使用轨迹球算法来实现场景的旋转。轨迹球是一个直观的界面,用于在三维空间中操纵场景。从概念上讲,轨迹球界面就像场景位于一个透明的球体中一样。将手放在球体表面并推动它会旋转球体。同样,单击鼠标右键并在屏幕上移动它会旋转场景。您可以在 OpenGL Wiki 上了解有关轨迹球理论的更多信息。在本项目中,我们使用的是作为 Glumpy 一部分提供的轨迹球实现。

我们使用drag_to 函数与轨迹球交互,将鼠标的当前位置作为起始位置,并将鼠标位置的变化作为参数。

self.trackball.drag_to(self.mouse_loc[0], self.mouse_loc[1], dx, dy)

渲染场景时,轨迹球上的trackball.matrix 是生成的旋转矩阵。

附注:四元数

旋转通常以两种方式表示。第一种是围绕每个轴的旋转值;您可以将其存储为一个包含三个浮点数的 3 元组。旋转的另一种常见表示是四元数,它是由一个具有 \(x\)\(y\)\(z\) 坐标的向量和一个 \(w\) 旋转组成的元素。与逐轴旋转相比,使用四元数具有许多优势;特别是,它们在数值上更加稳定。使用四元数可以避免诸如万向锁之类的問題。四元数的缺点是它们不太直观,难以理解。如果您很勇敢并想了解更多关于四元数的信息,可以参考 此解释

轨迹球实现通过在内部使用四元数来存储场景的旋转,从而避免了万向锁。幸运的是,我们不需要直接使用四元数,因为轨迹球上的矩阵成员会将旋转转换为矩阵。

平移场景

平移场景(即滑动场景)比旋转场景简单得多。场景平移由鼠标滚轮和鼠标左键提供。鼠标左键在 \(x\)\(y\) 坐标中平移场景。滚动鼠标滚轮会在 z 坐标(朝向或远离相机)中平移场景。Interaction 类存储当前场景平移,并使用translate 函数对其进行修改。查看器在渲染期间检索Interaction 相机位置,以用于glTranslated 调用。

选择场景对象

现在用户可以移动和旋转整个场景以获得所需的视角,下一步是允许用户修改和操纵组成场景的对象。

为了让用户操纵场景中的对象,他们需要能够选择项目。

为了选择一个物体,我们使用当前投影矩阵生成一条射线来表示鼠标点击,就好像鼠标指针向场景中发射了一条射线一样。被选中的节点是距离相机最近的与射线相交的节点。因此,拾取问题就简化为寻找射线与场景中节点的交点。所以问题是:我们如何判断射线是否击中了一个节点?

精确计算射线是否与节点相交是一个非常具有挑战性的问题,无论是在代码复杂度还是性能方面。我们需要为每种类型的基元编写射线-物体相交检查。对于具有复杂网格几何体和多个面的场景节点,精确计算射线-物体相交需要测试射线与每个面的相交,这将是计算量巨大的。

为了保持代码简洁和性能合理,我们使用一个简单、快速的近似方法来进行射线-物体相交测试。在我们的实现中,每个节点都存储了一个轴对齐包围盒 (AABB),它近似地表示了该节点所占用的空间。为了测试射线是否与节点相交,我们测试射线是否与该节点的 AABB 相交。这种实现意味着所有节点都共享相同的相交测试代码,并且意味着性能成本对于所有节点类型都是恒定且很小的。

    # class Viewer
    def get_ray(self, x, y):
        """ 
        Generate a ray beginning at the near plane, in the direction that
        the x, y coordinates are facing 

        Consumes: x, y coordinates of mouse on screen 
        Return: start, direction of the ray 
        """
        self.init_view()

        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()

        # get two points on the line.
        start = numpy.array(gluUnProject(x, y, 0.001))
        end = numpy.array(gluUnProject(x, y, 0.999))

        # convert those points into a ray
        direction = end - start
        direction = direction / norm(direction)

        return (start, direction)

    def pick(self, x, y):
        """ Execute pick of an object. Selects an object in the scene. """
        start, direction = self.get_ray(x, y)
        self.scene.pick(start, direction, self.modelView)

为了确定哪个节点被点击,我们遍历场景以测试射线是否击中任何节点。我们取消选择当前选中的节点,然后选择与射线起点最近的相交节点。

    # class Scene
    def pick(self, start, direction, mat):
        """ 
        Execute selection.
            
        start, direction describe a Ray. 
        mat is the inverse of the current modelview matrix for the scene.
        """
        if self.selected_node is not None:
            self.selected_node.select(False)
            self.selected_node = None

        # Keep track of the closest hit.
        mindist = sys.maxint
        closest_node = None
        for node in self.node_list:
            hit, distance = node.pick(start, direction, mat)
            if hit and distance < mindist:
                mindist, closest_node = distance, node

        # If we hit something, keep track of it.
        if closest_node is not None:
            closest_node.select()
            closest_node.depth = mindist
            closest_node.selected_loc = start + direction * mindist
            self.selected_node = closest_node

Node 类中,pick 函数测试射线是否与 Node 的轴对齐包围盒相交。如果节点被选中,select 函数会切换节点的选中状态。注意,AABB 的 ray_hit 函数将盒子坐标空间和射线坐标空间之间的变换矩阵作为第三个参数接受。每个节点在调用 ray_hit 函数之前,会将自己的变换应用于矩阵。

    # class Node
    def pick(self, start, direction, mat):
        """ 
        Return whether or not the ray hits the object

        Consume:  
        start, direction form the ray to check
        mat is the modelview matrix to transform the ray by 
        """

        # transform the modelview matrix by the current translation
        newmat = numpy.dot(
            numpy.dot(mat, self.translation_matrix), 
            numpy.linalg.inv(self.scaling_matrix)
        )
        results = self.aabb.ray_hit(start, direction, newmat)
        return results

    def select(self, select=None):
       """ Toggles or sets selected state """
       if select is not None:
           self.selected = select
       else:
           self.selected = not self.selected
    

射线-AABB 选择方法非常容易理解和实现。但是,在某些情况下,结果是错误的。

Figure 13.3 - AABB Error

图 13.3 - AABB 错误

例如,在 Sphere 基元的情况下,球体本身只在 AABB 的每个面的中心与 AABB 相交。但是,如果用户点击球体 AABB 的角点,即使用户想要点击球体后面的东西,也会检测到与球体的碰撞(图 13.3)。

这种在复杂度、性能和精度之间的权衡在计算机图形学和许多软件工程领域都很常见。

修改场景物体

接下来,我们想允许用户操作选中的节点。他们可能想要移动、调整大小或更改选中节点的颜色。当用户输入操作节点的命令时,Interaction 类会将输入转换为用户想要执行的操作,并调用相应的回调函数。

Viewer 收到这些事件之一的回调时,它会调用 Scene 上的相应函数,该函数又将变换应用于当前选中的 Node

    # class Viewer
    def move(self, x, y):
        """ Execute a move command on the scene. """
        start, direction = self.get_ray(x, y)
        self.scene.move_selected(start, direction, self.inverseModelView)

    def rotate_color(self, forward):
        """ 
        Rotate the color of the selected Node. 
        Boolean 'forward' indicates direction of rotation. 
        """
        self.scene.rotate_selected_color(forward)

    def scale(self, up):
        """ Scale the selected Node. Boolean up indicates scaling larger."""
        self.scene.scale_selected(up)

更改颜色

使用一个可能的颜色的列表来实现颜色操作。用户可以使用箭头键在列表中循环。场景会将颜色更改命令分派给当前选中的节点。

    # class Scene
    def rotate_selected_color(self, forwards):
        """ Rotate the color of the currently selected node """
        if self.selected_node is None: return
        self.selected_node.rotate_color(forwards)

每个节点都存储其当前颜色。rotate_color 函数只是修改节点的当前颜色。当渲染节点时,颜色会通过 glColor 传递给 OpenGL。

    # class Node
    def rotate_color(self, forwards):
        self.color_index += 1 if forwards else -1
        if self.color_index > color.MAX_COLOR:
            self.color_index = color.MIN_COLOR
        if self.color_index < color.MIN_COLOR:
            self.color_index = color.MAX_COLOR

缩放节点

与颜色一样,场景会将任何缩放修改分派给选中的节点(如果有)。

    # class Scene
    def scale_selected(self, up):
        """ Scale the current selection """
        if self.selected_node is None: return
        self.selected_node.scale(up)
    

每个节点都存储一个当前矩阵,该矩阵存储其缩放比例。在相应方向上按参数 \(x\)\(y\)\(z\) 进行缩放的矩阵是

\[ \begin{bmatrix} x & 0 & 0 & 0 \\ 0 & y & 0 & 0 \\ 0 & 0 & z & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \]

当用户修改节点的缩放比例时,生成的缩放矩阵会被乘到节点的当前缩放矩阵中。

    # class Node
    def scale(self, up):
        s =  1.1 if up else 0.9
        self.scaling_matrix = numpy.dot(self.scaling_matrix, scaling([s, s, s]))
        self.aabb.scale(s)

scaling 函数根据 \(x\)\(y\)\(z\) 缩放系数的列表返回这样的矩阵。

def scaling(scale):
    s = numpy.identity(4)
    s[0, 0] = scale[0]
    s[1, 1] = scale[1]
    s[2, 2] = scale[2]
    s[3, 3] = 1
    return s

移动节点

为了平移节点,我们使用与拾取相同的射线计算方法。我们将表示当前鼠标位置的射线传递给场景的 move 函数。节点的新位置应该在射线上。为了确定在射线的哪个位置放置节点,我们需要知道节点到相机的距离。由于我们在选择节点时存储了节点的位置和到相机的距离(在 pick 函数中),因此我们可以在这里使用这些数据。我们找到目标射线上距离相机相同距离的点,并计算新位置和旧位置之间的矢量差。然后,我们根据得到的向量平移节点。

    # class Scene
    def move_selected(self, start, direction, inv_modelview):
        """ 
        Move the selected node, if there is one.
            
        Consume: 
        start, direction describes the Ray to move to
        mat is the modelview matrix for the scene 
        """
        if self.selected_node is None: return

        # Find the current depth and location of the selected node
        node = self.selected_node
        depth = node.depth
        oldloc = node.selected_loc

        # The new location of the node is the same depth along the new ray
        newloc = (start + direction * depth)

        # transform the translation with the modelview matrix
        translation = newloc - oldloc
        pre_tran = numpy.array([translation[0], translation[1], translation[2], 0])
        translation = inv_modelview.dot(pre_tran)

        # translate the node and track its location
        node.translate(translation[0], translation[1], translation[2])
        node.selected_loc = newloc

请注意,新位置和旧位置是在相机坐标空间中定义的。我们需要我们的平移在世界坐标空间中定义。因此,我们通过乘以模型视图矩阵的逆矩阵将相机空间平移转换为世界空间平移。

与缩放一样,每个节点都存储一个矩阵,该矩阵表示其平移。平移矩阵如下所示

\[ \begin{bmatrix} 1 & 0 & 0 & x \\ 0 & 1 & 0 & y \\ 0 & 0 & 1 & z \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} \]

当节点被平移时,我们为当前平移构建一个新的平移矩阵,并将其乘以节点的平移矩阵,用于渲染过程中。

    # class Node
    def translate(self, x, y, z):
        self.translation_matrix = numpy.dot(
            self.translation_matrix, 
            translation([x, y, z]))

translation 函数根据表示 \(x\)\(y\)\(z\) 平移距离的列表返回一个平移矩阵。

def translation(displacement):
    t = numpy.identity(4)
    t[0, 3] = displacement[0]
    t[1, 3] = displacement[1]
    t[2, 3] = displacement[2]
    return t

放置节点

节点放置使用拾取和平移的两种技术。我们使用当前鼠标位置相同的射线计算方法来确定放置节点的位置。

    # class Viewer
    def place(self, shape, x, y):
        """ Execute a placement of a new primitive into the scene. """
        start, direction = self.get_ray(x, y)
        self.scene.place(shape, start, direction, self.inverseModelView)

为了放置一个新的节点,我们首先创建相应类型的节点的新实例,并将其添加到场景中。我们想将节点放置在用户光标下方,因此我们在射线上找到一个点,该点距离相机固定距离。同样,射线是在相机空间中表示的,因此我们通过将其乘以逆模型视图矩阵将得到的平移向量转换为世界坐标空间。最后,我们将新的节点按计算出的向量平移。

    # class Scene
    def place(self, shape, start, direction, inv_modelview):
        """ 
        Place a new node.
            
        Consume:  
        shape the shape to add
        start, direction describes the Ray to move to
        inv_modelview is the inverse modelview matrix for the scene 
        """
        new_node = None
        if shape == 'sphere': new_node = Sphere()
        elif shape == 'cube': new_node = Cube()
        elif shape == 'figure': new_node = SnowFigure()

        self.add_node(new_node)

        # place the node at the cursor in camera-space
        translation = (start + direction * self.PLACE_DEPTH)

        # convert the translation to world-space
        pre_tran = numpy.array([translation[0], translation[1], translation[2], 1])
        translation = inv_modelview.dot(pre_tran)

        new_node.translate(translation[0], translation[1], translation[2])

总结

恭喜!我们成功地实现了一个小型 3D 建模器!

Figure 13.4 - Sample Scene

图 13.4 - 示例场景

我们看到了如何开发一个可扩展的数据结构来表示场景中的物体。我们注意到,使用组合设计模式和基于树的数据结构使得遍历场景进行渲染变得容易,并且允许我们添加新的节点类型而不会增加复杂性。我们利用这种数据结构将设计渲染到屏幕上,并在场景图的遍历中操作 OpenGL 矩阵。我们构建了一个非常简单的应用级事件回调系统,并使用它来封装操作系统事件的处理。我们讨论了射线-物体碰撞检测的可能实现,以及正确性、复杂性和性能之间的权衡。最后,我们实现了操作场景内容的方法。

您可以期望在生产环境中的 3D 软件中找到这些相同的基本构建块。场景图结构和相对坐标空间存在于许多类型的 3D 图形应用程序中,从 CAD 工具到游戏引擎。本项目中的一个主要简化是用户界面。生产环境中的 3D 建模器应该拥有一个完整的用户界面,这将需要一个更加复杂事件系统,而不是我们简单的回调系统。

我们可以进行进一步的实验,为这个项目添加新的功能。尝试以下方法之一

进一步探索

为了进一步了解现实世界中的 3D 建模软件,一些开源项目很有趣。

Blender 是一个开源的、功能齐全的 3D 动画套件。它提供了一个完整的 3D 管线,用于构建视频中的特殊效果或用于游戏创作。建模器是该项目的一小部分,它很好地展示了将建模器集成到大型软件套件中的方法。

OpenSCAD 是一个开源的 3D 建模工具。它不是交互式的;相反,它读取一个脚本文件,该文件指定如何生成场景。这使得设计师能够“完全控制建模过程”。

有关计算机图形学中算法和技术的更多信息,Graphics Gems 是一个很棒的资源。

  1. 感谢 Anton Gerdelan 博士提供的图像。他的 OpenGL 教程书籍可在 http://antongerdelan.net/opengl/ 获取。