Skip to content

三维建模器

文章信息

作者:Erick Dransch

译者:Claude, skhe

原文A 3D Modeller

许可CC BY-NC-SA 3.0

引言

人类拥有与生俱来的创造能力,不断设计和构建新颖、实用且有趣的事物。在当今时代,软件辅助着设计和创造过程。计算机辅助设计 (CAD) 软件使创作者能够在构建物理版本之前,设计建筑、桥梁、视频游戏美术、电影怪物、3D 打印对象以及许多其他事物。

从本质上讲,CAD 工具是一种将三维设计转换为可在二维屏幕上查看和编辑的方法。为了实现这一目的,CAD 工具必须提供三项基本功能:

  1. 表示正在设计的对象的数据结构——计算机对用户构建的三维世界的理解
  2. 一种将三维设计以可理解的方式转换到二维屏幕上的显示方法
  3. 一种允许用户添加和修改设计的交互方法

此外,所有工具都需要从存储中保存和加载设计的机制,以实现协作和持久化。

特定领域的 CAD 工具会提供针对特定需求的附加功能。例如,建筑 CAD 工具会提供物理模拟来测试建筑所承受的气候应力,3D 打印工具会提供检查对象是否可实际打印的功能,电气 CAD 工具会模拟电流通过铜线的物理过程,而电影特效套件会包含精确模拟火焰效果的功能。

然而,基础 CAD 工具必须包含以下三项功能:数据表示、显示能力和交互机制。

接下来的内容将展示如何使用大约 500 行 Python 代码来表示三维设计、在屏幕上显示它们以及与之交互。

以渲染为导向

三维建模器中的设计决策从根本上是由渲染过程驱动的。目标是存储和渲染复杂对象,同时保持渲染代码的简洁。研究渲染过程可以揭示能够使用简单渲染逻辑存储任意复杂对象的数据结构。

管理接口和主循环

在开始渲染之前,需要进行一些准备工作。首先,必须创建一个用于显示设计的窗口。其次,与图形驱动程序通信需要建立渲染能力。开发者不直接与图形驱动程序交互,而是使用 OpenGL——一个跨平台抽象层,以及 GLUT (OpenGL Utility Toolkit) 来进行窗口管理。

关于 OpenGL 的说明

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

OpenGL 中的渲染依赖于由顶点 (vertex) 和法线 (normal) 定义的多边形。例如,渲染一个立方体的一面需要指定四个顶点和该面的法线。

传统 OpenGL 提供了一个"固定功能管线 (fixed function pipeline)"。通过设置全局变量,程序员可以启用和禁用光照、着色、面剔除等功能的自动化实现。此功能现已被弃用。

另一方面,现代 OpenGL 具有可编程渲染管线,程序员编写称为"着色器 (shader)"的小程序,在专用图形硬件 (GPU) 上运行。现代 OpenGL 已经取代了传统 OpenGL。

尽管传统 OpenGL 已被弃用,本项目仍然使用它。传统 OpenGL 提供的固定功能对于保持代码规模较小非常有用。它减少了所需的线性代数知识量,并简化了我们将要编写的代码。

关于 GLUT

GLUT 与 OpenGL 捆绑在一起,允许我们创建操作系统窗口并注册用户界面回调函数。这些基本功能足以满足我们的需求。更全面的窗口管理需要完整的窗口工具包,如 GTK 或 Qt。

查看器 (Viewer)

Viewer 类管理 GLUT 和 OpenGL 的设置,并驱动建模器。一个 Viewer 实例负责窗口创建和渲染,包含程序的主循环。在初始化期间,Viewer 创建 GUI 窗口并初始化 OpenGL。

init_interface 函数创建渲染窗口并指定渲染回调函数。init_opengl 函数配置 OpenGL 状态,建立矩阵、启用背面剔除、注册光照并启用对象着色。init_scene 函数创建 Scene 对象并放置初始节点为用户提供方向参考。init_interaction 函数注册用户交互回调。初始化完成后,glutMainLoop 将程序执行权转交给 GLUT。该函数永远不会返回;当相应事件发生时,已注册的回调函数将被执行。

python
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 函数之前,了解一些线性代数概念是有益的。

坐标空间 (Coordinate Space)

在这里,坐标空间由一个原点和一组三个基向量 (basis vector) 组成,通常为 x、y 和 z 轴。

点 (Point)

三维空间中的任何点都可以表示为从原点在 x、y 和 z 方向上的偏移。点的表示取决于坐标空间的上下文。同一个点在不同的坐标空间中有不同的表示,但任何三维点都可以在任何三维坐标空间中表示。

向量 (Vector)

向量由 x、y 和 z 值组成,表示两个点之间在各轴方向上的差值。

变换矩阵 (Transformation Matrix)

计算机图形学通常对不同类型的点使用多个坐标空间。变换矩阵用于在坐标空间之间转换点。将向量 v 从一个空间转换到另一个空间涉及乘以变换矩阵 M:v' = Mv。常见的变换包括平移 (translation)、缩放 (scaling) 和旋转 (rotation)。

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

图 13.1 - 变换管线

将物体转换为屏幕显示需要在几个坐标空间之间进行转换。

OpenGL 处理从视觉空间 (eye space) 到视口空间 (viewport space) 的变换,包含在图 13.1 的右侧。从视觉空间到齐次裁剪空间 (homogeneous clip space) 的转换使用 gluPerspective,而到标准化设备空间 (normalized device space) 和视口空间的转换使用 glViewport。这些矩阵相乘,存储为 GL_PROJECTION 矩阵。在本项目中,我们无需理解术语和详细机制。

左侧需要手动管理。模型矩阵 (model matrix) 将点从模型空间转换到世界空间。视图矩阵 (view matrix) 将点从世界空间转换到视觉空间。本项目将这两者合并为一个 ModelView 矩阵。

如需全面了解图形渲染管线和坐标空间,请参阅 Real Time Rendering 的第 2 章或类似的计算机图形学入门资源。

使用 Viewer 进行渲染

render 函数首先建立所需的 OpenGL 状态。它通过 init_view 初始化投影矩阵,并使用交互数据初始化 ModelView 矩阵,将场景空间转换到世界空间。该函数使用 glClear 清除屏幕,指示场景渲染自身,并渲染单位网格。

在网格渲染之前禁用光照。禁用光照后,OpenGL 使用纯色渲染物体而非模拟光源,为网格提供视觉上的区分。最后,glFlush 通知图形驱动程序缓冲区已准备好显示。

python
# 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)

渲染什么:场景 (Scene)

渲染管线初始化完毕并准备好在世界坐标空间中绘制,那么应该渲染什么内容呢?回想一下,目标是创建三维设计。我们需要一个包含设计的数据结构,以及渲染它的机制。注意在查看器的渲染循环中出现了 self.scene.render()。那么场景是什么?

Scene 类提供了表示设计的数据结构的接口。它抽象了数据结构的细节,同时提供设计交互所需的接口函数,包括渲染、添加项目和操作项目。一个 Scene 对象属于查看器。Scene 实例维护一个称为 node_list 的场景项目列表,并跟踪选中的项目。场景的 render 函数简单地在每个 node_list 成员上调用 render

python
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()

节点 (Node)

在 Scene 的 render 函数中,render 在每个场景项目上执行。这些项目是什么?它们被称为节点 (node)。从概念上讲,节点代表可以放置在场景中的任何东西。在面向对象编程中,Node 充当抽象基类。表示场景对象的类继承自 Node。这个基类便于对场景进行抽象推理。代码库不需要了解特定对象的细节;它只需要知道对象是 Node 实例即可。

每种 Node 类型定义自己的渲染行为和交互模式。Node 维护重要的内部数据:平移矩阵、缩放矩阵、颜色等。将节点的平移矩阵乘以其缩放矩阵即可得到将节点的模型坐标空间转换为世界坐标空间的变换矩阵。节点还存储一个轴对齐包围盒 (Axis-Aligned Bounding Box, AABB),将在稍后讨论选择时介绍。

最简单的 Node 实现是基元 (primitive)——可添加到场景中的单个实体形状。本项目包含 Cube(立方体)和 Sphere(球体)基元。

python
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 ModelView 矩阵设置为从模型坐标空间转换到视图坐标空间的变换矩阵开始的。一旦 OpenGL 矩阵更新完毕,就会执行 render_self,指示节点进行必要的 OpenGL 调用来绘制自身。最后,为该节点对 OpenGL 状态所做的更改会被撤销。glPushMatrixglPopMatrix 函数在渲染前后保存和恢复 ModelView 矩阵状态。

注意,节点存储了颜色、位置和缩放信息,并在渲染前将这些应用到 OpenGL 状态。当节点被选中时,它会发出光,提供视觉选中指示。

基元使用 OpenGL 的调用列表 (call list) 功能进行渲染。OpenGL 调用列表将一系列定义一次的 OpenGL 调用捆绑在一个名称下。glCallList(LIST_NAME) 会分发这些调用。每个基元(SphereCube)指定其渲染调用列表(此处未展示)。

例如,立方体的调用列表绘制六个面,中心位于原点,边长恰好为一个单位。

python
# 立方体定义伪代码
# 左面
((-0.5, -0.5, -0.5), (-0.5, -0.5, 0.5), (-0.5, 0.5, 0.5), (-0.5, 0.5, -0.5)),
# 后面
((-0.5, -0.5, -0.5), (-0.5, 0.5, -0.5), (0.5, 0.5, -0.5), (0.5, -0.5, -0.5)),
# 右面
((0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (0.5, 0.5, 0.5), (0.5, -0.5, 0.5)),
# 前面
((-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5)),
# 底面
((-0.5, -0.5, 0.5), (-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, -0.5, 0.5)),
# 顶面
((-0.5, 0.5, -0.5), (-0.5, 0.5, 0.5), (0.5, 0.5, 0.5), (0.5, 0.5, -0.5))

仅使用基元提供的建模能力有限。三维模型通常由多个基元或三角形网格 (triangle mesh) 组成(超出本项目的范围)。然而,Node 类的设计使得 Scene 节点可以由多个基元组成。事实上,任意的节点分组都可以得到支持,而不会增加额外的复杂性。

作为动机,考虑一个基本的图形:一个典型的雪人,由三个球体组成。虽然由三个基元组成,但将其视为单个对象是更理想的。

创建一个 HierarchicalNode 类——一个包含其他节点的 Node——可以实现这一点。它管理一个"子节点"列表。层次节点的 render_self 函数简单地在每个子节点上调用 render_self。有了 HierarchicalNode,向场景添加图形变得简单直接。定义雪人图形只需指定组成形状及其相对位置和大小。

图 13.2 - Node 子类层次结构

python
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 函数通过层次节点对树进行深度优先遍历 (depth-first traversal)。在遍历过程中,它维护一个用于世界空间转换的 ModelView 矩阵栈。在每一步中,它将当前矩阵推入栈中;完成子节点渲染后,弹出矩阵,使父节点的 ModelView 矩阵留在栈顶。

以这种方式扩展 Node 允许添加新的形状类型,而无需修改场景操作和渲染代码。通过这种设计模式将具有多个子节点的 Scene 对象视为单个对象,被称为组合设计模式 (Composite design pattern)。

用户交互

建模器现在可以存储和显示场景了。接下来需要一种交互方法。有两种类型的交互是必要的。首先,改变观察视角至关重要——围绕场景移动眼睛或摄像机。其次,添加和修改场景节点是关键。

启用用户交互需要检测按键和鼠标移动。操作系统已经能识别这些事件。GLUT 允许注册在特定事件发生时被调用的函数。编写解释按键和鼠标移动的函数,并指示 GLUT 在相应按键按下时调用这些函数,即可实现用户交互。识别按下的键后,接着进行输入解释和预期的场景操作。

Interaction 类包含监听操作系统事件并解释其含义的逻辑。Viewer 类拥有唯一的 Interaction 实例。GLUT 回调机制注册在鼠标按钮按下时 (glutMouseFunc)、鼠标移动时 (glutMotionFunc)、键盘按钮按下时 (glutKeyboardFunc) 和方向键按下时 (glutSpecialFunc) 被调用的函数。以下部分展示了处理输入事件的函数。

python
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 类隐藏了与其他代码无关的复杂性,并将操作系统事件转换为应用程序级别的事件。

python
# 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 down
            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 实例上注册回调。

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

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

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

这个应用程序级别的回调系统消除了其他系统了解操作系统输入的必要性。每个应用程序级别的回调代表一个有意义的应用请求。Interaction 类充当操作系统事件和应用程序级别事件之间的翻译器。如果想要将建模器移植到另一个工具包,只需用一个将新工具包输入转换为相同有意义的应用程序级别回调的类来替换 Interaction 类即可。表 13.1 显示了所使用的回调和参数。

表 13.1 - 交互回调和参数

回调参数用途
pickx:number, y:number选择鼠标指针位置处的节点。
movex:number, y:number将当前选中的节点移动到鼠标指针位置。
placeshape:string, x:number, y:number在鼠标指针位置放置指定类型的形状。
rotate_colorforward:boolean向前或向后在颜色列表中轮换当前选中节点的颜色。
scaleup:boolean根据参数放大或缩小当前选中的节点。

这个简单的回调系统提供了本项目所需的全部功能。然而,在生产环境的三维建模器中,用户界面对象通常会动态创建和销毁。这种情况需要更复杂的事件监听系统,对象可以在其中注册和注销事件回调。

与场景交互

通过回调机制从 Interaction 类接收到有意义的用户输入信息后,就可以将这些操作应用到 Scene 上了。

移动场景

本项目通过变换场景来实现相机运动。换句话说,固定的相机位置接收移动的场景,而不是相机移动。相机放置在 [0, 0, -15] 位置,面向世界空间原点。(或者,也可以通过更改透视矩阵来移动相机而不是场景;这一设计选择对项目影响甚微。)重新审视 Viewer 中的 render 函数,Interaction 状态在渲染 Scene 之前变换了 OpenGL 矩阵状态。场景交互涉及两种类型:旋转和平移。

使用轨迹球旋转场景

轨迹球 (trackball) 算法实现场景旋转。这提供了一种直观的三维场景操作界面。从概念上讲,场景存在于一个透明的球体内。将手放在球体表面并推动它就会旋转球体。类似地,点击鼠标右键并移动就会旋转场景。OpenGL Wiki 提供了轨迹球的理论信息。本项目使用来自 Glumpy 的轨迹球实现。

轨迹球通过 drag_to 函数接收交互,以当前鼠标位置作为起始位置,鼠标位置变化量作为参数。

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

在查看器渲染场景时,结果旋转矩阵为 trackball.matrix

补充:四元数 (Quaternion)

存在两种主要的旋转表示方法。第一种使用围绕每个轴的旋转值,存储为三值元组。第二种常见表示是四元数——由具有 x、y 和 z 坐标的向量加上 w 旋转值组成。四元数相比逐轴旋转有若干优势;特别是,它们提供更高的数值稳定性。四元数避免了万向节锁 (gimbal lock) 等问题。缺点是四元数不太直观,更难理解。对于勇于学习四元数的人,这篇解释提供了全面的信息。

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

平移场景

场景平移(滑动)比旋转简单得多。场景平移通过鼠标滚轮和鼠标左键实现。鼠标左键在 x 和 y 坐标上平移场景。鼠标滚轮滚动在 z 坐标上平移场景(朝向或远离相机)。Interaction 类存储当前场景平移量,并通过 translate 函数修改它。查看器在渲染期间获取 Interaction 的相机位置,用于 glTranslated 调用。

选择场景对象

有了移动和旋转整个场景以获得所需视角的能力,下一步是允许用户修改和操作场景对象。

操作对象需要能够选择项目。

选择项目使用当前投影矩阵生成代表鼠标点击的光线 (ray),就好像鼠标指针向场景中发射了一条光线。选中的节点是光线与之相交的、距离相机最近的节点。因此,拾取问题简化为找到光线和场景节点之间的交点。问题变成了:如何确定光线是否命中节点?

计算精确的光线-物体交点在代码复杂性和性能方面都具有挑战性。光线-物体交点检查需要为每种基元类型都有一个实现。具有由许多面组成的复杂网格几何体的场景需要进行昂贵的逐面光线测试。

为了保持紧凑的代码和合理的性能,本实现使用了一种简单、快速的光线-物体交点测试近似方法。每个节点存储一个轴对齐包围盒 (AABB),近似其占用的空间。测试光线-节点交点就变成了测试光线-AABB 交点。这种方法确保所有节点共享相同的交点测试代码,并为所有节点类型提供恒定的、较小的性能开销。

python
# 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)

确定哪个节点接收了点击涉及场景遍历,测试光线-节点交点。当前选中的节点取消选择,与光线原点交点最近的节点变为选中状态。

python
# 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 函数测试光线-AABB 交点。如果被选中,select 函数切换节点的选中状态。注意 AABB 的 ray_hit 函数接受包围盒坐标空间和光线坐标空间之间的变换矩阵作为第三个参数。每个节点在调用 ray_hit 之前将自己的变换应用于矩阵。

python
# 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 选择方法简单且易于实现。然而,在某些情况下结果会不正确。

图 13.3 - AABB 误差

例如,Sphere 基元仅在每个面的中心接触其 AABB。然而,点击 AABB 的角会注册为碰撞,即使用户打算点击球体后面的对象(图 13.3)。这种在复杂性、性能和准确性之间的权衡在计算机图形学和许多软件工程领域中很常见。

修改场景对象

允许用户操作选中的节点。用户可能希望移动、调整大小或改变颜色。当用户输入操作命令时,Interaction 类将输入转换为预期的操作,调用相应的回调。

Viewer 收到事件回调时,它调用适当的 Scene 函数,将变换应用于当前选中的 Node

python
# 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)

改变颜色

颜色操作使用可用颜色列表。用户使用方向键在列表中循环。场景将颜色更改命令分发给当前选中的节点。

python
# 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 函数简单地修改节点的当前颜色。当节点渲染时,OpenGL 通过 glColor 渲染该颜色。

python
# 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

缩放节点

与颜色更改一样,场景将缩放修改分发给选中的节点(如果存在)。

python
# 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 参数缩放的矩阵为:

[x0000y0000z00001]

当用户修改节点缩放时,生成的缩放矩阵会乘入当前缩放矩阵。

python
# 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 缩放因子返回这样一个矩阵。

python
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 函数中),因此该数据可用。找到沿目标光线与相机距离相同的点,并计算新旧位置之间的向量差。节点按结果向量进行平移。

python
# 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

注意新旧位置存在于相机坐标空间中。所需的平移必须存在于世界坐标空间中。因此,相机空间的平移通过乘以逆 ModelView 矩阵转换为世界空间的平移。

每个节点存储一个表示其平移的矩阵。平移矩阵如下所示:

[100x010y001z0001]

当平移节点时,为当前平移构建一个新的平移矩阵,乘入节点的平移矩阵以供渲染使用。

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

translation 函数根据给定的 x、y 和 z 平移距离返回一个平移矩阵。

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

放置节点

节点放置结合了拾取和平移的技术。当前鼠标位置的光线决定放置位置。

python
# 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)

添加新节点首先涉及创建相应节点类型的实例并将其添加到场景中。将节点放置在用户光标下意味着找到光线上与相机固定距离的点。光线代表相机空间,因此通过乘以逆 ModelView 矩阵将结果平移向量转换为世界坐标空间。最后,新节点按计算出的向量进行平移。

python
# 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])

总结

恭喜!一个微型三维建模器实现完成了!

图 13.4 - 示例场景

本文展示了开发一个可扩展的数据结构来表示场景对象。利用组合设计模式 (Composite design pattern) 和基于树的数据结构简化了用于渲染的场景遍历,并使添加新节点类型无需修改现有代码。利用此数据结构将设计渲染到屏幕上,在场景遍历期间操作 OpenGL 矩阵。一个简单的回调系统促进了应用程序级别的事件,封装了操作系统输入处理。讨论了光线-物体碰撞检测的实现以及正确性、复杂性和性能之间的权衡。最后,实现了场景内容的操作方法。

这些基本构建模块出现在生产环境的三维软件中。场景图结构和相对坐标空间可在大量三维图形应用中找到,从 CAD 工具到游戏引擎。本项目的一个主要简化涉及用户界面。生产环境的三维建模器需要完整的用户界面,需要比这个简单的回调系统更复杂的事件系统。

对本项目的实验性扩展提供了探索的机会。考虑尝试以下之一:

  • 添加一个支持三角形网格的 Node 类型,以实现任意形状。
  • 添加一个撤销栈,允许撤销/重做建模器操作。
  • 使用三维文件格式(如 DXF)保存/加载设计。
  • 集成渲染引擎:将设计导出以在逼真渲染器中使用。
  • 改进碰撞检测,使用精确的光线-物体交点。

延伸阅读

开源项目可以提供对现实世界三维建模软件的洞察。

Blender 是一个开源的全功能三维动画套件。它提供了完整的三维管线,用于构建视频或游戏创作中的特效。建模器是其中一个组件,展示了建模器如何集成到大型软件套件中。

OpenSCAD 是一个开源的三维建模工具。它不是交互式的;而是读取指定场景生成方式的脚本文件。这赋予设计者"对建模过程的完全控制"。

"Graphics Gems" 是计算机图形学算法和技术的综合资源。有关更多信息,请参阅 Graphics Gems


脚注:

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

基于 CC BY-NC-SA 3.0 许可发布